Skip to content

project overhaul: introduce kegboard v4, an esp32 + esphome based device - #20

Open
mik3y wants to merge 37 commits into
mainfrom
mikey/kegboard-esp32
Open

project overhaul: introduce kegboard v4, an esp32 + esphome based device#20
mik3y wants to merge 37 commits into
mainfrom
mikey/kegboard-esp32

Conversation

@mik3y

@mik3y mik3y commented Aug 3, 2026

Copy link
Copy Markdown
Member

This branch introduces the fourth major redesign of Kegboard in ~22 years 😯. Major changes:

  • New device family: Now targets esp32-based devices, which in 2026 are widely available, low cost, have great community support and toolchains, and of course tons of useful features such as wifi, ble, and more.
  • New framework: Built on top of esphome, as a collection of custom components. While using this framework is not strictly necessary for kegbot purposes, esphome gives us a great framework with some useful adjacent components (like a wifi captive portal), and makes it easier to use a kegboard with an alternative backend such as a home assistant server.
  • New protocol: A new data/messaging protocol is introduced for streaming data from the kegboard, initially over HTTP (but with possible extension to e.g. BLE later). While it is designed to be received by a kegbot-server implementation, it is backend agnostic and can be supplied to other/third-party receivers.

As with the previous generation of kegboards, all core features are supported, including:

  • Flow meter support
  • Relay/valve devices
  • OneWire device authentication
  • OneWire temperature sensors
  • Diagnostic LEDs and buzzers

More background and potentially more discussion in the community forum thread here.

mik3y added 8 commits August 2, 2026 07:16
Pours are now assembled on-device. Kegbot Server's API already accepts a
finished pour (POST /api/taps/<meter> with ticks/duration/pour_time/now),
so the board detects the pour, applies calibration, and posts a drink
directly: no kegbot-pycore, no usb tether, and a server outage costs a
retry rather than a pour.

- legacy avr firmware, the python kbsp library, eagle files, and the old
  sphinx docs leave main; all preserved on the `arduino` branch
- adds the framework-agnostic core -- pour_session, tick_series (bounded
  <offset_ms>:<ticks> series), kegbot_request, ring_queue -- carrying no
  esphome, arduino, or esp-idf headers; 471 assertions run on a plain
  host compiler in ~1s, and check-core-purity.py keeps the boundary
  honest
- drink posts omit volume_ml by default: the server already stores
  ml_per_tick per meter behind a calibration ui, and two sources of truth
  for volume reliably produces confusing data. opt in via send_volume
- pour timestamps fall back to a monotonic uptime pair when the clock has
  never synced; the server only uses (now - pour_time), so a board that
  has never seen ntp still reports accurate pour times
- queue overflow evicts oldest and counts the loss, bounding a long
  outage and making the data loss visible rather than silent
- test output is scoped by os and arch, since the repo is often shared
  between a host and a container over a bind mount; -Werror requires
  STRICT=1, which ci passes
- tooling mirrors esphome's, since these components compile into their
  tree: clang-format v13 with their .clang-format verbatim, ruff,
  yamllint, via pre-commit
- relicensed mit to match the rest of kegbot, clean because every
  gpl-licensed file left main. built images stay gplv3 (esphome's
  runtime); the `arduino` branch keeps gplv2-or-later
- reporting is http only for now; mqtt, ble, and websocket are planned
First half of the ESPHome adapter layer. The meter counts pulses in an
ISR and hands them to the kbcore pour state machine; all the pour
detection logic stays in the unit-tested core.

- core moves from namespace `kegboard` to `kbcore`. ESPHome components
  live in `esphome::<name>`, so a global `kegboard` namespace would be
  shadowed from inside the component and every reference would need a
  leading `::`
- hub derives a serial number from the last three mac bytes, giving an
  unconfigured board a stable identity that survives reflashing. meters
  default to `<serial>.flow<index>`, which is the name kegbot server
  keys a tap by
- hub takes its wall clock as a callback rather than a
  time::RealTimeClock*: esphome only copies headers for loaded
  components, so including the time header would break every config
  without `time:`
