import { EventPayload, PhyHubClient, MediaStreamOptions } from '.'; import { Instance, PeripheralOwnershipLoss, TwinMessageResult, TwinResponse, TwinTypeEnum } from './types/twin.types'; import { createTwinMessaging, TwinMessagingMethods } from './twin-messaging'; import { WebRTCManager, TwinTransport, MediaStreamResponderOptions, DataChannelResponderOptions, MediaStreamCallback, DataChannelCallback, } from './services/webrtc'; import { DescriptorValidator, validateEmittedFacet } from './advisory-validation'; export class PeripheralTwinInstance { public phyHubClient: PhyHubClient; public twinId: string; public edgeInstance: Instance | null = null; public peripheralTwinResponse: TwinResponse | null = null; private messaging: TwinMessagingMethods | null = null; private messagingTransport: TwinTransport | null = null; private webrtcTransport: TwinTransport | null = null; private webrtcManager: WebRTCManager | null = null; // Opt-in advisory descriptor validation. Null until the app calls // enableAdvisoryValidation(); while null, the emit paths behave byte-identically // to a non-validating client (no validation, no warnings). See // advisory-validation.ts for the opt-in/advisory/fail-open contract. private advisoryValidator: DescriptorValidator | null = null; // Ownership-loss revocation (TECH-1394): set when another device OR another // instance on this device took the twin over (peripheral hardwareIds are // unique tenant-wide, so a registration of the same id re-points the twin // instead of minting a duplicate). Once set, the owner-write paths (emit, // updateReported) reject locally with an explicit reason instead of // silently double-driving hardware this app no longer owns, and the // transport gate below stops INCOMING action execution — for a same-device // instance takeover the server cannot isolate the loser (action delivery is // device-room based and sibling instances share the device's sockets), so // dropping incoming messages here is the only cutoff that exists. private ownershipLostTo: PeripheralOwnershipLoss | null = null; private ownershipLostCallbacks: Set<(loss: PeripheralOwnershipLoss) => void> = new Set(); /** * True when this instance held the twin at initialize (the twin's deviceId * was this device and `desired.instanceId` — mandatory, stamped by phyhub * on every registration — was this app instance). The ownership-loss * watcher arms ONLY for owner instances: a consumer acquiring a sibling * instance's peripheral on the SAME device legitimately sees a foreign * `desired.instanceId` on every twinUpdated and must never self-revoke. */ public acquiredAsOwner = false; // Revocation gate wrappers around the transport onMessage callbacks — // tracked per original callback (with the twinId each was registered // under) so offMessage still unregisters by the caller's identity (same // pattern as validationWrappers) and markOwnershipLost can tear every // registration down. private revocationGateWrappers = new Map<(msg: any) => void, { twinId: string; gated: (msg: any) => void }>(); // Disposers for onUpdateReported/onUpdateDesired registrations, so // markOwnershipLost can drop them — without this the loser keeps observing // the new owner's twin activity through its own callbacks. private twinUpdateDisposers = new Set<() => void>(); // The advisory-validation branch of on() hands messaging a wrapper, not the // caller's callback — track wrapper per (eventType, callback) so off() can // still unregister by the caller's original identity (mirrors the wrapper // registry inside createTwinMessaging). private validationWrappers = new Map< string, Map< (message: any, respond?: (result: TwinMessageResult) => void) => void, (message: any, respond?: (result: TwinMessageResult) => void) => void > >(); constructor(phyHubClient: PhyHubClient, twinId: string) { this.phyHubClient = phyHubClient; this.twinId = twinId; } /** * Opt in to advisory descriptor validation of the facets this peripheral EMITS * (reported properties, event payloads, action returns). * * The app supplies precompiled validators built from its own compiled descriptor * schema (the build-time-embed approach — hub-client ships no JSON-schema runtime * of its own; see advisory-validation.ts for the bundle-size rationale). * * ADVISORY ONLY: a mismatch logs a warning and the message is STILL emitted/sent; * the validation outcome never affects whether or how anything goes on the wire. * Not calling this method leaves the existing behavior unchanged. */ enableAdvisoryValidation(validator: DescriptorValidator): void { this.advisoryValidator = validator; } private descriptorLabel(): string { return this.peripheralTwinResponse?.id ?? this.twinId; } // initialize the instance by performing sanity checks async initialize() { const edgeInstance = await this.phyHubClient.getInstance(); if (!edgeInstance) { console.error('Edge instance not found'); throw new Error('Edge instance not found'); } this.edgeInstance = edgeInstance; const peripheralTwinResponse = await this.phyHubClient.getTwinById(this.twinId); if (!peripheralTwinResponse) { console.error(`Peripheral twin with id ${this.twinId} not found`); throw new Error(`Peripheral twin with id ${this.twinId} not found`); } if (peripheralTwinResponse.type !== TwinTypeEnum.Peripheral) { console.error(`Twin ${this.twinId} is not a peripheral twin`); throw new Error(`Twin ${this.twinId} is not a peripheral twin`); } this.peripheralTwinResponse = peripheralTwinResponse; // Owner detection for the ownership-loss watcher: the twin names this // device AND this app instance. `desired.instanceId` is mandatory — // phyhub stamps it on every registration — so anything else, including // an absent value, means another owner. Evaluated once — a takeover // after this point is exactly what the watcher exists to catch. const ownerInstanceId = (peripheralTwinResponse.properties?.desired as { instanceId?: string } | undefined) ?.instanceId; this.acquiredAsOwner = peripheralTwinResponse.deviceId === edgeInstance.deviceId && ownerInstanceId === edgeInstance.id; const peripheralTwinId = peripheralTwinResponse.id; const deviceId = edgeInstance.deviceId; const sharedTransportFields = { subscribe: async (twinId: string) => { await this.phyHubClient.subscribeTwin(twinId); }, // Revocation gate on the INBOUND path: after an ownership loss the // sibling/new owner keeps registering the same messages (same-device // delivery is device-room broadcast), so the loser must drop them // locally — otherwise both instances execute every action. onMessage: (twinId: string, callback: (msg: any) => void) => { const gated = (msg: any) => { if (this.ownershipLostTo !== null) { return; } callback(msg); }; this.revocationGateWrappers.set(callback, { twinId, gated }); this.phyHubClient.onTwinMessage(twinId, gated); }, offMessage: (twinId: string, callback: (msg: any) => void) => { const gateEntry = this.revocationGateWrappers.get(callback); this.revocationGateWrappers.delete(callback); this.phyHubClient.offTwinMessage(twinId, gateEntry?.gated ?? callback); }, twinId: peripheralTwinId, }; // Messaging transport uses sendEvent (envelope sourceTwinId: edgeInstanceId). // This avoids the twinId === sourceTwinId path in edge-hub which causes // incorrect error responses and fire-and-forget phyhub forwarding. // The ownershipLostTo check on the OUTBOUND path silences in-flight // respond() callbacks after a revocation — the loser must not race error // responses against the new owner (first ack wins in the protocol). this.messagingTransport = { sendMessage: async (targetTwinId: string, data: any) => { if (this.ownershipLostTo !== null) { return; } this.phyHubClient.sendEvent(targetTwinId, data); }, ...sharedTransportFields, }; // WebRTC transport uses direct emit with peripheral identity (sourceTwinId: peripheralTwinId). // PeerConnectionManager filters by sourceTwinId and both sides target the peripheral twin, // so signals must come from the peripheral ID to pass the filter. this.webrtcTransport = { sendMessage: async (targetTwinId: string, data: any) => { if (this.ownershipLostTo !== null) { return; } const payload: EventPayload = { twinId: targetTwinId, sourceTwinId: peripheralTwinId, sourceDeviceId: deviceId, data, }; this.phyHubClient.emit('twinMessage', payload); }, ...sharedTransportFields, }; // Create messaging methods using the shared factory this.messaging = createTwinMessaging(this.messagingTransport, `peripheralInstance:${peripheralTwinResponse.id}`); await this.phyHubClient.subscribeTwin(this.twinId); } private ensureInitialized(): void { if (!this.messaging) { throw new Error('[PeripheralTwinInstance] Not initialized. Call initialize() first.'); } } /** * Owner-write guard (TECH-1394): after another device or instance took * this twin over, acting as its owner is an explicit error, not a silent * no-op — the server would reject a foreign-device report anyway * (PeripheralOwnershipError) and an emit would double-drive hardware this * app no longer controls. */ private assertOwnershipHeld(): void { if (this.ownershipLostTo !== null) { throw new Error( `[PeripheralTwinInstance] ownership lost: peripheral ${this.twinId} is now owned by ` + `${this.describeNewOwner()} — re-register (createPeripheralTwin) to take it back`, ); } } private describeNewOwner(): string { const { newOwnerDeviceId, newOwnerInstanceId } = this.ownershipLostTo ?? {}; if (newOwnerDeviceId) { return `device ${newOwnerDeviceId}`; } if (newOwnerInstanceId) { return `instance ${newOwnerInstanceId} on this device`; } return 'another owner'; } /** * Register a callback for the moment this app loses the twin to another * device or to another instance on the same device — the hook for * releasing the underlying hardware (stop the stream pipeline, drop the * vendor connection). Fires at most once. The loss names whichever part * of the owner identity changed (`newOwnerDeviceId` for a cross-device * takeover, `newOwnerInstanceId` for a same-device one). */ onOwnershipLost(callback: (loss: PeripheralOwnershipLoss) => void): void { this.ownershipLostCallbacks.add(callback); } /** * Called by PhyHubClient when it learns another device or instance owns * this twin (a `twinUpdated` naming a foreign owner, or a report rejected * with `PeripheralOwnershipError`). Idempotent — only the first call fires * the callbacks. Tears the instance's registrations down before the * callbacks run, so the app's release hook sees a fully quiesced instance: * - message listeners are unregistered (a dead-but-registered gated * listener would keep acking `Success 'Action completed'` at the * transport level for actions it silently dropped, and each * loss/re-acquire cycle would pile another dead set onto the socket * client); * - twin-update listeners (onUpdateReported/onUpdateDesired) are * disposed — the loser must not keep observing the new owner's twin * activity through its own callbacks; * - armed WebRTC responders are closed: they serve hardware this app no * longer represents, and their signaling is silenced by the transport * gate anyway. */ markOwnershipLost(loss: PeripheralOwnershipLoss = {}): void { if (this.ownershipLostTo !== null) { return; } this.ownershipLostTo = loss; for (const { twinId, gated } of this.revocationGateWrappers.values()) { this.phyHubClient.offTwinMessage(twinId, gated); } this.revocationGateWrappers.clear(); for (const dispose of [...this.twinUpdateDisposers]) { dispose(); } if (this.webrtcManager) { try { this.webrtcManager.close(); } catch (error) { console.error(`Failed to close WebRTC manager for taken-over peripheral ${this.twinId}`, error); } this.webrtcManager = null; } for (const callback of this.ownershipLostCallbacks) { try { callback(loss); } catch (error) { console.error(`Failed to run ownership-lost callback for peripheral ${this.twinId}`, error); } } } /** * Register a twin-update handler (backing onUpdateReported and * onUpdateDesired) with a tracked disposer, so markOwnershipLost can drop * every registration. Throws after an ownership loss — the alternative is * silently re-subscribing the twin room (onTwinUpdate re-subscribes ids * missing from subscribedTwins) that the revocation just left. */ private registerTwinUpdateHandler(peripheralTwinId: string, handler: (twin: TwinResponse) => void): () => void { this.assertOwnershipHeld(); this.phyHubClient.onTwinUpdate(peripheralTwinId, handler); const dispose = () => { this.twinUpdateDisposers.delete(dispose); this.phyHubClient.offTwinUpdate(peripheralTwinId, handler); }; this.twinUpdateDisposers.add(dispose); return dispose; } private getWebRTCManager(): WebRTCManager { this.ensureInitialized(); if (!this.webrtcManager) { // Inject the authenticated-socket ICE provider so peripheral-attached // WebRTC (camera/sensor media + data channels) gets TURN credentials — // mirroring PhyHubClient.getWebRTCManager(). Without this, peripheral // connections silently fall back to static STUN and can't traverse a // symmetric/CGNAT path. 'media' gets the 1h-floor TTL, safe for both // long-lived media and short data channels. this.webrtcManager = new WebRTCManager(this.webrtcTransport!, { iceServersProvider: () => this.phyHubClient.getIceServers('media'), }); } return this.webrtcManager; } /** * Send a message to this peripheral. * - Without callback: Fire-and-forget (returns void) * - With callback: Request-response pattern (returns Promise) * * Note: Peripheral emit is targeted (not broadcast), so callbacks are supported. * Internally uses to(twinId).emit() for request-response pattern. */ emit(eventType: string, payload: any): void; emit(eventType: string, payload: any, callback: (response: TwinMessageResult) => void): Promise; emit( eventType: string, payload: any, callback?: (response: TwinMessageResult) => void, ): void | Promise { this.ensureInitialized(); this.assertOwnershipHeld(); // Advisory event-payload validation (events..payload). Routing- // independent: outcome is observed only, the emit below is unconditional. if (this.advisoryValidator?.events) { validateEmittedFacet(this.advisoryValidator.events[eventType], payload, { descriptorLabel: this.descriptorLabel(), facet: 'event', facetKey: eventType, }); } if (callback) { // Use to().emit() for request-response since peripheral communication is targeted return this.messaging!.to(this.twinId).emit(eventType, payload, callback); } else { this.messaging!.emit(eventType, payload); } } /** * Listen for events, with optional respond callback for request-response. * Returns an unsubscribe function that removes this listener; calling it more * than once is a no-op. */ on(eventType: string, callback: (message: any, respond?: (result: TwinMessageResult) => void) => void): () => void { this.ensureInitialized(); // When advisory validation is enabled, wrap the respond callback so action // returns (actions..returns) are validated at respond() time. // The wrapper only observes — it forwards to the real respond unconditionally, // so request-response routing is identical to a non-validating client. const actionReturnValidator = this.advisoryValidator?.actionReturns?.[eventType]; if (!actionReturnValidator) { return this.messaging!.on(eventType, callback); } const descriptorLabel = this.descriptorLabel(); const validationWrapper = (message: any, respond?: (result: TwinMessageResult) => void) => { const wrappedRespond = respond ? (result: TwinMessageResult) => { validateEmittedFacet(actionReturnValidator, result, { descriptorLabel, facet: 'actionReturns', facetKey: eventType, }); respond(result); } : respond; callback(message, wrappedRespond); }; let wrappersForType = this.validationWrappers.get(eventType); if (!wrappersForType) { wrappersForType = new Map(); this.validationWrappers.set(eventType, wrappersForType); } wrappersForType.set(callback, validationWrapper); const disposeMessagingListener = this.messaging!.on(eventType, validationWrapper); return () => { // Same stale-disposer rule as createTwinMessaging: only clear the registry // entry if it still points at THIS wrapper, so a later on(type, sameCallback) // re-registration is not unregistered by an old disposer. const currentWrappers = this.validationWrappers.get(eventType); if (currentWrappers?.get(callback) === validationWrapper) { currentWrappers.delete(callback); if (currentWrappers.size === 0) { this.validationWrappers.delete(eventType); } } disposeMessagingListener(); }; } /** * Remove a listener previously registered with on(), matched by (eventType, * callback) identity. Returns true if a listener was removed. */ off(eventType: string, callback: (message: any, respond?: (result: TwinMessageResult) => void) => void): boolean { this.ensureInitialized(); // Validating registrations are keyed by their wrapper in messaging — translate // the caller's identity back through the wrapper registry first. const wrappersForType = this.validationWrappers.get(eventType); const validationWrapper = wrappersForType?.get(callback); if (wrappersForType && validationWrapper) { wrappersForType.delete(callback); if (wrappersForType.size === 0) { this.validationWrappers.delete(eventType); } return this.messaging!.off(eventType, validationWrapper); } return this.messaging!.off(eventType, callback); } to(targetTwinId: string) { this.ensureInitialized(); return this.messaging!.to(targetTwinId); } async getDataChannel(channelName?: string) { this.ensureInitialized(); const manager = this.getWebRTCManager(); return manager.createDataChannel(this.peripheralTwinResponse!.id, channelName); } /** * Arm a data-channel responder on this peripheral: many initiators, each as * its own peer connection and data channel. The callback fires ONCE PER * CONNECTED PEER with that peer's own channel and peer id (N=1 is the * single-peer case). * * @returns a stop() function that tears down the responder and all peers. */ async onDataChannel(callback: DataChannelCallback, options?: DataChannelResponderOptions): Promise<() => void> { this.ensureInitialized(); const manager = this.getWebRTCManager(); return manager.acceptDataChannel(this.peripheralTwinResponse!.id, callback, options); } async getMediaStream(options?: MediaStreamOptions) { this.ensureInitialized(); const manager = this.getWebRTCManager(); const channelName = options?.channelName ?? 'default'; const stream = await manager.createMediaStream(this.peripheralTwinResponse!.id, options, channelName); return { stream, close: () => stream.close() }; } /** * Arm a media responder on this peripheral: one local source (e.g. one camera) * fanned out to many simultaneous peers/viewers, each as its own peer * connection. The callback fires ONCE PER CONNECTED PEER with that peer's own * stream and peer id (N=1 is the single-viewer case). * * Provide options.createLocalStream to mint a fresh track per peer. * * @returns a stop() function that tears down the responder and all peers. */ async onMediaStream(callback: MediaStreamCallback, options?: MediaStreamResponderOptions): Promise<() => void> { this.ensureInitialized(); const manager = this.getWebRTCManager(); return manager.acceptMediaStream(this.peripheralTwinResponse!.id, options ?? {}, callback); } /** * Answer a SINGLE WebRTC offer delivered out-of-band (from an HTTP request body, * not the socket transport) against this peripheral's already-armed media * responder, and return the matching non-trickle answer SDP (WHEP). The viewer * connects using the publisher's relay candidates embedded in the answer — no * further signaling. * * Requires a prior onMediaStream() arming this peripheral's media responder for * the same channel; otherwise throws NoMediaResponderError. A responder at its * maxPeers cap throws PeerCapReachedError. * * @param offerSdp - the viewer's offer SDP * @param opts.viewerId - id distinguishing this viewer from other peers * @param opts.channelName - media channel (default: 'default') * @returns the gathering-complete answer SDP */ async answerMediaOffer(offerSdp: string, opts: { viewerId: string; channelName?: string }): Promise { this.ensureInitialized(); const manager = this.getWebRTCManager(); return manager.answerMediaOffer(this.peripheralTwinResponse!.id, offerSdp, opts); } async updateReported(properties: Record) { if (!this.peripheralTwinResponse) { throw new Error('Peripheral instance not initialized'); } this.assertOwnershipHeld(); const newReported = { ...properties, }; // Advisory reported-property validation (reportedProperties schema). Routing- // independent: outcome is observed only, the store below is unconditional. if (this.advisoryValidator?.reported) { validateEmittedFacet(this.advisoryValidator.reported, newReported, { descriptorLabel: this.descriptorLabel(), facet: 'reported', }); } const result = await this.phyHubClient.updateReportedProperties( this.peripheralTwinResponse.id, newReported, // A PeripheralTwinInstance is always a Peripheral; the device-local hub can // return a reused twin with `type` unset, which would otherwise fail with // "unsupported twin type undefined". this.peripheralTwinResponse.type ?? TwinTypeEnum.Peripheral, ); this.peripheralTwinResponse = Object.assign({}, this.peripheralTwinResponse, result); return result; } /** * Listen for changes to this peripheral's reported properties. * Returns an unsubscribe function that removes this listener. */ onUpdateReported(callback: (reportedProperties: Record) => void): () => void { if (!this.peripheralTwinResponse) { throw new Error('Peripheral instance not initialized'); } let previousProperties: Record | undefined = undefined; console.log(`[onUpdateReported] Registering handler for all reported properties`); const peripheralTwinId = this.peripheralTwinResponse.id; const twinUpdateHandler = (twin: TwinResponse) => { console.log(`[onUpdateReported] Twin update received for ${this.peripheralTwinResponse!.id}`); // Only process updates for this specific peripheral if (twin.id !== this.peripheralTwinResponse!.id) { console.log(`[onUpdateReported] Twin ID mismatch: ${twin.id} !== ${this.peripheralTwinResponse!.id}`); return; } const reported = twin.properties.reported as Record; // Check if reported properties exist if (reported) { // If this is the first update, always trigger if (previousProperties === undefined) { console.log(`[onUpdateReported] First update - calling callback`); previousProperties = JSON.parse(JSON.stringify(reported)); // Deep copy callback(reported); return; } const currentPropertiesStr = JSON.stringify(reported); const previousPropertiesStr = JSON.stringify(previousProperties); // Debug by printing parts of the strings - last 100 chars to avoid excessive logs console.log( `[onUpdateReported] Current (end): ...${currentPropertiesStr.substring(currentPropertiesStr.length - 100)}`, ); console.log( `[onUpdateReported] Previous (end): ...${previousPropertiesStr.substring(previousPropertiesStr.length - 100)}`, ); // Additional check - compare string lengths first if (currentPropertiesStr.length !== previousPropertiesStr.length) { console.log( `[onUpdateReported] String length changed: ${currentPropertiesStr.length} vs ${previousPropertiesStr.length}`, ); } const hasChanged = currentPropertiesStr !== previousPropertiesStr; console.log(`[onUpdateReported] Properties comparison:`, hasChanged ? 'CHANGED' : 'UNCHANGED'); if (hasChanged) { previousProperties = JSON.parse(currentPropertiesStr); // Deep copy console.log(`[onUpdateReported] Calling callback with updated reported properties`); callback(reported); } else { // Force a callback call at least every 5 updates to ensure clients get updates // This is a safety mechanism console.log(`[onUpdateReported] No change detected in properties`); } } else { console.log(`[onUpdateReported] No reported properties found`); } }; return this.registerTwinUpdateHandler(peripheralTwinId, twinUpdateHandler); } async updateDesired(properties: Record): Promise { if (!this.peripheralTwinResponse) { throw new Error('Peripheral instance not initialized'); } const payload: EventPayload = { twinId: this.peripheralTwinResponse.id, data: properties, }; const phyClientSocket = this.phyHubClient.getSocket(); return new Promise((resolve, reject) => { this.phyHubClient.emit('updatePeripheralTwinDesired', payload, (response: any) => { const { twin } = response; resolve(twin || {}); }); phyClientSocket?.on('error', (error: any) => { reject(error); }); }); } /** * Listen for changes to this peripheral's desired properties. * Returns an unsubscribe function that removes this listener. */ onUpdateDesired(callback: (desiredProperties: Record) => void): () => void { if (!this.peripheralTwinResponse) { throw new Error('Peripheral instance not initialized'); } let previousProperties: Record | undefined = undefined; console.log(`[onUpdateDesired] Registering handler for all desired properties`); const peripheralTwinId = this.peripheralTwinResponse.id; const twinUpdateHandler = (twin: TwinResponse) => { console.log(`[onUpdateDesired] Twin update received for ${this.peripheralTwinResponse!.id}`); // Only process updates for this specific peripheral if (twin.id !== this.peripheralTwinResponse!.id) { console.log(`[onUpdateDesired] Twin ID mismatch: ${twin.id} !== ${this.peripheralTwinResponse!.id}`); return; } const desired = twin.properties.desired as Record; // Check if desired properties exist if (desired) { // If this is the first update, always trigger if (previousProperties === undefined) { console.log(`[onUpdateDesired] First update - calling callback`); previousProperties = JSON.parse(JSON.stringify(desired)); // Deep copy callback(desired); return; } const currentPropertiesStr = JSON.stringify(desired); const previousPropertiesStr = JSON.stringify(previousProperties); // Debug by printing parts of the strings - last 100 chars to avoid excessive logs console.log( `[onUpdateDesired] Current (end): ...${currentPropertiesStr.substring(currentPropertiesStr.length - 100)}`, ); console.log( `[onUpdateDesired] Previous (end): ...${previousPropertiesStr.substring(previousPropertiesStr.length - 100)}`, ); // Additional check - compare string lengths first if (currentPropertiesStr.length !== previousPropertiesStr.length) { console.log( `[onUpdateDesired] String length changed: ${currentPropertiesStr.length} vs ${previousPropertiesStr.length}`, ); } const hasChanged = currentPropertiesStr !== previousPropertiesStr; console.log(`[onUpdateDesired] Properties comparison:`, hasChanged ? 'CHANGED' : 'UNCHANGED'); if (hasChanged) { previousProperties = JSON.parse(currentPropertiesStr); // Deep copy console.log(`[onUpdateDesired] Calling callback with updated desired properties`); callback(desired); } else { // Force a callback call at least every 5 updates to ensure clients get updates // This is a safety mechanism console.log(`[onUpdateDesired] No change detected in properties`); } } else { console.log(`[onUpdateDesired] No desired properties found`); } }; return this.registerTwinUpdateHandler(peripheralTwinId, twinUpdateHandler); } async remove(): Promise { if (!this.peripheralTwinResponse) { throw new Error('Peripheral instance not initialized'); } const payload: EventPayload = { data: { twinId: this.peripheralTwinResponse.id }, }; const phyClientSocket = this.phyHubClient.getSocket(); return new Promise((resolve, reject) => { this.phyHubClient.emit('deletePeripheralTwin', payload, (response: any) => { const { twin } = response; resolve(twin); }); phyClientSocket?.on('error', (error: any) => { reject(error); }); }); } }