SØNDAG
2026-09-13

Too many projects, too many ideas, too few hours — one learning a day anyway

The Lab

Where the physical world meets the stack: the house, the network, and one sixty-year-old boat. Every experiment links its posts — the failures are usually the better reads. Software projects live under Projects.

Home Assistant, ESP32 sensors, and heat meters decoded over the air.

Home Assistant at the center, ESP32 sensors around the house, and the fun part: wireless M-Bus heat meters and floor-heating thermostats decoded straight from 868 MHz radio with an SDR — custom rtl_433 decoders publishing into MQTT with auto-discovery. If a device emits radio waves, it can probably become a sensor entity.

ARCHITECTURE 868 MHz radio → SDR + rtl_433 decoders → MQTT auto-discovery → Home Assistant → automations
Home Assistant · MQTT · ESP32 · rtl_433

UniFi at home, with IPS logs flowing into Elasticsearch.

A UniFi-based home network where the security logs don’t just scroll by: IPS events ship via syslog into Elasticsearch through hand-built Grok pipelines, queryable and dashboarded. Lab-scale infrastructure run with production-scale habits.

ARCHITECTURE UniFi gateway → IPS events → syslog → Grok pipelines → Elasticsearch → dashboards
UniFi · Elasticsearch · Grok

An always-on local AI orchestrator with long-term memory.

The operating environment behind everything else: a resident orchestrator that routes tasks to LLM workers, runs scheduled agents (like the weekly Field Monitor that feeds this site’s news), and maintains long-term memory — nightly ingestion of work sessions and notes into a searchable store. The design principle: the orchestrator plans and verifies; disposable workers execute.

ARCHITECTURE always-on orchestrator → routes tasks to LLM workers → scheduled agents (news, code-milestones) → long-term memory ingested nightly
LLM routing · cron agents · vector memory 1 post →

A camera that reads the diesel sight glass, because the tanks predate sensors.

The boat’s fuel tanks are older than the idea of a fuel sensor — but they have a sight glass, and a sight glass can be looked at. So a small camera watches both glasses, a vision model reads the level off the image, and the result publishes as an ordinary MQTT sensor. One safeguard worth stealing: a diesel level only falls, so when the camera suddenly reads higher the number freezes until a refill is declared — a wrong reading is worse than no reading.

ARCHITECTURE one camera, two glasses → vision reads each → level-only-falls + coupled-tank guards → MQTT sensor
Raspberry Pi · vision AI · MQTT · go2rtc 3 posts →

Fuel-flow measurement for engines that have no flow sensor.

Twin 1960s diesels, no flow sensors, and no desire to cut into fuel lines. Instead: a model estimates burn from engine RPM using a cubic propeller-law curve, then calibrates itself against reality every time the tank level visibly drops — solving for the scale and idle-baseline that make the model match the measured fuel actually consumed. Estimated data is always labeled estimated; it never pretends to be a measurement.

ARCHITECTURE RPM + tank levels (SignalK) → propeller-law burn model → self-calibration on tank drops → MQTT → SignalK fuel paths
Python · SignalK · MQTT · physics

A minimal Android chartplotter that runs on the phone's own GPS and a sideloaded chart — no boat network needed.

I wanted a chartplotter in my pocket that still works when the boat’s systems are off. OpenCPN for Android was the obvious candidate, but it carries far more than I need; the right answer was a minimal Kotlin shell around a WebView, reusing the same S-52 renderer already proven on Maracaibo’s dashboard verbatim.

The chart is a purchased/licensed MBTiles file that arrives by the simplest safe path — hand it to the app, import it once, and it lives offline. Position comes from the phone’s own GPS first; if the boat’s SignalK server or an NMEA 0183 feed is on the same Wi-Fi, the app can merge that in, but the core plotter does not depend on either. North-up and course-up modes freeze below a speed threshold so the chart never spins on noise at anchor. A track manager, GPX import/export, MOB mark and course predictor fan are built in.

The APK is real and has been driven on an Android phone; the remaining acceptance is one outdoor trip to settle screen-off continuity, battery cost and course-up while actually moving.

ARCHITECTURE SAF-imported MBTiles chart → native tile handler → WebView + S-52 renderer → Kotlin shell → phone GPS + optional boat feed
Kotlin · Android · WebView · MapLibre · MBTiles · SignalK · NMEA 0183 2 posts →

A SignalK plugin that puts DMI's free Danish weather and marine observations on the boat's data bus.

The Danish Meteorological Institute gives its data away for free; this plugin puts it where a boat can use it — on the SignalK bus. It started as pure research: survey every free DMI dataset relevant to a vessel before writing a line of code. The plugin now publishes outside conditions (temperature, dew point, pressure, humidity, visibility, cloud cover, precipitation), marine values from the nearest model cell — honestly flagged as nearby, not at-boat — and official met warnings when one is live, with an Open-Meteo fallback when DMI’s marine track runs empty.

It runs on Maracaibo’s SignalK server today, and the repo was deliberately prepared for strangers: internal docs stripped from the public surface, history squashed to a clean root, documentation made SignalK-generic rather than about my boat. Published for anyone on Danish waters: github.com/dennismilton/signalk-dmi · npm signalk-dmi (listed in the SignalK Appstore).

ARCHITECTURE DMI Open Data API → signalk-dmi plugin → SignalK environment paths → dashboards + warnings
SignalK · Node.js · DMI Open Data API GitHub ↗

Reads and controls a Bluetooth Alpicool fridge from SignalK — dual-zone, published on npm.

