From 5f4e4cd297dc6aa0c49ac04210f1c7c0f965d905 Mon Sep 17 00:00:00 2001 From: Lars Kvanum Date: Wed, 14 Oct 2026 15:06:37 +0200 Subject: [PATCH] Release v1.10.38 test with Presence+ --- CHANGELOG.md | 10 + HOMEY_COMMUNITY_LISTING.md | 14 +- PROJECT_DOCUMENTATION.md | 8 +- README.md | 19 +- WORKLOG.md | 66 ++ docs/docs/circadian-light-group.html | 2 +- docs/docs/flow-cards.html | 45 ++ docs/docs/presence-plus.html | 130 ++++ docs/index.html | 35 ++ no.tiwas.booleantoolbox/.homeychangelog.json | 12 + .../.homeycompose/app.json | 2 +- .../triggers/presence_everyone_away_for.json | 94 +++ .../triggers/presence_person_away_for.json | 99 +++ no.tiwas.booleantoolbox/AppFlowCards.test.js | 23 +- ...ircadianLightGroupCollectionDevice.test.js | 63 ++ .../PresenceManager.test.js | 382 ++++++++++++ no.tiwas.booleantoolbox/README.txt | 7 + no.tiwas.booleantoolbox/app.js | 48 ++ .../device.js | 9 + .../lib/PresenceManager.js | 573 ++++++++++++++++++ no.tiwas.booleantoolbox/package-lock.json | 4 +- no.tiwas.booleantoolbox/package.json | 2 +- 22 files changed, 1635 insertions(+), 12 deletions(-) create mode 100644 docs/docs/presence-plus.html create mode 100644 no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_everyone_away_for.json create mode 100644 no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_person_away_for.json create mode 100644 no.tiwas.booleantoolbox/PresenceManager.test.js create mode 100644 no.tiwas.booleantoolbox/lib/PresenceManager.js diff --git a/CHANGELOG.md b/CHANGELOG.md index afd0b79..a7a91c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to Smart (Components) Toolkit for Homey will be documented i --- +## [1.10.38] - October 2026 (Test channel) + +### Added +- Presence+: two new triggers wait until one selected person, or everyone, has remained away for a configurable number of minutes. Returning home cancels the pending trigger, and absence timers survive app restarts without firing the same threshold twice. See the [Presence+ guide](https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html). + +### Fixed +- Presence+: a new Home → Away episode is now re-armed correctly even when the trigger call for the previous episode is still pending. Its delay remains anchored to the new Away time, and a Home event still cancels it. +- Circadian Light Group Collection: *Is paused* now returns yes when the Collection itself or at least one available member group is paused. Previously it only read the Collection's own pause flag, so an individually paused member group could be missed and an *All on* Flow could bypass Resume and skip every light in that group. +- Resume on a Collection continues to resume every member group. Together with the aggregate condition, a Flow can now reliably detect the mixed pause state, resume all groups, and then turn their lights on. + ## [1.10.35] - October 2026 (Test channel) ### Fixed diff --git a/HOMEY_COMMUNITY_LISTING.md b/HOMEY_COMMUNITY_LISTING.md index 35cfa7a..7d68c0e 100644 --- a/HOMEY_COMMUNITY_LISTING.md +++ b/HOMEY_COMMUNITY_LISTING.md @@ -1,11 +1,11 @@ URL: https://community.homey.app/t/app-smart-components-toolkit-was-boolean-toolbox-create-advanced-logic-with-simple-formulas-v1-10-16-store-v1-10-27-test-logic-device-reliability/143906 -Title: [APP] Smart (Components) Toolkit (was: Boolean Toolbox) - Create advanced logic with simple formulas [v1.10.16 store / v1.10.35 test - Circadian cards no longer time out, Flow name card] +Title: [APP] Smart (Components) Toolkit (was: Boolean Toolbox) - Create advanced logic with simple formulas [v1.10.16 store / v1.10.38 test - Presence+, Collection pause detection] Content: ![xlarge|690x483](upload://iSxhJPUltgcgPQ7gy4z5iisCv5F.jpeg) -# Smart (Components) Toolkit — store v1.10.16 / test v1.10.35 +# Smart (Components) Toolkit — store v1.10.16 / test v1.10.38 > **📚 Full Documentation:** https://tiwas.github.io/SmartComponentsToolkit/ @@ -17,6 +17,13 @@ Replace complex flow networks with powerful logic devices controlled by dynamic ## What's new +### v1.10.38 (test channel) + +- **New Presence+.** Trigger a Flow only after one selected person, or everyone, has remained away for a chosen number of minutes. A return before the delay expires cancels the pending trigger, filtering out brief Home/Away glitches. Countdown state survives an app restart. [Read the Presence+ guide](https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html). +- **Repeated departures are re-armed.** If someone returns Home and leaves again while the previous Presence+ trigger call is still finishing, the new absence keeps its own start time and triggers after the configured delay. +- **Collection pause detection fixed.** *Is paused* on a Circadian Light Group Collection now answers yes when the Collection itself or at least one available member group is paused. +- **Reliable All on after a partial pause.** An *All on* Flow can now take its Resume branch when one member group is paused. Resume still reaches every member group before the Flow continues to turn the lights on. + ### v1.10.35 (test channel) - **Circadian Light Group: no more "Timeout after 60000ms".** Homey stops an app's Flow card after 60 seconds, and the rest of the Flow then never runs. With a few lights that don't answer (an unplugged bulb, a Z-Wave module out of range), turning a group or Collection on could take almost that long, because the card waited for every retry. Now the card lets the Flow continue as soon as every light has its commands and has been checked, usually within a few seconds. Lights that haven't confirmed are retried in the background, and no Circadian card waits more than 50 seconds. @@ -177,7 +184,7 @@ Together they cover both sides: Flow Doctor shows how your Flows are wired, and ## ✨ Circadian Light Group — now on stable -A virtual **light device** that adjusts brightness and color temperature for a group of real lights — automatically following a circadian rhythm. Store is currently v1.10.16; v1.10.35 is available on the test channel. +A virtual **light device** that adjusts brightness and color temperature for a group of real lights — automatically following a circadian rhythm. Store is currently v1.10.16; v1.10.38 is available on the test channel. ### Circadian Light Group highlights @@ -249,6 +256,7 @@ Choose how the device knows how bright it is outside: |------------------------------|---------| | **Conditional Gates** | Simple GO/NO GO flow control without variables or devices. Short waits in the card, long waits in the background with a "wait finished" trigger. | | **Waiter Gates** | Wait until a device capability reaches a target value — in the card (up to 55 s) or in the background with a "wait finished" trigger. | +| **Presence+** | Wait until one selected person, or everyone, has remained away for the configured minutes; any return cancels the pending trigger. | | **Evaluate Expression** | Range checking and value mapping with AND/OR logic. | | **Math Compare** | Compare a calculated number, e.g. `Temperature + 3` against a threshold. | | **Gradient Map** | Map a number from one range to another and pass the `Mapped value` tag to the next card. | diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md index a89dc99..eaff6c4 100644 --- a/PROJECT_DOCUMENTATION.md +++ b/PROJECT_DOCUMENTATION.md @@ -54,12 +54,18 @@ * **Collection:** `runAwaitedMemberGroups` merges the member groups' outcomes and reports group failures once their background retries have finished. The Collection queue is released when the card's part is done. * **Key Files:** `drivers/circadian-light-group/device.js`, `drivers/circadian-light-group/driver.js`, `drivers/circadian-light-group-collection/device.js`, `CircadianLightGroupDevice.test.js`, `CircadianLightGroupCollectionDevice.test.js`, `CircadianLightGroupDriver.test.js`. +### 8. Presence+ +* **Purpose:** Debounces Homey's user presence before starting a Flow. `presence_person_away_for` watches one selected person; `presence_everyone_away_for` starts when the last tracked person leaves. +* **Mechanism:** `lib/PresenceManager.js` subscribes to `ManagerUsers` events and reads enabled standard and Advanced Flows to discover the unique person/delay thresholds that need timers. A Home transition cancels the relevant pending timers. +* **Persistence:** Continuous-absence timestamps and fired thresholds are stored in Homey settings. An app restart resumes an active countdown and does not repeat a threshold that already fired in the same absence episode. +* **Key Files:** `lib/PresenceManager.js`, `.homeycompose/flow/triggers/presence_*.json`, `PresenceManager.test.js`, and `app.js` (lifecycle and card routing). + ## Project Structure * `no.tiwas.booleantoolbox/`: Main Homey app source. * `app.js`: Application entry point and diagnostics collector. * `api.js`: Private settings endpoint for generating diagnostic reports. * `drivers/`: Device drivers (`composite-device`, `logic-device`, `logic-unit`, state and Circadian Light Group drivers). - * `lib/`: Core logic libraries (`CompositeAggregator.js`, `DiagnosticsReport.js`, `FlowIdentity.js`, `FormulaEvaluator.js`, `Logger.js`). + * `lib/`: Core logic libraries (`CompositeAggregator.js`, `DiagnosticsReport.js`, `FlowIdentity.js`, `FormulaEvaluator.js`, `Logger.js`, `PresenceManager.js`). * `locales/`: Translation files. * `docs/`: Documentation and web tools for the GitHub Pages site, published by `.github/workflows/pages.yml` on every push to `main` that changes `docs/` (Pages source: GitHub Actions). * `.github/workflows/message-check.yml` with `.github/scripts/message-check.js`: checks commit messages, branch names, PR, issue and comment text against `AI_RULES.md` §7 on every pull request, push, issue, comment and review. Tests: `node --test .github/scripts/message-check.test.js`. diff --git a/README.md b/README.md index f088308..60975a6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Advanced logic and state management for your Homey automations. Create smart dev [![Test](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapps-api.athom.com%2Fapi%2Fv1%2Fapp%2Fno.tiwas.booleantoolbox&query=%24.testVersion&label=test&color=orange)](https://homey.app/a/no.tiwas.booleantoolbox/test/) [![Homey](https://img.shields.io/badge/Homey-5.0+-green.svg)](https://homey.app) -> **v1.10.35 test** — Circadian Light Group Flows no longer stop at Homey's 60-second card limit: a card lets the Flow continue once every light has its commands, lights that do not answer are retried in the background, and new Advanced Flow cards *Turn on / Turn off / Toggle and report the result* tell you whether every light confirmed. See the [Circadian Light Group guide](https://tiwas.github.io/SmartComponentsToolkit/docs/circadian-light-group.html#retries). Builds on **v1.10.34**: the app log now shows when a wait takes over a Waiter ID that is already waiting. Builds on **v1.10.33**: new Advanced Flow card *Get the name of [this Flow]* returns the Flow's own name, id and folder as tags, and follows when you rename the Flow. See the [Flow card reference](https://tiwas.github.io/SmartComponentsToolkit/docs/flow-cards.html#action-flow-name). Builds on **v1.10.32**: the Circadian Light Group schedule now follows your Homey's time zone (it ran 1–2 hours late on UTC), the outdoor light estimate follows the real sun, mornings can get their own optional profile, and the editors keep lux anchors and saved lux sensors. See the [Circadian Light Group guide](https://tiwas.github.io/SmartComponentsToolkit/docs/circadian-light-group.html). Builds on **v1.10.31** (Homey Web API library 3.20.0) and **v1.10.30**, which fixes app restarts and Logic Devices that stopped reacting, and adds background waits beyond Homey's 60-second Flow card limit. See the [Conditional Gates](https://tiwas.github.io/SmartComponentsToolkit/docs/conditional-gates.html) and [Waiter Gates](https://tiwas.github.io/SmartComponentsToolkit/docs/waiter-gates.html) guides. +> **v1.10.38 test** — New **Presence+** triggers start a Flow only after one selected person, or everyone, has remained away for a chosen number of minutes; returning home cancels the pending trigger, and a later departure is re-armed even if the previous trigger call is still finishing. Circadian Light Group Collections now also report *Is paused* when a member group is paused. See the [Presence+ guide](https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html) and [Circadian Light Group guide](https://tiwas.github.io/SmartComponentsToolkit/docs/circadian-light-group.html). --- @@ -139,7 +139,7 @@ THEN: Pop state (restore previous) [Homey Flow delay: 5 minutes] --- -### Circadian Light Group *(stable v1.10.16 / test v1.10.35)* +### Circadian Light Group *(stable v1.10.16 / test v1.10.38)* Virtual light device that adjusts brightness and color temperature for a group of real lights based on time, sun position or ambient lux. @@ -230,6 +230,19 @@ Starting the wait again for the same gate (for example when the razor is turned --- +### Presence+ + +Homey's presence can briefly switch to Away and back. These triggers debounce that signal before a Flow is allowed to continue: + +- **[Person] has been away for [minutes]** - starts when the selected person leaves and is cancelled if that person returns first +- **Everyone has been away for [minutes]** - starts when the last present person leaves and is cancelled as soon as anyone returns + +The countdown runs in the background, survives an app restart, and fires once per continuous absence. No Flow card is kept open while it waits. + +[📚 Read Presence+ guide →](https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html) + +--- + ### Waiter Gates Waiter Gates let your flows pause and wait for device states to change, with YES/NO outputs: @@ -329,6 +342,8 @@ THEN: Turn on lights - State was captured/applied *(State Capture Device)* - Conditional Gate wait finished *(Conditional Gates)* - Capability wait finished *(Waiter Gates)* +- Person has been away for a while *(Presence+)* +- Everyone has been away for a while *(Presence+)* ### Conditions (AND) - Formula result is... diff --git a/WORKLOG.md b/WORKLOG.md index bcd568e..76be1bb 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -1,5 +1,71 @@ # Worklog +## 2026-10-14 — Presence+ re-arm fix + +### Requested +- Investigate why a person-specific Presence+ Flow triggered for the first absence, but did not trigger after the same person returned Home and then became Away again. +- Publish the corrected app as a new test version, update the website, README and Homey Community post source, and install it on the local Homey. + +### Findings +- The normal completed sequence does reset correctly: an observed Away-to-Home transition clears the person's fired thresholds and the next Home-to-Away transition receives a new absence start time. +- Reproduced a race when the person returns and leaves again while the previous trigger call is still in flight. The new absence is observed, but schedule refresh skips it because the person/delay key is still marked in flight. When the previous trigger finishes, the in-flight marker is removed without another schedule refresh, so no timer is created for the new absence. +- Confirmed the race live with temporary diagnostics during `homey app run --remote`. The first one-minute timer fired at 14:47:01 local time. Home was received at 14:47:28, the fired threshold was cleared, and the next Away was received at 14:47:39 with a new absence timestamp. That second episode was skipped only because the first call was still marked in flight. The first `FlowCardTrigger.trigger()` promise did not settle until 14:48:31, almost exactly 90 seconds after it started; releasing the marker did not cause a schedule refresh. +- Homey's SDK contract says the trigger promise resolves when the Flow is triggered. The observed 90-second lifetime therefore looks like a Homey-side trigger timeout or hang, not the intended lifetime of the short downstream Flow. The scheduler must still tolerate a trigger promise that settles late. +- The same scheduling pattern is used by the everyone-away trigger, so it is vulnerable to the equivalent race. + +### Implemented +- Presence events continue to update the current state immediately. When an older trigger call settles, the manager now detects a newer continuous-absence episode and schedules it using its original Away timestamp. If its deadline has already passed it fires immediately; otherwise only the remaining delay is scheduled. +- A Home event still cancels the episode naturally, so no explicit event queue is needed. A late completion also no longer restores the fired marker for an episode that has already ended. +- Applied the same re-arm behavior to the person-specific and everyone-away cards. +- Added regression tests for both trigger types with a deliberately unresolved first trigger call, a Home/Away cycle during that call, and exact scheduling of the second episode. +- Bumped the app to v1.10.38 and uploaded Build 67. Updated the GitHub Pages source with a Presence+ overview and dedicated guide, and updated the repository README, Store README, changelog and `HOMEY_COMMUNITY_LISTING.md` for the test release. + +### Verification +- Focused PresenceManager suite: 9 tests passed. +- Live `homey app run --remote` reproduction: first Away at 14:56:21 triggered at 14:57:21. Home at 14:58:01 and a new Away at 14:58:11 arrived while the first trigger promise was still open. When it settled at 14:58:51, Presence+ scheduled the remaining 20 seconds and fired the second trigger at 14:59:11 exactly one minute after the new Away event. +- Temporary live diagnostics were removed after the successful run. +- Lars completed the Homey test-channel release; the public Homey app API reports `testVersion` v1.10.38. +- Full test suite: 25 suites and 427 tests passed. Publish-level package validation passed with 912 files, an 8.09 MB bundle and 17 verified manifest assets. +- `homey app install` installed v1.10.38 successfully on `Lars's New Homey`. + +## 2026-10-13 — Debounced presence triggers + +### Requested +- Add Flow triggers that fire only after one selected person, or everyone, has remained away for a configurable number of minutes, so brief Home/Away glitches do not directly drive automations. + +### Implemented +- Added person-specific and everyone-away trigger cards with localized titles, hints, arguments, and output tags in all 11 app languages. +- Added an event-driven presence manager that observes Homey user updates, reads enabled standard and Advanced Flow configurations, and schedules exact background timers without keeping a Flow card open. +- Returning home cancels pending timers. Continuous-absence start times and fired thresholds are persisted so app restarts preserve countdowns and do not duplicate a trigger. +- Documented the new cards in the Store README and Flow-card reference. + +### Verification +- Added regression coverage for timer expiry, cancellation on return, last-person semantics, restart deduplication, argument routing, and Compose card references. +- `npm test -- --runInBand --silent`: 25 suites, 425 tests passed. +- `npm run test:package`: publish-level Homey validation passed; both presence trigger cards were generated in the manifest (912-file bundle, 17 assets). +- `git diff --check`: passed. + +## 2026-10-11 — Collection pause-state aggregation + +### Requested +- Investigate issue #76 against the local Homey, including the Advanced Flow "All on - Toolbox", because some lights did not turn on and restarting the app in debug mode had helped before. + +### Findings +- Reproduced the failure with `homey app run --remote` and the live Advanced Flow. The Collection's "Is paused" condition took the no branch while its main member group was paused. +- The subsequent Collection turn-on skipped all 11 lights in that paused group. The bedroom group ran, but its known unavailable member "Soverom: Nattbord v" could not be reached. +- Collection Pause and Resume already fan out to every member group. The inherited "Is paused" condition only read the Collection's own `clg_paused` capability, so it could return false while a member group was paused. + +### Implemented +- Overrode the Collection's pause condition so it returns true when the Collection itself or at least one available member group is paused. +- Documented the aggregate Collection condition and added regression coverage for aggregate pause detection and Resume fan-out when only a member is paused. + +### Verification +- Live retest on `Lars's New Homey`: the Collection condition took the true branch, Resume ran first for both member groups, and the formerly skipped main group then ran `turn_on_all_members`. Multiple previously off lights reported `onoff=true` and Lars confirmed that the lights came on. +- Remaining live failures are device-specific: `Smart Energy Illuminator` returned `TRANSMIT_COMPLETE_NO_ACK`, `Spisestue: Tunable White Bulb (E27)` was unreachable, and `Soverom: Nattbord v` was unreachable. +- `npm test -- --runInBand --silent`: 24 suites, 417 tests passed. +- `npm run test:package`: publish-level validation passed; 911-file bundle and 17 manifest assets verified. +- `git diff --check`: passed. + ## 2026-10-08 — Test v1.10.35 released ### Implemented diff --git a/docs/docs/circadian-light-group.html b/docs/docs/circadian-light-group.html index 6a1f31e..1ade13d 100644 --- a/docs/docs/circadian-light-group.html +++ b/docs/docs/circadian-light-group.html @@ -221,7 +221,7 @@

