@pellebot/homebridge-fritz-platform-hb2 1.2.1 → 1.3.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/CHANGELOG.md +46 -0
- package/README.md +48 -2
- package/config.schema.json +127 -0
- package/homebridge-ui/public/index.html +131 -3
- package/package.json +4 -3
- package/src/accessories/presence/motion/motion.accessory.js +84 -0
- package/src/accessories/presence/occupancy/occupancy.accessory.js +55 -0
- package/src/accessories/presence/presence.config.js +36 -0
- package/src/accessories/presence/presence.handler.js +322 -0
- package/src/accessories/presence/presence.js +9 -0
- package/src/accessories/presence/presence.setup.js +102 -0
- package/src/accessories/presence/presence_guest.handler.js +138 -0
- package/src/platform.js +19 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
> **Hinweis zum Format:** Releases ab `v1.0.0` werden unter dem npm-Paket `@pellebot/homebridge-fritz-platform-hb2` veröffentlicht. Die `6.1.0-hb2patch.x` Pre-Releases sind Vorstufen unter dem alten Paketnamen `@pellebot/homebridge-fritz-platform` (deprecated). Die `registerPlatform`-Kennung bleibt durchgängig `homebridge-fritz-platform`, sodass cached Accessories über den Rename hinweg matchen — kein Re-Pairing nötig.
|
|
4
4
|
|
|
5
|
+
# v1.3.0 — 2026-11-15 (Presence als Opt-in)
|
|
6
|
+
|
|
7
|
+
**WLAN-Anwesenheit („Presence“) ist als optionale Funktion zurück.** Auf Anfrage aus der Community (Haushalt nur mit Apple-Geräten) zeigt das Plugin wieder an, ob ein Gerät — typisch ein iPhone — im Netz der FRITZ!Box ist. Standardmäßig ist die Funktion aus; sie wirkt nur für `presence`-Einträge mit `active: true`.
|
|
8
|
+
|
|
9
|
+
## Neuerungen
|
|
10
|
+
|
|
11
|
+
- **Presence-Sensor je Gerät**: Zuordnung über IPv4- oder MAC-Adresse (Groß-/Kleinschreibung egal), Accessory-Typ `occupancy` (Belegungssensor, Standard) oder `motion` (Bewegungssensor). Grundlage ist der vorhandene Code des Original-Plugins in `src/accessories/presence/`, nach dem Minimal-Patch-Prinzip wieder eingebunden.
|
|
12
|
+
- **Abmeldeverzögerung `offDelay`** (Standard **300 s**): Der Sensor meldet „weg“ erst, wenn das Gerät so lange nicht mehr verbunden war. Verhindert das Springen, wenn ein schlafendes iPhone kurz aus dem WLAN geht. Unter 60 s warnt das Log. `onDelay` (Standard 0 s) für die Anmeldung.
|
|
13
|
+
- **Optional „Anyone“** (jemand ist zu Hause) und **„Guest“** (Gerät im Gast-WLAN) über `options.presence`.
|
|
14
|
+
- **`config.schema.json`**: neuer Abschnitt „Presence (WLAN, optional)“ und `options.presence`.
|
|
15
|
+
- **Setup-Wizard**: Schritt 4 hat einen aufklappbaren Abschnitt „Anwesenheit per WLAN“ (Geräte anlegen, Anyone/Guest). Bestehende `presence`-Einträge werden beim erneuten Speichern übernommen und nicht mehr verworfen.
|
|
16
|
+
- **CI**: Mock-Test `ci/presence-mock-test.cjs` (Opt-in, Verzögerung, MAC-Abgleich, Anyone/Guest) auf Node 22 und 24.
|
|
17
|
+
|
|
18
|
+
## Korrekturen am übernommenen Code
|
|
19
|
+
|
|
20
|
+
- MAC-Adressen in Kleinbuchstaben wurden bisher verworfen bzw. nicht gefunden; jetzt werden sie normalisiert und ohne Beachtung der Schreibweise abgeglichen.
|
|
21
|
+
- Fehlt `offDelay`, gilt jetzt 300 s statt 0 s (vorher sprang der Sensor bei jeder kurzen WLAN-Abmeldung). Ein ausdrücklich gesetzter Wert, auch 0, bleibt erhalten.
|
|
22
|
+
- Aufruf von `historyService.getInitialTime()` im Motion-Pfad wird defensiv geprüft (siehe Issue #2).
|
|
23
|
+
- `src/accessories/presence/` ist nicht mehr in `.npmignore` ausgeschlossen (sonst fehlte der Code im npm-Paket).
|
|
24
|
+
|
|
25
|
+
## Nicht übernommen
|
|
26
|
+
|
|
27
|
+
Ping-Prüfung (`ping`, `threshold`) und Telegram-Meldungen (`blockTelegram`) des Original-Plugins.
|
|
28
|
+
|
|
29
|
+
## Breaking Changes
|
|
30
|
+
|
|
31
|
+
Keine. Ohne `presence`-Konfiguration verhält sich das Plugin wie v1.2.x. Wer aus dem Original-Plugin noch `presence`-Einträge mit `active: true` in der Config hat, bekommt diese Sensoren jetzt wieder angezeigt.
|
|
32
|
+
|
|
33
|
+
## Getestet
|
|
34
|
+
|
|
35
|
+
Mock-Test in der CI (Node 22/24) und Hardwaretest am 15.11.2026: Homebridge 2.4.0, FRITZ!Box mit drei FRITZ!Repeatern im Mesh, iPhone mit fester privater WLAN-Adresse. Anmeldung erkannt, 25 min gesperrtes iPhone ohne Fehlwechsel, Abmeldung nach 309 s (300 s Verzögerung + Polling), erneute Anmeldung erkannt. Nicht getestet: Dauerlauf über Nacht, Motion-Typ, Gast-WLAN.
|
|
36
|
+
|
|
37
|
+
## Bekannte Limitationen
|
|
38
|
+
|
|
39
|
+
- iPhones mit „private WLAN-Adresse: Rotierend“ wechseln die MAC; für das Heim-WLAN auf „Fest“ stellen (README).
|
|
40
|
+
- Die am iPhone angezeigte WLAN-Adresse kann von der abweichen, die die FRITZ!Box führt (beobachtet mit FRITZ!Repeatern im Mesh). MAC-Adresse aus der FRITZ!Box-Oberfläche nehmen oder besser die IPv4-Adresse dort dauerhaft zuweisen und die IP eintragen.
|
|
41
|
+
- Je Sensor nur eine Adresse.
|
|
42
|
+
- Die FRITZ!Box meldet nur „verbunden“, nicht „Person anwesend“. Erkennung frühestens nach einem Polling-Zyklus (`options.polling.timer`) plus Verzögerung.
|
|
43
|
+
- Jeder Zyklus fragt die Host-Liste der FRITZ!Box ab (zusätzliche Last).
|
|
44
|
+
- Der Bewegungssensor zeigt in der Eve-App keinen Verlauf (fakegato gestubbt).
|
|
45
|
+
- Die Charakteristik „Last Activation“ des Bewegungssensors zählt ab Start der Bridge (fakegato-Stub mit `getInitialTime()` aus v1.2.1).
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
5
49
|
# v1.2.1 — 2026-11-14 (Bugfix: Absturz bei offenem Fenster)
|
|
6
50
|
|
|
7
51
|
**Behebt #2.** Sobald ein Thermostat ein offenes Fenster meldete (`windowOpen: 1`) oder ein Kontakt-/Fenstersensor öffnete, stürzte die Child-Bridge mit `TypeError: historyService.getInitialTime is not a function` ab. Nach fünf Abstürzen stoppte Homebridge die Child-Bridge, und das ganze Plugin fiel aus, solange das Fenster offen war.
|
|
@@ -19,6 +63,8 @@ Der NoOp-Ersatz für fakegato-history in `src/platform.js` bildete nur `addEntry
|
|
|
19
63
|
|
|
20
64
|
Keine.
|
|
21
65
|
|
|
66
|
+
---
|
|
67
|
+
|
|
22
68
|
# v1.2.0 — 2026-05-14 (Wizard Edit-Mode / Phase G)
|
|
23
69
|
|
|
24
70
|
**Setup-Wizard erhält Aktualisierungs-Routine.** Beim erneuten Aufruf des Wizards (z.B. um ein weiteres Gerät hinzuzufügen) werden die in `config.json` gespeicherten Einstellungen jetzt vorgeladen statt überschrieben. Damit ist der Hauptschmerz aus v1.1.x adressiert: manuell vergebene Tile-Namen, gewählter `accType` (z.B. `energy-light` statt `energy-temperature`) und Router-Switch-Zustände überleben einen Wizard-Re-Run unverändert.
|
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ Production: läuft produktiv beim Maintainer mit 12 DECT-Thermostaten, 1 DECT-La
|
|
|
27
27
|
| FRITZ!DECT 200 / 210 (schaltbare Steckdose) | `switch` | An/Aus + optional Energiemessung |
|
|
28
28
|
| FRITZ!Smart Energy 250 (OBIS-Reader, an ISKRA o.ä.) | `energy-temperature` oder `energy-light` | Aktuelle Leistung in W, kumulierte Energie in kWh — entweder als TemperatureSensor (°C-Tile, prominent im Raum) oder LightSensor (lx-Tile, passive Sensor-Anzeige, per Favorit ins Home-Tab holbar) |
|
|
29
29
|
| FRITZ!Box Router (Master) | (via `options`) | Gast-WLAN-Switch, WPS-Switch, optional weitere Toggles |
|
|
30
|
+
| Handy/Tablet im WLAN (optional, seit v1.3.0) | `presence[]` mit `occupancy` oder `motion` | Anwesenheit: Belegungs- oder Bewegungssensor je Gerät, optional „Anyone“ und „Guest“ |
|
|
30
31
|
|
|
31
32
|
## Architektur
|
|
32
33
|
|
|
@@ -42,13 +43,14 @@ Das **Router-Accessory** (Master FritzBox) wird per default mit `hide: true` aus
|
|
|
42
43
|
Aus dem ursprünglichen Code-Baum entfernt oder nicht migriert — wer eine dieser Funktionen braucht, ist hier nicht richtig:
|
|
43
44
|
|
|
44
45
|
- **Callmonitor** (Anrufüberwachung)
|
|
45
|
-
- **Presence** (WLAN-Anwesenheit)
|
|
46
46
|
- **Network-Watcher**
|
|
47
47
|
- **Wake-on-LAN**
|
|
48
48
|
- **Child Lock** (Internet-Sperren)
|
|
49
49
|
- **Extras-Subsysteme** (DNS-Switch, Fallback-Internet, Phonebook, Telegram-Notifications) — die WiFi/WPS-Switches der Extras-Datei sind ausgenommen und voll funktional
|
|
50
50
|
- **`fakegato-history`** (Eve-App History-Graphen — als No-Op gestubbt weil HAP-v2-inkompatibel)
|
|
51
51
|
|
|
52
|
+
**Presence** (WLAN-Anwesenheit) war bis v1.2.0 ausgeschlossen und ist seit **v1.3.0 als optionale Funktion** zurück — standardmäßig aus, siehe [Anwesenheit per WLAN](#anwesenheit-per-wlan-optional-seit-v130). Telegram-Meldungen und die Ping-Prüfung des Original-Plugins gehören nicht dazu.
|
|
53
|
+
|
|
52
54
|
## Voraussetzungen
|
|
53
55
|
|
|
54
56
|
- **Homebridge** ≥ 1.8.5 oder ≥ 2.0.0
|
|
@@ -191,6 +193,50 @@ Beide werden seit v1.1.1 unterstützt. Standard ist `energy-temperature`. Empfeh
|
|
|
191
193
|
|
|
192
194
|
**Richtungs-Trennung:** AVM spiegelt den NET-Power-Wert auf beiden Sub-AINs (gleiche Zahl mit Vorzeichen). Das Plugin trennt das anhand des AIN-Suffixes — `-1` zeigt den Wert nur wenn `power > 0` (Bezug), `-2` zeigt `|power|` nur wenn `power < 0` (Einspeisung). Im Ruhe-/Inverse-Zustand zeigt die jeweils andere Kachel `0`.
|
|
193
195
|
|
|
196
|
+
### Anwesenheit per WLAN (optional, seit v1.3.0)
|
|
197
|
+
|
|
198
|
+
Zeigt in HomeKit, ob ein Gerät (typisch: iPhone) im Netz der FRITZ!Box ist — als **Belegungssensor** (`occupancy`, Standard) oder **Bewegungssensor** (`motion`). Damit lassen sich Automationen wie „Letzte Person geht → Heizung runter“ bauen, ohne Standortfreigabe in der Home-App.
|
|
199
|
+
|
|
200
|
+
Die Funktion ist **Opt-in**: Ohne `presence`-Einträge mit `active: true` passiert nichts. Einrichtung entweder im Setup-Wizard (Schritt 4, Abschnitt „Anwesenheit per WLAN“) oder in der `config.json`:
|
|
201
|
+
|
|
202
|
+
```json
|
|
203
|
+
{
|
|
204
|
+
"presence": [
|
|
205
|
+
{
|
|
206
|
+
"active": true,
|
|
207
|
+
"name": "iPhone Anna",
|
|
208
|
+
"address": "AA:BB:CC:DD:EE:FF",
|
|
209
|
+
"accType": "occupancy",
|
|
210
|
+
"offDelay": 300
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
"options": {
|
|
214
|
+
"presence": { "anyone": true, "guest": false, "accType": "occupancy" }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
| Feld | Bedeutung |
|
|
220
|
+
|---|---|
|
|
221
|
+
| `address` | IPv4- oder MAC-Adresse wie in der FRITZ!Box unter *Heimnetz → Netzwerk* angezeigt (Groß-/Kleinschreibung egal) |
|
|
222
|
+
| `accType` | `occupancy` (Belegung) oder `motion` (Bewegung) |
|
|
223
|
+
| `onDelay` | Sekunden, die das Gerät verbunden sein muss, bevor der Sensor „erkannt“ meldet (Standard 0) |
|
|
224
|
+
| `offDelay` | Sekunden, die das Gerät weg sein muss, bevor der Sensor „nicht erkannt“ meldet (**Standard 300**) |
|
|
225
|
+
| `options.presence.anyone` | zusätzlicher Sensor „Anyone“: erkannt, solange mindestens ein Gerät erkannt ist |
|
|
226
|
+
| `options.presence.guest` | zusätzlicher Sensor „Guest“: erkannt, solange ein Gerät im Gast-WLAN ist |
|
|
227
|
+
|
|
228
|
+
**Wichtig für iPhone/iPad — private WLAN-Adresse:** iOS nutzt je WLAN eine eigene „private WLAN-Adresse“, die sich je nach Einstellung (*Rotierend*) regelmäßig ändert. Damit die Zuordnung hält:
|
|
229
|
+
|
|
230
|
+
1. Auf dem iPhone *Einstellungen → WLAN → (i) neben dem Heim-WLAN → Private WLAN-Adresse* auf **Fest** stellen (oder ausschalten). Die neue Adresse gilt erst nach dem nächsten Verbinden: WLAN einmal aus- und wieder einschalten.
|
|
231
|
+
2. In der FRITZ!Box das Gerät öffnen (*Heimnetz → Netzwerk → Gerät → Heimnetz*) und die **MAC-Adresse von dort** übernehmen, **nicht** die am iPhone angezeigte. Beide können abweichen, im Test mit FRITZ!Repeatern im Mesh war das der Fall.
|
|
232
|
+
3. **Besser:** dort **„IPv4-Adresse dauerhaft zuweisen“** aktivieren und diese IP als `address` eintragen. Das ist die robusteste Zuordnung.
|
|
233
|
+
|
|
234
|
+
Bleibt ein Sensor dauerhaft auf „nicht erkannt“, obwohl das Gerät im WLAN ist: in der FRITZ!Box nachsehen, unter welcher MAC/IP es gerade geführt wird, und die Config anpassen.
|
|
235
|
+
|
|
236
|
+
**Warum die Abmeldeverzögerung:** Schlafende iPhones trennen sich zum Stromsparen immer wieder kurz vom WLAN. Ohne Verzögerung springt der Sensor ständig zwischen „zu Hause“ und „weg“. `offDelay` unter 60 s erzeugt deshalb eine Warnung im Log; 300 s (5 min) ist ein guter Startwert, bei Fehlalarmen auf 600–900 s erhöhen. Die Erkennung ist so schnell wie das Polling (`options.polling.timer`, Standard 15 s) plus Verzögerung.
|
|
237
|
+
|
|
238
|
+
**Grenzen:** Die FRITZ!Box meldet nur, ob das Gerät verbunden ist — nicht, ob es wirklich jemand bei sich trägt. Jeder Abfragezyklus fragt die Host-Liste der FRITZ!Box ab (zusätzliche Last bei vielen Geräten oder kurzem Polling-Intervall). Meldet das Log beim Abruf der Host-Liste einen Berechtigungsfehler, dem FRITZ!Box-Benutzer zusätzlich *FRITZ!Box-Einstellungen* geben (wie bei den Router-Switches). Beim Bewegungssensor zeigt die Eve-App keine Verlaufsgrafik (fakegato ist gestubbt).
|
|
239
|
+
|
|
194
240
|
## Migration vom Original-Plugin
|
|
195
241
|
|
|
196
242
|
Wenn du bisher das unmaintained Original `homebridge-fritz-platform@6.0.19` eingesetzt hast:
|
|
@@ -199,7 +245,7 @@ Wenn du bisher das unmaintained Original `homebridge-fritz-platform@6.0.19` eing
|
|
|
199
245
|
2. **Update Homebridge auf 2.x**: `sudo npm install -g homebridge@latest`
|
|
200
246
|
3. **Original-Plugin entfernen**: `sudo npm uninstall -g homebridge-fritz-platform`
|
|
201
247
|
4. **Dieses Plugin installieren**: `sudo npm install -g @pellebot/homebridge-fritz-platform-hb2`
|
|
202
|
-
5. **Config anpassen**: nicht-mehr-unterstützte Subsysteme aus der Config entfernen (`
|
|
248
|
+
5. **Config anpassen**: nicht-mehr-unterstützte Subsysteme aus der Config entfernen (`callmonitor`, `wol`, `childLock`, `network`, `extras`, `telegram` — werden eh ignoriert, aber sauberer wenn weg). Bestehende `presence`-Einträge werden seit v1.3.0 wieder ausgewertet; `offDelay` prüfen (Standard jetzt 300 s), `ping`/`threshold`/`blockTelegram` werden nicht unterstützt.
|
|
203
249
|
6. **Homebridge neu starten**
|
|
204
250
|
|
|
205
251
|
Accessory-UUIDs basieren auf AIN + Name. Wer Device-Namen unverändert lässt, behält das HomeKit-Pairing bei und muss nichts in der Home-App neu zuordnen.
|
package/config.schema.json
CHANGED
|
@@ -708,6 +708,69 @@
|
|
|
708
708
|
}
|
|
709
709
|
}
|
|
710
710
|
},
|
|
711
|
+
"presence": {
|
|
712
|
+
"title": "Presence (WLAN, optional)",
|
|
713
|
+
"type": "array",
|
|
714
|
+
"description": "Opt-in since v1.3.0. Exposes an occupancy or motion sensor that shows whether a device (e.g. an iPhone) is connected to the FRITZ!Box network. Off unless an entry is set to active.",
|
|
715
|
+
"items": {
|
|
716
|
+
"type": "object",
|
|
717
|
+
"properties": {
|
|
718
|
+
"active": {
|
|
719
|
+
"title": "Active",
|
|
720
|
+
"type": "boolean",
|
|
721
|
+
"default": false,
|
|
722
|
+
"description": "If enabled, the presence sensor will be exposed to HomeKit."
|
|
723
|
+
},
|
|
724
|
+
"name": {
|
|
725
|
+
"title": "Name",
|
|
726
|
+
"type": "string",
|
|
727
|
+
"placeholder": "iPhone Andreas",
|
|
728
|
+
"description": "Name of the sensor in HomeKit. Must be unique and must not be \"Anyone\" or \"Guest\".",
|
|
729
|
+
"required": true
|
|
730
|
+
},
|
|
731
|
+
"address": {
|
|
732
|
+
"title": "IP or MAC address",
|
|
733
|
+
"type": "string",
|
|
734
|
+
"placeholder": "AA:BB:CC:DD:EE:FF",
|
|
735
|
+
"description": "IPv4 or MAC address of the device as shown in the FRITZ!Box (Heimnetz > Netzwerk). iPhones use a private Wi-Fi address: set it to \"Fixed\" for the home network, reconnect once, then take the MAC address from the FRITZ!Box device details (not the one shown on the phone) or, better, assign a permanent IPv4 address there and use that IP.",
|
|
736
|
+
"required": true
|
|
737
|
+
},
|
|
738
|
+
"accType": {
|
|
739
|
+
"title": "Accessory Type",
|
|
740
|
+
"type": "string",
|
|
741
|
+
"default": "occupancy",
|
|
742
|
+
"oneOf": [
|
|
743
|
+
{
|
|
744
|
+
"title": "Occupancy Sensor",
|
|
745
|
+
"enum": [
|
|
746
|
+
"occupancy"
|
|
747
|
+
]
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
"title": "Motion Sensor",
|
|
751
|
+
"enum": [
|
|
752
|
+
"motion"
|
|
753
|
+
]
|
|
754
|
+
}
|
|
755
|
+
]
|
|
756
|
+
},
|
|
757
|
+
"onDelay": {
|
|
758
|
+
"title": "Arrival delay (seconds)",
|
|
759
|
+
"type": "integer",
|
|
760
|
+
"minimum": 0,
|
|
761
|
+
"placeholder": 0,
|
|
762
|
+
"description": "Seconds the device must be connected before the sensor switches to detected. Default 0."
|
|
763
|
+
},
|
|
764
|
+
"offDelay": {
|
|
765
|
+
"title": "Leave delay (seconds)",
|
|
766
|
+
"type": "integer",
|
|
767
|
+
"minimum": 0,
|
|
768
|
+
"placeholder": 300,
|
|
769
|
+
"description": "Seconds the device must be disconnected before the sensor switches to not detected. Default 300. Sleeping iPhones leave the Wi-Fi for short periods; values below 60 make the sensor flap."
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
},
|
|
711
774
|
"options": {
|
|
712
775
|
"titel": "Options",
|
|
713
776
|
"type": "object",
|
|
@@ -740,6 +803,43 @@
|
|
|
740
803
|
}
|
|
741
804
|
}
|
|
742
805
|
}
|
|
806
|
+
},
|
|
807
|
+
"presence": {
|
|
808
|
+
"title": "Presence",
|
|
809
|
+
"type": "object",
|
|
810
|
+
"properties": {
|
|
811
|
+
"anyone": {
|
|
812
|
+
"title": "\"Anyone\" sensor",
|
|
813
|
+
"type": "boolean",
|
|
814
|
+
"default": false,
|
|
815
|
+
"description": "Additional sensor that is detected as long as at least one active presence device is detected."
|
|
816
|
+
},
|
|
817
|
+
"guest": {
|
|
818
|
+
"title": "\"Guest\" sensor",
|
|
819
|
+
"type": "boolean",
|
|
820
|
+
"default": false,
|
|
821
|
+
"description": "Additional sensor that is detected while at least one device is connected to the guest WLAN."
|
|
822
|
+
},
|
|
823
|
+
"accType": {
|
|
824
|
+
"title": "Accessory Type for Anyone/Guest",
|
|
825
|
+
"type": "string",
|
|
826
|
+
"default": "occupancy",
|
|
827
|
+
"oneOf": [
|
|
828
|
+
{
|
|
829
|
+
"title": "Occupancy Sensor",
|
|
830
|
+
"enum": [
|
|
831
|
+
"occupancy"
|
|
832
|
+
]
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
"title": "Motion Sensor",
|
|
836
|
+
"enum": [
|
|
837
|
+
"motion"
|
|
838
|
+
]
|
|
839
|
+
}
|
|
840
|
+
]
|
|
841
|
+
}
|
|
842
|
+
}
|
|
743
843
|
}
|
|
744
844
|
}
|
|
745
845
|
}
|
|
@@ -902,6 +1002,33 @@
|
|
|
902
1002
|
}
|
|
903
1003
|
]
|
|
904
1004
|
},
|
|
1005
|
+
{
|
|
1006
|
+
"title": "Presence (WLAN, optional)",
|
|
1007
|
+
"description": "Opt-in: shows in HomeKit whether a phone is in the home network. Leave empty to keep the feature off.",
|
|
1008
|
+
"expandable": true,
|
|
1009
|
+
"expanded": false,
|
|
1010
|
+
"orderable": false,
|
|
1011
|
+
"type": "section",
|
|
1012
|
+
"items": [
|
|
1013
|
+
{
|
|
1014
|
+
"key": "presence",
|
|
1015
|
+
"type": "array",
|
|
1016
|
+
"orderable": false,
|
|
1017
|
+
"buttonText": "Add Presence Device",
|
|
1018
|
+
"items": [
|
|
1019
|
+
"presence[].active",
|
|
1020
|
+
"presence[].name",
|
|
1021
|
+
"presence[].address",
|
|
1022
|
+
"presence[].accType",
|
|
1023
|
+
"presence[].onDelay",
|
|
1024
|
+
"presence[].offDelay"
|
|
1025
|
+
]
|
|
1026
|
+
},
|
|
1027
|
+
"options.presence.anyone",
|
|
1028
|
+
"options.presence.guest",
|
|
1029
|
+
"options.presence.accType"
|
|
1030
|
+
]
|
|
1031
|
+
},
|
|
905
1032
|
{
|
|
906
1033
|
"title": "Options",
|
|
907
1034
|
"orderable": false,
|
|
@@ -307,6 +307,40 @@
|
|
|
307
307
|
</div>
|
|
308
308
|
</details>
|
|
309
309
|
|
|
310
|
+
<!-- v1.3.0: optional WLAN presence (opt-in) -->
|
|
311
|
+
<details id="presenceDetails" style="margin-top: 1rem;">
|
|
312
|
+
<summary style="cursor: pointer;"><strong>Anwesenheit per WLAN (optional)</strong> — zeigt in HomeKit, ob ein Handy im Heimnetz ist</summary>
|
|
313
|
+
<div style="margin-top: 0.5rem; padding: 0.5rem; border-left: 3px solid #339DE0;">
|
|
314
|
+
<p style="font-size: 0.9rem;">
|
|
315
|
+
Je Gerät ein Sensor (Belegung oder Bewegung). Zuordnung über die IP- oder MAC-Adresse,
|
|
316
|
+
wie sie die FRITZ!Box unter <em>Heimnetz → Netzwerk</em> anzeigt.
|
|
317
|
+
<strong>iPhone/iPad:</strong> In den WLAN-Einstellungen des Geräts für das Heim-WLAN
|
|
318
|
+
<em>Private WLAN-Adresse → Fest</em> (bzw. „Aus“) wählen, WLAN einmal neu verbinden und dann die
|
|
319
|
+
MAC-Adresse <strong>aus der FRITZ!Box-Oberfläche</strong> nehmen (nicht die am iPhone angezeigte) oder besser
|
|
320
|
+
dort die IPv4-Adresse dauerhaft zuweisen und die IP eintragen.
|
|
321
|
+
Die <em>Abmeldeverzögerung</em> verhindert, dass der Sensor springt, wenn ein schlafendes
|
|
322
|
+
iPhone kurz aus dem WLAN geht (Standard 300 s).
|
|
323
|
+
</p>
|
|
324
|
+
<div id="presenceList"></div>
|
|
325
|
+
<button id="presenceAdd" type="button" class="btn btn-sm btn-outline-primary">+ Gerät hinzufügen</button>
|
|
326
|
+
<div style="margin-top: 0.75rem;">
|
|
327
|
+
<div class="form-check">
|
|
328
|
+
<input type="checkbox" id="presenceAnyone" class="form-check-input">
|
|
329
|
+
<label class="form-check-label" for="presenceAnyone">Zusätzlicher Sensor <em>„Anyone“</em> (jemand ist zu Hause)</label>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="form-check">
|
|
332
|
+
<input type="checkbox" id="presenceGuest" class="form-check-input">
|
|
333
|
+
<label class="form-check-label" for="presenceGuest">Zusätzlicher Sensor <em>„Guest“</em> (Gerät im Gast-WLAN)</label>
|
|
334
|
+
</div>
|
|
335
|
+
<label for="presenceAccType" style="font-size: 0.9rem; margin-top: 0.25rem;">Typ für Anyone/Guest:</label>
|
|
336
|
+
<select id="presenceAccType" class="form-select form-select-sm" style="max-width: 220px; display: inline-block;">
|
|
337
|
+
<option value="occupancy">Belegungssensor</option>
|
|
338
|
+
<option value="motion">Bewegungssensor</option>
|
|
339
|
+
</select>
|
|
340
|
+
</div>
|
|
341
|
+
</div>
|
|
342
|
+
</details>
|
|
343
|
+
|
|
310
344
|
<div class="wizard-nav">
|
|
311
345
|
<button id="backTo3" class="btn btn-outline-secondary">← Zurück zu Schritt 3</button>
|
|
312
346
|
<button id="goTo5" class="btn btn-primary">Weiter zu Schritt 5 →</button>
|
|
@@ -352,7 +386,9 @@
|
|
|
352
386
|
energyTiles: {}, // { ain: [{ obisChannel, name }, ...] } after step 3
|
|
353
387
|
energyAccType: 'energy-temperature', // 'energy-temperature' | 'energy-light' — chosen in step 3
|
|
354
388
|
routerSwitches: {}, // { wifi_guest: bool, wps: bool, ... } after step 4
|
|
355
|
-
existingConfig: null, // Phase G: { master, smarthome[] } from current config.json (null = first run)
|
|
389
|
+
existingConfig: null, // Phase G: { master, smarthome[], presence[], presenceOptions } from current config.json (null = first run)
|
|
390
|
+
presence: null, // v1.3.0: [{ active, name, address, accType, onDelay?, offDelay }] (null = not touched yet)
|
|
391
|
+
presenceOptions: null, // v1.3.0: { anyone, guest, accType }
|
|
356
392
|
};
|
|
357
393
|
|
|
358
394
|
// ============ DOM References ============
|
|
@@ -410,7 +446,10 @@
|
|
|
410
446
|
if (!fritz) return null;
|
|
411
447
|
const master = (fritz.devices || []).find((d) => d && d.master === true) || null;
|
|
412
448
|
const smarthome = Array.isArray(fritz.smarthome) ? fritz.smarthome : [];
|
|
413
|
-
|
|
449
|
+
// v1.3.0: keep presence entries so a wizard re-run does not drop them
|
|
450
|
+
const presence = Array.isArray(fritz.presence) ? fritz.presence : [];
|
|
451
|
+
const presenceOptions = (fritz.options && fritz.options.presence) || null;
|
|
452
|
+
return { master, smarthome, presence, presenceOptions };
|
|
414
453
|
} catch (err) {
|
|
415
454
|
return null;
|
|
416
455
|
}
|
|
@@ -670,17 +709,85 @@
|
|
|
670
709
|
goTo4.addEventListener('click', () => {
|
|
671
710
|
showStep(4);
|
|
672
711
|
hydrateRouterSwitches();
|
|
712
|
+
hydratePresence();
|
|
673
713
|
});
|
|
674
714
|
backTo3.addEventListener('click', () => showStep(3));
|
|
675
715
|
|
|
676
716
|
const goTo5 = document.getElementById('goTo5');
|
|
677
717
|
const backTo4 = document.getElementById('backTo4');
|
|
678
718
|
goTo5.addEventListener('click', () => {
|
|
719
|
+
collectPresence();
|
|
679
720
|
showStep(5);
|
|
680
721
|
renderSavePreview();
|
|
681
722
|
});
|
|
682
723
|
backTo4.addEventListener('click', () => showStep(4));
|
|
683
724
|
|
|
725
|
+
// ============ Step 4b — Presence (optional, v1.3.0) ============
|
|
726
|
+
const presenceList = document.getElementById('presenceList');
|
|
727
|
+
|
|
728
|
+
function presenceRow(entry) {
|
|
729
|
+
const e = entry || { active: true, name: '', address: '', accType: 'occupancy', offDelay: 300 };
|
|
730
|
+
const row = document.createElement('div');
|
|
731
|
+
row.className = 'presence-row device-card';
|
|
732
|
+
row.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.5rem; align-items:center; margin-bottom:0.5rem;';
|
|
733
|
+
if (typeof e.onDelay === 'number') row.dataset.onDelay = String(e.onDelay);
|
|
734
|
+
row.innerHTML = `
|
|
735
|
+
<input type="checkbox" class="form-check-input p-active" title="Aktiv" ${e.active ? 'checked' : ''}>
|
|
736
|
+
<input type="text" class="form-control form-control-sm p-name" placeholder="Name, z.B. iPhone Anna" style="max-width:180px;" value="${escapeHtml(e.name || '')}">
|
|
737
|
+
<input type="text" class="form-control form-control-sm p-address" placeholder="IP oder MAC" style="max-width:170px;" value="${escapeHtml(e.address || '')}">
|
|
738
|
+
<select class="form-select form-select-sm p-acctype" style="max-width:150px;">
|
|
739
|
+
<option value="occupancy" ${e.accType !== 'motion' ? 'selected' : ''}>Belegung</option>
|
|
740
|
+
<option value="motion" ${e.accType === 'motion' ? 'selected' : ''}>Bewegung</option>
|
|
741
|
+
</select>
|
|
742
|
+
<label style="font-size:0.8rem;">Abmeldung nach
|
|
743
|
+
<input type="number" min="0" class="form-control form-control-sm p-offdelay" style="width:80px; display:inline-block;" value="${typeof e.offDelay === 'number' ? e.offDelay : 300}"> s</label>
|
|
744
|
+
<button type="button" class="btn btn-sm btn-outline-danger p-remove" title="Entfernen">✕</button>
|
|
745
|
+
`;
|
|
746
|
+
row.querySelector('.p-remove').addEventListener('click', () => row.remove());
|
|
747
|
+
presenceList.appendChild(row);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function hydratePresence() {
|
|
751
|
+
if (presenceList.dataset.hydrated) return; // keep user edits when navigating back
|
|
752
|
+
presenceList.dataset.hydrated = '1';
|
|
753
|
+
const cfg = wizardState.existingConfig || {};
|
|
754
|
+
const entries = cfg.presence || [];
|
|
755
|
+
entries.forEach((e) => presenceRow(e));
|
|
756
|
+
const opts = cfg.presenceOptions || {};
|
|
757
|
+
document.getElementById('presenceAnyone').checked = !!opts.anyone;
|
|
758
|
+
document.getElementById('presenceGuest').checked = !!opts.guest;
|
|
759
|
+
document.getElementById('presenceAccType').value = opts.accType === 'motion' ? 'motion' : 'occupancy';
|
|
760
|
+
if (entries.length || opts.anyone || opts.guest) document.getElementById('presenceDetails').open = true;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
document.getElementById('presenceAdd').addEventListener('click', () => presenceRow(null));
|
|
764
|
+
|
|
765
|
+
function collectPresence() {
|
|
766
|
+
if (!presenceList.dataset.hydrated) return; // step 4 never shown — keep existing as is
|
|
767
|
+
const entries = [];
|
|
768
|
+
presenceList.querySelectorAll('.presence-row').forEach((row) => {
|
|
769
|
+
const name = row.querySelector('.p-name').value.trim();
|
|
770
|
+
const address = row.querySelector('.p-address').value.trim();
|
|
771
|
+
if (!name && !address) return;
|
|
772
|
+
const off = parseInt(row.querySelector('.p-offdelay').value, 10);
|
|
773
|
+
const entry = {
|
|
774
|
+
active: row.querySelector('.p-active').checked,
|
|
775
|
+
name,
|
|
776
|
+
address,
|
|
777
|
+
accType: row.querySelector('.p-acctype').value,
|
|
778
|
+
offDelay: Number.isFinite(off) && off >= 0 ? off : 300,
|
|
779
|
+
};
|
|
780
|
+
if (row.dataset.onDelay) entry.onDelay = parseInt(row.dataset.onDelay, 10);
|
|
781
|
+
entries.push(entry);
|
|
782
|
+
});
|
|
783
|
+
wizardState.presence = entries;
|
|
784
|
+
wizardState.presenceOptions = {
|
|
785
|
+
anyone: document.getElementById('presenceAnyone').checked,
|
|
786
|
+
guest: document.getElementById('presenceGuest').checked,
|
|
787
|
+
accType: document.getElementById('presenceAccType').value,
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
684
791
|
// ============ Step 5 — Build + Save ============
|
|
685
792
|
|
|
686
793
|
/**
|
|
@@ -786,7 +893,7 @@
|
|
|
786
893
|
}
|
|
787
894
|
}
|
|
788
895
|
|
|
789
|
-
|
|
896
|
+
const config = {
|
|
790
897
|
platform: 'FritzPlatform',
|
|
791
898
|
name: 'FritzBox',
|
|
792
899
|
devices: [master],
|
|
@@ -795,6 +902,19 @@
|
|
|
795
902
|
polling: { timer: 30 },
|
|
796
903
|
},
|
|
797
904
|
};
|
|
905
|
+
|
|
906
|
+
// v1.3.0: presence (opt-in). Use the wizard input if step 4 was shown,
|
|
907
|
+
// otherwise carry over what is already in config.json.
|
|
908
|
+
const existing = wizardState.existingConfig || {};
|
|
909
|
+
const presence = wizardState.presence !== null ? wizardState.presence : existing.presence || [];
|
|
910
|
+
const presenceOptions =
|
|
911
|
+
wizardState.presenceOptions !== null ? wizardState.presenceOptions : existing.presenceOptions || null;
|
|
912
|
+
if (presence.length) config.presence = presence;
|
|
913
|
+
if (presenceOptions && (presenceOptions.anyone || presenceOptions.guest)) {
|
|
914
|
+
config.options.presence = presenceOptions;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return config;
|
|
798
918
|
}
|
|
799
919
|
|
|
800
920
|
/**
|
|
@@ -868,6 +988,13 @@
|
|
|
868
988
|
lines.push(`Entfernt: <code>${escapeHtml(o.ain)}</code>${chan} — war <em>${escapeHtml(o.accType)}</em> "${escapeHtml(o.name || '')}"`);
|
|
869
989
|
}
|
|
870
990
|
|
|
991
|
+
// -- Presence (v1.3.0) --
|
|
992
|
+
const oldPresence = (existing.presence || []).filter((p) => p && p.active).map((p) => `${p.name}|${p.address}|${p.accType || 'occupancy'}|${p.offDelay}`);
|
|
993
|
+
const newPresence = (newConfig.presence || []).filter((p) => p && p.active).map((p) => `${p.name}|${p.address}|${p.accType || 'occupancy'}|${p.offDelay}`);
|
|
994
|
+
if (oldPresence.sort().join(';') !== newPresence.sort().join(';')) {
|
|
995
|
+
lines.push(`Anwesenheit: ${oldPresence.length} → ${newPresence.length} aktive(r) Sensor(en)`);
|
|
996
|
+
}
|
|
997
|
+
|
|
871
998
|
if (lines.length === 0) {
|
|
872
999
|
return `
|
|
873
1000
|
<div class="alert alert-secondary">
|
|
@@ -911,6 +1038,7 @@
|
|
|
911
1038
|
<li><strong>${nDevices}</strong> Smart-Home-Gerät(e) (Thermostate, Lampen, Sensoren, Schalter)</li>
|
|
912
1039
|
<li><strong>${nEnergy}</strong> Energy-Tile(s) für FRITZ!Smart Energy 250 OBIS-Reader</li>
|
|
913
1040
|
<li><strong>${nRouter}</strong> Router-Switch(es) als HomeKit-Tiles</li>
|
|
1041
|
+
<li><strong>${(config.presence || []).filter((p) => p.active).length}</strong> Anwesenheitssensor(en) (WLAN, optional)</li>
|
|
914
1042
|
</ul>
|
|
915
1043
|
</div>
|
|
916
1044
|
`;
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellebot/homebridge-fritz-platform-hb2",
|
|
3
3
|
"displayName": "Homebridge FRITZ!Box Platform (HB2)",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"description": "Homebridge 2.0 plugin for AVM FRITZ!DECT devices (thermostats, lamps, sensors, OBIS smart-meter readers)
|
|
4
|
+
"version": "1.3.0",
|
|
5
|
+
"description": "Homebridge 2.0 plugin for AVM FRITZ!DECT devices (thermostats, lamps, sensors, OBIS smart-meter readers) FRITZ!Box router switches (Guest WLAN, WPS) and optional WLAN presence sensors. Node 22 / 24 ready.",
|
|
6
6
|
"icon": "https://raw.githubusercontent.com/PelleBot/homebridge-fritz-platform-ng/main/branding/Icon.png",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "index.mjs",
|
|
@@ -42,7 +42,8 @@
|
|
|
42
42
|
"avm",
|
|
43
43
|
"thermostat",
|
|
44
44
|
"fritz-dect",
|
|
45
|
-
"homebridge-2"
|
|
45
|
+
"homebridge-2",
|
|
46
|
+
"presence"
|
|
46
47
|
],
|
|
47
48
|
"engines": {
|
|
48
49
|
"node": "^22.12.0 || ^24.0.0",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const moment = require('moment');
|
|
4
|
+
const logger = require('../../../utils/logger');
|
|
5
|
+
const Handler = require('../presence.handler');
|
|
6
|
+
const GuestHandler = require('../presence_guest.handler');
|
|
7
|
+
|
|
8
|
+
const timeout = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
9
|
+
|
|
10
|
+
class Accessory {
|
|
11
|
+
constructor(api, accessory, accessories, meshMaster, HistoryService) {
|
|
12
|
+
this.api = api;
|
|
13
|
+
this.accessory = accessory;
|
|
14
|
+
this.accessories = accessories;
|
|
15
|
+
this.HistoryService = HistoryService;
|
|
16
|
+
|
|
17
|
+
if (accessory.displayName === 'Guest') {
|
|
18
|
+
this.handler = GuestHandler.configure(api, accessories, accessory.context.config.polling, meshMaster);
|
|
19
|
+
} else {
|
|
20
|
+
this.handler = Handler.configure(api, accessories, accessory.context.config.polling, meshMaster);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
this.getService();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
|
|
27
|
+
// Services
|
|
28
|
+
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
|
|
29
|
+
|
|
30
|
+
async getService() {
|
|
31
|
+
let service = this.accessory.getService(this.api.hap.Service.MotionSensor);
|
|
32
|
+
let serviceOld = this.accessory.getService(this.api.hap.Service.OccupancySensor);
|
|
33
|
+
|
|
34
|
+
if (serviceOld) {
|
|
35
|
+
logger.info(
|
|
36
|
+
'Removing Occupancy service',
|
|
37
|
+
`${this.accessory.displayName} (${this.accessory.context.config.subtype})`
|
|
38
|
+
);
|
|
39
|
+
this.accessory.removeService(serviceOld);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!service) {
|
|
43
|
+
logger.info('Adding Motion service', `${this.accessory.displayName} (${this.accessory.context.config.subtype})`);
|
|
44
|
+
service = this.accessory.addService(
|
|
45
|
+
this.api.hap.Service.MotionSensor,
|
|
46
|
+
this.accessory.displayName,
|
|
47
|
+
this.accessory.context.config.subtype
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!service.testCharacteristic(this.api.hap.Characteristic.LastActivation)) {
|
|
52
|
+
service.addCharacteristic(this.api.hap.Characteristic.LastActivation);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
this.historyService = new this.HistoryService('motion', this.accessory, {
|
|
56
|
+
storage: 'fs',
|
|
57
|
+
path: this.api.user.storagePath() + '/fritzbox/',
|
|
58
|
+
disableTimer: true,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await timeout(250); //wait for historyService to load
|
|
62
|
+
|
|
63
|
+
service
|
|
64
|
+
.getCharacteristic(this.api.hap.Characteristic.MotionDetected)
|
|
65
|
+
.on('change', (context) => this.handler.change(context, this.accessory, null, this.historyService));
|
|
66
|
+
|
|
67
|
+
this.refreshHistory(service);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async refreshHistory(service) {
|
|
71
|
+
let state = service.getCharacteristic(this.api.hap.Characteristic.MotionDetected).value;
|
|
72
|
+
|
|
73
|
+
this.historyService.addEntry({
|
|
74
|
+
time: moment().unix(),
|
|
75
|
+
status: state ? 1 : 0,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
setTimeout(() => {
|
|
79
|
+
this.refreshHistory(service);
|
|
80
|
+
}, 10 * 60 * 1000);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = Accessory;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('../../../utils/logger');
|
|
4
|
+
const Handler = require('../presence.handler');
|
|
5
|
+
const GuestHandler = require('../presence_guest.handler');
|
|
6
|
+
|
|
7
|
+
class Accessory {
|
|
8
|
+
constructor(api, accessory, accessories, meshMaster) {
|
|
9
|
+
this.api = api;
|
|
10
|
+
this.accessory = accessory;
|
|
11
|
+
|
|
12
|
+
if (accessory.displayName === 'Guest') {
|
|
13
|
+
this.handler = GuestHandler.configure(api, accessories, accessory.context.config.polling, meshMaster);
|
|
14
|
+
} else {
|
|
15
|
+
this.handler = Handler.configure(api, accessories, accessory.context.config.polling, meshMaster);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
this.getService();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
|
|
22
|
+
// Services
|
|
23
|
+
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~//
|
|
24
|
+
|
|
25
|
+
async getService() {
|
|
26
|
+
let service = this.accessory.getService(this.api.hap.Service.OccupancySensor);
|
|
27
|
+
let serviceOld = this.accessory.getService(this.api.hap.Service.MotionSensor);
|
|
28
|
+
|
|
29
|
+
if (serviceOld) {
|
|
30
|
+
logger.info(
|
|
31
|
+
'Removing Motion service',
|
|
32
|
+
`${this.accessory.displayName} (${this.accessory.context.config.subtype})`
|
|
33
|
+
);
|
|
34
|
+
this.accessory.removeService(serviceOld);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!service) {
|
|
38
|
+
logger.info(
|
|
39
|
+
'Adding Occupancy service',
|
|
40
|
+
`${this.accessory.displayName} (${this.accessory.context.config.subtype})`
|
|
41
|
+
);
|
|
42
|
+
service = this.accessory.addService(
|
|
43
|
+
this.api.hap.Service.OccupancySensor,
|
|
44
|
+
this.accessory.displayName,
|
|
45
|
+
this.accessory.context.config.subtype
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
service
|
|
50
|
+
.getCharacteristic(this.api.hap.Characteristic.OccupancyDetected)
|
|
51
|
+
.on('change', (context) => this.handler.change(context, this.accessory));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = Accessory;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { validIP, validMAC } = require('../../utils/utils');
|
|
4
|
+
|
|
5
|
+
// Default leave delay (seconds). iPhones drop off the WLAN for short periods
|
|
6
|
+
// while asleep; without a delay the sensor would flap between home/away.
|
|
7
|
+
const DEFAULT_OFF_DELAY = 300;
|
|
8
|
+
|
|
9
|
+
const toInt = (value, fallback) => {
|
|
10
|
+
const parsed = parseInt(value);
|
|
11
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const Config = (userConfig) => {
|
|
15
|
+
const validTypes = ['occupancy', 'motion'];
|
|
16
|
+
const rawAddress = typeof userConfig.address === 'string' ? userConfig.address.trim() : userConfig.address;
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
active: userConfig.active || false,
|
|
20
|
+
name: userConfig.name,
|
|
21
|
+
// MACs are normalised to upper case (FRITZ!Box host list format)
|
|
22
|
+
address: validIP(rawAddress) || validMAC(typeof rawAddress === 'string' ? rawAddress.toUpperCase() : rawAddress),
|
|
23
|
+
type: 'presence',
|
|
24
|
+
subtype: validTypes.find((el) => el === userConfig.accType) || 'occupancy',
|
|
25
|
+
blockTelegram: userConfig.blockTelegram || false,
|
|
26
|
+
// Ping fallback of the original plugin is not supported in this fork
|
|
27
|
+
// (sleeping iPhones do not answer ICMP either; needs the system ping binary).
|
|
28
|
+
ping: false,
|
|
29
|
+
threshold: parseInt(userConfig.threshold) || 0,
|
|
30
|
+
accType: validTypes.find((el) => el === userConfig.accType) || 'occupancy',
|
|
31
|
+
onDelay: toInt(userConfig.onDelay, 0),
|
|
32
|
+
offDelay: toInt(userConfig.offDelay, DEFAULT_OFF_DELAY),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
module.exports = Config;
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const moment = require('moment');
|
|
4
|
+
const ping = require('ping');
|
|
5
|
+
const logger = require('../../utils/logger');
|
|
6
|
+
const { validIP } = require('../../utils/utils');
|
|
7
|
+
const Telegram = require('../../lib/telegram');
|
|
8
|
+
|
|
9
|
+
class Handler {
|
|
10
|
+
constructor() {
|
|
11
|
+
this.hosts = [];
|
|
12
|
+
this.configured = false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
configure(api, accessories, polling, meshMaster) {
|
|
16
|
+
if (this.configured) {
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
this.api = api;
|
|
21
|
+
this.accessories = accessories;
|
|
22
|
+
this.polling = polling;
|
|
23
|
+
this.fritzbox = meshMaster.fritzbox;
|
|
24
|
+
|
|
25
|
+
this.configured = true;
|
|
26
|
+
|
|
27
|
+
setTimeout(() => this.poll(), 1000);
|
|
28
|
+
|
|
29
|
+
return this;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// eslint-disable-next-line no-unused-vars
|
|
33
|
+
async change(context, accessory, subtype, historyService) {
|
|
34
|
+
if (context.oldValue !== context.newValue) {
|
|
35
|
+
if (!this.configured) {
|
|
36
|
+
logger.debug(
|
|
37
|
+
'Handler not configured yet. Skipping CHANGE event.',
|
|
38
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
39
|
+
);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
subtype = subtype || accessory.context.config.subtype;
|
|
44
|
+
|
|
45
|
+
// The fakegato stub may not implement getInitialTime() (see issue #2)
|
|
46
|
+
if (historyService && typeof historyService.getInitialTime === 'function') {
|
|
47
|
+
const lastActivation = moment().unix() - historyService.getInitialTime();
|
|
48
|
+
|
|
49
|
+
accessory
|
|
50
|
+
.getService(this.api.hap.Service.MotionSensor)
|
|
51
|
+
.getCharacteristic(this.api.hap.Characteristic.LastActivation)
|
|
52
|
+
.updateValue(lastActivation);
|
|
53
|
+
|
|
54
|
+
historyService.addEntry({ time: moment().unix(), status: context.newValue ? 1 : 0 });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!accessory.context.config.blockTelegram) {
|
|
58
|
+
let dest = false;
|
|
59
|
+
|
|
60
|
+
if (context.newValue) {
|
|
61
|
+
dest = accessory.displayName === 'Anyone' ? 'anyone_in' : 'user_in';
|
|
62
|
+
} else {
|
|
63
|
+
dest = accessory.displayName === 'Anyone' ? 'anyone_out' : 'user_out';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
Telegram.send('presence', dest, accessory.displayName === 'Anyone' ? false : accessory.displayName);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// eslint-disable-next-line no-unused-vars
|
|
72
|
+
async get(accessory, subtype, ownCharacteristic) {
|
|
73
|
+
if (!this.configured) {
|
|
74
|
+
logger.debug(
|
|
75
|
+
'Handler not configured yet. Skipping GET event.',
|
|
76
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
77
|
+
);
|
|
78
|
+
return accessory.context.config.subtype === 'motion' ? false : 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// eslint-disable-next-line no-unused-vars
|
|
82
|
+
subtype = subtype || accessory.context.config.subtype;
|
|
83
|
+
|
|
84
|
+
const service =
|
|
85
|
+
accessory.context.config.subtype === 'motion'
|
|
86
|
+
? this.api.hap.Service.MotionSensor
|
|
87
|
+
: this.api.hap.Service.OccupancySensor;
|
|
88
|
+
|
|
89
|
+
const characteristic =
|
|
90
|
+
accessory.context.config.subtype === 'motion'
|
|
91
|
+
? this.api.hap.Characteristic.MotionDetected
|
|
92
|
+
: this.api.hap.Characteristic.OccupancyDetected;
|
|
93
|
+
|
|
94
|
+
let state = accessory.getService(service).getCharacteristic(characteristic).value;
|
|
95
|
+
|
|
96
|
+
if (accessory.displayName === 'Anyone') {
|
|
97
|
+
const states = this.accessories
|
|
98
|
+
.filter(
|
|
99
|
+
(accessory) =>
|
|
100
|
+
accessory.context.config.type === 'presence' &&
|
|
101
|
+
accessory.displayName !== 'Anyone' &&
|
|
102
|
+
accessory.displayName !== 'Guest'
|
|
103
|
+
)
|
|
104
|
+
.map((accessory) => {
|
|
105
|
+
const service2 =
|
|
106
|
+
accessory.context.config.subtype === 'motion'
|
|
107
|
+
? this.api.hap.Service.MotionSensor
|
|
108
|
+
: this.api.hap.Service.OccupancySensor;
|
|
109
|
+
|
|
110
|
+
const characteristic2 =
|
|
111
|
+
accessory.context.config.subtype === 'motion'
|
|
112
|
+
? this.api.hap.Characteristic.MotionDetected
|
|
113
|
+
: this.api.hap.Characteristic.OccupancyDetected;
|
|
114
|
+
|
|
115
|
+
const state = accessory.getService(service2).getCharacteristic(characteristic2).value
|
|
116
|
+
? 'detected'
|
|
117
|
+
: 'not detected';
|
|
118
|
+
|
|
119
|
+
return state;
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
state = states.includes('detected');
|
|
123
|
+
} else {
|
|
124
|
+
let oldState = accessory.getService(service).getCharacteristic(characteristic).value;
|
|
125
|
+
let newState = oldState;
|
|
126
|
+
|
|
127
|
+
const host = this.hosts.find((host) => this.matchesHost(host, accessory.context.config.address));
|
|
128
|
+
|
|
129
|
+
if (host) {
|
|
130
|
+
logger.debug(host, `${accessory.displayName} (${subtype})`);
|
|
131
|
+
|
|
132
|
+
newState = host.active;
|
|
133
|
+
const address = host.ip;
|
|
134
|
+
const onDelay = accessory.context.config.onDelay;
|
|
135
|
+
const offDelay = accessory.context.config.offDelay;
|
|
136
|
+
const threshold = accessory.context.config.threshold;
|
|
137
|
+
|
|
138
|
+
if (newState == state && accessory.context.config.ping) {
|
|
139
|
+
const res = await ping.promise.probe(address);
|
|
140
|
+
|
|
141
|
+
if (res.alive != newState) {
|
|
142
|
+
logger.debug('Ping and FritzBox states are not equal.', `${accessory.displayName} (${subtype})`);
|
|
143
|
+
|
|
144
|
+
if (res.alive) {
|
|
145
|
+
accessory.context.lastSeen = Date.now();
|
|
146
|
+
newState = res.alive;
|
|
147
|
+
|
|
148
|
+
logger.debug('Taking the value of Ping. (DETECTED)', `${accessory.displayName} (${subtype})`);
|
|
149
|
+
} else {
|
|
150
|
+
if (accessory.context.lastSeen) {
|
|
151
|
+
let lastSeenMoment = moment(accessory.context.lastSeen);
|
|
152
|
+
let activeThreshold = moment().subtract(threshold, 'm');
|
|
153
|
+
|
|
154
|
+
if (lastSeenMoment.isAfter(activeThreshold)) {
|
|
155
|
+
newState = true;
|
|
156
|
+
accessory.context.lastSeen = false;
|
|
157
|
+
|
|
158
|
+
logger.debug(
|
|
159
|
+
'Taking the value of Ping. (DETECTED - THRESHOLD REACHED)',
|
|
160
|
+
`${accessory.displayName} (${subtype})`
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
newState = false;
|
|
164
|
+
|
|
165
|
+
logger.debug(
|
|
166
|
+
'Taking the value of Ping. (NOT DETECTED - THRESHOLD NOT REACHED)',
|
|
167
|
+
`${accessory.displayName} (${subtype})`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (newState != state) {
|
|
176
|
+
if (accessory.context.changedOn) {
|
|
177
|
+
let millis = Date.now() - accessory.context.changedOn;
|
|
178
|
+
let secElapsed = Math.floor(millis / 1000);
|
|
179
|
+
|
|
180
|
+
let passed = false;
|
|
181
|
+
|
|
182
|
+
if (newState && secElapsed > onDelay) {
|
|
183
|
+
passed = true;
|
|
184
|
+
} else if (!newState && secElapsed > offDelay) {
|
|
185
|
+
passed = true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (passed) {
|
|
189
|
+
state = newState;
|
|
190
|
+
accessory.context.changedOn = false;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
accessory.context.changedOn = Date.now();
|
|
194
|
+
|
|
195
|
+
logger.info(
|
|
196
|
+
`Occupancy state changed to ${newState ? 'DETECTED' : 'NOT DETECTED'}`,
|
|
197
|
+
`${accessory.displayName} (${subtype})`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
logger.info(
|
|
201
|
+
`Wait ${newState ? onDelay : offDelay}s before switching state!`,
|
|
202
|
+
`${accessory.displayName} (${subtype})`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
} else {
|
|
206
|
+
if (accessory.context.changedOn) {
|
|
207
|
+
accessory.context.changedOn = false;
|
|
208
|
+
|
|
209
|
+
logger.info(
|
|
210
|
+
`Occupancy state switched back to ${newState ? 'DETECTED' : 'NOT DETECTED'}`,
|
|
211
|
+
`${accessory.displayName} (${subtype})`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (state) {
|
|
219
|
+
state = accessory.context.config.subtype === 'motion' ? true : 1;
|
|
220
|
+
} else {
|
|
221
|
+
state = accessory.context.config.subtype === 'motion' ? false : 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
accessory.getService(service).getCharacteristic(characteristic).updateValue(state);
|
|
225
|
+
|
|
226
|
+
return state;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// IP match, or case-insensitive MAC match (host.mac may be empty)
|
|
230
|
+
matchesHost(host, address) {
|
|
231
|
+
if (!host || !address) return false;
|
|
232
|
+
if (host.ip === address) return true;
|
|
233
|
+
return typeof host.mac === 'string' && host.mac.toUpperCase() === String(address).toUpperCase();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// eslint-disable-next-line no-unused-vars
|
|
237
|
+
async set(state, accessory, subtype, ownCharacteristic) {}
|
|
238
|
+
|
|
239
|
+
async poll() {
|
|
240
|
+
try {
|
|
241
|
+
this.hosts = await this.fritzbox.getAllHostsV2();
|
|
242
|
+
//logger.debug(this.hosts, 'Hosts');
|
|
243
|
+
|
|
244
|
+
if (this.hosts.length) {
|
|
245
|
+
const userAccessories = this.accessories.filter(
|
|
246
|
+
(accessory) =>
|
|
247
|
+
accessory &&
|
|
248
|
+
accessory.context &&
|
|
249
|
+
accessory.context.config.type === 'presence' &&
|
|
250
|
+
accessory.displayName !== 'Anyone' &&
|
|
251
|
+
accessory.displayName !== 'Guest'
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
for (const accessory of userAccessories) {
|
|
255
|
+
const host = this.hosts.find((host) => this.matchesHost(host, accessory.context.config.address));
|
|
256
|
+
|
|
257
|
+
if (!host) {
|
|
258
|
+
logger.debug(
|
|
259
|
+
'User could not be found in hosts list. Looking for user manually.',
|
|
260
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
const service = validIP(accessory.context.config.address)
|
|
265
|
+
? 'X_AVM-DE_GetSpecificHostEntryByIP'
|
|
266
|
+
: 'GetSpecificHostEntry';
|
|
267
|
+
|
|
268
|
+
const input = validIP(accessory.context.config.address)
|
|
269
|
+
? { NewIPAddress: accessory.context.config.address }
|
|
270
|
+
: { NewMACAddress: accessory.context.config.address };
|
|
271
|
+
|
|
272
|
+
const user = await this.fritzbox.exec('urn:LanDeviceHosts-com:serviceId:Hosts1', service, input);
|
|
273
|
+
|
|
274
|
+
this.hosts.push({
|
|
275
|
+
mac: user.NewMACAddress,
|
|
276
|
+
ip: user.NewIPAddress,
|
|
277
|
+
active: user.NewActive === '1',
|
|
278
|
+
name: user.NewHostName,
|
|
279
|
+
interface: user.NewInterfaceType,
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (err.soap && err.soap.errorDescription === 'NoSuchEntryInArray') {
|
|
283
|
+
logger.debug(
|
|
284
|
+
'User could not be found manually. Setting user to inactive.',
|
|
285
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
this.hosts.push({
|
|
289
|
+
mac: accessory.context.config.address,
|
|
290
|
+
ip: accessory.context.config.address,
|
|
291
|
+
active: false,
|
|
292
|
+
name: accessory.displayName,
|
|
293
|
+
interface: '802.11',
|
|
294
|
+
});
|
|
295
|
+
} else {
|
|
296
|
+
throw new Error(err);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await this.get(accessory);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const anyoneAccessory = this.accessories.find(
|
|
305
|
+
(accessory) => accessory.context.config.type === 'presence' && accessory.displayName === 'Anyone'
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (anyoneAccessory) {
|
|
309
|
+
await this.get(anyoneAccessory);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
logger.warn('An error occurred during polling hosts!');
|
|
314
|
+
logger.error(err);
|
|
315
|
+
} finally {
|
|
316
|
+
setTimeout(() => this.poll(), this.polling.timer * 1000);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const handler = new Handler();
|
|
322
|
+
module.exports = handler;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
PresenceMotionAccessory: require('./motion/motion.accessory'),
|
|
5
|
+
PresenceOccupancyAccessory: require('./occupancy/occupancy.accessory'),
|
|
6
|
+
PresenceHandler: require('./presence.handler'),
|
|
7
|
+
PresenceConfig: require('./presence.config'),
|
|
8
|
+
PresenceSetup: require('./presence.setup'),
|
|
9
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const logger = require('../../utils/logger');
|
|
4
|
+
const { UUIDgenerate } = require('../../utils/utils');
|
|
5
|
+
const Config = require('./presence.config');
|
|
6
|
+
|
|
7
|
+
const Setup = (devices, presenceConfig, presenceOptions) => {
|
|
8
|
+
let exposed = false;
|
|
9
|
+
|
|
10
|
+
presenceConfig.forEach((config) => {
|
|
11
|
+
let error = false;
|
|
12
|
+
const user = Config(config);
|
|
13
|
+
|
|
14
|
+
if (!user.active) {
|
|
15
|
+
error = true;
|
|
16
|
+
} else if (!user.name) {
|
|
17
|
+
logger.warn('One of the PRESENCE devices has no name configured. This user will be skipped.');
|
|
18
|
+
error = true;
|
|
19
|
+
} else if (user.name === 'Anyone') {
|
|
20
|
+
logger.warn(
|
|
21
|
+
'One of the PRESENCE devices is called "Anyone". Please change name of this device! This device will be skipped.',
|
|
22
|
+
user.name
|
|
23
|
+
);
|
|
24
|
+
error = true;
|
|
25
|
+
} else if (!user.address) {
|
|
26
|
+
logger.warn(
|
|
27
|
+
'There is no or no valid IP/MAC address configured for this PRESENCE device. This device will be skipped.',
|
|
28
|
+
user.name
|
|
29
|
+
);
|
|
30
|
+
error = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!error && user.offDelay < 60) {
|
|
34
|
+
logger.warn(
|
|
35
|
+
`offDelay is ${user.offDelay}s. Sleeping iPhones often leave the WLAN for a few minutes; ` +
|
|
36
|
+
'values below 60s will make the sensor flap. Recommended: 300s or more.',
|
|
37
|
+
user.name
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!error) {
|
|
42
|
+
const uuid = UUIDgenerate(user.name);
|
|
43
|
+
|
|
44
|
+
if (devices.has(uuid)) {
|
|
45
|
+
logger.warn('Multiple devices are configured with this name. Duplicate devices will be skipped.', user.name);
|
|
46
|
+
} else {
|
|
47
|
+
logger.debug('New device added!', user.name);
|
|
48
|
+
devices.set(uuid, user);
|
|
49
|
+
|
|
50
|
+
exposed = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (presenceOptions.anyone && exposed) {
|
|
56
|
+
const validTypes = ['occupancy', 'motion'];
|
|
57
|
+
|
|
58
|
+
const anyoneConfig = {
|
|
59
|
+
name: 'Anyone',
|
|
60
|
+
type: 'presence',
|
|
61
|
+
subtype: validTypes.find((el) => el === presenceOptions.accType) || 'occupancy',
|
|
62
|
+
accType: validTypes.find((el) => el === presenceOptions.accType) || 'occupancy',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const uuid = UUIDgenerate(anyoneConfig.name);
|
|
66
|
+
|
|
67
|
+
if (devices.has(uuid)) {
|
|
68
|
+
logger.warn(
|
|
69
|
+
'Multiple devices are configured with this name. Duplicate devices will be skipped.',
|
|
70
|
+
anyoneConfig.name
|
|
71
|
+
);
|
|
72
|
+
} else {
|
|
73
|
+
logger.debug('New device added!', anyoneConfig.name);
|
|
74
|
+
devices.set(uuid, anyoneConfig);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (presenceOptions.guest) {
|
|
79
|
+
const validTypes = ['occupancy', 'motion'];
|
|
80
|
+
|
|
81
|
+
const guestConfig = {
|
|
82
|
+
name: 'Guest',
|
|
83
|
+
type: 'presence',
|
|
84
|
+
subtype: validTypes.find((el) => el === presenceOptions.accType) || 'occupancy',
|
|
85
|
+
accType: validTypes.find((el) => el === presenceOptions.accType) || 'occupancy',
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const uuid = UUIDgenerate(guestConfig.name);
|
|
89
|
+
|
|
90
|
+
if (devices.has(uuid)) {
|
|
91
|
+
logger.warn(
|
|
92
|
+
'Multiple devices are configured with this name. Duplicate devices will be skipped.',
|
|
93
|
+
guestConfig.name
|
|
94
|
+
);
|
|
95
|
+
} else {
|
|
96
|
+
logger.debug('New device added!', guestConfig.name);
|
|
97
|
+
devices.set(uuid, guestConfig);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
module.exports = Setup;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const moment = require('moment');
|
|
4
|
+
const logger = require('../../utils/logger');
|
|
5
|
+
const Telegram = require('../../lib/telegram');
|
|
6
|
+
|
|
7
|
+
class Handler {
|
|
8
|
+
constructor() {
|
|
9
|
+
this.hosts = [];
|
|
10
|
+
this.configured = false;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
configure(api, accessories, polling, meshMaster) {
|
|
14
|
+
if (this.configured) {
|
|
15
|
+
return this;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
this.api = api;
|
|
19
|
+
this.accessories = accessories;
|
|
20
|
+
this.polling = polling;
|
|
21
|
+
this.wifiUnitGuest = meshMaster.wifiUnits + 1;
|
|
22
|
+
this.fritzbox = meshMaster.fritzbox;
|
|
23
|
+
|
|
24
|
+
this.configured = true;
|
|
25
|
+
|
|
26
|
+
setTimeout(() => this.poll(), 1000);
|
|
27
|
+
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// eslint-disable-next-line no-unused-vars
|
|
32
|
+
async change(context, accessory, subtype, historyService) {
|
|
33
|
+
if (context.oldValue !== context.newValue) {
|
|
34
|
+
if (!this.configured) {
|
|
35
|
+
logger.debug(
|
|
36
|
+
'Handler not configured yet. Skipping CHANGE event.',
|
|
37
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
subtype = subtype || accessory.context.config.subtype;
|
|
43
|
+
|
|
44
|
+
// The fakegato stub may not implement getInitialTime() (see issue #2)
|
|
45
|
+
if (historyService && typeof historyService.getInitialTime === 'function') {
|
|
46
|
+
const lastActivation = moment().unix() - historyService.getInitialTime();
|
|
47
|
+
|
|
48
|
+
accessory
|
|
49
|
+
.getService(this.api.hap.Service.MotionSensor)
|
|
50
|
+
.getCharacteristic(this.api.hap.Characteristic.LastActivation)
|
|
51
|
+
.updateValue(lastActivation);
|
|
52
|
+
|
|
53
|
+
historyService.addEntry({ time: moment().unix(), status: context.newValue ? 1 : 0 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let dest = false;
|
|
57
|
+
|
|
58
|
+
if (context.newValue) {
|
|
59
|
+
dest = 'guest_in';
|
|
60
|
+
|
|
61
|
+
logger.info(
|
|
62
|
+
'Someone connected to Guest Wifi',
|
|
63
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
64
|
+
);
|
|
65
|
+
} else {
|
|
66
|
+
dest = 'guest_out';
|
|
67
|
+
|
|
68
|
+
logger.info('No one connected to Guest Wifi', `${accessory.displayName} (${accessory.context.config.subtype})`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Telegram.send('presence', dest, false);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// eslint-disable-next-line no-unused-vars
|
|
76
|
+
async get(accessory, subtype, ownCharacteristic) {
|
|
77
|
+
if (!this.configured) {
|
|
78
|
+
logger.debug(
|
|
79
|
+
'Handler not configured yet. Skipping GET event.',
|
|
80
|
+
`${accessory.displayName} (${accessory.context.config.subtype})`
|
|
81
|
+
);
|
|
82
|
+
return accessory.context.config.subtype === 'motion' ? false : 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// eslint-disable-next-line no-unused-vars
|
|
86
|
+
subtype = subtype || accessory.context.config.subtype;
|
|
87
|
+
|
|
88
|
+
const service =
|
|
89
|
+
accessory.context.config.subtype === 'motion'
|
|
90
|
+
? this.api.hap.Service.MotionSensor
|
|
91
|
+
: this.api.hap.Service.OccupancySensor;
|
|
92
|
+
|
|
93
|
+
const characteristic =
|
|
94
|
+
accessory.context.config.subtype === 'motion'
|
|
95
|
+
? this.api.hap.Characteristic.MotionDetected
|
|
96
|
+
: this.api.hap.Characteristic.OccupancyDetected;
|
|
97
|
+
|
|
98
|
+
let state = accessory.getService(service).getCharacteristic(characteristic).value;
|
|
99
|
+
|
|
100
|
+
const response = await this.fritzbox.exec(
|
|
101
|
+
`urn:WLANConfiguration-com:serviceId:WLANConfiguration${this.wifiUnitGuest}`,
|
|
102
|
+
'GetTotalAssociations'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
logger.debug(response, `${accessory.displayName} (${subtype})`);
|
|
106
|
+
|
|
107
|
+
const connectedToGuestWifi = parseInt(response.NewTotalAssociations);
|
|
108
|
+
|
|
109
|
+
if (connectedToGuestWifi) {
|
|
110
|
+
state = accessory.context.config.subtype === 'motion' ? true : 1;
|
|
111
|
+
} else {
|
|
112
|
+
state = accessory.context.config.subtype === 'motion' ? false : 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
accessory.getService(service).getCharacteristic(characteristic).updateValue(state);
|
|
116
|
+
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// eslint-disable-next-line no-unused-vars
|
|
121
|
+
async set(state, accessory, subtype, ownCharacteristic) {}
|
|
122
|
+
|
|
123
|
+
async poll() {
|
|
124
|
+
try {
|
|
125
|
+
const guestAccessory = this.accessories.find((accessory) => accessory && accessory.displayName === 'Guest');
|
|
126
|
+
|
|
127
|
+
await this.get(guestAccessory);
|
|
128
|
+
} catch (err) {
|
|
129
|
+
logger.warn('An error occurred during polling guest device!');
|
|
130
|
+
logger.error(err);
|
|
131
|
+
} finally {
|
|
132
|
+
setTimeout(() => this.poll(), this.polling.timer * 1000);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const handler = new Handler();
|
|
138
|
+
module.exports = handler;
|
package/src/platform.js
CHANGED
|
@@ -7,7 +7,7 @@ const { version } = require('../package.json');
|
|
|
7
7
|
const Telegram = require('./lib/telegram');
|
|
8
8
|
|
|
9
9
|
// Accessories — DECT-only Minimal-Patch-Fork.
|
|
10
|
-
// Out-of-scope subsystems (callmonitor,
|
|
10
|
+
// Out-of-scope subsystems (callmonitor, network, wol, childlock) are not
|
|
11
11
|
// wired in here. Their directories remain in src/accessories/ as dead code — they
|
|
12
12
|
// are intentionally not deleted because router/router.accessory.js still has a
|
|
13
13
|
// top-level require('../extras/extras.handler') that would fail at load time
|
|
@@ -42,6 +42,15 @@ const {
|
|
|
42
42
|
SHSetup,
|
|
43
43
|
} = require('./accessories/smarthome/smarthome');
|
|
44
44
|
|
|
45
|
+
// Presence (WLAN occupancy/motion sensors) — opt-in since v1.3.0.
|
|
46
|
+
// Only devices with `active: true` in config.presence (plus the optional
|
|
47
|
+
// "Anyone"/"Guest" sensors from options.presence) are exposed.
|
|
48
|
+
const {
|
|
49
|
+
PresenceMotionAccessory,
|
|
50
|
+
PresenceOccupancyAccessory,
|
|
51
|
+
PresenceSetup,
|
|
52
|
+
} = require('./accessories/presence/presence');
|
|
53
|
+
|
|
45
54
|
//Custom Types
|
|
46
55
|
const CustomTypes = require('./types/custom.types');
|
|
47
56
|
const EveTypes = require('./types/eve.types');
|
|
@@ -122,6 +131,10 @@ function FritzPlatform(log, config, api) {
|
|
|
122
131
|
SHSetup(this.devices, this.config.smarthome);
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
if (this.config.presence.length || this.config.options.presence.guest) {
|
|
135
|
+
PresenceSetup(this.devices, this.config.presence, this.config.options.presence);
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
this.api.on('didFinishLaunching', this.didFinishLaunching.bind(this));
|
|
126
139
|
}
|
|
127
140
|
|
|
@@ -233,6 +246,11 @@ FritzPlatform.prototype = {
|
|
|
233
246
|
else if (device.subtype === 'smarthome-energy-light')
|
|
234
247
|
new SHEnergyLightAccessory(this.api, accessory, this.accessories, this.meshMaster, HistoryService);
|
|
235
248
|
break;
|
|
249
|
+
case 'presence':
|
|
250
|
+
if (device.subtype === 'motion')
|
|
251
|
+
new PresenceMotionAccessory(this.api, accessory, this.accessories, this.meshMaster, HistoryService);
|
|
252
|
+
else new PresenceOccupancyAccessory(this.api, accessory, this.accessories, this.meshMaster);
|
|
253
|
+
break;
|
|
236
254
|
default:
|
|
237
255
|
logger.warn(`Can not setup accessory, type (${device.type}) unknown!`, device.name);
|
|
238
256
|
break;
|