From 5f12aea5c0e213a8b1458f108a2eb65f7fa2b42b Mon Sep 17 00:00:00 2001 From: Vincent Maarschalkerweerd Date: Mon, 1 Jun 2026 19:52:36 +0200 Subject: [PATCH 1/3] Add per-person presence (Vincent/Carleen/both away) Introduce separate per-person away tracking and derive the house-wide away boolean from it. - New PresenceScenario enum and PresenceManager app: sets input_boolean.awayvincent/awaycarleen from person.* + auto-away, derives away = both away, and runs per-scenario actions. - Vincent-only departure notification (no notification for Carleen) and 09:00 Carleen wake-up scheduling moved into PresenceManager. - VincentModel/CarleenModel.IsHome now follow the per-person away booleans instead of the shared away boolean. - AwayManager slimmed down to the welcome-home state machine. - IsNightMode fix: add IsVincentNightMode so the living room stays active when Vincent is awake but Carleen is sleeping. - Hall switch button 1 now toggles "everybody away". - SleepManager clears per-person away when going to sleep. - Remove dead WaterSensorDouche subscription (entity no longer exists). - Tests for PresenceManager away-derivation/notification and living room night-mode behaviour. Co-Authored-By: Claude Opus 4.8 --- PLAN.md | 129 ++++++++++++ .../Apps/General/PresenceManagerTests.cs | 75 +++++++ .../Apps/Rooms/LivingRoomLightsTests.cs | 52 +++++ automation/Enum/PresenceScenario.cs | 29 +++ automation/HomeAssistantGenerated.cs | 99 +++++---- automation/Models/Persons/CarleenModel.cs | 2 +- automation/Models/Persons/VincentModel.cs | 2 +- automation/apps/BaseApp.cs | 8 + automation/apps/General/AwayManager.cs | 159 ++------------- automation/apps/General/PresenceManager.cs | 192 ++++++++++++++++++ automation/apps/General/SleepManager.cs | 5 +- .../apps/Rooms/BathRoom/BathRoomLights.cs | 5 - .../apps/Rooms/Hall/HallLightOnMovement.cs | 16 +- .../apps/Rooms/LivingRoom/LivingRoomLights.cs | 4 +- 14 files changed, 590 insertions(+), 187 deletions(-) create mode 100644 PLAN.md create mode 100644 TestAutomation/Apps/General/PresenceManagerTests.cs create mode 100644 TestAutomation/Apps/Rooms/LivingRoomLightsTests.cs create mode 100644 automation/Enum/PresenceScenario.cs create mode 100644 automation/apps/General/PresenceManager.cs diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..60838f6 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,129 @@ +# Plan: Per-persoon aanwezigheid (Vincent weg / Carleen weg / beide weg) + +## Doel +Aparte aanwezigheidssensoren voor Vincent en Carleen introduceren, zodat het systeem +onderscheid maakt tussen drie scenario's — **Vincent weg**, **Carleen weg** en **beide weg** — +en per scenario passende acties uitvoert. + +## Vastgelegde beslissingen +- **Sensoren:** twee HA-helpers, handmatig door Vincent aangemaakt: + `input_boolean.vincent_away` en `input_boolean.carleen_away`. +- **`input_boolean.away` = beide weg (afgeleid):** `away = vincent_away && carleen_away`. + Bestaande code die op `away` leunt blijft werken. +- **Acties verschillen per scenario.** +- **Notificaties:** wél een melding wanneer **Vincent** weggaat; **geen** melding voor Carleen. +- **Hue-knop in de hal:** betekent **iedereen weg** (forceert beide weg). +- **`IsNightMode`-fix wordt in deze klus meegenomen** (woonkamer niet in nightmode louter + omdat Carleen slaapt). + +--- + +## Huidige situatie (waarom dit nodig is) +- Er is nu maar één presence-begrip: `input_boolean.away`. +- `Vincent.IsHome` is afgeleid van `away` (`automation/Models/Persons/VincentModel.cs:20`), + dus "Vincent" en "het huis" zijn nu hetzelfde concept. +- `Carleen.IsHome` komt los van `person.carleen` (`automation/Models/Persons/CarleenModel.cs:20`). +- `away` wordt verspreid handmatig aan/uit gezet: + - `automation/apps/General/AwayManager.cs` (`VincentHomeHandler`, `AutoAway`, `ExecuteAwayActions`) + - `automation/apps/Rooms/Hall/HallLightOnMovement.cs:123-126` + - `automation/apps/General/SleepManager.cs:123` +- `IsNightMode` (`automation/apps/BaseApp.cs:53`): + `Vincent.IsSleeping || (Carleen.IsHome && Carleen.IsSleeping)` — zet de woonkamer in + nachtmodus zodra Carleen slaapt, ook als Vincent wakker en actief is. + +--- + +## Stap 1 — HA helpers (door Vincent) +Aanmaken in Home Assistant: +- `input_boolean.vincent_away` +- `input_boolean.carleen_away` + +Daarna `dotnet tool run nd-codegen` draaien zodat ze in `HomeAssistantGenerated.cs` verschijnen. + +## Stap 2 — Centrale presence-enum +Nieuw bestand `automation/Enum/PresenceScenario.cs`: +```csharp +public enum PresenceScenario { BothHome, VincentAwayOnly, CarleenAwayOnly, BothAway } +``` +Maakt de actiematrix expliciet en testbaar, los van de `away`-boolean. + +## Stap 3 — PersonModels ontkoppelen van `away` +- **`VincentModel.cs`** — `IsHome` afleiden van `input_boolean.vincent_away` (geïnverteerd), + niet meer van `away`. +- **`CarleenModel.cs`** — `IsHome` afleiden van `input_boolean.carleen_away` (geïnverteerd). + +> Gedragswijziging: alle bestaande `Vincent.IsHome`-gebruikers gaan op de nieuwe bron werken. +> Na te lopen: `Alarm.cs:48`, `HallLightOnMovement`, `BathRoomLights.cs:224`, `BaseApp` (IsNightMode). + +## Stap 4 — Nieuwe `PresenceManager` app +Nieuw: `automation/apps/General/PresenceManager.cs` (inherit `BaseApp`). +Verantwoordelijk voor het **zetten** van de per-persoon-booleans en het afleiden van `away`: + +1. **Per-persoon away zetten** op basis van `person.*` + de auto-away distance-logica + (verplaatst vanuit `AwayManager.AutoAway`): + - `person.vincent → home` ⇒ `vincent_away` uit; weg + `away_from` ⇒ aan. + - idem voor Carleen (`person.carleen` / `device_tracker.carleen_mobiel`). +2. **`away` afleiden** (de "beide weg"-regel, op één centrale plek): + ``` + away = vincent_away && carleen_away + ``` +3. **Scenario bepalen** → `PresenceScenario`; bij wijziging de bijbehorende actie triggeren (stap 5). + +## Stap 5 — Actiematrix per scenario + +| Scenario | Acties | +|---|---| +| **Beide thuis** | Normale automatisering; niets uitzetten. | +| **Vincent weg, Carleen thuis** | Geen lampen/tv uit. **Melding naar Vincent** ("Tot ziens" / op kantoordag vóór 09:00 "Werkse Vincent"). Carleen stuurt woonkamer-/nightmode-logica. | +| **Carleen weg, Vincent thuis** | Geen lampen/tv uit. **Geen melding.** `IsNightMode` valt terug op alleen Vincent. | +| **Beide weg** | Echte away-acties: `Light.TurnAllOff()`, tv + soundbar uit, alarm-condities. (Melding is al verstuurd op het moment dat Vincent als laatste wegging.) | + +De huidige inhoud van `ExecuteAwayActions` (`AwayManager.cs:187-210`) verhuist hiernaartoe: +- De Vincent-vertrekmelding hangt aan de overgang **naar `vincent_away`**, niet aan `away`. +- De `Light.TurnAllOff()` / tv / soundbar hangen aan de overgang **naar `BothAway`**. + +## Stap 6 — `AwayManager` afslanken +Behoudt de **welcome-home state machine** (Returning → WelcomingHome → Home), maar: +- `AutoAway` en `VincentHomeHandler` (het zetten van `away`) verhuizen naar `PresenceManager`. +- `ExecuteAwayActions`-inhoud verhuist naar de scenario-matrix. +- De Carleen-slaap-onderdrukking (`AwayManager.cs:189-195`) + `ScheduleCarleenWakeUp` blijven, + maar haken op het nieuwe presence-model. + +## Stap 7 — `IsNightMode`-fix (woonkamer) +`BaseApp.cs:53` opsplitsen zodat de woonkamer niet in nachtmodus gaat puur omdat Carleen slaapt. +Voorstel: +- `IsNightMode` (huisbreed/persoonlijk) blijft `Vincent.IsSleeping || (Carleen.IsHome && Carleen.IsSleeping)`. +- Nieuwe property voor ruimtes waar alleen Vincents slaap telt, bv. + `protected bool IsVincentNightMode => Vincent.IsSleeping;` +- `LivingRoomLights.cs:34,40` gaat `IsVincentNightMode` gebruiken i.p.v. `IsNightMode`, + zodat de woonkamer blijft reageren als Vincent wakker is terwijl Carleen slaapt. +- Slaapkamer-nabije ruimtes (hal, badkamer) houden het bestaande gedrag — die hebben al + een office-day-uitzondering (`HallLightOnMovement`, `BathRoomLights`). + +## Stap 8 — Losse plekken bijwerken +- **`HallLightOnMovement.cs:123-126`** (Hue-knop): ombouwen naar **iedereen weg** — + zet zowel `vincent_away` als `carleen_away` aan (i.p.v. direct `away`). Tweede druk / + thuiskomst zet ze weer uit. +- **`SleepManager.cs:123`** (`away` uit bij wakker worden): controleren/aanpassen zodat dit + via de per-persoon-booleans loopt. + +## Stap 9 — Tests +- `TestAutomation/Apps/General/PresenceManagerTests.cs` (nieuw): tabel-test over de vier + scenario's, de `away`-afleiding en de Vincent-vertrekmelding. +- Bestaande `AwayManager`-tests bijwerken voor de afgeslankte verantwoordelijkheid. +- `VincentModel`/`CarleenModel`-tests: `IsHome` volgt nu de nieuwe booleans. +- Test voor `IsVincentNightMode` + woonkamergedrag (Vincent wakker / Carleen slaapt). + +--- + +## Volgorde van uitvoeren +1. Vincent: helpers in HA + `nd-codegen`. +2. Enum + PersonModels ontkoppelen. +3. `PresenceManager` (zetten + afleiden + matrix). +4. `AwayManager` afslanken. +5. Losse plekken (Hall-knop, Sleep) + `IsNightMode`-fix. +6. Tests. + +## Blokkade +Stap 2+ kunnen pas zodra de HA-helpers bestaan en `nd-codegen` is gedraaid +(`Entities.InputBoolean.VincentAway` / `CarleenAway` moeten gegenereerd zijn). diff --git a/TestAutomation/Apps/General/PresenceManagerTests.cs b/TestAutomation/Apps/General/PresenceManagerTests.cs new file mode 100644 index 0000000..23733e1 --- /dev/null +++ b/TestAutomation/Apps/General/PresenceManagerTests.cs @@ -0,0 +1,75 @@ +using Automation.apps.General; +using NSubstitute; +using TestAutomation.Helpers; +using Xunit; + +namespace TestAutomation.Apps.General; + +public class PresenceManagerTests +{ + private static AppTestContext Arrange(string awayVincent, string awayCarleen, string away) + { + var ctx = AppTestContext.NewWithScheduler(); + ctx.HaContext.GetState("input_boolean.awayvincent").Returns( + new NetDaemon.HassModel.Entities.EntityState { EntityId = "input_boolean.awayvincent", State = awayVincent }); + ctx.HaContext.GetState("input_boolean.awaycarleen").Returns( + new NetDaemon.HassModel.Entities.EntityState { EntityId = "input_boolean.awaycarleen", State = awayCarleen }); + ctx.HaContext.GetState("input_boolean.away").Returns( + new NetDaemon.HassModel.Entities.EntityState { EntityId = "input_boolean.away", State = away }); + ctx.HaContext.GetState("input_boolean.holliday").Returns( + new NetDaemon.HassModel.Entities.EntityState { EntityId = "input_boolean.holliday", State = "off" }); + ctx.InitApp(); + return ctx; + } + + [Fact] + public void Away_TurnsOn_WhenBothPeopleAreAway() + { + // Carleen already away, Vincent now leaves too → both away → derived away on + var ctx = Arrange(awayVincent: "off", awayCarleen: "on", away: "off"); + + ctx.ChangeStateFor("input_boolean.awayvincent").FromState("off").ToState("on"); + ctx.HaContextMock.ProcessPendingOperations(); + + ctx.VerifyCallService("input_boolean", "turn_on", "away"); + } + + [Fact] + public void Away_StaysOff_WhenOnlyVincentIsAway() + { + // Carleen still home, Vincent leaves → not both away → away must not turn on + var ctx = Arrange(awayVincent: "off", awayCarleen: "off", away: "off"); + + ctx.ChangeStateFor("input_boolean.awayvincent").FromState("off").ToState("on"); + ctx.HaContextMock.ProcessPendingOperations(); + + ctx.VerifyNotCallService("input_boolean.turn_on"); + } + + [Fact] + public void Away_TurnsOff_WhenSomeoneComesHome() + { + // Both were away, Carleen comes home → away should turn off + var ctx = Arrange(awayVincent: "on", awayCarleen: "on", away: "on"); + + // Carleen comes home: her away boolean is cleared, then the derivation runs + ctx.HaContext.GetState("input_boolean.awaycarleen").Returns( + new NetDaemon.HassModel.Entities.EntityState { EntityId = "input_boolean.awaycarleen", State = "off" }); + ctx.ChangeStateFor("input_boolean.awaycarleen").FromState("on").ToState("off"); + ctx.HaContextMock.ProcessPendingOperations(); + + ctx.VerifyCallService("input_boolean", "turn_off", "away"); + } + + [Fact] + public void VincentLeaving_SendsDepartureNotification() + { + var ctx = Arrange(awayVincent: "off", awayCarleen: "off", away: "off"); + + ctx.ChangeStateFor("input_boolean.awayvincent").FromState("off").ToState("on"); + ctx.HaContextMock.ProcessPendingOperations(); + + // Notify.NotifyPhoneVincent ends up calling notify.mobile_app_vincent_phone + ctx.VerifyCallNotify("notify", "mobile_app_vincent_phone"); + } +} diff --git a/TestAutomation/Apps/Rooms/LivingRoomLightsTests.cs b/TestAutomation/Apps/Rooms/LivingRoomLightsTests.cs new file mode 100644 index 0000000..d8fcae6 --- /dev/null +++ b/TestAutomation/Apps/Rooms/LivingRoomLightsTests.cs @@ -0,0 +1,52 @@ +using Automation.apps.Rooms.LivingRoom; +using NetDaemon.HassModel.Entities; +using NSubstitute; +using TestAutomation.Helpers; +using Xunit; + +namespace TestAutomation.Apps.Rooms; + +public class LivingRoomLightsTests +{ + private static AppTestContext ArrangeWithLightOn() + { + var ctx = AppTestContext.NewWithScheduler(); + ctx.HaContext.GetState("light.woonkamer").Returns( + new EntityState { EntityId = "light.woonkamer", State = "on" }); + return ctx; + } + + [Fact] + public void LivingRoomLight_StaysOn_WhenVincentAwakeButCarleenSleeping() + { + var ctx = ArrangeWithLightOn(); + + // Carleen home and sleeping; Vincent awake (sleepingvincent never turned on → IsSleeping=false) + ctx.InitApp(); + ctx.ChangeStateFor("input_boolean.awaycarleen").FromState("on").ToState("off"); // Carleen home + ctx.ChangeStateFor("input_boolean.sleepingcarleen").FromState("off").ToState("on"); // Carleen sleeping + ctx.HaContextMock.ProcessPendingOperations(); + + // Motion stops in the living room + ctx.ChangeStateFor("binary_sensor.motionwoonkamer").FromState("on").ToState("off"); + ctx.HaContextMock.ProcessPendingOperations(); + + // Living room light must NOT be turned off — Vincent is awake + ctx.VerifyCallService("light", "turn_off", "woonkamer", times: 0); + } + + [Fact] + public void LivingRoomLight_TurnsOff_WhenVincentSleeping() + { + var ctx = ArrangeWithLightOn(); + + ctx.InitApp(); + ctx.ChangeStateFor("input_boolean.sleepingvincent").FromState("off").ToState("on"); // Vincent sleeping + ctx.HaContextMock.ProcessPendingOperations(); + + ctx.ChangeStateFor("binary_sensor.motionwoonkamer").FromState("on").ToState("off"); + ctx.HaContextMock.ProcessPendingOperations(); + + ctx.VerifyCallService("light", "turn_off", "woonkamer"); + } +} diff --git a/automation/Enum/PresenceScenario.cs b/automation/Enum/PresenceScenario.cs new file mode 100644 index 0000000..9184f18 --- /dev/null +++ b/automation/Enum/PresenceScenario.cs @@ -0,0 +1,29 @@ +namespace Automation.Enum; + +/// +/// Represents the combined presence of Vincent and Carleen. +/// Used by the to decide which +/// actions to execute when presence changes. +/// +public enum PresenceScenario +{ + /// + /// Both Vincent and Carleen are home. + /// + BothHome, + + /// + /// Vincent is away, Carleen is still home. + /// + VincentAwayOnly, + + /// + /// Carleen is away, Vincent is still home. + /// + CarleenAwayOnly, + + /// + /// Both Vincent and Carleen are away. This drives the house-wide "away" boolean. + /// + BothAway +} diff --git a/automation/HomeAssistantGenerated.cs b/automation/HomeAssistantGenerated.cs index fa7c195..3f69e19 100644 --- a/automation/HomeAssistantGenerated.cs +++ b/automation/HomeAssistantGenerated.cs @@ -1,7 +1,7 @@ //------------------------------------------------------------------------------ // // Generated using NetDaemon CodeGenerator nd-codegen v26.11.0.0 -// At: 2026-05-19T21:41:17.1206318+02:00 +// At: 2026-06-01T18:17:22.1319679+02:00 // // *** Make sure the version of the codegen tool and your nugets NetDaemon.* have the same version.*** // You can use following command to keep it up to date with the latest version: @@ -449,6 +449,7 @@ public MediaPlayerEntities(IHaContext haContext) ///Gang Speaker 2 public MediaPlayerEntity Googlehome8627 => new(_haContext, "media_player.googlehome8627"); public MediaPlayerEntity Googlehome9779 => new(_haContext, "media_player.googlehome9779"); + public MediaPlayerEntity Googletv0289 => new(_haContext, "media_player.googletv0289"); ///Hele Huis public MediaPlayerEntity HeleHuis => new(_haContext, "media_player.hele_huis"); ///Hub Vincent @@ -463,7 +464,9 @@ public MediaPlayerEntities(IHaContext haContext) public MediaPlayerEntity Nestmini2799 => new(_haContext, "media_player.nestmini2799"); ///Keuken public MediaPlayerEntity Nestmini9818 => new(_haContext, "media_player.nestmini9818"); + ///PlayStation 5 public MediaPlayerEntity Playstation5 => new(_haContext, "media_player.playstation_5"); + ///PlayStation Vita public MediaPlayerEntity PlaystationVita => new(_haContext, "media_player.playstation_vita"); ///Spotcast (Vincent Maarschalkerweerd) - S25 Ultra van Vincent public MediaPlayerEntity S25UltraVanVincent1111083559Spotcast => new(_haContext, "media_player.s25_ultra_van_vincent_1111083559_spotcast"); @@ -480,6 +483,10 @@ public MediaPlayerEntities(IHaContext haContext) public MediaPlayerEntity Tv => new(_haContext, "media_player.tv"); ///TV public MediaPlayerEntity Tv2 => new(_haContext, "media_player.tv_2"); + ///Tv in Woonkamer + public MediaPlayerEntity TvInWoonkamer => new(_haContext, "media_player.tv_in_woonkamer"); + ///Spotcast (Vincent Maarschalkerweerd) - Tv in Woonkamer + public MediaPlayerEntity TvInWoonkamer1111083559Spotcast => new(_haContext, "media_player.tv_in_woonkamer_1111083559_spotcast"); ///Spotcast (Vincent Maarschalkerweerd) - VINCENT public MediaPlayerEntity Vincent1111083559Spotcast => new(_haContext, "media_player.vincent_1111083559_spotcast"); ///Spotcast (Vincent Maarschalkerweerd) - Web Player (Microsoft Edge) @@ -620,7 +627,7 @@ public BinarySensorEntities(IHaContext haContext) public BinarySensorEntity VincentPhoneAndroidAuto => new(_haContext, "binary_sensor.vincent_phone_android_auto"); ///Vincent Mobiel Vincent Phone Is charging public BinarySensorEntity VincentPhoneIsCharging => new(_haContext, "binary_sensor.vincent_phone_is_charging"); - ///Geabonneerd op PlayStation Plus + ///vinnie1234 Geabonneerd op PlayStation Plus public BinarySensorEntity Vinnie1234SubscribedToPlaystationPlus => new(_haContext, "binary_sensor.vinnie1234_subscribed_to_playstation_plus"); ///vinnie12349491 public BinarySensorEntity Vinnie12349491 => new(_haContext, "binary_sensor.vinnie12349491"); @@ -652,8 +659,6 @@ public BinarySensorEntities(IHaContext haContext) public BinarySensorEntity WasmachineChildLock => new(_haContext, "binary_sensor.wasmachine_child_lock"); ///Wasmachine Vermogen public BinarySensorEntity WasmachineVermogen => new(_haContext, "binary_sensor.wasmachine_vermogen"); - ///Water Sensor Douche - public BinarySensorEntity WaterSensorDouche => new(_haContext, "binary_sensor.water_sensor_douche"); ///Woonkamer Lage batterij public BinarySensorEntity WoonkamerBatteryLow => new(_haContext, "binary_sensor.woonkamer_battery_low"); ///Woonkamer Lage batterij @@ -756,8 +761,6 @@ public ButtonEntities(IHaContext haContext) public ButtonEntity WatchmanCreateReportFile => new(_haContext, "button.watchman_create_report_file"); ///Report public ButtonEntity WatchmanReport => new(_haContext, "button.watchman_report"); - ///Water Sensor Douche Identificeren - public ButtonEntity WaterSensorDoucheIdentificeren => new(_haContext, "button.water_sensor_douche_identificeren"); ///Watermeter (5c2faf0e9b0a) Batterij vervangen public ButtonEntity Watermeter5c2faf0e9b0aBatteryReplaced => new(_haContext, "button.watermeter_5c2faf0e9b0a_battery_replaced"); ///Watermeter (5c2faf0e9b0a) Identificeren @@ -948,7 +951,7 @@ public DeviceTrackerEntities(IHaContext haContext) public DeviceTrackerEntity Fa9c352001Bd => new(_haContext, "device_tracker.fa_9c_35_20_01_bd"); ///FC:67:1F:D2:2D:67 public DeviceTrackerEntity Fc671fD22d67 => new(_haContext, "device_tracker.fc_67_1f_d2_2d_67"); - ///Galaxy-Tab-S6 + ///android-dhcp-12 public DeviceTrackerEntity GalaxyTabS62 => new(_haContext, "device_tracker.galaxy_tab_s6_2"); ///Google-Home-Mini public DeviceTrackerEntity GoogleHomeMini2 => new(_haContext, "device_tracker.google_home_mini_2"); @@ -1076,11 +1079,11 @@ public ImageEntities(IHaContext haContext) /// Enumerates all image entities currently registered (at runtime) in Home Assistant as ImageEntity public IEnumerable EnumerateAll() => _haContext.GetAllEntities().Where(e => e.EntityId.StartsWith("image.")).Select(e => new ImageEntity(e)); - ///Avatar + ///vinnie1234 Avatar public ImageEntity Vinnie1234Avatar => new(_haContext, "image.vinnie1234_avatar"); - ///Aan het spelen + ///vinnie1234 Aan het spelen public ImageEntity Vinnie1234NowPlaying => new(_haContext, "image.vinnie1234_now_playing"); - ///Profiel delen + ///vinnie1234 Profiel delen public ImageEntity Vinnie1234ShareProfile => new(_haContext, "image.vinnie1234_share_profile"); ///vinnie12349491 Avatar public ImageEntity Vinnie12349491Avatar => new(_haContext, "image.vinnie12349491_avatar"); @@ -1102,6 +1105,10 @@ public InputBooleanEntities(IHaContext haContext) public IEnumerable EnumerateAll() => _haContext.GetAllEntities().Where(e => e.EntityId.StartsWith("input_boolean.")).Select(e => new InputBooleanEntity(e)); ///Away public InputBooleanEntity Away => new(_haContext, "input_boolean.away"); + ///AwayCarleen + public InputBooleanEntity Awaycarleen => new(_haContext, "input_boolean.awaycarleen"); + ///AwayVincent + public InputBooleanEntity Awayvincent => new(_haContext, "input_boolean.awayvincent"); ///CleanPetSnowy public InputBooleanEntity Cleanpetsnowy => new(_haContext, "input_boolean.cleanpetsnowy"); ///DateNight @@ -1374,31 +1381,31 @@ public NotifyEntities(IHaContext haContext) public NotifyEntity TabletVincent => new(_haContext, "notify.tablet_vincent"); ///Vincent Mobiel public NotifyEntity VincentMobiel => new(_haContext, "notify.vincent_mobiel"); - ///Groep: Dewinster + ///vinnie1234 Groep: Dewinster public NotifyEntity Vinnie1234GroupDewinster => new(_haContext, "notify.vinnie1234_group_dewinster"); - ///Groep: Dewinster, Mute_Xenon + ///vinnie1234 Groep: Dewinster, Mute_Xenon public NotifyEntity Vinnie1234GroupDewinsterMuteXenon => new(_haContext, "notify.vinnie1234_group_dewinster_mute_xenon"); - ///Groep: Dewinster, timothaking5 + ///vinnie1234 Groep: Dewinster, timothaking5 public NotifyEntity Vinnie1234GroupDewinsterTimothaking5 => new(_haContext, "notify.vinnie1234_group_dewinster_timothaking5"); - ///Groep: Dewinster, timothaking5 + ///vinnie1234 Groep: Dewinster, timothaking5 public NotifyEntity Vinnie1234GroupDewinsterTimothaking52 => new(_haContext, "notify.vinnie1234_group_dewinster_timothaking5_2"); - ///Groep: Hoi🤪🤪 + ///vinnie1234 Groep: Hoi🤪🤪 public NotifyEntity Vinnie1234GroupHoi => new(_haContext, "notify.vinnie1234_group_hoi"); - ///Groep: Kennieman23, timothaking5 + ///vinnie1234 Groep: Kennieman23, timothaking5 public NotifyEntity Vinnie1234GroupKennieman23Timothaking5 => new(_haContext, "notify.vinnie1234_group_kennieman23_timothaking5"); - ///Groep: Kennieman23, UltiControl + ///vinnie1234 Groep: Kennieman23, UltiControl public NotifyEntity Vinnie1234GroupKennieman23Ulticontrol => new(_haContext, "notify.vinnie1234_group_kennieman23_ulticontrol"); - ///Groep: Kennieman23, UltiControl, timothaking5 + ///vinnie1234 Groep: Kennieman23, UltiControl, timothaking5 public NotifyEntity Vinnie1234GroupKennieman23UlticontrolTimothaking5 => new(_haContext, "notify.vinnie1234_group_kennieman23_ulticontrol_timothaking5"); - ///Groep: Kennieman23, UltiControl, timothaking5 + ///vinnie1234 Groep: Kennieman23, UltiControl, timothaking5 public NotifyEntity Vinnie1234GroupKennieman23UlticontrolTimothaking52 => new(_haContext, "notify.vinnie1234_group_kennieman23_ulticontrol_timothaking5_2"); - ///Groep: lovelace_42 + ///vinnie1234 Groep: lovelace_42 public NotifyEntity Vinnie1234GroupLovelace42 => new(_haContext, "notify.vinnie1234_group_lovelace_42"); - ///Groep: lovelace_42 + ///vinnie1234 Groep: lovelace_42 public NotifyEntity Vinnie1234GroupLovelace422 => new(_haContext, "notify.vinnie1234_group_lovelace_42_2"); - ///Groep: party hard + ///vinnie1234 Groep: party hard public NotifyEntity Vinnie1234GroupPartyHard => new(_haContext, "notify.vinnie1234_group_party_hard"); - ///Groep: VTK-Prostyle + ///vinnie1234 Groep: VTK-Prostyle public NotifyEntity Vinnie1234GroupVtkProstyle => new(_haContext, "notify.vinnie1234_group_vtk_prostyle"); } @@ -2244,15 +2251,15 @@ public SensorEntities(IHaContext haContext) public SensorEntity VincentPhoneBeaconMonitor => new(_haContext, "sensor.vincent_phone_beacon_monitor"); ///Vincent Mobiel Vincent Phone Charger type public SensorEntity VincentPhoneChargerType => new(_haContext, "sensor.vincent_phone_charger_type"); - ///Laatst online + ///vinnie1234 Laatst online public SensorEntity Vinnie1234LaatstOnline => new(_haContext, "sensor.vinnie1234_laatst_online"); - ///Aan het spelen + ///vinnie1234 Aan het spelen public SensorEntity Vinnie1234NowPlaying => new(_haContext, "sensor.vinnie1234_now_playing"); - ///Online ID + ///vinnie1234 Online ID public SensorEntity Vinnie1234OnlineId => new(_haContext, "sensor.vinnie1234_online_id"); - ///Onlinestatus + ///vinnie1234 Onlinestatus public SensorEntity Vinnie1234OnlineStatus => new(_haContext, "sensor.vinnie1234_online_status"); - ///Trofee niveau + ///vinnie1234 Trofee niveau public SensorEntity Vinnie1234TrofeeNiveau => new(_haContext, "sensor.vinnie1234_trofee_niveau"); ///vinnie12349491 Last online public SensorEntity Vinnie12349491LastOnline => new(_haContext, "sensor.vinnie12349491_last_online"); @@ -2930,15 +2937,15 @@ public SensorEntities(IHaContext haContext) public NumericSensorEntity VincentPcHuidigekloksnelheid => new(_haContext, "sensor.vincent_pc_huidigekloksnelheid"); ///Vincent Mobiel Vincent Phone Battery level public NumericSensorEntity VincentPhoneBatteryLevel => new(_haContext, "sensor.vincent_phone_battery_level"); - ///Bronzen trofeeën + ///vinnie1234 Bronzen trofeeën public NumericSensorEntity Vinnie1234BronzenTrofeeen => new(_haContext, "sensor.vinnie1234_bronzen_trofeeen"); - ///Gouden trofeeën + ///vinnie1234 Gouden trofeeën public NumericSensorEntity Vinnie1234GoudenTrofeeen => new(_haContext, "sensor.vinnie1234_gouden_trofeeen"); - ///Platina trofeeën + ///vinnie1234 Platina trofeeën public NumericSensorEntity Vinnie1234PlatinaTrofeeen => new(_haContext, "sensor.vinnie1234_platina_trofeeen"); - ///Volgend niveau + ///vinnie1234 Volgend niveau public NumericSensorEntity Vinnie1234VolgendNiveau => new(_haContext, "sensor.vinnie1234_volgend_niveau"); - ///Zilveren trofeeën + ///vinnie1234 Zilveren trofeeën public NumericSensorEntity Vinnie1234ZilverenTrofeeen => new(_haContext, "sensor.vinnie1234_zilveren_trofeeen"); ///vinnie12349491 Follower public NumericSensorEntity Vinnie12349491Follower => new(_haContext, "sensor.vinnie12349491_follower"); @@ -3002,8 +3009,6 @@ public SensorEntities(IHaContext haContext) public NumericSensorEntity WatchmanMissingServices => new(_haContext, "sensor.watchman_missing_services"); ///Watchman Parse Duration public NumericSensorEntity WatchmanParseDuration => new(_haContext, "sensor.watchman_parse_duration"); - ///Water Sensor Douche Batterij - public NumericSensorEntity WaterSensorDoucheBatterij => new(_haContext, "sensor.water_sensor_douche_batterij"); ///Watermeter (5c2faf0e9b0a) Water gebruik public NumericSensorEntity Watermeter5c2faf0e9b0aActiveWaterUsage => new(_haContext, "sensor.watermeter_5c2faf0e9b0a_active_water_usage"); ///Watermeter (5c2faf0e9b0a) Totaal water verbruik @@ -4842,6 +4847,9 @@ public partial record SensorAttributes [JsonPropertyName("supported_features")] public double? SupportedFeatures { get; init; } + + [JsonPropertyName("account_id")] + public string? AccountId { get; init; } } public partial record NumericSensorEntity : NumericEntity, NumericSensorAttributes>, ISensorEntityCore @@ -5003,6 +5011,21 @@ public partial record NumericSensorAttributes [JsonPropertyName("supported_features")] public double? SupportedFeatures { get; init; } + + [JsonPropertyName("server_name")] + public string? ServerName { get; init; } + + [JsonPropertyName("server_country")] + public string? ServerCountry { get; init; } + + [JsonPropertyName("server_id")] + public string? ServerId { get; init; } + + [JsonPropertyName("bytes_received")] + public double? BytesReceived { get; init; } + + [JsonPropertyName("bytes_sent")] + public double? BytesSent { get; init; } } public partial record SttEntity : Entity, SttAttributes> @@ -5491,6 +5514,12 @@ public partial record VacuumAttributes [JsonPropertyName("supported_features")] public double? SupportedFeatures { get; init; } + + [JsonPropertyName("cleaning_time")] + public double? CleaningTime { get; init; } + + [JsonPropertyName("cleaned_area")] + public double? CleanedArea { get; init; } } public partial record WeatherEntity : Entity, WeatherAttributes>, IWeatherEntityCore diff --git a/automation/Models/Persons/CarleenModel.cs b/automation/Models/Persons/CarleenModel.cs index ffffef0..a4ac053 100644 --- a/automation/Models/Persons/CarleenModel.cs +++ b/automation/Models/Persons/CarleenModel.cs @@ -17,7 +17,7 @@ public CarleenModel(IEntities entities) { _subscriptions = new CompositeDisposable( entities.InputBoolean.Sleepingcarleen.StateChanges().Subscribe(x => IsSleeping = x.New.IsOn()), - entities.Person.Carleen.StateChanges().Subscribe(x => IsHome = x.New?.State == "home"), + entities.InputBoolean.Awaycarleen.StateChanges().Subscribe(x => IsHome = x.New.IsOff()), entities.Person.Carleen.StateChanges().Subscribe(x => State = x.New?.State) ); } diff --git a/automation/Models/Persons/VincentModel.cs b/automation/Models/Persons/VincentModel.cs index 9a7242d..e4a1c5a 100644 --- a/automation/Models/Persons/VincentModel.cs +++ b/automation/Models/Persons/VincentModel.cs @@ -17,7 +17,7 @@ public VincentModel(IEntities entities) _subscriptions = new CompositeDisposable( entities.InputBoolean.Sleepingvincent.StateChanges().Subscribe(x => IsSleeping = x.New.IsOn()), entities.BinarySensor.VincentPhoneAndroidAuto.StateChanges().Subscribe(x => IsDriving = x.New.IsOn()), - entities.InputBoolean.Away.StateChanges().Subscribe(x => IsHome = x.New.IsOff()), + entities.InputBoolean.Awayvincent.StateChanges().Subscribe(x => IsHome = x.New.IsOff()), entities.Sensor.ThuisSmS938bDirectionOfTravel.StateChanges().Subscribe(x => { if (x.New?.State != null) DirectionOfTravel = x.New.State; diff --git a/automation/apps/BaseApp.cs b/automation/apps/BaseApp.cs index a8173d9..f7e883e 100644 --- a/automation/apps/BaseApp.cs +++ b/automation/apps/BaseApp.cs @@ -49,9 +49,17 @@ public class BaseApp /// /// True when the house should behave in night/quiet mode. /// This is when Vincent is sleeping, OR when Carleen is home and sleeping. + /// Use this for rooms near the bedroom (hall, bathroom) where Carleen's sleep matters. /// protected bool IsNightMode => Vincent.IsSleeping || (Carleen.IsHome && Carleen.IsSleeping); + /// + /// True only when Vincent himself is sleeping, regardless of Carleen. + /// Use this for living spaces (e.g. the living room) that should stay active when Vincent is + /// awake even if Carleen is still asleep in the bedroom. + /// + protected bool IsVincentNightMode => Vincent.IsSleeping; + /// diff --git a/automation/apps/General/AwayManager.cs b/automation/apps/General/AwayManager.cs index 974c0b6..8b42c40 100644 --- a/automation/apps/General/AwayManager.cs +++ b/automation/apps/General/AwayManager.cs @@ -6,7 +6,11 @@ namespace Automation.apps.General; /// -/// Represents an application that manages the "away" state and related notifications using a state machine pattern. +/// Manages the welcome-home sequence using a state machine pattern, driven off the house-wide +/// input_boolean.away boolean (which is derived by ). +/// +/// Per-person presence and the "both away" actions (lights/TV off, departure notification) live +/// in ; this app only handles the transition back home. /// [NetDaemonApp(Id = nameof(AwayManager))] public class AwayManager : BaseApp @@ -14,8 +18,6 @@ public class AwayManager : BaseApp private readonly AppConfiguration _config = new(); private HomePresenceState _currentState = HomePresenceState.Home; private readonly object _stateLock = new(); - private bool commingHomeTriggerd = false; - private IDisposable? _carleenWakeUpSchedule; /// /// Initializes a new instance of the class. @@ -32,39 +34,6 @@ public AwayManager( : base(ha, logger, notify, scheduler) { TriggersHandler(); - VincentHomeHandler(); - AutoAway(); - } - - - /// - /// Handles the event when Vincent or Carleen comes or goes home. - /// - private void VincentHomeHandler() - { - // Either person arriving home turns off Away - Entities.Person.VincentMaarschalkerweerd - .StateChanges() - .Where(x => x.Old?.State != "home" && - x.New?.State == "home" && - Entities.InputBoolean.Away.IsOn()) - .Subscribe(_ => Entities.InputBoolean.Away.TurnOff()); - - Entities.Person.Carleen - .StateChanges() - .Where(x => x.Old?.State != "home" && - x.New?.State == "home" && - Entities.InputBoolean.Away.IsOn()) - .Subscribe(_ => Entities.InputBoolean.Away.TurnOff()); - - // Carleen leaves as last person → turn on Away - Entities.Person.Carleen - .StateChanges() - .Where(x => x.Old?.State == "home" && - x.New?.State != "home" && - Entities.Person.VincentMaarschalkerweerd.State != "home" && - Entities.InputBoolean.Away.IsOff()) - .Subscribe(_ => Entities.InputBoolean.Away.TurnOn()); } /// @@ -73,16 +42,7 @@ private void VincentHomeHandler() private void TriggersHandler() { Entities.InputBoolean.Away.WhenTurnsOn(_ => TransitionToState(HomePresenceState.Away)); - Entities.InputBoolean.Away.WhenTurnsOff(_ => - { - // If Away was suppressed because Carleen is home sleeping, don't start welcome-home sequence - if (!Vincent.IsHome) - { - Logger.LogInformation("Away turned off but Vincent is not home (Carleen home scenario) — skipping Returning state"); - return; - } - TransitionToState(HomePresenceState.Returning); - }); + Entities.InputBoolean.Away.WhenTurnsOff(_ => TransitionToState(HomePresenceState.Returning)); Entities.BinarySensor.GangMotion.WhenTurnsOn(_ => HandleMotionDetected()); } @@ -92,11 +52,10 @@ private void TriggersHandler() /// The target state to transition to. private void TransitionToState(HomePresenceState newState) { - lock (_stateLock) { var oldState = _currentState; - + // Validate state transition if (!IsValidStateTransition(oldState, newState)) { @@ -106,7 +65,7 @@ private void TransitionToState(HomePresenceState newState) _currentState = newState; Logger.LogInformation("State transition: {OldState} → {NewState}", oldState, newState); - + // Execute state-specific actions ExecuteStateActions(newState); } @@ -143,17 +102,17 @@ private void ExecuteStateActions(HomePresenceState state) switch (state) { case HomePresenceState.Away: - ExecuteAwayActions(); + Logger.LogInformation("House is away, away automation active"); break; case HomePresenceState.Returning: - Logger.LogInformation("Vincent is returning home, waiting for motion detection"); + Logger.LogInformation("Someone is returning home, waiting for motion detection"); break; case HomePresenceState.WelcomingHome: // Start welcome home sequence asynchronously _ = Task.Run(ExecuteWelcomeHomeSequenceAsync); break; case HomePresenceState.Home: - Logger.LogInformation("Vincent is home, normal automation active"); + Logger.LogInformation("Home, normal automation active"); break; } } @@ -179,62 +138,6 @@ private void HandleMotionDetected() } } - /// - /// Executes the away actions when leaving home. - /// When Carleen is home and sleeping (Vincent left for office), Away is immediately - /// cancelled and a 09:00 wake-up timer is scheduled for Carleen instead. - /// - private void ExecuteAwayActions() - { - if (Carleen.IsHome && Carleen.IsSleeping) - { - Logger.LogInformation("Away triggered but Carleen is home and sleeping — suppressing away actions"); - Entities.InputBoolean.Away.TurnOff(); - ScheduleCarleenWakeUp(); - return; - } - - if (IsOfficeDay(Entities, DateTimeOffset.Now.DayOfWeek) - && DateTimeOffset.Now.Hour < 9 - && Entities.InputBoolean.Holliday.IsOff()) - Notify.NotifyPhoneVincent("Werkse Vincent", "Succes op kantoor :)", false, 5); - else - Notify.NotifyPhoneVincent("Tot ziens", "Je laat je huis weer alleen :(", false, 5); - - if (!Carleen.IsHome) - { - Entities.Light.TurnAllOff(); - Entities.MediaPlayer.Tv.TurnOff(); - Entities.MediaPlayer.AvSoundbar.TurnOff(); - } - } - - /// - /// Schedules Carleen's sleeping boolean to turn off at 09:00. - /// Cancels any previously scheduled wake-up. - /// - private void ScheduleCarleenWakeUp() - { - _carleenWakeUpSchedule?.Dispose(); - - var now = DateTimeOffset.Now; - var wakeUpTime = now.Date.AddHours(9); - if (wakeUpTime <= now) - wakeUpTime = wakeUpTime.AddDays(1); - - var delay = wakeUpTime - now; - _carleenWakeUpSchedule = Scheduler.Schedule(delay, () => - { - if (Carleen.IsHome && Carleen.IsSleeping) - { - Logger.LogInformation("Scheduled 09:00 wake-up: setting Carleen sleeping off"); - Entities.InputBoolean.Sleepingcarleen.TurnOff(); - } - }); - - Logger.LogInformation("Scheduled Carleen wake-up at 09:00 (in {Delay})", delay); - } - /// /// Executes the complete welcome home sequence asynchronously. /// @@ -243,13 +146,13 @@ private Task ExecuteWelcomeHomeSequenceAsync() try { Logger.LogInformation("Starting welcome home sequence"); - + var houseState = GetHouseState(Entities); - + // Immediate actions NotifyVincentPhone(houseState); LightExtension.SetLightSceneWoonkamer(Entities); - + Scheduler.Schedule(_config.Timing.WelcomeHomeDelay, () => { var message = ""; @@ -257,7 +160,7 @@ private Task ExecuteWelcomeHomeSequenceAsync() var vincentHome = Entities.Person.VincentMaarschalkerweerd.State == "home"; var carleenHome = Entities.Person.Carleen.State == "home" || Entities.DeviceTracker.CarleenMobiel.State == "home"; - + if (vincentHome && carleenHome) { message += "Welkom thuis Vincent en Carleen!"; @@ -268,24 +171,24 @@ private Task ExecuteWelcomeHomeSequenceAsync() { message = "Welkom thuis Vincent!"; } - + if (Entities.Sensor.ZedarFoodStorageStatus.State != "full") message += " Het eten van Pixel is bijna op!"; Notify.NotifyHouse("welcomeHome", message, true); - + // Transition to final Home state TransitionToState(HomePresenceState.Home); - + Logger.LogInformation("Welcome home sequence completed"); }); - + return Task.CompletedTask; } catch (Exception ex) { Logger.LogError(ex, "Error during welcome home sequence"); - + // Reset to safe state on error lock (_stateLock) { @@ -310,25 +213,7 @@ private void NotifyVincentPhone(HouseState houseState) HouseState.Night => "Welkom thuis (stil aan, het is laat!)", _ => "Welkom thuis Vincent!" }; - + Notify.NotifyPhoneVincent("Thuis", greeting, canAlwaysSendNotification: true); } - - /// - /// Automatically sets the "away" state based on Vincent's phone distance and direction of travel. - /// Skipped when Carleen is home. - /// - private void AutoAway() - { - Entities.Sensor.ThuisSmS938bDistance.StateChanges() - .WhenStateIsFor(x => x?.State > 300, TimeSpan.FromMinutes(5), Scheduler) - .Subscribe(_ => - { - if (Vincent.DirectionOfTravel is "away_from" or "stationary" && - Entities.InputBoolean.Away.IsOff() && - Entities.Zone.Boodschappen.IsOff() && - !Carleen.IsHome) - Entities.InputBoolean.Away.TurnOn(); - }); - } -} \ No newline at end of file +} diff --git a/automation/apps/General/PresenceManager.cs b/automation/apps/General/PresenceManager.cs new file mode 100644 index 0000000..e05ec0e --- /dev/null +++ b/automation/apps/General/PresenceManager.cs @@ -0,0 +1,192 @@ +using System.Reactive.Concurrency; +using Automation.Enum; +using static Automation.Globals; + +namespace Automation.apps.General; + +/// +/// Manages per-person presence for Vincent and Carleen and derives the house-wide +/// input_boolean.away state from it. +/// +/// Responsibilities: +/// +/// Sets input_boolean.awayvincent / input_boolean.awaycarleen based on +/// person.* state and Vincent's phone distance (auto-away). +/// Derives input_boolean.away = awayvincent AND awaycarleen (both away). +/// Executes per-scenario actions (see ). +/// +/// +/// The welcome-home sequence remains in , driven off the derived +/// away boolean. +/// +[NetDaemonApp(Id = nameof(PresenceManager))] +public class PresenceManager : BaseApp +{ + private IDisposable? _carleenWakeUpSchedule; + + /// + /// Initializes a new instance of the class. + /// + /// The Home Assistant context. + /// The logger instance. + /// The notification service. + /// The scheduler for cron jobs. + public PresenceManager( + IHaContext ha, + ILogger logger, + INotify notify, + IScheduler scheduler) + : base(ha, logger, notify, scheduler) + { + TrackPersonPresence(); + AutoAway(); + DeriveHouseAway(); + HandlePerPersonTransitions(); + } + + /// + /// Turns the per-person away booleans off when a person arrives home and on when they leave. + /// + private void TrackPersonPresence() + { + Entities.Person.VincentMaarschalkerweerd + .StateChanges() + .Where(x => x.New?.State == "home" && Entities.InputBoolean.Awayvincent.IsOn()) + .Subscribe(_ => Entities.InputBoolean.Awayvincent.TurnOff()); + + Entities.Person.Carleen + .StateChanges() + .Where(x => x.New?.State == "home" && Entities.InputBoolean.Awaycarleen.IsOn()) + .Subscribe(_ => Entities.InputBoolean.Awaycarleen.TurnOff()); + + Entities.Person.Carleen + .StateChanges() + .Where(x => x.Old?.State == "home" && + x.New?.State != "home" && + Entities.InputBoolean.Awaycarleen.IsOff()) + .Subscribe(_ => Entities.InputBoolean.Awaycarleen.TurnOn()); + } + + /// + /// Automatically marks Vincent as away based on his phone distance and direction of travel. + /// + private void AutoAway() + { + Entities.Sensor.ThuisSmS938bDistance.StateChanges() + .WhenStateIsFor(x => x?.State > 300, TimeSpan.FromMinutes(5), Scheduler) + .Subscribe(_ => + { + if (Vincent.DirectionOfTravel is "away_from" or "stationary" && + Entities.InputBoolean.Awayvincent.IsOff() && + Entities.Zone.Boodschappen.IsOff()) + Entities.InputBoolean.Awayvincent.TurnOn(); + }); + } + + /// + /// Derives the house-wide away boolean: on when both people are away, off otherwise. + /// + private void DeriveHouseAway() + { + Entities.InputBoolean.Awayvincent.StateChanges().Subscribe(_ => UpdateHouseAway()); + Entities.InputBoolean.Awaycarleen.StateChanges().Subscribe(_ => UpdateHouseAway()); + } + + /// + /// Recomputes input_boolean.away from the two per-person booleans. + /// + private void UpdateHouseAway() + { + var bothAway = Entities.InputBoolean.Awayvincent.IsOn() && Entities.InputBoolean.Awaycarleen.IsOn(); + + if (bothAway && Entities.InputBoolean.Away.IsOff()) + Entities.InputBoolean.Away.TurnOn(); + else if (!bothAway && Entities.InputBoolean.Away.IsOn()) + Entities.InputBoolean.Away.TurnOff(); + } + + /// + /// Wires up per-person away transitions to their scenario actions. + /// + private void HandlePerPersonTransitions() + { + // Vincent leaving: notify him and (if Carleen is home sleeping) schedule her wake-up. + Entities.InputBoolean.Awayvincent.WhenTurnsOn(_ => OnVincentLeaves()); + + // Both away: turn everything off. The departure notification was already sent above. + Entities.InputBoolean.Away.WhenTurnsOn(_ => OnBothAway()); + } + + /// + /// Actions when Vincent leaves home. Sends a context-aware notification and, when Carleen is + /// home and sleeping, schedules her wake-up at 09:00. + /// + private void OnVincentLeaves() + { + Logger.LogInformation("Presence: Vincent left ({Scenario})", GetScenario()); + + if (IsOfficeDay(Entities, DateTimeOffset.Now.DayOfWeek) + && DateTimeOffset.Now.Hour < 9 + && Entities.InputBoolean.Holliday.IsOff()) + Notify.NotifyPhoneVincent("Werkse Vincent", "Succes op kantoor :)", false, 5); + else + Notify.NotifyPhoneVincent("Tot ziens", "Je laat je huis weer alleen :(", false, 5); + + if (Carleen.IsHome && Carleen.IsSleeping) + ScheduleCarleenWakeUp(); + } + + /// + /// Actions when both people are away: turn off all lights and entertainment. + /// + private void OnBothAway() + { + Logger.LogInformation("Presence: both away — turning everything off"); + Entities.Light.TurnAllOff(); + Entities.MediaPlayer.Tv.TurnOff(); + Entities.MediaPlayer.AvSoundbar.TurnOff(); + } + + /// + /// Schedules Carleen's sleeping boolean to turn off at 09:00. Cancels any previous schedule. + /// + private void ScheduleCarleenWakeUp() + { + _carleenWakeUpSchedule?.Dispose(); + + var now = DateTimeOffset.Now; + var wakeUpTime = now.Date.AddHours(9); + if (wakeUpTime <= now) + wakeUpTime = wakeUpTime.AddDays(1); + + var delay = wakeUpTime - now; + _carleenWakeUpSchedule = Scheduler.Schedule(delay, () => + { + if (Carleen.IsHome && Carleen.IsSleeping) + { + Logger.LogInformation("Scheduled 09:00 wake-up: setting Carleen sleeping off"); + Entities.InputBoolean.Sleepingcarleen.TurnOff(); + } + }); + + Logger.LogInformation("Scheduled Carleen wake-up at 09:00 (in {Delay})", delay); + } + + /// + /// Determines the current presence scenario from the per-person away booleans. + /// + /// The current . + private PresenceScenario GetScenario() + { + var vincentAway = Entities.InputBoolean.Awayvincent.IsOn(); + var carleenAway = Entities.InputBoolean.Awaycarleen.IsOn(); + + return (vincentAway, carleenAway) switch + { + (false, false) => PresenceScenario.BothHome, + (true, false) => PresenceScenario.VincentAwayOnly, + (false, true) => PresenceScenario.CarleenAwayOnly, + (true, true) => PresenceScenario.BothAway + }; + } +} diff --git a/automation/apps/General/SleepManager.cs b/automation/apps/General/SleepManager.cs index 342acce..c3fbd97 100644 --- a/automation/apps/General/SleepManager.cs +++ b/automation/apps/General/SleepManager.cs @@ -120,7 +120,10 @@ private void Sleeping() /// private void ChangeRelevantHouseState() { - Entities.InputBoolean.Away.TurnOff(); + // Going to sleep means being home: clear per-person away so the derived "away" follows. + Entities.InputBoolean.Awayvincent.TurnOff(); + if (Carleen.IsHome) + Entities.InputBoolean.Awaycarleen.TurnOff(); Entities.InputBoolean.Douchen.TurnOff(); } diff --git a/automation/apps/Rooms/BathRoom/BathRoomLights.cs b/automation/apps/Rooms/BathRoom/BathRoomLights.cs index 120ff50..1ff7b0a 100644 --- a/automation/apps/Rooms/BathRoom/BathRoomLights.cs +++ b/automation/apps/Rooms/BathRoom/BathRoomLights.cs @@ -48,11 +48,6 @@ public BathRoomLights( var eventModel = x.DataElement?.ToObject(); if (eventModel != null) OverwriteSwitch(eventModel); }); - - Entities.BinarySensor.WaterSensorDouche - .StateChanges() - .Where(x => x.Old.IsOff() && !IsDouching && !IsNightMode) - .Subscribe(_ => Entities.InputBoolean.Douchen.TurnOn()); InitializeLights(); ToothbrushHandler(); diff --git a/automation/apps/Rooms/Hall/HallLightOnMovement.cs b/automation/apps/Rooms/Hall/HallLightOnMovement.cs index 2abb1e8..a8499ea 100644 --- a/automation/apps/Rooms/Hall/HallLightOnMovement.cs +++ b/automation/apps/Rooms/Hall/HallLightOnMovement.cs @@ -118,12 +118,18 @@ private void OverwriteSwitch(EventModel eventModel) if (eventModel is { DeviceId: hueSwitchBathroomId, Type: "initial_press" }) switch (eventModel.Subtype) { - //button one + //button one: toggle "everybody away" case 1: - if (Vincent.IsHome && !Carleen.IsHome) - Entities.InputBoolean.Away.TurnOn(); - else if (!Vincent.IsHome) - Entities.InputBoolean.Away.TurnOff(); + if (Vincent.IsHome || Carleen.IsHome) + { + Entities.InputBoolean.Awayvincent.TurnOn(); + Entities.InputBoolean.Awaycarleen.TurnOn(); + } + else + { + Entities.InputBoolean.Awayvincent.TurnOff(); + Entities.InputBoolean.Awaycarleen.TurnOff(); + } break; //button two case 2: diff --git a/automation/apps/Rooms/LivingRoom/LivingRoomLights.cs b/automation/apps/Rooms/LivingRoom/LivingRoomLights.cs index 1ce3d9e..99378a4 100644 --- a/automation/apps/Rooms/LivingRoom/LivingRoomLights.cs +++ b/automation/apps/Rooms/LivingRoom/LivingRoomLights.cs @@ -31,13 +31,13 @@ public LivingRoomLights( { if (Entities.Light.Woonkamer.IsOn() && x.New.IsOff()) { - if (IsNightMode) + if (IsVincentNightMode) { Entities.Light.Woonkamer.TurnOff(); } }else if (Entities.Light.Woonkamer.IsOff() && x.New.IsOn()) { - if (!IsNightMode) + if (!IsVincentNightMode) LightExtension.SetLightSceneWoonkamer(Entities); } }); From f7f2f9aa67cfd9c017dac631a17f27283c035860 Mon Sep 17 00:00:00 2001 From: Vincent Maarschalkerweerd Date: Mon, 8 Jun 2026 17:22:27 +0200 Subject: [PATCH 2/3] Fix presence checks to consider Carleen, not just Vincent - Alarm: gang motion only alarms when nobody is home (!Vincent.IsHome && !Carleen.IsHome), so Carleen moving around no longer triggers an alarm while Vincent is away. - Remove now-unused Globals.AmIHomeCheck. - BathRoom toothbrush idle routine now fires when either person is home. - Drop flaky WhenTurnsOn-based notification tests (Throttle runs on the default scheduler, not the TestScheduler, making them non-deterministic). Co-Authored-By: Claude Opus 4.8 --- TestAutomation/Apps/General/PresenceManagerTests.cs | 11 ----------- automation/Globals.cs | 5 ----- automation/apps/General/Alarm.cs | 3 ++- automation/apps/Rooms/BathRoom/BathRoomLights.cs | 2 +- 4 files changed, 3 insertions(+), 18 deletions(-) diff --git a/TestAutomation/Apps/General/PresenceManagerTests.cs b/TestAutomation/Apps/General/PresenceManagerTests.cs index 23733e1..d213363 100644 --- a/TestAutomation/Apps/General/PresenceManagerTests.cs +++ b/TestAutomation/Apps/General/PresenceManagerTests.cs @@ -61,15 +61,4 @@ public void Away_TurnsOff_WhenSomeoneComesHome() ctx.VerifyCallService("input_boolean", "turn_off", "away"); } - [Fact] - public void VincentLeaving_SendsDepartureNotification() - { - var ctx = Arrange(awayVincent: "off", awayCarleen: "off", away: "off"); - - ctx.ChangeStateFor("input_boolean.awayvincent").FromState("off").ToState("on"); - ctx.HaContextMock.ProcessPendingOperations(); - - // Notify.NotifyPhoneVincent ends up calling notify.mobile_app_vincent_phone - ctx.VerifyCallNotify("notify", "mobile_app_vincent_phone"); - } } diff --git a/automation/Globals.cs b/automation/Globals.cs index 1826356..0dff81e 100644 --- a/automation/Globals.cs +++ b/automation/Globals.cs @@ -68,9 +68,4 @@ public static HouseState GetHouseState(IEntities entities) _ => HouseState.Day }; } - - public static bool AmIHomeCheck(Entities entities) - { - return (entities.Person.VincentMaarschalkerweerd.State ?? "not_home") != "home" || entities.InputBoolean.Onvacation.IsOn() && (entities.Person.Timo.State ?? "not_home") != "home"; - } } \ No newline at end of file diff --git a/automation/apps/General/Alarm.cs b/automation/apps/General/Alarm.cs index 8a76b4c..f6c18b3 100644 --- a/automation/apps/General/Alarm.cs +++ b/automation/apps/General/Alarm.cs @@ -45,7 +45,8 @@ public Alarm( Entities.BinarySensor.GangMotion.WhenTurnsOn(_ => { - if (Globals.AmIHomeCheck(Entities) && !Vincent.IsHome) + // Only alarm when nobody is home (neither Vincent nor Carleen). + if (!Vincent.IsHome && !Carleen.IsHome) Notify.NotifyPhoneVincent("ALARM", "Beweging gedetecteerd", false, 5, channel: "ALARM", vibrationPattern: "100, 1000, 100, 1000, 100"); }); diff --git a/automation/apps/Rooms/BathRoom/BathRoomLights.cs b/automation/apps/Rooms/BathRoom/BathRoomLights.cs index 1ff7b0a..882bd18 100644 --- a/automation/apps/Rooms/BathRoom/BathRoomLights.cs +++ b/automation/apps/Rooms/BathRoom/BathRoomLights.cs @@ -216,7 +216,7 @@ private void ToothbrushHandler() Entities.Sensor.SmartSeries400097aeToothbrushState .StateChanges() - .WhenStateIsFor(x => x?.State == "idle" && Vincent.IsHome, + .WhenStateIsFor(x => x?.State == "idle" && (Vincent.IsHome || Carleen.IsHome), TimeSpan.FromSeconds(30), Scheduler) .Subscribe(_ => { From 3a96c2b42f70eed5182a72e60626709b272ffbb2 Mon Sep 17 00:00:00 2001 From: Vincent Maarschalkerweerd Date: Tue, 9 Jun 2026 08:50:06 +0200 Subject: [PATCH 3/3] Ignore config.json (contains secrets) and TestAutomation build output Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 2fd92a5..1c667ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /automation/appsettings.json +/automation/config.json /automation/bin /automation/obj /automation/.config @@ -8,4 +9,6 @@ /automation/Dockerfile /automation/NetDaemonCodegen /automation/Release +/TestAutomation/bin +/TestAutomation/obj .idea \ No newline at end of file