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 +21 -0
- package/README.md +398 -0
- package/config.schema.json +89 -0
- package/homebridge-ui-logo.png +0 -0
- package/index.js +501 -0
- package/package.json +41 -0
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
|
+
}
|