A new Bluetooth fridge came onboard, and the research came first: the Alpicool BLE protocol turns out to be publicly reverse-engineered (credit to the community protocol references in Python and Go), but no SignalK plugin, node driver or polished Home Assistant integration existed. So this fills the gap — a proper SignalK plugin speaking BLE through BlueZ, publishing both zones with honest labels (the colder-target compartment is the freezer): temperatures, targets, power state, plus the fridge’s battery voltage and charge, all in SI units. Control is opt-in — per-zone target temperature and power as SignalK PUTs behind an enable toggle.

The engineering that mattered was three protocol gotchas, all grounded against the real hardware: the write characteristic only accepts write-without-response (a silent killer), the fridge answers nothing until a BIND command follows the connect, and notify frames fragment and concatenate — so the plugin carries a frame reassembler. Fourteen offline unit tests against known reference frames; verified live by moving each zone’s setpoint independently and watching the fridge obey. One physical quirk to know: the fridge holds a single BLE connection, so the phone app has to be off.

Published for anyone with the same fridge: github.com/dennismilton/signalk-alpicool · npm signalk-alpicool (listed in the SignalK Appstore).

ARCHITECTURE Alpicool BLE fridge → frame codec + reassembly → signalk-alpicool plugin → SignalK paths + control PUTs
SignalK · Node.js · Bluetooth LE · BlueZ GitHub ↗ 1 post →

Sonoff and eWeLink switches as native SignalK — driven direct over the LAN when they are local, through the cloud when they are not.

I have Sonoff relays and metering plugs doing real work, and I wanted them on the SignalK bus as first-class switches rather than behind an MQTT bridge. This plugin does that: a device the server can see on the LAN is driven direct and offline, one it cannot see goes through the eWeLink cloud, and it re-routes on its own as devices come and go. State is pushed, not polled — LAN mDNS plus the eWeLink WebSocket — so a switch thrown from the phone app or by a physical button shows up immediately. Metering plugs publish power, voltage and current alongside their state, and a SignalK PUT to the switch’s .state path throws the relay.

The part worth knowing before you install it: eWeLink needs a free developer account, not your phone-app login, because that is what supplies the device list and the LAN keys — you need it even for LAN-only use. The plugin does the whole OAuth exchange itself now, from the admin page, and keeps tokens and caches in SignalK’s plugin data directory, so an upgrade no longer wipes your authorisation. It degrades rather than dies: no credentials, no authorisation, no devices, a corrupt cache or a half-filled config row all start cleanly and say what is missing in the status line instead of failing quietly.

Under the hood it is the SignalK Python-plugin pattern — a thin index.js spawning a Python worker that owns the LAN crypto, discovery and WebSocket, piping deltas back. On npm as signalk-ewelink (1.0.5) and in the SignalK Appstore: github.com/dennismilton/signalk-ewelink · npm signalk-ewelink.

ARCHITECTURE eWeLink devices → LAN AES + mDNS, cloud fallback → plugin worker → SignalK switch paths + PUT control
SignalK · Node.js · Python · mDNS · WebSocket · OAuth GitHub ↗

Tuya and Smart Life WiFi sockets in SignalK over the local LAN protocol — no cloud in the control path.

Cheap Tuya sockets are everywhere, and almost every integration for them routes switching through a vendor cloud. That is the wrong shape for a boat, so this plugin talks the local LAN protocol instead: switching and reading go straight to the socket over tcp/6668 with its local key, and keep working with the internet unplugged. Each socket publishes its state, plus power, voltage and current when the hardware actually meters them, and a SignalK PUT to its .state path throws the relay. A LAN scan fills the device dropdown, so you pick a socket in the admin UI rather than typing an id.

The cloud is in exactly one place and only if you let it: Tuya’s local keys come from Tuya, not the network, so a free Tuya IoT developer account fetches them once. After that they are cached in SignalK’s plugin data directory — mode 0600, and outside the install, so an upgrade does not throw them away — and the credentials become optional. Everything that can be missing on a first run is treated as a state, not a crash: a missing tinytuya, no sockets configured, a socket without a key, a socket the scan has not seen yet, credentials Tuya rejected — each of those says so in the admin status line. Sockets that stop answering drop to no-data instead of reporting a stale value, which matters more than it sounds when a switch panel is telling you what is on.

Same shape as its sibling: a thin index.js spawning a Python worker that owns the Tuya protocol details, piping deltas back into SignalK. On npm as signalk-tuya (1.0.5) and in the SignalK Appstore: github.com/dennismilton/signalk-tuya · npm signalk-tuya.

ARCHITECTURE Tuya sockets on tcp/6668 (LAN 3.3) → plugin worker → SignalK paths + metering + PUT
SignalK · Node.js · Python · tinytuya · Tuya LAN 3.3 GitHub ↗

Where should I head tonight — nine cities mapped by what is actually open at the hour you ask about.

Pick a day and an hour, and it shows which areas have the most places open — Kødbyen around Vesterbrogade, Jomfru Ane Gade, St. Pauli around Wohlwillstraße. Zoom in and you get the venues themselves, by name. It is live at cityheatmap.com.

It only counts places that publish their opening hours. If nobody wrote down when a bar opens, it isn’t on the map. That keeps it honest, but coverage varies a lot:

Hamburg 70% · Berlin 62% · Copenhagen 58% · Barcelona 25% · Palma 14%

Palma is the worst — not one of its sixteen nightclubs publishes hours. Where I know nothing, the map shows nothing.

It won’t tell you how busy a place is. Nothing I can legally use knows that.

ARCHITECTURE OSM extracts → osmium filter → venue store + per-hour open masks → named places, streets and venue dots → static payload on cityheatmap.com
OpenStreetMap · osmium · Python · SQLite · MapLibre · OpenFreeMap · Cloudflare Pages cityheatmap.com ↗ 2 posts →