Metadata-Version: 2.4
Name: muse-driver-framework
Version: 1.1.7
Summary: Shared pure-Python framework for AMX MUSE Script-as-Device drivers
Author: AMX MUSE driver project
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# muse-driver-framework

Shared pure-Python runtime framework for AMX MUSE Script-as-Device programs.

The package contains the transport-independent pieces shared by the Precis
Driver and Meter Validation programs:

- asynchronous TCP connection lifecycle and reconnect handling;
- independent TCP plus UDP lifecycle supervision for dual-transport devices;
- a fixed-peer `AsyncUdpChannel` for datagram devices, without discovery or retries;
- fixed static `slot -> host:port` configuration with no discovery or dynamic rebinding;
- descriptor control registry and control type inference;
- bounded, latest-only `MeterStream` frame delivery;
- bounded, latest-only `XYSeriesStream` delivery for complete `[x, y]` frames.

`XYSeriesStream` accepts a variable-length array of finite pairs, including an
empty array. Each frame is atomic and only the newest complete frame is kept,
so a slow chart cannot create a backlog or mix points from different frames.

It has no third-party runtime dependencies and supports Python 3.9+.

Each framework import belongs to one model-specific Script-as-Device runtime.
The framework does not discover devices, verify model identity, or merge devices
from other driver programs. A driver connects directly to the static address
configured for each enabled slot. Multiple slots in one manager must represent
the same device model and protocol. The manager also rejects a factory that
returns different client classes for different slots; this is a local structural
guard, not a cross-brand registry or device identity check.

`AsyncUdpChannel` is a composable transport for a device that uses UDP beside
its TCP control connection. The driver creates one channel inside its own
manager/event loop with a fixed peer and local bind address. It accepts and
returns complete datagrams and bounds the receive queue. The default
`queue_policy="latest"` drops the oldest queued datagram when a consumer is
slow; only independently disposable packets may use this policy. The optional
`queue_policy="fifo"` retains accepted datagrams in local arrival order and
rejects new ones when full, reporting `BufferError` on the next `receive()`.
Accepted datagrams remain queued until consumed or the channel is closed.
It does not provide discovery, device validation,
retransmission, fragmentation, ordering, or acknowledgement; those behaviors
remain protocol-specific. A reliable UDP protocol must implement its framing
and retry rules in the driver instead of treating a datagram as a TCP reply.
`receive()` waits for one datagram, so best-effort telemetry readers should wrap
each receive in a short `asyncio.wait_for()` timeout and continue sending the
next sample after a timeout. Do not let a missing UDP sample block the driver's
TCP command/feedback loop. The timeout is a driver liveness policy, not a
framework acknowledgement or retry mechanism.
After a channel-level UDP error, a driver may call `restart()` to clear stale
datagrams and reopen the same fixed peer; this does not affect the manager's
TCP connection unless the driver explicitly chooses to coordinate them.

`AsyncTcpUdpDeviceClient` supplies the common coordination for this pattern:
TCP keeps its normal command/reconnect lifecycle while a driver-owned
`udp_receive_loop()` is supervised independently. A UDP parser failure is
reported and the UDP channel is reopened without tearing down TCP. The driver
still owns HiQnet or vendor-specific subscriptions, framing, ordering, and
complete-frame validation.

For UDP command replies or fragments, configure `udp_queue_policy="fifo"` and
a suitable bounded `udp_queue_size`. FIFO preserves local arrival order, not
network delivery or network ordering. The driver still applies sequence rules,
reassembles fragments and validates complete frames before offering them to
`MeterStream` or `XYSeriesStream`. An overflow invalidates assumptions about
completeness; recover according to the protocol rather than silently using a
partial frame. An uncaught overflow in `udp_receive_loop()` is reported and
causes only the UDP supervisor to restart its channel.

## Optional lifecycle extensions

The original lifecycle extensions are published in `1.1.6`. The additional
transport-resilience APIs described below are current source only and are not
in the published `1.1.6` package. Existing requirements and artifacts are unchanged.
See [examples](examples/README.md) for small TCP and TCP+UDP protocol templates.

