[{"data":1,"prerenderedAt":5057},["ShallowReactive",2],{"navigation":3,"index":46,"index-blogs":437},[4],{"title":5,"path":6,"stem":7,"children":8,"page":45},"Blog","/blog","blog",[9,13,17,21,25,29,33,37,41],{"title":10,"path":11,"stem":12},"Automating Google Docs with Claude Code: Build a CLI That Pulls, Edits, and Pushes","/blog/automating-google-docs-with-claude-code","blog/automating-google-docs-with-claude-code",{"title":14,"path":15,"stem":16},"Building a Garmin Connect IQ App in Monkey C: Lessons from a BLE Remote Control","/blog/building-a-garmin-connect-iq-app-in-monkey-c","blog/building-a-garmin-connect-iq-app-in-monkey-c",{"title":18,"path":19,"stem":20},"Building a YouTube Video Research Pipeline with Whisper and Claude","/blog/building-a-youtube-video-research-pipeline-with-whisper-and-claude","blog/building-a-youtube-video-research-pipeline-with-whisper-and-claude",{"title":22,"path":23,"stem":24},"The very first attempt to implement 4DX in Bravelab.io","/blog/implementing-4dx-in-bravelab","blog/implementing-4dx-in-bravelab",{"title":26,"path":27,"stem":28},"Learning Embedded Rust: A C/Python Developer's Rosetta Stone","/blog/learning-embedded-rust-rosetta-stone","blog/learning-embedded-rust-rosetta-stone",{"title":30,"path":31,"stem":32},"Mirroring a Wearable OLED Display to a Projector for Live Demos","/blog/mirroring-a-wearable-oled-display-to-a-projector-for-live-demos","blog/mirroring-a-wearable-oled-display-to-a-projector-for-live-demos",{"title":34,"path":35,"stem":36},"NFC Tag Emulation on nRF52840 with Rust — No SDK Required","/blog/nfc-tag-emulation-nrf52840-rust-embassy","blog/nfc-tag-emulation-nrf52840-rust-embassy",{"title":38,"path":39,"stem":40},"A quick introduction to profit sharing implementation","/blog/profit-sharing-implementation","blog/profit-sharing-implementation",{"title":42,"path":43,"stem":44},"Smart Home on a Budget: BLE Window Sensors with Two Pico Ws and MicroPython","/blog/smart-home-pico-w-ble-window-sensor-micropython","blog/smart-home-pico-w-ble-window-sensor-micropython",false,{"id":47,"title":48,"agent":49,"arenas":105,"async":121,"blog":155,"body":159,"contact":379,"description":163,"extension":380,"hero":381,"history":386,"how":387,"meta":392,"navigation":393,"opensource":394,"path":402,"polymath":403,"seo":417,"stem":418,"testimonials":419,"work":429,"__hash__":436},"index/index.yml","Mariusz Smenżyk",{"eyebrow":50,"title":51,"description":52,"stats":53,"flowNote":66,"flow":67,"items":83,"cta":104},"My latest independent project","Meet Vimek. The teammate that runs our back office.","Vimek is a Claude agent I built for the Vimo Swim team. It lives in Slack and reads sales, payment, shipping, ads and analytics data through our own MCP server. When it proposes a change in GitHub Projects, the CRM or the report schedules, it posts a preview, and nothing is written until an allowed teammate clicks Approve.",[54,57,60,63],{"value":55,"label":56},"24","read-only MCP tools over company data (Oct 2026)",{"value":58,"label":59},"3","Claude models, one for each job",{"value":61,"label":62},"Day 1","proof of concept and first deploy",{"value":64,"label":65},"1","Approve click before any proposed change is written","How it works. This is a description of the flow, not a real conversation.",[68,71,74,77,80],{"title":69,"description":70},"Ask","Someone mentions @Vimek in a Slack thread.",{"title":72,"description":73},"Read","Vimek answers from live data through read-only MCP tools. Every tool call is logged.",{"title":75,"description":76},"Propose","For a change in GitHub Projects, the CRM or the schedules, it posts a preview with Approve, Edit and Cancel.",{"title":78,"description":79},"Approve","Only a teammate on the allowed list can approve. Scheduled runs never propose changes.",{"title":81,"description":82},"Write","The change runs once and is recorded with the name of the person who approved it.",[84,88,92,96,100],{"title":85,"icon":86,"description":87},"Slack thread to task","i-lucide-square-kanban","Mention @Vimek in a thread and it proposes a GitHub Projects task, checks for duplicates and can move cards between columns, each after Approve.",{"title":89,"icon":90,"description":91},"Scheduled reports","i-lucide-calendar-clock","It can post where the sales funnel stands and the top 3 priorities on weekday mornings. The team adds new schedules by asking in Slack, and each one needs Approve.",{"title":93,"icon":94,"description":95},"CRM updates","i-lucide-user-check","\"Mark this lead as lost\" becomes a before and after preview. After Approve, the change lands on the lead's timeline with the name of who approved it.",{"title":97,"icon":98,"description":99},"Posted without Approve","i-lucide-message-square","Slack replies and scheduled reports are posted directly. The MCP server has one write tool, for release notes, and it stays off unless the API key allows writes.",{"title":101,"icon":102,"description":103},"Safe by design","i-lucide-shield-check","A read-only database role for the MCP server, an audit log for every tool call, and the token cost of every run logged.","Ask me about AI agents",{"title":106,"eyebrow":107,"items":108},"Three kinds of products I build.","What I build",[109,113,117],{"title":110,"icon":111,"description":112},"AI agents and automation","i-lucide-bot","Assistants, pipelines and MCP servers on Claude and Python. They turn data, video and documents into answers and actions, inside the tools a team already uses.",{"title":114,"icon":115,"description":116},"Web platforms and apps","i-lucide-app-window","Web platforms in Nuxt and Django, mobile apps in Flutter. From the first screen to launch, with staging, tests, analytics and crash reports from the start.",{"title":118,"icon":119,"description":120},"Hardware and wearables","i-lucide-watch","Embedded Rust on nRF52, BLE and ANT+, Garmin Connect IQ. Prototypes you can hold, strap on and test in the water.",{"eyebrow":122,"title":123,"description":124,"stats":125,"items":138},"Async by default","Fewer meetings. More Loom.","I work async. Instead of another call, you get a short video: a demo of what shipped, a decision explained on screen, or a review you can watch at 2x speed. You reply when it suits you, and nothing gets lost in a meeting nobody wrote down.",[126,129,132,135],{"value":127,"label":128},"1,400+","Loom videos recorded",{"value":130,"label":131},"17","Products and client projects run on video updates",{"value":133,"label":134},"176","Videos in a single product",{"value":136,"label":137},"2021","Working async since",[139,143,147,151],{"title":140,"icon":141,"description":142},"Demo after every release","i-lucide-play","Each release comes with a short walkthrough, so you see the real thing, not a status line.",{"title":144,"icon":145,"description":146},"Decisions on screen","i-lucide-git-branch","Trade-offs and options explained with the code or design open, ready to forward to your team.",{"title":148,"icon":149,"description":150},"Reviews you can replay","i-lucide-scan-eye","Code, design and data reviews recorded once and watched by everyone, in any time zone.",{"title":152,"icon":153,"description":154},"Onboarding that stays","i-lucide-library","Every video lands in a shared library, so new people catch up without a single meeting.",{"title":156,"eyebrow":157,"description":158},"Notes from the workbench.","Writing","What I learn while building, written up so the next person ships faster.",{"seo":160,"title":48,"description":163,"hero":164,"polymath":181,"arenas":236,"work":241,"agent":274,"opensource":292,"async":324,"how":335,"history":349,"testimonials":353,"blog":375,"contact":376},{"title":161,"description":162},"AI Product Builder","I build AI agents, apps and hardware that get past the demo. Not taking new projects. Open for consultations on AI strategy, architecture and MVP plans.","I build AI products that get past the demo. Agents that do real work, apps people open every day, and hardware that connects to both. AI-native from day one. Mostly in music and sport.",{"tagline":161,"status":165,"links":166,"marquee":174},"Not taking new projects · Open for consultations",[167,171],{"label":168,"to":169,"color":170},"Ask a question","#contact","neutral",{"label":172,"to":6,"color":170,"variant":173},"Read the blog","outline",[175,176,177,178,179,122,180],"Business first, code second","AI strategy","Architecture review","MVP and launch plans","Know the domain","Make Things Live",{"eyebrow":182,"title":183,"description":184,"items":185,"credentials":206},"Polymath","Engineer, founder, maker, TI coach.","I'm a polymath. I don't fit in one box, and that is the point. Each role feeds the others: the coach finds the real problem, the engineer builds the fix, the founder gets it to people.",[186,190,194,198,202],{"title":187,"icon":188,"description":189},"Engineer","i-lucide-code-xml","Twenty years of writing software. Today most of it is written with AI agents that I direct, review and ship.",{"title":191,"icon":192,"description":193},"Founder","i-lucide-rocket","Sizeof, Bravelab, MusicTech Lab and now Vimo Swim. I know what a product needs besides code.",{"title":195,"icon":196,"description":197},"Hardware maker","i-lucide-cpu","Embedded Rust on nRF52, BLE, OLED and my own boards, from the first breadboard to a production batch.",{"title":199,"icon":200,"description":201},"Swim tech & sports data","i-lucide-activity","Wearables, tempo trainers and training data for swimmers and coaches. Vimo Swim is where I put it all together.",{"title":203,"icon":204,"description":205},"Swim coach","i-lucide-waves","Total Immersion coach in Kraków. The pool is where my sport products meet real users first.",[207,212,217,222,227,231],{"name":208,"issuer":209,"year":210,"icon":119,"to":211},"Wearable Technologies and Sports Analytics","University of Michigan · Coursera","In progress","https://www.coursera.org/learn/wearable-technologies",{"name":213,"issuer":214,"year":215,"icon":204,"to":216},"SwimTech Coach (STC)","Elite Swim Performance Academy","2026","https://ude.my/UC-58aecbd8-6958-44c8-8f2d-d03345d685da",{"name":218,"issuer":219,"year":220,"icon":221},"Design Thinking In Practice","We are very","2020","i-lucide-lightbulb",{"name":223,"issuer":224,"year":225,"icon":226},"Postgraduate Studies in IT Business Management","AGH University of Kraków","2019","i-lucide-graduation-cap",{"name":228,"issuer":229,"year":225,"icon":230},"Team Kanban Practitioner","Lean Kanban University","i-lucide-kanban",{"name":232,"issuer":233,"year":234,"icon":235},"Management 3.0 Foundation","Management 3.0","2018","i-lucide-users",{"title":106,"eyebrow":107,"items":237},[238,239,240],{"title":110,"icon":111,"description":112},{"title":114,"icon":115,"description":116},{"title":118,"icon":119,"description":120},{"title":242,"eyebrow":243,"items":244},"Things I build and run.","Selected work",[245,251,257,263,268],{"title":246,"description":247,"tag":248,"to":249,"image":250},"Vimo Swim","Programmable swim metronome for swimmers and triathletes, plus apps for swimmers and coaches. In a testing program with Total Immersion coaches in Poland and Spain.","Sport · Hardware + apps","/projects/vimo-swim","/images/landing/vimo-swim-device.webp",{"title":252,"description":253,"tag":254,"to":255,"image":256},"Music metadata toolkit","DDEX and C2PA tools that catch metadata errors before release. Used in Claude, VS Code and Ableton Live.","Music · Open source","https://github.com/musictechlab/mtl-ddexvalidator","/images/stock/1574517947730-55cb23e608c2.webp",{"title":258,"description":259,"tag":260,"to":261,"image":262},"MCP servers","Servers that plug Claude into Trello, Codemagic, SignNow and Mailchimp. The tools I use to ship faster.","AI · Open source","https://github.com/msmenzyk/trello-mcp","/images/stock/1555066931-4365d14bab8c.webp",{"title":264,"description":265,"tag":266,"to":15,"image":267},"Vimo Swim for Garmin","Companion app for Garmin watches written in Monkey C. Tempo on the wrist, no phone in the water. Waiting for Connect IQ Store approval.","Sport · Wearable","/images/landing/vimo-swim-jetty.webp",{"title":269,"description":270,"tag":271,"to":272,"image":273},"Total Immersion Cracow","Swimming community in Kraków. Where my sport products get tested first.","Sport · Community","/projects/total-immersion-cracow","/images/landing/total-immersion-cracow.webp",{"eyebrow":50,"title":51,"description":52,"stats":275,"flowNote":66,"flow":280,"items":286,"cta":104},[276,277,278,279],{"value":55,"label":56},{"value":58,"label":59},{"value":61,"label":62},{"value":64,"label":65},[281,282,283,284,285],{"title":69,"description":70},{"title":72,"description":73},{"title":75,"description":76},{"title":78,"description":79},{"title":81,"description":82},[287,288,289,290,291],{"title":85,"icon":86,"description":87},{"title":89,"icon":90,"description":91},{"title":93,"icon":94,"description":95},{"title":97,"icon":98,"description":99},{"title":101,"icon":102,"description":103},{"eyebrow":293,"title":294,"description":295,"github":296,"githubPersonal":297,"items":298},"Open source","Built in the open.","MCP servers, Claude skills, music tech tools and small experiments, all public on GitHub. Music tech tools live under MusicTech Lab, my own tools and experiments under my account. Six of them below.","https://github.com/musictechlab","https://github.com/msmenzyk",[299,303,307,311,315,319],{"name":300,"description":301,"to":261,"language":302},"trello-mcp","Trello boards, lists and cards from Claude Code.","Python",{"name":304,"description":305,"to":306,"language":302},"mtl-metadata-mcp","Read and write tags in MP3, FLAC and OGG files.","https://github.com/musictechlab/mtl-metadata-mcp",{"name":308,"description":309,"to":310},"ddex-validate","Claude skill that validates DDEX ERN XML against official schemas.","https://github.com/musictechlab/ddex-validate",{"name":312,"description":313,"to":255,"language":314},"mtl-ddexvalidator","Web app that validates DDEX XML files against many schema versions.","Django",{"name":316,"description":317,"to":318,"language":302},"mtl-c2pa-ableton","Max for Live device that shows C2PA info inside Ableton.","https://github.com/musictechlab/mtl-c2pa-ableton",{"name":320,"description":321,"to":322,"language":323},"arduino-midi-ableton","Arduino MIDI controller with two buttons for Ableton Live.","https://github.com/msmenzyk/arduino-midi-ableton","C++",{"eyebrow":122,"title":123,"description":124,"stats":325,"items":330},[326,327,328,329],{"value":127,"label":128},{"value":130,"label":131},{"value":133,"label":134},{"value":136,"label":137},[331,332,333,334],{"title":140,"icon":141,"description":142},{"title":144,"icon":145,"description":146},{"title":148,"icon":149,"description":150},{"title":152,"icon":153,"description":154},{"title":336,"eyebrow":337,"description":338,"items":339},"Simple rules, kept.","How I work","I am not taking new projects. I am open for consultations, and they follow the same rules as the products I built: business first, one clear question, a written answer.",[340,343,346],{"title":341,"description":342},"What a consultation covers","AI strategy: where AI makes sense in your business and where it does not. Architecture and code review: what to fix first. MVP and launch plan: what to build first and what to leave out.",{"title":344,"description":345},"How it works","Email me one clear question. I reply within two working days. If a call helps, you book a slot in my calendar. After the call you get a written summary with next steps.",{"title":347,"description":348},"Where the advice comes from","Twenty years of building products end to end, from the first call to the app store or the device. Today I build my own products, Vimo Swim and Vimek, with the same tools I would recommend to you.",{"title":350,"eyebrow":351,"description":352},"Twenty years of shipping. Now with AI in every step.","Track record","I founded two software companies, served as CTO, and built products for labels, tour operators and startups. Today I work with AI tools and ship alone what used to need a team. Most of my products live in music and sport, where I know the users best.",[354,361,368],{"quote":355,"author":356},"I am continually impressed by how Mariusz shows up as both a strategic planner and a hands-on builder. He is especially good at helping to manage my \"feature creep\" by grounding big ideas into clear, achievable initiatives without killing momentum or curiosity. Mariusz can go from early research and prototyping to production-ready systems. What stands out most is his ability to roll up his sleeves and find solutions in situations where there aren't obvious answers. I appreciate the way he experiments, learns quickly, and delivers, in addition to the strategic support he provides.",{"name":357,"description":358,"avatar":359},"Michael Fiorentino","CEO at Ambistream",{"src":360,"alt":357},"/images/testimonials/michael-fiorentino.webp",{"quote":362,"author":363},"Marius and his team built us an incredibly useful piece of software and delivered on budget - doing a thoroughly professional and diligent job throughout the whole process.",{"name":364,"description":365,"avatar":366},"Matt Bristow","Director at Cherry Red Records",{"src":367,"alt":364},"/images/testimonials/matt-bristow.webp",{"quote":369,"author":370},"Mariusz proved to be 100% reliable partner. Extreme engagement in the project followed by professional work, great communication and dependability sums up Mariusz traits. Acute eye for detail, top notch technologies used is a standard. Not to mention first class relationship management. I had several ongoing projects which Mariusz will take a strong part in.",{"name":371,"description":372,"avatar":373},"Marek Godawski","Sr Director AI Software Engineering at Aven Hospitality",{"src":374,"alt":371},"/images/testimonials/marek-godawski.webp",{"title":156,"eyebrow":157,"description":158},{"title":377,"eyebrow":168,"description":378},"Ask me anything.","I'm not taking new projects right now, but I'm open for consultations on AI strategy, architecture and code review, or an MVP and launch plan. Email me your question. I reply within two working days, and if a call helps, you can book a slot in my calendar.",{"title":377,"eyebrow":168,"description":378},"yml",{"tagline":161,"status":165,"links":382,"marquee":385},[383,384],{"label":168,"to":169,"color":170},{"label":172,"to":6,"color":170,"variant":173},[175,176,177,178,179,122,180],{"title":350,"eyebrow":351,"description":352},{"title":336,"eyebrow":337,"description":338,"items":388},[389,390,391],{"title":341,"description":342},{"title":344,"description":345},{"title":347,"description":348},{},true,{"eyebrow":293,"title":294,"description":295,"github":296,"githubPersonal":297,"items":395},[396,397,398,399,400,401],{"name":300,"description":301,"to":261,"language":302},{"name":304,"description":305,"to":306,"language":302},{"name":308,"description":309,"to":310},{"name":312,"description":313,"to":255,"language":314},{"name":316,"description":317,"to":318,"language":302},{"name":320,"description":321,"to":322,"language":323},"/",{"eyebrow":182,"title":183,"description":184,"items":404,"credentials":410},[405,406,407,408,409],{"title":187,"icon":188,"description":189},{"title":191,"icon":192,"description":193},{"title":195,"icon":196,"description":197},{"title":199,"icon":200,"description":201},{"title":203,"icon":204,"description":205},[411,412,413,414,415,416],{"name":208,"issuer":209,"year":210,"icon":119,"to":211},{"name":213,"issuer":214,"year":215,"icon":204,"to":216},{"name":218,"issuer":219,"year":220,"icon":221},{"name":223,"issuer":224,"year":225,"icon":226},{"name":228,"issuer":229,"year":225,"icon":230},{"name":232,"issuer":233,"year":234,"icon":235},{"title":161,"description":162},"index",[420,423,426],{"quote":355,"author":421},{"name":357,"description":358,"avatar":422},{"src":360,"alt":357},{"quote":362,"author":424},{"name":364,"description":365,"avatar":425},{"src":367,"alt":364},{"quote":369,"author":427},{"name":371,"description":372,"avatar":428},{"src":374,"alt":371},{"title":242,"eyebrow":243,"items":430},[431,432,433,434,435],{"title":246,"description":247,"tag":248,"to":249,"image":250},{"title":252,"description":253,"tag":254,"to":255,"image":256},{"title":258,"description":259,"tag":260,"to":261,"image":262},{"title":264,"description":265,"tag":266,"to":15,"image":267},{"title":269,"description":270,"tag":271,"to":272,"image":273},"J97IczRP0UUrWRu01DVEc5nh72BxX7vhSk_j07QVqeg",[438,1618,2429,3887],{"id":439,"title":18,"author":440,"body":445,"date":1610,"description":1611,"extension":1612,"image":1613,"meta":1614,"minRead":645,"navigation":393,"path":19,"seo":1615,"stem":20,"__hash__":1617},"blog/blog/building-a-youtube-video-research-pipeline-with-whisper-and-claude.md",{"name":441,"description":442,"avatar":443},"Mariusz Smenzyk","Builder for sport. Make Things Live.",{"src":444,"alt":441},"/images/mariusz.jpeg",{"type":446,"value":447,"toc":1591},"minimark",[448,452,455,458,463,466,495,510,512,516,519,530,533,566,569,571,575,583,666,672,675,677,681,684,708,714,717,719,723,726,1123,1126,1152,1159,1161,1165,1168,1200,1203,1205,1209,1214,1217,1232,1236,1239,1243,1246,1301,1308,1312,1315,1341,1343,1347,1350,1356,1359,1361,1365,1368,1457,1460,1462,1466,1469,1495,1497,1501,1504,1507,1510,1512,1516,1587],[449,450,451],"p",{},"I watch a lot of YouTube — competitor demos, conference talks, market analyses, investor presentations. The problem? Key insights get lost. I'd watch a 45-minute video, think \"that was great,\" and a week later remember almost nothing specific.",[449,453,454],{},"So I built a tool that turns any YouTube video into a structured intelligence report — automatically. One CLI command: download, transcribe, analyze, display.",[456,457],"hr",{},[459,460,462],"h2",{"id":461},"the-problem","The Problem",[449,464,465],{},"Watching YouTube videos for market research is like drinking from a firehose:",[467,468,469,477,483,489],"ul",{},[470,471,472,476],"li",{},[473,474,475],"strong",{},"No searchability"," — you can't search across video content",[470,478,479,482],{},[473,480,481],{},"No structure"," — insights are scattered across 30-60 minute recordings",[470,484,485,488],{},[473,486,487],{},"No persistence"," — notes are informal, scattered, incomplete",[470,490,491,494],{},[473,492,493],{},"No shareability"," — team members have to rewatch the same videos",[449,496,497,498,504,505,509],{},"As a hardware startup founder building ",[499,500,246],"a",{"href":501,"rel":502},"https://vimoswim.com",[503],"nofollow"," — a swimming wearable — I consume hours of video content every week about wearable tech, sports science, embedded systems, and competitor products. I needed a system that could extract ",[506,507,508],"em",{},"structured, actionable intelligence"," from all this content.",[456,511],{},[459,513,515],{"id":514},"the-solution-a-four-step-pipeline","The Solution: A Four-Step Pipeline",[449,517,518],{},"The idea is simple — chain together three mature tools:",[520,521,526],"pre",{"className":522,"code":524,"language":525},[523],"language-text","YouTube URL → Audio (yt-dlp) → Transcript (Whisper) → Analysis (Claude) → Web Page (Nuxt)\n","text",[527,528,524],"code",{"__ignoreMap":529},"",[449,531,532],{},"One command runs the entire pipeline:",[520,534,538],{"className":535,"code":536,"language":537,"meta":529,"style":529},"language-bash shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","bb-dataroom video add \"https://www.youtube.com/watch?v=VIDEO_ID\"\n","bash",[527,539,540],{"__ignoreMap":529},[541,542,545,549,553,556,560,563],"span",{"class":543,"line":544},"line",1,[541,546,548],{"class":547},"sBMFI","bb-dataroom",[541,550,552],{"class":551},"sfazB"," video",[541,554,555],{"class":551}," add",[541,557,559],{"class":558},"sMK4o"," \"",[541,561,562],{"class":551},"https://www.youtube.com/watch?v=VIDEO_ID",[541,564,565],{"class":558},"\"\n",[449,567,568],{},"Let me walk through each step.",[456,570],{},[459,572,574],{"id":573},"step-1-download-audio-with-yt-dlp","Step 1: Download Audio with yt-dlp",[449,576,577,582],{},[499,578,581],{"href":579,"rel":580},"https://github.com/yt-dlp/yt-dlp",[503],"yt-dlp"," is the gold standard for YouTube downloading. Rather than calling it as a subprocess, I use its Python API directly for cleaner error handling:",[520,584,588],{"className":585,"code":586,"language":587,"meta":529,"style":529},"language-python shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import yt_dlp\n\nopts = {\n    \"format\": \"bestaudio/best\",\n    \"postprocessors\": [{\n        \"key\": \"FFmpegExtractAudio\",\n        \"preferredcodec\": \"wav\",\n    }],\n    \"postprocessor_args\": [\"-ar\", \"16000\", \"-ac\", \"1\"],\n}\n\nwith yt_dlp.YoutubeDL(opts) as ydl:\n    ydl.download([url])\n","python",[527,589,590,595,601,607,613,619,625,631,637,643,649,654,660],{"__ignoreMap":529},[541,591,592],{"class":543,"line":544},[541,593,594],{},"import yt_dlp\n",[541,596,598],{"class":543,"line":597},2,[541,599,600],{"emptyLinePlaceholder":393},"\n",[541,602,604],{"class":543,"line":603},3,[541,605,606],{},"opts = {\n",[541,608,610],{"class":543,"line":609},4,[541,611,612],{},"    \"format\": \"bestaudio/best\",\n",[541,614,616],{"class":543,"line":615},5,[541,617,618],{},"    \"postprocessors\": [{\n",[541,620,622],{"class":543,"line":621},6,[541,623,624],{},"        \"key\": \"FFmpegExtractAudio\",\n",[541,626,628],{"class":543,"line":627},7,[541,629,630],{},"        \"preferredcodec\": \"wav\",\n",[541,632,634],{"class":543,"line":633},8,[541,635,636],{},"    }],\n",[541,638,640],{"class":543,"line":639},9,[541,641,642],{},"    \"postprocessor_args\": [\"-ar\", \"16000\", \"-ac\", \"1\"],\n",[541,644,646],{"class":543,"line":645},10,[541,647,648],{},"}\n",[541,650,652],{"class":543,"line":651},11,[541,653,600],{"emptyLinePlaceholder":393},[541,655,657],{"class":543,"line":656},12,[541,658,659],{},"with yt_dlp.YoutubeDL(opts) as ydl:\n",[541,661,663],{"class":543,"line":662},13,[541,664,665],{},"    ydl.download([url])\n",[449,667,668,671],{},[473,669,670],{},"Key detail:"," We output 16kHz mono WAV — this is exactly what Whisper expects. Downsampling at download time saves a conversion step and reduces file size by ~4x compared to stereo 44.1kHz.",[449,673,674],{},"We also extract rich metadata (title, channel, duration, thumbnail, tags) in a separate call without downloading the video — useful for the frontend display later.",[456,676],{},[459,678,680],{"id":679},"step-2-transcribe-with-openai-whisper","Step 2: Transcribe with OpenAI Whisper",[449,682,683],{},"If you're familiar with OpenAI's Whisper, you know the basics. Here we take it further:",[520,685,687],{"className":585,"code":686,"language":587,"meta":529,"style":529},"import whisper\n\nmodel = whisper.load_model(\"base\")\nresult = whisper.transcribe(model, \"audio.wav\", verbose=False)\n",[527,688,689,694,698,703],{"__ignoreMap":529},[541,690,691],{"class":543,"line":544},[541,692,693],{},"import whisper\n",[541,695,696],{"class":543,"line":597},[541,697,600],{"emptyLinePlaceholder":393},[541,699,700],{"class":543,"line":603},[541,701,702],{},"model = whisper.load_model(\"base\")\n",[541,704,705],{"class":543,"line":609},[541,706,707],{},"result = whisper.transcribe(model, \"audio.wav\", verbose=False)\n",[449,709,710,713],{},[473,711,712],{},"Auto-language detection"," is crucial for a research tool. We don't hardcode a language — Whisper detects it automatically. The same command works for English conference talks, Polish market reports, or Japanese tech demos.",[449,715,716],{},"Transcripts are cached as JSON sidecar files with full segment timestamps. Re-running the pipeline on the same video skips transcription entirely — no wasted compute.",[456,718],{},[459,720,722],{"id":721},"step-3-analyze-with-claude","Step 3: Analyze with Claude",[449,724,725],{},"This is where it gets interesting. We send the full transcript to Claude with a structured prompt that returns JSON:",[520,727,731],{"className":728,"code":729,"language":730,"meta":529,"style":529},"language-json shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","{\n  \"tl_dr\": \"2-3 sentence executive summary\",\n  \"key_insights\": [\n    {\n      \"title\": \"Insight title\",\n      \"description\": \"What was said\",\n      \"relevance\": \"How it applies to our product\"\n    }\n  ],\n  \"market_signals\": [\n    {\n      \"signal\": \"Trend or opportunity\",\n      \"source\": \"Who mentioned it\",\n      \"strength\": \"strong | moderate | weak\"\n    }\n  ],\n  \"competitive_intel\": [\n    {\n      \"company_or_product\": \"Name\",\n      \"detail\": \"What was mentioned\",\n      \"threat_level\": \"high | medium | low | opportunity\"\n    }\n  ],\n  \"action_items\": [\n    {\n      \"action\": \"What to do\",\n      \"priority\": \"high | medium | low\",\n      \"category\": \"product | marketing | technical | business\"\n    }\n  ]\n}\n","json",[527,732,733,738,763,777,782,803,823,841,846,851,864,868,888,908,927,932,937,951,956,977,998,1017,1022,1027,1041,1046,1067,1088,1107,1112,1118],{"__ignoreMap":529},[541,734,735],{"class":543,"line":544},[541,736,737],{"class":558},"{\n",[541,739,740,743,747,750,753,755,758,760],{"class":543,"line":597},[541,741,742],{"class":558},"  \"",[541,744,746],{"class":745},"spNyl","tl_dr",[541,748,749],{"class":558},"\"",[541,751,752],{"class":558},":",[541,754,559],{"class":558},[541,756,757],{"class":551},"2-3 sentence executive summary",[541,759,749],{"class":558},[541,761,762],{"class":558},",\n",[541,764,765,767,770,772,774],{"class":543,"line":603},[541,766,742],{"class":558},[541,768,769],{"class":745},"key_insights",[541,771,749],{"class":558},[541,773,752],{"class":558},[541,775,776],{"class":558}," [\n",[541,778,779],{"class":543,"line":609},[541,780,781],{"class":558},"    {\n",[541,783,784,787,790,792,794,796,799,801],{"class":543,"line":615},[541,785,786],{"class":558},"      \"",[541,788,789],{"class":547},"title",[541,791,749],{"class":558},[541,793,752],{"class":558},[541,795,559],{"class":558},[541,797,798],{"class":551},"Insight title",[541,800,749],{"class":558},[541,802,762],{"class":558},[541,804,805,807,810,812,814,816,819,821],{"class":543,"line":621},[541,806,786],{"class":558},[541,808,809],{"class":547},"description",[541,811,749],{"class":558},[541,813,752],{"class":558},[541,815,559],{"class":558},[541,817,818],{"class":551},"What was said",[541,820,749],{"class":558},[541,822,762],{"class":558},[541,824,825,827,830,832,834,836,839],{"class":543,"line":627},[541,826,786],{"class":558},[541,828,829],{"class":547},"relevance",[541,831,749],{"class":558},[541,833,752],{"class":558},[541,835,559],{"class":558},[541,837,838],{"class":551},"How it applies to our product",[541,840,565],{"class":558},[541,842,843],{"class":543,"line":633},[541,844,845],{"class":558},"    }\n",[541,847,848],{"class":543,"line":639},[541,849,850],{"class":558},"  ],\n",[541,852,853,855,858,860,862],{"class":543,"line":645},[541,854,742],{"class":558},[541,856,857],{"class":745},"market_signals",[541,859,749],{"class":558},[541,861,752],{"class":558},[541,863,776],{"class":558},[541,865,866],{"class":543,"line":651},[541,867,781],{"class":558},[541,869,870,872,875,877,879,881,884,886],{"class":543,"line":656},[541,871,786],{"class":558},[541,873,874],{"class":547},"signal",[541,876,749],{"class":558},[541,878,752],{"class":558},[541,880,559],{"class":558},[541,882,883],{"class":551},"Trend or opportunity",[541,885,749],{"class":558},[541,887,762],{"class":558},[541,889,890,892,895,897,899,901,904,906],{"class":543,"line":662},[541,891,786],{"class":558},[541,893,894],{"class":547},"source",[541,896,749],{"class":558},[541,898,752],{"class":558},[541,900,559],{"class":558},[541,902,903],{"class":551},"Who mentioned it",[541,905,749],{"class":558},[541,907,762],{"class":558},[541,909,911,913,916,918,920,922,925],{"class":543,"line":910},14,[541,912,786],{"class":558},[541,914,915],{"class":547},"strength",[541,917,749],{"class":558},[541,919,752],{"class":558},[541,921,559],{"class":558},[541,923,924],{"class":551},"strong | moderate | weak",[541,926,565],{"class":558},[541,928,930],{"class":543,"line":929},15,[541,931,845],{"class":558},[541,933,935],{"class":543,"line":934},16,[541,936,850],{"class":558},[541,938,940,942,945,947,949],{"class":543,"line":939},17,[541,941,742],{"class":558},[541,943,944],{"class":745},"competitive_intel",[541,946,749],{"class":558},[541,948,752],{"class":558},[541,950,776],{"class":558},[541,952,954],{"class":543,"line":953},18,[541,955,781],{"class":558},[541,957,959,961,964,966,968,970,973,975],{"class":543,"line":958},19,[541,960,786],{"class":558},[541,962,963],{"class":547},"company_or_product",[541,965,749],{"class":558},[541,967,752],{"class":558},[541,969,559],{"class":558},[541,971,972],{"class":551},"Name",[541,974,749],{"class":558},[541,976,762],{"class":558},[541,978,980,982,985,987,989,991,994,996],{"class":543,"line":979},20,[541,981,786],{"class":558},[541,983,984],{"class":547},"detail",[541,986,749],{"class":558},[541,988,752],{"class":558},[541,990,559],{"class":558},[541,992,993],{"class":551},"What was mentioned",[541,995,749],{"class":558},[541,997,762],{"class":558},[541,999,1001,1003,1006,1008,1010,1012,1015],{"class":543,"line":1000},21,[541,1002,786],{"class":558},[541,1004,1005],{"class":547},"threat_level",[541,1007,749],{"class":558},[541,1009,752],{"class":558},[541,1011,559],{"class":558},[541,1013,1014],{"class":551},"high | medium | low | opportunity",[541,1016,565],{"class":558},[541,1018,1020],{"class":543,"line":1019},22,[541,1021,845],{"class":558},[541,1023,1025],{"class":543,"line":1024},23,[541,1026,850],{"class":558},[541,1028,1030,1032,1035,1037,1039],{"class":543,"line":1029},24,[541,1031,742],{"class":558},[541,1033,1034],{"class":745},"action_items",[541,1036,749],{"class":558},[541,1038,752],{"class":558},[541,1040,776],{"class":558},[541,1042,1044],{"class":543,"line":1043},25,[541,1045,781],{"class":558},[541,1047,1049,1051,1054,1056,1058,1060,1063,1065],{"class":543,"line":1048},26,[541,1050,786],{"class":558},[541,1052,1053],{"class":547},"action",[541,1055,749],{"class":558},[541,1057,752],{"class":558},[541,1059,559],{"class":558},[541,1061,1062],{"class":551},"What to do",[541,1064,749],{"class":558},[541,1066,762],{"class":558},[541,1068,1070,1072,1075,1077,1079,1081,1084,1086],{"class":543,"line":1069},27,[541,1071,786],{"class":558},[541,1073,1074],{"class":547},"priority",[541,1076,749],{"class":558},[541,1078,752],{"class":558},[541,1080,559],{"class":558},[541,1082,1083],{"class":551},"high | medium | low",[541,1085,749],{"class":558},[541,1087,762],{"class":558},[541,1089,1091,1093,1096,1098,1100,1102,1105],{"class":543,"line":1090},28,[541,1092,786],{"class":558},[541,1094,1095],{"class":547},"category",[541,1097,749],{"class":558},[541,1099,752],{"class":558},[541,1101,559],{"class":558},[541,1103,1104],{"class":551},"product | marketing | technical | business",[541,1106,565],{"class":558},[541,1108,1110],{"class":543,"line":1109},29,[541,1111,845],{"class":558},[541,1113,1115],{"class":543,"line":1114},30,[541,1116,1117],{"class":558},"  ]\n",[541,1119,1121],{"class":543,"line":1120},31,[541,1122,648],{"class":558},[449,1124,1125],{},"The analysis structure was designed for a startup context:",[467,1127,1128,1134,1140,1146],{},[470,1129,1130,1133],{},[473,1131,1132],{},"Key Insights"," — the most important takeaways, with explicit relevance mapping to our product",[470,1135,1136,1139],{},[473,1137,1138],{},"Market Signals"," — trends and shifts with strength ratings",[470,1141,1142,1145],{},[473,1143,1144],{},"Competitive Intelligence"," — companies and products mentioned, with threat assessment",[470,1147,1148,1151],{},[473,1149,1150],{},"Action Items"," — prioritized by category",[449,1153,1154,1155,1158],{},"I use ",[527,1156,1157],{},"claude-sonnet-4-5"," by default — it's fast enough for single-video analysis while being thorough in extracting nuanced business intelligence. The prompt is designed to work with any video language since Whisper handles the translation implicitly.",[456,1160],{},[459,1162,1164],{"id":1163},"step-4-generate-a-browsable-web-page","Step 4: Generate a Browsable Web Page",[449,1166,1167],{},"The analysis is written as a Nuxt Content markdown file with YAML frontmatter. The frontend renders it as part of our existing DataRoom application with:",[467,1169,1170,1176,1182,1188,1194],{},[470,1171,1172,1175],{},[473,1173,1174],{},"Embedded YouTube player"," — watch the original alongside the analysis",[470,1177,1178,1181],{},[473,1179,1180],{},"Structured sections"," — each analysis category gets its own visual section",[470,1183,1184,1187],{},[473,1185,1186],{},"Tag-based filtering"," — the LLM auto-generates relevant tags for cross-video search",[470,1189,1190,1193],{},[473,1191,1192],{},"Screenshot gallery"," — manually added screenshots for key slides/moments",[470,1195,1196,1199],{},[473,1197,1198],{},"Collapsible transcript"," — full timestamped transcript for reference",[449,1201,1202],{},"The Research section sits in the sidebar alongside our other knowledge base tools (chat archives, analytics, AI insights).",[456,1204],{},[459,1206,1208],{"id":1207},"architecture-decisions-worth-noting","Architecture Decisions Worth Noting",[1210,1211,1213],"h3",{"id":1212},"why-not-just-use-youtubes-auto-captions","Why Not Just Use YouTube's Auto-Captions?",[449,1215,1216],{},"YouTube's auto-generated captions are decent for simple content, but:",[1218,1219,1220,1223,1226,1229],"ol",{},[470,1221,1222],{},"They lack punctuation and proper formatting",[470,1224,1225],{},"They're not available for all videos",[470,1227,1228],{},"They can't be easily processed offline",[470,1230,1231],{},"Whisper produces significantly better results for non-English content and technical jargon",[1210,1233,1235],{"id":1234},"why-wav-instead-of-mp3","Why WAV Instead of MP3?",[449,1237,1238],{},"Whisper works best with uncompressed audio. MP3 compression introduces artifacts that can affect transcription accuracy, especially for quiet speech, heavy accents, or technical vocabulary. The WAV files are larger (~115 MB/hour) but we only keep them locally — they're excluded from git and deployment.",[1210,1240,1242],{"id":1241},"why-cache-at-every-step","Why Cache at Every Step?",[449,1244,1245],{},"Each step has a different cost profile:",[1247,1248,1249,1265],"table",{},[1250,1251,1252],"thead",{},[1253,1254,1255,1259,1262],"tr",{},[1256,1257,1258],"th",{},"Step",[1256,1260,1261],{},"Time",[1256,1263,1264],{},"Cost",[1266,1267,1268,1280,1291],"tbody",{},[1253,1269,1270,1274,1277],{},[1271,1272,1273],"td",{},"Download",[1271,1275,1276],{},"10-30s",[1271,1278,1279],{},"Free",[1253,1281,1282,1285,1288],{},[1271,1283,1284],{},"Transcription",[1271,1286,1287],{},"1-5 min",[1271,1289,1290],{},"Free (local)",[1253,1292,1293,1296,1298],{},[1271,1294,1295],{},"LLM Analysis",[1271,1297,1276],{},[1271,1299,1300],{},"~$0.02-0.05",[449,1302,1303,1304,1307],{},"By caching at each step, re-running the pipeline (e.g., to try a different prompt or a different LLM model) only repeats the changed step. The ",[527,1305,1306],{},"--force"," flag bypasses all caches when needed.",[1210,1309,1311],{"id":1310},"why-a-cli-instead-of-a-web-app","Why a CLI Instead of a Web App?",[449,1313,1314],{},"For a small team, a CLI is the right level of automation:",[467,1316,1317,1323,1329,1335],{},[470,1318,1319,1322],{},[473,1320,1321],{},"Fast to build"," — no auth, no queue, no webhooks",[470,1324,1325,1328],{},[473,1326,1327],{},"Easy to extend"," — adding a new analysis dimension is just editing a prompt template",[470,1330,1331,1334],{},[473,1332,1333],{},"Scriptable"," — can be integrated into batch workflows",[470,1336,1337,1340],{},[473,1338,1339],{},"Local-first"," — Whisper runs on your machine, no data leaves your network",[456,1342],{},[459,1344,1346],{"id":1345},"what-the-output-looks-like","What the Output Looks Like",[449,1348,1349],{},"Here's the asset structure for each analyzed video:",[520,1351,1354],{"className":1352,"code":1353,"language":525},[523],"web/content/research/{slug}.md              # Nuxt Content page\nweb/public/research/{slug}/\n  ├── audio.wav                              # Whisper input (16kHz mono)\n  ├── thumbnail.jpg                          # YouTube thumbnail\n  ├── transcript.json                        # Whisper output with segments\n  ├── analysis.json                          # LLM analysis cache\n  ├── metadata.json                          # Video metadata\n  └── screenshots/                           # Manual screenshots\n",[527,1355,1353],{"__ignoreMap":529},[449,1357,1358],{},"The generated markdown contains structured YAML frontmatter (video ID, channel, duration, tags, analysis date) that the Nuxt frontend uses for rendering cards, filtering, and navigation.",[456,1360],{},[459,1362,1364],{"id":1363},"real-world-workflow","Real-World Workflow",[449,1366,1367],{},"Here's what it looks like in practice:",[520,1369,1371],{"className":535,"code":1370,"language":537,"meta":529,"style":529},"# Analyze a competitor demo\nbb-dataroom video add \"https://youtube.com/watch?v=VIDEO_ID\"\n\n# Take some screenshots while watching, drop them in\ncp ~/Screenshots/*.png web/public/research/video-slug/screenshots/\nbb-dataroom video index\n\n# Start the dev server and browse\ncd web && pnpm dev\n",[527,1372,1373,1379,1394,1398,1403,1421,1430,1434,1439],{"__ignoreMap":529},[541,1374,1375],{"class":543,"line":544},[541,1376,1378],{"class":1377},"sHwdD","# Analyze a competitor demo\n",[541,1380,1381,1383,1385,1387,1389,1392],{"class":543,"line":597},[541,1382,548],{"class":547},[541,1384,552],{"class":551},[541,1386,555],{"class":551},[541,1388,559],{"class":558},[541,1390,1391],{"class":551},"https://youtube.com/watch?v=VIDEO_ID",[541,1393,565],{"class":558},[541,1395,1396],{"class":543,"line":603},[541,1397,600],{"emptyLinePlaceholder":393},[541,1399,1400],{"class":543,"line":609},[541,1401,1402],{"class":1377},"# Take some screenshots while watching, drop them in\n",[541,1404,1405,1408,1411,1415,1418],{"class":543,"line":615},[541,1406,1407],{"class":547},"cp",[541,1409,1410],{"class":551}," ~/Screenshots/",[541,1412,1414],{"class":1413},"sTEyZ","*",[541,1416,1417],{"class":551},".png",[541,1419,1420],{"class":551}," web/public/research/video-slug/screenshots/\n",[541,1422,1423,1425,1427],{"class":543,"line":621},[541,1424,548],{"class":547},[541,1426,552],{"class":551},[541,1428,1429],{"class":551}," index\n",[541,1431,1432],{"class":543,"line":627},[541,1433,600],{"emptyLinePlaceholder":393},[541,1435,1436],{"class":543,"line":633},[541,1437,1438],{"class":1377},"# Start the dev server and browse\n",[541,1440,1441,1445,1448,1451,1454],{"class":543,"line":639},[541,1442,1444],{"class":1443},"s2Zo4","cd",[541,1446,1447],{"class":551}," web",[541,1449,1450],{"class":558}," &&",[541,1452,1453],{"class":547}," pnpm",[541,1455,1456],{"class":551}," dev\n",[449,1458,1459],{},"The result is a searchable library of analyzed videos, organized by tags, with structured intelligence that the whole team can reference. No more \"I think I saw something about this in a video last month.\"",[456,1461],{},[459,1463,1465],{"id":1464},"whats-next","What's Next",[449,1467,1468],{},"A few ideas I'm considering:",[467,1470,1471,1477,1483,1489],{},[470,1472,1473,1476],{},[473,1474,1475],{},"Batch processing"," — analyze a YouTube playlist in one go",[470,1478,1479,1482],{},[473,1480,1481],{},"Channel monitoring"," — watch specific channels and auto-analyze new uploads",[470,1484,1485,1488],{},[473,1486,1487],{},"Cross-video synthesis"," — identify patterns across multiple analyzed videos (the existing AI synthesis pipeline from our chat analysis could be adapted)",[470,1490,1491,1494],{},[473,1492,1493],{},"Podcast support"," — same pipeline for audio-only content via RSS feeds",[456,1496],{},[459,1498,1500],{"id":1499},"wrapping-up","Wrapping Up",[449,1502,1503],{},"The gap between \"watching a YouTube video\" and \"having actionable intelligence from it\" is wider than most people realize. This pipeline bridges that gap with open-source tools and a structured LLM step.",[449,1505,1506],{},"Total development time was about 2 hours with Claude Code. The ROI is immediate — every analyzed video becomes a permanent, searchable knowledge asset instead of a fading memory.",[449,1508,1509],{},"If you're building a startup and consuming video content for research, something like this is worth building. The tools are mature, the code is straightforward, and the results compound over time.",[456,1511],{},[459,1513,1515],{"id":1514},"tech-stack","Tech Stack",[467,1517,1518,1526,1536,1546,1562,1577],{},[470,1519,1520,1525],{},[473,1521,1522],{},[499,1523,581],{"href":579,"rel":1524},[503]," — YouTube downloading (Python API)",[470,1527,1528,1535],{},[473,1529,1530],{},[499,1531,1534],{"href":1532,"rel":1533},"https://github.com/openai/whisper",[503],"OpenAI Whisper"," — local speech-to-text",[470,1537,1538,1545],{},[473,1539,1540],{},[499,1541,1544],{"href":1542,"rel":1543},"https://docs.anthropic.com",[503],"Claude API"," — structured analysis",[470,1547,1548,1561],{},[473,1549,1550,1555,1556],{},[499,1551,1554],{"href":1552,"rel":1553},"https://nuxt.com",[503],"Nuxt 4"," + ",[499,1557,1560],{"href":1558,"rel":1559},"https://content.nuxt.com",[503],"Nuxt Content"," — frontend rendering",[470,1563,1564,1576],{},[473,1565,1566,1555,1571],{},[499,1567,1570],{"href":1568,"rel":1569},"https://click.palletsprojects.com",[503],"Click",[499,1572,1575],{"href":1573,"rel":1574},"https://rich.readthedocs.io",[503],"Rich"," — CLI interface",[470,1578,1579,1586],{},[473,1580,1581],{},[499,1582,1585],{"href":1583,"rel":1584},"https://python-poetry.org",[503],"Poetry"," — Python dependency management",[1588,1589,1590],"style",{},"html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}",{"title":529,"searchDepth":597,"depth":597,"links":1592},[1593,1594,1595,1596,1597,1598,1599,1605,1606,1607,1608,1609],{"id":461,"depth":597,"text":462},{"id":514,"depth":597,"text":515},{"id":573,"depth":597,"text":574},{"id":679,"depth":597,"text":680},{"id":721,"depth":597,"text":722},{"id":1163,"depth":597,"text":1164},{"id":1207,"depth":597,"text":1208,"children":1600},[1601,1602,1603,1604],{"id":1212,"depth":603,"text":1213},{"id":1234,"depth":603,"text":1235},{"id":1241,"depth":603,"text":1242},{"id":1310,"depth":603,"text":1311},{"id":1345,"depth":597,"text":1346},{"id":1363,"depth":597,"text":1364},{"id":1464,"depth":597,"text":1465},{"id":1499,"depth":597,"text":1500},{"id":1514,"depth":597,"text":1515},"2026-02-19T00:00:00.000Z","How I built a CLI tool that downloads YouTube videos, transcribes them with Whisper, and generates structured market intelligence using Claude — all with one command.","md","/images/stock/1611162616475-46b635cb6868.webp",{},{"title":1616,"description":1611},"YouTube Research Pipeline with Whisper and Claude","XWO-nXNxF3EJt5ovkFhx-gabfOPJ-26rbHEFIRqEENk",{"id":1619,"title":10,"author":1620,"body":1622,"date":2423,"description":2424,"extension":1612,"image":262,"meta":2425,"minRead":633,"navigation":393,"path":11,"seo":2426,"stem":12,"__hash__":2428},"blog/blog/automating-google-docs-with-claude-code.md",{"name":441,"description":442,"avatar":1621},{"src":444,"alt":441},{"type":446,"value":1623,"toc":2399},[1624,1627,1634,1692,1695,1697,1701,1704,1710,1713,1719,1721,1725,1728,1734,1738,1745,1793,1796,1800,1803,1836,1839,1843,1846,1903,1906,1984,1988,1991,2029,2032,2034,2038,2041,2045,2056,2118,2122,2125,2134,2137,2141,2148,2152,2155,2179,2182,2184,2188,2192,2202,2206,2219,2223,2241,2251,2255,2289,2293,2316,2320,2374,2376,2380,2383,2386,2389,2391,2396],[449,1625,1626],{},"I had a problem: legal documents and IP valuations living in Google Docs, and Claude Code living in my terminal. Every time I wanted AI help editing a contract or fixing typos in a valuation document, I had to copy-paste between the browser and the CLI. It was slow, error-prone, and broke formatting.",[449,1628,1629,1630,1633],{},"So I built ",[527,1631,1632],{},"gdoc"," — a 200-line Python CLI that bridges Google Docs and Claude Code. Now my workflow looks like this:",[520,1635,1637],{"className":535,"code":1636,"language":537,"meta":529,"style":529},"# Pull a Google Doc as DOCX\ngdoc pull \"https://docs.google.com/document/d/1abc.../edit\"\n\n# Claude edits the DOCX directly (python-docx)\n\n# Push it back\ngdoc push \"https://docs.google.com/document/d/1abc.../edit\" ip/my-doc.docx\n",[527,1638,1639,1644,1658,1662,1667,1671,1676],{"__ignoreMap":529},[541,1640,1641],{"class":543,"line":544},[541,1642,1643],{"class":1377},"# Pull a Google Doc as DOCX\n",[541,1645,1646,1648,1651,1653,1656],{"class":543,"line":597},[541,1647,1632],{"class":547},[541,1649,1650],{"class":551}," pull",[541,1652,559],{"class":558},[541,1654,1655],{"class":551},"https://docs.google.com/document/d/1abc.../edit",[541,1657,565],{"class":558},[541,1659,1660],{"class":543,"line":603},[541,1661,600],{"emptyLinePlaceholder":393},[541,1663,1664],{"class":543,"line":609},[541,1665,1666],{"class":1377},"# Claude edits the DOCX directly (python-docx)\n",[541,1668,1669],{"class":543,"line":615},[541,1670,600],{"emptyLinePlaceholder":393},[541,1672,1673],{"class":543,"line":621},[541,1674,1675],{"class":1377},"# Push it back\n",[541,1677,1678,1680,1683,1685,1687,1689],{"class":543,"line":627},[541,1679,1632],{"class":547},[541,1681,1682],{"class":551}," push",[541,1684,559],{"class":558},[541,1686,1655],{"class":551},[541,1688,749],{"class":558},[541,1690,1691],{"class":551}," ip/my-doc.docx\n",[449,1693,1694],{},"No formatting loss. No copy-paste. Full round-trip.",[456,1696],{},[459,1698,1700],{"id":1699},"the-problem-with-markdown-round-trips","The Problem with Markdown Round-Trips",[449,1702,1703],{},"My first attempt used pandoc to convert Google Docs to Markdown, edit in Markdown, then convert back to DOCX. It sounded clean in theory:",[520,1705,1708],{"className":1706,"code":1707,"language":525},[523],"Google Doc → DOCX → Markdown → (edit) → DOCX → Google Doc\n",[527,1709,1707],{"__ignoreMap":529},[449,1711,1712],{},"The result? Tables got mangled. Images disappeared. Custom formatting was lost. The 1.6 MB document came back as a 22 KB skeleton.",[449,1714,1715,1718],{},[473,1716,1717],{},"Lesson learned",": if you want lossless editing, skip the Markdown step entirely and work on the DOCX directly.",[456,1720],{},[459,1722,1724],{"id":1723},"architecture","Architecture",[449,1726,1727],{},"The tool has four modules:",[520,1729,1732],{"className":1730,"code":1731,"language":525},[523],"gdoc/\n├── auth.py      # OAuth2 flow + token management\n├── drive.py     # Google Drive API: download/upload DOCX\n├── convert.py   # pandoc wrapper (optional, for initial reads)\n└── cli.py       # Click CLI: pull / push / auth commands\n",[527,1733,1731],{"__ignoreMap":529},[1210,1735,1737],{"id":1736},"authentication","Authentication",[449,1739,1740,1741,1744],{},"Google's OAuth2 for desktop apps uses ",[527,1742,1743],{},"InstalledAppFlow"," — it opens your browser, you authorize, and the token is saved locally:",[520,1746,1748],{"className":585,"code":1747,"language":587,"meta":529,"style":529},"from google_auth_oauthlib.flow import InstalledAppFlow\n\nSCOPES = [\"https://www.googleapis.com/auth/drive\"]\n\nflow = InstalledAppFlow.from_client_secrets_file(\n    \"credentials.json\", SCOPES\n)\ncreds = flow.run_local_server(port=0)\n# Save token for future use\n",[527,1749,1750,1755,1759,1764,1768,1773,1778,1783,1788],{"__ignoreMap":529},[541,1751,1752],{"class":543,"line":544},[541,1753,1754],{},"from google_auth_oauthlib.flow import InstalledAppFlow\n",[541,1756,1757],{"class":543,"line":597},[541,1758,600],{"emptyLinePlaceholder":393},[541,1760,1761],{"class":543,"line":603},[541,1762,1763],{},"SCOPES = [\"https://www.googleapis.com/auth/drive\"]\n",[541,1765,1766],{"class":543,"line":609},[541,1767,600],{"emptyLinePlaceholder":393},[541,1769,1770],{"class":543,"line":615},[541,1771,1772],{},"flow = InstalledAppFlow.from_client_secrets_file(\n",[541,1774,1775],{"class":543,"line":621},[541,1776,1777],{},"    \"credentials.json\", SCOPES\n",[541,1779,1780],{"class":543,"line":627},[541,1781,1782],{},")\n",[541,1784,1785],{"class":543,"line":633},[541,1786,1787],{},"creds = flow.run_local_server(port=0)\n",[541,1789,1790],{"class":543,"line":639},[541,1791,1792],{},"# Save token for future use\n",[449,1794,1795],{},"One-time setup, then it auto-refreshes.",[1210,1797,1799],{"id":1798},"pulling-a-document","Pulling a Document",[449,1801,1802],{},"The Google Drive API exports Google Docs as DOCX natively:",[520,1804,1806],{"className":585,"code":1805,"language":587,"meta":529,"style":529},"from googleapiclient.http import MediaIoBaseDownload\n\nrequest = service.files().export_media(\n    fileId=doc_id,\n    mimeType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"\n)\n",[527,1807,1808,1813,1817,1822,1827,1832],{"__ignoreMap":529},[541,1809,1810],{"class":543,"line":544},[541,1811,1812],{},"from googleapiclient.http import MediaIoBaseDownload\n",[541,1814,1815],{"class":543,"line":597},[541,1816,600],{"emptyLinePlaceholder":393},[541,1818,1819],{"class":543,"line":603},[541,1820,1821],{},"request = service.files().export_media(\n",[541,1823,1824],{"class":543,"line":609},[541,1825,1826],{},"    fileId=doc_id,\n",[541,1828,1829],{"class":543,"line":615},[541,1830,1831],{},"    mimeType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"\n",[541,1833,1834],{"class":543,"line":621},[541,1835,1782],{},[449,1837,1838],{},"This gives you a real DOCX file — complete with tables, images, styles, and formatting.",[1210,1840,1842],{"id":1841},"editing-with-python-docx","Editing with python-docx",[449,1844,1845],{},"This is where the magic happens. Instead of converting to Markdown, we manipulate the DOCX directly:",[520,1847,1849],{"className":585,"code":1848,"language":587,"meta":529,"style":529},"from docx import Document\n\ndoc = Document(\"my-document.docx\")\n\n# Fix a typo across the entire document\nfor p in doc.paragraphs:\n    for run in p.runs:\n        if \"minumum\" in run.text:\n            run.text = run.text.replace(\"minumum\", \"minimum\")\n\ndoc.save(\"my-document.docx\")\n",[527,1850,1851,1856,1860,1865,1869,1874,1879,1884,1889,1894,1898],{"__ignoreMap":529},[541,1852,1853],{"class":543,"line":544},[541,1854,1855],{},"from docx import Document\n",[541,1857,1858],{"class":543,"line":597},[541,1859,600],{"emptyLinePlaceholder":393},[541,1861,1862],{"class":543,"line":603},[541,1863,1864],{},"doc = Document(\"my-document.docx\")\n",[541,1866,1867],{"class":543,"line":609},[541,1868,600],{"emptyLinePlaceholder":393},[541,1870,1871],{"class":543,"line":615},[541,1872,1873],{},"# Fix a typo across the entire document\n",[541,1875,1876],{"class":543,"line":621},[541,1877,1878],{},"for p in doc.paragraphs:\n",[541,1880,1881],{"class":543,"line":627},[541,1882,1883],{},"    for run in p.runs:\n",[541,1885,1886],{"class":543,"line":633},[541,1887,1888],{},"        if \"minumum\" in run.text:\n",[541,1890,1891],{"class":543,"line":639},[541,1892,1893],{},"            run.text = run.text.replace(\"minumum\", \"minimum\")\n",[541,1895,1896],{"class":543,"line":645},[541,1897,600],{"emptyLinePlaceholder":393},[541,1899,1900],{"class":543,"line":651},[541,1901,1902],{},"doc.save(\"my-document.docx\")\n",[449,1904,1905],{},"You can do much more — add table rows, style headers, insert hyperlinks, change fonts:",[520,1907,1909],{"className":585,"code":1908,"language":587,"meta":529,"style":529},"from docx.shared import RGBColor\nfrom docx.oxml.ns import qn\nfrom docx.oxml import OxmlElement\n\n# Style a table header with dark background + white text\ndef set_cell_bg(cell, color_hex):\n    shd = OxmlElement(\"w:shd\")\n    shd.set(qn(\"w:fill\"), color_hex)\n    shd.set(qn(\"w:val\"), \"clear\")\n    cell._tc.get_or_add_tcPr().append(shd)\n\nfor cell in table.rows[0].cells:\n    set_cell_bg(cell, \"0F172B\")  # dark navy\n    for run in cell.paragraphs[0].runs:\n        run.font.color.rgb = RGBColor(255, 255, 255)  # white text\n",[527,1910,1911,1916,1921,1926,1930,1935,1940,1945,1950,1955,1960,1964,1969,1974,1979],{"__ignoreMap":529},[541,1912,1913],{"class":543,"line":544},[541,1914,1915],{},"from docx.shared import RGBColor\n",[541,1917,1918],{"class":543,"line":597},[541,1919,1920],{},"from docx.oxml.ns import qn\n",[541,1922,1923],{"class":543,"line":603},[541,1924,1925],{},"from docx.oxml import OxmlElement\n",[541,1927,1928],{"class":543,"line":609},[541,1929,600],{"emptyLinePlaceholder":393},[541,1931,1932],{"class":543,"line":615},[541,1933,1934],{},"# Style a table header with dark background + white text\n",[541,1936,1937],{"class":543,"line":621},[541,1938,1939],{},"def set_cell_bg(cell, color_hex):\n",[541,1941,1942],{"class":543,"line":627},[541,1943,1944],{},"    shd = OxmlElement(\"w:shd\")\n",[541,1946,1947],{"class":543,"line":633},[541,1948,1949],{},"    shd.set(qn(\"w:fill\"), color_hex)\n",[541,1951,1952],{"class":543,"line":639},[541,1953,1954],{},"    shd.set(qn(\"w:val\"), \"clear\")\n",[541,1956,1957],{"class":543,"line":645},[541,1958,1959],{},"    cell._tc.get_or_add_tcPr().append(shd)\n",[541,1961,1962],{"class":543,"line":651},[541,1963,600],{"emptyLinePlaceholder":393},[541,1965,1966],{"class":543,"line":656},[541,1967,1968],{},"for cell in table.rows[0].cells:\n",[541,1970,1971],{"class":543,"line":662},[541,1972,1973],{},"    set_cell_bg(cell, \"0F172B\")  # dark navy\n",[541,1975,1976],{"class":543,"line":910},[541,1977,1978],{},"    for run in cell.paragraphs[0].runs:\n",[541,1980,1981],{"class":543,"line":929},[541,1982,1983],{},"        run.font.color.rgb = RGBColor(255, 255, 255)  # white text\n",[1210,1985,1987],{"id":1986},"pushing-back","Pushing Back",[449,1989,1990],{},"Upload the modified DOCX, replacing the Google Doc content:",[520,1992,1994],{"className":585,"code":1993,"language":587,"meta":529,"style":529},"from googleapiclient.http import MediaFileUpload\n\nmedia = MediaFileUpload(\n    \"my-document.docx\",\n    mimetype=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"\n)\nservice.files().update(fileId=doc_id, media_body=media).execute()\n",[527,1995,1996,2001,2005,2010,2015,2020,2024],{"__ignoreMap":529},[541,1997,1998],{"class":543,"line":544},[541,1999,2000],{},"from googleapiclient.http import MediaFileUpload\n",[541,2002,2003],{"class":543,"line":597},[541,2004,600],{"emptyLinePlaceholder":393},[541,2006,2007],{"class":543,"line":603},[541,2008,2009],{},"media = MediaFileUpload(\n",[541,2011,2012],{"class":543,"line":609},[541,2013,2014],{},"    \"my-document.docx\",\n",[541,2016,2017],{"class":543,"line":615},[541,2018,2019],{},"    mimetype=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document\"\n",[541,2021,2022],{"class":543,"line":621},[541,2023,1782],{},[541,2025,2026],{"class":543,"line":627},[541,2027,2028],{},"service.files().update(fileId=doc_id, media_body=media).execute()\n",[449,2030,2031],{},"The Google Doc is updated in place — revision history preserved.",[456,2033],{},[459,2035,2037],{"id":2036},"real-world-examples","Real-World Examples",[449,2039,2040],{},"Here's what I've actually used this for in a single session:",[1210,2042,2044],{"id":2043},"_1-batch-typo-fixing-in-a-legal-contract","1. Batch Typo Fixing in a Legal Contract",[449,2046,2047,2048,2051,2052,2055],{},"I pulled a framework agreement (Umowa Ramowa), asked Claude to scan for typos, and it found 11 issues — from simple misspellings (",[527,2049,2050],{},"minumum"," → ",[527,2053,2054],{},"minimum",") to a completely truncated legal clause. All fixed programmatically:",[520,2057,2059],{"className":585,"code":2058,"language":587,"meta":529,"style":529},"fixes = {\n    \"alby\": \"aby\",\n    \"minumum\": \"minimum\",\n    \"mogłby\": \"mogły\",\n    \"tyś\": \"tys.\",\n    \"pcb\": \"PCB\",\n}\nfor p in doc.paragraphs:\n    for run in p.runs:\n        for old, new in fixes.items():\n            if old in run.text:\n                run.text = run.text.replace(old, new)\n",[527,2060,2061,2066,2071,2076,2081,2086,2091,2095,2099,2103,2108,2113],{"__ignoreMap":529},[541,2062,2063],{"class":543,"line":544},[541,2064,2065],{},"fixes = {\n",[541,2067,2068],{"class":543,"line":597},[541,2069,2070],{},"    \"alby\": \"aby\",\n",[541,2072,2073],{"class":543,"line":603},[541,2074,2075],{},"    \"minumum\": \"minimum\",\n",[541,2077,2078],{"class":543,"line":609},[541,2079,2080],{},"    \"mogłby\": \"mogły\",\n",[541,2082,2083],{"class":543,"line":615},[541,2084,2085],{},"    \"tyś\": \"tys.\",\n",[541,2087,2088],{"class":543,"line":621},[541,2089,2090],{},"    \"pcb\": \"PCB\",\n",[541,2092,2093],{"class":543,"line":627},[541,2094,648],{},[541,2096,2097],{"class":543,"line":633},[541,2098,1878],{},[541,2100,2101],{"class":543,"line":639},[541,2102,1883],{},[541,2104,2105],{"class":543,"line":645},[541,2106,2107],{},"        for old, new in fixes.items():\n",[541,2109,2110],{"class":543,"line":651},[541,2111,2112],{},"            if old in run.text:\n",[541,2114,2115],{"class":543,"line":656},[541,2116,2117],{},"                run.text = run.text.replace(old, new)\n",[1210,2119,2121],{"id":2120},"_2-styling-table-headers","2. Styling Table Headers",[449,2123,2124],{},"One command to style all table headers with the Vimo Swim brand color:",[520,2126,2128],{"className":535,"code":2127,"language":537,"meta":529,"style":529},"# oklch(20.8% .042 265.755) → #0F172B\n",[527,2129,2130],{"__ignoreMap":529},[541,2131,2132],{"class":543,"line":544},[541,2133,2127],{"class":1377},[449,2135,2136],{},"Claude converted the oklch color to hex RGB, then applied dark background + white font to all 10 table header rows.",[1210,2138,2140],{"id":2139},"_3-adding-github-links-to-an-ip-valuation-table","3. Adding GitHub Links to an IP Valuation Table",[449,2142,2143,2144,2147],{},"The IP valuation document lists 20 software components. Claude scanned the local ",[527,2145,2146],{},"beatbuddy-platform/"," directory, extracted GitHub remote URLs from each repo, and added clickable hyperlinks directly into the DOCX table cells.",[1210,2149,2151],{"id":2150},"_4-reading-google-docs-comments","4. Reading Google Docs Comments",[449,2153,2154],{},"Google Docs comments aren't in the DOCX — they're only accessible via the Drive API:",[520,2156,2158],{"className":585,"code":2157,"language":587,"meta":529,"style":529},"comments = service.comments().list(\n    fileId=doc_id,\n    fields=\"comments(author/displayName,content,quotedFileContent/value,resolved)\"\n).execute()\n",[527,2159,2160,2165,2169,2174],{"__ignoreMap":529},[541,2161,2162],{"class":543,"line":544},[541,2163,2164],{},"comments = service.comments().list(\n",[541,2166,2167],{"class":543,"line":597},[541,2168,1826],{},[541,2170,2171],{"class":543,"line":603},[541,2172,2173],{},"    fields=\"comments(author/displayName,content,quotedFileContent/value,resolved)\"\n",[541,2175,2176],{"class":543,"line":609},[541,2177,2178],{},").execute()\n",[449,2180,2181],{},"This let Claude read reviewer comments, summarize them in a table, and suggest specific contract clause amendments — all without opening a browser.",[456,2183],{},[459,2185,2187],{"id":2186},"setup-guide","Setup Guide",[1210,2189,2191],{"id":2190},"prerequisites","Prerequisites",[467,2193,2194,2197,2199],{},[470,2195,2196],{},"Python 3.11+",[470,2198,1585],{},[470,2200,2201],{},"pandoc (optional, for initial MD conversion)",[1210,2203,2205],{"id":2204},"_1-create-a-google-cloud-project","1. Create a Google Cloud Project",[449,2207,2208,2209,2214,2215,2218],{},"Go to ",[499,2210,2213],{"href":2211,"rel":2212},"https://console.cloud.google.com/",[503],"Google Cloud Console",", create a project, and enable the ",[473,2216,2217],{},"Google Drive API",".",[1210,2220,2222],{"id":2221},"_2-create-oauth-credentials","2. Create OAuth Credentials",[449,2224,2225,2226,2229,2230,2233,2234,2237,2238,2218],{},"Under ",[473,2227,2228],{},"APIs & Services → Credentials",", create an ",[473,2231,2232],{},"OAuth 2.0 Client ID"," with type ",[473,2235,2236],{},"Desktop app",". Download the JSON file as ",[527,2239,2240],{},"credentials.json",[449,2242,2243,2244,2246,2247,2250],{},"Important: you need a ",[473,2245,2236],{}," client, not a Web application. The CLI uses ",[527,2248,2249],{},"localhost"," redirect for the OAuth flow.",[1210,2252,2254],{"id":2253},"_3-install-the-tool","3. Install the Tool",[520,2256,2258],{"className":535,"code":2257,"language":537,"meta":529,"style":529},"poetry init\npoetry add google-api-python-client google-auth-oauthlib google-auth-httplib2 click python-docx\n",[527,2259,2260,2268],{"__ignoreMap":529},[541,2261,2262,2265],{"class":543,"line":544},[541,2263,2264],{"class":547},"poetry",[541,2266,2267],{"class":551}," init\n",[541,2269,2270,2272,2274,2277,2280,2283,2286],{"class":543,"line":597},[541,2271,2264],{"class":547},[541,2273,555],{"class":551},[541,2275,2276],{"class":551}," google-api-python-client",[541,2278,2279],{"class":551}," google-auth-oauthlib",[541,2281,2282],{"class":551}," google-auth-httplib2",[541,2284,2285],{"class":551}," click",[541,2287,2288],{"class":551}," python-docx\n",[1210,2290,2292],{"id":2291},"_4-authenticate","4. Authenticate",[520,2294,2296],{"className":535,"code":2295,"language":537,"meta":529,"style":529},"poetry run gdoc auth\n# Opens browser → authorize → token saved\n",[527,2297,2298,2311],{"__ignoreMap":529},[541,2299,2300,2302,2305,2308],{"class":543,"line":544},[541,2301,2264],{"class":547},[541,2303,2304],{"class":551}," run",[541,2306,2307],{"class":551}," gdoc",[541,2309,2310],{"class":551}," auth\n",[541,2312,2313],{"class":543,"line":597},[541,2314,2315],{"class":1377},"# Opens browser → authorize → token saved\n",[1210,2317,2319],{"id":2318},"_5-use-it","5. Use It",[520,2321,2323],{"className":535,"code":2322,"language":537,"meta":529,"style":529},"# Pull\npoetry run gdoc pull \"https://docs.google.com/document/d/1abc.../edit\"\n\n# Push\npoetry run gdoc push \"https://docs.google.com/document/d/1abc.../edit\" path/to/file.docx\n",[527,2324,2325,2330,2346,2350,2355],{"__ignoreMap":529},[541,2326,2327],{"class":543,"line":544},[541,2328,2329],{"class":1377},"# Pull\n",[541,2331,2332,2334,2336,2338,2340,2342,2344],{"class":543,"line":597},[541,2333,2264],{"class":547},[541,2335,2304],{"class":551},[541,2337,2307],{"class":551},[541,2339,1650],{"class":551},[541,2341,559],{"class":558},[541,2343,1655],{"class":551},[541,2345,565],{"class":558},[541,2347,2348],{"class":543,"line":603},[541,2349,600],{"emptyLinePlaceholder":393},[541,2351,2352],{"class":543,"line":609},[541,2353,2354],{"class":1377},"# Push\n",[541,2356,2357,2359,2361,2363,2365,2367,2369,2371],{"class":543,"line":615},[541,2358,2264],{"class":547},[541,2360,2304],{"class":551},[541,2362,2307],{"class":551},[541,2364,1682],{"class":551},[541,2366,559],{"class":558},[541,2368,1655],{"class":551},[541,2370,749],{"class":558},[541,2372,2373],{"class":551}," path/to/file.docx\n",[456,2375],{},[459,2377,2379],{"id":2378},"key-takeaway","Key Takeaway",[449,2381,2382],{},"The combination of Claude Code + Google Docs API + python-docx creates a workflow where you can treat Google Docs as editable files in your terminal. No browser tab-switching, no copy-paste, no formatting loss.",[449,2384,2385],{},"The tool is intentionally minimal — ~200 lines across 4 files. It doesn't try to be a full Google Docs client. It just bridges the gap between \"AI assistant in the terminal\" and \"documents my team collaborates on in Google Docs.\"",[449,2387,2388],{},"What surprised me most: once the pipeline existed, the use cases multiplied — typo checking, contract review, table styling, adding hyperlinks, reading comments, suggesting legal clauses. Each task that would have been 15 minutes of manual copy-paste became a 30-second command.",[456,2390],{},[449,2392,2393],{},[506,2394,2395],{},"Built with Claude Code, Google Drive API v3, and python-docx.",[1588,2397,2398],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}",{"title":529,"searchDepth":597,"depth":597,"links":2400},[2401,2402,2408,2414,2422],{"id":1699,"depth":597,"text":1700},{"id":1723,"depth":597,"text":1724,"children":2403},[2404,2405,2406,2407],{"id":1736,"depth":603,"text":1737},{"id":1798,"depth":603,"text":1799},{"id":1841,"depth":603,"text":1842},{"id":1986,"depth":603,"text":1987},{"id":2036,"depth":597,"text":2037,"children":2409},[2410,2411,2412,2413],{"id":2043,"depth":603,"text":2044},{"id":2120,"depth":603,"text":2121},{"id":2139,"depth":603,"text":2140},{"id":2150,"depth":603,"text":2151},{"id":2186,"depth":597,"text":2187,"children":2415},[2416,2417,2418,2419,2420,2421],{"id":2190,"depth":603,"text":2191},{"id":2204,"depth":603,"text":2205},{"id":2221,"depth":603,"text":2222},{"id":2253,"depth":603,"text":2254},{"id":2291,"depth":603,"text":2292},{"id":2318,"depth":603,"text":2319},{"id":2378,"depth":597,"text":2379},"2026-02-18T00:00:00.000Z","How I built a Python CLI tool that lets Claude Code read and write Google Docs directly — editing DOCX files programmatically, fixing typos, styling tables, and reading comments, all from the terminal.",{},{"title":2427,"description":2424},"Automating Google Docs with Claude Code: A Sync CLI","t05LvjChWb1on_emv8VLy9Xk7GT_l4zMkemKHxbI0yE",{"id":2430,"title":14,"author":2431,"body":2433,"date":2423,"description":3881,"extension":1612,"image":3882,"meta":3883,"minRead":645,"navigation":393,"path":15,"seo":3884,"stem":16,"__hash__":3886},"blog/blog/building-a-garmin-connect-iq-app-in-monkey-c.md",{"name":441,"description":442,"avatar":2432},{"src":444,"alt":441},{"type":446,"value":2434,"toc":3860},[2435,2438,2441,2443,2447,2454,2474,2477,2483,2485,2489,2492,2496,2499,2544,2547,2594,2605,2609,2612,2649,2656,2660,2663,2672,2686,2688,2692,2706,2708,2711,2715,2894,2897,2900,2928,2930,2934,2937,3014,3021,3024,3048,3051,3122,3124,3128,3135,3138,3209,3212,3214,3218,3221,3227,3346,3349,3373,3376,3378,3382,3389,3489,3504,3507,3531,3533,3537,3544,3591,3610,3612,3616,3625,3674,3677,3683,3686,3688,3692,3762,3764,3768,3796,3798,3802,3843,3846,3848,3857],[449,2436,2437],{},"When I needed to control a swim metronome from a Garmin watch, I expected the BLE (Bluetooth Low Energy) part to be the hard part. Instead, the real challenge was Monkey C — Garmin's proprietary language for Connect IQ apps. No regex. No string parsing methods. No stack traces. And a simulator that crashes if you initialize BLE too early.",[449,2439,2440],{},"This is what I learned building a real-world BLE remote control app for 27 Garmin watch models.",[456,2442],{},[459,2444,2446],{"id":2445},"what-were-building","What We're Building",[449,2448,2449,2450,2453],{},"The app connects a Garmin watch to a ",[499,2451,246],{"href":501,"rel":2452},[503]," swim metronome over BLE. The watch acts as a remote control:",[467,2455,2456,2462,2468],{},[470,2457,2458,2461],{},[473,2459,2460],{},"SELECT button"," → start/stop the metronome",[470,2463,2464,2467],{},[473,2465,2466],{},"UP/DOWN buttons"," → adjust tempo (±0.05 seconds)",[470,2469,2470,2473],{},[473,2471,2472],{},"Auto-reconnect"," → if connection drops underwater, it reconnects automatically",[449,2475,2476],{},"The entire app is 4 source files, ~650 lines of Monkey C. No frameworks, no dependencies — just the Connect IQ SDK.",[520,2478,2481],{"className":2479,"code":2480,"language":525},[523],"source/\n├── BeatBuddyApp.mc       # App lifecycle\n├── BeatBuddyComm.mc      # BLE: scanning, connection, command queue\n├── BeatBuddyDelegate.mc  # Button input handling\n└── BeatBuddyView.mc      # UI rendering\n",[527,2482,2480],{"__ignoreMap":529},[456,2484],{},[459,2486,2488],{"id":2487},"lesson-1-monkey-c-is-not-what-you-expect","Lesson 1: Monkey C Is Not What You Expect",[449,2490,2491],{},"Coming from Rust and Python, Monkey C feels like Java from 2005 — but running on a device with 64 KB of memory. Here's what caught me off guard:",[1210,2493,2495],{"id":2494},"no-string-methods","No String Methods",[449,2497,2498],{},"Need to find a comma in a byte array? Write your own loop:",[520,2500,2504],{"className":2501,"code":2502,"language":2503,"meta":529,"style":529},"language-monkey shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// Find comma (ASCII 44) in a byte array\nvar commaIdx = -1;\nfor (var i = 0; i \u003C value.size(); i++) {\n    if (value[i] == 44) {\n        commaIdx = i;\n        break;\n    }\n}\n","monkey",[527,2505,2506,2511,2516,2521,2526,2531,2536,2540],{"__ignoreMap":529},[541,2507,2508],{"class":543,"line":544},[541,2509,2510],{},"// Find comma (ASCII 44) in a byte array\n",[541,2512,2513],{"class":543,"line":597},[541,2514,2515],{},"var commaIdx = -1;\n",[541,2517,2518],{"class":543,"line":603},[541,2519,2520],{},"for (var i = 0; i \u003C value.size(); i++) {\n",[541,2522,2523],{"class":543,"line":609},[541,2524,2525],{},"    if (value[i] == 44) {\n",[541,2527,2528],{"class":543,"line":615},[541,2529,2530],{},"        commaIdx = i;\n",[541,2532,2533],{"class":543,"line":621},[541,2534,2535],{},"        break;\n",[541,2537,2538],{"class":543,"line":627},[541,2539,845],{},[541,2541,2542],{"class":543,"line":633},[541,2543,648],{},[449,2545,2546],{},"Need to parse an integer from ASCII digits? Manual arithmetic:",[520,2548,2550],{"className":2501,"code":2549,"language":2503,"meta":529,"style":529},"var tempo = 0;\nfor (var i = commaIdx + 1; i \u003C value.size(); i++) {\n    var digit = value[i] - 48;  // '0' = ASCII 48\n    if (digit >= 0 && digit \u003C= 9) {\n        tempo = tempo * 10 + digit;\n    } else {\n        break;\n    }\n}\n",[527,2551,2552,2557,2562,2567,2572,2577,2582,2586,2590],{"__ignoreMap":529},[541,2553,2554],{"class":543,"line":544},[541,2555,2556],{},"var tempo = 0;\n",[541,2558,2559],{"class":543,"line":597},[541,2560,2561],{},"for (var i = commaIdx + 1; i \u003C value.size(); i++) {\n",[541,2563,2564],{"class":543,"line":603},[541,2565,2566],{},"    var digit = value[i] - 48;  // '0' = ASCII 48\n",[541,2568,2569],{"class":543,"line":609},[541,2570,2571],{},"    if (digit >= 0 && digit \u003C= 9) {\n",[541,2573,2574],{"class":543,"line":615},[541,2575,2576],{},"        tempo = tempo * 10 + digit;\n",[541,2578,2579],{"class":543,"line":621},[541,2580,2581],{},"    } else {\n",[541,2583,2584],{"class":543,"line":627},[541,2585,2535],{},[541,2587,2588],{"class":543,"line":633},[541,2589,845],{},[541,2591,2592],{"class":543,"line":639},[541,2593,648],{},[449,2595,2596,2597,2600,2601,2604],{},"No ",[527,2598,2599],{},"parseInt()",". No ",[527,2602,2603],{},"String.find()",". No regex. You work at the byte level — which is fine once you accept it, but surprising when you first open the API docs.",[1210,2606,2608],{"id":2607},"nullable-types-require-explicit-casts","Nullable Types Require Explicit Casts",[449,2610,2611],{},"Monkey C has null safety, but the syntax is verbose:",[520,2613,2615],{"className":2501,"code":2614,"language":2503,"meta":529,"style":529},"private var _bleTimer as Timer.Timer?;  // nullable\n\nfunction onStop(state as Dictionary?) as Void {\n    if (_bleTimer != null) {\n        (_bleTimer as Timer.Timer).stop();  // must cast after null check\n    }\n}\n",[527,2616,2617,2622,2626,2631,2636,2641,2645],{"__ignoreMap":529},[541,2618,2619],{"class":543,"line":544},[541,2620,2621],{},"private var _bleTimer as Timer.Timer?;  // nullable\n",[541,2623,2624],{"class":543,"line":597},[541,2625,600],{"emptyLinePlaceholder":393},[541,2627,2628],{"class":543,"line":603},[541,2629,2630],{},"function onStop(state as Dictionary?) as Void {\n",[541,2632,2633],{"class":543,"line":609},[541,2634,2635],{},"    if (_bleTimer != null) {\n",[541,2637,2638],{"class":543,"line":615},[541,2639,2640],{},"        (_bleTimer as Timer.Timer).stop();  // must cast after null check\n",[541,2642,2643],{"class":543,"line":621},[541,2644,845],{},[541,2646,2647],{"class":543,"line":627},[541,2648,648],{},[449,2650,2651,2652,2655],{},"After a null check, you still need to cast with ",[527,2653,2654],{},"as Timer.Timer",". The compiler doesn't narrow the type automatically.",[1210,2657,2659],{"id":2658},"array-operations-are-limited","Array Operations Are Limited",[449,2661,2662],{},"No destructuring, no spread operator. Removing the first element from an array:",[520,2664,2666],{"className":2501,"code":2665,"language":2503,"meta":529,"style":529},"_cmdQueue = _cmdQueue.slice(1, null);  // null = to the end\n",[527,2667,2668],{"__ignoreMap":529},[541,2669,2670],{"class":543,"line":544},[541,2671,2665],{},[449,2673,2596,2674,2677,2678,2681,2682,2685],{},[527,2675,2676],{},"[1:]"," like Python. No ",[527,2679,2680],{},".shift()"," like JavaScript. ",[527,2683,2684],{},"slice(start, null)"," is the idiom.",[456,2687],{},[459,2689,2691],{"id":2690},"lesson-2-ble-on-garmin-is-event-driven-not-async","Lesson 2: BLE on Garmin Is Event-Driven, Not Async",[449,2693,2694,2695,2698,2699,2702,2703,2218],{},"If you've used BLE libraries like ",[527,2696,2697],{},"bleak"," (Python) or ",[527,2700,2701],{},"flutter_blue_plus"," (Flutter), you're used to async/await patterns. Garmin's BLE is entirely callback-based — and there's a critical constraint: ",[473,2704,2705],{},"only one GATT operation can be in-flight at a time",[1210,2707,462],{"id":461},[449,2709,2710],{},"If you send two BLE writes simultaneously, the second one silently fails or crashes the app. There's no error, no queue — it just doesn't work.",[1210,2712,2714],{"id":2713},"the-solution-command-queue-with-busy-flag","The Solution: Command Queue with Busy Flag",[520,2716,2718],{"className":2501,"code":2717,"language":2503,"meta":529,"style":529},"private var _cmdQueue as Array\u003CByteArray> = [];\nprivate var _cmdBusy as Boolean = false;\n\nfunction _enqueueCommand(data as ByteArray) as Void {\n    _cmdQueue.add(data);\n    if (!_cmdBusy) {\n        _processQueue();\n    }\n}\n\nfunction _processQueue() as Void {\n    if (_cmdQueue.size() == 0 || _cmdBusy) { return; }\n    if (_device == null || _connState != STATE_CONNECTED) {\n        _cmdQueue = [];\n        return;\n    }\n\n    var service = (_device as Ble.Device)\n        .getService(Ble.stringToUuid(COMMAND_SERVICE_UUID));\n    var controlChar = service\n        .getCharacteristic(Ble.stringToUuid(COMMAND_CONTROL_UUID));\n\n    var cmd = _cmdQueue[0];\n    _cmdQueue = _cmdQueue.slice(1, null);\n    _cmdBusy = true;\n\n    controlChar.requestWrite(cmd, {:writeType => Ble.WRITE_TYPE_WITH_RESPONSE});\n}\n\nfunction onCharacteristicWrite(\n    characteristic as Ble.Characteristic,\n    status as Ble.Status\n) as Void {\n    _cmdBusy = false;\n    _processQueue();  // process next command\n}\n",[527,2719,2720,2725,2730,2734,2739,2744,2749,2754,2758,2762,2766,2771,2776,2781,2786,2791,2795,2799,2804,2809,2814,2819,2823,2828,2833,2838,2842,2847,2851,2855,2860,2865,2871,2877,2883,2889],{"__ignoreMap":529},[541,2721,2722],{"class":543,"line":544},[541,2723,2724],{},"private var _cmdQueue as Array\u003CByteArray> = [];\n",[541,2726,2727],{"class":543,"line":597},[541,2728,2729],{},"private var _cmdBusy as Boolean = false;\n",[541,2731,2732],{"class":543,"line":603},[541,2733,600],{"emptyLinePlaceholder":393},[541,2735,2736],{"class":543,"line":609},[541,2737,2738],{},"function _enqueueCommand(data as ByteArray) as Void {\n",[541,2740,2741],{"class":543,"line":615},[541,2742,2743],{},"    _cmdQueue.add(data);\n",[541,2745,2746],{"class":543,"line":621},[541,2747,2748],{},"    if (!_cmdBusy) {\n",[541,2750,2751],{"class":543,"line":627},[541,2752,2753],{},"        _processQueue();\n",[541,2755,2756],{"class":543,"line":633},[541,2757,845],{},[541,2759,2760],{"class":543,"line":639},[541,2761,648],{},[541,2763,2764],{"class":543,"line":645},[541,2765,600],{"emptyLinePlaceholder":393},[541,2767,2768],{"class":543,"line":651},[541,2769,2770],{},"function _processQueue() as Void {\n",[541,2772,2773],{"class":543,"line":656},[541,2774,2775],{},"    if (_cmdQueue.size() == 0 || _cmdBusy) { return; }\n",[541,2777,2778],{"class":543,"line":662},[541,2779,2780],{},"    if (_device == null || _connState != STATE_CONNECTED) {\n",[541,2782,2783],{"class":543,"line":910},[541,2784,2785],{},"        _cmdQueue = [];\n",[541,2787,2788],{"class":543,"line":929},[541,2789,2790],{},"        return;\n",[541,2792,2793],{"class":543,"line":934},[541,2794,845],{},[541,2796,2797],{"class":543,"line":939},[541,2798,600],{"emptyLinePlaceholder":393},[541,2800,2801],{"class":543,"line":953},[541,2802,2803],{},"    var service = (_device as Ble.Device)\n",[541,2805,2806],{"class":543,"line":958},[541,2807,2808],{},"        .getService(Ble.stringToUuid(COMMAND_SERVICE_UUID));\n",[541,2810,2811],{"class":543,"line":979},[541,2812,2813],{},"    var controlChar = service\n",[541,2815,2816],{"class":543,"line":1000},[541,2817,2818],{},"        .getCharacteristic(Ble.stringToUuid(COMMAND_CONTROL_UUID));\n",[541,2820,2821],{"class":543,"line":1019},[541,2822,600],{"emptyLinePlaceholder":393},[541,2824,2825],{"class":543,"line":1024},[541,2826,2827],{},"    var cmd = _cmdQueue[0];\n",[541,2829,2830],{"class":543,"line":1029},[541,2831,2832],{},"    _cmdQueue = _cmdQueue.slice(1, null);\n",[541,2834,2835],{"class":543,"line":1043},[541,2836,2837],{},"    _cmdBusy = true;\n",[541,2839,2840],{"class":543,"line":1048},[541,2841,600],{"emptyLinePlaceholder":393},[541,2843,2844],{"class":543,"line":1069},[541,2845,2846],{},"    controlChar.requestWrite(cmd, {:writeType => Ble.WRITE_TYPE_WITH_RESPONSE});\n",[541,2848,2849],{"class":543,"line":1090},[541,2850,648],{},[541,2852,2853],{"class":543,"line":1109},[541,2854,600],{"emptyLinePlaceholder":393},[541,2856,2857],{"class":543,"line":1114},[541,2858,2859],{},"function onCharacteristicWrite(\n",[541,2861,2862],{"class":543,"line":1120},[541,2863,2864],{},"    characteristic as Ble.Characteristic,\n",[541,2866,2868],{"class":543,"line":2867},32,[541,2869,2870],{},"    status as Ble.Status\n",[541,2872,2874],{"class":543,"line":2873},33,[541,2875,2876],{},") as Void {\n",[541,2878,2880],{"class":543,"line":2879},34,[541,2881,2882],{},"    _cmdBusy = false;\n",[541,2884,2886],{"class":543,"line":2885},35,[541,2887,2888],{},"    _processQueue();  // process next command\n",[541,2890,2892],{"class":543,"line":2891},36,[541,2893,648],{},[449,2895,2896],{},"The pattern is simple: enqueue → process if idle → on write callback, process next. It's essentially a mutex implemented with a boolean flag. This ensures only one GATT write is in-flight at any time.",[449,2898,2899],{},"The same queue handles CCCD (notification subscription) writes. When you first connect, enabling notifications is the first \"command\" in the queue:",[520,2901,2903],{"className":2501,"code":2902,"language":2503,"meta":529,"style":529},"private function _enableStateNotifications() as Void {\n    var cccd = controlChar.getDescriptor(Ble.cccdUuid());\n    _cmdBusy = true;\n    cccd.requestWrite([0x01, 0x00]b);  // enable notifications\n}\n",[527,2904,2905,2910,2915,2919,2924],{"__ignoreMap":529},[541,2906,2907],{"class":543,"line":544},[541,2908,2909],{},"private function _enableStateNotifications() as Void {\n",[541,2911,2912],{"class":543,"line":597},[541,2913,2914],{},"    var cccd = controlChar.getDescriptor(Ble.cccdUuid());\n",[541,2916,2917],{"class":543,"line":603},[541,2918,2837],{},[541,2920,2921],{"class":543,"line":609},[541,2922,2923],{},"    cccd.requestWrite([0x01, 0x00]b);  // enable notifications\n",[541,2925,2926],{"class":543,"line":615},[541,2927,648],{},[456,2929],{},[459,2931,2933],{"id":2932},"lesson-3-ascii-protocol-binary-protocol","Lesson 3: ASCII Protocol > Binary Protocol",[449,2935,2936],{},"The BLE protocol between the watch and metronome uses plain ASCII strings — not packed binary structs:",[1247,2938,2939,2952],{},[1250,2940,2941],{},[1253,2942,2943,2946,2949],{},[1256,2944,2945],{},"Command",[1256,2947,2948],{},"Bytes",[1256,2950,2951],{},"Action",[1266,2953,2954,2969,2984,2999],{},[1253,2955,2956,2961,2966],{},[1271,2957,2958],{},[527,2959,2960],{},"\"1\"",[1271,2962,2963],{},[527,2964,2965],{},"[0x31]",[1271,2967,2968],{},"Start metronome",[1253,2970,2971,2976,2981],{},[1271,2972,2973],{},[527,2974,2975],{},"\"0\"",[1271,2977,2978],{},[527,2979,2980],{},"[0x30]",[1271,2982,2983],{},"Stop metronome",[1253,2985,2986,2991,2996],{},[1271,2987,2988],{},[527,2989,2990],{},"\"T130\"",[1271,2992,2993],{},[527,2994,2995],{},"[0x54, 0x31, 0x33, 0x30]",[1271,2997,2998],{},"Set tempo to 1.30s",[1253,3000,3001,3006,3011],{},[1271,3002,3003],{},[527,3004,3005],{},"\"P2\"",[1271,3007,3008],{},[527,3009,3010],{},"[0x50, 0x32]",[1271,3012,3013],{},"Switch to program P3",[449,3015,3016,3017,3020],{},"State notifications come back as ",[527,3018,3019],{},"\"1,130\""," — running, tempo 130 centiseconds.",[449,3022,3023],{},"Why ASCII instead of a binary struct? Three reasons:",[1218,3025,3026,3032,3038],{},[470,3027,3028,3031],{},[473,3029,3030],{},"Debuggability"," — you can read the commands in BLE logs without a decoder",[470,3033,3034,3037],{},[473,3035,3036],{},"Firmware simplicity"," — the nRF52832 firmware parses with basic string functions",[470,3039,3040,3043,3044,3047],{},[473,3041,3042],{},"Monkey C compatibility"," — no ",[527,3045,3046],{},"struct.pack()"," in Monkey C anyway",[449,3049,3050],{},"The conversion from string to byte array is manual:",[520,3052,3054],{"className":2501,"code":3053,"language":2503,"meta":529,"style":529},"function _stringToBytes(str as String) as ByteArray {\n    var chars = str.toUtf8Array();\n    var bytes = new [chars.size()]b;\n    for (var i = 0; i \u003C chars.size(); i++) {\n        bytes[i] = chars[i];\n    }\n    return bytes;\n}\n\nfunction sendTempo(centis as Number) as Void {\n    if (centis \u003C 10) { centis = 10; }\n    if (centis > 200) { centis = 200; }\n    _enqueueCommand(_stringToBytes(\"T\" + centis.toString()));\n}\n",[527,3055,3056,3061,3066,3071,3076,3081,3085,3090,3094,3098,3103,3108,3113,3118],{"__ignoreMap":529},[541,3057,3058],{"class":543,"line":544},[541,3059,3060],{},"function _stringToBytes(str as String) as ByteArray {\n",[541,3062,3063],{"class":543,"line":597},[541,3064,3065],{},"    var chars = str.toUtf8Array();\n",[541,3067,3068],{"class":543,"line":603},[541,3069,3070],{},"    var bytes = new [chars.size()]b;\n",[541,3072,3073],{"class":543,"line":609},[541,3074,3075],{},"    for (var i = 0; i \u003C chars.size(); i++) {\n",[541,3077,3078],{"class":543,"line":615},[541,3079,3080],{},"        bytes[i] = chars[i];\n",[541,3082,3083],{"class":543,"line":621},[541,3084,845],{},[541,3086,3087],{"class":543,"line":627},[541,3088,3089],{},"    return bytes;\n",[541,3091,3092],{"class":543,"line":633},[541,3093,648],{},[541,3095,3096],{"class":543,"line":639},[541,3097,600],{"emptyLinePlaceholder":393},[541,3099,3100],{"class":543,"line":645},[541,3101,3102],{},"function sendTempo(centis as Number) as Void {\n",[541,3104,3105],{"class":543,"line":651},[541,3106,3107],{},"    if (centis \u003C 10) { centis = 10; }\n",[541,3109,3110],{"class":543,"line":656},[541,3111,3112],{},"    if (centis > 200) { centis = 200; }\n",[541,3114,3115],{"class":543,"line":662},[541,3116,3117],{},"    _enqueueCommand(_stringToBytes(\"T\" + centis.toString()));\n",[541,3119,3120],{"class":543,"line":910},[541,3121,648],{},[456,3123],{},[459,3125,3127],{"id":3126},"lesson-4-the-1-second-delay-hack","Lesson 4: The 1-Second Delay Hack",[449,3129,3130,3131,3134],{},"The Garmin simulator crashes if you call ",[527,3132,3133],{},"Ble.setDelegate()"," before the view renders. I discovered this after hours of debugging — there's no error message, just a silent crash.",[449,3136,3137],{},"The fix is embarrassingly simple: delay BLE initialization by 1 second.",[520,3139,3141],{"className":2501,"code":3140,"language":2503,"meta":529,"style":529},"function onStart(state as Dictionary?) as Void {\n    _bleTimer = new Timer.Timer();\n    (_bleTimer as Timer.Timer).start(method(:onBleInit), 1000, false);\n}\n\nfunction onBleInit() as Void {\n    _bleTimer = null;\n    try {\n        Ble.setDelegate(_comm);\n        _comm.start();\n    } catch (e) {\n        System.println(\"BLE init error: \" + e.getErrorMessage());\n    }\n}\n",[527,3142,3143,3148,3153,3158,3162,3166,3171,3176,3181,3186,3191,3196,3201,3205],{"__ignoreMap":529},[541,3144,3145],{"class":543,"line":544},[541,3146,3147],{},"function onStart(state as Dictionary?) as Void {\n",[541,3149,3150],{"class":543,"line":597},[541,3151,3152],{},"    _bleTimer = new Timer.Timer();\n",[541,3154,3155],{"class":543,"line":603},[541,3156,3157],{},"    (_bleTimer as Timer.Timer).start(method(:onBleInit), 1000, false);\n",[541,3159,3160],{"class":543,"line":609},[541,3161,648],{},[541,3163,3164],{"class":543,"line":615},[541,3165,600],{"emptyLinePlaceholder":393},[541,3167,3168],{"class":543,"line":621},[541,3169,3170],{},"function onBleInit() as Void {\n",[541,3172,3173],{"class":543,"line":627},[541,3174,3175],{},"    _bleTimer = null;\n",[541,3177,3178],{"class":543,"line":633},[541,3179,3180],{},"    try {\n",[541,3182,3183],{"class":543,"line":639},[541,3184,3185],{},"        Ble.setDelegate(_comm);\n",[541,3187,3188],{"class":543,"line":645},[541,3189,3190],{},"        _comm.start();\n",[541,3192,3193],{"class":543,"line":651},[541,3194,3195],{},"    } catch (e) {\n",[541,3197,3198],{"class":543,"line":656},[541,3199,3200],{},"        System.println(\"BLE init error: \" + e.getErrorMessage());\n",[541,3202,3203],{"class":543,"line":662},[541,3204,845],{},[541,3206,3207],{"class":543,"line":910},[541,3208,648],{},[449,3210,3211],{},"This lets the watch display \"Searching...\" while BLE initializes in the background. On real hardware, it's barely noticeable. In the simulator, it's the difference between working and crashing.",[456,3213],{},[459,3215,3217],{"id":3216},"lesson-5-connection-state-machine","Lesson 5: Connection State Machine",[449,3219,3220],{},"BLE connections on a watch are unreliable — especially for a swim device that goes underwater. The app implements a simple state machine with automatic recovery:",[520,3222,3225],{"className":3223,"code":3224,"language":525},[523],"IDLE → SCANNING → CONNECTING → CONNECTED\n                                    ↓\n                               (disconnect)\n                                    ↓\n                               SCANNING (auto)\n",[527,3226,3224],{"__ignoreMap":529},[520,3228,3230],{"className":2501,"code":3229,"language":2503,"meta":529,"style":529},"static const STATE_IDLE = 0;\nstatic const STATE_SCANNING = 1;\nstatic const STATE_CONNECTING = 2;\nstatic const STATE_CONNECTED = 3;\n\nfunction onConnectedStateChanged(\n    device as Ble.Device,\n    state as Ble.ConnectionState\n) as Void {\n    if (state == Ble.CONNECTION_STATE_CONNECTED) {\n        _device = device;\n        _connState = STATE_CONNECTED;\n        _enableStateNotifications();\n    } else {\n        // Disconnected → clean up and restart scanning\n        _device = null;\n        _connState = STATE_SCANNING;\n        _cmdQueue = [];\n        _cmdBusy = false;\n        _running = false;\n        Ble.setScanState(Ble.SCAN_STATE_SCANNING);\n    }\n    WatchUi.requestUpdate();\n}\n",[527,3231,3232,3237,3242,3247,3252,3256,3261,3266,3271,3275,3280,3285,3290,3295,3299,3304,3309,3314,3318,3323,3328,3333,3337,3342],{"__ignoreMap":529},[541,3233,3234],{"class":543,"line":544},[541,3235,3236],{},"static const STATE_IDLE = 0;\n",[541,3238,3239],{"class":543,"line":597},[541,3240,3241],{},"static const STATE_SCANNING = 1;\n",[541,3243,3244],{"class":543,"line":603},[541,3245,3246],{},"static const STATE_CONNECTING = 2;\n",[541,3248,3249],{"class":543,"line":609},[541,3250,3251],{},"static const STATE_CONNECTED = 3;\n",[541,3253,3254],{"class":543,"line":615},[541,3255,600],{"emptyLinePlaceholder":393},[541,3257,3258],{"class":543,"line":621},[541,3259,3260],{},"function onConnectedStateChanged(\n",[541,3262,3263],{"class":543,"line":627},[541,3264,3265],{},"    device as Ble.Device,\n",[541,3267,3268],{"class":543,"line":633},[541,3269,3270],{},"    state as Ble.ConnectionState\n",[541,3272,3273],{"class":543,"line":639},[541,3274,2876],{},[541,3276,3277],{"class":543,"line":645},[541,3278,3279],{},"    if (state == Ble.CONNECTION_STATE_CONNECTED) {\n",[541,3281,3282],{"class":543,"line":651},[541,3283,3284],{},"        _device = device;\n",[541,3286,3287],{"class":543,"line":656},[541,3288,3289],{},"        _connState = STATE_CONNECTED;\n",[541,3291,3292],{"class":543,"line":662},[541,3293,3294],{},"        _enableStateNotifications();\n",[541,3296,3297],{"class":543,"line":910},[541,3298,2581],{},[541,3300,3301],{"class":543,"line":929},[541,3302,3303],{},"        // Disconnected → clean up and restart scanning\n",[541,3305,3306],{"class":543,"line":934},[541,3307,3308],{},"        _device = null;\n",[541,3310,3311],{"class":543,"line":939},[541,3312,3313],{},"        _connState = STATE_SCANNING;\n",[541,3315,3316],{"class":543,"line":953},[541,3317,2785],{},[541,3319,3320],{"class":543,"line":958},[541,3321,3322],{},"        _cmdBusy = false;\n",[541,3324,3325],{"class":543,"line":979},[541,3326,3327],{},"        _running = false;\n",[541,3329,3330],{"class":543,"line":1000},[541,3331,3332],{},"        Ble.setScanState(Ble.SCAN_STATE_SCANNING);\n",[541,3334,3335],{"class":543,"line":1019},[541,3336,845],{},[541,3338,3339],{"class":543,"line":1024},[541,3340,3341],{},"    WatchUi.requestUpdate();\n",[541,3343,3344],{"class":543,"line":1029},[541,3345,648],{},[449,3347,3348],{},"Key details:",[467,3350,3351,3357,3367],{},[470,3352,3353,3356],{},[473,3354,3355],{},"Queue flush on disconnect"," — stale commands would fail anyway",[470,3358,3359,3362,3363,3366],{},[473,3360,3361],{},"State reset"," — ",[527,3364,3365],{},"_running = false"," prevents the UI from showing \"PLAYING\" when disconnected",[470,3368,3369,3372],{},[473,3370,3371],{},"Immediate re-scan"," — no user action required to reconnect",[449,3374,3375],{},"This is critical for a swim use case. The watch might lose BLE contact when the user's arm enters the water, then reconnect on the next stroke. The state machine handles this transparently.",[456,3377],{},[459,3379,3381],{"id":3380},"lesson-6-drawing-the-ui-by-hand","Lesson 6: Drawing the UI by Hand",[449,3383,3384,3385,3388],{},"There's no UIKit, no Flutter widgets, no HTML. You get a ",[527,3386,3387],{},"Dc"," (drawing context) and pixel coordinates:",[520,3390,3392],{"className":2501,"code":3391,"language":2503,"meta":529,"style":529},"function onUpdate(dc as Dc) as Void {\n    dc.setColor(0x000000, 0x000000);  // black background\n    dc.clear();\n\n    // Title — yellow\n    dc.setColor(0xFFFF00, Graphics.COLOR_TRANSPARENT);\n    dc.drawText(_centerX, 20, Graphics.FONT_SMALL, \"BeatBuddy\",\n        Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);\n\n    // Tempo — large number\n    var tempoStr = secs.toString() + \".\" + frac.format(\"%02d\");\n    dc.drawText(_centerX, _centerY + 5, Graphics.FONT_NUMBER_HOT, tempoStr,\n        Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);\n\n    // Connection status — colored dot + text\n    dc.setColor(statusColor, Graphics.COLOR_TRANSPARENT);\n    dc.fillCircle(_centerX - 45, 60, 4);\n    dc.drawText(_centerX - 37, 60, Graphics.FONT_XTINY, statusText,\n        Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);\n}\n",[527,3393,3394,3399,3404,3409,3413,3418,3423,3428,3433,3437,3442,3447,3452,3456,3460,3465,3470,3475,3480,3485],{"__ignoreMap":529},[541,3395,3396],{"class":543,"line":544},[541,3397,3398],{},"function onUpdate(dc as Dc) as Void {\n",[541,3400,3401],{"class":543,"line":597},[541,3402,3403],{},"    dc.setColor(0x000000, 0x000000);  // black background\n",[541,3405,3406],{"class":543,"line":603},[541,3407,3408],{},"    dc.clear();\n",[541,3410,3411],{"class":543,"line":609},[541,3412,600],{"emptyLinePlaceholder":393},[541,3414,3415],{"class":543,"line":615},[541,3416,3417],{},"    // Title — yellow\n",[541,3419,3420],{"class":543,"line":621},[541,3421,3422],{},"    dc.setColor(0xFFFF00, Graphics.COLOR_TRANSPARENT);\n",[541,3424,3425],{"class":543,"line":627},[541,3426,3427],{},"    dc.drawText(_centerX, 20, Graphics.FONT_SMALL, \"BeatBuddy\",\n",[541,3429,3430],{"class":543,"line":633},[541,3431,3432],{},"        Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);\n",[541,3434,3435],{"class":543,"line":639},[541,3436,600],{"emptyLinePlaceholder":393},[541,3438,3439],{"class":543,"line":645},[541,3440,3441],{},"    // Tempo — large number\n",[541,3443,3444],{"class":543,"line":651},[541,3445,3446],{},"    var tempoStr = secs.toString() + \".\" + frac.format(\"%02d\");\n",[541,3448,3449],{"class":543,"line":656},[541,3450,3451],{},"    dc.drawText(_centerX, _centerY + 5, Graphics.FONT_NUMBER_HOT, tempoStr,\n",[541,3453,3454],{"class":543,"line":662},[541,3455,3432],{},[541,3457,3458],{"class":543,"line":910},[541,3459,600],{"emptyLinePlaceholder":393},[541,3461,3462],{"class":543,"line":929},[541,3463,3464],{},"    // Connection status — colored dot + text\n",[541,3466,3467],{"class":543,"line":934},[541,3468,3469],{},"    dc.setColor(statusColor, Graphics.COLOR_TRANSPARENT);\n",[541,3471,3472],{"class":543,"line":939},[541,3473,3474],{},"    dc.fillCircle(_centerX - 45, 60, 4);\n",[541,3476,3477],{"class":543,"line":953},[541,3478,3479],{},"    dc.drawText(_centerX - 37, 60, Graphics.FONT_XTINY, statusText,\n",[541,3481,3482],{"class":543,"line":958},[541,3483,3484],{},"        Graphics.TEXT_JUSTIFY_LEFT | Graphics.TEXT_JUSTIFY_VCENTER);\n",[541,3486,3487],{"class":543,"line":979},[541,3488,648],{},[449,3490,3491,3492,3495,3496,3499,3500,3503],{},"Everything is ",[527,3493,3494],{},"drawText()",", ",[527,3497,3498],{},"fillCircle()",", and ",[527,3501,3502],{},"setColor()",". The layout is hardcoded to pixel positions. It works — but it takes trial and error to get things aligned on a round watch face.",[449,3505,3506],{},"The UI refreshes on a 2-second timer plus immediate updates on button presses and BLE notifications:",[520,3508,3510],{"className":2501,"code":3509,"language":2503,"meta":529,"style":529},"function onShow() as Void {\n    _refreshTimer = new Timer.Timer();\n    (_refreshTimer as Timer.Timer).start(method(:onRefresh), 2000, true);\n}\n",[527,3511,3512,3517,3522,3527],{"__ignoreMap":529},[541,3513,3514],{"class":543,"line":544},[541,3515,3516],{},"function onShow() as Void {\n",[541,3518,3519],{"class":543,"line":597},[541,3520,3521],{},"    _refreshTimer = new Timer.Timer();\n",[541,3523,3524],{"class":543,"line":603},[541,3525,3526],{},"    (_refreshTimer as Timer.Timer).start(method(:onRefresh), 2000, true);\n",[541,3528,3529],{"class":543,"line":609},[541,3530,648],{},[456,3532],{},[459,3534,3536],{"id":3535},"lesson-7-supporting-27-devices-with-one-codebase","Lesson 7: Supporting 27 Devices with One Codebase",[449,3538,3539,3540,3543],{},"One of the best things about Connect IQ: device compatibility is declared in ",[527,3541,3542],{},"manifest.xml",", not in code:",[520,3545,3549],{"className":3546,"code":3547,"language":3548,"meta":529,"style":529},"language-xml shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","\u003Ciq:products>\n    \u003Ciq:product id=\"fenix6\" />\n    \u003Ciq:product id=\"fenix7\" />\n    \u003Ciq:product id=\"epix2pro47mm\" />\n    \u003Ciq:product id=\"fr265\" />\n    \u003Ciq:product id=\"venu3\" />\n    \u003C!-- ... 22 more devices ... -->\n\u003C/iq:products>\n","xml",[527,3550,3551,3556,3561,3566,3571,3576,3581,3586],{"__ignoreMap":529},[541,3552,3553],{"class":543,"line":544},[541,3554,3555],{},"\u003Ciq:products>\n",[541,3557,3558],{"class":543,"line":597},[541,3559,3560],{},"    \u003Ciq:product id=\"fenix6\" />\n",[541,3562,3563],{"class":543,"line":603},[541,3564,3565],{},"    \u003Ciq:product id=\"fenix7\" />\n",[541,3567,3568],{"class":543,"line":609},[541,3569,3570],{},"    \u003Ciq:product id=\"epix2pro47mm\" />\n",[541,3572,3573],{"class":543,"line":615},[541,3574,3575],{},"    \u003Ciq:product id=\"fr265\" />\n",[541,3577,3578],{"class":543,"line":621},[541,3579,3580],{},"    \u003Ciq:product id=\"venu3\" />\n",[541,3582,3583],{"class":543,"line":627},[541,3584,3585],{},"    \u003C!-- ... 22 more devices ... -->\n",[541,3587,3588],{"class":543,"line":633},[541,3589,3590],{},"\u003C/iq:products>\n",[449,3592,3593,3594,3597,3598,3601,3602,3605,3606,3609],{},"The SDK compiles a ",[527,3595,3596],{},".iq"," package that runs on all listed devices. No conditional code, no device-specific branches. The ",[527,3599,3600],{},"Graphics.FONT_NUMBER_HOT"," font scales to each screen size automatically. The only thing you need to watch for is screen dimensions — I use ",[527,3603,3604],{},"dc.getWidth()"," and ",[527,3607,3608],{},"dc.getHeight()"," instead of hardcoded values.",[456,3611],{},[459,3613,3615],{"id":3614},"lesson-8-build-a-simulator-seriously","Lesson 8: Build a Simulator (Seriously)",[449,3617,3618,3619,3624],{},"The Connect IQ simulator is slow and limited. I built a Python BLE simulator using ",[499,3620,3623],{"href":3621,"rel":3622},"https://bleak.readthedocs.io/",[503],"Bleak"," that acts like the Garmin watch — connecting to a real Vimo Swim device and sending the same commands:",[520,3626,3628],{"className":585,"code":3627,"language":587,"meta":529,"style":529},"COMMAND_SERVICE_UUID = \"87660000-9140-0ace-dead-beefba5eba11\"\nCOMMAND_CONTROL_UUID = \"87660001-9140-0ace-dead-beefba5eba11\"\n\nasync def send_command(client, cmd: str):\n    await client.write_gatt_char(\n        COMMAND_CONTROL_UUID,\n        cmd.encode(\"ascii\"),\n        response=True\n    )\n",[527,3629,3630,3635,3640,3644,3649,3654,3659,3664,3669],{"__ignoreMap":529},[541,3631,3632],{"class":543,"line":544},[541,3633,3634],{},"COMMAND_SERVICE_UUID = \"87660000-9140-0ace-dead-beefba5eba11\"\n",[541,3636,3637],{"class":543,"line":597},[541,3638,3639],{},"COMMAND_CONTROL_UUID = \"87660001-9140-0ace-dead-beefba5eba11\"\n",[541,3641,3642],{"class":543,"line":603},[541,3643,600],{"emptyLinePlaceholder":393},[541,3645,3646],{"class":543,"line":609},[541,3647,3648],{},"async def send_command(client, cmd: str):\n",[541,3650,3651],{"class":543,"line":615},[541,3652,3653],{},"    await client.write_gatt_char(\n",[541,3655,3656],{"class":543,"line":621},[541,3657,3658],{},"        COMMAND_CONTROL_UUID,\n",[541,3660,3661],{"class":543,"line":627},[541,3662,3663],{},"        cmd.encode(\"ascii\"),\n",[541,3665,3666],{"class":543,"line":633},[541,3667,3668],{},"        response=True\n",[541,3670,3671],{"class":543,"line":639},[541,3672,3673],{},"    )\n",[449,3675,3676],{},"Interactive terminal commands mirror the watch buttons:",[520,3678,3681],{"className":3679,"code":3680,"language":525},[523],"> s          # SELECT → toggle start/stop\n> +          # UP → slower (+5 centis)\n> -          # DOWN → faster (-5 centis)\n> t 130      # set tempo to 1.30s\n> p 2        # switch to program P3\n",[527,3682,3680],{"__ignoreMap":529},[449,3684,3685],{},"This lets me test the BLE protocol without flashing a real watch. The feedback loop went from \"change code → build → deploy to watch → test\" (5 minutes) to \"change code → run simulator → test\" (10 seconds).",[456,3687],{},[459,3689,3691],{"id":3690},"the-numbers","The Numbers",[1247,3693,3694,3704],{},[1250,3695,3696],{},[1253,3697,3698,3701],{},[1256,3699,3700],{},"Metric",[1256,3702,3703],{},"Value",[1266,3705,3706,3714,3722,3730,3738,3746,3754],{},[1253,3707,3708,3711],{},[1271,3709,3710],{},"Source files",[1271,3712,3713],{},"4",[1253,3715,3716,3719],{},[1271,3717,3718],{},"Lines of code",[1271,3720,3721],{},"~650",[1253,3723,3724,3727],{},[1271,3725,3726],{},"Supported devices",[1271,3728,3729],{},"27 Garmin watches",[1253,3731,3732,3735],{},[1271,3733,3734],{},"BLE commands",[1271,3736,3737],{},"4 (start, stop, tempo, program)",[1253,3739,3740,3743],{},[1271,3741,3742],{},"GATT characteristics",[1271,3744,3745],{},"1 (bidirectional)",[1253,3747,3748,3751],{},[1271,3749,3750],{},"Binary size (.iq)",[1271,3752,3753],{},"~15 KB",[1253,3755,3756,3759],{},[1271,3757,3758],{},"Development time",[1271,3760,3761],{},"~3 days",[456,3763],{},[459,3765,3767],{"id":3766},"what-id-do-differently","What I'd Do Differently",[1218,3769,3770,3776,3790],{},[470,3771,3772,3775],{},[473,3773,3774],{},"Start with the Python simulator."," I built it after the watch app. Building it first would have saved a day of debugging BLE issues in the Connect IQ simulator.",[470,3777,3778,3785,3786,3789],{},[473,3779,3780,3781,3784],{},"Use ",[527,3782,3783],{},"WRITE_TYPE_DEFAULT"," first."," I started with ",[527,3787,3788],{},"WRITE_TYPE_WITH_RESPONSE",", which is more reliable but slower. For a metronome remote, the latency of acknowledged writes is fine. For higher-throughput apps, test both.",[470,3791,3792,3795],{},[473,3793,3794],{},"Add program selection to the UI."," The current version shows only tempo. Adding a program selector would require a menu or a second page — Connect IQ supports both, but the round screen makes menus tricky.",[456,3797],{},[459,3799,3801],{"id":3800},"key-takeaways","Key Takeaways",[467,3803,3804,3810,3816,3822,3828,3834],{},[470,3805,3806,3809],{},[473,3807,3808],{},"Monkey C is low-level."," No regex, no string parsing, limited array operations. Accept it and write byte-level code.",[470,3811,3812,3815],{},[473,3813,3814],{},"BLE needs a command queue."," One GATT operation at a time. No exceptions.",[470,3817,3818,3821],{},[473,3819,3820],{},"ASCII protocols win."," Human-readable commands save hours of debugging.",[470,3823,3824,3827],{},[473,3825,3826],{},"Auto-reconnect is essential."," Especially for sports devices where connections drop constantly.",[470,3829,3830,3833],{},[473,3831,3832],{},"The simulator lies."," Test on real hardware as early as possible. The 1-second delay hack only exists because of a simulator bug.",[470,3835,3836,3842],{},[473,3837,3838,3839,2218],{},"27 devices, 0 ",[527,3840,3841],{},"#ifdef"," Connect IQ's manifest-based device support is genuinely well-designed.",[449,3844,3845],{},"If you're considering building a Connect IQ app — especially one with BLE — budget 70% of your time for BLE edge cases and 30% for everything else. The language is quirky but workable. The BLE stack is powerful but unforgiving. And the feeling of controlling hardware from your wrist is absolutely worth it.",[456,3847],{},[449,3849,3850],{},[506,3851,3852,3853,3856],{},"Built with Connect IQ SDK 8.x, Monkey C, and a healthy dose of ",[527,3854,3855],{},"System.println()"," debugging.",[1588,3858,3859],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":529,"searchDepth":597,"depth":597,"links":3861},[3862,3863,3868,3872,3873,3874,3875,3876,3877,3878,3879,3880],{"id":2445,"depth":597,"text":2446},{"id":2487,"depth":597,"text":2488,"children":3864},[3865,3866,3867],{"id":2494,"depth":603,"text":2495},{"id":2607,"depth":603,"text":2608},{"id":2658,"depth":603,"text":2659},{"id":2690,"depth":597,"text":2691,"children":3869},[3870,3871],{"id":461,"depth":603,"text":462},{"id":2713,"depth":603,"text":2714},{"id":2932,"depth":597,"text":2933},{"id":3126,"depth":597,"text":3127},{"id":3216,"depth":597,"text":3217},{"id":3380,"depth":597,"text":3381},{"id":3535,"depth":597,"text":3536},{"id":3614,"depth":597,"text":3615},{"id":3690,"depth":597,"text":3691},{"id":3766,"depth":597,"text":3767},{"id":3800,"depth":597,"text":3801},"What I learned building a Garmin watch app that controls a swim metronome over BLE — from GATT command queues to manual ASCII parsing in a language with no regex.","/images/stock/1523475496153-3d6cc0f0bf19.webp",{},{"title":3885,"description":3881},"Building a Garmin Connect IQ App in Monkey C","I5VztzYsnqs7dgtpuZDdxZkMQkx1KtX7Z0qk5g0IxMk",{"id":3888,"title":42,"author":3889,"body":3891,"date":2423,"description":5051,"extension":1612,"image":5052,"meta":5053,"minRead":639,"navigation":393,"path":43,"seo":5054,"stem":44,"__hash__":5056},"blog/blog/smart-home-pico-w-ble-window-sensor-micropython.md",{"name":441,"description":442,"avatar":3890},{"src":444,"alt":441},{"type":446,"value":3892,"toc":5028},[3893,3896,3898,3902,3905,3911,3925,3936,3938,3942,4005,4008,4010,4014,4017,4160,4164,4174,4188,4205,4215,4219,4222,4329,4332,4334,4338,4341,4462,4469,4473,4476,4579,4582,4584,4588,4595,4639,4642,4646,4657,4726,4729,4731,4735,4742,4832,4835,4894,4896,4900,4904,4918,4922,4925,4945,4948,4952,4960,4964,4970,4972,4974,4977,5003,5006,5008,5012,5018,5020,5025],[449,3894,3895],{},"Commercial smart home sensors cost $30-50 each, require a cloud subscription, and stop working when the company shuts down its servers. I built a window monitoring system for under $15 total — two Raspberry Pi Pico Ws, two reed switches, and six LEDs. No cloud. No app. No subscription. Just BLE and MicroPython.",[456,3897],{},[459,3899,3901],{"id":3900},"the-system","The System",[449,3903,3904],{},"Two Pico Ws talk over BLE (Bluetooth Low Energy):",[520,3906,3909],{"className":3907,"code":3908,"language":525},[523],"┌─────────────────┐        BLE         ┌─────────────────┐\n│   WINDOW PICO   │  ───────────────►  │   HUB PICO      │\n│   (peripheral)  │                    │   (central)      │\n│                 │                    │                  │\n│  Reed switch ── │──► OPEN/CLOSED ──► │ ── Green LED     │\n│  Red LED        │                    │ ── Red LED       │\n│  Yellow LED     │                    │ ── Yellow LED    │\n└─────────────────┘                    └─────────────────┘\n",[527,3910,3908],{"__ignoreMap":529},[467,3912,3913,3919],{},[470,3914,3915,3918],{},[473,3916,3917],{},"Window Pico"," — mounted on the window frame, monitors a magnetic reed switch, advertises over BLE",[470,3920,3921,3924],{},[473,3922,3923],{},"Hub Pico"," — anywhere in the room, scans for the window sensor, shows status with colored LEDs",[449,3926,3927,3928,3931,3932,3935],{},"When the window opens (magnet moves away from reed switch), the window Pico sends ",[527,3929,3930],{},"\"OPEN\""," over BLE. The hub turns its green LED on. When it closes: ",[527,3933,3934],{},"\"CLOSED\"",", red LED on. That's it.",[456,3937],{},[459,3939,3941],{"id":3940},"bill-of-materials","Bill of Materials",[1247,3943,3944,3957],{},[1250,3945,3946],{},[1253,3947,3948,3951,3954],{},[1256,3949,3950],{},"Part",[1256,3952,3953],{},"Price",[1256,3955,3956],{},"Quantity",[1266,3958,3959,3970,3980,3991],{},[1253,3960,3961,3964,3967],{},[1271,3962,3963],{},"Raspberry Pi Pico W",[1271,3965,3966],{},"~$6",[1271,3968,3969],{},"2",[1253,3971,3972,3975,3978],{},[1271,3973,3974],{},"Reed switch (magnetic)",[1271,3976,3977],{},"~$0.50",[1271,3979,64],{},[1253,3981,3982,3985,3988],{},[1271,3983,3984],{},"LEDs + resistors",[1271,3986,3987],{},"~$1",[1271,3989,3990],{},"6",[1253,3992,3993,3998,4003],{},[1271,3994,3995],{},[473,3996,3997],{},"Total",[1271,3999,4000],{},[473,4001,4002],{},"~$14",[1271,4004],{},[449,4006,4007],{},"No soldering required for prototyping — just a breadboard and jumper wires.",[456,4009],{},[459,4011,4013],{"id":4012},"the-peripheral-window-sensor","The Peripheral: Window Sensor",[449,4015,4016],{},"The window Pico runs as a BLE peripheral. It monitors a reed switch on GPIO 14 and advertises an Environmental Sensing Service:",[520,4018,4020],{"className":585,"code":4019,"language":587,"meta":529,"style":529},"from machine import Pin\nimport aioble\nimport bluetooth\nimport asyncio\n\nreed_switch = Pin(14, Pin.IN, Pin.PULL_UP)\nled_red = Pin(15, Pin.OUT)\n\nBLE_SVC_UUID = bluetooth.UUID(0x181A)      # Environmental Sensing\nBLE_CHAR_UUID = bluetooth.UUID(0x2A6E)     # Custom characteristic\n\nasync def monitor_reed_switch(characteristic):\n    last_state = None\n\n    while True:\n        current_state = reed_switch.value()\n\n        if current_state != last_state:\n            if current_state == 1:       # open (no magnet)\n                led_red.value(1)\n                message = \"OPEN\"\n            else:                        # closed (magnet near)\n                led_red.value(0)\n                message = \"CLOSED\"\n\n            characteristic.write(message.encode(\"utf-8\"))\n            last_state = current_state\n\n        await asyncio.sleep(0.1)         # 100ms debounce\n",[527,4021,4022,4027,4032,4037,4042,4046,4051,4056,4060,4065,4070,4074,4079,4084,4088,4093,4098,4102,4107,4112,4117,4122,4127,4132,4137,4141,4146,4151,4155],{"__ignoreMap":529},[541,4023,4024],{"class":543,"line":544},[541,4025,4026],{},"from machine import Pin\n",[541,4028,4029],{"class":543,"line":597},[541,4030,4031],{},"import aioble\n",[541,4033,4034],{"class":543,"line":603},[541,4035,4036],{},"import bluetooth\n",[541,4038,4039],{"class":543,"line":609},[541,4040,4041],{},"import asyncio\n",[541,4043,4044],{"class":543,"line":615},[541,4045,600],{"emptyLinePlaceholder":393},[541,4047,4048],{"class":543,"line":621},[541,4049,4050],{},"reed_switch = Pin(14, Pin.IN, Pin.PULL_UP)\n",[541,4052,4053],{"class":543,"line":627},[541,4054,4055],{},"led_red = Pin(15, Pin.OUT)\n",[541,4057,4058],{"class":543,"line":633},[541,4059,600],{"emptyLinePlaceholder":393},[541,4061,4062],{"class":543,"line":639},[541,4063,4064],{},"BLE_SVC_UUID = bluetooth.UUID(0x181A)      # Environmental Sensing\n",[541,4066,4067],{"class":543,"line":645},[541,4068,4069],{},"BLE_CHAR_UUID = bluetooth.UUID(0x2A6E)     # Custom characteristic\n",[541,4071,4072],{"class":543,"line":651},[541,4073,600],{"emptyLinePlaceholder":393},[541,4075,4076],{"class":543,"line":656},[541,4077,4078],{},"async def monitor_reed_switch(characteristic):\n",[541,4080,4081],{"class":543,"line":662},[541,4082,4083],{},"    last_state = None\n",[541,4085,4086],{"class":543,"line":910},[541,4087,600],{"emptyLinePlaceholder":393},[541,4089,4090],{"class":543,"line":929},[541,4091,4092],{},"    while True:\n",[541,4094,4095],{"class":543,"line":934},[541,4096,4097],{},"        current_state = reed_switch.value()\n",[541,4099,4100],{"class":543,"line":939},[541,4101,600],{"emptyLinePlaceholder":393},[541,4103,4104],{"class":543,"line":953},[541,4105,4106],{},"        if current_state != last_state:\n",[541,4108,4109],{"class":543,"line":958},[541,4110,4111],{},"            if current_state == 1:       # open (no magnet)\n",[541,4113,4114],{"class":543,"line":979},[541,4115,4116],{},"                led_red.value(1)\n",[541,4118,4119],{"class":543,"line":1000},[541,4120,4121],{},"                message = \"OPEN\"\n",[541,4123,4124],{"class":543,"line":1019},[541,4125,4126],{},"            else:                        # closed (magnet near)\n",[541,4128,4129],{"class":543,"line":1024},[541,4130,4131],{},"                led_red.value(0)\n",[541,4133,4134],{"class":543,"line":1029},[541,4135,4136],{},"                message = \"CLOSED\"\n",[541,4138,4139],{"class":543,"line":1043},[541,4140,600],{"emptyLinePlaceholder":393},[541,4142,4143],{"class":543,"line":1048},[541,4144,4145],{},"            characteristic.write(message.encode(\"utf-8\"))\n",[541,4147,4148],{"class":543,"line":1069},[541,4149,4150],{},"            last_state = current_state\n",[541,4152,4153],{"class":543,"line":1090},[541,4154,600],{"emptyLinePlaceholder":393},[541,4156,4157],{"class":543,"line":1109},[541,4158,4159],{},"        await asyncio.sleep(0.1)         # 100ms debounce\n",[1210,4161,4163],{"id":4162},"key-design-decisions","Key Design Decisions",[449,4165,4166,4169,4170,4173],{},[473,4167,4168],{},"State-change detection, not polling."," The sensor only sends a BLE message when the state ",[506,4171,4172],{},"changes",", not on every loop iteration. This is important for two reasons:",[1218,4175,4176,4182],{},[470,4177,4178,4181],{},[473,4179,4180],{},"Power"," — fewer BLE writes means longer battery life",[470,4183,4184,4187],{},[473,4185,4186],{},"Bandwidth"," — BLE notifications are cheap but not free",[449,4189,4190,4193,4194,4197,4198,4201,4202,4204],{},[473,4191,4192],{},"Internal pull-up resistors."," Reed switches are simple: closed circuit when a magnet is near, open circuit otherwise. Using ",[527,4195,4196],{},"Pin.PULL_UP"," means no external resistor needed — GPIO reads ",[527,4199,4200],{},"0"," when closed, ",[527,4203,64],{}," when open.",[449,4206,4207,4210,4211,4214],{},[473,4208,4209],{},"100ms async debounce."," Reed switches can bounce for a few milliseconds when the magnet transitions. ",[527,4212,4213],{},"asyncio.sleep(0.1)"," is long enough to filter noise, short enough to feel instant.",[1210,4216,4218],{"id":4217},"ble-advertising","BLE Advertising",[449,4220,4221],{},"The peripheral advertises continuously, waiting for a central to connect:",[520,4223,4225],{"className":585,"code":4224,"language":587,"meta":529,"style":529},"async def run_peripheral_mode():\n    ble_service = aioble.Service(BLE_SVC_UUID)\n    characteristic = aioble.Characteristic(\n        ble_service,\n        BLE_CHAR_UUID,\n        read=True,\n        notify=True,\n    )\n    aioble.register_services(ble_service)\n\n    while True:\n        async with await aioble.advertise(\n            interval_us=500000,       # advertise every 500ms\n            name=\"Peripheral\",\n            services=[BLE_SVC_UUID]\n        ) as connection:\n            print(\"Connected:\", connection.device)\n            await asyncio.create_task(\n                monitor_reed_switch(characteristic)\n            )\n            print(\"Disconnected. Re-advertising...\")\n",[527,4226,4227,4232,4237,4242,4247,4252,4257,4262,4266,4271,4275,4279,4284,4289,4294,4299,4304,4309,4314,4319,4324],{"__ignoreMap":529},[541,4228,4229],{"class":543,"line":544},[541,4230,4231],{},"async def run_peripheral_mode():\n",[541,4233,4234],{"class":543,"line":597},[541,4235,4236],{},"    ble_service = aioble.Service(BLE_SVC_UUID)\n",[541,4238,4239],{"class":543,"line":603},[541,4240,4241],{},"    characteristic = aioble.Characteristic(\n",[541,4243,4244],{"class":543,"line":609},[541,4245,4246],{},"        ble_service,\n",[541,4248,4249],{"class":543,"line":615},[541,4250,4251],{},"        BLE_CHAR_UUID,\n",[541,4253,4254],{"class":543,"line":621},[541,4255,4256],{},"        read=True,\n",[541,4258,4259],{"class":543,"line":627},[541,4260,4261],{},"        notify=True,\n",[541,4263,4264],{"class":543,"line":633},[541,4265,3673],{},[541,4267,4268],{"class":543,"line":639},[541,4269,4270],{},"    aioble.register_services(ble_service)\n",[541,4272,4273],{"class":543,"line":645},[541,4274,600],{"emptyLinePlaceholder":393},[541,4276,4277],{"class":543,"line":651},[541,4278,4092],{},[541,4280,4281],{"class":543,"line":656},[541,4282,4283],{},"        async with await aioble.advertise(\n",[541,4285,4286],{"class":543,"line":662},[541,4287,4288],{},"            interval_us=500000,       # advertise every 500ms\n",[541,4290,4291],{"class":543,"line":910},[541,4292,4293],{},"            name=\"Peripheral\",\n",[541,4295,4296],{"class":543,"line":929},[541,4297,4298],{},"            services=[BLE_SVC_UUID]\n",[541,4300,4301],{"class":543,"line":934},[541,4302,4303],{},"        ) as connection:\n",[541,4305,4306],{"class":543,"line":939},[541,4307,4308],{},"            print(\"Connected:\", connection.device)\n",[541,4310,4311],{"class":543,"line":953},[541,4312,4313],{},"            await asyncio.create_task(\n",[541,4315,4316],{"class":543,"line":958},[541,4317,4318],{},"                monitor_reed_switch(characteristic)\n",[541,4320,4321],{"class":543,"line":979},[541,4322,4323],{},"            )\n",[541,4325,4326],{"class":543,"line":1000},[541,4327,4328],{},"            print(\"Disconnected. Re-advertising...\")\n",[449,4330,4331],{},"When the connection drops, it immediately re-advertises. No manual recovery needed.",[456,4333],{},[459,4335,4337],{"id":4336},"the-central-hub","The Central: Hub",[449,4339,4340],{},"The hub Pico scans for the peripheral, connects, and reads the characteristic to control LEDs:",[520,4342,4344],{"className":585,"code":4343,"language":587,"meta":529,"style":529},"async def ble_scan():\n    async with aioble.scan(\n        5000,                        # 5-second scan window\n        interval_us=30000,\n        window_us=30000,\n        active=True\n    ) as scanner:\n        async for result in scanner:\n            if (result.name() == \"Peripheral\" and\n                BLE_SVC_UUID in result.services()):\n                return result\n    return None\n\nasync def receive_data_task(characteristic):\n    while True:\n        data = await characteristic.read()\n        if data:\n            message = data.decode(\"utf-8\")\n            if message == \"OPEN\":\n                led_green.value(1)\n                led_red.value(0)\n            elif message == \"CLOSED\":\n                led_green.value(0)\n                led_red.value(1)\n",[527,4345,4346,4351,4356,4361,4366,4371,4376,4381,4386,4391,4396,4401,4406,4410,4415,4419,4424,4429,4434,4439,4444,4448,4453,4458],{"__ignoreMap":529},[541,4347,4348],{"class":543,"line":544},[541,4349,4350],{},"async def ble_scan():\n",[541,4352,4353],{"class":543,"line":597},[541,4354,4355],{},"    async with aioble.scan(\n",[541,4357,4358],{"class":543,"line":603},[541,4359,4360],{},"        5000,                        # 5-second scan window\n",[541,4362,4363],{"class":543,"line":609},[541,4364,4365],{},"        interval_us=30000,\n",[541,4367,4368],{"class":543,"line":615},[541,4369,4370],{},"        window_us=30000,\n",[541,4372,4373],{"class":543,"line":621},[541,4374,4375],{},"        active=True\n",[541,4377,4378],{"class":543,"line":627},[541,4379,4380],{},"    ) as scanner:\n",[541,4382,4383],{"class":543,"line":633},[541,4384,4385],{},"        async for result in scanner:\n",[541,4387,4388],{"class":543,"line":639},[541,4389,4390],{},"            if (result.name() == \"Peripheral\" and\n",[541,4392,4393],{"class":543,"line":645},[541,4394,4395],{},"                BLE_SVC_UUID in result.services()):\n",[541,4397,4398],{"class":543,"line":651},[541,4399,4400],{},"                return result\n",[541,4402,4403],{"class":543,"line":656},[541,4404,4405],{},"    return None\n",[541,4407,4408],{"class":543,"line":662},[541,4409,600],{"emptyLinePlaceholder":393},[541,4411,4412],{"class":543,"line":910},[541,4413,4414],{},"async def receive_data_task(characteristic):\n",[541,4416,4417],{"class":543,"line":929},[541,4418,4092],{},[541,4420,4421],{"class":543,"line":934},[541,4422,4423],{},"        data = await characteristic.read()\n",[541,4425,4426],{"class":543,"line":939},[541,4427,4428],{},"        if data:\n",[541,4430,4431],{"class":543,"line":953},[541,4432,4433],{},"            message = data.decode(\"utf-8\")\n",[541,4435,4436],{"class":543,"line":958},[541,4437,4438],{},"            if message == \"OPEN\":\n",[541,4440,4441],{"class":543,"line":979},[541,4442,4443],{},"                led_green.value(1)\n",[541,4445,4446],{"class":543,"line":1000},[541,4447,4131],{},[541,4449,4450],{"class":543,"line":1019},[541,4451,4452],{},"            elif message == \"CLOSED\":\n",[541,4454,4455],{"class":543,"line":1024},[541,4456,4457],{},"                led_green.value(0)\n",[541,4459,4460],{"class":543,"line":1029},[541,4461,4116],{},[449,4463,4464,4465,4468],{},"The scan filters by ",[473,4466,4467],{},"both"," device name and service UUID — not just one. This prevents connecting to random BLE devices in range.",[1210,4470,4472],{"id":4471},"auto-reconnect-loop","Auto-Reconnect Loop",[449,4474,4475],{},"The central wraps everything in a retry loop:",[520,4477,4479],{"className":585,"code":4478,"language":587,"meta":529,"style":529},"async def run_central_mode():\n    while True:\n        device = await ble_scan()\n        if device is None:\n            continue\n\n        try:\n            connection = await device.device.connect()\n        except asyncio.TimeoutError:\n            continue\n\n        async with connection:\n            try:\n                service = await connection.service(BLE_SVC_UUID)\n                characteristic = await service.characteristic(BLE_CHAR_UUID)\n            except (asyncio.TimeoutError, AttributeError):\n                continue\n\n            await asyncio.create_task(\n                receive_data_task(characteristic)\n            )\n",[527,4480,4481,4486,4490,4495,4500,4505,4509,4514,4519,4524,4528,4532,4537,4542,4547,4552,4557,4562,4566,4570,4575],{"__ignoreMap":529},[541,4482,4483],{"class":543,"line":544},[541,4484,4485],{},"async def run_central_mode():\n",[541,4487,4488],{"class":543,"line":597},[541,4489,4092],{},[541,4491,4492],{"class":543,"line":603},[541,4493,4494],{},"        device = await ble_scan()\n",[541,4496,4497],{"class":543,"line":609},[541,4498,4499],{},"        if device is None:\n",[541,4501,4502],{"class":543,"line":615},[541,4503,4504],{},"            continue\n",[541,4506,4507],{"class":543,"line":621},[541,4508,600],{"emptyLinePlaceholder":393},[541,4510,4511],{"class":543,"line":627},[541,4512,4513],{},"        try:\n",[541,4515,4516],{"class":543,"line":633},[541,4517,4518],{},"            connection = await device.device.connect()\n",[541,4520,4521],{"class":543,"line":639},[541,4522,4523],{},"        except asyncio.TimeoutError:\n",[541,4525,4526],{"class":543,"line":645},[541,4527,4504],{},[541,4529,4530],{"class":543,"line":651},[541,4531,600],{"emptyLinePlaceholder":393},[541,4533,4534],{"class":543,"line":656},[541,4535,4536],{},"        async with connection:\n",[541,4538,4539],{"class":543,"line":662},[541,4540,4541],{},"            try:\n",[541,4543,4544],{"class":543,"line":910},[541,4545,4546],{},"                service = await connection.service(BLE_SVC_UUID)\n",[541,4548,4549],{"class":543,"line":929},[541,4550,4551],{},"                characteristic = await service.characteristic(BLE_CHAR_UUID)\n",[541,4553,4554],{"class":543,"line":934},[541,4555,4556],{},"            except (asyncio.TimeoutError, AttributeError):\n",[541,4558,4559],{"class":543,"line":939},[541,4560,4561],{},"                continue\n",[541,4563,4564],{"class":543,"line":953},[541,4565,600],{"emptyLinePlaceholder":393},[541,4567,4568],{"class":543,"line":958},[541,4569,4313],{},[541,4571,4572],{"class":543,"line":979},[541,4573,4574],{},"                receive_data_task(characteristic)\n",[541,4576,4577],{"class":543,"line":1000},[541,4578,4323],{},[449,4580,4581],{},"If the connection drops — scan again. If the scan times out — try again. The hub will find the window sensor eventually.",[456,4583],{},[459,4585,4587],{"id":4586},"concurrent-tasks-with-asyncio","Concurrent Tasks with asyncio",[449,4589,4590,4591,4594],{},"MicroPython on the Pico W supports ",[527,4592,4593],{},"asyncio"," — and it's the right way to handle multiple I/O tasks without threads:",[520,4596,4598],{"className":585,"code":4597,"language":587,"meta":529,"style":529},"async def main():\n    tasks = [\n        asyncio.create_task(monitor_vsys()),\n        asyncio.create_task(run_peripheral_mode()),\n    ]\n    await asyncio.gather(*tasks)\n\nasyncio.run(main())\n",[527,4599,4600,4605,4610,4615,4620,4625,4630,4634],{"__ignoreMap":529},[541,4601,4602],{"class":543,"line":544},[541,4603,4604],{},"async def main():\n",[541,4606,4607],{"class":543,"line":597},[541,4608,4609],{},"    tasks = [\n",[541,4611,4612],{"class":543,"line":603},[541,4613,4614],{},"        asyncio.create_task(monitor_vsys()),\n",[541,4616,4617],{"class":543,"line":609},[541,4618,4619],{},"        asyncio.create_task(run_peripheral_mode()),\n",[541,4621,4622],{"class":543,"line":615},[541,4623,4624],{},"    ]\n",[541,4626,4627],{"class":543,"line":621},[541,4628,4629],{},"    await asyncio.gather(*tasks)\n",[541,4631,4632],{"class":543,"line":627},[541,4633,600],{"emptyLinePlaceholder":393},[541,4635,4636],{"class":543,"line":633},[541,4637,4638],{},"asyncio.run(main())\n",[449,4640,4641],{},"This runs reed switch monitoring, VSYS power monitoring, and BLE advertising concurrently — all in a single thread. No race conditions, no locks, no shared memory issues.",[1210,4643,4645],{"id":4644},"the-boot-sequence","The Boot Sequence",[449,4647,4648,4649,4652,4653,4656],{},"A ",[527,4650,4651],{},"boot.py"," file runs before ",[527,4654,4655],{},"main.py"," and blinks all LEDs as a visual \"I'm alive\" signal:",[520,4658,4660],{"className":585,"code":4659,"language":587,"meta":529,"style":529},"from machine import Pin\nimport utime\n\nleds = [Pin(13, Pin.OUT), Pin(14, Pin.OUT), Pin(15, Pin.OUT)]\n\nfor _ in range(5):\n    for led in leds:\n        led.value(1)\n    utime.sleep(0.1)\n    for led in leds:\n        led.value(0)\n    utime.sleep(0.1)\n\nimport main\n",[527,4661,4662,4666,4671,4675,4680,4684,4689,4694,4699,4704,4708,4713,4717,4721],{"__ignoreMap":529},[541,4663,4664],{"class":543,"line":544},[541,4665,4026],{},[541,4667,4668],{"class":543,"line":597},[541,4669,4670],{},"import utime\n",[541,4672,4673],{"class":543,"line":603},[541,4674,600],{"emptyLinePlaceholder":393},[541,4676,4677],{"class":543,"line":609},[541,4678,4679],{},"leds = [Pin(13, Pin.OUT), Pin(14, Pin.OUT), Pin(15, Pin.OUT)]\n",[541,4681,4682],{"class":543,"line":615},[541,4683,600],{"emptyLinePlaceholder":393},[541,4685,4686],{"class":543,"line":621},[541,4687,4688],{},"for _ in range(5):\n",[541,4690,4691],{"class":543,"line":627},[541,4692,4693],{},"    for led in leds:\n",[541,4695,4696],{"class":543,"line":633},[541,4697,4698],{},"        led.value(1)\n",[541,4700,4701],{"class":543,"line":639},[541,4702,4703],{},"    utime.sleep(0.1)\n",[541,4705,4706],{"class":543,"line":645},[541,4707,4693],{},[541,4709,4710],{"class":543,"line":651},[541,4711,4712],{},"        led.value(0)\n",[541,4714,4715],{"class":543,"line":656},[541,4716,4703],{},[541,4718,4719],{"class":543,"line":662},[541,4720,600],{"emptyLinePlaceholder":393},[541,4722,4723],{"class":543,"line":910},[541,4724,4725],{},"import main\n",[449,4727,4728],{},"Five fast blinks, then the app starts. Simple, but incredibly useful when you're debugging a headless device — you know immediately if the Pico booted or is stuck.",[456,4730],{},[459,4732,4734],{"id":4733},"deployment-with-mpremote","Deployment with mpremote",[449,4736,4737,4738,4741],{},"No IDE needed. ",[527,4739,4740],{},"mpremote"," handles everything from the terminal:",[520,4743,4745],{"className":535,"code":4744,"language":537,"meta":529,"style":529},"# Upload code to the Pico\nmpremote connect /dev/cu.usbmodem101 fs cp main.py :main.py\n\n# Open a REPL\nmpremote connect /dev/cu.usbmodem101 repl\n\n# Run without uploading (for testing)\nmpremote run main.py\n\n# Monitor serial output\nscreen /dev/cu.usbmodem101 115200\n",[527,4746,4747,4752,4774,4778,4783,4794,4798,4803,4812,4816,4821],{"__ignoreMap":529},[541,4748,4749],{"class":543,"line":544},[541,4750,4751],{"class":1377},"# Upload code to the Pico\n",[541,4753,4754,4756,4759,4762,4765,4768,4771],{"class":543,"line":597},[541,4755,4740],{"class":547},[541,4757,4758],{"class":551}," connect",[541,4760,4761],{"class":551}," /dev/cu.usbmodem101",[541,4763,4764],{"class":551}," fs",[541,4766,4767],{"class":551}," cp",[541,4769,4770],{"class":551}," main.py",[541,4772,4773],{"class":551}," :main.py\n",[541,4775,4776],{"class":543,"line":603},[541,4777,600],{"emptyLinePlaceholder":393},[541,4779,4780],{"class":543,"line":609},[541,4781,4782],{"class":1377},"# Open a REPL\n",[541,4784,4785,4787,4789,4791],{"class":543,"line":615},[541,4786,4740],{"class":547},[541,4788,4758],{"class":551},[541,4790,4761],{"class":551},[541,4792,4793],{"class":551}," repl\n",[541,4795,4796],{"class":543,"line":621},[541,4797,600],{"emptyLinePlaceholder":393},[541,4799,4800],{"class":543,"line":627},[541,4801,4802],{"class":1377},"# Run without uploading (for testing)\n",[541,4804,4805,4807,4809],{"class":543,"line":633},[541,4806,4740],{"class":547},[541,4808,2304],{"class":551},[541,4810,4811],{"class":551}," main.py\n",[541,4813,4814],{"class":543,"line":639},[541,4815,600],{"emptyLinePlaceholder":393},[541,4817,4818],{"class":543,"line":645},[541,4819,4820],{"class":1377},"# Monitor serial output\n",[541,4822,4823,4826,4828],{"class":543,"line":651},[541,4824,4825],{"class":547},"screen",[541,4827,4761],{"class":551},[541,4829,4831],{"class":4830},"sbssI"," 115200\n",[449,4833,4834],{},"I use a Makefile to avoid typing device paths:",[520,4836,4840],{"className":4837,"code":4838,"language":4839,"meta":529,"style":529},"language-makefile shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","PORT ?= /dev/cu.usbmodem101\n\nupload:\n    mpremote connect $(PORT) fs cp main.py :main.py\n    mpremote connect $(PORT) fs cp boot.py :boot.py\n\nrepl:\n    mpremote connect $(PORT) repl\n\nlogs:\n    mpremote connect $(PORT) run :main.py\n","makefile",[527,4841,4842,4847,4851,4856,4861,4866,4870,4875,4880,4884,4889],{"__ignoreMap":529},[541,4843,4844],{"class":543,"line":544},[541,4845,4846],{},"PORT ?= /dev/cu.usbmodem101\n",[541,4848,4849],{"class":543,"line":597},[541,4850,600],{"emptyLinePlaceholder":393},[541,4852,4853],{"class":543,"line":603},[541,4854,4855],{},"upload:\n",[541,4857,4858],{"class":543,"line":609},[541,4859,4860],{},"    mpremote connect $(PORT) fs cp main.py :main.py\n",[541,4862,4863],{"class":543,"line":615},[541,4864,4865],{},"    mpremote connect $(PORT) fs cp boot.py :boot.py\n",[541,4867,4868],{"class":543,"line":621},[541,4869,600],{"emptyLinePlaceholder":393},[541,4871,4872],{"class":543,"line":627},[541,4873,4874],{},"repl:\n",[541,4876,4877],{"class":543,"line":633},[541,4878,4879],{},"    mpremote connect $(PORT) repl\n",[541,4881,4882],{"class":543,"line":639},[541,4883,600],{"emptyLinePlaceholder":393},[541,4885,4886],{"class":543,"line":645},[541,4887,4888],{},"logs:\n",[541,4890,4891],{"class":543,"line":651},[541,4892,4893],{},"    mpremote connect $(PORT) run :main.py\n",[456,4895],{},[459,4897,4899],{"id":4898},"what-i-learned","What I Learned",[1210,4901,4903],{"id":4902},"_1-aioble-is-excellent","1. aioble Is Excellent",[449,4905,4906,4907,4910,4911,4914,4915,2218],{},"MicroPython's ",[527,4908,4909],{},"aioble"," library is genuinely well-designed. The ",[527,4912,4913],{},"async with await aioble.advertise(...)"," pattern handles connection lifecycle cleanly — when the block exits (disconnect), it automatically restarts. Same for ",[527,4916,4917],{},"aioble.scan()",[1210,4919,4921],{"id":4920},"_2-ble-wifi-for-sensor-networks","2. BLE > WiFi for Sensor Networks",[449,4923,4924],{},"My first instinct was WiFi — HTTP requests to a local server. But BLE has three advantages for this use case:",[467,4926,4927,4933,4939],{},[470,4928,4929,4932],{},[473,4930,4931],{},"No router dependency"," — works if your WiFi goes down",[470,4934,4935,4938],{},[473,4936,4937],{},"Lower power"," — BLE uses milliwatts vs WiFi's hundreds of milliwatts",[470,4940,4941,4944],{},[473,4942,4943],{},"Simpler protocol"," — write bytes to a characteristic vs HTTP request/response",[449,4946,4947],{},"WiFi makes sense when you need cloud connectivity or a web dashboard. For room-level sensor communication, BLE wins.",[1210,4949,4951],{"id":4950},"_3-ascii-messages-are-fine","3. ASCII Messages Are Fine",[449,4953,4954,4955,3605,4957,4959],{},"I send ",[527,4956,3930],{},[527,4958,3934],{}," as UTF-8 strings over BLE. Could I pack this into a single bit? Yes. Does it matter on a system with one message per state change? No. Readability beats optimization when you're debugging at 2 AM with a serial monitor.",[1210,4961,4963],{"id":4962},"_4-the-pico-ws-ble-stack-is-solid","4. The Pico W's BLE Stack Is Solid",[449,4965,4966,4967,4969],{},"I expected crashes, weird disconnects, stack overflows. Instead, the Pico W's BLE stack (via CYW43) just works. Advertising, scanning, connecting, reading, writing — all stable. The ",[527,4968,4909],{}," library abstracts the complexity well.",[456,4971],{},[459,4973,1465],{"id":1464},[449,4975,4976],{},"This is a prototype. The production version would add:",[467,4978,4979,4985,4991,4997],{},[470,4980,4981,4984],{},[473,4982,4983],{},"Battery power"," — the Pico W can run on 2× AA batteries with deep sleep between readings",[470,4986,4987,4990],{},[473,4988,4989],{},"Multiple sensors"," — one hub scanning for N window peripherals (the central code already handles re-scanning)",[470,4992,4993,4996],{},[473,4994,4995],{},"MQTT bridge"," — add WiFi to the hub only, forward BLE state to Home Assistant",[470,4998,4999,5002],{},[473,5000,5001],{},"Enclosure"," — 3D-printed case for the window unit, with the reed switch on a flex cable",[449,5004,5005],{},"The total cost for a 5-window system: ~$40. A commercial equivalent (Aqara, Eve, etc.): $150-250 plus a hub plus a subscription.",[456,5007],{},[459,5009,5011],{"id":5010},"gpio-reference","GPIO Reference",[520,5013,5016],{"className":5014,"code":5015,"language":525},[523],"Window Pico (Peripheral)     Hub Pico (Central)\n┌──────────────────────┐     ┌──────────────────────┐\n│ GPIO 14: Reed switch │     │ GPIO 13: Yellow LED   │\n│ GPIO 15: Red LED     │     │ GPIO 14: Green LED    │\n│ GPIO 13: Yellow LED  │     │ GPIO 15: Red LED      │\n└──────────────────────┘     └──────────────────────┘\n",[527,5017,5015],{"__ignoreMap":529},[456,5019],{},[449,5021,5022],{},[506,5023,5024],{},"Built with 2× Raspberry Pi Pico W, MicroPython, aioble, and $14 worth of components.",[1588,5026,5027],{},"html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}",{"title":529,"searchDepth":597,"depth":597,"links":5029},[5030,5031,5032,5036,5039,5042,5043,5049,5050],{"id":3900,"depth":597,"text":3901},{"id":3940,"depth":597,"text":3941},{"id":4012,"depth":597,"text":4013,"children":5033},[5034,5035],{"id":4162,"depth":603,"text":4163},{"id":4217,"depth":603,"text":4218},{"id":4336,"depth":597,"text":4337,"children":5037},[5038],{"id":4471,"depth":603,"text":4472},{"id":4586,"depth":597,"text":4587,"children":5040},[5041],{"id":4644,"depth":603,"text":4645},{"id":4733,"depth":597,"text":4734},{"id":4898,"depth":597,"text":4899,"children":5044},[5045,5046,5047,5048],{"id":4902,"depth":603,"text":4903},{"id":4920,"depth":603,"text":4921},{"id":4950,"depth":603,"text":4951},{"id":4962,"depth":603,"text":4963},{"id":1464,"depth":597,"text":1465},{"id":5010,"depth":597,"text":5011},"How I built a wireless window monitoring system using two Raspberry Pi Pico Ws, reed switches, and BLE — with async MicroPython, aioble, and zero cloud dependencies.","/images/stock/1629654297299-c8506221ca97.webp",{},{"title":5055,"description":5051},"BLE Window Sensors with Pico W and MicroPython","at7YZkPPE5PFVfr1SFMa0sbebz0sWt-7w039zNrQA_I",1792754839704]