Skip to content

Subsystem PC Daemon

OneSeventyFour edited this page May 14, 2026 · 1 revision

Subsystem: PC daemon

host/pythings/pc_daemon/pc_daemon.py is the Python process that:

  • Holds the only TCP connection to the serial bridge (and therefore to the dongle).
  • Reads/writes show data and receiver definitions in SQLite.
  • Runs the show schedule.
  • Coordinates OTA flashes.
  • Publishes a JSON state snapshot for the WS server / browser to consume.

It runs under supervisord inside the container as the firework-daemon program. There is exactly one instance per Backyard Hero install.

Threads

FireworkDaemon.run() starts five long-lived threads:

Thread What it does
poll_command_dir Polls /tmp/d_cmd/ at 2 Hz for JSON command files dropped by the API. Reads, deletes, dispatches via handle_command(cmd).
read_from_tcp Reads bytes from the TCP socket to the serial bridge. Splits on \n, dispatches each line to the protocol handler.
monitor_switch At 10 Hz, reads the latest GPIO state (start/stop, arm, manual fire) from gpio_handler (which is fed by gpio_status JSON from the dongle), enforces interlocks.
state_flusher Coalesces state-dirty signals into snapshot writes (file + Unix-socket push). Debounces ~10 ms; emits at most every ~10 ms during bursts, plus a 1 s heartbeat.
(run_show worker) Spawned on demand from start_schedule. Runs the precheck → countdown → schedule loop.

Top-level command types

handle_command(cmd) dispatches on cmd['type']. The full list:

Type Body Effect
serial { data: "..." } Pass data straight to the dongle (debug).
manual_fire { data: { zone, target } } Manually fire one cue (with arm + start + manual-fire interlock checks).
db_query { query: "..." } Run a raw SQL query (debug).
delegate_launch { do_it: bool } Toggle the delegate_start_to_client flag.
start_show (none) Start the loaded schedule (delegate-aware).
stop_show (none) Stop the schedule.
pause_show (none) Pause; transition to DELEGATE_WAIT if delegate is on.
schedule { schedule: [...] } Override the current schedule (rare; debug).
stop_schedule (none) Same as stop_show.
load_show { id: <showId> } Load show from SQLite onto the receivers.
unload_show (none) Send reset to all receivers; clear local state.
select_serial { device, baud } Reconfigure the bridge to talk to a different port.
set_brightness { brightness } Set the LED brightness on the dongle.
set_receiver_timeout { timeout_ms } Set the dongle's "how long without RX = offline" threshold.
set_command_response_timeout { timeout_ms } Set the dongle's per-command response timeout.
set_clock_sync_interval { interval_ms } Set the dongle's clock-sync poll cadence.
set_debug_mode { debug_mode } Toggle dongle debug verbosity.
set_fire_repeat { repeat_ct } Set the per-cue fire-repeat count.
reload_receivers (none) Re-read Receivers table; reconcile dongle's poll list. Refused while a show is loaded.
retry_receiver { ident } Re-send sync for one receiver.
fetch_receiver_config { ident?, fire_duration_ms? } Send rxcfg <ident> (CONFIG_QUERY). Optionally write a new fire duration first.
set_rf_channel { channel } (0..125) Hot-swap the dongle's RF channel. Refused while a show is loaded or armed.
ota_flash_start { ident, image_path, rate } Begin OTA flash.
ota_flash_abort (none) Abort in-flight OTA.
scan_radio { passes, ch_start, ch_end } Start an RF spectrum scan.

See Daemon command reference for the exact JSON shapes of each.

BYHProtocolHandler

protocol_handler/BYHProtocolHandler.py (~1500 lines) is where most of the show logic lives.

Receiver registration

