Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Contents:
overview
install
settings
kegboards
management
commands
developers
Expand Down
57 changes: 57 additions & 0 deletions docs/source/kegboards.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
.. _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
``<controller>.<sensor>``.
* **Token presentments** are checked against the token database
(**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.
8 changes: 8 additions & 0 deletions docs/source/releases/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions pykeg/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,24 @@ 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)
# 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):
"""Admin-editable site settings, covering the old settings forms."""

Expand Down
106 changes: 106 additions & 0 deletions pykeg/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
9 changes: 9 additions & 0 deletions pykeg/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -30,11 +32,18 @@
# 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/<str:filename>", views_admin.delete_backup),
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/<str:device>", views_admin.kegboard_forget),
path("admin/kegboards/<str:device>/allow", views_admin.kegboard_allow),
path("admin/kegboards/<str:device>/deny", views_admin.kegboard_deny),
path("admin/kegboards/<str:device>/revoke", views_admin.kegboard_revoke),
path("admin/logs", views_admin.logs),
path("admin/plugins", views_admin.plugins),
path("admin/plugins/<str:short_name>/settings", views_admin.plugin_settings),
Expand Down
54 changes: 54 additions & 0 deletions pykeg/api/views_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
37 changes: 37 additions & 0 deletions pykeg/core/migrations/0007_kegboard_protocol_fields.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
18 changes: 18 additions & 0 deletions pykeg/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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()
Expand Down
Empty file added pykeg/kegboard/__init__.py
Empty file.
Loading