One algorithm, implemented twice — in TypeScript and embedded C — and stitched together by CI
An IoT early-warning system that alerts on the rate of change in water temperature and TDS, with the same decision logic in the cloud and on the device, pinned by golden-fixture contract tests. How Uomamori is built.
Pre-orders are open with a fully refundable ¥500 deposit. The hardware has not started shipping. Japanese-language product.
Why this is in a consulting portfolio
Projects with hardware in them break at the seams between "the firmware person", "the cloud person" and "the app person". Uomamori needed the same decision logic on both sides, so I wrote both — and stitched them together with a contract test.
Why I built it
Ornamental-fish breeders run 10 to 100 tanks in a single room. A failed heater or a leak becomes irreversible if you notice it a few hours late.
Measurement in this domain is awkward, though. Electrodes drift out of calibration as they age. Alert on absolute thresholds and every drift produces a false alarm, until eventually nobody reads the notifications at all.
The hard part
Catching real anomalies without false alarms meant looking at the speed of change rather than the absolute value. That much is a straightforward design decision.
The hard part was that both the cloud and the device needed that judgement. A device with no connectivity still has to detect anomalies on its own; the cloud has to reason across many devices. So the same logic was required in two places — one in TypeScript, the other in C on an ESP32-C3.
I could see the future where those two implementations drift apart.
The call I made (and what I gave up)
Write both, and stitch them together with a contract test.
The decision fits a least-squares regression over the samples in a window and takes slope × window width as the effective Δ — more robust to noise than an endpoint difference. That is implemented on both the TypeScript and the embedded-C side, and the C implementation's real output from deterministic fixed inputs is committed as golden fixtures, which the TypeScript tests then check themselves against. Change one side alone and CI always breaks.
Two teams' documents get checked against the same master copy, every time. Change one side only, and it comes straight back.
What I gave up was DRY. I accepted two copies of the same algorithm. Since the languages and runtimes cannot share code anyway, I decided what gets shared is the behaviour.
What actually ships
The embedded side runs the regression over a 16-point ring, accumulating in double to avoid loss of significance, and only fires after N consecutive threshold crossings.
For power loss, the flash ring buffer is built so that a half-written record fails its CRC and only that single record is discarded — the good records on either side always survive. Flash operations are injectable, so the same C runs against an in-memory fake in host tests, with no hardware.
Device auth is a three-party HMAC that deliberately keeps no nonce store: samples are idempotent on (device_id, seq, ts) and hello/config are read-only, so a replay is harmless. The reasoning for not having one is written down as part of the design.
The ingest path ACKs first and defers alert evaluation and counters until after the response.
Numbers counted from the code
30 commits between 2026-07-03 and 07-27. The firmware is 7,429 lines of C and headers on ESP-IDF 5.2.2. The database has 25 RLS policies, and the device-secret table alone carries no policy whatsoever, so not a single row is reachable through PostgREST — the plaintext exists only on the QR code in the box. The time series is monthly-partitioned, with 7 pg_cron jobs driving rollups, retention and partition maintenance.
CI has two unusual gates. copy-lint fails the build if marketing copy claims something the hardware cannot do, such as detecting ammonia; each of its 21 rules carries a written reason. bom-lint fails if the bill-of-materials arithmetic drifts more than 10% from the pricing model's COGS. The firmware build also has a size gate: it fails above 1,400 KB against a 1,600 KB OTA slot.
What this proves for your project
Sensors, gateway, time-series database, notifications, billing — and a CI that constrains what you are allowed to claim — carried end to end by one person. On projects that lift field sensors into the cloud (manufacturing, agriculture, facility maintenance, remote check-ins) there is no translation cost between layers.
In particular, the technique for keeping one piece of logic identical across two runtimes — golden-fixture contract tests — is not IoT-specific. It applies just as well to a calculation that must agree between mobile and server.
Work that spans hardware and cloud falls under SaaS / industry DX development. For vertical DX engagements, see the lumber-industry case study.
Numbers counted from the code
- Δ-detection implementations
- 2languagesTypeScript and ESP32-C3 embedded C, pinned by golden-fixture contract tests — change one side alone and CI breaks
- Firmware
- 7,429linesESP-IDF 5.2.2 / C, with flash operations injectable so the same code is host-testable without hardware
- RLS policies
- 25policiesThe device-secret table carries no policy at all and is reachable only by service_role
- Copy linter
- 21rulesCI fails if marketing copy claims something the hardware cannot do, such as detecting ammonia
FAQ
- Can I buy Uomamori now?
- It is in a pre-order phase. Reservations are taken with a fully refundable ¥500 deposit, and the hardware has not started shipping.
- What can it measure?
- Water temperature and TDS (total dissolved solids). pH can be added with a separate add-on. Anything not measured — ammonia, for example — it cannot measure. That boundary is enforced by a copy linter in CI: copy claiming otherwise does not build.
- Does electrode calibration drift cause false alarms?
- It is designed not to. Alerts come from a rate of change derived by least-squares regression over a window of samples rather than an absolute threshold, so slow calibration drift does not fire it.
- The same logic runs in the cloud and on the device — can they diverge?
- If they diverge, CI fails. The embedded C implementation's real output from deterministic fixed inputs is committed as golden fixtures, and the TypeScript tests check themselves against those. Changing one side alone always breaks.
- Does a power cut corrupt data?
- No. The flash ring buffer is power-loss safe: a half-written record fails its CRC and only that one record is discarded. The good records on either side survive.
- Where do alerts arrive?
- LINE, push notification and email. If many devices at one site go silent simultaneously, they collapse into a single notification.
同様の課題、抱えていませんか?
あなたのビジネス課題も、最新の技術で解決できます。 まずは30分の無料技術相談から、状況をお聞かせください。
自社の課題もSaaS化できるか相談するプロジェクト単位(請負)・技術顧問、どちらにも対応可能です