Conditions

diff --git a/docs/docs/flow-cards.html b/docs/docs/flow-cards.html index 9585021..649eee2 100644 --- a/docs/docs/flow-cards.html +++ b/docs/docs/flow-cards.html @@ -59,6 +59,7 @@ State Capture Device Conditional Gates Waiter Gates + Presence+ @@ -81,6 +82,8 @@

WHEN (Triggers)

  • → Formula changed to...
  • → State changed (LD)
  • → Formula timed out
  • +
  • → Person remained away
  • +
  • → Everyone remained away
  • → Conditional Gate wait finished
  • → Capability wait finished
  • @@ -259,6 +262,48 @@

    Formula [formula] timed o + +
    +

    [Person] has been away for [minutes]

    +

    Starts counting when the selected Homey person becomes Away. It fires only if the same person remains away for the full delay; returning home cancels the pending trigger.

    +

    Presence+ setup, examples and limitations →

    +
    +

    Parameters:

    +
      +
    • person - Enabled Homey user with an available presence state
    • +
    • minutes - Whole minutes from 1 to 10,080 (seven days)
    • +
    +

    Output tokens:

    +
      +
    • person_name - Name of the person
    • +
    • away_minutes - Actual continuous absence in minutes
    • +
    • away_since - ISO timestamp for the start of the absence
    • +
    +
    +
    + + +
    +

    Everyone has been away for [minutes]

    +

    Starts counting when the last present Homey person becomes Away. It fires only if nobody returns for the full delay; any return cancels the pending trigger.

    +
    +

    Parameters:

    +
      +
    • minutes - Whole minutes from 1 to 10,080 (seven days)
    • +
    +

    Output tokens:

    +
      +
    • away_minutes - Actual continuous absence in minutes
    • +
    • away_since - ISO timestamp for when the last person left
    • +
    • people_count - Number of people included
    • +
    • last_person_name - Name of the last person to leave
    • +
    +
    +
    +

    Only enabled Homey users whose presence is available are included. Countdown state and fired thresholds are persisted across app restarts.

    +
    +
    +

    Wait for gate [gate_name] finished

    diff --git a/docs/docs/presence-plus.html b/docs/docs/presence-plus.html new file mode 100644 index 0000000..de256d4 --- /dev/null +++ b/docs/docs/presence-plus.html @@ -0,0 +1,130 @@ + + + + + + Presence+ - Smart (Components) Toolkit for Homey + + + + + + +
    +
    +

    Presence+

    + Test v1.10.38 +
    +

    Reliable Home/Away automations without reacting to brief presence glitches.

    + +
    +

    Why Presence+?

    +

    A phone or presence source can briefly report Away and then Home again. Starting an alarm, changing the heating or turning everything off directly from that first Away event can therefore run an automation nobody wanted.

    +

    Presence+ treats Away as a candidate event. It starts a background countdown and triggers your Flow only when the person, or the whole household, remains away for the chosen number of minutes. A return during the countdown cancels it.

    +
    + +
    +

    Trigger cards

    +
    +
    +
    + When +

    [Person] has been away for [minutes]

    +
    +

    Starts when the selected Homey user becomes Away. Only that person's return cancels this countdown.

    +

    Tags: person name, minutes away and away-since timestamp.

    +
    +
    +
    + When +

    Everyone has been away for [minutes]

    +
    +

    Starts when the last present Homey user leaves. Any person's return cancels this countdown.

    +

    Tags: minutes away, away-since timestamp, people counted and the last person to leave.

    +
    +
    +

    The delay must be a whole number from 1 to 10,080 minutes (seven days).

    +
    + +
    +

    Examples

    +

    Turn the house off after the last person leaves

    +
    WHEN: Everyone has been away for 10 minutes
    +THEN: Turn off all lights
    +THEN: Set heating to Away
    +

    Notify when one person stays away

    +
    WHEN: Alex has been away for 30 minutes
    +THEN: Send notification "Alex has been away since [Away since]"
    +
    + +
    +

    How countdowns behave

    +
      +
    • The wait runs in the app background; it does not keep a Flow card open.
    • +
    • Each unique person-and-delay or everyone-and-delay configuration gets one countdown, even when several Flows use it.
    • +
    • All matching Flows are triggered when their shared threshold is reached.
    • +
    • A return followed by a new departure starts a new episode from that departure time, even if the previous trigger call is still finishing.
    • +
    • Countdown state survives an app restart, and a threshold already fired during the same continuous absence is not fired twice.
    • +
    • Changing, enabling, disabling or deleting a Flow refreshes the active countdown configurations.
    • +
    +
    + +
    +

    Scope and limitations

    +
      +
    • Presence+ uses enabled Homey users whose presence state is available.
    • +
    • It filters short Away/Home glitches; it cannot improve how a phone or presence source decides the state itself.
    • +
    • If the app is stopped for an entire Home-to-Away-to-Home cycle, Homey only exposes the current state after startup, so the missed transitions cannot be reconstructed.
    • +
    +
    + +

    See all parameters and output tags in the Flow Cards reference →

    +
    + + + + diff --git a/docs/index.html b/docs/index.html index fb4bc0e..ccbfb60 100644 --- a/docs/index.html +++ b/docs/index.html @@ -286,6 +286,38 @@

    Conditional Gates

    Read Conditional Gates guide →

    + +
    +
    +

    Presence+

    + Test v1.10.38 +
    +

    Debounce Homey's Home/Away signal before it starts an automation. Presence+ waits until one selected person, or everyone, has stayed away for the full configured delay.

    +
    + + + + + + + + + + + + + + + + + + + +
    PersonTrigger only after a selected person has remained away
    EveryoneStart counting when the last present person leaves
    CancellationA return before the delay expires cancels the pending trigger
    PersistenceCountdowns survive app restarts without firing the same threshold twice
    +
    +

    Read Presence+ guide →

    +
    +
    @@ -464,6 +496,8 @@

    Triggers (WHEN)

  • Formula timed out
  • State changed (Logic Device)
  • State was captured/applied (State Capture)
  • +
  • Person remained away (Presence+)
  • +
  • Everyone remained away (Presence+)
  • @@ -545,6 +579,7 @@

    Documentation

  • State Capture Device
  • Conditional Gates
  • Waiter Gates
  • +
  • Presence+ (test)
  • Circadian Light Group (test)
  • Flow Cards Reference
  • diff --git a/no.tiwas.booleantoolbox/.homeychangelog.json b/no.tiwas.booleantoolbox/.homeychangelog.json index 0edbcfa..02a3725 100644 --- a/no.tiwas.booleantoolbox/.homeychangelog.json +++ b/no.tiwas.booleantoolbox/.homeychangelog.json @@ -162,5 +162,17 @@ "1.10.35": { "en": "Circadian Light Group: Flows no longer stop with \"Timeout after 60000ms\". Turning a group or Collection on or off now lets the Flow continue once every light has its commands and has been checked; lights that do not answer are retried in the background, and no Circadian card waits more than 50 seconds. New Advanced Flow cards \"Turn on / Turn off / Toggle and report the result\" give \"All lights confirmed\" (yes/no) and \"Status\" (text). Guide: https://tiwas.github.io/SmartComponentsToolkit/docs/circadian-light-group.html#retries", "no": "Døgnrytme-lysgruppe: Flows stopper ikke lenger med «Timeout after 60000ms». Når en gruppe eller samling slås på eller av, går flowen videre så snart alle lysene har fått kommandoene og er sjekket; lys som ikke svarer prøves på nytt i bakgrunnen, og ingen døgnrytme-kort venter mer enn 50 sekunder. Nye kort for avanserte flows, «Slå på / Slå av / Veksle og meld resultatet», gir «Alle lys bekreftet» (ja/nei) og «Status» (tekst). Guide: https://tiwas.github.io/SmartComponentsToolkit/docs/circadian-light-group.html#retries" + }, + "1.10.36": { + "en": "New Presence Guards trigger only after one selected person, or everyone, has remained away for a chosen number of minutes; returning home cancels the timer. Circadian Light Group Collection: 'Is paused' now also detects paused member groups, so Resume is no longer skipped.", + "no": "Nye Presence Guards utløses først når én valgt person, eller alle, har vært borte i et valgt antall minutter; en hjemkomst avbryter timeren. Døgnrytme-lyssamling: «Er pauset» oppdager nå også pausede medlemsgrupper, slik at Resume ikke hoppes over." + }, + "1.10.37": { + "en": "New Presence+ triggers wait until one selected person, or everyone, has remained away for a chosen number of minutes; returning home cancels the timer. Circadian Light Group Collection: 'Is paused' now also detects paused member groups, so Resume is no longer skipped. Guide: https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html", + "no": "Nye Presence+-triggere venter til én valgt person, eller alle, har vært borte i et valgt antall minutter; en hjemkomst avbryter timeren. Døgnrytme-lyssamling: «Er pauset» oppdager nå også pausede medlemsgrupper, slik at Resume ikke hoppes over. Guide: https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html" + }, + "1.10.38": { + "en": "New Presence+ triggers wait until one selected person, or everyone, has remained away for a chosen number of minutes; returning home cancels the timer, and a later departure is re-armed even if the previous trigger call is still finishing. Circadian Light Group Collection: Is paused now also detects paused member groups, so Resume is no longer skipped. Guide: https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html", + "no": "Nye Presence+-triggere venter til én valgt person, eller alle, har vært borte i et valgt antall minutter; en hjemkomst avbryter timeren, og en senere avreise armeres på nytt selv om forrige triggerkall fortsatt avsluttes. Døgnrytme-lyssamling: Er pauset oppdager nå også pausede medlemsgrupper, slik at Resume ikke hoppes over. Guide: https://tiwas.github.io/SmartComponentsToolkit/docs/presence-plus.html" } } diff --git a/no.tiwas.booleantoolbox/.homeycompose/app.json b/no.tiwas.booleantoolbox/.homeycompose/app.json index f543a0a..7a72bd3 100644 --- a/no.tiwas.booleantoolbox/.homeycompose/app.json +++ b/no.tiwas.booleantoolbox/.homeycompose/app.json @@ -1,7 +1,7 @@ { "id": "no.tiwas.booleantoolbox", "support": "https://community.homey.app/t/app-boolean-toolbox-create-advanced-logic-with-simple-formulas/143906", - "version": "1.10.35", + "version": "1.10.38", "compatibility": ">=5.0.0", "sdk": 3, "permissions": [ diff --git a/no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_everyone_away_for.json b/no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_everyone_away_for.json new file mode 100644 index 0000000..07d4dd5 --- /dev/null +++ b/no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_everyone_away_for.json @@ -0,0 +1,94 @@ +{ + "id": "presence_everyone_away_for", + "title": { + "en": "Everyone has been away for a while", + "no": "Alle har vært borte en stund", + "de": "Alle sind seit einiger Zeit abwesend", + "nl": "Iedereen is al een tijdje weg", + "fr": "Tout le monde est absent depuis un moment", + "da": "Alle har været væk et stykke tid", + "fi": "Kaikki ovat olleet poissa jonkin aikaa", + "sv": "Alla har varit borta en stund", + "pl": "Wszyscy są nieobecni od pewnego czasu", + "it": "Tutti sono assenti da un po'", + "es": "Todos han estado ausentes un tiempo" + }, + "titleFormatted": { + "en": "Everyone has been away for [[minutes]] minutes", + "no": "Alle har vært borte i [[minutes]] minutter", + "de": "Alle sind seit [[minutes]] Minuten abwesend", + "nl": "Iedereen is [[minutes]] minuten weg", + "fr": "Tout le monde est absent depuis [[minutes]] minutes", + "da": "Alle har været væk i [[minutes]] minutter", + "fi": "Kaikki ovat olleet poissa [[minutes]] minuuttia", + "sv": "Alla har varit borta i [[minutes]] minuter", + "pl": "Wszyscy są nieobecni od [[minutes]] minut", + "it": "Tutti sono assenti da [[minutes]] minuti", + "es": "Todos han estado ausentes durante [[minutes]] minutos" + }, + "hint": { + "en": "Starts counting when the last present person leaves and fires only if nobody returns before the delay expires. Any return cancels the pending trigger.", + "no": "Starter nedtellingen når den siste personen drar, og utløses bare dersom ingen kommer tilbake før tiden er ute. Enhver hjemkomst avbryter den ventende utløseren.", + "de": "Startet, wenn die letzte anwesende Person geht, und wird nur ausgelöst, wenn vor Ablauf der Verzögerung niemand zurückkehrt. Jede Rückkehr bricht den ausstehenden Auslöser ab.", + "nl": "Begint te tellen wanneer de laatste aanwezige persoon vertrekt en wordt alleen geactiveerd als niemand terugkeert voordat de vertraging afloopt. Elke terugkeer annuleert de wachtende trigger.", + "fr": "Le compte à rebours démarre au départ de la dernière personne présente et ne se déclenche que si personne ne revient avant la fin du délai. Tout retour annule le déclencheur en attente.", + "da": "Starter nedtællingen, når den sidste tilstedeværende person går, og udløses kun, hvis ingen vender tilbage, før tiden er gået. Enhver hjemkomst annullerer den ventende udløser.", + "fi": "Aloittaa laskennan viimeisen paikalla olevan henkilön lähtiessä ja käynnistyy vain, jos kukaan ei palaa ennen viiveen päättymistä. Mikä tahansa paluu peruuttaa odottavan käynnistyksen.", + "sv": "Startar nedräkningen när den sista närvarande personen går och utlöses bara om ingen återvänder innan tiden har gått. Varje hemkomst avbryter den väntande utlösaren.", + "pl": "Rozpoczyna odliczanie, gdy ostatnia obecna osoba wychodzi, i uruchamia się tylko wtedy, gdy nikt nie wróci przed upływem czasu. Każdy powrót anuluje oczekujące wyzwolenie.", + "it": "Avvia il conteggio quando l'ultima persona presente esce e si attiva solo se nessuno torna prima della scadenza. Qualsiasi ritorno annulla l'attivazione in attesa.", + "es": "Empieza a contar cuando sale la última persona presente y solo se activa si nadie regresa antes de que termine el plazo. Cualquier regreso cancela el disparador pendiente." + }, + "args": [ + { + "type": "number", + "name": "minutes", + "title": { + "en": "Minutes", "no": "Minutter", "de": "Minuten", "nl": "Minuten", + "fr": "Minutes", "da": "Minutter", "fi": "Minuutit", "sv": "Minuter", + "pl": "Minuty", "it": "Minuti", "es": "Minutos" + }, + "min": 1, + "max": 10080, + "step": 1 + } + ], + "tokens": [ + { + "name": "away_minutes", + "type": "number", + "title": { + "en": "Minutes away", "no": "Minutter borte", "de": "Minuten abwesend", "nl": "Minuten weg", + "fr": "Minutes d'absence", "da": "Minutter væk", "fi": "Poissaolominuutit", "sv": "Minuter borta", + "pl": "Minuty nieobecności", "it": "Minuti di assenza", "es": "Minutos de ausencia" + } + }, + { + "name": "away_since", + "type": "string", + "title": { + "en": "Away since", "no": "Borte siden", "de": "Abwesend seit", "nl": "Weg sinds", + "fr": "Absent depuis", "da": "Væk siden", "fi": "Poissa alkaen", "sv": "Borta sedan", + "pl": "Nieobecni od", "it": "Assenti da", "es": "Ausentes desde" + } + }, + { + "name": "people_count", + "type": "number", + "title": { + "en": "People counted", "no": "Personer telt", "de": "Gezählte Personen", "nl": "Getelde personen", + "fr": "Personnes comptées", "da": "Personer talt", "fi": "Lasketut henkilöt", "sv": "Räknade personer", + "pl": "Liczba osób", "it": "Persone conteggiate", "es": "Personas contadas" + } + }, + { + "name": "last_person_name", + "type": "string", + "title": { + "en": "Last person to leave", "no": "Siste person som dro", "de": "Zuletzt gegangene Person", "nl": "Laatste persoon die vertrok", + "fr": "Dernière personne partie", "da": "Sidste person der gik", "fi": "Viimeisenä lähtenyt henkilö", "sv": "Sista personen som gick", + "pl": "Ostatnia osoba, która wyszła", "it": "Ultima persona uscita", "es": "Última persona en salir" + } + } + ] +} diff --git a/no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_person_away_for.json b/no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_person_away_for.json new file mode 100644 index 0000000..19ff423 --- /dev/null +++ b/no.tiwas.booleantoolbox/.homeycompose/flow/triggers/presence_person_away_for.json @@ -0,0 +1,99 @@ +{ + "id": "presence_person_away_for", + "title": { + "en": "A person has been away for a while", + "no": "En person har vært borte en stund", + "de": "Eine Person ist seit einiger Zeit abwesend", + "nl": "Een persoon is al een tijdje weg", + "fr": "Une personne est absente depuis un moment", + "da": "En person har været væk et stykke tid", + "fi": "Henkilö on ollut poissa jonkin aikaa", + "sv": "En person har varit borta en stund", + "pl": "Osoba jest nieobecna od pewnego czasu", + "it": "Una persona è assente da un po'", + "es": "Una persona ha estado ausente un tiempo" + }, + "titleFormatted": { + "en": "[[person]] has been away for [[minutes]] minutes", + "no": "[[person]] har vært borte i [[minutes]] minutter", + "de": "[[person]] ist seit [[minutes]] Minuten abwesend", + "nl": "[[person]] is [[minutes]] minuten weg", + "fr": "[[person]] est absent(e) depuis [[minutes]] minutes", + "da": "[[person]] har været væk i [[minutes]] minutter", + "fi": "[[person]] on ollut poissa [[minutes]] minuuttia", + "sv": "[[person]] har varit borta i [[minutes]] minuter", + "pl": "[[person]] jest nieobecny(-a) od [[minutes]] minut", + "it": "[[person]] è assente da [[minutes]] minuti", + "es": "[[person]] ha estado ausente durante [[minutes]] minutos" + }, + "hint": { + "en": "Starts counting when the selected person leaves and fires only if they have not returned before the delay expires. A return cancels the pending trigger.", + "no": "Starter nedtellingen når den valgte personen drar, og utløses bare dersom personen ikke har kommet tilbake før tiden er ute. En hjemkomst avbryter den ventende utløseren.", + "de": "Startet beim Verlassen der ausgewählten Person und wird nur ausgelöst, wenn sie vor Ablauf der Verzögerung nicht zurückkehrt. Eine Rückkehr bricht den ausstehenden Auslöser ab.", + "nl": "Begint te tellen wanneer de gekozen persoon vertrekt en wordt alleen geactiveerd als die niet terugkeert voordat de vertraging afloopt. Terugkeer annuleert de wachtende trigger.", + "fr": "Le compte à rebours démarre au départ de la personne sélectionnée et ne se déclenche que si elle ne revient pas avant la fin du délai. Son retour annule le déclencheur en attente.", + "da": "Starter nedtællingen, når den valgte person går, og udløses kun, hvis personen ikke vender tilbage, før tiden er gået. En hjemkomst annullerer den ventende udløser.", + "fi": "Aloittaa laskennan valitun henkilön lähtiessä ja käynnistyy vain, jos hän ei palaa ennen viiveen päättymistä. Paluu peruuttaa odottavan käynnistyksen.", + "sv": "Startar nedräkningen när den valda personen går och utlöses bara om personen inte återvänder innan tiden har gått. En hemkomst avbryter den väntande utlösaren.", + "pl": "Rozpoczyna odliczanie, gdy wybrana osoba wychodzi, i uruchamia się tylko wtedy, gdy nie wróci przed upływem czasu. Powrót anuluje oczekujące wyzwolenie.", + "it": "Avvia il conteggio quando la persona selezionata esce e si attiva solo se non torna prima della scadenza. Il ritorno annulla l'attivazione in attesa.", + "es": "Empieza a contar cuando la persona seleccionada sale y solo se activa si no regresa antes de que termine el plazo. El regreso cancela el disparador pendiente." + }, + "args": [ + { + "type": "autocomplete", + "name": "person", + "title": { + "en": "Person", "no": "Person", "de": "Person", "nl": "Persoon", + "fr": "Personne", "da": "Person", "fi": "Henkilö", "sv": "Person", + "pl": "Osoba", "it": "Persona", "es": "Persona" + }, + "placeholder": { + "en": "Select a person", "no": "Velg en person", "de": "Person auswählen", "nl": "Kies een persoon", + "fr": "Sélectionner une personne", "da": "Vælg en person", "fi": "Valitse henkilö", "sv": "Välj en person", + "pl": "Wybierz osobę", "it": "Seleziona una persona", "es": "Selecciona una persona" + } + }, + { + "type": "number", + "name": "minutes", + "title": { + "en": "Minutes", "no": "Minutter", "de": "Minuten", "nl": "Minuten", + "fr": "Minutes", "da": "Minutter", "fi": "Minuutit", "sv": "Minuter", + "pl": "Minuty", "it": "Minuti", "es": "Minutos" + }, + "min": 1, + "max": 10080, + "step": 1 + } + ], + "tokens": [ + { + "name": "person_name", + "type": "string", + "title": { + "en": "Person name", "no": "Personnavn", "de": "Name der Person", "nl": "Naam van persoon", + "fr": "Nom de la personne", "da": "Personnavn", "fi": "Henkilön nimi", "sv": "Personnamn", + "pl": "Imię osoby", "it": "Nome della persona", "es": "Nombre de la persona" + } + }, + { + "name": "away_minutes", + "type": "number", + "title": { + "en": "Minutes away", "no": "Minutter borte", "de": "Minuten abwesend", "nl": "Minuten weg", + "fr": "Minutes d'absence", "da": "Minutter væk", "fi": "Poissaolominuutit", "sv": "Minuter borta", + "pl": "Minuty nieobecności", "it": "Minuti di assenza", "es": "Minutos de ausencia" + } + }, + { + "name": "away_since", + "type": "string", + "title": { + "en": "Away since", "no": "Borte siden", "de": "Abwesend seit", "nl": "Weg sinds", + "fr": "Absent(e) depuis", "da": "Væk siden", "fi": "Poissa alkaen", "sv": "Borta sedan", + "pl": "Nieobecny(-a) od", "it": "Assente da", "es": "Ausente desde" + } + } + ] +} diff --git a/no.tiwas.booleantoolbox/AppFlowCards.test.js b/no.tiwas.booleantoolbox/AppFlowCards.test.js index c199474..aab3db8 100644 --- a/no.tiwas.booleantoolbox/AppFlowCards.test.js +++ b/no.tiwas.booleantoolbox/AppFlowCards.test.js @@ -53,7 +53,7 @@ function createApp() { }, }; - return { app, actionCards, conditionCards }; + return { app, actionCards, conditionCards, triggerCards }; } describe("app-level Flow cards", () => { @@ -156,4 +156,25 @@ describe("app-level Flow cards", () => { error_message: "", }); }); + + test("presence triggers route only matching person and delay arguments", async () => { + const { app, triggerCards } = createApp(); + await app.registerAllFlowCards(); + + const personListener = triggerCards.get("presence_person_away_for").runListener; + const everyoneListener = triggerCards.get("presence_everyone_away_for").runListener; + + await expect(personListener( + { person: { id: "user-a", name: "Ada" }, minutes: 5 }, + { person_id: "user-a", minutes: 5 }, + )).resolves.toBe(true); + await expect(personListener( + { person: { id: "user-a", name: "Ada" }, minutes: 10 }, + { person_id: "user-a", minutes: 5 }, + )).resolves.toBe(false); + await expect(everyoneListener( + { minutes: 15 }, + { minutes: 15 }, + )).resolves.toBe(true); + }); }); diff --git a/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js b/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js index 56556f8..9f547b9 100644 --- a/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js +++ b/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js @@ -75,6 +75,69 @@ describe('CircadianLightGroupCollectionDevice scheduling', () => { expect(settled).toBe(true); }); + test('reports paused when any member group is paused', async () => { + const device = createCollectionHarness(); + device.getCapabilityValue = jest.fn(() => false); + device.resolveMemberEntries = jest.fn().mockResolvedValue([ + { + memberDevice: { + getCapabilityValue: jest.fn(() => false), + }, + }, + { + memberDevice: { + getCapabilityValue: jest.fn(capability => capability === 'clg_paused'), + }, + }, + { memberDevice: null }, + ]); + + await expect(device.onConditionIsPaused()).resolves.toBe(true); + }); + + test('reports not paused when neither the Collection nor any member group is paused', async () => { + const device = createCollectionHarness(); + device.getCapabilityValue = jest.fn(() => false); + device.resolveMemberEntries = jest.fn().mockResolvedValue([ + { + memberDevice: { + getCapabilityValue: jest.fn(() => false), + }, + }, + { memberDevice: null }, + ]); + + await expect(device.onConditionIsPaused()).resolves.toBe(false); + }); + + test('resume reaches every member group when only a member is paused', async () => { + const device = createCollectionHarness(); + const capabilityValues = { clg_paused: false }; + const members = [ + { onFlowResume: jest.fn().mockResolvedValue(true) }, + { onFlowResume: jest.fn().mockResolvedValue(true) }, + ]; + + device.getCapabilityValue = jest.fn(capability => capabilityValues[capability]); + device.setCapabilityValue = jest.fn(async (capability, value) => { + capabilityValues[capability] = value; + }); + device.clearPauseTimer = jest.fn(); + device.pauseDebug = jest.fn(); + device.clearPersistedPauseState = jest.fn().mockResolvedValue(undefined); + device.firePauseTrigger = jest.fn().mockResolvedValue(undefined); + device.runAwaitedMemberGroups = jest.fn(async (label, taskFn) => { + expect(label).toBe('resume'); + await Promise.all(members.map(member => taskFn(member))); + return true; + }); + + await expect(device.onFlowResume()).resolves.toBe(true); + + members.forEach(member => expect(member.onFlowResume).toHaveBeenCalledTimes(1)); + expect(device.firePauseTrigger).not.toHaveBeenCalled(); + }); + test.each([ [true, 'pause', 'onFlowPause'], [false, 'resume', 'onFlowResume'], diff --git a/no.tiwas.booleantoolbox/PresenceManager.test.js b/no.tiwas.booleantoolbox/PresenceManager.test.js new file mode 100644 index 0000000..8bcf1f8 --- /dev/null +++ b/no.tiwas.booleantoolbox/PresenceManager.test.js @@ -0,0 +1,382 @@ +"use strict"; + +const { EventEmitter } = require("node:events"); +const PresenceManager = require("./lib/PresenceManager"); + +class MockManager extends EventEmitter { + constructor(getValues) { + super(); + this.getValues = getValues; + this.connect = jest.fn(async () => {}); + } +} + +function trigger(id, args) { + return { + id: `homey:app:no.tiwas.booleantoolbox:${id}`, + args, + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createHarness({ users, flows = {}, advancedFlows = {}, stored = null }) { + let persisted = stored; + const usersManager = new MockManager(() => users); + usersManager.getUsers = jest.fn(async () => usersManager.getValues()); + + const flowManager = new MockManager(() => flows); + flowManager.getFlows = jest.fn(async () => flowManager.getValues()); + flowManager.getAdvancedFlows = jest.fn(async () => advancedFlows); + + const personCard = { trigger: jest.fn(async () => {}) }; + const everyoneCard = { trigger: jest.fn(async () => {}) }; + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + const homey = { + manifest: { id: "no.tiwas.booleantoolbox" }, + settings: { + get: jest.fn(() => persisted), + set: jest.fn(async (key, value) => { + persisted = value; + }), + }, + }; + const api = { users: usersManager, flow: flowManager }; + const manager = new PresenceManager({ + homey, + logger, + getApi: jest.fn(async () => api), + }); + manager.setTriggerCards({ person: personCard, everyone: everyoneCard }); + + return { + manager, + usersManager, + flowManager, + personCard, + everyoneCard, + getPersisted: () => persisted, + }; +} + +describe("PresenceManager", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2026-10-13T08:00:00.000Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("fires a person trigger after the configured continuous absence", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: true }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.PERSON_TRIGGER_ID, { + person: { id: "userA", name: "Ada" }, + minutes: 5, + }), + }, + }; + const harness = createHarness({ users, flows }); + await harness.manager.init(); + + users.userA.present = false; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(100); + await jest.advanceTimersByTimeAsync(5 * 60 * 1000); + + expect(harness.personCard.trigger).toHaveBeenCalledTimes(1); + expect(harness.personCard.trigger).toHaveBeenCalledWith( + expect.objectContaining({ + person_name: "Ada", + away_minutes: 5, + away_since: "2026-10-13T08:00:00.000Z", + }), + { person_id: "userA", minutes: 5 }, + ); + + await harness.manager.destroy(); + }); + + test("a return cancels a pending person trigger", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: true }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.PERSON_TRIGGER_ID, { + person: { id: "userA", name: "Ada" }, + minutes: 5, + }), + }, + }; + const harness = createHarness({ users, flows }); + await harness.manager.init(); + + users.userA.present = false; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(5 * 60 * 1000 - 50); + users.userA.present = true; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(100); + + expect(harness.personCard.trigger).not.toHaveBeenCalled(); + await harness.manager.destroy(); + }); + + test("re-arms a new person absence after the previous trigger settles", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: true }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.PERSON_TRIGGER_ID, { + person: { id: "userA", name: "Ada" }, + minutes: 1, + }), + }, + }; + const firstTrigger = deferred(); + const harness = createHarness({ users, flows }); + harness.personCard.trigger + .mockImplementationOnce(() => firstTrigger.promise) + .mockResolvedValue(undefined); + await harness.manager.init(); + + users.userA.present = false; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(harness.personCard.trigger).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(5 * 1000); + users.userA.present = true; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(1000); + users.userA.present = false; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(harness.personCard.trigger).toHaveBeenCalledTimes(1); + + firstTrigger.resolve(); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(29 * 1000 + 999); + expect(harness.personCard.trigger).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + + expect(harness.personCard.trigger).toHaveBeenCalledTimes(2); + expect(harness.personCard.trigger).toHaveBeenLastCalledWith( + expect.objectContaining({ + away_minutes: 1, + away_since: "2026-10-13T08:01:06.000Z", + }), + { person_id: "userA", minutes: 1 }, + ); + await harness.manager.destroy(); + }); + + test("starts the everyone timer when the last present person leaves", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: false }, + userB: { id: "userB", name: "Ben", enabled: true, present: true }, + }; + const advancedFlows = { + advancedA: { + enabled: true, + cards: { + cardA: { + ...trigger(PresenceManager.EVERYONE_TRIGGER_ID, { minutes: 2 }), + type: "trigger", + }, + }, + }, + }; + const harness = createHarness({ users, advancedFlows }); + await harness.manager.init(); + + users.userB.present = false; + harness.usersManager.emit("user.update", users.userB); + await jest.advanceTimersByTimeAsync(100); + await jest.advanceTimersByTimeAsync(2 * 60 * 1000); + + expect(harness.everyoneCard.trigger).toHaveBeenCalledTimes(1); + expect(harness.everyoneCard.trigger).toHaveBeenCalledWith( + expect.objectContaining({ + away_minutes: 2, + people_count: 2, + last_person_name: "Ben", + }), + { minutes: 2 }, + ); + + await harness.manager.destroy(); + }); + + test("any return cancels a pending everyone trigger", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: false }, + userB: { id: "userB", name: "Ben", enabled: true, present: false }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.EVERYONE_TRIGGER_ID, { minutes: 5 }), + }, + }; + const harness = createHarness({ users, flows }); + await harness.manager.init(); + await jest.advanceTimersByTimeAsync(2 * 60 * 1000); + + users.userA.present = true; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(100); + await jest.advanceTimersByTimeAsync(5 * 60 * 1000); + + expect(harness.everyoneCard.trigger).not.toHaveBeenCalled(); + await harness.manager.destroy(); + }); + + test("re-arms a new everyone-away episode after the previous trigger settles", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: true }, + userB: { id: "userB", name: "Ben", enabled: true, present: false }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.EVERYONE_TRIGGER_ID, { minutes: 1 }), + }, + }; + const firstTrigger = deferred(); + const harness = createHarness({ users, flows }); + harness.everyoneCard.trigger + .mockImplementationOnce(() => firstTrigger.promise) + .mockResolvedValue(undefined); + await harness.manager.init(); + + users.userA.present = false; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(harness.everyoneCard.trigger).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(5 * 1000); + users.userA.present = true; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(1000); + users.userA.present = false; + harness.usersManager.emit("user.update", users.userA); + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(harness.everyoneCard.trigger).toHaveBeenCalledTimes(1); + + firstTrigger.resolve(); + await Promise.resolve(); + await jest.advanceTimersByTimeAsync(29 * 1000 + 999); + expect(harness.everyoneCard.trigger).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(1); + + expect(harness.everyoneCard.trigger).toHaveBeenCalledTimes(2); + expect(harness.everyoneCard.trigger).toHaveBeenLastCalledWith( + expect.objectContaining({ + away_minutes: 1, + away_since: "2026-10-13T08:01:06.000Z", + last_person_name: "Ada", + }), + { minutes: 1 }, + ); + await harness.manager.destroy(); + }); + + test("resumes a pending absence countdown after an app restart", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: false }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.PERSON_TRIGGER_ID, { + person: { id: "userA", name: "Ada" }, + minutes: 2, + }), + }, + }; + const first = createHarness({ users, flows }); + await first.manager.init(); + await jest.advanceTimersByTimeAsync(60 * 1000); + const persisted = first.getPersisted(); + await first.manager.destroy(); + + const restarted = createHarness({ users, flows, stored: persisted }); + await restarted.manager.init(); + await jest.advanceTimersByTimeAsync(59 * 1000); + expect(restarted.personCard.trigger).not.toHaveBeenCalled(); + await jest.advanceTimersByTimeAsync(1000); + + expect(restarted.personCard.trigger).toHaveBeenCalledTimes(1); + await restarted.manager.destroy(); + }); + + test("does not repeat an already-fired threshold after an app restart", async () => { + const users = { + userA: { id: "userA", name: "Ada", enabled: true, present: false }, + }; + const flows = { + flowA: { + enabled: true, + trigger: trigger(PresenceManager.PERSON_TRIGGER_ID, { + person: { id: "userA", name: "Ada" }, + minutes: 1, + }), + }, + }; + const first = createHarness({ users, flows }); + await first.manager.init(); + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(first.personCard.trigger).toHaveBeenCalledTimes(1); + const persisted = first.getPersisted(); + await first.manager.destroy(); + + const restarted = createHarness({ users, flows, stored: persisted }); + await restarted.manager.init(); + await jest.advanceTimersByTimeAsync(5 * 60 * 1000); + + expect(restarted.personCard.trigger).not.toHaveBeenCalled(); + await restarted.manager.destroy(); + }); + + test("run listeners match both the target and configured delay", () => { + expect(PresenceManager.personRunMatches( + { person: { id: "userA" }, minutes: 5 }, + { person_id: "userA", minutes: 5 }, + )).toBe(true); + expect(PresenceManager.personRunMatches( + { person: { id: "userB" }, minutes: 5 }, + { person_id: "userA", minutes: 5 }, + )).toBe(false); + expect(PresenceManager.everyoneRunMatches( + { minutes: 10 }, + { minutes: 10 }, + )).toBe(true); + expect(PresenceManager.everyoneRunMatches( + { minutes: 15 }, + { minutes: 10 }, + )).toBe(false); + }); +}); diff --git a/no.tiwas.booleantoolbox/README.txt b/no.tiwas.booleantoolbox/README.txt index 3a876ac..ecd4303 100644 --- a/no.tiwas.booleantoolbox/README.txt +++ b/no.tiwas.booleantoolbox/README.txt @@ -52,6 +52,13 @@ Generate a privacy-conscious diagnostic report from App Settings and open the Gi == STANDALONE FLOW CARDS == +Presence+ + Debounce Homey's presence signal before starting a Flow. + Repeated departures are re-armed from their own Away time. + - Trigger after one selected person has remained away for a configurable number of minutes + - Trigger after everyone has remained away since the last present person left + - A return cancels the pending trigger; countdowns survive app restarts + Conditional Gates Persistent named GO/NO GO gates for coordinating flows without a virtual device. Gates and pending waits are kept in memory until the app restarts. - Check a gate immediately diff --git a/no.tiwas.booleantoolbox/app.js b/no.tiwas.booleantoolbox/app.js index fc2f415..7f6846d 100644 --- a/no.tiwas.booleantoolbox/app.js +++ b/no.tiwas.booleantoolbox/app.js @@ -7,6 +7,7 @@ const Logger = require("./lib/Logger"); const WaiterManager = require("./lib/WaiterManager"); const CapturedStateManager = require("./lib/CapturedStateManager"); const FlowIdentity = require("./lib/FlowIdentity"); +const PresenceManager = require("./lib/PresenceManager"); const { buildDiagnosticsReport, buildGitHubIssueUrl, @@ -295,6 +296,14 @@ module.exports = class BooleanToolboxApp extends Homey.App { // Initialize CapturedStateManager this.capturedStateManager = new CapturedStateManager(this.homey, this.logger); + // Tracks Homey user presence and runs delayed absence triggers outside + // the approximately 60-second lifetime of an individual Flow card. + this.presenceManager = new PresenceManager({ + homey: this.homey, + logger: this.logger, + getApi: () => this.ensureHomeyApi(), + }); + // Lets the flow_whoami card find the Advanced Flow it runs in this.flowIdentity = new FlowIdentity({ appId: this.homey.manifest?.id || "no.tiwas.booleantoolbox", @@ -306,6 +315,12 @@ module.exports = class BooleanToolboxApp extends Homey.App { // Register ALL Flow Cards here using generic methods await this.registerAllFlowCards(); + try { + await this.presenceManager.init(); + } catch (error) { + this.logger.error("Presence guard initialization failed", error); + } + await this.refreshDeviceRegistry("startup").catch((error) => { this.logger.error("Device registry startup refresh failed", error); }); @@ -334,6 +349,10 @@ module.exports = class BooleanToolboxApp extends Homey.App { this.diagnosticMemoryInterval = null; } + if (this.presenceManager) { + await this.presenceManager.destroy(); + } + // Cleanup WaiterManager if (this.waiterManager) { this.waiterManager.destroy(); @@ -875,6 +894,7 @@ module.exports = class BooleanToolboxApp extends Homey.App { api.flow, api.flowAdv, api.apps, + api.users, ].forEach((manager) => { if (!manager || typeof manager.setMaxListeners !== "function") { return; @@ -2146,6 +2166,34 @@ module.exports = class BooleanToolboxApp extends Homey.App { this.logger.error(` -> FAILED: Registering APP TRIGGER 'wait_until_finished'`, e); } + // --- Presence guards --- + // The manager discovers the configured delays in enabled Flows and + // schedules them in the background. The run listeners only route each + // timer event to cards with the matching person and delay. + try { + const personAwayCard = this.homey.flow.getTriggerCard( + "presence_person_away_for", + ); + personAwayCard.registerArgumentAutocompleteListener("person", (query) => + this.presenceManager?.getPeopleAutocomplete(query) || []); + personAwayCard.registerRunListener(async (args, state) => + PresenceManager.personRunMatches(args, state)); + + const everyoneAwayCard = this.homey.flow.getTriggerCard( + "presence_everyone_away_for", + ); + everyoneAwayCard.registerRunListener(async (args, state) => + PresenceManager.everyoneRunMatches(args, state)); + + this.presenceManager?.setTriggerCards({ + person: personAwayCard, + everyone: everyoneAwayCard, + }); + this.logger.debug(" -> OK: Presence guard triggers registered"); + } catch (e) { + this.logger.error(" -> FAILED: Registering presence guard triggers", e); + } + this.logger.info("app.flow_cards_registered", {}); } diff --git a/no.tiwas.booleantoolbox/drivers/circadian-light-group-collection/device.js b/no.tiwas.booleantoolbox/drivers/circadian-light-group-collection/device.js index 8fe45f5..50ede1e 100644 --- a/no.tiwas.booleantoolbox/drivers/circadian-light-group-collection/device.js +++ b/no.tiwas.booleantoolbox/drivers/circadian-light-group-collection/device.js @@ -428,6 +428,15 @@ class CircadianLightGroupCollectionDevice extends CircadianLightGroupDevice { })); } + async onConditionIsPaused() { + if (this.getCapabilityValue('clg_paused') === true) return true; + + const entries = await this.resolveMemberEntries(); + return entries.some(entry => ( + entry.memberDevice?.getCapabilityValue('clg_paused') === true + )); + } + async onConditionIsInPhase(args) { const entries = (await this.resolveMemberEntries()).filter(entry => entry.memberDevice); if (entries.length === 0) return false; diff --git a/no.tiwas.booleantoolbox/lib/PresenceManager.js b/no.tiwas.booleantoolbox/lib/PresenceManager.js new file mode 100644 index 0000000..fd89fd2 --- /dev/null +++ b/no.tiwas.booleantoolbox/lib/PresenceManager.js @@ -0,0 +1,573 @@ +"use strict"; + +const PRESENCE_STATE_KEY = "presence_guard_state_v1"; +const PERSON_TRIGGER_ID = "presence_person_away_for"; +const EVERYONE_TRIGGER_ID = "presence_everyone_away_for"; +const MIN_DELAY_MINUTES = 1; +const MAX_DELAY_MINUTES = 7 * 24 * 60; +const REFRESH_DEBOUNCE_MS = 100; + +/** + * Tracks Homey's user presence and schedules delayed absence Flow triggers. + * + * Trigger-card arguments live in the user's Flows, not in the app runtime. + * This manager therefore reads enabled standard and Advanced Flows, reduces + * them to unique person/delay configurations, and schedules one timer for + * each configuration. Absence episodes and fired thresholds are persisted so + * app restarts neither reset a running delay nor fire the same threshold twice. + */ +class PresenceManager { + constructor({ homey, logger, getApi, now = () => Date.now() }) { + this.homey = homey; + this.logger = logger; + this.getApi = getApi; + this.now = now; + + this.users = new Map(); + this.personConfigurations = new Map(); + this.everyoneConfigurations = new Map(); + this.timers = new Map(); + this.inFlight = new Set(); + this.managerListeners = []; + this.firedPerson = {}; + this.firedEveryone = {}; + this.allAbsentSince = null; + this.lastPersonName = ""; + this.personTriggerCard = null; + this.everyoneTriggerCard = null; + this.userRefreshTimer = null; + this.flowRefreshTimer = null; + this.persistPromise = Promise.resolve(); + this.userRevision = 0; + this.destroyed = false; + } + + setTriggerCards({ person, everyone }) { + this.personTriggerCard = person; + this.everyoneTriggerCard = everyone; + } + + static autocompleteId(value) { + if (typeof value === "string") return value.trim(); + if (!value || typeof value !== "object") return ""; + return String(value.id || value.name || "").trim(); + } + + static delayMinutes(value) { + const minutes = Number(value); + if ( + !Number.isInteger(minutes) + || minutes < MIN_DELAY_MINUTES + || minutes > MAX_DELAY_MINUTES + ) { + return null; + } + return minutes; + } + + static triggerMatches(card, triggerId, appId) { + if (!card || typeof card !== "object") return false; + const fullId = `homey:app:${appId}:${triggerId}`; + return [card.id, card.uri].some((value) => + typeof value === "string" + && (value === triggerId || value === fullId || value.endsWith(`:${triggerId}`))); + } + + static personRunMatches(args, state) { + const personId = PresenceManager.autocompleteId(args?.person); + const minutes = PresenceManager.delayMinutes(args?.minutes); + return !!personId + && minutes !== null + && personId === state?.person_id + && minutes === state?.minutes; + } + + static everyoneRunMatches(args, state) { + const minutes = PresenceManager.delayMinutes(args?.minutes); + return minutes !== null && minutes === state?.minutes; + } + + async init() { + this.loadPersistedState(); + + const api = await this.getApi(); + if (!api?.users || !api?.flow) { + throw new Error("Homey users or Flow API is not available"); + } + + this.api = api; + this.usersManager = api.users; + this.flowManager = api.flow; + this.bindManagerEvents(); + + if (typeof this.usersManager.connect === "function") { + await this.usersManager.connect(); + } + if (typeof this.flowManager.connect === "function") { + await this.flowManager.connect(); + } + + await this.syncUsers(); + await this.refreshConfigurations(); + this.logger.info("Presence guard initialized", { + users: this.users.size, + personTimers: this.personConfigurations.size, + everyoneTimers: this.everyoneConfigurations.size, + }); + } + + loadPersistedState() { + let stored = null; + try { + stored = this.homey.settings.get(PRESENCE_STATE_KEY); + } catch (error) { + this.logger.warn("Could not read persisted presence state", { + message: error.message, + }); + } + + if (!stored || typeof stored !== "object") return; + + for (const [id, value] of Object.entries(stored.users || {})) { + if (!value || typeof value !== "object") continue; + this.users.set(id, { + id, + name: typeof value.name === "string" ? value.name : id, + present: value.present === true, + absentSince: Number.isFinite(value.absentSince) ? value.absentSince : null, + }); + } + + this.allAbsentSince = Number.isFinite(stored.allAbsentSince) + ? stored.allAbsentSince + : null; + this.lastPersonName = typeof stored.lastPersonName === "string" + ? stored.lastPersonName + : ""; + this.firedPerson = stored.firedPerson && typeof stored.firedPerson === "object" + ? { ...stored.firedPerson } + : {}; + this.firedEveryone = stored.firedEveryone && typeof stored.firedEveryone === "object" + ? { ...stored.firedEveryone } + : {}; + } + + bindManagerEvents() { + const bind = (manager, event, listener) => { + if (!manager || typeof manager.on !== "function") return; + manager.on(event, listener); + this.managerListeners.push({ manager, event, listener }); + }; + + ["user.create", "user.update"].forEach((event) => { + bind(this.usersManager, event, (user) => this.applyUserEvent(user)); + }); + bind(this.usersManager, "user.delete", (user) => this.applyUserEvent(user, true)); + [ + "flow.create", + "flow.update", + "flow.delete", + "advancedflow.create", + "advancedflow.update", + "advancedflow.delete", + ].forEach((event) => { + bind(this.flowManager, event, () => this.queueFlowRefresh()); + }); + bind(this.api, "reconnect", () => { + this.queueUserRefresh(); + this.queueFlowRefresh(); + }); + } + + applyUserEvent(user, deleted = false) { + if (this.destroyed || !user?.id) return; + this.userRevision += 1; + const previous = this.users.get(user.id); + let lastTransitionUser = null; + + if (deleted || user.enabled === false) { + this.users.delete(user.id); + this.clearPersonFired(user.id); + if (previous?.present === true) { + lastTransitionUser = { + ...previous, + absentSince: this.now(), + }; + } + } else if (typeof user.present === "boolean") { + const present = user.present === true; + this.users.set(user.id, { + id: user.id, + name: typeof user.name === "string" && user.name ? user.name : user.id, + present, + absentSince: present + ? null + : previous?.present === false && Number.isFinite(previous.absentSince) + ? previous.absentSince + : this.now(), + }); + if (!present && previous?.present !== false) { + lastTransitionUser = this.users.get(user.id); + } + if (present && previous?.present === false) { + this.clearPersonFired(user.id); + } + } else { + this.queueUserRefresh(); + return; + } + + // Apply the realtime event before any asynchronous refresh. In + // particular, a return at the timer boundary must cancel the timer + // immediately instead of waiting for a REST round-trip. + this.updateEveryoneState(lastTransitionUser); + this.persistState(); + this.refreshSchedules(); + } + + queueUserRefresh() { + if (this.destroyed) return; + if (this.userRefreshTimer) clearTimeout(this.userRefreshTimer); + this.userRefreshTimer = setTimeout(() => { + this.userRefreshTimer = null; + this.syncUsers().catch((error) => { + this.logger.error("Presence state refresh failed", error); + }); + }, REFRESH_DEBOUNCE_MS); + } + + queueFlowRefresh() { + if (this.destroyed) return; + if (this.flowRefreshTimer) clearTimeout(this.flowRefreshTimer); + this.flowRefreshTimer = setTimeout(() => { + this.flowRefreshTimer = null; + this.refreshConfigurations().catch((error) => { + this.logger.error("Presence Flow refresh failed", error); + }); + }, REFRESH_DEBOUNCE_MS); + } + + async syncUsers() { + const revision = this.userRevision; + const rawUsers = await this.usersManager.getUsers({ $cache: false }); + if (revision !== this.userRevision) { + this.queueUserRefresh(); + return; + } + const currentUsers = Object.values(rawUsers || {}).filter((user) => + user + && user.id + && user.enabled !== false + && typeof user.present === "boolean"); + const currentIds = new Set(currentUsers.map((user) => user.id)); + const now = this.now(); + let lastTransitionUser = null; + + for (const user of currentUsers) { + const previous = this.users.get(user.id); + const present = user.present === true; + const absentSince = present + ? null + : previous?.present === false && Number.isFinite(previous.absentSince) + ? previous.absentSince + : now; + + const current = { + id: user.id, + name: typeof user.name === "string" && user.name ? user.name : user.id, + present, + absentSince, + }; + this.users.set(user.id, current); + if (!present && previous?.present !== false) lastTransitionUser = current; + + if (present && previous?.present === false) { + this.clearPersonFired(user.id); + } + } + + for (const userId of [...this.users.keys()]) { + if (currentIds.has(userId)) continue; + const removed = this.users.get(userId); + this.users.delete(userId); + this.clearPersonFired(userId); + if (removed?.present === true) { + lastTransitionUser = { + ...removed, + absentSince: now, + }; + } + } + + this.updateEveryoneState(lastTransitionUser); + await this.persistState(); + this.refreshSchedules(); + } + + updateEveryoneState(lastTransitionUser = null) { + const trackedUsers = [...this.users.values()]; + const everyoneAway = trackedUsers.length > 0 + && trackedUsers.every((user) => user.present === false); + + if (!everyoneAway) { + if (this.allAbsentSince !== null) this.firedEveryone = {}; + this.allAbsentSince = null; + this.lastPersonName = ""; + return; + } + + if (Number.isFinite(this.allAbsentSince)) return; + + const lastToLeave = lastTransitionUser + || trackedUsers.reduce((latest, user) => + !latest || user.absentSince > latest.absentSince ? user : latest, null); + this.allAbsentSince = lastToLeave?.absentSince || this.now(); + this.lastPersonName = lastToLeave?.name || ""; + } + + clearPersonFired(userId) { + const prefix = `${userId}|`; + for (const key of Object.keys(this.firedPerson)) { + if (key.startsWith(prefix)) delete this.firedPerson[key]; + } + } + + async refreshConfigurations() { + const [flows, advancedFlows] = await Promise.all([ + this.flowManager.getFlows({ $cache: false }), + this.getAdvancedFlows(), + ]); + const personConfigurations = new Map(); + const everyoneConfigurations = new Map(); + const appId = this.homey.manifest?.id || "no.tiwas.booleantoolbox"; + + const collect = (card) => { + if (PresenceManager.triggerMatches(card, PERSON_TRIGGER_ID, appId)) { + const personId = PresenceManager.autocompleteId(card.args?.person); + const minutes = PresenceManager.delayMinutes(card.args?.minutes); + if (personId && minutes !== null) { + personConfigurations.set(`${personId}|${minutes}`, { personId, minutes }); + } + } + if (PresenceManager.triggerMatches(card, EVERYONE_TRIGGER_ID, appId)) { + const minutes = PresenceManager.delayMinutes(card.args?.minutes); + if (minutes !== null) { + everyoneConfigurations.set(String(minutes), { minutes }); + } + } + }; + + for (const flow of Object.values(flows || {})) { + if (flow?.enabled === false) continue; + collect(flow?.trigger); + } + for (const flow of Object.values(advancedFlows || {})) { + if (flow?.enabled === false) continue; + for (const card of Object.values(flow?.cards || {})) { + if (card?.type === "trigger") collect(card); + } + } + + this.personConfigurations = personConfigurations; + this.everyoneConfigurations = everyoneConfigurations; + this.refreshSchedules(); + } + + async getAdvancedFlows() { + try { + if (typeof this.flowManager.getAdvancedFlows === "function") { + return await this.flowManager.getAdvancedFlows({ $cache: false }); + } + if (this.api?.flowAdv && typeof this.api.flowAdv.getFlows === "function") { + return await this.api.flowAdv.getFlows({ $cache: false }); + } + } catch (error) { + this.logger.warn("Advanced Flows are unavailable for presence guards", { + message: error.message, + }); + } + return {}; + } + + refreshSchedules() { + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + if (this.destroyed) return; + + for (const [key, configuration] of this.personConfigurations) { + const user = this.users.get(configuration.personId); + if (!user || user.present || !Number.isFinite(user.absentSince)) continue; + if (this.firedPerson[key] === user.absentSince || this.inFlight.has(`person:${key}`)) { + continue; + } + this.schedule(`person:${key}`, user.absentSince, configuration.minutes, () => + this.firePerson(configuration, user.absentSince)); + } + + if (!Number.isFinite(this.allAbsentSince)) return; + for (const [key, configuration] of this.everyoneConfigurations) { + if ( + this.firedEveryone[key] === this.allAbsentSince + || this.inFlight.has(`everyone:${key}`) + ) { + continue; + } + const episodeStart = this.allAbsentSince; + this.schedule(`everyone:${key}`, episodeStart, configuration.minutes, () => + this.fireEveryone(configuration, episodeStart)); + } + } + + schedule(key, episodeStart, minutes, callback) { + const dueAt = episodeStart + minutes * 60 * 1000; + const delay = Math.max(0, dueAt - this.now()); + const timer = setTimeout(() => { + this.timers.delete(key); + Promise.resolve(callback()).catch((error) => { + this.logger.error("Presence timer failed", error); + }); + }, delay); + this.timers.set(key, timer); + } + + async firePerson(configuration, episodeStart) { + const key = `${configuration.personId}|${configuration.minutes}`; + const inFlightKey = `person:${key}`; + const user = this.users.get(configuration.personId); + if ( + !this.personTriggerCard + || !user + || user.present + || user.absentSince !== episodeStart + || this.firedPerson[key] === episodeStart + || this.inFlight.has(inFlightKey) + ) { + return; + } + + this.inFlight.add(inFlightKey); + try { + await this.personTriggerCard.trigger({ + person_name: user.name, + away_minutes: this.elapsedMinutes(episodeStart), + away_since: new Date(episodeStart).toISOString(), + }, { + person_id: user.id, + minutes: configuration.minutes, + }); + const currentUser = this.users.get(configuration.personId); + if ( + currentUser?.present === false + && currentUser.absentSince === episodeStart + ) { + this.firedPerson[key] = episodeStart; + await this.persistState(); + } + } finally { + this.inFlight.delete(inFlightKey); + const currentUser = this.users.get(configuration.personId); + if ( + currentUser?.present === false + && Number.isFinite(currentUser.absentSince) + && currentUser.absentSince !== episodeStart + ) { + this.refreshSchedules(); + } + } + } + + async fireEveryone(configuration, episodeStart) { + const key = String(configuration.minutes); + const inFlightKey = `everyone:${key}`; + if ( + !this.everyoneTriggerCard + || this.allAbsentSince !== episodeStart + || [...this.users.values()].some((user) => user.present) + || this.firedEveryone[key] === episodeStart + || this.inFlight.has(inFlightKey) + ) { + return; + } + + this.inFlight.add(inFlightKey); + try { + await this.everyoneTriggerCard.trigger({ + away_minutes: this.elapsedMinutes(episodeStart), + away_since: new Date(episodeStart).toISOString(), + people_count: this.users.size, + last_person_name: this.lastPersonName, + }, { + minutes: configuration.minutes, + }); + if (this.allAbsentSince === episodeStart) { + this.firedEveryone[key] = episodeStart; + await this.persistState(); + } + } finally { + this.inFlight.delete(inFlightKey); + if ( + Number.isFinite(this.allAbsentSince) + && this.allAbsentSince !== episodeStart + ) { + this.refreshSchedules(); + } + } + } + + elapsedMinutes(episodeStart) { + return Number(((this.now() - episodeStart) / 60000).toFixed(2)); + } + + getPeopleAutocomplete(query = "") { + const normalizedQuery = String(query || "").trim().toLowerCase(); + return [...this.users.values()] + .filter((user) => !normalizedQuery || user.name.toLowerCase().includes(normalizedQuery)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((user) => ({ id: user.id, name: user.name })); + } + + persistState() { + const state = { + version: 1, + users: Object.fromEntries(this.users), + allAbsentSince: this.allAbsentSince, + lastPersonName: this.lastPersonName, + firedPerson: this.firedPerson, + firedEveryone: this.firedEveryone, + updatedAt: new Date(this.now()).toISOString(), + }; + this.persistPromise = this.persistPromise + .catch(() => {}) + .then(() => Promise.resolve(this.homey.settings.set(PRESENCE_STATE_KEY, state))) + .catch((error) => { + this.logger.error("Could not persist presence state", error); + }); + return this.persistPromise; + } + + async destroy() { + this.destroyed = true; + if (this.userRefreshTimer) clearTimeout(this.userRefreshTimer); + if (this.flowRefreshTimer) clearTimeout(this.flowRefreshTimer); + this.userRefreshTimer = null; + this.flowRefreshTimer = null; + + for (const timer of this.timers.values()) clearTimeout(timer); + this.timers.clear(); + for (const { manager, event, listener } of this.managerListeners) { + if (typeof manager.removeListener === "function") { + manager.removeListener(event, listener); + } + } + this.managerListeners = []; + await this.persistPromise; + } +} + +PresenceManager.PRESENCE_STATE_KEY = PRESENCE_STATE_KEY; +PresenceManager.PERSON_TRIGGER_ID = PERSON_TRIGGER_ID; +PresenceManager.EVERYONE_TRIGGER_ID = EVERYONE_TRIGGER_ID; +PresenceManager.MIN_DELAY_MINUTES = MIN_DELAY_MINUTES; +PresenceManager.MAX_DELAY_MINUTES = MAX_DELAY_MINUTES; + +module.exports = PresenceManager; diff --git a/no.tiwas.booleantoolbox/package-lock.json b/no.tiwas.booleantoolbox/package-lock.json index 76e01c4..bf2c44e 100644 --- a/no.tiwas.booleantoolbox/package-lock.json +++ b/no.tiwas.booleantoolbox/package-lock.json @@ -1,12 +1,12 @@ { "name": "no.tiwas.booleantoolbox", - "version": "1.10.35", + "version": "1.10.38", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "no.tiwas.booleantoolbox", - "version": "1.10.35", + "version": "1.10.38", "license": "ISC", "dependencies": { "homey-api": "3.20.0", diff --git a/no.tiwas.booleantoolbox/package.json b/no.tiwas.booleantoolbox/package.json index 3dd3996..c703b6f 100644 --- a/no.tiwas.booleantoolbox/package.json +++ b/no.tiwas.booleantoolbox/package.json @@ -1,6 +1,6 @@ { "name": "no.tiwas.booleantoolbox", - "version": "1.10.35", + "version": "1.10.38", "main": "app.js", "dependencies": { "homey-api": "3.20.0",