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.
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OUT_OF_RANGE = void 0;
4
+ exports.createDefaultState = createDefaultState;
5
+ exports.parseNotification = parseNotification;
6
+ exports.applyResponse = applyResponse;
7
+ const constants_1 = require("./constants");
8
+ // Sentinel defaults ensure applyResponse always returns changed=true on the
9
+ // first state dump, so every field gets pushed to HomeKit via updateCharacteristic.
10
+ function createDefaultState() {
11
+ return {
12
+ power: false,
13
+ volume: -1,
14
+ mute: false,
15
+ source: -1,
16
+ preset: -1,
17
+ quietMode: false,
18
+ bass: -1,
19
+ treble: -1,
20
+ centerVolume: -1,
21
+ rearVolume: -1,
22
+ balance: -1,
23
+ subwooferConnected: false,
24
+ };
25
+ }
26
+ /**
27
+ * Parse a BLE notification from the UpStream characteristic.
28
+ * Returns the response code and value, or undefined if not a standard status response.
29
+ *
30
+ * Notifications have the format: CC 05/06 AA ... <code> <value>
31
+ * The last 2 bytes are always code + value for standard status responses.
32
+ * Version and OTA responses are ignored (handled separately if needed).
33
+ */
34
+ function parseNotification(data) {
35
+ if (data.length < 4) {
36
+ return undefined;
37
+ }
38
+ // Version responses (AA 01 03 ...) have trailing bytes that look like valid
39
+ // status codes — e.g. MCU v1.71 ends with 01 47 which would be Volume=71.
40
+ // Filter them here as defense-in-depth (also filtered in LovesacDevice).
41
+ if (data.length >= 5 && data[2] === 0xAA && data[3] === 0x01 && data[4] === 0x03) {
42
+ return undefined;
43
+ }
44
+ const code = data[data.length - 2];
45
+ const value = data[data.length - 1];
46
+ // Validate code is in our known range
47
+ if (code < constants_1.ResponseCode.Volume || code > constants_1.ResponseCode.RearVolume) {
48
+ return undefined;
49
+ }
50
+ return { code, value };
51
+ }
52
+ function inRange(value, min, max) {
53
+ return value >= min && value <= max;
54
+ }
55
+ /**
56
+ * Apply a parsed response to the device state. Returns true if state changed.
57
+ * Out-of-range values are logged and ignored to guard against firmware bugs.
58
+ */
59
+ function applyResponse(state, response) {
60
+ const { code, value } = response;
61
+ switch (code) {
62
+ case constants_1.ResponseCode.Volume:
63
+ if (!inRange(value, 0, 36)) {
64
+ return exports.OUT_OF_RANGE;
65
+ }
66
+ if (state.volume === value) {
67
+ return false;
68
+ }
69
+ state.volume = value;
70
+ return true;
71
+ case constants_1.ResponseCode.CenterVolume:
72
+ if (!inRange(value, 0, 30)) {
73
+ return exports.OUT_OF_RANGE;
74
+ }
75
+ if (state.centerVolume === value) {
76
+ return false;
77
+ }
78
+ state.centerVolume = value;
79
+ return true;
80
+ case constants_1.ResponseCode.Treble:
81
+ if (!inRange(value, 0, 20)) {
82
+ return exports.OUT_OF_RANGE;
83
+ }
84
+ if (state.treble === value) {
85
+ return false;
86
+ }
87
+ state.treble = value;
88
+ return true;
89
+ case constants_1.ResponseCode.Bass:
90
+ if (!inRange(value, 0, 20)) {
91
+ return exports.OUT_OF_RANGE;
92
+ }
93
+ if (state.bass === value) {
94
+ return false;
95
+ }
96
+ state.bass = value;
97
+ return true;
98
+ case constants_1.ResponseCode.Mute: {
99
+ if (!inRange(value, 0, 1)) {
100
+ return exports.OUT_OF_RANGE;
101
+ }
102
+ const muted = value === 1;
103
+ if (state.mute === muted) {
104
+ return false;
105
+ }
106
+ state.mute = muted;
107
+ return true;
108
+ }
109
+ case constants_1.ResponseCode.QuietMode: {
110
+ if (!inRange(value, 0, 1)) {
111
+ return exports.OUT_OF_RANGE;
112
+ }
113
+ const on = value === 1;
114
+ if (state.quietMode === on) {
115
+ return false;
116
+ }
117
+ state.quietMode = on;
118
+ return true;
119
+ }
120
+ case constants_1.ResponseCode.Balance:
121
+ if (!inRange(value, 0, 100)) {
122
+ return exports.OUT_OF_RANGE;
123
+ }
124
+ if (state.balance === value) {
125
+ return false;
126
+ }
127
+ state.balance = value;
128
+ return true;
129
+ case constants_1.ResponseCode.Source:
130
+ if (!inRange(value, 0, 3)) {
131
+ return exports.OUT_OF_RANGE;
132
+ }
133
+ if (state.source === value) {
134
+ return false;
135
+ }
136
+ state.source = value;
137
+ return true;
138
+ case constants_1.ResponseCode.Power: {
139
+ if (!inRange(value, 0, 1)) {
140
+ return exports.OUT_OF_RANGE;
141
+ }
142
+ // INVERTED: 0x00 = ON, 0x01 = OFF
143
+ const on = value === 0;
144
+ if (state.power === on) {
145
+ return false;
146
+ }
147
+ state.power = on;
148
+ return true;
149
+ }
150
+ case constants_1.ResponseCode.Preset:
151
+ if (!inRange(value, 0, 3)) {
152
+ return exports.OUT_OF_RANGE;
153
+ }
154
+ if (state.preset === value) {
155
+ return false;
156
+ }
157
+ state.preset = value;
158
+ return true;
159
+ case constants_1.ResponseCode.Subwoofer: {
160
+ if (!inRange(value, 0, 1)) {
161
+ return exports.OUT_OF_RANGE;
162
+ }
163
+ const connected = value === 1;
164
+ if (state.subwooferConnected === connected) {
165
+ return false;
166
+ }
167
+ state.subwooferConnected = connected;
168
+ return true;
169
+ }
170
+ case constants_1.ResponseCode.RearVolume:
171
+ if (!inRange(value, 0, 30)) {
172
+ return exports.OUT_OF_RANGE;
173
+ }
174
+ if (state.rearVolume === value) {
175
+ return false;
176
+ }
177
+ state.rearVolume = value;
178
+ return true;
179
+ default:
180
+ return false;
181
+ }
182
+ }
183
+ /** Sentinel: applyResponse returns this for out-of-range values so the caller can log a warning. */
184
+ exports.OUT_OF_RANGE = 'out_of_range';
package/dist/scan.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone BLE scanner to find Lovesac StealthTech devices.
4
+ * Usage: npx homebridge-lovesac-stealthtech scan
5
+ * or: node dist/scan.js
6
+ */
7
+ export {};
package/dist/scan.js ADDED
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Standalone BLE scanner to find Lovesac StealthTech devices.
5
+ * Usage: npx homebridge-lovesac-stealthtech scan
6
+ * or: node dist/scan.js
7
+ */
8
+ var __importDefault = (this && this.__importDefault) || function (mod) {
9
+ return (mod && mod.__esModule) ? mod : { "default": mod };
10
+ };
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ const noble_1 = __importDefault(require("@stoprocent/noble"));
13
+ const settings_1 = require("./settings");
14
+ console.log('Scanning for Lovesac StealthTech devices (%ds)...', settings_1.BLE_SCAN_TIMEOUT / 1000);
15
+ console.log('Make sure the soundbar is powered on and no other app is connected.\n');
16
+ const found = [];
17
+ noble_1.default.on('discover', (peripheral) => {
18
+ const name = peripheral.advertisement?.localName ?? '(unnamed)';
19
+ const rssi = peripheral.rssi ?? 0;
20
+ const id = peripheral.address !== '' && peripheral.address !== 'unknown'
21
+ ? peripheral.address
22
+ : peripheral.id ?? peripheral.uuid ?? '(unknown)';
23
+ console.log(' Found: %s [%s] RSSI=%d', name, id, rssi);
24
+ found.push({ id, name, rssi });
25
+ });
26
+ const startScan = () => {
27
+ noble_1.default.startScanning([settings_1.SOFA_SERVICE_UUID_SHORT], false, (err) => {
28
+ if (err) {
29
+ console.error('Scan error:', err.message);
30
+ process.exit(1);
31
+ }
32
+ });
33
+ };
34
+ if (noble_1.default.state === 'poweredOn') {
35
+ startScan();
36
+ }
37
+ else {
38
+ noble_1.default.once('stateChange', (state) => {
39
+ if (state === 'poweredOn') {
40
+ startScan();
41
+ }
42
+ else {
43
+ console.error('Bluetooth adapter state:', state);
44
+ process.exit(1);
45
+ }
46
+ });
47
+ }
48
+ setTimeout(() => {
49
+ noble_1.default.stopScanning();
50
+ console.log('\nScan complete.');
51
+ if (found.length === 0) {
52
+ console.log('No Lovesac devices found.');
53
+ }
54
+ else {
55
+ console.log('\nAdd this to your Homebridge config:');
56
+ console.log(JSON.stringify({
57
+ platform: 'LovesacStealthTech',
58
+ devices: [{ name: found[0].name, address: found[0].id }],
59
+ }, null, 2));
60
+ }
61
+ process.exit(0);
62
+ }, settings_1.BLE_SCAN_TIMEOUT);
@@ -0,0 +1,38 @@
1
+ import type { PlatformConfig } from 'homebridge';
2
+ export declare const PLUGIN_NAME = "homebridge-lovesac-stealthtech";
3
+ export declare const PLATFORM_NAME = "LovesacStealthTech";
4
+ export declare const SOFA_SERVICE_UUID = "65786365-6c70-6f69-6e74-2e636f6d0000";
5
+ export declare const SOFA_SERVICE_UUID_SHORT = "657863656c706f696e742e636f6d0000";
6
+ export declare const CharUUID: {
7
+ readonly UpStream: "657863656c706f696e742e636f6d0001";
8
+ readonly DeviceInfo: "657863656c706f696e742e636f6d0002";
9
+ readonly EqControl: "657863656c706f696e742e636f6d0003";
10
+ readonly AudioPath: "657863656c706f696e742e636f6d0004";
11
+ readonly PlayerControl: "657863656c706f696e742e636f6d0005";
12
+ readonly SystemLayout: "657863656c706f696e742e636f6d0006";
13
+ readonly Source: "657863656c706f696e742e636f6d0007";
14
+ readonly Covering: "657863656c706f696e742e636f6d0008";
15
+ readonly UserSetting: "657863656c706f696e742e636f6d0009";
16
+ readonly OTA: "657863656c706f696e742e636f6d000a";
17
+ };
18
+ export declare const MAX_VOLUME = 36;
19
+ export declare const BLE_SCAN_TIMEOUT = 15000;
20
+ export declare function errorMessage(err: unknown): string;
21
+ export interface LovesacDeviceConfig {
22
+ name: string;
23
+ address: string;
24
+ idleTimeout: number;
25
+ pollInterval: number;
26
+ volumeControl: 'fan' | 'lightbulb' | 'none';
27
+ volumeStep: number;
28
+ presets: {
29
+ movies: boolean;
30
+ music: boolean;
31
+ tv: boolean;
32
+ news: boolean;
33
+ };
34
+ }
35
+ export interface LovesacPlatformConfig extends PlatformConfig {
36
+ devices?: Partial<LovesacDeviceConfig>[];
37
+ }
38
+ export declare function resolveDeviceConfig(raw: Partial<LovesacDeviceConfig>): LovesacDeviceConfig;
@@ -0,0 +1,44 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BLE_SCAN_TIMEOUT = exports.MAX_VOLUME = exports.CharUUID = exports.SOFA_SERVICE_UUID_SHORT = exports.SOFA_SERVICE_UUID = exports.PLATFORM_NAME = exports.PLUGIN_NAME = void 0;
4
+ exports.errorMessage = errorMessage;
5
+ exports.resolveDeviceConfig = resolveDeviceConfig;
6
+ exports.PLUGIN_NAME = 'homebridge-lovesac-stealthtech';
7
+ exports.PLATFORM_NAME = 'LovesacStealthTech';
8
+ // BLE GATT UUIDs — custom service encodes "excelpoint.com" in ASCII
9
+ exports.SOFA_SERVICE_UUID = '65786365-6c70-6f69-6e74-2e636f6d0000';
10
+ // Noble expects UUIDs without dashes, lowercase
11
+ exports.SOFA_SERVICE_UUID_SHORT = '657863656c706f696e742e636f6d0000';
12
+ exports.CharUUID = {
13
+ UpStream: '657863656c706f696e742e636f6d0001',
14
+ DeviceInfo: '657863656c706f696e742e636f6d0002',
15
+ EqControl: '657863656c706f696e742e636f6d0003',
16
+ AudioPath: '657863656c706f696e742e636f6d0004',
17
+ PlayerControl: '657863656c706f696e742e636f6d0005',
18
+ SystemLayout: '657863656c706f696e742e636f6d0006',
19
+ Source: '657863656c706f696e742e636f6d0007',
20
+ Covering: '657863656c706f696e742e636f6d0008',
21
+ UserSetting: '657863656c706f696e742e636f6d0009',
22
+ OTA: '657863656c706f696e742e636f6d000a',
23
+ };
24
+ exports.MAX_VOLUME = 36;
25
+ exports.BLE_SCAN_TIMEOUT = 15000;
26
+ function errorMessage(err) {
27
+ return err instanceof Error ? err.message : String(err);
28
+ }
29
+ function resolveDeviceConfig(raw) {
30
+ return {
31
+ name: raw.name ?? 'Lovesac StealthTech',
32
+ address: raw.address ?? '',
33
+ idleTimeout: raw.idleTimeout ?? 60,
34
+ pollInterval: raw.pollInterval ?? 90,
35
+ volumeControl: raw.volumeControl ?? 'fan',
36
+ volumeStep: raw.volumeStep ?? 2,
37
+ presets: {
38
+ movies: raw.presets?.movies ?? true,
39
+ music: raw.presets?.music ?? true,
40
+ tv: raw.presets?.tv ?? true,
41
+ news: raw.presets?.news ?? true,
42
+ },
43
+ };
44
+ }
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "homebridge-lovesac-stealthtech",
3
+ "version": "0.0.0-development",
4
+ "description": "Homebridge plugin for Lovesac StealthTech Sound + Charge BLE control",
5
+ "main": "dist/index.js",
6
+ "files": [
7
+ "dist",
8
+ "config.schema.json"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "watch": "tsc -w",
13
+ "prepublishOnly": "npm run build",
14
+ "scan": "node dist/scan.js",
15
+ "semantic-release": "semantic-release"
16
+ },
17
+ "keywords": [
18
+ "homebridge-plugin",
19
+ "lovesac",
20
+ "stealthtech",
21
+ "ble",
22
+ "bluetooth"
23
+ ],
24
+ "author": "Alex Rosenberg <alexr@leftfield.org>",
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/ohmantics/homebridge-lovesac-stealthtech.git"
29
+ },
30
+ "homepage": "https://github.com/ohmantics/homebridge-lovesac-stealthtech",
31
+ "bugs": {
32
+ "url": "https://github.com/ohmantics/homebridge-lovesac-stealthtech/issues"
33
+ },
34
+ "funding": {
35
+ "type": "github",
36
+ "url": "https://github.com/sponsors/ohmantics"
37
+ },
38
+ "engines": {
39
+ "node": "^20.9.0 || ^22 || ^24",
40
+ "homebridge": "^1.8.0 || ^2.0.0-beta.0"
41
+ },
42
+ "dependencies": {
43
+ "@stoprocent/noble": "^1.15.0"
44
+ },
45
+ "peerDependencies": {
46
+ "homebridge": "^1.8.0 || ^2.0.0-beta.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^20.11.0",
50
+ "conventional-changelog-conventionalcommits": "^8.0.0",
51
+ "homebridge": "^1.8.0",
52
+ "semantic-release": "^24.0.0",
53
+ "typescript": "^5.3.0"
54
+ },
55
+ "release": {
56
+ "branches": ["main"],
57
+ "plugins": [
58
+ [
59
+ "@semantic-release/commit-analyzer",
60
+ {
61
+ "preset": "conventionalcommits",
62
+ "releaseRules": [
63
+ {
64
+ "type": "build",
65
+ "scope": "deps",
66
+ "release": "patch"
67
+ }
68
+ ]
69
+ }
70
+ ],
71
+ [
72
+ "@semantic-release/release-notes-generator",
73
+ {
74
+ "preset": "conventionalcommits",
75
+ "presetConfig": {
76
+ "types": [
77
+ { "type": "feat", "section": "Features" },
78
+ { "type": "fix", "section": "Bug Fixes" },
79
+ { "type": "build", "section": "Dependencies", "hidden": false }
80
+ ]
81
+ }
82
+ }
83
+ ],
84
+ "@semantic-release/npm",
85
+ "@semantic-release/github"
86
+ ]
87
+ }
88
+ }