Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add compat flag to override (almost) arbitrary CC API queries #5987

Merged
merged 6 commits into from
Jul 10, 2023
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
41 changes: 41 additions & 0 deletions docs/config-files/file-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,47 @@ Some legacy devices emit an NIF when a local event occurs (e.g. a button press)

Some multi-channel devices incorrectly report state changes for one of their endpoints via the root device, however there is no way to automatically detect for which endpoint these reports are meant. The flag `mapRootReportsToEndpoint` can be used to specify which endpoint these reports are mapped to. Without this flag, reports to the root device are silently ignored, unless `preserveRootApplicationCCValueIDs` is `true`.

### `overrideQueries`

A frequent reason for device not "working" correctly is that they respond to queries incorrectly, e.g. RGB bulbs not reporting support for the blue color channel, or thermostats reporting the wrong supported modes. Using `overrideQueries`, the responses to these queries can be overridden, so they are not queried from the device anymore. Example:

```js
"overrideQueries": {
// For which CC the queries should be overridden. Also accepts the decimal or hexadecimal CC ID.
"Schedule Entry Lock": [
{
// Which endpoint the query should be overridden for (optional).
// Defaults to the root endpoint 0
"endpoint": 1,
// Which API method should be overridden. Available methods depend on the CC.
"method": "getNumSlots",
// Multiple overrides can optionally be specified for the same method, distinguished
// by the method arguments. If `matchArgs` is not specified, the override
// is used for all calls to the method.
// The arguments must be exactly the same as in the API call and are
// compared using equality (===)
"matchArgs": [1, 2, 3]
// The result that should be returned by the API method when called.
"result": {
"numWeekDaySlots": 0,
"numYearDaySlots": 0,
"numDailyRepeatingSlots": 1
},
// Which values should be stored in the value DB when the API method is called (optional).
// The keys are the names of the predefined values of the given CC,
// see the CC documentation for available values.
"persistValues": {
"numWeekDaySlots": 0,
"numYearDaySlots": 0,
"numDailyRepeatingSlots": 1,
// To pass arguments for dynamic CC values, put them in round brackets (must be parseable by `JSON.parse()`)
"userEnabled(1)": true
},
}
]
}
```

### `preserveEndpoints`

Many devices unnecessarily use endpoints when they could (or do) provide all functionality via the root device. `zwave-js` tries to detect these cases and ignore all endpoints. To opt out of this behavior or to preserve single endpoints, `preserveEndpoints` can be used. Example:
Expand Down
19 changes: 19 additions & 0 deletions maintenance/schemas/device-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -614,6 +614,25 @@
"minProperties": 1,
"additionalProperties": false
},
"overrideQueries": {
"type": "object",
"additionalProperties": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"endpoint": { "type": "integer", "minimum": 0 },
"method": { "type": "string" },
"matchArgs": { "type": "array" },
"result": true,
"persistValues": { "type": "object" }
},
"required": ["method", "result"],
"additionalProperties": false
}
}
},
"preserveEndpoints": {
"oneOf": [
{
Expand Down
116 changes: 115 additions & 1 deletion packages/cc/src/lib/API.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { type CompatOverrideQueries } from "@zwave-js/config";
import {
CommandClasses,
NODE_ID_BROADCAST,
NOT_KNOWN,
ZWaveError,
ZWaveErrorCodes,
getCCName,
isZWaveError,
stripUndefined,
type Duration,
Expand All @@ -21,16 +23,19 @@ import {
import type { ZWaveApplicationHost } from "@zwave-js/host";
import {
getEnumMemberName,
getErrorMessage,
num2hex,
type AllOrNone,
type OnlyMethods,
} from "@zwave-js/shared";
import { isArray } from "alcalzone-shared/typeguards";
import {
getAPI,
getCCValues,
getCommandClass,
getImplementedVersion,
} from "./CommandClassDecorators";
import { type StaticCCValue } from "./Values";

export type ValueIDProperties = Pick<ValueID, "property" | "propertyKey">;

Expand Down Expand Up @@ -200,7 +205,31 @@ export class CCAPI {
ZWaveErrorCodes.CC_NotSupported,
);
}
return target[property as keyof CCAPI];

// If a device config defines overrides for an API call, return a wrapper method that applies them first before calling the actual method
const fallback = target[property as keyof CCAPI];
if (
typeof property === "string" &&
!endpoint.virtual &&
typeof fallback === "function"
) {
const overrides = applHost.getDeviceConfig?.(
endpoint.nodeId,
)?.compat?.overrideQueries;
if (overrides?.hasOverride(ccId)) {
return overrideQueriesWrapper(
applHost,
endpoint,
ccId,
property,
overrides,
fallback,
);
}
}

// Else just access the property
return fallback;
},
});
} else {
Expand Down Expand Up @@ -553,6 +582,91 @@ export class CCAPI {
}
}

function overrideQueriesWrapper(
applHost: ZWaveApplicationHost,
endpoint: IZWaveEndpoint,
ccId: CommandClasses,
method: string,
overrides: CompatOverrideQueries,
fallback: (...args: any[]) => any,
): (...args: any[]) => any {
// We must not capture the `this` context here, because the API methods are bound on use
return function (this: any, ...args: any[]) {
const match = overrides.matchOverride(
ccId,
endpoint.index,
method,
args,
);
if (!match) return fallback.call(this, ...args);

applHost.controllerLog.logNode(endpoint.nodeId, {
message: `API call ${method} for ${getCCName(
ccId,
)} CC overridden by a compat flag.`,
level: "debug",
direction: "none",
});

// Persist values if necessary
if (match.persistValues) {
const ccValues = getCCValues(ccId);
const valueDB = applHost.getValueDB(endpoint.nodeId);
if (ccValues) {
for (const [prop, value] of Object.entries(
match.persistValues,
)) {
try {
let valueId: ValueID | undefined;
// We use a simplistic parser to support dynamic value IDs:
// If end with round brackets with something inside, they are considered dynamic
// Otherwise static
const argsMatch = prop.match(/^(.*)\((.*)\)$/);
if (argsMatch) {
const methodName = argsMatch[1];
const methodArgs = JSON.parse(`[${argsMatch[2]}]`);

const dynValue = ccValues[methodName];
if (typeof dynValue === "function") {
valueId = dynValue(...methodArgs).endpoint(
endpoint.index,
);
}
} else {
const staticValue = ccValues[prop] as
| StaticCCValue
| undefined;
if (typeof staticValue?.endpoint === "function") {
valueId = staticValue.endpoint(endpoint.index);
}
}
if (valueId) {
valueDB.setValue(valueId, value);
} else {
applHost.controllerLog.logNode(endpoint.nodeId, {
message: `Failed to persist value ${prop} during overridden API call: value does not exist`,
level: "error",
direction: "none",
});
}
} catch (e) {
applHost.controllerLog.logNode(endpoint.nodeId, {
message: `Failed to persist value ${prop} during overridden API call: ${getErrorMessage(
e,
)}`,
level: "error",
direction: "none",
});
}
}
}
}

// API methods are always async
return Promise.resolve(match.result);
};
}

/** A CC API that is only available for physical endpoints */
export class PhysicalCCAPI extends CCAPI {
public constructor(
Expand Down
Loading