Skip to content

Commit

Permalink
Improve weekly schedule configuration for Viessman ZK03840 and Ubisys…
Browse files Browse the repository at this point in the history
… H1 (#5550)

* hvacThermostat: weekly_schedule should also allow a simple {"hour": 19, "minute": 30} object

This so that we can make actually have it described somewhat as expose data as we have no 'time' or 'datetime' picker.

* exposes: support weeklySchedule on climate expose

* Add new exposes to Ubisys H1

* exposes: wrap dayofweek in another abstraction composite

This is getting redicilous, maybe some additions to the exposes format are needed.

* testing - drop me
  • Loading branch information
sjorge authored May 7, 2023
1 parent bb64ed2 commit 35089f7
Show file tree
Hide file tree
Showing 4 changed files with 98 additions and 7 deletions.
64 changes: 60 additions & 4 deletions converters/toZigbee.js
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,29 @@ const converters = {
thermostat_weekly_schedule: {
key: ['weekly_schedule'],
convertSet: async (entity, key, value, meta) => {
/*
* We want to support a simple human creatable format to send a schedule:
{"weekly_schedule": {
"dayofweek": ["monday", "tuesday"],
"transitions": [
{"heatSetpoint": 16, "transitionTime": "0:00"},
{"heatSetpoint": 20, "transitionTime": "18:00"},
{"heatSetpoint": 16, "transitionTime": "19:30"}
]}}
* However exposes is not flexible enough to describe something like this. There is a
* much more verbose format we also support so that exposes work.
{"weekly_schedule": {
"dayofweek": [
{"day": "monday"},
{"day": "tuesday"}
],
"transitions": [
{"heatSetpoint": 16, "transitionTime": {"hour": 0, "minute": 0}},
{"heatSetpoint": 20, "transitionTime": {"hour": 18, "minute": 0}},
{"heatSetpoint": 16, "transitionTime": {"hour": 19, "minute": 30}}
]}}
*/
const payload = {
dayofweek: value.dayofweek,
transitions: value.transitions,
Expand All @@ -1229,16 +1252,19 @@ const converters = {
if (Array.isArray(payload.transitions)) {
// calculate numoftrans
if (typeof value.numoftrans !== 'undefined') {
meta.logger.warn(`weekly_schedule: igonoring provided numoftrans value (${JSON.stringify(value.numoftrans)}),\
this is now calculated automatically`);
meta.logger.warn(
`weekly_schedule: ignoring provided numoftrans value (${JSON.stringify(value.numoftrans)}), ` +
'this is now calculated automatically',
);
}
payload.numoftrans = payload.transitions.length;

// mode is calculated below
if (typeof value.mode !== 'undefined') {
meta.logger.warn(
`weekly_schedule: igonoring provided mode value (${JSON.stringify(value.mode)}),\
this is now calculated automatically`);
`weekly_schedule: ignoring provided mode value (${JSON.stringify(value.mode)}), ` +
'this is now calculated automatically',
);
}
payload.mode = [];

Expand Down Expand Up @@ -1270,6 +1296,27 @@ const converters = {
} else {
elem['transitionTime'] = ((parseInt(time[0]) * 60) + parseInt(time[1]));
}
} else if (typeof elem['transitionTime'] === 'object') {
if (!elem['transitionTime'].hasOwnProperty('hour') || !elem['transitionTime'].hasOwnProperty('minute')) {
throw new Error(
'weekly_schedule: expected 24h time object (e.g. {"hour": 19, "minute": 30}), ' +
`but got '${JSON.stringify(elem['transitionTime'])}'!`,
);
} else if (isNaN(elem['transitionTime']['hour'])) {
throw new Error(
'weekly_schedule: expected time.hour to be a number, ' +
`but got '${elem['transitionTime']['hour']}'!`,
);
} else if (isNaN(elem['transitionTime']['minute'])) {
throw new Error(
'weekly_schedule: expected time.minute to be a number, ' +
`but got '${elem['transitionTime']['minute']}'!`,
);
} else {
elem['transitionTime'] = (
(parseInt(elem['transitionTime']['hour']) * 60) + parseInt(elem['transitionTime']['minute'])
);
}
}
}
} else {
Expand All @@ -1291,6 +1338,15 @@ const converters = {
if (Array.isArray(payload.dayofweek)) {
let dayofweek = 0;
for (let d of payload.dayofweek) {
if (typeof d === 'object') {
if (!d.hasOwnProperty('day')) {
throw new Error(
'weekly_schedule: expected dayofweek to be string or {"day": "str"}, ' +
`but got '${JSON.strinify(d)}'!`,
);
}
d = d.day;
}
// lookup dayofweek bit
d = utils.getKey(constants.thermostatDayOfWeek, d.toLowerCase(), d, Number);
dayofweek |= (1 << d);
Expand Down
3 changes: 2 additions & 1 deletion devices/ubisys.js
Original file line number Diff line number Diff line change
Expand Up @@ -883,7 +883,8 @@ module.exports = [
.withRunningMode(['off', 'heat'])
.withSetpoint('occupied_heating_setpoint', 7, 30, 0.5)
.withLocalTemperature()
.withPiHeatingDemand(ea.STATE_GET),
.withPiHeatingDemand(ea.STATE_GET)
.withWeeklySchedule(['heat']),
exposes.binary('vacation_mode', ea.STATE_GET, true, false)
.withDescription('When Vacation Mode is active the schedule is disabled and unoccupied_heating_setpoint is used.'),
],
Expand Down
6 changes: 4 additions & 2 deletions devices/viessmann.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ module.exports = [
fromZigbee: [fz.legacy.viessmann_thermostat_att_report, fz.battery, fz.legacy.hvac_user_interface],
toZigbee: [tz.thermostat_local_temperature, tz.thermostat_occupied_heating_setpoint, tz.thermostat_control_sequence_of_operation,
tz.thermostat_system_mode, tz.thermostat_keypad_lockout, tz.viessmann_window_open, tz.viessmann_window_open_force,
tz.viessmann_assembly_mode,
tz.viessmann_assembly_mode, tz.thermostat_weekly_schedule, tz.thermostat_clear_weekly_schedule,
],
exposes: [
exposes.climate().withSetpoint('occupied_heating_setpoint', 7, 30, 1)
.withLocalTemperature().withSystemMode(['heat', 'sleep']),
.withLocalTemperature()
.withSystemMode(['heat', 'sleep'])
.withWeeklySchedule(['heat']),
exposes.binary('window_open', ea.STATE_GET, true, false)
.withDescription('Detected by sudden temperature drop or set manually.'),
exposes.binary('window_open_force', ea.ALL, true, false)
Expand Down
32 changes: 32 additions & 0 deletions lib/exposes.js
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,38 @@ class Climate extends Base {
this.features.push(new Enum('ac_louver_position', access, positions).withDescription('Ac louver position of this device'));
return this;
}

withWeeklySchedule(modes, access=a.ALL) {
assert(!this.endpoint, 'Cannot add feature after adding endpoint');
const allowed = ['heat', 'cool'];
modes.forEach((m) => assert(allowed.includes(m)));

const featureDayOfWeek = new List('dayofweek', a.SET, new Composite('day', 'dayofweek', a.SET).withFeature(new Enum('day', a.SET, [
'monday', 'tuesday', 'wednesday', 'thursday', 'friday',
'saturday', 'sunday', 'away_or_vacation',
]))).withLengthMin(1).withLengthMax(8).withDescription('Days on which the schedule will be active.');

const featureTransitionTime = new Composite('time', 'transitionTime', a.SET)
.withFeature(new Numeric('hour', a.SET))
.withFeature(new Numeric('minute', a.SET))
.withDescription('Trigger transition X minutes after 00:00.');
const featureTransitionHeatSetPoint = new Numeric('heatSetpoint', a.SET)
.withDescription('Target heat setpoint');
const featureTransitionCoolSetPoint = new Numeric('coolSetpoint', a.SET)
.withDescription('Target cool setpoint');
let featureTransition = new Composite('transition', 'transition', a.SET).withFeature(featureTransitionTime);
if (modes.includes('heat')) featureTransition = featureTransition.withFeature(featureTransitionHeatSetPoint);
if (modes.includes('cool')) featureTransition = featureTransition.withFeature(featureTransitionCoolSetPoint);
const featureTransitions = new List('transitions', a.SET, featureTransition)
.withLengthMin(1).withLengthMax(10);

const schedule = new Composite('schedule', 'weekly_schedule', access)
.withFeature(featureDayOfWeek)
.withFeature(featureTransitions);

this.features.push(schedule);
return this;
}
}
/**
* The access property is a 3-bit bitmask.
Expand Down

0 comments on commit 35089f7

Please sign in to comment.