- meter exposes total/volume/flow_rate/pouring entities, on_pour_start
  and on_pour_end triggers, and reset_total/end_pour/set_calibration
  actions
- sensor updates are throttled to report_interval during a pour but
  always published on a pour boundary
- ruff ignores E501; the formatter owns line length, and it only fires
  on lines it cannot split
Posts finished pours and temperature readings to a kegbot server. This
is what replaces kegbot-pycore.

Delivery is queue-first: a completed pour is enqueued, never posted
inline. The meter's loop stays responsive, and a server that is down,
slow, or unreachable costs a retry rather than a pour.

- meters register through add_meter() rather than yaml automations, so a
  pour cannot be lost to a forgotten `on_pour_end:` block
- pours are sent before temperatures, and only one item per loop
  iteration: each send blocks, so draining a backlog at once would stall
  meter polling and lose ticks
- 4xx responses are discarded rather than retried. a bad key, an unknown
  meter, or a tap with no keg cannot be fixed by trying again, and an
  unknown meter is the normal state before a tap is configured, so
  retrying would block the queue forever
- failures back off exponentially from retry_interval to a 5m cap; a new
  pour clears the backoff and attempts immediately
- installs the hub's clock source, since this is the component that
  already depends on `time`
- optional queue_depth and dropped diagnostic sensors. a non-zero
  dropped count means pours were lost and is worth alerting on
- boards for esp32-s3-devkitc-1 (reference), esp32dev, and esp32-c6,
  each with a pin map. meter pins avoid strapping, flash, native-usb,
  and the input-only 34-39 range, which has no internal pull-ups and so
  cannot bias an open-collector meter
- packages/base.yaml carries wifi with ap fallback, ota, sntp, and the
  http client
- worked examples for kegbot server and for home assistant alone
- ci runs core unit tests, pre-commit, config validation, and an
  esp-idf compile matrix; esphome is pinned so a release cannot land on
  a contributor mid-pr
- a weekly job builds against esphome dev, so their breaking changes
  show up as a scheduled failure rather than a user bug report
M4 is yaml over stock components; no new c++.

- each relay gets a watchdog: a relay left on is usually a solenoid
  valve held open, and if whatever turned it on stops talking, nobody is
  left to turn it off. scripts use mode: restart, so re-issuing the on
  command restarts the countdown rather than stacking timers, matching
  the legacy set_output behaviour
- buzzer plays the avr firmware's boot/auth/ping melodies, transcribed
  from tones.h to rtttl
- boards gain led, rfid rx, and auth-bus pin substitutions
One grant at a time: the person at the tap is whoever presented the
most recent token. Covers both reader styles -- a momentary rfid scan
expires on its own, a held ibutton is revoked on detach.

- a different token replaces the grant and clears the resolved
  username, so a newcomer cannot inherit the previous holder's tab
- detach only revokes if the token matches the active grant; a stale
  event from another reader cannot close someone else's tap
- extend() pushes expiry out, used to keep the valve open under a pour
  that outlasts the grant window
- expiry math survives the 32-bit millis rollover
- 46 assertions; 517 total across the core suites
kegboard_auth turns token events from any reader (rdm6300, wiegand,
pn532, kegboard_onewire) into a grant: resolves the user against
/api/auth-tokens, opens the flow toggle, and attributes pours to the
resolved username. kegboard_onewire adds the arrive/leave events that
esphome's one_wire bus lacks, which is the entire point of an ibutton
reader.

- authorization is decided on-device: the tap works through a server
  outage and the valve opens at local speed. the trade is that a
  server-side revocation stays valid until the next lookup
- require_known_token defaults false: an unregistered fob pours as
  guest rather than doing nothing. set true to lock the tap
- an active pour extends the grant, and revocation ends any pour in
  flight before clearing the username, so the drink still lands on
  whoever poured it
