Skip to content

NixOS support - #379

Open
mrosseel wants to merge 423 commits into
brickbots:mainfrom
mrosseel:nixos
Open

NixOS support#379
mrosseel wants to merge 423 commits into
brickbots:mainfrom
mrosseel:nixos

Conversation

@mrosseel

Copy link
Copy Markdown
Collaborator

Summary

  • Full NixOS-based system for PiFinder (replaces Raspbian)
  • Declarative system configuration via Nix flake
  • SD card image, netboot, and migration bootstrap tarball builds
  • Software update via nixos-rebuild with GitHub release/PR channels

Test plan

  • Flash SD image and verify boot
  • Test WiFi AP and client mode switching
  • Test software update UI channels
  • Test hostname rename via web UI

🤖 Generated with Claude Code

@mrosseel mrosseel added the testable Ready for testing via PiFinder software update label Feb 12, 2026
@mrosseel mrosseel added testable Ready for testing via PiFinder software update and removed testable Ready for testing via PiFinder software update labels Feb 14, 2026
@mrosseel
mrosseel force-pushed the nixos branch 6 times, most recently from 485daaa to d021534 Compare May 28, 2026 12:22
mrosseel added a commit to mrosseel/PiFinder that referenced this pull request May 28, 2026
…cceed)

Brickbots/PiFinder runs the workflow on pull_request:synchronize from
PR brickbots#379 (mrosseel:nixos -> brickbots:main). With ATTIC_TOKEN now also
set on brickbots, build-emulated's 'Push to Attic' step succeeds — the
last failing step. But stamp-build then tries to checkout the PR head
ref (mrosseel:nixos) and 'git push' a pifinder-build.json commit there,
which can't work from brickbots' Actions runner (no write access to the
fork). The PR run therefore failed at the stamp step even after attic
was wired correctly.

Gating stamp-build on github.event_name == 'push' keeps the canonical
stamp on the mrosseel:nixos push run (where it works) and skips it on
brickbots PR runs (which only need to verify the build).

Net effect: both repos' CI runs in parallel without stomping —
- Both build and push the same closure to cache.pifinder.eu (attic
  FastCDC-dedups, so the second push is a no-op),
- Only mrosseel stamps pifinder-build.json,
- build-migration-tarball already gates on github.ref == refs/heads/nixos
  so it only runs on mrosseel push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mrosseel and others added 17 commits June 19, 2026 00:27
The 6 .direnv/ nix-direnv cache files were tracked but regenerate on
every direnv reload, so rebases baked divergent copies into the nixos
stack. Gitignore + untrack stops that churn going forward.

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

Replace the 524-line nixos/pkgs/python-packages.nix (26 manually packaged PyPI deps, each with hand-chased hashes and build patches) with a uv-managed workspace realized into the Nix store via uv2nix.

Changes:
* Deps declared in python/pyproject.toml, pinned in python/uv.lock (117 pkgs)
* nixos/pkgs/uv-python.nix builds the runtime/dev virtualenvs; the 5 native packages (python-libinput, python-prctl, python-pam, dbus-python, pygobject) keep their build patches as uv2nix overrides
* flake.nix: add pyproject-nix/uv2nix/pyproject-build-systems inputs, thread via specialArgs, devShell uses the uv2nix devEnv
* libcamera Python bindings stay a Nix overlay (not on PyPI)

All four nixosConfigurations + the devShell evaluate; the aarch64 build is to be validated by CI.

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

