Skip to content

Commit fc39e7a

Browse files
authored
Improved motion sensor logic and added unit tests (#212)
* Implemented persiststrue method specially for the motion sensor. * Added unit tests for motion sensor. * Updated unit tests * Removed test values.
1 parent 5e3e303 commit fc39e7a

7 files changed

Lines changed: 349 additions & 17 deletions

File tree

CodeCasa.sln

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.Lights.Timelines",
4747
EndProject
4848
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.AutomationPipelines.Lights.Mqtt", "src\CodeCasa.AutomationPipelines.Lights.Mqtt\CodeCasa.AutomationPipelines.Lights.Mqtt.csproj", "{84685AB7-AEAA-41E5-8086-254ACDC5EE6B}"
4949
EndProject
50+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CodeCasa.NetDaemon.Sensors.Composite.Tests", "tests\CodeCasa.NetDaemon.Sensors.Composite.Tests\CodeCasa.NetDaemon.Sensors.Composite.Tests.csproj", "{21BE27AF-3760-4862-9BF4-4B9D3EA93466}"
51+
EndProject
5052
Global
5153
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5254
Debug|Any CPU = Debug|Any CPU
@@ -137,6 +139,10 @@ Global
137139
{84685AB7-AEAA-41E5-8086-254ACDC5EE6B}.Debug|Any CPU.Build.0 = Debug|Any CPU
138140
{84685AB7-AEAA-41E5-8086-254ACDC5EE6B}.Release|Any CPU.ActiveCfg = Release|Any CPU
139141
{84685AB7-AEAA-41E5-8086-254ACDC5EE6B}.Release|Any CPU.Build.0 = Release|Any CPU
142+
{21BE27AF-3760-4862-9BF4-4B9D3EA93466}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
143+
{21BE27AF-3760-4862-9BF4-4B9D3EA93466}.Debug|Any CPU.Build.0 = Debug|Any CPU
144+
{21BE27AF-3760-4862-9BF4-4B9D3EA93466}.Release|Any CPU.ActiveCfg = Release|Any CPU
145+
{21BE27AF-3760-4862-9BF4-4B9D3EA93466}.Release|Any CPU.Build.0 = Release|Any CPU
140146
EndGlobalSection
141147
GlobalSection(SolutionProperties) = preSolution
142148
HideSolutionNode = FALSE
@@ -147,6 +153,7 @@ Global
147153
{FA26C18B-24D0-4F3D-958C-A9BA61861C65} = {5BCD08C3-3034-4D08-AC01-2AB6DFD67C33}
148154
{5C7FB111-095B-F881-7268-4284370C8AAA} = {5BCD08C3-3034-4D08-AC01-2AB6DFD67C33}
149155
{96DB93C1-036A-436A-AF7A-AEC07243A929} = {5BCD08C3-3034-4D08-AC01-2AB6DFD67C33}
156+
{21BE27AF-3760-4862-9BF4-4B9D3EA93466} = {5BCD08C3-3034-4D08-AC01-2AB6DFD67C33}
150157
EndGlobalSection
151158
GlobalSection(ExtensibilityGlobals) = postSolution
152159
SolutionGuid = {5AAE8D3A-9457-4676-8F57-B2D78594CCC7}

src/CodeCasa.NetDaemon.Sensors.Composite/CodeCasa.NetDaemon.Sensors.Composite.csproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>
1919
</PropertyGroup>
2020

21+
<ItemGroup>
22+
<InternalsVisibleTo Include="CodeCasa.NetDaemon.Sensors.Composite.Tests" />
23+
</ItemGroup>
24+
2125
<ItemGroup>
2226
<None Include="..\..\LICENSE">
2327
<Pack>True</Pack>
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Reactive.Concurrency;
2+
using System.Reactive.Linq;
3+
4+
namespace CodeCasa.NetDaemon.Sensors.Composite.Extensions
5+
{
6+
internal static class BooleanObservableExtensions
7+
{
8+
public static IObservable<bool> PersistTrue(
9+
this IObservable<bool> source,
10+
TimeSpan persistentFor,
11+
IScheduler scheduler)
12+
{
13+
return Observable.Create<bool>(observer =>
14+
{
15+
DateTimeOffset? lastTrueAt = null;
16+
17+
return source.Select(value =>
18+
{
19+
var now = scheduler.Now;
20+
21+
if (value)
22+
{
23+
lastTrueAt = now;
24+
return Observable.Return(true);
25+
}
26+
27+
if (lastTrueAt == null)
28+
{
29+
return Observable.Return(false);
30+
}
31+
32+
var elapsed = now - lastTrueAt.Value;
33+
var remaining = persistentFor - elapsed;
34+
35+
return remaining <= TimeSpan.Zero
36+
? Observable.Return(false)
37+
: Observable.Return(false).Delay(remaining, scheduler);
38+
})
39+
.Switch()
40+
.Subscribe(observer);
41+
});
42+
}
43+
44+
public static IObservable<bool> CombineWithBrightness(this IObservable<bool> motion, IObservable<bool> brightnessLessThanThreshold)
45+
{
46+
bool? triggered = null;
47+
return motion.CombineLatest(brightnessLessThanThreshold, (motionTriggered, brightnessTriggered) =>
48+
{
49+
if (motionTriggered && brightnessTriggered)
50+
{
51+
triggered = true;
52+
}
53+
else {
54+
if (triggered == false)
55+
{
56+
// Prevent duplicate false emissions.
57+
return null;
58+
}
59+
60+
if (!motionTriggered)
61+
{
62+
triggered = false;
63+
}
64+
}
65+
66+
return triggered;
67+
}).Where(b => b != null).Select(b => b!.Value);
68+
}
69+
}
70+
}

src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Reactive.Concurrency;
33
using System.Reactive.Linq;
44
using CodeCasa.NetDaemon.Extensions.Observables;
5+
using CodeCasa.NetDaemon.Sensors.Composite.Extensions;
56
using CodeCasa.NetDaemon.Sensors.Composite.Generated;
67
using NetDaemon.HassModel.Entities;
78

@@ -78,7 +79,7 @@ protected MotionSensor(IScheduler scheduler,
7879
/// Creates an observable that tracks motion persistence based on a brightness threshold.
7980
/// </summary>
8081
/// <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+
/// <param name="offDelay">The minimum duration to keep the motion state active after the sensor detects movement. Defaults to 60 seconds.</param>
8283
/// <returns>
8384
/// An <see cref="IObservable{T}"/> that emits <c>true</c> when motion is detected under the brightness threshold,
8485
/// and remains <c>true</c> until the motion <paramref name="offDelay"/> expires.
@@ -92,26 +93,13 @@ public IObservable<bool> CreatePersistentMotionObservable(double brightnessThres
9293
{
9394
offDelay ??= TimeSpan.FromSeconds(60);
9495

95-
var motionLastXTime =
96-
_binarySensorEntity.PersistOnFor(offDelay.Value, _scheduler);
96+
var motion =
97+
_binarySensorEntity.ToBooleanObservable().PersistTrue(offDelay.Value, _scheduler);
9798

9899
var brightnessLessThanX = _numericSensorEntity
99100
.ToBooleanObservable(s => s.State <= brightnessThreshold);
100101

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();
102+
return motion.CombineWithBrightness(brightnessLessThanX);
115103
}
116104

117105
/// <summary>
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
using CodeCasa.NetDaemon.Sensors.Composite.Extensions;
2+
using Microsoft.Reactive.Testing;
3+
using System.Reactive.Linq;
4+
5+
namespace CodeCasa.NetDaemon.Sensors.Composite.Tests
6+
{
7+
[TestClass]
8+
public class BooleanObservableExtensionsTests : ReactiveTest
9+
{
10+
[TestMethod]
11+
public void CombineWithBrightness_ShouldEmitTrue_WhenMotionAndBrightnessAreBothTrue()
12+
{
13+
var scheduler = new TestScheduler();
14+
15+
// Motion: true at Subscribed + 10
16+
// Brightness: true (below threshold) from the start
17+
var motion = scheduler.CreateHotObservable(
18+
OnNext(Subscribed + 10, true)
19+
);
20+
var brightness = scheduler.CreateHotObservable<bool>();
21+
22+
var res = scheduler.Start(() => motion.StartWith(false).CombineWithBrightness(brightness.StartWith(true)));
23+
24+
res.Messages.AssertEqual(
25+
OnNext(Subscribed, false),
26+
OnNext(Subscribed + 10, true)
27+
);
28+
}
29+
30+
[TestMethod]
31+
public void CombineWithBrightness_ShouldNotEmitTrue_WhenMotionTrueButBrightnessAboveThreshold()
32+
{
33+
var scheduler = new TestScheduler();
34+
35+
// Motion: true at Subscribed + 10
36+
// Brightness: false (above threshold) from the start
37+
var motion = scheduler.CreateHotObservable(
38+
OnNext(Subscribed + 10, true)
39+
);
40+
var brightness = scheduler.CreateHotObservable<bool>();
41+
42+
var res = scheduler.Start(() => motion.StartWith(false).CombineWithBrightness(brightness.StartWith(false)));
43+
44+
// (true, false): motion triggered but brightness too high, latch stays null and nothing additional is emitted
45+
res.Messages.AssertEqual(
46+
OnNext(Subscribed, false)
47+
);
48+
}
49+
50+
[TestMethod]
51+
public void CombineWithBrightness_ShouldEmitFalse_WhenMotionClears()
52+
{
53+
var scheduler = new TestScheduler();
54+
55+
// Motion: true at Subscribed + 10, false at Subscribed + 50
56+
// Brightness: always true (below threshold)
57+
var motion = scheduler.CreateHotObservable(
58+
OnNext(Subscribed + 10, true),
59+
OnNext(Subscribed + 50, false)
60+
);
61+
var brightness = scheduler.CreateHotObservable<bool>();
62+
63+
var res = scheduler.Start(() => motion.StartWith(false).CombineWithBrightness(brightness.StartWith(true)));
64+
65+
res.Messages.AssertEqual(
66+
OnNext(Subscribed, false),
67+
OnNext(Subscribed + 10, true),
68+
OnNext(Subscribed + 50, false)
69+
);
70+
}
71+
72+
[TestMethod]
73+
public void CombineWithBrightness_ShouldStayTrue_WhenBrightnessIncreasesAfterTrigger()
74+
{
75+
var scheduler = new TestScheduler();
76+
77+
// Motion: true at Subscribed + 10, false at Subscribed + 80
78+
// Brightness: true initially, flips to false at Subscribed + 30 (light came on)
79+
var motion = scheduler.CreateHotObservable(
80+
OnNext(Subscribed + 10, true),
81+
OnNext(Subscribed + 80, false)
82+
);
83+
var brightness = scheduler.CreateHotObservable(
84+
OnNext(Subscribed + 30, false)
85+
);
86+
87+
var res = scheduler.Start(() => motion.StartWith(false).CombineWithBrightness(brightness.StartWith(true)));
88+
89+
// Triggered at +10, brightness change at +30 re-emits the latched true (does not reset), only motion clearing at +80 resets
90+
res.Messages.AssertEqual(
91+
OnNext(Subscribed, false),
92+
OnNext(Subscribed + 10, true),
93+
OnNext(Subscribed + 30, true),
94+
OnNext(Subscribed + 80, false)
95+
);
96+
}
97+
98+
[TestMethod]
99+
public void CombineWithBrightness_ShouldNotEmitDuplicateFalse_WhenNotYetTriggered()
100+
{
101+
var scheduler = new TestScheduler();
102+
103+
// Motion stays false; brightness changes several times — no true should ever be emitted
104+
var motion = scheduler.CreateHotObservable<bool>();
105+
var brightness = scheduler.CreateHotObservable(
106+
OnNext(Subscribed + 20, true),
107+
OnNext(Subscribed + 40, false)
108+
);
109+
110+
var res = scheduler.Start(() => motion.StartWith(false).CombineWithBrightness(brightness.StartWith(false)));
111+
112+
// No emissions at all: triggered was never set so false is suppressed
113+
res.Messages.AssertEqual(
114+
OnNext(Subscribed, false)
115+
);
116+
}
117+
118+
[TestMethod]
119+
public void CombineWithBrightness_ShouldTriggerAgain_AfterMotionClearsAndRetriggers()
120+
{
121+
var scheduler = new TestScheduler();
122+
123+
// First trigger cycle: motion true at +10, false at +50
124+
// Second trigger cycle: motion true at +100, false at +150
125+
// Brightness always below threshold
126+
var motion = scheduler.CreateHotObservable(
127+
OnNext(Subscribed + 10, true),
128+
OnNext(Subscribed + 50, false),
129+
OnNext(Subscribed + 100, true),
130+
OnNext(Subscribed + 150, false)
131+
);
132+
var brightness = scheduler.CreateHotObservable<bool>();
133+
134+
var res = scheduler.Start(() => motion.StartWith(false).CombineWithBrightness(brightness.StartWith(true)));
135+
136+
res.Messages.AssertEqual(
137+
OnNext(Subscribed, false),
138+
OnNext(Subscribed + 10, true),
139+
OnNext(Subscribed + 50, false),
140+
OnNext(Subscribed + 100, true),
141+
OnNext(Subscribed + 150, false)
142+
);
143+
}
144+
145+
[TestMethod]
146+
public void PersistTrue_ShouldDelayFalse_WhenEmittedBeforePersistentTimeout()
147+
{
148+
var scheduler = new TestScheduler();
149+
var persistentFor = TimeSpan.FromTicks(100);
150+
151+
// Input: False at Subscribed (initial, simulates BehaviorSubject), True at Subscribed + 10, False at Subscribed + 50 (before persistentFor)
152+
var source = scheduler.CreateHotObservable(
153+
OnNext(Subscribed + 10, true),
154+
OnNext(Subscribed + 50, false)
155+
);
156+
157+
var res = scheduler.Start(() => source.StartWith(false).PersistTrue(persistentFor, scheduler));
158+
159+
// Expected: False at Subscribed (initial), True at Subscribed + 10, False at Subscribed + 110 (Subscribed + 10 + persistentFor)
160+
res.Messages.AssertEqual(
161+
OnNext(Subscribed, false),
162+
OnNext(Subscribed + 10, true),
163+
OnNext(Subscribed + 110, false)
164+
);
165+
}
166+
167+
[TestMethod]
168+
public void PersistTrue_ShouldReset_WhenNewTrueEmittedDuringDelay()
169+
{
170+
var scheduler = new TestScheduler();
171+
var persistentFor = TimeSpan.FromTicks(100);
172+
173+
// Input: False at Subscribed (initial, simulates BehaviorSubject), True at Subscribed + 10, False at Subscribed + 50, True at Subscribed + 80 (resets window), False at Subscribed + 90
174+
var source = scheduler.CreateHotObservable(
175+
OnNext(Subscribed + 10, true),
176+
OnNext(Subscribed + 50, false),
177+
OnNext(Subscribed + 80, true),
178+
OnNext(Subscribed + 90, false)
179+
);
180+
181+
var res = scheduler.Start(() => source.StartWith(false).PersistTrue(persistentFor, scheduler));
182+
183+
// Expected: False at Subscribed (initial), True at Subscribed + 10, True at Subscribed + 80, False at Subscribed + 180 (Subscribed + 80 + persistentFor)
184+
res.Messages.AssertEqual(
185+
OnNext(Subscribed, false),
186+
OnNext(Subscribed + 10, true),
187+
OnNext(Subscribed + 80, true),
188+
OnNext(Subscribed + 180, false)
189+
);
190+
}
191+
192+
[TestMethod]
193+
public void PersistTrue_ShouldEmitFalseImmediately_IfAfterPersistentTimeout()
194+
{
195+
var scheduler = new TestScheduler();
196+
var persistentFor = TimeSpan.FromTicks(100);
197+
198+
// Input: False at Subscribed (initial, simulates BehaviorSubject), True at Subscribed + 10, False at Subscribed + 150 (after persistentFor has elapsed)
199+
var source = scheduler.CreateHotObservable(
200+
OnNext(Subscribed + 10, true),
201+
OnNext(Subscribed + 150, false)
202+
);
203+
204+
var res = scheduler.Start(() => source.StartWith(false).PersistTrue(persistentFor, scheduler));
205+
206+
// Expected: False at Subscribed (initial), True at Subscribed + 10, False at Subscribed + 150 (immediate, persistentFor already elapsed)
207+
res.Messages.AssertEqual(
208+
OnNext(Subscribed, false),
209+
OnNext(Subscribed + 10, true),
210+
OnNext(Subscribed + 150, false)
211+
);
212+
}
213+
214+
[TestMethod]
215+
public void PersistTrue_ShouldEmitFalseImmediately_IfAfterPersistentTimeout_AfterReset()
216+
{
217+
var scheduler = new TestScheduler();
218+
var persistentFor = TimeSpan.FromTicks(100);
219+
220+
// Input: False at Subscribed (initial, simulates BehaviorSubject), True at Subscribed + 10, False at Subscribed + 50, True at Subscribed + 80 (resets window), False at Subscribed + 90
221+
var source = scheduler.CreateHotObservable(
222+
OnNext(Subscribed + 10, true),
223+
OnNext(Subscribed + 50, false),
224+
OnNext(Subscribed + 80, true),
225+
OnNext(Subscribed + 230, false)
226+
);
227+
228+
var res = scheduler.Start(() => source.StartWith(false).PersistTrue(persistentFor, scheduler));
229+
230+
// Expected: False at Subscribed (initial), True at Subscribed + 10, True at Subscribed + 80, False at Subscribed + 180 (Subscribed + 80 + persistentFor)
231+
res.Messages.AssertEqual(
232+
OnNext(Subscribed, false),
233+
OnNext(Subscribed + 10, true),
234+
OnNext(Subscribed + 80, true),
235+
OnNext(Subscribed + 230, false)
236+
);
237+
}
238+
}
239+
}

0 commit comments

Comments
 (0)