Koenkk / Koenkk/zigbee2mqtt

[External Converter]: Danfoss Ally / Popp 701721 — per-day text weekly schedule

Open
#33,068 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

external converter
Dominant language
TypeScript
Stars
15.7k
Forks
2k
Avg merge
18h 55m
Merged PRs (30d)
35

Description

Link

https://www.zigbee2mqtt.io/devices/014G2461.html (Same definition also covers Popp 701721 and Hive UK7004240 / TRV001 / TRV003 as whiteLabel entries. Tested on a Popp 701721, zigbeeModel eT093WRO.)

Database entry

Not applicable — this is an already natively supported device (014G2461), not a new one.

Zigbee2MQTT version

2.14.1

External converter
/**
 * Danfoss / Popp / Hive Ally — clones the stock definition and adds a
 * per-day, plain-text weekly schedule. Rationale/history: see the
 * accompanying GitHub issue write-up.
 *
 * Place in `<z2m data dir>/external_converters/`.
 */

const exposes = require('zigbee-herdsman-converters/lib/exposes');
const utils = require('zigbee-herdsman-converters/lib/utils');

const e = exposes.presets;
const ea = exposes.access;

// Same deep-import zigbee-herdsman-converters/src/index.ts uses internally
// to lazy-load each device file: `./devices/*` -> `dist/devices/*.js`.
const {definitions: danfossDefinitions} = require('zigbee-herdsman-converters/devices/danfoss');

// Match by zigbeeModel (hardware-fixed) rather than the cosmetic `model` field.
// eTRV0100 (Danfoss Ally) and eT093WRO (Popp 701721, used for testing here)
// are both whiteLabel aliases of the same shared "014G2461" definition today -
// checking for either is just belt-and-braces, not a guarantee if upstream
// ever splits them into separate definition objects.
const KNOWN_MODEL_IDS = ['eTRV0100', 'eT093WRO'];
const officialAlly = danfossDefinitions.find(
    (d) => Array.isArray(d.zigbeeModel) && d.zigbeeModel.some((m) => KNOWN_MODEL_IDS.includes(m)),
);

if (!officialAlly) {
    throw new Error(
        `[danfoss_ally_schedule_planner] Could not find a Danfoss Ally / Popp definition (looked for ` +
            `${KNOWN_MODEL_IDS.join(' or ')}) inside zigbee-herdsman-converters/devices/danfoss.`,
    );
}

const WEEK_DAYS = [
    {name: 'monday', bit: 2},
    {name: 'tuesday', bit: 4},
    {name: 'wednesday', bit: 8},
    {name: 'thursday', bit: 16},
    {name: 'friday', bit: 32},
    {name: 'saturday', bit: 64},
    {name: 'sunday', bit: 1},
];
const dayToBit = Object.fromEntries(WEEK_DAYS.map((d) => [d.name, d.bit]));
const bitToDay = Object.fromEntries(WEEK_DAYS.map((d) => [d.bit, d.name]));

// eTRV ZCL spec, attribute 0x0201/0x0022 "Number of Daily transitions": fixed at 6.
const MAX_DAILY_TRANSITIONS = 6;

