Skip to content

Releases: moWerk/asteroid-docking-bay

0.8 — a watch is a watch, over any wire

Choose a tag to compare

@moWerk moWerk released this 22 Jul 17:53

asteroid-docking-bay 0.8 — "a watch is a watch, over any wire — and easy on the eye"

0.7 taught the fleet manager to name every watch as exactly itself. 0.8 does two
larger things: it makes a watch fully operable over SSH, not just ADB, and —
foremost — it turns the web UI from a working table into something that, replayed
in the right light, reads as a small design study. Ninety commits. The
functional half is the wire; the other half is the glass.

The wire — SSH becomes a first-class transport

Until now every watch feature spoke ADB. 0.8 introduces a transport
abstraction
(AdbTransport / SshTransport) and routes every Watch command
through it, so the Control Center, the toggles, time-sync, screenshots, even a
graceful power-off all work identically whether a watch is on ADB or in
SSH/developer mode. _reachable_transport() simply picks the wire that answers.

The hard part of "several watches on SSH at once" is that every developer-mode
watch defaults to the SailfishOS-inherited 192.168.2.15, so two of them collide
and become unaddressable. 0.8 solves it end to end:

  • Per-watch SSH IPs, sticky per serial, handed out from 192.168.13.37
    (LEET), set over ADB before the switch so each watch keeps its own address.
  • A situational prefer ADB / prefer SSH toggle in the top bar — the
    user's standing consent for how the fleet is kept.
  • Auto-alignment: a stray watch that self-enumerated on the shared .15 is
    quietly relocated to its unique IP (under prefer SSH) or returned to ADB
    (under prefer ADB) — built entirely from the two existing switch ops, no new
    device code, and verified live on hardware.
  • Exact-codename addressing so rover hits rover, not "the first
    rubyfish", and an ambiguous name refuses rather than guesses.

Around it: passive standby-drain measurement across a power-off, fastboot power
actions and a downloadable getvar report for a bricked unit, a per-watch event
timeline external tools can write to, and honest boot-state signalling —
"booting up" for a cold boot, "boot failed?" (question mark deliberate)
once the window lapses, and "reconnecting" when a running watch merely
re-enumerates after a VBUS cut.

The glass — an origin story

It started with one pill. A rounded, bordered capsule — first the power
toggle and the ADB/SSH badges — turned out to be the whole vocabulary waiting to
happen. Everything after is that pill, and its sibling the dot, answering one
self-imposed constraint: it all has to live in the row. No drilling into a
screen for the common case. That constraint is the engine of the entire study.

Because everything had to fit a row, things folded:

  • Five action buttons collapsed into one menu — grouped, indented, every
    option visible at once.
  • The battery cell became a filling gauge — a real little battery whose fill
    grows left-to-right.
  • The stats became a row of dots — power, wearability, battery-graph, charge —
    each a glyph in a circle, and each now clickable: the battery dots all open
    one Battery Info panel (history folded into its foot); the power dot opens a
    short power menu; the wearability dot opens drain/wear. Duplicate panels were
    retired into one.
  • Redundancy fell away: the socket 1 label became s1, the ├─ tree glyphs
    vanished (the hub labels already break the flow), the Port column folded into
    Power, and the last-seen age stopped pretending to be a pill.

Then came colour as weight. Colour is not decoration here; it's a rationed
signal, and we caught ourselves overspending it. The battery was amber everywhere
("technically the mid-band, but so much ambiguous amber"), and Smart's green
"yes" competed with the greens that mean power and charge. So we pulled colour
back to meaning: green = powered / live, orange = ambiguous or stale,
blue = a type or an action (Smart became ppps in blue), red = danger,
pink = worn. A battery is now grey until it's connected and we can honestly
colour its charge. Amber was freed to mean only caution.

Alongside it, honest nomenclature — the UI claims only what it can assert.
not docked (which blames the plug) became the neutral no link; a graceful
power-down became shelved; and a raw toggle-off now clears the shelved
marker so it never falsely claims a watch is safely down.

And consolidation as the finish: one --pill-h token drives the height of
every pill, dot, gauge and toggle at once; one centring class serves four columns;
one Battery Info panel is reached from three places. When something could be
deleted or shared instead of added, it was.

The toggle deserves its own line. It was designed twice — an elaborate sliding
orbit-eclipse, wired in faithfully, judged "out of language", and brought home
to the flat ● ON/OFF pill it always wanted to be — but now carrying an
animated exec hand-off the original never had.

Then all the pieces fell into place at once: the table went to the screen edges
and back to a viewport-scaled margin, a seeded Depth Drift starfield was
painted behind everything so the header and margins twinkle, and the table itself
turned milk-glass — semi-opaque rows frosting the stars so they faintly shine
through.

Under the hood

  • Op-table contract pinned by tests + docs/CONTAINERS.md; split-mode ready
    throughout.
  • ~290 tests, every new behaviour planted-bug validated; a headless-DOM
    harness renders the page's JS so column order, wiring and each pill/dot is
    checked without a browser.
  • RAG updated with the set:ip-applies-on-transition finding and the SSH-IP
    relocation mechanism.

asteroid-docking-bay is a full-LLM-written project under moWerk's direction; the
process record ships in the repo, and that transparency is the point.

0.7 — every watch, exactly itself

Choose a tag to compare

@moWerk moWerk released this 21 Jul 18:19
1a2761c

asteroid-docking-bay 0.7 — every watch, exactly itself

0.6 was a UX and flashing pass. 0.7 is about identity and honesty: the
dashboard now knows what each docked watch actually is — its exact hardware
codename, screen shape and resolution — even when the factory image lies about
it; it shows each watch's product photo with its own live screen composited
inside the frame
; it keeps a watch's last-known readings on screen, marked
stale, when it drops off the bus instead of blanking; and it charges several
watches at once
instead of silently queueing them.

