Skip to content

release: Captua 0.1.0-beta - #437

Merged
juancobo merged 324 commits into
mainfrom
dev
Oct 26, 2026
Merged

juancobo merged 324 commits into
mainfrom
dev

Conversation

@juancobo

Copy link
Copy Markdown
Member

Merges dev into main for Captua 0.1.0-beta. Pins backend f0c2dd9 and frontend 2436a69, now on both submodules' main (backend #69, frontend #147). Release notes: CHANGELOG.md, section 0.1.0-beta.

Frontend #86 to #89: capture screen usable by finger (64px controls,
collapsible panel), project and volume in the capture top bar with a
symmetric review button, status count in the Reordenar row, tab leftovers
removed. Closes NEH-180 and NEH-138 on the appliance.
chore(submodule): bump frontend to 748ce1e
Backend #90 and frontend #90 (NEH-71): camera orientation persisted per
body, seeded on the capture screen and saved on every change.
chore(submodule): bump backend to 7f5aa0d and frontend to 1eb9e1d (NEH-71)
NEH-230: drag-to-pan and 1:1 in the review views, filename labels below the page (frontend #91).
chore(submodule): bump frontend to b5364aa (NEH-230)
NEH-222: the live preview reads from the still's own configuration (backend #91) and the viewport takes its shape from the frames (frontend #92).
chore(submodule): bump backend to 8ccc3b5 and frontend to bad7c51 (NEH-222)
NEH-162: the email is optional on account creation.
NEH-166: the dashboard's camera test takes a real still, returned inline and never stored.
chore(submodule): bump backend to f3f42f8 and frontend to c70cde1
NEH-228: the capture key is learned from the pedal rather than configured.
chore(submodule): bump frontend to cbd3ff1
The page walked an operator all the way through preparing cards and
connecting both bodies without ever saying that the capture itself has
never been run against these cameras. The backend captures over the wire
and neither it nor pychdk v0.1.2 falls back to the card, so if the A2500's
alpha CHDK port refuses remote capture the whole capture fails - and the
page as written would have had them find that out mid-session.
The page said a shared parity makes the appliance lay every body out in
bus order, so a healthy odd-shooting body could sit at index 0. That was
true of the backend as written and it was the bug: the healthy body
carried no refusal and its odd pages were filed as even ones. The backend
now keeps a claimed parity on its own index and moves only the cameras
actually sharing one, which is what this now describes.
Both were mine. A capture that fails evicts the camera, so saying it
still enumerates and previews reassures an operator about the one thing
that failure takes away - and would have them read a vanished camera as
an unrelated fault. And only an assigned parity holds an index: a camera
with no parity yet can be pushed to another index when a third appears,
so 'never moved by one' was the inference and not the guarantee.
…parity

The sentence I wrote to replace one overclaim was itself unconditional:
a camera with EVEN on its card does move when a second EVEN arrives
ahead of it on the bus, which is what the rest of the same paragraph
goes on to describe. The opening now carries the condition the paragraph
was already explaining.
feat(setup): CHDK camera provisioning and the operator guide (NEH-231)
Brings in the CHDK backend for NEH-231: enumeration over PTP/USB, live
view decoding, remote capture straight down the wire, and the page-parity
layout. 471 tests from a 306 baseline. Acceptance is the bench - no part
of this has been run against a camera.
Backend picks up the pychdk v0.1.3 pin (#99) on top of NEH-255. Frontend
advances ten commits to its dev tip, which the superproject has not
tracked since cbd3ff1.
chore(submodule): bump backend to cb578e5 and frontend to 9178252
rollback-update.sh returned the git checkout and the database and then
said "The appliance is back on the previous version". That was false.
update.sh had already rebuilt the frontend image from the new code and
resolved the pixi environment against the new lock, and rollback
rebuilds neither, so the running system was a hybrid: old code, new
environment, new frontend image.

Rebuilding either one needs the registries, and rollback is exactly
when a remote unit is already in trouble. So this does not attempt the
repair; it stops claiming it happened. The summary now reports per
component - database, code, pixi environment, frontend image - whether
each corresponds to the version now checked out, and names the command
that would fix the ones that do not.

Getting that right needed two facts rollback could not previously
know, both recorded by update.sh:

The digest of the pixi lock the update installed against. Reported as
its own state rather than as an environment verdict, because start.sh
runs `pixi run start`, which installs when required, so by the time
the summary prints, the restart may already have reinstalled against
the restored lock. The script says it does not check which.

The image of the running frontend container, recorded beside
last-deployed-SHA on a SUCCESSFUL update. Not the tag, which moves
before anything stops, and not "whatever was serving before this
attempt": a failed update leaves the new image serving while
last-deployed-SHA still names the old commit, so a retry would
otherwise record an image that never belonged to the commit being
restored.

Every path that cannot establish correspondence says UNKNOWN and still
names the remedy, rather than reading as benign. That covers a missing
or unreadable record, one already pruned by the retention policy, a
record truncated mid-write (a short image id compared against a full
one would read as a definite mismatch), a dump chosen from outside the
backup directory, a symlink standing in for either the record or the
dump, and a code rollback that was skipped because the commit was
unavailable.

Records are tied to their attempt rather than to a commit.
last-deployed-SHA only advances on success, so several aborted
attempts share one previously-deployed value; a reference written in
the same step as the dump identifies which attempt the dump belongs
to.
On the first boot of a freshly provisioned Pi 5 the Ctrl+Alt+F2
terminal escape was dead. Everything install-kiosk-service.sh
provisions was correct, but thd had opened only 6 input devices and
not the keyboard interface carrying the F-keys: its input node
appeared after thd enumerated. `systemctl restart triggerhappy`
brought it to 9 and the escape worked. An escape hatch that needs
someone to SSH in and restart a daemon is not an escape hatch on a
sealed appliance.

thd opens /dev/input/event* once, at start, via --deviceglob. A node
appearing later reaches it only through the packaged udev rule, so
this adds a sync helper run from our own rule (61-, after the packaged
60-, carrying its guard against thd's own virtual device) and once
from ExecStartPost, which covers a node that appears between thd's
enumeration and its socket binding.

The helper only ever adds. An earlier version removed the existing
registration first so that a second add could not duplicate it, which
inverted the risk: th-cmd opens the device anew under --passfd and can
fail, and a successful remove followed by a failed add leaves the
keyboard unmonitored while thd keeps running - a state the unmodified
script would never have produced, reached on every service start,
since ExecStartPost sweeps every node. Duplicates are prevented
instead by skipping a device thd already holds, and tolerated when
that cannot be established: two descriptors on one device fire chvt
twice, which is the same as once, while a skipped add costs the only
local way back into the machine. A non-idempotent trigger added later
would need this revisited.

Identity is treated as unknown rather than guessed. systemd supplies
MAINPID for ExecStartPost; otherwise a single thd process is taken to
be the service's, and both none and several mean unknown, because a
diagnostic thd may hold the keyboard and answering from the wrong
process would skip the add the service needed.

Both th-cmd calls are bounded: its sendmsg has no deadline, so a full
queue would otherwise hold a udev worker, and the leading '-' on
ExecStartPost ignores the exit status without exempting the unit from
its start timeout. The udev path queues the work through systemd-run
so it never runs in the worker at all, and that enqueue is bounded
too, along with the logger call that reports its failure - logger
writes to a blocking socket, so a stalled journal would wedge a worker
just as surely as a stalled manager. A node missed either way is
picked up by the sweep on the next thd start.

Verification is on hardware: there is no /dev/input, th-cmd or thd in
development. The bench procedure is in the release notes for
0.1.0-beta.
Report truthfully what a rollback restores, and hand late keyboards to triggerhappy (NEH-218, NEH-184)
The backend's offsite backup path shells out to rclone, and the
appliance cannot get it from apt: Debian Bookworm packages 1.60, which
fails B2 authentication for application keys created after mid-2024,
and fails in a way indistinguishable from a wrong key. A unit running
it would report every backup as a credentials problem.

So provisioning installs a pinned official static build under
/usr/local/bin, verifying its SHA-256 before installing anything, and
asserts the installed version afterwards. The floor is 1.70.

Offline-safe by design: if the rclone already present is new enough it
touches no network at all, which is what makes it safe to run on every
provision and every update. Nothing is installed on a checksum
mismatch, an unreachable download, an unknown architecture or a failed
unpack, and the binary is installed via a temporary name and moved into
place so the appliance never has a torn binary at the final path.

The per-unit B2 remote is written as a credential, not as
configuration: mode 0600, owned by the service user, at
/var/lib/dtk/rclone.conf, outside the repository so it cannot be
committed by accident. It is written with a heredoc rather than
`rclone config create` so the application key never appears in the
process table, and key material supplied in a file is parsed rather
than sourced, so an operator's stray line is not executed as root. An
existing remote is left completely alone; only the mode and owner,
which drift on a cloned or hand-edited card, are re-asserted.

A unit with no key material gets no remote and no DTK_OFFSITE_REMOTE,
which leaves the backend's whole path a deliberate no-op rather than a
half-configured remote failing every thirty minutes.

--advisory changes only the exit status, never the diagnosis. setup.sh
calls it plainly, because a unit that cannot end up with a working
rclone must not be handed to an archive as provisioned. update.sh calls
it with the flag, because a merely-offline field unit must not lose an
otherwise fine update over a backup binary it could not fetch.
setup.sh runs once, at first provisioning, so the update path is the
only way an appliance already in the field reaches a release whose
backend uploads through rclone. Without this, such a unit would run a
backup path with no rclone to run it. This is the same reasoning step
4b already applies to CHDK USB access, and it sits beside it.

--advisory, unlike setup.sh's plain call. At provisioning a unit that
cannot end up with a working rclone must not be handed to an archive
as provisioned. Here the cost inverts: a merely-offline unit updating
to a release whose dependency manifests did not move would otherwise
abort an otherwise fine update over a feature that stays disabled
until it has a key. Failed updates on remote units are expensive -
that is why NEH-214, NEH-215, NEH-216 and NEH-218 exist - and this
would have invented a new way to fail one. The operator sees the full
diagnosis either way.

Placed after step 2's internet honesty check, so an operator has
already been warned and confirmed, and before the stack is stopped.
Rounds 2 to 6 of the builder work, committed so it does not live only in
a working tree. Review round 4 left six blocking findings, M to R, which
are the next work on this branch.
Seven findings from the fourth review round, each with a dry run against
the previous file.

A migration could disable a working backup. The writer always emitted
DTK_OFFSITE_REMOTE, empty included, and offsite.env wins over .env per
key — so the first run on a unit configured by an earlier version turned
its backup off and called it the intended state. The key is now written
only when a remote is actually recorded. Absence does not override .env,
and the run reports what .env still names and whether a credential
stands behind it. Nothing claims to have disabled anything it did not.

A read failure was treated as absence, and absence is what authorises
writing a credential — so an offsite.env this script could not read was
replaced, destroying the recorded remote. grep's status is now read
explicitly: 1 is absent, anything above it aborts.

A strict KEY=value match hid a later `export KEY=value`, which
python-dotenv would have taken. Every declaration of a key is examined,
and a non-strict one anywhere makes the file malformed.

Two runs could both find no credential and the second would replace the
first. The credential is published with ln, which refuses rather than
replaces, and the whole run holds an exclusive flock. The wait is
bounded at 120 seconds: update.sh calls this synchronously before the
pre-migration backup and passes --advisory so provisioning can never
cost an update, and an unbounded wait would have hung the update by a
route that flag cannot reach.

Field classification piped printf into grep -q, so under pipefail a
large but valid config read as unusable once the output passed the pipe
buffer and printf took SIGPIPE. One awk pass over a here-string now
consumes the whole value, and a check that could not run reports
undetermined rather than making a claim about the credential.

Both invocations that read the config pass --ask-password=false. An
encrypted config would otherwise have waited for a password behind
suppressed stderr, in the lsf case after both files were written.

bash -n clean; shellcheck 0.11.0 reports nothing at --severity=style,
and --enable=all trips only the five optional codes the file already
tripped.
…record

The repair's undo removed the record before it rewrote HOST_IP, so a power
cut or a failed rewrite in between left HOST_IP=captua.local on a unit
called digitool, with nothing left to repair it. HOST_IP now goes back
first, and the undo waits for the next start if it cannot.
…ing the rename

A restore after a failed update, or a rollback, brings back a start-kiosk.sh
without the lock cleanup while the unit keeps the name captua, so the next
Chromium found a lock naming digitool and refused the profile. The rename
now points the running kiosk's SingletonLock at the new name with the same
pid (and an undo points it back), which any Chromium of this host takes
over once that pid has exited. The kiosk user is read from the tty1
autologin.
…ss events at boot

The dispatcher hook restarted the alias on up, down, dhcp4-change,
dhcp6-change and connectivity-change, several of them within one second at
boot. systemd hit the start limit and left the unit failed. The unit now has
no start limit, and the hook restarts only on up, dhcp4-change and
dhcp6-change. The install step resets a failed alias unit so one stuck in the
start limit recovers on the next update.
… down

When the interface the alias published on goes away while another stays up,
NetworkManager emits down and no up for the survivor, so the alias kept
advertising the dead address. down restarts it again; connectivity-change
still does not.
…(NEH-294)

The scripts that read a DTK_ setting from the environment now read
CAPTUA_<X> first and DTK_<X> second, as the backend does. When both are
set to different values CAPTUA_<X> wins and one warning names the two
variables, never their values.

The rule lives in scripts/captua-env.sh, sourced by setup.sh, update.sh,
provision-rclone.sh and check-stack-health.sh. rollback-update.sh keeps
its own copy of the function, since its kept copy under /var/lib/dtk runs
without a scripts/ directory; a test checks the two stay identical. The
helpers install-kiosk-service.sh writes to /usr/local/sbin are installed
alone, so they use the nested ${CAPTUA_X:-${DTK_X:-default}} form.

Everything the scripts write keeps its DTK_ name: .env, offsite.env, the
systemd units, directories, compose names and the Postgres names are
unchanged, so a rollback to pre-0.1.0 code still reads them.
… volume to review, and address the operator as tú (NEH-100)

The card now follows the interface 0.1.0 ships: every role shuts down and restarts the unit from the user menu, which the sidebar's user row opens on Resumen and Proyectos and the avatar opens on the capture and volume screens. The Pi's power button is the first fallback, the console the last.

New sections: sending a finished volume to review and taking it back, and reconnecting the cameras from Resumen or the capture screen. The console restart says no message appears and to wait about 30 seconds.

Every label quoted is the interface's own, the card speaks to the operator as tú like the interface, and guillemets are gone.
feat: reach units at captua.local, keep digitool.local as an alias, and call the API on the page's own origin (NEH-293, NEH-296)
Brings in the dialogs that fit 800×480, the relative API base, the user menu with shutdown and restart for every role, the welcome screen in both languages with the credits, the captua_ storage keys and the CAPTUA_ setting names (frontend #132–#141).
chore(submodule): bump frontend to 10f5bc8
feat(scripts): read CAPTUA_ settings, falling back to the DTK_ names (NEH-294)
docs(operator-card): the operator card for 0.1.0 (NEH-100)
/api/ already had access_log off, but nginx's error log prints the request line
in full whenever the backend is down or slow, and the image, thumbnail, preview,
file and export-download URLs carry the session token in the query string. I
give those paths a nested location that logs only crit and leave every other API
path at error level.
fix(nginx): keep session tokens out of nginx's error log (NEH-301)
- backend 8c757d9 Merge pull request #150 from UCSB-AMPLab/backend/neh-301-log-redaction
- backend 6794bfe Merge pull request #149 from UCSB-AMPLab/backend/neh-300-serial-fallback
- backend 56cf4d6 Merge pull request #148 from UCSB-AMPLab/backend/setup-script-paths
- backend efdc097 Merge pull request #147 from UCSB-AMPLab/backend/captua-names
- backend 91b1067 Merge pull request #146 from UCSB-AMPLab/backend/rotation-multi-file
- backend 368f400 Merge pull request #145 from UCSB-AMPLab/backend/comment-rules
- backend b2f33c9 Merge pull request #144 from UCSB-AMPLab/backend/dockerfile-comment-english
- frontend 116ef22 Merge pull request #142 from UCSB-AMPLab/frontend/neh-300-rotation-seed
chore(submodule): bump backend to 8c757d9 and frontend to 116ef22
Brings in the AMPL name in the Credits page's logo text (frontend #143).
chore(submodule): bump frontend to 31d0b7b
chore(submodule): bump frontend to 9cf6eea, the credits page in two columns
Copilot AI balanced review requested due to automatic review settings October 26, 2026 06:19
@juancobo
juancobo merged commit 957205c into main Oct 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

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