homebridge-lovesac-stealthtech 0.0.0-development

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 Alex Rosenberg
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,155 @@
1
+ # homebridge-lovesac-stealthtech
2
+
3
+ Homebridge plugin for the [**Lovesac StealthTech Sound + Charge**](https://www.lovesac.com/stealthtech.html) system. Control your StealthTech soundbar from Apple HomeKit using Bluetooth.
4
+
5
+ ## Features
6
+
7
+ - **Power on/off** from HomeKit and Siri
8
+ - **Input switching** — HDMI-ARC, Bluetooth, AUX, Optical
9
+ - **Volume control** — slider in the Home app, plus volume buttons in Control Center
10
+ - **Mute** toggle
11
+ - **Audio presets** — Movies, Music, TV, News as individual switches
12
+ - **Quiet mode** toggle
13
+ - **Auto-discovery** — finds your StealthTech device automatically
14
+ - **Background polling** — keeps HomeKit in sync when settings change elsewhere
15
+
16
+ ## Requirements
17
+
18
+ - [Homebridge](https://homebridge.io) v1.8.0 or later
19
+ - Node.js 18, 20, or 22
20
+ - Bluetooth adapter (built-in on Mac; USB dongle on Linux/Raspberry Pi)
21
+ - macOS, Linux, or Raspberry Pi (any platform supported by [@stoprocent/noble](https://github.com/nicolo-ribaudo/noble))
22
+
23
+ ## Installation
24
+
25
+ ### Via Homebridge UI
26
+
27
+ Search for `homebridge-lovesac-stealthtech` in the Homebridge UI plugin tab.
28
+
29
+ ### Via CLI
30
+
31
+ ```sh
32
+ npm install -g homebridge-lovesac-stealthtech
33
+ ```
34
+
35
+ ## Pairing
36
+
37
+ This plugin uses the Television service, which Apple requires as an **external accessory**. It won't appear automatically — you need to add it by hand:
38
+
39
+ 1. Open the **Home** app on your iPhone or iPad
40
+ 2. Tap **+** → **Add Accessory**
41
+ 3. Tap **More Options...**
42
+ 4. Select **Lovesac StealthTech**
43
+ 5. Enter the setup code from your Homebridge config (default: `031-45-154`)
44
+
45
+ > **Note:** Due to a known Apple bug, the input sources and preset switches may all show generic names (like "Input Source 2") when you first add the accessory. Just accept the defaults — the plugin will correct all the names automatically within a few moments.
46
+
47
+ ## Using It
48
+
49
+ | Accessory | What it does |
50
+ |---|---|
51
+ | **Lovesac StealthTech** | Power on/off, input source selection |
52
+ | **Volume** (Fan or Lightbulb tile) | Volume slider (0–100%); on/off toggles mute |
53
+ | **Movies / Music / TV / News Mode** (Switch tiles) | Activate audio presets (one at a time) |
54
+ | **Quiet Mode** (Switch tile) | Toggle quiet mode |
55
+
56
+ ### Control Center Remote
57
+
58
+ The Lovesac StealthTech appears in the Control Center remote (the Apple TV Remote widget). Select it from the device picker at the top, then:
59
+
60
+ - **Up/Down arrows** — Volume up/down
61
+ - **Play/Pause** — Toggle mute
62
+ - **Info (i)** — Cycle through presets
63
+
64
+ ### Siri
65
+
66
+ Siri support for external accessories (which the Television service requires) is limited. Basic commands like "turn on" or "set volume" may conflict with other devices in the same room. Siri via HomePod may work better than from an iPhone. The Control Center remote is the most reliable way to control the StealthTech without opening the Home app.
67
+
68
+ ## Important: Only One Bluetooth Connection at a Time
69
+
70
+ The StealthTech hardware allows **only one Bluetooth connection at a time**. This means the Homebridge plugin and the Lovesac mobile app can't be connected simultaneously.
71
+
72
+ The plugin handles this gracefully — it connects briefly to send commands or check the current state, then disconnects so the Bluetooth slot is free. With the default settings, the plugin is connected for roughly 5 seconds every 90 seconds, leaving the connection open for the Lovesac app about 94% of the time.
73
+
74
+ ### Tips for using both HomeKit and the Lovesac app
75
+
76
+ - **Close the Lovesac app when you're done with it.** The app holds the connection open while it's in the foreground (and sometimes in the background). While the app is connected, HomeKit commands won't go through.
77
+ - **Force-quit the app** if HomeKit seems stuck. The app may be holding the connection in the background.
78
+ - You can **increase the poll interval** in the config if you use the Lovesac app frequently. A longer interval gives the app more time to connect, though HomeKit will be slower to pick up changes made outside of it.
79
+
80
+ ### If HomeKit shows "Not Responding"
81
+
82
+ This usually means a Bluetooth command timed out. Common causes:
83
+
84
+ - The Lovesac mobile app has the connection open (close or force-quit it)
85
+ - The StealthTech device is too far from your Home Hub (Apple TV, HomePod, or iPad) — Bluetooth range is between the Hub and the soundbar, not your phone
86
+ - Another Bluetooth client is connected to the device
87
+
88
+ The plugin will automatically retry on the next poll or the next time you send a command.
89
+
90
+ ## Configuration
91
+
92
+ The minimal config is all most people need — the plugin will find your StealthTech device automatically:
93
+
94
+ ```json
95
+ {
96
+ "platform": "LovesacStealthTech",
97
+ "devices": [
98
+ {
99
+ "name": "Lovesac StealthTech"
100
+ }
101
+ ]
102
+ }
103
+ ```
104
+
105
+ ### All Options
106
+
107
+ ```json
108
+ {
109
+ "platform": "LovesacStealthTech",
110
+ "devices": [
111
+ {
112
+ "name": "Lovesac StealthTech",
113
+ "address": "",
114
+ "idleTimeout": 60,
115
+ "pollInterval": 90,
116
+ "volumeControl": "fan",
117
+ "volumeStep": 2,
118
+ "presets": {
119
+ "movies": true,
120
+ "music": true,
121
+ "tv": true,
122
+ "news": true
123
+ }
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ | Option | Default | Description |
130
+ |---|---|---|
131
+ | `name` | `"Lovesac StealthTech"` | Name of the accessory in HomeKit. |
132
+ | `address` | *(auto-discover)* | BLE address. Leave blank for auto-discovery. Use `npx homebridge-lovesac-stealthtech scan` to find the address if auto-discovery fails. |
133
+ | `idleTimeout` | `60` | Seconds to wait after the last command before disconnecting. Range: 10–600. |
134
+ | `pollInterval` | `90` | Seconds between background state checks. Longer intervals make it easier to use the Lovesac app alongside HomeKit; shorter intervals keep HomeKit more up to date. Set to `0` to disable polling entirely. Range: 0–600. |
135
+ | `volumeControl` | `"fan"` | How to expose the volume slider: `"fan"`, `"lightbulb"`, or `"none"`. Fan is recommended — Siri can "set Volume to 50%". |
136
+ | `volumeStep` | `2` | Volume increment for Control Center remote up/down buttons. Range: 1–5. |
137
+ | `presets` | all enabled | Show or hide individual preset switches (movies, music, tv, news). |
138
+
139
+ > **Note:** Keep `pollInterval` larger than `idleTimeout` so the plugin actually disconnects between polls.
140
+
141
+ ### Scanner
142
+
143
+ If auto-discovery doesn't find your device (e.g., you have multiple StealthTech systems), you can scan manually:
144
+
145
+ ```sh
146
+ npx homebridge-lovesac-stealthtech scan
147
+ ```
148
+
149
+ ## Contributing
150
+
151
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and build instructions.
152
+
153
+ ## License
154
+
155
+ [MIT](LICENSE)
@@ -0,0 +1,96 @@
1
+ {
2
+ "pluginAlias": "LovesacStealthTech",
3
+ "pluginType": "platform",
4
+ "singular": true,
5
+ "headerDisplay": "Control your Lovesac StealthTech Sound + Charge system via BLE.",
6
+ "footerDisplay": "The plugin will automatically discover your StealthTech device via Bluetooth. You only need to set the BLE address if auto-discovery doesn't work.",
7
+ "schema": {
8
+ "type": "object",
9
+ "properties": {
10
+ "devices": {
11
+ "title": "Devices",
12
+ "type": "array",
13
+ "items": {
14
+ "type": "object",
15
+ "properties": {
16
+ "name": {
17
+ "title": "Name",
18
+ "type": "string",
19
+ "default": "Lovesac StealthTech",
20
+ "description": "The name of the accessory as it appears in HomeKit."
21
+ },
22
+ "address": {
23
+ "title": "BLE Address (optional)",
24
+ "type": "string",
25
+ "description": "Leave blank for auto-discovery. Only needed if you have multiple StealthTech devices or auto-discovery fails. Use `npx homebridge-lovesac-stealthtech scan` to find the address.",
26
+ "placeholder": "Auto-discover"
27
+ },
28
+ "idleTimeout": {
29
+ "title": "Idle Timeout (seconds)",
30
+ "type": "integer",
31
+ "default": 60,
32
+ "minimum": 10,
33
+ "maximum": 600,
34
+ "description": "Disconnect BLE after this many seconds of inactivity. Allows the Lovesac mobile app to connect."
35
+ },
36
+ "pollInterval": {
37
+ "title": "Poll Interval (seconds)",
38
+ "type": "integer",
39
+ "default": 90,
40
+ "minimum": 0,
41
+ "maximum": 600,
42
+ "description": "How often to check the device for state changes. Lower values give more responsive UI but keep the BLE connection active more often, which may prevent the Lovesac mobile app from connecting. Set to 0 to disable background polling. Should be greater than Idle Timeout to free the BLE slot between polls."
43
+ },
44
+ "volumeControl": {
45
+ "title": "Volume Control Type",
46
+ "type": "string",
47
+ "default": "fan",
48
+ "oneOf": [
49
+ { "title": "Fan (Recommended)", "enum": ["fan"] },
50
+ { "title": "Lightbulb", "enum": ["lightbulb"] },
51
+ { "title": "None (remote buttons only)", "enum": ["none"] }
52
+ ],
53
+ "description": "Accessory type used to expose volume as a percentage slider for Siri control."
54
+ },
55
+ "volumeStep": {
56
+ "title": "Volume Step",
57
+ "type": "integer",
58
+ "default": 2,
59
+ "minimum": 1,
60
+ "maximum": 5,
61
+ "description": "Volume increment/decrement for remote button presses."
62
+ },
63
+ "presets": {
64
+ "title": "Preset Switches",
65
+ "type": "object",
66
+ "properties": {
67
+ "movies": {
68
+ "title": "Movies Mode",
69
+ "type": "boolean",
70
+ "default": true
71
+ },
72
+ "music": {
73
+ "title": "Music Mode",
74
+ "type": "boolean",
75
+ "default": true
76
+ },
77
+ "tv": {
78
+ "title": "TV Mode",
79
+ "type": "boolean",
80
+ "default": true
81
+ },
82
+ "news": {
83
+ "title": "News Mode",
84
+ "type": "boolean",
85
+ "default": true
86
+ }
87
+ },
88
+ "additionalProperties": false
89
+ }
90
+ },
91
+ "required": []
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,36 @@
1
+ import type { PlatformAccessory } from 'homebridge';
2
+ import type { LovesacPlatform } from './platform';
3
+ import type { LovesacDeviceConfig } from './settings';
4
+ import { LovesacDevice } from './protocol/LovesacDevice';
5
+ export declare class LovesacAccessory {
6
+ private readonly platform;
7
+ private readonly accessory;
8
+ private readonly config;
9
+ private readonly device;
10
+ private readonly tvService;
11
+ private readonly speakerService;
12
+ private volumeService;
13
+ private readonly inputSources;
14
+ private readonly presetSwitches;
15
+ private quietModeService;
16
+ private volumeDebounceTimer;
17
+ private readonly Characteristic;
18
+ constructor(platform: LovesacPlatform, accessory: PlatformAccessory, config: LovesacDeviceConfig, device: LovesacDevice);
19
+ private setPower;
20
+ private setActiveIdentifier;
21
+ private setRemoteKey;
22
+ private setMute;
23
+ private setVolumeSelector;
24
+ private setVolumeOn;
25
+ private setVolumePercent;
26
+ /**
27
+ * Workaround for tvOS 18+ Home Hub bug (homebridge/homebridge#3703).
28
+ * The Home Hub forcibly renames services to localized generic defaults
29
+ * during setup. We reject all renames and always push back the name
30
+ * from the Homebridge config — language-independent.
31
+ */
32
+ private setupConfiguredNameHandler;
33
+ private cyclePreset;
34
+ private updatePresetSwitches;
35
+ private handleStateChange;
36
+ }
@@ -0,0 +1,352 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LovesacAccessory = void 0;
4
+ const hap_nodejs_1 = require("hap-nodejs");
5
+ const settings_1 = require("./settings");
6
+ const constants_1 = require("./protocol/constants");
7
+ class LovesacAccessory {
8
+ platform;
9
+ accessory;
10
+ config;
11
+ device;
12
+ tvService;
13
+ speakerService;
14
+ volumeService = null;
15
+ inputSources = [];
16
+ presetSwitches = [];
17
+ quietModeService = null;
18
+ volumeDebounceTimer = null;
19
+ Characteristic;
20
+ constructor(platform, accessory, config, device) {
21
+ this.platform = platform;
22
+ this.accessory = accessory;
23
+ this.config = config;
24
+ this.device = device;
25
+ this.Characteristic = this.platform.api.hap.Characteristic;
26
+ const Service = this.platform.api.hap.Service;
27
+ // --- Accessory Information ---
28
+ const infoService = this.accessory.getService(Service.AccessoryInformation) ??
29
+ this.accessory.addService(Service.AccessoryInformation);
30
+ infoService
31
+ .setCharacteristic(this.Characteristic.Manufacturer, 'Lovesac')
32
+ .setCharacteristic(this.Characteristic.Model, 'StealthTech Sound + Charge')
33
+ .setCharacteristic(this.Characteristic.SerialNumber, this.config.address || 'Auto')
34
+ .setCharacteristic(this.Characteristic.FirmwareRevision, '0.0');
35
+ // Update serial number and firmware after BLE connects
36
+ this.device.onVersionResolved(() => {
37
+ const addr = this.device.getResolvedAddress();
38
+ if (addr && addr !== this.config.address) {
39
+ infoService.updateCharacteristic(this.Characteristic.SerialNumber, addr);
40
+ }
41
+ if (this.device.mcuVersion) {
42
+ infoService.updateCharacteristic(this.Characteristic.FirmwareRevision, this.device.mcuVersion);
43
+ }
44
+ });
45
+ // --- Television (primary) ---
46
+ // tvOS 18 workaround: pass name as displayName, use setValue() for ConfiguredName,
47
+ // and add onGet/onSet to reject bogus "TV" renames from Apple TV Home Hub.
48
+ // See: https://github.com/homebridge/homebridge/issues/3703
49
+ this.tvService = this.accessory.addService(Service.Television, this.config.name, 'television');
50
+ this.tvService.setPrimaryService(true);
51
+ this.tvService.setCharacteristic(this.Characteristic.SleepDiscoveryMode, this.Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE);
52
+ // Use setValue() for ConfiguredName and Name to ensure proper HAP notification
53
+ this.tvService.getCharacteristic(this.Characteristic.ConfiguredName).setValue(this.config.name);
54
+ this.tvService.getCharacteristic(this.Characteristic.Name).setValue(this.config.name);
55
+ this.platform.log.info('TV ConfiguredName set to "%s", Name set to "%s"', this.tvService.getCharacteristic(this.Characteristic.ConfiguredName).value, this.tvService.getCharacteristic(this.Characteristic.Name).value);
56
+ this.setupConfiguredNameHandler(this.tvService, this.config.name);
57
+ // Active (power)
58
+ this.tvService.getCharacteristic(this.Characteristic.Active)
59
+ .onSet(this.setPower.bind(this));
60
+ // ActiveIdentifier (input source)
61
+ this.tvService.getCharacteristic(this.Characteristic.ActiveIdentifier)
62
+ .onSet(this.setActiveIdentifier.bind(this));
63
+ // Remote key
64
+ this.tvService.getCharacteristic(this.Characteristic.RemoteKey)
65
+ .onSet(this.setRemoteKey.bind(this));
66
+ // --- Input Sources ---
67
+ const InputSourceType = this.Characteristic.InputSourceType;
68
+ const inputConfigs = [
69
+ { name: 'HDMI-ARC', source: constants_1.SourceValue.HDMI, identifier: 1, type: InputSourceType.HDMI },
70
+ { name: 'Bluetooth', source: constants_1.SourceValue.Bluetooth, identifier: 2, type: InputSourceType.OTHER },
71
+ { name: 'AUX', source: constants_1.SourceValue.AUX, identifier: 3, type: InputSourceType.OTHER },
72
+ { name: 'Optical', source: constants_1.SourceValue.Optical, identifier: 4, type: InputSourceType.OTHER },
73
+ ];
74
+ for (const input of inputConfigs) {
75
+ // Pass name as displayName for tvOS 18 compatibility
76
+ const inputService = this.accessory.addService(Service.InputSource, input.name, `input-${input.identifier}`);
77
+ // Set Identifier first — critical for HomeKit
78
+ inputService.setCharacteristic(this.Characteristic.Identifier, input.identifier);
79
+ // Use setValue() for ConfiguredName and Name to ensure proper HAP notification
80
+ inputService.getCharacteristic(this.Characteristic.ConfiguredName).setValue(input.name);
81
+ inputService.getCharacteristic(this.Characteristic.Name).setValue(input.name);
82
+ this.platform.log.info('Input[%d] ConfiguredName="%s", Name="%s"', input.identifier, inputService.getCharacteristic(this.Characteristic.ConfiguredName).value, inputService.getCharacteristic(this.Characteristic.Name).value);
83
+ inputService
84
+ .setCharacteristic(this.Characteristic.IsConfigured, this.Characteristic.IsConfigured.CONFIGURED)
85
+ .setCharacteristic(this.Characteristic.InputSourceType, input.type)
86
+ .setCharacteristic(this.Characteristic.CurrentVisibilityState, this.Characteristic.CurrentVisibilityState.SHOWN)
87
+ .setCharacteristic(this.Characteristic.TargetVisibilityState, this.Characteristic.TargetVisibilityState.SHOWN);
88
+ this.setupConfiguredNameHandler(inputService, input.name);
89
+ this.tvService.addLinkedService(inputService);
90
+ this.inputSources.push(inputService);
91
+ }
92
+ // --- Television Speaker ---
93
+ this.speakerService = this.accessory.addService(Service.TelevisionSpeaker, 'Speaker', 'speaker');
94
+ this.speakerService
95
+ .setCharacteristic(this.Characteristic.VolumeControlType, this.Characteristic.VolumeControlType.RELATIVE_WITH_CURRENT);
96
+ this.speakerService.getCharacteristic(this.Characteristic.Mute)
97
+ .onSet(this.setMute.bind(this));
98
+ this.speakerService.getCharacteristic(this.Characteristic.VolumeSelector)
99
+ .onSet(this.setVolumeSelector.bind(this));
100
+ this.tvService.addLinkedService(this.speakerService);
101
+ // --- Volume Proxy (Fan or Lightbulb) ---
102
+ if (this.config.volumeControl === 'fan' || this.config.volumeControl === 'lightbulb') {
103
+ const isFan = this.config.volumeControl === 'fan';
104
+ const svcType = isFan ? Service.Fan : Service.Lightbulb;
105
+ const subtype = isFan ? 'volume-fan' : 'volume-lightbulb';
106
+ this.volumeService = this.accessory.addService(svcType, 'Volume', subtype);
107
+ this.volumeService.addOptionalCharacteristic(this.Characteristic.ConfiguredName);
108
+ this.volumeService.getCharacteristic(this.Characteristic.ConfiguredName).setValue('Volume');
109
+ this.setupConfiguredNameHandler(this.volumeService, 'Volume');
110
+ this.volumeService.getCharacteristic(this.Characteristic.On)
111
+ .onSet(this.setVolumeOn.bind(this));
112
+ const levelChar = isFan ? this.Characteristic.RotationSpeed : this.Characteristic.Brightness;
113
+ this.volumeService.getCharacteristic(levelChar)
114
+ .onSet(this.setVolumePercent.bind(this));
115
+ }
116
+ // --- Preset Switches ---
117
+ const presetConfigs = [
118
+ { key: 'movies', name: 'Movies Mode', readVal: constants_1.PresetReadValue.Movies, writeVal: constants_1.PresetWriteValue.Movies },
119
+ { key: 'music', name: 'Music Mode', readVal: constants_1.PresetReadValue.Music, writeVal: constants_1.PresetWriteValue.Music },
120
+ { key: 'tv', name: 'TV Mode', readVal: constants_1.PresetReadValue.TV, writeVal: constants_1.PresetWriteValue.TV },
121
+ { key: 'news', name: 'News Mode', readVal: constants_1.PresetReadValue.News, writeVal: constants_1.PresetWriteValue.News },
122
+ ];
123
+ for (const preset of presetConfigs) {
124
+ if (!this.config.presets[preset.key]) {
125
+ continue;
126
+ }
127
+ const switchService = this.accessory.addService(Service.Switch, preset.name, `preset-${preset.key}`);
128
+ switchService.addOptionalCharacteristic(this.Characteristic.ConfiguredName);
129
+ switchService.getCharacteristic(this.Characteristic.ConfiguredName).setValue(preset.name);
130
+ this.setupConfiguredNameHandler(switchService, preset.name);
131
+ switchService.getCharacteristic(this.Characteristic.On)
132
+ .onSet(async (value) => {
133
+ if (!value) {
134
+ return; // Turning off is a no-op — can't unselect a preset
135
+ }
136
+ try {
137
+ await this.device.setPreset(preset.writeVal);
138
+ this.updatePresetSwitches(preset.readVal);
139
+ }
140
+ catch (err) {
141
+ this.platform.log.error('setPreset failed: %s', (0, settings_1.errorMessage)(err));
142
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
143
+ }
144
+ });
145
+ this.presetSwitches.push({
146
+ service: switchService,
147
+ presetRead: preset.readVal,
148
+ presetWrite: preset.writeVal,
149
+ });
150
+ }
151
+ // --- Quiet Mode ---
152
+ this.quietModeService = this.accessory.addService(Service.Switch, 'Quiet Mode', 'quiet-mode');
153
+ this.quietModeService.addOptionalCharacteristic(this.Characteristic.ConfiguredName);
154
+ this.quietModeService.getCharacteristic(this.Characteristic.ConfiguredName).setValue('Quiet Mode');
155
+ this.setupConfiguredNameHandler(this.quietModeService, 'Quiet Mode');
156
+ this.quietModeService.getCharacteristic(this.Characteristic.On)
157
+ .onSet(async (value) => {
158
+ try {
159
+ await this.device.setQuietMode(value);
160
+ }
161
+ catch (err) {
162
+ this.platform.log.error('setQuietMode failed: %s', (0, settings_1.errorMessage)(err));
163
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
164
+ }
165
+ });
166
+ // --- Listen for device state changes ---
167
+ this.device.onStateChange(this.handleStateChange.bind(this));
168
+ // Start background polling (also triggers initial state fetch via onReconnect)
169
+ this.device.startPolling(this.config.pollInterval);
170
+ }
171
+ // --- Power ---
172
+ async setPower(value) {
173
+ try {
174
+ const on = value === this.Characteristic.Active.ACTIVE;
175
+ await this.device.setPower(on);
176
+ }
177
+ catch (err) {
178
+ this.platform.log.error('setPower failed: %s', (0, settings_1.errorMessage)(err));
179
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
180
+ }
181
+ }
182
+ // --- Input Source ---
183
+ // HomeKit Identifier 1-4 maps to SourceValue 0-3
184
+ async setActiveIdentifier(value) {
185
+ try {
186
+ const source = value - 1;
187
+ await this.device.setSource(source);
188
+ }
189
+ catch (err) {
190
+ this.platform.log.error('setActiveIdentifier failed: %s', (0, settings_1.errorMessage)(err));
191
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
192
+ }
193
+ }
194
+ // --- Remote Key ---
195
+ async setRemoteKey(value) {
196
+ try {
197
+ const RemoteKey = this.Characteristic.RemoteKey;
198
+ switch (value) {
199
+ case RemoteKey.ARROW_UP:
200
+ await this.device.volumeUp(this.config.volumeStep);
201
+ break;
202
+ case RemoteKey.ARROW_DOWN:
203
+ await this.device.volumeDown(this.config.volumeStep);
204
+ break;
205
+ case RemoteKey.SELECT:
206
+ case RemoteKey.PLAY_PAUSE:
207
+ await this.device.setMute(!this.device.state.mute);
208
+ break;
209
+ case RemoteKey.INFORMATION:
210
+ await this.cyclePreset();
211
+ break;
212
+ }
213
+ }
214
+ catch (err) {
215
+ this.platform.log.error('setRemoteKey failed: %s', (0, settings_1.errorMessage)(err));
216
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
217
+ }
218
+ }
219
+ // --- Mute ---
220
+ async setMute(value) {
221
+ try {
222
+ await this.device.setMute(value);
223
+ }
224
+ catch (err) {
225
+ this.platform.log.error('setMute failed: %s', (0, settings_1.errorMessage)(err));
226
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
227
+ }
228
+ }
229
+ // --- Volume Selector (up/down buttons in Control Center remote) ---
230
+ async setVolumeSelector(value) {
231
+ try {
232
+ if (value === this.Characteristic.VolumeSelector.INCREMENT) {
233
+ await this.device.volumeUp(this.config.volumeStep);
234
+ }
235
+ else {
236
+ await this.device.volumeDown(this.config.volumeStep);
237
+ }
238
+ }
239
+ catch (err) {
240
+ this.platform.log.error('setVolumeSelector failed: %s', (0, settings_1.errorMessage)(err));
241
+ throw new hap_nodejs_1.HapStatusError(-70402 /* HAPStatus.SERVICE_COMMUNICATION_FAILURE */);
242
+ }
243
+ }
244
+ // --- Volume Proxy (Fan/Lightbulb) ---
245
+ async setVolumeOn(value) {
246
+ if (!value) {
247
+ await this.device.setMute(true);
248
+ }
249
+ else if (this.device.state.mute) {
250
+ await this.device.setMute(false);
251
+ }
252
+ }
253
+ setVolumePercent(value) {
254
+ const percent = value;
255
+ // Debounce: HomeKit slider sends many rapid updates
256
+ if (this.volumeDebounceTimer) {
257
+ clearTimeout(this.volumeDebounceTimer);
258
+ }
259
+ this.volumeDebounceTimer = setTimeout(() => {
260
+ const volume = this.device.percentToVolume(percent);
261
+ this.device.setVolume(volume).catch((err) => {
262
+ this.platform.log.error('Failed to set volume: %s', (0, settings_1.errorMessage)(err));
263
+ });
264
+ }, 100);
265
+ }
266
+ // --- Helpers ---
267
+ /**
268
+ * Workaround for tvOS 18+ Home Hub bug (homebridge/homebridge#3703).
269
+ * The Home Hub forcibly renames services to localized generic defaults
270
+ * during setup. We reject all renames and always push back the name
271
+ * from the Homebridge config — language-independent.
272
+ */
273
+ setupConfiguredNameHandler(service, originalName) {
274
+ const subtype = service.subtype ?? '(primary)';
275
+ service.getCharacteristic(this.Characteristic.ConfiguredName)
276
+ .onGet(() => {
277
+ this.platform.log.debug('[%s] ConfiguredName GET → "%s"', subtype, originalName);
278
+ return originalName;
279
+ })
280
+ .onSet((value) => {
281
+ const newName = value;
282
+ if (newName === originalName) {
283
+ this.platform.log.debug('[%s] ConfiguredName SET "%s" (unchanged)', subtype, newName);
284
+ return;
285
+ }
286
+ this.platform.log.info('[%s] Rejecting rename "%s" → pushing back "%s"', subtype, newName, originalName);
287
+ setTimeout(() => {
288
+ service.getCharacteristic(this.Characteristic.ConfiguredName).updateValue(originalName);
289
+ }, 500);
290
+ });
291
+ }
292
+ async cyclePreset() {
293
+ const enabledPresets = this.presetSwitches.map((s) => s.presetRead);
294
+ if (enabledPresets.length === 0) {
295
+ return;
296
+ }
297
+ const currentIndex = enabledPresets.indexOf(this.device.state.preset);
298
+ const nextIndex = (currentIndex + 1) % enabledPresets.length;
299
+ const nextWrite = (0, constants_1.presetReadToWrite)(enabledPresets[nextIndex]);
300
+ await this.device.setPreset(nextWrite);
301
+ this.updatePresetSwitches(enabledPresets[nextIndex]);
302
+ }
303
+ updatePresetSwitches(activePreset) {
304
+ for (const ps of this.presetSwitches) {
305
+ ps.service.getCharacteristic(this.Characteristic.On)
306
+ .updateValue(ps.presetRead === activePreset);
307
+ }
308
+ }
309
+ // --- State Change Handler ---
310
+ handleStateChange(code, _value) {
311
+ switch (code) {
312
+ case constants_1.ResponseCode.Power:
313
+ this.tvService.getCharacteristic(this.Characteristic.Active)
314
+ .updateValue(this.device.state.power
315
+ ? this.Characteristic.Active.ACTIVE
316
+ : this.Characteristic.Active.INACTIVE);
317
+ break;
318
+ case constants_1.ResponseCode.Volume:
319
+ if (this.volumeService) {
320
+ const percent = this.device.volumeToPercent(this.device.state.volume);
321
+ const levelChar = this.config.volumeControl === 'fan'
322
+ ? this.Characteristic.RotationSpeed : this.Characteristic.Brightness;
323
+ this.volumeService.getCharacteristic(levelChar).updateValue(percent);
324
+ this.volumeService.getCharacteristic(this.Characteristic.On)
325
+ .updateValue(!this.device.state.mute && this.device.state.volume > 0);
326
+ }
327
+ break;
328
+ case constants_1.ResponseCode.Mute:
329
+ this.speakerService.getCharacteristic(this.Characteristic.Mute)
330
+ .updateValue(this.device.state.mute);
331
+ if (this.volumeService) {
332
+ this.volumeService.getCharacteristic(this.Characteristic.On)
333
+ .updateValue(!this.device.state.mute && this.device.state.volume > 0);
334
+ }
335
+ break;
336
+ case constants_1.ResponseCode.Source:
337
+ this.tvService.getCharacteristic(this.Characteristic.ActiveIdentifier)
338
+ .updateValue(this.device.state.source + 1);
339
+ break;
340
+ case constants_1.ResponseCode.Preset:
341
+ this.updatePresetSwitches(this.device.state.preset);
342
+ break;
343
+ case constants_1.ResponseCode.QuietMode:
344
+ if (this.quietModeService) {
345
+ this.quietModeService.getCharacteristic(this.Characteristic.On)
346
+ .updateValue(this.device.state.quietMode);
347
+ }
348
+ break;
349
+ }
350
+ }
351
+ }
352
+ exports.LovesacAccessory = LovesacAccessory;