homebridge-smartthings-oauth 1.0.43 → 1.0.44-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,29 @@
1
1
  # Changelog
2
2
  All notable changes to this project will be documented in this file.
3
3
 
4
+ ## [1.0.44] - Samsung Frame TV Local WebSocket Support
5
+
6
+ ### Added
7
+ - **Samsung Frame TV Full Power Off**: Frame TVs now support true power off via a 3.5-second long-press of KEY_POWER sent over local WebSocket. The standard SmartThings `switch.off` command only puts Frame TVs into Art Mode — this bypasses that behavior entirely.
8
+ - **Art Mode Switch**: A separate HomeKit switch to toggle Art Mode on and off for each configured Frame TV. Appears as its own tile in the Home app for easy access and automation.
9
+ - **Local WebSocket Connection Manager** (`src/local/samsungWebSocket.ts`): Handles secure WebSocket connections to Samsung TVs on port 8002 (remote control) and port 8001 (Art Mode channel). Supports lazy connect-on-demand and automatic idle disconnect after 8 seconds.
10
+ - **TV Authorization Token Flow**: First-time connections prompt an "Allow/Deny" popup on the TV. Once accepted, the token is saved automatically and reused for all future connections — no manual pairing code needed.
11
+ - **Frame TV Configuration UI**: New "Samsung Frame TV Settings" section in the Homebridge UI to add/remove Frame TV devices with IP address, full power off toggle, and Art Mode toggle.
12
+ - **Frame TV Auto-Detection**: The plugin automatically detects Frame TVs by checking the `artSupported` field from SmartThings device status. A helpful log message is shown during startup if a Frame TV is found but not yet configured, guiding users to add the TV's local IP address.
13
+ - **`frameTvDevices` config field**: New array in `config.schema.json` for configuring Frame TV devices with `deviceName`, `ip`, `enableFullPowerOff`, `enableArtModeSwitch`, and optional `token` fields.
14
+ - **README section**: Added documentation covering Frame TV auto-detection, configuration options, first-time pairing, troubleshooting, and how to recover from an accidental "Deny" on the TV popup.
15
+
16
+ ### Changed
17
+ - **TelevisionService**: When a Frame TV is configured, power off is intercepted and routed through the local WebSocket instead of the SmartThings API. Power on continues to use the SmartThings API as normal. On WebSocket failure, HomeKit shows "No Response" (no silent fallback to SmartThings).
18
+ - **MultiServiceAccessory**: Detects Frame TV devices by matching the SmartThings device name against `frameTvDevices` config entries (case-insensitive). Creates a shared `SamsungWebSocket` instance used by both the TV service and Art Mode switch.
19
+ - **Platform**: Registers Art Mode accessories as separate platform accessories after device discovery, with UUIDs derived from `deviceId + '-artmode'` to ensure uniqueness.
20
+
21
+ ### Security
22
+ - **Dependency audit**: Fixed all 6 npm audit vulnerabilities (axios, form-data, glob, js-yaml, brace-expansion, diff) — 0 vulnerabilities remaining.
23
+
24
+ ### Dependencies
25
+ - Added `ws` (^8.0.0) and `@types/ws` (^8.0.0) for local WebSocket communication with Samsung TVs.
26
+
4
27
  ## [1.0.43] - Real-Time Subscription Manager UI
5
28
  ### Added
6
29
  - **Capability Subscription Selector**: New UI card in Homebridge settings to manually choose which capabilities get real-time SmartThings subscriptions (max 20). Available for users with webhooks configured.
package/README.md CHANGED
@@ -270,6 +270,83 @@ Once webhooks are working, you can control which capabilities get real-time subs
270
270
 
271
271
  ---
272
272
 
