Compare commits

...

21 commits

Author SHA1 Message Date
00a43dd1f8
Implemented EnumSwitch entity type
Done for `zigbee.mqtt`, other plugins will follow
2022-11-11 01:46:38 +01:00
801ed05684
Added support for binary sensors (in zigbee.mqtt for now) 2022-11-05 01:47:50 +01:00
6454f9d018
Propert snake case -> camel case conversion for backend entities -> frontend components 2022-11-04 22:53:24 +01:00
0f19104512
Improved zigbee.mqtt node property queries.
Now handling cases of nodes with values having multiple levels (> 1) of
nested properties.
2022-11-04 22:51:40 +01:00
5ca3c06f96
Normalize device names in set_lights 2022-11-02 23:32:21 +01:00
d5f8d55b4b
Fixed zigbee.mqtt light entity conversion 2022-11-02 23:07:12 +01:00
636d1ced3a
A more robust way of splitting devices provided in the <ieee_address:value> format 2022-11-02 22:49:19 +01:00
7db84acd34
Notify of entity scan timeouts on the console instead of creating tons of notifications 2022-11-02 22:24:06 +01:00
02abef71e3
Fixes for zigbee devices polling
- Don't publish a `get` request if the device has no exposed queriable
  attributes.

- Perform the recursive build of the `get` request payload before
  checking for the `access` attribute.
2022-11-02 21:54:47 +01:00
64513be6b8
Initial implementation of sensor entities.
Implemented (at least in `zigbee.mqtt`, for now):

- `TemperatureSensor`
- `HumiditySensor`
- `VoltageSensor`
- `CurrentSensor`
- `EnergySensor`
- `PowerSensor`
- `NumericSensor` (generic fallback 1)
- `RawSensor` (generic fallback 2)
- `Sensor` (root class)
2022-11-02 16:38:17 +01:00
440cd60d6e
A (slightly) smarter way to infer the plural spelling of singular entity names 2022-11-02 16:35:20 +01:00
3d1a08f7af
Changed default entity grouping on the frontend.
Changed from `type` to `category`, which is basically the `name_plural`
attribute of the associated entity type metadata.

This allows us to define distinct entity metadata entries that we still
want to share the same grouping - for instance, `temperature_sensor`,
`humidity_sensor` and `battery` should all be grouped under `Sensors` on
the frontend.
2022-11-02 16:33:12 +01:00
68dd09e8ae
Removed unused expanded data attribute 2022-11-02 16:31:50 +01:00
d7214c4c83
Fix for No converter available warnings on zigbee2mqtt
Only include readable (not state-only) properties on the payload sent to
to `zigbee2mqtt/<device>/get`.
2022-10-31 00:51:26 +01:00
a1cf671334
Added support for link_quality entities to zigbee.mqtt 2022-10-30 11:03:22 +01:00
78dc8416fb
Snake case -> camel case for backend -> frontend entity types conversion 2022-10-30 11:01:46 +01:00
691d109fb7
Expunge entities after session commit to ensure that the ORM objects can be reused 2022-10-30 11:00:09 +01:00
71ccf6d04a
Support for battery sensors on zigbee.mqtt 2022-10-29 18:16:38 +02:00
42651e937b
LINT fixes on zigbee.mqtt plugin 2022-10-29 14:09:44 +02:00
d61b053f72
Support for battery entities 2022-10-29 13:38:42 +02:00
cdacf50fc7
Support for decimal.Decimal type JSON serialization 2022-10-29 13:35:52 +02:00
31 changed files with 1238 additions and 207 deletions

View file