### Physical Device Online State

The following opt-in policies are available in public `1.1.6`. BLU and Precis
power-off/power-on availability was tested with real hardware and one local
Automator panel on 2026-10-12; this does not validate every future device protocol.

- `feedback_timeout_seconds` (default `0`, disabled): a connection-owned
  watchdog closes and reconnects TCP without valid protocol replies. An open
  socket, junk bytes, successful writes and UDP traffic do not prove TCP is
  usable. The protocol parser calls `mark_feedback_received()` only after
  decoding a valid TCP reply.
- `health_probe_seconds` (default `0`, disabled): independent read-only probes
  via `health_commands()`, serialized through the same bounded sender. This is
  separate from bulk `status_poll_seconds`; a protocol can query one small
  known parameter even with bulk polling disabled. Feedback timeout must exceed
  probe interval. No discovery or identity verification occurs.
- `handshake_timeout_seconds` (default `0`, disabled) optionally bounds the
  whole `handshake()`, separate from protocol login/read-step timeouts.
  Precis opts into 30 seconds and BLU into 10 seconds. Legacy drivers keep
  their existing login behavior.
- `online` is the sole physical-device availability state. Command admission
  no longer inspects `feedback_snapshot` or waits for all parameter replies.
  FIFO/latest-wins, offline queue rejection and automatic reconnection remain.
- The public `FeedbackSnapshot` utility remains importable for compatibility
  with already published drivers; it is not used by Precis/BLU or by command
  admission, and does not impose a readiness requirement on controls.

Precis and BLU use a configurable 5-second read-only probe and 15-second valid
feedback deadline. EOF/errors may detect failure earlier; silent power loss is
not instantly detectable. A device without readable probes or periodic valid
status must not be given a timeout that mistakes legitimate silence for failure.
UDP socket-open is only a local transport condition; realtime frame freshness
is checked separately. TCP recovery re-runs the driver's initialization;
subscription commands remain protocol-owned, not generic vendor rules.

Expose readonly boolean health parameters in `program.json`, with semantic
metadata `.metadata.availabilityRole` equal to `online`. Put it
beside the physical device's controls (inside each same-model slot, not beside
aggregate counts). ControlPanel uses the nearest annotated ancestor, shares
subscriptions and applies disabling automatically. `false` disables only that
physical scope; `true` restores it without a snapshot or per-point gate. No
annotated online parameter means no automatic disabling. Names are arbitrary;
frontend authors write no health code. The driver publishes these through its
existing MUSE adapter; the framework remains independent of `mojo`.

Old drivers without these policies/metadata keep their previous behavior.
Publishing the framework alone does not update existing descriptors or panel
bridges. Use the appropriate published exact requirement, then re-Apply
bindings to generate health subscriptions. Do not overwrite published versions.

Register optional realtime publishers in the client constructor, before start:

```python
self.register_stream("meters", self.meter_stream, publish_meter_frame)
self.register_stream("positions", self.xy_stream, publish_xy_frame)
```

Each registered stream has one publisher. The client owns its task throughout
TCP reconnections and cancels/awaits it when the client stops. Streams are not
closed on stop and can be reused after a restart. Names and stream objects must
be unique within a client; do not share a stream with another consumer or client.
Without registration, the existing manual `run()` / `take_frame()` APIs remain
unchanged. `offer_frame()` still only replaces one latest-frame cache, never
creates a task per sample. Registration does not implement protocol assembly,
subscriptions, source invalidation or epoch resets; those remain in the driver.

Publishers may be synchronous or async, but must not block the owning event
loop. A publisher exception is logged and exposed in
`runtime_stats()["streams"][name]["lastError"]`; TCP and other publishers keep
running. Failed publishers are not automatically retried; restarting the client
lifecycle starts them again. Explicit stream `close()` is terminal, unlike
canceling its publisher task. Callbacks must cooperate with task cancellation.