The README covers use;
docs/ARCHITECTURE.md is the map;
docs/ADDING-A-WATCH.md is new. These notes are the
technical companion. Tests 131 → 178, CI-gated on Python 3.9 and 3.14.

Exact identity, resolved from the hardware

AsteroidOS's porting process shares one system image across hardware variants
to save space, so machine.conf's MACHINE= is ambiguous — a TicWatch E2
ships the skipjack image and reports skipjack, though it's really tunny.
0.7 shows the exact user-facing codename anyway, matching asteroidos.org:

  • Screen geometry is read live from /etc/asteroid/machine.conf (the same
    source qml-asteroid's DeviceSpecs uses) for shape, and from
    /sys/class/graphics/fb0/modes for the true resolution — deliberately not
    virtual_size, which is double-buffered and reports the wrong height.
  • Variants are disambiguated by resolution, which is unique within a
    shared-image family (skipjack 360 vs tunny 400; beluga 320×360 vs
    belugaxl 402×476). The full fleet ground truth — every variant's
    resolution, GPS, LTE, RAM and case size, detectable or not — lives in
    watch_variants.json; the resolver uses only what it can read today and
    falls back to the base codename rather than guessing. Add a detector later
    and the same table applies with no re-survey.
  • It's purely cosmetic: config and every operation still key on the
    MACHINE image (the thing actually flashed). The Control Center adds a
    Machine (image) field so a user who clicked "tunny" sees why it flashes
    skipjack.
  • New watches usually need zero changes (identity is read live, the photo
    is fetched); docs/ADDING-A-WATCH.md and onboard-new-watch-to-fleet.sh
    cover the rare shared-image case.

The live screen, inside the watch

Each row now shows the watch's product photo, and clicking it opens the watch
with its live screenshot composited into the screen:

  • The screen position is read from the product PNG's transparent alpha
    cutout
    — no per-watch coordinate table. A flood-fill from the border
    strips the render's transparent background; whatever enclosed transparency
    remains is the screen hole (robust to an opaque foreground splitting it,
    e.g. a watch's physical hands, which correctly occlude the shot).
  • The screenshot sits behind the product image so the bezel and hands cover
    its edges; the alpha cutout is the mask. It renders at 2/3 native resolution
    (the JPEG is heavily compressed) with object-fit: contain so it never
    distorts or over-scales, sized so the watch scales around a true-size screen
    and re-fits responsively on window resize. An un-cut image falls straight
    back to a side-by-side view, so images upgrade themselves as their screen is
    cut. Round screens are clipped to a circle; the row thumbnail gets a black
    screen-fill so it can't shine through.

Nothing blanks: stale-value display

A watch leaving the bus used to empty its row. Now a per-serial last-seen
store (persisted, so it survives a restart) keeps the last battery, Control
Center stats and screenshot
and when they were seen:

  • The battery column shows the cached percent in amber with its age; the
    Control Center renders the last-known stats on a stale frame with a "last
    live Nh ago" stamp; the watch-image overlay shows the last screenshot dimmed.
  • The live battery field keeps its None-when-offline contract, so nothing
    mistakes a cached number for a fresh one.

A Stats column and richer rows

The per-row status icons moved out of the codename cell into a dedicated
Stats column and grew into a full strip: the wearable / battery-swap
verdict, a ? until a watch has ever been drain-tested, the watch-side
charge state (delivered-power ground truth — a docked watch reading
Discharging is a dirty contact), a click-to-open battery-history
sparkline
from the event log, and a last-live age when offline. A freshly
plugged-in watch flashes its row.

Charge several at once, and smoother controls

  • Concurrent charging. Charge no longer holds the one-at-a-time bus lock
    for its whole duration — pressing Charge powers the port on immediately and
    several watches charge at once. (Real brownout/voltage-drop protection is the
    planned successor to the blind lock.)
  • Smart on power-cycle. The ↺ button now runs the PPPS test and records the
    smart verdict, so a ? resolves without a full re-onboard.
  • Downloadable diagnostics. Collect-diagnostics now offers the bundle as a
    browser download instead of stranding it on the host.
  • Consistent overlays. The watch-image panel anchors to the click and
    flips to fit like the Control Center; the row menus lost their fragile
    mouseleave auto-close (outside-click / Escape only) and regained per-action
    accent colours.

Fixed

  • Remote operation restored — a render-time ReferenceError (a helper
    referencing a render-local const) was surfacing as a permanent "connection
    error"; every render now runs in a headless smoke test.

0.6 — flashing, rest, and a quieter UI

Choose a tag to compare

@moWerk moWerk released this 13 Jul 23:54

asteroid-docking-bay 0.6 — flashing, rest, and a quieter UI

0.5 shipped the container split and closed by naming three UI gaps as "next":
a stuck screen-force-on with no indicator, no split-mode "backend unreachable"
banner, and no fastboot/SSH row indicators. 0.6 closes all three — then keeps
going. You can now flash a release and back up a watch's data from the
browser
; a watch always returns to rest after an operation instead of
draining behind an "off" port; the battery reading trusts the right gauge;
and a genuine UX pass strips the cheesy emoji and finally makes the page
usable on a phone.

The README covers use;
docs/ARCHITECTURE.md is the map. These notes are the
technical companion. Tests 103 → 131, CI-gated on Python 3.9 and 3.14.

The three UI gaps 0.5 flagged — closed

  • Stuck demo-mode screen, caught and released. A forgotten
    mcetool -D on holds a watch's display on and drains it invisibly. The
    status path now reads mce's Blank inhibit in the same round-trip and
    exposes a per-port flag: a fleet-wide banner names any draining watch with a
    one-click release all, each affected row carries a click-to-release
    marker, and the Control Center's screen control is a stateful toggle
    (dark = off, bright warning-amber = forced on) that reads ground truth.
  • Backend-unreachable banner. In split mode, if the backend is down the
    page keeps the last-known table on screen, stamps the time "stale (backend
    down)", and raises a banner instead of silently blanking to an empty fleet.
  • fastboot / SSH indicators. A watch in the bootloader or in SSH/developer
    USB mode now shows a clear pill in the Connection column, backed by a tested
    connection-state resolver.

Flashing, from the browser

The Flashing menu (renamed from "Flash" now that it holds more than
flashing) is real:

  • Flash a chosen release — Flash 2.1 / 2.0 work, not just the nightly;
    a channel is swapped into the release URL with its own cached image dir.
  • Data Backup / Restore — pull a watch's ~ceres/.config, connman WiFi
    credentials and a dconf dump to a per-watch folder on the host, and push
    them back (re-owned to ceres, connman restarted so WiFi reconnects). Verified
    end to end: back up a watch, flash it clean, restore — settings and WiFi
    survive.
  • No more silent oem unlock on every reflash — an AsteroidOS watch is
    already unlocked; deliberate unlocking is a separate, warned action for a
    future WearOS-onboarding flow.
  • Flashes hit the exact watch you clicked, resolved by slot + serial, not
    by codename — so two watches sharing a codename can't cross wires (found live
    during the first browser flash).
  • Dump mmcblk0 / Restore from dump are staged but greyed ("not yet
    implemented"): a byte-exact raw image needs a per-model debug-ramdisk boot
    that crippled-fastboot watches can't do, so it's gated behind a per-codename
    capability table still to come.

The fleet comes to rest

A cluster of lifecycle fixes so watches don't sit awake or half-charged:

  • Return to rest: an optional drain_rest_recharge charges a finished
    drain back into the healthy band before powering off, so a test doesn't
    leave a watch stored near-empty.
  • Map ends default-off: the mapping pass now powers each identified watch
    gracefully back down instead of leaving the fleet awake.
  • SSH→ADB recovery: a watch that boots into SSH/developer mode (a fresh
    flash's default) can be switched to ADB — from a click on its SSH badge, and
    automatically during a flash — over 192.168.2.15.
  • Timer defers to the web service: the periodic check-charge now skips any
    watch the running web UI is mid-drain/-charge/-workbench on, so the two never
    collide on a decision (flock only ever covered the bus).
  • Sticky smart verdict: a proven port-switchable result no longer flickers
    to "?" when a later marginal re-test comes back inconclusive.
  • Fake-power self-heal (opt-in): fake_power_self_heal auto-cycles a port
    that reports power but never connects — the stale-node wedge — once, with
    backoff, never during an op.
  • Collect diagnostics: a Workbench action bundles journal, dmesg, battery,
    thermal, storage, connman and dconf into a per-watch tarball for bug reports.

The battery reading trusts the right gauge

Some watches expose two power_supply battery nodes. On at least one, the
generic battery node read a stale, miscalibrated 50% while the real
nanohub_fuelgauge — what the watch's own UI reads — reported the true 100%.
The dock now prefers the named hardware fuel gauge, falling through to
battery on watches that don't have one.

The UX pass

  • The emoji are gone. Wrench, floppy, clipboard, camera, lightning, the
    see-no-evil monkey and the rest were cheesy and, as colour-emoji, too bright
    on the dark theme. Buttons and menus are clean text now; the meaning lives in
    the labels and the existing colour classes. Kept: the header star ornament,
    the AsteroidOS logo, and the monochrome UI glyphs that are actually
    functional (dropdown caret, cycle/refresh icons, drain arrow, the row tree).
  • The page is responsive again. An earlier fixed-width table (meant to stop
    columns shifting on string length) forced a permanent horizontal scrollbar;
    that trade wasn't worth it. The table is fluid again — columns follow the
    page width.
  • It works on a phone. A viewport meta tag (its absence was letting mobile
    Chrome font-boost the buttons into giants) plus a stacked card layout below
    720px: each row becomes a slim, legible card, one labelled field per line, no
    sideways scrolling.

Known limitations / on the roadmap

  • mmcblk0 dump/restore is deferred pending the per-codename fastboot/
    ramdisk capability table (above).
  • SSH→ADB is wired and its plumbing verified, but the live switch needs a
    watch actually in SSH mode to exercise end to end.
  • drain_rest_recharge and fake_power_self_heal are opt-in (they actuate
    hardware / change stored charge) — enable them in the charge config.
  • The container backend on real USB remains experimental, as in 0.5.

Provenance (unchanged stance)

Written by an LLM coding agent (Anthropic Claude), directed, tested and
ground-truthed on hardware by the maintainer; commit discipline follows
cbea.ms — one coherent change per commit. Several fixes in this release were
found live on the rig during ordinary use (the wrong-watch flash, the fuel
gauge, the missing viewport meta). GPL-3.0-only; the commit history is the
unedited record.

Requirements

Unchanged: Python ≥ 3.9 (stdlib), adb; uhubctl for discovery/fallback;
fastboot + wget for flashing; bottle for the web UI; podman for the container
split. For development: pytest (the CI gate).

asteroid-docking-bay 0.5 — the split, and the machine that reviews itself

Choose a tag to compare

@moWerk moWerk released this 13 Jul 09:33

asteroid-docking-bay 0.5 — the split, and the machine that reviews itself

0.4 was the review release — the point where the code became something other
humans could read. 0.5 is what that reviewability bought: a real external
contributor whose security design became an architecture, a workflow that
answers a naming remark with a pushed commit before the reviewer's next
coffee, and a deep self-audit that found its own bugs before shipping them.
The headline feature is the container split. The headline change is that
the project now demonstrably holds itself to a standard.

The README covers
use; docs/ARCHITECTURE.md and
docs/CONTAINERS.md are the maps; the
docs/audits/ folder is new and holds the process record
(see below). These notes are the technical companion.

The container split (experimental)

For a network-exposed deployment, the web UI can now run as an unprivileged
frontend container that reaches a separate, host-touching backend
over a token-gated socket — so a compromise of the exposed HTTP surface
cannot reach the USB devices, the config, or the operations. The design and
threat model are Ed Beroset's, elaborated into a concrete contract:

  • rpc.py — newline-delimited JSON over TCP. A TokenGate with a
    constant-time compare gives a mismatched token no reply on the wire
    (no oracle for a probe) while logging per peer and escalating from a
    growing backoff to a listener shutdown, so a box under attack surfaces as
    a failed unit. It refuses an empty token outright.
  • rpcops.py — the single op table. Every host-touching operation the web
    API offers is a named op on an allow-list; there is deliberately no generic
    "run a command" op, and the allow-list is itself a test — adding a
    capability must consciously touch it.
  • serve --backend host:port turns the web server into a pure proxy through
    an RPC client instead of the in-process caller — the routes are identical
    either way, so the monolith and the split share one implementation.
  • Streaming (flash, onboarding) bridges the same way: the backend yields raw
    messages, the frontend wraps them as SSE.
  • containers/ ships podman Containerfiles and quadlets: the frontend
    publishes the only host port, runs read-only, drops all capabilities, is
    non-root and has no volumes; the backend stays on the container network
    with USB/sysfs access and its own adb server.

Honest scope: the container split is experimental. The single-process
serve is unchanged, fully tested, and remains the default for bare-metal
installs. The split's plumbing is verified end to end (two containers, token
secret, RPC, hardened frontend), but the backend container touching real
USB on a target host is the one surface still to be trialled — flagged in the
quadlet for per-host tuning. Run the split only if you want the isolation and
are ready to shake out the device passthrough.

Everything under the split changed shape

Getting to a clean split forced the web server to stop being a pile of
near-identical route functions. It is now a thin frontend: routes parse HTTP
and dispatch through a caller to the op table. Nineteen uniform routes
collapsed into an importable data table; the drift and op-coverage tests read
that table directly. The cache warmer moved to where the operations live.

Tests: 48 → 103, and they hunt their own class of bug

New suites, each written to catch a failure class that had actually shipped:

  • Static integrity — a symtable undefined-name sweep and an unused-import
    sweep on every module, because a mechanical move once silently truncated a
    function and refactors leave import lists lying.
  • UI wiring — every onclick handler must be a defined function, every
    getElementById target must exist or be created at runtime, every JS API
    path must hit a registered route. Each check was validated by planting the
    exact historical bug it exists to catch.
  • Op-table contract — every op the frontend dispatches is registered, and
    the registered set is pinned as the documented allow-list.
  • RPC transport — framing across TCP fragments, unicode tokens, escalation,
    and the edge cases a good tester imagines (an empty token is no gate at all
    — found and fixed here).

A GitHub Actions workflow now refuses a check-in unless the suite passes,
on the floor and current Python — Beroset's commercial-standard rule.

Consolidation, naming, and Beroset's review made code

Applying his guidance directly and with his name in each commit: one variable
name per concept across the package (the adb map is devices, hubs iterate as
hub); dead code deleted (a caller-less function, a vestigial JS state
machine); simplify-over-add wherever the diff allowed. The
consolidation pass records
what shrank and — honestly — what was deliberately not merged and why.

The self-audit (new: docs/audits/)

Between build and ship, a
deep second-pass audit ran over
the branch: static caller-integrity, tri-mode live comparison against the
running fleet, and failure injection on real hardware (client disconnects,
backend killed mid-operation, token abuse to shutdown). It found — and this
release fixes — a hardened frontend that could never start (read-only rootfs
vs the default secret mount), a host-adb design that was unreachable as first
specified, PID-1 containers that ignored SIGTERM, and the empty-token hole.
The audit ships with the code, methodology and dead ends included, so a
reviewer can go deeper than the diff. That transparency is the point of this
project, not a footnote to it.

Also fixed

  • Workbench end now powers the watch down to rest (audit F8): ending a
    checkout cut the port without the graceful poweroff drain and charge
    perform, leaving the watch running on battery behind an "off" port.
  • The running version is on the page. A fixed top bar shows the update
    stamp (left) and the version (right), so "did my upgrade land?" has an
    answer — and the varying-length stamp no longer repositions the header.

Known limitations / on the roadmap

  • Container backend on real USB: untested, experimental (above).
  • A forgotten screen-force-on (mcetool -D on) from the Control Center can
    strand a watch's display on with no indicator that it is active — a
    demo-mode detection + release safety is the next UI-pass priority.
  • Split-mode UI has no "backend unreachable" banner yet.

Provenance (unchanged stance)

Written by an LLM coding agent (Anthropic Claude), directed, tested and
ground-truthed on hardware by the maintainer, and — central to this release —
pre-reviewed and shaped by an external human contributor, Ed Beroset, whose
findings and design are credited in the commit history, the audit records,
and above. Commit discipline follows cbea.ms at his prompting: one coherent
change per commit. GPL-3.0-only. The commit history and the audit folder are
the unedited record.

Requirements

Unchanged: Python ≥ 3.9 (stdlib), adb; uhubctl for discovery/fallback;
fastboot + wget for flashing; bottle for the web UI; podman for the container
split. For development: pytest (the CI gate).

asteroid-docking-bay 0.4 — the review release

Choose a tag to compare

@moWerk moWerk released this 09 Jul 19:48

asteroid-docking-bay 0.4 — the review release

0.3 ended with a first outside contributor; 0.4 is what happened when he was
taken seriously. Ed Beroset asked for a modular, object-oriented,
pytest-testable codebase — reasonable things to want before investing review
effort in a 4300-line single file. This release is that refactor, plus
everything his pre-review and a two-machine fresh-install campaign shook out
of it: nine bugs and stale docs found by fresh eyes on hardware and habits
the original rig never exercised. Nothing here adds a feature. Everything
here makes the next feature reviewable.

The README covers
use; docs/ARCHITECTURE.md is the map reviewers
should start from. These notes are the technical companion.

The package split

bin/asteroid-docking-bay is now a thin launcher; the implementation lives
in the asteroid_docking_bay/ package — 13 modules with a documented
dependency direction and two deliberate seams (see ARCHITECTURE.md).
Code moved verbatim, and that claim was proven rather than assumed: an AST
sweep compared every function body against the monolith and caught the one
silent truncation the split had produced (a dropped return that compiled
fine and would have broken OS detection). Everything else is byte-identical
modulo the deliberate renames.

install.sh installs the launcher plus the package under
~/.local/share/asteroid-docking-bay/lib and removes both on --uninstall.
Repo-checkout use is unchanged: ./bin/asteroid-docking-bay just works.

Classes where state actually lives

  • Watch(serial) — every action bound to one watch: the Control Center
    data batch, connman toggles, clock sync, ceres-session screenshot/
    notification, buzz, screen.
  • Operation with ChargeOp / DrainOp / WorkbenchOp — the shared
    lifecycle (duplicate/conflict refusal, registry seed, durable persist,
    worker spawn, stop, resume-after-restart) written once; the web routes
    collapse to Op.start() / Op.stop(). Verified end-to-end on hardware,
    including kill-the-service-mid-op resume.
  • ConfigManager + ChargeConfig/FlashConfig dataclasses — Beroset's
    design, implemented as proposed: file I/O and the read-modify-write lock in
    one place, settings defaults declared once, ~30 scattered
    .get("key", default) fallbacks replaced by typed attribute access. The
    config file format is unchanged.
  • EventLog(dir), TaskStore(dir) (both directory-injectable — that
    is what makes them testable), PowerCache(ttl), and
    ChargeDropDetector (the losing-power alarm as a state machine).

Port power switching deliberately stays functional — ARCHITECTURE.md explains
why, and names the mapping rework (#2) as the point where a port abstraction
would earn its keep.

Tests

35 pytest tests over the pure logic: the adb devices -l and uhubctl output
parsers, per-serial state lookup, hub/port path math, charge-drop alarm,
standby-rate and next-due projection, EventLog and ConfigManager round-trips,
the foreign-device guard, and a mocked-hardware run of the whole charge
worker. Writing them immediately paid out: the parser tests exposed a
long-standing latent bug where adb daemon-restart notices were parsed as
bogus devices.

What review and fresh installs found (all fixed here)

From Beroset's pre-review, on his Fedora rig:

  • map powered off non-watch devices — including a keyboard-integrated
    hub and its mouse. A foreign-device guard now classifies every occupied
    port before anything switches; adb/fastboot-visible serials count as
    watches even under non-Google vendor IDs (a hacked Ticwatch found the
    refinement).
  • fastboot oem unlock aborted the flash on already-unlocked bootloaders,
    stranding the watch in fastboot. Now best-effort; a truly locked bootloader
    still fails loudly at the flash itself.
  • A resumed blind-mode charge fed the UI a countdown already in the past,
    closing a refresh loop that hammered /api/status at 30+ req/s. Fixed at
    all three layers (worker, status builder, frontend), with a regression test.
  • Every web menu action silently died on a missing toast element (the
    screenshot button's mysterious no-op), and the battery-current arrow
    rendered as a literal HTML entity.

From fresh installs on x86_64 (EndeavourOS) and aarch64 (Pinebook Pro):

  • install.sh's printed advice still referenced the plugdev group its own
    rules had abandoned, claimed uhubctl is in the Arch repos (it is AUR-only),
    and said pip install bottle on systems that ship no pip. All hints now
    name working commands per distro.
  • A port conflict (:8080 already owned by a container forward) crash-looped
    the service with a raw traceback; it now exits with the --port and
    systemd-override instructions spelled out.
  • Firewalld silently blocking LAN access to the web UI is now documented.
  • --uninstall got its first real execution (on the Pinebook) and removes
    the package directory the new layout introduced.

Known notes

  • Group membership: the udev rules grant sysfs port-write access via the
    users group. A user added to it mid-session gets fast switching in new
    logins immediately, but long-running systemd user services keep the old
    groups until the next full logout/login; the tool logs which mode it is in
    at startup and falls back to uhubctl transparently.
  • map's detection window is sized for AsteroidOS boot times; watches that
    need a long cold boot are onboarded from the web UI instead (30 s window
    plus one automatic power-cycle retry), or raise
    charge.onboard_wait_seconds.

Provenance (unchanged stance)

Written by an LLM coding agent (Anthropic Claude) directed, tested and
reviewed from the user/tester side by the maintainer — and, new in this
release, pre-reviewed by an external human contributor whose findings are
credited above and in the commit history. Commit discipline follows
cbea.ms/git-commit at Beroset's prompting: one coherent change per commit.
GPL-3.0-only. The commit history remains the unedited record.

Requirements

Unchanged: Python ≥ 3.9 (stdlib), adb; uhubctl for discovery/fallback;
fastboot + wget for flashing; bottle for the web UI. For development:
pytest.

asteroid-docking-bay 0.3 — the watch, mirrored

Choose a tag to compare

@moWerk moWerk released this 08 Jul 23:56

asteroid-docking-bay 0.3 — the watch, mirrored

0.2 was going to be a quiet performance release. It became 0.3 instead, because working out why port switching was slow turned into rewriting how the tool touches USB at all — and once the dashboard was fast enough to feel alive, it wanted to do more than switch power. The commit history has the whole arc: the sysfs migration, one genuine regression, a near-revert-to-0.1 at 1 a.m. ("USB is a bitch and we gotta tame it"), the root cause finally cornered with falsifiable measurements instead of guesses, and then a creative burst where the tool learned to read the watch's own About page and drive its hardware from a browser tab. 0.2 is folded in; this is everything since 0.1.

The README covers day-to-day use. These notes are the technical companion — detailed enough to review.

What's new since 0.1

  • Direct sysfs port switching — near-instant; uhubctl demoted to discovery.
  • Status page off the USB scan path — typically under 100 ms (was 13–70 s).
  • Rootless switching via a udev rule, with a startup diagnostic.
  • Durable operations — running charges / drains / workbench survive a restart.
  • Adaptive charge cadence + a per-watch event log.
  • Control Center — full device telemetry and hardware control from the browser.
  • Row actions regrouped into floating menus + new watch actions.
  • Charge power-loss alarm and graceful return-to-rest.

Direct sysfs switching — the core change

uhubctl re-enumerates the entire USB tree via libusb on every command (5–13 s on this project's 5-level hub cascade). That was both the "snail" UI and — the part that took a night to see — the churn that raced the adb daemon into LIBUSB_ERROR wedges. Every port toggle was shaking the bus while adb tried to enumerate it.

0.3 switches power by writing the kernel's per-port attribute directly:

/sys/bus/usb/devices/<hub>:1.0/<hub>-portN/disable   # 0 = on, 1 = off

One targeted write, no tree scan — a blink instead of ~5 s. Power state is read the same way. uhubctl stays for discovery/mapping and the PPPS test, and as a transparent fallback when the attribute isn't writable.

Correction to the 0.1 concurrency model. The elaborate uhubctl-serialization-plus-flock described in the 0.1 notes was treating a symptom. With uhubctl off the hot path the cross-process bus contention it guarded against largely evaporates; the lock remains only around the residual discovery calls. This is called out honestly because the 0.1 notes presented that machinery as the fix, and it wasn't the root cause.

Rootless switching

The disable attribute is root-only by default. A udev rule matches each smart hub by vendor ID (the same set used for uhubctl access) and RUN+='s a chmod over its child *-portN/disable attributes, granting the users group write — a USB port is not itself a udev device, so the hub is matched and its ports chmod'd. The host's own root hub is a different vendor and is never touched (verified by a dry-run: 40 attrs granted, root hub untouched). Without the rule the tool falls back to uhubctl; the serve process logs Port switching: sysfs (instant) or the fallback at startup so the state is never a mystery.

Status path — fast, off the bus

The status page no longer scans the bus. Port power comes from a TTL'd in-memory cache (the tool is the only thing that switches these ports, so it is authoritative; updated on every write), filled by a gentle sequential background warmer so that a slow, variable disable read never blocks a refresh — and a present child device proves a port is powered with no read at all. fastboot devices (another multi-second USB scan that ignores its own subprocess timeout) moved into the same warmer, and every per-watch adb shell/battery read gained a timeout (an unbounded dumpsys on a sluggish watch was a multi-minute page stall). Measured end to end: 13–70 s → typically under 100 ms, with occasional sub-second cache-fill blips. No parallel USB reads — that concurrency is exactly what wedges.

Durable operations

Charge, drain, and workbench state persist to atomic-write JSON task files and resume on startup, so a service restart (or a deploy) no longer forfeits a running drain test. This closes the first 0.1 known-limitation.

Adaptive cadence + event log

The 12-hour sustain timer skips waking a watch whose measured standby drain projects it still comfortably above low_threshold, computed from a per-watch JSONL event log of every off-state reading. Watches with no drain history are always checked.

Control Center — the watch, mirrored on the host

The flagship new feature. The data sources were read from AsteroidOS's own settings app (AboutPage.qml → SysInfo/uname/qVersion, QuickPanelPage.qml → connman), then every command confirmed live on hardware. Click a watch's codename → a wide, multi-column panel reads, in a single adb shell batch:

  • System — kernel, Qt, SoC, CPU clock, uptime, boot reason (catches unexpected reboots), memory, storage.
  • Battery — charge, status, health, technology, voltage, real charge/discharge current in mA (signed), temperature, cycle count, and USB-input voltage (confirms the port is truly delivering power, not just switched on). CURRENT_NOW is a far better drain signal than the coarse percentage.
  • Network & links — WiFi state, IP, traffic, Bluetooth, and the connected companion phone.

Controls from the same panel: WiFi/Bluetooth toggles (connman), buzz (the /sys/class/timed_output/vibrator haptic — locate a watch in a full dock), screen-on (mcetool -D on), screenshot, and clock + timezone sync from the host. Session tools (screenshot, notifications) run in the watch's ceres user session via su, because adb shell is root and cannot reach the user's Wayland compositor or D-Bus session bus. The panel flips above or below the click to stay on-screen and reflows to one column when narrow.

Row actions → menus, plus new actions

The per-row action column regrouped into three floating menus — Power (charge, drain, power-off/reboot/bootloader), Workbench (band-hold checkout plus attended actions that leave the watch on), and Flash — that flip above/below to fit the viewport; Refresh became a small leading recycle icon. New watch actions were harvested from the AsteroidOS wiki and beroset's watch-util and confirmed live: set-time-from-host (date -s + timedatectl set-timezone), screenshot (screenshottool, pulled + served as a JPEG), and test notification (notificationtool). usb_moded_util -s adb_mode is catalogued as a clean SSH→ADB switch for onboarding recovery.

Charge power-loss alarm

The charge loop used to discard a non-rising battery read as "transient — keep charging." But a charging watch that loses charge over consecutive polls is not transient — it is losing power despite the attempt (dirty contacts, a bad cable, a failing port). That case now raises a visible losing power! alarm plus a charge_power_loss event, keeps trying in case a flaky contact recovers, and clears the moment the battery gains again.

Graceful return-to-rest

Charge and drain now end by shutting the watch down over ADB and cutting VBUS immediately — a shutdown that completes with power still present makes these watches auto-boot back — so a finished operation leaves the watch genuinely off rather than silently draining. It never powers a port on or blocks when adb is unhealthy.

First outside contribution

beroset's #1 is the first external code in what began as a pure-LLM experiment. It reworks adb_devices to parse adb devices -l, so each watch now carries a structured state — status plus its USB path, product, and model — rather than a bare "device" string. That richer data is the basis for the mapping rework proposed in #2. Merged into 0.3, with a follow-up commit making the new dict None-safe across the web builder and the ADB wait-loops (an absent serial used to index None).

Provenance (unchanged stance)

Written end-to-end by an LLM coding agent (Anthropic Claude), directed, tested, and reviewed by a human maintainer from the user/tester side, verified on a real mixed fleet before each commit. GPL-3.0-only. The flash sequence and the new backup / settings command patterns derive from beroset's GPL-3.0 asteroid-hosttools. The commit history is the unedited record — including a genuine regression and a near-revert to 0.1, both left in the log.

Known limitations in 0.3

  • The sysfs disable write needs the udev rule (or root); without it the tool transparently falls back to slow uhubctl.
  • Motion / heart-rate / step sensors are exposed via sensorfw (D-Bus), not sysfs, and are not yet surfaced.
  • Flash-menu Backup / Restore and versioned (2.x) flashes are placeholders — the backup/restore commands are known (pull ~ceres/.config + /var/lib/connman + dconf dump; restore = push back + dconf load), but 2.x releases do not exist yet.
  • The web service and the periodic timer still coordinate at the USB-bus level, not the decision level.
  • The web service was once observed running two serve processes (a restart leftover); the power cache is TTL'd so a per-process cache cannot serve stale state regardless.

Requirements

Python ≥ 3.9 (stdlib only), adb; uhubctl for discovery, fallback switching, and the PPPS test; fastboot and wget for flashing; bottle for the web UI. Rootless instant switching wants the udev rule (see README). A hub that does true per-port VBUS switching — the built-in test tells you honestly whether yours does.

asteroid-docking-bay 0.1

Choose a tag to compare

@moWerk moWerk released this 04 Jul 16:43

asteroid-docking-bay 0.1 — first release

Fleet manager for AsteroidOS smartwatches: keeps a whole collection of watches docked on smart USB hubs, sustains every battery in a healthy 40–80% band, measures which batteries are still wearable, and flashes nightlies to real hardware across every codename you own.

The README covers installation and day-to-day use. These notes are the technical companion: how the thing actually works, in enough detail to review it.

No binaries, on purpose

There is nothing to build. The entire tool is one Python 3 file (bin/asteroid-docking-bay) using only the standard library; the web UI additionally needs bottle (pure Python, one pip install). External tools (uhubctl, adb, fastboot, wget) come from your distro. The auto-attached source tarball plus ./install.sh is the complete distribution. Anyone "unable to build" is already covered, because there is no build.

Provenance — read this if FOSS ethics matter to you

This codebase was written end-to-end by an LLM coding agent (Anthropic Claude), directed, tested, and reviewed by a human maintainer working purely from the user/tester side. Every feature was verified on real hardware (a mixed fleet of 2014–2018 AsteroidOS and Wear OS watches on Lenovo dock hubs) before its commit, and the commit history is the unedited record of that process, including the bugs, the wrong hypotheses, and the reverts. License is GPL-3.0-only; the flashing sequence derives from beroset's GPL-3.0 asteroid-hosttools. If LLM-authored code is not compatible with your ethics, this project is not for you, and we would rather you know that from the release notes than from a diff.

Everything below is inspectable in the single source file; section names match the code comments.

Architecture

One process per role, both from the same file:

  • CLI / timer process: map, status, on/off/cycle, charge, check-charge (what the systemd timer runs), test-ports, flash, discover.
  • Web process (serve, systemd user service): Bottle app on a threading WSGI server. All operation state (charge, drain, workbench, flash, remap tasks) lives server-side in per-slot dicts, so browser reloads and multiple clients see the same running operations. Long operations run in daemon threads with threading.Event cancellation; flash and remap stream their log output to the browser via SSE with heartbeats.

Ports are addressed as location:port slots (e.g. 1-6.4:2), matching uhubctl and sysfs topology. Config is a single JSON file; every read-modify-write cycle is serialized by a config lock.

Concurrency and queueing model

Three layers, from coarse to fine:

  1. ADB-heavy operations serialize: charge, flash, and remap acquire a global in-process lock before powering a port on and hold it until the port is off again, so at most one such operation owns the bus at a time. Drain-test polls intentionally do not take this lock; each poll is a short power-on → read → power-off pinned to one port, and holding the global lock for a poll's full ADB wait (up to ~2 min) was observed to starve every other operation.
  2. uhubctl invocations serialize twice: an in-process re-entrant lock, plus an exclusive flock on a lock file shared across processes. The second one exists because the web service and the periodic timer are separate processes, and concurrent uhubctl bus scans from two processes were observed to glitch dock ports mid-charge.
  3. Status is scanned once and cached: one full uhubctl scan carries every port's power and connect state (no per-port subprocess fan-out), and the JSON status response is cached for 2 seconds behind a lock so parallel browser tabs share a single scan. Every state-changing endpoint invalidates the cache, so a post-action refresh never serves pre-action state.

Power-switching verification (PPPS test)

The central lesson of this project: hub status registers lie. Hubs acknowledge power commands and toggle their status bit while VBUS stays live; conversely, a wedged hub can claim power while delivering nothing. The port test therefore never trusts the register and instead uses an evidence hierarchy:

  1. ADB drop: the adb server actively talks to each device. If VBUS is really cut, the watch drops off adb devices within seconds. This is the primary proof.
  2. Watch-side power state: if the device keeps chatting after "off", it is asked directly (dumpsys battery on Android-based watches) whether it still sees external power, which distinguishes a self-powered data link from an uncut VBUS.
  3. sysfs disappearance counts as positive proof only. Persistence proves nothing, because these docks do not raise a disconnect event for a port they power off, leaving a stale kernel node behind while the device is dark.

Verdicts are three-valued and stored per port: SMART (VBUS cut confirmed by a live device), NOT SMART (device demonstrably kept power), UNVERIFIED (no device present to test against, e.g. an empty port). ADB samples taken from a just-crashed adb server are discarded, because its freshly restarted replacement briefly reports an empty device list, which would fake a "device dropped" confirmation.

Charge algorithm

Charge-to-target, not charge-by-the-clock. A charge cycle powers the port, waits for the watch (with one automatic port power-cycle if it fails to enumerate, see hardware notes), then polls the battery every 2 minutes and stops exactly at high_threshold (default 80%), hard-capped by charge_max_minutes (default 240) so a broken battery read cannot charge forever. A watch already at or above target finishes immediately. Watches whose battery cannot be read fall back to a blind fixed-duration charge. Every poll also re-asserts port power: whatever cuts the port mid-charge (a concurrent process, a dock glitch) is noticed, logged, and re-powered instead of silently polling a dead port. Battery level is read from the standard Linux power_supply class over ADB shell in a single invocation. The same loop drives the manual CLI charge, the web Charge button, and the 12-hour systemd sustain timer.

Drain test and wearability triage

A drain test measures true standby drain: power the port off and let the watch run on battery, waking it every 30 minutes for one battery reading (power on → wait for ADB → read → power off), until it reaches a 15% floor or is stopped. The drain rate is computed from timestamps, so delayed polls skew nothing. Results are written as JSON (one file per test, with the full reading series and the watch serial).

From the newest result per watch, the UI derives a wearability verdict: estimated 100→15% standby time (85 / rate). At or above wearable_min_hours (default 24) the watch is wearable; below it, it is a battery-swap candidate best kept as a dock-sustained dev unit. The verdict annotates the watch's row permanently and a history view lists every recorded test, so a rate rising across months exposes battery wear early.

Workbench mode

Checked-out watches (hands-on development over WiFi/SSH) previously meant a powered dock pegging the battery at 100% for hours. Workbench mode holds the band during the session instead: charge to high_threshold, then rest with the port off, re-check every workbench_poll_minutes (default 30), recharge at low_threshold. If the battery cannot be read (USB switched to RNDIS/SSH mode) it degrades to a blind duty cycle (workbench_blind_charge_minutes of power per rest period). Charge, drain, and flash refuse on a checked-out watch, and vice versa. Returning the watch drops it back into the sustain fleet already inside the band.

Live soft-remapping

Every status refresh maps each ADB-online watch's serial to its physical hub port via a single sysfs scan. When that disagrees with the config, the watch was demonstrably moved, and the mapping updates itself within one refresh cycle, including watch-swaps between ports. Only booted, ADB-visible watches are followed; moving a powered-off watch still needs one click on its new port. Each port also stores the exact serial last seen there (port_serials), which is preferred everywhere a port resolves to a serial. This closes the duplicate-codename ambiguity where two units of the same watch model answered for each other's battery readings and power actions. A watch's old seat is cleared only on positive evidence (an exact serial binding, or a fleet-wide unambiguous codename match); ambiguous cases leave the old mapping alone rather than guess.

Flash pipeline

Per watch: download zImage-dtb-<codename>.fastboot and asteroid-image-<codename>.rootfs.ext4 from the AsteroidOS nightly server with SHA512 verification (cached copies that pass the checksum are reused), power the port, wait for ADB (with an SSH/RNDIS-mode hint if the watch answers on the network instead), adb reboot bootloader, wait for the fastboot device, fastboot flash userdata + flash boot + continue, then clear stale SSH known_hosts entries. The web UI streams the whole run live into an inline log; the CLI flashes a list or the entire fleet sequentially and prints a summary. Failed watches stay powered for inspection.

Hardware failure handling

Written against a misbehaving dock and kept because every fleet will eventually meet one of these:

  • Stale device nodes: these docks raise no disconnect event for ports they power off, so dark devices linger in sysfs, and a lingering dark node makes the adb server's startup fail (LIBUSB_ERROR_IO), wedging ADB bus-wide. A plain off→on often does not re-enumerate past a stale node; a single-invocation uhubctl power cycle does. The drain-test read path and the PPPS test both use one automatic cycle as their recovery primitive.
  • adb server crashes during device-disconnect storms are tolerated: device...
Read more