Skip to content

Commit

Permalink
feat: Add ability to set weekly schedule to Sonoff TRVZB (#6443)
Browse files Browse the repository at this point in the history
* Expose schedule in UI.

* Send weekly schedule to TRV

* Handle all use cases; add tests

* fix: linting issues

* Update sonoff.ts

* Update modernExtend.ts

* Update xiaomi.ts

---------

Co-authored-by: Koen Kanters <koenkanters94@gmail.com>
  • Loading branch information
photomoose and Koenkk authored Jan 20, 2024
1 parent c8a2cee commit 241fc9a
Show file tree
Hide file tree
Showing 4 changed files with 456 additions and 16 deletions.
126 changes: 123 additions & 3 deletions src/devices/sonoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import fz from '../converters/fromZigbee';
import tz from '../converters/toZigbee';
import * as constants from '../lib/constants';
import * as reporting from '../lib/reporting';
import {binary, forcePowerSource, numeric, enumLookup, onOff} from '../lib/modernExtend';
import {Definition, Fz, KeyValue} from '../lib/types';
import {binary, enumLookup, forcePowerSource, numeric, onOff, customTimeResponse} from '../lib/modernExtend';
import {Definition, Fz, KeyValue, KeyValueAny, ModernExtend, Tz} from '../lib/types';
import * as ota from '../lib/ota';
import * as utils from '../lib/utils';

const e = exposes.presets;
const ea = exposes.access;
import * as ota from '../lib/ota';

const fzLocal = {
router_config: {
Expand All @@ -23,6 +24,123 @@ const fzLocal = {
} satisfies Fz.Converter,
};

const sonoffExtend = {
weeklySchedule: (): ModernExtend => {
const exposes = e.composite('schedule', 'weekly_schedule', ea.STATE_SET)
.withDescription('The preset heating schedule to use when the system mode is set to "auto" (indicated with ⏲ on the TRV). ' +
'Up to 6 transitions can be defined per day, where a transition is expressed in the format \'HH:mm/temperature\', each ' +
'separated by a space. The first transition for each day must start at 00:00 and the valid temperature range is 4-35°C ' +
'(in 0.5°C steps). The temperature will be set at the time of the first transition until the time of the next transition, ' +
'e.g. \'04:00/20 10:00/25\' will result in the temperature being set to 20°C at 04:00 until 10:00, when it will change to 25°C.')
.withFeature(e.text('sunday', ea.STATE_SET))
.withFeature(e.text('monday', ea.STATE_SET))
.withFeature(e.text('tuesday', ea.STATE_SET))
.withFeature(e.text('wednesday', ea.STATE_SET))
.withFeature(e.text('thursday', ea.STATE_SET))
.withFeature(e.text('friday', ea.STATE_SET))
.withFeature(e.text('saturday', ea.STATE_SET));

const fromZigbee: Fz.Converter[] = [{
cluster: 'hvacThermostat',
type: ['commandGetWeeklyScheduleRsp'],
convert: (model, msg, publish, options, meta) => {
const day = Object.entries(constants.thermostatDayOfWeek)
.find((d) => msg.data.dayofweek & 1<<+d[0])[1];

const transitions = msg.data.transitions
.map((t: { heatSetpoint: number, transitionTime: number }) => {
const totalMinutes = t.transitionTime;
const hours = totalMinutes / 60;
const rHours = Math.floor(hours);
const minutes = (hours - rHours) * 60;
const rMinutes = Math.round(minutes);
const strHours = rHours.toString().padStart(2, '0');
const strMinutes = rMinutes.toString().padStart(2, '0');

return `${strHours}:${strMinutes}/${t.heatSetpoint / 100}`;
})
.sort()
.join(' ');

return {
weekly_schedule: {
...meta.state.weekly_schedule as Record<string, string>[],
[day]: transitions,
},
};
},
}];

const toZigbee: Tz.Converter[] = [{
key: ['weekly_schedule'],
convertSet: async (entity, key, value, meta) => {
// Transition format: HH:mm/temperature
const transitionRegex = /^(0[0-9]|1[0-9]|2[0-3]):([0-5][0-9])\/(\d+(\.5)?)$/;

utils.assertObject(value, key);

for (const dayOfWeekName of Object.keys(value)) {
const dayKey = utils.getKey(constants.thermostatDayOfWeek, dayOfWeekName.toLowerCase(), null);

if (dayKey === null) {
throw new Error(`Invalid schedule: invalid day name, found: ${dayOfWeekName}`);
}

const dayOfWeekBit = Number(dayKey);

const transitions = value[dayOfWeekName].split(' ').sort();

if (transitions.length > 6) {
throw new Error('Invalid schedule: days must have no more than 6 transitions');
}

const payload: KeyValueAny = {
dayofweek: (1 << Number(dayOfWeekBit)),
numoftrans: transitions.length,
mode: (1 << 0), // heat
transitions: [],
};

for (const transition of transitions) {
const matches = transition.match(transitionRegex);

if (!matches) {
throw new Error('Invalid schedule: transitions must be in format HH:mm/temperature (e.g. 12:00/15.5), ' +
'found: ' + transition);
}

const hour = parseInt(matches[1]);
const mins = parseInt(matches[2]);
const temp = parseFloat(matches[3]);

if (temp < 4 || temp > 35) {
throw new Error(`Invalid schedule: temperature value must be between 4-35 (inclusive), found: ${temp}`);
}

payload.transitions.push({
transitionTime: (hour * 60) + mins,
heatSetpoint: Math.round(temp * 100),
});
}

if (payload.transitions[0].transitionTime !== 0) {
throw new Error('Invalid schedule: the first transition of each day should start at 00:00');
}

await entity.command('hvacThermostat', 'setWeeklySchedule', payload, utils.getOptions(meta.mapped, entity));
}
},
}];

return {
exposes: [exposes],
fromZigbee,
toZigbee,
isModernExtend: true,
};
},
};

const definitions: Definition[] = [
{
zigbeeModel: ['NSPanelP-Router'],
Expand Down Expand Up @@ -504,6 +622,8 @@ const definitions: Definition[] = [
unit: 'mV',
access: 'STATE_GET',
}),
sonoffExtend.weeklySchedule(),
customTimeResponse('1970_UTC'),
],
configure: async (device, coordinatorEndpoint, logger) => {
const endpoint = device.getEndpoint(1);
Expand Down
15 changes: 2 additions & 13 deletions src/devices/xiaomi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
light, numeric, binary, enumLookup, forceDeviceType,
temperature, humidity, forcePowerSource, quirkAddEndpointCluster,
quirkCheckinInterval,
customTimeResponse,
} from '../lib/modernExtend';
const e = exposes.presets;
const ea = exposes.access;
Expand Down Expand Up @@ -2041,19 +2042,7 @@ const definitions: Definition[] = [
exposes: [e.switch(), e.power().withAccess(ea.STATE_GET), e.energy(), e.device_temperature(), e.voltage(),
e.power_outage_memory(), e.led_disabled_night(),
e.auto_off(30)],
onEvent: async (type, data, device) => {
device.skipTimeResponse = true;
// According to the Zigbee the genTime.time should be the seconds since 1 January 2020 UTC
// However the device expects this to be the seconds since 1 January in the local time zone.
// Disable the responses of zigbee-herdsman and respond here instead.
// https://github.com/Koenkk/zigbee-herdsman-converters/pull/2843#issuecomment-888532667
if (type === 'message' && data.type === 'read' && data.cluster === 'genTime') {
const oneJanuary2000 = new Date('January 01, 2000 00:00:00 UTC+00:00').getTime();
const secondsUTC = Math.round(((new Date()).getTime() - oneJanuary2000) / 1000);
const secondsLocal = secondsUTC - (new Date()).getTimezoneOffset() * 60;
device.getEndpoint(1).readResponse('genTime', data.meta.zclTransactionSequenceNumber, {time: secondsLocal});
}
},
extend: [customTimeResponse('2000_LOCAL')],
},
{
zigbeeModel: ['lumi.ctrl_86plug', 'lumi.ctrl_86plug.aq1'],
Expand Down
26 changes: 26 additions & 0 deletions src/lib/modernExtend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,32 @@ export function reconfigureReportingsOnDeviceAnnounce(): ModernExtend {
return {onEvent, isModernExtend: true};
}

export function customTimeResponse(start: '1970_UTC' | '2000_LOCAL'): ModernExtend {
const onEvent: OnEvent = async (type, data, device, options, state: KeyValue) => {
device.skipTimeResponse = true;
// The Zigbee Cluster Library specification states that the genTime.time response should be the
// number of seconds since 1st Jan 2000 00:00:00 UTC. This extend modifies that:
// 1970_UTC: number of seconds since the Unix Epoch (1st Jan 1970 00:00:00 UTC)
// 2000_LOCAL: seconds since 1 January in the local time zone.
// Disable the responses of zigbee-herdsman and respond here instead.
if (type === 'message' && data.type === 'read' && data.cluster === 'genTime') {
const payload: KeyValue = {};
if (start === '1970_UTC') {
const time = Math.round(((new Date()).getTime()) / 1000);
payload.time = time;
payload.localTime = time - (new Date()).getTimezoneOffset() * 60;
} else if (start === '2000_LOCAL') {
const oneJanuary2000 = new Date('January 01, 2000 00:00:00 UTC+00:00').getTime();
const secondsUTC = Math.round(((new Date()).getTime() - oneJanuary2000) / 1000);
payload.time = secondsUTC - (new Date()).getTimezoneOffset() * 60;
}
data.endpoint.readResponse('genTime', data.meta.zclTransactionSequenceNumber, payload);
}
};

return {onEvent, isModernExtend: true};
}

export function forceDeviceType(args: {type: 'EndDevice' | 'Router'}): ModernExtend {
const configure: Configure = async (device, coordinatorEndpoint, logger) => {
device.type = args.type;
Expand Down
Loading

0 comments on commit 241fc9a

Please sign in to comment.