Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CodeCasa.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Title>CodeCasa.NetDaemon.Sensors.Composite</Title>
<PackageId>CodeCasa.NetDaemon.Sensors.Composite</PackageId>
<Authors>Jasper Lammers</Authors>
<Company>DevJasper</Company>
<Description>High-level composite sensor abstractions for NetDaemon, combining multiple Home Assistant entities into logical observables.</Description>
<RepositoryUrl>https://github.com/DevJasperNL/CodeCasa</RepositoryUrl>
<PackageTags>NetDaemon;Home Automation;Home Assistant;Smart Home;Motion;Illuminance;Reactive Programming;Rx;Rx.NET;Observables;C#;.NET 10;Composite Sensors</PackageTags>
<PackageLicenseFile>LICENSE</PackageLicenseFile>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<PackageIcon>ccnd_icon.png</PackageIcon>
<PackageReleaseNotes>https://github.com/DevJasperNL/CodeCasa/releases</PackageReleaseNotes>
</PropertyGroup>

<ItemGroup>
<None Include="..\..\LICENSE">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\README.md">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
<None Include="..\..\img\ccnd_icon.png">
<Pack>True</Pack>
<PackagePath>\</PackagePath>
</None>
</ItemGroup>

<ItemGroup>
<PackageReference Include="NetDaemon.HassModel" Version="26.3.0" />
<PackageReference Include="System.Reactive" Version="6.1.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CodeCasa.NetDaemon.Extensions.Observables\CodeCasa.NetDaemon.Extensions.Observables.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -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<string>? 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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using NetDaemon.HassModel;
using NetDaemon.HassModel.Entities;

namespace CodeCasa.NetDaemon.Sensors.Composite.Generated;

internal partial record BinarySensorEntity : Entity<BinarySensorEntity, EntityState<BinarySensorAttributes>, BinarySensorAttributes>, IBinarySensorEntityCore
{
public BinarySensorEntity(IHaContext haContext, string entityId) : base(haContext, entityId)
{
}

public BinarySensorEntity(IEntityCore entity) : base(entity)
{
}
}
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using NetDaemon.HassModel;
using NetDaemon.HassModel.Entities;

namespace CodeCasa.NetDaemon.Sensors.Composite.Generated
{
internal partial record NumericSensorEntity : NumericEntity<NumericSensorEntity, NumericEntityState<NumericSensorAttributes>, NumericSensorAttributes>, ISensorEntityCore
{
public NumericSensorEntity(IHaContext haContext, string entityId) : base(haContext, entityId)
{
}

public NumericSensorEntity(IEntityCore entity) : base(entity)
{
}
}
}
124 changes: 124 additions & 0 deletions src/CodeCasa.NetDaemon.Sensors.Composite/MotionSensor.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Provides a base implementation for a reactive motion sensor that integrates illuminance data.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public abstract class MotionSensor : IObservable<bool>
{
private readonly IScheduler _scheduler;
private readonly BinarySensorEntity _binarySensorEntity;
private readonly NumericSensorEntity _numericSensorEntity;

private readonly IObservable<bool> _defaultObservable;

/// <summary>
/// Initializes a new instance of the <see cref="MotionSensor"/> class.
/// </summary>
/// <param name="scheduler">The scheduler used for time-based operations.</param>
/// <param name="motionOccupancySensor">The occupancy sensor entity core.</param>
/// <param name="motionIlluminanceLuxSensor">The illuminance sensor entity core.</param>
protected MotionSensor(IScheduler scheduler,
IBinarySensorEntityCore motionOccupancySensor,
ISensorEntityCore motionIlluminanceLuxSensor)
{
_scheduler = scheduler;
_binarySensorEntity = new BinarySensorEntity(motionOccupancySensor);
_numericSensorEntity = new NumericSensorEntity(motionIlluminanceLuxSensor);

MotionOccupancySensor = motionOccupancySensor;
MotionIlluminanceLuxSensor = motionIlluminanceLuxSensor;

_defaultObservable = CreatePersistentMotionObservable();
}

/// <summary>
/// Gets the occupancy sensor entity core.
/// </summary>
public IBinarySensorEntityCore MotionOccupancySensor { get; }

/// <summary>
/// Gets the illuminance sensor entity core.
/// </summary>
public ISensorEntityCore MotionIlluminanceLuxSensor { get; }

/// <summary>
/// An event stream that fires once when the motion criteria are first met (low light and movement).
/// </summary>
/// <remarks>
/// Emits a <see cref="Unit"/> when the persistent motion state transitions from <c>false</c> to <c>true</c>.
/// </remarks>
public IObservable<Unit> Triggered => _defaultObservable.Where(b => b).Select(_ => Unit.Default);

/// <summary>
/// An event stream that fires once when the motion state is reset.
/// </summary>
/// <remarks>
/// Emits a <see cref="Unit"/> when the motion sensor's <c>offDelay</c> has expired,
/// signaling that occupancy is no longer detected.
/// </remarks>
public IObservable<Unit> Cleared => _defaultObservable.Where(b => !b).Select(_ => Unit.Default);

/// <summary>
/// Gets an observable representing the motion state from the occupancy sensor.
/// </summary>
public IObservable<bool> Motion => _binarySensorEntity.ToBooleanObservable();

/// <summary>
/// Creates an observable that tracks motion persistence based on a brightness threshold.
/// </summary>
/// <param name="brightnessThreshold">The maximum brightness level allowed to initially trigger the motion state.</param>
/// <param name="offDelay">The duration to keep the motion state active after the sensor stops detecting movement. Defaults to 60 seconds.</param>
/// <returns>
/// An <see cref="IObservable{T}"/> that emits <c>true</c> when motion is detected under the brightness threshold,
/// and remains <c>true</c> until the motion <paramref name="offDelay"/> expires.
/// </returns>
/// <remarks>
/// This method implements a "latch" logic: the observable only flips to <c>true</c> if both motion is detected
/// AND brightness is low. However, once triggered, it stays <c>true</c> even if brightness increases,
/// until the motion sensor itself resets.
/// </remarks>
public IObservable<bool> 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();
}

/// <summary>
/// Subscribes an observer to the default persistent motion stream.
/// </summary>
/// <param name="observer">The object that is to receive notifications.</param>
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
public IDisposable Subscribe(IObserver<bool> observer) => _defaultObservable.Subscribe(observer);
}
}