From 2f5be128710cabd8b626d773868174dd84730f57 Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 25 Feb 2026 12:36:43 +0100 Subject: [PATCH 1/2] Implementing motion sensor in CodeCasa.NetDaemon.Sensors.Composite. --- CodeCasa.sln | 6 ++ ...odeCasa.NetDaemon.Sensors.Composite.csproj | 45 ++++++++++++++ .../Generated/BinarySensorAttributes.cs | 24 ++++++++ .../Generated/BinarySensorEntity.cs | 15 +++++ .../Generated/NumericSensorAttributes.cs | 48 +++++++++++++++ .../Generated/NumericSensorEntity.cs | 16 +++++ .../MotionSensor.cs | 59 +++++++++++++++++++ 7 files changed, 213 insertions(+) create mode 100644 src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj create mode 100644 src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorAttributes.cs create mode 100644 src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorEntity.cs create mode 100644 src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorAttributes.cs create mode 100644 src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorEntity.cs create mode 100644 src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs diff --git a/CodeCasa.sln b/CodeCasa.sln index 3918155..9a7f567 100644 --- a/CodeCasa.sln +++ b/CodeCasa.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.AutomationPipeline EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Notifications.Lights", "src\CodeCasa.Notifications.Lights\CodeCasa.Notifications.Lights.csproj", "{B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.NetDaemon.Sensors.Composite", "src\CodeCasa.NetDaemon.Sensors.Composite\CodeCasa.NetDaemon.Sensors.Composite.csproj", "{725ED4CC-2360-4FB5-A199-F1F42B7526B1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -119,6 +121,10 @@ Global {B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}.Debug|Any CPU.Build.0 = Debug|Any CPU {B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}.Release|Any CPU.ActiveCfg = Release|Any CPU {B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}.Release|Any CPU.Build.0 = Release|Any CPU + {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj b/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj new file mode 100644 index 0000000..7aa40d4 --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj @@ -0,0 +1,45 @@ + + + + net10.0 + enable + enable + CodeCasa.NetDaemon.Sensors.Composite + CodeCasa.NetDaemon.Sensors.Composite + Jasper Lammers + DevJasper + High-level composite sensor abstractions for NetDaemon, combining multiple Home Assistant entities into logical observables. + https://github.com/DevJasperNL/CodeCasa + NetDaemon;Home Automation;Home Assistant;Smart Home;Motion;Illuminance;Reactive Programming;Rx;Rx.NET;Observables;C#;.NET 10;Composite Sensors + LICENSE + README.md + True + ccnd_icon.png + https://github.com/DevJasperNL/CodeCasa/releases + + + + + True + \ + + + True + \ + + + True + \ + + + + + + + + + + + + + diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorAttributes.cs b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorAttributes.cs new file mode 100644 index 0000000..1d30cd6 --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorAttributes.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; + +namespace CodeCasa.NetDaemon.Sensors.Composite.Generated; + +internal partial record BinarySensorAttributes +{ + [JsonPropertyName("device_class")] + public string? DeviceClass { get; init; } + + [JsonPropertyName("friendly_name")] + public string? FriendlyName { get; init; } + + [JsonPropertyName("entity_id")] + public IReadOnlyList? EntityId { get; init; } + + [JsonPropertyName("icon")] + public string? Icon { get; init; } + + [JsonPropertyName("restored")] + public bool? Restored { get; init; } + + [JsonPropertyName("supported_features")] + public double? SupportedFeatures { get; init; } +} \ No newline at end of file diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorEntity.cs b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorEntity.cs new file mode 100644 index 0000000..1a7bd3b --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/BinarySensorEntity.cs @@ -0,0 +1,15 @@ +using NetDaemon.HassModel; +using NetDaemon.HassModel.Entities; + +namespace CodeCasa.NetDaemon.Sensors.Composite.Generated; + +internal partial record BinarySensorEntity : Entity, BinarySensorAttributes>, IBinarySensorEntityCore +{ + public BinarySensorEntity(IHaContext haContext, string entityId) : base(haContext, entityId) + { + } + + public BinarySensorEntity(IEntityCore entity) : base(entity) + { + } +} \ No newline at end of file diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorAttributes.cs b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorAttributes.cs new file mode 100644 index 0000000..bfc74fb --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorAttributes.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Serialization; + +namespace CodeCasa.NetDaemon.Sensors.Composite.Generated; + +internal partial record NumericSensorAttributes +{ + [JsonPropertyName("state_class")] + public string? StateClass { get; init; } + + [JsonPropertyName("unit_of_measurement")] + public string? UnitOfMeasurement { get; init; } + + [JsonPropertyName("device_class")] + public string? DeviceClass { get; init; } + + [JsonPropertyName("friendly_name")] + public string? FriendlyName { get; init; } + + [JsonPropertyName("icon")] + public string? Icon { get; init; } + + [JsonPropertyName("Available")] + public string? Available { get; init; } + + [JsonPropertyName("Available (Important)")] + public string? AvailableImportant { get; init; } + + [JsonPropertyName("Available (Opportunistic)")] + public string? AvailableOpportunistic { get; init; } + + [JsonPropertyName("Total")] + public string? Total { get; init; } + + [JsonPropertyName("marker_high_level")] + public double? MarkerHighLevel { get; init; } + + [JsonPropertyName("marker_low_level")] + public double? MarkerLowLevel { get; init; } + + [JsonPropertyName("marker_type")] + public string? MarkerType { get; init; } + + [JsonPropertyName("restored")] + public bool? Restored { get; init; } + + [JsonPropertyName("supported_features")] + public double? SupportedFeatures { get; init; } +} \ No newline at end of file diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorEntity.cs b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorEntity.cs new file mode 100644 index 0000000..aa5f8e5 --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/Generated/NumericSensorEntity.cs @@ -0,0 +1,16 @@ +using NetDaemon.HassModel; +using NetDaemon.HassModel.Entities; + +namespace CodeCasa.NetDaemon.Sensors.Composite.Generated +{ + internal partial record NumericSensorEntity : NumericEntity, NumericSensorAttributes>, ISensorEntityCore + { + public NumericSensorEntity(IHaContext haContext, string entityId) : base(haContext, entityId) + { + } + + public NumericSensorEntity(IEntityCore entity) : base(entity) + { + } + } +} diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs b/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs new file mode 100644 index 0000000..a854a8e --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs @@ -0,0 +1,59 @@ +using System.Reactive; +using System.Reactive.Concurrency; +using System.Reactive.Linq; +using CodeCasa.NetDaemon.Extensions.Observables; +using CodeCasa.NetDaemon.Sensors.Composite.Generated; +using NetDaemon.HassModel.Entities; + +namespace CodeCasa.NetDaemon.Sensors.Composite +{ + public abstract class MotionSensor : IObservable + { + private readonly IScheduler _scheduler; + private readonly IObservable _defaultObservable; + + protected MotionSensor(IScheduler scheduler, + IBinarySensorEntityCore motionOccupancySensor, + ISensorEntityCore motionIlluminanceLuxSensor) + { + _scheduler = scheduler; + MotionOccupancySensor = motionOccupancySensor; + MotionIlluminanceLuxSensor = motionIlluminanceLuxSensor; + + _defaultObservable = CreateMotionObservable(); + } + + public IBinarySensorEntityCore MotionOccupancySensor { get; } + public ISensorEntityCore MotionIlluminanceLuxSensor { get; } + public IObservable Triggered => _defaultObservable.Where(b => b).Select(_ => Unit.Default); + public IObservable Cleared => _defaultObservable.Where(b => !b).Select(_ => Unit.Default); + + public IObservable CreateMotionObservable(double brightnessThreshold = 5, TimeSpan? offDelay = null) + { + offDelay ??= TimeSpan.FromSeconds(60); + + var motionLastXTime = + new BinarySensorEntity(MotionOccupancySensor).PersistOnFor(offDelay.Value, _scheduler); + + var brightnessLessThanX = new NumericSensorEntity(MotionIlluminanceLuxSensor) + .ToBooleanObservable(s => s.State <= brightnessThreshold); + + var triggered = false; + return motionLastXTime.CombineLatest(brightnessLessThanX, (motionTriggered, brightnessTriggered) => + { + if (motionTriggered && brightnessTriggered) + { + triggered = true; + } + else if (!motionTriggered) + { + triggered = false; + } + + return triggered; + }).DistinctUntilChanged(); + } + + public IDisposable Subscribe(IObserver observer) => _defaultObservable.Subscribe(observer); + } +} From fe9c8f5c7e3e29c045c768db0e311be05d3e32ef Mon Sep 17 00:00:00 2001 From: Jasper Date: Wed, 25 Feb 2026 12:55:03 +0100 Subject: [PATCH 2/2] Exposing more logic and writing xml comments. --- .../MotionSensor.cs | 73 ++++++++++++++++++- 1 file changed, 69 insertions(+), 4 deletions(-) diff --git a/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs b/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs index a854a8e..427325a 100644 --- a/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs @@ -7,35 +7,95 @@ namespace CodeCasa.NetDaemon.Sensors.Composite { + /// + /// Provides a base implementation for a reactive motion sensor that integrates illuminance data. + /// + /// + /// This class handles the logic for persistent motion detection, ensuring that motion triggers + /// are light-sensitive upon activation but remain active regardless of light changes until motion ceases. + /// public abstract class MotionSensor : IObservable { private readonly IScheduler _scheduler; + private readonly BinarySensorEntity _binarySensorEntity; + private readonly NumericSensorEntity _numericSensorEntity; + private readonly IObservable _defaultObservable; + /// + /// Initializes a new instance of the class. + /// + /// The scheduler used for time-based operations. + /// The occupancy sensor entity core. + /// The illuminance sensor entity core. protected MotionSensor(IScheduler scheduler, IBinarySensorEntityCore motionOccupancySensor, ISensorEntityCore motionIlluminanceLuxSensor) { _scheduler = scheduler; + _binarySensorEntity = new BinarySensorEntity(motionOccupancySensor); + _numericSensorEntity = new NumericSensorEntity(motionIlluminanceLuxSensor); + MotionOccupancySensor = motionOccupancySensor; MotionIlluminanceLuxSensor = motionIlluminanceLuxSensor; - _defaultObservable = CreateMotionObservable(); + _defaultObservable = CreatePersistentMotionObservable(); } + /// + /// Gets the occupancy sensor entity core. + /// public IBinarySensorEntityCore MotionOccupancySensor { get; } + + /// + /// Gets the illuminance sensor entity core. + /// public ISensorEntityCore MotionIlluminanceLuxSensor { get; } + + /// + /// An event stream that fires once when the motion criteria are first met (low light and movement). + /// + /// + /// Emits a when the persistent motion state transitions from false to true. + /// public IObservable Triggered => _defaultObservable.Where(b => b).Select(_ => Unit.Default); + + /// + /// An event stream that fires once when the motion state is reset. + /// + /// + /// Emits a when the motion sensor's offDelay has expired, + /// signaling that occupancy is no longer detected. + /// public IObservable Cleared => _defaultObservable.Where(b => !b).Select(_ => Unit.Default); - public IObservable CreateMotionObservable(double brightnessThreshold = 5, TimeSpan? offDelay = null) + /// + /// Gets an observable representing the motion state from the occupancy sensor. + /// + public IObservable Motion => _binarySensorEntity.ToBooleanObservable(); + + /// + /// Creates an observable that tracks motion persistence based on a brightness threshold. + /// + /// The maximum brightness level allowed to initially trigger the motion state. + /// The duration to keep the motion state active after the sensor stops detecting movement. Defaults to 60 seconds. + /// + /// An that emits true when motion is detected under the brightness threshold, + /// and remains true until the motion expires. + /// + /// + /// This method implements a "latch" logic: the observable only flips to true if both motion is detected + /// AND brightness is low. However, once triggered, it stays true even if brightness increases, + /// until the motion sensor itself resets. + /// + public IObservable CreatePersistentMotionObservable(double brightnessThreshold = 5, TimeSpan? offDelay = null) { offDelay ??= TimeSpan.FromSeconds(60); var motionLastXTime = - new BinarySensorEntity(MotionOccupancySensor).PersistOnFor(offDelay.Value, _scheduler); + _binarySensorEntity.PersistOnFor(offDelay.Value, _scheduler); - var brightnessLessThanX = new NumericSensorEntity(MotionIlluminanceLuxSensor) + var brightnessLessThanX = _numericSensorEntity .ToBooleanObservable(s => s.State <= brightnessThreshold); var triggered = false; @@ -54,6 +114,11 @@ public IObservable CreateMotionObservable(double brightnessThreshold = 5, }).DistinctUntilChanged(); } + /// + /// Subscribes an observer to the default persistent motion stream. + /// + /// The object that is to receive notifications. + /// A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them. public IDisposable Subscribe(IObserver observer) => _defaultObservable.Subscribe(observer); } }