diff --git a/LINK_DESIGN.md b/LINK_DESIGN.md new file mode 100644 index 0000000000..28df846865 --- /dev/null +++ b/LINK_DESIGN.md @@ -0,0 +1,779 @@ +# Companion Link Design Document + +**Status:** Planning +**Date:** February 14, 2026 +**Purpose:** Replace deprecated Bitfocus Cloud with self-hosted MQTT-based "Companion Link" + +## Overview + +Deprecate the centralized Bitfocus Cloud service and replace it with a modular, self-hosted "Companion Link" system using MQTT 5.0 as the first transport backend. Enable remote button control and state synchronization via selective subscription event bus with service discovery and chunking support for large messages. + +**Critical:** Keep existing [companion/lib/Cloud/](companion/lib/Cloud/) code completely untouched. + +## Architecture + +### Core Components + +- **LinkController** - Main orchestrator (replaces CloudController for Link) +- **LinkTransport** - Abstract transport interface +- **MqttTransport** - First implementation using MQTT 5.0 +- **Protocol Messages** - Transport-agnostic message types with JSON Schema +- **ChunkManager** - Handles chunking/reassembly for large messages +- **PeerRegistry** - Tracks discovered peers (online/offline) and which transports they're reachable on +- **TransportManager** - Manages multiple transport instances, routes messages appropriately + +### Transport Decision: MQTT vs Redis + +**Chosen: MQTT 5.0** + +Reasons: + +- Purpose-built for pub/sub event buses +- QoS levels for reliable delivery +- Native request/response with correlation data (MQTT 5) +- Better suited for geo-distributed multi-broker setups +- Lower self-hosting complexity (Mosquitto) +- Broker auth with username/password + optional TLS + +**Library:** `mqtt` (MQTT.js) - Industry standard, TypeScript support + +--- + +## Implementation Plan + +### Step 1: Create Transport-Agnostic Link Architecture + +**Location:** [companion/lib/Service/Link/](companion/lib/Service/Link/) + +**Core Protocol Messages:** + +- `Announcement` - Service discovery broadcast +- `GoingOffline` - Graceful shutdown notification +- `SubscribeRequest` - Request button state updates +- `SubscribeResponse` - Initial state for subscribed buttons +- `UnsubscribeRequest` - Stop receiving updates +- `ButtonStateUpdate` - Button pressed/released/style changed +- `ButtonPressCommand` - Remote button press +- `ButtonReleaseCommand` - Remote button release +- `BitmapRequest` - Request bitmap at specific resolution +- `BitmapResponse` - Rendered bitmap (chunked if >63KB) +- `ChunkMessage` - Generic chunking envelope + +**Schema:** AsyncAPI for protocol documentation (message-based async protocol), JSON Schema for message payload validation, versioned (starting v1) + +**LinkTransport Interface:** + +```typescript +abstract class LinkTransport { + abstract connect(config: TransportConfig): Promise + abstract disconnect(): Promise + abstract publish(topic: string, message: any, options?: PublishOptions): Promise + abstract subscribe(pattern: string, handler: MessageHandler): Promise + abstract unsubscribe(pattern: string): Promise + abstract request(topic: string, message: any, timeout?: number): Promise +} +``` + +**LinkController:** + +- Manages transport lifecycle +- Coordinates peer discovery +- Handles subscription management (deduplicates MQTT subscriptions locally) +- Routes messages between transport and internal systems +- Maintains peer registry + +--- + +### Step 2: Implement Service Discovery with Offline Handling + +**Discovery Protocol:** + +**Announcement:** + +- **Frequency:** Every 30-60 seconds +- **Topic:** `companion-link/discovery/{uuid}` +- **Retained:** Yes (so new peers see existing instances) +- **Message:** + + ```json + { + "id": "uuid-v4", + "name": "Studio 1 Companion", + "version": "3.x.x", + "protocolVersion": 1, + "pageCount": 99, + "gridSize": { "rows": 8, "cols": 8 }, + + "timestamp": 1234567890 + } + ``` + +**Going Offline:** + +- **Topic:** `companion-link/discovery/{uuid}` +- **Retained:** Yes (publish empty message to clear retained announcement) +- **Sent on:** Graceful shutdown, user disable + +**Peer Registry:** + +- Maintains list of discovered peers in memory +- Tracks which transport instances each peer is reachable on +- Tracks online/offline status per transport based on timestamp +- Mark offline on specific transport if no announcement received for 2× interval (60-120s) +- Peer shown as online if reachable on at least one transport +- **Never** delete other installations from transport layer +- Persist offline peers in DB for UI display +- User can manually delete peers from UI + +--- + +### Step 3: Implement MQTT 5.0 Transport Backend + +**Library:** `mqtt` (MQTT.js) + +**Configuration (per transport instance):** + +- Broker URL - single broker endpoint +- Username + Password (broker auth) +- Optional TLS +- Client ID: `companion-link-{uuid}-{transport-id}` +- Clean session: false (persistent) +- QoS: 1 (at-least-once delivery) +- Optional: Internal failover broker URLs (transport-managed) + +**Topic Hierarchy:** + +``` +companion-link/discovery/{uuid} # Service discovery (retained) +companion-link/{uuid}/location/{page}/{row}/{col}/bitmap # Button bitmaps (or chunks) +companion-link/{uuid}/location/{page}/{row}/{col}/state # Button state updates +companion-link/{uuid}/location/{page}/{row}/{col}/press # Press commands +companion-link/{uuid}/location/{page}/{row}/{col}/release # Release commands +companion-link/{uuid}/rpc/{correlationId}/request # RPC requests +companion-link/{uuid}/rpc/{correlationId}/response # RPC responses +companion-link/chunks # Chunked messages (all instances) +``` + +**Design Notes:** + +- Per-location topics for bitmaps enable efficient multi-client subscriptions +- Clients MQTT-subscribe to specific location topics +- Clients send application-level `SubscribeRequest` to tell instance to start publishing +- Instance publishes bitmaps/states only for subscribed locations +- Multiple clients can subscribe to same location without duplicate publishing + +**Multi-Transport Instance Support:** + +- User can add multiple transport instances (e.g., multiple MQTT brokers) +- Each transport instance = single logical connection +- LinkController tracks which peers are reachable via which transports +- Messages sent over all available transports to a peer (initial implementation) +- Future: Configurable redundancy level (e.g., use best 2-3 paths instead of all) +- Each transport instance managed independently +- Peer discovery happens per-transport, aggregated by LinkController + +--- + +### Step 4: Design Subscription-Based State Sync Protocol + +**Flow:** + +1. **Client subscribes to MQTT topics:** + + ```typescript + mqtt.subscribe('companion-link/abc-123/location/1/2/3/bitmap') + mqtt.subscribe('companion-link/abc-123/location/1/2/3/state') + ``` + +2. **Client sends application-level SubscribeRequest:** + - Via RPC to target instance + - Message: `{buttons: [{page: 1, row: 2, col: 3, resolution: {width: 72, height: 72}}]}` + - Tells instance what resolution to render for each location + - Tells instance to start publishing to those location topics + +3. **Instance responds with current state:** + - `SubscribeResponse: {states: [{page: 1, row: 2, col: 3, ...bitmap}]}` + - Bitmap rendered at requested resolution + - Immediate delivery of initial state + - No retained messages needed (initial state in response) + +4. **Instance publishes updates to subscribed topics:** + - Only for locations with active subscriptions + - Publishes all subscribed resolutions for that location + - Track subscriptions: `Map>` + - Publishes to `companion-link/{uuid}/location/{page}/{row}/{col}/bitmap/{width}x{height}` + - State topic remains resolution-agnostic: `companion-link/{uuid}/location/{page}/{row}/{col}/state` + +5. **Client sends UnsubscribeRequest when done:** + - When surface switches pages, send UnsubscribeRequest for resolutions no longer visible + - When client disconnects, send UnsubscribeRequest for all active subscriptions + - Allows server to immediately clean up unused caches + - Fallback: server evicts cache after 5-minute timeout if no bitmap requests received + +6. **Subscription deduplication:** + - If multiple local buttons want same remote button at same resolution + - LinkController deduplicates MQTT subscriptions internally + - Single MQTT subscription per (location, resolution) pair + - Fan-out to multiple local consumers + +**Button State Update Message:** + +- **Contains:** Only the rendered bitmap (PNG base64 or chunked) +- **No:** Text, colors, or other render properties +- Bitmap rendered with `show_topbar: false` always + +--- + +### Step 5: Implement Chunking Protocol for Large Messages + +**When:** Bitmap PNG >63KB (to stay under 64KB MQTT broker limit) + +**Protocol:** Fire-and-forget chunking + +**Message Format:** + +``` +{ + "id": "uuid-v4", // Unique message ID + "idx": 0, // Chunk index (0-based) + "total": 3, // Total chunks + "size": 45000, // Total assembled size + "crc": 123456789 // CRC32 of this chunk's data +} +\n + +``` + +**Topic:** `companion-link/chunks` (global) or per-instance topics + +**Chunk Size:** ~63KB (64KB limit - 200 bytes JSON overhead) + +**Sender:** + +1. Split binary into chunks +2. Generate unique message ID +3. Publish all chunks with metadata +4. Fire-and-forget (no ACK) + +**Receiver:** + +1. Maintain reassembly buffers (Map) +2. Verify per-chunk CRC32 +3. When all chunks received, assemble by index +4. Verify total size +5. Emit complete message +6. Cleanup buffer + +**Timeouts:** + +- 60 seconds from first chunk +- Discard incomplete messages on timeout +- Background cleanup task every 10 seconds + +**Topic Strategy:** + +- Use per-location AND per-resolution topics: `companion-link/{uuid}/location/{page}/{row}/{col}/bitmap/{width}x{height}` +- Publish chunks to resolution-specific topic +- Subscribers request specific resolution they need +- Multiple surfaces can request different resolutions for same button +- Efficient: Only renders resolutions that are actually requested + +--- + +### Step 6: Build Bitmap Rendering Pipeline for Link + +**Per-Resolution Rendering Strategy:** + +When button state changes, LinkController needs to render for all subscribed resolutions: + +```typescript +// Track active subscriptions per location +subscriptions: Map> + +// On button state change for location +const resolutions = subscriptions.get(locationKey) +for (const { width, height } of resolutions) { + renderAndPublish(page, row, col, width, height) +} +``` + +**Resolution-Aware Caching:** + +- Cache key: `${page}:${row}:${col}:${width}x${height}` +- Only render resolutions that are actively subscribed via SubscribeRequest +- Track subscriptions explicitly: client must send UnsubscribeRequest when no longer needs a resolution +- Timeout-based eviction: if no bitmap requested for a resolution in 5 minutes, evict from cache +- Client responsibility: send UnsubscribeRequest when switching pages/disconnecting to allow immediate cleanup +- Memory-bounded: only caches what's in use + short timeout window + +**Rendering Flow:** + +1. **Render button at requested resolution:** + - Use [GraphicsRenderer](companion/lib/Graphics/Renderer.ts) + - Set `show_topbar: false` before rendering + - Call `drawButtonImageUnwrapped()` with target resolution directly + - NO need for transformButtonImage scaling - render native +2. **Encode as PNG:** + - Via [ImageResult](companion/lib/Graphics/ImageResult.ts) + - PNG compression keeps most buttons <30KB +3. **Check size and chunk if needed:** + - If PNG >63KB, use chunking protocol + - Publish chunks to `companion-link/{uuid}/location/{page}/{row}/{col}/bitmap/{width}x{height}` +4. **Latency Optimization:** + - First subscription: Render on-demand (unavoidable latency) + - Subsequent updates: Pre-rendered and cached per resolution + - Button state change triggers re-render for all active resolutions simultaneously + +**Alternative for Simple Buttons (Future Optimization):** + +- For text-only buttons without custom images, could send style JSON instead +- Client renders locally (zero latency, minimal bandwidth) +- Falls back to bitmap for complex buttons +- Not part of MVP + +**Message Payload:** + +- `BitmapResponse: {png: base64, width, height}` - if <63KB +- Or send via chunking protocol - if ≥63KB + +--- + +### Step 7: Port bitfocus-cloud Module to Internal Link Actions + +**Source:** [bundled-modules/bitfocus-cloud/](bundled-modules/bitfocus-cloud/) + +**Target:** Internal actions (core Companion) + +**Remote Control Reference (Preferred):** + +- New control type: `remote_link` +- Directly references a remote button by instance UUID + page/row/col +- Automatically mirrors remote button bitmap and state +- Pressing local button triggers press on remote button +- Simplifies 99% use case: just showing/controlling remote buttons +- User selects: target instance (from discovered peers) + location +- No manual action/feedback configuration needed + +**Internal Actions (Alternative/Advanced):** + +- `companion_link_press_button` - Press button on remote instance +- `companion_link_release_button` - Release button on remote instance +- `companion_link_set_page` - Change page on remote instance + +**Internal Feedbacks (Alternative/Advanced):** + +- `companion_link_button_state` - Show state of remote button +- Implements subscription to remote button states + +**Note:** Both approaches should be available - remote control references for simple mirroring, actions/feedbacks for custom integration + +**Visibility:** + +- Only show when Link transport is configured and enabled +- Check in action/feedback `isVisible()` methods + +**Target Selection:** + +- Dropdown of discovered peers from PeerRegistry +- User selects remote instance by name/ID +- Actions use selected peer's UUID for routing + +**Implementation:** + +1. Extract button trigger logic from module +2. Create internal action/feedback definitions +3. Use LinkController to send commands via transport +4. Subscribe to remote button states for feedback +5. Deduplicate subscriptions in LinkController + +**Bundled Module:** + +- Mark as deprecated in next release +- Provide migration guide +- Eventually remove from bundled-modules/ + +--- + +### Step 8: Build Link Configuration UI + +**Location:** [webui/src/Pages/Link/](webui/src/Pages/Link/) + +**Separation:** Completely separate from [Pages/Cloud/](webui/src/Pages/Cloud/) (leave untouched) + +**UI Components:** + +1. **Transport Instance Management** + - List of configured transport instances + - Add/Edit/Delete transport instance + - Transport type selector (MQTT, WebRTC, etc.) + - Per-transport configuration fields: + - MQTT: Broker URL, Username, Password, Enable TLS + - Connection status indicator per instance (green/red/yellow) + - Test connection button per instance + - Enable/disable individual transport instances + +2. **Multi-Transport Setup** + - Support adding multiple transport instances of same or different types + - Each transport instance managed independently + - Show connection status per instance + - Future: Redundancy level setting (how many paths to use per peer) + +3. **Instance Identity** + - Display current UUID + - Button to regenerate UUID (with warning) + - Display instance name (editable) + +4. **Discovered Peers Table** + - Columns: Name, UUID, Status (Online/Offline), Version, Transports, Last Seen, Actions + - Status badge (green=online, gray=offline) + - Transports column: List of transport instances peer is reachable on + - Last seen timestamp (per transport) + - Manual delete button (trash icon) + - Auto-refresh every 10 seconds + +5. **Transport Selector** + - Dropdown: "MQTT" (only option initially) + - Placeholder for future: WebRTC, WebSocket, etc. + - Grayed out options with "Coming Soon" + +6. **Enable/Disable Toggle** + - Master switch for Link service + - Shows warning when disabling (peers will see as offline) + +**State Management:** + +- Store transport instance configs in DB +- Real-time peer updates via websocket from backend +- Persist offline peers for UI display + +**TransportManager:** + +- Manages lifecycle of all transport instances +- Routes outgoing messages to appropriate transports +- Aggregates peer discovery from all transports +- Initial implementation: Send messages over all available transports to a peer +- Future optimization: Configurable redundancy (e.g., use best 2-3 paths based on latency/reliability) +- Handles transport-specific failures gracefully + +--- + +## Technical Specifications + +### JSON Schema Examples + +**Announcement Message:** + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "version": { "type": "number", "const": 1 }, + "type": { "type": "string", "const": "announcement" }, + "payload": { + "type": "object", + "properties": { + "id": { "type": "string", "format": "uuid" }, + "name": { "type": "string" }, + "version": { "type": "string" }, + "protocolVersion": { "type": "number" }, + "pageCount": { "type": "number" }, + "gridSize": { + "type": "object", + "properties": { + "rows": { "type": "number" }, + "cols": { "type": "number" } + }, + "required": ["rows", "cols"] + }, + "timestamp": { "type": "number" } + }, + "required": ["id", "name", "version", "protocolVersion", "pageCount", "gridSize", "timestamp"] + } + }, + "required": ["version", "type", "payload"] +} +``` + +**ButtonPressCommand:** + +```json +{ + "version": 1, + "type": "button.press", + "payload": { + "page": 1, + "row": 2, + "col": 3, + "timestamp": 1234567890 + } +} +``` + +**SubscribeRequest:** + +```json +{ + "version": 1, + "type": "subscribe.request", + "payload": { + "buttons": [ + { + "page": 1, + "row": 2, + "col": 3, + "resolution": { + "width": 72, + "height": 72 + } + }, + { + "page": 1, + "row": 2, + "col": 4, + "resolution": { + "width": 128, + "height": 128 + } + } + ], + "timestamp": 1234567890 + } +} +``` + +**SubscribeResponse:** + +```json +{ + "version": 1, + "type": "subscribe.response", + "payload": { + "states": [ + { + "page": 1, + "row": 2, + "col": 3, + "bitmap": "", + "pressed": false + } + ], + "timestamp": 1234567890 + } +} +``` + +### Transport Abstraction Notes + +The MQTT transport will abstract transport-specific features (correlation data, QoS) internally. Future transports (WebRTC, WebSocket, Redis) will need to provide equivalent guarantees: + +- **Reliability:** At-least-once delivery (MQTT QoS 1) +- **Ordering:** Best-effort (not strictly required) +- **Request/Response:** RPC pattern with timeout +- **Pub/Sub:** Topic-based with wildcard subscriptions + +The core protocol remains transport-agnostic. Each transport implementation maps core protocol concepts to transport-specific mechanisms. + +--- + +## Further Considerations (To Be Resolved) + +### 1. Button State Message Granularity + +**Decision:** Only send rendered bitmap (PNG base64 or chunked) + +### 2. Discovery Announcement Retention + +**Open Question:** When publishing `GoingOffline`, should we: + +- Option A: Publish empty retained message to clear announcement? +- Option B: Publish GoingOffline as retained so peers know it's intentional? + +### 3. Subscription Multiplexing + +**Decision:** Deduplicate MQTT subscriptions locally in LinkController + +### 4. Chunking Topic Strategy + +**Decision:** Use per-location AND per-resolution topics `companion-link/{uuid}/location/{page}/{row}/{col}/bitmap/{width}x{height}` for efficiency. Each subscription specifies desired resolution. Multiple surfaces can request different resolutions for the same button. + +### 5. Multi-Resolution Rendering Strategy + +**Decision:** + +- Clients include desired resolution in SubscribeRequest +- Server renders and caches per (location, resolution) pair +- Track subscriptions: `Map>` +- Only render resolutions actually in use +- First subscription has initial render latency (unavoidable) +- Subsequent updates are instant from cache +- When button state changes, re-render all subscribed resolutions simultaneously + +**Memory Management:** + +- Only cache resolutions actively subscribed +- When last subscriber for a resolution unsubscribes, evict that cache entry +- Cache key: `${page}:${row}:${col}:${width}x${height}` + +### 6. Transport Abstraction Leak + +**Note:** MQTT-specific features (correlation data, QoS) won't map directly to other transports. The MQTT transport class will handle enough abstraction internally to make this work. Implementation details to be determined during development. + +### 7. Latency Measurement Per Transport + +**Decision:** Measure round-trip latency to each peer over each transport instance. + +**Implementation:** + +- Send periodic ping/pong messages to discovered peers +- Track RTT (round-trip time) per (peer, transport) tuple +- Store in PeerRegistry alongside connection status +- Display in UI for observability + +**Usage:** + +- **For now:** Display only - for monitoring and debugging +- **Future:** May be used for intelligent routing decisions (select fastest N transports per peer) +- **Note:** Do NOT use for routing in initial implementation - all available transports used equally + +**Frequency:** + +- Measure every 30-60 seconds (align with announcement interval) +- Or on-demand when needed for debugging + +--- + +## Migration from Cloud + +**No migration support.** Clean break from Bitfocus Cloud. + +Users must: + +1. Set up their own MQTT broker +2. Configure Link in Companion +3. Manually recreate any cloud-based button integrations using new Link actions + +--- + +## Dependencies + +**New Package:** + +- `mqtt` - MQTT.js library for MQTT 5.0 client + +**Existing Libraries (Reused):** + +- `@julusian/image-rs` - Image scaling/letterboxing +- Canvas/graphics system - Button rendering +- JSON Schema - Message validation + +--- + +## Timeline & Phasing + +**Phase 1:** Core architecture + MQTT transport (MVP) +**Phase 2:** Chunking protocol + bitmap rendering +**Phase 3:** Remote Link button control type + full wiring +**Phase 4:** Testing + documentation +**Phase 5:** Future transports (WebRTC, etc.) + +--- + +## Phase 3 Implementation: Remote Link Button + +### Control Type: `remotelinkbutton` + +A new button control type that mirrors a button from a remote Companion instance. + +**Files:** + +- `shared-lib/lib/Model/ButtonModel.ts` — `RemoteLinkButtonModel`, `RemoteLinkButtonVisualState`, `RemoteLinkButtonRuntimeProps` +- `companion/lib/Controls/ControlTypes/LinkButton.ts` — `ControlButtonRemoteLink` class +- `companion/lib/Controls/ControlsTrpcRouter.ts` — `setLinkConfig` endpoint +- `webui/src/Buttons/EditButton/RemoteLinkButtonEditor.tsx` — Configuration UI +- `webui/src/Buttons/EditButton/EditButton.tsx` — Renders editor for `remotelinkbutton` +- `webui/src/Buttons/EditButton/SelectButtonTypeDropdown.tsx` — "Remote Link button" option + +### Data Model + +```typescript +interface RemoteLinkButtonModel { + type: 'remotelinkbutton' + peerUuid: string // Remote peer UUID (from PeerRegistry) + page: string // Page number (supports variables/expressions) + row: string // Row number (supports variables/expressions) + col: string // Column number (supports variables/expressions) +} +``` + +### Visual States + +| State | Appearance | When | +| --------------- | -------------------------------------- | ----------------------------------- | +| `unknown_peer` | "Unknown peer" text, cloud error icon | Peer UUID not found in PeerRegistry | +| `unreachable` | "Unreachable" text, cloud icon | Peer known but offline | +| `loading` | "Loading..." text, cloud icon | Subscribed, awaiting first bitmap | +| `bitmap` | Remote button's rendered bitmap | Receiving updates from remote | +| `loop_detected` | "Loop detected" text, cloud error icon | Source chain contains our own UUID | + +### Controller Wiring (LinkController) + +**Inbound flow** (other peers subscribing to our buttons): + +1. Listen for RPC subscribe requests on `rpcRequestWildcard(ourUuid)` +2. Add to SubscriptionManager → BitmapRenderer watches for changes +3. BitmapRenderer emits `bitmapReady` → publish `ButtonUpdateMessage` on MQTT +4. Listen for press/release commands → forward to `ControlsController.pressControl()` + +**Outbound flow** (our link buttons subscribing to remote): + +1. `controlCountChanged` event triggers `#syncOutboundSubscriptions()` +2. For each link button: resolve peer + location → send subscribe request +3. Subscribe to `updateWildcard(peerUuid)` on MQTT +4. On receiving `ButtonUpdateMessage`, check sourceChain for loops, feed to link button +5. On link button press, publish press/release command to remote peer + +### Loop Detection + +When receiving a `ButtonUpdateMessage`: + +- If `sourceChain` contains our own UUID → set visual state to `loop_detected` +- Otherwise, display the bitmap normally + +When publishing updates: + +- Append our UUID to `sourceChain` so downstream instances can detect loops + +### Press Forwarding + +When a user presses a remote link button: + +1. `ControlButtonRemoteLink.pressControl()` → calls `#onPress` callback +2. LinkController resolves the target peer + location +3. Publishes `ButtonPressMessage`/`ButtonReleaseMessage` on `pressTopic(peerUuid, page, row, col)` +4. Remote peer's LinkController receives it → calls `controls.pressControl(controlId, pressed, 'link')` + +### TODO Items + +- Resolve variables/expressions in page/row/col fields (currently only numeric parsing) +- Get actual pressed state of local buttons for outbound updates +- Track requestor UUID in subscribe requests for per-peer subscription management +- Send proper RPC responses with initial button state + +--- + +## Open Questions + +1. ~~Should we implement AsyncAPI instead of JSON Schema for message definitions?~~ **Resolved:** Use AsyncAPI for protocol docs, JSON Schema for payload validation +2. What broker(s) should documentation recommend for self-hosting? +3. Should we provide docker-compose examples for broker setup? +4. ~~How to handle version negotiation if protocol evolves?~~ **Resolved:** Include `protocolVersion` field in announcements, define handling when needed +5. What timeout value for cache eviction? (Currently proposed: 5 minutes) +6. Should remote control references support custom styling override, or strictly mirror remote? +7. How to select which transport instances to use when multiple paths available? (Initial: all, Future: configurable) +8. What criteria for "best N paths" selection? (Latency, reliability, manual priority?) +9. Should transport instances have user-defined names/labels for UI clarity? + +--- + +**End of Design Document** diff --git a/assets/link-protocol.schema.json b/assets/link-protocol.schema.json new file mode 100644 index 0000000000..93c39632ef --- /dev/null +++ b/assets/link-protocol.schema.json @@ -0,0 +1,344 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "LinkProtocolPayloads", + "description": "Companion Link protocol payload definitions. Each $def is a message payload type identified by its message type string. Protocol.ts composes these into discriminated-union message envelopes.", + "type": "object", + "properties": { + "_payloads": { + "description": "Reference anchor — ensures all $defs are reachable and generated as TypeScript interfaces.", + "type": "object", + "properties": { + "announcement": { "$ref": "#/$defs/AnnouncementPayload" }, + "subscribeRequest": { "$ref": "#/$defs/SubscribeRequestPayload" }, + "subscribeResponse": { "$ref": "#/$defs/SubscribeResponsePayload" }, + "unsubscribeRequest": { "$ref": "#/$defs/UnsubscribeRequestPayload" }, + "buttonCommand": { "$ref": "#/$defs/ButtonCommandPayload" }, + "buttonUpdate": { "$ref": "#/$defs/ButtonUpdatePayload" }, + "buttonLocation": { "$ref": "#/$defs/ButtonLocation" }, + "buttonLocationWithResolution": { "$ref": "#/$defs/ButtonLocationWithResolution" }, + "subscribeResponseButtonState": { "$ref": "#/$defs/SubscribeResponseButtonState" }, + "gridSize": { "$ref": "#/$defs/GridSize" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "GridSize": { + "title": "LinkGridSize", + "description": "Grid dimensions for an instance.", + "type": "object", + "properties": { + "rows": { + "description": "Number of rows in the grid.", + "type": "number", + "minimum": 0 + }, + "cols": { + "description": "Number of columns in the grid.", + "type": "number", + "minimum": 0 + } + }, + "required": ["rows", "cols"], + "additionalProperties": false + }, + "ButtonLocation": { + "title": "LinkButtonLocation", + "description": "Identifies a button by its page, row, and column.", + "type": "object", + "properties": { + "page": { + "description": "Page number.", + "type": "number", + "minimum": 0 + }, + "row": { + "description": "Row number.", + "type": "number", + "minimum": 0 + }, + "col": { + "description": "Column number.", + "type": "number", + "minimum": 0 + } + }, + "required": ["page", "row", "col"], + "additionalProperties": false + }, + "ButtonLocationWithResolution": { + "title": "LinkButtonLocationWithResolution", + "description": "A button location with a requested bitmap resolution.", + "type": "object", + "properties": { + "page": { + "description": "Page number.", + "type": "number", + "minimum": 0 + }, + "row": { + "description": "Row number.", + "type": "number", + "minimum": 0 + }, + "col": { + "description": "Column number.", + "type": "number", + "minimum": 0 + }, + "width": { + "description": "Requested bitmap width in pixels.", + "type": "number", + "minimum": 1 + }, + "height": { + "description": "Requested bitmap height in pixels.", + "type": "number", + "minimum": 1 + } + }, + "required": ["page", "row", "col", "width", "height"], + "additionalProperties": false + }, + "AnnouncementPayload": { + "title": "LinkAnnouncementPayload", + "description": "Discovery announcement from a Companion instance.", + "type": "object", + "properties": { + "id": { + "description": "Unique instance UUID.", + "type": "string" + }, + "name": { + "description": "Human-readable instance name.", + "type": "string" + }, + "version": { + "description": "Companion application version string.", + "type": "string" + }, + "protocolVersion": { + "description": "Link protocol version number.", + "type": "number" + }, + "pageCount": { + "description": "Number of pages configured on this instance.", + "type": "number", + "minimum": 0 + }, + "gridSize": { + "$ref": "#/$defs/GridSize" + }, + "timestamp": { + "description": "Unix timestamp in milliseconds.", + "type": "number" + } + }, + "required": ["id", "name", "version", "protocolVersion", "pageCount", "gridSize", "timestamp"], + "additionalProperties": false + }, + "SubscribeRequestPayload": { + "title": "LinkSubscribeRequestPayload", + "description": "Request to subscribe to button state updates at specific resolutions.", + "type": "object", + "properties": { + "requestorId": { + "description": "UUID of the peer making the subscription request.", + "type": "string" + }, + "buttons": { + "description": "List of buttons and their requested resolutions.", + "type": "array", + "items": { + "$ref": "#/$defs/ButtonLocationWithResolution" + } + }, + "timestamp": { + "description": "Unix timestamp in milliseconds.", + "type": "number" + } + }, + "required": ["requestorId", "buttons", "timestamp"], + "additionalProperties": false + }, + "SubscribeResponseButtonState": { + "title": "LinkSubscribeResponseButtonState", + "description": "Initial state for a subscribed button.", + "type": "object", + "properties": { + "page": { + "description": "Page number.", + "type": "number", + "minimum": 0 + }, + "row": { + "description": "Row number.", + "type": "number", + "minimum": 0 + }, + "col": { + "description": "Column number.", + "type": "number", + "minimum": 0 + }, + "width": { + "description": "Bitmap width in pixels.", + "type": "number", + "minimum": 1 + }, + "height": { + "description": "Bitmap height in pixels.", + "type": "number", + "minimum": 1 + }, + "dataUrl": { + "description": "PNG data URL at the requested resolution, or null if no render is available.", + "type": ["string", "null"] + }, + "pressed": { + "description": "Whether the button is currently pressed.", + "type": "boolean" + }, + "sourceChain": { + "description": "Ordered list of instance UUIDs this button image has passed through, for loop detection.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["page", "row", "col", "width", "height", "dataUrl", "pressed", "sourceChain"], + "additionalProperties": false + }, + "SubscribeResponsePayload": { + "title": "LinkSubscribeResponsePayload", + "description": "Response containing initial state for subscribed buttons.", + "type": "object", + "properties": { + "states": { + "description": "List of initial button states.", + "type": "array", + "items": { + "$ref": "#/$defs/SubscribeResponseButtonState" + } + }, + "timestamp": { + "description": "Unix timestamp in milliseconds.", + "type": "number" + } + }, + "required": ["states", "timestamp"], + "additionalProperties": false + }, + "UnsubscribeRequestPayload": { + "title": "LinkUnsubscribeRequestPayload", + "description": "Request to unsubscribe from button state updates.", + "type": "object", + "properties": { + "buttons": { + "description": "List of buttons and resolutions to unsubscribe from.", + "type": "array", + "items": { + "$ref": "#/$defs/ButtonLocationWithResolution" + } + }, + "timestamp": { + "description": "Unix timestamp in milliseconds.", + "type": "number" + } + }, + "required": ["buttons", "timestamp"], + "additionalProperties": false + }, + "ButtonCommandPayload": { + "title": "LinkButtonCommandPayload", + "description": "Payload for button press or release commands.", + "type": "object", + "properties": { + "page": { + "description": "Page number.", + "type": "number", + "minimum": 0 + }, + "row": { + "description": "Row number.", + "type": "number", + "minimum": 0 + }, + "col": { + "description": "Column number.", + "type": "number", + "minimum": 0 + }, + "sourceUuid": { + "description": "UUID of the instance sending this command.", + "type": "string" + }, + "surfaceId": { + "description": "Optional surface identifier for long press support. Will be prefixed with source UUID on receiving end.", + "type": "string" + }, + "timestamp": { + "description": "Unix timestamp in milliseconds.", + "type": "number" + } + }, + "required": ["page", "row", "col", "sourceUuid", "timestamp"], + "additionalProperties": false + }, + "ButtonUpdatePayload": { + "title": "LinkButtonUpdatePayload", + "description": "Combined bitmap and state update for a subscribed button at a specific resolution.", + "type": "object", + "properties": { + "page": { + "description": "Page number.", + "type": "number", + "minimum": 0 + }, + "row": { + "description": "Row number.", + "type": "number", + "minimum": 0 + }, + "col": { + "description": "Column number.", + "type": "number", + "minimum": 0 + }, + "width": { + "description": "Bitmap width in pixels.", + "type": "number", + "minimum": 1 + }, + "height": { + "description": "Bitmap height in pixels.", + "type": "number", + "minimum": 1 + }, + "dataUrl": { + "description": "PNG data URL at the subscribed resolution.", + "type": "string" + }, + "pressed": { + "description": "Whether the button is currently pressed.", + "type": "boolean" + }, + "sourceChain": { + "description": "Ordered list of instance UUIDs this button image has passed through, for loop detection. The publishing instance appends its own UUID before sending.", + "type": "array", + "items": { + "type": "string" + } + }, + "timestamp": { + "description": "Unix timestamp in milliseconds.", + "type": "number" + } + }, + "required": ["page", "row", "col", "width", "height", "dataUrl", "pressed", "sourceChain", "timestamp"], + "additionalProperties": false + } + } +} diff --git a/companion/lib/Controls/ControlTypes/LinkButton.ts b/companion/lib/Controls/ControlTypes/LinkButton.ts new file mode 100644 index 0000000000..cc5cd4b6ff --- /dev/null +++ b/companion/lib/Controls/ControlTypes/LinkButton.ts @@ -0,0 +1,408 @@ +import type { + RemoteLinkButtonModel, + RemoteLinkButtonRuntimeProps, + RemoteLinkButtonVisualState, +} from '@companion-app/shared/Model/ButtonModel.js' +import { ControlBase } from '../ControlBase.js' +import type { + ControlWithoutActionSets, + ControlWithoutActions, + ControlWithoutEntities, + ControlWithoutEvents, + ControlWithoutOptions, + ControlWithoutPushed, + ControlWithoutStyle, +} from '../IControlFragments.js' +import type { ControlDependencies } from '../ControlDependencies.js' +import type { ControlLocation } from '@companion-app/shared/Model/Common.js' +import { ParseLocationString } from '../../Internal/Util.js' +import type { DrawStyleModel, DrawStyleButtonModel } from '@companion-app/shared/Model/StyleModel.js' +import { VisitorReferencesUpdaterVisitor } from '../../Resources/Visitors/ReferencesUpdater.js' + +/** Custom draw style for Remote Link button visual states. */ +type RemoteLinkDrawStyle = + | { + type: 'bitmap' + dataUrl: string + pressed: boolean + } + | { + type: 'placeholder' + text: string + fontSize: number + isError: boolean + } + +/** Default model when creating a new Remote Link button. */ +const DEFAULT_MODEL: RemoteLinkButtonModel = { + type: 'remotelinkbutton', + peerUuid: '', + location: '', +} + +/** + * Control that mirrors a button from a remote Companion instance via Link. + * + * This control does not have its own actions, feedbacks, or style properties. + * Instead, it displays a bitmap received from a remote peer (via the Link + * subscription system) and forwards press/release events back to that peer. + * + * The LinkController feeds data into this control via the public setters: + * - {@link setVisualState} – transitions between placeholder states + * - {@link setBitmap} – provides the remote bitmap data URL + * - {@link setPeerName} – provides the resolved peer display name + */ +export class ControlButtonRemoteLink + extends ControlBase + implements + ControlWithoutActions, + ControlWithoutEntities, + ControlWithoutStyle, + ControlWithoutEvents, + ControlWithoutActionSets, + ControlWithoutOptions, + ControlWithoutPushed +{ + readonly type = 'remotelinkbutton' + + readonly supportsActions = false + readonly supportsEntities = false + readonly supportsStyle = false + readonly supportsEvents = false + readonly supportsActionSets = false + readonly supportsOptions = false + readonly supportsPushed = false + + /** The persisted configuration. */ + #config: RemoteLinkButtonModel + + /** Current visual state (runtime-only, not persisted). */ + #visualState: RemoteLinkButtonVisualState = 'unknown_peer' + + /** Cached remote bitmap data URL, or null. */ + #bitmapDataUrl: string | null = null + + /** Whether the remote button is currently pressed. */ + #remotePressed = false + + /** Human-readable name of the target peer. */ + #peerName: string | null = null + + /** Callback for press forwarding (set by LinkController). */ + #onPress: ((controlId: string, pressed: boolean, surfaceId: string | undefined) => void) | null = null + + /** Callback when link config changes (set by LinkController). */ + #onConfigChanged: ((controlId: string) => void) | null = null + + /** Cached set of variable IDs referenced in the location field. */ + #cachedVariables: ReadonlySet | null = null + + constructor(deps: ControlDependencies, controlId: string, storage: RemoteLinkButtonModel | null, isImport: boolean) { + super(deps, controlId, 'Controls/Button/RemoteLink') + + if (!storage) { + this.#config = { ...DEFAULT_MODEL } + this.commitChange() + } else { + if (storage.type !== 'remotelinkbutton') + throw new Error(`Invalid type given to ControlButtonRemoteLink: "${storage.type}"`) + this.#config = storage + if (isImport) this.commitChange() + } + + // Initialize variable cache + this.#updateCachedVariables() + } + + // ── Public getters for LinkController ──────────────────────── + + /** The configured remote peer UUID. */ + get peerUuid(): string { + return this.#config.peerUuid + } + + /** The configured remote location (raw string, may contain variables/expressions). */ + get linkLocation(): string { + return this.#config.location + } + + /** + * Parse the configured location with the given press location context. + * Returns null if the location cannot be parsed or variables cannot be resolved. + */ + parseLocation(pressLocation: ControlLocation | undefined): ControlLocation | null { + return ParseLocationString(this.#config.location, pressLocation) + } + + /** Current visual state. */ + get visualState(): RemoteLinkButtonVisualState { + return this.#visualState + } + + // ── Public setters (called by LinkController) ──────────────── + + /** + * Update the visual state of this Remote Link button. + * Triggers a redraw and runtime property change. + */ + setVisualState(state: RemoteLinkButtonVisualState): void { + if (this.#visualState === state) return + this.#visualState = state + + // Clear bitmap if we're no longer in bitmap state + if (state !== 'bitmap') { + this.#bitmapDataUrl = null + this.#remotePressed = false + } + + this.sendRuntimePropsChange() + this.triggerRedraw() + } + + /** + * Set the remote bitmap data URL and pressed state. + * Automatically transitions visual state to 'bitmap'. + */ + setBitmap(dataUrl: string, pressed: boolean): void { + this.#bitmapDataUrl = dataUrl + this.#remotePressed = pressed + + if (this.#visualState !== 'bitmap') { + this.#visualState = 'bitmap' + this.sendRuntimePropsChange() + } + + this.triggerRedraw() + } + + /** + * Set the human-readable peer name (for UI display). + */ + setPeerName(name: string | null): void { + if (this.#peerName === name) return + this.#peerName = name + this.sendRuntimePropsChange() + } + + /** + * Set the press handler callback (called by LinkController). + */ + setOnPress(handler: ((controlId: string, pressed: boolean, surfaceId: string | undefined) => void) | null): void { + this.#onPress = handler + } + + /** + * Set the config change callback (called by LinkController). + */ + setOnConfigChanged(handler: ((controlId: string) => void) | null): void { + this.#onConfigChanged = handler + } + + /** + * Update the link configuration fields. + * Called from tRPC when the user edits the control. + */ + setLinkConfig(config: Partial>): void { + let changed = false + + if (config.peerUuid !== undefined && config.peerUuid !== this.#config.peerUuid) { + this.#config = { ...this.#config, peerUuid: config.peerUuid } + changed = true + } + if (config.location !== undefined && config.location !== this.#config.location) { + this.#config = { ...this.#config, location: config.location } + this.#updateCachedVariables() + changed = true + } + + if (changed) { + this.commitChange(true) + this.#onConfigChanged?.(this.controlId) + } + } + + // ── ControlBase abstract implementations ───────────────────── + + getDrawStyle(): DrawStyleModel { + const style = this.#getInternalDrawStyle() + return this.#convertToDrawStyleModel(style) + } + + /** + * Get the internal draw style (our custom type for link button states). + * This is kept separate from DrawStyleModel to avoid coupling to the legacy button system. + */ + #getInternalDrawStyle(): RemoteLinkDrawStyle { + switch (this.#visualState) { + case 'bitmap': + return { + type: 'bitmap', + dataUrl: this.#bitmapDataUrl ?? '', + pressed: this.#remotePressed, + } + case 'unknown_peer': + return { + type: 'placeholder', + text: 'Unknown peer', + fontSize: 15, + isError: true, + } + case 'unreachable': + return { + type: 'placeholder', + text: 'Unreachable', + fontSize: 12, + isError: false, + } + case 'loading': + return { + type: 'placeholder', + text: 'Loading...', + fontSize: 14, + isError: false, + } + case 'loop_detected': + return { + type: 'placeholder', + text: 'Loop detected', + fontSize: 11, + isError: true, + } + } + } + + /** + * Convert our internal draw style to DrawStyleButtonModel for the graphics renderer. + * This is a minimal conversion - we don't use most of the button style properties. + */ + #convertToDrawStyleModel(style: RemoteLinkDrawStyle): DrawStyleButtonModel { + if (style.type === 'bitmap') { + return { + style: 'button', + text: '', + textExpression: undefined, + size: 'auto', + alignment: 'center:center', + pngalignment: 'center:center', + color: 0xffffff, + bgcolor: 0x000000, + show_topbar: false, + png64: style.dataUrl, + imageBuffers: [], + pushed: false, + cloud: true, + cloud_error: false, + stepCurrent: 1, + stepCount: 1, + button_status: undefined, + action_running: undefined, + } + } else { + // Placeholder state + return { + style: 'button', + text: style.text, + textExpression: undefined, + size: style.fontSize, + alignment: 'center:center', + pngalignment: 'center:center', + color: 0x999999, + bgcolor: 0x000000, + show_topbar: 'default', + png64: null, + imageBuffers: [], + pushed: false, + cloud: !style.isError, + cloud_error: style.isError, + stepCurrent: 1, + stepCount: 1, + button_status: undefined, + action_running: undefined, + } + } + } + + getBitmapSize(): { width: number; height: number } | null { + return { width: 72, height: 72 } + } + + /** + * Update the cached set of variable IDs referenced in the location field. + */ + #updateCachedVariables(): void { + const variables = new Set() + // Extract variable references from location field + // Regex matches $(label:variable) patterns + const reg = /\$\(([^:$)]+):([^$)]+)\)/g + const matches = this.#config.location.matchAll(reg) + for (const match of matches) { + variables.add(`${match[1]}:${match[2]}`) + } + this.#cachedVariables = variables + } + + collectReferencedConnectionsAndVariables( + _foundConnectionIds: Set, + foundConnectionLabels: Set, + foundVariables: Set + ): void { + // Use cached variables if available + if (this.#cachedVariables) { + for (const varId of this.#cachedVariables) { + foundVariables.add(varId) + const colonIndex = varId.indexOf(':') + if (colonIndex !== -1) { + foundConnectionLabels.add(varId.substring(0, colonIndex)) + } + } + } + } + + triggerLocationHasChanged(): void { + // Nothing location-dependent + } + + pressControl(_pressed: boolean, _surfaceId: string | undefined): void { + // Forward press to the LinkController via the callback + this.#onPress?.(this.controlId, _pressed, _surfaceId) + } + + toJSON(_clone = true): RemoteLinkButtonModel { + return { + type: this.#config.type, + peerUuid: this.#config.peerUuid, + location: this.#config.location, + } + } + + toRuntimeJSON(): RemoteLinkButtonRuntimeProps { + return { + visualState: this.#visualState, + peerName: this.#peerName, + } + } + + renameVariables(labelFrom: string, labelTo: string): void { + // Use the established VisitorReferencesUpdaterVisitor to handle variable renaming + const visitor = new VisitorReferencesUpdaterVisitor({ [labelFrom]: labelTo }, undefined) + const wrapper = { location: this.#config.location } + visitor.visitString(wrapper, 'location') + + if (wrapper.location !== this.#config.location) { + this.#config = { ...this.#config, location: wrapper.location } + this.#updateCachedVariables() + this.commitChange(true) + this.#onConfigChanged?.(this.controlId) + } + } + + onVariablesChanged(allChangedVariables: ReadonlySet): void { + // Early exit if no cached variables + if (!this.#cachedVariables) return + // Efficient check: if sets are disjoint (no overlap), no need to trigger update + if (this.#cachedVariables.isDisjointFrom(allChangedVariables)) return + + // At least one referenced variable changed, trigger config change to re-sync subscription + this.#onConfigChanged?.(this.controlId) + } +} diff --git a/companion/lib/Controls/Controller.ts b/companion/lib/Controls/Controller.ts index 173c540ca1..56b7785f67 100644 --- a/companion/lib/Controls/Controller.ts +++ b/companion/lib/Controls/Controller.ts @@ -2,6 +2,7 @@ import { ControlButtonNormal } from './ControlTypes/Button/Normal.js' import { ControlButtonPageDown } from './ControlTypes/PageDown.js' import { ControlButtonPageNumber } from './ControlTypes/PageNumber.js' import { ControlButtonPageUp } from './ControlTypes/PageUp.js' +import { ControlButtonRemoteLink } from './ControlTypes/LinkButton.js' import { CreateBankControlId, CreatePresetControlId, CreateTriggerControlId } from '@companion-app/shared/ControlId.js' import { ActionRunner } from './ActionRunner.js' import { ActionRecorder } from './ActionRecorder.js' @@ -302,6 +303,8 @@ export class ControlsController { return new ControlButtonPageUp(this.#createControlDependencies(), controlId, controlObj2, isImport) } else if (controlObj2?.type === 'pagedown' || (controlType === 'pagedown' && !controlObj2)) { return new ControlButtonPageDown(this.#createControlDependencies(), controlId, controlObj2, isImport) + } else if (controlObj2?.type === 'remotelinkbutton' || (controlType === 'remotelinkbutton' && !controlObj2)) { + return new ControlButtonRemoteLink(this.#createControlDependencies(), controlId, controlObj2, isImport) } } diff --git a/companion/lib/Controls/ControlsTrpcRouter.ts b/companion/lib/Controls/ControlsTrpcRouter.ts index d78e35a608..2ac27603e1 100644 --- a/companion/lib/Controls/ControlsTrpcRouter.ts +++ b/companion/lib/Controls/ControlsTrpcRouter.ts @@ -11,6 +11,7 @@ import type { Logger } from '../Log/Controller.js' import type { ControlCommonEvents } from './ControlDependencies.js' import type EventEmitter from 'node:events' import { JsonValueSchema } from '@companion-app/shared/Model/Options.js' +import { ControlButtonRemoteLink } from './ControlTypes/LinkButton.js' // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export function createControlsTrpcRouter( @@ -300,5 +301,27 @@ export function createControlsTrpcRouter( throw new Error(`Control "${input.controlId}" does not support options`) } }), + + setLinkConfig: publicProcedure + .input( + z.object({ + controlId: z.string(), + config: z.object({ + peerUuid: z.string().optional(), + location: z.string().optional(), + }), + }) + ) + .mutation(async ({ input }) => { + const control = controlsMap.get(input.controlId) + if (!control) return false + + if (control instanceof ControlButtonRemoteLink) { + control.setLinkConfig(input.config) + return true + } else { + throw new Error(`Control "${input.controlId}" is not a remote link button`) + } + }), } } diff --git a/companion/lib/Link/BitmapRenderer.ts b/companion/lib/Link/BitmapRenderer.ts new file mode 100644 index 0000000000..25d45a99fd --- /dev/null +++ b/companion/lib/Link/BitmapRenderer.ts @@ -0,0 +1,161 @@ +import EventEmitter from 'node:events' +import * as imageRs from '@julusian/image-rs' +import LogController from '../Log/Controller.js' +import type { GraphicsController } from '../Graphics/Controller.js' +import type { ImageResult } from '../Graphics/ImageResult.js' +import type { ControlLocation } from '@companion-app/shared/Model/Common.js' +import type { SubscriptionManager } from './SubscriptionManager.js' + +/** Key for cached bitmaps: page:row:col:widthxheight */ +type BitmapCacheKey = `${number}:${number}:${number}:${number}x${number}` + +/** A rendered bitmap ready to be published */ +export interface RenderedBitmap { + page: number + row: number + col: number + width: number + height: number + /** PNG data URL string, JSON-safe */ + dataUrl: string + /** Whether the button is currently pressed */ + pressed: boolean +} + +export type BitmapRendererEvents = { + /** Emitted when a bitmap has been rendered and is ready to publish */ + bitmapReady: [bitmap: RenderedBitmap] +} + +/** + * Handles scaling and encoding button bitmaps for Link. + * + * Listens to the GraphicsController's `button_drawn` events, and when a button + * that has active subscriptions changes, scales it to each requested resolution + * using image-rs, encodes to a PNG data URL, and emits `bitmapReady`. + */ +export class BitmapRenderer extends EventEmitter { + readonly #logger = LogController.createLogger('Link/BitmapRenderer') + readonly #graphics: GraphicsController + readonly #subscriptionManager: SubscriptionManager + + /** Cache of data URLs keyed by location + resolution */ + readonly #cache = new Map() + + /** Bound handler for button_drawn events */ + readonly #onButtonDrawn: (location: ControlLocation, render: ImageResult) => void + + constructor(graphics: GraphicsController, subscriptionManager: SubscriptionManager) { + super() + this.setMaxListeners(0) + this.#graphics = graphics + this.#subscriptionManager = subscriptionManager + + this.#onButtonDrawn = (location, render) => { + this.#handleButtonDrawn(location, render) + } + } + + /** + * Start listening for button state changes. + */ + start(): void { + this.#graphics.on('button_drawn', this.#onButtonDrawn) + } + + /** + * Stop listening and clear cache. + */ + stop(): void { + this.#graphics.off('button_drawn', this.#onButtonDrawn) + this.#cache.clear() + } + + /** + * Get the current bitmap for a button on demand at a specific resolution. + * Used for initial state when a subscription is first created. + */ + async renderOnDemand( + page: number, + row: number, + col: number, + width: number, + height: number + ): Promise { + const location: ControlLocation = { pageNumber: page, row, column: col } + const cached = this.#graphics.getCachedRender(location) + if (!cached) return null + + // Extract pressed state from ImageResult style + const pressed = typeof cached.style === 'object' && cached.style.style === 'button' ? cached.style.pushed : false + + const dataUrl = await this.#scaleToDataUrl(cached, width, height) + const cacheKey = this.#makeCacheKey(page, row, col, width, height) + this.#cache.set(cacheKey, dataUrl) + + return { page, row, col, width, height, dataUrl, pressed } + } + + /** + * Get a cached data URL if available (no re-render). + */ + getCached(page: number, row: number, col: number, width: number, height: number): string | undefined { + return this.#cache.get(this.#makeCacheKey(page, row, col, width, height)) + } + + /** + * Remove cached data URLs for a location at all resolutions. + * Called when the last subscriber at a given resolution unsubscribes. + */ + evictCache(page: number, row: number, col: number, width: number, height: number): void { + this.#cache.delete(this.#makeCacheKey(page, row, col, width, height)) + } + + /** + * Handle a button being redrawn by the graphics controller. + * Scale to each subscribed resolution and emit bitmapReady. + */ + #handleButtonDrawn(location: ControlLocation, render: ImageResult): void { + const page = location.pageNumber + const row = location.row + const col = location.column + + // Extract pressed state from ImageResult style + const pressed = typeof render.style === 'object' && render.style.style === 'button' ? render.style.pushed : false + + // Get all resolutions subscribed for this location + const resolutions = this.#subscriptionManager.getResolutionsForLocation(page, row, col) + if (resolutions.length === 0) return + + // Scale and emit for each resolution + for (const { width, height } of resolutions) { + this.#scaleToDataUrl(render, width, height) + .then((dataUrl) => { + const cacheKey = this.#makeCacheKey(page, row, col, width, height) + this.#cache.set(cacheKey, dataUrl) + this.emit('bitmapReady', { page, row, col, width, height, dataUrl, pressed }) + }) + .catch((err) => { + this.#logger.warn(`Failed to scale bitmap for ${page}/${row}/${col} @ ${width}x${height}: ${err}`) + }) + } + } + + /** + * Scale an ImageResult to the target resolution and return a PNG data URL. + */ + async #scaleToDataUrl(render: ImageResult, width: number, height: number): Promise { + const image = imageRs.ImageTransformer.fromBuffer( + render.buffer, + render.bufferWidth, + render.bufferHeight, + 'rgba' + ).scale(width, height, 'Fit') + + return image.toDataUrl('png') + } + + #makeCacheKey(page: number, row: number, col: number, width: number, height: number): BitmapCacheKey { + return `${page}:${row}:${col}:${width}x${height}` + } +} diff --git a/companion/lib/Link/ChunkManager.ts b/companion/lib/Link/ChunkManager.ts new file mode 100644 index 0000000000..3f19a2bced --- /dev/null +++ b/companion/lib/Link/ChunkManager.ts @@ -0,0 +1,255 @@ +import { v4 } from 'uuid' +import EventEmitter from 'node:events' +import LogController from '../Log/Controller.js' +import { crc32 } from './Crc32.js' + +/** Maximum chunk payload size (~63KB to stay under 64KB MQTT broker limit) */ +const MAX_CHUNK_SIZE = 63 * 1024 + +/** Timeout for incomplete reassembly buffers (60 seconds) */ +const REASSEMBLY_TIMEOUT_MS = 60_000 + +/** Interval for cleaning up stale reassembly buffers (10 seconds) */ +const CLEANUP_INTERVAL_MS = 10_000 + +/** Separator between JSON header and binary data in a chunk message */ +const CHUNK_SEPARATOR = '\n' + +/** Header prepended to chunked messages so receivers know the payload was chunked */ +export interface ChunkHeader { + /** Unique message ID for reassembly */ + id: string + /** Chunk index (0-based) */ + idx: number + /** Total number of chunks */ + total: number + /** Total assembled size in bytes */ + size: number + /** CRC32 of this chunk's binary data */ + crc: number +} + +/** A chunk ready to be published */ +export interface OutboundChunk { + /** The chunk payload (JSON header + newline + binary data) */ + payload: Buffer +} + +/** Internal reassembly buffer */ +interface ReassemblyBuffer { + /** Total number of chunks expected */ + total: number + /** Total expected assembled size */ + size: number + /** Received chunks indexed by position */ + chunks: Map + /** Timestamp when first chunk was received */ + firstChunkTime: number +} + +export type ChunkManagerEvents = { + /** Emitted when a complete message has been reassembled */ + assembled: [messageId: string, data: Buffer] +} + +/** + * Handles chunking of large messages and reassembly of received chunks. + * + * Chunking protocol: + * - Messages larger than MAX_CHUNK_SIZE are split into chunks + * - Each chunk is a Buffer: JSON header + '\n' + binary data + * - The JSON header contains id, idx, total, size, and crc fields + * - Receiver reassembles chunks by message ID and verifies CRC32 per chunk + * - Incomplete messages are discarded after REASSEMBLY_TIMEOUT_MS + */ +export class ChunkManager extends EventEmitter { + readonly #logger = LogController.createLogger('Link/ChunkManager') + + /** Active reassembly buffers keyed by message ID */ + readonly #reassemblyBuffers = new Map() + + /** Cleanup interval handle */ + #cleanupInterval: ReturnType | null = null + + /** + * Check whether a payload needs chunking. + */ + needsChunking(data: Buffer): boolean { + return data.length > MAX_CHUNK_SIZE + } + + /** + * Split a large payload into chunks ready for publishing. + * Each chunk is a self-contained Buffer with a JSON header and binary data. + * + * @param data - The complete binary payload to chunk + * @returns Array of outbound chunks + */ + chunkify(data: Buffer): OutboundChunk[] { + const messageId = v4() + const totalSize = data.length + const chunkCount = Math.ceil(totalSize / MAX_CHUNK_SIZE) + const chunks: OutboundChunk[] = [] + + for (let i = 0; i < chunkCount; i++) { + const start = i * MAX_CHUNK_SIZE + const end = Math.min(start + MAX_CHUNK_SIZE, totalSize) + const chunkData = data.subarray(start, end) + + const header: ChunkHeader = { + id: messageId, + idx: i, + total: chunkCount, + size: totalSize, + crc: crc32(chunkData), + } + + const headerJson = JSON.stringify(header) + const headerBuf = Buffer.from(headerJson, 'utf-8') + const separatorBuf = Buffer.from(CHUNK_SEPARATOR, 'utf-8') + + const payload = Buffer.concat([headerBuf, separatorBuf, chunkData]) + chunks.push({ payload }) + } + + return chunks + } + + /** + * Process a received chunk message. + * When all chunks for a message are received, emits 'assembled' with the complete data. + * + * @param payload - Raw chunk message (JSON header + '\n' + binary data) + */ + handleChunk(payload: Buffer): void { + // Parse header: find the newline separator + const separatorIndex = payload.indexOf(CHUNK_SEPARATOR) + if (separatorIndex === -1) { + this.#logger.warn('Received chunk without header separator') + return + } + + const headerStr = payload.subarray(0, separatorIndex).toString('utf-8') + const chunkData = payload.subarray(separatorIndex + 1) + + let header: ChunkHeader + try { + header = JSON.parse(headerStr) + } catch { + this.#logger.warn('Failed to parse chunk header') + return + } + + // Validate header fields + if ( + typeof header.id !== 'string' || + typeof header.idx !== 'number' || + typeof header.total !== 'number' || + typeof header.size !== 'number' || + typeof header.crc !== 'number' + ) { + this.#logger.warn('Invalid chunk header fields') + return + } + + // Verify CRC32 of chunk data + const computedCrc = crc32(chunkData) + if (computedCrc !== header.crc) { + this.#logger.warn(`CRC32 mismatch for chunk ${header.idx} of message ${header.id}`) + return + } + + // Get or create reassembly buffer + let buffer = this.#reassemblyBuffers.get(header.id) + if (!buffer) { + buffer = { + total: header.total, + size: header.size, + chunks: new Map(), + firstChunkTime: Date.now(), + } + this.#reassemblyBuffers.set(header.id, buffer) + } + + // Validate consistency + if (buffer.total !== header.total || buffer.size !== header.size) { + this.#logger.warn(`Inconsistent chunk metadata for message ${header.id}`) + this.#reassemblyBuffers.delete(header.id) + return + } + + // Store chunk (ignore duplicates) + if (!buffer.chunks.has(header.idx)) { + buffer.chunks.set(header.idx, chunkData) + } + + // Check if all chunks received + if (buffer.chunks.size === buffer.total) { + this.#assemble(header.id, buffer) + } + } + + /** + * Start the background cleanup timer for stale reassembly buffers. + */ + start(): void { + this.stop() + this.#cleanupInterval = setInterval(() => this.#cleanupStale(), CLEANUP_INTERVAL_MS) + } + + /** + * Stop the cleanup timer and clear all reassembly buffers. + */ + stop(): void { + if (this.#cleanupInterval) { + clearInterval(this.#cleanupInterval) + this.#cleanupInterval = null + } + this.#reassemblyBuffers.clear() + } + + /** + * Assemble a complete message from all received chunks. + */ + #assemble(messageId: string, buffer: ReassemblyBuffer): void { + this.#reassemblyBuffers.delete(messageId) + + // Build the complete payload in order + const parts: Buffer[] = [] + for (let i = 0; i < buffer.total; i++) { + const chunk = buffer.chunks.get(i) + if (!chunk) { + this.#logger.warn(`Missing chunk ${i} for message ${messageId} during assembly`) + return + } + parts.push(chunk) + } + + const assembled = Buffer.concat(parts) + + // Verify total size + if (assembled.length !== buffer.size) { + this.#logger.warn( + `Size mismatch for message ${messageId}: expected ${buffer.size}, got ${assembled.length}` + ) + return + } + + this.emit('assembled', messageId, assembled) + } + + /** + * Remove reassembly buffers that have timed out. + */ + #cleanupStale(): void { + const now = Date.now() + for (const [messageId, buffer] of this.#reassemblyBuffers) { + if (now - buffer.firstChunkTime > REASSEMBLY_TIMEOUT_MS) { + this.#logger.debug( + `Discarding incomplete message ${messageId} (${buffer.chunks.size}/${buffer.total} chunks after ${REASSEMBLY_TIMEOUT_MS}ms)` + ) + this.#reassemblyBuffers.delete(messageId) + } + } + } +} diff --git a/companion/lib/Link/Controller.ts b/companion/lib/Link/Controller.ts new file mode 100644 index 0000000000..bfc96b81f5 --- /dev/null +++ b/companion/lib/Link/Controller.ts @@ -0,0 +1,1080 @@ +import isEqual from 'fast-deep-equal' +import { v4 } from 'uuid' +import EventEmitter from 'node:events' +import LogController from '../Log/Controller.js' +import type { AppInfo } from '../Registry.js' +import type { DataDatabase } from '../Data/Database.js' +import type { DataStoreTableView } from '../Data/StoreBase.js' +import { publicProcedure, router, toIterable } from '../UI/TRPC.js' +import z from 'zod' +import { + DEFAULT_MQTT_CONFIG, + type LinkControllerState, + type LinkPeerInfo, + type LinkTransportConfig, + type LinkTransportState, +} from '@companion-app/shared/Model/Link.js' +import { TransportManager } from './TransportManager.js' +import { PeerRegistry } from './PeerRegistry.js' +import { + discoveryTopic, + discoveryWildcard, + updateTopic, + updateWildcard, + pressTopic, + releaseTopic, + pressWildcard, + releaseWildcard, + rpcRequestTopic, + rpcResponseTopic, + rpcRequestWildcard, + LINK_TOPIC_PREFIX, + type AnnouncementMessage, + type AnnouncementPayload, + type SubscribeRequestMessage, + type SubscribeResponseMessage, + type ButtonUpdateMessage, + type ButtonPressMessage, + type ButtonReleaseMessage, + type LinkMessage, +} from './Protocol.js' +import { stringifyError } from '@companion-app/shared/Stringify.js' +import type { DataUserConfig } from '../Data/UserConfig.js' +import type { IPageStore } from '../Page/Store.js' +import type { ControlsController } from '../Controls/Controller.js' +import type { GraphicsController } from '../Graphics/Controller.js' +import { SubscriptionManager } from './SubscriptionManager.js' +import { BitmapRenderer } from './BitmapRenderer.js' +import { ControlButtonRemoteLink } from '../Controls/ControlTypes/LinkButton.js' +import type { ControlCommonEvents } from '../Controls/ControlDependencies.js' + +const LINK_TABLE = 'link' + +/** Announcement interval in ms (60 seconds) */ +const ANNOUNCEMENT_INTERVAL = 15_000 + +/** DB schema for the link table */ +interface LinkDbTable { + uuid: string + settings: LinkSettings + transports: LinkTransportConfig[] +} + +interface LinkSettings { + enabled: boolean +} + +/** Events emitted to the UI via tRPC subscriptions */ +export type LinkUIEvents = { + controllerState: [state: LinkControllerState] + transportStates: [states: LinkTransportState[]] + peers: [peers: LinkPeerInfo[]] + transports: [configs: LinkTransportConfig[]] +} + +/** + * Main orchestrator for the Companion Link system. + * Manages transports, peer discovery, announcement lifecycle, + * inbound subscriptions (other peers reading our buttons), and + * outbound subscriptions (our link buttons reading remote buttons). + */ +export class LinkController { + readonly #logger = LogController.createLogger('Link/Controller') + + readonly #appInfo: AppInfo + readonly #userconfig: DataUserConfig + readonly #pageStore: IPageStore + readonly #dbTable: DataStoreTableView + readonly #transportManager: TransportManager + readonly #peerRegistry: PeerRegistry + readonly #uiEvents = new EventEmitter() + + // Phase 2+3 dependencies + readonly #controls: ControlsController + readonly #controlEvents: EventEmitter + + // Phase 2: Inbound (other peers subscribing to our buttons) + readonly #subscriptionManager: SubscriptionManager + readonly #bitmapRenderer: BitmapRenderer + + // Phase 3: Outbound (our link buttons subscribing to remote buttons) + /** Map of controlId → tracked subscription info */ + readonly #outboundSubs = new Map< + string, + { peerUuid: string; page: number; row: number; col: number; subscribed: boolean } + >() + + /** Set of peer UUIDs we are currently listening on for updates */ + readonly #subscribedUpdatePeers = new Set() + + /** Pending RPC response callbacks keyed by correlationId */ + readonly #pendingRpc = new Map void>() + + /** Current controller state */ + #state: LinkControllerState + + /** Transport configurations */ + #transportConfigs: LinkTransportConfig[] + + /** Announcement interval handle */ + #announcementInterval: ReturnType | null = null + + constructor( + appInfo: AppInfo, + db: DataDatabase, + userconfig: DataUserConfig, + pageStore: IPageStore, + controls: ControlsController, + graphics: GraphicsController, + controlEvents: EventEmitter + ) { + this.#appInfo = appInfo + this.#userconfig = userconfig + this.#pageStore = pageStore + this.#controls = controls + this.#controlEvents = controlEvents + this.#dbTable = db.getTableView(LINK_TABLE) + + this.#uiEvents.setMaxListeners(0) + + // Load or initialize state from DB + const uuid = this.#dbTable.getPrimitiveOrDefault('uuid', v4()) + + const settings = this.#dbTable.getOrDefault('settings', { + enabled: false, + }) + + this.#state = { enabled: settings.enabled, uuid } + + // Load transport configurations + this.#transportConfigs = this.#dbTable.getOrDefault('transports', []) + + // Create managers + this.#transportManager = new TransportManager() + this.#peerRegistry = new PeerRegistry(uuid) + + // Wire transport manager events + this.#transportManager.on('transportStatusChanged', (states: LinkTransportState[]) => { + this.#uiEvents.emit('transportStates', states) + }) + + this.#transportManager.on('message', (_transportId: string, topic: string, payload: Buffer) => { + this.#handleIncomingMessage(_transportId, topic, payload) + }) + + // Wire peer registry events + this.#peerRegistry.on('peersChanged', (peers: LinkPeerInfo[]) => { + this.#uiEvents.emit('peers', peers) + // Re-evaluate link button states when peer availability changes + this.#syncOutboundSubscriptions() + }) + + // Phase 2: Inbound subscription system + this.#subscriptionManager = new SubscriptionManager() + this.#bitmapRenderer = new BitmapRenderer(graphics, this.#subscriptionManager) + + // When BitmapRenderer has a rendered bitmap, publish it + this.#bitmapRenderer.on('bitmapReady', (bitmap) => { + this.#publishButtonUpdate( + bitmap.page, + bitmap.row, + bitmap.col, + bitmap.width, + bitmap.height, + bitmap.dataUrl, + bitmap.pressed + ) + }) + + // When a subscription is removed, evict the bitmap cache + this.#subscriptionManager.on('subscriptionRemoved', (page, row, col, width, height) => { + this.#bitmapRenderer.evictCache(page, row, col, width, height) + }) + + // Phase 3: React to link button creation/deletion + this.#controlEvents.on('controlCountChanged', () => { + this.#syncOutboundSubscriptions() + }) + + // Start if enabled + if (settings.enabled) { + this.#start().catch((e) => { + this.#logger.error(`Failed to start Link service: ${stringifyError(e)}`) + }) + } + + // Re-announce when Installation Name or gridSize changes + this.#userconfig.on('keyChanged', (key) => { + if ((key === 'installName' || key === 'gridSize') && this.#state.enabled) { + this.#sendAnnouncement().catch((e) => { + this.#logger.warn(`Failed to re-announce after ${key} change: ${stringifyError(e)}`) + }) + } + }) + + // Re-announce when page count changes + this.#pageStore.on('pagecount', () => { + if (this.#state.enabled) { + this.#sendAnnouncement().catch((e) => { + this.#logger.warn(`Failed to re-announce after page count change: ${stringifyError(e)}`) + }) + } + }) + } + + createTrpcRouter() { + const self = this + return router({ + // ── State subscription ────────────────────────────────────── + watchState: publicProcedure.subscription(async function* ({ signal }) { + const changes = toIterable(self.#uiEvents, 'controllerState', signal) + yield self.#state + for await (const [change] of changes) { + yield change + } + }), + + // ── Transport states subscription ─────────────────────────── + watchTransportStates: publicProcedure.subscription(async function* ({ signal }) { + const changes = toIterable(self.#uiEvents, 'transportStates', signal) + yield self.#transportManager.getTransportStates() + for await (const [change] of changes) { + yield change + } + }), + + // ── Peers subscription ────────────────────────────────────── + watchPeers: publicProcedure.subscription(async function* ({ signal }) { + const changes = toIterable(self.#uiEvents, 'peers', signal) + yield self.#peerRegistry.getPeers() + for await (const [change] of changes) { + yield change + } + }), + + // ── Transport configs subscription ────────────────────────── + watchTransportConfigs: publicProcedure.subscription(async function* ({ signal }) { + const changes = toIterable(self.#uiEvents, 'transports', signal) + yield self.#transportConfigs + for await (const [change] of changes) { + yield change + } + }), + + // ── Enable / disable Link ─────────────────────────────────── + setEnabled: publicProcedure.input(z.object({ enabled: z.boolean() })).mutation(async ({ input }) => { + if (input.enabled === this.#state.enabled) return + + this.#setState({ enabled: input.enabled }) + this.#saveSettings() + + if (input.enabled) { + await this.#start() + } else { + await this.#stop() + } + }), + + // ── Regenerate UUID ────────────────────────────────────────── + regenerateUUID: publicProcedure.mutation(async () => { + const newUuid = v4() + this.#setState({ uuid: newUuid }) + this.#dbTable.setPrimitive('uuid', newUuid) + this.#peerRegistry.setSelfUuid(newUuid) + + // If running, re-announce with new UUID + if (this.#state.enabled) { + await this.#sendAnnouncement() + } + }), + + // ── Add transport ─────────────────────────────────────────── + addTransport: publicProcedure + .input( + z.object({ + type: z.enum(['mqtt']), + label: z.string().min(1).max(100), + }) + ) + .mutation(async ({ input }) => { + const config: LinkTransportConfig = { + id: v4(), + type: input.type, + label: input.label, + enabled: false, + config: DEFAULT_MQTT_CONFIG, + } + + this.#transportConfigs.push(config) + this.#saveTransportConfigs() + + if (this.#state.enabled) { + await this.#transportManager.addTransport(config) + } + + return config.id + }), + + // ── Update transport config ───────────────────────────────── + updateTransport: publicProcedure + .input( + z.object({ + id: z.string(), + label: z.string().min(1).max(100).optional(), + enabled: z.boolean().optional(), + config: z + .object({ + brokerUrl: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + tls: z.boolean().optional(), + }) + .optional(), + }) + ) + .mutation(async ({ input }) => { + const idx = this.#transportConfigs.findIndex((t) => t.id === input.id) + if (idx === -1) throw new Error(`Transport ${input.id} not found`) + + const existing = this.#transportConfigs[idx] + + const updated: LinkTransportConfig = { + ...existing, + label: input.label ?? existing.label, + enabled: input.enabled ?? existing.enabled, + config: { + ...existing.config, + ...input.config, + }, + } + + this.#transportConfigs[idx] = updated + this.#saveTransportConfigs() + + if (this.#state.enabled) { + await this.#transportManager.addTransport(updated) + } + }), + + // ── Remove transport ──────────────────────────────────────── + removeTransport: publicProcedure.input(z.object({ id: z.string() })).mutation(async ({ input }) => { + const idx = this.#transportConfigs.findIndex((t) => t.id === input.id) + if (idx === -1) return + + this.#transportConfigs.splice(idx, 1) + this.#saveTransportConfigs() + + this.#peerRegistry.removeTransport(input.id) + await this.#transportManager.removeTransport(input.id) + }), + + // ── Delete a discovered peer ──────────────────────────────── + deletePeer: publicProcedure.input(z.object({ peerId: z.string() })).mutation(({ input }) => { + this.#peerRegistry.deletePeer(input.peerId) + }), + }) + } + + /** + * Shutdown the Link controller. + */ + async destroy(): Promise { + await this.#stop() + } + + // ── Private Lifecycle ───────────────────────────────────────────── + + async #start(): Promise { + this.#logger.info('Starting Link service') + this.#peerRegistry.start() + + // Start all configured transports + for (const config of this.#transportConfigs) { + await this.#transportManager.addTransport(config) + } + + // Subscribe to discovery announcements + await this.#transportManager.subscribeAll(discoveryWildcard()) + + // Subscribe to inbound RPC requests (other peers subscribing to our buttons) + await this.#transportManager.subscribeAll(rpcRequestWildcard(this.#state.uuid)) + + // Subscribe to inbound press/release commands for our buttons + await this.#transportManager.subscribeAll(pressWildcard(this.#state.uuid)) + await this.#transportManager.subscribeAll(releaseWildcard(this.#state.uuid)) + + // Start the bitmap renderer (listens for button_drawn events) + this.#bitmapRenderer.start() + + // Send initial announcement + await this.#sendAnnouncement() + + // Start periodic announcements + this.#announcementInterval = setInterval(() => { + this.#sendAnnouncement().catch((e) => { + this.#logger.warn(`Failed to send periodic announcement: ${stringifyError(e)}`) + }) + }, ANNOUNCEMENT_INTERVAL) + + // Initial scan for existing link buttons + this.#syncOutboundSubscriptions() + } + + async #stop(): Promise { + this.#logger.info('Stopping Link service') + + if (this.#announcementInterval) { + clearInterval(this.#announcementInterval) + this.#announcementInterval = null + } + + // Stop bitmap renderer + this.#bitmapRenderer.stop() + + // Clear inbound subscriptions + this.#subscriptionManager.clear() + + // Clear outbound subscriptions + this.#outboundSubs.clear() + this.#subscribedUpdatePeers.clear() + this.#pendingRpc.clear() + + // Send empty retained message to signal offline + try { + await this.#transportManager.publishToAll(discoveryTopic(this.#state.uuid), '', { retain: true }) + } catch (e) { + this.#logger.warn(`Failed to send offline announcement: ${stringifyError(e)}`) + } + + this.#peerRegistry.stop() + await this.#transportManager.removeAll() + } + + async #sendAnnouncement(): Promise { + const installName = this.#userconfig.getKey('installName') as string + const name = + installName && installName.length > 0 ? installName : `Companion (${this.#appInfo.machineId.slice(0, 8)})` + + const gridSize = this.#userconfig.getKey('gridSize') + const pageCount = this.#pageStore.getPageCount() + + const payload: AnnouncementPayload = { + id: this.#state.uuid, + name, + version: this.#appInfo.appVersion, + protocolVersion: 1, + pageCount, + gridSize: { + rows: gridSize.maxRow - gridSize.minRow + 1, + cols: gridSize.maxColumn - gridSize.minColumn + 1, + }, + timestamp: Date.now(), + } + + const message: AnnouncementMessage = { + version: 1, + type: 'announcement', + payload, + } + + const topic = discoveryTopic(this.#state.uuid) + await this.#transportManager.publishToAll(topic, JSON.stringify(message), { retain: true }) + } + + // ── Message Handling ────────────────────────────────────────────── + + #handleIncomingMessage(transportId: string, topic: string, payload: Buffer): void { + const prefix = `${LINK_TOPIC_PREFIX}/` + if (!topic.startsWith(prefix)) return + + if (topic.startsWith(`${LINK_TOPIC_PREFIX}/discovery/`)) { + this.#handleDiscoveryMessage(transportId, topic, payload) + return + } + + // Parse topic: companion-link//... + const rest = topic.slice(prefix.length) + const slashIdx = rest.indexOf('/') + if (slashIdx === -1) return + + const topicUuid = rest.slice(0, slashIdx) + const subPath = rest.slice(slashIdx + 1) + + // Inbound RPC requests to our UUID + if (topicUuid === this.#state.uuid && subPath.startsWith('rpc/') && subPath.endsWith('/request')) { + this.#handleRpcRequest(transportId, subPath, payload) + return + } + + // Inbound RPC responses (to our UUID, for our pending RPC calls) + if (topicUuid === this.#state.uuid && subPath.startsWith('rpc/') && subPath.endsWith('/response')) { + this.#handleRpcResponse(subPath, payload) + return + } + + // Inbound press/release commands to our UUID + if (topicUuid === this.#state.uuid && subPath.startsWith('location/')) { + this.#handleInboundCommand(subPath, payload) + return + } + + // Outbound: button update from a remote peer we've subscribed to + if (topicUuid !== this.#state.uuid && subPath.startsWith('location/') && subPath.includes('/update/')) { + this.#handleRemoteButtonUpdate(topicUuid, subPath, payload) + return + } + + this.#logger.silly(`Unhandled message on topic: ${topic}`) + } + + /** + * Handle an inbound RPC request (subscribe/unsubscribe from another peer). + */ + #handleRpcRequest(transportId: string, subPath: string, payload: Buffer): void { + // subPath format: rpc//request + const match = subPath.match(/^rpc\/([^/]+)\/request$/) + if (!match) return + + const correlationId = match[1] + + let message: LinkMessage + try { + message = JSON.parse(payload.toString('utf-8')) as LinkMessage + } catch { + this.#logger.warn(`Failed to parse RPC request`) + return + } + + if (message.type === 'subscribe.request') { + this.#handleSubscribeRequest(transportId, correlationId, message as SubscribeRequestMessage) + } else if (message.type === 'unsubscribe.request') { + this.#handleUnsubscribeRequest(message.payload) + } else { + this.#logger.warn(`Unknown RPC request type: ${message.type}`) + } + } + + /** + * Handle an inbound RPC response from a remote peer. + */ + #handleRpcResponse(subPath: string, payload: Buffer): void { + // subPath format: rpc//response + const match = subPath.match(/^rpc\/([^/]+)\/response$/) + if (!match) return + + const correlationId = match[1] + + let message: LinkMessage + try { + message = JSON.parse(payload.toString('utf-8')) as LinkMessage + } catch { + this.#logger.warn(`Failed to parse RPC response`) + return + } + + const callback = this.#pendingRpc.get(correlationId) + if (callback) { + this.#pendingRpc.delete(correlationId) + callback(message) + } + } + + /** + * Handle a subscribe request from a remote peer. + */ + #handleSubscribeRequest(_transportId: string, correlationId: string, message: SubscribeRequestMessage): void { + const requestorId = message.payload.requestorId + + const responseButtons: Array<{ + page: number + row: number + col: number + width: number + height: number + pressed: boolean + dataUrl: string | null + sourceChain: string[] + }> = [] + + for (const btn of message.payload.buttons) { + this.#subscriptionManager.subscribe(requestorId, btn.page, btn.row, btn.col, btn.width, btn.height) + + // Get current state + const cached = this.#bitmapRenderer.getCached(btn.page, btn.row, btn.col, btn.width, btn.height) + + // Check if the button is currently pressed + const location = { pageNumber: btn.page, row: btn.row, column: btn.col } + const controlId = this.#pageStore.getControlIdAt(location) + const control = controlId ? this.#controls.getControl(controlId) : undefined + const pressed = control?.supportsPushed ? false : false // TODO: get actual pressed state + + responseButtons.push({ + page: btn.page, + row: btn.row, + col: btn.col, + width: btn.width, + height: btn.height, + pressed, + dataUrl: cached ?? null, + sourceChain: [this.#state.uuid], + }) + + // If no cached render, render on demand + if (!cached) { + this.#bitmapRenderer + .renderOnDemand(btn.page, btn.row, btn.col, btn.width, btn.height) + .then((result) => { + if (result) { + this.#publishButtonUpdate( + btn.page, + btn.row, + btn.col, + btn.width, + btn.height, + result.dataUrl, + result.pressed + ) + } + }) + .catch((err) => { + this.#logger.warn(`Failed to render on demand: ${stringifyError(err)}`) + }) + } + } + + // Send subscribe response back to the requestor + const responseMsg: SubscribeResponseMessage = { + version: 1, + type: 'subscribe.response', + payload: { + states: responseButtons, + timestamp: Date.now(), + }, + } + + const responseTopic = rpcResponseTopic(requestorId, correlationId) + this.#transportManager.publishToAll(responseTopic, JSON.stringify(responseMsg)).catch((err) => { + this.#logger.warn(`Failed to send subscribe response to ${requestorId}: ${stringifyError(err)}`) + }) + + this.#logger.debug( + `Processed subscribe request from ${requestorId} with ${message.payload.buttons.length} buttons (correlationId=${correlationId})` + ) + } + + /** + * Handle an unsubscribe request from a remote peer. + */ + #handleUnsubscribeRequest(payload: unknown): void { + const unsubPayload = payload as { + buttons?: Array<{ page: number; row: number; col: number; width: number; height: number }> + } + if (unsubPayload.buttons) { + for (const btn of unsubPayload.buttons) { + this.#subscriptionManager.unsubscribe('remote', btn.page, btn.row, btn.col, btn.width, btn.height) + } + } + } + + /** + * Handle an inbound press/release command (remote peer pressing one of our buttons). + */ + #handleInboundCommand(subPath: string, payload: Buffer): void { + // subPath format: location////press or /release + const match = subPath.match(/^location\/(\d+)\/(\d+)\/(\d+)\/(press|release)$/) + if (!match) return + + const page = Number(match[1]) + const row = Number(match[2]) + const col = Number(match[3]) + const action = match[4] + + // Parse the payload to extract sourceUuid and surfaceId + let message: ButtonPressMessage | ButtonReleaseMessage + try { + message = JSON.parse(payload.toString('utf-8')) + } catch { + this.#logger.warn(`Failed to parse inbound ${action} command`) + return + } + + const sourceUuid = message.payload.sourceUuid + const remoteSurfaceId = message.payload.surfaceId + + // Prepend source UUID to surfaceId to make it globally unique + const surfaceId = remoteSurfaceId ? `${sourceUuid}:${remoteSurfaceId}` : undefined + + const location = { pageNumber: page, row, column: col } + const controlId = this.#pageStore.getControlIdAt(location) + if (!controlId) { + this.#logger.debug(`No control at page=${page} row=${row} col=${col} for inbound ${action}`) + return + } + + this.#logger.debug( + `Inbound ${action} for control ${controlId} at page=${page} row=${row} col=${col} from ${sourceUuid}` + ) + this.#controls.pressControl(controlId, action === 'press', surfaceId) + } + + /** + * Handle a button update message from a remote peer (for our outbound subscriptions). + */ + #handleRemoteButtonUpdate(peerUuid: string, subPath: string, payload: Buffer): void { + // subPath format: location////update/ + const match = subPath.match(/^location\/(\d+)\/(\d+)\/(\d+)\/update\/(\d+)x(\d+)$/) + if (!match) return + + const page = Number(match[1]) + const row = Number(match[2]) + const col = Number(match[3]) + + let message: ButtonUpdateMessage + try { + message = JSON.parse(payload.toString('utf-8')) as ButtonUpdateMessage + } catch { + this.#logger.warn(`Failed to parse button update from ${peerUuid}`) + return + } + + if (message.type !== 'button.update') return + + const updatePayload = message.payload + + // Loop detection: check if our UUID is in the source chain + const isLoop = updatePayload.sourceChain?.includes(this.#state.uuid) + + // Find all link buttons targeting this peer + location + for (const [controlId, sub] of this.#outboundSubs) { + if (sub.peerUuid === peerUuid && sub.page === page && sub.row === row && sub.col === col) { + const control = this.#controls.getControl(controlId) + if (control instanceof ControlButtonRemoteLink) { + if (isLoop) { + control.setVisualState('loop_detected') + } else if (updatePayload.dataUrl) { + control.setBitmap(updatePayload.dataUrl, updatePayload.pressed ?? false) + } + } + } + } + } + + /** + * Publish a button update for a subscribed location. + */ + #publishButtonUpdate( + page: number, + row: number, + col: number, + width: number, + height: number, + dataUrl: string, + pressed: boolean + ): void { + if (!this.#state.enabled) return + + const updateMsg: ButtonUpdateMessage = { + version: 1, + type: 'button.update', + payload: { + page, + row, + col, + width, + height, + pressed, + dataUrl, + sourceChain: [this.#state.uuid], + timestamp: Date.now(), + }, + } + + const topic = updateTopic(this.#state.uuid, page, row, col, width, height) + this.#transportManager.publishToAll(topic, JSON.stringify(updateMsg)).catch((err) => { + this.#logger.warn(`Failed to publish button update: ${stringifyError(err)}`) + }) + } + + // ── Phase 3: Outbound Subscription Management ──────────────────── + + /** + * Scan all controls for remotelinkbutton types and synchronize outbound subscriptions. + */ + #syncOutboundSubscriptions(): void { + if (!this.#state.enabled) return + + const allControls = this.#controls.getAllControls() + const activeControlIds = new Set() + + // Find all link buttons and register/update subscriptions + for (const [controlId, control] of allControls) { + if (!(control instanceof ControlButtonRemoteLink)) continue + + activeControlIds.add(controlId) + + const peerUuid = control.peerUuid + const parsedLocation = control.parseLocation(undefined) + + const page = parsedLocation?.pageNumber + const row = parsedLocation?.row + const col = parsedLocation?.column + + if (!peerUuid || page === undefined || row === undefined || col === undefined) { + // Not fully configured or location contains unresolved variables + control.setVisualState('unknown_peer') + + // Set up the press handler even if not configured + control.setOnPress((cid, pressed, surfaceId) => { + this.#handleLinkButtonPress(cid, pressed, surfaceId) + }) + + // Remove from outbound if previously tracked + this.#outboundSubs.delete(controlId) + continue + } + + // Set up press handler + control.setOnPress((cid, pressed, surfaceId) => { + this.#handleLinkButtonPress(cid, pressed, surfaceId) + }) + + // Set up config change handler + control.setOnConfigChanged(() => { + this.#syncOutboundSubscriptions() + }) + + // Set peer name from registry + const peer = this.#peerRegistry.getPeers().find((p) => p.id === peerUuid) + control.setPeerName(peer?.name ?? null) + + // Check peer status + if (!peer) { + control.setVisualState('unknown_peer') + this.#outboundSubs.delete(controlId) + continue + } + + if (!peer.online) { + control.setVisualState('unreachable') + this.#outboundSubs.delete(controlId) + continue + } + + // Track the subscription + const existing = this.#outboundSubs.get(controlId) + if ( + !existing || + existing.peerUuid !== peerUuid || + existing.page !== page || + existing.row !== row || + existing.col !== col + ) { + // New or changed subscription + this.#outboundSubs.set(controlId, { peerUuid, page, row, col, subscribed: false }) + control.setVisualState('loading') + + // Ensure we're listening for updates from this peer + this.#ensureUpdateSubscription(peerUuid) + + // Send subscribe request + this.#sendSubscribeRequest(peerUuid, page, row, col) + } + } + + // Clean up removed link buttons + for (const [controlId] of this.#outboundSubs) { + if (!activeControlIds.has(controlId)) { + this.#outboundSubs.delete(controlId) + } + } + + // Clean up update subscriptions for peers no longer referenced + this.#cleanUnusedPeerSubscriptions() + } + + /** + * Ensure we're subscribed to button updates from a specific peer on MQTT. + */ + #ensureUpdateSubscription(peerUuid: string): void { + if (this.#subscribedUpdatePeers.has(peerUuid)) return + this.#subscribedUpdatePeers.add(peerUuid) + + this.#transportManager.subscribeAll(updateWildcard(peerUuid)).catch((err) => { + this.#logger.warn(`Failed to subscribe to updates from ${peerUuid}: ${stringifyError(err)}`) + }) + } + + /** + * Unsubscribe from peers that are no longer needed. + */ + #cleanUnusedPeerSubscriptions(): void { + const neededPeers = new Set() + for (const sub of this.#outboundSubs.values()) { + neededPeers.add(sub.peerUuid) + } + + for (const peerUuid of this.#subscribedUpdatePeers) { + if (!neededPeers.has(peerUuid)) { + this.#subscribedUpdatePeers.delete(peerUuid) + this.#transportManager.unsubscribeAll(updateWildcard(peerUuid)).catch((err) => { + this.#logger.warn(`Failed to unsubscribe from ${peerUuid}: ${stringifyError(err)}`) + }) + } + } + } + + /** + * Send a subscribe request to a remote peer. + */ + #sendSubscribeRequest(peerUuid: string, page: number, row: number, col: number): void { + const msg: SubscribeRequestMessage = { + version: 1, + type: 'subscribe.request', + payload: { + requestorId: this.#state.uuid, + buttons: [ + { + page, + row, + col, + width: 72, + height: 72, + }, + ], + timestamp: Date.now(), + }, + } + + const correlationId = v4() + + // Register callback to handle the response + this.#pendingRpc.set(correlationId, (response: LinkMessage) => { + if (response.type === 'subscribe.response') { + this.#handleSubscribeResponse(peerUuid, response as SubscribeResponseMessage) + } + }) + + const topic = rpcRequestTopic(peerUuid, correlationId) + this.#transportManager.publishToAll(topic, JSON.stringify(msg)).catch((err) => { + this.#logger.warn(`Failed to send subscribe request to ${peerUuid}: ${stringifyError(err)}`) + this.#pendingRpc.delete(correlationId) + }) + } + + /** + * Handle a subscribe response from a remote peer. + */ + #handleSubscribeResponse(peerUuid: string, message: SubscribeResponseMessage): void { + for (const buttonState of message.payload.states) { + const { page, row, col, dataUrl, pressed, sourceChain } = buttonState + + // Check for loop detection + const isLoop = sourceChain?.includes(this.#state.uuid) + + // Find all link buttons targeting this peer + location + for (const [controlId, sub] of this.#outboundSubs) { + if (sub.peerUuid === peerUuid && sub.page === page && sub.row === row && sub.col === col) { + const control = this.#controls.getControl(controlId) + if (control instanceof ControlButtonRemoteLink) { + // Mark subscription as successful + sub.subscribed = true + + if (isLoop) { + control.setVisualState('loop_detected') + } else if (dataUrl) { + // Got initial bitmap, display it + control.setBitmap(dataUrl, pressed ?? false) + } + // else: No initial bitmap available yet (button hasn't rendered or existing subscription) + // Keep current state (likely 'loading') and wait for first update via transport + } + } + } + } + } + + /** + * Handle a press on a local link button — forward press/release to the remote peer. + */ + #handleLinkButtonPress(controlId: string, pressed: boolean, surfaceId: string | undefined): void { + const sub = this.#outboundSubs.get(controlId) + if (!sub) return + + const topic = pressed + ? pressTopic(sub.peerUuid, sub.page, sub.row, sub.col) + : releaseTopic(sub.peerUuid, sub.page, sub.row, sub.col) + + const msg: ButtonPressMessage | ButtonReleaseMessage = { + version: 1, + type: pressed ? 'button.press' : 'button.release', + payload: { + page: sub.page, + row: sub.row, + col: sub.col, + sourceUuid: this.#state.uuid, + surfaceId, + timestamp: Date.now(), + }, + } + + this.#transportManager.publishToAll(topic, JSON.stringify(msg)).catch((err) => { + this.#logger.warn(`Failed to forward press to ${sub.peerUuid}: ${stringifyError(err)}`) + }) + } + + #handleDiscoveryMessage(transportId: string, _topic: string, payload: Buffer): void { + const payloadStr = payload.toString('utf-8') + + this.#logger.debug(`Received discovery message on ${_topic} from transport ${transportId}`) + + // Empty payload = peer went offline (empty retained message) + if (!payloadStr || payloadStr.length === 0) { + // Extract peer UUID from topic + const parts = _topic.split('/') + const peerId = parts[parts.length - 1] + if (peerId) { + this.#logger.debug(`Peer ${peerId} went offline`) + this.#peerRegistry.handlePeerOffline(transportId, peerId) + } + return + } + + try { + const message = JSON.parse(payloadStr) as AnnouncementMessage + if (message.version !== 1 || message.type !== 'announcement') { + this.#logger.warn(`Invalid announcement message format`) + return + } + + this.#logger.debug(`Processing announcement from peer ${message.payload.id}`) + this.#peerRegistry.handleAnnouncement(transportId, message.payload) + } catch (e) { + this.#logger.warn(`Failed to parse discovery message: ${stringifyError(e)}`) + } + } + + // ── State Management ────────────────────────────────────────────── + + #setState(draftState: Partial): void { + const newState: LinkControllerState = { + ...this.#state, + ...draftState, + } + + if (!isEqual(newState, this.#state)) { + this.#state = newState + this.#uiEvents.emit('controllerState', newState) + } + } + + #saveSettings(): void { + this.#dbTable.set('settings', { + enabled: this.#state.enabled, + }) + } + + #saveTransportConfigs(): void { + this.#dbTable.set('transports', this.#transportConfigs) + this.#uiEvents.emit('transports', this.#transportConfigs) + } +} diff --git a/companion/lib/Link/Crc32.ts b/companion/lib/Link/Crc32.ts new file mode 100644 index 0000000000..f9ee4c9378 --- /dev/null +++ b/companion/lib/Link/Crc32.ts @@ -0,0 +1,31 @@ +/** + * CRC32 implementation for chunk integrity verification. + * Uses the standard CRC-32 polynomial (0xEDB88320). + */ + +/** Pre-computed CRC32 lookup table */ +const CRC32_TABLE = new Uint32Array(256) + +// Generate CRC32 lookup table +for (let i = 0; i < 256; i++) { + let crc = i + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1 + } + CRC32_TABLE[i] = crc +} + +/** + * Compute CRC32 checksum of a buffer. + * @param data - Input buffer + * @returns CRC32 checksum as an unsigned 32-bit integer + */ +export function crc32(data: Buffer | Uint8Array): number { + let crc = 0xffffffff + + for (let i = 0; i < data.length; i++) { + crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ data[i]) & 0xff] + } + + return (crc ^ 0xffffffff) >>> 0 +} diff --git a/companion/lib/Link/LinkProtocolSchema.ts b/companion/lib/Link/LinkProtocolSchema.ts new file mode 100644 index 0000000000..f25ad89c29 --- /dev/null +++ b/companion/lib/Link/LinkProtocolSchema.ts @@ -0,0 +1,262 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +/** + * Companion Link protocol payload definitions. Each $def is a message payload type identified by its message type string. Protocol.ts composes these into discriminated-union message envelopes. + */ +export interface LinkProtocolPayloads { + /** + * Reference anchor — ensures all $defs are reachable and generated as TypeScript interfaces. + */ + _payloads?: { + announcement?: LinkAnnouncementPayload + subscribeRequest?: LinkSubscribeRequestPayload + subscribeResponse?: LinkSubscribeResponsePayload + unsubscribeRequest?: LinkUnsubscribeRequestPayload + buttonCommand?: LinkButtonCommandPayload + buttonUpdate?: LinkButtonUpdatePayload + buttonLocation?: LinkButtonLocation + buttonLocationWithResolution?: LinkButtonLocationWithResolution + subscribeResponseButtonState?: LinkSubscribeResponseButtonState + gridSize?: LinkGridSize + } +} +/** + * Discovery announcement from a Companion instance. + */ +export interface LinkAnnouncementPayload { + /** + * Unique instance UUID. + */ + id: string + /** + * Human-readable instance name. + */ + name: string + /** + * Companion application version string. + */ + version: string + /** + * Link protocol version number. + */ + protocolVersion: number + /** + * Number of pages configured on this instance. + */ + pageCount: number + gridSize: LinkGridSize + /** + * Unix timestamp in milliseconds. + */ + timestamp: number +} +/** + * Grid dimensions for an instance. + */ +export interface LinkGridSize { + /** + * Number of rows in the grid. + */ + rows: number + /** + * Number of columns in the grid. + */ + cols: number +} +/** + * Request to subscribe to button state updates at specific resolutions. + */ +export interface LinkSubscribeRequestPayload { + /** + * UUID of the peer making the subscription request. + */ + requestorId: string + /** + * List of buttons and their requested resolutions. + */ + buttons: LinkButtonLocationWithResolution[] + /** + * Unix timestamp in milliseconds. + */ + timestamp: number +} +/** + * A button location with a requested bitmap resolution. + */ +export interface LinkButtonLocationWithResolution { + /** + * Page number. + */ + page: number + /** + * Row number. + */ + row: number + /** + * Column number. + */ + col: number + /** + * Requested bitmap width in pixels. + */ + width: number + /** + * Requested bitmap height in pixels. + */ + height: number +} +/** + * Response containing initial state for subscribed buttons. + */ +export interface LinkSubscribeResponsePayload { + /** + * List of initial button states. + */ + states: LinkSubscribeResponseButtonState[] + /** + * Unix timestamp in milliseconds. + */ + timestamp: number +} +/** + * Initial state for a subscribed button. + */ +export interface LinkSubscribeResponseButtonState { + /** + * Page number. + */ + page: number + /** + * Row number. + */ + row: number + /** + * Column number. + */ + col: number + /** + * Bitmap width in pixels. + */ + width: number + /** + * Bitmap height in pixels. + */ + height: number + /** + * PNG data URL at the requested resolution, or null if no render is available. + */ + dataUrl: string | null + /** + * Whether the button is currently pressed. + */ + pressed: boolean + /** + * Ordered list of instance UUIDs this button image has passed through, for loop detection. + */ + sourceChain: string[] +} +/** + * Request to unsubscribe from button state updates. + */ +export interface LinkUnsubscribeRequestPayload { + /** + * List of buttons and resolutions to unsubscribe from. + */ + buttons: LinkButtonLocationWithResolution[] + /** + * Unix timestamp in milliseconds. + */ + timestamp: number +} +/** + * Payload for button press or release commands. + */ +export interface LinkButtonCommandPayload { + /** + * Page number. + */ + page: number + /** + * Row number. + */ + row: number + /** + * Column number. + */ + col: number + /** + * UUID of the instance sending this command. + */ + sourceUuid: string + /** + * Optional surface identifier for long press support. Will be prefixed with source UUID on receiving end. + */ + surfaceId?: string + /** + * Unix timestamp in milliseconds. + */ + timestamp: number +} +/** + * Combined bitmap and state update for a subscribed button at a specific resolution. + */ +export interface LinkButtonUpdatePayload { + /** + * Page number. + */ + page: number + /** + * Row number. + */ + row: number + /** + * Column number. + */ + col: number + /** + * Bitmap width in pixels. + */ + width: number + /** + * Bitmap height in pixels. + */ + height: number + /** + * PNG data URL at the subscribed resolution. + */ + dataUrl: string + /** + * Whether the button is currently pressed. + */ + pressed: boolean + /** + * Ordered list of instance UUIDs this button image has passed through, for loop detection. The publishing instance appends its own UUID before sending. + */ + sourceChain: string[] + /** + * Unix timestamp in milliseconds. + */ + timestamp: number +} +/** + * Identifies a button by its page, row, and column. + */ +export interface LinkButtonLocation { + /** + * Page number. + */ + page: number + /** + * Row number. + */ + row: number + /** + * Column number. + */ + col: number +} diff --git a/companion/lib/Link/MqttTransport.ts b/companion/lib/Link/MqttTransport.ts new file mode 100644 index 0000000000..4f48128b98 --- /dev/null +++ b/companion/lib/Link/MqttTransport.ts @@ -0,0 +1,141 @@ +import mqtt from 'mqtt' +import LogController from '../Log/Controller.js' +import type { LinkMqttConfig } from '@companion-app/shared/Model/Link.js' +import { LinkTransport, type PublishOptions } from './Transport.js' +import { stringifyError } from '@companion-app/shared/Stringify.js' + +/** + * MQTT 5.0 transport implementation for Companion Link. + * Each instance represents a single connection to one MQTT broker. + */ +export class MqttTransport extends LinkTransport { + readonly #logger = LogController.createLogger('Link/MqttTransport') + + #client: mqtt.MqttClient | null = null + + /** Set of topics currently subscribed to (for resubscription on reconnect) */ + readonly #subscribedTopics = new Set() + + async connect(config: LinkMqttConfig): Promise { + if (this.#client) { + await this.disconnect() + } + + this.setStatus('connecting') + this.#logger.info(`Connecting to MQTT broker: ${config.brokerUrl}`) + + const url = config.tls ? config.brokerUrl.replace(/^mqtt:/, 'mqtts:') : config.brokerUrl + + const client = mqtt.connect(url, { + protocolVersion: 5, + username: config.username || undefined, + password: config.password || undefined, + clean: true, + reconnectPeriod: 5000, + connectTimeout: 10000, + }) + + this.#client = client + + client.on('connect', () => { + this.#logger.info('Connected to MQTT broker') + this.setStatus('connected') + + // Resubscribe to all tracked topics on reconnect + for (const topic of this.#subscribedTopics) { + this.#logger.debug(`Subscribing to ${topic}`) + client.subscribe(topic, { qos: 1 }, (err: Error | null) => { + if (err) { + this.#logger.warn(`Failed to resubscribe to ${topic}: ${stringifyError(err)}`) + } else { + this.#logger.debug(`Successfully subscribed to ${topic}`) + } + }) + } + }) + + client.on('reconnect', () => { + this.#logger.debug('Reconnecting to MQTT broker...') + this.setStatus('connecting') + }) + + client.on('error', (err: Error) => { + this.#logger.error(`MQTT error: ${stringifyError(err)}`) + this.setStatus('error', stringifyError(err)) + }) + + client.on('offline', () => { + this.#logger.debug('MQTT client offline') + this.setStatus('disconnected') + }) + + client.on('close', () => { + this.#logger.debug('MQTT connection closed') + if (this.status !== 'error') { + this.setStatus('disconnected') + } + }) + + client.on('message', (topic: string, payload: Buffer) => { + this.emit('message', topic, payload) + }) + } + + async disconnect(): Promise { + const client = this.#client + if (!client) return + + this.#client = null + this.#subscribedTopics.clear() + + try { + await client.endAsync(true) + } catch (e) { + this.#logger.warn(`Error disconnecting MQTT client: ${stringifyError(e)}`) + } + + this.setStatus('disconnected') + this.#logger.info('Disconnected from MQTT broker') + } + + async publish(topic: string, payload: string | Buffer, options?: PublishOptions): Promise { + const client = this.#client + if (!client?.connected) { + this.#logger.debug(`Cannot publish to ${topic}: not connected`) + return + } + + await client.publishAsync(topic, payload, { + qos: options?.qos ?? 1, + retain: options?.retain ?? false, + }) + } + + async subscribe(pattern: string): Promise { + this.#subscribedTopics.add(pattern) + + const client = this.#client + if (!client?.connected) { + this.#logger.debug(`Deferring subscription to ${pattern} until connected`) + // Will subscribe on connect via the resubscribe logic + return + } + + this.#logger.debug(`Subscribing to ${pattern}`) + await client.subscribeAsync(pattern, { qos: 1 }) + this.#logger.debug(`Successfully subscribed to ${pattern}`) + } + + async unsubscribe(pattern: string): Promise { + this.#subscribedTopics.delete(pattern) + + const client = this.#client + if (!client?.connected) return + + try { + await client.unsubscribeAsync(pattern) + } catch (e) { + this.#logger.warn(`Error unsubscribing from ${pattern}: ${stringifyError(e)}`) + } + } +} diff --git a/companion/lib/Link/PeerRegistry.ts b/companion/lib/Link/PeerRegistry.ts new file mode 100644 index 0000000000..7ba7982dcc --- /dev/null +++ b/companion/lib/Link/PeerRegistry.ts @@ -0,0 +1,210 @@ +import EventEmitter from 'node:events' +import LogController from '../Log/Controller.js' +import type { LinkPeerInfo } from '@companion-app/shared/Model/Link.js' +import type { AnnouncementPayload } from './Protocol.js' + +/** Internal per-transport peer data */ +interface PeerTransportEntry { + /** Last seen timestamp from this transport */ + lastSeen: number + /** Whether considered online on this transport */ + online: boolean +} + +/** Internal peer record */ +interface PeerRecord { + /** Core peer info from announcement */ + info: Omit + /** Per-transport reachability state */ + transports: Map +} + +export type PeerRegistryEvents = { + /** Emitted whenever the peers list changes */ + peersChanged: [peers: LinkPeerInfo[]] +} + +/** + * Tracks discovered peers across all transports. + * Aggregates per-transport reachability into a unified peer list. + */ +export class PeerRegistry extends EventEmitter { + readonly #logger = LogController.createLogger('Link/PeerRegistry') + + /** Map of peer UUID → PeerRecord */ + readonly #peers = new Map() + + /** Our own UUID, so we don't track ourselves */ + #selfUuid: string + + /** Offline timeout in ms (2× announcement interval) */ + readonly #offlineTimeout: number + + /** Interval handle for checking stale peers */ + #checkInterval: ReturnType | null = null + + constructor(selfUuid: string, offlineTimeoutMs = 120_000) { + super() + this.setMaxListeners(0) + this.#selfUuid = selfUuid + this.#offlineTimeout = offlineTimeoutMs + } + + /** Start the periodic stale-check timer */ + start(): void { + this.stop() + this.#checkInterval = setInterval(() => this.#checkStalePeers(), 30_000) + } + + /** Stop the periodic stale-check timer */ + stop(): void { + if (this.#checkInterval) { + clearInterval(this.#checkInterval) + this.#checkInterval = null + } + } + + /** Update our own UUID (e.g., after regeneration) */ + setSelfUuid(uuid: string): void { + this.#selfUuid = uuid + // Remove ourselves if we were somehow tracked + if (this.#peers.delete(uuid)) { + this.#emitPeers() + } + } + + /** + * Process an announcement from a peer on a specific transport. + * @param transportId - The transport instance ID the announcement arrived on + * @param announcement - The announcement payload + */ + handleAnnouncement(transportId: string, announcement: AnnouncementPayload): void { + if (announcement.id === this.#selfUuid) return + + let record = this.#peers.get(announcement.id) + if (!record) { + record = { + info: { + id: announcement.id, + name: announcement.name, + version: announcement.version, + protocolVersion: announcement.protocolVersion, + pageCount: announcement.pageCount, + gridSize: announcement.gridSize, + }, + transports: new Map(), + } + this.#peers.set(announcement.id, record) + this.#logger.info(`Discovered new peer: ${announcement.name} (${announcement.id})`) + } + + // Update peer info (may have changed) + record.info.name = announcement.name + record.info.version = announcement.version + record.info.protocolVersion = announcement.protocolVersion + record.info.pageCount = announcement.pageCount + record.info.gridSize = announcement.gridSize + + // Update transport entry + record.transports.set(transportId, { + lastSeen: Date.now(), + online: true, + }) + + this.#emitPeers() + } + + /** + * Handle a peer going offline on a specific transport. + * Called when an empty retained message is received (graceful offline). + */ + handlePeerOffline(transportId: string, peerId: string): void { + const record = this.#peers.get(peerId) + if (!record) return + + const entry = record.transports.get(transportId) + if (entry) { + entry.online = false + } + + this.#emitPeers() + } + + /** + * Remove all peer entries associated with a transport instance. + * Called when a transport is removed or disconnects. + */ + removeTransport(transportId: string): void { + for (const record of this.#peers.values()) { + record.transports.delete(transportId) + } + this.#emitPeers() + } + + /** + * Delete a peer entirely (user action from UI). + */ + deletePeer(peerId: string): boolean { + const deleted = this.#peers.delete(peerId) + if (deleted) { + this.#emitPeers() + } + return deleted + } + + /** Get the current list of peers for the UI */ + getPeers(): LinkPeerInfo[] { + return this.#buildPeerList() + } + + /** Check for peers that haven't been seen recently and mark them offline */ + #checkStalePeers(): void { + const now = Date.now() + let changed = false + + for (const record of this.#peers.values()) { + for (const [, entry] of record.transports) { + if (entry.online && now - entry.lastSeen > this.#offlineTimeout) { + entry.online = false + changed = true + } + } + } + + if (changed) { + this.#emitPeers() + } + } + + /** Build the flat peer list from internal records */ + #buildPeerList(): LinkPeerInfo[] { + const peers: LinkPeerInfo[] = [] + + for (const record of this.#peers.values()) { + const onlineTransports: string[] = [] + let latestSeen = 0 + + for (const [transportId, entry] of record.transports) { + if (entry.online) { + onlineTransports.push(transportId) + } + if (entry.lastSeen > latestSeen) { + latestSeen = entry.lastSeen + } + } + + peers.push({ + ...record.info, + online: onlineTransports.length > 0, + transports: onlineTransports, + lastSeen: latestSeen, + }) + } + + return peers + } + + #emitPeers(): void { + this.emit('peersChanged', this.#buildPeerList()) + } +} diff --git a/companion/lib/Link/Protocol.ts b/companion/lib/Link/Protocol.ts new file mode 100644 index 0000000000..a7794238b8 --- /dev/null +++ b/companion/lib/Link/Protocol.ts @@ -0,0 +1,131 @@ +/** + * Companion Link protocol message types + * + * Payload interfaces are generated from assets/link-protocol.schema.json + * via `yarn build:link-schema`. Do NOT hand-edit the payload shapes here — + * modify the JSON Schema instead and re-run the generator. + * + * This file re-exports those generated types, defines the generic message + * envelope, composes discriminated-union message types, and provides + * MQTT topic helpers. + */ + +// ── Re-export generated payload types ────────────────────── +export type { + LinkAnnouncementPayload as AnnouncementPayload, + LinkSubscribeRequestPayload as SubscribeRequestPayload, + LinkSubscribeResponsePayload as SubscribeResponsePayload, + LinkSubscribeResponseButtonState as SubscribeResponseButtonState, + LinkUnsubscribeRequestPayload as UnsubscribeRequestPayload, + LinkButtonCommandPayload as ButtonCommandPayload, + LinkButtonUpdatePayload as ButtonUpdatePayload, + LinkButtonLocation as ButtonLocation, + LinkButtonLocationWithResolution as ButtonLocationWithResolution, + LinkGridSize as GridSize, +} from './LinkProtocolSchema.js' + +import type { + LinkAnnouncementPayload, + LinkSubscribeRequestPayload, + LinkSubscribeResponsePayload, + LinkUnsubscribeRequestPayload, + LinkButtonCommandPayload, + LinkButtonUpdatePayload, +} from './LinkProtocolSchema.js' + +// ── Message envelope ─────────────────────────────────────── + +/** Base envelope for all Link protocol messages */ +export interface LinkMessage { + /** Protocol envelope version */ + version: 1 + /** Message type discriminator */ + type: T + /** Message payload */ + payload: P +} + +// ── Composed message types (envelope + payload) ──────────── + +export type AnnouncementMessage = LinkMessage<'announcement', LinkAnnouncementPayload> + +export type SubscribeRequestMessage = LinkMessage<'subscribe.request', LinkSubscribeRequestPayload> +export type SubscribeResponseMessage = LinkMessage<'subscribe.response', LinkSubscribeResponsePayload> +export type UnsubscribeRequestMessage = LinkMessage<'unsubscribe.request', LinkUnsubscribeRequestPayload> + +export type ButtonPressMessage = LinkMessage<'button.press', LinkButtonCommandPayload> +export type ButtonReleaseMessage = LinkMessage<'button.release', LinkButtonCommandPayload> + +export type ButtonUpdateMessage = LinkMessage<'button.update', LinkButtonUpdatePayload> + +// ── Topic Helpers ────────────────────────────────────────── + +export const LINK_TOPIC_PREFIX = 'companion-link' + +export function discoveryTopic(uuid: string): string { + return `${LINK_TOPIC_PREFIX}/discovery/${uuid}` +} + +export function discoveryWildcard(): string { + return `${LINK_TOPIC_PREFIX}/discovery/+` +} + +export function updateTopic( + uuid: string, + page: number, + row: number, + col: number, + width: number, + height: number +): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/location/${page}/${row}/${col}/update/${width}x${height}` +} + +export function pressTopic(uuid: string, page: number, row: number, col: number): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/location/${page}/${row}/${col}/press` +} + +export function releaseTopic(uuid: string, page: number, row: number, col: number): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/location/${page}/${row}/${col}/release` +} + +export function rpcRequestTopic(uuid: string, correlationId: string): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/rpc/${correlationId}/request` +} + +export function rpcResponseTopic(uuid: string, correlationId: string): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/rpc/${correlationId}/response` +} + +export function chunksTopic(): string { + return `${LINK_TOPIC_PREFIX}/chunks` +} + +/** + * Wildcard pattern for subscribing to all button update topics for a specific instance. + * Used by clients that want to receive updates from a remote instance. + */ +export function updateWildcard(uuid: string): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/location/+/+/+/update/+` +} + +/** + * Wildcard pattern for subscribing to all press commands for our instance. + */ +export function pressWildcard(uuid: string): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/location/+/+/+/press` +} + +/** + * Wildcard pattern for subscribing to all release commands for our instance. + */ +export function releaseWildcard(uuid: string): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/location/+/+/+/release` +} + +/** + * Wildcard pattern for subscribing to all RPC requests for our instance. + */ +export function rpcRequestWildcard(uuid: string): string { + return `${LINK_TOPIC_PREFIX}/${uuid}/rpc/+/request` +} diff --git a/companion/lib/Link/SubscriptionManager.ts b/companion/lib/Link/SubscriptionManager.ts new file mode 100644 index 0000000000..129066d0e7 --- /dev/null +++ b/companion/lib/Link/SubscriptionManager.ts @@ -0,0 +1,263 @@ +import EventEmitter from 'node:events' +import LogController from '../Log/Controller.js' + +/** A resolution requested by a subscriber */ +export interface SubscriptionResolution { + width: number + height: number +} + +/** Key for a button location */ +export type LocationKey = `${number}:${number}:${number}` + +/** Key for a (location, resolution) pair */ +export type LocationResolutionKey = `${number}:${number}:${number}:${number}x${number}` + +/** Events emitted by SubscriptionManager */ +export type SubscriptionManagerEvents = { + /** A new (location, resolution) pair was subscribed for the first time */ + subscriptionAdded: [page: number, row: number, col: number, width: number, height: number] + /** The last subscriber for a (location, resolution) pair unsubscribed */ + subscriptionRemoved: [page: number, row: number, col: number, width: number, height: number] +} + +/** + * Tracks which button locations are subscribed at which resolutions by remote peers. + * + * Handles deduplication: if multiple remote clients subscribe to the same button + * at the same resolution, we only render once and fan out. + * + * This is the "inbound" subscription manager - it tracks what OTHER instances + * are asking US to publish. + */ +export class SubscriptionManager extends EventEmitter { + readonly #logger = LogController.createLogger('Link/SubscriptionManager') + + /** + * Map of (location:resolution) → Set of subscriber IDs. + * Subscriber IDs are typically peer UUIDs or transport-specific identifiers. + */ + readonly #subscriptions = new Map>() + + /** Eviction timeout handles per (location, resolution) */ + readonly #evictionTimers = new Map>() + + /** Cache eviction timeout (5 minutes) */ + readonly #evictionTimeoutMs: number + + constructor(evictionTimeoutMs = 5 * 60 * 1000) { + super() + this.setMaxListeners(0) + this.#evictionTimeoutMs = evictionTimeoutMs + } + + /** + * Add a subscription for a button at a specific resolution. + * + * @param subscriberId - Identifier for the subscriber (e.g. peer UUID) + * @param page - Page number + * @param row - Row number + * @param col - Column number + * @param width - Desired bitmap width + * @param height - Desired bitmap height + * @returns true if this is a new (location, resolution) subscription (first subscriber) + */ + subscribe( + subscriberId: string, + page: number, + row: number, + col: number, + width: number, + height: number + ): boolean { + const key = this.#makeKey(page, row, col, width, height) + + // Cancel any pending eviction + this.#cancelEviction(key) + + let subscribers = this.#subscriptions.get(key) + const isNew = !subscribers || subscribers.size === 0 + + if (!subscribers) { + subscribers = new Set() + this.#subscriptions.set(key, subscribers) + } + + subscribers.add(subscriberId) + + if (isNew) { + this.#logger.debug( + `New subscription: page=${page} row=${row} col=${col} ${width}x${height} by ${subscriberId}` + ) + this.emit('subscriptionAdded', page, row, col, width, height) + } + + return isNew + } + + /** + * Remove a subscription for a button at a specific resolution. + * + * @returns true if this was the last subscriber (subscription fully removed) + */ + unsubscribe( + subscriberId: string, + page: number, + row: number, + col: number, + width: number, + height: number + ): boolean { + const key = this.#makeKey(page, row, col, width, height) + const subscribers = this.#subscriptions.get(key) + if (!subscribers) return false + + subscribers.delete(subscriberId) + + if (subscribers.size === 0) { + this.#subscriptions.delete(key) + this.#logger.debug( + `Last subscriber removed: page=${page} row=${row} col=${col} ${width}x${height}` + ) + this.emit('subscriptionRemoved', page, row, col, width, height) + return true + } + + return false + } + + /** + * Remove all subscriptions for a specific subscriber. + * Used when a peer disconnects. + */ + unsubscribeAll(subscriberId: string): void { + for (const [key, subscribers] of this.#subscriptions) { + subscribers.delete(subscriberId) + if (subscribers.size === 0) { + this.#subscriptions.delete(key) + const parsed = this.#parseKey(key) + if (parsed) { + this.emit('subscriptionRemoved', parsed.page, parsed.row, parsed.col, parsed.width, parsed.height) + } + } + } + } + + /** + * Get all active resolutions for a specific button location. + * Used when a button changes and we need to know which resolutions to render. + */ + getResolutionsForLocation(page: number, row: number, col: number): SubscriptionResolution[] { + const locationPrefix = `${page}:${row}:${col}:` + const resolutions: SubscriptionResolution[] = [] + + for (const key of this.#subscriptions.keys()) { + if (key.startsWith(locationPrefix)) { + const parsed = this.#parseKey(key) + if (parsed) { + resolutions.push({ width: parsed.width, height: parsed.height }) + } + } + } + + return resolutions + } + + /** + * Check whether a specific (location, resolution) pair has active subscribers. + */ + hasSubscribers(page: number, row: number, col: number, width: number, height: number): boolean { + const key = this.#makeKey(page, row, col, width, height) + const subscribers = this.#subscriptions.get(key) + return !!subscribers && subscribers.size > 0 + } + + /** + * Get all subscribed locations (unique page/row/col combinations). + */ + getSubscribedLocations(): Array<{ page: number; row: number; col: number }> { + const seen = new Set() + const locations: Array<{ page: number; row: number; col: number }> = [] + + for (const key of this.#subscriptions.keys()) { + const parsed = this.#parseKey(key) + if (parsed) { + const locKey: LocationKey = `${parsed.page}:${parsed.row}:${parsed.col}` + if (!seen.has(locKey)) { + seen.add(locKey) + locations.push({ page: parsed.page, row: parsed.row, col: parsed.col }) + } + } + } + + return locations + } + + /** + * Reset the eviction timer for a (location, resolution) pair. + * Called when a bitmap request is received, extending the subscription lifetime. + */ + touchSubscription(page: number, row: number, col: number, width: number, height: number): void { + const key = this.#makeKey(page, row, col, width, height) + if (!this.#subscriptions.has(key)) return + + this.#cancelEviction(key) + this.#startEviction(key, page, row, col, width, height) + } + + /** + * Clear all subscriptions and eviction timers. + */ + clear(): void { + for (const timer of this.#evictionTimers.values()) { + clearTimeout(timer) + } + this.#evictionTimers.clear() + this.#subscriptions.clear() + } + + #makeKey(page: number, row: number, col: number, width: number, height: number): LocationResolutionKey { + return `${page}:${row}:${col}:${width}x${height}` + } + + #parseKey(key: LocationResolutionKey): { page: number; row: number; col: number; width: number; height: number } | null { + const match = key.match(/^(\d+):(\d+):(\d+):(\d+)x(\d+)$/) + if (!match) return null + return { + page: Number(match[1]), + row: Number(match[2]), + col: Number(match[3]), + width: Number(match[4]), + height: Number(match[5]), + } + } + + #cancelEviction(key: LocationResolutionKey): void { + const timer = this.#evictionTimers.get(key) + if (timer) { + clearTimeout(timer) + this.#evictionTimers.delete(key) + } + } + + #startEviction( + key: LocationResolutionKey, + page: number, + row: number, + col: number, + width: number, + height: number + ): void { + const timer = setTimeout(() => { + this.#evictionTimers.delete(key) + // Force-unsubscribe all subscribers for this key + const subscribers = this.#subscriptions.get(key) + if (subscribers && subscribers.size > 0) { + this.#logger.debug(`Evicting stale subscription: page=${page} row=${row} col=${col} ${width}x${height}`) + this.#subscriptions.delete(key) + this.emit('subscriptionRemoved', page, row, col, width, height) + } + }, this.#evictionTimeoutMs) + this.#evictionTimers.set(key, timer) + } +} diff --git a/companion/lib/Link/Transport.ts b/companion/lib/Link/Transport.ts new file mode 100644 index 0000000000..acae9acf94 --- /dev/null +++ b/companion/lib/Link/Transport.ts @@ -0,0 +1,61 @@ +import EventEmitter from 'node:events' +import type { LinkTransportStatus, LinkTransportTypeConfig } from '@companion-app/shared/Model/Link.js' + +/** Options for publishing a message */ +export interface PublishOptions { + /** QoS level (0, 1, or 2) */ + qos?: 0 | 1 | 2 + /** Whether to retain the message on the broker */ + retain?: boolean +} + +/** Handler for incoming messages on a subscribed topic */ +export type MessageHandler = (topic: string, payload: Buffer) => void + +/** Events emitted by a LinkTransport */ +export type LinkTransportEvents = { + /** Connection status changed */ + statusChanged: [status: LinkTransportStatus, error: string | null] + /** A message was received on a subscribed topic */ + message: [topic: string, payload: Buffer] +} + +/** + * Abstract transport interface for Companion Link. + * Each transport instance represents a single logical connection. + * Concrete implementations (MQTT, WebRTC, etc.) extend this class. + */ +export abstract class LinkTransport extends EventEmitter { + /** Current connection status */ + status: LinkTransportStatus = 'disconnected' + + /** Current error message, if any */ + error: string | null = null + + constructor() { + super() + this.setMaxListeners(0) + } + + /** Connect to the transport backend */ + abstract connect(config: LinkTransportTypeConfig): Promise + + /** Disconnect from the transport backend */ + abstract disconnect(): Promise + + /** Publish a message to a topic */ + abstract publish(topic: string, payload: string | Buffer, options?: PublishOptions): Promise + + /** Subscribe to a topic pattern */ + abstract subscribe(pattern: string): Promise + + /** Unsubscribe from a topic pattern */ + abstract unsubscribe(pattern: string): Promise + + /** Update the status and emit an event */ + protected setStatus(status: LinkTransportStatus, error: string | null = null): void { + this.status = status + this.error = error + this.emit('statusChanged', status, error) + } +} diff --git a/companion/lib/Link/TransportManager.ts b/companion/lib/Link/TransportManager.ts new file mode 100644 index 0000000000..9e7e0250e1 --- /dev/null +++ b/companion/lib/Link/TransportManager.ts @@ -0,0 +1,174 @@ +import EventEmitter from 'node:events' +import LogController from '../Log/Controller.js' +import type { LinkTransportConfig, LinkTransportState, LinkTransportStatus } from '@companion-app/shared/Model/Link.js' +import type { LinkTransport, PublishOptions } from './Transport.js' +import { MqttTransport } from './MqttTransport.js' +import { stringifyError } from '@companion-app/shared/Stringify.js' + +/** A managed transport instance */ +interface ManagedTransport { + config: LinkTransportConfig + transport: LinkTransport +} + +export type TransportManagerEvents = { + /** Emitted when a transport's status changes */ + transportStatusChanged: [states: LinkTransportState[]] + /** Emitted when a message is received from any transport */ + message: [transportId: string, topic: string, payload: Buffer] +} + +/** + * Manages the lifecycle of multiple transport instances. + * Routes outgoing messages to appropriate transports. + * Aggregates incoming messages from all transports. + */ +export class TransportManager extends EventEmitter { + readonly #logger = LogController.createLogger('Link/TransportManager') + + /** Map of transport instance ID → ManagedTransport */ + readonly #transports = new Map() + + /** + * Add or update a transport instance. + * If a transport with the same ID already exists, it will be disconnected and replaced. + */ + async addTransport(config: LinkTransportConfig): Promise { + // Tear down existing transport with same ID + await this.removeTransport(config.id) + + const transport = this.#createTransport(config.type) + + const managed: ManagedTransport = { config, transport } + this.#transports.set(config.id, managed) + + // Wire up events + transport.on('statusChanged', (_status: LinkTransportStatus, _error: string | null) => { + this.emit('transportStatusChanged', this.getTransportStates()) + }) + + transport.on('message', (topic: string, payload: Buffer) => { + this.emit('message', config.id, topic, payload) + }) + + // Connect if enabled + if (config.enabled) { + try { + await transport.connect(config.config) + } catch (e) { + this.#logger.error(`Failed to connect transport ${config.id}: ${stringifyError(e)}`) + } + } + + this.emit('transportStatusChanged', this.getTransportStates()) + } + + /** + * Remove a transport instance. + */ + async removeTransport(id: string): Promise { + const managed = this.#transports.get(id) + if (!managed) return + + this.#transports.delete(id) + + try { + await managed.transport.disconnect() + } catch (e) { + this.#logger.warn(`Error disconnecting transport ${id}: ${stringifyError(e)}`) + } + + managed.transport.removeAllListeners() + this.emit('transportStatusChanged', this.getTransportStates()) + } + + /** + * Disconnect and remove all transports. + */ + async removeAll(): Promise { + const ids = [...this.#transports.keys()] + await Promise.all(ids.map(async (id) => this.removeTransport(id))) + } + + /** + * Publish a message to all connected transports. + * This is the initial "send over all" strategy. + */ + async publishToAll(topic: string, payload: string | Buffer, options?: PublishOptions): Promise { + const promises: Promise[] = [] + for (const [, managed] of this.#transports) { + if (managed.transport.status === 'connected') { + promises.push( + managed.transport.publish(topic, payload, options).catch((e) => { + this.#logger.warn(`Failed to publish to transport ${managed.config.id}: ${stringifyError(e)}`) + }) + ) + } + } + await Promise.all(promises) + } + + /** + * Subscribe to a topic pattern on all transports. + */ + async subscribeAll(pattern: string): Promise { + const promises: Promise[] = [] + for (const [, managed] of this.#transports) { + promises.push( + managed.transport.subscribe(pattern).catch((e) => { + this.#logger.warn(`Failed to subscribe on transport ${managed.config.id}: ${stringifyError(e)}`) + }) + ) + } + await Promise.all(promises) + } + + /** + * Unsubscribe from a topic pattern on all transports. + */ + async unsubscribeAll(pattern: string): Promise { + const promises: Promise[] = [] + for (const [, managed] of this.#transports) { + promises.push( + managed.transport.unsubscribe(pattern).catch((e) => { + this.#logger.warn(`Failed to unsubscribe on transport ${managed.config.id}: ${stringifyError(e)}`) + }) + ) + } + await Promise.all(promises) + } + + /** Get the current states of all transports for the UI */ + getTransportStates(): LinkTransportState[] { + const states: LinkTransportState[] = [] + for (const [id, managed] of this.#transports) { + states.push({ + id, + status: managed.transport.status, + error: managed.transport.error, + }) + } + return states + } + + /** Get the set of transport IDs that are currently connected */ + getConnectedTransportIds(): string[] { + const ids: string[] = [] + for (const [id, managed] of this.#transports) { + if (managed.transport.status === 'connected') { + ids.push(id) + } + } + return ids + } + + /** Create a concrete transport instance based on type */ + #createTransport(type: LinkTransportConfig['type']): LinkTransport { + switch (type) { + case 'mqtt': + return new MqttTransport() + default: + throw new Error(`Unknown transport type: ${type}`) + } + } +} diff --git a/companion/lib/Registry.ts b/companion/lib/Registry.ts index a398b238ec..2ff4948161 100644 --- a/companion/lib/Registry.ts +++ b/companion/lib/Registry.ts @@ -4,6 +4,7 @@ import fs from 'fs-extra' import express from 'express' import LogController, { type Logger } from './Log/Controller.js' import { CloudController } from './Cloud/Controller.js' +import { LinkController } from './Link/Controller.js' import { ControlsController } from './Controls/Controller.js' import { GraphicsController } from './Graphics/Controller.js' import { DataController } from './Data/Controller.js' @@ -83,6 +84,10 @@ export class Registry { * The cloud controller */ cloud!: CloudController + /** + * The link controller + */ + link!: LinkController /** * The core controls controller */ @@ -288,6 +293,15 @@ export class Registry { this.graphics, pageStore ) + this.link = new LinkController( + this.#appInfo, + this.db, + this.userconfig, + pageStore, + this.controls, + this.graphics, + controlEvents + ) this.usageStatistics = new DataUsageStatistics( this.#appInfo, this.surfaces, diff --git a/companion/lib/UI/TRPC.ts b/companion/lib/UI/TRPC.ts index 9af6805313..7efd7c1b52 100644 --- a/companion/lib/UI/TRPC.ts +++ b/companion/lib/UI/TRPC.ts @@ -107,6 +107,7 @@ export function createTrpcRouter(registry: Registry) { userConfig: registry.userconfig.createTrpcRouter(), instances: registry.instance.createTrpcRouter(), cloud: registry.cloud.createTrpcRouter(), + link: registry.link.createTrpcRouter(), usageStatistics: registry.usageStatistics.createTrpcRouter(), preview: registry.preview.createTrpcRouter(), diff --git a/companion/package.json b/companion/package.json index b185e2feea..c71578abe2 100644 --- a/companion/package.json +++ b/companion/package.json @@ -71,6 +71,7 @@ "fast-json-patch": "patch:fast-json-patch@npm%3A3.1.1#~/.yarn/patches/fast-json-patch-npm-3.1.1-7e8bb70a45.patch", "fs-extra": "^11.3.3", "get-port": "^7.1.0", + "mqtt": "^5.12.1", "nanoid": "^5.1.6", "node-cron": "^4.2.1", "node-hid": "^3.3.0", diff --git a/package.json b/package.json index ee6228c0a1..05f951dbb0 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "test": "vitest", "build:writefile": "tsx ./tools/build_writefile.mts", "build:openapi": "zx ./tools/generate_openapi.mjs", + "build:link-schema": "tsx ./tools/link_schema_compile.mts", "build:satellite-schema": "tsx ./tools/satellite_schema_compile.mts", "module:bulk": "zx ./tools/module_bulk.mjs", "build:watch": "tsc --build --watch", diff --git a/shared-lib/lib/Model/ButtonModel.ts b/shared-lib/lib/Model/ButtonModel.ts index 570d65b068..51e8a40ac4 100644 --- a/shared-lib/lib/Model/ButtonModel.ts +++ b/shared-lib/lib/Model/ButtonModel.ts @@ -2,7 +2,12 @@ import type { ActionSetsModel, ActionStepOptions } from './ActionModel.js' import type { SomeEntityModel } from './EntityModel.js' import type { ButtonStyleProperties } from './StyleModel.js' -export type SomeButtonModel = PageNumberButtonModel | PageUpButtonModel | PageDownButtonModel | NormalButtonModel +export type SomeButtonModel = + | PageNumberButtonModel + | PageUpButtonModel + | PageDownButtonModel + | NormalButtonModel + | RemoteLinkButtonModel export interface PageNumberButtonModel { readonly type: 'pagenum' @@ -61,3 +66,37 @@ export type ButtonStatus = 'good' | 'warning' | 'error' export interface NormalButtonRuntimeProps { current_step_id: string } + +/** + * Visual state of a Link button, dictating how the renderer should draw it. + * - `unknown_peer`: Cloud icon + error indicator (peer UUID not in PeerRegistry) + * - `unreachable`: Cloud icon + disconnected style (peer known but offline) + * - `loading`: Cloud icon + loading indicator (subscribed, awaiting first bitmap) + * - `bitmap`: Remote bitmap is available and should be displayed + * - `loop_detected`: Cloud icon + infinity symbol (source chain contains our own UUID) + */ +export type RemoteLinkButtonVisualState = 'unknown_peer' | 'unreachable' | 'loading' | 'bitmap' | 'loop_detected' + +/** Persisted model for a remote Link (remote mirror) button. */ +export interface RemoteLinkButtonModel { + readonly type: 'remotelinkbutton' + + /** UUID of the remote peer instance to mirror. Empty string = not configured. */ + peerUuid: string + + /** + * Remote button location in standard Companion LocationString format. + * Examples: "1/0/0", "$(this:page)/$(this:row)/$(this:column)", "this" + * Parsed using ParseLocationString with full variable support. + */ + location: string +} + +/** Runtime (non-persisted) properties sent to the UI for a remote Link button. */ +export interface RemoteLinkButtonRuntimeProps { + /** Current visual state of the link button. */ + visualState: RemoteLinkButtonVisualState + + /** Human-readable name of the target peer, if known. */ + peerName: string | null +} diff --git a/shared-lib/lib/Model/Link.ts b/shared-lib/lib/Model/Link.ts new file mode 100644 index 0000000000..78fbf09f11 --- /dev/null +++ b/shared-lib/lib/Model/Link.ts @@ -0,0 +1,87 @@ +/** + * Companion Link shared types + * Used by both backend and frontend + */ + +/** Configuration for a single transport instance */ +export interface LinkTransportConfig { + /** Unique ID for this transport instance */ + id: string + /** Transport type (mqtt, etc.) */ + type: LinkTransportType + /** User-defined label for this transport instance */ + label: string + /** Whether this transport instance is enabled */ + enabled: boolean + /** Transport-specific configuration */ + config: LinkTransportTypeConfig +} + +/** Supported transport types */ +export type LinkTransportType = 'mqtt' + +/** Union of all transport-specific configs */ +export type LinkTransportTypeConfig = LinkMqttConfig + +/** MQTT-specific transport configuration */ +export interface LinkMqttConfig { + /** Broker URL (e.g., mqtt://broker.example.com:1883) */ + brokerUrl: string + /** Username for broker auth */ + username: string + /** Password for broker auth */ + password: string + /** Whether to use TLS */ + tls: boolean +} + +/** Connection status of a transport instance */ +export type LinkTransportStatus = 'disconnected' | 'connecting' | 'connected' | 'error' + +/** Runtime state of a transport instance for UI */ +export interface LinkTransportState { + /** Transport config ID */ + id: string + /** Current connection status */ + status: LinkTransportStatus + /** Error message if status is 'error' */ + error: string | null +} + +/** A discovered peer instance */ +export interface LinkPeerInfo { + /** Peer's unique ID */ + id: string + /** Human-readable name */ + name: string + /** Companion version string */ + version: string + /** Protocol version number */ + protocolVersion: number + /** Number of pages */ + pageCount: number + /** Grid dimensions */ + gridSize: { rows: number; cols: number } + /** Whether the peer is online (reachable on at least one transport) */ + online: boolean + /** Which transport instance IDs this peer is reachable on */ + transports: string[] + /** Unix timestamp of last announcement (across all transports) */ + lastSeen: number +} + +/** Overall Link controller state for the UI */ +export interface LinkControllerState { + /** Whether the Link service is enabled */ + enabled: boolean + /** This instance's UUID */ + uuid: string +} + +/** Default MQTT config for new transport instances */ +export const DEFAULT_MQTT_CONFIG: LinkMqttConfig = { + brokerUrl: 'mqtt://localhost:1883', + username: '', + password: '', + tls: false, +} diff --git a/tools/link_schema_compile.mts b/tools/link_schema_compile.mts new file mode 100644 index 0000000000..43c02fdffd --- /dev/null +++ b/tools/link_schema_compile.mts @@ -0,0 +1,16 @@ +import { path } from 'zx' +import fs from 'fs' +import { compileFromFile } from 'json-schema-to-typescript' + +const schemaPath = path.join(import.meta.dirname, '../assets/link-protocol.schema.json') + +const PrettierConf = JSON.parse(fs.readFileSync(new URL('../.prettierrc', import.meta.url), 'utf8')) + +// Compile JSON Schema to TypeScript types +const compiledTypescript = await compileFromFile(schemaPath, { + additionalProperties: false, + style: PrettierConf, + enableConstEnums: false, +}) + +fs.writeFileSync(new URL('../companion/lib/Link/LinkProtocolSchema.ts', import.meta.url), compiledTypescript, 'utf8') diff --git a/webui/src/Buttons/EditButton/EditButton.tsx b/webui/src/Buttons/EditButton/EditButton.tsx index 593a067acd..0183ba0940 100644 --- a/webui/src/Buttons/EditButton/EditButton.tsx +++ b/webui/src/Buttons/EditButton/EditButton.tsx @@ -21,6 +21,7 @@ import { LocalVariablesEditor } from '../../Controls/LocalVariablesEditor.js' import { useLocalVariablesStore } from '../../Controls/LocalVariablesStore.js' import { useButtonImageForControlId } from '~/Hooks/useButtonImageForControlId.js' import { useControlConfig } from '~/Hooks/useControlConfig.js' +import { RemoteLinkButtonEditor } from './RemoteLinkButtonEditor.js' interface EditButtonProps { location: ControlLocation @@ -138,6 +139,10 @@ const EditButtonContent = observer(function EditButton({ {config.type === 'button' && ( )} + + {config.type === 'remotelinkbutton' && ( + + )} ) }) diff --git a/webui/src/Buttons/EditButton/RemoteLinkButtonEditor.tsx b/webui/src/Buttons/EditButton/RemoteLinkButtonEditor.tsx new file mode 100644 index 0000000000..b3955642d0 --- /dev/null +++ b/webui/src/Buttons/EditButton/RemoteLinkButtonEditor.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useState } from 'react' +import { CCol, CFormLabel, CFormSelect } from '@coreui/react' +import { TextInputField } from '~/Components/TextInputField.js' +import type { RemoteLinkButtonModel, RemoteLinkButtonRuntimeProps } from '@companion-app/shared/Model/ButtonModel.js' +import type { LinkPeerInfo } from '@companion-app/shared/Model/Link.js' +import { trpc, useMutationExt } from '~/Resources/TRPC.js' +import { useSubscription } from '@trpc/tanstack-react-query' + +interface RemoteLinkButtonEditorProps { + controlId: string + config: RemoteLinkButtonModel + runtimeProps: Record | false +} + +function useLinkPeersForEditor(): LinkPeerInfo[] | null { + const [peers, setPeers] = useState(null) + + useSubscription( + trpc.link.watchPeers.subscriptionOptions(undefined, { + onStarted: () => setPeers(null), + onData: (data) => setPeers(data), + onError: () => setPeers(null), + }) + ) + + return peers +} + +export function RemoteLinkButtonEditor({ + controlId, + config, + runtimeProps, +}: RemoteLinkButtonEditorProps): React.JSX.Element { + const peers = useLinkPeersForEditor() + + const setLinkConfig = useMutationExt(trpc.controls.setLinkConfig.mutationOptions()) + + const setPeerUuid = useCallback( + (e: React.ChangeEvent) => { + setLinkConfig.mutate({ controlId, config: { peerUuid: e.target.value } }) + }, + [controlId, setLinkConfig] + ) + + const setLocation = useCallback( + (value: string) => { + setLinkConfig.mutate({ controlId, config: { location: value } }) + }, + [controlId, setLinkConfig] + ) + + const runtime = runtimeProps as RemoteLinkButtonRuntimeProps | false + + return ( + <> +

