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);
+ }
+}