An unofficial, community-maintained extension for NetDaemon
A fluent device/entity mapping framework for NetDaemon apps. It sits on top of IMqttEntityManager and
lets an app describe a device model's properties and methods as Home Assistant entities, declaratively,
instead of calling the MQTT entity manager by hand.
dotnet add package Zyga.NetDaemon.Fluent.EntitiesTargets .NET 10 only — this follows from NetDaemon.HassModel/NetDaemon.Extensions.Mqtt (the APIs this
library sits on top of), which ship net10.0 builds only.
A complete, runnable example lives in samples/NetDaemon.Fluent.Entities.Sample —
one device model mapping every entity kind the library supports (sensor, binary sensor, switch,
temperature sensor, timestamp sensor, button, template sensor), built and compile-checked by CI on every
push. Point its appsettings.json at a real HA/MQTT instance to run it.
- A device model is a plain class (e.g.
Tank) whose properties/methods compute the values you want to expose. - A
DeviceMap<TDevice>subclass declares, in its constructor, which members ofTDevicemap to which HA entities, using a fluent API:AsDevice(...), thenMap(...)chained withWith*()andAs*(). - An app calls
IDeviceService.Init<TMap>()once, typically fromIAsyncInitializable.InitializeAsync.DeviceServicewalks the map's entities and dispatches each one to the matchingIEntityInitializer, which creates the MQTT discovery entity and, for sensors and template sensors, keeps it updated.
// TankApp.cs
public class TankApp : IAsyncInitializable
{
private readonly IDeviceService _deviceService;
public async Task InitializeAsync(CancellationToken cancellationToken)
{
await _deviceService.Init<TankMap>();
}
}Every map starts by naming the HA device the entities below it will belong to:
public class TankMap : DeviceMap<Model.Tank>
{
public TankMap(Entities entities, IHaContext haContext, TankConfig config)
{
AsDevice("tank_nd")
.WithFriendlyName("Tank");
...
}
}AsDevice(identifier) registers a single instance of TDevice. When several instances of the same
device model type need to exist side by side, use the keyed overload, AsDevice(identifier, key), and
address each instance afterwards through the *Keyed members of IDeviceService — see the
reference table below:
// Two DeviceMap<TDevice> instances mapping the same device model, keyed to tell them apart
public class VitotrolRadiatorCircuitMap : DeviceMap<VitotrolCircuit>
{
public VitotrolRadiatorCircuitMap()
{
AsDevice("vitotrol_radiators", VitotrolGroup.Radiators)
.WithFriendlyName("Vitotrol Radiators");
...
}
}Map(...) is overloaded by the device model member's shape. Each overload returns a typed fluent root
exposing only the As*() terminals that make sense for that type.
| Device model member | Map(...) overload |
Returns |
|---|---|---|
int property |
Map(Expression<Func<TDevice, int>>, string) |
GenericPropertyRoot<TDevice, int> |
string property |
Map(Expression<Func<TDevice, string>>, string) |
GenericPropertyRoot<TDevice, string> |
double property |
Map(Expression<Func<TDevice, double>>, string) |
GenericPropertyRoot<TDevice, double> |
DateTime property |
Map(Expression<Func<TDevice, DateTime>>, string) |
DateTimePropertyRoot<TDevice> |
bool? property |
Map(Expression<Func<TDevice, bool?>>, string) |
BooleanPropertyRoot<TDevice> |
single-parameter bool? method |
Map(Expression<Func<TDevice, bool?, bool?>>, string) |
SingleParameterMethodRoot<TDevice, bool?, bool?> |
parameterless void method |
Map(Expression<Action<TDevice>>, string) |
ActionRoot |
// int property -> AsSensor()
Map(x => x.ModeId, "mode_id")
.WithFriendlyName("Mode Id")
.AsSensor()
.WithId(92);
// DateTime property -> AsTimestamp()
Map(x => x.CurrentDate, "current_date")
.WithFriendlyName("Current Date")
.AsTimestamp()
.WithId(5385);
// bool? property -> AsSwitch()
Map(x => x.Channel1, "channel1")
.AsSwitch().InitiallyOn();
// parameterless action method -> AsButton()
Map(x => x.ToggleGate(), "gate")
.WithFriendlyName("Front Gate")
.AsButton();(string/double properties follow the same GenericPropertyRoot<TDevice, T> shape as int above -
.AsSensor()/.AsTemplateSensor() - and bool? properties also support .AsBinarySensor() alongside
.AsSwitch().)
The entity id's second argument ("mode_id", "channel1", ...) can be a short id or a fully qualified one
matching the terminal's HA domain prefix; either way it is composed with the device id into
{prefix}.{deviceId}_{name} and validated against ^[a-z0-9_]+\.[a-z0-9_]+$.
A model method with no parameters that returns bool? or string has exactly the same delegate type,
Func<TDevice, bool?>, as a lambda reading a bool? property - so C#'s overload resolution routes both
to the same Map(Expression<Func<TDevice, bool?>>, string) overload, reaching AsTemplateSensor() from
there the same as if it had been a property. (The reverse terminals - AsSensor(), AsSwitch(), etc. -
call GetProperty() internally and only work with an actual property access, not a method call; only
AsTemplateSensor()'s GetMethodInfo() accepts a method call.) The explicit single-parameter overload
(Map(Expression<Func<TDevice, bool?, bool?>>, string)) exists for methods that take the triggering
entity's current (converted) state as their argument, and is likewise completed with
.AsTemplateSensor() - except it returns ISingleTriggerSensor, so it accepts exactly one
TriggeredBy(...) rather than Triggered.By(...) chaining.
Each Map(...) overload exposes a subset of these, matching what makes sense for the mapped member's type.
| Terminal | HA domain | Device class | Extra chained calls | Backed by |
|---|---|---|---|---|
AsSensor() |
sensor |
none | WithId(int) |
property |
AsBinarySensor() |
binary_sensor |
none | WithId(int) |
property |
AsSwitch() |
switch |
none | InitiallyOn() / InitiallyOff() |
property |
AsTemperatureSensor() |
sensor |
temperature (°C, thermometer icon) |
WithId(int) |
property |
AsTimestamp() |
sensor |
timestamp |
WithId(int) |
property |
AsButton() |
button |
none | — | parameterless method |
AsTemplateSensor() |
sensor or binary_sensor (inferred from the method's return type) |
none | TriggeredBy(...) / Triggered.By(...), StableForSeconds(int), GetFullEntityId() |
method |
Detail on each, with examples:
-
AsSensor()- a plain HAsensor, no device class.Map(x => x.ModeId, "mode_id") .WithFriendlyName("Mode Id") .AsSensor() .WithId(92);
-
AsBinarySensor()- a HAbinary_sensorbacked directly by abool?property (as opposed to a template sensor computed from a method). -
AsSwitch()- a HAswitch; chainInitiallyOn()/InitiallyOff()to set its state when created.Map(x => x.Channel1, "channel1") .AsSwitch().InitiallyOn();
-
AsTemperatureSensor()- a HAsensorwith thetemperaturedevice class,°Cunit, and a thermometer icon applied automatically.Map(x => x.ExhaustGasTemperature, "exhaust_gas_temp") .WithFriendlyName("Exhaust Gas Temperature") .AsTemperatureSensor() .WithId(5372);
-
AsTimestamp()- a HAsensorwith thetimestampdevice class.Map(x => x.CurrentDate, "current_date") .WithFriendlyName("Current Date") .AsTimestamp() .WithId(5385);
-
AsButton()- a HAbuttonthat invokes the mapped parameterless method when pressed.Map(x => x.ToggleGate(), "gate") .WithFriendlyName("Front Gate") .AsButton();
-
AsTemplateSensor()- a push-based sensor whose value is (re)computed by invoking the mapped method whenever a triggering entity changes state (see the next section):var overloadUp = Map(x => x.OverloadUp(), "overload_up") .WithFriendlyName("Overload Up") .AsTemplateSensor() .TriggeredBy(entities.BinarySensor.SomeUpstreamSensor) .GetFullEntityId();
The HA domain (
sensorvs.binary_sensor) is inferred from the mapped method's return type:bool/bool?becomesbinary_sensor, everything else becomessensor.
(WithId is covered in Shared chain methods above.)
A template sensor does nothing until at least one triggering entity is registered - that's what actually
adds it to the device's entity list. Register one with TriggeredBy(entity), or several by chaining
Triggered.By(entity1).By(entity2)...:
// A sensor driven by four upstream (already-debounced) template sensors
Map(x => x.LevelFloatString(), "level_float")
.WithFriendlyName("Level Float")
.AsTemplateSensor()
.Triggered
.By(haContext.Entity(overloadUpStable))
.By(haContext.Entity(maxUpStable))
.By(haContext.Entity(lowUpStable))
.By(haContext.Entity(minUpStable));StableForSeconds(n) debounces the trigger: a state change only takes effect once the triggering entity's
state has remained unchanged for n seconds (a further change within that window restarts the wait). It
must be chained before TriggeredBy, since it switches the builder to the single-trigger shape:
var maxUpStable =
Map(x => x.MaxUpStable(), "max_up_stable")
.WithFriendlyName("Max Up Stable")
.AsTemplateSensor()
.StableForSeconds(config.FloatSensorStableSeconds)
.TriggeredBy(haContext.Entity(maxUp))
.GetFullEntityId();TriggeredBy/Triggered.By(...) return a builder exposing GetFullEntityId(), which returns the fully
composed entity id (e.g. sensor.tank_nd_max_up_stable) - letting you chain one template sensor as the
trigger of the next, as shown above (max_up -> max_up_stable -> level_float).
A template sensor's computed value that is null or blank disables the entity (marks it unavailable in
HA) rather than publishing an empty state.
| Method | Chain it on | What it does |
|---|---|---|
WithFriendlyName(string) |
AsDevice(...) return value, or any Map(...) root (before the terminal As*() call) |
Sets the display name shown in Home Assistant, for the device or the entity respectively. |
WithIcon(MaterialIconKind) |
Any Map(...) root (before the terminal As*() call) |
Sets the entity's icon, rendered to the mdi:... string HA expects. AsTemperatureSensor() sets its own thermometer icon unconditionally, so WithIcon chained before it has no visible effect there — use it on the other terminals, which don't set one of their own. |
WithId(int sensorId) |
The terminal itself — AsSensor(), AsBinarySensor(), AsTemperatureSensor(), or AsTimestamp() (the four built on SensorMapRoot<TMap>; not AsSwitch(), AsButton(), or AsTemplateSensor()) |
Tags the sensor with an external numeric data point id, so IDeviceService.Update(IEnumerable<IIdValue>) can route an externally-sourced value to the right sensor without needing to know its HA entity id — see IDeviceService.GetSensorIds to read the ids back. |
AsDevice("tank_nd")
.WithFriendlyName("Tank");
Map(x => x.OverloadUp(), "overload_up")
.WithFriendlyName("Overload Up")
.WithIcon(MaterialIconKind.Water) // any Material.Icons.MaterialIconKind value
.AsTemplateSensor()
.TriggeredBy(entities.BinarySensor.SomeUpstreamSensor);
Map(x => x.ModeId, "mode_id")
.AsSensor()
.WithId(92); // chained on the terminal, not on the Map(...) rootOne call:
services.AddFluentEntities(Assembly.GetExecutingAssembly());AddFluentEntities registers everything: the built-in entity initializers and type converters, the
library's own internal services, and - by scanning the assemblies you pass in - every
DeviceMap<TDevice> subclass you've declared and its device model type, so Tank/SampleDevice/
whatever your model classes are called resolve with no further registration, whether their map used
AsDevice(identifier) or the keyed AsDevice(identifier, key) overload. Pass every assembly that
contains a DeviceMap<TDevice> subclass (usually just your own, i.e. Assembly.GetExecutingAssembly()
from your host project, but add more if your maps are spread across several assemblies).
That's it for the common case. The three things it does, if you want the detail:
- Calls
AddEntityInitializers()andAddTypeConverters()(see below - still public on their own, in case you want to call them without the rest). - Registers the library's own internal services directly (
DeviceService,EntityService,DeviceRegistry, and the rest) — no reflection, no third-party scanning library required. - Scans the assemblies you passed in for
IDeviceMapsubclasses, registers each one as scoped, and auto-registers each one's device model type both as a plain scoped service and as a service keyed toKeyedService.AnyKey— a wildcard that satisfies a keyed resolution for any key, which is what lets a keyed device's model resolve without this method ever needing to know the actual key value (it can't — that value is only set once the map's own constructor runs, well afterAddFluentEntitieshas already returned).
AddEntityInitializers() and Converters.RegistrationsExtensions.AddTypeConverters() stay public and
unchanged, for hosts that already have their own generic-service-scanning setup (keyed by their own marker
interfaces, say) and would rather fold this library into that instead of calling AddFluentEntities:
services
.AddEntityInitializers() // NetDaemon.Fluent.Entities.RegistrationExtensions
.AddTypeConverters(); // NetDaemon.Fluent.Entities.Converters.RegistrationsExtensionsAddEntityInitializers()registers the built-inIEntityInitializers (TemplateSensorService,SensorService,ButtonService,SwitchService) as scoped services keyed by theEntityDefinitionsubclass they handle, soDeviceServicecan dispatch each mapped entity to the right one.AddTypeConverters()registers the built-inITypeConverters (int,double,string,bool,DateTime) as scoped services keyed by the CLR type they convert, so sensor/template-sensor values of any of those types can be converted to/from MQTT sensor strings.
Taking this path means you're on your own for the three things AddFluentEntities otherwise handles:
registering the library's own internal services (marked with this library's DI marker interfaces,
NetDaemon.Fluent.Entities.DependencyInjection.ISingleService/IScopedService, if your scan can use them),
registering every DeviceMap<TDevice> subclass, and registering every device model type — including the
KeyedService.AnyKey trick above for keyed devices, which is easy to miss since nothing else will tell you
it's needed until a template sensor fails to resolve its device at runtime.
Everything an app calls at runtime, after a DeviceMap<TDevice> has been declared. "Non-keyed" members
address the single AsDevice(identifier) instance of TDevice; *Keyed members address one of several
AsDevice(identifier, key) instances by key.
| Member | Keyed? | What it does |
|---|---|---|
Init<TMap>() |
— | Resolves TMap from DI and initializes its device. A no-op if the device (or device/key pair) is already registered. Call once, typically from IAsyncInitializable.InitializeAsync. |
InitFromMap<TMap>(TMap map) |
— | Same as Init<TMap>(), but initializes an already-constructed map instance instead of resolving one from DI. |
GetSensorIds<TDevice>() |
both (two overloads; keyed takes object key) |
Returns the external numeric sensor ids declared via WithId(int) for the registered device. |
Update<TDevice>(IEnumerable<IIdValue> dataPoints) |
UpdateKeyed<TDevice>(object key, IEnumerable<IIdValue> dataPoints) |
Routes each externally-sourced value in dataPoints to the sensor declared with the matching WithId id. |
Update<TDevice>() |
(no keyed overload — use UpdateKeyedFromModel) |
Resolves the device model from DI and recomputes every sensor's value directly from it. |
UpdateFromModel<TDevice>(TDevice device) |
UpdateKeyedFromModel<TDevice>(object key, TDevice device) |
Recomputes every sensor's value from the given device model instance, without resolving one from DI. |
Disable<TDevice>() |
DisableKeyed<TDevice>(object key) |
Marks all of the device's entities unavailable in HA. |
Enable<TDevice>() |
EnableKeyed<TDevice>(object key) |
Marks all of the device's entities available in HA. |
EnsureEnabled<TDevice>() |
(no keyed overload) | Enables the device only if it is currently disabled; otherwise does nothing. |
All TDevice type parameters are constrained to class. None of these need the device's DeviceMap<TDevice>
passed back in — only the device model type (and, for keyed devices, the key used in AsDevice(identifier, key)).
- .NET 10
NetDaemon.HassModel26.36.0+NetDaemon.Extensions.Mqtt26.36.0+
Issues and pull requests are welcome. See CONTRIBUTING.md.
This package uses MinVer for semantic versioning,
derived from v-prefixed git tags (e.g. v1.2.3). There is no <Version> element
anywhere in the source — maintainers cut a release by pushing a tag.