@ -0,0 +1,117 @@
<template>
<div class="entity battery-container">
<div class="head">
<div class="col-1 icon">
<EntityIcon
:icon="icon"
:loading="loading"
:error="error" />
</div>
<div class="col-s-8 col-m-9 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-s-3 col-m-2 buttons pull-right">
<span class="value-percent"
v-text="valuePercent + '%'"
v-if="valuePercent != null" />
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
const thresholds = [
{
iconClass: 'full',
color: '#157145',
value: 0.9,
},
{
iconClass: 'three-quarters',
color: '#94C595',
value: 0.825,
},
{
iconClass: 'half',
color: '#F0B67F',
value: 0.625,
},
{
iconClass: 'quarter',
color: '#FE5F55',
value: 0.375,
},
{
iconClass: 'low',
color: '#CC444B',
value: 0.15,
},
{
iconClass: 'empty',
color: '#EC0B43',
value: 0.05,
},
]
export default {
name: 'Battery',
components: {EntityIcon},
mixins: [EntityMixin],
computed: {
valuePercent() {
if (this.value?.value == null)
return null
const min = this.value.min || 0
const max = this.value.max || 100
return ((100 * this.value.value) / (max - min)).toFixed(0)
},
icon() {
const icon = {...(this.value.meta?.icon || {})}
let value = this.valuePercent
let threshold = thresholds[0]
if (value != null) {
value = parseFloat(value) / 100
for (const t of thresholds) {
if (value > t.value)
break
threshold = t
}
}
icon['class'] = `fas fa-battery-${threshold.iconClass}`
icon['color'] = threshold.color
return icon
},
},
methods: {
prevent(event) {
event.stopPropagation()
return false
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.battery-container {
.head {
.value-percent {
font-size: 1.1em;
font-weight: bold;
opacity: 0.7;
}
}
}
</style>

View file

@ -0,0 +1,50 @@
<template>
<div class="entity sensor-container">
<div class="head">
<div class="col-1 icon">
<EntityIcon
:icon="value.meta?.icon || {}"
:loading="loading"
:error="error" />
</div>
<div class="col-s-8 col-m-9 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-s-3 col-m-2 pull-right" v-if="value.value != null">
<ToggleSwitch :value="value.value" :disabled="true" />
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
import ToggleSwitch from "@/components/elements/ToggleSwitch"
export default {
name: 'BinarySensor',
components: {EntityIcon, ToggleSwitch},
mixins: [EntityMixin],
}
</script>
<style lang="scss" scoped>
@import "common";
.sensor-container {
.head {
.value {
font-size: 1.1em;
font-weight: bold;
opacity: 0.7;
}
.unit {
margin-left: 0.2em;
}
}
}
</style>

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -26,10 +26,15 @@ export default {
},
mounted() {
if (this.type !== 'Entity')
if (this.type !== 'Entity') {
const type = this.type.split('_').map((t) =>
t[0].toUpperCase() + t.slice(1)
).join('')
this.component = defineAsyncComponent(
() => import(`@/components/panels/Entities/${this.type}`)
() => import(`@/components/panels/Entities/${type}`)
)
}
},
}
</script>

View file

@ -31,7 +31,8 @@ export default {
computed: {
type() {
let entityType = (this.value.type || '')
return entityType.charAt(0).toUpperCase() + entityType.slice(1)
return entityType.split('_').
map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join('')
},
},
}

View file

@ -0,0 +1,129 @@
<template>
<div class="entity switch-container">
<div class="head" :class="{expanded: expanded}">
<div class="col-1 icon">
<EntityIcon
:icon="this.value.meta?.icon || {}"
:loading="loading"
:error="error" />
</div>
<div class="col-s-8 col-m-9 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-s-3 col-m-2 buttons pull-right">
<button @click.stop="expanded = !expanded" v-if="hasValues">
<i class="fas"
:class="{'fa-angle-up': expanded, 'fa-angle-down': !expanded}" />
</button>
<span class="value"
v-text="value.value"
v-if="value?.value != null" />
</div>
</div>
<div class="body" v-if="expanded" @click.stop="prevent">
<div class="row">
<div class="input">
<select @input="setValue" ref="values">
<option value="" v-if="value.is_write_only" selected>--</option>
<option :value="v" v-for="v in value.values" :key="v" v-text="v" />
</select>
</div>
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
export default {
name: 'EnumSwitch',
components: {EntityIcon},
mixins: [EntityMixin],
data() {
return {
expanded: false,
}
},
computed: {
hasValues() {
return !!this?.value?.values?.length
}
},
methods: {
prevent(event) {
event.stopPropagation()
return false
},
async setValue(event) {
if (!event.target.value?.length)
return
this.$emit('loading', true)
if (this.value.is_write_only) {
const self = this;
setTimeout(() => {
self.$refs.values.value = ''
}, 1000)
}
try {
await this.request('entities.execute', {
id: this.value.id,
action: 'set_value',
data: event.target.value,
})
} finally {
this.$emit('loading', false)
}
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.switch-container {
.head {
.buttons {
button {
margin-right: 0.5em;
}
}
.value {
font-size: 1.1em;
font-weight: bold;
opacity: 0.7;
}
}
.body {
.row {
display: flex;
.icon {
width: 2em;
text-align: center;
}
.input {
width: calc(100% - 2em);
select {
width: 100%;
}
}
}
}
}
</style>

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -27,16 +27,14 @@
<div class="frame">
<div class="header">
<span class="section left">
<Icon v-bind="entitiesMeta[group.name].icon || {}"
v-if="selector.grouping === 'type' && entitiesMeta[group.name]" />
<Icon v-bind="entitiesMeta[typesByCategory[group.name]].icon || {}"
v-if="selector.grouping === 'category' && entitiesMeta[typesByCategory[group.name]]" />
<Icon :class="pluginIcons[group.name]?.class" :url="pluginIcons[group.name]?.imgUrl"
v-else-if="selector.grouping === 'plugin' && pluginIcons[group.name]" />
</span>
<span class="section center">
<div class="title" v-text="entitiesMeta[group.name].name_plural"
v-if="selector.grouping === 'type' && entitiesMeta[group.name]"/>
<div class="title" v-text="group.name" v-else-if="selector.grouping === 'plugin'"/>
<div class="title" v-text="group.name" />
</span>
<span class="section right">
@ -99,7 +97,7 @@ export default {
modalEntityId: null,
modalVisible: false,
selector: {
grouping: 'type',
grouping: 'category',
selectedEntities: {},
},
}
@ -114,13 +112,24 @@ export default {
return icons
},
entityTypes() {
return this.groupEntities('type')
},
typesByCategory() {
return Object.entries(meta).reduce((obj, [type, meta]) => {
obj[meta.name_plural] = type
return obj
}, {})
},
entityGroups() {
return {
'id': Object.entries(this.groupEntities('id')).reduce((obj, [id, entities]) => {
obj[id] = entities[0]
return obj
}, {}),
'type': this.groupEntities('type'),
'category': this.groupEntities('category'),
'plugin': this.groupEntities('plugin'),
}
},
@ -148,6 +157,7 @@ export default {
return Object.values(this.entities).reduce((obj, entity) => {
const entities = obj[entity[attr]] || {}
entities[entity.id] = entity
obj[entity[attr]] = Object.values(entities).sort((a, b) => {
return a.name.localeCompare(b.name)
})
@ -178,11 +188,7 @@ export default {
delete self.entityTimeouts[id]
self.errorEntities[id] = entity
self.notify({
error: true,
title: entity.plugin,
text: `Scan timeout for ${entity.name}`,
})
console.warn(`Scan timeout for ${entity.name}`)
}, this.entityScanTimeout * 1000)
obj[id] = true
@ -198,6 +204,7 @@ export default {
try {
this.entities = (await this.request('entities.get')).reduce((obj, entity) => {
entity.name = entity?.meta?.name_override || entity.name
entity.category = meta[entity.type].name_plural
entity.meta = {
...(meta[entity.type] || {}),
...(entity.meta || {}),
@ -225,6 +232,7 @@ export default {
},
onEntityInput(entity) {
entity.category = meta[entity.type].name_plural
this.entities[entity.id] = entity
this.clearEntityTimeouts(entity.id)
if (this.loadingEntities[entity.id])
@ -247,6 +255,7 @@ export default {
else
entity.name = event.entity?.name || this.entities[entityId]?.name
entity.category = meta[entity.type].name_plural
entity.meta = {
...(meta[event.entity.type] || {}),
...(this.entities[entityId]?.meta || {}),

View file

@ -0,0 +1,58 @@
<template>
<div class="entity link-quality-container">
<div class="head">
<div class="col-1 icon">
<EntityIcon
:icon="value.meta?.icon || {}"
:loading="loading"
:error="error" />
</div>
<div class="col-s-8 col-m-9 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-s-3 col-m-2 buttons pull-right">
<span class="value-percent"
v-text="valuePercent + '%'"
v-if="valuePercent != null" />
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
export default {
name: 'LinkQuality',
components: {EntityIcon},
mixins: [EntityMixin],
computed: {
valuePercent() {
if (this.value?.value == null)
return null
const min = this.value.min || 0
const max = this.value.max || 100
return ((100 * this.value.value) / (max - min)).toFixed(0)
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.link-quality-container {
.head {
.value-percent {
font-size: 1.1em;
font-weight: bold;
opacity: 0.7;
}
}
}
</style>

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -98,7 +98,15 @@ export default {
methods: {
prettifyGroupingName(name) {
return name ? this.prettify(name) + 's' : ''
if (!name)
return ''
name = this.prettify(name)
if (name.endsWith('y'))
name = name.slice(0, name.length-1) + 'ie'
name += 's'
return name
},
iconForGroup(group) {

View file

@ -0,0 +1,52 @@
<template>
<div class="entity sensor-container">
<div class="head">
<div class="col-1 icon">
<EntityIcon
:icon="value.meta?.icon || {}"
:loading="loading"
:error="error" />
</div>
<div class="col-s-8 col-m-9 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-s-3 col-m-2 pull-right"
v-if="value.value != null">
<span class="unit" v-text="value.unit"
v-if="value.unit != null" />
<span class="value" v-text="value.value" />
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
export default {
name: 'Sensor',
components: {EntityIcon},
mixins: [EntityMixin],
}
</script>
<style lang="scss" scoped>
@import "common";
.sensor-container {
.head {
.value {
font-size: 1.1em;
font-weight: bold;
opacity: 0.7;
}
.unit {
margin-left: 0.2em;
}
}
}
</style>

View file

@ -0,0 +1 @@
Sensor.vue

View file

@ -1,9 +1,17 @@
{
"entity": {
"name": "Entity",
"name_plural": "Entities",
"battery": {
"name": "Battery",
"name_plural": "Batteries",
"icon": {
"class": "fas fa-circle-question"
"class": "fas fa-battery-full"
}
},
"current_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-bolt"
}
},
@ -15,11 +23,35 @@
}
},
"switch": {
"name": "Switch",
"name_plural": "Switches",
"dimmer": {
"name": "Dimmer",
"name_plural": "Dimmers",
"icon": {
"class": "fas fa-toggle-on"
"class": "fas fa-gauge"
}
},
"energy_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-plug"
}
},
"entity": {
"name": "Entity",
"name_plural": "Entities",
"icon": {
"class": "fas fa-circle-question"
}
},
"humidity_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-droplet"
}
},
@ -31,11 +63,75 @@
}
},
"dimmer": {
"name": "Dimmer",
"name_plural": "Dimmers",
"link_quality": {
"name": "Link Quality",
"name_plural": "Link Qualities",
"icon": {
"class": "fas fa-tower-broadcast"
}
},
"power_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-plug"
}
},
"temperature_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-temperature-half"
}
},
"enum_switch": {
"name": "Switch",
"name_plural": "Switches",
"icon": {
"class": "fas fa-gauge"
}
},
"switch": {
"name": "Switch",
"name_plural": "Switches",
"icon": {
"class": "fas fa-toggle-on"
}
},
"voltage_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-car-battery"
}
},
"binary_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-thermometer"
}
},
"numeric_sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-thermometer"
}
},
"sensor": {
"name": "Sensor",
"name_plural": "Sensors",
"icon": {
"class": "fas fa-thermometer"
}
}
}

View file

@ -246,10 +246,12 @@ class ZigbeeMqttBackend(MqttBackend):
self.bus.post(ZigbeeMqttDeviceConnectedEvent(device=name, **event_args))
exposes = (device.get('definition', {}) or {}).get('exposes', [])
client.publish(
self.base_topic + '/' + name + '/get',
json.dumps(self._plugin.build_device_get_request(exposes)),
)
payload = self._plugin.build_device_get_request(exposes)
if payload:
client.publish(
self.base_topic + '/' + name + '/get',
json.dumps(payload),
)
devices_copy = [*self._devices.keys()]
for name in devices_copy:

View file

@ -259,6 +259,9 @@ class EntitiesEngine(Thread):
session.add_all(entities)
session.commit()
for e in entities:
session.expunge(e)
with self._entities_cache_lock:
for entity in entities:
self._cache_entities(entity, overwrite_cache=True)

View file

@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, ForeignKey
from .devices import entity_types_registry
from .sensors import NumericSensor
if not entity_types_registry.get('Battery'):
class Battery(NumericSensor):
__tablename__ = 'battery'
def __init__(
self, *args, unit: str = '%', min: float = 0, max: float = 100, **kwargs
):
super().__init__(*args, min=min, max=max, unit=unit, **kwargs)
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['Battery'] = Battery
else:
Battery = entity_types_registry['Battery']

View file

@ -0,0 +1,76 @@
from sqlalchemy import Column, Integer, ForeignKey
from .devices import entity_types_registry
from .sensors import NumericSensor
if not entity_types_registry.get('PowerSensor'):
class PowerSensor(NumericSensor):
__tablename__ = 'power_sensor'
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['PowerSensor'] = PowerSensor
else:
PowerSensor = entity_types_registry['PowerSensor']
if not entity_types_registry.get('CurrentSensor'):
class CurrentSensor(NumericSensor):
__tablename__ = 'current_sensor'
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['CurrentSensor'] = CurrentSensor
else:
CurrentSensor = entity_types_registry['CurrentSensor']
if not entity_types_registry.get('VoltageSensor'):
class VoltageSensor(NumericSensor):
__tablename__ = 'voltage_sensor'
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['VoltageSensor'] = VoltageSensor
else:
VoltageSensor = entity_types_registry['VoltageSensor']
if not entity_types_registry.get('EnergySensor'):
class EnergySensor(NumericSensor):
__tablename__ = 'energy_sensor'
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['EnergySensor'] = EnergySensor
else:
EnergySensor = entity_types_registry['EnergySensor']

View file

@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, ForeignKey
from .devices import entity_types_registry
from .sensors import NumericSensor
if not entity_types_registry.get('HumiditySensor'):
class HumiditySensor(NumericSensor):
__tablename__ = 'humidity_sensor'
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['HumiditySensor'] = HumiditySensor
else:
HumiditySensor = entity_types_registry['HumiditySensor']

View file

@ -0,0 +1,27 @@
from sqlalchemy import Column, Integer, ForeignKey
from .devices import entity_types_registry
from .sensors import NumericSensor
if not entity_types_registry.get('LinkQuality'):
class LinkQuality(NumericSensor):
__tablename__ = 'link_quality'
def __init__(
self, *args, unit: str = '%', min: float = 0, max: float = 100, **kwargs
):
super().__init__(*args, min=min, max=max, unit=unit, **kwargs)
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['LinkQuality'] = LinkQuality
else:
LinkQuality = entity_types_registry['LinkQuality']

View file

@ -0,0 +1,85 @@
import logging
from sqlalchemy import Column, Integer, ForeignKey, Boolean, Numeric, String
from .devices import Device, entity_types_registry
logger = logging.getLogger(__name__)
class Sensor(Device):
__abstract__ = True
if not entity_types_registry.get('RawSensor'):
class RawSensor(Sensor):
__tablename__ = 'raw_sensor'
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
value = Column(String)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['RawSensor'] = RawSensor
else:
RawSensor = entity_types_registry['RawSensor']
if not entity_types_registry.get('NumericSensor'):
class NumericSensor(Sensor):
__tablename__ = 'numeric_sensor'
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
value = Column(Numeric)
min = Column(Numeric)
max = Column(Numeric)
unit = Column(String)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['NumericSensor'] = NumericSensor
else:
NumericSensor = entity_types_registry['NumericSensor']
if not entity_types_registry.get('BinarySensor'):
class BinarySensor(Sensor):
__tablename__ = 'binary_sensor'
def __init__(self, *args, value=None, **kwargs):
if isinstance(value, str):
value = value.lower()
if value in {True, 1, '1', 't', 'true', 'on'}:
value = True
elif value in {False, 0, '0', 'f', 'false', 'off'}:
value = False
elif value is not None:
logger.warning(f'Unsupported value for BinarySensor type: {value}')
value = None
super().__init__(*args, value=value, **kwargs)
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
value = Column(Boolean)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['BinarySensor'] = BinarySensor
else:
BinarySensor = entity_types_registry['BinarySensor']

View file

@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, ForeignKey, Boolean
from sqlalchemy import Column, Integer, ForeignKey, Boolean, String, JSON
from .devices import Device, entity_types_registry
@ -20,3 +20,23 @@ if not entity_types_registry.get('Switch'):
entity_types_registry['Switch'] = Switch
else:
Switch = entity_types_registry['Switch']
if not entity_types_registry.get('EnumSwitch'):
class EnumSwitch(Device):
__tablename__ = 'enum_switch'
id = Column(
Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True
)
value = Column(String)
values = Column(JSON)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['EnumSwitch'] = EnumSwitch
else:
EnumSwitch = entity_types_registry['EnumSwitch']

View file

@ -0,0 +1,22 @@
from sqlalchemy import Column, Integer, ForeignKey
from .devices import entity_types_registry
from .sensors import NumericSensor
if not entity_types_registry.get('TemperatureSensor'):
class TemperatureSensor(NumericSensor):
__tablename__ = 'temperature_sensor'
id = Column(
Integer, ForeignKey(NumericSensor.id, ondelete='CASCADE'), primary_key=True
)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
entity_types_registry['TemperatureSensor'] = TemperatureSensor
else:
TemperatureSensor = entity_types_registry['TemperatureSensor']

View file

@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
import decimal
import datetime
import logging
import inspect
@ -19,7 +20,7 @@ class JSONAble(ABC):
raise NotImplementedError()
class Message(object):
class Message:
"""
Message generic class
"""
@ -38,6 +39,8 @@ class Message(object):
return int(obj)
if isinstance(obj, np.ndarray):
return obj.tolist()
if isinstance(obj, decimal.Decimal):
return float(obj)
if callable(obj):
return '<function at {}.{}>'.format(obj.__module__, obj.__name__)
@ -45,9 +48,7 @@ class Message(object):
@staticmethod
def parse_datetime(obj):
if isinstance(obj, datetime.datetime) or \
isinstance(obj, datetime.date) or \
isinstance(obj, datetime.time):
if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
return obj.isoformat()
def default(self, obj):
@ -68,8 +69,11 @@ class Message(object):
try:
return super().default(obj)
except Exception as e:
logger.warning('Could not serialize object type {}: {}: {}'.format(
type(obj), str(e), obj))
logger.warning(
'Could not serialize object type {}: {}: {}'.format(
type(obj), str(e), obj
)
)
def __init__(self, timestamp=None, *_, **__):
self.timestamp = timestamp or time.time()
@ -80,12 +84,15 @@ class Message(object):
the message into a UTF-8 JSON string
"""
return json.dumps({
attr: getattr(self, attr)
for attr in self.__dir__()
if (attr != '_timestamp' or not attr.startswith('_'))
and not inspect.ismethod(getattr(self, attr))
}, cls=self.Encoder).replace('\n', ' ')
return json.dumps(
{
attr: getattr(self, attr)
for attr in self.__dir__()
if (attr != '_timestamp' or not attr.startswith('_'))
and not inspect.ismethod(getattr(self, attr))
},
cls=self.Encoder,
).replace('\n', ' ')
def __bytes__(self):
"""
@ -105,7 +112,7 @@ class Message(object):
if isinstance(msg, cls):
msg = str(msg)
if isinstance(msg, bytes) or isinstance(msg, bytearray):
if isinstance(msg, (bytes, bytearray)):
msg = msg.decode('utf-8')
if isinstance(msg, str):
try:

View file

@ -1,18 +1,30 @@
import json
import re
import threading
from queue import Queue
from typing import Optional, List, Any, Dict, Union
from typing import Optional, List, Any, Dict, Union, Tuple
from platypush.entities import manages
from platypush.entities.batteries import Battery
from platypush.entities.electricity import (
CurrentSensor,
EnergySensor,
PowerSensor,
VoltageSensor,
)
from platypush.entities.humidity import HumiditySensor
from platypush.entities.lights import Light
from platypush.entities.switches import Switch
from platypush.entities.linkquality import LinkQuality
from platypush.entities.sensors import Sensor, BinarySensor, NumericSensor
from platypush.entities.switches import Switch, EnumSwitch
from platypush.entities.temperature import TemperatureSensor
from platypush.message import Mapping
from platypush.message.response import Response
from platypush.plugins.mqtt import MqttPlugin, action
@manages(Light, Switch)
@manages(Light, Switch, LinkQuality, Battery, Sensor)
class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
"""
This plugin allows you to interact with Zigbee devices over MQTT through any Zigbee sniffer and
@ -161,14 +173,11 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
}
def transform_entities(self, devices):
from platypush.entities.switches import Switch
compatible_entities = []
for dev in devices:
if not dev:
continue
converted_entity = None
dev_def = dev.get("definition") or {}
dev_info = {
"type": dev.get("type"),
@ -185,68 +194,80 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
light_info = self._get_light_meta(dev)
switch_info = self._get_switch_meta(dev)
sensors = self._get_sensors(dev)
enum_switches = self._get_enum_switches(dev)
if light_info:
converted_entity = Light(
id=dev['ieee_address'],
name=dev.get('friendly_name'),
on=dev.get('state', {}).get('state') == switch_info.get('value_on'),
brightness_min=light_info.get('brightness_min'),
brightness_max=light_info.get('brightness_max'),
temperature_min=light_info.get('temperature_min'),
temperature_max=light_info.get('temperature_max'),
hue_min=light_info.get('hue_min'),
hue_max=light_info.get('hue_max'),
saturation_min=light_info.get('saturation_min'),
saturation_max=light_info.get('saturation_max'),
brightness=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('brightness_name', 'brightness'))
),
temperature=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('temperature_name', 'temperature'))
),
hue=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('hue_name', 'hue'))
),
saturation=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('saturation_name', 'saturation'))
),
x=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('x_name', 'x'))
),
y=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('y_name', 'y'))
),
description=dev_def.get('description'),
data=dev_info,
compatible_entities.append(
Light(
id=f'{dev["ieee_address"]}:light',
name=dev.get('friendly_name'),
on=dev.get('state', {}).get('state')
== switch_info.get('value_on'),
brightness_min=light_info.get('brightness_min'),
brightness_max=light_info.get('brightness_max'),
temperature_min=light_info.get('temperature_min'),
temperature_max=light_info.get('temperature_max'),
hue_min=light_info.get('hue_min'),
hue_max=light_info.get('hue_max'),
saturation_min=light_info.get('saturation_min'),
saturation_max=light_info.get('saturation_max'),
brightness=(
dev.get('state', {}).get(
light_info.get('brightness_name', 'brightness')
)
),
temperature=(
dev.get('state', {}).get(
light_info.get('temperature_name', 'temperature')
)
),
hue=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('hue_name', 'hue'))
),
saturation=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('saturation_name', 'saturation'))
),
x=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('x_name', 'x'))
),
y=(
dev.get('state', {})
.get('color', {})
.get(light_info.get('y_name', 'y'))
),
description=dev_def.get('description'),
data=dev_info,
)
)
elif switch_info and dev.get('state', {}).get('state') is not None:
converted_entity = Switch(
id=dev['ieee_address'],
name=dev.get('friendly_name'),
state=dev.get('state', {}).get('state') == switch_info['value_on'],
description=dev_def.get("description"),
data=dev_info,
compatible_entities.append(
Switch(
id=f'{dev["ieee_address"]}:switch',
name=dev.get('friendly_name'),
state=dev.get('state', {}).get('state')
== switch_info['value_on'],
description=dev_def.get("description"),
data=dev_info,
is_read_only=switch_info['is_read_only'],
is_write_only=switch_info['is_write_only'],
)
)
if converted_entity:
compatible_entities.append(converted_entity)
if sensors:
compatible_entities += sensors
if enum_switches:
compatible_entities += enum_switches
return super().transform_entities(compatible_entities) # type: ignore
def _get_network_info(self, **kwargs):
def _get_network_info(self, **kwargs) -> dict:
self.logger.info('Fetching Zigbee network information')
client = None
mqtt_args = self._mqtt_args(**kwargs)
@ -308,28 +329,30 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
}
self.logger.info('Zigbee network configuration updated')
return info
finally:
try:
client.loop_stop()
client.disconnect()
if client:
client.loop_stop()
client.disconnect()
except Exception as e:
self.logger.warning(
'Error on MQTT client disconnection: {}'.format(str(e))
)
return info
def _topic(self, topic):
return self.base_topic + '/' + topic
@staticmethod
def _parse_response(response: Union[dict, Response]) -> dict:
if isinstance(response, Response):
response = response.output
response = response.output # type: ignore[reportGeneralTypeIssues]
assert response.get('status') != 'error', response.get(
assert response.get('status') != 'error', response.get( # type: ignore[reportGeneralTypeIssues]
'error', 'zigbee2mqtt error'
)
return response
return response # type: ignore[reportGeneralTypeIssues]
@action
def devices(self, **kwargs) -> List[Dict[str, Any]]:
@ -503,7 +526,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
]
"""
return self._get_network_info(**kwargs).get('devices')
return self._get_network_info(**kwargs).get('devices', {})
@action
def permit_join(
@ -520,12 +543,13 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
"""
if timeout:
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/permit_join'),
msg={'value': permit, 'time': timeout},
reply_topic=self._topic('bridge/response/permit_join'),
**self._mqtt_args(**kwargs),
)
or {}
)
return self.publish(
@ -560,7 +584,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/config/log_level'),
msg={'value': level},
reply_topic=self._topic('bridge/response/config/log_level'),
@ -573,14 +597,14 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
"""
Change the options of a device. Options can only be changed, not added or deleted.
:param device: Display name of the device.
:param device: Display name or IEEE address of the device.
:param option: Option name.
:param value: New value.
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/options'),
reply_topic=self._topic('bridge/response/device/options'),
msg={
@ -606,7 +630,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/remove'),
msg={'id': device, 'force': force},
reply_topic=self._topic('bridge/response/device/remove'),
@ -624,7 +648,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/ban'),
reply_topic=self._topic('bridge/response/device/ban'),
msg={'id': device},
@ -643,7 +667,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/whitelist'),
reply_topic=self._topic('bridge/response/device/whitelist'),
msg={'id': device},
@ -666,8 +690,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
self.logger.info('Old and new name are the same: nothing to do')
return
# noinspection PyUnresolvedReferences
devices = self.devices().output
devices = self.devices().output # type: ignore[reportGeneralTypeIssues]
assert not [
dev for dev in devices if dev.get('friendly_name') == name
], 'A device named {} already exists on the network'.format(name)
@ -684,7 +707,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
}
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/rename'),
msg=req,
reply_topic=self._topic('bridge/response/device/rename'),
@ -694,9 +717,16 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
@staticmethod
def build_device_get_request(values: List[Dict[str, Any]]) -> dict:
def extract_value(value: dict, root: dict):
if not value.get('access', 1) & 0x1:
# Property not readable
def extract_value(value: dict, root: dict, depth: int = 0):
for feature in value.get('features', []):
new_root = root
if depth > 0:
new_root = root[value['property']] = root.get(value['property'], {})
extract_value(feature, new_root, depth=depth + 1)
if not value.get('access', 1) & 0x4:
# Property not readable/query-able
return
if 'features' not in value:
@ -708,9 +738,6 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
root[value['property']] = root.get(value['property'], {})
root = root[value['property']]
for feature in value['features']:
extract_value(feature, root)
ret = {}
for value in values:
extract_value(value, root=ret)
@ -722,7 +749,6 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
device, self._info['devices_by_addr'].get(device, {})
)
# noinspection PyShadowingBuiltins
@action
def device_get(
self, device: str, property: Optional[str] = None, **kwargs
@ -741,7 +767,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
kwargs = self._mqtt_args(**kwargs)
device_info = self._get_device_info(device)
if device_info:
device = device_info.get('friendly_name') or device_info['ieee_address']
device = device_info.get('friendly_name') or self._ieee_address(device_info)
if property:
properties = self.publish(
@ -749,7 +775,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
reply_topic=self._topic(device),
msg={property: ''},
**kwargs,
).output
).output # type: ignore[reportGeneralTypeIssues]
assert property in properties, f'No such property: {property}'
return {property: properties[property]}
@ -774,7 +800,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
reply_topic=self._topic(device),
msg=self.build_device_get_request(exposes),
**kwargs,
).output
).output # type: ignore[reportGeneralTypeIssues]
if device_info:
self.publish_entities( # type: ignore
@ -816,13 +842,15 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
kwargs = self._mqtt_args(**kwargs)
if not devices:
devices = {
device['friendly_name'] or device['ieee_address']
for device in self.devices(**kwargs).output
}
devices = list(
{
device['friendly_name'] or device['ieee_address']
for device in self.devices(**kwargs).output # type: ignore[reportGeneralTypeIssues]
}
)
def worker(device: str, q: Queue):
q.put(self.device_get(device, **kwargs).output)
q.put(self.device_get(device, **kwargs).output) # type: ignore[reportGeneralTypeIssues]
queues = {}
workers = {}
@ -857,7 +885,6 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
"""
return self.devices_get([device] if device else None, *args, **kwargs)
# noinspection PyShadowingBuiltins,DuplicatedCode
@action
def device_set(
self,
@ -880,22 +907,63 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
msg = (values or {}).copy()
reply_topic = self._topic(device)
if property:
msg[property] = value
stored_property = next(
iter(
exposed
for exposed in (
self._info.get('devices_by_addr', {})
.get(device, {})
.get('definition', {})
.get('exposes', {})
)
if exposed.get('property') == property
),
None,
)
if stored_property and self._is_write_only(stored_property):
# Don't wait for an update from a value that is not readable
reply_topic = None
properties = self.publish(
topic=self._topic(device + '/set'),
reply_topic=self._topic(device),
reply_topic=reply_topic,
msg=msg,
**self._mqtt_args(**kwargs),
).output
).output # type: ignore[reportGeneralTypeIssues]
if property:
if property and reply_topic:
assert property in properties, 'No such property: ' + property
return {property: properties[property]}
return properties
@action
def set_value(
self, device: str, property: Optional[str] = None, data=None, **kwargs
):
"""
Entity-compatible way of setting a value on a node.
:param device: Device friendly name, IEEE address or internal entity ID
in ``<address>:<property>`` format.
:param property: Name of the property to set. If not specified here, it
should be specified on ``device`` in ``<address>:<property>``
format.
:param kwargs: Extra arguments to be passed to
:meth:`platypush.plugins.mqtt.MqttPlugin.publish`` (default: query
the default configured device).
"""
dev, prop = self._ieee_address(device, with_property=True)
if not property:
property = prop
self.device_set(dev, property, data, **kwargs)
@action
def device_check_ota_updates(self, device: str, **kwargs) -> dict:
"""
@ -917,7 +985,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
"""
ret = self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/ota_update/check'),
reply_topic=self._topic('bridge/response/device/ota_update/check'),
msg={'id': device},
@ -941,7 +1009,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/ota_update/update'),
reply_topic=self._topic('bridge/response/device/ota_update/update'),
msg={'id': device},
@ -957,7 +1025,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
:param kwargs: Extra arguments to be passed to :meth:`platypush.plugins.mqtt.MqttPlugin.publish``
(default: query the default configured device).
"""
return self._get_network_info(**kwargs).get('groups')
return self._get_network_info(**kwargs).get('groups', [])
@action
def info(self, **kwargs) -> dict:
@ -1113,7 +1181,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
)
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/group/add'),
reply_topic=self._topic('bridge/response/group/add'),
msg=payload,
@ -1142,7 +1210,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
reply_topic=self._topic(group),
msg=msg,
**self._mqtt_args(**kwargs),
).output
).output # type: ignore[reportGeneralTypeIssues]
if property:
assert property in properties, 'No such property: ' + property
@ -1169,7 +1237,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
reply_topic=self._topic(group),
msg={property: value},
**self._mqtt_args(**kwargs),
).output
).output # type: ignore[reportGeneralTypeIssues]
if property:
assert property in properties, 'No such property: ' + property
@ -1191,14 +1259,17 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
self.logger.info('Old and new name are the same: nothing to do')
return
# noinspection PyUnresolvedReferences
groups = {group.get('friendly_name'): group for group in self.groups().output}
groups = {
group.get('friendly_name'): group
for group in self.groups().output # type: ignore[reportGeneralTypeIssues]
}
assert (
name not in groups
), 'A group named {} already exists on the network'.format(name)
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/group/rename'),
reply_topic=self._topic('bridge/response/group/rename'),
msg={'from': group, 'to': name} if group else name,
@ -1216,7 +1287,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/group/remove'),
reply_topic=self._topic('bridge/response/group/remove'),
msg=name,
@ -1235,7 +1306,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/group/members/add'),
reply_topic=self._topic('bridge/response/group/members/add'),
msg={
@ -1258,7 +1329,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic(
'bridge/request/group/members/remove{}'.format(
'_all' if device is None else ''
@ -1294,7 +1365,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/bind'),
reply_topic=self._topic('bridge/response/device/bind'),
msg={'from': source, 'to': target},
@ -1315,7 +1386,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
(default: query the default configured device).
"""
return self._parse_response(
self.publish(
self.publish( # type: ignore[reportGeneralTypeIssues]
topic=self._topic('bridge/request/device/unbind'),
reply_topic=self._topic('bridge/response/device/unbind'),
msg={'from': source, 'to': target},
@ -1324,54 +1395,55 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
)
@action
def on(self, device, *args, **kwargs) -> dict:
def on(self, device, *_, **__) -> dict:
"""
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.on` and turns on a Zigbee device with a writable
binary property.
"""
switch_info = self._get_switch_info(device)
assert switch_info, '{} is not a valid switch'.format(device)
device = switch_info.get('friendly_name') or switch_info['ieee_address']
device = switch_info.get('friendly_name') or self._ieee_address(switch_info)
props = self.device_set(
device, switch_info['property'], switch_info['value_on']
).output
).output # type: ignore[reportGeneralTypeIssues]
return self._properties_to_switch(
device=device, props=props, switch_info=switch_info
)
@action
def off(self, device, *args, **kwargs) -> dict:
def off(self, device, *_, **__) -> dict:
"""
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.off` and turns off a Zigbee device with a
writable binary property.
"""
switch_info = self._get_switch_info(device)
assert switch_info, '{} is not a valid switch'.format(device)
device = switch_info.get('friendly_name') or switch_info['ieee_address']
device = switch_info.get('friendly_name') or self._ieee_address(switch_info)
props = self.device_set(
device, switch_info['property'], switch_info['value_off']
).output
).output # type: ignore[reportGeneralTypeIssues]
return self._properties_to_switch(
device=device, props=props, switch_info=switch_info
)
@action
def toggle(self, device, *args, **kwargs) -> dict:
def toggle(self, device, *_, **__) -> dict:
"""
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle` and toggles a Zigbee device with a
writable binary property.
Implements :meth:`platypush.plugins.switch.plugin.SwitchPlugin.toggle`
and toggles a Zigbee device with a writable binary property.
"""
switch_info = self._get_switch_info(device)
assert switch_info, '{} is not a valid switch'.format(device)
device = switch_info.get('friendly_name') or switch_info['ieee_address']
device = switch_info.get('friendly_name') or self._ieee_address(switch_info)
props = self.device_set(
device, switch_info['property'], switch_info['value_toggle']
).output
).output # type: ignore[reportGeneralTypeIssues]
return self._properties_to_switch(
device=device, props=props, switch_info=switch_info
)
def _get_switch_info(self, device: str):
device = self._ieee_address(device)
switches_info = self._get_switches_info()
info = switches_info.get(device)
if info:
@ -1392,7 +1464,44 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
}
@staticmethod
def _get_switch_meta(device_info: dict) -> dict:
def _is_read_only(feature: dict) -> bool:
return bool(feature.get('access', 0) & 2) == 0 and (
bool(feature.get('access', 0) & 1) == 1
or bool(feature.get('access', 0) & 4) == 1
)
@staticmethod
def _is_write_only(feature: dict) -> bool:
return bool(feature.get('access', 0) & 2) == 1 and (
bool(feature.get('access', 0) & 1) == 0
or bool(feature.get('access', 0) & 4) == 0
)
@staticmethod
def _ieee_address(
device: Union[dict, str], with_property=False
) -> Union[str, Tuple[str, Optional[str]]]:
# Entity value IDs are stored in the `<address>:<property>`
# format. Therefore, we need to split by `:` if we want to
# retrieve the original address.
if isinstance(device, dict):
dev = device['ieee_address']
else:
dev = device
# IEEE address + property format
if re.search(r'^0x[0-9a-fA-F]{16}:', dev):
parts = dev.split(':')
return (
(parts[0], parts[1] if len(parts) > 1 else None)
if with_property
else parts[0]
)
return (dev, None) if with_property else dev
@classmethod
def _get_switch_meta(cls, device_info: dict) -> dict:
exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
for exposed in exposes:
for feature in exposed.get('features', []):
@ -1409,14 +1518,113 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'value_on': feature['value_on'],
'value_off': feature['value_off'],
'value_toggle': feature.get('value_toggle', None),
'is_read_only': not bool(feature.get('access', 0) & 2),
'is_write_only': not bool(feature.get('access', 0) & 1),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
return {}
@staticmethod
def _get_light_meta(device_info: dict) -> dict:
@classmethod
def _get_sensors(cls, device_info: dict) -> List[Sensor]:
sensors = []
exposes = [
exposed
for exposed in (device_info.get('definition', {}) or {}).get('exposes', [])
if (exposed.get('property') and cls._is_read_only(exposed))
]
for exposed in exposes:
entity_type = None
sensor_args = {
'id': f'{device_info["ieee_address"]}:{exposed["property"]}',
'name': (
device_info.get('friendly_name', '[Unnamed device]')
+ ' ['
+ exposed.get('description', '')
+ ']'
),
'value': device_info.get('state', {}).get(exposed['property']),
'description': exposed.get('description'),
'is_read_only': cls._is_read_only(exposed),
'is_write_only': cls._is_write_only(exposed),
'data': device_info,
}
if exposed.get('type') == 'numeric':
sensor_args.update(
{
'min': exposed.get('value_min'),
'max': exposed.get('value_max'),
'unit': exposed.get('unit'),
}
)
if exposed.get('property') == 'battery':
entity_type = Battery
elif exposed.get('property') == 'linkquality':
entity_type = LinkQuality
elif exposed.get('property') == 'current':
entity_type = CurrentSensor
elif exposed.get('property') == 'energy':
entity_type = EnergySensor
elif exposed.get('property') == 'power':
entity_type = PowerSensor
elif exposed.get('property') == 'voltage':
entity_type = VoltageSensor
elif exposed.get('property', '').endswith('temperature'):
entity_type = TemperatureSensor
elif exposed.get('property', '').endswith('humidity'):
entity_type = HumiditySensor
elif exposed.get('type') == 'binary':
entity_type = BinarySensor
sensor_args['value'] = sensor_args['value'] == exposed.get(
'value_on', True
)
elif exposed.get('type') == 'numeric':
entity_type = NumericSensor
if entity_type:
sensors.append(entity_type(**sensor_args))
return sensors
@classmethod
def _get_enum_switches(cls, device_info: dict) -> List[Sensor]:
devices = []
exposes = [
exposed
for exposed in (device_info.get('definition', {}) or {}).get('exposes', [])
if (
exposed.get('property')
and exposed.get('access', 0) & 2
and exposed.get('type') == 'enum'
and exposed.get('values')
)
]
for exposed in exposes:
devices.append(
EnumSwitch(
id=f'{device_info["ieee_address"]}:{exposed["property"]}',
name=(
device_info.get('friendly_name', '[Unnamed device]')
+ ' ['
+ exposed.get('description', '')
+ ']'
),
value=device_info.get(exposed['property']),
values=exposed.get('values', []),
description=exposed.get('description'),
is_read_only=cls._is_read_only(exposed),
is_write_only=cls._is_write_only(exposed),
data=device_info,
)
)
return devices
@classmethod
def _get_light_meta(cls, device_info: dict) -> dict:
exposes = (device_info.get('definition', {}) or {}).get('exposes', [])
for exposed in exposes:
if exposed.get('type') == 'light':
@ -1438,8 +1646,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'value_off': feature['value_off'],
'state_name': feature['name'],
'value_toggle': feature.get('value_toggle', None),
'is_read_only': not bool(feature.get('access', 0) & 2),
'is_write_only': not bool(feature.get('access', 0) & 1),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
elif (
feature.get('property') == 'brightness'
@ -1451,8 +1659,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'brightness_name': feature['name'],
'brightness_min': feature['value_min'],
'brightness_max': feature['value_max'],
'is_read_only': not bool(feature.get('access', 0) & 2),
'is_write_only': not bool(feature.get('access', 0) & 1),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
elif (
feature.get('property') == 'color_temp'
@ -1464,8 +1672,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'temperature_name': feature['name'],
'temperature_min': feature['value_min'],
'temperature_max': feature['value_max'],
'is_read_only': not bool(feature.get('access', 0) & 2),
'is_write_only': not bool(feature.get('access', 0) & 1),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
elif (
feature.get('property') == 'color'
@ -1481,12 +1689,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'hue_max': color_feature.get(
'value_max', 65535
),
'is_read_only': not bool(
feature.get('access', 0) & 2
),
'is_write_only': not bool(
feature.get('access', 0) & 1
),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
)
elif color_feature.get('property') == 'saturation':
@ -1499,12 +1703,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'saturation_max': color_feature.get(
'value_max', 255
),
'is_read_only': not bool(
feature.get('access', 0) & 2
),
'is_write_only': not bool(
feature.get('access', 0) & 1
),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
)
elif color_feature.get('property') == 'x':
@ -1513,12 +1713,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'x_name': color_feature['name'],
'x_min': color_feature.get('value_min', 0.0),
'x_max': color_feature.get('value_max', 1.0),
'is_read_only': not bool(
feature.get('access', 0) & 2
),
'is_write_only': not bool(
feature.get('access', 0) & 1
),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
)
elif color_feature.get('property') == 'y':
@ -1527,12 +1723,8 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
'y_name': color_feature['name'],
'y_min': color_feature.get('value_min', 0),
'y_max': color_feature.get('value_max', 255),
'is_read_only': not bool(
feature.get('access', 0) & 2
),
'is_write_only': not bool(
feature.get('access', 0) & 1
),
'is_read_only': cls._is_read_only(feature),
'is_write_only': cls._is_write_only(feature),
}
)
@ -1548,8 +1740,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
return {}
def _get_switches_info(self) -> dict:
# noinspection PyUnresolvedReferences
devices = self.devices().output
devices = self.devices().output # type: ignore[reportGeneralTypeIssues]
switches_info = {}
for device in devices:
@ -1558,7 +1749,7 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
continue
switches_info[
device.get('friendly_name', device.get('ieee_address'))
device.get('friendly_name', device['ieee_address'] + ':switch')
] = info
return switches_info
@ -1571,14 +1762,13 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
``state`` property that can be set to ``ON`` or ``OFF``).
"""
switches_info = self._get_switches_info()
# noinspection PyUnresolvedReferences
return [
self._properties_to_switch(
device=name, props=switch, switch_info=switches_info[name]
)
for name, switch in self.devices_get(
list(switches_info.keys())
).output.items()
).output.items() # type: ignore[reportGeneralTypeIssues]
]
@action
@ -1587,10 +1777,11 @@ class ZigbeeMqttPlugin(MqttPlugin): # lgtm [py/missing-call-to-init]
Set the state for one or more Zigbee lights.
"""
lights = [lights] if isinstance(lights, str) else lights
lights = [self._ieee_address(t) for t in lights]
devices = [
dev
for dev in self._get_network_info().get('devices', [])
if dev.get('ieee_address') in lights or dev.get('friendly_name') in lights
if self._ieee_address(dev) in lights or dev.get('friendly_name') in lights
]
for dev in devices:

