From 9b089dfbde53d1936f212c261c31778b2f2f9d21 Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 06:25:34 +0000 Subject: [PATCH 1/8] core: controller auth tokens and drink pour ids Groundwork for the kegboard event protocol: a paired controller holds its bearer token; drinks carry the device-assigned pour_id (unique, the dedup and correlation key). --- .../0007_kegboard_protocol_fields.py | 37 +++++++++++++++++++ pykeg/core/models.py | 18 +++++++++ 2 files changed, 55 insertions(+) create mode 100644 pykeg/core/migrations/0007_kegboard_protocol_fields.py diff --git a/pykeg/core/migrations/0007_kegboard_protocol_fields.py b/pykeg/core/migrations/0007_kegboard_protocol_fields.py new file mode 100644 index 00000000..031b5833 --- /dev/null +++ b/pykeg/core/migrations/0007_kegboard_protocol_fields.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.16 on 2026-08-04 06:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0006_hardware_ordering"), + ] + + operations = [ + migrations.AddField( + model_name="controller", + name="auth_token", + field=models.CharField( + blank=True, + editable=False, + help_text="Bearer token for the kegboard event protocol; set when the device is paired.", + max_length=128, + null=True, + unique=True, + ), + ), + migrations.AddField( + model_name="drink", + name="pour_id", + field=models.CharField( + blank=True, + editable=False, + help_text="Device-assigned pour identifier (kegboard event protocol).", + max_length=64, + null=True, + unique=True, + ), + ), + ] diff --git a/pykeg/core/models.py b/pykeg/core/models.py index 128eb2bc..8dbb7744 100644 --- a/pykeg/core/models.py +++ b/pykeg/core/models.py @@ -819,6 +819,14 @@ class Meta: serial_number = models.CharField( max_length=128, blank=True, null=True, help_text="Serial number (optional)." ) + auth_token = models.CharField( + max_length=128, + blank=True, + null=True, + unique=True, + editable=False, + help_text="Bearer token for the kegboard event protocol; set when the device is paired.", + ) def __str__(self): return f"Controller: {self.name}" @@ -1350,6 +1358,14 @@ class Meta: editable=False, help_text="Tick update sequence that generated this drink (diagnostic data).", ) + pour_id = models.CharField( + max_length=64, + blank=True, + null=True, + unique=True, + editable=False, + help_text="Device-assigned pour identifier (kegboard event protocol).", + ) picture = models.OneToOneField( "Picture", blank=True, @@ -1446,6 +1462,7 @@ def record_drink( tick_time_series="", photo=None, spilled=False, + pour_id=None, ): """Records a new drink against a given tap. @@ -1525,6 +1542,7 @@ def record_drink( duration=duration, shout=shout, tick_time_series=tick_time_series, + pour_id=pour_id or None, ) DrinkingSession.AssignSessionForDrink(d) d.save() From 60c0af9a4f307954bb08df00b2033b8cea6d7e4e Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 06:31:15 +0000 Subject: [PATCH 2/8] kegboard: event protocol v1 endpoint POST /kegboard-event implements the kegboard event protocol: bearer auth, dashboard-driven pairing over 401 responses (one-shot token delivery via a consumable redis slot), per-boot dedup cursor, and a command queue that re-sends authorize/deny until acked. - pour -> Drink.record_drink (device-authoritative volume, age_ms timing, pour_id dedup); unknown users fall back to guest - pour_update -> short-ttl redis stash for the future realtime ui - temperature -> ThermoSensor.log_sensor_reading - token attached -> authorize (all meters) or deny, same round trip - status -> roster health + flow meter sync from device calibration - unknown types accepted and ignored per spec All transient state (roster, cursor, commands, live pours) lives in redis; the only durable artifacts are the controller token and drink pour ids. --- pykeg/api/urls.py | 4 + pykeg/kegboard/__init__.py | 0 pykeg/kegboard/state.py | 147 ++++++++++++++++ pykeg/kegboard/tests.py | 337 ++++++++++++++++++++++++++++++++++++ pykeg/kegboard/views.py | 346 +++++++++++++++++++++++++++++++++++++ pykeg/web/middleware.py | 5 + 6 files changed, 839 insertions(+) create mode 100644 pykeg/kegboard/__init__.py create mode 100644 pykeg/kegboard/state.py create mode 100644 pykeg/kegboard/tests.py create mode 100644 pykeg/kegboard/views.py diff --git a/pykeg/api/urls.py b/pykeg/api/urls.py index 715eda58..e2bd0af7 100644 --- a/pykeg/api/urls.py +++ b/pykeg/api/urls.py @@ -2,6 +2,8 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from rest_framework import routers +from pykeg.kegboard import views as kegboard_views + from . import views, views_account, views_admin, views_setup router = routers.DefaultRouter(trailing_slash=False) @@ -30,6 +32,8 @@ # Must precede the router so it wins over the users/{pk} detail route. path("users/me", views.me), path("", include(router.urls)), + # Device-facing kegboard event protocol endpoint (schema-excluded). + path("kegboard-event", kegboard_views.kegboard_event, name="kegboard-event"), path("admin/backups", views_admin.backups), path("admin/backups/", views_admin.delete_backup), path("admin/bugreport", views_admin.bugreport), diff --git a/pykeg/kegboard/__init__.py b/pykeg/kegboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/pykeg/kegboard/state.py b/pykeg/kegboard/state.py new file mode 100644 index 00000000..120fc0bb --- /dev/null +++ b/pykeg/kegboard/state.py @@ -0,0 +1,147 @@ +"""Transient kegboard state, kept in redis. + +Only two things about a kegboard are durable: a paired controller's +bearer token (Controller.auth_token) and a drink's pour_id. Everything +else here is either self-refreshing (the device roster: boards +re-announce every few seconds while unpaired and every heartbeat once +paired) or short-lived by nature (the one-shot token delivery slot, +the dedup cursor, pending commands, live pour updates), so losing +redis costs at most a re-approval click — never a credential and +never a drink. +""" + +import secrets +from datetime import timedelta + +from django.core.cache import cache +from django.utils import timezone + +ROSTER_KEY = "kegboard:device:{name}" +TOKEN_SLOT_KEY = "kegboard:token-delivery:{name}" +CURSOR_KEY = "kegboard:cursor:{name}" +COMMANDS_KEY = "kegboard:commands:{name}" +POUR_UPDATE_KEY = "kegboard:pour-update:{tap_id}" + +# Devices are dropped from the roster when silent this long. Matches +# the protocol's 7-day dedup retention guidance. +ROSTER_TTL = int(timedelta(days=7).total_seconds()) +CURSOR_TTL = ROSTER_TTL +# The board polls every 5s while pairing; an hour is generous. +TOKEN_SLOT_TTL = int(timedelta(hours=1).total_seconds()) +# Commands are re-sent until acked; authorize/deny are stale within +# minutes regardless (the drinker is standing at the tap). +COMMANDS_TTL = int(timedelta(hours=1).total_seconds()) +POUR_UPDATE_TTL = 10 + +STATE_PENDING = "pending" +STATE_DENIED = "denied" +STATE_PAIRED = "paired" + + +def mint_token(): + return f"kbe_{secrets.token_hex(20)}" + + +def mint_command_id(): + return f"cmd_{secrets.token_hex(4)}" + + +# Device roster: pairing candidates and paired-device health. + + +def get_device(name): + return cache.get(ROSTER_KEY.format(name=name)) + + +def list_devices(): + keys = cache.keys(ROSTER_KEY.format(name="*")) + entries = [cache.get(key) for key in keys] + return sorted( + (entry for entry in entries if entry), + key=lambda entry: entry.get("last_seen") or "", + reverse=True, + ) + + +def update_device(name, **fields): + """Merges fields into the device's roster entry, refreshing its TTL.""" + now = timezone.now().isoformat() + entry = get_device(name) or {"device": name, "first_seen": now, "state": STATE_PENDING} + entry["last_seen"] = now + entry.update(fields) + cache.set(ROSTER_KEY.format(name=name), entry, ROSTER_TTL) + return entry + + +def set_device_state(name, state): + return update_device(name, state=state) + + +def forget_device(name): + cache.delete(ROSTER_KEY.format(name=name)) + cache.delete(CURSOR_KEY.format(name=name)) + cache.delete(COMMANDS_KEY.format(name=name)) + cache.delete(TOKEN_SLOT_KEY.format(name=name)) + + +# One-shot token delivery. + + +def stage_token(name, token): + """Arms the pairing slot: the device's next poll receives the token.""" + cache.set(TOKEN_SLOT_KEY.format(name=name), token, TOKEN_SLOT_TTL) + + +def take_staged_token(name): + """Consumes the slot; the token is never deliverable twice.""" + key = TOKEN_SLOT_KEY.format(name=name) + token = cache.get(key) + if token is not None: + cache.delete(key) + return token + + +# Dedup cursor: (boot_id, last processed event id) per device. The +# device queue is RAM-only, so once a new boot_id appears, older-boot +# events can never arrive again — one cursor per device suffices. + + +def get_cursor(name): + return cache.get(CURSOR_KEY.format(name=name)) + + +def set_cursor(name, boot_id, last_id): + cache.set(CURSOR_KEY.format(name=name), {"boot_id": boot_id, "last_id": last_id}, CURSOR_TTL) + + +# Server -> device command queue. Commands ride every 200 response +# until the device acknowledges them with a command_result event. + + +def queue_command(name, command_type, data): + command = {"id": mint_command_id(), "type": command_type, "data": data} + commands = cache.get(COMMANDS_KEY.format(name=name)) or [] + commands.append(command) + cache.set(COMMANDS_KEY.format(name=name), commands, COMMANDS_TTL) + return command + + +def pending_commands(name): + return cache.get(COMMANDS_KEY.format(name=name)) or [] + + +def ack_command(name, command_id): + key = COMMANDS_KEY.format(name=name) + commands = [c for c in (cache.get(key) or []) if c["id"] != command_id] + cache.set(key, commands, COMMANDS_TTL) + + +# Live pour state, for the (future) realtime UI. + + +def stash_pour_update(tap_id, data): + cache.set(POUR_UPDATE_KEY.format(tap_id=tap_id), data, POUR_UPDATE_TTL) + + +def get_pour_update(tap_id): + return cache.get(POUR_UPDATE_KEY.format(tap_id=tap_id)) diff --git a/pykeg/kegboard/tests.py b/pykeg/kegboard/tests.py new file mode 100644 index 00000000..32168b5f --- /dev/null +++ b/pykeg/kegboard/tests.py @@ -0,0 +1,337 @@ +"""Tests for the kegboard event protocol endpoint.""" + +import json +from datetime import timedelta + +from django.core.cache import cache +from django.test import TestCase +from django.utils import timezone + +from pykeg.core import models +from pykeg.core.util import get_version +from pykeg.kegboard import state + +ENDPOINT = "/api/kegboard-event" +DEVICE = "kegboard-a1b2c3" + +STATUS_DATA = { + "state": "heartbeat", + "fw_version": "4.0.0", + "uptime_ms": 12345, + "events_dropped": 0, + "config": {"heartbeat_ms": 60000, "pour_update_ms": 1000, "queue_capacity": 16}, +} + + +class KegboardTestCase(TestCase): + fixtures = ["testdata/demo-site.json"] + + def setUp(self): + cache.clear() + self.site = models.KegbotSite.get() + self.site.server_version = get_version() + self.site.save() + self.controller = models.Controller.objects.get(name="kegboard") + self.user = models.User.objects.exclude(username="guest").first() + + def pair(self): + self.controller.auth_token = "kbe_testtoken" + self.controller.save() + return self.controller.auth_token + + def post(self, events, device=DEVICE, boot_id="boot-1", token=None): + headers = {} + if token: + headers["HTTP_AUTHORIZATION"] = f"Bearer {token}" + return self.client.post( + ENDPOINT, + data=json.dumps( + { + "v": 1, + "device": device, + "boot_id": boot_id, + "sent_uptime_ms": 1000, + "events": events, + } + ), + content_type="application/json", + **headers, + ) + + def status_event(self, event_id=1, **overrides): + return {"id": event_id, "type": "status", "age_ms": 0, "data": {**STATUS_DATA, **overrides}} + + def pour_event(self, event_id=1, **overrides): + data = { + "meter": 0, + "pour_id": "pour-1", + "volume_ml": 355.0, + "duration_ms": 7100, + "ticks": 1919, + **overrides, + } + return {"id": event_id, "type": "pour", "age_ms": 5000, "data": data} + + +class EnvelopeTest(KegboardTestCase): + def test_malformed_batch(self): + response = self.client.post(ENDPOINT, data="{nope", content_type="application/json") + self.assertEqual(400, response.status_code) + + response = self.post([]) + self.assertEqual(400, response.status_code) + + def test_unsupported_version(self): + response = self.client.post( + ENDPOINT, + data=json.dumps( + { + "v": 2, + "device": DEVICE, + "boot_id": "b", + "sent_uptime_ms": 0, + "events": [self.status_event()], + } + ), + content_type="application/json", + ) + self.assertEqual(400, response.status_code) + + def test_oversized_batch(self): + big = "x" * (17 * 1024) + response = self.client.post( + ENDPOINT, data=json.dumps({"v": 1, "junk": big}), content_type="application/json" + ) + self.assertEqual(413, response.status_code) + + +class SetupModeTest(KegboardTestCase): + def test_setup_mode_returns_503(self): + # 5xx keeps events queued on the device until the site is ready; + # the /api setup gate's 403 would make boards drop their batches. + self.site.is_setup = False + self.site.save() + response = self.post([self.status_event()]) + self.assertEqual(503, response.status_code) + + +class PairingTest(KegboardTestCase): + def test_unpaired_device_is_pending(self): + response = self.post([self.status_event()]) + self.assertEqual(401, response.status_code) + self.assertEqual({"pairing": {"state": "pending"}}, response.json()) + + entry = state.get_device(DEVICE) + self.assertEqual("pending", entry["state"]) + self.assertTrue(entry["first_seen"]) + self.assertTrue(entry["ip"]) + + # first_seen is stable across announcements. + first_seen = entry["first_seen"] + self.post([self.status_event()]) + self.assertEqual(first_seen, state.get_device(DEVICE)["first_seen"]) + + def test_denied_device(self): + state.set_device_state(DEVICE, state.STATE_DENIED) + response = self.post([self.status_event()]) + self.assertEqual(401, response.status_code) + self.assertEqual({"pairing": {"state": "denied"}}, response.json()) + + def test_token_delivered_exactly_once(self): + state.stage_token(DEVICE, "kbe_fresh") + response = self.post([self.status_event()]) + self.assertEqual(401, response.status_code) + self.assertEqual({"pairing": {"state": "allowed", "token": "kbe_fresh"}}, response.json()) + + # The slot is consumed: the same request now re-enters pairing. + response = self.post([self.status_event()]) + self.assertEqual({"pairing": {"state": "pending"}}, response.json()) + + def test_bad_token_reenters_pairing(self): + self.pair() + response = self.post([self.status_event()], token="kbe_wrong") + self.assertEqual(401, response.status_code) + self.assertEqual({"pairing": {"state": "pending"}}, response.json()) + + +class DedupTest(KegboardTestCase): + def test_batch_replay_is_idempotent(self): + token = self.pair() + events = [self.pour_event(event_id=7)] + self.assertEqual(200, self.post(events, token=token).status_code) + self.assertEqual(1, models.Drink.objects.filter(pour_id="pour-1").count()) + + # Same batch again (lost 2xx): nothing new. + drinks_before = models.Drink.objects.count() + self.assertEqual(200, self.post(events, token=token).status_code) + self.assertEqual(drinks_before, models.Drink.objects.count()) + + def test_new_boot_resets_cursor(self): + token = self.pair() + self.post([self.pour_event(event_id=7)], boot_id="boot-1", token=token) + # Same event id in a new boot is a new event. + self.post([self.pour_event(event_id=7, pour_id="pour-2")], boot_id="boot-2", token=token) + self.assertTrue(models.Drink.objects.filter(pour_id="pour-2").exists()) + + def test_pour_id_is_second_line_of_defense(self): + token = self.pair() + self.post([self.pour_event(event_id=1)], boot_id="boot-1", token=token) + # Different transport identity, same pour_id: still one drink. + self.post([self.pour_event(event_id=1)], boot_id="boot-2", token=token) + self.assertEqual(1, models.Drink.objects.filter(pour_id="pour-1").count()) + + +class PourTest(KegboardTestCase): + def test_pour_records_drink(self): + token = self.pair() + response = self.post( + [self.pour_event(user=self.user.username, tick_series="0:3 100:14")], token=token + ) + self.assertEqual(200, response.status_code) + + drink = models.Drink.objects.get(pour_id="pour-1") + self.assertEqual(355.0, drink.volume_ml) + self.assertEqual(self.user, drink.user) + self.assertEqual(7, drink.duration) + # age_ms anchors the pour five seconds in the past. + age = timezone.now() - drink.time + self.assertTrue(timedelta(seconds=4) < age < timedelta(seconds=30)) + + def test_pour_without_user_is_guest(self): + token = self.pair() + self.post([self.pour_event()], token=token) + drink = models.Drink.objects.get(pour_id="pour-1") + self.assertTrue(drink.is_guest_pour()) + + def test_pour_with_unknown_user_is_guest(self): + token = self.pair() + self.post([self.pour_event(user="who-is-this")], token=token) + drink = models.Drink.objects.get(pour_id="pour-1") + self.assertTrue(drink.is_guest_pour()) + + def test_pour_on_unbound_meter_is_dropped(self): + token = self.pair() + response = self.post([self.pour_event(meter=9)], token=token) + self.assertEqual(200, response.status_code) + self.assertFalse(models.Drink.objects.filter(pour_id="pour-1").exists()) + + def test_pour_update_is_stashed(self): + token = self.pair() + event = { + "id": 1, + "type": "pour_update", + "age_ms": 0, + "data": {"meter": 0, "pour_id": "pour-1", "volume_ml": 120.4, "duration_ms": 2400}, + } + self.assertEqual(200, self.post([event], token=token).status_code) + meter = models.FlowMeter.objects.get(controller=self.controller, port_name="flow0") + update = state.get_pour_update(meter.tap_id) + self.assertEqual(120.4, update["volume_ml"]) + self.assertFalse(models.Drink.objects.filter(pour_id="pour-1").exists()) + + +class TemperatureTest(KegboardTestCase): + def test_temperature_logged(self): + token = self.pair() + event = { + "id": 1, + "type": "temperature", + "age_ms": 0, + "data": {"sensor": "thermo-28ff", "temp_c": 4.25}, + } + self.assertEqual(200, self.post([event], token=token).status_code) + sensor = models.ThermoSensor.objects.get(raw_name="kegboard.thermo-28ff") + self.assertEqual(4.25, sensor.LastLog().temp) + + +class TokenAuthTest(KegboardTestCase): + def token_event(self, value, event_id=1): + return { + "id": event_id, + "type": "token", + "age_ms": 0, + "data": {"auth_device": "core.rfid", "token": value, "action": "attached"}, + } + + def test_assigned_token_authorizes_all_meters(self): + token = self.pair() + models.AuthenticationToken.objects.create( + auth_device="core.rfid", token_value="0089f2c4", user=self.user + ) + response = self.post([self.token_event("0089f2c4")], token=token) + commands = response.json()["commands"] + self.assertEqual(1, len(commands)) + self.assertEqual("authorize", commands[0]["type"]) + self.assertEqual([0, 1], commands[0]["data"]["meters"]) + self.assertEqual(self.user.username, commands[0]["data"]["user"]) + + def test_unknown_token_is_denied(self): + token = self.pair() + response = self.post([self.token_event("beefbeef")], token=token) + commands = response.json()["commands"] + self.assertEqual("deny", commands[0]["type"]) + self.assertEqual("Unknown token", commands[0]["data"]["reason"]) + + def test_unassigned_token_is_denied(self): + token = self.pair() + models.AuthenticationToken.objects.create(auth_device="core.rfid", token_value="0089f2c4") + response = self.post([self.token_event("0089f2c4")], token=token) + commands = response.json()["commands"] + self.assertEqual("deny", commands[0]["type"]) + + def test_commands_resent_until_acked(self): + token = self.pair() + models.AuthenticationToken.objects.create( + auth_device="core.rfid", token_value="0089f2c4", user=self.user + ) + response = self.post([self.token_event("0089f2c4")], token=token) + command = response.json()["commands"][0] + + # Still pending on the next exchange. + response = self.post([self.status_event(event_id=2)], token=token) + self.assertEqual([command], response.json()["commands"]) + + ack = { + "id": 3, + "type": "command_result", + "age_ms": 0, + "data": {"command": command["id"], "result": "ok"}, + } + response = self.post([ack], token=token) + self.assertEqual([], response.json()["commands"]) + + +class StatusTest(KegboardTestCase): + def test_status_updates_health_and_meters(self): + token = self.pair() + event = self.status_event( + wifi_rssi_dbm=-61, + meters=[ + {"meter": 0, "total_ticks": 1000, "ml_per_tick": 0.5}, + {"meter": 2, "total_ticks": 0, "ml_per_tick": 0.25}, + ], + ) + self.assertEqual(200, self.post([event], token=token).status_code) + + entry = state.get_device(self.controller.name) + self.assertEqual("paired", entry["state"]) + self.assertEqual("4.0.0", entry["fw_version"]) + self.assertEqual(-61, entry["wifi_rssi_dbm"]) + + meter0 = models.FlowMeter.objects.get(controller=self.controller, port_name="flow0") + self.assertEqual(2.0, meter0.ticks_per_ml) + # A new meter number is created on sight. + meter2 = models.FlowMeter.objects.get(controller=self.controller, port_name="flow2") + self.assertEqual(4.0, meter2.ticks_per_ml) + + def test_unknown_event_type_is_ignored(self): + token = self.pair() + event = {"id": 1, "type": "hologram", "age_ms": 0, "data": {"x": 1}} + self.assertEqual(200, self.post([event], token=token).status_code) + + def test_bad_payload_is_dropped_not_fatal(self): + token = self.pair() + bad = {"id": 1, "type": "pour", "age_ms": 0, "data": {"nope": True}} + good = self.pour_event(event_id=2) + self.assertEqual(200, self.post([bad, good], token=token).status_code) + self.assertTrue(models.Drink.objects.filter(pour_id="pour-1").exists()) diff --git a/pykeg/kegboard/views.py b/pykeg/kegboard/views.py new file mode 100644 index 00000000..45659beb --- /dev/null +++ b/pykeg/kegboard/views.py @@ -0,0 +1,346 @@ +"""The kegboard event protocol endpoint (POST /kegboard-event). + +Implements v1 of the protocol: bearer-token authentication with a +dashboard-driven pairing flow, at-least-once delivery made idempotent +by a per-boot cursor, and event processing that flows through the +existing domain models (record_drink, log_sensor_reading), so system +events and stats behave exactly as for any other drink. +""" + +import logging +from datetime import timedelta + +from django.http import JsonResponse +from django.utils import timezone +from drf_spectacular.utils import extend_schema +from rest_framework import serializers +from rest_framework.decorators import api_view, authentication_classes, permission_classes +from rest_framework.response import Response + +from pykeg.core import models + +from . import state + +logger = logging.getLogger(__name__) + +PROTOCOL_VERSION = 1 +MAX_BODY_BYTES = 16 * 1024 +# Grant lifetime for a token-authorized pour; the device extends it +# while beer is actively flowing and clamps it to its own maximum. +AUTHORIZE_DURATION_MS = 30 * 1000 + + +class EventSerializer(serializers.Serializer): + id = serializers.IntegerField(min_value=1) + type = serializers.CharField() + age_ms = serializers.IntegerField(min_value=0) + time = serializers.DateTimeField(required=False) + data = serializers.DictField() + + +class EnvelopeSerializer(serializers.Serializer): + v = serializers.IntegerField() + device = serializers.CharField(max_length=64) + boot_id = serializers.CharField(max_length=32) + sent_uptime_ms = serializers.IntegerField(min_value=0) + events = EventSerializer(many=True, min_length=1, max_length=16) + + +class PourSerializer(serializers.Serializer): + meter = serializers.IntegerField(min_value=0) + pour_id = serializers.CharField(max_length=64) + volume_ml = serializers.FloatField(min_value=0) + duration_ms = serializers.IntegerField(min_value=0) + user = serializers.CharField(required=False) + auth_device = serializers.CharField(required=False) + auth_token = serializers.CharField(required=False) + ticks = serializers.IntegerField(min_value=0, required=False) + ml_per_tick = serializers.FloatField(required=False) + tick_series = serializers.CharField(required=False) + + +class PourUpdateSerializer(serializers.Serializer): + meter = serializers.IntegerField(min_value=0) + pour_id = serializers.CharField(max_length=64) + volume_ml = serializers.FloatField(min_value=0) + duration_ms = serializers.IntegerField(min_value=0) + + +class TemperatureSerializer(serializers.Serializer): + sensor = serializers.CharField() + temp_c = serializers.FloatField() + + +class TokenSerializer(serializers.Serializer): + auth_device = serializers.CharField() + token = serializers.CharField() + action = serializers.ChoiceField(choices=["attached", "detached"]) + status = serializers.ChoiceField(choices=["accepted", "denied"], required=False) + user = serializers.CharField(required=False) + + +class StatusSerializer(serializers.Serializer): + state = serializers.ChoiceField(choices=["boot", "heartbeat"]) + fw_version = serializers.CharField() + uptime_ms = serializers.IntegerField(min_value=0) + wifi_rssi_dbm = serializers.IntegerField(required=False) + events_dropped = serializers.IntegerField(min_value=0) + config = serializers.DictField() + meters = serializers.ListField(child=serializers.DictField(), required=False) + + +class CommandResultSerializer(serializers.Serializer): + command = serializers.CharField() + result = serializers.ChoiceField(choices=["ok", "error", "unsupported"]) + message = serializers.CharField(required=False) + + +DATA_SERIALIZERS = { + "pour": PourSerializer, + "pour_update": PourUpdateSerializer, + "temperature": TemperatureSerializer, + "token": TokenSerializer, + "status": StatusSerializer, + "command_result": CommandResultSerializer, +} + + +def _client_ip(request): + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded: + return forwarded.split(",")[0].strip() + return request.META.get("REMOTE_ADDR") + + +def _bearer_token(request): + header = request.META.get("HTTP_AUTHORIZATION", "") + if header.startswith("Bearer "): + return header[len("Bearer ") :].strip() or None + return None + + +def _pairing_response(device_name, request): + """The 401 pairing flow for any request we can't authenticate.""" + staged = state.take_staged_token(device_name) + if staged is not None: + state.set_device_state(device_name, state.STATE_PAIRED) + return JsonResponse({"pairing": {"state": "allowed", "token": staged}}, status=401) + + entry = state.get_device(device_name) + if entry and entry.get("state") == state.STATE_DENIED: + state.update_device(device_name, ip=_client_ip(request)) + return JsonResponse({"pairing": {"state": "denied"}}, status=401) + + state.update_device(device_name, state=state.STATE_PENDING, ip=_client_ip(request)) + return JsonResponse({"pairing": {"state": "pending"}}, status=401) + + +def _meter_number(port_name): + if port_name.startswith("flow"): + try: + return int(port_name[len("flow") :]) + except ValueError: + pass + return None + + +def _find_meter(controller, number): + return models.FlowMeter.objects.filter(controller=controller, port_name=f"flow{number}").first() + + +def _handle_pour(controller, data, event_time): + meter = _find_meter(controller, data["meter"]) + if not meter or not meter.tap: + logger.warning( + f"kegboard {controller.name}: pour on unbound meter {data['meter']}, dropped" + ) + return + if models.Drink.objects.filter(pour_id=data["pour_id"]).exists(): + return + username = data.get("user") + if username and not models.User.objects.filter(username=username).exists(): + logger.warning(f"kegboard {controller.name}: unknown user {username!r}, recording as guest") + username = None + try: + models.Drink.record_drink( + meter.tap, + ticks=data.get("ticks") or 0, + volume_ml=data["volume_ml"], + username=username, + pour_time=event_time, + duration=data["duration_ms"] // 1000, + tick_time_series=data.get("tick_series", ""), + pour_id=data["pour_id"], + ) + except ValueError as e: + logger.warning(f"kegboard {controller.name}: pour dropped: {e}") + + +def _handle_pour_update(controller, data, event_time): + meter = _find_meter(controller, data["meter"]) + if not meter or not meter.tap: + return + state.stash_pour_update( + meter.tap_id, + { + "pour_id": data["pour_id"], + "volume_ml": data["volume_ml"], + "duration_ms": data["duration_ms"], + "updated": timezone.now().isoformat(), + }, + ) + + +def _handle_temperature(controller, data, event_time): + raw_name = f"{controller.name}.{data['sensor']}" + sensor, _ = models.ThermoSensor.objects.get_or_create( + raw_name=raw_name, defaults={"nice_name": data["sensor"]} + ) + try: + sensor.log_sensor_reading(data["temp_c"], when=event_time) + except ValueError as e: + logger.warning(f"kegboard {controller.name}: temperature dropped: {e}") + + +def _handle_token(controller, data, event_time): + if data["action"] != "attached" or data.get("status"): + # Detaches and locally-decided presentments are audit-only. + logger.info(f"kegboard {controller.name}: token event: {data}") + return + + token = models.AuthenticationToken.objects.filter( + auth_device=data["auth_device"], token_value=data["token"] + ).first() + if token and token.IsActive() and token.user: + meters = sorted( + number + for number in ( + _meter_number(port) + for port in controller.meters.values_list("port_name", flat=True) + ) + if number is not None + ) + state.queue_command( + controller.name, + "authorize", + { + "meters": meters, + "user": token.user.username, + "duration_ms": AUTHORIZE_DURATION_MS, + "auth_device": data["auth_device"], + "token": data["token"], + }, + ) + else: + if token is None: + reason = "Unknown token" + elif not token.IsActive(): + reason = "Token is disabled" + else: + reason = "Token not assigned to a user" + state.queue_command( + controller.name, + "deny", + {"auth_device": data["auth_device"], "token": data["token"], "reason": reason}, + ) + + +def _handle_status(controller, data, event_time): + state.update_device( + controller.name, + state=state.STATE_PAIRED, + fw_version=data["fw_version"], + uptime_ms=data["uptime_ms"], + wifi_rssi_dbm=data.get("wifi_rssi_dbm"), + events_dropped=data["events_dropped"], + config=data["config"], + meters=data.get("meters"), + ) + for entry in data.get("meters") or []: + number = entry.get("meter") + ml_per_tick = entry.get("ml_per_tick") + if not isinstance(number, int) or not ml_per_tick: + continue + meter, _ = models.FlowMeter.objects.get_or_create( + controller=controller, port_name=f"flow{number}" + ) + ticks_per_ml = 1.0 / ml_per_tick + if abs(meter.ticks_per_ml - ticks_per_ml) > 1e-9: + meter.ticks_per_ml = ticks_per_ml + meter.save(update_fields=["ticks_per_ml"]) + + +def _handle_command_result(controller, data, event_time): + if data["result"] != "ok": + logger.warning(f"kegboard {controller.name}: command {data['command']}: {data}") + state.ack_command(controller.name, data["command"]) + + +EVENT_HANDLERS = { + "pour": _handle_pour, + "pour_update": _handle_pour_update, + "temperature": _handle_temperature, + "token": _handle_token, + "status": _handle_status, + "command_result": _handle_command_result, +} + + +@extend_schema(exclude=True) +@api_view(["POST"]) +@authentication_classes([]) +@permission_classes([]) +def kegboard_event(request): + """Receives a kegboard event batch; excluded from the api schema.""" + if getattr(request, "need_setup", False) or getattr(request, "need_upgrade", False): + # 5xx keeps events queued on the device until the site is ready. + return JsonResponse({"error": "setup_required"}, status=503) + + if len(request.body) > MAX_BODY_BYTES: + return JsonResponse({"error": "too_large"}, status=413) + + envelope = EnvelopeSerializer(data=request.data) + if not envelope.is_valid(): + return JsonResponse({"error": "invalid", "detail": envelope.errors}, status=400) + batch = envelope.validated_data + if batch["v"] != PROTOCOL_VERSION: + return JsonResponse({"error": "unsupported_version"}, status=400) + + device_name = batch["device"] + token = _bearer_token(request) + controller = ( + models.Controller.objects.filter(auth_token=token).first() if token is not None else None + ) + if controller is None: + return _pairing_response(device_name, request) + + state.update_device(controller.name, state=state.STATE_PAIRED, ip=_client_ip(request)) + + # Dedup: ids are monotonic per boot and the device queue does not + # survive reboot, so one (boot_id, last_id) cursor is complete. + cursor = state.get_cursor(controller.name) + last_seen_id = cursor["last_id"] if cursor and cursor["boot_id"] == batch["boot_id"] else 0 + + received = timezone.now() + max_id = last_seen_id + for event in batch["events"]: + if event["id"] <= last_seen_id: + continue + max_id = max(max_id, event["id"]) + handler = EVENT_HANDLERS.get(event["type"]) + if not handler: + logger.debug(f"kegboard {controller.name}: ignoring event type {event['type']!r}") + continue + data_serializer = DATA_SERIALIZERS[event["type"]](data=event["data"]) + if not data_serializer.is_valid(): + logger.warning( + f"kegboard {controller.name}: bad {event['type']} payload, dropped: " + f"{data_serializer.errors}" + ) + continue + event_time = received - timedelta(milliseconds=event["age_ms"]) + handler(controller, data_serializer.validated_data, event_time) + + state.set_cursor(controller.name, batch["boot_id"], max_id) + + return Response({"commands": state.pending_commands(controller.name)}) diff --git a/pykeg/web/middleware.py b/pykeg/web/middleware.py index 3bfc19cb..793adf6b 100644 --- a/pykeg/web/middleware.py +++ b/pykeg/web/middleware.py @@ -136,6 +136,11 @@ def process_view(self, request, view_func, view_args, view_kwargs): # The setup API is how the frontend performs setup/upgrade. return None + if request.path.startswith("/api/kegboard-event"): + # The kegboard endpoint answers setup mode itself (503, so + # devices keep events queued instead of dropping the batch). + return None + if not request.path.startswith("/api/"): return None From c9348a3cecd5b4c40c0dac934ae7a4ce8fccf34a Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 06:36:46 +0000 Subject: [PATCH 3/8] api: kegboard pairing admin endpoints GET /api/admin/kegboards lists unpaired boards streaming in (redis roster) merged with paired controllers; allow mints the token and creates the controller, deny/revoke/forget round out the lifecycle. Pins SCHEMA_PATH_PREFIX: operation-id prefix auto-detection breaks once a non-/api route exists. --- pykeg/api/serializers.py | 16 ++++ pykeg/api/tests.py | 106 +++++++++++++++++++++++++ pykeg/api/urls.py | 5 ++ pykeg/api/views_admin.py | 54 +++++++++++++ pykeg/kegboard/pairing.py | 62 +++++++++++++++ pykeg/kegboard/state.py | 9 ++- pykeg/settings.py | 3 + web-ui/api-client/sdk.gen.ts | 107 ++++++++++++++++++++++++- web-ui/api-client/types.gen.ts | 96 +++++++++++++++++++++++ web-ui/schema.yaml | 138 +++++++++++++++++++++++++++++++++ 10 files changed, 593 insertions(+), 3 deletions(-) create mode 100644 pykeg/kegboard/pairing.py diff --git a/pykeg/api/serializers.py b/pykeg/api/serializers.py index 21efdaa2..89289d40 100644 --- a/pykeg/api/serializers.py +++ b/pykeg/api/serializers.py @@ -614,6 +614,22 @@ class EmailTestRequestSerializer(serializers.Serializer): address = serializers.EmailField() +class KegboardDeviceSerializer(serializers.Serializer): + """A kegboard on the pairing dashboard: roster entry + health.""" + + device = serializers.CharField() + state = serializers.CharField() + first_seen = serializers.DateTimeField(required=False) + last_seen = serializers.DateTimeField(required=False) + ip = serializers.CharField(required=False, allow_null=True) + fw_version = serializers.CharField(required=False, allow_null=True) + uptime_ms = serializers.IntegerField(required=False, allow_null=True) + wifi_rssi_dbm = serializers.IntegerField(required=False, allow_null=True) + events_dropped = serializers.IntegerField(required=False, allow_null=True) + config = serializers.DictField(required=False, allow_null=True) + controller_id = serializers.IntegerField(required=False, allow_null=True) + + class SiteSettingsSerializer(serializers.ModelSerializer): """Admin-editable site settings, covering the old settings forms.""" diff --git a/pykeg/api/tests.py b/pykeg/api/tests.py index f1cf8d32..9b8df977 100644 --- a/pykeg/api/tests.py +++ b/pykeg/api/tests.py @@ -1170,6 +1170,112 @@ def test_unknown_plugin_settings(self): self.assertEqual(404, response.status_code) +class KegboardAdminTestCase(TestCase): + fixtures = ["testdata/demo-site.json"] + + def setUp(self): + cache.clear() + self.client = ApiClient() + self.site = models.KegbotSite.objects.all().first() + self.site.server_version = get_version() + self.site.save() + self.admin = models.User.objects.get(username="admin") + self.admin.is_staff = True + self.admin.save() + self.admin_key = models.ApiKey.objects.get_or_create(user=self.admin)[0] + self.alice = models.User.objects.get(username="alice") + self.alice_key = models.ApiKey.objects.get_or_create(user=self.alice)[0] + + def as_admin(self): + self.client.api_key = self.admin_key.key + self.client.add_auth() + return self.client.client + + def announce(self, device="kegboard-new"): + """Simulates an unpaired board posting a batch.""" + return self.client.client.post( + "/api/kegboard-event", + { + "v": 1, + "device": device, + "boot_id": "boot-1", + "sent_uptime_ms": 0, + "events": [ + { + "id": 1, + "type": "status", + "age_ms": 0, + "data": { + "state": "boot", + "fw_version": "4.0.0", + "uptime_ms": 0, + "events_dropped": 0, + "config": { + "heartbeat_ms": 60000, + "pour_update_ms": 1000, + "queue_capacity": 16, + }, + }, + } + ], + }, + format="json", + ) + + def test_requires_admin(self): + self.client.api_key = self.alice_key.key + status_code, _ = self.client.get("/api/admin/kegboards") + self.assertEqual(403, status_code) + + def test_pairing_lifecycle(self): + self.assertEqual(401, self.announce().status_code) + + # The board shows up pending, with pairing metadata. + response = self.as_admin().get("/api/admin/kegboards") + devices = {d["device"]: d for d in response.json()} + self.assertEqual("pending", devices["kegboard-new"]["state"]) + self.assertTrue(devices["kegboard-new"]["first_seen"]) + + # Allow: controller created, board picks up its token once. + response = self.as_admin().post("/api/admin/kegboards/kegboard-new/allow") + self.assertEqual(200, response.status_code) + self.assertEqual("allowed", response.json()["state"]) + controller = models.Controller.objects.get(name="kegboard-new") + self.assertTrue(controller.auth_token.startswith("kbe_")) + + pickup = self.announce().json() + self.assertEqual("allowed", pickup["pairing"]["state"]) + self.assertEqual(controller.auth_token, pickup["pairing"]["token"]) + + # Revoke: token cleared; board re-enters pairing. + response = self.as_admin().post("/api/admin/kegboards/kegboard-new/revoke") + self.assertEqual(204, response.status_code) + controller.refresh_from_db() + self.assertIsNone(controller.auth_token) + self.assertEqual("pending", self.announce().json()["pairing"]["state"]) + + def test_deny_and_forget(self): + self.announce() + response = self.as_admin().post("/api/admin/kegboards/kegboard-new/deny") + self.assertEqual(200, response.status_code) + self.assertEqual("denied", self.announce().json()["pairing"]["state"]) + + response = self.as_admin().delete("/api/admin/kegboards/kegboard-new") + self.assertEqual(204, response.status_code) + response = self.as_admin().get("/api/admin/kegboards") + self.assertEqual([], [d for d in response.json() if d["device"] == "kegboard-new"]) + + def test_paired_board_listed_without_roster_entry(self): + # Redis lost the roster (restart): a paired controller still shows. + controller = models.Controller.objects.get(name="kegboard") + controller.auth_token = "kbe_x" + controller.save() + response = self.as_admin().get("/api/admin/kegboards") + devices = {d["device"]: d for d in response.json()} + self.assertEqual("paired", devices["kegboard"]["state"]) + self.assertEqual(controller.id, devices["kegboard"]["controller_id"]) + + class MeEndpointTestCase(TestCase): fixtures = ["testdata/demo-site.json"] diff --git a/pykeg/api/urls.py b/pykeg/api/urls.py index e2bd0af7..72504fc2 100644 --- a/pykeg/api/urls.py +++ b/pykeg/api/urls.py @@ -39,6 +39,11 @@ path("admin/bugreport", views_admin.bugreport), path("admin/dashboard", views_admin.dashboard), path("admin/email-test", views_admin.email_test), + path("admin/kegboards", views_admin.kegboards), + path("admin/kegboards/", views_admin.kegboard_forget), + path("admin/kegboards//allow", views_admin.kegboard_allow), + path("admin/kegboards//deny", views_admin.kegboard_deny), + path("admin/kegboards//revoke", views_admin.kegboard_revoke), path("admin/logs", views_admin.logs), path("admin/plugins", views_admin.plugins), path("admin/plugins//settings", views_admin.plugin_settings), diff --git a/pykeg/api/views_admin.py b/pykeg/api/views_admin.py index 22e4c74d..42caef0a 100644 --- a/pykeg/api/views_admin.py +++ b/pykeg/api/views_admin.py @@ -24,6 +24,8 @@ from pykeg.backup import backup as backup_lib from pykeg.core import models, tasks +from pykeg.kegboard import pairing as kegboard_pairing +from pykeg.kegboard import state as kegboard_state from pykeg.logging.handlers import RedisListHandler from pykeg.util import bugreport as bugreport_util from pykeg.util.email import build_message @@ -202,3 +204,55 @@ def bugreport(request): logger.exception("Error generating bugreport") error = str(e) return Response({"output": out.getvalue(), "error": error}) + + +@extend_schema(responses=serializers.KegboardDeviceSerializer(many=True)) +@api_view(["GET"]) +@permission_classes([permissions.IsAdminUser]) +def kegboards(request): + """Lists kegboard devices: unpaired boards streaming in, and paired boards.""" + devices = kegboard_pairing.list_devices() + controller_ids = dict( + models.Controller.objects.filter(auth_token__isnull=False).values_list("name", "id") + ) + for device in devices: + device["controller_id"] = controller_ids.get(device["device"]) + return Response(serializers.KegboardDeviceSerializer(devices, many=True).data) + + +@extend_schema(request=None, responses=serializers.KegboardDeviceSerializer) +@api_view(["POST"]) +@permission_classes([permissions.IsAdminUser]) +def kegboard_allow(request, device): + """Approves a kegboard: mints its token and creates its controller.""" + controller = kegboard_pairing.allow_device(device) + entry = kegboard_state.get_device(device) or {"device": device, "state": "allowed"} + entry["controller_id"] = controller.id + return Response(serializers.KegboardDeviceSerializer(entry).data) + + +@extend_schema(request=None, responses=serializers.KegboardDeviceSerializer) +@api_view(["POST"]) +@permission_classes([permissions.IsAdminUser]) +def kegboard_deny(request, device): + """Refuses a kegboard; it stays listed so the decision can be reversed.""" + kegboard_pairing.deny_device(device) + return Response(serializers.KegboardDeviceSerializer(kegboard_state.get_device(device)).data) + + +@extend_schema(request=None, responses=None) +@api_view(["POST"]) +@permission_classes([permissions.IsAdminUser]) +def kegboard_revoke(request, device): + """Revokes a kegboard's token; its next request re-enters pairing.""" + kegboard_pairing.revoke_device(device) + return Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema(responses=None) +@api_view(["DELETE"]) +@permission_classes([permissions.IsAdminUser]) +def kegboard_forget(request, device): + """Drops a pending or denied kegboard from the roster.""" + kegboard_pairing.forget_device(device) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/pykeg/kegboard/pairing.py b/pykeg/kegboard/pairing.py new file mode 100644 index 00000000..b88431e7 --- /dev/null +++ b/pykeg/kegboard/pairing.py @@ -0,0 +1,62 @@ +"""Dashboard-side pairing operations for kegboard devices.""" + +from pykeg.core import models + +from . import state + + +def list_devices(): + """Roster entries merged with paired controllers redis has forgotten. + + A paired board that hasn't spoken since redis last restarted has no + roster entry but still holds a valid token; surface it (with no + liveness data) so it can be revoked. + """ + entries = {entry["device"]: dict(entry) for entry in state.list_devices()} + paired_names = set( + models.Controller.objects.filter(auth_token__isnull=False).values_list("name", flat=True) + ) + for name, entry in entries.items(): + stale = entry.get("state") in (state.STATE_PAIRED, state.STATE_ALLOWED) + if stale and name not in paired_names: + # Token was revoked but the board hasn't re-announced yet. + entry["state"] = state.STATE_PENDING + for name in sorted(paired_names - set(entries)): + entries[name] = {"device": name, "state": state.STATE_PAIRED} + return list(entries.values()) + + +def allow_device(name): + """Approves a device: mints its token and arms the delivery slot. + + The controller row is created (or reused) immediately so taps can + be configured before the board even picks up its token. Re-allowing + a revoked or token-lost device replaces the old token. + """ + token = state.mint_token() + controller, created = models.Controller.objects.get_or_create( + name=name, defaults={"model_name": "Kegboard"} + ) + controller.auth_token = token + controller.save(update_fields=["auth_token"]) + state.stage_token(name, token) + state.set_device_state(name, state.STATE_ALLOWED) + return controller + + +def deny_device(name): + state.set_device_state(name, state.STATE_DENIED) + + +def revoke_device(name): + """Revokes the device's token; its next request re-enters pairing. + + The controller row (and its meters, taps, drink history) is kept. + """ + models.Controller.objects.filter(name=name).update(auth_token=None) + state.forget_device(name) + + +def forget_device(name): + """Drops a pending/denied device from the roster.""" + state.forget_device(name) diff --git a/pykeg/kegboard/state.py b/pykeg/kegboard/state.py index 120fc0bb..9ade5292 100644 --- a/pykeg/kegboard/state.py +++ b/pykeg/kegboard/state.py @@ -35,6 +35,8 @@ STATE_PENDING = "pending" STATE_DENIED = "denied" +# Approved from the dashboard; token staged but not yet picked up. +STATE_ALLOWED = "allowed" STATE_PAIRED = "paired" @@ -54,8 +56,11 @@ def get_device(name): def list_devices(): - keys = cache.keys(ROSTER_KEY.format(name="*")) - entries = [cache.get(key) for key in keys] + # cache.keys() returns decorated raw keys (prefix/version); recover + # the device name from the suffix and re-fetch through the cache api. + marker = ROSTER_KEY.format(name="") + names = [key.rsplit(marker, 1)[1] for key in cache.keys(ROSTER_KEY.format(name="*"))] + entries = [get_device(name) for name in sorted(set(names))] return sorted( (entry for entry in entries if entry), key=lambda entry: entry.get("last_seen") or "", diff --git a/pykeg/settings.py b/pykeg/settings.py index bcaea433..9e33dd4d 100644 --- a/pykeg/settings.py +++ b/pykeg/settings.py @@ -341,6 +341,9 @@ "TITLE": "Kegbot Server API", "DESCRIPTION": "The Kegbot Server REST API.", "VERSION": None, + # Pin the operation-id prefix stripping: auto-detection breaks as + # soon as any non-/api route (e.g. /kegboard-event) exists. + "SCHEMA_PATH_PREFIX": "/api", "SERVE_INCLUDE_SCHEMA": False, "SERVE_PERMISSIONS": ["rest_framework.permissions.AllowAny"], # Serve the docs UI assets locally (no CDN). diff --git a/web-ui/api-client/sdk.gen.ts b/web-ui/api-client/sdk.gen.ts index c2380cec..4b46b48e 100644 --- a/web-ui/api-client/sdk.gen.ts +++ b/web-ui/api-client/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import { type Options as ClientOptions, type TDataShape, type Client, formDataBodySerializer } from './client'; -import type { AccountActivateCreateData, AccountActivateCreateResponses, AccountConfirmEmailCreateData, AccountConfirmEmailCreateResponses, AccountEmailCreateData, AccountEmailCreateResponses, AccountMugshotCreateData, AccountMugshotCreateResponses, AccountPasswordCreateData, AccountPasswordCreateResponses, AccountRegenerateApiKeyCreateData, AccountRegenerateApiKeyCreateResponses, AdminBackupsRetrieveData, AdminBackupsRetrieveResponses, AdminBackupsCreateData, AdminBackupsCreateResponses, AdminBackupsDestroyData, AdminBackupsDestroyResponses, AdminBugreportRetrieveData, AdminBugreportRetrieveResponses, AdminDashboardRetrieveData, AdminDashboardRetrieveResponses, AdminEmailTestCreateData, AdminEmailTestCreateResponses, AdminLogsRetrieveData, AdminLogsRetrieveResponses, AdminPluginsRetrieveData, AdminPluginsRetrieveResponses, AdminPluginsSettingsRetrieveData, AdminPluginsSettingsRetrieveResponses, AdminPluginsSettingsUpdateData, AdminPluginsSettingsUpdateResponses, ApiKeysListData, ApiKeysListResponses, ApiKeysCreateData, ApiKeysCreateResponses, ApiKeysDestroyData, ApiKeysDestroyResponses, ApiKeysRetrieveData, ApiKeysRetrieveResponses, ApiKeysPartialUpdateData, ApiKeysPartialUpdateResponses, ApiKeysUpdateData, ApiKeysUpdateResponses, AuthTokensListData, AuthTokensListResponses, AuthTokensCreateData, AuthTokensCreateResponses, AuthTokensDestroyData, AuthTokensDestroyResponses, AuthTokensRetrieveData, AuthTokensRetrieveResponses, AuthTokensPartialUpdateData, AuthTokensPartialUpdateResponses, AuthTokensUpdateData, AuthTokensUpdateResponses, AuthLoginCreateData, AuthLoginCreateResponses, AuthLogoutCreateData, AuthLogoutCreateResponses, AuthPasswordResetCreateData, AuthPasswordResetCreateResponses, AuthPasswordResetConfirmCreateData, AuthPasswordResetConfirmCreateResponses, AuthRegisterCreateData, AuthRegisterCreateResponses, BeverageProducersListData, BeverageProducersListResponses, BeverageProducersCreateData, BeverageProducersCreateResponses, BeverageProducersDestroyData, BeverageProducersDestroyResponses, BeverageProducersRetrieveData, BeverageProducersRetrieveResponses, BeverageProducersPartialUpdateData, BeverageProducersPartialUpdateResponses, BeverageProducersUpdateData, BeverageProducersUpdateResponses, BeverageProducersPictureCreateData, BeverageProducersPictureCreateResponses, BeveragesListData, BeveragesListResponses, BeveragesCreateData, BeveragesCreateResponses, BeveragesDestroyData, BeveragesDestroyResponses, BeveragesRetrieveData, BeveragesRetrieveResponses, BeveragesPartialUpdateData, BeveragesPartialUpdateResponses, BeveragesUpdateData, BeveragesUpdateResponses, BeveragesPictureCreateData, BeveragesPictureCreateResponses, ControllersListData, ControllersListResponses, ControllersCreateData, ControllersCreateResponses, ControllersDestroyData, ControllersDestroyResponses, ControllersRetrieveData, ControllersRetrieveResponses, ControllersPartialUpdateData, ControllersPartialUpdateResponses, ControllersUpdateData, ControllersUpdateResponses, DevicesListData, DevicesListResponses, DevicesCreateData, DevicesCreateResponses, DevicesDestroyData, DevicesDestroyResponses, DevicesRetrieveData, DevicesRetrieveResponses, DevicesPartialUpdateData, DevicesPartialUpdateResponses, DevicesUpdateData, DevicesUpdateResponses, DrinksListData, DrinksListResponses, DrinksDestroyData, DrinksDestroyResponses, DrinksRetrieveData, DrinksRetrieveResponses, DrinksPartialUpdateData, DrinksPartialUpdateResponses, DrinksPictureDestroyData, DrinksPictureDestroyResponses, DrinksPictureCreateData, DrinksPictureCreateResponses, DrinksReassignCreateData, DrinksReassignCreateResponses, EventsListData, EventsListResponses, EventsRetrieveData, EventsRetrieveResponses, FlowMetersListData, FlowMetersListResponses, FlowMetersCreateData, FlowMetersCreateResponses, FlowMetersDestroyData, FlowMetersDestroyResponses, FlowMetersRetrieveData, FlowMetersRetrieveResponses, FlowMetersPartialUpdateData, FlowMetersPartialUpdateResponses, FlowMetersUpdateData, FlowMetersUpdateResponses, FlowTogglesListData, FlowTogglesListResponses, FlowTogglesCreateData, FlowTogglesCreateResponses, FlowTogglesDestroyData, FlowTogglesDestroyResponses, FlowTogglesRetrieveData, FlowTogglesRetrieveResponses, FlowTogglesPartialUpdateData, FlowTogglesPartialUpdateResponses, FlowTogglesUpdateData, FlowTogglesUpdateResponses, InvitationsListData, InvitationsListResponses, InvitationsCreateData, InvitationsCreateResponses, InvitationsDestroyData, InvitationsDestroyResponses, InvitationsRetrieveData, InvitationsRetrieveResponses, KegsListData, KegsListResponses, KegsCreateData, KegsCreateResponses, KegsDestroyData, KegsDestroyResponses, KegsRetrieveData, KegsRetrieveResponses, KegsPartialUpdateData, KegsPartialUpdateResponses, KegsUpdateData, KegsUpdateResponses, KegsEndCreateData, KegsEndCreateResponses, KegsReactivateCreateData, KegsReactivateCreateResponses, KegsSpillCreateData, KegsSpillCreateResponses, KegsStatsRetrieveData, KegsStatsRetrieveResponses, NotificationSettingsListData, NotificationSettingsListResponses, NotificationSettingsCreateData, NotificationSettingsCreateResponses, NotificationSettingsDestroyData, NotificationSettingsDestroyResponses, NotificationSettingsRetrieveData, NotificationSettingsRetrieveResponses, NotificationSettingsPartialUpdateData, NotificationSettingsPartialUpdateResponses, NotificationSettingsUpdateData, NotificationSettingsUpdateResponses, PluginDataListData, PluginDataListResponses, PluginDataCreateData, PluginDataCreateResponses, PluginDataDestroyData, PluginDataDestroyResponses, PluginDataRetrieveData, PluginDataRetrieveResponses, PluginDataPartialUpdateData, PluginDataPartialUpdateResponses, PluginDataUpdateData, PluginDataUpdateResponses, SessionsListData, SessionsListResponses, SessionsRetrieveData, SessionsRetrieveResponses, SessionsStatsRetrieveData, SessionsStatsRetrieveResponses, SessionsCurrentRetrieveData, SessionsCurrentRetrieveResponses, SessionsDirectoryRetrieveData, SessionsDirectoryRetrieveResponses, SetupAdminUserCreateData, SetupAdminUserCreateResponses, SetupFinishCreateData, SetupFinishCreateResponses, SetupMigrateCreateData, SetupMigrateCreateResponses, SetupSettingsCreateData, SetupSettingsCreateResponses, SetupStatusRetrieveData, SetupStatusRetrieveResponses, SetupUpgradeCreateData, SetupUpgradeCreateResponses, SiteRetrieveData, SiteRetrieveResponses, SitePartialUpdateData, SitePartialUpdateResponses, SiteBackgroundImageCreateData, SiteBackgroundImageCreateResponses, StatsListData, StatsListResponses, StatsRetrieveData, StatsRetrieveResponses, StatsSystemRetrieveData, StatsSystemRetrieveResponses, StatusRetrieveData, StatusRetrieveResponses, TapsListData, TapsListResponses, TapsCreateData, TapsCreateResponses, TapsDestroyData, TapsDestroyResponses, TapsRetrieveData, TapsRetrieveResponses, TapsPartialUpdateData, TapsPartialUpdateResponses, TapsUpdateData, TapsUpdateResponses, TapsAttachKegCreateData, TapsAttachKegCreateResponses, TapsConnectMeterCreateData, TapsConnectMeterCreateResponses, TapsConnectThermoCreateData, TapsConnectThermoCreateResponses, TapsConnectToggleCreateData, TapsConnectToggleCreateResponses, TapsEndKegCreateData, TapsEndKegCreateResponses, TapsRecordDrinkCreateData, TapsRecordDrinkCreateResponses, TapsStartKegCreateData, TapsStartKegCreateResponses, ThermoLogsListData, ThermoLogsListResponses, ThermoLogsRetrieveData, ThermoLogsRetrieveResponses, ThermoSensorsListData, ThermoSensorsListResponses, ThermoSensorsCreateData, ThermoSensorsCreateResponses, ThermoSensorsDestroyData, ThermoSensorsDestroyResponses, ThermoSensorsRetrieveData, ThermoSensorsRetrieveResponses, ThermoSensorsPartialUpdateData, ThermoSensorsPartialUpdateResponses, ThermoSensorsUpdateData, ThermoSensorsUpdateResponses, UsersListData, UsersListResponses, UsersCreateData, UsersCreateResponses, UsersRetrieveData, UsersRetrieveResponses, UsersPartialUpdateData, UsersPartialUpdateResponses, UsersSetPasswordCreateData, UsersSetPasswordCreateResponses, UsersStatsRetrieveData, UsersStatsRetrieveResponses, UsersMeRetrieveData, UsersMeRetrieveResponses, UsersMePartialUpdateData, UsersMePartialUpdateResponses } from './types.gen'; +import type { AccountActivateCreateData, AccountActivateCreateResponses, AccountConfirmEmailCreateData, AccountConfirmEmailCreateResponses, AccountEmailCreateData, AccountEmailCreateResponses, AccountMugshotCreateData, AccountMugshotCreateResponses, AccountPasswordCreateData, AccountPasswordCreateResponses, AccountRegenerateApiKeyCreateData, AccountRegenerateApiKeyCreateResponses, AdminBackupsRetrieveData, AdminBackupsRetrieveResponses, AdminBackupsCreateData, AdminBackupsCreateResponses, AdminBackupsDestroyData, AdminBackupsDestroyResponses, AdminBugreportRetrieveData, AdminBugreportRetrieveResponses, AdminDashboardRetrieveData, AdminDashboardRetrieveResponses, AdminEmailTestCreateData, AdminEmailTestCreateResponses, AdminKegboardsListData, AdminKegboardsListResponses, AdminKegboardsDestroyData, AdminKegboardsDestroyResponses, AdminKegboardsAllowCreateData, AdminKegboardsAllowCreateResponses, AdminKegboardsDenyCreateData, AdminKegboardsDenyCreateResponses, AdminKegboardsRevokeCreateData, AdminKegboardsRevokeCreateResponses, AdminLogsRetrieveData, AdminLogsRetrieveResponses, AdminPluginsRetrieveData, AdminPluginsRetrieveResponses, AdminPluginsSettingsRetrieveData, AdminPluginsSettingsRetrieveResponses, AdminPluginsSettingsUpdateData, AdminPluginsSettingsUpdateResponses, ApiKeysListData, ApiKeysListResponses, ApiKeysCreateData, ApiKeysCreateResponses, ApiKeysDestroyData, ApiKeysDestroyResponses, ApiKeysRetrieveData, ApiKeysRetrieveResponses, ApiKeysPartialUpdateData, ApiKeysPartialUpdateResponses, ApiKeysUpdateData, ApiKeysUpdateResponses, AuthTokensListData, AuthTokensListResponses, AuthTokensCreateData, AuthTokensCreateResponses, AuthTokensDestroyData, AuthTokensDestroyResponses, AuthTokensRetrieveData, AuthTokensRetrieveResponses, AuthTokensPartialUpdateData, AuthTokensPartialUpdateResponses, AuthTokensUpdateData, AuthTokensUpdateResponses, AuthLoginCreateData, AuthLoginCreateResponses, AuthLogoutCreateData, AuthLogoutCreateResponses, AuthPasswordResetCreateData, AuthPasswordResetCreateResponses, AuthPasswordResetConfirmCreateData, AuthPasswordResetConfirmCreateResponses, AuthRegisterCreateData, AuthRegisterCreateResponses, BeverageProducersListData, BeverageProducersListResponses, BeverageProducersCreateData, BeverageProducersCreateResponses, BeverageProducersDestroyData, BeverageProducersDestroyResponses, BeverageProducersRetrieveData, BeverageProducersRetrieveResponses, BeverageProducersPartialUpdateData, BeverageProducersPartialUpdateResponses, BeverageProducersUpdateData, BeverageProducersUpdateResponses, BeverageProducersPictureCreateData, BeverageProducersPictureCreateResponses, BeveragesListData, BeveragesListResponses, BeveragesCreateData, BeveragesCreateResponses, BeveragesDestroyData, BeveragesDestroyResponses, BeveragesRetrieveData, BeveragesRetrieveResponses, BeveragesPartialUpdateData, BeveragesPartialUpdateResponses, BeveragesUpdateData, BeveragesUpdateResponses, BeveragesPictureCreateData, BeveragesPictureCreateResponses, ControllersListData, ControllersListResponses, ControllersCreateData, ControllersCreateResponses, ControllersDestroyData, ControllersDestroyResponses, ControllersRetrieveData, ControllersRetrieveResponses, ControllersPartialUpdateData, ControllersPartialUpdateResponses, ControllersUpdateData, ControllersUpdateResponses, DevicesListData, DevicesListResponses, DevicesCreateData, DevicesCreateResponses, DevicesDestroyData, DevicesDestroyResponses, DevicesRetrieveData, DevicesRetrieveResponses, DevicesPartialUpdateData, DevicesPartialUpdateResponses, DevicesUpdateData, DevicesUpdateResponses, DrinksListData, DrinksListResponses, DrinksDestroyData, DrinksDestroyResponses, DrinksRetrieveData, DrinksRetrieveResponses, DrinksPartialUpdateData, DrinksPartialUpdateResponses, DrinksPictureDestroyData, DrinksPictureDestroyResponses, DrinksPictureCreateData, DrinksPictureCreateResponses, DrinksReassignCreateData, DrinksReassignCreateResponses, EventsListData, EventsListResponses, EventsRetrieveData, EventsRetrieveResponses, FlowMetersListData, FlowMetersListResponses, FlowMetersCreateData, FlowMetersCreateResponses, FlowMetersDestroyData, FlowMetersDestroyResponses, FlowMetersRetrieveData, FlowMetersRetrieveResponses, FlowMetersPartialUpdateData, FlowMetersPartialUpdateResponses, FlowMetersUpdateData, FlowMetersUpdateResponses, FlowTogglesListData, FlowTogglesListResponses, FlowTogglesCreateData, FlowTogglesCreateResponses, FlowTogglesDestroyData, FlowTogglesDestroyResponses, FlowTogglesRetrieveData, FlowTogglesRetrieveResponses, FlowTogglesPartialUpdateData, FlowTogglesPartialUpdateResponses, FlowTogglesUpdateData, FlowTogglesUpdateResponses, InvitationsListData, InvitationsListResponses, InvitationsCreateData, InvitationsCreateResponses, InvitationsDestroyData, InvitationsDestroyResponses, InvitationsRetrieveData, InvitationsRetrieveResponses, KegsListData, KegsListResponses, KegsCreateData, KegsCreateResponses, KegsDestroyData, KegsDestroyResponses, KegsRetrieveData, KegsRetrieveResponses, KegsPartialUpdateData, KegsPartialUpdateResponses, KegsUpdateData, KegsUpdateResponses, KegsEndCreateData, KegsEndCreateResponses, KegsReactivateCreateData, KegsReactivateCreateResponses, KegsSpillCreateData, KegsSpillCreateResponses, KegsStatsRetrieveData, KegsStatsRetrieveResponses, NotificationSettingsListData, NotificationSettingsListResponses, NotificationSettingsCreateData, NotificationSettingsCreateResponses, NotificationSettingsDestroyData, NotificationSettingsDestroyResponses, NotificationSettingsRetrieveData, NotificationSettingsRetrieveResponses, NotificationSettingsPartialUpdateData, NotificationSettingsPartialUpdateResponses, NotificationSettingsUpdateData, NotificationSettingsUpdateResponses, PluginDataListData, PluginDataListResponses, PluginDataCreateData, PluginDataCreateResponses, PluginDataDestroyData, PluginDataDestroyResponses, PluginDataRetrieveData, PluginDataRetrieveResponses, PluginDataPartialUpdateData, PluginDataPartialUpdateResponses, PluginDataUpdateData, PluginDataUpdateResponses, SessionsListData, SessionsListResponses, SessionsRetrieveData, SessionsRetrieveResponses, SessionsStatsRetrieveData, SessionsStatsRetrieveResponses, SessionsCurrentRetrieveData, SessionsCurrentRetrieveResponses, SessionsDirectoryRetrieveData, SessionsDirectoryRetrieveResponses, SetupAdminUserCreateData, SetupAdminUserCreateResponses, SetupFinishCreateData, SetupFinishCreateResponses, SetupMigrateCreateData, SetupMigrateCreateResponses, SetupSettingsCreateData, SetupSettingsCreateResponses, SetupStatusRetrieveData, SetupStatusRetrieveResponses, SetupUpgradeCreateData, SetupUpgradeCreateResponses, SiteRetrieveData, SiteRetrieveResponses, SitePartialUpdateData, SitePartialUpdateResponses, SiteBackgroundImageCreateData, SiteBackgroundImageCreateResponses, StatsListData, StatsListResponses, StatsRetrieveData, StatsRetrieveResponses, StatsSystemRetrieveData, StatsSystemRetrieveResponses, StatusRetrieveData, StatusRetrieveResponses, TapsListData, TapsListResponses, TapsCreateData, TapsCreateResponses, TapsDestroyData, TapsDestroyResponses, TapsRetrieveData, TapsRetrieveResponses, TapsPartialUpdateData, TapsPartialUpdateResponses, TapsUpdateData, TapsUpdateResponses, TapsAttachKegCreateData, TapsAttachKegCreateResponses, TapsConnectMeterCreateData, TapsConnectMeterCreateResponses, TapsConnectThermoCreateData, TapsConnectThermoCreateResponses, TapsConnectToggleCreateData, TapsConnectToggleCreateResponses, TapsEndKegCreateData, TapsEndKegCreateResponses, TapsRecordDrinkCreateData, TapsRecordDrinkCreateResponses, TapsStartKegCreateData, TapsStartKegCreateResponses, ThermoLogsListData, ThermoLogsListResponses, ThermoLogsRetrieveData, ThermoLogsRetrieveResponses, ThermoSensorsListData, ThermoSensorsListResponses, ThermoSensorsCreateData, ThermoSensorsCreateResponses, ThermoSensorsDestroyData, ThermoSensorsDestroyResponses, ThermoSensorsRetrieveData, ThermoSensorsRetrieveResponses, ThermoSensorsPartialUpdateData, ThermoSensorsPartialUpdateResponses, ThermoSensorsUpdateData, ThermoSensorsUpdateResponses, UsersListData, UsersListResponses, UsersCreateData, UsersCreateResponses, UsersRetrieveData, UsersRetrieveResponses, UsersPartialUpdateData, UsersPartialUpdateResponses, UsersSetPasswordCreateData, UsersSetPasswordCreateResponses, UsersStatsRetrieveData, UsersStatsRetrieveResponses, UsersMeRetrieveData, UsersMeRetrieveResponses, UsersMePartialUpdateData, UsersMePartialUpdateResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -284,6 +284,111 @@ export const adminEmailTestCreate = (optio }); }; +/** + * Lists kegboard devices: unpaired boards streaming in, and paired boards. + */ +export const adminKegboardsList = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ + security: [ + { + scheme: 'basic', + type: 'http' + }, + { + in: 'cookie', + name: 'sessionid', + type: 'apiKey' + } + ], + url: '/api/admin/kegboards', + ...options + }); +}; + +/** + * Drops a pending or denied kegboard from the roster. + */ +export const adminKegboardsDestroy = (options: Options) => { + return (options.client ?? _heyApiClient).delete({ + security: [ + { + scheme: 'basic', + type: 'http' + }, + { + in: 'cookie', + name: 'sessionid', + type: 'apiKey' + } + ], + url: '/api/admin/kegboards/{device}', + ...options + }); +}; + +/** + * Approves a kegboard: mints its token and creates its controller. + */ +export const adminKegboardsAllowCreate = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + security: [ + { + scheme: 'basic', + type: 'http' + }, + { + in: 'cookie', + name: 'sessionid', + type: 'apiKey' + } + ], + url: '/api/admin/kegboards/{device}/allow', + ...options + }); +}; + +/** + * Refuses a kegboard; it stays listed so the decision can be reversed. + */ +export const adminKegboardsDenyCreate = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + security: [ + { + scheme: 'basic', + type: 'http' + }, + { + in: 'cookie', + name: 'sessionid', + type: 'apiKey' + } + ], + url: '/api/admin/kegboards/{device}/deny', + ...options + }); +}; + +/** + * Revokes a kegboard's token; its next request re-enters pairing. + */ +export const adminKegboardsRevokeCreate = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + security: [ + { + scheme: 'basic', + type: 'http' + }, + { + in: 'cookie', + name: 'sessionid', + type: 'apiKey' + } + ], + url: '/api/admin/kegboards/{device}/revoke', + ...options + }); +}; + /** * Returns recent log records (newest first) from the redis log handler. */ diff --git a/web-ui/api-client/types.gen.ts b/web-ui/api-client/types.gen.ts index 047c1cd9..08e51414 100644 --- a/web-ui/api-client/types.gen.ts +++ b/web-ui/api-client/types.gen.ts @@ -1420,6 +1420,25 @@ export type KegTapRequest = { */ export type KegTypeEnum = 'mini' | 'corny-2_5-gal' | 'corny-3-gal' | 'corny' | 'sixth' | 'euro-30-liter' | 'euro-half' | 'quarter' | 'euro' | 'half-barrel' | 'other'; +/** + * A kegboard on the pairing dashboard: roster entry + health. + */ +export type KegboardDevice = { + device: string; + state: string; + first_seen?: string; + last_seen?: string; + ip?: string | null; + fw_version?: string | null; + uptime_ms?: number | null; + wifi_rssi_dbm?: number | null; + events_dropped?: number | null; + config?: { + [key: string]: unknown; + } | null; + controller_id?: number | null; +}; + export type KegbotSite = { readonly name: string; readonly server_version: string | null; @@ -7390,6 +7409,83 @@ export type AdminEmailTestCreateResponses = { export type AdminEmailTestCreateResponse = AdminEmailTestCreateResponses[keyof AdminEmailTestCreateResponses]; +export type AdminKegboardsListData = { + body?: never; + path?: never; + query?: never; + url: '/api/admin/kegboards'; +}; + +export type AdminKegboardsListResponses = { + 200: Array; +}; + +export type AdminKegboardsListResponse = AdminKegboardsListResponses[keyof AdminKegboardsListResponses]; + +export type AdminKegboardsDestroyData = { + body?: never; + path: { + device: string; + }; + query?: never; + url: '/api/admin/kegboards/{device}'; +}; + +export type AdminKegboardsDestroyResponses = { + /** + * No response body + */ + 204: void; +}; + +export type AdminKegboardsDestroyResponse = AdminKegboardsDestroyResponses[keyof AdminKegboardsDestroyResponses]; + +export type AdminKegboardsAllowCreateData = { + body?: never; + path: { + device: string; + }; + query?: never; + url: '/api/admin/kegboards/{device}/allow'; +}; + +export type AdminKegboardsAllowCreateResponses = { + 200: KegboardDevice; +}; + +export type AdminKegboardsAllowCreateResponse = AdminKegboardsAllowCreateResponses[keyof AdminKegboardsAllowCreateResponses]; + +export type AdminKegboardsDenyCreateData = { + body?: never; + path: { + device: string; + }; + query?: never; + url: '/api/admin/kegboards/{device}/deny'; +}; + +export type AdminKegboardsDenyCreateResponses = { + 200: KegboardDevice; +}; + +export type AdminKegboardsDenyCreateResponse = AdminKegboardsDenyCreateResponses[keyof AdminKegboardsDenyCreateResponses]; + +export type AdminKegboardsRevokeCreateData = { + body?: never; + path: { + device: string; + }; + query?: never; + url: '/api/admin/kegboards/{device}/revoke'; +}; + +export type AdminKegboardsRevokeCreateResponses = { + /** + * No response body + */ + 200: unknown; +}; + export type AdminLogsRetrieveData = { body?: never; path?: never; diff --git a/web-ui/schema.yaml b/web-ui/schema.yaml index ee085a78..36f25c6b 100644 --- a/web-ui/schema.yaml +++ b/web-ui/schema.yaml @@ -270,6 +270,105 @@ paths: schema: type: boolean description: '' + /api/admin/kegboards: + get: + operationId: admin_kegboards_list + description: 'Lists kegboard devices: unpaired boards streaming in, and paired + boards.' + tags: + - admin + security: + - apiKeyBasicAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/KegboardDevice' + description: '' + /api/admin/kegboards/{device}: + delete: + operationId: admin_kegboards_destroy + description: Drops a pending or denied kegboard from the roster. + parameters: + - in: path + name: device + schema: + type: string + required: true + tags: + - admin + security: + - apiKeyBasicAuth: [] + - cookieAuth: [] + responses: + '204': + description: No response body + /api/admin/kegboards/{device}/allow: + post: + operationId: admin_kegboards_allow_create + description: 'Approves a kegboard: mints its token and creates its controller.' + parameters: + - in: path + name: device + schema: + type: string + required: true + tags: + - admin + security: + - apiKeyBasicAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KegboardDevice' + description: '' + /api/admin/kegboards/{device}/deny: + post: + operationId: admin_kegboards_deny_create + description: Refuses a kegboard; it stays listed so the decision can be reversed. + parameters: + - in: path + name: device + schema: + type: string + required: true + tags: + - admin + security: + - apiKeyBasicAuth: [] + - cookieAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KegboardDevice' + description: '' + /api/admin/kegboards/{device}/revoke: + post: + operationId: admin_kegboards_revoke_create + description: Revokes a kegboard's token; its next request re-enters pairing. + parameters: + - in: path + name: device + schema: + type: string + required: true + tags: + - admin + security: + - apiKeyBasicAuth: [] + - cookieAuth: [] + responses: + '200': + description: No response body /api/admin/logs: get: operationId: admin_logs_retrieve @@ -6331,6 +6430,45 @@ components: * `euro` - European Full Barrel (100 L) * `half-barrel` - Half Barrel (15.5 gal) * `other` - Other + KegboardDevice: + type: object + description: 'A kegboard on the pairing dashboard: roster entry + health.' + properties: + device: + type: string + state: + type: string + first_seen: + type: string + format: date-time + last_seen: + type: string + format: date-time + ip: + type: string + nullable: true + fw_version: + type: string + nullable: true + uptime_ms: + type: integer + nullable: true + wifi_rssi_dbm: + type: integer + nullable: true + events_dropped: + type: integer + nullable: true + config: + type: object + additionalProperties: {} + nullable: true + controller_id: + type: integer + nullable: true + required: + - device + - state KegbotSite: type: object properties: From 1fba56dfe970f9ca3337be07f897d77825c4637b Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 07:01:51 +0000 Subject: [PATCH 4/8] web-ui: kegboard pairing and controller configuration Controllers page is two tables: kegboards (pairing candidates and paired boards, 5s poll) and legacy manual controllers. Pending boards carry allow/deny inline, with first-seen time and source address; paired rows show liveness, firmware, wi-fi (bars + dBm, color coded), and dropped events. Clicking a row expands the controller's meters and toggles with inline tap assignment. Kegboard ports and calibration are device-managed and read-only here; deleting a kegboard's controller revokes its access and the board re-enters pairing. --- web-ui/views/admin/controllers-view.tsx | 892 +++++++++++++++++------- 1 file changed, 659 insertions(+), 233 deletions(-) diff --git a/web-ui/views/admin/controllers-view.tsx b/web-ui/views/admin/controllers-view.tsx index b4603db4..94acad0a 100644 --- a/web-ui/views/admin/controllers-view.tsx +++ b/web-ui/views/admin/controllers-view.tsx @@ -1,7 +1,13 @@ +import SignalWifi0BarIcon from "@mui/icons-material/SignalWifi0Bar"; +import SignalWifi1BarIcon from "@mui/icons-material/SignalWifi1Bar"; +import SignalWifi2BarIcon from "@mui/icons-material/SignalWifi2Bar"; +import SignalWifi3BarIcon from "@mui/icons-material/SignalWifi3Bar"; +import SignalWifi4BarIcon from "@mui/icons-material/SignalWifi4Bar"; +import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; -import Card from "@mui/material/Card"; -import CardContent from "@mui/material/CardContent"; -import CardHeader from "@mui/material/CardHeader"; +import Chip from "@mui/material/Chip"; +import Collapse from "@mui/material/Collapse"; +import MenuItem from "@mui/material/MenuItem"; import Stack from "@mui/material/Stack"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -11,8 +17,13 @@ import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TextField from "@mui/material/TextField"; import Typography from "@mui/material/Typography"; -import { type FormEvent, useState } from "react"; +import { type FormEvent, Fragment, type ReactNode, useState } from "react"; +import type { Controller, FlowMeter, FlowToggle, KegboardDevice, KegTap } from "@/api-client"; import { + adminKegboardsAllowCreate, + adminKegboardsDenyCreate, + adminKegboardsDestroy, + adminKegboardsList, controllersCreate, controllersDestroy, controllersList, @@ -22,17 +33,378 @@ import { flowTogglesCreate, flowTogglesDestroy, flowTogglesList, + tapsConnectMeterCreate, + tapsConnectToggleCreate, + tapsList, } from "@/api-client"; import { useConfirm } from "@/components/confirm-context"; import { Page } from "@/components/page"; +import { Section } from "@/components/section"; import { useSnackbar } from "@/components/snackbar-context"; +import { useFormatters } from "@/components/use-formatters"; import { toErrorMessage, unwrap } from "@/lib/api"; import { useAsyncData } from "@/lib/use-async-data"; +import { MONO_FONT } from "@/theme/typography"; + +type Act = (action: () => Promise, message: string) => Promise; + +const DEFAULT_HEARTBEAT_MS = 60_000; + +function isOnline(device: KegboardDevice): boolean { + if (!device.last_seen) { + return false; + } + const heartbeat = Number(device.config?.heartbeat_ms) || DEFAULT_HEARTBEAT_MS; + return Date.now() - new Date(device.last_seen).getTime() < heartbeat * 2.5; +} + +const WIFI_ICONS = [ + SignalWifi0BarIcon, + SignalWifi1BarIcon, + SignalWifi2BarIcon, + SignalWifi3BarIcon, + SignalWifi4BarIcon, +]; + +/** Signal-bar icon plus the raw dBm reading. */ +function WifiStrength({ rssiDbm }: { rssiDbm: number }) { + // Typical Wi-Fi quality thresholds. + const bars = + rssiDbm >= -50 ? 4 : rssiDbm >= -60 ? 3 : rssiDbm >= -70 ? 2 : rssiDbm >= -80 ? 1 : 0; + const Icon = WIFI_ICONS[bars]; + const color = bars >= 3 ? "success.main" : bars === 2 ? "warning.main" : "error.main"; + return ( + + + + {rssiDbm} dBm + + + ); +} + +function OnlineDot({ online }: { online: boolean }) { + return ( + + ); +} + +/** Inline tap picker for a meter or toggle row. */ +function TapSelect({ + taps, + value, + onChange, +}: { + taps: KegTap[]; + value: number | null | undefined; + onChange: (tapId: number | null) => void; +}) { + return ( + onChange(e.target.value === "" ? null : Number(e.target.value))} + sx={{ minWidth: 180 }} + > + + Unassigned + + {taps.map((tap) => ( + + {tap.name} + + ))} + + ); +} + +function PortRow({ + port, + detail, + children, + onDelete, +}: { + port: string; + detail?: string; + children: ReactNode; + onDelete?: () => void; +}) { + return ( + + {port} + + {detail ?? ""} + + {children} + {onDelete && ( + + )} + + ); +} + +/** + * Meters and toggles for one controller, with inline tap assignment. + * + * `managed` controllers (kegboards) own their port inventory and + * calibration device-side: ports can't be added, deleted, or + * recalibrated here — only assigned to taps. + */ +function ControllerConfig({ + controller, + meters, + toggles, + taps, + act, + managed, + footer, +}: { + controller: Controller; + meters: FlowMeter[]; + toggles: FlowToggle[]; + taps: KegTap[]; + act: Act; + managed?: boolean; + footer?: ReactNode; +}) { + const [meterForm, setMeterForm] = useState({ port: "", ticksPerMl: "" }); + const [togglePort, setTogglePort] = useState(""); + + const assignMeter = (meter: FlowMeter, tapId: number | null) => + act(async () => { + if (tapId != null) { + await unwrap(tapsConnectMeterCreate({ path: { id: tapId }, body: { meter_id: meter.id } })); + } else if (meter.tap_id != null) { + await unwrap( + tapsConnectMeterCreate({ path: { id: meter.tap_id }, body: { meter_id: null } }), + ); + } + }, "Meter assignment saved."); + + const assignToggle = (toggle: FlowToggle, tapId: number | null) => + act(async () => { + if (tapId != null) { + await unwrap( + tapsConnectToggleCreate({ path: { id: tapId }, body: { toggle_id: toggle.id } }), + ); + } else if (toggle.tap_id != null) { + await unwrap( + tapsConnectToggleCreate({ path: { id: toggle.tap_id }, body: { toggle_id: null } }), + ); + } + }, "Toggle assignment saved."); + + return ( + + + + Meters + + {meters.length === 0 && ( + + No meters. + + )} + {meters.map((meter) => ( + + void act( + () => unwrap(flowMetersDestroy({ path: { id: meter.id } })), + "Meter deleted.", + ) + } + > + void assignMeter(meter, tapId)} + /> + + ))} + {!managed && ( +
{ + e.preventDefault(); + void act( + () => + unwrap( + flowMetersCreate({ + body: { + controller_id: controller.id, + port_name: meterForm.port, + ticks_per_ml: Number(meterForm.ticksPerMl) || undefined, + } as never, + }), + ), + "Meter created.", + ).then(() => setMeterForm({ port: "", ticksPerMl: "" })); + }} + > + + setMeterForm({ ...meterForm, port: e.target.value })} + size="small" + placeholder="flow0" + required + /> + setMeterForm({ ...meterForm, ticksPerMl: e.target.value })} + size="small" + sx={{ width: 130 }} + /> + + +
+ )} +
+ + + + Toggles + + {toggles.length === 0 && ( + + No toggles. + + )} + {toggles.map((toggle) => ( + + void act( + () => unwrap(flowTogglesDestroy({ path: { id: toggle.id } })), + "Toggle deleted.", + ) + } + > + void assignToggle(toggle, tapId)} + /> + + ))} + {!managed && ( +
{ + e.preventDefault(); + void act( + () => + unwrap( + flowTogglesCreate({ + body: { + controller_id: controller.id, + port_name: togglePort, + } as never, + }), + ), + "Toggle created.", + ).then(() => setTogglePort("")); + }} + > + + setTogglePort(e.target.value)} + size="small" + placeholder="relay0" + required + /> + + +
+ )} +
+ + {managed && ( + + Ports and calibration are managed by the board. + + )} + + {footer} +
+ ); +} + +/** A clickable row plus its collapsible detail row. */ +function ExpandableRow({ + cells, + colSpan, + expanded, + onToggle, + children, +}: { + cells: ReactNode; + colSpan: number; + expanded: boolean; + onToggle: () => void; + children: ReactNode; +}) { + return ( + + td": expanded ? { borderBottom: "none" } : undefined }} + > + {cells} + + + + + {children} + + + + + ); +} export function ControllersView() { const { showMessage } = useSnackbar(); + const { relative } = useFormatters(); const confirm = useConfirm(); + const kegboards = useAsyncData(async () => unwrap(adminKegboardsList()), { pollMs: 5000 }); const controllers = useAsyncData( async () => (await unwrap(controllersList({ query: { page_size: 100 } }))).results ?? [], ); @@ -42,18 +414,22 @@ export function ControllersView() { const toggles = useAsyncData( async () => (await unwrap(flowTogglesList({ query: { page_size: 100 } }))).results ?? [], ); + const taps = useAsyncData( + async () => (await unwrap(tapsList({ query: { page_size: 100 } }))).results ?? [], + ); + const [expanded, setExpanded] = useState(null); const [controllerName, setControllerName] = useState(""); - const [meterForm, setMeterForm] = useState({ controllerId: "", port: "", ticksPerMl: "" }); - const [toggleForm, setToggleForm] = useState({ controllerId: "", port: "" }); const reloadAll = () => { + kegboards.reload(); controllers.reload(); meters.reload(); toggles.reload(); + taps.reload(); }; - const act = async (action: () => Promise, message: string) => { + const act: Act = async (action, message) => { try { await action(); showMessage(message); @@ -63,263 +439,313 @@ export function ControllersView() { } }; - const addController = (event: FormEvent) => { - event.preventDefault(); - void act( - () => unwrap(controllersCreate({ body: { name: controllerName } })), - "Controller created.", - ).then(() => setControllerName("")); - }; + const toggleExpanded = (key: string) => setExpanded((current) => (current === key ? null : key)); + + const configFor = (controller: Controller, managed: boolean, footer?: ReactNode) => ( + m.controller_id === controller.id)} + toggles={(toggles.data ?? []).filter((t) => t.controller_id === controller.id)} + taps={taps.data ?? []} + act={act} + managed={managed} + footer={footer} + /> + ); + + // Kegboard actions. + + const allow = (device: KegboardDevice) => + act( + () => unwrap(adminKegboardsAllowCreate({ path: { device: device.device } })), + `${device.device} allowed; it will pair on its next check-in.`, + ); + + const deny = (device: KegboardDevice) => + act( + () => unwrap(adminKegboardsDenyCreate({ path: { device: device.device } })), + `${device.device} denied.`, + ); - const removeController = async (id: number, name: string) => { + const forget = (device: KegboardDevice) => + act( + () => unwrap(adminKegboardsDestroy({ path: { device: device.device } })), + `${device.device} removed.`, + ); + + const removeController = async (controller: Controller, isKegboard: boolean) => { if ( await confirm({ - title: `Delete controller "${name}"?`, + title: `Delete controller "${controller.name}"?`, + message: isKegboard + ? "Its meters and toggles are deleted with it, and the board's access is revoked. Drinks are kept; the board will reappear here for pairing." + : "Its meters and toggles are deleted with it. Drinks are kept.", confirmText: "Delete", destructive: true, }) ) { - await act(() => unwrap(controllersDestroy({ path: { id } })), "Controller deleted."); + await act( + () => unwrap(controllersDestroy({ path: { id: controller.id } })), + "Controller deleted.", + ); } }; + const allKegboards = kegboards.data ?? []; + const kegboardControllerIds = new Set( + allKegboards.map((d) => d.controller_id).filter((id) => id != null), + ); + const controllerById = new Map((controllers.data ?? []).map((c) => [c.id, c])); + const otherControllers = (controllers.data ?? []).filter((c) => !kegboardControllerIds.has(c.id)); + + const stateChip = (device: KegboardDevice) => { + if (device.state === "paired") { + return device.last_seen ? relative(device.last_seen) : "—"; + } + const label = + device.state === "pending" + ? "wants to pair" + : device.state === "allowed" + ? "pairing…" + : "denied"; + const color = device.state === "pending" ? "warning" : "default"; + return ; + }; + return ( - - - - - + +
+ + + Kegboards on your network announce themselves here automatically; approve a board to + start recording its pours. + + {allKegboards.length === 0 ? ( + + No kegboards have announced themselves yet. A board configured with this server's + address appears here automatically. + + ) : ( - Name - Model - Serial - + Board + Last seen + Firmware + Wi-Fi + Dropped + - {(controllers.data ?? []).map((controller) => ( - - {controller.name} - {controller.model_name} - {controller.serial_number} - - - - - ))} - -
-
-
- - setControllerName(e.target.value)} - size="small" - required - sx={{ flexGrow: 1 }} - /> - - -
-
- - - - - - - - - - - {(meters.data ?? []).map((meter) => ( - - Meter #{meter.id} - port {meter.port_name} - {meter.ticks_per_ml} ticks/mL - - {meter.tap_id != null ? `tap #${meter.tap_id}` : "unattached"} - - - - - - ))} + {allKegboards.map((device) => { + const controller = + device.controller_id != null + ? controllerById.get(device.controller_id) + : undefined; + const cells = ( + + + + {device.device} + + + {stateChip(device)} + + + {device.fw_version ?? "—"} + + + {device.wifi_rssi_dbm != null ? ( + + ) : ( + "—" + )} + + + {(device.events_dropped ?? 0) > 0 ? ( + + ) : ( + "0" + )} + + e.stopPropagation()}> + {(device.state === "pending" || device.state === "denied") && ( + + + {device.state === "denied" ? ( + + ) : ( + + )} + + )} + + + ); + if (!controller) { + return ( + + {cells.props.children} + + ); + } + return ( + toggleExpanded(device.device)} + > + {configFor( + controller, + true, + + + , + )} + + ); + })}
-
{ - e.preventDefault(); - void act( - () => - unwrap( - flowMetersCreate({ - body: { - controller_id: Number(meterForm.controllerId), - port_name: meterForm.port, - ticks_per_ml: Number(meterForm.ticksPerMl) || undefined, - } as never, - }), - ), - "Meter created.", - ); - }} - > - - setMeterForm({ ...meterForm, controllerId: e.target.value })} - size="small" - sx={{ minWidth: 160 }} - slotProps={{ select: { native: true } }} - > - - ))} - - setMeterForm({ ...meterForm, port: e.target.value })} - size="small" - required - /> - setMeterForm({ ...meterForm, ticksPerMl: e.target.value })} - size="small" - /> - - -
-
-
-
+ )} + +
- - - - +
+ + + Manually configured (legacy) controllers, e.g. kegbot-pycore. + + {otherControllers.length > 0 && ( + + + Name + Model + Serial + Ports + + - {(toggles.data ?? []).map((toggle) => ( - - Toggle #{toggle.id} - port {toggle.port_name} - - {toggle.tap_id != null ? `tap #${toggle.tap_id}` : "unattached"} - - - - - - ))} + {otherControllers.map((controller) => { + const meterCount = (meters.data ?? []).filter( + (m) => m.controller_id === controller.id, + ).length; + const toggleCount = (toggles.data ?? []).filter( + (t) => t.controller_id === controller.id, + ).length; + return ( + toggleExpanded(`c${controller.id}`)} + cells={ + + + {controller.name} + + + {controller.model_name ?? "—"} + + + {controller.serial_number ?? "—"} + + + {meterCount} {meterCount === 1 ? "meter" : "meters"} · {toggleCount}{" "} + {toggleCount === 1 ? "toggle" : "toggles"} + + + } + > + {configFor( + controller, + false, + + + , + )} + + ); + })}
-
{ - e.preventDefault(); - void act( - () => - unwrap( - flowTogglesCreate({ - body: { - controller_id: Number(toggleForm.controllerId), - port_name: toggleForm.port, - } as never, - }), - ), - "Toggle created.", - ); - }} - > - - setToggleForm({ ...toggleForm, controllerId: e.target.value })} - size="small" - sx={{ minWidth: 160 }} - slotProps={{ select: { native: true } }} - > - - ))} - - setToggleForm({ ...toggleForm, port: e.target.value })} - size="small" - required - /> - - -
- - Attach meters, toggles, and sensors to taps from each tap's page. - -
- - + )} +
e.preventDefault()}> + + setControllerName(e.target.value)} + size="small" + required + sx={{ flexGrow: 1 }} + /> + + +
+ +
); From ea64559b3d614d76b32e8ad225b7d56fa5d311c8 Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 06:42:56 +0000 Subject: [PATCH 5/8] docs: kegboard pairing guide and changelog entry --- docs/source/index.rst | 1 + docs/source/kegboards.rst | 54 ++++++++++++++++++++++++++++++ docs/source/releases/changelog.rst | 8 +++++ 3 files changed, 63 insertions(+) create mode 100644 docs/source/kegboards.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index c47fb124..cc45e9f7 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ Contents: overview install settings + kegboards management commands developers diff --git a/docs/source/kegboards.rst b/docs/source/kegboards.rst new file mode 100644 index 00000000..a0c82789 --- /dev/null +++ b/docs/source/kegboards.rst @@ -0,0 +1,54 @@ +.. _kegboards: + +Kegboards +========= + +Kegboard v4 controllers report to the server over HTTP using the kegboard +event protocol. The server receives batches of events — pours, temperature +readings, token presentments, and heartbeats — at:: + + POST /api/kegboard-event + +Configure the board with this URL (path included). Everything else is +driven from the server. + +Pairing a board +--------------- + +Boards authenticate with a bearer token that the server provisions; you +never handle a credential yourself: + +1. Point the board at your server's ``/api/kegboard-event`` URL. +2. Open **Admin → Controllers**. The board announces itself and appears + in the *Kegboards* section within a few seconds, with its first-seen + time and source address. +3. Click **Allow**. This creates a controller for the board and stages + its token; the board picks it up on its next check-in (within + seconds) and starts delivering events. Events that occurred before + pairing were queued on the board and deliver afterwards. + +Click **Deny** to refuse a board; it stays listed so the decision can be +reversed. To disconnect a paired board, delete its controller — this +also invalidates the board's token, so it reappears for pairing on its +next check-in (drinks are kept). Re-allowing a board always issues a +fresh token. + +Use TLS for the reporting URL whenever possible: the token is a plain +bearer credential. + +What gets recorded +------------------ + +* **Pours** become drinks on the tap bound to the reporting meter + (bind meters to taps from each tap's admin page). The board's own + calibrated volume is authoritative. Pours on unbound meters are + logged and dropped. +* **Meters** are created automatically (``flow0``, ``flow1``, ...) from + the board's status reports, including calibration. +* **Temperature readings** are logged against auto-created sensors named + ``.``. +* **Token presentments** are checked against the token database + (**Admin → Tokens**): an active, assigned token authorizes pouring on + the board's meters for 30 seconds; anything else is refused. +* **Heartbeats** drive the liveness, firmware, signal, and dropped-event + columns in the Kegboards section. diff --git a/docs/source/releases/changelog.rst b/docs/source/releases/changelog.rst index 1d4ecb22..478d698b 100644 --- a/docs/source/releases/changelog.rst +++ b/docs/source/releases/changelog.rst @@ -32,6 +32,14 @@ brought up to date. activation); admin user management and site settings; backups, logs, test e-mail, and bugreport endpoints; an API-driven setup wizard; and plugin settings. +* **Kegboard v4 boards are supported natively** via the new kegboard event + protocol endpoint at ``/api/kegboard-event``. Boards pair from the admin + Controllers page — an unprovisioned board announces itself and appears + there automatically; approving it creates a controller and provisions its + bearer token, with no key entry. Pours (device-authoritative volumes), + temperature readings, and heartbeats flow in over HTTP with outage-proof + queueing, and token presentments are authorized or denied by the server + in a single round trip. * Python 3.14 is now required (was 3.10). * Django 5.2 LTS (was 3.2). * Web server switched from gunicorn/gevent to waitress. From a3afe09531a9ef10e11cab3929c35392b44f2232 Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 16:17:26 +0000 Subject: [PATCH 6/8] kegboard: surface rejected batches on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A board whose batches all 400 looked stuck at "pairing…" while plainly hitting the server. Rejections now pin last_error to the roster entry (cleared by the next good request) and the dashboard shows a "rejected requests" chip with the reason on hover; rejects are also logged with the device name. --- pykeg/api/serializers.py | 2 ++ pykeg/kegboard/tests.py | 17 +++++++++++++++ pykeg/kegboard/views.py | 29 +++++++++++++++++++++---- web-ui/api-client/types.gen.ts | 1 + web-ui/schema.yaml | 3 +++ web-ui/views/admin/controllers-view.tsx | 20 +++++++++++++++-- 6 files changed, 66 insertions(+), 6 deletions(-) diff --git a/pykeg/api/serializers.py b/pykeg/api/serializers.py index 89289d40..b903046a 100644 --- a/pykeg/api/serializers.py +++ b/pykeg/api/serializers.py @@ -628,6 +628,8 @@ class KegboardDeviceSerializer(serializers.Serializer): events_dropped = serializers.IntegerField(required=False, allow_null=True) config = serializers.DictField(required=False, allow_null=True) controller_id = serializers.IntegerField(required=False, allow_null=True) + # Why the device's most recent batch was rejected, if it was. + last_error = serializers.CharField(required=False, allow_null=True) class SiteSettingsSerializer(serializers.ModelSerializer): diff --git a/pykeg/kegboard/tests.py b/pykeg/kegboard/tests.py index 32168b5f..1795d9db 100644 --- a/pykeg/kegboard/tests.py +++ b/pykeg/kegboard/tests.py @@ -97,6 +97,23 @@ def test_unsupported_version(self): ) self.assertEqual(400, response.status_code) + def test_rejected_batch_is_pinned_to_the_roster(self): + # A board whose batches all 400 still surfaces on the dashboard, + # with the rejection reason, instead of silently "pairing" forever. + response = self.client.post( + ENDPOINT, + data=json.dumps({"v": 1, "device": DEVICE, "events": []}), + content_type="application/json", + ) + self.assertEqual(400, response.status_code) + entry = state.get_device(DEVICE) + self.assertIn("invalid batch", entry["last_error"]) + self.assertTrue(entry["last_seen"]) + + # A good batch clears the error. + self.post([self.status_event()]) + self.assertIsNone(state.get_device(DEVICE)["last_error"]) + def test_oversized_batch(self): big = "x" * (17 * 1024) response = self.client.post( diff --git a/pykeg/kegboard/views.py b/pykeg/kegboard/views.py index 45659beb..978522f4 100644 --- a/pykeg/kegboard/views.py +++ b/pykeg/kegboard/views.py @@ -123,18 +123,35 @@ def _pairing_response(device_name, request): """The 401 pairing flow for any request we can't authenticate.""" staged = state.take_staged_token(device_name) if staged is not None: - state.set_device_state(device_name, state.STATE_PAIRED) + state.update_device(device_name, state=state.STATE_PAIRED, last_error=None) return JsonResponse({"pairing": {"state": "allowed", "token": staged}}, status=401) entry = state.get_device(device_name) if entry and entry.get("state") == state.STATE_DENIED: - state.update_device(device_name, ip=_client_ip(request)) + state.update_device(device_name, ip=_client_ip(request), last_error=None) return JsonResponse({"pairing": {"state": "denied"}}, status=401) - state.update_device(device_name, state=state.STATE_PENDING, ip=_client_ip(request)) + state.update_device( + device_name, state=state.STATE_PENDING, ip=_client_ip(request), last_error=None + ) return JsonResponse({"pairing": {"state": "pending"}}, status=401) +def _note_rejected_batch(request, error): + """Pins a rejected batch to its device's roster entry, best effort. + + A board whose batches all 400 would otherwise be invisible on the + dashboard while plainly "reporting" in the server logs. + """ + data = request.data + device_name = data.get("device") if isinstance(data, dict) else None + if not isinstance(device_name, str) or not device_name or len(device_name) > 64: + logger.warning(f"kegboard: rejected batch from unidentifiable device: {error}") + return + logger.warning(f"kegboard {device_name}: rejected batch: {error}") + state.update_device(device_name, ip=_client_ip(request), last_error=str(error)[:300]) + + def _meter_number(port_name): if port_name.startswith("flow"): try: @@ -301,9 +318,11 @@ def kegboard_event(request): envelope = EnvelopeSerializer(data=request.data) if not envelope.is_valid(): + _note_rejected_batch(request, f"invalid batch: {envelope.errors}") return JsonResponse({"error": "invalid", "detail": envelope.errors}, status=400) batch = envelope.validated_data if batch["v"] != PROTOCOL_VERSION: + _note_rejected_batch(request, f"unsupported protocol version {batch['v']}") return JsonResponse({"error": "unsupported_version"}, status=400) device_name = batch["device"] @@ -314,7 +333,9 @@ def kegboard_event(request): if controller is None: return _pairing_response(device_name, request) - state.update_device(controller.name, state=state.STATE_PAIRED, ip=_client_ip(request)) + state.update_device( + controller.name, state=state.STATE_PAIRED, ip=_client_ip(request), last_error=None + ) # Dedup: ids are monotonic per boot and the device queue does not # survive reboot, so one (boot_id, last_id) cursor is complete. diff --git a/web-ui/api-client/types.gen.ts b/web-ui/api-client/types.gen.ts index 08e51414..b6d7afb5 100644 --- a/web-ui/api-client/types.gen.ts +++ b/web-ui/api-client/types.gen.ts @@ -1437,6 +1437,7 @@ export type KegboardDevice = { [key: string]: unknown; } | null; controller_id?: number | null; + last_error?: string | null; }; export type KegbotSite = { diff --git a/web-ui/schema.yaml b/web-ui/schema.yaml index 36f25c6b..73fc1f67 100644 --- a/web-ui/schema.yaml +++ b/web-ui/schema.yaml @@ -6466,6 +6466,9 @@ components: controller_id: type: integer nullable: true + last_error: + type: string + nullable: true required: - device - state diff --git a/web-ui/views/admin/controllers-view.tsx b/web-ui/views/admin/controllers-view.tsx index 94acad0a..ec0ca64d 100644 --- a/web-ui/views/admin/controllers-view.tsx +++ b/web-ui/views/admin/controllers-view.tsx @@ -499,8 +499,19 @@ export function ControllersView() { const otherControllers = (controllers.data ?? []).filter((c) => !kegboardControllerIds.has(c.id)); const stateChip = (device: KegboardDevice) => { + // A rejected batch trumps the pairing state: the board is talking, + // but the server can't understand it. + const errorChip = device.last_error ? ( + + ) : null; if (device.state === "paired") { - return device.last_seen ? relative(device.last_seen) : "—"; + return errorChip ?? (device.last_seen ? relative(device.last_seen) : "—"); } const label = device.state === "pending" @@ -509,7 +520,12 @@ export function ControllersView() { ? "pairing…" : "denied"; const color = device.state === "pending" ? "warning" : "default"; - return ; + return ( + + + {errorChip} + + ); }; return ( From 9d08bd31959cb2f5383a66915f6de9397cbdad1d Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Tue, 4 Aug 2026 16:48:38 +0000 Subject: [PATCH 7/8] kegboard: adopt renamed protocol fields Protocol rename upstream: meter_number/relay_number in objects, meter_numbers in authorize/deauthorize, relays instead of toggles. Controllers page now says Relays; the FlowToggle model keeps its name (internal, and shared with the legacy v1 api). --- pykeg/kegboard/tests.py | 17 +++++++++++------ pykeg/kegboard/views.py | 14 +++++++------- web-ui/views/admin/controllers-view.tsx | 16 ++++++++-------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/pykeg/kegboard/tests.py b/pykeg/kegboard/tests.py index 1795d9db..7ec9b427 100644 --- a/pykeg/kegboard/tests.py +++ b/pykeg/kegboard/tests.py @@ -63,7 +63,7 @@ def status_event(self, event_id=1, **overrides): def pour_event(self, event_id=1, **overrides): data = { - "meter": 0, + "meter_number": 0, "pour_id": "pour-1", "volume_ml": 355.0, "duration_ms": 7100, @@ -228,7 +228,7 @@ def test_pour_with_unknown_user_is_guest(self): def test_pour_on_unbound_meter_is_dropped(self): token = self.pair() - response = self.post([self.pour_event(meter=9)], token=token) + response = self.post([self.pour_event(meter_number=9)], token=token) self.assertEqual(200, response.status_code) self.assertFalse(models.Drink.objects.filter(pour_id="pour-1").exists()) @@ -238,7 +238,12 @@ def test_pour_update_is_stashed(self): "id": 1, "type": "pour_update", "age_ms": 0, - "data": {"meter": 0, "pour_id": "pour-1", "volume_ml": 120.4, "duration_ms": 2400}, + "data": { + "meter_number": 0, + "pour_id": "pour-1", + "volume_ml": 120.4, + "duration_ms": 2400, + }, } self.assertEqual(200, self.post([event], token=token).status_code) meter = models.FlowMeter.objects.get(controller=self.controller, port_name="flow0") @@ -279,7 +284,7 @@ def test_assigned_token_authorizes_all_meters(self): commands = response.json()["commands"] self.assertEqual(1, len(commands)) self.assertEqual("authorize", commands[0]["type"]) - self.assertEqual([0, 1], commands[0]["data"]["meters"]) + self.assertEqual([0, 1], commands[0]["data"]["meter_numbers"]) self.assertEqual(self.user.username, commands[0]["data"]["user"]) def test_unknown_token_is_denied(self): @@ -324,8 +329,8 @@ def test_status_updates_health_and_meters(self): event = self.status_event( wifi_rssi_dbm=-61, meters=[ - {"meter": 0, "total_ticks": 1000, "ml_per_tick": 0.5}, - {"meter": 2, "total_ticks": 0, "ml_per_tick": 0.25}, + {"meter_number": 0, "total_ticks": 1000, "ml_per_tick": 0.5}, + {"meter_number": 2, "total_ticks": 0, "ml_per_tick": 0.25}, ], ) self.assertEqual(200, self.post([event], token=token).status_code) diff --git a/pykeg/kegboard/views.py b/pykeg/kegboard/views.py index 978522f4..17a37fbb 100644 --- a/pykeg/kegboard/views.py +++ b/pykeg/kegboard/views.py @@ -47,7 +47,7 @@ class EnvelopeSerializer(serializers.Serializer): class PourSerializer(serializers.Serializer): - meter = serializers.IntegerField(min_value=0) + meter_number = serializers.IntegerField(min_value=0) pour_id = serializers.CharField(max_length=64) volume_ml = serializers.FloatField(min_value=0) duration_ms = serializers.IntegerField(min_value=0) @@ -60,7 +60,7 @@ class PourSerializer(serializers.Serializer): class PourUpdateSerializer(serializers.Serializer): - meter = serializers.IntegerField(min_value=0) + meter_number = serializers.IntegerField(min_value=0) pour_id = serializers.CharField(max_length=64) volume_ml = serializers.FloatField(min_value=0) duration_ms = serializers.IntegerField(min_value=0) @@ -166,10 +166,10 @@ def _find_meter(controller, number): def _handle_pour(controller, data, event_time): - meter = _find_meter(controller, data["meter"]) + meter = _find_meter(controller, data["meter_number"]) if not meter or not meter.tap: logger.warning( - f"kegboard {controller.name}: pour on unbound meter {data['meter']}, dropped" + f"kegboard {controller.name}: pour on unbound meter {data['meter_number']}, dropped" ) return if models.Drink.objects.filter(pour_id=data["pour_id"]).exists(): @@ -194,7 +194,7 @@ def _handle_pour(controller, data, event_time): def _handle_pour_update(controller, data, event_time): - meter = _find_meter(controller, data["meter"]) + meter = _find_meter(controller, data["meter_number"]) if not meter or not meter.tap: return state.stash_pour_update( @@ -241,7 +241,7 @@ def _handle_token(controller, data, event_time): controller.name, "authorize", { - "meters": meters, + "meter_numbers": meters, "user": token.user.username, "duration_ms": AUTHORIZE_DURATION_MS, "auth_device": data["auth_device"], @@ -274,7 +274,7 @@ def _handle_status(controller, data, event_time): meters=data.get("meters"), ) for entry in data.get("meters") or []: - number = entry.get("meter") + number = entry.get("meter_number") ml_per_tick = entry.get("ml_per_tick") if not isinstance(number, int) or not ml_per_tick: continue diff --git a/web-ui/views/admin/controllers-view.tsx b/web-ui/views/admin/controllers-view.tsx index ec0ca64d..3e86f6bb 100644 --- a/web-ui/views/admin/controllers-view.tsx +++ b/web-ui/views/admin/controllers-view.tsx @@ -210,7 +210,7 @@ function ControllerConfig({ tapsConnectToggleCreate({ path: { id: toggle.tap_id }, body: { toggle_id: null } }), ); } - }, "Toggle assignment saved."); + }, "Relay assignment saved."); return ( @@ -291,11 +291,11 @@ function ControllerConfig({ - Toggles + Relays {toggles.length === 0 && ( - No toggles. + No relays. )} {toggles.map((toggle) => ( @@ -308,7 +308,7 @@ function ControllerConfig({ : () => void act( () => unwrap(flowTogglesDestroy({ path: { id: toggle.id } })), - "Toggle deleted.", + "Relay deleted.", ) } > @@ -333,13 +333,13 @@ function ControllerConfig({ } as never, }), ), - "Toggle created.", + "Relay created.", ).then(() => setTogglePort("")); }} > setTogglePort(e.target.value)} size="small" @@ -347,7 +347,7 @@ function ControllerConfig({ required /> @@ -710,7 +710,7 @@ export function ControllersView() { {meterCount} {meterCount === 1 ? "meter" : "meters"} · {toggleCount}{" "} - {toggleCount === 1 ? "toggle" : "toggles"} + {toggleCount === 1 ? "relay" : "relays"} } From 3350ebf26df1ebb44d6af48de97aca384b3755b7 Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Wed, 5 Aug 2026 16:45:23 +0000 Subject: [PATCH 8/8] kegboard: adopt the grant model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The protocol moved authorization into server-issued grants: authorize now carries a grant_id, meter_numbers/relay_numbers sets (the meter-relay association is ours, from tap bindings), and limits (we set a 30s idle limit; the device clamp bounds lifetime). Identity left the wire — pours echo grant_id and the server resolves the user from its grant record (redis, 7-day ttl, so queued pours delivered late still attribute; no grant means guest). token events are always a question now, and grant_end events report every ending with a reason (logged; pours remain the volume record). --- docs/source/kegboards.rst | 7 ++- pykeg/kegboard/state.py | 21 +++++++++ pykeg/kegboard/tests.py | 60 +++++++++++++++++++++----- pykeg/kegboard/views.py | 90 +++++++++++++++++++++++++++++++-------- 4 files changed, 148 insertions(+), 30 deletions(-) diff --git a/docs/source/kegboards.rst b/docs/source/kegboards.rst index a0c82789..e8b8ad01 100644 --- a/docs/source/kegboards.rst +++ b/docs/source/kegboards.rst @@ -48,7 +48,10 @@ What gets recorded * **Temperature readings** are logged against auto-created sensors named ``.``. * **Token presentments** are checked against the token database - (**Admin → Tokens**): an active, assigned token authorizes pouring on - the board's meters for 30 seconds; anything else is refused. + (**Admin → Tokens**): an active, assigned token receives a pouring + grant covering the board's meters and the relays bound to their taps + (30-second idle limit; the board's own safety clamp bounds total + time); anything else is refused. Pours are attributed from the grant, + so identity never travels to the board. * **Heartbeats** drive the liveness, firmware, signal, and dropped-event columns in the Kegboards section. diff --git a/pykeg/kegboard/state.py b/pykeg/kegboard/state.py index 9ade5292..419a2570 100644 --- a/pykeg/kegboard/state.py +++ b/pykeg/kegboard/state.py @@ -21,6 +21,7 @@ CURSOR_KEY = "kegboard:cursor:{name}" COMMANDS_KEY = "kegboard:commands:{name}" POUR_UPDATE_KEY = "kegboard:pour-update:{tap_id}" +GRANT_KEY = "kegboard:grant:{grant_id}" # Devices are dropped from the roster when silent this long. Matches # the protocol's 7-day dedup retention guidance. @@ -32,6 +33,10 @@ # minutes regardless (the drinker is standing at the tap). COMMANDS_TTL = int(timedelta(hours=1).total_seconds()) POUR_UPDATE_TTL = 10 +# Pours echo their grant_id and can deliver long after the grant ended +# (queued through an outage); keep the attribution record as long as +# the dedup retention window. Lost redis -> late pours become guest. +GRANT_TTL = ROSTER_TTL STATE_PENDING = "pending" STATE_DENIED = "denied" @@ -48,6 +53,10 @@ def mint_command_id(): return f"cmd_{secrets.token_hex(4)}" +def mint_grant_id(): + return f"g_{secrets.token_hex(4)}" + + # Device roster: pairing candidates and paired-device health. @@ -141,6 +150,18 @@ def ack_command(name, command_id): cache.set(key, commands, COMMANDS_TTL) +# Grant records: the server-side meaning of a grant_id. The device only +# echoes the id; attribution happens here. + + +def store_grant(grant_id, username): + cache.set(GRANT_KEY.format(grant_id=grant_id), {"user": username}, GRANT_TTL) + + +def get_grant(grant_id): + return cache.get(GRANT_KEY.format(grant_id=grant_id)) + + # Live pour state, for the (future) realtime UI. diff --git a/pykeg/kegboard/tests.py b/pykeg/kegboard/tests.py index 7ec9b427..c6fd05bb 100644 --- a/pykeg/kegboard/tests.py +++ b/pykeg/kegboard/tests.py @@ -201,28 +201,45 @@ def test_pour_id_is_second_line_of_defense(self): class PourTest(KegboardTestCase): def test_pour_records_drink(self): token = self.pair() - response = self.post( - [self.pour_event(user=self.user.username, tick_series="0:3 100:14")], token=token - ) + response = self.post([self.pour_event(tick_series="0:3 100:14")], token=token) self.assertEqual(200, response.status_code) drink = models.Drink.objects.get(pour_id="pour-1") self.assertEqual(355.0, drink.volume_ml) - self.assertEqual(self.user, drink.user) self.assertEqual(7, drink.duration) # age_ms anchors the pour five seconds in the past. age = timezone.now() - drink.time self.assertTrue(timedelta(seconds=4) < age < timedelta(seconds=30)) - def test_pour_without_user_is_guest(self): + def test_pour_without_grant_is_guest(self): token = self.pair() self.post([self.pour_event()], token=token) drink = models.Drink.objects.get(pour_id="pour-1") self.assertTrue(drink.is_guest_pour()) - def test_pour_with_unknown_user_is_guest(self): + def test_pour_with_grant_is_attributed(self): + # Identity never travels down: the pour carries only our + # grant_id, and attribution comes from the grant record. token = self.pair() - self.post([self.pour_event(user="who-is-this")], token=token) + models.AuthenticationToken.objects.create( + auth_device="core.rfid", token_value="0089f2c4", user=self.user + ) + event = { + "id": 1, + "type": "token", + "age_ms": 0, + "data": {"auth_device": "core.rfid", "token": "0089f2c4", "action": "attached"}, + } + response = self.post([event], token=token) + grant_id = response.json()["commands"][0]["data"]["grant_id"] + + self.post([self.pour_event(event_id=2, grant_id=grant_id)], token=token) + drink = models.Drink.objects.get(pour_id="pour-1") + self.assertEqual(self.user, drink.user) + + def test_pour_with_unknown_grant_is_guest(self): + token = self.pair() + self.post([self.pour_event(grant_id="g_gone")], token=token) drink = models.Drink.objects.get(pour_id="pour-1") self.assertTrue(drink.is_guest_pour()) @@ -275,7 +292,7 @@ def token_event(self, value, event_id=1): "data": {"auth_device": "core.rfid", "token": value, "action": "attached"}, } - def test_assigned_token_authorizes_all_meters(self): + def test_assigned_token_creates_a_grant(self): token = self.pair() models.AuthenticationToken.objects.create( auth_device="core.rfid", token_value="0089f2c4", user=self.user @@ -284,8 +301,31 @@ def test_assigned_token_authorizes_all_meters(self): commands = response.json()["commands"] self.assertEqual(1, len(commands)) self.assertEqual("authorize", commands[0]["type"]) - self.assertEqual([0, 1], commands[0]["data"]["meter_numbers"]) - self.assertEqual(self.user.username, commands[0]["data"]["user"]) + grant = commands[0]["data"] + # All meters; the relays bound to their taps; no identity on the wire. + self.assertEqual([0, 1], grant["meter_numbers"]) + self.assertEqual([0, 1], grant["relay_numbers"]) + self.assertTrue(grant["grant_id"].startswith("g_")) + self.assertEqual(30000, grant["max_idle_ms"]) + self.assertNotIn("user", grant) + # The grant record resolves to the token's user server-side. + self.assertEqual(self.user.username, state.get_grant(grant["grant_id"])["user"]) + + def test_grant_end_is_accepted(self): + token = self.pair() + event = { + "id": 1, + "type": "grant_end", + "age_ms": 0, + "data": { + "meter_numbers": [0], + "reason": "max_idle", + "grant_id": "g_5501", + "volume_ml": 355.0, + "duration_ms": 42000, + }, + } + self.assertEqual(200, self.post([event], token=token).status_code) def test_unknown_token_is_denied(self): token = self.pair() diff --git a/pykeg/kegboard/views.py b/pykeg/kegboard/views.py index 17a37fbb..0307695c 100644 --- a/pykeg/kegboard/views.py +++ b/pykeg/kegboard/views.py @@ -25,9 +25,9 @@ PROTOCOL_VERSION = 1 MAX_BODY_BYTES = 16 * 1024 -# Grant lifetime for a token-authorized pour; the device extends it -# while beer is actively flowing and clamps it to its own maximum. -AUTHORIZE_DURATION_MS = 30 * 1000 +# Idle limit for a token-created grant: flow resets it, so a slow glass +# stays alive; total lifetime is bounded by the device's own clamp. +AUTHORIZE_IDLE_MS = 30 * 1000 class EventSerializer(serializers.Serializer): @@ -51,9 +51,9 @@ class PourSerializer(serializers.Serializer): pour_id = serializers.CharField(max_length=64) volume_ml = serializers.FloatField(min_value=0) duration_ms = serializers.IntegerField(min_value=0) - user = serializers.CharField(required=False) auth_device = serializers.CharField(required=False) auth_token = serializers.CharField(required=False) + grant_id = serializers.CharField(max_length=64, required=False) ticks = serializers.IntegerField(min_value=0, required=False) ml_per_tick = serializers.FloatField(required=False) tick_series = serializers.CharField(required=False) @@ -75,8 +75,18 @@ class TokenSerializer(serializers.Serializer): auth_device = serializers.CharField() token = serializers.CharField() action = serializers.ChoiceField(choices=["attached", "detached"]) - status = serializers.ChoiceField(choices=["accepted", "denied"], required=False) - user = serializers.CharField(required=False) + + +class GrantEndSerializer(serializers.Serializer): + meter_numbers = serializers.ListField(child=serializers.IntegerField(min_value=0)) + reason = serializers.ChoiceField( + choices=["max_volume", "max_duration", "max_idle", "detach", "command", "replaced"] + ) + grant_id = serializers.CharField(max_length=64) + volume_ml = serializers.FloatField(min_value=0) + duration_ms = serializers.IntegerField(min_value=0) + auth_device = serializers.CharField(required=False) + auth_token = serializers.CharField(required=False) class StatusSerializer(serializers.Serializer): @@ -102,6 +112,7 @@ class CommandResultSerializer(serializers.Serializer): "token": TokenSerializer, "status": StatusSerializer, "command_result": CommandResultSerializer, + "grant_end": GrantEndSerializer, } @@ -152,10 +163,10 @@ def _note_rejected_batch(request, error): state.update_device(device_name, ip=_client_ip(request), last_error=str(error)[:300]) -def _meter_number(port_name): - if port_name.startswith("flow"): +def _port_number(port_name, prefix): + if port_name.startswith(prefix): try: - return int(port_name[len("flow") :]) + return int(port_name[len(prefix) :]) except ValueError: pass return None @@ -174,7 +185,18 @@ def _handle_pour(controller, data, event_time): return if models.Drink.objects.filter(pour_id=data["pour_id"]).exists(): return - username = data.get("user") + # Identity never travels down: the pour echoes our grant_id and we + # resolve the user from the grant record. No grant -> guest pour. + username = None + grant_id = data.get("grant_id") + if grant_id: + grant = state.get_grant(grant_id) + if grant: + username = grant.get("user") + else: + logger.warning( + f"kegboard {controller.name}: unknown grant {grant_id!r}, recording as guest" + ) if username and not models.User.objects.filter(username=username).exists(): logger.warning(f"kegboard {controller.name}: unknown user {username!r}, recording as guest") username = None @@ -220,8 +242,9 @@ def _handle_temperature(controller, data, event_time): def _handle_token(controller, data, event_time): - if data["action"] != "attached" or data.get("status"): - # Detaches and locally-decided presentments are audit-only. + if data["action"] != "attached": + # Detaches are audit-only; the grant lifecycle arrives via + # grant_end events. logger.info(f"kegboard {controller.name}: token event: {data}") return @@ -229,21 +252,37 @@ def _handle_token(controller, data, event_time): auth_device=data["auth_device"], token_value=data["token"] ).first() if token and token.IsActive() and token.user: - meters = sorted( + # v1 policy: the grant covers every meter on the board. The + # meter<->relay association is ours: energize the relays bound + # to the granted meters' taps. + meters = [] + tap_ids = set() + for meter in controller.meters.all(): + number = _port_number(meter.port_name, "flow") + if number is None: + continue + meters.append(number) + if meter.tap_id is not None: + tap_ids.add(meter.tap_id) + relays = sorted( number for number in ( - _meter_number(port) - for port in controller.meters.values_list("port_name", flat=True) + _port_number(toggle.port_name, "relay") + for toggle in controller.toggles.all() + if toggle.tap_id in tap_ids ) if number is not None ) + grant_id = state.mint_grant_id() + state.store_grant(grant_id, token.user.username) state.queue_command( controller.name, "authorize", { - "meter_numbers": meters, - "user": token.user.username, - "duration_ms": AUTHORIZE_DURATION_MS, + "grant_id": grant_id, + "meter_numbers": sorted(meters), + "relay_numbers": relays, + "max_idle_ms": AUTHORIZE_IDLE_MS, "auth_device": data["auth_device"], "token": data["token"], }, @@ -287,6 +326,20 @@ def _handle_status(controller, data, event_time): meter.save(update_fields=["ticks_per_ml"]) +def _handle_grant_end(controller, data, event_time): + """Grant lifecycle bookkeeping. + + The pour events are the volume record; the grant totals here are + snapshots for cross-checking. The grant record itself is kept until + its TTL so queued pours delivered late still attribute. + """ + logger.info( + f"kegboard {controller.name}: grant {data['grant_id']} ended " + f"({data['reason']}): {data['volume_ml']} mL over {data['duration_ms']} ms " + f"on meters {data['meter_numbers']}" + ) + + def _handle_command_result(controller, data, event_time): if data["result"] != "ok": logger.warning(f"kegboard {controller.name}: command {data['command']}: {data}") @@ -300,6 +353,7 @@ def _handle_command_result(controller, data, event_time): "token": _handle_token, "status": _handle_status, "command_result": _handle_command_result, + "grant_end": _handle_grant_end, }