onlyn00bs-badge

OnlyN00bs: a DEF CON 34 friend-finder badge. ESP32 firmware, Web Bluetooth setup app, printable case
git clone https://git.virtualshack.io/onlyn00bs-badge.git
Log | Files | Refs | README | LICENSE

commit f6c7a0b7a0dc2a41b51075a53d238b944bf72710
Author: virtualtack <[email protected]>
Date:   Sat, 26 Sep 2026 12:09:56 -0700

Snapshot of v1.10-dc34-84-g33b1eae

Diffstat:
A.dockerignore | 13+++++++++++++
A.gitignore | 9+++++++++
ADockerfile | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ALICENSE | 21+++++++++++++++++++++
ALICENSE-CC-BY-SA-4.0.txt | 428+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ASNAPSHOT.md | 24++++++++++++++++++++++++
Adocs/docker.md | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/hmac-beacon-auth.md | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adocs/provisioning.md | 364+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adrivers/README.md | 22++++++++++++++++++++++
Afirmware/git_rev.py | 32++++++++++++++++++++++++++++++++
Afirmware/platformio.ini | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/battery.cpp | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/battery.h | 29+++++++++++++++++++++++++++++
Afirmware/src/beacon.h | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/clock.h | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/config.h | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/display.h | 187+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/display_oled.cpp | 909+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/display_serial.cpp | 296+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/encounters.h | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/logos.h | 146+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/main.cpp | 1656+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/peers.h | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/pet.h | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/points.h | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/points_history.h | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Afirmware/src/secrets.h.example | 32++++++++++++++++++++++++++++++++
Afirmware/src/test_log.h | 48++++++++++++++++++++++++++++++++++++++++++++++++
Ahardware/BOM.md | 14++++++++++++++
Ahardware/cad/README.md | 24++++++++++++++++++++++++
Ahardware/cad/gen_caped_crusader.py | 352+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahardware/cad/gen_cat_ears.py | 463+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahardware/cad/gen_stl.py | 2034+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahardware/cad/gen_tophat.py | 234+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ahardware/cad/stl/accessories/caped_crusader_v0.1.stl | 0
Ahardware/cad/stl/accessories/caped_crusader_v0.1_front.png | 0
Ahardware/cad/stl/accessories/caped_crusader_v0.1_iso.png | 0
Ahardware/cad/stl/accessories/caped_crusader_v0.1_on_badge.png | 0
Ahardware/cad/stl/accessories/caped_crusader_v0.1_on_badge_iso.png | 0
Ahardware/cad/stl/accessories/cat_ears_v1.0.stl | 0
Ahardware/cad/stl/accessories/cat_ears_v1.0_front.png | 0
Ahardware/cad/stl/accessories/cat_ears_v1.0_iso.png | 0
Ahardware/cad/stl/accessories/cat_ears_v1.0_top.png | 0
Ahardware/cad/stl/accessories/tophat_v0.1.stl | 0
Ahardware/cad/stl/accessories/tophat_v0.1_front.png | 0
Ahardware/cad/stl/accessories/tophat_v0.1_iso.png | 0
Ahardware/cad/stl/accessories/tophat_v0.1_on_badge.png | 0
Ahardware/cad/stl/accessories/tophat_v0.1_top.png | 0
Ahardware/cad/stl/badge_v1.51_B_back.stl | 0
Ahardware/cad/stl/badge_v1.51_B_front.stl | 0
Ahardware/cad/stl/badge_v1.51_B_front_text.stl | 0
Aprovisioning/index.html | 568+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/assets/badge.css | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/assets/icon.svg | 9+++++++++
Aweb/src/assets/img/accessories.jpg | 0
Aweb/src/assets/img/all_colors.jpg | 0
Aweb/src/assets/img/glow_1.jpg | 0
Aweb/src/assets/img/glow_2.jpg | 0
Aweb/src/assets/img/lanyard.jpg | 0
Aweb/src/assets/img/og-badge.jpg | 0
Aweb/src/assets/img/thumbs/accessories.jpg | 0
Aweb/src/assets/img/thumbs/all_colors.jpg | 0
Aweb/src/assets/img/thumbs/glow_1.jpg | 0
Aweb/src/assets/img/thumbs/glow_2.jpg | 0
Aweb/src/assets/img/thumbs/lanyard.jpg | 0
Aweb/src/gallery/index.html | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/index.html | 349+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aweb/src/manifest.webmanifest | 13+++++++++++++
Aweb/src/sw.js | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
71 files changed, 10554 insertions(+), 0 deletions(-)

diff --git a/.dockerignore b/.dockerignore @@ -0,0 +1,13 @@ +firmware/.pio +firmware/.vscode +firmware/idf_component.yml +firmware/managed_components +.git +.gitignore +**/.DS_Store +docs +hardware +provisioning +*.md +Dockerfile +.dockerignore diff --git a/.gitignore b/.gitignore @@ -0,0 +1,9 @@ +.pio/ +firmware/.pio/ +firmware/src/idf_component.yml +firmware/src/idf_component.yml.orig +firmware/managed_components/ +firmware/src/secrets.h +__pycache__/ +.vscode/ +.DS_Store diff --git a/Dockerfile b/Dockerfile @@ -0,0 +1,61 @@ +# syntax=docker/dockerfile:1 +# +# Reproducible PlatformIO build environment for the friend-finder badge. +# Builds the firmware identically on any machine — no host PlatformIO needed. +# +# docker build -t badge-fw . # build the image (also verifies the FW compiles) +# docker run --rm badge-fw pio run -e oled_v3 # build a specific env from the baked-in source +# docker run --rm -v "$PWD/firmware:/project" badge-fw pio run -e oled_v3 # build your LIVE tree +# +# The image bakes in the heavy one-time downloads (pioarduino core-3.x platform, +# the xtensa toolchain, the registry libs) so container builds are fast and work +# offline. See docs/docker.md for the full workflow. +# +# ⚠️ FLASHING is a HOST concern, not a container one. Docker Desktop on macOS / +# Windows cannot pass a USB serial port into the container, so build here and +# flash from the host (`pio run -t upload`). On a LINUX host you *can* flash from +# the container with `--device=/dev/ttyUSB0`. Details in docs/docker.md. + +FROM python:3.13-slim + +# git — currently UNUSED in-container: no lib_deps are git URLs anymore, and +# .git isn't COPYed below, so the pre:git_rev.py hook stamps GIT_REV="nogit" +# regardless. Kept as cheap insurance for a build that later vendors .git. +# ca-certificates— the pioarduino platform + packages are fetched over https +RUN apt-get update && apt-get install -y --no-install-recommends \ + git ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Pin PlatformIO Core to match the dev machine (parity). Bump deliberately, in +# lockstep with whatever the team builds against locally. +ARG PLATFORMIO_VERSION=6.1.19 +RUN pip install --no-cache-dir "platformio==${PLATFORMIO_VERSION}" intelhex +# ^^^^^^^^ +# intelhex: once pioarduino is installed, the STOCK-espressif32 fallback envs +# (`oled`, `esp32dev`) fail at the image-packaging step without it +# The core-3.x badge builds don't need it; it's cheap +# insurance so every env in platformio.ini builds in this image. + +# Keep PlatformIO's package cache INSIDE the image (committed in the warm layer +# below) so container builds need no network. +ENV PLATFORMIO_CORE_DIR=/opt/pio + +WORKDIR /project + +# ── Warm layer ────────────────────────────────────────────────────────────── +# Copy ONLY platformio.ini first, then fetch every declared dependency. Editing +# source later won't re-trigger this (large, slow) toolchain download — it's a +# separate, cache-friendly layer keyed on platformio.ini alone. +COPY firmware/platformio.ini ./platformio.ini +RUN pio pkg install -e esp32dev_v3 -e oled_v3 + +# ── Source + self-verifying compile ───────────────────────────────────────── +# Copy the project and compile both core-3.x targets during the image build, so +# `docker build` itself proves the firmware compiles. Uses the warmed /opt/pio +# cache, so this is just a (fast, offline) compile — no re-downloads. +COPY firmware/ ./ +RUN pio run -e esp32dev_v3 -e oled_v3 + +# Default action: build the default env (oled_v3). Override per-invocation, +# e.g. `docker run --rm badge-fw pio run -e esp32dev_v3`. +CMD ["pio", "run"] diff --git a/LICENSE b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 virtualtack + +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/LICENSE-CC-BY-SA-4.0.txt b/LICENSE-CC-BY-SA-4.0.txt @@ -0,0 +1,428 @@ +Attribution-ShareAlike 4.0 International + +======================================================================= + +Creative Commons Corporation ("Creative Commons") is not a law firm and +does not provide legal services or legal advice. Distribution of +Creative Commons public licenses does not create a lawyer-client or +other relationship. Creative Commons makes its licenses and related +information available on an "as-is" basis. Creative Commons gives no +warranties regarding its licenses, any material licensed under their +terms and conditions, or any related information. Creative Commons +disclaims all liability for damages resulting from their use to the +fullest extent possible. + +Using Creative Commons Public Licenses + +Creative Commons public licenses provide a standard set of terms and +conditions that creators and other rights holders may use to share +original works of authorship and other material subject to copyright +and certain other rights specified in the public license below. The +following considerations are for informational purposes only, are not +exhaustive, and do not form part of our licenses. + + Considerations for licensors: Our public licenses are + intended for use by those authorized to give the public + permission to use material in ways otherwise restricted by + copyright and certain other rights. Our licenses are + irrevocable. Licensors should read and understand the terms + and conditions of the license they choose before applying it. + Licensors should also secure all rights necessary before + applying our licenses so that the public can reuse the + material as expected. Licensors should clearly mark any + material not subject to the license. This includes other CC- + licensed material, or material used under an exception or + limitation to copyright. More considerations for licensors: + wiki.creativecommons.org/Considerations_for_licensors + + Considerations for the public: By using one of our public + licenses, a licensor grants the public permission to use the + licensed material under specified terms and conditions. If + the licensor's permission is not necessary for any reason--for + example, because of any applicable exception or limitation to + copyright--then that use is not regulated by the license. Our + licenses grant only permissions under copyright and certain + other rights that a licensor has authority to grant. Use of + the licensed material may still be restricted for other + reasons, including because others have copyright or other + rights in the material. A licensor may make special requests, + such as asking that all changes be marked or described. + Although not required by our licenses, you are encouraged to + respect those requests where reasonable. More considerations + for the public: + wiki.creativecommons.org/Considerations_for_licensees + +======================================================================= + +Creative Commons Attribution-ShareAlike 4.0 International Public +License + +By exercising the Licensed Rights (defined below), You accept and agree +to be bound by the terms and conditions of this Creative Commons +Attribution-ShareAlike 4.0 International Public License ("Public +License"). To the extent this Public License may be interpreted as a +contract, You are granted the Licensed Rights in consideration of Your +acceptance of these terms and conditions, and the Licensor grants You +such rights in consideration of benefits the Licensor receives from +making the Licensed Material available under these terms and +conditions. + + +Section 1 -- Definitions. + + a. Adapted Material means material subject to Copyright and Similar + Rights that is derived from or based upon the Licensed Material + and in which the Licensed Material is translated, altered, + arranged, transformed, or otherwise modified in a manner requiring + permission under the Copyright and Similar Rights held by the + Licensor. For purposes of this Public License, where the Licensed + Material is a musical work, performance, or sound recording, + Adapted Material is always produced where the Licensed Material is + synched in timed relation with a moving image. + + b. Adapter's License means the license You apply to Your Copyright + and Similar Rights in Your contributions to Adapted Material in + accordance with the terms and conditions of this Public License. + + c. BY-SA Compatible License means a license listed at + creativecommons.org/compatiblelicenses, approved by Creative + Commons as essentially the equivalent of this Public License. + + d. Copyright and Similar Rights means copyright and/or similar rights + closely related to copyright including, without limitation, + performance, broadcast, sound recording, and Sui Generis Database + Rights, without regard to how the rights are labeled or + categorized. For purposes of this Public License, the rights + specified in Section 2(b)(1)-(2) are not Copyright and Similar + Rights. + + e. Effective Technological Measures means those measures that, in the + absence of proper authority, may not be circumvented under laws + fulfilling obligations under Article 11 of the WIPO Copyright + Treaty adopted on December 20, 1996, and/or similar international + agreements. + + f. Exceptions and Limitations means fair use, fair dealing, and/or + any other exception or limitation to Copyright and Similar Rights + that applies to Your use of the Licensed Material. + + g. License Elements means the license attributes listed in the name + of a Creative Commons Public License. The License Elements of this + Public License are Attribution and ShareAlike. + + h. Licensed Material means the artistic or literary work, database, + or other material to which the Licensor applied this Public + License. + + i. Licensed Rights means the rights granted to You subject to the + terms and conditions of this Public License, which are limited to + all Copyright and Similar Rights that apply to Your use of the + Licensed Material and that the Licensor has authority to license. + + j. Licensor means the individual(s) or entity(ies) granting rights + under this Public License. + + k. Share means to provide material to the public by any means or + process that requires permission under the Licensed Rights, such + as reproduction, public display, public performance, distribution, + dissemination, communication, or importation, and to make material + available to the public including in ways that members of the + public may access the material from a place and at a time + individually chosen by them. + + l. Sui Generis Database Rights means rights other than copyright + resulting from Directive 96/9/EC of the European Parliament and of + the Council of 11 March 1996 on the legal protection of databases, + as amended and/or succeeded, as well as other essentially + equivalent rights anywhere in the world. + + m. You means the individual or entity exercising the Licensed Rights + under this Public License. Your has a corresponding meaning. + + +Section 2 -- Scope. + + a. License grant. + + 1. Subject to the terms and conditions of this Public License, + the Licensor hereby grants You a worldwide, royalty-free, + non-sublicensable, non-exclusive, irrevocable license to + exercise the Licensed Rights in the Licensed Material to: + + a. reproduce and Share the Licensed Material, in whole or + in part; and + + b. produce, reproduce, and Share Adapted Material. + + 2. Exceptions and Limitations. For the avoidance of doubt, where + Exceptions and Limitations apply to Your use, this Public + License does not apply, and You do not need to comply with + its terms and conditions. + + 3. Term. The term of this Public License is specified in Section + 6(a). + + 4. Media and formats; technical modifications allowed. The + Licensor authorizes You to exercise the Licensed Rights in + all media and formats whether now known or hereafter created, + and to make technical modifications necessary to do so. The + Licensor waives and/or agrees not to assert any right or + authority to forbid You from making technical modifications + necessary to exercise the Licensed Rights, including + technical modifications necessary to circumvent Effective + Technological Measures. For purposes of this Public License, + simply making modifications authorized by this Section 2(a) + (4) never produces Adapted Material. + + 5. Downstream recipients. + + a. Offer from the Licensor -- Licensed Material. Every + recipient of the Licensed Material automatically + receives an offer from the Licensor to exercise the + Licensed Rights under the terms and conditions of this + Public License. + + b. Additional offer from the Licensor -- Adapted Material. + Every recipient of Adapted Material from You + automatically receives an offer from the Licensor to + exercise the Licensed Rights in the Adapted Material + under the conditions of the Adapter's License You apply. + + c. No downstream restrictions. You may not offer or impose + any additional or different terms or conditions on, or + apply any Effective Technological Measures to, the + Licensed Material if doing so restricts exercise of the + Licensed Rights by any recipient of the Licensed + Material. + + 6. No endorsement. Nothing in this Public License constitutes or + may be construed as permission to assert or imply that You + are, or that Your use of the Licensed Material is, connected + with, or sponsored, endorsed, or granted official status by, + the Licensor or others designated to receive attribution as + provided in Section 3(a)(1)(A)(i). + + b. Other rights. + + 1. Moral rights, such as the right of integrity, are not + licensed under this Public License, nor are publicity, + privacy, and/or other similar personality rights; however, to + the extent possible, the Licensor waives and/or agrees not to + assert any such rights held by the Licensor to the limited + extent necessary to allow You to exercise the Licensed + Rights, but not otherwise. + + 2. Patent and trademark rights are not licensed under this + Public License. + + 3. To the extent possible, the Licensor waives any right to + collect royalties from You for the exercise of the Licensed + Rights, whether directly or through a collecting society + under any voluntary or waivable statutory or compulsory + licensing scheme. In all other cases the Licensor expressly + reserves any right to collect such royalties. + + +Section 3 -- License Conditions. + +Your exercise of the Licensed Rights is expressly made subject to the +following conditions. + + a. Attribution. + + 1. If You Share the Licensed Material (including in modified + form), You must: + + a. retain the following if it is supplied by the Licensor + with the Licensed Material: + + i. identification of the creator(s) of the Licensed + Material and any others designated to receive + attribution, in any reasonable manner requested by + the Licensor (including by pseudonym if + designated); + + ii. a copyright notice; + + iii. a notice that refers to this Public License; + + iv. a notice that refers to the disclaimer of + warranties; + + v. a URI or hyperlink to the Licensed Material to the + extent reasonably practicable; + + b. indicate if You modified the Licensed Material and + retain an indication of any previous modifications; and + + c. indicate the Licensed Material is licensed under this + Public License, and include the text of, or the URI or + hyperlink to, this Public License. + + 2. You may satisfy the conditions in Section 3(a)(1) in any + reasonable manner based on the medium, means, and context in + which You Share the Licensed Material. For example, it may be + reasonable to satisfy the conditions by providing a URI or + hyperlink to a resource that includes the required + information. + + 3. If requested by the Licensor, You must remove any of the + information required by Section 3(a)(1)(A) to the extent + reasonably practicable. + + b. ShareAlike. + + In addition to the conditions in Section 3(a), if You Share + Adapted Material You produce, the following conditions also apply. + + 1. The Adapter's License You apply must be a Creative Commons + license with the same License Elements, this version or + later, or a BY-SA Compatible License. + + 2. You must include the text of, or the URI or hyperlink to, the + Adapter's License You apply. You may satisfy this condition + in any reasonable manner based on the medium, means, and + context in which You Share Adapted Material. + + 3. You may not offer or impose any additional or different terms + or conditions on, or apply any Effective Technological + Measures to, Adapted Material that restrict exercise of the + rights granted under the Adapter's License You apply. + + +Section 4 -- Sui Generis Database Rights. + +Where the Licensed Rights include Sui Generis Database Rights that +apply to Your use of the Licensed Material: + + a. for the avoidance of doubt, Section 2(a)(1) grants You the right + to extract, reuse, reproduce, and Share all or a substantial + portion of the contents of the database; + + b. if You include all or a substantial portion of the database + contents in a database in which You have Sui Generis Database + Rights, then the database in which You have Sui Generis Database + Rights (but not its individual contents) is Adapted Material, + including for purposes of Section 3(b); and + + c. You must comply with the conditions in Section 3(a) if You Share + all or a substantial portion of the contents of the database. + +For the avoidance of doubt, this Section 4 supplements and does not +replace Your obligations under this Public License where the Licensed +Rights include other Copyright and Similar Rights. + + +Section 5 -- Disclaimer of Warranties and Limitation of Liability. + + a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE + EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS + AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF + ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, + IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, + WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR + PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, + ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT + KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT + ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU. + + b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE + TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, + NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT, + INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES, + COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR + USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN + ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR + DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR + IN PART, THIS LIMITATION MAY NOT APPLY TO YOU. + + c. The disclaimer of warranties and limitation of liability provided + above shall be interpreted in a manner that, to the extent + possible, most closely approximates an absolute disclaimer and + waiver of all liability. + + +Section 6 -- Term and Termination. + + a. This Public License applies for the term of the Copyright and + Similar Rights licensed here. However, if You fail to comply with + this Public License, then Your rights under this Public License + terminate automatically. + + b. Where Your right to use the Licensed Material has terminated under + Section 6(a), it reinstates: + + 1. automatically as of the date the violation is cured, provided + it is cured within 30 days of Your discovery of the + violation; or + + 2. upon express reinstatement by the Licensor. + + For the avoidance of doubt, this Section 6(b) does not affect any + right the Licensor may have to seek remedies for Your violations + of this Public License. + + c. For the avoidance of doubt, the Licensor may also offer the + Licensed Material under separate terms or conditions or stop + distributing the Licensed Material at any time; however, doing so + will not terminate this Public License. + + d. Sections 1, 5, 6, 7, and 8 survive termination of this Public + License. + + +Section 7 -- Other Terms and Conditions. + + a. The Licensor shall not be bound by any additional or different + terms or conditions communicated by You unless expressly agreed. + + b. Any arrangements, understandings, or agreements regarding the + Licensed Material not stated herein are separate from and + independent of the terms and conditions of this Public License. + + +Section 8 -- Interpretation. + + a. For the avoidance of doubt, this Public License does not, and + shall not be interpreted to, reduce, limit, restrict, or impose + conditions on any use of the Licensed Material that could lawfully + be made without permission under this Public License. + + b. To the extent possible, if any provision of this Public License is + deemed unenforceable, it shall be automatically reformed to the + minimum extent necessary to make it enforceable. If the provision + cannot be reformed, it shall be severed from this Public License + without affecting the enforceability of the remaining terms and + conditions. + + c. No term or condition of this Public License will be waived and no + failure to comply consented to unless expressly agreed to by the + Licensor. + + d. Nothing in this Public License constitutes or may be interpreted + as a limitation upon, or waiver of, any privileges and immunities + that apply to the Licensor or You, including from the legal + processes of any jurisdiction or authority. + + +======================================================================= + +Creative Commons is not a party to its public +licenses. Notwithstanding, Creative Commons may elect to apply one of +its public licenses to material it publishes and in those instances +will be considered the “Licensor.” The text of the Creative Commons +public licenses is dedicated to the public domain under the CC0 Public +Domain Dedication. Except for the limited purpose of indicating that +material is shared under a Creative Commons public license or as +otherwise permitted by the Creative Commons policies published at +creativecommons.org/policies, Creative Commons does not authorize the +use of the trademark "Creative Commons" or any other trademark or logo +of Creative Commons without its prior written consent including, +without limitation, in connection with any unauthorized modifications +to any of its public licenses or any other arrangements, +understandings, or agreements concerning use of licensed material. For +the avoidance of doubt, this paragraph does not form part of the +public licenses. + +Creative Commons may be contacted at creativecommons.org. + diff --git a/README.md b/README.md @@ -0,0 +1,114 @@ +# 📟 OnlyN00bs — a DEF CON 34 friend-finder badge + +A badge for a friend group: each badge broadcasts your handle and shows the handles of +other crew badges in range — sorted by signal strength, so the closest friends float to +the top. Built to be **spoof-proof in the worst RF environment on earth**: beacons are +HMAC-signed with a shared group key, so randos and impersonators never make it onto your +screen. + +``` + you: crash_override ── 4 nearby ────────── + ▁▂▃▄▅ acid_burn -52 dBm + ▁▂▃▄ cereal_killer -61 dBm + ▁▂▃ lord_nikon -73 dBm + ▁▂ phantom_phreak -88 dBm +``` + +User guide (setup, screens, button, battery, troubleshooting): +[badge.virtualshack.io](https://badge.virtualshack.io). + +## Hardware + +An off-the-shelf **FireBeetle 2 ESP32-E** (onboard LiPo charger + JST-PH) driving a +**2.42" SSD1309 mono OLED** off a **2000 mAh LiPo**, one push button, in a 3D-printed case. +Parts: [`hardware/BOM.md`](hardware/BOM.md). Case files: [`hardware/cad/`](hardware/cad/). + +## Layout + +``` +firmware/ PlatformIO project (Arduino-ESP32 core 3.x) + platformio.ini build envs (below) + src/ + config.h channel, intervals, BLE UUIDs, pins + secrets.h.example template for your crew's group key (copy to secrets.h) + beacon.h signed wire format + HMAC sign/verify + peers.h bounded, flood-resistant peer table + encounters.h LittleFS encounter log + report aggregation + clock.h soft real-time clock (BLE-seeded, NVS-checkpointed) + points.h · points_history.h · pet.h · battery.* + display_oled.cpp the badge display (U8g2) + main.cpp mode state machine (provisioning / discovery / views) +provisioning/ + index.html Web Bluetooth setup app — handle, avatar, clock, metrics export +web/src/ the site at badge.virtualshack.io — static, no build step; the + service worker makes it (and the setup app) work offline +hardware/ + BOM.md parts for one badge + cad/ case generators (pure Python → STL) + the production STLs +docs/ + hmac-beacon-auth.md the beacon signing scheme and its limits + provisioning.md the BLE setup protocol + docker.md reproducible build container +drivers/ USB-serial driver note for flashing from macOS +``` + +## How it works + +**Setup (BLE).** In setup mode the badge advertises a BLE GATT service and shows a fresh +3-character pairing code. Open `provisioning/index.html` in Chrome or Edge (Web Bluetooth +— not available on iOS), connect, enter the code, then set a handle, avatar and clock. The +handle is sanitized (printable ASCII, ≤ 24 bytes) on both sides and stored in NVS. Setup +only listens while the badge is in setup mode. Details: [`docs/provisioning.md`](docs/provisioning.md). + +**Discovery (ESP-NOW).** The badge broadcasts a signed beacon about once a second on a +fixed channel. Incoming beacons are HMAC-verified against the group key *before* anything +touches the peer table; the table is bounded and ages entries out, so a flood of garbage +can't exhaust memory or fill the screen. + +**Encounters, points, avatar.** Each continuous stretch near a friend is logged to +LittleFS. The badge ranks friends by time together, awards points for time spent near crew, +graphs them over the con, and runs an ASCII avatar that reacts to events. The setup app +exports the logs and points over BLE to charts, PDF and JSON — nothing leaves your browser. + +## Security model + +Full mechanism and limits: [`docs/hmac-beacon-auth.md`](docs/hmac-beacon-auth.md). + +- **Shared group key.** Beacon = `{magic, version, counter, handle, HMAC-SHA256(key, …)}`. + Verification failure → silent drop. That filters outsiders and spoofers. +- **It signs, it does not encrypt.** Anyone listening on 2.4 GHz can read handles and + follow a badge. Pick a handle you're happy to have in the air. +- **`counter`** jumps forward each boot, so captured beacons can't be replayed as fresher. +- **Handles are untrusted input.** A handle set over BLE is clamped to printable ASCII + (≤ 24 bytes) before it's stored; a received handle must carry a valid signature and pass + a 1–24 byte length check, but its bytes aren't filtered — a key holder can send any + byte values. + +## Build and flash + +```sh +cp firmware/src/secrets.h.example firmware/src/secrets.h +# put your crew's key in it: openssl rand -hex 32 +cd firmware +pio run -e oled_v3_prod -t upload # refuses to build without a real key +pio device monitor +``` + +Every badge in a crew needs the **same** key; badges with different keys can't see each +other. `oled_v3` is the same firmware without the key check, for development. Prefer no +host toolchain: [`docs/docker.md`](docs/docker.md). Then open `provisioning/index.html` +from `localhost` (a secure context, so Web Bluetooth works) to set the badge up. + +## License + +| what | paths | license | +|---|---|---| +| Code | `firmware/`, `provisioning/`, `web/src/` (HTML, CSS, JS), `hardware/cad/*.py`, `Dockerfile` | [MIT](LICENSE) | +| Case models, docs, photos | `hardware/cad/stl/`, `docs/`, `*.md`, `web/src/assets/img/` | [CC BY-SA 4.0](LICENSE-CC-BY-SA-4.0.txt) | + +Libraries are fetched at build time and keep their own licenses (arduino-esp32 LGPL-2.1, +ESP-IDF Apache-2.0, NimBLE-Arduino Apache-2.0, U8g2 BSD-2-Clause) — relevant if you +redistribute a compiled firmware image. + +Attribution: **virtualtack** — [badge.virtualshack.io](https://badge.virtualshack.io). +See [`SNAPSHOT.md`](SNAPSHOT.md) for what this repository deliberately leaves out. diff --git a/SNAPSHOT.md b/SNAPSHOT.md @@ -0,0 +1,24 @@ +# About this snapshot + +This repository is the public release of the OnlyN00bs badge, taken from its private +development repo at `v1.10-dc34-84-g33b1eae` on 2026-09-26. It holds what describes the **production unit +as it is**: the firmware, the Web Bluetooth setup app, the site, the parts list, and the +case. + +Deliberately left out: + +- **Development history** — the decision log, plans and design notes, bring-up checklists, + audits and status tracking. This is the finished badge, not the path to it. +- **Anything from badges in service at DEF CON 34** — flash dumps, encounter logs, and the + record of who got which unit. That is other people's data. +- **Hardware detail beyond the BOM** — costs, vendors, orders and sourcing notes — and case + variants that never went into production. +- **How the website is deployed** — server, proxy and publishing tooling. The site itself + is in `web/src/`, live at [badge.virtualshack.io](https://badge.virtualshack.io). + +**There is no group key in here, and there never was.** `firmware/src/secrets.h` is +gitignored; without it the build uses a public throwaway key, and the `oled_v3_prod` +environment refuses to compile. Make your own crew's key: +`cp firmware/src/secrets.h.example firmware/src/secrets.h`, then `openssl rand -hex 32`. +Badges only see badges that share a key — your crew won't interoperate with ours, which is +the point. diff --git a/docs/docker.md b/docs/docker.md @@ -0,0 +1,98 @@ +# Docker: reproducible build environment + +Spin up the firmware build toolchain on any machine without installing +PlatformIO, the pioarduino platform, or the xtensa toolchain by hand. The +[`Dockerfile`](../Dockerfile) at the repo root bakes all of that in, pinned, so +a build on your laptop and a build in CI produce the same artifacts. + +> **Scope:** this containerizes **building**. It does **not** containerize +> flashing or provisioning — those touch host hardware (USB serial, Bluetooth) +> that Docker Desktop can't reach on macOS/Windows. See *Flashing* below. + +## What's pinned + +| Layer | Pin | Where | +|---|---|---| +| PlatformIO Core | `6.1.19` | `ARG PLATFORMIO_VERSION` in the Dockerfile (matches the dev machine) | +| Arduino platform | pioarduino `55.03.38` (core 3.x / IDF 5.x) | `[core3x]` in `firmware/platformio.ini` | +| Base image | `python:3.13-slim` | Dockerfile | +| Libs | NimBLE-Arduino `^2.1.0`, U8g2 `^2.35.30` | `firmware/platformio.ini` | + +The image build **self-verifies**: it compiles `esp32dev_v3` + `oled_v3` as its +final step, so `docker build` failing means the firmware doesn't compile. + +## Build the image + +```sh +# from the repo root +docker build -t badge-fw . +``` + +First build pulls the base image + pioarduino platform + xtensa toolchain (a few +hundred MB, one time); they're cached in the image afterwards, so later container +builds are fast and need no network. + +## Build firmware + +Two modes: + +**A) Baked-in source** (self-contained — good for CI / a clean checkout): +```sh +docker run --rm badge-fw pio run -e oled_v3 # the badge build (default env) +docker run --rm badge-fw pio run -e esp32dev_v3 # serial dev build (deprecated) +``` + +**B) Live working tree** (iterate on the host, build in the container): +```sh +docker run --rm -v "$PWD/firmware:/project" badge-fw pio run -e oled_v3 +``` +The mount shadows the baked-in source with your edits; the toolchain cache +(`/opt/pio`) is separate, so builds still run offline. Build artifacts land in +`firmware/.pio/build/<env>/` on the host (already git-ignored). + +> On Linux, a bind-mounted build writes `.pio` as root. Add `--user "$(id -u):$(id -g)"` +> if you want host-owned artifacts. + +## Get the binary out (baked-in mode) + +```sh +id=$(docker create badge-fw) +docker cp "$id:/project/.pio/build/oled_v3/firmware.bin" ./firmware.bin +docker rm "$id" +``` +(Mode B writes it straight to `firmware/.pio/build/...` on the host — no copy needed.) + +## Flashing + +Flashing needs a USB serial port, which **Docker Desktop on macOS/Windows cannot +pass into a container**. So: + +- **macOS / Windows:** build in the container, **flash from the host** with your + local PlatformIO (`cd firmware && pio run -e oled_v3 -t upload`), or flash the + extracted `firmware.bin` with esptool. (Host serial notes live in the + `esptool-serial-access` memory.) +- **Linux host:** you *can* flash from the container by passing the device: + ```sh + docker run --rm --device=/dev/ttyUSB0 -v "$PWD/firmware:/project" \ + badge-fw pio run -e oled_v3 -t upload --upload-port /dev/ttyUSB0 + ``` + (`/dev/ttyACM0` for native-USB boards.) + +## Provisioning + +The Web-Bluetooth provisioning page (`provisioning/index.html`) is a **host + +browser** task — BLE doesn't cross the container boundary, and it needs +Chrome/Edge with Web Bluetooth. Serve it on the host: +`cd provisioning && python3 -m http.server 8000`. + +## Maintaining + +- **Bump PlatformIO Core:** change `ARG PLATFORMIO_VERSION` and rebuild. Keep it + matched to the dev machine's `pio --version`. +- **Bump the Arduino platform:** edit the pioarduino tag in + `firmware/platformio.ini` (`[core3x]`) — the Dockerfile picks it up, no Docker + edit needed. +- **A new env that pulls new libs:** the warm layer installs deps for + `esp32dev_v3` + `oled_v3`; if you add an env you want pre-warmed, add it to the + `pio pkg install` line. Other envs still build, they just fetch their platform + on first use. diff --git a/docs/hmac-beacon-auth.md b/docs/hmac-beacon-auth.md @@ -0,0 +1,223 @@ +# Beacon authentication: the HMAC system + +How the badge proves a beacon came from a crew member and not from a rando or a +spoofer in the (very hostile) DefCon RF soup. This is the load-bearing security +mechanism — everything else (peer table, finder sort, encounter log, points) +trusts that a beacon which *reaches* it has already been authenticated here. + +Source of truth: [`firmware/src/beacon.h`](../firmware/src/beacon.h) (wire format ++ sign/verify) and [`firmware/src/config.h`](../firmware/src/config.h) (the group +key + handle clamp). This doc explains the *why*; the code is the *what*. + +> Threat model in one line: **friends vs. the con.** We are not defending against +> a nation-state with the group key — we are keeping outsiders and impersonators +> who *don't* have the key off everyone's screen. Pick that frame up before +> reading "what it doesn't stop" below. + +--- + +## Why HMAC (and not a plain hash, or encryption) + +A beacon is a tiny public announcement: *"handle `cereal_killer` is here."* The handle +isn't secret — it's literally meant to show up on other people's screens. So we +don't want **confidentiality**. We want **authenticity + integrity**: a receiver +must be able to tell that this beacon was minted by *a holder of the shared group +key* and wasn't altered in flight. + +``` + plain hash SHA256(message) ← anyone can compute it → anyone can forge + encryption AES(key, message) ← hides the handle we WANT public; wrong tool + keyed MAC HMAC-SHA256(key, message) ← unforgeable without the key ✓ this one +``` + +An **HMAC** (Hash-based Message Authentication Code) folds a secret key into the +hash so that only key-holders can produce a tag that verifies. Without the key you +can't compute the right tag for a message, and you can't tweak a signed message +without invalidating its tag. That's exactly "came from the crew, unmodified." + +We use **HMAC-SHA256** because mbedTLS ships inside the ESP32 Arduino core +(`mbedtls/md.h`) — no extra dependency, hardware-accelerated SHA on the ESP32. + +--- + +## The wire format + +A `Beacon` is a fixed-size, `#pragma pack(1)` struct broadcast over ESP-NOW ~1×/sec +to the broadcast address on a fixed channel. **64 bytes** total (wire **v2**, which added +`pet` + `pet_face`; v1 badges won't verify a v2 beacon and vice-versa): + +``` + offset field size notes + ────── ─────────── ──── ───────────────────────────────────────────── + 0 magic 1 0xB1 — cheap pre-filter, not security + 1 version 1 0x02 — wire-format version (BEACON_VERSION) + 2 counter 4 uint32, monotonic-ish freshness hint + 6 handle_len 1 1..24 (HANDLE_MAX_LEN) + 7 handle 24 zero-padded; the padding is signed too + 31 pet 1 sender's pet index (==PET_BUILTIN_N means custom) + 32 pet_face 16 sender's pet idle face, zero-padded (PET_FACE_MAX) + ┌───────────────────────────────────────────────┐ + │ signed region = bytes [0 .. 48) = 48 bytes │ ← offsetof(Beacon, tag) + └───────────────────────────────────────────────┘ + 48 tag 16 HMAC-SHA256(PSK, signed_region) truncated to 128 bits + ────── ─── + 64 +``` + +The **signed region is everything before `tag`** (`beacon_signed_len()` = +`offsetof(Beacon, tag)` = 48 bytes). The tag is computed *over* those 48 bytes and +then appended. So `magic`, `version`, `counter`, `handle_len`, `pet`, `pet_face`, +**and the zero-padding of `handle`** are all authenticated — you can't tamper with any +of them without breaking the tag. + +ESP-NOW's payload limit is 250 bytes; at 64 bytes the beacon has enormous +headroom (relevant to the Ed25519 upgrade path below). + +--- + +## Signing (transmit path) + +`beacon_sign()` ([beacon.h:49](../firmware/src/beacon.h)) — caller zero-pads +`handle` first, then: + +``` + full[32] = HMAC-SHA256(GROUP_PSK, beacon[0..48)) // mbedtls_md_hmac + tag[16] = full[0..16) // truncate to 128 bits +``` + +We keep the **first 128 bits** of the 256-bit HMAC. 128 bits of MAC is comfortably +beyond brute-forceable (an attacker gets one online guess per broadcast; there's no +offline oracle), and halving the tag saves 16 bytes on every packet. + +## Verifying (receive path) + +`beacon_verify()` ([beacon.h:63](../firmware/src/beacon.h)) runs **cheap structural +checks first, the expensive crypto last**, and a receiver drops anything that fails +*before* it ever touches the peer table: + +``` + incoming bytes + │ + ├─ len < sizeof(Beacon)? → drop (runt / truncated) + ├─ magic != 0xB1? → drop (not ours / noise) ┐ cheap + ├─ version != 2? → drop (wrong wire version) │ pre- + ├─ handle_len ∉ [1..24]? → drop (malformed) ┘ filters + │ + ├─ full = HMAC-SHA256(PSK, bytes[0..48)) ← the work + └─ ct_equal(full[0..16), tag, 16)? → NO → drop (forgery / wrong key) + YES → accept → peer table +``` + +Ordering matters: the magic/version/length gates are one-byte comparisons that +reject the overwhelming majority of con-floor garbage for almost no CPU, so we only +pay for an HMAC computation on packets that are at least shaped like ours. The HMAC +is the actual trust boundary; the pre-filters are just there to make the common +"not even ours" case free. + +### Constant-time comparison + +The tag compare is `ct_equal()` ([beacon.h:56](../firmware/src/beacon.h)), which +XOR-accumulates all 16 bytes and checks the result once at the end: + +```c +uint8_t diff = 0; +for (i = 0; i < n; i++) diff |= a[i] ^ b[i]; +return diff == 0; +``` + +A naive `memcmp` returns early on the first mismatching byte, so its runtime leaks +*how many leading bytes matched*. An attacker who can measure that timing could +forge a valid tag byte-by-byte (a classic MAC timing attack). The XOR-accumulate +form always touches all 16 bytes, so the timing is independent of where the +mismatch is. It's cheap insurance and the correct default for any MAC/tag compare. + +--- + +## What this stops — and what it doesn't + +**Stops (the friends-vs-the-con threat model):** +- **Outsiders / randos** — no group key → can't produce a verifying tag → silently + dropped, never rendered, never logged. +- **Spoofers** — can't forge "I'm `acid_burn`" without the key; can't replay a + captured beacon with the handle swapped (that breaks the tag). +- **Tampering / corruption** — any bit flip in the signed region fails verify. +- **Outsider floods** — unsigned garbage gets rejected at the cheap pre-filters or the + HMAC, before it can consume a slot in the bounded peer table (`MAX_PEERS`). *Caveat:* + this stops *keyless* floods only. A **key holder** can still fill the 32-slot table + with valid, signed beacons carrying distinct handles — the HMAC proves "a crew member + sent this," not that the handles are real. That's the same trust boundary as + friend-impersonates-friend. Received handles are length-checked in `beacon_verify` but + not charset-filtered, so a key holder can send arbitrary handle bytes. + +**Does NOT stop (by design, accepted for v1):** +- **Friend-impersonates-friend.** The key is a *shared symmetric group* secret, so + *any* key-holder can mint a valid beacon for *any* handle. Authenticity is "a + crew member sent this," not "*this specific* crew member sent this." Fix = the + Ed25519 upgrade below. +- **A lost / stolen badge.** The PSK lives in flash (no secure-boot / flash- + encryption in v1), so a recovered badge yields the group key → the whole crew is + compromised until you **rekey everyone**. There's no per-badge revocation with a + shared key. +- **Replay, strictly.** `beacon_verify()` does **not** gate on `counter` — a + captured-and-rebroadcast beacon still verifies (it's a real, signed beacon). The + `counter` is a *freshness hint* — **not** a cryptographic anti-replay nonce. Note it + is currently **stored but not read**: `peers.upsert()` copies it into the peer slot + (`peers.h:43`) and nothing consumes it for freshness, dedup, or ordering. So it neither + defends against replay nor is wired into any peer-table logic today; it's a reserved + field the Ed25519 upgrade (or a real anti-replay scheme) could put to work. True + anti-replay (per-peer monotonic counters or timestamp binding enforced in verify) is a + noted non-goal for v1. For a presence beacon that re-broadcasts every second anyway, + replaying one is low-value. + +This is the right amount of security for the job: it makes the screen trustworthy +against the con, without pretending to be a PKI. + +--- + +## Upgrade path: per-badge Ed25519 + allowlist + +When "friends can't impersonate *each other*" and "revoke a lost badge without +rekeying the crew" become worth the complexity, swap the symmetric PSK for +**asymmetric identities**: + +``` + each badge: Ed25519 keypair (private key stays on the badge) + each badge: carries an ALLOWLIST of the crew's public keys + beacon: { …, pubkey(32), Ed25519_signature(64) } + verify: pubkey ∈ allowlist? AND sig valid over the signed region? +``` + +- **Non-impersonation** — only the holder of `cereal_killer`'s private key can sign as + `cereal_killer`; another crew member's key signs as *them*. +- **Revocation** — drop a compromised badge's pubkey from everyone's allowlist; no + group rekey. +- **Payload budget** — a 64-byte sig + 32-byte pubkey + the 48-byte body ≈ 127 + bytes, still well under ESP-NOW's 250-byte limit. + +Cost: key distribution/management, slower verify (asymmetric vs. a single HMAC), +and bigger packets. Parked until the threat model demands it. + +--- + +## Where to change things + +- **The group key** — `GROUP_PSK[]` in [`config.h`](../firmware/src/config.h). + ⚠️ The repo ships a throwaway key; generate a real one (`openssl rand -hex 32`) + and keep it out of git before anything public. +- **Tag length** — `TAG_LEN` in [`beacon.h`](../firmware/src/beacon.h) (currently + 16 = 128 bits). Both sign and verify read it, so changing it is one edit — but + every badge must agree. +- **Hash / MAC algorithm** — `MBEDTLS_MD_SHA256` in `beacon_hmac()`. Same "all + badges must agree" rule. +- **Handle clamp** — `HANDLE_MAX_LEN` (config.h); also enforced on the web + provisioning side before a handle is ever stored. Untrusted input is length- and + charset-clamped on both sides before it's signed or rendered. + +--- + +## References + +- [`firmware/src/beacon.h`](../firmware/src/beacon.h) — wire format, `beacon_sign`, + `beacon_verify`, `ct_equal` +- [`firmware/src/config.h`](../firmware/src/config.h) — `GROUP_PSK`, `HANDLE_MAX_LEN` +- [`README.md`](../README.md) — "Security model" summary (this doc is its expansion) diff --git a/docs/provisioning.md b/docs/provisioning.md @@ -0,0 +1,364 @@ +# Provisioning: the web/BLE setup flow + +How a blank badge gets a handle. On first boot (or any time you force it) the badge +comes up as a **BLE GATT peripheral**, a browser talks to it over **Web Bluetooth**, +and you type the badge's name + a couple of optional extras. The write lands in NVS, +the badge reboots, and from then on it lives in discovery mode broadcasting that +handle. + +Source of truth: +[`firmware/src/main.cpp`](../firmware/src/main.cpp) (the `PROVISIONING MODE` block + +`setup()`), [`firmware/src/config.h`](../firmware/src/config.h) (UUIDs + clamps), and +the companion app [`provisioning/index.html`](../provisioning/index.html). This doc is +the *why*; the code is the *what*. + +> One radio, never shared. The ESP32 has a single 2.4 GHz radio. Provisioning runs +> **BLE**; discovery runs **WiFi/ESP-NOW**. The firmware picks exactly one at boot so +> the two never have to coexist. You provision, the badge reboots, and only then does +> the ESP-NOW beacon start. + +--- + +## The two modes, chosen at boot + +``` + BOOT ─┬─ (no handle stored) OR (BOOT held) OR (one-shot "provreq" flag) ─► PROVISIONING (BLE) + │ browser writes pairing code → handle (+ clock, + pet) → NVS → reboot + └─ (handle stored, no override) ──────────────────────────────────► DISCOVERY (ESP-NOW) +``` + +`setup()` resolves the mode. There are **three ways** to land in provisioning — a +fresh badge, the BOOT button, or a one-shot NVS flag set from the on-device menu +(`Options → Badge Settings → Developer Options → Provisioning Mode`) so you can re-seed +without holding a button through a power cycle: + +```cpp +// firmware/src/main.cpp — setup() +bool force_provision = (digitalRead(BUTTON_PIN) == LOW) || // BOOT held at power-on + (prefs.getUChar("provreq", 0) == 1); // menu → Provisioning Mode +prefs.putUChar("provreq", 0); // consume the one-shot + +String stored = prefs.getString("handle", ""); +bool have_handle = stored.length() > 0 && !force_provision; + +if (!have_handle) { mode = MODE_PROVISION; start_provisioning(); } +else { mode = MODE_DISCOVER; start_discovery(); } +``` + +--- + +## The GATT layout + +One custom service, five characteristics — four **write**, plus one **read/notify** +metrics stream. The UUIDs are shared between firmware and browser — +they **must** match `config.h` and `index.html` or the browser can't find them. + +``` + service 6e8f0001-… "badge-setup" provisioning service + ├─ 0006 CODE (write) 3-char pairing code ── GATES everything below + ├─ 0002 HANDLE (write) the name your crew sees + ├─ 0003 TIME (write) epoch secs, u32 LE — seed the soft-clock + ├─ 0004 PET (write) custom-avatar blob (name + 5 faces) + └─ 0005 METRICS(read/notify) framed CSV/TSV dump — pairing-code gated (live, v1.7) +``` + +```cpp +// firmware/src/config.h +#define PROV_SERVICE_UUID "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e" +#define PROV_CHAR_UUID "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e" // handle (write) +#define PROV_TIME_CHAR_UUID "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e" // epoch secs, u32 LE (write) +#define PROV_PET_CHAR_UUID "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e" // custom pet blob (write) +#define PROV_CODE_CHAR_UUID "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e" // 3-char code — GATES the others +``` + +`start_provisioning()` stands the whole thing up with NimBLE — create the server, +create the service, attach a callback object per characteristic: + +```cpp +// firmware/src/main.cpp — start_provisioning() +NimBLEServer* server = NimBLEDevice::createServer(); +server->setCallbacks(new ServerCB()); +NimBLEService* svc = server->createService(PROV_SERVICE_UUID); + +svc->createCharacteristic(PROV_CODE_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new CodeWriteCB()); +svc->createCharacteristic(PROV_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new HandleWriteCB()); +svc->createCharacteristic(PROV_TIME_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new TimeWriteCB()); +svc->createCharacteristic(PROV_PET_CHAR_UUID, NIMBLE_PROPERTY::WRITE)->setCallbacks(new PetWriteCB()); +svc->start(); +``` + +--- + +## The pairing-code gate (physical presence, not crypto) + +BLE writes are cleartext, so anyone in range could otherwise scribble a handle onto +any badge in setup mode. The defense is a **fresh 3-char code** shown on the badge's +own screen each session. The browser must write that code first; until it matches, +every other write is dropped on the floor. + +``` + ┌─────────────┐ code shown on OLED ┌──────────┐ + │ badge OLED │ ── "code: K7Q" ────────────────►│ human │ + │ SETUP MODE │ │ eyeball │ + └─────────────┘ └────┬─────┘ + │ retypes K7Q + ▼ + handle / time / pet writes ◄── unlocked only after ── CODE write matches +``` + +The code is drawn from an **unambiguous uppercase alphabet** (no `0/O/1/I/L`) so it's +easy to read off a tiny mono OLED and retype: + +```cpp +// firmware/src/main.cpp — a fresh code per provisioning session +static void gen_prov_code(char out[4]) { + static const char A[] = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 30 glyphs + for (int i = 0; i < 3; i++) out[i] = A[esp_random() % (sizeof(A) - 1)]; + out[3] = '\0'; +} +``` + +The gate is a single `volatile bool`. Only an exact (case-insensitive) match flips it: + +```cpp +// firmware/src/main.cpp — CodeWriteCB::onWrite +char in[4] = {0}; +for (uint8_t i = 0; i < v.length() && i < 3; i++) { + char ch = (char)v.data()[i]; + in[i] = (ch >= 'a' && ch <= 'z') ? (char)(ch - 32) : ch; // uppercase +} +g_prov_authed = (strcmp(in, g_prov_code) == 0); +``` + +Every other callback opens with the same guard, so an unauthorized write is a no-op: + +```cpp +// firmware/src/main.cpp — HandleWriteCB::onWrite (TimeWriteCB / PetWriteCB are identical) +void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override { + if (!g_prov_authed) { Serial.println("[PROV] handle write ignored — pairing code not accepted"); return; } + NimBLEAttValue v = c->getValue(); + char clean[HANDLE_MAX_LEN + 1]; + uint8_t len = sanitize_handle(v.data(), v.length(), clean); + if (len == 0) return; // ignore empty/garbage writes + prefs.putString("handle", clean); + g_saved = true; // loop() will reboot us +} +``` + +And the auth flag resets on every disconnect, so a new connection must re-send the +code — you can't authorize once and let a second client ride the same session: + +```cpp +// firmware/src/main.cpp — ServerCB +void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override { + g_prov_authed = false; // each new connection must re-send the code + NimBLEDevice::startAdvertising(); // allow repeated attempts +} +``` + +> **What it is / isn't.** The code proves *line-of-sight to the screen* — a +> physical-presence gate against drive-by BLE provisioning. It is **not** crypto: +> BLE writes are still cleartext, so someone who can *see your screen* (or shoulder-surf +> the code) could provision your badge. That's an acceptable threat model for a con +> badge — the real beacon-authenticity guarantees live in the HMAC layer +> ([`docs/hmac-beacon-auth.md`](hmac-beacon-auth.md)), not here. +> +> **What the code protects.** The read/notify metrics stream (above) is behind the same +> code, so a matched code also dumps the owner's **encounter log**, not just the ability to +> rename the badge. Guessing is bounded: repeated wrong codes latch the gate shut until a +> reboot, which needs physical access. The 3-char code (27,000 keyspace) proves +> *presence*, not secrecy. + +--- + +## Advertising: why the name lives in the scan response + +The badge advertises as `badge-setup-<full 6-byte MAC>` (e.g. +`badge-setup-14335C591858`) so multiple badges in setup mode are distinguishable in +the browser's device picker and map 1:1 to the bench roster (which dedups by MAC). + +That 24-char name is the catch. A legacy BLE advertisement is only 31 bytes, and the +128-bit service UUID (18 B) + flags (3 B) already eat 21 of them — the name won't +fit alongside. So the firmware builds **both packets explicitly**: the primary +advertisement carries flags + the service UUID (so the browser's service filter can +find it), and the **scan response** carries the full name *alone* in its own 31 B: + +```cpp +// firmware/src/main.cpp — start_provisioning() +NimBLEAdvertisementData advData; // PRIMARY: flags + service filter +advData.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP); +advData.addServiceUUID(PROV_SERVICE_UUID); +adv->setAdvertisementData(advData); + +NimBLEAdvertisementData scanData; // SCAN RESPONSE: full name, alone +scanData.setName(prov_name); +adv->setScanResponseData(scanData); +adv->enableScanResponse(true); +adv->start(); +``` + +Letting NimBLE auto-route the name truncated it to an 11-char *Shortened Local Name* +(`badge-setup`), which killed the per-badge MAC suffix — hence the manual split. + +--- + +## The browser side (Web Bluetooth) + +The companion app is a single static [`provisioning/index.html`](../provisioning/index.html) +— no build step, no dependencies. It needs **Chrome or Edge** (Web Bluetooth isn't +in Firefox/Safari) served over **https or localhost** (Web Bluetooth is a secure +context). Its UUID constants mirror `config.h`: + +```js +// provisioning/index.html — must match firmware/src/config.h +const SERVICE_UUID = "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e"; +const CHAR_UUID = "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e"; // handle +const TIME_UUID = "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e"; // clock sync +const PET_UUID = "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e"; // custom pet +const CODE_UUID = "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e"; // pairing code (gates the others) +``` + +**Connect** — `requestDevice` pops the OS chooser, filtered to our service and +name prefix, then resolves the primary service: + +```js +// provisioning/index.html — connect() +const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [SERVICE_UUID] }, { namePrefix: DEVICE_NAME }], + optionalServices: [SERVICE_UUID] +}); +const server = await device.gatt.connect(); +return { device, service: await server.getPrimaryService(SERVICE_UUID) }; +``` + +**Code first, every time** — because the badge resets its auth flag on each new +connection, the app writes the code immediately after connecting for *every* action +(save / sync / pet): + +```js +// provisioning/index.html — writeCode() +const code = ($("code").value || "").trim().toUpperCase(); +if (code.length !== 3) throw new Error("enter the 3-char pairing code shown on the badge"); +const cch = await service.getCharacteristic(CODE_UUID); +await cch.writeValue(new TextEncoder().encode(code)); +``` + +**The main flow** — connect → code → handle → (best-effort) clock → disconnect: + +```js +// provisioning/index.html — provision() +const { device, service } = await connect(); +await writeCode(service); // unlock +const ch = await service.getCharacteristic(CHAR_UUID); +await ch.writeValue(new TextEncoder().encode(handle).slice(0, MAX_LEN)); +try { await writeTime(service); } catch (_) { /* time char optional */ } +device.gatt.disconnect(); +``` + +The handle input is clamped to printable ASCII and 24 bytes **in the browser**, and +the firmware re-clamps with `sanitize_handle()` on arrival — never trust the client. + +--- + +## The optional writes + +**Clock sync** — the phone/laptop knows real wall-clock time; the badge doesn't. The +app writes the current epoch as a little-endian `u32` so the con report can show real +timestamps. There's also a **`sync clock only`** button that skips the handle write +entirely (re-sync after a battery swap without renaming): + +```js +// provisioning/index.html — writeTime() +const tb = new Uint8Array(4); +new DataView(tb.buffer).setUint32(0, Math.floor(Date.now() / 1000), true); // LE u32 epoch +await (await service.getCharacteristic(TIME_UUID)).writeValue(tb); +``` + +```cpp +// firmware/src/main.cpp — TimeWriteCB::onWrite (guard omitted) +uint32_t epoch = (uint32_t)d[0] | ((uint32_t)d[1] << 8) | + ((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24); +g_clock.set(epoch); +// re-sync on an already-named badge reboots to home; first-time setup waits for the handle write +if (prefs.getString("handle", "").length() > 0) g_saved = true; +``` + +**Custom pet** — an optional blob of `name` + 5 state faces (idle / contact / +milestone / lonely / low-batt), newline-separated. The browser joins the fields; the +firmware splits on `\n`, clamps each field to printable ASCII + its max length, stores +it to NVS slot `PET_BUILTIN_N`, and auto-selects it. The pet model itself is in +`firmware/src/pet.h`. + +```js +// provisioning/index.html — writePet() +const blob = [f("pet_name"), f("pet_idle"), f("pet_contact"), + f("pet_milestone"), f("pet_lonely"), f("pet_lowbatt")].join("\n"); +await (await service.getCharacteristic(PET_UUID)).writeValue(new TextEncoder().encode(blob)); +``` + +--- + +## Commit: the reboot handshake + +Callbacks run on the BLE stack task; they don't reboot inline. Instead the write that +completes provisioning sets a `volatile bool g_saved`, and the main `loop()` — which +in provisioning mode does nothing but poll that flag — performs the restart. That +guarantees the NVS write is flushed and the BLE client sees its write-response before +the radio drops: + +```cpp +// firmware/src/main.cpp — loop() +if (mode == MODE_PROVISION) { + if (g_saved) { + display_boot("saved — rebooting to home"); + delay(800); + ESP.restart(); // comes back up in DISCOVERY with the new handle + } + delay(50); + return; +} +``` + +Full round-trip: + +``` + browser badge (BLE peripheral) + ─────── ────────────────────── + requestDevice / connect ──────► advertising as badge-setup-<MAC> + write CODE "K7Q" ──────► CodeWriteCB → g_prov_authed = true + write HANDLE "crash…" ──────► HandleWriteCB → sanitize → NVS → g_saved = true + write TIME <epoch> ──────► TimeWriteCB → g_clock.set() + gatt.disconnect() ──────► ServerCB::onDisconnect → g_prov_authed = false + loop() sees g_saved → ESP.restart() + ↳ reboots into DISCOVERY, beaconing "crash…" +``` + +--- + +## Onboard metrics export + +The fifth characteristic (`PROV_METRICS_CHAR_UUID`, read/notify) streams a framed +CSV/TSV dump to the browser: the **encounter log** (`/encounters.log`) + **points +history** (`/points_history.csv`) + the **best-friends ledger** (`/best_friends.csv`), +plus the Test-Mode CSVs (`/test_batt.csv`, `/test_pts.csv`) when Debug is on. It is +**gated by the pairing code** — the badge won't stream until the code matches — chunked +to the negotiated MTU, and the page reassembles the notify chunks up to an +`==== EOF ====` sentinel, then the badge auto-reboots to discovery a few seconds later. + +Firmware: `stream_metrics()` in `main.cpp` (runs from `loop()`, never a BLE callback); +served by the characteristic created in `start_provisioning()`. Browser: +`fetchMetricsOverBle()` in `index.html` renders inline-SVG charts (friends ranking · +best friends · points over time) and offers **PDF** (print-to-PDF) and re-importable +**JSON** export, all from one in-memory `g_data`. + +> **Export regularly over a long con:** the encounter log is size-capped +> (`ENC_LOG_MAX_BYTES` in `config.h`) and trims its oldest entries once it's full, so a +> daily JSON export keeps the whole history. + +--- + +## Related docs + +- [`docs/hmac-beacon-auth.md`](hmac-beacon-auth.md) — beacon authenticity in discovery mode (the actual crypto) +- `firmware/src/clock.h` — the soft-clock the TIME write seeds +- `firmware/src/pet.h` — the custom-pet model the PET write feeds diff --git a/drivers/README.md b/drivers/README.md @@ -0,0 +1,22 @@ +# drivers/ + +Host-side drivers needed to flash the badge. **Binaries are git-ignored** (they're +re-downloadable from the vendor); this file is the breadcrumb so the knowledge stays in-tree. + +## CH34xVCPDriver (macOS) — required to flash the FireBeetle on a Mac + +The **FireBeetle 2 ESP32-E** uses a **CH340** USB-UART bridge (VID `0x1A86` / PID `0x7522`) — *not* +the CP2102 the bench testers use. macOS enumerates the chip but attaches no driver by default, so +**no `/dev/cu.*` port appears** and the board looks dead (it isn't — confirm with `ioreg -p IOUSB`, +which shows the device; `system_profiler SPUSBDataType` reads false-empty on Apple Silicon). + +- **Source:** WCH official → https://github.com/WCHSoftGroup/ch34xser_macos → `CH34xVCPDriver.dmg` + (the macOS 11+ build is a DriverKit System Extension — Apple-Silicon-native, no kext / reduced- + security reboot) +- **sha256** (downloaded 2026-06-04): `161f61a5c7a0de34ed056c76cc21cb9a32ab94a03a0084f91054ea53e9ec2dc8` +- **Signed:** Developer ID — Nanjing Qinheng Microelectronics (WCH), Team `5JZGQTGU4W`; Apple-notarized +- **Install:** open the `.dmg` → run `CH34xVCPDriver.app` → click **Install** → enable it in + System Settings → General → Login Items & Extensions → Driver Extensions → unplug/replug → + the port appears as `/dev/cu.wchusbserial*`. + +(Use the `.dmg`, not the legacy `.pkg` kext, on Apple Silicon.) diff --git a/firmware/git_rev.py b/firmware/git_rev.py @@ -0,0 +1,32 @@ +# PlatformIO pre-build hook: inject the git short rev as the GIT_REV macro so the +# firmware can print which build is flashed on boot. A trailing "+" marks a build +# made with uncommitted (tracked) changes under firmware/. This is what lets you +# glance at the badge and know exactly which commit is running — the safeguard +# against flashing a stale/stranded build. +Import("env") # noqa: F821 (provided by PlatformIO/SCons) +import subprocess + +proj = env["PROJECT_DIR"] # the firmware/ dir (holds platformio.ini) + + +def _git(args, default=""): + try: + out = subprocess.check_output(["git"] + args, cwd=proj, + stderr=subprocess.DEVNULL) + return out.decode().strip() + except Exception: + return default + + +rev = _git(["rev-parse", "--short", "HEAD"], "nogit") +if rev != "nogit": + # Dirty = uncommitted *tracked* changes under firmware/. Untracked build + # artifacts (e.g. src/idf_component.yml.orig) are ignored on purpose so they + # don't spuriously flag every build dirty. + dirty = subprocess.call(["git", "diff", "--quiet", "HEAD", "--", "."], + cwd=proj, stderr=subprocess.DEVNULL) != 0 + if dirty: + rev += "+" + +env.Append(CPPDEFINES=[("GIT_REV", env.StringifyMacro(rev))]) +print("git_rev: GIT_REV=%s" % rev) diff --git a/firmware/platformio.ini b/firmware/platformio.ini @@ -0,0 +1,112 @@ +; DefCon friend-finder badge — firmware build config +; +; Envs (badge target first): +; oled_v3 — THE BADGE (default env): ESP32 + 2.42" SSD1309 OLED (U8g2) on Arduino +; core 3.x (pioarduino) — live per-peer RSSI, so "closest friend first" +; sorting + the points RSSI-gate actually work. huge_app partition. +; esp32dev_v3 — DEPRECATED: core-3.x serial build, was the dev/test default. The serial +; backend was test-only (prove the firmware pre-hardware); build oled_v3 +; instead. Kept for headless regression checks that need no panel. +; oled — core-2.x OLED badge build, kept as a proven FALLBACK (RSSI=0). +; esp32dev — DEPRECATED: core-2.x serial build (RSSI=0). Test-only; superseded by oled. +; esp32c3 — parked custom-PCB v2 target (native USB, serial backend). +; +; Why two cores: PlatformIO's official espressif32 platform is still on Arduino +; core 2.0.17 (even at v7.0.1, 2026-05), whose ESP-NOW recv callback does NOT +; expose RSSI. The community pioarduino platform provides core 3.x / IDF 5.x, +; which does. RSSI is measured by the RECEIVER, so a single core-3.x observer is +; enough to read signal strength; peers can run either core. The firmware handles +; both via #if ESP_ARDUINO_VERSION_MAJOR>=3 (main.cpp on_espnow_recv). +; +; The radio is time-shared: BLE provisioning OR WiFi/ESP-NOW discovery, never +; both at once — so the single 2.4GHz radio never has to coexist. +; +; Display backend is selected per-env via -DBADGE_DISPLAY_{SERIAL,OLED}; +; the firmware core is identical across all targets. + +[platformio] +default_envs = oled_v3 ; THE BADGE (was esp32dev_v3 — serial dev build, now deprecated) +src_dir = src + +[env] +framework = arduino +monitor_speed = 115200 +extra_scripts = pre:git_rev.py ; injects -DGIT_REV="<short-rev>" for the boot build banner +lib_deps = + h2zero/NimBLE-Arduino @ ^2.1.0 +build_flags = + -DCORE_DEBUG_LEVEL=3 + +; ── Arduino-ESP32 core 3.x base (pioarduino) ───────────────────────────────── +; Shared by the badge (oled_v3) and the default dev build (esp32dev_v3). Pins the +; pioarduino release in ONE place and uses huge_app.csv (no-OTA: 3MB app / ~960KB +; filesystem) so the heftier core-3.x image has headroom — the default table left +; the OLED build at 98.5%. Bump the tag here to update both envs. +; Latest tag: github.com/pioarduino/platform-espressif32/releases +[core3x] +platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38/platform-espressif32.zip +board = esp32dev +board_build.partitions = huge_app.csv + +; --- THE BADGE: ESP32 + 2.42" SSD1309 mono OLED (I2C), single 18650, core 3.x --- +[env:oled_v3] +extends = core3x +build_flags = + ${env.build_flags} + -DBADGE_DISPLAY_OLED=1 + -DBUTTON_PIN=27 ; FireBeetle 2 ESP32-E user button is on GPIO27 (not GPIO0) +lib_deps = + ${env.lib_deps} + olikraus/U8g2 @ ^2.35.30 + +; --- THE BADGE, PRODUCTION FLEET BUILD --- +; Identical firmware to oled_v3, plus -DBADGE_PRODUCTION so config.h REFUSES to compile +; with the throwaway GROUP_PSK (see the GROUP SECRET block there). Use THIS env for the +; fleet flash — it can't ship the placeholder key. Use plain oled_v3 for dev iteration +; (it warns but builds on the placeholder). Requires a real firmware/src/secrets.h. +[env:oled_v3_prod] +extends = env:oled_v3 +build_flags = + ${env:oled_v3.build_flags} + -DBADGE_PRODUCTION=1 + +; --- DEPRECATED (serial backend, test-only): core-3.x serial dev/test build. Was the +; default; the badge (oled_v3) is now default. Kept for headless regression checks. --- +[env:esp32dev_v3] +extends = core3x +build_flags = + ${env.build_flags} + -DBADGE_DISPLAY_SERIAL=1 + +; ── core-2.x fallbacks (stock espressif32 — RSSI=0, no proximity sort) ──────── + +; --- Fallback: core-2.x OLED badge build --- +[env:oled] +platform = espressif32 @ ^6.9.0 +board = esp32dev +build_flags = + ${env.build_flags} + -DBADGE_DISPLAY_OLED=1 + -DBUTTON_PIN=27 ; FireBeetle 2 ESP32-E user button is on GPIO27 (not GPIO0) +lib_deps = + ${env.lib_deps} + olikraus/U8g2 @ ^2.35.30 + +; --- DEPRECATED (serial backend, test-only): core-2.x serial build for generic ESP32 +; dev kits. Superseded by the OLED fallback (oled). --- +[env:esp32dev] +platform = espressif32 @ ^6.9.0 +board = esp32dev +build_flags = + ${env.build_flags} + -DBADGE_DISPLAY_SERIAL=1 + +; --- ESP32-C3: parked custom-PCB v2 target (native USB) --- +[env:esp32c3] +platform = espressif32 @ ^6.9.0 +board = esp32-c3-devkitm-1 +build_flags = + ${env.build_flags} + -DBADGE_DISPLAY_SERIAL=1 + -DARDUINO_USB_MODE=1 + -DARDUINO_USB_CDC_ON_BOOT=1 diff --git a/firmware/src/battery.cpp b/firmware/src/battery.cpp @@ -0,0 +1,67 @@ +// battery.cpp — LiPo fuel gauge implementation. See battery.h. +// +#include <Arduino.h> +#include "battery.h" +#include "config.h" + +static uint16_t g_mv = 0; // smoothed battery millivolts (0 = not yet sampled) +static uint32_t g_last_sample_ms = 0; + +// Burst-read VBAT → divider-corrected millivolts. One discarded priming read lets +// the ADC sample/hold cap settle (it can't fully charge through the ~500kΩ onboard +// divider in a single conversion), then average VBAT_BURST_SAMPLES reads to beat +// down the per-read noise + low-bias. Pin millivolts are averaged BEFORE the +// ×VBAT_DIVIDER scale so the rounding happens at full resolution. +static uint16_t read_vbat_mv() { + (void)analogReadMilliVolts(PIN_VBAT); // priming read (settle the S/H cap) + uint32_t sum = 0; + for (int i = 0; i < VBAT_BURST_SAMPLES; i++) + sum += analogReadMilliVolts(PIN_VBAT); + return (uint16_t)((sum / VBAT_BURST_SAMPLES) * VBAT_DIVIDER); +} + +void battery_init() { + // GPIO34 is input-only; analogReadMilliVolts handles attenuation/calibration. + // 11dB attenuation lets the ~0..2.1V at the pin (half of 4.2V) read cleanly. + // + // Arduino-ESP32 core 3.x's ADC oneshot driver wants the pin attached as an + // analog channel BEFORE attenuation is configured, else it logs + // [E] __analogChannelConfig(): Pin is not configured as analog channel + // A throwaway analogRead() attaches the channel first; harmless ordering on + // core 2.x, which configures the pin lazily either way. + analogRead(PIN_VBAT); + analogSetPinAttenuation(PIN_VBAT, ADC_11db); + // Seed the EMA with one immediate (burst-averaged) reading so the first frame + // isn't 0% — and so the gauge opens near the true voltage, not a low single read. + g_mv = read_vbat_mv(); + g_last_sample_ms = 0; +} + +void battery_sample(uint32_t now) { + if (g_last_sample_ms != 0 && now - g_last_sample_ms < VBAT_SAMPLE_MS) return; + g_last_sample_ms = now; + + uint16_t raw = read_vbat_mv(); + if (g_mv == 0) { + g_mv = raw; // first real sample + } else { + // EMA: g = g + alpha*(raw - g), alpha = NUM/DEN, in integer math. + int32_t delta = (int32_t)raw - (int32_t)g_mv; + g_mv = (uint16_t)((int32_t)g_mv + (delta * VBAT_EMA_NUM) / VBAT_EMA_DEN); + } +} + +uint16_t battery_mv() { + return g_mv; +} + +uint8_t battery_pct() { + if (g_mv <= VBAT_EMPTY_MV) return 0; + if (g_mv >= VBAT_FULL_MV) return 100; + uint32_t span = VBAT_FULL_MV - VBAT_EMPTY_MV; + return (uint8_t)(((uint32_t)(g_mv - VBAT_EMPTY_MV) * 100) / span); +} + +bool battery_is_low() { + return g_mv != 0 && g_mv <= VBAT_LOW_MV; +} diff --git a/firmware/src/battery.h b/firmware/src/battery.h @@ -0,0 +1,29 @@ +// battery.h — LiPo fuel gauge for the FireBeetle 2 ESP32-E. +// +// The board exposes battery voltage through an onboard 1:2 divider on GPIO34. +// This module reads it (factory-calibrated, via analogReadMilliVolts), smooths +// it with an EMA so a beacon-TX voltage dip doesn't make the gauge twitch, and +// maps it to a coarse 0–100% "charge tonight?" estimate. +// +// Mapping is LINEAR on voltage (VBAT_EMPTY_MV..VBAT_FULL_MV). LiPo discharge is +// actually nonlinear (flat in the middle), so this reads slightly pessimistic +// mid-range — the safe direction for a low-battery warning. +// +#pragma once +#include <stdint.h> + +// Configure the ADC pin/attenuation. Call once from setup(). +void battery_init(); + +// Take a throttled, EMA-smoothed sample. Call every loop(); it self-limits to +// one real reading per VBAT_SAMPLE_MS. +void battery_sample(uint32_t now); + +// Latest smoothed battery voltage, in millivolts (already divider-corrected). +uint16_t battery_mv(); + +// Smoothed charge estimate, 0..100. Clamped. +uint8_t battery_pct(); + +// True once the smoothed voltage is at/below VBAT_LOW_MV. +bool battery_is_low(); diff --git a/firmware/src/beacon.h b/firmware/src/beacon.h @@ -0,0 +1,71 @@ +#pragma once +#include <Arduino.h> +#include <string.h> +#include <stddef.h> +#include "mbedtls/md.h" // mbedTLS ships inside the ESP32 Arduino core +#include "config.h" +#include "pet.h" // PET_FACE_MAX — beacons carry the sender's pet face + +// ───────────────────────────────────────────────────────────────────────── +// Beacon wire format (fixed size, broadcast over ESP-NOW ~1/sec). +// +// The signed region is everything before `tag`. The receiver recomputes +// HMAC-SHA256(PSK, signed_region) and constant-time-compares against `tag`. +// Unverified beacons are dropped before they ever touch the peer table. +// +// `counter` is a freshness hint (replay deterrent). With a symmetric group +// key the real guarantee is "this came from a holder of our key" — which is +// exactly the friends-vs-the-con threat model. Upgrade path to per-badge +// Ed25519 identities is noted in the README. +// ───────────────────────────────────────────────────────────────────────── + +#define BEACON_MAGIC 0xB1 +#define BEACON_VERSION 2 // v2: added pet + pet_face (v1 badges won't verify) +#define TAG_LEN 16 // HMAC-SHA256 truncated to 128 bits + +#pragma pack(push, 1) +struct Beacon { + uint8_t magic; // BEACON_MAGIC — cheap pre-filter + uint8_t version; // BEACON_VERSION + uint32_t counter; // monotonic-ish freshness hint + uint8_t handle_len; // 1..HANDLE_MAX_LEN + char handle[HANDLE_MAX_LEN]; // zero-padded; padding is part of the MAC + uint8_t pet; // sender's pet index (0..PET_BUILTIN_N; ==N means custom) + char pet_face[PET_FACE_MAX]; // sender's pet idle face, zero-padded (renders any pet) + uint8_t tag[TAG_LEN]; // signature over the bytes above +}; +#pragma pack(pop) + +// Bytes covered by the signature = everything up to (not including) `tag`. +static inline size_t beacon_signed_len() { return offsetof(Beacon, tag); } + +static inline void beacon_hmac(const Beacon* b, uint8_t out[32]) { + const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_hmac(info, GROUP_PSK, GROUP_PSK_LEN, + (const uint8_t*)b, beacon_signed_len(), out); +} + +// Fill `tag` for an outgoing beacon. Caller must zero-pad `handle` first. +static inline void beacon_sign(Beacon* b) { + uint8_t full[32]; + beacon_hmac(b, full); + memcpy(b->tag, full, TAG_LEN); +} + +// Constant-time compare — don't leak the tag via early-exit timing. +static inline bool ct_equal(const uint8_t* a, const uint8_t* b, size_t n) { + uint8_t diff = 0; + for (size_t i = 0; i < n; i++) diff |= a[i] ^ b[i]; + return diff == 0; +} + +// Validate structure + signature. `len` is the received byte count. +static inline bool beacon_verify(const Beacon* b, size_t len) { + if (len < sizeof(Beacon)) return false; // runt + if (b->magic != BEACON_MAGIC) return false; + if (b->version != BEACON_VERSION) return false; + if (b->handle_len == 0 || b->handle_len > HANDLE_MAX_LEN) return false; + uint8_t full[32]; + beacon_hmac(b, full); + return ct_equal(full, b->tag, TAG_LEN); +} diff --git a/firmware/src/clock.h b/firmware/src/clock.h @@ -0,0 +1,91 @@ +#pragma once +#include <Arduino.h> +#include <Preferences.h> +#include "config.h" + +// ───────────────────────────────────────────────────────────────────────── +// Soft real-time clock. +// +// The FireBeetle 2 ESP32-E badge board has no RTC, so we seed wall-clock time from the +// phone over BLE during provisioning, then free-run off millis(). To survive +// reboots without re-syncing, we checkpoint the current epoch to NVS every +// CLOCK_PERSIST_MS and resume from there on boot (downtime isn't counted — +// fine for a badge that runs continuously through a con). +// +// If never synced, now() returns 0 and synced() is false → the report labels +// timestamps as relative rather than wall-clock. +// +// A brownout reset means we were dark for an unknown span, so the resumed epoch is +// skewed by that downtime. setup() then calls mark_stale(): synced() goes false +// (report falls back to relative) and stale() drives a "re-sync" nudge, until a +// fresh BLE sync (OPTIONS → Provisioning Mode → hosted page) restores trust. The stale +// flag is persisted, so a later clean power-cycle can't silently un-stale it. +// ───────────────────────────────────────────────────────────────────────── +class SoftClock { +public: + void begin() { + _p.begin("clock", false); + _base_epoch = _p.getUInt("epoch", 0); + _stale = _p.getUChar("stale", 0) != 0; + _base_ms = millis(); + _last_persist = millis(); + } + + // "synced" = we hold a wall-clock we TRUST. False if never seeded, OR if marked + // stale (e.g. resumed-from-NVS after a brownout, when the badge was dark for an + // unknown span) — callers then fall back to relative time. A BLE re-sync restores it. + bool synced() const { return _base_epoch != 0 && !_stale; } + + // Epoch is present but no longer trustworthy (downtime-skewed). Drives the nudge. + bool stale() const { return _base_epoch != 0 && _stale; } + + uint32_t now() const { + if (!_base_epoch) return 0; + return _base_epoch + (millis() - _base_ms) / 1000; + } + + // Called from the BLE time characteristic — a fresh sync restores trust. + void set(uint32_t epoch) { + _base_epoch = epoch; + _base_ms = millis(); + _stale = false; + _p.putUInt("epoch", epoch); + _p.putUChar("stale", 0); + } + + // Mark the persisted epoch untrusted. Persisted so it survives further reboots + // until a re-sync clears it — a clean power-cycle after a brownout must not + // silently "un-stale" a clock that's still skewed by the dark period. + void mark_stale() { + if (!_base_epoch || _stale) return; + _stale = true; + _p.putUChar("stale", 1); + } + + // Factory reset: wipe the persisted epoch + stale flag. The clock lives in its OWN NVS + // namespace ("clock"), so main.cpp's prefs.clear() on the "badge" namespace does NOT + // reach it — without this a "wiped" badge still carries the previous owner's wall clock. + // Caller is expected to restart immediately; the in-RAM state is reset too regardless. + void factory_clear() { + _p.clear(); + _base_epoch = 0; + _base_ms = millis(); + _stale = false; + } + + // Call often; checkpoints to NVS at most once per CLOCK_PERSIST_MS. + void tick() { + if (!_base_epoch) return; + if (millis() - _last_persist >= CLOCK_PERSIST_MS) { + _last_persist = millis(); + _p.putUInt("epoch", now()); + } + } + +private: + Preferences _p; + uint32_t _base_epoch = 0; + uint32_t _base_ms = 0; + uint32_t _last_persist = 0; + bool _stale = false; // epoch present but downtime-skewed → untrusted +}; diff --git a/firmware/src/config.h b/firmware/src/config.h @@ -0,0 +1,342 @@ +#pragma once +#include <stdint.h> +#include <stddef.h> + +// Short git rev of the build, injected by git_rev.py (pre: build script) so the +// badge can show which firmware is flashed on boot. A trailing '+' means the +// firmware tree had uncommitted changes at build time. Fallback for builds +// without git (e.g. a tarball checkout). +#ifndef GIT_REV +#define GIT_REV "nogit" +#endif + +// Firmware release version — human-facing, shown on boot. Distinct from +// BEACON_VERSION (the wire protocol). Bump FW_VERSION_MINOR for features / +// FW_VERSION_MAJOR for milestones; GIT_REV still pins the exact build. The +// string forms are derived, so the major/minor numbers are the single source. +#define FW_VERSION_MAJOR 1 +#define FW_VERSION_MINOR 10 +#define _FW_STR2(x) #x +#define _FW_STR(x) _FW_STR2(x) +#define FW_VERSION_STR "v" _FW_STR(FW_VERSION_MAJOR) "." _FW_STR(FW_VERSION_MINOR) +#define FW_BUILD_STR FW_VERSION_STR " " GIT_REV // e.g. "v1.1 5597f1a" + +// ───────────────────────────────────────────────────────────────────────── +// GROUP SECRET +// Every badge in your crew flashes the SAME 32-byte key. Beacons are +// HMAC-signed with it; a badge silently drops any beacon that doesn't +// verify — so outsiders and spoofers in the (very hostile) DefCon RF +// soup never make it onto anyone's screen. +// +// The REAL key lives in firmware/src/secrets.h, which is GITIGNORED and must +// never be committed. Create it once, on the flashing machine, from the +// tracked template: +// cp firmware/src/secrets.h.example firmware/src/secrets.h +// openssl rand -hex 32 # paste the 32 bytes into secrets.h, +// # then delete its IS_PLACEHOLDER line +// Store the same key in your password manager: it is unrecoverable, and every +// crew badge must flash the IDENTICAL key or they can't see each other. +// +// If secrets.h is absent (or still the placeholder), GROUP_PSK falls back to a +// PUBLIC throwaway key and beacons are UNAUTHENTICATED — fine for dev, never +// for the fleet. The guard below makes a production build (env oled_v3_prod, +// which sets -DBADGE_PRODUCTION) REFUSE to compile in that state; plain oled_v3 +// still builds (with a warning) so day-to-day iteration isn't blocked. +// ───────────────────────────────────────────────────────────────────────── +#if __has_include("secrets.h") +#include "secrets.h" // defines GROUP_PSK / GROUP_PSK_LEN + GROUP_PSK_SET +#endif + +#ifndef GROUP_PSK_SET +// Fallback: the well-known PUBLIC placeholder. Not a secret — safe to commit. +static const uint8_t GROUP_PSK[] = { + 0xde,0xad,0xbe,0xef, 0x00,0x11,0x22,0x33, 0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb, + 0xcc,0xdd,0xee,0xff, 0x13,0x37,0xc0,0xde, 0xfe,0xed,0xfa,0xce, 0xba,0xdc,0x0f,0xee +}; +static const size_t GROUP_PSK_LEN = sizeof(GROUP_PSK); +#define GROUP_PSK_IS_PLACEHOLDER 1 +#endif + +// Production guard. A fleet build (-DBADGE_PRODUCTION, set only by env oled_v3_prod) +// refuses to compile with the throwaway key. A real secrets.h defines GROUP_PSK_SET and +// omits GROUP_PSK_IS_PLACEHOLDER, so it passes; a missing secrets.h OR one still holding +// the placeholder marker stops the build right here. +#if defined(BADGE_PRODUCTION) && defined(GROUP_PSK_IS_PLACEHOLDER) +#error "Production build (oled_v3_prod) with the throwaway GROUP_PSK. Create firmware/src/secrets.h from secrets.h.example, set a real key (openssl rand -hex 32), and delete its GROUP_PSK_IS_PLACEHOLDER line. See the GROUP SECRET block in config.h." +#endif + +// Dev builds on the placeholder still work — but say so, loudly, every compile. +#if defined(GROUP_PSK_IS_PLACEHOLDER) && !defined(BADGE_PRODUCTION) +#warning "Building with the PUBLIC throwaway GROUP_PSK — beacons are UNAUTHENTICATED. Fine for dev; the fleet must use env oled_v3_prod with a real firmware/src/secrets.h." +#endif + +// ─── Radio / discovery ─────────────────────────────────────────────────── +// Badge Settings → STEALTH is a BROADCAST switch (NVS key "radio", default ON = not +// stealthed; the key and the internal flag stay radio-polarity, the UI inverts). Turning +// it OFF gates send_beacon() only: the badge goes silent so nobody can see it, but the +// ESP-NOW stack stays up and RX is untouched — you still see crew in FINDER and still +// score. That asymmetry is deliberate. Tearing the stack down instead would be a bigger +// change than it looks: start_discovery() is not idempotent (it bumps the persisted +// beacon counter by +1000 and RELOADS points/ledger/history from NVS on every call), so +// a re-enable would clobber up to POINTS_PERSIST_MS of live accrual, and the peer table +// draining on TTL would fake a synchronized mass-departure into the encounter log. Gating +// TX sidesteps both. Full RF-down (esp_now_deinit + WiFi off, for the battery win) needs +// that refactor first. +#define ESPNOW_CHANNEL 1 // ALL badges must agree on one channel +#define BEACON_INTERVAL_MS 1000 // how often we broadcast our presence +#define PEER_TTL_MS 15000 // drop a peer unseen for this long +#define DISPLAY_REFRESH_MS 3000 // how often we redraw the finder screen +#define BATT_LOW_BLINK_MS 600 // home battery-glyph blink half-period when low (slow-ish) +#define MAX_PEERS 32 // bounded table = flood-resistant + +// ─── Test mode (OPTIONS-menu toggle; logs to LittleFS + serial dump on boot) ── +#define TEST_LOG_INTERVAL_MS 600000 // battery-log cadence on the plateau (10 min) +#define TEST_LOG_FAST_MS 60000 // battery-log cadence below the knee (1 min) +#define TEST_LOG_CLIFF_MV 3600 // VBAT mv at/below which the fast cadence kicks in +#define TEST_PTLOG_INTERVAL_MS 60000 // points-log cadence (1 min; points tick ≤ 1/min) +#define TEST_LOG_MAX_BYTES 65536 // per-file safety cap (~64 KB) so it can't fill flash +#define TEST_MAC_HOLD_MS 3000 // how long the boot MAC screen holds (read/photograph) +#define TEST_MODE_POPUP_MS 1500 // "Test Mode Activated" pet popup hold, before the splash + +// ─── Disk guard (BACKSTOP — the log's real bound is ENC_LOG_MAX_BYTES below) ── +// The encounter log grows one ~50B record per peer departure. It is now capped in +// enc_append, so this guard should never fire on encounter growth alone; if it does, +// the pressure is the Test-Mode CSVs and trimming the log is best-effort. At ≥90% full: +// drop the OLDEST encounters back toward ~80% and raise a dismissable pet toast (≤10% +// free) so you can export before losing more. Do NOT restore this as the primary bound — +// the 90/80 pair is unsatisfiable by construction (see the note below). +#define DISK_CHECK_MS 60000 // how often to poll LittleFS usage +#define DISK_FULL_NUM 9 // trip the guard at used ≥ (NUM/DEN) of total … +#define DISK_FULL_DEN 10 // = 90% full +#define DISK_TARGET_NUM 8 // … then trim the log back toward (NUM/DEN) of total +#define DISK_TARGET_DEN 10 // = 80% full +#define DISK_WARN_REMIND_MS 600000 // re-warn (toast) at most every 10 min while full + +// ─── Encounter-log cap ────────────────────────────────────────────────────── +// The disk guard above used to be the ONLY bound on the log, and it could not work. By the +// time it trips at 90% used there is only ~90 KB free, but it asks the trim to preserve +// ~734 KB (80% of the partition) — so the copy runs out of space partway. The copy walks +// FORWARD from the cut point, so what survived was the OLDEST slice of the intended tail: +// the guard destroyed the newest ~640 KB, the exact inverse of "keeps the most recent +// history". +// +// Capping the log proactively fixes it by construction: at 256 KB there is ~640 KB free, far +// more than the 192 KB the copy needs, so the trim is always feasible. The 90/80 guard +// becomes a backstop that should never fire, and enc_report()'s parse cost is bounded too. +// +// 256 KB ≈ 5,900 records ≈ well past a 4-day con at the project's own churn model +// (~800 KB / 1.5 days assumes a crowded hallway continuously; real logs are far smaller). +#define ENC_LOG_MAX_BYTES 262144 // trim once the log grows past this (256 KB) +#define ENC_LOG_TRIM_BYTES 196608 // ...down to this much of the NEWEST history (192 KB) +#define ENC_TRIM_FREE_MARGIN 16384 // slack left free when clamping a trim to real space + +// ─── Identity ──────────────────────────────────────────────────────────── +#define HANDLE_MAX_LEN 24 // bytes; untrusted input is clamped to this + +// ─── Logging / report ───────────────────────────────────────────────────── +#define REPORT_HOLD_MS 1500 // hold BUTTON this long to cycle the view +#define TAP_MAX_MS 400 // a release within this = a "tap" (vs a hold) +#define DOUBLETAP_GAP_MS 400 // two taps within this = double-tap (next view); a lone + // tap fires as a single (sub-view) once the window passes +#define CLOCK_PERSIST_MS 60000 // how often the soft-clock checkpoints to NVS + +// Provisioning escape hatch. Provisioning mode never times out and doesn't beacon, so a +// badge that lands there by accident is inert with no on-device way out — worst for the +// iOS-only crowd, who can't run the Web Bluetooth app to provision their way out either. +// Hold BUTTON this long in setup mode to reboot straight to the home view. +// Well clear of REPORT_HOLD_MS so it can't be hit by muscle memory for "go home". +#define PROV_ESCAPE_HOLD_MS 5000 // hold-to-exit duration +#define PROV_ESCAPE_FEEDBACK_MS 600 // start the on-screen countdown after this much hold + +// Pairing-code attempt limit. The 3-char code is a PHYSICAL-PRESENCE gate — it means +// "you can see the badge's screen" — and unlimited guessing defeats exactly that: 30^3 = +// 27,000 codes, ~1.7 min to average compromise with no line-of-sight at all. After this +// many wrong codes the gate latches shut until a reboot, which needs physical access — +// restoring the property. Someone retyping a code off the screen never gets near 5. +#define PROV_CODE_MAX_TRIES 5 + +// Wall-clock display timezone (POSIX TZ string). The seeded epoch is UTC (the +// provisioning page sends Date.now()/1000), so the badge renders local time via +// localtime_r() with this TZ set at boot. Pacific w/ US DST rules — DEF CON is on +// Pacific, and the rules auto-handle PST(−8)/PDT(−7). Change this one string to relocate. +#define DISPLAY_TZ "PST8PDT,M3.2.0,M11.1.0" + +// ─── Points (gamified proximity) ─────────────────────────────────────────── +// +1 point per full minute spent with ≥1 crew badge in range. The accrued +// proximity time is checkpointed to NVS so a reboot mid-con doesn't zero it. +#define POINTS_PERSIST_MS 60000 // how often the proximity tally checkpoints + +// RSSI gate: only count a minute when the CLOSEST crew badge is at least this +// strong (dBm). Tighter = they must be physically nearer to score. Rough feel: +// -60 ≈ same little huddle · -70 ≈ same area · -80 ≈ anywhere in earshot. +// ⚠️ The Arduino core 2.x ESP-NOW callback reports rssi=0 for everyone, so on +// that toolchain the gate ALWAYS passes (≡ "any crew in range"); it only bites +// on the core 3.x / pioarduino build that exposes real RSSI. To disable the +// gate entirely, set this to -128 (every real signal clears it). +#define POINTS_MIN_RSSI -80 + +// Group multiplier: scoring scales with how many crew badges are in range +// (the peer-table / FINDER count — NOT the RSSI gate, which still decides +// whether you earn at all). ≥X2 badges → every earned second counts ×2; +// ≥X3 → ×3. A solo 1-on-1 (one crew in range) earns at the base ×1; ×2 needs +// a small group (2+ around), ×3 the group huddle. The +// multiplied seconds flow into BOTH the tally and the per-friend ledger, so +// the points view, graph, and best-friends ranking stay mutually consistent. +// Crossing a threshold upward fires a pet "xN bonus!" pop-up (cooldown- +// limited); the active level shows as an inverse chip on FINDER/POINTS. +#define POINTS_X2_PEERS 2 // ≥ this many crew in range → ×2 (a small group) +#define POINTS_X3_PEERS 5 // ≥ this many crew in range → ×3 (the group huddle) + +// ─── Points history (the POINTS-view graph) ───────────────────────────────── +// Cumulative-score samples drawn as a line graph on the POINTS view: +// x = real wall-clock time over a fixed 5-day window (the event), y = points. +// Each sample carries its epoch; the buffer is persisted to NVS so the graph +// survives reboots. A reboot inserts a visible break in the line — the board +// has no RTC, so a power-off can't be measured (clock.h: downtime isn't +// counted): we can mark the break but not size the gap. If the clock was never +// synced (epoch 0), the renderer falls back to uniform spacing. +// 120 hourly samples span the 5-day axis; ~120×8 bytes in RAM and in NVS. +#define POINTS_HISTORY_LEN 120 // samples kept (120 h = 5 days) +#define POINTS_HISTORY_SAMPLE_MS 3600000UL // 1 h cadence (LEN×cadence = 5 days) +#define POINTS_GRAPH_SPAN_S 432000UL // 5-day window (hourly samples) +#define POINTS_GRAPH_24H_SPAN_S 86400UL // "last 24h" window (hourly samples) +#define POINTS_GRAPH_1H_SPAN_S 3600UL // "last 1h" window (fine 1-min ring) +// A single tap on the POINTS view cycles the window: 5d → 24h → 1h. The 1-hour +// view needs finer-than-hourly data, so it reads a small in-RAM 1-min ring: +#define POINTS_RECENT_LEN 60 // 1h ring samples (60 × 1 min = 1h); volatile +#define POINTS_RECENT_SAMPLE_MS 60000UL // 1-min cadence for the recent ring + +// ─── Provisioning (BLE GATT) ───────────────────────────────────────────── +// UUIDs MUST match provisioning/index.html. Generate your own for a real +// build (these are placeholders): https://www.uuidgenerator.net/ +#define PROV_DEVICE_NAME "badge-setup" // base name; start_provisioning() appends "-<full MAC>" + // so badges are distinct in the picker + map to the roster +#define PROV_SERVICE_UUID "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e" +#define PROV_CHAR_UUID "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e" // handle (write) +#define PROV_TIME_CHAR_UUID "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e" // epoch secs, u32 LE (write) +#define PROV_PET_CHAR_UUID "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e" // custom pet blob (write) +#define PROV_CODE_CHAR_UUID "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e" // 3-char pairing code (write) — GATES the others +// Metrics readout for the provisioning web app's reports/viz: a read/notify characteristic +// that streams a framed CSV/TSV dump (encounters.log + points history + the Test-Mode CSVs +// when Debug is on) to the browser, gated by the pairing code. Chunked to the negotiated MTU; +// the page reassembles the notify chunks until "==== EOF ====". Served in start_provisioning(); +// streamed from loop() (stream_metrics). Web side wired in provisioning/index.html. +#define PROV_METRICS_CHAR_UUID "6e8f0005-b5a3-f393-e0a9-e50e24dcca9e" // metrics CSV stream (read/notify) +#define METRICS_CHUNK_MAX 240 // cap per-notify payload bytes (used as min with MTU−3) +#define METRICS_PACE_MS 8 // delay between notify chunks (tx-buffer breathing room) +#define METRICS_NOTIFY_RETRIES 40 // retry a chunk this many times if the tx buffer is full (no silent drops) +#define METRICS_REBOOT_MS 4000 // after the stream, reboot to discovery this long later + +// ─── Pins ──────────────────────────────────────────────────────────────── +// Single tap = sub-view action (FINDER page-down · POINTS window · PET/SPLASH pick); +// double tap = next view; long-press = jump home to FINDER. Hold this button +// during boot to force re-provisioning. Active-low w/ INPUT_PULLUP. Dev boards: +// BOOT on GPIO0. The FireBeetle 2 ESP32-E badge wires its user button to GPIO27, +// so the oled envs set -DBUTTON_PIN=27 (platformio.ini); default stays GPIO0. +#ifndef BUTTON_PIN +#define BUTTON_PIN 0 +#endif + +// ─── OLED (SSD1309 2.42", I2C) ────────────────────────────────────────────── +// Adjust to your host board's I2C pins. Classic ESP32 default is 21/22, and +// most "ESP32 + 18650" boards expose those too — confirm against your board. +#define OLED_SDA_PIN 21 +#define OLED_SCL_PIN 22 + +// ─── Screen brightness (Badge Settings → Display → Brightness) ────────────── +// A level index cycled in the menu and persisted to NVS (key "bright"). The index +// is backend-independent (main.cpp owns the cycling); the OLED backend maps it to +// the SSD1309 contrast register below, and serial has no panel so it ignores it. +#define SCREEN_BRIGHT_LEVELS 3 +#define SCREEN_BRIGHT_DEFAULT 1 // MED — matches the pre-existing look, see OLED_BRIGHT_MED + +// SSD1309 contrast register (0x81) values, 0..255, indexed by the level above. MED is +// u8g2's own SSD1309 init default (0x6f = 111, see u8x8_d_ssd1309.c) — so a badge with +// no saved pick looks exactly as it did before this setting existed. Contrast 0 is +// dim-but-visible on this panel rather than off, so no level can black out the UI and +// strand the user with no way back to the menu; LOW is floored well above that anyway. +#define OLED_BRIGHT_LOW 16 +#define OLED_BRIGHT_MED 111 +#define OLED_BRIGHT_HIGH 255 + +// ─── Boot splash (OLED animation style) ───────────────────────────────────── +// The OLED plays an animated splash at power-on. BOOT_SPLASH_STYLE is the +// factory default; it's overridden at runtime by the user's pick in the MENU +// view (persisted to NVS, key "splash"). Serial/e-ink builds ignore the style +// (serial prints a static ASCII banner; e-ink draws a static logo). +#define BOOT_SPLASH_RADAR 0 // sonar sweep + blips → wordmark (thematic) +#define BOOT_SPLASH_TERMINAL 1 // POST-style typewriter self-test +#define BOOT_SPLASH_GLITCH 2 // scanline-noise wordmark reveal +#define BOOT_SPLASH_MATRIX 3 // "onlyn00bs" glyph rain, bright head + fading trail +// The number of styles above. Everything that cycles or wraps the selection reads THIS +// rather than a literal — the count was previously a bare `% 3` repeated across main.cpp +// and both display backends, which is exactly the kind of thing that goes stale when a +// style is added. Bump it and the picker, the wrap and the name tables all follow. +#define BOOT_SPLASH_COUNT 4 +#define BOOT_SPLASH_STYLE BOOT_SPLASH_RADAR + +// ─── Boot logo card ───────────────────────────────────────────────────────── +// A three-frame logo card that plays right AFTER the splash the user picked above +// (not instead of it) and before the disclaimer. The bitmaps are baked from +// source artwork into src/logos.h by a generator that isn't part of this release; the +// bitmaps are plain data, and nothing here needs to know the image geometry. +// The card is a sequence of full-screen frames; this is the budget for the WHOLE set, +// split evenly across however many frames are in it. So adding or removing a logo +// changes what you see without changing how long boot takes. +// +// Currently the set is the OnlyNoobs wordmark alone. The emblem and foot bitmaps are +// still baked into logos.h — a three-up single-frame arrangement was tried on glass +// (2026-07-25) and rejected: at 38px the emblem's interior collapses into noise. Giving +// each mark the whole panel is the only arrangement where they stay legible, so if they +// come back they come back as extra frames here, not as a denser layout. +#define BOOT_LOGO_HOLD_MS 1620 // whole-card budget (×SCALE → ~4.9 s) +#define BOOT_LOGO_TOTAL_MS ((uint32_t)BOOT_LOGO_HOLD_MS * BOOT_TIMING_SCALE) + +// Boot pacing: lengthen every boot-sequence beat — the splash animation, the +// serial POST readout, and the "Welcome" screen — by this factor. 1 = snappy. +#define BOOT_TIMING_SCALE 3 +#define BOOT_DIAG_HOLD_MS 700 // base pause holding the serial POST block (×SCALE) +#define DISCLAIMER_HOLD_MS 900 // base hold for the boot heat/LiPo disclaimer screen (×SCALE → ~2.7s) + +// ─── Pet (companion) ──────────────────────────────────────────────────────── +// A selectable ASCII face on the PET view that reacts to events. See pet.h. +#define PET_REACT_MS 4000 // a reaction face (contact/milestone) holds this long +#define PET_MILESTONE_STEP 100 // celebrate every N points +#define PET_POPUP_COOLDOWN_MS 10000 // min gap between reaction pop-ups over other views +#define PET_POPUP_MS 1700 // reaction popup hold — long enough to read 2 lines +#define PET_MILESTONE_POPUP_MS 2200 // milestone popup holds a bit longer (bigger deal) +#define PET_LOWBATT_POPUP_MS 3000 // low-battery toast holds longest (it matters) +#define PET_LOWBATT_REMIND_MS 300000 // re-warn every 5 min while still low (persistent) + +// ─── Reminders (non-blocking pet-toasts; User Settings → Reminders) ────────── +// Scheduled nudges shown as a pet toast. The board has no RTC, so they fire on an +// UPTIME interval (not wall-clock 8am/5pm) — no phone-sync/clock dependency. The +// toast is non-blocking (the loop keeps beaconing/scoring): dismissed by any button +// press or auto-cleared after REMINDER_TOAST_MS. A master "Global" toggle gates all +// reminders; each specific one (Shower, …) has its own toggle. Both default ON and +// persist to NVS (keys "rmglob"/"rmshow"); toggling a switch OFF disables it. +#define REMINDER_SHOWER_MS 28800000UL // 8 h of uptime between shower reminders +#define REMINDER_TOAST_MS 30000 // auto-dismiss a reminder toast after 30 s + +// ─── Battery sense (FireBeetle 2 ESP32-E onboard divider) ────────────────── +// The FireBeetle 2 ESP32-E permanently wires VBAT through an onboard 1:2 +// divider (two 1MΩ resistors, ~2.5µA leak) to GPIO34 / ADC1_CH6 (input-only). +// We read with analogReadMilliVolts() so the ESP32's factory ADC calibration +// linearises the raw count; then ×VBAT_DIVIDER undoes the divider. (Other dev +// kits without this divider will read garbage on GPIO34 — fine, the battery +// view is only meaningful on the real badge board.) +#define PIN_VBAT 34 +#define VBAT_DIVIDER 2 // onboard divider: pin-mV × 2 = battery-mV +#define VBAT_FULL_MV 4200 // LiPo fully charged → 100% +#define VBAT_EMPTY_MV 3300 // 0% gauge point; margin above the ~3.0V floor +#define VBAT_LOW_MV 3450 // show LOW warning at/below this (~15%) +#define VBAT_SAMPLE_MS 500 // min interval between real ADC reads +#define VBAT_EMA_NUM 1 // EMA weight on the new sample = NUM/DEN (0.2): +#define VBAT_EMA_DEN 5 // smooths the dip from each ESP-NOW TX burst +// Burst averaging: the onboard VBAT divider is two 1MΩ resistors (~500kΩ source +// impedance) — far above what the ESP32 ADC sample/hold cap can settle through in +// one conversion, so a single read sits low + noisy. Each sample now discards a +// priming read (lets the cap track) then averages this many back-to-back reads. +// Pure firmware; no hardware change. (Per-board calibration + a LiPo SoC curve are +// the other two accuracy levers, not implemented.) +#define VBAT_BURST_SAMPLES 16 // reads averaged per sample (after 1 priming read) diff --git a/firmware/src/display.h b/firmware/src/display.h @@ -0,0 +1,187 @@ +#pragma once +#include "peers.h" +#include "encounters.h" +#include "points.h" +#include "points_history.h" + +// ───────────────────────────────────────────────────────────────────────── +// Display abstraction. The board-independent core calls these; the concrete +// implementation is chosen at build time via a -D flag in platformio.ini: +// +// BADGE_DISPLAY_SERIAL → display_serial.cpp (works on ANY ESP32 today; +// renders to the serial monitor) +// BADGE_DISPLAY_EINK → display_eink.cpp (LilyGo T5 4.7") +// +// Swapping screens = swapping one .cpp file. The protocol never changes. +// ───────────────────────────────────────────────────────────────────────── + +void display_init(); + +// Power the panel down before deep sleep. The SSD1309 draws ~10-20 mA and keeps drawing +// with the SoC asleep — without this the sleep saves almost nothing. One-way by design: +// waking is a full restart through display_init(), so there is no matching _wake(). +void display_sleep(); + +// Boot splash / one-line status. +void display_boot(const char* line); + +// Animated boot splash, shown once at power-on before the mode screen. OLED +// animates (style = one of BOOT_SPLASH_* in config.h); serial prints a static +// ASCII banner; e-ink draws a static logo. Blocks ~1–1.5s (boot only). +void display_boot_splash(int style); + +// Logo card, shown once at boot immediately AFTER display_boot_splash() and before the +// disclaimer — so it plays whichever splash style the user picked, rather than replacing +// one. The OLED blits the baked bitmaps from logos.h as full-screen frames, sharing the +// BOOT_LOGO_TOTAL_MS budget between them; serial prints a one-line credit. Blocking +// (boot only), ~4.9s on the OLED. +void display_boot_logos(); + +// A light-hearted heat + LiPo-safety disclaimer, shown once at boot right after the +// splash. A static full-screen text card; blocking (boot only) — the caller holds it +// on screen (DISCLAIMER_HOLD_MS × BOOT_TIMING_SCALE) then continues the boot sequence. +void display_disclaimer(); + +// Provisioning mode: tell the user to connect over BLE, show the device name and the +// 3-char pairing `code` they must enter in the web app to unlock provisioning. +void display_provisioning(const char* device_name, const char* code); + +// Whoami: a read-only identity screen — the badge's handle (username) + eFuse MAC. +void display_whoami(const char* handle, const char* mac); + +// Home view — the at-a-glance default/initial screen: the user's handle, pet face, +// total points, battery %, and wall-clock time. `clock_str` is preformatted by the +// caller ("HH:MM", or "--:--" when the clock is unsynced/stale) so the backends stay +// clock-agnostic; `batt_low` flags the warning threshold for an inline marker. +// `disk_pct` is LittleFS % USED (same polarity as the STORAGE view), printed as a second +// header readout beside the battery — as text; the disk glyph itself is static, unlike the +// battery's proportional one. The caller supplies it from its cached figures — usedBytes() +// must never be walked from a live redraw. +// `radio_on` is the Badge Settings → Stealth switch (inverted: stealth ON = radio off). +// When it's false the badge is NOT broadcasting, and HOME says so with an inverse STEALTH +// chip along the bottom: a silent badge with no on-screen tell is indistinguishable from a +// broken one, and this is the default view. +void display_home(const char* handle, const char* pet_face, uint32_t points, + uint8_t batt_pct, bool batt_low, uint8_t disk_pct, const char* clock_str, + bool radio_on); + +// The finder list renders this many peers per screen (one "page"); main.cpp uses it for +// the page count + wrap math, the OLED backend for its row slice. Fixed by the 64px panel. +#define FINDER_PAGE_ROWS 5 + +// Finder mode: render the sorted peer list (closest first). The OLED header is a +// static "// finder" title (matching the other view headers); each peer row shows +// that peer's handle. `my_handle` is used only by the serial backend's diagnostic +// line. show_rssi toggles the OLED right column: false = peer's pet face, true = +// dBm. `mult` is the active group points-multiplier (1 = none; 2/3 show an +// indicator chip in the header — see POINTS_X2_PEERS / POINTS_X3_PEERS). `page` is the +// 0-based page of the list (FINDER_PAGE_ROWS per screen); the OLED renders that slice +// and shows "p/P" in the header when P>1. The serial backend lists all peers, ignoring it. +void display_peers(const char* my_handle, const Peer* peers, int n, bool show_rssi, + uint8_t mult, int page); + +// Report mode: the "social graph of your con" — aggregated friend stats. +void display_report(const char* my_handle, const Report& r); + +// Points mode: gamified proximity score. `points` is 1 per full minute spent +// with crew close enough to clear the RSSI gate; `prox_seconds` is the +// underlying tally (its sub-minute remainder drives a live countdown to the +// next point). `peers_now` is the current in-range crew count; `earning` is +// whether the tally is accruing right now (≥1 crew AND within the RSSI gate AND not +// stealthed) — so the view can distinguish "no crew" from "crew here but too far". +// `stealth` says WHY it's paused when that's the reason: stealth stops scoring, and crew +// can be in range and in-gate at the time, so "too far" would be an outright lie. Check +// it before the distance case. +// `hist`/`hist_n` are the timestamped points-over-time samples (oldest→newest) +// for the line graph on this view; `hist_n` may be 0 (nothing sampled yet). +// `mult` is the active group multiplier (1/2/3): ≥2 shows the indicator chip and +// scales the "+1 pt in Ns" countdown (the tally ticks ×mult per wall second). +void display_points(const char* my_handle, uint32_t points, + uint32_t prox_seconds, int peers_now, bool earning, + const PointSample* hist, int hist_n, + uint32_t graph_span_s, const char* window_label, bool anchor_newest, + uint8_t mult, bool stealth); + +// Best-friends mode: the top crew by close-time shared with you, most first. +// `top[i].seconds / 60` is the minutes you spent near friend i; `total_points` +// is your whole score. Each friend is ≤ the total, but they overlap (a group +// minute credits everyone), so they don't sum to it. `n` is how many are in +// `top` (may be < 3 early on, 0 before you've scored anything). +void display_friends(const char* my_handle, const PointFriend* top, int n, + uint32_t total_points); + +// Battery mode: coarse LiPo fuel gauge for "charge tonight?" decisions. `mv` is +// the divider-corrected battery voltage in millivolts, `pct` the 0..100 charge +// estimate (linear on voltage — slightly pessimistic mid-range, the safe way), +// `low` flags that we're at/below the warning threshold. +void display_battery(const char* my_handle, uint16_t mv, uint8_t pct, bool low); + +// STORAGE readout — the BATTERY view's single-tap alternate. `used`/`total` are LittleFS +// bytes, cached by the caller (usedBytes() walks the filesystem, so it must not be re-read +// on every live redraw). Reports USED, per disk convention — the bar fills as the disk +// fills, the opposite polarity to the battery face it otherwise mirrors — with the +// remaining space shown alongside as a free-bytes figure. `full` is the caller's +// disk-guard verdict (same >=90% trip that trims the log), drawn as a warning. +void display_storage(const char* my_handle, size_t used, size_t total, bool full); + +// Pet selection screen — the badge companion: name header + a big ASCII face (no +// stats readout). `face` is the current state-face (idle / reaction / lonely / +// low-batt), already resolved by the caller. Tap swaps the pet. +void display_pet(const char* name, const char* face); + +// Settings MENU — currently the boot-splash picker. `splash_sel` is +// 0..BOOT_SPLASH_COUNT-1 (radar / terminal / glitch / matrix). +void display_menu(int splash_sel); + +// Transient reaction pop-up: a big pet face + a reason line (line1), with an +// optional detail line (line2 — pass nullptr for a single-line toast). Briefly +// overlays the current view when an event fires off the PET view (cooldown by caller). +void display_pet_popup(const char* face, const char* line1, const char* line2, + uint16_t hold_ms); + +// NON-BLOCKING toast — same look as display_pet_popup (big face + 1–2 lines) but it +// draws and returns immediately (no hold/delay). The caller owns its lifetime: it +// stays on screen until the caller redraws (dismissed by a button press / timeout in +// the main loop). Used for reminder alerts, which must not freeze beaconing/scoring. +void display_toast(const char* face, const char* line1, const char* line2); + +// OPTIONS menus. `screen` selects which one to draw: ROOT (Back · User Settings · +// Badge Settings), USER (Back · Pet · Finder View · Reminders), BADGE (Back · Display · +// Stealth · Developer Options · Power), DISPLAY (Back · Splash), DEVOPTS (Back · Whoami · +// Debug · Provisioning Mode), REMIND (Back · Global · Shower), or POWER (Back · Reboot · +// Deep sleep). A cursor list with Back first (the default row). OptView carries the +// dynamic row values; each screen reads only the fields it renders — beats a positional +// const char* per row as the tree grows. +// NOTE: BADGE is at 5 rows, which is the renderer's hard cap (`rows[5]` + the y0/dy +// ladder). A sixth row there needs the array and the ladder widened first — which is why +// POWER exists as a sub-menu: it holds Reboot + Deep sleep in the slot Reboot alone used +// to occupy, instead of growing BADGE past the cap. +enum OptMenu { OPTM_ROOT = 0, OPTM_USER, OPTM_BADGE, OPTM_DISPLAY, OPTM_DEVOPTS, OPTM_REMIND, + OPTM_POWER }; +struct OptView { + const char* pet; // USER — active pet name + const char* splash; // DISPLAY — boot-splash style name + const char* bright; // DISPLAY — "LOW"/"MED"/"HIGH" + const char* test_state; // DEVOPTS — "ON"/"OFF" + const char* finder_mode; // USER — "pet"/"dBm" + const char* remind_global; // REMIND — "ON"/"OFF" + const char* remind_shower; // REMIND — "ON"/"OFF" + // BADGE — "ON"/"OFF" for STEALTH, which is the INVERSE of the internal g_radio_on: + // stealth ON = not broadcasting. The caller does the inversion; don't re-flip it here. + const char* stealth; +}; +void display_options_menu(int screen, int cursor, const OptView& v); + +// Factory-reset confirmation. Its own screen rather than a row in DEVOPTS because the +// menu's activate gesture is a double-tap: one fumbled press on a bare "Factory Reset" row +// would wipe someone's whole con with no way back. Spells out what goes, and `cursor` 0 is +// Cancel — the default landing row, so the destructive option is never the one under the +// cursor when you arrive. 1 = confirm. +void display_reset_confirm(int cursor); + +// Screen brightness. `level` indexes the SCREEN_BRIGHT_* table (0=LOW, 1=MED, 2=HIGH); +// out-of-range falls back to SCREEN_BRIGHT_DEFAULT. The OLED backend maps it to the +// panel's contrast register; serial has no panel and no-ops. Must be called AFTER the +// display is up (display_init() claims the I2C bus) — and, for the saved pick, after +// NVS is open, which is later than display_init() in setup(). +void display_set_brightness(uint8_t level); diff --git a/firmware/src/display_oled.cpp b/firmware/src/display_oled.cpp @@ -0,0 +1,909 @@ +// Monochrome OLED implementation of the display interface — SSD1309 2.42" +// (128×64) over I2C, via U8g2. This is the chosen badge display: self-lit +// (readable in a dark venue), dirt-simple, and a single 18650 runs it for days. +// +// 128×64 is tight, so layouts are deliberately terse: a header line + a handful +// of rows. Handles are truncated to fit. Pixels are the only power cost, so we +// keep the screen mostly dark. + +#if defined(BADGE_DISPLAY_OLED) + +#include <Arduino.h> +#include <U8g2lib.h> +#include <math.h> +#include "display.h" +#include "config.h" +#include "logos.h" // GENERATED — boot-card bitmaps + +// Hardware-I2C SSD1309. Last two args are SCL/SDA for ESP32 (see config.h). +static U8G2_SSD1309_128X64_NONAME0_F_HW_I2C + u8g2(U8G2_R0, U8X8_PIN_NONE, OLED_SCL_PIN, OLED_SDA_PIN); + +#define ROW_FONT u8g2_font_6x10_tf // ~6px wide → ~21 chars, ~6 rows tall +#define ROW_CHARS 21 + +static void truncate(char* dst, const char* src, int maxch) { + int i = 0; + for (; src[i] && i < maxch; i++) dst[i] = src[i]; + dst[i] = '\0'; +} + +// Inverse-video multiplier chip ("x2"/"x3") — the group-bonus indicator on the +// FINDER and POINTS headers. Draws right-aligned ending at `right_x` in the +// header band (y 0..8); nothing at mult<2. Returns the horizontal space taken +// (width + gap) so callers can keep right-stacking header elements. +static int draw_mult_chip(int right_x, uint8_t mult) { + if (mult < 2) return 0; + char t[4]; + snprintf(t, sizeof(t), "x%u", (unsigned)mult); + int w = u8g2.getStrWidth(t) + 4; // 2px pad each side + u8g2.drawRBox(right_x - w, 0, w, 9, 1); + u8g2.setDrawColor(0); // cut the label out of the box + u8g2.drawStr(right_x - w + 2, 7, t); + u8g2.setDrawColor(1); + return w + 3; +} + +// 4-segment signal bar (like a phone), filled to the RSSI level. Inactive slots +// render as a 1px base stub, NOT an outline: the bars are only 2px wide, and a +// 2px-wide drawFrame() is just its left+right edges — i.e. both columns — so it +// is indistinguishable from a filled box. (That bug made every bar look full.) +static void draw_bars(int x, int baseline, int8_t rssi) { + int lvl = rssi >= -55 ? 4 : rssi >= -65 ? 3 : rssi >= -75 ? 2 : rssi >= -85 ? 1 : 0; + for (int i = 0; i < 4; i++) { + int h = 2 + i * 2; + int bx = x + i * 3; + if (i < lvl) u8g2.drawBox(bx, baseline - h, 2, h); // filled to level + else u8g2.drawBox(bx, baseline - 1, 2, 1); // empty: base stub + } +} + +// Line plot of the points samples in the box (x,y)-(x+w,y+h). Y auto-scales to +// the window's [min,max] points (flat series → baseline). X is real wall-clock +// over the fixed POINTS_GRAPH_SPAN_S window (anchored at the oldest sample) when +// the clock is synced; otherwise it falls back to uniform spacing. A sample +// flagged seg_start (the first after a reboot) breaks the line — the badge has +// no RTC so we mark the discontinuity without sizing the off-period. +// Plots samples within a time window: span_s wide, anchored at `anchor` (epoch). +// 5-day view → span=5d, anchor=oldest sample (all samples shown). 24h view → +// span=24h, anchor=newest−24h (only the last day shown, Y rescaled to it). When +// the clock was never synced, falls back to uniform spacing over all samples. +static void draw_sparkline(int x, int y, int w, int h, const PointSample* v, int n, + uint32_t span_s, uint32_t anchor, bool synced) { + if (n < 1) return; + // First pass: in-window count + Y range (in-window = epoch ≥ anchor, or all if unsynced). + int inwin = 0; bool any = false; uint16_t mn = 0, mx = 0; + for (int i = 0; i < n; i++) { + if (synced && v[i].epoch < anchor) continue; + inwin++; + if (!any) { mn = mx = v[i].points; any = true; } + else { if (v[i].points < mn) mn = v[i].points; if (v[i].points > mx) mx = v[i].points; } + } + if (!any) return; + uint32_t yspan = (uint32_t)mx - mn; + int prevx = 0, prevy = 0; bool first = true; int idx = 0; + for (int i = 0; i < n; i++) { + if (synced && v[i].epoch < anchor) continue; + int px; + if (synced) { + uint32_t dt = (v[i].epoch > anchor) ? (v[i].epoch - anchor) : 0; + if (dt > span_s) dt = span_s; + px = x + (int)(dt * (uint32_t)(w - 1) / span_s); + } else { + px = x + (inwin == 1 ? 0 : (idx * (w - 1)) / (inwin - 1)); + } + int py = yspan == 0 ? (y + h - 1) + : (y + (h - 1) - (int)(((uint32_t)(v[i].points - mn) * (h - 1)) / yspan)); + if (first || v[i].seg_start) u8g2.drawPixel(px, py); // segment break + else u8g2.drawLine(prevx, prevy, px, py); + prevx = px; prevy = py; first = false; idx++; + } +} + +// I2C bus recovery — un-stick a wedged bus before init. If the ESP32 was reset +// via EN mid-transaction (a serial-monitor/OTA reset, or the physical RST button: +// EN resets the MCU but NOT the OLED, which stays powered on 3V3), the SSD1309 can +// be left holding SDA low. The hardware-I2C driver then returns ESP_ERR_INVALID_STATE +// on every write and the panel stays dark until a full power-cycle. Clock SCL up to +// 9 times to let the slave finish its hung byte + ACK and release SDA, then issue a +// STOP. Bit-banged before u8g2.begin() claims the pins. No-op on a healthy bus. +static void i2c_bus_recover() { + pinMode(OLED_SDA_PIN, INPUT_PULLUP); + pinMode(OLED_SCL_PIN, OUTPUT); + for (int i = 0; i < 9 && digitalRead(OLED_SDA_PIN) == LOW; i++) { + digitalWrite(OLED_SCL_PIN, HIGH); delayMicroseconds(5); // ~100kHz, matches the driver + digitalWrite(OLED_SCL_PIN, LOW); delayMicroseconds(5); + } + // STOP: SDA rises while SCL is high. + pinMode(OLED_SDA_PIN, OUTPUT); + digitalWrite(OLED_SDA_PIN, LOW); delayMicroseconds(5); + digitalWrite(OLED_SCL_PIN, HIGH); delayMicroseconds(5); + digitalWrite(OLED_SDA_PIN, HIGH); delayMicroseconds(5); + // Release both lines; u8g2.begin() re-inits the hardware I2C peripheral on these pins. + pinMode(OLED_SDA_PIN, INPUT_PULLUP); + pinMode(OLED_SCL_PIN, INPUT_PULLUP); +} + +void display_init() { + i2c_bus_recover(); // un-wedge a bus left stuck by an EN-only reset before init + u8g2.begin(); + u8g2.setFont(ROW_FONT); +} + +// Deep-sleep companion: SSD1309 display-off (sleep mode). Blanking the buffer would NOT +// do — an all-black frame still runs the panel's charge pump and draws nearly full +// current. setPowerSave(1) is the register write that actually stops it. +void display_sleep() { + u8g2.setPowerSave(1); +} + +// Level index → SSD1309 contrast register. u8g2's begin() already programs +// OLED_BRIGHT_MED as part of the SSD1309 init sequence, so an unset/invalid level +// lands on the panel's pre-existing look rather than a surprise. +void display_set_brightness(uint8_t level) { + static const uint8_t kContrast[SCREEN_BRIGHT_LEVELS] = + { OLED_BRIGHT_LOW, OLED_BRIGHT_MED, OLED_BRIGHT_HIGH }; + if (level >= SCREEN_BRIGHT_LEVELS) level = SCREEN_BRIGHT_DEFAULT; + u8g2.setContrast(kContrast[level]); +} + +void display_boot(const char* line) { + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 12, "friend-finder"); + u8g2.drawStr(0, 28, line); + u8g2.sendBuffer(); +} + +// Heat + LiPo-safety disclaimer, shown once at boot (after the splash). A static text +// card; blocking — it holds itself on screen for DISCLAIMER_HOLD_MS × BOOT_TIMING_SCALE +// (boot only) then returns. Header in the 6x10 row font; the body uses the smaller 5x7 +// font (25 chars/line vs 21) so the full note fits the 128px width. ASCII-only. +void display_disclaimer() { + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 8, "// WARNING"); + u8g2.drawHLine(0, 11, 128); + u8g2.setFont(u8g2_font_5x7_tf); // body: 5px/char → 25 fit vs 21 + u8g2.drawStr(0, 22, "This badge runs on a LIPO"); + u8g2.drawStr(0, 32, "battery. We're in Vegas."); + u8g2.drawStr(0, 42, "Keep an eye on how hot"); + u8g2.drawStr(0, 52, "it gets. Spicy pillow?"); + u8g2.drawStr(0, 62, "Discontinue use."); + u8g2.sendBuffer(); + u8g2.setFont(ROW_FONT); // restore the default for later screens + delay((uint32_t)DISCLAIMER_HOLD_MS * BOOT_TIMING_SCALE); +} + +// ─── Boot splash animations ────────────────────────────────────────────────── +// One of the BOOT_SPLASH_COUNT styles plays once at power-on (BOOT_SPLASH_* in +// config.h). All are blocking — they run in setup() before any mode screen. + +// Final resting frame shared by radar + glitch: wordmark + signal-bar flourish. +static void splash_wordmark() { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_7x13B_tf); + const char* t = "FRIEND-FINDER"; + u8g2.drawStr((128 - u8g2.getStrWidth(t)) / 2, 24, t); + const int bx = 40, by = 46; // rising bars, centered + for (int i = 0; i < 8; i++) { int h = 2 + i; u8g2.drawBox(bx + i * 6, by - h, 4, h); } + u8g2.setFont(ROW_FONT); + char v[ROW_CHARS + 1]; + snprintf(v, sizeof(v), "%s ch%d", FW_BUILD_STR, ESPNOW_CHANNEL); // version + rev + channel + u8g2.drawStr((128 - u8g2.getStrWidth(v)) / 2, 62, v); + u8g2.sendBuffer(); + delay(500 * BOOT_TIMING_SCALE); +} + +// Style 0 — sonar sweep around a scope; crew "blips" ping in as the sweep passes +// them, then it resolves to the wordmark. Mirrors what the badge does. +static void splash_radar() { + const int cx = 64, cy = 31, R = 30; + const float D2R = 0.017453293f; + struct { int deg, r; } blip[] = { {55, 18}, {140, 27}, {235, 12}, {310, 23} }; + const int NB = 4; + bool found[NB] = { false, false, false, false }; + for (int s = 0; s <= 540; s += 12) { // 1.5 sweeps + float a = (s - 90) * D2R; + u8g2.clearBuffer(); + u8g2.drawCircle(cx, cy, R); + u8g2.drawCircle(cx, cy, R * 2 / 3); + u8g2.drawPixel(cx, cy); + u8g2.drawLine(cx, cy, cx + (int)(R * cosf(a)), cy + (int)(R * sinf(a))); + for (int i = 0; i < NB; i++) { + if (s >= blip[i].deg) found[i] = true; // discovered as the sweep crosses it + if (found[i]) { + float ba = (blip[i].deg - 90) * D2R; + u8g2.drawDisc(cx + (int)(blip[i].r * cosf(ba)), cy + (int)(blip[i].r * sinf(ba)), 2); + } + } + u8g2.sendBuffer(); + delay(22 * BOOT_TIMING_SCALE); + } + splash_wordmark(); +} + +// Style 1 — POST-style typewriter self-test, revealed line by line, char by char. +static void splash_terminal() { + char l0[32]; + snprintf(l0, sizeof(l0), "./friend-finder_%d_%d.sh", // banner = "run" the versioned binary + FW_VERSION_MAJOR, FW_VERSION_MINOR); + char lb[32]; + snprintf(lb, sizeof(lb), "> beacon ... ch %d", ESPNOW_CHANNEL); // read the define, never hardcode it: + // the POST used to claim "ch 1" no + // matter what the radio actually did + const char* L[] = { + l0, "> radio ..... OK", "> crypto ..... OK", + "> fs ..... OK", "> clock .... SET", lb, + "badge online", + }; + const int NL = 7; + u8g2.setFont(u8g2_font_5x8_tf); + char shown[8][24]; + for (int i = 0; i < NL; i++) shown[i][0] = '\0'; + for (int i = 0; i < NL; i++) { + int len = (int)strlen(L[i]); + for (int c = 1; c <= len; c++) { + strncpy(shown[i], L[i], c); shown[i][c] = '\0'; + u8g2.clearBuffer(); + for (int j = 0; j <= i; j++) u8g2.drawStr(2, 9 + j * 8, shown[j]); + u8g2.drawBox(2 + c * 5, 9 + i * 8 - 7, 4, 8); // blinking-ish cursor block + u8g2.sendBuffer(); + delay(7 * BOOT_TIMING_SCALE); + } + } + delay(350 * BOOT_TIMING_SCALE); +} + +// Style 2 — wordmark assembles out of scanline noise, then the bars sweep in. +static void splash_glitch() { + randomSeed(micros()); + const char* t1 = "FRIEND"; + const char* t2 = "FINDER"; + u8g2.setFont(u8g2_font_8x13B_tf); + for (int f = 0; f < 22; f++) { + u8g2.clearBuffer(); + int jx = (f < 16) ? (int)random(-3, 4) : 0; // jitter settles late + u8g2.drawStr((128 - u8g2.getStrWidth(t1)) / 2 + jx, 24, t1); + u8g2.drawStr((128 - u8g2.getStrWidth(t2)) / 2 - jx, 40, t2); + int noise = 120 - f * 6; if (noise < 0) noise = 0; // thinning static + for (int i = 0; i < noise; i++) u8g2.drawPixel(random(0, 128), random(0, 64)); + u8g2.sendBuffer(); + delay(28 * BOOT_TIMING_SCALE); + } + for (int k = 1; k <= 8; k++) { // bars sweep in + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_8x13B_tf); + u8g2.drawStr((128 - u8g2.getStrWidth(t1)) / 2, 24, t1); + u8g2.drawStr((128 - u8g2.getStrWidth(t2)) / 2, 40, t2); + for (int i = 0; i < k; i++) { int h = 2 + i; u8g2.drawBox(40 + i * 6, 60 - h, 4, h); } + u8g2.sendBuffer(); + delay(35 * BOOT_TIMING_SCALE); + } + delay(250 * BOOT_TIMING_SCALE); +} + +// Style 3 — glyph rain: columns of falling characters, each with a bright head and a +// trail that fades out behind it, resolving to the wordmark. The alphabet is just the +// letters of "onlyn00bs" (so 'n' and '0' fall twice as often, which suits the wordmark). +// +// The panel is 1-bit, so a real brightness ramp down the trail isn't available. Depth is +// faked with three tiers instead: the head is drawn INVERSE (a filled cell with the glyph +// knocked out of it, the brightest thing on screen), the body is plain glyphs, and the +// last two cells get alternate scanlines knocked out — a 50% dither that reads as dim. +static void splash_matrix() { + static const char GLYPHS[] = "onlyn00bs"; + const int NG = (int)sizeof(GLYPHS) - 1; + const int CW = 5, CH = 8; // 5x7 font in a 5x8 cell + const int COLS = 128 / CW; // 25 columns (125px; 3px slack at the right) + const int ROWS = 64 / CH; // 8 rows + + // Position is 1/16th-row fixed point so columns can fall at different sub-row speeds; + // stepping in whole rows would make every column move in lockstep. static, not stack: + // this is ~250B and setup()'s stack has better uses. + static int16_t pos[COLS]; // head position, 1/16 row units (negative = above screen) + static uint8_t vel[COLS]; // fall rate in the same units per frame + static uint8_t len[COLS]; // trail length, cells + static uint8_t cell[COLS][ROWS]; // which glyph sits in each grid cell + + randomSeed(micros()); + for (int c = 0; c < COLS; c++) { + pos[c] = -(int16_t)random(0, ROWS * 16 * 2); // stagger entry so they don't arrive as a wall + vel[c] = 3 + random(0, 8); // ~0.2–0.6 rows/frame + len[c] = 3 + random(0, ROWS - 2); + for (int r = 0; r < ROWS; r++) cell[c][r] = random(0, NG); + } + + u8g2.setFont(u8g2_font_5x7_tf); + for (int f = 0; f < 46; f++) { + u8g2.clearBuffer(); + for (int c = 0; c < COLS; c++) { + int head = pos[c] >> 4; + for (int t = 0; t < len[c]; t++) { + int r = head - t; + if (r < 0 || r >= ROWS) continue; + int x = c * CW, ytop = r * CH; + char s[2] = { GLYPHS[cell[c][r]], '\0' }; + if (t == 0) { // head — inverse cell + u8g2.drawBox(x, ytop, CW, CH); + u8g2.setDrawColor(0); + u8g2.drawStr(x, ytop + 7, s); + u8g2.setDrawColor(1); + } else { + u8g2.drawStr(x, ytop + 7, s); + if (t >= len[c] - 2) { // tail — dither to half density + u8g2.setDrawColor(0); + for (int yy = ytop; yy < ytop + CH; yy += 2) u8g2.drawHLine(x, yy, CW); + u8g2.setDrawColor(1); + } + } + } + pos[c] += vel[c]; + if ((pos[c] >> 4) - len[c] >= ROWS) { // whole trail cleared the bottom → respawn + pos[c] = -(int16_t)random(0, ROWS * 16); + vel[c] = 3 + random(0, 8); + len[c] = 3 + random(0, ROWS - 2); + } + if (random(0, 6) == 0) // shimmer: without this the columns just + cell[c][random(0, ROWS)] = random(0, NG); // scroll rigidly and it reads as a texture + } + u8g2.sendBuffer(); + delay(22 * BOOT_TIMING_SCALE); // same frame budget as the radar sweep + } + splash_wordmark(); +} + +void display_boot_splash(int style) { + switch (style) { + case BOOT_SPLASH_TERMINAL: splash_terminal(); break; + case BOOT_SPLASH_GLITCH: splash_glitch(); break; + case BOOT_SPLASH_MATRIX: splash_matrix(); break; + case BOOT_SPLASH_RADAR: + default: splash_radar(); break; + } + delay(1000); // hold the final splash frame 1s longer + u8g2.setFont(ROW_FONT); // restore default for later screens +} + +// The boot logo card: full-screen frames, each mark centred on the whole 128×64 panel. +// That's the only arrangement where these marks stay legible — a denser single-frame +// layout was tried on glass and rejected (see config.h). Geometry comes from the W/H that +// logos.h carries, so re-baking at a different size needs no change here. +// +// The set is currently the wordmark alone. LOGO_EMBLEM_* and LOGO_FOOT_* are still baked +// into logos.h, so restoring either is one more entry in this array — the per-frame hold +// divides the fixed budget, so boot time won't move when you do. +void display_boot_logos() { + static const struct { const unsigned char* bits; uint8_t w, h; } card[] = { + { LOGO_ONLYNOOBS_BITS, LOGO_ONLYNOOBS_W, LOGO_ONLYNOOBS_H }, + }; + const uint8_t n = sizeof(card) / sizeof(card[0]); + for (uint8_t i = 0; i < n; i++) { + u8g2.clearBuffer(); + u8g2.drawXBMP((128 - card[i].w) / 2, (64 - card[i].h) / 2, + card[i].w, card[i].h, card[i].bits); + u8g2.sendBuffer(); + delay(BOOT_LOGO_TOTAL_MS / n); + } + u8g2.setFont(ROW_FONT); // restore default for later screens +} + +void display_provisioning(const char* device_name, const char* code) { + char buf[32]; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 10, "SETUP MODE"); + u8g2.setFont(u8g2_font_5x8_tf); // 5px wide → the full badge-setup-<MAC> name fits one line + truncate(buf, device_name, 25); // 25 * 5px = 125 < 128 + u8g2.drawStr(0, 21, buf); + // pairing code — big, the web app needs it to unlock provisioning + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 41, "code:"); + u8g2.setFont(u8g2_font_9x18B_tf); + u8g2.drawStr(36, 43, code); + u8g2.setFont(u8g2_font_5x8_tf); + // The escape hatch wins this line over the old "enter code in the app". The code is + // already labelled above and the web app tells you what to do with it, but nothing + // else on the device tells you setup mode can be left — and it never times out. + // 25 chars exactly: 25 * 5px = 125 < 128. + u8g2.drawStr(0, 60, "hold BTN 5s to exit setup"); + u8g2.sendBuffer(); +} + +void display_peers(const char* my_handle, const Peer* peers, int n, bool show_rssi, + uint8_t mult, int page) { + (void)my_handle; // OLED header is a static title, like the other views + char buf[ROW_CHARS + 1]; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + // Paginate: FINDER_PAGE_ROWS closest peers per screen. Clamp defensively (main.cpp + // already clamps as peers leave, but never trust the caller for indexing). + int pages = (n + FINDER_PAGE_ROWS - 1) / FINDER_PAGE_ROWS; + if (pages < 1) pages = 1; + if (page >= pages) page = pages - 1; + if (page < 0) page = 0; + int start = page * FINDER_PAGE_ROWS; + int end = n < start + FINDER_PAGE_ROWS ? n : start + FINDER_PAGE_ROWS; + + // header: "// finder" title + right-aligned "(N)" peer count, plus a "p/P" page tag + // (only when there's more than one page). The inverse multiplier chip sits left of it + // when a group bonus is active. (Column mode lives in User Settings → Finder View.) + u8g2.drawStr(0, 8, "// finder"); + if (pages > 1) snprintf(buf, sizeof(buf), "(%d) %d/%d", n, page + 1, pages); + else snprintf(buf, sizeof(buf), "(%d)", n); + int rw = u8g2.getStrWidth(buf); + u8g2.drawStr(128 - rw, 8, buf); + draw_mult_chip(128 - rw - 3, mult); + u8g2.drawHLine(0, 11, 128); + + if (n == 0) { + u8g2.drawStr(0, 30, "nobody in range"); + u8g2.sendBuffer(); + return; + } + + for (int i = start; i < end; i++) { // this page's slice (≤ FINDER_PAGE_ROWS rows) + int y = 23 + (i - start) * 10; // baselines: 23,33,43,53,63 + draw_bars(0, y, peers[i].rssi); + // right column toggles between the peer's pet face and signed dBm; the bars + // always show signal, so the column is free to carry the pet by default. + char rcol[PET_FACE_MAX + 1]; + if (show_rssi) snprintf(rcol, sizeof(rcol), "%d", peers[i].rssi); + else truncate(rcol, peers[i].pet_face, 8); + int rcol_w = u8g2.getStrWidth(rcol); + u8g2.drawStr(128 - rcol_w, y, rcol); + // handle fills whatever's left between the bars and the right column + int maxch = (128 - rcol_w - 2 - 16) / 6; + if (maxch > ROW_CHARS) maxch = ROW_CHARS; + if (maxch < 0) maxch = 0; + truncate(buf, peers[i].handle, maxch); + u8g2.drawStr(16, y, buf); + } + u8g2.sendBuffer(); +} + +static void fmt_dur(uint32_t s, char* out, size_t n) { + uint32_t h = s / 3600, m = (s % 3600) / 60; + if (h) snprintf(out, n, "%uh%02um", (unsigned)h, (unsigned)m); + else snprintf(out, n, "%um", (unsigned)m); +} + +void display_report(const char* my_handle, const Report& r) { + char buf[ROW_CHARS + 1], dur[16]; + (void)my_handle; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + u8g2.drawStr(0, 8, "// con report"); + if (!r.time_synced) { // relative times → nudge a clock re-sync + const char* tag = "resync"; + u8g2.drawStr(128 - u8g2.getStrWidth(tag), 8, tag); + } + u8g2.drawHLine(0, 11, 128); + + fmt_dur(r.total_seconds, dur, sizeof(dur)); + snprintf(buf, sizeof(buf), "%df %dx %s", r.unique, r.total_encounters, dur); + u8g2.drawStr(0, 23, buf); + + int shown = r.n < 4 ? r.n : 4; // top 4 friends + for (int i = 0; i < shown; i++) { + int y = 35 + i * 10; // 35,45,55,(63) + if (y > 63) break; + fmt_dur(r.friends[i].total_seconds, dur, sizeof(dur)); + char name[15]; + truncate(name, r.friends[i].handle, 11); + snprintf(buf, sizeof(buf), "%-11s x%u %s", name, + (unsigned)r.friends[i].encounters, dur); + u8g2.drawStr(0, y, buf); + } + u8g2.sendBuffer(); +} + +void display_points(const char* my_handle, uint32_t points, + uint32_t prox_seconds, int peers_now, bool earning, + const PointSample* hist, int hist_n, + uint32_t graph_span_s, const char* window_label, bool anchor_newest, + uint8_t mult, bool stealth) { + char buf[ROW_CHARS + 1]; + (void)my_handle; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + // header: "// points" + window tag + [x2 chip] + right-aligned score. The graph + // owns the body now, so the score lives up here instead of as the big number. + u8g2.drawStr(0, 8, "// points"); + u8g2.drawStr(60, 8, window_label); + snprintf(buf, sizeof(buf), "%up", (unsigned)points); + int sw = u8g2.getStrWidth(buf); + u8g2.drawStr(128 - sw, 8, buf); + draw_mult_chip(128 - sw - 3, mult); + u8g2.drawHLine(0, 11, 128); + + // points-over-time line graph fills the middle band (y 14..50) + if (hist_n >= 2) { + bool synced = (hist[hist_n - 1].epoch != 0); + uint32_t anchor = (anchor_newest && synced && hist[hist_n - 1].epoch > graph_span_s) + ? hist[hist_n - 1].epoch - graph_span_s : hist[0].epoch; + draw_sparkline(0, 14, 128, 37, hist, hist_n, graph_span_s, anchor, synced); + } else { + u8g2.drawStr(0, 34, "collecting graph..."); + } + + // status line: live countdown when earning, else why we're paused. With a + // group bonus the tally ticks ×mult per wall second, so the wall-clock wait + // to the next point is the remainder ÷ mult (rounded up). + if (earning) { + uint32_t rem = 60 - prox_seconds % 60; + if (mult > 1) rem = (rem + mult - 1) / mult; + snprintf(buf, sizeof(buf), "+1 pt in %us", (unsigned)rem); + } + else if (stealth) // check first: crew can be right here and in-gate, + snprintf(buf, sizeof(buf), "paused stealth on"); // so "too far" would be a lie + else if (peers_now > 0) + snprintf(buf, sizeof(buf), "paused crew too far"); + else + snprintf(buf, sizeof(buf), "paused no crew"); + u8g2.drawStr(0, 62, buf); + + u8g2.sendBuffer(); +} + +void display_battery(const char* my_handle, uint16_t mv, uint8_t pct, bool low) { + char buf[ROW_CHARS + 1]; + (void)my_handle; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + // header: "// battery LOW!" + u8g2.drawStr(0, 8, "// battery"); + if (low) { + const char* w = "LOW!"; + u8g2.drawStr(128 - u8g2.getStrWidth(w), 8, w); + } + u8g2.drawHLine(0, 11, 128); + + // big percentage, centered (digit font for the number + small "%" after it) + snprintf(buf, sizeof(buf), "%u", (unsigned)pct); + u8g2.setFont(u8g2_font_logisoso28_tn); + int pw = u8g2.getStrWidth(buf); + u8g2.setFont(ROW_FONT); + int sw = u8g2.getStrWidth("%"); + int px = (128 - (pw + 3 + sw)) / 2; // center the "XX%" block + u8g2.setFont(u8g2_font_logisoso28_tn); + u8g2.drawStr(px, 44, buf); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(px + pw + 3, 44, "%"); + + // voltage on the right, e.g. "3.85V" + snprintf(buf, sizeof(buf), "%u.%02uV", mv / 1000, (mv % 1000) / 10); + u8g2.drawStr(128 - u8g2.getStrWidth(buf), 30, buf); + + // full-width charge bar along the bottom + const int bx = 0, by = 52, bw = 128, bh = 11; + u8g2.drawFrame(bx, by, bw, bh); + int fill = (int)((long)(bw - 2) * pct / 100); + if (fill > 0) u8g2.drawBox(bx + 1, by + 1, fill, bh - 2); + + u8g2.sendBuffer(); +} + +// "742K" / "1.2M" / "60B" — a compact size for the 128px-wide readout. +static void fmt_bytes(size_t b, char* out, size_t n) { + if (b >= 1024UL * 1024UL) snprintf(out, n, "%u.%uM", (unsigned)(b >> 20), + (unsigned)((((b >> 10) & 1023) * 10) >> 10)); + else if (b >= 1024UL) snprintf(out, n, "%uK", (unsigned)(b >> 10)); + else snprintf(out, n, "%uB", (unsigned)b); +} + +// Deliberately the same furniture as display_battery (header + big centred %, a figure on +// the right, a full-width bar) — it's the same view under a single tap, so it should read +// as one. It does invert the battery's polarity, though: a disk bar fills as the disk +// fills, per convention, so the number and the bar both report USED while the battery face +// reports what's left. Free space is still carried by the "871K free" label on the right. +void display_storage(const char* my_handle, size_t used, size_t total, bool full) { + char buf[ROW_CHARS + 1], sz[12]; + (void)my_handle; + size_t freeb = (total > used) ? total - used : 0; + // Drives BOTH the big number and the bar, so they can never disagree. + uint8_t pct = total ? (uint8_t)((uint64_t)used * 100 / total) : 0; // % USED + + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + // header: "// storage FULL!" + u8g2.drawStr(0, 8, "// storage"); + if (full) { + const char* w = "FULL!"; + u8g2.drawStr(128 - u8g2.getStrWidth(w), 8, w); + } + u8g2.drawHLine(0, 11, 128); + + // big used percentage, centered (digit font for the number + small "%" after it) + snprintf(buf, sizeof(buf), "%u", (unsigned)pct); + u8g2.setFont(u8g2_font_logisoso28_tn); + int pw = u8g2.getStrWidth(buf); + u8g2.setFont(ROW_FONT); + int sw = u8g2.getStrWidth("%"); + int px = (128 - (pw + 3 + sw)) / 2; // center the "XX%" block + u8g2.setFont(u8g2_font_logisoso28_tn); + u8g2.drawStr(px, 44, buf); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(px + pw + 3, 44, "%"); + + // free space on the right, e.g. "742K free" — the remaining-space figure the % no longer carries + fmt_bytes(freeb, sz, sizeof(sz)); + snprintf(buf, sizeof(buf), "%s free", sz); + u8g2.drawStr(128 - u8g2.getStrWidth(buf), 30, buf); + + // full-width usage bar along the bottom — fills as the disk fills + const int bx = 0, by = 52, bw = 128, bh = 11; + u8g2.drawFrame(bx, by, bw, bh); + int fill = (int)((long)(bw - 2) * pct / 100); + if (fill > 0) u8g2.drawBox(bx + 1, by + 1, fill, bh - 2); + + u8g2.sendBuffer(); +} + +void display_friends(const char* my_handle, const PointFriend* top, int n, + uint32_t total_points) { + char buf[ROW_CHARS + 1], name[16]; + (void)my_handle; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + u8g2.drawStr(0, 8, "// best friends"); + u8g2.drawHLine(0, 11, 128); + + if (n == 0) { + u8g2.drawStr(0, 30, "no points yet"); + u8g2.sendBuffer(); + return; + } + + int shown = n < 3 ? n : 3; // podium: top 3 + for (int i = 0; i < shown; i++) { + int y = 24 + i * 12; // baselines 24,36,48 + truncate(name, top[i].handle, 14); + snprintf(buf, sizeof(buf), "%d %s", i + 1, name); + u8g2.drawStr(0, y, buf); + snprintf(buf, sizeof(buf), "%u", (unsigned)(top[i].seconds / 60)); + u8g2.drawStr(128 - u8g2.getStrWidth(buf), y, buf); // points, right-aligned + } + + snprintf(buf, sizeof(buf), "of %u pts total", (unsigned)total_points); + u8g2.drawStr(0, 62, buf); + u8g2.sendBuffer(); +} + +void display_pet(const char* name, const char* face) { + char buf[ROW_CHARS + 1]; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + snprintf(buf, sizeof(buf), "// %s", name); + u8g2.drawStr(0, 8, buf); + u8g2.drawHLine(0, 11, 128); + // big ASCII face, centered in the body + u8g2.setFont(u8g2_font_9x18B_tf); + u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, 40, face); + // "tap to swap" hint (points/peers readout removed) + u8g2.setFont(ROW_FONT); + u8g2.drawStr(128 - u8g2.getStrWidth("tap:swap"), 62, "tap:swap"); + u8g2.sendBuffer(); +} + +// Tiny battery glyph (11×7 body + 2px terminal nub); inner fill ∝ pct. Top-left at (x,y). +static void draw_batt_glyph(int x, int y, uint8_t pct) { + u8g2.drawFrame(x, y, 11, 7); // body outline + u8g2.drawBox(x + 11, y + 2, 2, 3); // + terminal nub on the right + uint8_t fw = (uint8_t)((pct * 9 + 50) / 100); // inner fill width 0..9 (rounded) + if (fw) u8g2.drawBox(x + 1, y + 1, fw, 5); // fill from the left +} + +// Tiny floppy-disk glyph — 9×7 static, matching the battery glyph's height. Shutter on top, +// label below, both permanent: +// ######### +// #..###..# shutter +// #..###..# +// #.......# +// #.#####.# label +// #.#####.# +// ######### +// Deliberately NOT a fill gauge. A proportional fill failed at both ends: at a realistic 3% +// used it left the label blank — an empty box with a slot, unreadable as a disk — and near +// full it swallowed the icon into a blob. There are only ~35 interior pixels here, not enough +// to be an icon AND a gauge, and the adjacent "%" text already carries the number. So the +// glyph's one job is to say "disk". Hand-drawn rather than Open Iconic: the smallest iconic +// size (1x) is 8×8 and overflows this 7px band. Top-left at (x,y). +static void draw_disk_glyph(int x, int y) { + u8g2.drawFrame(x, y, 9, 7); // disk body + u8g2.drawBox(x + 3, y + 1, 3, 2); // shutter, top-centre + u8g2.drawBox(x + 2, y + 4, 5, 2); // label +} + +void display_home(const char* handle, const char* face, uint32_t points, + uint8_t batt_pct, bool batt_low, uint8_t disk_pct, const char* clock_str, + bool radio_on) { + char buf[ROW_CHARS + 1], name[20]; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + + // header: "// home" (left), then disk glyph + % and battery glyph + % (right). Clock retired. + (void)clock_str; // wall-clock display being retired + u8g2.drawStr(0, 8, "// home"); + // battery: glyph + %, right-aligned. When low: a steady "!" after the % + a slow glyph blink. + snprintf(buf, sizeof(buf), "%u%%%s", (unsigned)batt_pct, batt_low ? "!" : ""); + int pw = u8g2.getStrWidth(buf); + u8g2.drawStr(128 - pw, 8, buf); // percentage (+ "!" when low) at the far right + int batt_x = 128 - pw - 15; // 13px glyph + 2px gap. Computed even while the + // glyph is blinked off, so storage never shifts. + if (!batt_low || (millis() / BATT_LOW_BLINK_MS) % 2 == 0) + draw_batt_glyph(batt_x, 2, batt_pct); // blinks while low + // storage: glyph + % used, parked left of the battery block. Worst realistic case (both + // reading 100%) still clears the "// home" title by ~8px. + snprintf(buf, sizeof(buf), "%u%%", (unsigned)disk_pct); + int dw = u8g2.getStrWidth(buf); + u8g2.drawStr(batt_x - 4 - dw, 8, buf); // 4px gap between the two blocks + draw_disk_glyph(batt_x - 4 - dw - 11, 2); // 9px glyph + 2px gap; the % text is the readout + u8g2.drawHLine(0, 11, 128); + + // username — prominent, centered + truncate(name, handle, 18); + u8g2.setFont(u8g2_font_7x13B_tf); + u8g2.drawStr((128 - u8g2.getStrWidth(name)) / 2, 28, name); + + // pet face (left) + total points (right), sharing the middle band + u8g2.setFont(u8g2_font_9x18B_tf); + u8g2.drawStr(4, 48, face); + u8g2.setFont(ROW_FONT); + snprintf(buf, sizeof(buf), "%u pts", (unsigned)points); + u8g2.drawStr(128 - u8g2.getStrWidth(buf), 46, buf); + + // Stealth tell, in the free band under the face/points row. Inverse so it can't be + // mistaken for a stat, and named for the mode rather than the failure so it reads as + // deliberate. The header is already full (title + disk + battery) and its worst case + // leaves ~6px, so this lives down here where nothing can collide with it. + if (!radio_on) { + const char* lbl = "STEALTH"; + int tw = u8g2.getStrWidth(lbl); + int bw = tw + 6; // 3px pad each side + u8g2.drawRBox((128 - bw) / 2, 53, bw, 11, 1); + u8g2.setDrawColor(0); // cut the label out of the box + u8g2.drawStr((128 - tw) / 2, 62, lbl); + u8g2.setDrawColor(1); + } + + u8g2.sendBuffer(); +} + +// Factory-reset confirmation. Body in the 5x7 font (25 chars/line vs 21) so the list of +// what actually gets destroyed fits without abbreviating it into uselessness. +void display_reset_confirm(int cursor) { + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 8, "// factory reset"); + u8g2.drawHLine(0, 11, 128); + u8g2.setFont(u8g2_font_5x7_tf); + u8g2.drawStr(0, 21, "Erases your handle,"); + u8g2.drawStr(0, 30, "avatar, points, log"); + u8g2.drawStr(0, 39, "and settings. Undo: no."); + u8g2.setFont(ROW_FONT); + static const char* opt[2] = { "Cancel", "ERASE" }; // Cancel first = the default row + for (int i = 0; i < 2; i++) { + int y = 50 + i * 11; // 50 → 43..52, 61 → 54..63; fits 64px + if (i == cursor) u8g2.drawStr(2, y, ">"); + u8g2.drawStr(14, y, opt[i]); + } + u8g2.sendBuffer(); +} + +void display_menu(int splash_sel) { + static const char* names[BOOT_SPLASH_COUNT] = { "radar", "terminal", "glitch", "matrix" }; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 8, "// boot splash"); + u8g2.drawHLine(0, 11, 128); + // 22/10 rather than the old 24/11: with a 4th style the last row's baseline landed at 57 + // (glyphs 50..59) and collided with the "tap: change" hint at 62 (55..64). This clears it. + for (int i = 0; i < BOOT_SPLASH_COUNT; i++) { + int y = 22 + i * 10; + if (i == splash_sel) u8g2.drawStr(2, y, ">"); + u8g2.drawStr(14, y, names[i]); + } + u8g2.drawStr(0, 62, "tap: change"); + u8g2.sendBuffer(); +} + +void display_whoami(const char* handle, const char* mac) { + char buf[ROW_CHARS + 1], line[ROW_CHARS + 1]; + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 10, "// whoami"); + u8g2.drawHLine(0, 13, 128); + truncate(buf, (handle && handle[0]) ? handle : "(unset)", ROW_CHARS - 6); + snprintf(line, sizeof(line), "user: %s", buf); + u8g2.drawStr(0, 30, line); + u8g2.drawStr(0, 46, "mac:"); + u8g2.drawStr(0, 61, mac); // "14:33:5C:xx:xx:xx" — 17 chars fits one line + u8g2.sendBuffer(); +} + +void display_pet_popup(const char* face, const char* line1, const char* line2, uint16_t hold_ms) { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_9x18B_tf); + u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, line2 ? 30 : 36, face); // lift the face for 2 lines + u8g2.setFont(ROW_FONT); + u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, line2 ? 44 : 52, line1); + if (line2) u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 54, line2); + u8g2.sendBuffer(); + delay(hold_ms); + u8g2.setFont(ROW_FONT); +} + +// Same face + 1–2 lines as display_pet_popup, but NON-BLOCKING (no delay). The caller +// keeps it on screen and dismisses it (button / timeout) by redrawing the live view. +void display_toast(const char* face, const char* line1, const char* line2) { + u8g2.clearBuffer(); + u8g2.setFont(u8g2_font_9x18B_tf); + u8g2.drawStr((128 - u8g2.getStrWidth(face)) / 2, line2 ? 30 : 36, face); + u8g2.setFont(ROW_FONT); + u8g2.drawStr((128 - u8g2.getStrWidth(line1)) / 2, line2 ? 44 : 52, line1); + if (line2) u8g2.drawStr((128 - u8g2.getStrWidth(line2)) / 2, 54, line2); + u8g2.sendBuffer(); // no delay — caller owns the frame's lifetime +} + +void display_options_menu(int screen, int cursor, const OptView& v) { + char r1[ROW_CHARS + 1], r2[ROW_CHARS + 1]; + const char* title; + const char* rows[5]; // Back first = the default row, every screen + int nrows; + if (screen == OPTM_USER) { + title = "// user settings"; + // "Avatar: " is the same 8 chars as the old "Pet: ", so it keeps the value column + // aligned with Splash:/Bright:/Debug:/Global:/Shower:. At 8 + an 11-char name it is + // exactly 19 chars = 114px at x=14 — the full width, with nothing to spare. + snprintf(r1, sizeof(r1), "Avatar: %s", v.pet); + snprintf(r2, sizeof(r2), "Finder View: %s", v.finder_mode); + rows[0] = "Back"; rows[1] = r1; rows[2] = r2; rows[3] = "Reminders"; nrows = 4; + } else if (screen == OPTM_DISPLAY) { + title = "// display"; + snprintf(r1, sizeof(r1), "Splash: %s", v.splash); + snprintf(r2, sizeof(r2), "Bright: %s", v.bright); + rows[0] = "Back"; rows[1] = r1; rows[2] = r2; nrows = 3; + } else if (screen == OPTM_DEVOPTS) { + title = "// developer options"; + snprintf(r1, sizeof(r1), "Debug: %s", v.test_state); + rows[0] = "Back"; rows[1] = "Whoami"; rows[2] = r1; + rows[3] = "Provisioning Mode"; rows[4] = "Factory Reset"; nrows = 5; // 5 = the rows[] cap + } else if (screen == OPTM_REMIND) { + title = "// reminders"; + snprintf(r1, sizeof(r1), "Global: %s", v.remind_global); + snprintf(r2, sizeof(r2), "Shower: %s", v.remind_shower); + rows[0] = "Back"; rows[1] = r1; rows[2] = r2; nrows = 3; + } else if (screen == OPTM_BADGE) { + title = "// badge settings"; + snprintf(r1, sizeof(r1), "Stealth: %s", v.stealth); + rows[0] = "Back"; rows[1] = "Display"; rows[2] = r1; + rows[3] = "Developer Options"; rows[4] = "Power"; nrows = 5; // 5 = the rows[] cap + } else if (screen == OPTM_POWER) { + title = "// power"; + rows[0] = "Back"; rows[1] = "Reboot"; rows[2] = "Deep sleep"; nrows = 3; + } else { // OPTM_ROOT + title = "// options"; + rows[0] = "Back"; rows[1] = "User Settings"; rows[2] = "Badge Settings"; nrows = 3; + } + u8g2.clearBuffer(); + u8g2.setFont(ROW_FONT); + u8g2.drawStr(0, 8, title); + u8g2.drawHLine(0, 11, 128); + int y0 = (nrows >= 5) ? 21 : (nrows == 4) ? 22 : 24; // tighten as rows grow + int dy = (nrows >= 5) ? 10 : (nrows == 4) ? 12 : 14; + for (int i = 0; i < nrows; i++) { + int y = y0 + i * dy; + if (i == cursor) u8g2.drawStr(2, y, ">"); + u8g2.drawStr(14, y, rows[i]); + } + u8g2.sendBuffer(); +} + +#endif // BADGE_DISPLAY_OLED diff --git a/firmware/src/display_serial.cpp b/firmware/src/display_serial.cpp @@ -0,0 +1,296 @@ +// Serial implementation of the display interface. +// +// This lets you flash the badge to ANY ESP32 dev board and validate the whole +// protocol — provisioning, signed beacons, the peer table, RSSI sorting, the +// encounter log and the report — over the serial monitor, before the e-ink +// renderer exists. It's the "rule zero: prove the firmware before you touch +// hardware" build. + +#if defined(BADGE_DISPLAY_SERIAL) + +#include <Arduino.h> +#include "display.h" + +void display_init() { + // Serial is already started in main setup(); nothing else to do. +} + +void display_sleep() { + Serial.println("[PWR] display off (headless build — no panel to power down)"); +} + +void display_set_brightness(uint8_t level) { + (void)level; // no panel to dim — the setting still persists for the OLED build +} + +void display_boot(const char* line) { + Serial.printf("\n[BOOT] %s\n", line); +} + +void display_boot_splash(int style) { + (void)style; // serial doesn't animate — one banner + Serial.println(); + Serial.println(" ▁▂▃▄▅▆▇ F R I E N D - F I N D E R ▇▆▅▄▃▂▁"); + Serial.println(" signed ESP-NOW · crew radar · v1"); +} + +void display_boot_logos() { // no panel — the OLED's logo card as one line + Serial.println(" O n l y N 0 0 b s"); +} + +void display_disclaimer() { // heat/LiPo safety note — no hold on serial + Serial.println("\n [WARNING] This badge runs on a LiPo battery. We're in Vegas. " + "Keep an eye on how hot it gets. Spicy pillow? Discontinue use."); +} + +void display_provisioning(const char* device_name, const char* code) { + Serial.println("\n┌─────────────────────────────────────┐"); + Serial.println( "│ PROVISIONING MODE │"); + Serial.printf( "│ Connect over BLE to \"%s\"\n", device_name); + Serial.printf( "│ Pairing code: %s (enter in the app)\n", code); + Serial.println( "│ then write your handle. │"); + Serial.println( "│ Hold BUTTON 5s to exit setup mode. │"); + Serial.println( "└─────────────────────────────────────┘"); +} + +void display_whoami(const char* handle, const char* mac) { + Serial.printf("\n── whoami ──\n user: %s\n mac: %s\n", + (handle && handle[0]) ? handle : "(unset)", mac); +} + +// crude ASCII signal bar from RSSI (dBm): -50 strong … -90 weak +static const char* rssi_bar(int8_t rssi) { + if (rssi >= -55) return "▁▂▃▄▅"; + if (rssi >= -65) return "▁▂▃▄ "; + if (rssi >= -75) return "▁▂▃ "; + if (rssi >= -85) return "▁▂ "; + return "▁ "; +} + +void display_peers(const char* my_handle, const Peer* peers, int n, bool show_rssi, + uint8_t mult, int page) { + (void)show_rssi; // serial has room for both the pet face and dBm, no toggle + (void)page; // serial lists every peer (no 5-row cap), so paging is OLED-only + char mtag[16] = ""; + if (mult >= 2) snprintf(mtag, sizeof(mtag), " ── x%u bonus", (unsigned)mult); + Serial.printf("\n── finder ── you: %s ── %d nearby%s ──\n", my_handle, n, mtag); + if (n == 0) { + Serial.println(" (nobody in range yet)"); + return; + } + for (int i = 0; i < n; i++) { + Serial.printf(" %s %-8s %-24s %4d dBm\n", + rssi_bar(peers[i].rssi), peers[i].pet_face, peers[i].handle, + peers[i].rssi); + } +} + +static void fmt_dur(uint32_t s, char* out, size_t n) { + uint32_t h = s / 3600, m = (s % 3600) / 60, sec = s % 60; + if (h) snprintf(out, n, "%uh%02um", (unsigned)h, (unsigned)m); + else if (m) snprintf(out, n, "%um%02us", (unsigned)m, (unsigned)sec); + else snprintf(out, n, "%us", (unsigned)sec); +} + +void display_report(const char* my_handle, const Report& r) { + char dur[24]; + Serial.println("\n╔═══════════════════════════════════════════════╗"); + Serial.printf( "║ CON REPORT — %-31s ║\n", my_handle); + Serial.println("╚═══════════════════════════════════════════════╝"); + fmt_dur(r.total_seconds, dur, sizeof(dur)); + Serial.printf(" %d friends · %d crossings · %s together\n\n", + r.unique, r.total_encounters, dur); + + if (r.n == 0) { + Serial.println(" (no encounters logged yet)"); + } else { + Serial.println(" friend seen time-together closest"); + Serial.println(" ─────────────────────── ──── ───────────── ───────"); + for (int i = 0; i < r.n; i++) { + fmt_dur(r.friends[i].total_seconds, dur, sizeof(dur)); + Serial.printf(" %-23s x%-3u %-13s %4d dBm\n", + r.friends[i].handle, (unsigned)r.friends[i].encounters, + dur, (int)r.friends[i].rssi_max); + } + } + if (!r.time_synced) + Serial.println("\n (times relative — clock unsynced/stale; re-sync via Options -> Badge Settings -> Provisioning Mode)"); + Serial.println(); +} + +void display_points(const char* my_handle, uint32_t points, + uint32_t prox_seconds, int peers_now, bool earning, + const PointSample* hist, int hist_n, + uint32_t graph_span_s, const char* window_label, bool anchor_newest, + uint8_t mult, bool stealth) { + Serial.printf("\n── points ── you: %s ──\n", my_handle); + Serial.printf(" %u points (%um%02us in proximity)\n", + (unsigned)points, + (unsigned)(prox_seconds / 60), (unsigned)(prox_seconds % 60)); + if (earning) { + // with a group bonus the tally ticks ×mult per wall second → wait = rem ÷ mult + uint32_t rem = 60 - prox_seconds % 60; + if (mult > 1) rem = (rem + mult - 1) / mult; + Serial.printf(" earning%s — %d crew in range, +1 pt in %us\n", + mult >= 3 ? " x3" : mult >= 2 ? " x2" : "", + peers_now, (unsigned)rem); + } + else if (stealth) // check first: crew can be in range AND in-gate, so + Serial.println(" paused — STEALTH on (not broadcasting)"); // "below gate" would lie + else if (peers_now > 0) + Serial.printf(" paused — %d crew in range but below RSSI gate\n", peers_now); + else + Serial.println(" paused — no crew in range"); + + // points-over-time sparkline (oldest→newest), Y auto-scaled to the window; + // a '┊' marks a reboot break (seg_start). Trailing label notes whether the + // samples carry real wall-clock epochs or fell back to relative ordering. + if (hist_n >= 2) { + static const char* lv[8] = {"▁","▂","▃","▄","▅","▆","▇","█"}; + bool synced = (hist[hist_n - 1].epoch != 0); + uint32_t anchor = (anchor_newest && synced && hist[hist_n - 1].epoch > graph_span_s) + ? hist[hist_n - 1].epoch - graph_span_s : hist[0].epoch; + // in-window (epoch ≥ anchor, or all if unsynced) min/max + bool any = false; uint16_t mn = 0, mx = 0; int inwin = 0; + for (int i = 0; i < hist_n; i++) { + if (synced && hist[i].epoch < anchor) continue; + inwin++; + if (!any) { mn = mx = hist[i].points; any = true; } + else { if (hist[i].points < mn) mn = hist[i].points; if (hist[i].points > mx) mx = hist[i].points; } + } + uint32_t yspan = (uint32_t)mx - mn; + Serial.print(" "); + bool first = true; + for (int i = 0; i < hist_n; i++) { + if (synced && hist[i].epoch < anchor) continue; + if (!first && hist[i].seg_start) Serial.print("┊"); // reboot break + int idx = yspan ? (int)(((uint32_t)(hist[i].points - mn) * 7) / yspan) : 0; + Serial.print(lv[idx]); + first = false; + } + Serial.printf(" [%u..%u pts, %d smpl, %s, %s]\n", (unsigned)mn, (unsigned)mx, + inwin, window_label, synced ? "wall-clock" : "relative"); + } +} + +void display_battery(const char* my_handle, uint16_t mv, uint8_t pct, bool low) { + Serial.printf("\n── battery ── you: %s ──\n", my_handle); + Serial.printf(" %u%% (%u.%02u V)%s\n", + (unsigned)pct, mv / 1000, (mv % 1000) / 10, + low ? " ** LOW — charge soon **" : ""); +} + +void display_storage(const char* my_handle, size_t used, size_t total, bool full) { + size_t freeb = (total > used) ? total - used : 0; + unsigned pct = total ? (unsigned)((uint64_t)used * 100 / total) : 0; // % USED (disk convention) + Serial.printf("\n── storage ── you: %s ──\n", my_handle); + Serial.printf(" %u%% used (%u B free of %u)%s\n", + pct, (unsigned)freeb, (unsigned)total, + full ? " ** FULL — export soon **" : ""); +} + +void display_friends(const char* my_handle, const PointFriend* top, int n, + uint32_t total_points) { + Serial.printf("\n── best friends ── you: %s ── %u pts total ──\n", + my_handle, (unsigned)total_points); + if (n == 0) { + Serial.println(" (no points yet — go find your crew)"); + return; + } + int shown = n < 3 ? n : 3; + for (int i = 0; i < shown; i++) { + uint32_t s = top[i].seconds; + Serial.printf(" %d. %-24s %u pts (%um%02us close)\n", + i + 1, top[i].handle, (unsigned)(s / 60), + (unsigned)(s / 60), (unsigned)(s % 60)); + } +} + +void display_pet(const char* name, const char* face) { + Serial.printf("\n── avatar: %s ──\n", name); + Serial.printf(" %s\n", face); + Serial.printf(" (tap to swap avatar)\n"); +} + +void display_home(const char* handle, const char* face, uint32_t points, + uint8_t batt_pct, bool batt_low, uint8_t disk_pct, const char* clock_str, + bool radio_on) { + Serial.printf("\n── home ── %s ── %s ──%s\n", handle, clock_str, + radio_on ? "" : " [STEALTH]"); + Serial.printf(" %s %u pts · bat %u%%%s · disk %u%% used\n", + face, (unsigned)points, (unsigned)batt_pct, + batt_low ? " (LOW)" : "", (unsigned)disk_pct); +} + +void display_reset_confirm(int cursor) { + Serial.println("\n── factory reset ──"); + Serial.println(" Erases handle, avatar, points, encounter log and settings."); + Serial.printf(" %s Cancel\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s ERASE\n", cursor == 1 ? ">" : " "); +} + +void display_menu(int splash_sel) { + static const char* names[BOOT_SPLASH_COUNT] = { "radar", "terminal", "glitch", "matrix" }; + Serial.println("\n── boot splash ──"); + for (int i = 0; i < BOOT_SPLASH_COUNT; i++) + Serial.printf(" %s %s\n", i == splash_sel ? ">" : " ", names[i]); + Serial.println(" (tap to change)"); +} + +void display_pet_popup(const char* face, const char* line1, const char* line2, uint16_t hold_ms) { + (void)hold_ms; // serial popup is a one-line print, no hold + if (line2) Serial.printf(" [avatar] %s — %s: %s\n", face, line1, line2); + else Serial.printf(" [avatar] %s — %s\n", face, line1); +} + +void display_toast(const char* face, const char* line1, const char* line2) { + if (line2) Serial.printf(" [toast] %s — %s: %s\n", face, line1, line2); + else Serial.printf(" [toast] %s — %s\n", face, line1); +} + +void display_options_menu(int screen, int cursor, const OptView& v) { + if (screen == OPTM_USER) { + Serial.println("\n── user settings ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); // first = default row + Serial.printf(" %s Avatar: %s\n", cursor == 1 ? ">" : " ", v.pet); + Serial.printf(" %s Finder View: %s\n", cursor == 2 ? ">" : " ", v.finder_mode); + Serial.printf(" %s Reminders\n", cursor == 3 ? ">" : " "); + } else if (screen == OPTM_DISPLAY) { + Serial.println("\n── display ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s Splash: %s\n", cursor == 1 ? ">" : " ", v.splash); + Serial.printf(" %s Bright: %s\n", cursor == 2 ? ">" : " ", v.bright); + } else if (screen == OPTM_DEVOPTS) { + Serial.println("\n── developer options ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s Whoami\n", cursor == 1 ? ">" : " "); + Serial.printf(" %s Debug: %s\n", cursor == 2 ? ">" : " ", v.test_state); + Serial.printf(" %s Provisioning Mode\n", cursor == 3 ? ">" : " "); + Serial.printf(" %s Factory Reset\n", cursor == 4 ? ">" : " "); + } else if (screen == OPTM_REMIND) { + Serial.println("\n── reminders ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s Global: %s\n", cursor == 1 ? ">" : " ", v.remind_global); + Serial.printf(" %s Shower: %s\n", cursor == 2 ? ">" : " ", v.remind_shower); + } else if (screen == OPTM_BADGE) { + Serial.println("\n── badge settings ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s Display\n", cursor == 1 ? ">" : " "); + Serial.printf(" %s Stealth: %s\n", cursor == 2 ? ">" : " ", v.stealth); + Serial.printf(" %s Developer Options\n", cursor == 3 ? ">" : " "); + Serial.printf(" %s Power\n", cursor == 4 ? ">" : " "); + } else if (screen == OPTM_POWER) { + Serial.println("\n── power ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s Reboot\n", cursor == 1 ? ">" : " "); + Serial.printf(" %s Deep sleep\n", cursor == 2 ? ">" : " "); + } else { // OPTM_ROOT + Serial.println("\n── options ──"); + Serial.printf(" %s Back\n", cursor == 0 ? ">" : " "); + Serial.printf(" %s User Settings\n", cursor == 1 ? ">" : " "); + Serial.printf(" %s Badge Settings\n", cursor == 2 ? ">" : " "); + } + Serial.println(" (tap: move · double-tap: enter)"); +} + +#endif // BADGE_DISPLAY_SERIAL diff --git a/firmware/src/encounters.h b/firmware/src/encounters.h @@ -0,0 +1,234 @@ +#pragma once +#include <Arduino.h> +#include <LittleFS.h> +#include <string.h> +#include "config.h" + +// ───────────────────────────────────────────────────────────────────────── +// Encounter log + report aggregation (your "social graph of the con"). +// +// An *encounter* is one continuous stretch you were near a friend. When a +// peer ages out of the table (leaves range), one record is appended to +// LittleFS. The report aggregates the whole log into per-friend stats. +// +// Privacy by construction: only HMAC-verified crew beacons ever reach the +// peer table, so the log can only ever contain your own group — never randos. +// +// Wire format (tab-separated; a sanitized handle can't contain TAB/newline): +// first_epoch <TAB> last_epoch <TAB> sightings <TAB> rssi_max <TAB> handle\n +// ───────────────────────────────────────────────────────────────────────── + +#define ENC_LOG_PATH "/encounters.log" +#define ENC_LOG_TMP "/encounters.tmp" // scratch for drop-oldest compaction +#define MAX_REPORT_FRIENDS 48 + +struct Encounter { + char handle[HANDLE_MAX_LEN + 1]; + uint32_t first_epoch; + uint32_t last_epoch; + uint16_t sightings; + int8_t rssi_max; +}; + +struct FriendStat { + char handle[HANDLE_MAX_LEN + 1]; + uint16_t encounters; + uint32_t total_seconds; + int8_t rssi_max; + uint32_t first_epoch; + uint32_t last_epoch; +}; + +struct Report { + int unique; + int total_encounters; + uint32_t total_seconds; + bool time_synced; + FriendStat friends[MAX_REPORT_FRIENDS]; + int n; +}; + +// Defined below; both are used by enc_append, which sits above them for readability. +inline size_t enc_trim_oldest(size_t keep_bytes); +inline size_t enc_size(); + +inline bool enc_begin() { + bool ok = LittleFS.begin(true); // format on first mount + // A power cut during a trim leaves ENC_LOG_TMP behind, and nothing else ever cleans it up — + // it would just sit there consuming space the next trim needs. Boot is the safe moment: no + // trim can be in flight. + if (ok) LittleFS.remove(ENC_LOG_TMP); + return ok; +} + +inline void enc_append(const Encounter& e) { + File f = LittleFS.open(ENC_LOG_PATH, FILE_APPEND); + if (!f) return; + f.printf("%u\t%u\t%u\t%d\t%s\n", + (unsigned)e.first_epoch, (unsigned)e.last_epoch, + (unsigned)e.sightings, (int)e.rssi_max, e.handle); + f.close(); // close BEFORE measuring: a File opened for + // append re-stats the path, which can't see + // still-buffered bytes. + // Proactive cap. Keeping the log bounded here — with hundreds of KB free — is what + // makes the trim always feasible. Left to the 90%-full disk guard instead, the copy has + // nowhere to go and silently keeps the WRONG end of the log. + if (enc_size() > ENC_LOG_MAX_BYTES) enc_trim_oldest(ENC_LOG_TRIM_BYTES); +} + +inline void enc_clear() { LittleFS.remove(ENC_LOG_PATH); } + +// Current on-disk size of the log (bytes); 0 if absent. +inline size_t enc_size() { + File f = LittleFS.open(ENC_LOG_PATH, FILE_READ); + if (!f) return 0; + size_t s = f.size(); + f.close(); + return s; +} + +// Drop the OLDEST records so the log keeps only (about) the newest `keep_bytes` — the +// disk guard's "drop-oldest cap" (bounds the log without ever stopping recording). Records +// are appended chronologically, so the newest live at the tail: we keep the tail. Streaming +// LittleFS→LittleFS copy (never slurps the file into RAM), cut aligned to the next record +// boundary so no half-line survives. Returns bytes freed (0 = no-op / nothing to keep). +inline size_t enc_trim_oldest(size_t keep_bytes) { + // The tmp copy coexists with the original until the rename, so the tail we preserve has to + // fit in the space that is ACTUALLY free — not in whatever the caller hoped for. Asking for + // more than fits is how this silently kept the oldest slice instead of the newest. + // Clamping degrades gracefully: worst case we keep less recent history than requested, but + // it is always the most recent history. + size_t total = LittleFS.totalBytes(), used = LittleFS.usedBytes(); + size_t room = (total > used) ? total - used : 0; + room = (room > ENC_TRIM_FREE_MARGIN) ? room - ENC_TRIM_FREE_MARGIN : 0; + if (room == 0) return 0; // no working space — leave the log intact + if (keep_bytes > room) keep_bytes = room; + + File f = LittleFS.open(ENC_LOG_PATH, FILE_READ); + if (!f) return 0; + size_t sz = f.size(); + if (sz <= keep_bytes) { f.close(); return 0; } + f.seek(sz - keep_bytes); // jump to ~keep_bytes before EOF + while (f.available() && f.read() != '\n') { } // advance past the partial first line + if (!f.available()) { f.close(); return 0; } // nothing whole left after the cut — leave as-is + File t = LittleFS.open(ENC_LOG_TMP, FILE_WRITE); + if (!t) { f.close(); return 0; } + uint8_t buf[256]; + bool ok = true; + while (f.available()) { + size_t n = f.read(buf, sizeof(buf)); + if (!n) break; + if (t.write(buf, n) != n) { ok = false; break; } // ENOSPC/IO — an unchecked write here + } // once silently lost the newest history + size_t newsz = t.size(); + t.close(); + f.close(); + // Anything short of a complete copy: throw the partial away and leave the live log alone. + // A truncated log is worse than an oversized one — it's the con souvenir. + if (!ok || newsz == 0) { LittleFS.remove(ENC_LOG_TMP); return 0; } + + // Replace by rename, WITHOUT removing the live log first: littlefs replaces the destination + // as part of the rename, so there is no window where neither file exists. The old + // remove-then-rename could lose the ENTIRE log to a battery cut and orphan the tmp. + // Fallback kept because overwrite-on-rename wasn't verifiable on this bench (the VFS ships + // precompiled) — if it refuses, we do it the old way rather than not trim at all. + if (!LittleFS.rename(ENC_LOG_TMP, ENC_LOG_PATH)) { + LittleFS.remove(ENC_LOG_PATH); + if (!LittleFS.rename(ENC_LOG_TMP, ENC_LOG_PATH)) { LittleFS.remove(ENC_LOG_TMP); return 0; } + } + return (sz > newsz) ? (sz - newsz) : 0; +} + +// Fold one tab-separated record into the aggregate. `line` is NUL-terminated and is modified +// in place (tabs become NULs). Malformed lines are dropped, as before. +inline void enc_report_line(Report* r, char* line) { + char* save = nullptr; + char* c_first = strtok_r(line, "\t", &save); + char* c_last = strtok_r(nullptr, "\t", &save); + char* c_sght = strtok_r(nullptr, "\t", &save); + char* c_rssi = strtok_r(nullptr, "\t", &save); + char* h = strtok_r(nullptr, "\t", &save); + if (!c_first || !c_last || !c_sght || !c_rssi || !h) return; + (void)c_sght; // sightings parsed but unused, as before + + uint32_t first = (uint32_t)strtoul(c_first, nullptr, 10); + uint32_t last = (uint32_t)strtoul(c_last, nullptr, 10); + int8_t rssi = (int8_t)strtol(c_rssi, nullptr, 10); + + while (*h == ' ') h++; // trim, matching the old String::trim() + for (int i = (int)strlen(h) - 1; i >= 0 && (h[i] == ' ' || h[i] == '\r'); i--) h[i] = '\0'; + if (!*h) return; + + int idx = -1; + for (int i = 0; i < r->n; i++) + if (strcmp(h, r->friends[i].handle) == 0) { idx = i; break; } + if (idx < 0) { + if (r->n >= MAX_REPORT_FRIENDS) return; + idx = r->n++; + FriendStat& nf = r->friends[idx]; + strncpy(nf.handle, h, HANDLE_MAX_LEN); + nf.handle[HANDLE_MAX_LEN] = '\0'; + nf.rssi_max = -127; + nf.first_epoch = first; + nf.last_epoch = last; + } + + FriendStat& fs = r->friends[idx]; + fs.encounters++; + if (last > first) fs.total_seconds += (last - first); + if (rssi > fs.rssi_max) fs.rssi_max = rssi; + if (first < fs.first_epoch) fs.first_epoch = first; + if (last > fs.last_epoch) fs.last_epoch = last; + + r->total_encounters++; + if (last > first) r->total_seconds += (last - first); +} + +// Read the whole log and aggregate into per-friend stats. +// +// Block reads + a fixed line buffer, deliberately — NOT String/readStringUntil. That combo is +// one buffered fread PER BYTE plus a String::concat per byte, plus five substring temporaries +// per record; a 10-digit epoch sits exactly at the SSO boundary so both epochs heap-allocate. +// ~6-7 malloc/free per record turned entering REPORT — an ordinary double-tap — into a +// multi-second freeze on a large log. This version allocates nothing. +inline void enc_report(Report* r, bool time_synced) { + memset(r, 0, sizeof(*r)); + r->time_synced = time_synced; + + File f = LittleFS.open(ENC_LOG_PATH, FILE_READ); + if (!f) return; + + uint8_t blk[256]; + char line[96]; // a record is ~44 B; 96 is slack, not a limit in practice + size_t ll = 0; + bool overlong = false; // line outgrew the buffer → drop it, resync at the next \n + + for (;;) { + int n = f.read(blk, sizeof(blk)); + if (n <= 0) break; + for (int i = 0; i < n; i++) { + char c = (char)blk[i]; + if (c != '\n') { + if (ll < sizeof(line) - 1) line[ll++] = c; + else overlong = true; + continue; + } + if (!overlong && ll) { line[ll] = '\0'; enc_report_line(r, line); } + ll = 0; overlong = false; + } + } + if (!overlong && ll) { line[ll] = '\0'; enc_report_line(r, line); } // last line, no \n + f.close(); + + // Sort friends by time-together, descending (your most-seen crew first). + for (int i = 1; i < r->n; i++) { + FriendStat key = r->friends[i]; + int j = i - 1; + while (j >= 0 && r->friends[j].total_seconds < key.total_seconds) { + r->friends[j + 1] = r->friends[j]; + j--; + } + r->friends[j + 1] = key; + } + r->unique = r->n; +} diff --git a/firmware/src/logos.h b/firmware/src/logos.h @@ -0,0 +1,146 @@ +#pragma once +// GENERATED FILE — do not hand-edit. +// +// Boot-card logos as 1-bit bitmaps (generated data). +// +// XBM bit order (LSB = leftmost pixel, rows byte-padded) — blit with +// u8g2.drawXBMP(x, y, W, H, BITS). Each logo carries its own real width/height +// so the render code can centre it without hardcoding any geometry. + +#include <U8g2lib.h> + +// wordmark [SHOWN AT BOOT] — 124x27, 432 bytes +#define LOGO_ONLYNOOBS_W 124 +#define LOGO_ONLYNOOBS_H 27 +static const unsigned char LOGO_ONLYNOOBS_BITS[] U8X8_PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x3e, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, + 0x07, 0x1c, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0xc0, 0xe3, 0x01, 0x00, + 0x00, 0x00, 0x00, 0x1e, 0x06, 0x1f, 0xf8, 0x00, 0x3e, 0xe0, 0x00, 0x00, + 0x60, 0x00, 0x07, 0x00, 0x00, 0x0c, 0x00, 0x0f, 0x86, 0x1f, 0xfe, 0x01, + 0x7f, 0xe0, 0x00, 0x00, 0x10, 0x00, 0x0c, 0x00, 0x00, 0x0c, 0x80, 0x03, + 0xc6, 0x1c, 0xdf, 0xc1, 0xe7, 0xf0, 0x00, 0x00, 0x08, 0x00, 0x18, 0x00, + 0x00, 0x0c, 0xc0, 0x01, 0x67, 0x9e, 0x87, 0xe1, 0xe3, 0x78, 0x00, 0x00, + 0x04, 0x00, 0x10, 0x00, 0x00, 0x0c, 0xe0, 0x00, 0x37, 0xcf, 0x83, 0xf1, + 0xe1, 0x38, 0x00, 0x00, 0x06, 0x3c, 0x20, 0x00, 0x00, 0x0c, 0xf0, 0x00, + 0x1f, 0xef, 0x81, 0xf1, 0xe0, 0x3c, 0xe0, 0x00, 0x02, 0x7e, 0x60, 0x88, + 0x07, 0x8c, 0x70, 0x8c, 0x8f, 0xe7, 0x81, 0x79, 0xe0, 0x1c, 0x70, 0x00, + 0x03, 0xdb, 0x40, 0xe8, 0x1f, 0x8c, 0x31, 0x84, 0x87, 0xf7, 0xc0, 0x3d, + 0xe0, 0xfe, 0x39, 0x00, 0x61, 0xff, 0x46, 0x38, 0x18, 0x0c, 0x31, 0xc6, + 0x87, 0x73, 0xc0, 0x3d, 0x60, 0xfe, 0x39, 0x00, 0xf1, 0xff, 0x46, 0x18, + 0x30, 0x0c, 0x13, 0xc6, 0xc3, 0x7b, 0xc0, 0x1c, 0x70, 0xdf, 0x39, 0x00, + 0xf1, 0xff, 0xcf, 0x18, 0x20, 0x0c, 0x33, 0xe2, 0xc1, 0x39, 0xe0, 0x1e, + 0x70, 0x8f, 0x3d, 0x00, 0x01, 0xff, 0xc1, 0x08, 0x20, 0x0c, 0x12, 0xe3, + 0xe1, 0x3d, 0xe0, 0x0e, 0xb8, 0xc7, 0x7d, 0x08, 0x01, 0x7c, 0xc0, 0x08, + 0x20, 0x0c, 0x06, 0xf1, 0xe0, 0x3c, 0x70, 0x0e, 0xb8, 0xc7, 0x77, 0x0c, + 0x01, 0x3e, 0x40, 0x08, 0x20, 0x0c, 0x84, 0x71, 0xe0, 0x1c, 0x70, 0x0e, + 0x9c, 0xc3, 0x73, 0x04, 0xe1, 0xe7, 0x43, 0x08, 0x20, 0x0c, 0x8c, 0x79, + 0x70, 0x1c, 0x38, 0x0e, 0xdc, 0xe3, 0x63, 0x06, 0xe2, 0x81, 0x43, 0x08, + 0x20, 0x0c, 0x88, 0x38, 0x70, 0x1c, 0x1c, 0x0e, 0xce, 0xe1, 0xe0, 0x03, + 0xc2, 0x00, 0x23, 0x08, 0x20, 0x0c, 0xd8, 0x3c, 0x70, 0x3c, 0x1e, 0x0e, + 0xc7, 0x71, 0xf2, 0x01, 0x46, 0x00, 0x21, 0x08, 0x20, 0x0c, 0x50, 0x1e, + 0x30, 0x38, 0x0f, 0xde, 0xc3, 0x79, 0xfe, 0x01, 0x04, 0x00, 0x10, 0x08, + 0x20, 0x0c, 0x70, 0x0e, 0x30, 0xf8, 0x03, 0xfc, 0x81, 0x3f, 0xfe, 0x00, + 0x08, 0x00, 0x18, 0x08, 0x20, 0x0c, 0x20, 0x0e, 0x30, 0xf0, 0x00, 0x78, + 0x00, 0x0e, 0x3c, 0x00, 0x30, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x20, 0x06, + 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x03, 0x00, + 0x00, 0x00, 0x30, 0x00, 0x70, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x80, 0xff, 0x01, 0x00, 0x00, 0x00, 0x10, 0x00, 0xe0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x80, 0x1d, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x80, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +// emblem (baked, not drawn) — 60x60, 480 bytes +#define LOGO_EMBLEM_W 60 +#define LOGO_EMBLEM_H 60 +static const unsigned char LOGO_EMBLEM_BITS[] U8X8_PROGMEM = { + 0x00, 0x1e, 0x00, 0xf8, 0x01, 0x80, 0x07, 0x00, 0x00, 0x07, 0xc0, 0xff, + 0x3f, 0x00, 0x0e, 0x00, 0x80, 0x03, 0xf8, 0xff, 0xff, 0x01, 0x1c, 0x00, + 0xc0, 0x00, 0xfe, 0xff, 0xff, 0x07, 0x38, 0x00, 0x60, 0x00, 0xff, 0x87, + 0xfd, 0x0f, 0x60, 0x00, 0x30, 0xc0, 0xff, 0xf7, 0xfd, 0x3f, 0xc0, 0x00, + 0x18, 0xe0, 0xff, 0xff, 0xff, 0x7f, 0x80, 0x01, 0x0c, 0xf0, 0xff, 0xff, + 0xff, 0xff, 0x80, 0x03, 0x06, 0xf8, 0xff, 0xff, 0xff, 0xff, 0x01, 0x07, + 0x07, 0xfc, 0xff, 0xff, 0xff, 0xff, 0x03, 0x0e, 0x03, 0xfe, 0xff, 0xff, + 0xff, 0xff, 0x07, 0x0e, 0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0c, + 0x81, 0xff, 0xff, 0xff, 0xff, 0xff, 0x1f, 0x08, 0xc0, 0xff, 0xff, 0xff, + 0xff, 0xff, 0x3f, 0x08, 0xc0, 0xff, 0x83, 0xff, 0x1f, 0xfc, 0x3f, 0x00, + 0xe0, 0xff, 0x00, 0xfe, 0x0f, 0xf0, 0x7f, 0x00, 0xe0, 0x7f, 0x38, 0xfe, + 0xc7, 0xe1, 0x7f, 0x00, 0xf0, 0x7f, 0x7c, 0xf8, 0xe3, 0xe3, 0xff, 0x00, + 0xf0, 0x0f, 0xfe, 0xc0, 0xf3, 0xe7, 0xff, 0x00, 0xf8, 0x01, 0xfe, 0x00, + 0xf3, 0xe7, 0xff, 0x01, 0xf8, 0x08, 0xfe, 0x00, 0xf1, 0xc7, 0xff, 0x01, + 0xf8, 0x1c, 0x7c, 0x08, 0xe0, 0x83, 0xf3, 0x01, 0xfc, 0x3e, 0x3c, 0x08, + 0xc0, 0xc3, 0xf7, 0x03, 0x7c, 0xfc, 0x00, 0x08, 0x00, 0xf0, 0xe3, 0x03, + 0x7c, 0xf8, 0x00, 0x08, 0x00, 0xc0, 0xe1, 0x03, 0x7c, 0xf8, 0x0f, 0x09, + 0x00, 0x87, 0xe1, 0x03, 0x7e, 0xca, 0x1f, 0x0a, 0x80, 0x1f, 0x85, 0x03, + 0x1e, 0xcf, 0x3f, 0x7c, 0xc0, 0x3f, 0x8e, 0x03, 0x9e, 0xc3, 0x7f, 0x18, + 0xe0, 0x3f, 0x9c, 0x07, 0x1e, 0xc0, 0xff, 0x00, 0xf0, 0x3f, 0x00, 0x06, + 0x1e, 0xc0, 0xff, 0x61, 0xf8, 0x3f, 0x80, 0x07, 0x1e, 0xc2, 0xff, 0xf7, + 0xff, 0x3f, 0x84, 0x07, 0x1e, 0xc2, 0xff, 0xff, 0xff, 0x3f, 0x84, 0x03, + 0x1c, 0xc2, 0xff, 0xff, 0xff, 0x3f, 0x84, 0x03, 0xbc, 0xc3, 0x7f, 0xfe, + 0xef, 0x3f, 0xde, 0x03, 0xfc, 0xcf, 0x3f, 0xfc, 0xc3, 0x3f, 0xff, 0x03, + 0x3c, 0x4f, 0x2e, 0xfc, 0xe1, 0x3f, 0xcf, 0x03, 0x3c, 0x4f, 0x26, 0xfc, + 0xc1, 0x3f, 0xcf, 0x01, 0xf8, 0x4f, 0x26, 0x72, 0x44, 0x3f, 0xff, 0x01, + 0x78, 0x46, 0x22, 0x03, 0x4c, 0x3f, 0xe7, 0x01, 0x78, 0x4e, 0x22, 0x00, + 0x44, 0x26, 0x87, 0x01, 0xf0, 0x4f, 0x22, 0x02, 0x44, 0x26, 0x97, 0x00, + 0xb0, 0x49, 0x02, 0x0e, 0x47, 0x26, 0xc9, 0x00, 0x20, 0x49, 0x02, 0x9e, + 0x43, 0x26, 0x40, 0x00, 0x60, 0x46, 0x02, 0x0c, 0x43, 0x26, 0x64, 0x00, + 0x40, 0x44, 0x02, 0x0c, 0x43, 0x26, 0x3e, 0x00, 0xc1, 0x40, 0x02, 0x0e, + 0x43, 0x24, 0x3e, 0x08, 0x81, 0xe1, 0x03, 0x0f, 0x6f, 0x24, 0x1f, 0x08, + 0x03, 0xf3, 0x07, 0x0c, 0x43, 0xf4, 0x0f, 0x0c, 0x03, 0xe2, 0x0d, 0x0e, + 0x27, 0xfc, 0x07, 0x0e, 0x07, 0x84, 0xcc, 0x0f, 0x1f, 0xf0, 0x03, 0x0e, + 0x0e, 0x08, 0x03, 0x1c, 0x03, 0xfc, 0x01, 0x07, 0x1c, 0x30, 0x06, 0x64, + 0x00, 0xfe, 0x80, 0x03, 0x18, 0x60, 0x00, 0x60, 0x80, 0x7f, 0xc0, 0x01, + 0x30, 0xc0, 0x01, 0x66, 0x80, 0x3f, 0xc0, 0x00, 0xf0, 0x00, 0x07, 0xfe, + 0x01, 0x0e, 0x70, 0x00, 0xc0, 0x01, 0x1e, 0x00, 0x80, 0x03, 0x38, 0x00, + 0x80, 0x03, 0xf0, 0x00, 0xf0, 0x00, 0x1c, 0x00, 0x00, 0x0f, 0x80, 0xff, + 0x1f, 0x00, 0x0f, 0x00, 0x00, 0x1e, 0x00, 0x70, 0x00, 0xc0, 0x07, 0x00, +}; + +// foot (baked, not drawn) — 60x60, 480 bytes +#define LOGO_FOOT_W 60 +#define LOGO_FOOT_H 60 +static const unsigned char LOGO_FOOT_BITS[] U8X8_PROGMEM = { + 0x00, 0xc0, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xfe, 0xff, 0x23, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, + 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0x3f, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, + 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0x3f, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xfc, 0xff, 0xe3, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, + 0x83, 0x7f, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0x03, 0x7c, 0x00, 0x00, + 0x00, 0x00, 0xff, 0xff, 0x03, 0x60, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, + 0x03, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0xff, 0x03, 0x00, 0x00, 0x00, + 0x00, 0x80, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0xff, + 0x07, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, + 0x00, 0xe0, 0xff, 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, + 0x07, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, + 0x00, 0xe0, 0xff, 0xff, 0x0f, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, + 0x0f, 0x00, 0x00, 0x00, 0x00, 0xe0, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, + 0x00, 0xe0, 0xff, 0xff, 0x1f, 0x00, 0x00, 0x00, 0x00, 0xfc, 0xff, 0xff, + 0x1f, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, + 0x00, 0xff, 0xff, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0xfa, 0xf1, 0xff, + 0x3f, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xe0, 0xff, 0x3f, 0x00, 0x00, 0x00, + 0x00, 0x40, 0xc0, 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, + 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0xff, 0x7f, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, + 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfe, 0xff, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xfc, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, + 0xff, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf8, 0xff, 0x7f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0xf0, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe0, + 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xff, 0x7f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x80, 0xff, 0x7f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xff, 0x3f, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x3f, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xe0, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +// total bitmap payload: 1392 bytes of flash diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp @@ -0,0 +1,1656 @@ +// ───────────────────────────────────────────────────────────────────────── +// DefCon friend-finder badge — firmware core +// +// Modes, chosen at boot. The radio is never shared: +// +// BOOT ─┬─ (no handle stored) OR (BOOT button held) ─► PROVISIONING (BLE) +// │ phone writes handle + clock → NVS → reboot +// └─ (handle stored) ───────────────────────────► DISCOVERY (ESP-NOW) +// TX signed beacon ~1Hz; RX verify → peer table → display +// peers leaving range → encounter log (LittleFS) +// +1 point per minute while ≥1 crew is in range (→ NVS), +// ×2 with crew around / ×3 in a group of 5+ (the group +// multiplier — POINTS_X2/X3_PEERS), credited to every +// in-gate companion (the points ledger) +// hold BUTTON ≥REPORT_HOLD_MS → cycle view: +// FINDER → REPORT → POINTS → FRIENDS → BATTERY → FINDER +// ───────────────────────────────────────────────────────────────────────── + +#include <Arduino.h> +#include <time.h> // localtime_r / tzset — render the soft-clock epoch in DISPLAY_TZ +#include <Preferences.h> +#include <WiFi.h> +#include <esp_now.h> +#include <esp_wifi.h> +#include <esp_mac.h> +#include <esp_system.h> +#include <esp_sleep.h> // ext0 wake + deep sleep entry (OPTIONS → Power → Deep sleep) +#include <NimBLEDevice.h> + +#include "config.h" +#include "beacon.h" +#include "clock.h" +#include "encounters.h" +#include "peers.h" +#include "points.h" +#include "points_history.h" +#include "battery.h" +#include "display.h" +#include "pet.h" +#include "test_log.h" + +enum Mode { MODE_PROVISION, MODE_DISCOVER }; +static Mode mode; + +// In discovery mode the BUTTON cycles through these views (REPORT is a static +// snapshot; HOME, FINDER and POINTS are live and redraw on every display tick). +// HOME is the at-a-glance default/initial view; it leads the ring. +enum View { VIEW_HOME = 0, VIEW_FINDER, VIEW_REPORT, VIEW_POINTS, VIEW_FRIENDS, VIEW_BATTERY, + VIEW_OPTIONS, VIEW_COUNT }; +static View g_view = VIEW_HOME; +// FINDER right column: false = peer pet face, true = dBm. Set via User Settings → +// "Finder View" (persisted to NVS, key "finder"); the FINDER header no longer labels it. +static bool g_finder_rssi = false; +// FINDER paging: the list shows FINDER_PAGE_ROWS closest peers per screen; a single tap +// pages down (wrapping). g_finder_pages caches the last render's page total so the tap +// handler can wrap without re-locking the peer table. +static int g_finder_page = 0; +static int g_finder_pages = 1; +static int finder_pages(int n) { // total finder pages for n peers (≥1) + int p = (n + FINDER_PAGE_ROWS - 1) / FINDER_PAGE_ROWS; + return p < 1 ? 1 : p; +} + +// OPTIONS is a nested menu: the ROOT list (Back · User Settings · Badge Settings), the +// two settings sub-menus, their sub-sub-menus (Display / Developer Options / Power under +// Badge, Reminders under User), or a child value-picker (pet, reached from User; splash, +// from Display). Every screen stays <= 5 rows, which is the renderer's row array and +// y0/dy layout ladder budget. BADGE sits exactly ON that cap — "Power" replaced "Reboot" +// there rather than adding a row, which is why the reboot action moved down a level. +enum OptScreen { OPT_MENU = 0, OPT_USER, OPT_BADGE, OPT_DISPLAY, OPT_DEVOPTS, + OPT_PET, OPT_SPLASH, OPT_WHOAMI, OPT_REMIND, OPT_RESET, OPT_POWER }; +static OptScreen g_opt_screen = OPT_MENU; +static int g_opt_cursor = 0; // current menu row; 0=Back leads so it's the + // DEFAULT row (double-tap in, double-tap back + // out). Reset to 0 entering a sub-menu; restored + // to the sub-menu's row when backing out to ROOT. +// POINTS graph window, cycled by a single tap on the POINTS view. +enum PointsWin { PWIN_5DAY = 0, PWIN_24H, PWIN_1H, PWIN_COUNT }; +static PointsWin g_points_window = PWIN_5DAY; + +// Settings + companion state (NVS-backed: keys "splash", "pet", "bright"). +static uint8_t g_splash_style = BOOT_SPLASH_STYLE; // active boot splash (0..BOOT_SPLASH_COUNT-1) +static uint8_t g_bright = SCREEN_BRIGHT_DEFAULT; // screen brightness (0=LOW..2=HIGH) +static int g_pet_idx = 0; // active pet (0..PET_BUILTIN_N-1) +static PetState g_pet_react = PET_IDLE; // transient reaction (PET_IDLE = none) +static uint32_t g_pet_react_until = 0; +static uint32_t g_pet_popup_until = 0; // cooldown gate for reaction pop-ups +static Pet g_custom_pet = {}; // uploaded custom pet (slot PET_BUILTIN_N) +static bool g_custom_valid = false; // is a custom pet stored in NVS? +static const Pet* current_pet(); // fwd decl (send_beacon needs it) + +// Reminders (User Settings → Reminders; NVS keys "rmglob"/"rmshow"). "Global" is the +// master switch for all reminders; "Shower" gates the shower reminder specifically. +// Both default ON — toggling a switch OFF disables it. The alert is a NON-BLOCKING +// toast (g_toast_active): shown by the loop, dismissed by any button press or after +// REMINDER_TOAST_MS, so the badge keeps beaconing/scoring while it's up. +static bool g_remind_global = true; +static bool g_remind_shower = true; +static bool g_toast_active = false; // a reminder toast is on screen +static uint32_t g_toast_until = 0; // auto-dismiss deadline (millis) + +// Broadcast switch, surfaced as Badge Settings → STEALTH. Note the polarity: this holds +// the RADIO state, and stealth is its inverse (stealth ON = g_radio_on false). The name +// stays radio-side because that's what the code actually gates; the inversion happens once, +// where OptView is built. NVS key "radio", default ON (= not stealthed). +// Off gates send_beacon() and nothing else: the badge stops broadcasting — nobody can see +// it — but the ESP-NOW stack stays up, so RX, the peer table, FINDER and scoring all keep +// working. See the Radio/discovery block in config.h for why it's TX-only, not a teardown. +static bool g_radio_on = true; + +static Preferences prefs; +static SoftClock g_clock; +static char g_handle[HANDLE_MAX_LEN + 1] = {0}; +static uint32_t g_counter = 0; + +// Points: +1 per full minute spent with ≥1 crew badge in range. We tally the +// proximity time in seconds (g_prox_seconds, persisted to NVS) and carry the +// sub-second remainder between ticks (g_prox_accum_ms). points = seconds / 60. +// Each earned second is also credited to EVERY in-gate friend in g_ledger, +// which backs the "Best Friends" view: a friend's seconds = how many of your +// social minutes they shared (so each ≤ the tally, but they overlap and don't +// sum to it — being near three friends at once is one shared minute, not three). +static uint32_t g_prox_seconds = 0; +static uint32_t g_prox_accum_ms = 0; +static PointsLedger g_ledger; +static PointsHistory g_points_history; // rolling score samples for the POINTS graph (hourly, 5-day) +static PointsRecent g_points_recent; // fine 1-min ring for the 1-hour POINTS view (in-RAM) + +// Peer table is touched by both the ESP-NOW RX callback and loop() → guard it. +static PeerTable peers; +static portMUX_TYPE peerMux = portMUX_INITIALIZER_UNLOCKED; + +// ─── Test mode (OPTIONS → Test): LittleFS CSV logs + boot MAC / serial dump ── +static bool g_test_mode = false; +static uint32_t g_boot_id = 0; // ++ each time test mode goes active; tags every log line +static TestLog g_batt_log("/test_batt.csv", "boot,elapsed_ms,vbat_mv,soc_pct"); +static TestLog g_pts_log ("/test_pts.csv", "boot,elapsed_ms,points,prox_s,peers,mult"); + +static const uint8_t BCAST[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; + +// Session set of badge MACs we've already greeted, so the "new crew!" pet +// reaction fires exactly once per device — not on every TTL re-entry, the way +// the old snapshot-diff did. MAC (not handle) is the identity: it's the stable +// hardware address and is available on both Arduino cores (handles can change or +// collide). RAM-only by design — a reboot re-greets your crew. Bounded ring: +// past CONTACT_SEEN_MAX distinct devices the oldest is evicted, so a long-gone +// badge may re-greet once (fine for a cosmetic pop-up). Single-writer (the recv +// callback is serialized on the WiFi task), so the set itself needs no lock; the +// main loop only reads/clears the volatile flag. +static constexpr int CONTACT_SEEN_MAX = 256; +static uint8_t g_seen_macs[CONTACT_SEEN_MAX][6]; +static int g_seen_n = 0; // entries in use (0..MAX) +static int g_seen_head = 0; // ring evict point once full +static volatile bool g_new_contact = false; // a never-seen MAC just arrived +static char g_contact_handle[HANDLE_MAX_LEN + 1] = {0}; // its handle, for the contact toast + +// First-sighting test for a source MAC: returns true (and records it) only the +// first time this MAC is seen this session. Called from the recv callback only. +static bool note_contact_mac(const uint8_t* mac) { + if (!mac) return false; + for (int i = 0; i < g_seen_n; i++) + if (memcmp(g_seen_macs[i], mac, 6) == 0) return false; // already greeted + if (g_seen_n < CONTACT_SEEN_MAX) { + memcpy(g_seen_macs[g_seen_n++], mac, 6); + } else { + memcpy(g_seen_macs[g_seen_head], mac, 6); // evict oldest + g_seen_head = (g_seen_head + 1) % CONTACT_SEEN_MAX; + } + return true; +} + +// ─── shared helpers ──────────────────────────────────────────────────────── + +// Clamp untrusted input to printable ASCII, max HANDLE_MAX_LEN bytes. +static uint8_t sanitize_handle(const uint8_t* in, size_t n, char* out) { + uint8_t k = 0; + for (size_t i = 0; i < n && k < HANDLE_MAX_LEN; i++) { + uint8_t c = in[i]; + // drop control/non-ASCII AND comma: a comma would break the CSV metrics export + // (best_friends.csv, and the encounters TSV the web comma-izes on receipt). + if (c >= 0x20 && c <= 0x7E && c != ',') out[k++] = (char)c; + } + out[k] = '\0'; + return k; +} + +// ─── PROVISIONING MODE (BLE GATT) ─────────────────────────────────────────── + +static volatile bool g_saved = false; + +// Pairing code: a fresh 3-char code is shown on the badge each provisioning session; +// the web app must write it (PROV_CODE_CHAR_UUID) before any handle/time/pet write is +// honored — a physical-presence gate against drive-by BLE provisioning. NOT crypto (BLE +// writes are cleartext); it just requires line-of-sight to the badge screen. +static char g_prov_code[4] = {0}; +static volatile bool g_prov_authed = false; +static char g_prov_name[32] = {0}; // advertised name; kept for setup-screen redraws + +// Pairing-code attempt limiting (see PROV_CODE_MAX_TRIES). The tally is per-BOOT, not +// per-connection: onDisconnect must NOT reset it, or reconnecting resets the limit and +// the limit means nothing — free reconnects were the actual finding. +static volatile uint8_t g_prov_fails = 0; +static volatile bool g_prov_locked = false; // latched; only a reboot clears it +static volatile bool g_prov_lock_pending = false; // loop(): rotate, redraw, drop the peer +static volatile uint16_t g_prov_conn = 0; // conn handle of the last code writer + +// Metrics export (BLE, provisioning mode): the reserved metrics char streams a framed CSV/TSV +// dump to the web app once the pairing code is accepted. The client subscribes → onSubscribe +// flags a request; the actual streaming runs in loop() (never in a BLE callback), then the +// badge reboots to discovery. g_metrics_ch = the char (for notify); g_metrics_conn = the peer +// (for the MTU query that sizes the chunks). +static NimBLECharacteristic* g_metrics_ch = nullptr; +static volatile uint16_t g_metrics_conn = 0; +static volatile bool g_metrics_req = false; // subscribed → stream once authed +static bool g_metrics_done = false; // stream sent → reboot to discovery +static uint32_t g_metrics_reboot_at = 0; + +// Unambiguous uppercase alphabet (no 0/O/1/I/L) — easy to read off-screen + retype. +static void gen_prov_code(char out[4]) { + static const char A[] = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // 30 glyphs + for (int i = 0; i < 3; i++) out[i] = A[esp_random() % (sizeof(A) - 1)]; + out[3] = '\0'; +} + +class HandleWriteCB : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override { + if (!g_prov_authed) { Serial.println("[PROV] handle write ignored — pairing code not accepted"); return; } + NimBLEAttValue v = c->getValue(); + char clean[HANDLE_MAX_LEN + 1]; + uint8_t len = sanitize_handle(v.data(), v.length(), clean); + if (len == 0) return; // ignore empty/garbage writes + prefs.putString("handle", clean); + Serial.printf("[PROV] stored handle: \"%s\"\n", clean); + g_saved = true; // loop() will reboot us + } +}; + +// Optional: phone writes the current epoch (u32 little-endian) to seed the +// soft-clock, so the report can show real wall-clock times. +class TimeWriteCB : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override { + if (!g_prov_authed) { Serial.println("[PROV] time write ignored — pairing code not accepted"); return; } + NimBLEAttValue v = c->getValue(); + if (v.length() < 4) return; + const uint8_t* d = v.data(); + uint32_t epoch = (uint32_t)d[0] | ((uint32_t)d[1] << 8) | + ((uint32_t)d[2] << 16) | ((uint32_t)d[3] << 24); + g_clock.set(epoch); + Serial.printf("[PROV] clock synced: %u\n", (unsigned)epoch); + // If a handle is already stored, this is a re-sync (not first-time setup), + // so reboot back into home for the corrected clock to take effect. During + // first-time provisioning the handle isn't stored yet, so this no-ops and we + // wait for the handle write to trigger the reboot. + if (prefs.getString("handle", "").length() > 0) g_saved = true; + } +}; + +// Optional: phone uploads a custom pet — a blob of up to 6 newline-separated +// fields (name, then the 5 state-faces idle/contact/milestone/lonely/lowbatt). +// Each field is clamped to printable ASCII + its max length, stored to NVS, and +// auto-selected. If a handle is already stored, reboots into home (re-provision). +class PetWriteCB : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* c, NimBLEConnInfo&) override { + if (!g_prov_authed) { Serial.println("[PROV] pet write ignored — pairing code not accepted"); return; } + NimBLEAttValue v = c->getValue(); + const char* data = (const char*)v.data(); + size_t len = v.length(); + Pet p; + memset(&p, 0, sizeof(p)); + int field = 0; + size_t start = 0; + for (size_t i = 0; i <= len && field < 1 + PET_STATE_COUNT; i++) { + if (i == len || data[i] == '\n') { + uint8_t maxlen = (field == 0) ? PET_NAME_MAX - 1 : PET_FACE_MAX - 1; + char clean[PET_FACE_MAX]; + uint8_t k = 0; + for (size_t j = start; j < i && k < maxlen; j++) { + uint8_t ch = (uint8_t)data[j]; + if (ch >= 0x20 && ch <= 0x7E) clean[k++] = (char)ch; // printable ASCII only + } + clean[k] = '\0'; + if (field == 0) strncpy(p.name, clean, PET_NAME_MAX - 1); + else strncpy(p.face[field - 1], clean, PET_FACE_MAX - 1); + field++; + start = i + 1; + } + } + if (p.name[0] == '\0') return; // need at least a name + g_custom_pet = p; + g_custom_valid = true; + prefs.putBytes("petcust", &g_custom_pet, sizeof(g_custom_pet)); + prefs.putUChar("petc_ok", 1); + g_pet_idx = PET_BUILTIN_N; // auto-select the new custom pet + prefs.putUInt("pet", g_pet_idx); + Serial.printf("[PROV] custom pet stored: \"%s\"\n", p.name); + if (prefs.getString("handle", "").length() > 0) g_saved = true; + } +}; + +// The gate: the web app writes the 3-char code shown on the badge here; only an exact +// (case-insensitive) match flips g_prov_authed, unlocking the handle/time/pet writes. +class CodeWriteCB : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* c, NimBLEConnInfo& info) override { + // Checked first: once latched, nothing gets compared at all, so a locked badge is + // also a badge that no longer leaks whether a guess was close. + if (g_prov_locked) { + g_prov_authed = false; + Serial.println("[PROV] code write rejected — gate locked until reboot"); + return; + } + + NimBLEAttValue v = c->getValue(); + char in[4] = {0}; + for (uint8_t i = 0; i < v.length() && i < 3; i++) { + char ch = (char)v.data()[i]; + in[i] = (ch >= 'a' && ch <= 'z') ? (char)(ch - 32) : ch; // uppercase + } + g_prov_authed = (strcmp(in, g_prov_code) == 0); + if (g_prov_authed) { + g_prov_fails = 0; // a good code clears the tally + Serial.printf("[PROV] pairing code \"%s\" -> ACCEPTED\n", in); + return; + } + + if (g_prov_fails < 255) g_prov_fails++; + Serial.printf("[PROV] pairing code \"%s\" -> REJECTED (%u/%u)\n", + in, (unsigned)g_prov_fails, (unsigned)PROV_CODE_MAX_TRIES); + if (g_prov_fails >= PROV_CODE_MAX_TRIES) { + g_prov_locked = true; // latch immediately — before loop() gets a turn + g_prov_conn = info.getConnHandle(); + g_prov_lock_pending = true; // loop() rotates the code, redraws, and disconnects + } + } +}; + +// Metrics stream: the web app subscribes to notifications (after entering the pairing code) +// to pull the framed dump. We only flag the request here; loop() does the streaming, gated on +// g_prov_authed, so this BLE-stack callback stays fast (no long work in the callback). +class MetricsReadCB : public NimBLECharacteristicCallbacks { + void onSubscribe(NimBLECharacteristic*, NimBLEConnInfo& info, uint16_t subValue) override { + if (subValue == 0) return; // client unsubscribed + g_metrics_conn = info.getConnHandle(); // for the MTU query + g_metrics_req = true; // loop() streams once the code is accepted + Serial.println("[METRICS] client subscribed — will stream after the pairing code"); + } +}; + +class ServerCB : public NimBLEServerCallbacks { + void onDisconnect(NimBLEServer*, NimBLEConnInfo&, int) override { + g_prov_authed = false; // each new connection must re-send the code + g_metrics_req = false; // cancel any pending (unauthed) stream request + // NOTE: g_prov_fails is deliberately NOT reset here. Advertising restarts below, so a + // reconnect is free — if the tally reset with it, the attempt limit would be worthless. + NimBLEDevice::startAdvertising(); // allow repeated attempts + } +}; + +static void start_provisioning() { + // Per-badge advertised name: base + the FULL 6-byte eFuse MAC (e.g. + // "badge-setup-14335C591858"), so badges in pairing mode are uniquely identifiable in + // the browser device picker AND map 1:1 to the bench roster (which dedups by full MAC). + // 24 chars fits the 31B BLE scan-response budget; the OLED setup screen renders it in a + // 5px font so the whole name fits one line. + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + // File-static, not a local: prov_escape_tick() redraws this screen when an aborted + // hold-to-exit has to put the setup screen back. + snprintf(g_prov_name, sizeof(g_prov_name), "%s-%02X%02X%02X%02X%02X%02X", + PROV_DEVICE_NAME, mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + const char* prov_name = g_prov_name; + + gen_prov_code(g_prov_code); // fresh pairing code per session + g_prov_authed = false; + display_provisioning(prov_name, g_prov_code); + + NimBLEDevice::init(prov_name); + NimBLEServer* server = NimBLEDevice::createServer(); + server->setCallbacks(new ServerCB()); + + NimBLEService* svc = server->createService(PROV_SERVICE_UUID); + + NimBLECharacteristic* hch = + svc->createCharacteristic(PROV_CHAR_UUID, NIMBLE_PROPERTY::WRITE); + hch->setCallbacks(new HandleWriteCB()); + + NimBLECharacteristic* tch = + svc->createCharacteristic(PROV_TIME_CHAR_UUID, NIMBLE_PROPERTY::WRITE); + tch->setCallbacks(new TimeWriteCB()); + + NimBLECharacteristic* pch = + svc->createCharacteristic(PROV_PET_CHAR_UUID, NIMBLE_PROPERTY::WRITE); + pch->setCallbacks(new PetWriteCB()); + + NimBLECharacteristic* cch = // pairing-code gate (write before the others) + svc->createCharacteristic(PROV_CODE_CHAR_UUID, NIMBLE_PROPERTY::WRITE); + cch->setCallbacks(new CodeWriteCB()); + + // Metrics characteristic (READ|NOTIFY): streams a framed CSV/TSV dump — encounters.log + + // points history, plus the Test-Mode CSVs when Debug is on — to the provisioning web app, + // gated by the pairing code. Chunked to the negotiated MTU; the page reassembles the notify + // chunks until the "==== EOF ====" sentinel. Streaming happens in loop() (see stream_metrics). + NimBLECharacteristic* mch = + svc->createCharacteristic(PROV_METRICS_CHAR_UUID, + NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY); + mch->setCallbacks(new MetricsReadCB()); + mch->setValue("subscribe (notify) after the pairing code to pull metrics"); + g_metrics_ch = mch; + + svc->start(); + + // The 128-bit service UUID (18B) + flags (3B) fill 21B of the 31B legacy adv packet, + // so the name can't also live there. Build BOTH packets EXPLICITLY rather than rely on + // auto-routing: the previous `setName()` shortened a 24-char name down to "badge-setup" + // (31B − the 18B UUID = a 13B slot → an 11-char *Shortened Local Name*). Here the scan + // response carries the full name ALONE (its own 31B), so the complete name survives. + NimBLEAdvertising* adv = NimBLEDevice::getAdvertising(); + + NimBLEAdvertisementData advData; // PRIMARY: flags + service filter + advData.setFlags(BLE_HS_ADV_F_DISC_GEN | BLE_HS_ADV_F_BREDR_UNSUP); + advData.addServiceUUID(PROV_SERVICE_UUID); + adv->setAdvertisementData(advData); + + NimBLEAdvertisementData scanData; // SCAN RESPONSE: full name, alone + scanData.setName(prov_name); + adv->setScanResponseData(scanData); + + adv->enableScanResponse(true); + adv->start(); +} + +// ─── DISCOVERY MODE (ESP-NOW) ─────────────────────────────────────────────── + +// The ESP-NOW receive callback signature changed between Arduino-ESP32 cores: +// core 3.x (IDF 5.x): (const esp_now_recv_info_t*, ...) — exposes RSSI +// core 2.x (IDF 4.x): (const uint8_t* mac, ...) — no RSSI +// Support both: compiles on the stock toolchain today, and gains proximity +// sorting for free on core 3.x (pioarduino platform — see platformio.ini). +#if ESP_ARDUINO_VERSION_MAJOR >= 3 +static void on_espnow_recv(const esp_now_recv_info_t* info, + const uint8_t* data, int len) { + int8_t rssi = (info && info->rx_ctrl) ? info->rx_ctrl->rssi : 0; + const uint8_t* src = info ? info->src_addr : nullptr; +#else +static void on_espnow_recv(const uint8_t* mac, + const uint8_t* data, int len) { + int8_t rssi = 0; // RSSI not exposed by the core 2.x ESP-NOW callback + const uint8_t* src = mac; +#endif + // Verify (HMAC) OUTSIDE the lock — never run crypto in a critical section. + if (!beacon_verify((const Beacon*)data, (size_t)len)) return; + const Beacon* b = (const Beacon*)data; + uint32_t epoch = g_clock.now(); + + portENTER_CRITICAL(&peerMux); + peers.upsert(b->handle, b->handle_len, rssi, b->counter, millis(), epoch, + b->pet, b->pet_face); + portEXIT_CRITICAL(&peerMux); + + // MAC dedup is independent of the peer table, so keep it out of the lock: fire + // the contact flag once, the first time this device's MAC is heard. + if (note_contact_mac(src)) { // first time this MAC is heard + uint8_t hl = b->handle_len <= HANDLE_MAX_LEN ? b->handle_len : HANDLE_MAX_LEN; + memcpy(g_contact_handle, b->handle, hl); + g_contact_handle[hl] = '\0'; // name the peer in the contact toast + g_new_contact = true; + } +} + +static void send_beacon() { + Beacon b; + memset(&b, 0, sizeof(b)); // zero-pad handle for the MAC + b.magic = BEACON_MAGIC; + b.version = BEACON_VERSION; + b.counter = ++g_counter; + size_t hl = strlen(g_handle); + if (hl > HANDLE_MAX_LEN) hl = HANDLE_MAX_LEN; + b.handle_len = (uint8_t)hl; + memcpy(b.handle, g_handle, hl); + b.pet = (uint8_t)g_pet_idx; // our selected pet … + strncpy(b.pet_face, current_pet()->face[PET_IDLE], PET_FACE_MAX); // … + its idle face (zero-padded) + beacon_sign(&b); + esp_now_send(BCAST, (const uint8_t*)&b, sizeof(b)); +} + +static void start_discovery() { + enc_begin(); // mount LittleFS for the encounter log + + // Persisted counter jumps forward each boot so old captured beacons (with + // lower counters) can't be replayed as "fresher" after a power cycle. + g_counter = prefs.getUInt("ctr", 0) + 1000; + prefs.putUInt("ctr", g_counter); + + // Resume the points tally + per-friend ledger — a power cycle mid-con must + // not zero your score or your best-friends ranking. + g_prox_seconds = prefs.getUInt("prox", 0); + g_ledger.load(prefs); + g_points_history.load(prefs); // restore the graph; arms a reboot break + + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + esp_wifi_set_channel(ESPNOW_CHANNEL, WIFI_SECOND_CHAN_NONE); + // NB: the ESP32's DEFAULT max TX (~19.5 dBm) is already its effective ceiling here. Calling + // esp_wifi_set_max_tx_power(80 or 84) paradoxically *lowered* it to 17.5 dBm on this chip + // (rc=ESP_OK, but the read-back regressed — an ESP-IDF quirk), so we deliberately DON'T set + // it; we just read + log. The real range lever is antenna keepout, not these tenths of a dB. + int8_t txp = 0; esp_wifi_get_max_tx_power(&txp); + Serial.printf("[DISC] TX power: %.2f dBm\n", txp * 0.25f); + + if (esp_now_init() != ESP_OK) { + display_boot("ESP-NOW init FAILED"); + return; + } + esp_now_register_recv_cb(on_espnow_recv); + + esp_now_peer_info_t p = {}; + memcpy(p.peer_addr, BCAST, 6); + p.channel = ESPNOW_CHANNEL; + p.ifidx = WIFI_IF_STA; + p.encrypt = false; + esp_now_add_peer(&p); + + Serial.printf("[DISC] %s as \"%s\" on ch %d\n", + g_radio_on ? "beaconing" : "listening only (STEALTH on)", + g_handle, ESPNOW_CHANNEL); +} + +// Finalize in-range encounters, aggregate the log, and render the report. +static void enter_report() { + // static scratch: this path nests loop()→enter_report→enc_append→vfprintf, and ev[32] (~1.1KB) + // + Report r (~1.9KB) on the stack tripped the loopTask stack canary (a reboot on FINDER→REPORT). + // Single-task, non-reentrant, refilled every call (ev by flush, r by enc_report's memset), so + // static is safe and moves ~3KB off the stack. + static Encounter ev[MAX_PEERS]; + int en = 0; + portENTER_CRITICAL(&peerMux); + peers.flush(ev, en, MAX_PEERS); + portEXIT_CRITICAL(&peerMux); + for (int i = 0; i < en; i++) enc_append(ev[i]); // file IO outside the lock + + static Report r; + enc_report(&r, g_clock.synced()); + display_report(g_handle, r); +} + +// True when ≥1 crew is in range AND the closest clears the RSSI gate. The peer +// snapshot is sorted closest-first, so snap[0] carries the strongest signal. +// +// STEALTH also stops scoring. Points are meant to measure time spent WITH crew, and +// while stealthed the exchange is one-way: they can't see us, so they aren't being +// credited for us either. Earning off badges that can't earn off you back is a leech. +// Gating it here rather than at the accrual site is deliberate — the POINTS view reads +// the same predicate, so the tally and the on-screen "earning/paused" state can't drift. +static bool points_earning(const Peer* snap, int n) { + return g_radio_on && n > 0 && snap[0].rssi >= POINTS_MIN_RSSI; +} + +// Group multiplier from the in-range crew count (the peer table — anyone on the +// FINDER list counts, gated or not; the RSSI gate still decides WHETHER you +// earn). A 1-on-1 earns at base ×1; ×2 needs POINTS_X2_PEERS+ crew in range, ×3 +// needs POINTS_X3_PEERS+. Each earned second is scaled by this before tally + ledger. +static uint8_t points_multiplier(int n) { + if (n >= POINTS_X3_PEERS) return 3; + if (n >= POINTS_X2_PEERS) return 2; + return 1; +} + +// Render the points scoreboard for the current tally + in-range crew count. +static void render_points() { + Peer snap[MAX_PEERS]; + int n; + portENTER_CRITICAL(&peerMux); + n = peers.snapshot(snap, MAX_PEERS); + portEXIT_CRITICAL(&peerMux); + static PointSample hist[POINTS_HISTORY_LEN]; // static: keep ~960B off the loop-task + int hn; // stack (POINTS-only; render runs single-threaded) + uint32_t span; + const char* label; + if (g_points_window == PWIN_1H) { + hn = g_points_recent.snapshot(hist, POINTS_HISTORY_LEN); // fine 1-min ring + span = POINTS_GRAPH_1H_SPAN_S; label = "1h"; + } else if (g_points_window == PWIN_24H) { + hn = g_points_history.snapshot(hist, POINTS_HISTORY_LEN); + span = POINTS_GRAPH_24H_SPAN_S; label = "24h"; + } else { + hn = g_points_history.snapshot(hist, POINTS_HISTORY_LEN); + span = POINTS_GRAPH_SPAN_S; label = "5d"; + } + display_points(g_handle, g_prox_seconds / 60, g_prox_seconds, n, + points_earning(snap, n), hist, hn, span, label, + g_points_window != PWIN_5DAY, points_multiplier(n), !g_radio_on); +} + +// Render the best-friends scoreboard from the points ledger (top contributors). +static void render_friends() { + PointFriend top[MAX_POINT_FRIENDS]; + int n = g_ledger.top(top, MAX_POINT_FRIENDS); + display_friends(g_handle, top, n, g_prox_seconds / 60); +} + +// ─── disk usage cache ──────────────────────────────────────────────────────── +// LittleFS.usedBytes() walks the filesystem, so it is NOT safe to call from a live +// redraw (the BATTERY/STORAGE view repaints every VIEW_REFRESH). The disk guard already +// polls once a minute (DISK_CHECK_MS); both it and the STORAGE view read this cache +// instead of re-walking. disk_refresh() re-reads on demand — entering the STORAGE view +// forces one, since the guard's first poll is a whole DISK_CHECK_MS after boot. +static size_t g_disk_total = 0, g_disk_used = 0; + +static void disk_refresh() { + g_disk_total = LittleFS.totalBytes(); + g_disk_used = LittleFS.usedBytes(); +} + +// The disk-guard trip: >=90% full. Shared so the STORAGE view's "FULL!" badge lights on +// exactly the condition that trims the log, rather than a second threshold that could drift. +static bool disk_is_full() { + return g_disk_total && g_disk_used * DISK_FULL_DEN >= g_disk_total * DISK_FULL_NUM; +} + +// % USED, matching the STORAGE view's polarity — for the HOME header gauge. +static uint8_t disk_pct() { + return g_disk_total ? (uint8_t)((uint64_t)g_disk_used * 100 / g_disk_total) : 0; +} + +// BATTERY view sub-toggle: a single tap flips to the STORAGE readout and back, the same +// way POINTS cycles its window. Reset on fresh entry so cycling views always lands on the +// battery face — storage is a peek, not a place to park. +static bool g_batt_storage = false; + +// Render the battery fuel gauge from the latest smoothed ADC reading — or, when toggled, +// the storage readout from the cached LittleFS figures. +static void render_battery() { + if (g_batt_storage) display_storage(g_handle, g_disk_used, g_disk_total, disk_is_full()); + else display_battery(g_handle, battery_mv(), battery_pct(), battery_is_low()); +} + +// ─── pet (companion) ───────────────────────────────────────────────────────── + +// The active pet: a built-in, or the uploaded custom pet in slot PET_BUILTIN_N. +static const Pet* current_pet() { + if (g_pet_idx == PET_BUILTIN_N && g_custom_valid) return &g_custom_pet; + return pet_get(g_pet_idx); +} +// How many pets are selectable (built-ins + the custom slot iff uploaded). +static int pet_total() { return PET_BUILTIN_N + (g_custom_valid ? 1 : 0); } + +// Resolve the face to show: an active reaction wins, else the ambient state +// (low-batt > lonely > idle). +static const char* pet_face_now(const Pet* p, uint32_t now, int nearby, bool lowbatt) { + if (g_pet_react != PET_IDLE && now < g_pet_react_until) return p->face[g_pet_react]; + if (lowbatt) return p->face[PET_LOWBATT]; + if (nearby == 0) return p->face[PET_LONELY]; + return p->face[PET_IDLE]; +} + +static void render_pet(); // fwd decl (pet_react may redraw it) + +// Fire a transient reaction (contact/milestone). On the PET view it redraws the +// face; off it, the reaction pops up briefly over the current view, rate-limited +// by PET_POPUP_COOLDOWN_MS so a crowded room doesn't spam it. +static void pet_react(PetState s, uint32_t now, const char* line1 = nullptr, + const char* line2 = nullptr, uint16_t hold = 0, bool force = false) { + g_pet_react = s; + g_pet_react_until = now + PET_REACT_MS; + if (g_view == VIEW_OPTIONS && g_opt_screen == OPT_PET) { render_pet(); return; } + // A reminder toast owns the screen — hold reaction pop-ups (the face still updates) so a + // blocking pet_popup can't clobber it; the toast auto-clears in ≤ REMINDER_TOAST_MS. + if (!g_toast_active && (force || now >= g_pet_popup_until)) { // force = important enough to skip the cooldown + g_pet_popup_until = now + PET_POPUP_COOLDOWN_MS; + const Pet* p = current_pet(); + // line1 = the reason (caller wins, else a generic), line2 = the detail (optional) + const char* l1 = line1 ? line1 : (s == PET_MILESTONE ? "milestone" : "new crew"); + uint16_t h = hold ? hold : (s == PET_MILESTONE ? PET_MILESTONE_POPUP_MS : PET_POPUP_MS); + display_pet_popup(p->face[s], l1, line2, h); + } +} + +// Render the PET view: the active pet's current face + a quick stat line. +static void render_pet() { + Peer snap[MAX_PEERS]; + int n; + portENTER_CRITICAL(&peerMux); + n = peers.snapshot(snap, MAX_PEERS); + portEXIT_CRITICAL(&peerMux); + const Pet* p = current_pet(); + display_pet(p->name, pet_face_now(p, millis(), n, battery_is_low())); // n still feeds the lonely/idle face +} + +// Render the settings MENU (boot-splash picker). +static void render_menu() { + display_menu(g_splash_style); +} + +static const char* splash_name(uint8_t s) { + static const char* n[BOOT_SPLASH_COUNT] = { "radar", "terminal", "glitch", "matrix" }; + return n[s % BOOT_SPLASH_COUNT]; +} + +static const char* bright_name(uint8_t b) { + static const char* n[SCREEN_BRIGHT_LEVELS] = { "LOW", "MED", "HIGH" }; + return n[b % SCREEN_BRIGHT_LEVELS]; +} + +// Snapshot the peer table and draw the current FINDER page (closest first, in the +// selected pet/dBm column mode). Recomputes the page total and clamps the page in +// case peers have left. Used by the ring, the page tap, and the live redraw. +static void render_finder() { + Peer snap[MAX_PEERS]; + int n; + portENTER_CRITICAL(&peerMux); + n = peers.snapshot(snap, MAX_PEERS); + portEXIT_CRITICAL(&peerMux); + g_finder_pages = finder_pages(n); + if (g_finder_page >= g_finder_pages) g_finder_page = g_finder_pages - 1; // peers left → clamp + display_peers(g_handle, snap, n, g_finder_rssi, points_multiplier(n), g_finder_page); +} + +// Render the HOME view — the at-a-glance default: handle, pet, total points, +// battery, and wall-clock time. The clock is formatted HERE (epoch→HH:MM, or +// "--:--" when unsynced/stale) so both display backends stay clock-agnostic. +static void render_home() { + Peer snap[MAX_PEERS]; + int n; + portENTER_CRITICAL(&peerMux); + n = peers.snapshot(snap, MAX_PEERS); // for the resolved pet face (lonely/idle) + portEXIT_CRITICAL(&peerMux); + const Pet* p = current_pet(); + char clk[6]; + if (g_clock.synced()) { + time_t t = (time_t)g_clock.now(); // seeded epoch is UTC (phone sends Date.now()/1000) + struct tm lt; + localtime_r(&t, &lt); // → DISPLAY_TZ (Pacific, DST-aware) via tzset() at boot + snprintf(clk, sizeof(clk), "%02u:%02u", (unsigned)lt.tm_hour, (unsigned)lt.tm_min); + } else { + strncpy(clk, "--:--", sizeof(clk)); // never synced, or stale → don't show a wrong time + } + display_home(g_handle, pet_face_now(p, millis(), n, battery_is_low()), + g_prox_seconds / 60, battery_pct(), battery_is_low(), disk_pct(), clk, + g_radio_on); +} + +// Wipe every trace of this badge's owner and restart into a fresh-board state. Exists so a +// badge can be handed on WITHOUT a USB cable — the flashing SOP's `esptool erase_flash` is +// still the authority when a board is on the bench, but that is no help +// to someone passing a badge to a friend at the con. +// +// Three separate stores have to go, and missing any one leaves the badge identifiably the +// previous owner's: +// "badge" NVS — handle, avatar, points/ledger/history, splash, brightness, stealth, … +// "clock" NVS — a SEPARATE namespace, so prefs.clear() above does not reach it +// LittleFS — /encounters.log (the social graph) + the Test-Mode CSVs. format() rather +// than enc_clear(), which only removes the log and would strand the CSVs. +// Never returns: the caller's world (g_handle, the peer table, the ledger) is now +// inconsistent with NVS, so the only safe next step is a cold boot. +static void factory_reset() { + display_boot("erasing..."); // format() can take a moment; don't look hung + prefs.clear(); + g_clock.factory_clear(); + LittleFS.format(); + delay(150); // let the NVS + FS commits land before the reset + esp_restart(); +} + +// OPTIONS → Power → "Deep sleep". The closest thing to an OFF switch this hardware has: +// the SoC's RTC domain stays alive to watch one pin, everything else stops. Never returns — +// waking is a full application restart (ESP_RST_DEEPSLEEP), not a resume, so setup() runs +// again from the top and the badge comes up on HOME. +// +// NOT actually off. The regulator, the charge IC and the onboard VBAT divider on GPIO34 all +// sit on the battery side of anything software controls, so a slept badge still drains. +// Board-level sleep current is unmeasured and decides whether this +// is an "off" or a long nap. +// +// Wake is the button and ONLY the button: this board has no VBUS-detect tap, +// so plugging in USB will not wake it (it does still charge). esptool drives EN/BOOT over +// RTS/DTR, which resets the SoC regardless — a slept unit is still flashable. +static void enter_deep_sleep() { + display_pet_popup(current_pet()->face[PET_IDLE], "Sleeping...", "Press to wake", 2000); + g_points_history.save(prefs); // sleep may last days; don't strand the last sample + display_sleep(); // panel off — without this the sleep saves ~nothing + + // Don't sleep into a held button. ext0 wakes on the pin going LOW and the button is + // INPUT_PULLUP/active-low, so entering deep sleep while it is still down re-wakes us + // instantly and the badge looks like it ignored the command. Same guard the provisioning + // escape hatch uses before its restart. + while (digitalRead(BUTTON_PIN) == LOW) delay(10); + delay(50); // let the contact settle before arming the wake + + esp_sleep_enable_ext0_wakeup((gpio_num_t)BUTTON_PIN, 0); // 0 = wake on LOW (press) + esp_deep_sleep_start(); +} + +// Jump to the HOME view (long-press from anywhere, or the OPTIONS "Back" row). +static void go_home() { + g_view = VIEW_HOME; + render_home(); +} + +// Render whichever OPTIONS screen is active: parent list or a child picker. +// Formatted eFuse MAC "14:33:5C:xx:xx:xx" (same base MAC as the provisioning name). +static void format_mac(char out[18]) { + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + snprintf(out, 18, "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); +} + +static void render_options() { + if (g_opt_screen == OPT_PET) { render_pet(); return; } // pet face + live reactions + if (g_opt_screen == OPT_SPLASH) { render_menu(); return; } // splash picker + if (g_opt_screen == OPT_RESET) { display_reset_confirm(g_opt_cursor); return; } + if (g_opt_screen == OPT_WHOAMI) { // read-only identity screen + char ms[18]; format_mac(ms); + display_whoami(g_handle, ms); + return; + } + int m = (g_opt_screen == OPT_USER) ? OPTM_USER + : (g_opt_screen == OPT_BADGE) ? OPTM_BADGE + : (g_opt_screen == OPT_DISPLAY) ? OPTM_DISPLAY + : (g_opt_screen == OPT_DEVOPTS) ? OPTM_DEVOPTS + : (g_opt_screen == OPT_POWER) ? OPTM_POWER + : (g_opt_screen == OPT_REMIND) ? OPTM_REMIND : OPTM_ROOT; + OptView v = { current_pet()->name, splash_name(g_splash_style), bright_name(g_bright), + g_test_mode ? "ON" : "OFF", g_finder_rssi ? "dBm" : "avatar", + g_remind_global ? "ON" : "OFF", g_remind_shower ? "ON" : "OFF", + g_radio_on ? "OFF" : "ON" }; // stealth is the INVERSE of the radio state + display_options_menu(m, g_opt_cursor, v); +} + +// Redraw whichever view is active — used to restore the screen after a reminder toast +// clears (the toast overlays the live view; dismissing it repaints what was underneath). +static void redraw_current_view() { + switch (g_view) { + case VIEW_HOME: render_home(); break; + case VIEW_FINDER: render_finder(); break; + case VIEW_REPORT: enter_report(); break; // re-snapshot the static report + case VIEW_POINTS: render_points(); break; + case VIEW_FRIENDS: render_friends(); break; + case VIEW_BATTERY: render_battery(); break; + case VIEW_OPTIONS: render_options(); break; + default: render_home(); break; + } +} + +// Fire a NON-BLOCKING reminder toast: draw it now and arm an auto-dismiss deadline. The +// loop holds the frame (view redraws are suppressed while g_toast_active) until a button +// press or REMINDER_TOAST_MS clears it — unlike display_pet_popup it never blocks the loop, +// so beaconing/scoring keep running under the toast. +static void show_toast(const char* face, const char* l1, const char* l2) { + g_toast_active = true; + g_toast_until = millis() + REMINDER_TOAST_MS; + display_toast(face, l1, l2); +} + +// Unified one-button gesture detector. With a single button we disambiguate by +// tap count and hold: +// single tap → sub-view action (fires after DOUBLETAP_GAP_MS rules out a 2nd tap) +// double tap → next view (fires immediately on the second tap) +// long press → fires once at REPORT_HOLD_MS while held (jump home to FINDER) +// A "tap" is a press released within TAP_MAX_MS. (Holding BOOT at power-on forces +// provisioning — a separate boot-time check in setup().) Call every loop. +enum Gesture { GEST_NONE = 0, GEST_SINGLE, GEST_DOUBLE, GEST_LONG }; + +static Gesture button_gesture() { + static uint32_t press_ms = 0; + static uint32_t first_tap_ms = 0; + static bool was_down = false; + static bool pending = false; // a first tap is awaiting a possible second + static bool long_fired = false; // long-press already emitted for this hold + uint32_t now = millis(); + bool down = (digitalRead(BUTTON_PIN) == LOW); + Gesture g = GEST_NONE; + + if (down && !was_down) { // press edge + press_ms = now; + long_fired = false; + } else if (down && was_down) { // still held → emit long-press once at threshold + if (!long_fired && now - press_ms >= REPORT_HOLD_MS) { + long_fired = true; + pending = false; // a long hold cancels any pending tap + g = GEST_LONG; + } + } else if (!down && was_down) { // release edge + if (!long_fired && now - press_ms <= TAP_MAX_MS) { // a short tap + if (pending && now - first_tap_ms <= DOUBLETAP_GAP_MS) { + pending = false; + g = GEST_DOUBLE; // second tap within the window → double + } else { + pending = true; // first tap; wait the window out + first_tap_ms = now; + } + } else { + pending = false; // a hold/long, not a tap + } + } + was_down = down; + // A lone first tap, no second within the window → single (only once released). + if (g == GEST_NONE && pending && !down && now - first_tap_ms > DOUBLETAP_GAP_MS) { + pending = false; + g = GEST_SINGLE; + } + return g; +} + +// Whatever the setup screen should currently be. Once the pairing gate has latched, +// redrawing the normal setup screen would show a freshly rotated code that cannot work — +// baiting the owner into retyping forever. Show the lockout instead. +static void prov_redraw() { + if (g_prov_locked) display_boot("locked, power-cycle"); + else display_provisioning(g_prov_name, g_prov_code); +} + +// ─── provisioning escape hatch ────────────────────────────────────────────── +// Setup mode never times out and doesn't beacon, so a badge that lands there by accident +// is inert with no way out on the device itself. The firmware even nudges people toward +// re-provisioning to fix a stale clock — and the crowd most likely to follow that nudge +// into a dead end is the iOS-only one, who cannot run the Web Bluetooth app to provision +// their way back out. Holding BUTTON for PROV_ESCAPE_HOLD_MS reboots to the home view. +// +// Two things this MUST get right or it silently does nothing at all: +// +// 1. THE BUTTON IS STILL DOWN WHEN WE REBOOT. setup() reads BUTTON_PIN to decide +// whether to force provisioning, so restarting mid-hold walks straight back into +// setup mode. We wait for the release first. +// +// 2. A BADGE WITH NO STORED HANDLE RE-ENTERS SETUP REGARDLESS of the button — +// have_handle is false, so setup() has nowhere else to send it. Escaping therefore +// has to leave a usable handle behind, so we seed a MAC-derived default. That is +// the difference between "escaped" and "escaped into the same room". +// +// Called only from the MODE_PROVISION branch of loop(), where the button is otherwise +// unused, so there's no gesture to conflict with. +static void prov_escape_tick() { + static uint32_t press_ms = 0; + static bool was_down = false; + static bool armed = false; // see below — requires one release before it listens + static int shown = -1; // countdown value currently drawn (-1 = setup screen) + + uint32_t now = millis(); + bool down = (digitalRead(BUTTON_PIN) == LOW); + + // Trap 3: the usual way INTO setup mode is holding BOOT through power-on, so the + // button is often still down on our first ticks. Without this, entering setup mode + // and simply not letting go would bounce you straight back out — the feature + // sabotaging the very gesture that reaches it. Require a fresh press. + if (!armed) { + if (down) return; + armed = true; + } + + if (down && !was_down) press_ms = now; // press edge + was_down = down; + + if (!down) { // released early → restore the screen + if (shown >= 0) { prov_redraw(); shown = -1; } + return; + } + + uint32_t held = now - press_ms; + if (held < PROV_ESCAPE_FEEDBACK_MS) return; // ignore incidental presses + + if (held < PROV_ESCAPE_HOLD_MS) { // counting down, once per second + int left = (int)((PROV_ESCAPE_HOLD_MS - held + 999) / 1000); + if (left != shown) { + char line[24]; + snprintf(line, sizeof(line), "exit setup in %d", left); + display_boot(line); + shown = left; + } + return; + } + + // Held the full duration → leave setup mode. + if (prefs.getString("handle", "").length() == 0) { // trap 2: never-provisioned badge + uint8_t mac[6]; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + char dflt[HANDLE_MAX_LEN + 1]; + snprintf(dflt, sizeof(dflt), "n00b-%02X%02X", mac[4], mac[5]); + prefs.putString("handle", dflt); // renameable later from the web app + char line[24]; + snprintf(line, sizeof(line), "named %s", dflt); + display_boot(line); + delay(1200); // long enough to read it + } + + display_boot("exiting setup"); + while (digitalRead(BUTTON_PIN) == LOW) delay(10); // trap 1: don't reboot into a held button + delay(50); // debounce the release + ESP.restart(); +} + +// ─── lifecycle ────────────────────────────────────────────────────────────── + +// Active boot-splash style: the user's NVS pick (set in the MENU view), defaulting to the +// compiled-in BOOT_SPLASH_STYLE. Wrapped on BOOT_SPLASH_COUNT so a stored index from an +// older build (or a wider one) can never index past the name table. +static int pick_splash_style() { + return (int)(prefs.getUInt("splash", BOOT_SPLASH_STYLE) % BOOT_SPLASH_COUNT); +} + +// Serial POST / boot diagnostics — printed after the splash, before the welcome. +static void print_boot_diag(const char* modestr, const char* handle) { + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); + const char* rst; + switch (esp_reset_reason()) { + case ESP_RST_POWERON: rst = "POWERON"; break; + case ESP_RST_SW: rst = "SW"; break; + case ESP_RST_PANIC: rst = "PANIC"; break; + case ESP_RST_INT_WDT: + case ESP_RST_TASK_WDT: + case ESP_RST_WDT: rst = "WDT"; break; + case ESP_RST_BROWNOUT: rst = "BROWNOUT"; break; + case ESP_RST_DEEPSLEEP: rst = "DEEPSLEEP"; break; + default: rst = "OTHER"; break; + } + Serial.println(" ── boot ───────────────────────────────"); + Serial.printf( " build : %s\n", FW_BUILD_STR); + Serial.printf( " chip : %s · %d core @ %d MHz\n", + ESP.getChipModel(), ESP.getChipCores(), (int)ESP.getCpuFreqMHz()); + Serial.printf( " flash : %u MB heap: %u KB free\n", + (unsigned)(ESP.getFlashChipSize() >> 20), + (unsigned)(ESP.getFreeHeap() >> 10)); + Serial.printf( " mac : %02X:%02X:%02X:%02X:%02X:%02X\n", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + Serial.printf( " reset : %s\n", rst); + Serial.printf( " mode : %s handle \"%s\" ch %d\n", + modestr, handle, ESPNOW_CHANNEL); + Serial.printf( " stealth: %s\n", g_radio_on ? "OFF (broadcasting)" + : "ON (not broadcasting — RX still live)"); + Serial.println(" ────────────────────────────────────────"); + delay((uint32_t)BOOT_DIAG_HOLD_MS * BOOT_TIMING_SCALE); // hold so the readout is readable +} + +void setup() { + Serial.begin(115200); + delay(200); + display_init(); + + prefs.begin("badge", false); // up top: settings (splash/pet) + handle live here + + // Brightness first: display_init() ran before NVS was open (it has to — it owns the + // bus), so the saved level can only be applied here. Do it before the splash so the + // very first frame the user sees is at their pick, not a flash of the panel default. + g_bright = (uint8_t)(prefs.getUChar("bright", SCREEN_BRIGHT_DEFAULT) % SCREEN_BRIGHT_LEVELS); + display_set_brightness(g_bright); + + g_splash_style = (uint8_t)pick_splash_style(); + // restore a custom pet (slot PET_BUILTIN_N) if one was uploaded + if (prefs.getUChar("petc_ok", 0) == 1 && + prefs.getBytes("petcust", &g_custom_pet, sizeof(g_custom_pet)) == sizeof(g_custom_pet)) + g_custom_valid = true; + g_pet_idx = (int)(prefs.getUInt("pet", 0) % pet_total()); + g_finder_rssi = (prefs.getUChar("finder", 0) == 1); // FINDER column: 0=pet face, 1=dBm (User Settings) + g_remind_global = (prefs.getUChar("rmglob", 1) == 1); // reminders default ON (toggle to disable) + g_remind_shower = (prefs.getUChar("rmshow", 1) == 1); + g_radio_on = (prefs.getUChar("radio", 1) == 1); // broadcast defaults ON (Badge Settings → Radio) + g_test_mode = (prefs.getUChar("testmode", 0) == 1); // OPTIONS → Test (persisted) + if (g_test_mode) { + g_boot_id = prefs.getUInt("bootid", 0) + 1; + prefs.putUInt("bootid", g_boot_id); + // The user's pet announces test mode BEFORE the boot splash. + display_pet_popup(current_pet()->face[PET_MILESTONE], "Debug Mode On", nullptr, TEST_MODE_POPUP_MS); + } + display_boot_splash(g_splash_style); // animated splash (OLED) / ASCII banner (serial) + display_boot_logos(); // logo card — FOLLOWS the chosen splash, every boot + display_disclaimer(); // heat/LiPo safety card, held then continues boot + + g_clock.begin(); + setenv("TZ", DISPLAY_TZ, 1); // wall-clock shown in Pacific (DST-aware); epoch stays UTC + tzset(); + // A brownout reset means we were dark for an unknown span, so the resumed-from-NVS + // wall-clock is skewed → flag it stale (report shows relative + a re-sync nudge). + // + // A deep-sleep wake is the SAME failure with a different cause, and it is the more likely + // one now that OPTIONS → Power → Deep sleep exists: the soft clock free-runs off millis(), + // which resets to 0 on wake, so a badge slept overnight resumes from its last 60 s NVS + // checkpoint hours behind. Without this the report would show a confidently-WRONG + // wall-clock with no warning — exactly the bug the stale flag was added to kill. + { + esp_reset_reason_t rr = esp_reset_reason(); + if (rr == ESP_RST_BROWNOUT || rr == ESP_RST_DEEPSLEEP) g_clock.mark_stale(); + } + battery_init(); + + pinMode(BUTTON_PIN, INPUT_PULLUP); + delay(50); + // Enter provisioning if BOOT is held, OR if OPTIONS → Provisioning Mode requested it + // (one-shot NVS flag) so the hosted BLE page can re-seed handle/clock/pet without a + // button-held reboot. + bool force_provision = (digitalRead(BUTTON_PIN) == LOW) || + (prefs.getUChar("provreq", 0) == 1); + prefs.putUChar("provreq", 0); // consume the one-shot + + String stored = prefs.getString("handle", ""); + bool have_handle = stored.length() > 0 && !force_provision; + if (have_handle) { + strncpy(g_handle, stored.c_str(), HANDLE_MAX_LEN); + g_handle[HANDLE_MAX_LEN] = '\0'; + } + + // Serial POST / boot diagnostics, after the splash and before the welcome. + print_boot_diag(have_handle ? "DISCOVERY" : "PROVISION", + have_handle ? g_handle : "(unset)"); + + if (g_test_mode) { // TEST MODE: show the MAC + dump prior logs + uint8_t mac[6] = {0}; + esp_read_mac(mac, ESP_MAC_WIFI_STA); // the STA MAC = the peer-table identity + char macs[18]; + snprintf(macs, sizeof(macs), "%02X:%02X:%02X:%02X:%02X:%02X", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + display_boot(macs); // on the OLED — read/photograph it, no USB needed + Serial.printf("[TEST] MAC %s -- dumping logs --\n", macs); + LittleFS.begin(true); // ensure mounted before reading the CSVs + g_batt_log.dump(Serial); + g_pts_log.dump(Serial); + delay(TEST_MAC_HOLD_MS); // hold the MAC screen long enough to capture + } + + if (!have_handle) { + mode = MODE_PROVISION; + start_provisioning(); + } else { + // Welcome as a pet TOAST (the old welcome screen in toast form) — the pet greets you by + // handle. "Welcome," over "<handle>!" across the toast's two lines (as the old screen was) + // so the full handle keeps its own line width; one combined line would cap the handle ~8 + // chars. PET_CONTACT = the greeting "new crew in range" face. + char hl[HANDLE_MAX_LEN + 2]; + snprintf(hl, sizeof(hl), "%.*s!", 17, g_handle); // handle + '!', clamped to the toast line + display_pet_popup(current_pet()->face[PET_CONTACT], "Welcome,", hl, 1200 * BOOT_TIMING_SCALE); + mode = MODE_DISCOVER; + start_discovery(); + disk_refresh(); // seed the cache — start_discovery() has just mounted LittleFS, and the + // guard's first poll is DISK_CHECK_MS away, but HOME shows the disk + // gauge immediately and would otherwise read 0% for the first minute. + } +} + +// ─── test-mode loggers (gated by g_test_mode at the call sites) ────────────── + +// Battery: adaptive cadence — coarse on the plateau, fast once the cell drops +// below the knee (the cliff is where VBAT_EMPTY + the real curve live). +static void test_batt_log_tick(uint32_t now) { + static uint32_t last = 0; + uint16_t mv = battery_mv(); + uint32_t iv = (mv && mv < TEST_LOG_CLIFF_MV) ? TEST_LOG_FAST_MS : TEST_LOG_INTERVAL_MS; + if (last != 0 && (now - last) < iv) return; + last = now; + char line[48]; + snprintf(line, sizeof(line), "%lu,%lu,%u,%u", + (unsigned long)g_boot_id, (unsigned long)now, mv, battery_pct()); + g_batt_log.append(line); +} + +// Points: 1-min snapshots of the live scoring counters (the graph rings don't +// expose these as CSV — handy for validating the group-multiplier logic). +static void test_pts_log_tick(uint32_t now, int peers_now, uint8_t mult) { + static uint32_t last = 0; + if (last != 0 && (now - last) < TEST_PTLOG_INTERVAL_MS) return; + last = now; + char line[64]; + snprintf(line, sizeof(line), "%lu,%lu,%lu,%lu,%d,%u", + (unsigned long)g_boot_id, (unsigned long)now, + (unsigned long)(g_prox_seconds / 60), (unsigned long)g_prox_seconds, + peers_now, (unsigned)mult); + g_pts_log.append(line); +} + +// ─── metrics export (BLE stream, provisioning mode) ────────────────────────── +// Send `data` (len bytes) as ≤`chunk` notify pieces, paced so the BLE tx buffer can drain. +// Returns false if the peer vanished mid-send (caller aborts the stream). +static bool metrics_notify(const uint8_t* data, size_t len, size_t chunk) { + size_t off = 0; + while (off < len) { + if (!g_metrics_ch || NimBLEDevice::getServer()->getConnectedCount() == 0) return false; + size_t n = (len - off < chunk) ? (len - off) : chunk; + // notify(data,len) sends THIS slice directly (no setValue/notify race) and returns false when + // the tx buffer is full — retry with backoff so a chunk is NEVER silently dropped. Dropped + // chunks fuse CSV lines into garbage on the far end (the bug that made vbat_mv read in the + // millions). Abort if the peer vanishes. + int tries = 0; + while (!g_metrics_ch->notify(data + off, n)) { + if (++tries > METRICS_NOTIFY_RETRIES || + NimBLEDevice::getServer()->getConnectedCount() == 0) return false; + delay(METRICS_PACE_MS); // let the controller drain, then retry the same slice + } + off += n; + delay(METRICS_PACE_MS); // steady pacing between chunks (also feeds the WDT) + } + return true; +} +static bool metrics_notify_str(const char* s, size_t chunk) { + return metrics_notify((const uint8_t*)s, strlen(s), chunk); +} + +// Stream one LittleFS file, framed like TestLog::dump (==== path ==== … ==== end ====). +static bool metrics_stream_file(const char* path, size_t chunk) { + char hdr[64]; + snprintf(hdr, sizeof(hdr), "==== %s ====\n", path); + if (!metrics_notify_str(hdr, chunk)) return false; + File f = LittleFS.open(path, FILE_READ); + if (f) { + uint8_t buf[METRICS_CHUNK_MAX]; + while (f.available()) { + size_t n = f.read(buf, chunk); + if (n && !metrics_notify(buf, n, chunk)) { f.close(); return false; } + } + f.close(); + } else if (!metrics_notify_str("(none)\n", chunk)) { + return false; + } + return metrics_notify_str("==== end ====\n", chunk); +} + +// Stream the points-history ring as CSV (formatted from the NVS-backed RAM buffer). +static bool metrics_stream_points(size_t chunk) { + if (!metrics_notify_str("==== /points_history.csv ====\n", chunk)) return false; + if (!metrics_notify_str("epoch,points,seg_start\n", chunk)) return false; + static PointSample ps[POINTS_HISTORY_LEN]; // static: keep it off the stack + int n = g_points_history.snapshot(ps, POINTS_HISTORY_LEN); + char line[32]; + for (int i = 0; i < n; i++) { + int m = snprintf(line, sizeof(line), "%lu,%u,%u\n", + (unsigned long)ps[i].epoch, (unsigned)ps[i].points, (unsigned)ps[i].seg_start); + if (!metrics_notify((const uint8_t*)line, (size_t)m, chunk)) return false; + } + return metrics_notify_str("==== end ====\n", chunk); +} + +// Stream the best-friends ledger (per-friend credited proximity seconds, ranked). Handle LAST — +// sanitized handles may contain a comma, so keep the numeric column unsplittable by it. +static bool metrics_stream_friends(size_t chunk) { + if (!metrics_notify_str("==== /best_friends.csv ====\n", chunk)) return false; + if (!metrics_notify_str("seconds,handle\n", chunk)) return false; + static PointFriend bf[MAX_POINT_FRIENDS]; + int n = g_ledger.top(bf, MAX_POINT_FRIENDS); + char line[HANDLE_MAX_LEN + 16]; + for (int i = 0; i < n; i++) { + int m = snprintf(line, sizeof(line), "%lu,%s\n", (unsigned long)bf[i].seconds, bf[i].handle); + if (!metrics_notify((const uint8_t*)line, (size_t)m, chunk)) return false; + } + return metrics_notify_str("==== end ====\n", chunk); +} + +// The whole export: encounters.log always, points history always, the Test-Mode CSVs only +// when Debug is on. Chunk size = the negotiated MTU (−3 ATT overhead), capped. Ends with the +// "==== EOF ====" sentinel the page reassembles up to. Runs in loop() — never a BLE callback. +static void stream_metrics() { + // The export runs in PROVISIONING mode, which never calls start_discovery() — so LittleFS may + // be unmounted and the NVS points-history ring unloaded (0 samples). Prepare both here or + // points_history streams empty even when the OLED shows a populated graph, and the log files + // read as "(none)" on a non-Test badge. Idempotent — start_discovery already did this in + // discovery mode (re-begin just warns "Already Mounted"). + LittleFS.begin(true); + g_points_history.load(prefs); + g_ledger.load(prefs); // best-friends ledger — also only loaded in start_discovery() + uint16_t mtu = NimBLEDevice::getServer()->getPeerMTU(g_metrics_conn); + size_t chunk = (mtu > 23) ? (size_t)(mtu - 3) : 20; + if (chunk > METRICS_CHUNK_MAX) chunk = METRICS_CHUNK_MAX; + Serial.printf("[METRICS] streaming (mtu=%u chunk=%u debug=%d)\n", + (unsigned)mtu, (unsigned)chunk, (int)g_test_mode); + if (!metrics_stream_file(ENC_LOG_PATH, chunk)) { Serial.println("[METRICS] aborted (peer gone)"); return; } + if (!metrics_stream_points(chunk)) { Serial.println("[METRICS] aborted (peer gone)"); return; } + if (!metrics_stream_friends(chunk)) { Serial.println("[METRICS] aborted (peer gone)"); return; } + if (g_test_mode) { + if (!metrics_stream_file("/test_batt.csv", chunk)) return; + if (!metrics_stream_file("/test_pts.csv", chunk)) return; + } + metrics_notify_str("==== EOF ====\n", chunk); + Serial.println("[METRICS] stream complete"); +} + +void loop() { + if (mode == MODE_PROVISION) { + if (g_saved) { + display_boot("saved — rebooting to home"); + delay(800); + ESP.restart(); + } + // Pairing gate latched (PROV_CODE_MAX_TRIES bad codes). The callback only sets the + // flag; the screen redraw and the disconnect happen here so the BLE stack callback + // stays fast — same rule the metrics stream follows. + if (g_prov_lock_pending) { + g_prov_lock_pending = false; + gen_prov_code(g_prov_code); // burn the code that was being ground down + prov_redraw(); // → the lockout screen + NimBLEServer* srv = NimBLEDevice::getServer(); + if (srv) srv->disconnect(g_prov_conn); // drop the peer; reconnecting won't help + Serial.printf("[PROV] gate LOCKED after %u bad codes — power-cycle to retry\n", + (unsigned)PROV_CODE_MAX_TRIES); + } + + prov_escape_tick(); // hold-to-exit: the only on-device way out of setup mode + // Metrics export: once the client has subscribed AND entered the pairing code, stream the + // framed dump here (not in the BLE callback), then reboot to discovery a moment later so + // the badge returns to normal use without a manual power-cycle. + if (g_metrics_req && g_prov_authed && !g_metrics_done) { + g_metrics_req = false; + stream_metrics(); + g_metrics_done = true; + g_metrics_reboot_at = millis() + METRICS_REBOOT_MS; + } + if (g_metrics_done && (int32_t)(millis() - g_metrics_reboot_at) >= 0) { + display_boot("export done — rebooting"); + delay(300); + ESP.restart(); + } + delay(50); + return; + } + + // MODE_DISCOVER + static uint32_t last_beacon = 0, last_display = 0; + uint32_t now = millis(); + g_clock.tick(); + battery_sample(now); // throttled + smoothed internally + if (g_test_mode) test_batt_log_tick(now); // test mode: log VBAT on the adaptive cadence + + // Low-battery toast: fire when VBAT first crosses the LOW threshold, then re-warn + // every PET_LOWBATT_REMIND_MS while still low — persistent, since it matters. Forced + // past the reaction cooldown so an unrelated toast can't swallow the warning. + static bool prev_low = false; + static uint32_t last_lowbatt_warn = 0; + bool low_now = battery_is_low(); + if (low_now && (!prev_low || now - last_lowbatt_warn >= PET_LOWBATT_REMIND_MS)) { + last_lowbatt_warn = now; + char d[16]; + snprintf(d, sizeof(d), "%u%% - charge", (unsigned)battery_pct()); + pet_react(PET_LOWBATT, now, "low battery", d, PET_LOWBATT_POPUP_MS, true); + } + prev_low = low_now; + + // Shower reminder — an 8h UPTIME interval (the board has no RTC, so we nudge on elapsed + // uptime, not wall-clock 8am/5pm). "Global" gates all reminders, "Shower" gates this one + // (User Settings → Reminders, both default ON). Seeded to `now` so it never fires at boot, + // and skipped while a toast is already up. show_toast is non-blocking (loop keeps running). + static uint32_t last_shower_reminder = 0; + if (last_shower_reminder == 0) last_shower_reminder = now; + if (g_remind_global && g_remind_shower && !g_toast_active && + now - last_shower_reminder >= REMINDER_SHOWER_MS) { + last_shower_reminder = now; + show_toast(current_pet()->face[PET_MILESTONE], "shower time", "freshen up!"); + } + + // Disk guard — now a BACKSTOP, not the primary bound. enc_append caps the log at + // ENC_LOG_MAX_BYTES, so this should never fire on encounter growth alone; if it does, the + // pressure is coming from somewhere else (the Test-Mode CSVs) and trimming the log is a + // best-effort response. It used to be the only bound, and it could not work: at ≥90% used + // there is ~90 KB free but `keep` below asks for ~80% of the partition, so the copy ran out + // of space and — walking forward from the cut — kept the OLDEST slice, destroying the newest + // history. enc_trim_oldest now clamps `keep` to real free space and refuses to replace the + // live log on a short write, so an over-large request degrades to "less history, still the + // most recent" instead of silent inversion. + // NVS (settings/points/handle) is a separate partition — safe. + static uint32_t last_disk_check = 0, last_disk_warn = 0; + if (now - last_disk_check >= DISK_CHECK_MS) { + last_disk_check = now; + disk_refresh(); // also feeds the STORAGE view + size_t total = g_disk_total, used = g_disk_used; + if (disk_is_full()) { // ≥90% full + size_t logsz = enc_size(); + size_t nonlog = (used > logsz) ? used - logsz : 0; // test CSVs + FS overhead + size_t target = (size_t)((uint64_t)total * DISK_TARGET_NUM / DISK_TARGET_DEN); + size_t keep = (target > nonlog) ? target - nonlog : total / 20; // newest log bytes to keep (≥5% floor) + size_t freed = enc_trim_oldest(keep); + disk_refresh(); // the trim freed space — re-cache + Serial.printf("[DISK] %u/%u used (>=90%%) - dropped %u B of oldest encounters\n", + (unsigned)used, (unsigned)total, (unsigned)freed); + if (!g_toast_active && (last_disk_warn == 0 || now - last_disk_warn >= DISK_WARN_REMIND_MS)) { + last_disk_warn = now; + show_toast(current_pet()->face[PET_LOWBATT], "log almost full", "export soon"); + } + } + } + + // One button (called every loop to keep its edge state current): + // double tap → next view + // single tap → change this view's sub-setting (POINTS window · PET swap · MENU splash) + Gesture gest = button_gesture(); + // A reminder toast overlays the live view: dismiss it on ANY button press (press consumed, + // not routed) or when its deadline passes; either way repaint the view underneath. While + // it's up, the view redraws further down are suppressed so it holds the frame. + if (g_toast_active && (gest != GEST_NONE || (int32_t)(now - g_toast_until) >= 0)) { + g_toast_active = false; + gest = GEST_NONE; // swallow the dismissing press + redraw_current_view(); + } + if (gest == GEST_LONG) { + go_home(); // long-press → home from anywhere + } else if (g_view == VIEW_OPTIONS) { + // Nested OPTIONS menu — single tap = move/cycle, double tap = enter/back. + if (g_opt_screen == OPT_MENU) { // ROOT: Back · User Settings · Badge Settings + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 3; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) go_home(); // "Back" — the default row + else if (g_opt_cursor == 1) { g_opt_screen = OPT_USER; g_opt_cursor = 0; render_options(); } + else { g_opt_screen = OPT_BADGE; g_opt_cursor = 0; render_options(); } + } + } else if (g_opt_screen == OPT_USER) { // Back · Pet · Finder View · Reminders + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 4; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_MENU; g_opt_cursor = 1; render_options(); } // Back → ROOT (on "User Settings") + else if (g_opt_cursor == 1) { g_opt_screen = OPT_PET; render_options(); } + else if (g_opt_cursor == 2) { g_finder_rssi = !g_finder_rssi; // "Finder View" — inline toggle pet ↔ dBm + prefs.putUChar("finder", g_finder_rssi ? 1 : 0); render_options(); } + else { g_opt_screen = OPT_REMIND; g_opt_cursor = 0; render_options(); } // "Reminders" — submenu + } + } else if (g_opt_screen == OPT_BADGE) { // Back · Display · Stealth · Developer Options · Power + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 5; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_MENU; g_opt_cursor = 2; render_options(); } // Back → ROOT (on "Badge Settings") + else if (g_opt_cursor == 1) { g_opt_screen = OPT_DISPLAY; g_opt_cursor = 0; render_options(); } // "Display" — submenu + else if (g_opt_cursor == 2) { // "Stealth" — inline toggle: stop/resume broadcasting + g_radio_on = !g_radio_on; // TX-only gate; RX and the peer table are untouched + prefs.putUChar("radio", g_radio_on ? 1 : 0); + render_options(); + } + else if (g_opt_cursor == 3) { g_opt_screen = OPT_DEVOPTS; g_opt_cursor = 0; render_options(); } // "Developer Options" — submenu + else { g_opt_screen = OPT_POWER; g_opt_cursor = 0; render_options(); } // "Power" — submenu (was Reboot) + } + } else if (g_opt_screen == OPT_POWER) { // Back · Reboot · Deep sleep + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 3; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_BADGE; g_opt_cursor = 4; render_options(); } // Back → Badge (on "Power") + else if (g_opt_cursor == 1) { // "Reboot" — plain restart (moved here from Badge) + display_pet_popup(current_pet()->face[PET_IDLE], "Rebooting...", nullptr, 700); + esp_restart(); + } + else enter_deep_sleep(); // "Deep sleep" — never returns; button wakes + } + } else if (g_opt_screen == OPT_DISPLAY) { // Back · Splash · Brightness + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 3; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_BADGE; g_opt_cursor = 1; render_options(); } // Back → Badge (on "Display") + else if (g_opt_cursor == 1) { g_opt_screen = OPT_SPLASH; render_options(); } // "Splash" — picker + else { // "Brightness" — inline cycle LOW → MED → HIGH + g_bright = (g_bright + 1) % SCREEN_BRIGHT_LEVELS; + prefs.putUChar("bright", g_bright); + display_set_brightness(g_bright); // takes effect on this very redraw + render_options(); + } + } + } else if (g_opt_screen == OPT_DEVOPTS) { // Back · Whoami · Debug · Provisioning · Factory Reset + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 5; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_BADGE; g_opt_cursor = 3; render_options(); } // Back → Badge (on "Developer Options") + else if (g_opt_cursor == 1) { g_opt_screen = OPT_WHOAMI; render_options(); } // "Whoami" — identity screen + else if (g_opt_cursor == 2) { // "Debug" — toggle diagnostics + g_test_mode = !g_test_mode; + prefs.putUChar("testmode", g_test_mode ? 1 : 0); + if (g_test_mode) { // enabling → fresh capture + new boot id + g_boot_id = prefs.getUInt("bootid", 0) + 1; + prefs.putUInt("bootid", g_boot_id); + g_batt_log.reset(); + g_pts_log.reset(); + } + render_options(); + } else if (g_opt_cursor == 3) { // "Provisioning Mode" — reboot into BLE setup + prefs.putUChar("provreq", 1); // one-shot: setup() enters provisioning + display_pet_popup(current_pet()->face[PET_CONTACT], "Provisioning mode", nullptr, 1200); + esp_restart(); // BLE comes up → hosted page sets handle/clock/pet + } else { // "Factory Reset" — confirm first, never inline + g_opt_screen = OPT_RESET; g_opt_cursor = 0; // land on Cancel + render_options(); + } + } + } else if (g_opt_screen == OPT_RESET) { // Cancel · ERASE (destructive; confirm gate) + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 2; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_DEVOPTS; g_opt_cursor = 4; render_options(); } // Cancel + else { factory_reset(); } // does not return — restarts + } + } else if (g_opt_screen == OPT_WHOAMI) { // read-only identity screen + if (gest == GEST_DOUBLE) { g_opt_screen = OPT_DEVOPTS; g_opt_cursor = 1; render_options(); } // back → Developer Options (on Whoami) + } else if (g_opt_screen == OPT_PET) { + if (gest == GEST_SINGLE) { + g_pet_idx = (g_pet_idx + 1) % pet_total(); + prefs.putUInt("pet", g_pet_idx); + render_pet(); + } else if (gest == GEST_DOUBLE) { g_opt_screen = OPT_USER; g_opt_cursor = 1; render_options(); } // back → User Settings (on "Pet") + } else if (g_opt_screen == OPT_REMIND) { // Back · Global · Shower (toggles; default ON) + if (gest == GEST_SINGLE) { + g_opt_cursor = (g_opt_cursor + 1) % 3; + render_options(); + } else if (gest == GEST_DOUBLE) { + if (g_opt_cursor == 0) { g_opt_screen = OPT_USER; g_opt_cursor = 3; render_options(); } // Back → User (on "Reminders") + else if (g_opt_cursor == 1) { g_remind_global = !g_remind_global; // master switch + prefs.putUChar("rmglob", g_remind_global ? 1 : 0); render_options(); } + else { g_remind_shower = !g_remind_shower; // shower reminder + prefs.putUChar("rmshow", g_remind_shower ? 1 : 0); render_options(); } + } + } else { // OPT_SPLASH + if (gest == GEST_SINGLE) { + g_splash_style = (g_splash_style + 1) % BOOT_SPLASH_COUNT; + prefs.putUInt("splash", g_splash_style); + render_menu(); + } else if (gest == GEST_DOUBLE) { g_opt_screen = OPT_DISPLAY; g_opt_cursor = 1; render_options(); } // back → Display (on "Splash") + } + } else if (gest == GEST_DOUBLE) { + g_view = (View)((g_view + 1) % VIEW_COUNT); + switch (g_view) { + case VIEW_HOME: render_home(); break; // also redraws live below + case VIEW_FINDER: g_finder_page = 0; render_finder(); break; // fresh entry → page 1; live below + case VIEW_REPORT: enter_report(); break; // static snapshot, drawn once + case VIEW_POINTS: render_points(); break; // also redraws live below + case VIEW_FRIENDS: render_friends(); break; // also redraws live below + case VIEW_BATTERY: g_batt_storage = false; render_battery(); break; // fresh entry → battery face; live below + case VIEW_OPTIONS: g_opt_screen = OPT_MENU; g_opt_cursor = 0; render_options(); break; + default: display_boot("resuming"); break; + } + } else if (gest == GEST_SINGLE) { + if (g_view == VIEW_POINTS) { // POINTS: cycle the graph window + g_points_window = (PointsWin)((g_points_window + 1) % PWIN_COUNT); + render_points(); + } else if (g_view == VIEW_FINDER) { // FINDER: page down the badge list (wraps) + g_finder_page = (g_finder_page + 1) % g_finder_pages; + render_finder(); + } else if (g_view == VIEW_BATTERY) { // BATTERY: flip to the storage readout + g_batt_storage = !g_batt_storage; + if (g_batt_storage) disk_refresh(); // fresh on arrival — the guard only polls every 60 s + render_battery(); + } + } + + // Broadcast. Gated by the Stealth switch: while stealthed we simply never transmit, so + // badge is invisible to everyone else while still hearing them. g_counter therefore + // stops advancing too, which is fine — it only has to be monotonic, and receivers + // compare it per-sender, so it just resumes where it left off when the radio comes back. + if (g_radio_on && now - last_beacon >= BEACON_INTERVAL_MS) { + last_beacon = now; + send_beacon(); + } + + // Low-battery glyph blink: the home view needs a faster redraw than the 3 s + // view refresh to animate it. Cheap (re-renders home only), and only while low. + static uint32_t last_lowblink = 0; + if (!g_toast_active && g_view == VIEW_HOME && battery_is_low() && now - last_lowblink >= BATT_LOW_BLINK_MS / 2) { + last_lowblink = now; + render_home(); + } + + // Expire + log departures, accrue points, and redraw the live view (FINDER + // or POINTS). REPORT is a static snapshot, so we leave its screen untouched. + if (now - last_display >= DISPLAY_REFRESH_MS) { + last_display = now; + static Encounter ev[MAX_PEERS]; // static: this block also nests into enc_append→ + int en = 0; // vfprintf on departures; keep ev (~1.1KB) off the + Peer snap[MAX_PEERS]; // loopTask stack (same canary risk as enter_report) + int n; + portENTER_CRITICAL(&peerMux); + peers.expire(now, ev, en, MAX_PEERS); + n = peers.snapshot(snap, MAX_PEERS); + portEXIT_CRITICAL(&peerMux); + for (int i = 0; i < en; i++) enc_append(ev[i]); // file IO outside the lock + + // Points: add the elapsed tick to the proximity tally whenever crew are + // both in range AND close enough to clear the RSSI gate. Seeded to `now` on + // the first pass so we never count boot time. Whole seconds earned this tick + // are scaled by the group multiplier (×2 crew around / ×3 group of 5+) and + // credited to EVERY in-gate companion for the best-friends view — the same + // scaled amount, so friend minutes stay ≤ the total and the views agree. + bool earning = points_earning(snap, n); + uint8_t mult = points_multiplier(n); + static uint32_t last_points_ms = 0; + if (last_points_ms == 0) last_points_ms = now; + uint32_t dt = now - last_points_ms; + last_points_ms = now; + if (earning) { + g_prox_accum_ms += dt; + uint32_t whole = g_prox_accum_ms / 1000; + g_prox_accum_ms %= 1000; + if (whole) { + g_prox_seconds += whole * mult; // each wall second counts ×mult + // Credit the minute to EVERY in-gate friend (snapshot is sorted + // closest-first, so stop at the first one below the gate). + for (int i = 0; i < n && snap[i].rssi >= POINTS_MIN_RSSI; i++) + g_ledger.credit(snap[i].handle, whole * mult); + } + } + + // Group-bonus activation pop-up: when the multiplier level RISES (crew + // gathered), celebrate it like an achievement — the milestone face with an + // "xN bonus!" label. Falling levels change the header chip silently. The + // pet_react cooldown + the 15s peer TTL keep a flapping 5th badge from + // spamming the screen. + static uint8_t prev_mult = 1; + if (mult > prev_mult) { + char mb[16]; + snprintf(mb, sizeof(mb), "x%u score", (unsigned)mult); + pet_react(PET_MILESTONE, now, "group bonus", mb); + } + prev_mult = mult; + + // Checkpoint the tally to NVS at most once per interval, and only when it + // actually moved (no needless flash writes while you're off alone). + static uint32_t last_persist_ms = 0; + static uint32_t last_persisted_s = 0xFFFFFFFF; + if (now - last_persist_ms >= POINTS_PERSIST_MS && g_prox_seconds != last_persisted_s) { + last_persist_ms = now; + last_persisted_s = g_prox_seconds; + prefs.putUInt("prox", g_prox_seconds); + g_ledger.save(prefs); // same cadence/guard as the tally + } + + // Sample the cumulative score (with wall-clock epoch) for the POINTS graph, + // self-throttled to the hourly cadence. Persist only when a new sample lands + // (≈hourly) so the graph survives reboots without churning flash. + if (g_points_history.sample((uint16_t)(g_prox_seconds / 60), g_clock.now(), now)) + g_points_history.save(prefs); + g_points_recent.sample((uint16_t)(g_prox_seconds / 60), g_clock.now(), now); // 1h ring (in-RAM) + if (g_test_mode) test_pts_log_tick(now, n, mult); // test mode: snapshot the live counters + + // Pet reactions: a never-before-seen device MAC → CONTACT (fires once per + // device per session, flagged by the recv callback); crossing a + // PET_MILESTONE_STEP points boundary → MILESTONE. Off the PET view, pet_react + // pops the reaction up over the current view (cooldown-limited). Clear the + // flag before reacting so a contact arriving mid-reaction isn't lost. + if (g_new_contact) { g_new_contact = false; pet_react(PET_CONTACT, now, "new crew", g_contact_handle); } + static uint32_t prev_milestone_pts = 0; + uint32_t pts_now = g_prox_seconds / 60; + if (pts_now > 0 && pts_now / PET_MILESTONE_STEP > prev_milestone_pts / PET_MILESTONE_STEP) { + char ms[16]; // the milestone value = the detail line + snprintf(ms, sizeof(ms), "%u pts", + (unsigned)((pts_now / PET_MILESTONE_STEP) * PET_MILESTONE_STEP)); + pet_react(PET_MILESTONE, now, "milestone", ms); + } + prev_milestone_pts = pts_now; + + if (g_toast_active) { /* a reminder toast owns the screen — hold its frame */ } + else if (g_view == VIEW_HOME) render_home(); // live clock/points/battery + else if (g_view == VIEW_FINDER) { // live: keep the page clamped as peers move + g_finder_pages = finder_pages(n); + if (g_finder_page >= g_finder_pages) g_finder_page = g_finder_pages - 1; + display_peers(g_handle, snap, n, g_finder_rssi, mult, g_finder_page); + } + else if (g_view == VIEW_POINTS) render_points(); // passes the history graph + else if (g_view == VIEW_FRIENDS) render_friends(); + else if (g_view == VIEW_BATTERY) render_battery(); + else if (g_view == VIEW_OPTIONS && g_opt_screen == OPT_PET) render_pet(); // live pet reactions + } + + delay(10); +} diff --git a/firmware/src/peers.h b/firmware/src/peers.h @@ -0,0 +1,119 @@ +#pragma once +#include <Arduino.h> +#include <string.h> +#include "config.h" +#include "pet.h" // PET_FACE_MAX — peers carry the sender's pet face +#include "encounters.h" // Encounter struct (emitted when a peer leaves range) + +// One discovered friend, with the running "encounter" segment we're building. +struct Peer { + char handle[HANDLE_MAX_LEN + 1]; // +1 for NUL so it's printable + int8_t rssi; // last seen signal strength (dBm) + uint32_t last_seen; // millis() of last valid beacon (TTL) + uint32_t counter; // last beacon counter (freshness) + uint32_t first_epoch; // wall-clock start of this segment + uint32_t last_epoch; // wall-clock of last sighting + uint16_t sightings; // beacons heard this segment + int8_t rssi_max; // closest approach this segment + uint8_t pet; // sender's pet index (==PET_BUILTIN_N → custom) + char pet_face[PET_FACE_MAX + 1]; // sender's pet idle face (+1 NUL for printable) +}; + +// Bounded, flood-resistant table of nearby badges. Not internally locked — +// the caller (main.cpp) guards access with a portMUX, since the ESP-NOW +// receive callback and the main loop both touch it. +class PeerTable { +public: + // Insert or refresh a peer. Evicts the stalest entry if full. + void upsert(const char* handle, uint8_t hlen, int8_t rssi, + uint32_t counter, uint32_t now_ms, uint32_t now_epoch, + uint8_t pet, const char* pet_face) { + int idx = find(handle, hlen); + if (idx < 0) { // new segment begins + idx = (_n < MAX_PEERS) ? _n++ : stalest(); + memset(&_peers[idx], 0, sizeof(Peer)); + memcpy(_peers[idx].handle, handle, hlen); + _peers[idx].handle[hlen] = '\0'; + _peers[idx].first_epoch = now_epoch; + _peers[idx].rssi_max = rssi; + } + Peer& p = _peers[idx]; + p.rssi = rssi; + p.last_seen = now_ms; + p.counter = counter; + p.last_epoch = now_epoch; + p.sightings++; + if (rssi > p.rssi_max) p.rssi_max = rssi; + p.pet = pet; // refresh pet each beacon (swaps live) + memcpy(p.pet_face, pet_face, PET_FACE_MAX); + p.pet_face[PET_FACE_MAX] = '\0'; + } + + // Drop peers idle > PEER_TTL_MS, emitting one Encounter per departure. + void expire(uint32_t now_ms, Encounter* out, int& out_n, int max) { + out_n = 0; + for (int i = 0; i < _n; ) { + if (now_ms - _peers[i].last_seen > PEER_TTL_MS) { + if (out && out_n < max) to_encounter(_peers[i], out[out_n++]); + _peers[i] = _peers[--_n]; // swap-remove + } else { + i++; + } + } + } + + // Finalize all in-range peers as encounters (used when entering REPORT), then + // start a fresh segment for each so they aren't double-counted on expiry. + void flush(Encounter* out, int& out_n, int max) { + out_n = 0; + for (int i = 0; i < _n; i++) { + if (out && out_n < max) to_encounter(_peers[i], out[out_n++]); + _peers[i].first_epoch = _peers[i].last_epoch; + _peers[i].sightings = 1; + _peers[i].rssi_max = _peers[i].rssi; + } + } + + // Copy current peers into `out`, sorted by RSSI (closest first). + int snapshot(Peer* out, int max) { + int n = (_n < max) ? _n : max; + for (int i = 0; i < n; i++) out[i] = _peers[i]; + for (int i = 1; i < n; i++) { // insertion sort, n is tiny + Peer key = out[i]; + int j = i - 1; + while (j >= 0 && out[j].rssi < key.rssi) { out[j + 1] = out[j]; j--; } + out[j + 1] = key; + } + return n; + } + + int count() const { return _n; } + +private: + Peer _peers[MAX_PEERS]; + int _n = 0; + + static void to_encounter(const Peer& p, Encounter& e) { + strncpy(e.handle, p.handle, HANDLE_MAX_LEN); + e.handle[HANDLE_MAX_LEN] = '\0'; + e.first_epoch = p.first_epoch; + e.last_epoch = p.last_epoch; + e.sightings = p.sightings; + e.rssi_max = p.rssi_max; + } + + int find(const char* handle, uint8_t hlen) { + for (int i = 0; i < _n; i++) + if (strlen(_peers[i].handle) == hlen && + memcmp(_peers[i].handle, handle, hlen) == 0) + return i; + return -1; + } + + int stalest() { + int idx = 0; + for (int i = 1; i < _n; i++) + if (_peers[i].last_seen < _peers[idx].last_seen) idx = i; + return idx; + } +}; diff --git a/firmware/src/pet.h b/firmware/src/pet.h @@ -0,0 +1,52 @@ +#pragma once +#include <stdint.h> + +// The badge companion. A "pet" is a name + a face per state (short ASCII faces, +// e.g. "(^_^)"). The active one is chosen on the PET view (double-tap to swap); +// 3 ship built-in, with a 4th "custom" slot uploadable via the provisioning page +// (see docs/provisioning.md). +// +// ─── NAMING: the user-facing word is "AVATAR"; the code says "pet" ─────────── +// Every string a recipient reads — the OLED's "Avatar:" row, the setup app, the +// FAQ at badge.virtualshack.io — says *avatar*. Identifiers here (pet.h, PET_*, +// g_pet_idx, OptView::pet, PET_UUID) deliberately still say *pet*. That split is +// intentional, not leftover: renaming the identifiers buys a recipient nothing, +// and one class of them CANNOT be renamed safely at all — +// +// the NVS keys "pet", "petcust", "petc_ok" (main.cpp). +// +// Those are persisted on every badge already in the field. Changing a key does +// not migrate the value, it orphans it: every custom avatar uploaded before the +// change silently reverts to the built-in default, and the owner's selected slot +// resets. If you rename anything here, leave the NVS keys and the BLE UUID alone. + +enum PetState { + PET_IDLE = 0, // resting + PET_CONTACT, // a new crew badge just came into range + PET_MILESTONE, // crossed a points milestone + PET_LONELY, // no crew in range + PET_LOWBATT, // battery low + PET_STATE_COUNT +}; + +#define PET_NAME_MAX 12 +#define PET_FACE_MAX 16 +#define PET_BUILTIN_N 3 + +struct Pet { + char name[PET_NAME_MAX]; + char face[PET_STATE_COUNT][PET_FACE_MAX]; // indexed by PetState +}; + +// Built-in pets. Face order matches PetState: idle, contact, milestone, lonely, lowbatt. +static const Pet PET_BUILTINS[PET_BUILTIN_N] = { + { "ghost", { "(^_^)", "(o_o)!", "(^o^)", "(-_-)z", "(>_<)" } }, + { "bot", { "[o_o]", "[O_O]!", "[^o^]", "[-_-]", "[x_x]" } }, + { "cat", { "(=^.^=)", "(=o.o=)", "(=^o^=)", "(=-.-=)", "(=T.T=)" } }, +}; + +// Active pet by index (clamped). Custom slot (index PET_BUILTIN_N) lands in Phase 3. +static inline const Pet* pet_get(int idx) { + if (idx < 0 || idx >= PET_BUILTIN_N) idx = 0; + return &PET_BUILTINS[idx]; +} diff --git a/firmware/src/points.h b/firmware/src/points.h @@ -0,0 +1,95 @@ +#pragma once +#include <Arduino.h> +#include <Preferences.h> +#include <string.h> +#include "config.h" + +// ───────────────────────────────────────────────────────────────────────── +// Points ledger — per-friend attribution of your proximity score. +// +// The global points tally (main.cpp) counts one point per minute spent with +// crew close enough to clear the RSSI gate. This ledger records WHO you shared +// those minutes with: each earning tick is credited to EVERY in-gate friend. +// So a friend's seconds = how many of your social minutes they were part of +// (each ≤ the tally, but they overlap — a minute spent in a group of three +// credits all three). "Best Friends" ranks the top sharers, most first. +// +// Bounded (crew is small) and main-loop-only — no locking needed, unlike the +// peer table. Persisted to NVS as a blob so a reboot mid-con keeps the ranking. +// ───────────────────────────────────────────────────────────────────────── + +#ifndef MAX_POINT_FRIENDS +#define MAX_POINT_FRIENDS 24 // > expected crew size; evicts the smallest +#endif + +struct PointFriend { + char handle[HANDLE_MAX_LEN + 1]; + uint32_t seconds; // gated proximity time credited to this friend +}; + +class PointsLedger { +public: + // Credit `secs` of proximity to `handle` (the closest crew this tick). + void credit(const char* handle, uint32_t secs) { + if (!secs || !handle || !handle[0]) return; + int idx = find(handle); + if (idx < 0) { + idx = (_n < MAX_POINT_FRIENDS) ? _n++ : smallest(); + memset(&_f[idx], 0, sizeof(PointFriend)); + strncpy(_f[idx].handle, handle, HANDLE_MAX_LEN); + _f[idx].handle[HANDLE_MAX_LEN] = '\0'; + } + _f[idx].seconds += secs; + } + + uint32_t total() const { + uint32_t s = 0; + for (int i = 0; i < _n; i++) s += _f[i].seconds; + return s; + } + + // Copy entries into `out` sorted by contribution (most first); returns the + // number copied (≤ max). `out` must hold up to MAX_POINT_FRIENDS. + int top(PointFriend* out, int max) const { + for (int i = 0; i < _n; i++) out[i] = _f[i]; + for (int i = 1; i < _n; i++) { // insertion sort, n is tiny + PointFriend key = out[i]; + int j = i - 1; + while (j >= 0 && out[j].seconds < key.seconds) { out[j + 1] = out[j]; j--; } + out[j + 1] = key; + } + return (_n < max) ? _n : max; + } + + void save(Preferences& p) const { + p.putUInt("pts_n", (uint32_t)_n); + if (_n > 0) p.putBytes("pts", _f, (size_t)_n * sizeof(PointFriend)); + } + + void load(Preferences& p) { + memset(_f, 0, sizeof(_f)); + uint32_t n = p.getUInt("pts_n", 0); + if (n == 0 || n > MAX_POINT_FRIENDS) { _n = 0; return; } + size_t want = (size_t)n * sizeof(PointFriend); + size_t got = p.getBytes("pts", _f, want); + _n = (got == want) ? (int)n : 0; // partial/missing blob → start clean + for (int i = 0; i < _n; i++) _f[i].handle[HANDLE_MAX_LEN] = '\0'; + } + +private: + PointFriend _f[MAX_POINT_FRIENDS]; + int _n = 0; + + int find(const char* handle) const { + for (int i = 0; i < _n; i++) + if (strncmp(_f[i].handle, handle, HANDLE_MAX_LEN) == 0) return i; + return -1; + } + + int smallest() const { + int idx = 0; + for (int i = 1; i < _n; i++) + if (_f[i].seconds < _f[idx].seconds) idx = i; + return idx; + } +}; diff --git a/firmware/src/points_history.h b/firmware/src/points_history.h @@ -0,0 +1,105 @@ +#pragma once +#include <stdint.h> +#include <Preferences.h> +#include "config.h" + +// ───────────────────────────────────────────────────────────────────────── +// Points history — timestamped samples of the cumulative points score, for +// the line graph on the POINTS view (x = wall-clock time over a fixed 5-day +// window, y = points). +// +// Each sample carries its wall-clock epoch (from the soft-clock) so points can +// be placed at their true time on the event axis. The buffer is persisted to +// NVS and restored on boot, so the graph survives reboots. Because the board +// has no RTC (clock.h doesn't count power-off time), the first sample after a +// reboot is flagged `seg_start` — the renderer breaks the line there to mark +// "rebooted here" (we can show the break, but not the true off-duration). If +// the clock was never synced, samples carry epoch 0 and the renderer falls +// back to uniform spacing. +// +// Sampled at most once per POINTS_HISTORY_SAMPLE_MS (hourly), so LEN samples +// span the 5-day axis. +// ───────────────────────────────────────────────────────────────────────── + +struct PointSample { + uint32_t epoch; // wall-clock seconds (0 = clock unsynced when sampled) + uint16_t points; // cumulative score at that time + uint8_t seg_start; // 1 if a reboot/discontinuity precedes this sample +}; + +class PointsHistory { + public: + // Append the current score at most once per sample interval. `epoch` is + // g_clock.now() (0 if unsynced); `now_ms` is millis(). Returns true iff a new + // sample was actually stored (so the caller can persist only when it changed). + bool sample(uint16_t points, uint32_t epoch, uint32_t now_ms) { + if (_last_ms != 0 && now_ms - _last_ms < POINTS_HISTORY_SAMPLE_MS) return false; + _last_ms = now_ms; + _buf[_head] = { epoch, points, (uint8_t)(_pending_break ? 1 : 0) }; + _pending_break = false; + _head = (_head + 1) % POINTS_HISTORY_LEN; + if (_count < POINTS_HISTORY_LEN) _count++; + return true; + } + + // Copy samples oldest→newest into `out` (capacity `max`); returns the count. + int snapshot(PointSample* out, int max) const { + if (max <= 0) return 0; + int n = _count, skip = 0; + if (n > max) { skip = n - max; n = max; } + int start = (_head - _count + skip + POINTS_HISTORY_LEN) % POINTS_HISTORY_LEN; + for (int i = 0; i < n; i++) + out[i] = _buf[(start + i) % POINTS_HISTORY_LEN]; + return n; + } + + // Persist / restore the whole ring to NVS (mirrors PointsLedger). Restoring a + // non-empty buffer arms a break before the next sample — that's the reboot. + void save(Preferences& p) const { + p.putBytes("phist", _buf, sizeof(_buf)); + p.putUInt("phist_h", (uint32_t)_head); + p.putUInt("phist_c", (uint32_t)_count); + } + void load(Preferences& p) { + if (p.getBytes("phist", _buf, sizeof(_buf)) != sizeof(_buf)) return; + _head = (int)p.getUInt("phist_h", 0) % POINTS_HISTORY_LEN; + _count = (int)p.getUInt("phist_c", 0); + if (_count > POINTS_HISTORY_LEN) _count = POINTS_HISTORY_LEN; + _pending_break = (_count > 0); // a reboot just happened → break the line + } + + private: + PointSample _buf[POINTS_HISTORY_LEN] = {}; + int _head = 0; // next write index + int _count = 0; // valid samples (≤ LEN) + uint32_t _last_ms = 0; // last accepted sample time (millis) + bool _pending_break = false; +}; + +// Fine-grained recent ring for the "last 1 hour" POINTS view. 1-minute cadence, +// in-RAM only (volatile recent detail — resets on reboot, never persisted, no +// reboot-break bookkeeping). POINTS_RECENT_LEN × POINTS_RECENT_SAMPLE_MS = 1 h. +class PointsRecent { + public: + bool sample(uint16_t points, uint32_t epoch, uint32_t now_ms) { + if (_last_ms != 0 && now_ms - _last_ms < POINTS_RECENT_SAMPLE_MS) return false; + _last_ms = now_ms; + _buf[_head] = { epoch, points, 0 }; + _head = (_head + 1) % POINTS_RECENT_LEN; + if (_count < POINTS_RECENT_LEN) _count++; + return true; + } + int snapshot(PointSample* out, int max) const { + if (max <= 0) return 0; + int n = _count, skip = 0; + if (n > max) { skip = n - max; n = max; } + int start = (_head - _count + skip + POINTS_RECENT_LEN) % POINTS_RECENT_LEN; + for (int i = 0; i < n; i++) out[i] = _buf[(start + i) % POINTS_RECENT_LEN]; + return n; + } + private: + PointSample _buf[POINTS_RECENT_LEN] = {}; + int _head = 0; + int _count = 0; + uint32_t _last_ms = 0; +}; diff --git a/firmware/src/secrets.h.example b/firmware/src/secrets.h.example @@ -0,0 +1,32 @@ +#pragma once +// ───────────────────────────────────────────────────────────────────────── +// secrets.h — the crew GROUP SECRET (HMAC key for beacon auth) +// +// THIS FILE (secrets.h.example) IS A TEMPLATE, TRACKED IN GIT. It holds only +// the public throwaway placeholder — it is NOT a real key. +// +// To set the real crew key for a fleet flash, on the flashing machine: +// 1. cp firmware/src/secrets.h.example firmware/src/secrets.h +// (secrets.h is gitignored — it must never be committed) +// 2. openssl rand -hex 32 +// 3. paste those 32 bytes into GROUP_PSK below, in secrets.h +// 4. DELETE the GROUP_PSK_IS_PLACEHOLDER line at the bottom +// 5. store the same key in your password manager — it is unrecoverable, +// and every crew badge must flash the IDENTICAL key +// +// Then build the fleet with env oled_v3_prod, which REFUSES to compile unless +// a real secrets.h (placeholder line removed) is present. See config.h. +// ───────────────────────────────────────────────────────────────────────── +#include <stdint.h> +#include <stddef.h> + +// REPLACE these 32 bytes with the output of `openssl rand -hex 32`. +static const uint8_t GROUP_PSK[] = { + 0xde,0xad,0xbe,0xef, 0x00,0x11,0x22,0x33, 0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb, + 0xcc,0xdd,0xee,0xff, 0x13,0x37,0xc0,0xde, 0xfe,0xed,0xfa,0xce, 0xba,0xdc,0x0f,0xee +}; +static const size_t GROUP_PSK_LEN = sizeof(GROUP_PSK); + +#define GROUP_PSK_SET 1 // tells config.h a key was provided here + +#define GROUP_PSK_IS_PLACEHOLDER 1 // ← DELETE this line once you set a real key above diff --git a/firmware/src/test_log.h b/firmware/src/test_log.h @@ -0,0 +1,48 @@ +#pragma once +// Test-mode CSV logger — a minimal append-only log over LittleFS, used by TEST +// MODE (toggled in the OPTIONS menu) to capture diagnostics for later analysis. +// Each instance owns one file: it appends one preformatted CSV line per sample +// (flushed immediately by closing, so a brownout can't eat the tail), writes its +// header lazily on the first line, caps the file by byte size (a safety valve so +// a forgotten test badge can't fill flash), and can dump itself to a Stream. The +// sampling CADENCE lives in the caller — this is just persistence + retrieval. +// Adding a new metric = a new TestLog instance; that's the whole "room for more". +#include <Arduino.h> +#include <LittleFS.h> +#include "config.h" + +#ifndef TEST_LOG_MAX_BYTES +#define TEST_LOG_MAX_BYTES 65536 +#endif + +class TestLog { +public: + TestLog(const char* path, const char* header) : _path(path), _header(header) {} + + // Append a CSV line (no trailing newline needed). Lazily writes the header to a + // new/empty file; stops silently once the byte cap is reached. + void append(const char* line) { + File f = LittleFS.open(_path, "a"); + if (!f) return; + if (f.size() == 0) f.println(_header); + if (f.size() < TEST_LOG_MAX_BYTES) f.println(line); + f.close(); + } + + // Dump the whole file to `out` — the retrieval path: plug in USB, read serial. + void dump(Stream& out) const { + out.print("==== "); out.print(_path); out.println(" ===="); + File f = LittleFS.open(_path, "r"); + if (!f) { out.println("(none)"); return; } + while (f.available()) out.write(f.read()); + f.close(); + out.println("==== end ===="); + } + + // Start a fresh capture (called when test mode is enabled). + void reset() { LittleFS.remove(_path); } + +private: + const char* _path; + const char* _header; +}; diff --git a/hardware/BOM.md b/hardware/BOM.md @@ -0,0 +1,14 @@ +# Bill of materials — one badge + +| # | Part | Qty | Spec | +|---|---|---|---| +| 1 | DFRobot FireBeetle 2 ESP32-E (DFR0654) | 1 | ESP32-D0WD-V3, 4 MB flash, USB-C, onboard LiPo charger + JST-PH 2.0 | +| 2 | 2.42" OLED module | 1 | SSD1309, 128×64, I2C (address `0x3C`) | +| 3 | EEMB 103454 LiPo cell | 1 | 3.7 V, 2000 mAh, JST-PH 2.0 lead — check polarity against the board's B+/B− before plugging in | +| 4 | Twidec PBS-110 push button | 1 | 7 mm, momentary | +| 5 | Case — `hardware/cad/stl/badge_*_B_back.stl` + `badge_*_B_front.stl` | 1 set | PETG | +| 6 | Screw, M3 self-tapping | 4 | into the case's 2.5 mm pilot bosses | +| 7 | Hookup wire | — | OLED (4 wires) + button (2 wires) | +| 8 | Lanyard | 1 | optional; breakaway | + +Accessories (optional, clip into the case openings): `hardware/cad/stl/accessories/`. diff --git a/hardware/cad/README.md b/hardware/cad/README.md @@ -0,0 +1,24 @@ +# Case + +The production case is **layout B**: a walled tray (`*_B_back.stl`) and a lid with the +display window (`*_B_front.stl`); `*_B_front_text.stl` is the lid with the raised +lettering. Parts for one badge are in [`../BOM.md`](../BOM.md). + +## Print + +- **Material:** PETG (holds its shape in a hot car; PLA softens around 60 °C). +- **Tray:** open side up, floor on the bed — support-free. +- **Lid:** flat side down. +- 0.2 mm layers, 15–20 % infill. + +## Regenerate + +```sh +python3 gen_stl.py +``` + +No dependencies — pure Python writing binary STL. Every dimension is in the `PARAMS` block +at the top of `gen_stl.py`; change a number and re-run. The accessories each have their own +generator: `gen_cat_ears.py`, `gen_tophat.py`, `gen_caped_crusader.py`. + +License: [CC BY-SA 4.0](../../LICENSE-CC-BY-SA-4.0.txt) for the models, MIT for the scripts. diff --git a/hardware/cad/gen_caped_crusader.py b/hardware/cad/gen_caped_crusader.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +r""" +gen_caped_crusader.py — slide-on CAPED-CRUSADER frame for the badge case (accessory). + +A fork of gen_cat_ears.py: the SAME proven retention chassis (a rectangular band that +rides the badge's perimeter walls, back-loaded, tied by an inward LEDGE ring behind the +back face), but the top rail carries a bat COWL (two sharp, close-set ears flanking the +lanyard-tab slot) and the two side edges bloom into bold, scalloped cape WINGS that sweep +OUTBOARD of the badge — so the OLED / buttons / vents on the front stay clear. + + OUTLINE (looking at the badge face-on) + + /\ /\ sharp close-set bat ears, either side of the + / \ / \ tab slot (the tab pokes up between them) + ____/ \____/ \____ + / ┌──────────────┐ \ wings sweep out past the badge outline + / ╲ │ │ ╱ \ + / ╲ ╲ │ badge W x H │ ╱ ╱ \ (badge face — kept clear) + \ ╲╲ │ │ ╱╱ / + \ ╲ └──────────────┘ ╱ / + \ ╱╲ ╱╲ / scalloped cape hem, outboard + \╱ ╲________________╱ ╲╱ + +WHY THE FRAME MOUNT (not the tophat's tab-drop): the cape + cowl carry real mass and want +a robust anchor. The band grips the whole perimeter and bottoms out on the ledge — plenty +— and because the lanyard tab threads the top-rail SLOT (exactly as the cat-ear frame), the +tab's side bore is untouched: cord routing is unchanged, no crown-slot compromise. + +COWL, NOT A BROW BRIDGE: the tab is full-depth, so the band stays a C (open at the slot) — +the two ears can't be joined by a brow across the band without fouling the tab. They're +tied instead by the LEDGE ring behind (unbroken across the top), same trick that keeps the +cat ears out of cantilever. The cowl read comes from the ears being sharp, tall and +close-set at the slot; a connecting front brow relief is a v0.2 refinement. + +WINGS ARE OUTBOARD AND CHUNKY: each wing replaces the straight side edge with a convex +top sweep out to a wingtip, then a scalloped hem back down to the bottom corner. They're +extruded at band depth (bold, cartoon-superhero — not thin membrane, per the brief), solid +out to the tip, so the near-perimeter part still grips the badge and the rest cantilevers +from a thick, stiff root. + +Badge/tab dimensions are IMPORTED from gen_stl; the pure mesh helpers (arc/fillet/cap/wall/ +extrude) are IMPORTED from gen_cat_ears so the watertight-concave-profile fixes are shared. + +Print FLAT, ledge-side down — the ledge is the widest section and sits on the bed, so every +transition is an upward step: no supports. PETG. + +Out: ./stl/accessories/caped_crusader_<ver>.stl +""" + +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gen_stl import ( # noqa: E402 + P, plate_outline, cavity_depth, + ensure_ccw, rounded_rect, write_stl, bbox, edge_manifold_issues, + mesh_volume_cc, signed_area, _DENSITY_G_CC, git_rev, +) +from gen_cat_ears import ( # noqa: E402 (shared pure helpers) + arc, fillet, _dedup, cap, wall, extrude, +) + +# ---------------------------------------------------------------------------- +# PARAMS — the accessory's own numbers. Everything about the BADGE is imported. +# ---------------------------------------------------------------------------- +VERSION = "v0.1" +LAYOUT = "B" + +TOL = 0.25 # per-side slide clearance, frame ↔ badge wall +RAIL = 3.0 # frame border width (the grip, all four sides) +BAND_Z_WANT = 8.0 # how far the band WANTS to ride down the badge's sides +USB_CLEAR = 2.5 # keep the band's top edge this far below the USB slot +LEDGE_W = 2.5 # how far the back lip turns inward over the badge back face +LEDGE_Z = 1.2 # lip thickness +SLOT_TOL = 0.4 # per-side clearance around the lanyard tab in the top rail + +# --- the bat cowl (two sharp ears flanking the tab slot) ---------------------- +EAR_BASE = 16.0 # ear base width (fatter) +EAR_OUT = 5.0 # gap from the slot corner to the ear's inner base — spreads the + # ears outboard (left/right) so they flank the tab more widely +EAR_H = 30.0 # ear height above the frame's outer top edge (tall, iconic) +EAR_TILT = 3.0 # apex offset OUTBOARD of the base centre (the ear lean) +EAR_TIP_R = 0.7 # tip rounding (small → sharp) +TIP_SEG = 6 + +# --- the cape (drapes below + around the badge, scalloped hem) ----------------- +WING_SPAN = 22.0 # how far the wingtip reaches beyond the frame side edge +WING_TIP_UP = 9.0 # wingtip height above the badge bottom (a low, wide sweep) +WING_LEAD_BULGE = 3.0 # convex bulge of the wing leading edge (shoulder → wingtip) +WING_LEAD_SEG = 12 +CAPE_DROP = 16.0 # how far the cape hem hangs below the badge bottom (centre point) +SCALLOP_SAG = 3.5 # hem scoop depth (concave, toward the badge) +SCALLOP_SEG = 9 + +SEG = P["corner_seg"] +MATERIAL = P["material"] +INFILL = P["infill"] + +# ---------------------------------------------------------------------------- +# DERIVED — badge geometry, straight from gen_stl. +# ---------------------------------------------------------------------------- +W, H = plate_outline(LAYOUT) +BADGE_D = 2 * P["plate_t"] + cavity_depth(LAYOUT) +BADGE_R = P["corner_r"] +CENTROID = (W / 2.0, H / 2.0) + +USB_SLOT_Z0 = (P["plate_t"] + P["fb_standoff_h"] + P["usb_slot_top_margin"] + + P["usb_slot_z_offset"] - P["usb_slot_h"]) +BAND_Z = min(BAND_Z_WANT, USB_SLOT_Z0 - USB_CLEAR) if P["usb_cutout"] else BAND_Z_WANT + +TAB_X0 = (W - P["ear_w"]) / 2.0 +TAB_X1 = TAB_X0 + P["ear_w"] +SLOT_X0, SLOT_X1 = TAB_X0 - SLOT_TOL, TAB_X1 + SLOT_TOL + +# ear base corners in x: inner base sits EAR_OUT outboard of the slot corner, then the +# base runs a further EAR_BASE outward. +EARL_IN = SLOT_X0 - EAR_OUT # left ear inner base +EARL_OUT = EARL_IN - EAR_BASE # left ear outer base +EARR_IN = SLOT_X1 + EAR_OUT # right ear inner base +EARR_OUT = EARR_IN + EAR_BASE # right ear outer base + +IX0, IY0 = -TOL, -TOL +IX1, IY1 = W + TOL, H + TOL +IR = BADGE_R + TOL +OX0, OY0 = IX0 - RAIL, IY0 - RAIL +OX1, OY1 = IX1 + RAIL, IY1 + RAIL +OR_ = IR + RAIL + +LX0, LY0 = LEDGE_W, LEDGE_W +LX1, LY1 = W - LEDGE_W, H - LEDGE_W +LR = max(0.5, BADGE_R - LEDGE_W) + +Z_LEDGE0, Z_LEDGE1 = 0.0, LEDGE_Z +Z_BAND0, Z_BAND1 = LEDGE_Z - 0.4, LEDGE_Z + BAND_Z + +OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stl", "accessories") + + +# ---------------------------------------------------------------------------- +# the bat cowl — sharp ears whose inner base sits ON the tab-slot corner +# ---------------------------------------------------------------------------- + +def _ear_apex(side): + """Apex point of one ear. side=-1 left, +1 right; base centre from the base corners.""" + base_c = (EARL_IN + EARL_OUT) / 2.0 if side < 0 else (EARR_IN + EARR_OUT) / 2.0 + return (base_c + side * EAR_TILT, OY1 + EAR_H) + + +def left_ear_leftward(): + """Left ear on the band's left-going walk: inner base → sharp tip → outer base. + Returns the run inclusive of both base corners.""" + inner = (EARL_IN, OY1) + outer = (EARL_OUT, OY1) + return [inner] + fillet(inner, _ear_apex(-1), outer, EAR_TIP_R, TIP_SEG) + [outer] + + +def right_ear_toward_slot(): + """Right ear on the band's right-return walk: outer base → sharp tip → inner base.""" + outer = (EARR_OUT, OY1) + inner = (EARR_IN, OY1) + return [outer] + fillet(outer, _ear_apex(+1), inner, EAR_TIP_R, TIP_SEG) + [inner] + + +# ---------------------------------------------------------------------------- +# the cape wings — convex top sweep + scalloped hem, outboard +# ---------------------------------------------------------------------------- + +def _outward(p): + """Unit vector from the badge centroid to p (points away from the badge).""" + dx, dy = p[0] - CENTROID[0], p[1] - CENTROID[1] + L = math.hypot(dx, dy) or 1.0 + return (dx / L, dy / L) + + +def _convex_sweep(p0, p1, bulge, seg): + """p0→p1 bulged AWAY from the badge centroid by `bulge` (a bold wing leading edge). + Includes p0, excludes p1.""" + out = [] + for s in range(seg): + t = s / seg + mx = p0[0] + (p1[0] - p0[0]) * t + my = p0[1] + (p1[1] - p0[1]) * t + ox, oy = _outward((mx, my)) + b = bulge * math.sin(math.pi * t) + out.append((mx + ox * b, my + oy * b)) + return out + + +def _hem_through(tips, sag, seg): + """Connect a list of hem tips with concave scoops, each scoop curving toward the + badge centroid → a scalloped cape edge with pointed tips. Includes tips[0], + excludes tips[-1] (the caller adds it).""" + out = [] + for i in range(len(tips) - 1): + p0, p1 = tips[i], tips[i + 1] + mx, my = (p0[0] + p1[0]) / 2.0, (p0[1] + p1[1]) / 2.0 + ox, oy = _outward((mx, my)) # scoop dips toward the badge (−outward) + for s in range(seg): + t = s / seg + sx = p0[0] + (p1[0] - p0[0]) * t + sy = p0[1] + (p1[1] - p0[1]) * t + dip = sag * math.sin(math.pi * t) + out.append((sx - ox * dip, sy - oy * dip)) + return out + + +def lower_cape(): + """The cape, CCW (the lower way round): left shoulder (OX0,OY1) → convex leading + edge → left wingtip (low, wide) → scalloped hem draping BELOW the badge → right + wingtip → convex leading edge → right shoulder (OX1,OY1). The hem's centre tip hangs + lowest for a classic caped point.""" + LW = (OX0 - WING_SPAN, OY0 + WING_TIP_UP) + RW = (OX1 + WING_SPAN, OY0 + WING_TIP_UP) + pts = _convex_sweep((OX0, OY1), LW, WING_LEAD_BULGE, WING_LEAD_SEG) + [LW] + hem_tips = [LW, + (W * 0.28, OY0 - CAPE_DROP * 0.85), + (W * 0.50, OY0 - CAPE_DROP * 1.30), + (W * 0.72, OY0 - CAPE_DROP * 0.85), + RW] + pts += _hem_through(hem_tips, SCALLOP_SAG, SCALLOP_SEG) + pts += [RW] + pts += _convex_sweep(RW, (OX1, OY1), WING_LEAD_BULGE, WING_LEAD_SEG) + [(OX1, OY1)] + return pts + + +# ---------------------------------------------------------------------------- +# frame profiles +# ---------------------------------------------------------------------------- + +def inner_walk_cw(x_start, x_end): + """Inner opening (badge) traversed CW — the return leg of the C, material on the left.""" + pts = [(x_start, IY1), (IX1 - IR, IY1)] + pts += arc(IX1 - IR, IY1 - IR, IR, 90, 0) + pts += arc(IX1 - IR, IY0 + IR, IR, 0, -90) + pts += [(IX0 + IR, IY0)] + pts += arc(IX0 + IR, IY0 + IR, IR, 270, 180) + pts += arc(IX0 + IR, IY1 - IR, IR, 180, 90) + pts += [(x_end, IY1)] + return pts + + +def band_outer(): + """Outer boundary of the band, CCW, from the slot-left corner (SLOT_X0,OY1) all + the way round (left ear, left wing, bottom, right wing, right ear) to the + slot-right corner (SLOT_X1,OY1). The slot itself is closed by band_profile.""" + p = left_ear_leftward() # (SLOT_X0,OY1) → tip → (SLOT_X0-EAR_BASE,OY1) + p += lower_cape() # (implicit brow to left shoulder) → cape → right shoulder + p += right_ear_toward_slot() # (implicit brow to right ear) → (SLOT_X1,OY1) + return p + + +def band_profile(): + """The band cross-section: a C, the top rail cut by the tab slot → one CCW polygon.""" + pts = [(SLOT_X0, OY1)] + pts += band_outer() + pts += [(SLOT_X1, OY1), (SLOT_X1, IY1)] # down the slot's right wall + pts += inner_walk_cw(SLOT_X1, SLOT_X0) + pts += [(SLOT_X0, IY1)] # up the slot's left wall + poly = ensure_ccw(_dedup(pts)) + assert signed_area(poly) > 0 + return poly + + +def cowl_top_run_left(): + """The unbroken cowl top for the LEDGE ring, moving LEFT: right ear (outer→tip→inner) + → flat across the slot → left ear (inner→tip→outer). This ties the two ear stubs + together behind the back face (they are NOT cantilevers).""" + p = [(EARR_OUT, OY1)] + p += fillet((EARR_OUT, OY1), _ear_apex(+1), (EARR_IN, OY1), EAR_TIP_R, TIP_SEG) + p += [(EARR_IN, OY1), (EARL_IN, OY1)] # flat across the cowl (ties the ear stubs) + p += fillet((EARL_IN, OY1), _ear_apex(-1), (EARL_OUT, OY1), EAR_TIP_R, TIP_SEG) + p += [(EARL_OUT, OY1)] + return p + + +def ledge_profile(): + """The retaining lip: a plain perimeter RING (no wings) with the cowl ears on top, + behind the back face. Wings are band-only; the ledge just retains + ties the ears.""" + pts = [(OX1 - OR_, OY1)] + pts += cowl_top_run_left() # both ears + flat top, moving left + pts += arc(OX0 + OR_, OY1 - OR_, OR_, 90, 180) # top-left + pts += arc(OX0 + OR_, OY0 + OR_, OR_, 180, 270) # bottom-left + pts += [(OX1 - OR_, OY0)] # bottom edge + pts += arc(OX1 - OR_, OY0 + OR_, OR_, -90, 0) # bottom-right + pts += arc(OX1 - OR_, OY1 - OR_, OR_, 0, 90) # top-right + outer = ensure_ccw(_dedup(pts)) + hole = list(reversed(ensure_ccw(rounded_rect(LX0, LY0, LX1, LY1, LR, SEG)))) + return outer, hole + + +# ---------------------------------------------------------------------------- +# build +# ---------------------------------------------------------------------------- + +def make_frame(): + tris = [] + expect = 0.0 + + l_outer, l_hole = ledge_profile() + tris += extrude(l_outer, [l_hole], Z_LEDGE0, Z_LEDGE1) + expect += (signed_area(l_outer) + signed_area(l_hole)) * (Z_LEDGE1 - Z_LEDGE0) + + band = band_profile() + tris += extrude(band, [], Z_BAND0, Z_BAND1) + expect += signed_area(band) * (Z_BAND1 - Z_BAND0) + + return tris, expect + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + tris, expect_mm3 = make_frame() + + # invariant: the mesh's signed volume must match the analytic one (ledge + band, each + # a closed solid; their overlap is double-counted identically on both sides). A flipped + # wall normal or a fan-tiled concave cap fails this by a mile. + vol = mesh_volume_cc(tris) + assert abs(vol * 1000.0 - expect_mm3) <= 0.02 * abs(expect_mm3), ( + "mesh volume %.1f mm^3 != analytic %.1f mm^3 — check cap/wall normals" + % (vol * 1000.0, expect_mm3)) + + path = os.path.join(OUT_DIR, "caped_crusader_%s.stl" % VERSION) + write_stl(path, tris, "badge caped-crusader frame %s / case %s" % (VERSION, P["case_version"])) + + bw, bh, bd = bbox(tris) + grams = vol * _DENSITY_G_CC[MATERIAL] * INFILL # approx (overlap double-counts) + bad = edge_manifold_issues(tris) + + print("caped-crusader frame %s (badge case %s, layout %s, git %s)" % ( + VERSION, P["case_version"], LAYOUT, git_rev() or "?")) + print(" badge it wraps %.1f x %.1f x %.1f mm" % (W, H, BADGE_D)) + print(" frame envelope %.1f x %.1f x %.1f mm (%d tris)" % (bw, bh, bd, len(tris))) + print(" bat cowl ears base %.0f, height %.0f, tip r%.1f, spread %.0f outboard of the slot" % ( + EAR_BASE, EAR_H, EAR_TIP_R, EAR_OUT)) + print(" cape span %.0f outboard, hem drops %.0f below the badge, sag %.1f" % ( + WING_SPAN, CAPE_DROP, SCALLOP_SAG)) + print(" band ride depth %.1f mm%s" % ( + BAND_Z, " <- clamped from %.1f (USB slot at z=%.1f, keep %.1f clear)" + % (BAND_Z_WANT, USB_SLOT_Z0, USB_CLEAR) if BAND_Z < BAND_Z_WANT else "")) + print(" tab slot x %.1f..%.1f (tab %.1f..%.1f + %.2f/side)" % ( + SLOT_X0, SLOT_X1, TAB_X0, TAB_X1, SLOT_TOL)) + print(" material %s @ %.0f%% -> ~%.1f cc, ~%.0f g (approx, overlap-composed)" % ( + MATERIAL, INFILL * 100, vol, grams)) + print(" manifold %s" % ("OK (closed)" if bad == 0 else + "%d open edges (overlap-composed: ledge/band union)" % bad)) + print(" -> %s" % path) + + +if __name__ == "__main__": + main() diff --git a/hardware/cad/gen_cat_ears.py b/hardware/cad/gen_cat_ears.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +r""" +gen_cat_ears.py — slide-on CAT EAR frame for the badge case (accessory). + +A rectangular frame that mirrors the badge outline plus a slide tolerance, with two +pointed (round-tipped) ears rising off the top rail, one either side of the centred +lanyard tab. It loads from the BACK and rides the perimeter walls. + + OUTLINE (looking at the badge face-on) + + /\ /\ + / \ ┌─────┐ / \ ears, on the top-rail stubs + / \ │ tab │ / \ + ┌─┴──────┴──────┤ ├───────────┴──────┴─┐ + │ ╔═════════════╧═════╧══════════════════╗ │ slot: the lanyard tab passes + │ ║ ║ │ through, keying the frame in XY + │ ║ badge outline W x H ║ │ + │ ╚══════════════════════════════════════╝ │ + └──────────────────────────────────────────┘ + + SECTION (the badge's depth axis runs left→right) + + ledge band + ┌────┐──────────────────┐ + │////│//////////////////│ // = frame + │////└──────────────────┴─────────────────────────┐ + │ ┌────────────────────────────────────────────┤ + └────┤ badge: tray ─────────────── lid │ + z=-LZ │ │ + z=0 z=BADGE_D + (badge back face) + +WHY IT LOADS FROM THE BACK: the lanyard tab (gen_stl `lanyard_tab`) is FULL DEPTH on +the tray — it spans badge z 0→(plate_t + cavity). A frame coming from the front would +foul it at the wall top; coming from the back, the tab simply threads through the top +rail's slot. That slot is also the anti-rotation key. + +RETENTION: the LEDGE — the last LEDGE_Z mm of the part turn inward past the badge +outline, so the frame bottoms out flush against the tray's back face instead of +sliding on through. Because the ledge ends up BEHIND the back face (badge z < 0) it +never has to clear the tab, so it stays a closed ring: the two top-rail stubs are +tied together at the back and the ears are not cantilevers. It also lands 1.5mm of +flat contact inboard of the case's 1mm back chamfer, and still clears the M3 screw +counterbores (which start 3.8mm in) and the back vent field (11mm in). + +BAND DEPTH IS DERIVED, NOT CHOSEN: the −y wall carries the USB-C slot, whose bottom +edge sits at `USB_SLOT_Z0` above the back face. A rail standing proud of that wall +anywhere near the port would hold a cable's overmould off the case, so BAND_Z is +clamped to USB_SLOT_Z0 − USB_CLEAR. Move the slot in gen_stl and this follows. + +Badge dimensions are IMPORTED from gen_stl (plate_outline / cavity_depth / P), so the +frame can never drift out of sync with the case it wraps. + +Print FLAT, ledge-side down — the ledge is the widest section and it's on the bed, so +every transition is an upward step: no supports, no bridges. PETG. + +Out: ./stl/accessories/cat_ears_<ver>.stl +""" + +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gen_stl import ( # noqa: E402 + P, plate_outline, cavity_depth, + ensure_ccw, rounded_rect, _bridge_holes, _triangulate, _tri_area2, _orient, + write_stl, bbox, edge_manifold_issues, mesh_volume_cc, signed_area, + _DENSITY_G_CC, git_rev, +) + +# ---------------------------------------------------------------------------- +# PARAMS — the accessory's own numbers. Everything about the BADGE is imported. +# ---------------------------------------------------------------------------- +VERSION = "v1.0" +LAYOUT = "B" # the production layout + +TOL = 0.25 # per-side slide clearance, frame ↔ badge wall. 0.25 is a + # push fit in PETG at 0.2 layers; go 0.35 if it binds. +RAIL = 3.0 # frame border width (the visible edge, all four sides) +BAND_Z_WANT = 8.0 # how far the band WANTS to ride down the badge's sides +USB_CLEAR = 2.5 # keep the band's top edge this far below the USB slot + +LEDGE_W = 2.5 # how far the back lip turns inward over the badge back face + # (1mm of that sits over the case's back chamfer) +LEDGE_Z = 1.2 # lip thickness — how far the frame stands proud of the back + +SLOT_TOL = 0.4 # per-side clearance around the lanyard tab in the top rail + +EAR_GAP = 1.0 # ear's outer base corner, inboard of the top edge's straight run +EAR_BASE = 26.0 # ear base width +EAR_H = 20.0 # ear height above the frame's outer top edge +EAR_TILT = 3.0 # apex offset OUTBOARD of the base centre (the feline lean) +EAR_TIP_R = 2.5 # tip rounding + +EAR_RECESS = True # recessed inner-ear on the front-facing side (2nd-colour swap) +EAR_RECESS_D = 0.6 # recess depth — matches front_text_h, so the same filament + # change trick as the lid lettering works here +EAR_RECESS_INSET = 3.5 # wall left between the ear outline and the recess +EAR_RECESS_TIP_R = 1.6 + +SEG = P["corner_seg"] # arc resolution — mirrors the badge's own corners +TIP_SEG = 10 +MATERIAL = P["material"] +INFILL = P["infill"] + +# ---------------------------------------------------------------------------- +# DERIVED — badge geometry, straight from gen_stl. Nothing here is a magic number. +# ---------------------------------------------------------------------------- +W, H = plate_outline(LAYOUT) # badge face, mm +BADGE_D = 2 * P["plate_t"] + cavity_depth(LAYOUT) # assembled depth +BADGE_R = P["corner_r"] + +# USB-C slot bottom edge, above the back face — the same expression gen_stl's +# usb_slot_profile uses for z1, minus the slot height. +USB_SLOT_Z0 = (P["plate_t"] + P["fb_standoff_h"] + P["usb_slot_top_margin"] + + P["usb_slot_z_offset"] - P["usb_slot_h"]) +BAND_Z = min(BAND_Z_WANT, USB_SLOT_Z0 - USB_CLEAR) if P["usb_cutout"] else BAND_Z_WANT + +# the lanyard tab we have to thread: centred, ear_w wide, sitting above the top edge +TAB_X0 = (W - P["ear_w"]) / 2.0 +TAB_X1 = TAB_X0 + P["ear_w"] +SLOT_X0, SLOT_X1 = TAB_X0 - SLOT_TOL, TAB_X1 + SLOT_TOL + +# frame loops, in badge face coordinates (badge spans x 0..W, y 0..H) +IX0, IY0 = -TOL, -TOL # inner opening = badge + tolerance +IX1, IY1 = W + TOL, H + TOL +IR = BADGE_R + TOL +OX0, OY0 = IX0 - RAIL, IY0 - RAIL # outer edge +OX1, OY1 = IX1 + RAIL, IY1 + RAIL +OR_ = IR + RAIL + +LX0, LY0 = LEDGE_W, LEDGE_W # ledge opening = badge − LEDGE_W +LX1, LY1 = W - LEDGE_W, H - LEDGE_W +LR = max(0.5, BADGE_R - LEDGE_W) + +# z stack, part-local (bed at 0). The ledge starts below so the two solids OVERLAP +# rather than meeting on a coincident face — compose-by-overlap, as gen_stl does for +# the bosses and the lanyard tab. +Z_LEDGE0, Z_LEDGE1 = 0.0, LEDGE_Z +Z_BAND0, Z_BAND1 = LEDGE_Z - 0.4, LEDGE_Z + BAND_Z + +OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stl", "accessories") + + +# ---------------------------------------------------------------------------- +# 2D helpers +# ---------------------------------------------------------------------------- + +def arc(cx, cy, r, a0, a1, seg=SEG): + """Points along a circular arc, degrees, both ends inclusive.""" + return [(cx + r * math.cos(math.radians(a0 + (a1 - a0) * s / seg)), + cy + r * math.sin(math.radians(a0 + (a1 - a0) * s / seg))) + for s in range(seg + 1)] + + +def fillet(prev_p, v, next_p, r, seg): + """Round the corner at `v` between the edges v→prev_p and v→next_p. Returns the + arc from the prev-side tangent point to the next-side tangent point, so a walk + reads [..., prev_p] + fillet(prev_p, v, next_p, ...) + [next_p, ...] and `v` + itself drops out. Sweep direction follows the shorter way round, so it works for + a corner traversed either handedness.""" + ux, uy = prev_p[0] - v[0], prev_p[1] - v[1] + vx, vy = next_p[0] - v[0], next_p[1] - v[1] + lu, lv = math.hypot(ux, uy), math.hypot(vx, vy) + if lu < 1e-9 or lv < 1e-9: + return [v] + ux, uy, vx, vy = ux / lu, uy / lu, vx / lv, vy / lv + half = math.acos(max(-1.0, min(1.0, ux * vx + uy * vy))) / 2.0 + if half < 1e-6 or half > math.pi / 2 - 1e-6: + return [v] + t = r / math.tan(half) + if t > 0.98 * min(lu, lv): # radius too big for these edges + return [v] + bx, by = ux + vx, uy + vy + lb = math.hypot(bx, by) + if lb < 1e-9: + return [v] + c = (v[0] + bx / lb * (r / math.sin(half)), v[1] + by / lb * (r / math.sin(half))) + t1 = (v[0] + ux * t, v[1] + uy * t) + t2 = (v[0] + vx * t, v[1] + vy * t) + a1 = math.atan2(t1[1] - c[1], t1[0] - c[0]) + a2 = math.atan2(t2[1] - c[1], t2[0] - c[0]) + d = (a2 - a1) % (2 * math.pi) + if d > math.pi: + d -= 2 * math.pi + return [(c[0] + r * math.cos(a1 + d * s / seg), c[1] + r * math.sin(a1 + d * s / seg)) + for s in range(seg + 1)] + + +def _dedup(poly, eps=1e-7): + """Drop consecutive (and wrap-around) duplicate points.""" + out = [] + for p in poly: + if not out or math.hypot(p[0] - out[-1][0], p[1] - out[-1][1]) > eps: + out.append(p) + while len(out) > 1 and math.hypot(out[0][0] - out[-1][0], out[0][1] - out[-1][1]) <= eps: + out.pop() + return out + + +# ---------------------------------------------------------------------------- +# the ears +# ---------------------------------------------------------------------------- + +def ear_triangle(side): + """The three defining points of one ear, as (base_outer, base_inner, apex). + `side` is -1 for the left ear, +1 for the right. The base sits ON the frame's + outer top edge; the apex leans OUTBOARD by EAR_TILT.""" + if side < 0: + bx_out = OX0 + OR_ + EAR_GAP # clear of the corner arc + bx_in = bx_out + EAR_BASE + else: + bx_out = OX1 - OR_ - EAR_GAP + bx_in = bx_out - EAR_BASE + apex_x = (bx_out + bx_in) / 2.0 + side * EAR_TILT + return ((bx_out, OY1), (bx_in, OY1), (apex_x, OY1 + EAR_H)) + + +def ear_run(side): + """The ear's contribution to a top-edge walk travelling RIGHT→LEFT (the CCW + direction along the top). Returns points from the first base corner reached to + the second, going up one side, round the tip, and down the other.""" + base_out, base_in, apex = ear_triangle(side) + first, second = (base_in, base_out) if side < 0 else (base_out, base_in) + return [first] + fillet(first, apex, second, EAR_TIP_R, TIP_SEG) + [second] + + +def ear_recess_loop(side): + """CCW loop of the inner-ear recess: the ear triangle inset by EAR_RECESS_INSET, + with a rounded tip. Convex, so it caps with a plain fan.""" + tri = ear_triangle(side) + cx = sum(p[0] for p in tri) / 3.0 + cy = sum(p[1] for p in tri) / 3.0 + inset = [] + for i, v in enumerate(tri): + a, b = tri[(i + 1) % 3], tri[(i + 2) % 3] + ux, uy = a[0] - v[0], a[1] - v[1] + vx, vy = b[0] - v[0], b[1] - v[1] + lu, lv = math.hypot(ux, uy), math.hypot(vx, vy) + ux, uy, vx, vy = ux / lu, uy / lu, vx / lv, vy / lv + half = math.acos(max(-1.0, min(1.0, ux * vx + uy * vy))) / 2.0 + bx, by = ux + vx, uy + vy + lb = math.hypot(bx, by) + inset.append((v[0] + bx / lb * (EAR_RECESS_INSET / math.sin(half)), + v[1] + by / lb * (EAR_RECESS_INSET / math.sin(half)))) + b_out, b_in, apex = inset + loop = [b_out, b_in] + fillet(b_in, apex, b_out, EAR_RECESS_TIP_R, TIP_SEG) + loop = _dedup(loop) + # sanity: the recess must stay inside the ear and above the rail + assert min(p[1] for p in loop) > OY1 + 0.1, "recess base dips into the top rail" + assert cy > 0 + return ensure_ccw(loop) + + +# ---------------------------------------------------------------------------- +# the frame profiles +# ---------------------------------------------------------------------------- + +def _top_run(x_from, x_to): + """Walk the OUTER top edge leftward from x_from to x_to, emitting any ear whose + base falls inside the span. Endpoints are emitted by the caller's arcs/dives.""" + pts = [] + for side in (+1, -1): # right ear first (higher x) + base_out, base_in, _ = ear_triangle(side) + lo, hi = sorted((base_out[0], base_in[0])) + if x_to <= lo and hi <= x_from: + pts += ear_run(side) + return pts + + +def outer_walk(x_start, x_end): + """The frame's outer boundary, CCW, from (x_start, OY1) on the top edge all the + way round to (x_end, OY1). With x_start > x_end it is a closed loop's worth of + travel; the caller supplies the two endpoints and whatever joins them.""" + pts = _top_run(x_start, OX0 + OR_) + pts += arc(OX0 + OR_, OY1 - OR_, OR_, 90, 180) # top-left + pts += arc(OX0 + OR_, OY0 + OR_, OR_, 180, 270) # bottom-left + pts += [(OX1 - OR_, OY0)] # bottom edge, L→R + pts += arc(OX1 - OR_, OY0 + OR_, OR_, -90, 0) # bottom-right + pts += arc(OX1 - OR_, OY1 - OR_, OR_, 0, 90) # top-right + pts += _top_run(OX1 - OR_, x_end) + return pts + + +def inner_walk_cw(x_start, x_end): + """The inner opening traversed CLOCKWISE from (x_start, IY1) on its top edge + round to (x_end, IY1) — the return leg of the C profile, so material stays on + the left of travel throughout.""" + pts = [(x_start, IY1), (IX1 - IR, IY1)] + pts += arc(IX1 - IR, IY1 - IR, IR, 90, 0) # top-right, CW + pts += arc(IX1 - IR, IY0 + IR, IR, 0, -90) # bottom-right, CW + pts += [(IX0 + IR, IY0)] # bottom edge, R→L + pts += arc(IX0 + IR, IY0 + IR, IR, 270, 180) # bottom-left, CW + pts += arc(IX0 + IR, IY1 - IR, IR, 180, 90) # top-left, CW + pts += [(x_end, IY1)] + return pts + + +def band_profile(): + """The sliding band's cross-section: a C — the top rail is cut through by the + lanyard-tab slot, so outer and inner boundaries join into ONE simple CCW polygon + with no hole.""" + pts = [(SLOT_X0, OY1)] + pts += outer_walk(SLOT_X0, SLOT_X1) + pts += [(SLOT_X1, OY1), (SLOT_X1, IY1)] # down the slot's right wall + pts += inner_walk_cw(SLOT_X1, SLOT_X0) + pts += [(SLOT_X0, IY1)] # up the slot's left wall + poly = ensure_ccw(_dedup(pts)) + assert signed_area(poly) > 0 + return poly + + +def ledge_profile(): + """The retaining lip's cross-section: a plain RING. It sits entirely behind the + badge's back face, so the tab never reaches it and the top rail stays unbroken — + which is what stops the ear stubs from being cantilevers.""" + pts = [(OX1 - OR_, OY1)] + pts += _top_run(OX1 - OR_, OX0 + OR_) + pts += arc(OX0 + OR_, OY1 - OR_, OR_, 90, 180) + pts += arc(OX0 + OR_, OY0 + OR_, OR_, 180, 270) + pts += [(OX1 - OR_, OY0)] + pts += arc(OX1 - OR_, OY0 + OR_, OR_, -90, 0) + pts += arc(OX1 - OR_, OY1 - OR_, OR_, 0, 90) + outer = ensure_ccw(_dedup(pts)) + hole = list(reversed(ensure_ccw(rounded_rect(LX0, LY0, LX1, LY1, LR, SEG)))) + return outer, hole + + +# ---------------------------------------------------------------------------- +# build +# ---------------------------------------------------------------------------- + +def cap(outer, holes, z, updir): + """Flat cap of a CCW outer loop with CW holes. Unlike gen_stl's cap_with_holes we + NEVER fall back to a fan: every profile here is concave (the ear bulges, the tab + slot, the C), and a fan would quietly tile the convex hull — which is exactly the + bug this function exists to prevent. The area assertion is the guard: a cap that + doesn't sum to its polygon's own area is a broken cap, full stop.""" + merged = _dedup(_bridge_holes(list(outer), holes) if holes else list(outer)) + tris2d = _triangulate(merged) + want = abs(signed_area(merged)) + got = sum(abs(_tri_area2(a, b, c)) for a, b, c in tris2d) / 2.0 + assert abs(got - want) <= 1e-3 * max(1.0, want), ( + "cap triangulation lost area: %.2f vs %.2f mm^2" % (got, want)) + return [_orient((a[0], a[1], z), (b[0], b[1], z), (c[0], c[1], z), updir) + for a, b, c in tris2d] + + +def wall(loop, z0, z1): + """Vertical wall around a closed loop, z0→z1, with normals taken from the EDGE + DIRECTION: material sits to the left of travel, so outward is (dy, −dx). + + gen_stl's _side_wall points normals away from the loop's CENTROID instead, which + is fine for the simple rings it was written for but WRONG here — the band's C + profile carries its outer edge and its inner opening in a single loop, and a + centroid rule flips every normal on the inner run. The edge rule needs no such + assumption, and it serves outer loops, CW holes and pocket walls identically: + just hand it the loop wound so the material is on the left.""" + tris = [] + k = len(loop) + for i in range(k): + x0, y0 = loop[i] + x1, y1 = loop[(i + 1) % k] + tgt = (y1 - y0, -(x1 - x0), 0.0) + a = (x0, y0, z0); b = (x1, y1, z0); c = (x1, y1, z1); d = (x0, y0, z1) + tris.append(_orient(a, b, c, tgt)) + tris.append(_orient(a, c, d, tgt)) + return tris + + +def extrude(outer, holes, z0, z1): + """Closed solid from a CCW outer loop + CW hole loops, z0→z1.""" + tris = cap(outer, holes, z0, (0, 0, -1)) + tris += cap(outer, holes, z1, (0, 0, 1)) + tris += wall(outer, z0, z1) + for h in holes: # already CW → material on the left + tris += wall(h, z0, z1) + return tris + + +def make_frame(): + """Returns (tris, expected_volume_mm3). The volume is computed analytically from + the profiles so main() can assert the MESH agrees — a normal-orientation or + triangulation slip shows up there as a wildly wrong number, which is exactly how + the first two bugs in this part were caught.""" + tris = [] + expect = 0.0 + + # --- ledge: the back lip that stops the slide (a closed ring) --------------- + l_outer, l_hole = ledge_profile() + tris += extrude(l_outer, [l_hole], Z_LEDGE0, Z_LEDGE1) + ledge_area = signed_area(l_outer) + signed_area(l_hole) # hole is CW → negative + expect += ledge_area * (Z_LEDGE1 - Z_LEDGE0) + + # --- band: rides the perimeter walls, slotted for the lanyard tab ----------- + band = band_profile() + recesses = [ear_recess_loop(-1), ear_recess_loop(+1)] if EAR_RECESS else [] + holes = [list(reversed(r)) for r in recesses] + band_area = signed_area(band) + expect += band_area * (Z_BAND1 - Z_BAND0) + + tris += cap(band, [], Z_BAND0, (0, 0, -1)) + tris += wall(band, Z_BAND0, Z_BAND1) + tris += cap(band, holes, Z_BAND1, (0, 0, 1)) + + # --- inner-ear pockets: floor + walls, sunk into the front-facing side ------ + for r in recesses: + zf = Z_BAND1 - EAR_RECESS_D + tris += cap(r, [], zf, (0, 0, 1)) # pocket floor, faces up + tris += wall(list(reversed(r)), zf, Z_BAND1) # pocket walls — CW, material outside + expect -= signed_area(r) * EAR_RECESS_D + + # the ledge and band solids overlap by (Z_LEDGE1 − Z_BAND0); the band's section is + # wholly inside the ledge ring there, so the printed part is `expect` minus that. + solid = expect - band_area * (Z_LEDGE1 - Z_BAND0) + return tris, expect, solid + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + tris, expect_mm3, solid_mm3 = make_frame() + + # the invariant: the mesh's own signed volume must match the analytic one. A + # flipped wall normal or a fan-tiled concave cap fails this by a mile. + vol = mesh_volume_cc(tris) + assert abs(vol * 1000.0 - expect_mm3) <= 0.02 * abs(expect_mm3), ( + "mesh volume %.1f mm^3 != analytic %.1f mm^3 — check cap/wall normals" + % (vol * 1000.0, expect_mm3)) + + path = os.path.join(OUT_DIR, "cat_ears_%s.stl" % VERSION) + write_stl(path, tris, "badge cat-ear frame %s / case %s" % (VERSION, P["case_version"])) + + bw, bh, bd = bbox(tris) + vol = solid_mm3 / 1000.0 # printed volume (overlap resolved) + grams = vol * _DENSITY_G_CC[MATERIAL] * INFILL + bad = edge_manifold_issues(tris) + + print("cat-ear frame %s (badge case %s, layout %s, git %s)" % ( + VERSION, P["case_version"], LAYOUT, git_rev() or "?")) + print(" badge it wraps %.1f x %.1f x %.1f mm" % (W, H, BADGE_D)) + print(" frame envelope %.1f x %.1f x %.1f mm (%d tris)" % (bw, bh, bd, len(tris))) + print(" inner opening %.2f x %.2f mm (badge + %.2f/side, r%.2f)" % ( + IX1 - IX0, IY1 - IY0, TOL, IR)) + print(" band ride depth %.1f mm%s" % ( + BAND_Z, " <- CLAMPED from %.1f: USB slot starts at z=%.1f, keeping %.1f clear" + % (BAND_Z_WANT, USB_SLOT_Z0, USB_CLEAR) if BAND_Z < BAND_Z_WANT else "")) + print(" back ledge %.1f mm inward x %.1f mm proud" % (LEDGE_W, LEDGE_Z)) + print(" tab slot x %.1f..%.1f (tab %.1f..%.1f + %.2f/side)" % ( + SLOT_X0, SLOT_X1, TAB_X0, TAB_X1, SLOT_TOL)) + print(" ears base %.0f, height %.0f, tip r%.1f, tilt %.1f%s" % ( + EAR_BASE, EAR_H, EAR_TIP_R, EAR_TILT, + ", inner recess %.1f deep" % EAR_RECESS_D if EAR_RECESS else "")) + print(" material %s @ %.0f%% -> %.1f cc, ~%.0f g" % ( + MATERIAL, INFILL * 100, vol, grams)) + print(" manifold %s" % ("OK (closed)" if bad == 0 else + "%d open edges (overlap-composed: ledge/band union)" % bad)) + print(" -> %s" % path) + + +if __name__ == "__main__": + main() diff --git a/hardware/cad/gen_stl.py b/hardware/cad/gen_stl.py @@ -0,0 +1,2034 @@ +#!/usr/bin/env python3 +""" +DefCon friend-finder badge — prototype enclosure STL generator (dependency-free). + +Emits binary STL meshes for a two-piece tray+lid case (flat-sandwich fallback via +PARAMS["walls"]). No external libraries: geometry is built from axis-aligned boxes, +walls, and rounded corners, written as raw triangles. Watertight by construction. + +Two layouts (locked: B primary, A backup): + B Coplanar — battery beside electronics. Bigger face, slimmer. ~95 x 90 x 24 mm <- PRIMARY + A Stacked — battery behind electronics. Compact face, deeper. ~95 x 74 x 30 mm + +Each layout produces TWO printable parts: + *_front.stl front bezel / LID — display-window cut + rounded corners + ears (flat) + *_back.stl back TRAY — floor + perimeter walls + M3 corner screw bosses + ears + (set PARAMS["walls"]=False for the old flat slab) + +Run: python3 gen_stl.py +Out: ./stl/badge_<ver>_{A,B}_{front,back}.stl (ver from PARAMS) — stl/ holds only + the CURRENT version; each run sweeps prior versions to ./stl_archive/. Plus a + console size/weight report; each STL's 80-byte header names its version. + +All dims in mm. Tweak the PARAMS block; everything else is derived. +""" + +import struct +import math +import os +import subprocess + +# ---------------------------------------------------------------------------- +# PARAMS — every real-world measurement lives here. Verify w/ calipers on +# arrival; these are datasheet/vendor numbers (the enclosure spec is ./README.md). +# ---------------------------------------------------------------------------- +P = { + # version stamp — baked into each STL's filename AND its 80-byte header, so + # every iteration persists for posterity and a shared file names its version. + # Bump MINOR for tweaks, MAJOR for a layout/structural milestone (v0 = the old + # flat sandwich; v1 = walled tray + M3 bosses). The git short-rev is printed in + # the console only (NOT baked into the STL, so regen stays churn-free) — mirrors + # the firmware FW_VERSION (human) / GIT_REV (exact) split. + "case_version": "v1.51", # v1.51: maker URL virtualshack.io → LID top band (pixel font, band-centred, printable), off the cramped tray strip. v1.50: house in the battery pocket + + # OLED 2.42" SSD1309 module (the FACE driver) — v1.15: RE-BASED to caliper + # actuals (2026-06-11, checklist #10–#13); the 71.0×43.5 datasheet ran 1.0 + # wide and 4.5 SHORT of the real module (the v1.10→v1.13 collar saga's root + # cause). The window/collar OPENINGS are preserved exactly via the clearance/ + # extra mapping below — the dialed fit carries over; only the basis is real now. + "oled_pcb_w": 70.0, # PCB long edge (measured; datasheet said 71.0) + "oled_pcb_h": 48.0, # PCB short edge (measured; datasheet said 43.5!) + "oled_active_w": 55.0, # lit area (reference) + "oled_active_h": 27.5, + # v1.5: rectangular PASSTHROUGH window — sized to the glass panel + a small gap so + # the OLED glass nests THROUGH the opening (PCB captured behind the lid). Replaced + # the old active-area + bezel-lip window: easier assembly, no glass-offset guess. + "oled_glass_w": 61.0, # glass panel, measured: 4.5mm offset all around → CENTRED + "oled_glass_h": 39.0, + "window_clearance": 0.5, # per-side glass→opening gap (re-base: opening stays 63.0×40.0) + "window_extra_w": 1.0, # extra WIDTH on top of the clearance (the validated v1.9 slack) + "window_extra_h": 0.0, # extra HEIGHT (the v1.8+v1.10 slack now lives in the real glass dims) + # guide ridges — a short collar on the LID's inner face, framing the OLED PCB + # outline; the module nests into it for XY registration during assembly (doesn't + # touch the window opening, so it doesn't shrink the visible screen). + "guide_ridges": True, + "guide_h": 2.5, # ridge height into the cavity + "guide_t": 1.2, # ridge wall thickness + "guide_clearance": 0.25, # gap PCB→ridge per side (re-base: opening stays 74.0×48.5) + # collar-FOOTPRINT trim — adjusts the collar's XY opening ONLY (not the plate + # outline or the display window). v1.15 re-base: the validated opening maps to + # real dims as 0.25/side snug height + the historic width slack as extra_w. + "collar_extra_w": 3.5, # collar WIDTH slack on top of the clearance (historic, harmless) + "collar_extra_h": 0.0, # HEIGHT: the 0.25/side IS the validated v1.13 snug fit + # OLED header notch — the module's 4-pin header sits on its RIGHT edge + # (screen-facing; user pick 2026-06-11), so the collar wall on that edge gets a + # centred gap for the header + leads. MIRROR NOTE (empirical, v1.17): the + # v1.15 derivation put viewer-right at lid −x — the FIT PRINT proved that + # WRONG (notch landed opposite the tray's USB wall). As assembled, viewer- + # right = +x on BOTH parts; the notch lives on +x. Trust the print, not the + # chirality argument. + "oled_header_notch_w": 22.0, # notch width (18mm header span + tolerance) + # OLED mounting pegs — the measured pattern (#13): 4× Ø2.0 holes @ 64.66×42.21 + # c2c (near-edge caliper method). PEGS, not screws: an M2 can't engage a 2mm + # lid and a boss can't pass the module's own Ø2 holes. Assembly: module drops + # over the pegs (glass through the window, PCB flush on the lid), then melt + # the peg tips with an iron to stake — or leave friction-fit. + "oled_pegs": True, + "oled_mount_w": 65.16, # peg c2c, long axis: measured 64.66 + 0.5 width slack (v1.25 fit — height pair was perfect) + "oled_mount_h": 42.21, # hole-centre spacing, short axis + "oled_peg_d": 1.8, # peg Ø — clearance into the Ø2.0 holes + "oled_peg_h": 4.0, # ≈PCB (~1.6) + ~2.4 proud for heat-staking + + # front case button — EXTERNAL panel-mount momentary, wired GPIO27→GND in + # parallel with the onboard dome: a round through-hole + # in the LID below the screen, centred on W, mid-band between the bottom wall + # and the guide collar. Hole Ø = the part's THREAD OD. The part of record is + # the Twidec PBS-110 (7 mm momentary, pre-soldered leads) — VALIDATED on glass + # 2026-06-11: it threads into the v1.14 as-printed Ø7.0 hole, nut seats flat. + # Auto-skipped if the band is too tight to web the hole (layout A). + "btn_hole": True, + "btn_hole_d": 7.0, # Twidec PBS-110 thread — validated by fit test + + # FireBeetle 2 ESP32-E (the DEPTH driver's neighbor) — caliper pass 2026-06-10: + # 60.0 × 25.0 (short edge 0.4 under datasheet); USB-C overhangs the short edge + # 2.0mm (62.0 total), 9.0mm wide, 3.45mm above the 1.55mm PCB. The USB wall slot + # itself is deferred to the board-mounting pass (its vertical position needs the + # standoff design); the outline trim lands here. + "fb_w": 60.0, + "fb_h": 25.0, # measured (was 25.4 datasheet) — B plate height −0.4 + "fb_usb_overhang": 2.0, # USB-C proud of the short edge (62.0 board+connector total) + # FireBeetle mounting — the CAD-exact M2 pattern (±28.4 along the length, + # ±11.1 across, about the board centre). Standoff bosses + # rise from the TRAY floor; COMPONENTS FACE THE FLOOR (the 7mm JST sets the + # height), keeping the lid side clear for the case button. M2 screws drop in + # from the front during back-insertion assembly. B placement (v1.33): PORTRAIT, + # mounted top-LEFT with the USB short edge facing the −y (bottom) wall and the + # antenna short edge getting the +y margin. The board stays COMPONENT-DOWN (no + # flip — standoff stack unchanged from v1.32), which lands the JST on the board's + # right side, facing the top-RIGHT battery: a short, tidy wire run (the "mirror" + # of a board flip — same wiring win, no re-validation). (v1.32 was landscape with + # USB on the +x/right wall; recover it with fb_orient="landscape", usb_wall="+x".) + "fb_standoffs": True, + "fb_orient": "portrait", # B board orientation: "portrait" (USB short edge → −y) | "landscape" (→ +x, v1.32) + "usb_wall": "-y", # wall the USB-C slot punches through: "-y" (bottom) | "+x" (right, v1.32) + "fb_hole_dx": 28.4, # hole offset along the board length (from vendor CAD) + "fb_hole_dy": 11.1, # hole offset across the board width (from vendor CAD) + "fb_standoff_h": 9.5, # JST 7.0 + 2.5 floor clearance (v1.16: 8.5→9.5 — raises the + # board, and with it the USB dremel path, +1mm off the back) + "fb_standoff_r": 2.25, # boss Ø4.5 — ~1.4mm wall around the M2 pilot + "fb_pilot_r": 0.8, # M2 self-tap pilot, Ø1.6 + + # USB-C cutout (v1.20; relocated to the −y/bottom wall in v1.33) — a REAL through- + # slot, replacing the v1.19 raised dremel outline (the slot position is geometrically + # locked, so we model the hole for the fleet instead of cutting 20 by hand). Position + # tracks the board so it can't drift: for the bottom wall (usb_wall "-y") it's X-centred + # on the FireBeetle's −y short edge (the connector); for "+x" it's Y-centred on the +x + # edge. z is slaved to the standoff height either way (so "the dremel path rises with the + # board" — v1.16 — is automatic, and the board mounts identically, component-down). The + # optional slot TOP 45° inverted-V roof prints SUPPORT-FREE floor-down (a flat top would + # be a ~10mm bridge); the flat bottom is fine (an upward face). usb_cutout:False reverts + # to a solid wall (usb_roof_45:False → plain rectangle). The wall is set by usb_wall above. + "usb_cutout": True, + "usb_slot_w": 10.0, # slot opening along the wall (y) — 9mm connector + tolerance + "usb_slot_h": 4.5, # rectangular opening height (z) — the USB-C body + "usb_slot_top_margin": 0.5,# rectangle top above the board plane (ft + fb_standoff_h) + "usb_slot_z_offset": 1.0, # v1.37: 0.0 → +1.0 (another 1mm UP); v1.34 took it −2.0 → 0.0 (− = DOWN) — + # empirical fit nudge, kept separate from top_margin so it stays slaved to the standoff + "usb_roof_45": False, # plain rectangular slot — X1C/H2D bridge 10mm fine, and a tiny + # support peels right off; the peak added no usable plug clearance + + # EEMB 103454 LiPo, 3.7V 2000mAh — the cell in hand (×4); plugs straight into + # the FireBeetle JST-PH, no holder. Caliper-confirm thickness — pouches run a + # hair over the 10.0 nominal. (Thinner alt on hand: Qimoo 503035, 5×30×35, + # 500mAh — slimmer case but well under a con day, so a backup not the hero.) + "bat_w": 54.0, # length (103454 = 4mm longer than the old 103450 model) + "bat_h": 34.0, # width → drives layout-B face height + "bat_t": 10.0, # thickness → the depth driver + + # battery retention (v1.34) — a FULL collar cradling the 103454 pouch (vertical, in + # B-portrait's RIGHT column, v1.35 hard against the right wall — leads are long). Walls + # stand on the tray floor (compose-by-overlap like the standoffs). The floor under the + # pocket stays SOLID (no vents — debris/short keepout). + # B-portrait only (A stacks the cell behind the board, no floor pocket). + "bat_collar": True, + "bat_collar_h": 8.0, # wall height (z) — cradles ~8 of the 10mm pouch; the soft top bulges free + "bat_collar_t": 2.0, # wall thickness + "bat_collar_clear": 1.0, # clearance per side on the TOP/BOTTOM (horizontal, y) walls + "bat_collar_clear_x": 0.5, # clearance per side on the LEFT/RIGHT (vertical, x) walls — v1.35: 0.5 (1mm snugger) + "bat_notch": False, # JST-lead notch in the −x wall — v1.35: OFF (leads route around; long enough) + "bat_notch_w": 16.0, # notch width (y span) when bat_notch is on, biased low toward the FB's JST + + # plate / shell + "plate_t": 2.0, # thickness of each flat plate + "cavity_pad": 4.0, # extra internal depth clearance (adds to cavity → +overall depth) + "edge_margin": 12.0, # plastic border — keeps the 7mm corner bosses clear of the OLED guide + # collar (v1.7: 6→11; v1.12: 11→12 once the collar grew +2mm) + "corner_r": 3.0, # rounded outer corners + "corner_seg": 8, # arc resolution per corner + # v1.18: soften the two EXPOSED face↔wall edges (lid front, tray back) with a + # 45° chamfer — comfort against the chest, snag + chip resistance. Outer-only + # and modest, so it never reaches the window/button/bosses or the wall interior. + # The lid↔tray PARTING SEAM stays square (flush seat). Chamfer (not fillet): + # prints clean in any orientation and stays watertight in this box-soup builder. + "chamfer": True, + "chamfer_size": 1.0, # 45° bevel depth on the exposed face edges (mm) + # honeycomb venting — hex perforations in BOTH the back floor (a full field) and the + # front lid (a grille below the window). Built per-cell (each cell = rect minus one + # hex) so a field stays watertight; cells go solid near keepouts (screw bosses, FB + # standoffs, OLED pegs). Same hex spec on both faces (5/1.6). The front's converging + # vent notches need the robust diagonal-split cap triangulator (_triangulate). + "vent_back": True, # honeycomb hex field in the back floor (spliced as a notch) + "vent_hex_flats": 5.0, # hex across-flats (mm) + "vent_wall": 1.6, # min wall between hexes (mm) — sturdy + printable + "vent_margin": 9.0, # back: keep the field this far inside the perimeter walls + "vent_keepout_r": 6.0, # leave a cell solid within this of a screw/standoff/peg centre + # front-lid grille (v1.23, now OFF): the hex-strip-below-the-window machinery. vent_front + # is False since v1.45 (the two front portholes replaced it), so front_vent_regions now + # only positions those portholes. B only (A's centred window has no clean bottom band). + "vent_front": False, # v1.45: front honeycomb grille OFF (replaced by the front_hole_d portholes) + "vent_front_margin": 3.5, # keep the field this far inside the lid edge (> corner_r, off the arcs) + "vent_front_gap": 1.2, # clearance from the collar footprint / button / window + # v1.45: two large round holes where the front grille used to be — one centred in each grille + # band flanking the button (reuses front_vent_regions for placement). Each seats a bottom-edge + # vertex below it (_with_base) so it bridges straight down. Replaces the hex grille + the top holes. + "front_holes": True, + "front_hole_d": 12.0, # porthole diameter + "front_hole_seg": 36, # circle resolution + "front_hole_dx": 7.0, # v1.47: shift each porthole this far toward its nearer L/R edge (outward from + # the region centre). Left porthole is the limit — at +2 up it's ~2.6mm from the + # outer-bottom FB standoff (13.4,5.6) at dx=7 (was 4mm at dx=5). + "front_hole_dy": 2.0, # v1.47: shift the portholes UP (leaves ~1.7mm to the OLED collar — near the max) + "vent_front_btn_clear_cols": 1, # v1.39: leave this many innermost hex columns SOLID beside the button + # (each field: the left field's rightmost col + the right field's leftmost col). + # Widens the clear band around the button; every other hex stays bit-identical. + # side-wall honeycomb vents (v1.38) — hex perforations through the −x/+x perimeter walls + # (B only), matching the floor + lid fields. FLAT-TOP hexes (circle_loop's default) so the + # vertical wall prints SUPPORT-FREE: the hole top is a short ~3mm bridge and the side faces + # sit 30° off vertical. Inset with a solid border (doesn't reach the edges, like the lid + # grille) + clears the corner bosses. −y wall (USB slot + floor keyhole) and +y wall (ear) + # stay solid. Same flats/wall as the other fields (vent_hex_flats / vent_wall). + "vent_walls": True, # hex vents through the two side (−x/+x) walls (B) + "vent_wall_margin": 2.5, # solid border between the field and the wall edges / corner bosses + + # tray walls + M3 corner screw bosses — the back piece is now a TRAY (floor + + # perimeter walls), the front stays the flat LID. Board-independent geometry, + # so it's safe to print for reference before the boards are measured. + "walls": True, # False → fall back to the v0 flat back slab + "wall_t": 2.0, # perimeter wall thickness + # M3 corner lid screws — ONE boss design serves both fastening methods: the + # post is sized for a heat-set insert (the larger bore), and "fastener" picks + # the hole — self-tap for the prototype, insert for production. Same posts and + # positions either way, so the self-tap prototype validates the layout for both. + "fastener": "selftap", # "selftap" (prototype, threads into plastic) | "insert" (production heat-set) + "boss_r": 3.5, # post radius — sized for the insert (~1.5mm wall around a 4mm bore) + "boss_hole_selftap": 1.25, # M3 self-tap pilot, 2.5mm dia + "boss_hole_insert": 2.0, # M3 heat-set insert bore, 4.0mm dia — confirm to YOUR insert's datasheet + "boss_seg": 24, # boss cylinder resolution + "boss_clear_r": 1.7, # screw-shank clearance hole (M3 ~3.4mm) in the tray floor (back insertion) + + # lanyard ears (two, top corners) — now with a real through-hole + "ear_front": False, # v1.42: top loop REMOVED from the front lid (relocating the attachment) + "ear_back": True, # still on the back tray for now (next steps move it) + "ear_w": 9.0, # v1.48: 10→9 — trim the excess front-on width; with ear_h 9 the tab is square + "ear_h": 9.0, # v1.48: 7→9 — taller tab to hold the 4mm bore (1mm lip + 4 bore + 1 wall + 3 cap) + "ear_hole_r": 2.0, # 4mm hole, ~1.5mm walls inside a 7mm ear + "ear_hole_seg": 16, + "ear_full_depth": True, # v1.43: solid block (vs a flat 2mm tab), extruded z0→z1. v1.48: z1 is the tray + # WALL TOP (plate_t + cavity), so the tab ends flush with the walls — it no + # longer overhangs the extra plate_t out to the front-face plane + "ear_corner_r": 3.0, # v1.43: round the tab's top corners (mirrors the badge's corner_r) + "ear_bore": True, # v1.44: side-to-side (X) rounded-rect bore through the tab — the lanyard interface + "ear_bore_y": 4.0, # v1.48: 2→4 — taller bore opening (more room for cord/clip) + "ear_bore_z": 6.0, # bore depth (Z) — centred at the tab's mid-depth (≈ the depth-wise CoG) so the + # badge hangs face-vertical: a worn badge tips in pitch until its CoG sits + # under the cord, so putting the cord over the CoG ≈ zero tip + "ear_bore_y0": 1.0, # bore bottom this far above the badge top edge (thin non-load lip below) + "ear_bore_r": 0.8, # bore corner radius (rounded rectangle) + + # build-number imprint — raised 7-seg digits of the case version on the inner + # face of each part (readable on a loose printed part; hidden once assembled). + "imprint": True, + "imprint_h": 0.8, # raised height + "imprint_digit_w": 4.0, # digit cell width + "imprint_digit_h": 6.0, # digit cell height (v1.7: bigger so it prints legibly) + "imprint_seg": 1.0, # segment thickness + + # maker's mark (v1.50) — an ASCII house raised in the (solid) battery pocket of the + # BACK tray: a signature found on teardown, hidden under the cell once assembled. The + # build-number, which used to share the pocket, moves up above the FireBeetle. v1.51: + # the site-tag URL LEAVES the cramped tray bottom strip (0.5mm px wouldn't print) and + # takes the LID's inner-face top band instead — 76mm of clear run there fits it at + # near-version size, still hidden on teardown (placed in make_front). Both at imprint_h. + "maker_mark": True, # the house in the battery pocket + "maker_px": 1.5, # house pixel size (mm) — grid is 12x10 → ~18 x 15mm + "maker_tag": "virtualshack.io", # site-tag URL, now on the lid's top band ("" = off → lid keeps the 7-seg version stamp) + "maker_tag_px": 0.8, # lid tag pixel size (mm) → 5.6mm glyphs; ~71mm run centred in the 76mm clear band + + # front-face text (v1.26) — raised 5x7 pixel text on the LID's outer face, in the solid + # bands the honeycomb grille leaves clear: TOP runs horizontal above the window; the SIDES + # run vertical alongside it (left reads up, right reads down). Set the three strings to your + # text (A-Z 0-9 space - . / ! : + #); "" = that zone is blank. B-tuned; A renders too. + "front_text": True, + "front_text_top": "OnlyN00bs", # v1.46: restored — the top band is clear again (top holes gone in v1.45) + # (was blanked in v1.40 when the top hole row lived here) + "front_text_left": "DC34", # vertical up the left side + "front_text_right": "2026", # vertical down the right side + "front_text_px": 1.2, # pixel size (mm): a glyph is 5px wide x 7px tall (~6 x 8.4mm) + "front_text_h": 0.6, # colour-inlay depth INTO the face (mm) — the 2nd-filament layer thickness + "front_text_col_gap": 1.0, # blank columns between glyphs (in px units) + + # top-band hole row (v1.40) — a centred row of round through-holes across the top band of the B + # lid (above the OLED window), replacing the old top text. Each hole is auto-skipped if it can't + # keep clear of the two top corner M3 bosses (so it's safe if n/pitch grow). The lid's version + # imprint is dropped when this is on (the band is now holes); the TRAY keeps its imprint. + "top_holes": False, # v1.45: top Ø12 row removed (restores the full OLED collar) + "top_hole_d": 12.0, # hole diameter (v1.41: 5→12 — too big for the band above the collar, so the + # collar's top bar is notched around each hole; ~2mm of OLED PCB edge shows + # in the bottom of each hole, and the bar survives between/beside the holes) + "top_hole_pitch": 30.0, # centre-to-centre spacing along x + "top_hole_n": 2, # number of holes (centred about the lid midline) + "top_hole_y": 75.0, # centre y — balanced ~2mm walls to the top edge and the window + "top_hole_seg": 36, # circle resolution + + # material — the print material of record (also drives the weight estimate) + "material": "PETG", # PETG for the build (heat/impact-tolerant for Vegas); PLA is fine for fit tests + "infill": 0.30, # rough effective fill incl. perimeters/top-bottom +} + +# filament density (g/cc) for the weight estimate — PLA ~1.24, PETG ~1.27. +_DENSITY_G_CC = {"PLA": 1.24, "PETG": 1.27} + +# ---------------------------------------------------------------------------- +# tiny mesh kit — triangles as ((x,y,z),(x,y,z),(x,y,z)) +# ---------------------------------------------------------------------------- + +def box(x0, y0, z0, x1, y1, z1): + """Axis-aligned box -> 12 triangles, outward normals.""" + # 8 corners + p = [ + (x0, y0, z0), (x1, y0, z0), (x1, y1, z0), (x0, y1, z0), # bottom 0-3 + (x0, y0, z1), (x1, y0, z1), (x1, y1, z1), (x0, y1, z1), # top 4-7 + ] + # faces as quads (ccw seen from outside) + quads = [ + (0, 3, 2, 1), # bottom (-z) + (4, 5, 6, 7), # top (+z) + (0, 1, 5, 4), # front (-y) + (2, 3, 7, 6), # back (+y) + (1, 2, 6, 5), # right (+x) + (3, 0, 4, 7), # left (-x) + ] + tris = [] + for a, b, c, d in quads: + tris.append((p[a], p[b], p[c])) + tris.append((p[a], p[c], p[d])) + return tris + + +def prism(poly, z0, z1): + """Extrude a CCW 2D polygon [(x,y),...] from z0 to z1 -> closed mesh. + Caps via fan triangulation (poly must be convex for correct caps).""" + tris = [] + n = len(poly) + # walls + for i in range(n): + x0, y0 = poly[i] + x1, y1 = poly[(i + 1) % n] + a = (x0, y0, z0); b = (x1, y1, z0) + c = (x1, y1, z1); d = (x0, y0, z1) + tris.append((a, b, c)) + tris.append((a, c, d)) + # bottom cap (-z, reversed for outward normal) + top cap (+z) + for i in range(1, n - 1): + a = (poly[0][0], poly[0][1], z0) + b = (poly[i][0], poly[i][1], z0) + c = (poly[i + 1][0], poly[i + 1][1], z0) + tris.append((a, c, b)) # bottom faces -z + ta = (poly[0][0], poly[0][1], z1) + tb = (poly[i][0], poly[i][1], z1) + tc = (poly[i + 1][0], poly[i + 1][1], z1) + tris.append((ta, tb, tc)) # top faces +z + return tris + + +def rounded_rect(x0, y0, x1, y1, r, seg): + """CCW polygon of an axis-aligned rect with rounded corners.""" + if r <= 0: + return [(x0, y0), (x1, y0), (x1, y1), (x0, y1)] + pts = [] + # corner centers + sweep ranges (ccw starting bottom-right) + corners = [ + (x1 - r, y0 + r, -90, 0), # bottom-right + (x1 - r, y1 - r, 0, 90), # top-right + (x0 + r, y1 - r, 90, 180), # top-left + (x0 + r, y0 + r, 180, 270), # bottom-left + ] + for cx, cy, a0, a1 in corners: + for s in range(seg + 1): + ang = math.radians(a0 + (a1 - a0) * s / seg) + pts.append((cx + r * math.cos(ang), cy + r * math.sin(ang))) + return pts + + +def disc(cx, cy, r, z0, z1, seg): + """A solid cylinder (used as a negative — but we build positive then skip).""" + poly = [(cx + r * math.cos(2 * math.pi * i / seg), + cy + r * math.sin(2 * math.pi * i / seg)) for i in range(seg)] + return prism(poly, z0, z1) + + +def ensure_ccw(poly): + """Return poly wound counter-clockwise (positive signed area).""" + return poly if signed_area(poly) >= 0 else poly[::-1] + + +def circle_loop(cx, cy, r, k): + """CCW loop of k points on a circle (a hole boundary).""" + return [(cx + r * math.cos(2 * math.pi * i / k), + cy + r * math.sin(2 * math.pi * i / k)) for i in range(k)] + + +def perimeter_sample(poly, k): + """Resample a closed polygon's perimeter into exactly k points, spaced evenly + by arc length, preserving orientation. Lets a hole loop carry the SAME point + count as its outer loop so the two can be stitched index-to-index.""" + n = len(poly) + seglen, total = [], 0.0 + for i in range(n): + x0, y0 = poly[i]; x1, y1 = poly[(i + 1) % n] + d = math.hypot(x1 - x0, y1 - y0); seglen.append(d); total += d + step = total / k + pts, i, acc = [], 0, 0.0 + for j in range(k): + target = j * step + while i < n and acc + seglen[i] < target - 1e-9: + acc += seglen[i]; i += 1 + if i >= n: + i = n - 1 + x0, y0 = poly[i]; x1, y1 = poly[(i + 1) % n] + t = 0.0 if seglen[i] == 0 else (target - acc) / seglen[i] + pts.append((x0 + (x1 - x0) * t, y0 + (y1 - y0) * t)) + return pts + + +def _orient(a, b, c, target): + """Return the triangle wound so its normal points along `target` (dot ≥ 0).""" + nx, ny, nz = tri_normal((a, b, c)) + return (a, b, c) if nx*target[0] + ny*target[1] + nz*target[2] >= 0 else (a, c, b) + + +def annulus_slab(outer, inner, z0, z1): + """Watertight slab of the region between an outer loop and an inner (hole) + loop — both CCW with the SAME point count. Caps tile the ring; outer walls + face outward, inner walls face into the hole. Normals are oriented by + construction, so the result is a closed 2-manifold (use perimeter_sample / + circle_loop to match counts). This is the one primitive behind the display + window, the ear holes, and any future screw holes.""" + assert len(outer) == len(inner), "outer/inner loops must match point count" + k = len(outer) + ocx = sum(p[0] for p in outer) / k; ocy = sum(p[1] for p in outer) / k + icx = sum(p[0] for p in inner) / k; icy = sum(p[1] for p in inner) / k + tris = [] + for i in range(k): + j = (i + 1) % k + oA, oB = outer[i], outer[j] + iA, iB = inner[i], inner[j] + # caps: ring quad [oA, oB, iB, iA] at each z (−z bottom, +z top) + for zz, up in ((z0, (0, 0, -1)), (z1, (0, 0, 1))): + A = (oA[0], oA[1], zz); B = (oB[0], oB[1], zz) + C = (iB[0], iB[1], zz); D = (iA[0], iA[1], zz) + tris.append(_orient(A, B, C, up)) + tris.append(_orient(A, C, D, up)) + # outer wall — normal points away from the outer centroid + tgt = (oA[0] - ocx, oA[1] - ocy, 0) + A = (oA[0], oA[1], z0); B = (oB[0], oB[1], z0) + C = (oB[0], oB[1], z1); D = (oA[0], oA[1], z1) + tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt)) + # inner (hole) wall — normal points toward the hole centroid + tgt = (icx - iA[0], icy - iA[1], 0) + A = (iA[0], iA[1], z0); B = (iB[0], iB[1], z0) + C = (iB[0], iB[1], z1); D = (iA[0], iA[1], z1) + tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt)) + return tris + + +def edge_manifold_issues(tris): + """Count undirected edges NOT shared by exactly two triangles. 0 → the mesh + is a closed 2-manifold (watertight). Stronger than the tri-count==bytes check.""" + cnt = {} + def key(p, q): + a = (round(p[0], 4), round(p[1], 4), round(p[2], 4)) + b = (round(q[0], 4), round(q[1], 4), round(q[2], 4)) + return (a, b) if a <= b else (b, a) + for a, b, c in tris: + for e in (key(a, b), key(b, c), key(c, a)): + cnt[e] = cnt.get(e, 0) + 1 + return sum(1 for v in cnt.values() if v != 2) + + +def signed_area(poly): + a = 0.0 + n = len(poly) + for i in range(n): + x0, y0 = poly[i]; x1, y1 = poly[(i + 1) % n] + a += x0 * y1 - x1 * y0 + return a / 2.0 + + +def tri_normal(t): + (ax, ay, az), (bx, by, bz), (cx, cy, cz) = t + ux, uy, uz = bx - ax, by - ay, bz - az + vx, vy, vz = cx - ax, cy - ay, cz - az + nx, ny, nz = uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx + L = math.sqrt(nx*nx + ny*ny + nz*nz) or 1.0 + return nx/L, ny/L, nz/L + + +def mesh_volume_cc(tris): + """Signed volume via divergence (sum of tetra), mm^3 -> cc.""" + vol = 0.0 + for (a, b, c) in tris: + vol += (a[0]*(b[1]*c[2]-b[2]*c[1]) + - a[1]*(b[0]*c[2]-b[2]*c[0]) + + a[2]*(b[0]*c[1]-b[1]*c[0])) / 6.0 + return abs(vol) / 1000.0 + + +def git_rev(): + """Git short rev of the tree generating these STLs, for the console report + (mirrors the firmware's GIT_REV). A trailing '+' marks uncommitted tracked + changes under hardware/cad/; 'nogit' if not in a repo. Deliberately NOT baked + into the STL header — that would re-stamp every file on each regen.""" + here = os.path.dirname(os.path.abspath(__file__)) + + def _git(args, default=""): + try: + return subprocess.check_output(["git"] + args, cwd=here, + stderr=subprocess.DEVNULL).decode().strip() + except Exception: + return default + + rev = _git(["rev-parse", "--short", "HEAD"], "nogit") + if rev != "nogit": + dirty = subprocess.call(["git", "diff", "--quiet", "HEAD", "--", "."], + cwd=here, stderr=subprocess.DEVNULL) != 0 + if dirty: + rev += "+" + return rev + + +def write_stl(path, tris, header=""): + # binary-STL header is a free 80-byte comment field — stamp it (version label) + head = header.encode("ascii", "replace")[:80] + head = head + b"\0" * (80 - len(head)) + with open(path, "wb") as f: + f.write(head) + f.write(struct.pack("<I", len(tris))) + for t in tris: + nx, ny, nz = tri_normal(t) + f.write(struct.pack("<3f", nx, ny, nz)) + for v in t: + f.write(struct.pack("<3f", *v)) + f.write(struct.pack("<H", 0)) + + +def bbox(tris): + xs = [v[0] for t in tris for v in t] + ys = [v[1] for t in tris for v in t] + zs = [v[2] for t in tris for v in t] + return (max(xs)-min(xs), max(ys)-min(ys), max(zs)-min(zs)) + +# ---------------------------------------------------------------------------- +# tray + boss kit — open surface pieces that compose into ONE closed solid. +# (annulus_slab handles single-hole slabs; a tray is a floor + walls, so it +# needs the floor/wall/rim pieces below to stay watertight without an interior +# cap.) Normals are forced per-triangle via _orient, so winding can't go wrong. +# ---------------------------------------------------------------------------- + +def cavity_depth(layout): + """Internal clearance between the two plate inner faces (front lid ↔ back + floor). OLED zone + the behind-stack; the tray wall height equals this.""" + oled_zone = 6.0 + behind = (6.0 + P["bat_t"]) if layout == "A" else max(6.0, P["bat_t"]) + return oled_zone + behind + P["cavity_pad"] # +pad. A: 6+16+pad, B: 6+10+pad + + +def _fan_cap(poly, z, updir): + """Flat cap of a CONVEX polygon at height z, every tri forced to face updir.""" + tris = [] + a = (poly[0][0], poly[0][1], z) + for i in range(1, len(poly) - 1): + b = (poly[i][0], poly[i][1], z) + c = (poly[i + 1][0], poly[i + 1][1], z) + tris.append(_orient(a, b, c, updir)) + return tris + + +def _ring_cap(outer, inner, z, updir): + """Flat ring between matched outer/inner loops (same point count) at height z.""" + k = len(outer) + tris = [] + for i in range(k): + j = (i + 1) % k + A = (outer[i][0], outer[i][1], z); B = (outer[j][0], outer[j][1], z) + C = (inner[j][0], inner[j][1], z); D = (inner[i][0], inner[i][1], z) + tris.append(_orient(A, B, C, updir)); tris.append(_orient(A, C, D, updir)) + return tris + + +def _side_wall(loop, z0, z1, outward, skip=None): + """Vertical wall around a closed loop, z0→z1. Normals face away from the loop + centroid (outward=True) or toward it (outward=False). `skip(a, b)` → True drops + the edge between loop points a and b (v1.20: the +x straight run is skipped so a + windowed face — `_vwall_with_window` — can replace it with the USB-C cutout).""" + k = len(loop) + cx = sum(p[0] for p in loop) / k; cy = sum(p[1] for p in loop) / k + tris = [] + for i in range(k): + j = (i + 1) % k + if skip and skip(loop[i], loop[j]): + continue + A = (loop[i][0], loop[i][1], z0); B = (loop[j][0], loop[j][1], z0) + C = (loop[j][0], loop[j][1], z1); D = (loop[i][0], loop[i][1], z1) + mx = (loop[i][0] + loop[j][0]) / 2.0; my = (loop[i][1] + loop[j][1]) / 2.0 + tgt = (mx - cx, my - cy, 0) if outward else (cx - mx, cy - my, 0) + tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt)) + return tris + + +def boss_positions(W, H): + """Four screw bosses, one per corner, inset just inside the perimeter walls.""" + d = P["wall_t"] + P["boss_r"] + return [(d, d), (W - d, d), (d, H - d), (W - d, H - d)] + + +def screw_bosses(W, H, z0, z1): + """M3 corner bosses: closed tubes (post + bore) standing on the cavity floor. + The bore bottoms out on the floor below (blind hole from the top), so a screw + through the lid threads straight in. "fastener" picks the bore — a narrow + self-tap pilot (prototype) or a wider heat-set-insert bore (production); the + post is the same size either way. Each is its own watertight solid that + overlaps the floor — same compose-by-overlap trick as the lanyard ears.""" + bs, seg = P["boss_r"], P["boss_seg"] + hr = P["boss_hole_insert"] if P["fastener"] == "insert" else P["boss_hole_selftap"] + tris = [] + for cx, cy in boss_positions(W, H): + outer = ensure_ccw(circle_loop(cx, cy, bs, seg)) + inner = ensure_ccw(circle_loop(cx, cy, hr, seg)) + tris += annulus_slab(outer, inner, z0, z1) + return tris + + +# ---------------------------------------------------------------------------- +# polygon-with-holes triangulation — ear clipping + hole bridging. Lets a flat +# face carry MULTIPLE true through-holes (screw clearance, USB, vents) where the +# single-hole annulus can't. Holes are CW loops inside a CCW outer loop. +# ---------------------------------------------------------------------------- +_EPS = 1e-9 + +def _tri_area2(a, b, c): + return (b[0]-a[0])*(c[1]-a[1]) - (c[0]-a[0])*(b[1]-a[1]) + + +def _strictly_inside(p, a, b, c): + d1=_tri_area2(p,a,b); d2=_tri_area2(p,b,c); d3=_tri_area2(p,c,a) + if abs(d1)<_EPS or abs(d2)<_EPS or abs(d3)<_EPS: + return False # on an edge / coincident → doesn't block an ear + return (d1>0)==(d2>0)==(d3>0) + + +def _earclip(poly): + """Triangulate a simple CCW polygon to coord-triples (ear clipping, O(n²)).""" + pts=list(poly); idx=list(range(len(pts))); tris=[]; guard=0 + while len(idx)>3 and guard<50000: + guard+=1; made=False + for ii in range(len(idx)): + i0,i1,i2=idx[(ii-1)%len(idx)],idx[ii],idx[(ii+1)%len(idx)] + a,b,c=pts[i0],pts[i1],pts[i2] + if _tri_area2(a,b,c)<=_EPS: # reflex / degenerate + continue + if any(_strictly_inside(pts[j],a,b,c) for j in idx if j not in (i0,i1,i2)): + continue + tris.append((a,b,c)); idx.pop(ii); made=True; break + if not made: + break + if len(idx)==3: + tris.append((pts[idx[0]],pts[idx[1]],pts[idx[2]])) + return tris + + +def _on_seg(p, a, b): + """p on the CLOSED segment a-b (collinear within tol AND inside the bbox).""" + if abs(_tri_area2(a, b, p)) > 1e-7: + return False + return (min(a[0], b[0]) - 1e-7 <= p[0] <= max(a[0], b[0]) + 1e-7 and + min(a[1], b[1]) - 1e-7 <= p[1] <= max(a[1], b[1]) + 1e-7) + + +def _proper_cross(a, b, c, d): + """True iff segments a-b and c-d cross at a point interior to BOTH (strict).""" + d1 = _tri_area2(c, d, a); d2 = _tri_area2(c, d, b) + d3 = _tri_area2(a, b, c); d4 = _tri_area2(a, b, d) + return (((d1 > _EPS and d2 < -_EPS) or (d1 < -_EPS and d2 > _EPS)) and + ((d3 > _EPS and d4 < -_EPS) or (d3 < -_EPS and d4 > _EPS))) + + +def _pt_in_poly(p, poly): + """Even-odd ray cast: True iff p is strictly inside the polygon.""" + ins = False; n = len(poly) + for i in range(n): + a, b = poly[i], poly[(i + 1) % n] + if (a[1] > p[1]) != (b[1] > p[1]): + xi = a[0] + (p[1] - a[1]) / (b[1] - a[1]) * (b[0] - a[0]) + if p[0] < xi: + ins = not ins + return ins + + +def _valid_diagonal(poly, i, j): + """True iff poly[i]-poly[j] is a real internal diagonal: it crosses no edge, no other + vertex lies on it, and its midpoint is inside the polygon. This is what makes the + triangulator robust to the keyhole slits — a diagonal that would span a zero-width + slit fails the on-segment / midpoint test, where a plain convex-ear test does not.""" + n = len(poly); a, b = poly[i], poly[j] + for k in range(n): + if k in (i, j) or (k + 1) % n in (i, j): + continue # edge incident to a diagonal endpoint + u, v = poly[k], poly[(k + 1) % n] + if _proper_cross(a, b, u, v): + return False + if _on_seg(u, a, b): # a non-incident vertex sits on the diagonal + return False + return _pt_in_poly(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0), poly) + + +def _dedup(poly): + """Drop consecutive (and wrap-around) duplicate vertices — the zero-length edges the + keyhole bridges/slits leave behind, which otherwise stall the triangulator.""" + out = [] + for p in poly: + if not out or abs(p[0] - out[-1][0]) > 1e-7 or abs(p[1] - out[-1][1]) > 1e-7: + out.append(p) + while len(out) > 1 and abs(out[0][0] - out[-1][0]) <= 1e-7 and abs(out[0][1] - out[-1][1]) <= 1e-7: + out.pop() + return out + + +def _triangulate(poly): + """Triangulate a (weakly-)simple CCW polygon to coord-triples, Steiner-free (uses only + input vertices, so the cap keeps matching the walls/cells that share its loops). Ear-clips + where it can; when it STALLS — which converging v1.22 keyhole slits cause in one cap (the + front grille: vent notches flanking the button, plus the window) — it splits on a valid + internal diagonal and recurses. Both halves share that diagonal, so the mesh stays + watertight. Supersedes plain _earclip, which can't finish such a cap. CAVEAT: the diagonal- + split path still mis-meshes some thin/tall single-/two-column notches — cap_with_holes + guards against shipping those via an area check. Replace with a vetted CDT to lift that.""" + poly = _dedup(poly) + if len(poly) < 3: + return [] + tris = [] + work = poly + while len(work) > 3: + m = len(work); clipped = False + for ii in range(m): + i0, i1, i2 = (ii - 1) % m, ii, (ii + 1) % m + a, b, c = work[i0], work[i1], work[i2] + if _tri_area2(a, b, c) <= _EPS: # reflex / collinear + continue + if any(k not in (i0, i1, i2) and _strictly_inside(work[k], a, b, c) for k in range(m)): + continue + if not _valid_diagonal(work, i0, i2): # blocks slit-spanning ears + continue + tris.append((a, b, c)); work = work[:ii] + work[ii + 1:]; clipped = True; break + if clipped: + continue + # stalled: split on a valid diagonal and recurse — the two halves share the cut edge + split = None + for ai in range(m): + for bi in range(ai + 2, m): + if ai == 0 and bi == m - 1: + continue + if _valid_diagonal(work, ai, bi): + split = (ai, bi); break + if split: + break + if split is None: + break # unreachable for a valid polygon + ai, bi = split + return tris + _triangulate(work[ai:bi + 1]) + _triangulate(work[bi:] + work[:ai + 1]) + if len(work) == 3: + tris.append((work[0], work[1], work[2])) + return tris + + +def _hole_loop(cx, cy, r, seg): + """CW circle loop — a hole boundary for cap_with_holes.""" + return list(reversed(circle_loop(cx, cy, r, seg))) + + +def _bridge_holes(outer, holes): + """Merge CW holes into a CCW outer loop via short corner-facing slits → one + simple CCW polygon. Each hole bridges outward to its nearest outer vertex + (using the hole vertex facing it), so the slit can't cut back across the hole.""" + assign={} + for hole in holes: + hx=sum(p[0] for p in hole)/len(hole); hy=sum(p[1] for p in hole)/len(hole) + oi=min(range(len(outer)), key=lambda k:(outer[k][0]-hx)**2+(outer[k][1]-hy)**2) + ov=outer[oi] + hj=min(range(len(hole)), key=lambda k:(hole[k][0]-ov[0])**2+(hole[k][1]-ov[1])**2) + assign.setdefault(oi,[]).append(hole[hj:]+hole[:hj]) + merged=[] + for i,ov in enumerate(outer): + merged.append(ov) + for rot in assign.get(i,[]): + merged += rot + [rot[0], ov] + return merged + + +def cap_with_holes(outer, holes, z, updir): + """Flat cap of a CCW outer loop with CW holes at height z, facing updir. + Falls back to a plain fan when there are no holes, so it's a drop-in cap.""" + if not holes: + return _fan_cap(outer, z, updir) + poly = _dedup(_bridge_holes(outer, holes)) + tris2d = _earclip(poly) + # earclip can STALL (too few tris) or, worse, run to full count yet place a triangle + # across a keyhole slit (covering a vent cell) — caught by comparing the triangulated + # area to the polygon's own area. Either failure → the robust diagonal-split path. + want = abs(signed_area(poly)) + got = sum(abs(_tri_area2(a, b, c)) for a, b, c in tris2d) / 2.0 + if len(tris2d) < len(poly) - 2 or abs(got - want) > 1e-3 * max(1.0, want): + tris2d = _triangulate(poly) + return [_orient((a[0],a[1],z),(b[0],b[1],z),(c[0],c[1],z),updir) for a,b,c in tris2d] + + +def honeycomb_region(x0, y0, x1, y1, z0, z1, flats, wall, keep=None): + """Honeycomb-vented slab section filling [x0,x1]x[y0,y1] from z0 to z1. Tiled into + a grid of cells, each capped top+bottom as 'cell rect minus one hex' — a SINGLE + hole per cell, so hole-bridging is always valid (cap_with_holes is robust for one + hole, NOT for a whole field of them). Adjacent cells share corner-matched edges, so + the field is watertight by construction. keep(cx,cy)->True leaves a cell solid (no + hex) for keepouts. Returns (tris, meta) where meta=(x0,y0,x1,y1,nx,ny,cw,ch) — the + grid metrics the host slab uses to splice this region in as a notch (splice_vent_notch).""" + pitch = flats + wall + nx = max(1, int(round((x1 - x0) / pitch))) + ny = max(1, int(round((y1 - y0) / pitch))) + cw = (x1 - x0) / nx + ch = (y1 - y0) / ny + r = (min(cw, ch) - wall) / math.sqrt(3.0) # circumradius → across-flats = min(cw,ch)-wall + tris = [] + for j in range(ny): + for i in range(nx): + ax, ay = x0 + i * cw, y0 + j * ch + bx, by = ax + cw, ay + ch + rect = [(ax, ay), (bx, ay), (bx, by), (ax, by)] # CCW cell + cx, cy = (ax + bx) / 2.0, (ay + by) / 2.0 + holes = [] if (keep and keep(cx, cy)) else [_hole_loop(cx, cy, r, 6)] + tris += cap_with_holes(rect, holes, z1, (0, 0, 1)) # top face (up) + tris += cap_with_holes(rect, holes, z0, (0, 0, -1)) # bottom face (down) + for h in holes: + tris += _side_wall(h, z0, z1, False) # hex wall, faces into the hole + return tris, (x0, y0, x1, y1, nx, ny, cw, ch) # grid metrics for the notch splice + + +def splice_vent_notch(outline, meta, slit_idx): + """Splice the honeycomb region's rectangular boundary into a CCW floor `outline` as + a NOTCH (concavity) reachable by one zero-width slit rising from the outline's bottom + edge at the vent's slit_idx column — instead of a hole. So the floor cap only has to + bridge the few remaining real holes (the corner screws), which it does fine; the big + central opening that crossed bridge-slits is gone. Vent edges are subdivided to the + cell grid so the cells stitch with no T-junctions.""" + x0, y0, x1, y1, nx, ny, cw, ch = meta + si = max(1, min(nx - 1, slit_idx)) + slit_x = x0 + si * cw + B = [(x0 + i * cw, y0) for i in range(nx + 1)] # bottom edge L→R + L = [(x0, y0 + j * ch) for j in range(ny + 1)] # left edge B→T + T = [(x0 + i * cw, y1) for i in range(nx + 1)] # top edge L→R + R = [(x1, y0 + j * ch) for j in range(ny + 1)] # right edge B→T + vloop = [B[i] for i in range(si, -1, -1)] # B[si]..B[0] (=L[0]) + vloop += [L[j] for j in range(1, ny + 1)] # L[1]..L[ny] (=T[0]) + vloop += [T[i] for i in range(1, nx + 1)] # T[1]..T[nx] (=R[ny]) + vloop += [R[j] for j in range(ny - 1, -1, -1)] # R[ny-1]..R[0] (=B[nx]) + vloop += [B[i] for i in range(nx - 1, si, -1)] # B[nx-1]..B[si+1] (CW around the vent) + ymin = min(p[1] for p in outline); eps = 1e-6 + base = (slit_x, ymin) + for k, pp in enumerate(outline): # base already a shared vertex (preferred) + if abs(pp[0] - slit_x) < eps and abs(pp[1] - ymin) < eps: + return outline[:k + 1] + vloop + [B[si], base] + outline[k + 1:] + for k in range(len(outline)): # else splice it onto the bottom segment + p, q = outline[k], outline[(k + 1) % len(outline)] + if abs(p[1] - ymin) < eps and abs(q[1] - ymin) < eps and \ + min(p[0], q[0]) - eps < slit_x < max(p[0], q[0]) + eps: + return outline[:k + 1] + [base] + vloop + [B[si], base] + outline[k + 1:] + return outline # no bottom edge found → unchanged + + +def _with_base(outline, slit_x): + """Insert a collinear vertex at (slit_x, ymin) on the outline's bottom edge so the + vent-slit base is SHARED by the floor cap and the skirt / walls / rim that touch the + same edge — without it the cap subdivides the bottom edge and they don't (T-junction).""" + ymin = min(p[1] for p in outline); eps = 1e-6 + for k in range(len(outline)): + p, q = outline[k], outline[(k + 1) % len(outline)] + if abs(p[1] - ymin) < eps and abs(q[1] - ymin) < eps and \ + min(p[0], q[0]) - eps < slit_x < max(p[0], q[0]) + eps: + return outline[:k + 1] + [(slit_x, ymin)] + outline[k + 1:] + return outline + + +def _with_top(outline, hole_x): + """Insert a collinear vertex at (hole_x, ymax) on the outline's TOP edge — the mirror of + `_with_base`. Gives each top-band through-hole a nearby outer vertex straight above it, so + cap_with_holes bridges it with a short vertical slit instead of a long diagonal to a far + corner (which, when two holes share that corner, crosses itself → non-manifold).""" + ymax = max(p[1] for p in outline); eps = 1e-6 + for k in range(len(outline)): + p, q = outline[k], outline[(k + 1) % len(outline)] + if abs(p[1] - ymax) < eps and abs(q[1] - ymax) < eps and \ + min(p[0], q[0]) - eps < hole_x < max(p[0], q[0]) + eps: + return outline[:k + 1] + [(hole_x, ymax)] + outline[k + 1:] + return outline + + +# ---------------------------------------------------------------------------- +# plate builders +# ---------------------------------------------------------------------------- + +def plate_outline(layout): + """Return (W, H) of the outer plate for the given layout, OLED-driven width, + content-driven height.""" + em = P["edge_margin"] + W = P["oled_pcb_w"] + 2 * em + if layout == "A": + # face = OLED + margins (battery/board hide behind) + H = P["oled_pcb_h"] + 2 * em + else: + # B coplanar: OLED on top; FireBeetle + battery sit BEHIND it (in z) and only the + # overage pokes below. v1.33 portrait — the FB (left) is BOTTOM-PINNED at the USB + # wall and a vertical battery (right) sits beside it; their ROW width (fb_h + bat_h = + # 25+34 = 59) stays under the OLED's 70 so the OLED keeps driving W (no growth). We + # deliberately keep the v1.32 PLATE HEIGHT rather than growing to the raw 60mm board + # length: bottom-pinned, the 60mm FB tops out ~19mm below the +y wall at H=83 (ample + # antenna margin), AND 83 is the tallest height the validated front-vent grille + # triangulates cleanly (84+ trips a thin-notch bug in the cap triangulator — see _triangulate). + electronics_col = P["fb_h"] + P["bat_h"] # 25.4 + 34 = 59.4 → H 83 (both boards fit within it) + H = max(P["oled_pcb_h"], electronics_col) + 2 * em + return W, H + + +def ear(x, y, t): + """A lanyard ear with a round through-hole. (x, y) is its lower-left corner; + it sits ear_w × ear_h above the plate's top edge. Watertight via annulus.""" + ew, eh = P["ear_w"], P["ear_h"] + hr, hseg = P["ear_hole_r"], P["ear_hole_seg"] + rect = ensure_ccw([(x, y), (x + ew, y), (x + ew, y + eh), (x, y + eh)]) + outer = perimeter_sample(rect, hseg) + inner = ensure_ccw(circle_loop(x + ew / 2.0, y + eh / 2.0, hr, hseg)) + return annulus_slab(outer, inner, 0, t) + + +def _swap_to_x(tris): + """Re-map every vertex (a,b,c)→(c,a,b): a Z-extruded build whose 2D loops are (worldY, worldZ) + becomes a tunnel running along worldX. The swap is cyclic (det +1), so annulus_slab's + by-construction normals survive (same trick as the v1.38 side-wall vents).""" + return [((a[2], a[0], a[1]), (b[2], b[0], b[1]), (c[2], c[0], c[1])) for a, b, c in tris] + + +def lanyard_tab(x, y, z0, z1): + """Lanyard tab (ear_w × ear_h above the plate top edge) extruded z0→z1 (the FULL case depth, vs + the old flat ear), with rounded TOP corners (ear_corner_r) to mirror the badge. With `ear_bore`, + a side-to-side (X) rounded-rectangular bore — the lanyard interface — is cut through the lower + block, centred at MID-DEPTH (≈ the depth-wise CoG) so the cord sits over the CoG and the badge + hangs face-vertical (no pitch tip). Built as region A (the bore tube — an X-extruded washer via + annulus_slab + coord-swap) ∪ region B (the rounded cap); each a closed solid overlapping the + other (compose-by-overlap, watertight).""" + ew, eh = P["ear_w"], P["ear_h"] + r = min(P["ear_corner_r"], ew / 2.0, eh - 0.5) + seg = P["corner_seg"] + cyr = y + eh - r # top-corner arc-centre height + + def cap_poly(ybot): # rounded-top footprint from a straight bottom edge + poly = [(x, ybot), (x + ew, ybot)] + for s in range(seg + 1): # top-RIGHT arc 0°→90° + a = math.radians(90.0 * s / seg) + poly.append((x + ew - r + r * math.cos(a), cyr + r * math.sin(a))) + for s in range(seg + 1): # top-LEFT arc 90°→180° + a = math.radians(90.0 + 90.0 * s / seg) + poly.append((x + r + r * math.cos(a), cyr + r * math.sin(a))) + return ensure_ccw(poly) + + if not P["ear_bore"]: + return prism(cap_poly(y), z0, z1) # solid block (no interface yet) + + # region A — lower block y[y, y+eh-r], z[z0,z1], with the side-to-side bore (an X tunnel) + by0 = y + P["ear_bore_y0"]; by1 = by0 + P["ear_bore_y"] + bz0 = z0 + (z1 - z0) / 2.0 - P["ear_bore_z"] / 2.0; bz1 = bz0 + P["ear_bore_z"] # centred at mid-depth + yA1 = y + eh - r # block top = where the corner rounding starts + N = 64 + outer = perimeter_sample(ensure_ccw([(y, z0), (yA1, z0), (yA1, z1), (y, z1)]), N) # (worldY,worldZ) rect + inner = perimeter_sample(ensure_ccw(rounded_rect(by0, bz0, by1, bz1, P["ear_bore_r"], 8)), N) + A = _swap_to_x(annulus_slab(outer, inner, x, x + ew)) # tunnel along worldX + # region B — rounded cap above the bore (down to the bore top → overlaps A, no coincident faces) + B = prism(cap_poly(by1), z0, z1) + return A + B + + +# 7-segment glyphs — lit segments per char (a=top b=up-R c=lo-R d=bottom e=lo-L f=up-L g=mid) +_SEG7 = {"0":"abcdef", "1":"bc", "2":"abdeg", "3":"abcdg", "4":"bcfg", "5":"acdfg", + "6":"acdefg", "7":"abc", "8":"abcdefg", "9":"abcdfg", + "A":"abcefg", "B":"cdefg"} # A; B as 7-seg lowercase 'b' (uppercase B = 8) + +def _seg_boxes(ch, x0, y0, z0, z1): + """Raised boxes for one 7-seg char at cell origin (x0,y0). '.' → a small dot. + Segments are gapped apart so no two boxes share an exact edge (stays manifold, + like the bosses) — they only sit on the face cap below them.""" + w, h, s = P["imprint_digit_w"], P["imprint_digit_h"], P["imprint_seg"] + g = 0.3 + if ch == ".": + return box(x0, y0, z0, x0 + s, y0 + s, z1) + rects = { + "a": (x0+s+g, y0+h-s, x0+w-s-g, y0+h), + "g": (x0+s+g, y0+(h-s)/2, x0+w-s-g, y0+(h+s)/2), + "d": (x0+s+g, y0, x0+w-s-g, y0+s), + "f": (x0, y0+h/2+g, x0+s, y0+h-g), + "b": (x0+w-s, y0+h/2+g, x0+w, y0+h-g), + "e": (x0, y0+g, x0+s, y0+h/2-g), + "c": (x0+w-s, y0+g, x0+w, y0+h/2-g), + } + tris = [] + for k in _SEG7.get(ch, ""): + ax, ay, bx, by = rects[k] + tris += box(ax, ay, z0, bx, by, z1) + return tris + +def imprint_text(text, x0, y0, z_face): + """Raised 7-seg `text` starting at (x0, y0) on a face at z_face (raised +z).""" + if not P["imprint"]: + return [] + z0, z1 = z_face, z_face + P["imprint_h"] + adv, dotadv = P["imprint_digit_w"] + 1.0, P["imprint_seg"] + 1.0 + tris, x = [], x0 + for ch in text: + tris += _seg_boxes(ch, x, y0, z0, z1) + x += dotadv if ch == "." else adv + return tris + + +def _imprint_run_len(text): + """Width (mm) of an imprint_text run — for centring. Sums per-char advances and + drops the trailing gap after the last glyph (cf. the advances in imprint_text).""" + adv, dotadv = P["imprint_digit_w"] + 1.0, P["imprint_seg"] + 1.0 + w = sum(dotadv if ch == "." else adv for ch in text) + return max(0.0, w - 1.0) + + +# ---------------------------------------------------------------------------- +# 5x7 pixel font — for parametric raised text on the front face (top + sides). +# Each glyph is 7 rows (top→bottom) x 5 cols, '1' = a lit pixel. Lit pixels become +# raised boxes (box-soup native, watertight by construction like the 7-seg imprint). +# ---------------------------------------------------------------------------- +_FONT5X7 = { + " ": ["00000","00000","00000","00000","00000","00000","00000"], + "A": ["01110","10001","10001","11111","10001","10001","10001"], + "B": ["11110","10001","10001","11110","10001","10001","11110"], + "C": ["01110","10001","10000","10000","10000","10001","01110"], + "D": ["11110","10001","10001","10001","10001","10001","11110"], + "E": ["11111","10000","10000","11110","10000","10000","11111"], + "F": ["11111","10000","10000","11110","10000","10000","10000"], + "G": ["01110","10001","10000","10111","10001","10001","01111"], + "H": ["10001","10001","10001","11111","10001","10001","10001"], + "I": ["01110","00100","00100","00100","00100","00100","01110"], + "J": ["00111","00010","00010","00010","00010","10010","01100"], + "K": ["10001","10010","10100","11000","10100","10010","10001"], + "L": ["10000","10000","10000","10000","10000","10000","11111"], + "M": ["10001","11011","10101","10101","10001","10001","10001"], + "N": ["10001","10001","11001","10101","10011","10001","10001"], + "O": ["01110","10001","10001","10001","10001","10001","01110"], + "P": ["11110","10001","10001","11110","10000","10000","10000"], + "Q": ["01110","10001","10001","10001","10101","10010","01101"], + "R": ["11110","10001","10001","11110","10100","10010","10001"], + "S": ["01111","10000","10000","01110","00001","00001","11110"], + "T": ["11111","00100","00100","00100","00100","00100","00100"], + "U": ["10001","10001","10001","10001","10001","10001","01110"], + "V": ["10001","10001","10001","10001","10001","01010","00100"], + "W": ["10001","10001","10001","10101","10101","11011","10001"], + "X": ["10001","10001","01010","00100","01010","10001","10001"], + "Y": ["10001","10001","01010","00100","00100","00100","00100"], + "Z": ["11111","00001","00010","00100","01000","10000","11111"], + "0": ["01110","10001","10011","10101","11001","10001","01110"], + "1": ["00100","01100","00100","00100","00100","00100","01110"], + "2": ["01110","10001","00001","00010","00100","01000","11111"], + "3": ["11111","00010","00100","00010","00001","10001","01110"], + "4": ["00010","00110","01010","10010","11111","00010","00010"], + "5": ["11111","10000","11110","00001","00001","10001","01110"], + "6": ["00110","01000","10000","11110","10001","10001","01110"], + "7": ["11111","00001","00010","00100","01000","01000","01000"], + "8": ["01110","10001","10001","01110","10001","10001","01110"], + "9": ["01110","10001","10001","01111","00001","00010","01100"], + "-": ["00000","00000","00000","11111","00000","00000","00000"], + ".": ["00000","00000","00000","00000","00000","01100","01100"], + "/": ["00001","00010","00010","00100","01000","01000","10000"], + "!": ["00100","00100","00100","00100","00100","00000","00100"], + ":": ["00000","01100","01100","00000","01100","01100","00000"], + "+": ["00000","00100","00100","11111","00100","00100","00000"], + "#": ["01010","01010","11111","01010","11111","01010","01010"], + # lowercase — 8 rows: baseline stays at row 6 (shared with the 7-row caps/digits), an + # extra row 7 carries descenders (g j p q y). x-height letters fill rows 2-6, ascenders + # (b d f h k l t) go full height. pixel_text maps row 7 to -px, so they hang below. + "a": ["00000","00000","01110","00001","01111","10001","01111","00000"], + "b": ["10000","10000","10000","11110","10001","10001","11110","00000"], + "c": ["00000","00000","01110","10000","10000","10000","01110","00000"], # open right side (no col-4 closers) so it doesn't read as 'o' when printed + "d": ["00001","00001","00001","01111","10001","10001","01111","00000"], + "e": ["00000","00000","01110","10001","11111","10000","01110","00000"], + "f": ["00110","01000","11110","01000","01000","01000","01000","00000"], + "g": ["00000","00000","01111","10001","10001","01111","00001","01110"], + "h": ["10000","10000","10000","11110","10001","10001","10001","00000"], + "i": ["00100","00000","00100","00100","00100","00100","00100","00000"], + "j": ["00010","00000","00010","00010","00010","00010","10010","01100"], + "k": ["10000","10000","10010","10100","11000","10100","10010","00000"], + "l": ["01100","00100","00100","00100","00100","00100","01110","00000"], + "m": ["00000","00000","11010","10101","10101","10101","10101","00000"], + "n": ["00000","00000","10110","11001","10001","10001","10001","00000"], + "o": ["00000","00000","01110","10001","10001","10001","01110","00000"], + "p": ["00000","00000","11110","10001","10001","11110","10000","10000"], + "q": ["00000","00000","01111","10001","10001","01111","00001","00001"], + "r": ["00000","00000","10110","11001","10000","10000","10000","00000"], + "s": ["00000","00000","01111","10000","01110","00001","11110","00000"], + "t": ["01000","01000","11110","01000","01000","01001","00110","00000"], + "u": ["00000","00000","10001","10001","10001","10011","01101","00000"], + "v": ["00000","00000","10001","10001","10001","01010","00100","00000"], + "w": ["00000","00000","10001","10001","10101","10101","01010","00000"], + "x": ["00000","00000","10001","01010","00100","01010","10001","00000"], + "y": ["00000","00000","10001","10001","10001","01111","00001","01110"], + "z": ["00000","00000","11111","00010","00100","01000","11111","00000"], +} + + +def _text_run_len(text, px, col_gap): + """Length of `text` along the run direction (mm) — 5-wide glyphs spaced by col_gap.""" + n = len(text) + return ((n * (5 + col_gap) - col_gap) * px) if n else 0.0 + + +def pixel_text(text, ox, oy, run, rise, z0, z1, px, col_gap=1.0, row_gap=0.2): + """Raised 5x7 pixel `text` on a face, z0→z1. `run`=(rx,ry) is the unit direction along the + text line (also each glyph's column/width axis); `rise`=(ux,uy) the unit direction along the + glyph rows (height); both must be axis-aligned (±x/±y). Horizontal text: run=(1,0),rise=(0,1). + Vertical: run=(0,±1),rise=(∓1,0). Lit pixels are merged into per-row run boxes (solid strokes) + with a thin row_gap so no two boxes share an edge (stays manifold, like the imprint segments); + a sub-nozzle gap prints as a solid block. Unknown chars advance as a space.""" + tris = [] + bx, by = ox, oy + for ch in text: + glyph = _FONT5X7.get(ch) or _FONT5X7.get(ch.upper()) # exact case first, else fold to caps + if glyph: + for row in range(len(glyph)): # 7-row caps/digits or 8-row lowercase + line, col = glyph[row], 0 + while col < 5: + if line[col] == "1": + c0 = col + while col < 5 and line[col] == "1": + col += 1 + rs0 = (6 - row) * px # this row's band along rise + corners = [(c0 * px, rs0), (col * px, rs0), + (c0 * px, rs0 + px - row_gap), (col * px, rs0 + px - row_gap)] + xs = [bx + rl * run[0] + rsv * rise[0] for rl, rsv in corners] + ys = [by + rl * run[1] + rsv * rise[1] for rl, rsv in corners] + tris += box(min(xs), min(ys), z0, max(xs), max(ys), z1) + else: + col += 1 + bx += (5 + col_gap) * px * run[0] + by += (5 + col_gap) * px * run[1] + return tris + + +# ASCII-house maker's mark (v1.50) — a bitmap grid, '#'=raised pixel, top row first. +# CAPPED chimney at the top-right corner (the body's right wall carries up as its outer +# wall); gable roof; one window. Rendered by pixel_art as raised boxes (box-soup native, +# watertight like pixel_text). 12 wide x 10 tall. +_MAKER_HOUSE = [ + "...####..###", + "..#....#.#.#", + ".#......##.#", + "#........#.#", + "############", + "#..........#", + "#..######..#", + "#..#....#..#", + "#..#....#..#", + "############", +] + + +def pixel_art(grid, ox, oy, z0, z1, px, row_gap=0.2): + """Raised bitmap `grid` (rows of '#'/'1' = lit, top→bottom) on a face, z0→z1. (ox,oy) + is the art's bottom-left; +x runs along columns, +y up the rows. Lit pixels merge into + per-row run boxes with a thin row_gap so no two boxes share an edge (stays manifold — + the same trick as pixel_text / the imprint segments).""" + tris = [] + n = len(grid) + for r, line in enumerate(grid): + yb = (n - 1 - r) * px # row 0 = top row + col, w = 0, len(line) + while col < w: + if line[col] in "1#": + c0 = col + while col < w and line[col] in "1#": + col += 1 + tris += box(ox + c0 * px, oy + yb, z0, + ox + col * px, oy + yb + px - row_gap, z1) + else: + col += 1 + return tris + + +def guide_collar(cx, cy, z_face, top_gaps=None): + """Short ridge frame on the lid's inner face around the OLED PCB outline, for + XY registration during assembly (v1.10). v1.15: rebuilt from overlapping BOXES + (was a closed annulus) so the header edge carries a NOTCH — the module's pin + header exits through a gap instead of fouling the wall. Viewer-right = model + −x on the lid (see the mirror note in PARAMS), so the notch lives on −x. + Boxes overlap at the corners but the top/bottom walls are inset 0.6 where + they meet the side walls — no two boxes share an exact face/edge (coincident + geometry breaks the manifold check; same trick as the imprint segments). + v1.41: `top_gaps` = [(x_centre, half_width), …] cuts those x-spans out of the TOP + bar so a top-band through-hole doesn't leave collar fragments poking into it — + the bar survives as the segments between the gaps (OLED still registers on the + pegs + the 3 other sides + the remaining top segments).""" + if not P["guide_ridges"]: + return [] + gc, gt = P["guide_clearance"], P["guide_t"] + hw = P["oled_pcb_w"] / 2.0 + gc + P["collar_extra_w"] / 2.0 + hh = P["oled_pcb_h"] / 2.0 + gc + P["collar_extra_h"] / 2.0 + z0, z1 = z_face, z_face + P["guide_h"] + x0, x1 = cx - hw, cx + hw # collar INNER faces + y0, y1 = cy - hh, cy + hh + ov = 0.6 # corner overlap inset + nw2 = P["oled_header_notch_w"] / 2.0 + tris = [] + tris += box(x0 - gt, y0 - gt, z0, x0, y1 + gt, z1) # −x wall (viewer-LEFT), full + tris += box(x1, y0 - gt, z0, x1 + gt, cy - nw2, z1) # +x wall below the notch + tris += box(x1, cy + nw2, z0, x1 + gt, y1 + gt, z1) # +x wall above the notch + tris += box(x0 - gt + ov, y0 - gt, z0, x1 + gt - ov, y0, z1) # bottom wall + # top wall — split into the segments left clear by top_gaps (else one solid bar) + txl, txr = x0 - gt + ov, x1 + gt - ov + cuts = sorted((max(txl, g0 - g1), min(txr, g0 + g1)) for g0, g1 in (top_gaps or [])) + cur = txl + for clo, chi in cuts: + if clo > cur + 0.5: + tris += box(cur, y1, z0, clo, y1 + gt, z1) + cur = max(cur, chi) + if txr > cur + 0.5: + tris += box(cur, y1, z0, txr, y1 + gt, z1) + return tris + + +def oled_pegs(cx, cy, z_face): + """Four Ø-oled_peg_d posts on the lid's inner face at the OLED's measured + mounting pattern (checklist #13). The module drops over them — glass through + the window, PCB flush on the lid — then the tips get heat-staked (or stay + friction-fit). Each peg is a closed cylinder overlapping the lid cap, the + same compose-by-overlap as the bosses.""" + if not P["oled_pegs"]: + return [] + r = P["oled_peg_d"] / 2.0 + dx, dy = P["oled_mount_w"] / 2.0, P["oled_mount_h"] / 2.0 + tris = [] + for sx in (-1, 1): + for sy in (-1, 1): + tris += disc(cx + sx * dx, cy + sy * dy, r, + z_face, z_face + P["oled_peg_h"], 16) + return tris + + +def fb_board_center(layout, W, H): + """FireBeetle centre in the face plane. B portrait (v1.33): mounted top-LEFT, USB + short edge facing the −y/bottom wall (the 2.0mm connector overhang reaching the wall + inner face), antenna short edge getting the +y margin. B landscape (v1.32): top edge + on the OLED's top line, USB short edge toward the +x wall. A (backup): plate centre.""" + if layout == "A": + return W / 2.0, H / 2.0 + if P["fb_orient"] == "portrait": + # x: board (fb_h wide) seated at the left edge_margin, clear of the corner bosses. + # y: USB end at the bottom wall — cy = wall + overhang + half the board LENGTH. + cx = P["edge_margin"] + P["fb_h"] / 2.0 + cy = P["wall_t"] + P["fb_usb_overhang"] + P["fb_w"] / 2.0 + return cx, cy + cx = W - P["wall_t"] - P["fb_usb_overhang"] - P["fb_w"] / 2.0 + cy = (H - P["edge_margin"]) - P["fb_h"] / 2.0 + return cx, cy + + +def fb_standoff_positions(layout, W, H): + """The four M2 standoff centres at the FireBeetle's CAD-exact hole pattern (±28.4 along + the board LENGTH, ±11.1 across the WIDTH), rotated for the board's orientation. Single + source of truth so the standoff bosses and the vent keepouts can never disagree.""" + cx, cy = fb_board_center(layout, W, H) + if layout == "B" and P["fb_orient"] == "portrait": + ax, ay = P["fb_hole_dy"], P["fb_hole_dx"] # portrait: length(28.4) runs along y, width(11.1) along x + else: + ax, ay = P["fb_hole_dx"], P["fb_hole_dy"] # landscape / A: length along x + return [(cx + sx * ax, cy + sy * ay) for sx in (-1, 1) for sy in (-1, 1)] + + +def fb_standoffs(layout, W, H, z0): + """Four M2 standoff bosses on the tray floor at the FireBeetle's CAD-exact + hole pattern. Height clears the 7mm JST (components face the floor); screws + drop in from the front during back-insertion assembly. Same closed-tube + primitive as the M3 lid bosses.""" + if not P["fb_standoffs"]: + return [] + tris = [] + for px, py in fb_standoff_positions(layout, W, H): + outer = ensure_ccw(circle_loop(px, py, P["fb_standoff_r"], P["boss_seg"])) + inner = ensure_ccw(circle_loop(px, py, P["fb_pilot_r"], P["boss_seg"])) + tris += annulus_slab(outer, inner, z0, z0 + P["fb_standoff_h"]) + return tris + + +def battery_footprint(layout, W, H): + """The 103454 LiPo pouch's floor footprint (34 wide × 54 tall, VERTICAL) for B-portrait. + v1.35: hard against the RIGHT (opposite the FB — leads are long), the collar's outer wall + 1mm clear of the right corner bosses. Returns (x0,y0,x1,y1) or None (A / landscape stack + the cell elsewhere — no floor pocket).""" + if layout != "B" or P["fb_orient"] != "portrait": + return None + boss_left = W - P["wall_t"] - 2 * P["boss_r"] # left edge of the right corner bosses (~85) + x1 = (boss_left - 1.0) - P["bat_collar_t"] - P["bat_collar_clear_x"] # pouch right edge (wall 1mm off the boss) + x0 = x1 - P["bat_h"] # 34mm across (vertical pouch) + # v1.36: top-pinned (as far UP as possible) — the −y wall sits 1mm off the cavity's top + # inner wall, clearing the centred case-button terminals down at the bottom band. + y1 = (H - P["wall_t"] - 1.0) - P["bat_collar_t"] - P["bat_collar_clear"] # pouch top edge + y0 = y1 - P["bat_w"] # 54mm tall + return (x0, y0, x1, y1) + + +def battery_keepout_rect(layout, W, H): + """The floor rectangle kept SOLID (no vent cells) under the battery collar — the wall-outer + rect grown half a hex so no wall foots on a half-cell. None when there's no pocket.""" + fp = battery_footprint(layout, W, H) + if fp is None or not P["bat_collar"]: + return None + gx = P["bat_collar_clear_x"] + P["bat_collar_t"] + P["vent_hex_flats"] / 2.0 + gy = P["bat_collar_clear"] + P["bat_collar_t"] + P["vent_hex_flats"] / 2.0 + return (fp[0] - gx, fp[1] - gy, fp[2] + gx, fp[3] + gy) + + +def battery_collar(layout, W, H, ft): + """Full retention collar for the pouch: four walls framing the footprint, standing on the + tray floor. LEFT/RIGHT (vertical) walls use bat_collar_clear_x; TOP/BOTTOM use bat_collar_ + clear. Each wall is a closed box that overlaps its neighbours at the corners and embeds into + the floor — the codebase's compose-by-overlap trick (watertight by construction; the slicer + unions the overlaps). Optional JST-lead notch (bat_notch) splits the −x wall.""" + fp = battery_footprint(layout, W, H) + if fp is None or not P["bat_collar"]: + return [] + x0, y0, x1, y1 = fp + cx, cy, t, h = P["bat_collar_clear_x"], P["bat_collar_clear"], P["bat_collar_t"], P["bat_collar_h"] + px0, py0, px1, py1 = x0 - cx, y0 - cy, x1 + cx, y1 + cy # pocket inner faces (per-axis clearance) + ox0, oy0, ox1, oy1 = px0 - t, py0 - t, px1 + t, py1 + t # wall outer faces + z0, z1 = ft - 0.5, ft + h # embed 0.5 into the floor for a clean union + ih = t / 2.0 # left/right walls own the corners (full y) + tris = box(px1, oy0, z0, ox1, oy1, z1) # right (+x) wall, full y + if P["bat_notch"]: + ny0 = y0 + 1.0; ny1 = ny0 + P["bat_notch_w"] # JST-lead notch, biased low + tris += box(ox0, oy0, z0, px0, ny0, z1) # −x wall below the notch + tris += box(ox0, ny1, z0, px0, oy1, z1) # −x wall above the notch + else: + tris += box(ox0, oy0, z0, px0, oy1, z1) # −x (FB-facing) wall, solid (no notch) + tris += box(ox0 + ih, py1, z0, ox1 - ih, oy1, z1) # top (+y) wall — inset x, interpenetrates the sides + tris += box(ox0 + ih, oy0, z0, ox1 - ih, py0, z1) # bottom (−y) wall — inset x, no coincident corner edge + return tris + + +def usb_slot_profile(layout, W, H, ft): + """The USB-C cutout opening as a CW loop in the USB wall's (u, z) plane — u is the + in-wall coordinate: x for the −y/bottom wall, y for the +x/right wall. Centred on the + FireBeetle's USB short edge (x-centre for "-y", y-centre for "+x"); z slaved to the + standoff height so the slot rises with the board (the board mounts identically either + way). Optionally topped by a 45° inverted-V (usb_roof_45). Returns None when off. CW + because a hole loop inside the CCW wall-face rectangle is wound opposite (cf. _hole_loop).""" + if not P["usb_cutout"]: + return None + wall = P["usb_wall"] if layout == "B" else "+x" # A (centered backup) stays on the +x wall + cx, cy = fb_board_center(layout, W, H) + u = cx if wall == "-y" else cy # connector centred on the USB short edge + u0, u1 = u - P["usb_slot_w"] / 2.0, u + P["usb_slot_w"] / 2.0 + z1 = ft + P["fb_standoff_h"] + P["usb_slot_top_margin"] + P["usb_slot_z_offset"] # top — slaved to the board + offset + z0 = z1 - P["usb_slot_h"] # rectangle bottom (flat, prints clean) + if P["usb_roof_45"]: + hw = (u1 - u0) / 2.0 # 45° apex: rises half the width above z1 + prof = [(u0, z0), (u1, z0), (u1, z1), ((u0 + u1) / 2.0, z1 + hw), (u0, z1)] + else: + prof = [(u0, z0), (u1, z0), (u1, z1), (u0, z1)] # plain rectangle (supports needed) + return prof if signed_area(prof) < 0 else prof[::-1] # force CW (hole winding) + + +def _vwall_with_window(const, rect_uv, hole_cw, outward, plane="x"): + """A FLAT vertical wall face at a constant coordinate, carrying a CW hole in a CCW + rectangle (ear-clip + hole-bridge, the same machinery as the lid window), then lifted + to 3D. plane="x": face at x=const, (u,v)=(y,z), normal (outward,0,0) — the +x/right + wall. plane="y": face at y=const, (u,v)=(x,z), normal (0,outward,0) — the −y/bottom + wall. The hole boundary edges land exactly on the tunnel quads, and the rectangle + boundary edges land on the loop walls / skirt / rim that surround this run — watertight.""" + tris2d = _earclip(_bridge_holes(ensure_ccw(list(rect_uv)), [hole_cw])) + if plane == "x": + lift = lambda u, v: (const, u, v); tgt = (outward, 0, 0) + else: + lift = lambda u, v: (u, const, v); tgt = (0, outward, 0) + return [_orient(lift(*a), lift(*b), lift(*c), tgt) for a, b, c in tris2d] + + +def usb_tunnel(hole_cw, c_out, c_in, plane="x"): + """The slot passage: one quad per hole edge, bridging the exterior hole (const=c_out) + to the interior hole (const=c_in). Normals face INTO the passage (toward the hole's + centroid), like a clearance-hole wall. plane="x": (u,v)=(y,z) at constant x. plane="y": + (u,v)=(x,z) at constant y. Shares every hole edge with the two windowed faces.""" + k = len(hole_cw) + cu = sum(p[0] for p in hole_cw) / k; cv = sum(p[1] for p in hole_cw) / k + lift = (lambda c, u, v: (c, u, v)) if plane == "x" else (lambda c, u, v: (u, c, v)) + tris = [] + for i in range(k): + j = (i + 1) % k + Pi, Pj = hole_cw[i], hole_cw[j] + A = lift(c_out, Pi[0], Pi[1]); B = lift(c_out, Pj[0], Pj[1]) + C = lift(c_in, Pj[0], Pj[1]); D = lift(c_in, Pi[0], Pi[1]) + mu = (Pi[0] + Pj[0]) / 2.0; mv = (Pi[1] + Pj[1]) / 2.0 + tgt = (0, cu - mu, cv - mv) if plane == "x" else (cu - mu, 0, cv - mv) + tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt)) + return tris + + +def _subdiv_rect(u0, v0, u1, v1, nu, nv): + """CCW rectangle perimeter subdivided on an nu×nv grid — shares every vertex with a + honeycomb_region of the same metrics, so a face that bridges this as a hole stitches to + the field cells with NO T-junctions.""" + cw = (u1 - u0) / nu; ch = (v1 - v0) / nv + pts = [(u0 + i * cw, v0) for i in range(nu)] # bottom L→R + pts += [(u1, v0 + j * ch) for j in range(nv)] # right B→T + pts += [(u0 + (nu - i) * cw, v1) for i in range(nu)] # top R→L + pts += [(u0, v1 - j * ch) for j in range(nv)] # left T→B + return pts + + +def _vwall_cap(const, rect_uv, holes_cw, outward, plane="x"): + """Like _vwall_with_window but robust for a many-vertex hole: ear-clip with the + area-check + _triangulate fallback (cf. cap_with_holes), then lift to 3D. plane "x" + → face at x=const, (u,v)=(y,z); plane "y" → y=const, (u,v)=(x,z).""" + outer = ensure_ccw(list(rect_uv)) + if holes_cw: + poly = _dedup(_bridge_holes(outer, holes_cw)) + tris2d = _earclip(poly) + want = abs(signed_area(poly)) + got = sum(abs(_tri_area2(a, b, c)) for a, b, c in tris2d) / 2.0 + if len(tris2d) < len(poly) - 2 or abs(got - want) > 1e-3 * max(1.0, want): + tris2d = _triangulate(poly) + else: + tris2d = _earclip(outer) + lift = (lambda u, v: (const, u, v)) if plane == "x" else (lambda u, v: (u, const, v)) + tgt = (outward, 0, 0) if plane == "x" else (0, outward, 0) + return [_orient(lift(*a), lift(*b), lift(*c), tgt) for a, b, c in tris2d] + + +def wall_vent(layout, W, H, side, ft, ext_z0, top, r, r_in): + """Honeycomb vent through one side ('+x' / '-x') wall of the B tray. The field is an + INSET box in the wall's (y,z) plane — flat-top hexes (support-free up a vertical wall). + Build the cells in a canonical honeycomb box (its x→wall-y, y→wall-z, z→wall-thickness) + then rotate (hx,hy,hz)→(hz,hx,hy) into place (a det=+1 cyclic swap → normals survive). + The exterior + interior faces are rebuilt here as `wall-run rect minus the field hole` + (a single hole → robust), the hole subdivided on the cell grid so it stitches to the + cells. The caller SKIPS this wall's straight run in _side_wall. [] when off / not B.""" + if not (P["vent_walls"] and layout == "B"): + return [] + wt = P["wall_t"] + m = P["vent_wall_margin"]; bd = wt + P["boss_r"] + uf0 = bd + P["boss_r"] + m; uf1 = H - bd - P["boss_r"] - m # along-wall (y): clear both corners + vf0 = ft + m; vf1 = top - m # up (z): above floor, below rim + if side == "+x": + zt0, zt1 = W - wt, W # thickness span (→ wall x); z1 cap (normal +z→+x) = exterior + x_out, s_out, x_in, s_in = W, +1, W - wt, -1 + else: # "-x": z0 cap (normal −z→−x) at x=0 = exterior + zt0, zt1 = 0.0, wt + x_out, s_out, x_in, s_in = 0.0, -1, wt, +1 + cells, vmeta = honeycomb_region(uf0, vf0, uf1, vf1, zt0, zt1, P["vent_hex_flats"], P["vent_wall"]) + cells = [tuple((p[2], p[0], p[1]) for p in tri) for tri in cells] # rotate canonical → wall (x,y,z) + x0, y0, x1, y1, nx, ny, cw, ch = vmeta + fh_cw = _subdiv_rect(x0, y0, x1, y1, nx, ny)[::-1] # field perimeter as a CW hole + ext_rect = [(r, ext_z0), (H - r, ext_z0), (H - r, top), (r, top)] # exterior run (y,z) + in_rect = [(wt + r_in, ft), (H - wt - r_in, ft), (H - wt - r_in, top), (wt + r_in, top)] + tris = cells + tris += _vwall_cap(x_out, ext_rect, [fh_cw], s_out, "x") # exterior face + field hole + tris += _vwall_cap(x_in, in_rect, [fh_cw], s_in, "x") # cavity face + field hole + return tris + + +def _chamfer_inset(W, H, r, seg, c): + """The outer rounded-rect inset by `c` on every side — same `seg`, so it carries + the SAME point count as the full outer and the two stitch index-to-index for a + bevel skirt. Corner radius shrinks with the inset (clamped ≥0.5).""" + return ensure_ccw(rounded_rect(c, c, W - c, H - c, max(0.5, r - c), seg)) + + +def _skirt(lo, hi, zlo, zhi, zdir): + """45° bevel band linking loop `lo` @zlo (the inset face edge) to loop `hi` @zhi + (the full-size wall foot) — matched point counts. Normals face outward AND along + zdir (−1 = a front/underside chamfer, facing down-and-out). This is the softened + case edge; the wall continues vertically from `hi` above it.""" + k = len(lo) + cx = sum(p[0] for p in lo) / k; cy = sum(p[1] for p in lo) / k + tris = [] + for i in range(k): + j = (i + 1) % k + A = (lo[i][0], lo[i][1], zlo); B = (lo[j][0], lo[j][1], zlo) + C = (hi[j][0], hi[j][1], zhi); D = (hi[i][0], hi[i][1], zhi) + mx = (lo[i][0] + lo[j][0]) / 2.0; my = (lo[i][1] + lo[j][1]) / 2.0 + tgt = (mx - cx, my - cy, zdir) + tris.append(_orient(A, B, C, tgt)); tris.append(_orient(A, C, D, tgt)) + return tris + + +def front_vent_regions(layout, W, H, cx, cy, bx, bhw): + """Honeycomb bottom grille on the lid: a hex strip below the OLED window, split into two + by the case button. It sits below the guide-collar footprint and inside the corner bosses, + reached by a slit up from the bottom edge (the v1.22 keyhole). Returns (regions, keepc): + keepc = boss + peg centres left solid. B only — A's centred window leaves no clean band. + vent_front is OFF since v1.45, so this now only positions the two front portholes.""" + if layout != "B": + return [], [] + m, g = P["vent_front_margin"], P["vent_front_gap"] + col_hh = P["oled_pcb_h"] / 2.0 + P["guide_clearance"] + P["collar_extra_h"] / 2.0 + P["guide_t"] + col_y0 = cy - col_hh + by0, by1 = m, col_y0 - g # below the collar footprint + bd = P["wall_t"] + P["boss_r"] # corner-boss inset from the edge + x0, x1 = bd + P["vent_keepout_r"] + 1.0, W - bd - P["vent_keepout_r"] - 1.0 # clear the bosses + regions = [] + if bx is not None: # split around the case button + regions.append((x0, by0, bx - bhw - g, by1)) + regions.append((bx + bhw + g, by0, x1, by1)) + else: + regions.append((x0, by0, x1, by1)) + keepc = list(boss_positions(W, H)) # 4 corner M3 bosses + dx, dy = P["oled_mount_w"] / 2.0, P["oled_mount_h"] / 2.0 + keepc += [(cx + sx * dx, cy + sy * dy) for sx in (-1, 1) for sy in (-1, 1)] # 4 OLED pegs + return regions, keepc + + +def make_front(layout): + """Front bezel / LID: rounded plate with a true display-window hole, the M3 + screw bosses (posts standing into the cavity — back insertion: screws come up + from the rear through the tray floor into these), and a single centred lanyard + loop. Clean front face — no screw heads on the screen side.""" + W, H = plate_outline(layout) + t = P["plate_t"]; r = P["corner_r"]; seg = P["corner_seg"] + + win_w = P["oled_glass_w"] + 2 * P["window_clearance"] + P["window_extra_w"] # glass passthrough (rectangular) + win_h = P["oled_glass_h"] + 2 * P["window_clearance"] + P["window_extra_h"] + + # OLED centered in A; in the top band for B + if layout == "A": + cx, cy = W / 2.0, H / 2.0 + else: + cx = W / 2.0 + cy = H - P["edge_margin"] - P["oled_pcb_h"] / 2.0 + + wx0, wx1 = cx - win_w / 2.0, cx + win_w / 2.0 + wy0, wy1 = cy - win_h / 2.0, cy + win_h / 2.0 + + outer = ensure_ccw(rounded_rect(0, 0, W, H, r, seg)) + window = ensure_ccw([(wx0, wy0), (wx1, wy0), (wx1, wy1), (wx0, wy1)]) + + # v1.14: lid plate as a MULTI-hole slab (cap_with_holes — the same machinery as + # the tray floor's screw holes) instead of the single-hole annulus, so it can + # carry the display window AND the round front-button hole together. + holes = [list(reversed(window))] # CW hole loops + btn_cx = None # case-button centre (splits the bottom vent) + if P["btn_hole"]: + hh_out = (P["oled_pcb_h"] / 2.0 + P["guide_clearance"] + + P["collar_extra_h"] / 2.0 + P["guide_t"]) + band_lo = P["wall_t"] # inner face of the bottom wall + band_hi = cy - hh_out # bottom edge of the guide collar + if band_hi - band_lo >= P["btn_hole_d"] + 3.0: # hole + ~1.5mm web each side + bx, by = W / 2.0, (band_lo + band_hi) / 2.0 + holes.append(_hole_loop(bx, by, P["btn_hole_d"] / 2.0, P["boss_seg"])) + btn_cx = bx + # v1.40: a centred row of Ø5 through-holes across the top band (B), replacing the old top text. + # Skip any hole that can't clear the two top corner M3 bosses (keeps it safe if n/pitch change). + # Each placed hole also seats a vertex on the top edge straight above it (top_xs → _with_top), + # so it bridges with a short vertical slit instead of a self-crossing diagonal to a far corner. + top_xs = [] + if P["top_holes"] and layout == "B": + n, pitch, hy = P["top_hole_n"], P["top_hole_pitch"], P["top_hole_y"] + hr = P["top_hole_d"] / 2.0 + min_boss = P["boss_r"] + hr + 1.0 # ≥1mm web to a boss post + for i in range(n): + hx = W / 2.0 + (i - (n - 1) / 2.0) * pitch + if all((hx - bxx) ** 2 + (hy - byy) ** 2 >= min_boss ** 2 for bxx, byy in boss_positions(W, H)): + holes.append(_hole_loop(hx, hy, hr, P["top_hole_seg"])) + top_xs.append(hx) + # v1.45: large round holes where the front honeycomb grille used to be — one centred in each of + # the two grille bands flanking the button (reuses front_vent_regions for placement). Each seats a + # bottom-edge vertex below it (front_xs → _with_base) so it bridges straight down, not across. + front_xs = [] + if P["front_holes"] and layout == "B": + fr = P["front_hole_d"] / 2.0 + regions, _ = front_vent_regions(layout, W, H, cx, cy, btn_cx, P["btn_hole_d"] / 2.0) + for rx0, ry0, rx1, ry1 in regions: + fx, fy = (rx0 + rx1) / 2.0, (ry0 + ry1) / 2.0 + fx += (-1.0 if fx < W / 2.0 else 1.0) * P["front_hole_dx"] # v1.47: toward the nearer L/R edge + fy += P["front_hole_dy"] # and up + holes.append(_hole_loop(fx, fy, fr, P["front_hole_seg"])) + front_xs.append(fx) + if btn_cx is not None: + front_xs.append(btn_cx) # seat the button's bottom bridge too (no vent notch now) + # v1.23: honeycomb grille. Each region is a per-cell hex panel (0→t, watertight by + # construction); its rectangular boundary is spliced into BOTH lid caps as a notch + # reached by one slit up from the bottom edge (the v1.22 back-floor keyhole), so the + # caps only bridge the real window/button holes. The slit BASE of every region is shared + # into the outer + chamfer-inset outlines (_with_base) so the skirt/rim stitch with no + # T-junction. The converging slits make cap_with_holes fall back to _triangulate. + vmetas, slit_xs, vent_cells = [], [], [] + if P["vent_front"]: + regions, keepc = front_vent_regions(layout, W, H, cx, cy, btn_cx, P["btn_hole_d"] / 2.0) + kr2 = P["vent_keepout_r"] ** 2 + keep = lambda X, Y: any((X - kx) ** 2 + (Y - ky) ** 2 < kr2 for kx, ky in keepc) + ncols = P["vent_front_btn_clear_cols"] # innermost columns to leave SOLID beside the button + split = btn_cx is not None and len(regions) == 2 # left/right fields flank the button + pitch = P["vent_hex_flats"] + P["vent_wall"] + for ridx, (rx0, ry0, rx1, ry1) in enumerate(regions): + rkeep = keep + if split and ncols > 0: # v1.39: button-side column(s) → solid (no hex) + nx_r = max(1, int(round((rx1 - rx0) / pitch))) + cw_r = (rx1 - rx0) / nx_r # SAME metric honeycomb_region uses → other cells untouched + if ridx == 0: # LEFT field → its rightmost (button-adjacent) column + thr = rx1 - ncols * cw_r + rkeep = (lambda bk, x_: lambda X, Y: bk(X, Y) or X > x_)(keep, thr) + else: # RIGHT field → its leftmost (button-adjacent) column + thr = rx0 + ncols * cw_r + rkeep = (lambda bk, x_: lambda X, Y: bk(X, Y) or X < x_)(keep, thr) + cells, vm = honeycomb_region(rx0, ry0, rx1, ry1, 0.0, t, + P["vent_hex_flats"], P["vent_wall"], rkeep) + nx = vm[4] + sidx = max(1, min(nx - 1, nx // 2)) # interior slit column (perpendicular) + vent_cells += cells + vmetas.append((vm, sidx)) + slit_xs.append(vm[0] + sidx * vm[6]) + + def based(ol): # share each slit base with skirt/rim + for sx in slit_xs: + ol = _with_base(ol, sx) + for hx in top_xs: # seat a vertex above each top-band hole + ol = _with_top(ol, hx) + for fx in front_xs: # seat a vertex below each front porthole / button + ol = _with_base(ol, fx) + if front_xs: # front holes present → seat a TOP vertex above the + ol = _with_top(ol, cx) # window centre so it bridges UP, not down through the button + return ol + + def facecap(outline, z, updir): # splice every notch, then cap + bridge holes + ol = outline + for vm, sidx in vmetas: + ol = splice_vent_notch(ol, vm, sidx) + return cap_with_holes(ol, holes, z, updir) + + # v1.18: chamfer the exposed FRONT face↔rim edge (z=0). The front face shrinks + # to an inset outline, a 45° skirt ramps out to full size over `c`, and the rim + # wall starts at z=c. Holes (window/button) sit far from the perimeter, so they + # keep full-height walls untouched. The inner face (z=t, the lid↔tray seam) stays + # square for a flush seat. + c = P["chamfer_size"] if P["chamfer"] else 0.0 + outer_b = based(outer) # bottom edge carries the shared slit bases + if 0 < c < t: + outer_in = based(_chamfer_inset(W, H, r, seg, c)) + tris = facecap(outer_in, 0, (0, 0, -1)) # inset front face + vent notches + tris += _skirt(outer_in, outer_b, 0, c, -1) # 45° bevel + tris += _side_wall(outer_b, c, t, True) # rim above the bevel + else: + tris = facecap(outer_b, 0, (0, 0, -1)) # front face + vent notches + tris += _side_wall(outer_b, 0, t, True) # plate rim + tris += facecap(outer_b, t, (0, 0, 1)) # inner face (seam side — square) + vent notches + for h in holes: + tris += _side_wall(h, 0, t, False) # window + button hole walls + tris += vent_cells # honeycomb cells fill the notches + # bosses live on the LID for back insertion — posts spanning the cavity, bore + # opening at the tip (which meets the tray floor when assembled). + tris += screw_bosses(W, H, t, t + cavity_depth(layout)) + if P["ear_front"]: + tris += ear((W - P["ear_w"]) / 2.0, H, t) # single centred lanyard loop + # LID top band (above the window/collar, clear of the top corner bosses): the maker URL, + # band-centred in the 5x7 pixel font (v1.51). px 0.8 → 5.6mm glyphs, ~the 7-seg version + # height, with a ~71mm run centred in the 76mm clear span. Inner face → hidden on teardown, + # and it prints clean (the lid prints face-DOWN, so this face is UP). The build-number is + # NOT duplicated here anymore; the TRAY carries it (above the FB) for teardown ID. A blank + # tag falls back to the 7-seg version stamp. v1.40: skipped when the top-hole row owns this band (B). + if not (P["top_holes"] and layout == "B"): + if P["maker_tag"]: + upx = P["maker_tag_px"] + uox = W / 2.0 - _text_run_len(P["maker_tag"], upx, 1.0) / 2.0 # centred on the plate midline (= band centre) + uoy = (H - 2.0) - 7 * upx # ascender tops flush ~2mm below the top edge + tris += pixel_text(P["maker_tag"], uox, uoy, (1.0, 0.0), (0.0, 1.0), + t, t + P["imprint_h"], upx) + else: + tris += imprint_text(P["case_version"].lstrip("v") + layout, 12.0, H - P["imprint_digit_h"] - 2.0, t) + # OLED registration ridges on the inner face — notch the top bar around any top-band holes + collar_gaps = [(hx, P["top_hole_d"] / 2.0 + 0.6) for hx in top_xs] + tris += guide_collar(cx, cy, t, top_gaps=collar_gaps) + tris += oled_pegs(cx, cy, t) # OLED mounting pegs (measured #13 pattern) + # v1.30: the front face stays FLAT — raised text broke face-down FDM printing (it would sit + # below the bed). The front text is now a SEPARATE colour-inlay mesh (make_front_text → + # *_front_text.stl), extruded INTO the face, to drop into an AMS slice as a 2nd-filament part. + return tris + + +def _reflect_x(tris, xm): + """Mirror geometry across the vertical plane x=xm (x → 2·xm − x). Reflection reverses + orientation, so swap two vertices per triangle to keep normals pointing outward.""" + out = [] + for a, b, c in tris: + ra = (2 * xm - a[0], a[1], a[2]) + rb = (2 * xm - b[0], b[1], b[2]) + rc = (2 * xm - c[0], c[1], c[2]) + out.append((ra, rc, rb)) # swap b,c to restore winding + return out + + +def front_text_tris(layout, z0, z1): + """The front-face pixel text as glyph boxes spanning z0→z1, in the solid bands the grille + leaves clear: TOP horizontal above the window, SIDES vertical alongside it (left reads up, + right down). Factored out so the colour-inlay mesh and any future inline use share one + placement. B-tuned; A renders too. "" zones are skipped. + + MIRROR: the lid prints face-DOWN, so the show face is viewed along +z — where +x runs left, + which mirrors text laid out +x-right. Each block is reflected about its OWN x-centre, so it + reads correctly from the front while staying in its band (top centred, left at −x, right +x).""" + if not P["front_text"]: + return [] + W, H = plate_outline(layout) + if layout == "A": + cx, cy = W / 2.0, H / 2.0 + else: + cx, cy = W / 2.0, H - P["edge_margin"] - P["oled_pcb_h"] / 2.0 + win_w = P["oled_glass_w"] + 2 * P["window_clearance"] + P["window_extra_w"] + win_h = P["oled_glass_h"] + 2 * P["window_clearance"] + P["window_extra_h"] + wx0, wx1, wy1 = cx - win_w / 2.0, cx + win_w / 2.0, cy + win_h / 2.0 + px, cg, m = P["front_text_px"], P["front_text_col_gap"], 2.0 + + def placed(s, ox, oy, run, rise): # render a block, then un-mirror in place + blk = pixel_text(s, ox, oy, run, rise, z0, z1, px, cg) + if not blk: + return [] + xs = [v[0] for tr in blk for v in tr] + return _reflect_x(blk, (min(xs) + max(xs)) / 2.0) + + tris = [] + if P["front_text_top"]: # horizontal, centred above the window + s = P["front_text_top"] + ox = (W - _text_run_len(s, px, cg)) / 2.0 + oy = (wy1 + (H - m)) / 2.0 - 3.5 * px # centre the 7px glyph in the top band + tris += placed(s, ox, oy, (1.0, 0.0), (0.0, 1.0)) + if P["front_text_left"]: # vertical, reading up the left band + s = P["front_text_left"] + oy = cy - _text_run_len(s, px, cg) / 2.0 + ox = (m + (wx0 - 1.5)) / 2.0 + 3.5 * px # centre the 7px glyph across the band + tris += placed(s, ox, oy, (0.0, 1.0), (-1.0, 0.0)) + if P["front_text_right"]: # vertical, reading down the right band + s = P["front_text_right"] + oy = cy + _text_run_len(s, px, cg) / 2.0 + ox = ((wx1 + 1.5) + (W - m)) / 2.0 - 3.5 * px + tris += placed(s, ox, oy, (0.0, -1.0), (1.0, 0.0)) + return tris + + +def make_front_text(layout): + """Front-face colour INLAY: the pixel text extruded from the face (z=0) INTO the plate by + front_text_h, as a standalone mesh (disjoint closed glyph boxes → passes the watertight + check). It OVERLAPS the flat lid; in the slicer, add it as a part of the lid object and + assign your 2nd filament — the slicer colours the glyph volume, the face stays flat, and it + prints face-down (the inlay is the bottom layers). 'Multi-colour by parts'.""" + return front_text_tris(layout, 0.0, P["front_text_h"]) + + +def make_back(layout): + """Back piece / TRAY (walls on, default): floor + rounded perimeter walls, open + to the front so the boards drop in. For BACK insertion the screw bosses live on + the lid, so the floor carries the M3 screw-shank CLEARANCE holes (heads sit on + the back). Single centred lanyard loop. Built as one closed solid: underside + floor + outer walls + top rim (lid seat) + inner cavity walls + cavity floor, + with clearance holes punched through the floor. walls off → v0 flat slab.""" + W, H = plate_outline(layout) + t = P["plate_t"]; r = P["corner_r"]; seg = P["corner_seg"] + + if not P["walls"]: + poly = ensure_ccw(rounded_rect(0, 0, W, H, r, seg)) + tris = prism(poly, 0, t) + if P["ear_back"]: + tris += ear((W - P["ear_w"]) / 2.0, H, t) # single centred lanyard loop + return tris + + wt = P["wall_t"] + r_in = max(0.5, r - wt) + outer = ensure_ccw(rounded_rect(0, 0, W, H, r, seg)) + inner = ensure_ccw(rounded_rect(wt, wt, W - wt, H - wt, r_in, seg)) # same pt count + ft = t # floor thickness + top = ft + cavity_depth(layout) # wall top = lid seat + + # back-insertion: screws come up through the floor into the lid bosses, so the + # floor carries 4 clearance holes (bosses themselves now live on the lid). + clr = [_hole_loop(cx, cy, P["boss_clear_r"], P["boss_seg"]) for cx, cy in boss_positions(W, H)] + + # honeycomb vent field: a per-cell hex panel filling a margin-inset rect of the + # floor, cells left solid near the screw bosses + FB standoffs. It's a coplanar + # section of the floor slab (0→ft); its perimeter is punched as ONE subdivided hole + # in both floor faces (floor_holes, below) and the cells fill it (no perimeter wall — + # continuous slab). The screw-hole WALLS stay on `clr` only. + vents_tris, vmeta = [], None + if P["vent_back"]: + m = P["vent_margin"] + keepc = list(boss_positions(W, H)) + fb_standoff_positions(layout, W, H) + kr2 = P["vent_keepout_r"] ** 2 + bk = battery_keepout_rect(layout, W, H) # keep the floor SOLID under the battery collar + in_bat = (lambda cx, cy: bk[0] <= cx <= bk[2] and bk[1] <= cy <= bk[3]) if bk else (lambda cx, cy: False) + keep = lambda cx, cy: in_bat(cx, cy) or any((cx - kx) ** 2 + (cy - ky) ** 2 < kr2 for kx, ky in keepc) + vents_tris, vmeta = honeycomb_region(wt + m, wt + m, W - wt - m, H - wt - m, + 0.0, ft, P["vent_hex_flats"], P["vent_wall"], keep) + + # the floor caps get the vent spliced in as a NOTCH (keyhole) so they only bridge + # the corner screw holes (clr); the cells (vents_tris) fill the notch. The slit base + # is shared (via _with_base) with the skirt/walls/rim so there's no T-junction there. + slit_x = (vmeta[0] + (vmeta[4] // 2) * vmeta[6]) if vmeta else None + based = (lambda ol: _with_base(ol, slit_x)) if vmeta else (lambda ol: ol) + def floorcap(outline, z, updir): + ol = splice_vent_notch(outline, vmeta, vmeta[4] // 2) if vmeta else outline + return cap_with_holes(ol, clr, z, updir) + # v1.18: chamfer the exposed BACK face↔wall edge (z=0), mirroring the lid front. + # Clearance holes sit ~5.5mm in (well clear of a ≤1.5mm inset); the top rim (the + # lid seat) stays square. + # v1.20: a real USB-C through-slot — its STRAIGHT run is skipped in both the exterior + # and interior loop walls; in its place go two windowed faces (flat verticals carrying + # the slot hole) joined by a tunnel. The hole boundary stitches to the tunnel, the + # rectangle boundary to the surrounding walls/skirt/rim. v1.33: the wall is set by + # usb_wall — "+x" (right) skips the +x run; "-y" (bottom) skips the bottom run (and the + # rebuilt bottom faces re-thread the vent-slit base vertex so the floor-cap notch still + # stitches). eps>0 isolates the straight edge(s); adjacent corner-arc points fall short. + penta = usb_slot_profile(layout, W, H, ft) + eps = 1e-6 + usb_w = P["usb_wall"] if layout == "B" else "+x" # A (centered backup) stays on the +x wall + vw = P["vent_walls"] and layout == "B" # hex-vent the −x/+x side walls (rebuilt below) + o_conds, i_conds = [], [] # straight runs to SKIP, rebuilt as windowed/vented faces + if penta and usb_w == "-y": # USB on the bottom wall: skip the y≈0 / y≈wt runs + o_conds.append(lambda a, b: a[1] < eps and b[1] < eps) + i_conds.append(lambda a, b: a[1] < wt + eps and b[1] < wt + eps) + elif penta: # USB on the +x/right wall (v1.32 / A) + o_conds.append(lambda a, b: a[0] > W - eps and b[0] > W - eps) + i_conds.append(lambda a, b: a[0] > (W - wt) - eps and b[0] > (W - wt) - eps) + if vw: # +x and −x runs → vented faces (wall_vent) + o_conds.append(lambda a, b: a[0] > W - eps and b[0] > W - eps) + o_conds.append(lambda a, b: a[0] < eps and b[0] < eps) + i_conds.append(lambda a, b: a[0] > (W - wt) - eps and b[0] > (W - wt) - eps) + i_conds.append(lambda a, b: a[0] < wt + eps and b[0] < wt + eps) + skip_o = (lambda a, b: any(c(a, b) for c in o_conds)) if o_conds else None + skip_i = (lambda a, b: any(c(a, b) for c in i_conds)) if i_conds else None + + # v1.18: chamfer the exposed BACK face↔wall edge (z=0), mirroring the lid front. + # Clearance holes sit ~5.5mm in (well clear of a ≤1.5mm inset); the top rim (the + # lid seat) stays square. + c = P["chamfer_size"] if P["chamfer"] else 0.0 + outer_b = based(outer) # bottom edge carries the shared slit base + inner_b = based(inner) + if 0 < c < ft: + outer_in = based(_chamfer_inset(W, H, r, seg, c)) + tris = floorcap(outer_in, 0.0, (0, 0, -1)) # inset underside floor + vent notch + tris += _skirt(outer_in, outer_b, 0.0, c, -1) # 45° bevel + tris += _side_wall(outer_b, c, top, True, skip=skip_o) # exterior walls above the bevel + ext_z0 = c + else: + tris = floorcap(outer_b, 0.0, (0, 0, -1)) # underside floor + screws + vent notch + tris += _side_wall(outer_b, 0.0, top, True, skip=skip_o) # exterior walls (full height) + ext_z0 = 0.0 + tris += _ring_cap(outer_b, inner_b, top, (0, 0, 1)) # top rim — the lid rests here + tris += _side_wall(inner_b, ft, top, False, skip=skip_i) # cavity inner walls + tris += floorcap(inner_b, ft, (0, 0, 1)) # cavity floor + screws + vent notch + for h in clr: + tris += _side_wall(h, 0.0, ft, False) # clearance-hole walls (face into the hole) + tris += vents_tris # honeycomb vent cells (fill the punched region) + if vw: # hex vents through the −x/+x side walls (B) + tris += wall_vent(layout, W, H, "+x", ft, ext_z0, top, r, r_in) + tris += wall_vent(layout, W, H, "-x", ft, ext_z0, top, r, r_in) + if penta and usb_w == "+x": # USB-C slot replaces the skipped +x run + ext_rect = [(r, ext_z0), (H - r, ext_z0), (H - r, top), (r, top)] # +x exterior face (y,z) + in_rect = [(wt + r_in, ft), (H - wt - r_in, ft), # +x interior face (y,z) + (H - wt - r_in, top), (wt + r_in, top)] + tris += _vwall_with_window(W, ext_rect, penta, +1) # exterior face + hole (faces +x) + tris += _vwall_with_window(W - wt, in_rect, penta, -1) # cavity face + hole (faces −x) + tris += usb_tunnel(penta, W, W - wt) # the slot passage walls + elif penta: # "-y" bottom wall — faces in the (x,z) plane + def _yrect(xL, xR, zb): # CCW (x,z) face; vent-slit vertex on BOTH horizontal + s = slit_x if (slit_x is not None and xL + eps < slit_x < xR - eps) else None + bot = [(xL, zb), (s, zb), (xR, zb)] if s is not None else [(xL, zb), (xR, zb)] # ← floor cap/skirt + top_ = [(xR, top), (s, top), (xL, top)] if s is not None else [(xR, top), (xL, top)] # ← ring rim + return bot + top_ + ext_rect = _yrect(r, W - r, ext_z0) # −y exterior (x,z) + in_rect = _yrect(wt + r_in, W - wt - r_in, ft) # −y interior (x,z) + tris += _vwall_with_window(0.0, ext_rect, penta, -1, "y") # exterior bottom face (faces −y) + tris += _vwall_with_window(wt, in_rect, penta, +1, "y") # cavity bottom face (faces +y) + tris += usb_tunnel(penta, 0.0, wt, "y") # the slot passage walls + tris += fb_standoffs(layout, W, H, ft) # FireBeetle M2 standoff bosses + tris += battery_collar(layout, W, H, ft) # B-portrait: pouch retention collar (top-right) + if P["ear_back"]: + ex = (W - P["ear_w"]) / 2.0 # centred lanyard tab + if P["ear_full_depth"]: + tris += lanyard_tab(ex, H, 0.0, P["plate_t"] + cavity_depth(layout)) # v1.48: end at the tray WALL TOP + # (flush with the perimeter walls / lid seat) — no longer runs the extra plate_t to the front face + else: + tris += ear(ex, H, t) # legacy flat 2mm loop + # inner-floor marks (v1.50). B-portrait has a solid battery pocket + top/bottom solid + # strips (the vent field is inset by vent_margin), so all three land on solid floor with + # no vent surgery: the maker's-mark HOUSE fills the pocket (hidden under the cell); the + # build-number moves UP above the FireBeetle; the site TAG runs the bottom strip. Other + # layouts (no pocket) keep the legacy bottom-left build-number. + vstr = P["case_version"].lstrip("v") + layout + bfp = battery_footprint(layout, W, H) + if bfp: + # maker's house — centred in the pocket + if P["maker_mark"]: + hpx = P["maker_px"] + hw, hh = len(_MAKER_HOUSE[0]) * hpx, len(_MAKER_HOUSE) * hpx + hox = (bfp[0] + bfp[2]) / 2.0 - hw / 2.0 + hoy = (bfp[1] + bfp[3]) / 2.0 - hh / 2.0 + tris += pixel_art(_MAKER_HOUSE, hox, hoy, ft, ft + P["imprint_h"], hpx) + # build-number → top solid strip above the FB (vent field ends at H-wt-vent_margin), + # centred on the board so it reads over the mounting area. + fbx, _ = fb_board_center(layout, W, H) + ix = fbx - _imprint_run_len(vstr) / 2.0 + iy = H - P["wall_t"] - 1.0 - P["imprint_digit_h"] + tris += imprint_text(vstr, ix, iy, ft) + # v1.51: the site-tag URL moved OFF this bottom strip (0.5mm px was unprintable) up to the + # LID's top band — see make_front. The pocket now carries just the house + build-number. + else: + tris += imprint_text(vstr, 12.0, 2.0, ft) + return tris + + +def est_weight_g(tris): + cc = mesh_volume_cc(tris) + density = _DENSITY_G_CC.get(P["material"], 1.27) + return cc * density * P["infill"], cc + +# ---------------------------------------------------------------------------- +# main +# ---------------------------------------------------------------------------- + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + out = os.path.join(here, "stl") + os.makedirs(out, exist_ok=True) + + ver = P["case_version"] + rev = git_rev() + print(f"DefCon badge enclosure — STL generator case {ver} (git {rev})") + print(f"material {P['material']} (~{_DENSITY_G_CC.get(P['material'], 1.27):.2f} g/cc, " + f"{P['infill']*100:.0f}% fill — weight est. only)") + _bore = P['boss_hole_insert'] if P['fastener'] == 'insert' else P['boss_hole_selftap'] + print(f"lid fastener: M3 {P['fastener']} (boss Ø{2*P['boss_r']:.0f}mm, bore Ø{2*_bore:.1f}mm)") + print(f"display window: {P['oled_glass_w']+2*P['window_clearance']+P['window_extra_w']:.1f} x " + f"{P['oled_glass_h']+2*P['window_clearance']+P['window_extra_h']:.1f} mm (glass passthrough" + f"{', + guide ridges' if P['guide_ridges'] else ''})") + if P["btn_hole"]: + print(f"case button: Ø{P['btn_hole_d']:.1f} mm lid hole below the screen (Twidec PBS-110, " + f"fit-validated); auto-skipped where the band is too tight") + if P["oled_pegs"]: + print(f"oled mount: 4x Ø{P['oled_peg_d']:.1f} pegs @ {P['oled_mount_w']:.2f} x " + f"{P['oled_mount_h']:.2f} c2c + {P['oled_header_notch_w']:.0f}mm collar notch " + f"(header edge: screen-right)") + if P["fb_standoffs"]: + _orient_note = (f"{P['fb_orient']}, USB → {P['usb_wall']} wall (B); JST faces the battery side" + if P['fb_orient'] == 'portrait' else "landscape, USB toward viewer-right") + print(f"fb standoffs: 4x M2 bosses Ø{2*P['fb_standoff_r']:.1f} (pilot Ø{2*P['fb_pilot_r']:.1f}) " + f"h={P['fb_standoff_h']:.1f} — components face the floor; {_orient_note}") + if P["chamfer"]: + print(f"edge chamfer: {P['chamfer_size']:.1f}mm 45° on the exposed faces " + f"(lid front + tray back); parting seam left square") + if P["usb_cutout"]: + _wall_name = {"-y": "−y/bottom", "+x": "+x/right"}.get(P['usb_wall'], P['usb_wall']) + print(f"usb-c cutout: {P['usb_slot_w']:.0f}x{P['usb_slot_h']:.1f}mm through-slot in the {_wall_name} wall (B) " + f"(z slaved to standoffs){', 45° self-supporting roof' if P['usb_roof_45'] else ''}") + if P["bat_collar"]: + _notch = f" + {P['bat_notch_w']:.0f}mm JST notch" if P["bat_notch"] else " (no notch)" + print(f"battery collar (B): full {P['bat_collar_h']:.0f}mm pocket for the {P['bat_w']:.0f}x{P['bat_h']:.0f} pouch, " + f"hard-right; clearance {P['bat_collar_clear_x']:.1f}/side (x) · {P['bat_collar_clear']:.1f}/side (y){_notch}") + if P["vent_walls"]: + print(f"side-wall vents (B): flat-top {P['vent_hex_flats']:.0f}mm hexes through the −x/+x walls, " + f"inset {P['vent_wall_margin']:.1f}mm border (support-free)") + if P["imprint"]: + _lid = f"maker URL '{P['maker_tag']}' @ {P['maker_tag_px']:.1f}mm px" if P["maker_tag"] else "7-seg version" + print(f"imprint: 7-seg '{P['case_version'].lstrip('v')}' + variant (A/B) on the TRAY inner face; {_lid} on the LID top band") + if P["front_text"]: + zt = [z for z in (("top", P["front_text_top"]), ("left", P["front_text_left"]), + ("right", P["front_text_right"])) if z[1]] + print(f"front text: 5x7 pixels @ {P['front_text_px']:.1f}mm px, {P['front_text_h']:.1f}mm colour " + f"INLAY (→ *_front_text.stl, add as an AMS part) — " + ", ".join(f"{k}='{v}'" for k, v in zt)) + print("=" * 58) + total_plastic = {"A": 0.0, "B": 0.0} + for layout in ("A", "B"): + W, H = plate_outline(layout) + depth = 2 * P["plate_t"] + cavity_depth(layout) + label = "Stacked (battery behind)" if layout == "A" else "Coplanar (battery beside)" + kind = "walled tray + flat lid" if P["walls"] else "flat sandwich" + print(f"\n[{layout}] {label}") + print(f" outer plate : {W:.1f} x {H:.1f} mm (+ {P['ear_h']:.0f}mm lanyard ears)") + print(f" assembled : ~{depth:.0f} mm deep ({kind}; " + f"{cavity_depth(layout):.0f}mm cavity)") + roles = {"front": "lid", "back": "tray" if P["walls"] else "back", "front_text": "colour inlay"} + builders = [("front", make_front), ("back", make_back)] + if P["front_text"]: + builders.append(("front_text", make_front_text)) # separate AMS 2nd-filament part + for part, builder in builders: + tris = builder(layout) + if not tris: # e.g. front_text with all zones blank + continue + path = os.path.join(out, f"badge_{ver}_{layout}_{part}.stl") + write_stl(path, tris, f"DEFCON badge case {ver} {P['fastener']} | {layout}_{part}") + g, cc = est_weight_g(tris) + bx, by, bz = bbox(tris) + if part != "front_text": # the inlay overlaps the lid — not extra plastic + total_plastic[layout] += g + issues = edge_manifold_issues(tris) + wt = "watertight" if issues == 0 else f"⚠ {issues} open edges" + tag = f"{part} ({roles[part]})" + print(f" - {tag:<12} {bx:5.1f} x {by:5.1f} x {bz:4.1f} mm " + f"~{g:4.1f} g plastic [{wt}] -> {os.path.relpath(path, here)}") + # add the fixed payload for a total badge weight guess + payload = 22 + 38 + 6 + 8 # oled + battery + firebeetle + fasteners + print(f" est. TOTAL badge: ~{total_plastic[layout] + payload:.0f} g " + f"({total_plastic[layout]:.0f} g plastic + {payload} g payload)") + + # keep stl/ to the CURRENT version — sweep any prior-version STLs into stl_archive/ + archive = os.path.join(here, "stl_archive") + moved = 0 + for fn in sorted(os.listdir(out)): + if fn.startswith("badge_") and fn.endswith(".stl") and f"_{ver}_" not in fn: + os.makedirs(archive, exist_ok=True) + os.replace(os.path.join(out, fn), os.path.join(archive, fn)) + moved += 1 + if moved: + print(f"\narchived {moved} prior-version STL(s) → stl_archive/ (stl/ keeps {ver})") + + print("\nNote: LID = window + button hole + M3 bosses + OLED collar/notch/pegs +") + print("centred loop + imprint; TRAY = floor (screw-clearance holes) + walls + FB") + print("standoffs (B: portrait, top-left) + USB-C through-slot in the −y/bottom wall +") + print("a full battery-retention collar (B, top-right) + honeycomb vents in the back floor") + print("AND the −x/+x side walls (B). Back insertion (heads on the rear). Each part is") + print("checked for watertightness (every edge shared by exactly two triangles).") + + +if __name__ == "__main__": + main() diff --git a/hardware/cad/gen_tophat.py b/hardware/cad/gen_tophat.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +r""" +gen_tophat.py — drop-on TOPHAT accessory for the badge case (novelty, sibling of gen_cat_ears). + +A hollow stovepipe tophat that sits astride the centred lanyard tab. The tab pokes up +into the crown; the lanyard clip lives INSIDE the hollow and the cord threads out a slot +in the crown top — so the badge looks like it's wearing a hat that's being lifted off by +the lanyard. Loads from ABOVE (straight down over the tab), friction-held. + + FRONT (looking at the badge face-on) SECTION (Z = case depth runs L→R) + + ╔═══════════════╗ crown, hollow ╔═══════════════╗ + ║ ┌───┐ slot ║ ║ ║ <- flat top + ║ │ │ ║ crown top with ║ ┌───────┐ ║ w/ cord slot + ║ │tab│ clip ║ the cord slot ║ │ hollow │ ║ + ═════╬═══╧═══╧════════╬═ hatband (2-col) ║ │ ┌──┐ │ ║ <- fins grip the + ▓▓▓ ║ ║ ═════╬═══╪══▓▓══╪═══╬═ tab's 9mm faces + ══╪═══╩═══════════════╩══╪══ brim on the ▓▓▓║ │tab│ │ ║▓▓▓ + │ badge │ top edge ══╧══╧══╧══╧══╧══╧══ brim ↔ top edge + │ │ 0 22 (Z, tray-wall-top depth) + +RETENTION: two fins inside the crown hug the tab's two 9mm-apart faces at a slide-fit +(TOL/side, a PETG push fit like the cat-ears frame). They're tied into the crown wall by +low webs so they aren't loose islands, and they grip over the tab's ~6mm straight run +(below the tab's rounded top). The brim bottoms out on the badge top edge — that flat +seat plus the tab's 22mm depth is what stops the hat pitching fore/aft. Anti-rotation is +free: the tab is a rectangular key. + +LANYARD ROUTING (v0.1): the cord loops OVER the tab's rounded top inside the hollow and +exits the crown-top slot; the clip stays captured below the slot. This bypasses the tab's +side-to-side bore (which the fins cover), so the hang point rises a few mm above the +bore's CoG-tuned line — fine for a novelty, may add a touch of pitch. A future variant can +split the fins with a bore relief to keep the tuned interface. + +Badge dimensions are IMPORTED from gen_stl (plate_outline / cavity_depth / P / ear_*), so +the hat can never drift out of sync with the tab it wraps. + +BUILD: the round bodies of revolution (brim, crown tube, roof, hatband) are built in a +local Z-up frame with annulus_slab and rotated onto the badge (local +Z → world +Y, a +det-+1 rotation so the watertight normals survive). The rectangular fins/webs are plain +world-space boxes. Everything composes by OVERLAP (same trick as gen_stl's bosses / the +lanyard tab): each piece is its own closed solid, the slicer unions them, so the reported +mesh has expected non-manifold seams at the overlaps — that's not a defect here. + +Print BRIM-DOWN, crown up: the opening is on the bed (no bridge there), fins and brim grow +straight up. The only overhang is the flat crown top — a ~29mm bridge broken by the slot; +add a few support lines or accept minor interior sag (the top SURFACE prints clean). + +Out: ./stl/accessories/tophat_<ver>.stl +""" + +import math +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from gen_stl import ( # noqa: E402 + P, plate_outline, cavity_depth, + ensure_ccw, rounded_rect, circle_loop, perimeter_sample, annulus_slab, box, + write_stl, bbox, edge_manifold_issues, mesh_volume_cc, signed_area, + _DENSITY_G_CC, git_rev, +) + +# ---------------------------------------------------------------------------- +# PARAMS — the hat's own numbers. Everything about the BADGE/tab is imported. +# ---------------------------------------------------------------------------- +VERSION = "v0.1" +LAYOUT = "B" # the production layout + +# --- the hat silhouette -------------------------------------------------------- +CROWN_OD = 34.0 # crown outer diameter +WALL = 2.6 # crown/brim/roof wall thickness +CROWN_H = 36.0 # total hat height above the badge top edge +CROWN_TOP_T = 2.6 # thickness of the flat crown top (the roof) +BRIM_OD = 48.0 # brim outer diameter +BRIM_T = 3.0 # brim thickness + +# --- the cord/clip exit slot in the crown top --------------------------------- +CLIP_W = 22.35 # measured lanyard clip-mechanism width (the long side must clear it) +SLOT_CLEAR = 1.0 # clearance added around the clip on the long side (X) +SLOT_HX = (CLIP_W + SLOT_CLEAR) / 2.0 # long half-width across the badge (X) -> 23.35mm slot +SLOT_HY = 3.5 # short half-depth (badge Z) -> 7mm slot (+1mm wiggle over the old 6mm) +SLOT_R = 2.5 # slot corner rounding + +# --- the hatband (a proud ring, a 2nd-colour swap target like the ear recess) -- +BAND = True +BAND_PROUD = 1.2 # how far the band stands off the crown +BAND_Z0, BAND_Z1 = BRIM_T - 0.4, BRIM_T + 3.6 # sit the band ON the brim (0.4 overlap) so its + # proud underside is backed by the brim top — no floating ledge, so + # no support material in a brim↔band gap (printed brim-down) + +# --- the tab socket (retention) ------------------------------------------------ +TOL = 0.30 # per-side slide clearance, fin ↔ tab face. Push fit in PETG. +FIN_T = 2.2 # fin thickness (outboard of the grip face) +FIN_H = 7.0 # fin height above the top edge (grips the tab's ~6mm straight run) +FIN_Z_INSET = 2.0 # fin ends held this far off the tab's front/back faces +WEB_H = 5.0 # tie-web height (fin outer face -> crown inner wall) +WEB_Z0, WEB_Z1 = 6.0, 16.0 # tie-web depth span (kept where a straight box stays inside + # the round wall — see the derivation below) +WEB_EMBED = 1.6 # how far the web reaches past the crown inner wall (into material) + +SEG = 96 # circle resolution for the revolved bodies + +MATERIAL = P["material"] +INFILL = P["infill"] + +# ---------------------------------------------------------------------------- +# DERIVED — badge/tab geometry, straight from gen_stl. No magic numbers. +# ---------------------------------------------------------------------------- +W, H = plate_outline(LAYOUT) # badge face, mm (top edge at y=H) +TAB_DEPTH = P["plate_t"] + cavity_depth(LAYOUT) # tab spans z 0..TAB_DEPTH (tray wall top) +CX = W / 2.0 # hat axis, x (tab is centred) +CZ = TAB_DEPTH / 2.0 # hat axis, z (tab depth centre) + +EAR_W = P["ear_w"] # tab width (x) +EAR_H = P["ear_h"] # tab protrusion above the top edge (y) +EAR_R = P["ear_corner_r"] # tab top-corner rounding +TAB_X0 = (W - EAR_W) / 2.0 # tab faces in x +TAB_X1 = TAB_X0 + EAR_W + +CROWN_ID = CROWN_OD - 2 * WALL # crown inner diameter +ROOF_OR = CROWN_ID / 2.0 + 0.5 # roof plug outer radius (0.5 into the wall) + +# fin x-bands: the grip faces sit TOL outboard of the tab faces +FIN_L_X1 = TAB_X0 - TOL # left fin inner (grip) face +FIN_L_X0 = FIN_L_X1 - FIN_T +FIN_R_X0 = TAB_X1 + TOL # right fin inner (grip) face +FIN_R_X1 = FIN_R_X0 + FIN_T +FIN_Z0 = CZ - TAB_DEPTH / 2.0 + FIN_Z_INSET # = FIN_Z_INSET +FIN_Z1 = CZ + TAB_DEPTH / 2.0 - FIN_Z_INSET + +OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "stl", "accessories") + + +# ---------------------------------------------------------------------------- +# local Z-up build -> world (local +Z is the hat axis -> world +Y, up out of the +# top edge). Rotation about world X by -90deg (det +1) so annulus_slab's +# by-construction normals survive; then translate the axis to (CX, H, CZ). +# ---------------------------------------------------------------------------- + +def _to_world(tris): + def m(p): + lx, ly, lz = p + return (lx + CX, lz + H, -ly + CZ) + return [(m(a), m(b), m(c)) for a, b, c in tris] + + +def _slot_loop(k): + """The cord-exit slot as a CCW loop of k points (a rounded rect centred on the axis).""" + rr = ensure_ccw(rounded_rect(-SLOT_HX, -SLOT_HY, SLOT_HX, SLOT_HY, SLOT_R, 16)) + return perimeter_sample(rr, k) + + +# ---------------------------------------------------------------------------- +# build +# ---------------------------------------------------------------------------- + +def make_hat(): + local = [] # tris built in the local Z-up frame (revolved bodies) + + # --- brim: a flat ring; inner = crown bore so the tab/fins pass through ------- + brim_out = circle_loop(0, 0, BRIM_OD / 2.0, SEG) + brim_in = circle_loop(0, 0, CROWN_ID / 2.0, SEG) + local += annulus_slab(ensure_ccw(brim_out), ensure_ccw(brim_in), 0.0, BRIM_T) + + # --- crown tube: the stovepipe wall (overlaps the brim by 0.4) ---------------- + crown_out = circle_loop(0, 0, CROWN_OD / 2.0, SEG) + crown_in = circle_loop(0, 0, CROWN_ID / 2.0, SEG) + local += annulus_slab(ensure_ccw(crown_out), ensure_ccw(crown_in), BRIM_T - 0.4, CROWN_H) + + # --- roof: the flat top, plugging the bore from inside, with the cord slot ----- + roof_out = circle_loop(0, 0, ROOF_OR, SEG) # 0.5 into the wall -> no coincident face + roof_in = _slot_loop(SEG) + local += annulus_slab(ensure_ccw(roof_out), ensure_ccw(roof_in), + CROWN_H - CROWN_TOP_T, CROWN_H) + + # --- hatband: a proud ring (2nd-colour swap) ---------------------------------- + if BAND: + band_out = circle_loop(0, 0, CROWN_OD / 2.0 + BAND_PROUD, SEG) + band_in = circle_loop(0, 0, CROWN_OD / 2.0 - 0.4, SEG) # 0.4 into the wall + local += annulus_slab(ensure_ccw(band_out), ensure_ccw(band_in), BAND_Z0, BAND_Z1) + + tris = _to_world(local) + + # --- retention: two grip fins + two tie-webs, in WORLD space ------------------ + y0 = H + y_fin1 = H + FIN_H + y_web1 = H + WEB_H + # fins hug the tab's x-faces over its depth + tris += box(FIN_L_X0, y0, FIN_Z0, FIN_L_X1, y_fin1, FIN_Z1) + tris += box(FIN_R_X0, y0, FIN_Z0, FIN_R_X1, y_fin1, FIN_Z1) + # webs tie each fin's outer face into the crown inner wall (embed WEB_EMBED past it) + wall_l = CX - CROWN_ID / 2.0 # crown inner wall x at the centre plane + wall_r = CX + CROWN_ID / 2.0 + tris += box(wall_l - WEB_EMBED, y0, WEB_Z0, FIN_L_X1, y_web1, WEB_Z1) + tris += box(FIN_R_X0, y0, WEB_Z0, wall_r + WEB_EMBED, y_web1, WEB_Z1) + + return tris + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + tris = make_hat() + + path = os.path.join(OUT_DIR, "tophat_%s.stl" % VERSION) + write_stl(path, tris, "badge tophat %s / case %s" % (VERSION, P["case_version"])) + + bw, bh, bd = bbox(tris) + vol = mesh_volume_cc(tris) # approx: overlaps double-count a little + grams = vol * _DENSITY_G_CC[MATERIAL] * INFILL + bad = edge_manifold_issues(tris) + + print("tophat %s (badge case %s, layout %s, git %s)" % ( + VERSION, P["case_version"], LAYOUT, git_rev() or "?")) + print(" sits on tab %.1f(x) x %.1f(up) x %.1f(z-depth) mm at badge top-centre" % ( + EAR_W, EAR_H, TAB_DEPTH)) + print(" hat envelope %.1f x %.1f x %.1f mm (%d tris)" % (bw, bh, bd, len(tris))) + print(" crown OD %.1f, ID %.1f, height %.1f, wall %.1f" % ( + CROWN_OD, CROWN_ID, CROWN_H, WALL)) + print(" brim OD %.1f, thick %.1f" % (BRIM_OD, BRIM_T)) + print(" cord slot %.1f x %.1f mm (r%.1f) in the crown top" % ( + 2 * SLOT_HX, 2 * SLOT_HY, SLOT_R)) + print(" tab socket fins %.1f thick, %.2f/side slide, grip %.1f mm tall%s" % ( + FIN_T, TOL, FIN_H, ", hatband band" if BAND else "")) + print(" material %s @ %.0f%% -> ~%.1f cc, ~%.0f g (approx, overlap-composed)" % ( + MATERIAL, INFILL * 100, vol, grams)) + print(" manifold %s" % ("OK (closed)" if bad == 0 else + "%d open edges (overlap-composed: brim/crown/roof/fins union)" % bad)) + print(" -> %s" % path) + + +if __name__ == "__main__": + main() diff --git a/hardware/cad/stl/accessories/caped_crusader_v0.1.stl b/hardware/cad/stl/accessories/caped_crusader_v0.1.stl Binary files differ. diff --git a/hardware/cad/stl/accessories/caped_crusader_v0.1_front.png b/hardware/cad/stl/accessories/caped_crusader_v0.1_front.png Binary files differ. diff --git a/hardware/cad/stl/accessories/caped_crusader_v0.1_iso.png b/hardware/cad/stl/accessories/caped_crusader_v0.1_iso.png Binary files differ. diff --git a/hardware/cad/stl/accessories/caped_crusader_v0.1_on_badge.png b/hardware/cad/stl/accessories/caped_crusader_v0.1_on_badge.png Binary files differ. diff --git a/hardware/cad/stl/accessories/caped_crusader_v0.1_on_badge_iso.png b/hardware/cad/stl/accessories/caped_crusader_v0.1_on_badge_iso.png Binary files differ. diff --git a/hardware/cad/stl/accessories/cat_ears_v1.0.stl b/hardware/cad/stl/accessories/cat_ears_v1.0.stl Binary files differ. diff --git a/hardware/cad/stl/accessories/cat_ears_v1.0_front.png b/hardware/cad/stl/accessories/cat_ears_v1.0_front.png Binary files differ. diff --git a/hardware/cad/stl/accessories/cat_ears_v1.0_iso.png b/hardware/cad/stl/accessories/cat_ears_v1.0_iso.png Binary files differ. diff --git a/hardware/cad/stl/accessories/cat_ears_v1.0_top.png b/hardware/cad/stl/accessories/cat_ears_v1.0_top.png Binary files differ. diff --git a/hardware/cad/stl/accessories/tophat_v0.1.stl b/hardware/cad/stl/accessories/tophat_v0.1.stl Binary files differ. diff --git a/hardware/cad/stl/accessories/tophat_v0.1_front.png b/hardware/cad/stl/accessories/tophat_v0.1_front.png Binary files differ. diff --git a/hardware/cad/stl/accessories/tophat_v0.1_iso.png b/hardware/cad/stl/accessories/tophat_v0.1_iso.png Binary files differ. diff --git a/hardware/cad/stl/accessories/tophat_v0.1_on_badge.png b/hardware/cad/stl/accessories/tophat_v0.1_on_badge.png Binary files differ. diff --git a/hardware/cad/stl/accessories/tophat_v0.1_top.png b/hardware/cad/stl/accessories/tophat_v0.1_top.png Binary files differ. diff --git a/hardware/cad/stl/badge_v1.51_B_back.stl b/hardware/cad/stl/badge_v1.51_B_back.stl Binary files differ. diff --git a/hardware/cad/stl/badge_v1.51_B_front.stl b/hardware/cad/stl/badge_v1.51_B_front.stl Binary files differ. diff --git a/hardware/cad/stl/badge_v1.51_B_front_text.stl b/hardware/cad/stl/badge_v1.51_B_front_text.stl Binary files differ. diff --git a/provisioning/index.html b/provisioning/index.html @@ -0,0 +1,568 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>badge :: set handle</title> +<style> + :root { --bg:#0a0e14; --fg:#c8d3e0; --accent:#3ddc97; --warn:#ffb454; --err:#ff5370; --panel:#121821; } + * { box-sizing:border-box; } + body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center; + background:var(--bg); color:var(--fg); font:16px/1.5 ui-monospace,Menlo,Consolas,monospace; } + .card { width:min(420px,92vw); background:var(--panel); border:1px solid #1e2733; + border-radius:12px; padding:28px; box-shadow:0 0 40px #0008; } + h1 { margin:0 0 4px; font-size:20px; color:var(--accent); letter-spacing:.5px; } + .sub { margin:0 0 22px; font-size:13px; color:#6b7a8d; } + label { display:block; font-size:12px; color:#6b7a8d; margin-bottom:6px; text-transform:uppercase; letter-spacing:1px; } + input { width:100%; padding:12px 14px; font:inherit; color:var(--fg); background:#0c1119; + border:1px solid #243040; border-radius:8px; outline:none; } + input:focus { border-color:var(--accent); } + .meta { display:flex; justify-content:space-between; font-size:11px; color:#4a5666; margin-top:6px; } + button { width:100%; margin-top:20px; padding:13px; font:inherit; font-weight:600; cursor:pointer; + color:#08110b; background:var(--accent); border:none; border-radius:8px; transition:opacity .15s; } + button:disabled { opacity:.4; cursor:not-allowed; } + button.secondary { margin-top:10px; background:transparent; color:var(--accent); border:1px solid #243040; } + #status { margin-top:18px; font-size:13px; min-height:1.4em; } + .ok { color:var(--accent); } .warn { color:var(--warn); } .err { color:var(--err); } + .hint { margin-top:20px; padding-top:16px; border-top:1px solid #1e2733; font-size:12px; color:#5a6878; } + /* browser gate — shown when Web Bluetooth is missing, so a recipient gets an + explanation instead of a button that silently does nothing. */ + .gate { display:none; margin:0 0 20px; padding:14px 16px; border-radius:8px; + background:#2a1520; border:1px solid var(--err); font-size:13px; line-height:1.5; } + .gate.show { display:block; } + .gate b { color:var(--err); } + .gate p { margin:0 0 8px; } .gate p:last-child { margin-bottom:0; } + .gate a { color:var(--accent); } + .home { display:block; margin-top:16px; font-size:12px; color:#5a6878; text-align:center; } + .home a { color:#5a6878; } +</style> +</head> +<body> + <div class="card"> + <h1>// badge handle</h1> + <p class="sub">connect over bluetooth &amp; set your username</p> + + <div id="gate" class="gate"></div> + + <label for="handle">handle</label> + <div id="handleWarn" style="display:none;color:#e0a83a;font-size:11px;margin:2px 0 4px;">⚠ no commas — they break the CSV / metrics export</div> + <input id="handle" maxlength="24" placeholder="e.g. crash_override" autocomplete="off" autocapitalize="off" spellcheck="false"> + <div class="meta"><span id="count">0 / 24</span><span>printable ASCII only</span></div> + + <label for="code">pairing code</label> + <input id="code" maxlength="3" placeholder="K7Q" autocomplete="off" autocapitalize="characters" spellcheck="false" style="text-transform:uppercase;letter-spacing:6px;font-weight:700;text-align:center;"> + <div class="meta"><span>the 3 chars shown on the badge screen</span><span>required</span></div> + + <button id="go">connect &amp; save</button> + <button id="sync" class="secondary">sync clock only</button> + <div id="status"></div> + + <div class="hint"> + Needs <b>Chrome / Edge</b> (Web Bluetooth) over <b>https</b> or <b>localhost</b>.<br/> + Put the badge in setup mode (either by holding the microcontroller's BOOT button on boot (requires opening the case), or <b>Options → Badge Settings → Developer Options → Provisioning Mode</b>), + then <b>type the 3-char code shown on its screen</b> above, hit connect, and pick + <code>badge-setup-&lt;MAC&gt;</code> (the full MAC matches your bench roster).<br/>The badge ignores + provisioning until that code matches, and <b>locks setup after 5 wrong codes</b> — power-cycle + it for a fresh one. <br/>Already named? Hit <b>sync clock only</b> to re-sync the + time (e.g. after a battery swap) without renaming — the badge reboots back to home. + </div> + + <div class="hint"> + <label style="margin-top:0;color:var(--accent);">custom avatar (optional)</label> + <input id="pet_name" maxlength="11" placeholder="name — e.g. ghost" autocomplete="off" spellcheck="false"> + <input id="pet_idle" maxlength="15" placeholder="idle face — e.g. (^_^)" autocomplete="off" spellcheck="false" style="margin-top:8px;"> + <input id="pet_contact" maxlength="15" placeholder="new-crew face — e.g. (o_o)!" autocomplete="off" spellcheck="false" style="margin-top:8px;"> + <input id="pet_milestone" maxlength="15" placeholder="milestone face — e.g. (^o^)" autocomplete="off" spellcheck="false" style="margin-top:8px;"> + <input id="pet_lonely" maxlength="15" placeholder="lonely face — e.g. (-_-)z" autocomplete="off" spellcheck="false" style="margin-top:8px;"> + <input id="pet_lowbatt" maxlength="15" placeholder="low-batt face — e.g. (&gt;_&lt;)" autocomplete="off" spellcheck="false" style="margin-top:8px;"> + <button id="petBtn" class="secondary">upload custom avatar</button> + <p style="margin:10px 0 0;font-size:11px;color:#5a6878;">Uploads to the badge's 4th avatar slot — pick it on the device: User Settings → Avatar.</p> + </div> + + <div class="hint"> + <label style="margin-top:0;color:var(--accent);">// onboard metrics (beta)</label> + <p style="margin:0 0 10px;font-size:11px;color:#5a6878;"> + Pulls the badge's encounter log + points history over BLE (plus the Test-Mode + CSVs <code>/test_batt.csv</code> / <code>/test_pts.csv</code> if Debug is on). + Put the badge in provisioning mode, enter its pairing code, then fetch. + </p> + <label for="mcode" style="margin-top:0;">pairing code</label> + <input id="mcode" maxlength="3" placeholder="K7Q" autocomplete="off" autocapitalize="characters" spellcheck="false" style="text-transform:uppercase;letter-spacing:6px;font-weight:700;text-align:center;"> + <button id="fetchBtn" class="secondary" style="margin-top:10px;">fetch from badge (ble)</button> + <label for="jsonFile" style="margin-top:12px;">…or import a JSON export</label> + <input id="jsonFile" type="file" accept=".json,application/json" style="padding:9px 10px;"> + <div id="report" style="margin-top:14px;"></div> + <button id="pdfBtn" class="secondary" style="display:none;margin-top:4px;">export figures to PDF</button> + <button id="jsonBtn" class="secondary" style="display:none;margin-top:4px;">download JSON</button> + </div> + + <p class="home"><a href="../">← screens, battery safety &amp; troubleshooting</a></p> + </div> + +<script> + // ── must match firmware/src/config.h ── + const SERVICE_UUID = "6e8f0001-b5a3-f393-e0a9-e50e24dcca9e"; + const CHAR_UUID = "6e8f0002-b5a3-f393-e0a9-e50e24dcca9e"; // handle + const TIME_UUID = "6e8f0003-b5a3-f393-e0a9-e50e24dcca9e"; // clock sync + const PET_UUID = "6e8f0004-b5a3-f393-e0a9-e50e24dcca9e"; // custom pet + const CODE_UUID = "6e8f0006-b5a3-f393-e0a9-e50e24dcca9e"; // 3-char pairing code (gates the others) + const DEVICE_NAME = "badge-setup"; + const MAX_LEN = 24; + + const $ = id => document.getElementById(id); + const handleEl = $("handle"), statusEl = $("status"), btn = $("go"), syncBtn = $("sync"), countEl = $("count"), petBtn = $("petBtn"), handleWarn = $("handleWarn"); + + // Set by the browser gate at the bottom of this script when Web Bluetooth is + // unavailable. Anything that re-enables a button has to respect it, or typing + // would quietly undo the gate. + let g_bt_blocked = false; + + // keep input to printable ASCII, clamp to MAX_LEN bytes (ASCII = 1 byte/char here) + handleEl.addEventListener("input", () => { + let v = handleEl.value.replace(/[^\x20-\x7E]/g, ""); + if (v.length > MAX_LEN) v = v.slice(0, MAX_LEN); + handleEl.value = v; + countEl.textContent = `${v.length} / ${MAX_LEN}`; + const hasComma = v.includes(","); // commas break the CSV export → warn + block provisioning + handleWarn.style.display = hasComma ? "" : "none"; + btn.disabled = hasComma || g_bt_blocked; + }); + + function setStatus(msg, cls="") { statusEl.className = cls; statusEl.textContent = msg; } + + // The badge latches its pairing gate after PROV_CODE_MAX_TRIES (5) wrong codes and drops + // the link — so a mid-flow disconnect is far more often "you mistyped the code a few + // times" than a real radio problem. The raw GATT error says none of that. + function friendlyError(e) { + const m = (e && e.message) ? e.message : String(e); + if (/disconnect|GATT Server is disconnected|NetworkError/i.test(m)) { + return m + " — note the badge locks setup after 5 wrong pairing codes. Power-cycle it " + + "and read a fresh code off its screen."; + } + return m; + } + + // Connect to a badge in setup mode and return its GATT service. + async function connect() { + if (!navigator.bluetooth) throw new Error("Web Bluetooth unavailable — use Chrome/Edge over https"); + setStatus("requesting device…"); + const device = await navigator.bluetooth.requestDevice({ + filters: [{ services: [SERVICE_UUID] }, { namePrefix: DEVICE_NAME }], + optionalServices: [SERVICE_UUID] + }); + setStatus("connecting…"); + const server = await device.gatt.connect(); + return { device, service: await server.getPrimaryService(SERVICE_UUID) }; + } + + // Write the 3-char pairing code shown on the badge. The badge ignores every other + // write until this matches, so call it right after connect() in every action. + async function writeCode(service, id = "code") { + const code = ($(id).value || "").trim().toUpperCase(); + if (code.length !== 3) throw new Error("enter the 3-char pairing code shown on the badge"); + const cch = await service.getCharacteristic(CODE_UUID); + await cch.writeValue(new TextEncoder().encode(code)); + } + + // Write the phone's current epoch (little-endian u32) to the clock characteristic. + async function writeTime(service) { + const tch = await service.getCharacteristic(TIME_UUID); + const tb = new Uint8Array(4); + new DataView(tb.buffer).setUint32(0, Math.floor(Date.now() / 1000), true); + await tch.writeValue(tb); + } + + async function provision() { + const handle = handleEl.value.trim(); + if (!handle) { setStatus("enter a handle first", "warn"); return; } + if (handle.includes(",")) { setStatus("remove the comma — it breaks the CSV/metrics export", "err"); return; } + btn.disabled = syncBtn.disabled = true; + try { + const { device, service } = await connect(); + setStatus("sending pairing code…"); + await writeCode(service); + setStatus("writing handle…"); + const ch = await service.getCharacteristic(CHAR_UUID); + await ch.writeValue(new TextEncoder().encode(handle).slice(0, MAX_LEN)); + // Best-effort clock sync so the con report can show real wall-clock times. + let synced = false; + try { await writeTime(service); synced = true; } catch (_) { /* time char optional */ } + device.gatt.disconnect(); + setStatus(`✓ sent "${handle}"${synced ? " + clock" : ""} — badge reboots to home if the code matched`, "ok"); + } catch (e) { + setStatus("✗ " + friendlyError(e), "err"); + } finally { + btn.disabled = syncBtn.disabled = false; + } + } + + // Re-sync the clock only (no handle change). The badge reboots to home if + // it already has a handle stored. + async function syncClock() { + btn.disabled = syncBtn.disabled = true; + try { + const { device, service } = await connect(); + setStatus("sending pairing code…"); + await writeCode(service); + setStatus("syncing clock…"); + await writeTime(service); + device.gatt.disconnect(); + setStatus("✓ sent — badge reboots to home if the code matched", "ok"); + } catch (e) { + setStatus("✗ " + friendlyError(e), "err"); + } finally { + btn.disabled = syncBtn.disabled = false; + } + } + + // keep custom-avatar fields to printable ASCII (element ids stay pet_* — see below) + ["pet_name","pet_idle","pet_contact","pet_milestone","pet_lonely","pet_lowbatt"].forEach(id => { + $(id).addEventListener("input", e => { e.target.value = e.target.value.replace(/[^\x20-\x7E]/g, ""); }); + }); + + // Write the custom-pet blob: name + 5 faces, newline-separated. + async function writePet(service) { + const f = id => $(id).value; + const blob = [f("pet_name"), f("pet_idle"), f("pet_contact"), + f("pet_milestone"), f("pet_lonely"), f("pet_lowbatt")].join("\n"); + const pch = await service.getCharacteristic(PET_UUID); + await pch.writeValue(new TextEncoder().encode(blob)); + } + + async function uploadPet() { + const name = $("pet_name").value.trim(); + if (!name) { setStatus("give your avatar a name first", "warn"); return; } + btn.disabled = syncBtn.disabled = petBtn.disabled = true; + try { + const { device, service } = await connect(); + setStatus("sending pairing code…"); + await writeCode(service); + setStatus("uploading avatar…"); + await writePet(service); + device.gatt.disconnect(); + setStatus(`✓ sent avatar "${name}" — if the code matched, pick it on the badge: User Settings → Avatar`, "ok"); + } catch (e) { + setStatus("✗ " + friendlyError(e), "err"); + } finally { + btn.disabled = syncBtn.disabled = petBtn.disabled = false; + } + } + + // ── onboard metrics / reports ────────────────────────────────────────────── + // The badge persists Test-Mode diagnostics as CSV on LittleFS. Two retrieval + // paths, both live: (1) BLE auto-fetch over the metrics characteristic, served + // by the firmware since v1.7 (PROV_METRICS_CHAR_UUID in firmware/src/config.h, + // stream_metrics() in main.cpp); (2) manual import of a serial + // `==== … ==== end ====` dump or a JSON export from a previous session. + const METRICS_UUID = "6e8f0005-b5a3-f393-e0a9-e50e24dcca9e"; // read/notify, gated by the pairing code + const fetchBtn = $("fetchBtn"), jsonFileEl = $("jsonFile"), reportEl = $("report"), + pdfBtn = $("pdfBtn"), jsonBtn = $("jsonBtn"); + // State from the last render, for the exports. g_figures = [{title,summary,svg}] (PDF); + // g_data = {device, exported, sections:[{name,header,rows}]} (JSON round-trip). + let g_figures = [], g_reportDevice = "", g_data = null; + const FIGURE_TITLE = { + "/encounters.log": "Encounter Log — Friends Ranking", + "/best_friends.csv": "Best Friends — Shared Time", + "/points_history.csv": "Points Over Time", + }; + + // Known export schemas. The BLE dump frames each source as `==== path ==== … ==== end ====`. + // /encounters.log is headerless TSV on the badge (we add the header + comma-ize on receipt); + // the rest carry their own CSV header. (firmware: encounters.h, points_history.h, main.cpp.) + const METRIC_SCHEMAS = { + "/encounters.log": "first_epoch,last_epoch,sightings,rssi_max,handle", + "/points_history.csv": "epoch,points,seg_start", + "/test_batt.csv": "boot,elapsed_ms,vbat_mv,soc_pct", + "/test_pts.csv": "boot,elapsed_ms,points,prox_s,peers,mult", + }; + + // Parse a CSV blob → {header:[], rows:[[...]]}; tolerates the dump's `==== … ====` + // frame lines by dropping them. + function parseCsv(text) { + const lines = text.trim().split(/\r?\n/).filter(l => l && !l.startsWith("====")); + if (!lines.length) return { header: [], rows: [] }; + return { + header: lines[0].split(",").map(s => s.trim()), + rows: lines.slice(1).map(l => l.split(",").map(s => s.trim())), + }; + } + + // ── inline SVG charts (self-contained — the page is used air-gapped) ──────── + const esc = s => String(s).replace(/[&<>"]/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;" }[c])); + const svg = (w, h, inner) => `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" style="display:block">${inner}</svg>`; + function fmtDur(s) { + s = Math.round(s); + if (s < 60) return s + "s"; + if (s < 3600) return Math.floor(s / 60) + "m"; + return Math.floor(s / 3600) + "h" + Math.floor((s % 3600) / 60) + "m"; + } + function chartNote(msg) { + return `<div style="height:40px;border:1px dashed #243040;border-radius:6px;display:flex;` + + `align-items:center;justify-content:center;color:#4a5666;font-size:11px;">${esc(msg)}</div>`; + } + // Horizontal bar chart: items = [{label, value, sub}], longest value = full width. + function barChart(items) { + const W = 300, rowH = 17, pad = 82, valW = 46; + const max = Math.max(...items.map(i => i.value)) || 1, h = items.length * rowH + 4; + let s = ""; + items.forEach((it, i) => { + const y = i * rowH; + const label = it.label.length > 12 ? it.label.slice(0, 11) + "…" : it.label; + const bw = Math.max(1, Math.round((W - pad - valW) * it.value / max)); + s += `<text x="0" y="${y+12}" fill="#8aa3b8" font-size="10" font-family="monospace">${esc(label)}</text>`; + s += `<rect x="${pad}" y="${y+3}" width="${bw}" height="10" rx="2" style="fill:var(--accent)"/>`; + s += `<text x="${pad+bw+4}" y="${y+12}" fill="#5a6878" font-size="9" font-family="monospace">${esc(it.sub)}</text>`; + }); + return svg(W, h, s); + } + // Line chart of ys over xs (parallel arrays), with min/max y labels. + function lineChart(xs, ys) { + const W = 300, H = 70, pad = 4; + const xmin = Math.min(...xs), xmax = Math.max(...xs), ymin = Math.min(...ys), ymax = Math.max(...ys); + const xr = (xmax - xmin) || 1, yr = (ymax - ymin) || 1; + const pts = xs.map((x, i) => { + const px = pad + (W - 2 * pad) * (x - xmin) / xr; + const py = (H - pad) - (H - 2 * pad) * (ys[i] - ymin) / yr; + return `${px.toFixed(1)},${py.toFixed(1)}`; + }).join(" "); + return svg(W, H, `<polyline points="${pts}" style="fill:none;stroke:var(--accent);stroke-width:1.5"/>` + + `<text x="0" y="9" fill="#5a6878" font-size="9">${ymax}</text>` + + `<text x="0" y="${H-2}" fill="#5a6878" font-size="9">${ymin}</text>`); + } + // Friends-ranking: aggregate the encounter log per handle by time-together, top 8. + function friendsChart(rows) { + const agg = {}; + for (const r of rows) { + const first = +r[0], last = +r[1], h = r[4]; + if (!h) continue; + (agg[h] || (agg[h] = { secs: 0 })).secs += (last > first ? last - first : 0); + } + const items = Object.entries(agg).map(([h, a]) => ({ label: h, value: a.secs, sub: fmtDur(a.secs) })) + .sort((x, y) => y.value - x.value).slice(0, 8); + return items.length + ? `<div style="font-size:10px;color:#5a6878;margin-bottom:3px;">top crew by time together</div>` + barChart(items) + : chartNote("no encounters logged yet"); + } + // Points-over-time: epoch (or row index if the clock was unsynced) vs cumulative points. + function pointsChart(rows) { + const valid = rows.filter(r => !isNaN(+r[1])); + if (valid.length < 2) return chartNote("no points history yet"); + const ys = valid.map(r => +r[1]); + const xs = valid.map((r, i) => { const e = +r[0]; return e > 0 ? e : i; }); + return `<div style="font-size:10px;color:#5a6878;margin-bottom:3px;">points over time</div>` + lineChart(xs, ys); + } + // Best-friends ledger: rows = [seconds, handle…] (handle last, may contain commas). Bar by time. + function bestFriendsChart(rows) { + const items = rows.map(r => ({ label: r.slice(1).join(","), value: +r[0], sub: fmtDur(+r[0]) })) + .filter(it => it.label && !isNaN(it.value)) + .sort((x, y) => y.value - x.value).slice(0, 8); + return items.length + ? `<div style="font-size:10px;color:#5a6878;margin-bottom:3px;">best friends by shared time</div>` + barChart(items) + : chartNote("no best-friends data yet"); + } + function chartFor(name, rows) { + if (name === "/encounters.log") return friendsChart(rows); + if (name === "/best_friends.csv") return bestFriendsChart(rows); + if (name === "/points_history.csv") return pointsChart(rows); + return ""; // diagnostics CSVs: numeric summary only (no battery chart, per design) + } + + // Per-section numeric summary + a section-appropriate chart, from structured data. + function renderSection(name, header, rows) { + const summary = header.map((h, i) => { + const v = rows.map(r => parseFloat(r[i])).filter(x => !isNaN(x)); + return v.length ? `${h}: n=${v.length} min=${Math.min(...v)} max=${Math.max(...v)}` : `${h}: —`; + }).join(" · "); + const chart = chartFor(name, rows); + if (chart && chart.includes("<svg")) // a real figure (not a "no data" note) → collect for the PDF + g_figures.push({ title: FIGURE_TITLE[name] || name, summary, svg: chart }); + const el = document.createElement("div"); + el.style.cssText = "margin-bottom:12px;font-size:11px;color:#8aa3b8;"; + el.innerHTML = `<b style="color:var(--accent)">${esc(name)}</b> — ${rows.length} rows<br>${summary}` + + (chart ? `<div style="margin-top:6px;">${chart}</div>` : ""); + reportEl.appendChild(el); + } + + // Import a JSON export (round-trip: re-render a saved dump without re-fetching the badge). + jsonFileEl.addEventListener("change", async () => { + if (!jsonFileEl.files.length) return; + try { + const obj = JSON.parse(await jsonFileEl.files[0].text()); + if (!obj || !Array.isArray(obj.sections)) throw new Error("not a badge-metrics JSON export"); + reportEl.innerHTML = ""; + g_figures = []; + g_data = obj; + g_reportDevice = obj.device || ""; + for (const s of obj.sections) renderSection(s.name, s.header || [], s.rows || []); + pdfBtn.style.display = g_figures.length ? "" : "none"; + jsonBtn.style.display = obj.sections.length ? "" : "none"; + setStatus(`✓ imported ${obj.sections.length} section(s) from JSON`, "ok"); + } catch (e) { + setStatus("✗ " + friendlyError(e), "err"); + } finally { + jsonFileEl.value = ""; // allow re-importing the same file + } + }); + + // Download the fetched/imported data as a structured JSON file (re-importable above). Direct + // Blob download — no library, so it works on the air-gapped page. + function exportJson() { + if (!g_data || !g_data.sections.length) { setStatus("nothing to export — fetch first", "warn"); return; } + const blob = new Blob([JSON.stringify(g_data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `badge-metrics-${(g_reportDevice || "badge").replace(/[^\w.-]/g, "_")}-${Date.now()}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + setStatus("✓ downloaded JSON", "ok"); + } + + // Export the collected figures to PDF via the browser's native print-to-PDF (no external + // library — the page is air-gapped): open a print-styled doc with each figure under a header. + function exportPdf() { + if (!g_figures.length) { setStatus("nothing to export — fetch first", "warn"); return; } + const w = window.open("", "_blank"); + if (!w) { setStatus("popup blocked — allow popups to export the PDF", "err"); return; } + const when = new Date().toLocaleString(); + const secs = g_figures.map(f => + `<section><h2>${esc(f.title)}</h2>` + + (f.summary ? `<p class="sum">${esc(f.summary)}</p>` : "") + + `<div class="fig">${f.svg}</div></section>`).join(""); + w.document.write( + `<!doctype html><html><head><meta charset="utf-8"><title>badge metrics report</title><style>` + + `:root{--accent:#2563eb}` + + `body{font-family:system-ui,-apple-system,sans-serif;color:#111;margin:28px;}` + + `h1{font-size:20px;margin:0 0 2px;}.meta{color:#666;font-size:12px;margin:0 0 22px;}` + + `section{margin:0 0 24px;page-break-inside:avoid;}` + + `h2{font-size:14px;margin:0 0 6px;border-bottom:1px solid #ddd;padding-bottom:4px;}` + + `.sum{color:#555;font-size:11px;margin:0 0 8px;font-family:monospace;word-break:break-word;}` + + `.fig{max-width:540px;}svg{max-width:540px;}svg text{fill:#333;}` + + `@media print{body{margin:0;}}` + + `</style></head><body>` + + `<h1>DEF CON badge — metrics report</h1>` + + `<p class="meta">${esc(g_reportDevice || "badge")} · exported ${esc(when)} · ${g_figures.length} figure(s)</p>` + + secs + `</body></html>`); + w.document.close(); + w.focus(); + setTimeout(() => { try { w.print(); } catch (_) {} }, 350); + setStatus("opened print view — choose “Save as PDF”", "ok"); + } + + // Split the reassembled dump into { path: content } by its `==== path ====` frame lines + // (`==== end ====` / `==== EOF ====` close the current section). Mirrors TestLog::dump framing. + function parseSections(text) { + const out = {}; let cur = null, buf = []; + const flush = () => { if (cur) out[cur] = buf.join("\n"); cur = null; buf = []; }; + for (const line of text.split(/\r?\n/)) { + const m = line.match(/^==== (.+?) ====$/); + if (m) { flush(); if (m[1] !== "end" && m[1] !== "EOF") cur = m[1]; } + else if (cur !== null) buf.push(line); + } + flush(); + return out; + } + + // Pull the metrics dump off the badge over BLE: connect → send the pairing code (the badge + // streams only once it matches) → subscribe to the metrics char → reassemble the notify chunks + // until the "==== EOF ====" sentinel → split the sections → render each. The badge reboots to + // discovery a few seconds after the stream, so it's one-shot per provisioning session. + async function fetchMetricsOverBle() { + fetchBtn.disabled = true; + let dev = null; + try { + const { device, service } = await connect(); + dev = device; + g_reportDevice = device.name || ""; // e.g. "badge-setup-14335C519F20" — for the PDF header + setStatus("sending pairing code…"); + await writeCode(service, "mcode"); // gate — the badge ignores the stream until this matches + const mch = await service.getCharacteristic(METRICS_UUID); + let text = ""; + const done = new Promise((resolve, reject) => { + const to = setTimeout(() => reject(new Error("timed out waiting for the badge stream (right code? still in provisioning mode?)")), 25000); + mch.addEventListener("characteristicvaluechanged", (e) => { + text += new TextDecoder().decode(e.target.value); // chunks are ASCII CSV/TSV + setStatus(`receiving… ${text.length} bytes`); + if (text.includes("==== EOF ====")) { clearTimeout(to); resolve(); } + }); + }); + setStatus("subscribing…"); + await mch.startNotifications(); // → badge onSubscribe → it streams (code accepted) + await done; + await mch.stopNotifications().catch(() => {}); + const sections = parseSections(text); + const names = Object.keys(sections); + reportEl.innerHTML = ""; + g_figures = []; + g_data = { device: g_reportDevice, exported: new Date().toISOString(), sections: [] }; + for (const name of names) { + let content = sections[name]; + if (name === "/encounters.log") // headerless TSV → add header + comma-ize for parseCsv + content = METRIC_SCHEMAS["/encounters.log"] + "\n" + content.replace(/\t/g, ","); + const { header, rows } = parseCsv(content); + g_data.sections.push({ name, header, rows }); // structured, for the JSON export + renderSection(name, header, rows); + } + pdfBtn.style.display = g_figures.length ? "" : "none"; + jsonBtn.style.display = g_data.sections.length ? "" : "none"; + setStatus(`✓ fetched ${text.length} bytes · ${names.length} section(s)` + + `${g_figures.length ? ` · ${g_figures.length} figure(s)` : ""} — badge reboots to home shortly`, "ok"); + } catch (e) { + setStatus("✗ " + friendlyError(e), "err"); + } finally { + try { if (dev && dev.gatt && dev.gatt.connected) dev.gatt.disconnect(); } catch (_) {} + fetchBtn.disabled = false; + } + } + + btn.addEventListener("click", provision); + syncBtn.addEventListener("click", syncClock); + petBtn.addEventListener("click", uploadPet); + fetchBtn.addEventListener("click", fetchMetricsOverBle); + pdfBtn.addEventListener("click", exportPdf); + jsonBtn.addEventListener("click", exportJson); + + // ── browser gate ─────────────────────────────────────────────────────────── + // Web Bluetooth is Chrome/Edge only, and needs https or localhost. Every iOS + // browser is WebKit underneath, so no iPhone can provision a badge — Chrome for + // iOS included. Say that up front instead of handing someone a dead button. + (function gateCheck() { + const gate = $("gate"); + const ua = navigator.userAgent; + const isIOS = /iPad|iPhone|iPod/.test(ua) || + (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); // iPadOS + const insecure = !window.isSecureContext; + if (navigator.bluetooth && !insecure) return; // all good, stay hidden + + let msg; + if (isIOS) { + msg = `<p><b>iOS can't do this.</b></p><p>Web Bluetooth doesn't exist on iPhone or + iPad — every iOS browser is Safari underneath, so Chrome for iOS won't help + either. Borrow an <b>Android phone or a laptop</b> running Chrome or Edge.</p>`; + } else if (insecure) { + msg = `<p><b>Insecure page.</b></p><p>Web Bluetooth only runs over <b>https</b> or + <b>localhost</b>. Open this page at its https address, or serve it locally + with <code>python3 -m http.server</code>.</p>`; + } else { + msg = `<p><b>This browser can't do Bluetooth.</b></p><p>Badge setup needs + <b>Chrome or Edge</b> on desktop or Android. Firefox and Safari don't + implement Web Bluetooth at all.</p>`; + } + gate.innerHTML = msg + `<p>Everything else on this page still works — you can + import a JSON export and read the charts.</p>`; + gate.className = "gate show"; + g_bt_blocked = true; + [btn, syncBtn, petBtn, fetchBtn].forEach(b => { b.disabled = true; }); + setStatus("bluetooth unavailable in this browser", "err"); + })(); + + // Offline shell, when hosted (badge.virtualshack.io/provision/ → ../sw.js). + // Harmless 404 when this file is opened straight out of the repo. + if ("serviceWorker" in navigator && window.isSecureContext) { + navigator.serviceWorker.register("../sw.js").catch(() => {}); + } +</script> +</body> +</html> diff --git a/web/src/assets/badge.css b/web/src/assets/badge.css @@ -0,0 +1,289 @@ +/* badge.virtualshack.io — shared shell + * + * Palette rule: parent-site chrome + * from virtualshack.io (hugo-coder dark: #212121 / #dadada / #42a5f5) with the + * badge's mint #3ddc97 kept as the sub-brand accent. The `//` heading motif + * mirrors the device OLED (// home, // storage, // options) — that through-line + * from screen → page → docs is the brand, keep it. + * + * Parent site is colorscheme-auto, so we honour prefers-color-scheme too. + */ + +:root { + --bg: #212121; /* virtualshack.io dark bg */ + --fg: #dadada; + --link: #42a5f5; /* parent accent */ + --accent: #3ddc97; /* badge sub-brand mint */ + --panel: #2b2b2b; + --rule: #424242; + --code-bg: #4f4f4f; + --muted: #8b949e; + --warn: #ffb454; + --err: #ff5370; +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #fafafa; + --fg: #212121; + --link: #1565c0; + --accent: #0f9d63; /* mint darkened for contrast on white */ + --panel: #efefef; + --rule: #d5d5d5; + --code-bg: #e4e4e4; + --muted: #5c6570; + --warn: #a05e00; + --err: #c62828; + } +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 15px/1.65 ui-monospace, Menlo, Consolas, monospace; + -webkit-text-size-adjust: 100%; +} + +.wrapper { max-width: 780px; margin: 0 auto; padding: 32px 20px 64px; } + +a { color: var(--link); text-decoration: none; } +a:hover, a:focus { text-decoration: underline; } + +h1, h2, h3 { line-height: 1.3; font-weight: 700; } +h1 { font-size: 26px; margin: 0 0 6px; color: var(--accent); letter-spacing: .5px; } +h2 { font-size: 18px; margin: 40px 0 12px; color: var(--accent); scroll-margin-top: 16px; } +h3 { font-size: 15px; margin: 24px 0 8px; color: var(--fg); } + +p, li { margin: 0 0 12px; } +ul, ol { padding-left: 22px; } + +code { + background: var(--code-bg); + color: var(--fg); + padding: 1px 5px; + border-radius: 3px; + font-size: .92em; +} + +pre { + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 8px; + padding: 14px 16px; + overflow-x: auto; /* diagrams scroll themselves; the page never does */ + font-size: 13px; + line-height: 1.5; +} +pre code { background: none; padding: 0; } + +.tagline { color: var(--muted); margin: 0 0 4px; } +.muted { color: var(--muted); } +.small { font-size: 13px; } + +hr { border: 0; border-top: 1px solid var(--rule); margin: 36px 0; } + +/* callouts — one shape, three severities */ +.note { + border-left: 3px solid var(--link); + background: var(--panel); + padding: 12px 16px; + border-radius: 0 8px 8px 0; + margin: 16px 0; +} +.note.warn { border-left-color: var(--warn); } +.note.stop { border-left-color: var(--err); } +.note :last-child { margin-bottom: 0; } +.note b { color: var(--fg); } +/* .log is a density modifier, not a fourth severity — the changelog is a list of many + short items, and the default 12px gap between them makes the callout enormous. */ +.note.log ul { margin: 6px 0 0; } +.note.log li { margin: 0 0 6px; } + +/* the big call-to-action to the provisioning app */ +.cta { + display: inline-block; + margin: 8px 0 4px; + padding: 12px 20px; + background: var(--accent); + color: #08110b; + font-weight: 700; + border-radius: 8px; +} +.cta:hover, .cta:focus { text-decoration: none; opacity: .9; } + +/* Non-link CTA while the setup app isn't hosted yet: same shape, but muted and inert. */ +.cta.disabled { + cursor: default; + background: var(--panel); + color: var(--muted); + border: 1px solid var(--rule); +} +.cta .soon { font-weight: 400; font-size: 13px; opacity: .85; } + +/* Back-to-top link above the footer. */ +.totop { + display: inline-block; + margin: 32px 0 8px; + font-size: 13px; + color: var(--muted); +} +.totop:hover, .totop:focus { color: var(--accent); text-decoration: none; } + +/* table of contents */ +.toc { border: 1px solid var(--rule); border-radius: 8px; padding: 14px 16px 6px; margin: 24px 0; } +.toc ul { list-style: none; padding: 0; margin: 0; columns: 2; column-gap: 24px; } +.toc li { margin: 0 0 6px; } +@media (max-width: 520px) { .toc ul { columns: 1; } } + +/* ── photos ────────────────────────────────────────────────────────────────── + Usage: + <figure> + <img src="assets/photo-badge.jpg" alt="a badge, screen lit, showing HOME" + width="1600" height="1200" loading="lazy" decoding="async"> + <figcaption>the whole point</figcaption> + </figure> + Always set width/height (the real pixel dimensions) — the browser reserves the + space before the file loads, so nothing jumps as photos stream in on con wifi. + loading="lazy" on everything below the fold; NOT on the first/hero image, which + should load immediately. A faint border keeps a dark photo from bleeding into + the dark page background. */ +img { max-width: 100%; height: auto; display: block; } + +figure { + margin: 24px 0; +} +figure img { + width: 100%; + border: 1px solid var(--rule); + border-radius: 8px; + background: var(--panel); /* shows while the image is still decoding */ +} +figcaption { + margin-top: 8px; + font-size: 13px; + color: var(--muted); + text-align: center; +} + +/* Clickable photo: small inline, full resolution in a new tab. + The <img> points at a ~900px thumbnail (assets/img/thumbs/), the <a> at the original — + so the page loads ~0.5MB instead of ~1.7MB, which matters on con wifi, and the detail + is still one click away. */ +a.photo { + display: block; + max-width: 420px; + margin: 20px auto; + text-decoration: none; +} +a.photo img { + width: 100%; + height: auto; + border: 1px solid var(--rule); + border-radius: 8px; + background: var(--panel); /* holds the space while decoding */ + transition: border-color .15s ease, opacity .15s ease; +} +a.photo:hover img, +a.photo:focus-visible img { border-color: var(--accent); opacity: .9; } +a.photo:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; } + +/* Persistent hint — without it nobody discovers the images are clickable. */ +a.photo::after { + content: "click to enlarge ⤢"; + display: block; + margin-top: 6px; + font-size: 12px; + color: var(--muted); + text-align: center; +} +a.photo:hover::after, +a.photo:focus-visible::after { color: var(--accent); } + +/* Portrait shots pair up nicely; .shots handles the collapse on narrow screens. */ +.shots a.photo { max-width: none; margin: 0; } + +/* Two or more photos side by side; collapses to one column on a phone. */ +.shots { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 16px; + margin: 24px 0; +} +.shots figure { margin: 0; } + +dl { margin: 0; } +dt { color: var(--accent); margin-top: 16px; } +dd { margin: 4px 0 0; } + +footer { + margin-top: 56px; + padding-top: 16px; + border-top: 1px solid var(--rule); + color: var(--muted); + font-size: 13px; +} + +/* offline/browser banners injected by JS */ +#banner { display: none; } +#banner.show { display: block; } + +/* ── gallery (gallery/index.html) ──────────────────────────────────────────── + Tiles are uniform 4:3 crops of the thumbnail (object-fit), so a mix of portrait and + landscape shots still lines up; the full, uncropped photo is one click away. Grid and + <img> markup are generated from the gallery manifest. */ +.gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 12px; + margin: 24px 0; +} +@media (max-width: 520px) { .gallery { grid-template-columns: 1fr 1fr; gap: 8px; } } +.gallery figure { margin: 0; } +.gallery a { display: block; border-radius: 8px; } +.gallery img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + border: 1px solid var(--rule); + border-radius: 8px; + background: var(--panel); + transition: border-color .15s ease, opacity .15s ease; +} +.gallery a:hover img, .gallery a:focus-visible img { border-color: var(--accent); opacity: .9; } +.gallery a:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.gallery figcaption { margin-top: 6px; font-size: 12px; color: var(--muted); } + +/* Lightbox: a full-viewport <dialog>. Always dark — it's a photo viewer, not page chrome. + The figure ignores pointer events so a click on empty space reaches the dialog (= close). */ +dialog.lightbox { + width: 100vw; height: 100vh; max-width: none; max-height: none; + margin: 0; padding: 56px 16px 24px; border: 0; + background: rgba(10, 10, 10, .97); + color: #dadada; +} +dialog.lightbox::backdrop { background: rgba(0, 0, 0, .6); } +dialog.lightbox figure { + margin: 0; height: 100%; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; + pointer-events: none; +} +dialog.lightbox img { + width: auto; height: auto; max-width: 100%; max-height: calc(100vh - 130px); + border-radius: 6px; pointer-events: auto; +} +dialog.lightbox figcaption { font-size: 13px; color: #bdbdbd; text-align: center; } +.lightbox button { + position: fixed; + min-width: 44px; min-height: 44px; /* thumb-sized */ + font: 20px/1 ui-monospace, Menlo, Consolas, monospace; + color: #dadada; background: rgba(43, 43, 43, .85); + border: 1px solid #424242; border-radius: 8px; cursor: pointer; +} +.lightbox button:hover, .lightbox button:focus-visible { border-color: #3ddc97; color: #3ddc97; outline: none; } +.lightbox .close { top: 8px; right: 12px; } +.lightbox .prev { left: 12px; top: 50%; transform: translateY(-50%); } +.lightbox .next { right: 12px; top: 50%; transform: translateY(-50%); } +.lightbox .count { position: fixed; top: 20px; left: 16px; font-size: 13px; color: #8b949e; } diff --git a/web/src/assets/icon.svg b/web/src/assets/icon.svg @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="onlyn00bs badge"> + <rect width="64" height="64" rx="12" fill="#212121"/> + <rect x="8" y="14" width="48" height="30" rx="4" fill="#0a0e14" stroke="#3ddc97" stroke-width="2"/> + <g fill="#3ddc97" font-family="ui-monospace,Menlo,Consolas,monospace" font-size="16" font-weight="700"> + <text x="15" y="35">//</text> + <text x="32" y="35" fill="#dadada">n00b</text> + </g> + <rect x="26" y="50" width="12" height="4" rx="2" fill="#424242"/> +</svg> diff --git a/web/src/assets/img/accessories.jpg b/web/src/assets/img/accessories.jpg Binary files differ. diff --git a/web/src/assets/img/all_colors.jpg b/web/src/assets/img/all_colors.jpg Binary files differ. diff --git a/web/src/assets/img/glow_1.jpg b/web/src/assets/img/glow_1.jpg Binary files differ. diff --git a/web/src/assets/img/glow_2.jpg b/web/src/assets/img/glow_2.jpg Binary files differ. diff --git a/web/src/assets/img/lanyard.jpg b/web/src/assets/img/lanyard.jpg Binary files differ. diff --git a/web/src/assets/img/og-badge.jpg b/web/src/assets/img/og-badge.jpg Binary files differ. diff --git a/web/src/assets/img/thumbs/accessories.jpg b/web/src/assets/img/thumbs/accessories.jpg Binary files differ. diff --git a/web/src/assets/img/thumbs/all_colors.jpg b/web/src/assets/img/thumbs/all_colors.jpg Binary files differ. diff --git a/web/src/assets/img/thumbs/glow_1.jpg b/web/src/assets/img/thumbs/glow_1.jpg Binary files differ. diff --git a/web/src/assets/img/thumbs/glow_2.jpg b/web/src/assets/img/thumbs/glow_2.jpg Binary files differ. diff --git a/web/src/assets/img/thumbs/lanyard.jpg b/web/src/assets/img/thumbs/lanyard.jpg Binary files differ. diff --git a/web/src/gallery/index.html b/web/src/gallery/index.html @@ -0,0 +1,111 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>onlyn00bs badge · photos</title> +<meta name="description" content="OnlyN00bs badges in the wild at DEF CON 34."> +<meta name="color-scheme" content="dark light"> +<meta property="og:title" content="onlyn00bs badge · photos"> +<meta property="og:description" content="OnlyN00bs badges in the wild at DEF CON 34."> +<meta property="og:type" content="website"> +<meta property="og:site_name" content="virtualshack"> +<meta property="og:url" content="https://badge.virtualshack.io/gallery/"> +<meta property="og:image" content="https://badge.virtualshack.io/assets/img/og-badge.jpg"> +<meta property="og:image:width" content="1200"> +<meta property="og:image:height" content="630"> +<meta name="twitter:card" content="summary_large_image"> +<meta name="twitter:image" content="https://badge.virtualshack.io/assets/img/og-badge.jpg"> +<!-- ?v= must match CACHE in ../sw.js — same rule as index.html --> +<link rel="stylesheet" href="../assets/badge.css?v=33"> +<link rel="icon" type="image/svg+xml" href="../assets/icon.svg"> +<link rel="manifest" href="../manifest.webmanifest"> +<meta name="theme-color" content="#212121"> +</head> +<body> +<main class="wrapper" id="top"> + + <h1>// photos</h1> + <p class="tagline">onlyn00bs badges in the wild · def con 34</p> + <p class="small muted"><a href="https://virtualshack.io/">virtualshack.io</a> / + <a href="../">badge</a> / photos</p> + + <p>Badges, not faces — on purpose. Everyone here signed up for a badge, not a photo.</p> + + <!-- The grid between these markers is generated from the gallery manifest. --> + <!-- gallery:begin --> + <p class="muted">Photos from the con are on their way.</p> + <!-- gallery:end --> + + <p class="small muted">Photos CC BY-SA 4.0 · virtualtack. + <a href="../#source">The badge is open source too.</a></p> + + <p style="text-align:center"><a class="totop" href="../">← back to the badge</a></p> + +</main> + +<dialog class="lightbox" id="lightbox" aria-label="photo viewer"> + <figure><img alt=""><figcaption></figcaption></figure> + <button class="close" type="button" aria-label="close">✕</button> + <button class="prev" type="button" aria-label="previous photo">←</button> + <button class="next" type="button" aria-label="next photo">→</button> + <span class="count"></span> +</dialog> + +<script> + // Lightbox. Progressive enhancement: every tile is a real link to the full image, so with + // no JS (or no <dialog>) a click just opens the photo. Modifier-clicks are left alone so + // "open in new tab" still works. + (() => { + const links = [...document.querySelectorAll(".gallery a")]; + const dlg = document.getElementById("lightbox"); + if (!links.length || !dlg.showModal) return; + const img = dlg.querySelector("img"); + const cap = dlg.querySelector("figcaption"); + const count = dlg.querySelector(".count"); + let i = 0; + + const show = (n) => { + i = (n + links.length) % links.length; + const a = links[i]; + const fc = a.closest("figure").querySelector("figcaption"); + img.width = a.dataset.w; // reserve the shape before the bytes arrive + img.height = a.dataset.h; + img.src = a.href; + img.alt = a.querySelector("img").alt; + cap.textContent = fc ? fc.textContent : ""; + count.textContent = `${i + 1} / ${links.length}`; + new Image().src = links[(i + 1) % links.length].href; // warm the next one + }; + + links.forEach((a, n) => a.addEventListener("click", (e) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return; + e.preventDefault(); + show(n); + dlg.showModal(); + })); + dlg.querySelector(".prev").addEventListener("click", () => show(i - 1)); + dlg.querySelector(".next").addEventListener("click", () => show(i + 1)); + dlg.querySelector(".close").addEventListener("click", () => dlg.close()); + dlg.addEventListener("click", (e) => { if (e.target === dlg) dlg.close(); }); // empty space + dlg.addEventListener("keydown", (e) => { + if (e.key === "ArrowLeft") show(i - 1); + if (e.key === "ArrowRight") show(i + 1); + }); + let x0 = null; + dlg.addEventListener("touchstart", (e) => { x0 = e.touches[0].clientX; }, { passive: true }); + dlg.addEventListener("touchend", (e) => { + if (x0 === null) return; + const dx = e.changedTouches[0].clientX - x0; + x0 = null; + if (Math.abs(dx) > 50) show(i + (dx < 0 ? 1 : -1)); + }); + dlg.addEventListener("close", () => img.removeAttribute("src")); + })(); + + if ("serviceWorker" in navigator) { + addEventListener("load", () => navigator.serviceWorker.register("../sw.js").catch(() => {})); + } +</script> +</body> +</html> diff --git a/web/src/index.html b/web/src/index.html @@ -0,0 +1,349 @@ +<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>onlyn00bs badge</title> +<meta name="description" content="OnlyN00bs — a DEF CON friend-finder badge. Setup, screens, battery safety, and what it broadcasts."> +<meta name="color-scheme" content="dark light"> +<meta property="og:title" content="onlyn00bs badge"> +<meta property="og:description" content="A DEF CON friend-finder badge. Setup, screens, battery safety, and what it broadcasts."> +<meta property="og:type" content="website"> +<meta property="og:site_name" content="virtualshack"> +<meta property="og:url" content="https://badge.virtualshack.io/"> +<!-- og:image MUST be an absolute URL — Discord, Slack and Twitter all refuse to resolve a + relative one, and the card silently renders with no image. 1200x630 (1.91:1) is the + shape those unfurlers crop to; feeding them the raw 4:3 photo means each platform + picks its own crop. --> +<meta property="og:image" content="https://badge.virtualshack.io/assets/img/og-badge.jpg"> +<meta property="og:image:type" content="image/jpeg"> +<meta property="og:image:width" content="1200"> +<meta property="og:image:height" content="630"> +<meta property="og:image:alt" content="Seven OnlyN00bs badges in coloured 3D-printed cases, screens lit showing the finder, points, report, best-friends, home and battery views."> +<!-- Without summary_large_image, Twitter/X and some clients render a small square thumbnail + instead of the wide card. --> +<meta name="twitter:card" content="summary_large_image"> +<meta name="twitter:title" content="onlyn00bs badge"> +<meta name="twitter:description" content="A DEF CON friend-finder badge. Setup, screens, battery safety, and what it broadcasts."> +<meta name="twitter:image" content="https://badge.virtualshack.io/assets/img/og-badge.jpg"> +<!-- ?v= must match CACHE in sw.js — bump BOTH on any content change. index.html is served + no-cache but /assets/ is not, and Cloudflare stretches it further (origin says + max-age=3600, the edge served 14400), so without a version in the URL a CSS change is + invisible to visitors for hours after a deploy. A new query = a new edge cache key = + it lands immediately. Safe for offline: sw.js matches with ignoreSearch, so this still + resolves to the precached bare URL. --> +<link rel="stylesheet" href="assets/badge.css?v=33"> +<link rel="icon" type="image/svg+xml" href="assets/icon.svg"> +<link rel="manifest" href="manifest.webmanifest"> +<meta name="theme-color" content="#212121"> +</head> +<body> +<main class="wrapper" id="top"> + + <h1>// onlyn00bs</h1> + <p class="tagline">a friend-finder badge for def con 34 · firmware v1.10</p> + <p class="small muted"><a href="https://virtualshack.io/">virtualshack.io</a> / badge</p> + + <p style="text-align:center"><a class="cta" href="provision/">Set up my badge →</a></p> + + <!-- Changelog. Newest first, CAPPED AT THE 3 MOST RECENT — add to the top and drop the + bottom entry, so this stays a glance and never grows into a wall. Older entries live + in git history, not here. Badge changes and page corrections both land in it, because + from a reader's side "the badge does something new" and "this page was wrong" are the + same question: is what I read last time still true? Keep entries to one line and + user-facing (what changed for them, not which commit did it). --> + <div class="note log"> + <p><b>What's new</b> <span class="small muted">— newest first</span></p> + <ul class="small"> + <li><b>2026-09-26</b> · <b>The badge is open source</b> — firmware, setup app, this site, + and the case models. <a href="#source">Source + license</a>.</li> + <li><b>2026-08-05</b> · New <b>Deep sleep</b> feature — put the badge into low power mode without unplugging the + battery. <code>Badge Settings → Power → Deep sleep</code>, then press the button to + wake it. <b>Reboot has moved into the same Power menu.</b> Badges flashed before + 2026-08-05 need a firmware update to get it.</li> + <li><b>2026-08-01</b> · <b>The setup app is live on this site</b> — hit + <a href="provision/">set up my badge</a> and go. Once you've loaded the PWA, it will + remain available for offline browser use.</li> + </ul> + </div> + + <nav class="toc"> + <ul> + <li><a href="#what">// the concept</a></li> + <li><a href="#hardware">// the hardware</a></li> + <li><a href="#software">// the firmware</a></li> + <li><a href="#case">// the case, lanyard, &amp; accessories</a></li> + <li><a href="#source">// the source</a></li> + <!-- the link appears once the gallery has photos --> + <!-- gallery-link:begin --> + <!-- gallery-link:end --> + <li><a href="#setup">// setup</a></li> + <li><a href="#screens">// usage</a></li> + <li><a href="#button">// the button</a></li> + <li><a href="#battery">// battery &amp; safety</a></li> + <li><a href="#trouble">// troubleshooting</a></li> + <li><a href="#privacy">// privacy</a></li> + </ul> + </nav> + + <h2 id="what">// the concept</h2> + +<p>OnlyN00bs is not just an amateur badge project for DefCon 34, it's an amateur badge project for DefCon 34 brought to life through relentless tokenmaxxing. Could anything be more appropriate for 2026?</p> + +<p>Each badge scans for fellow n00bs and feet enthusiasts, displaying real-time proximity information and beaming your own handle - and ASCII avatar - to those in range. Avatars are user-customizable — faces, name, the lot. Earn made up points. Earn even more made up points by being in range of your friends.</p> + +<p>Designed to be at least mildly secure, every beacon your badge sends and receives over ESP-NOW is HMAC-signed.</p> + + <h2 id="hardware">// the hardware (tl;dr)</h2> +<ul> + <li>ESP32-E (tariffs oof)</li> +<li>2.42" SSD1309 OLED (blue, yellow, and green available in limited quantities)</li> +<li>2000 mAh LiPo battery (12-14 hours per charge observed in testing)</li> +<li>USB-C charging (🫡 🇪🇺 )</li> +<li>A single button</li> +</ul> + + <h2 id="software">// the firmware (tl;dr)</h2> + <li>"Secure" communications using "military-grade encryption" (a.k.a. HMAC signing)</li> + <ul><li><p class="small">Currently clean on OpSec 👊 🇺🇸🔥</p></li></ul> + <li>Beyond simple friend radar, badges track encounters over time, best friend(s), and more.</li> +<ul><li>Points are awarded for time spent near fellow badge-holders, with group bonus multipliers</li></ul> +<li>Customizable integrated virtual ASCII avatar that celebrates milestones, notifies you of low battery (and when to shower), and more</li> +<li>Points-history graph with multiple views</li> +<li>Battery gauge (a little rough) and storage remaining gauge</li> +<li>Fully wireless BLE provisioning via PWA, on-screen pairing code to deter casual interlopers</li> +<li>Points and encounter data export for end-of-Con visualizations (currently in development)</li> + + <h2 id="case">// the case</h2> + <p>Each badge comes in a 3D printed glow-in-the-dark PETG plastic case. There are four primary color options: green, blue, pink (purple?), and multicolor.</p> + <p class="small">Note: ignore the lanyard in this photo, it was used for testing.</p> + + <a class="photo" href="assets/img/all_colors.jpg" target="_blank" rel="noopener"> + <img src="assets/img/thumbs/all_colors.jpg" alt="the four case colours side by side: green, blue, pink and multicolour" + width="900" height="677" loading="lazy" decoding="async"></a> + + <p>In order of glowieness ("luminescence" if you're snooty), the green is the brightest, followed by blue/multicolor, and pink:</p> + + <div class="shots"> + <a class="photo" href="assets/img/glow_1.jpg" target="_blank" rel="noopener"> + <img src="assets/img/thumbs/glow_1.jpg" alt="cases glowing in the dark, first comparison shot" + width="677" height="900" loading="lazy" decoding="async"></a> + <a class="photo" href="assets/img/glow_2.jpg" target="_blank" rel="noopener"> + <img src="assets/img/thumbs/glow_2.jpg" alt="cases glowing in the dark, second comparison shot" + width="677" height="900" loading="lazy" decoding="async"></a> + </div> + <h3 id="lanyard">// the lanyard</h3> + <p>Each badge will come with a lanyard:</p> + <a class="photo" href="assets/img/lanyard.jpg" target="_blank" rel="noopener"> + <img src="assets/img/thumbs/lanyard.jpg" alt="the lanyard that ships with each badge" + width="900" height="675" loading="lazy" decoding="async"></a> + <h3 id="accessories">// the accessories</h3> + <p>The two openings in the case were designed to be backwards compatible with DC33 OnlyFeet/N00b_Sec Croc jibbitz (and the rest of <a href="https://www.crocs.com/c/jibbitz" target="_blank" rel="noopener">Crocs Jibbitz</a> ecosystem... bring 'em if ya got 'em.). Equally, you can buy them from Crocs, 3D print them, and/or buy them from <a href="https://www.etsy.com/market/jibbitz" target="_blank" rel="noopener">Etsy</a>.</p> + <p>Additionally, a variety of 3D printed accessories (e.g., as seen in the photo above) will be provided for customization. These are still in development (but if you have an idea for something really funny, let me know.):</p> + <a class="photo" href="assets/img/accessories.jpg" target="_blank" rel="noopener"> + <img src="assets/img/thumbs/accessories.jpg" alt="3D-printed accessories that clip into the case openings" + width="900" height="675" loading="lazy" decoding="async"></a> + + <h2 id="source">// the source</h2> + <p>The badge is open source: the firmware, the setup app, this site, the parts list, + and the parametric case (STLs included, so you can print one without touching CAD).</p> + <p style="text-align:center"><a class="cta" href="https://git.virtualshack.io/onlyn00bs-badge/">Browse the source →</a></p> + <p class="small muted" style="text-align:center"><code>git clone https://git.virtualshack.io/onlyn00bs-badge.git</code> · <a href="https://git.virtualshack.io/onlyn00bs-badge/releases/">releases</a></p> + <ul> + <li><b>License:</b> MIT for the code; CC BY-SA 4.0 for the case models, docs and photos.</li> + <li><b>Not included: the group key.</b> Badges only see badges signed with the same key, so + build your own crew with your own key and it won't talk to ours. The README says how.</li> + <li><b>Also not included: anyone's data.</b> Encounter logs and the list of who got which badge + stayed private. The repo is the badge, not the con.</li> + </ul> + + <h2 id="setup">// setup</h2> + + <p>You need: the badge, a computer (with <b>Chrome or Edge</b>) or an Android phone, and about two + minutes. <b>Not an iPhone</b>: setup uses Web Bluetooth, which doesn't exist on iOS in any + browser.</p> + + <ol> + <li><b>Put the badge in setup mode.</b> From the badge: <code>OPTIONS → Badge Settings → + Developer Options → Provisioning Mode</code>. (You can also hold the microcontroller's + <code>BOOT</code> button while powering it on — but that means opening the case, so the + menu is the easier route.)</li> + <li><b>Read the 3-character pairing code</b> off the badge screen (something like + <code>K7Q</code>). This stops strangers from reconfiguring your badge.</li> + <li><b>Open <a href="provision/">the setup app</a></b>, type in your handle, the pairing code, optionally configure an avatar, then hit <em>connect &amp; save</em>.</li> + <li><b>Select your badge</b> from the browser's Bluetooth pop-up. It will show up as + <code>badge-setup-&lt;MAC&gt;</code>. The badge then reboots into normal mode with + your handle set, and its internal clock (used to timestamp your encounters — it isn't + shown on screen) set from your device.</li> + </ol> + <div class="note stop"><p>If that internal clock drifts after a battery swap — your con REPORT + times go "relative" — the same web app has a <em>sync clock only</em> button that re-syncs it + without touching your handle.</p> +</div> + + <div class="note warn"> + <p><b>Stuck in setup mode? Hold the badge's button for 5 seconds.</b> A badge in setup mode is + not beaconing and won't see anyone, and will sit there forever. Holding the button + counts down on screen and reboots straight to HOME.</p> + </div> + <p class="small">Note: A badge that has never been set up will name itself something like + <code>n00b-1858</code> on the way out, so that it works immediately.</p> + <h2 id="screens">// usage</h2> + + <p>Seven views, in a loop. Double-tap the button to walk forward through them:</p> + +<pre><code> double-tap walks down this list a single tap here does… + ┌────────────────────────────────────────────────────────────── + │ HOME handle, avatar, points, battery + │ FINDER who's in range, closest first page the list + │ REPORT your con so far + │ POINTS points over time, as a graph cycle 5d / 24h / 1h + │ FRIENDS best-friends ledger + │ BATTERY charge level flip to storage + │ OPTIONS settings move the cursor + └── and wraps back to HOME ──────────────────────────────────── + + hold the button 1.5s from anywhere → jump straight to HOME +</code></pre> + + <dl> + <dt>HOME</dt> + <dd>Your handle, your avatar, total points, and battery. The default view.</dd> + + <dt>FINDER</dt> + <dd>Who's in range right now, closest first. <b>Tap</b> to page through the list if + there are more badges than fit. A badge drops off ~15 seconds after its last + beacon.</dd> + + <dt>REPORT</dt> + <dd>Your con so far: an encounter summary, snapshotted when you arrive on the + screen.</dd> + + <dt>POINTS</dt> + <dd>Points over time as a graph. <b>Tap</b> to cycle the window: 5 days / 24 hours / + 1 hour.</dd> + + <dt>FRIENDS</dt> + <dd>Your best-friends ledger — who you've actually spent time near, ranked by shared + minutes.</dd> + + <dt>BATTERY</dt> + <dd>Live battery gauge. <b>Tap</b> to flip to the storage readout (how full the + badge's flash is).</dd> + + <dt>OPTIONS</dt> + <dd>Note: In OPTIONS, <b>tap</b> moves the cursor down a row, + <b>double-tap</b> selects it (and double-taps back out).</dd> + </dl> + + <h2 id="button">// the button</h2> + + <p>One button, three gestures:</p> + +<pre><code> tap → do this screen's thing (page the list, cycle the graph…) + tap tap → next screen + hold 1.5s → jump back to HOME (works from anywhere) + + in SETUP MODE only: + hold 5s → leave setup, reboot to HOME +</code></pre> + + <p>If you're ever lost, hold the button. You'll land on HOME.</p> + + <h2 id="battery">// battery &amp; safety</h2> + + <p>It's a lithium-polymer pouch cell and we're in the desert. Do the math.</p> + + <ul> + <li><b>Tested runtime:</b> about <b>14 hours</b>. Bring a power bank or charge nightly.</li> + <li><b>Remember</b> that LiPo batteries left empty for more than a few hours can be permanently damaged.</li> + <li><b>Charging:</b> via USB-C, roughly <b>4 hours</b> from empty to full. Charge it on a + hard surface — not on a hotel bed, a couch, or buried in a backpack.</li> + <li><b>Never</b> charge a pack that is hot, puffy, swollen, or has been crushed or + punctured. A swollen pouch is done; stop using it. <b>I'll give you a new one for free.</b></li> + <li><b>Never leave it in a parked car</b>, on a dashboard, or in direct sun. A closed car + in Vegas will cook a pouch cell well past anything it's rated for.</li> + <li>Don't leave it charging unattended.</li> + <li><b>Deep sleep is not "off":</b> a sleeping badge still draws a (very) small amount of power and will die/kill the battery eventually. For longer periods (8+ hours), charge it to roughly half and unplug the battery at the JST connector.</li> + <li>Dead or damaged cells go to battery-recycling (please don't toss them in the trash).</li> + </ul> + + <h2 id="trouble">// troubleshooting</h2> + + <dl> + <dt>The setup page has no "connect" button, or the button does nothing</dt> + <dd>You're on a browser without Web Bluetooth. Use Chrome or Edge on a laptop or + Android. iOS cannot do this at all — I wasn't lying before lol.</dd> + + <dt>FINDER shows nobody</dt> + <dd>In order of likelihood: (1) nobody is actually in range — the radio range is in the tens + of meters (and bodies absorb 2.4 GHz), (2) the other badge is in setup + mode, so it isn't beaconing, (3) mismatched builds — if multiple people see + nothing, compare firmware versions on <code>OPTIONS → Whoami</code>.</dd> + + <dt>My REPORT times look wrong or say "relative"</dt> + <dd>There's no clock on screen, but the badge still keeps time internally to timestamp your + encounters — and it has no battery-backed clock, so it gets the time from your phone or + laptop during setup. After a battery swap that can drift; the REPORT view then shows + <em>relative</em> times instead of real ones. Run <em>sync clock only</em> in the setup app + to fix it. Don't do this on an iPhone-only trip — you can't (sensing the theme yet?).</dd> + + <dt>I'm stuck on the SETUP MODE screen</dt> + <dd><b>Hold the button for 5 seconds.</b> The screen counts down and the badge reboots + to HOME. Works with no app and no network. One catch: it wants a <em>fresh</em> press, + so if you got here by holding <code>BOOT</code> through power-on, let go first, then + hold again.</dd> + + <dt>My badge won't connect over Bluetooth</dt> + <dd>It only advertises while in setup mode. If the browser's chooser is empty, the + badge isn't in setup mode — power-cycle it holding <code>BOOT</code>.</dd> + + <dt>It says the pairing code is wrong</dt> + <dd>The code is 3 characters shown on the badge screen and it changes each time the + badge enters setup mode (the charset deliberately has no + <code>0/O</code> or <code>1/I/L</code>).</dd> + + <dt>The badge says <code>locked, power-cycle</code></dt> + <dd><b>Five wrong pairing codes and the badge shuts its setup gate until it reboots</b> + — that's on purpose, so nobody can sit in range guessing codes at your badge. Power- + cycle it (hold <code>BOOT</code> if you want to go straight back into setup) and + you'll get a fresh code.</dd> + </dl> + + <h2 id="privacy">// privacy</h2> + + <p>It is a radio and DEF CON is full of weird people listening to radios.</p> + + <ul> + <li>Your badge broadcasts <b>your handle in cleartext, once per second</b>, plus a + stable hardware address that doesn't change.</li> + <li>The HMAC signature proves a beacon came from someone with the group key. It + <b>signs, it does not encrypt</b> — anyone sniffing 2.4 GHz nearby can read the + handles and follow a badge around the venue.</li> + <li>So: <b>pick a handle you're happy having in the air</b>, and assume presence is + public.</li> + <li>If you'd rather not be seen, <b>Badge Settings → Stealth</b> turns your broadcast + off. You can still see other badges; they can't see you.</li> + <li>Encounter logs, points, and your best-friends list stay on the badge. Nothing is + uploaded anywhere — the setup app talks only to the badge in front of you, and the + metrics export for post-con visualizations runs in your browser.</li> + <li><b>Nobody can reconfigure your badge over the air.</b> Setup only listens while the + badge is in setup mode.</li> + </ul> + + <p style="text-align:center"><a class="totop" href="#top">↑ back to top</a></p> + + <footer style="text-align:center"> + <p>onlyn00bs · def con 34<br/>lab grown in <a href="https://virtualshack.io/">virtualtack's virtualshack</a> with some help from Claude</p><br/> + <p class="small">If something here is wrong or + missing, please let me know.</p> + </footer> + +</main> +<script> + if ("serviceWorker" in navigator) { + addEventListener("load", () => navigator.serviceWorker.register("sw.js").catch(() => {})); + } +</script> +</body> +</html> diff --git a/web/src/manifest.webmanifest b/web/src/manifest.webmanifest @@ -0,0 +1,13 @@ +{ + "name": "onlyn00bs badge", + "short_name": "onlyn00bs", + "description": "Setup and field guide for the OnlyN00bs DEF CON friend-finder badge.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#212121", + "theme_color": "#212121", + "icons": [ + { "src": "assets/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/web/src/sw.js b/web/src/sw.js @@ -0,0 +1,94 @@ +/* badge.virtualshack.io — offline shell + * + * Why this exists: DEF CON's network is hostile and congested, and the setup app is + * useless if you can't load it at the moment you need it. + * Load the site once before you travel and it keeps working after. + * + * Strategy: precache the whole (tiny) site on install, then serve cache-first and + * refresh in the background. Cache-first is deliberate — on a bad network, "instant + * and slightly stale" beats "spinner forever". + * + * Bump CACHE on every content change, otherwise returning visitors keep the old copy + * until the background refresh lands (i.e. one reload behind). + * + * Bump the ?v= on the stylesheet <link> in index.html to the SAME number at the same time. + * index.html is served no-cache but /assets/ is not, so a CSS edit otherwise sits behind + * Cloudflare's edge cache for hours after a deploy. Keeping the two numbers equal means + * there is one version to remember, not two. + */ + +const CACHE = "onlyn00bs-v33"; + +// Resolved against the worker's own location, so it's right whether the site is served at +// / (production) or under a path (a local preview). +const GALLERY_IMG = new URL("assets/gallery/", self.location).pathname; + +const PRECACHE = [ + "./", + "index.html", + "assets/badge.css", + "assets/icon.svg", + "manifest.webmanifest", + // The setup app. Precached deliberately: this is THE page you need working on a hostile + // con network, and it is self-contained (no imports, no external assets), so one entry + // covers the whole flow. A --public build omits it and the cache.add 404s harmlessly + // (see allSettled below). + "provision/", +]; + +self.addEventListener("install", (e) => { + e.waitUntil((async () => { + const cache = await caches.open(CACHE); + // Individually, not addAll(): one 404 shouldn't fail the whole install and + // leave a recipient with no offline copy at all. + await Promise.allSettled(PRECACHE.map((url) => cache.add(new Request(url, { cache: "reload" })))); + await self.skipWaiting(); + })()); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))); + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", (e) => { + const req = e.request; + if (req.method !== "GET") return; + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; // never touch cross-origin + // Gallery photos are network-only. Everything else this worker sees gets cached as it's + // fetched, which is right for a ~2 MB site and wrong for a photo gallery: every photo + // anyone ever opened would sit in their cache until the next CACHE bump. The gallery + // page itself is still cached like any other page; only the images pass through. + if (url.pathname.startsWith(GALLERY_IMG)) return; + + e.respondWith((async () => { + const cache = await caches.open(CACHE); + const hit = await cache.match(req, { ignoreSearch: true }); + + const fresh = fetch(req) + .then((res) => { + if (res && res.ok && res.type === "basic") cache.put(req, res.clone()); + return res; + }) + .catch(() => null); + + if (hit) return hit; // cache-first + + const res = await fresh; + if (res) return res; + + // Offline and never cached: hand navigations the shell rather than a browser error. + if (req.mode === "navigate") { + const shell = await cache.match("./", { ignoreSearch: true }) || await cache.match("index.html"); + if (shell) return shell; + } + return new Response("offline — this page was never cached", { + status: 503, + headers: { "Content-Type": "text/plain" }, + }); + })()); +});