Skip to content

Commit 71e9635

Browse files
authored
Implementing motion sensor in CodeCasa.NetDaemon.Sensors.Composite (#203)
* Implementing motion sensor in CodeCasa.NetDaemon.Sensors.Composite. * Exposing more logic and writing xml comments.
1 parent 8177bcd commit 71e9635

7 files changed

Lines changed: 278 additions & 0 deletions

File tree

CodeCasa.sln

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.AutomationPipeline
4141
EndProject
4242
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Notifications.Lights", "src\CodeCasa.Notifications.Lights\CodeCasa.Notifications.Lights.csproj", "{B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}"
4343
EndProject
44+
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}"
45+
EndProject
4446
Global
4547
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4648
Debug|Any CPU = Debug|Any CPU
@@ -119,6 +121,10 @@ Global
119121
{B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
120122
{B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
121123
{B79CCDE0-2EB5-453B-B42C-B52014D8F3D6}.Release|Any CPU.Build.0 = Release|Any CPU
124+
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
125+
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Debug|Any CPU.Build.0 = Debug|Any CPU
126+
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.ActiveCfg = Release|Any CPU
127+
{725ED4CC-2360-4FB5-A199-F1F42B7526B1}.Release|Any CPU.Build.0 = Release|Any CPU
122128
EndGlobalSection
123129
GlobalSection(SolutionProperties) = preSolution
124130
HideSolutionNode = FALSE
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net10.0</TargetFramework>
5+
<ImplicitUsings>enable</ImplicitUsings>
6+
<Nullable>enable</Nullable>
7+
<Title>CodeCasa.NetDaemon.Sensors.Composite</Title>
8+
<PackageId>CodeCasa.NetDaemon.Sensors.Composite</PackageId>
9+
<Authors>Jasper Lammers</Authors>
10+
<Company>DevJasper</Company>
11+
<Description>High-level composite sensor abstractions for NetDaemon, combining multiple Home Assistant entities into logical observables.</Description>
12+
<RepositoryUrl>https://github.com/DevJasperNL/CodeCasa</RepositoryUrl>
13+
<PackageTags>NetDaemon;Home Automation;Home Assistant;Smart Home;Motion;Illuminance;Reactive Programming;Rx;Rx.NET;Observables;C#;.NET 10;Composite Sensors</PackageTags>
14+
<PackageLicenseFile>LICENSE</PackageLicenseFile>
15+
<PackageReadmeFile>README.md</PackageReadmeFile>
16+
<GenerateDocumentationFile>True</GenerateDocumentationFile>
17+
<PackageIcon>ccnd_icon.png</PackageIcon>
18+
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>
19+
</PropertyGroup>
20+
21+
<ItemGroup>
22+
<None Include="..\..\LICENSE">
23+
<Pack>True</Pack>
24+
<PackagePath>\</PackagePath>
25+
</None>
26+
<None Include="..\..\README.md">
27+
<Pack>True</Pack>
28+
<PackagePath>\</PackagePath>
29+
</None>
30+
<None Include="..\..\img\ccnd_icon.png">
31+
<Pack>True</Pack>
32+
<PackagePath>\</PackagePath>
33+
</None>
34+
</ItemGroup>
35+
36+
<ItemGroup>
37+
<PackageReference Include="NetDaemon.HassModel" Version="26.3.0" />
38+
<PackageReference Include="System.Reactive" Version="6.1.0" />
39+
</ItemGroup>
40+
41+
<ItemGroup>
42+
<ProjectReference Include="..\CodeCasa.NetDaemon.Extensions.Observables\CodeCasa.NetDaemon.Extensions.Observables.csproj" />
43+
</ItemGroup>
44+
45+
</Project>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace CodeCasa.NetDaemon.Sensors.Composite.Generated;
4+
5+
internal partial record BinarySensorAttributes
6+
{
7+
[JsonPropertyName("device_class")]
8+
public string? DeviceClass { get; init; }
9+
10+
[JsonPropertyName("friendly_name")]
11+
public string? FriendlyName { get; init; }
12+
13+
[JsonPropertyName("entity_id")]
14+
public IReadOnlyList<string>? EntityId { get; init; }
15+
16+
[JsonPropertyName("icon")]
17+
public string? Icon { get; init; }
18+
19+
[JsonPropertyName("restored")]
20+
public bool? Restored { get; init; }
21+
22+
[JsonPropertyName("supported_features")]
23+
public double? SupportedFeatures { get; init; }
24+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using NetDaemon.HassModel;
2+
using NetDaemon.HassModel.Entities;
3+
4+
namespace CodeCasa.NetDaemon.Sensors.Composite.Generated;
5+
6+
internal partial record BinarySensorEntity : Entity<BinarySensorEntity, EntityState<BinarySensorAttributes>, BinarySensorAttributes>, IBinarySensorEntityCore
7+
{
8+
public BinarySensorEntity(IHaContext haContext, string entityId) : base(haContext, entityId)
9+
{
10+
}
11+
12+
public BinarySensorEntity(IEntityCore entity) : base(entity)
13+
{
14+
}
15+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace CodeCasa.NetDaemon.Sensors.Composite.Generated;
4+
5+
internal partial record NumericSensorAttributes
6+
{
7+
[JsonPropertyName("state_class")]
8+
public string? StateClass { get; init; }
9+
10+
[JsonPropertyName("unit_of_measurement")]
11+
public string? UnitOfMeasurement { get; init; }
12+
13+
[JsonPropertyName("device_class")]
14+
public string? DeviceClass { get; init; }
15+
16+
[JsonPropertyName("friendly_name")]
17+
public string? FriendlyName { get; init; }
18+
19+
[JsonPropertyName("icon")]
20+
public string? Icon { get; init; }
21+
22+
[JsonPropertyName("Available")]
23+
public string? Available { get; init; }
24+
25+
[JsonPropertyName("Available (Important)")]
26+
public string? AvailableImportant { get; init; }
27+
28+
[JsonPropertyName("Available (Opportunistic)")]
29+
public string? AvailableOpportunistic { get; init; }
30+
31+
[JsonPropertyName("Total")]
32+
public string? Total { get; init; }
33+
34+
[JsonPropertyName("marker_high_level")]
35+
public double? MarkerHighLevel { get; init; }
36+
37+
[JsonPropertyName("marker_low_level")]
38+
public double? MarkerLowLevel { get; init; }
39+
40+
[JsonPropertyName("marker_type")]
41+
public string? MarkerType { get; init; }
42+
43+
[JsonPropertyName("restored")]
44+
public bool? Restored { get; init; }
45+
46+
[JsonPropertyName("supported_features")]
47+
public double? SupportedFeatures { get; init; }
48+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using NetDaemon.HassModel;
2+
using NetDaemon.HassModel.Entities;
3+
4+
namespace CodeCasa.NetDaemon.Sensors.Composite.Generated
5+
{
6+
internal partial record NumericSensorEntity : NumericEntity<NumericSensorEntity, NumericEntityState<NumericSensorAttributes>, NumericSensorAttributes>, ISensorEntityCore
7+
{
8+
public NumericSensorEntity(IHaContext haContext, string entityId) : base(haContext, entityId)
9+
{
10+
}
11+
12+
public NumericSensorEntity(IEntityCore entity) : base(entity)
13+
{
14+
}
15+
}
16+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
using System.Reactive;
2+
using System.Reactive.Concurrency;
3+
using System.Reactive.Linq;
4+
using CodeCasa.NetDaemon.Extensions.Observables;
5+
using CodeCasa.NetDaemon.Sensors.Composite.Generated;
6+
using NetDaemon.HassModel.Entities;
7+
8+
namespace CodeCasa.NetDaemon.Sensors.Composite
9+
{
10+
/// <summary>
11+
/// Provides a base implementation for a reactive motion sensor that integrates illuminance data.
12+
/// </summary>
13+
/// <remarks>
14+
/// This class handles the logic for persistent motion detection, ensuring that motion triggers
15+
/// are light-sensitive upon activation but remain active regardless of light changes until motion ceases.
16+
/// </remarks>
17+
public abstract class MotionSensor : IObservable<bool>
18+
{
19+
private readonly IScheduler _scheduler;
20+
private readonly BinarySensorEntity _binarySensorEntity;
21+
private readonly NumericSensorEntity _numericSensorEntity;
22+
23+
private readonly IObservable<bool> _defaultObservable;
24+
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="MotionSensor"/> class.
27+
/// </summary>
28+
/// <param name="scheduler">The scheduler used for time-based operations.</param>
29+
/// <param name="motionOccupancySensor">The occupancy sensor entity core.</param>
30+
/// <param name="motionIlluminanceLuxSensor">The illuminance sensor entity core.</param>
31+
protected MotionSensor(IScheduler scheduler,
32+
IBinarySensorEntityCore motionOccupancySensor,
33+
ISensorEntityCore motionIlluminanceLuxSensor)
34+
{
35+
_scheduler = scheduler;
36+
_binarySensorEntity = new BinarySensorEntity(motionOccupancySensor);
37+
_numericSensorEntity = new NumericSensorEntity(motionIlluminanceLuxSensor);
38+
39+
MotionOccupancySensor = motionOccupancySensor;
40+
MotionIlluminanceLuxSensor = motionIlluminanceLuxSensor;
41+
42+
_defaultObservable = CreatePersistentMotionObservable();
43+
}
44+
45+
/// <summary>
46+
/// Gets the occupancy sensor entity core.
47+
/// </summary>
48+
public IBinarySensorEntityCore MotionOccupancySensor { get; }
49+
50+
/// <summary>
51+
/// Gets the illuminance sensor entity core.
52+
/// </summary>
53+
public ISensorEntityCore MotionIlluminanceLuxSensor { get; }
54+
55+
/// <summary>
56+
/// An event stream that fires once when the motion criteria are first met (low light and movement).
57+
/// </summary>
58+
/// <remarks>
59+
/// Emits a <see cref="Unit"/> when the persistent motion state transitions from <c>false</c> to <c>true</c>.
60+
/// </remarks>
61+
public IObservable<Unit> Triggered => _defaultObservable.Where(b => b).Select(_ => Unit.Default);
62+
63+
/// <summary>
64+
/// An event stream that fires once when the motion state is reset.
65+
/// </summary>
66+
/// <remarks>
67+
/// Emits a <see cref="Unit"/> when the motion sensor's <c>offDelay</c> has expired,
68+
/// signaling that occupancy is no longer detected.
69+
/// </remarks>
70+
public IObservable<Unit> Cleared => _defaultObservable.Where(b => !b).Select(_ => Unit.Default);
71+
72+
/// <summary>
73+
/// Gets an observable representing the motion state from the occupancy sensor.
74+
/// </summary>
75+
public IObservable<bool> Motion => _binarySensorEntity.ToBooleanObservable();
76+
77+
/// <summary>
78+
/// Creates an observable that tracks motion persistence based on a brightness threshold.
79+
/// </summary>
80+
/// <param name="brightnessThreshold">The maximum brightness level allowed to initially trigger the motion state.</param>
81+
/// <param name="offDelay">The duration to keep the motion state active after the sensor stops detecting movement. Defaults to 60 seconds.</param>
82+
/// <returns>
83+
/// An <see cref="IObservable{T}"/> that emits <c>true</c> when motion is detected under the brightness threshold,
84+
/// and remains <c>true</c> until the motion <paramref name="offDelay"/> expires.
85+
/// </returns>
86+
/// <remarks>
87+
/// This method implements a "latch" logic: the observable only flips to <c>true</c> if both motion is detected
88+
/// AND brightness is low. However, once triggered, it stays <c>true</c> even if brightness increases,
89+
/// until the motion sensor itself resets.
90+
/// </remarks>
91+
public IObservable<bool> CreatePersistentMotionObservable(double brightnessThreshold = 5, TimeSpan? offDelay = null)
92+
{
93+
offDelay ??= TimeSpan.FromSeconds(60);
94+
95+
var motionLastXTime =
96+
_binarySensorEntity.PersistOnFor(offDelay.Value, _scheduler);
97+
98+
var brightnessLessThanX = _numericSensorEntity
99+
.ToBooleanObservable(s => s.State <= brightnessThreshold);
100+
101+
var triggered = false;
102+
return motionLastXTime.CombineLatest(brightnessLessThanX, (motionTriggered, brightnessTriggered) =>
103+
{
104+
if (motionTriggered && brightnessTriggered)
105+
{
106+
triggered = true;
107+
}
108+
else if (!motionTriggered)
109+
{
110+
triggered = false;
111+
}
112+
113+
return triggered;
114+
}).DistinctUntilChanged();
115+
}
116+
117+
/// <summary>
118+
/// Subscribes an observer to the default persistent motion stream.
119+
/// </summary>
120+
/// <param name="observer">The object that is to receive notifications.</param>
121+
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
122+
public IDisposable Subscribe(IObserver<bool> observer) => _defaultObservable.Subscribe(observer);
123+
}
124+
}

0 commit comments

Comments
 (0)