CodecFork.generateVideoFramesLibav and generateVideoFramesGstreamer are async
generator functions, so their bodies -- including the call_later(10, timeoutExit)
that lets the worker kill itself -- do not run until the caller requests the first
frame. The PluginFork handle is discarded at handout in LibavGenerator and
GstreamerGenerator, so a worker that is handed a session but never iterated has no
watchdog and no reachable terminate(), and idles for the life of the plugin host.
aclose() on a never-started async generator returns without executing the body, so
even a consumer that closes the RPC proxy cannot trigger the self-destruct.
Make the two methods coroutines that return the inner generator. rpc.py does
await maybe_await(invoke(*args)) before serializing, so the async iterator proxy
seen by the caller is unchanged, but the watchdog is armed as soon as the fork is
committed to a session.
Also keep the PluginFork handle at the call sites and terminate it if the session
cannot be handed to the caller.
Fixes#2138
Claude-Session: https://claude.ai/code/session_018kjDzffi8VZKA1mn6nrGfo
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
getNativeId matched cameras on host (IP) as a fallback identity key. IPs
are not stable: when DHCP recycles an address, a newly provisioned camera
resolves to an unrelated camera's nativeId. cleanDict() then overwrites the
original camera's mac/anonymousDeviceId entries, collapsing two cameras onto
one Scrypted device and dropping the other from the device list.
Match only on stable hardware identifiers (mac, anonymousDeviceId, id) and
stop maintaining the host map.
The setting's three choices are Default/Assist/Replace, but the description
text described the "Assist" behavior using the literal word "Filter" instead
of interpolating BUILTIN_MOTION_SENSOR_ASSIST — no such option exists, which
is confusing since this setting controls a real, consequential choice
(whether Scrypted's own motion detection replaces or just verifies the
camera's built-in sensor).
Fixes#2116
Replace the python-sdk-v* tag trigger with workflow_dispatch so the
PyPI publish is kicked off manually. Trusted publishing is unaffected
since it binds to the workflow filename and pypi environment, not the
trigger type.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* python-client: link transitive server modules required by plugin_remote
plugin_remote now imports cluster_labels, cluster_setup, plugin_console,
plugin_pip, and plugin_volume (plus a lazy plugin_repl import), but
packages/python-client only symlinks plugin_remote, rpc, rpc_reader, and
scrypted_python. As a result the client cannot be imported at all:
ModuleNotFoundError: No module named 'cluster_labels'
Add symlinks for the missing modules, matching the existing pattern of
sharing one implementation with server/python.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaSWgBV1XsZxWiYM16sWME
* python-client: extract reusable scrypted_client library from test.py
The only way to connect to Scrypted from Python has been to copy the
bootstrap out of test.py, which is not importable (module-level event
loop) and had drifted from the current rpc_reader/PluginRemote APIs
(writeJSON vs writeSerialized, missing ClusterSetup argument).
Move the transport and connection handshake into an importable
scrypted_client module:
- EioRpcTransport gains a close() method, an optional injectable
aiohttp session for the engine.io connection, and queues its send
loop on the running loop instead of via run_coroutine_threadsafe.
- connect_scrypted_client() performs login, the engine.io connect, and
the getRemote handshake, with a connect timeout and consistent
ScryptedConnectionError on failure. It also attaches the constructed
SystemManager to remote.systemManager, the same wiring loadZip does
for plugins, so PluginRemote.notify can dispatch events to
systemManager.listen() callbacks.
- test.py becomes a small demo of the library and exits cleanly
without os._exit(); the server URL is configurable via
SCRYPTED_BASE_URL.
Verified live against a Scrypted server (device enumeration and OnOff
state reads).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaSWgBV1XsZxWiYM16sWME
* python-client: bound all connect phases and tear down tasks on close
Address review feedback:
- close() now cancels and awaits both the send loop and the peer read
loop (previously the send task was cancelled but never awaited and
the read task was untracked), so closing the event loop after
close() no longer risks 'Task was destroyed but it is pending'
warnings.
- connect_scrypted_client() stores the read-loop task on the transport
and propagates read-loop failures into the pending handshake future,
so a link that dies mid-handshake fails immediately with the
underlying error instead of waiting out the timeout.
- The timeout parameter now bounds every phase: the login POST
(aiohttp ClientTimeout), the engine.io connect (asyncio.wait_for),
and the wait for initial system state.
- Document session ownership: login_session is borrowed and never
closed; an http_session given to EioRpcTransport is owned by the
transport and closed by close().
Verified live against a Scrypted server: clean run with empty stderr,
plus connection-refused and bad-credential paths both raising
ScryptedConnectionError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaSWgBV1XsZxWiYM16sWME
* python-client: lazily import plugin host modules in plugin_remote
A client consuming plugin_remote (for SystemManager, DeviceManager,
MediaManager) only needs the types and the engine.io/rpc bits. The
plugin host modules (cluster_labels, plugin_console, plugin_pip,
plugin_volume) are only used inside loadZipWrapped, so import them
there -- importing plugin_remote no longer requires them, and the
client directory drops those symlinks (plugin_repl was already a lazy
import). cluster_setup stays: the client bootstrap constructs a
ClusterSetup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* python-client: package as scrypted-sdk for PyPI
Adds a pyproject.toml and a hatchling build hook that generates the
scrypted_sdk package at build time: it copies the client-required
modules (client.py, rpc, rpc_reader, cluster_setup, plugin_remote, and
the scrypted_python types -- reading through the symlinks into
server/python and sdk/types) and mechanically rewrites the flat imports
to package-qualified ones (import rpc -> from scrypted_sdk import rpc),
so nothing is installed into consumers' top-level namespace. No runtime
code is modified and nothing generated is committed.
A python-sdk-v* tag builds, smoke-tests, and publishes to the existing
scrypted-sdk PyPI project via trusted publishing; the same workflow
builds and smoke-tests on PRs that touch the shared Python sources.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* python-client: add examples/light.py mirroring the typescript client example
The Python equivalent of packages/client/examples/light.ts: connect,
look up a light by name, turn it on, wait, turn it off, disconnect.
Works both with the installed scrypted-sdk package and directly from a
repo checkout (falls back to the flat modules in the parent directory).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* python-client: link transitive server modules required by plugin_remote
plugin_remote now imports cluster_labels, cluster_setup, plugin_console,
plugin_pip, and plugin_volume (plus a lazy plugin_repl import), but
packages/python-client only symlinks plugin_remote, rpc, rpc_reader, and
scrypted_python. As a result the client cannot be imported at all:
ModuleNotFoundError: No module named 'cluster_labels'
Add symlinks for the missing modules, matching the existing pattern of
sharing one implementation with server/python.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaSWgBV1XsZxWiYM16sWME
* python-client: extract reusable scrypted_client library from test.py
The only way to connect to Scrypted from Python has been to copy the
bootstrap out of test.py, which is not importable (module-level event
loop) and had drifted from the current rpc_reader/PluginRemote APIs
(writeJSON vs writeSerialized, missing ClusterSetup argument).
Move the transport and connection handshake into an importable
scrypted_client module:
- EioRpcTransport gains a close() method, an optional injectable
aiohttp session for the engine.io connection, and queues its send
loop on the running loop instead of via run_coroutine_threadsafe.
- connect_scrypted_client() performs login, the engine.io connect, and
the getRemote handshake, with a connect timeout and consistent
ScryptedConnectionError on failure. It also attaches the constructed
SystemManager to remote.systemManager, the same wiring loadZip does
for plugins, so PluginRemote.notify can dispatch events to
systemManager.listen() callbacks.
- test.py becomes a small demo of the library and exits cleanly
without os._exit(); the server URL is configurable via
SCRYPTED_BASE_URL.
Verified live against a Scrypted server (device enumeration and OnOff
state reads).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaSWgBV1XsZxWiYM16sWME
* python-client: bound all connect phases and tear down tasks on close
Address review feedback:
- close() now cancels and awaits both the send loop and the peer read
loop (previously the send task was cancelled but never awaited and
the read task was untracked), so closing the event loop after
close() no longer risks 'Task was destroyed but it is pending'
warnings.
- connect_scrypted_client() stores the read-loop task on the transport
and propagates read-loop failures into the pending handshake future,
so a link that dies mid-handshake fails immediately with the
underlying error instead of waiting out the timeout.
- The timeout parameter now bounds every phase: the login POST
(aiohttp ClientTimeout), the engine.io connect (asyncio.wait_for),
and the wait for initial system state.
- Document session ownership: login_session is borrowed and never
closed; an http_session given to EioRpcTransport is owned by the
transport and closed by close().
Verified live against a Scrypted server: clean run with empty stderr,
plus connection-refused and bad-credential paths both raising
ScryptedConnectionError.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaSWgBV1XsZxWiYM16sWME
* python-client: lazily import plugin host modules in plugin_remote
A client consuming plugin_remote (for SystemManager, DeviceManager,
MediaManager) only needs the types and the engine.io/rpc bits. The
plugin host modules (cluster_labels, plugin_console, plugin_pip,
plugin_volume) are only used inside loadZipWrapped, so import them
there -- importing plugin_remote no longer requires them, and the
client directory drops those symlinks (plugin_repl was already a lazy
import). cluster_setup stays: the client bootstrap constructs a
ClusterSetup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
test.py has drifted from the server code it exercises: RpcTransport
implementations must provide writeSerialized (rpc.py no longer calls
writeJSON), and PluginRemote.__init__ now takes a ClusterSetup as its
first argument instead of the peer. Verified against a live scrypted
server (connects and enumerates devices).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The python port of plugin-remote's notify() updates systemState but the
dispatch to systemManager.events was left as a commented-out TODO, so
systemManager.listen() callbacks never fire in python plugins/clients
(plugin-remote.ts dispatches via notifyEventDetails here). Port the
current typescript semantics: plain property changes update state and
notify listeners with the unwrapped value, everything else passes
through raw, and mixin property events no longer write device state.
Also fix EventRegistry.listenDevice to register callbacks in the
token-keyed listener set (it raised KeyError on the device id key,
diverging from event-registry.ts) so watch-mode device listeners work.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(alexa): v3 API compliance fixes, fan speed, and security panel control
Bring the Alexa Smart Home integration in line with the current v3 API.
Tier 1 (malformed responses / missing handlers):
- BrightnessController: report brightness under the Alexa.BrightnessController
namespace instead of Alexa.PowerController so Set/AdjustBrightness responses
match the advertised capability.
- SmartVision ObjectDetection: flatten the doubly-nested payload.events array.
- SecurityPanelController: add Arm/Disarm directive handlers (previously
advertised but unhandled, silently falling through to a fake success), wired
to armSecuritySystem/disarmSecuritySystem. Enforce the spec rule rejecting a
direct ARMED_AWAY -> other armed-state transition with AUTHORIZATION_REQUIRED.
Drop the advertised FOUR_DIGIT_PIN authorization, which Scrypted cannot honor.
- Auth failures now return an Alexa INVALID_AUTHORIZATION_CREDENTIAL
ErrorResponse instead of a bare HTTP 500 that Alexa retries and surfaces as a
generic skill error.
API modernization:
- EndpointHealth: downgrade to the documented v3.1 / connectivity-only shape and
stop emitting the undocumented battery property (and 3.2 version), which risks
the whole capability being rejected.
- Fan: add Alexa.RangeController (Fan.Speed, 0-100%) for devices implementing the
Fan interface, alongside the existing PowerController on/off.
Cleanups:
- addAccessToken: initialize event.endpoint as an object, not an array.
- Hoist getArmState to a shared export instead of duplicating it.
Bump version to 0.4.0.
* fix(alexa): improve camera stream quality and connectivity
Tune the WebRTC negotiation for Alexa camera sessions.
- Resolution: raise the proxied stream cap from 720p to 1080p. The previous
1280x720 screen hint forced the transcoder into medium-resolution mode for
every endpoint, so even an Echo Show 15 or Fire TV only received 720p. 1080p
lets larger displays render sharply while the transcoder still clamps width
and falls back to 720p when H.264 High can't be negotiated. We cap rather
than send the uncapped source, since Alexa's directive carries no display
hint and a 4K stream would waste bandwidth on smaller Echo devices.
- Two-way audio: only advertise isFullDuplexAudioSupported when the camera
implements the Intercom interface. Advertising full duplex on a one-way
camera makes Alexa set up a return mic path that goes nowhere.
- TURN: stop unconditionally disabling TURN, and expose a "Use TURN Servers"
plugin setting (on by default). Alexa sessions are proxied and often cross
NATs where a TURN relay is the only path that connects; combined with
disabled trickle ICE (all candidates in the initial SDP), omitting the relay
candidate left NAT-blocked sessions with no fallback. When enabled, TURN
usage defers to the WebRTC plugin's own setting, matching the Google Home
integration; disabling it force-disables TURN for Alexa sessions only.
Bump version to 0.5.0.
The two-way audio talkback loopback (RtspServer over listenZeroSingleClient)
only supports TCP interleaved transport. The talkback ffmpeg input was built
without -rtsp_transport, so ffmpeg attempts a UDP SETUP first and the loopback
responds 461 Unsupported Transport.
The equivalent webrtc talkback path (plugins/webrtc/src/session-control.ts)
has always specified -rtsp_transport tcp; the homekit path did not. Align it.
Before: talkback was silent and the log showed "method SETUP failed: 461
Unsupported Transport". After: the 461 is gone and talkback audio works.