بنقرة واحدة
zwave-implement-cc
Guide for implementing Z-Wave Command Classes (CCs) in ZWave.NET
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
القائمة
Guide for implementing Z-Wave Command Classes (CCs) in ZWave.NET
التثبيت باستخدام Codex أو Claude انسخ هذا Prompt والصقه في Codex أو Claude أو مساعد آخر ليراجع صفحة Skill ويثبّتها لك.
استنادا إلى تصنيف SOC المهني
| name | zwave-implement-cc |
| description | Guide for implementing Z-Wave Command Classes (CCs) in ZWave.NET |
This skill guides you through implementing a new Z-Wave Command Class (CC) in the ZWave.NET codebase, in src/ZWave.CommandClasses/.
Use this as a quick reference. Each item is explained in detail below.
CommandClassId has an entry for this CC in src/ZWave.Protocol/CommandClassId.cssrc/ZWave.CommandClasses/{Name}CommandClass.csbyte-backed, one entry per spec command)[CommandClass] attribute, constructor, IsCommandSupported, InterviewAsync, ProcessUnsolicitedCommandParse methods on report commandsdotnet build --configuration Release to verifyBefore starting, you need:
CommandClassId enum value from src/ZWave.Protocol/CommandClassId.cs.If the CommandClassId enum does not yet have an entry for this CC, add it to src/ZWave.Protocol/CommandClassId.cs with the correct byte value and XML doc comment.
For simple CCs with few commands, a single file is sufficient: src/ZWave.CommandClasses/{Name}CommandClass.cs
For larger CCs with multiple command groups, use the partial class pattern described below.
The file uses the ZWave.CommandClasses namespace (file-scoped) and must include using Microsoft.Extensions.Logging;. The contents are ordered as follows:
byte-backed) listing every command in the CCsealed, inherits CommandClass<TCommand>The implementation MUST conform to the Z-Wave Application Specification exactly. Do not guess, assume, or improvise when the spec is unclear or ambiguous. If any aspect of a command's behavior, field encoding, validation rule, or edge case is not explicitly clear from the spec text provided, stop and ask the user before proceeding. This includes but is not limited to:
It is always better to ask than to make a wrong assumption that will need to be fixed later.
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in the Z-Wave specifications are to be interpreted as described in RFC 2119. Follow the spec's normative language when deciding how to handle fields, validation, and behavior.
Z-Wave Command Classes are designed to be forward-compatible. When implementing a CC that spans multiple versions, implement the receiver for the highest version. In practice this means:
_version >= 2 to decide if a field is present is a forward-compatibility violation.IsCommandSupported), not to ignore data that is present in a payload.Version vs EffectiveVersion: The base class provides both. Version (nullable byte?) is the actual reported version, or null if not yet known. EffectiveVersion is Version.GetValueOrDefault(1) — it defaults to 1 when unknown. Use Version in IsCommandSupported to express "we don't know yet" (null). Use EffectiveVersion when building outbound commands where the command payload format varies by version.
The base class CommandClass.ProcessCommand distinguishes between solicited and unsolicited reports:
AwaitNextReportAsync) and completes them directly. The GetAsync method then calls the report's Parse method on the returned frame. ProcessUnsolicitedCommand is not called for solicited reports.ProcessUnsolicitedCommand, which calls Parse and updates cached state.This means Parse is called exactly once per report — either in GetAsync (solicited) or ProcessUnsolicitedCommand (unsolicited), never both.
For any report type that can be received unsolicited, the CC class MUST expose an event Action<TReport>? event that fires on both solicited and unsolicited reports. This allows library consumers (e.g., Node) to monitor all incoming data without polling.
Naming: On{ReportType}Received (e.g., OnEndpointReportReceived, OnCapabilityReportReceived).
Raising events:
GetAsync methods: raise the event after Parse, before returning.ProcessUnsolicitedCommand: raise the event after Parse.// In the CC class (or the appropriate partial class for the command group):
public event Action<{Name}Report>? On{Name}ReportReceived;
// In GetAsync:
public async Task<{Name}Report> GetAsync(CancellationToken cancellationToken)
{
...
{Name}Report report = {Name}ReportCommand.Parse(reportFrame, Logger);
LastReport = report;
On{Name}ReportReceived?.Invoke(report);
return report;
}
// In ProcessUnsolicitedCommand:
case {Name}Command.Report:
{
{Name}Report report = {Name}ReportCommand.Parse(frame, Logger);
LastReport = report;
On{Name}ReportReceived?.Invoke(report);
break;
}
Which reports get events: All report types that a device could send unsolicited. Reports that are only ever solicited (e.g., responses to capability queries that never change) do not need events, but MAY have them if useful for monitoring.
Not everything a CC can do should be exposed as cached state on the CC class. Use this guideline:
GetAsync and ProcessUnsolicitedCommand): Use for ongoing device state and behavior — values that change over time and that callers may want to read without sending a command. Examples: whether a light is on/off, current thermostat setpoint, battery level.
LastReport (e.g., BasicReport? LastReport).Last{Descriptive} (e.g., LastHealthReport, LastTestResult, LastNotification, LastInterval).Last prefix. Examples: HardwareInfo, Capabilities, SupportedSensorTypes, SwitchType, IntervalCapabilities.When in doubt, ask the user whether a value should be cached state or a direct API.
CCs that track per-key readings (e.g., per-sensor-type readings, per-color-component state) use two kinds of cached state:
new() at the field declaration. The public property is non-nullable. Empty means "no readings received yet"; populated means readings exist. This ensures callers and UI code can always enumerate without null checks, and unsolicited reports are never silently dropped.null. The public property is nullable. null means "not yet interviewed / version doesn't support this query". This lets callers and UI distinguish "unknown" from "known empty."// ✅ Readings dictionary — eagerly initialized, non-nullable
private Dictionary<SensorType, SensorReport?> _sensorValues = new();
public IReadOnlyDictionary<SensorType, SensorReport?> SensorValues => _sensorValues;
// ✅ Capability property — starts null, nullable
private Dictionary<SensorType, IReadOnlySet<Scale>?>? _supportedScales;
public IReadOnlyDictionary<SensorType, IReadOnlySet<Scale>?>? SupportedScales => _supportedScales;
For CCs with a version-dependent discovery path (e.g., MultilevelSensor V1-4 has no SupportedSensorGet), the readings dictionary is always available regardless of version, while capability properties remain null on older versions. When GetSupportedAsync runs, it rebuilds the readings dictionary to include keys for every supported type (preserving any existing values).
Validation is performed in the report command's static Parse method. The Parse method:
ILogger parameter describing what's wrongZWaveException.Throw(ZWaveErrorCode.InvalidPayload, ...) with a concise messageThe base class handles exception propagation differently depending on the report path:
Parse in GetAsync to the caller. The caller sees the exception.ProcessUnsolicitedCommand in a try/catch and swallows the exception. Since Parse already logged a warning before throwing, no information is lost.This means Parse methods should always log-then-throw on validation failure — they do not need to worry about which path is calling them.
Multi-stage validation for variable-length payloads: Some reports contain a length or size field that determines how many subsequent bytes to read (e.g., a valueSize field). In these cases, validate in stages:
if (frame.CommandParameters.Length < 2) // minimum header
throw ...;
int valueSize = frame.CommandParameters.Span[1] & 0b0000_0111;
if (frame.CommandParameters.Length < 2 + valueSize) // header + declared data
throw ...;
ReadOnlySpan<byte> valueBytes = frame.CommandParameters.Span.Slice(2, valueSize);
Method names should be natural C# API names, not direct translations of the spec's wire-level command names. Reorder words for readability when the spec name is awkward:
| Spec command name | C# method name |
|---|---|
LOGGING_SUPPORTED_GET | GetSupportedLoggingTypesAsync |
SUPPORTED_GET | GetSupportedAsync |
INTERVAL_CAPABILITIES_GET | GetIntervalCapabilitiesAsync |
SET | SetAsync |
The inner command struct names should still match the spec ordering for traceability (e.g., LoggingSupportedGetCommand).
Some commands return results across multiple report frames (indicated by a "Reports to Follow" field). These must be aggregated so the public API returns a single complete result. The caller should not need to know about the multi-frame nature of the response.
The report command struct should expose a ParseInto method that takes the collection to append to (avoiding intermediate list allocations) and returns only the metadata (e.g. reportsToFollow). See AssociationReportCommand.ParseInto for the reference implementation.
// In the report command struct:
public static byte ParseInto(
CommandClassFrame frame,
List<{Item}> items,
ILogger logger)
{
// validate frame...
byte reportsToFollow = span[0];
// parse items and add to the provided list...
items.Add(...);
return reportsToFollow;
}
// In the CC class:
public async Task<IReadOnlyList<{Item}>> GetAllItemsAsync(CancellationToken cancellationToken)
{
var command = {Name}GetCommand.Create();
await SendCommandAsync(command, cancellationToken).ConfigureAwait(false);
List<{Item}> allItems = [];
byte reportsToFollow;
do
{
CommandClassFrame reportFrame = await AwaitNextReportAsync<{Name}ReportCommand>(cancellationToken).ConfigureAwait(false);
reportsToFollow = {Name}ReportCommand.ParseInto(reportFrame, allItems, Logger);
}
while (reportsToFollow > 0);
return allItems;
}
Create a byte-backed enum with an entry for each command defined in the spec. Use the hex command IDs from the specification.
using Microsoft.Extensions.Logging;
namespace ZWave.CommandClasses;
public enum {Name}Command : byte
{
/// <summary>
/// {Description from spec}
/// </summary>
Set = 0x01,
/// <summary>
/// {Description from spec}
/// </summary>
Get = 0x02,
/// <summary>
/// {Description from spec}
/// </summary>
Report = 0x03,
}
Define any enums and report record structs needed to represent the CC's data. These go at the top of the file, before the CC class.
Report structs are public readonly record struct with positional (primary constructor) parameters:
/// <summary>
/// Represents a {Name} Report received from a device.
/// </summary>
public readonly record struct {Name}Report(
/// <summary>
/// {Description}
/// </summary>
{Type} {PropertyName},
/// <summary>
/// {Description of optional field}
/// </summary>
{Type}? {OptionalPropertyName});
Key points:
readonly record struct with positional parameters — not manual constructors and properties.{Name}Report (not {Name}State). The CC class property is LastReport (not State).GenericValue?).NextIndicatorId in Indicator Supported Report) are interview implementation details and MUST NOT appear in the public report struct. Instead, have the Parse method return a value tuple (TReport Report, TId NextId). The public GetSupportedAsync discards the next ID with _, while the interview loop destructures both values to follow the chain.[CommandClass(CommandClassId.{Name})]
public sealed class {Name}CommandClass : CommandClass<{Name}Command>
{
internal {Name}CommandClass(
CommandClassInfo info,
IDriver driver,
IEndpoint endpoint,
ILogger logger)
: base(info, driver, endpoint, logger)
{
}
/// <summary>
/// Gets the last report received from the device.
/// </summary>
public {Name}Report? LastReport { get; private set; }
// IsCommandSupported — return true/false/null based on version
public override bool? IsCommandSupported({Name}Command command)
=> command switch
{
{Name}Command.Get => true,
{Name}Command.Set => true,
// For version-gated commands:
// {Name}Command.SomeV2Command => Version.HasValue ? Version >= 2 : null,
_ => false,
};
// InterviewAsync — query the device for its current state only
internal override async Task InterviewAsync(CancellationToken cancellationToken)
{
_ = await GetAsync(cancellationToken).ConfigureAwait(false);
}
// Public API methods (Get, Set, etc.)
// ... see patterns below ...
// ProcessUnsolicitedCommand — handle unsolicited incoming report frames
protected override void ProcessUnsolicitedCommand(CommandClassFrame frame)
{
// ... see pattern below ...
}
// Internal inner command structs
// ... see patterns below ...
}
[CommandClass(CommandClassId.{Name})] attribute: This is required. A Roslyn source generator scans for this attribute and auto-generates the CommandClassFactory mapping. No other registration is needed.internal, takes (CommandClassInfo info, IDriver driver, IEndpoint endpoint, ILogger logger), calls base(info, driver, endpoint, logger). The Endpoint property provides access to the endpoint this CC belongs to (e.g. Endpoint.NodeId, Endpoint.CommandClasses, Endpoint.GetCommandClass()).IsCommandSupported: Return true for always-available commands, false for report-only/unsupported commands, and use Version.HasValue ? Version >= N : null for version-gated commands. Use null when it's unknown whether the command is supported.Dependencies: Only override if this CC does NOT depend on the Version CC. The default is { CommandClassId.Version }. The Version CC itself overrides this to Array.Empty<CommandClassId>().There are two patterns depending on whether the result is cached state or returned directly. In both patterns, the GetAsync method receives the raw frame from AwaitNextReportAsync and calls Parse directly — ProcessUnsolicitedCommand is NOT called for solicited reports.
Use when the value is ongoing device state (see "State vs. Direct API" above).
public async Task<{Name}Report> GetAsync(CancellationToken cancellationToken)
{
{Name}GetCommand command = {Name}GetCommand.Create();
await SendCommandAsync(command, cancellationToken).ConfigureAwait(false);
CommandClassFrame reportFrame = await AwaitNextReportAsync<{Name}ReportCommand>(cancellationToken).ConfigureAwait(false);
{Name}Report report = {Name}ReportCommand.Parse(reportFrame, Logger);
LastReport = report;
On{Name}ReportReceived?.Invoke(report);
return report;
}
The pattern is: create command → SendCommandAsync → AwaitNextReportAsync<TReport> → Parse the returned frame → update LastReport → raise event → return.
Use when the value is device data queried on demand. Parse the report frame directly and return the result without storing it on the CC class.
public async Task<byte> GetCommandClassVersionAsync(CommandClassId commandClassId, CancellationToken cancellationToken)
{
var command = VersionCommandClassGetCommand.Create(commandClassId);
await SendCommandAsync(command, cancellationToken).ConfigureAwait(false);
CommandClassFrame reportFrame = await AwaitNextReportAsync<VersionCommandClassReportCommand>(
predicate: frame =>
{
return frame.CommandParameters.Length > 0
&& (CommandClassId)frame.CommandParameters.Span[0] == commandClassId;
},
cancellationToken).ConfigureAwait(false);
(CommandClassId _, byte commandClassVersion) = VersionCommandClassReportCommand.Parse(reportFrame, Logger);
return commandClassVersion;
}
Use the predicate overload of AwaitNextReportAsync when you need to match a specific report (e.g., by a key field in the response).
IMPORTANT: Predicate functions must NOT call Parse. Predicates run on every incoming frame for this CC, including non-matching frames that may be malformed. Calling Parse in a predicate would:
Instead, predicates should read the raw frame.CommandParameters.Span bytes directly with bounds checks:
predicate: frame =>
{
return frame.CommandParameters.Length > 0
&& (SomeEnum)frame.CommandParameters.Span[0] == expectedValue;
}
public async Task SetAsync({parameters}, CancellationToken cancellationToken)
{
var command = {Name}SetCommand.Create(EffectiveVersion, {parameters});
await SendCommandAsync(command, cancellationToken).ConfigureAwait(false);
}
Pass EffectiveVersion when the command payload varies by version (see "Version vs EffectiveVersion" above).
ProcessUnsolicitedCommandThis method handles unsolicited incoming report frames only (device-initiated, not responses to Get commands). It updates cached state properties and raises report events. The base class calls this only when no awaiter matched the frame.
Only include cases for commands that can actually arrive unsolicited — typically Report commands. Do not add no-op cases for outbound-only commands like Set and Get.
protected override void ProcessUnsolicitedCommand(CommandClassFrame frame)
{
switch (({Name}Command)frame.CommandId)
{
case {Name}Command.Report:
{
{Name}Report report = {Name}ReportCommand.Parse(frame, Logger);
LastReport = report;
On{Name}ReportReceived?.Invoke(report);
break;
}
}
}
Key points:
Parse method, assign to cached state, and raise the event.Parse. If Parse throws, the base class catches and swallows the exception (Parse already logged a warning).GetAsync.Each command is an internal readonly struct implementing ICommand (from ZWave.CommandClasses), nested inside the CC class. The internal visibility enables direct unit testing of command creation and report parsing. There are three patterns:
internal readonly struct {Name}GetCommand : ICommand
{
public {Name}GetCommand(CommandClassFrame frame)
{
Frame = frame;
}
public static CommandClassId CommandClassId => CommandClassId.{Name};
public static byte CommandId => (byte){Name}Command.Get;
public CommandClassFrame Frame { get; }
public static {Name}GetCommand Create()
{
CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId);
return new {Name}GetCommand(frame);
}
}
internal readonly struct {Name}SetCommand : ICommand
{
public {Name}SetCommand(CommandClassFrame frame)
{
Frame = frame;
}
public static CommandClassId CommandClassId => CommandClassId.{Name};
public static byte CommandId => (byte){Name}Command.Set;
public CommandClassFrame Frame { get; }
public static {Name}SetCommand Create(byte version, {parameter types})
{
// Build the command parameters byte array
Span<byte> commandParameters = stackalloc byte[{size}];
commandParameters[0] = {value};
// ... fill remaining bytes ...
CommandClassFrame frame = CommandClassFrame.Create(CommandClassId, CommandId, commandParameters);
return new {Name}SetCommand(frame);
}
}
For simple parameters, prefer the collection expression syntax:
ReadOnlySpan<byte> commandParameters = [(byte)param1, param2];
For version-conditional fields (e.g., duration added in V2):
bool includeDuration = version >= 2 && duration.HasValue;
Span<byte> commandParameters = stackalloc byte[1 + (includeDuration ? 1 : 0)];
internal readonly struct {Name}ReportCommand : ICommand
{
public {Name}ReportCommand(CommandClassFrame frame)
{
Frame = frame;
}
public static CommandClassId CommandClassId => CommandClassId.{Name};
public static byte CommandId => (byte){Name}Command.Report;
public CommandClassFrame Frame { get; }
public static {Name}Report Parse(CommandClassFrame frame, ILogger logger)
{
// Validate minimum payload length
if (frame.CommandParameters.Length < 1)
{
logger.LogWarning("{Name} Report frame is too short ({Length} bytes)", frame.CommandParameters.Length);
ZWaveException.Throw(ZWaveErrorCode.InvalidPayload, "{Name} Report frame is too short");
}
ReadOnlySpan<byte> span = frame.CommandParameters.Span;
{Type} requiredField = span[0];
// For fields added in later versions, check payload length (NOT version):
{Type}? optionalField = span.Length > 1
? span[1]
: null;
return new {Name}Report(requiredField, optionalField);
}
}
Key points for report commands:
Parse method that takes (CommandClassFrame frame, ILogger logger) and returns the public report record struct. They do NOT store version or have instance properties for parsed fields.Parse validates the frame, logs warnings, and throws on validation errors. Both GetAsync and ProcessUnsolicitedCommand call Parse — the base class handles exception propagation appropriately for each path.frame.CommandParameters.Span[index]..ToUInt16BE(), .ToUInt32BE(), .ToInt32BE().(span[N] & 0b0000_1111).Create method for bidirectional support (constructing outgoing frames, e.g. for controller-side responses and unit testing round-trips).When a command was added in version N of the CC:
IsCommandSupported: Version.HasValue ? Version >= N : nullInterviewAsync: if (IsCommandSupported({Name}Command.X).GetValueOrDefault()) { ... }The interview queries the device for its current state (not all data). Only call Gets during the interview for values that should be cached as state properties. Common patterns:
GetAsync for the primary state.Task.CompletedTask for CCs where all functionality is on-demand (e.g. Powerlevel CC).These existing types are available for use:
DurationReport — Duration from a report (byte → TimeSpan)DurationSet — Duration for a set command (TimeSpan → byte)GenericValue — Generic 0-100/on-off valueCommandClassFrame — The raw frame wrapper (CC ID + Command ID + parameters)Use the extension methods in BinaryExtensions.cs:
span.ToUInt16BE(), span.ToUInt32BE(), span.ToInt32BE()value.WriteBytesBE(span)HashSet<{EnumType}> supported = new HashSet<{EnumType}>();
ReadOnlySpan<byte> bitMask = frame.CommandParameters.Span.Slice(offset, length);
for (int byteNum = 0; byteNum < bitMask.Length; byteNum++)
{
for (int bitNum = 0; bitNum < 8; bitNum++)
{
if ((bitMask[byteNum] & (1 << bitNum)) != 0)
{
{EnumType} value = ({EnumType})((byteNum << 3) + bitNum);
supported.Add(value);
}
}
}
var — use explicit types (e.g. CommandClassFrame frame = ..., not var frame = ...). Exception: var is acceptable for the result of a Create() call on a command struct, since the type is obvious from the right-hand side.? for nullable properties.ConfigureAwait(false) on every await.sealed CC classes.internal constructor on the CC class.When a CC has many command groups (e.g., Multi Channel CC with Endpoint, Capability, EndpointFind, CommandEncapsulation, AggregatedMembers), split the implementation into partial classes to keep files manageable.
{Name}CommandClass.cs — contains the command enum, class declaration with constructor, IsCommandSupported, InterviewAsync, and ProcessUnsolicitedCommand{Name}CommandClass.{Group}.cs — each contains a command group (Get/Report or Get/Set/Report triplet), the associated public report record struct, the inner command structs, the public accessor methods, and the event Action<TReport>? event for that reportMultiChannelCommandClass.cs — enum, constructor, interview, unsolicited handler
MultiChannelCommandClass.Endpoint.cs — Endpoint Get/Report, record, event
MultiChannelCommandClass.Capability.cs — Capability Get/Report, record, event
MultiChannelCommandClass.EndpointFind.cs — Endpoint Find/Find Report, accessor
MultiChannelCommandClass.CommandEncapsulation.cs — Encapsulation create/parse, event
MultiChannelCommandClass.AggregatedMembers.cs — Aggregated Members Get/Report, accessor
IsCommandSupported, InterviewAsync, ProcessUnsolicitedCommand, and any callbacks or shared state.Test classes follow the same partial class split:
{Name}CommandClassTests.cs — [TestClass] public partial class {Name}CommandClassTests { }{Name}CommandClassTests.{Group}.cs — tests for each command groupAfter implementing, build from the repo root:
dotnet build --configuration Release
The source generator will automatically pick up the [CommandClass] attribute and register the new CC in the factory. No manual registration is needed.
Do not run dotnet format — it cannot run source generators and will produce false errors.