Skip to content

Commit 642c1ff

Browse files
committed
Add extended vacuum device
Signed-off-by: Michael Krug <michi.krug@gmail.com>
1 parent 74fd3b3 commit 642c1ff

11 files changed

Lines changed: 576 additions & 15 deletions

File tree

docs/USAGE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,24 @@ Switch { ga="Washer" [ inverted=false ] }
237237
Switch { ga="Dishwasher" [ inverted=false ] }
238238
```
239239
240+
### Vacuum as Group with advanced functionality
241+
242+
| | |
243+
|---|---|
244+
| **Device Type** | [Vacuum](https://developers.home.google.com/cloud-to-cloud/guides/vacuum) |
245+
| **Supported Traits** | [StartStop](https://developers.home.google.com/cloud-to-cloud/traits/startstop), [Dock](https://developers.home.google.com/cloud-to-cloud/traits/dock), [Locator](https://developers.home.google.com/cloud-to-cloud/traits/locator), [RunCycle](https://developers.home.google.com/cloud-to-cloud/traits/runcycle), [EnergyStorage](https://developers.home.google.com/cloud-to-cloud/traits/energystorage) |
246+
| **Supported Items** | Group as `Vacuum` with the following members:<br>(required) Switch as `vacuumPower`<br>(optional) Switch as `vacuumDock`<br>(optional) Switch as `vacuumLocate`<br>(optional) Number or Dimmer as `vacuumBattery`<br>(optional) String as `vacuumCurrentCycle` |
247+
| **Configuration** | (optional) `inverted=true/false`<br>(optional) `checkState=true/false` |
248+
249+
```shell
250+
Group vacuumRobot { ga="Vacuum" [ checkState=true ] }
251+
Switch vacuumPowerItem (vacuumRobot) { ga="vacuumPower" }
252+
Switch vacuumDockItem (vacuumRobot) { ga="vacuumDock" }
253+
Switch vacuumLocateItem (vacuumRobot) { ga="vacuumLocate" }
254+
Number vacuumBatteryItem (vacuumRobot) { ga="vacuumBattery" }
255+
String vacuumCycleItem (vacuumRobot) { ga="vacuumCurrentCycle" }
256+
```
257+
240258
### Lock
241259
242260
| | |

functions/commands/dock.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
const DefaultCommand = require('./default.js');
2+
3+
class Dock extends DefaultCommand {
4+
static get type() {
5+
return 'action.devices.commands.Dock';
6+
}
7+
8+
static validateParams() {
9+
return true; // No required parameters for dock command
10+
}
11+
12+
static getItemName(device) {
13+
if (this.getDeviceType(device) === 'Vacuum') {
14+
const members = this.getMembers(device);
15+
if ('vacuumDock' in members) {
16+
return members.vacuumDock;
17+
}
18+
throw { statusCode: 400 };
19+
}
20+
return device.id;
21+
}
22+
23+
static convertParamsToValue() {
24+
return 'ON'; // Dock command always sends ON to dock the vacuum
25+
}
26+
27+
static getResponseStates() {
28+
return {
29+
isDocked: true,
30+
isRunning: false,
31+
isPaused: false
32+
};
33+
}
34+
35+
static checkCurrentState(target, state) {
36+
if (target === state) {
37+
throw { errorCode: 'alreadyDocked' };
38+
}
39+
}
40+
}
41+
42+
module.exports = Dock;

functions/commands/locate.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const DefaultCommand = require('./default.js');
2+
3+
class Locate extends DefaultCommand {
4+
static get type() {
5+
return 'action.devices.commands.Locate';
6+
}
7+
8+
static validateParams() {
9+
return true; // No required parameters for locate command
10+
}
11+
12+
static getItemName(device) {
13+
if (this.getDeviceType(device) === 'Vacuum') {
14+
const members = this.getMembers(device);
15+
if ('vacuumLocate' in members) {
16+
return members.vacuumLocate;
17+
}
18+
throw { statusCode: 400 };
19+
}
20+
return device.id;
21+
}
22+
23+
static convertParamsToValue() {
24+
return 'ON'; // Locate command sends ON to trigger the locate function
25+
}
26+
27+
static checkCurrentState() {
28+
return;
29+
}
30+
}
31+
32+
module.exports = Locate;

functions/commands/startstop.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,17 @@ class StartStop extends DefaultCommand {
2020
return params.start ? 'ON' : 'OFF';
2121
}
2222

23+
static getItemName(device) {
24+
if (this.getDeviceType(device) === 'Vacuum') {
25+
const members = this.getMembers(device);
26+
if ('vacuumPower' in members) {
27+
return members.vacuumPower;
28+
}
29+
throw { statusCode: 400 };
30+
}
31+
return device.id;
32+
}
33+
2334
static getResponseStates(params) {
2435
return {
2536
isRunning: params.start,

functions/devices/default.js

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -136,24 +136,36 @@ class DefaultDevice {
136136
}
137137

138138
/**
139-
* @returns {object}
139+
* Gets supported members from an openHAB group item
140+
* @param {object} item - The openHAB item with potential members
141+
* @returns {object} Mapped members with their names and states
140142
*/
141143
static getMembers(item) {
142144
const supportedMembers = this.supportedMembers;
143145
const members = {};
144-
if (item.members && item.members.length) {
145-
item.members.forEach((member) => {
146-
if (member.metadata && member.metadata.ga) {
147-
const memberType = supportedMembers.find((m) => {
148-
const memberType = (member.groupType || member.type || '').split(':')[0];
149-
return m.types.includes(memberType) && member.metadata.ga.value.toLowerCase() === m.name.toLowerCase();
150-
});
151-
if (memberType) {
152-
members[memberType.name] = { name: member.name, state: member.state };
153-
}
154-
}
155-
});
146+
// Early return if no members or supportedMembers
147+
if (!item.members?.length || !supportedMembers.length) {
148+
return members;
156149
}
150+
item.members.forEach((member) => {
151+
// Skip members without Google Assistant metadata
152+
if (!member.metadata?.ga?.value) {
153+
return;
154+
}
155+
const memberType = (member.groupType || member.type || '').split(':')[0];
156+
const gaValue = member.metadata.ga.value.toLowerCase();
157+
// Find matching supported member by type and name
158+
const matchedType = supportedMembers.find(
159+
(supportedMember) =>
160+
supportedMember.types.includes(memberType) && supportedMember.name.toLowerCase() === gaValue
161+
);
162+
if (matchedType) {
163+
members[matchedType.name] = {
164+
name: member.name,
165+
state: member.state
166+
};
167+
}
168+
});
157169
return members;
158170
}
159171
}

functions/devices/simplevacuum.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const StartStopSwitch = require('./startstopswitch.js');
2+
3+
class SimpleVacuum extends StartStopSwitch {
4+
static get type() {
5+
return 'action.devices.types.VACUUM';
6+
}
7+
}
8+
9+
module.exports = SimpleVacuum;

functions/devices/vacuum.js

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,101 @@
1-
const StartStopSwitch = require('./startstopswitch.js');
1+
const DefaultDevice = require('./default.js');
22

3-
class Vacuum extends StartStopSwitch {
3+
class Vacuum extends DefaultDevice {
44
static get type() {
55
return 'action.devices.types.VACUUM';
66
}
7+
8+
static getTraits(item) {
9+
const members = this.getMembers(item);
10+
const traits = ['action.devices.traits.StartStop'];
11+
if ('vacuumDock' in members) traits.push('action.devices.traits.Dock');
12+
if ('vacuumLocate' in members) traits.push('action.devices.traits.Locator');
13+
if ('vacuumCurrentCycle' in members) traits.push('action.devices.traits.RunCycle');
14+
if ('vacuumBattery' in members) traits.push('action.devices.traits.EnergyStorage');
15+
return traits;
16+
}
17+
18+
static get requiredItemTypes() {
19+
return ['Group'];
20+
}
21+
22+
static matchesDeviceType(item) {
23+
return super.matchesDeviceType(item) && Object.keys(this.getMembers(item)).length > 0;
24+
}
25+
26+
static get supportedMembers() {
27+
return [
28+
{ name: 'vacuumPower', types: ['Switch'] },
29+
{ name: 'vacuumDock', types: ['Switch'] },
30+
{ name: 'vacuumBattery', types: ['Number', 'Dimmer'] },
31+
{ name: 'vacuumLocate', types: ['Switch'] },
32+
{ name: 'vacuumCurrentCycle', types: ['String'] }
33+
];
34+
}
35+
36+
static getAttributes(item) {
37+
const attributes = {
38+
pausable: false
39+
};
40+
41+
const members = this.getMembers(item);
42+
if ('vacuumBattery' in members) {
43+
attributes.queryOnlyEnergyStorage = true;
44+
}
45+
46+
return attributes;
47+
}
48+
49+
static getState(item) {
50+
const state = {
51+
isRunning: false,
52+
isPaused: false
53+
};
54+
const config = this.getConfig(item);
55+
const members = this.getMembers(item);
56+
57+
for (const member in members) {
58+
switch (member) {
59+
case 'vacuumPower': {
60+
let isRunning = members[member].state === 'ON';
61+
if (config.inverted === true) {
62+
isRunning = !isRunning;
63+
}
64+
state.isRunning = isRunning;
65+
state.isPaused = false; // Default to not paused when running/stopped
66+
break;
67+
}
68+
69+
case 'vacuumDock':
70+
state.isDocked = members[member].state === 'ON';
71+
break;
72+
73+
case 'vacuumBattery': {
74+
const batteryLevel = parseInt(members[member].state) || 0;
75+
state.descriptiveCapacityRemaining = 'FULL';
76+
state.capacityRemaining = [{ unit: 'PERCENTAGE', rawValue: batteryLevel }];
77+
if (batteryLevel <= 15) {
78+
state.descriptiveCapacityRemaining = 'CRITICALLY_LOW';
79+
} else if (batteryLevel <= 25) {
80+
state.descriptiveCapacityRemaining = 'LOW';
81+
} else if (batteryLevel <= 75) {
82+
state.descriptiveCapacityRemaining = 'MEDIUM';
83+
}
84+
break;
85+
}
86+
87+
case 'vacuumCurrentCycle':
88+
state.currentRunCycle = [
89+
{
90+
currentCycle: members[member].state || 'unknown',
91+
lang: 'en'
92+
}
93+
];
94+
break;
95+
}
96+
}
97+
return state;
98+
}
799
}
8100

9101
module.exports = Vacuum;

tests/commands/dock.test.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
const Command = require('../../functions/commands/dock.js');
2+
3+
describe('Dock Command', () => {
4+
test('validateParams', () => {
5+
expect(Command.validateParams()).toBe(true);
6+
expect(Command.validateParams({})).toBe(true);
7+
expect(Command.validateParams({ anyParam: 'value' })).toBe(true);
8+
});
9+
10+
describe('getItemName', () => {
11+
test('getItemName - Group with vacuumDock member', () => {
12+
const device = {
13+
id: 'VacuumGroup',
14+
customData: {
15+
deviceType: 'Vacuum',
16+
members: {
17+
vacuumDock: 'VacuumDockSwitch'
18+
}
19+
}
20+
};
21+
expect(Command.getItemName(device)).toBe('VacuumDockSwitch');
22+
});
23+
24+
test('getItemName - simple Switch item', () => {
25+
const device = {
26+
id: 'VacuumSwitch',
27+
customData: {
28+
itemType: 'Switch'
29+
}
30+
};
31+
expect(Command.getItemName(device)).toBe('VacuumSwitch');
32+
});
33+
});
34+
35+
test('convertParamsToValue', () => {
36+
expect(Command.convertParamsToValue()).toBe('ON');
37+
expect(Command.convertParamsToValue({})).toBe('ON');
38+
expect(Command.convertParamsToValue({ anyParam: 'value' })).toBe('ON');
39+
});
40+
41+
test('getResponseStates', () => {
42+
expect(Command.getResponseStates()).toStrictEqual({
43+
isDocked: true,
44+
isRunning: false,
45+
isPaused: false
46+
});
47+
});
48+
49+
test('checkCurrentState', () => {
50+
expect(Command.checkCurrentState('OFF', 'ON')).toBeUndefined();
51+
52+
expect.assertions(2);
53+
try {
54+
Command.checkCurrentState('ON', 'ON');
55+
} catch (e) {
56+
expect(e.errorCode).toBe('alreadyDocked');
57+
}
58+
});
59+
});

tests/commands/locate.test.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
const Command = require('../../functions/commands/locate.js');
2+
3+
describe('Locate Command', () => {
4+
test('validateParams', () => {
5+
expect(Command.validateParams()).toBe(true);
6+
expect(Command.validateParams({})).toBe(true);
7+
expect(Command.validateParams({ anyParam: 'value' })).toBe(true);
8+
});
9+
10+
describe('getItemName', () => {
11+
test('getItemName - Group with vacuumLocate member', () => {
12+
const device = {
13+
id: 'VacuumGroup',
14+
customData: {
15+
deviceType: 'Vacuum',
16+
members: {
17+
vacuumLocate: 'VacuumLocateSwitch'
18+
}
19+
}
20+
};
21+
expect(Command.getItemName(device)).toBe('VacuumLocateSwitch');
22+
});
23+
24+
test('getItemName - simple Switch item', () => {
25+
const device = {
26+
id: 'VacuumSwitch',
27+
customData: {
28+
itemType: 'Switch'
29+
}
30+
};
31+
expect(Command.getItemName(device)).toBe('VacuumSwitch');
32+
});
33+
});
34+
35+
test('convertParamsToValue', () => {
36+
expect(Command.convertParamsToValue()).toBe('ON');
37+
expect(Command.convertParamsToValue({})).toBe('ON');
38+
expect(Command.convertParamsToValue({ anyParam: 'value' })).toBe('ON');
39+
});
40+
41+
test('checkCurrentState', () => {
42+
// Locate command can always be executed
43+
expect(Command.checkCurrentState('ON', 'ON')).toBeUndefined();
44+
expect(Command.checkCurrentState('OFF', 'ON')).toBeUndefined();
45+
expect(Command.checkCurrentState('ON', 'OFF')).toBeUndefined();
46+
});
47+
});

0 commit comments

Comments
 (0)