On init (and on reload_receivers):

  1. Read every enabled row from the Receivers table.
  2. Build the in-memory receivers dict keyed by ident: { label, type, cues, enabled, metadata, configuration_version }.
  3. For each non-Bilusocn receiver, send sync <ident> 0 1 to the dongle. This forces the dongle to add the receiver to its TDMA poll list.
  4. (Bilusocn receivers are skipped — there's no bidirectional link to maintain.)

Per-receiver telemetry

The handler maintains two per-receiver state pieces:

  • status: live telemetry (battery, lmt, loadComplete, startReady, continuity, lat, successPercent, fwVersion, boardVersion, numBoards, cuesAvailable, fireDurationMs).
  • drift: most recent host-vs-dongle clock offset (used to project receiver timestamps into host wall time).

Two paths populate them:

  • process_status_msg on the dongle's per-second aggregate status JSON. Walks the receivers[] array, merges each entry into the per-receiver dict, recomputes the rolling latency average over 20 samples.
  • process_rxupd_msg on the dongle's "fast path" per-receiver update. Fires every time the dongle gets new state from one receiver. Critical detail: if the x (latency sample) field is absent, the dongle had a TX failure on this receiver — so the handler does not advance lmt. This avoids "fake fresh" status when the radio is failing.

Online detection

receiver_is_connected(ident) returns true if now_ms - status.lmt < 8000 (LATENCY_TO_CONSIDER_ONLINE_MS).

Loading a show

load_show(firing_array, show_id):

  1. For each item, resolve (zone, target) → device id by walking receivers[*].cues. If duplicates (same zone:target across receivers) — error.
  2. Annotate each item with device_id, type, async_fire (true for everything except Bilusocn).
  3. Group async items by device → async_device_load_dict.
  4. Call load_async_fire_targets:
    • startload <ident> <count> <show_id>. Wait 50 ms.
    • Send showloadn chunks (up to 6 cues per line, 30 ms apart). Time values are round(startTime * 1000) ms; positions are target - 1 (0-based on the wire).
  5. Set load_waiting = True. Return False.
  6. Subsequent process_rxupd_msg calls update loadComplete. Once every async target reports loadComplete=true for this show id, signal_show_loaded flips state to LOADED.

Running a show

run_show():

  1. Precheck (run_precheck):
    • Battery: every cue's target must report battery >= min_battery_to_fire_pct (default 30, configurable via protocols.BKYD_TS_HYBRID.config).
    • Continuity (if require_continuity is true): the receiver's continuity[2] masks (two 64-bit values for up to 128 cues) must have bit (target - 1) set.
    • Returns a list of error strings; non-empty → abort.
  2. Pick show_start_time = now_ms + SHOW_START_TIME_SECONDS (25 s).
  3. Broadcast showstart (×6 repeats) to all preloaded receivers via send_to_active_nodes.
  4. Wait for startReady=true from every preloaded receiver. Reissue showstart to stragglers every 6 iterations. Abort ABORT_PRE_START_SECONDS (10 s) before T-0 if anyone still isn't ready.
  5. Countdown. While now < show_start_time, send play 0 to all preloaded receivers every 3 s.
  6. Schedule loop. For each item in the firing array, busy-wait until (time.time() - start_time_epoch_sms) >= delay + pause_offset, then call fire_item(item):
    • async_fire = True → no-op (the receiver fires it from its preloaded schedule).
    • async_fire = False (Bilusocn) → 433fire <BSC_pkg> x to the dongle.
  7. End. When the schedule's last item has been processed, transition RUN_STATE to STOPPED.

Pause / stop

  • Pause (UI button or start-switch UP mid-show): set schedule_pause_event, send pause 0 to all receivers. Receivers freeze their local schedule. The host accumulates pause_offset so resumed cues are correctly delayed.
  • Stop (Abort button or both switches UP): set schedule_stop_event, send stop 0. Receivers immediately quit firing.

OTA flash driver

protocol_handler/OtaFlashDriver.py. Single-threaded driver started by start_ota_flash(ident, image_path, rate).

Lifecycle:

  1. Read the entire .bin into memory (on the API thread, so the OTA worker doesn't do disk I/O).
  2. Compute CRC32.
  3. flash_begin <ident> <total_size> <total_chunks> <crc_hex> <rate> to the dongle. Wait for begin_ok event (parsed by feed_event from compact OA/ON lines).
  4. For each 29-byte chunk: flash_data <idx> <hex>. Wait for ack (idx matches) or nack (retry).
  5. Strict ACK gate — never have two chunks in flight. Avoids saturating the dongle's 128-deep command queue.
  6. flash_end. Wait for done (or timeout after 20 s of silence).
  7. The dongle then serviceOtaRejoins the receiver — pinging it with CLOCK_SYNC until a RECEIVER_STATUS from the new firmware arrives, or 30 s elapses.

Retries:

  • Up to 12 host-side attempts per chunk, with backoff.
  • After attempts 3, 6, 9: send flash_recover <idx> <level> (level 0 = REPLAY, 1 = SOFT, 2 = FULL). The dongle drops/reopens the radio at the appropriate level to recover from a wedged link.

Gating:

  • start_ota_flash refuses if a show is loaded, the system is armed, the receiver is offline, the receiver is BILUSOCN_433_TX_ONLY, or the image is unreadable.

State publishing

update_state_file() is called by the state_flusher thread. It builds a JSON dict containing:

  • Daemon health (device_running, device_found, daemon_lup).
  • Show state (show_loaded, loaded_show_name, loaded_show_id, show_running, device_is_transmitting).
  • Switch state (device_is_armed, manual_fire_active, start_sw_active).
  • Errors (fire_check_failures, proto_handler_errors, proto_handler_status, active_protocol).
  • Delegate flag (dstc, waiting_for_client_start), show start time (sst).
  • Per-receiver receivers dict.
  • OTA snapshot (from OtaFlashDriver.get_ota_state()).
  • Settings (LED brightness, fire repeat count, RF channel, last RF scan summary, command queue depth/capacity, …).

Writes via:

  1. First: best-effort UDP send to /tmp/byh_state.sock (the WS server's Unix datagram listener). Sub-millisecond delivery to subscribers.
  2. Then: atomic file write to /data/state via mkstemp + os.replace. Acts as the durable record and a fallback for any reader that polls instead of subscribing.

Configuration

Config is loaded from /config/systemcfg.json on startup. The fields the daemon cares about:

  • system.dongle_port — the TCP bridge configures the serial port from this.
  • system.dongle_baud — fixed 115200.
  • protocols.BKYD_TS_HYBRID.configmin_battery_to_fire_pct, require_continuity. Re-read on every run_precheck.

The Receivers table is the source of truth for receivers. The legacy receivers block in systemcfg.json is only used to seed the table the very first time the daemon starts on an empty database (seedReceiversFromSystemCfgIfEmpty() in sqldb.js).

Clone this wiki locally