homebridge-philips-dline-sicp 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Thomas Dazy
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,162 @@
1
+ # homebridge-philips-dline-sicp
2
+
3
+ Homebridge plugin to control **Philips D‑Line** signage displays (e.g., **55BDL4511D/00**) over LAN using the **SICP** protocol on **TCP:5000**.
4
+ Exposes the device as a **HomeKit Television** (power + input selection), with optional per‑input **Switches**.
5
+
6
+ > ⚠️ This plugin is vendor‑unofficial. Keep your display on a trusted VLAN. No auth is implemented by the display on port 5000.
7
+
8
+ ## Features
9
+ - Power On/Off (SICP `0x18`).
10
+ - Input selection via configurable SICP codes (default HDMI1..4).
11
+ - Optional **per‑input switches** for quick Siri/Home automations.
12
+ - Multiple displays supported (platform plugin).
13
+
14
+ ## Requirements
15
+ - Node.js >= 18.x
16
+ - Homebridge >= 1.6.0
17
+ - Philips D-Line display with **Network Control over RJ45** enabled (OSD menu), and reachable on the LAN.
18
+ - The display should listen on **TCP port 5000** (default for SICP over IP).
19
+
20
+ ## Installation
21
+ 1. Copy this folder to your Homebridge environment, then:
22
+ ```bash
23
+ cd homebridge-philips-dline-sicp
24
+ npm install
25
+
26
+ # For local development / manual install:
27
+ sudo npm i -g .
28
+ ```
29
+ *or install from npm once published:*
30
+ ```bash
31
+ sudo npm i -g homebridge-philips-dline-sicp
32
+ ```
33
+ 2. In Homebridge UI (Config), add the platform and displays as below.
34
+
35
+ ## Configuration
36
+ Add to your Homebridge `config.json`:
37
+
38
+ ```json
39
+ {
40
+ "platforms": [
41
+ {
42
+ "platform": "PhilipsDLinePlatform",
43
+ "displays": [
44
+ {
45
+ "name": "Salon TV",
46
+ "host": "192.168.1.120",
47
+ "port": 5000,
48
+ "monitorId": 1,
49
+ "includeGroup": true,
50
+ "groupId": 0,
51
+ "pollInterval": 10,
52
+ "exposeInputSwitches": true,
53
+ "inputs": [
54
+ { "label": "HDMI 1", "code": "0x0D", "identifier": 1 },
55
+ { "label": "HDMI 2", "code": "0x06", "identifier": 2 },
56
+ { "label": "HDMI 3", "code": "0x0F", "identifier": 3 },
57
+ { "label": "HDMI 4", "code": "0x19", "identifier": 4 }
58
+ ],
59
+ "exposeBrightness": true
60
+ }
61
+ }
62
+ ]
63
+ }
64
+ ]
65
+ }
66
+ ```
67
+
68
+ ### Notes
69
+ - `monitorId`: use the Monitor ID set in the OSD (often 1). `0` means broadcast (typically no reply).
70
+ - `includeGroup`: some firmwares expect a *Group* byte; leave `true` unless you see no ACK, then try `false`.
71
+ - `inputs`: SICP input codes vary by model/firmware. The defaults work on many D-Line firmwares; adjust if needed.
72
+ - `pollInterval`: seconds between lightweight status refresh attempts. Set `0` to disable polling.
73
+ - `exposeInputSwitches`: creates a `Switch` per input (mutually exclusive) for simple automations.
74
+ - `exposeBrightness`: (Default `true`). Exposes a Lightbulb service for brightness control. **Warning**: If enabled, HomeKit may group this with other lights ("Turn on all lights" -> Turns on TV). Set to `false` if you experience this issue.
75
+
76
+ ## Usage
77
+ - Power: Use the Television tile → On/Off.
78
+ - Input: Change the **input** from the TV tile, or toggle the per‑input switches (if enabled).
79
+ - Siri: “Switch Salon TV to HDMI 2”, “Turn on Salon TV”.
80
+
81
+ ## Troubleshooting
82
+ - **No response / timeouts**: check that the display answers on `tcp/5000` (`telnet IP 5000`), and that “Network control / RJ45” is enabled.
83
+ - **Input won’t change**: some displays require a short delay after power on; the plugin already waits ~300ms but you can increase it in code if needed.
84
+ - **Wrong input codes**: run with debugging, try other codes for `0xAC` (input set). If you have the SICP table for your firmware, copy the exact codes into `inputs`.
85
+ - **Security**: do not expose the port to the Internet. Restrict to your LAN/VLAN.
86
+
87
+ ## License
88
+ MIT
89
+
90
+ ## Volume & Brightness
91
+
92
+ You can control **volume** (HomeKit TelevisionSpeaker) and **brightness** (as a Lightbulb service named *Backlight*).
93
+ Because SICP codes may vary by firmware, you can choose between **absolute** and **relative** modes:
94
+
95
+ ### Volume config
96
+ ```json
97
+ "volume": {
98
+ "min": 0,
99
+ "max": 100,
100
+ "initial": 15,
101
+ "setCode": "0x44", // OPTIONAL: absolute set: [0x44, value 0..100]
102
+ "upCode": "0x45", // OPTIONAL: relative up: [0x45]
103
+ "downCode": "0x46", // OPTIONAL: relative down: [0x46]
104
+ "muteSetCode": "0x47", // OPTIONAL: absolute mute: [0x47, 0|1]
105
+ "muteToggleCode": "0x48", // OPTIONAL: toggle mute: [0x48]
106
+ "stepDelayMs": 120 // delay between relative steps
107
+ }
108
+ ```
109
+ > Provide either `setCode` **or** (`upCode` and `downCode`). `muteSetCode` or `muteToggleCode` are optional.
110
+
111
+ ### Brightness config
112
+ ```json
113
+ "brightness": {
114
+ "min": 0,
115
+ "max": 100,
116
+ "initial": 50,
117
+ "setCode": "0x10", // OPTIONAL: absolute set: [0x10, value 0..100]
118
+ "upCode": "0x11", // OPTIONAL: relative up: [0x11]
119
+ "downCode": "0x12", // OPTIONAL: relative down: [0x12]
120
+ "stepDelayMs": 120
121
+ }
122
+ ```
123
+ > If your firmware supports DDC-like absolute brightness, use `setCode` (often `0x32` or `0x10`). Otherwise use relative up/down.
124
+ > **Note**: If you do not configure brightness codes, the plugin will attempt a default SICP command (`0x32`) which works on many D-Line models.
125
+
126
+ ### Example full display config
127
+ ```json
128
+ {
129
+ "name": "Salon TV",
130
+ "host": "192.168.1.120",
131
+ "port": 5000,
132
+ "monitorId": 1,
133
+ "includeGroup": true,
134
+ "groupId": 0,
135
+ "pollInterval": 10,
136
+ "exposeInputSwitches": true,
137
+ "inputs": [
138
+ { "label": "HDMI 1", "code": "0x0D", "identifier": 1 },
139
+ { "label": "HDMI 2", "code": "0x06", "identifier": 2 },
140
+ { "label": "HDMI 3", "code": "0x0F", "identifier": 3 },
141
+ { "label": "HDMI 4", "code": "0x19", "identifier": 4 }
142
+ ],
143
+ "volume": {
144
+ "min": 0,
145
+ "max": 60,
146
+ "initial": 10,
147
+ "setCode": "0x44",
148
+ "muteSetCode": "0x47"
149
+ },
150
+ "brightness": {
151
+ "min": 0,
152
+ "max": 100,
153
+ "initial": 50,
154
+ "setCode": "0x10"
155
+ }
156
+ }
157
+ ```
158
+
159
+
160
+ ## If it still shows as a switch
161
+ - The plugin now explicitly sets the Accessory Category to `TELEVISION`.
162
+ - If the icon remains a box/switch, try restarting Homebridge or removing/re-adding the accessory (or clearing `cachedAccessories`).
@@ -0,0 +1,116 @@
1
+ {
2
+ "pluginAlias": "PhilipsDLinePlatform",
3
+ "pluginType": "platform",
4
+ "schema": {
5
+ "type": "object",
6
+ "properties": {
7
+ "displays": {
8
+ "type": "array",
9
+ "items": {
10
+ "type": "object",
11
+ "properties": {
12
+ "name": {
13
+ "type": "string",
14
+ "title": "Display name"
15
+ },
16
+ "host": {
17
+ "type": "string",
18
+ "title": "IP / Hostname"
19
+ },
20
+ "port": {
21
+ "type": "number",
22
+ "title": "TCP port",
23
+ "default": 5000
24
+ },
25
+ "monitorId": {
26
+ "type": "number",
27
+ "title": "Monitor ID",
28
+ "default": 1
29
+ },
30
+ "includeGroup": {
31
+ "type": "boolean",
32
+ "title": "Include Group byte",
33
+ "default": true
34
+ },
35
+ "groupId": {
36
+ "type": "number",
37
+ "title": "Group ID",
38
+ "default": 0
39
+ },
40
+ "pollInterval": {
41
+ "type": "number",
42
+ "title": "Poll interval (s)",
43
+ "default": 10
44
+ },
45
+ "exposeInputSwitches": {
46
+ "type": "boolean",
47
+ "title": "Expose per-input switches",
48
+ "default": false
49
+ },
50
+ "exposeBrightness": {
51
+ "type": "boolean",
52
+ "title": "Expose Brightness (as Lightbulb)",
53
+ "description": "WARNING: Exposing brightness creates a Lightbulb service. HomeKit may group this with other lights, causing the TV to turn on when you say 'Turn on all lights'. Disable this if you experience unwanted behavior. The setting provides a slider for backlight control.",
54
+ "default": true
55
+ },
56
+ "inputs": {
57
+ "type": "array",
58
+ "title": "Inputs",
59
+ "items": {
60
+ "type": "object",
61
+ "properties": {
62
+ "label": {
63
+ "type": "string",
64
+ "title": "Label"
65
+ },
66
+ "code": {
67
+ "type": "string",
68
+ "title": "SICP code (e.g., 0x0D)"
69
+ },
70
+ "identifier": {
71
+ "type": "number",
72
+ "title": "HomeKit Identifier (1..)"
73
+ }
74
+ },
75
+ "required": [
76
+ "label",
77
+ "code",
78
+ "identifier"
79
+ ]
80
+ },
81
+ "default": [
82
+ {
83
+ "label": "HDMI 1",
84
+ "code": "0x0D",
85
+ "identifier": 1
86
+ },
87
+ {
88
+ "label": "HDMI 2",
89
+ "code": "0x06",
90
+ "identifier": 2
91
+ },
92
+ {
93
+ "label": "HDMI 3",
94
+ "code": "0x0F",
95
+ "identifier": 3
96
+ },
97
+ {
98
+ "label": "HDMI 4",
99
+ "code": "0x19",
100
+ "identifier": 4
101
+ }
102
+ ]
103
+ }
104
+ },
105
+ "required": [
106
+ "name",
107
+ "host"
108
+ ]
109
+ }
110
+ }
111
+ },
112
+ "required": [
113
+ "displays"
114
+ ]
115
+ }
116
+ }
package/index.js ADDED
@@ -0,0 +1,576 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Homebridge plugin to control Philips D-Line signage displays (e.g., 55BDL4511D/00)
5
+ * over LAN using SICP over TCP (default port 5000).
6
+ *
7
+ * Exposes a HomeKit Television with inputs, volume (TelevisionSpeaker), and brightness (as a Lightbulb service).
8
+ */
9
+
10
+ const net = require('net');
11
+ let hap;
12
+
13
+ const PLUGIN_NAME = 'homebridge-philips-dline-sicp';
14
+ const PLATFORM_NAME = 'PhilipsDLinePlatform';
15
+
16
+ /** Simple promise-based sleep */
17
+ function delay(ms) { return new Promise(res => setTimeout(res, ms)); }
18
+
19
+ /** Clamp helper */
20
+ function clamp(n, min, max) { return Math.max(min, Math.min(max, n)); }
21
+
22
+ /** A light-weight send queue so we don't overlap TCP writes */
23
+ class SendQueue {
24
+ constructor(sender) {
25
+ this.sender = sender; // async (buf) => Buffer
26
+ this.queue = Promise.resolve();
27
+ }
28
+ send(buf) {
29
+ const job = async () => {
30
+ try {
31
+ return await this.sender(buf);
32
+ } catch (e) {
33
+ throw e;
34
+ }
35
+ };
36
+ const p = this.queue.then(job, job);
37
+ this.queue = p.catch(() => { });
38
+ return p;
39
+ }
40
+ }
41
+
42
+ /** Build a SICP packet */
43
+ function buildSicpPacket(monitorId, dataBytes, includeGroup = true, groupId = 0x00) {
44
+ const body = includeGroup ? [monitorId & 0xFF, groupId & 0xFF, ...dataBytes] : [monitorId & 0xFF, ...dataBytes];
45
+ const msgSize = 1 + body.length + 1; // size + body + checksum
46
+ const arr = [msgSize & 0xFF, ...body];
47
+ let checksum = 0x00;
48
+ for (const b of arr) checksum ^= (b & 0xFF);
49
+ arr.push(checksum & 0xFF);
50
+ return Buffer.from(arr);
51
+ }
52
+
53
+ /** Parse a basic ACK/NACK/NAV (best-effort) */
54
+ function parseReply(buf) {
55
+ const bytes = [...buf];
56
+ return {
57
+ raw: bytes.map(b => '0x' + b.toString(16).padStart(2, '0')).join(' '),
58
+ ok: bytes.includes(0x06),
59
+ nack: bytes.includes(0x15),
60
+ nav: bytes.includes(0x18),
61
+ };
62
+ }
63
+
64
+ /** TCP client for SICP */
65
+ class SicpClient {
66
+ constructor(host, port = 5000, timeoutMs = 1200) {
67
+ this.host = host;
68
+ this.port = port;
69
+ this.timeoutMs = timeoutMs;
70
+ this.queue = new SendQueue(this._sendOnce.bind(this));
71
+ }
72
+
73
+ async send(pkt) {
74
+ return this.queue.send(pkt);
75
+ }
76
+
77
+ _sendOnce(pkt) {
78
+ return new Promise((resolve, reject) => {
79
+ const socket = new net.Socket();
80
+ let chunks = [];
81
+ let done = false;
82
+
83
+ const finish = (err, data) => {
84
+ if (done) return;
85
+ done = true;
86
+ try { socket.destroy(); } catch { }
87
+ if (err) reject(err);
88
+ else resolve(Buffer.concat(data || []));
89
+ };
90
+
91
+ socket.setTimeout(this.timeoutMs, () => finish(new Error('SICP: timeout'), chunks));
92
+ socket.once('error', (e) => finish(e, chunks));
93
+ socket.connect(this.port, this.host, () => {
94
+ socket.write(pkt);
95
+ });
96
+ socket.on('data', (d) => chunks.push(Buffer.from(d)));
97
+ socket.once('close', () => finish(null, chunks));
98
+ // Some firmwares keep the socket open; close after a short delay
99
+ setTimeout(() => { try { socket.end(); } catch { } }, 200);
100
+ });
101
+ }
102
+ }
103
+
104
+ /** Accessory representing one D-Line TV */
105
+ class PhilipsDLineTelevisionAccessory {
106
+ constructor(platform, accessory, conf) {
107
+ this.platform = platform;
108
+ this.accessory = accessory;
109
+ this.log = platform.log;
110
+ this.name = conf.name || 'Philips D-Line';
111
+ this.host = conf.host;
112
+ this.port = conf.port || 5000;
113
+ this.monitorId = (conf.monitorId === 0 || conf.monitorId) ? conf.monitorId : 1;
114
+ this.includeGroup = conf.includeGroup !== false; // default true
115
+ this.groupId = conf.groupId || 0x00;
116
+ this.pollInterval = conf.pollInterval || 10; // seconds
117
+ this.exposeBrightness = conf.exposeBrightness !== false; // default true
118
+
119
+ // Inputs config
120
+ this.inputs = Array.isArray(conf.inputs) && conf.inputs.length ? conf.inputs : [
121
+ { label: 'HDMI 1', code: '0x0D', identifier: 1 },
122
+ { label: 'HDMI 2', code: '0x06', identifier: 2 },
123
+ { label: 'HDMI 3', code: '0x0F', identifier: 3 },
124
+ { label: 'HDMI 4', code: '0x19', identifier: 4 },
125
+ ];
126
+ this.exposeInputSwitches = !!conf.exposeInputSwitches;
127
+
128
+ // Volume config (two modes: absolute set, or relative up/down repeat)
129
+ this.volume = {
130
+ min: conf.volume?.min ?? 0,
131
+ max: conf.volume?.max ?? 100,
132
+ // Absolute set: send [volSetCode, value] if provided
133
+ setCode: conf.volume?.setCode, // e.g. "0x44"
134
+ // Relative: send [volUpCode] or [volDownCode] N times
135
+ upCode: conf.volume?.upCode,
136
+ downCode: conf.volume?.downCode,
137
+ // Mute support
138
+ muteSetCode: conf.volume?.muteSetCode, // absolute mute set [code, 0/1]
139
+ muteToggleCode: conf.volume?.muteToggleCode, // single code to toggle
140
+ // UI state
141
+ current: clamp(conf.volume?.initial ?? 15, conf.volume?.min ?? 0, conf.volume?.max ?? 100),
142
+ muted: false,
143
+ stepMs: conf.volume?.stepDelayMs ?? 120, // delay between relative steps
144
+ };
145
+
146
+ // Brightness config (absolute preferred; fallback relative)
147
+ this.brightness = {
148
+ min: conf.brightness?.min ?? 0,
149
+ max: conf.brightness?.max ?? 100,
150
+ setCode: conf.brightness?.setCode, // e.g. "0x10"
151
+ upCode: conf.brightness?.upCode,
152
+ downCode: conf.brightness?.downCode,
153
+ current: clamp(conf.brightness?.initial ?? 50, conf.brightness?.min ?? 0, conf.brightness?.max ?? 100),
154
+ stepMs: conf.brightness?.stepDelayMs ?? 120,
155
+ };
156
+
157
+ // State
158
+ this.active = 0; // 0=INACTIVE, 1=ACTIVE
159
+ this.activeIdentifier = this.inputs[0]?.identifier ?? 1;
160
+
161
+ this.client = new SicpClient(this.host, this.port);
162
+ this._setupServices();
163
+ this._startPolling();
164
+ }
165
+
166
+ _setupServices() {
167
+ const Service = hap.Service;
168
+ const Characteristic = hap.Characteristic;
169
+
170
+ this.televisionService = this.accessory.getService(Service.Television)
171
+ || this.accessory.addService(Service.Television, this.name);
172
+
173
+ this.televisionService
174
+ .setCharacteristic(Characteristic.ConfiguredName, this.name)
175
+ .setCharacteristic(Characteristic.SleepDiscoveryMode, Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE);
176
+
177
+ // Power
178
+ this.televisionService.getCharacteristic(Characteristic.Active)
179
+ .onGet(this.handleGetActive.bind(this))
180
+ .onSet(this.handleSetActive.bind(this));
181
+
182
+ // Active input
183
+ this.televisionService.getCharacteristic(Characteristic.ActiveIdentifier)
184
+ .onGet(async () => this.activeIdentifier)
185
+ .onSet(this.handleSetActiveIdentifier.bind(this));
186
+
187
+ // Add inputs as InputSource services
188
+ this.inputs.forEach((inp, idx) => {
189
+ const id = (typeof inp.identifier === 'number') ? inp.identifier : (idx + 1);
190
+ const label = inp.label || `Input ${id}`;
191
+
192
+ let inputService = this.accessory.getService(label);
193
+ if (!inputService) {
194
+ inputService = this.accessory.addService(Service.InputSource, label, 'input-' + id);
195
+ }
196
+ inputService
197
+ .setCharacteristic(Characteristic.Identifier, id)
198
+ .setCharacteristic(Characteristic.ConfiguredName, label)
199
+ .setCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.HDMI)
200
+ .setCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED)
201
+ .setCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN);
202
+
203
+ this.televisionService.addLinkedService(inputService);
204
+ });
205
+
206
+ // Optional: expose input switches
207
+ if (this.exposeInputSwitches) {
208
+ this.inputs.forEach((inp, idx) => {
209
+ const id = (typeof inp.identifier === 'number') ? inp.identifier : (idx + 1);
210
+ const label = `${inp.label || `Input ${id}`} Switch`;
211
+ const s = this.accessory.addService(Service.Switch, label, 'switch-' + id);
212
+ s.getCharacteristic(Characteristic.On)
213
+ .onGet(async () => this.active && (this.activeIdentifier === id))
214
+ .onSet(async (val) => {
215
+ if (val) {
216
+ await this._ensureOn();
217
+ await this._setInputByIdentifier(id);
218
+ // Turn off other input switches
219
+ this.inputs.forEach((other, j) => {
220
+ const oid = (typeof other.identifier === 'number') ? other.identifier : (j + 1);
221
+ if (oid !== id) {
222
+ const os = this.accessory.getService(`${other.label || `Input ${oid}`} Switch`);
223
+ if (os) os.updateCharacteristic(Characteristic.On, false);
224
+ }
225
+ });
226
+ } else {
227
+ // no-op
228
+ }
229
+ });
230
+ });
231
+ }
232
+
233
+ // --- TelevisionSpeaker (volume & mute) ---
234
+ this.speakerService = this.accessory.getService(Service.TelevisionSpeaker)
235
+ || this.accessory.addService(Service.TelevisionSpeaker);
236
+
237
+ this.speakerService
238
+ .setCharacteristic(Characteristic.Active, Characteristic.Active.ACTIVE)
239
+ .setCharacteristic(Characteristic.VolumeControlType, Characteristic.VolumeControlType.ABSOLUTE); // we emulate absolute
240
+
241
+ this.speakerService.getCharacteristic(Characteristic.Mute)
242
+ .onGet(async () => this.volume.muted)
243
+ .onSet(this.handleSetMute.bind(this));
244
+
245
+ this.speakerService.getCharacteristic(Characteristic.Volume)
246
+ .onGet(async () => this.volume.current)
247
+ .onSet(this.handleSetVolume.bind(this));
248
+
249
+ // Link speaker to TV
250
+ this.televisionService.addLinkedService(this.speakerService);
251
+
252
+ // --- Brightness as a Lightbulb service ---
253
+ if (this.exposeBrightness) {
254
+ this.backlightService = this.accessory.getService('Backlight')
255
+ || this.accessory.addService(Service.Lightbulb, 'Backlight', 'backlight');
256
+
257
+ this.backlightService.getCharacteristic(Characteristic.On)
258
+ .onGet(async () => this.active === 1 && this.brightness.current > this.brightness.min)
259
+ .onSet(async (val) => {
260
+ if (val) {
261
+ // If TV is OFF, do NOT turn it on automatically just because "All lights" were turned on.
262
+ if (this.active !== 1) {
263
+ this.log.info('Ignoring Backlight On request because TV is OFF.');
264
+ // We can throw an error to indicate failure, or just revert the state.
265
+ // Throwing an error might be annoying in scenes, but reverting is cleaner.
266
+ setTimeout(() => {
267
+ this.backlightService.updateCharacteristic(Characteristic.On, false);
268
+ }, 100);
269
+ return;
270
+ }
271
+ if (this.brightness.current <= this.brightness.min) {
272
+ await this._setBrightness(Math.max(this.brightness.min + 1, 10));
273
+ }
274
+ } else {
275
+ // Turning off backlight -> set brightness to min
276
+ await this._setBrightness(this.brightness.min);
277
+ }
278
+ });
279
+
280
+ this.backlightService.getCharacteristic(Characteristic.Brightness)
281
+ .onGet(async () => this.brightness.current)
282
+ .onSet(async (val) => {
283
+ // If TV is OFF, ignore brightness set
284
+ if (this.active !== 1) {
285
+ this.log.info('Ignoring Brightness Set request because TV is OFF.');
286
+ return;
287
+ }
288
+ await this._setBrightness(val);
289
+ });
290
+ } else {
291
+ // Remove service if it exists (e.g. if user disabled it)
292
+ const existing = this.accessory.getService('Backlight');
293
+ if (existing) {
294
+ this.accessory.removeService(existing);
295
+ }
296
+ }
297
+
298
+ // Publish Accessory information
299
+ const info = this.accessory.getService(Service.AccessoryInformation);
300
+ info
301
+ .setCharacteristic(Characteristic.Manufacturer, 'Philips (Signage)')
302
+ .setCharacteristic(Characteristic.Model, 'D-Line (SICP over IP)')
303
+ .setCharacteristic(Characteristic.SerialNumber, this.host);
304
+ }
305
+
306
+ // ---------------- Handlers ----------------
307
+
308
+ async handleGetActive() {
309
+ try {
310
+ const pkt = buildSicpPacket(this.monitorId, [0x19], this.includeGroup, this.groupId); // Get Power
311
+ const reply = await this.client.send(pkt);
312
+ const parsed = parseReply(reply);
313
+ // parsed.raw is e.g. "0x06 0x02" where 0x02 is ON.
314
+ this.platform.log.debug('GetActive reply:', parsed.raw);
315
+
316
+ // SICP Get Power Reply: [Len, Mon, Grp, ACK(06), Status] or just [ACK, Status] depending on model.
317
+ // We need to actually parse the status byte.
318
+ // Assuming the last byte before checksum or the byte after 0x06 is status.
319
+ // Let's look for 0x02 (On) or 0x01 (Standby) in the reply.
320
+ const bytes = [...reply];
321
+ // Simple heuristic: if it contains 0x02, it's ON. If 0x01, it's OFF.
322
+ // Note: This matches the Set Power command (0x02=On, 0x01=Off).
323
+ if (bytes.includes(0x02)) {
324
+ this.active = 1;
325
+ } else if (bytes.includes(0x01)) {
326
+ this.active = 0;
327
+ }
328
+ // If we got a reply but couldn't check status, we validly connected at least.
329
+ // But typically we should trust the parsed status.
330
+
331
+ return this.active;
332
+ } catch (e) {
333
+ this.log.warn('GetActive failed (TV unreachable?):', e.message);
334
+ this.active = 0; // Assume OFF if unreachable
335
+ return this.active;
336
+ }
337
+ }
338
+
339
+ async handleSetActive(value) {
340
+ const on = (value === 1 || value === true);
341
+ try {
342
+ const pkt = buildSicpPacket(this.monitorId, [0x18, on ? 0x02 : 0x01], this.includeGroup, this.groupId); // Set Power
343
+ const reply = await this.client.send(pkt);
344
+ const parsed = parseReply(reply);
345
+ this.log.debug('SetActive reply:', parsed.raw);
346
+ if (parsed.nack || parsed.nav) throw new Error('Device rejected command');
347
+ this.active = on ? 1 : 0;
348
+ if (!on && this.exposeInputSwitches) {
349
+ this.inputs.forEach((inp, idx) => {
350
+ const id = (typeof inp.identifier === 'number') ? inp.identifier : (idx + 1);
351
+ const s = this.accessory.getService(`${inp.label || `Input ${id}`} Switch`);
352
+ if (s) s.updateCharacteristic(hap.Characteristic.On, false);
353
+ });
354
+ }
355
+ } catch (e) {
356
+ this.log.error('SetActive error:', e.message);
357
+ this.televisionService.updateCharacteristic(hap.Characteristic.Active, this.active);
358
+ throw e;
359
+ }
360
+ }
361
+
362
+ async handleSetActiveIdentifier(identifier) {
363
+ await this._ensureOn();
364
+ await this._setInputByIdentifier(identifier);
365
+ }
366
+
367
+ async handleSetVolume(val) {
368
+ const target = clamp(Number(val), this.volume.min, this.volume.max);
369
+ await this._ensureOn();
370
+ if (this.volume.setCode) {
371
+ // Absolute mode
372
+ const code = this._parseCode(this.volume.setCode);
373
+ if (code === 0x44) {
374
+ // SICP Volume Set: [0x44, SpeakerVol, AudioOutVol]; use 0xFF (no change) for Audio Out
375
+ await this._send([code, target & 0xFF, 0xFF]);
376
+ } else {
377
+ await this._send([code, target & 0xFF]);
378
+ }
379
+ } else if (this.volume.upCode && this.volume.downCode) {
380
+ // Relative mode: step towards target
381
+ const diff = target - this.volume.current;
382
+ const stepCode = diff > 0 ? this._parseCode(this.volume.upCode) : this._parseCode(this.volume.downCode);
383
+ for (let i = 0; i < Math.abs(diff); i++) {
384
+ await this._send([stepCode]);
385
+ await delay(this.volume.stepMs);
386
+ }
387
+ } else {
388
+ this.log.warn('Volume codes not configured; ignoring setVolume.');
389
+ }
390
+ this.volume.current = target;
391
+ this.speakerService.updateCharacteristic(hap.Characteristic.Volume, this.volume.current);
392
+ }
393
+
394
+ async handleSetMute(val) {
395
+ const mute = !!val;
396
+ await this._ensureOn();
397
+ if (this.volume.muteSetCode) {
398
+ const code = this._parseCode(this.volume.muteSetCode);
399
+ await this._send([code, mute ? 0x01 : 0x00]);
400
+ } else if (this.volume.muteToggleCode) {
401
+ const code = this._parseCode(this.volume.muteToggleCode);
402
+ // If desired state differs, send one toggle
403
+ if (mute !== this.volume.muted) {
404
+ await this._send([code]);
405
+ }
406
+ } else {
407
+ this.log.warn('Mute codes not configured; ignoring mute.');
408
+ }
409
+ this.volume.muted = mute;
410
+ this.speakerService.updateCharacteristic(hap.Characteristic.Mute, this.volume.muted);
411
+ }
412
+
413
+ // ---------------- Helpers ----------------
414
+
415
+ async _ensureOn() {
416
+ if (this.active !== 1) {
417
+ await this.handleSetActive(1);
418
+ await delay(300);
419
+ }
420
+ }
421
+
422
+ _codeFromIdentifier(identifier) {
423
+ const inp = this.inputs.find(x => (x.identifier === identifier));
424
+ if (!inp) return null;
425
+ const raw = (typeof inp.code === 'string') ? inp.code : inp.code?.toString();
426
+ if (!raw) return null;
427
+ const code = raw.trim().toLowerCase().startsWith('0x') ? parseInt(raw, 16) : parseInt(raw, 10);
428
+ if (Number.isNaN(code)) return null;
429
+ return code & 0xFF;
430
+ }
431
+
432
+ _parseCode(raw) {
433
+ if (typeof raw === 'number') return raw & 0xFF;
434
+ const s = String(raw).trim().toLowerCase();
435
+ return (s.startsWith('0x') ? parseInt(s, 16) : parseInt(s, 10)) & 0xFF;
436
+ }
437
+
438
+ async _setInputByIdentifier(identifier) {
439
+ const code = this._codeFromIdentifier(identifier);
440
+ if (code == null) {
441
+ this.log.error('Unknown input identifier:', identifier);
442
+ return;
443
+ }
444
+ const reply = await this._send([0xAC, code]); // Set Input
445
+ const parsed = parseReply(reply);
446
+ this.log.debug(`SetInput(${identifier}/0x${code.toString(16)}) reply:`, parsed.raw);
447
+ if (parsed.nack || parsed.nav) throw new Error('Device rejected input change');
448
+ this.activeIdentifier = identifier;
449
+ this.televisionService.updateCharacteristic(hap.Characteristic.ActiveIdentifier, identifier);
450
+ if (this.exposeInputSwitches) {
451
+ this.inputs.forEach((inp, idx) => {
452
+ const id = (typeof inp.identifier === 'number') ? inp.identifier : (idx + 1);
453
+ const s = this.accessory.getService(`${inp.label || `Input ${id}`} Switch`);
454
+ if (s) s.updateCharacteristic(hap.Characteristic.On, id === identifier);
455
+ });
456
+ }
457
+ }
458
+
459
+ async _setBrightness(val) {
460
+ const target = clamp(Number(val), this.brightness.min, this.brightness.max);
461
+ if (this.brightness.setCode) {
462
+ const code = this._parseCode(this.brightness.setCode);
463
+ if (code === 0x32) {
464
+ // SICP Video Parameters Set: [0x32, Brightness, Color, Contrast, Sharpness, Tint, BlackLevel, Gamma]
465
+ // Use 0xFF for "no change" on other fields (supported since SICP 2.09)
466
+ await this._send([code, target & 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
467
+ } else {
468
+ await this._send([code, target & 0xFF]);
469
+ }
470
+ } else if (this.brightness.upCode && this.brightness.downCode) {
471
+ const diff = target - this.brightness.current;
472
+ const stepCode = diff > 0 ? this._parseCode(this.brightness.upCode) : this._parseCode(this.brightness.downCode);
473
+ for (let i = 0; i < Math.abs(diff); i++) {
474
+ await this._send([stepCode]);
475
+ await delay(this.brightness.stepMs);
476
+ }
477
+ } else {
478
+ // Fallback: If no brightness setCode or up/down codes, try default 0x32 (Video Parameters)
479
+ // Many D-Lines support 0x32 for brightness.
480
+ this.log.debug('No brightness codes configured, attempting default SICP 0x32 command.');
481
+ try {
482
+ // [0x32, Brightness, Color, Contrast, Sharpness, Tint, BlackLevel, Gamma]
483
+ // We'll try just sending [0x32, val]. Some older SICP might accept just valid params.
484
+ // Or better, use the full safe string:
485
+ // await this._send([0x32, target & 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
486
+ // Wait, the code existed but was inside 'if (this.brightness.setCode)'.
487
+ // Let's force try 0x32 if nothing else is set.
488
+ await this._send([0x32, target & 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]);
489
+ } catch (e) {
490
+ this.log.warn('Default brightness 0x32 failed:', e.message);
491
+ }
492
+ }
493
+ this.brightness.current = target;
494
+ if (this.exposeBrightness) {
495
+ this.backlightService.updateCharacteristic(hap.Characteristic.Brightness, this.brightness.current);
496
+ this.backlightService.updateCharacteristic(hap.Characteristic.On, this.brightness.current > this.brightness.min);
497
+ }
498
+ }
499
+
500
+ async _send(dataBytes) {
501
+ const pkt = buildSicpPacket(this.monitorId, dataBytes, this.includeGroup, this.groupId);
502
+ const reply = await this.client.send(pkt);
503
+ return reply;
504
+ }
505
+
506
+ _startPolling() {
507
+ if (!this.pollInterval || this.pollInterval <= 0) return;
508
+ const loop = async () => {
509
+ try {
510
+ await delay(this.pollInterval * 1000);
511
+ await this.handleGetActive().catch(() => { });
512
+ } catch (e) {
513
+ // ignore
514
+ } finally {
515
+ setImmediate(loop);
516
+ }
517
+ };
518
+ loop();
519
+ }
520
+ }
521
+
522
+ /** Platform (supports multiple displays) */
523
+ class PhilipsDLinePlatform {
524
+ constructor(log, config, api) {
525
+ this.log = log;
526
+ this.config = config || {};
527
+ this.api = api;
528
+ hap = api.hap;
529
+
530
+ this.accessories = new Map(); // UUID -> accessory
531
+
532
+ if (!this.config.displays || !Array.isArray(this.config.displays) || this.config.displays.length === 0) {
533
+ this.log.warn('No "displays" configured. Please add at least one display.');
534
+ }
535
+
536
+ api.on('didFinishLaunching', () => {
537
+ this.discover();
538
+ });
539
+ }
540
+
541
+ configureAccessory(accessory) {
542
+ this.accessories.set(accessory.UUID, accessory);
543
+ }
544
+
545
+ discover() {
546
+ const displays = this.config.displays || [];
547
+ displays.forEach(conf => {
548
+ if (!conf || !conf.host) {
549
+ this.log.warn('Skipping display without "host" field:', conf);
550
+ return;
551
+ }
552
+ const uuid = this.api.hap.uuid.generate(`philips-dline:${conf.host}:${conf.name || ''}`);
553
+ let accessory = this.accessories.get(uuid);
554
+ if (!accessory) {
555
+ accessory = new this.api.platformAccessory(conf.name || 'Philips D-Line', uuid);
556
+ accessory.context.conf = conf;
557
+ // Fix for icon issue: Set category to TELEVISION
558
+ accessory.category = this.api.hap.Categories.TELEVISION;
559
+
560
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
561
+ this.accessories.set(uuid, accessory);
562
+ this.log.info('Registered new display accessory:', conf.name || conf.host);
563
+ } else {
564
+ accessory.context.conf = conf;
565
+ // Ensure category is updated if it was missing
566
+ accessory.category = this.api.hap.Categories.TELEVISION;
567
+ this.log.info('Updated display accessory:', conf.name || conf.host);
568
+ }
569
+ new PhilipsDLineTelevisionAccessory(this, accessory, conf);
570
+ });
571
+ }
572
+ }
573
+
574
+ module.exports = (api) => {
575
+ api.registerPlatform(PLATFORM_NAME, PhilipsDLinePlatform);
576
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "homebridge-philips-dline-sicp",
3
+ "version": "0.1.3",
4
+ "description": "Homebridge plugin to control Philips D-Line signage displays (e.g., 55BDL4511D) over LAN using SICP (TCP:5000). Exposes as a HomeKit Television with inputs.",
5
+ "main": "index.js",
6
+ "keywords": [
7
+ "homebridge-plugin",
8
+ "philips",
9
+ "d-line",
10
+ "signage",
11
+ "television",
12
+ "sicp"
13
+ ],
14
+ "author": "ChatGPT",
15
+ "license": "MIT",
16
+ "engines": {
17
+ "node": ">=18.0.0",
18
+ "homebridge": ">=1.6.0"
19
+ },
20
+ "dependencies": {},
21
+ "devDependencies": {},
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://example.com/homebridge-philips-dline-sicp.git"
25
+ },
26
+ "homepage": "https://github.com/yourname/homebridge-philips-dline-sicp",
27
+ "bugs": {
28
+ "url": "https://github.com/yourname/homebridge-philips-dline-sicp/issues"
29
+ },
30
+ "contributors": [
31
+ "Thomas Dazy"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }