From 9d1384a9dfaadfad504a1109151fc22e09f7b0cb Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Mon, 3 Aug 2026 19:33:55 +0000 Subject: [PATCH 1/9] api: fix cancel-drink returning a 500 - Drink.cancel_drink deletes the record and returns nothing, but the view tried to serialize its return value; serialize the drink before canceling instead --- pykeg/web/api/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pykeg/web/api/views.py b/pykeg/web/api/views.py index 87f3c0fb..a7789db8 100644 --- a/pykeg/web/api/views.py +++ b/pykeg/web/api/views.py @@ -698,9 +698,11 @@ def cancel_drink(request): if not form.is_valid(): raise kbapi.BadRequestError(_form_errors(form)) cd = form.cleaned_data - drink = models.Drink.objects.get(id=cd["id"]) - res = drink.cancel_drink(spilled=cd.get("spilled", False)) - return protolib.ToProto(res, full=True) + drink = get_object_or_404(models.Drink, id=cd["id"]) + # Serialize before canceling: cancel_drink deletes the record. + result = protolib.ToDict(drink, full=True) + drink.cancel_drink(spilled=cd.get("spilled", False)) + return result @require_http_methods(["POST"]) From b106f8f38f066f09c3453edf12f8b8c25e39f63f Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Mon, 3 Aug 2026 19:33:55 +0000 Subject: [PATCH 2/9] api: add pycore contract tests - pins the exact legacy-api surface kegbot-pycore (and the fullscreen page) depend on, ahead of deprecating everything else --- pykeg/web/api/pycore_contract_test.py | 167 ++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 pykeg/web/api/pycore_contract_test.py diff --git a/pykeg/web/api/pycore_contract_test.py b/pykeg/web/api/pycore_contract_test.py new file mode 100644 index 00000000..de7ad758 --- /dev/null +++ b/pykeg/web/api/pycore_contract_test.py @@ -0,0 +1,167 @@ +"""Contract tests for the legacy API surface kegbot-pycore depends on. + +kegbot-pycore's WebBackend (via the old kegbot.api kbapi client) calls +exactly these endpoints and reads the asserted fields. The kegweb +fullscreen page additionally polls the events endpoint. This is the +compatibility contract for the deprecated v1 API: these tests must keep +passing until pycore moves to its replacement protocol. +""" + +from django.test import TestCase + +from pykeg.core import defaults, models +from pykeg.util import kbjson + +METER_NAME = "kegboard.flow0" +API_KEY = "123" + + +class PycoreContractTestCase(TestCase): + def setUp(self): + self.site = defaults.set_defaults(set_is_setup=True, create_controller=True) + self.admin = models.User.objects.create(username="admin", is_staff=True) + self.apikey = models.ApiKey.objects.create(user=self.admin, key=API_KEY) + + def get(self, subpath, data={}): + data = dict(data, api_key=API_KEY) + response = self.client.get(f"/api/{subpath}", data=data) + return response, kbjson.loads(response.content) + + def post(self, subpath, data={}): + data = dict(data, api_key=API_KEY) + response = self.client.post(f"/api/{subpath}", data=data) + return response, kbjson.loads(response.content) + + def start_keg(self): + return models.Keg.start_keg( + METER_NAME, + beverage_name="Test Beer", + beverage_type="beer", + producer_name="Test Producer", + style_name="Test Style", + ) + + def test_get_status(self): + response, data = self.get("status") + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + status = data.object + self.assertIn("site_info", status) + self.assertIn("title", status.site_info) + self.assertIn("server_version", status.site_info) + + # pycore syncs taps from status: meter_name, ml_per_tick, relay_name. + self.assertEqual(2, len(status.taps)) + for tap in status.taps: + self.assertIn("meter_name", tap) + self.assertIn("ml_per_tick", tap) + self.assertIn("relay_name", tap) + + def test_get_taps(self): + response, data = self.get("taps") + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + taps = data.objects + self.assertEqual(2, len(taps)) + self.assertEqual(METER_NAME, taps[0].meter_name) + tap = models.KegTap.get_from_meter_name(METER_NAME) + meter = tap.current_meter() + self.assertAlmostEqual(1 / meter.ticks_per_ml, taps[0].ml_per_tick, places=4) + toggle = tap.current_toggle() + expected_relay = toggle.toggle_name() if toggle else "" + self.assertEqual(expected_relay, taps[0].relay_name) + + def test_record_and_cancel_drink(self): + self.start_keg() + + response, data = self.post(f"taps/{METER_NAME}", data={"ticks": 2200}) + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + drink = data.object + self.assertIn("id", drink) + meter = models.KegTap.get_from_meter_name(METER_NAME).current_meter() + self.assertAlmostEqual(2200 / meter.ticks_per_ml, drink.volume_ml, places=3) + self.assertIn("session_id", drink) + self.assertIn("time", drink) + + response, data = self.post("cancel-drink", data={"id": drink.id, "spilled": True}) + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + self.assertEqual(0, models.Drink.objects.count()) + + def test_record_drink_unknown_meter_is_not_found(self): + response, data = self.post("taps/unknown.meter", data={"ticks": 100}) + self.assertEqual(404, response.status_code) + self.assertEqual("error", data.meta.result) + self.assertEqual("NotFoundError", data.error.code) + + def test_log_sensor_reading(self): + response, data = self.post("thermo-sensors/kegboard.thermo0", data={"temp_c": 4.5}) + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + log = data.object + self.assertAlmostEqual(4.5, log.temperature_c, places=3) + self.assertIn("sensor_id", log) + self.assertIn("time", log) + + def test_get_auth_token(self): + response, data = self.get("auth-tokens/core.rfid/deadbeef") + self.assertEqual(404, response.status_code) + self.assertEqual("error", data.meta.result) + self.assertEqual("NotFoundError", data.error.code) + + token = models.AuthenticationToken.create_auth_token( + "core.rfid", "deadbeef", username=self.admin.username + ) + token.enabled = True + token.save() + + response, data = self.get("auth-tokens/core.rfid/deadbeef") + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + obj = data.object + self.assertEqual("core.rfid", obj.auth_device) + self.assertEqual("deadbeef", obj.token_value) + self.assertEqual(self.admin.username, obj.username) + self.assertTrue(obj.enabled) + + def test_create_controller_and_meters(self): + response, data = self.post( + "controllers", + data={"name": "kegboard2", "model_name": "unknown", "serial_number": "unknown"}, + ) + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + controller = data.object + self.assertIn("id", controller) + self.assertEqual("kegboard2", controller.name) + + response, data = self.post( + "flow-meters", + data={"controller": controller.id, "port_name": "flow0", "ticks_per_ml": 2.2}, + ) + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + + meter = data.object + self.assertIn("id", meter) + self.assertEqual("kegboard2.flow0", meter.name) + + def test_get_events(self): + # The kegweb fullscreen page polls events and reads objects[0].id. + self.start_keg() + + response, data = self.get("events/") + self.assertEqual(200, response.status_code) + self.assertEqual("ok", data.meta.result) + self.assertTrue(len(data.objects) > 0) + self.assertIn("id", data.objects[0]) + # Newest first. + ids = [e.id for e in data.objects] + self.assertEqual(sorted(ids, reverse=True), ids) From b347c457f079f6a3e538618388fbd355f0885e58 Mon Sep 17 00:00:00 2001 From: mike wakerly Date: Mon, 3 Aug 2026 19:37:01 +0000 Subject: [PATCH 3/9] api: stop using protocol buffers - legacy responses are now built as plain dicts (pykeg.web.api.serialize) with the same field names and presence rules the proto encoding produced; fields the old encoding marked deprecated are dropped - api exceptions move to pykeg.web.api.exceptions; pykeg.proto is gone and the protobuf dependency (and last tie to kegbot-api) with it --- pykeg/contrib/webhook/plugin.py | 4 +- pykeg/proto/Makefile | 8 - pykeg/proto/README.md | 15 - pykeg/proto/__init__.py | 0 pykeg/proto/api.proto | 144 ------ pykeg/proto/api_pb2.py | 97 ---- pykeg/proto/kbapi.py | 31 -- pykeg/proto/models.proto | 620 ------------------------- pykeg/proto/models_pb2.py | 343 -------------- pykeg/proto/protolib.py | 493 -------------------- pykeg/proto/protoutil.py | 50 -- pykeg/proto/util.py | 25 - pykeg/{proto => web/api}/exceptions.py | 17 +- pykeg/web/api/serialize.py | 404 ++++++++++++++++ pykeg/web/api/util.py | 24 +- pykeg/web/api/views.py | 115 +++-- pyproject.toml | 3 - uv.lock | 17 - 18 files changed, 476 insertions(+), 1934 deletions(-) delete mode 100644 pykeg/proto/Makefile delete mode 100644 pykeg/proto/README.md delete mode 100644 pykeg/proto/__init__.py delete mode 100644 pykeg/proto/api.proto delete mode 100644 pykeg/proto/api_pb2.py delete mode 100644 pykeg/proto/kbapi.py delete mode 100644 pykeg/proto/models.proto delete mode 100644 pykeg/proto/models_pb2.py delete mode 100644 pykeg/proto/protolib.py delete mode 100644 pykeg/proto/protoutil.py delete mode 100644 pykeg/proto/util.py rename pykeg/{proto => web/api}/exceptions.py (73%) create mode 100644 pykeg/web/api/serialize.py diff --git a/pykeg/contrib/webhook/plugin.py b/pykeg/contrib/webhook/plugin.py index 2a58b171..f3d9fd7e 100644 --- a/pykeg/contrib/webhook/plugin.py +++ b/pykeg/contrib/webhook/plugin.py @@ -2,7 +2,7 @@ from pykeg.core.util import SuppressTaskErrors from pykeg.plugin import plugin -from pykeg.proto import protolib +from pykeg.web.api import serialize from . import forms, tasks, views @@ -27,7 +27,7 @@ def handle_event(self, event): self.logger.info(f"Handling new event: {event.id}") settings = self.get_site_settings() urls = settings.get("webhook_urls", "").strip().split() - event_dict = protolib.ToDict(event, full=True) + event_dict = serialize.to_dict(event, full=True) for url in urls: with SuppressTaskErrors(self.logger): tasks.webhook_post.delay(url, event_dict) diff --git a/pykeg/proto/Makefile b/pykeg/proto/Makefile deleted file mode 100644 index da881f35..00000000 --- a/pykeg/proto/Makefile +++ /dev/null @@ -1,8 +0,0 @@ -all: python - -python: - protoc --python_out=. *.proto - -.PHONY: python - -# vim: noet diff --git a/pykeg/proto/README.md b/pykeg/proto/README.md deleted file mode 100644 index b203ee38..00000000 --- a/pykeg/proto/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# Kegbot `proto` files - -Ages ago, we selected [Protocol -Buffers](https://developers.google.com/protocol-buffers) to define Kegbot's -core models and aspects of the rest API. This directory stores the proto -definitions and the python generated code. - -This code has been migrated from the [kegbot-api -repo](https://github.com/kegbot/kegbot-api) which is now deprecated. - -## Other clients - -At time of writing, the [Kegbot Android -App](https://github.com/kegbot/kegbot-android) also uses these protobuf -definitions. diff --git a/pykeg/proto/__init__.py b/pykeg/proto/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/pykeg/proto/api.proto b/pykeg/proto/api.proto deleted file mode 100644 index 7154e945..00000000 --- a/pykeg/proto/api.proto +++ /dev/null @@ -1,144 +0,0 @@ -syntax = "proto2"; -option java_package = "org.kegbot.proto"; -option optimize_for = CODE_SIZE; - -import "models.proto"; - -// Common - -message Meta { - - // The total number of records available for this request. - optional uint32 total = 1; - - // The maximum number of records returned in this request. - optional uint32 limit = 2; - - // The position of the first record returned, among "total". - optional uint32 pos = 3; -} - -// Requests - -message UserRegistrationRequest { - - // Desired username. - required string username = 1; - - // User's e-mail address. - required string email = 2; - - // Initial password for logging in. If unspecified, the account will be - // registered with a random password, which can be e-mailed to the user. - optional string password = 3; - - // One of "male", "female". - optional string gender = 4; - - // Twitter username. - optional string twitter_name = 5; -} - -// Message used for recording a new drink on the backend. -message RecordDrinkRequest { - - // Name of the tap on which this drink was poured. This information is - // required, but this field may be omitted when the tap name is given - // elsewhere. (In the Kegweb API, the tap_name is part of the URL receiving - // the POST.) - optional string tap_name = 1; - - // The number of ticks, as reported by the flowmeter. Required. - required uint32 ticks = 2; - - // The volume of the pour. If unspecified, the backend will use the current - // tap configuration to compute the pour's volume based on the value of - // "ticks". - optional float volume_ml = 3; - - // The username responsible for the pour. If unspecified, this pour is - // treated as an anonymous pour. - optional string username = 4; - - // The date and time of the pour, in seconds before "now", where "now" is the - // current time on the backend at the time the request is processed. If this - // field is unspecified, a default value of "0" (meaning "now") is assumed. - // This value is ignored if "record_date" is specified. - optional uint32 seconds_ago = 5 [default = 0]; - - // The absolute date and time of the pour, as an ISO8061 UTC timestamp. If - // specified and valid, this value supercedes any value given for - // "seconds_ago", which will be ignored. - optional string record_date = 6; - - // The time taken, in seconds, to complete the pour. - optional uint32 duration_seconds = 7; - - // The authentication token used to pour the drink. - optional string auth_token = 8; - - // If true, the pour is recorded as "spilled": no drink record will be - // generated, and all fields other than "tap_name" and the volume ("ticks", or - // "volume_ml" if given) are ignored. The volume will be added to the - // spilled total for the tap’s current keg. - optional bool spilled = 9; - - // Optional message from the user about the pour. - optional string shout = 10; - - // See Drink model. - optional string tick_time_series = 11; -} - -// Message used for recording a temperature sensor reading on the backend. -message RecordTemperatureRequest { - - // The name of the sensor, as stored in the backend. - required string sensor_name = 1; - - // The observed temperature, in degress centigrade. - required float temp_c = 2; - - // The date of the reading, as an ISO8601 UTC timestamp. If this field is - // unspecified, the record_date will be the current time on the backend at the - // time the request is processed. - optional string record_date = 3; -} - -// Responses - -message SyncResponse { - // All configured controllers. - repeated Controller controllers = 1; - - // Recently-poured drinks. - repeated Drink drinks = 2; - - // Recent system events. - repeated SystemEvent events = 3; - - // Kegs on tap. - repeated Keg active_kegs = 4; - - // All configured meters. - repeated FlowMeter meters = 5; - - // Site info. - optional SiteInfo site_info = 6; - - // Sound events. - repeated SoundEvent sound_events = 7; - - // All taps. - repeated KegTap taps = 8; - - // All toggles. - repeated FlowToggle toggles = 9; - - // Current session (if there is one). - optional Session active_session = 10; - - // Currently-active users (if any). - repeated User active_users = 11; - -} diff --git a/pykeg/proto/api_pb2.py b/pykeg/proto/api_pb2.py deleted file mode 100644 index 303dea20..00000000 --- a/pykeg/proto/api_pb2.py +++ /dev/null @@ -1,97 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# source: api.proto -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import message as _message -from google.protobuf import reflection as _reflection -from google.protobuf import symbol_database as _symbol_database - -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from . import models_pb2 as models__pb2 - -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\tapi.proto\x1a\x0cmodels.proto"1\n\x04Meta\x12\r\n\x05total\x18\x01 \x01(\r\x12\r\n\x05limit\x18\x02 \x01(\r\x12\x0b\n\x03pos\x18\x03 \x01(\r"r\n\x17UserRegistrationRequest\x12\x10\n\x08username\x18\x01 \x02(\t\x12\r\n\x05\x65mail\x18\x02 \x02(\t\x12\x10\n\x08password\x18\x03 \x01(\t\x12\x0e\n\x06gender\x18\x04 \x01(\t\x12\x14\n\x0ctwitter_name\x18\x05 \x01(\t"\xef\x01\n\x12RecordDrinkRequest\x12\x10\n\x08tap_name\x18\x01 \x01(\t\x12\r\n\x05ticks\x18\x02 \x02(\r\x12\x11\n\tvolume_ml\x18\x03 \x01(\x02\x12\x10\n\x08username\x18\x04 \x01(\t\x12\x16\n\x0bseconds_ago\x18\x05 \x01(\r:\x01\x30\x12\x13\n\x0brecord_date\x18\x06 \x01(\t\x12\x18\n\x10\x64uration_seconds\x18\x07 \x01(\r\x12\x12\n\nauth_token\x18\x08 \x01(\t\x12\x0f\n\x07spilled\x18\t \x01(\x08\x12\r\n\x05shout\x18\n \x01(\t\x12\x18\n\x10tick_time_series\x18\x0b \x01(\t"T\n\x18RecordTemperatureRequest\x12\x13\n\x0bsensor_name\x18\x01 \x02(\t\x12\x0e\n\x06temp_c\x18\x02 \x02(\x02\x12\x13\n\x0brecord_date\x18\x03 \x01(\t"\xd2\x02\n\x0cSyncResponse\x12 \n\x0b\x63ontrollers\x18\x01 \x03(\x0b\x32\x0b.Controller\x12\x16\n\x06\x64rinks\x18\x02 \x03(\x0b\x32\x06.Drink\x12\x1c\n\x06\x65vents\x18\x03 \x03(\x0b\x32\x0c.SystemEvent\x12\x19\n\x0b\x61\x63tive_kegs\x18\x04 \x03(\x0b\x32\x04.Keg\x12\x1a\n\x06meters\x18\x05 \x03(\x0b\x32\n.FlowMeter\x12\x1c\n\tsite_info\x18\x06 \x01(\x0b\x32\t.SiteInfo\x12!\n\x0csound_events\x18\x07 \x03(\x0b\x32\x0b.SoundEvent\x12\x15\n\x04taps\x18\x08 \x03(\x0b\x32\x07.KegTap\x12\x1c\n\x07toggles\x18\t \x03(\x0b\x32\x0b.FlowToggle\x12 \n\x0e\x61\x63tive_session\x18\n \x01(\x0b\x32\x08.Session\x12\x1b\n\x0c\x61\x63tive_users\x18\x0b \x03(\x0b\x32\x05.UserB\x14\n\x10org.kegbot.protoH\x02' -) - - -_META = DESCRIPTOR.message_types_by_name["Meta"] -_USERREGISTRATIONREQUEST = DESCRIPTOR.message_types_by_name["UserRegistrationRequest"] -_RECORDDRINKREQUEST = DESCRIPTOR.message_types_by_name["RecordDrinkRequest"] -_RECORDTEMPERATUREREQUEST = DESCRIPTOR.message_types_by_name["RecordTemperatureRequest"] -_SYNCRESPONSE = DESCRIPTOR.message_types_by_name["SyncResponse"] -Meta = _reflection.GeneratedProtocolMessageType( - "Meta", - (_message.Message,), - { - "DESCRIPTOR": _META, - "__module__": "api_pb2" - # @@protoc_insertion_point(class_scope:Meta) - }, -) -_sym_db.RegisterMessage(Meta) - -UserRegistrationRequest = _reflection.GeneratedProtocolMessageType( - "UserRegistrationRequest", - (_message.Message,), - { - "DESCRIPTOR": _USERREGISTRATIONREQUEST, - "__module__": "api_pb2" - # @@protoc_insertion_point(class_scope:UserRegistrationRequest) - }, -) -_sym_db.RegisterMessage(UserRegistrationRequest) - -RecordDrinkRequest = _reflection.GeneratedProtocolMessageType( - "RecordDrinkRequest", - (_message.Message,), - { - "DESCRIPTOR": _RECORDDRINKREQUEST, - "__module__": "api_pb2" - # @@protoc_insertion_point(class_scope:RecordDrinkRequest) - }, -) -_sym_db.RegisterMessage(RecordDrinkRequest) - -RecordTemperatureRequest = _reflection.GeneratedProtocolMessageType( - "RecordTemperatureRequest", - (_message.Message,), - { - "DESCRIPTOR": _RECORDTEMPERATUREREQUEST, - "__module__": "api_pb2" - # @@protoc_insertion_point(class_scope:RecordTemperatureRequest) - }, -) -_sym_db.RegisterMessage(RecordTemperatureRequest) - -SyncResponse = _reflection.GeneratedProtocolMessageType( - "SyncResponse", - (_message.Message,), - { - "DESCRIPTOR": _SYNCRESPONSE, - "__module__": "api_pb2" - # @@protoc_insertion_point(class_scope:SyncResponse) - }, -) -_sym_db.RegisterMessage(SyncResponse) - -if _descriptor._USE_C_DESCRIPTORS == False: - - DESCRIPTOR._options = None - DESCRIPTOR._serialized_options = b"\n\020org.kegbot.protoH\002" - _META._serialized_start = 27 - _META._serialized_end = 76 - _USERREGISTRATIONREQUEST._serialized_start = 78 - _USERREGISTRATIONREQUEST._serialized_end = 192 - _RECORDDRINKREQUEST._serialized_start = 195 - _RECORDDRINKREQUEST._serialized_end = 434 - _RECORDTEMPERATUREREQUEST._serialized_start = 436 - _RECORDTEMPERATUREREQUEST._serialized_end = 520 - _SYNCRESPONSE._serialized_start = 523 - _SYNCRESPONSE._serialized_end = 861 -# @@protoc_insertion_point(module_scope) diff --git a/pykeg/proto/kbapi.py b/pykeg/proto/kbapi.py deleted file mode 100644 index fbff9816..00000000 --- a/pykeg/proto/kbapi.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Kegweb API exceptions. - -These are re-exported here so server code can `from pykeg.proto import kbapi` -and reference e.g. `kbapi.NoAuthTokenError`. The old gflags-based HTTP client -lived here too; it was unused by the server and now lives in the kegbot-api -project. -""" - -from .exceptions import ( - BadApiKeyError, - BadRequestError, - Error, - ErrorCodeToException, - NoAuthTokenError, - NotFoundError, - PermissionDeniedError, - RequestError, - ServerError, -) - -__all__ = [ - "BadApiKeyError", - "BadRequestError", - "Error", - "ErrorCodeToException", - "NoAuthTokenError", - "NotFoundError", - "PermissionDeniedError", - "RequestError", - "ServerError", -] diff --git a/pykeg/proto/models.proto b/pykeg/proto/models.proto deleted file mode 100644 index b6071bae..00000000 --- a/pykeg/proto/models.proto +++ /dev/null @@ -1,620 +0,0 @@ -syntax = "proto2"; -option java_package = "org.kegbot.proto"; -option optimize_for = CODE_SIZE; - -// An authentication token, which a drinker uses to authenticate to the system. -message AuthenticationToken { - // The unique identifier for this token. - required uint32 id = 1; - - // The name of the auth device that owns this token, such as ``core.onewire`` - // or ``contrib.phidget.rfid``. - required string auth_device = 2; - - // The unique key. - required string token_value = 3; - - // The user owning the token. - optional string username = 4; - - // An optional human-readable name for this token. - optional string nice_name = 5; - - // True if the token is enabled. - optional bool enabled = 6; - - // The date the token was created, as an ISO8061 UTC timestamp. - required string created_time = 7; - - // The date after which the token is invalid, as an ISO8061 UTC timestamp. - // Only available to admin users. - optional string expire_time = 8; - - // The token's pin, if any. - // Only available to admin users. - optional string pin = 9; - - optional User user = 10; -} - -// A beverage production company or brand. -message BeverageProducer { - - // The unique identifier for this producer. - required uint32 id = 1; - - // The name of the brewer or production company. - required string name = 2; - - // Country of origin. - optional string country = 3; - - // State of origin. - optional string origin_state = 4; - - // City of origin. - optional string origin_city = 5; - - // True if this is a home producer. - optional bool is_homebrew = 6; - - // URL for producer. - optional string url = 7; - - // Free-form description. - optional string description = 8; - - // Image. - optional Image picture = 9; - -} - -// A type of beer, coffee, wine, or other beverage. -message Beverage { - - // Unique id for this beverage. - required uint32 id = 1; - - // Beverage name. - required string name = 2; - - // Producer. - required BeverageProducer producer = 3; - - // Type of beverage. - required string beverage_type = 4; - - // Beverage style. - optional string style = 5; - - // Free-form description. - optional string description = 6; - - // Logo or other image. - optional Image picture = 7; - - // Vintage. - optional uint32 vintage_year = 8; - - // Alcohol by volume (0.0-100.0). - optional double abv_percent = 9; - - // Calories. - optional double calories_per_ml = 10; - - // Carbohydrates. - optional double carbs_per_ml = 11; - - // Original gravity. - optional double original_gravity = 12; - - // Specific gravity. - optional double specific_gravity = 13; - - // Untappd id (for beers only). - optional string untappd_id = 14; - - // Beverage backend. - optional string beverage_backend = 15; - - // Id within beverage backend. - optional string beverage_backend_id = 16; - - // v1.1 fields - - optional string color_hex = 17; - optional double srm = 18; - optional double ibu = 19; - optional double star_rating = 20; - -} - -// A named beer style of beer, such as "India Pale Ale". -message BeerStyle { - - // The unique identifier for this beer style. - required uint32 id = 1; - - // The name of the beer style. - required string name = 2; - -} - -// A specific kind of beer: describes the beer's name, style, and brewer. -message BeerType { - - // The unique identifier for this beer type. - required string id = 1; - - // The brand name of the beer. - required string name = 2; - - // Brewer information for the beer. May refer to an 'unknown' or generic - // brewer record. - required string brewer_id = 3; - - // Style information for the beer. May refer to an 'unknown' or generic beer - // style. - required string style_id = 4; - - // For seasonal or special edition beers, the year or other edition name. - optional string edition = 6; - - // Alcohol by volume, as a percentage of the total volume. - optional double abv = 7; - - // Number of calories per ounce of beverage. - optional double calories_oz = 8; - - // Number of carbohydrates per ounce of beverage. - optional double carbs_oz = 9; - - // Specific/final gravity of the beer, if known. - optional double specific_gravity = 10; - - // Original gravity of the beer, if known. - optional double original_gravity = 11; - - // Image for this beer. - optional Image image = 12; - -} - -// A specific producer of our favorite beverage. -message Brewer { - - // The unique identifier for this beer type. - required string id = 1; - - // The name of the brewer. - required string name = 2; - - // The country of the brewer's headquarters. - optional string country = 3 [default = '']; - - // The state of the brewer's headquarters. - optional string origin_state = 4 [default = '']; - - // The city of the brewer's headquarters. - optional string origin_city = 5 [default = '']; - - // Type of production (usually either 'retail' or 'homebrew'). - optional string production = 6 [default = '']; - - // URL of brewer. - optional string url = 7 [default = '']; - - // Free-form description. - optional string description = 8 [default = '']; - - // Image or logo for this brewer. - optional Image image = 9; - -} - -// Describes a single recorded pour from the Kegbot. -message Drink { - - // The unique identifier for this drink. - required uint32 id = 1; - - // The number of meter ticks recorded for the drink. - required uint32 ticks = 2; - - // The volume of the drink, in milliliters. - required double volume_ml = 3; - - // The session this drink belongs to. - required uint32 session_id = 4; - - // UTC time when the drink was poured, as an ISO8061 UTC timestamp. - required string time = 5; - - // Duration, in seconds, of the pour. - optional uint32 duration = 6; - - // The Keg from which the drink was poured. May be unset if the drink was - // not associated with a keg. - optional uint32 keg_id = 8; - - // The User that poured the drink. Snset if the drinker was unknown - // (anonymous pour). - optional string user_id = 9; - - // Auth token value used to pour the drink, if known. - optional uint32 auth_token_id = 10; - - // Canonical URL for this object. - optional string url = 11; - - // Comment from the drinker at the time of the pour. - optional string shout = 12; - - optional User user = 13; - optional Keg keg = 14; - optional Session session = 15; - repeated Image images = 16; - - // Vector of tick updates. Format is: "