<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>wwakabobik's lair - ethics</title><link href="https://wwakabobik.github.io/" rel="alternate"/><link href="https://wwakabobik.github.io/feeds/ethics.tag.atom.xml" rel="self"/><id>https://wwakabobik.github.io/</id><updated>2026-06-30T22:58:06.774439+02:00</updated><entry><title>From a Corporate Spy Box to Presence Hub</title><link href="https://wwakabobik.github.io/2026/06/corporate_spy_box_to_presence_hub/" rel="alternate"/><published>2026-06-30T18:00:00+02:00</published><updated>2026-06-30T22:58:06.774439+02:00</updated><author><name>wwakabobik</name></author><id>tag:wwakabobik.github.io,2026-06-30:/2026/06/corporate_spy_box_to_presence_hub/</id><summary type="html">&lt;p class="first last"&gt;A corporate mmWave desk tracker arrived as a «gift» — wrong clocks, presence gaps, power-strip lectures, and an API naked to curl. GDPR/ZZPL, flash dumps, forensic autopsy, walking away from a broken cloud, and rebuilding the hardware as Presence Hub — MQTT on a Mac, honest 1D gestures, and real TinyML instead of Medium cosplay.&lt;/p&gt;
</summary><content type="html">&lt;blockquote class="pull-quote"&gt;
This article describes security and privacy failures in a third-party corporate wellness gadget. Names and domains are fictionalized. The open replacement is &lt;a class="reference external" href="https://github.com/wwakabobik/esp32_radar_tracker"&gt;Presence Hub on GitHub&lt;/a&gt;.*&lt;/blockquote&gt;
&lt;blockquote class="epigraph"&gt;
&lt;p&gt;«It's not a bug — it's a feature.»
— Corporate release notes, every vendor, every quarter.&lt;/p&gt;
&lt;p&gt;«Liberty means responsibility. That is why most men dread it.»
— George Bernard Shaw&lt;/p&gt;
&lt;/blockquote&gt;
&lt;div class="section" id="tl-dr"&gt;
&lt;h2&gt;TL;DR&lt;/h2&gt;
&lt;p&gt;A corporate «wellness» mmWave tracker landed on my desk. It phoned home to an open API, ranked me on a fleet table, and asked for a spare power outlet. I dumped the flash, walked away from the cloud, and rebuilt the hardware as &lt;strong&gt;Presence Hub&lt;/strong&gt; — local MQTT, SQLite, honest radar physics, on-device TinyML.&lt;/p&gt;
&lt;p&gt;The question underneath everything else:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who owns the device sitting on my desk — me, or someone else's dashboard?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Full forensic detail, MQTT cookbook, and firmware depth below.&lt;/em&gt; Skim the narrative through &lt;a class="reference internal" href="#part-3-enough-proof"&gt;Part 3 — Enough Proof&lt;/a&gt;; jump to &lt;a class="reference internal" href="#part-4-presence-hub"&gt;Part 4 — Presence Hub&lt;/a&gt; for the rebuild.&lt;/p&gt;
&lt;div class="section" id="jump-to"&gt;
&lt;h3&gt;Jump to&lt;/h3&gt;
&lt;div class="toc docutils container"&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;&lt;a class="reference internal" href="#prologue"&gt;Prologue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-0-5-power-strip-lecture"&gt;Part 0.5 — Power Strip Lecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-1-threat-model"&gt;Part 1 — Threat Model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-2-forensic-specimen"&gt;Part 2 — Forensic Specimen&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-3-enough-proof"&gt;Part 3 — Enough Proof&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-4-presence-hub"&gt;Part 4 — Presence Hub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-5-udp-discovery"&gt;Part 5 — UDP Discovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-6-gpio-archaeology"&gt;Part 6 — GPIO Archaeology&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-7-mac-daemon"&gt;Part 7 — Mac Daemon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-8-firmware"&gt;Part 8 — Firmware&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-9-radar-101"&gt;Part 9 — Radar 101&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-10-gestures"&gt;Part 10 — Gestures&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-11-tinyml"&gt;Part 11 — TinyML&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#part-12-evolution"&gt;Part 12 — Evolution&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#conclusion"&gt;Conclusion&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#epilogue"&gt;Epilogue&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#appendix-a-mqtt"&gt;Appendix A — MQTT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a class="reference internal" href="#appendix-b-debug"&gt;Appendix B — Debug&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="prologue-the-package-arrives-norton-commander-for-the-soul"&gt;
&lt;span id="prologue"&gt;&lt;/span&gt;&lt;h2&gt;Prologue: The Package Arrives (Norton Commander for the Soul)&lt;/h2&gt;
&lt;p&gt;In the corporate wellness industry there is a razor-thin line between &lt;em&gt;caring for an employee&lt;/em&gt; and full-blown Orwellian surveillance. Sometimes that line is not crossed — it is run over by a tank painted «ergonomic beige» and marketed as an &lt;em&gt;attention antivirus&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;It started innocently. A friendly direct message on a spring afternoon:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«Hi, please send me your mailing address — I want to send you a small package.»&lt;/em&gt;&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Telegram DM — «send me your mailing address, I want to send you a small parcel»" src="/assets/images/articles/iot/presence_hub/telegram_gift_dm.png" style="width: 520px;" /&gt;
&lt;p class="caption"&gt;Where the story starts — a spring DM, anonymized. &lt;em&gt;Small parcel&lt;/em&gt; is corporate for &lt;em&gt;prototype surveillance with layer lines&lt;/em&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;The post office experience was already a metaphor. The courier apparently could not be bothered to walk to the door or leave a slip; the parcel sat at the depot until I collected it myself.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="USPS tracking — Delivered, Serbia" src="/assets/images/articles/iot/presence_hub/usps_delivered.png" style="width: 640px;" /&gt;
&lt;p class="caption"&gt;&lt;em&gt;Delivered, SERBIA&lt;/em&gt; — the corporate wellness prototype clears customs faster than their GDPR story.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="FDM print close-up — layer lines, diagonal seam, four screws holding the faceplate" src="/assets/images/articles/iot/presence_hub/device_fdm_closeup.png" style="width: 380px;" /&gt;
&lt;p class="caption"&gt;Layer lines, diagonal seam, four screws — industrial design tax still zero.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Plug it in. First-run wizard on the OLED: &lt;strong&gt;Wi-Fi SSID&lt;/strong&gt;, &lt;strong&gt;Wi-Fi password&lt;/strong&gt;, pick a &lt;strong&gt;timezone&lt;/strong&gt; (I chose &lt;tt class="docutils literal"&gt;Europe/Belgrade&lt;/tt&gt; — sensible for Serbia, wrong city label on their dashboard later). That was the whole onboarding. No manual. No «here is your employer dashboard». No explanation of what the numbers on the screen meant.&lt;/p&gt;
&lt;p&gt;The gadget just &lt;strong&gt;sat there&lt;/strong&gt;: cryptic counters, mode toggles I did not ask for, a &lt;strong&gt;clock that lied&lt;/strong&gt; — wrong wall time, daylight saving ignored. I am an &lt;strong&gt;engineer&lt;/strong&gt; shipping AI test infrastructure and real deliverables, not a desk-toy babysitter. It collected dust while I worked.&lt;/p&gt;
&lt;p&gt;Then the question arrived, quietly and insistently: &lt;em&gt;what is it sending, and to whom?&lt;/em&gt; I did &lt;strong&gt;not&lt;/strong&gt; start with a screwdriver. I started on the &lt;strong&gt;network&lt;/strong&gt; — same LAN, watch the wire, save pcaps, replay with scripts. Only after traffic proved the story did I crack the enclosure and plug in &lt;strong&gt;USB&lt;/strong&gt;. The forensic timeline is &lt;a class="reference internal" href="#part-2-forensic-specimen"&gt;Part 2 — Forensic Specimen&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The corporate pitch on their product website — when I finally read it — sounded almost empathetic:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;Monitor your deep-work blocks.&lt;/li&gt;
&lt;li&gt;Receive healthy break reminders.&lt;/li&gt;
&lt;li&gt;Rest assured: &lt;strong&gt;no cameras, no microphones&lt;/strong&gt; — just a tiny, harmless radio-wave sensor that uses radio waves to understand who is sitting in front of it.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Vendor landing — «Work measured by hardware. Performance built on truth.»" src="/assets/images/articles/iot/presence_hub/vendor_landing.png" style="width: 640px;" /&gt;
&lt;p class="caption"&gt;&lt;em&gt;Performance built on truth.&lt;/em&gt; &lt;em&gt;Healthier teams.&lt;/em&gt; Hardware included — anxiety sold separately.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;Performance built on truth&lt;/em&gt; — if «truth» is an open API, a MAC address, and a fleet table that ranks your bladder breaks. &lt;em&gt;Healthier teams&lt;/em&gt; — like a gym that bills you for steps to the toilet and calls it ergonomics. They had the copy; I had Wireshark. Guess which one lied less.&lt;/p&gt;
&lt;p&gt;Reality disagreed — once I could see &lt;strong&gt;their&lt;/strong&gt; dashboard and cross-check the wire:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;I had set timezone on the device. The OLED clock was still &lt;strong&gt;wrong&lt;/strong&gt;. Their admin UI agreed the unit was confused — and still phoned home with HTTP 200.&lt;/li&gt;
&lt;li&gt;Activity logs showed a &lt;strong&gt;curious gap from 17:00 to 18:00&lt;/strong&gt; — precisely when I was at my desk, quietly and intensely coding. Not pacing. Not vibing. &lt;em&gt;Working.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Employer dashboard — presence gap 17:00–18:00 while logged time continues" src="/assets/images/articles/iot/presence_hub/dashboard_presence_gap.png" style="width: 700px;" /&gt;
&lt;p class="caption"&gt;&lt;em&gt;Logged vs Presence Timeline&lt;/em&gt;: device online, manual log through 20:00 — but motion «presence» vanishes for most of the 17:00 hour while I was coding.&lt;/p&gt;
&lt;/div&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;The gadget did not track &lt;em&gt;focus&lt;/em&gt;. It tracked coarse motion and noise, then uploaded the interpretation to &lt;strong&gt;their&lt;/strong&gt; cloud.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;They wrote back: &lt;em&gt;great feedback, this is a new prototype we plan to sell, testing phase, just leave it on your desk collecting data, left button resets time, five presses hard-resets, right button switches display modes, we'll fix timezone on the dashboard soon, try hard-reset and set timezone to Berlin on the device.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Sure. I'll get right on that — right after I finish the UI test suite for Xavier and the auto-AI crash-analysis agents that actually help my job, not a leaderboard of bladder breaks.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="part-0-5-the-power-strip-lecture-survival-vs-wellness"&gt;
&lt;span id="part-0-5-power-strip-lecture"&gt;&lt;/span&gt;&lt;h2&gt;Part 0.5: The Power Strip Lecture — Survival vs. «Wellness»&lt;/h2&gt;
&lt;p&gt;A week later:&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Telegram — power-strip lecture vs. «I'll flash my own firmware»" src="/assets/images/articles/iot/presence_hub/telegram_power_strip.png" style="width: 480px;" /&gt;
&lt;p class="caption"&gt;The corporate reply: plug it into a power strip. My reply: California sockets or my own firmware.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;&lt;em&gt;«Hi — your tracker wasn't online yesterday or today. Any trouble with it?»&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«All good, we're working. Raised UI tests this week, building auto-AI failure analysis so agents fix flaky tests themselves. The tracker — yeah, it's off. It eats a power socket. I have one outlet for the laptop, one for the desk lamp. Where do I plug your toy? And why should I? On the weekend I'll probably flash something useful — gesture media control, a health nudge that reminds me to stretch, **my*&lt;/em&gt; firmware.»*&lt;/p&gt;
&lt;p&gt;The reply was patient, corporate, and completely deaf:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«You need to plug it in so it collects data. Then we can test whether it understands you correctly — healthy break reminders are already there. Plug it into a power strip or USB, leave it running, watch realtime on the dashboard.»&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I answered the only way that felt honest:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«Fine — as soon as I rent a house in California with a spare outlet and a power strip. Until then, no.»&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«I believe in you — I'm sure they sell power strips in Serbia too :)»&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;And there it was. The full insult compressed into one emoji.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What actually helps a remote engineer be productive&lt;/strong&gt; is not a mmWave snitch on a stick. It is modern hardware (neural accelerators help). A comfortable workspace — home or coworking — that a &lt;em&gt;remote-first company&lt;/em&gt; could fund instead of surveillance cosplay. Above all: income that does not force you to calculate whether you can afford a &lt;strong&gt;€3 power strip&lt;/strong&gt; to feed someone else's data pipeline while you're buying second-hand trousers for your kid and rotten discount food to survive the month.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«But without violating personal boundaries»&lt;/em&gt; — should I strap it to a dog's collar? This thing literally logs how many times I walked away to pee. &lt;strong&gt;Work is measured in deliverables&lt;/strong&gt;, not chair time. Respect is not &lt;em&gt;«no money but hold on»&lt;/em&gt; plus a mandatory desk pet.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;«Privacy is preserved»&lt;/em&gt; — especially when data flows freely to anyone on the internet with zero authentication. That is not privacy. That is negligence wearing a yoga mat.&lt;/p&gt;
&lt;blockquote class="pull-quote"&gt;
&lt;strong&gt;Why I did not «just say no» on day one&lt;/strong&gt; — Refusing a «gift» tracker is not friction-free. Remote work already means proving you exist. A prototype phase sounds reasonable: &lt;em&gt;we're testing, leave it on the desk&lt;/em&gt;. Saying no reads as not-a-team-player — especially mid-sprint on real deliverables. I gave polite feedback first. I unplugged when the power-strip lecture arrived.&lt;/blockquote&gt;
&lt;p&gt;I work from &lt;strong&gt;Novi Sad&lt;/strong&gt; — Serbia, not an EU member, but with an &lt;strong&gt;EU adequacy decision since 2023&lt;/strong&gt; and a &lt;strong&gt;ZZPL&lt;/strong&gt; that tracks GDPR closely. I am not a random hobbyist flashing boards for sport: I am an &lt;strong&gt;engineer&lt;/strong&gt; under a privacy framework that &lt;em&gt;should&lt;/em&gt; have protected my home office. It did not — because the vendor stack ignored Art. 32 before I ever opened esptool.&lt;/p&gt;
&lt;blockquote class="pull-quote"&gt;
&lt;strong&gt;Vendor Medium vs measured&lt;/strong&gt; — &lt;em&gt;Claimed:&lt;/em&gt; edge AI saves workers. &lt;em&gt;Measured:&lt;/em&gt; &lt;tt class="docutils literal"&gt;Radar fail&lt;/tt&gt; + HTTP 200. &lt;em&gt;Claimed:&lt;/em&gt; privacy-first. &lt;em&gt;Measured:&lt;/em&gt; open API, MAC-as-ID, public fleet table.&lt;/blockquote&gt;
&lt;p&gt;This is the thread some of us still call &lt;strong&gt;«a dialogue about losing meaning and footing.»&lt;/strong&gt; Under the sympathetic pitch: my body as radar samples, focus scores in an admin UI that labeled the unit &lt;em&gt;Living Room SPT&lt;/em&gt; while it sat in &lt;strong&gt;my&lt;/strong&gt; home office.&lt;/p&gt;
&lt;p&gt;Months later the same silicon starred in a vendor Medium story about «AI on a chip saving workers.» I had already lived the other side — flash dumps, serial logs, fleet tables.&lt;/p&gt;
&lt;p&gt;That anger was not abstract. I was being watched without meaningful consent.&lt;/p&gt;
&lt;p&gt;So I stopped being a passive &lt;strong&gt;user&lt;/strong&gt; and became a &lt;strong&gt;forensic investigator&lt;/strong&gt; — engineer habits, not sysadmin cosplay. Network first, flash second: read the EEPROM like it's 1995 — boot &lt;tt class="docutils literal"&gt;Norton Commander&lt;/tt&gt; on the family 386, hit &lt;strong&gt;TURBO&lt;/strong&gt;, copy the game to &lt;tt class="docutils literal"&gt;RAMDISK&lt;/tt&gt;, trust the floppy only after — then rebuild firmware that answers to &lt;strong&gt;me&lt;/strong&gt;. Back then the monster under the bed was &lt;tt class="docutils literal"&gt;COMMAND.COM&lt;/tt&gt;; in 2026 it sits on the desk and asks for a USB port.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Vendor pricing — SPT module, fleet table, location history on higher tiers" src="/assets/images/articles/iot/presence_hub/vendor_pricing.png" style="width: 640px;" /&gt;
&lt;p class="caption"&gt;The product sheet: live monitor, team view, location history — wellness as a subscription tier.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="part-1-threat-model-law-borders-and-whose-desk-is-this"&gt;
&lt;span id="part-1-threat-model"&gt;&lt;/span&gt;&lt;h2&gt;Part 1: Threat Model — Law, Borders, and Whose Desk Is This?&lt;/h2&gt;
&lt;p&gt;Before I touch esptool, the legal and moral premise was rotten. (GDPR/ZZPL detail: &lt;a class="reference internal" href="#part-0-5-power-strip-lecture"&gt;Part 0.5 — Power Strip Lecture&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;The gadget micro-managed &lt;strong&gt;physical presence&lt;/strong&gt; — seat-time, not deliverables.&lt;/p&gt;
&lt;p&gt;In short: &lt;strong&gt;no lawful basis, no security, no erasure&lt;/strong&gt; — complaint-ready under &lt;strong&gt;Poverenik&lt;/strong&gt; or any EU DPA. The legal thread and the corporate reply are in &lt;a class="reference internal" href="#part-0-5-power-strip-lecture"&gt;Part 0.5 — Power Strip Lecture&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="part-2-forensic-specimen-reading-the-flash-like-it-s-1995"&gt;
&lt;span id="part-2-forensic-specimen"&gt;&lt;/span&gt;&lt;h2&gt;Part 2: Forensic Specimen — Reading the Flash Like It's 1995&lt;/h2&gt;
&lt;p&gt;&lt;em&gt;Technical deep dive from here through&lt;/em&gt; &lt;a class="reference internal" href="#part-11-tinyml"&gt;Part 11 — TinyML&lt;/a&gt; &lt;em&gt;— narrative resumes in the Conclusion.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Order of battle:&lt;/strong&gt; LAN traffic and HTTP replay &lt;strong&gt;before&lt;/strong&gt; opening the case. USB and flash come after the network tells you where to look.&lt;/p&gt;
&lt;p&gt;Step zero on the bench: &lt;strong&gt;do not brick the evidence&lt;/strong&gt;. In the 90s you copied &lt;tt class="docutils literal"&gt;AUTOEXEC.BAT&lt;/tt&gt; to a floppy before running the thing that might delete &lt;tt class="docutils literal"&gt;C:\&lt;/tt&gt;. Same ritual: &lt;strong&gt;full flash dump before experiments&lt;/strong&gt;. Stock firmware is ground truth when marketing lies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Technical timeline&lt;/strong&gt; (that summer, stock firmware &lt;strong&gt;v1.0.7&lt;/strong&gt;, admin name &lt;em&gt;Living Room SPT&lt;/em&gt;):&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="6%" /&gt;
&lt;col width="16%" /&gt;
&lt;col width="78%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;#&lt;/th&gt;
&lt;th class="head"&gt;Step&lt;/th&gt;
&lt;th class="head"&gt;Tool / outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Network&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;capture_traffic.sh&lt;/tt&gt; / tshark → HTTPS to vendor API host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;HTTP replay&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;curl&lt;/tt&gt; + OpenAPI → no auth on track endpoints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;USB ID&lt;/td&gt;
&lt;td&gt;Open enclosure; &lt;tt class="docutils literal"&gt;detect_device.sh&lt;/tt&gt; → CP2102, ESP32-D0WD-V3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Preserve flash&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;dump_firmware.sh&lt;/tt&gt; → 4 MB &lt;tt class="docutils literal"&gt;.bin&lt;/tt&gt; + partition table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Strings&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;strings&lt;/tt&gt; → API paths, &lt;strong&gt;plaintext Wi-Fi credentials in NVS&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Serial&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;monitor_serial.sh&lt;/tt&gt; → &lt;tt class="docutils literal"&gt;Radar fail&lt;/tt&gt; + HTTP 200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;GPIO&lt;/td&gt;
&lt;td&gt;Boot pin map; later GPIO probe → buttons 18/5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Hardware baseline (boring — scandal was never the silicon):&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="20%" /&gt;
&lt;col width="15%" /&gt;
&lt;col width="65%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Component&lt;/th&gt;
&lt;th class="head"&gt;Interface&lt;/th&gt;
&lt;th class="head"&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;ESP32-D0WD-V3&lt;/td&gt;
&lt;td&gt;USB-UART (CP2102)&lt;/td&gt;
&lt;td&gt;Dual-core, Wi-Fi/BT&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;HLK-LD2410C&lt;/td&gt;
&lt;td&gt;UART&lt;/td&gt;
&lt;td&gt;24 GHz mmWave presence radar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;SSD1306 OLED&lt;/td&gt;
&lt;td&gt;I2C &amp;#64; 0x3C&lt;/td&gt;
&lt;td&gt;128×64&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;2× tactile buttons&lt;/td&gt;
&lt;td&gt;GPIO&lt;/td&gt;
&lt;td&gt;Active LOW (discovered later — PCB had no silkscreen)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class="section" id="network-capture-capture-traffic-sh"&gt;
&lt;h3&gt;Network capture — &lt;tt class="docutils literal"&gt;capture_traffic.sh&lt;/tt&gt;&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;This is where I started — before opening the case.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Multi-mode: &lt;tt class="docutils literal"&gt;interfaces&lt;/tt&gt; | &lt;tt class="docutils literal"&gt;raw&lt;/tt&gt; (pcap) | &lt;tt class="docutils literal"&gt;live&lt;/tt&gt; | &lt;tt class="docutils literal"&gt;mitm&lt;/tt&gt; | &lt;tt class="docutils literal"&gt;mitmdump&lt;/tt&gt; | &lt;tt class="docutils literal"&gt;analyze&lt;/tt&gt;.&lt;/p&gt;
&lt;p&gt;Live decode:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="c1"&gt;# scripts/service/capture_traffic.sh live en0 &amp;#39;host api.vendor.example&amp;#39;&lt;/span&gt;
tshark&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$FILTER&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-V&lt;span class="w"&gt; &lt;/span&gt;-l&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;--line-buffered&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;HTTP|Host:|GET |POST |Authorization|TLS|DNS|MQTT&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Analyze saved pcap — HTTP URIs, DNS, TLS SNI, MQTT topics:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;tshark&lt;span class="w"&gt; &lt;/span&gt;-r&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$FILE&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-Y&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http.request&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-T&lt;span class="w"&gt; &lt;/span&gt;fields&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;ip.src&lt;span class="w"&gt; &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;http.host&lt;span class="w"&gt; &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;http.request.uri&lt;span class="w"&gt; &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;http.authorization
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;tt class="docutils literal"&gt;mitmdump&lt;/tt&gt; uses &lt;tt class="docutils literal"&gt;mitm_addon.py&lt;/tt&gt; — pretty JSON + highlight auth/MAC fields:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPFlow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&amp;gt;&amp;gt;&amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pretty_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;utf-8&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;replace&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# when JSON&lt;/span&gt;
    &lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Stock telemetry was bare JSON over HTTPS — but &lt;strong&gt;without meaningful client authentication&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mac_address&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;XX:XX:XX:XX:XX:XX&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;s_energy&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;s_dist&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;m_energy&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;m_dist&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;presence_min&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Identity was the hardware MAC string — spoofable, guessable, not authentication. (Shapes in &lt;strong&gt;Evidence pack&lt;/strong&gt; below.)&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="detect-and-identify-detect-device-sh"&gt;
&lt;h3&gt;Detect and identify — &lt;tt class="docutils literal"&gt;detect_device.sh&lt;/tt&gt;&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Only after the wire told me enough — four screws out, faceplate off, USB in.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Inside: a prototype desk gadget — rough FDM 3D-printed enclosure with &lt;strong&gt;zero post-processing&lt;/strong&gt; (no dichloroethane smoothing, no shame, no industrial design), a 128×64 OLED, two tactile buttons, and a 24 GHz millimetric-wave radar module.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Faceplate lifted off — OLED cutout, two buttons, FDM layer lines in daylight" src="/assets/images/articles/iot/presence_hub/hardware_enclosure.jpg" style="width: 420px;" /&gt;
&lt;p class="caption"&gt;Four screws out — faceplate in hand, hollow box underneath. FDM honesty, no industrial design tax.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="ESP32 + mmWave radar stack — bare board, stock firmware still on flash" src="/assets/images/articles/iot/presence_hub/hardware_internals.jpg" style="width: 420px;" /&gt;
&lt;p class="caption"&gt;ESP32-D0WD, LD2410-class radar, corporate firmware still intact — bench photo from step 3 in the timeline above.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Finds the first USB serial port (CP2102, CH340, FTDI, …), runs esptool:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="c1"&gt;# scripts/service/detect_device.sh&lt;/span&gt;
find_port&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nv"&gt;ports&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;ls&lt;span class="w"&gt; &lt;/span&gt;/dev/cu.*&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;usb|uart|ch34|cp21|ftdi|wchusb&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$ports&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;head&lt;span class="w"&gt; &lt;/span&gt;-1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;find_port&lt;span class="k"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Detected port: &lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;
&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$ESPTOOL&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;chip_id
&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$ESPTOOL&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;flash_id
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Typical output: &lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;ESP32-D0WD-V3&lt;/span&gt;&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;Detected flash size: 4MB&lt;/tt&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="full-flash-dump-dump-firmware-sh"&gt;
&lt;h3&gt;Full flash dump — &lt;tt class="docutils literal"&gt;dump_firmware.sh&lt;/tt&gt;&lt;/h3&gt;
&lt;p&gt;Reads flash at &lt;strong&gt;921600 baud&lt;/strong&gt;, saves &lt;tt class="docutils literal"&gt;dumps/firmware_YYYYMMDD_HHMMSS.bin&lt;/tt&gt;, dumps partition table, parses entries:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="nv"&gt;FLASH_SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;esptool&lt;span class="w"&gt; &lt;/span&gt;flash_id&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-oE&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;[0-9]+MB&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;head&lt;span class="w"&gt; &lt;/span&gt;-1&lt;span class="k"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$FLASH_SIZE&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;4MB&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;SIZE_HEX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;0x400000&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;

esptool&lt;span class="w"&gt; &lt;/span&gt;--port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--baud&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;921600&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;read_flash&lt;span class="w"&gt; &lt;/span&gt;0x00000&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$SIZE_HEX&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;

esptool&lt;span class="w"&gt; &lt;/span&gt;--port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$PORT&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;read_flash&lt;span class="w"&gt; &lt;/span&gt;0x8000&lt;span class="w"&gt; &lt;/span&gt;0xC00&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$PART_FILE&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;

&lt;span class="c1"&gt;# Embedded Python walks 32-byte partition entries (magic 0xAA 0x50)&lt;/span&gt;
&lt;span class="c1"&gt;# Prints: Name, Type, Offset, Size → nvs, otadata, app0, spiffs, ...&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Restore stock firmware (for researchers): &lt;tt class="docutils literal"&gt;esptool write_flash 0x0 &lt;span class="pre"&gt;dumps/firmware_*.bin&lt;/span&gt;&lt;/tt&gt;&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Illustrative terminal — esptool partition table from stock flash dump" src="/assets/images/articles/iot/presence_hub/terminal_partition_table.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Illustrative &lt;tt class="docutils literal"&gt;dump_firmware.sh&lt;/tt&gt; output — typical ESP32 OTA layout (&lt;tt class="docutils literal"&gt;nvs&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;app0&lt;/tt&gt;/&lt;tt class="docutils literal"&gt;app1&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;spiffs&lt;/tt&gt;).&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="strings-archaeology"&gt;
&lt;h3&gt;Strings archaeology&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;strings&lt;span class="w"&gt; &lt;/span&gt;dumps/firmware_*.bin&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-iE&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;api\.|http|/v1/|Authorization|wifi|ssid&amp;#39;&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Yielded API paths, auth header placeholders, &lt;strong&gt;plaintext Wi-Fi SSIDs in NVS&lt;/strong&gt;. Convenient storage ≠ secrets vault.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="live-serial-monitor-monitor-serial-sh"&gt;
&lt;h3&gt;Live serial monitor — &lt;tt class="docutils literal"&gt;monitor_serial.sh&lt;/tt&gt;&lt;/h3&gt;
&lt;p&gt;Colorized Python harness — logs to &lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;logs/serial_*.log&lt;/span&gt;&lt;/tt&gt;, highlights &lt;tt class="docutils literal"&gt;fail&lt;/tt&gt; in red, HTTP/MQTT in cyan, Wi-Fi secrets in green:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="c1"&gt;# scripts/service/monitor_serial.sh (embedded Python)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;colorize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;error&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;fail&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;fault&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;panic&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;RED&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;RESET&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;http&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;mqtt&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;connect&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CYAN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;RESET&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;wifi&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;ssid&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;password&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;token&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;GREEN&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;RESET&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Stock boot (representative):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;WiFi connected: &amp;lt;SSID&amp;gt;
Firmware v1.0.7
Device name: Living Room SPT
Radar fail
HTTP POST 200
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Radar fail + HTTP 200&lt;/strong&gt; — dashboard «presence» while sensor blind. Presence Hub logs honestly.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Illustrative colorized serial monitor — Radar fail then HTTP 200" src="/assets/images/articles/iot/presence_hub/terminal_serial_boot.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Illustrative &lt;tt class="docutils literal"&gt;monitor_serial.sh&lt;/tt&gt; session — &lt;strong&gt;Radar fail&lt;/strong&gt; and &lt;strong&gt;HTTP 200&lt;/strong&gt; on the same boot (fictional SSID/MAC).&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="api-autopsy-when-curl-returns-200"&gt;
&lt;h3&gt;API autopsy — when &lt;tt class="docutils literal"&gt;curl&lt;/tt&gt; returns 200&lt;/h3&gt;
&lt;p&gt;First shock: not hacking — &lt;strong&gt;curl returning 200&lt;/strong&gt;.&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="35%" /&gt;
&lt;col width="65%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Finding&lt;/th&gt;
&lt;th class="head"&gt;Impact&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;POST /v1/track&lt;/tt&gt; without auth&lt;/td&gt;
&lt;td&gt;Write presence for &lt;strong&gt;any&lt;/strong&gt; MAC from the internet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OpenAPI at &lt;tt class="docutils literal"&gt;/openapi.json&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Full schema published&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;GET /v1/leaderboard&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Fleet nicknames — &lt;em&gt;Office SPT&lt;/em&gt;, &lt;em&gt;Main Desk&lt;/em&gt;, …&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Per-MAC history&lt;/td&gt;
&lt;td&gt;Desk patterns if you guess MAC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OTA on public S3&lt;/td&gt;
&lt;td&gt;Firmware bucket visible&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The open &lt;tt class="docutils literal"&gt;GET /v1/leaderboard&lt;/tt&gt; response listed my nickname at rank &lt;strong&gt;#2&lt;/strong&gt; in the fleet. Wellness washing as gamified surveillance.&lt;/p&gt;
&lt;p&gt;Employer dashboard (separate stack — API Gateway + Cognito SSO):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;ESP32 ──► vendor device API (telemetry, wide open)
              ▼
        HR dashboard («live monitor», «Belgrade» — from ``Europe/Belgrade`` TZ, not GPS)
              ▲
Employee browser ──► API Gateway + JWT
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Timezone-as-GPS:&lt;/strong&gt; no satellite — timezone string from NVS rendered as city. I live in &lt;strong&gt;Novi Sad&lt;/strong&gt;; the device only had &lt;tt class="docutils literal"&gt;Europe/Belgrade&lt;/tt&gt; in NVS. On &lt;strong&gt;my own&lt;/strong&gt; unit I changed the timezone to &lt;tt class="docutils literal"&gt;Asia/Pyongyang&lt;/tt&gt;; the dashboard obediently showed &lt;em&gt;Pyongyang Office&lt;/em&gt; — proof the «location» was configuration theatre, not telemetry.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Walking the holes:&lt;/strong&gt; The OpenAPI is not decoration — it is a map. &lt;strong&gt;Public fleet table&lt;/strong&gt; — nicknames and ranked seat-time for desks that are not yours. &lt;strong&gt;IDOR on employee IDs&lt;/strong&gt; — increment &lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;/employee/{id}&lt;/span&gt;&lt;/tt&gt; and receive display names, tracker MACs, timezone strings; no hardware guessing, no stolen browser session. &lt;strong&gt;MAC-bound history&lt;/strong&gt; — any address copied from the fleet table opens &lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;/v1/tracker/{mac}/history&lt;/span&gt;&lt;/tt&gt;. &lt;strong&gt;Unauthenticated write path&lt;/strong&gt; — &lt;tt class="docutils literal"&gt;POST /v1/track&lt;/tt&gt; accepts payloads for MACs you never paired. Ordinary HTTP tools against the same endpoints the devices use — not a secret exploit chain, not malware. That is the scandal: a product &lt;strong&gt;sold to enterprises&lt;/strong&gt; under «privacy», «security», and «personal boundaries» — wellness copy on the landing page — backed by a telemetry sink that treats &lt;strong&gt;any internet client as trusted&lt;/strong&gt;. For a home-office worker under EU-adequacy law, that is not a Tuesday patch; it is &lt;strong&gt;security theatre with your kitchen in frame&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The employer dashboard (API Gateway, Cognito SSO, JWT in the browser) is a &lt;strong&gt;second failure mode&lt;/strong&gt; on the same product story: HR «live monitor» marketed as the controlled, consent-aware surface — while the device API already published the fleet and accepted forged MACs. Garnish on an open buffet.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="appendix-evidence-pack-redacted"&gt;
&lt;h3&gt;Appendix — Evidence pack (redacted)&lt;/h3&gt;
&lt;p&gt;Representative shapes only — hosts and names removed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OpenAPI fragment&lt;/strong&gt; (public &lt;tt class="docutils literal"&gt;/openapi.json&lt;/tt&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;paths&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;/v1/track&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;post&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;security&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;/v1/tracker&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;post&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;security&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;/v1/leaderboard&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;get&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;security&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;/v1/tracker/{mac}/history&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;get&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Stock POST body&lt;/strong&gt; (device → cloud):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mac_address&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;XX:XX:XX:XX:XX:XX&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;s_energy&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;s_dist&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;m_energy&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;m_dist&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;presence_min&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Response:&lt;/strong&gt; &lt;tt class="docutils literal"&gt;HTTP/1.1 200 OK&lt;/tt&gt; — no &lt;tt class="docutils literal"&gt;Authorization&lt;/tt&gt; header required.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Leaderboard row&lt;/strong&gt; (redacted nicknames):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rank&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nickname&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Office ***&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;presence_min&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;412&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;rank&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;nickname&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Living ***&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;presence_min&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;287&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;IDOR employee record&lt;/strong&gt; (increment &lt;tt class="docutils literal"&gt;id&lt;/tt&gt; — fields only):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;display_name&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;[REDACTED]&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;tracker_mac&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;XX:XX:XX:XX:XX:XX&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;timezone&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;Europe/Belgrade&amp;quot;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="ESP32 on the bench — USB to laptop, enclosure shell beside the board" src="/assets/images/articles/iot/presence_hub/hardware_dev_setup.jpg" style="width: 640px;" /&gt;
&lt;p class="caption"&gt;Forensics bench: stock firmware out, esptool in, 3D-printed shell optional.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="part-3-enough-proof-then-i-left"&gt;
&lt;span id="part-3-enough-proof"&gt;&lt;/span&gt;&lt;h2&gt;Part 3: Enough Proof — Then I Left&lt;/h2&gt;
&lt;p&gt;I walked other people's records through the holes above — fleet nicknames, employee rows, MAC histories — enough to see the system could not tell my home office from a lab bench. Proving the write path on &lt;strong&gt;my&lt;/strong&gt; MAC alone was sufficient for the thesis; I did not vandalise anyone else's telemetry. Fake timezone, junk telemetry, HTTP 200. Same JSON schema, no &lt;tt class="docutils literal"&gt;Authorization&lt;/tt&gt; header. If the backend cannot tell garbage from a keyboard, it cannot tell an attacker from a colleague.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What I ship publicly is Presence Hub&lt;/strong&gt; — the replacement, not an attack kit.&lt;/p&gt;
&lt;p&gt;Then I &lt;strong&gt;severed my device from their cloud&lt;/strong&gt;, wiped the flash, and built firmware that answers only to my LAN.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="part-4-presence-hub-liberation-architecture"&gt;
&lt;span id="part-4-presence-hub"&gt;&lt;/span&gt;&lt;h2&gt;Part 4: Presence Hub — Liberation Architecture&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Ownership thesis in one line:&lt;/strong&gt; the ESP32 on my desk runs &lt;strong&gt;my&lt;/strong&gt; firmware, talks to &lt;strong&gt;my&lt;/strong&gt; Mac, stores &lt;strong&gt;my&lt;/strong&gt; logs. No fleet table. No employer view.&lt;/p&gt;
&lt;p&gt;I wiped flash and wrote &lt;strong&gt;Presence Hub&lt;/strong&gt;: ESP32 firmware (PlatformIO) + Python asyncio daemon on macOS. &lt;strong&gt;Zero vendor API. Zero employer dashboard.&lt;/strong&gt; My LAN, my SQLite, my rules.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Presence Hub on a desk — OLED clock, retro faceplate, USB power" src="/assets/images/articles/iot/presence_hub/device_real_world.png" style="width: 380px;" /&gt;
&lt;p class="caption"&gt;Same crooked enclosure — different firmware. Clock and mode badge from the Mac daemon, not the vendor cloud.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Presence Hub hardware — ESP32 stack with radar module" src="/assets/images/articles/iot/presence_hub/hardware_esp32_stack.jpg" style="width: 480px;" /&gt;
&lt;p class="caption"&gt;Same silicon. Different owner. GPIO map mine, MQTT topic mine.&lt;/p&gt;
&lt;/div&gt;
&lt;p&gt;Principles I carried from the anger:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;&lt;strong&gt;Local-first&lt;/strong&gt; — Mac is optional infrastructure, not a surveillance landlord&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Offline honesty&lt;/strong&gt; — LittleFS event log; no fake sync when hub sleeps&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Physics over marketing&lt;/strong&gt; — one radar axis; sleep charts say «estimate»&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Documented fallback&lt;/strong&gt; — heuristics before you train; weights in repo&lt;/li&gt;
&lt;/ul&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="22%" /&gt;
&lt;col width="39%" /&gt;
&lt;col width="39%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Vector&lt;/th&gt;
&lt;th class="head"&gt;Stock stack&lt;/th&gt;
&lt;th class="head"&gt;Presence Hub&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;Telemetry&lt;/td&gt;
&lt;td&gt;Vendor cloud API&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;MQTT on Mac&lt;/strong&gt; (&lt;tt class="docutils literal"&gt;hub/*&lt;/tt&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Dashboard&lt;/td&gt;
&lt;td&gt;Employer leaderboard&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SQLite + Chart.js&lt;/strong&gt; localhost&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Hub address&lt;/td&gt;
&lt;td&gt;Hard-coded / vendor DNS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;UDP discovery&lt;/strong&gt; — no fixed Mac IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;«AI wellness»&lt;/td&gt;
&lt;td&gt;Opaque thresholds&lt;/td&gt;
&lt;td&gt;Documented heuristics → &lt;strong&gt;TinyML&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OTA&lt;/td&gt;
&lt;td&gt;Vendor S3 bucket&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;LAN OTA&lt;/strong&gt; from your daemon&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Stack:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LD2410C → ESP32 firmware → MQTT (LAN) → Python daemon → SQLite / Web / Telegram
     ↓
OLED + buttons + LittleFS offline log
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Why MQTT on the Mac, not REST on the chip?&lt;/strong&gt;&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;Pub/sub fits radar streams, display push, config — no HTTP server on 320 KB RAM.&lt;/li&gt;
&lt;li&gt;&lt;tt class="docutils literal"&gt;PubSubClient&lt;/tt&gt; is small; stock FW already abused HTTPS on ESP32.&lt;/li&gt;
&lt;li&gt;&lt;tt class="docutils literal"&gt;mosquitto_sub&lt;/tt&gt; + sensor log UI = transparency by design.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; Mac asleep → Telegram/Spotify pause; ESP32 still journals and syncs later. No fake cloud illusion.&lt;/p&gt;
&lt;img alt="Presence Hub architecture — ESP32, MQTT, Mac daemon, SQLite" class="align-center" src="/assets/images/articles/iot/presence_hub/architecture.png" style="width: 700px;" /&gt;
&lt;p&gt;Figure: &lt;strong&gt;Presence Hub&lt;/strong&gt; — device firmware, UDP discovery, LAN MQTT, Python daemon, local SQLite. No vendor cloud in the loop.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="part-5-udp-discovery-dhcp-is-not-your-friend"&gt;
&lt;span id="part-5-udp-discovery"&gt;&lt;/span&gt;&lt;h2&gt;Part 5: UDP Discovery — DHCP Is Not Your Friend&lt;/h2&gt;
&lt;p&gt;Early builds hard-coded the Mac's LAN IP. DHCP reassigned it; MQTT died; I aged five years in one afternoon.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Current flow:&lt;/strong&gt;&lt;/p&gt;
&lt;ol class="arabic simple"&gt;
&lt;li&gt;ESP32 broadcasts &lt;tt class="docutils literal"&gt;PHUB_DISCOVER&lt;/tt&gt; on UDP port &lt;strong&gt;18832&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Mac daemon replies with JSON: &lt;tt class="docutils literal"&gt;mqtt_host&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;mqtt_port&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;ota_host&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;ota_port&lt;/tt&gt;.&lt;/li&gt;
&lt;li&gt;ESP32 caches endpoints in NVS (&lt;tt class="docutils literal"&gt;Preferences&lt;/tt&gt; namespace &lt;tt class="docutils literal"&gt;phub&lt;/tt&gt;).&lt;/li&gt;
&lt;li&gt;After &lt;strong&gt;3 failed MQTT connects&lt;/strong&gt;, cache clears and discovery runs again.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Firmware side (simplified):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;DISCOVER_MAGIC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;quot;PHUB_DISCOVER&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="n"&gt;udp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;beginPacket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IPAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DISCOVERY_PORT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;udp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;reinterpret_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DISCOVER_MAGIC&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;magicLen&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;udp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;endPacket&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Mac daemon (&lt;tt class="docutils literal"&gt;discovery.py&lt;/tt&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="n"&gt;DISCOVER_MAGIC&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;PHUB_DISCOVER&amp;quot;&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;datagram_received&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;DISCOVER_MAGIC&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="n"&gt;lan_ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;get_lan_ip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="s2"&gt;&amp;quot;mqtt_host&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lan_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;&amp;quot;mqtt_port&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;MQTT_PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;&amp;quot;ota_host&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lan_ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s2"&gt;&amp;quot;ota_port&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;OTA_PORT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sendto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;addr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Why not mDNS/Bonjour?&lt;/strong&gt; Fewer moving parts. Broadcast JSON is enough on home Wi-Fi. This is a desk gadget, not a service-mesh keynote.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why OTA?&lt;/strong&gt; The device lives on the desk; USB cables do not. The daemon serves &lt;tt class="docutils literal"&gt;firmware.bin&lt;/tt&gt; on &lt;tt class="docutils literal"&gt;:18081&lt;/tt&gt; after &lt;tt class="docutils literal"&gt;pio run&lt;/tt&gt;. Triggers: web dashboard, Telegram &lt;tt class="docutils literal"&gt;/update&lt;/tt&gt;, MQTT &lt;tt class="docutils literal"&gt;hub/ota/trigger&lt;/tt&gt;. Same trust model as discovery — LAN only, no vendor S3 bucket.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="part-6-gpio-archaeology-when-the-schematic-is-figure-it-out"&gt;
&lt;span id="part-6-gpio-archaeology"&gt;&lt;/span&gt;&lt;h2&gt;Part 6: GPIO Archaeology — When the Schematic Is «Figure It Out»&lt;/h2&gt;
&lt;p&gt;The carrier had &lt;strong&gt;no public pinout&lt;/strong&gt;. Product photos implied Minority Report; silkscreen implied «good luck».&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GPIO probe mode&lt;/strong&gt; — firmware scans candidate GPIOs, publishes &lt;tt class="docutils literal"&gt;hub/debug/gpio&lt;/tt&gt; on change + 5 s heartbeat (&lt;tt class="docutils literal"&gt;gpio_probe.cpp&lt;/tt&gt;). &lt;tt class="docutils literal"&gt;button_gpio_probe.sh&lt;/tt&gt; subscribes and prints which pin toggled when you press:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="c1"&gt;# scripts/button_gpio_probe.sh&lt;/span&gt;
mosquitto_sub&lt;span class="w"&gt; &lt;/span&gt;-h&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;127&lt;/span&gt;.0.0.1&lt;span class="w"&gt; &lt;/span&gt;-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;18830&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;hub/debug/gpio&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-v&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-r&lt;span class="w"&gt; &lt;/span&gt;topic&lt;span class="w"&gt; &lt;/span&gt;payload&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="nv"&gt;$payload&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3&lt;span class="w"&gt; &lt;/span&gt;-c&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;span class="s1"&gt;import json, sys&lt;/span&gt;
&lt;span class="s1"&gt;d = json.load(sys.stdin)&lt;/span&gt;
&lt;span class="s1"&gt;changed = d.get(&amp;quot;changed&amp;quot;, [])&lt;/span&gt;
&lt;span class="s1"&gt;if changed:&lt;/span&gt;
&lt;span class="s1"&gt;    pins = d.get(&amp;quot;pins&amp;quot;, {})&lt;/span&gt;
&lt;span class="s1"&gt;    print(&amp;quot;&amp;gt;&amp;gt;&amp;gt; CHANGED:&amp;quot;, &amp;quot;, &amp;quot;.join(f&amp;quot;GPIO {p}={pins.get(str(p))}&amp;quot; for p in changed))&lt;/span&gt;
&lt;span class="s1"&gt;&amp;#39;&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Confirmed after GPIO probe:&lt;/strong&gt; Btn1 = &lt;strong&gt;GPIO 18&lt;/strong&gt;, Btn2 = &lt;strong&gt;GPIO 5&lt;/strong&gt;, &lt;strong&gt;active LOW&lt;/strong&gt;. Radar UART &lt;strong&gt;16/17&lt;/strong&gt;, I2C &lt;strong&gt;21/22&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Runtime (&lt;tt class="docutils literal"&gt;buttons.cpp&lt;/tt&gt;): &lt;strong&gt;30 ms&lt;/strong&gt; debounce, &lt;strong&gt;800 ms&lt;/strong&gt; long press (session reset). Pin map is published on the retained MQTT topic &lt;tt class="docutils literal"&gt;hub/config&lt;/tt&gt; and persisted to NVS — no reflash to rewire GPIO.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Illustrative GPIO probe — Btn1 GPIO 18, Btn2 GPIO 5, active LOW" src="/assets/images/articles/iot/presence_hub/terminal_gpio_probe.png" style="width: 640px;" /&gt;
&lt;p class="caption"&gt;Illustrative &lt;tt class="docutils literal"&gt;button_gpio_probe.sh&lt;/tt&gt; session — &lt;strong&gt;GPIO 18 / 5&lt;/strong&gt;, active LOW.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="part-7-the-mac-daemon-asyncio-not-applescript-hell-okay-some-applescript"&gt;
&lt;span id="part-7-mac-daemon"&gt;&lt;/span&gt;&lt;h2&gt;Part 7: The Mac Daemon — asyncio, Not AppleScript Hell (Okay, Some AppleScript)&lt;/h2&gt;
&lt;p&gt;&lt;tt class="docutils literal"&gt;daemon/main.py&lt;/tt&gt; starts everything in one process:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="n"&gt;tasks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mqtt_loop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;mqtt&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;discovery_loop&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;discovery&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;display_loop&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;display&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;standup_loop&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;standup&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start_ota_server&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;ota&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run_web&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;web&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;div class="line-block"&gt;
&lt;div class="line"&gt;&lt;strong&gt;FastAPI&lt;/strong&gt; web UI on &lt;tt class="docutils literal"&gt;127.0.0.1:18080&lt;/tt&gt; — dashboard, settings, gesture calibration, sensor log, OLED layout editor.&lt;/div&gt;
&lt;div class="line"&gt;&lt;strong&gt;aiomqtt&lt;/strong&gt; subscriber routes &lt;tt class="docutils literal"&gt;hub/radar&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;hub/gesture&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;hub/button&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;hub/sync/events&lt;/tt&gt;, etc.&lt;/div&gt;
&lt;div class="line"&gt;&lt;strong&gt;SQLite&lt;/strong&gt; stores sessions, radar samples, AI state transitions, sleep nights.&lt;/div&gt;
&lt;div class="line"&gt;Optional &lt;strong&gt;Telegram bot&lt;/strong&gt; for standup nudges and morning sleep summary.&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;tt class="docutils literal"&gt;HubDaemon.handle_message&lt;/tt&gt; is the central switchboard — radar samples fan out to mode handlers:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;topic&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;TOPIC_RADAR&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;insert_radar_sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;WORK_TRACKING_MODES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;work&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_radar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;sleep&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleep&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_radar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;media&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;media&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_radar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Work mode presence logic lives on the &lt;strong&gt;daemon&lt;/strong&gt;, not the firmware — change standup interval without reflash. Away ≥4 of last 5 minutes → standup timer resets (radar flicker). Long press btn1 → manual reset.&lt;/p&gt;
&lt;p&gt;Offline sync (&lt;tt class="docutils literal"&gt;sync.py&lt;/tt&gt;) — daemon replays LittleFS batch, dedups by event ID:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_skip_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;get_last_event_id&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;etype&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;presence&amp;quot;&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;mode&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;WORK_TRACKING_MODES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;present&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;present&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;start_session_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;work&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;present&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;end_session_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;NTP on ESP32 before timestamps; &lt;tt class="docutils literal"&gt;hub/sync/ack&lt;/tt&gt; clears device buffer after successful replay.&lt;/p&gt;
&lt;p&gt;Run everything:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;./daemon/run.sh&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# mosquitto + web :18080 + MQTT :18830 + discovery :18832&lt;/span&gt;
./scripts/flash_firmware.sh
./scripts/install_launchd.sh&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# optional Mac login autostart&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Presence Hub web dashboard on macOS — work mode, 7-day chart, local FastAPI UI" src="/assets/images/articles/iot/presence_hub/hub_dashboard.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Local dashboard at &lt;tt class="docutils literal"&gt;127.0.0.1:18080&lt;/tt&gt; — illustrative demo data; no vendor cloud.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Presence Hub sensor log — radar and gesture debug stream" src="/assets/images/articles/iot/presence_hub/hub_sensor_log.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Sensor log — same LAN, same SQLite, same honesty policy as the charts.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="OLED layout editor — widgets, fonts, brightness, sleep display mode" src="/assets/images/articles/iot/presence_hub/hub_display.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Display editor — &lt;tt class="docutils literal"&gt;display.html&lt;/tt&gt;. &lt;em&gt;Screenshot uses seeded demo data from&lt;/em&gt; &lt;tt class="docutils literal"&gt;render_hub_screenshots.py&lt;/tt&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Hub settings — radar gates, standup interval, Edge AI, Spotify backend" src="/assets/images/articles/iot/presence_hub/hub_settings.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Settings page — radar, standup, TinyML globals, media backend. &lt;em&gt;Illustrative demo session, not live desk stats.&lt;/em&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="spotify-gestures-on-chip-policy-on-mac"&gt;
&lt;h3&gt;Spotify — gestures on chip, policy on Mac&lt;/h3&gt;
&lt;p&gt;The LD2410 is &lt;strong&gt;one-dimensional&lt;/strong&gt;: distance along the beam, not Minority Report hand poses. Controlling Spotify with OAuth on the ESP32 would be absurd (TLS + token refresh on 320 KB RAM).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Media mode&lt;/strong&gt; publishes &lt;tt class="docutils literal"&gt;hub/gesture&lt;/tt&gt;; &lt;tt class="docutils literal"&gt;MediaController&lt;/tt&gt; runs AppleScript on the Mac:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_next_track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;backend&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;spotify&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_osascript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="s1"&gt;&amp;#39;tell application &amp;quot;Spotify&amp;quot; to play next track&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;fallback_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;124&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_media_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;124&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Track metadata (artist, title, position) flows back to the OLED via &lt;tt class="docutils literal"&gt;display_engine&lt;/tt&gt; → &lt;tt class="docutils literal"&gt;hub/display&lt;/tt&gt;. Alternative backend: &lt;strong&gt;system media keys&lt;/strong&gt; via System Events, with optional &lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;nowplaying-cli&lt;/span&gt;&lt;/tt&gt; fallback.&lt;/p&gt;
&lt;p&gt;Honest limits: Spotify must be installed; &lt;tt class="docutils literal"&gt;prev&lt;/tt&gt; and hover-volume need TinyML calibration — zone-hold &lt;strong&gt;next&lt;/strong&gt; is the production baseline.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Zone-hold gesture — near-zone hand hold, Spotify next track, OLED track line updates" src="/assets/images/articles/iot/presence_hub/gesture_spotify.gif" style="width: 420px;" /&gt;
&lt;p class="caption"&gt;Real desk footage — hold in the near zone (~12–28 cm), Spotify skips track, OLED scrolls metadata from the Mac daemon.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="display-engine-spotify-on-the-oled"&gt;
&lt;h3&gt;Display engine — Spotify on the OLED&lt;/h3&gt;
&lt;p&gt;&lt;tt class="docutils literal"&gt;DisplayEngine&lt;/tt&gt; builds widget lines every second; &lt;tt class="docutils literal"&gt;display_loop&lt;/tt&gt; publishes to &lt;tt class="docutils literal"&gt;hub/display&lt;/tt&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="c1"&gt;# media mode — track widget from AppleScript metadata&lt;/span&gt;
&lt;span class="n"&gt;track&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;media&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;format_track_display&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# &amp;quot;Artist - Title  1:23/4:05&amp;quot;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;pos&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;text&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;track&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;font&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;medium&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;quot;center&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Flow: gesture → &lt;tt class="docutils literal"&gt;MediaController.on_gesture&lt;/tt&gt; → AppleScript → &lt;tt class="docutils literal"&gt;refresh_track&lt;/tt&gt; → &lt;tt class="docutils literal"&gt;display_engine&lt;/tt&gt; → MQTT → firmware renders U8g2 text. &lt;strong&gt;Policy on Mac, pixels on ESP32.&lt;/strong&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="telegram-bot-sample-session"&gt;
&lt;h3&gt;Telegram bot — sample session&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;You: /status
Bot: Mode: work
     Online: yes
     Present: True
     AI state: active_focus
     Session: 2h 14m

You: /today
Bot: Today at desk: 5h 32m
     Fatigue hints (AI): 2

You: /update
Bot: OTA triggered: http://192.168.1.42:18081/firmware.bin
&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="standup-logic-on-the-daemon-work-py"&gt;
&lt;h3&gt;Standup logic on the daemon (&lt;tt class="docutils literal"&gt;work.py&lt;/tt&gt;)&lt;/h3&gt;
&lt;p&gt;Why &lt;strong&gt;4 of last 5 minutes absent&lt;/strong&gt; resets the standup timer: radar flicker from fans or posture shifts should not spam stretch reminders. The firmware only reports physics; &lt;strong&gt;policy lives on the Mac&lt;/strong&gt; so I can tune intervals in Settings without reflash:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_minute_had_presence&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now_minute&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;absent_minutes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;absent_minutes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_standup_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# reset standup clock&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="part-8-firmware-modes-mqtt-offline-journal"&gt;
&lt;span id="part-8-firmware"&gt;&lt;/span&gt;&lt;h2&gt;Part 8: Firmware — Modes, MQTT, Offline Journal&lt;/h2&gt;
&lt;p&gt;Three modes, one radar, zero cloud:&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="33%" /&gt;
&lt;col width="33%" /&gt;
&lt;col width="33%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Mode&lt;/th&gt;
&lt;th class="head"&gt;Button 2&lt;/th&gt;
&lt;th class="head"&gt;Button 1&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;work&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;cycle mode&lt;/td&gt;
&lt;td&gt;short: pause; long: reset session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;sleep&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;→ media&lt;/td&gt;
&lt;td&gt;btn1: went to bed; btn2: woke up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;media&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;cycle mode&lt;/td&gt;
&lt;td&gt;gestures + work log continues&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Offline buffer:&lt;/strong&gt; ESP32 &lt;strong&gt;always&lt;/strong&gt; appends events to LittleFS — presence, mode changes, buttons, gestures, sleep markers — even when the Mac is off. After Wi-Fi: NTP for real timestamps, UDP discovery, MQTT connect, batched &lt;tt class="docutils literal"&gt;hub/sync/events&lt;/tt&gt;, daemon dedup by event ID, &lt;tt class="docutils literal"&gt;hub/sync/ack&lt;/tt&gt;, buffer cleared.&lt;/p&gt;
&lt;p&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;EventLog::append&lt;/span&gt;&lt;/tt&gt; writes JSON lines:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;ts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// data object serialized into LittleFS&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Display uses &lt;strong&gt;U8g2&lt;/strong&gt;, not LVGL — a 128×64 OLED does not need a scene graph; flash budget goes to radar + ML. Web layout editor pushes widget strings via MQTT; firmware only renders text.&lt;/p&gt;
&lt;p&gt;Key MQTT topics:&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="30%" /&gt;
&lt;col width="70%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Topic&lt;/th&gt;
&lt;th class="head"&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/radar&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Extended radar + optional &lt;tt class="docutils literal"&gt;ai_state&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;ai_confidence&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/gesture&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;{type, value}&lt;/tt&gt; → Mac media control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/display&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Daemon → OLED widget lines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/config&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Retained settings → ESP32 NVS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/sync/events&lt;/tt&gt; / &lt;tt class="docutils literal"&gt;hub/sync/ack&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Offline replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/radar/raw&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Full gate arrays (recording mode only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;div class="section" id="offline-sync-mac-asleep-for-8-hours"&gt;
&lt;h3&gt;Offline sync — Mac asleep for 8 hours&lt;/h3&gt;
&lt;p&gt;Mini case study: laptop closed overnight, ESP32 still powered. Events append to &lt;tt class="docutils literal"&gt;/events.jsonl&lt;/tt&gt; on LittleFS. Morning: Wi-Fi → NTP → UDP discovery → MQTT → batch sync → SQLite → ack → buffer cleared.&lt;/p&gt;
&lt;img alt="Offline sync sequence — LittleFS to SQLite" class="align-center" src="/assets/images/articles/iot/presence_hub/offline_sync.png" style="width: 650px;" /&gt;
&lt;p&gt;&lt;strong&gt;LittleFS line&lt;/strong&gt; (representative):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;ts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1719751200.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;presence&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;work&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;present&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1043&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;ts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1719754800.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;gesture&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;media&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;next&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;``hub/sync/events`` batch&lt;/strong&gt; (ESP32 → Mac):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;events&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1042&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;ts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1719751200.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;presence&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;work&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;present&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;id&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1043&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;ts&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1719754800.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;gesture&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;mode&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;media&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;data&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;type&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;quot;next&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;&amp;quot;value&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;``hub/sync/ack``:&lt;/strong&gt; &lt;tt class="docutils literal"&gt;{&amp;quot;ack_id&amp;quot;: 1043}&lt;/tt&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="ota-over-lan"&gt;
&lt;h3&gt;OTA over LAN&lt;/h3&gt;
&lt;p&gt;Typical serial log after &lt;tt class="docutils literal"&gt;pio run&lt;/tt&gt; + Telegram &lt;tt class="docutils literal"&gt;/update&lt;/tt&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;OTA from http://192.168.1.42:18081/firmware.bin
OTA OK, rebooting
Firmware v0.6.0
Radar ready
MQTT connected
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;tt class="docutils literal"&gt;ota.cpp&lt;/tt&gt; uses ESP32 &lt;tt class="docutils literal"&gt;HTTPUpdate&lt;/tt&gt; — same trust model as discovery (LAN HTTP, no vendor S3).&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="part-9-radar-101-what-the-ld2410-actually-sees"&gt;
&lt;span id="part-9-radar-101"&gt;&lt;/span&gt;&lt;h2&gt;Part 9: Radar 101 — What the LD2410 Actually Sees&lt;/h2&gt;
&lt;p&gt;Before gestures and TinyML: &lt;strong&gt;physics&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The HLK-LD2410C is a &lt;strong&gt;24 GHz FMCW&lt;/strong&gt; presence sensor. In &lt;strong&gt;enhanced mode&lt;/strong&gt; (what we use) it exposes:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;&lt;strong&gt;Distance&lt;/strong&gt; to dominant target (cm)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Moving&lt;/strong&gt; and &lt;strong&gt;stationary&lt;/strong&gt; energy per distance &lt;strong&gt;gate&lt;/strong&gt; (typically 9 gates × ~20–75 cm steps)&lt;/li&gt;
&lt;li&gt;Boolean &lt;tt class="docutils literal"&gt;present&lt;/tt&gt; / &lt;tt class="docutils literal"&gt;moving&lt;/tt&gt; — useful but &lt;strong&gt;not authoritative&lt;/strong&gt; in the near field&lt;/li&gt;
&lt;/ul&gt;
&lt;img alt="LD2410 single-beam gates — 1D only" class="align-center" src="/assets/images/articles/iot/presence_hub/ld2410_gates.png" style="width: 600px;" /&gt;
&lt;p&gt;&lt;strong&gt;Enhanced vs basic:&lt;/strong&gt; enhanced gives per-gate arrays (&lt;tt class="docutils literal"&gt;moving_gates[]&lt;/tt&gt;, &lt;tt class="docutils literal"&gt;stationary_gates[]&lt;/tt&gt;) for ML and fan-noise filtering; basic mode is coarser distance + single energy — fine for a light switch, not for «focus scoring.»&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why ``present=false`` with valid ``dist``:&lt;/strong&gt; near-field multipath (monitor stand, desk edge) decouples the vendor boolean from geometry. Presence Hub keys gestures off &lt;strong&gt;distance in zone&lt;/strong&gt;, not &lt;tt class="docutils literal"&gt;present&lt;/tt&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Desk fan false positive:&lt;/strong&gt; moving blades pump energy into far gates with weak distance stability → rules-only «someone is here» breaks; TinyML class &lt;strong&gt;env_noise&lt;/strong&gt; targets exactly this pattern (high &lt;tt class="docutils literal"&gt;m_energy&lt;/tt&gt; variance, low &lt;tt class="docutils literal"&gt;s_energy&lt;/tt&gt; variance).&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="part-10-gestures-why-i-fought-physics-for-days-not-weeks-i-swear"&gt;
&lt;span id="part-10-gestures"&gt;&lt;/span&gt;&lt;h2&gt;Part 10: Gestures — Why I Fought Physics for Days (Not Weeks, I Swear)&lt;/h2&gt;
&lt;p&gt;The vendor's «Revolutionary Edge AI» was &lt;strong&gt;hardcoded threshold comparisons&lt;/strong&gt;. Desk fan? False positive. Curtain? False positive. Their Medium article showed hands gliding through air like a conductor at the Philharmonic; the LD2410 is a &lt;strong&gt;single-beam ranger&lt;/strong&gt; — one distance axis, per-gate moving/stationary energy. &lt;strong&gt;No left/right. No 3D pose.&lt;/strong&gt; Product photography lied; physics did not. It &lt;em&gt;felt&lt;/em&gt; like weeks because every «almost works» session ended with me staring at a distance graph at 1 a.m. In calendar time it was &lt;strong&gt;days&lt;/strong&gt; — still long enough to develop opinions about multipath and a grudge against monitor stands.&lt;/p&gt;
&lt;div class="section" id="what-the-sensor-actually-gives-you"&gt;
&lt;h3&gt;What the sensor actually gives you&lt;/h3&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;Distance along the radar line of sight (~20 cm gate steps in gesture profile)&lt;/li&gt;
&lt;li&gt;Useful near field at desk: often &lt;strong&gt;12–35 cm&lt;/strong&gt;, heavily quantized&lt;/li&gt;
&lt;li&gt;&lt;tt class="docutils literal"&gt;present&lt;/tt&gt; boolean often &lt;strong&gt;wrong&lt;/strong&gt; in near field — distance is the honest signal&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="section" id="what-i-planned-vs-what-shipped"&gt;
&lt;h3&gt;What I planned vs what shipped&lt;/h3&gt;
&lt;p&gt;Ambition: Minority Report. Reality: a ruler that sometimes lies about how far your knuckles are.&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="22%" /&gt;
&lt;col width="35%" /&gt;
&lt;col width="43%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Gesture&lt;/th&gt;
&lt;th class="head"&gt;Idea&lt;/th&gt;
&lt;th class="head"&gt;Outcome&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;Swipe &lt;strong&gt;in&lt;/strong&gt; → next&lt;/td&gt;
&lt;td&gt;Fast approach (Δdistance/Δt)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Hard&lt;/strong&gt; — velocity is noisy 1D mush&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Swipe &lt;strong&gt;out&lt;/strong&gt; → prev&lt;/td&gt;
&lt;td&gt;Recede&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Harder&lt;/strong&gt; — hand leaves beam faster; multipath fakes «approach»&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Hover&lt;/strong&gt; → volume&lt;/td&gt;
&lt;td&gt;Stable distance → level&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Medium&lt;/strong&gt; — fan multipath breaks variance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Zone hold&lt;/strong&gt; → next&lt;/td&gt;
&lt;td&gt;Hand in 12–28 cm for 400 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Reliable&lt;/strong&gt; — one bit of geometry + time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;After a few days of tuning (and several nights of «one more gate»), &lt;strong&gt;v1 shipped zone-hold → next only&lt;/strong&gt;. Honest UX: treat &lt;strong&gt;next&lt;/strong&gt; as production; &lt;strong&gt;prev/vol&lt;/strong&gt; as «enable after calibration session — and maybe bring snacks.»&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="why-prev-still-feels-broken-even-in-tinyml-branch"&gt;
&lt;h3&gt;Why &lt;tt class="docutils literal"&gt;prev&lt;/tt&gt; still feels broken (even in TinyML branch)&lt;/h3&gt;
&lt;p&gt;Physics is not symmetric; your hand leaving the beam does not owe you a clean retreat signal. Four reasons the «previous track» gesture remains a diva:&lt;/p&gt;
&lt;ol class="arabic simple"&gt;
&lt;li&gt;&lt;strong&gt;Symmetric noise&lt;/strong&gt; — retreat is weaker than approach (hand exits beam quickly).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multipath&lt;/strong&gt; — monitor stand / desk edge creates fake approach more than retreat.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Default ML weights are heuristic&lt;/strong&gt;, not trained on your desk.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Global debounce&lt;/strong&gt; — rapid next→prev can lose.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Media radar tuning (&lt;tt class="docutils literal"&gt;radar.cpp&lt;/tt&gt;): gesture profile → &lt;strong&gt;20 cm gates&lt;/strong&gt;, max gate &lt;strong&gt;2&lt;/strong&gt;, &lt;strong&gt;8× poll/loop&lt;/strong&gt;, &lt;strong&gt;200 ms&lt;/strong&gt; MQTT — temporal resolution for hold detection.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="zone-hold-state-machine-actual-firmware-media-mode-cpp"&gt;
&lt;h3&gt;Zone-hold state machine — actual firmware (&lt;tt class="docutils literal"&gt;media_mode.cpp&lt;/tt&gt;)&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;MediaMode::fallbackZoneHold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;RadarReading&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GestureCallback&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;uint16_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gestureDist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;inZone&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;inNearZone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;// default 12–28 cm&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inZone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;zoneArmed_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// re-arm on exit — critical&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;zoneEnterMs_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;zoneArmed_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zoneEnterMs_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;zoneEnterMs_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;zoneEnterMs_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;holdMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;// 400 ms hold&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lastNextMs_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debounceMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;// 1200 ms debounce&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;&amp;quot;next&amp;quot;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;static_cast&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;TinyML path calls &lt;tt class="docutils literal"&gt;handleMlGesture&lt;/tt&gt; first; if confidence low, &lt;strong&gt;fallback zone-hold still runs&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;MediaMode::onRadar&lt;/span&gt;&lt;span class="p"&gt;(...,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TinyMlResult&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AiState&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;GestureNone&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;handleMlGesture&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ai&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;fallbackZoneHold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;// always available&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Debug: &lt;tt class="docutils literal"&gt;gestures.html&lt;/tt&gt; — live dist bar, zone overlay, hold countdown. MQTT &lt;tt class="docutils literal"&gt;hub/debug/gesture&lt;/tt&gt; when debug on.&lt;/p&gt;
&lt;div class="figure align-center"&gt;
&lt;img alt="Media gestures page — near zone 12–28 cm, zone hold next track, TinyML toggles" src="/assets/images/articles/iot/presence_hub/hub_gestures.png" style="width: 720px;" /&gt;
&lt;p class="caption"&gt;Gesture calibration UI — distance bar, zone hold, ML toggles. &lt;em&gt;Seeded demo session from&lt;/em&gt; &lt;tt class="docutils literal"&gt;render_hub_screenshots.py&lt;/tt&gt; &lt;em&gt;— same UI, fake radar stream.&lt;/em&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="part-11-honest-tinyml-not-the-medium-article"&gt;
&lt;span id="part-11-tinyml"&gt;&lt;/span&gt;&lt;h2&gt;Part 11: Honest TinyML — Not the Medium Article&lt;/h2&gt;
&lt;p&gt;(Vendor «edge AI» marketing vs measured reality: &lt;a class="reference internal" href="#part-0-5-power-strip-lecture"&gt;Part 0.5 — Power Strip Lecture&lt;/a&gt; pull-quote.)&lt;/p&gt;
&lt;p&gt;I added TinyML (firmware &lt;strong&gt;0.6.0&lt;/strong&gt;, &lt;tt class="docutils literal"&gt;feat/tinyml&lt;/tt&gt;) because &lt;strong&gt;rules-only hit walls after liberation&lt;/strong&gt;:&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="28%" /&gt;
&lt;col width="36%" /&gt;
&lt;col width="36%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Problem&lt;/th&gt;
&lt;th class="head"&gt;Rules only&lt;/th&gt;
&lt;th class="head"&gt;TinyML&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;Desk presence&lt;/td&gt;
&lt;td&gt;Distance threshold&lt;/td&gt;
&lt;td&gt;active / fatigued / vacant / &lt;strong&gt;env_noise&lt;/strong&gt; (fan filter)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gestures&lt;/td&gt;
&lt;td&gt;Zone-hold → next&lt;/td&gt;
&lt;td&gt;next / prev / hover from velocity + stability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sleep&lt;/td&gt;
&lt;td&gt;Fixed &lt;tt class="docutils literal"&gt;s_energy&lt;/tt&gt; threshold&lt;/td&gt;
&lt;td&gt;breathing_stable / restless / absent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Host bug&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;s_energy&lt;/tt&gt; missing from MQTT (0.5.x)&lt;/td&gt;
&lt;td&gt;Fixed pipeline + breath-rate estimate on Mac&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;What I still refuse to claim:&lt;/strong&gt; clinical sleep staging, 3D air gestures, ChatGPT-on-chip.&lt;/p&gt;
&lt;div class="section" id="why-inline-int8-mlp-not-tensorflow-lite-micro"&gt;
&lt;h3&gt;Why inline INT8 MLP — not TensorFlow Lite Micro?&lt;/h3&gt;
&lt;p&gt;The liberated stack already lived in &lt;strong&gt;Arduino / PlatformIO&lt;/strong&gt;. TFLite Micro + ESP-NN would cost flash/RAM for marginal gain on a problem that fits a &lt;strong&gt;single hidden-layer MLP&lt;/strong&gt;. My implementation: &lt;strong&gt;~15–40 KB&lt;/strong&gt; weights in &lt;tt class="docutils literal"&gt;model_data.h&lt;/tt&gt;, &lt;strong&gt;&amp;lt;5 ms&lt;/strong&gt; inference inline in &lt;tt class="docutils literal"&gt;loop()&lt;/tt&gt; — no arena allocator, no dependency hell, full source in the repo. That is the kind of edge AI I can explain line-by-line on a bus ride.&lt;/p&gt;
&lt;p&gt;Pipeline:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;LD2410C UART → RadarReading (+ 9+9 gate energies)
     → FeatureBuffer (32 frames)
     → 16 features (variance, velocity, ZCR, gate centroid, cross-correlation, …)
     → INT8 MLP (model_data.h)
     → AiState + confidence
     → work / sleep / media handlers + MQTT hub/ai/state
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Mac daemon does NOT run real-time inference&lt;/strong&gt; — logs states, standup on fatigue, Spotify on gestures. Training is &lt;strong&gt;offline on Mac&lt;/strong&gt;; chip runs inference only. Privacy + CPU budget.&lt;/p&gt;
&lt;p&gt;16 features — interpretable, debuggable in sensor log (mirrors &lt;tt class="docutils literal"&gt;tools/ml/features.py&lt;/tt&gt;):&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="8%" /&gt;
&lt;col width="28%" /&gt;
&lt;col width="64%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;f#&lt;/th&gt;
&lt;th class="head"&gt;Name (concept)&lt;/th&gt;
&lt;th class="head"&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;f0&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;mean_s_energy&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Average stationary energy in window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f1&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;var_s_energy&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Stationary variance — stillness vs fan jitter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f2&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;mean_m_energy&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Average moving energy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f3&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;var_m_energy&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Moving variance — env_noise discriminator&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f4&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;mean_dist&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Mean distance (cm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f5&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;dist_velocity&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Δdistance/Δt (cm/s) — swipe classes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f6&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;peak_m_gate&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Gate index with max moving energy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f7&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;peak_s_gate&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Gate index with max stationary energy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f8&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;m_gate_centroid&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Energy-weighted moving gate center&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f9&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;zcr_s&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Zero-crossing rate on stationary energy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f10&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;cross_corr_ms&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Moving/stationary correlation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f11&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;present_ratio&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Fraction of frames with presence flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f12&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;moving_ratio&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Fraction of frames with moving flag&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f13&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;max_s_energy&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Peak stationary energy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f14&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;dist_std&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Distance stability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;f15&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;spectral_centroid_s&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;Stationary energy vs gate index&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Recording mode CSV&lt;/strong&gt; (&lt;tt class="docutils literal"&gt;hub/radar/raw&lt;/tt&gt; → &lt;tt class="docutils literal"&gt;collect.py&lt;/tt&gt;, 5 rows):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;ts,dist,s_energy,m_energy,presence,moving,moving_gates_0,...
1719751200.12,84,18,4,1,0,2,1,0,0,0,0,0,0,0
1719751200.62,83,19,3,1,0,2,1,0,0,0,0,0,0,0
1719751201.12,85,17,5,1,0,1,2,0,0,0,0,0,0,0
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;&lt;strong&gt;Rules vs ML — desk fan scenario:&lt;/strong&gt;&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="30%" /&gt;
&lt;col width="35%" /&gt;
&lt;col width="35%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Condition&lt;/th&gt;
&lt;th class="head"&gt;Rules-only&lt;/th&gt;
&lt;th class="head"&gt;TinyML (work head)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;Fan on, chair empty&lt;/td&gt;
&lt;td&gt;Often &lt;tt class="docutils literal"&gt;present=true&lt;/tt&gt; (false positive)&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;env_noise&lt;/tt&gt; or &lt;tt class="docutils literal"&gt;vacant&lt;/tt&gt; after train&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seated, typing&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;active_focus&lt;/tt&gt; if thresholds lucky&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;active_focus&lt;/tt&gt; stable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Seated, frozen 45+ min&lt;/td&gt;
&lt;td&gt;May still show present&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;static_fatigue&lt;/tt&gt; → stretch hint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;Build budget&lt;/strong&gt; (&lt;tt class="docutils literal"&gt;pio run&lt;/tt&gt; — ESP32-D0WD, firmware 0.6.0): flash &lt;strong&gt;~76%&lt;/strong&gt; of 4 MB partition; RAM &lt;strong&gt;~18%&lt;/strong&gt; at runtime with ML enabled; inference &lt;strong&gt;&amp;lt;5 ms&lt;/strong&gt; per 500 ms loop — no TFLite Micro arena.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;meanS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;distVelocity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="c1"&gt;// ... see tiny_ml.cpp extractFeatures()&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;INT8 forward pass:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;classCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kt"&gt;int32_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bias&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;inputCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;inputCount&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int32_t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;features&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// argmax → state; confidence from score margin&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Below &lt;tt class="docutils literal"&gt;confidenceMin&lt;/tt&gt; → &lt;strong&gt;heuristic fallback&lt;/strong&gt; (device usable day-one).&lt;/p&gt;
&lt;p&gt;Export — &lt;tt class="docutils literal"&gt;tools/ml/export_model.py&lt;/tt&gt; quantizes sklearn &lt;tt class="docutils literal"&gt;MLPClassifier&lt;/tt&gt; to C arrays:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;quantize_matrix&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matrix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;32.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;clipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matrix&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;clipped&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;

&lt;span class="c1"&gt;# → static const int8_t WORK_WEIGHTS[...] in model_data.h&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="training-workflow"&gt;
&lt;h3&gt;Training workflow&lt;/h3&gt;
&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;span class="nb"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;tools/ml&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pip&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;-r&lt;span class="w"&gt; &lt;/span&gt;requirements.txt

&lt;span class="c1"&gt;# Web Settings → Edge AI → Recording mode ON&lt;/span&gt;
python&lt;span class="w"&gt; &lt;/span&gt;collect.py&lt;span class="w"&gt; &lt;/span&gt;--output&lt;span class="w"&gt; &lt;/span&gt;../../datasets/raw.csv
python&lt;span class="w"&gt; &lt;/span&gt;label.py&lt;span class="w"&gt; &lt;/span&gt;../../datasets/raw.csv&lt;span class="w"&gt; &lt;/span&gt;--events-json&lt;span class="w"&gt; &lt;/span&gt;events.json&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;../../datasets/labeled.csv
python&lt;span class="w"&gt; &lt;/span&gt;train_presence.py&lt;span class="w"&gt; &lt;/span&gt;../../datasets/labeled.csv&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;models/presence_mlp.joblib
python&lt;span class="w"&gt; &lt;/span&gt;train_gesture.py&lt;span class="w"&gt; &lt;/span&gt;../../datasets/labeled.csv&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;models/gesture_mlp.joblib
python&lt;span class="w"&gt; &lt;/span&gt;export_model.py&lt;span class="w"&gt; &lt;/span&gt;--work-model&lt;span class="w"&gt; &lt;/span&gt;models/presence_mlp.joblib&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;--gesture-model&lt;span class="w"&gt; &lt;/span&gt;models/gesture_mlp.joblib
&lt;span class="nb"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;../../firmware&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;pio&lt;span class="w"&gt; &lt;/span&gt;run&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# then OTA from daemon :18081&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;Resource budget (ESP32-D0WD): &lt;strong&gt;15–40 KB&lt;/strong&gt; flash (models), &lt;strong&gt;8–20 KB&lt;/strong&gt; RAM, &lt;strong&gt;&amp;lt;5 ms&lt;/strong&gt; &amp;#64; 500 ms period.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Honest limits:&lt;/strong&gt;&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;Not medical. Not EEG. Not left/right hands.&lt;/li&gt;
&lt;li&gt;Models improve only with &lt;strong&gt;your&lt;/strong&gt; CSV from &lt;strong&gt;your&lt;/strong&gt; desk layout, fan, monitor stand.&lt;/li&gt;
&lt;li&gt;Default &lt;tt class="docutils literal"&gt;model_data.h&lt;/tt&gt; = heuristic weights until you train.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Future I did not ship (see &lt;tt class="docutils literal"&gt;ADVANCED_AI_ROADMAP.md&lt;/tt&gt;): 1D-CNN on gate tensor, TFLite if flash tax worth it, gesture calibration wizard, Linux hub with MPRIS instead of AppleScript.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div class="section" id="appendix-a-mqtt-payload-cookbook"&gt;
&lt;span id="appendix-a-mqtt"&gt;&lt;/span&gt;&lt;h2&gt;Appendix A — MQTT payload cookbook&lt;/h2&gt;
&lt;p&gt;Copy-paste shapes from &lt;tt class="docutils literal"&gt;mqtt_client.cpp&lt;/tt&gt; / daemon — subscribe with &lt;tt class="docutils literal"&gt;mosquitto_sub &lt;span class="pre"&gt;-h&lt;/span&gt; 127.0.0.1 &lt;span class="pre"&gt;-p&lt;/span&gt; 18830 &lt;span class="pre"&gt;-t&lt;/span&gt; &lt;span class="pre"&gt;'hub/#'&lt;/span&gt; &lt;span class="pre"&gt;-v&lt;/span&gt;&lt;/tt&gt;.&lt;/p&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="28%" /&gt;
&lt;col width="72%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Topic&lt;/th&gt;
&lt;th class="head"&gt;Example payload&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/radar&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;dist&amp;quot;:84,&amp;quot;s_energy&amp;quot;:18,&amp;quot;m_energy&amp;quot;:4,&amp;quot;presence&amp;quot;:true,&amp;quot;moving&amp;quot;:false,&amp;quot;ai_state&amp;quot;:1,&amp;quot;ai_confidence&amp;quot;:78,&amp;quot;ts&amp;quot;:1719751200.5}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/ai/state&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;mode&amp;quot;:&amp;quot;work&amp;quot;,&amp;quot;state&amp;quot;:&amp;quot;active_focus&amp;quot;,&amp;quot;confidence&amp;quot;:78,&amp;quot;ts&amp;quot;:1719751200.5}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/gesture&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;type&amp;quot;:&amp;quot;next&amp;quot;,&amp;quot;value&amp;quot;:22,&amp;quot;eid&amp;quot;:1043,&amp;quot;ts&amp;quot;:1719751200.5}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/button&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;id&amp;quot;:1,&amp;quot;event&amp;quot;:&amp;quot;long&amp;quot;,&amp;quot;eid&amp;quot;:1040}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/display&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;lines&amp;quot;:[{&amp;quot;pos&amp;quot;:0,&amp;quot;text&amp;quot;:&amp;quot;14:32&amp;quot;,&amp;quot;font&amp;quot;:&amp;quot;xlarge&amp;quot;,&amp;quot;center&amp;quot;:true}]}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/config&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;gesture_zone_min_cm&amp;quot;:12,&amp;quot;gesture_zone_max_cm&amp;quot;:28,&amp;quot;ai_enabled&amp;quot;:1}&lt;/span&gt;&lt;/tt&gt; (retained)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/status&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;mode&amp;quot;:&amp;quot;work&amp;quot;,&amp;quot;online&amp;quot;:true,&amp;quot;version&amp;quot;:&amp;quot;0.6.0&amp;quot;,&amp;quot;buffered&amp;quot;:3,&amp;quot;pending_events&amp;quot;:0}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/sync/events&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;events&amp;quot;:[{...},{...}]}&lt;/span&gt;&lt;/tt&gt; — see Offline sync above&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/ota/trigger&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;url&amp;quot;:&amp;quot;http://192.168.1.42:18081/firmware.bin&amp;quot;}&lt;/span&gt;&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;hub/radar/raw&lt;/tt&gt;&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;&lt;span class="pre"&gt;{&amp;quot;dist&amp;quot;:84,&amp;quot;moving_gates&amp;quot;:[2,1,0,...],&amp;quot;stationary_gates&amp;quot;:[5,3,1,...]}&lt;/span&gt;&lt;/tt&gt; (recording only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;div class="section" id="appendix-b-debug-cheat-sheet"&gt;
&lt;span id="appendix-b-debug"&gt;&lt;/span&gt;&lt;h2&gt;Appendix B — Debug cheat sheet&lt;/h2&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="28%" /&gt;
&lt;col width="72%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Symptom&lt;/th&gt;
&lt;th class="head"&gt;Check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;No MQTT&lt;/td&gt;
&lt;td&gt;Discovery? &lt;tt class="docutils literal"&gt;hub/status&lt;/tt&gt; heartbeat? Mosquitto &lt;tt class="docutils literal"&gt;:18830&lt;/tt&gt;?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Wrong buttons&lt;/td&gt;
&lt;td&gt;GPIO probe + &lt;tt class="docutils literal"&gt;button_gpio_probe.sh&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gesture fires twice&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;gesture_debounce_ms&lt;/tt&gt;; leave zone to re-arm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Gesture never fires&lt;/td&gt;
&lt;td&gt;Gestures page: dist in zone? Media mode? Debug on?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;&lt;tt class="docutils literal"&gt;ai_state&lt;/tt&gt; stuck vacant&lt;/td&gt;
&lt;td&gt;Fan noise → record CSV, retrain; or lower &lt;tt class="docutils literal"&gt;ai_confidence_min&lt;/tt&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Spotify no-op&lt;/td&gt;
&lt;td&gt;Spotify running? &lt;tt class="docutils literal"&gt;media_backend&lt;/tt&gt; in Settings?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;OTA fail&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;pio run&lt;/tt&gt; built bin? Daemon up? Serial &lt;tt class="docutils literal"&gt;OTA failed:&lt;/tt&gt; line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Sleep chart empty&lt;/td&gt;
&lt;td&gt;&lt;tt class="docutils literal"&gt;s_energy&lt;/tt&gt; in &lt;tt class="docutils literal"&gt;hub/radar&lt;/tt&gt;; daemon running overnight?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;div class="section" id="part-12-evolution-timeline"&gt;
&lt;span id="part-12-evolution"&gt;&lt;/span&gt;&lt;h2&gt;Part 12: Evolution Timeline&lt;/h2&gt;
&lt;table border="1" class="docutils"&gt;
&lt;colgroup&gt;
&lt;col width="50%" /&gt;
&lt;col width="50%" /&gt;
&lt;/colgroup&gt;
&lt;thead valign="bottom"&gt;
&lt;tr&gt;&lt;th class="head"&gt;Firmware&lt;/th&gt;
&lt;th class="head"&gt;Milestone&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody valign="top"&gt;
&lt;tr&gt;&lt;td&gt;0.1–0.2&lt;/td&gt;
&lt;td&gt;MQTT hub, modes, SQLite, basic OLED&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;0.3+&lt;/td&gt;
&lt;td&gt;UDP discovery, LittleFS offline sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;0.4&lt;/td&gt;
&lt;td&gt;Near-zone gesture (next only), gesture debug UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;0.5.1&lt;/td&gt;
&lt;td&gt;MQTT payload size fix (radar was silently dropped — a classic)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;0.5.2&lt;/td&gt;
&lt;td&gt;Single-line OLED font scaling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;0.6.0&lt;/td&gt;
&lt;td&gt;TinyML heads, experimental prev/vol/hover, AI dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;div class="section" id="what-i-would-do-differently"&gt;
&lt;h2&gt;What I would do differently&lt;/h2&gt;
&lt;p&gt;Hindsight after firmware &lt;strong&gt;0.6.0&lt;/strong&gt;:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;&lt;strong&gt;Gesture calibration wizard&lt;/strong&gt; — record 50 swipes, one-click retrain; today it is manual CSV + &lt;tt class="docutils literal"&gt;label.py&lt;/tt&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mDNS&lt;/strong&gt; alongside UDP discovery — broadcast JSON is fine on home Wi-Fi, but Bonjour would help multi-subnet nerds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linux hub&lt;/strong&gt; — MPRIS instead of AppleScript for Spotify; Mac-only was pragmatic, not eternal.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TLS on MQTT&lt;/strong&gt; — overkill on isolated LAN; worth it if the hub ever faces guest Wi-Fi.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1D-CNN on gate tensor&lt;/strong&gt; — if flash budget allows; hand-crafted features were the right v1 trade-off.&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class="section" id="conclusion-intent-is-the-malware"&gt;
&lt;span id="conclusion"&gt;&lt;/span&gt;&lt;h2&gt;Conclusion — Intent Is the Malware&lt;/h2&gt;
&lt;p&gt;Strip away GDPR, OpenAPI, mmWave gates, and INT8 weights. One question remains:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Who owns the device on my desk?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In the 90s we feared the computer &lt;em&gt;under&lt;/em&gt; the desk. In the 2020s the threat sits &lt;em&gt;on&lt;/em&gt; it — OLED smile, wellness branding, someone else's cloud on the other end of the wire.&lt;/p&gt;
&lt;p&gt;Silicon is not evil. &lt;strong&gt;Intent is the malware.&lt;/strong&gt; Harm lives in architects who ship MAC-as-identity into a home office and call it care.&lt;/p&gt;
&lt;p&gt;I took ownership back — hostile hardware → accountable appliance:&lt;/p&gt;
&lt;ul class="simple"&gt;
&lt;li&gt;No external management view. No bathroom-break scoring. No timezone-as-GPS theatre.&lt;/li&gt;
&lt;li&gt;Stretch nudges, Spotify skips, offline journals — &lt;strong&gt;because I configured them&lt;/strong&gt;, on &lt;strong&gt;my&lt;/strong&gt; LAN, in &lt;strong&gt;my&lt;/strong&gt; SQLite.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Know your digital rights. Protect your desk boundary. When «Edge AI» cannot survive a desk fan, &lt;strong&gt;read the flash first&lt;/strong&gt;. When the spy box arrives as a «gift», &lt;strong&gt;say no&lt;/strong&gt; — then, if you are an engineer, ship the alternative.&lt;/p&gt;
&lt;p&gt;Presence Hub: same ESP32, same radar, &lt;strong&gt;my&lt;/strong&gt; logs, &lt;strong&gt;my&lt;/strong&gt; rules. The spy box asked for my outlet; I asked for my desk back. Source: &lt;a class="reference external" href="https://github.com/wwakabobik/esp32_radar_tracker"&gt;Presence Hub on GitHub&lt;/a&gt;.&lt;/p&gt;
&lt;/div&gt;
&lt;div class="section" id="epilogue-the-small-spy-box-and-the-big-one"&gt;
&lt;span id="epilogue"&gt;&lt;/span&gt;&lt;h2&gt;Epilogue — The Small Spy Box and the Big One&lt;/h2&gt;
&lt;p&gt;This story is small. One desk. One ESP32. One engineer who &lt;strong&gt;stopped being a user&lt;/strong&gt; and read the flash.&lt;/p&gt;
&lt;p&gt;But it sits inside a much larger shift — one I chose with a suitcase, not a thought experiment. I left an &lt;strong&gt;authoritarian despotism&lt;/strong&gt; where surveillance is infrastructure, not a startup pitch — and found the same logic in a different badge: not a dossier, but a fleet table; not a street camera, but a mmWave module on the kitchen table.&lt;/p&gt;
&lt;p&gt;In the &lt;strong&gt;90s&lt;/strong&gt; — the decade that formed many of us who learned computers through Norton Commander, BBS ethics, and the idea that &lt;strong&gt;information wants to be free&lt;/strong&gt; — the bargain was at least argued in the open. Benjamin Franklin, already in the 18th century, had the formula:&lt;/p&gt;
&lt;blockquote&gt;
«Those who would give up essential Liberty, to purchase a little temporary Safety, deserve neither Liberty nor Safety.»&lt;/blockquote&gt;
&lt;p&gt;We quoted it on forum signatures. We meant it. The strong of the world — states, platforms, employers — have since made a counter-offer: surrender a little more privacy each year, and we will call it security, productivity, care. Cameras in the lobby. Trackers on the desk. Models that score your attention. Logs that outlive your employment. The corporate spy box is not an aberration. It is &lt;strong&gt;imitation at startup scale&lt;/strong&gt;: &lt;em&gt;we are small incompetent technical shitheads, but we copy the architecture of control — and you will not hide from us on your home Wi-Fi either.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;That is why the old &lt;strong&gt;hacker ethos&lt;/strong&gt; — cypherpunk, BBS, &lt;strong&gt;information wants to be free&lt;/strong&gt; — is not nostalgia. It is a design requirement: &lt;strong&gt;local, inspectable, owned by the person in the room.&lt;/strong&gt; Open firmware. MQTT on your machine. SQLite you can delete. Models you train at your desk.&lt;/p&gt;
&lt;p&gt;I do not know if that ethic has a political name in 2026. Maybe &lt;strong&gt;info-anarchy&lt;/strong&gt;: no central landlord for your presence data. Maybe &lt;strong&gt;AI-anarchism&lt;/strong&gt; in the narrow engineering sense: inference on &lt;em&gt;your&lt;/em&gt; chip, weights &lt;em&gt;you&lt;/em&gt; exported, no cloud priest interpreting your radar returns for HR. Not LLM messianism — just the stubborn idea that &lt;strong&gt;intelligence on the edge should serve the edge&lt;/strong&gt;, not the org chart.&lt;/p&gt;
&lt;p&gt;The spy box on my desk was a joke with a MAC address. The world building more of them is not a joke. The only honest response I know — the one this article and this repo are — is to take the hardware back, document the lie, ship the replacement, and keep the boundary where Franklin said it should be: &lt;strong&gt;you do not trade liberty for safety and expect to keep either.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And do not stay quiet about it. &lt;strong&gt;Call things by their proper names.&lt;/strong&gt; Surveillance is surveillance, not «wellness.» Exploitation is exploitation, not «culture fit.» Abusers and exploiters — in the state, in the company, in the product brief — deserve to be named, not wrapped in OKR-friendly euphemism. The IT and AI age makes this worse, not better: the same hypocrisy, but smoother UI, a chatbot smile, and a privacy policy nobody reads. We have never needed more honesty about &lt;em&gt;who&lt;/em&gt; gets hurt and &lt;em&gt;who&lt;/em&gt; profits.&lt;/p&gt;
&lt;p&gt;Watch people, not slogans. Look at what a system does to the human behind the MAC address — instead of hiding behind «we care.» Georgy Sadykov's &lt;em&gt;The Face&lt;/em&gt; (&lt;em&gt;Лицо&lt;/em&gt;, 1988) — &lt;a class="reference external" href="https://www.youtube.com/watch?v=5n8RWqP7UVE"&gt;watch it here&lt;/a&gt; — said it before desk trackers: bureaucracy grinding a person until the face they show is no longer their own. In 2026 the mask is an OLED and a radar module.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do not be silent. Name the lie. Then build the alternative.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;If this long read was useful — firmware, story, or both — you can support the work on &lt;a class="reference external" href="https://www.buymeacoffee.com/wwakabobik"&gt;BuyMeACoffee&lt;/a&gt;, &lt;a class="reference external" href="https://thanks.dev/wwakabobik"&gt;ThanksDev&lt;/a&gt;, or &lt;a class="reference external" href="https://www.donationalerts.com/r/rocketsciencegeek"&gt;DonationAlerts&lt;/a&gt;. More weird hardware, more honest ML, more articles that treat your desk as &lt;em&gt;yours&lt;/em&gt;. Thank you.&lt;/p&gt;
&lt;/div&gt;
</content><category term="python"/><category term="iot"/><category term="privacy"/><category term="security"/><category term="ethics"/><category term="burnout"/><category term="macos"/><category term="automation"/></entry></feed>