Home

03 state and redis

§7e.3 State & Redis cache

Inputs

dcc-bus consumes three kinds of input and produces one kind of output:

                   ┌──────────────────────────────────────────────────┐
                   │                  dcc-bus                          │
                   │                                                  │
       Redis ─────►│  roster snapshots (allowed_vehicles,             │
       GET+sub     │  defined_trains) + loco:state cache              │
                   │                                                  │
       Redis ─────►│  pub/sub: server-initiated cmds + roster updates │
       pub/sub     │                                                  │
                   │                                                  │
       command  ──►│  WebSocket frontend clients                      │
       station     │  (loco.* and system.estop)                       │
       (DCC,        │  (connection params from CLI --station-*)        │
       bidir)     ──┴──►  state cache + audit events                  │
                   │                                                  │
                   └────────┬─────────────────────────────────────────┘
                            │
                            ▼
                          Redis (string keys + pub/sub)
                            │
                            ▼
                   loco-server (SQLite catalogue, snapshot publisher)
                   browsers     (snapshot on subscribe)

Catalogue truth lives in loco-server's SQLite. The daemon never queries it. Instead loco-server builds JSON snapshots and publishes them on Redis using types and helpers from pkgs/bigfred/contract.

The authoritative inventory of every Redis key, channel and key template used across loco-server and dcc-bus lives in pkgs/bigfred/contract/redis.go (templates + builder functions). Roster JSON shapes and Marshal / Unmarshal* helpers live in allowedvehicles.go. The literals below document those same templates; see also §3.2 Contract package.

Layout roster snapshots

For each layout L the server maintains two Redis string keys and mirrors the same JSON on matching pub/sub channels (SET + PUBLISH in one pipeline so subscribers always match GET):

Key / channel JSON root Purpose
bigfred:layout:<L>:allowed_vehicles { layoutId, updatedAt, vehicles[] } Drivable vehicles on the layout roster. Each entry: vehicleId, addr, ownerUserId, controllerUserIds[], plus per-vehicle dead-man's switch catalogue: rp1Function (F0..F31, default F2), emergencyLightsFunction (default F0), deadManSwitchOption (stop | stop_horn | stop_horn_emergency_lights). Dummies (no DCC address) are omitted.
bigfred:layout:<L>:defined_trains { layoutId, updatedAt, trains[] } Trains on the layout roster. Each train: trainId, ownerUserId, controllerUserIds[], ordered members[] (vehicleId, position, reversed, speedMultiplier, optional addr). Dummies keep addr == null.

Daemon boot: GET both keys (may be missing → empty roster until the server publishes).

Runtime: SUBSCRIBE to both channels; each message replaces the in-memory cache (Router.ApplyAllowedVehicles / ApplyDefinedTrains).

Publisher: LayoutVehicleService.SyncLayoutRosterToRedis after layout roster mutations, vehicle catalogue updates affecting roster trains, train catalogue changes, and once per layout at loco-server bootstrap.

loco.subscribe gates on membership in allowed_vehicles. loco.setSpeed / loco.toggleFn additionally require session.userId ∈ controllerUserIds for that address. train.setSpeed gates on session.userId ∈ train.controllerUserIds from the defined_trains snapshot. The daemon has no notion of leases or takeovers — both controller sets are flat, already-resolved projections that loco-server computes from the catalogue (owner, active lessees, takeover self-lease). Whenever any of those change, loco-server republishes the snapshots and the daemon swaps its maps before the next command.

Per-vehicle invalidation channels (planned)

Finer-grained channels from the original design (bigfred:vehicle:<id>:lease, takeover, functions, …) remain future work. Until then, any catalogue change that affects driving rights flows through a full roster snapshot on the two layout keys above.

State feed — external-throttle visibility

The command station this daemon owns is only one of several possible controllers for any given loco: a physical handheld throttle plugged straight into the Z21 or onto the LocoNet bus can change a loco's speed, direction or functions without BigFred ever issuing the command. To keep the throttle UI honest, dcc-bus runs a state feed that mirrors whatever it observes on the bus back into the loco:state cache and out to WS clients. See §7e.9 for the driver-capability research that drives the two implementations below.

The feed (Router.RunStateFeed) picks one of two strategies at startup depending on whether the driver implements the optional commandstation.StateObserver capability:

Both strategies funnel into the same reconciler (applyObservation). For Z21, speed/direction on the wire and the SET-vs-INFO step-mode asymmetry are documented in §7e.9 ("Z21 drive encoding").

  1. Merge the (possibly partial) observation onto the last cached snapshot.
  2. Only store + fan when the merged state actually changes. This change-guard also collapses the echo of BigFred's own writes (those already wrote Redis with their real source / controlledByUserId), so in-app driver attribution survives while genuine external changes still surface.
  3. Write the snapshot to Redis (loco:state:<layoutId>:<addr>, TTL refreshed) and fan loco.state out to every subscriber of addr via the in-memory Hub (and, through StoreState, onto the dcc-bus:evt:<L>:<C> event channel).

An externally-driven change carries controlledByUserId: 0 (nobody in BigFred owns it) and source: "external" (push) or source: "poller" (poll-detected drift). The frontend treats both as authoritative and applies them rather than suppressing them as its own optimistic echo. The feed also keeps the per-(addr, fn) fnCache honest after an external function change, so the next in-app toggle is not wrongly collapsed as a no-op.

controlledBy for in-app writes is still computed inside the daemon from the most recent setSpeed / toggleFn caller, pub/sub takeover state, and explicit re-broadcasts from loco-server (see §7e.5).

