@Sescandell, I’m currently making a PR for the pilot wire for the Thermostat integration and my QUBINO device only returns a dimmer and depending on the value it corresponds to a pilot wire state.
Is there a way to have modes instead of a dimmer in the integration? Thanks in advance.
Below is an analysis by Claude:
# Qubino Flush Pilot (ZMNHJD): Support for Pilot Wire Radiator
> External Z-Wave integration spec. It relies on the external integrations
> framework of the Gladys monorepo (`docs/specs/external-integrations/`):
> devices are published via `POST /discovered_device` (C.3, SDK
> `publishDiscoveredDevices`), commands arrive via
> `external-integration.device.set-value` (C.4), and states are reported via
> `POST /state` (B.6).
## 1. Problem
The Qubino Flush Pilot (ZMNHJD) controls a pilot wire radiator. It does this via
its **Multilevel Switch** (CC 38): each range of levels corresponds to a
pilot wire command. Today, the integration publishes it as its device class
says, i.e., a dimmer (position 0–99 %, on/off state, "restore previous value").
Consequences:
- the user sees "30 %" while the radiator is in **Eco**;
- the thermostat cannot control it. Its pilot wire actuator
(`THERMOSTAT_PILOT_WIRE_FEATURE`, thermostat spec C.1.1) only accepts a
`heater` / `pilot-wire-mode` feature;
- scenes, the dashboard, and HomeKit see a dimmer, not a command.
The level → command translation is specific to this product: it therefore belongs to
the integration that knows this product. The thermostat, like the rest of Gladys,
only sees the standard type.
## 2. Identify the Device
| Field | Value |
|---|---|
| `manufacturerId` | 345 (`0x0159`, Qubino) |
| `productType` | 4 (`0x0004`) |
| `productId` | 81 (`0x0051`) |
| `deviceId` zwave-js | `345-81-4` |
| `deviceClass` | basic 4, generic 17, specific 1 |
| config zwave-js | `0x0159/zmnhjd.json`, label `ZMNHJD`, "Flush Pilot" |
**The device class is not usable.** Generic 17 / specific 1
("Multilevel Switch, dimmer") is the class of all dimmers. Using it would
transform every dimmer into a pilot wire. The device is therefore identified by its
**product identifier** (`manufacturerId` + `productType` + `productId`), verified
**before** any rule based on the device class.
## 3. Published Device
The node publishes **a single** actionable feature instead of those of the
dimmer:
| Field | Value |
|---|---|
| `category` | `heater` (`DEVICE_FEATURE_CATEGORIES.HEATER`) |
| `type` | `pilot-wire-mode` (`DEVICE_FEATURE_TYPES.HEATER.PILOT_WIRE_MODE`) |
| `min` / `max` | `0` / `5` (`PILOT_WIRE_MODE.OFF` … `PILOT_WIRE_MODE.COMFORT`) |
| `read_only` | `false` |
| `has_feedback` | `true`: the module returns `currentValue` after each change, including a press on its own buttons |
| `keep_history` | `true` |
| source value | CC 38 `currentValue`, endpoint 0 |
**Not published** for this product:
- the dimmer features (`position`, the on/off `state` deduced from the
level, `restorePrevious`). "Restore previous value" would send an arbitrary
command to the radiator;
- the explicit Binary Switch (CC 37). The integration already removes it from any node
that has a Multilevel Switch;
- `Up` / `Down` / `duration` / `event` (CC 38), as today on all nodes.
Unchanged: configuration parameters (CC 112: input types, mode of inputs 11/12/13, state after power cut 30). They remain out of scope
(section 8).
## 4. Write a Command (Gladys → Device)
On `external-integration.device.set-value` for this feature, `value`
is a `PILOT_WIRE_MODE`. It is written as a level by the `set` command of the
Multilevel Switch (CC 38, endpoint 0):
| Order (`PILOT_WIRE_MODE`) | Value | Level Written |
|---|---|---|
| `OFF` (stop) | 0 | 0 |
| `FROST_PROTECTION` (anti-freeze) | 1 | 20 |
| `ECO` | 2 | 30 |
| `COMFORT_2` (comfort −2 °C) | 4 | 40 |
| `COMFORT_1` (comfort −1 °C) | 3 | 50 |
| `COMFORT` (comfort) | 5 | 99 |
- The levels written are those measured on the module: 0 / 20 / 30 / 40 / 50 / 99.
- Any other value receives a **command-result failure** ("unknown pilot wire
command"), and nothing is sent to the module.
- **No optimistic state.** The state is reported when the module confirms with
`currentValue` (section 5), not when the command is sent. A radiator that missed the
frame should not appear to have obeyed.
## 5. Read a Command (Device → Gladys)
Every update of `currentValue` on CC 38, the level is read **by
range**, and the command is reported via `POST /state`:
| Level | Reported Order |
|---|---|
| 0–10 | `OFF` |
| 11–20 | `FROST_PROTECTION` |
| 21–30 | `ECO` |
| 31–40 | `COMFORT_2` |
| 41–50 | `COMFORT_1` |
| 51–99 | `COMFORT` |
| other (e.g., 255, a non-numeric value) | nothing is reported |
Reading by range rather than by exact value is essential: the module
can return any level in a range. A press on its button was observed at **60** before 99, and it is indeed a comfort command.
`targetValue` is not read: `currentValue` is what the module actually applies.
## 6. Devices Created Before This Change
A node already created in Gladys as a dimmer keeps its dimmer features until it is updated from the discovery screen. The update replaces them with the pilot wire feature: same `external_id` of
device, new `external_id` of feature.
- The history of the old dimmer features is not carried over. Levels and commands are not the same values.
- Scenes and dashboard widgets that pointed to the old dimmer feature need to be re-pointed manually. Device migration
(`device-migration.md`) moves selectors from one feature to another, but a "30 %" in a scene would not mean a command.
- A thermostat (virtual, `THERMOSTAT_PILOT_WIRE_FEATURE`) is then configured on the new feature in its edit form. Nothing else on the thermostat side: the preset → command mapping is in the
thermostat spec, C.1.1.
## 7. Tests
- **Discovery:**
- a node with `deviceId` `345-81-4` and class 17-1 publishes exactly one
feature `heater` / `pilot-wire-mode`, without position, state,
`restorePrevious` or Binary Switch;
- a node of class 17-1 with **another** product identifier is still
published as a dimmer (no regression).
- **Writing:** each of the six commands produces a CC 38 `set` with its level, and an unknown command gives a command-result failure, without sending anything.
- **Reading:** the range boundaries (0, 10, 11, 20, 21, 30, 31, 40, 41, 50, 51,
99), the observed 60 → `COMFORT`, and 255 / −1 / a non-numeric value do not report anything.
- **State feedback:** after a write, no state is reported until
`currentValue` has arrived.
## 8. Out of Scope
- Configuration parameters CC 112 (button modes, state after power cut).
- Other Qubino references to pilot wire, as long as their product identifier
is not known and it is not confirmed that they use the same levels. Each is added explicitly to the list of products, never
by the device class.
- Any regulation logic: the integration translates the commands, it's the
thermostat that decides them.
## 9. Manual Verification
1. Update the "Radiator (Office)" node from the discovery screen: a single "pilot wire" feature appears.
2. From the device, send each command and verify the level in
zwave-js-ui: off 0, anti-freeze 20, eco 30, comfort −2 40, comfort −1 50,
comfort 99.
3. Press the module button: the order displayed in Gladys follows (60 →
comfort).
4. In the thermostat form, choose this feature as the pilot wire actuator, then choose Eco in the widget: the module goes to 30.
```## 10. Open points
- **Beach markers.** The markings (0/20/30/40/50/99) are measured. The
reading ranges come from the Qubino documentation and still need to be verified
in the ZMNHJD documentation.
- **Comfort numbering −1 / −2.** `PILOT_WIRE_MODE` gives `COMFORT_1 = 3` and
`COMFORT_2 = 4`. The table in section 4 follows the constant, not the order of the
levels (40 = comfort −2, 50 = comfort −1).
