diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..f2d86c5 --- /dev/null +++ b/.clang-format @@ -0,0 +1,137 @@ +Language: Cpp +AccessModifierOffset: -1 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: DontAlign +AlignOperands: true +AlignTrailingComments: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: MultiLine +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Attach +BreakBeforeInheritanceComma: false +BreakInheritanceList: BeforeColon +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakConstructorInitializers: BeforeColon +BreakAfterJavaFieldAnnotations: false +BreakStringLiterals: true +ColumnLimit: 120 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - foreach + - Q_FOREACH + - BOOST_FOREACH +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^' + Priority: 2 + - Regex: '^<.*\.h>' + Priority: 1 + - Regex: '^<.*' + Priority: 2 + - Regex: '.*' + Priority: 3 +IncludeIsMainRegex: '([-_](test|unittest))?$' +IndentCaseLabels: true +IndentPPDirectives: None +IndentWidth: 2 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 2000 +PointerAlignment: Right +RawStringFormats: + - Language: Cpp + Delimiters: + - cc + - CC + - cpp + - Cpp + - CPP + - 'c++' + - 'C++' + CanonicalDelimiter: '' + BasedOnStyle: google + - Language: TextProto + Delimiters: + - pb + - PB + - proto + - PROTO + EnclosingFunctions: + - EqualsProto + - EquivToProto + - PARSE_PARTIAL_TEXT_PROTO + - PARSE_TEST_PROTO + - PARSE_TEXT_PROTO + - ParseTextOrDie + - ParseTextProtoOrDie + CanonicalDelimiter: '' + BasedOnStyle: google +ReflowComments: true +SortIncludes: false +SortUsingDeclarations: false +SpaceAfterCStyleCast: true +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInContainerLiterals: false +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Auto +TabWidth: 2 +UseTab: Never diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..94069d3 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,136 @@ +--- +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Pinned deliberately. ESPHome makes breaking changes to its external + # component API on a regular cadence, so contributors should not have a + # release land on them mid-PR. The nightly job in dev.yaml tracks upstream. + ESPHOME_VERSION: "2026.6.5" + +jobs: + core-tests: + name: Core unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - name: Check core purity + run: python3 script/check-core-purity.py + + - name: Run tests + run: make -C tests/core STRICT=1 + + - name: Install jsonschema + run: pip install jsonschema + + # The firmware's serialized output must validate against the normative + # protocol schemas, and the schema files must match the doc appendices. + - name: Check protocol schema conformance + run: python3 script/check-events-schema.py + + lint: + name: Lint and format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - uses: pre-commit/action@v3.0.1 + + host-compile: + name: Host smoke compile + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install ESPHome + run: pip install "esphome==${ESPHOME_VERSION}" + + # Compiles the components natively with the system compiler: a full + # type-check of the C++ in seconds, no ESP toolchain download. The ESP32 + # matrix below still proves the real targets. + - name: Compile host smoke test + run: esphome compile tests/host/smoke.yaml + + config: + name: Validate configs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install ESPHome + run: pip install "esphome==${ESPHOME_VERSION}" + + - name: Provide dummy secrets + run: cp examples/secrets.yaml.example examples/secrets.yaml + + - name: Validate + run: | + for config in examples/*.yaml; do + case "$config" in *secrets*) continue;; esac + echo "::group::$config" + esphome config "$config" + echo "::endgroup::" + done + + compile: + name: Compile ${{ matrix.board }} + runs-on: ubuntu-latest + needs: [core-tests, config, host-compile] + strategy: + fail-fast: false + matrix: + include: + - board: esp32-s3-devkitc-1 + config: examples/kegbot-2tap.yaml + - board: esp32-devkit + config: examples/home-assistant-2tap.yaml + # Widest surface: relays, buzzer, LEDs, rfid, ibutton, and auth. + - board: esp32-s3-full + config: examples/kegbot-full.yaml + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Cache PlatformIO and ESP-IDF toolchains + uses: actions/cache@v4 + with: + path: | + ~/.platformio + ~/.espressif + key: pio-${{ matrix.board }}-${{ env.ESPHOME_VERSION }}-${{ hashFiles('boards/*.yaml') }} + restore-keys: | + pio-${{ matrix.board }}-${{ env.ESPHOME_VERSION }}- + + - name: Install ESPHome + run: pip install "esphome==${ESPHOME_VERSION}" + + - name: Provide dummy secrets + run: cp examples/secrets.yaml.example examples/secrets.yaml + + - name: Compile + run: esphome compile ${{ matrix.config }} diff --git a/.github/workflows/esphome-dev.yaml b/.github/workflows/esphome-dev.yaml new file mode 100644 index 0000000..b920e9f --- /dev/null +++ b/.github/workflows/esphome-dev.yaml @@ -0,0 +1,35 @@ +--- +# Tracks ESPHome's dev branch so their breaking changes surface here as a +# failing scheduled build rather than as a user bug report weeks later. +# +# This job is expected to break occasionally. It does not gate PRs. +name: ESPHome dev + +on: + schedule: + # Weekly, Monday early UTC. + - cron: "0 5 * * 1" + workflow_dispatch: + +jobs: + compile-against-dev: + name: Compile against ESPHome dev + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install ESPHome from dev + run: pip install git+https://github.com/esphome/esphome.git@dev + + - name: Report version under test + run: esphome version + + - name: Provide dummy secrets + run: cp examples/secrets.yaml.example examples/secrets.yaml + + - name: Compile + run: esphome compile examples/kegbot-full.yaml diff --git a/.gitignore b/.gitignore index 97bdb1c..fb2342c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,19 @@ -### global stuff -*.DS_Store +# Python / tooling +.venv/ +__pycache__/ *.pyc -*.egg -*.o -*.hex -python/dist/* -python/distribute-* -python/*egg-info +# ESPHome build output +.esphome/ +secrets.yaml -arduino/kegboard/.dep -arduino/kegboard/.lib +# Host test build output +tests/core/build/ + +# Sphinx build output +docs/_build/ + +# Editors / OS +.DS_Store +compile_commands.json +uv.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2e99324 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +--- +# Formatting and lint hooks. Install with: pre-commit install +# +# The C++ and Python conventions here deliberately mirror ESPHome's own, since +# our components are compiled into their tree and most contributors will be +# coming from that codebase. clang-format is pinned to the same v13 they pin. +repos: + - repo: https://github.com/pre-commit/mirrors-clang-format + rev: v13.0.1 + hooks: + - id: clang-format + types_or: [c, c++] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.1 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.37.1 + hooks: + - id: yamllint + exclude: ^\.clang-format$ + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-merge-conflict diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..2466981 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +# Install the docs toolchain with uv from docs/uv.lock and build with +# sphinx. docs/ is a self-contained uv project; the firmware repo itself +# contains nothing to install. +build: + os: ubuntu-24.04 + tools: + python: '3.13' + commands: + - asdf plugin add uv + - asdf install uv latest + - asdf global uv latest + - uv sync --project docs --frozen + - uv run --project docs --no-sync sphinx-build -T -b html docs $READTHEDOCS_OUTPUT/html diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..8623698 --- /dev/null +++ b/.yamllint @@ -0,0 +1,21 @@ +--- +extends: default + +ignore: | + .clang-format + .venv/ + +rules: + document-start: + present: true + line-length: + max: 120 + allow-non-breakable-words: true + allow-non-breakable-inline-mappings: true + truthy: + # GitHub Actions uses a bare `on:` key. + check-keys: false + comments: + min-spaces-from-content: 1 + braces: + max-spaces-inside: 1 diff --git a/LICENSE.txt b/LICENSE.txt index 4eb3f1e..370eb90 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,278 +1,18 @@ - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. +Copyright 2003-2026 The Kegbot Project Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 830c9b1..89e6108 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,273 @@ # Kegboard -This is Kegboard, the Arduino-based beer kegerator controller from Kegbot. You can use -Kegboard with Kegbot, or something else. +Kegboard is the kegerator controller firmware from the [Kegbot][kegbot] project. +It reads flow meters, temperature sensors, and auth tokens, drives valves and +relays, and reports pours to a [Kegbot Server][kegbot-server]. -The main repository is located at: https://github.com/Kegbot/kegboard +This is **Kegboard v4**, a ground-up rewrite for **ESP32** built on +[ESPHome][esphome]. -You can find more information about Kegboard at: http://kegbot.org/kegboard/ +> **Looking for the Arduino version?** The v2/v3 firmware lives on the +> [`arduino`](https://github.com/Kegbot/kegboard/tree/arduino) branch. It is +> feature-frozen and does not speak the same protocol as this one. -## More Info & Help +**[๐Ÿ“– Documentation](https://docs.kegbot.org/projects/kegboard)** โ€” overview, +operating modes, installation, wiring, configuration, and the protocol specs. +Sources in [`docs/`](docs/). -If you're reading this on Github, please note that we don't maintain much documentation there. +## What changed, and why -Please see our main website, http://kegbot.org/, for [documentation](http://kegbot.org/docs), -the [Kegbot Forum](http://kegbot.org/kegbb/), and the [Kegbot Blog](http://kegbot.org/blog/). +The old Kegboard was a dumb sensor pipe: it streamed raw tick counts over USB +serial, and a host daemon (`kegbot-pycore`) assembled those ticks into pours and +posted them to the server. That meant a cable, a always-on host, and a lost pour +whenever the host was down. -You can also find us on **#kegbot** on freenode IRC. +Kegboard v4 is a networked appliance: -You should definitely follow [@kegbot](http://twitter.com/kegbot) on Twitter, all the -cool kids are. +- **Pours are assembled on the device.** The board detects the start and end + of a pour, applies its own calibration, and reports a finished pour with an + authoritative `volume_ml`. +- **One simple protocol.** Everything is JSON batches to a single endpoint + (`POST /kegboard-event`), specified in + [docs/kegboard-event-protocol.md](docs/kegboard-event-protocol.md) with + normative JSON Schemas. Any server can implement it. +- **Outages are survivable.** Events queue and deliver late with correct + timestamps โ€” even on a board whose clock never synced โ€” and retries can + never create a duplicate drink. +- **No API keys to carry around.** An unprovisioned board shows up on the + server dashboard by name; click allow and it provisions itself. +- **No `kegbot-pycore`, no USB cable.** WiFi and HTTP. +- **It's ESPHome.** Adding a display, a pressure sensor, a second thermometer, + or a different RFID reader is YAML you write, not a firmware release we ship. -## License and Copyright +## Configuration -All code is offered under the **GPLv2** license, unless otherwise noted. Please see -``LICENSE.txt`` for the full license. +A minimal two-tap board reporting to a server: -All code and documentation are **Copyright 2003-2012 Mike Wakerly**, unless otherwise noted. +```yaml +external_components: + - source: github://Kegbot/kegboard@main -## Contributing +kegboard: + id: kb -We love getting patches! Send us a pull request, or hop on to IRC if you'd like to chat -about something substantial. +kegboard_meter: + - id: flow0 + pin: GPIO4 + meter_number: 0 + total: + name: Tap 1 Ticks + pouring: + name: Tap 1 Pouring +kegboard_reporter: + reporting_url: !secret kegboard_reporting_url + meters: [flow0] +``` + +See `examples/` for complete configs, and `boards/` for pin maps. + +### `kegboard` + +| Option | Default | Notes | +|---|---|---| +| `serial_number` | `kegboard-` | Device identity in the protocol. Set explicitly to adopt a replaced board's identity. | + +### `kegboard_meter` + +| Option | Default | Notes | +|---|---|---| +| `pin` | required | Meter input. Pulled up internally; counts falling edges. | +| `meter_number` | `0` | The protocol's meter number: `(device, meter_number)` identifies a tap server-side. Must be unique per meter (validated at build). The YAML `id` is a config-internal reference and is never reported. | +| `ml_per_tick` | `0.185` | SwissFlow SF800 and clones (~5.4 ticks/mL). The device's calibration is authoritative: reported volume comes from this. | +| `debounce` | `1200us` | Matches the legacy firmware's filter. | +| `idle_timeout` | `10s` | Silence after which a pour is considered finished. | +| `min_pour_ticks` | `3` | Anything shorter is treated as a drip and discarded. | +| `max_pour_duration` | `5min` | Safety cutoff for a stuck meter; `0s` disables. | +| `report_interval` | `250ms` | Throttle for sensor updates during a pour. | +| `series_resolution` | `100ms` | Bucket width for the diagnostic tick series; `0s` disables. | + +Optional entities: `total`, `volume`, `flow_rate`, `pouring`. +Triggers: `on_pour_start`, `on_pour_end` (with `ticks`, `volume_ml`, `duration_ms`). +Actions: `kegboard_meter.reset_total`, `.end_pour`, `.set_calibration`. + +### `kegboard_reporter` + +Speaks the [Kegboard Event Protocol](docs/kegboard-event-protocol.md). + +| Option | Default | Notes | +|---|---|---| +| `reporting_url` | required | Full URL, path included, e.g. `https://kegbot.example.com/api/kegboard-event`. No credential is configured โ€” the device provisions its own bearer token by pairing via the server dashboard, and it persists in flash. | +| `meters` | `[]` | Meters whose pours are reported. | +| `relays` | `[]` | The device's numbered relays: `relay_number:` plus `relay:`. Reported in the `status` inventory, and the targets of server grants. | +| `thermo_sensors` | `[]` | `sensor:`/`name:` pairs; any ESPHome sensor works. | +| `heartbeat_interval` | `60s` | Status event cadence; also bounds worst-case command latency. | +| `pour_update_interval` | `1s` | Live `pour_update` cadence; `0s` disables. | +| `retry_interval` | `30s` | Base for exponential backoff, capped at 5 min. | + +Optional diagnostic entities: `queue_depth`, `dropped`. A non-zero `dropped` +means events were lost and is worth alerting on. + +### `kegboard_auth` + +Applies [authenticated pouring](docs/authenticated-pouring.md): +server-decided grants driving valve relays and tagging pours for server-side +attribution. Requires a `kegboard_reporter`. (Serverless installs can gate +valves with plain ESPHome automations on the reader triggers instead.) + +| Option | Default | Notes | +|---|---|---| +| `offline_policy` | `deny` | Token presented while the server is unreachable: `deny` (signal refusal), or `guest` (stay silent; pours proceed as guest pours). Neither opens valves. | +| `max_grant_duration` | `5min` | Device-side clamp on server-issued grants: the final bound on valve-open time. | + +Actions: `kegboard_auth.token_attached` / `.token_detached` (`device`, +`token`), `.revoke`. Condition: `.is_authorized`. Triggers: `on_authorized` +(`auth_device`, `token`), `on_denied` (`reason`), `on_revoked`. Optional +entities: `authorized`. + +### `kegboard_onewire` + +iButton presence on a 1-Wire bus โ€” ESPHome's `one_wire` enumerates devices but +has no arrive/leave events. Triggers `on_token_attached` and +`on_token_detached` with the ROM code as hex. + +`max_missed_searches` (default `4`) is how many consecutive misses before a +detach is reported. A held iButton makes intermittent contact, so reporting on +the first miss would make it flap several times a second. + +### Relays and buzzer + +`packages/relays.yaml` and `packages/buzzer.yaml` are plain YAML over stock +components. The relay watchdog is worth keeping from the AVR firmware: a relay +left on is usually a valve held open, so each one switches itself off +`relay_watchdog_timeout` (default `10s`) after turning on. On grant-driven +relays, set it longer than `max_grant_duration` (the grant clamp is their +bound) or the watchdog closes the valve mid-grant. + +## Repository layout + +``` +components/ ESPHome external components (this repo is the component source) + kegboard/ Hub component + the framework-agnostic core (see CORE.md) + kegboard_meter/ Flow meter and pour detection + kegboard_reporter/ Event protocol client (batching, pairing, commands) + kegboard_auth/ Per-meter authorization + kegboard_onewire/ iButton presence +packages/ Composable YAML users include +boards/ Pin maps per target board +docs/ Manual + protocol specifications +schemas/ Normative JSON Schemas for the protocol +examples/ Worked configurations +tests/core/ Host unit tests -- plain g++, no hardware, no toolchain +script/ CI helpers +``` + +## Development + +Host unit tests for the core logic need nothing but a C++ compiler: + +```console +$ make -C tests/core +$ make -C tests/core STRICT=1 # warnings as errors, as CI runs it +``` + +Build output is scoped by OS and architecture (`build/Darwin-arm64/`, etc.), so +a checkout shared between a host and a container or VM won't hand one +platform's binaries to the other. + +Formatting and lint mirror ESPHome's own conventions, since these components +compile into ESPHome's tree: + +```console +$ pip install pre-commit && pre-commit install +$ pre-commit run --all-files +``` + +The manual is a Sphinx project in `docs/`, published at +[docs.kegbot.org/projects/kegboard](https://docs.kegbot.org/projects/kegboard). +Its toolchain is managed by [uv](https://docs.astral.sh/uv/), synced +automatically on first use: + +```console +$ make -C docs html # output in docs/_build/html/ +$ make -C docs livehtml # live-rebuild server while editing +``` + +## Simulator + +`tools/kegboard-sim.py` is a TUI kegboard for developing receivers without +hardware. It speaks the full protocol โ€” pairing, batching, `age_ms`, +commands, dedup โ€” and validates every outgoing batch against the schemas, so +it cannot teach a server the wrong protocol. + +```console +$ uv run tools/kegboard-sim.py http://localhost:8000/kegboard-event +``` + +Single keys drive it: pour a beer (with live `pour_update`s), toggle +temperature logging, present preset tokens (including an unknown fob and a +presence-style iButton), kill the heartbeat so your server's liveness check +can notice, go offline to build a backlog that delivers late with correct +ages, replay the last batch verbatim to exercise dedup, send an +unknown event type, and reboot to reset `boot_id`. + +## Debugging + +The reporter logs one line per delivery at `DEBUG` (status, event count, +bytes). To see the actual protocol traffic โ€” every request and response body, +including `pour_update`s while beer is flowing โ€” raise its log level to +`VERY_VERBOSE`: + +```yaml +logger: + logs: + kegboard_reporter: VERY_VERBOSE +``` + +Then attach with `esphome logs .yaml`. Two notes: + +- `VERY_VERBOSE` lines are compiled out at default log levels, so production + builds pay nothing for this machinery. +- The logger truncates lines to its buffer (default 512 bytes); a full batch + body will clip. Add `logger: { tx_buffer_size: 2048 }` if that bites. + +## Hardware notes + +**ESP32 GPIOs are not 5 V tolerant.** Most beer flow meters are open-collector +hall-effect sensors, which are safe on a 3.3 V pull-up because they only ever +pull the line to ground. Meters with a push-pull 5 V output will damage the +ESP32 and need a level shifter or divider. Check your meter before wiring it. + +## History + +Kegboard was created in 2004 and has received several major updates over the +years: + +- **v1 (2004)** โ€” PIC16-based, written in the JAL programming language. +- **v2 (~2009)** โ€” rewritten for Arduino, introducing the KBSP serial + protocol. +- **v3 (2014)** โ€” the Kegboard Pro Mini, a fully-assembled Arduino-based + board. +- **v4 (2026)** โ€” this ground-up ESP32 rewrite on ESPHome. + +## License and copyright + +Kegboard v4 is offered under the **MIT** license, matching the rest of the +Kegbot project; see `LICENSE.txt`. + +Two notes on what that does and doesn't cover: + +- **Built firmware images are GPLv3.** ESPHome's C++ runtime is GPLv3, and a + compiled Kegboard image links against it. MIT is GPL-compatible, so this is + fine and is how ESPHome external components normally work โ€” but if you + distribute binaries, you are distributing GPLv3 binaries. The source in this + repository remains MIT and is reusable as such. +- **The `arduino` branch is still GPLv2-or-later.** The legacy AVR firmware was + written under that license and had outside contributors; nothing here + relicenses it. + +Copyright 2003-2026 The Kegbot Project Contributors + +[kegbot]: https://kegbot.org/ +[kegbot-server]: https://github.com/Kegbot/kegbot-server +[esphome]: https://esphome.io/ diff --git a/arduino/kegboard/KegboardPacket.cpp b/arduino/kegboard/KegboardPacket.cpp deleted file mode 100644 index 3ac3adf..0000000 --- a/arduino/kegboard/KegboardPacket.cpp +++ /dev/null @@ -1,152 +0,0 @@ -/** - * kegboard.pde - Kegboard v3 Arduino project - * Copyright 2003-2011 Mike Wakerly - * - * This file is part of the Kegbot package of the Kegbot project. - * For more information on Kegbot, see http://kegbot.org/ - * - * Kegbot is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegbot is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegbot. If not, see . - */ - -#include "Arduino.h" -#include "kegboard.h" -#include "KegboardPacket.h" - -#include -#include -#include -#include - -static uint16_t crc_ccitt_update_int(uint16_t crc, int value) { - crc = _crc_ccitt_update(crc, value & 0xff); - return _crc_ccitt_update(crc, (value >> 8) & 0xff); -} - -static void serial_print_int(int value) { - Serial.write(value & 0xff); - Serial.write((value >> 8) & 0xff); -} - -KegboardPacket::KegboardPacket() -{ - Reset(); -} - -bool KegboardPacket::IsReset() { - return (m_type == 0) && (m_len == 0); -} - -void KegboardPacket::Reset() -{ - m_len = 0; - m_type = 0; -} - -void KegboardPacket::AddTag(uint8_t tag, uint8_t buflen, const char *buf) -{ - m_payload[m_len++] = tag; - m_payload[m_len++] = buflen; - AppendBytes(buf, buflen); -} - -int KegboardPacket::FindTagLength(uint8_t tagnum) { - uint8_t* buf = FindTag(tagnum); - if (buf == NULL) { - return -1; - } - return buf[1] & 0xff; -} - -uint8_t* KegboardPacket::FindTag(uint8_t tagnum) { - uint8_t pos=0; - while (pos < m_len && pos < KBSP_PAYLOAD_MAXLEN) { - uint8_t tag = m_payload[pos]; - if (tag == tagnum) { - return m_payload+pos; - } - pos += 2 + m_payload[pos+1]; - } - return NULL; -} - -bool KegboardPacket::ReadTag(uint8_t tagnum, uint8_t *value) { - uint8_t *offptr = FindTag(tagnum); - if (offptr == NULL) { - return false; - } - *value = *(offptr+2); - return true; -} - -int KegboardPacket::CopyTagData(uint8_t tagnum, void* dest) { - uint8_t *offptr = FindTag(tagnum); - if (offptr == NULL) { - return -1; - } - uint8_t slen = *(offptr+1); - memcpy(dest, (offptr+2), slen); - return slen; -} - -void KegboardPacket::AppendBytes(const char *buf, int buflen) -{ - int i=0; - while (i < buflen && m_len < KBSP_PAYLOAD_MAXLEN) { - m_payload[m_len++] = (uint8_t) (*(buf+i)); - i++; - } -} - -uint16_t KegboardPacket::GenCrc() -{ - uint16_t crc = KBSP_PREFIX_CRC; - - crc = crc_ccitt_update_int(crc, m_type); - crc = crc_ccitt_update_int(crc, m_len); - - for (int i=0; i - -class KegboardPacket { - public: - KegboardPacket(); - void SetType(int type) {m_type = type;} - int GetType() {return m_type;} - void AddTag(uint8_t tag, uint8_t buflen, const char *buf); - - bool ReadTag(uint8_t tagnum, uint8_t *value); - int CopyTagData(uint8_t tagnum, void *dest); - - // Returns length of tag's payload, or -1 if not found. - int FindTagLength(uint8_t tagnum); - uint8_t* FindTag(uint8_t tagnum); - - void AppendBytes(const char *buf, int buflen); - void Reset(); - bool IsReset(); - void Print(); - uint16_t GenCrc(); - private: - int m_type; - uint8_t m_len; - uint8_t m_payload[KBSP_PAYLOAD_MAXLEN]; -}; diff --git a/arduino/kegboard/Makefile b/arduino/kegboard/Makefile deleted file mode 100644 index ea52dd9..0000000 --- a/arduino/kegboard/Makefile +++ /dev/null @@ -1,459 +0,0 @@ -#_______________________________________________________________________________ -# -# edam's Arduino makefile -#_______________________________________________________________________________ -# version 0.5 -# -# Copyright (C) 2011, 2012, 2013 Tim Marston . -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. -# -#_______________________________________________________________________________ -# -# -# This is a general purpose makefile for use with Arduino hardware and -# software. It works with the arduino-1.0 and later software releases. It -# should work GNU/Linux and OS X. To download the latest version of this -# makefile visit the following website where you can also find documentation on -# it's use. (The following text can only really be considered a reference.) -# -# http://ed.am/dev/make/arduino-mk -# -# This makefile can be used as a drop-in replacement for the Arduino IDE's -# build system. To use it, just copy arduino.mk in to your project directory. -# Or, you could save it somewhere (I keep mine at ~/src/arduino.mk) and create -# a symlink to it in your project directory, named "Makefile". For example: -# -# $ ln -s ~/src/arduino.mk Makefile -# -# The Arduino software (version 1.0 or later) is required. On GNU/Linux you -# can probably install the software from your package manager. If you are -# using Debian (or a derivative), try `apt-get install arduino`. Otherwise, -# you can download the Arduino software manually from http://arduino.cc/. It -# is suggested that you install it at ~/opt/arduino (or /Applications on OS X) -# if you are unsure. -# -# If you downloaded the Arduino software manually and unpacked it somewhere -# other than ~/opt/arduino (or /Applications), you will need to set up the -# ARDUINODIR environment variable to be the path where you unpacked it. (If -# unset, ARDUINODIR defaults to some sensible places). You could set this in -# your ~/.profile by adding something like this: -# -# export ARDUINODIR=~/somewhere/arduino-1.0 -# -# For each project, you will also need to set BOARD to the type of Arduino -# you're building for. Type `make boards` for a list of acceptable values. -# For example: -# -# $ export BOARD=uno -# $ make -# -# You may also need to set SERIALDEV if it is not detected correctly. -# -# The presence of a .ino (or .pde) file causes the arduino.mk to automatically -# determine values for SOURCES, TARGET and LIBRARIES. Any .c, .cc and .cpp -# files in the project directory (or any "util" or "utility" subdirectories) -# are automatically included in the build and are scanned for Arduino libraries -# that have been #included. Note, there can only be one .ino (or .pde) file in -# a project directory and if you want to be compatible with the Arduino IDE, it -# should be called the same as the directory name. -# -# Alternatively, if you want to manually specify build variables, create a -# Makefile that defines SOURCES and LIBRARIES and then includes arduino.mk. -# (There is no need to define TARGET). You can also specify the BOARD here, if -# the project has a specific one. Here is an example Makefile: -# -# SOURCES := main.cc other.cc -# LIBRARIES := EEPROM -# BOARD := pro5v -# include ~/src/arduino.mk -# -# Here is a complete list of configuration parameters: -# -# ARDUINODIR The path where the Arduino software is installed on your system. -# -# ARDUINOCONST The Arduino software version, as an integer, used to define the -# ARDUINO version constant. This defaults to 100 if undefined. -# -# AVRDUDECONF The avrdude.conf to use. If undefined, this defaults to a guess -# based on where avrdude is. If set empty, no avrdude.conf is -# passed to avrdude (so the system default is used). -# -# AVRDUDEFLAGS Specify any additional flags for avrdude. The usual flags, -# required to build the project, will be appended to this. -# -# AVRTOOLSPATH A space-separated list of directories that is searched in order -# when looking for the avr build tools. This defaults to PATH, -# followed by subdirectories in ARDUINODIR. -# -# BOARD Specify a target board type. Run `make boards` to see available -# board types. -# -# CPPFLAGS Specify any additional flags for the compiler. The usual flags, -# required to build the project, will be appended to this. -# -# LINKFLAGS Specify any additional flags for the linker. The usual flags, -# required to build the project, will be appended to this. -# -# LIBRARIES A list of Arduino libraries to build and include. This is set -# automatically if a .ino (or .pde) is found. -# -# LIBRARYPATH A space-separated list of directories that is searched in order -# when looking for Arduino libraries. This defaults to "libs", -# "libraries" (in the project directory), then your sketchbook -# "libraries" directory, then the Arduino libraries directory. -# -# SERIALDEV The POSIX device name of the serial device that is the Arduino. -# If unspecified, an attempt is made to guess the name of a -# connected Arduino's serial device, which may work in some cases. -# -# SOURCES A list of all source files of whatever language. The language -# type is determined by the file extension. This is set -# automatically if a .ino (or .pde) is found. -# -# TARGET The name of the target file. This is set automatically if a -# .ino (or .pde) is found, but it is not necessary to set it -# otherwise. -# -# This makefile also defines the following goals for use on the command line -# when you run make: -# -# all This is the default if no goal is specified. It builds the -# target. -# -# target Builds the target. -# -# upload Uploads the target (building it, as necessary) to an attached -# Arduino. -# -# clean Deletes files created during the build. -# -# boards Display a list of available board names, so that you can set the -# BOARD environment variable appropriately. -# -# monitor Start `screen` on the serial device. This is meant to be an -# equivalent to the Arduino serial monitor. -# -# size Displays size information about the built target. -# -# bootloader Burns the bootloader for your board to it. -# -# Builds the specified file, either an object file or the target, -# from those that that would be built for the project. -#_______________________________________________________________________________ -# - -# default arduino software directory, check software exists -ifndef ARDUINODIR -ARDUINODIR := $(firstword $(wildcard ~/opt/arduino /usr/share/arduino \ - /Applications/Arduino.app/Contents/Resources/Java \ - $(HOME)/Applications/Arduino.app/Contents/Resources/Java)) -endif -ifeq "$(wildcard $(ARDUINODIR)/hardware/arduino/boards.txt)" "" -$(error ARDUINODIR is not set correctly; arduino software not found) -endif - -# default arduino version -ARDUINOCONST ?= 100 - -# default path for avr tools -AVRTOOLSPATH ?= $(subst :, , $(PATH)) $(ARDUINODIR)/hardware/tools \ - $(ARDUINODIR)/hardware/tools/avr/bin - -# default path to find libraries -LIBRARYPATH ?= libraries libs $(SKETCHBOOKDIR)/libraries $(ARDUINODIR)/libraries - -# default serial device to a poor guess (something that might be an arduino) -SERIALDEVGUESS := 0 -ifndef SERIALDEV -SERIALDEV := $(firstword $(wildcard \ - /dev/ttyACM? /dev/ttyUSB? /dev/tty.usbserial* /dev/tty.usbmodem*)) -SERIALDEVGUESS := 1 -endif - -# no board? -ifndef BOARD -ifneq "$(MAKECMDGOALS)" "boards" -ifneq "$(MAKECMDGOALS)" "clean" -$(error BOARD is unset. Type 'make boards' to see possible values) -endif -endif -endif - -# obtain board parameters from the arduino boards.txt file -BOARDSFILE := $(ARDUINODIR)/hardware/arduino/boards.txt -readboardsparam = $(shell sed -ne "s/$(BOARD).$(1)=\(.*\)/\1/p" $(BOARDSFILE)) -BOARD_BUILD_MCU := $(call readboardsparam,build.mcu) -BOARD_BUILD_FCPU := $(call readboardsparam,build.f_cpu) -BOARD_BUILD_VARIANT := $(call readboardsparam,build.variant) -BOARD_UPLOAD_SPEED := $(call readboardsparam,upload.speed) -BOARD_UPLOAD_PROTOCOL := $(call readboardsparam,upload.protocol) -BOARD_USB_VID := $(call readboardsparam,build.vid) -BOARD_USB_PID := $(call readboardsparam,build.pid) -BOARD_BOOTLOADER_UNLOCK := $(call readboardsparam,bootloader.unlock_bits) -BOARD_BOOTLOADER_LOCK := $(call readboardsparam,bootloader.lock_bits) -BOARD_BOOTLOADER_LFUSES := $(call readboardsparam,bootloader.low_fuses) -BOARD_BOOTLOADER_HFUSES := $(call readboardsparam,bootloader.high_fuses) -BOARD_BOOTLOADER_EFUSES := $(call readboardsparam,bootloader.extended_fuses) -BOARD_BOOTLOADER_PATH := $(call readboardsparam,bootloader.path) -BOARD_BOOTLOADER_FILE := $(call readboardsparam,bootloader.file) - -# obtain preferences from the IDE's preferences.txt -PREFERENCESFILE := $(firstword $(wildcard \ - $(HOME)/.arduino/preferences.txt $(HOME)/Library/Arduino/preferences.txt)) -ifneq "$(PREFERENCESFILE)" "" -readpreferencesparam = $(shell sed -ne "s/$(1)=\(.*\)/\1/p" $(PREFERENCESFILE)) -SKETCHBOOKDIR := $(call readpreferencesparam,sketchbook.path) -endif - -# invalid board? -ifeq "$(BOARD_BUILD_MCU)" "" -ifneq "$(MAKECMDGOALS)" "boards" -ifneq "$(MAKECMDGOALS)" "clean" -$(error BOARD is invalid. Type 'make boards' to see possible values) -endif -endif -endif - -# auto mode? -INOFILE := $(wildcard *.ino *.pde) -ifdef INOFILE -ifneq "$(words $(INOFILE))" "1" -$(error There is more than one .pde or .ino file in this directory!) -endif - -# automatically determine sources and targeet -TARGET := $(basename $(INOFILE)) -SOURCES := $(INOFILE) \ - $(wildcard *.c *.cc *.cpp *.C) \ - $(wildcard $(addprefix util/, *.c *.cc *.cpp *.C)) \ - $(wildcard $(addprefix utility/, *.c *.cc *.cpp *.C)) - -# automatically determine included libraries -LIBRARIES := $(filter $(notdir $(wildcard $(addsuffix /*, $(LIBRARYPATH)))), \ - $(shell sed -ne "s/^ *\# *include *[<\"]\(.*\)\.h[>\"]/\1/p" $(SOURCES))) - -endif - -# software -findsoftware = $(firstword $(wildcard $(addsuffix /$(1), $(AVRTOOLSPATH)))) -CC := $(call findsoftware,avr-gcc) -CXX := $(call findsoftware,avr-g++) -LD := $(call findsoftware,avr-ld) -AR := $(call findsoftware,avr-ar) -OBJCOPY := $(call findsoftware,avr-objcopy) -AVRDUDE := $(call findsoftware,avrdude) -AVRSIZE := $(call findsoftware,avr-size) - -# directories -ARDUINOCOREDIR := $(ARDUINODIR)/hardware/arduino/cores/arduino -LIBRARYDIRS := $(foreach lib, $(LIBRARIES), \ - $(firstword $(wildcard $(addsuffix /$(lib), $(LIBRARYPATH))))) -LIBRARYDIRS += $(addsuffix /utility, $(LIBRARYDIRS)) - -# files -TARGET := $(if $(TARGET),$(TARGET),a.out) -OBJECTS := $(addsuffix .o, $(basename $(SOURCES))) -DEPFILES := $(patsubst %, .dep/%.dep, $(SOURCES)) -ARDUINOLIB := .lib/arduino.a -ARDUINOLIBOBJS := $(foreach dir, $(ARDUINOCOREDIR) $(LIBRARYDIRS), \ - $(patsubst %, .lib/%.o, $(wildcard $(addprefix $(dir)/, *.c *.cpp)))) -BOOTLOADERHEX := $(addprefix \ - $(ARDUINODIR)/hardware/arduino/bootloaders/$(BOARD_BOOTLOADER_PATH)/, \ - $(BOARD_BOOTLOADER_FILE)) - -# avrdude confifuration -ifeq "$(AVRDUDECONF)" "" -ifeq "$(AVRDUDE)" "$(ARDUINODIR)/hardware/tools/avr/bin/avrdude" -AVRDUDECONF := $(ARDUINODIR)/hardware/tools/avr/etc/avrdude.conf -else -AVRDUDECONF := $(wildcard $(AVRDUDE).conf) -endif -endif - -# flags -BOARD_UPPER = $(shell tr '[:lower:]' '[:upper:]' <<< $(BOARD)) - -CPPFLAGS += -Os -Wall -fno-exceptions -ffunction-sections -fdata-sections -CPPFLAGS += -funsigned-char -funsigned-bitfields -fpack-struct -fshort-enums -CPPFLAGS += -mmcu=$(BOARD_BUILD_MCU) -CPPFLAGS += -DF_CPU=$(BOARD_BUILD_FCPU) -DARDUINO=$(ARDUINOCONST) -CPPFLAGS += -DUSB_VID=$(BOARD_USB_VID) -DUSB_PID=$(BOARD_USB_PID) -CPPFLAGS += -DBOARD_$(BOARD_UPPER)=1 -CPPFLAGS += -I. -Iutil -Iutility -I $(ARDUINOCOREDIR) -CPPFLAGS += -I $(ARDUINODIR)/hardware/arduino/variants/$(BOARD_BUILD_VARIANT)/ -CPPFLAGS += $(addprefix -I , $(LIBRARYDIRS)) -CPPDEPFLAGS = -MMD -MP -MF .dep/$<.dep -CPPINOFLAGS := -x c++ -include $(ARDUINOCOREDIR)/Arduino.h -AVRDUDEFLAGS += $(addprefix -C , $(AVRDUDECONF)) -DV -AVRDUDEFLAGS += -p $(BOARD_BUILD_MCU) -P $(SERIALDEV) -AVRDUDEFLAGS += -c $(BOARD_UPLOAD_PROTOCOL) -b $(BOARD_UPLOAD_SPEED) -LINKFLAGS += -Os -Wl,--gc-sections -mmcu=$(BOARD_BUILD_MCU) - -# figure out which arg to use with stty (for OS X, GNU and busybox stty) -STTYFARG := $(shell stty --help 2>&1 | \ - grep -q 'illegal option' && echo -f || echo -F) - -# include dependencies -ifneq "$(MAKECMDGOALS)" "clean" --include $(DEPFILES) -endif - -# default rule -.DEFAULT_GOAL := all - -#_______________________________________________________________________________ -# RULES - -.PHONY: all target upload clean boards monitor size bootloader - -all: target - -target: $(TARGET).hex - -upload: target - @echo "\nUploading to board..." - @test -n "$(SERIALDEV)" || { \ - echo "error: SERIALDEV could not be determined automatically." >&2; \ - exit 1; } - @test 0 -eq $(SERIALDEVGUESS) || { \ - echo "*GUESSING* at serial device:" $(SERIALDEV); \ - echo; } -ifeq "$(BOARD_BOOTLOADER_PATH)" "caterina" - stty $(STTYFARG) $(SERIALDEV) speed 1200 - sleep 1 -else - stty $(STTYFARG) $(SERIALDEV) hupcl -endif - $(AVRDUDE) $(AVRDUDEFLAGS) -U flash:w:$(TARGET).hex:i - -clean: - rm -f $(OBJECTS) - rm -f $(TARGET).elf $(TARGET).hex $(ARDUINOLIB) *~ - rm -rf .lib .dep - -boards: - @echo "Available values for BOARD:" - @sed -nEe '/^#/d; /^[^.]+\.name=/p' $(BOARDSFILE) | \ - sed -Ee 's/([^.]+)\.name=(.*)/\1 \2/' \ - -e 's/(.{12}) *(.*)/\1 \2/' - -monitor: - @test -n "$(SERIALDEV)" || { \ - echo "error: SERIALDEV could not be determined automatically." >&2; \ - exit 1; } - @test -n `which screen` || { \ - echo "error: can't find GNU screen, you might need to install it." >&2 \ - exit 1; } - @test 0 -eq $(SERIALDEVGUESS) || { \ - echo "*GUESSING* at serial device:" $(SERIALDEV); \ - echo; } - screen $(SERIALDEV) - -size: $(TARGET).elf - echo && $(AVRSIZE) --format=avr --mcu=$(BOARD_BUILD_MCU) $(TARGET).elf - -bootloader: - @echo "Burning bootloader to board..." - @test -n "$(SERIALDEV)" || { \ - echo "error: SERIALDEV could not be determined automatically." >&2; \ - exit 1; } - @test 0 -eq $(SERIALDEVGUESS) || { \ - echo "*GUESSING* at serial device:" $(SERIALDEV); \ - echo; } - stty $(STTYFARG) $(SERIALDEV) hupcl - $(AVRDUDE) $(AVRDUDEFLAGS) -U lock:w:$(BOARD_BOOTLOADER_UNLOCK):m - $(AVRDUDE) $(AVRDUDEFLAGS) -eU lfuse:w:$(BOARD_BOOTLOADER_LFUSES):m - $(AVRDUDE) $(AVRDUDEFLAGS) -U hfuse:w:$(BOARD_BOOTLOADER_HFUSES):m -ifneq "$(BOARD_BOOTLOADER_EFUSES)" "" - $(AVRDUDE) $(AVRDUDEFLAGS) -U efuse:w:$(BOARD_BOOTLOADER_EFUSES):m -endif -ifneq "$(BOOTLOADERHEX)" "" - $(AVRDUDE) $(AVRDUDEFLAGS) -U flash:w:$(BOOTLOADERHEX):i -endif - $(AVRDUDE) $(AVRDUDEFLAGS) -U lock:w:$(BOARD_BOOTLOADER_LOCK):m - -# building the target - -$(TARGET).hex: $(TARGET).elf - $(OBJCOPY) -O ihex -R .eeprom $< $@ - -.INTERMEDIATE: $(TARGET).elf - -$(TARGET).elf: $(ARDUINOLIB) $(OBJECTS) - $(CC) $(LINKFLAGS) $(OBJECTS) $(ARDUINOLIB) -lm -o $@ - -%.o: %.c .dep - echo "Build $@" - mkdir -p .dep/$(dir $<) - $(COMPILE.c) $(CPPDEPFLAGS) -Werror -o $@ $< - -%.o: %.cpp .dep - echo "Build $@" - mkdir -p .dep/$(dir $<) - $(COMPILE.cpp) $(CPPDEPFLAGS) -Werror -o $@ $< - -%.o: %.cc .dep - echo "Build $@" - mkdir -p .dep/$(dir $<) - $(COMPILE.cpp) $(CPPDEPFLAGS) -Werror -o $@ $< - -%.o: %.C .dep - echo "Build $@" - mkdir -p .dep/$(dir $<) - $(COMPILE.cpp) $(CPPDEPFLAGS) -o $@ $< - -%.o: %.ino .dep - echo "Build $@ ino"; echo `pwd` - mkdir -p .dep/$(dir $<) - $(COMPILE.cpp) $(CPPDEPFLAGS) -Werror -o $@ $(CPPINOFLAGS) $< - -%.o: %.pde .dep - echo "Build $@ pde" - mkdir -p .dep/$(dir $<) - $(COMPILE.cpp) $(CPPDEPFLAGS) -o $@ $(CPPINOFLAGS) $< - -.dep: - mkdir -p $@ - -# building the arduino library - -$(ARDUINOLIB): $(ARDUINOLIBOBJS) - $(AR) rcs $@ $? - -.lib/%.c.o: %.c - mkdir -p $(dir $@) - $(COMPILE.c) -o $@ $< - -.lib/%.cpp.o: %.cpp - mkdir -p $(dir $@) - $(COMPILE.cpp) -o $@ $< - -.lib/%.cc.o: %.cc - mkdir -p $(dir $@) - $(COMPILE.cpp) -o $@ $< - -.lib/%.C.o: %.C - mkdir -p $(dir $@) - $(COMPILE.cpp) -o $@ $< - -# Local Variables: -# mode: makefile -# tab-width: 4 -# End: diff --git a/arduino/kegboard/OneWire.cpp b/arduino/kegboard/OneWire.cpp deleted file mode 100644 index 6ee0008..0000000 --- a/arduino/kegboard/OneWire.cpp +++ /dev/null @@ -1,403 +0,0 @@ -/* -Copyright (c) 2007, Jim Studt - -Updated to work with arduino-0008 and to include skip() as of -2007/07/06. --RJL20 - -Modified to calculate the 8-bit CRC directly, avoiding the need for -the 256-byte lookup table to be loaded in RAM. Tested in arduino-0010 --- Tom Pollard, Jan 23, 2008 - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -Much of the code was inspired by Derek Yerger's code, though I don't -think much of that remains. In any event that was.. - (copyleft) 2006 by Derek Yerger - Free to distribute freely. - -The CRC code was excerpted and inspired by the Dallas Semiconductor -sample code bearing this copyright. -//--------------------------------------------------------------------------- -// Copyright (C) 2000 Dallas Semiconductor Corporation, All Rights Reserved. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the "Software"), -// to deal in the Software without restriction, including without limitation -// the rights to use, copy, modify, merge, publish, distribute, sublicense, -// and/or sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -// IN NO EVENT SHALL DALLAS SEMICONDUCTOR BE LIABLE FOR ANY CLAIM, DAMAGES -// OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// -// Except as contained in this notice, the name of Dallas Semiconductor -// shall not be used except as stated in the Dallas Semiconductor -// Branding Policy. -//-------------------------------------------------------------------------- -*/ - -#include "OneWire.h" -#include "Arduino.h" - -extern "C" { -#include -#include "pins_arduino.h" -} - - -OneWire::OneWire( uint8_t pinArg) -{ - pin = pinArg; - port = digitalPinToPort(pin); - bitmask = digitalPinToBitMask(pin); - outputReg = portOutputRegister(port); - inputReg = portInputRegister(port); - modeReg = portModeRegister(port); -#if ONEWIRE_SEARCH - reset_search(); -#endif -} - -// -// Perform the onewire reset function. We will wait up to 250uS for -// the bus to come high, if it doesn't then it is broken or shorted -// and we return a 0; -// -// Returns 1 if a device asserted a presence pulse, 0 otherwise. -// -uint8_t OneWire::reset() { - uint8_t r; - uint8_t retries = 125; - - // wait until the wire is high... just in case - pinMode(pin,INPUT); - do { - if ( retries-- == 0) return 0; - delayMicroseconds(2); - } while( !digitalRead( pin)); - - digitalWrite(pin,0); // pull low for 500uS - pinMode(pin,OUTPUT); - delayMicroseconds(500); - pinMode(pin,INPUT); - delayMicroseconds(65); - r = !digitalRead(pin); - delayMicroseconds(490); - return r; -} - -// -// Write a bit. Port and bit is used to cut lookup time and provide -// more certain timing. -// -void OneWire::write_bit(uint8_t v) { - static uint8_t lowTime[] = { 55, 5 }; - static uint8_t highTime[] = { 5, 55}; - - v = (v&1); - *modeReg |= bitmask; // make pin an output, do first since we - // expect to be at 1 - *outputReg &= ~bitmask; // zero - delayMicroseconds(lowTime[v]); - *outputReg |= bitmask; // one, push pin up - important for - // parasites, they might start in here - delayMicroseconds(highTime[v]); -} - -// -// Read a bit. Port and bit is used to cut lookup time and provide -// more certain timing. -// -uint8_t OneWire::read_bit() { - uint8_t r; - - *modeReg |= bitmask; // make pin an output, do first since we expect to be at 1 - *outputReg &= ~bitmask; // zero - delayMicroseconds(1); - *modeReg &= ~bitmask; // let pin float, pull up will raise - delayMicroseconds(5); // A "read slot" is when 1mcs > t > 2mcs - r = (*inputReg & bitmask) ? 1 : 0; // check the bit - delayMicroseconds(50); // whole bit slot is 60-120uS, need to give some time - - return r; -} - -// -// Write a byte. The writing code uses the active drivers to raise the -// pin high, if you need power after the write (e.g. DS18S20 in -// parasite power mode) then set 'power' to 1, otherwise the pin will -// go tri-state at the end of the write to avoid heating in a short or -// other mishap. -// -void OneWire::write(uint8_t v, uint8_t power) { - uint8_t bitMask; - - for (bitMask = 0x01; bitMask; bitMask <<= 1) { - OneWire::write_bit((bitMask & v) ? 1 : 0); - } - if (!power) { - pinMode(pin,INPUT); - digitalWrite(pin,0); - } -} - -// -// Read a byte -// -uint8_t OneWire::read() { - uint8_t bitMask; - uint8_t r = 0; - - for (bitMask = 0x01; bitMask; bitMask <<= 1) { - if (OneWire::read_bit()) { - r |= bitMask; - } - } - return r; -} - -// -// Do a ROM select -// -void OneWire::select(uint8_t rom[8]) -{ - int i; - write(0x55,0); // Choose ROM - for(i = 0; i < 8; i++) { - write(rom[i],0); - } -} - -// -// Do a ROM skip -// -void OneWire::skip() -{ - write(0xCC, 0); // Skip ROM -} - -void OneWire::depower() -{ - pinMode(pin, INPUT); -} - -#if ONEWIRE_SEARCH - -// -// You need to use this function to start a search again from the beginning. -// You do not need to do it for the first search, though you could. -// -void OneWire::reset_search() -{ - uint8_t i; - - last_discrepancy = -1; - searchExhausted = 0; - for(i = 7; ; i--) { - address[i] = 0; - if (i == 0) { - break; - } - } -} - -// -// Perform a search. If this function returns a '1' then it has -// enumerated the next device and you may retrieve the ROM from the -// OneWire::address variable. If there are no devices, no further -// devices, or something horrible happens in the middle of the -// enumeration then a 0 is returned. If a new device is found then -// its address is copied to newAddr. Use OneWire::reset_search() to -// start over. -// -uint8_t OneWire::search(uint8_t *newAddr) -{ - uint8_t i; - - if (searchExhausted) { - return 0; - } - - if (!reset()) { - return 0; - } - - write(0xf0, 0); - last_zero = 0; - - for(i = 0; i < 64; i++) { - uint8_t id_bit = read_bit(); - uint8_t cmp_id_bit = read_bit(); - uint8_t ibyte = i/8; - uint8_t ibit = 1<<(i&7); - uint8_t search_direction; - - if ((id_bit == 1) && (cmp_id_bit == 1)) { - // Participaing device stopped responding (ie removed during search); - // search should be terminated and bus reset. - // Reference: http://www.maxim-ic.com/appnotes.cfm/an_pk/187 - reset_search(); - searchExhausted = 1; - return 0; - } else if ((id_bit == 0) && (cmp_id_bit == 0)) { - if (i == last_discrepancy) { - search_direction = 1; - } else if (i > last_discrepancy) { - search_direction = 0; - } else { - search_direction = (address[ibyte] >> (i%8)) & 0x1; - } - if (search_direction == 0) { - last_zero = i; - } - } else { - search_direction = id_bit; - } - - if (search_direction) { - address[ibyte] |= ibit; - } else { - address[ibyte] &= ~ibit; - } - - write_bit(search_direction); - } - - last_discrepancy = last_zero; - - if (last_discrepancy == 0) { - searchExhausted = 1; - } - - if (OneWire::crc8(address, 7) != address[7]) { - reset_search(); - return 0; - } - - for (i = 0; i < 8; i++) { - newAddr[i] = address[i]; - } - return 1; -} -#endif - -#if ONEWIRE_CRC -// The 1-Wire CRC scheme is described in Maxim Application Note 27: -// "Understanding and Using Cyclic Redundancy Checks with Maxim iButton Products" -// - -#if ONEWIRE_CRC8_TABLE -// This table comes from Dallas sample code where it is freely reusable, -// though Copyright (C) 2000 Dallas Semiconductor Corporation -static uint8_t dscrc_table[] = { - 0, 94,188,226, 97, 63,221,131,194,156,126, 32,163,253, 31, 65, - 157,195, 33,127,252,162, 64, 30, 95, 1,227,189, 62, 96,130,220, - 35,125,159,193, 66, 28,254,160,225,191, 93, 3,128,222, 60, 98, - 190,224, 2, 92,223,129, 99, 61,124, 34,192,158, 29, 67,161,255, - 70, 24,250,164, 39,121,155,197,132,218, 56,102,229,187, 89, 7, - 219,133,103, 57,186,228, 6, 88, 25, 71,165,251,120, 38,196,154, - 101, 59,217,135, 4, 90,184,230,167,249, 27, 69,198,152,122, 36, - 248,166, 68, 26,153,199, 37,123, 58,100,134,216, 91, 5,231,185, - 140,210, 48,110,237,179, 81, 15, 78, 16,242,172, 47,113,147,205, - 17, 79,173,243,112, 46,204,146,211,141,111, 49,178,236, 14, 80, - 175,241, 19, 77,206,144,114, 44,109, 51,209,143, 12, 82,176,238, - 50,108,142,208, 83, 13,239,177,240,174, 76, 18,145,207, 45,115, - 202,148,118, 40,171,245, 23, 73, 8, 86,180,234,105, 55,213,139, - 87, 9,235,181, 54,104,138,212,149,203, 41,119,244,170, 72, 22, - 233,183, 85, 11,136,214, 52,106, 43,117,151,201, 74, 20,246,168, - 116, 42,200,150, 21, 75,169,247,182,232, 10, 84,215,137,107, 53 -}; - -// -// Compute a Dallas Semiconductor 8 bit CRC. These show up in the ROM -// and the registers. (note: this might better be done without to -// table, it would probably be smaller and certainly fast enough -// compared to all those delayMicrosecond() calls. But I got -// confused, so I use this table from the examples.) -// -uint8_t OneWire::crc8(uint8_t *addr, uint8_t len) -{ - uint8_t i; - uint8_t crc = 0; - - for (i = 0; i < len; i++) { - crc = dscrc_table[crc ^ addr[i]]; - } - return crc; -} -#else -// -// Compute a Dallas Semiconductor 8 bit CRC directly. -// -uint8_t OneWire::crc8( uint8_t *addr, uint8_t len) -{ - uint8_t i, j; - uint8_t crc = 0; - - for (i = 0; i < len; i++) { - uint8_t inbyte = addr[i]; - for (j = 0; j < 8; j++) { - uint8_t mix = (crc ^ inbyte) & 0x01; - crc >>= 1; - if (mix) crc ^= 0x8C; - inbyte >>= 1; - } - } - return crc; -} -#endif - -#if ONEWIRE_CRC16 -static short oddparity[16] = { 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0 }; - -// -// Compute a Dallas Semiconductor 16 bit CRC. I have never seen one of -// these, but here it is. -// -unsigned short OneWire::crc16(unsigned short *data, unsigned short len) -{ - unsigned short i; - unsigned short crc = 0; - - for (i = 0; i < len; i++) { - unsigned short cdata = data[len]; - - cdata = (cdata ^ (crc & 0xff)) & 0xff; - crc >>= 8; - - if (oddparity[cdata & 0xf] ^ oddparity[cdata >> 4]) crc ^= 0xc001; - - cdata <<= 6; - crc ^= cdata; - cdata <<= 1; - crc ^= cdata; - } - return crc; -} -#endif - -#endif diff --git a/arduino/kegboard/OneWire.h b/arduino/kegboard/OneWire.h deleted file mode 100644 index ee890b5..0000000 --- a/arduino/kegboard/OneWire.h +++ /dev/null @@ -1,107 +0,0 @@ -#ifndef OneWire_h -#define OneWire_h - -#include - -// you can exclude onewire_search by defining that to 0 -#ifndef ONEWIRE_SEARCH -#define ONEWIRE_SEARCH 1 -#endif - -// You can exclude CRC checks altogether by defining this to 0 -#ifndef ONEWIRE_CRC -#define ONEWIRE_CRC 1 -#endif - -// Select the table-lookup method of computing the 8-bit CRC by setting this to 1 -#ifndef ONEWIRE_CRC8_TABLE -#define ONEWIRE_CRC8_TABLE 0 -#endif - -// You can allow 16-bit CRC checks by defining this to 1 -// (Note that ONEWIRE_CRC must also be 1.) -#ifndef ONEWIRE_CRC16 -#define ONEWIRE_CRC16 0 -#endif - -class OneWire -{ - private: -#if ONEWIRE_SEARCH - uint8_t address[8]; - char last_discrepancy; - char last_zero; - uint8_t searchExhausted; -#endif - uint8_t pin; - uint8_t port; - uint8_t bitmask; - volatile uint8_t *outputReg; - volatile uint8_t *inputReg; - volatile uint8_t *modeReg; - - public: - OneWire( uint8_t pin); - - // Perform a 1-Wire reset cycle. Returns 1 if a device responds - // with a presence pulse. Returns 0 if there is no device or the - // bus is shorted or otherwise held low for more than 250uS - uint8_t reset(); - - // Issue a 1-Wire rom select command, you do the reset first. - void select( uint8_t rom[8]); - - // Issue a 1-Wire rom skip command, to address all on bus. - void skip(); - - // Write a byte. If 'power' is one then the wire is held high at - // the end for parasitically powered devices. You are responsible - // for eventually depowering it by calling depower() or doing - // another read or write. - void write( uint8_t v, uint8_t power = 0); - - // Read a byte. - uint8_t read(); - - // Write a bit. The bus is always left powered at the end, see - // note in write() about that. - void write_bit( uint8_t v); - - // Read a bit. - uint8_t read_bit(); - - // Stop forcing power onto the bus. You only need to do this if - // you used the 'power' flag to write() or used a write_bit() call - // and aren't about to do another read or write. You would rather - // not leave this powered if you don't have to, just in case - // someone shorts your bus. - void depower(); - -#if ONEWIRE_SEARCH - // Clear the search state so that if will start from the beginning again. - void reset_search(); - - // Look for the next device. Returns 1 if a new address has been - // returned. A zero might mean that the bus is shorted, there are - // no devices, or you have already retrieved all of them. It - // might be a good idea to check the CRC to make sure you didn't - // get garbage. The order is deterministic. You will always get - // the same devices in the same order. - uint8_t search(uint8_t *newAddr); -#endif - -#if ONEWIRE_CRC - // Compute a Dallas Semiconductor 8 bit CRC, these are used in the - // ROM and scratchpad registers. - static uint8_t crc8( uint8_t *addr, uint8_t len); - -#if ONEWIRE_CRC16 - // Compute a Dallas Semiconductor 16 bit CRC. Maybe. I don't have - // any devices that use this so this might be wrong. I just copied - // it from their sample code. - static unsigned short crc16(unsigned short *data, unsigned short len); -#endif -#endif -}; - -#endif diff --git a/arduino/kegboard/PCInterrupt.cpp b/arduino/kegboard/PCInterrupt.cpp deleted file mode 100644 index 1fa007a..0000000 --- a/arduino/kegboard/PCInterrupt.cpp +++ /dev/null @@ -1,153 +0,0 @@ -// -// PCInterrupt.cpp -// KegBoard -// -// An extension to the interrupt support for Arduino. -// Adds pin change interrupts to the external interrupts, allowing -// any pin to support external interrupts efficiently. -// -// Theory: all IO pins on Atmega168 are covered by Pin Change Interrupts. -// The PCINT corresponding to the pin must be enabled and masked, and -// an ISR routine provided. Since PCINTs are per port, not per pin, the ISR -// must use some logic to actually implement a per-pin interrupt service. -// -// Pin to interrupt map: -// D0-D7 = PCINT 16-23 = PCIR2 = PD = PCIE2 = pcmsk2 -// D8-D13 = PCINT 0-5 = PCIR0 = PB = PCIE0 = pcmsk0 -// A0-A5 (D14-D19) = PCINT 8-13 = PCIR1 = PC = PCIE1 = pcmsk1 -// -// Originally by ckiick at http://www.arduino.cc/playground/Main/PcInt -// Modified to support RISING/FALLING by John Boiles 9/30/10 -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . -// - -#include "Arduino.h" -#include "pins_arduino.h" -#include "kegboard_config.h" - -volatile uint8_t *PCintPortToInputMask[] = { - &PCMSK0, - &PCMSK1, - &PCMSK2 -}; - -static int PCintMode[24]; - -typedef void (*voidFuncPtr)(void); - -volatile static voidFuncPtr PCintFunc[24] = { NULL }; - -volatile static uint8_t PCintLast[3]; - -void PCattachInterrupt(uint8_t pin, void (*userFunc)(void), int mode) { - uint8_t bit = digitalPinToBitMask(pin); - uint8_t port = digitalPinToPort(pin); - uint8_t slot; - volatile uint8_t *pcmask; - - pinMode(pin, INPUT); - // map pin to PCIR register - if (port == NOT_A_PORT) { - return; - } - - port -= 2; - pcmask = PCintPortToInputMask[port]; - - // Fix by Baziki. In the original sources there was a little bug, - // which caused analog ports to work incorrectly. - if (port == 1) { - slot = port * 8 + (pin - 14); - } - else { - slot = port * 8 + (pin % 8); - } - - PCintMode[slot] = mode; - PCintFunc[slot] = userFunc; - // set the mask - *pcmask |= bit; - // enable the interrupt - PCICR |= 0x01 << port; -} - -void PCdetachInterrupt(uint8_t pin) { - uint8_t bit = digitalPinToBitMask(pin); - uint8_t port = digitalPinToPort(pin); - volatile uint8_t *pcmask; - - // map pin to PCIR register - if (port == NOT_A_PORT) { - return; - } else { - port -= 2; - pcmask = PCintPortToInputMask[port]; - } - - // disable the mask. - *pcmask &= ~bit; - // if that's the last one, disable the interrupt. - if (*pcmask == 0) { - PCICR &= ~(0x01 << port); - } -} - -#if KB_ENABLE_WIEGAND_RFID - -// common code for isr handler. "port" is the PCINT number. -// there isn't really a good way to back-map ports and masks to pins. -static void PCint(uint8_t port) { - uint8_t bit; - uint8_t curr; - uint8_t mask; - uint8_t pin; - - // get the pin states for the indicated port. - curr = *portInputRegister(port+2); - mask = curr ^ PCintLast[port]; - // mask is pins that have changed. screen out non pcint pins. - if ((mask &= *PCintPortToInputMask[port]) == 0) { - return; - } - // mask is pcint pins that have changed. - for (uint8_t i=0; i < 8; i++) { - bit = 0x01 << i; - if (bit & mask) { - pin = port * 8 + i; - // Trigger interrupt if mode is CHANGE, or if mode is RISING and - // the bit is currently high, or if mode is FALLING and bit is low. - if ((PCintMode[pin] == CHANGE - || ((PCintMode[pin] == RISING) && (curr & bit)) - || ((PCintMode[pin] == FALLING) && !(curr & bit))) - && (PCintFunc[pin] != NULL)) { - PCintFunc[pin](); - } - } - } - // Save current pin values - PCintLast[port] = curr; -} - -SIGNAL(PCINT0_vect) { - PCint(0); -} -SIGNAL(PCINT1_vect) { - PCint(1); -} -SIGNAL(PCINT2_vect) { - PCint(2); -} -#endif - diff --git a/arduino/kegboard/PCInterrupt.h b/arduino/kegboard/PCInterrupt.h deleted file mode 100644 index 0eb4b81..0000000 --- a/arduino/kegboard/PCInterrupt.h +++ /dev/null @@ -1,46 +0,0 @@ -// -// PCInterrupt.h -// KegBoard -// -// An extension to the interrupt support for Arduino. -// Adds pin change interrupts to the external interrupts, allowing -// any pin to support external interrupts efficiently. -// -// Theory: all IO pins on Atmega168 are covered by Pin Change Interrupts. -// The PCINT corresponding to the pin must be enabled and masked, and -// an ISR routine provided. Since PCINTs are per port, not per pin, the ISR -// must use some logic to actually implement a per-pin interrupt service. -// -// Pin to interrupt map: -// D0-D7 = PCINT 16-23 = PCIR2 = PD = PCIE2 = pcmsk2 -// D8-D13 = PCINT 0-5 = PCIR0 = PB = PCIE0 = pcmsk0 -// A0-A5 (D14-D19) = PCINT 8-13 = PCIR1 = PC = PCIE1 = pcmsk1 -// -// Originally by ckiick at http://www.arduino.cc/playground/Main/PcInt -// Modified to support RISING/FALLING by John Boiles 9/30/10 -// -// This program is free software; you can redistribute it and/or -// modify it under the terms of the GNU General Public License -// as published by the Free Software Foundation; either version 2 -// of the License, or (at your option) any later version. - -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU General Public License for more details. -// -// You should have received a copy of the GNU General Public License -// along with this program. If not, see . -// - -#include - -/* - * Dttach an interrupt to a specific pin using pin change interrupts. - */ -void PCattachInterrupt(uint8_t pin, void (*userFunc)(void), int mode); - -/* - * Detach an pin change interrupt from a specific pin. - */ -void PCdetachInterrupt(uint8_t pin); diff --git a/arduino/kegboard/Wiegand.cpp b/arduino/kegboard/Wiegand.cpp deleted file mode 100644 index d02774c..0000000 --- a/arduino/kegboard/Wiegand.cpp +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2012 Mike Wakerly - * - * This file is part of the Kegboard package of the Kegbot project. - * For more information on Kegboard or Kegbot, see http://kegbot.org/ - * - * Kegboard is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegboard is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegboard. If not, see . - */ - -#include "Arduino.h" -#include "Wiegand.h" -#include "pins_arduino.h" - -Wiegand::Wiegand() { - bitpos_ = 0; -} - -void Wiegand::handleData0Pulse() { - bitpos_++; -} - -void Wiegand::handleData1Pulse() { - int index = bitpos_ / 8; - if (index >= WIEGAND_BUFSIZ) { - return; - } - int offset = bitpos_ % 8; - buf_[index] |= (uint8_t) (1 << offset); - bitpos_++; -} - -int Wiegand::getData(uint8_t* data) { - memcpy(data, buf_, WIEGAND_BUFSIZ); - return bitpos_; -} - -void Wiegand::reset() { - bitpos_ = 0; - memset(buf_, 0, WIEGAND_BUFSIZ); -} - diff --git a/arduino/kegboard/Wiegand.h b/arduino/kegboard/Wiegand.h deleted file mode 100644 index bc49595..0000000 --- a/arduino/kegboard/Wiegand.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2012 Mike Wakerly - * - * This file is part of the Kegboard package of the Kegbot project. - * For more information on Kegboard or Kegbot, see http://kegbot.org/ - * - * Kegboard is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegboard is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegboard. If not, see . - */ - -#define WIEGAND_BUFSIZ 5 - -class Wiegand { - public: - Wiegand(); - void handleData0Pulse(); - void handleData1Pulse(); - int getData(uint8_t* data); - void reset(); - private: - uint8_t data0_pin_; // Wiegand DATA0 pin - uint8_t data1_pin_; // Wiegand DATA1 pin - volatile int bitpos_; // Current bit position - uint8_t buf_[WIEGAND_BUFSIZ]; -}; diff --git a/arduino/kegboard/buzzer.cpp b/arduino/kegboard/buzzer.cpp deleted file mode 100644 index 21af061..0000000 --- a/arduino/kegboard/buzzer.cpp +++ /dev/null @@ -1,51 +0,0 @@ -/** - * buzzer.cpp - Arduino buzzer routines - * Copyright 2014 Mike Wakerly - * - * This file is part of the Kegbot package of the Kegbot project. - * For more information on Kegbot, see http://kegbot.org/ - * - * Kegbot is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegbot is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegbot. If not, see . - */ - -#include "buzzer.h" - -#include - -// Play a sequence of MelodyNotes -// Sequence must terminate with octave == -1 -void play_notes(uint32_t* notes, int pin) -{ - int i=0; - noTone(pin); - - while (true) { - uint32_t note = notes[i++]; - if (note == MELODY_END) { - break; - } - - uint32_t frequency = FREQUENCY(note); - uint32_t duration = DURATION(note); - - if (frequency == 0) { - noTone(pin); - } else { - tone(pin, frequency); - } - - delay(duration); - } - noTone(pin); -} diff --git a/arduino/kegboard/buzzer.h b/arduino/kegboard/buzzer.h deleted file mode 100644 index 4fbde33..0000000 --- a/arduino/kegboard/buzzer.h +++ /dev/null @@ -1,18 +0,0 @@ -#ifndef BUZZER_H -#define BUZZER_H - -#include "Arduino.h" - -// Note is: -// 16 bits for frequency -// 16 bits for duration -#define NOTE(frequency, duration) \ - (((frequency & 0xffffUL) << 16) | (duration & 0xffffUL)) -#define DURATION(note) (note & 0xffffUL) -#define FREQUENCY(note) ((note >> 16) & 0xffffUL) -#define SILENT(duration) NOTE(0, duration) -#define MELODY_END 0 - -void play_notes(uint32_t* notes, int pin); - -#endif // BUZZER_H diff --git a/arduino/kegboard/ds1820.cpp b/arduino/kegboard/ds1820.cpp deleted file mode 100644 index 55ec511..0000000 --- a/arduino/kegboard/ds1820.cpp +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Copyright 2003-2010 Mike Wakerly - * - * This file is part of the Kegbot package of the Kegbot project. - * For more information on Kegbot, see http://kegbot.org/ - * - * Kegbot is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegbot is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegbot. If not, see . - */ - -#include -#include - -#include "kegboard.h" -#include "ds1820.h" -#include "OneWire.h" - -#define REFRESH_MS 5000 - -DS1820Sensor::DS1820Sensor() { - m_bus = 0; - m_initialized = false; - Reset(); -} - -void DS1820Sensor::Reset() { - m_converting = false; - m_conversion_start_clock = 0; - m_temp = INVALID_TEMPERATURE_VALUE; - m_temp_is_valid = false; - - for (int i=0; i<8; i++) { - m_addr[i] = 0; - } -} - -void DS1820Sensor::Initialize(OneWire* bus, uint8_t* addr) -{ - m_bus = bus; - for (int i=0; i<8;i++) { - m_addr[i] = addr[i]; - } - m_initialized = true; -} - -bool DS1820Sensor::Initialized() { - return m_initialized; -} - -bool DS1820Sensor::Update(unsigned long clock) -{ - if (!m_initialized) { - return false; - } - - if (clock < m_conversion_start_clock) { // overflow of clock - m_conversion_start_clock = 0; - } - - if (!m_converting) { - // we're not converting, and it is time to start - m_converting = true; - m_conversion_start_clock = clock; - StartConversion(); - } else if (m_converting && (clock - m_conversion_start_clock) >= 1000) { - // we're converting and it is time to fetch - m_converting = false; - - if (FetchConversion()) { - m_temp_is_valid = true; - } else { - m_temp_is_valid = false; - } - return true; - } else { - // we're either in the middle of a conversion, or it is too soon to start - // the next cycle. - } - return false; - -} - -bool DS1820Sensor::ResetAndSelect() -{ - if (!m_bus->reset()) { - return false; - } - m_bus->select(m_addr); - return true; -} - -bool DS1820Sensor::StartConversion() -{ - if (!ResetAndSelect()) - return false; - m_bus->write(0x44, 1); - return true; -} - -bool DS1820Sensor::FetchConversion() -{ - if (!ResetAndSelect()) { - return false; - } - - uint8_t data[9]; - m_bus->write(0xBE); // read scratchpad - - bool null_data = true; - for (int i = 0; i < 9; i++) { - data[i] = m_bus->read(); - if (data[i] != 0) { - null_data = false; - } - } - - if (null_data) { - return false; - } - - if (OneWire::crc8(data, 8) != data[8]) { - // bad CRC, drop reading. - return false; - } - - m_temp = ((data[1] << 8) | data[0]); - return true; -} - -long DS1820Sensor::GetTemp(void) -{ - if (!m_temp_is_valid) - return INVALID_TEMPERATURE_VALUE; - - // The value returned by the DS18B20 is a 16-bit 2's complement sign-extended - // value. The value is the temperature, either increments of 1/16th deg C - // (DS18B20 default) or 1/2 deg C (DS18S20 default). - // - // This method returns the temperature in 1/10^6 deg C, so the value is scaled - // up by (10^6/16) = 62500 or (10^6/2) = 500000. - long res = (long)m_temp; - switch (m_addr[0]) { - case ONEWIRE_FAMILY_DS18B20: - return res * 62500L; - case ONEWIRE_FAMILY_DS18S20: - return res * 500000L; - default: - return INVALID_TEMPERATURE_VALUE; - } -} - -bool DS1820Sensor::Busy() { - return m_converting; -} - -int DS1820Sensor::CompareId(uint8_t* other) { - for (int i = 0; i < 8; i++) { - if (m_addr[i] == other[i]) { - continue; - } else { - return (m_addr[i] < other[i]) ? -1 : 1; - } - } - return 0; -} - -void DS1820Sensor::PrintTemp(void) -{ - long temp = GetTemp() / 1000000; - Serial.print(temp); -} diff --git a/arduino/kegboard/ds1820.h b/arduino/kegboard/ds1820.h deleted file mode 100644 index d8911ff..0000000 --- a/arduino/kegboard/ds1820.h +++ /dev/null @@ -1,37 +0,0 @@ -#include - -#define ONEWIRE_FAMILY_DS18B20 0x28 -#define ONEWIRE_FAMILY_DS18S20 0x10 -#define INVALID_TEMPERATURE_VALUE INT_MIN - -class OneWire; - -class DS1820Sensor { - public: - DS1820Sensor(); - bool Update(unsigned long clock); - void PrintTemp(void); - long GetTemp(); - bool Busy(); - bool Initialized(); - void Reset(); - int CompareId(uint8_t* other); - void Initialize(OneWire* bus, uint8_t* addr); - - uint8_t m_addr[8]; - - private: - bool ResetAndSelect(); - bool StartConversion(); - bool FetchConversion(); - void Reset(uint8_t *addr); - - private: - OneWire* m_bus; - - bool m_initialized; - bool m_converting; - unsigned long m_conversion_start_clock; - int m_temp; - bool m_temp_is_valid; -}; diff --git a/arduino/kegboard/kegboard.h b/arduino/kegboard/kegboard.h deleted file mode 100644 index 40e40e4..0000000 --- a/arduino/kegboard/kegboard.h +++ /dev/null @@ -1,94 +0,0 @@ -#include "HardwareSerial.h" - -#define LOG(s) Serial.println(s); - -#define KB_BOARDNAME_MAXLEN 8 - -#define KBM_HELLO_ID 0x01 -#define KBM_HELLO_TAG_FIRMWARE_VERSION 0x01 -#define KBM_HELLO_TAG_PROTOCOL_VERSION 0x02 -#define KBM_HELLO_TAG_SERIAL_NUMBER 0x03 -#define KBM_HELLO_TAG_UPTIME_MILLIS 0x04 -#define KBM_HELLO_TAG_UPTIME_DAYS 0x05 - -#define KBM_THERMO_READING 0x11 -#define KBM_THERMO_READING_TAG_SENSOR_NAME 0x01 -#define KBM_THERMO_READING_TAG_SENSOR_READING 0x02 - -#define KBM_METER_STATUS 0x10 -#define KBM_METER_STATUS_TAG_METER_NAME 0x01 -#define KBM_METER_STATUS_TAG_METER_READING 0x02 - -#define KBM_OUTPUT_STATUS 0x12 -#define KBM_OUTPUT_STATUS_TAG_OUTPUT_NAME 0x01 -#define KBM_OUTPUT_STATUS_TAG_OUTPUT_READING 0x02 - -#define KBM_ONEWIRE_PRESENCE 0x13 -#define KBM_ONEWIRE_PRESENCE_TAG_DEVICE_ID 0x01 -#define KBM_ONEWIRE_PRESENCE_TAG_STATUS 0x02 - -#define KBM_AUTH_TOKEN 0x14 -#define KBM_AUTH_TOKEN_TAG_DEVICE 0x01 -#define KBM_AUTH_TOKEN_TAG_TOKEN 0x02 -#define KBM_AUTH_TOKEN_TAG_STATUS 0x03 - -#define KBM_PING 0x81 - -#define KBM_SET_OUTPUT 0x84 -#define KBM_SET_OUTPUT_TAG_OUTPUT_ID 0x01 -#define KBM_SET_OUTPUT_TAG_OUTPUT_MODE 0x02 - -#define OUTPUT_DISABLED 0 -#define OUTPUT_ENABLED 1 - -#define KBM_SET_SERIAL_NUMBER 0x85 -#define KBM_SET_SERIAL_NUMBER_TAG_SERIAL 0x01 - -#define KBSP_PREFIX "KBSP v1:" -#define KBSP_PREFIX_CRC 0xe3af -#define KBSP_TRAILER "\r\n" - -#define KBSP_HEADER_LEN 12 -#define KBSP_HEADER_PREFIX_LEN 8 -#define KBSP_HEADER_ID_LEN 2 -#define KBSP_HEADER_PAYLOADLEN_LEN 2 - -#define KBSP_FOOTER_LEN 4 -#define KBSP_FOOTER_CRC_LEN 2 -#define KBSP_FOOTER_TRAILER_LEN 2 - -#define KBSP_PAYLOAD_MAXLEN 112 - -// Milliseconds/day -#define MS_PER_DAY 86400000UL -// Interval between test pulse trains -#define KB_SELFTEST_INTERVAL_MS 500 - -// Number of pulses per test pulse train -#define KB_SELFTEST_PULSES 10 - -// Minimum time, in MS, between meter update packets. Setting this to zero will -// cause the kegboard to send a meter update message for nearly every tick; this -// is not recommended. -#define KB_METER_UPDATE_INTERVAL_MS 100 - -// Heartbeat interval. A "hello" packet will be emitted to the host this often. -#define KB_HEARTBEAT_INTERVAL_MS (10 * 1000) - -// Number of relay outputs -#define KB_NUM_RELAY_OUTPUTS 6 - -// Maximum time a relay will remain enabled after a "set_output" command. The -// timer is reset whenenver a new "set_output" command is received. -#define KB_RELAY_WATCHDOG_MS 10000 - -// RFID defines -#define STX 0x02 -#define ETX 0x03 -#define RFID_DATA_CHARS 10 -#define RFID_CHECKSUM_CHARS 2 -#define RFID_PARALLAX_PAYLOAD_CHARS 4 -#define RFID_PARALLAX_LEGACY_PAYLOAD_CHARS 10 -#define RFID_PAYLOAD_CHARS 12 -#define CR '\r' -#define LF '\n' diff --git a/arduino/kegboard/kegboard.ino b/arduino/kegboard/kegboard.ino deleted file mode 100644 index 59aaecc..0000000 --- a/arduino/kegboard/kegboard.ino +++ /dev/null @@ -1,1039 +0,0 @@ -/** - * kegboard.pde - Kegboard v3 Arduino project - * Copyright 2003-2011 Mike Wakerly - * - * This file is part of the Kegbot package of the Kegbot project. - * For more information on Kegbot, see http://kegbot.org/ - * - * Kegbot is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegbot is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegbot. If not, see . - */ - -/** - * This firmware is intended for an Arduino Diecimila board (or similar) - * http://www.arduino.cc/en/Main/ArduinoBoardDiecimila - * - * This firmware implements the Kegboard Serial Protocol, version 1 (KBSP v1). - * For more information on what that means, see the kegbot docs: - * http://kegbot.org/docs/ - * - * You may change the pin configuration by editing kegboard_config.h; you should - * not need to change anything in this file. - * - * TODO: - * - get/set boardname with eeprom - * - Thermo: - * * check CRC - * * clean up code - * - leak detect circuit/alarm support - */ - -#include "Arduino.h" - -#include -#include -#include -#include -#include - -#include "kegboard.h" -#include "kegboard_config.h" -#include "kegboard_eeprom.h" -#include "ds1820.h" -#include "KegboardPacket.h" -#include "version.h" - -#if (KB_ENABLE_ONEWIRE_THERMO || KB_ENABLE_ONEWIRE_PRESENCE) -#include "OneWire.h" -#endif - -#if KB_ENABLE_BUZZER -#include "buzzer.h" -#include "tones.h" -#endif - -#if KB_ENABLE_ID12_RFID -#include -static SoftwareSerial gSerialRfid(KB_PIN_SERIAL_RFID_RX, -1); -int gRfidPos = -1; -unsigned char gRfidChecksum = 0; -unsigned char gRfidBuf[RFID_PAYLOAD_CHARS]; -#endif - -#if KB_ENABLE_PARALLAX_RFID -#include -static SoftwareSerial gSerialRfid(KB_PIN_SERIAL_RFID_RX, KB_PIN_SERIAL_RFID_TX); -int gRfidPos = -1; -unsigned char gRfidBuf[RFID_PARALLAX_LEGACY_PAYLOAD_CHARS]; -#endif - -#if KB_ENABLE_WIEGAND_RFID -#include "Wiegand.h" -#include "PCInterrupt.h" -#endif - -// -// Other Globals -// - -// Up to 6 meters supported if using Arduino Mega -static unsigned long volatile gMeters[] = {0, 0, 0, 0, 0, 0}; -static unsigned long volatile gLastMeters[] = {0, 0, 0, 0, 0, 0}; -static uint8_t gOutputPins[] = { - KB_PIN_RELAY_A, - KB_PIN_RELAY_B, - KB_PIN_RELAY_C, - KB_PIN_RELAY_D, - KB_PIN_LED_FLOW_A, - KB_PIN_LED_FLOW_B -}; - -static KegboardPacket gInputPacket; - -// Structure that holds the state of incoming serial bytes. -typedef struct { - uint8_t header_bytes_read; - uint8_t payload_bytes_remain; - bool have_packet; -} RxPacketStat; - -static RxPacketStat gPacketStat; - -// Relay output status. -typedef struct { - bool enabled; - unsigned long touched_timestamp_ms; -} RelayOutputStat; - -static RelayOutputStat gRelayStatus[KB_NUM_RELAY_OUTPUTS]; - -// Structure to keep information about this device's uptime. -typedef struct { - unsigned long uptime_ms; - unsigned long uptime_days; - unsigned long last_uptime_ms; - unsigned long last_meter_event; - unsigned long last_heartbeat; -} UptimeStat; - -static UptimeStat gUptimeStat; - -#if KB_ENABLE_CHIP_LED -static int gChipLedBrightness = 0xff; -#endif - -static uint8_t gSerialNumber[SERIAL_NUMBER_SIZE_BYTES]; - -#if KB_ENABLE_ONEWIRE_PRESENCE -// Structure used to cache information about devices on the onewire bus. -typedef struct { - uint64_t id; - bool valid; - uint8_t present_count; -} OnewireEntry; - -static OnewireEntry gOnewireCache[ONEWIRE_CACHE_SIZE]; -#endif - -#if KB_ENABLE_SELFTEST -static unsigned long gLastTestPulseMillis = 0; -#endif - -#if KB_ENABLE_BUZZER -uint32_t BOOT_MELODY[] = { - NOTE(FREQ_C6, 75), SILENT(25), - NOTE(FREQ_C6, 75), SILENT(25), - NOTE(FREQ_C6, 75), SILENT(25), - - NOTE(FREQ_F6, 200), - MELODY_END -}; - -uint32_t PING_MELODY[] = { - NOTE(FREQ_D6, 100), SILENT(25), - NOTE(FREQ_A6, 100), SILENT(25), - MELODY_END -}; - -#if (KB_ENABLE_ID12_RFID || KB_ENABLE_ONEWIRE_PRESENCE || KB_ENABLE_PARALLAX_RFID) -uint32_t AUTH_ON_MELODY[] = { - NOTE(FREQ_A6, 50), SILENT(10), - NOTE(FREQ_D6, 50), SILENT(10), - NOTE(FREQ_F6, 50), SILENT(10), - MELODY_END -}; -#endif // KB_ENABLE_ID12_RFID || KB_ENABLE_ONEWIRE_PRESENCE || KB_ENABLE_PARALLAX_RFID -#endif // KB_ENABLE_BUZZER - -#if KB_ENABLE_ONEWIRE_THERMO -static OneWire gOnewireThermoBus(KB_PIN_ONEWIRE_THERMO); -static DS1820Sensor gThermoSensor; -#endif - -#if KB_ENABLE_ONEWIRE_PRESENCE -static OneWire gOnewireIdBus(KB_PIN_ONEWIRE_PRESENCE); -#endif - -#if KB_ENABLE_WIEGAND_RFID -#define WIEGAND_TIMEOUT_MILLIS 200 -static Wiegand gWiegand; -static unsigned long gLastWiegandInterruptMillis = 0; -#endif - -// -// ISRs -// - -#if KB_ENABLE_SOFT_DEBOUNCE -#define CHECK_METER(pin, meter_index) \ - do { \ - delayMicroseconds(KB_SOFT_DEBOUNCE_MICROS); \ - if (digitalRead(pin) == 0) \ - gMeters[meter_index] += 1; \ - } while(0) -#else -#define CHECK_METER(pin, meter_index) gMeters[meter_index] += 1 -#endif - -void meterInterruptA() -{ - CHECK_METER(KB_PIN_METER_A, 0); -} - -#ifdef KB_PIN_METER_B -void meterInterruptB() -{ - CHECK_METER(KB_PIN_METER_B, 1); -} -#endif - -#ifdef KB_PIN_METER_C -void meterInterruptC() -{ - CHECK_METER(KB_PIN_METER_C, 2); -} -#endif - -#ifdef KB_PIN_METER_D -void meterInterruptD() -{ - CHECK_METER(KB_PIN_METER_D, 3); -} -#endif - -#ifdef KB_PIN_METER_E -void meterInterruptE() -{ - CHECK_METER(KB_PIN_METER_E, 4); -} -#endif - -#ifdef KB_PIN_METER_F -void meterInterruptF() -{ - CHECK_METER(KB_PIN_METER_F, 5); -} -#endif - -#if KB_ENABLE_WIEGAND_RFID -void wiegandData0() { - gWiegand.handleData0Pulse(); - gLastWiegandInterruptMillis = millis(); -} -void wiegandData1() { - gWiegand.handleData1Pulse(); - gLastWiegandInterruptMillis = millis(); -} -#endif - -// -// Serial I/O -// - -void writeHelloPacket() -{ - int firmware_version = FIRMWARE_VERSION; - KegboardPacket packet; - packet.SetType(KBM_HELLO_ID); - packet.AddTag(KBM_HELLO_TAG_FIRMWARE_VERSION, sizeof(firmware_version), (char*)&firmware_version); - packet.AddTag(KBM_HELLO_TAG_SERIAL_NUMBER, SERIAL_NUMBER_SIZE_BYTES, (char*)gSerialNumber); - packet.AddTag(KBM_HELLO_TAG_UPTIME_MILLIS, sizeof(gUptimeStat.uptime_ms), (char*)&gUptimeStat.uptime_ms); - packet.AddTag(KBM_HELLO_TAG_UPTIME_DAYS, sizeof(gUptimeStat.uptime_days), (char*)&gUptimeStat.uptime_days); - packet.Print(); -} - -#if KB_ENABLE_ONEWIRE_THERMO -void byteToChars(uint8_t byte, char* out) { - for (int i=0; i<2; i++) { - uint8_t val = (byte >> (4*i)) & 0xf; - if (val < 10) { - out[1-i] = (char) ('0' + val); - } else if (val < 16) { - out[1-i] = (char) ('a' + (val - 10)); - } - } -} - -void writeThermoPacket(DS1820Sensor *sensor) -{ - long temp = sensor->GetTemp(); - if (temp == INVALID_TEMPERATURE_VALUE) { - return; - } - - char name[23] = "thermo-"; - char* pos = (name + 7); - for (int i=7; i>=0; i--) { - byteToChars(sensor->m_addr[i], pos); - pos += 2; - } - KegboardPacket packet; - packet.SetType(KBM_THERMO_READING); - packet.AddTag(KBM_THERMO_READING_TAG_SENSOR_NAME, 23, name); - packet.AddTag(KBM_THERMO_READING_TAG_SENSOR_READING, sizeof(temp), (char*)(&temp)); - packet.Print(); -} -#endif - -void writeRelayPacket(int channel) -{ - char name[6] = "relay"; - int status = (int) (gRelayStatus[channel].enabled); - name[5] = 0x30 + channel; - KegboardPacket packet; - packet.SetType(KBM_OUTPUT_STATUS); - packet.AddTag(KBM_OUTPUT_STATUS_TAG_OUTPUT_NAME, 6, name); - packet.AddTag(KBM_OUTPUT_STATUS_TAG_OUTPUT_READING, sizeof(status), (char*)(&status)); - packet.Print(); -} - -void writeMeterPacket(int channel) -{ - char name[5] = "flow"; - unsigned long status = gMeters[channel]; - if (status == gLastMeters[channel]) { - return; - } else { - gLastMeters[channel] = status; - } - - switch (channel) { - case 0: -#if BOARD_KBPM - RXLED1; -#else - digitalWrite(KB_PIN_LED_FLOW_A, HIGH); -#endif - break; - case 1: -#if BOARD_KBPM - TXLED1; -#else - digitalWrite(KB_PIN_LED_FLOW_B, HIGH); -#endif - break; - default: - break; - } - - name[4] = 0x30 + channel; - KegboardPacket packet; - packet.SetType(KBM_METER_STATUS); - packet.AddTag(KBM_METER_STATUS_TAG_METER_NAME, 5, name); - packet.AddTag(KBM_METER_STATUS_TAG_METER_READING, sizeof(status), (char*)(&status)); - packet.Print(); -} - -void writeAuthPacket(const char* device_name, uint8_t* token, int token_len, - char status) { - KegboardPacket packet; - packet.SetType(KBM_AUTH_TOKEN); - packet.AddTag(KBM_AUTH_TOKEN_TAG_DEVICE, strlen(device_name), device_name); - packet.AddTag(KBM_AUTH_TOKEN_TAG_TOKEN, token_len, (char*)token); - packet.AddTag(KBM_AUTH_TOKEN_TAG_STATUS, 1, &status); - packet.Print(); -#if KB_ENABLE_BUZZER - if (status == 1) { - play_notes(AUTH_ON_MELODY, KB_PIN_BUZZER); - } -#endif -} - -#if KB_ENABLE_SELFTEST -void doTestPulse() -{ - // Strobes the test pin `KB_SELFTEST_PULSES` times, every - // `KB_SELFTEST_INTERVAL_MS` milliseconds - unsigned long now = millis(); - if ((now - gLastTestPulseMillis) >= KB_SELFTEST_INTERVAL_MS) { - gLastTestPulseMillis = now; - for (int i=0; i= 0) { - analogWrite(KB_PIN_LED_CHIP, gChipLedBrightness); - } else { - analogWrite(KB_PIN_LED_CHIP, 0); - } - gChipLedBrightness -= rate; - if (gChipLedBrightness < -32) { - gChipLedBrightness = 0xff; - } -} -#endif - -// -// Main -// - -void setup() -{ - memset(&gUptimeStat, 0, sizeof(UptimeStat)); - memset(&gPacketStat, 0, sizeof(RxPacketStat)); - memset(gSerialNumber, 0, SERIAL_NUMBER_SIZE_BYTES); - - if (eeprom_is_valid()) { - eeprom_read_serialno(gSerialNumber); - } - -#if KB_ENABLE_CHIP_LED - pinMode(KB_PIN_LED_CHIP, OUTPUT); - digitalWrite(KB_PIN_LED_CHIP, HIGH); -#endif - - // Flow meter steup. Enable internal weak pullup to prevent disconnected line - // from ticking away. - pinMode(KB_PIN_METER_A, INPUT); - digitalWrite(KB_PIN_METER_A, HIGH); - attachInterrupt(0, meterInterruptA, FALLING); - -#ifdef KB_PIN_METER_B - pinMode(KB_PIN_METER_B, INPUT); - digitalWrite(KB_PIN_METER_B, HIGH); - attachInterrupt(1, meterInterruptB, FALLING); -#endif - -#ifdef KB_PIN_METER_C - pinMode(KB_PIN_METER_C, INPUT); - digitalWrite(KB_PIN_METER_C, HIGH); - attachInterrupt(2, meterInterruptC, FALLING); -#endif - -#ifdef KB_PIN_METER_D - pinMode(KB_PIN_METER_D, INPUT); - digitalWrite(KB_PIN_METER_D, HIGH); - attachInterrupt(3, meterInterruptD, FALLING); -#endif - -#ifdef KB_PIN_METER_E - pinMode(KB_PIN_METER_E, INPUT); - digitalWrite(KB_PIN_METER_E, HIGH); - attachInterrupt(4, meterInterruptE, FALLING); -#endif - -#ifdef KB_PIN_METER_F - pinMode(KB_PIN_METER_F, INPUT); - digitalWrite(KB_PIN_METER_F, HIGH); - attachInterrupt(5, meterInterruptF, FALLING); -#endif - - pinMode(KB_PIN_RELAY_A, OUTPUT); - pinMode(KB_PIN_RELAY_B, OUTPUT); - pinMode(KB_PIN_RELAY_C, OUTPUT); - pinMode(KB_PIN_RELAY_D, OUTPUT); - pinMode(KB_PIN_LED_FLOW_A, OUTPUT); - pinMode(KB_PIN_LED_FLOW_B, OUTPUT); - pinMode(KB_PIN_ALARM, OUTPUT); - pinMode(KB_PIN_TEST_PULSE, OUTPUT); - - Serial.begin(115200); - - digitalWrite(KB_PIN_LED_FLOW_A, HIGH); - digitalWrite(KB_PIN_LED_FLOW_B, HIGH); - -#if KB_ENABLE_BUZZER - pinMode(KB_PIN_BUZZER, OUTPUT); - play_notes(BOOT_MELODY, KB_PIN_BUZZER); -#endif - - digitalWrite(KB_PIN_LED_FLOW_A, LOW); - digitalWrite(KB_PIN_LED_FLOW_B, LOW); - -#if KB_ENABLE_ID12_RFID - gSerialRfid.begin(9600); - pinMode(KB_PIN_RFID_RESET, OUTPUT); - digitalWrite(KB_PIN_RFID_RESET, HIGH); -#endif - -#if KB_ENABLE_PARALLAX_RFID - gSerialRfid.begin(9600); - pinMode(KB_PIN_SERIAL_RFID_RX, INPUT); - pinMode(KB_PIN_SERIAL_RFID_TX, OUTPUT); -#endif - -#if KB_ENABLE_WIEGAND_RFID - PCattachInterrupt(KB_PIN_WIEGAND_RFID_DATA0, wiegandData0, FALLING); - PCattachInterrupt(KB_PIN_WIEGAND_RFID_DATA1, wiegandData1, FALLING); - - pinMode(KB_PIN_WIEGAND_RFID_DATA0, OUTPUT); - pinMode(KB_PIN_WIEGAND_RFID_DATA1, OUTPUT); - digitalWrite(KB_PIN_WIEGAND_RFID_DATA0, HIGH); - digitalWrite(KB_PIN_WIEGAND_RFID_DATA1, HIGH); - digitalWrite(KB_PIN_WIEGAND_RFID_DATA0, LOW); - digitalWrite(KB_PIN_WIEGAND_RFID_DATA1, LOW); - pinMode(KB_PIN_WIEGAND_RFID_DATA0, INPUT); - pinMode(KB_PIN_WIEGAND_RFID_DATA1, INPUT); - digitalWrite(KB_PIN_WIEGAND_RFID_DATA0, HIGH); - digitalWrite(KB_PIN_WIEGAND_RFID_DATA1, HIGH); - gWiegand.reset(); - gLastWiegandInterruptMillis = 0; -#endif - - writeHelloPacket(); -} - -void updateTimekeeping() { - // TODO(mikey): it would be more efficient to take control of timer0 - unsigned long now = millis(); - gUptimeStat.uptime_ms += now - gUptimeStat.last_uptime_ms; - gUptimeStat.last_uptime_ms = now; - - if (gUptimeStat.uptime_ms >= MS_PER_DAY) { - gUptimeStat.uptime_days += 1; - gUptimeStat.uptime_ms -= MS_PER_DAY; - } - - if ((now - gUptimeStat.last_heartbeat) > KB_HEARTBEAT_INTERVAL_MS) { - gUptimeStat.last_heartbeat = now; - writeHelloPacket(); - } -} - -#if KB_ENABLE_ONEWIRE_THERMO -int stepOnewireThermoBus() { - uint8_t addr[8]; - unsigned long now = millis(); - - // Are we already working on a sensor? service it, possibly emitting a a - // thermo packet. - if (gThermoSensor.Initialized() || gThermoSensor.Busy()) { - if (gThermoSensor.Update(now)) { - // Just finished conversion - writeThermoPacket(&gThermoSensor); - gThermoSensor.Reset(); - } else if (gThermoSensor.Busy()) { - // More cycles needed on this sensor - return 1; - } else { - // finished or not started - } - } - - // First time, or finished with last sensor; clean up, and look more more - // devices. - int more_search = gOnewireThermoBus.search(addr); - if (!more_search) { - // Bus exhausted; start over - gOnewireThermoBus.reset_search(); - return 0; - } - - // New sensor. Initialize and start work. - gThermoSensor.Initialize(&gOnewireThermoBus, addr); - gThermoSensor.Update(now); - return 1; -} -#endif - -#if KB_ENABLE_ONEWIRE_PRESENCE -void stepOnewireIdBus() { - uint64_t addr; - uint8_t* addr_ptr = (uint8_t*) &addr; - - // No more devices on the bus; reset the bus. - if (!gOnewireIdBus.search(addr_ptr)) { - gOnewireIdBus.reset_search(); - - for (int i=0; i < ONEWIRE_CACHE_SIZE; i++) { - OnewireEntry* entry = &gOnewireCache[i]; - if (!entry->valid) { - continue; - } - - entry->present_count -= 1; - if (entry->present_count == 0) { - entry->valid = false; - writeAuthPacket("onewire", (uint8_t*)&(entry->id), 8, 0); - } - } - return; - } - - // We found a device; check the address CRC and ignore if invalid. - if (OneWire::crc8(addr_ptr, 7) != addr_ptr[7]) { - return; - } - - // Ignore the null address. TODO(mikey): Is there a bug in OneWire.cpp that - // causes this to be reported? - if (addr == 0) { - return; - } - - // Look for id in cache. If seen last time around, mark present (and do not - // emit packet). - for (int i=0; i < ONEWIRE_CACHE_SIZE; i++) { - OnewireEntry* entry = &gOnewireCache[i]; - if (entry->valid && entry->id == addr) { - entry->present_count = ONEWIRE_CACHE_MAX_MISSING_SEARCHES; - return; - } - } - - // Add id to cache and emit presence packet. - // NOTE(mikey): If the cache is full, no packet will be emitted. This is - // probably the best behavior; removing a device from the bus will clear up an - // entry in the cache. - for (int i=0; i < ONEWIRE_CACHE_SIZE; i++) { - OnewireEntry* entry = &gOnewireCache[i]; - if (!entry->valid) { - entry->valid = true; - entry->present_count = ONEWIRE_CACHE_MAX_MISSING_SEARCHES; - entry->id = addr; - writeAuthPacket("onewire", (uint8_t*)&(entry->id), 8, 1); - return; - } - } -} -#endif - -static void readSerialBytes(char *dest_buf, int num_bytes, int offset) { - while (num_bytes-- != 0) { - dest_buf[offset++] = Serial.read(); - } -} - -#if KB_ENABLE_ID12_RFID -static void doProcessRfid() { - if (gSerialRfid.available() == 0) { - return; - } - - if (gRfidPos == -1) { - if (gSerialRfid.read() != 0x02) { - return; - } else { - gRfidPos = 0; - gRfidChecksum = 0; - } - } - - while (gRfidPos < 12) { - unsigned char b; - int rfid_index = (RFID_PAYLOAD_CHARS/2 - 1) - gRfidPos / 2; - if (gSerialRfid.available() == 0) { - return; - } - - b = gSerialRfid.read(); - if (b == CR || b == LF || b == STX || b == ETX) { - goto out_reset; - } - - // ASCII to hex - if (b >= '0' && b <= '9') { - b -= '0'; - } else if (b >= 'A' && b <= 'F') { - b -= 'A'; - b += 10; - } - - if ((gRfidPos % 2) == 0) { - // Clears previous value. - gRfidBuf[rfid_index] = b << 4; - } else { - gRfidBuf[rfid_index] |= b; - gRfidChecksum ^= gRfidBuf[rfid_index]; - } - - gRfidPos++; - } - - if (gRfidPos == RFID_PAYLOAD_CHARS) { - if (gRfidChecksum == 0) { - writeAuthPacket("core.rfid", gRfidBuf+1, 5, 1); - writeAuthPacket("core.rfid", gRfidBuf+1, 5, 0); - } - } - - digitalWrite(KB_PIN_RFID_RESET, LOW); - delay(200); - digitalWrite(KB_PIN_RFID_RESET, HIGH); - -out_reset: - gRfidPos = -1; - gRfidChecksum = 0; -} -#endif - -#if KB_ENABLE_PARALLAX_RFID && !KB_ENABLE_PARALLAX_RFID_LEGACY_TAGS -static void doProcessParallaxRfid() { - if (gSerialRfid.available() > 0) { - int errorCode; - errorCode = gSerialRfid.read(); - if (errorCode != 0x01) { - return; - } else { - int pos = 0; - while (pos < RFID_PARALLAX_PAYLOAD_CHARS) { - if (gSerialRfid.available() > 0) { - gRfidBuf[pos] = gSerialRfid.read(); - pos++; - } - } - if (pos == RFID_PARALLAX_PAYLOAD_CHARS) { - writeAuthPacket("core.rfid", gRfidBuf, 4, 1 ); - } - } - } -} - -#elif KB_ENABLE_PARALLAX_RFID && KB_ENABLE_PARALLAX_RFID_LEGACY_TAGS -static void doProcessParallaxRfid() { - if (gSerialRfid.available() > 0) { - int errorCode; - errorCode = gSerialRfid.read(); - if (errorCode != 10) { - return; - } else { - int pos = 0; - while (pos < RFID_PARALLAX_LEGACY_PAYLOAD_CHARS) { - if (gSerialRfid.available() > 0) { - gRfidBuf[pos] = gSerialRfid.read(); - if ((gRfidBuf[pos] == 10) || (gRfidBuf[pos] == 13)) { - break; - } - pos++; - } - } - if (pos == RFID_PARALLAX_LEGACY_PAYLOAD_CHARS) { - writeAuthPacket("core.rfid", gRfidBuf, 10, 1 ); - } - } - - } -} -#endif - -#if KB_ENABLE_PARALLAX_RFID -long previousTimer = 0; -static void doProcessRfid() { - long timer = millis(); - if ((timer - previousTimer) > 400) { -#if KB_ENABLE_PARALLAX_RFID_LEGACY_TAGS - gSerialRfid.print("!RW"); - gSerialRfid.write(0x0F); -#else - gSerialRfid.print("!RW"); - gSerialRfid.write(0x01); - gSerialRfid.write(byte(32)); -#endif - previousTimer = timer; - } else { - doProcessParallaxRfid(); - } -} -#endif - -void resetInputPacket() { - memset(&gPacketStat, 0, sizeof(RxPacketStat)); - gInputPacket.Reset(); -} - -void readIncomingSerialData() { - char serial_buf[KBSP_PAYLOAD_MAXLEN]; - volatile uint8_t bytes_available = Serial.available(); - - // Do not read a new packet if we have one awiting processing. This should - // never happen. - if (gPacketStat.have_packet) { - return; - } - - // Look for a new packet. - if (gPacketStat.header_bytes_read < KBSP_HEADER_PREFIX_LEN) { - while (bytes_available > 0) { - char next_char = Serial.read(); - bytes_available -= 1; - - if (next_char == KBSP_PREFIX[gPacketStat.header_bytes_read]) { - gPacketStat.header_bytes_read++; - if (gPacketStat.header_bytes_read == KBSP_HEADER_PREFIX_LEN) { - // Found start of packet, break. - break; - } - } else { - // Wrong character in prefix; reset framing. - if (next_char == KBSP_PREFIX[0]) { - gPacketStat.header_bytes_read = 1; - } else { - gPacketStat.header_bytes_read = 0; - } - } - } - } - - // Read the remainder of the header, if not yet found. - if (gPacketStat.header_bytes_read < KBSP_HEADER_LEN) { - if (bytes_available < 4) { - return; - } - gInputPacket.SetType(Serial.read() | (Serial.read() << 8)); - gPacketStat.payload_bytes_remain = Serial.read() | (Serial.read() << 8); - bytes_available -= 4; - gPacketStat.header_bytes_read += 4; - - // Check that the 'len' field is not bogus. If it is, throw out the packet - // and reset. - if (gPacketStat.payload_bytes_remain > KBSP_PAYLOAD_MAXLEN) { - goto out_reset; - } - } - - // If we haven't yet found a frame, or there are no more bytes to read after - // finding a frame, bail out. - if (bytes_available == 0 || (gPacketStat.header_bytes_read < KBSP_HEADER_LEN)) { - return; - } - - // TODO(mikey): Just read directly into KegboardPacket. - if (gPacketStat.payload_bytes_remain) { - int bytes_to_read = (gPacketStat.payload_bytes_remain >= bytes_available) ? - bytes_available : gPacketStat.payload_bytes_remain; - readSerialBytes(serial_buf, bytes_to_read, 0); - gInputPacket.AppendBytes(serial_buf, bytes_to_read); - gPacketStat.payload_bytes_remain -= bytes_to_read; - bytes_available -= bytes_to_read; - } - - // Need more payload bytes than are now available. - if (gPacketStat.payload_bytes_remain > 0) { - return; - } - - // We have a complete payload. Now grab the footer. - if (!gPacketStat.have_packet) { - if (bytes_available < KBSP_FOOTER_LEN) { - return; - } - readSerialBytes(serial_buf, KBSP_FOOTER_LEN, 0); - - // Check CRC - - // Check trailer - if (strncmp((serial_buf + 2), KBSP_TRAILER, KBSP_FOOTER_TRAILER_LEN)) { - goto out_reset; - } - gPacketStat.have_packet = true; - } - - // Done! - return; - -out_reset: - resetInputPacket(); -} - -#if KB_ENABLE_WIEGAND_RFID -void doProcessWiegand() { - if (gLastWiegandInterruptMillis == 0) { - return; - } - unsigned long now = millis(); - - if ((now - gLastWiegandInterruptMillis) > WIEGAND_TIMEOUT_MILLIS) { - uint8_t buf[WIEGAND_BUFSIZ]; - int num_bits = gWiegand.getData(buf); - if (num_bits > 0) { - writeAuthPacket("core.rfid", buf, WIEGAND_BUFSIZ, 1); - writeAuthPacket("core.rfid", buf, WIEGAND_BUFSIZ, 0); - } - gWiegand.reset(); - gLastWiegandInterruptMillis = 0; - } -} -#endif - -void setRelayOutput(uint8_t id, uint8_t mode) { - gRelayStatus[id].touched_timestamp_ms = millis(); - if (mode == OUTPUT_DISABLED && gRelayStatus[id].enabled) { - digitalWrite(gOutputPins[id], LOW); - gRelayStatus[id].enabled = false; - } else if (mode == OUTPUT_ENABLED && !gRelayStatus[id].enabled) { - digitalWrite(gOutputPins[id], HIGH); - gRelayStatus[id].enabled = true; - } else { - return; - } - writeRelayPacket(id); -} - -void handleInputPacket() { - if (!gPacketStat.have_packet) { - return; - } - - // Process the input packet. - switch (gInputPacket.GetType()) { - case KBM_PING: -#if KB_ENABLE_BUZZER - play_notes(PING_MELODY, KB_PIN_BUZZER); -#endif - writeHelloPacket(); - break; - - case KBM_SET_OUTPUT: { - uint8_t id, mode; - - if (!gInputPacket.ReadTag(KBM_SET_OUTPUT_TAG_OUTPUT_ID, &id) - || !gInputPacket.ReadTag(KBM_SET_OUTPUT_TAG_OUTPUT_MODE, &mode)) { - break; - } - - if (id < KB_NUM_RELAY_OUTPUTS) { - setRelayOutput(id, mode); - } - break; - } - - case KBM_SET_SERIAL_NUMBER: { - // Serial number can only be set if not already set. - if (eeprom_is_valid()) { - break; - } - - if (gInputPacket.FindTagLength(KBM_SET_SERIAL_NUMBER_TAG_SERIAL) >= SERIAL_NUMBER_SIZE_BYTES) { - break; - } - - memset(gSerialNumber, 0, SERIAL_NUMBER_SIZE_BYTES); - gInputPacket.CopyTagData(KBM_SET_SERIAL_NUMBER_TAG_SERIAL, gSerialNumber); - eeprom_write_serialno(gSerialNumber); - writeHelloPacket(); - - break; - } - } - resetInputPacket(); -} - -void writeMeterPackets() { - unsigned long now = millis(); - - // Forcibly coalesce meter updates; we want to be responsive, but sending - // meter updates at every opportunity would cause too many messages to be - // sent. - if ((now - gUptimeStat.last_meter_event) > KB_METER_UPDATE_INTERVAL_MS) { - gUptimeStat.last_meter_event = now; - } else { - return; - } - - writeMeterPacket(0); -#ifdef KB_PIN_METER_B - writeMeterPacket(1); -#endif -#ifdef KB_PIN_METER_C - writeMeterPacket(2); -#endif -#ifdef KB_PIN_METER_D - writeMeterPacket(3); -#endif -#ifdef KB_PIN_METER_E - writeMeterPacket(4); -#endif -#ifdef KB_PIN_METER_F - writeMeterPacket(5); -#endif -} - -void stepRelayWatchdog() { - for (int i = 0; i < KB_NUM_RELAY_OUTPUTS; i++) { - if (gRelayStatus[i].enabled == true) { - unsigned long now = millis(); - if ((now - gRelayStatus[i].touched_timestamp_ms) > KB_RELAY_WATCHDOG_MS) { - setRelayOutput(i, OUTPUT_DISABLED); - } - } - } -} - -void loop() -{ - updateTimekeeping(); - -#if KB_ENABLE_CHIP_LED - pulseChipLed(); -#endif - - readIncomingSerialData(); - handleInputPacket(); - - writeMeterPackets(); - stepRelayWatchdog(); - -#if KB_ENABLE_ONEWIRE_THERMO - stepOnewireThermoBus(); -#endif - -#if KB_ENABLE_ONEWIRE_PRESENCE - stepOnewireIdBus(); -#endif - -#if KB_ENABLE_ID12_RFID - doProcessRfid(); -#endif - -#if KB_ENABLE_PARALLAX_RFID - doProcessRfid(); -#endif - -#if KB_ENABLE_WIEGAND_RFID - doProcessWiegand(); -#endif - -#if KB_ENABLE_SELFTEST - doTestPulse(); -#endif - - digitalWrite(KB_PIN_LED_FLOW_A, LOW); - digitalWrite(KB_PIN_LED_FLOW_B, LOW); - -#if BOARD_KBPM - if (!Serial) { - RXLED0; - TXLED0; - } -#endif -} - -// vim: syntax=c diff --git a/arduino/kegboard/kegboard_config.h b/arduino/kegboard/kegboard_config.h deleted file mode 100644 index 185f188..0000000 --- a/arduino/kegboard/kegboard_config.h +++ /dev/null @@ -1,150 +0,0 @@ -// -// Feature configuration -// - -// You may enable/disable kegboard features here as desired. The deafult are -// safe. - -// Check for & report 1-wire temperature sensors? -#define KB_ENABLE_ONEWIRE_THERMO 1 - -// Check for & report 1-wire devices on the ID bus? -#define KB_ENABLE_ONEWIRE_PRESENCE 1 - -// Enable a selftest pulse? -#define KB_ENABLE_SELFTEST 1 - -// Enable buzzer? -#define KB_ENABLE_BUZZER 1 - -// Enable PARALLAX RFID? -#define KB_ENABLE_PARALLAX_RFID 0 -#define KB_ENABLE_PARALLAX_RFID_LEGACY_TAGS 0 - -// Enable ID-12 RFID? -#define KB_ENABLE_ID12_RFID 1 - -// Enable Wiegand RFID reader? -// Note: Must set KB_ENABLE_ID12_RFID to 0 if enabling this. -#define KB_ENABLE_WIEGAND_RFID 0 - -// Enable software debounce? EXPERIMENTAL. Enabling this feature may negatively -// affect pour accuracy. In particular, a delay is added to each flow meter -// ISR, disabling all other interrupts during this time. -#define KB_ENABLE_SOFT_DEBOUNCE 0 - -// Approximate minimum pulse width required for incoming external interrupts. -#define KB_SOFT_DEBOUNCE_MICROS 1200 - -// Enable chip LED? -#define KB_ENABLE_CHIP_LED 0 - -#if BOARD_KBPM -#undef KB_ENABLE_BUZZER -#undef KB_ENABLE_ID12_RFID -#undef KB_ENABLE_CHIP_LED -#undef KB_ENABLE_SELFTEST -#define KB_ENABLE_BUZZER 0 -#define KB_ENABLE_ID12_RFID 0 -#define KB_ENABLE_CHIP_LED 1 -#define KB_ENABLE_SELFTEST 0 -#endif - -// -// Pin configuration - KEGBOARD VERSION -// - -// You may change values in this section if you know what you are doing -- -// though you ordinarily shouldn't need to change these. -// -// Digital pin allocation: -// 2 - flowmeter 0 pulse (input) -// 3 - flowmeter 1 pulse (input) -// 4 - flow 0 LED (output) -// 5 - flow 1 LED (output) -// 6 - rfid (input from ID-12) -// 7 - thermo onewire bus (1-wire, input/output) -// 8 - presence onewire bus (1-wire, input/output) -// 9 - gpio pin C -// 10 - rfid reset -// 11 - buzzer (output) -// 12 - test pulse train (output) -// 13 - alarm (output) -// Analog pin allocation: -// A0 - relay 0 control (output) -// A1 - relay 1 control (output) -// A2 - relay 2 control (output) -// A3 - relay 3 control (output) -// A4 - gpio pin A -// A5 - gpio pin B -// - -#define KB_PIN_METER_A 2 -//Parallax RFID needs two IO pins, using flow meter B's by default -#if KB_ENABLE_PARALLAX_RFID -#define KB_PIN_SERIAL_RFID_TX 3 -#else -#define KB_PIN_METER_B 3 -#endif -#define KB_PIN_LED_FLOW_A 4 -#define KB_PIN_LED_FLOW_B 5 -#define KB_PIN_SERIAL_RFID_RX 6 -#define KB_PIN_ONEWIRE_THERMO 7 -#define KB_PIN_ONEWIRE_PRESENCE 8 -#define KB_PIN_LED_CHIP 9 -#define KB_PIN_RFID_RESET 10 -#define KB_PIN_BUZZER 11 -#define KB_PIN_TEST_PULSE 12 -#define KB_PIN_ALARM 13 -#define KB_PIN_RELAY_A A0 -#define KB_PIN_RELAY_B A1 -#define KB_PIN_RELAY_C A2 -#define KB_PIN_RELAY_D A3 -#define KB_PIN_GPIO_A A4 -#define KB_PIN_GPIO_B A5 - - -#define KB_PIN_MAGSTRIPE_CLOCK 3 -#define KB_PIN_MAGSTRIPE_DATA A4 -#define KB_PIN_MAGSTRIPE_CARD_PRESENT A5 - -#define KB_PIN_WIEGAND_RFID_DATA0 A4 -#define KB_PIN_WIEGAND_RFID_DATA1 A5 - -// Atmega1280 (aka Arduino mega) section -#if defined(__AVR_ATmega1280__) || defined(__AVR_ATmega2560__) -#define KB_NUM_METERS 6 -#define KB_PIN_METER_C 21 -#define KB_PIN_METER_D 20 -#define KB_PIN_METER_E 19 -#define KB_PIN_METER_F 18 -#else -#define KB_NUM_METERS 2 -#endif // defined(__AVR_ATmega1280__) || defined(__AVR_ATmega2560__) - -// -// Device configuration defaults -// - -#define KB_DEFAULT_BOARDNAME "kegboard" -#define KB_DEFAULT_BOARDNAME_LEN 8 // must match #chars above -#define KB_DEFAULT_BAUD_RATE 115200 - -// Size in entries of the onewire presence bus cache. This many IDs can be -// concurrently tracked on the bus. -#define ONEWIRE_CACHE_SIZE 8 - -// Number of full onewire bus searches to complete before considering a -// non-responding onewire id missing. This is used to dampen against glitches -// where a device might be absent from a search. -#define ONEWIRE_CACHE_MAX_MISSING_SEARCHES 4 - -// -// Error checking -// - -#if (KB_ENABLE_ID12_RFID + KB_ENABLE_WIEGAND_RFID + KB_ENABLE_PARALLAX_RFID) > 1 -// TODO(mikey): work around pin change interrupt sharing issues. -#error "ID12 RFID and WIEGAND RFID cannot be used together." -#error "Please disable one of them in kegboard_config.h" -#endif diff --git a/arduino/kegboard/kegboard_eeprom.cpp b/arduino/kegboard/kegboard_eeprom.cpp deleted file mode 100644 index 39f1762..0000000 --- a/arduino/kegboard/kegboard_eeprom.cpp +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright 2014 Mike Wakerly - * - * This file is part of the Kegboard package of the Kegbot project. - * For more information on Kegbot, see http://kegbot.org/ - * - * Kegbot is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegbot is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegbot. If not, see . - */ - -#include -#include - -#include "kegboard_eeprom.h" - -int eeprom_is_valid() { - return EEPROM.read(0) == (EEP_MAGIC >> 8) && - EEPROM.read(1) == (EEP_MAGIC & 0xff); -} - -int eeprom_read_serialno(uint8_t *buf) { - if (!eeprom_is_valid()) { - return 0; - } - int i = 0; - for (int off=2; i < SERIAL_NUMBER_SIZE_BYTES - 1; i++, off++) { - buf[i] = EEPROM.read(off); - if (buf[i] == '\0') { - break; - } - } - buf[i] = '\0'; - return i - 1; -} - -void eeprom_write_serialno(uint8_t *serialno) { - EEPROM.write(0, '\0'); - EEPROM.write(1, '\0'); - for (int i=0, off=2; i < SERIAL_NUMBER_SIZE_BYTES; i++, off++) { - EEPROM.write(off, serialno[i]); - } - EEPROM.write(0, EEP_MAGIC >> 8); - EEPROM.write(1, EEP_MAGIC & 0xff); -} diff --git a/arduino/kegboard/kegboard_eeprom.h b/arduino/kegboard/kegboard_eeprom.h deleted file mode 100644 index 5642e0c..0000000 --- a/arduino/kegboard/kegboard_eeprom.h +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright 2014 Mike Wakerly - * - * This file is part of the Kegboard package of the Kegbot project. - * For more information on Kegbot, see http://kegbot.org/ - * - * Kegbot is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Kegbot is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Kegbot. If not, see . - */ - - // Kegboard EEPROM constants. - -// Memory Layout -// -// BYTES DESCRIPTION -// ----------- -------------------------------------------------------- -// 0-1 EEP_MAGIC flag (2 bytes); indicates programmed EEPROM. -// 2-31 Board serial number (8 bytes). -// 31-Max Reserved. - -#ifndef KEGBOARD_EEPROM_H_ -#define KEGBOARD_EEPROM_H_ - -#include - -#define EEP_MAGIC 0x4a1e -#define SERIAL_NUMBER_SIZE_BYTES 30 - -int eeprom_is_valid(); -int eeprom_read_serialno(uint8_t *buf); -void eeprom_write_serialno(uint8_t *serialno); - -#endif // KEGBOARD_EEPROM_H_ \ No newline at end of file diff --git a/arduino/kegboard/tones.h b/arduino/kegboard/tones.h deleted file mode 100644 index ad85424..0000000 --- a/arduino/kegboard/tones.h +++ /dev/null @@ -1,92 +0,0 @@ -// Buzzer note frequencies. -// Source: http://arduino.cc/en/Tutorial/tone - -#define FREQ_B0 31 -#define FREQ_C1 33 -#define FREQ_CS1 35 -#define FREQ_D1 37 -#define FREQ_DS1 39 -#define FREQ_E1 41 -#define FREQ_F1 44 -#define FREQ_FS1 46 -#define FREQ_G1 49 -#define FREQ_GS1 52 -#define FREQ_A1 55 -#define FREQ_AS1 58 -#define FREQ_B1 62 -#define FREQ_C2 65 -#define FREQ_CS2 69 -#define FREQ_D2 73 -#define FREQ_DS2 78 -#define FREQ_E2 82 -#define FREQ_F2 87 -#define FREQ_FS2 93 -#define FREQ_G2 98 -#define FREQ_GS2 104 -#define FREQ_A2 110 -#define FREQ_AS2 117 -#define FREQ_B2 123 -#define FREQ_C3 131 -#define FREQ_CS3 139 -#define FREQ_D3 147 -#define FREQ_DS3 156 -#define FREQ_E3 165 -#define FREQ_F3 175 -#define FREQ_FS3 185 -#define FREQ_G3 196 -#define FREQ_GS3 208 -#define FREQ_A3 220 -#define FREQ_AS3 233 -#define FREQ_B3 247 -#define FREQ_C4 262 -#define FREQ_CS4 277 -#define FREQ_D4 294 -#define FREQ_DS4 311 -#define FREQ_E4 330 -#define FREQ_F4 349 -#define FREQ_FS4 370 -#define FREQ_G4 392 -#define FREQ_GS4 415 -#define FREQ_A4 440 -#define FREQ_AS4 466 -#define FREQ_B4 494 -#define FREQ_C5 523 -#define FREQ_CS5 554 -#define FREQ_D5 587 -#define FREQ_DS5 622 -#define FREQ_E5 659 -#define FREQ_F5 698 -#define FREQ_FS5 740 -#define FREQ_G5 784 -#define FREQ_GS5 831 -#define FREQ_A5 880 -#define FREQ_AS5 932 -#define FREQ_B5 988 -#define FREQ_C6 1047 -#define FREQ_CS6 1109 -#define FREQ_D6 1175 -#define FREQ_DS6 1245 -#define FREQ_E6 1319 -#define FREQ_F6 1397 -#define FREQ_FS6 1480 -#define FREQ_G6 1568 -#define FREQ_GS6 1661 -#define FREQ_A6 1760 -#define FREQ_AS6 1865 -#define FREQ_B6 1976 -#define FREQ_C7 2093 -#define FREQ_CS7 2217 -#define FREQ_D7 2349 -#define FREQ_DS7 2489 -#define FREQ_E7 2637 -#define FREQ_F7 2794 -#define FREQ_FS7 2960 -#define FREQ_G7 3136 -#define FREQ_GS7 3322 -#define FREQ_A7 3520 -#define FREQ_AS7 3729 -#define FREQ_B7 3951 -#define FREQ_C8 4186 -#define FREQ_CS8 4435 -#define FREQ_D8 4699 -#define FREQ_DS8 4978 diff --git a/arduino/kegboard/version.h b/arduino/kegboard/version.h deleted file mode 100644 index 831f2d3..0000000 --- a/arduino/kegboard/version.h +++ /dev/null @@ -1,6 +0,0 @@ -#pragma once - -// Version of the kegboard firmware. This is bumped whenever there's a -// significant new feature in the firmware. - -#define FIRMWARE_VERSION 18 diff --git a/boards/esp32-c6-devkitc-1.yaml b/boards/esp32-c6-devkitc-1.yaml new file mode 100644 index 0000000..565e8a0 --- /dev/null +++ b/boards/esp32-c6-devkitc-1.yaml @@ -0,0 +1,23 @@ +--- +# ESP32-C6-DevKitC-1. Forward-looking target: WiFi 6, and Thread/Zigbee radios +# for whatever the kegerator grows into next. Requires ESP-IDF; the Arduino +# core does not support this chip. + +esp32: + board: esp32-c6-devkitc-1 + framework: + type: esp-idf + +substitutions: + meter0_pin: GPIO4 + meter1_pin: GPIO5 + meter2_pin: GPIO6 + meter3_pin: GPIO7 + onewire_pin: GPIO10 + relay0_pin: GPIO11 + relay1_pin: GPIO18 + buzzer_pin: GPIO19 + led0_pin: GPIO20 + led1_pin: GPIO21 + rfid_rx_pin: GPIO22 + onewire_auth_pin: GPIO23 diff --git a/boards/esp32-devkit.yaml b/boards/esp32-devkit.yaml new file mode 100644 index 0000000..9460e23 --- /dev/null +++ b/boards/esp32-devkit.yaml @@ -0,0 +1,26 @@ +--- +# Classic ESP32 DevKitC (WROOM-32). Kept as a target because it is what most +# people already have in a drawer. +# +# Meter pins avoid the input-only range (34-39, which have no internal +# pull-ups and so cannot bias an open-collector meter), the flash pins +# (6-11), and the strapping pins (0, 2, 12, 15). + +esp32: + board: esp32dev + framework: + type: esp-idf + +substitutions: + meter0_pin: GPIO4 + meter1_pin: GPIO5 + meter2_pin: GPIO13 + meter3_pin: GPIO14 + onewire_pin: GPIO16 + relay0_pin: GPIO17 + relay1_pin: GPIO18 + buzzer_pin: GPIO19 + led0_pin: GPIO21 + led1_pin: GPIO22 + rfid_rx_pin: GPIO23 + onewire_auth_pin: GPIO25 diff --git a/boards/esp32-s3-devkitc-1.yaml b/boards/esp32-s3-devkitc-1.yaml new file mode 100644 index 0000000..5ba7435 --- /dev/null +++ b/boards/esp32-s3-devkitc-1.yaml @@ -0,0 +1,28 @@ +--- +# ESP32-S3-DevKitC-1 -- the Kegboard reference target. +# +# Chosen as the reference because it has native USB with USB-Serial-JTAG (so +# flashing and debugging need no external adapter), ample GPIO for four taps +# plus sensors, and PSRAM headroom for later additions like a display. +# +# Meter pins avoid GPIO 19/20 (native USB), 26-32 (SPI flash/PSRAM), and the +# strapping pins 0/3/45/46. + +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +substitutions: + meter0_pin: GPIO4 + meter1_pin: GPIO5 + meter2_pin: GPIO6 + meter3_pin: GPIO7 + onewire_pin: GPIO15 + relay0_pin: GPIO16 + relay1_pin: GPIO17 + buzzer_pin: GPIO18 + led0_pin: GPIO8 + led1_pin: GPIO9 + rfid_rx_pin: GPIO44 + onewire_auth_pin: GPIO21 diff --git a/components/kegboard/CORE.md b/components/kegboard/CORE.md new file mode 100644 index 0000000..c1343f1 --- /dev/null +++ b/components/kegboard/CORE.md @@ -0,0 +1,51 @@ +# Kegboard core + +The files listed below are the **framework-agnostic core**: the logic that is +genuinely kegboard-specific rather than ESPHome-specific. + +- `pour_session.h` / `.cpp` โ€” pour detection state machine +- `tick_series.h` / `.cpp` โ€” bounded `:` diagnostic series +- `json_writer.h` / `.cpp` โ€” minimal JSON serializer +- `events.h` / `.cpp` โ€” event protocol payloads and batch envelope +- `grant_table.h` / `.cpp` โ€” per-meter authorization grants +- `auth_engine.h` / `.cpp` โ€” grant semantics composed with the device: validation, limits, relays, attribution, endings +- `delivery.h` / `.cpp` โ€” send scheduling: backoff, status handling, pairing cadence, command dedup +- `ring_queue.h` โ€” bounded FIFO for offline event buffering + +Core code lives in the global `kbcore` namespace, deliberately *not* in +`esphome::kegboard`. The ESPHome component in this same directory occupies +`esphome::kegboard`, and a global `kegboard` namespace would be shadowed by it +from inside the component โ€” every reference would silently need a leading `::`. +`kbcore` sidesteps that. + +## Rules + +1. **No framework headers.** Core files must not include anything from + `esphome/`, Arduino, or ESP-IDF. Only the C++ standard library. CI enforces + this (`script/check-core-purity.py`). +2. **No I/O, no clocks, no timers.** Time is passed in as arguments. This is + what lets the whole state machine run under host unit tests. +3. **Every core file has host tests** in `tests/core/`, run with plain `g++` + on every push. No hardware, no toolchain download. + +## Why + +Choosing ESPHome buys an enormous amount of infrastructure, but it is a +third-party framework that makes breaking changes to its external-component +API on a regular cadence. Keeping the kegboard-specific logic behind this +boundary means the ESPHome components stay thin adapters: if ESPHome ever +becomes untenable, porting to bare ESP-IDF is an adapter rewrite rather than a +firmware rewrite. + +It also has an immediate payoff, which is the real reason to bother: pour +detection, calibration, and the queue-and-retry path are the parts most likely +to have subtle bugs, and this is what makes them testable in milliseconds +without flashing a board. + +## Note on layout + +These files sit directly in `components/kegboard/` rather than a `core/` +subdirectory because ESPHome only copies source files found at the top level +of an external component โ€” `ComponentManifest.resources` descends into +subdirectories only for manifests constructed with `recursive_sources=True`, +which is reserved for ESPHome's own internal components. diff --git a/components/kegboard/__init__.py b/components/kegboard/__init__.py new file mode 100644 index 0000000..236fb6e --- /dev/null +++ b/components/kegboard/__init__.py @@ -0,0 +1,45 @@ +"""Kegboard hub component. + +Holds the board identity that meters and reporters share. Every other +kegboard_* component depends on this one. +""" + +import esphome.codegen as cg +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@mikey"] + +CONF_KEGBOARD_ID = "kegboard_id" +CONF_SERIAL_NUMBER = "serial_number" + +kegboard_ns = cg.esphome_ns.namespace("kegboard") +KegboardHub = kegboard_ns.class_("KegboardHub", cg.Component) + + +def validate_serial_number(value): + value = cv.string_strict(value) + if not value: + raise cv.Invalid("Serial number must not be empty") + # The serial number is the protocol's device identity and tends to end + # up in server-side names and URLs. Keeping it to safe characters avoids + # surprising encoding behaviour later. + if any(c.isspace() for c in value): + raise cv.Invalid("Serial number must not contain whitespace") + return value + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(KegboardHub), + cv.Optional(CONF_SERIAL_NUMBER): validate_serial_number, + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CONF_SERIAL_NUMBER in config: + cg.add(var.set_serial_number(config[CONF_SERIAL_NUMBER])) diff --git a/components/kegboard/auth_engine.cpp b/components/kegboard/auth_engine.cpp new file mode 100644 index 0000000..834502d --- /dev/null +++ b/components/kegboard/auth_engine.cpp @@ -0,0 +1,154 @@ +#include "auth_engine.h" + +#include + +namespace kbcore { + +static bool contains_u8(const std::vector &v, uint8_t value) { + return std::find(v.begin(), v.end(), value) != v.end(); +} + +void AuthEngine::adopt_in_flight_pour_(uint8_t meter) { + // POLICY โ€” a grant arriving mid-pour ADOPTS the pour: it keeps running + // and, at its end, is attributed to the grant in full โ€” the + // forgot-to-authenticate-first case. Limit + // accounting is not retroactive: the baseline below excludes the volume + // already poured. To change the policy to "split into a new pour" + // instead, call device_.end_pour here โ€” everything else stays as is. + if (this->device_.is_pouring(meter)) + this->pour_seen_ml_[meter] = this->device_.session_volume_ml(meter); +} + +AuthEngine::Outcome AuthEngine::authorize(const GrantSpec &spec, uint32_t now_ms) { + if (spec.grant_id.empty()) + return {false, false, "missing grant_id"}; + if (spec.meters.empty()) + return {false, false, "missing meter_numbers"}; + // A grant naming a meter or relay the device does not have is rejected + // in whole. + for (uint8_t meter : spec.meters) { + if (!this->device_.has_meter(meter)) + return {false, false, "unknown meter " + std::to_string(meter)}; + } + for (uint8_t relay : spec.relays) { + if (!this->device_.has_relay(relay)) + return {false, false, "unknown relay " + std::to_string(relay)}; + } + + // Copy before authorize() mutates the table; relays an update drops must + // release below (unless another grant still names them). + const Grant *existing = this->grants_.grant_by_id(spec.grant_id); + const bool updated = existing != nullptr; + const std::vector prev_relays = updated ? existing->spec.relays : std::vector{}; + + this->process_ends_(this->grants_.authorize(spec, now_ms)); + + for (uint8_t relay : prev_relays) { + if (!contains_u8(spec.relays, relay) && !this->grants_.covers_relay(relay)) + this->device_.set_relay(relay, false); + } + + // Reentrancy: process_ends_ ends replaced grants' pours, whose pour path + // feeds flow back through on_pour_end โ€” which can, in principle, end the + // just-created grant (a tail delta against a tiny max_volume_ml). Its + // grant_end is emitted already; do not energize relays for a dead grant. + if (this->grants_.grant_by_id(spec.grant_id) == nullptr) + return {true, updated, "grant ended while being applied"}; + + for (uint8_t meter : spec.meters) { + this->device_.set_attribution(meter, spec); + this->adopt_in_flight_pour_(meter); + } + for (uint8_t relay : spec.relays) + this->device_.set_relay(relay, true); + + return {true, updated, ""}; +} + +size_t AuthEngine::deauthorize(const std::vector &grant_ids, bool all, uint32_t now_ms) { + // Ids matching no active grant are ignored; the grant may have already + // ended on its own, and the grant_end stream is the record. + const auto ends = all ? this->grants_.deauthorize_all(now_ms) : this->grants_.deauthorize(grant_ids, now_ms); + const size_t count = ends.size(); + this->process_ends_(ends); + return count; +} + +size_t AuthEngine::detach(const std::string &auth_device, const std::string &token, uint32_t now_ms) { + const auto ends = this->grants_.detach(auth_device, token, now_ms); + const size_t count = ends.size(); + this->process_ends_(ends); + return count; +} + +void AuthEngine::on_pour_end(uint8_t meter, float volume_ml, uint32_t now_ms) { + float seen = 0.0f; + auto it = this->pour_seen_ml_.find(meter); + if (it != this->pour_seen_ml_.end()) { + seen = it->second; + it->second = 0.0f; + } + const float delta = volume_ml - seen; + if (delta > 0.0f) + this->process_ends_(this->grants_.record_flow(meter, delta, now_ms)); +} + +void AuthEngine::poll(uint32_t now_ms) { + if (!this->grants_.any_active()) { + if (!this->pour_seen_ml_.empty()) + this->pour_seen_ml_.clear(); + return; + } + + // A pour that ended without settling its counter (e.g. a discarded drip) + // must not poison the next pour's deltas. + for (auto &entry : this->pour_seen_ml_) { + if (!this->device_.is_pouring(entry.first)) + entry.second = 0.0f; + } + + // Live flow accounting: deltas reset the idle clock and accrue toward + // max_volume_ml, so the valve closes the moment a limit trips, not at + // pour end. + for (uint8_t meter : this->grants_.active_meters()) { + if (!this->device_.is_pouring(meter)) + continue; + float &seen = this->pour_seen_ml_[meter]; + const float volume = this->device_.session_volume_ml(meter); + if (volume > seen) { + const float delta = volume - seen; + seen = volume; + this->process_ends_(this->grants_.record_flow(meter, delta, now_ms)); + } + } + + this->process_ends_(this->grants_.poll(now_ms)); +} + +void AuthEngine::process_ends_(const std::vector &ends) { + if (ends.empty()) + return; + for (const auto &end : ends) { + for (uint8_t meter : end.meters) { + // Settle the flow-accounting baseline first: the departing grant's + // totals are already snapshotted, and the pour's last unobserved + // dribble must not be credited to whichever grant covers this meter + // next. + if (this->device_.is_pouring(meter)) + this->pour_seen_ml_[meter] = this->device_.session_volume_ml(meter); + // End any pour in flight before clearing attribution, so the drink + // still lands on the departing grant โ€” and its pour event precedes + // the grant_end. + this->device_.end_pour(meter); + this->device_.clear_attribution(meter); + } + for (uint8_t relay : end.relays) { + // A relay stays energized while any other active grant names it. + if (!this->grants_.covers_relay(relay)) + this->device_.set_relay(relay, false); + } + this->device_.emit_grant_end(end); + } +} + +} // namespace kbcore diff --git a/components/kegboard/auth_engine.h b/components/kegboard/auth_engine.h new file mode 100644 index 0000000..efb7e73 --- /dev/null +++ b/components/kegboard/auth_engine.h @@ -0,0 +1,103 @@ +#pragma once + +// The authorization engine: composes the grant table with flow accounting +// and the device actions grants imply โ€” validation against the inventory, +// relay energize/release, pour attribution, adoption of in-flight pours, +// and the pour-before-grant_end ordering. Implements the grant semantics +// of docs/authenticated-pouring.md, corner cases included. The ESPHome +// component supplies the device through callbacks; keeping the composition +// here is what lets the host suite exercise those semantics without a +// board. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include +#include +#include +#include + +#include "grant_table.h" + +namespace kbcore { + +/// How the engine touches the device. Every callback must be set. +/// +/// end_pour must end any in-flight pour synchronously, running the device's +/// normal pour path โ€” including calling AuthEngine::on_pour_end โ€” before it +/// returns, so a pour's event is queued before the grant_end that ended it. +struct AuthDevice { + std::function has_meter; + std::function has_relay; + std::function is_pouring; + std::function session_volume_ml; + std::function end_pour; + std::function set_attribution; + std::function clear_attribution; + std::function set_relay; + /// Queue the grant_end event; called after the pour path has run. + std::function emit_grant_end; +}; + +class AuthEngine { + public: + explicit AuthEngine(AuthDevice device) : device_(std::move(device)) {} + + void set_max_grant_duration_ms(uint32_t v) { this->grants_.set_max_duration_ms(v); } + uint32_t max_grant_duration_ms() const { return this->grants_.max_duration_ms(); } + + struct Outcome { + bool ok{true}; + /// True when an existing grant was updated in place. + bool updated{false}; + /// Error detail, or a note on an ok outcome (e.g. the grant died while + /// being applied). + std::string message; + }; + + /// Validate the grant against the device inventory and apply it: a grant + /// naming a meter or relay the device does not have is rejected in + /// whole. Applies replacement, in-place updates, adoption of in-flight + /// pours, and relay changes. + Outcome authorize(const GrantSpec &spec, uint32_t now_ms); + + /// Revoke by id, or everything when `all`. Unknown ids are ignored. + /// @return grants ended. + size_t deauthorize(const std::vector &grant_ids, bool all, uint32_t now_ms); + + /// Presence detach. @return grants ended. + size_t detach(const std::string &auth_device, const std::string &token, uint32_t now_ms); + + void revoke_all(uint32_t now_ms) { this->process_ends_(this->grants_.deauthorize_all(now_ms)); } + + /// A pour completed with `volume_ml`: true up flow accounting with + /// whatever accrued since the last poll. Call from the device's pour + /// path, after the pour event is queued. + void on_pour_end(uint8_t meter, float volume_ml, uint32_t now_ms); + + /// Periodic: live flow deltas feed the volume and idle limits (the valve + /// closes the moment a limit trips, not at pour end), and due grants + /// expire. + void poll(uint32_t now_ms); + + bool any_active() const { return this->grants_.any_active(); } + const GrantTable &grants() const { return this->grants_; } + + private: + /// Act on grant endings: end in-flight pours first (the pour event + /// precedes the grant_end), clear attribution, release relays no other + /// grant names, emit grant_end events. + void process_ends_(const std::vector &ends); + void adopt_in_flight_pour_(uint8_t meter); + + AuthDevice device_; + GrantTable grants_; + + /// Session volume already fed into the grant table per meter, so poll() + /// can hand the table deltas while a pour runs and on_pour_end can true + /// up the tail. + std::map pour_seen_ml_; +}; + +} // namespace kbcore diff --git a/components/kegboard/delivery.cpp b/components/kegboard/delivery.cpp new file mode 100644 index 0000000..044541d --- /dev/null +++ b/components/kegboard/delivery.cpp @@ -0,0 +1,60 @@ +#include "delivery.h" + +namespace kbcore { + +BatchDisposition classify_status(int http_status) { + if (http_status >= 200 && http_status < 300) + return BatchDisposition::ACCEPTED; + if (http_status == 401) + return BatchDisposition::PAIRING; + if (http_status >= 400 && http_status < 500) + return BatchDisposition::REJECTED; + return BatchDisposition::TRANSIENT; +} + +void Delivery::note_enqueue(bool reset_backoff, uint32_t now_ms) { + if (reset_backoff) + this->next_attempt_ms_ = now_ms; +} + +void Delivery::on_accepted(uint32_t now_ms) { + this->consecutive_failures_ = 0; + this->next_attempt_ms_ = now_ms; +} + +void Delivery::on_rejected(uint32_t now_ms) { this->next_attempt_ms_ = now_ms; } + +uint32_t Delivery::on_transient(uint32_t now_ms) { + this->consecutive_failures_++; + uint32_t delay_ms = this->retry_interval_ms_; + for (uint32_t i = 1; i < this->consecutive_failures_ && delay_ms < MAX_RETRY_INTERVAL_MS; i++) + delay_ms *= 2; + if (delay_ms > MAX_RETRY_INTERVAL_MS) + delay_ms = MAX_RETRY_INTERVAL_MS; + this->next_attempt_ms_ = now_ms + delay_ms; + return delay_ms; +} + +void Delivery::on_pairing_pending(uint32_t now_ms) { + const uint32_t since_start = now_ms - this->pairing_started_ms_; + const uint32_t interval = since_start < PAIRING_FAST_WINDOW_MS ? PAIRING_FAST_POLL_MS : this->heartbeat_ms_; + this->next_attempt_ms_ = now_ms + interval; +} + +void Delivery::on_pairing_allowed(uint32_t now_ms) { this->next_attempt_ms_ = now_ms; } + +const char *Delivery::command_result(const std::string &id) const { + for (const auto &recent : this->recent_commands_) { + if (recent.id == id) + return recent.result; + } + return nullptr; +} + +void Delivery::record_command(const std::string &id, const char *result) { + this->recent_commands_.push_back(AppliedCommand{id, result}); + if (this->recent_commands_.size() > COMMAND_DEDUP_WINDOW) + this->recent_commands_.erase(this->recent_commands_.begin()); +} + +} // namespace kbcore diff --git a/components/kegboard/delivery.h b/components/kegboard/delivery.h new file mode 100644 index 0000000..e554404 --- /dev/null +++ b/components/kegboard/delivery.h @@ -0,0 +1,93 @@ +#pragma once + +// Delivery scheduling for the event protocol (docs/kegboard-event-protocol.md): +// the status-code table, command dedup and re-acks, pairing cadence, and +// retry backoff. The reporter feeds observations in; this answers "send +// now?" and "what was that command's result?". Keeping the policy here is +// what lets the host suite pin the protocol's promises down without an +// HTTP stack. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include +#include + +namespace kbcore { + +/// What the protocol's status-code table tells the device to do with a +/// batch. Network errors and timeouts never reach classify_status: they +/// are TRANSIENT by definition. +enum class BatchDisposition : uint8_t { + ACCEPTED, ///< 2xx: dequeue the batch, process the response body. + PAIRING, ///< 401: keep events queued, enter/continue pairing. + REJECTED, ///< other 4xx: the batch can never succeed; drop it. + TRANSIENT, ///< 5xx: keep events queued, back off. +}; + +BatchDisposition classify_status(int http_status); + +/// Attempt timing, backoff, pairing cadence, and the command ledger. +/// All timestamps are the device's monotonic milliseconds; comparisons are +/// rollover-safe (32-bit signed differences). +class Delivery { + public: + static constexpr uint32_t MAX_RETRY_INTERVAL_MS = 300000; + static constexpr uint32_t PAIRING_FAST_POLL_MS = 5000; + static constexpr uint32_t PAIRING_FAST_WINDOW_MS = 60000; + static constexpr size_t COMMAND_DEDUP_WINDOW = 16; + + void set_retry_interval_ms(uint32_t v) { this->retry_interval_ms_ = v; } + void set_heartbeat_ms(uint32_t v) { this->heartbeat_ms_ = v; } + + /// Pairing was denied: nothing more is sent until reboot. + bool denied() const { return this->denied_; } + + /// Whether a send attempt is allowed now. + bool due(uint32_t now_ms) const { + return !this->denied_ && static_cast(now_ms - this->next_attempt_ms_) >= 0; + } + + uint32_t consecutive_failures() const { return this->consecutive_failures_; } + + /// An event was queued. Pours and tokens reset backoff and earn an + /// immediate attempt; everything else waits its turn. + void note_enqueue(bool reset_backoff, uint32_t now_ms); + + /// 2xx: batch delivered. + void on_accepted(uint32_t now_ms); + /// Other 4xx: batch dropped; the next batch may go immediately. + void on_rejected(uint32_t now_ms); + /// 5xx / network error / timeout. @return the delay applied, for logging. + uint32_t on_transient(uint32_t now_ms); + + /// Entering (or re-entering) pairing anchors the fast-poll window. + void pairing_started(uint32_t now_ms) { this->pairing_started_ms_ = now_ms; } + /// 401 + pending: poll fast inside the window, then at heartbeat cadence. + void on_pairing_pending(uint32_t now_ms); + /// 401 + allowed: deliver the queued backlog immediately. + void on_pairing_allowed(uint32_t now_ms); + void on_pairing_denied() { this->denied_ = true; } + + /// The recorded result for a command id, or nullptr if unseen. A + /// duplicate is re-acknowledged with this, never re-applied. + const char *command_result(const std::string &id) const; + void record_command(const std::string &id, const char *result); + + private: + struct AppliedCommand { + std::string id; + const char *result; + }; + + uint32_t retry_interval_ms_{30000}; + uint32_t heartbeat_ms_{60000}; + uint32_t next_attempt_ms_{0}; + uint32_t consecutive_failures_{0}; + uint32_t pairing_started_ms_{0}; + bool denied_{false}; + std::vector recent_commands_; +}; + +} // namespace kbcore diff --git a/components/kegboard/events.cpp b/components/kegboard/events.cpp new file mode 100644 index 0000000..a1d8118 --- /dev/null +++ b/components/kegboard/events.cpp @@ -0,0 +1,238 @@ +#include "events.h" + +#include + +#include "json_writer.h" + +namespace kbcore { + +std::string pour_data_json(const PourData &d) { + JsonWriter w; + w.begin_object(); + w.key("meter_number"); + w.value(static_cast(d.meter)); + w.key("pour_id"); + w.value(d.pour_id); + w.key("volume_ml"); + w.value(d.volume_ml, 3); + w.key("duration_ms"); + w.value(d.duration_ms); + if (!d.auth_device.empty()) { + w.key("auth_device"); + w.value(d.auth_device); + } + if (!d.auth_token.empty()) { + w.key("auth_token"); + w.value(d.auth_token); + } + if (!d.grant_id.empty()) { + w.key("grant_id"); + w.value(d.grant_id); + } + if (d.ticks != UINT32_MAX) { + w.key("ticks"); + w.value(d.ticks); + } + if (d.ml_per_tick > 0.0f) { + w.key("ml_per_tick"); + w.value(d.ml_per_tick, 4); + } + if (!d.tick_series.empty()) { + w.key("tick_series"); + w.value(d.tick_series); + } + w.end_object(); + return w.str(); +} + +std::string pour_update_data_json(uint8_t meter, const std::string &pour_id, float volume_ml, uint32_t duration_ms) { + JsonWriter w; + w.begin_object(); + w.key("meter_number"); + w.value(static_cast(meter)); + w.key("pour_id"); + w.value(pour_id); + w.key("volume_ml"); + w.value(volume_ml, 3); + w.key("duration_ms"); + w.value(duration_ms); + w.end_object(); + return w.str(); +} + +std::string temperature_data_json(const std::string &sensor, float temp_c) { + JsonWriter w; + w.begin_object(); + w.key("sensor"); + w.value(sensor); + w.key("temp_c"); + w.value(temp_c, 3); + w.end_object(); + return w.str(); +} + +std::string token_data_json(const std::string &auth_device, const std::string &token, bool attached) { + JsonWriter w; + w.begin_object(); + w.key("auth_device"); + w.value(auth_device); + w.key("token"); + w.value(token); + w.key("action"); + w.value(attached ? "attached" : "detached"); + w.end_object(); + return w.str(); +} + +std::string status_data_json(const StatusData &d) { + JsonWriter w; + w.begin_object(); + w.key("state"); + w.value(d.boot ? "boot" : "heartbeat"); + w.key("fw_version"); + w.value(d.fw_version); + w.key("uptime_ms"); + w.value(d.uptime_ms); + if (d.has_rssi) { + w.key("wifi_rssi_dbm"); + w.value(d.rssi_dbm); + } + w.key("events_dropped"); + w.value(d.events_dropped); + w.key("config"); + w.begin_object(); + w.key("heartbeat_ms"); + w.value(d.heartbeat_ms); + w.key("pour_update_ms"); + w.value(d.pour_update_ms); + w.key("queue_capacity"); + w.value(d.queue_capacity); + w.end_object(); + if (!d.meters.empty()) { + w.key("meters"); + w.begin_array(); + for (const auto &m : d.meters) { + w.begin_object(); + w.key("meter_number"); + w.value(static_cast(m.meter)); + w.key("total_ticks"); + w.value(m.total_ticks); + w.key("ml_per_tick"); + w.value(m.ml_per_tick, 4); + w.end_object(); + } + w.end_array(); + } + if (!d.relays.empty()) { + w.key("relays"); + w.begin_array(); + for (uint8_t relay : d.relays) { + w.begin_object(); + w.key("relay_number"); + w.value(static_cast(relay)); + w.end_object(); + } + w.end_array(); + } + w.end_object(); + return w.str(); +} + +std::string grant_end_data_json(const GrantEnd &end) { + JsonWriter w; + w.begin_object(); + w.key("meter_numbers"); + w.begin_array(); + for (uint8_t meter : end.meters) + w.value(static_cast(meter)); + w.end_array(); + w.key("reason"); + w.value(grant_end_reason_str(end.reason)); + if (!end.auth_device.empty()) { + w.key("auth_device"); + w.value(end.auth_device); + } + if (!end.token.empty()) { + w.key("auth_token"); + w.value(end.token); + } + w.key("grant_id"); + w.value(end.grant_id); + w.key("volume_ml"); + w.value(end.volume_ml, 3); + w.key("duration_ms"); + w.value(end.duration_ms); + w.end_object(); + return w.str(); +} + +std::string command_result_data_json(const std::string &command, const char *result, const std::string &message) { + JsonWriter w; + w.begin_object(); + w.key("command"); + w.value(command); + w.key("result"); + w.value(result); + if (!message.empty()) { + w.key("message"); + w.value(message); + } + w.end_object(); + return w.str(); +} + +std::string serialize_batch(const std::string &device, const std::string &boot_id, uint32_t now_ms, + const std::vector &events) { + JsonWriter w; + w.begin_object(); + w.key("v"); + w.value(static_cast(1)); + w.key("device"); + w.value(device); + w.key("boot_id"); + w.value(boot_id); + w.key("sent_uptime_ms"); + w.value(now_ms); + w.key("events"); + w.begin_array(); + for (const Event *e : events) { + w.begin_object(); + w.key("id"); + w.value(e->id); + w.key("type"); + w.value(e->type); + w.key("age_ms"); + w.value(now_ms - e->created_ms); + if (!e->time.empty()) { + w.key("time"); + w.value(e->time); + } + w.key("data"); + w.raw_value(e->data_json); + w.end_object(); + } + w.end_array(); + w.end_object(); + return w.str(); +} + +std::string format_boot_id(uint32_t random) { + char buf[9]; + snprintf(buf, sizeof(buf), "%08x", random); + return std::string(buf); +} + +std::string format_uuid4(const uint8_t random_bytes[16]) { + uint8_t b[16]; + for (int i = 0; i < 16; i++) + b[i] = random_bytes[i]; + b[6] = (b[6] & 0x0f) | 0x40; // version 4 + b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant + + char buf[37]; + snprintf(buf, sizeof(buf), "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", b[0], b[1], b[2], + b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]); + return std::string(buf); +} + +} // namespace kbcore diff --git a/components/kegboard/events.h b/components/kegboard/events.h new file mode 100644 index 0000000..0f6b754 --- /dev/null +++ b/components/kegboard/events.h @@ -0,0 +1,118 @@ +#pragma once + +// Kegboard Event Protocol: event construction and batch serialization. +// Implements the wire format in docs/kegboard-event-protocol.md; the host +// tests validate the output of this module against the normative schemas in +// schemas/. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include +#include + +#include "grant_table.h" + +namespace kbcore { + +/// Maximum events per batch, per the protocol's request envelope. +constexpr size_t MAX_BATCH_EVENTS = 16; + +/// A protocol event, payload pre-serialized. +/// +/// The `data` object is rendered at creation time (when its inputs are at +/// hand) and stored as JSON text; only the envelope โ€” and in particular +/// `age_ms`, which must be recomputed at every send โ€” is rendered at batch +/// time. +struct Event { + uint32_t id{0}; + /// Protocol type string, e.g. "pour". Points at a string literal. + const char *type{""}; + /// Monotonic ms at which the event occurred; age_ms derives from this. + uint32_t created_ms{0}; + /// RFC 3339 wall time, or empty if the clock was not synced. Informational. + std::string time; + /// Serialized `data` object, from one of the builders below. + std::string data_json; +}; + +// --- Payload builders ------------------------------------------------------ +// Each returns the serialized `data` object for one event type. Optional +// protocol fields are omitted when their inputs are empty/negative, matching +// the schema's required lists exactly. + +struct PourData { + uint8_t meter{0}; + std::string pour_id; + float volume_ml{0.0f}; + uint32_t duration_ms{0}; + std::string auth_device; + std::string auth_token; + /// Server-assigned id of the covering grant; empty for ungated (guest) + /// pours. + std::string grant_id; + /// UINT32_MAX omits `ticks`. + uint32_t ticks{UINT32_MAX}; + /// <= 0 omits `ml_per_tick`. + float ml_per_tick{0.0f}; + std::string tick_series; +}; + +std::string pour_data_json(const PourData &d); + +std::string pour_update_data_json(uint8_t meter, const std::string &pour_id, float volume_ml, uint32_t duration_ms); + +std::string temperature_data_json(const std::string &sensor, float temp_c); + +std::string token_data_json(const std::string &auth_device, const std::string &token, bool attached); + +struct StatusMeter { + uint8_t meter{0}; + uint32_t total_ticks{0}; + /// Always emitted; the schema requires it and config validation forbids 0. + float ml_per_tick{0.0f}; +}; + +struct StatusData { + bool boot{false}; + std::string fw_version; + uint32_t uptime_ms{0}; + /// true includes wifi_rssi_dbm. + bool has_rssi{false}; + int32_t rssi_dbm{0}; + uint32_t events_dropped{0}; + uint32_t heartbeat_ms{0}; + uint32_t pour_update_ms{0}; + uint32_t queue_capacity{0}; + std::vector meters; + /// Relay numbers; exhaustive inventory, like meters. + std::vector relays; +}; + +std::string status_data_json(const StatusData &d); + +std::string command_result_data_json(const std::string &command, const char *result, const std::string &message); + +/// `grant_end`, straight from a GrantTable ending. +std::string grant_end_data_json(const GrantEnd &end); + +// --- Batch serialization --------------------------------------------------- + +/// Serialize a batch envelope. `now_ms` is the monotonic clock at send time; +/// each event's age_ms is computed from it (unsigned subtraction, so correct +/// across the 32-bit rollover). Caller limits `events` to MAX_BATCH_EVENTS. +std::string serialize_batch(const std::string &device, const std::string &boot_id, uint32_t now_ms, + const std::vector &events); + +// --- Identifier formatting ------------------------------------------------- + +/// 8-hex-char boot id from one random word. +std::string format_boot_id(uint32_t random); + +/// Canonical lowercase UUIDv4 from 16 random bytes; sets the version and +/// variant bits. The protocol treats pour ids as opaque, so this format may +/// change without notice โ€” nothing outside this function may assume it. +std::string format_uuid4(const uint8_t random_bytes[16]); + +} // namespace kbcore diff --git a/components/kegboard/grant_table.cpp b/components/kegboard/grant_table.cpp new file mode 100644 index 0000000..6e16ed1 --- /dev/null +++ b/components/kegboard/grant_table.cpp @@ -0,0 +1,207 @@ +#include "grant_table.h" + +#include + +namespace kbcore { + +const char *grant_end_reason_str(GrantEndReason reason) { + switch (reason) { + case GrantEndReason::MAX_VOLUME: + return "max_volume"; + case GrantEndReason::MAX_DURATION: + return "max_duration"; + case GrantEndReason::MAX_IDLE: + return "max_idle"; + case GrantEndReason::DETACH: + return "detach"; + case GrantEndReason::COMMAND: + return "command"; + case GrantEndReason::REPLACED: + return "replaced"; + } + return "command"; +} + +static bool contains_u8(const std::vector &v, uint8_t value) { + return std::find(v.begin(), v.end(), value) != v.end(); +} + +uint32_t GrantTable::effective_max_duration_(uint32_t requested_ms) const { + if (requested_ms == 0 || requested_ms > this->max_duration_ms_) + return this->max_duration_ms_; + return requested_ms; +} + +GrantEnd GrantTable::make_end_(const Grant &grant, std::vector meters, GrantEndReason reason, + uint32_t now_ms) const { + GrantEnd end; + end.grant_id = grant.spec.grant_id; + end.auth_device = grant.spec.auth_device; + end.token = grant.spec.token; + end.meters = std::move(meters); + end.reason = reason; + end.volume_ml = grant.poured_ml; + end.duration_ms = now_ms - grant.created_ms; + return end; +} + +GrantEnd GrantTable::end_grant_(size_t index, GrantEndReason reason, uint32_t now_ms) { + Grant &grant = this->grants_[index]; + GrantEnd end = this->make_end_(grant, grant.spec.meters, reason, now_ms); + end.relays = grant.spec.relays; + this->grants_.erase(this->grants_.begin() + index); + return end; +} + +std::vector GrantTable::authorize(const GrantSpec &spec, uint32_t now_ms) { + std::vector ends; + + // Meters entering the new scope leave whichever other grant covers them; + // an update also sheds the meters no longer in its scope. + for (size_t i = 0; i < this->grants_.size();) { + Grant &g = this->grants_[i]; + const bool is_target = g.spec.grant_id == spec.grant_id; + std::vector lost; + for (uint8_t m : g.spec.meters) { + const bool in_new_scope = contains_u8(spec.meters, m); + if (is_target ? !in_new_scope : in_new_scope) + lost.push_back(m); + } + if (lost.empty()) { + i++; + continue; + } + if (lost.size() == g.spec.meters.size() && !is_target) { + ends.push_back(this->end_grant_(i, GrantEndReason::REPLACED, now_ms)); + continue; // erased; this index now holds the next grant + } + auto &meters = g.spec.meters; + meters.erase(std::remove_if(meters.begin(), meters.end(), [&](uint8_t m) { return contains_u8(lost, m); }), + meters.end()); + ends.push_back(this->make_end_(g, std::move(lost), GrantEndReason::REPLACED, now_ms)); + i++; + } + + for (auto &g : this->grants_) { + if (g.spec.grant_id == spec.grant_id) { + // Update in place: sets and limits replace; counters carry over, so a + // top-up cannot reset volume already poured, and the clamp still + // bounds total lifetime from the original creation. + g.spec = spec; + g.effective_max_duration_ms = this->effective_max_duration_(spec.max_duration_ms); + return ends; + } + } + + Grant g; + g.spec = spec; + g.created_ms = now_ms; + g.last_flow_ms = now_ms; + g.effective_max_duration_ms = this->effective_max_duration_(spec.max_duration_ms); + this->grants_.push_back(std::move(g)); + return ends; +} + +std::vector GrantTable::deauthorize(const std::vector &grant_ids, uint32_t now_ms) { + std::vector ends; + for (size_t i = 0; i < this->grants_.size();) { + const auto &id = this->grants_[i].spec.grant_id; + if (std::find(grant_ids.begin(), grant_ids.end(), id) != grant_ids.end()) { + ends.push_back(this->end_grant_(i, GrantEndReason::COMMAND, now_ms)); + } else { + i++; + } + } + return ends; +} + +std::vector GrantTable::deauthorize_all(uint32_t now_ms) { + std::vector ends; + while (!this->grants_.empty()) + ends.push_back(this->end_grant_(0, GrantEndReason::COMMAND, now_ms)); + return ends; +} + +std::vector GrantTable::detach(const std::string &auth_device, const std::string &token, uint32_t now_ms) { + std::vector ends; + for (size_t i = 0; i < this->grants_.size();) { + const GrantSpec &spec = this->grants_[i].spec; + const bool device_matches = spec.auth_device.empty() || auth_device.empty() || spec.auth_device == auth_device; + if (spec.token == token && device_matches) { + ends.push_back(this->end_grant_(i, GrantEndReason::DETACH, now_ms)); + } else { + i++; + } + } + return ends; +} + +std::vector GrantTable::record_flow(uint8_t meter, float delta_ml, uint32_t now_ms) { + for (size_t i = 0; i < this->grants_.size(); i++) { + Grant &g = this->grants_[i]; + if (!contains_u8(g.spec.meters, meter)) + continue; + g.last_flow_ms = now_ms; + g.poured_ml += delta_ml; + if (g.spec.max_volume_ml > 0.0f && g.poured_ml >= g.spec.max_volume_ml) + return {this->end_grant_(i, GrantEndReason::MAX_VOLUME, now_ms)}; + return {}; + } + return {}; +} + +std::vector GrantTable::poll(uint32_t now_ms) { + std::vector ends; + for (size_t i = 0; i < this->grants_.size();) { + const Grant &g = this->grants_[i]; + // Signed differences survive the 32-bit millisecond rollover; unsigned + // comparisons would expire every grant at the wrap. + if (static_cast(now_ms - (g.created_ms + g.effective_max_duration_ms)) >= 0) { + ends.push_back(this->end_grant_(i, GrantEndReason::MAX_DURATION, now_ms)); + continue; + } + if (g.spec.max_idle_ms > 0 && static_cast(now_ms - (g.last_flow_ms + g.spec.max_idle_ms)) >= 0) { + ends.push_back(this->end_grant_(i, GrantEndReason::MAX_IDLE, now_ms)); + continue; + } + i++; + } + return ends; +} + +const Grant *GrantTable::grant_for(uint8_t meter) const { + for (const auto &g : this->grants_) { + if (contains_u8(g.spec.meters, meter)) + return &g; + } + return nullptr; +} + +const Grant *GrantTable::grant_by_id(const std::string &grant_id) const { + for (const auto &g : this->grants_) { + if (g.spec.grant_id == grant_id) + return &g; + } + return nullptr; +} + +bool GrantTable::covers_relay(uint8_t relay) const { + for (const auto &g : this->grants_) { + if (contains_u8(g.spec.relays, relay)) + return true; + } + return false; +} + +std::vector GrantTable::active_meters() const { + std::vector meters; + for (const auto &g : this->grants_) { + for (uint8_t m : g.spec.meters) { + if (!contains_u8(meters, m)) + meters.push_back(m); + } + } + return meters; +} + +} // namespace kbcore diff --git a/components/kegboard/grant_table.h b/components/kegboard/grant_table.h new file mode 100644 index 0000000..1496be3 --- /dev/null +++ b/components/kegboard/grant_table.h @@ -0,0 +1,132 @@ +#pragma once + +// Authorization grants. +// +// Implements the device half of docs/authenticated-pouring.md: server-issued +// grants that each carry their own meter and relay sets and limits, one +// active grant per meter, in-place updates by grant id, and the device-side +// duration clamp. Every ending is reported with a reason, for the grant_end +// event. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include +#include + +namespace kbcore { + +/// Why a grant (or part of one) ended. +enum class GrantEndReason : uint8_t { MAX_VOLUME, MAX_DURATION, MAX_IDLE, DETACH, COMMAND, REPLACED }; + +/// The protocol string for a reason, e.g. "max_volume". +const char *grant_end_reason_str(GrantEndReason reason); + +/// What an authorize command carries. +struct GrantSpec { + std::string grant_id; + std::vector meters; + std::vector relays; + std::string auth_device; + std::string token; + /// 0 = unlimited. + float max_volume_ml{0.0f}; + /// 0 = unbounded by the issuer; the table clamp always applies. + uint32_t max_duration_ms{0}; + /// 0 = no idle limit. + uint32_t max_idle_ms{0}; +}; + +struct Grant { + GrantSpec spec; + uint32_t created_ms{0}; + uint32_t last_flow_ms{0}; + float poured_ml{0.0f}; + /// max_duration_ms clamped to the table maximum; never 0. + uint32_t effective_max_duration_ms{0}; +}; + +/// One grant ending, ready to become a grant_end event. For a partial ending +/// (reason REPLACED with the grant surviving) `relays` is empty โ€” the grant +/// keeps its relays. `volume_ml`/`duration_ms` are snapshots of the whole +/// grant, never per-meter deltas. +struct GrantEnd { + std::string grant_id; + std::string auth_device; + std::string token; + /// Meters released by this ending. + std::vector meters; + /// Relays the ended grant named; empty when the grant survives. + std::vector relays; + GrantEndReason reason{GrantEndReason::COMMAND}; + float volume_ml{0.0f}; + uint32_t duration_ms{0}; +}; + +/// Tracks active grants. One grant per meter; a grant covering an +/// already-covered meter takes it over. All mutating calls return the +/// resulting endings so the caller can drive relays, end pours, and queue +/// grant_end events without re-deriving state. +class GrantTable { + public: + /// Device-side safety backstop on total grant lifetime. + /// Applies whatever max_duration_ms says, including "unlimited". + void set_max_duration_ms(uint32_t v) { max_duration_ms_ = v; } + uint32_t max_duration_ms() const { return max_duration_ms_; } + + /// Create โ€” or, when a live grant already has spec.grant_id, update โ€” a + /// grant. Meters entering the scope leave whichever grant covered them; + /// an update sheds the meters no longer in its scope. Updates replace + /// sets and limits but carry poured volume and grant age over. + /// @return REPLACED endings for every meter that left a grant. + std::vector authorize(const GrantSpec &spec, uint32_t now_ms); + + /// Revoke by grant id (reason COMMAND). Unknown ids are ignored. + std::vector deauthorize(const std::vector &grant_ids, uint32_t now_ms); + + /// Revoke everything (reason COMMAND). + std::vector deauthorize_all(uint32_t now_ms); + + /// End every grant held by the presentment, for presence-reader detach + /// (reason DETACH). Matches the grant's token, and its auth_device too + /// when both sides carry one โ€” so a stale detach, or the same token value + /// leaving a different reader, cannot close someone else's tap. + std::vector detach(const std::string &auth_device, const std::string &token, uint32_t now_ms); + + /// Flow observed on a meter: resets the covering grant's idle clock and + /// accrues volume. @return a MAX_VOLUME ending if the limit tripped. + std::vector record_flow(uint8_t meter, float delta_ml, uint32_t now_ms); + + /// Expire due grants (MAX_DURATION / MAX_IDLE). + std::vector poll(uint32_t now_ms); + + /// The active grant covering a meter, or nullptr. Valid until the next + /// mutation. + const Grant *grant_for(uint8_t meter) const; + + /// The active grant with this id, or nullptr. Valid until the next + /// mutation. + const Grant *grant_by_id(const std::string &grant_id) const; + + /// True while any active grant names this relay (a relay is + /// energized while any grant names it). + bool covers_relay(uint8_t relay) const; + + /// Every meter covered by an active grant. + std::vector active_meters() const; + + bool any_active() const { return !grants_.empty(); } + size_t active_count() const { return grants_.size(); } + + private: + GrantEnd make_end_(const Grant &grant, std::vector meters, GrantEndReason reason, uint32_t now_ms) const; + /// End the whole grant at `index` and remove it. + GrantEnd end_grant_(size_t index, GrantEndReason reason, uint32_t now_ms); + uint32_t effective_max_duration_(uint32_t requested_ms) const; + + std::vector grants_; + uint32_t max_duration_ms_{300000}; +}; + +} // namespace kbcore diff --git a/components/kegboard/json_writer.cpp b/components/kegboard/json_writer.cpp new file mode 100644 index 0000000..87f982f --- /dev/null +++ b/components/kegboard/json_writer.cpp @@ -0,0 +1,134 @@ +#include "json_writer.h" + +#include +#include + +namespace kbcore { + +void JsonWriter::element_prefix_() { + if (this->after_key_) { + // Value directly follows its key; no comma. + this->after_key_ = false; + return; + } + if (!this->has_element_.empty()) { + if (this->has_element_.back()) + this->out_ += ','; + this->has_element_.back() = true; + } +} + +void JsonWriter::begin_object() { + this->element_prefix_(); + this->out_ += '{'; + this->has_element_.push_back(false); +} + +void JsonWriter::end_object() { + this->has_element_.pop_back(); + this->out_ += '}'; +} + +void JsonWriter::begin_array() { + this->element_prefix_(); + this->out_ += '['; + this->has_element_.push_back(false); +} + +void JsonWriter::end_array() { + this->has_element_.pop_back(); + this->out_ += ']'; +} + +void JsonWriter::key(const char *name) { + this->element_prefix_(); + this->out_ += '"'; + this->append_escaped_(name); + this->out_ += "\":"; + this->after_key_ = true; +} + +void JsonWriter::value(const std::string &v) { this->value(v.c_str()); } + +void JsonWriter::value(const char *v) { + this->element_prefix_(); + this->out_ += '"'; + this->append_escaped_(v); + this->out_ += '"'; +} + +void JsonWriter::value(bool v) { + this->element_prefix_(); + this->out_ += v ? "true" : "false"; +} + +void JsonWriter::value(uint32_t v) { + this->element_prefix_(); + this->out_ += std::to_string(v); +} + +void JsonWriter::value(int32_t v) { + this->element_prefix_(); + this->out_ += std::to_string(v); +} + +void JsonWriter::value(double v, int decimals) { + this->element_prefix_(); + if (std::isnan(v) || std::isinf(v)) { + this->out_ += '0'; + return; + } + char buf[32]; + int n = snprintf(buf, sizeof(buf), "%.*f", decimals, v); + if (n < 0 || static_cast(n) >= sizeof(buf)) { + this->out_ += '0'; + return; + } + this->out_.append(buf, n); +} + +void JsonWriter::raw_value(const std::string &json) { + this->element_prefix_(); + this->out_ += json; +} + +void JsonWriter::append_escaped_(const char *s) { + for (const char *p = s; *p != '\0'; p++) { + const unsigned char c = static_cast(*p); + switch (c) { + case '"': + this->out_ += "\\\""; + break; + case '\\': + this->out_ += "\\\\"; + break; + case '\n': + this->out_ += "\\n"; + break; + case '\r': + this->out_ += "\\r"; + break; + case '\t': + this->out_ += "\\t"; + break; + default: + if (c < 0x20) { + char buf[8]; + snprintf(buf, sizeof(buf), "\\u%04x", c); + this->out_ += buf; + } else { + // UTF-8 multibyte sequences pass through untouched. + this->out_ += static_cast(c); + } + break; + } + } +} + +void JsonWriter::clear() { + this->out_.clear(); + this->has_element_.clear(); + this->after_key_ = false; +} + +} // namespace kbcore diff --git a/components/kegboard/json_writer.h b/components/kegboard/json_writer.h new file mode 100644 index 0000000..30088c0 --- /dev/null +++ b/components/kegboard/json_writer.h @@ -0,0 +1,60 @@ +#pragma once + +// Minimal JSON serializer. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include +#include + +namespace kbcore { + +/// Builds a JSON document into a string. +/// +/// Write-only and allocation-light: enough for the event protocol, and small +/// enough to audit. The caller is responsible for structural sanity (matching +/// begin/end, keys only inside objects); the writer handles commas, quoting, +/// and escaping. Output is deterministic, which is what lets the host tests +/// diff and schema-check it. +class JsonWriter { + public: + void begin_object(); + void end_object(); + void begin_array(); + void end_array(); + + /// Write an object key. Must be followed by exactly one value or container. + void key(const char *name); + + void value(const std::string &v); + void value(const char *v); + void value(bool v); + void value(uint32_t v); + void value(int32_t v); + /// Fixed-decimal float. NaN/inf serialize as 0, since JSON has no spelling + /// for them and a corrupt reading must not corrupt the document. + void value(double v, int decimals); + + /// Splice pre-serialized JSON in as a value, verbatim. The caller vouches + /// that it is a complete, valid JSON value; used to embed event payloads + /// that were rendered at creation time into the batch envelope. + void raw_value(const std::string &json); + + /// The finished document. Valid once all containers are closed. + const std::string &str() const { return out_; } + + void clear(); + + private: + void element_prefix_(); + void append_escaped_(const char *s); + + std::string out_; + /// One entry per open container: whether it already has an element. + std::vector has_element_; + bool after_key_{false}; +}; + +} // namespace kbcore diff --git a/components/kegboard/kegboard.cpp b/components/kegboard/kegboard.cpp new file mode 100644 index 0000000..53280af --- /dev/null +++ b/components/kegboard/kegboard.cpp @@ -0,0 +1,35 @@ +#include "kegboard.h" + +#include + +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +namespace esphome::kegboard { + +static const char *const TAG = "kegboard"; + +const char *const KEGBOARD_VERSION = "4.0.0-pre1"; + +void KegboardHub::setup() { + if (!this->serial_number_.empty()) + return; + + // Derive a stable identity from the last three MAC bytes. The server + // keys taps by `(device, meter_number)`, so this needs to survive + // reflashing and stay unique across boards; the MAC gives both for free. + uint8_t mac[6]; + get_mac_address_raw(mac); + + char buf[24]; + snprintf(buf, sizeof(buf), "kegboard-%02x%02x%02x", mac[3], mac[4], mac[5]); + this->serial_number_ = buf; +} + +void KegboardHub::dump_config() { + ESP_LOGCONFIG(TAG, "Kegboard:"); + ESP_LOGCONFIG(TAG, " Version: %s", KEGBOARD_VERSION); + ESP_LOGCONFIG(TAG, " Serial number: %s", this->serial_number_.c_str()); +} + +} // namespace esphome::kegboard diff --git a/components/kegboard/kegboard.h b/components/kegboard/kegboard.h new file mode 100644 index 0000000..aa8ed1f --- /dev/null +++ b/components/kegboard/kegboard.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include "esphome/core/component.h" + +namespace esphome::kegboard { + +extern const char *const KEGBOARD_VERSION; + +/// Shared board identity for a Kegboard. +/// +/// Mostly exists so meters and reporters agree on what this board is called. +/// The protocol identifies a tap by `(device, meter_number)`, so the serial +/// number is what ties a physical board to its taps on the server. +class KegboardHub : public Component { + public: + void setup() override; + void dump_config() override; + /// Identity must exist before meters or reporters read it. + float get_setup_priority() const override { return setup_priority::DATA + 10.0f; } + + void set_serial_number(const std::string &serial_number) { this->serial_number_ = serial_number; } + + /// Board serial number, e.g. "kegboard-a1b2c3". Derived from the WiFi MAC + /// when not set in config, so an unconfigured board still gets a stable, + /// unique identity that survives reflashing. + const std::string &serial_number() const { return this->serial_number_; } + + const char *version() const { return KEGBOARD_VERSION; } + + /// Installed by whichever component owns a real-time clock. + /// + /// This is a callback rather than a `time::RealTimeClock *` on purpose: + /// ESPHome only copies headers for components that are actually loaded, so + /// including the time component's header here would break every config that + /// does not configure `time:`. A callback keeps `time` a dependency of only + /// the components that genuinely need wall-clock timestamps. + void set_clock_source(std::function &&source) { this->clock_source_ = std::move(source); } + + /// Current unix time, or 0 when no clock is installed or it has not synced. + /// Callers must treat 0 as "unknown" rather than as an epoch timestamp. + uint32_t now_unix() const { return this->clock_source_ ? this->clock_source_() : 0; } + + protected: + std::string serial_number_; + std::function clock_source_; +}; + +} // namespace esphome::kegboard diff --git a/components/kegboard/pour_session.cpp b/components/kegboard/pour_session.cpp new file mode 100644 index 0000000..37a3051 --- /dev/null +++ b/components/kegboard/pour_session.cpp @@ -0,0 +1,78 @@ +#include "pour_session.h" + +namespace kbcore { + +bool PourSession::add_ticks(uint32_t ticks, uint32_t now_ms, uint32_t now_unix) { + if (ticks == 0) + return false; + + this->total_ticks_ += ticks; + + bool started = false; + if (!this->pouring_) { + this->pouring_ = true; + this->session_ticks_ = 0; + this->start_ms_ = now_ms; + this->start_unix_ = now_unix; + this->series_.reset(this->series_resolution_ms_); + started = true; + } + + this->session_ticks_ += ticks; + this->last_tick_ms_ = now_ms; + this->series_.add(now_ms - this->start_ms_, ticks); + + return started; +} + +bool PourSession::poll(uint32_t now_ms, PourRecord *out) { + if (!this->pouring_) + return false; + + // Unsigned subtraction here is deliberate: it stays correct across the + // 32-bit millisecond rollover at ~49.7 days of uptime. + bool idle = (now_ms - this->last_tick_ms_) >= this->config_.idle_timeout_ms; + bool too_long = this->config_.max_duration_ms != 0 && (now_ms - this->start_ms_) >= this->config_.max_duration_ms; + + if (!idle && !too_long) + return false; + + return this->finish_(now_ms, out); +} + +bool PourSession::end_now(uint32_t now_ms, PourRecord *out) { + if (!this->pouring_) + return false; + return this->finish_(now_ms, out); +} + +bool PourSession::finish_(uint32_t now_ms, PourRecord *out) { + (void) now_ms; + + uint32_t ticks = this->session_ticks_; + + this->pouring_ = false; + this->session_ticks_ = 0; + + // Below the threshold this was a drip, a bump, or line noise. Drop it + // rather than reporting a pour nobody made. + if (ticks < this->config_.min_pour_ticks) { + this->series_.reset(this->series_resolution_ms_); + return false; + } + + if (out != nullptr) { + out->ticks = ticks; + out->volume_ml = ticks * this->config_.ml_per_tick; + out->start_unix = this->start_unix_; + // Measure to the last tick, not to the moment we noticed the pour ended, + // so the idle timeout is not counted as pour duration. + out->duration_ms = this->last_tick_ms_ - this->start_ms_; + out->series = this->series_; + } + + this->series_.reset(this->series_resolution_ms_); + return true; +} + +} // namespace kbcore diff --git a/components/kegboard/pour_session.h b/components/kegboard/pour_session.h new file mode 100644 index 0000000..96fded5 --- /dev/null +++ b/components/kegboard/pour_session.h @@ -0,0 +1,121 @@ +#pragma once + +// Pour session state machine. +// +// This file is part of the framework-agnostic kegboard core: it must not +// include any ESPHome (or Arduino, or ESP-IDF) headers, and must remain +// compilable and testable on a host with plain g++. See CORE.md. + +#include + +#include "tick_series.h" + +namespace kbcore { + +/// A pour that has ended and is ready to be reported. +struct PourRecord { + /// Ticks accumulated over the whole pour. + uint32_t ticks{0}; + /// Volume in milliliters, ticks * ml_per_tick at the time the pour ended. + float volume_ml{0.0f}; + /// Wall time the pour started, as a unix timestamp. Zero if the clock was + /// not synchronized when the pour began; consumers must handle that. + uint32_t start_unix{0}; + /// Duration from first to last tick, in milliseconds. + uint32_t duration_ms{0}; + /// Per-interval tick counts, oldest first. May be empty if the series was + /// disabled or overflowed. + TickSeries series; +}; + +/// Tunables for a single meter's pour detection. Times are milliseconds. +struct PourConfig { + /// A pour ends once this long passes with no ticks. + uint32_t idle_timeout_ms{10000}; + /// Pours shorter than this many ticks are discarded as drips or noise. + uint32_t min_pour_ticks{3}; + /// A pour is force-ended after this long, to bound a stuck or free-running + /// meter. Zero disables the cutoff. + uint32_t max_duration_ms{300000}; + /// Milliliters per tick. The SwissFlow SF800 default is ~0.185 (5.4 + /// ticks/mL); this is the value users calibrate. + float ml_per_tick{0.185f}; +}; + +/// Tracks one meter's transition between idle and pouring. +/// +/// The caller feeds it ticks (from an ISR, batched) and a monotonic clock, and +/// it reports when a pour begins and when one has ended. It owns no I/O and no +/// timers, which is what makes it testable on a host. +/// +/// All times passed in are monotonic milliseconds since boot. Wall-clock unix +/// times are passed separately and only used to stamp the resulting record, +/// because a device may pour before its clock is ever synchronized. +class PourSession { + public: + explicit PourSession(const PourConfig &config) : config_(config) {} + + const PourConfig &config() const { return config_; } + void set_config(const PourConfig &config) { config_ = config; } + void set_ml_per_tick(float ml_per_tick) { config_.ml_per_tick = ml_per_tick; } + + /// True while a pour is in progress. + bool is_pouring() const { return pouring_; } + + /// Ticks accumulated in the current pour. Zero when idle. + uint32_t session_ticks() const { return pouring_ ? session_ticks_ : 0; } + + /// Volume of the current pour in mL. Zero when idle. + float session_volume_ml() const { return session_ticks() * config_.ml_per_tick; } + + /// Elapsed time of the current pour in ms, measured to `now_ms`. Zero when + /// idle. Unsigned subtraction keeps this correct across the millis wrap. + uint32_t session_duration_ms(uint32_t now_ms) const { return pouring_ ? now_ms - start_ms_ : 0; } + + /// Lifetime tick count, which survives pour boundaries and never resets + /// except via reset_total(). + uint32_t total_ticks() const { return total_ticks_; } + void reset_total() { total_ticks_ = 0; } + + /// Feed ticks observed since the last call. + /// + /// @param ticks Ticks counted since the previous call; may be zero. + /// @param now_ms Monotonic milliseconds since boot. + /// @param now_unix Current unix time, or 0 if the clock is not synced. + /// @return true if this call started a new pour. + bool add_ticks(uint32_t ticks, uint32_t now_ms, uint32_t now_unix); + + /// Advance time without adding ticks, ending the pour if it has gone idle + /// or hit the duration cutoff. + /// + /// @param out Receives the finished pour when this returns true. + /// @return true if a pour ended on this call and passed min_pour_ticks. + /// A pour that ends below the threshold is discarded silently and + /// this returns false, so callers cannot mistake a drip for a pour. + bool poll(uint32_t now_ms, PourRecord *out); + + /// Force the current pour to end, e.g. because the tap was locked out. + /// Follows the same min_pour_ticks rule as poll(). + bool end_now(uint32_t now_ms, PourRecord *out); + + /// Configure the tick time series recorded during a pour. A resolution of + /// zero disables recording. + void set_series_resolution_ms(uint32_t resolution_ms) { series_resolution_ms_ = resolution_ms; } + + private: + bool finish_(uint32_t now_ms, PourRecord *out); + + PourConfig config_; + + bool pouring_{false}; + uint32_t session_ticks_{0}; + uint32_t total_ticks_{0}; + uint32_t start_ms_{0}; + uint32_t start_unix_{0}; + uint32_t last_tick_ms_{0}; + + uint32_t series_resolution_ms_{TickSeries::DEFAULT_RESOLUTION_MS}; + TickSeries series_; +}; + +} // namespace kbcore diff --git a/components/kegboard/ring_queue.h b/components/kegboard/ring_queue.h new file mode 100644 index 0000000..fc346dd --- /dev/null +++ b/components/kegboard/ring_queue.h @@ -0,0 +1,82 @@ +#pragma once + +// Fixed-capacity FIFO. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include + +namespace kbcore { + +/// A bounded FIFO backed by a fixed array, for buffering reports that could +/// not be delivered yet. +/// +/// Capacity is fixed at compile time so the memory cost of an outage is known +/// up front and a long one cannot exhaust the heap. When the queue is full, +/// push() evicts the oldest entry and increments dropped(); callers are +/// expected to surface that counter, because it means data loss. +/// +/// Evicting the oldest rather than rejecting the newest is a deliberate +/// choice: during a prolonged outage the newest reports are the ones a user is +/// most likely to be watching for, and an unbounded backlog of stale pours is +/// not worth the pours being poured right now. +template class RingQueue { + public: + static constexpr size_t capacity() { return N; } + + size_t size() const { return this->size_; } + bool empty() const { return this->size_ == 0; } + bool full() const { return this->size_ == N; } + + /// Number of entries discarded because the queue was full. + uint32_t dropped() const { return this->dropped_; } + + /// Append an item. Returns false if an older item had to be evicted. + bool push(const T &item) { + bool evicted = false; + if (this->size_ == N) { + this->head_ = (this->head_ + 1) % N; + this->size_--; + this->dropped_++; + evicted = true; + } + size_t tail = (this->head_ + this->size_) % N; + this->items_[tail] = item; + this->size_++; + return !evicted; + } + + /// Oldest item, or nullptr when empty. Valid until the next mutation. + const T *peek() const { return this->size_ == 0 ? nullptr : &this->items_[this->head_]; } + + /// Item `i` positions from the oldest, or nullptr past the end. Lets a + /// sender assemble a batch without popping anything until it is accepted. + const T *at(size_t i) const { return i >= this->size_ ? nullptr : &this->items_[(this->head_ + i) % N]; } + + /// Remove the oldest item, optionally copying it to `out`. + bool pop(T *out = nullptr) { + if (this->size_ == 0) + return false; + if (out != nullptr) + *out = this->items_[this->head_]; + this->items_[this->head_] = T{}; + this->head_ = (this->head_ + 1) % N; + this->size_--; + return true; + } + + void clear() { + while (this->pop()) { + } + } + + private: + T items_[N]{}; + size_t head_{0}; + size_t size_{0}; + uint32_t dropped_{0}; +}; + +} // namespace kbcore diff --git a/components/kegboard/tick_series.cpp b/components/kegboard/tick_series.cpp new file mode 100644 index 0000000..b65450a --- /dev/null +++ b/components/kegboard/tick_series.cpp @@ -0,0 +1,90 @@ +#include "tick_series.h" + +namespace kbcore { + +void TickSeries::reset(uint32_t resolution_ms) { + this->count_ = 0; + this->resolution_ms_ = resolution_ms; + this->coarsened_ = false; +} + +void TickSeries::add(uint32_t offset_ms, uint32_t ticks) { + if (ticks == 0 || !this->enabled()) + return; + + // Snap to the start of the bucket this offset falls in, so repeated updates + // within one interval accumulate into a single entry. + uint32_t bucket = (offset_ms / this->resolution_ms_) * this->resolution_ms_; + + if (this->count_ > 0 && this->buckets_[this->count_ - 1].offset_ms == bucket) { + this->buckets_[this->count_ - 1].ticks += ticks; + return; + } + + if (this->count_ == CAPACITY) { + this->coarsen_(); + // Recompute against the new, wider resolution and retry the merge, since + // this offset may now belong to the last bucket. + bucket = (offset_ms / this->resolution_ms_) * this->resolution_ms_; + if (this->count_ > 0 && this->buckets_[this->count_ - 1].offset_ms == bucket) { + this->buckets_[this->count_ - 1].ticks += ticks; + return; + } + } + + this->buckets_[this->count_].offset_ms = bucket; + this->buckets_[this->count_].ticks = ticks; + this->count_++; +} + +void TickSeries::coarsen_() { + // Merge buckets pairwise: [0,1] -> 0, [2,3] -> 1, ... Each merged bucket + // keeps the earlier offset and the summed tick count, so no ticks are lost. + size_t out = 0; + for (size_t in = 0; in < this->count_; in += 2) { + uint32_t ticks = this->buckets_[in].ticks; + if (in + 1 < this->count_) + ticks += this->buckets_[in + 1].ticks; + this->buckets_[out].offset_ms = this->buckets_[in].offset_ms; + this->buckets_[out].ticks = ticks; + out++; + } + this->count_ = out; + this->resolution_ms_ *= 2; + this->coarsened_ = true; + + // Merging by position can leave two entries sharing a bucket once the + // resolution widens; collapse any such neighbours so offsets stay unique + // and strictly increasing. + size_t write = 0; + for (size_t read = 0; read < this->count_; read++) { + uint32_t bucket = (this->buckets_[read].offset_ms / this->resolution_ms_) * this->resolution_ms_; + if (write > 0 && this->buckets_[write - 1].offset_ms == bucket) { + this->buckets_[write - 1].ticks += this->buckets_[read].ticks; + continue; + } + this->buckets_[write].offset_ms = bucket; + this->buckets_[write].ticks = this->buckets_[read].ticks; + write++; + } + this->count_ = write; +} + +std::string TickSeries::to_string() const { + std::string out; + if (this->count_ == 0) + return out; + + // Rough reservation: offsets and counts are both short decimal strings. + out.reserve(this->count_ * 12); + for (size_t i = 0; i < this->count_; i++) { + if (i != 0) + out += ' '; + out += std::to_string(this->buckets_[i].offset_ms); + out += ':'; + out += std::to_string(this->buckets_[i].ticks); + } + return out; +} + +} // namespace kbcore diff --git a/components/kegboard/tick_series.h b/components/kegboard/tick_series.h new file mode 100644 index 0000000..2d911b0 --- /dev/null +++ b/components/kegboard/tick_series.h @@ -0,0 +1,73 @@ +#pragma once + +// Bounded tick time series. +// +// Part of the framework-agnostic kegboard core: no ESPHome, Arduino, or +// ESP-IDF headers. See CORE.md. + +#include +#include +#include + +namespace kbcore { + +/// A bounded record of when ticks arrived during a pour. +/// +/// Reported as the pour event's `tick_series`, a space-separated sequence +/// of `:` pairs. It is diagnostic +/// data only, so this class prioritizes bounded memory over fidelity: ticks +/// are bucketed at a configurable resolution, and when the buffer fills, the +/// series is coarsened in place (adjacent buckets merged, resolution doubled) +/// rather than truncated. A long pour therefore stays fully represented, at +/// progressively lower time resolution. +class TickSeries { + public: + /// Maximum number of buckets held. At 8 bytes per bucket this caps the + /// series at a few hundred bytes per in-flight pour. + static constexpr size_t CAPACITY = 64; + + /// Default bucketing resolution, matching the legacy AVR firmware's + /// KB_METER_UPDATE_INTERVAL_MS. + static constexpr uint32_t DEFAULT_RESOLUTION_MS = 100; + + struct Bucket { + uint32_t offset_ms; + uint32_t ticks; + }; + + /// Discard all data and begin a new series at the given resolution. + /// A resolution of zero disables recording entirely. + void reset(uint32_t resolution_ms = DEFAULT_RESOLUTION_MS); + + /// Record ticks observed at `offset_ms` after the start of the pour. + void add(uint32_t offset_ms, uint32_t ticks); + + /// Serialize to Kegbot's `:` wire format. Returns an empty + /// string when the series is empty or recording is disabled. + std::string to_string() const; + + size_t size() const { return count_; } + bool empty() const { return count_ == 0; } + bool enabled() const { return resolution_ms_ != 0; } + + /// Current bucket width. Grows past the configured value if the series had + /// to be coarsened to fit. + uint32_t resolution_ms() const { return resolution_ms_; } + + /// True if the series was coarsened at least once, i.e. its resolution is + /// no longer the one it started with. + bool coarsened() const { return coarsened_; } + + const Bucket &operator[](size_t i) const { return buckets_[i]; } + + private: + /// Halve the bucket count by merging adjacent pairs, doubling resolution. + void coarsen_(); + + Bucket buckets_[CAPACITY]{}; + size_t count_{0}; + uint32_t resolution_ms_{DEFAULT_RESOLUTION_MS}; + bool coarsened_{false}; +}; + +} // namespace kbcore diff --git a/components/kegboard_auth/__init__.py b/components/kegboard_auth/__init__.py new file mode 100644 index 0000000..072e86a --- /dev/null +++ b/components/kegboard_auth/__init__.py @@ -0,0 +1,203 @@ +"""Authorization for taps, per docs/authenticated-pouring.md. + +Turns token events from any reader into server-decided grants: each grant +arrives naming the meters it covers, the relays it opens, and its limits. +The component drives relays and tags pours for server-side attribution; the +device never learns user identity. +""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_DEVICE, CONF_ID, CONF_TRIGGER_ID +import esphome.final_validate as fv + +from ..kegboard_reporter import KegboardReporter + +CODEOWNERS = ["@mikey"] +DEPENDENCIES = ["kegboard"] +AUTO_LOAD = ["binary_sensor"] + +CONF_AUTHORIZED = "authorized" +CONF_MAX_GRANT_DURATION = "max_grant_duration" +CONF_OFFLINE_POLICY = "offline_policy" +CONF_ON_AUTHORIZED = "on_authorized" +CONF_ON_DENIED = "on_denied" +CONF_ON_REVOKED = "on_revoked" +CONF_REPORTER_ID = "reporter_id" +CONF_TOKEN = "token" + +kegboard_auth_ns = cg.esphome_ns.namespace("kegboard_auth") +KegboardAuth = kegboard_auth_ns.class_("KegboardAuth", cg.Component) + +OfflinePolicy = kegboard_auth_ns.enum("OfflinePolicy", is_class=True) +OFFLINE_POLICIES = {"deny": OfflinePolicy.DENY, "guest": OfflinePolicy.GUEST} + +AuthorizedTrigger = kegboard_auth_ns.class_( + "AuthorizedTrigger", automation.Trigger.template(cg.std_string, cg.std_string) +) +DeniedTrigger = kegboard_auth_ns.class_( + "DeniedTrigger", automation.Trigger.template(cg.std_string) +) +RevokedTrigger = kegboard_auth_ns.class_( + "RevokedTrigger", automation.Trigger.template() +) + +TokenAttachedAction = kegboard_auth_ns.class_("TokenAttachedAction", automation.Action) +TokenDetachedAction = kegboard_auth_ns.class_("TokenDetachedAction", automation.Action) +RevokeAction = kegboard_auth_ns.class_("RevokeAction", automation.Action) +AuthorizedCondition = kegboard_auth_ns.class_( + "AuthorizedCondition", automation.Condition +) + + +def _final_validate(config): + """Bind to the config's kegboard_reporter automatically. + + Like every other *_id reference, users should never have to write + reporter_id when there is only one reporter -- which is every real + config. It stays available for the exotic multiple-reporter case. + """ + if CONF_REPORTER_ID in config: + return config + + reporter = fv.full_config.get().get("kegboard_reporter") + if not reporter: + raise cv.Invalid( + "kegboard_auth needs a kegboard_reporter configured: the server " + "decides every presentment. (Boards without a server can gate " + "valves with plain ESPHome automations on the reader triggers.)" + ) + # Not MULTI_CONF today, so this is a single config dict; keep the list + # branch in case that ever changes. + if isinstance(reporter, list): + if len(reporter) > 1: + raise cv.Invalid( + "Multiple kegboard_reporter instances; set reporter_id on " + "kegboard_auth to choose one." + ) + reporter = reporter[0] + config[CONF_REPORTER_ID] = reporter[CONF_ID] + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(KegboardAuth), + cv.Optional(CONF_REPORTER_ID): cv.use_id(KegboardReporter), + cv.Optional(CONF_OFFLINE_POLICY, default="deny"): cv.one_of( + *OFFLINE_POLICIES, lower=True + ), + # Device-side safety backstop on server-issued grants. Bounded + # so the rollover-safe expiry math (32-bit signed millisecond + # differences) stays valid. + cv.Optional(CONF_MAX_GRANT_DURATION, default="5min"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(max=cv.TimePeriod(hours=24)), + ), + cv.Optional(CONF_AUTHORIZED): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_ON_AUTHORIZED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(AuthorizedTrigger)} + ), + cv.Optional(CONF_ON_DENIED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DeniedTrigger)} + ), + cv.Optional(CONF_ON_REVOKED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(RevokedTrigger)} + ), + } + ).extend(cv.COMPONENT_SCHEMA), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + if CONF_REPORTER_ID in config: + cg.add(var.set_reporter(await cg.get_variable(config[CONF_REPORTER_ID]))) + + cg.add(var.set_offline_policy(OFFLINE_POLICIES[config[CONF_OFFLINE_POLICY]])) + cg.add(var.set_max_grant_duration_ms(config[CONF_MAX_GRANT_DURATION])) + + if CONF_AUTHORIZED in config: + sens = await binary_sensor.new_binary_sensor(config[CONF_AUTHORIZED]) + cg.add(var.set_authorized_binary_sensor(sens)) + + for conf in config.get(CONF_ON_AUTHORIZED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_authorized_trigger(trigger)) + await automation.build_automation( + trigger, + [(cg.std_string, "auth_device"), (cg.std_string, "token")], + conf, + ) + + for conf in config.get(CONF_ON_DENIED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_denied_trigger(trigger)) + await automation.build_automation(trigger, [(cg.std_string, "reason")], conf) + + for conf in config.get(CONF_ON_REVOKED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_revoked_trigger(trigger)) + await automation.build_automation(trigger, [], conf) + + +TOKEN_ACTION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(KegboardAuth), + cv.Required(CONF_DEVICE): cv.templatable(cv.string_strict), + cv.Required(CONF_TOKEN): cv.templatable(cv.string_strict), + } +) + + +@automation.register_action( + "kegboard_auth.token_attached", + TokenAttachedAction, + TOKEN_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "kegboard_auth.token_detached", + TokenDetachedAction, + TOKEN_ACTION_SCHEMA, + synchronous=True, +) +async def token_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + cg.add( + var.set_device(await cg.templatable(config[CONF_DEVICE], args, cg.std_string)) + ) + cg.add(var.set_token(await cg.templatable(config[CONF_TOKEN], args, cg.std_string))) + return var + + +@automation.register_action( + "kegboard_auth.revoke", + RevokeAction, + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(KegboardAuth)}), + synchronous=True, +) +async def revoke_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_condition( + "kegboard_auth.is_authorized", + AuthorizedCondition, + automation.maybe_simple_id({cv.GenerateID(): cv.use_id(KegboardAuth)}), +) +async def is_authorized_to_code(config, condition_id, template_arg, args): + var = cg.new_Pvariable(condition_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/components/kegboard_auth/kegboard_auth.cpp b/components/kegboard_auth/kegboard_auth.cpp new file mode 100644 index 0000000..efc0a5e --- /dev/null +++ b/components/kegboard_auth/kegboard_auth.cpp @@ -0,0 +1,271 @@ +#include "kegboard_auth.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::kegboard_auth { + +static const char *const TAG = "kegboard_auth"; + +void KegboardAuth::setup() { + if (this->reporter_ != nullptr) { + this->reporter_->set_command_handler([this](const std::string &type, JsonObjectConst data, std::string &message) { + return this->handle_command_(type, data, message); + }); + this->meters_ = this->reporter_->meter_list(); + } + + kbcore::AuthDevice device; + device.has_meter = [this](uint8_t meter) { return this->meter_by_number_(meter) != nullptr; }; + device.has_relay = [this](uint8_t relay) { return this->reporter_ != nullptr && this->reporter_->has_relay(relay); }; + device.is_pouring = [this](uint8_t meter) { + auto *m = this->meter_by_number_(meter); + return m != nullptr && m->is_pouring(); + }; + device.session_volume_ml = [this](uint8_t meter) { + auto *m = this->meter_by_number_(meter); + return m != nullptr ? m->session_volume_ml() : 0.0f; + }; + device.end_pour = [this](uint8_t meter) { + auto *m = this->meter_by_number_(meter); + if (m != nullptr) + m->end_pour(); + }; + device.set_attribution = [this](uint8_t meter, const kbcore::GrantSpec &spec) { + auto *m = this->meter_by_number_(meter); + if (m != nullptr) + m->set_active_auth(spec.grant_id, spec.auth_device, spec.token); + }; + device.clear_attribution = [this](uint8_t meter) { + auto *m = this->meter_by_number_(meter); + if (m != nullptr) + m->clear_active_auth(); + }; + device.set_relay = [this](uint8_t relay, bool on) { + auto *sw = this->reporter_ != nullptr ? this->reporter_->relay_by_number(relay) : nullptr; + if (sw == nullptr) + return; + if (on) { + sw->turn_on(); + } else { + sw->turn_off(); + } + }; + device.emit_grant_end = [this](const kbcore::GrantEnd &end) { + ESP_LOGI(TAG, "Grant %s ended (%s): %u meter(s)", end.grant_id.c_str(), kbcore::grant_end_reason_str(end.reason), + static_cast(end.meters.size())); + if (this->reporter_ != nullptr) + this->reporter_->queue_grant_end(end); + for (auto *trigger : this->revoked_triggers_) + trigger->trigger(); + }; + + this->engine_ = std::make_unique(std::move(device)); + this->engine_->set_max_grant_duration_ms(this->max_grant_duration_ms_); + + // True up flow accounting at pour end. This callback registers after the + // reporter's (which is added at construction time), so a volume limit + // tripping here queues its grant_end after the pour event. + for (auto *meter : this->meters_) { + meter->add_on_pour_callback([this](kegboard_meter::KegboardMeter &m, const kbcore::PourRecord &record) { + this->engine_->on_pour_end(m.meter_number(), record.volume_ml, millis()); + }); + } + + this->publish_state_(); +} + +kegboard_meter::KegboardMeter *KegboardAuth::meter_by_number_(uint8_t meter) { + for (auto *m : this->meters_) { + if (m->meter_number() == meter) + return m; + } + return nullptr; +} + +void KegboardAuth::token_attached(const std::string &auth_device, const std::string &token) { + if (token.empty()) + return; + + // The decision rides the response to the token event. The command handler + // runs inside send_token_ask() and sets decision_received_. + if (this->reporter_ == nullptr) { + ESP_LOGW(TAG, "No reporter; denying"); + this->fire_denied_("no reporter configured"); + return; + } + + this->decision_received_ = false; + const bool delivered = this->reporter_->send_token_ask(auth_device, token); + + if (!delivered) { + if (this->offline_policy_ == OfflinePolicy::GUEST) { + // Nothing opens and nothing is granted: pours proceed as ordinary + // guest pours, and the queued token event preserves the audit trail. + // The only difference from `deny` is that the user is not signaled a + // refusal. + ESP_LOGW(TAG, "Server unreachable; %s/%s pours as guest", auth_device.c_str(), token.c_str()); + } else { + ESP_LOGW(TAG, "Server unreachable; denying %s/%s", auth_device.c_str(), token.c_str()); + this->fire_denied_("server unreachable"); + } + return; + } + + if (!this->decision_received_) { + // Delivered but the server answered with neither authorize nor deny -- + // a server bug, defensively treated as denial without the user signal. + ESP_LOGW(TAG, "Server did not decide on %s/%s; treating as denied", auth_device.c_str(), token.c_str()); + } +} + +kegboard_reporter::CommandOutcome KegboardAuth::handle_command_(const std::string &type, JsonObjectConst data, + std::string &message) { + using kegboard_reporter::CommandOutcome; + + if (type == "authorize") + return this->handle_authorize_(data, message); + + if (type == "deny") { + this->decision_received_ = true; + const std::string reason = data["reason"].is() ? data["reason"].as() : ""; + ESP_LOGW(TAG, "Denied by server%s%s", reason.empty() ? "" : ": ", reason.c_str()); + this->fire_denied_(reason); + return CommandOutcome::OK; + } + + if (type == "deauthorize") + return this->handle_deauthorize_(data, message); + + return CommandOutcome::UNSUPPORTED; +} + +kegboard_reporter::CommandOutcome KegboardAuth::handle_authorize_(JsonObjectConst data, std::string &message) { + using kegboard_reporter::CommandOutcome; + + // Whatever happens below, the server did decide; a malformed grant must + // not read as "no decision" (which would double-signal a denial). + this->decision_received_ = true; + + kbcore::GrantSpec spec; + if (data["grant_id"].is()) + spec.grant_id = data["grant_id"].as(); + + JsonArrayConst meters_json = data["meter_numbers"].as(); + if (!meters_json.isNull()) { + for (JsonVariantConst v : meters_json) { + if (!v.is()) { + message = "bad meter_numbers"; + return CommandOutcome::ERROR; + } + spec.meters.push_back(v.as()); + } + } + JsonArrayConst relays_json = data["relay_numbers"].as(); + if (!relays_json.isNull()) { + for (JsonVariantConst v : relays_json) { + if (!v.is()) { + message = "bad relay_numbers"; + return CommandOutcome::ERROR; + } + spec.relays.push_back(v.as()); + } + } + + spec.auth_device = data["auth_device"].is() ? data["auth_device"].as() : ""; + spec.token = data["token"].is() ? data["token"].as() : ""; + spec.max_volume_ml = data["max_volume_ml"].is() ? data["max_volume_ml"].as() : 0.0f; + spec.max_duration_ms = data["max_duration_ms"].is() ? data["max_duration_ms"].as() : 0; + spec.max_idle_ms = data["max_idle_ms"].is() ? data["max_idle_ms"].as() : 0; + + const auto outcome = this->engine_->authorize(spec, millis()); + if (!outcome.ok) { + message = outcome.message; + return CommandOutcome::ERROR; + } + if (!outcome.message.empty()) { + ESP_LOGW(TAG, "Grant %s: %s", spec.grant_id.c_str(), outcome.message.c_str()); + } else { + ESP_LOGI(TAG, "%s grant %s: %u meter(s), %u relay(s)", outcome.updated ? "Updated" : "Applied", + spec.grant_id.c_str(), static_cast(spec.meters.size()), + static_cast(spec.relays.size())); + } + for (auto *trigger : this->authorized_triggers_) + trigger->trigger(spec.auth_device, spec.token); + this->publish_state_(); + return CommandOutcome::OK; +} + +kegboard_reporter::CommandOutcome KegboardAuth::handle_deauthorize_(JsonObjectConst data, std::string &message) { + using kegboard_reporter::CommandOutcome; + + size_t count = 0; + JsonVariantConst ids_var = data["grant_ids"]; + if (ids_var.isNull()) { + // Absent means every active grant: the emergency stop. + count = this->engine_->deauthorize({}, true, millis()); + } else if (!ids_var.is()) { + // Malformed input must not read as the most destructive interpretation. + message = "bad grant_ids"; + return CommandOutcome::ERROR; + } else { + std::vector ids; + for (JsonVariantConst v : ids_var.as()) { + if (v.is()) + ids.emplace_back(v.as()); + } + count = this->engine_->deauthorize(ids, false, millis()); + } + ESP_LOGI(TAG, "Deauthorized %u grant(s) by server command", static_cast(count)); + this->publish_state_(); + return CommandOutcome::OK; +} + +void KegboardAuth::token_detached(const std::string &auth_device, const std::string &token) { + // An empty token must not match the grants issued without a token echo. + if (token.empty()) + return; + // The token event first, so the wire order is detach, then any ended + // grant's pour, then its grant_end. + if (this->reporter_ != nullptr) + this->reporter_->queue_token_event(auth_device, token, false); + const size_t count = this->engine_->detach(auth_device, token, millis()); + if (count == 0) + return; + ESP_LOGI(TAG, "Token removed; ended %u grant(s)", static_cast(count)); + this->publish_state_(); +} + +void KegboardAuth::revoke_all() { + this->engine_->revoke_all(millis()); + this->publish_state_(); +} + +void KegboardAuth::loop() { + this->engine_->poll(millis()); + // Cheap: publish_state_ deduplicates via the sensor itself, but avoid + // the call entirely in the common idle case. + if (this->authorized_sensor_ != nullptr && this->authorized_sensor_->state != this->engine_->any_active()) + this->publish_state_(); +} + +void KegboardAuth::publish_state_() { + if (this->authorized_sensor_ != nullptr && this->engine_ != nullptr) + this->authorized_sensor_->publish_state(this->engine_->any_active()); +} + +void KegboardAuth::fire_denied_(const std::string &reason) { + for (auto *trigger : this->denied_triggers_) + trigger->trigger(reason); +} + +void KegboardAuth::dump_config() { + ESP_LOGCONFIG(TAG, "Kegboard Auth:"); + ESP_LOGCONFIG(TAG, " Offline policy: %s", this->offline_policy_ == OfflinePolicy::DENY ? "deny" : "guest"); + ESP_LOGCONFIG(TAG, " Max grant duration: %" PRIu32 " s", this->max_grant_duration_ms_ / 1000); + ESP_LOGCONFIG(TAG, " Meters: %u", static_cast(this->meters_.size())); +} + +} // namespace esphome::kegboard_auth diff --git a/components/kegboard_auth/kegboard_auth.h b/components/kegboard_auth/kegboard_auth.h new file mode 100644 index 0000000..778583b --- /dev/null +++ b/components/kegboard_auth/kegboard_auth.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/kegboard/auth_engine.h" +#include "esphome/components/kegboard_meter/kegboard_meter.h" +#include "esphome/components/kegboard_reporter/kegboard_reporter.h" +#include "esphome/components/switch/switch.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome::kegboard_auth { + +/// What happens when a token is presented while the server is unreachable. +enum class OfflinePolicy : uint8_t { DENY, GUEST }; + +/// Fires with (auth_device, token) when a grant is applied. The device never +/// learns user identity; the server resolves it from the token or grant_id. +class AuthorizedTrigger : public Trigger {}; +/// Fires with the server's reason (may be "") when a presentment is refused. +class DeniedTrigger : public Trigger {}; +/// Fires when a grant ends (limit, detach, replacement, or deauthorize). +class RevokedTrigger : public Trigger<> {}; + +/// Applies authorization to taps, per docs/authenticated-pouring.md. +/// +/// A thin adapter: token presentments go to the server through the +/// reporter, and the server's authorize/deny/deauthorize commands drive a +/// kbcore::AuthEngine, which owns all grant semantics (validation, limits, +/// relays, attribution, adoption, endings). This class supplies the engine +/// its device โ€” meters and relays via the reporter โ€” plus JSON parsing, +/// entities, triggers, and logging. +class KegboardAuth : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + void set_reporter(kegboard_reporter::KegboardReporter *r) { this->reporter_ = r; } + void set_offline_policy(OfflinePolicy p) { this->offline_policy_ = p; } + void set_max_grant_duration_ms(uint32_t v) { this->max_grant_duration_ms_ = v; } + + void set_authorized_binary_sensor(binary_sensor::BinarySensor *s) { this->authorized_sensor_ = s; } + + void add_on_authorized_trigger(AuthorizedTrigger *t) { this->authorized_triggers_.push_back(t); } + void add_on_denied_trigger(DeniedTrigger *t) { this->denied_triggers_.push_back(t); } + void add_on_revoked_trigger(RevokedTrigger *t) { this->revoked_triggers_.push_back(t); } + + /// A reader saw a token arrive. + void token_attached(const std::string &auth_device, const std::string &token); + + /// A reader saw a token leave (presence readers only). + void token_detached(const std::string &auth_device, const std::string &token); + + /// Revoke every grant, e.g. a manual lockout. + void revoke_all(); + + bool is_authorized() const { return this->engine_ != nullptr && this->engine_->any_active(); } + + protected: + kegboard_reporter::CommandOutcome handle_command_(const std::string &type, JsonObjectConst data, + std::string &message); + kegboard_reporter::CommandOutcome handle_authorize_(JsonObjectConst data, std::string &message); + kegboard_reporter::CommandOutcome handle_deauthorize_(JsonObjectConst data, std::string &message); + + kegboard_meter::KegboardMeter *meter_by_number_(uint8_t meter); + void publish_state_(); + void fire_denied_(const std::string &reason); + + std::unique_ptr engine_; + kegboard_reporter::KegboardReporter *reporter_{nullptr}; + OfflinePolicy offline_policy_{OfflinePolicy::DENY}; + uint32_t max_grant_duration_ms_{300000}; + + /// The reporter's meters: the inventory grants are validated against. + std::vector meters_; + + /// Set while dispatching commands from a token-ask response, so the absence + /// of any decision can be detected (treated as deny, per the doc). + bool decision_received_{false}; + + binary_sensor::BinarySensor *authorized_sensor_{nullptr}; + + std::vector authorized_triggers_; + std::vector denied_triggers_; + std::vector revoked_triggers_; +}; + +template class TokenAttachedAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, device) + TEMPLATABLE_VALUE(std::string, token) + + void play(const Ts &...x) override { + this->parent_->token_attached(this->device_.value(x...), this->token_.value(x...)); + } +}; + +template class TokenDetachedAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, device) + TEMPLATABLE_VALUE(std::string, token) + + void play(const Ts &...x) override { + this->parent_->token_detached(this->device_.value(x...), this->token_.value(x...)); + } +}; + +template class RevokeAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->revoke_all(); } +}; + +template class AuthorizedCondition : public Condition, public Parented { + public: + bool check(const Ts &...x) override { return this->parent_->is_authorized(); } +}; + +} // namespace esphome::kegboard_auth diff --git a/components/kegboard_meter/__init__.py b/components/kegboard_meter/__init__.py new file mode 100644 index 0000000..a016eba --- /dev/null +++ b/components/kegboard_meter/__init__.py @@ -0,0 +1,247 @@ +"""Flow meter component. + +Counts pulses in an ISR and hands them to the framework-agnostic pour state +machine in `components/kegboard`. One entry per physical meter. +""" + +from esphome import automation, pins +import esphome.codegen as cg +from esphome.components import binary_sensor, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_PIN, + CONF_TRIGGER_ID, + CONF_VALUE, + ICON_PULSE, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) +import esphome.final_validate as fv + +from ..kegboard import CONF_KEGBOARD_ID, KegboardHub + +CODEOWNERS = ["@mikey"] +DEPENDENCIES = ["kegboard"] +AUTO_LOAD = ["sensor", "binary_sensor"] +MULTI_CONF = True + +CONF_DEBOUNCE = "debounce" +CONF_FLOW_RATE = "flow_rate" +CONF_IDLE_TIMEOUT = "idle_timeout" +CONF_MAX_POUR_DURATION = "max_pour_duration" +CONF_METER_NUMBER = "meter_number" +CONF_MIN_POUR_TICKS = "min_pour_ticks" +CONF_ML_PER_TICK = "ml_per_tick" +CONF_ON_POUR_END = "on_pour_end" +CONF_ON_POUR_START = "on_pour_start" +CONF_POURING = "pouring" +CONF_REPORT_INTERVAL = "report_interval" +CONF_SERIES_RESOLUTION = "series_resolution" +CONF_TOTAL = "total" +CONF_VOLUME = "volume" + +UNIT_MILLILITER = "mL" +UNIT_MILLILITER_PER_MINUTE = "mL/min" +UNIT_TICKS = "ticks" + +kegboard_meter_ns = cg.esphome_ns.namespace("kegboard_meter") +KegboardMeter = kegboard_meter_ns.class_("KegboardMeter", cg.Component) + +PourStartTrigger = kegboard_meter_ns.class_( + "PourStartTrigger", automation.Trigger.template() +) +PourEndTrigger = kegboard_meter_ns.class_( + "PourEndTrigger", automation.Trigger.template(cg.uint32, cg.float_, cg.uint32) +) + +ResetTotalAction = kegboard_meter_ns.class_("ResetTotalAction", automation.Action) +EndPourAction = kegboard_meter_ns.class_("EndPourAction", automation.Action) +SetCalibrationAction = kegboard_meter_ns.class_( + "SetCalibrationAction", automation.Action +) + +# The SwissFlow SF800 and its clones -- by far the most common Kegbot meter -- +# produce about 5.4 ticks per mL. +DEFAULT_ML_PER_TICK = 0.185 + + +def positive_nonzero_float(value): + value = cv.positive_float(value) + if value == 0: + raise cv.Invalid("ml_per_tick must be greater than 0") + return value + + +def _final_validate(config): + """Reject duplicate meter numbers across all kegboard_meter instances. + + The default is 0, so a multi-meter config that forgets to set + meter_number would otherwise report every tap as meter 0 and the server + would silently merge them. + """ + full = fv.full_config.get() + seen = {} + for meter in full.get("kegboard_meter", []): + number = meter[CONF_METER_NUMBER] + if number in seen: + raise cv.Invalid( + f"Duplicate meter_number {number}: used by both " + f"'{seen[number]}' and '{meter[CONF_ID]}'. Each meter needs " + "a unique meter_number; it is what the server identifies the " + "tap by." + ) + seen[number] = meter[CONF_ID] + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(KegboardMeter), + cv.GenerateID(CONF_KEGBOARD_ID): cv.use_id(KegboardHub), + cv.Required(CONF_PIN): pins.internal_gpio_input_pin_schema, + # The protocol's meter number: (device, meter_number) is how the + # server identifies a tap. Unrelated to the YAML `id`, which is a + # config-internal reference and never reported anywhere. + cv.Optional(CONF_METER_NUMBER, default=0): cv.uint8_t, + cv.Optional( + CONF_ML_PER_TICK, default=DEFAULT_ML_PER_TICK + ): positive_nonzero_float, + cv.Optional( + CONF_DEBOUNCE, default="1200us" + ): cv.positive_time_period_microseconds, + cv.Optional( + CONF_IDLE_TIMEOUT, default="10s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_MIN_POUR_TICKS, default=3): cv.uint32_t, + # Zero disables the cutoff entirely. + cv.Optional( + CONF_MAX_POUR_DURATION, default="5min" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_REPORT_INTERVAL, default="250ms" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_SERIES_RESOLUTION, default="100ms" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_TOTAL): sensor.sensor_schema( + unit_of_measurement=UNIT_TICKS, + icon=ICON_PULSE, + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + ), + cv.Optional(CONF_VOLUME): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLILITER, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_FLOW_RATE): sensor.sensor_schema( + unit_of_measurement=UNIT_MILLILITER_PER_MINUTE, + accuracy_decimals=1, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POURING): binary_sensor.binary_sensor_schema(), + cv.Optional(CONF_ON_POUR_START): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PourStartTrigger)} + ), + cv.Optional(CONF_ON_POUR_END): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PourEndTrigger)} + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + hub = await cg.get_variable(config[CONF_KEGBOARD_ID]) + cg.add(var.set_hub(hub)) + + pin = await cg.gpio_pin_expression(config[CONF_PIN]) + cg.add(var.set_pin(pin)) + + cg.add(var.set_meter_number(config[CONF_METER_NUMBER])) + + cg.add(var.set_ml_per_tick(config[CONF_ML_PER_TICK])) + cg.add(var.set_filter_us(config[CONF_DEBOUNCE])) + cg.add(var.set_idle_timeout_ms(config[CONF_IDLE_TIMEOUT])) + cg.add(var.set_min_pour_ticks(config[CONF_MIN_POUR_TICKS])) + cg.add(var.set_max_duration_ms(config[CONF_MAX_POUR_DURATION])) + cg.add(var.set_report_interval_ms(config[CONF_REPORT_INTERVAL])) + cg.add(var.set_series_resolution_ms(config[CONF_SERIES_RESOLUTION])) + + if CONF_TOTAL in config: + cg.add(var.set_total_sensor(await sensor.new_sensor(config[CONF_TOTAL]))) + if CONF_VOLUME in config: + cg.add(var.set_volume_sensor(await sensor.new_sensor(config[CONF_VOLUME]))) + if CONF_FLOW_RATE in config: + cg.add( + var.set_flow_rate_sensor(await sensor.new_sensor(config[CONF_FLOW_RATE])) + ) + if CONF_POURING in config: + cg.add( + var.set_pouring_binary_sensor( + await binary_sensor.new_binary_sensor(config[CONF_POURING]) + ) + ) + + for conf in config.get(CONF_ON_POUR_START, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_pour_start_trigger(trigger)) + await automation.build_automation(trigger, [], conf) + + for conf in config.get(CONF_ON_POUR_END, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_pour_end_trigger(trigger)) + await automation.build_automation( + trigger, + [ + (cg.uint32, "ticks"), + (cg.float_, "volume_ml"), + (cg.uint32, "duration_ms"), + ], + conf, + ) + + +METER_ACTION_SCHEMA = automation.maybe_simple_id( + {cv.GenerateID(): cv.use_id(KegboardMeter)} +) + + +@automation.register_action( + "kegboard_meter.reset_total", + ResetTotalAction, + METER_ACTION_SCHEMA, + synchronous=True, +) +@automation.register_action( + "kegboard_meter.end_pour", EndPourAction, METER_ACTION_SCHEMA, synchronous=True +) +async def meter_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "kegboard_meter.set_calibration", + SetCalibrationAction, + cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(KegboardMeter), + cv.Required(CONF_VALUE): cv.templatable(cv.positive_float), + } + ), + synchronous=True, +) +async def set_calibration_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_VALUE], args, cg.float_) + cg.add(var.set_ml_per_tick(template_)) + return var diff --git a/components/kegboard_meter/kegboard_meter.cpp b/components/kegboard_meter/kegboard_meter.cpp new file mode 100644 index 0000000..a4cb033 --- /dev/null +++ b/components/kegboard_meter/kegboard_meter.cpp @@ -0,0 +1,157 @@ +#include "kegboard_meter.h" + +#include "esphome/components/kegboard/events.h" + +#include + +#include "esphome/core/hal.h" +#include "esphome/core/log.h" + +namespace esphome::kegboard_meter { + +static const char *const TAG = "kegboard_meter"; + +void KegboardMeter::gpio_intr(KegboardMeter *meter) { + const uint32_t now = micros(); + + // Flow meter reed and hall sensors bounce. Anything arriving sooner than the + // filter window is the same edge ringing, and counting it would inflate the + // pour. Unsigned subtraction keeps this correct across the micros() wrap. + if (now - meter->last_edge_us_ < meter->filter_us_) + return; + + meter->last_edge_us_ = now; + meter->isr_ticks_++; +} + +void KegboardMeter::setup() { + this->session_.set_config(this->pour_config_); + this->session_.set_series_resolution_ms(this->series_resolution_ms_); + + this->pin_->setup(); + this->last_edge_us_ = micros(); + this->pin_->attach_interrupt(KegboardMeter::gpio_intr, this, gpio::INTERRUPT_FALLING_EDGE); + + this->last_report_ms_ = millis(); + this->publish_state_(true); +} + +uint32_t KegboardMeter::take_isr_ticks_() { + uint32_t ticks; + { +#ifndef USE_HOST + // Guarded because the host platform has no interrupts to mask, and so no + // InterruptLock implementation. There the "ISR" never runs either, so the + // unlocked read is equally correct. + InterruptLock lock; +#endif + ticks = this->isr_ticks_; + this->isr_ticks_ = 0; + } + return ticks; +} + +void KegboardMeter::set_ml_per_tick(float v) { + this->pour_config_.ml_per_tick = v; + this->session_.set_ml_per_tick(v); +} + +void KegboardMeter::reset_total() { + this->session_.reset_total(); + this->publish_state_(true); +} + +void KegboardMeter::end_pour() { + kbcore::PourRecord record; + if (this->session_.end_now(millis(), &record)) + this->handle_pour_end_(record); + this->publish_state_(true); +} + +void KegboardMeter::loop() { + const uint32_t ticks = this->take_isr_ticks_(); + const uint32_t now_ms = millis(); + + if (ticks > 0) { + this->ticks_since_report_ += ticks; + if (this->session_.add_ticks(ticks, now_ms, this->hub_ != nullptr ? this->hub_->now_unix() : 0)) { + uint8_t random[16]; + random_bytes(random, sizeof(random)); + this->pour_id_ = kbcore::format_uuid4(random); + ESP_LOGD(TAG, "meter %u: pour started (%s)", this->meter_number_, this->pour_id_.c_str()); + for (auto *trigger : this->pour_start_triggers_) + trigger->trigger(); + this->publish_state_(true); + } + } + + kbcore::PourRecord record; + if (this->session_.poll(now_ms, &record)) { + this->handle_pour_end_(record); + this->publish_state_(true); + return; + } + + this->publish_state_(false); +} + +void KegboardMeter::handle_pour_end_(const kbcore::PourRecord &record) { + ESP_LOGI(TAG, "meter %u: pour ended, %" PRIu32 " ticks (%.1f mL) in %" PRIu32 " ms", this->meter_number_, + record.ticks, record.volume_ml, record.duration_ms); + + for (auto *trigger : this->pour_end_triggers_) + trigger->trigger(record.ticks, record.volume_ml, record.duration_ms); + + this->pour_callbacks_.call(*this, record); +} + +void KegboardMeter::publish_state_(bool force) { + const uint32_t now_ms = millis(); + const bool pouring = this->session_.is_pouring(); + + // A pour starting or ending is always worth reporting immediately; the + // stream of updates during a pour is throttled so a fast meter does not + // flood the API connection. + if (pouring != this->was_pouring_) + force = true; + + if (!force && (now_ms - this->last_report_ms_) < this->report_interval_ms_) + return; + + const uint32_t elapsed_ms = now_ms - this->last_report_ms_; + this->last_report_ms_ = now_ms; + this->was_pouring_ = pouring; + + if (this->total_sensor_ != nullptr) + this->total_sensor_->publish_state(this->session_.total_ticks()); + + if (this->volume_sensor_ != nullptr) + this->volume_sensor_->publish_state(this->session_.session_volume_ml()); + + if (this->flow_rate_sensor_ != nullptr) { + float rate = 0.0f; + if (elapsed_ms > 0 && this->ticks_since_report_ > 0) + rate = (this->ticks_since_report_ * this->pour_config_.ml_per_tick) * (60000.0f / elapsed_ms); + this->flow_rate_sensor_->publish_state(rate); + } + this->ticks_since_report_ = 0; + + if (this->pouring_sensor_ != nullptr) + this->pouring_sensor_->publish_state(pouring); +} + +void KegboardMeter::dump_config() { + ESP_LOGCONFIG(TAG, "Kegboard Meter %u:", this->meter_number_); + LOG_PIN(" Pin: ", this->pin_); + ESP_LOGCONFIG(TAG, " Debounce: %" PRIu32 " us", this->filter_us_); + ESP_LOGCONFIG(TAG, " Calibration: %.4f mL/tick", this->pour_config_.ml_per_tick); + ESP_LOGCONFIG(TAG, " Idle timeout: %" PRIu32 " ms", this->pour_config_.idle_timeout_ms); + ESP_LOGCONFIG(TAG, " Minimum pour: %" PRIu32 " ticks", this->pour_config_.min_pour_ticks); + ESP_LOGCONFIG(TAG, " Maximum duration: %" PRIu32 " ms", this->pour_config_.max_duration_ms); + LOG_SENSOR(" ", "Total", this->total_sensor_); + LOG_SENSOR(" ", "Volume", this->volume_sensor_); + LOG_SENSOR(" ", "Flow rate", this->flow_rate_sensor_); + LOG_BINARY_SENSOR(" ", "Pouring", this->pouring_sensor_); +} + +} // namespace esphome::kegboard_meter diff --git a/components/kegboard_meter/kegboard_meter.h b/components/kegboard_meter/kegboard_meter.h new file mode 100644 index 0000000..0940531 --- /dev/null +++ b/components/kegboard_meter/kegboard_meter.h @@ -0,0 +1,157 @@ +#pragma once + +#include +#include + +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/kegboard/kegboard.h" +#include "esphome/components/kegboard/pour_session.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#include "esphome/core/gpio.h" +#include "esphome/core/helpers.h" + +namespace esphome::kegboard_meter { + +class PourStartTrigger : public Trigger<> {}; + +/// Fires with (ticks, volume_ml, duration_ms). +class PourEndTrigger : public Trigger {}; + +/// One flow meter: counts pulses and turns them into pours. +/// +/// Counting happens in an ISR; everything else runs in the main loop, where +/// the accumulated ticks are handed to a kbcore::PourSession that owns all the +/// actual pour-detection logic. Keeping the state machine out of here is what +/// lets it be unit tested on a host. +class KegboardMeter : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::DATA; } + + void set_hub(kegboard::KegboardHub *hub) { this->hub_ = hub; } + void set_pin(InternalGPIOPin *pin) { this->pin_ = pin; } + void set_meter_number(uint8_t meter_number) { this->meter_number_ = meter_number; } + void set_filter_us(uint32_t filter_us) { this->filter_us_ = filter_us; } + void set_report_interval_ms(uint32_t interval) { this->report_interval_ms_ = interval; } + + void set_idle_timeout_ms(uint32_t v) { this->pour_config_.idle_timeout_ms = v; } + void set_min_pour_ticks(uint32_t v) { this->pour_config_.min_pour_ticks = v; } + void set_max_duration_ms(uint32_t v) { this->pour_config_.max_duration_ms = v; } + void set_ml_per_tick(float v); + void set_series_resolution_ms(uint32_t v) { this->series_resolution_ms_ = v; } + + void set_total_sensor(sensor::Sensor *s) { this->total_sensor_ = s; } + void set_volume_sensor(sensor::Sensor *s) { this->volume_sensor_ = s; } + void set_flow_rate_sensor(sensor::Sensor *s) { this->flow_rate_sensor_ = s; } + void set_pouring_binary_sensor(binary_sensor::BinarySensor *s) { this->pouring_sensor_ = s; } + + void add_on_pour_start_trigger(Trigger<> *trigger) { this->pour_start_triggers_.push_back(trigger); } + void add_on_pour_end_trigger(Trigger *trigger) { + this->pour_end_triggers_.push_back(trigger); + } + + /// Called with every completed pour. The meter reference gives access to + /// pour_id, meter number, and attribution at pour end. Reporters subscribe here + /// rather than being wired through YAML automations, so a pour cannot be + /// silently dropped by a missing `on_pour_end:` block. + void add_on_pour_callback(std::function &&callback) { + this->pour_callbacks_.add(std::move(callback)); + } + + /// Attribution for pours on this meter, set by kegboard_auth while a grant + /// is active; cleared (all empty) when none, making the pour a guest + /// pour. + void set_active_auth(const std::string &grant_id, const std::string &auth_device, const std::string &token) { + this->active_grant_id_ = grant_id; + this->active_auth_device_ = auth_device; + this->active_auth_token_ = token; + } + void clear_active_auth() { this->set_active_auth("", "", ""); } + const std::string &active_grant_id() const { return this->active_grant_id_; } + const std::string &active_auth_device() const { return this->active_auth_device_; } + const std::string &active_auth_token() const { return this->active_auth_token_; } + + bool is_pouring() const { return this->session_.is_pouring(); } + + /// The protocol's meter number: `(device, meter_number)` identifies a tap + /// server-side. Unrelated to the YAML `id`, which is never on the wire. + uint8_t meter_number() const { return this->meter_number_; } + + /// Protocol pour id of the in-progress (or just-ended, during the pour + /// callback) pour. Generated fresh at each pour start. + const std::string &pour_id() const { return this->pour_id_; } + + /// Live pour figures for pour_update events. + float session_volume_ml() const { return this->session_.session_volume_ml(); } + uint32_t session_duration_ms(uint32_t now_ms) const { return this->session_.session_duration_ms(now_ms); } + + float ml_per_tick() const { return this->pour_config_.ml_per_tick; } + uint32_t total_ticks() const { return this->session_.total_ticks(); } + + void reset_total(); + + /// End any pour in progress immediately, reporting it if it qualifies. + void end_pour(); + + protected: + static void gpio_intr(KegboardMeter *meter); + + uint32_t take_isr_ticks_(); + void publish_state_(bool force); + void handle_pour_end_(const kbcore::PourRecord &record); + + kegboard::KegboardHub *hub_{nullptr}; + InternalGPIOPin *pin_{nullptr}; + uint8_t meter_number_{0}; + + kbcore::PourConfig pour_config_; + kbcore::PourSession session_{kbcore::PourConfig{}}; + uint32_t series_resolution_ms_{kbcore::TickSeries::DEFAULT_RESOLUTION_MS}; + + sensor::Sensor *total_sensor_{nullptr}; + sensor::Sensor *volume_sensor_{nullptr}; + sensor::Sensor *flow_rate_sensor_{nullptr}; + binary_sensor::BinarySensor *pouring_sensor_{nullptr}; + + std::string active_grant_id_; + std::string active_auth_device_; + std::string active_auth_token_; + std::string pour_id_; + + std::vector *> pour_start_triggers_; + std::vector *> pour_end_triggers_; + CallbackManager pour_callbacks_; + + // Written by the ISR, drained by loop() under an InterruptLock. + volatile uint32_t isr_ticks_{0}; + volatile uint32_t last_edge_us_{0}; + uint32_t filter_us_{1200}; + + uint32_t report_interval_ms_{250}; + uint32_t last_report_ms_{0}; + uint32_t ticks_since_report_{0}; + bool was_pouring_{false}; +}; + +template class ResetTotalAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->reset_total(); } +}; + +template class EndPourAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->end_pour(); } +}; + +template class SetCalibrationAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(float, ml_per_tick) + + void play(const Ts &...x) override { this->parent_->set_ml_per_tick(this->ml_per_tick_.value(x...)); } +}; + +} // namespace esphome::kegboard_meter diff --git a/components/kegboard_onewire/__init__.py b/components/kegboard_onewire/__init__.py new file mode 100644 index 0000000..da7743b --- /dev/null +++ b/components/kegboard_onewire/__init__.py @@ -0,0 +1,63 @@ +"""iButton presence on a 1-Wire bus. + +ESPHome's one_wire bus enumerates devices but has no arrive/leave events, +which is the entire point of an iButton reader. This adds them. +""" + +from esphome import automation +import esphome.codegen as cg +from esphome.components.one_wire import CONF_ONE_WIRE_ID, OneWireBus +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_TRIGGER_ID + +CODEOWNERS = ["@mikey"] +DEPENDENCIES = ["one_wire"] +MULTI_CONF = True + +CONF_MAX_MISSED_SEARCHES = "max_missed_searches" +CONF_ON_TOKEN_ATTACHED = "on_token_attached" +CONF_ON_TOKEN_DETACHED = "on_token_detached" + +kegboard_onewire_ns = cg.esphome_ns.namespace("kegboard_onewire") +KegboardOneWire = kegboard_onewire_ns.class_("KegboardOneWire", cg.PollingComponent) + +TokenAttachedTrigger = kegboard_onewire_ns.class_( + "TokenAttachedTrigger", automation.Trigger.template(cg.std_string) +) +TokenDetachedTrigger = kegboard_onewire_ns.class_( + "TokenDetachedTrigger", automation.Trigger.template(cg.std_string) +) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(KegboardOneWire), + cv.GenerateID(CONF_ONE_WIRE_ID): cv.use_id(OneWireBus), + # A held iButton makes intermittent contact, so a single missed search + # is normal. Four matches the AVR firmware. + cv.Optional(CONF_MAX_MISSED_SEARCHES, default=4): cv.uint8_t, + cv.Optional(CONF_ON_TOKEN_ATTACHED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TokenAttachedTrigger)} + ), + cv.Optional(CONF_ON_TOKEN_DETACHED): automation.validate_automation( + {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TokenDetachedTrigger)} + ), + } +).extend(cv.polling_component_schema("1s")) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_bus(await cg.get_variable(config[CONF_ONE_WIRE_ID]))) + cg.add(var.set_max_missed_searches(config[CONF_MAX_MISSED_SEARCHES])) + + for conf in config.get(CONF_ON_TOKEN_ATTACHED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_attached_trigger(trigger)) + await automation.build_automation(trigger, [(cg.std_string, "x")], conf) + + for conf in config.get(CONF_ON_TOKEN_DETACHED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID]) + cg.add(var.add_on_detached_trigger(trigger)) + await automation.build_automation(trigger, [(cg.std_string, "x")], conf) diff --git a/components/kegboard_onewire/kegboard_onewire.cpp b/components/kegboard_onewire/kegboard_onewire.cpp new file mode 100644 index 0000000..6878290 --- /dev/null +++ b/components/kegboard_onewire/kegboard_onewire.cpp @@ -0,0 +1,79 @@ +#include "kegboard_onewire.h" + +#include +#include + +#include "esphome/core/log.h" + +namespace esphome::kegboard_onewire { + +static const char *const TAG = "kegboard_onewire"; + +void KegboardOneWire::setup() { + if (this->bus_ == nullptr) { + this->mark_failed(); + return; + } +} + +std::string KegboardOneWire::format_token_(uint64_t address) { + char buf[17]; + snprintf(buf, sizeof(buf), "%016" PRIx64, address); + return std::string(buf); +} + +void KegboardOneWire::update() { + this->bus_->search(); + const std::vector &found = this->bus_->get_devices(); + + // Age every known device, then clear the counter for any still present. A + // device seen this round is at zero misses; one absent for too many rounds + // is gone. + for (auto &entry : this->present_) + entry.misses++; + + for (uint64_t address : found) { + if (address == 0) + continue; + + bool known = false; + for (auto &entry : this->present_) { + if (entry.address == address) { + entry.misses = 0; + known = true; + break; + } + } + if (known) + continue; + + this->present_.push_back(Entry{address, 0}); + const std::string token = format_token_(address); + ESP_LOGI(TAG, "Token attached: %s", token.c_str()); + for (auto *trigger : this->attached_triggers_) + trigger->trigger(token); + } + + for (auto it = this->present_.begin(); it != this->present_.end();) { + if (it->misses <= this->max_missed_searches_) { + ++it; + continue; + } + const std::string token = format_token_(it->address); + ESP_LOGI(TAG, "Token detached: %s", token.c_str()); + for (auto *trigger : this->detached_triggers_) + trigger->trigger(token); + it = this->present_.erase(it); + } +} + +void KegboardOneWire::dump_config() { + ESP_LOGCONFIG(TAG, "Kegboard 1-Wire auth:"); + ESP_LOGCONFIG(TAG, " Missed searches before detach: %u", this->max_missed_searches_); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, " No 1-Wire bus configured"); + } +} + +} // namespace esphome::kegboard_onewire diff --git a/components/kegboard_onewire/kegboard_onewire.h b/components/kegboard_onewire/kegboard_onewire.h new file mode 100644 index 0000000..21c3fa4 --- /dev/null +++ b/components/kegboard_onewire/kegboard_onewire.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include + +#include "esphome/components/one_wire/one_wire_bus.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" + +namespace esphome::kegboard_onewire { + +/// Fires with the token id as a lowercase hex string. +class TokenAttachedTrigger : public Trigger {}; +class TokenDetachedTrigger : public Trigger {}; + +/// Tracks iButtons touched to a 1-Wire bus. +/// +/// ESPHome's one_wire bus can enumerate devices, but has no notion of a device +/// arriving or leaving, which is the whole point of an iButton reader. This +/// adds that: repeated searches, with an appearance reported once and a +/// disappearance only after several consecutive misses. +/// +/// The miss counter matters more than it looks. A finger-held iButton makes +/// intermittent contact, and a single dropped search is normal; reporting a +/// detach on the first miss would make a held token flap between attached and +/// detached several times a second. The AVR firmware used four missed searches +/// and that number has a decade of beer behind it, so it is the default here. +class KegboardOneWire : public PollingComponent { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_bus(one_wire::OneWireBus *bus) { this->bus_ = bus; } + void set_max_missed_searches(uint8_t count) { this->max_missed_searches_ = count; } + + void add_on_attached_trigger(TokenAttachedTrigger *trigger) { this->attached_triggers_.push_back(trigger); } + void add_on_detached_trigger(TokenDetachedTrigger *trigger) { this->detached_triggers_.push_back(trigger); } + + /// Device name reported alongside the token, matching the legacy firmware's + /// value so an existing Kegbot Server recognises the tokens. + static const char *device_name() { return "onewire"; } + + protected: + struct Entry { + uint64_t address; + uint8_t misses; + }; + + /// Kegbot stores iButton tokens as the 16-hex-digit ROM code. + static std::string format_token_(uint64_t address); + + one_wire::OneWireBus *bus_{nullptr}; + uint8_t max_missed_searches_{4}; + std::vector present_; + + std::vector attached_triggers_; + std::vector detached_triggers_; +}; + +} // namespace esphome::kegboard_onewire diff --git a/components/kegboard_reporter/__init__.py b/components/kegboard_reporter/__init__.py new file mode 100644 index 0000000..b40b7cd --- /dev/null +++ b/components/kegboard_reporter/__init__.py @@ -0,0 +1,156 @@ +"""Kegboard Event Protocol reporter. + +Speaks docs/kegboard-event-protocol.md: batched events over a single HTTP +endpoint, pairing, and server command dispatch. +""" + +import esphome.codegen as cg +from esphome.components import sensor, switch, time +from esphome.components.http_request import ( + CONF_HTTP_REQUEST_ID, + HttpRequestComponent, +) +import esphome.config_validation as cv +from esphome.const import ( + CONF_ID, + CONF_NAME, + CONF_SENSOR, + CONF_TIME_ID, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + STATE_CLASS_TOTAL_INCREASING, +) + +from ..kegboard import CONF_KEGBOARD_ID, KegboardHub +from ..kegboard_meter import KegboardMeter + +CODEOWNERS = ["@mikey"] +DEPENDENCIES = ["kegboard", "http_request", "time", "json"] +AUTO_LOAD = ["sensor"] + +CONF_REPORTING_URL = "reporting_url" +CONF_DROPPED = "dropped" +CONF_HEARTBEAT_INTERVAL = "heartbeat_interval" +CONF_METERS = "meters" +CONF_POUR_UPDATE_INTERVAL = "pour_update_interval" +CONF_QUEUE_DEPTH = "queue_depth" +CONF_RELAY = "relay" +CONF_RELAY_NUMBER = "relay_number" +CONF_RELAYS = "relays" +CONF_RETRY_INTERVAL = "retry_interval" +CONF_THERMO_SENSORS = "thermo_sensors" + +kegboard_reporter_ns = cg.esphome_ns.namespace("kegboard_reporter") +KegboardReporter = kegboard_reporter_ns.class_("KegboardReporter", cg.Component) + + +def validate_reporting_url(value): + value = cv.url(value) + if not value.startswith(("http://", "https://")): + raise cv.Invalid("Reporting URL must start with http:// or https://") + return value + + +THERMO_SENSOR_SCHEMA = cv.Schema( + { + cv.Required(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Required(CONF_NAME): cv.string_strict, + } +) + +RELAY_SCHEMA = cv.Schema( + { + # The protocol's relay number: reported in the status inventory and + # the target of grant relay sets (and, later, set_relay). + cv.Required(CONF_RELAY_NUMBER): cv.int_range(min=0, max=255), + cv.Required(CONF_RELAY): cv.use_id(switch.Switch), + } +) + + +def _validate_unique_relay_numbers(relays): + seen = set() + for relay in relays: + number = relay[CONF_RELAY_NUMBER] + if number in seen: + raise cv.Invalid(f"Duplicate relay_number {number}") + seen.add(number) + return relays + + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(KegboardReporter), + cv.GenerateID(CONF_KEGBOARD_ID): cv.use_id(KegboardHub), + cv.GenerateID(CONF_HTTP_REQUEST_ID): cv.use_id(HttpRequestComponent), + cv.GenerateID(CONF_TIME_ID): cv.use_id(time.RealTimeClock), + # Full URL, path included, e.g. + # https://kegbot.example.com/api/kegboard-event + # No credential is configured: the device provisions its own bearer + # token by pairing via the server dashboard. + cv.Required(CONF_REPORTING_URL): validate_reporting_url, + cv.Optional(CONF_METERS, default=[]): cv.ensure_list(cv.use_id(KegboardMeter)), + cv.Optional(CONF_RELAYS, default=[]): cv.All( + cv.ensure_list(RELAY_SCHEMA), _validate_unique_relay_numbers + ), + cv.Optional(CONF_THERMO_SENSORS, default=[]): cv.ensure_list( + THERMO_SENSOR_SCHEMA + ), + # The status schema requires heartbeat_ms >= 1000. + cv.Optional(CONF_HEARTBEAT_INTERVAL, default="60s"): cv.All( + cv.positive_time_period_milliseconds, + cv.Range(min=cv.TimePeriod(seconds=1)), + ), + # 0s disables pour_update events. + cv.Optional( + CONF_POUR_UPDATE_INTERVAL, default="1s" + ): cv.positive_time_period_milliseconds, + cv.Optional( + CONF_RETRY_INTERVAL, default="30s" + ): cv.positive_time_period_milliseconds, + cv.Optional(CONF_QUEUE_DEPTH): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_DROPPED): sensor.sensor_schema( + accuracy_decimals=0, + state_class=STATE_CLASS_TOTAL_INCREASING, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +).extend(cv.COMPONENT_SCHEMA) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + cg.add(var.set_hub(await cg.get_variable(config[CONF_KEGBOARD_ID]))) + cg.add(var.set_http_request(await cg.get_variable(config[CONF_HTTP_REQUEST_ID]))) + cg.add(var.set_time(await cg.get_variable(config[CONF_TIME_ID]))) + + cg.add(var.set_reporting_url(config[CONF_REPORTING_URL])) + cg.add(var.set_heartbeat_interval_ms(config[CONF_HEARTBEAT_INTERVAL])) + cg.add(var.set_pour_update_interval_ms(config[CONF_POUR_UPDATE_INTERVAL])) + cg.add(var.set_retry_interval_ms(config[CONF_RETRY_INTERVAL])) + + for meter_id in config[CONF_METERS]: + cg.add(var.add_meter(await cg.get_variable(meter_id))) + + for conf in config[CONF_RELAYS]: + relay = await cg.get_variable(conf[CONF_RELAY]) + cg.add(var.add_relay(conf[CONF_RELAY_NUMBER], relay)) + + for conf in config[CONF_THERMO_SENSORS]: + thermo = await cg.get_variable(conf[CONF_SENSOR]) + cg.add(var.add_thermo_sensor(thermo, conf[CONF_NAME])) + + if CONF_QUEUE_DEPTH in config: + cg.add( + var.set_queue_depth_sensor( + await sensor.new_sensor(config[CONF_QUEUE_DEPTH]) + ) + ) + if CONF_DROPPED in config: + cg.add(var.set_dropped_sensor(await sensor.new_sensor(config[CONF_DROPPED]))) diff --git a/components/kegboard_reporter/kegboard_reporter.cpp b/components/kegboard_reporter/kegboard_reporter.cpp new file mode 100644 index 0000000..8120a30 --- /dev/null +++ b/components/kegboard_reporter/kegboard_reporter.cpp @@ -0,0 +1,465 @@ +#include "kegboard_reporter.h" + +#include +#include +#include +#include + +#include "esphome/core/application.h" +#include "esphome/core/hal.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#ifdef USE_WIFI +#include "esphome/components/wifi/wifi_component.h" +#endif + +namespace esphome::kegboard_reporter { + +static const char *const TAG = "kegboard_reporter"; + +/// Fixed-size flash slot for the provisioned bearer token. +struct TokenStore { + char token[96]; +}; + +void KegboardReporter::setup() { + this->boot_id_ = kbcore::format_boot_id(random_uint32()); + + // Give the rest of the firmware a wall clock without making `time` a + // dependency of every component. See KegboardHub::set_clock_source(). + if (this->hub_ != nullptr && this->time_ != nullptr) { + auto *clock = this->time_; + this->hub_->set_clock_source([clock]() -> uint32_t { + auto now = clock->now(); + return now.is_valid() ? static_cast(now.timestamp) : 0; + }); + } + + this->load_token_(); + + this->delivery_.pairing_started(millis()); + this->next_heartbeat_ms_ = millis() + this->heartbeat_ms_; + this->enqueue_status_(true); + this->publish_diagnostics_(); +} + +void KegboardReporter::load_token_() { + this->token_pref_ = global_preferences->make_preference(fnv1_hash("kegboard_bearer_token")); + TokenStore store{}; + if (this->token_pref_.load(&store)) { + store.token[sizeof(store.token) - 1] = '\0'; + this->bearer_token_ = store.token; + if (!this->bearer_token_.empty()) + ESP_LOGI(TAG, "Loaded provisioned token from flash"); + } +} + +void KegboardReporter::save_token_(const std::string &token) { + TokenStore store{}; + if (token.size() >= sizeof(store.token)) { + ESP_LOGE(TAG, "Provisioned token too long (%u bytes); not saving", static_cast(token.size())); + return; + } + memcpy(store.token, token.c_str(), token.size() + 1); + this->token_pref_.save(&store); + global_preferences->sync(); +} + +kbcore::Event KegboardReporter::make_event_(const char *type, std::string data_json) { + kbcore::Event e; + e.id = this->next_event_id_++; + e.type = type; + e.created_ms = millis(); + e.time = this->rfc3339_now_(); + e.data_json = std::move(data_json); + return e; +} + +std::string KegboardReporter::rfc3339_now_() { + const uint32_t epoch = this->hub_ != nullptr ? this->hub_->now_unix() : 0; + if (epoch == 0) + return ""; + time_t t = epoch; + struct tm tm_utc; + gmtime_r(&t, &tm_utc); + char buf[24]; + strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm_utc); + return std::string(buf); +} + +void KegboardReporter::enqueue_(kbcore::Event &&event, bool reset_backoff) { + if (!this->queue_.push(event)) + ESP_LOGW(TAG, "Event queue full; dropped the oldest event (%" PRIu32 " total)", this->queue_.dropped()); + // A pour or token event is worth an immediate attempt even mid-backoff; + // status heartbeats and command acks wait their turn. + this->delivery_.note_enqueue(reset_backoff, millis()); + this->publish_diagnostics_(); +} + +void KegboardReporter::add_meter(kegboard_meter::KegboardMeter *meter) { + this->meters_.push_back(MeterState{meter, 0, ""}); + meter->add_on_pour_callback([this](kegboard_meter::KegboardMeter &m, const kbcore::PourRecord &record) { + kbcore::PourData d; + d.meter = m.meter_number(); + d.pour_id = m.pour_id(); + d.volume_ml = record.volume_ml; + d.duration_ms = record.duration_ms; + d.auth_device = m.active_auth_device(); + d.auth_token = m.active_auth_token(); + d.grant_id = m.active_grant_id(); + d.ticks = record.ticks; + d.ml_per_tick = m.ml_per_tick(); + d.tick_series = record.series.to_string(); + this->enqueue_(this->make_event_("pour", kbcore::pour_data_json(d))); + }); +} + +void KegboardReporter::add_relay(uint8_t relay_number, switch_::Switch *relay) { + this->relays_.push_back(RelayEntry{relay_number, relay}); +} + +kegboard_meter::KegboardMeter *KegboardReporter::meter_by_number(uint8_t meter_number) const { + for (const auto &state : this->meters_) { + if (state.meter->meter_number() == meter_number) + return state.meter; + } + return nullptr; +} + +switch_::Switch *KegboardReporter::relay_by_number(uint8_t relay_number) const { + for (const auto &entry : this->relays_) { + if (entry.relay_number == relay_number) + return entry.relay; + } + return nullptr; +} + +bool KegboardReporter::has_relay(uint8_t relay_number) const { return this->relay_by_number(relay_number) != nullptr; } + +std::vector KegboardReporter::meter_list() const { + std::vector meters; + meters.reserve(this->meters_.size()); + for (const auto &state : this->meters_) + meters.push_back(state.meter); + return meters; +} + +void KegboardReporter::add_thermo_sensor(sensor::Sensor *sensor, const std::string &name) { + sensor->add_on_state_callback([this, name](float value) { + if (std::isnan(value)) + return; + // Periodic readings must not reset backoff โ€” only pours and tokens do. + // A steadily-sampling sensor would otherwise hammer a down server at + // its own cadence forever. + this->enqueue_(this->make_event_("temperature", kbcore::temperature_data_json(name, value)), false); + }); +} + +bool KegboardReporter::send_token_ask(const std::string &auth_device, const std::string &token) { + this->enqueue_(this->make_event_("token", kbcore::token_data_json(auth_device, token, true))); + // The authorization decision rides the response to this send; commands are + // dispatched inside send_batch_() before it returns. + return this->send_batch_({}); +} + +void KegboardReporter::queue_token_event(const std::string &auth_device, const std::string &token, bool attached) { + this->enqueue_(this->make_event_("token", kbcore::token_data_json(auth_device, token, attached))); +} + +void KegboardReporter::queue_grant_end(const kbcore::GrantEnd &end) { + this->enqueue_(this->make_event_("grant_end", kbcore::grant_end_data_json(end)), false); +} + +void KegboardReporter::enqueue_status_(bool boot) { + kbcore::StatusData d; + d.boot = boot; + d.fw_version = this->hub_ != nullptr ? this->hub_->version() : "unknown"; + d.uptime_ms = millis(); + d.events_dropped = this->queue_.dropped() + this->extra_dropped_; + d.heartbeat_ms = this->heartbeat_ms_; + d.pour_update_ms = this->pour_update_ms_; + d.queue_capacity = this->queue_.capacity(); +#ifdef USE_WIFI + if (wifi::global_wifi_component != nullptr) { + d.has_rssi = true; + d.rssi_dbm = wifi::global_wifi_component->wifi_rssi(); + } +#endif + for (const auto &state : this->meters_) { + kbcore::StatusMeter m; + m.meter = state.meter->meter_number(); + m.total_ticks = state.meter->total_ticks(); + m.ml_per_tick = state.meter->ml_per_tick(); + d.meters.push_back(m); + } + for (const auto &entry : this->relays_) + d.relays.push_back(entry.relay_number); + this->enqueue_(this->make_event_("status", kbcore::status_data_json(d)), false); +} + +void KegboardReporter::collect_pour_updates_(std::vector &out) { + if (this->pour_update_ms_ == 0 || !this->healthy_()) + return; + + const uint32_t now = millis(); + for (auto &state : this->meters_) { + if (!state.meter->is_pouring()) + continue; + const std::string &pour_id = state.meter->pour_id(); + const bool new_pour = pour_id != state.last_update_pour_id; + if (!new_pour && (now - state.last_update_ms) < this->pour_update_ms_) + continue; + + state.last_update_ms = now; + state.last_update_pour_id = pour_id; + + out.push_back(this->make_event_( + "pour_update", + kbcore::pour_update_data_json(state.meter->meter_number(), pour_id, state.meter->session_volume_ml(), + state.meter->session_duration_ms(now)))); + } +} + +void KegboardReporter::loop() { + if (this->delivery_.denied() || this->reporting_url_.empty() || this->http_ == nullptr) + return; + + const uint32_t now = millis(); + + if (static_cast(now - this->next_heartbeat_ms_) >= 0) { + this->next_heartbeat_ms_ = now + this->heartbeat_ms_; + this->enqueue_status_(false); + } + + std::vector updates; + this->collect_pour_updates_(updates); + + if (!updates.empty() || (this->delivery_.due(now) && !this->queue_.empty())) { + this->send_batch_(std::move(updates)); + this->publish_diagnostics_(); + } +} + +bool KegboardReporter::send_batch_(std::vector &&ephemeral) { + if (this->delivery_.denied() || this->reporting_url_.empty() || this->http_ == nullptr) + return false; + // Queued events first (oldest-first), then ephemeral updates in whatever + // room remains. Ephemeral events are never queued: if this send fails they + // are simply gone, per protocol. + std::vector batch; + size_t queued_in_batch = 0; + for (size_t i = 0; i < this->queue_.size() && batch.size() < kbcore::MAX_BATCH_EVENTS; i++) { + batch.push_back(this->queue_.at(i)); + queued_in_batch++; + } + for (const auto &e : ephemeral) { + if (batch.size() >= kbcore::MAX_BATCH_EVENTS) + break; + batch.push_back(&e); + } + if (batch.empty()) + return true; + + const std::string body = kbcore::serialize_batch(this->hub_ != nullptr ? this->hub_->serial_number() : "kegboard", + this->boot_id_, millis(), batch); + + ESP_LOGVV(TAG, "POST %s\n%s", this->reporting_url_.c_str(), body.c_str()); + + std::vector headers; + headers.push_back({"Content-Type", "application/json"}); + if (this->is_paired()) + headers.push_back({"Authorization", "Bearer " + this->bearer_token_}); + + auto container = this->http_->post(this->reporting_url_, body, headers); + if (container == nullptr) { + ESP_LOGW(TAG, "POST failed: no response"); + const uint32_t delay_ms = this->delivery_.on_transient(millis()); + ESP_LOGW(TAG, "Retrying in %" PRIu32 " s (%" PRIu32 " consecutive failures)", delay_ms / 1000, + this->delivery_.consecutive_failures()); + return false; + } + + const int status = container->status_code; + // Read the body without trusting Content-Length up front: a chunked + // response reports none, and silently dropping the body would drop the + // commands in it โ€” including a token-ask decision, which would then read + // as "server did not decide". Bounded; protocol responses are small. + static constexpr size_t MAX_RESPONSE_BYTES = 8192; + std::string response; + uint8_t chunk[512]; + uint32_t last_data_ms = millis(); + while (response.size() < MAX_RESPONSE_BYTES) { + const int n = container->read(chunk, sizeof(chunk)); + App.feed_wdt(); + yield(); + const auto step = + http_request::http_read_loop_result(n, last_data_ms, this->http_->get_timeout(), container->is_read_complete()); + if (step == http_request::HttpReadLoopResult::DATA) { + response.append(reinterpret_cast(chunk), static_cast(n)); + continue; + } + if (step == http_request::HttpReadLoopResult::RETRY) + continue; + if (step != http_request::HttpReadLoopResult::COMPLETE) + response.clear(); // error/timeout: never parse a truncated body + break; + } + container->end(); + + // Success is otherwise silent; failures get their own warnings below. + ESP_LOGD(TAG, "POST -> %d (%u events, %u bytes)", status, static_cast(batch.size()), + static_cast(body.size())); + if (!response.empty()) + ESP_LOGVV(TAG, "Response: %s", response.c_str()); + + this->handle_response_(status, response, queued_in_batch); + return http_request::is_success(status); +} + +void KegboardReporter::handle_response_(int status, const std::string &body, size_t queued_in_batch) { + switch (kbcore::classify_status(status)) { + case kbcore::BatchDisposition::ACCEPTED: { + for (size_t i = 0; i < queued_in_batch; i++) + this->queue_.pop(); + this->delivery_.on_accepted(millis()); + this->dispatch_commands_(body); + return; + } + case kbcore::BatchDisposition::PAIRING: { + // Includes a revoked or rotated token: drop it and re-enter pairing. + if (this->is_paired()) { + ESP_LOGW(TAG, "Token rejected; re-entering pairing"); + this->bearer_token_.clear(); + this->save_token_(""); + this->delivery_.pairing_started(millis()); + } + this->handle_pairing_(body); + return; + } + case kbcore::BatchDisposition::REJECTED: { + // The batch can never succeed; retrying cannot help. + ESP_LOGE(TAG, "Server rejected batch (%d); dropping %u events", status, static_cast(queued_in_batch)); + for (size_t i = 0; i < queued_in_batch; i++) + this->queue_.pop(); + this->extra_dropped_ += queued_in_batch; + this->delivery_.on_rejected(millis()); + return; + } + case kbcore::BatchDisposition::TRANSIENT: { + ESP_LOGW(TAG, "Delivery failed (%d)", status); + const uint32_t delay_ms = this->delivery_.on_transient(millis()); + ESP_LOGW(TAG, "Retrying in %" PRIu32 " s (%" PRIu32 " consecutive failures)", delay_ms / 1000, + this->delivery_.consecutive_failures()); + return; + } + } +} + +void KegboardReporter::handle_pairing_(const std::string &body) { + std::string state = "pending"; + std::string token; + if (!body.empty()) { + json::parse_json(body, [&](JsonObject root) -> bool { + JsonObjectConst pairing = root["pairing"].as(); + if (pairing.isNull()) + return false; + if (pairing["state"].is()) + state = pairing["state"].as(); + if (pairing["token"].is()) + token = pairing["token"].as(); + return true; + }); + } + + if (state == "allowed" && !token.empty()) { + ESP_LOGI(TAG, "Pairing allowed; token provisioned"); + this->bearer_token_ = token; + this->save_token_(token); + // Deliver the queued backlog under the new identity immediately. + this->delivery_.on_pairing_allowed(millis()); + return; + } + + if (state == "denied") { + ESP_LOGW(TAG, "Pairing denied by server; stopping until reboot"); + this->delivery_.on_pairing_denied(); + return; + } + + // Pending: poll fast for the first minute, then at heartbeat cadence. + this->delivery_.on_pairing_pending(millis()); + ESP_LOGI(TAG, "Pairing pending; approve this device (%s) on the server dashboard", + this->hub_ != nullptr ? this->hub_->serial_number().c_str() : "kegboard"); +} + +void KegboardReporter::dispatch_commands_(const std::string &body) { + if (body.empty()) + return; + + json::parse_json(body, [this](JsonObject root) -> bool { + JsonArrayConst commands = root["commands"].as(); + if (commands.isNull()) + return true; + + for (JsonObjectConst cmd : commands) { + // No id means nothing can be acknowledged; skip. A missing type is + // acknowledged `unsupported` like any unknown type. + if (!cmd["id"].is()) + continue; + const std::string id = cmd["id"].as(); + const std::string type = cmd["type"].is() ? cmd["type"].as() : ""; + + const char *applied = this->delivery_.command_result(id); + if (applied != nullptr) { + // The server re-sends until it sees a command_result; the earlier + // ack may have been evicted before delivery. Re-acknowledge without + // re-applying. + ESP_LOGD(TAG, "Command %s re-delivered; re-acking %s", id.c_str(), applied); + this->enqueue_(this->make_event_("command_result", kbcore::command_result_data_json(id, applied, "")), false); + continue; + } + + CommandOutcome outcome = CommandOutcome::UNSUPPORTED; + std::string message; + if (this->command_handler_) { + outcome = this->command_handler_(type, cmd["data"].as(), message); + } + + const char *result = outcome == CommandOutcome::OK ? "ok" + : outcome == CommandOutcome::ERROR ? "error" + : "unsupported"; + this->delivery_.record_command(id, result); + + ESP_LOGD(TAG, "Command %s (%s) -> %s", id.c_str(), type.c_str(), result); + this->enqueue_(this->make_event_("command_result", kbcore::command_result_data_json(id, result, message)), false); + } + return true; + }); +} + +void KegboardReporter::publish_diagnostics_() { + const uint32_t depth = this->queue_.size(); + const uint32_t dropped = this->queue_.dropped() + this->extra_dropped_; + if (this->queue_depth_sensor_ != nullptr && depth != this->last_published_depth_) { + this->queue_depth_sensor_->publish_state(depth); + this->last_published_depth_ = depth; + } + if (this->dropped_sensor_ != nullptr && dropped != this->last_published_dropped_) { + this->dropped_sensor_->publish_state(dropped); + this->last_published_dropped_ = dropped; + } +} + +void KegboardReporter::dump_config() { + ESP_LOGCONFIG(TAG, "Kegboard Reporter:"); + ESP_LOGCONFIG(TAG, " Reporting URL: %s", this->reporting_url_.c_str()); + ESP_LOGCONFIG(TAG, " Boot id: %s", this->boot_id_.c_str()); + ESP_LOGCONFIG(TAG, " Token: %s", this->is_paired() ? "provisioned" : "unpaired"); + ESP_LOGCONFIG(TAG, " Heartbeat: %" PRIu32 " s", this->heartbeat_ms_ / 1000); + ESP_LOGCONFIG(TAG, " Pour updates: every %" PRIu32 " ms", this->pour_update_ms_); + ESP_LOGCONFIG(TAG, " Meters: %u", static_cast(this->meters_.size())); + ESP_LOGCONFIG(TAG, " Relays: %u", static_cast(this->relays_.size())); +} + +} // namespace esphome::kegboard_reporter diff --git a/components/kegboard_reporter/kegboard_reporter.h b/components/kegboard_reporter/kegboard_reporter.h new file mode 100644 index 0000000..66aceb8 --- /dev/null +++ b/components/kegboard_reporter/kegboard_reporter.h @@ -0,0 +1,162 @@ +#pragma once + +#include +#include +#include + +#include "esphome/components/http_request/http_request.h" +#include "esphome/components/json/json_util.h" +#include "esphome/components/kegboard/delivery.h" +#include "esphome/components/kegboard/events.h" +#include "esphome/components/kegboard/kegboard.h" +#include "esphome/components/kegboard/ring_queue.h" +#include "esphome/components/kegboard_meter/kegboard_meter.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/time/real_time_clock.h" +#include "esphome/core/component.h" +#include "esphome/core/preferences.h" + +namespace esphome::switch_ { +class Switch; +} // namespace esphome::switch_ + +namespace esphome::kegboard_reporter { + +/// Outcome a command handler reports back, mirroring command_result. +enum class CommandOutcome : uint8_t { OK, ERROR, UNSUPPORTED }; + +/// Handler for server commands. Receives the command type and its `data` +/// object; returns the outcome and may set `message` for error detail. +using CommandHandler = + std::function; + +/// Speaks the Kegboard Event Protocol (docs/kegboard-event-protocol.md). +/// +/// Owns the event queue, batch delivery with backoff, the pairing state +/// machine, and command dispatch. Meters, sensors, and the auth component +/// feed events in; the auth component registers a command handler for +/// authorize/deny/deauthorize. +class KegboardReporter : public Component { + public: + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::AFTER_CONNECTION; } + + void set_hub(kegboard::KegboardHub *hub) { this->hub_ = hub; } + void set_http_request(http_request::HttpRequestComponent *h) { this->http_ = h; } + void set_time(time::RealTimeClock *t) { this->time_ = t; } + /// Full reporting URL, path included, e.g. + /// "https://kegbot.example.com/api/kegboard-event". Used verbatim. + void set_reporting_url(const std::string &url) { this->reporting_url_ = url; } + void set_heartbeat_interval_ms(uint32_t v) { + this->heartbeat_ms_ = v; + this->delivery_.set_heartbeat_ms(v); + } + void set_pour_update_interval_ms(uint32_t v) { this->pour_update_ms_ = v; } + void set_retry_interval_ms(uint32_t v) { this->delivery_.set_retry_interval_ms(v); } + + void add_meter(kegboard_meter::KegboardMeter *meter); + /// Register a numbered relay (protocol relay_number โ†’ the relay to drive). + /// Reported in the status inventory; the targets of grant relay sets. + void add_relay(uint8_t relay_number, switch_::Switch *relay); + void add_thermo_sensor(sensor::Sensor *sensor, const std::string &name); + + /// Device inventory lookups, for grant validation and application (auth). + kegboard_meter::KegboardMeter *meter_by_number(uint8_t meter_number) const; + switch_::Switch *relay_by_number(uint8_t relay_number) const; + bool has_relay(uint8_t relay_number) const; + std::vector meter_list() const; + + void set_queue_depth_sensor(sensor::Sensor *s) { this->queue_depth_sensor_ = s; } + void set_dropped_sensor(sensor::Sensor *s) { this->dropped_sensor_ = s; } + + /// Auth integration ------------------------------------------------------- + + /// Register the handler for server commands (single consumer: auth). + void set_command_handler(CommandHandler &&handler) { this->command_handler_ = std::move(handler); } + + /// Emit a token event โ€” always a question for the server โ€” and flush + /// immediately; any commands in the response are dispatched before + /// this returns. @return false if delivery failed (caller applies its + /// offline policy). + bool send_token_ask(const std::string &auth_device, const std::string &token); + + /// Emit a token event (used by auth for detach; attach rides + /// send_token_ask). + void queue_token_event(const std::string &auth_device, const std::string &token, bool attached); + + /// Emit a grant_end event. Queued normally: it does not + /// reset backoff, and when delivery is healthy it goes out on the next + /// send. + void queue_grant_end(const kbcore::GrantEnd &end); + + bool is_paired() const { return !this->bearer_token_.empty(); } + + protected: + struct MeterState { + kegboard_meter::KegboardMeter *meter; + uint32_t last_update_ms{0}; + std::string last_update_pour_id; + }; + + struct RelayEntry { + uint8_t relay_number; + switch_::Switch *relay; + }; + + kbcore::Event make_event_(const char *type, std::string data_json); + void enqueue_(kbcore::Event &&event, bool reset_backoff = true); + std::string rfc3339_now_(); + + void enqueue_status_(bool boot); + void collect_pour_updates_(std::vector &out); + + /// Send one batch (queued + ephemeral). Returns true on 2xx. + bool send_batch_(std::vector &&ephemeral); + void handle_response_(int status, const std::string &body, size_t queued_in_batch); + void dispatch_commands_(const std::string &body); + void handle_pairing_(const std::string &body); + + /// Deliveries are currently succeeding. Deliberately not conditioned on + /// is_paired(): against a server that never asks for auth, the device runs + /// unpaired forever and must still behave fully. + bool healthy_() const { return !this->delivery_.denied() && this->delivery_.consecutive_failures() == 0; } + void publish_diagnostics_(); + void load_token_(); + void save_token_(const std::string &token); + + kegboard::KegboardHub *hub_{nullptr}; + http_request::HttpRequestComponent *http_{nullptr}; + time::RealTimeClock *time_{nullptr}; + + std::string reporting_url_; + std::string bearer_token_; + std::string boot_id_; + uint32_t next_event_id_{1}; + + kbcore::RingQueue queue_; + /// Extra drops beyond the queue's own count (e.g. 4xx-dropped batches). + uint32_t extra_dropped_{0}; + + std::vector meters_; + std::vector relays_; + CommandHandler command_handler_; + + uint32_t heartbeat_ms_{60000}; + uint32_t pour_update_ms_{1000}; + + uint32_t next_heartbeat_ms_{0}; + + /// Attempt timing, backoff, pairing cadence, and command dedup/re-acks. + kbcore::Delivery delivery_; + + ESPPreferenceObject token_pref_; + + sensor::Sensor *queue_depth_sensor_{nullptr}; + sensor::Sensor *dropped_sensor_{nullptr}; + uint32_t last_published_depth_{UINT32_MAX}; + uint32_t last_published_dropped_{UINT32_MAX}; +}; + +} // namespace esphome::kegboard_reporter diff --git a/docs/Makefile b/docs/Makefile index 560b51e..943e008 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,153 +1,24 @@ -# Makefile for Sphinx documentation -# +# Minimal makefile for Sphinx documentation, run through uv: any target +# syncs the toolchain from uv.lock into .venv first, so `make html` works +# with nothing installed but uv. -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext +SPHINXOPTS ?= +SPHINXBUILD ?= uv run sphinx-build +SOURCEDIR = . +BUILDDIR = _build +# Put it first so that "make" without argument is like "make help". help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Kegboard.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Kegboard.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Kegboard" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Kegboard" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." +.PHONY: help livehtml Makefile -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." +LIVEPORT ?= 8010 -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." +livehtml: + uv run sphinx-autobuild --port $(LIVEPORT) "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..afe9ac1 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,13 @@ +# Kegboard Docs + +Sphinx sources for the Kegboard manual, published at + as a subproject of the main +Kegbot docs site. + +The toolchain is managed by [uv](https://docs.astral.sh/uv/); any make +target syncs it from `uv.lock` automatically: + +```console +$ make html # output in _build/html/ +$ make livehtml # live-rebuild server while editing +``` diff --git a/docs/_static/kegbot-logo-black.png b/docs/_static/kegbot-logo-black.png new file mode 100644 index 0000000..d357b6f Binary files /dev/null and b/docs/_static/kegbot-logo-black.png differ diff --git a/docs/_static/kegbot-logo-white.png b/docs/_static/kegbot-logo-white.png new file mode 100644 index 0000000..e4d8696 Binary files /dev/null and b/docs/_static/kegbot-logo-white.png differ diff --git a/docs/authenticated-pouring.md b/docs/authenticated-pouring.md new file mode 100644 index 0000000..f4cd8c5 --- /dev/null +++ b/docs/authenticated-pouring.md @@ -0,0 +1,220 @@ +# Authenticated Pouring + +How a token presented at a Kegboard becomes an open valve and an attributed +pour. This document specifies the interaction between device and server; the +message envelopes it uses (`token` events, commands, `command_result`) are +defined in the main protocol doc. + +## 1. Background + +A Kegboard install always has flow meters on its beer lines: every pour is +measured, whether or not anyone is identified. Many installs add more, per +tap: a solenoid valve on a relay, so beer only flows for someone +authorized, and a token reader nearby (RFID, iButton, ...) to identify who +that is. Others have no valves at all โ€” anyone can pour at any time, and +authentication only decides who a pour is attributed to. + +**Everything in this document is optional.** A monitoring-only Kegboard โ€” +just meters, no readers, no valves โ€” needs none of it: it simply reports +guest pours, and its config never mentions authentication at all. + +A presented token means something โ€” which user, which taps, how much โ€” and +the tokenโ†’user database that answers this can be large. It lives on the +server; the valves and the pour detection live on the device. This doc +defines who decides what, and how the two sides stay simple. + +## 2. Summary + +- A **pour** is metered flow, reported to the server as an event + (main doc ยง5.1) โ€” authenticated or not. +- A **grant** is permission to pour. The server creates one with an + `authorize` command, naming the meters it covers, the relays (valves) it + opens, and its limits: max volume, max total time, max idle time. One + active grant per meter. +- Presenting a physical auth token sends a `token` event to the server. + In response, the server decides whether to grant access (`authorize` + command), opening any relevant valves; or to deny it (`deny` command). +- Pours are tagged with the covering grant's id; the server maps that to a + user. **Identity never reaches the device.** +- A grant ends when a limit is hit, its token detaches, the server revokes + it, or a newer grant takes its meters. Every ending is reported as a + `grant_end` event with its reason. +- Server unreachable? A configured offline policy (ยง6) decides whether the + user is signaled a refusal โ€” either way, nothing opens, and the queued + `token` event preserves the audit trail. + +The rest of this doc unpacks each of these. + +## 3. Authorization modes + +An install is either authenticated or open: + +| Mode | Who decides | Needs server online? | Use case | +|---|---|---|---| +| authenticated (`kegboard_auth` configured) | Server, per token presentment | Yes (with a configurable offline fallback) | Gated or attributed taps. Scales to any token database; policy lives in one place. | +| `open` (no auth component) | Nobody โ€” no gating | No | Meters-only installs, no valves. Every pour is a guest pour. | + +Authorization always means the server decides. A serverless install that +wants token-driven valves can wire the reader triggers to relay switches +with plain ESPHome automations โ€” outside this protocol entirely. + +The device is **stateless about policy**: it holds no token database and +no meterโ†”relay map โ€” only the currently active grant(s), each of which +arrives naming the meters it covers and the relays it opens (see main doc +ยง7.1). The meterโ†”relay association is backend configuration, owned in +exactly one place: the server composes each grant's sets from it, and the +device applies them verbatim. + +## 4. The core flow + +1. **Token presented.** The reader reports a token; the device emits a + `token` event with `action: "attached"` โ€” always a question for the + server. The device flushes the batch immediately. +2. **Server decides.** The server looks up the token, applies whatever + policy it likes (user standing, keg access, time of day), and responds โ€” + in the same HTTP response โ€” with an `authorize` or a `deny` command. + Servers SHOULD always answer a decision-requesting token event with one + of the two. +3. **Device acts.** On `authorize`, the device opens the relays named in + the grant, tags subsequent pours on the granted meters with the + grant, and acknowledges with a `command_result` event. On `deny`, the + tap stays closed and the device SHOULD signal the user (e.g. a refusal + tone). If the response carries neither โ€” a server bug, defensively โ€” + the device treats the presentment as denied, without the user signal. +4. **Grant ends** by whichever comes first: token detach (presence + readers), any of the grant's limits โ€” volume poured, total time, idle + time โ€” being reached, or a server `deauthorize` command. The device + closes the valve, ends any in-flight pour (still tagged with the + departing grant), clears the grant, and reports a `grant_end` event + naming the reason (main doc ยง5.7), so the server never has to guess + why a tap went cold. + +Identity never travels down. The server attributes the resulting pours +itself, from each pour's `grant_id` โ€” the server's own identifier for the +grant (main doc ยง5.1, ยง7.1). The device acts on tokens and grants; it +never knows who a user is. + +Authorization latency is one HTTP round trip, because the decision rides the +response to the token event itself: + +``` +reader device server + | | | + | token 0089f2c4 | | + |------------------>| POST /kegboard-event | + | | [token attached] ---------------->| + | | | lookup, policy + | | 200 {commands:[authorize]} | + | |<------------------------------------| + | | open valve(s), record grant | + | | | + | | ...pour happens... | + | | POST [pour grant_id=g_1, | + | | command_result ok] ---------->| +``` + +## 5. Commands + +The three commands this flow uses are fully specified in the main protocol +doc (ยง7.1โ€“7.3); this section describes only their role in the flow. + +- **`authorize`** carries exactly one grant: a server-assigned id, the + meters it covers and the relays it opens (if any). The server composes + these sets from its own meter and relay configuration, and any limits. The device + applies it verbatim: relays open, and pours on the granted meters are + tagged with the grant, until a limit (volume, total time, idle time) or + the device's own clamp ends it. Several grants at once โ€” different + taps, different policy โ€” are simply several `authorize` commands in + one response. +- **`deny`** is the explicit refusal: the device signals the user, and no + state changes. +- **`deauthorize`** is a server-initiated cutoff, revoking grants by + id โ€” an admin button, a policy engine, an emergency stop. Detach and + the grant's own limits do the same thing device-side without a + command. + +## 6. Offline behavior + +The decision-maker being remote means presentment can race an outage. The +device applies a configured `offline_policy` when a token event cannot be +delivered (network error / 5xx / no response before a short timeout, +suggested 5 s): + +| `offline_policy` | Behavior | +|---|---| +| `deny` (default) | Tap stays closed; the device signals refusal. Correct for installs where gating is the point. | +| `guest` | **Nothing opens and nothing is granted** โ€” the only difference from `deny` is that the user is not signaled a refusal. Pours proceed as ordinary guest pours (a meter without a valve meters regardless), and the queued `token` event reaches the server later, preserving the audit trail. Opening valves offline may be revisited later; for now an offline server never results in an opened valve. | + +Note what is deliberately absent: a device-side token cache. Caching +assignments would reintroduce the state this design removes and creates +stale-revocation problems. + +## 7. Multi-meter, multi-user + +Grants are per meter, so a two-tap device can simultaneously have Alice on +meter 0 and Bob on meter 1: + +- Alice presents; the server responds + `authorize {grant_id: "g_1", meter_numbers: [0], relay_numbers: [0], ...}`, + recording `g_1` as Alice's. +- Bob presents; the server responds + `authorize {grant_id: "g_2", meter_numbers: [1], relay_numbers: [1], ...}`, + recording `g_2` as Bob's. +- Each meter's pours arrive tagged with their own grant's `grant_id`, so + the server attributes meter 0's pours to Alice and meter 1's to Bob. + Detach, limits, and `deauthorize` affect only their own grant's meters. + +Which meter a presentment maps to is **server policy**, not protocol. The +`token` event tells the server which reader saw the token (`auth_device`); +an install with one reader per tap can name readers accordingly (e.g. +`core.rfid.0`) and the server maps reader โ†’ meter. An install with one +shared reader can grant all meters, or apply fancier policy (the user's +reserved tap, the tap with their keg on it). The protocol only carries the +outcome: each grant's `meter_numbers` and `relay_numbers`. + +## 8. Grant and pour corner cases + +Every way a pour and a grant can interact, and the rule for each. Two +principles cover them all: + +- **Attribution is decided when the pour ends.** The pour is tagged + (`auth_device`, `auth_token`, `grant_id` โ€” main doc ยง5.1) with the grant + covering its meter at that moment, in full. The server resolves the user + from `grant_id` โ€” pinned to its own authorization decision, so a + late-delivered pour attributes correctly even if the token was reassigned + in the meantime. +- **Limit accounting is decided as flow is observed.** Flow counts toward + the grant covering the meter at the moment it flows โ€” toward its + `max_volume_ml`, and resetting its `max_idle_ms` โ€” and is never + retroactive. + +Where the two disagree (case 2 below), that is deliberate: the rules stay +simple, and the mismatch is confined to a corner. In general this catalog +favors the simple implementation over the clever one, accepting that a +handful of corner cases do slightly surprising things. + +1. **Pour with no grant.** An unauthenticated guest pour: no auth fields + at all. This is every pour in `open` mode, and any pour on a meter + nobody has authorized โ€” installs without valves meter everything, all + the time. +2. **Grant arrives mid-pour: it adopts the pour.** The pour keeps its + `pour_id` and, at its end, is attributed to the grant **in full**, + including the volume poured before the grant arrived. This is the + headline corner case on valve-less installs: someone starts pouring, + realizes they forgot to authenticate, and keys in mid-glass โ€” the whole + glass lands on their tab. Per the second principle, the pre-grant + volume does *not* count toward the grant's limits. Adoption is a + single, clearly marked policy point in the firmware, so it can become + "split into a new pour" later without disturbing anything else. +3. **Grant replaces another mid-pour: the pour splits.** The in-flight + pour ends immediately, tagged with the departing grant; flow that + continues opens a new pour (fresh `pour_id`) under the new grant. Pour + boundaries always align with authorization boundaries, so consecutive + drinkers never share a pour record. +4. **Grant updated (same `grant_id`) mid-pour: nothing happens.** It is + the same grant; the pour continues under it, counters intact. +5. **Grant ends mid-pour** โ€” limit reached, token detached, `deauthorize`: + the pour ends first, tagged with the grant, and its event precedes the + `grant_end` (main doc ยง5.7). Flow that continues โ€” after the valve + closes, or on a meter that never had one โ€” is case 1 again: a new, + unauthenticated guest pour. diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..5a4910a --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,10 @@ +# Changelog + +## Current version (in development) + +* First esp32/esphome firmware release. +* First release of Kegboard Event Protocol. + +## Earlier changelog entries + +_For previous changelog entries, see [`changelog.rst@arduino`](https://github.com/Kegbot/kegboard/blob/arduino/docs/source/changelog.rst)._ diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..5638bc5 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,34 @@ +# Sphinx configuration for the Kegboard manual, published at +# https://docs.kegbot.org/projects/kegboard as a subproject of the +# main Kegbot docs site (github.com/Kegbot/kegbot-overview-docs). + +project = "Kegboard" +copyright = "2003-2026, The Kegbot Project Contributors" +author = "The Kegbot Project Contributors" +release = "4.0.0-pre1" + +extensions = [ + "myst_parser", +] +myst_enable_extensions = [ + "deflist", + "smartquotes", + "replacements", +] +# The protocol specs deep-link their own numbered subsections. +myst_heading_anchors = 4 + +# The protocol specs use "..." ellipses inside JSON examples, which the +# strict JSON lexer rejects before Pygments retries in relaxed mode. +suppress_warnings = ["misc.highlighting_failure"] + +# README.md documents how to build this manual; it is not part of it. +exclude_patterns = ["_build", "README.md", "Thumbs.db", ".DS_Store"] + +html_theme = "furo" +html_theme_options = { + "light_logo": "kegbot-logo-black.png", + "dark_logo": "kegbot-logo-white.png", +} +html_static_path = ["_static"] +html_title = "Kegboard" diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..9822fc3 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,111 @@ +# Configuration Reference + +Kegboard is configured in ESPHome YAML. This chapter covers the Kegboard +components; everything else (WiFi, sensors, displays, automations) is stock +ESPHome โ€” see [esphome.io](https://esphome.io/). `examples/` has complete +configs; `packages/` has the composable pieces. + +```yaml +external_components: + - source: github://Kegbot/kegboard@main +``` + +## `kegboard` + +The hub. Required by every other component. + +| Option | Default | Notes | +|---|---|---| +| `serial_number` | `kegboard-` | Device identity in the protocol. Set explicitly to adopt a replaced board's identity. | + +## `kegboard_meter` + +One entry per flow meter. + +```yaml +kegboard_meter: + - id: flow0 + pin: GPIO4 + meter_number: 0 + total: + name: Tap 1 Ticks + pouring: + name: Tap 1 Pouring +``` + +| Option | Default | Notes | +|---|---|---| +| `pin` | required | Meter input. Pulled up internally; counts falling edges. | +| `meter_number` | `0` | The protocol's meter number: `(device, meter_number)` identifies a tap server-side. Must be unique per meter (validated at build). The YAML `id` is a config-internal reference and is never reported. | +| `ml_per_tick` | `0.185` | SwissFlow SF800 and clones (~5.4 ticks/mL). The device's calibration is authoritative: reported volume comes from this. | +| `debounce` | `1200us` | Matches the legacy firmware's filter. | +| `idle_timeout` | `10s` | Silence after which a pour is considered finished. | +| `min_pour_ticks` | `3` | Anything shorter is treated as a drip and discarded. | +| `max_pour_duration` | `5min` | Safety cutoff for a stuck meter; `0s` disables. | +| `report_interval` | `250ms` | Throttle for sensor updates during a pour. | +| `series_resolution` | `100ms` | Bucket width for the diagnostic tick series; `0s` disables. | + +Optional entities: `total`, `volume`, `flow_rate`, `pouring`. +Triggers: `on_pour_start`, `on_pour_end` (with `ticks`, `volume_ml`, +`duration_ms`). +Actions: `kegboard_meter.reset_total`, `.end_pour`, `.set_calibration`. + +## `kegboard_reporter` + +Speaks the [Kegboard Event Protocol](kegboard-event-protocol.md) to a server. + +| Option | Default | Notes | +|---|---|---| +| `reporting_url` | required | Full URL, path included, e.g. `https://kegbot.example.com/api/kegboard-event`. No credential is configured โ€” the device provisions its own bearer token by pairing via the server dashboard, and it persists in flash. | +| `meters` | `[]` | Meters whose pours are reported. | +| `relays` | `[]` | The device's numbered relays: `relay_number:` plus `relay:` (the relay to drive, e.g. from `packages/relays.yaml`). Reported in the `status` inventory, and the targets of server grants (and of `set_relay`, once that reserved command is specified). | +| `thermo_sensors` | `[]` | `sensor:`/`name:` pairs; any ESPHome sensor works. | +| `heartbeat_interval` | `60s` | Status event cadence; also bounds worst-case command latency. | +| `pour_update_interval` | `1s` | Live `pour_update` cadence; `0s` disables. | +| `retry_interval` | `30s` | Base for exponential backoff, capped at 5 min. | + +Optional diagnostic entities: `queue_depth`, `dropped`. A non-zero `dropped` +means events were lost and is worth alerting on. + +## `kegboard_auth` + +Applies [authenticated pouring](authenticated-pouring.md): server-decided +grants driving valve relays and tagging pours for server-side attribution. +Requires a `kegboard_reporter`. (Serverless installs can gate valves with +plain ESPHome automations on the reader triggers instead.) + +| Option | Default | Notes | +|---|---|---| +| `offline_policy` | `deny` | Token presented while the server is unreachable: `deny` (signal refusal), or `guest` (stay silent; pours proceed as guest pours). Neither opens valves. | +| `max_grant_duration` | `5min` | Device-side clamp on server-issued grants: the final bound on valve-open time. | + +Actions: `kegboard_auth.token_attached` / `.token_detached` (`device`, +`token`), `.revoke`. Condition: `.is_authorized`. Triggers: `on_authorized` +(`auth_device`, `token`), `on_denied` (`reason`), `on_revoked`. Optional +entities: `authorized`. + +Readers feed it through the actions โ€” see `examples/kegbot-full.yaml` for +RFID and iButton wiring. + +## `kegboard_onewire` + +iButton presence on a 1-Wire bus โ€” ESPHome's `one_wire` enumerates devices +but has no arrive/leave events. Triggers `on_token_attached` and +`on_token_detached` with the ROM code as hex. + +| Option | Default | Notes | +|---|---|---| +| `one_wire_id` | auto | The bus to poll; bound automatically when exactly one `one_wire` bus exists, required with several. Use a bus separate from the thermo sensors. | +| `update_interval` | `1s` | Poll cadence. | +| `max_missed_searches` | `4` | Consecutive misses before a detach is reported. A held iButton makes intermittent contact; reporting on the first miss would flap several times a second. | + +## Packages + +Plain YAML over stock components; include what you have. + +| Package | Provides | +|---|---| +| `packages/base.yaml` | WiFi + fallback AP, OTA, logging, native API, SNTP time, HTTP client. Expects `name` and `friendly_name` substitutions. | +| `packages/relays.yaml` | Two GPIO relays, each with a watchdog: auto-off after `relay_watchdog_timeout` from the on-edge (default `10s`, `0s` disables). On grant-driven relays set it longer than `max_grant_duration` โ€” the grant clamp is their bound โ€” or the watchdog closes the valve mid-grant. | +| `packages/buzzer.yaml` | Passive piezo via `rtttl`, with the legacy boot/auth/ping melodies as scripts. | +| `boards/*.yaml` | Chip selection + pin-map substitutions. See [Hardware & Wiring](hardware.md). | diff --git a/docs/developer-notes.md b/docs/developer-notes.md new file mode 100644 index 0000000..fbff443 --- /dev/null +++ b/docs/developer-notes.md @@ -0,0 +1,136 @@ +# Developer Notes + +## Repository layout + +``` +components/ ESPHome external components (this repo is the component source) + kegboard/ Hub component + the framework-agnostic core (see CORE.md) + kegboard_meter/ Flow meter and pour detection + kegboard_reporter/ Event protocol client (batching, pairing, commands) + kegboard_auth/ Per-meter authorization + kegboard_onewire/ iButton presence +packages/ Composable YAML users include +boards/ Pin maps per target board +docs/ This manual + protocol specifications +schemas/ Normative JSON Schemas for the protocol +examples/ Worked configurations +tests/core/ Host unit tests โ€” plain g++, no hardware, no toolchain +tools/ kegboard-sim, the TUI simulator +script/ CI helpers +``` + +## Building and testing + +Host unit tests for the core logic need nothing but a C++ compiler: + +```console +$ make -C tests/core +$ make -C tests/core STRICT=1 # warnings as errors, as CI runs it +``` + +Firmware builds are plain ESPHome: + +```console +$ esphome compile examples/kegbot-2tap.yaml +``` + +Formatting and lint mirror ESPHome's conventions (clang-format, ruff, +yamllint), since these components compile into ESPHome's tree: + +```console +$ pip install pre-commit && pre-commit install +$ pre-commit run --all-files +``` + +## Hacking on the core + +The rules, enforced by CI (`script/check-core-purity.py`): + +1. Core files (`components/kegboard/*.cpp/h` in the `kbcore` namespace) + include only the C++ standard library โ€” no ESPHome, Arduino, or ESP-IDF + headers. +2. No I/O, no clocks, no timers; time is passed in as arguments. +3. Every core file has host tests in `tests/core/`. + +This is what keeps pour detection, the grant table, and the queue-and-retry +path testable in milliseconds without flashing a board โ€” and what keeps the +ESPHome components thin enough to port away from ESPHome if that ever becomes +necessary. Details in +[`components/kegboard/CORE.md`](https://github.com/Kegbot/kegboard/blob/main/components/kegboard/CORE.md). + +`script/check-events-schema.py` keeps the C++ event serializers and the JSON +Schemas in agreement. + +## Extending a board + +Most extensions are YAML, not firmware: + +- **More taps:** another `kegboard_meter` entry with a fresh `meter_number`. +- **A different reader:** any ESPHome reader component whose trigger calls + `kegboard_auth.token_attached` with a `device` name and token string. The + string must match how the token is registered server-side โ€” log the first + scan and register that exact value. `examples/kegbot-full.yaml` shows RFID + (`rdm6300`) and iButton (`kegboard_onewire`). +- **Displays, pressure sensors, more relays, anything ESPHome supports:** + stock components alongside the Kegboard ones; `on_pour_*` triggers and the + `pouring`/`authorized` entities are the integration points. + +## The simulator + +`tools/kegboard-sim.py` is a TUI kegboard for developing receivers without +hardware. It speaks the full protocol โ€” pairing, batching, `age_ms`, +commands, dedup โ€” and validates every outgoing batch against the schemas, so +it cannot teach a server the wrong protocol. + +```console +$ uv run tools/kegboard-sim.py http://localhost:8000/kegboard-event +``` + +Single keys pour a beer (with live `pour_update`s), toggle temperature +logging, present preset tokens, kill the heartbeat, go offline to build a +backlog that delivers late, replay the last batch to exercise dedup, send an +unknown event type, and reboot to reset `boot_id`. + +## Protocol traffic logging + +The reporter logs one line per delivery at `DEBUG`. For full request and +response bodies: + +```yaml +logger: + logs: + kegboard_reporter: VERY_VERBOSE +``` + +`VERY_VERBOSE` lines are compiled out at default log levels, so production +builds pay nothing. The logger truncates lines to its buffer (default 512 +bytes); add `logger: { tx_buffer_size: 2048 }` if a batch body clips. + +## Cutting a release + +The canonical firmware version is `KEGBOARD_VERSION` in +`components/kegboard/kegboard.cpp`; `docs/conf.py` and `packages/base.yaml` +mirror it and must always agree. Don't edit them by hand โ€” release with: + +```console +$ script/bump.py --dry-run # preview +$ script/bump.py # 4.0.1 -> 4.0.2; a -pre/-dev suffix is dropped +$ script/bump.py 4.1.0 # or pick the version explicitly +``` + +`bump.py` verifies the tree is clean and the tag is free, updates all three +version files, dates the changelog's "Current version (in development)" +section as `## vX.Y.Z (YYYY-MM-DD)` (leaving a fresh open section above it), +commits, and creates the `vX.Y.Z` tag. It does not push; publish with: + +```console +$ git push origin HEAD vX.Y.Z +``` + +## Protocol changes + +The [event protocol](kegboard-event-protocol.md) and +[authenticated pouring](authenticated-pouring.md) docs are the contract, and +the schemas in `schemas/` are normative. Changing the wire format means +updating the spec, the schemas, the core serializers, the simulator, and the +schema-agreement check together. diff --git a/docs/hardware.md b/docs/hardware.md new file mode 100644 index 0000000..32532ca --- /dev/null +++ b/docs/hardware.md @@ -0,0 +1,85 @@ +# Hardware & Wiring + +Kegboard v4 targets generic ESP32 devkits. There is no custom PCB (yet); the +legacy kegboard-mini/mega/coaster boards ran the AVR firmware and live on the +[`arduino` branch](https://github.com/Kegbot/kegboard/tree/arduino). + +## Supported boards + +| Board package | Chip | Notes | +|---|---|---| +| `boards/esp32-s3-devkitc-1.yaml` | ESP32-S3 | **Reference target.** Native USB (no adapter to flash), ample GPIO, PSRAM headroom. | +| `boards/esp32-devkit.yaml` | ESP32 (WROOM-32) | What most people already have in a drawer. | +| `boards/esp32-c6-devkitc-1.yaml` | ESP32-C6 | Forward-looking: WiFi 6, Thread/Zigbee radios. | + +Other ESPHome-supported ESP32 variants work; write your own board package +with the same substitution names. + +## Pin maps + +Defined as substitutions in each board package; override them in your config +to relocate a peripheral. + +| Substitution | S3 | Classic | C6 | Used by | +|---|---|---|---|---| +| `meter0_pin`โ€“`meter3_pin` | 4โ€“7 | 4, 5, 13, 14 | 4โ€“7 | Flow meters | +| `onewire_pin` | 15 | 16 | 10 | Thermo 1-Wire bus | +| `relay0_pin`, `relay1_pin` | 16, 17 | 17, 18 | 11, 18 | Relays | +| `buzzer_pin` | 18 | 19 | 19 | Piezo buzzer | +| `led0_pin`, `led1_pin` | 8, 9 | 21, 22 | 20, 21 | Flow LEDs | +| `rfid_rx_pin` | 44 | 23 | 22 | RFID reader UART | +| `onewire_auth_pin` | 21 | 25 | 23 | iButton 1-Wire bus | + +The maps avoid each chip's landmines: strapping pins, flash/PSRAM pins, +native-USB pins, and (on the classic ESP32) GPIO 34โ€“39, which are input-only +with no internal pull-ups and so cannot bias an open-collector meter. + +## Flow meters + +Kegboard supports open-collector meters โ€” typically hall-effect sensors that +pulse once per fixed volume as liquid passes. Meter inputs use the internal +pull-up and count falling edges; wire the meter's output straight to the pin, +its ground to ground. + +> **ESP32 GPIOs are not 5 V tolerant.** Open-collector meters are safe on a +> 3.3 V pull-up even when powered from 5 V, because they only ever pull the +> line to ground. Meters with a push-pull 5 V output will damage the ESP32 +> and need a level shifter or divider. Check your meter before wiring it. + +The default calibration (`ml_per_tick: 0.185`, ~5.4 ticks/mL) matches the +SwissFlow SF800 and its clones. Other meters work; set `ml_per_tick` +accordingly (a Vision 2000 is ~2200 ticks/L โ†’ `0.4545`). + +## Temperature sensors + +DS18B20/DS18S20 sensors on the `onewire_pin` bus, via ESPHome's `one_wire` +and `dallas_temp`. Any number of sensors on one bus; each needs the usual +4.7 kฮฉ pull-up to 3.3 V (many breakout probes include it). + +## Authentication readers + +- **125 kHz RFID:** RDM6300-class readers (the ID-12 lineage) on + `rfid_rx_pin` via UART at 9600 baud, using ESPHome's `rdm6300`. TX-only โ€” + nothing is wired back to the reader. +- **iButton:** a second 1-Wire bus on `onewire_auth_pin`, read by + `kegboard_onewire`. Kept separate from the thermo bus so a wet hand stays + away from the keg sensors. +- **Anything else:** any ESPHome reader (`wiegand`, `pn532`, `rc522`, ...) + can feed `kegboard_auth` โ€” see + [extending](developer-notes.md#extending-a-board). + +## Relays and valves + +`packages/relays.yaml` defines two GPIO relay outputs, each with a watchdog +that switches it off `relay_watchdog_timeout` (default 10 s) after it turns +on โ€” see the package's notes before putting a grant-driven valve on one. +Use a relay module rated for logic-level (3.3 V) input, or a +transistor driver. What's downstream is usually a solenoid valve; give it its +own supply and a flyback diode if the module lacks one. + +## Buzzer and LEDs + +A **passive** piezo on `buzzer_pin` plays the legacy Kegboard melodies +(`packages/buzzer.yaml`); an active buzzer generates its own tone and will +ignore them. Flow LEDs are ordinary GPIO LEDs with resistors, wired per the +pin map and driven from `on_pour_start`/`on_pour_end` automations. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..066768b --- /dev/null +++ b/docs/index.md @@ -0,0 +1,25 @@ +# Kegboard Manual + +This is the manual for **Kegboard**, the ESP32-based hardware controller for a [Kegbot](https://kegbot.org/). + +```{toctree} +:caption: Kegboard Manual +:maxdepth: 1 + +overview +operating-modes +installation +hardware +configuration +operation +developer-notes +changelog +``` + +```{toctree} +:caption: "Appendix" +:maxdepth: 1 + +kegboard-event-protocol +authenticated-pouring +``` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..3a4ce30 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,94 @@ +# Installation + +Kegboard is flashed like any ESPHome project: write a YAML config, build, +flash once over USB, then update over the air. Prebuilt binaries and a +browser-based installer are planned but not yet available. + +## Install ESPHome + +Any current ESPHome (2025.x or later) works. With [uv](https://docs.astral.sh/uv/): + +```console +$ uv tool install esphome +``` + +or `pip install esphome`. See the +[ESPHome installation guide](https://esphome.io/guides/installing_esphome) +for other options, including the Home Assistant add-on. + +## Get a configuration + +Start from an example: + +```console +$ git clone https://github.com/Kegbot/kegboard.git +$ cd kegboard/examples +$ cp secrets.yaml.example secrets.yaml # fill in WiFi + reporting URL +``` + +Pick the example matching your [operating mode](operating-modes.md) โ€” +`kegbot-2tap.yaml`, `home-assistant-2tap.yaml`, or `kegbot-full.yaml` โ€” and +trim it to the hardware you actually have. + +Configs outside this repository consume the components directly: + +```yaml +external_components: + - source: github://Kegbot/kegboard@main +``` + +## Board configuration + +Each config includes a board package from `boards/`, which selects the chip +and defines the pin map as substitutions: + +```yaml +packages: + board: !include ../boards/esp32-s3-devkitc-1.yaml + base: !include ../packages/base.yaml +``` + +To move a peripheral to a different pin, override the substitution in your +config rather than editing the board file. See +[Hardware & Wiring](hardware.md) for the maps and which pins are safe. + +The `base` package provides WiFi (with a fallback `kegboard-setup` access +point and captive portal), OTA, logging, the native API, SNTP time, and the +HTTP client. + +## Flash + +First flash is over USB: + +```console +$ esphome run kegbot-2tap.yaml +``` + +`esphome run` compiles, prompts for the port, flashes, and tails the log. +Every later `esphome run` offers OTA over WiFi instead โ€” the cable is only +ever needed once. + +If the board can't reach your WiFi it raises the `kegboard-setup` access +point; connect to it and enter credentials in the captive portal. + +## Pair with the server + +Nothing to configure: no API key, no token. On first contact with a server +that requires authentication, the board enters pairing and appears on the +server dashboard under its `serial_number` (default `kegboard-`). +Approve it there; the board stores its credential in flash and is +provisioned from then on. Revoking the credential server-side sends the +board back into pairing, so key rotation is "revoke, then re-allow" with no +device-side ceremony. Details in [protocol ยง8](kegboard-event-protocol.md). + +Events recorded before approval are queued, not lost โ€” they deliver, with +correct timestamps, once pairing completes. + +## Verify + +- `esphome logs kegbot-2tap.yaml` and blow through a meter (or short the + meter pin to ground a few times): tick counts should move and a pour + should end after `idle_timeout`. +- Check the server dashboard for the device and the pour. +- For calibration, pour a known volume and scale `ml_per_tick`; see + [`kegboard_meter`](configuration.md#kegboard_meter). diff --git a/docs/kegboard-event-protocol.md b/docs/kegboard-event-protocol.md new file mode 100644 index 0000000..8b4dcee --- /dev/null +++ b/docs/kegboard-event-protocol.md @@ -0,0 +1,1250 @@ +# Kegboard Event Protocol + +**Version:** 1 + +The protocol a Kegboard controller uses to talk to a server. + +Companion document: [Authenticated Pouring](authenticated-pouring.md), which +specifies how token presentment, server-side authorization, and valve control +compose on top of this protocol. + +## 1. Protocol Goals + +- **One endpoint, one document schema.** A third party should be able to receive + Kegboard data by implementing a single HTTP handler against a single JSON + Schema. This document plus the schemas is the whole contract. +- **The device is authoritative for volume.** Calibration (`ml_per_tick`) + lives on the controller. Reports always carry `volume_ml`. Raw meter + values ("ticks") may be reported, but only as diagnostic values. +- **Outage-proof.** Events queue on the device and deliver late with correct + timestamps, without requiring the device to have a synchronized clock. +- **Exactly-once effect.** Delivery is at-least-once; event ids make + processing idempotent, so a retry can never create a duplicate drink. +- **Simple authentication.** Devices stream data to endpoint that may optionally + require authentication. A simple pairing protocol makes it happen. +- **Transport-portable.** The envelope is defined over HTTP here, but carries + no HTTP-isms in the body, so the same messages can later ride MQTT, BLE, or + WebSocket unchanged. + +## 2. Transport + +``` +POST {reporting_url} +Authorization: Bearer +Content-Type: application/json +``` + +- The reporting URL โ€” path included โ€” is device configuration, used verbatim; + the device attaches no meaning to it. Servers SHOULD expose the endpoint at + a stable, documented path such as `/kegboard-event`. +- The request body is a single JSON object (ยง3); the response body is a + single JSON object (ยง7). +- Maximum request body size a server must accept: **16 KiB**. The device + bounds itself well under this. + +### Authentication + +Authentication is optional, and the server drives it: + +- A device holding a bearer token sends `Authorization: Bearer ` on + every request. A device without one sends **no `Authorization` header at + all** โ€” there is no placeholder, no empty header, no handshake. +- A server MAY simply accept (2xx) unauthenticated batches. The device then + never pairs and never holds a token. This is deliberate: the minimum + viable receiver is a single unauthenticated HTTP handler with no auth + machinery whatsoever. +- **Only a 401 introduces authentication.** A 401 tells the device this + server wants a credential, sending it into pairing (ยง8); once provisioned, + every subsequent request carries the header. The same 401 later serves as + revocation โ€” the device drops its token and re-pairs. +- TLS is strongly recommended whenever tokens are in play; the token is a + plain bearer credential. + +### Status-code semantics + +The device keys its queue behavior on the status code: + +| Status | Device behavior | +|---|---| +| 2xx | Batch accepted (or safely deduplicated). Remove events from queue. Process response body (ยง7). | +| 401 | Not authorized. Keep events queued; enter/continue pairing (ยง8). | +| other 4xx | Batch can never succeed (malformed, unsupported version). Drop the batch, surface an error diagnostic. | +| 5xx / network error / timeout | Transient. Keep events queued, retry with exponential backoff. | + +A server that accepts a batch MUST have durably processed (or deduplicated) +every event in it before returning 2xx. There is no partial acceptance: if a +server cannot process one event in a batch, it either drops that event +internally (and still returns 2xx) or fails the whole batch with 5xx. This +keeps device logic trivial. + +## 3. Request envelope + +```json +{ + "v": 1, + "device": "kegboard-a1b2c3", + "boot_id": "9f3a2c1b", + "sent_uptime_ms": 8123456, + "events": [ ... ] +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `v` | integer | yes | Protocol version. This document describes `1`. | +| `device` | string | yes | Stable device identity, e.g. `kegboard-a1b2c3`. Derived from MAC by default, user-overridable. | +| `boot_id` | string | yes | Opaque id regenerated each boot (e.g. 8 hex chars). Scopes sequence numbers so they need not survive reboot. | +| `sent_uptime_ms` | integer | yes | Device uptime when this request was serialized. Diagnostic. | +| `events` | array | yes | 1โ€“16 events, oldest first. | + +## 4. Event envelope + +Every element of `events`: + +```json +{ + "id": 42, + "type": "pour", + "age_ms": 4200, + "time": "2026-08-03T18:02:11Z", + "data": { ... } +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `id` | integer | yes | Sequence number, monotonically increasing per boot, starting at 1. `(device, boot_id, id)` is the global dedup key. | +| `type` | string | yes | One of ยง5's types. Servers MUST ignore (but still 2xx) unknown types. | +| `age_ms` | integer | yes | How long before serialization the event occurred. Recomputed at every (re)send. **This is the authoritative time signal**; see ยง6. | +| `time` | string (RFC 3339) | no | Wall time of the event, present only if the device clock was synchronized when the event occurred. Informational. | +| `data` | object | yes | Type-specific payload. | + +> **Why event ids are boot-scoped integers while `pour_id` (ยง5.1) is a +> globally unique string:** event ids are transport bookkeeping โ€” consumed +> once at ingest for dedup and ordering, then never referenced again โ€” so a +> composed key is fine and a per-event UUID would spend 36 bytes on every +> heartbeat and temperature reading for no benefit. Pours are domain objects +> that outlive the transport (they become database rows, URLs, log lines), so +> they carry an identifier that is unique as-is, with nothing to compose and +> nothing to get wrong. + +## 5. Event types + +### 5.1 `pour` + +A completed pour: the durable record. Emitted once, when the pour ends. + +Like every event, a pour is timed by the envelope's `age_ms` (ยง4, ยง6) โ€” there +is no separate timestamp in the payload, and queued pours delivered late keep +correct timing automatically. The anchor is the **end of the pour**: the +event is created the moment the pour finishes, so +`pour_end = server_now - age_ms`, and the start is `pour_end - duration_ms`. + +```json +{ + "meter_number": 0, + "pour_id": "5f8e2c34-9d1b-4a7e-b02c-8f13d9a6e415", + "volume_ml": 355.2, + "duration_ms": 7100, + "auth_device": "core.rfid", + "auth_token": "0089f2c4", + "grant_id": "g_5501", + "ticks": 1919, + "ml_per_tick": 0.185, + "tick_series": "0:3 100:14 200:31" +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `meter_number` | integer | yes | Meter number on this device, 0-based. `(device, meter_number)` identifies a tap server-side. | +| `pour_id` | string | yes | Globally unique, **opaque** pour identifier; see ยง5.2. | +| `volume_ml` | number | yes | Poured volume. **Authoritative.** Computed on-device from its own calibration. | +| `duration_ms` | integer | yes | First tick to last tick. | +| `auth_device` | string | no | Reader that authorized the pour (`core.rfid`, `onewire`, ...). | +| `auth_token` | string | no | Token that authorized the pour. | +| `grant_id` | string | no | The server-assigned id of the grant that covered this pour (ยง7.1). Absent for ungated (guest) pours. | +| `ticks` | integer | no | Raw tick count. Advisory diagnostic only; servers MUST NOT compute volume from it. | +| `ml_per_tick` | number | no | Calibration in effect when the pour ended. Diagnostic; lets a server sanity-check `ticks * ml_per_tick โ‰ˆ volume_ml`. | +| `tick_series` | string | no | Space-separated `:` pairs. Diagnostic. | + +There is no user field: **the device never learns identity**. The server +attributes a pour from `grant_id` โ€” which pins it to the server's own +authorization decision, and so stays correct even if the token is reassigned +between the pour and a late queued delivery. A pour without one is a guest +pour. + +### 5.2 `pour_update` + +A pour in progress: the live view. Lets a server UI render an odometer while +beer is flowing. Emitted at most every `pour_update_ms` (device-tunable, +default 1000; `0` disables) from pour start until the final `pour` event. + +```json +{ + "meter_number": 0, + "pour_id": "5f8e2c34-9d1b-4a7e-b02c-8f13d9a6e415", + "volume_ml": 120.4, + "duration_ms": 2400 +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `meter_number` | integer | yes | Meter number. | +| `pour_id` | string | yes | Identifies the pour: all updates of one pour and its final `pour` event carry the same value. **Opaque and globally unique** โ€” the device currently generates a UUIDv4, but clients MUST NOT validate the format; it may change. Usable as-is as a key, with no device/boot qualifiers. | +| `volume_ml` | number | yes | Volume so far. | +| `duration_ms` | integer | yes | Elapsed since first tick. | + +There is no rate field: flow rate is derivable (`volume_ml / duration_ms`, or +better, the delta between successive updates), and the protocol does not +carry what a receiver can compute. + +**`pour_update` is best-effort and ephemeral**, and is the one exception to +ยง9's delivery guarantees: the device sends updates only when the connection +is healthy, silently discards them rather than queueing on failure, and their +loss does not count toward `events_dropped`. Servers MUST NOT treat updates +as authoritative โ€” the final `pour` event is the record, and a server +receiving updates but no final `pour` (device rebooted mid-pour) must not +synthesize a drink from them. + +### 5.3 `temperature` + +A sensor reading. Emitted at the device's configured interval. + +```json +{ "sensor": "thermo-28ff641d8fbb0517", "temp_c": 4.25 } +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `sensor` | string | yes | Device-scoped sensor name. | +| `temp_c` | number | yes | Degrees Celsius. | + +### 5.4 `token` + +An auth token arrived or left. Central to server-side authorization; see the +[Authenticated Pouring](authenticated-pouring.md) doc for the full flow. + +```json +{ + "auth_device": "onewire", + "token": "0000000012345678", + "action": "attached" +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `auth_device` | string | yes | Reader name (`core.rfid`, `onewire`, ...). | +| `token` | string | yes | Token value, lowercase hex by convention. | +| `action` | string | yes | `attached` or `detached`. | + +An `attached` event is always a question: the server decides, and its +answer โ€” an `authorize` or `deny` command โ€” rides the same response (see +companion doc). + +The device SHOULD flush a batch immediately when a token is attached, since +authorization latency is the user standing at the tap waiting. + +### 5.5 `status` + +Device health and configuration. Emitted at boot and then at the heartbeat +interval (default **1 minute**). Also the vehicle for making data loss +visible, for letting the server discover device settings it cannot set, +and for self-describing the device's hardware: `meters` and `relays` are +**exhaustive inventories**, so a server can allocate its records for every +port automatically โ€” and retire records for ports that stop being reported. + +```json +{ + "state": "boot", + "fw_version": "4.0.0", + "uptime_ms": 12345, + "wifi_rssi_dbm": -61, + "events_dropped": 0, + "config": { + "heartbeat_ms": 60000, + "pour_update_ms": 1000, + "queue_capacity": 16 + }, + "meters": [ + { "meter_number": 0, "total_ticks": 918234, "ml_per_tick": 0.185 }, + { "meter_number": 1, "total_ticks": 40112, "ml_per_tick": 0.185 } + ], + "relays": [ + { "relay_number": 0 }, + { "relay_number": 1 } + ] +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `state` | string | yes | `boot` for the first status after power-on, else `heartbeat`. | +| `fw_version` | string | yes | Firmware version. | +| `uptime_ms` | integer | yes | Uptime at event creation. | +| `wifi_rssi_dbm` | integer | no | Signal strength. | +| `events_dropped` | integer | yes | Lifetime count of events evicted from the queue before delivery. A non-zero delta between heartbeats means data was lost. | +| `config` | object | yes | Operative device settings the server should be able to discover without being able to set them: heartbeat interval, pour-update interval, queue capacity. Extensible; servers MUST ignore unknown keys. | +| `meters` | array | no | Every meter the device has, with lifetime tick totals and current calibration (`ml_per_tick` always present). Exhaustive when present: a meter absent from the list does not exist on the device. Also lets a server detect missed pours by gap analysis. | +| `relays` | array | no | Every relay (valve output) the device has. Exhaustive when present, same rule as `meters`. Entries are objects for extensibility; servers MUST ignore unknown keys. | + +A server that auto-provisions from these inventories SHOULD be +conservative about retirement: dropping a record it created is safe, but +a port an operator has configured (e.g. bound to a tap) deserves a +warning rather than silent deletion โ€” a transient misreport must not +sever operator configuration. + +### 5.6 `command_result` + +Acknowledges a server command (ยง7), giving the command channel the same +at-least-once/idempotent semantics as the event channel. + +```json +{ "command": "cmd_8f21", "result": "ok" } +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `command` | string | yes | The `id` of the command being acknowledged. | +| `result` | string | yes | `ok`, `error`, or `unsupported`. | +| `message` | string | no | Human-readable detail on `error`/`unsupported`. | + +### 5.7 `grant_end` + +Reports that an authorization grant (ยง7.1) ended, and why. Emitted once per +ending โ€” including partial endings, where only some of a grant's meters are +released โ€” so the server gets the complete grant lifecycle from this one +event type instead of inferring it from timers of its own. + +```json +{ + "meter_numbers": [0], + "reason": "max_volume", + "auth_device": "core.rfid", + "auth_token": "0089f2c4", + "grant_id": "g_5501", + "volume_ml": 2004.9, + "duration_ms": 84200 +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `meter_numbers` | array of integer | yes | The meters released by this ending. | +| `reason` | string | yes | Why the grant ended; see below. | +| `auth_device` / `auth_token` | string | no | Echo of the presentment that created the grant, as on `pour` (ยง5.1). | +| `grant_id` | string | yes | The server-assigned id of the grant (ยง7.1). | +| `volume_ml` | number | yes | Total volume poured under the grant, across all its meters โ€” a snapshot at this ending, see below. | +| `duration_ms` | integer | yes | Grant age at this ending. | + +| `reason` | Meaning | +|---|---| +| `max_volume` | Cumulative poured volume reached `max_volume_ml`. | +| `max_duration` | Grant lifetime reached `max_duration_ms` โ€” or the device's own `max_grant_duration` clamp (ยง7.1). | +| `max_idle` | No flow on any granted meter for `max_idle_ms`. | +| `detach` | The grant's token detached (presence readers). | +| `command` | A server `deauthorize` (ยง7.3). | +| `replaced` | An `authorize` โ€” a new grant, or an update to this one โ€” took the listed meters out of this grant's scope (ยง7.1). | + +`volume_ml` and `duration_ms` are **snapshots of the whole grant, not +deltas**: a partial ending reports the grant's running totals at that +moment, and the same volume appears again โ€” grown โ€” in the grant's later +endings. Servers MUST NOT sum `grant_end` volumes; the `pour` events are +the volume record, and these totals are for cross-checking and display. + +A limit ending an in-flight pour ends the pour first, so the final `pour` +event precedes the `grant_end` in the queue. Reason `command` is redundant +with the `deauthorize`'s own `command_result` acknowledgment, deliberately: +a server can track grant lifecycles from `grant_end` alone. Like any event, +`grant_end` queues and delivers at-least-once (ยง9) โ€” a grant that ends +during an outage is reported when connectivity returns. + +## 6. Time model + +The device may not have wall-clock time โ€” at boot, before NTP, or on a +network with no time source. The protocol therefore never depends on the +device's clock: + +- **`age_ms` is authoritative.** The receiving server computes + `event_time = server_now - age_ms`. Because `age_ms` is recomputed each + time the batch is serialized, this stays correct for queued events + delivered hours late and on devices that never sync. +- **`time` is informational.** Present only when the device clock was synced + at event creation. Servers MAY log it, SHOULD prefer `age_ms`, and MUST NOT + reject events over disagreement between the two. + +Transit latency adds error on the order of the HTTP round trip; for pours and +temperatures this is noise. + +## 7. Response and commands + +The response to every 2xx exchange, authenticated or not: + +```json +{ + "commands": [ + { "id": "cmd_8f21", "type": "authorize", "data": { ... } } + ] +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `commands` | array | no | Serverโ†’device instructions, oldest first. Absent means none โ€” equivalent to `[]`. | +| `commands[].id` | string | yes | Server-assigned, opaque. Devices MUST deduplicate on it: a server re-sends a command until it sees a `command_result`, so the same command may arrive more than once. A duplicate is not re-applied but SHOULD be acknowledged again โ€” the earlier `command_result` may have been lost before delivery. | +| `commands[].type` | string | yes | Command type. Devices MUST acknowledge unknown types with `result: "unsupported"`. | +| `commands[].data` | object | yes | Type-specific payload. | + +This channel is poll-based by design: the device already initiates a request +on every pour, token presentment, and heartbeat, so no listener, NAT +traversal, or second credential is needed. Worst-case command latency equals +the heartbeat interval (1 min default); token-triggered commands arrive in +the same round trip as the token event (see companion doc). A future +transport (WebSocket/MQTT) can push the same command objects unchanged. + +The complete command catalog follows. How these commands compose with token +presentment into an authorization flow โ€” including offline behavior โ€” is +specified in +[Authenticated Pouring](authenticated-pouring.md). + +### 7.1 `authorize` + +Creates โ€” or updates โ€” a grant: the device energizes the grant's relays and +tags pours on the grant's meters with it. One command carries exactly one +grant; a server issuing several grants at once โ€” different taps, different +policy โ€” sends several `authorize` commands in the same response. Typically +sent in the same response as a decision-requesting `token` event (see +companion doc), but valid in any response. + +```json +{ + "id": "cmd_8f21", + "type": "authorize", + "data": { + "grant_id": "g_5501", + "meter_numbers": [0], + "relay_numbers": [1], + "max_volume_ml": 2000, + "max_duration_ms": 120000, + "max_idle_ms": 30000, + "auth_device": "core.rfid", + "token": "0089f2c4" + } +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `grant_id` | string | yes | Server-assigned grant identifier: opaque to the device, at most 64 chars. Pours and `grant_end` events echo it (ยง5.1, ยง5.7); `deauthorize` revokes by it (ยง7.3). Naming a live grant's id updates that grant in place (below). | +| `meter_numbers` | array of integer | yes | Meters this grant covers: pours on them are tagged with the grant, and their flow feeds the volume and idle limits. **The server decides the set** โ€” this is how one token opens one tap, several, or all. | +| `relay_numbers` | array of integer | no | Relays to energize (typically driving solenoid valves) for the life of the grant. Absent or empty โ†’ attribution-only: the meters still meter, no valve is driven. | +| `max_volume_ml` | number | no | Most the grant may pour, summed across its meters. `0` or absent โ†’ unlimited. | +| `max_duration_ms` | integer | no | Hard cap on grant lifetime, from grant creation โ€” reaching it ends the grant even mid-pour. `0` or absent โ†’ unbounded by the server; the device clamp below still applies. | +| `max_idle_ms` | integer | no | Longest stretch with no flow on any granted meter. Flow resets it, so this โ€” not `max_duration_ms` โ€” is what keeps a slow glass alive. `0` or absent โ†’ no idle limit. | +| `auth_device` / `token` | string | no | Echo of the presentment that triggered this grant. The device records them onto resulting `pour` events and uses them to release the grant on the matching detach. | + +Device semantics: + +- **The meterโ†”relay association stays on the server.** The device applies + the two sets verbatim โ€” energize `relay_numbers`, cover `meter_numbers` โ€” + and holds no mapping between them; nothing in grant behavior depends on + which relay serves which meter. +- One active grant **per meter**. A new grant covering an already-covered + meter takes that meter over โ€” the person at the tap is whoever presented + most recently. The takeover is reported as `grant_end` with reason + `replaced` (ยง5.7). +- **Updates.** An `authorize` (under a new command id) naming a live + grant's `grant_id` updates it in place: the sets and limits are replaced, + while poured volume and grant age carry over. This is how a server tops + up a volume budget or extends a session without ending the grant. Meters + leaving the scope are reported as `grant_end` with reason `replaced`. An + update cannot extend a grant past the device clamp (below); for more + time, issue a new grant. +- A relay is energized while **any** active grant names it, and released + when the last such grant ends. +- Pours on a granted meter carry the grant's `auth_device`/`auth_token` + echo and its `grant_id` (ยง5.1). A grant ending mid-pour ends the pour + first, so a pour is always tagged with the grant that actually poured + it. A grant *arriving* mid-pour adopts the in-flight pour, and a grant + *replacing* another mid-pour splits it โ€” the full pourร—grant corner-case + catalog is in the companion doc (ยง8). +- **Limits end grants locally.** When any limit is reached the device + deauthorizes the grant itself โ€” relays released, in-flight pour ended โ€” + and reports a `grant_end` event (ยง5.7) naming which limit tripped. + Volume enforcement is best-effort at the margin: the valve closes the + moment the limit trips, but beer already in flight still registers, so + the final pour may slightly overshoot `max_volume_ml`. +- A grant naming a meter or relay the device does not have is acknowledged + `error` and not applied, in whole. +- **Safety backstop:** the device clamps every grant's total lifetime โ€” + from creation, updates included โ€” to its own `max_grant_duration` + (default **5 minutes**), whatever `max_duration_ms` says โ€” including + "unlimited". A server asking for more + gets the clamp, silently; the command is still acknowledged `ok`. A valve + is a thing that pours beer on the floor when software misbehaves, so the + final bound on "how long can it stay open" belongs to the device. +- Applying the same command id twice is a full no-op โ€” limits, counters, + and timers are not reset (idempotent, since the server re-sends until + acked). + +### 7.2 `deny` + +The explicit refusal of a token presentment. The device signals the user +(refusal tone, LED) and acknowledges with `command_result: ok`. No state +changes: existing grants on other meters are untouched. + +```json +{ + "id": "cmd_8f23", + "type": "deny", + "data": { + "auth_device": "core.rfid", + "token": "0089f2c4", + "reason": "Token not assigned to a user" + } +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `auth_device` / `token` | string | no | Echo of the refused presentment, so a device with several readers signals at the right one. | +| `reason` | string | no | Human-readable explanation. Devices with a display MAY show it; all devices MAY log it. | + +### 7.3 `deauthorize` + +Revokes grants by id: releases their relays, ends any in-flight pour on +their meters (still tagged with the grant that poured it), clears them. +This is the server-initiated cutoff โ€” an admin button, a policy engine, an +emergency stop. Detach and the grant's own limits do the same thing +device-side without a command. Each grant ended this way is also reported +as `grant_end` with reason `command` (ยง5.7). + +```json +{ + "id": "cmd_8f22", + "type": "deauthorize", + "data": { "grant_ids": ["g_5501"] } +} +``` + +| Field | Type | Req | Description | +|---|---|---|---| +| `grant_ids` | array of string | no | Grants to revoke. **Absent means every active grant** โ€” the emergency stop. | + +An id matching no active grant is ignored, and the command still +acknowledges `ok`: the grant may simply have ended on its own before the +command arrived, and the `grant_end` stream already tells the server how. + +### 7.4 Reserved types + +`set_config`, `set_relay`, and `identify` are reserved for a later +revision; until specified, devices answer them with `unsupported`. + +## 8. Pairing + +Provisions the bearer token so a user never has to transport a credential. +The flow is: an unprovisioned device announces itself; it appears on the +server dashboard **by name**; a human clicks allow or deny. + +An unprovisioned device sends ordinary batches (at minimum its `status` +events โ€” pours queue as usual and deliver after pairing) with **no +`Authorization` header**. A server that does not require authentication +simply accepts them, and pairing never begins (ยง2). A server that requires +authentication responds `401` with a pairing body: + +```json +{ "pairing": { "state": "pending" } } +``` + +| `pairing.state` | Server meaning | Device behavior | +|---|---|---| +| `pending` | Device is on the dashboard awaiting a decision. | Keep events queued. Poll every 5 s for the first minute, then at the heartbeat interval. | +| `allowed` | A human approved this device. `pairing.token` holds the newly provisioned bearer token. | Store the token in flash, retry immediately with `Authorization` set. Queued events deliver. | +| `denied` | A human refused this device. | Stop polling until reboot. | + +```json +{ "pairing": { "state": "allowed", "token": "kbe_9c8ef2a1..." } } +``` + +Rules: + +- The token is delivered exactly once, in the `allowed` response. A server + MUST treat the device's first authenticated request as confirmation and + MUST NOT return the token again; a device that loses it re-pairs. +- **Any** 401 on an authenticated request โ€” revoked or rotated token โ€” sends + the device back into this flow. Key rotation is therefore "revoke, then + re-allow from the dashboard," with no device-side ceremony. +- Device identity is self-asserted (TOFU): the human clicking *allow* against + the expected device name is the trust decision. TLS protects the token in + transit. Servers SHOULD show first-seen time and source IP on the pending + entry to make that decision an informed one. +- A `denied` device stays visible server-side so the decision can be + reversed; on its next boot it will ask again. + +## 9. Delivery semantics + +- **At-least-once** for all types except `pour_update` (ยง5.2). The device + retries batches on 5xx/network failure with exponential backoff (base + 30 s, cap 5 min). A new pour or token event resets backoff and triggers an + immediate attempt. +- **Idempotent processing.** Servers MUST deduplicate on + `(device, boot_id, id)`. A retention window of 7 days is sufficient; the + device never re-sends anything older than its queue. +- **Bounded queue, oldest-first eviction.** The queue is fixed-size RAM. + When full, the oldest event is evicted and `events_dropped` increments โ€” + during a long outage the most recent pours are the ones worth keeping. + Events do not survive reboot; `boot_id` makes that harmless for dedup. +- **Ordering.** Events within a batch are oldest-first. `id` orders events + within a boot. Servers should not assume cross-boot ordering. + +## 10. Compatibility rules + +- Servers MUST ignore unknown fields anywhere in the request. +- Servers MUST accept (2xx) and ignore unknown event `type`s. +- Devices MUST ignore unknown fields in the response, and MUST answer + unknown command types with `command_result: unsupported`. +- Additive changes (new optional fields, new event types, new command types) + do not bump `v`. Breaking changes bump `v`, and a server MAY reject + versions it does not speak with a non-401 4xx. + +## 11. Full example + +Device pours twice during a server outage, then connectivity returns; one +batch delivers both pours and a heartbeat: + +``` +POST /kegboard-event HTTP/1.1 +Authorization: Bearer kbe_9c8ef2a1... +Content-Type: application/json + +{ + "v": 1, + "device": "kegboard-a1b2c3", + "boot_id": "9f3a2c1b", + "sent_uptime_ms": 7523000, + "events": [ + { + "id": 17, + "type": "pour", + "age_ms": 912000, + "data": { + "meter_number": 0, + "pour_id": "9b0e6a11-2f4c-49d3-8f6a-c1d2e3f40517", + "volume_ml": 473.1, + "duration_ms": 9800, + "auth_device": "core.rfid", + "auth_token": "0089f2c4", + "grant_id": "g_5488", + "ticks": 2557, + "ml_per_tick": 0.185 + } + }, + { + "id": 18, + "type": "pour", + "age_ms": 402000, + "data": { "meter_number": 1, "pour_id": "0d4f9b82-6e3a-4c15-a7b8-2c9d0e1f6a3b", "volume_ml": 355.0, "duration_ms": 7100 } + }, + { + "id": 19, + "type": "status", + "age_ms": 0, + "time": "2026-08-03T18:02:11Z", + "data": { + "state": "heartbeat", + "fw_version": "4.0.0", + "uptime_ms": 7523000, + "events_dropped": 0, + "config": { "heartbeat_ms": 60000, "pour_update_ms": 1000, "queue_capacity": 16 }, + "meters": [ + { "meter_number": 0, "total_ticks": 920791, "ml_per_tick": 0.185 }, + { "meter_number": 1, "total_ticks": 42031, "ml_per_tick": 0.185 } + ], + "relays": [ { "relay_number": 0 }, { "relay_number": 1 } ] + } + } + ] +} +``` + +``` +HTTP/1.1 200 OK +Content-Type: application/json + +{ "commands": [] } +``` + +The server records pour 17 as having happened 912 s ago and pour 18 as 402 s +ago, regardless of what the device's clock believed. + +## Appendix A. Request JSON Schema + +Normative, to ship in-repo as `schemas/kegboard-event.schema.json`. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kegbot.org/schemas/kegboard-event/1", + "title": "Kegboard event batch", + "type": "object", + "required": [ + "v", + "device", + "boot_id", + "sent_uptime_ms", + "events" + ], + "properties": { + "v": { + "const": 1 + }, + "device": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "boot_id": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "sent_uptime_ms": { + "type": "integer", + "minimum": 0 + }, + "events": { + "type": "array", + "minItems": 1, + "maxItems": 16, + "items": { + "$ref": "#/$defs/event" + } + } + }, + "$defs": { + "event": { + "type": "object", + "required": [ + "id", + "type", + "age_ms", + "data" + ], + "properties": { + "id": { + "type": "integer", + "minimum": 1 + }, + "type": { + "type": "string" + }, + "age_ms": { + "type": "integer", + "minimum": 0 + }, + "time": { + "type": "string", + "format": "date-time" + }, + "data": { + "type": "object" + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "const": "pour" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/pour" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "pour_update" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/pour_update" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "temperature" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/temperature" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "token" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/token" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "status" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/status" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "command_result" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/command_result" + } + } + } + }, + { + "if": { + "properties": { + "type": { + "const": "grant_end" + } + } + }, + "then": { + "properties": { + "data": { + "$ref": "#/$defs/grant_end" + } + } + } + } + ] + }, + "pour": { + "type": "object", + "required": [ + "meter_number", + "pour_id", + "volume_ml", + "duration_ms" + ], + "properties": { + "meter_number": { + "type": "integer", + "minimum": 0 + }, + "pour_id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "volume_ml": { + "type": "number", + "exclusiveMinimum": 0 + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + }, + "auth_device": { + "type": "string" + }, + "auth_token": { + "type": "string" + }, + "grant_id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "ticks": { + "type": "integer", + "minimum": 0 + }, + "ml_per_tick": { + "type": "number", + "exclusiveMinimum": 0 + }, + "tick_series": { + "type": "string", + "pattern": "^\\d+:\\d+( \\d+:\\d+)*$" + } + } + }, + "pour_update": { + "type": "object", + "required": [ + "meter_number", + "pour_id", + "volume_ml", + "duration_ms" + ], + "properties": { + "meter_number": { + "type": "integer", + "minimum": 0 + }, + "pour_id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "volume_ml": { + "type": "number", + "minimum": 0 + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + } + } + }, + "temperature": { + "type": "object", + "required": [ + "sensor", + "temp_c" + ], + "properties": { + "sensor": { + "type": "string", + "minLength": 1 + }, + "temp_c": { + "type": "number" + } + } + }, + "token": { + "type": "object", + "required": [ + "auth_device", + "token", + "action" + ], + "properties": { + "auth_device": { + "type": "string", + "minLength": 1 + }, + "token": { + "type": "string", + "minLength": 1 + }, + "action": { + "enum": [ + "attached", + "detached" + ] + } + } + }, + "status": { + "type": "object", + "required": [ + "state", + "fw_version", + "uptime_ms", + "events_dropped", + "config" + ], + "properties": { + "state": { + "enum": [ + "boot", + "heartbeat" + ] + }, + "fw_version": { + "type": "string" + }, + "uptime_ms": { + "type": "integer", + "minimum": 0 + }, + "wifi_rssi_dbm": { + "type": "integer" + }, + "events_dropped": { + "type": "integer", + "minimum": 0 + }, + "config": { + "type": "object", + "required": [ + "heartbeat_ms", + "pour_update_ms", + "queue_capacity" + ], + "properties": { + "heartbeat_ms": { + "type": "integer", + "minimum": 1000 + }, + "pour_update_ms": { + "type": "integer", + "minimum": 0 + }, + "queue_capacity": { + "type": "integer", + "minimum": 1 + } + } + }, + "meters": { + "type": "array", + "items": { + "type": "object", + "required": [ + "meter_number", + "total_ticks", + "ml_per_tick" + ], + "properties": { + "meter_number": { + "type": "integer", + "minimum": 0 + }, + "total_ticks": { + "type": "integer", + "minimum": 0 + }, + "ml_per_tick": { + "type": "number", + "exclusiveMinimum": 0 + } + } + } + }, + "relays": { + "type": "array", + "items": { + "type": "object", + "required": [ + "relay_number" + ], + "properties": { + "relay_number": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "command_result": { + "type": "object", + "required": [ + "command", + "result" + ], + "properties": { + "command": { + "type": "string", + "minLength": 1 + }, + "result": { + "enum": [ + "ok", + "error", + "unsupported" + ] + }, + "message": { + "type": "string" + } + } + }, + "grant_end": { + "type": "object", + "required": [ + "meter_numbers", + "reason", + "grant_id", + "volume_ml", + "duration_ms" + ], + "properties": { + "meter_numbers": { + "type": "array", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 0 + } + }, + "reason": { + "enum": [ + "max_volume", + "max_duration", + "max_idle", + "detach", + "command", + "replaced" + ] + }, + "auth_device": { + "type": "string" + }, + "auth_token": { + "type": "string" + }, + "grant_id": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "volume_ml": { + "type": "number", + "minimum": 0 + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + } + } + } + } +} +``` + +## Appendix B. Response JSON Schema + +Normative, to ship in-repo as `schemas/kegboard-event-response.schema.json`. +Covers both the authenticated (200) and pairing (401) responses. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://kegbot.org/schemas/kegboard-event-response/1", + "title": "Kegboard event response", + "type": "object", + "properties": { + "commands": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type", "data"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "type": { "type": "string" }, + "data": { "type": "object" } + }, + "allOf": [ + { + "if": { "properties": { "type": { "const": "authorize" } } }, + "then": { "properties": { "data": { "$ref": "#/$defs/authorize" } } } + }, + { + "if": { "properties": { "type": { "const": "deny" } } }, + "then": { "properties": { "data": { "$ref": "#/$defs/deny" } } } + }, + { + "if": { "properties": { "type": { "const": "deauthorize" } } }, + "then": { "properties": { "data": { "$ref": "#/$defs/deauthorize" } } } + } + ] + } + }, + "pairing": { + "type": "object", + "required": ["state"], + "properties": { + "state": { "enum": ["pending", "allowed", "denied"] }, + "token": { "type": "string", "minLength": 1 } + }, + "if": { "properties": { "state": { "const": "allowed" } } }, + "then": { "required": ["state", "token"] } + } + }, + "$defs": { + "authorize": { + "type": "object", + "required": ["grant_id", "meter_numbers"], + "properties": { + "grant_id": { "type": "string", "minLength": 1, "maxLength": 64 }, + "meter_numbers": { + "type": "array", + "minItems": 1, + "items": { "type": "integer", "minimum": 0 } + }, + "relay_numbers": { + "type": "array", + "items": { "type": "integer", "minimum": 0 } + }, + "max_volume_ml": { "type": "number", "minimum": 0 }, + "max_duration_ms": { "type": "integer", "minimum": 0 }, + "max_idle_ms": { "type": "integer", "minimum": 0 }, + "auth_device": { "type": "string" }, + "token": { "type": "string" } + } + }, + "deny": { + "type": "object", + "properties": { + "auth_device": { "type": "string" }, + "token": { "type": "string" }, + "reason": { "type": "string" } + } + }, + "deauthorize": { + "type": "object", + "properties": { + "grant_ids": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1, "maxLength": 64 } + } + } + } + } +} +``` diff --git a/docs/operating-modes.md b/docs/operating-modes.md new file mode 100644 index 0000000..2b3d3a9 --- /dev/null +++ b/docs/operating-modes.md @@ -0,0 +1,52 @@ +# Operating Modes + +A Kegboard is useful at three levels of integration. The modes are additive, +not exclusive: a board reporting to a Kegbot Server can stay connected to +Home Assistant, and every board works standalone when the network is down. + +## Standalone + +No server, nothing else on the network. The board meters pours and runs +local automations (`on_pour_start`, `on_pour_end`). Token readers still fire +their triggers, so a serverless board can gate valves with plain ESPHome +automations if wanted. Watch it work with `esphome logs`. + +This is also the right mode for bench work: verify wiring and calibration +before pointing the board at anything. + +## Home Assistant mode + +Enable ESPHome's native `api:` (the `base` package does) and Home Assistant +discovers the board automatically. Every configured entity โ€” tick totals, +pour volume, flow rate, pouring state, temperatures, relays, auth state โ€” +appears in HA for dashboards and automations. No Kegbot Server involved. + +`examples/home-assistant-2tap.yaml` is a complete config for this mode. + +## Kegbot Server mode + +The flagship. Add `kegboard_reporter` with a `reporting_url` and the board +speaks the [Kegboard Event Protocol](kegboard-event-protocol.md): + +- Finished pours are POSTed as events with authoritative `volume_ml`, + attribution, and a diagnostic tick series. +- Events queue during outages and deliver late with correct timestamps. +- The board pairs itself from the server dashboard โ€” no API key to configure. +- Server commands (authorize, deny, valve control) ride back in HTTP + responses; with `kegboard_auth`, the server decides every token + presentment. + +The server does not have to be Kegbot Server: the protocol is a single +HTTP endpoint with published schemas, and the minimum viable receiver is one +unauthenticated handler. + +`examples/kegbot-2tap.yaml` is the starting point; +`examples/kegbot-full.yaml` adds relays, buzzer, RFID, and iButton auth. + +## Choosing + +| You have | Use | +|---|---| +| A bench and a meter | Standalone | +| Home Assistant | HA mode (keep it enabled in the other modes too โ€” it is how you watch a pour live) | +| A Kegbot Server, or your own receiver | Kegbot Server mode | diff --git a/docs/operation.md b/docs/operation.md new file mode 100644 index 0000000..b80d17b --- /dev/null +++ b/docs/operation.md @@ -0,0 +1,91 @@ +# Theory of Operation + +The internal design of the firmware. Skippable; read it before hacking on the +components or debugging a deployment. + +## Structure + +Kegboard-specific logic lives in a framework-agnostic core (`kbcore` +namespace, plain C++, no ESPHome/ESP-IDF headers, time passed in as +arguments) that runs under host unit tests. The ESPHome components are thin +adapters around it. Rationale and rules in +[`components/kegboard/CORE.md`](https://github.com/Kegbot/kegboard/blob/main/components/kegboard/CORE.md). + +## Flow sensing and pour detection + +Each meter pin counts falling edges in an interrupt handler, with a software +debounce (default 1200 ยตs, matching the legacy firmware). Open-collector +hall-effect meters emit a fixed volume per pulse, so volume is tick count ร— +`ml_per_tick` โ€” an odometer. + +On top of the counter runs a pour state machine: + +- The first tick after idle **starts a pour** (`on_pour_start`, `pouring` + goes on). +- Silence for `idle_timeout` **ends it**. Pours shorter than + `min_pour_ticks` are discarded as drips; pours exceeding + `max_pour_duration` are cut off as a stuck meter. +- The finished pour carries ticks, `volume_ml`, duration, attribution from + the meter's grant (if any), and a bounded tick time series + (`series_resolution` buckets) for diagnostics. + +Calibration is applied on the device; the reported `volume_ml` is +authoritative, raw ticks are advisory. + +## Reporting + +The reporter batches events โ€” pours, live pour updates, temperatures, token +presentments, heartbeats, command results โ€” and POSTs them as JSON to +`reporting_url` per the [event protocol](kegboard-event-protocol.md). + +- **Queueing.** Events that can't be delivered wait in a bounded queue and + retry with exponential backoff (base `retry_interval`, capped at 5 min). + Each event carries its age, so a batch delivered late lands with correct + timestamps even on a board whose clock never synced. The queue is bounded + RAM: when full, the oldest events are evicted first โ€” during a long outage + the most recent pours are the ones worth keeping โ€” and the `dropped` + counter advances. Events do not survive reboot. +- **Idempotency.** Delivery is at-least-once; `(device, boot_id, id)` makes + processing idempotent, so a retry can never create a duplicate drink. +- **Heartbeats** every `heartbeat_interval` give the server a liveness signal + and bound worst-case command latency, since server commands ride only in + HTTP responses. +- **Pairing.** A 401 sends the device into pairing: it appears on the server + dashboard, and on approval receives a bearer token, persisted in flash. + A later 401 revokes it and restarts pairing. + +## Authorization + +`kegboard_auth` holds no token database and no meterโ†”relay map โ€” only the +currently active grants, one per meter, each naming its own meters and +relays from the server. A token presentment is flushed to the server +immediately and the decision (`authorize`/`deny`) returns in the same HTTP +round trip; grants end at their server-set limits โ€” volume poured, total +time, idle time โ€” with total time always clamped to `max_grant_duration`, +on token detach, or on a server `deauthorize`. Every ending is reported +upstream as a `grant_end` event naming the reason. Full semantics in +[Authenticated Pouring](authenticated-pouring.md). + +## Relays + +A relay left on is usually a valve held open. Each relay in +`packages/relays.yaml` starts a watchdog timer when switched on and switches +itself off after `relay_watchdog_timeout`. The timer runs from the on-edge +and cannot be refreshed while the relay is on; it protects relays driven +manually or from Home Assistant. Grant-driven relays are bounded by the +grant clamp (`max_grant_duration`) instead โ€” **set the watchdog longer than +the clamp** (or `0s`) on relays the server grants, or the watchdog will +close the valve mid-grant. + +## Buzzer + +When a passive piezo is connected, the board plays melodies transcribed from +the AVR firmware: + +| Event | Sound | +|---|---| +| Boot complete | Short musical tune (`play_boot_melody`) | +| Token authorized | Rising three-note chirp (`play_auth_melody`) | +| Ping | Two notes (`play_ping_melody`) | + +The scripts are plain `rtttl`; wire them to any trigger you like. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..f9aacb3 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,65 @@ +# Kegboard Overview + +## What is a Kegboard? + +*Kegboard* is the controller board in a [Kegbot](https://kegbot.org/) system. +It's the device that monitors flow meters and publishes this data to a system +like [Kegbot Backend](https://github.com/kegbot/kegbot-backend). + +A kegboard can also monitor additional, optional accessories like temperature sensors +and OneWire-based authentication devices; and it can drive valves, +relays, and buzzers. + +An open source Kegboard project has existed since around 2005. Today, it is +built for esp32-based devices, and leverages the [ESPHome](https://esphome.io/) +framework. + +You can flash this firmware to a device and use it in standalone mode, or +point it at a [Kegbot Server](https://github.com/Kegbot/kegbot-server) instance +for full functionality. + +## Features + +- **Flow sensing.** Any number of meters, limited only by GPIO. Pour + detection runs on the device: start, end, volume, flow rate, and a + diagnostic tick time series per pour. +- **On-device calibration.** `ml_per_tick` lives on the board; reported + volumes are authoritative. +- **Temperature sensing.** DS18B20/DS18S20 sensors on a 1-Wire bus, any + number of them, via ESPHome's stock `dallas_temp`. +- **Authentication.** 125 kHz RFID readers and iButtons out of the box; any + reader ESPHome supports can feed the auth component. +- **Authenticated pouring.** Server-decided grants drive valve relays and + pour attribution. See + [Authenticated Pouring](authenticated-pouring.md). +- **Relay control with watchdog.** Each relay switches itself off after a + timeout, so a crashed controller never leaves a valve open; grant-held + relays are bounded by the grant clamp instead. +- **Buzzer.** The classic Kegboard melodies, transcribed from the AVR + firmware. +- **Outage-proof reporting.** Events queue on the device and deliver late + with correct timestamps; retries can never create a duplicate drink. +- **Dashboard pairing.** No API keys: an unprovisioned board appears on the + server dashboard by name and provisions itself when approved. +- **Home Assistant, free.** Every meter, sensor, and relay is an ESPHome + entity; a server is optional. +- **Open protocol.** Everything is JSON to a single HTTP endpoint, specified + in the [Kegboard Event Protocol](kegboard-event-protocol.md) with normative + schemas. Any server can implement it. + +Everything is optional except a meter. A board with no thermo sensor, no +reader, and no server still meters pours. + +## Requirements + +- **An ESP32 board.** Any ESPHome-supported variant works; tested pin maps + ship for the ESP32-S3-DevKitC-1 (reference), the classic ESP32 DevKitC, and + the ESP32-C6-DevKitC-1. See [Hardware & Wiring](hardware.md). +- **Flow meters.** Open-collector hall-effect meters such as the SwissFlow + SF800. **ESP32 GPIOs are not 5 V tolerant** โ€” see the + [hardware notes](hardware.md#flow-meters). +- **WiFi.** +- **ESPHome** to build and flash, until prebuilt binaries ship. See + [Installation](installation.md). +- Optionally, a **[Kegbot Server](https://github.com/Kegbot/kegbot-server)** + (or anything else implementing the event protocol) to record drinks. diff --git a/docs/pyproject.toml b/docs/pyproject.toml new file mode 100644 index 0000000..862ff9b --- /dev/null +++ b/docs/pyproject.toml @@ -0,0 +1,17 @@ +# The docs toolchain, managed by uv. This is a virtual project (nothing is +# packaged): `uv run`/`uv sync` here install Sphinx and friends from uv.lock. + +[project] +name = "kegboard-docs" +version = "0.0.0" +description = "Sphinx project for the Kegboard manual." +requires-python = ">=3.11" +dependencies = [ + "furo", + "myst-parser", + "sphinx", + "sphinx-autobuild", +] + +[tool.uv] +package = false diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst deleted file mode 100644 index 9acdb79..0000000 --- a/docs/source/changelog.rst +++ /dev/null @@ -1,78 +0,0 @@ -.. _kegboard-changelog: - -Changelog -========= - -Arduino Firmware ------------------ - -v18 (2014-05-14) -^^^^^^^^^^^^^^^^ -* Added heartbeat: device will send a Hello message every 10 seconds. -* Hello message now includes uptime information. - -v17 (2014-02-12) -^^^^^^^^^^^^^^^^ -* Fixed a bug that broke serial communication (introduced in v16). - -v16 (2014-02-07) -^^^^^^^^^^^^^^^^ -* Added chip LED support for Kegboard Pro Mini. - -v15 (2014-01-16) -^^^^^^^^^^^^^^^^ -* Added `set_serial_number` command. - -v14 (2013-07-23) -^^^^^^^^^^^^^^^^ -* Flow LEDs are now toggled on system startup and during pours. -* Experimental debounce feature. -* Support for Parallax RFID readers. - -v13 (2012-10-28) -^^^^^^^^^^^^^^^^ -* Adds support for Wiegand RFID readers (HID ProxPro and similar). - -v12 (2012-07-07) -^^^^^^^^^^^^^^^^ -* Respond to ping with a short melody. - -v11 (2012-05-02) -^^^^^^^^^^^^^^^^ -* Updates for Arduino SDK v1.0; no functional changes. - -v10 (2011-06-19) -^^^^^^^^^^^^^^^^ -* Reverse ID-12 RFID endianness. - -v9 (2011-06-13) -^^^^^^^^^^^^^^^ -* Support ID-12 RFID input - -v8 (2011-06-11) -^^^^^^^^^^^^^^^ -* Expand 'set_output' to support onboard kegboard relay's, flow led's - -v7 (2011-03-16) -^^^^^^^^^^^^^^^ -* Added implementation of `set_output` command, relay output watchdog. - -v6 (2010-09-22) -^^^^^^^^^^^^^^^ -* Added auth_token message. - -v5 (2010-01-10) -^^^^^^^^^^^^^^^ -* Fix issue that caused flow events to be reported too frequently. - -v4 (2010-01-04) -^^^^^^^^^^^^^^^ -* Initial documented version. - -Support Library ---------------- - -v1.0.0 (2012-07-01) -^^^^^^^^^^^^^^^^^^^ -* Support library added to kegboard repository. -* Previous versions were located in the old master Kegbot repository: https://github.com/Kegbot/kegbot diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index f0c516e..0000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,289 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Kegboard documentation build configuration file, created by -# sphinx-quickstart on Wed Jul 18 20:35:24 2012. -# -# This file is execfile()d with the current directory set to its containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys, os - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('.')) - -# -- General configuration ----------------------------------------------------- - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be extensions -# coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.ifconfig', 'sphinx.ext.viewcode'] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix of source filenames. -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'Kegboard' -copyright = u'2012, mike wakerly' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '0.0' -# The full version, including alpha/beta/rc tags. -release = '0.0' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - - -# -- Options for HTML output --------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'default' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Output file base name for HTML help builder. -htmlhelp_basename = 'Kegboarddoc' - - -# -- Options for LaTeX output -------------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [ - ('index', 'Kegboard.tex', u'Kegboard Documentation', - u'mike wakerly', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output -------------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'kegboard', u'Kegboard Documentation', - [u'mike wakerly'], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------------ - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ('index', 'Kegboard', u'Kegboard Documentation', - u'mike wakerly', 'Kegboard', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - - -# -- Options for Epub output --------------------------------------------------- - -# Bibliographic Dublin Core info. -epub_title = u'Kegboard' -epub_author = u'mike wakerly' -epub_publisher = u'mike wakerly' -epub_copyright = u'2012, mike wakerly' - -# The language of the text. It defaults to the language option -# or en if the language is not set. -#epub_language = '' - -# The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' - -# The unique identifier of the text. This can be a ISBN number -# or the project homepage. -#epub_identifier = '' - -# A unique identification for the text. -#epub_uid = '' - -# A tuple containing the cover image and cover page html template filenames. -#epub_cover = () - -# HTML files that should be inserted before the pages created by sphinx. -# The format is a list of tuples containing the path and title. -#epub_pre_files = [] - -# HTML files shat should be inserted after the pages created by sphinx. -# The format is a list of tuples containing the path and title. -#epub_post_files = [] - -# A list of files that should not be packed into the epub file. -#epub_exclude_files = [] - -# The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 - -# Allow duplicate toc entries. -#epub_tocdup = True - - -# Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {'http://docs.python.org/': None} diff --git a/docs/source/firmware.rst b/docs/source/firmware.rst deleted file mode 100644 index 06e1cfb..0000000 --- a/docs/source/firmware.rst +++ /dev/null @@ -1,84 +0,0 @@ -============================== -Building and Flashing Firmware -============================== - -This section describes how to build and flash the Kegboard firmware on a -standard Arduino device. - -Install Arduino software -======================== - -The Arduino project provides a free software development environment for Mac, -Windows, and Linux. It includes a basic text editor, avr microcontroller -toolchains, and many standard libraries. In short, it is everything you need to -program an arduino board. - -Download the software from the `Arduino Downloads Page -`_. Packages are available for Linux, -Mac OS X, and Windows. - -.. note:: - The latest tested version is Arduino 1.0.4 - -When unzipped you will have a single directory that contains all the arduino -software. The name will be something like ``arduino-1.0.4/`` (the version number -will be different for previous versions.) - -Place this directory somewhere appropriate. For Mac users, you can drag it to -your Applications folder. - - -Compile and flash the firmware -============================== - -A new Arduino board includes a basic bootloader on internal flash. The board -needs to be programmed with the custom Kegboard firmware. - -Binary versions of the Kegboard firmware are not provided, so you need to build -it yourself. This process isn't too hard. If you already have the Arduino -software installed, and you have a clone of the kegboard repository somewhere, -you're most of the way there - -The latest version of the Kegboard firmware is available in the **kegboard** -distribution, under the directory ``src/kegboard/``. - -You can also download the entire Kegboard github repository as a zip file: -`Download Kegboard repository `_. - -The file ``kegboard.ino`` is the main source to the firmware. This file is a -C source file, using the file extension preferred by the Arduino development -tools. - -Compile -------- - -Open the file ``kegboard.ino`` in the Arduino studio application. You should see -a listing of the source. You do not need to make any changes to the source. - -Next, configure the Arduino environment to match your Arduino. In particular: - -* Select the correct board type from menu :menuselection:`Tools --> Board` -* Select the serial port it is attached to from the menu - :menuselection:`Tools --> Serial Port` - -You now be ready to build the firmware. Select the menu item -:menuselection:`Sketch --> Verify/Compile`. - - -Flash ------ - -To install the firmware, should select the menu -:menuselection:`File --> Upload to I/O Board` in the Arduino software. The -firmware will be uploaded to you device. - -Depending on your hardware, it may be necessary to reset the board using the -reset pushbutton when starting the upload. - - -Test pin --------- - -To simulate a flow meter, you can connect Pin 12 to either of the two flow meter -pins with a short jumper wire. This pin continuously outputs a slow stream of -pulses, much like a flow meter would do. diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index 4548560..0000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -.. _kegboard-guide: - -Kegboard Firmware Manual -======================== - -This document describes how to program and hack on a Kegboard, our -Arduino-based keg controller board. - -Kegboard software works with the -`Kegboard Pro Mini `_, and can also be used -with a `do-it-yourself Arduino Kegboard `_. - -.. toctree:: - :maxdepth: 2 - - overview - operation - wiring - firmware - support-library - serial-protocol - changelog diff --git a/docs/source/operation.rst b/docs/source/operation.rst deleted file mode 100644 index d66901c..0000000 --- a/docs/source/operation.rst +++ /dev/null @@ -1,83 +0,0 @@ -Theory of Operation -=================== - -This chapter describes the internal design of the Kegboard firmware and how it -manages connected sensors. If you're not interested, you can safely skip to the -next chapter. - -Main event loop ---------------- - -Kegboard's two principle responsibilities are: - -* Monitors and report status and events from attached sensors. -* Accept commands from the host to enable and disable output relays. - -When the board is powered, it immediately begins listening to sensors and -sending events on the serial port. If temperature sensing is enabled, the board -also periodically polls attached sensors. Additionally, the host can send -commands the board at any time. (Commands and events are detailed in -:ref:`kegboard-serial-protocol`.) - - -Flow sensing ------------- - -Each flow meter is connected to one of the Arduino's external interrupt pins. -(On an Arduino, these are digital pins 2 and 3.) - -Kegboard supports "open collector" flow meters. These meters are typically -built using hall effect sensors. As liquid passes through the meter, a series -of pulses is emitted on its output pin. - -Every pulse emitted by the meter corresponds to the same fixed volume of fluid, -therefore volume is determined simply by counting the pulses. The exact volume -of a pulse is a physical property of the meter; the popular Vision 2000 meter -pulses 2200 times per liter. - -In the interrupt service routine for each of these pins, Kegboard increments a -counter every time there is a pulse, keeping a running total of each meter's -volume (similar to an odometer). - - -OneWire presence and temperature sensing ----------------------------------------- - -The Kegboard firmware supports two distinct OneWire (1-wire) sensor busses: the -"thermo" bus, and the "presence" bus. - -The "thermo" bus supports reading Dallas/Maxim DS18B20 and 18S20 OneWire -temperature sensors. This bus is reserved exclusively for temperature sensors; -OneWire devices not matching the DS18B20 or DS18S20 family codes will be ignored -on this bus. Any number of sensors may be attached. - -The firmware also supports a second OneWire bus, which is continuously polled -for OneWire devices. Whenever a OneWire device such as an iButton is connected, -its unique 64-bit OneWire device ID is reported as an authentication token using -:ref:`kegboard-serial-protocol`. - - -Relays ------- - -When a relay is enabled, Kegboard enables the corresponding output and starts a -timer. If the host has not re-activated the relay within that timer, Kegboard -automatically deactivated the output. This prevents prolonged relay operation -if the host crashes unexpectedly. - - -Piezo buzzer ------------- - -A low-cost piezo buzzer can be connected to the :ref:`buzzer output pin -`. When connected, Kegboard will serenade you with some sweet -tunes. - -+----------------------+-------------------------------------------------------+ -| Event | Sound | -+======================+=======================================================+ -| Board power up | Short musical tune (4 notes). | -+----------------------+-------------------------------------------------------+ -| Auth Token Added | Three-tone "added" sound. | -+----------------------+-------------------------------------------------------+ - diff --git a/docs/source/overview.rst b/docs/source/overview.rst deleted file mode 100644 index ba541f7..0000000 --- a/docs/source/overview.rst +++ /dev/null @@ -1,62 +0,0 @@ -================= -Kegboard Overview -================= - -This page describes *Kegboard*, the Arduino-based controller board for Kegbot. - -What is a Kegboard? -=================== - -*Kegboard* is the name we use for the microcontroller board used in a Kegbot -system. Kegboard is the device that monitors all sensors, including the flow -sensors that are essential in any Kegbot configuration. - -There are two commonly-used Kegboard targets: - -* `Kegboard Pro Mini `_, a fully-assembled board, - introduced in early 2014 and sold at the `Kegbot Store `_. -* `DIY Arduino Kegboard `_, a do-it-yourself - option that can be built using an `Arduino `_ - board. - -The Kegboard software package includes firmware and support libraries that -work with either kind of Kegboard. - -Features -======== - -Since not all Kegbots are alike, the Kegbot firmware is designed with -flexibility in mind: We try to support many features and add-on devices in the -core firmware, while still keeping basic functionality tight and fast for the -common configurations. - -Depending on hardware, the Kegboard firmware can support the following -features: - -* **Flow Sensing:** Two independent flow meter inputs (or 6 on Arduino Mega), - allowing you to monitor that many individual beer taps with just one board. -* **Temperature Sensing:** Dedicated OneWire bus for reading DS1820 (DS18S20 and - DS18B20) temperature sensors. An unlimited number of sensors can be - connected, allowing you to independently track keg temperature and ambient - temperature. -* **RFID Authentication:** Authenticate users with cheap 125kHz RFIDs by - connecting the optional ID-12 RFID reader. -* **OneWire Authentication:** Authenticate users with durable iButtons. -* **Relay/Value Control:** Four general purpose outputs can be used - to toggle external devices, such as a valve to prevent unauthorized access. - Relays are monitored by an internal watchdog. -* **Buzzer:** Kegboard will play a short melody whenever an - authentication token is connected or swiped. -* **Extensible Serial Protocol:** If you don't want to use the - rest of the Kegbot software, you can still use Kegboard by implementing its - simple and extensible serial protocol in your system. (See - :ref:`kegboard-serial-protocol`). - -.. note:: - - Because of its limited size, certain features (such as relay control and - RFID reading) are not available on Kegboard Pro Mini. - -Kegboard's firmware is designed to operate correctly even when a feature is -not being used. For example, if the temperature sensor input is not -connected, other features will continue to operate normally. diff --git a/docs/source/serial-protocol.rst b/docs/source/serial-protocol.rst deleted file mode 100644 index 3a93ca2..0000000 --- a/docs/source/serial-protocol.rst +++ /dev/null @@ -1,351 +0,0 @@ -.. _kegboard-serial-protocol: - -================================== -Kegboard Serial Protocol Reference -================================== - -About -===== - -This document describes the protocol implemented in the Kegboard firmware. The -protocol is a simple serial protocol for exchanging data and commands between a -host computer and the controller board. - -.. note:: - Most users don't need to be too familiar with the Kegboard protocol. This - document is intended for someone building a new type of controller board, or - attempting to extend the existing board. For example, if you build a new type - of controller board that speaks this protocol, you should be able to use the - rest of the Kegboard software without further modification. - -Protocol Overview -================= - -The Kegboard Serial Protocol is a binary protocol between the kegboard and the -host PC. Data is delivered from the board in the **message frame format** -(described later). The host can also control and configure the board by sending -messages in the same format. - -Messages arrive from the board *asynchronously*; the host does not need to -request updates to receive information about sensors. Similarly, the host can -issue commands to the board at any time. - - -Software Support -================ - -The pykeg software includes libraries for reading and writing KBSP packets. A -unittest with a sample packet capture is also included. See the code available -in ``pykeg/hw/kegboard``. - - -Message Frame Format -==================== - -Data from the Kegboard is always sent to the host in the `Kegboard Message` -frame format. All messages take the same basic format:: - -