diff --git a/src/devices/sonoff.ts b/src/devices/sonoff.ts index 9ee05882bfee2..9d26d8b7a6e00 100644 --- a/src/devices/sonoff.ts +++ b/src/devices/sonoff.ts @@ -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: { @@ -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[], + [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'], @@ -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); diff --git a/src/devices/xiaomi.ts b/src/devices/xiaomi.ts index c41a5e77f2801..8a68c5d73d927 100644 --- a/src/devices/xiaomi.ts +++ b/src/devices/xiaomi.ts @@ -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; @@ -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'], diff --git a/src/lib/modernExtend.ts b/src/lib/modernExtend.ts index f0366457f3537..3a2b77b1f0957 100644 --- a/src/lib/modernExtend.ts +++ b/src/lib/modernExtend.ts @@ -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; diff --git a/test/sonoff.test.ts b/test/sonoff.test.ts new file mode 100644 index 0000000000000..872398d973b03 --- /dev/null +++ b/test/sonoff.test.ts @@ -0,0 +1,305 @@ +import * as index from "../src/index"; +import {Definition, Fz, KeyValueAny, Tz, Zh} from "../lib/types"; +import {Endpoint, Entity} from "zigbee-herdsman/dist/controller/model"; + +interface State { + readonly weekly_schedule: { + readonly sunday: string; + readonly monday: string; + readonly tuesday: string; + readonly wednesday: string; + readonly thursday: string; + readonly friday: string; + readonly saturday: string; + } +} + +describe('Sonoff TRVZB', () => { + let trv: Definition; + + beforeEach(() => { + trv = index.findByModel('TRVZB'); + }); + + describe('weekly schedule', () => { + describe('fromZigbee', () => { + let fzConverter: Fz.Converter; + let meta: Fz.Meta; + + beforeEach(() => { + fzConverter = trv.fromZigbee.find((c) => c.cluster === 'hvacThermostat' && c.type.includes('commandGetWeeklyScheduleRsp')); + + meta = { + state: {}, + logger: null, + device: null, + deviceExposesChanged: null + }; + }); + + const days = [ + { dayofweek: 0x01, day: 'sunday' }, + { dayofweek: 0x02, day: 'monday' }, + { dayofweek: 0x04, day: 'tuesday' }, + { dayofweek: 0x08, day: 'wednesday' }, + { dayofweek: 0x10, day: 'thursday' }, + { dayofweek: 0x20, day: 'friday' }, + { dayofweek: 0x40, day: 'saturday' }, + ] + + describe.each(days)('when a commandGetWeeklyScheduleRsp message is received for $day', ({dayofweek, day}) => { + it('should set state', () => { + const msg: Fz.Message = { + data: { + dayofweek: dayofweek, + transitions: [{ + transitionTime: 0, + heatSetpoint: 500, + },{ + transitionTime: 90, + heatSetpoint: 1000, + }] + }, + endpoint: null, + device: null, + meta: null, + groupID: null, + type: 'commandGetWeeklyScheduleRsp', + cluster: 'hvacThermostat', + linkquality: 0 + }; + + const state = fzConverter.convert(trv, msg, null, null, meta) as State; + + expect(state.weekly_schedule).toEqual({ + [day]: "00:00/5 01:30/10" + }); + }); + }); + + describe('when multiple commandGetWeeklyScheduleRsp messages are received for different days', () =>{ + let state: State; + + beforeEach(() => { + const msg1: Fz.Message = { + data: { + dayofweek: 0x01, + transitions: [{ + transitionTime: 0, + heatSetpoint: 500, + },{ + transitionTime: 90, + heatSetpoint: 1000, + }] + }, + endpoint: null, + device: null, + meta: null, + groupID: null, + type: 'commandGetWeeklyScheduleRsp', + cluster: 'hvacThermostat', + linkquality: 0 + }; + + const msg2: Fz.Message = { + data: { + dayofweek: 0x02, + transitions: [{ + transitionTime: 60, + heatSetpoint: 550, + },{ + transitionTime: 180, + heatSetpoint: 1250, + }] + }, + endpoint: null, + device: null, + meta: null, + groupID: null, + type: 'commandGetWeeklyScheduleRsp', + cluster: 'hvacThermostat', + linkquality: 0 + }; + + meta.state = fzConverter.convert(trv, msg1, null, null, meta) as KeyValueAny; + state = fzConverter.convert(trv, msg2, null, null, meta) as State; + }); + + it('should merge the schedules into state', () => { + expect(state.weekly_schedule).toEqual({ + sunday: "00:00/5 01:30/10", + monday: "01:00/5.5 03:00/12.5" + }); + }); + }); + }); + + describe('toZigbee', () => { + let tzConverter: Tz.Converter; + let meta: Tz.Meta; + let commandFn: jest.Mock; + let endpoint: Endpoint; + + const invalidTransitions = [ + { transition: '', description: 'empty string' }, + { transition: '0:00/5', description: 'hours not two digits' }, + { transition: '24:00/5', description: 'hours greater than 23' }, + { transition: '23:0/5', description: 'minutes not two digits' }, + { transition: '23:60/5', description: 'minutes greater than 59' }, + { transition: '23:59', description: 'missing slash' }, + { transition: '23:59/', description: 'missing temperature' }, + { transition: '23:59/-1', description: 'negative temperature' }, + { transition: '23:59/523:59/5', description: 'missing space separator' }, + { transition: '00:00/10.1', description: 'temperature decimal point is not 0.5' }, + ]; + + beforeEach(() => { + tzConverter = trv.toZigbee.find((c) => c.key.includes('weekly_schedule')); + + meta = { + state: {}, + logger: null, + device: null, + message: null, + mapped: null, + options: null, + endpoint_name: null + }; + + commandFn = jest.fn(); + + endpoint = { + command: commandFn + } as unknown as Endpoint; + }); + + it.each(invalidTransitions)('should throw error if transition format is invalid ($description)', async ({transition, description}) => { + await expect( + tzConverter.convertSet(endpoint, 'weekly_schedule', { + monday: transition + }, meta)).rejects.toEqual(new Error(`Invalid schedule: transitions must be in format HH:mm/temperature (e.g. 12:00/15.5), found: ${transition}`)); + }); + + it('should throw error if first transition does not start at 00:00', async () => { + await expect( + tzConverter.convertSet(endpoint, 'weekly_schedule', { + monday: '00:01/5' + }, meta)).rejects.toEqual(new Error('Invalid schedule: the first transition of each day should start at 00:00')); + }); + + it('should throw error if day has more than 6 transitions', async () => { + await expect( + tzConverter.convertSet(endpoint, 'weekly_schedule', { + monday: '00:00/1 00:00/1 00:00/1 00:00/1 00:00/1 00:00/1 00:00/1' + }, meta)).rejects.toEqual(new Error('Invalid schedule: days must have no more than 6 transitions')); + }); + + it.each([3, 36])('should throw error if temperature value is outside of valid range ($temperature) ', async (temperature) => { + await expect( + tzConverter.convertSet(endpoint, 'weekly_schedule', { + monday: `00:00/${temperature}` + }, meta)).rejects.toEqual(new Error(`Invalid schedule: temperature value must be between 4-35 (inclusive), found: ${temperature}`)); + }); + + it('should throw error if day name is invalid', async () => { + await expect( + tzConverter.convertSet(endpoint, 'weekly_schedule', { + notaday: `00:00/5` + }, meta)).rejects.toEqual(new Error('Invalid schedule: invalid day name, found: notaday')); + }); + + it('should send setWeeklySchedule command if transitions are valid', async () => { + await tzConverter.convertSet(endpoint, 'weekly_schedule', { + sunday: `00:00/5 06:30/10.5 12:00/15 18:30/20 20:45/15.5 23:00/4` + }, meta); + + expect(commandFn).toHaveBeenCalledWith('hvacThermostat', 'setWeeklySchedule', { + dayofweek: 1, + numoftrans: 6, + mode: 1, + transitions: [{ + heatSetpoint: 500, + transitionTime: 0 + },{ + heatSetpoint: 1050, + transitionTime: 390 + },{ + heatSetpoint: 1500, + transitionTime: 720 + },{ + heatSetpoint: 2000, + transitionTime: 1110 + },{ + heatSetpoint: 1550, + transitionTime: 1245 + },{ + heatSetpoint: 400, + transitionTime: 1380 + }] + }, {}); + }); + + it('should send setWeeklySchedule command with transitions in ascending time order', async () => { + await tzConverter.convertSet(endpoint, 'weekly_schedule', { + sunday: `00:00/5 12:00/15 06:30/10.5` + }, meta); + + expect(commandFn).toHaveBeenCalledWith('hvacThermostat', 'setWeeklySchedule', { + dayofweek: 1, + numoftrans: 3, + mode: 1, + transitions: [{ + heatSetpoint: 500, + transitionTime: 0 + },{ + heatSetpoint: 1050, + transitionTime: 390 + },{ + heatSetpoint: 1500, + transitionTime: 720 + }] + }, {}); + }); + + it('should send a setWeeklySchedule command for each day', async () => { + await tzConverter.convertSet(endpoint, 'weekly_schedule', { + sunday: `00:00/5`, + monday: `00:00/10`, + tuesday: `00:00/15`, + }, meta); + + expect(commandFn).toHaveBeenCalledTimes(3); + + expect(commandFn).toHaveBeenCalledWith('hvacThermostat', 'setWeeklySchedule', { + dayofweek: 1, + numoftrans: 1, + mode: 1, + transitions: [{ + heatSetpoint: 500, + transitionTime: 0 + }] + }, {}); + + expect(commandFn).toHaveBeenCalledWith('hvacThermostat', 'setWeeklySchedule', { + dayofweek: 2, + numoftrans: 1, + mode: 1, + transitions: [{ + heatSetpoint: 1000, + transitionTime: 0 + }] + }, {}); + + expect(commandFn).toHaveBeenCalledWith('hvacThermostat', 'setWeeklySchedule', { + dayofweek: 4, + numoftrans: 1, + mode: 1, + transitions: [{ + heatSetpoint: 1500, + transitionTime: 0 + }] + }, {}); + }); + }); + }); +});