DeterminateSystems/magic-nix-cache-action now returns HTTP 418 and the
GitHub Actions cache rate-limits it (Twirp ResourceExhausted, "rate limit
exceeded"), so `nix develop` cannot fetch the dev environment and every
lint/test/type-check job fails before ruff/pytest/mypy even run — which all
testable PRs inherit.

Mirror build.yml/release.yml and substitute from the self-hosted Attic cache
cache.pifinder.eu (ADR 0004) instead, falling back to cache.nixos.org when
ATTIC_TOKEN is unavailable (e.g. fork PRs) so the job never hard-fails.

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

Changes:
* networking.nix: pifinder-wifi-fallback service+timer brings up PiFinder-AP when no client connects within 45s (or when AP is forced via the UI), so a device with an unreachable saved network stays reachable and AP survives reboot.
* sys_utils.py: persist chosen WiFi mode to PiFinder_data/wifi_mode for the fallback service to restore.
* software.py: show a clear 'No internet - check WiFi' screen instead of an empty channel list when GitHub is unreachable.
* utils.py: log why get_sys_utils() falls back to the no-op fake (previously swallowed) so a failed NM/dbus/pam import is diagnosable.
* remove dead Raspberry Pi OS scripts switch-ap.sh/switch-cli.sh (dhcpcd/hostapd; inert on NixOS).

Needs on-device validation of the AP fallback timing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When the Pi5 self-hosted runner is slow or unavailable, the fallback now uses GitHub's free hosted arm64 runner (public repos) and builds natively: no QEMU binfmt, no --system aarch64-linux cross flag, and extra-platforms dropped. Timeout cut 360 -> 60 min. Job id kept as build-emulated so downstream needs: references are unchanged.

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

These C-extension packages build from sdist in the uv2nix devEnv, but their
overrides did not declare a build backend. When the packages are not cached
(notably the x86_64 lint/test/type-check runners) the sdist build fails and
takes the whole devEnv down with it:

- dbus-python, pygobject: "No module named 'mesonpy'" -> add the meson-python
  build system via resolveBuildSystem (alongside the existing pkg-config + C
  libs).
- evdev: "No module named 'setuptools'" -> add the setuptools build system
  (evdev had no override at all).

Mirrors the existing python-libinput / python-prctl overrides, which already
use resolveBuildSystem { setuptools = []; }.

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

Surfaced by the first real build. In uv-python.nix:
* adafruit-blinka: ignore missing libgpiod.so.2 (vendored non-Pi SoC helpers)
* dbus-python/pygobject/pycairo: add ninja for the meson-python build
* pygobject: add pycairo for py3cairo.h
* evdev: add linuxHeaders and repoint setup.py's header search off /usr/include
* rpi-gpio/sh/spidev/pidng: provide the setuptools build backend
* python-pam: patch the installed wheel module in postInstall (no src/ at patch time)
pyproject.toml: empty [tool.setuptools] package set so the virtual root builds an empty wheel instead of tripping setuptools flat-layout discovery.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(a) Configure cache.pifinder.eu/pifinder as a public substituter (public key, no token) in build-emulated and the lint/test/type-check jobs, so fork-PR runs (brickbots) pull from the cache instead of building from source. Gate attic login + push on ATTIC_TOKEN so tokenless runs are pull-only and don't fail at push.
(b) Add always() to stamp-build so a skipped build-emulated (when build-native succeeds) no longer skips the stamp — green builds now update pifinder-build.json.
Also add nixos/brickbots-attic-setup.md: how to give brickbots a push token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- delete by NM connection UUID, not by id == ssid. The old match silently
  no-ops on any entry whose connection id differs from its SSID (e.g. the
  corrupt "0x20" entry whose SSID held byte-array text), so such networks
  could never be deleted from the UI.
- add: persist via add_connection(save_to_disk=True) first, then make a
  best-effort activate. Saving no longer depends on being able to activate
  right now, which failed (and saved nothing) when wlan0 was busy in AP mode
  or the network was out of range.
- get_wifi_networks: re-query NetworkManager live instead of returning a
  stale cache, so changes made outside the process (AP/CLI switch, repairs)
  show up without an app restart.
- decode SSIDs with errors="replace" so a single undecodable SSID can't
  crash the whole network list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NetworkManager (like other security-sensitive plugin loaders) refuses to
load any plugin file not owned by root. A store with non-root paths — uid
1000 baked into the migration tarball, or imported by single-user nix —
makes NM drop its wifi device plugin entirely: wlan0 shows as "unmanaged",
WIFI-HW as "missing", and no wifi client connection ever comes up.

Add an idempotent boot oneshot (ordered before NetworkManager) that
normalises /nix/store and the nix db dir back to root, remounting the store
read-write only when a repair is actually needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The uv2nix env builds RPi.GPIO 0.7.1 from PyPI. Its C module init calls
get_rpi_info(), which reads the board revision from
/proc/device-tree/system/linux,revision or the /proc/cpuinfo "Revision"
line. On a NixOS Pi 4 (arm64, mainline device tree) neither is present, so
the import raises "This module can only be run on a Raspberry Pi!" — and
since adafruit-blinka imports it via `board`, PiFinder crash-loops at
startup.

Add a postPatch that makes get_rpi_info() fall back to the always-present
/proc/device-tree/model string, synthesising a Pi 4 Model B revision when it
sees "Raspberry Pi". Detection then succeeds and the app starts.

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

/nix/store is a read-only bind mount of the same device as /. The repair
oneshot remounted it with "remount,rw"/"remount,ro", which operates on the
shared superblock, so restoring ro flipped / (and /nix/var) read-only too —
breaking the next pifinder-upgrade (nix build could not write temproots).

Carry "bind" on both remounts so only this mount's per-mount ro flag changes
and the rootfs superblock is left alone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
mrosseel and others added 30 commits July 18, 2026 16:44
python/result and python/result-lib are nix build output symlinks that were
committed before .gitignore's result/result-* rule existed, so they stayed
tracked and kept getting re-snapshotted into unrelated commits. Untrack them;
the existing ignore rule keeps them out from now on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXDxJkU5CzAegv3FcMEwEq
The NixOS migration UI flow (UIMigrationConfirm/UIMigrationProgress) was removed
from ui/software.py in 'refactor(software): delete the NixOS migration UI flow',
but a later sqm-fix merge reintroduced the test's import and spec builders for
those deleted classes, breaking test collection. Drop the stale references.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXDxJkU5CzAegv3FcMEwEq
test_radec_entry exercised an older dependency-injection API (BlinkingCursor
time_provider; CoordinateConverter/CoordinateEntryLogic calc_utils_provider;
LayoutConfig no-arg) that radec_entry dropped for module-level calc_utils/time.
Patch calc_utils/time at the module level and pass a display stub to
LayoutConfig so the suite matches the implementation. 6 stale tests were failing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PXDxJkU5CzAegv3FcMEwEq
Surface the ten shielded optical-black rows the IMX290/462 sensor already
transmits and use their trimmed mean as the complete per-frame pedestal for
radiometric SQM. This makes IMX462 SQM zero-touch: no lens cap, dark frames,
or calibration wizard from the user.

Kernel: a second sensor source pad (MEDIA_BUS_FMT_SENSOR_DATA) routes the OB
rows to the receiver as a separate metadata stream without altering the
1920x1080 image matrix.

libcamera: the imx290 cam-helper unpacks the shielded pixels, trims the outer
5% each side and averages the central 90% (preserving sub-ADU resolution on
the 16-bit SensorBlackLevels scale), publishing {l,l,l,l+1}. The +1 marker
lets Python tell a measured value from a static tuning tuple.

Python: camera_pi reads SensorBlackLevels for imx290/imx462 and passes the
native-ADU value as the radiometer sample's optical_black_pedestal. A valid OB
value is the complete pedestal; calibrated then profile pedestals remain the
fallback when OB is missing or unmarked.

Validated on mr2 (IMX462, gain 30) by a same-frame cupboard test: active-green
vs OB dark-accumulation slopes agree to well under 1 ADU/s, so OB is a valid
complete pedestal. Kernel is built via nixos-hardware; pifinder-fast/
pifinder-kernel-cross give a fast x86_64 cross build to seed the binary cache
so CI substitutes the kernel instead of compiling it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EWb2JET2JE1t4ya3HZPNL
Add the OB pedestal to the SQM architecture note and glossary, and record the
decision in ADR-0023. The justification is the same-frame cupboard test on
mr2's IMX462 (active-green vs OB slopes agree to <1 ADU/s), scoped to IMX462;
IMX296/IMX477 OB remain open. Defines 'optical black' as a glossary term and
sets the pedestal precedence (valid OB > calibrated > profile bias).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EWb2JET2JE1t4ya3HZPNL
python/.venv, mypy/pytest/ruff caches and __pycache__ directories are not
needed at runtime and inflated the source output to ~935 MiB, making Attic
cache pushes absurdly slow. Removing them drops it to ~19 MiB (cache push
~10-15 s, device upgrade fetch ~30-43 MiB).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EWb2JET2JE1t4ya3HZPNL
…stal

Gate BlackLevelTracker creation behind SQM_BLACK_LEVEL_TRACKER_ENABLED
(default False) so the radiometer pedestal is OB when present, else the static
profile bias_offset — no tracker in between. Lets us observe the optical-black
pedestal's effect in isolation during rollout/validation. Reversible: flip the
flag (or revert) to restore the tracker fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EWb2JET2JE1t4ya3HZPNL
All aperture/annulus/wing radii are defined for the ~1.0-scale Bayer-green
photometry images (imx462 green: 490px vs the 512 solve image). The mono
imx296 does photometry on its full-res 1088px frame (scale 2.125), where the
fixed radii sit inside the star profile: the r=5 aperture holds only 83-94%
of a star's flux (measured growth curves, 2026-07-18 sweeps), every local
background annulus lands on the PSF itself, and the wing estimator's sky
ring eats the wings so it measures f~1 and stays permanently inert.

Convert all radii from solve-image pixels to the photometry image's pitch
(_scaled_photometry_radii) and give WingEstimator a set_scale() that
rescales its patch geometry and clears the rolling window on change. Both
the production path (solver.update_sqm) and the calibration wizard's SQM
preview use the shared helper.

Effect by sensor:
- imx462/imx290: radii round to (5, 10, 17) - essentially unchanged.
- imx296: radii become (11, 21, 38); on the 2026-07-18 reference sweeps
  this moves the median error vs a hand-held meter from -0.20 to +0.00
  and lets the estimator track real focus drift.
- hq: also gets larger radii; its sqm_band_offset (like imx296's) should
  be refit against reference sweeps after this change.

Validated: 121 tests pass across test_sqm, test_solver_sqm,
test_sqm_calibration, test_sweep_frame_record, test_solver_shmem
(9 new tests cover the scaling).
The sensor's OB clamp pins raw black to a target that moves with sensor
state: on the 2026-07-18 imx296 reference sweeps the delivered level was
55.9-56.3 ADU while the device's wizard calibration (bias 58) and the
profile constant (60) both over-subtracted. At dark-site signal levels a
2-4 ADU over-subtraction produces a strong SQM-vs-exposure slope
(-0.9..-1.5 mag/decade) and kills short exposures outright
(background - pedestal <= 0, 'background unresolved').

The BlackLevelTracker already measures the in-session intercept but was
suppressed whenever a wizard calibration existed - precisely backwards: a
stored bias is one moment's measurement of a moving level. Flip the
priority in both SQM paths (radiometric and stellar): a confidently fitted
tracked intercept wins for bias; the wizard's dark-current rate remains
authoritative and is added on top (the intercept fit cannot separate dark
from sky). The tracker's own stderr / deviation-band / lease gates bound
how far it can stray, and an accepted fit >2 ADU from the static bias now
logs at info.

Replayed against the 2026-07-18 imx296 sweeps (radiometric feed, wizard
anchor 58): tracker conditions on all four (stderr 0.26-0.32), fits
55.9-56.3, and the recomputed SQM exposure slopes collapse from
-0.9..-1.5 to -0.01..+0.52 with the residual offset a tight constant
(sd 0.04) for a later band-offset refit.

Also: the published black_level_tracked flag now reflects the leased
pedestal actually used, not the raw last fit.

Validated: 136 tests pass across the SQM suites (4 updated/new).
Rich's 2026-07-18 calibration reports (imx296 + imx462, wizard run
interleaved with the reference sweeps) show two capture defects:

1. The first ~3 frames of every stage report actual_exposure_us at the
   PREVIOUS exposure (req=1us, actual=999999us) yet pass the frame gate,
   because _capture_and_wait checks the exposure_time field, which echoes
   the committed setting rather than what the sensor delivered. Stale
   full-length frames land in the bias/dark stacks.

2. After the exposure change lands, the sensor's optical-black clamp
   re-settles to a new level over ~3 more frames (imx296: 56 -> 59 ADU).
   Frames in that window measure neither the old nor the new black level.
   (On the imx296 the settled clamp is genuinely exposure-dependent:
   ~56 ADU at 1s vs ~59 ADU at the 29us minimum - a min-exposure bias is
   only valid for its own regime, which is why the tracked in-session
   pedestal takes priority in operation.)

Together these explain the wizard bias of 58 on a sensor whose 1s clamp
level is 56: a median over stale-1s frames at 56, settling frames, and
settled-29us frames at 59.

Fix: _capture_and_wait gates on actual_exposure_us (exposure_time only as
fallback), re-requests a capture when a stale frame arrives, and uses an
exposure-proportional tolerance; bias and dark stages discard
CLAMP_SETTLE_DISCARD_FRAMES frames after every exposure change (the dark
ramp changes exposure per step). Adds ~30-40s to a wizard run.

Evidence: dumps/logs/rich-imx296/sqm_calibration_report_imx296_*.json.

Validated: 141 tests pass across the SQM suites (5 new).
feat(sqm): per-frame optical-black pedestal for IMX290/IMX462 (zero-touch)
Telemetry menu under Settings > Telemetry:
- Record: inline On/Off toggle (no submenu), same pattern as Test Mode.
- Sections: one multi-select checklist with inline checkmarks (like the
  Catalogs filter) for what to record — IMU, SQM, Solves, Targets and
  Images. Toggling applies live to an in-progress recording.
- Max Size: session cap (250 MB / 500 MB / 1 GB / 2 GB / Unlimited).
- Load: unchanged.

Images ships OFF by default, preserving the old telemetry_images=false:
one 512x512 PNG is written per solve, so at ~1 solve/s it costs roughly
300 MB/hour against a few MB/hour for every other section combined.

Session size cap. Frames are written by the camera process, so the
recorder measures the session directory from its flush loop rather than
counting its own writes. On reaching the cap, frame capture is suspended
(the one unbounded consumer) while the event log keeps running, so a
capped session stays useful instead of going dark. Default 1 GB.

Also logs the SQM *ingredients*, not just the end products, so a session
stays recomputable if the SQM/airglow maths changes in code later:
- radio event gains the raw per-frame inputs — red/blue Bayer backgrounds,
  optical-black pedestal, photometry image size (green bg and exposure
  were already there).
- the session header snapshots the FULL camera profile constants plus the
  airglow calibration and camera_type — the constants those ingredients
  were produced under.
The derived published 'sqm' and applied 'floor' are still logged, but
explicitly as a what-the-device-showed audit trail, not ground truth.

The airglow import is optional: the model is part of the SQM stack only on
branches that carry it (deepchart has the radiometer but not airglow), and
there a session simply records no airglow constants.
pull_request_target defaults actions/checkout to the repository's default
branch. That branch carries this workflow so the event fires, but not
necessarily .github/scripts/, so update-manifest died with

    bash: .github/scripts/publish_manifest.sh: No such file or directory

after a successful 19-minute build -- the image was pushed to Attic but no
device could ever see it, because the manifest was never written.

Check out the PR's base branch instead: it is the branch this workflow file
itself came from, so the scripts are always beside it, and it stays
maintainer-controlled. Checking out the PR head here is not an option -- this
job holds contents:write and the Attic token.
The preview label did nothing testable did not. nixos-pr-build.yml checked
for either in both job conditions, and nothing downstream inspected the
label: update_manifest.py never receives it and writes every PR build to
channels["unstable"]. Upstream never had a preview label at all, so this
was fork-only. Removed from the workflow; the GitHub label is deleted and
testable's description now says what it actually does.

The channel docs claimed beta closures live in the retained,
GC-disabled pifinder-release cache. release.yml sends beta to the
short-retention pifinder cache instead -- "so prereleases stay
transient" -- so beta's rollback is durable only while its closure
survives GC. Corrected in ADR 0001, ADR 0002 and the nixos CONTEXT,
including the rollback-guarantee claims that followed from it. ADR 0002's
rejected "beta = live main head" option keeps its original reasoning with
an amendment banner noting the cache argument no longer distinguishes
beta; the curation symmetry with stable still does.

Also corrected: ADR 0003 said migration resolves the unstable trunk
because stable and beta are empty -- beta now holds v3.0.0-beta, so it
resolves beta. The "render the trunk row more prominently" delta in
ADR 0002 and the CONTEXT is implemented (software.py). The ADR README
index was missing 0004 through 0007.
chore(nixos): retire the preview label and correct stale channel docs
The BallTree behind both the object-list Nearby sort and the chart's
nearby-DSO markers was built and queried as [ra, dec] against sklearn's
haversine metric, which reads dimension 0 as latitude. Separations came out
right only between objects sharing a meridian, and worse towards the poles.
Index and query as [dec, ra].

Rank a bounded window (NEAREST_LIST_CAP = 200) rather than ordering the whole
catalog to draw nine rows. This bounds the k-NN query and the cursor-tracking
helper, which together were the per-frame cost during a slew.

Count what the carousel actually navigates. UIObjectList draws, scrolls, opens
and serialises _menu_items_sorted, so get_nr_of_menu_items() now measures that
list rather than the source; otherwise long-DOWN parks the cursor past the end
of the ranked window and opening that row raises IndexError. The catalog's own
object count stays the source length, reported in catalog_info_1.

Trigger the re-rank on great-circle separation rather than per-axis RA/Dec
degrees, and raise MAX_TIME to 10 s -- that trigger is for catalog and filter
changes, not pointing.

In the Nearby sort, hold the cursor on the top row while the user has not
scrolled, so the focused object tracks the pointing; pin it to the selected
object once they scroll off the top.

Also: implement SortOrder.RA and route both sort labels through one helper,
drop the redundant pre-sort nearby_refresh() in mm_change_sort, make the
unreachable 'No Solve Yet' message reachable, cache the spatial index on the
filter's dirty_time as UIChart already does, skip the scrollbar when the list
is empty, and hold off the in-frame re-rank until the index is built.

Tests place objects off a shared meridian and at high declination -- a
same-meridian check cannot observe the axis order at all. A UI regression test
drives long-DOWN then RIGHT over a catalog larger than the cap.

See docs/adr/0029-nearby-ranking-correctness-and-cost.md
fix(nearby): rank against true angular distance, in a bounded window
Conflicts resolved as in the delta-updates branch: nearby.py and
test_nearby.py taken from upstream (same fix, evolved in review as
PR brickbots#622); observations_db.py keeps this branch's shared identity cache,
which supersedes upstream's batch resolve; test_ui_modules.py takes
upstream (superset). Drop the duplicate 0029-nearby-ranking ADR —
upstream renumbered it to 0030 with identical content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registering gpio-poweroff replaces the firmware power-off handler instead
of adding to it. On a Pi 4B (rev 3) nothing answers the GPIO14 kill line,
so the handler waits out its 3 s timeout, logs a WARN backtrace and parks
the kernel. The screen keeps showing "Shutting Down" and the unit never
halts.

The image already builds both BCM2711 device trees and lets u-boot pick
one from the board revision, so the overlay list can differ per tree. The
4B tree now gets every overlay except gpio-poweroff. One image still
serves both boards, and a card moved between units corrects itself at the
next boot.

Raspbian gates the same overlay with a [cm4] config.txt filter (upstream
PR brickbots#639, ADR 0034). The firmware never reads config.txt overlay lines on
this boot path, so the per-tree overlay list is the equivalent here.
load_observed_objects_cache() stopped calling _resolve_object_ids() when
the cache moved to a single joined query (brickbots#623). Both tests still
overrode that method, so one counted zero calls and the other never
suppressed the object id it meant to suppress.

They now hook _query_observed_identities(), the seam the loader really
uses. The null-resolution test also overrides _resolve_object_id(),
because log_object() adds the id through that path before the loader
runs. The one-query test clears the process-wide cache first: logging
refreshes the fingerprint, and a warm cache short-circuits the rebuild
the test is there to measure.

_resolve_object_ids() now has no caller outside these tests.
Ten commits, from "Bumping version for release" to the rev4 dovetail
case fix.

Conflict resolutions worth recording:

- server.py, the eyepiece edit handler: both sides fixed the same bug
  differently. Upstream changed the lookup list to eyepieces; this branch
  had replaced update_eyepiece() with a direct assignment but kept the
  wrong list. Upstream's version wins. update_eyepiece() assigns and then
  sorts, so nothing is lost.

- ui/software.py: upstream brickbots#638 moves the Update Now and Cancel labels
  onto msg_top/msg_bottom. This branch replaced that screen with the
  channel browser and its confirm view, so the block the fix targets no
  longer exists. Kept this branch's file.

- locale: the catalogs merged as a union, then went through pybabel
  extract, update and compile. Every translation from both sides survives.
  pybabel's fuzzy guesses were dropped ("250 MB" matched to "25mm"), which
  leaves those strings untranslated and keeps the zero-fuzzy convention
  both sides follow. Four dead msgids from the old update screen went with
  it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

testable Ready for testing via PiFinder software update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants