Metadata-Version: 2.4
Name: weiguo-adaptive-driver
Version: 1.0.0
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
Provides-Extra: websocket
Requires-Dist: websockets==15.0.1; extra == "websocket"
Provides-Extra: mqtt
Requires-Dist: aiomqtt==2.4.0; extra == "mqtt"
Requires-Dist: paho-mqtt==2.1.0; extra == "mqtt"
Requires-Dist: typing-extensions<5,>=4.4; python_version < "3.11" and extra == "mqtt"
Provides-Extra: http
Requires-Dist: httpx<0.29,>=0.28.1; extra == "http"
Provides-Extra: telnet
Requires-Dist: telnetlib3<5.1,>=5.0.1; extra == "telnet"
Provides-Extra: ssh
Requires-Dist: asyncssh<2.22,>=2.21.1; python_version < "3.10" and extra == "ssh"
Requires-Dist: asyncssh<2.25,>=2.24.1; python_version >= "3.10" and extra == "ssh"
Requires-Dist: cryptography<49,>=48.0.1; extra == "ssh"

# weiguo-adaptive-driver

Current release: **`weiguo-adaptive-driver==1.0.0`**. This is the new package and
Python namespace for the maintained framework, based on the previous
1.3.1.dev1 source. Use `from weiguo_adaptive_driver import ...` throughout.
The old `muse-driver-framework` distribution and `muse_driver_framework` import
are retired for current source; no import alias is shipped.

```sh
python -m pip install weiguo-adaptive-driver==1.0.0
```

Select `[http]`, `[websocket]`, `[mqtt]`, `[telnet]` or `[ssh]` only when needed.
The base package remains pure Python with no mandatory third-party dependency.
Four maintained driver sources and all seven templates pin the new release;
existing MUSE programs change only after a separate full upload. No new control
queue, discovery or deployment mechanism is introduced by this rename.

High-load MUSE capacity limits remain as recorded in POST_STRESS_ACCEPTANCE.md;
this release does not claim that the 35-device/four-panel 10 Hz target passed.
The older numbered feature/acceptance sections below describe the previous
package's development history; installation examples use the new package.

See [UDP shared-port design and acceptance](UDP_SHARED_ACCEPTANCE.md) for the
configuration, architecture boundaries, protocol references and simulation scope.
Pure UDP and TCP+UDP clients automatically share identical fixed local binds;
there is no shared/exclusive configuration switch. Port `0` creates an independent
OS-assigned socket per channel; different fixed binds also use separate sockets.
Their existing `create_udp_channel()` / `receive()` / `send()` interface remains.
A socket routes by resolved source IP+port, or by fixed IP when
`udp_strict_peer_port` is false. Ambiguous overlapping source routes are rejected.
Distinct managers and programs do not share listeners. Direct `AsyncUdpChannel`
construction owns a private broker, unless explicitly given `broker=...`; driver
overrides use `super().create_udp_channel()` or `manager.udp_channel(...)` to join
their manager's broker. Per-device FIFO/latest buffers and lifecycle stay separate.

PyPI distributes only the framework library and its build/documentation metadata.
The five stable and two candidate driver templates, protocol recipes and tests stay in the development
checkout; they are not included in either the wheel or source distribution.
Copy a template locally, then let its `requirements.txt` declare which framework
version MUSE should install. Templates are not synchronized from PyPI.

## Raw TCP TLS (1.3.0)

Use the existing `AsyncTcpDeviceClient` / `AsyncTcpDeviceManager` with `tls: true`.
Default `false` preserves plaintext TCP. This option supports outbound
`tcp_mode: "client"` only; server mode with TLS is rejected explicitly.
TLS uses Python's standard library, without a new extra or template.

`tls_ca_file` / `tls_ca_data` configure private trust; `tls_cert_file` /
`tls_key_file` optionally present a client certificate. Relative file paths resolve
from `config.json`. `tls_server_hostname` defaults to `host`; set the certificate's
DNS name when connecting to an IP endpoint whose certificate identifies that name.
Certificate and hostname verification remain enabled. The connection deadline
includes the TLS handshake; failures do not fall back to plaintext.

Framing, application login, FIFO/latest, ACK, watchdog and reconnect still use the
existing TCP protocol/lifecycle. Runtime statistics report `tcpTls` and the active
`tlsVersion`; TLS itself does not prove application state or device execution.
The TCP part of `AsyncTcpUdpDeviceClient` can use the same option; its UDP channel
remains unchanged. Telnet/SSH, STARTTLS and a TLS server are separate protocols or
modes and are outside this addition.

See [TCP TLS design and acceptance](TCP_TLS_ACCEPTANCE.md) and the existing
`examples/scoped-device/config.tls.example.json`. Stable template requirements
remain 1.2.0; adopting TLS requires the exact 1.3.0 dependency after this release
is published. No current business driver is upgraded.

## P2 message transports (1.3.0)

`AsyncWebSocketDeviceClient` / `AsyncWebSocketDeviceManager` preserve complete
text/binary messages with optional `[websocket]` dependencies. Protocol handshake
owns receive until authenticated; then the listener starts before initial RPC
queries. Paths, headers, subprotocols and verified WSS CA/client certificates are
configurable; Ping/Pong alone never marks a physical device online.

`AsyncMqttDeviceClient` / `AsyncMqttDeviceManager` separate logical devices from
one `MqttBrokerSession` per manager. A `MqttTopicRouter` shares subscriptions by
reference count and routes to bounded per-device FIFOs. `[mqtt]` is optional.
MQTT 3.1.1, QoS 0/1/2 and retained metadata are supported; MQTT 5 and cross-process
sharing aren't included. Transport PUBACK/PUBCOMP never substitutes for a device
action ACK. A new clean session and protocol-owned reads rebuild real state;
pending writes aren't replayed after session loss.

The seven local templates include the new candidate `websocket-device` (Shelly
WS plus optional OBS/HA recipes) and `mqtt-device` (Tasmota plus Shelly RPC).
These two require the 1.3.0 message transports and their named extra;
the original five and installed drivers keep public `1.2.0` pins. Read each
candidate template's Chinese integration guide before editing protocol hooks.
The actual WS/TLS sockets and Aedes MQTT broker tests model documented product
subsets, not vendor firmware certification. See [P2 acceptance](P2_MESSAGE_ACCEPTANCE.md).

## Script-as-Device workflow

Maintain one complete local `program.json` and upload the whole driver folder
with the native VSCode MUSE Plugin. No descriptor-generation step or second
`device-descriptor.json` is needed. Each driver entry point exports its registered
descriptor to memory through `context.export.exporter.exportDescriptor()`;
it does not read the controller-managed `program.json` at runtime. This SDK glue
stays outside the transport library. The workflow was verified on MUSE 1.15.45.

This entry-point change does not require a new framework or panel release.
Existing drivers must receive updated entry-point source through a full driver
upload; changing the installed framework alone cannot update their Python files.
See the development checkout's `Script-as-Device开发与上传流程.md` for the current
upload, testing and deployment checklist. Keep site configuration private.

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

- asynchronous TCP connection lifecycle and reconnect handling;
- pure UDP clients/managers with protocol-validated online state;
- optional asynchronous HTTP/HTTPS request/response clients and managers;
- explicit optional Telnet/SSH byte streams reusing the TCP lifecycle and queues;
- 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.

TCP/UDP have no third-party runtime dependencies and support Python 3.9+.
HTTP devices opt into `weiguo-adaptive-driver[http]` (HTTPX 0.28.x); importing
the framework or using TCP/UDP does not import or require HTTPX.
Telnet and SSH similarly opt into `[telnet]` / `[ssh]`; raw TCP/UDP do not load
telnetlib3, AsyncSSH or cryptography.

## Telnet/SSH and gateway templates (1.2.0)

`AsyncTelnetDeviceClient` uses telnetlib3 for ongoing negotiation and preserves
application bytes/whitespace after removing the Telnet line ending. It uses the
normal TCP manager. Application login prompts belong in the driver's handshake.

`AsyncSshDeviceClient` uses AsyncSSH for an explicit shell/command byte channel.
`AsyncSshDeviceManager.load_config` supports password or key authentication and
resolves `ssh_known_hosts` / `ssh_client_keys` relative to the config file.
Host-key checking remains enabled. Optional `ssh_term_type` / `ssh_command`
select the device's terminal or command; no session protocol is auto-detected.
The owned SSH connection closes before `session_ended()`.

TCP-based public online follows handshake and the explicit initial command
group. A required initialization ACK which is rejected therefore cannot briefly
publish online. Protocols without initial ACKs retain session-ready semantics;
there is no new requirement to wait for every point's feedback.

Python 3.9 uses AsyncSSH 2.21.x; Python 3.10+ uses 2.24.x. SSH also requires the
tested cryptography 48.x series; Telnet uses telnetlib3 5.0.x. These are optional
dependencies, with loopback validation separate from MUSE platform validation.
See `examples/scoped-device/session_examples.py` and its session guide.

`examples/gateway-device` is a fifth complete template: one static endpoint,
multiple configured object IDs, exact descriptor subscopes and a shared queue.
See `examples/协议选择与配方指南.md` for framing, UDP topology, HTTP method semantics
and action/absolute-target choices. No panel contract changes are required.

## HTTP/HTTPS devices (1.2.0)

`AsyncHttpDeviceClient` and `AsyncHttpDeviceManager` share the existing command
queues and descriptor feedback contract. Configure a fixed `base_url`, optional
`http_auth` (`none`/`basic`/`digest`), credentials, `http_headers`, and an optional
`http_ca_file`. HTTPS validates certificates normally; relative CA paths in a
loaded config are relative to that file. Override `create_http_client()` for a
device-specific SSLContext or authentication scheme.

`HttpRequest(method, path, query=..., headers=..., body=...)` preserves the
protocol's raw body; `HttpRequest.json(...)` encodes JSON. Paths are relative to
the configured base URL, including its path prefix. `HttpResponse` exposes the
request, status, lowercase headers and raw body, plus `json()` and `text()`.
The driver validates HTTP status and business content, then calls
`mark_feedback_received()`. An HTTP 200 or local client creation never marks
online. Initial read-only queries and polling can establish online without a
pre-existing online state. `request_timeout_seconds` is a total deadline,
including a Digest exchange. Failed writes are not replayed; local sessions
discard queued writes on failure. Session cleanup uses `session_ended()`.

See `examples/http-device/` for a complete copyable driver and simulated-device
tests. These APIs are included in 1.2.0, not the previously published 1.1.9
package. Test source with `pip install -e '.[http]'`, or install
the published `weiguo-adaptive-driver[http]==1.0.0`.

## Runtime boundaries

For byte-preserving TCP framing, override `read_message()` with
`read_delimited(self.reader, b'\0')` (also LF/CR/CRLF) or
`read_fixed(self.reader, frame_size)`. They return bytes, do not decode/strip,
and return None only at a frame boundary EOF. Incomplete frames raise
`asyncio.IncompleteReadError`. The legacy default line decoder is unchanged.
`examples/scoped-device/framing_examples.py` also demonstrates a length header
without adding vendor-specific length fields to the framework.

Override `control_send_policy(control)` to select `fifo` or `latest` by operation
semantics. Defaults preserve the old mapping; relative actions or confirmed
numeric values can use FIFO, while absolute targets use latest. ACK still
requires FIFO. No new panel metadata is required. See `send_policy_examples.py`.
FIFO starts at driver admission: the panel also coalesces continuous inputs.
Use a discrete action/enum or per-action Command/API input for relative moves,
not a continuous slider whose intermediate values may already be merged.
`completion_ack_example.py` in each TCP/UDP template demonstrates a single
in-flight command without request IDs, distinguishing ACK from completion while
unsolicited feedback continues. Choose the confirmation point for the protocol;
neither ACK nor completion fabricates actual point values.

`examples/tcp-udp-device/subscription_example.py` uses the existing initialization
query and UDP-open hooks to register a UDP destination after TCP initialization
and UDP bind, whichever completes first. It re-registers on TCP reconnect or UDP
port change without imposing that relationship on independent UDP notifications.
The `udpOnline` runtime statistic means the UDP channel opened, not evidence
that remote feedback is current; use `udpSocketOpen` for socket diagnostics.
The device's public online state remains protocol-specific. This is not an
Art-Net output scheduler or an SSE client. WebSocket uses the separate optional
message transport described above, not this TCP/UDP adapter.

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 usable by pure UDP devices or beside
a 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.

## Pure UDP devices (introduced in 1.1.9)

`AsyncUdpDeviceClient` and `AsyncUdpDeviceManager` provide a complete pure UDP
runtime without opening TCP connections. They share the existing bounded command
queue, FIFO/latest control routing, ACK wait hooks, stream ownership, thread and
cross-thread scheduler with TCP; existing TCP classes keep their public names
and connection behavior. The current source version is 1.2.0; publishing and
target installation are separate tasks. All five complete templates pin 1.2.0.

Copy [examples/udp-device](examples/udp-device/README.md) for a complete MUSE
entry point, descriptor, config and independently testable protocol. Implement
`handle_feedback(bytes)` and call `mark_feedback_received()` only after validating
the reply. An open socket or successful send never marks UDP online. Ordinary
controls are rejected while offline; initialization queries, polling and health
probes can discover restored feedback. There is no complete-snapshot gate.

The pure UDP manager defaults to FIFO reception (100 datagrams), a 15-second
valid-feedback timeout, no periodic probe/poll, and status queries on open.
Configure protocol-appropriate probes and timeouts; a device with no query or
unsolicited reply cannot prove online by opening UDP. Timeout/socket error/FIFO
overflow closes the session, publishes offline, clears stale queued commands and
reopens the fixed endpoint. Invalid input reported by the parser as `ValueError`
or `UnicodeError` is logged and ignored without refreshing the feedback deadline.
Programming errors reach the supervisor. No automatic retransmission is added.

`host`/`port` is the fixed command destination. `udp_local_host`/`udp_local_port`
is the local bind (port 0 selects an ephemeral port); configure the actual port
for stations sending unsolicited feedback to a fixed destination. Conflicting
enabled local binds are rejected. No discovery or runtime rebinding is added.

Strict source IP+port matching remains the default. For documented protocols
replying from another source port, pure UDP config may explicitly set
`udp_strict_peer_port: false` (channel API: `strict_peer_port=False`). It still
filters by the configured peer IP and sends to the configured destination port.
This does not authenticate packets. Source resolution is part of the bounded
open operation. The low-level channel still defaults to latest reception for
backward compatibility; the pure UDP manager chooses FIFO explicitly.

Protocol code owns subscriptions, framing, ACK correlation, idempotence,
retries and state resynchronization. Delayed bare ACKs cannot be disambiguated
generically. `before_disconnect()` runs after session tasks stop and must not
wait on their receiver. Registered complete-frame publishers live across UDP
session restarts and stop with the client, as they do for TCP.

## Shared runtime contracts (introduced in 1.1.9, included in 1.2.0)

These additions are transport-neutral. TCP, pure UDP and the TCP control side
of TCP+UDP use the same contracts; protocol matching/retries/identity and host
SDK calls remain outside the framework. The templates pin 1.2.0; existing device
drivers retain their individual exact pins until explicitly upgraded.

- `async prepare_command_ack(command)`: for `(delay, command, True)` steps,
  called after the delay and before send. Allocate/reset the protocol waiter
  here; `wait_for_command_ack(command)` still awaits it after send. Default
  prepare is empty for compatibility. A prepare error prevents sending. A
  send/wait error or cancellation ends the session; do final cleanup below.
  Ordinary no-ACK steps do not call prepare. Keep `encode_command()` a codec.