Redis key layout

Key Type Owner Purpose
loco:state:<layoutId>:<addr> string dcc-bus writes Per-loco JSON snapshot (LocoStatePayload). TTL refreshed on change.
dcc-bus:ports hash loco-server <layoutId>:<csId><port> allocation table; persisted across server restarts.
dcc-bus:<L>:<C>:status string dcc-bus One of starting | running | draining | degraded; consumed by loco-server for the system.status event.
dcc-bus:<L>:<C>:sessions hash dcc-bus <sessionId><openedAt,unix>; lets loco-server and the operator inspect active throttles per daemon.
dcc-bus:cmd:<L>:<C> pub/sub channel loco-server publishes, dcc-bus consumes Server-initiated DCC commands (scripts, dead-man, takeover-release fan-out). Not used for frontend train.setSpeed — that action lives on the daemon WS. See "Command channel" below.
dcc-bus:evt:<L>:<C> pub/sub channel dcc-bus publishes, loco-server consumes Outbound throttle events that loco-server needs to mirror onto its own WS (cross-tab fan-out, audit fan-in). See "Event channel" below.
bigfred:layout:<L>:allowed_vehicles string + pub/sub loco-server publishes Full drivable-vehicle roster for layout L.
bigfred:layout:<L>:defined_trains string + pub/sub loco-server publishes Full train roster for layout L.
bigfred:layout:<L>:emergency:<userId> pub/sub channel loco-server publishes Cross-process dead-man's switch fan-out (§7e.5, planned).

All keys share the Redis instance configured by --redis-addr on loco-server and dcc-bus. Roster snapshot keys have no TTL; loco:state entries use a short TTL refreshed on each write. Pub/sub channels are ephemeral by definition.

Snapshot on subscribe

When a frontend issues loco.subscribe { addr } to the daemon, the WS handler:

  1. Validates authorization (§7e.5: CanDriveLoco / read access).
  2. Adds the client to the in-memory Hub for addr.
  3. Reads HGET loco:state:<csId> <addr> from Redis; if present, immediately emits loco.state {…} to that single client. If absent, fires a one-shot Station.GetSpeed(addr) against the DCC bus and broadcasts the result.

This preserves §5.3's promise — "the UI doesn't wait for the poller" — and stays inside the daemon's own state caches.

Command channel (server → daemon)

Throttle write operations originated by frontends arrive directly on the daemon's WebSocket (loco.setSpeed, loco.toggleFn, train.setSpeed, system.estop). Throttle operations originated by anything else inside loco-server (scripts, takeover-release SetSpeed(0), dead-man's switch fan-out) reach the daemon via the dcc-bus:cmd:<L>:<C> Redis pub/sub channel.

Payload envelope:

{
  "type": "loco.setSpeed" | "loco.toggleFn" | "system.estop",
  "id":   "uuid (for ack via dcc-bus:evt:<L>:<C>)",
  "actor": {
    "userId":   42,
    "source":   "frontend" | "script" | "deadman" | "takeover" | "train",
    "sessionId":"optional; for cross-tab attribution"
  },
  "payload": { "addr": 3, "speed": 64, "forward": true }
}

The daemon:

  1. Re-checks the policy for the actor.userId even though loco-server already evaluated it. Policy evaluation is pure and cheap; the duplicate check eliminates a "TOCTOU" between the server's decision and the daemon's command write.
  2. Invokes the matching Station method.
  3. Updates the Redis state hash (same path as a frontend write).
  4. Publishes the resulting loco.state event on both:
  5. the in-memory Hub (for connected WS clients on this daemon),
  6. the dcc-bus:evt:<L>:<C> Redis channel (for loco-server's cross-tab fan-out and audit fan-in).

The source discriminator is preserved end-to-end so the broadcast loco.state.controlledBy correctly reads "signalman" for takeover writes, "train" for train fan-out, "driver" for direct writes, etc. (§4.2 enum).

Event channel (daemon → server)

Conversely, every DCC state change observed by the daemon (including events caused by an external physical throttle the daemon polls but did not author) is published on dcc-bus:evt:<L>:<C>. The server's LocoEventConsumer (lives in loco-server, listens on this channel) mirrors the event onto the server WS for clients who are subscribed there (not the typical throttle client, but e.g. an MCP SSE session, or the dashboard for some read-only widget). It also writes audit rows if the event is takeover-relevant (e.g. logs vehicle.taken_over).

Throttle audit lines (a driver pressed setSpeed at 11:42:13) are not added to the audit log by default — that would balloon the table. Only takeover state transitions, emergency-plan executions and script invocations are audited (existing rule, §3a.5).

State reconciliation across daemons

When two daemons share the same command station (different layouts, same cs), they both poll the bus and they both write to loco:state:<csId>. Conflicting writes:

In-memory state inside the daemon

Structure Source Notes
allowed map[addr] allowed_vehicles snapshot Gates loco.subscribe and estop-all scope.
byAddr map[addr] → AllowedVehicle same snapshot controllerUserIds for drive commands.
trains []DefinedTrain defined_trains snapshot train.setSpeed fan-out + drive gate (controllerUserIds, member speedMultiplier, timing fields, excludeFromSpeed, addr).
fnCache per (addr, fn) local Avoids duplicate DCC function packets.
WS session JWT at upgrade userId, subscribed addresses, dead-man targets.

No SQLite connection. No per-row catalogue LRU — the roster maps are replaced wholesale on each snapshot message.