- ibutton detach requires 4 consecutive missed searches (the avr
  firmware's number): a held fob makes intermittent contact, and
  reporting on the first miss would flap several times a second
- meter pour callbacks now carry the active username; the reporter
  passes it through to the drink post
- action/condition overrides now match Action::play(const Ts &...) --
  by-value signatures only collapse to the same overload when the
  template pack is empty, so the meter actions compiled until the first
  automation with arguments instantiated one
- id-only actions (revoke, reset_total, end_pour, is_authorized) accept
  the bare-id shorthand via maybe_simple_id
- examples/kegbot-full.yaml wires all of it: two gated taps, valve
  relay, buzzer, leds, rfid, and ibutton
esphome's host platform compiles the components with the system
compiler in seconds -- a full c++ type-check with no esp toolchain
download. ci runs it as the first gate ahead of the esp32 matrix.

- the smoke config deliberately exercises actions invoked from
  automations with arguments, exactly where an override-signature
  mismatch hides from the plain examples
- meter's isr drain guards InterruptLock with #ifndef USE_HOST; the
  host platform implements no interrupts to mask (and the "isr" never
  runs there, so the unlocked read is equally correct)
- kegboard_onewire is absent from the smoke config: esphome's own gpio
  one_wire does not link on host for the same InterruptLock reason. the
  esp32 matrix covers it via kegbot-full
- the weekly esphome-dev canary now builds kegbot-full
This was referenced Aug 3, 2026
mik3y added 2 commits August 3, 2026 17:11
Defines the reporting protocol that replaces both the legacy kbsp
serial protocol and the legacy pykeg http api. One endpoint
(POST /kegboard-event), json batches with normative json schemas for
request and response, bearer auth.

- device is authoritative for volume: volume_ml always present and
  computed from on-device calibration; ticks/ml_per_tick/tick_series
  ride along as diagnostics only
- at-least-once delivery with idempotent processing: events dedup on
  (device, boot_id, id); a retried batch can never create a duplicate
  drink. pour_id is a separate globally unique opaque string, usable
  as-is server-side (event ids stay boot-scoped integers; rationale in
  the doc)
- age_ms is the authoritative time signal, recomputed at each send, so
  queued events deliver late with correct timestamps and a device with
  no synced clock still reports accurately
- pour_update events give a live pour view (tunable rate, default 1s);
  best-effort and exempt from delivery guarantees
- status heartbeats (default 1m) carry a config object so operative
  device settings are server-discoverable, plus events_dropped and
  per-meter totals so data loss and missed pours are detectable
- pairing: an unprovisioned device announces itself and appears on the
  server dashboard for allow/deny; the token is provisioned in-band and
  the user never transports a credential. any 401 re-enters pairing, so
  key rotation is revoke-then-reallow
- commands ride the response to device-initiated requests (no inbound
  connections): authorize/deny/deauthorize are specified in the
  authenticated-pouring doc; grants are per meter, the server decides
  the meter set, and the device clamps grant duration (default 5m) as
  the final bound on valve-open time
- offline_policy governs token presentment during an outage: deny
  (default) or guest, which attributes but never opens a valve
The kbcore side of the kegboard event protocol: json writer, payload
builders for every event type, batch envelope serialization, and
boot_id/uuid formatting. Schemas extracted from the doc appendices to
schemas/ as the normative files.

- payloads are rendered at event creation; only the envelope is
  rendered at send time, so age_ms is recomputed per (re)send and a
  queued event delivered late still lands at the right time
- ci proves conformance end to end: check-events-schema.py drives the
  test binary's emit mode and validates every produced document against
  the schema file, and asserts the schema files still match the doc
  appendices, so doc, schema, and code cannot drift apart silently
- json writer is write-only, deterministic, and escapes correctly
  (quotes, control chars as \uXXXX, utf-8 passthrough); nan/inf floats
  serialize as 0 rather than corrupting the document
- pour ids format as uuidv4 from injected random bytes, keeping the
  core free of platform rng and the tests deterministic
@mik3y
mik3y force-pushed the mikey/kegboard-esp32 branch 2 times, most recently from 2b9fed5 to c1db065 Compare August 3, 2026 17:15
Replaces the legacy pykeg client stack (kegboard_kegbot, kegbot_request,
auth_session) with the kegboard event protocol end to end: a reporter
that batches events to the configured reporting url, per-meter
server-decided authorization, and pairing.

kegboard_reporter:
- reporting_url is a full url, path included, used verbatim
- queue-first delivery with backoff; pours and token events reset
  backoff for an immediate attempt, heartbeats and command acks wait
  their turn; non-401 4xx drops the batch and counts the loss
- pairing is the only way a device gets a credential: unauthenticated
  batches until the server allows the device, provisioned token
  persisted in flash, and any 401 (revoked or rotated token) drops it
  and re-enters pairing
- commands dispatched from every 2xx response with dedup on command id
  and command_result acks; the auth component registers the handler
- pour_update events at a tunable cadence (default 1s), best-effort:
  sent only when healthy, never queued, never counted as dropped
- status heartbeats (default 1m) carry config, rssi, dropped count, and
  per-meter totals

kegboard_auth:
- per-meter grants (kbcore::GrantTable) replace the single-grant
  session: alice on meter 0 and bob on meter 1 coexist, and the server
  decides each grant's meter set, user, and duration
- server mode sends every presentment; authorize/deny ride back in the
  same round trip. offline_policy deny (default) or guest, which
  attributes but never opens a valve
- duration clamp (max_grant_duration, default 5m) bounds valve-open
  time regardless of what the server asks for
- local mode accepts every token as guest, serverlessly
- reporter binding is automatic when a single kegboard_reporter exists,
  like every other *_id reference; server mode with none fails
  validation with a pointer to `mode: local`

kegboard_meter:
- pours carry a uuid pour_id generated at pour start, plus full
  attribution (user, auth_device, auth_token) captured at pour end
- `index` is now `meter_number`, matching the protocol field: the yaml
  `id` is a config-internal reference and never on the wire, and the
  docs now say so. duplicate meter numbers fail config validation --
  the default is 0, so a multi-meter config that forgot to set it would
  have reported every tap as meter 0 and the server would silently
  merge them
- `meter_name` removed: it built the legacy `<serial>.flow<n>` kegbot
  naming, which died with the pykeg api

The legacy pykeg api client and its core modules are gone; examples,
host smoke test, and readme updated. Whole stack compiles natively on
the host platform and every emitted document validates against the
protocol schemas in ci.
@mik3y
mik3y force-pushed the mikey/kegboard-esp32 branch from c1db065 to 6281e78 Compare August 3, 2026 17:15
@scottresnik

Copy link
Copy Markdown

This is awesome. This exactly what I was thinking of doing if my current setup ever died.

@mik3y

mik3y commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

This is awesome. This exactly what I was thinking of doing if my current setup ever died.

@scottresnik awesome, thanks for the quick gut check! (and even more awesome your system is still running!)

I should have linked to the forum thread here, too.

mik3y added 3 commits August 3, 2026 10:48
- pour.pour_id is required; the device always generates one, so the
  "SHOULD, MUST if updates" caveat described an impossible state
- pour_update.rate_ml_per_min removed: derivable from volume/duration,
  and better from deltas between successive updates
- response `commands` is optional; absent means none
- status.meters[].ml_per_tick is required; ml_per_tick: 0 is now a
  config-time error
A device without a token sends no Authorization header, and a server
that accepts unauthenticated batches never triggers pairing -- the
minimum receiver needs no auth machinery. Only a 401 introduces auth,
and later serves as revocation.

- pairing section reframed to match: it begins when a server 401s
- fixes healthy_() requiring is_paired(), which silently suppressed
  pour_update events against auth-less servers
age_ms already covers queued pours (envelope field, recomputed per
send); now documented that it measures to the pour's end, with
start = end - duration_ms.
@mik3y
mik3y force-pushed the mikey/kegboard-esp32 branch from 8682951 to 3187693 Compare August 3, 2026 17:49
Silences the register_action synchronous= warnings. All six actions do
their work inline in play() -- nothing defers to a callback or loop()
-- so synchronous=True is correct and enables the StringRef
optimization.
@mik3y

mik3y commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Current status: Flashed code to a basic esp32 devkit device. Auto-discovered by & works in home assistant!

image

mik3y added 5 commits August 3, 2026 18:43
Success was silent while failures were loud, which made bring-up blind:
no way to see what the device actually sent.

- ESP_LOGD per POST: status, event count, byte size
- ESP_LOGVV: full request and response bodies; compiled out at default
  log levels, so production builds pay nothing
Speaks the full event protocol so the server side can be built without
hardware: pairing (pending/allowed/denied, token persisted per
url+device), batching with recomputed age_ms, command dispatch with
dedup and command_result acks, per-meter grants with pour attribution.
Validates every outgoing batch against schemas/ so it cannot drift from
the protocol. Single-file, runs via `uv run` with inline deps.

Beyond the basics (pours with live updates and slight per-pour
variance, temperature logging, preset tokens incl. an unknown fob and a
presence ibutton), it can misbehave on purpose:

- kill the heartbeat so server liveness detection can notice
- go offline to build a backlog that delivers late with correct ages
- replay the last batch verbatim to exercise server dedup
- send an unknown event type (server must 2xx and ignore)
- reboot: new boot_id, ids reset

ui: a right-hand command palette lists every key with a short muted
description (? minimizes it to the bottom bar; keys, footer labels, and
help text share one table so they cannot drift). the traffic log shows
each request body as sent and each json response; non-json bodies
summarize to one line. ctrl-c quits.

Verified headless end to end against an inline receiver (19 checks:
pairing flow, attribution, dedup, offline aging) plus textual pilot
smokes of the ui.
The esp32 rewrite is the project's 4th generation: v1 was pic16-based
(written in jal), v2 arduino, v3 the kegboard pro mini, v4 this. Prose
says v4, a history section at the bottom of the readme records the
lineage with dates, and version strings follow to 4.0.0-dev (firmware,
project metadata, sim, doc examples).
- new docs/ manual: overview, operating modes, installation, hardware,
  configuration, theory of operation, developer notes
- protocol specs unchanged, pulled in as an appendix toctree
- sphinx + myst + furo, matching docs.kegbot.org conventions; publishes
  as the kegboard subproject via .readthedocs.yaml
- docs/ is a self-contained uv project; make -C docs html / livehtml
  (liveserver on port 8010)
- README: docs pointer + build instructions
- examples: kegbot-2tap on the classic devkit board

WIP: copy edits pending before publish.
mik3y added 16 commits August 4, 2026 16:46
- meters and the new relays array are exhaustive hardware inventories,
  so a server can allocate and retire port records automatically;
  retirement guidance says warn, don't delete, for operator-configured
  ports
- self-explanatory field names: meter_number and relay_number in
  objects, meter_numbers for the authorize/deauthorize arrays; the
  reserved set_output command becomes set_relay
- "relay" replaces "toggle" throughout (matches the hardware docs,
  relay0/relay1 ports, and esphome entities)
- schema and simulator updated to match
- drop the DRAFT note
- authorize/deny/deauthorize are now §7.1–7.3 of the protocol doc, which
  is meant to be the exhaustive contract; authenticated-pouring keeps
  only a flow-level summary of their roles
- reserved command types get their own §7.4
- response schema (Appendix B + in-repo) now types the three command
  payloads via per-type $defs with if/then dispatch, mirroring the
  request schema's event pattern
- authorize carries exactly one grant: a server-assigned grant_id,
  meter_numbers/relay_numbers sets (the meter↔relay association stays
  on the backend and travels in each command), and limits —
  max_volume_ml, max_duration_ms, max_idle_ms, 0 meaning unlimited,
  with the device clamp always bounding total lifetime; re-using a
  live grant_id updates the grant in place, counters intact
- identity leaves the wire: no user field anywhere — pours and grant
  endings carry grant_id plus the token echo, and the server resolves
  the user itself
- deauthorize revokes by grant_ids; absent means every active grant
- new grant_end event reports every ending with a reason (max_volume,
  max_duration, max_idle, detach, command, replaced); totals are
  snapshots, and the final pour always precedes its grant_end
- duplicate commands are re-acknowledged, not just deduplicated
- authenticated-pouring restructured: background and summary up front,
  everything optional for monitoring-only boards, and a corner-case
  catalog (§9) for pours × grants — guest pours, mid-pour adoption,
  splitting on replacement, offline-guest scope
- schemas match the appendices byte-for-byte; config docs speak
  "relay" uniformly and gates become local-mode-only
- GrantTable rewritten around grant objects: server ids, meter/relay
  sets, limits with the clamp spanning updates, per-meter takeover,
  and every ending returned with its reason
- kegboard_auth validates grants against the device inventory (unknown
  meter/relay acks error, not applied), feeds live flow into the
  limits so a volume cap closes the valve mid-pour, ends pours before
  queueing grant_end, adopts in-flight pours at an explicit policy
  point (authenticated-pouring §9), and revokes by grant id —
  malformed grant_ids never read as the emergency stop
- reporter gains the numbered relay registry (relays option + status
  inventory), grant_end emission, duplicate-command re-acks, and
  response reads that no longer trust Content-Length
- identity is gone from the device: pours carry grant_id instead of
  user, token events and triggers drop user, the user entity is
  removed, on_authorized fires with (auth_device, token)
- event payloads emit meter_number as the schema requires (the old
  "meter" key never validated); status self-describes relays
- gates are local-mode-only and ignored in server mode; local mode
  requires at least one gate (relay optional); grant durations are
  bounded to keep the rollover math valid; detach matches the
  auth_device/token pair and ignores empty tokens
- grant table and event builders covered by host tests; the host
  smoke config and examples exercise the new surface
- authorize/deauthorize by grant: sets, limits, update-in-place, and
  inventory validation, mirroring the firmware
- limits enforced live: volume cutoff mid-pour, idle and duration
  watchers, with the pour event always preceding its grant_end
- pour lifecycle matches the corner-case catalog: adoption mid-pour,
  splitting on replacement, zero-volume pours discarded as drips
- duplicate commands re-acked, detach matches the device/token pair,
  grant endings flush promptly, reboot forgets applied command ids
Local mode let a serverless board accept every token as guest on
config-wired gates. It was thin as access control, it kept the one
remaining device-side copy of the meter↔relay association alive, and a
serverless install can reproduce it with plain ESPHome automations on
the reader triggers. We may bring a device-decided mode back some day —
perhaps with a proper allow-list — but for now authorization simply
means the server decides, and everything is smaller for it.

- kegboard_auth loses mode, gates, and local_grant_duration; it now
  requires a kegboard_reporter, and its meter inventory is the
  reporter's
- the device-decided grant machinery goes with it: no GrantSpec.local,
  no internal grant ids — every grant is server-issued, so grant_end
  now requires grant_id
- offline_policy: guest simplifies to "stay silent": nothing opens,
  nothing is granted, pours proceed as ordinary guest pours, and the
  queued token event preserves the audit trail — the only difference
  from deny is the missing refusal signal
- the token event loses its status field (local decisions were its
  only producer): an attached event is always a question for the
  server
- docs: modes collapse to authenticated vs open, the local-mode
  section and the offline-guest corner case disappear, and the
  corner-case catalog renumbers to §8
- schemas, examples, smoke config, tests, and the simulator follow
…gure around it

ESPHome deduplicates repeated switch publishes, so a redundant turn_on
does not re-fire on_turn_on — the watchdog timer runs from the on-edge
and cannot be refreshed while the relay is on. kegboard_auth energizes a
grant's relays once, so a 10s watchdog was silently closing granted
valves mid-grant while attribution carried on.

- kegbot-full example raises relay_watchdog_timeout to 6min, above the
  5min grant clamp: the clamp bounds grant-held relays, the watchdog
  covers manual/HA toggles
- relays.yaml drops the false "re-issuing the on command restarts the
  timer" claim and documents the grant-driven guidance
- operation, configuration, hardware, overview, and README stop
  claiming the two limits compose independently and state the rule:
  watchdog longer than max_grant_duration (or 0s) on granted relays
Protocol §9 grants immediate-attempt privileges to pours and tokens
only, but the thermo callback used enqueue_'s default reset_backoff, so
a device with a 60s sensor retried a down server every 60s forever
instead of backing off to the 5min cap.
The option was renamed long ago; copy-pasting the snippet failed
validation.
- overview, operating-modes, operation, and the offline-behavior heading
  stop naming a `server` mode / local decisions that no longer exist
- meter and reporter header comments and the sim drop mentions of
  locally decided grants and the removed token status field
- core comments (hub identity, serial-number validation, tick series)
  cite the v4 protocol -- (device, meter_number) tap identity and
  tick_series -- instead of retired pykeg-era behavior
When a grant ends or is replaced mid-glass, the firmware ends the pour
and keeps metering — the sim's pour task just stopped, so receivers
never saw split-pour traffic (authenticated-pouring §8, cases 3 and 5).
The pour now runs in segments: a grant boundary finishes the current
pour and the remaining flow opens a fresh pour_id under whatever covers
the meter — the new grant, or nobody (a guest pour).

- tick_series entries become per-interval deltas that sum to the pour's
  ticks (they were cumulative/steps, adding up to about half)
- gitignore uv.lock (a tooling artifact)
- healthy-and-paired now flushes whenever the queue is non-empty, like
  the firmware, so command acks and grant endings no longer sit out the
  heartbeat interval
- authorize limits are type-checked (numeric, non-negative) and acked
  `error` on garbage instead of crashing the dispatch task or poisoning
  grant state; meter/relay numbers reject booleans (JSON true == 1)
The reporter's spec surface (status-code handling, backoff, pairing
cadence, command dedup/re-acks) and the grant/pour composition
(adoption, splits, limit trips, pour-before-grant_end ordering) lived
only in ESPHome-bound components, where the host suite could not reach
them — the recent temperature-backoff bug is exactly the class that gap
allows. Both state machines now live in kbcore.