- `async session_ended()`: called once after **each entered control-transport
  attempt**, including failed open/handshake, even without a socket or an online
  transition. Receiver/sender tasks are stopped, sockets closed, stale queues
  cleared first. No I/O is available. Bounded by `disconnect_timeout_seconds`;
  exceptions/timeouts are logged, default is empty. Must tolerate partial
  initialization and cooperate with cancellation. `before_disconnect()` remains
  a separate, resource-available hook for sending an unsubscribe/close message.
  Neither hook can wait on the stopped receiver. A first stagger delay cancelled
  before any open attempt does not invoke the per-session hook. Independent
  mixed-mode UDP telemetry still uses `udp_closed()`; its restarts never end the
  TCP control session or trigger `session_ended()`.
- `manager.submit(client, steps) -> concurrent.futures.Future[bool]`: True means
  actual FIFO admission, False means offline/full/stopped/not running; enqueue
  errors appear as Future exceptions. This is not send/ACK/device execution.
  Use a thread-safe, nonblocking done callback or await `asyncio.wrap_future()`;
  never block the owner loop on `.result()`. A callback may execute immediately
  on the submitting thread if the Future is already complete. Cancellation does
  not retract commands already admitted; do not mutate submitted commands.
  Existing `schedule()` keeps its scheduling-only bool. Continuous coalescing
  remains `schedule_latest()`; submit does not silently turn text into latest.
- String/dstring descriptor controls infer `text` and now use discrete FIFO,
  including optional ACK. Numeric/boolean/stream behavior is unchanged.

TCP handshake retains exclusive access to its reader for legacy login code.
The ordinary listener starts **after handshake, before startup queries**, so
ACK-requiring status initialization can receive replies. A handshake needing a
reply must read it itself; there is no background listener during TCP handshake.
For mixed clients, `udp_strict_peer_port`, `udp_max_send_buffer_bytes` and
`udp_open_timeout_seconds` are forwarded to the independent UDP channel. Defaults
remain strict IP+port, 65536 bytes and 10 seconds; telemetry defaults to latest,
pure UDP defaults to FIFO. Use FIFO explicitly for fragments/events/replies.
TCP online semantics are preserved; no new first-feedback gate is imposed on
existing drivers. A UDP telemetry packet never proves TCP control liveness.

## Optional lifecycle extensions

The original lifecycle extensions were published in `1.1.6`. The 1.1.8 source
candidate introduced the additional transport-resilience and host-adapter APIs
described below; all are included in 1.2.0. Publication and target-runtime
installation are separate steps. Older artifacts are unchanged.
See [examples](examples/README.md) for TCP, pure UDP, TCP+UDP and HTTP templates.

The original five copyable directories include the MUSE entry point, descriptor,
configuration example, protocol module and minimal loopback tests. The TCP/UDP
directories have optional ACK recipes; HTTP uses its request/response flow.
The mixed template uses ordinary TCP controls plus a UDP readonly indicator;
no complete template requires sibling example modules.

TCP configuration slots and ports accept integers or integer strings. Decimal
JSON numbers and booleans are rejected instead of silently truncated. Zero
retains the existing local-port/default-remote-port semantics.