Override public `async before_disconnect(self)` for optional protocol cleanup
while `self.writer` is still available, including after a failed handshake.
It is bounded by `disconnect_timeout_seconds` (default 1 second); socket closure
has a separate timeout of the same length. Exceptions are logged and the socket
is still closed. The command sender has already stopped: write/drain directly,
not enqueue a command and wait for its reply. The hook must tolerate partial
initialization and cooperate with cancellation. No existing driver needs to
override this hook; existing private cleanup overrides remain compatible.

UDP health distinguishes `udpSocketOpen` from `udpLastDatagramAt` (monotonic
milliseconds), also available as channel `lastDatagramAt`. Socket-open is not
remote-online; even an oversized received datagram updates this raw activity
timestamp. Only the driver can determine whether complete, valid device data is
fresh. Timestamp and counters on a reusable channel persist through restart;
the dual client retains the last timestamp when replacing a channel. The old
`udpOnline` field retains its socket-ready meaning for compatibility. Channel
stats additionally report `queuePolicy` and `overflows`; no background health
poller, discovery service or cross-driver state is added.

### Transport resilience (1.1.7 release preparation)

TCP and UDP keep separate supervisors. A UDP receiver exception or unexpected
supervisor return/cancellation is recorded and retried independently; TCP sender/listener
tasks are not canceled. Transport independence does not remove a protocol's
legitimate dependency on TCP subscription or authentication. BLU retains its
TCP session gate; other drivers can accept UDP independently of `is_online`.

- `udp_hook_timeout_seconds` defaults to 5 seconds and must be finite/positive.
  It bounds each `udp_opened()` / `udp_closed()` hook. Hooks must be async,
  nonblocking and cooperate with cancellation; a timeout cannot preempt blocking
  Python code or a coroutine that suppresses cancellation indefinitely.
- `udp_state_changed()` is synchronous and nonblocking. Callback exceptions
  are recorded without killing UDP supervision or changing physical `online`.
  Callback delivery failure remains an observable error, not a device-offline
  decision. `udpOnline` keeps its historical socket-ready meaning.
- UDP retry delay stays fixed and configurable. Nonfinite delays are rejected;
  a configured zero has a 5 ms minimum scheduling delay to avoid a failure spin.
  This is transport recovery, not UDP packet retransmission or protocol retry.
- `runtime_stats()` additionally exposes `udpSupervisorRunning` and
  `recoveryPending`; stats belong only to this device instance.

Use `await self.enqueue_recovery("subscriptions", commands,
timeout_seconds=1)` from an async protocol hook to request TCP recovery commands.
The key is scoped to this device and only one request with that key may be
waiting, queued or sending. Duplicate callers share the admission result.
Queue admission is bounded by the caller's deadline, preserves ordinary sender
FIFO, and returns false when full/offline. True means local queue admission,
never a hardware ACK or successful re-subscription. Protocols decide how to
respond to rejection; BLU lets the UDP supervisor retry its recovery hook.
Commands are still generated by the protocol, not by the framework.

Cancellation or a TCP session change discards a waiting recovery request.
Disconnect clears queued recovery markers, with no replay into a new session.
Duplicate-caller cancellation does not cancel the owning request. Keys and
queued requests are capacity-bounded; there is no unbounded background retry
queue or cross-driver connection pool. Existing `enqueue()` and latest-wins
control behavior are unchanged. Re-run initialization after a TCP reconnect
according to the protocol; the framework does not invent subscriptions.

These additions were tested using local loopback sockets, fault injection and
50 independent asyncio-loop/thread driver runtimes. They were not published,
installed in Automator, uploaded to MUSE, or retested on physical hardware.
Manager shutdown cancels device-owner tasks first and allows 250 ms for orderly
cleanup before canceling residual tasks. The fallback also handles legacy
receivers that swallow an initial cancellation during a receive timeout race.
New BLU source requires `enqueue_recovery` and refuses to start with old framework
code; publish a new framework version and update its exact requirement before
uploading this source. Published `1.1.6` remains unchanged.

