@ecobridge.xyz/devicemanager

A comprehensive, TypeScript-first device manager for discovering and communicating with network devices. 🔌

npm version License: MIT

🎯 Overview

@ecobridge.xyz/devicemanager provides a unified, object-oriented API for discovering and modeling network devices, with live communication where protocol implementations are available. Whether you're building a document scanning workflow, managing printers, controlling smart home devices, or monitoring UPS systems — this library has you covered.

Supported Device Types:

  • 🖨️ Scanners — Brother native network scanning, eSCL (AirScan), and SANE
  • 📄 Printers — IPP/AirPrint discovery, capabilities, jobs, cancellation, and print execution; JetDirect/raw port detection
  • 📁 SMB Shares — Host local folders through smartsamba and emit settled document arrivals through RxJS
  • 🧊 3D Printers — Bambu Lab (LAN Only Mode + Developer Mode), Klipper via Moonraker, OctoPrint and PrusaLink: discovery, live status, upload and print, pause/resume/stop
  • 🔊 Speakers — Sonos, AirPlay, Chromecast, and DLNA discovery; live transport and volume control over UPnP for Sonos and DLNA, plus announcements that interrupt and restore playback
  • 🔋 UPS Systems — NUT, SNMP protocols
  • 📡 SNMP Devices — Community-based SNMP v1 and v2c sessions; v3 is not implemented
  • 🏠 Smart Home — Home Assistant integration (lights, switches, sensors, climate, locks, fans, cameras, covers)

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

📦 Installation

The published package includes the SNMP and WebSocket type dependencies used by its declarations. TypeScript consumers do not need to install @types/net-snmp or @types/ws separately.

# Using pnpm (recommended)
pnpm add @ecobridge.xyz/devicemanager

# Using npm
npm install @ecobridge.xyz/devicemanager

# Using yarn
yarn add @ecobridge.xyz/devicemanager

🚀 Quick Start

The OOP Pattern: Discovery → Selection → Feature → Operation

import { DeviceManager, ScanFeature } from '@ecobridge.xyz/devicemanager';
import * as fs from 'fs/promises';

async function scanDocument() {
  const manager = new DeviceManager();

  // 1️⃣ DISCOVERY - Find scanners in your network
  await manager.discoverScanners('192.168.1.0/24');

  // 2️⃣ INSPECTION - See what's available
  const scanners = manager.getDevices({ hasFeature: 'scan' });
  console.log('Found scanners:', scanners.map(s => `${s.name} at ${s.address}`));

  // 3️⃣ SELECTION - Choose your device (explicit, no magic!)
  const device = manager.selectDevice({ address: '192.168.1.100' });

  // 4️⃣ FEATURE ACCESS - Get the capability you need
  const scanFeature = device.selectFeature<ScanFeature>('scan');

  // 5️⃣ OPERATION - Do the thing!
  await scanFeature.connect();
  const result = await scanFeature.scan({
    source: 'flatbed',
    resolution: 300,
    colorMode: 'color',
    format: 'jpeg',
  });

  await fs.writeFile('scan.jpg', result.data);
  console.log(`Saved: scan.jpg (${result.data.length} bytes)`);

  await manager.shutdown();
}

scanDocument();

🏗️ Architecture

The library follows a clean, composable architecture:

┌─────────────────────────────────────────────────────────────┐
│                      DeviceManager                          │
│  • Discovery (mDNS, SSDP, Network Scanning)                │
│  • Device Registry (by IP, deduplication)                  │
│  • Device Selection (query & assert patterns)              │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                     UniversalDevice                         │
│  • Represents any network device                           │
│  • Composable features (scan, print, volume, etc.)         │
│  • Connection lifecycle management                         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                        Features                             │
│  ScanFeature │ PrintFeature │ PlaybackFeature │ VolumeFeature
│  PowerFeature │ SnmpFeature │ LightFeature │ SwitchFeature  │
│  SensorFeature │ ClimateFeature │ CameraFeature │ ...       │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                        Protocols                            │
│ Brother Scan │ eSCL │ SANE │ IPP │ SNMP │ NUT │ UPnP/SOAP  │
└─────────────────────────────────────────────────────────────┘

📖 API Reference

DeviceManager

The central orchestrator for device discovery and management.

const manager = new DeviceManager({
  discoveryTimeout: 10000,  // Discovery timeout in ms
  enableRetry: true,        // Enable retry with exponential backoff
  maxRetries: 5,            // Maximum retry attempts
});

Constructing DeviceManager does not initiate network discovery. Call startDiscovery() explicitly when continuous mDNS/SSDP discovery (and the Bambu Lab announcement listener on UDP 2021/1990) should begin; the autoDiscovery option does not currently trigger startup.

Discovery Methods

// Focused scanner discovery
const scanners = await manager.discoverScanners('192.168.1.0/24', {
  timeout: 3000,
  concurrency: 50,
});

// Focused printer discovery
const printers = await manager.discoverPrinters('192.168.1.0/24');

// General network scan (all device types)
const devices = await manager.scanNetwork({
  ipRange: '192.168.1.0/24',
  probeBrother: true,
  probeEscl: true,
  probeIpp: true,
  probeSane: true,
  probeAirplay: true,
  probeSonos: true,
  probeChromecast: true,
});

// mDNS/SSDP continuous discovery
await manager.startDiscovery();
manager.on('device:found', ({ device, featureType }) => {
  console.log(`Found: ${device.name}`);
});
await manager.stopDiscovery();

Device Selection

// Query pattern (returns array, may be empty)
const allDevices = manager.getDevices();
const scanners = manager.getDevices({ hasFeature: 'scan' });
const brotherDevices = manager.getDevices({ name: 'Brother' });

// Assert pattern (returns single device, throws if not found)
const device = manager.selectDevice({ address: '192.168.1.100' });
const scanner = manager.selectDevice({ name: 'Brother', hasFeature: 'scan' });

IDeviceSelector Interface

interface IDeviceSelector {
  id?: string;               // Exact match on device ID
  address?: string;          // Exact match on IP address
  name?: string;             // Partial match (case-insensitive)
  model?: string;            // Partial match (case-insensitive)
  manufacturer?: string;     // Partial match (case-insensitive)
  hasFeature?: TFeatureType; // Must have this feature
  hasFeatures?: TFeatureType[];   // Must have ALL features
  hasAnyFeature?: TFeatureType[]; // Must have ANY feature
}

UniversalDevice

Represents any network device with composable features.

const device = manager.selectDevice({ address: '192.168.1.100' });

// Device properties
console.log(device.name);         // "Brother MFC-J5730DW"
console.log(device.address);      // "192.168.1.100"
console.log(device.manufacturer); // "Brother"
console.log(device.model);        // "MFC-J5730DW"
console.log(device.status);       // 'online' | 'offline' | 'busy' | 'error'

// Feature access (safe query - returns undefined)
const maybeScan = device.getFeature<ScanFeature>('scan');

// Feature access (assert - throws if not available)
const scanFeature = device.selectFeature<ScanFeature>('scan');

// Check capabilities
device.hasFeature('scan');           // true/false
device.hasFeatures(['scan', 'print']); // must have ALL
device.hasAnyFeature(['scan', 'print']); // must have ANY
device.getFeatureTypes();            // ['scan', 'print', ...]

Features

🖨️ ScanFeature

const scanFeature = device.selectFeature<ScanFeature>('scan');
await scanFeature.connect();

// Get capabilities
const caps = await scanFeature.getCapabilities();
// { resolutions: [100, 200, 300, 600], formats: ['jpeg', 'png', 'pdf'], ... }

// Scan a document
const result = await scanFeature.scan({
  source: 'flatbed',       // 'flatbed' | 'adf' | 'adf-duplex'
  resolution: 300,         // DPI
  colorMode: 'color',      // 'color' | 'grayscale' | 'blackwhite'
  format: 'jpeg',          // 'jpeg' | 'png' | 'pdf' | 'tiff'
  quality: 85,             // JPEG quality (1-100)
  area: { x: 0, y: 0, width: 210, height: 297 }, // mm
});

// result.data is a Buffer containing the scanned image
await fs.writeFile('scan.jpg', result.data);

// ADF scans expose every returned document. `data` remains the first document
// for backwards compatibility. Use `pages` to avoid dropping later sheets/sides.
console.log(`Scanner returned ${result.pageCount} document(s)`);
for (const [index, page] of (result.pages ?? []).entries()) {
  await fs.writeFile(`scan-${index + 1}.jpg`, page.data);
}

If a scanner times out or cancels after transferring one or more documents, EsclScanError.partialResult contains those documents with isComplete: false. Callers can persist partialResult.pages before deciding whether to retry. Some scanner firmware accepts eSCL settings but returns a different format or resolution. Check result.settingsWarnings before treating the requested scan quality as confirmed.

Brother devices discovered on TCP 54921 use the native scan transport before eSCL. It explicitly selects AUTO for ADF input, controls simplex/duplex, and rejects a lease when the device does not grant the requested resolution. The native transport currently returns color JPEG pages; unsupported output formats or color modes fail explicitly instead of being silently substituted. Duplex-capable scanners default to adf-duplex; callers can still request adf or flatbed explicitly. The transport preserves every returned side. Blank-page classification and removal belong in consuming applications, where document-specific review rules can be applied.

📄 PrintFeature

PrintFeature.print() submits jobs through IPP, the print transport used by AirPrint. It reads printer capabilities, submits jobs, reports job state, and supports cancellation. Network discovery can also identify an open JetDirect/raw port at TCP 9100, but JetDirect print execution is not implemented.

const printFeature = device.selectFeature<PrintFeature>('print');
await printFeature.connect();

// Get printer capabilities
const caps = await printFeature.getCapabilities();
console.log(caps.airPrintSupported, caps.documentFormats, caps.resolutions);

// Print a document
const job = await printFeature.print(pdfBuffer, {
  copies: 2,
  mediaSize: 'iso_a4_210x297mm',
  sides: 'two-sided-long-edge',
  quality: 'high',
  colorMode: 'color',
  jobName: 'My Document',
});

// Get current job information
const jobInfo = await printFeature.getJobInfo(job.id);

🧊 Print3dFeature (3D printers)

Print3dFeature is the 3D printer counterpart to the IPP print feature. It uploads sliced files, starts, pauses, resumes and stops jobs, and streams live status. One protocol-neutral API covers four printer families:

Protocol Discovery Credentials Upload formats Live status
bambu — Bambu Lab (LAN Only Mode + Developer Mode) SSDP NOTIFY broadcasts on UDP 2021/1990 serial + LAN access code .gcode.3mf MQTT push over TLS (8883)
moonraker — Klipper mDNS _moonraker._tcp (only with [zeroconf] in moonraker.conf) API key, or none for trusted_clients .gcode, .g, .gco WebSocket printer.objects.subscribe
octoprint — OctoPrint mDNS _octoprint._tcp API key .gcode, .gco, .g push socket /sockjs/websocket
prusalink — Prusa (Buddy firmware, PrusaLink on a Pi) mDNS _prusalink._tcp printer password (Digest, user maker) or API key .gcode, plus .bgcode on firmware 5.1+ polling /api/v1/status (PrusaLink has no push channel)

Discovery never carries credentials and broadcasts do not cross subnets. Discovered printers are listed with their identity; addPrint3dPrinter() supplies the credentials (or adds a printer by address), replaces any print3d feature already registered for that address, and connects it.

import { DeviceManager, Print3dFeature, Print3dProtocolError } from '@ecobridge.xyz/devicemanager';

const manager = new DeviceManager();
manager.on('device:found', ({ device, featureType }) => {
  if (featureType === 'print3d') console.log(device.name, device.model, device.serialNumber, device.address);
});
await manager.startDiscovery();

// The user picked a printer and entered its access code.
const device = await manager.addPrint3dPrinter({
  protocol: 'bambu',
  host: '192.168.5.40',
  serial: '00M09A350100123',
  accessCode: '12345678',
});
const printer = device.selectFeature<Print3dFeature>('print3d');

console.log(printer.identity);      // model, serialNumber, firmwareVersion, host
console.log(printer.capabilities);  // fileFormats, buildVolume, camera, filamentSlotCount

printer.status$.subscribe((status) => {
  // state: idle | preparing | printing | paused | finished | cancelled | error | offline
  console.log(status.state, status.progressPercent, status.currentLayer, status.totalLayers,
    status.remainingSeconds, status.nozzle, status.bed, status.jobName, status.errors, status.filamentSlots);
});

