homebridge-openclaw 2.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Davide Vargas P.
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,398 @@
1
+ # homebridge-openclaw
2
+
3
+ **EN** — Homebridge plugin that exposes a simplified REST API so an [OpenClaw](https://docs.openclaw.ai) agent can list and control HomeKit devices.
4
+
5
+ **ES** — Plugin para Homebridge que expone una API REST simplificada para que un agente de [OpenClaw](https://docs.openclaw.ai) pueda listar y controlar dispositivos HomeKit.
6
+
7
+ ---
8
+
9
+ - [English](#english)
10
+ - [Español](#español)
11
+
12
+ ---
13
+
14
+ ## English
15
+
16
+ ### Requirements
17
+
18
+ - **homebridge-config-ui-x** installed (included in the official Docker image).
19
+ - Homebridge started with the **`-I`** (insecure) flag so the UI can read/write characteristics.
20
+
21
+ ### Installation
22
+
23
+ ```bash
24
+ npm install homebridge-openclaw
25
+ ```
26
+
27
+ Or via **Homebridge Config UI X**: Plugins → search “openclaw” → Install.
28
+
29
+ ### Minimum configuration
30
+
31
+ Add to your Homebridge `config.json` under **`accessories`**:
32
+
33
+ ```json
34
+ {
35
+ "accessory": "OpenClawAPI",
36
+ "name": "OpenClaw API"
37
+ }
38
+ ```
39
+
40
+ That’s it. The plugin will:
41
+
42
+ - Detect the UI credentials automatically (reads `.uix-secrets` and `auth.json` from the filesystem).
43
+ - Generate a unique API token and save it to `.openclaw-token`.
44
+ - Listen on port 8899.
45
+
46
+ ### Advanced configuration
47
+
48
+ ```json
49
+ {
50
+ "accessory": "OpenClawAPI",
51
+ "name": "OpenClaw API",
52
+ "apiPort": 8899,
53
+ "apiBind": "0.0.0.0",
54
+ "token": "my-custom-token",
55
+ "rateLimit": 100,
56
+ "homebridgeUiUrl": "http://localhost:8581",
57
+ "homebridgeUiUser": "admin",
58
+ "homebridgeUiPass": "admin"
59
+ }
60
+ ```
61
+
62
+ | Parameter | Type | Default | Description |
63
+ |-----------|------|---------|-------------|
64
+ | `apiPort` | number | 8899 | REST server port |
65
+ | `apiBind` | string | `0.0.0.0` | Bind address (`127.0.0.1` = local only) |
66
+ | `token` | string | auto | Bearer token for OpenClaw API calls |
67
+ | `rateLimit` | number | 100 | Max requests per minute per IP |
68
+ | `homebridgeUiUrl` | string | `http://localhost:8581` | Config UI X URL (only if not default) |
69
+ | `homebridgeUiUser` | string | auto | UI username (only if auto-detection fails) |
70
+ | `homebridgeUiPass` | string | auto | UI password (only if auto-detection fails) |
71
+
72
+ ### Security
73
+
74
+ **Internal auth (plugin → Config UI X)**
75
+ The plugin reads Homebridge internal files (`.uix-secrets` and `auth.json`) to sign valid JWTs. **No username or password is required in `config.json`.**
76
+ Only if those files are unavailable (e.g. non-Docker setups), `homebridgeUiUser` / `homebridgeUiPass` are used as fallback.
77
+
78
+ **API token (OpenClaw → plugin)**
79
+ Resolved in this order:
80
+
81
+ 1. **Environment variable** `OPENCLAW_HB_TOKEN` — ideal for Docker Compose / Kubernetes.
82
+ 2. **File** `.openclaw-token` in Homebridge storage — ideal if OpenClaw has filesystem access (same NAS, shared volume).
83
+ 3. **`token`** in `config.json` — manual fallback.
84
+ 4. **Auto-generated** — if none of the above exist, a unique token is generated (HMAC of Homebridge secretKey), saved to `.openclaw-token`, and printed in the logs.
85
+
86
+ **Rate limiting**
87
+ Default: 100 requests per minute per IP. Configurable via `rateLimit`.
88
+
89
+ ### Getting the token for OpenClaw
90
+
91
+ **Option A: Read the file (recommended)**
92
+ After first start, the token is in:
93
+
94
+ ```
95
+ /var/lib/homebridge/.openclaw-token
96
+ ```
97
+
98
+ If using Docker with a mounted volume (e.g. `/Volumes/docker/HomeBridge`):
99
+
100
+ ```bash
101
+ cat /Volumes/docker/HomeBridge/.openclaw-token
102
+ ```
103
+
104
+ **Option B: Check the logs**
105
+ On first start, the token is printed in the Homebridge logs:
106
+
107
+ ```
108
+ [homebridge-openclaw] ────────────────────────────────────────
109
+ [homebridge-openclaw] API Token: abc123...
110
+ [homebridge-openclaw] Configure this token in your OpenClaw agent.
111
+ [homebridge-openclaw] ────────────────────────────────────────
112
+ ```
113
+
114
+ **Option C: Environment variable**
115
+ In Docker Compose:
116
+
117
+ ```yaml
118
+ environment:
119
+ - OPENCLAW_HB_TOKEN=my-shared-token
120
+ ```
121
+
122
+ Configure the same value in OpenClaw.
123
+
124
+ ### REST API
125
+
126
+ Base URL: `http://<homebridge-ip>:8899`
127
+
128
+ All requests (except `/health`) require:
129
+
130
+ ```
131
+ Authorization: Bearer <token>
132
+ ```
133
+
134
+ | Endpoint | Method | Auth | Description |
135
+ |----------|--------|------|-------------|
136
+ | `/health` | GET | No | Health check |
137
+ | `/api/devices` | GET | Yes | List all devices |
138
+ | `/api/devices/type/<type>` | GET | Yes | List by type (e.g. `switch`, `lightbulb`) |
139
+ | `/api/devices/<id>` | GET | Yes | Device state |
140
+ | `/api/devices/<id>/control` | POST | Yes | Control one device |
141
+ | `/api/devices/control` | POST | Yes | Control multiple devices |
142
+
143
+ **Health (no auth)**
144
+
145
+ ```
146
+ GET /health
147
+ ```
148
+
149
+ **Control one device**
150
+
151
+ ```
152
+ POST /api/devices/<id>/control
153
+ Content-Type: application/json
154
+
155
+ { "action": "on", "value": true }
156
+ ```
157
+
158
+ **Control multiple devices**
159
+
160
+ ```
161
+ POST /api/devices/control
162
+ Content-Type: application/json
163
+
164
+ {
165
+ "devices": [
166
+ { "id": "xxx", "action": "on", "value": true },
167
+ { "id": "yyy", "action": "on", "value": false }
168
+ ]
169
+ }
170
+ ```
171
+
172
+ **Supported actions**
173
+
174
+ | Action | Value | Devices |
175
+ |--------|-------|---------|
176
+ | `on` / `power` | `true` / `false` | switch, lightbulb, outlet, fan |
177
+ | `brightness` / `dim` | 0–100 | lightbulb |
178
+ | `hue` | 0–360 | RGB lightbulb |
179
+ | `saturation` | 0–100 | RGB lightbulb |
180
+ | `color` | `{ "hue": 240, "saturation": 100 }` | RGB lightbulb |
181
+ | `colorTemperature` / `ct` | mired | lightbulb |
182
+ | `targetTemperature` / `temperature` | 10–35 | thermostat |
183
+ | `thermostatMode` / `mode` | `off` / `heat` / `cool` / `auto` | thermostat |
184
+ | `lock` | `true` / `false` | lock |
185
+ | `speed` / `rotationSpeed` | 0–100 | fan |
186
+ | `position` / `targetPosition` | 0–100 | blinds |
187
+ | `tilt` / `targetTilt` | -90 to 90 | blinds |
188
+ | `garageDoor` / `garage` | `true`=open / `false`=close | garage |
189
+
190
+ ### Using from OpenClaw
191
+
192
+ Example with `exec` and `curl`:
193
+
194
+ ```bash
195
+ # List devices
196
+ exec('curl -s -H "Authorization: Bearer TOKEN" http://HOMEBRIDGE_IP:8899/api/devices')
197
+
198
+ # Turn on a light
199
+ exec('curl -s -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d \'{"action":"on","value":true}\' http://HOMEBRIDGE_IP:8899/api/devices/DEVICE_ID/control')
200
+ ```
201
+
202
+ ### License
203
+
204
+ MIT — see [LICENSE](LICENSE).
205
+
206
+ ---
207
+
208
+ ## Español
209
+
210
+ ### Requisitos
211
+
212
+ - **homebridge-config-ui-x** instalado (viene con la imagen Docker oficial).
213
+ - Homebridge iniciado con la bandera **`-I`** (modo inseguro) para que el UI pueda leer/escribir características.
214
+
215
+ ### Instalación
216
+
217
+ ```bash
218
+ npm install homebridge-openclaw
219
+ ```
220
+
221
+ O desde **Homebridge Config UI X**: Plugins → buscar “openclaw” → Instalar.
222
+
223
+ ### Configuración mínima
224
+
225
+ Añadir al `config.json` de Homebridge en la sección **`accessories`**:
226
+
227
+ ```json
228
+ {
229
+ "accessory": "OpenClawAPI",
230
+ "name": "OpenClaw API"
231
+ }
232
+ ```
233
+
234
+ Con eso basta. El plugin:
235
+
236
+ - Detecta automáticamente las credenciales del UI (lee `.uix-secrets` y `auth.json` del sistema de archivos).
237
+ - Genera un token API único y lo guarda en `.openclaw-token`.
238
+ - Escucha en el puerto 8899.
239
+
240
+ ### Configuración avanzada
241
+
242
+ ```json
243
+ {
244
+ "accessory": "OpenClawAPI",
245
+ "name": "OpenClaw API",
246
+ "apiPort": 8899,
247
+ "apiBind": "0.0.0.0",
248
+ "token": "mi-token-personalizado",
249
+ "rateLimit": 100,
250
+ "homebridgeUiUrl": "http://localhost:8581",
251
+ "homebridgeUiUser": "admin",
252
+ "homebridgeUiPass": "admin"
253
+ }
254
+ ```
255
+
256
+ | Parámetro | Tipo | Por defecto | Descripción |
257
+ |-----------|------|-------------|-------------|
258
+ | `apiPort` | number | 8899 | Puerto del servidor REST |
259
+ | `apiBind` | string | `0.0.0.0` | Dirección de bind (`127.0.0.1` = solo local) |
260
+ | `token` | string | auto | Token Bearer para autenticar llamadas de OpenClaw |
261
+ | `rateLimit` | number | 100 | Máximo de peticiones por minuto por IP |
262
+ | `homebridgeUiUrl` | string | `http://localhost:8581` | URL del Config UI X (solo si no es la por defecto) |
263
+ | `homebridgeUiUser` | string | auto | Usuario del UI (solo si falla la auto-detección) |
264
+ | `homebridgeUiPass` | string | auto | Contraseña del UI (solo si falla la auto-detección) |
265
+
266
+ ### Seguridad
267
+
268
+ **Autenticación interna (plugin → Config UI X)**
269
+ El plugin lee los archivos internos de Homebridge (`.uix-secrets` y `auth.json`) para firmar JWTs válidos. **No necesita usuario ni contraseña en `config.json`.**
270
+ Solo si esos archivos no están disponibles (p. ej. instalaciones no-Docker), se usan `homebridgeUiUser` / `homebridgeUiPass` como respaldo.
271
+
272
+ **Token API (OpenClaw → plugin)**
273
+ Se resuelve en este orden:
274
+
275
+ 1. **Variable de entorno** `OPENCLAW_HB_TOKEN` — ideal para Docker Compose / Kubernetes.
276
+ 2. **Archivo** `.openclaw-token` en el directorio de almacenamiento de Homebridge — ideal si OpenClaw tiene acceso al sistema de archivos (mismo NAS, volumen compartido).
277
+ 3. **Campo `token`** en `config.json` — respaldo manual.
278
+ 4. **Auto-generado** — si no existe ninguna de las anteriores, se genera un token único (HMAC del secretKey de Homebridge), se guarda en `.openclaw-token` y se muestra en los logs.
279
+
280
+ **Rate limiting**
281
+ Por defecto: 100 peticiones por minuto por IP. Configurable con `rateLimit`.
282
+
283
+ ### Obtener el token para OpenClaw
284
+
285
+ **Opción A: Leer el archivo (recomendado)**
286
+ Tras el primer arranque, el token está en:
287
+
288
+ ```
289
+ /var/lib/homebridge/.openclaw-token
290
+ ```
291
+
292
+ Si usas Docker y el volumen está montado (ej.: `/Volumes/docker/HomeBridge`):
293
+
294
+ ```bash
295
+ cat /Volumes/docker/HomeBridge/.openclaw-token
296
+ ```
297
+
298
+ **Opción B: Ver los logs**
299
+ En el primer arranque, el token aparece en los logs de Homebridge:
300
+
301
+ ```
302
+ [homebridge-openclaw] ────────────────────────────────────────
303
+ [homebridge-openclaw] API Token: abc123...
304
+ [homebridge-openclaw] Configure this token in your OpenClaw agent.
305
+ [homebridge-openclaw] ────────────────────────────────────────
306
+ ```
307
+
308
+ **Opción C: Variable de entorno**
309
+ En Docker Compose:
310
+
311
+ ```yaml
312
+ environment:
313
+ - OPENCLAW_HB_TOKEN=mi-token-compartido
314
+ ```
315
+
316
+ Configurar el mismo valor en OpenClaw.
317
+
318
+ ### API REST
319
+
320
+ URL base: `http://<ip-homebridge>:8899`
321
+
322
+ Todas las peticiones (excepto `/health`) requieren:
323
+
324
+ ```
325
+ Authorization: Bearer <token>
326
+ ```
327
+
328
+ | Endpoint | Método | Auth | Descripción |
329
+ |----------|--------|------|-------------|
330
+ | `/health` | GET | No | Comprobación de estado |
331
+ | `/api/devices` | GET | Sí | Listar todos los dispositivos |
332
+ | `/api/devices/type/<tipo>` | GET | Sí | Listar por tipo (ej. `switch`, `lightbulb`) |
333
+ | `/api/devices/<id>` | GET | Sí | Estado de un dispositivo |
334
+ | `/api/devices/<id>/control` | POST | Sí | Controlar un dispositivo |
335
+ | `/api/devices/control` | POST | Sí | Controlar varios dispositivos |
336
+
337
+ **Health (sin auth)**
338
+
339
+ ```
340
+ GET /health
341
+ ```
342
+
343
+ **Controlar un dispositivo**
344
+
345
+ ```
346
+ POST /api/devices/<id>/control
347
+ Content-Type: application/json
348
+
349
+ { "action": "on", "value": true }
350
+ ```
351
+
352
+ **Controlar varios dispositivos**
353
+
354
+ ```
355
+ POST /api/devices/control
356
+ Content-Type: application/json
357
+
358
+ {
359
+ "devices": [
360
+ { "id": "xxx", "action": "on", "value": true },
361
+ { "id": "yyy", "action": "on", "value": false }
362
+ ]
363
+ }
364
+ ```
365
+
366
+ **Acciones soportadas**
367
+
368
+ | Acción | Valor | Dispositivos |
369
+ |--------|-------|--------------|
370
+ | `on` / `power` | `true` / `false` | switch, lightbulb, outlet, fan |
371
+ | `brightness` / `dim` | 0–100 | lightbulb |
372
+ | `hue` | 0–360 | lightbulb RGB |
373
+ | `saturation` | 0–100 | lightbulb RGB |
374
+ | `color` | `{ "hue": 240, "saturation": 100 }` | lightbulb RGB |
375
+ | `colorTemperature` / `ct` | mired | lightbulb |
376
+ | `targetTemperature` / `temperature` | 10–35 | thermostat |
377
+ | `thermostatMode` / `mode` | `off` / `heat` / `cool` / `auto` | thermostat |
378
+ | `lock` | `true` / `false` | lock |
379
+ | `speed` / `rotationSpeed` | 0–100 | fan |
380
+ | `position` / `targetPosition` | 0–100 | blinds |
381
+ | `tilt` / `targetTilt` | -90 a 90 | blinds |
382
+ | `garageDoor` / `garage` | `true`=abrir / `false`=cerrar | garage |
383
+
384
+ ### Uso desde OpenClaw
385
+
386
+ Ejemplo con `exec` y `curl`:
387
+
388
+ ```bash
389
+ # Listar dispositivos
390
+ exec('curl -s -H "Authorization: Bearer TOKEN" http://IP_HOMEBRIDGE:8899/api/devices')
391
+
392
+ # Encender una luz
393
+ exec('curl -s -X POST -H "Authorization: Bearer TOKEN" -H "Content-Type: application/json" -d \'{"action":"on","value":true}\' http://IP_HOMEBRIDGE:8899/api/devices/DEVICE_ID/control')
394
+ ```
395
+
396
+ ### Licencia
397
+
398
+ MIT — ver [LICENSE](LICENSE).
@@ -0,0 +1,89 @@
1
+ {
2
+ "pluginAlias": "OpenClawAPI",
3
+ "pluginType": "accessory",
4
+ "singular": true,
5
+ "headerDisplay": "This plugin exposes a REST API so that [OpenClaw](https://docs.openclaw.ai) agents can list and control your HomeKit devices via Homebridge.",
6
+ "footerDisplay": "For full documentation see the [README](https://github.com/davidevp/homebridge-openclaw#readme).",
7
+ "schema": {
8
+ "type": "object",
9
+ "properties": {
10
+ "name": {
11
+ "title": "Name",
12
+ "type": "string",
13
+ "default": "OpenClaw API",
14
+ "description": "Display name for this accessory in Homebridge."
15
+ },
16
+ "apiPort": {
17
+ "title": "API Port",
18
+ "type": "integer",
19
+ "default": 8899,
20
+ "minimum": 1024,
21
+ "maximum": 65535,
22
+ "description": "Port for the REST API server (1024-65535)."
23
+ },
24
+ "apiBind": {
25
+ "title": "Bind Address",
26
+ "type": "string",
27
+ "default": "0.0.0.0",
28
+ "description": "Network interface to bind to. Use 127.0.0.1 for local-only access.",
29
+ "oneOf": [
30
+ { "title": "All interfaces (0.0.0.0)", "enum": ["0.0.0.0"] },
31
+ { "title": "Local only (127.0.0.1)", "enum": ["127.0.0.1"] }
32
+ ]
33
+ },
34
+ "token": {
35
+ "title": "API Token",
36
+ "type": "string",
37
+ "description": "Bearer token for authenticating OpenClaw requests. Leave empty to auto-generate."
38
+ },
39
+ "rateLimit": {
40
+ "title": "Rate Limit",
41
+ "type": "integer",
42
+ "default": 100,
43
+ "minimum": 1,
44
+ "maximum": 10000,
45
+ "description": "Maximum requests per minute per IP address (1-10000)."
46
+ },
47
+ "homebridgeUiUrl": {
48
+ "title": "Config UI X URL",
49
+ "type": "string",
50
+ "placeholder": "http://localhost:8581",
51
+ "description": "URL of Homebridge Config UI X. Only change if running on a non-default port."
52
+ },
53
+ "homebridgeUiUser": {
54
+ "title": "Config UI X Username",
55
+ "type": "string",
56
+ "description": "Only required if automatic credential detection fails (non-Docker setups)."
57
+ },
58
+ "homebridgeUiPass": {
59
+ "title": "Config UI X Password",
60
+ "type": "string",
61
+ "description": "Only required if automatic credential detection fails (non-Docker setups)."
62
+ }
63
+ },
64
+ "required": ["name"]
65
+ },
66
+ "layout": [
67
+ {
68
+ "type": "fieldset",
69
+ "title": "Server",
70
+ "items": [
71
+ { "key": "apiPort", "type": "number" },
72
+ "apiBind",
73
+ { "key": "rateLimit", "type": "number" }
74
+ ]
75
+ },
76
+ {
77
+ "type": "fieldset",
78
+ "title": "Authentication",
79
+ "description": "The token is auto-generated if left empty. Only fill the UI credentials if auto-detection does not work.",
80
+ "items": ["token"]
81
+ },
82
+ {
83
+ "type": "fieldset",
84
+ "title": "Advanced (Config UI X)",
85
+ "expandable": true,
86
+ "items": ["homebridgeUiUrl", "homebridgeUiUser", "homebridgeUiPass"]
87
+ }
88
+ ]
89
+ }
Binary file
package/index.js ADDED
@@ -0,0 +1,501 @@
1
+ /**
2
+ * homebridge-openclaw v2.1.0
3
+ *
4
+ * Exposes a simplified REST API so that an OpenClaw agent can list and
5
+ * control HomeKit devices managed by Homebridge.
6
+ *
7
+ * Security:
8
+ * - Authenticates with Config UI X internally via JWT (no plaintext
9
+ * passwords in config.json). Reads .uix-secrets + auth.json directly.
10
+ * - API token resolved from: env var → file → config → auto-generated.
11
+ * - Rate-limited. Bind address configurable.
12
+ *
13
+ * Requirements:
14
+ * - homebridge-config-ui-x (comes with the official Docker image)
15
+ * - Homebridge started with -I flag (insecure mode)
16
+ */
17
+
18
+ 'use strict';
19
+
20
+ const { readFileSync, writeFileSync, existsSync } = require('fs');
21
+ const { createHmac } = require('crypto');
22
+ const { resolve } = require('path');
23
+ const express = require('express');
24
+ const jwt = require('jsonwebtoken');
25
+ const rateLimit = require('express-rate-limit');
26
+
27
+ // ─── Constants ──────────────────────────────────────────────────────────────
28
+ const PLUGIN_NAME = 'homebridge-openclaw';
29
+ const ACCESSORY_NAME = 'OpenClawAPI';
30
+ const VERSION = '2.1.0';
31
+ const DEFAULT_PORT = 8899;
32
+ const DEFAULT_BIND = '0.0.0.0';
33
+ const DEFAULT_RATE_LIMIT = 100; // requests per minute
34
+ const DEFAULT_UI_URL = 'http://localhost:8581';
35
+ const TOKEN_FILE_NAME = '.openclaw-token';
36
+ const TOKEN_ENV_VAR = 'OPENCLAW_HB_TOKEN';
37
+
38
+ // ─── Helpers: storage path detection ────────────────────────────────────────
39
+
40
+ function detectStoragePath() {
41
+ if (process.env.UIX_STORAGE_PATH) return process.env.UIX_STORAGE_PATH;
42
+ const candidates = [
43
+ '/var/lib/homebridge',
44
+ resolve(require('os').homedir(), '.homebridge'),
45
+ ];
46
+ for (const p of candidates) {
47
+ if (existsSync(resolve(p, '.uix-secrets'))) return p;
48
+ }
49
+ return candidates[0];
50
+ }
51
+
52
+ // ─── Helpers: API token resolution (4 layers) ───────────────────────────────
53
+
54
+ function resolveApiToken(config, storagePath, log) {
55
+ // Layer 1: environment variable
56
+ if (process.env[TOKEN_ENV_VAR]) {
57
+ log.info(`[${PLUGIN_NAME}] API token loaded from environment variable ${TOKEN_ENV_VAR}.`);
58
+ return { token: process.env[TOKEN_ENV_VAR], source: 'env' };
59
+ }
60
+
61
+ // Layer 2: token file
62
+ const tokenFilePath = resolve(storagePath, TOKEN_FILE_NAME);
63
+ if (existsSync(tokenFilePath)) {
64
+ try {
65
+ const fileToken = readFileSync(tokenFilePath, 'utf8').trim();
66
+ if (fileToken.length >= 16) {
67
+ log.info(`[${PLUGIN_NAME}] API token loaded from ${tokenFilePath}.`);
68
+ return { token: fileToken, source: 'file' };
69
+ }
70
+ } catch (_) { /* fall through */ }
71
+ }
72
+
73
+ // Layer 3: config.json field
74
+ if (config.token && config.token.length >= 8) {
75
+ log.info(`[${PLUGIN_NAME}] API token loaded from config.json.`);
76
+ return { token: config.token, source: 'config' };
77
+ }
78
+
79
+ // Layer 4: auto-generate from secretKey
80
+ let secretKey = 'openclaw-default-seed';
81
+ try {
82
+ const secrets = JSON.parse(readFileSync(resolve(storagePath, '.uix-secrets'), 'utf8'));
83
+ if (secrets.secretKey) secretKey = secrets.secretKey;
84
+ } catch (_) { /* use default seed */ }
85
+
86
+ const generated = createHmac('sha256', secretKey).update('openclaw-hb-api-token').digest('hex').slice(0, 48);
87
+
88
+ // Persist to file so OpenClaw can read it
89
+ try {
90
+ writeFileSync(tokenFilePath, generated + '\n', { mode: 0o600 });
91
+ log.info(`[${PLUGIN_NAME}] API token auto-generated and saved to ${tokenFilePath}.`);
92
+ } catch (err) {
93
+ log.warn(`[${PLUGIN_NAME}] Could not write token file: ${err.message}. Token only in logs.`);
94
+ }
95
+
96
+ log.info(`[${PLUGIN_NAME}] ────────────────────────────────────────`);
97
+ log.info(`[${PLUGIN_NAME}] API Token: ${generated}`);
98
+ log.info(`[${PLUGIN_NAME}] Configure this token in your OpenClaw agent.`);
99
+ log.info(`[${PLUGIN_NAME}] ────────────────────────────────────────`);
100
+
101
+ return { token: generated, source: 'auto' };
102
+ }
103
+
104
+ // ─── UiClient: talks to Config UI X API ─────────────────────────────────────
105
+
106
+ class UiClient {
107
+ constructor({ storagePath, uiUrl, uiUser, uiPass, log }) {
108
+ this.storagePath = storagePath;
109
+ this.baseUrl = (uiUrl || DEFAULT_UI_URL).replace(/\/+$/, '');
110
+ this.uiUser = uiUser || null;
111
+ this.uiPass = uiPass || null;
112
+ this.log = log;
113
+ this.jwt = null;
114
+ this.jwtExpires = 0;
115
+ this.secretKey = null;
116
+ this.adminUser = null;
117
+ this.instanceId = null;
118
+ this.authMode = 'none'; // 'jwt-direct' | 'login' | 'none'
119
+
120
+ this._detectAuthMode();
121
+ }
122
+
123
+ /** Detect the best auth strategy available. */
124
+ _detectAuthMode() {
125
+ // Try reading .uix-secrets
126
+ try {
127
+ const secretsPath = resolve(this.storagePath, '.uix-secrets');
128
+ const secrets = JSON.parse(readFileSync(secretsPath, 'utf8'));
129
+ if (secrets.secretKey) {
130
+ this.secretKey = secrets.secretKey;
131
+ this.instanceId = require('crypto').createHash('sha256').update(secrets.secretKey).digest('hex');
132
+ }
133
+ } catch (_) { /* not available */ }
134
+
135
+ // Try reading auth.json for admin username
136
+ try {
137
+ const authPath = resolve(this.storagePath, 'auth.json');
138
+ const users = JSON.parse(readFileSync(authPath, 'utf8'));
139
+ const admin = Array.isArray(users) ? users.find(u => u.admin) : null;
140
+ if (admin) this.adminUser = admin.username;
141
+ } catch (_) { /* not available */ }
142
+
143
+ if (this.secretKey && this.adminUser) {
144
+ this.authMode = 'jwt-direct';
145
+ this.log.info(`[${PLUGIN_NAME}] Auth mode: JWT direct (no password needed).`);
146
+ } else if (this.uiUser && this.uiPass) {
147
+ this.authMode = 'login';
148
+ this.log.info(`[${PLUGIN_NAME}] Auth mode: login (credentials from config).`);
149
+ } else {
150
+ this.log.error(`[${PLUGIN_NAME}] Auth mode: none — cannot authenticate with Config UI X!`);
151
+ this.log.error(`[${PLUGIN_NAME}] Ensure .uix-secrets exists or provide homebridgeUiUser/homebridgeUiPass.`);
152
+ }
153
+ }
154
+
155
+ /** Sign a JWT using the same format Config UI X expects. */
156
+ _signJwt() {
157
+ const payload = {
158
+ username: this.adminUser,
159
+ name: this.adminUser,
160
+ admin: true,
161
+ instanceId: this.instanceId,
162
+ };
163
+ return jwt.sign(payload, this.secretKey, { expiresIn: '8h' });
164
+ }
165
+
166
+ /** Get a valid token (refresh if needed). */
167
+ async token() {
168
+ if (this.authMode === 'jwt-direct') {
169
+ if (!this.jwt || Date.now() >= this.jwtExpires) {
170
+ this.jwt = this._signJwt();
171
+ this.jwtExpires = Date.now() + 7 * 3600 * 1000; // refresh in 7h
172
+ }
173
+ return this.jwt;
174
+ }
175
+
176
+ if (this.authMode === 'login') {
177
+ if (!this.jwt || Date.now() >= this.jwtExpires) {
178
+ await this._loginAuth();
179
+ }
180
+ return this.jwt;
181
+ }
182
+
183
+ throw new Error('No authentication method available for Config UI X.');
184
+ }
185
+
186
+ /** Authenticate via HTTP login (fallback). */
187
+ async _loginAuth() {
188
+ const res = await fetch(`${this.baseUrl}/api/auth/login`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify({ username: this.uiUser, password: this.uiPass, otp: '' }),
192
+ });
193
+ if (!res.ok) {
194
+ const text = await res.text().catch(() => '');
195
+ throw new Error(`Config UI login failed (${res.status}): ${text}`);
196
+ }
197
+ const data = await res.json();
198
+ this.jwt = data.access_token;
199
+ this.jwtExpires = Date.now() + ((data.expires_in || 28800) - 60) * 1000;
200
+ }
201
+
202
+ /** GET /api/accessories */
203
+ async getAccessories() {
204
+ const tok = await this.token();
205
+ const res = await fetch(`${this.baseUrl}/api/accessories`, {
206
+ headers: { Authorization: `Bearer ${tok}`, Accept: 'application/json' },
207
+ });
208
+ if (!res.ok) throw new Error(`GET /api/accessories → ${res.status}`);
209
+ return res.json();
210
+ }
211
+
212
+ /** PUT /api/accessories/:uniqueId */
213
+ async setCharacteristic(uniqueId, characteristicType, value) {
214
+ const tok = await this.token();
215
+ const res = await fetch(`${this.baseUrl}/api/accessories/${encodeURIComponent(uniqueId)}`, {
216
+ method: 'PUT',
217
+ headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' },
218
+ body: JSON.stringify({ characteristicType, value }),
219
+ });
220
+ if (!res.ok) {
221
+ const text = await res.text().catch(() => '');
222
+ throw new Error(`PUT ${characteristicType} → ${res.status}: ${text}`);
223
+ }
224
+ return res.json();
225
+ }
226
+ }
227
+
228
+ // ─── Accessory data parsing ─────────────────────────────────────────────────
229
+
230
+ function parseAccessories(raw) {
231
+ const list = Array.isArray(raw) ? raw : [];
232
+ const devices = [];
233
+ for (const svc of list) {
234
+ const name = svc.serviceName || svc.accessoryInformation?.Name || '';
235
+ const type = svc.humanType || svc.type || 'Unknown';
236
+ const uid = svc.uniqueId;
237
+ if (!uid || type === 'AccessoryInformation' || type === 'ProtocolInformation') continue;
238
+ if (name.toLowerCase() === 'openclaw api') continue;
239
+
240
+ const chars = (svc.serviceCharacteristics || []);
241
+ const writableChars = chars.filter(c => c.canWrite).map(c => c.type);
242
+
243
+ devices.push({
244
+ id: uid,
245
+ name,
246
+ type: mapType(type),
247
+ humanType: type,
248
+ state: svc.values || {},
249
+ characteristics: writableChars,
250
+ manufacturer: svc.accessoryInformation?.Manufacturer || '',
251
+ model: svc.accessoryInformation?.Model || '',
252
+ });
253
+ }
254
+ return devices;
255
+ }
256
+
257
+ function mapType(humanType) {
258
+ const t = (humanType || '').toLowerCase();
259
+ if (t.includes('light') || t.includes('bulb')) return 'lightbulb';
260
+ if (t.includes('switch')) return 'switch';
261
+ if (t.includes('outlet')) return 'outlet';
262
+ if (t.includes('thermostat')) return 'thermostat';
263
+ if (t.includes('lock')) return 'lock';
264
+ if (t.includes('fan')) return 'fan';
265
+ if (t.includes('window') || t.includes('blind') || t.includes('covering')) return 'blinds';
266
+ if (t.includes('garage')) return 'garage';
267
+ if (t.includes('motion')) return 'motion';
268
+ if (t.includes('temperature')) return 'sensor';
269
+ if (t.includes('humidity')) return 'sensor';
270
+ if (t.includes('contact')) return 'sensor';
271
+ if (t.includes('camera')) return 'camera';
272
+ return 'other';
273
+ }
274
+
275
+ // ─── Action resolution ──────────────────────────────────────────────────────
276
+
277
+ const MODE_MAP = { off: 0, heat: 1, cool: 2, auto: 3 };
278
+
279
+ function resolveAction(action, value) {
280
+ switch (action) {
281
+ case 'on': case 'power':
282
+ return { characteristicType: 'On', value: Boolean(value) };
283
+ case 'toggle':
284
+ return { characteristicType: 'On', value: Boolean(value) };
285
+ case 'brightness': case 'dim':
286
+ return { characteristicType: 'Brightness', value: clamp(value, 0, 100) };
287
+ case 'hue':
288
+ return { characteristicType: 'Hue', value: clamp(value, 0, 360) };
289
+ case 'saturation':
290
+ return { characteristicType: 'Saturation', value: clamp(value, 0, 100) };
291
+ case 'color': {
292
+ const ops = [];
293
+ if (value?.hue !== undefined) ops.push({ characteristicType: 'Hue', value: Number(value.hue) });
294
+ if (value?.saturation !== undefined) ops.push({ characteristicType: 'Saturation', value: Number(value.saturation) });
295
+ return ops;
296
+ }
297
+ case 'colorTemperature': case 'ct':
298
+ return { characteristicType: 'ColorTemperature', value: Number(value) };
299
+ case 'targetTemperature': case 'temperature':
300
+ return { characteristicType: 'TargetTemperature', value: Number(value) };
301
+ case 'thermostatMode': case 'mode':
302
+ return { characteristicType: 'TargetHeatingCoolingState', value: MODE_MAP[String(value).toLowerCase()] ?? Number(value) };
303
+ case 'lock':
304
+ return { characteristicType: 'LockTargetState', value: value ? 1 : 0 };
305
+ case 'speed': case 'rotationSpeed':
306
+ return { characteristicType: 'RotationSpeed', value: clamp(value, 0, 100) };
307
+ case 'position': case 'targetPosition':
308
+ return { characteristicType: 'TargetPosition', value: clamp(value, 0, 100) };
309
+ case 'tilt': case 'targetTilt':
310
+ return { characteristicType: 'TargetHorizontalTiltAngle', value: clamp(value, -90, 90) };
311
+ case 'garageDoor': case 'garage':
312
+ return { characteristicType: 'TargetDoorState', value: value ? 0 : 1 };
313
+ default:
314
+ return null;
315
+ }
316
+ }
317
+
318
+ function clamp(v, min, max) { return Math.max(min, Math.min(max, Number(v))); }
319
+
320
+ // ─── Express routes ─────────────────────────────────────────────────────────
321
+
322
+ function setupRoutes(app, apiToken, uiClient) {
323
+ // Auth middleware
324
+ function auth(req, res, next) {
325
+ const h = req.headers.authorization || '';
326
+ if (!h.startsWith('Bearer ') || h.substring(7) !== apiToken) {
327
+ return res.status(401).json({ error: 'Unauthorized', message: 'Invalid or missing Bearer token.' });
328
+ }
329
+ next();
330
+ }
331
+
332
+ // Health (no auth, sanitized)
333
+ app.get('/health', async (_req, res) => {
334
+ let deviceCount = 0;
335
+ let connected = false;
336
+ try {
337
+ const raw = await uiClient.getAccessories();
338
+ deviceCount = parseAccessories(raw).length;
339
+ connected = true;
340
+ } catch (_) { /* silent */ }
341
+ res.json({
342
+ status: connected ? 'ok' : 'degraded',
343
+ plugin: PLUGIN_NAME,
344
+ version: VERSION,
345
+ timestamp: new Date().toISOString(),
346
+ devices: deviceCount,
347
+ });
348
+ });
349
+
350
+ // List devices
351
+ app.get('/api/devices', auth, async (_req, res) => {
352
+ try {
353
+ const raw = await uiClient.getAccessories();
354
+ const devices = parseAccessories(raw);
355
+ res.json({ success: true, count: devices.length, devices });
356
+ } catch (err) {
357
+ res.status(502).json({ error: 'Upstream error', message: err.message });
358
+ }
359
+ });
360
+
361
+ // List by type
362
+ app.get('/api/devices/type/:type', auth, async (req, res) => {
363
+ try {
364
+ const raw = await uiClient.getAccessories();
365
+ const devices = parseAccessories(raw).filter(d => d.type === req.params.type.toLowerCase());
366
+ res.json({ success: true, count: devices.length, devices });
367
+ } catch (err) {
368
+ res.status(502).json({ error: 'Upstream error', message: err.message });
369
+ }
370
+ });
371
+
372
+ // Get single device
373
+ app.get('/api/devices/:id', auth, async (req, res) => {
374
+ try {
375
+ const raw = await uiClient.getAccessories();
376
+ const device = parseAccessories(raw).find(d => d.id === req.params.id);
377
+ if (!device) return res.status(404).json({ error: 'Not Found', message: `Device '${req.params.id}' not found.` });
378
+ res.json({ success: true, device });
379
+ } catch (err) {
380
+ res.status(502).json({ error: 'Upstream error', message: err.message });
381
+ }
382
+ });
383
+
384
+ // Control single device
385
+ app.post('/api/devices/:id/control', auth, async (req, res) => {
386
+ const { action, value } = req.body || {};
387
+ if (!action) return res.status(400).json({ error: 'Bad Request', message: 'Missing "action".' });
388
+ const resolved = resolveAction(action, value);
389
+ if (!resolved) return res.status(400).json({ error: 'Bad Request', message: `Unknown action: ${action}.` });
390
+ try {
391
+ const ops = Array.isArray(resolved) ? resolved : [resolved];
392
+ const results = [];
393
+ for (const op of ops) {
394
+ results.push(await uiClient.setCharacteristic(req.params.id, op.characteristicType, op.value));
395
+ }
396
+ res.json({ success: true, id: req.params.id, action, results });
397
+ } catch (err) {
398
+ res.status(502).json({ error: 'Control error', message: err.message });
399
+ }
400
+ });
401
+
402
+ // Control multiple devices
403
+ app.post('/api/devices/control', auth, async (req, res) => {
404
+ const { devices } = req.body || {};
405
+ if (!Array.isArray(devices)) return res.status(400).json({ error: 'Bad Request', message: 'Expected "devices" array.' });
406
+ const results = [];
407
+ for (const item of devices) {
408
+ const resolved = resolveAction(item.action, item.value);
409
+ if (!resolved) { results.push({ id: item.id, success: false, error: `Unknown action: ${item.action}` }); continue; }
410
+ try {
411
+ const ops = Array.isArray(resolved) ? resolved : [resolved];
412
+ for (const op of ops) await uiClient.setCharacteristic(item.id, op.characteristicType, op.value);
413
+ results.push({ id: item.id, success: true });
414
+ } catch (err) {
415
+ results.push({ id: item.id, success: false, error: err.message });
416
+ }
417
+ }
418
+ res.json({ success: true, results });
419
+ });
420
+ }
421
+
422
+ // ─── Homebridge registration ────────────────────────────────────────────────
423
+
424
+ module.exports = function (api) {
425
+ api.registerAccessory(PLUGIN_NAME, ACCESSORY_NAME, OpenClawAccessory);
426
+ };
427
+
428
+ class OpenClawAccessory {
429
+ constructor(log, config, api) {
430
+ this.log = log;
431
+ this.config = config;
432
+ this.api = api;
433
+ this.name = config.name || 'OpenClaw API';
434
+
435
+ const storagePath = detectStoragePath();
436
+ this.storagePath = storagePath;
437
+
438
+ // Resolve API token (what OpenClaw sends to us)
439
+ const { token: apiToken } = resolveApiToken(config, storagePath, log);
440
+ this.apiToken = apiToken;
441
+
442
+ // Create UI client (how we talk to Config UI X)
443
+ this.uiClient = new UiClient({
444
+ storagePath,
445
+ uiUrl: config.homebridgeUiUrl,
446
+ uiUser: config.homebridgeUiUser,
447
+ uiPass: config.homebridgeUiPass,
448
+ log,
449
+ });
450
+
451
+ api.on('didFinishLaunching', () => this._startServer());
452
+ }
453
+
454
+ async _startServer() {
455
+ const port = this.config.apiPort || DEFAULT_PORT;
456
+ const bind = this.config.apiBind || DEFAULT_BIND;
457
+ const rpmLimit = this.config.rateLimit || DEFAULT_RATE_LIMIT;
458
+
459
+ // Verify auth works
460
+ try {
461
+ await this.uiClient.token();
462
+ this.log.info(`[${PLUGIN_NAME}] Connected to Config UI X (${this.uiClient.authMode}).`);
463
+ } catch (err) {
464
+ this.log.error(`[${PLUGIN_NAME}] Config UI X auth failed: ${err.message}`);
465
+ }
466
+
467
+ const app = express();
468
+ app.use(express.json());
469
+ app.set('trust proxy', 1);
470
+
471
+ // Rate limiting
472
+ app.use(rateLimit({
473
+ windowMs: 60 * 1000,
474
+ max: rpmLimit,
475
+ standardHeaders: true,
476
+ legacyHeaders: false,
477
+ message: { error: 'Too Many Requests', message: `Rate limit: ${rpmLimit} requests per minute.` },
478
+ }));
479
+
480
+ setupRoutes(app, this.apiToken, this.uiClient);
481
+
482
+ const server = app.listen(port, bind, () => {
483
+ this.log.info(`[${PLUGIN_NAME}] REST API listening on ${bind}:${port}`);
484
+ });
485
+ server.on('error', err => {
486
+ this.log.error(`[${PLUGIN_NAME}] Server error: ${err.message}`);
487
+ });
488
+ }
489
+
490
+ getServices() {
491
+ const Service = this.api.hap.Service;
492
+ const Characteristic = this.api.hap.Characteristic;
493
+ const info = new Service.AccessoryInformation();
494
+ info
495
+ .setCharacteristic(Characteristic.Name, 'OpenClaw API')
496
+ .setCharacteristic(Characteristic.Manufacturer, 'OpenClaw')
497
+ .setCharacteristic(Characteristic.Model, 'REST API Bridge')
498
+ .setCharacteristic(Characteristic.SerialNumber, VERSION);
499
+ return [info];
500
+ }
501
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "homebridge-openclaw",
3
+ "displayName": "OpenClaw API",
4
+ "version": "2.1.0",
5
+ "description": "Expose a REST API so OpenClaw agents can list and control HomeKit devices via Homebridge.",
6
+ "main": "index.js",
7
+ "keywords": [
8
+ "homebridge-plugin",
9
+ "homebridge",
10
+ "homekit",
11
+ "openclaw",
12
+ "smart-home",
13
+ "rest-api"
14
+ ],
15
+ "engines": {
16
+ "node": ">=18.0.0",
17
+ "homebridge": ">=1.6.0"
18
+ },
19
+ "dependencies": {
20
+ "express": "^4.18.2",
21
+ "express-rate-limit": "^7.5.0",
22
+ "jsonwebtoken": "^9.0.2"
23
+ },
24
+ "author": "",
25
+ "license": "MIT",
26
+ "files": [
27
+ "index.js",
28
+ "config.schema.json",
29
+ "homebridge-ui-logo.png",
30
+ "LICENSE",
31
+ "README.md"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/davidevp/homebridge-openclaw.git"
36
+ },
37
+ "homepage": "https://github.com/davidevp/homebridge-openclaw#readme",
38
+ "bugs": {
39
+ "url": "https://github.com/davidevp/homebridge-openclaw/issues"
40
+ }
41
+ }