Remote Link button

+

+ This button mirrors a button from a remote Companion instance. Configure the target peer and button location + below. +

+ + {runtime && ( +
+ +
+ )} + + + Target Peer + + + {peers?.map((peer) => ( + + ))} + + + + + Location + + + + ) +} + +function StatusDisplay({ visualState, peerName }: { visualState: string; peerName: string | null }): React.JSX.Element { + let statusText: string + let statusColor: string + + switch (visualState) { + case 'bitmap': + statusText = 'Connected' + statusColor = 'success' + break + case 'loading': + statusText = 'Loading...' + statusColor = 'warning' + break + case 'unreachable': + statusText = 'Peer offline' + statusColor = 'danger' + break + case 'loop_detected': + statusText = 'Loop detected' + statusColor = 'danger' + break + case 'unknown_peer': + default: + statusText = 'Unknown peer' + statusColor = 'secondary' + break + } + + return ( +
+ Status: {statusText} + {peerName && ( + + {' '} + — Peer: {peerName} + + )} +
+ ) +} diff --git a/webui/src/Buttons/EditButton/SelectButtonTypeDropdown.tsx b/webui/src/Buttons/EditButton/SelectButtonTypeDropdown.tsx index 5a84278843..c677e109a0 100644 --- a/webui/src/Buttons/EditButton/SelectButtonTypeDropdown.tsx +++ b/webui/src/Buttons/EditButton/SelectButtonTypeDropdown.tsx @@ -26,8 +26,14 @@ export function SelectButtonTypeDropdown({ return } - if (currentType && currentType !== 'pageup' && currentType !== 'pagedown' && currentType !== 'pagenum') { - if (newType === 'pageup' || newType === 'pagedown' || newType === 'pagenum') { + if ( + currentType && + currentType !== 'pageup' && + currentType !== 'pagedown' && + currentType !== 'pagenum' && + currentType !== 'remotelinkbutton' + ) { + if (newType === 'pageup' || newType === 'pagedown' || newType === 'pagenum' || newType === 'remotelinkbutton') { show_warning = true } } @@ -71,6 +77,7 @@ export function SelectButtonTypeDropdown({ setButtonType('pageup')}>Page up setButtonType('pagenum')}>Page number setButtonType('pagedown')}>Page down + setButtonType('remotelinkbutton')}>Remote Link button ) diff --git a/webui/src/Layout/Sidebar.tsx b/webui/src/Layout/Sidebar.tsx index 71822c780d..0aa79f4b55 100644 --- a/webui/src/Layout/Sidebar.tsx +++ b/webui/src/Layout/Sidebar.tsx @@ -17,6 +17,7 @@ import { faCog, faClipboardList, faCloud, + faLink, faTh, faClock, faPlug, @@ -224,6 +225,7 @@ export const MySidebar = memo(function MySidebar() { + {window.localStorage.getItem('show_companion_cloud') === '1' && ( diff --git a/webui/src/Link/index.tsx b/webui/src/Link/index.tsx new file mode 100644 index 0000000000..233a265d31 --- /dev/null +++ b/webui/src/Link/index.tsx @@ -0,0 +1,421 @@ +import React, { useCallback, useState } from 'react' +import { + CButton, + CCard, + CCardBody, + CCardHeader, + CCol, + CFormInput, + CFormLabel, + CFormSwitch, + CRow, + CTable, + CTableBody, + CTableDataCell, + CTableHead, + CTableHeaderCell, + CTableRow, +} from '@coreui/react' +import { LoadingRetryOrError } from '~/Resources/Loading.js' +import type { + LinkControllerState, + LinkPeerInfo, + LinkTransportConfig, + LinkTransportState, +} from '@companion-app/shared/Model/Link.js' +import { useSubscription } from '@trpc/tanstack-react-query' +import { trpc, useMutationExt } from '~/Resources/TRPC.js' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus, faTrash } from '@fortawesome/free-solid-svg-icons' + +export function LinkPage(): React.JSX.Element { + const linkState = useLinkState() + + return ( +
+

Companion Link

+

+ Companion Link is the next generation of Companion Cloud, taking a “bring your own infrastructure” + approach. Instead of relying on a hosted cloud service, you connect your Companion instances together using + off-the-shelf MQTT brokers that you deploy and manage yourself. This gives you full control over your data, + latency, and network topology. +

+

+ Configure one or more MQTT transports below to enable peer discovery, remote button presses, and bitmap + streaming between Companion instances. +

+ + {linkState ? : } +
+ ) +} + +// ── Hooks ────────────────────────────────────────────────── + +function useLinkState(): LinkControllerState | null { + const [state, setState] = useState(null) + + useSubscription( + trpc.link.watchState.subscriptionOptions(undefined, { + onStarted: () => setState(null), + onData: (data) => setState(data), + onError: () => setState(null), + }) + ) + + return state +} + +function useLinkTransportConfigs(): LinkTransportConfig[] | null { + const [configs, setConfigs] = useState(null) + + useSubscription( + trpc.link.watchTransportConfigs.subscriptionOptions(undefined, { + onStarted: () => setConfigs(null), + onData: (data) => setConfigs(data), + onError: () => setConfigs(null), + }) + ) + + return configs +} + +function useLinkTransportStates(): LinkTransportState[] | null { + const [states, setStates] = useState(null) + + useSubscription( + trpc.link.watchTransportStates.subscriptionOptions(undefined, { + onStarted: () => setStates(null), + onData: (data) => setStates(data), + onError: () => setStates(null), + }) + ) + + return states +} + +function useLinkPeers(): LinkPeerInfo[] | null { + const [peers, setPeers] = useState(null) + + useSubscription( + trpc.link.watchPeers.subscriptionOptions(undefined, { + onStarted: () => setPeers(null), + onData: (data) => setPeers(data), + onError: () => setPeers(null), + }) + ) + + return peers +} + +// ── Main Content ─────────────────────────────────────────── + +function LinkPageContent({ state }: { state: LinkControllerState }): React.JSX.Element { + const transportConfigs = useLinkTransportConfigs() + const transportStates = useLinkTransportStates() + const peers = useLinkPeers() + + const setEnabled = useMutationExt(trpc.link.setEnabled.mutationOptions()) + const regenerateUUID = useMutationExt(trpc.link.regenerateUUID.mutationOptions()) + + const handleEnabledChange = useCallback( + (e: React.ChangeEvent) => { + setEnabled.mutate({ enabled: e.target.checked }) + }, + [setEnabled] + ) + + const handleRegenerateUUID = useCallback(() => { + if (window.confirm('Are you sure? This will change your instance identity for all peers.')) { + regenerateUUID.mutate() + } + }, [regenerateUUID]) + + return ( + <> + {/* Settings Card */} + + Settings + + + + Enabled + + + + + + + + Instance Name + + + + This instance will be announced to peers using your Installation Name from the Settings + page. + + + + + + Instance UUID + + +
+ {state.uuid} + + Regenerate + +
+
+
+
+
+ + {/* Transports Card */} + + + {/* Peers Card */} + + + ) +} + +// ── Transports Panel ─────────────────────────────────────── + +function TransportsPanel({ + configs, + states, + enabled, +}: { + configs: LinkTransportConfig[] | null + states: LinkTransportState[] | null + enabled: boolean +}): React.JSX.Element { + const addTransport = useMutationExt(trpc.link.addTransport.mutationOptions()) + const removeTransport = useMutationExt(trpc.link.removeTransport.mutationOptions()) + const updateTransport = useMutationExt(trpc.link.updateTransport.mutationOptions()) + + const handleAddTransport = useCallback(() => { + addTransport.mutate({ type: 'mqtt', label: 'New MQTT Transport' }) + }, [addTransport]) + + const handleRemoveTransport = useCallback( + (id: string) => { + if (window.confirm('Remove this transport?')) { + removeTransport.mutate({ id }) + } + }, + [removeTransport] + ) + + return ( + + + Transports + + Add MQTT Transport + + + + {!configs ? ( + + ) : configs.length === 0 ? ( +

No transports configured. Add one to start connecting.

+ ) : ( + configs.map((config) => { + const transportState = states?.find((s) => s.id === config.id) + return ( + updateTransport.mutate({ id: config.id, ...updates })} + /> + ) + }) + )} +
+
+ ) +} + +interface TransportUpdateInput { + label?: string + enabled?: boolean + config?: { + brokerUrl?: string + username?: string + password?: string + tls?: boolean + } +} + +function TransportConfigRow({ + config, + state, + onRemove, + onUpdate, +}: { + config: LinkTransportConfig + state: LinkTransportState | null + onRemove: (id: string) => void + onUpdate: (updates: TransportUpdateInput) => void +}): React.JSX.Element { + const statusColor = getStatusColor(state?.status ?? 'disconnected') + + return ( + + + + + {config.label} + {state?.status ?? 'unknown'} + {state?.error && {state.error}} + + + onUpdate({ enabled: e.target.checked })} + /> + + + onRemove(config.id)}> + + + + + {config.type === 'mqtt' && ( + onUpdate({ config: mqttUpdates })} /> + )} + + + ) +} + +function MqttConfigFields({ + config, + onUpdate, +}: { + config: LinkTransportConfig['config'] + onUpdate: (updates: TransportUpdateInput['config']) => void +}): React.JSX.Element { + return ( + + + Broker URL + onUpdate({ brokerUrl: e.target.value })} + onBlur={(e) => onUpdate({ brokerUrl: e.target.value })} + /> + + + Username + onUpdate({ username: e.target.value })} /> + + + Password + onUpdate({ password: e.target.value })} /> + + + ) +} + +// ── Peers Panel ──────────────────────────────────────────── + +function PeersPanel({ peers }: { peers: LinkPeerInfo[] | null }): React.JSX.Element { + const deletePeer = useMutationExt(trpc.link.deletePeer.mutationOptions()) + + const handleDeletePeer = useCallback( + (peerId: string) => { + if (window.confirm('Remove this peer from the list?')) { + deletePeer.mutate({ peerId }) + } + }, + [deletePeer] + ) + + return ( + + Discovered Peers + + {!peers ? ( + + ) : peers.length === 0 ? ( +