try {
  await printer.print(sliced3mf, { fileName: 'bracket.gcode.3mf', bambu: { plate: 1, useAms: true, amsMapping: [0] } });
} catch (error) {
  if (error instanceof Print3dProtocolError && error.code === 'developer-mode-required') {
    console.error(error.printerMessage, error.printerCode); // e.g. 'mqtt message verify failed', 84033543
  }
}

await printer.pause();
await printer.resume();
await printer.stop();

The other protocols take their own options, for example { protocol: 'moonraker', host: 'voron.local' }, { protocol: 'octoprint', host: 'octopi.local', apiKey } or { protocol: 'prusalink', host: '192.168.5.60', password }. Without a device manager, createPrint3dPrinter(options) builds a device and createPrint3dController(options) returns the bare protocol controller (BambuProtocol, MoonrakerProtocol, OctoPrintProtocol, PrusaLinkProtocol).

  • status$ replays the latest status and stays open across reconnects; the feature also emits print3d:status, print3d:state, print3d:uploaded, print3d:started, print3d:paused, print3d:resumed, print3d:stopped and print3d:deleted. refreshStatus() reads a fresh status.
  • upload() stores a file without starting it; print() uploads and starts it; deleteFile() removes an uploaded file.
  • Live connections reconnect with backoff (1 s to 30 s) and report offline while the printer is unreachable.
  • Every failure is a Print3dProtocolError with protocol, code (auth-failed, developer-mode-required, command-rejected, unsupported-format, tls-verification, not-connected, invalid-options, invalid-response, http, transport, timeout, cancelled, closed), effectOutcome (not-sent, rejected, indeterminate) and the printer's own printerMessage/printerCode/httpStatus where it returned one. Only transport and timeout are retried by connect().
Bambu Lab: LAN Only Mode and Developer Mode

Since the Authorization Control firmware (X1 series 01.08.03.00 and later, January 2025), Bambu printers accept job commands from LAN clients only in Developer Mode. On the printer, enable LAN Only Mode first, then Developer Mode (see the Bambu Lab wiki article "Enable Developer Mode"). The printer then no longer connects to Bambu Cloud. Without Developer Mode, status reading still works, but print(), pause(), resume() and stop() are refused: the error code is developer-mode-required and printerMessage / printerCode carry the printer's reply, e.g. "mqtt message verify failed" / 84033543, or HMS 0500_0500_0001_0007 on P1/A1 firmware. BambuProtocol.developerMode reads the mode from the report's fun field where the printer sends it (not on P1/A1).

How the Bambu protocol talks to the printer:

  • Status — MQTT over TLS on 8883, user bblp, password = access code, subscribed to device/<serial>/report. A full report (pushall) is requested on connect; later reports are merged (P1/A1 send only changed fields). readStatus() requests a full report at most every 3 s, and at most every 5 minutes on P1/A1, as Bambu recommends.
  • Upload — implicit FTPS on 990 to the storage root. The printer requires every data connection to resume the control connection's TLS session; the client does that.
  • Print — project_file for Metadata/plate_<n>.gcode of the uploaded .gcode.3mf, addressed as file:///sdcard/<name> (X1, P1, A1 series) or ftp:///<name> (H2 series); bambu.fileUrl overrides this. Job options: plate, useAms (default: the printer reports an AMS), amsMapping (default [0]), bedLevelling (default on), flowCalibration, vibrationCalibration, timelapse (default off).
  • Commands — published at QoS 1. An echo with result: success confirms a command; an echo with err_code/result: failed, or the verification HMS, rejects it. Many firmware versions acknowledge only through the next status report, so without an echo a command counts as delivered after commandAckMs (default 5 s).
  • Certificate verification — the printer certificate's chain must end in one of Bambu Lab's printer CAs (bundled as BAMBU_CA_CERTIFICATES, from ha-bambulab) and its common name must equal the serial; TLS is capped at 1.2. tls: { mode: 'bambu-ca', additionalCa } adds CAs, tls: { mode: 'ca', ca } replaces them. There is no mode that accepts any certificate.
  • Discovery — BambuDiscovery listens on UDP 2021 and 1990 (with address reuse, next to Bambu Studio or OrcaSlicer) for NOTIFY packets with NT urn:bambulab-com:device:3dprinter:1 and admits only announcements whose Location equals the sender. DeviceManager.startDiscovery() runs it; when a printer announces a new address, its unconnected entry at the old address is dropped (device:lost, or feature:removed when that device has other features).

Bambu Connect and the Bambu cloud API are not used: this package implements only the direct LAN path with Developer Mode.

📁 SMB Share Provider

DeviceManager.provideSmbShares() hosts one or more local folders with @push.rocks/smartsamba. The provider waits until a new file has stopped changing before it emits document:arrived, which keeps consumers from opening a scan while the sending device is still writing it.

import { DeviceManager } from '@ecobridge.xyz/devicemanager';

const manager = new DeviceManager({ autoDiscovery: false });
const provider = await manager.provideSmbShares({
  host: '0.0.0.0',
  port: 445,
  users: [{
    username: 'scanner',
    password: process.env.SMB_SCANNER_PASSWORD!,
  }],
  shares: [{
    name: 'scans',
    path: '/srv/device-scans',
    users: [{ username: 'scanner', access: 'readWrite' }],
  }],
  settleTimeMs: 1000,
});

const subscription = provider.documents$.subscribe((document) => {
  console.log(`New document: ${document.shareName}/${document.relativePath}`);
});

// The same event is forwarded through the manager as smb:document:arrived.
const managerSubscription = manager.events$.subscribe((event) => {
  if (event.name === 'smb:document:arrived') {
    console.log(event.args[0]);
  }
});

// Later:
subscription.unsubscribe();
managerSubscription.unsubscribe();
await manager.shutdown();

The provider recursively monitors every configured share. It also emits document:changed, document:removed, started, stopped, and provider:error. Existing files are treated as the startup baseline unless emitExistingDocuments is enabled.

Binding SMB's standard TCP port 445 can require elevated privileges. Published smartsamba 0.3.1 packages support signed SMB 2.0.2 and 2.1 clients and include Linux amd64 and arm64 engines. Other platforms can provide a locally built engine through SMARTSAMBA_RUST_BINARY. Do not store SMB passwords in device metadata or log provider options.

Yeelight LAN control

probeYeelight() uses the documented vendor discovery exchange on multicast port 1982. Select the local IPv4 interface explicitly; the probe admits only the requested bulb address and optional expected device ID. Its deadline and AbortSignal cover discovery and socket cleanup. No URL fetch, DNS lookup or automatic TCP connection follows discovery.

import { probeYeelight, YeelightProtocol, YeelightError } from '@ecobridge.xyz/devicemanager';

const advertisement = await probeYeelight({
  address: '192.168.1.20',
  localAddress: '192.168.1.10',
  expectedId: '0x000000000015243f',
  timeoutMs: 5000,
});
const bulb = new YeelightProtocol({
  address: advertisement.address,
  port: advertisement.port,
  supportedMethods: advertisement.supportedMethods,
});
const unsubscribe = bulb.subscribeProperties((properties) => {
  console.log(properties);
});
try {
  await bulb.connect();
  const properties = await bulb.getProperties(['power', 'bright', 'rgb']);
  await bulb.setPower(true);
  await bulb.setBrightness(50, { effect: 'smooth', durationMs: 350 });
  if (advertisement.supportedMethods.includes('set_rgb')) await bulb.setRgb(0xff0000);
} catch (error) {
  if (error instanceof YeelightError) {
    console.error(error.code, error.effectOutcome);
  }
  throw error;
} finally {
  unsubscribe();
  await bulb.close();
}

Enable LAN Control in the Yeelight app first. These are unauthenticated, unencrypted LAN identifiers and capabilities; matching a discovery ID does not cryptographically authenticate a device. Choose the home, endpoint and interface from trusted application configuration. TCP does not expose a documented device ID or supported-method property, so do not infer either from getProperties().

The client owns one terminal connection with exact request IDs, strict UTF-8 JSON and CRLF framing. It never retries or reconnects. It defaults to 5-second connection, request and incomplete-frame deadlines, 64-KiB frames, 32 pending requests and 32 subscriptions. Options can raise frame bounds to 1 MiB and pending/subscription bounds to 128. The rolling per-connection quota is 60 commands per minute; capacity and quota errors refuse admission before sending. Queued TCP write bytes are capped by the configured frame bound too. The bulb's documented total LAN quota spans other clients too; this instance cannot coordinate their command traffic.

Controls accept boolean power, integer brightness 1–100 and packed integer RGB 0–16777215. Smooth transitions require at least 30 ms; invalid values reject without clamping. Only advertised methods can be sent. Scenes, colour flows, music mode, background-channel controls and arbitrary commands are outside this API. A matching ok acknowledges the command protocol; authoritative state comes from getProperties() or props notifications. A control failure reports not-sent, rejected or indeterminate; a missing acknowledgement after dispatch is indeterminate and must be reconciled before a caller considers another effect. Reads use not-sent because they dispatch no physical effect.

connect() accepts startup cancellation and a deadline; its signal is released when startup settles. Every request accepts its own signal/deadline. close() settles active requests, removes subscriptions/timers, destroys the owned TCP socket and waits for cleanup. It permanently refuses new requests and reconnects. Each subscription has independent disposal. A throwing subscription terminates the connection and is observable through lastError with code callback-error.

Protocol reference: Yeelight WiFi Light Inter-Operation Specification. LAN setup: Yeelight LAN Control instructions.

🔊 PlaybackFeature & VolumeFeature

PlaybackFeature and VolumeFeature provide normalized capability and state models, backed by a protocol controller when one is available.

Sonos and DLNA renderers are driven over UPnP SOAP: the factories and DeviceManager attach a UpnpPlaybackController (AVTransport) and UpnpVolumeController (RenderingControl), so these calls reach the real device. AirPlay and Chromecast have no bundled protocol client, so for those the same methods only update local feature state and emit events.

const playback = device.selectFeature<PlaybackFeature>('playback');
const volume = device.selectFeature<VolumeFeature>('volume');
await playback.connect(); // primes cached state from the device

// Transport control — real AVTransport SOAP calls on Sonos/DLNA
await playback.play('http://example.com/audio.mp3');
await playback.pause();
await playback.stop();
await playback.seek(120); // seconds, sent as a REL_TIME target

// Live status, read back from the device
const status = await playback.getPlaybackStatus();
// { state: 'playing', position: 65, duration: 260, track: { title, artist, album, ... } }

// Volume — real RenderingControl SOAP calls on Sonos/DLNA
await volume.setVolume(50);    // 0-100, clamped to the feature range
await volume.setMute(true);
await volume.toggleMute();
const level = await volume.getVolume();

A failed command rejects rather than reporting a false success, and the cached state is left untouched when the device refuses.

Drive the protocol directly when you do not need the device model:

import { UpnpPlaybackController, UpnpVolumeController } from '@ecobridge.xyz/devicemanager';

const playback = UpnpPlaybackController.forSonos('192.168.1.52');
await playback.play();

const volume = UpnpVolumeController.forSonos('192.168.1.52');
await volume.setVolume(35);

Sonos grouping and zones (ZoneGroupTopology, GroupRenderingControl), the queue, favorites, alarms and EQ are not implemented, and there is no GENA event subscription — status is read on demand rather than pushed.

UpnpSoapClient(baseUrl, { timeoutMs, maxResponseBytes, abortSignal }) owns its request lifetime. Defaults are 10 seconds and 1 MiB per response. SOAP methods and controller methods accept an optional final AbortSignal; getXml(path, signal) applies the same limits to device and service descriptions. Requests remain on the configured HTTP(S) origin, reject redirects, and retain their deadline while reading the response body. await client.close() aborts and drains requests and prevents reuse. Controllers also expose close(); closing a controller cancels its own work and closes its client only when it created that client. An injected shared client remains owned by its caller.

SOAP parsing validates the envelope, namespace and action response, including faults returned with HTTP 200. UpnpSoapFault exposes the action, HTTP status, error code and description. Missing, duplicate, malformed or invalid typed outputs fail rather than becoming a stopped player, zero volume or unmuted state. Volume writes require an integer from 0 to 100. parseXml, extractValues and extractOptionalValues provide the same bounded XML validation for vendor descriptions and metadata; values are strings, CDATA is preserved, and entities are decoded once. DTDs and nesting beyond 64 elements are rejected.

