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..427325a --- /dev/null +++ b/src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs @@ -0,0 +1,124 @@ +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 +{ + /// + /// 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 = 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); + + /// + /// 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 = + _binarySensorEntity.PersistOnFor(offDelay.Value, _scheduler); + + var brightnessLessThanX = _numericSensorEntity + .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(); + } + + /// + /// 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); + } +}