Skip to content

Commit 9a1e155

Browse files
authored
Merge pull request #679 from openhab/rotation-trait
Add support for rotation trait
2 parents 6741b98 + ea93102 commit 9a1e155

18 files changed

Lines changed: 1140 additions & 41 deletions

docs/USAGE.md

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ If you have any issues, questions or an idea for additional features, please tak
2727
This documentation refers to release [v4.1.0](https://github.com/openhab/openhab-google-assistant/releases/tag/v4.1.0) of [openHAB Google Assistant](https://github.com/openhab/openhab-google-assistant) published on 2025-07-23
2828
:::
2929

30+
### v4.2.0 (Upcoming)
31+
32+
- Added [Rotation trait](https://developers.home.google.com/cloud-to-cloud/traits/rotation) support to Awning, Blinds, Curtain, Pergola and Shutter devices
33+
3034
### v4.1.0
3135

3236
- Added `queryOnly=true` option to [`Switch`](#switch) devices indicating that the device can only be queried for state information and not be controlled
@@ -496,9 +500,9 @@ Dimmer humidifierFanSpeedItem (humidifierGroup) { ga="humidifierFanSpeed" }
496500
| | |
497501
|---|---|
498502
| **Device Type** | [Awning](https://developers.home.google.com/cloud-to-cloud/guides/awning), [Blinds](https://developers.home.google.com/cloud-to-cloud/guides/blinds), [Curtain](https://developers.home.google.com/cloud-to-cloud/guides/curtain), [Door](https://developers.home.google.com/cloud-to-cloud/guides/door), [Garage](https://developers.home.google.com/cloud-to-cloud/guides/garage), [Gate](https://developers.home.google.com/cloud-to-cloud/guides/gate), [Pergola](https://developers.home.google.com/cloud-to-cloud/guides/pergola), [Shutter](https://developers.home.google.com/cloud-to-cloud/guides/shutter), [Window](https://developers.home.google.com/cloud-to-cloud/guides/window) |
499-
| **Supported Traits** | [OpenClose](https://developers.home.google.com/cloud-to-cloud/traits/openclose), [StartStop](https://developers.home.google.com/cloud-to-cloud/traits/startstop) |
500-
| **Supported Items** | Contact (no device control), Switch (no open percentage), Rollershutter |
501-
| **Configuration** | (optional) `discreteOnly=true/false`<br>(optional) `queryOnly=true/false`<br>(optional) `inverted=true/false`<br>(optional) `checkState=true/false` |
503+
| **Supported Traits** | [OpenClose](https://developers.home.google.com/cloud-to-cloud/traits/openclose), [StartStop](https://developers.home.google.com/cloud-to-cloud/traits/startstop), [Rotation](https://developers.home.google.com/cloud-to-cloud/traits/rotation) (awning, blinds, curtain, pergola, shutter - requires group configuration) |
504+
| **Supported Items** | Contact (no device control), Switch (no open percentage), Rollershutter, Group (for rotation support) |
505+
| **Configuration** | (optional) `discreteOnly=true/false`<br>(optional) `queryOnly=true/false`<br>(optional) `inverted=true/false`<br>(optional) `checkState=true/false`<br>**Rotation Support (Groups with shutterRotation only):**<br>(optional) `supportsDegrees=true/false` (default: true)<br>(optional) `supportsContinuousRotation=true/false` (default: false)<br>(optional) `rotationDegreesMin=degrees` (default: 0)<br>(optional) `rotationDegreesMax=degrees` (default: 90) |
502506
503507
Blinds and similar devices should always use the `Rollershutter` item type for proper functionality.
504508
Since Google and openHAB use the opposite percentage value for "opened" and "closed", the action will translate this automatically.
@@ -507,7 +511,64 @@ If the values are still inverted in your case, you can state the `inverted=true`
507511
Since Google only tells the open percentage (and not the verb "close" or "down"), it can not be differentiated between saying "set blind to 100%" or "open blind".
508512
Therefore, it is not possible to "not invert" the verbs, if the user chooses to invert the numbers.
509513
514+
#### Rotation Support for Awning, Blinds, Curtain, Pergola, and Shutter
515+
516+
Awnings, blinds, curtains, pergolas, and shutters support the [Rotation trait](https://developers.home.google.com/cloud-to-cloud/traits/rotation), allowing control of slat tilt/rotation in addition to opening/closing. This feature is only available for group-based configurations with separate position and rotation controls.
517+
518+
**Group Configuration for Rotation:**
519+
For devices with separate items for position and rotation control:
520+
510521
```shell
522+
Group blindsGroup { ga="Blinds" }
523+
Rollershutter blindsPosition { ga="shutterPosition" }
524+
Number blindsRotation { ga="shutterRotation" }
525+
```
526+
527+
**Voice Commands:**
528+
529+
- "Open the shutters to 50%"
530+
- "Tilt the shutters to 30 degrees"
531+
- "Set the blinds to 75%"
532+
- "Rotate the blinds to 45 degrees"
533+
534+
**Group Members:**
535+
536+
- `shutterPosition`: Controls the opening/closing position (Rollershutter, Dimmer, Number)
537+
- `shutterRotation`: Controls the slat rotation/tilt (Rollershutter, Dimmer, Number)
538+
539+
**Configuration Options:**
540+
The rotation trait supports the following optional configuration parameters:
541+
542+
- `supportsDegrees=true/false` (default: true) - Enable degree-based control (percentage is always supported)
543+
- Set to `false` if you only want percentage-based control without degree conversion
544+
- `supportsContinuousRotation=true/false` (default: false) - Enable continuous rotation beyond limits
545+
- `rotationDegreesMin=degrees` (default: 0) - Minimum rotation in degrees (only used when supportsDegrees is true)
546+
- `rotationDegreesMax=degrees` (default: 90) - Maximum rotation in degrees (only used when supportsDegrees is true)
547+
548+
::: tip Note
549+
openHAB items always store rotation as percentages (0-100). When `supportsDegrees=true`, the percentage is converted to/from degrees using the configured range for Google Assistant commands and state responses.
550+
:::
551+
552+
```shell
553+
Group shutterGroup { ga="Shutter" [ rotationDegreesMin=-45, rotationDegreesMax=45 ] }
554+
Rollershutter shutterPosition { ga="shutterPosition" }
555+
Number shutterRotation { ga="shutterRotation" }
556+
557+
# Example with continuous rotation support
558+
Group blindsGroup { ga="Blinds" [ supportsContinuousRotation=true, rotationDegreesMin=0, rotationDegreesMax=360 ] }
559+
Rollershutter blindsCover { ga="shutterPosition" }
560+
Number blindsRotation { ga="shutterRotation" }
561+
562+
# Example with percentage-only control (no degree conversion)
563+
Group awningGroup { ga="Awning" [ supportsDegrees=false ] }
564+
Rollershutter awningPosition { ga="shutterPosition" }
565+
Dimmer awningSlats { ga="shutterRotation" }
566+
```
567+
568+
**Basic Examples:**
569+
570+
```shell
571+
# Simple devices (no rotation)
511572
Rollershutter { ga="Awning" }
512573
Rollershutter { ga="Blinds" [ inverted=true ] }
513574
Rollershutter { ga="Curtain" }
@@ -517,6 +578,15 @@ Contact { ga="Gate" }
517578
Rollershutter { ga="Pergola" }
518579
Rollershutter { ga="Shutter" }
519580
Rollershutter { ga="Window" }
581+
582+
# Advanced devices with rotation support
583+
Group shutterGroup { ga="Shutter" }
584+
Rollershutter ShutterPosition { ga="shutterPosition" }
585+
Number ShutterTilt { ga="shutterRotation" }
586+
587+
Group blindsGroup { ga="Blinds" [ rotationDegreesMin=0, rotationDegreesMax=180 ] }
588+
Rollershutter blindsPosition { ga="shutterPosition" }
589+
Rollershutter blindsSlats { ga="shutterRotation" }
520590
```
521591
522592
### Charger

functions/commandRegistry.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const COMMAND_REGISTRY = [
3838
require('./commands/mute.js'),
3939
require('./commands/onoff.js'),
4040
require('./commands/openclose.js'),
41+
require('./commands/rotateabsolute.js'),
4142
require('./commands/selectchannel.js'),
4243
require('./commands/setfanspeed.js'),
4344
require('./commands/sethumidity.js'),

functions/commands/openclose.js

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,40 @@ class OpenClose extends DefaultCommand {
99
return 'openPercent' in params && typeof params.openPercent === 'number';
1010
}
1111

12+
static getItemName(device) {
13+
const members = this.getMembers(device);
14+
// If device is a group with shutterPosition member, use that
15+
if ('shutterPosition' in members) {
16+
return members.shutterPosition;
17+
}
18+
// Otherwise use the device itself (single item case)
19+
return device.id;
20+
}
21+
1222
static convertParamsToValue(params, _, device) {
1323
const itemType = this.getItemType(device);
14-
if (itemType === 'Contact') {
24+
const members = this.getMembers(device);
25+
26+
// Determine the actual item type to work with
27+
let actualItemType = itemType;
28+
if (itemType === 'Group' && 'shutterPosition' in members) {
29+
// For group shutters, use the stored shutterPosition item type from customData
30+
actualItemType = device.customData?.shutterPositionItemType || 'Rollershutter';
31+
}
32+
33+
if (actualItemType === 'Contact') {
1534
throw { statusCode: 400 };
1635
}
36+
1737
let openPercent = params.openPercent;
1838
if (this.isInverted(device)) {
1939
openPercent = 100 - openPercent;
2040
}
21-
if (itemType === 'Rollershutter') {
41+
42+
if (actualItemType === 'Rollershutter') {
2243
return openPercent === 0 ? 'DOWN' : openPercent === 100 ? 'UP' : (100 - openPercent).toString();
2344
}
24-
if (itemType === 'Switch') {
45+
if (actualItemType === 'Switch') {
2546
return openPercent === 0 ? 'OFF' : 'ON';
2647
}
2748
return openPercent.toString();
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
const DefaultCommand = require('./default.js');
2+
3+
class RotateAbsolute extends DefaultCommand {
4+
static get type() {
5+
return 'action.devices.commands.RotateAbsolute';
6+
}
7+
8+
static validateParams(params) {
9+
return (
10+
('rotationPercent' in params && typeof params.rotationPercent === 'number') ||
11+
('rotationDegrees' in params && typeof params.rotationDegrees === 'number')
12+
);
13+
}
14+
15+
static getItemName(device) {
16+
const members = this.getMembers(device);
17+
// If device is a group with rotation member, use that
18+
if ('shutterRotation' in members) {
19+
return members.shutterRotation;
20+
}
21+
// Otherwise use the device itself (simple shutter case)
22+
return device.id;
23+
}
24+
25+
static convertParamsToValue(params, _, device) {
26+
let targetValue;
27+
28+
if ('rotationPercent' in params) {
29+
targetValue = params.rotationPercent;
30+
} else if ('rotationDegrees' in params) {
31+
// Convert degrees to percentage based on stored configuration
32+
const rotationConfig = (device.customData && device.customData.rotationConfig) || {};
33+
const rotationDegreesMin = rotationConfig.rotationDegreesMin ?? 0;
34+
const rotationDegreesMax = rotationConfig.rotationDegreesMax ?? 90;
35+
const degreeRange = rotationDegreesMax - rotationDegreesMin;
36+
const normalizedDegrees = Math.max(0, Math.min(degreeRange, params.rotationDegrees - rotationDegreesMin));
37+
targetValue = Math.round((normalizedDegrees / degreeRange) * 100);
38+
}
39+
40+
// Handle inversion if device is inverted
41+
if (this.isInverted(device)) {
42+
targetValue = 100 - targetValue;
43+
}
44+
45+
return targetValue.toString();
46+
}
47+
48+
static getResponseStates(params, _, device) {
49+
const response = {};
50+
const rotationConfig = device?.customData?.rotationConfig ?? {};
51+
const supportsDegrees = rotationConfig.supportsDegrees !== false;
52+
const rotationDegreesMin = rotationConfig.rotationDegreesMin ?? 0;
53+
const rotationDegreesMax = rotationConfig.rotationDegreesMax ?? 90;
54+
const degreeRange = rotationDegreesMax - rotationDegreesMin;
55+
56+
// Always include rotationPercent and optionally rotationDegrees in response
57+
if ('rotationPercent' in params) {
58+
response.rotationPercent = params.rotationPercent;
59+
if (supportsDegrees) {
60+
response.rotationDegrees = Math.round(rotationDegreesMin + (params.rotationPercent / 100) * degreeRange);
61+
}
62+
} else {
63+
// rotationDegrees in params
64+
const normalizedDegrees = Math.max(0, Math.min(degreeRange, params.rotationDegrees - rotationDegreesMin));
65+
response.rotationPercent = Math.round((normalizedDegrees / degreeRange) * 100);
66+
response.rotationDegrees = params.rotationDegrees;
67+
}
68+
69+
return response;
70+
}
71+
}
72+
73+
module.exports = RotateAbsolute;

functions/deviceRegistry.js

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const Scene = require('./devices/scene.js');
4343
const SecuritySystem = require('./devices/securitysystem.js');
4444
const SimpleSecuritySystem = require('./devices/simplesecuritysystem.js');
4545
const Sensor = require('./devices/sensor.js');
46+
const Shutter = require('./devices/shutter.js');
4647
const Speaker = require('./devices/speaker.js');
4748
const SpecialColorLight = require('./devices/specialcolorlight.js');
4849
const TemperatureSensor = require('./devices/temperaturesensor.js');
@@ -106,6 +107,7 @@ const DEVICE_REGISTRY = [
106107
SecuritySystem,
107108
SimpleSecuritySystem,
108109
Sensor,
110+
Shutter,
109111
Speaker,
110112
SpecialColorLight,
111113
TemperatureSensor,
@@ -131,17 +133,20 @@ const DEVICE_REGISTRY = [
131133
{ type: 'SPRINKLER', name: 'Sprinkler' }
132134
]),
133135

136+
// Simple device type variants - Shutter-based devices (support rotation trait)
137+
...createDeviceVariants(Shutter, [
138+
{ type: 'AWNING', name: 'Awning' },
139+
{ type: 'BLINDS', name: 'Blinds' },
140+
{ type: 'CURTAIN', name: 'Curtain' },
141+
{ type: 'PERGOLA', name: 'Pergola' }
142+
]),
143+
134144
// Simple device type variants - OpenCloseDevice-based devices
135145
...createDeviceVariants(OpenCloseDevice, [
136146
{ type: 'DOOR', name: 'Door' },
137147
{ type: 'GATE', name: 'Gate' },
138-
{ type: 'AWNING', name: 'Awning' },
139-
{ type: 'PERGOLA', name: 'Pergola' },
140148
{ type: 'GARAGE', name: 'Garage' },
141-
{ type: 'WINDOW', name: 'Window' },
142-
{ type: 'BLINDS', name: 'Blinds' },
143-
{ type: 'CURTAIN', name: 'Curtain' },
144-
{ type: 'SHUTTER', name: 'Shutter' }
149+
{ type: 'WINDOW', name: 'Window' }
145150
]),
146151

147152
// Simple device type variants - Switch-based devices

functions/devices/climatesensor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ class ClimateSensor extends DefaultDevice {
7272
}
7373
if ('humidityAmbient' in members) {
7474
const config = this.getConfig(item);
75-
const maxHumidity = (config.maxHumidity && parseInt(config.maxHumidity)) || 100;
75+
const maxHumidity = parseInt(config.maxHumidity) || 100;
7676
const humidity = Math.round(parseFloat(members.humidityAmbient.state) * (100 / maxHumidity));
7777
state.humidityAmbientPercent = humidity;
7878
state.humiditySetpointPercent = humidity;

functions/devices/default.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,8 @@ class DefaultDevice {
158158
if (matchedType) {
159159
members[matchedType.name] = {
160160
name: member.name,
161-
state: member.state
161+
state: member.state,
162+
type: memberType
162163
};
163164
}
164165
});

functions/devices/humidifier.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,14 @@ class Humidifier extends DefaultDevice {
104104

105105
// Humidity setpoint
106106
if ('humidifierHumiditySetpoint' in members) {
107-
const maxHumidity = (config.maxHumidity && parseInt(config.maxHumidity)) || 100;
107+
const maxHumidity = parseInt(config.maxHumidity) || 100;
108108
const humidity = Math.round(parseFloat(members.humidifierHumiditySetpoint.state) * (100 / maxHumidity));
109109
state.humiditySetpointPercent = humidity;
110110
}
111111

112112
// Ambient humidity (read-only)
113113
if ('humidifierHumidityAmbient' in members) {
114-
const maxHumidity = (config.maxHumidity && parseInt(config.maxHumidity)) || 100;
114+
const maxHumidity = parseInt(config.maxHumidity) || 100;
115115
const humidity = Math.round(parseFloat(members.humidifierHumidityAmbient.state) * (100 / maxHumidity));
116116
state.humidityAmbientPercent = humidity;
117117
}

functions/devices/humiditysensor.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class HumiditySensor extends DefaultDevice {
2525

2626
static getState(item) {
2727
const config = this.getConfig(item);
28-
const maxHumidity = (config.maxHumidity && parseInt(config.maxHumidity)) || 100;
28+
const maxHumidity = parseInt(config.maxHumidity) || 100;
2929
const state = Math.round(parseFloat(item.state) * (100 / maxHumidity));
3030
return {
3131
humidityAmbientPercent: state,

functions/devices/securitysystem.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,17 @@ class SecuritySystem extends DefaultDevice {
7676
if (item.members && item.members.length) {
7777
item.members.forEach((member) => {
7878
if (member.metadata && member.metadata.ga) {
79-
const memberType = supportedMembers.find((m) => {
80-
const memberType = (member.groupType || member.type || '').split(':')[0];
81-
return m.types.includes(memberType) && member.metadata.ga.value.toLowerCase() === m.name.toLowerCase();
82-
});
83-
if (memberType) {
79+
const memberType = (member.groupType || member.type || '').split(':')[0];
80+
const supportedMember = supportedMembers.find(
81+
(m) => m.types.includes(memberType) && member.metadata.ga.value.toLowerCase() === m.name.toLowerCase()
82+
);
83+
if (supportedMember) {
8484
const memberDetails = { name: member.name, state: member.state, config: this.getConfig(member) };
85-
if (memberType.name === memberZone) {
85+
if (supportedMember.name === memberZone) {
8686
members.zones = members.zones || [];
8787
members.zones.push(memberDetails);
8888
} else {
89-
members[memberType.name] = memberDetails;
89+
members[supportedMember.name] = memberDetails;
9090
}
9191
}
9292
}

0 commit comments

Comments
 (0)