client.generateDidlMetadata(title, url, mimeType?) creates DIDL-Lite metadata for setAVTransportURI. Audio MIME identifiers such as audio/mpeg select object.item.audioItem, with case-insensitive type matching. The default remains video/mp4 with object.item.videoItem; other MIME types retain the video class. Pass a bare type/subtype identifier with names of at most 127 characters each; parameters, wildcards and malformed identifiers are rejected. Titles and URLs must contain valid XML 1.0 text. The helper escapes title, URL and protocolInfo attribute values; pass its result directly to setAVTransportURI, which escapes the metadata once more for the outer SOAP document.

📢 Announcements

SpeakerAnnouncer plays a short clip on a speaker and restores whatever it interrupted. A UPnP renderer fetches audio rather than accepting a push, so the clip is published on a local HTTP server (ClipServer) and the speaker is pointed at that URL.

import { SpeakerAnnouncer } from '@ecobridge.xyz/devicemanager';

const announcer = new SpeakerAnnouncer();
await announcer.start();

await announcer.announceOnSonos('192.168.1.52', { data: wavBuffer }, { volume: 35 });

await announcer.stop();

The announcement snapshots the transport URI, position and volume, plays the clip, then puts everything back — resuming only if the speaker was playing beforehand. Restore also runs when the announcement fails, so a broken clip never strands the speaker on a dead URL at announcement volume.

No speech engine is bundled. Supply any synthesizer through the ISpeechSynthesizer interface to use announceText():

const announcer = new SpeakerAnnouncer({
  synthesizer: {
    synthesize: async (text) => ({ data: await myTts(text), contentType: 'audio/wav' }),
  },
});
await announcer.announceText('192.168.1.52', 'Front door open');

Audio must be something the renderer decodes; 44.1 kHz mono 16-bit WAV is a safe default. ClipServer sets Content-Type and Content-Length and answers HEAD and Range requests, though Sonos issues neither for short clips.

Announcements must target the group coordinator. Bonded satellites — home-theatre surrounds and subs — have no transport of their own and reject transport commands, while still accepting volume changes.

🔋 PowerFeature (UPS)

const power = device.selectFeature<PowerFeature>('power');
await power.connect();

// Get UPS status
const status = await power.getStatus();
// 'online' | 'onbattery' | 'lowbattery' | ... | 'unknown'

// Get battery info
const battery = await power.getBatteryInfo();
// { charge: 0, runtime: 0 } until a protocol-specific implementation updates it

// Run a battery test when supported
if (power.supportsTest) {
  await power.testBattery();
}

🏠 Smart Home Features

// Light control
const light = device.selectFeature<LightFeature>('light');
await light.turnOn();
await light.setBrightness(80); // 0-255
await light.setRgbColor(255, 100, 50);
await light.setColorTemp(4000); // Kelvin

// Switch control
const switch_ = device.selectFeature<SwitchFeature>('switch');
await switch_.turnOn();
await switch_.turnOff();
await switch_.toggle();

// Climate control
const climate = device.selectFeature<ClimateFeature>('climate');
await climate.setTargetTemp(22);
await climate.setHvacMode('heat'); // 'heat' | 'cool' | 'auto' | 'off'

// Sensor reading
const sensor = device.selectFeature<SensorFeature>('sensor');
const reading = await sensor.refreshState();
// { value: 22.5, numericValue: 22.5, unit: '°C', lastUpdated: Date }

// Cached accessors reflect the most recently fetched or externally updated state
console.log(sensor.value, sensor.numericValue, sensor.unit, sensor.lastUpdated);

Protocol Direct Access

For advanced use cases, you can access protocols directly:

import { EsclProtocol, IppProtocol, SnmpProtocol } from '@ecobridge.xyz/devicemanager';

// Direct eSCL (AirScan) access
const escl = new EsclProtocol('192.168.1.100', 80, false);
const caps = await escl.getCapabilities();
const result = await escl.scan({ source: 'flatbed', resolution: 300 });

// Direct IPP access
const ipp = new IppProtocol('192.168.1.100', 631, '/ipp/print');
const printerAttrs = await ipp.getPrinterAttributes();

// Direct SNMP access
const snmp = new SnmpProtocol('192.168.1.100', { community: 'public' });
const sysDescr = await snmp.get('1.3.6.1.2.1.1.1.0');

Tuya 3.4 Local Protocol

TuyaProtocol opens one explicitly configured LAN session for a mains-powered Tuya device with local protocol 3.4. It authenticates frames with HMAC-SHA256 and negotiates a fresh AES session key. Other protocol versions, cloud-only devices, sleeping battery devices and gateway sub-devices are unsupported. It does not discover credentials or guess a product's datapoint IDs.

import { TuyaProtocol, TuyaProtocolError } from '@ecobridge.xyz/devicemanager';

// Supply the device's actual 16-byte local key from your encrypted secret store.
const client = new TuyaProtocol({
  deviceId: configuredDeviceId,
  localKey: configuredLocalKeyBytes,
  host: '192.168.1.50',
  version: '3.4',
});
const unsubscribe = client.subscribe((report) => {
  // Only datapoints reported by this authenticated session update the cache.
  console.log(report.datapoints);
});
try {
  await client.connect();
  const snapshot = await client.queryDatapoints();
  console.log(snapshot.datapoints);
  // Select a writable datapoint from this product's verified schema.
  const receipt = await client.setDatapoints({[configuredSwitchDatapointId]: true});
  console.log(receipt.outcome); // 'acknowledged': protocol acceptance
} catch (error) {
  if (error instanceof TuyaProtocolError && error.effectOutcome === 'indeterminate') {
    // The write may have reached the device; do not automatically repeat it.
    console.log('Inspect authoritative device state before another operation.');
  } else throw error;
} finally {
  unsubscribe();
  await client.close();
}

queryDatapoints(signal?) requires an authenticated STATUS reply with the query's sequence. setDatapoints(datapoints, signal?) requires a matching CONTROL_NEW ACK; an unrelated state push cannot complete it. Some firmware does not echo a command's sequence; that firmware fails the operation deadline and needs a separately verified protocol contract. ACKs establish protocol acceptance, and physical completion is observed through subsequent reports. Writes never update cached state optimistically.

TuyaProtocolError exposes code, sequence, optional returnCode, and effectOutcome: not-sent, indeterminate after a transmission attempt, or rejected for an explicit correlated device rejection. No effect is automatically retried and no connection is automatically recreated. close() is permanent, idempotent, rejects pending operations and joins socket shutdown. Use a new instance for a new session. Caller-owned key bytes are copied, and the engine clears its key copies on termination. Errors omit credential and raw payload contents.

Options default to TCP port 6668, a 5-second total connect deadline, a 5-second request deadline, a 10-second heartbeat interval, 64 KiB maximum frames and 16 concurrent caller requests. One additional heartbeat slot keeps health checks independent of caller saturation. Hosts must be literal IPv4 private/link-local/ loopback or IPv6 local/loopback addresses. Datapoint maps contain 1–255 IDs in the range 1–255, with boolean, safe-integer or bounded string values. Nested/raw values, accessors, duplicate JSON properties and unsupported response identities fail closed.

connected, immutable datapoints, lastError and subscribeErrors() expose session health. Subscribers run synchronously and must not block. Async work started by a subscriber remains caller-owned; rejected subscriber promises are reported without interrupting receive processing. A lifetime abortSignal in the options closes the entire session; method signals cancel only their own pending operation.

The Tuya product schema model is described in the vendor's DP Model and Control Protocol. LAN framing is documented by the original TinyTuya protocol implementation. This implementation has encrypted loopback fixture coverage; real device firmware qualification remains required for each product.

Legacy Kasa relay protocol

KasaProtocol owns the legacy TP-Link IOT XOR TCP transport, using an explicit unicast IP address and port 9999 by default. Its supported subset is a single relay advertised by IOT.SMARTPLUGSWITCH: plugs and switches with relay_state equal to 0 or 1. Multi-outlet strips, bulbs, dimmer settings, energy meters, schedules, Tapo, SMART/AES/KLAP, discovery broadcasts, and cloud login are outside this API. It accepts no credentials and performs no authentication; the device identity check binds operations to the observed legacy endpoint, not to a cryptographically authenticated peer.

import { KasaProtocol, KasaProtocolError } from '@ecobridge.xyz/devicemanager';

const kasa = new KasaProtocol({
  host: '192.168.1.40',
  expectedDeviceId: '800611223344556677889900AABBCCDDEEFF001122',
  timeoutMs: 5000,
});
try {
  const device = await kasa.getSystemInfo();
  console.log(device.alias, device.powerState, device.capabilities);
  const acknowledgment = await kasa.setPowerState(device.deviceId, true);
  console.log(acknowledgment.acknowledgment); // Protocol acceptance only.
  console.log(await kasa.getPowerState()); // A fresh authoritative query.
} catch (error) {
  if (error instanceof KasaProtocolError) {
    console.error(error.code, error.effectOutcome);
  } else {
    throw error;
  }
} finally {
  await kasa.close();
}

getSystemInfo() returns immutable identity, model, versions, MAC, power state, and a power capability. The first successful query pins the device ID when expectedDeviceId is omitted. setPowerState(deviceId, boolean) revalidates that identity and capability on the same TCP connection before the relay request. It returns an immutable IKasaPowerAcknowledgment with physicalStateConfirmed: false; it does not mutate a cached state. Each method accepts { abortSignal }.

Each client serializes a bounded FIFO: maxPendingOperations defaults to 16 (maximum 128), including active work. timeoutMs covers admission, queueing, connection, identity preflight, and response, defaults to 5000, and is bounded to 1–30000 ms. maxResponseBytes defaults to 65536 and is bounded to 128–1048576. Responses require a bounded unsigned length, valid UTF-8/JSON without duplicate keys, and the exact requested system method. No command is retried or automatically reconnected.

KasaProtocolError.effectOutcome is not-sent before relay dispatch, rejected for a valid device error, and indeterminate when a relay may have been dispatched without a valid acknowledgment. KasaDeviceError also exposes the method and numeric device error code. Diagnostics omit raw device content. close() is terminal and idempotent: it rejects queued work, cancels active work, and waits for owned sockets to close. A constructor abortSignal closes the client for its entire lifetime.

Wire behavior was verified against the published TP-Link client and python-kasa XOR transport. Tests use local encrypted TCP fixtures; physical-device behavior remains unverified.

LIFX LAN Lighting

LifxProtocol binds one explicit unicast IPv4 endpoint and six-byte device MAC to a bounded, serialized UDP client. It does not discover devices or change an endpoint from incoming traffic. The LIFX LAN protocol is unauthenticated; matching a MAC and UDP sender does not provide cryptographic device authentication.

import { LifxProtocol, LifxProtocolError } from '@ecobridge.xyz/devicemanager';

const light = new LifxProtocol({
  host: '192.168.1.42',
  macAddress: 'd073d5001337',
  port: 56700,
  timeoutMs: 5000,
  maxPendingOperations: 32,
});

try {
  const info = await light.getDeviceInfo();
  console.log(info.product, info.firmware.major, info.capabilities);
  const result = await light.setPower(true);
  console.log(result.outcome, result.observedState.power);
  await light.setHsbk({ hue: 21845, saturation: 65535, brightness: 32768, kelvin: 3500 });
} catch (error) {
  if (error instanceof LifxProtocolError) console.log(error.code, error.effectOutcome);
  throw error;
} finally {
  await light.close();
}

getDeviceInfo() queries the vendor, product and full uint64 firmware build (bigint). Its capabilities come from the documented official product registry snapshot retrieved on 2026-10-06, including firmware-dependent kelvin ranges. Unknown products remain available for identity queries. getLightState() and setPower() require a known single-zone lighting product; setHsbk() additionally requires color support and a kelvin value inside the actual firmware range. The hue, saturation and brightness arguments are exact LAN uint16 values from 0 through 65535. There are no inferred percent, hue-angle or color conversions. Matrix, chained, multizone and relay controls, effects, themes, HEV and infrared controls are outside this API. White products support power and state reads, and reject HSBK writes.

Write options accept durationMs from 0 through 60000, defaulting to zero. All operations accept { signal: AbortSignal }; writes accept it alongside durationMs. The deadline includes queue time, startup, capability queries, acknowledgement and the separate state observation. maxPendingOperations includes the active operation, defaults to 32, and may be set from 1 through 128. Packets are spaced at least 50 milliseconds apart, replies must match the configured UDP sender and the complete source/sequence/target/type correlation, and a sequence wrap changes the source generation to reject old replies. No message is retransmitted automatically. close() is terminal and idempotent, and joins requests, timers, queued work and the socket.