### 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 (included in 1.1.8)

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. The current release
preparation does not install into Automator, upload to MUSE or retest 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. Its exact requirement has now been explicitly upgraded to the published
1.2.0. Previously published versions remain unchanged; this documentation update
does not rebuild or replace the published 1.2.0 artifacts.

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
weiguo-adaptive-driver==1.0.0
```

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

```python
from weiguo_adaptive_driver import AsyncTcpUdpDeviceClient, AsyncTcpDeviceManager
```

MUSE must install that exact dependency, through the public index when reachable
or a matching prepared offline wheel bundle. 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 `sh ../local-validation/test-source.sh`. First install
`requirements-dev.txt` in the testing environment to exercise HTTP as well;
without HTTPX, the optional HTTP I/O tests are explicitly skipped. 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.

## 1.1.8: Explicit Feedback Scopes

Version 1.1.8 introduces an intentional API migration beyond published 1.1.7.
`ProgramControlRegistry.paths_for(slot, type, channel)` is removed. No Precis-
specific compatibility entry is retained. The current templates pin 1.2.0.
Publish its prepared artifacts before uploading these templates; editing source
does not publish a package. Previously published versions remain unchanged.

```python
registry = ProgramControlRegistry(program_descriptor)
device = registry.for_scope("/rack/units/0")
paths = device.paths_for("slider", 7)
channels = device.protocol_channels({"slider", "toggle"})
```

Scopes are explicit descriptor container paths, not MUSE network scopes. `/`
means the entire descriptor (appropriate for a flat single-device driver).
Arrays use zero-based indexes; arbitrary nested objects and device names work.
Unknown containers fail with ValueError rather than silently selecting another
device. Queries and feedback aliases are indexed once at initialization; all
aliases with the same type/channel inside a scope are returned. The root view
intentionally spans all devices; choose a single-device subtree for isolation.
Shared protocol numbers across device scopes are allowed. If a protocol reuses
numbers inside a device (for example across banks), select that bank's subtree
or implement its compound address mapping in the driver. No discovery or
automatic device-boundary inference is performed.

`DescriptorFeedbackPublisher(registry, update)` injects a synchronous host
callback without importing `mojo`. `publish(path, value)` resolves a declared
ordinary parameter and converts its descriptor path to the host export path;
`publish_route(scope, type, channel, value)` fans out to matching aliases and
returns their count (zero for unknown protocol points). The driver owns value
decoding and range conversion. No optimistic command echo, deduplication, new
queue, or readiness gate is added. Callback exceptions propagate to the caller;
callbacks run on the caller's thread and must not block. This does not prove SDK
thread marshalling. Meter/XY-series scalar publishing is rejected: use their
complete latest-frame APIs instead. Their epochs, sequence rules and rate limits
are unchanged.

Precis selects `/processors/<slot>` explicitly; the independent BLU-806 uses `/`
for its flat matrix controls and keeps protocol-specific object/SVID mapping.
Meter Validation remains transport-free synthetic telemetry and needs no fake
TCP client. Its ordinary status/manifest parameters now use the host adapter;
its complete real-time frames still use stream events. None of these use a
native HiQnet Provider.

Raw TCP input/output with `traffic_debug` now uses the shared `DriverLogger` at
DEBUG instead of INFO; connection changes remain INFO, errors remain WARN/ERROR.
Explicit raw feedback events still follow the existing `traffic_debug` opt-in
behavior. UDP parsers can use the same logger; no per-datagram log is mandatory.
Never enable raw logging around credentials without reviewing protocol payloads.

The copyable `examples/scoped-device/` separates a thin MUSE entry point from an
SDK-independent invented protocol and includes descriptor/config examples. Its
tests use two loopback simulators, not physical hardware. Existing TCP/UDP
examples remain useful as protocol illustrations; they are not universal drivers.

## 1.1.8: Host Adapter, Logging and Development Types

`weiguo_adaptive_driver.muse.MuseFeedbackAdapter` is a separate MUSE adaptation
layer. Inject `context.export`; the module itself never imports `mojo`. The
protocol layer can continue to use an ordinary `DescriptorFeedbackPublisher`
with any synchronous callback. Precis, BLU-806 and Meter Validation now route
ordinary parameters through the adapter instead of calling `export.update`
throughout their protocol code. Native event dispatch and complete telemetry
frames remain separate; neither goes through scalar publishing.

```python
feedback = MuseFeedbackAdapter(registry, context.export)
feedback.publish("/units/0/volume", decoded_volume)
feedback.publish_many({"/units/0/name": device_name})
feedback.publish_online(("/units/0/online",), client.is_online)
```

The driver supplies exact paths; the adapter does not guess device boundaries,
choose another slot, infer power/network failures or wait for complete feedback.
`publish_online` requires boolean values and declared boolean target parameters.
`publish_many` preflights all paths before any host call, but is not an atomic
host transaction: callback failure can leave preceding fields published.
Repeated confirmed values are always forwarded. No framework value cache or
deduplication is introduced; protocol-specific decoded state stays in the driver.
Declared async callbacks are rejected. All ordinary callbacks are synchronous,
run on the caller's thread, and must be nonblocking. SDK thread behavior still
requires target-runtime validation.

`DriverLogger.wrap(host_log)` accepts MUSE-style `warn` or standard `warning`.
It is idempotent. TRACE falls back only to DEBUG, and DEBUG only to TRACE; if
neither exists, raw logs are omitted, never promoted to INFO. `raw_enabled`
reports method availability, not the host's active level filter. ERROR falls
back to WARN only when ERROR is absent. INFO plus WARN/WARNING are required.
The adapter does not create another log queue or retain payloads. Explicit raw
feedback events remain opt-in and are not automatically redacted.

Development contracts include `Logger`, `HostExport`, `FeedbackUpdate`,
`FramePublisher`, and `CommandStep`. Public descriptor/scope, core TCP
entry points, UDP I/O and stream APIs have annotations, and `py.typed` is package
data. Command sequences retain both two-item steps and three-item ACK steps.
Raw JSON descriptors/configuration and frames retain dynamic fields; protocol
validation is still necessary. This is not a claim that all legacy transport
internals are strict-typed.

From this framework directory, use a development virtual environment:

```sh
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-dev.txt
.venv/bin/python ../local-validation/typecheck-driver.py
```

The checker strictly checks contracts, logger, feedback publisher, MUSE adapter,
the minimal example protocol and a positive API fixture. It also requires four
intentional API mistakes to fail. Existing transport modules expose their new
annotations to callers but their internal type errors are excluded explicitly
in the configuration. No MUSE SDK stubs are copied into runtime drivers.
To include type checking in the full source runner, set `MUSE_TYPECHECK=1` and
`PYTHON` to that development environment's Python.

These APIs originated in 1.1.8 and are included in 1.2.0. The current release is
prepared locally; publication is a separate operation. All five templates pin
1.2.0. Publish that version before deploying a template that requires it.

## 可选的设备间隔与正常拒绝

`config.example.json` 的 `min_command_interval_seconds` 默认 0；按设备手册填写。
它约束该客户端受管命令之间的最小空隙：TCP/UDP 从本地发送完成计时，HTTP 从收到响应后计时；ACK 等待和步骤 delay 会抵扣剩余时间。
`latest_command_interval_seconds` 仍只是同一连续控件的更新间隔。初始化查询经过发送组；在 handshake 里直接写 writer 的报文由协议自行安排。

`CommandRejected` 表示设备合法拒绝当前命令。发送队列会停止本组后续步骤，继续下一组，保留会话，不重发或伪造目标值。
HTTP 解析可以直接抛出它；TCP/UDP 接收器应完成对应 ACK Future 的异常，由 sender 的等待函数抛出。
首次初始化失败仍沿初始化失败处理；它不是入队结果或执行成功回调。参见 ACK/Completion 配方。


## 1.3.1.dev1 post-stress prerelease

The source candidate adds `control_send_key(control)` as the queue-identity hook.
Its default preserves protocol-channel aliases. The gateway template prefixes the
logical object address, so two objects can both use slider/channel 1 on one
socket without overwriting each other's target.

`MuseFeedbackAdapter(..., deduplicate=True)` opts into changed ordinary-state
exports. Explicit `feedbackIntervalSeconds` parameter metadata additionally
coalesces trailing state on the owning asyncio loop. A forced actual feedback
publication can use `publish(path, value, force=True)`. Protocol ACK/events and
health still process every valid reply. `DescriptorFeedbackPublisher` and legacy
adapter callers retain repeated delivery. `bind_scope(scope, online_path)` resets
only that device's export cache on offline; `close()` discards delayed old state.
No polling, command echo or per-device publishing thread is introduced.

The seven source templates now select this public prerelease and include
lifecycle wiring. Existing business driver pins remain unchanged. See
[template feedback instructions](examples/反馈导出说明.md) and
[post-stress evidence](POST_STRESS_ACCEPTANCE.md). Do not treat source tests or
local replay as successful MUSE installation of the candidate.

MQTT on Python 3.10: the `[mqtt]` extra explicitly supplies `typing-extensions`
for Python <3.11. aiomqtt 2.4.0 imports `Self` from that package on 3.10, while
its upstream wheel only declares the dependency for Python <3.10. A clean MUSE
3.10 installation exposed this packaging gap; the fix does not add a base dependency.
