homebridge-iport-bezel 1.0.0
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 +21 -0
- package/README.md +179 -0
- package/config.schema.json +130 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +618 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry Kutergin
|
|
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,179 @@
|
|
|
1
|
+
# homebridge-iport-bezel
|
|
2
|
+
|
|
3
|
+
A [Homebridge](https://homebridge.io) platform plugin for [iPort Surface Mount Buttons](https://www.iportproducts.com/products/surface-mount-buttons/) (SM Buttons / SM Bezel — 6- and 10-button models).
|
|
4
|
+
|
|
5
|
+
Each bezel is exposed to HomeKit as a single accessory whose buttons appear as numbered `StatelessProgrammableSwitch` services. Button presses are surfaced as **single press**, **double press**, and **long press** events that can drive any HomeKit automation.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Multiple bezels in one platform — list them in `config.json`.
|
|
10
|
+
- Persistent caching of accessories across Homebridge restarts.
|
|
11
|
+
- Automatic TCP reconnect to each bezel (5 s back-off) and periodic LED-query keep-alive (60 s).
|
|
12
|
+
- Single / double / long press detection (server-side, see [Gesture timing](#gesture-timing)).
|
|
13
|
+
- HomeKit "ServiceLabel" pattern — buttons appear numbered 1…10 in the Home app and can be individually renamed.
|
|
14
|
+
- Auto-reconciliation with the bezel's auto-repeat behavior — one held button produces exactly one long-press event.
|
|
15
|
+
- Per-bezel **Lightbulb** service for full HSB color control of the LEDs.
|
|
16
|
+
- Visual press confirmation — every recognized press briefly flicks the LEDs to green (or its inverse if the LEDs are already green) so the user sees the gesture was registered.
|
|
17
|
+
- All timing thresholds (long-press, double-press window, flick durations) configurable via `config.json`.
|
|
18
|
+
|
|
19
|
+
## Requirements
|
|
20
|
+
|
|
21
|
+
- Homebridge ≥ 1.0
|
|
22
|
+
- Node.js ≥ 18
|
|
23
|
+
- iPort SM Buttons hardware from April 2016 or later (firmware V6+). The earlier "DDM" hardware is not supported (see iPort's spec).
|
|
24
|
+
- Each bezel reachable on TCP port `10001` and assigned a stable IP (DHCP reservation or static).
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
### Via Homebridge UI
|
|
29
|
+
|
|
30
|
+
Search for `homebridge-iport-bezel` in the Plugins tab (once published to npm).
|
|
31
|
+
|
|
32
|
+
### From source (git)
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone git@github.com:dmitry-kutergin/iport-homebridge.git
|
|
36
|
+
cd iport-homebridge
|
|
37
|
+
npm install
|
|
38
|
+
npm run build
|
|
39
|
+
sudo npm install -g .
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then restart Homebridge.
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
Add a platform entry to `~/.homebridge/config.json` (or use the Homebridge UI's JSON editor):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"platforms": [
|
|
51
|
+
{
|
|
52
|
+
"platform": "IportBezelPlatform",
|
|
53
|
+
"name": "iPort Bezels",
|
|
54
|
+
"longPressMs": 500,
|
|
55
|
+
"doublePressMs": 500,
|
|
56
|
+
"singleFlickMs": 300,
|
|
57
|
+
"longFlickMs": 1000,
|
|
58
|
+
"doubleFlickGapMs": 150,
|
|
59
|
+
"ips": [
|
|
60
|
+
{
|
|
61
|
+
"ip": "192.168.1.50",
|
|
62
|
+
"accessoryName": "Lobby Bezel",
|
|
63
|
+
"buttonCount": 10
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"ip": "192.168.1.51",
|
|
67
|
+
"accessoryName": "Office Bezel",
|
|
68
|
+
"buttonCount": 10
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
"ip": "192.168.1.52",
|
|
72
|
+
"accessoryName": "Living Room Bezel",
|
|
73
|
+
"buttonCount": 6
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
]
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Field reference
|
|
82
|
+
|
|
83
|
+
| Field | Required | Default | Description |
|
|
84
|
+
| --- | --- | --- | --- |
|
|
85
|
+
| `platform` | yes | — | Must be `"IportBezelPlatform"`. |
|
|
86
|
+
| `name` | yes | — | Display name for the platform (free text). |
|
|
87
|
+
| `ips` | yes | — | Array of bezel definitions. |
|
|
88
|
+
| `ips[].ip` | yes | — | LAN IP of the bezel. The bezel must be reachable on TCP `10001`. |
|
|
89
|
+
| `ips[].accessoryName` | no | `"iPort Bezel"` | HomeKit accessory name. |
|
|
90
|
+
| `ips[].buttonCount` | no | `10` | Number of buttons on the bezel (6 or 10). |
|
|
91
|
+
| `longPressMs` | no | `500` | Press-and-hold duration before a `LONG_PRESS` fires. |
|
|
92
|
+
| `doublePressMs` | no | `500` | Time window after release in which a second tap is registered as a `DOUBLE_PRESS`. Also sets the `SINGLE_PRESS` emit delay (we must wait this long after release to know it's not a double). |
|
|
93
|
+
| `singleFlickMs` | no | `300` | Duration of a single green flick (used for `SINGLE_PRESS` confirmation and each half of the double flick). |
|
|
94
|
+
| `longFlickMs` | no | `1000` | Duration of the green flick used for `LONG_PRESS` confirmation. |
|
|
95
|
+
| `doubleFlickGapMs` | no | `150` | Restore-color gap between the two flicks of a `DOUBLE_PRESS` confirmation. |
|
|
96
|
+
|
|
97
|
+
Hosting multiple bezels under a [Child Bridge](https://github.com/homebridge/homebridge/wiki/Child-Bridges) is recommended so a single misbehaving bezel can't slow the main bridge.
|
|
98
|
+
|
|
99
|
+
## Gesture timing
|
|
100
|
+
|
|
101
|
+
Per-button state machine (all thresholds configurable — see [Field reference](#field-reference)):
|
|
102
|
+
|
|
103
|
+
- **Single press** — quick tap. Emitted `doublePressMs` (default 500 ms) **after release** (the wait is required so a possible second tap can be detected as a double press).
|
|
104
|
+
- **Double press** — second tap arrives within `doublePressMs` after release of the first.
|
|
105
|
+
- **Long press** — fires the moment the hold crosses `longPressMs` (default 500 ms), then ignores everything else until you let go. One hold = one event.
|
|
106
|
+
|
|
107
|
+
The bezel auto-repeats `state:1` every ~300 ms while held; the plugin coalesces those into a single gesture.
|
|
108
|
+
|
|
109
|
+
## LED control and visual feedback
|
|
110
|
+
|
|
111
|
+
Each bezel exposes a **Lightbulb** service with On / Brightness / Hue / Saturation. The plugin converts HomeKit HSB → RGB and sends `led=#RRGGBB` to the bezel. The bezel's LED command sets all LEDs to the same color, so it is one Lightbulb per bezel rather than per button.
|
|
112
|
+
|
|
113
|
+
**At startup** the bezel is queried (`led=?`) and its reply seeds both the LedController and the HomeKit Lightbulb characteristics — so the slider in Home accurately reflects what the LEDs are physically showing on first connect. After that, HomeKit is the source of truth: subsequent bezel replies (keep-alive echoes) are ignored so they can't drift the slider. If the bezel doesn't reply within 5 s of connect, the plugin falls back to pushing HomeKit's cached value to the bezel.
|
|
114
|
+
|
|
115
|
+
Every recognized button press briefly flicks the LEDs to **green** so the user gets immediate confirmation:
|
|
116
|
+
|
|
117
|
+
| Gesture | LED flick pattern |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| Single press | `singleFlickMs` green → restore. |
|
|
120
|
+
| Double press | `singleFlickMs` green → `doubleFlickGapMs` restore → `singleFlickMs` green → restore. |
|
|
121
|
+
| Long press | `longFlickMs` green → restore. |
|
|
122
|
+
|
|
123
|
+
If the current LED color is already greenish, the flick uses the **bitwise inverse** of the current color (e.g. pure green flicks to pure magenta) so the confirmation stays visible.
|
|
124
|
+
|
|
125
|
+
## HomeKit usage
|
|
126
|
+
|
|
127
|
+
`StatelessProgrammableSwitch` services have no visible on/off state in the Home app's main UI. To use them:
|
|
128
|
+
|
|
129
|
+
1. In Home, **+ → Add Automation → An Accessory Is Controlled**.
|
|
130
|
+
2. Pick the bezel accessory; each numbered button shows up.
|
|
131
|
+
3. Choose **Single Press / Double Press / Long Press** and configure the action.
|
|
132
|
+
|
|
133
|
+
Buttons can also be renamed individually — open the accessory's settings, tap a button, edit its name. The plugin stores user-set names via the HomeKit `ConfiguredName` characteristic and they persist across restarts.
|
|
134
|
+
|
|
135
|
+
## Development
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm install
|
|
139
|
+
npm run build # compile TypeScript → dist/
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
To iterate against a real Homebridge install, link the package:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
npm link
|
|
146
|
+
# in your homebridge install dir:
|
|
147
|
+
npm link homebridge-iport-bezel
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The full TypeScript source is in `src/index.ts`.
|
|
151
|
+
|
|
152
|
+
## Logging
|
|
153
|
+
|
|
154
|
+
By default the plugin emits info-level lines for one-time events (`Registering new iPort Bezel:`, `connection restored`, `seeded from bezel: led=#…`) and warnings for connection loss. Per-press logs (`button N single/double/long pressed`) and per-step LED flick traces are emitted at **debug** level so the Homebridge log stays quiet during normal use. To see them temporarily, start Homebridge with `-D` or enable debug in the UI.
|
|
155
|
+
|
|
156
|
+
## Troubleshooting
|
|
157
|
+
|
|
158
|
+
**New services don't appear in the Home app.** iOS / macOS HomeKit aggressively caches the HAP database for paired bridges. After adding a new service (e.g., the Lightbulb), the client may not refetch the schema on its own.
|
|
159
|
+
|
|
160
|
+
1. Force-quit Home (swipe up on iPhone, ⌘Q on Mac) and reopen.
|
|
161
|
+
2. If still missing, unpair the child bridge in the Homebridge UI (`Status → Bridge → Child Bridges → ⋮ → Unpair Bridge`) and re-add it from the QR code.
|
|
162
|
+
|
|
163
|
+
**Repeated "MaxListenersExceededWarning: shutdown listeners".** Not from this plugin — usually `homebridge-camera-ui` registers many shutdown listeners. Harmless; restart with `NODE_OPTIONS=--trace-warnings` to confirm the source.
|
|
164
|
+
|
|
165
|
+
## Protocol reference
|
|
166
|
+
|
|
167
|
+
This plugin implements the JSON-over-TCP protocol described in *iPort SM Buttons API and Driver Development, Rev. G*:
|
|
168
|
+
|
|
169
|
+
- Bezel listens on **TCP 10001** as the server; the plugin is the client.
|
|
170
|
+
- On connect, the bezel sends a "connection" report (with `keys[]`) listing the current state of all buttons.
|
|
171
|
+
- On press/release, the bezel sends an "event" report with `events[]` containing `{label: "key N", state: "0"|"1"}`.
|
|
172
|
+
- The bezel auto-repeats `state:1` events every ~300 ms while a button is held — the plugin coalesces these into a single gesture.
|
|
173
|
+
- LED commands are framed as `<CR>led=…<CR>` (leading **and** trailing carriage return, per spec page 16). The plugin sends:
|
|
174
|
+
- `led=#RRGGBB` to set color (used for HomeKit changes and for the flick animation).
|
|
175
|
+
- `led=?` to query — the bezel replies in **9-digit decimal RGB** form (e.g. `led=000255000` for pure green). The parser handles both hex and decimal reply formats.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT — see [LICENSE](./LICENSE).
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"pluginAlias": "IportBezelPlatform",
|
|
3
|
+
"pluginType": "platform",
|
|
4
|
+
"singular": true,
|
|
5
|
+
"headerDisplay": "Exposes [iPort Surface Mount Buttons](https://www.iportproducts.com/products/surface-mount-buttons/) (SM Buttons / SM Bezel) to HomeKit. Each bezel becomes one accessory with numbered button services and a per-bezel Lightbulb for LED color control.",
|
|
6
|
+
"footerDisplay": "Each bezel must be reachable on TCP port `10001` and assigned a stable IP (DHCP reservation or static).",
|
|
7
|
+
"schema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"properties": {
|
|
10
|
+
"name": {
|
|
11
|
+
"title": "Platform name",
|
|
12
|
+
"type": "string",
|
|
13
|
+
"default": "iPort Bezels",
|
|
14
|
+
"required": true,
|
|
15
|
+
"description": "Display name shown in the Homebridge log."
|
|
16
|
+
},
|
|
17
|
+
"ips": {
|
|
18
|
+
"title": "Bezels",
|
|
19
|
+
"type": "array",
|
|
20
|
+
"minItems": 1,
|
|
21
|
+
"items": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"properties": {
|
|
24
|
+
"ip": {
|
|
25
|
+
"title": "IP address",
|
|
26
|
+
"type": "string",
|
|
27
|
+
"required": true,
|
|
28
|
+
"format": "ipv4",
|
|
29
|
+
"placeholder": "192.168.1.50",
|
|
30
|
+
"description": "LAN IP of this bezel. Bezel must be reachable on TCP 10001."
|
|
31
|
+
},
|
|
32
|
+
"accessoryName": {
|
|
33
|
+
"title": "Accessory name",
|
|
34
|
+
"type": "string",
|
|
35
|
+
"default": "iPort Bezel",
|
|
36
|
+
"description": "How this bezel appears in the Home app."
|
|
37
|
+
},
|
|
38
|
+
"buttonCount": {
|
|
39
|
+
"title": "Button count",
|
|
40
|
+
"type": "integer",
|
|
41
|
+
"default": 10,
|
|
42
|
+
"oneOf": [
|
|
43
|
+
{ "title": "6 buttons", "enum": [6] },
|
|
44
|
+
{ "title": "10 buttons", "enum": [10] }
|
|
45
|
+
],
|
|
46
|
+
"description": "Number of physical buttons on this bezel."
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"longPressMs": {
|
|
52
|
+
"title": "Long-press threshold (ms)",
|
|
53
|
+
"type": "integer",
|
|
54
|
+
"default": 500,
|
|
55
|
+
"minimum": 100,
|
|
56
|
+
"maximum": 5000,
|
|
57
|
+
"description": "Press-and-hold duration before a LONG_PRESS fires."
|
|
58
|
+
},
|
|
59
|
+
"doublePressMs": {
|
|
60
|
+
"title": "Double-press window (ms)",
|
|
61
|
+
"type": "integer",
|
|
62
|
+
"default": 500,
|
|
63
|
+
"minimum": 100,
|
|
64
|
+
"maximum": 2000,
|
|
65
|
+
"description": "Window after release for a second tap to register as DOUBLE_PRESS. Also the delay before SINGLE_PRESS emits."
|
|
66
|
+
},
|
|
67
|
+
"singleFlickMs": {
|
|
68
|
+
"title": "Single-press flick duration (ms)",
|
|
69
|
+
"type": "integer",
|
|
70
|
+
"default": 300,
|
|
71
|
+
"minimum": 50,
|
|
72
|
+
"maximum": 2000,
|
|
73
|
+
"description": "Length of the green LED flick on SINGLE_PRESS (also each half of the double-press flick)."
|
|
74
|
+
},
|
|
75
|
+
"longFlickMs": {
|
|
76
|
+
"title": "Long-press flick duration (ms)",
|
|
77
|
+
"type": "integer",
|
|
78
|
+
"default": 1000,
|
|
79
|
+
"minimum": 100,
|
|
80
|
+
"maximum": 5000,
|
|
81
|
+
"description": "Length of the green LED flick on LONG_PRESS."
|
|
82
|
+
},
|
|
83
|
+
"doubleFlickGapMs": {
|
|
84
|
+
"title": "Double-flick gap (ms)",
|
|
85
|
+
"type": "integer",
|
|
86
|
+
"default": 150,
|
|
87
|
+
"minimum": 0,
|
|
88
|
+
"maximum": 1000,
|
|
89
|
+
"description": "Restore-color gap between the two flicks of a DOUBLE_PRESS confirmation."
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"layout": [
|
|
94
|
+
"name",
|
|
95
|
+
{
|
|
96
|
+
"key": "ips",
|
|
97
|
+
"type": "array",
|
|
98
|
+
"title": "Bezels",
|
|
99
|
+
"buttonText": "Add bezel",
|
|
100
|
+
"items": [
|
|
101
|
+
"ips[].ip",
|
|
102
|
+
"ips[].accessoryName",
|
|
103
|
+
"ips[].buttonCount"
|
|
104
|
+
]
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"type": "fieldset",
|
|
108
|
+
"title": "Advanced — gesture timings",
|
|
109
|
+
"expandable": true,
|
|
110
|
+
"expanded": false,
|
|
111
|
+
"description": "Defaults work for most users. Adjust only if the built-in single / double / long press detection feels off.",
|
|
112
|
+
"items": [
|
|
113
|
+
"longPressMs",
|
|
114
|
+
"doublePressMs"
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"type": "fieldset",
|
|
119
|
+
"title": "Advanced — LED flick timings",
|
|
120
|
+
"expandable": true,
|
|
121
|
+
"expanded": false,
|
|
122
|
+
"description": "Length of the green LED flash used to confirm each press.",
|
|
123
|
+
"items": [
|
|
124
|
+
"singleFlickMs",
|
|
125
|
+
"longFlickMs",
|
|
126
|
+
"doubleFlickGapMs"
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ─── Protocol types ──────────────────────────────────────────────────────────
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
function parseKey(k) {
|
|
5
|
+
const m = /(\d+)/.exec(k.label || '');
|
|
6
|
+
return {
|
|
7
|
+
num: m ? parseInt(m[1], 10) : 0,
|
|
8
|
+
state: k.state === '1' ? 1 : 0,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
// ─── Color helpers ───────────────────────────────────────────────────────────
|
|
12
|
+
function hexToHsb(hex) {
|
|
13
|
+
const r = parseInt(hex.substring(0, 2), 16) / 255;
|
|
14
|
+
const g = parseInt(hex.substring(2, 4), 16) / 255;
|
|
15
|
+
const b = parseInt(hex.substring(4, 6), 16) / 255;
|
|
16
|
+
const max = Math.max(r, g, b);
|
|
17
|
+
const min = Math.min(r, g, b);
|
|
18
|
+
const d = max - min;
|
|
19
|
+
let h = 0;
|
|
20
|
+
if (d !== 0) {
|
|
21
|
+
if (max === r)
|
|
22
|
+
h = ((g - b) / d) % 6;
|
|
23
|
+
else if (max === g)
|
|
24
|
+
h = (b - r) / d + 2;
|
|
25
|
+
else
|
|
26
|
+
h = (r - g) / d + 4;
|
|
27
|
+
h *= 60;
|
|
28
|
+
if (h < 0)
|
|
29
|
+
h += 360;
|
|
30
|
+
}
|
|
31
|
+
const s = max === 0 ? 0 : (d / max) * 100;
|
|
32
|
+
const bri = max * 100;
|
|
33
|
+
return { h, s, bri };
|
|
34
|
+
}
|
|
35
|
+
function hsbToHex(h, s, b) {
|
|
36
|
+
const sn = Math.max(0, Math.min(100, s)) / 100;
|
|
37
|
+
const bn = Math.max(0, Math.min(100, b)) / 100;
|
|
38
|
+
const hh = ((h % 360) + 360) % 360;
|
|
39
|
+
const c = bn * sn;
|
|
40
|
+
const x = c * (1 - Math.abs(((hh / 60) % 2) - 1));
|
|
41
|
+
const m = bn - c;
|
|
42
|
+
let r = 0, g = 0, bl = 0;
|
|
43
|
+
if (hh < 60) {
|
|
44
|
+
r = c;
|
|
45
|
+
g = x;
|
|
46
|
+
bl = 0;
|
|
47
|
+
}
|
|
48
|
+
else if (hh < 120) {
|
|
49
|
+
r = x;
|
|
50
|
+
g = c;
|
|
51
|
+
bl = 0;
|
|
52
|
+
}
|
|
53
|
+
else if (hh < 180) {
|
|
54
|
+
r = 0;
|
|
55
|
+
g = c;
|
|
56
|
+
bl = x;
|
|
57
|
+
}
|
|
58
|
+
else if (hh < 240) {
|
|
59
|
+
r = 0;
|
|
60
|
+
g = x;
|
|
61
|
+
bl = c;
|
|
62
|
+
}
|
|
63
|
+
else if (hh < 300) {
|
|
64
|
+
r = x;
|
|
65
|
+
g = 0;
|
|
66
|
+
bl = c;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
r = c;
|
|
70
|
+
g = 0;
|
|
71
|
+
bl = x;
|
|
72
|
+
}
|
|
73
|
+
const to = (n) => Math.round((n + m) * 255).toString(16).padStart(2, '0').toUpperCase();
|
|
74
|
+
return to(r) + to(g) + to(bl);
|
|
75
|
+
}
|
|
76
|
+
function isGreenish(hex) {
|
|
77
|
+
const r = parseInt(hex.substring(0, 2), 16);
|
|
78
|
+
const g = parseInt(hex.substring(2, 4), 16);
|
|
79
|
+
const b = parseInt(hex.substring(4, 6), 16);
|
|
80
|
+
return g > 128 && g > r && g > b;
|
|
81
|
+
}
|
|
82
|
+
function invertHex(hex) {
|
|
83
|
+
const r = 255 - parseInt(hex.substring(0, 2), 16);
|
|
84
|
+
const g = 255 - parseInt(hex.substring(2, 4), 16);
|
|
85
|
+
const b = 255 - parseInt(hex.substring(4, 6), 16);
|
|
86
|
+
return [r, g, b].map(n => n.toString(16).padStart(2, '0').toUpperCase()).join('');
|
|
87
|
+
}
|
|
88
|
+
const FLICK_GREEN = '00FF00';
|
|
89
|
+
// ─── TCP Client ──────────────────────────────────────────────────────────────
|
|
90
|
+
const TCP_PORT = 10001;
|
|
91
|
+
const DELIMITER = '\r\n';
|
|
92
|
+
const RECONNECT_MS = 5000;
|
|
93
|
+
const HEALTH_MS = 60000;
|
|
94
|
+
class TcpClient {
|
|
95
|
+
constructor(host, onReconn) {
|
|
96
|
+
this.host = host;
|
|
97
|
+
this.onReconn = onReconn;
|
|
98
|
+
this.socket = null;
|
|
99
|
+
this.buf = '';
|
|
100
|
+
this.tmrReconn = null;
|
|
101
|
+
this.tmrHealth = null;
|
|
102
|
+
this.alive = false;
|
|
103
|
+
this.lastSentLed = null;
|
|
104
|
+
}
|
|
105
|
+
connect() {
|
|
106
|
+
this.tmrReconn = null;
|
|
107
|
+
const net = require('net');
|
|
108
|
+
this.socket = net.connect(TCP_PORT, this.host, () => {
|
|
109
|
+
this.alive = true;
|
|
110
|
+
this.buf = '';
|
|
111
|
+
this.startHealth();
|
|
112
|
+
this.onUp?.();
|
|
113
|
+
});
|
|
114
|
+
this.socket.on('data', (d) => {
|
|
115
|
+
this.buf += d.toString();
|
|
116
|
+
let i;
|
|
117
|
+
while ((i = this.buf.indexOf(DELIMITER)) !== -1) {
|
|
118
|
+
const line = this.buf.substring(0, i).trim();
|
|
119
|
+
this.buf = this.buf.substring(i + DELIMITER.length);
|
|
120
|
+
if (line)
|
|
121
|
+
this.parse(line);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
this.socket.on('error', () => this.crash('sock err'));
|
|
125
|
+
this.socket.on('close', () => this.crash('closed'));
|
|
126
|
+
}
|
|
127
|
+
destroy() {
|
|
128
|
+
this.killHealth();
|
|
129
|
+
if (this.tmrReconn) {
|
|
130
|
+
clearTimeout(this.tmrReconn);
|
|
131
|
+
this.tmrReconn = null;
|
|
132
|
+
}
|
|
133
|
+
if (this.socket) {
|
|
134
|
+
this.socket.destroy();
|
|
135
|
+
this.socket = null;
|
|
136
|
+
}
|
|
137
|
+
this.alive = false;
|
|
138
|
+
}
|
|
139
|
+
writeLed(hex) {
|
|
140
|
+
if (!this.alive || !this.socket)
|
|
141
|
+
return;
|
|
142
|
+
// Per spec page 16, LED commands are framed as <CR>led=...<CR>.
|
|
143
|
+
// Without the leading CR the bezel processes the first command of a
|
|
144
|
+
// connection but ignores back-to-back commands (the second one is appended
|
|
145
|
+
// to the trailing-CR state of the first).
|
|
146
|
+
this.socket.write(`\rled=#${hex}\r`);
|
|
147
|
+
this.lastSentLed = hex;
|
|
148
|
+
}
|
|
149
|
+
queryLed() {
|
|
150
|
+
if (!this.alive || !this.socket)
|
|
151
|
+
return;
|
|
152
|
+
this.socket.write('\rled=?\r');
|
|
153
|
+
}
|
|
154
|
+
// ── internals ─────────────────────────────────────────────────────────────
|
|
155
|
+
// Per SM Buttons API spec (Rev. G, page 13): reports have no `type` field.
|
|
156
|
+
// A connection report carries `keys[]`; an event report carries `events[]`.
|
|
157
|
+
// Each entry is {label: "key N", state: "0"|"1"}.
|
|
158
|
+
// Non-JSON lines are LED responses (either "led=#RRGGBB" hex or
|
|
159
|
+
// "led=RRRGGGBBB" 9-digit decimal per spec page 16).
|
|
160
|
+
parse(line) {
|
|
161
|
+
let r;
|
|
162
|
+
try {
|
|
163
|
+
r = JSON.parse(line);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Per spec page 16, the bezel uses two LED formats:
|
|
167
|
+
// hex : led=#1791EF
|
|
168
|
+
// decimal : led=023145239 (9-digit, 3+3+3 zero-padded RGB)
|
|
169
|
+
// Match the hex form (with required '#') OR the 9-digit decimal form
|
|
170
|
+
// explicitly. The previous regex matched 6 hex chars first and would
|
|
171
|
+
// mis-parse "led=000255000" as hex 0x000255 instead of decimal R=0/G=255/B=0.
|
|
172
|
+
const m = /led\s*=\s*(?:#([0-9A-Fa-f]{6})|(\d{9}))/.exec(line);
|
|
173
|
+
if (m) {
|
|
174
|
+
let hex;
|
|
175
|
+
if (m[1]) {
|
|
176
|
+
hex = m[1].toUpperCase();
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
const p = m[2];
|
|
180
|
+
const r2 = parseInt(p.substring(0, 3), 10);
|
|
181
|
+
const g2 = parseInt(p.substring(3, 6), 10);
|
|
182
|
+
const b2 = parseInt(p.substring(6, 9), 10);
|
|
183
|
+
hex = [r2, g2, b2].map(n => Math.min(255, n).toString(16).padStart(2, '0')).join('').toUpperCase();
|
|
184
|
+
}
|
|
185
|
+
this.onLed?.(hex);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (r.events && r.events.length) {
|
|
190
|
+
this.onEvent?.(r.events.map(parseKey));
|
|
191
|
+
}
|
|
192
|
+
else if (r.keys && r.keys.length) {
|
|
193
|
+
this.onKeys?.(r.keys.map(parseKey));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
crash(reason) {
|
|
197
|
+
this.alive = false;
|
|
198
|
+
this.killHealth();
|
|
199
|
+
if (this.socket) {
|
|
200
|
+
this.socket.destroy();
|
|
201
|
+
this.socket = null;
|
|
202
|
+
}
|
|
203
|
+
this.onLost?.();
|
|
204
|
+
this.tmrReconn = setTimeout(() => {
|
|
205
|
+
this.onReconn();
|
|
206
|
+
this.connect();
|
|
207
|
+
}, RECONNECT_MS);
|
|
208
|
+
this.tmrReconn.unref?.();
|
|
209
|
+
}
|
|
210
|
+
startHealth() {
|
|
211
|
+
this.killHealth();
|
|
212
|
+
this.tmrHealth = setInterval(() => {
|
|
213
|
+
if (!this.alive)
|
|
214
|
+
this.crash('health timeout');
|
|
215
|
+
else
|
|
216
|
+
this.queryLed();
|
|
217
|
+
}, HEALTH_MS);
|
|
218
|
+
this.tmrHealth.unref?.();
|
|
219
|
+
}
|
|
220
|
+
killHealth() {
|
|
221
|
+
if (this.tmrHealth) {
|
|
222
|
+
clearInterval(this.tmrHealth);
|
|
223
|
+
this.tmrHealth = null;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const DEFAULT_TIMINGS = {
|
|
228
|
+
longPressMs: 500,
|
|
229
|
+
doublePressMs: 500,
|
|
230
|
+
singleFlickMs: 300,
|
|
231
|
+
longFlickMs: 1000,
|
|
232
|
+
doubleFlickGapMs: 150,
|
|
233
|
+
};
|
|
234
|
+
class LedController {
|
|
235
|
+
constructor(tcp, timings, log) {
|
|
236
|
+
this.tcp = tcp;
|
|
237
|
+
this.timings = timings;
|
|
238
|
+
this.log = log;
|
|
239
|
+
this.h = 0;
|
|
240
|
+
this.s = 0;
|
|
241
|
+
this.bri = 100;
|
|
242
|
+
this.on = true;
|
|
243
|
+
this.applyTimer = null;
|
|
244
|
+
this.flickTimer = null;
|
|
245
|
+
this.flicking = false;
|
|
246
|
+
}
|
|
247
|
+
setHue(v) { this.h = v; this.schedule(); }
|
|
248
|
+
setSaturation(v) { this.s = v; this.schedule(); }
|
|
249
|
+
setBrightness(v) { this.bri = v; this.schedule(); }
|
|
250
|
+
setOn(v) { this.on = !!v; this.schedule(); }
|
|
251
|
+
// Push the current HSB/on state to the bezel right away (no debounce).
|
|
252
|
+
applyImmediate() {
|
|
253
|
+
if (this.applyTimer) {
|
|
254
|
+
clearTimeout(this.applyTimer);
|
|
255
|
+
this.applyTimer = null;
|
|
256
|
+
}
|
|
257
|
+
if (!this.flicking)
|
|
258
|
+
this.tcp.writeLed(this.userHex());
|
|
259
|
+
}
|
|
260
|
+
// Sync internal state from a hex value the bezel reported (no write back).
|
|
261
|
+
// Returns the HSB so callers can mirror to HomeKit characteristics.
|
|
262
|
+
applyFromBezel(hex) {
|
|
263
|
+
if (this.applyTimer) {
|
|
264
|
+
clearTimeout(this.applyTimer);
|
|
265
|
+
this.applyTimer = null;
|
|
266
|
+
}
|
|
267
|
+
const on = hex.toUpperCase() !== '000000';
|
|
268
|
+
const hsb = hexToHsb(hex);
|
|
269
|
+
this.on = on;
|
|
270
|
+
this.h = hsb.h;
|
|
271
|
+
this.s = hsb.s;
|
|
272
|
+
this.bri = on ? hsb.bri : this.bri; // keep last bri when "off" so toggling back restores it
|
|
273
|
+
return { on, h: hsb.h, s: hsb.s, bri: hsb.bri };
|
|
274
|
+
}
|
|
275
|
+
flick(pattern) {
|
|
276
|
+
if (this.flickTimer) {
|
|
277
|
+
clearTimeout(this.flickTimer);
|
|
278
|
+
this.flickTimer = null;
|
|
279
|
+
}
|
|
280
|
+
const orig = this.userHex();
|
|
281
|
+
const accent = isGreenish(orig) ? invertHex(orig) : FLICK_GREEN;
|
|
282
|
+
const single = this.timings.singleFlickMs;
|
|
283
|
+
const long = this.timings.longFlickMs;
|
|
284
|
+
const gap = this.timings.doubleFlickGapMs;
|
|
285
|
+
let steps;
|
|
286
|
+
switch (pattern) {
|
|
287
|
+
case 'single':
|
|
288
|
+
steps = [{ hex: accent, ms: single }, { hex: orig, ms: 0 }];
|
|
289
|
+
break;
|
|
290
|
+
case 'double':
|
|
291
|
+
steps = [
|
|
292
|
+
{ hex: accent, ms: single }, { hex: orig, ms: gap },
|
|
293
|
+
{ hex: accent, ms: single }, { hex: orig, ms: 0 },
|
|
294
|
+
];
|
|
295
|
+
break;
|
|
296
|
+
case 'long':
|
|
297
|
+
steps = [{ hex: accent, ms: long }, { hex: orig, ms: 0 }];
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
this.flicking = true;
|
|
301
|
+
this.runFlick(steps);
|
|
302
|
+
}
|
|
303
|
+
schedule() {
|
|
304
|
+
if (this.applyTimer)
|
|
305
|
+
clearTimeout(this.applyTimer);
|
|
306
|
+
this.applyTimer = setTimeout(() => { this.applyTimer = null; this.apply(); }, 50);
|
|
307
|
+
this.applyTimer.unref?.();
|
|
308
|
+
}
|
|
309
|
+
apply() {
|
|
310
|
+
if (!this.flicking)
|
|
311
|
+
this.tcp.writeLed(this.userHex());
|
|
312
|
+
}
|
|
313
|
+
userHex() {
|
|
314
|
+
return this.on ? hsbToHex(this.h, this.s, this.bri) : '000000';
|
|
315
|
+
}
|
|
316
|
+
runFlick(steps) {
|
|
317
|
+
if (steps.length === 0) {
|
|
318
|
+
this.flicking = false;
|
|
319
|
+
this.flickTimer = null;
|
|
320
|
+
this.tcp.writeLed(this.userHex());
|
|
321
|
+
this.log?.debug(`flick done → restore ${this.userHex()}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const [head, ...tail] = steps;
|
|
325
|
+
this.tcp.writeLed(head.hex);
|
|
326
|
+
this.log?.debug(`flick step → ${head.hex} (next in ${head.ms}ms, ${tail.length} step(s) remaining)`);
|
|
327
|
+
if (tail.length === 0) {
|
|
328
|
+
this.flicking = false;
|
|
329
|
+
this.flickTimer = null;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.flickTimer = setTimeout(() => this.runFlick(tail), head.ms);
|
|
333
|
+
this.flickTimer.unref?.();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
const PLUGIN_NAME = 'homebridge-iport-bezel';
|
|
337
|
+
const PLATFORM_NAME = 'IportBezelPlatform';
|
|
338
|
+
class IportBezelPlatform {
|
|
339
|
+
constructor(log, config, api) {
|
|
340
|
+
this.log = log;
|
|
341
|
+
this.config = config;
|
|
342
|
+
this.api = api;
|
|
343
|
+
this.cached = new Map();
|
|
344
|
+
// Runtime state kept off `accessory.context` so Homebridge's JSON
|
|
345
|
+
// serialization of the cache doesn't choke on TCP sockets / Timeout objects.
|
|
346
|
+
this.runtime = new Map();
|
|
347
|
+
const cfg = config;
|
|
348
|
+
const num = (key) => {
|
|
349
|
+
const v = cfg[key];
|
|
350
|
+
const n = typeof v === 'number' ? v : typeof v === 'string' ? parseInt(v, 10) : NaN;
|
|
351
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_TIMINGS[key];
|
|
352
|
+
};
|
|
353
|
+
this.timings = {
|
|
354
|
+
longPressMs: num('longPressMs'),
|
|
355
|
+
doublePressMs: num('doublePressMs'),
|
|
356
|
+
singleFlickMs: num('singleFlickMs'),
|
|
357
|
+
longFlickMs: num('longFlickMs'),
|
|
358
|
+
doubleFlickGapMs: num('doubleFlickGapMs'),
|
|
359
|
+
};
|
|
360
|
+
this.log.debug(`Timings: ${JSON.stringify(this.timings)}`);
|
|
361
|
+
this.api.on('didFinishLaunching', () => this.discoverDevices());
|
|
362
|
+
}
|
|
363
|
+
configureAccessory(acc) {
|
|
364
|
+
this.cached.set(acc.UUID, acc);
|
|
365
|
+
}
|
|
366
|
+
discoverDevices() {
|
|
367
|
+
const ips = this.config.ips || [];
|
|
368
|
+
const seen = new Set();
|
|
369
|
+
for (const ipCfg of ips) {
|
|
370
|
+
if (!ipCfg.ip) {
|
|
371
|
+
this.log.warn('Skipping iPort config entry with no ip');
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const uuid = this.api.hap.uuid.generate(`iport-bezel-${ipCfg.ip}`);
|
|
375
|
+
seen.add(uuid);
|
|
376
|
+
const existing = this.cached.get(uuid);
|
|
377
|
+
if (existing) {
|
|
378
|
+
this.log.info(`Restoring cached iPort Bezel: ${existing.displayName} (${ipCfg.ip})`);
|
|
379
|
+
this.configureBezel(existing, ipCfg, false);
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
const name = ipCfg.accessoryName || 'iPort Bezel';
|
|
383
|
+
this.log.info(`Registering new iPort Bezel: ${name} (${ipCfg.ip})`);
|
|
384
|
+
const acc = new this.api.platformAccessory(name, uuid);
|
|
385
|
+
this.configureBezel(acc, ipCfg, true);
|
|
386
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// Remove cached accessories that are no longer in config.
|
|
390
|
+
for (const [uuid, acc] of this.cached) {
|
|
391
|
+
if (!seen.has(uuid)) {
|
|
392
|
+
this.log.info(`Removing stale iPort Bezel from cache: ${acc.displayName}`);
|
|
393
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [acc]);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// ── wire up services + TCP for one accessory ────────────────────────────
|
|
398
|
+
configureBezel(acc, cfg, fresh) {
|
|
399
|
+
const hap = this.api.hap;
|
|
400
|
+
const ip = cfg.ip;
|
|
401
|
+
const name = cfg.accessoryName || acc.displayName || 'iPort Bezel';
|
|
402
|
+
const count = cfg.buttonCount || 10;
|
|
403
|
+
// Only plain-serializable values may live in acc.context — Homebridge
|
|
404
|
+
// JSON-stringifies it for cachedAccessories.
|
|
405
|
+
acc.context.ip = ip;
|
|
406
|
+
acc.context.buttonCount = count;
|
|
407
|
+
// Tear down any previous runtime for this UUID (re-discovery).
|
|
408
|
+
const prev = this.runtime.get(acc.UUID);
|
|
409
|
+
if (prev)
|
|
410
|
+
prev.tcp.destroy();
|
|
411
|
+
// ServiceLabel makes the Home app present sub-services as numbered buttons
|
|
412
|
+
// (canonical HomeKit pattern for multi-button accessories).
|
|
413
|
+
let labelSvc = acc.getService(hap.Service.ServiceLabel);
|
|
414
|
+
if (!labelSvc)
|
|
415
|
+
labelSvc = acc.addService(hap.Service.ServiceLabel);
|
|
416
|
+
labelSvc.setCharacteristic(hap.Characteristic.ServiceLabelNamespace, hap.Characteristic.ServiceLabelNamespace.ARABIC_NUMERALS);
|
|
417
|
+
const services = [];
|
|
418
|
+
for (let i = 1; i <= count; i++) {
|
|
419
|
+
const subtype = `btn${i}`;
|
|
420
|
+
const label = `${name} - Btn ${i}`;
|
|
421
|
+
let svc = acc.getServiceById(hap.Service.StatelessProgrammableSwitch, subtype);
|
|
422
|
+
if (!svc) {
|
|
423
|
+
svc = acc.addService(hap.Service.StatelessProgrammableSwitch, label, subtype);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
svc.setCharacteristic(hap.Characteristic.Name, label);
|
|
427
|
+
}
|
|
428
|
+
// Numbered button index in the Home app.
|
|
429
|
+
svc.setCharacteristic(hap.Characteristic.ServiceLabelIndex, i);
|
|
430
|
+
// ConfiguredName lets the user rename each button individually in Home.
|
|
431
|
+
// Declare it optional first to silence the "not in optional section" warning.
|
|
432
|
+
svc.addOptionalCharacteristic(hap.Characteristic.ConfiguredName);
|
|
433
|
+
if (!svc.testCharacteristic(hap.Characteristic.ConfiguredName)
|
|
434
|
+
|| !svc.getCharacteristic(hap.Characteristic.ConfiguredName).value) {
|
|
435
|
+
svc.setCharacteristic(hap.Characteristic.ConfiguredName, label);
|
|
436
|
+
}
|
|
437
|
+
services.push(svc);
|
|
438
|
+
}
|
|
439
|
+
// Drop any leftover button services beyond `count` from previous configs.
|
|
440
|
+
const keep = new Set(services);
|
|
441
|
+
for (const svc of acc.services.slice()) {
|
|
442
|
+
if (svc.UUID === hap.Service.StatelessProgrammableSwitch.UUID && !keep.has(svc)) {
|
|
443
|
+
acc.removeService(svc);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// ── Lightbulb (LED color) ───────────────────────────────────────────────
|
|
447
|
+
const bulbName = `${name} LEDs`;
|
|
448
|
+
let bulb = acc.getService(hap.Service.Lightbulb);
|
|
449
|
+
let bulbJustCreated = false;
|
|
450
|
+
if (!bulb) {
|
|
451
|
+
bulb = acc.addService(hap.Service.Lightbulb, bulbName, 'leds');
|
|
452
|
+
bulbJustCreated = true;
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
bulb.setCharacteristic(hap.Characteristic.Name, bulbName);
|
|
456
|
+
}
|
|
457
|
+
bulb.addOptionalCharacteristic(hap.Characteristic.ConfiguredName);
|
|
458
|
+
if (!bulb.testCharacteristic(hap.Characteristic.ConfiguredName)
|
|
459
|
+
|| !bulb.getCharacteristic(hap.Characteristic.ConfiguredName).value) {
|
|
460
|
+
bulb.setCharacteristic(hap.Characteristic.ConfiguredName, bulbName);
|
|
461
|
+
}
|
|
462
|
+
// Ensure HSB characteristics exist on the Lightbulb service.
|
|
463
|
+
for (const ch of [hap.Characteristic.Brightness, hap.Characteristic.Hue, hap.Characteristic.Saturation]) {
|
|
464
|
+
if (!bulb.testCharacteristic(ch))
|
|
465
|
+
bulb.addCharacteristic(ch);
|
|
466
|
+
}
|
|
467
|
+
// Initialize sensible defaults exactly once per accessory. The flag lives
|
|
468
|
+
// in acc.context so it survives Homebridge restarts. Without this, freshly
|
|
469
|
+
// added (or previously bezel-mirrored, now-corrupted) Lightbulbs default
|
|
470
|
+
// to off / bri=0 / random hue.
|
|
471
|
+
if (bulbJustCreated || !acc.context.lightbulbInitialized) {
|
|
472
|
+
this.log.info(`[${ip}] initializing Lightbulb to white at 100% on`);
|
|
473
|
+
bulb.setCharacteristic(hap.Characteristic.On, true);
|
|
474
|
+
bulb.setCharacteristic(hap.Characteristic.Brightness, 100);
|
|
475
|
+
bulb.setCharacteristic(hap.Characteristic.Hue, 0);
|
|
476
|
+
bulb.setCharacteristic(hap.Characteristic.Saturation, 0);
|
|
477
|
+
acc.context.lightbulbInitialized = true;
|
|
478
|
+
}
|
|
479
|
+
const tcp = new TcpClient(ip, () => { });
|
|
480
|
+
const led = new LedController(tcp, this.timings, { debug: (m) => this.log.debug(`[${ip}] ${m}`) });
|
|
481
|
+
const btnState = Array.from({ length: count }, () => ({ held: false, consumed: false, singleTimer: null, longTimer: null }));
|
|
482
|
+
const rt = { services, btnState, tcp, led };
|
|
483
|
+
this.runtime.set(acc.UUID, rt);
|
|
484
|
+
// On first connect, the bezel is the source of truth: we send led=? and
|
|
485
|
+
// mirror the reply into both LedController and HomeKit. Subsequent led=
|
|
486
|
+
// responses (periodic keep-alives, echoes from our own writes) are ignored
|
|
487
|
+
// so they don't drift the slider.
|
|
488
|
+
let seededFromBezel = false;
|
|
489
|
+
tcp.onLed = (hex) => {
|
|
490
|
+
if (seededFromBezel) {
|
|
491
|
+
this.log.debug(`[${ip}] bezel led=#${hex} (post-seed, ignored)`);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
seededFromBezel = true;
|
|
495
|
+
const { on, h, s, bri } = led.applyFromBezel(hex);
|
|
496
|
+
this.log.info(`[${ip}] seeded from bezel: led=#${hex} (on=${on}, h=${h.toFixed(0)}, s=${s.toFixed(0)}, bri=${bri.toFixed(0)})`);
|
|
497
|
+
bulb.updateCharacteristic(hap.Characteristic.On, on);
|
|
498
|
+
if (on) {
|
|
499
|
+
bulb.updateCharacteristic(hap.Characteristic.Hue, h);
|
|
500
|
+
bulb.updateCharacteristic(hap.Characteristic.Saturation, s);
|
|
501
|
+
bulb.updateCharacteristic(hap.Characteristic.Brightness, Math.max(1, Math.round(bri)));
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
// Seed LedController provisionally from HomeKit cache. If the bezel
|
|
505
|
+
// responds to our query in time, applyFromBezel will overwrite this.
|
|
506
|
+
const seedOn = !!bulb.getCharacteristic(hap.Characteristic.On).value;
|
|
507
|
+
const seedHue = Number(bulb.getCharacteristic(hap.Characteristic.Hue).value ?? 0);
|
|
508
|
+
const seedSat = Number(bulb.getCharacteristic(hap.Characteristic.Saturation).value ?? 0);
|
|
509
|
+
const seedBri = Number(bulb.getCharacteristic(hap.Characteristic.Brightness).value ?? 100);
|
|
510
|
+
led.setOn(seedOn);
|
|
511
|
+
led.setHue(seedHue);
|
|
512
|
+
led.setSaturation(seedSat);
|
|
513
|
+
led.setBrightness(seedBri);
|
|
514
|
+
bulb.getCharacteristic(hap.Characteristic.On)
|
|
515
|
+
.onSet((v) => led.setOn(!!v));
|
|
516
|
+
bulb.getCharacteristic(hap.Characteristic.Brightness)
|
|
517
|
+
.onSet((v) => led.setBrightness(Number(v)));
|
|
518
|
+
bulb.getCharacteristic(hap.Characteristic.Hue)
|
|
519
|
+
.onSet((v) => led.setHue(Number(v)));
|
|
520
|
+
bulb.getCharacteristic(hap.Characteristic.Saturation)
|
|
521
|
+
.onSet((v) => led.setSaturation(Number(v)));
|
|
522
|
+
const PSE = hap.Characteristic.ProgrammableSwitchEvent;
|
|
523
|
+
// The bezel auto-repeats state:1 every ~300ms while held. Gesture rules:
|
|
524
|
+
// - First state:1: start hold; schedule LONG_PRESS at LONG_PRESS_MS.
|
|
525
|
+
// - LONG_PRESS fires: mark `consumed`; ignore every subsequent state:1
|
|
526
|
+
// and the eventual state:0 (no further events for this hold).
|
|
527
|
+
// - state:1 while still held (auto-repeat): ignore.
|
|
528
|
+
// - state:0 (release):
|
|
529
|
+
// • if not consumed: short tap → schedule SINGLE_PRESS at DOUBLE_PRESS_MS.
|
|
530
|
+
// • if a 2nd state:1 arrives in that window → DOUBLE_PRESS, consume,
|
|
531
|
+
// and ignore the upcoming release.
|
|
532
|
+
tcp.onEvent = (events) => {
|
|
533
|
+
for (const evt of events) {
|
|
534
|
+
const idx = evt.num - 1;
|
|
535
|
+
const btn = rt.services[idx];
|
|
536
|
+
const st = rt.btnState[idx];
|
|
537
|
+
if (!btn || !st) {
|
|
538
|
+
this.log.debug(`${ip} event for unknown button ${evt.num}`);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
if (evt.state === 1) {
|
|
542
|
+
if (st.held)
|
|
543
|
+
continue; // auto-repeat while still pressed — ignore
|
|
544
|
+
st.held = true;
|
|
545
|
+
if (st.singleTimer) {
|
|
546
|
+
// 2nd distinct press within the double-press window → DOUBLE.
|
|
547
|
+
clearTimeout(st.singleTimer);
|
|
548
|
+
st.singleTimer = null;
|
|
549
|
+
st.consumed = true; // don't fire anything on this press's release
|
|
550
|
+
this.log.debug(`${ip} button ${evt.num} double pressed`);
|
|
551
|
+
btn.updateCharacteristic(PSE, PSE.DOUBLE_PRESS);
|
|
552
|
+
rt.led.flick('double');
|
|
553
|
+
}
|
|
554
|
+
else {
|
|
555
|
+
// First press of a new gesture — schedule LONG_PRESS at longPressMs.
|
|
556
|
+
st.longTimer = setTimeout(() => {
|
|
557
|
+
st.longTimer = null;
|
|
558
|
+
st.consumed = true; // ignore auto-repeats and release until next gesture
|
|
559
|
+
this.log.debug(`${ip} button ${evt.num} long pressed`);
|
|
560
|
+
btn.updateCharacteristic(PSE, PSE.LONG_PRESS);
|
|
561
|
+
rt.led.flick('long');
|
|
562
|
+
}, this.timings.longPressMs);
|
|
563
|
+
st.longTimer.unref?.();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
// Release.
|
|
568
|
+
st.held = false;
|
|
569
|
+
if (st.longTimer) {
|
|
570
|
+
clearTimeout(st.longTimer);
|
|
571
|
+
st.longTimer = null;
|
|
572
|
+
}
|
|
573
|
+
if (st.consumed) {
|
|
574
|
+
st.consumed = false; // gesture already emitted — reset for next press
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
// Short tap — wait doublePressMs for a possible 2nd press before firing SINGLE.
|
|
578
|
+
st.singleTimer = setTimeout(() => {
|
|
579
|
+
st.singleTimer = null;
|
|
580
|
+
this.log.debug(`${ip} button ${evt.num} single pressed`);
|
|
581
|
+
btn.updateCharacteristic(PSE, PSE.SINGLE_PRESS);
|
|
582
|
+
rt.led.flick('single');
|
|
583
|
+
}, this.timings.doublePressMs);
|
|
584
|
+
st.singleTimer.unref?.();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
tcp.onKeys = () => { };
|
|
589
|
+
tcp.onUp = () => {
|
|
590
|
+
this.log.info(`${ip} connection restored`);
|
|
591
|
+
if (!seededFromBezel) {
|
|
592
|
+
// First connect after Homebridge start — read the bezel.
|
|
593
|
+
tcp.queryLed();
|
|
594
|
+
// Fallback: if no reply in 5s, lock the seed flag and push HomeKit's
|
|
595
|
+
// value so the bezel reflects the cached UI state.
|
|
596
|
+
setTimeout(() => {
|
|
597
|
+
if (!seededFromBezel) {
|
|
598
|
+
seededFromBezel = true;
|
|
599
|
+
this.log.warn(`[${ip}] bezel did not reply to led=?; pushing HomeKit state`);
|
|
600
|
+
rt.led.applyImmediate();
|
|
601
|
+
}
|
|
602
|
+
}, 5000).unref?.();
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// Reconnect: push HomeKit's current state in case the bezel rebooted.
|
|
606
|
+
rt.led.applyImmediate();
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
tcp.onLost = () => { this.log.warn(`${ip} connection lost`); };
|
|
610
|
+
tcp.connect();
|
|
611
|
+
if (!fresh)
|
|
612
|
+
this.api.updatePlatformAccessories([acc]);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// ─── Entry Point ─────────────────────────────────────────────────────────────
|
|
616
|
+
exports.default = (api) => {
|
|
617
|
+
api.registerPlatform('homebridge-iport-bezel', 'IportBezelPlatform', IportBezelPlatform);
|
|
618
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "homebridge-iport-bezel",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Homebridge platform plugin for iPort Surface Mount Buttons (SM Buttons / SM Bezel) — 6 and 10 button models. Exposes each button as a HomeKit stateless switch and the LEDs as a Lightbulb with full HSB color control.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"config.schema.json",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@types/node": "^20.0.0",
|
|
14
|
+
"homebridge": "^1.0.0",
|
|
15
|
+
"rimraf": "^3.0.2",
|
|
16
|
+
"typescript": "^5.0.0"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"homebridge": ">=1.0.0",
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "rimraf ./dist && tsc",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/dmitry-kutergin/iport-homebridge.git"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"iPort",
|
|
32
|
+
"iPort SM",
|
|
33
|
+
"iPort Bezel",
|
|
34
|
+
"SM Buttons",
|
|
35
|
+
"Homebridge",
|
|
36
|
+
"Home Automation",
|
|
37
|
+
"HomeKit",
|
|
38
|
+
"homebridge-plugin"
|
|
39
|
+
],
|
|
40
|
+
"author": "Dmitry Kutergin",
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/dmitry-kutergin/iport-homebridge/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/dmitry-kutergin/iport-homebridge#readme"
|
|
46
|
+
}
|