LIFX acknowledges a set request before processing it. A successful write returns { outcome: 'accepted', observedState }; this is receipt acceptance with a separately queried observation, which may precede completion or reflect a concurrent writer. It does not promise physical completion. LifxProtocolError.effectOutcome is not-sent, indeterminate, rejected (an exact StateUnhandled response), or accepted when a later observation fails after an acknowledgement. An effect without its acknowledgement is indeterminate and must not be retried automatically.

The implementation follows the official packet format, request and acknowledgement semantics, and state payload definitions. Opaque reserved reply bytes are ignored as in the official decoding example; all outgoing reserved bytes are zero. Verification uses loopback UDP fixtures; real device and firmware interoperability has not been verified.

Samsung TV Remote Protocol

SamsungTvProtocol provides bounded device-information reads and authenticated remote control for Samsung TVs advertising the Tizen v2 remote channel. The configured UUID must match /api/v2/ before pairing and before every key. Device snapshots contain only observed device information, power state and the current remote capability declarations; commands never change the snapshot optimistically.

import {
  SamsungTvProtocol,
  type ISamsungTvProtocolOptions,
} from '@ecobridge.xyz/devicemanager';

async function openTv(
  trustedTvCa: string,
  persistToken: ISamsungTvProtocolOptions['persistToken'],
): Promise<SamsungTvProtocol> {
  const tv = new SamsungTvProtocol({
    httpOrigin: 'http://192.168.1.55:8001',
    websocketOrigin: 'wss://192.168.1.55:8002',
    expectedDeviceId: '2ff68485-c4cb-48c7-a6ac-9c50e44d7084',
    allowUnencryptedLocalDiscovery: true,
    ca: trustedTvCa,
    persistToken,
  });
  try {
    await tv.start();
    const result = await tv.sendKey('KEY_HOME');
    // result.transport === 'sent'; result.effectOutcome === 'indeterminate'.
    return tv;
  } catch (error) {
    await tv.close();
    throw error;
  }
}

The caller must provide encrypted, durable, home-scoped pairing persistence. The protocol awaits persistToken(token, signal) before enabling controls, and also persists the confirmed token when an existing token is supplied. It owns no database, file or cache and exposes no token getter. The callback must honor cancellation; terminal close() cancels operations and joins any accepted persistence callback and owned socket before resolving. It then clears its current token, channel and persistence-callback references; immutable connection settings retain no token or provider. Caller-owned configuration, provider closures and stored credentials remain under caller control. Clearing references does not guarantee physical zeroization of JavaScript strings; memory reclamation is controlled by the runtime. Start a fresh instance for an explicit retry.

Pairing always uses WSS with certificate and hostname verification enabled. ca adds an explicitly trusted CA; the certificate must identify the configured host. There is no option to disable TLS checks. Unencrypted REST discovery is opt-in, contains no token, authorization header or cookie, and only admits literal IPv4 private, link-local or loopback addresses, or IPv6 ULA and loopback addresses. HTTPS REST and WSS must use the same host. Origins cannot contain userinfo, paths, query parameters or fragments; redirects are rejected.

start({signal}), readDeviceInfo({signal}) and sendKey(key, {signal}) use one operation deadline (10 seconds by default, maximum 120 seconds) and bounded HTTP bodies/WebSocket frames (64 KiB by default, maximum 1 MiB). The remote key surface is a fixed conservative set of single Click commands; directional keys additionally require the current remote_fourDirections declaration. remote_available enables the known protocol commands, rather than proving that a particular application will act on each key. Concurrent key calls are rejected. No command is retried, repeated or reconnected automatically.

The Samsung channel does not acknowledge a physical effect. A completed socket write therefore returns {transport: 'sent', effectOutcome: 'indeterminate'}. Errors expose a stable SamsungTvProtocolError.code and an effectOutcome of not-sent, rejected or indeterminate; they exclude endpoint queries, tokens and remote error payloads. Only startup pairing can be explicitly rejected. A lost effectful send remains indeterminate. Power-on/Wake-on-LAN, absolute volume/mute assignment, app launch, press/hold/release, legacy TCP/PIN protocols and Frame Art Mode are outside this API.

Protocol provenance: Samsung's Smart View sender documentation, the upstream SamsungTVWS connection and remote command implementation, and Home Assistant's SamsungTV bridge. The implementation uses the existing ws transport; it does not copy those clients' filesystem persistence, TLS bypasses or optimistic state behavior.

Home Assistant Integration

import { HomeAssistantProtocol, HomeAssistantDiscovery } from '@ecobridge.xyz/devicemanager';

// Connect to Home Assistant
const ha = new HomeAssistantProtocol({
  host: 'homeassistant.local',
  port: 8123,
  token: 'your_long_lived_access_token',
});

await ha.connect();

// Get all entities
const entities = await ha.getStates();

// Control a light
await ha.callService(
  'light',
  'turn_on',
  { entity_id: 'light.living_room' },
  { brightness: 200 }
);

// Subscribe to state changes
await ha.subscribeToStateChanges();
ha.on('state:changed', (event) => {
  console.log(`${event.entity_id}: ${event.new_state?.state ?? 'removed'}`);
});

// Auto-discover Home Assistant instances via mDNS
const discovery = new HomeAssistantDiscovery();
discovery.on('instance:found', (instance) => {
  console.log(`Found HA at ${instance.host}:${instance.port}`);
});
await discovery.startMdnsDiscovery();

Helper Utilities

import {
  withRetry,
  isValidIp,
  cidrToIps,
  getLocalSubnet,
} from '@ecobridge.xyz/devicemanager';

// Retry with exponential backoff
const result = await withRetry(
  () => someFlakeyOperation(),
  { maxRetries: 3, baseDelay: 1000, multiplier: 2 }
);

// IP utilities
isValidIp('192.168.1.1');           // true
cidrToIps('192.168.1.0/30');        // ['192.168.1.1', '192.168.1.2']
getLocalSubnet();                    // '192.168.1.0/24'

🔍 Discovery Methods

The library supports multiple discovery mechanisms:

Method Protocol Use Case
discoverScanners() eSCL, SANE Find network scanners
discoverPrinters() IPP Find network printers
scanNetwork() All Comprehensive subnet scan
startDiscovery() mDNS, SSDP, Bambu UDP 2021/1990 Continuous auto-discovery

mDNS Service Types

import { SERVICE_TYPES } from '@ecobridge.xyz/devicemanager';

// Keys: ESCL, ESCL_SECURE, SANE, IPP, IPPS, PDL,
// AIRPLAY, RAOP, SONOS, GOOGLECAST, SPOTIFY,
// OCTOPRINT, MOONRAKER, PRUSALINK

SSDP Service Types

import { SSDP_SERVICE_TYPES } from '@ecobridge.xyz/devicemanager';

// Root, DLNA media renderer/server, Sonos ZonePlayer,
// UPnP basic device and internet gateway service types

SsdpDiscovery uses native UDP sockets bound to every non-internal IPv4 interface. Searches are repeated every 30 seconds, and search(serviceType) can send an additional focused M-SEARCH while discovery is running.

Automatic discovery accepts only absolute, uncredentialed HTTP(S) LOCATION URLs whose literal IPv4 host equals the UDP sender's address. Admission happens before adding or updating a device and before HTTP I/O. Description fetches omit credentials and reject every redirect, including redirects on the same host. Invalid advertisements leave an existing device unchanged. DNS-name advertisements are excluded: supporting them safely requires verified resolution and pinning the actual HTTP connection to the advertised sender, rather than a separate DNS check followed by an unpinned fetch. SSDP advertisements remain unauthenticated discovery hints; callers must validate the selected device before trusting or controlling it.

import { SsdpDiscovery, SSDP_SERVICE_TYPES } from '@ecobridge.xyz/devicemanager';

const discovery = new SsdpDiscovery();
const startup = new AbortController();
await discovery.start([SSDP_SERVICE_TYPES.SONOS_ZONE_PLAYER], {
  signal: startup.signal,
  timeoutMs: 10000,
});
// Running discovery belongs to this instance until stop() completes.
await discovery.stop();

start(serviceTypes?, options?) accepts ISsdpStartOptions: an optional startup signal and a total timeoutMs budget, including lifecycle queue time. The default budget is 10 seconds; explicit values must be integers from 1 to 60000 ms. Caller cancellation rejects with the signal's reason; expiration rejects with TimeoutError. Failed startup rejects after owned cleanup settles. After successful startup the caller signal is detached, so stop() owns running discovery teardown.

start() and stop() are idempotent. Calling stop() immediately cancels every startup already queued, pending bind and send operations, and description fetches. It waits for owned socket closure and pending work to settle. Startup requested after that stop can begin normally. Individual search sends have a 10-second deadline. Description requests retain a five-second deadline and accept at most 1 MiB, checked against advertised length and actual streamed bytes; unreachable or oversized descriptions leave the SSDP device without a description.

If socket closure fails, teardown attempts every socket and rejects with an AggregateError. The instance retains ownership of unclosed sockets and rejects another startup until an explicit stop() completes their closure. If startup and its cleanup both fail, the startup rejection includes both errors and identifies the startup error as its cause.

Runtime transport failures or throwing device:found/device:updated subscribers stop discovery and notify an attached error listener. The readonly lastError getter retains the latest runtime failure and resets on fresh startup. If an error listener also throws, lastError retains both failures in an AggregateError; owned cleanup still completes. Exceptions from started and stopped subscribers reject the corresponding lifecycle call after resource cleanup.

🎯 Feature Types

type TFeatureType =
  | 'scan'       // Document scanning
  | 'print'      // Document printing
  | 'print3d'    // 3D printing (Bambu Lab, Moonraker, OctoPrint, PrusaLink)
  | 'fax'        // Fax send/receive
  | 'copy'       // Copy (scan + print)
  | 'playback'   // Media playback
  | 'volume'     // Volume control
  | 'power'      // Power/UPS status
  | 'snmp'       // SNMP queries
  | 'dlna-render'// DLNA renderer
  | 'dlna-serve' // DLNA server
  | 'light'      // Smart lights
  | 'climate'    // HVAC/thermostats
  | 'sensor'     // Sensors
  | 'camera'     // Cameras
  | 'cover'      // Blinds, garage doors
  | 'switch'     // Smart switches
  | 'lock'       // Smart locks
  | 'fan'        // Fans
  ;

🔧 Advanced Usage

Custom Device Creation

Use the factory functions for creating devices with specific features:

import { createScanner, createPrinter, createSpeaker } from '@ecobridge.xyz/devicemanager';

// Create a scanner device manually
const scanner = createScanner({
  id: 'my-scanner',
  name: 'Office Scanner',
  address: '192.168.1.50',
  port: 80,
  protocol: 'escl',
  txtRecords: {},
});

// Create a printer device
const printer = createPrinter({
  id: 'my-printer',
  name: 'Office Printer',
  address: '192.168.1.51',
  port: 631,
  txtRecords: { rp: '/ipp/print' },
});

// Create a Sonos speaker with live UPnP playback and volume control attached
const speaker = createSpeaker({
  id: 'living-room-sonos',
  name: 'Living Room',
  address: '192.168.1.52',
  port: 1400,
  protocol: 'sonos',
});

Smart Home Factory Functions

The smart-home factories require a protocolClient that implements the corresponding feature interface, such as ILightProtocolClient for createSmartLight(). HomeAssistantProtocol exposes Home Assistant-specific service methods and entity state shapes, so it cannot be passed directly as that client; use a protocol adapter that implements the required interface.

Event Handling

DeviceManager, UniversalDevice, every Feature, and host-side providers remain Node EventEmitter instances and additionally expose events$. Each observable item contains the event name, its args, and emittedAt.

const manager = new DeviceManager();

const subscription = manager.events$.subscribe((event) => {
  console.log(event.name, event.args, event.emittedAt);
});

// Discovery events
manager.on('device:found', ({ device, featureType }) => {
  console.log(`Found ${device.name} with ${featureType} capability`);
});

manager.on('device:lost', (address) => {
  console.log(`Device at ${address} went offline`);
});

// Network scan progress
manager.on('network:progress', (progress) => {
  console.log(`Scanning: ${progress.percentage}% - Found ${progress.devicesFound} devices`);
});

