Skip to content

refactor(catalog): single-source the object-type code set from OBJ_TYPES#21

Closed
mrosseel wants to merge 14 commits into
mainfrom
obj-type-centralize
Closed

refactor(catalog): single-source the object-type code set from OBJ_TYPES#21
mrosseel wants to merge 14 commits into
mainfrom
obj-type-centralize

Conversation

@mrosseel

Copy link
Copy Markdown
Owner

What

OBJ_TYPES (python/PiFinder/obj_types.py) becomes the single source of truth for the object-type code set. The allowed codes were hand-duplicated in three places that had already drifted.

  • Filter menu (ui/menu_structure.py) — the Type filter items are now generated from OBJ_TYPES (drops the hardcoded list).
  • default_config.json — removed the hardcoded filter.object_types array; the filter defaults to list(OBJ_TYPES) in catalogs.py.
  • obj_types.py — reordered to the filter-menu display order (iteration order only; lookups are unaffected) and documented as the canonical source.
  • Drift guard (tests/test_obj_types_docs.py) — fails if default_config.json re-hardcodes the set or the docs "Object type codes" table drifts from OBJ_TYPES.

User-facing change

The only visible effect is the Type filter menu adopting the same labels already used on the object-detail screen (they previously disagreed):

Before After
Cluster/Neb Cluster + Neb
P. Nebula Planetary
Double Str Double star
Triple Str Triple star
Unknown Unkn

Order is unchanged. No i18n regression — these msgids already exist and are translated in every locale (they are the detail-screen labels).

Test plan

  • 221 menu/UI tests, catalog/filter/config unit tests, and smoke tests pass; ruff clean.
  • New drift-guard test.

Note: the "Object type codes" docs table the guard checks ships in the related obslist CSV-import branch; the README check no-ops until both land on main.

🤖 Generated with Claude Code

