Skip to content

Commit 0776fae

Browse files
committed
feat(exposes): add withDefault for composite feature defaults
Adds an optional per-feature `default` (via `withDefault`) so consumers (e.g. a Home Assistant composite editor card) can pre-fill fields of a new/empty atomic composite slot and build a complete payload. A composite must be written to the device as one complete object in a single message; individually writing fields breaks devices like the SONOFF valve (see #12495/#12510 reverted by #12647). Seeds defaults on the SONOFF cyclic timed/quantitative irrigation composites.
1 parent 22a3b87 commit 0776fae

3 files changed

Lines changed: 54 additions & 8 deletions

File tree

src/devices/sonoff.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2355,30 +2355,35 @@ const sonoffExtend = {
23552355
const exposes = e
23562356
.composite("cyclic_timed_irrigation", "cyclic_timed_irrigation", ea.ALL)
23572357
.withDescription("Smart water valve cycle timing irrigation")
2358-
.withFeature(e.numeric("current_count", ea.STATE).withDescription("Number of times it has been executed").withUnit("times"))
2358+
.withFeature(
2359+
e.numeric("current_count", ea.STATE).withDescription("Number of times it has been executed").withUnit("times").withDefault(0),
2360+
)
23592361
.withFeature(
23602362
e
23612363
.numeric("total_number", ea.STATE_SET)
23622364
.withDescription("Total times of circulating irrigation")
23632365
.withUnit("times")
23642366
.withValueMin(0)
2365-
.withValueMax(100),
2367+
.withValueMax(100)
2368+
.withDefault(1),
23662369
)
23672370
.withFeature(
23682371
e
23692372
.numeric("irrigation_duration", ea.STATE_SET)
23702373
.withDescription("Single irrigation duration")
23712374
.withUnit("seconds")
23722375
.withValueMin(0)
2373-
.withValueMax(86400),
2376+
.withValueMax(86400)
2377+
.withDefault(60),
23742378
)
23752379
.withFeature(
23762380
e
23772381
.numeric("irrigation_interval", ea.STATE_SET)
23782382
.withDescription("Time interval between two adjacent irrigation")
23792383
.withUnit("seconds")
23802384
.withValueMin(0)
2381-
.withValueMax(86400),
2385+
.withValueMax(86400)
2386+
.withDefault(60),
23822387
);
23832388
const fromZigbee = [
23842389
{
@@ -2477,30 +2482,35 @@ const sonoffExtend = {
24772482
const exposes = e
24782483
.composite("cyclic_quantitative_irrigation", "cyclic_quantitative_irrigation", ea.ALL)
24792484
.withDescription("Smart water valve circulating quantitative irrigation")
2480-
.withFeature(e.numeric("current_count", ea.STATE).withDescription("Number of times it has been executed").withUnit("times"))
2485+
.withFeature(
2486+
e.numeric("current_count", ea.STATE).withDescription("Number of times it has been executed").withUnit("times").withDefault(0),
2487+
)
24812488
.withFeature(
24822489
e
24832490
.numeric("total_number", ea.STATE_SET)
24842491
.withDescription("Total times of circulating irrigation")
24852492
.withUnit("times")
24862493
.withValueMin(0)
2487-
.withValueMax(100),
2494+
.withValueMax(100)
2495+
.withDefault(1),
24882496
)
24892497
.withFeature(
24902498
e
24912499
.numeric("irrigation_capacity", ea.STATE_SET)
24922500
.withDescription("Single irrigation capacity")
24932501
.withUnit("liter")
24942502
.withValueMin(0)
2495-
.withValueMax(6500),
2503+
.withValueMax(6500)
2504+
.withDefault(1),
24962505
)
24972506
.withFeature(
24982507
e
24992508
.numeric("irrigation_interval", ea.STATE_SET)
25002509
.withDescription("Time interval between two adjacent irrigation")
25012510
.withUnit("seconds")
25022511
.withValueMin(0)
2503-
.withValueMax(86400),
2512+
.withValueMax(86400)
2513+
.withDefault(60),
25042514
);
25052515
const fromZigbee = [
25062516
{

src/lib/exposes.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {Access, LevelConfigFeatures, Range} from "./types";
1414
import {getLabelFromName} from "./utils";
1515

1616
export type Feature = Numeric | Binary | Enum | Composite | List | Text;
17+
export type DefaultValue = number | string | boolean;
1718
export interface HomeAssistant {
1819
type?: "valve";
1920
entityCategory?: "config" | "diagnostic";
@@ -32,6 +33,8 @@ export class Base {
3233
description?: string;
3334
features?: Feature[];
3435
category?: "config" | "diagnostic";
36+
// Cards need explicit defaults to build complete payloads for new atomic composites, which must be written as one object.
37+
default?: DefaultValue;
3538
homeassistant?: HomeAssistant;
3639

3740
withEndpoint(endpointName: string) {
@@ -81,6 +84,11 @@ export class Base {
8184
return this;
8285
}
8386

87+
withDefault(value: DefaultValue) {
88+
this.default = value;
89+
return this;
90+
}
91+
8492
withHomeAssistant(homeassistant: HomeAssistant) {
8593
this.homeassistant = homeassistant;
8694
return this;
@@ -134,6 +142,9 @@ export class Base {
134142
target.features = this.features.map((f) => f.clone());
135143
}
136144
target.category = this.category;
145+
if (this.default !== undefined) {
146+
target.default = this.default;
147+
}
137148
target.homeassistant = this.homeassistant ? {...this.homeassistant} : undefined;
138149
}
139150
}

test/utils.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {beforeEach, describe, expect, it} from "vitest";
22
import type {Device} from "zigbee-herdsman/dist/controller/model";
3+
import {access, presets} from "../src/lib/exposes";
34
import type {Tz} from "../src/lib/types";
45
import {batteryVoltageToPercentage, getFromLookup, getFromLookupByValue, getTransition, mapNumberRange, toNumber} from "../src/lib/utils";
56
import {mockDevice} from "./utils";
@@ -231,4 +232,28 @@ describe("utils", () => {
231232
});
232233
});
233234
});
235+
236+
describe("exposes defaults", () => {
237+
it("sets explicit defaults", () => {
238+
const expose = presets.numeric("irrigation_duration", access.ALL).withDefault(60);
239+
240+
expect(expose.default).toBe(60);
241+
});
242+
243+
it("preserves defaults when cloned", () => {
244+
const expose = presets
245+
.composite("cyclic_timed_irrigation", "cyclic_timed_irrigation", access.ALL)
246+
.withFeature(presets.numeric("total_number", access.STATE_SET).withDefault(1));
247+
248+
const clone = expose.clone();
249+
250+
expect(clone.features[0].default).toBe(1);
251+
});
252+
253+
it("does not serialize unset defaults", () => {
254+
const expose = presets.numeric("irrigation_interval", access.ALL);
255+
256+
expect(JSON.parse(JSON.stringify(expose))).not.toHaveProperty("default");
257+
});
258+
});
234259
});

0 commit comments

Comments
 (0)