diff --git a/PROJECT_DOCUMENTATION.md b/PROJECT_DOCUMENTATION.md
index 5b04692..a89dc99 100644
--- a/PROJECT_DOCUMENTATION.md
+++ b/PROJECT_DOCUMENTATION.md
@@ -47,6 +47,13 @@
* **Run:** `lib/FlowIdentity.js` reads the Advanced Flows on every run (`$cache: false`, no memory between runs, so renames show at once) and finds the Flow holding the card with that id. Nothing picked, no saved Flow, or the id in several Flows (a copied card or duplicated Flow) returns a marker name such as `[Duplicate]`, `has_error: true` and an explanation in `error_message`, instead of throwing, because Homey drops tokens on the error output. It only throws when the Flows cannot be read.
* **Key Files:** `lib/FlowIdentity.js`, `.homeycompose/flow/actions/flow_whoami.json`, `app.js` (registration), `FlowIdentity.test.js`.
+### 7. Circadian Light Group and the Flow card limit
+* **Retries after the card:** member on/off commands and profile updates call `runDeviceTasksParallel` with `deferRetries`. It returns after the first parallel pass and one verification; with a verify step, unconfirmed lights are checked once more after 1.5 s (`SETTLE_CHECK_MS`) before the call returns. Lights still unconfirmed are listed in `pending`, and the reduced parallel retry and final serial retry run in `background`. A newer command stops them through the operation generation (`acquireOp`), also during the final serial pass, so a superseded command reports nothing. A member command (`runMemberCommand`) stays active, so the scheduler waits, until its background retries finish; verification, `alarm_config`, `clg_error_occurred` and `clg_target_changed` are reported then.
+* **Time budget:** every Circadian action card and the `onoff`/`clg_paused` capability listeners go through `runWithinCardTimeBudget` (50 s). Work still running after that goes on in the background.
+* **Outcome and tokens:** operations return an outcome (`completed`, `ok`, `total`, `pending`, `failed`, `skipped`, `superseded`, `budgetExceeded`, `background`, and `groups` for a Collection). `ok` keeps the boolean each operation returned before, so the Collection's group error reporting is unchanged, and cards without tokens still return it. Only `clg_turn_on`, `clg_turn_off` and `clg_toggle` return `completed` and `status` (`toCardTokens`, texts under `circadian_outcome` in the locales). They are no longer deprecated, because the device's own On/Off/Toggle cards cannot return tokens. Homey shows THEN cards with tokens only in Advanced Flows, so cards that may be in standard Flows (Apply now, Resume, Apply temporary state and the others) get no tokens; `CircadianLightGroupDriver.test.js` checks this.
+* **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`.
+
## Project Structure
* `no.tiwas.booleantoolbox/`: Main Homey app source.
* `app.js`: Application entry point and diagnostics collector.
diff --git a/WORKLOG.md b/WORKLOG.md
index 02e5bf1..b115898 100644
--- a/WORKLOG.md
+++ b/WORKLOG.md
@@ -1,5 +1,57 @@
# Worklog
+## 2026-10-08 — Circadian Light Group and the 60 s Flow card limit (investigation)
+
+### Requested
+- Lars suspected Homey's 60 s Flow card limit is behind the Circadian Light Group problems. He asked for a check on his Homey and for proposed fixes, and suggested moving the retry logic out of the card into the background.
+
+### Findings
+- Lars's "All on - Toolbox" Flow uses the Circadian Light Group **Collection** (`clg_is_paused` → `clg_resume` → the device's own On card). "All on (flood)" pauses the Collection for 12 h, and "All off (actions)" uses its Off card. The Collection fans out to "Circadian Light Group" (11 lights) and "CLG (soverom)" (5 lights).
+- Since v1.10.25 the Collection cards stay pending until every member group has finished its first pass, verification, a reduced parallel retry and a final serial retry (`runCollectionOperation` → `runAwaitedMemberGroups` → each group's `runDeviceTasksParallel`). The On card goes through the `onoff` capability listener, which awaits the same work.
+- Time per light per pass: up to 10 s for a write to a light that does not answer (homey-api `DEFAULT_TIMEOUT`), about 1.4 s for a Z-Wave `TRANSMIT_COMPLETE_NO_ACK` (log from 2026-07-02), up to 2.5 s waiting for the on acknowledgement, and 150 ms after each write. Every light that is not verified runs through all three passes.
+- Three member lights do not answer: "Spisestue: Tunable White Bulb (E27)" (last update 6 Oct), "Soverom: Nattbord v" (last update 24 Feb) and "Smart Energy Illuminator" (dim last updated 24 Jul). Since tonight's "All on", the main group logs "member update failed" on every 2-minute timer.
+- Tonight's "All on" (20:16 local time) included a resume and two rounds of verification with retries. The first light went on at 20:16:06.8. The main group's last "2 member(s) could not be verified … after retries" came at 20:16:59.960, and the Collection's `onoff` was recorded at 20:16:59.974, when its listener finished. The whole sequence was about 53 s. The diagnostics report could not tell which card started each step.
+- `clg_apply_now` measured with `runFlowCardAction` (Homey's `elapsedTime`): Collection 7.0 s, main group 6.2 s, bedroom group 1.0 s. A plain profile update is far below the limit. The long cases are turn-on and resume with verification and retries.
+- When Homey stops a card at 60 s, the Flow does not follow that card's outputs. In "All on - Toolbox", a `clg_resume` that times out would stop the Collection On card and the other two lights after it. The app's own work goes on in the background.
+
+### Decision
+- Lars agreed to move the retries out of the card, asked for a yes/no token and a text token that say what happened when the Flow continued before everything was done, and suggested sending every command first and collecting the retry information afterwards. For on/off he chose to bring back the hidden `clg_turn_on`/`clg_turn_off`/`clg_toggle` cards with tokens, because the device's own On/Off cards cannot return tokens.
+
+### Implemented (branch `clg-background-retries`)
+- `runDeviceTasksParallel` has a `deferRetries` option. It returns after the first parallel pass and one verification, lists unconfirmed lights in `pending`, and runs the reduced parallel retry and final serial retry in `background`. With a verify step, unconfirmed lights are checked once more after 1.5 s before it returns, so a light that reports late is neither listed as unconfirmed nor written twice. A newer command stops the retries through the operation generation, as before.
+- Member on/off commands (`onFlowTurnOnAllMembers`, `onFlowTurnOffAllMembers`, `onFlowTurnOnMember`) and profile updates use it. A member command stays active until its background retries finish, so the scheduler still waits. Verification, `alarm_config`, `clg_error_occurred` and `clg_target_changed` are reported when the retries finish, with the same messages as before.
+- `runWithinCardTimeBudget` (50 s) wraps every Circadian action card and the `onoff`/`clg_paused` capability listeners. Work still running after that goes on in the background and is logged; an error before the budget still fails the card.
+- Operations return an outcome (`completed`, `ok`, `total`, `pending`, `failed`, `skipped`, `superseded`, `budgetExceeded`, `background`, `groups`). `ok` keeps each operation's old boolean, so the Collection reports group errors exactly as before; cards without tokens still return that boolean.
+- `clg_turn_on`, `clg_turn_off` and `clg_toggle` return the tokens `completed` ("All lights confirmed", yes/no) and `status` (text). They are no longer deprecated and are titled "… and report the result" in all 11 languages, with an English and Norwegian hint. The status texts are under `circadian_outcome` in all 11 locales (Norwegian translated, the others English).
+- Collection: `runAwaitedMemberGroups` merges the groups' outcomes (one sentence per group when they ended differently) and reports group failures when every group's background retries have finished. The Collection queue is released when the card's part is done. The old group error message listed `undefined` instead of group names; it now names the groups.
+- Docs: `docs/docs/circadian-light-group.html` (retries after the card, the tokens, the re-enabled cards, a troubleshooting entry) and `PROJECT_DOCUMENTATION.md` (section 7).
+- Companion tool `docs/tools/clg-editor.html`: the config schema did not change, so it needs no update.
+
+### Verification
+- Read-only checks on Lars's New Homey before the change (Homey MCP, API playground in Chrome). The only card run was `clg_apply_now`, which does the same as the 2-minute timer.
+- Jest: 24 suites / 400 tests pass. New tests cover the background retries (late report without a second write, a newer command stopping them, unchanged behaviour without `deferRetries`), a member command staying active until its retries finish, the profile update's reporting after its retries, the time budget (finished in time, past the budget, failing after it, failing before it), the status texts and translation, the Collection merge and its error reporting, and that every card with tokens in its definition returns them.
+- `npm run test:package`: publish-level validation passes; the composed manifest has the tokens on the nine cards and `clg_turn_on`/`clg_turn_off`/`clg_toggle` without `deprecated`.
+- Not yet tested live on the Homey.
+
+### Review (local code review, high effort) and fixes
+- Tokens on existing cards: the first version also gave tokens to `clg_apply_now`, `clg_resume`, `clg_turn_on_member`, `clg_apply_state`, `clg_force_red_mode` and `clg_set_external_lux`. Homey shows THEN cards with tokens only in Advanced Flows (found with `flow_whoami` in 1.10.33), so these cards would have left standard Flows. They are back to their definitions on `main`. Only the three re-enabled on/off cards have tokens, and a driver test checks the cards used in standard Flows.
+- A command that a newer one took over during the final serial retry still reported its result: an alarm, an error trigger and a Collection group error. `runRetries` now checks for a newer command after the last pass as well.
+- The settle check ran only in the background, so the card's tokens could list lights that were only slow to report. The card now checks unconfirmed lights once more after 1.5 s before it returns. A card waits that extra 1.5 s only when a light has not confirmed.
+- Profile cards that arrive while on/off retries run are applied when the retries finish. This is unchanged and intended: the order is kept, and the total delay matches the old in-card retries. It is described in the PR.
+- The time limit for capability listeners is not verified. The On card returns after the first pass, which normally takes seconds, so it stays far below either limit. Not changed.
+- Clean-up: `runMemberCommand` holds the begin/settle/finish steps of the three member commands, and `outcomeWithPendingFailed` builds the fallback outcome once.
+- New tests: a late report confirmed before the card returns, supersession during the settle check, the reduced retry and the final serial retry, the Collection queue starting the next operation while retries run, and the `onoff`/`clg_paused` listeners going through the time budget.
+- Jest: 24 suites / 405 tests pass. `npm run test:package` passes.
+
+### Codex review (PR #72, first commit) and fixes
+- P2: an older Collection operation whose background retries finished after a newer operation had reported could clear the newer operation's alarm. Only the newest Collection operation reports now (`collectionReportGen`).
+- P2: a Collection "Apply now" during a member group's on/off retries returns `skipped: 'deferred'` from that group, and that counted as a failed group, which set the alarm and fired "unresponsive members". A deferred profile update is no longer a failed group (`isFailedGroup`).
+- Two new Collection tests. Jest: 24 suites / 407 tests pass.
+- Second Codex review (`d54c12f`), P2: a member group's profile update that the group's own scheduler or a card run directly on the group took over returns `ok: false, superseded: true`. That counted as a failed group and set the Collection alarm. Before this change, a Collection "Apply now" taken over in the same way was also reported, but with background retries it happens more often. A superseded group outcome is no longer a failed group. Three new tests; Jest: 24 suites / 409 tests pass.
+- Third Codex review (`b298bb7`), P2: `collectionReportGen` went up only after the new operation's first pass had finished. An older operation whose retries finished in that window could still report. The number is now taken when the operation starts, and the first-pass report checks it too. New test; Jest: 24 suites / 410 tests pass.
+- Fourth Codex review (`51a1d04`), P2: pausing the group did not stop turn-on retries that were running in the background, so a retry could turn a light on after the pause. Pausing, through the card or the capability, now takes over the running operation (`stopRunningLightCommands`). Deleting the group does the same, so retries do not keep writing to its lights. Three new tests; Jest: 24 suites / 413 tests pass.
+- Fifth Codex review (`5a1d4b5`), P2: when a profile update had a failure that is already final and also lights still being retried, the first result said `ok: true` and hid that failure, so a Collection "Apply now" no longer reported it as before. The first result's `ok` now counts the failures that are not retried. New test; Jest: 24 suites / 414 tests pass.
+
## 2026-10-05 — Test v1.10.34 released
### Implemented
diff --git a/docs/docs/circadian-light-group.html b/docs/docs/circadian-light-group.html
index 9eb7207..1adc73e 100644
--- a/docs/docs/circadian-light-group.html
+++ b/docs/docs/circadian-light-group.html
@@ -232,7 +232,7 @@
Actions
Then Pause until time — a wall-clock time (HH:MM) in your Homey's time zone.
Then Pause until solar event — pick from the 14 solar events plus an offset.
Then Resume.
- Then Turn on / Turn off / Toggle.
+ Then Turn on / Turn off / Toggle and report the result (Advanced Flow) — the same as the device's own On, Off and Toggle cards, but with the result tokens described below. The device's own cards cannot return tokens.
Then Set red threshold — permanently changes the configured threshold.
Then Apply temporary state — push specific dim, temperature, saturation values (any subset) and an optional "force red" flag. Values are temporary and the next scheduler tick (~2 min) restores calculated values. Useful for testing or quick "moods".
Then Force red mode — on / off / clear, with an optional duration.
@@ -241,7 +241,21 @@ Actions
Then Turn on all members — switch every enabled light in the group on at the current circadian dim/temperature.
Then Turn off all members — switch every enabled light in the group off.
- The multi-device actions and the scheduler push to up to 5 lights in parallel, verify on/off state afterwards, and serially retry transient Z-Wave / Zigbee timeouts. If a conflicting command arrives (e.g. an "all off" right after an "all on"), the new command supersedes the running one — no fighting between flows.
+ The multi-device actions and the scheduler push to up to 5 lights in parallel, verify the result afterwards, and retry lights that did not confirm, for example after a Z-Wave / Zigbee timeout. If a conflicting command arrives (e.g. an "all off" right after an "all on"), the new command supersedes the running one — no fighting between flows.
+
+ Retries after the card, and the result tokens
+ Homey stops an app's Flow card after 60 seconds, and the Flow then stops at that card. A light that does not answer can take up to 10 seconds per attempt, so a group with a few dead lights could come close to that limit. To keep the Flow moving:
+
+ - A card sends every light its commands, checks each light once, and then lets the Flow continue. This usually takes a few seconds.
+ - If a light has not confirmed yet, the card checks once more after 1.5 seconds. Lights that still have not confirmed are retried in the background. A newer command for the same group stops those retries.
+ - If a card is still busy after 50 seconds, the Flow continues anyway and the rest finishes in the background.
+ - Lights that still fail after the retries are reported by the Error occurred trigger, as before.
+
+ The cards Turn on / Turn off / Toggle and report the result give two tokens. Homey shows THEN cards with tokens only in Advanced Flows, so these three cards are Advanced Flow only; the other Circadian cards have no tokens and still work in standard Flows.
+
+ - All lights confirmed (yes/no) — yes when every light had confirmed before the Flow continued, or when nothing had to change (for example because the group is paused). No when the Flow continued while lights were still being retried, or when a light did not respond.
+ - Status (text) — what happened, for example Continued before everything was confirmed. Lights still being retried in the background (1 of 11): Dining room bulb. For a Circadian Light Group Collection the text has one sentence per group when the groups ended differently.
+
@@ -275,6 +289,7 @@
Troubleshooting
- A light isn't included in the picker — only devices with
class === 'light' show up. Some installations classify smart bulbs incorrectly. You can manually add the light by editing the JSON in the device's settings.
- Lights don't change — make sure the group's on/off is on, that clg_paused is false, and that the lights themselves are on. The group only adjusts already-on lights.
+ - "All lights confirmed" is often no — the Status token names the lights that do not answer. A light that is unplugged or out of range slows every command down; untick Enabled for that light in the group's light settings until it works again.
- Polar latitudes — at extreme latitudes, sunrise/sunset may not occur for parts of the year. Each solar anchor has a polar fallback time used when the event doesn't happen today.
- Lux mode doesn't trigger — the anchor only triggers on a true crossing (previous reading on one side of the threshold, current reading on the other). Until the first crossing of the day, the fallback time is used. The first sensor reading after a restart is used as baseline only — no crossing is detected for it.
- Red mode kicks in earlier/later than expected — adjust the red threshold slider. With default profile (evening 0.25, night 0), threshold 0.2 gives red in late night. Raise the threshold to make red appear earlier in the evening.
diff --git a/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_toggle.json b/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_toggle.json
index 0c3650f..17c5920 100644
--- a/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_toggle.json
+++ b/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_toggle.json
@@ -1,18 +1,21 @@
{
"id": "clg_toggle",
- "deprecated": true,
"title": {
- "en": "Toggle",
- "no": "Veksle",
- "de": "Umschalten",
- "nl": "Schakelen",
- "fr": "Basculer",
- "da": "Skift",
- "fi": "Vaihda",
- "sv": "Växla",
- "pl": "Przełącz",
- "it": "Commuta",
- "es": "Alternar"
+ "en": "Toggle and report the result",
+ "no": "Veksle og meld resultatet",
+ "de": "Umschalten und Ergebnis melden",
+ "nl": "Omschakelen en resultaat melden",
+ "fr": "Basculer et signaler le résultat",
+ "da": "Skift og meld resultatet",
+ "fi": "Vaihda ja ilmoita tulos",
+ "sv": "Växla och rapportera resultatet",
+ "pl": "Przełącz i zgłoś wynik",
+ "it": "Commuta e segnala il risultato",
+ "es": "Alternar e informar del resultado"
+ },
+ "hint": {
+ "en": "Turns the group and its lights on or off, like the device's own Toggle card, and returns tokens. The Flow continues once every light has been sent its commands and checked once. Lights that have not confirmed yet are retried in the background. \"All lights confirmed\" and \"Status\" tell you whether that happened.",
+ "no": "Slår gruppen og lysene på eller av, som enhetens eget «Veksle»-kort, og gir tokens. Flowen fortsetter når alle lysene har fått kommandoene og er sjekket én gang. Lys som ikke har bekreftet ennå, prøves på nytt i bakgrunnen. «Alle lys bekreftet» og «Status» forteller om det skjedde."
},
"args": [
{
@@ -20,5 +23,43 @@
"name": "device",
"filter": "driver_id=circadian-light-group|circadian-light-group-collection"
}
+ ],
+ "tokens": [
+ {
+ "name": "completed",
+ "type": "boolean",
+ "title": {
+ "en": "All lights confirmed",
+ "no": "Alle lys bekreftet",
+ "da": "Alle lys bekræftet",
+ "sv": "Alla lampor bekräftade",
+ "de": "Alle Lampen bestätigt",
+ "nl": "Alle lampen bevestigd",
+ "fr": "Toutes les lampes confirmées",
+ "it": "Tutte le luci confermate",
+ "es": "Todas las luces confirmadas",
+ "fi": "Kaikki valot vahvistettu",
+ "pl": "Wszystkie światła potwierdzone"
+ },
+ "example": true
+ },
+ {
+ "name": "status",
+ "type": "string",
+ "title": {
+ "en": "Status",
+ "no": "Status",
+ "da": "Status",
+ "sv": "Status",
+ "de": "Status",
+ "nl": "Status",
+ "fr": "Statut",
+ "it": "Stato",
+ "es": "Estado",
+ "fi": "Tila",
+ "pl": "Status"
+ },
+ "example": "All lights confirmed (11)."
+ }
]
}
diff --git a/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_off.json b/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_off.json
index a81117f..b3b4de6 100644
--- a/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_off.json
+++ b/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_off.json
@@ -1,18 +1,21 @@
{
"id": "clg_turn_off",
- "deprecated": true,
"title": {
- "en": "Turn off",
- "no": "Slå av",
- "de": "Ausschalten",
- "nl": "Uitschakelen",
- "fr": "Désactiver",
- "da": "Sluk",
- "fi": "Sammuta",
- "sv": "Stäng av",
- "pl": "Wyłącz",
- "it": "Spegni",
- "es": "Apagar"
+ "en": "Turn off and report the result",
+ "no": "Slå av og meld resultatet",
+ "de": "Ausschalten und Ergebnis melden",
+ "nl": "Uitschakelen en resultaat melden",
+ "fr": "Éteindre et signaler le résultat",
+ "da": "Sluk og meld resultatet",
+ "fi": "Sammuta ja ilmoita tulos",
+ "sv": "Stäng av och rapportera resultatet",
+ "pl": "Wyłącz i zgłoś wynik",
+ "it": "Spegni e segnala il risultato",
+ "es": "Apagar e informar del resultado"
+ },
+ "hint": {
+ "en": "Turns the group and its lights off, like the device's own Off card, and returns tokens. The Flow continues once every light has been sent its command and checked once. Lights that have not confirmed yet are retried in the background. \"All lights confirmed\" and \"Status\" tell you whether that happened.",
+ "no": "Slår av gruppen og lysene, som enhetens eget «Slå av»-kort, og gir tokens. Flowen fortsetter når alle lysene har fått kommandoen og er sjekket én gang. Lys som ikke har bekreftet ennå, prøves på nytt i bakgrunnen. «Alle lys bekreftet» og «Status» forteller om det skjedde."
},
"args": [
{
@@ -20,5 +23,43 @@
"name": "device",
"filter": "driver_id=circadian-light-group|circadian-light-group-collection"
}
+ ],
+ "tokens": [
+ {
+ "name": "completed",
+ "type": "boolean",
+ "title": {
+ "en": "All lights confirmed",
+ "no": "Alle lys bekreftet",
+ "da": "Alle lys bekræftet",
+ "sv": "Alla lampor bekräftade",
+ "de": "Alle Lampen bestätigt",
+ "nl": "Alle lampen bevestigd",
+ "fr": "Toutes les lampes confirmées",
+ "it": "Tutte le luci confermate",
+ "es": "Todas las luces confirmadas",
+ "fi": "Kaikki valot vahvistettu",
+ "pl": "Wszystkie światła potwierdzone"
+ },
+ "example": true
+ },
+ {
+ "name": "status",
+ "type": "string",
+ "title": {
+ "en": "Status",
+ "no": "Status",
+ "da": "Status",
+ "sv": "Status",
+ "de": "Status",
+ "nl": "Status",
+ "fr": "Statut",
+ "it": "Stato",
+ "es": "Estado",
+ "fi": "Tila",
+ "pl": "Status"
+ },
+ "example": "All lights confirmed (11)."
+ }
]
}
diff --git a/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_on.json b/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_on.json
index 4a32dda..75b502d 100644
--- a/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_on.json
+++ b/no.tiwas.booleantoolbox/.homeycompose/flow/actions/clg_turn_on.json
@@ -1,18 +1,21 @@
{
"id": "clg_turn_on",
- "deprecated": true,
"title": {
- "en": "Turn on",
- "no": "Slå på",
- "de": "Einschalten",
- "nl": "Inschakelen",
- "fr": "Activer",
- "da": "Tænd",
- "fi": "Käynnistä",
- "sv": "Slå på",
- "pl": "Włącz",
- "it": "Accendi",
- "es": "Encender"
+ "en": "Turn on and report the result",
+ "no": "Slå på og meld resultatet",
+ "de": "Einschalten und Ergebnis melden",
+ "nl": "Inschakelen en resultaat melden",
+ "fr": "Allumer et signaler le résultat",
+ "da": "Tænd og meld resultatet",
+ "fi": "Käynnistä ja ilmoita tulos",
+ "sv": "Slå på och rapportera resultatet",
+ "pl": "Włącz i zgłoś wynik",
+ "it": "Accendi e segnala il risultato",
+ "es": "Encender e informar del resultado"
+ },
+ "hint": {
+ "en": "Turns the group and its lights on, like the device's own On card, and returns tokens. The Flow continues once every light has been sent its commands and checked once. Lights that have not confirmed yet are retried in the background. \"All lights confirmed\" and \"Status\" tell you whether that happened.",
+ "no": "Slår på gruppen og lysene, som enhetens eget «Slå på»-kort, og gir tokens. Flowen fortsetter når alle lysene har fått kommandoene og er sjekket én gang. Lys som ikke har bekreftet ennå, prøves på nytt i bakgrunnen. «Alle lys bekreftet» og «Status» forteller om det skjedde."
},
"args": [
{
@@ -20,5 +23,43 @@
"name": "device",
"filter": "driver_id=circadian-light-group|circadian-light-group-collection"
}
+ ],
+ "tokens": [
+ {
+ "name": "completed",
+ "type": "boolean",
+ "title": {
+ "en": "All lights confirmed",
+ "no": "Alle lys bekreftet",
+ "da": "Alle lys bekræftet",
+ "sv": "Alla lampor bekräftade",
+ "de": "Alle Lampen bestätigt",
+ "nl": "Alle lampen bevestigd",
+ "fr": "Toutes les lampes confirmées",
+ "it": "Tutte le luci confermate",
+ "es": "Todas las luces confirmadas",
+ "fi": "Kaikki valot vahvistettu",
+ "pl": "Wszystkie światła potwierdzone"
+ },
+ "example": true
+ },
+ {
+ "name": "status",
+ "type": "string",
+ "title": {
+ "en": "Status",
+ "no": "Status",
+ "da": "Status",
+ "sv": "Status",
+ "de": "Status",
+ "nl": "Status",
+ "fr": "Statut",
+ "it": "Stato",
+ "es": "Estado",
+ "fi": "Tila",
+ "pl": "Status"
+ },
+ "example": "All lights confirmed (11)."
+ }
]
}
diff --git a/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js b/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js
index c07fa80..56556f8 100644
--- a/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js
+++ b/no.tiwas.booleantoolbox/CircadianLightGroupCollectionDevice.test.js
@@ -70,8 +70,8 @@ describe('CircadianLightGroupCollectionDevice scheduling', () => {
expect(settled).toBe(false);
expect(device.applyCurrentProfile).not.toHaveBeenCalled();
- membersFinished.resolve({ ok: [{ item: { id: 'group-1' } }], failed: [] });
- await expect(resume).resolves.toBe(true);
+ membersFinished.resolve({ ok: [{ ok: true, item: { id: 'group-1', name: 'Group 1' } }], failed: [] });
+ await expect(resume).resolves.toEqual(expect.objectContaining({ ok: true, completed: true }));
expect(settled).toBe(true);
});
@@ -186,3 +186,201 @@ describe('CircadianLightGroupCollectionDevice scheduling', () => {
expect(order).toEqual(['failed', 'succeeding']);
});
});
+
+describe('CircadianLightGroupCollectionDevice results after the Flow card', () => {
+ const { createOperationOutcome } = CircadianLightGroupCollectionDevice;
+
+ function createFanOutHarness(groups) {
+ const device = createCollectionHarness();
+ device.currentOpGen = 0;
+ device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
+ device.triggerError = jest.fn().mockResolvedValue(undefined);
+ device.resolveMemberEntries = jest.fn().mockResolvedValue(groups.map(group => ({
+ id: group.id,
+ name: group.name,
+ item: { id: group.id, name: group.name },
+ memberDevice: group.device,
+ })));
+ return device;
+ }
+
+ test('returns after the first pass of every group and reports group failures when the retries finish', async () => {
+ const mainRetries = deferred();
+ const main = {
+ onFlowTurnOn: jest.fn().mockResolvedValue(createOperationOutcome({
+ total: 11,
+ pending: ['Dining room bulb'],
+ background: mainRetries.promise,
+ })),
+ };
+ const bedroom = {
+ onFlowTurnOn: jest.fn().mockResolvedValue(createOperationOutcome({ total: 5 })),
+ };
+ const device = createFanOutHarness([
+ { id: 'main', name: 'Main', device: main },
+ { id: 'bedroom', name: 'Bedroom', device: bedroom },
+ ]);
+
+ const outcome = await device.runAwaitedMemberGroups('turn_on', group => group.onFlowTurnOn());
+
+ expect(outcome).toEqual(expect.objectContaining({
+ completed: false,
+ total: 16,
+ pending: ['Dining room bulb'],
+ }));
+ expect(device.setCapabilityValue).not.toHaveBeenCalled();
+ expect(device.describeOperationOutcome(outcome)).toBe(
+ 'Main: Continued before everything was confirmed. Lights still being retried in the background (1 of 11): Dining room bulb. '
+ + 'Bedroom: All lights confirmed (5).'
+ );
+
+ mainRetries.resolve(createOperationOutcome({ ok: false, total: 11, failed: ['Dining room bulb'] }));
+ const final = await outcome.background;
+
+ expect(final).toEqual(expect.objectContaining({ completed: false, ok: false, failed: ['Dining room bulb'] }));
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', true);
+ expect(device.triggerError).toHaveBeenCalledWith('turn_on: 1 group(s) had unresponsive members: Main');
+ });
+
+ test('starts the next queued operation while the previous one still retries in the background', async () => {
+ const device = createCollectionHarness();
+ const retries = deferred();
+ const started = [];
+ device.setCollectionOnoff = jest.fn().mockResolvedValue(undefined);
+ device.runAwaitedMemberGroups = jest.fn(async (label) => {
+ started.push(label);
+ return label === 'turn_on'
+ ? createOperationOutcome({ total: 11, pending: ['Hall'], background: retries.promise })
+ : createOperationOutcome({ total: 11 });
+ });
+
+ const turnOn = await device.onFlowTurnOn();
+ const turnOff = await device.onFlowTurnOff();
+
+ expect(turnOn).toEqual(expect.objectContaining({ completed: false, pending: ['Hall'] }));
+ expect(turnOff).toEqual(expect.objectContaining({ completed: true }));
+ expect(started).toEqual(['turn_on', 'turn_off']);
+ retries.resolve(createOperationOutcome({ superseded: true }));
+ });
+
+ test('reports at once and sums the lights when no group retries in the background', async () => {
+ const device = createFanOutHarness([
+ { id: 'main', name: 'Main', device: { onFlowTurnOn: jest.fn().mockResolvedValue(createOperationOutcome({ total: 11 })) } },
+ { id: 'bedroom', name: 'Bedroom', device: { onFlowTurnOn: jest.fn().mockResolvedValue(createOperationOutcome({ total: 5 })) } },
+ ]);
+
+ const outcome = await device.runAwaitedMemberGroups('turn_on', group => group.onFlowTurnOn());
+
+ expect(outcome).toEqual(expect.objectContaining({ completed: true, ok: true, total: 16, background: null }));
+ expect(device.describeOperationOutcome(outcome)).toBe('All lights confirmed (16).');
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
+ expect(device.triggerError).not.toHaveBeenCalled();
+ });
+
+ test('an older operation finishing its retries late does not overwrite a newer report', async () => {
+ const oldRetries = deferred();
+ const main = {
+ onFlowTurnOn: jest.fn().mockResolvedValue(createOperationOutcome({
+ total: 11,
+ pending: ['Hall'],
+ background: oldRetries.promise,
+ })),
+ onFlowTurnOff: jest.fn().mockResolvedValue(createOperationOutcome({ ok: false, total: 11, failed: ['Desk'] })),
+ };
+ const device = createFanOutHarness([{ id: 'main', name: 'Main', device: main }]);
+
+ const turnOn = await device.runAwaitedMemberGroups('turn_on', group => group.onFlowTurnOn());
+ await device.runAwaitedMemberGroups('turn_off', group => group.onFlowTurnOff());
+ expect(device.setCapabilityValue).toHaveBeenLastCalledWith('alarm_config', true);
+
+ oldRetries.resolve(createOperationOutcome({ total: 11, superseded: true }));
+ await turnOn.background;
+
+ expect(device.setCapabilityValue).toHaveBeenCalledTimes(1);
+ expect(device.setCapabilityValue).toHaveBeenLastCalledWith('alarm_config', true);
+ expect(device.triggerError).toHaveBeenCalledTimes(1);
+ });
+
+ test('an older operation whose retries finish while a newer one is still running does not report', async () => {
+ const oldRetries = deferred();
+ const newFirstPass = deferred();
+ const main = {
+ onFlowTurnOn: jest.fn().mockResolvedValue(createOperationOutcome({
+ total: 11,
+ pending: ['Hall'],
+ background: oldRetries.promise,
+ })),
+ onFlowTurnOff: jest.fn(() => newFirstPass.promise),
+ };
+ const device = createFanOutHarness([{ id: 'main', name: 'Main', device: main }]);
+
+ const turnOn = await device.runAwaitedMemberGroups('turn_on', group => group.onFlowTurnOn());
+ const turnOff = device.runAwaitedMemberGroups('turn_off', group => group.onFlowTurnOff());
+ await flushAsyncWork();
+
+ oldRetries.resolve(createOperationOutcome({ ok: false, total: 11, failed: ['Hall'] }));
+ await turnOn.background;
+ expect(device.setCapabilityValue).not.toHaveBeenCalled();
+ expect(device.triggerError).not.toHaveBeenCalled();
+
+ newFirstPass.resolve(createOperationOutcome({ total: 11 }));
+ await turnOff;
+ expect(device.setCapabilityValue).toHaveBeenCalledTimes(1);
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
+ });
+
+ test.each([
+ ['postponed', { ok: false, skipped: 'deferred' }],
+ ['taken over by a newer command', { ok: false, total: 11, superseded: true }],
+ ])('a profile update a group %s is not reported as a failed group', async (description, groupOutcome) => {
+ const device = createFanOutHarness([
+ {
+ id: 'main',
+ name: 'Main',
+ device: { applyCurrentProfile: jest.fn().mockResolvedValue(createOperationOutcome(groupOutcome)) },
+ },
+ ]);
+
+ const outcome = await device.runAwaitedMemberGroups('apply_flow', group => group.applyCurrentProfile());
+
+ expect(outcome).toEqual(expect.objectContaining({ ok: true, completed: false }));
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
+ expect(device.triggerError).not.toHaveBeenCalled();
+ });
+
+ test('a group whose background retries are taken over is not reported once they finish', async () => {
+ const retries = deferred();
+ const device = createFanOutHarness([
+ {
+ id: 'main',
+ name: 'Main',
+ device: {
+ applyCurrentProfile: jest.fn().mockResolvedValue(createOperationOutcome({
+ total: 11,
+ pending: ['Hall'],
+ background: retries.promise,
+ })),
+ },
+ },
+ ]);
+
+ const outcome = await device.runAwaitedMemberGroups('apply_flow', group => group.applyCurrentProfile());
+ retries.resolve(createOperationOutcome({ ok: false, total: 11, superseded: true }));
+ await outcome.background;
+
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
+ expect(device.triggerError).not.toHaveBeenCalled();
+ });
+
+ test('names a group that could not run in the error message', async () => {
+ const device = createFanOutHarness([
+ { id: 'main', name: 'Main', device: null },
+ ]);
+
+ const outcome = await device.runAwaitedMemberGroups('turn_off', group => group.onFlowTurnOff());
+
+ expect(outcome).toEqual(expect.objectContaining({ completed: false, ok: false }));
+ expect(device.describeOperationOutcome(outcome)).toBe('Main: did not finish.');
+ expect(device.triggerError).toHaveBeenCalledWith('turn_off: 1 group(s) had unresponsive members: Main');
+ });
+});
diff --git a/no.tiwas.booleantoolbox/CircadianLightGroupDevice.test.js b/no.tiwas.booleantoolbox/CircadianLightGroupDevice.test.js
index c0ed240..1e0a869 100644
--- a/no.tiwas.booleantoolbox/CircadianLightGroupDevice.test.js
+++ b/no.tiwas.booleantoolbox/CircadianLightGroupDevice.test.js
@@ -454,7 +454,11 @@ describe('CircadianLightGroupDevice light application', () => {
device._applyCurrentProfileImpl = jest.fn().mockResolvedValue(true);
const command = device.beginMemberCommand('turn_on_all_members');
- await expect(device.applyCurrentProfile({ reason: 'timer' })).resolves.toBe(false);
+ await expect(device.applyCurrentProfile({ reason: 'timer' })).resolves.toEqual(expect.objectContaining({
+ ok: false,
+ completed: false,
+ skipped: 'deferred',
+ }));
expect(device.currentOpGen).toBe(command.gen);
expect(device._applyCurrentProfileImpl).not.toHaveBeenCalled();
@@ -586,7 +590,11 @@ describe('CircadianLightGroupDevice light application', () => {
device.computeCurrentTarget = jest.fn();
device.runDeviceTasksParallel = jest.fn();
- await expect(device.onFlowTurnOnAllMembers()).resolves.toBe(true);
+ await expect(device.onFlowTurnOnAllMembers()).resolves.toEqual(expect.objectContaining({
+ ok: true,
+ completed: true,
+ skipped: 'paused',
+ }));
expect(device.computeCurrentTarget).not.toHaveBeenCalled();
expect(device.runDeviceTasksParallel).not.toHaveBeenCalled();
@@ -629,7 +637,12 @@ describe('CircadianLightGroupDevice light application', () => {
device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
device.triggerError = jest.fn().mockResolvedValue(undefined);
- await expect(device.onFlowTurnOnAllMembers()).resolves.toBe(true);
+ await expect(device.onFlowTurnOnAllMembers()).resolves.toEqual(expect.objectContaining({
+ ok: true,
+ completed: true,
+ total: 1,
+ background: null,
+ }));
expect(device.runDeviceTasksParallel).toHaveBeenCalledWith(
members,
@@ -637,6 +650,7 @@ describe('CircadianLightGroupDevice light application', () => {
expect.objectContaining({
label: 'turn_on_all_members',
verifyFn: expect.any(Function),
+ deferRetries: true,
})
);
expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
@@ -661,7 +675,11 @@ describe('CircadianLightGroupDevice light application', () => {
device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
device.triggerError = jest.fn().mockResolvedValue(undefined);
- await expect(device.onFlowTurnOffAllMembers()).resolves.toBe(false);
+ await expect(device.onFlowTurnOffAllMembers()).resolves.toEqual(expect.objectContaining({
+ ok: false,
+ completed: false,
+ failed: ['Kitchen'],
+ }));
expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', true);
expect(device.triggerError).toHaveBeenCalledWith(
@@ -913,3 +931,434 @@ describe('CircadianLightGroupDevice capability watcher cleanup', () => {
expect(instance.destroy).toHaveBeenCalledTimes(1);
});
});
+
+function deferredPromise() {
+ let resolve;
+ let reject;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+}
+
+function createTaskHarness() {
+ const device = createDeviceHarness();
+ device.error = jest.fn();
+ return device;
+}
+
+describe('CircadianLightGroupDevice retries after the Flow card', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('returns after the first pass and retries unconfirmed lights in the background', async () => {
+ jest.useFakeTimers();
+ const device = createTaskHarness();
+ const items = [{ id: 'light-1', name: 'Kitchen' }, { id: 'light-2', name: 'Hall' }];
+ const confirmed = new Set();
+ const taskFn = jest.fn(async (item, attempt) => {
+ if (item.id === 'light-2' && attempt === 0) throw new Error('Timeout after 10000ms');
+ confirmed.add(item.id);
+ });
+ const verifyFn = jest.fn(async item => confirmed.has(item.id));
+
+ const pass = device.runDeviceTasksParallel(items, taskFn, {
+ label: 'turn_on_all_members',
+ verifyFn,
+ deferRetries: true,
+ });
+ await jest.advanceTimersByTimeAsync(1500);
+ const result = await pass;
+
+ expect(result.ok.map(res => res.item.id)).toEqual(['light-1']);
+ expect(result.pending.map(res => res.item.id)).toEqual(['light-2']);
+ expect(result.failed).toEqual([]);
+ expect(result.background).toBeInstanceOf(Promise);
+
+ const final = await result.background;
+
+ expect(final.ok.map(res => res.item.id).sort()).toEqual(['light-1', 'light-2']);
+ expect(final.failed).toEqual([]);
+ expect(final.superseded).toBe(false);
+ expect(taskFn).toHaveBeenCalledWith(items[1], 1);
+ });
+
+ test('a light that reports late is confirmed before the card returns, without another write', async () => {
+ jest.useFakeTimers();
+ const device = createTaskHarness();
+ const item = { id: 'light-1', name: 'Kitchen' };
+ const taskFn = jest.fn().mockResolvedValue(undefined);
+ const verifyFn = jest.fn().mockResolvedValueOnce(false).mockResolvedValue(true);
+
+ const pass = device.runDeviceTasksParallel([item], taskFn, { verifyFn, deferRetries: true });
+ await jest.advanceTimersByTimeAsync(1500);
+ const result = await pass;
+
+ expect(result.ok.map(res => res.item.id)).toEqual(['light-1']);
+ expect(result.failed).toEqual([]);
+ expect(result.background).toBeUndefined();
+ expect(verifyFn).toHaveBeenCalledTimes(2);
+ expect(taskFn).toHaveBeenCalledTimes(1);
+ });
+
+ test('a newer command during the settle check ends the command without retries', async () => {
+ jest.useFakeTimers();
+ const device = createTaskHarness();
+ let current = true;
+ const taskFn = jest.fn().mockRejectedValue(new Error('Timeout after 10000ms'));
+
+ const pass = device.runDeviceTasksParallel([{ id: 'light-1', name: 'Kitchen' }], taskFn, {
+ verifyFn: jest.fn().mockResolvedValue(false),
+ isCurrent: () => current,
+ deferRetries: true,
+ });
+ current = false;
+ await jest.advanceTimersByTimeAsync(1500);
+ const result = await pass;
+
+ expect(result.superseded).toBe(true);
+ expect(result.background).toBeUndefined();
+ expect(taskFn).toHaveBeenCalledTimes(1);
+ });
+
+ test.each([
+ [1, 'during the reduced retry'],
+ [2, 'during the final serial retry'],
+ ])('a newer command arriving on attempt %i stops the background retries (%s)', async (supersedeOnAttempt) => {
+ jest.useFakeTimers();
+ const device = createTaskHarness();
+ let current = true;
+ const taskFn = jest.fn(async (item, attempt) => {
+ if (attempt === supersedeOnAttempt) current = false;
+ throw new Error('Timeout after 10000ms');
+ });
+
+ const pass = device.runDeviceTasksParallel([{ id: 'light-1', name: 'Kitchen' }], taskFn, {
+ verifyFn: jest.fn().mockResolvedValue(false),
+ isCurrent: () => current,
+ deferRetries: true,
+ });
+ await jest.advanceTimersByTimeAsync(1500);
+ const result = await pass;
+ const final = await result.background;
+
+ expect(final.superseded).toBe(true);
+ expect(taskFn).toHaveBeenCalledTimes(supersedeOnAttempt + 1);
+ });
+
+ test('keeps retrying inside the call when retries are not deferred', async () => {
+ const device = createTaskHarness();
+ const taskFn = jest.fn()
+ .mockRejectedValueOnce(new Error('Timeout after 10000ms'))
+ .mockResolvedValue(undefined);
+
+ const result = await device.runDeviceTasksParallel([{ id: 'light-1', name: 'Kitchen' }], taskFn, {});
+
+ expect(result.background).toBeUndefined();
+ expect(result.ok.map(res => res.item.id)).toEqual(['light-1']);
+ expect(taskFn).toHaveBeenCalledTimes(2);
+ });
+
+ test('keeps the member command active and reports verification once background retries finish', async () => {
+ const device = createDeviceHarness();
+ const member = { id: 'light-1', name: 'Kitchen' };
+ const background = deferredPromise();
+
+ device.currentOpGen = 0;
+ device.getCapabilityValue = jest.fn(capability => capability === 'onoff');
+ device.getConfig = jest.fn(() => ({ devices: [member] }));
+ device.computeCurrentTarget = jest.fn().mockResolvedValue({ mode: 'temperature', temperature: 0.3, dim: 0.5 });
+ device.runDeviceTasksParallel = jest.fn().mockResolvedValue({
+ ok: [],
+ failed: [],
+ pending: [{ item: member, ok: false, retryable: true }],
+ superseded: false,
+ background: background.promise,
+ });
+ device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
+ device.triggerError = jest.fn().mockResolvedValue(undefined);
+ device._applyCurrentProfileImpl = jest.fn();
+
+ const outcome = await device.onFlowTurnOnAllMembers();
+
+ expect(outcome).toEqual(expect.objectContaining({ completed: false, pending: ['Kitchen'], total: 1 }));
+ expect(device.activeMemberCommand).toEqual(expect.objectContaining({ label: 'turn_on_all_members' }));
+ expect(device.setCapabilityValue).not.toHaveBeenCalled();
+ await expect(device.applyCurrentProfile({ reason: 'timer' })).resolves.toEqual(
+ expect.objectContaining({ skipped: 'deferred' })
+ );
+
+ background.resolve({ ok: [{ ok: true, item: member }], failed: [], superseded: false });
+ const final = await outcome.background;
+
+ expect(final).toEqual(expect.objectContaining({ completed: true, ok: true, total: 1 }));
+ expect(device.activeMemberCommand).toBeNull();
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
+ expect(device.triggerError).not.toHaveBeenCalled();
+ await Promise.resolve();
+ expect(device._applyCurrentProfileImpl).toHaveBeenCalledWith('deferred-timer', expect.anything());
+ });
+
+ test('fires the target trigger and error reporting for a profile update after its background retries', async () => {
+ const device = createDeviceHarness();
+ const member = { id: 'light-1', name: 'Kitchen' };
+ const background = deferredPromise();
+ const targetChanged = { trigger: jest.fn().mockResolvedValue(undefined) };
+
+ device.error = jest.fn();
+ device.previousPhase = null;
+ device.previousRedMode = null;
+ device.getConfig = jest.fn(() => ({ profile: {}, devices: [member] }));
+ device.outdoorProvider = {
+ getOutdoorLight: jest.fn().mockResolvedValue({ outdoorComputedLux: 100, source: 'test' }),
+ };
+ device.getGeo = jest.fn(() => ({}));
+ device.getTimeZone = jest.fn(() => 'Europe/Oslo');
+ device.getStoreValue = jest.fn().mockResolvedValue({});
+ device.getCapabilityValue = jest.fn(capability => capability === 'onoff');
+ device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
+ device.triggerError = jest.fn().mockResolvedValue(undefined);
+ device.homey = { flow: { getDeviceTriggerCard: jest.fn(() => targetChanged) } };
+ device.runDeviceTasksParallel = jest.fn().mockResolvedValue({
+ ok: [],
+ failed: [],
+ pending: [{ item: member, ok: false, retryable: true, error: new Error('Timeout after 10000ms') }],
+ superseded: false,
+ background: background.promise,
+ });
+
+ const outcome = await device._applyCurrentProfileImpl('flow', { isCurrent: () => true });
+
+ expect(outcome).toEqual(expect.objectContaining({ completed: false, pending: ['Kitchen'] }));
+ expect(device.homey.flow.getDeviceTriggerCard).not.toHaveBeenCalledWith('clg_target_changed');
+ expect(device.setCapabilityValue).not.toHaveBeenCalledWith('alarm_config', expect.anything());
+
+ background.resolve({ ok: [{ ok: true, item: member }], failed: [], superseded: false });
+ const final = await outcome.background;
+
+ expect(final).toEqual(expect.objectContaining({ completed: true, ok: true }));
+ expect(device.homey.flow.getDeviceTriggerCard).toHaveBeenCalledWith('clg_target_changed');
+ expect(targetChanged.trigger).toHaveBeenCalledTimes(1);
+ expect(device.setCapabilityValue).toHaveBeenCalledWith('alarm_config', false);
+ });
+
+ test.each([
+ ['the Pause card', device => device.onFlowPause({ amount: 0, unit: 'minutes' })],
+ ['the paused capability', device => device.onPausedCapabilityChanged(true)],
+ ])('pausing through %s stops on/off retries still running in the background', async (description, pause) => {
+ const device = createPauseHarness(false);
+ device.currentOpGen = 0;
+ const command = device.beginMemberCommand('turn_on_all_members');
+
+ await pause(device);
+
+ expect(command.isCurrent()).toBe(false);
+ });
+
+ test('deleting the group stops on/off retries still running in the background', async () => {
+ const device = createDeviceHarness();
+ device.currentOpGen = 0;
+ device.teardownLuxWatchers = jest.fn().mockResolvedValue(undefined);
+ device.teardownMemberOnoffWatchers = jest.fn().mockResolvedValue(undefined);
+ const command = device.beginMemberCommand('turn_on_all_members');
+
+ await device.onDeleted();
+
+ expect(command.isCurrent()).toBe(false);
+ });
+
+ test('a profile update reports a failure that is already final while other lights are retried', async () => {
+ const device = createDeviceHarness();
+ const broken = { id: 'light-1', name: 'Kitchen' };
+ const slow = { id: 'light-2', name: 'Hall' };
+ const background = deferredPromise();
+
+ device.error = jest.fn();
+ device.previousPhase = null;
+ device.previousRedMode = null;
+ device.getConfig = jest.fn(() => ({ profile: {}, devices: [broken, slow] }));
+ device.outdoorProvider = {
+ getOutdoorLight: jest.fn().mockResolvedValue({ outdoorComputedLux: 100, source: 'test' }),
+ };
+ device.getGeo = jest.fn(() => ({}));
+ device.getTimeZone = jest.fn(() => 'Europe/Oslo');
+ device.getStoreValue = jest.fn().mockResolvedValue({});
+ device.getCapabilityValue = jest.fn(capability => capability === 'onoff');
+ device.setCapabilityValue = jest.fn().mockResolvedValue(undefined);
+ device.homey = { flow: { getDeviceTriggerCard: jest.fn(() => ({ trigger: jest.fn().mockResolvedValue(undefined) })) } };
+ device.runDeviceTasksParallel = jest.fn().mockResolvedValue({
+ ok: [],
+ failed: [{ item: broken, ok: false, retryable: false, error: new Error('Capability not setable') }],
+ pending: [{ item: slow, ok: false, retryable: true, error: new Error('Timeout after 10000ms') }],
+ superseded: false,
+ background: background.promise,
+ });
+
+ const outcome = await device._applyCurrentProfileImpl('flow', { isCurrent: () => true });
+
+ expect(outcome).toEqual(expect.objectContaining({
+ ok: false,
+ completed: false,
+ failed: ['Kitchen'],
+ pending: ['Hall'],
+ }));
+ background.resolve({ ok: [], failed: [], superseded: true });
+ await outcome.background;
+ });
+
+ test('resume still counts as successful when the group is off', async () => {
+ const device = createPauseHarness(true);
+ device.applyCurrentProfile = jest.fn().mockResolvedValue(
+ CircadianLightGroupDevice.createOperationOutcome({ ok: false, total: 3, skipped: 'off' })
+ );
+ device.pauseDebug = jest.fn();
+ device.clearPersistedPauseState = jest.fn().mockResolvedValue(undefined);
+
+ const outcome = await device.onFlowResume();
+
+ expect(outcome).toEqual(expect.objectContaining({ ok: true, completed: true, skipped: 'off' }));
+ });
+});
+
+describe('CircadianLightGroupDevice Flow card time budget', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('the onoff and clg_paused capability listeners run within the time budget', async () => {
+ const device = createDeviceHarness();
+ const listeners = {};
+ device.homey = {};
+ device.registerCapabilityListener = jest.fn((capability, listener) => {
+ listeners[capability] = listener;
+ });
+ ['ensureDefaultCapabilityValues', 'restorePersistedOnoffState', 'restorePersistedPauseState',
+ 'setupLuxWatchers', 'setupMemberOnoffWatchers', 'startScheduler', 'setOnoffState', 'fireOnoffTrigger']
+ .forEach((method) => {
+ device[method] = jest.fn().mockResolvedValue(undefined);
+ });
+ device.onFlowTurnOnAllMembers = jest.fn().mockResolvedValue(CircadianLightGroupDevice.createOperationOutcome({ total: 2 }));
+ device.onFlowTurnOffAllMembers = jest.fn().mockResolvedValue(CircadianLightGroupDevice.createOperationOutcome({ total: 2 }));
+ device.onPausedCapabilityChanged = jest.fn().mockResolvedValue(undefined);
+ const budget = jest.spyOn(device, 'runWithinCardTimeBudget');
+
+ await device.onInit();
+ await listeners.onoff(true);
+ await listeners.onoff(false);
+ await listeners.clg_paused(true);
+
+ expect(budget).toHaveBeenNthCalledWith(1, 'onoff', expect.any(Promise));
+ expect(budget).toHaveBeenNthCalledWith(2, 'onoff', expect.any(Promise));
+ expect(budget).toHaveBeenNthCalledWith(3, 'clg_paused', expect.any(Promise));
+ expect(device.setOnoffState).toHaveBeenCalledWith(true);
+ expect(device.onFlowTurnOnAllMembers).toHaveBeenCalledTimes(1);
+ expect(device.onFlowTurnOffAllMembers).toHaveBeenCalledTimes(1);
+ expect(device.onPausedCapabilityChanged).toHaveBeenCalledWith(true);
+ });
+
+ test('returns the result when the work finishes within the budget', async () => {
+ const device = createTaskHarness();
+ const outcome = CircadianLightGroupDevice.createOperationOutcome({ total: 2 });
+
+ await expect(device.runWithinCardTimeBudget('clg_turn_on', Promise.resolve(outcome))).resolves.toBe(outcome);
+ });
+
+ test('lets the Flow continue after the budget while the work goes on', async () => {
+ jest.useFakeTimers();
+ const device = createTaskHarness();
+ const work = deferredPromise();
+ device.recordAppDiagnostic = jest.fn();
+
+ const result = device.runWithinCardTimeBudget('clg_turn_on', work.promise);
+ await jest.advanceTimersByTimeAsync(CircadianLightGroupDevice.CARD_TIME_BUDGET_MS);
+ const outcome = await result;
+
+ expect(outcome).toEqual(expect.objectContaining({ completed: false, budgetExceeded: true }));
+ expect(device.recordAppDiagnostic).toHaveBeenCalledWith('WARN', expect.any(String));
+
+ const finalBackground = Promise.resolve(CircadianLightGroupDevice.createOperationOutcome({ total: 4 }));
+ work.resolve(CircadianLightGroupDevice.createOperationOutcome({ total: 4, pending: ['Hall'], background: finalBackground }));
+ await expect(outcome.background).resolves.toEqual(expect.objectContaining({ completed: true, total: 4 }));
+ });
+
+ test('logs work that fails after the Flow continued instead of leaving it unhandled', async () => {
+ jest.useFakeTimers();
+ const device = createTaskHarness();
+ const work = deferredPromise();
+ device.recordAppDiagnostic = jest.fn();
+
+ const result = device.runWithinCardTimeBudget('clg_turn_on', work.promise);
+ await jest.advanceTimersByTimeAsync(CircadianLightGroupDevice.CARD_TIME_BUDGET_MS);
+ const outcome = await result;
+ work.reject(new Error('boom'));
+
+ await expect(outcome.background).resolves.toEqual(expect.objectContaining({ ok: false }));
+ expect(device.error).toHaveBeenCalledWith('clg_turn_on failed after the Flow had continued:', expect.any(Error));
+ });
+
+ test('still fails the card when the work fails within the budget', async () => {
+ const device = createTaskHarness();
+
+ await expect(device.runWithinCardTimeBudget('clg_turn_on_member', Promise.reject(new Error('No light selected'))))
+ .rejects.toThrow('No light selected');
+ });
+});
+
+describe('CircadianLightGroupDevice card tokens', () => {
+ const { createOperationOutcome } = CircadianLightGroupDevice;
+
+ test.each([
+ [createOperationOutcome({ total: 11 }), true, 'All lights confirmed (11).'],
+ [
+ createOperationOutcome({ total: 11, pending: ['Hall', 'Desk'] }),
+ false,
+ 'Continued before everything was confirmed. Lights still being retried in the background (2 of 11): Hall, Desk.',
+ ],
+ [
+ createOperationOutcome({ ok: false, total: 5, failed: ['Bedside'] }),
+ false,
+ 'Lights that did not respond (1 of 5): Bedside.',
+ ],
+ [createOperationOutcome({ total: 5, skipped: 'paused' }), true, 'The group is paused, so the lights were not changed.'],
+ [createOperationOutcome({ ok: false, total: 5, skipped: 'off' }), true, 'The group is off, so the lights were not changed.'],
+ [
+ createOperationOutcome({ ok: false, skipped: 'deferred' }),
+ false,
+ 'An on/off command is still running. The profile is applied when it has finished.',
+ ],
+ [createOperationOutcome({ superseded: true }), false, 'A newer command took over before this one had finished.'],
+ [
+ createOperationOutcome({ budgetExceeded: true }),
+ false,
+ 'Still running after 50 seconds. The Flow continued and the rest goes on in the background.',
+ ],
+ [true, true, 'Done.'],
+ ])('describes %j', (outcome, completed, status) => {
+ const device = createDeviceHarness();
+
+ expect(device.toCardTokens(outcome)).toEqual({ completed, status });
+ });
+
+ test('uses the translated text and fills in its values', () => {
+ const device = createDeviceHarness();
+ device.homey = {
+ __: jest.fn(key => (key === 'circadian_outcome.pending'
+ ? 'Gikk videre før alt var bekreftet ({count} av {total}): {names}.'
+ : key)),
+ };
+
+ expect(device.toCardTokens(createOperationOutcome({ total: 3, pending: ['Hall'] }))).toEqual({
+ completed: false,
+ status: 'Gikk videre før alt var bekreftet (1 av 3): Hall.',
+ });
+ });
+
+ test('cards without tokens keep the boolean they returned before', () => {
+ const device = createDeviceHarness();
+
+ expect(device.toLegacyCardResult(createOperationOutcome({ ok: false, failed: ['Hall'] }))).toBe(false);
+ expect(device.toLegacyCardResult(true)).toBe(true);
+ });
+});
diff --git a/no.tiwas.booleantoolbox/CircadianLightGroupDriver.test.js b/no.tiwas.booleantoolbox/CircadianLightGroupDriver.test.js
index 553d069..e475108 100644
--- a/no.tiwas.booleantoolbox/CircadianLightGroupDriver.test.js
+++ b/no.tiwas.booleantoolbox/CircadianLightGroupDriver.test.js
@@ -1,10 +1,86 @@
jest.mock('homey', () => ({
Driver: class {},
+ Device: class {},
}), { virtual: true });
jest.mock('suncalc', () => ({}), { virtual: true });
+const fs = require('node:fs');
+const path = require('node:path');
const CircadianLightGroupDriver = require('./drivers/circadian-light-group/driver');
+const CircadianLightGroupDevice = require('./drivers/circadian-light-group/device');
+
+function registerCards() {
+ const listeners = {};
+ const card = id => ({
+ registerRunListener: jest.fn((listener) => {
+ listeners[id] = listener;
+ }),
+ registerArgumentAutocompleteListener: jest.fn(),
+ });
+ const driver = Object.create(CircadianLightGroupDriver.prototype);
+ driver.homey = { flow: { getActionCard: jest.fn(card), getConditionCard: jest.fn(card) } };
+ driver.registerFlowCards();
+ return { driver, listeners };
+}
+
+function readActionCard(id) {
+ const file = path.join(__dirname, '.homeycompose', 'flow', 'actions', `${id}.json`);
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
+}
+
+describe('CircadianLightGroupDriver Flow card results', () => {
+ test('every action card that declares tokens returns them, and the others keep their old result', async () => {
+ const { driver, listeners } = registerCards();
+ const actionIds = driver.homey.flow.getActionCard.mock.calls.map(([id]) => id);
+ const outcome = CircadianLightGroupDevice.createOperationOutcome({ total: 3, pending: ['Hall'] });
+
+ for (const id of actionIds) {
+ const definition = readActionCard(id);
+ const device = Object.create(CircadianLightGroupDevice.prototype);
+ device.debug = jest.fn();
+ const handler = jest.fn().mockResolvedValue(outcome);
+ const result = await listeners[id]({
+ device: new Proxy(device, {
+ get: (target, property) => (typeof property === 'string' && property.startsWith('onFlow') ? handler : target[property]),
+ }),
+ });
+
+ if (Array.isArray(definition.tokens)) {
+ expect(definition.tokens.map(token => token.name)).toEqual(['completed', 'status']);
+ expect(definition.deprecated).toBeUndefined();
+ expect(result).toEqual({
+ completed: false,
+ status: 'Continued before everything was confirmed. Lights still being retried in the background (1 of 3): Hall.',
+ });
+ } else {
+ expect(result).toBe(true);
+ }
+ }
+
+ expect(actionIds).toEqual(expect.arrayContaining(['clg_turn_on', 'clg_turn_off', 'clg_toggle', 'clg_resume']));
+ ['clg_turn_on', 'clg_turn_off', 'clg_toggle'].forEach((id) => {
+ expect(readActionCard(id).tokens).toBeDefined();
+ });
+ });
+
+ test('cards that can be in standard Flows get no tokens, because Homey hides THEN cards with tokens there', () => {
+ [
+ 'clg_apply_now',
+ 'clg_resume',
+ 'clg_turn_on_member',
+ 'clg_apply_state',
+ 'clg_force_red_mode',
+ 'clg_set_external_lux',
+ 'clg_pause',
+ 'clg_pause_until_time',
+ 'clg_pause_until_solar',
+ 'clg_set_red_threshold',
+ ].forEach((id) => {
+ expect({ id, tokens: readActionCard(id).tokens }).toEqual({ id, tokens: undefined });
+ });
+ });
+});
describe('CircadianLightGroupDriver probe cleanup', () => {
afterEach(() => {
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 61dcfa1..8fe45f5 100644
--- a/no.tiwas.booleantoolbox/drivers/circadian-light-group-collection/device.js
+++ b/no.tiwas.booleantoolbox/drivers/circadian-light-group-collection/device.js
@@ -2,6 +2,8 @@
const CircadianLightGroupDevice = require('../circadian-light-group/device');
+const { createOperationOutcome, toOperationOutcome } = CircadianLightGroupDevice;
+
const COLLECTION_OPERATION_BATCH_MS = 50;
const COLLECTION_OPERATION_PRIORITY = Object.freeze({
pause: 0,
@@ -111,13 +113,52 @@ class CircadianLightGroupCollectionDevice extends CircadianLightGroupDevice {
isCurrent: op.isCurrent,
});
- const failed = result.failed || [];
- await this.setCapabilityValue('alarm_config', failed.length > 0).catch(this.error);
- if (failed.length > 0) {
- const names = failed.map(e => e.name || e.id).join(', ');
- await this.triggerError(`${label}: ${failed.length} group(s) had unresponsive members: ${names}`);
+ // Group failures are reported by runAwaitedMemberGroups once every
+ // member group, including its background retries, has finished.
+ return { ...result, entries };
+ }
+
+ // A group counts as failed when it threw or its own result was not ok. A
+ // profile update the group postponed until its on/off command has finished
+ // is still to come, and one a newer command took over was replaced on
+ // purpose, so neither is a failure.
+ isFailedGroup(group) {
+ return group.failed
+ || (group.outcome?.ok === false
+ && group.outcome.skipped !== 'deferred'
+ && group.outcome.superseded !== true);
+ }
+
+ // Combines the member groups' outcomes into one outcome for the Collection.
+ mergeGroupOutcomes(groups, { superseded = false, background = null } = {}) {
+ const active = groups.filter(group => !group.failed && group.outcome);
+ const changed = active.filter(group => !group.outcome.skipped);
+ const skipReasons = [...new Set(active.map(group => group.outcome.skipped))];
+ const failedGroups = groups.filter(group => this.isFailedGroup(group));
+ return createOperationOutcome({
+ ok: !superseded && failedGroups.length === 0,
+ total: changed.reduce((sum, group) => sum + group.outcome.total, 0),
+ pending: active.flatMap(group => group.outcome.pending),
+ failed: active.flatMap(group => group.outcome.failed),
+ skipped: active.length === groups.length && active.length > 0 && changed.length === 0 && skipReasons.length === 1
+ ? skipReasons[0]
+ : null,
+ superseded: superseded || active.some(group => group.outcome.superseded),
+ budgetExceeded: active.some(group => group.outcome.budgetExceeded),
+ background,
+ groups,
+ completed: groups.every(group => !group.failed && group.outcome && group.outcome.completed),
+ });
+ }
+
+ async reportCollectionGroupFailures(label, outcome) {
+ const names = (outcome.groups || [])
+ .filter(group => this.isFailedGroup(group))
+ .map(group => group.name);
+ await this.setCapabilityValue('alarm_config', names.length > 0).catch(this.error);
+ if (names.length > 0) {
+ await this.triggerError(`${label}: ${names.length} group(s) had unresponsive members: ${names.join(', ')}`);
}
- return result;
}
getCollectionOperationPriority(label) {
@@ -181,18 +222,65 @@ class CircadianLightGroupCollectionDevice extends CircadianLightGroupDevice {
return operation;
}
+ // Runs taskFn on every member group and returns once each group has done its
+ // first pass. Retries a group runs in the background are followed by the
+ // Collection's own `background`, which reports group failures at the end.
async runAwaitedMemberGroups(label, taskFn) {
+ // Only the newest Collection operation reports. Taking the number before
+ // the fan-out stops an older operation whose retries finish while this
+ // one is still running from overwriting the alarm or error.
+ this.collectionReportGen = (this.collectionReportGen || 0) + 1;
+ const reportGen = this.collectionReportGen;
+ const isNewest = () => this.collectionReportGen === reportGen;
+
+ const outcomes = new Map();
const result = await this.runForMemberGroups(label, async (device, item, attempt) => {
- const completed = await taskFn(device, item, attempt);
- if (completed === false) {
- throw new Error(`${item?.name || item?.id || 'Circadian Light Group'} reported an incomplete operation`);
- }
+ outcomes.set(item, toOperationOutcome(await taskFn(device, item, attempt)));
});
- return result?.superseded !== true && (result?.failed || []).length === 0;
+
+ const runs = [...(result?.ok || []), ...(result?.failed || [])];
+ const entries = Array.isArray(result?.entries) ? result.entries : runs.map(res => res.item);
+ const groups = entries.map((entry) => {
+ const run = runs.find(candidate => candidate.item === entry);
+ const succeeded = run?.ok === true;
+ return {
+ name: entry?.name || entry?.id || '',
+ outcome: succeeded ? (outcomes.get(entry?.item) || createOperationOutcome()) : null,
+ failed: !succeeded,
+ };
+ });
+ const superseded = result?.superseded === true;
+
+ if (!groups.some(group => group.outcome?.background)) {
+ const outcome = this.mergeGroupOutcomes(groups, { superseded });
+ if (isNewest()) await this.reportCollectionGroupFailures(label, outcome);
+ return outcome;
+ }
+
+ const background = Promise.all(groups.map(async (group) => {
+ if (!group.outcome?.background) return group;
+ const final = await group.outcome.background
+ .catch(() => createOperationOutcome({ ok: false }));
+ return { ...group, outcome: toOperationOutcome(final) };
+ }))
+ .then(async (finalGroups) => {
+ const final = this.mergeGroupOutcomes(finalGroups, { superseded });
+ if (isNewest()) {
+ await this.reportCollectionGroupFailures(label, final);
+ } else {
+ this.debug(`collection_${label}: a newer operation has reported; skipping this one's report`);
+ }
+ return final;
+ })
+ .catch((error) => {
+ this.error(`collection_${label}: background follow-up failed:`, error);
+ return createOperationOutcome({ ok: false });
+ });
+ return this.mergeGroupOutcomes(groups, { superseded, background });
}
async applyCurrentProfile({ reason = 'manual' } = {}) {
- if (this.deleted) return false;
+ if (this.deleted) return createOperationOutcome({ ok: false, skipped: 'deleted' });
return this.runCollectionOperation(`apply_${reason}`, async () => this.runAwaitedMemberGroups(`apply_${reason}`, async (device) => {
return device.applyCurrentProfile({ reason: `collection-${reason}` });
}));
diff --git a/no.tiwas.booleantoolbox/drivers/circadian-light-group/device.js b/no.tiwas.booleantoolbox/drivers/circadian-light-group/device.js
index 389df66..6dbaf3a 100644
--- a/no.tiwas.booleantoolbox/drivers/circadian-light-group/device.js
+++ b/no.tiwas.booleantoolbox/drivers/circadian-light-group/device.js
@@ -19,6 +19,29 @@ const CLG_ONOFF_STORE_KEY = 'clg_onoff_state';
const CLG_ONOFF_LEGACY_STORE_KEY = 'clg_onoff';
const CLG_PAUSE_STORE_KEY = 'clg_pause_state';
const MAX_TIMEOUT_MS = 2147483647;
+// Homey stops app Flow cards after 60 s. A card returns after this budget and
+// the rest of its work goes on in the background.
+const CARD_TIME_BUDGET_MS = 50000;
+// Lights can report a new value a little after the write; unconfirmed lights
+// are checked again after this pause before they count as needing a retry.
+const SETTLE_CHECK_MS = 1500;
+const CARD_BUDGET_ELAPSED = Symbol('card-budget-elapsed');
+
+const OUTCOME_TEXT = {
+ confirmed: 'All lights confirmed ({total}).',
+ done: 'Done.',
+ pending: 'Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.',
+ failed: 'Lights that did not respond ({count} of {total}): {names}.',
+ not_confirmed: 'Not every light could be confirmed.',
+ paused: 'The group is paused, so the lights were not changed.',
+ off: 'The group is off, so the lights were not changed.',
+ no_lights: 'The group has no enabled lights.',
+ deferred: 'An on/off command is still running. The profile is applied when it has finished.',
+ superseded: 'A newer command took over before this one had finished.',
+ budget: 'Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.',
+ deleted: 'The group has been deleted.',
+ group_failed: 'did not finish.',
+};
const TRANSIENT_ERROR_PATTERNS = [
/TRANSMIT_COMPLETE_NO_ACK/i,
@@ -53,6 +76,94 @@ function capabilityValueMatches(actual, expected) {
return actual === expected;
}
+function taskItemName(item) {
+ return item?.name || item?.id || '';
+}
+
+// What a light operation did, for Flow card tokens and for a Collection.
+// `ok` keeps the boolean each operation returned before outcomes existed, so
+// Collection error reporting is unchanged. `background`, when set, resolves to
+// the final outcome once the retries running after the card have finished.
+function createOperationOutcome({
+ ok = true,
+ total = 0,
+ pending = [],
+ failed = [],
+ skipped = null,
+ superseded = false,
+ budgetExceeded = false,
+ background = null,
+ groups = null,
+ completed,
+} = {}) {
+ // A combined outcome passes `completed` itself; otherwise a result that is
+ // not ok only counts as complete when nothing was meant to change.
+ const settled = typeof completed === 'boolean'
+ ? completed
+ : (ok !== false || skipped !== null);
+ return {
+ completed: settled
+ && !superseded
+ && !budgetExceeded
+ && skipped !== 'deferred'
+ && pending.length === 0
+ && failed.length === 0,
+ ok,
+ total,
+ pending,
+ failed,
+ skipped,
+ superseded,
+ budgetExceeded,
+ background,
+ groups,
+ };
+}
+
+function isOperationOutcome(value) {
+ return Boolean(value)
+ && typeof value === 'object'
+ && typeof value.completed === 'boolean'
+ && Array.isArray(value.pending);
+}
+
+function toOperationOutcome(value) {
+ if (isOperationOutcome(value)) return value;
+ return createOperationOutcome({ ok: value !== false });
+}
+
+function outcomeFromTaskResult(result, { ok, background = null } = {}) {
+ const confirmed = Array.isArray(result?.ok) ? result.ok : [];
+ const failed = Array.isArray(result?.failed) ? result.failed : [];
+ const pending = Array.isArray(result?.pending) ? result.pending : [];
+ return createOperationOutcome({
+ ok,
+ total: confirmed.length + failed.length + pending.length,
+ pending: pending.map(res => taskItemName(res.item)),
+ failed: failed.map(res => taskItemName(res.item)),
+ superseded: result?.superseded === true,
+ background,
+ });
+}
+
+// The outcome to report when the background retries themselves fail: every
+// light that was still pending counts as failed.
+function outcomeWithPendingFailed(result) {
+ return outcomeFromTaskResult({
+ ...result,
+ failed: [...(result.failed || []), ...(result.pending || [])],
+ pending: [],
+ }, { ok: false });
+}
+
+// Copies an outcome with a fixed `ok`, also for its background result.
+function withLegacyOk(outcome, ok) {
+ const background = outcome.background
+ ? outcome.background.then(final => withLegacyOk(toOperationOutcome(final), ok))
+ : null;
+ return createOperationOutcome({ ...outcome, ok, background });
+}
+
class CircadianLightGroupDevice extends Homey.Device {
async onInit() {
this.debug('CircadianLightGroupDevice has been initialized');
@@ -76,17 +187,18 @@ class CircadianLightGroupDevice extends Homey.Device {
this.pauseTimer = null;
this.registerCapabilityListener('onoff', async (value) => {
- await this.setOnoffState(value);
- await this.fireOnoffTrigger(value);
- if (value === true) {
- await this.onFlowTurnOnAllMembers();
- } else {
- await this.onFlowTurnOffAllMembers();
- }
+ await this.runWithinCardTimeBudget('onoff', (async () => {
+ await this.setOnoffState(value);
+ await this.fireOnoffTrigger(value);
+ if (value === true) {
+ return this.onFlowTurnOnAllMembers();
+ }
+ return this.onFlowTurnOffAllMembers();
+ })());
});
this.registerCapabilityListener('clg_paused', async (value) => {
- await this.onPausedCapabilityChanged(value);
+ await this.runWithinCardTimeBudget('clg_paused', this.onPausedCapabilityChanged(value));
});
this.registerCapabilityListener('dim', async () => {});
@@ -125,10 +237,116 @@ class CircadianLightGroupDevice extends Homey.Device {
}
}
+ // Lets a Flow card or capability listener return before Homey's 60 s limit.
+ // If the operation is still running when the budget is used up, it goes on
+ // in the background and the caller gets an outcome that says so.
+ async runWithinCardTimeBudget(label, operation) {
+ const work = Promise.resolve(operation);
+ let timer = null;
+ const budgetElapsed = new Promise((resolve) => {
+ timer = setTimeout(() => resolve(CARD_BUDGET_ELAPSED), CARD_TIME_BUDGET_MS);
+ });
+
+ let result;
+ try {
+ result = await Promise.race([work, budgetElapsed]);
+ } finally {
+ clearTimeout(timer);
+ }
+ if (result !== CARD_BUDGET_ELAPSED) return result;
+
+ this.debug(`${label}: still running after ${CARD_TIME_BUDGET_MS / 1000}s; the Flow continues while it finishes in the background`);
+ this.recordAppDiagnostic('WARN', 'A Circadian Light Group Flow card used its time budget; the rest goes on in the background.');
+ const background = work
+ .then(value => toOperationOutcome(value))
+ .then(outcome => (outcome.background ? outcome.background : outcome))
+ .catch((error) => {
+ this.error(`${label} failed after the Flow had continued:`, error);
+ return createOperationOutcome({ ok: false });
+ });
+ return createOperationOutcome({ ok: true, budgetExceeded: true, background });
+ }
+
+ translateOutcomeText(key, values = {}) {
+ let template = OUTCOME_TEXT[key] || '';
+ try {
+ const translationKey = `circadian_outcome.${key}`;
+ const translated = this.homey?.__?.(translationKey);
+ if (typeof translated === 'string' && translated && translated !== translationKey) template = translated;
+ } catch (error) {
+ // Fall back to English below
+ }
+ return template.replace(/\{(\w+)\}/g, (match, name) => (
+ Object.prototype.hasOwnProperty.call(values, name) ? String(values[name]) : match
+ ));
+ }
+
+ describeOperationOutcome(value) {
+ const outcome = toOperationOutcome(value);
+ if (outcome.budgetExceeded) {
+ return this.translateOutcomeText('budget', { seconds: CARD_TIME_BUDGET_MS / 1000 });
+ }
+ // A Collection: one sentence for all groups when every group confirmed its
+ // lights, otherwise one sentence per group.
+ if (Array.isArray(outcome.groups) && outcome.groups.length > 0) {
+ const describeGroup = group => (group.failed
+ ? this.translateOutcomeText('group_failed')
+ : this.describeOperationOutcome(group.outcome));
+ const [onlyGroup] = outcome.groups;
+ if (outcome.groups.length === 1 && !onlyGroup.failed) return describeGroup(onlyGroup);
+ const uniform = outcome.groups.every(group => !group.failed
+ && group.outcome
+ && group.outcome.completed
+ && !group.outcome.skipped);
+ if (!uniform) {
+ return outcome.groups.map(group => `${group.name}: ${describeGroup(group)}`).join(' ');
+ }
+ }
+ if (outcome.superseded) return this.translateOutcomeText('superseded');
+ if (outcome.skipped === 'no-lights') return this.translateOutcomeText('no_lights');
+ if (outcome.skipped) return this.translateOutcomeText(outcome.skipped);
+
+ const parts = [];
+ const total = outcome.total;
+ if (outcome.pending.length > 0) {
+ parts.push(this.translateOutcomeText('pending', {
+ count: outcome.pending.length,
+ total,
+ names: outcome.pending.join(', '),
+ }));
+ }
+ if (outcome.failed.length > 0) {
+ parts.push(this.translateOutcomeText('failed', {
+ count: outcome.failed.length,
+ total,
+ names: outcome.failed.join(', '),
+ }));
+ }
+ if (parts.length > 0) return parts.join(' ');
+ if (!outcome.completed) return this.translateOutcomeText('not_confirmed');
+ if (total === 0) return this.translateOutcomeText('done');
+ return this.translateOutcomeText('confirmed', { total });
+ }
+
+ // Return tokens for the Circadian action cards that declare them.
+ toCardTokens(value) {
+ const outcome = toOperationOutcome(value);
+ return {
+ completed: outcome.completed,
+ status: this.describeOperationOutcome(outcome),
+ };
+ }
+
+ // Cards without tokens keep returning what they returned before outcomes.
+ toLegacyCardResult(value) {
+ return isOperationOutcome(value) ? value.ok : value;
+ }
+
async onPausedCapabilityChanged(value) {
this.pauseDebug(`capability changed value=${value}`);
this.clearPauseTimer();
if (value === true) {
+ this.stopRunningLightCommands('pause');
await this.persistPauseState(null);
} else {
await this.clearPersistedPauseState();
@@ -595,18 +813,18 @@ class CircadianLightGroupDevice extends Homey.Device {
}
async applyCurrentProfile({ reason = 'manual' } = {}) {
- if (this.deleted) return false;
+ if (this.deleted) return createOperationOutcome({ ok: false, skipped: 'deleted' });
if (this.activeMemberCommand) {
this.deferredProfileApplyReason = String(reason).replace(/^deferred-/, '');
this.debug(`apply[${reason}] DEFERRED while ${this.activeMemberCommand.label} is active`);
- return false;
+ return createOperationOutcome({ ok: false, skipped: 'deferred' });
}
const op = this.acquireOp(`apply[${reason}]`);
return this._applyCurrentProfileImpl(reason, op);
}
async _applyCurrentProfileImpl(reason, op) {
- if (this.deleted) return false;
+ if (this.deleted) return createOperationOutcome({ ok: false, skipped: 'deleted' });
const config = this.getConfig();
const allDevices = Array.isArray(config.devices) ? config.devices : [];
@@ -661,49 +879,71 @@ class CircadianLightGroupDevice extends Homey.Device {
if (!shouldApplyToLights) {
this.debug(`apply[${reason}] SKIPPED push to lights: onoff=${this.getCapabilityValue('onoff')} clg_paused=${this.getCapabilityValue('clg_paused')}`);
- return false;
+ return createOperationOutcome({
+ ok: false,
+ total: devices.length,
+ skipped: this.getCapabilityValue('clg_paused') === true ? 'paused' : 'off',
+ });
}
if (devices.length === 0) {
this.debug(`apply[${reason}] SKIPPED push to lights: no enabled devices in config`);
await this.setCapabilityValue('alarm_config', true).catch(this.error);
- return false;
+ return createOperationOutcome({ ok: false, skipped: 'no-lights' });
}
await this.requestExternalOutdoorLightIfNeeded(config);
const debugCtx = { remaining: 3, reason, isCurrent: op.isCurrent };
- const { failed, superseded } = await this.runDeviceTasksParallel(devices, async (item) => {
+ const result = await this.runDeviceTasksParallel(devices, async (item) => {
await this.applyTargetToDevice(item, target, debugCtx);
- }, { label: `apply[${reason}]`, isCurrent: op.isCurrent });
+ }, { label: `apply[${reason}]`, isCurrent: op.isCurrent, deferRetries: true });
- if (superseded) return false;
+ const finishApply = async (final) => {
+ if (final.superseded) return outcomeFromTaskResult(final, { ok: false });
- failed.slice(0, 3).forEach((failure) => {
- this.recordAppDiagnostic(
- 'ERROR',
- `Circadian member update failed during ${reason}.`,
- failure.error,
- );
- });
+ const { failed } = final;
+ failed.slice(0, 3).forEach((failure) => {
+ this.recordAppDiagnostic(
+ 'ERROR',
+ `Circadian member update failed during ${reason}.`,
+ failure.error,
+ );
+ });
- const nonTransientFailures = failed.filter(res => !isTransientDeviceError(res.error));
- await this.setCapabilityValue('alarm_config', nonTransientFailures.length > 0).catch(this.error);
+ const nonTransientFailures = failed.filter(res => !isTransientDeviceError(res.error));
+ await this.setCapabilityValue('alarm_config', nonTransientFailures.length > 0).catch(this.error);
- if (nonTransientFailures.length > 0) {
- await this.triggerError(`${nonTransientFailures.length} light(s) failed during ${reason}`);
- }
+ if (nonTransientFailures.length > 0) {
+ await this.triggerError(`${nonTransientFailures.length} light(s) failed during ${reason}`);
+ }
- await this.homey.flow.getDeviceTriggerCard('clg_target_changed')
- .trigger(this, {
- phase: target.phase,
- dim: target.dim,
- temperature: target.temperature,
- outdoor_lux: outdoor.outdoorComputedLux || 0,
- })
- .catch(this.error);
+ await this.homey.flow.getDeviceTriggerCard('clg_target_changed')
+ .trigger(this, {
+ phase: target.phase,
+ dim: target.dim,
+ temperature: target.temperature,
+ outdoor_lux: outdoor.outdoorComputedLux || 0,
+ })
+ .catch(this.error);
+
+ return outcomeFromTaskResult(final, { ok: nonTransientFailures.length === 0 });
+ };
- return nonTransientFailures.length === 0;
+ if (!result.background) return finishApply(result);
+
+ // Retries go on after the caller (often a Flow card) has its answer; the
+ // error reporting and the target-changed trigger follow when they finish.
+ const background = result.background
+ .then(finishApply)
+ .catch((error) => {
+ this.error(`apply[${reason}] background retry failed:`, error);
+ return outcomeWithPendingFailed(result);
+ });
+ // Failures that are not retried are final already; only the pending
+ // lights wait for the background result.
+ const finalFailures = result.failed.filter(res => !isTransientDeviceError(res.error));
+ return outcomeFromTaskResult(result, { ok: finalFailures.length === 0, background });
}
async requestExternalOutdoorLightIfNeeded(config) {
@@ -729,6 +969,13 @@ class CircadianLightGroupDevice extends Homey.Device {
};
}
+ // A newer operation makes every running one stop between writes, including
+ // retries that go on in the background after a card has returned. Pausing
+ // and deleting the group use this so a retry cannot change a light after it.
+ stopRunningLightCommands(label) {
+ this.acquireOp(label);
+ }
+
beginMemberCommand(label) {
const op = this.acquireOp(label);
this.activeMemberCommand = { label, gen: op.gen };
@@ -754,6 +1001,10 @@ class CircadianLightGroupDevice extends Homey.Device {
// Run an async task per device with staged backoff:
// first bounded parallelism, then serial verification, then a smaller parallel retry,
// then a final serial retry for anything still not confirmed.
+ // With `deferRetries`, it returns after the first pass and its verification
+ // (plus one more check of unconfirmed devices after a short pause). Devices
+ // that are still not confirmed are listed in `pending`, and the retries run
+ // in the background: `background` resolves to the final summary.
async runDeviceTasksParallel(items, taskFn, opts = {}) {
const list = Array.isArray(items) ? items : [];
if (list.length === 0) return { ok: [], failed: [] };
@@ -775,9 +1026,10 @@ class CircadianLightGroupDevice extends Homey.Device {
return !res.ok && res.retryable !== false;
});
- const summarize = (superseded = false) => {
+ const summarize = (superseded = false, { separatePending = false } = {}) => {
const ok = [];
const failed = [];
+ const pending = [];
for (let index = 0; index < list.length; index += 1) {
const res = results[index] || {
ok: false,
@@ -785,9 +1037,11 @@ class CircadianLightGroupDevice extends Homey.Device {
error: new Error('not processed'),
retryable: true,
};
- (res.ok ? ok : failed).push(res);
+ if (res.ok) ok.push(res);
+ else if (separatePending && res.retryable !== false) pending.push(res);
+ else failed.push(res);
}
- return { ok, failed, superseded };
+ return separatePending ? { ok, failed, pending, superseded } : { ok, failed, superseded };
};
const runPass = async (indexes, passConcurrency, attempt, passLabel) => {
@@ -864,26 +1118,56 @@ class CircadianLightGroupDevice extends Homey.Device {
return summarize(true);
}
- let pending = pendingIndexes();
- if (pending.length > 0 && maxRetries >= 1) {
- this.debug(`${label}: reduced parallel retry for ${pending.length} device(s), concurrency=${Math.min(retryConcurrency, pending.length)}`);
- await runPass(pending, retryConcurrency, 1, 'reduced parallel retry');
- await verifyIndexesSerial(pending, 'reduced parallel retry');
- }
+ const runRetries = async () => {
+ let pending = pendingIndexes();
+ if (pending.length > 0 && maxRetries >= 1) {
+ this.debug(`${label}: reduced parallel retry for ${pending.length} device(s), concurrency=${Math.min(retryConcurrency, pending.length)}`);
+ await runPass(pending, retryConcurrency, 1, 'reduced parallel retry');
+ await verifyIndexesSerial(pending, 'reduced parallel retry');
+ }
- if (!isCurrent()) {
- this.debug(`${label}: superseded after reduced retry - skipping final serial retry`);
- return summarize(true);
- }
+ if (!isCurrent()) {
+ this.debug(`${label}: superseded after reduced retry - skipping final serial retry`);
+ return summarize(true);
+ }
+
+ pending = pendingIndexes();
+ if (pending.length > 0 && maxRetries >= 2) {
+ this.debug(`${label}: final serial retry for ${pending.length} device(s)`);
+ await runPass(pending, 1, 2, 'final serial retry');
+ await verifyIndexesSerial(pending, 'final serial retry');
+ }
- pending = pendingIndexes();
- if (pending.length > 0 && maxRetries >= 2) {
- this.debug(`${label}: final serial retry for ${pending.length} device(s)`);
- await runPass(pending, 1, 2, 'final serial retry');
- await verifyIndexesSerial(pending, 'final serial retry');
+ // A newer command may have taken over during the last pass; its own
+ // result is the one that counts, so this one reports nothing.
+ if (!isCurrent()) {
+ this.debug(`${label}: superseded during final serial retry`);
+ return summarize(true);
+ }
+
+ return summarize(false);
+ };
+
+ if (!opts.deferRetries) return runRetries();
+
+ if (verifyFn && pendingIndexes().length > 0) {
+ // A light may confirm its new value a moment after the write. Check the
+ // unconfirmed ones once more, so the result only lists lights that need
+ // a retry and a slow report does not cause an extra write.
+ await new Promise(resolve => setTimeout(resolve, SETTLE_CHECK_MS));
+ if (!isCurrent()) {
+ this.debug(`${label}: superseded before the settle check`);
+ return summarize(true);
+ }
+ await verifyIndexesSerial(pendingIndexes(), 'settle check');
}
- return summarize(false);
+ const firstPending = pendingIndexes();
+ if (firstPending.length === 0 || maxRetries < 1) return summarize(false);
+
+ this.debug(`${label}: ${firstPending.length} device(s) not confirmed after the first pass; retrying in the background`);
+ const background = runRetries();
+ return { ...summarize(false, { separatePending: true }), background };
}
async applyTargetToDevice(item, target, debugCtx) {
@@ -1116,6 +1400,42 @@ class CircadianLightGroupDevice extends Homey.Device {
return false;
}
+ // Runs a member on/off command. When `run` returns an outcome with
+ // background retries, settleMemberCommand ends the command after them, so
+ // the scheduler waits until they have finished.
+ async runMemberCommand(label, run) {
+ const op = this.beginMemberCommand(label);
+ let outcome = null;
+ try {
+ outcome = await run(op);
+ return outcome;
+ } finally {
+ if (!outcome?.background) this.finishMemberCommand(op);
+ }
+ }
+
+ // Turns a member command result into an outcome. When retries go on in the
+ // background, the command stays active (so the scheduler waits) until they
+ // finish, and the verification is reported then.
+ async settleMemberCommand(op, label, result, expectedDescription, reportOptions) {
+ if (!result?.background) {
+ const ok = await this.reportMemberTaskVerification(label, result, expectedDescription, reportOptions);
+ return outcomeFromTaskResult(result, { ok });
+ }
+
+ const background = result.background
+ .then(async (final) => {
+ const ok = await this.reportMemberTaskVerification(label, final, expectedDescription, reportOptions);
+ return outcomeFromTaskResult(final, { ok });
+ })
+ .catch((error) => {
+ this.error(`${label}: background retry failed:`, error);
+ return outcomeWithPendingFailed(result);
+ })
+ .finally(() => this.finishMemberCommand(op));
+ return outcomeFromTaskResult(result, { ok: true, background });
+ }
+
applyOverridesToTarget(target) {
const now = Date.now();
@@ -1269,11 +1589,10 @@ class CircadianLightGroupDevice extends Homey.Device {
if (this.getCapabilityValue('clg_paused') === true) {
this.debug(`turn_on_member[${item.name || item.id}]: SKIPPED turn on because clg_paused=true`);
- return true;
+ return createOperationOutcome({ total: 1, skipped: 'paused' });
}
- const op = this.beginMemberCommand('turn_on_member');
- try {
+ return this.runMemberCommand('turn_on_member', async (op) => {
const target = await this.computeCurrentTarget(config);
const verifiesTarget = this.getCapabilityValue('onoff') === true && this.getCapabilityValue('clg_paused') !== true;
const verifyFn = verifiesTarget
@@ -1285,30 +1604,29 @@ class CircadianLightGroupDevice extends Homey.Device {
label: 'turn_on_member',
verifyFn,
isCurrent: op.isCurrent,
+ deferRetries: true,
});
- return await this.reportMemberTaskVerification(
+ return this.settleMemberCommand(
+ op,
`turn_on_member[${item.name || item.id}]`,
result,
verifiesTarget ? 'on and at target after retries' : 'on after retries',
{ clearAlarmOnSuccess: false }
);
- } finally {
- this.finishMemberCommand(op);
- }
+ });
}
async onFlowTurnOnAllMembers() {
const config = this.getConfig();
const members = (Array.isArray(config.devices) ? config.devices : []).filter(d => d.enabled !== false);
- if (members.length === 0) return true;
+ if (members.length === 0) return createOperationOutcome({ skipped: 'no-lights' });
if (this.getCapabilityValue('clg_paused') === true) {
this.debug(`turn_on_all_members: SKIPPED ${members.length} member(s) because clg_paused=true`);
- return true;
+ return createOperationOutcome({ total: members.length, skipped: 'paused' });
}
- const op = this.beginMemberCommand('turn_on_all_members');
- try {
+ return this.runMemberCommand('turn_on_all_members', async (op) => {
const target = await this.computeCurrentTarget(config);
const verifiesTarget = this.getCapabilityValue('onoff') === true && this.getCapabilityValue('clg_paused') !== true;
const verifyFn = verifiesTarget
@@ -1320,35 +1638,33 @@ class CircadianLightGroupDevice extends Homey.Device {
label: 'turn_on_all_members',
verifyFn,
isCurrent: op.isCurrent,
+ deferRetries: true,
});
- return await this.reportMemberTaskVerification(
+ return this.settleMemberCommand(
+ op,
'turn_on_all_members',
result,
verifiesTarget ? 'on and at target after retries' : 'on after retries'
);
- } finally {
- this.finishMemberCommand(op);
- }
+ });
}
async onFlowTurnOffAllMembers() {
const config = this.getConfig();
const members = (Array.isArray(config.devices) ? config.devices : []).filter(d => d.enabled !== false);
- if (members.length === 0) return true;
+ if (members.length === 0) return createOperationOutcome({ skipped: 'no-lights' });
- const op = this.beginMemberCommand('turn_off_all_members');
- try {
+ return this.runMemberCommand('turn_off_all_members', async (op) => {
const result = await this.runDeviceTasksParallel(members, async (item) => {
await this.turnOffMember(item, op.isCurrent);
}, {
label: 'turn_off_all_members',
verifyFn: (item) => this.verifyMemberOnoff(item, false),
isCurrent: op.isCurrent,
+ deferRetries: true,
});
- return await this.reportMemberTaskVerification('turn_off_all_members', result, 'off after retries');
- } finally {
- this.finishMemberCommand(op);
- }
+ return this.settleMemberCommand(op, 'turn_off_all_members', result, 'off after retries');
+ });
}
async computeCurrentTarget(config) {
@@ -1558,8 +1874,8 @@ class CircadianLightGroupDevice extends Homey.Device {
forceRed: args.force_red === true,
expiresAt,
};
- await this.applyCurrentProfile({ reason: 'flow-apply-state' });
- return true;
+ // The card still counts as successful, as before; the outcome adds its tokens.
+ return withLegacyOk(await this.applyCurrentProfile({ reason: 'flow-apply-state' }), true);
}
async onFlowForceRedMode(args) {
@@ -1583,8 +1899,8 @@ class CircadianLightGroupDevice extends Homey.Device {
}, duration * 60000);
}
}
- await this.applyCurrentProfile({ reason: 'flow-force-red' });
- return true;
+ // The card still counts as successful, as before; the outcome adds its tokens.
+ return withLegacyOk(await this.applyCurrentProfile({ reason: 'flow-force-red' }), true);
}
// ---- Flow condition handlers ----
@@ -1612,6 +1928,7 @@ class CircadianLightGroupDevice extends Homey.Device {
const wasPaused = this.getCapabilityValue('clg_paused') === true;
this.pauseDebug(`flow pause args=${this.describePauseArgs(args)} durationMs=${ms} expiresAt=${expiresAt ? new Date(expiresAt).toISOString() : 'manual'} wasPaused=${wasPaused}`);
this.clearPauseTimer();
+ this.stopRunningLightCommands('pause');
await this.setCapabilityValue('clg_paused', true);
await this.persistPauseState(expiresAt);
if (!wasPaused) await this.firePauseTrigger(true);
@@ -1631,8 +1948,8 @@ class CircadianLightGroupDevice extends Homey.Device {
await this.setCapabilityValue('clg_paused', false);
await this.clearPersistedPauseState();
if (wasPaused) await this.firePauseTrigger(false);
- await this.applyCurrentProfile({ reason: 'flow-resume' });
- return true;
+ // The card still counts as successful, as before; the outcome adds its tokens.
+ return withLegacyOk(await this.applyCurrentProfile({ reason: 'flow-resume' }), true);
}
async onFlowSetExternalLux(args) {
@@ -1644,8 +1961,8 @@ class CircadianLightGroupDevice extends Homey.Device {
const value = this.outdoorProvider.setExternalValue(lux, validMinutes, source);
await this.setCapabilityValue('measure_outdoor_lux', value.outdoorComputedLux).catch(this.error);
- await this.applyCurrentProfile({ reason: 'external-lux' });
- return true;
+ // The card still counts as successful, as before; the outcome adds its tokens.
+ return withLegacyOk(await this.applyCurrentProfile({ reason: 'external-lux' }), true);
}
async onSettings({ newSettings, changedKeys }) {
@@ -1665,6 +1982,7 @@ class CircadianLightGroupDevice extends Homey.Device {
async onDeleted() {
this.deleted = true;
+ this.stopRunningLightCommands('deleted');
this.stopScheduler();
this.clearPauseTimer();
await this.teardownLuxWatchers();
@@ -1673,3 +1991,7 @@ class CircadianLightGroupDevice extends Homey.Device {
}
module.exports = CircadianLightGroupDevice;
+module.exports.CARD_TIME_BUDGET_MS = CARD_TIME_BUDGET_MS;
+module.exports.createOperationOutcome = createOperationOutcome;
+module.exports.isOperationOutcome = isOperationOutcome;
+module.exports.toOperationOutcome = toOperationOutcome;
diff --git a/no.tiwas.booleantoolbox/drivers/circadian-light-group/driver.js b/no.tiwas.booleantoolbox/drivers/circadian-light-group/driver.js
index 39e77e2..f496050 100644
--- a/no.tiwas.booleantoolbox/drivers/circadian-light-group/driver.js
+++ b/no.tiwas.booleantoolbox/drivers/circadian-light-group/driver.js
@@ -158,7 +158,19 @@ class CircadianLightGroupDriver extends Homey.Driver {
}
registerFlowCards() {
- const action = (id, fn) => this.homey.flow.getActionCard(id).registerRunListener(async (args) => args.device[fn](args));
+ // Cards that declare the `completed` and `status` tokens. Homey shows THEN
+ // cards with tokens only in Advanced Flows, so cards that are used in
+ // standard Flows must not get tokens.
+ const tokenCards = new Set([
+ 'clg_turn_on',
+ 'clg_turn_off',
+ 'clg_toggle',
+ ]);
+ const action = (id, fn) => this.homey.flow.getActionCard(id).registerRunListener(async (args) => {
+ const device = args.device;
+ const result = await device.runWithinCardTimeBudget(id, device[fn](args));
+ return tokenCards.has(id) ? device.toCardTokens(result) : device.toLegacyCardResult(result);
+ });
const cond = (id, fn) => this.homey.flow.getConditionCard(id).registerRunListener(async (args) => args.device[fn](args));
action('clg_apply_now', 'onFlowApplyNow');
diff --git a/no.tiwas.booleantoolbox/locales/da.json b/no.tiwas.booleantoolbox/locales/da.json
index 2a35480..69d1ee4 100644
--- a/no.tiwas.booleantoolbox/locales/da.json
+++ b/no.tiwas.booleantoolbox/locales/da.json
@@ -725,5 +725,20 @@
"not_found": "Kunne ikke finde dette kort i et gemt flow. Gem flowet, og kør det igen.",
"copied": "Dette kort er kopieret, så samme id findes i flere flows. Åbn kortet i kopien, og vælg \"dette flow\" igen. Flows:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/de.json b/no.tiwas.booleantoolbox/locales/de.json
index a86bcf6..2ad019f 100644
--- a/no.tiwas.booleantoolbox/locales/de.json
+++ b/no.tiwas.booleantoolbox/locales/de.json
@@ -726,5 +726,20 @@
"not_found": "Diese Karte wurde in keinem gespeicherten Flow gefunden. Speichere den Flow und führe ihn erneut aus.",
"copied": "Diese Karte wurde kopiert, daher steht dieselbe ID in mehreren Flows. Öffne die Karte in der Kopie und wähle erneut „diesem Flow“. Flows:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/en.json b/no.tiwas.booleantoolbox/locales/en.json
index 96a9dc8..0318cc0 100644
--- a/no.tiwas.booleantoolbox/locales/en.json
+++ b/no.tiwas.booleantoolbox/locales/en.json
@@ -760,5 +760,20 @@
"not_found": "Could not find this card in a saved Flow. Save the Flow and run it again.",
"copied": "This card has been copied, so the same id is in several Flows. Open the card in the copy and pick \"this Flow\" again. Flows:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/es.json b/no.tiwas.booleantoolbox/locales/es.json
index deb4cb8..7182db5 100644
--- a/no.tiwas.booleantoolbox/locales/es.json
+++ b/no.tiwas.booleantoolbox/locales/es.json
@@ -712,5 +712,20 @@
"not_found": "No se encontró esta tarjeta en un Flow guardado. Guarda el Flow y vuelve a ejecutarlo.",
"copied": "Esta tarjeta se ha copiado, así que el mismo ID está en varios Flows. Abre la tarjeta en la copia y vuelve a elegir \"este Flow\". Flows:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/fi.json b/no.tiwas.booleantoolbox/locales/fi.json
index 6f6d661..a72ffb9 100644
--- a/no.tiwas.booleantoolbox/locales/fi.json
+++ b/no.tiwas.booleantoolbox/locales/fi.json
@@ -712,5 +712,20 @@
"not_found": "Tätä korttia ei löytynyt tallennetusta Flow'sta. Tallenna Flow ja suorita se uudelleen.",
"copied": "Tämä kortti on kopioitu, joten sama tunnus on useassa Flow'ssa. Avaa kortti kopiossa ja valitse uudelleen \"tämän Flown\". Flow't:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/fr.json b/no.tiwas.booleantoolbox/locales/fr.json
index cb77525..7c95fd8 100644
--- a/no.tiwas.booleantoolbox/locales/fr.json
+++ b/no.tiwas.booleantoolbox/locales/fr.json
@@ -712,5 +712,20 @@
"not_found": "Cette carte est introuvable dans un Flow enregistré. Enregistrez le Flow et relancez-le.",
"copied": "Cette carte a été copiée : le même ID se trouve dans plusieurs Flows. Ouvrez la carte dans la copie et choisissez à nouveau « ce Flow ». Flows :"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/it.json b/no.tiwas.booleantoolbox/locales/it.json
index 0578a4b..7aae572 100644
--- a/no.tiwas.booleantoolbox/locales/it.json
+++ b/no.tiwas.booleantoolbox/locales/it.json
@@ -712,5 +712,20 @@
"not_found": "Questa scheda non è stata trovata in un Flow salvato. Salva il Flow ed eseguilo di nuovo.",
"copied": "Questa scheda è stata copiata, quindi lo stesso ID è in più Flow. Apri la scheda nella copia e scegli di nuovo \"questo Flow\". Flow:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/nl.json b/no.tiwas.booleantoolbox/locales/nl.json
index 82cff36..0e1bd55 100644
--- a/no.tiwas.booleantoolbox/locales/nl.json
+++ b/no.tiwas.booleantoolbox/locales/nl.json
@@ -712,5 +712,20 @@
"not_found": "Deze kaart is niet gevonden in een opgeslagen Flow. Sla de Flow op en voer hem opnieuw uit.",
"copied": "Deze kaart is gekopieerd, dus dezelfde ID staat in meerdere Flows. Open de kaart in de kopie en kies opnieuw \"deze Flow\". Flows:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/no.json b/no.tiwas.booleantoolbox/locales/no.json
index bb1baf9..acb69ae 100644
--- a/no.tiwas.booleantoolbox/locales/no.json
+++ b/no.tiwas.booleantoolbox/locales/no.json
@@ -761,5 +761,20 @@
"not_found": "Fant ikke dette kortet i en lagret flow. Lagre flowen og kjør den på nytt.",
"copied": "Dette kortet er kopiert, så samme id finnes i flere flows. Åpne kortet i kopien og velg «denne flowen» på nytt. Flows:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "Alle lys bekreftet ({total}).",
+ "done": "Ferdig.",
+ "pending": "Gikk videre før alt var bekreftet. Lys som fortsatt prøves på nytt i bakgrunnen ({count} av {total}): {names}.",
+ "failed": "Lys som ikke svarte ({count} av {total}): {names}.",
+ "not_confirmed": "Ikke alle lys kunne bekreftes.",
+ "paused": "Gruppen er satt på pause, så lysene ble ikke endret.",
+ "off": "Gruppen er av, så lysene ble ikke endret.",
+ "no_lights": "Gruppen har ingen aktive lys.",
+ "deferred": "En av/på-kommando pågår fortsatt. Profilen brukes når den er ferdig.",
+ "superseded": "En nyere kommando tok over før denne var ferdig.",
+ "budget": "Pågår fortsatt etter {seconds} sekunder. Flowen gikk videre, og resten fortsetter i bakgrunnen.",
+ "deleted": "Gruppen er slettet.",
+ "group_failed": "ble ikke ferdig."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/pl.json b/no.tiwas.booleantoolbox/locales/pl.json
index 9781ef3..15701e3 100644
--- a/no.tiwas.booleantoolbox/locales/pl.json
+++ b/no.tiwas.booleantoolbox/locales/pl.json
@@ -712,5 +712,20 @@
"not_found": "Nie znaleziono tej karty w zapisanym Flow. Zapisz Flow i uruchom go ponownie.",
"copied": "Ta karta została skopiowana, więc to samo ID jest w kilku Flow. Otwórz kartę w kopii i ponownie wybierz „tego Flow”. Flow:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}
diff --git a/no.tiwas.booleantoolbox/locales/sv.json b/no.tiwas.booleantoolbox/locales/sv.json
index df33e62..11ac08b 100644
--- a/no.tiwas.booleantoolbox/locales/sv.json
+++ b/no.tiwas.booleantoolbox/locales/sv.json
@@ -712,5 +712,20 @@
"not_found": "Hittade inte det här kortet i ett sparat flöde. Spara flödet och kör det igen.",
"copied": "Det här kortet har kopierats, så samma id finns i flera flöden. Öppna kortet i kopian och välj \"detta flöde\" igen. Flöden:"
}
+ },
+ "circadian_outcome": {
+ "confirmed": "All lights confirmed ({total}).",
+ "done": "Done.",
+ "pending": "Continued before everything was confirmed. Lights still being retried in the background ({count} of {total}): {names}.",
+ "failed": "Lights that did not respond ({count} of {total}): {names}.",
+ "not_confirmed": "Not every light could be confirmed.",
+ "paused": "The group is paused, so the lights were not changed.",
+ "off": "The group is off, so the lights were not changed.",
+ "no_lights": "The group has no enabled lights.",
+ "deferred": "An on/off command is still running. The profile is applied when it has finished.",
+ "superseded": "A newer command took over before this one had finished.",
+ "budget": "Still running after {seconds} seconds. The Flow continued and the rest goes on in the background.",
+ "deleted": "The group has been deleted.",
+ "group_failed": "did not finish."
}
}