Radio State Pipeline Design¶
Status: draft for review Date: 2026-06-02 Scope: public open-core runtime, web, rigctld, and backend-neutral state flow
Summary¶
RigPlane needs one canonical radio state model. WebSocket, HTTP snapshots, rigctld reads, diagnostics, and future processing hooks should all consume the same state source, with one state revision sequence and separate freshness versioning. Polling, push frames, command responses, and adaptive acquisition are ways to discover values; they must not be delivery pipelines.
The target architecture separates command ingress from data ingress:
Command ingress:
WebSocket / HTTP / rigctld / public API
-> CommandIntent
-> backend-neutral CommandService
-> backend executor / existing command queue / IcomCommander
Data ingress:
CI-V push / command response / poll response / Yaesu poller / Hamlib rigctld client
-> Observation
-> StateStore.apply(...)
-> ChangeSet + revision
Consumers:
WebSocket / HTTP state / rigctld GET / diagnostics / future hooks
The first delivery milestone should introduce the neutral contracts and migrate the highest-impact Icom/Web path. Later milestones should bring Yaesu, Hamlib/external-rigctld, rigctld SET/GET, and adaptive policy hooks onto the same model.
Problem Statement¶
The current command execution system is reliable and should remain intact. The fragile area is the pipeline from "a value was received from the radio" to "all consumers observe the same state".
Current symptoms:
- S-meter and other meters can update in
RadioStatewithout consistently advancing the public web revision. The UI then sees meters in bursts when an unrelated event advances the revision. - X6200 can show multi-second lag after tuning from the radio panel. IC-7610 LAN often masks the same architectural weakness with faster transport and richer unsolicited state.
- WebSocket and HTTP both update the frontend store, but they do not currently share a canonical state revision owned by the state model.
- rigctld maintains local pending/cache state for some operations instead of consuming the same radio state source as Web.
- Radio-specific workarounds are tempting because acquisition policy and state delivery are not cleanly separated.
Goals¶
- Make the radio state model the single source of truth for confirmed radio state and canonical state revision.
- Ensure every meaningful state mutation is represented as a typed
ChangeSet. - Treat WebSocket and HTTP as two representations of the same state source.
- Treat rigctld GET as a consumer of the same state source.
- Preserve the existing command queue, IcomCommander, transport pacing, raw CI-V transaction ownership, and backend safety behavior.
- Represent writes as
CommandIntentlifecycle events, not as direct state delivery. - Support optional pending state for local writes without confusing it with confirmed observations from the radio.
- Make radio-specific behavior capability/policy-driven rather than backend-name branches.
- Provide explicit hooks for diagnostics, pulse/adaptive polling, meter coalescing, and future public processing.
Non-Goals¶
- Do not replace the existing serialized command executors in the first milestone.
- Do not introduce direct
libhamlibbindings. Core's Hamlib boundary remains externalrigctld. - Do not change rigctld wire protocol behavior.
- Do not redesign the frontend UI.
- Do not add proprietary, hosted-account, customer-specific, or Pro-only workflow logic to Core.
Current Model¶
Web State Delivery¶
The frontend currently starts HTTP polling for /api/v1/state, then opens the
control WebSocket. Both channels write into the same frontend store.
HTTP polling only calls the store callback when revision or
healthRevision advances. The control WebSocket receives state_update
messages and also writes to the store. This is operationally useful, but the
backend revision is not owned by the radio state model.
Icom CI-V State Updates¶
The CI-V RX path decodes solicited and unsolicited frames and mutates state. Only selected fields call a state-change callback. Some high-value fields such as meters and frequency/mode are updated silently or through separate paths.
That creates this failure mode:
meter frame arrives
-> RadioState meter field changes
-> no state revision bump
-> no Web state broadcast
-> later unrelated event bumps revision
-> UI receives several meter changes as one jump
SET Commands¶
WebSocket commands and HTTP commands mostly enter the Web CommandQueue, then
the poller executes them through the backend API. Some commands also apply
manual optimistic updates to RadioState and bump a poller-owned revision.
rigctld SET commands currently call backend methods directly and maintain local pending/cache values for some fields. This is a separate command ingress path and a separate read cache.
Target Architecture¶
The shared state service is a runtime-level service, not Web-only wiring. Web, rigctld, CLI/public API paths, and backend runtimes should receive the same service instance through startup composition. This is required for rigctld and public SDK calls to observe the same source of truth as Web.
flowchart LR
subgraph "Command Ingress"
WS_CMD["WebSocket command"]
HTTP_CMD["HTTP command"]
RIGCTL_SET["rigctld SET"]
API_CMD["Public API call"]
end
subgraph "Command Layer"
INTENT["CommandIntent"]
SERVICE["CommandService"]
EXEC["Backend executor\nCommandQueue / IcomCommander / backend API"]
LIFE["CommandLifecycleEvent"]
end
subgraph "Data Ingress"
CIV_PUSH["CI-V unsolicited frame"]
CIV_RESP["CI-V command/poll response"]
STATE_POLL["StatePollable poll result"]
HAMLIB_RESP["External rigctld client response"]
end
subgraph "State Pipeline"
OBS["Observation"]
STORE["RadioStateModel / StateStore"]
CHANGE["ChangeSet + state revision"]
end
subgraph "Consumers"
WS_STATE["WebSocket state_update"]
HTTP_STATE["HTTP /api/v1/state + ETag"]
RIGCTL_GET["rigctld GET"]
DIAG["Diagnostics"]
HOOKS["Policy/hooks"]
end
WS_CMD --> INTENT
HTTP_CMD --> INTENT
RIGCTL_SET --> INTENT
API_CMD --> INTENT
INTENT --> SERVICE --> EXEC
SERVICE --> LIFE
CIV_PUSH --> OBS
CIV_RESP --> OBS
STATE_POLL --> OBS
HAMLIB_RESP --> OBS
EXEC -. "may produce response/echo" .-> OBS
OBS --> STORE --> CHANGE
CHANGE --> WS_STATE
CHANGE --> HTTP_STATE
CHANGE --> RIGCTL_GET
CHANGE --> DIAG
CHANGE --> HOOKS
LIFE --> DIAG
LIFE --> HOOKS
Core Concepts¶
State Ownership and Migration Invariants¶
The target model is single-writer for confirmed state: StateStore.apply(...)
is the only place that changes the consumer-visible confirmed state model.
To make that enforceable, the state service must own a private state instance
or immutable snapshot source. Exposing a mutable RadioState object as the
consumer-visible state source is a migration hazard and should be treated as a
legacy compatibility surface until each field family moves behind the state
service.
During migration, every legacy writer must be classified before it is touched:
observation_adapter: decodes or polls values and emits observations only.pending_overlay: records local intent but does not change confirmed state.executor_cache: private timeout/pacing cache used by a command executor, never exposed as fresher consumer state.protocol_local_keep: protocol/session state that is not radio state, such as rigctld session VFO handling.compatibility_shim: temporary projection retained for API/wire stability.delete: replaced by the shared state model and protected by tests.
No legacy writer may remain a consumer-visible source of truth once its field family has migrated. If a temporary compatibility shim remains, the spec or inventory must name its owner, replacement, and removal condition.
RadioStateModel / StateStore¶
The state model owns the canonical RadioState instance and the canonical
state revision. It is the only layer that applies observations to confirmed
state.
Responsibilities:
- Validate and normalize field updates.
- Compare previous and next values.
- Emit
ChangeSetonly when confirmed state changes. - Keep one monotonic state revision for applied model snapshots. This revision is a local version/cache-invalidation sequence, not proof that the physical radio has not changed.
- Provide full snapshots for HTTP and initial WebSocket state.
- Provide optional projections for rigctld and diagnostics.
- Track per-field freshness metadata: last observation time, source, confidence, max age, and stale/unknown status.
- Maintain health/freshness revision separately from value revision for validity changes that do not alter a data value.
The implementation should avoid web or rigctld imports. Contracts should live in a neutral layer; the concrete runtime object can be wired into Web and rigctld at startup.
Layer placement rule:
core: neutral contracts only, such asObservation,FieldPath, andChangeSet. Core must not contain Web or rigctld projection schemas.runtime: state service implementation and backend orchestration.webandrigctld: projections, wire schemas, and compatibility adapters.profiles: static capability and acquisition-policy metadata, with loader validation.
Observation¶
An Observation is a decoded value from an acquisition source. It is not a
command acknowledgement and not a Web event.
Suggested fields:
path: typedFieldPath, not an ad-hoc string. It must distinguish receiver, VFO slot, active slot, global fields, scope controls, and meter families. Examples:receiver.main.slot.A.freq_hz,receiver.sub.active_slot,receiver.main.s_meter,global.ptt.value: normalized Python value.source:civ_unsolicited,poll_response,command_response,state_poller,hamlib_response,local_reconcile.receiver: optional receiver identity.timestamp_monotonic.quality: optional flags such asconfirmed,stale,partial,synthetic.correlation_id: optional command intent or poll request id.
Observation hooks should fire for every decoded sample, even if the value did not change. This supports diagnostics and policy decisions such as "radio is alive" and "meter sample rate".
ChangeSet¶
A ChangeSet is the result of applying one or more observations to the state
model.
Suggested fields:
revision: canonical state revision after the change.changes: typed field changes with previous and next values.timestamp_monotonic.sources: acquisition sources represented in this change set.coalesced: whether multiple observations were batched.
Change hooks fire only when confirmed state changes. WebSocket deltas, HTTP ETags, rigctld GET cache invalidation, and diagnostics should derive from this event.
Revision, Freshness, and Missed Events¶
State revision answers only this question: "has the model applied a newer known state value?" It must not be interpreted as "the radio definitely did not change."
If an unsolicited radio frame is lost, malformed, or never emitted by the radio, the model cannot infer the missing value change from revision alone. The system therefore also needs field freshness and reconciliation:
- Each state field or field family tracks
last_observed_monotonic, source, confidence, and a policy-defined freshness window. - Freshness transitions, such as
fresh -> staleorunknown -> fresh, emit a health/freshness event and may advance a separate freshness revision. - Consumers that need authoritative state can call an
ensure_fresh(paths, max_age)style API. That API may return the current fresh value, trigger an acquisition read, or report stale/unavailable state. - Critical fields such as frequency, mode, PTT, power state, and selected receiver need reconciliation polling even when push is supported.
- Meter fields can have shorter freshness windows and coalesced delivery, but the latest sample must still be tracked independently from Web delivery rate.
This keeps revision useful for transport deltas while preventing it from becoming a false correctness signal.
FieldPath¶
FieldPath should be a typed schema or enum-like value so pending overlays,
freshness, and observations cannot accidentally target the wrong receiver or
VFO slot.
Required path dimensions:
- scope:
global,receiver,scope_controls,connection,health. - receiver:
main,sub, or profile-specific receiver id. - slot:
active,A,B, ornone. - field family:
freq_mode,operator_toggles,operator_controls,meters,tx_state,slow_state. - field name: normalized
RadioStatefield name.
The VFO slot dimension is required because RigPlane supports selected and
unselected frequency/mode reads. A path such as main.freq is not precise
enough once pending confirmation and freshness windows are field-specific.
CommandIntent¶
A CommandIntent represents an attempt to change or query radio state from a
consumer.
Suggested fields:
id: stable command id.name: backend-neutral operation name, such asset_freq.params: normalized command parameters.source:websocket,http,rigctld,public_api,internal_policy.target: receiver/VFO/global target.priority: user, normal, background.timeout.pending_policy: whether the command should create a pending local overlay.expected_observations: optional fields likely to confirm the command.
Command lifecycle events are separate from state changes:
acceptedqueuedsentacknowledgedfailedtimed_outconfirmedsuperseded
Pending State¶
For user experience and rigctld compatibility, some writes need immediate read-after-write behavior. This should be represented as pending intent, not as confirmed radio state.
Example:
CommandIntent(set_freq=14074000, source=rigctld)
-> pending overlay: main.freq=14074000, confirmed=false
-> CI-V observation: main.freq=14074000
-> confirmed state change, pending cleared
The public state schema can remain backward compatible by continuing to expose plain values. Additive metadata can expose pending/confirmed status later if needed. Internally, consumers must be able to distinguish confirmed state from local intent.
Pending overlays must be scoped. A pending value is keyed by at least:
- source: WebSocket, HTTP, rigctld, public API, internal policy.
- session/client id when the protocol has session-local behavior.
- command id.
FieldPath.- target receiver/VFO slot.
- expiration/confirmation deadline.
Global pending overlays may be used only when read-after-write behavior should
be visible to every consumer. rigctld protocol-local state, such as split TX VFO
selection or parser VFO mode, is not radio state and should remain
protocol_local_keep unless a separate state projection is explicitly designed.
Acquisition Policy¶
Acquisition policies decide how values are obtained. They do not own state delivery.
Examples:
- Prefer CI-V unsolicited frames when a backend/profile supports them.
- Run low-rate reconciliation reads for critical state even when push is supported, because unsolicited frames can be missed.
- Poll frequency/mode on a short pulse after local writes or detected external activity when unsolicited updates are unavailable.
- Trigger
ensure_freshreads when a consumer requires data newer than the field's freshness window. - Poll S-meter at a profile-safe target rate.
- Poll TX meters more aggressively while transmitting.
- Slow down background state queries when the link is idle or congested.
- Reduce background telemetry before delaying user commands.
Policy decisions should be capability-driven:
- transport type and safe minimum gap
- profile meter support
- unsolicited frequency/mode support
- unsolicited meter support
- command response semantics
- serial backpressure/error rate
- external CAT session ownership
No policy should branch directly on a model name such as X6200 unless the model profile exposes that capability or constraint.
AcquisitionScheduler and ensure_fresh¶
ensure_fresh(paths, max_age, timeout, reason) must not perform arbitrary
direct backend reads from Web or rigctld. It asks an AcquisitionScheduler for
fresh observations and waits for the state model to observe or reject them.
Scheduler requirements:
- Accept typed
FieldPathrequests and map them to backend/profile-safe acquisition commands. - Use priorities: user-facing freshness, command confirmation, reconciliation, background telemetry.
- Use dedupe keys so concurrent
ensure_freshcalls for the same field family share one acquisition request. - Respect external CAT/raw CI-V ownership and pause or fail freshness requests instead of polluting an owned byte stream.
- Preserve Icom fire-and-forget polling semantics: queued acquisition requests should emit CI-V queries and wait for RX-pump observations, not bypass the queue with direct request-response reads in the Web poller path.
- Prefer command preemption over background reconciliation on slow serial links.
- Surface timeout/unavailable results without inventing synthetic confirmed state.
For Icom, an ensure_fresh confirmation should normally be:
ensure_fresh(path)
-> AcquisitionScheduler queues deduped query
-> CI-V RX pump receives response
-> Observation(path)
-> StateStore.apply(...)
-> waiter resolves against ChangeSet or fresh unchanged observation
Direct getter-style reads may still exist inside backend executors for public API compatibility, but their results must be converted into observations before they become consumer-visible state.
Hooks¶
The architecture should expose these hook categories:
on_command(event): command lifecycle, audit, latency, failure analysis.on_observation(observation): raw sample accounting and acquisition policy.on_change(changeset): canonical state revision, delivery, history.on_policy_signal(signal): pulse/adaptive scheduling, backpressure, mode changes such as RX/TX.
Hooks must not mutate RadioState directly. They may enqueue acquisition work,
emit diagnostics, update metrics, or request command execution through the
command service.
HTTP and WebSocket Representation¶
HTTP and WebSocket must become projections of the same RadioStateModel.
flowchart TB
STORE["RadioStateModel / StateStore"]
SNAP["Full public snapshot"]
DIFF["ChangeSet projection"]
HTTP["GET /api/v1/state\nETag = state revision + health/freshness revision"]
WS_INIT["Initial WebSocket full state"]
WS_DELTA["WebSocket state_update delta"]
STORE --> SNAP
STORE --> DIFF
SNAP --> HTTP
SNAP --> WS_INIT
DIFF --> WS_DELTA
Rules:
- Initial WebSocket state and HTTP snapshot come from the same snapshot builder.
- HTTP ETag uses the canonical state revision plus health/freshness revision.
- WebSocket state payloads expose canonical state revision as
stateRevision; legacyrevision, while present, is only an alias for that canonical value. - No state revision advance means "the model has no newer applied value"; it does not prove the physical radio did not change.
- Any transport-local sequence, if needed, should be a separate field and not the state revision.
- Delta encoding is a representation concern. It must not own canonical state revision.
- HTTP ETags include state revision plus health/freshness revision so stale transitions can be observed even when values do not change.
- HTTP polling can remain as a startup/fallback edge case, but it is not a second source of truth.
WebSocket Wire Revision Semantics¶
Current WebSocket deltas use an envelope revision that can overwrite the public state revision in the frontend. The migration must split these concepts.
Target wire fields:
stateRevision: canonical value revision fromStateStore.freshnessRevision: freshness/health validity revision.transportSeq: optional per-WebSocket delta/full-message sequence for ordering and drift detection.- legacy
revision: backward-compatible alias forstateRevisiononly while old clients require it.
Frontend rules:
- A full-state envelope must not overwrite canonical state revision with
transportSeq. - A delta envelope applies only when it has a compatible base or carries enough data to recover.
- HTTP and WebSocket races are resolved by canonical
stateRevisionplusfreshnessRevision, not by transport-local sequence. - Restart/reset detection must use canonical state revision and server identity if available, not delta encoder sequence.
- Optimistic patches remain local overlays until confirmed, expired, or superseded by canonical state/freshness changes.
MOR-347 implementation note:
- Web poller-owned public revision has been removed.
- HTTP and WebSocket payloads use
StateStoresnapshots for canonicalstateRevision/freshnessRevision; legacyrevisionaliasesstateRevision. - WebSocket delta/full envelopes include additive
transportSeqfor transport ordering only.
Command Flow¶
sequenceDiagram
participant Client as Web/HTTP/rigctld/API
participant CommandService
participant Executor as Existing executor
participant Radio
participant Acquisition as RX/poll response
participant StateStore
participant Consumers as WS/HTTP/rigctld/diagnostics
Client->>CommandService: CommandIntent(set_freq)
CommandService-->>Client: accepted / queued
CommandService->>Executor: execute intent
Executor->>Radio: backend-specific command
Executor-->>CommandService: sent/ack/failed
Radio-->>Acquisition: response or unsolicited echo
Acquisition->>StateStore: Observation(freq)
StateStore->>StateStore: apply + compare
StateStore-->>Consumers: ChangeSet(revision++)
StateStore-->>CommandService: confirmation correlation
CommandService-->>Client: confirmed or timed out
Important separation:
- Command acceptance does not imply confirmed state.
- Fire-and-forget command execution may still create pending local intent.
- Confirmed state changes only through observations or explicit local reconciliation events.
- Failed commands clear or expire pending overlays without creating confirmed state.
Backend Coverage¶
Icom CI-V¶
Icom is the first high-impact migration path because it already has an RX pump that can decode unsolicited frames and poll responses.
Required changes in the Icom path:
- Convert decoded CI-V frames into observations for all state-bearing frames, including frequency, mode, meters, PTT, power, DSP flags, and levels.
- Preserve raw CI-V transaction ownership and request tracking.
- Keep IcomCommander and serial/LAN pacing.
- Mark poller-originated queries as acquisition work, not state delivery work.
- Use background priority and dedupe for background acquisition where possible.
StatePollable Backends¶
Backends that expose request-response polling should publish observations from their polling results. The existing poller can remain as the acquisition mechanism.
Hamlib External rigctld Provider¶
Core's Hamlib boundary remains an external rigctld process. The Hamlib client
backend should translate rigctld responses into observations and expose
capabilities that describe whether useful push/notification behavior is
available.
rigctld Server¶
rigctld GET should read from the shared state model. rigctld SET should become
command ingress into CommandService, or at minimum publish command intents and
pending overlays while still using the existing backend method path during
migration.
Milestones¶
Milestone 0: Evidence and Diagnostics¶
Deliverables:
- Legacy baseline counters for current CI-V frame ingress by command/subcommand family, including meters and frequency/mode frames.
- Legacy baseline counters for current
_notify_change(...)calls and event names. - Legacy baseline counters for current poller revision bumps, WebSocket broadcasts, HTTP ETag changes, and frontend accepted/rejected state updates.
- Command queue latency and executor latency metrics.
- Serial partial-frame, timeout, external-CAT pause, and backpressure metrics.
- Baseline traces that can answer whether X6200 emits unsolicited frequency frames during VFO tuning and how long Web/rigctld take to observe the change.
Acceptance:
- It is possible to compare current meter frame ingress rate with current Web/UI state delivery rate.
- It is possible to determine whether X6200 emits unsolicited frequency frames during VFO tuning.
- It is possible to identify which legacy mechanism advanced the public state revision for a given Web update.
- No behavior changes are required in this milestone.
Milestone 1: Core Contracts and StateStore¶
Deliverables:
- Backend-neutral
Observation,ChangeSet,CommandIntent, andCommandLifecycleEventcontracts. RadioStateModel/StateStoreimplementation with canonical revision.- Typed
FieldPathschema covering receiver, VFO slot, global, scope control, and meter paths. - State ownership inventory that classifies legacy writers as
observation_adapter,pending_overlay,executor_cache,protocol_local_keep,compatibility_shim, ordelete. - FieldPath registry/table for existing public schema fields, including MAIN/SUB, VFO A/B/active, global fields, scope controls, Yaesu extensions, meter families, and legacy aliases.
- Per-field freshness metadata and separate freshness/health revision semantics.
- Active
FreshnessClock/ticker for freshness deadlines and stale events. - New counters for observations,
ChangeSetemission, freshness transitions, and state/freshness revision movement. - Snapshot projection for the existing public state schema.
- Hook registration for observation/change/command/policy events.
- Unit tests for revision behavior, no-op applies, receiver paths, and coalesced changes.
Acceptance:
- Applying a changed observation increments state revision exactly once.
- Applying an unchanged observation does not increment state revision.
- Freshness-only transitions do not fake data changes, but they are visible to consumers through freshness/health revision.
- High-rate observations can advance
observationSeqwithout forcing HTTP state revision churn. - Full snapshots match the existing public state contract.
- No Web or rigctld imports are introduced into core/runtime state modules.
Milestone 1.5: Minimal AcquisitionScheduler¶
Deliverables:
- Minimal
AcquisitionSchedulercontract and implementation for freshness and reconciliation reads. - Dedupe keys and waiter semantics for concurrent requests for the same
FieldPathfamily. - Priority classes for user freshness, command confirmation, reconciliation, and background telemetry.
- External-CAT/raw CI-V ownership checks.
- Icom-safe acquisition path that queues queries and waits for RX-pump observations instead of bypassing fire-and-forget semantics.
Acceptance:
ensure_freshcan request an acquisition read without Web or rigctld calling direct backend getters.- Concurrent freshness requests for the same field family share one acquisition request.
- External-CAT ownership causes freshness requests to pause, fail, or report unavailable state without polluting the owned stream.
- User-facing commands can still preempt background reconciliation on slow transports.
Milestone 2: Icom RX and Web State Migration¶
Deliverables:
- Icom CI-V RX path emits observations for frequency, mode, meters, and existing notify-backed fields.
- Icom poll responses feed the same observation path.
- Icom freshness reads go through
AcquisitionSchedulerand RX-pump observations, respecting fire-and-forget and external-CAT ownership. - Web HTTP snapshot and initial WebSocket full state are built from
StateStore. - WebSocket deltas use canonical state revision.
- Meter changes are coalesced for Web delivery at a bounded rate.
Acceptance:
- S-meter changes advance state revision or are included in bounded coalesced revisions.
- Frequency changes from unsolicited CI-V frames reach Web without waiting for unrelated events.
- If an unsolicited frequency/mode event is missed, a reconciliation read can eventually correct the shared state.
- WebSocket envelope migration separates
stateRevision,freshnessRevision, andtransportSeq; frontend code does not overwrite canonical state revision with a transport-local sequence. - HTTP
/api/v1/stateand WebSocket initial state agree on revision and data. - Existing command execution behavior remains compatible.
Milestone 3: Backend-Neutral Acquisition Adapters¶
Deliverables:
StatePollableadapters publish observations instead of direct Web callbacks.- Yaesu request-response polling updates the shared state model.
- External Hamlib/rigctld client backend responses update the shared state model.
- RadioProfile schema additions for acquisition policy metadata: push support, safe poll rates, freshness windows, reconciliation intervals, transport constraints, and meter family support.
- Profile loader validation for acquisition policy metadata, with conservative defaults for existing profiles.
- Capability/policy metadata describes push support, safe poll rates, meter support, freshness windows, reconciliation intervals, and transport constraints.
Acceptance:
- Web and HTTP do not need backend-specific state delivery code.
- Backend differences are represented through capabilities and acquisition policies.
- No model-specific X6200 branch is required for the general state pipeline.
- The "no model-specific branch" acceptance is backed by profile metadata and loader validation, not implicit backend-name checks.
Milestone 4: rigctld Consumer Migration¶
Deliverables:
- rigctld GET reads from shared state snapshots/projections where freshness is acceptable.
- rigctld fallback reads still produce observations when they hit the radio.
- rigctld SET publishes command intents and pending overlays.
- Existing rigctld wire responses remain compatible with Hamlib clients.
Acceptance:
- Web and rigctld see consistent frequency/mode/meter state after the same radio observation.
- rigctld read-after-write behavior is preserved through pending intent or confirmed state.
- Local rigctld caches are either removed or reduced to compatibility shims.
Milestone 5: Unified CommandService¶
Deliverables:
- WebSocket, HTTP, rigctld, and public API write paths enter a backend-neutral
CommandService. - Existing command queues/executors remain as backend execution adapters.
- Command lifecycle events are observable.
- Pending overlays are correlated with observations and cleared on confirmation, supersession, timeout, or failure.
- Pending overlays are scoped by source/session/command id/
FieldPathand do not replace protocol-local state such as rigctld session VFO handling.
Acceptance:
- Command ingress is consistent across Web, HTTP, rigctld, and public API.
- Command success/failure is not confused with confirmed state mutation.
- User-facing commands can preempt background acquisition on slow transports.
- rigctld protocol-local state is either preserved as
protocol_local_keepor explicitly migrated with wire-compatibility tests.
Milestone 6: Adaptive Acquisition Policies¶
Deliverables:
- Frequency/mode pulse policy for radios without reliable unsolicited updates.
- Low-rate reconciliation policy for critical state even when push is available.
- Meter telemetry policy separated from slow state polling.
- TX-aware meter policy for POWER/ALC/SWR/COMP.
- Idle decay policy for background state queries.
- Backpressure policy that reduces background telemetry before delaying user commands.
Acceptance:
- X6200-like serial radios can use short freq/mode pulse polling without increasing all background traffic.
- Push-capable radios still recover from missed critical-state events through bounded reconciliation.
- Meters have stable, bounded delivery cadence appropriate to transport capability.
- Policy behavior is configured through capabilities/profile metadata, not hard-coded model branches.
Milestone 7: Cleanup and Compatibility Hardening¶
Deliverables:
- Produce a cleanup inventory that lists every legacy state/revision/cache owner, its replacement, migration status, tests that protect it, and whether it must be kept, migrated, or deleted.
- Produce explicit Web implementation and rigctl/rigctld implementation audits before deleting compatibility paths.
- Remove or deprecate poller-owned public state revision.
- Remove duplicate state caches where replaced by the shared state model.
- Remove manual state-change callback paths once observations cover the same fields.
- Rename or split any transport-local sequence counters that are currently exposed as state revision.
- Update docs for Web, rigctld, state pipeline, and backend capabilities.
- Add regression tests for known meter and X6200 frequency-lag scenarios.
Acceptance:
- The cleanup inventory is complete enough that each legacy tail has an owner and an explicit keep/migrate/delete decision.
- Legacy confirmed-state writers are either migrated to observation adapters or retained only as named compatibility shims that cannot be fresher than the state model.
- The Web audit covers backend server state delivery, frontend state ingestion, and HTTP/WebSocket compatibility.
- The rigctl/rigctld audit covers command parsing, SET execution, GET state reads, pending/cache behavior, and Hamlib wire compatibility.
- One canonical state revision remains for Web/HTTP state.
- No stale silent mutation path exists for supported state fields.
- No legacy cache can serve fresher-looking data than the shared state model.
- Public API, Web state schema, and rigctld wire behavior remain compatible or any additive changes are documented.
Legacy Cleanup Inventory¶
Cleanup must be an explicit workstream, not an incidental final sweep. Before
removing code, create an inventory document and classify every legacy state path
as keep, migrate, or delete. Each delete item needs a replacement path
and regression coverage.
Before the first implementation code change, complete an audit gate covering:
- all Icom
_RADIO_STATE_HANDLERS,_notify_change(...)sites, and directRadioStatewrites; - Web
RadioPollerrevision, mutation,mark_polled, andbump_revisionsites; - Web server public-state building, ETag, broadcast, delta, radio connect, and power paths;
- frontend HTTP/WS/store revision logic, optimistic overlays, restart handling, and stale rejection;
- rigctld pending/cache/VFO/protocol-local paths and Hamlib error mapping;
StateCacheCapable, core_state_cache, and any runtime cache exposed to consumers;- Yaesu and external rigctld-client direct
RadioStatemutations; - profile schema gaps for acquisition policy metadata.
Initial inventory candidates:
- Web
RadioPollerrevision:_revision,revision,bump_revision, and all command branches that mutateRadioStateand manually advance the revision. - Web public state builder: any code that reads state revision from the poller instead of the state model.
- Web broadcast path:
_broadcast_state_update,_on_radio_state_change, and_on_poller_state_eventshould consumeChangeSet/freshness events rather than acting as state mutation triggers. - Web
DeltaEncoder: keep delta encoding as a representation concern, but stop treating its counter as canonical state revision. Rename or expose a separate transport sequence only if needed. - CI-V RX manual notifications:
_notify_change(...)calls should be replaced by observation emission for all supported state-bearing frames. - StatePollable startup callback: direct Web callbacks should migrate to observation application through the shared state model.
- rigctld local state:
_FallbackRigState,_PendingRigState, and routing caches should either become projections/pending overlays from the state model or be retained only as documented compatibility shims. - rigctld protocol-local state:
_split_tx_vfo, client VFO mode,chk_vfoparsing state, and similar Hamlib session semantics should be classified separately from radio state and usually kept as protocol-local state. - runtime
_state_cache: decide field by field whether it remains an executor optimization, migrates into the state model freshness index, or is removed. It must not become a second source of truth for consumers. - Public
StateCacheCapable/state_cache: decide and document whether the protocol remains as an executor timeout cache, becomes a projection ofStateStore, or is deprecated with compatibility guarantees. - Web HTTP ETag handling: move from poller revision plus health revision to state revision plus health/freshness revision.
- Frontend revision assumptions: HTTP polling, WebSocket delta application, and store stale-state rejection should align with canonical state revision and additive freshness metadata.
- Documentation tails: update Web, rigctld, layer docs, and any outdated poller cadence or "poller broadcasts state" descriptions.
Required Web audit surfaces:
web_startup: backend startup wiring forStatePollable,StateNotifyCapable,RadioPoller, and state callbacks.web/server: public state building, ETag construction, health/freshness revision handling,_broadcast_state_update, and radio connect/power paths that currently perform optimistic state mutation.web/handlers/control: initial WebSocket state, command ACK behavior, subscription behavior, and any direct fallback state payload generation.web/radio_poller: command execution, acquisition queries, optimistic mutations,mark_polled, and all revision bump sites.web/_delta_encoder: delta encoding must remain a transport projection, not the owner of canonical state revision.web/runtime_helpers: public state schema projection and backward-compatible field names.- Web revision migration tests must be written before changing wire behavior: HTTP-vs-WebSocket race, full/delta handling, restart/reset, optimistic patch confirmation/expiry, and no overwrite of canonical revision by transport sequence.
- Frontend transport/store: HTTP polling, WebSocket delta application, revision/healthRevision stale rejection, restart detection, and optimistic patches.
- Web tests: state endpoint ETags, WebSocket initial/delta messages, frontend stale-state rejection, and meter/frequency regression coverage.
Required rigctl/rigctld audit surfaces:
rigctld/contractandrigctld/protocol: command definitions, VFO argument handling, parser behavior, and stable Hamlib-compatible wire responses.rigctld/handler: GET/SET dispatch, direct backend calls,_FallbackRigState,_PendingRigState, read-after-write behavior, mode/data-mode compatibility, and error mapping.- rigctld protocol-local behavior:
_split_tx_vfo, client/session VFO mode,chk_vfo, read-only gates, and Hamlib error code mapping. rigctld/server: per-client lifecycle, rate limiting, circuit breaker interactions, poller startup/shutdown, and command timeout behavior.rigctld/poller: state refresh cadence, cache updates, circuit breaker behavior, and whether it should become an acquisition adapter.rigctld/routing: level/function routing that currently depends on local cache projections.rigctld/state_cacheand core_state_cacheshims: decide whether each use is an executor timeout cache, compatibility shim, or obsolete duplicate.- Integration tests: WSJT-X/fldigi-like command sequences, rigctl
F/M/f/mread-after-write, VFO-prefixed commands, stale/fresh reads, and Hamlib error codes. - rigctld caches must not be removed until pending-overlay parity tests prove read-after-write behavior and protocol-local state remains compatible.
Cleanup rules:
- Do not remove a legacy cache before the corresponding consumer reads from the state model or an explicit compatibility shim.
- Do not remove optimistic/pending behavior before
CommandIntentpending overlays preserve read-after-write semantics. - Do not remove Web HTTP polling; reduce it to startup/fallback representation of the same state model.
- Do not remove backend executor caches that are still required for timeout
fallback until
ensure_freshsemantics cover the same behavior. - Every deleted path needs a regression test or an explicit reason why the path is unreachable after migration.
Coalescing and Rate Limits¶
The state model should emit canonical changes for discrete state. High-rate telemetry, especially meters, needs separate sample and delivery semantics so HTTP ETags do not churn at meter sample rate while diagnostics still see every sample.
Revision semantics:
observationSeq: advances for every decoded observation sample, including meter samples that do not become public state updates.stateRevision: advances when the consumer-visible state snapshot changes.freshnessRevision: advances when validity/freshness changes without a value change.transportSeq: optional WebSocket delivery sequence.
For high-rate meter fields, StateStore may retain the latest raw sample and
emit observation hooks for every sample, while publishing a coalesced ChangeSet
at the configured meter delivery cadence. The published ChangeSet must include
the latest sample in the batch and enough metadata for diagnostics to know how
many samples were coalesced.
Guidelines:
- Frequency/mode changes should be delivered with minimal coalescing.
- Meter delivery to Web should be bounded, for example 20-30 Hz on capable links and lower on slow serial links.
- Observation hooks may see every sample even when Web delivery is coalesced.
- Coalescing must not hide the last value in a burst.
- Backpressure must drop or merge background telemetry before user-visible command lifecycle events.
Freshness Clock¶
Freshness cannot be only a passive timestamp checked during reads. The state service needs an active clock/ticker that manages freshness deadlines.
Responsibilities:
- Maintain nearest stale deadlines by field family.
- Emit freshness events when a field transitions
fresh -> stale,unknown -> fresh, orstale -> fresh. - Advance
freshnessRevisionwhen these transitions affect public snapshots or consumers. - Trigger reconciliation requests for critical stale fields according to policy.
- Avoid one timer per leaf field by grouping compatible paths into field families.
This replaces the current pattern where caches become stale only when some caller asks them.
Error Handling¶
- Failed commands emit lifecycle failure events and clear relevant pending overlays.
- Timed-out pending overlays expire and may trigger reconciliation polling.
- Reconnect marks stale fields and may reset transport-local policy state.
- Missing expected observations do not silently confirm intent. They expire the pending overlay and may trigger targeted reconciliation.
- External CAT/raw CI-V ownership remains exclusive; acquisition policies pause when required by the existing ownership mechanism.
- Malformed or partial frames become diagnostics observations or counters, not state mutations.
- Health revision remains separate from state revision, but HTTP ETags include state, health, and freshness revision inputs.
Testing Strategy¶
Unit tests:
StateStore.applyincrements revision only on real changes.FieldPathrouting distinguishes MAIN/SUB, VFO A/B, active slot, global, and meter paths.- No-op observations do not emit
ChangeSet. - Meter observations can be coalesced without losing latest values.
- Meter sample accounting advances observation metadata without forcing every
sample into HTTP-visible
stateRevision. - Pending overlays confirm, expire, and supersede correctly.
- Pending overlays are scoped by source/session/command id/path and do not leak across unrelated consumers.
- Freshness transitions are observable without changing confirmed values.
ensure_freshreturns fresh cached values, triggers acquisition for stale values, and reports unavailable state when acquisition fails.FreshnessClockemits stale transitions without requiring another radio frame or consumer read.- Snapshot projection matches the existing public state schema.
Icom tests:
- CI-V
0x15meter frames produce observations and state changes. - CI-V
0x00/0x03frequency frames produce observations and state changes. - Poll responses and unsolicited frames use the same apply path.
- Reconciliation reads correct stale or missed frequency/mode observations.
ensure_freshrequests respect external CAT ownership and do not bypass the Icom fire-and-forget RX-pump observation path.- Background acquisition uses priority/dedupe where supported.
Web tests:
- Initial WebSocket full state and HTTP
/api/v1/stateare generated from the same state revision. - WebSocket deltas use canonical
stateRevisionand optionaltransportSeqwithout overwriting public snapshot revision. - HTTP ETag advances when state revision advances.
- HTTP ETag or equivalent freshness token advances when critical fields become stale even if values do not change.
- HTTP-vs-WebSocket races, restart/reset detection, optimistic patch confirmation/expiry, and stale rejection are covered by frontend tests.
rigctld tests:
- GET frequency/mode reads shared state when fresh.
- SET frequency creates command intent and preserves read-after-write behavior.
- Fallback radio reads publish observations.
- GET paths can request fresh-enough values instead of trusting indefinitely stale state.
- Protocol-local state, including session VFO handling and split TX VFO state, remains wire-compatible and does not masquerade as confirmed radio state.
- Hamlib error mapping and read-only gates remain compatible.
Integration/fake-backend tests:
- X6200-like serial profile shows bounded frequency update latency after VFO tuning when unsolicited frames are present.
- X6200-like no-push profile uses pulse polling without flooding background state queries.
- Meter delivery cadence is stable and bounded.
Open Decisions¶
- Exact module placement for contracts and implementation must satisfy the import-linter layer matrix. The preferred split is neutral contracts in core and runtime orchestration in runtime.
- Public exposure of pending metadata should be additive and may be deferred.
- The exact Web meter delivery rate should be profile/policy driven after diagnostics establish realistic serial and LAN rates.
- The command confirmation timeout policy should differ by command family and transport.
- The public
StateCacheCapablecompatibility plan should be decided before runtime cache cleanup begins.
Review Checklist¶
- The radio state model is the only canonical source of state revision.
- WebSocket and HTTP are representations of the same state source.
- Pollers acquire observations; they do not own state delivery.
- rigctld is both command ingress and state consumer.
- Radio-specific behavior is modeled as capabilities/policy.
- Existing command serialization and raw CI-V ownership are preserved.