273
+ ## Samsung Frame TV Support (Optional)
274
+
275
+ Samsung Frame TVs behave differently from standard TVs: when the SmartThings API sends a power-off command, the TV enters **Art Mode** instead of truly shutting down. This plugin provides optional local WebSocket control to fix this, enabling true power off and an Art Mode toggle switch in HomeKit.
276
+
277
+ ### Auto-Detection
278
+
279
+ The plugin automatically detects Frame TVs by checking the `artSupported` field reported by SmartThings. If your TV supports Art Mode, you'll see a log message during startup:
280
+
281
+ > `Frame TV detected: "Living Room TV" reports artSupported=true. To enable full power off and Art Mode control, add this device to the "frameTvDevices" config with its local IP address.`
282
+
283
+ The local IP address cannot be auto-detected from SmartThings, so you'll need to provide it manually in the configuration.
284
+
285
+ ### What It Does
286
+
287
+ - **Full Power Off** (optional, enabled by default): Sends a 3.5-second long-press of the power key via local WebSocket, which fully powers down the TV instead of entering Art Mode
288
+ - **Art Mode Switch** (optional, enabled by default): Exposes a separate switch in HomeKit to toggle Art Mode on and off
289
+ - Power **on** continues to work through the SmartThings API as normal
290
+
291
+ ### Configuration Options
292
+
293
+ You can choose how the plugin handles your Frame TV:
294
+
295
+ | Setting | Effect |
296
+ |---------|--------|
297
+ | **Full Power Off = ON** (default) | Turning off the TV in HomeKit sends a full power off via local WebSocket. Art Mode switch controls Art Mode separately. |
298
+ | **Full Power Off = OFF** | Turning off the TV in HomeKit uses the standard SmartThings command (which enters Art Mode on Frame TVs). Useful if you prefer the default Samsung behavior. |
299
+ | **Art Mode Switch = ON** (default) | A separate "Art Mode" switch appears in HomeKit for toggling Art Mode on/off. |
300
+ | **Art Mode Switch = OFF** | No Art Mode switch. The TV behaves like a standard TV in HomeKit. |
301
+
302
+ This means you can mix and match. For example, you could disable Full Power Off but still have the Art Mode switch — giving you the standard Samsung power behavior plus manual Art Mode control.
303
+
304
+ ### Setup
305
+
306
+ 1. Open plugin settings in the Homebridge UI
307
+ 2. Scroll down to the **"Samsung Frame TV Settings"** section
308
+ 3. Click **"Add Frame TV Device"**
309
+ 4. Enter the **device name** (must match exactly how it appears in SmartThings, case-insensitive)
310
+ 5. Enter the **TV's local IP address** (assign a static IP on your router for reliability)
311
+ 6. Toggle **Full Power Off** and **Art Mode Switch** as desired
312
+ 7. Save and restart Homebridge
313
+
314
+ ### First-Time TV Pairing
315
+
316
+ When Homebridge starts with a Frame TV configured, the plugin will attempt to establish a local WebSocket connection to the TV. On the first connection, the TV needs to authorize the plugin:
317
+
318
+ 1. Make sure the TV is **powered on** before starting Homebridge
319
+ 2. After Homebridge starts, a popup will appear on the TV screen asking to allow the connection
320
+ 3. Using your TV remote, select **"Allow"** on the popup
321
+ 4. The plugin will automatically save an authorization token for future connections — the popup will not appear again
322
+
323
+ If the pairing fails (e.g., the TV was off or the popup timed out), the plugin will log an error. Simply restart Homebridge with the TV on to retry the pairing process.
324
+
325
+ ### If You Clicked "Deny" by Mistake
326
+
327
+ If you accidentally denied the connection, the TV remembers this decision and will reject all future connection attempts. To fix this:
328
+
329
+ 1. On your TV, go to **Settings > General > External Device Manager > Device Connection Manager > Device List**
330
+ 2. Find the "Homebridge SmartThings" entry and either change its permission to **Allow** or remove the entry entirely
331
+ 3. Restart Homebridge to initiate a new pairing
332
+
333
+ ### Troubleshooting Frame TV
334
+
335
+ **"Connection timeout" errors in the logs**
336
+ - Verify the TV is powered on and connected to the same network as Homebridge
337
+ - Confirm the IP address is correct (check your router's DHCP client list)
338
+ - Make sure no firewall is blocking port 8001 (Art Mode) or port 8002 (remote control)
339
+
340
+ **"Authorization denied by TV" errors**
341
+ - The saved token may have expired or been invalidated. The plugin will automatically clear the old token. Restart Homebridge with the TV on to get a new authorization popup.
342
+
343
+ **Art Mode switch not appearing**
344
+ - The Art Mode switch is enabled by default. Check that it hasn't been disabled in the Frame TV device settings.
345
+ - Restart Homebridge after changing the configuration
346
+ - The Art Mode switch appears as a separate tile in HomeKit, not inside the TV accessory
347
+
348
+ ---
349
+
273
350
  ## Troubleshooting
274
351
 
275
352
  ### Common Issues
@@ -184,6 +184,44 @@
184
184
  "type": "string"
185
185
  }