const danfossTextSchedulePlanner = () => ({
    fromZigbee: [
        {
            cluster: 'hvacThermostat',
            type: ['commandGetWeeklyScheduleRsp'],
            convert: (model, msg) => {
                const dayBit = msg.data.dayofweek;
                const scheduleStr = msg.data.transitions
                    .map((t) => {
                        const h = Math.floor(t.transitionTime / 60).toString().padStart(2, '0');
                        const m = (t.transitionTime % 60).toString().padStart(2, '0');
                        return `${h}:${m}/${(t.heatSetpoint / 100).toFixed(1)}`;
                    })
                    .join(' ');

                const result = {};
                for (const [bit, day] of Object.entries(bitToDay)) {
                    if ((dayBit & Number(bit)) !== 0) {
                        result[`schedule_${day}`] = scheduleStr;
                    }
                }
                return result;
            },
        },
    ],
    toZigbee: [
        {
            key: WEEK_DAYS.map((d) => `schedule_${d.name}`),
            convertSet: async (entity, key, value, meta) => {
                const dayName = key.replace('schedule_', '');
                const transitions = value
                    .split(' ')
                    .filter((part) => part.includes('/'))
                    .map((entry) => {
                        const [timeStr, tempStr] = entry.split('/');
                        const [hours, minutes] = timeStr.split(':').map(Number);
                        return {
                            transitionTime: hours * 60 + minutes,
                            heatSetpoint: Math.round(parseFloat(tempStr) * 100),
                        };
                    })
                    .sort((a, b) => a.transitionTime - b.transitionTime);

                if (transitions.length > MAX_DAILY_TRANSITIONS) {
                    throw new Error(
                        `${key}: got ${transitions.length} transitions, but the Danfoss Ally accepts at most ` +
                            `${MAX_DAILY_TRANSITIONS} per day (see the eTRV ZCL spec, attribute 0x0201/0x0022).`,
                    );
                }

                await entity.command(
                    'hvacThermostat',
                    'setWeeklySchedule',
                    {dayofweek: dayToBit[dayName], mode: 1, numoftrans: transitions.length, transitions},
                    utils.getOptions(meta.mapped, entity),
                );
                return {state: {[key]: value}};
            },
        },
        {
            key: ['refresh_schedule'],
            convertSet: async (entity, key, value, meta) => {
                await entity.command(
                    'hvacThermostat',
                    'getWeeklySchedule',
                    {daystoreturn: 127, modetoreturn: 1},
                    utils.getOptions(meta.mapped, entity),
                );
            },
        },
    ],
    exposes: [
        e.enum('refresh_schedule', ea.SET, ['READ_NOW'])
            .withDescription('Fetch the currently stored weekly schedule from the device.')
            .withCategory('config'),
        ...WEEK_DAYS.map((d) =>
            e.text(`schedule_${d.name}`, ea.STATE_SET)
                .withDescription('Format: "HH:MM/Temp HH:MM/Temp ...", e.g. "06:00/21.0 22:00/17.0" (max 6 transitions/day).')
                .withCategory('config'),
        ),
    ],
    isModernExtend: true,
});

module.exports = {
    ...officialAlly,
    description: `${officialAlly.description} + per-day text weekly schedule planner`,
    extend: [...officialAlly.extend, danfossTextSchedulePlanner()],
};
What does/doesn't work with the external definition?

Everything from the stock definition works unchanged (it's cloned, not
re-implemented): climate/setpoints, system mode, all danfoss* attributes,
keypad lockout, programming operation mode, battery, OTA, native time sync.

New feature added here — schedule_mondayschedule_sunday (text, get/set)
and refresh_schedule — tested on a physical Popp 701721 (eT093WRO) on
Zigbee2MQTT 2.14.1: set and read back all seven days plus refresh_schedule,
all working correctly.

Notes
  • This is a feature addition to an already-supported device, not a new-device
    report. I'm using this form rather than a pull request specifically so a
    maintainer can review and adapt the code on their own terms.
  • Why per-day text fields instead of the generic weekly_schedule /
    clear_weekly_schedule exposes (which already exist in the library and are
    already wired into this device): the ZCL SetWeeklySchedule command takes
    ONE dayofweek bitmask plus ONE shared transitions list per call, so
    giving different days different schedules needs one command per day
    regardless — a single combined JSON form doesn't map cleanly onto that, and
    isn't exposed in the frontend for this device today. Seven small,
    independent schedule_<day> fields sidestep that and are easy to
    read/copy-paste between days.
  • Per the Danfoss eTRV ZCL cluster spec, transitions within a day must be
    chronologically ordered (this converter sorts them before sending) and the
    device hard-caps at 6 transitions/day (attribute 0x0201/0x0022) — validated
    client-side here with a clear error.

Disclosure: I'm not a programmer — doing something at this level on my own
was difficult for me previously also now. This is a reworked version of my
original converter (born in pain) for z2m v2.9.1, AI assisted in code analysys of
zigbee-herdsman-converters source and proposed changes — which is exactly why
I'm submitting it as an external converter rather than a pull request.
This issue was also formatted with AI assistance.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading the existing Danfoss definition in zigbee-herdsman-converters/devices/danfoss and the generic weekly_schedule and clear_weekly_schedule exposes mentioned in the issue. Compare the external converter's per-day fields and refresh_schedule behavior with that existing support. Done means the stock device features remain unchanged while all seven schedules can be set and read back, including on the tested Popp 701721.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, typescript
Domain
embedded-iot
Issue type
Feature
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.