// Device events
const device = manager.selectDevice({ address: '192.168.1.100' });
device.on('status:changed', ({ oldStatus, newStatus }) => {
  console.log(`Status: ${oldStatus} → ${newStatus}`);
});
device.on('feature:connected', (featureType) => {
  console.log(`Feature ${featureType} connected`);
});

subscription.unsubscribe();

Error Handling

The library uses a fail-fast approach with clear error messages:

try {
  // Throws if no device matches
  const device = manager.selectDevice({ address: '192.168.1.999' });
} catch (err) {
  // "No device found matching: {\"address\":\"192.168.1.999\"}"
}

try {
  // Throws if device doesn't have the feature
  const printFeature = device.selectFeature<PrintFeature>('print');
} catch (err) {
  // "Device 'Brother Scanner' does not have feature 'print'"
}

// Safe alternatives that don't throw
const devices = manager.getDevices({ address: '192.168.1.999' }); // []
const maybePrint = device.getFeature<PrintFeature>('print'); // undefined

📋 Requirements

  • Node.js 18+ (native fetch support required)
  • TypeScript 5.0+ (recommended)
  • Network access to target devices

🙏 Credits

Built with ❤️ using:

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

Nanoleaf selected-controller LAN REST

NanoleafProtocol reads a selected controller and supports power, absolute brightness, hue, saturation, colour temperature and selection of an existing advertised scene. The supported Light Panels API models are NL22, NL29, NL42, NL52, NL59, NL64 and NL69; these are the model identifiers confirmed in the vendor's Light Panels API documentation.

import { NanoleafProtocol } from '@ecobridge.xyz/devicemanager';

// Application adapter: retrieve an existing token from your encrypted store.
declare const readToken: (signal: AbortSignal) => Promise<string>;

const controller = new NanoleafProtocol({
  origin: 'http://192.168.1.120:16021',
  expectedSerialNumber: 'your-selected-controller-serial',
  expectedModel: 'NL29',
  getAuthToken: readToken,
  allowUnencryptedHttp: true,
});
try {
  const observed = await controller.readSnapshot();
  const receipt = await controller.setState({ power: true, brightness: 30 });
  console.log(observed.model, receipt.acceptance, receipt.snapshot.brightness?.value);
} finally {
  await controller.close();
}

The origin must be a literal private, link-local or loopback IPv4 address, without a path, credentials, query or fragment. Vendor HTTP puts the token in the request path, so plaintext requests require allowUnencryptedHttp: true; this does not authenticate the network peer cryptographically. HTTPS verifies certificate trust and the selected IP address; ca accepts an explicit PEM trust anchor. The protocol does not log tokens, retain the source options object, persist credentials or generate pairing tokens. getAuthToken(signal) is called once per operation and must honor cancellation; close() joins accepted callbacks even if one ignores cancellation.

Every control reads the controller's root resource first, verifies exact serial/model/manufacturer, and checks currently advertised numeric ranges or scene names. Inputs are finite absolute integers without clamping, transitions, increments or arbitrary animation payloads. Hue/saturation and colour-temperature commands cannot be mixed. Missing capabilities grant no control. Returned snapshots and nested ranges/scene arrays are frozen observations.

Each control is submitted once. acceptance: 'accepted' means the documented HTTP 204 acknowledgement arrived; snapshot is a fresh root readback and may differ from the requested state. It does not prove a physical effect. NanoleafProtocolError.effectOutcome distinguishes not-sent, documented rejected responses and indeterminate outcomes after submission, including failed readback, cancellation or disconnect. There are no redirects, retries or optimistic state changes.

Operations are serialized, bounded by maxPendingOperations (default 16), and use an admission-time timeoutMs (default 3 seconds, optionally supplied per call). Queued cancellation or deadline expiry never dispatches later. Responses are limited by maxResponseBytes (default 512 KiB) and must be uncompressed JSON. close() is terminal, aborts active and queued work, and joins owned HTTP requests and sockets. Concurrent and synchronous reentrant close calls share one promise, including while a credential-provider callback is draining.

Pairing/revocation, discovery, SSE, identify, panel/rhythm metadata, layout changes, animation creation, streaming, incremental/transition controls, IPv6, unconfirmed model identifiers and Matter Essentials-specific contracts are outside this API. Firmware with malformed capability ranges, including the vendor-documented Light Panels 1.5.0 temperature-range defect, is rejected rather than repaired locally. Tests use local HTTP/TLS fixtures; no physical controller has been verified.

WiZ Selected-Device UDP Protocol

WizProtocol talks to one explicitly selected WiZ LAN device. Configure its private, link-local or loopback IPv4 address and unicast MAC; the default destination port is 38899. It does not discover devices, register push subscriptions, provision Wi-Fi or send broadcast packets.

import { WizProtocol, WizProtocolError } from '@ecobridge.xyz/devicemanager';

const light = new WizProtocol({
  host: '192.168.1.42',
  macAddress: 'a8bb50aabbcc',
  timeoutMs: 5000,
});
try {
  const info = await light.getDeviceInfo();
  if (info.capabilities.power) {
    const result = await light.setPower(true);
    console.log(result.outcome, result.observedState.power);
  }
} catch (error) {
  if (error instanceof WizProtocolError) {
    console.log(error.code, error.effectOutcome);
  } else {
    throw error;
  }
} finally {
  await light.close();
}

Every read and control obtains fresh getSystemConfig and getPilot replies. Both must report the configured MAC. Capabilities come from the actual documented module identifier and pilot fields: single-head RGB/TW/DW lights and sockets admit only their supported subset. Unknown, fan and dual-head module identifiers remain read-only; caller-supplied capability assertions are not accepted. The controls are explicit setPower(boolean), setBrightness(1..100) and setRgb({ red, green, blue }) with each channel in 0..255. They add no implicit state, scene or transition parameters. Firmware may itself change lighting mode or power when handling RGB or brightness. No toggle, scenes, effects, temperature, fan or dual-head controls are provided.

An operation's deadline includes queue time, preflight, its single write and readback. signal and timeoutMs can be supplied per operation. The FIFO queue defaults to 16 admitted operations; overflow rejects before sending. close() is terminal, idempotent, cancels all admitted work and waits for the active UDP socket to close.

WizProtocolError.effectOutcome reports not-sent, indeterminate, rejected or accepted. A submitted write with a lost or malformed acknowledgement is indeterminate and is never automatically retransmitted. A valid negative reply is rejected. success: true means firmware acceptance, not physical actuation: the returned observedState is a separate subsequent read and may differ from the requested value. If readback fails after acknowledgement, the error retains the accepted outcome; callers must not interpret that error as an unsent write.

WiZ UDP has no cryptographic authentication. Address/port checks and fresh matching MAC reports establish consistency with the selected LAN endpoint; they cannot establish ownership, authorization, physical identity or protection from a malicious LAN peer. Replies are bound to the exact requested method and a distinct ephemeral local port for every exchange. The client remembers up to maxExchanges ports (default 4096) and rejects exhaustion/reuse rather than admit an older exchange. This is correlation, not cryptographic anti-replay. JSON packets have byte, nesting, property and value bounds; duplicate properties, invalid UTF-8 and malformed known fields are rejected. Unknown bounded firmware fields are ignored.

Protocol references: the maintained pywizlight wire implementation, its documented module classification, and the independently implemented wizlight protocol types. Verification uses real loopback UDP fixtures; no physical WiZ hardware was accessed.

Selected Denon and Marantz receivers

DenonProtocol implements the documented TCP control profiles for Denon AVR-1913/AVR-2113CI and Marantz NR1605/SR5009. Supply a selected private IPv4 address, an explicitly selected UPnP description URL at that same address, the exact UDN and model, and an owner volume ceiling. No discovery, DNS, guessed HTTP path, redirect, arbitrary command, or automatic retry occurs.

const receiver = new DenonProtocol({
  address: '192.168.1.42',
  descriptionUrl: selectedDescriptionUrl,
  expectedUdn: selectedReceiverUdn,
  expectedModel: 'AVR-1913',
  maximumVolumeDb: -10,
});
try {
  const initial = await receiver.start();
  const result = await receiver.setVolumeDb(-30.5);
  console.log(initial.input, result.outcome, result.observation.volumeDb);
} finally {
  await receiver.close();
}

start() and readState() fetch the current UPnP root identity and observe receiver power plus main-zone mute, volume and input over TCP. All four observations must be valid; unavailable properties do not become guessed capabilities or defaults. Before every effect, the descriptor must still match the configured UDN, manufacturer and documented model, and the current settings must be readable. The descriptor and legacy TCP channel are plaintext, unauthenticated LAN evidence: they do not cryptographically authenticate a peer. The URL must come from an explicitly selected device description; the adapter does not assume that any particular vendor HTTP path exists.

Controls are setPower(boolean), setMute(boolean), setVolumeDb(number) and setInput(string). Volume uses absolute half-decibel steps from -79.5 to +18 dB, restricted by the required maximumVolumeDb. Minimum-volume sentinel values are reported as null, rather than a fabricated dB reading. Input selection intersects the documented physical-source command subset with sources already observed on this same receiver; observedInputs exposes that evidence. Model names alone do not admit unobserved inputs. Streaming shortcuts, increments, toggles, surround modes, extra zones and HEOS/account operations are absent.

Each control is written once. IDenonEffectResult.dispatch reports the local TCP write, while outcome reports whether subsequent receiver observations match the requested target. Events and query responses have the same vendor format, with no distinct acknowledgement or request ID; a matching observation does not prove command causality, electrical output or audible playback. A non-matching observation produces indeterminate, and a timeout, disconnect, abort or close after dispatch throws DenonProtocolError with effectOutcome: 'indeterminate'. Pre-dispatch errors use 'not-sent'.

A bounded FIFO includes active and waiting operations. The absolute operation deadline begins at queue admission and includes identity retrieval, TCP queries, the effect and readback. Power-on preserves the vendor-required one-second quiet period before any subsequent command, including a later caller after cancellation. Every caller operation owns a fresh connection and drains it before the next operation. close() is terminal, cancels active and queued operations, joins the owned HTTP/TCP resources and clears input evidence; failed startup also leaves a terminal object. signal and timeoutMs are available on all operations. Receivers configured to disable network access in standby cannot be controlled while unreachable; no wake or retry is inferred.

The tested profiles come from Denon's AVR-2113CI/AVR-1913 protocol, Marantz's 2014 protocol workbook and the UPnP Device Architecture. Verification uses real local HTTP/TCP fixtures. Physical receiver firmware, actual descriptor spelling and availability on the four models have not been verified against hardware; other models are explicitly unsupported.

Selected Onkyo receiver over eISCP

OnkyoProtocol controls one explicitly selected Onkyo main zone over a single TCP connection. It accepts private, IPv4 link-local and loopback unicast addresses, with TCP port 60128 by default. Construction opens no sockets. The first operation opens the connection; a failed startup, active cancellation, deadline or transport failure is terminal. close() rejects waiting operations and joins the active operation and socket closure. There is no reconnect or control retry.

import { OnkyoProtocol, probeOnkyo } from '@ecobridge.xyz/devicemanager';

const advertisement = await probeOnkyo({ address: '192.168.1.40' });
// Compare the advertised model and MAC with the selected receiver before trusting configuration.
const receiver = new OnkyoProtocol({
  address: advertisement.address,
  port: advertisement.port,
  expectedMacAddress: '0009B0D24B75',
  expectedModel: 'TX-NR609',
});
try {
  const state = await receiver.inspect();
  const powerObservation = await receiver.setPower(true);
  await receiver.setMuted(false);
  if (state.receiver.integerVolumeMaximum !== undefined) {
    await receiver.setVolume(20); // absolute integer receiver units, never percent or an increment
  }
  if (state.receiver.inputs.some((input) => input.id === '10')) {
    await receiver.setInput('10');
  }
} finally {
  await receiver.close();
}

probeOnkyo sends one unicast UDP query to the selected address and validates the response source, frame, model, MAC and advertised TCP port. It does not broadcast or select the first discovered receiver. eISCP is an unauthenticated LAN protocol: a reported MAC identifies the selected receiver but is not cryptographic peer authentication.

