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