View file

@ -2,13 +2,14 @@ from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, List, Union
from platypush.entities import manages
from platypush.entities.batteries import Battery
from platypush.entities.dimmers import Dimmer
from platypush.entities.lights import Light
from platypush.entities.switches import Switch
from platypush.plugins import Plugin, action
@manages(Dimmer, Light, Switch)
@manages(Battery, Dimmer, Light, Switch)
class ZwaveBasePlugin(Plugin, ABC):
"""
Base class for Z-Wave plugins.

View file

@ -6,6 +6,7 @@ from datetime import datetime
from threading import Timer
from typing import Optional, List, Any, Dict, Union, Iterable, Mapping, Callable
from platypush.entities.batteries import Battery
from platypush.entities.dimmers import Dimmer
from platypush.entities.switches import Switch
from platypush.message.event.zwave import ZwaveNodeRenamedEvent, ZwaveNodeEvent
@ -482,6 +483,14 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
value, 'switch_multilevel', 'switch_toggle_multilevel'
) and not value.get('is_read_only')
@classmethod
def _is_battery(cls, value: Mapping):
return (
cls._matches_classes(value, 'battery')
and value.get('is_read_only')
and not value['id'].endswith('-isLow')
)
def _to_entity_args(self, value: Mapping) -> dict:
if value['id'].endswith('-targetValue'):
current_value_id = '-'.join(value['id'].split('-')[:-1] + ['currentValue'])
@ -525,6 +534,12 @@ class ZwaveMqttPlugin(MqttPlugin, ZwaveBasePlugin):
entity_args['value'] = value['data']
entity_args['min'] = value['min']
entity_args['max'] = value['max']
elif self._is_battery(value):
entity_type = Battery
entity_args['value'] = value['data']
entity_args['min'] = value['min']
entity_args['max'] = value['max']
entity_args['unit'] = value.get('units', '%')
elif self._is_switch(value):
entity_type = Switch
entity_args['state'] = value['data']