brickbots and others added 14 commits June 22, 2026 10:12
…ill all translations (brickbots#488)

* i18n: fix web-template extraction and wrap missing UI strings

The Jinja2 extractor in babel.cfg pointed at `views2/` (which doesn't
exist) and the extract command only scanned `./PiFinder`, so the 141
`{{ _() }}` strings in python/views/*.html were never extracted — the
entire web UI fell back to English regardless of the selected language.
Point the extractor at `**.html` and add `./views` as a scan root
(babel.cfg, noxfile.py, and the i18n SKILL.md docs).

Also wrap user-visible device strings that were missing `_()`:
- menu_structure.py: Comets / Confirm / Cancel (their twins elsewhere
  were already wrapped, so these reuse existing msgids)
- location_list.py: action labels (Load/Rename/Delete) and the Loaded/
  Deleted/Renamed/Location-Name popups. English keys are kept for action
  dispatch; labels are translated at draw time via a small label map.
- callbacks.py: Location Reset / Time-Date Reset / user-object popup

And fix broken `_()` antipatterns:
- object_details.py: animated-ellipsis f-strings produced a different
  msgid per frame; translate the base word and append dots outside `_()`
- object_list.py: drop a no-op double `_()` wrap around a runtime string
- software.py: positional `{}` -> named `{mode}` placeholder

f-string popups are converted to named-placeholder `.format()` so
translators get stable msgids.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n: regenerate catalogs and AI-fill all translations (de/es/fr/zh)

Run the babel extract/update/compile pipeline against the corrected
config. The template (.pot) grows 508 -> 666 msgids: the newly-extracted
web-template strings plus the device strings wrapped in the previous
commit.

Fill every untranslated and fuzzy entry across all four languages
(181 each for de/es/fr, 186 for zh) with machine translations, each
tagged `# AI-TRANSLATED (claude): needs human review` so a reviewer can
find and validate them (`grep -rn AI-TRANSLATED python/locale/`).
Placeholders, newlines, and the EQ / RA/Dec / Alt/Az / GPS abbreviations
are preserved verbatim; gettext's `msgfmt -c` passes for all languages.
One low-confidence zh entry ("det {n}") is additionally flagged
`#, fuzzy` so it falls back to English until reviewed.

All languages are now fully translated. .mo binaries recompiled.

Note: the bulk of the .po line churn is unavoidable re-wrapping —
pybabel 2.16.0 does not reproduce the committed files' previous wrapping
at any --width, so any `nox -s babel` run reflows them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loading an observing (Ops) list pushed its objects to UIObjectList as a
"custom" list, and refresh_object_list() assigned that list straight
through without running it past catalog_filter. As a result, changing the
altitude filter (or any filter) had no effect on the list — the object
count never changed.

Run "custom" lists through catalog_filter.apply(), gated by a new
"filtered" flag in the item definition so only observing lists opt in.
Name-search results (the other "custom" consumer) stay unfiltered, so
searching for an object by name doesn't return zero results when it's
below the altitude cutoff.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
* feat: NixOS migration support

* fix(migration): align tetra3 sys.path with upstream

upstream defines utils.tetra3_dir as the inner package path
(python/PiFinder/tetra3/tetra3) and every sys.path.append(tetra3_dir)
site relies on that. migration had the short submodule-root path plus
an extra sys.path.append(tetra3_dir / 'tetra3') workaround in
solver_main.py and ui/preview.py, but solver.py never got the
workaround — so 'from tetra3 import cedar_detect_client' fails when
the inner module does the bare 'import cedar_detect_pb2'.

Take upstream's pattern verbatim: long tetra3_dir, single
sys.path.append, no workaround. Fixes PR brickbots#433's nox ui_tests
failure.

* fix+test: cover migration UIs in smoke harness + coerce progress to int

Two coupled changes for upstream's new test_all_ui_modules_covered
guard (PR brickbots#438):

- Wire UIMigrationConfirm and UIMigrationProgress into _DYNAMIC_IDS
  with item_definition fixtures. Their __init__ methods use .get()
  with defaults so a stub version_info dict exercises construction
  + key handlers.

- UIReleaseNotes stays in _COVERAGE_SKIP — its active() fetches
  markdown over HTTP and needs a network mock.

- UIMigrationProgress.update() was crashing under the smoke harness
  because sys_utils mock returned MagicMock for percent/status.
  Coerce percent to int and accept status only as str; on bad data
  keep the prior value. This also hardens against a corrupt
  /tmp/nixos_migration_progress JSON file at runtime.

* chore: prune dead code and move dev artifacts off the branch

Dead code dropped from the tree:
  * python/PiFinder/sys_utils_nixos.py (~591 lines): never imported,
    get_sys_utils() has no NixOS dispatch path. The NixOS-side system
    utilities ship inside the migration tarball as
    python/PiFinder/sys_utils.py on the nixos branch.
  * python/pyproject.toml dbus/gi mypy ignores: only made sense for the
    above; belong on the nixos branch.
  * python/scripts/migration_calc.py (~509 lines): described an
    unimplemented A/B-partition layout; no caller in the active flow
    (nixos_migration.sh uses nixos_migration_calc.py instead).

Out-of-tree (moved to local notes):
  * MIGRATION_BRANCH_STATE.md (107 lines): internal hand-off notes, not
    user-facing docs.
  * python/scripts/test_migration_loopdev.sh (~498 lines): offline test
    harness that re-implemented an older design (tar.gz + magic-header
    staging) rather than invoking nixos_migration_init.sh — it had
    already drifted from the real flow (which is tar.zst + RAM-staged).
    Useful as a future starting point for a real integration test but
    misleading to ship in the repo.

Documentation:
  * migration_gate.txt: add header comment explaining the killswitch
    contract; update _fetch_migration_gate parser to skip "#" lines so
    the file is self-documenting without breaking semantics.

* chore: tighten migration error handling (timeout, except, sha256 hard-fail)

Three small fixes from the PR review:

- ui/software.py UIMigrationProgress.update: replace the redundant
  `except (AttributeError, Exception)` with a targeted `AttributeError`
  guard around the sys_utils.get_migration_progress() lookup (only
  needed when running against sys_utils_fake). The helper itself
  swallows OS/JSON errors and returns {}, so wrapping everything in
  `except Exception` was hiding real failures from the polling loop.

- ui/software.py get_release_version: add timeout=REQUEST_TIMEOUT to the
  requests.get call (it previously had none and would hang the UI
  thread if GitHub stalled). Widen the except to RequestException so
  Timeout, ReadTimeout, etc. are all caught.

- sys_utils.start_nixos_migration: hard-fail with ValueError when neither
  migration_sha256_url nor migration_sha256 produces a value. Previously
  the helper logged a warning and returned "", which the migration
  script then treated as "skip checksum verification". An in-place OS
  replacement must not run without integrity verification.

* fix(migration): hex-encode SSID + escape PSK in NM keyfile emission

The initramfs WiFi-migration step generated NetworkManager .nmconnection
files by interpolating the SSID and PSK directly from wpa_supplicant.conf
into a heredoc. SSIDs containing characters with semantic meaning in NM
keyfile format (semicolon, brackets, equals, leading/trailing whitespace)
or in the filesystem (slash, NUL, "..") could break the connection file,
the file name, or both.

Failure mode: WiFi config goes missing after migration -> headless device
is unreachable until re-flashed.

Fixes:

- Encode SSID as semicolon-separated hex bytes (ssid=4d;79;...). This
  is NM keyfile's standard binary form and is safe for any byte content
  including non-ASCII and special chars.
- Escape the id= and psk= values for NM keyfile format: backslashes
  doubled, semicolons backslashed.
- Sanitize the filename to [A-Za-z0-9._-]; empty / "." / ".." after
  sanitization fall back to "wifi".
- Use printf %s instead of echo when feeding the parser, so SSIDs
  starting with "-" or containing backslash escapes are not mangled by
  echo's flag interpretation.

Verified end-to-end with a sample wpa_supplicant.conf containing spaces,
slashes, and a semicolon in the PSK -- files generated cleanly with the
expected escaping.

* feat(migration): SSD1333 display support + JSON config gate

ui: render migration status from frame 0, expose underlying error,
and allow back/exit on terminal pre-start failure so the user isn't
trapped on the failure screen.

* fix(migration): chown /nix/store to root after tarball extraction

The migration tarball can carry non-root (uid 1000) store paths; tar
preserves them, and NetworkManager then refuses to load its wifi plugin so
the migrated device comes up with wlan0 "unmanaged" and no wifi.

Normalise /nix/store and the nix db dir to root right after the rootfs move,
while the new root is still writable (it is mounted read-only once NixOS
boots). The fix-nix-store-ownership boot service is the runtime backstop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): keep initramfs progress display readable and flicker-free

- nixos_migration_init.sh: after the first stage, drive migration_progress
  with --update so the OLED framebuffer is overwritten in place instead of
  resetting/blanking to black between every stage
- shorten over-wide stage strings to fit the 128px display
  ("Loading tarball to RAM" -> "Loading tarball",
   "Validated (NMB free)" -> "Validated: NMB")
- migration_progress.c: render the "DO NOT POWER OFF" banner as a solid
  bright bar with black text for contrast
- migration_progress.c: auto-fit the variable-length stage name
  (scale down, truncate as last resort) so it never overflows either panel
- rebuild the static aarch64 migration_progress binary to match

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(migration): keep the OLED progress display alive across stages

The flicker-free --update path left the screen black. migration_progress
was spawned fresh per stage, and each process re-requested the RST GPIO
(driven low = panel reset) then skipped re-init, so the panel was wiped
after the first stage. Replace it with a single long-lived --serve process
that initialises the panel once and redraws in place from stdin, so the
display updates without ever resetting to black.

- migration_progress.c: add --serve (read '<pct> <num> <total> <name>'
  lines from stdin); drop the broken skip_reset/--update path
- nixos_migration_init.sh: start one --serve process fed via a FIFO on
  fd 3; ignore SIGPIPE so a dead display can never abort the migration
- nixos_migration.sh: drop the percent from the 'Downloading...' status so
  the screen no longer shows two mismatched percentages (download progress
  is already reflected in the overall bar)
- rebuild the static aarch64 migration_progress binary

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `bloom_remap` path swapped the arrow keys (and their LNG_/ALT_
variants) when screen_direction was "as_bloom". This is keyboard input
remapping, distinct from the IMU/display/camera orientation that the
"as_bloom" screen_direction otherwise drives — so strip it out.

Drops the `bloom_remap` parameter from run_keyboard() across the pi,
local, and none keyboard backends, removes the swap block in
KeyboardPi.__init__ (inlining the now-unconditional UP/DOWN/LEFT/RIGHT
keymaps), and stops main.py computing/passing the flag. The "as_bloom"
screen_direction value and its orientation handling are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review the release..main delta and update the v2.6.0 release notes:
add Polar Alignment Assist (brickbots#459) and Multi-Format Observing List
Import (brickbots#394); add comet-propagation speedup (brickbots#470) and missing bug
fixes (brickbots#472, brickbots#479, brickbots#465, brickbots#483, brickbots#473, brickbots#474); correct the Telemetry
menu path; note the menu reorganization (brickbots#480); refresh i18n (brickbots#488),
developer items (brickbots#478, brickbots#481), and the footer commit/file/line stats.
Excludes the NixOS migration (brickbots#433).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…al place/time, web i18n) (brickbots#489)

Cross-checked the release..main delta for 2.6.0 against the docs and filled
the four user-facing gaps that lacked coverage:

- quick_start: correct the stale "must be mounted perpendicular" claim — the
  new quaternion IMU works out its own orientation, so any mounting angle is
  fine; add a note that location/time can be entered by hand without GPS.
- user_guide: add the any-mounting-angle fact to How It Works; add a Push-To
  note that Mount Type -> Equatorial switches guidance to RA/Dec; add a new
  Star Chart section (Coordinate Sys. options, Zenith/NCP/SCP up labels, the
  "!" GPS-not-ready fallback) and a new Place & Time section (Enter Coords,
  Set Time/Date chaining, GPS-overwrite protection, usable without GPS).
- connectivity: document the web interface's DE/FR/ES language support and the
  Logs configuration switch/upload feature.

All claims verified against the code; sphinx-build -n (nitpicky) is clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…otes

Two statements didn't match the code:

- Mount Type -> Equatorial does not enable IMU tracking. The rewritten
  dead-reckoning runs identically for all mount types (mount_type is only
  read in aim_degrees to pick Alt/Az vs RA/Dec Push-To deltas). Reword so the
  equatorial-mount IMU win and the Mount Type readout setting are separate.
- The web interface language follows the connecting device's browser
  Accept-Language (server_locale in server.py), not the device UI Language
  setting, and is selected per request rather than after a restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rickbots#491)

The Polar Align adjust screen now always renders directional Alt/Az
arrows instead of +/- axis signs, regardless of the configured mount
type (PR brickbots#486). Regenerate the two affected user-guide screenshots so
the manual matches the shipped UI:

- polar_align_adjust_docs.png: "+" signs -> directional arrows
- polar_align_marking_menu_docs.png: arrows now show through the wheel
  (the background adjust screen previously showed "+")

The surrounding prose already says "follow the arrows", so no text
change is needed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s#492)

The Selenium web tests drive PiFinder's remote keypad with fixed key
sequences and assert the resulting screen. Two menu changes on main
shifted item indices, deterministically breaking 16 navigation tests:

- Objects submenu gained an "Obs Lists" item at index 3, shifting
  Custom (3->4), Name Search (4->5) and Set Filters (5->6).
- Start submenu reordered so GPS Status is now index 3 (was 2).

Update the affected key sequences plus their docstrings/comments:
- test_web_remote_filter.py (11): Objects->Set Filters hop RDDDDD->RDDDDDD
- test_web_remote_objects.py (2): Custom RDDDR->RDDDDR; Set Filters ->RDDDDDDR
- test_web_remote.py (2): Name Search RDDDDR->RDDDDDR
- test_web_locations.py (1): Start->GPS Status RDD->RDDD

Verified: all 16 pass against a live headless instance.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The 0001-0011 ADR namespace had collided under parallel-worktree work:
0003 (x3), 0004 (x2), 0005 (x2), and 0010 (x2) each had multiple files.

Resolution rule: the most-referenced file keeps the contested number
(ties -> earliest creation); latecomers move to the first globally-free
slot (0012+, since 0006-0008 are reserved by unmerged hardware branches).

Kept in place: object-image-orientation (0003), pygame-keyboard (0004),
focus-hfd-self-contained-in-ui (0005), zero-match-recovery (0010).

Renamed via git mv (history preserved):
- 0003-solver-integrator-message            -> 0012
- 0004-pointing-estimate-timing             -> 0013
- 0005-failed-solve-preserves-estimate      -> 0014
- 0010-user-docs-page-granularity           -> 0015
- 0003-pifinder-native-observing-list-format -> 0016

All in-repo references updated: positioning/catalog CONTEXT.md glossaries
and ax docs, the api_extensions/positioning.py docstrings, the obslist
schema.json, the docs skill pointer, the 0014->0012 cross-ref, and the
0016 title line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(nixos): add update_manifest.py helper

* ci(nixos): add publish_manifest.sh helper

* ci(nixos): add testable-PR build workflow (pull_request_target, label-gated)

* ci(nixos): simplify publish_manifest.sh (drop one-time collapse)

* ci: rotate Attic dev cache public key after S3 cutover (8UU… → Vkem…)
…tton, SSD1333 display (brickbots#498)

* Working ssd1333 and adjustments to brightness control for ssh1351, needs testing

* Fixed screen rotation, add in directional buttons

* Fix brightness chord on 5-column keypad matrix

The keypad matrix grew from 4 to 5 columns when the directional buttons
were added, which shifted every key's computed keycode (i*cols+j). The
matrix scanner still hard-coded SQUARE as keycode 15 (and the up/down
auto-repeat as [17, 18]), so the SQUARE+/- brightness chord and the
hold-to-repeat both pointed at the wrong keys.

Derive square_keycodes and repeat_keycodes from the keymap instead of
hard-coding indices, so they track the layout. This also makes the
brightness chord work from the new d-pad SQUARE and enables repeat on
both directional clusters.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Result of grilling session

* Add read-only BQ25895 battery telemetry (plumbing + tests)

Adds battery monitoring for the rev-4 board's TI BQ25895 single-cell
Li-ion charger (I2C 0x6A, bus 1). Read-only telemetry only: the sole
write is pulsing REG02 to trigger a one-shot ADC conversion; the power
path is managed in hardware (see docs/adr/0006). No UI/web/warnings yet.

- types/hardware.py: ChargeStatus enum, BatteryState, HardwareCapabilities
- battery_bq25895.py: pure decode_registers/estimate_soc, BQ25895 I2C
  wrapper, battery_monitor process (exits, never fakes, on init failure)
- battery_fake.py: -fh twin emitting a deterministic discharging cell
- hardware_detect.py: import-safe I2C probe + detect_capabilities()
- state.py: battery()/set_battery() + hardware()/set_hardware()
- main.py: module fork, publish capabilities, conditional Battery spawn
- pyproject.toml: mypy override for adafruit_bus_device.*

Register scaling verified against BQ25895-datasheet.pdf and live rev-4
readings. 39 unit tests cover decode, SoC interpolation/clamping,
charge-status mapping, and hardware detection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Link screen to BQM presence

* Drive GPIO14 low at shutdown to cut power (gpio-poweroff latch)

Provision the gpio-poweroff device-tree overlay (active_low) so the kernel drives GPIO14 low as the final shutdown step, tripping the LTC2954 power controller to drop EN on the TPS61088 boost and cut power. Free GPIO14 by disabling the serial console so the kernel does not drive console bytes onto the kill line. Document the latch in the Battery glossary and ADR 0007.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add POWER_BTN: power button opens/confirms shutdown menu

Wire the new hardware power button (GPIO15, POWER_BTN keycode) through
key dispatch to the UI. From any screen a press jumps to the shutdown
confirmation menu; on that screen the power button acts as the right
key (select), so a first press raises the Confirm/Cancel prompt and a
second press confirms.

- keyboard_pi.py: emit POWER_BTN on a >1s hold; init power_sent latch in
  __init__ (was read before assignment, raising AttributeError on the
  first hold) and drop the dead local + duplicate GPIO.setup.
- main.py: dispatch POWER_BTN to MenuManager.key_power.
- MenuManager.key_power: dismiss help/marking-menu overlay and swallow,
  else forward to the active module.
- UIBase.key_power: default jumps to the shutdown menu item.
- UITextMenu.key_power: on the shutdown screen act as key_right (select);
  every other text menu falls back to the jump default.
- server.py: expose POWER_BTN by name in /api/key's button_dict.
- docs/ax/ui: glossary entry for the power key + key_power in the key_*
  table and dispatch notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add Sound context docs: glossary, ADR, context-map entry

Documents the planned rev-4 buzzer sound system from the grilling
session — design only, no implementation yet:

- docs/ax/sound/CONTEXT.md: glossary (earcon, note, intent volume,
  master volume, important/transient earcon, stale/max-age, resonance)
- docs/adr/0008-sound-best-effort-delivery.md: best-effort,
  monotonic-stamped, latest-wins delivery; shutdown bounded-wait as
  the documented exception against the GPIO14 power latch
- CONTEXT-MAP.md: Sound context + its UI/shutdown/hardware-gating
  relationships

Implementation is handed off separately (handoff_sound.md, untracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add buzzer earcon sound subsystem (rev-4 PWM ch0)

v1 audible feedback for the rev-4 passive piezo buzzer (PWM ch0 / GPIO12):
named events become short earcons played on the buzzer.

- types/sound.py: pure, import-safe data model (Earcon enum, Note, EarconDef,
  PlayEarcon/SetVolume wire messages)
- sound.py: earcon catalog, pure logic (note_duty / total_duration_ms /
  select_winner), BuzzerPWM hardware seam, request() helper, and the
  sound_monitor process entry (mirrors battery_monitor)
- has_buzzer capability set from the rev-4 charger probe; the process spawns
  only on real Pi hardware, so dev/-fh stays silent and request() no-ops on
  the None queue
- wired producers: STARTUP, KEYPRESS, VOLUME_SAMPLE, SHUTDOWN
  (ERROR / LOW_BATTERY / SOLVE_LOCK defined but unwired in v1)
- best-effort delivery: monotonic-stamped, latest-wins drain, stale transients
  dropped, important earcons exempt (ADR 0008)
- shutdown cue routed through the main loop so its bounded wait reliably
  precedes the GPIO14 power latch
- Volume menu (Off/1-5, default 2) under User Pref + sound_volume config default
- unit tests for note_duty, select_winner, total_duration_ms, and a player
  smoke test with a fake driver

Tested on rev-4 hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add standalone earcon player CLI (python -m PiFinder.sound)

A command-line tool for tuning the buzzer by ear, reusing the existing
BuzzerPWM / play_earcon / catalog code (no duplication). Requires real
hardware (PWM ch0); only --list works without it.

- play_tone(): raw-tone primitive (absolute duty, bypasses the
  master-volume mapping, clamped to MAX_DUTY), sibling to play_earcon
- _run_cli(): argparse front-end with --list, --earcon (comma list or
  'all'), --all, --level, --tone FREQ:MS:DUTY, --sweep START:STOP:STEP:MS
  (find resonance), --duty, --repeat, --gap; builds the driver once and
  always stop()s it in a finally
- unit tests for play_tone (duty pass-through + clamping)
- tune KEYPRESS to 1000 Hz

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Adjust sounds

* Compose startup and shutdown earcons

Replace the placeholder STARTUP and SHUTDOWN note data with two tuned,
matched cues in G major:

- STARTUP: rising G5-A5-C6-G6 (do-re-fa-do'), resolving up an octave to a
  held tonic.
- SHUTDOWN: falling G6-C6-G5 (do'-fa-do), the startup's climb mirrored back
  down to the held low tonic.

They bookend each other (rise to wake, fall to sleep; each ends on the
other's starting pitch). Both use de-quantized note lengths for a looser,
less metronomic feel. Notes sit below the ~4 kHz resonance, so loudness
rises naturally toward the top. KEYPRESS is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Add battery level indicator to the title bar

Show a battery glyph in the title bar, left of the GPS/solver icons, on
battery-enabled (rev-4 BQ25895) hardware only. Driven off the published
BatteryState: charge_status picks a charging (bolt) glyph while the cell is
charging (when state_of_charge_pct is None), otherwise state_of_charge_pct is
quantized into ~20% buckets with an empty-outline glyph at <=10% remaining.

Gated on shared_state.hardware().has_bq25895 and a non-None battery() so it
stays hidden on non-battery boards and during the startup window before the
first sample, rather than showing a fake level. Positioned just left of the
GPS icon with a small proportional gap; battery hardware is always paired with
a 176px+ display, so it clears the rotating constellation/SQM without reflow.

Add unit tests covering the bucket boundaries, the charging states, and the
plugged-in-and-full (CHARGE_DONE) case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Adjust spacing for battery indicator

* Conditional placement of Constellation/SQM for battery indicator

* Turn off Raspberry Pi power LED on startup

Add sys_utils.set_power_led(on) to control the Pi's red PWR LED (a plain
gpio-led, on/off only — not dimmable) via passwordless sudo, matching the
other privileged helpers in the module. Mirror it as a no-op in
sys_utils_fake for off-hardware runs.

Call it from main.py right after the keypad backlights come up to switch
the LED off, since a fixed-on red light hurts night vision. Best-effort
and wrapped so it never blocks startup. No config.txt change, so the LED
returns to its firmware default at boot and is turned off once PiFinder
starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make boot splash resolution-flexible for the 176 panel

Cherry-picked from a48cd4c (ssd1333_ui_hw_test), splash.py portion only:
splash.py (pifinder_splash.service, a boot service separate from the main
app) hardcoded get_display("ssd1351") + the 128 welcome.png +
rectangle([0,0,128,16]). On real SSD1333 hardware that inits the wrong
controller. It now detects the panel via hardware_detect.detect_capabilities()
(rev-4 -> ssd1333, else ssd1351, mirroring main.py), scales the welcome image
to fill resX x resY, and spans the version/wifi banner across resX at a
resolution-scaled height. No-op on the 128 panel.

The companion ui/console.py fix from a48cd4c is already present on this
branch; the ssd1333-ui-flex-handoff.md dev artifact was intentionally omitted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Apply BQ25895 fast-charge config at runtime

The battery monitor now writes a fixed fast-charge configuration to the
rev-4 BQ25895 on every poll: disable the I2C watchdog (REG07), and raise
the input current limit (REG00 IINLIM) and fast-charge current (REG04
ICHG) to ~1.5 A. Out of reset the chip defaults to a 40 s watchdog, a
low (often 500 mA) input limit and 2048 mA charge, and the input limit
is the real bottleneck on charge rate.

Re-asserting each poll (rather than once at startup) restores the config
after a chip reset, brownout, or USB re-detection. It is idempotent:
plan_charging_writes() emits only the registers that have drifted, so
steady state is three reads and zero writes. EN_ILIM is preserved, so
the external ILIM-pin resistor stays a hardware ceiling; OTG/HIZ/
charge-enable are never touched (OTG/boost is disabled in hardware via
the /OTG strap).

This reverses the read-only stance of ADR 0006 (for charge-rate tuning,
not OTG safety); supersede it with ADR 0011 and update the battery
CONTEXT.md glossary to match. The encode/plan helpers are pure and
covered by tests/test_battery_config.py.

Verified live on a rev-4 unit: REG07 0x9D->0x8D, REG00 0x66->0x5C
(1500 mA), REG04 0x20->0x17 (1472 mA), idempotent on re-run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(adr): renumber 0011-battery-fast-charge -> 0017 (resolve cross-branch collision)

On main, ADR 0011 is observing-list-descriptions; this branch independently
created a second 0011 (battery-fast-charge-config). Renumber the branch's ADR
to 0017 (the next globally-free slot after main's renumber to 0012-0016) so the
two no longer collide when new_hardware_features merges into main.

Renamed via git mv (history preserved):
- 0011-battery-fast-charge-config -> 0017-battery-fast-charge-config

References updated: docs/ax/battery/CONTEXT.md, the 0006 supersede link,
battery_bq25895.py, and test_battery_config.py. ADR 0007 (gpio-poweroff)
references on the same lines were left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* i18n: translate "Volume" (de/es/fr/zh) for new sound menu

The new_hardware_features sound menu adds a `_("Volume")` setting. The
branch's prior `pybabel update` inserted the msgid into all four .po
files but left msgstr empty. Fill them in and recompile .mo:

  de: Lautstärke   es: Volumen   fr: Volume   zh: 音量

Each is tagged `# AI-TRANSLATED (claude): needs human review`. The
sibling `_("Off")` value reuses an existing, human-translated msgid, so
no new work was needed there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(battery): keep fast-charge input limit across cable replug while off

On rev-4 (BQ25895), unplugging and re-plugging the charge cable with the
PiFinder powered off dropped the input current limit back to ~500 mA and
left it there, since the per-poll re-assert only runs while the app is up.
The chip re-runs USB adapter detection (AUTO_DPDM_EN) on each VBUS
insertion and classifies the port as a low-current SDP.

The charger stays battery-powered when the system is off (the power-off
latch only drops the SYS boost) and the watchdog is already disabled, so
its registers persist. The only remaining trigger that reverts IINLIM is
adapter re-detection. Clear AUTO_DPDM_EN (REG02 bit 0) as part of the
fast-charge config so the configured 1.5 A input limit survives later
cable replugs with no software running, once the app has configured the
chip once.

Caveat: a full power-on reset (battery drained/disconnected) restores
AUTO_DPDM_EN=1, so the first insertion after that charges slowly until the
unit is next booted; the chip has no non-volatile config.

- battery_bq25895: add AUTO_DPDM_MASK; thread REG02 through
  plan_charging_writes / apply_charging_config (RMW, only bit 0 touched).
- tests: update signature, add a dedicated AUTO_DPDM case.
- docs: ADR 0017, battery CONTEXT.md, bq25895 design notes (incl. manual
  i2cset recipe and the DCP-signature hardware alternative).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Adjust screen rotation for PF4

* chore: apply pre-PR review fixes (ruff format + comment typos)

- sys_utils: two blank lines after set_power_led() (ruff format)
- ui/base: fix two battery-indicator comment/docstring typos
  ("indicated"->"indicator", "a bit of"->"a bit if the"); collapse
  a title-text draw() call to one line (ruff format)

Verified before PR: nox lint clean, nox format clean, mypy clean
(137 files), i18n complete (Volume/Off wrapped + translated +
compiled in de/es/fr/zh; no full-babel churn since .pot is untracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The allowed object-type codes were hand-duplicated in the filter menu,
default_config.json and the docs. Make OBJ_TYPES the one source:
- generate the Type filter menu items from OBJ_TYPES (drops the hardcoded list)
- default filter.object_types to list(OBJ_TYPES) instead of a JSON array
- order OBJ_TYPES to the filter-menu display order
- add a drift-guard test (README codes table + default_config)
@mrosseel

Copy link
Copy Markdown
Owner Author

Superseded by brickbots#511 (correct upstream target: brickbots main).

@mrosseel mrosseel closed this Jun 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants