Skip to content

Commit

Permalink
Merge branch 'release/0.0.100'
Browse files Browse the repository at this point in the history
  • Loading branch information
dmulcahey committed May 31, 2023
2 parents 6cf31d8 + a9c883d commit cdc900f
Show file tree
Hide file tree
Showing 19 changed files with 662 additions and 50 deletions.
38 changes: 28 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ ZHA Device Handlers are custom quirks implementations for [Zigpy](https://github

ZHA device handlers bridge the functionality gap created when manufacturers deviate from the ZCL specification, handling deviations and exceptions by parsing custom messages to and from Zigbee devices. Zigbee devices that deviate from or do not fully conform to the standard specifications set by the Zigbee Alliance may require the development of custom ZHA Device Handlers (ZHA custom quirks handler implementation) to for all their functions to work properly with the ZHA component in Home Assistant.

Custom quirks implementations for zigpy implemented as ZHA Device Handlers are a similar concept to that of [Hub-connected Device Handlers for the SmartThings Classics platform](https://docs.smartthings.com/en/latest/device-type-developers-guide/) as well that of [Zigbee-Herdsman Converters / Zigbee-Shepherd Converters as used by Zigbee2mqtt](https://www.zigbee2mqtt.io/how_tos/how_to_support_new_devices.html), meaning they are virtual representation of a physical device that expose additional functionality that is not provided out-of-the-box by the existing integration between these platforms. See [Device Specifics](#Device-Specifics) for details.
Custom quirks implementations for zigpy implemented as ZHA Device Handlers are a similar concept to that of [Hub-connected Device Handlers for the SmartThings Classics platform](https://stdavedemo.readthedocs.io/en/latest/device-type-developers-guide/) as well that of [Zigbee-Herdsman Converters (formerly Zigbee-Shepherd Converters) as used by Zigbee2mqtt](https://www.zigbee2mqtt.io/advanced/support-new-devices/01_support_new_devices.html), meaning they are virtual representation of a physical device that expose additional functionality that is not provided out-of-the-box by the existing integration between these platforms. See [Device Specifics](#Device-Specifics) for details.

# How to contribute

Expand All @@ -17,15 +17,33 @@ ZHA device handlers and it's provided Quirks allow Zigpy, ZHA and Home Assistant

## What are these specifications

[Zigbee PRO 2017 (R22) Protocol Specification](https://zigbeealliance.org/wp-content/uploads/2019/11/docs-05-3474-21-0csg-zigbee-specification.pdf)

[Zigbee Cluster Library (R8)](https://zigbeealliance.org/wp-content/uploads/2021/10/07-5123-08-Zigbee-Cluster-Library.pdf)

[Zigbee Base Device Behavior Specification (V1.0)](https://zigbeealliance.org/wp-content/uploads/zip/zigbee-base-device-behavior-bdb-v1-0.zip)

[Zigbee Lighting & Occupancy Device Specification (V1.0)](https://zigbeealliance.org/wp-content/uploads/2019/11/docs-15-0014-05-0plo-Lighting-OccupancyDevice-Specification-V1.0.pdf)

[Zigbee Primer](https://docs.smartthings.com/en/latest/device-type-developers-guide/zigbee-primer.html)
Reference official Zigbee specification documentation from Connectivity Standards Alliance (a.k.a. "CSA-IOT", formerly "Zigbee Alliance"):

- Zigbee Protocol Specification (also known as "Zigbee Pro" specifications)
- [Zigbee Protocol Specification 2023 (also known as "Zigbee PRO 2023" or just Zigbee R23)](https://csa-iot.org/wp-content/uploads/2023/04/05-3474-23-csg-zigbee-specification-compressed.pdf)
- [Zigbee Protocol Specification 2017 (also known as "Zigbee PRO 2017" or just Zigbee R22)](https://csa-iot.org/wp-content/uploads/2022/01/docs-05-3474-22-0csg-zigbee-specification-1.pdf)
- [Zigbee Protocol Specification 2015 (also known as "Zigbee PRO 2015" or just Zigbee R21)](https://zigbeealliance.org/wp-content/uploads/2019/11/docs-05-3474-21-0csg-zigbee-specification.pdf)
- Zigbee Cluster Library Specification
- [Zigbee Cluster Library Specification R8 (Revision 8)](https://zigbeealliance.org/wp-content/uploads/2021/10/07-5123-08-Zigbee-Cluster-Library.pdf)
- [Zigbee Cluster Library Specification R7 (Revision 7)](https://github.com/Koenkk/zigbee-herdsman/blob/master/docs/Zigbee%20Cluster%20Library%20Specification%20v7.pdf)
- [Zigbee Cluster Library Specification R6 (Revision 6)](https://zigbeealliance.org/wp-content/uploads/2019/12/07-5123-06-zigbee-cluster-library-specification.pdf)
- Zigbee Device Specifications
- [Zigbee Base Device Behavior Specification (V1.0)](https://zigbeealliance.org/wp-content/uploads/2019/12/docs-13-0402-13-00zi-Base-Device-Behavior-Specification-2-1.pdf)
- [Zigbee Lighting & Occupancy Device Specification (V1.0)](https://zigbeealliance.org/wp-content/uploads/2019/11/docs-15-0014-05-0plo-Lighting-OccupancyDevice-Specification-V1.0.pdf)
- ZigBee Green Power (ZGP "GreenPower" Profile) specifications
- [Zigbee PRO Green Power feature specification Basic functionality set (v 1.1.1)](https://csa-iot.org/wp-content/uploads/2022/01/docs-14-0563-18-batt-Green-Power-Basic-specification-v1.1.1.pdf)
- [Zigbee PRO Green Power feature Specification 1.0a (Revision 26)](https://zigbeealliance.org/wp-content/uploads/2019/11/docs-09-5499-26-batt-zigbee-green-power-specification.pdf)
- ZigBee Smart Energy (ZSE / Zigbee SE "Smart Energy" Profile) specifications
- Zigbee Smart Energy Standard 1.4
- [ZigBee Smart Energy Standard (v1.2a)](https://zigbeealliance.org/wp-content/uploads/2019/11/docs-07-5356-19-0zse-zigbee-smart-energy-profile-specification.pdf)

In additional you can also reference third-party and manufacturer specific documentation:

- [Tuya - Zigbee Connection Standard (Tuya Smart Documentation)](https://github.com/Koenkk/zigbee-herdsman/blob/master/docs/Zigbee%20Connection%20Standard_Tuya%20Smart_Documentation.pdf)
- [Zigbee2MQTT guide on understanding the custom 'manuSpecificTuya' cluster that TuYa devices uses](https://www.zigbee2mqtt.io/advanced/support-new-devices/02_support_new_tuya_devices.html)
- [Samsung SmartThings -Device Handlers](https://stdavedemo.readthedocs.io/en/latest/device-type-developers-guide/)
- [Samsung SmartThings - Zigbee Primer](https://stdavedemo.readthedocs.io/en/latest/device-type-developers-guide/zigbee-primer.html)
- [Samsung SmartThings - Building ZigBee Device Handlers](https://stdavedemo.readthedocs.io/en/latest/device-type-developers-guide/building-zigbee-device-handlers.html)

## What is a device in human terms

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from setuptools import find_packages, setup

VERSION = "0.0.99"
VERSION = "0.0.100"


setup(
Expand Down
25 changes: 25 additions & 0 deletions tests/test_develco.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,31 @@
"""Tests for Develco/Frient A/S quirks."""
import pytest
from zigpy.zcl.clusters.general import DeviceTemperature

import zhaquirks.develco.motion
import zhaquirks.develco.power_plug

from tests.common import ClusterListener

zhaquirks.setup()


@pytest.mark.parametrize("quirk", (zhaquirks.develco.power_plug.SPLZB131,))
async def test_develco_plug_device_temp_multiplier(zigpy_device_from_quirk, quirk):
"""Test device temperature multiplication."""

device = zigpy_device_from_quirk(quirk)

dev_temp_cluster = device.endpoints[2].device_temperature
dev_temp_listener = ClusterListener(dev_temp_cluster)

dev_temp_attr_id = DeviceTemperature.attributes_by_name["current_temperature"].id

# turn off heating
dev_temp_cluster._update_attribute(dev_temp_attr_id, 25)
assert len(dev_temp_listener.attribute_updates) == 1
assert dev_temp_listener.attribute_updates[0][0] == dev_temp_attr_id
assert dev_temp_listener.attribute_updates[0][1] == 2500 # multiplied by 100


def test_motion_signature(assert_signature_matches_quirk):
Expand Down
50 changes: 50 additions & 0 deletions tests/test_ikea.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
"""Tests for Ikea Starkvind quirks."""

from unittest import mock

from zigpy.zcl import foundation
from zigpy.zcl.clusters.measurement import PM25

import zhaquirks
import zhaquirks.ikea.starkvind

zhaquirks.setup()


def test_ikea_starkvind(assert_signature_matches_quirk):
"""Test new 'STARKVIND Air purifier table' signature is matched to its quirk."""
Expand Down Expand Up @@ -72,3 +80,45 @@ def test_ikea_starkvind_v2(assert_signature_matches_quirk):
}

assert_signature_matches_quirk(zhaquirks.ikea.starkvind.IkeaSTARKVIND_v2, signature)


async def test_pm25_cluster_read(zigpy_device_from_quirk):
"""Test reading from PM25 cluster"""

starkvind_device = zigpy_device_from_quirk(zhaquirks.ikea.starkvind.IkeaSTARKVIND)
assert starkvind_device.model == "STARKVIND Air purifier"

pm25_cluster = starkvind_device.endpoints[1].in_clusters[PM25.cluster_id]
ikea_cluster = starkvind_device.endpoints[1].in_clusters[
zhaquirks.ikea.starkvind.IkeaAirpurifier.cluster_id
]

# Mock the read attribute to on the IkeaAirpurifier cluster
# to always return 6 for anything.
def mock_read(attributes, manufacturer=None):
records = [
foundation.ReadAttributeRecord(
attr, foundation.Status.SUCCESS, foundation.TypeValue(None, 6)
)
for attr in attributes
]
return (records,)

patch_ikeacluster_read = mock.patch.object(
ikea_cluster, "_read_attributes", mock.AsyncMock(side_effect=mock_read)
)
with patch_ikeacluster_read:
# Reading "measured_value" should read the "air_quality_25pm" value from
# the IkeaAirpurifier cluster
success, fail = await pm25_cluster.read_attributes(["measured_value"])
assert success
assert 6 in success.values()
assert not fail

# Same call with allow_cache=True; a bug previously prevented this from working
success, fail = await pm25_cluster.read_attributes(
["measured_value"], allow_cache=True
)
assert success
assert 6 in success.values()
assert not fail
28 changes: 28 additions & 0 deletions tests/test_legrand.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,31 @@ async def test_legrand_battery(zigpy_device_from_quirk, voltage, bpr):
power_cluster = device.endpoints[1].power
power_cluster.update_attribute(0x0020, voltage)
assert power_cluster["battery_percentage_remaining"] == bpr


def test_light_switch_with_neutral_signature(assert_signature_matches_quirk):
signature = {
"node_descriptor": "NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, user_descriptor_available=1, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress|RxOnWhenIdle|MainsPowered|FullFunctionDevice: 142>, manufacturer_code=4129, maximum_buffer_size=89, maximum_incoming_transfer_size=63, server_mask=10752, maximum_outgoing_transfer_size=63, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)",
"endpoints": {
"1": {
"profile_id": 260,
"device_type": "0x0100",
"in_clusters": [
"0x0000",
"0x0003",
"0x0004",
"0x0005",
"0x0006",
"0x000f",
"0xfc01",
],
"out_clusters": ["0x0000", "0x0019", "0xfc01"],
}
},
"manufacturer": " Legrand",
"model": " Light switch with neutral",
"class": "zigpy.device.Device",
}
assert_signature_matches_quirk(
zhaquirks.legrand.switch.LightSwitchWithNeutral, signature
)
25 changes: 25 additions & 0 deletions tests/test_quirks.py
Original file line number Diff line number Diff line change
Expand Up @@ -772,3 +772,28 @@ def test_attributes_updated_not_replaced(quirk: CustomDevice) -> None:
f"Cluster {cluster} deletes parent class's attributes instead of"
f" extending them: {base_attr_names - quirk_attr_names}"
)


@pytest.mark.parametrize("quirk", ALL_QUIRK_CLASSES)
def test_no_duplicate_clusters(quirk: CustomDevice) -> None:
"""Verify no quirks contain clusters with duplicate cluster ids in the replacement."""

def check_for_duplicate_cluster_ids(clusters) -> None:
used_cluster_ids = set()

for cluster in clusters:
if isinstance(cluster, int):
cluster_id = cluster
else:
cluster_id = cluster.cluster_id

if cluster_id in used_cluster_ids:
pytest.fail(
f"Cluster ID 0x{cluster_id:04X} is used more than once in the"
f" replacement for endpoint {ep_id} in {quirk}"
)
used_cluster_ids.add(cluster_id)

for ep_id, ep_data in quirk.replacement[ENDPOINTS].items():
check_for_duplicate_cluster_ids(ep_data.get(INPUT_CLUSTERS, []))
check_for_duplicate_cluster_ids(ep_data.get(OUTPUT_CLUSTERS, []))
19 changes: 19 additions & 0 deletions tests/test_tuya.py
Original file line number Diff line number Diff line change
Expand Up @@ -1408,6 +1408,25 @@ def test_ts0601_valve_signature(assert_signature_matches_quirk):
assert_signature_matches_quirk(zhaquirks.tuya.ts0601_valve.TuyaValve, signature)


def test_ts0601_motion_signature(assert_signature_matches_quirk):
"""Test TS0601 motion by TreatLife remote signature is matched to its quirk."""
signature = {
"node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=10752, maximum_outgoing_transfer_size=66, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
"endpoints": {
"1": {
"profile_id": 260,
"device_type": "0x0051",
"in_clusters": ["0x0000", "0x0004", "0x0005", "0xef00"],
"out_clusters": ["0x000a", "0x0019"],
}
},
"manufacturer": "_TZE200_ppuj1vem",
"model": "TS0601",
"class": "zigpy.device.Device",
}
assert_signature_matches_quirk(zhaquirks.tuya.ts0601_motion.NeoMotion, signature)


def test_multiple_attributes_report():
"""Test a multi attribute report from Tuya device."""

Expand Down
106 changes: 106 additions & 0 deletions zhaquirks/develco/power_plug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Develco smart plugs."""
from zigpy.profiles import zha
from zigpy.quirks import CustomCluster, CustomDevice
from zigpy.zcl.clusters.general import (
Alarms,
Basic,
DeviceTemperature,
Groups,
Identify,
OnOff,
Ota,
Scenes,
Time,
)
from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
from zigpy.zcl.clusters.measurement import OccupancySensing
from zigpy.zcl.clusters.smartenergy import Metering

from zhaquirks.const import (
DEVICE_TYPE,
ENDPOINTS,
INPUT_CLUSTERS,
MODELS_INFO,
OUTPUT_CLUSTERS,
PROFILE_ID,
)
from zhaquirks.develco import DEVELCO

DEV_TEMP_ID = DeviceTemperature.attributes_by_name["current_temperature"].id


class DevelcoDeviceTemperature(CustomCluster, DeviceTemperature):
"""Custom device temperature cluster to multiply the temperature by 100."""

def _update_attribute(self, attrid, value):
if attrid == DEV_TEMP_ID:
value = value * 100
super()._update_attribute(attrid, value)


class SPLZB131(CustomDevice):
"""Custom device Develco smart plug device."""

signature = {
MODELS_INFO: [(DEVELCO, "SPLZB-131")],
ENDPOINTS: {
1: {
PROFILE_ID: 0xC0C9,
DEVICE_TYPE: 1,
INPUT_CLUSTERS: [
Scenes.cluster_id,
OnOff.cluster_id,
],
OUTPUT_CLUSTERS: [],
},
2: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
INPUT_CLUSTERS: [
Basic.cluster_id,
DeviceTemperature.cluster_id,
Identify.cluster_id,
Groups.cluster_id,
Scenes.cluster_id,
OnOff.cluster_id,
Alarms.cluster_id,
Metering.cluster_id,
ElectricalMeasurement.cluster_id,
],
OUTPUT_CLUSTERS: [
Basic.cluster_id,
Identify.cluster_id,
Time.cluster_id,
Ota.cluster_id,
OccupancySensing.cluster_id,
],
},
},
}

replacement = {
ENDPOINTS: {
2: {
PROFILE_ID: zha.PROFILE_ID,
DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
INPUT_CLUSTERS: [
Basic.cluster_id,
DevelcoDeviceTemperature,
Identify.cluster_id,
Groups.cluster_id,
Scenes.cluster_id,
OnOff.cluster_id,
Alarms.cluster_id,
Metering.cluster_id,
ElectricalMeasurement.cluster_id,
],
OUTPUT_CLUSTERS: [
Basic.cluster_id,
Identify.cluster_id,
Time.cluster_id,
Ota.cluster_id,
OccupancySensing.cluster_id,
],
},
}
}
2 changes: 1 addition & 1 deletion zhaquirks/ikea/starkvind.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ async def read_attributes(
await self.endpoint.device.endpoints[1]
.in_clusters[64637]
.read_attributes(
{"air_quality_25pm"},
["air_quality_25pm"],
allow_cache=allow_cache,
only_cache=only_cache,
manufacturer=manufacturer,
Expand Down
28 changes: 28 additions & 0 deletions zhaquirks/legrand/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,30 @@
"""Module for Legrand devices."""

from zigpy.quirks import CustomCluster
import zigpy.types as t
from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster

from zhaquirks import PowerConfigurationCluster

LEGRAND = "Legrand"
MANUFACTURER_SPECIFIC_CLUSTER_ID = 0xFC01 # decimal = 64513


class LegrandCluster(CustomCluster, ManufacturerSpecificCluster):
"""LegrandCluster."""

cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID
name = "LegrandCluster"
ep_attribute = "legrand_cluster"
attributes = {
0x0000: ("dimmer", t.data16, True),
0x0001: ("led_dark", t.Bool, True),
0x0002: ("led_on", t.Bool, True),
}


class LegrandPowerConfigurationCluster(PowerConfigurationCluster):
"""PowerConfiguration conversor 'V --> %' for Legrand devices."""

MIN_VOLTS = 2.5
MAX_VOLTS = 3.0
Loading

0 comments on commit cdc900f

Please sign in to comment.