Every operation queries receiver information and checks the exact configured MAC and model plus an enabled main zone. Power and mute controls require valid current state reports. Input controls accept only enabled main-zone selectors advertised by that receiver. Absolute volume writes require an explicit integer volstep="1", a positive advertised maximum no greater than 100, and a consistent volume report. Unknown, half-step or absent volume contracts remain read-only; volumeRaw reports wire units without guessed scaling. Unavailable state fields are absent from inspect() results. Inspection queries fields sequentially; its observedAt records completion, rather than an atomic snapshot of every field.

eISCP status messages carry no request identifier and can be unsolicited. A successful control returns an immutable IOnkyoControlObservation containing the requested value and a matching receiver state report received after dispatch. It explicitly sets commandAcknowledged: false and causalityConfirmed: false. This is neither a correlated acknowledgement nor proof that the call caused a physical change. A nonmatching status does not count as success. An error after dispatch carries effectOutcome: 'indeterminate'; validation and failures before dispatch carry 'not-sent'.

Operations use a bounded FIFO (default 16 admitted calls), a deadline measured from admission (default 5000 ms), caller signal, bounded eISCP v1 frames (default 65536 bytes), partial-frame deadlines and a status-traffic bound. Commands are paced more than 50 ms apart. Queued cancellation removes only that queued call; active cancellation terminates the connection so late responses cannot satisfy another call. No arbitrary raw command API, other zones, relative/toggle controls, inferred model capability, or physical-device certification is provided.

Selected Daikin BRP069 local climate control

DaikinProtocol communicates with a selected legacy BRP069 comma-separated HTTP controller. It requires an explicit private IPv4 address and the expected adapter MAC. Loopback is also accepted for local fixtures. It performs no discovery, DNS lookup, login, or cloud fallback. These LAN messages are unauthenticated: the MAC check identifies the observed adapter but does not authenticate the network peer.

import { DaikinProtocol, DaikinProtocolError } from '@ecobridge.xyz/devicemanager';

const airConditioner = new DaikinProtocol({
  address: '192.168.1.40',
  expectedMac: '409F38D107AC',
  timeoutMs: 5000,
});

try {
  const state = await airConditioner.readState();
  if (state.capabilities.targetTemperature) {
    const result = await airConditioner.setControl({ targetTemperatureCelsius: 22 });
    console.log(result.acknowledgment, result.effectOutcome, result.observed);
  }
  await airConditioner.setControl({ power: false });
} catch (error) {
  if (error instanceof DaikinProtocolError && error.effectOutcome === 'indeterminate') {
    // Inspect fresh state before deciding on another action; the write may have happened.
  }
  throw error;
} finally {
  await airConditioner.close();
}

Every read and write preflight freshly observes /common/basic_info, /aircon/get_model_info, and /aircon/get_control_info. Every write preserves the observed mandatory mode, stemp, shum, f_rate, and f_dir, changing only the explicitly requested power or target temperature. It never implicitly powers on when changing a target, switches operating modes, clamps a target, or replaces missing fields with desired state. Both fan control advertisement flags and the complete combined-direction control fields are required for writes. Split swing direction models and incomplete control families remain read only.

Target changes are a conservative integer Celsius subset: 18–33 in the current cool mode or 10–31 in the current heat mode. They additionally require observed temperature support, the recorded standard temp_rng=0 family, and an actual numeric current target. Missing or unfamiliar range encodings, or additional model-specific range limits, prevent target writes. hmlmt_l=10 or 10.0 is the one recorded standard lower-limit value admitted. Auto, dry, and fan targets, mode changes, humidity controls, fan speed changes, swing changes, schedules, AirBase, SkyFi, BRP084, registered HTTPS controllers, and cloud APIs are outside this client's subset. Read state can still report auto, dry, or fan modes and non-numeric target sentinels without inventing a target.

setControl() sends the setter once, then independently reads identity, model, and state again. ret=OK supplies acknowledgment: 'accepted'; only matching observed requested values produce effectOutcome: 'confirmed'. A target also requires the same observed mode. A different readback remains indeterminate. Confirmation is the state at that observation, not a guarantee of continued state or exclusive ownership of the device. The controller has no compare-and-set protocol; another controller can change state between preflight and dispatch.

DaikinProtocolError carries a stable code and effectOutcome: not-sent before setter dispatch, rejected for a valid explicit setter error, and indeterminate after a setter might have been sent. Failed readback after an accepted setter is indeterminate. Error messages omit raw responses and account fields. There are no automatic retries, redirects, or optimistic state updates.

Operations run in a bounded FIFO. The admission deadline covers queue time, preflight, setter, and readback. Each operation accepts { signal }. HTTP headers, body bytes, field counts, and values are bounded; compressed, malformed, duplicate-field, and invalid UTF-8 responses are rejected. close() is terminal, idempotent, cancels queued and active operations, and waits for owned HTTP transports to close.

Protocol sources: the primary BRP069 implementation, its recorded controller responses, and the controller API reference. Real HTTP fixtures cover this implementation; physical Daikin hardware has not been exercised.

BroadlinkProtocol supports the documented RM mini 3 type 0x2737 and RM4 mini 0x51da command envelopes. Configure an exact private IPv4 address, six-byte unicast MAC and device type. Loopback is admitted for local fixtures; host addresses ending in .0 or .255, hostnames, public addresses, discovery broadcasts and every other device type are rejected. An optional localAddress selects the local private IPv4 interface. The default device port is 80.

import { BroadlinkProtocol } from '@ecobridge.xyz/devicemanager';

async function transmitApprovedIr(packet: Uint8Array): Promise<void> {
  const remote = new BroadlinkProtocol({
    host: '192.168.1.55',
    macAddress: 'a0:43:b0:54:44:6e',
    deviceType: 0x2737,
  });
  try {
    await remote.start();
    const firmware = await remote.readFirmware();
    const receipt = await remote.sendIr(packet);
    // Firmware is observed. The receipt confirms bridge acceptance only.
    // receipt.physicalEffectConfirmed is always false.
  } finally {
    await remote.close();
  }
}

Startup sends one direct unicast identity query and checks the configured MAC/type before one AES-128-CBC session exchange. Each effect repeats the identity query. readFirmware() returns only the bridge's observed numeric firmware version; there is no appliance power, volume, temperature or playback state inferred from an IR command. sendIr() takes the device-native packet, 6–1024 bytes, with type 0x26, repeat byte zero, exact little-endian body length and complete positive ON/OFF pulse pairs. Long pulses retain their documented leading-zero, big-endian encoding. The facade copies and validates those bytes before queueing. It does not generate waveforms or assign semantic names to arbitrary codes.

Operations run in a FIFO with 32 admitted calls by default (maximum 128). One 5-second deadline (maximum 60 seconds) includes queue time, UDP bind and all exchanges. start, readFirmware and sendIr accept {signal}. A cancelled or expired queued call cannot emit an effect. Every exchange uses a fresh local UDP port, checked against earlier ports in the instance, and validates the exact remote IP/port. Encrypted responses must echo their counter, match MAC/type and the documented response command, and pass frame/payload checksums and AES block bounds. Wrong counters are ignored. Firmware omitting these correlation or checksum fields is unsupported; it fails instead of accepting an ambiguous reply. Counter exhaustion or local-port reuse reports CORRELATION_EXHAUSTED before another datagram. Choose a fresh instance explicitly; the facade does not retry, reconnect, reauthenticate or retransmit effects.

A valid acknowledgement returns {outcome: 'accepted', physicalEffectConfirmed: false}. The bridge cannot prove that an appliance received or acted on its IR transmission. Errors expose a stable BroadlinkProtocolError.code and effectOutcome: not-sent, indeterminate, rejected or accepted. An effect handed to UDP without a valid acknowledgement remains indeterminate, including cancellation, timeout or corrupted replies. An expired/rejected session terminates the instance and its queued calls; it never replays the failed action. close() is terminal and idempotent, and joins queued work, UDP send callbacks and socket close before resolving.

The default key, fixed IV and additive checksums are properties of this LAN protocol. They provide neither authenticated encryption nor cryptographic device identity or user authorization. MAC/IP matching prevents accidental device misbinding, not an active LAN attacker. Use a trusted, isolated local network; a higher layer must enforce home access and action authorization. Session keys and authentication plaintext are private and never returned or logged. The facade clears its owned byte buffers on success, failure and close. JavaScript/runtime and OpenSSL copies remain outside its control; physical memory zeroization is not claimed. The protocol owns no credential persistence, files, cloud accounts, provisioning, learning, RF operations or periodic watchdog work.

Framing and model provenance: python-broadlink's protocol notes, its device and model registry, and the maintained 1.0.6 transport and remote envelopes. Response command values are documented in the Vera Broadlink protocol implementation. The implementation uses existing Node crypto and UDP imports. Encrypted local UDP fixtures cover framing, corruption, exact identity/counter/source filtering, acceptance versus unknown physical effect, cancellation and terminal drain. Real hardware and firmware compatibility have not been verified.

copyBroadlinkIrPacket(packet) validates the selected native IR format and returns an owned copy before configuration persistence. The caller owns that returned buffer and must clear it when finished; no pulse conversion or repeat inference occurs.

Selected Pioneer SC-95 receivers

PioneerProtocol implements the SC-95/CUXJ main-zone commands documented in the Pioneer FY16 control sheet. It reads current power, mute, raw volume and input, and supports absolute power, mute, volume and selection of previously observed physical sources. Other Pioneer models and eISCP receiver families have no admitted profile here.

import { PioneerProtocol } from '@ecobridge.xyz/devicemanager';

declare const selectedDescriptionUrl: string;
declare const selectedReceiverUdn: string;
const receiver = new PioneerProtocol({
  address: '192.168.1.80',
  descriptionUrl: selectedDescriptionUrl,
  expectedUdn: selectedReceiverUdn,
  expectedModel: 'SC-95',
  allowUnencryptedLan: true,
  maximumVolumeRaw: 121, // Explicit owner ceiling in receiver protocol units.
});
try {
  const state = await receiver.inspect();
  const result = await receiver.setMuted(true);
  console.log(state.inputId, result.observation.muted);
} finally {
  await receiver.close();
}

Select a private or loopback literal IPv4 and an existing UPnP description URL at that same address. Supply the exact selected UUID and model. Each operation refreshes the description and validates its UUID, Pioneer manufacturer and model before TCP commands. HTTP redirects, credentials, query strings, malformed XML and oversized descriptions are refused. allowUnencryptedLan: true acknowledges plaintext HTTP/TCP: identity observations establish consistency with a selected LAN endpoint, and do not cryptographically authenticate the TCP peer. No identity, credential or runtime application state is persisted.

The SC-95 manual, printed page 58 documents configurable IP-control ports and recommends port 23 or 49152–65535. The default is 23; configure the selected port explicitly when needed. Network Standby must already be enabled for control while the receiver is in standby. The adapter does not change that setting or automatically wake/reconnect/retry.

Volume is an absolute integer raw level from 0 through 185, limited by the required maximumVolumeRaw owner ceiling. 0 retains the vendor minimum sentinel. The vendor sheet's numeric dB example conflicts with its parameter table, so the API supplies no guessed dB or percentage conversion. Input selection grants no capability from a model label alone: observedInputs contains only documented SC-95 physical input IDs actually reported during this session; setInput() requires one of them. Streaming sources, cyclic selectors, zone controls, toggles and arbitrary commands are excluded.

One bounded FIFO owns one persistent connection and spaces commands by at least 100 ms. Deadlines begin when an operation is admitted, including its queue wait. Queued cancellation removes that operation; active cancellation, timeout, identity mismatch, receiver errors, malformed responses or a transport failure close the session terminally. close() is idempotent, cancels queued and active work, joins cleanup and permanently refuses new operations.

Each effect is submitted once. dispatch: 'written' reports local submission. outcome: 'observed-target' means subsequent receiver reports matched the target; a different readback is indeterminate. Status reports can be unsolicited and carry no unique request ID, so every result explicitly has commandAcknowledged: false and causalityConfirmed: false. Errors after dispatch carry PioneerProtocolError.effectOutcome: 'indeterminate'; earlier errors carry not-sent. Neither receiver readback nor a successful fixture proves a physical effect. Tests use local TCP/HTTP fixtures; hardware behavior has not been verified.

Selected Panasonic VIERA plaintext NRC

PanasonicProtocol controls one explicitly selected Panasonic UPnP NRC device. Supply its private IPv4 address, selected description URL, exact UDN and modelName. start() checks the current root device and service description. Every sendKey() and readProfile() fetches both descriptions again; metadata is never an enduring authorization to control a later device at the same address.