186
186
  },
187
+ "frameTvDevices": {
188
+ "title": "Samsung Frame TV Devices (Local WebSocket Control)",
189
+ "type": "array",
190
+ "description": "Configure Samsung Frame TVs for full power off and art mode control via local WebSocket. Only needed for Frame TVs where the SmartThings power-off enters Art Mode instead of truly turning off.",
191
+ "items": {
192
+ "type": "object",
193
+ "properties": {
194
+ "deviceName": {
195
+ "title": "Device Name",
196
+ "type": "string",
197
+ "description": "Exact SmartThings device name to match (case-insensitive)"
198
+ },
199
+ "ip": {
200
+ "title": "TV IP Address",
201
+ "type": "string",
202
+ "description": "Static local IP address of the Samsung Frame TV"
203
+ },
204
+ "enableFullPowerOff": {
205
+ "title": "Enable Full Power Off",
206
+ "type": "boolean",
207
+ "default": true,
208
+ "description": "Use 3.5s long-press KEY_POWER via local WebSocket instead of SmartThings switch.off (which only enters Art Mode on Frame TVs)"
209
+ },
210
+ "enableArtModeSwitch": {
211
+ "title": "Enable Art Mode Switch",
212
+ "type": "boolean",
213
+ "default": true,
214
+ "description": "Expose a separate switch in HomeKit to toggle Art Mode on/off"
215
+ },
216
+ "token": {
217
+ "title": "TV Token (Auto-saved)",
218
+ "type": "string",
219
+ "description": "Authorization token from the TV. Usually auto-saved after first connection — leave empty unless troubleshooting."
220
+ }
221
+ },
222
+ "required": ["deviceName", "ip"]
223
+ }
224
+ },
187
225
  "ShowOnlyDevices": {
188
226
  "title": "Show Only Devices (Whitelist)",
189
227
  "type": "array",
@@ -0,0 +1,51 @@
1
+ import { Logger } from 'homebridge';
2
+ export declare class SamsungWebSocket {
3
+ private readonly ip;
4
+ private readonly log;
5
+ private readonly appName;
6
+ private readonly storagePath;
7
+ private token;
8
+ private remoteWs;
9
+ private artWs;
10
+ private idleTimer;
11
+ private readonly idleTimeoutMs;
12
+ private connecting;
13
+ private artConnecting;
14
+ constructor(ip: string, log: Logger, storagePath: string, token?: string, appName?: string);
15
+ private get encodedAppName();
16
+ private get remoteUrl();
17
+ private get artModeUrl();
18
+ private get tokenFilePath();
19
+ private loadToken;
20
+ private saveToken;
21
+ private handleConnectMessage;
22
+ private connectRemote;
23
+ private connectArtMode;
24
+ private resetIdleTimer;
25
+ private disconnectRemote;
26
+ private disconnectArt;
27
+ private sendKey;
28
+ /**
29
+ * Send a click (short press) of a key
30
+ */
31
+ clickKey(key: string): Promise<void>;
32
+ /**
33
+ * Hold a key for the specified duration (press, wait, release)
34
+ * Used for Frame TV full power off (3.5s hold of KEY_POWER)
35
+ */
36
+ holdKey(key: string, durationMs: number): Promise<void>;
37
+ /**
38
+ * Get current art mode status
39
+ * Returns 'on' or 'off'
40
+ */
41
+ getArtModeStatus(): Promise<string>;
42
+ /**
43
+ * Set art mode status ('on' or 'off')
44
+ */
45
+ setArtModeStatus(status: 'on' | 'off'): Promise<void>;
46
+ /**
47
+ * Clean up all connections
48
+ */
49
+ destroy(): void;
50
+ }
51
+ //# sourceMappingURL=samsungWebSocket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"samsungWebSocket.d.ts","sourceRoot":"","sources":["../../src/local/samsungWebSocket.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAIpC,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAS;IAC5B,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAS;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,QAAQ,CAA0B;IAC1C,OAAO,CAAC,KAAK,CAA0B;IACvC,OAAO,CAAC,SAAS,CAA+B;IAChD,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,aAAa,CAAS;gBAElB,EAAE,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,OAAO,SAA2B;IAa5G,OAAO,KAAK,cAAc,GAEzB;IAED,OAAO,KAAK,SAAS,GAMpB;IAED,OAAO,KAAK,UAAU,GAGrB;IAED,OAAO,KAAK,aAAa,GAIxB;IAED,OAAO,CAAC,SAAS;IAejB,OAAO,CAAC,SAAS;IASjB,OAAO,CAAC,oBAAoB;IAW5B,OAAO,CAAC,aAAa;IAiGrB,OAAO,CAAC,cAAc;IA6FtB,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,gBAAgB;IAQxB,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,OAAO;IAcf;;OAEG;IACG,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAK1C;;;OAGG;IACG,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQ7D;;;OAGG;IACG,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC;IAmDzC;;OAEG;IACG,gBAAgB,CAAC,MAAM,EAAE,IAAI,GAAG,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC;IAwB3D;;OAEG;IACH,OAAO,IAAI,IAAI;CAShB"}
@@ -0,0 +1,433 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.SamsungWebSocket = void 0;
40
+ const ws_1 = __importDefault(require("ws"));
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ class SamsungWebSocket {
44
+ constructor(ip, log, storagePath, token, appName = 'Homebridge SmartThings') {
45
+ this.remoteWs = null;
46
+ this.artWs = null;
47
+ this.idleTimer = null;
48
+ this.idleTimeoutMs = 8000;
49
+ this.connecting = false;
50
+ this.artConnecting = false;
51
+ this.ip = ip;
52
+ this.log = log;
53
+ this.appName = appName;
54
+ this.storagePath = storagePath;
55
+ this.token = token || null;
56
+ // Try to load a previously saved token if none was provided
57
+ if (!this.token) {
58
+ this.token = this.loadToken();
59
+ }
60
+ }
61
+ get encodedAppName() {
62
+ return Buffer.from(this.appName).toString('base64');
63
+ }
64
+ get remoteUrl() {
65
+ let url = `wss://${this.ip}:8002/api/v2/channels/samsung.remote.control?name=${this.encodedAppName}`;
66
+ if (this.token) {
67
+ url += `&token=${this.token}`;
68
+ }
69
+ return url;
70
+ }
71
+ get artModeUrl() {
72
+ // Art mode channel uses unencrypted WS on port 8001 (no token needed)
73
+ return `ws://${this.ip}:8001/api/v2/channels/com.samsung.art-app?name=${this.encodedAppName}`;
74
+ }
75
+ get tokenFilePath() {
76
+ // Sanitize IP for filename
77
+ const safeIp = this.ip.replace(/[^a-zA-Z0-9.-]/g, '_');
78
+ return path.join(this.storagePath, `samsung_tv_token_${safeIp}.json`);
79
+ }
80
+ loadToken() {
81
+ try {
82
+ if (fs.existsSync(this.tokenFilePath)) {
83
+ const data = JSON.parse(fs.readFileSync(this.tokenFilePath, 'utf-8'));
84
+ if (data.token) {
85
+ this.log.debug(`Samsung WebSocket: Loaded saved token for ${this.ip}`);
86
+ return data.token;
87
+ }
88
+ }
89
+ }
90
+ catch (err) {
91
+ this.log.debug(`Samsung WebSocket: Could not load saved token for ${this.ip}: ${err}`);
92
+ }
93
+ return null;
94
+ }
95
+ saveToken(token) {
96
+ try {
97
+ fs.writeFileSync(this.tokenFilePath, JSON.stringify({ token, ip: this.ip, savedAt: new Date().toISOString() }));
98
+ this.log.info(`Samsung WebSocket: Token saved for ${this.ip} — future connections will skip TV authorization popup`);
99
+ }
100
+ catch (err) {
101
+ this.log.warn(`Samsung WebSocket: Could not save token for ${this.ip}: ${err}`);
102
+ }
103
+ }
104
+ handleConnectMessage(msg) {
105
+ var _a;
106
+ // Samsung TVs return a token in the ms.channel.connect event data
107
+ // This token must be saved and used for future connections to skip the Allow/Deny popup
108
+ const tokenFromTv = (_a = msg.data) === null || _a === void 0 ? void 0 : _a.token;
109
+ if (tokenFromTv && tokenFromTv !== this.token) {
110
+ this.log.info(`Samsung WebSocket: Received authorization token from TV at ${this.ip}`);
111
+ this.token = tokenFromTv;
112
+ this.saveToken(tokenFromTv);
113
+ }
114
+ }
115
+ connectRemote() {
116
+ return new Promise((resolve, reject) => {
117
+ if (this.remoteWs && this.remoteWs.readyState === ws_1.default.OPEN) {
118
+ this.resetIdleTimer();
119
+ resolve(this.remoteWs);
120
+ return;
121
+ }
122
+ if (this.connecting) {
123
+ const waitInterval = setInterval(() => {
124
+ if (!this.connecting) {
125
+ clearInterval(waitInterval);
126
+ if (this.remoteWs && this.remoteWs.readyState === ws_1.default.OPEN) {
127
+ resolve(this.remoteWs);
128
+ }
129
+ else {
130
+ reject(new Error('Remote WebSocket connection failed while waiting'));
131
+ }
132
+ }
133
+ }, 100);
134
+ return;
135
+ }
136
+ this.connecting = true;
137
+ const hasToken = !!this.token;
138
+ this.log.debug(`Samsung WebSocket: Connecting to remote control at ${this.ip}:8002 (token=${hasToken ? 'yes' : 'NO — TV will show Allow/Deny popup'})`);
139
+ if (!hasToken) {
140
+ this.log.warn(`Samsung WebSocket: No saved token for ${this.ip}. ` +
141
+ 'The TV will display an "Allow" popup. Please accept it on the TV screen. ' +
142
+ 'The token will be saved automatically for future connections.');
143
+ }
144
+ const ws = new ws_1.default(this.remoteUrl, { rejectUnauthorized: false });
145
+ // Give extra time for first-time authorization (user needs to press Allow on TV)
146
+ const timeoutMs = hasToken ? 5000 : 30000;
147
+ const connectTimeout = setTimeout(() => {
148
+ this.connecting = false;
149
+ ws.terminate();
150
+ const msg = hasToken
151
+ ? `Samsung WebSocket: Connection timeout to ${this.ip} (TV may be off)`
152
+ : `Samsung WebSocket: Connection timeout to ${this.ip} — did you accept the "Allow" popup on the TV?`;
153
+ reject(new Error(msg));
154
+ }, timeoutMs);
155
+ ws.on('open', () => {
156
+ this.log.debug(`Samsung WebSocket: TCP connected to ${this.ip}:8002, waiting for channel connect...`);
157
+ });
158
+ ws.on('message', (data) => {
159
+ try {
160
+ const msg = JSON.parse(data.toString());
161
+ if (msg.event === 'ms.channel.connect') {
162
+ clearTimeout(connectTimeout);
163
+ this.handleConnectMessage(msg);
164
+ this.log.debug('Samsung WebSocket: Remote control channel connected');
165
+ this.remoteWs = ws;
166
+ this.connecting = false;
167
+ this.resetIdleTimer();
168
+ resolve(ws);
169
+ }
170
+ else if (msg.event === 'ms.channel.unauthorized') {
171
+ clearTimeout(connectTimeout);
172
+ this.connecting = false;
173
+ ws.terminate();
174
+ // Token is invalid/expired — clear it so next attempt shows popup
175
+ this.token = null;
176
+ try {
177
+ fs.unlinkSync(this.tokenFilePath);
178
+ }
179
+ catch ( /* ignore */_a) { /* ignore */ }
180
+ reject(new Error(`Samsung WebSocket: Authorization denied by TV at ${this.ip}. ` +
181
+ 'Saved token was invalid. Restart Homebridge to retry — the TV will show a new Allow/Deny popup.'));
182
+ }
183
+ }
184
+ catch (_b) {
185
+ // Ignore non-JSON messages
186
+ }
187
+ });
188
+ ws.on('error', (err) => {
189
+ clearTimeout(connectTimeout);
190
+ this.connecting = false;
191
+ this.log.error(`Samsung WebSocket: Remote connection error: ${err.message}`);
192
+ reject(err);
193
+ });
194
+ ws.on('close', () => {
195
+ clearTimeout(connectTimeout);
196
+ this.connecting = false;
197
+ if (this.remoteWs === ws) {
198
+ this.remoteWs = null;
199
+ }
200
+ this.log.debug('Samsung WebSocket: Remote control connection closed');
201
+ });
202
+ });
203
+ }
204
+ connectArtMode() {
205
+ return new Promise((resolve, reject) => {
206
+ if (this.artWs && this.artWs.readyState === ws_1.default.OPEN) {
207
+ resolve(this.artWs);
208
+ return;
209
+ }
210
+ if (this.artConnecting) {
211
+ const waitInterval = setInterval(() => {
212
+ if (!this.artConnecting) {
213
+ clearInterval(waitInterval);
214
+ if (this.artWs && this.artWs.readyState === ws_1.default.OPEN) {
215
+ resolve(this.artWs);
216
+ }
217
+ else {
218
+ reject(new Error('Art mode WebSocket connection failed while waiting'));
219
+ }
220
+ }
221
+ }, 100);
222
+ return;
223
+ }
224
+ this.artConnecting = true;
225
+ this.log.debug(`Samsung WebSocket: Connecting to art mode channel at ${this.ip}:8001`);
226
+ const ws = new ws_1.default(this.artModeUrl);
227
+ const connectTimeout = setTimeout(() => {
228
+ this.artConnecting = false;
229
+ ws.terminate();
230
+ reject(new Error(`Samsung WebSocket: Art mode connection timeout to ${this.ip}`));
231
+ }, 5000);
232
+ ws.on('open', () => {
233
+ this.log.debug(`Samsung WebSocket: TCP connected to art mode channel at ${this.ip}:8001`);
234
+ });
235
+ ws.on('message', (data) => {
236
+ try {
237
+ const msg = JSON.parse(data.toString());
238
+ if (msg.event === 'ms.channel.connect') {
239
+ // Art channel on port 8001 may also return a token
240
+ this.handleConnectMessage(msg);
241
+ this.log.debug('Samsung WebSocket: Art mode channel connected');
242
+ }
243
+ else if (msg.event === 'ms.channel.ready') {
244
+ // Reference: samsung-tizen plugin resolves on ms.channel.ready
245
+ clearTimeout(connectTimeout);
246
+ if (!this.artWs) {
247
+ this.artWs = ws;
248
+ this.artConnecting = false;
249
+ resolve(ws);
250
+ }
251
+ }
252
+ else if (msg.event === 'd2d_service_message') {
253
+ clearTimeout(connectTimeout);
254
+ if (!this.artWs) {
255
+ this.artWs = ws;
256
+ this.artConnecting = false;
257
+ resolve(ws);
258
+ }
259
+ }
260
+ }
261
+ catch (_a) {
262
+ // Ignore non-JSON messages
263
+ }
264
+ });
265
+ // Fallback: some TVs don't send a connect message on the art channel
266
+ const fallbackTimeout = setTimeout(() => {
267
+ if (!this.artWs && ws.readyState === ws_1.default.OPEN) {
268
+ this.artWs = ws;
269
+ this.artConnecting = false;
270
+ resolve(ws);
271
+ }
272
+ }, 2000);
273
+ ws.on('error', (err) => {
274
+ clearTimeout(connectTimeout);
275
+ clearTimeout(fallbackTimeout);
276
+ this.artConnecting = false;
277
+ this.log.error(`Samsung WebSocket: Art mode connection error: ${err.message}`);
278
+ reject(err);
279
+ });
280
+ ws.on('close', () => {
281
+ clearTimeout(connectTimeout);
282
+ clearTimeout(fallbackTimeout);
283
+ this.artConnecting = false;
284
+ if (this.artWs === ws) {
285
+ this.artWs = null;
286
+ }
287
+ this.log.debug('Samsung WebSocket: Art mode connection closed');
288
+ });
289
+ });
290
+ }
291
+ resetIdleTimer() {
292
+ if (this.idleTimer) {
293
+ clearTimeout(this.idleTimer);
294
+ }
295
+ this.idleTimer = setTimeout(() => {
296
+ this.disconnectRemote();
297
+ }, this.idleTimeoutMs);
298
+ }
299
+ disconnectRemote() {
300
+ if (this.remoteWs) {
301
+ this.log.debug('Samsung WebSocket: Disconnecting remote (idle timeout)');
302
+ this.remoteWs.close();
303
+ this.remoteWs = null;
304
+ }
305
+ }
306
+ disconnectArt() {
307
+ if (this.artWs) {
308
+ this.log.debug('Samsung WebSocket: Disconnecting art mode');
309
+ this.artWs.close();
310
+ this.artWs = null;
311
+ }
312
+ }
313
+ sendKey(ws, cmd, key) {
314
+ const payload = JSON.stringify({
315
+ method: 'ms.remote.control',
316
+ params: {
317
+ Cmd: cmd,
318
+ DataOfCmd: key,
319
+ Option: false,
320
+ TypeOfRemote: 'SendRemoteKey',
321
+ },
322
+ });
323
+ this.log.debug(`Samsung WebSocket: Sending key ${cmd} ${key}`);
324
+ ws.send(payload);
325
+ }
326
+ /**
327
+ * Send a click (short press) of a key
328
+ */
329
+ async clickKey(key) {
330
+ const ws = await this.connectRemote();
331
+ this.sendKey(ws, 'Click', key);
332
+ }
333
+ /**
334
+ * Hold a key for the specified duration (press, wait, release)
335
+ * Used for Frame TV full power off (3.5s hold of KEY_POWER)
336
+ */
337
+ async holdKey(key, durationMs) {
338
+ const ws = await this.connectRemote();
339
+ this.sendKey(ws, 'Press', key);
340
+ await new Promise(resolve => setTimeout(resolve, durationMs));
341
+ this.sendKey(ws, 'Release', key);
342
+ this.log.debug(`Samsung WebSocket: Held ${key} for ${durationMs}ms`);
343
+ }
344
+ /**
345
+ * Get current art mode status
346
+ * Returns 'on' or 'off'
347
+ */
348
+ async getArtModeStatus() {
349
+ return new Promise(async (resolve, reject) => {
350
+ try {
351
+ const ws = await this.connectArtMode();
352
+ const timeout = setTimeout(() => {
353
+ this.disconnectArt();
354
+ // Default to 'off' if we can't determine status
355
+ resolve('off');
356
+ }, 3000);
357
+ const messageHandler = (data) => {
358
+ try {
359
+ const msg = JSON.parse(data.toString());
360
+ if (msg.event === 'd2d_service_message' && msg.data) {
361
+ const eventData = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
362
+ if (eventData.event === 'art_mode_changed' || eventData.event === 'artmode_status') {
363
+ clearTimeout(timeout);
364
+ ws.removeListener('message', messageHandler);
365
+ const status = eventData.value === 'on' || eventData.status === 'on' ? 'on' : 'off';
366
+ this.log.debug(`Samsung WebSocket: Art mode status: ${status}`);
367
+ this.disconnectArt();
368
+ resolve(status);
369
+ }
370
+ }
371
+ }
372
+ catch (_a) {
373
+ // Ignore parse errors
374
+ }
375
+ };
376
+ ws.on('message', messageHandler);
377
+ // Request art mode status
378
+ const request = JSON.stringify({
379
+ method: 'ms.channel.emit',
380
+ params: {
381
+ event: 'art_app_request',
382
+ to: 'host',
383
+ data: JSON.stringify({
384
+ request: 'get_artmode_status',
385
+ id: String(Date.now()),
386
+ }),
387
+ },
388
+ });
389
+ ws.send(request);
390
+ }
391
+ catch (err) {
392
+ reject(err);
393
+ }
394
+ });
395
+ }
396
+ /**
397
+ * Set art mode status ('on' or 'off')
398
+ */
399
+ async setArtModeStatus(status) {
400
+ const ws = await this.connectArtMode();
401
+ const request = JSON.stringify({
402
+ method: 'ms.channel.emit',
403
+ params: {
404
+ event: 'art_app_request',
405
+ to: 'host',
406
+ data: JSON.stringify({
407
+ request: 'set_artmode_status',
408
+ value: status,
409
+ id: String(Date.now()),
410
+ }),
411
+ },
412
+ });
413
+ this.log.debug(`Samsung WebSocket: Setting art mode to ${status}`);
414
+ ws.send(request);
415
+ // Give the TV a moment to process, then disconnect
416
+ await new Promise(resolve => setTimeout(resolve, 500));
417
+ this.disconnectArt();
418
+ }
419
+ /**
420
+ * Clean up all connections
421
+ */
422
+ destroy() {
423
+ if (this.idleTimer) {
424
+ clearTimeout(this.idleTimer);
425
+ this.idleTimer = null;
426
+ }
427
+ this.disconnectRemote();
428
+ this.disconnectArt();
429
+ this.log.debug('Samsung WebSocket: All connections closed');
430
+ }
431
+ }
432
+ exports.SamsungWebSocket = SamsungWebSocket;
433
+ //# sourceMappingURL=samsungWebSocket.js.map