The manager constructor accepts the normalized device list, not the config root:
pass `config[entries_key]` and `config["connection_stagger_seconds"]`. Root
objects, malformed entries and duplicate slots now fail with a useful error
before any client is created. This does not alter existing JSON formats.

## Runtime use in MUSE

Each Script-as-Device program declares the exact framework version in its own
`requirements.txt`:

```text
muse-driver-framework==1.1.4
```

The program imports the package, never a copied local `driver_framework.py`:

```python
from muse_driver_framework import AsyncTcpUdpDeviceClient, AsyncTcpDeviceManager
```

MUSE must have internet access when it installs the requirements file. Keep
the version exact so a framework release cannot silently change an existing
driver.

## Local verification

Build and install the wheel into an isolated environment before testing a
driver. The driver directories should not contain a local framework copy.

To test unpublished source changes, run `python3 -m unittest discover -s tests -v`
here, or the parent project's `local-validation/test-source.sh`. The framework
suite includes line and length-prefixed TCP framing, dual transport failure
isolation, real-socket FIFO overflow and fragment retention, cleanup timeouts,
publisher failure isolation, complete Meter/XY-series frame recovery,
and 50 mixed-protocol runtimes repeated through start/stop. Separate tests host
realtime streams in 50 independent managers and in 50 clients on one loop.
The parent regression runner uses `PYTHONPATH` for source testing only;
MUSE still installs the pinned public
version, not these local edits. Tests and builds do not publish or deploy code.

Numeric commands and configuration timers reject nonfinite values before
clamping or scheduling. Realtime Meter/XY-series descriptors are read-only and
cannot declare both delivery types on one parameter. Driver entry points
should call `manager.stop()` from their normal stop/exit cleanup.

TCP devices use a bounded `StreamReader` limit (`tcp_read_limit_bytes`, default
65536). This protects line-oriented drivers from an unbounded malformed frame;
binary or length-prefixed protocols must override `read_message()` and perform
their own complete-frame validation. A read-limit error is a connection error,
so the normal driver reconnect lifecycle handles it.

Connection stability options are per device and optional. Existing configs keep
the historical fixed reconnect delay by default. A driver may add
`connect_timeout_seconds`, `reconnect_backoff_factor`,
`reconnect_max_delay_seconds`, and `reconnect_jitter_seconds` to reduce a
reconnect storm without changing its static slot mapping. The manager exposes
`runtime_stats()` for low-rate health reporting; it is not a discovery API.

The TCP manager owns one event loop per model-specific driver runtime. A UDP
channel used by that driver must be created, opened, consumed, and closed on
the same loop. Independent driver programs do not share channels or lifecycle
state, even when their static addresses belong to the same LAN.

## Performance and ownership

The control registry compiles path, protocol-channel and feedback-route indexes
once at construction. Treat the descriptor and registered controls as a startup
snapshot; create a new registry after changing their mapping. Lookup results do
not expose mutable internal sets or path lists.

Continuous commands are also coalesced by device/key before crossing into the
TCP event loop, so a burst does not create one scheduled task per intermediate
value. Pending keys per device are bounded by `command_queue_size`; stopping
discards them. FIFO commands are not coalesced, and new continuous keys retain
their scheduling positions relative to FIFO requests.

Meter and XY-series caches own their accepted frames. `snapshot()` and
`take_frame()` capture a complete frame under the producer lock, then copy it
outside the lock; callers still receive independent mutable arrays. This reduces
producer contention without removing validation or changing the wire format.

`run(publish)` includes copying and synchronous/asynchronous publishing time in
the rate-limit period, rather than sleeping an extra full period after publishing.
Slow callbacks still reduce achieved throughput. Missed periods are not replayed,
and idle streams wait instead of spinning. These are limits, not a promise that
every configuration or MUSE host sustains the declared rate.

The parent project's `local-validation/benchmark-driver.py` measures source CPU
costs; `local-validation/stress-driver.py` tests 50 loopback TCP processors and
multiple complete telemetry streams with a mock SDK. Neither tests actual MUSE
SDK transport or physical devices. Unpublished source changes require a new
framework release and updated pinned driver requirements before deployment.