import { PanasonicProtocol } from '@ecobridge.xyz/devicemanager';

const tv = new PanasonicProtocol({
  address: '192.168.1.52',
  descriptionUrl: selectedDescriptionUrl,
  expectedUdn: selectedUdn,
  expectedModelName: selectedModelName,
});

try {
  const profile = await tv.start();
  if (profile.keys.includes('up')) {
    const receipt = await tv.sendKey('up');
    console.log(receipt.acknowledgement); // 'soap-response'
  }
} finally {
  await tv.close();
}

The fixed keys are up, down, left, right, enter, back, exit, menu, home, volume-up, volume-down and mute-toggle. An advertised SCPD allowed value list restricts this subset further. The protocol requires the current X_SendKey action and its single string X_KeyEvent input; callers cannot pass arbitrary key codes, SOAP actions or XML.

Receipts mean the selected service returned a matching successful SOAP response. They do not establish TV state, sound level, a known mute setting or a physical effect. Volume buttons provide no volume ceiling. Errors expose code and effectOutcome: not-sent before the control boundary, rejected for a validated SOAP fault, and indeterminate for failures after the command may have been sent. Commands are issued once and never automatically replayed.

Operations accept { signal, timeoutMs }; the total default deadline is five seconds across descriptions and the command. Responses default to 64 KiB. Only one operation runs at a time; concurrent calls fail with capacity. close() aborts and drains owned requests and permanently closes the instance. Descriptions, URLBase, SCPD and controls stay on the selected HTTP origin; redirects, hostnames, public addresses and credential-bearing URLs are refused.

This foundation admits only the observed plaintext NRC contract. Advertising PIN/authentication/encryption actions produces pairing-required before control. Encrypted sessions, PIN pairing, credential management, renderer volume/mute state, power/wake, input selection, apps, automatic discovery and platform-specific Android/Google/Fire TV flows are outside this API.

Panasonic's TV Remote 2 setup describes same-network remote use and model-specific enablement. The wire subset is corroborated by the primary panasonic-viera implementation and node-panasonic-viera implementation. A Panasonic-authored SOAP wire specification was not found. Verification used local HTTP fixtures; no physical TV or complete model family was qualified. UPnP identity is unauthenticated device metadata and assumes a trusted LAN; it does not authenticate hardware against a malicious network peer.

Selected Yamaha receiver

YamahaProtocol controls the main zone of an explicitly selected RX-V485 using Yamaha Extended Control v1. Supply a literal private, link-local or loopback IPv4 HTTP origin, the expected 12-character device_id, the model, allowUnencryptedHttp: true, and a mandatory integer maxVolume ceiling within the receiver's reported raw volume range. Requested volume must match the freshly reported range_step grid and stay within both the owner ceiling and receiver's max_volume setting; no dB or percentage conversion is inferred.

import { YamahaProtocol } from '@ecobridge.xyz/devicemanager';

const receiver = new YamahaProtocol({
  origin: 'http://192.168.1.50',
  expectedDeviceId: '00A0DE123456', // Replace with the selected receiver's device_id.
  expectedModel: 'RX-V485',
  allowUnencryptedHttp: true,
  maxVolume: 60, // Choose an owner ceiling within this receiver's reported raw volume range.
});
try {
  const state = await receiver.start();
  await receiver.setVolume(40);
  await receiver.setInput(state.capabilities.inputs[0]);
  await receiver.setPower(true);
  await receiver.setMute(false);
} finally {
  await receiver.close();
}

Every operation refreshes system/getDeviceInfo, system/getFeatures, and main/getStatus. Every command repeats that observation before dispatch and attempts the same full readback afterwards. Power-on, unmute and input selection require an observed volume at or below the owner ceiling; their readback cannot report observed-target above that ceiling. Input selection accepts only fresh advertised identifiers and sends mode=autoplay_disabled.

Commands are absolute, explicit, serialized and never replayed. A result separates dispatch: 'acknowledged' from outcome: 'observed-target' | 'indeterminate'. Lost or malformed acknowledgements, cancellation, timeout, identity changes and readback failures after dispatch throw YamahaProtocolError with effectOutcome: 'indeterminate'; an observed state after a lost acknowledgement does not establish successful dispatch. A deadline or cancellation prevents further readback requests. Inspect state deliberately before another command. Queue capacity, response bytes, headers and operation deadlines are bounded; deadlines begin on admission and include queue wait. close() cancels active/queued work and awaits owned HTTP request drain.

Yamaha's LAN API is plaintext and does not authenticate the receiver. Matching device_id/model does not protect against a malicious LAN participant. No DNS lookup, discovery, redirect following, proxy, UDP subscription, linking, playback, other zones or other Yamaha models are admitted by this profile. Independent controllers or receiver startup settings may change volume between observations; this API cannot guarantee a physical volume limit against those changes.

Evidence: Yamaha RX-V485 product information, Yamaha-authored Extended Control Basic Rev2 specification mirror, pp. 7–13, 48–54. The stable device_id is documented from API 1.17 and v1 backwards compatibility is documented. Rev2's overall preface addresses firmware supporting API 2.00 onward; this profile uses the earlier v1 fields and rejects receivers that omit any required identity/capability/status field. API 1.17 support is validated against the documented fields and synthetic fixtures; no physical receiver was tested.

Selected Modbus TCP devices

ModbusProtocol owns one selected plaintext TCP connection. Configure a literal private, link-local or loopback IPv4 address, port (default502), explicit unit1–247 or direct TCP unit255, expected vendor/product and allowUnencryptedLan: true. An optional expectedRevision also pins the revision. Each operation freshly reads the three mandatory basic device-identification objects through43/14; bounded stream pagination is supported. Missing, malformed or mismatching identity prevents the requested read/write. These strings and the endpoint/unit select a device family on the LAN; they provide no cryptographic authentication or unique serial identity. Modbus Security/TLS is outside this API.

inspect() reports the observed identity. readCoils() and readDiscreteInputs() return up to2000 bits; readHoldingRegisters() and readInputRegisters() return up to125 unsigned16-bit register values. Addresses are zero-based protocol addresses, without 40001 notation conversion, scaling, signed interpretation or invented register meanings. Results and value arrays are immutable snapshots.

Writes are disabled by default. writableCoils explicitly permits selected addresses; writableRegisters permits addresses with inclusive raw minimum/maximum values. The constructor copies this policy. writeSingleCoil() emits the specified absolute boolean state and writeSingleRegister() the specified raw integer once. A successful IModbusWriteReceipt confirms the correlated Modbus echo and always reports physicalEffectConfirmed: false. The device's actual register map, safety constraints and physical outcome remain caller-owned.

Every operation accepts { signal, timeoutMs }. Deadlines include queue time. The bounded FIFO permits one exchange at a time, with a default16 admitted operations (maximum128). Queued cancellation removes that operation. Active cancellation, timeout, malformed traffic, identity mismatch or transport failure closes the client permanently and drains admitted work. Await close() for socket teardown. Server exceptions retain their code and cause no automatic retry. Errors distinguish not-sent from conservative indeterminate after write dispatch; reconstruct a client only through an explicit owner decision, without replaying an uncertain write.

MBAP frames are bounded to260 bytes. Transaction IDs are not reused: after65535 requests the connection ends, requiring explicit creation of a new client. Stale transaction responses are discarded with a32-frame operation bound; selected unit/function and echoed write address/value must match. No discovery, arbitrary function/PDU, serial RTU, broadcasts, multi-register writes, register-map guessing or hardware qualification is claimed.

Wire references: Modbus Application Protocol V1.1b3, sections6.1–6.6 and6.21, and Messaging Implementation Guide V1.0b, sections3.1.3 and4.4.1.3. Physical hardware remains unverified.

Selected Kodi HTTP JSON-RPC foundation

KodiProtocol observes a caller-selected literal private, link-local or loopback IPv4 HTTP origin through /jsonrpc. Select the expected application name and major version and explicitly accept plaintext with allowUnencryptedHttp: true. The documented JSON-RPC API13 family is supported when the live introspection schema matches the selected methods. Enable Kodi's webserver separately. No discovery or pairing runs.

validateKodiOrigin(value) is the public, pure selected-origin validator used by KodiProtocol. It returns the normalized HTTP origin for a literal private, link-local or loopback unicast IPv4 peer, and throws KodiProtocolError with code: 'invalid-options' for unsupported input. Discovery callers can share this exact policy without constructing a protocol; validation performs no DNS lookup, request, credential callback or peer-authentication check.

readSnapshot() freshly checks the API version, transport-filtered Application introspection, current RPC permissions and Application properties. It returns application name/version, volume, muted state and admitted control capabilities. Kodi's application properties provide no unique device identifier; snapshots explicitly report selection: 'configured-http-endpoint' and uniqueDeviceIdentityVerified: false. Plain HTTP and Basic credentials provide no cryptographic server identity.

Controls default to disabled. Supplying control: { trustSelectedEndpoint: true, maximumVolume: 30 } explicitly trusts this selected endpoint and admits only setVolume(integer) and setMuted(boolean). Every control checks fresh schema, permissions and application selection before its single dispatch. Volume must lie in both the advertised integer range and the copied owner ceiling. Unmuting is refused when the observed volume exceeds that ceiling. Concurrent controllers can change state between observations and dispatch; the ceiling does not bound other controllers or physical audio output.

Control receipts contain the actual correlated RPC result and a fresh snapshot, with physicalEffectConfirmed: false. Requested, reported and subsequently observed state may differ. A failure after dispatch has effectOutcome: 'indeterminate'; it is never retried. Increment/decrement, toggle, arbitrary RPC, player/media/library/filesystem/power commands and notification transports are outside this foundation.

Optional HTTP Basic credentials come from getBasicCredentials(signal). The callback and secret storage remain caller-owned; it must honor cancellation. Printable ASCII credentials are supported, with a username without colon. The provider is invoked once per admitted operation, never for rejected admission, and its reference is private and cleared after terminal drainage. No credentials or remote RPC error messages/data appear in protocol diagnostics.

Operations are serialized in a bounded FIFO: default 16 admitted operations, configurable 1–128 including the active operation. The default deadline is 5 seconds, configurable 1–60,000 milliseconds, and starts at admission. The default response limit is 65,536 bytes, configurable 1,024–1,048,576. Queued cancellation or expiry removes that entry; active cancellation, active expiry, HTTP/transport failure, malformed response or selection mismatch closes the client terminally and rejects queued work. await close() joins admitted callbacks and HTTP request/socket teardown. Caller-owned credential resources remain caller-owned. Create a new client explicitly after terminal closure.

Verified with real loopback HTTP fixtures and resource-capped qualification. No Kodi hardware or production installation was tested.

Protocol references: Kodi HTTP API, webserver authentication, released Omega method schema, released Omega type schema, runtime introspection serialization, permission casing and JSON-RPC 2.0.

Selected Sony BRAVIA REST display

SonyBraviaProtocol implements the documented BRAVIA Professional version 1.0 REST family on a selected private IPv4 address. Supply the exact observed model and active-network MAC, a caller-owned private-level PSK, explicit consent to unencrypted LAN HTTP, and a speaker volume ceiling in raw integer levels. expectedSerial adds an exact serial check when the display reports one. Missing or unusable active-network MACs cannot establish selection.

import { SonyBraviaProtocol } from '@ecobridge.xyz/devicemanager';

async function setSelectedSpeakerVolume(privateLevelPsk: string) {
  const display = new SonyBraviaProtocol({
    address: '192.168.1.50',
    expectedModel: 'FW-55BZ35F',
    expectedMacAddress: '04:5D:4B:AD:6D:E1',
    psk: privateLevelPsk,
    allowUnencryptedLan: true,
    volumeLimitRaw: 40,
  });
  try {
    const before = await display.readState();
    const result = await display.setVolumeRaw(20);
    return { before, result };
  } finally {
    await display.close();
  }
}

Every operation refreshes supported APIs and selected identity. readState() includes advertised power and speaker observations. Separate setPower(boolean), setMute(boolean) and setVolumeRaw(integer) calls perform fresh preflight and independent readback for their relevant state. Sony's mute method has no output target; setMute requests device-wide mute and observes speaker mute. Volume explicitly targets speaker, requires the current advertised range and caller ceiling, and never converts a percentage or clamps a value. The caller ceiling is 0..100 raw levels and does not change the reported device range.

