Timers, and a web panel that signs its own requests
One-shot timers
"Turn this unit off in 45 minutes" — decided by the server, so it happens
whether or not the phone is still in the house, charged, or in the country. In the
Android app it is an hourglass beside the power switch; over HTTP it is
POST /api/timers.
It is a new subsystem, not a new kind of program. Programs are standing
intentions that keep applying; a timer exists to happen once and then stop
existing. Making it a schedule entry would have taught every client that reads
programs about a schedule that is not really a schedule, and left spent ones
sitting in the list looking like they will fire again. So: timers.json, its own
runner, its own endpoints — and it fires through the same
devices/control.py:apply_to_unit path as a button press.
- You ask for minutes, never a time of day. The server computes the moment
from its own clock, and every response carries a server-computed
seconds_remaining. A phone in another timezone, or with a wrong clock, still
gets the right result — this project has been bitten by clock skew before. - A new timer replaces that unit's existing one; a fired timer deletes itself;
an overdue one fires late rather than being skipped, because "off, late" beats
"on all night". settingsis a full control payload, defaulting to power-off, so "switch to
eco in an hour" needs no second feature.- The runner says dispatched, not applied, and warns when the unit reports
itself offline — msmart'sapply()does not raise for an unreachable unit, so a
success is not proof it was heard. True of every command; it just matters more
unattended.
New settings: AC_TIMERS (default <config dir>/timers.json) and
AC_TIMER_TICK (default 15s — finer than the scheduler's 30s, because a
schedule only has to hit the right minute while a timer is a promise about a
moment). Separate asyncio task, so a disk error in one cannot stop the other.
The web panel signs its requests (auth v2)
The panel was the last client still using the legacy bearer token, which is why a
server set to AC_MIN_AUTH_VERSION=2 used to lock out its own UI. It now
signs every request with Ed25519, like the app.
The key pair is generated in the browser with its private half
non-extractable: WebCrypto signs with it and will not hand the bytes back to
the page that created it. It lives in IndexedDB; enrolment registers only the
public key, so nothing secret crosses the wire in either direction.
Signatures bind method + path + timestamp + nonce + SHA3-512 of the body. WebCrypto
has no SHA-3 at all, so the panel ships a small Keccak implementation, checked
against 254 vectors. A clock_skew rejection is retried once with the server's
offset learned, and a replay rejection with a fresh nonce, so a drifted clock
self-heals instead of prompting for re-pairing. Browsers without Ed25519 fall back
to the bearer token, which still works unless the server requires v2.
OPNsense is a tier-1 port
os-breeze-core — a page under Services, service control through configd, an rc
script — with the entire Python runtime vendored, because OPNsense is
FreeBSD:14:amd64 with python311 and neither rust nor pip.
The container images were rebuilt from scratch
Five, each named for what it actually is instead of by tag archaeology: Alpine
Edge (musl, x86_64 and arm64, self-updating), UBI 9 (glibc) at two x86-64 psABI
levels, and one with nginx bundled so HTTPS works on first start. Every image
has a first-run setup script; the nginx one sets up TLS too.
That rebuild also found a real bug: no image had a timezone database, so TZ
was silently ignored and every schedule and curve fired on UTC.
The documentation moved to the wiki
The README was ~2,000 words and docs/ another ~25,000. Both are replaced by
the wiki, including a
version history
going back to 1.0.0.
Packaging
- A build now records the commit it came from, and the packagers refuse to
label a stale bundle with a new version. This release is the reason: the
riscv64 bundle silently carried a 3.0.5 binary into 3.2.0-labelled packages.
They were deleted, and the guard exists so it cannot happen quietly again. mkpkg-netbsd.shsays it needs root instead of failing on a barecp.
Notes
- No riscv64 in this release. Its bundle is a ~2-hour emulated build and the
attempt for 3.2.0 produced a stale binary, so nothing riscv64 is attached rather
than something mislabelled. The most recent riscv64 packages are on
v3.1.0. - The attached bundles are stamped commit
481714f; the commits between it and
this tag touchpackaging/only, so the server and panel they contain are the
tagged ones. - FreeBSD
.pkgand NetBSD.tgzare built from source on real BSD hosts;
OpenBSD installs from the source tarball. - Everything here is also on the signed repositories at
https://bolero.salataputarica.hr.eu.org, andwingethas a 3.2.0 manifest.