@pellebot/homebridge-fritz-platform-hb2 1.2.0 → 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 CHANGED
@@ -2,6 +2,69 @@
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
+
49
+ # v1.2.1 — 2026-11-14 (Bugfix: Absturz bei offenem Fenster)
50
+
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.
52
+
53
+ ## Ursache
54
+
55
+ Der NoOp-Ersatz für fakegato-history in `src/platform.js` bildete nur `addEntry`, `cleanPersist`, `setExtraPersistedData` und `getExtraPersistedData` nach. Die Handler für `smarthome-contact` und `smarthome-window` rufen zusätzlich `getInitialTime()` auf, um die Eve-Werte `LastActivation` und `ClosedDuration` zu berechnen.
56
+
57
+ ## Änderung
58
+
59
+ - Der Stub merkt sich beim Erzeugen den Zeitpunkt (Unix-Sekunden) und gibt ihn über `getInitialTime()` zurück. Die Eve-Dauerwerte zählen damit ab Start der Bridge.
60
+ - Abgleich aller `historyService.*`-Aufrufe in den aktiven Pfaden (`smarthome/`, `router/`, `extras/`): verwendet werden nur `addEntry` und `getInitialTime` – beide sind jetzt im Stub vorhanden.
61
+
62
+ ## Breaking Changes
63
+
64
+ Keine.
65
+
66
+ ---
67
+
5
68
  # v1.2.0 — 2026-05-14 (Wizard Edit-Mode / Phase G)
6
69
 
7
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 (`presence`, `callmonitor`, `wol`, `childLock`, `network`, `extras`, `telegram` — werden eh ignoriert, aber sauberer wenn weg).
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.
@@ -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
- return { master, smarthome };
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
- return {
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.2.0",
5
- "description": "Homebridge 2.0 plugin for AVM FRITZ!DECT devices (thermostats, lamps, sensors, OBIS smart-meter readers) and FRITZ!Box router switches (Guest WLAN, WPS). Node 22 / 24 ready.",
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, presence, network, wol, childlock) are not
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');
@@ -72,8 +81,16 @@ function FritzPlatform(log, config, api) {
72
81
  // Functional impact: Eve App will not show historical graphs for
73
82
  // thermostats, temperature, humidity, contact/window, outlet energy.
74
83
  // HomeKit core functionality (control, current state) is unaffected.
75
- HistoryService = function NoOpHistoryService() {};
84
+ // getInitialTime() is used by the contact/window handlers (Eve
85
+ // LastActivation/ClosedDuration). The stub returns the construction time
86
+ // in Unix seconds, so durations count from bridge start (issue #2).
87
+ HistoryService = function NoOpHistoryService() {
88
+ this.initialTime = Math.floor(Date.now() / 1000);
89
+ };
76
90
  HistoryService.prototype.addEntry = function () {};
91
+ HistoryService.prototype.getInitialTime = function () {
92
+ return this.initialTime;
93
+ };
77
94
  HistoryService.prototype.cleanPersist = function () {};
78
95
  HistoryService.prototype.setExtraPersistedData = function () {};
79
96
  HistoryService.prototype.getExtraPersistedData = function () {
@@ -114,6 +131,10 @@ function FritzPlatform(log, config, api) {
114
131
  SHSetup(this.devices, this.config.smarthome);
115
132
  }
116
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
+
117
138
  this.api.on('didFinishLaunching', this.didFinishLaunching.bind(this));
118
139
  }
119
140
 
@@ -225,6 +246,11 @@ FritzPlatform.prototype = {
225
246
  else if (device.subtype === 'smarthome-energy-light')
226
247
  new SHEnergyLightAccessory(this.api, accessory, this.accessories, this.meshMaster, HistoryService);
227
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;
228
254
  default:
229
255
  logger.warn(`Can not setup accessory, type (${device.type}) unknown!`, device.name);
230
256
  break;