Control results distinguish commandAcknowledged: true from observation, which reports whether the independent state matches the requested value. causalityConfirmed remains false. Errors expose a stable code, effectOutcome (not-sent, rejected, or indeterminate) and commandAcknowledged. A timeout or changed identity after an acknowledgment remains indeterminate and preserves that acknowledgment. No response text or caller abort reason is exposed. The facade never logs, returns or persists the PSK; the caller owns credential storage.

Only freshly advertised version 1.0 HTTP methods are admitted. There is no public arbitrary RPC, IR command, media, pairing, relative/toggle control, discovery, wake fallback, retry or replay. A suspended display may stop responding over HTTP; power-on requires the documented reachable Sleep/Remote start configuration. Reported state does not prove physical output or causality. See Sony's API reference and REST structure and PSK access levels.

timeoutMs defaults to 5000 and is bounded to 1..30000; an operation override has the same range. Its deadline starts at FIFO admission and covers preflight, the single control dispatch, readback and transport drain. Admission defaults to 16 pending operations, configurable to 1..128; responses default to 32768 bytes, configurable to 1024..65536. Native requests and sockets are owned through close. close() is terminal, idempotent, cancels admitted work and joins all transport drains.

Disposable real HTTP fixtures cover selection, API/range changes, correlation, malformed/oversized/truncated responses, acknowledgment versus observation, cancellation, FIFO capacity/deadlines and terminal drain. A separate bounded load case is enabled with SONYBRAVIA_LOAD=1 and must run in Docker capped to two CPUs and 4 GiB; these tests make no hardware claim.

Selected Bose SoundTouch HTTP/XML

BoseSoundTouchProtocol owns one caller-selected private IPv4 SoundTouch endpoint. Pass the exact uppercase unicast deviceID and exact type previously observed from /info, plus explicit plaintext admission. It verifies fresh /info, /capabilities and /volume responses before every control. Returned identity retains the documented type; the API supplies no separate model field. Account UUIDs, serial numbers and cloud URLs are omitted.

import { BoseSoundTouchProtocol } from '@ecobridge.xyz/devicemanager';

const speaker = new BoseSoundTouchProtocol({
  address: '192.168.1.90',
  expectedDeviceId: 'D05FB8A9591C', // Replace with the selected speaker's exact /info deviceID.
  expectedType: 'SoundTouch 20',  // Replace with its exact /info type.
  allowPlaintext: true,
  controlPolicy: { maximumVolume: 40 }, // Explicit owner ceiling; omission is read-only.
});
try {
  const current = await speaker.readState();
  const volume = await speaker.setControl({ volume: 25 });
  const mute = await speaker.setControl({ muted: true });
} finally {
  await speaker.close();
}

Volume is an absolute integer from 0 to 100. Bose documents that an increase may unmute. Mute uses the documented complete volume payload and requires the freshly observed target and actual volume to agree; capabilities.mute reflects that admission condition. A combined muted: true request with an increasing volume is rejected before POST. No synthesized empty-volume mute request is sent. Other controllers can race between observation and dispatch; this API provides no compare-and-set.

The documented response is status STRING without a defined success vocabulary. A control result reports acknowledgment: 'status-received', the bounded status, observation: 'matching-device-state' | 'mismatched-device-state', and causalityConfirmed: false. Matching readback is an observation, not proof that this request caused a physical or acoustic effect. Failures after POST carry effectOutcome: 'indeterminate'; pre-dispatch failures carry 'not-sent'. No automatic retries or replay occur.

/capabilities declarations are returned as frozen data; their URLs are never followed. capabilities.standby is always false because the published API does not document an absolute non-toggle standby command. Remote keys, source selection, presets, media URLs, discovery and cloud operations are outside this subset. Connection failures remain explicit.

Each admitted operation has one deadline (default 5000 ms) including FIFO waiting and all exchanges. Defaults bound pending operations to 16 and each response to 16384 bytes; supported constructor overrides remain bounded. XML schema, UTF-8, tag count/depth and entities are validated. Cancellation accepts { signal }. close() is terminal and idempotent: it cancels active/queued operations and waits for every owned HTTP close before settling. The caller owns protocol cleanup.

Contract sources: Bose SoundTouch Web API v1.1, April 1, 2026, sections 10–12.16. Bose's official cloud notice confirms cloud support ended May 6, 2026 and describes remaining local functions. Neither document certifies a selected device's current firmware. This subset was verified with real loopback HTTP fixtures, strict TypeScript and capped Docker; physical hardware and acoustic output remain unverified. Plaintext deviceID/type checks do not cryptographically authenticate a peer.

SoundTouch owner control policy

SoundTouch controls require an explicit owner policy in the constructor. Omitting controlPolicy keeps the selected protocol read-only. Pass controlPolicy: { maximumVolume: 40 } for an absolute integer ceiling from 0 through 100; the policy is copied and frozen at construction. Reported device capabilities retain the documented vendor range of 0–100 and do not grant owner control authority.

Every POST uses the protocol's own final fresh identity and settled-volume observation. Its requested or preserved target must stay within the owner ceiling. Reads above the ceiling remain available, and an absolute lowering into the permitted range is allowed without silently clamping. A mute-only request preserves the freshly settled volume and therefore rejects when it would send a target above the ceiling.

An increase while freshly muted is rejected even when the request also asks to unmute. Bose applies the mute setting before volume, so explicit unmute also rejects when the current volume exceeds the ceiling, including a payload that simultaneously lowers it. Lower the volume while preserving mute, then request unmute as a separate operation after a fresh in-range observation. These are individually admitted operations; no transaction, compare-and-set, physical-effect or acoustic guarantee is claimed. Other controllers can still race after the last observation.

const speaker = new BoseSoundTouchProtocol({
  address: '192.168.1.90',
  expectedDeviceId: 'D05FB8A9591C',
  expectedType: 'SoundTouch 20',
  allowPlaintext: true,
  controlPolicy: { maximumVolume: 40 },
});

The status STRING response still has no defined success vocabulary. Results retain acknowledgment, independent observed state and causalityConfirmed: false. No automatic retry or replay is added. Admission rules follow Bose's SoundTouch Web API v1.1, April 1, 2026, section 12.12, including mute-before-volume ordering. Physical hardware and acoustic effects remain unverified.

Selected Squeezebox / Lyrion status

SqueezeboxProtocol reads one explicitly selected player through the verified Lyrion Music Server 9.1.1 JSON-RPC 1.0 HTTP endpoint. Select a private or loopback literal IPv4 origin, the server UUID, the player's MAC address and its exact reported model. An expected player UUID is optional. Every snapshot refreshes the server and player identity before requesting current status.

import { SqueezeboxProtocol } from '@ecobridge.xyz/devicemanager';

const player = new SqueezeboxProtocol({
  origin: 'http://192.168.1.40:9000',
  expectedServerUuid: '11111111-2222-3333-4444-555555555555',
  playerId: '00:04:20:11:22:33',
  expectedPlayerModel: 'squeezebox3', // The exact model reported by your selected player.
  allowUnencryptedHttp: true,
  timeoutMs: 5000,
  maxPendingOperations: 16,
  maxResponseBytes: 65536,
});

try {
  const snapshot = await player.readSnapshot({ signal: AbortSignal.timeout(5000) });
  console.log(snapshot.identity, snapshot.name, snapshot.connected, snapshot.mode);
  console.log(snapshot.volume, snapshot.muted, snapshot.capabilities);
} finally {
  await player.close();
}

The UUIDs, MAC and model above are illustrative; supply the selected server and player's actual values. readSnapshot() returns the current playback mode, connection report, optional power, optional volume and explicit mute report, synchronization presence, and current volume-control flags. Volume uses the documented 0..100 mixer scale and preserves fractional values. Missing capability reports remain absent. A digital or active volume-control flag is exposed only when the server reports it.

This profile is read-only: canSetVolume, canSetMuted and canControlPlayback are always false. Released Lyrion mixer commands can propagate to synchronized players, and changing volume can unmute a player. Writes require a separate owner policy for all affected players. The facade exposes no write, playback, playlist, media URL or arbitrary RPC operation.

The endpoint uses explicitly acknowledged plaintext HTTP with no credentials or credential provider. A server UUID, player MAC/model and optional player UUID bind replies to the selected server reports; they do not authenticate the peer. A snapshot consists of consecutive correlated JSON-RPC reports, without an atomic observation or physical-effect guarantee. Password-protected HTTP endpoints fail admission without a fallback.

Defaults bound each operation to 5 seconds, including queue time, with at most 16 admitted operations and 64 KiB per response. Owners may choose 1..60000 ms, 1..128 admitted operations and 512..1048576 response bytes. Response headers are limited to 8 KiB and 64 fields; compressed responses, redirects and malformed or mismatched replies are refused. The facade owns and drains its HTTP requests and sockets. Cancelling or timing out a queued operation removes that item; an active failure or cancellation permanently closes the facade, rejects queued work and drains resources. close() is idempotent and retains cleanup failures through rejection. Construct another explicitly selected facade for a new logical lifetime. No operation retries or replays.

SqueezeboxProtocolError exposes a finite code and effectOutcome: 'not-sent'; this read-only API never dispatches an effect. The facade also exposes isClosed and lastError.

Primary references: JSON-RPC CLI transport, player identity and mixer queries, compound status queries, and released 9.1.1 mixer implementation.

Philips JointSPACE v1 observations

PhilipsJointSpaceProtocol reads one explicitly selected Philips 32PFL7606H/12 through the documented JointSPACE v1 JSON API on port 1925. JointSPACE must already be enabled by the owner. Configure an exact serial number and raw system/softwareversion string from the selected TV, an explicit literal private/link-local/loopback IPv4 HTTP origin, apiPathVersion: 1, and allowUnencryptedHttp: true.

import {
  PhilipsJointSpaceProtocol,
  type IPhilipsJointSpaceSnapshot,
} from '@ecobridge.xyz/devicemanager';

async function observeSelectedTv(
  origin: string,
  selectedSerial: string,
  selectedSoftware: string,
): Promise<IPhilipsJointSpaceSnapshot> {
  const tv = new PhilipsJointSpaceProtocol({
    origin,
    expectedModel: '32PFL7606H/12',
    expectedSerialNumber: selectedSerial,
    expectedSoftwareVersion: selectedSoftware,
    apiPathVersion: 1,
    allowUnencryptedHttp: true,
  });
  try {
    return await tv.start({ timeoutMs: 5000 });
  } finally {
    await tv.close();
  }
}

start() returns a fresh immutable observation and admits later explicit readSnapshot() calls. Each operation serially reads /1/system, /1/audio/volume, /1/sources, /1/sources/current, then /1/system again to recheck the selected model, serial and software. Volume remains a vendor integer with observed minimum/maximum and mute state; source IDs come from the fresh bounded source map. Separate resources do not form an atomic or physical-state guarantee.

V1 system documentation does not provide a MAC address or runtime API version. apiPathVersion selects the documented /1/ paths; apiVersionEvidence describes that path and schema evidence. macAddressVerified is false and identityAuthentication is unauthenticated-lan. Serial/model/software matching over plaintext does not cryptographically authenticate a peer. Firmware values are compared exactly without translating Philips download-release numbers into guessed runtime strings.

This API exposes read-only observations. It sends no POST, power, key, volume/mute/source control, pairing, discovery, cloud, media URL or arbitrary-resource requests. It performs no automatic retry or replay and owns no credentials, providers or persistence. PhilipsJointSpaceProtocolError.effectOutcome is not-sent because no state-changing request is sent; preceding GETs may have occurred.

One inclusive operation deadline covers FIFO waiting, HTTP admission, all resource reads and local drain. Defaults are 5000 ms, 16 admitted/queued operations and 65536 response bytes per GET; supported bounds are respectively 1..60000, 1..128 and 256..1048576. Response headers are capped at 8192 bytes; source maps at 64 items. HTTP must return 200 uncompressed UTF-8 JSON with the documented resource schema. Redirects, authentication responses and unsupported fields are rejected.

Queued cancellation/deadline expiry removes that operation before dispatch. An active timeout, cancellation, malformed response, transport failure or selection mismatch closes the owner terminally and rejects queued work. close() is idempotent and joins owned requests/sockets; failed start() and createAndStart() paths also drain their ownership. Reopening requires a new explicitly selected instance.

Primary references: JointSPACE project, v1 API, v1 system, v1 audio volume, v1 sources, and v1 current source.

S
Description
No description provided
Readme
4 MiB
Languages
TypeScript 100%