No peers discovered yet. Peers will appear here when they announce themselves.

+ ) : ( + + + + Name + Status + Version + Pages + Grid + Last Seen + + + + + {peers.map((peer) => ( + + ))} + + + )} +
+
+ ) +} + +function PeerRow({ peer, onDelete }: { peer: LinkPeerInfo; onDelete: (id: string) => void }): React.JSX.Element { + const statusColor = peer.online ? 'success' : 'secondary' + const lastSeenStr = peer.lastSeen ? new Date(peer.lastSeen).toLocaleString() : 'Never' + + return ( + + + {peer.name} +
+ {peer.id} +
+ + {peer.online ? 'Online' : 'Offline'} +
+ + {peer.transports.length} transport{peer.transports.length !== 1 ? 's' : ''} + +
+ {peer.version} + {peer.pageCount} + + {peer.gridSize.rows}×{peer.gridSize.cols} + + {lastSeenStr} + + onDelete(peer.id)}> + + + +
+ ) +} + +// ── Helpers ──────────────────────────────────────────────── + +function getStatusColor(status: string): string { + switch (status) { + case 'connected': + return 'success' + case 'connecting': + return 'warning' + case 'error': + return 'danger' + default: + return 'secondary' + } +} diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index 220538f20b..164d407d10 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as StandaloneEmulatorRouteImport } from './routes/_standalone/emu import { Route as AppTriggersRouteImport } from './routes/_app/triggers.tsx' import { Route as AppModulesRouteImport } from './routes/_app/modules.tsx' import { Route as AppLogRouteImport } from './routes/_app/log.tsx' +import { Route as AppLinkRouteImport } from './routes/_app/link.tsx' import { Route as AppImportExportRouteImport } from './routes/_app/import-export.tsx' import { Route as AppConnectionsRouteImport } from './routes/_app/connections.tsx' import { Route as AppCloudRouteImport } from './routes/_app/cloud.tsx' @@ -161,6 +162,11 @@ const AppLogRoute = AppLogRouteImport.update({ path: '/log', getParentRoute: () => AppRoute, } as any) +const AppLinkRoute = AppLinkRouteImport.update({ + id: '/link', + path: '/link', + getParentRoute: () => AppRoute, +} as any) const AppImportExportRoute = AppImportExportRouteImport.update({ id: '/import-export', path: '/import-export', @@ -433,6 +439,7 @@ export interface FileRoutesByFullPath { '/cloud': typeof AppCloudRoute '/connections': typeof AppConnectionsRouteWithChildren '/import-export': typeof AppImportExportRoute + '/link': typeof AppLinkRoute '/log': typeof AppLogRoute '/modules': typeof AppModulesRouteWithChildren '/triggers': typeof AppTriggersRouteWithChildren @@ -495,6 +502,7 @@ export interface FileRoutesByTo { '/buttons': typeof AppButtonsRouteWithChildren '/cloud': typeof AppCloudRoute '/import-export': typeof AppImportExportRoute + '/link': typeof AppLinkRoute '/log': typeof AppLogRoute '/tablet': typeof StandaloneTabletDotlazyRoute '/': typeof AppIndexRoute @@ -553,6 +561,7 @@ export interface FileRoutesById { '/_app/cloud': typeof AppCloudRoute '/_app/connections': typeof AppConnectionsRouteWithChildren '/_app/import-export': typeof AppImportExportRoute + '/_app/link': typeof AppLinkRoute '/_app/log': typeof AppLogRoute '/_app/modules': typeof AppModulesRouteWithChildren '/_app/triggers': typeof AppTriggersRouteWithChildren @@ -620,6 +629,7 @@ export interface FileRouteTypes { | '/cloud' | '/connections' | '/import-export' + | '/link' | '/log' | '/modules' | '/triggers' @@ -682,6 +692,7 @@ export interface FileRouteTypes { | '/buttons' | '/cloud' | '/import-export' + | '/link' | '/log' | '/tablet' | '/' @@ -739,6 +750,7 @@ export interface FileRouteTypes { | '/_app/cloud' | '/_app/connections' | '/_app/import-export' + | '/_app/link' | '/_app/log' | '/_app/modules' | '/_app/triggers' @@ -920,6 +932,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppLogRouteImport parentRoute: typeof AppRoute } + '/_app/link': { + id: '/_app/link' + path: '/link' + fullPath: '/link' + preLoaderRoute: typeof AppLinkRouteImport + parentRoute: typeof AppRoute + } '/_app/import-export': { id: '/_app/import-export' path: '/import-export' @@ -1392,6 +1411,7 @@ interface AppRouteChildren { AppCloudRoute: typeof AppCloudRoute AppConnectionsRoute: typeof AppConnectionsRouteWithChildren AppImportExportRoute: typeof AppImportExportRoute + AppLinkRoute: typeof AppLinkRoute AppLogRoute: typeof AppLogRoute AppModulesRoute: typeof AppModulesRouteWithChildren AppTriggersRoute: typeof AppTriggersRouteWithChildren @@ -1422,6 +1442,7 @@ const AppRouteChildren: AppRouteChildren = { AppCloudRoute: AppCloudRoute, AppConnectionsRoute: AppConnectionsRouteWithChildren, AppImportExportRoute: AppImportExportRoute, + AppLinkRoute: AppLinkRoute, AppLogRoute: AppLogRoute, AppModulesRoute: AppModulesRouteWithChildren, AppTriggersRoute: AppTriggersRouteWithChildren, diff --git a/webui/src/routes/_app/link.tsx b/webui/src/routes/_app/link.tsx new file mode 100644 index 0000000000..5067c479a5 --- /dev/null +++ b/webui/src/routes/_app/link.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router' +import { LinkPage } from '~/Link/index.js' + +export const Route = createFileRoute('/_app/link')({ + component: LinkPage, +}) diff --git a/yarn.lock b/yarn.lock index 30b00bbbf3..3cd0c48079 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1601,6 +1601,13 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.28.6": + version: 7.28.6 + resolution: "@babel/runtime@npm:7.28.6" + checksum: 10c0/358cf2429992ac1c466df1a21c1601d595c46930a13c1d4662fde908d44ee78ec3c183aaff513ecb01ef8c55c3624afe0309eeeb34715672dbfadb7feedb2c0d + languageName: node + linkType: hard + "@babel/template@npm:^7.27.1, @babel/template@npm:^7.27.2": version: 7.27.2 resolution: "@babel/template@npm:7.27.2" @@ -7921,6 +7928,15 @@ __metadata: languageName: node linkType: hard +"@types/readable-stream@npm:^4.0.0, @types/readable-stream@npm:^4.0.21": + version: 4.0.23 + resolution: "@types/readable-stream@npm:4.0.23" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/89ae3f6a53d186252c4c957b715c8dc12b318be30aeb3546f6513163572e5eebe0f61261e70c6d3f7496d63741ed6f92fbc5d17bfe9a72b0abfe720ee8fa471a + languageName: node + linkType: hard + "@types/responselike@npm:^1.0.0": version: 1.0.3 resolution: "@types/responselike@npm:1.0.3" @@ -8802,6 +8818,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "accepts@npm:^2.0.0": version: 2.0.0 resolution: "accepts@npm:2.0.0" @@ -9754,6 +9779,18 @@ __metadata: languageName: node linkType: hard +"bl@npm:^6.0.8": + version: 6.1.6 + resolution: "bl@npm:6.1.6" + dependencies: + "@types/readable-stream": "npm:^4.0.0" + buffer: "npm:^6.0.3" + inherits: "npm:^2.0.4" + readable-stream: "npm:^4.2.0" + checksum: 10c0/91195dae603a389ffb7343c2c69722648d0d61998eac09f60cecab7c1f25500bf98babc21e5ec703dd3555d93a1aae8a0d1cdfcada4d23df75adc9e434daa45c + languageName: node + linkType: hard + "body-parser@npm:^2.2.1": version: 2.2.1 resolution: "body-parser@npm:2.2.1" @@ -9875,6 +9912,18 @@ __metadata: languageName: node linkType: hard +"broker-factory@npm:^3.1.13": + version: 3.1.13 + resolution: "broker-factory@npm:3.1.13" + dependencies: + "@babel/runtime": "npm:^7.28.6" + fast-unique-numbers: "npm:^9.0.26" + tslib: "npm:^2.8.1" + worker-factory: "npm:^7.0.48" + checksum: 10c0/87cf2ba822975d74fd0a67a7aa24c4d70f27975329e29fae86eb30331bee6fb7facc5756b6284ed4422e740ca34912a245dd1f6a3ade9ff1cbc8c82567070ad1 + languageName: node + linkType: hard + "browserslist-to-esbuild@npm:^2.1.1": version: 2.1.1 resolution: "browserslist-to-esbuild@npm:2.1.1" @@ -10695,6 +10744,13 @@ __metadata: languageName: node linkType: hard +"commist@npm:^3.2.0": + version: 3.2.0 + resolution: "commist@npm:3.2.0" + checksum: 10c0/ab2d14921d30f649889adbec5dbf1712d45681bbc3f863ee5078e02465b2e8510d47a5643e137ffa0698b8199b5ce787d8be131982bcae4f294c8225d1046def + languageName: node + linkType: hard + "common-path-prefix@npm:^3.0.0": version: 3.0.0 resolution: "common-path-prefix@npm:3.0.0" @@ -10747,6 +10803,7 @@ __metadata: fast-json-patch: "patch:fast-json-patch@npm%3A3.1.1#~/.yarn/patches/fast-json-patch-npm-3.1.1-7e8bb70a45.patch" fs-extra: "npm:^11.3.3" get-port: "npm:^7.1.0" + mqtt: "npm:^5.12.1" nanoid: "npm:^5.1.6" node-cron: "npm:^4.2.1" node-hid: "npm:^3.3.0" @@ -10828,6 +10885,18 @@ __metadata: languageName: node linkType: hard +"concat-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "concat-stream@npm:2.0.0" + dependencies: + buffer-from: "npm:^1.0.0" + inherits: "npm:^2.0.3" + readable-stream: "npm:^3.0.2" + typedarray: "npm:^0.0.6" + checksum: 10c0/29565dd9198fe1d8cf57f6cc71527dbc6ad67e12e4ac9401feb389c53042b2dceedf47034cbe702dfc4fd8df3ae7e6bfeeebe732cc4fa2674e484c13f04c219a + languageName: node + linkType: hard + "concurrently@npm:^9.2.1": version: 9.2.1 resolution: "concurrently@npm:9.2.1" @@ -13135,6 +13204,13 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + "eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.4, eventemitter3@npm:^4.0.7": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" @@ -13149,7 +13225,7 @@ __metadata: languageName: node linkType: hard -"events@npm:^3.2.0": +"events@npm:^3.2.0, events@npm:^3.3.0": version: 3.3.0 resolution: "events@npm:3.3.0" checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 @@ -13456,6 +13532,16 @@ __metadata: languageName: node linkType: hard +"fast-unique-numbers@npm:^9.0.26": + version: 9.0.26 + resolution: "fast-unique-numbers@npm:9.0.26" + dependencies: + "@babel/runtime": "npm:^7.28.6" + tslib: "npm:^2.8.1" + checksum: 10c0/db0e280bef97e48a78f6a1c0e6a7f014aabd031aeb1b455e90b8010bd9c2817a75fc9be6046efdb1bf39a7a934386466137a212fcfefdfb820fb0084b9ab79f8 + languageName: node + linkType: hard + "fast-uri@npm:^3.0.1": version: 3.0.1 resolution: "fast-uri@npm:3.0.1" @@ -14726,6 +14812,13 @@ __metadata: languageName: node linkType: hard +"help-me@npm:^5.0.0": + version: 5.0.0 + resolution: "help-me@npm:5.0.0" + checksum: 10c0/054c0e2e9ae2231c85ab5e04f75109b9d068ffcc54e58fb22079822a5ace8ff3d02c66fd45379c902ad5ab825e5d2e1451fcc2f7eab1eb49e7d488133ba4cacb + languageName: node + linkType: hard + "hermes-estree@npm:0.25.1": version: 0.25.1 resolution: "hermes-estree@npm:0.25.1" @@ -16154,6 +16247,13 @@ __metadata: languageName: node linkType: hard +"js-sdsl@npm:4.3.0": + version: 4.3.0 + resolution: "js-sdsl@npm:4.3.0" + checksum: 10c0/cd3c342d08ed646d271bc59e5da12d732fee4a6fa32a30f6552d66cdf5c744b1fc5b61cddaff80702c1c6bb92add3b17ce0b6ff8a2cc7f9188df4065183aa7fb + languageName: node + linkType: hard + "js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0": version: 4.0.0 resolution: "js-tokens@npm:4.0.0" @@ -16913,7 +17013,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.2.0": +"lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 10c0/ebd04fbca961e6c1d6c0af3799adcc966a1babe798f685bb84e6599266599cd95d94630b10262f5424539bc4640107e8a33aa28585374abf561d30d16f4b39fb @@ -18314,6 +18414,45 @@ __metadata: languageName: node linkType: hard +"mqtt-packet@npm:^9.0.2": + version: 9.0.2 + resolution: "mqtt-packet@npm:9.0.2" + dependencies: + bl: "npm:^6.0.8" + debug: "npm:^4.3.4" + process-nextick-args: "npm:^2.0.1" + checksum: 10c0/3890efe98d4e9562f70afdcdc5729681cf751a0fcde796e1d681f5a41504a4a9abfd35ba4a2b39fb8e70ffb86d5fb8a0bb806da5a332ded6904dd8eaa6e66888 + languageName: node + linkType: hard + +"mqtt@npm:^5.12.1": + version: 5.15.0 + resolution: "mqtt@npm:5.15.0" + dependencies: + "@types/readable-stream": "npm:^4.0.21" + "@types/ws": "npm:^8.18.1" + commist: "npm:^3.2.0" + concat-stream: "npm:^2.0.0" + debug: "npm:^4.4.1" + help-me: "npm:^5.0.0" + lru-cache: "npm:^10.4.3" + minimist: "npm:^1.2.8" + mqtt-packet: "npm:^9.0.2" + number-allocator: "npm:^1.0.14" + readable-stream: "npm:^4.7.0" + rfdc: "npm:^1.4.1" + socks: "npm:^2.8.6" + split2: "npm:^4.2.0" + worker-timers: "npm:^8.0.23" + ws: "npm:^8.18.3" + bin: + mqtt: build/bin/mqtt.js + mqtt_pub: build/bin/pub.js + mqtt_sub: build/bin/sub.js + checksum: 10c0/cfa887a7c360ce6d16285a8787b83047f5ecd7280a9b75bc42ed0c5ff935fcbd2b6f54edb85b0d17d84a946c9b5b4994b239bee0f7f72f5e100bece7f6e4eab9 + languageName: node + linkType: hard + "mrmime@npm:^2.0.0": version: 2.0.0 resolution: "mrmime@npm:2.0.0" @@ -18759,6 +18898,16 @@ __metadata: languageName: node linkType: hard +"number-allocator@npm:^1.0.14": + version: 1.0.14 + resolution: "number-allocator@npm:1.0.14" + dependencies: + debug: "npm:^4.3.1" + js-sdsl: "npm:4.3.0" + checksum: 10c0/273fc81f61018fddaee4277cb08a7d994838e9d202cf90f5f329d087e605e4ac82405b397f1ea608f46ae26f47e17b7b4318ed2a8dd6541bf794c1ced47b876a + languageName: node + linkType: hard + "object-assign@npm:^4, object-assign@npm:^4.1.1": version: 4.1.1 resolution: "object-assign@npm:4.1.1" @@ -20630,13 +20779,20 @@ __metadata: languageName: node linkType: hard -"process-nextick-args@npm:~2.0.0": +"process-nextick-args@npm:^2.0.1, process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" checksum: 10c0/bec089239487833d46b59d80327a1605e1c5287eaad770a291add7f45fda1bb5e28b38e0e061add0a1d0ee0984788ce74fa394d345eed1c420cacf392c554367 languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "progress@npm:^2.0.3": version: 2.0.3 resolution: "progress@npm:2.0.3" @@ -21415,7 +21571,7 @@ __metadata: languageName: node linkType: hard -"readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": +"readable-stream@npm:^3.0.2, readable-stream@npm:^3.0.6, readable-stream@npm:^3.1.1, readable-stream@npm:^3.4.0, readable-stream@npm:^3.6.2": version: 3.6.2 resolution: "readable-stream@npm:3.6.2" dependencies: @@ -21426,6 +21582,19 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.2.0, readable-stream@npm:^4.7.0": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10c0/fd86d068da21cfdb10f7a4479f2e47d9c0a9b0c862fc0c840a7e5360201580a55ac399c764b12a4f6fa291f8cee74d9c4b7562e0d53b3c4b2769f2c98155d957 + languageName: node + linkType: hard + "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -23174,7 +23343,7 @@ __metadata: languageName: node linkType: hard -"socks@npm:^2.8.3": +"socks@npm:^2.8.3, socks@npm:^2.8.6": version: 2.8.7 resolution: "socks@npm:2.8.7" dependencies: @@ -23284,6 +23453,13 @@ __metadata: languageName: node linkType: hard +"split2@npm:^4.2.0": + version: 4.2.0 + resolution: "split2@npm:4.2.0" + checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 + languageName: node + linkType: hard + "split@npm:0.3": version: 0.3.3 resolution: "split@npm:0.3.3" @@ -23586,7 +23762,7 @@ __metadata: languageName: node linkType: hard -"string_decoder@npm:^1.1.1": +"string_decoder@npm:^1.1.1, string_decoder@npm:^1.3.0": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" dependencies: @@ -24598,6 +24774,13 @@ __metadata: languageName: node linkType: hard +"typedarray@npm:^0.0.6": + version: 0.0.6 + resolution: "typedarray@npm:0.0.6" + checksum: 10c0/6005cb31df50eef8b1f3c780eb71a17925f3038a100d82f9406ac2ad1de5eb59f8e6decbdc145b3a1f8e5836e17b0c0002fb698b9fe2516b8f9f9ff602d36412 + languageName: node + linkType: hard + "typescript-eslint@npm:^8.53.1": version: 8.53.1 resolution: "typescript-eslint@npm:8.53.1" @@ -25955,6 +26138,53 @@ __metadata: languageName: node linkType: hard +"worker-factory@npm:^7.0.48": + version: 7.0.48 + resolution: "worker-factory@npm:7.0.48" + dependencies: + "@babel/runtime": "npm:^7.28.6" + fast-unique-numbers: "npm:^9.0.26" + tslib: "npm:^2.8.1" + checksum: 10c0/1de9a9a78ab01c70201a8d95af6e318be0a60b22cd68c2db8af86f64445a0e58eaa4708a076c6436d6980871f6c71c1a12fe5f7c7a8897edcac67d5a0cca2a0c + languageName: node + linkType: hard + +"worker-timers-broker@npm:^8.0.15": + version: 8.0.15 + resolution: "worker-timers-broker@npm:8.0.15" + dependencies: + "@babel/runtime": "npm:^7.28.6" + broker-factory: "npm:^3.1.13" + fast-unique-numbers: "npm:^9.0.26" + tslib: "npm:^2.8.1" + worker-timers-worker: "npm:^9.0.13" + checksum: 10c0/099e7f24cd9cd019c3805d08ecfa0a5dc83f4f8fe0f9122871dcf2f792d447eef716b1202491956d1fef0ec1b31d43a6d0a55b35f394e66dd7025378144c068c + languageName: node + linkType: hard + +"worker-timers-worker@npm:^9.0.13": + version: 9.0.13 + resolution: "worker-timers-worker@npm:9.0.13" + dependencies: + "@babel/runtime": "npm:^7.28.6" + tslib: "npm:^2.8.1" + worker-factory: "npm:^7.0.48" + checksum: 10c0/65bea423782364febba0efe673a5dfc3a965e4c43b3f79ae6cf7f33c2ab2166d566ff186e9a1b4b5d0e6a84c4589c2d403692e887cc48acca246dc561f7c62f5 + languageName: node + linkType: hard + +"worker-timers@npm:^8.0.23": + version: 8.0.30 + resolution: "worker-timers@npm:8.0.30" + dependencies: + "@babel/runtime": "npm:^7.28.6" + tslib: "npm:^2.8.1" + worker-timers-broker: "npm:^8.0.15" + worker-timers-worker: "npm:^9.0.13" + checksum: 10c0/6fe57e28de2ec0b62254250fee4e92f3b8e6552cf1f8498c595dd9df1d1b1c18c622d932fbf2154403737bafafa86b248d7f454553665eff36a29667f1665ccc + languageName: node + linkType: hard + "workerpool@npm:^10.0.1": version: 10.0.1 resolution: "workerpool@npm:10.0.1"