- kbcore::Delivery owns attempt timing: the status table, exponential
  backoff with cap and reset rules, pairing fast/slow polling, denied
  state, and the command ledger for dedup and re-acks
- kbcore::AuthEngine owns grant semantics end to end — inventory
  validation, replacement/updates/adoption, live flow accounting, relay
  refcounting, endings — driving the device through callbacks
- kegboard_reporter and kegboard_auth become thin adapters: HTTP, JSON
  parsing, entities, triggers, and logging
- new host suites: test_delivery (backoff/pairing/ledger, rollover) and
  test_auth_engine (the corner-case catalog against a fake board,
  including ordering, no-double-count true-up, and the grant-killed-
  while-applied reentrancy case)
- purity checker and CORE.md cover the new files
- packages/base.yaml http timeout drops to 5s: it bounds how long
  someone stands at the tap waiting for an offline decision, and the
  authenticated-pouring doc already suggested 5s
- protocol doc: commands ride every 2xx exchange, authenticated or not
- configuration doc: one_wire_id is auto-bound with a single bus, not
  required
- heartbeat_interval gains a 1s floor to match the status schema
- PLAN-kegboard-esp32.md is marked historical: several designs in it
  (local gating, token caches, API keys, a flash queue) were considered
  and rejected, and the docs and code supersede it
- code comments largely stop citing doc section numbers: the code is
  canonical over time, so comments say what the rule is rather than
  where the doc states it; pointers to doc files remain at file level
- drop the empty kegboard_kegbot remnant
Covers the authorize branch where an update sheds every one of the
target grant's old meters: the grant must survive with counters and age
intact, keep its relays (a partial ending releases nothing), and cover
the new set.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants