zigpy / zha-device-handlers

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.
Apache License 2.0
722 stars 670 forks source link

[Device Support Request] Legrand contactor PN: 412171 - 412191 - 199122 #3339

Open aauzi opened 2 weeks ago

aauzi commented 2 weeks ago

Problem description

The device is natively detected as a zha.DeviceType.ON_OFF_PLUG_IN_UNIT by ZHA but the automatically provided switch does not control the OnOf cluster it exposes.

On, Off and Toggle OnOff cluster commands result in UNSUPPORTED_CLUSTER_COMMAND failures.

Solution description

The device implements two manufacturer specific clusters:

When the device is in Switch mode, it operates normally the OnOff cluster.

However, when the device is in Auto mode, on, off and toggle OnOff custer's commands are not supported.

The factory defaut is Auto mode. This explains the falty behavior.

The joined custom quirk implements redirection of OnOff commands and on_off attribute to/from the AutoOnOff cluster (id: 0xfc41).

Ideally a complete support would allow to configure the mode and would expose 3 states control and 4 states display: Off (manual), Off (auto), On (auto) and On (manual)

Screenshots/Video

Screenshots/Video Manufacturer documentation: (https://assets.legrand.com/pim/NP-FT-GT/F03037EN-05%20(Connected%20contactor).pdf) Mockup achieved with the attached quirck, zha-toolkit and a bunch of script, templates and automations: ![legrand-contactor-mockup](https://github.com/user-attachments/assets/533f1e15-682c-422c-92c0-49785d71dc3f)

Device signature

Device signature ```json { "node_descriptor": { "logical_type": 1, "complex_descriptor_available": 0, "user_descriptor_available": 1, "reserved": 0, "aps_flags": 0, "frequency_band": 8, "mac_capability_flags": 142, "manufacturer_code": 4129, "maximum_buffer_size": 89, "maximum_incoming_transfer_size": 63, "server_mask": 11264, "maximum_outgoing_transfer_size": 63, "descriptor_capability_field": 0 }, "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x010a", "input_clusters": [ "0x0000", "0x0003", "0x0004", "0x0005", "0x0006", "0x000f", "0x0b04", "0xfc01", "0xfc41" ], "output_clusters": [ "0x0000", "0x0005", "0x0006", "0x0019", "0xfc01" ] }, "242": { "profile_id": "0xa1e0", "device_type": "0x0066", "input_clusters": [ "0x0021" ], "output_clusters": [ "0x0021" ] } }, "manufacturer": " Legrand", "model": " Contactor", "class": "contactor.LegrandContactor" } ```

Diagnostic information

Diagnostic information ```json { "home_assistant": { "installation_type": "Home Assistant Core", "version": "2024.8.3", "dev": false, "hassio": false, "virtualenv": true, "python_version": "3.12.4", "docker": false, "arch": "x86_64", "timezone": "Europe/Paris", "os_name": "Linux", "os_version": "6.10.6-200.fc40.x86_64", "run_as_root": false }, "custom_components": { "zha_toolkit": { "documentation": "https://github.com/mdeweerd/zha-toolkit", "version": "1.0.0", "requirements": [ "aiofiles>=0.4.0", "pytz>=2016.10" ] }, "remote_homeassistant": { "documentation": "https://github.com/custom-components/remote_homeassistant", "version": "4.2", "requirements": [] }, "smartthinq_sensors": { "documentation": "https://github.com/ollo69/ha-smartthinq-sensors", "version": "0.39.2", "requirements": [ "pycountry>=23.12.11", "xmltodict>=0.13.0", "charset_normalizer>=3.2.0" ] }, "xiaomi_miot": { "documentation": "https://github.com/al-one/hass-xiaomi-miot", "version": "0.7.20", "requirements": [ "construct>=2.10.68", "python-miio>=0.5.12", "micloud>=0.5" ] }, "miheater": { "documentation": "https://github.com/ee02217/homeassistant-mi-heater", "version": "1.3.0", "requirements": [] }, "garmin_connect": { "documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect", "version": "0.2.19", "requirements": [ "garminconnect>=0.2.15", "tzlocal" ] }, "tapo": { "documentation": "https://github.com/petretiandrea/home-assistant-tapo-p100", "version": "3.1.2", "requirements": [ "plugp100==5.1.3" ] }, "hacs": { "documentation": "https://hacs.xyz/docs/configuration/start", "version": "1.34.0", "requirements": [ "aiogithubapi>=22.10.1" ] }, "average": { "documentation": "https://github.com/Limych/ha-average", "version": "2.3.5-alpha", "requirements": [] }, "solis": { "documentation": "https://github.com/hultenvp/solis-sensor/", "version": "3.6.0", "requirements": [ "aiofiles>=23.1.0,<24.0.0" ] } }, "integration_manifest": { "domain": "zha", "name": "Zigbee Home Automation", "after_dependencies": [ "onboarding", "usb" ], "codeowners": [ "dmulcahey", "adminiuga", "puddly", "TheJulianJES" ], "config_flow": true, "dependencies": [ "file_upload" ], "documentation": "https://www.home-assistant.io/integrations/zha", "iot_class": "local_polling", "loggers": [ "aiosqlite", "bellows", "crccheck", "pure_pcapy3", "zhaquirks", "zigpy", "zigpy_deconz", "zigpy_xbee", "zigpy_zigate", "zigpy_znp", "zha", "universal_silabs_flasher" ], "requirements": [ "universal-silabs-flasher==0.0.22", "zha==0.0.31" ], "usb": [ { "vid": "10C4", "pid": "EA60", "description": "*2652*", "known_devices": [ "slae.sh cc2652rb stick" ] }, { "vid": "10C4", "pid": "EA60", "description": "*slzb-07*", "known_devices": [ "smlight slzb-07" ] }, { "vid": "1A86", "pid": "55D4", "description": "*sonoff*plus*", "known_devices": [ "sonoff zigbee dongle plus v2" ] }, { "vid": "10C4", "pid": "EA60", "description": "*sonoff*plus*", "known_devices": [ "sonoff zigbee dongle plus" ] }, { "vid": "10C4", "pid": "EA60", "description": "*tubeszb*", "known_devices": [ "TubesZB Coordinator" ] }, { "vid": "1A86", "pid": "7523", "description": "*tubeszb*", "known_devices": [ "TubesZB Coordinator" ] }, { "vid": "1A86", "pid": "7523", "description": "*zigstar*", "known_devices": [ "ZigStar Coordinators" ] }, { "vid": "1CF1", "pid": "0030", "description": "*conbee*", "known_devices": [ "Conbee II" ] }, { "vid": "0403", "pid": "6015", "description": "*conbee*", "known_devices": [ "Conbee III" ] }, { "vid": "10C4", "pid": "8A2A", "description": "*zigbee*", "known_devices": [ "Nortek HUSBZB-1" ] }, { "vid": "0403", "pid": "6015", "description": "*zigate*", "known_devices": [ "ZiGate+" ] }, { "vid": "10C4", "pid": "EA60", "description": "*zigate*", "known_devices": [ "ZiGate" ] }, { "vid": "10C4", "pid": "8B34", "description": "*bv 2010/10*", "known_devices": [ "Bitron Video AV2010/10" ] } ], "zeroconf": [ { "type": "_esphomelib._tcp.local.", "name": "tube*" }, { "type": "_zigate-zigbee-gateway._tcp.local.", "name": "*zigate*" }, { "type": "_zigstar_gw._tcp.local.", "name": "*zigstar*" }, { "type": "_uzg-01._tcp.local.", "name": "uzg-01*" }, { "type": "_slzb-06._tcp.local.", "name": "slzb-06*" }, { "type": "_xzg._tcp.local.", "name": "xzg*" }, { "type": "_czc._tcp.local.", "name": "czc*" } ], "is_built_in": true }, "setup_times": { "null": { "setup": 5.284999497234821e-05 }, "01J6HPR7JHWMSKDPFC8ABGY0HX": { "wait_import_platforms": -0.04487564798910171, "wait_base_component": -0.0003326290170662105, "config_entry_setup": 11.01593358896207 } }, "data": { "ieee": "**REDACTED**", "nwk": 12509, "manufacturer": " Legrand", "model": " Contactor", "name": " Legrand Contactor", "quirk_applied": true, "quirk_class": "contactor.LegrandContactor", "quirk_id": null, "manufacturer_code": 4129, "power_source": "Mains", "lqi": 132, "rssi": -67, "last_seen": "2024-08-31T18:45:11", "available": true, "device_type": "Router", "signature": { "node_descriptor": { "logical_type": 1, "complex_descriptor_available": 0, "user_descriptor_available": 1, "reserved": 0, "aps_flags": 0, "frequency_band": 8, "mac_capability_flags": 142, "manufacturer_code": 4129, "maximum_buffer_size": 89, "maximum_incoming_transfer_size": 63, "server_mask": 11264, "maximum_outgoing_transfer_size": 63, "descriptor_capability_field": 0 }, "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x010a", "input_clusters": [ "0x0000", "0x0003", "0x0004", "0x0005", "0x0006", "0x000f", "0x0b04", "0xfc01", "0xfc41" ], "output_clusters": [ "0x0000", "0x0005", "0x0006", "0x0019", "0xfc01" ] }, "242": { "profile_id": "0xa1e0", "device_type": "0x0066", "input_clusters": [ "0x0021" ], "output_clusters": [ "0x0021" ] } }, "manufacturer": " Legrand", "model": " Contactor" }, "active_coordinator": false, "entities": [ { "entity_id": "binary_sensor.legrand_contactor_entree_binaire", "name": " Legrand Contactor" }, { "entity_id": "binary_sensor.legrand_contactor_ouverture", "name": " Legrand Contactor" }, { "entity_id": "button.legrand_contactor_identifier", "name": " Legrand Contactor" }, { "entity_id": "select.legrand_contactor_comportement_au_demarrage", "name": " Legrand Contactor" }, { "entity_id": "sensor.legrand_contactor_puissance_apparente", "name": " Legrand Contactor" }, { "entity_id": "sensor.legrand_contactor_courant", "name": " Legrand Contactor" }, { "entity_id": "sensor.legrand_contactor_tension", "name": " Legrand Contactor" }, { "entity_id": "sensor.legrand_contactor_puissance", "name": " Legrand Contactor" }, { "entity_id": "switch.legrand_contactor_commutateur", "name": " Legrand Contactor" }, { "entity_id": "update.legrand_contactor_micrologiciel", "name": " Legrand Contactor" } ], "neighbors": [ { "device_type": "Coordinator", "rx_on_when_idle": "On", "relationship": "Sibling", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x0000", "permit_joining": "Accepting", "depth": "0", "lqi": "139" }, { "device_type": "Router", "rx_on_when_idle": "On", "relationship": "Parent", "extended_pan_id": "**REDACTED**", "ieee": "**REDACTED**", "nwk": "0x92B3", "permit_joining": "Accepting", "depth": "1", "lqi": "252" } ], "routes": [ { "dest_nwk": "0x0000", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0x0000" }, { "dest_nwk": "0x92B3", "route_status": "Active", "memory_constrained": false, "many_to_one": false, "route_record_required": false, "next_hop": "0x92B3" } ], "endpoint_names": [ { "name": "ON_OFF_PLUG_IN_UNIT" }, { "name": "COMBO_BASIC" } ], "user_given_name": null, "device_reg_id": "80dfdd11dc6fae2b509f83eb67c5b084", "area_id": "couloir", "cluster_details": { "1": { "device_type": { "name": "ON_OFF_PLUG_IN_UNIT", "id": 266 }, "profile_id": 260, "in_clusters": { "0x0000": { "endpoint_attribute": "basic", "attributes": { "0x0001": { "attribute_name": "app_version", "value": 0 }, "0xfffd": { "attribute_name": "cluster_revision", "value": 3 }, "0x0006": { "attribute_name": "date_code", "value": " " }, "0x0008": { "attribute_name": "generic_device_class", "value": 0 }, "0x0009": { "attribute_name": "generic_device_type", "value": 255 }, "0x0003": { "attribute_name": "hw_version", "value": 2 }, "0x0004": { "attribute_name": "manufacturer", "value": " Legrand" }, "0x0005": { "attribute_name": "model", "value": " Contactor" }, "0x000b": { "attribute_name": "product_url", "value": " https://developer.legrand.com" }, "0x0002": { "attribute_name": "stack_version", "value": 69 }, "0x4000": { "attribute_name": "sw_build_id", "value": "005a" }, "0x0000": { "attribute_name": "zcl_version", "value": 8 } }, "unsupported_attributes": { "0x0010": { "attribute_name": "location_desc" }, "0x0013": { "attribute_name": "alarm_mask" }, "0x000c": { "attribute_name": "manufacturer_version_details" }, "0x000d": { "attribute_name": "serial_number" }, "0x0011": { "attribute_name": "physical_env" }, "0x0012": { "attribute_name": "device_enabled" }, "0x0014": { "attribute_name": "disable_local_config" } } }, "0x0003": { "endpoint_attribute": "identify", "attributes": { "0xfffd": { "attribute_name": "cluster_revision", "value": 2 }, "0x0000": { "attribute_name": "identify_time", "value": 0 } }, "unsupported_attributes": {} }, "0x0004": { "endpoint_attribute": "groups", "attributes": {}, "unsupported_attributes": {} }, "0x0005": { "endpoint_attribute": "scenes", "attributes": {}, "unsupported_attributes": {} }, "0x0006": { "endpoint_attribute": "on_off", "attributes": { "0x0000": { "attribute_name": "on_off", "value": 1 }, "0x4003": { "attribute_name": "start_up_on_off", "value": 255 } }, "unsupported_attributes": {} }, "0x000f": { "endpoint_attribute": "binary_input", "attributes": { "0x0055": { "attribute_name": "present_value", "value": 0 } }, "unsupported_attributes": { "0x0100": { "attribute_name": "application_type" }, "0x0004": { "attribute_name": "active_text" }, "0x001c": { "attribute_name": "description" } } }, "0x0b04": { "endpoint_attribute": "electrical_measurement", "attributes": { "0x0603": { "attribute_name": "ac_current_divisor", "value": 0 }, "0x0602": { "attribute_name": "ac_current_multiplier", "value": 0 }, "0x0605": { "attribute_name": "ac_power_divisor", "value": 1 }, "0x0604": { "attribute_name": "ac_power_multiplier", "value": 1 }, "0x0601": { "attribute_name": "ac_voltage_divisor", "value": 0 }, "0x0600": { "attribute_name": "ac_voltage_multiplier", "value": 0 }, "0x050b": { "attribute_name": "active_power", "value": 0 }, "0x050f": { "attribute_name": "apparent_power", "value": 0 }, "0x0000": { "attribute_name": "measurement_type", "value": 1 }, "0x0508": { "attribute_name": "rms_current", "value": 0 }, "0x0505": { "attribute_name": "rms_voltage", "value": 0 } }, "unsupported_attributes": { "0x0300": { "attribute_name": "ac_frequency" }, "0x0401": { "attribute_name": "ac_frequency_divisor" }, "0x0302": { "attribute_name": "ac_frequency_max" }, "0x0400": { "attribute_name": "ac_frequency_multiplier" }, "0x0403": { "attribute_name": "power_divisor" }, "0x0402": { "attribute_name": "power_multiplier" }, "0x0507": { "attribute_name": "rms_voltage_max" }, "0x050a": { "attribute_name": "rms_current_max" }, "0x050d": { "attribute_name": "active_power_max" }, "0x0510": { "attribute_name": "power_factor" } } }, "0xfc01": { "endpoint_attribute": "legrand_contactor_mode", "attributes": { "0x0000": { "attribute_name": "device_mode", "value": [ 3, 0 ] } }, "unsupported_attributes": {} }, "0xfc41": { "endpoint_attribute": "legrand_contactor_auto_on_off", "attributes": {}, "unsupported_attributes": {} } }, "out_clusters": { "0x0006": { "endpoint_attribute": "on_off", "attributes": {}, "unsupported_attributes": {} }, "0x0000": { "endpoint_attribute": "basic", "attributes": {}, "unsupported_attributes": {} }, "0x0005": { "endpoint_attribute": "scenes", "attributes": {}, "unsupported_attributes": {} }, "0x0019": { "endpoint_attribute": "ota", "attributes": { "0xfffd": { "attribute_name": "cluster_revision", "value": 1 }, "0x0002": { "attribute_name": "current_file_version", "value": 5916159 } }, "unsupported_attributes": { "0x0001": { "attribute_name": "file_offset" }, "0x0002": { "attribute_name": "current_file_version" }, "0x0004": { "attribute_name": "downloaded_file_version" }, "0x0005": { "attribute_name": "downloaded_zigbee_stack_version" }, "0xfffe": { "attribute_name": "reporting_status" } } }, "0xfc01": { "endpoint_attribute": "legrand_contactor_mode", "attributes": {}, "unsupported_attributes": {} } } }, "242": { "device_type": { "name": "COMBO_BASIC", "id": 102 }, "profile_id": 41440, "in_clusters": { "0x0021": { "endpoint_attribute": "green_power", "attributes": {}, "unsupported_attributes": {} } }, "out_clusters": { "0x0021": { "endpoint_attribute": "green_power", "attributes": {}, "unsupported_attributes": {} } } } } } } ```

Logs

Logs N/A

Custom quirk

Custom quirk ```python """Module for Legrand Contactor, Drivia with Netatmo 20AX - 230V~ - 50Hz PN: 412171 - 412191 - 199122 Documentation (en): https://assets.legrand.com/pim/NP-FT-GT/F03037EN-05%20(Connected%20contactor).pdf Documentation (fr): https://assets.legrand.com/pim/NP-FT-GT/F03037FR-05%20(Contacteur%20Connect%C3%A9).pdf Tested with PN: 199122 FW version: 0x005a45ff signature: { "node_descriptor": { "logical_type": 1, "complex_descriptor_available": 0, "user_descriptor_available": 1, "reserved": 0, "aps_flags": 0, "frequency_band": 8, "mac_capability_flags": 142, "manufacturer_code": 4129, "maximum_buffer_size": 89, "maximum_incoming_transfer_size": 63, "server_mask": 11264, "maximum_outgoing_transfer_size": 63, "descriptor_capability_field": 0 }, "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x010a", "input_clusters": [ "0x0000", "0x0003", "0x0004", "0x0005", "0x0006", "0x000f", "0x0b04", "0xfc01", "0xfc41" ], "output_clusters": [ "0x0000", "0x0005", "0x0006", "0x0019", "0xfc01" ] }, "242": { "profile_id": "0xa1e0", "device_type": "0x0066", "input_clusters": [ "0x0021" ], "output_clusters": [ "0x0021" ] } }, "manufacturer": " Legrand", "model": " Contactor", "class": "contactor.LegrandContactor" } """ import logging from zigpy.profiles import zgp, zha from zigpy.quirks import CustomCluster, CustomDevice import zigpy.types as t from zigpy.zcl.clusters.general import ( Basic, BinaryInput, GreenPowerProxy, Groups, Identify, OnOff, Ota, Scenes, ) from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement from zigpy.zcl.foundation import ( BaseCommandDefs, BaseAttributeDefs, ZCLAttributeDef, ZCLCommandDef, ) from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, INPUT_CLUSTERS, MODELS_INFO, OUTPUT_CLUSTERS, PROFILE_ID, ) from zhaquirks import Bus from zhaquirks.legrand import LEGRAND, MANUFACTURER_SPECIFIC_CLUSTER_ID # decimal = 64513 _LOGGER = logging.getLogger(__name__) _LOGGER.setLevel(logging.DEBUG) MANUFACTURER_SPECIFIC_CLUSTER_ID_2 = 0xFC41 # decimal = 64577 class DeviceMode(t.enum16): """Device mode.""" MODE_SWITCH = 0x0300 MODE_AUTO = 0x0400 class LegrandContactorMode(CustomCluster): """Legrand Mode cluster. ZHA Toolkit scan result: cluster_id: "0xfc01" # decimal = 64513 title: LegrandContactorMode name: legrand_contactor_mode attributes: "0x0000": attribute_id: "0x0000" attribute_name: device_mode value_type: - "0x09" - data16 - Discrete access: READ|WRITE access_acl: 3 manf_id: 4129 attribute_value: - 4 - 0 "0x0001": attribute_id: "0x0001" attribute_name: led_dark value_type: - "0x10" - Bool - Discrete access: READ|WRITE access_acl: 3 manf_id: 4129 attribute_value: 0 "0x0002": attribute_id: "0x0002" attribute_name: led_on value_type: - "0x10" - Bool - Discrete access: READ|WRITE access_acl: 3 manf_id: 4129 attribute_value: 0 commands_received: "0x03": # NOTE: no action identified. One mandatory parameter: arg[0]=2 command_id: "0x03" command_name: "3" command_arguments: not_in_zcl "0x0e": # NOTE: no action identified. Accept any parameter. command_id: "0x0e" command_name: "14" command_arguments: not_in_zcl "0x11": # NOTE: no action identified. Accept any parameter. command_id: "0x11" command_name: "17" command_arguments: not_in_zcl "0x14": # NOTE: acts as a pairing reset. No parameter needed. command_id: "0x14" command_name: "20" command_arguments: not_in_zcl commands_generated: "0x04": command_id: "0x04" command_name: "4" command_args: not_in_zcl "0x0c": command_id: "0x0c" command_name: "12" command_args: not_in_zcl "0x10": command_id: "0x10" command_name: "16" command_args: not_in_zcl """ cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID name = "LegrandContactorMode" ep_attribute = "legrand_contactor_mode" CONTACTOR_IS_SWITCH_REPORTED = "contactor_is_switch_reported" MODE_ID = 0x0000 DEVICE_MODES = [DeviceMode.MODE_SWITCH, DeviceMode.MODE_AUTO] class AttributeDefs(BaseAttributeDefs): """Attribute definitions.""" device_mode = ZCLAttributeDef( id=0x0000, type=t.data16, # DeviceMode is_manufacturer_specific=True, ) led_dark = ZCLAttributeDef( id=0x0001, type=t.Bool, # LedDarkSwitch is_manufacturer_specific=True, ) led_on = ZCLAttributeDef( id=0x0002, type=t.Bool, # LedOnSwitch is_manufacturer_specific=True, ) def _update_attribute(self, attrid, value): """Attribute update.""" _LOGGER.debug(f'LegrandContactorMode._update_attribute: attrid={attrid}, value={value}') super()._update_attribute(attrid, value) if attrid == self.MODE_ID and value is not None: device_mode = (int(value[0]) << 8) + int(value[1]) if device_mode in self.DEVICE_MODES: self.endpoint.device.mode_bus.listener_event(self.CONTACTOR_IS_SWITCH_REPORTED, device_mode == DeviceMode.MODE_SWITCH) class AutoStatus(t.enum8): """Auto mode status values.""" ForcedOff = 0x00 ForcedOn = 0x01 AutoOff = 0x02 AutoOn = 0x03 class AutoOverride(t.enum8): """Auto override arguments values.""" ForceOff = 0x00 ForceOn = 0x01 Automatic = 0x02 # no override class LegrandContactorAutoOnOff(CustomCluster): """Legrand Auto OnOff cluster. ZHA Toolkit scan result: cluster_id: "0xfc41" # decimal = 64577 title: LegrandContactorAutoOnOff name: legrand_contactor_auto_on_off attributes: "0x0000": attribute_id: "0x0000" attribute_name: status value_type: - "0x30" - enum8 - Discrete access: READ|REPORT access_acl: 5 manf_id: 4129 attribute_value: 3 "0x0001": attribute_id: "0x0001" attribute_name: on_off value_type: - "0x10" - Bool - Discrete access: READ|REPORT access_acl: 5 manf_id: 4129 attribute_value: 1 "0xfffd": attribute_id: "0xfffd" attribute_name: "65533" value_type: - "0x21" - uint16_t - Analog access: READ access_acl: 1 manf_id: 4129 attribute_value: 1 commands_received: "0x00": command_id: "0x00" command_name: override command_arguments: commands_generated: "0x0a": command_id: "0x0a" command_name: "10" """ cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID_2 name = "LegrandContactorAutoOnOff" ep_attribute = "legrand_contactor_auto_on_off" AUTO_ON_OFF_REPORTED = "auto_on_off_reported" AUTO_STATUS_REPORTED = "auto_status_reported" STATUS_ID = 0 ON_OFF_ID = 1 OVERRIDE_CMD_ID = 0x00 class AttributeDefs(BaseAttributeDefs): """Attribute definitions.""" status = ZCLAttributeDef( id=0x0000, type=AutoStatus, #t.enum8 is_manufacturer_specific=True, ) on_off = ZCLAttributeDef( id=0x0001, type=t.Bool, # on_off in AUTO mode is_manufacturer_specific=True, ) class ServerCommandDefs(BaseCommandDefs): """Server Command Definitions.""" override = ZCLCommandDef( id=0x00, schema={ "mode": AutoOverride, }, is_manufacturer_specific=True, ) def _update_attribute(self, attrid, value): """Attribute update.""" _LOGGER.debug(f'LegrandContactorAutoOnOff._update_attribute: attrid={attrid}, value={value}') super()._update_attribute(attrid, value) if attrid == self.ON_OFF_ID and value is not None: self.endpoint.device.auto_status_bus.listener_event(self.AUTO_ON_OFF_REPORTED, value) if attrid == self.STATUS_ID and value is not None: self.endpoint.device.auto_status_bus.listener_event(self.AUTO_STATUS_REPORTED, value) class LegrandContactorSwitchOnOff(CustomCluster, OnOff): """Legrand Switch OnOff cluster When the device is in Switch mode, it operates normally the OnOff cluster. However, when the device is in Auto mode, on, off and toggle OnOff custer's commands are not supported. This class redirects them to the AutoOnOff cluster (id: 0xfc41). Similarly, it redirects on_off attribute reads. NOTE: The name and ep_attribute class attributes are NOT changed to benefit of generic OnOff cluster's entities creation: - switch - startup behavior select """ cluster_id = OnOff.cluster_id ON_OFF_ID = 0x0000 OFF_CMD_ID = 0x00 ON_CMD_ID = 0x01 TOGGLE_CMD_ID = 0x02 TOGGLE_MAP = {AutoStatus.ForcedOn: AutoOverride.ForceOff, AutoStatus.ForcedOff: AutoOverride.ForceOff} def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.mode_bus.add_listener(self) self._contactor_is_switch = None self._auto_on_off = None self._auto_status = None def contactor_is_switch_reported(self, value): """Contactor is switch reported.""" _LOGGER.debug(f'LegrandContactorSwitchOnOff.contactor_is_switch_reported: value={value}') self._contactor_is_switch = value def auto_on_off_reported(self, value): """Auto mode on_off status reported.""" _LOGGER.debug(f'LegrandContactorSwitchOnOff.on_off_reported: value={value}') if not self._contactor_mode_is_switch: self._auto_on_off = value self._update_attribute(self.ON_OFF_ID, value) def auto_status_reported(self, value): """Auto status reported.""" _LOGGER.debug(f'LegrandContactorSwitchOnOff.auto_status_reported: value={value}') self._auto_status = value async def read_attributes(self, attr_ids, *args, **kwargs): """Read attributes. Redirects on_off attribute reads to the AutoOnOff cluster (id: 0xfc41) when the device is in Auto mode.""" _LOGGER.debug('LegrandContactorSwitchOnOff.read_attributes') if not self.ON_OFF_ID in attr_ids: _LOGGER.debug('LegrandContactorSwitchOnOff.read_attributes not reading on_off attribute.') return await super(OnOff, self).read_attributes(attr_ids, *args, **kwargs) await self._update_contactor_mode() if self._contactor_is_switch: _LOGGER.debug('LegrandContactorSwitchOnOff.read_attributes in switch mode.') return await super(OnOff, self).read_attributes(attr_ids, *args, **kwargs) attr_ids_1 = [ attr_id for attr_id in attr_ids if self.ON_OFF_ID != attr_id ] if not len(attr_ids_1): _LOGGER.debug('LegrandContactorSwitchOnOff._read_attributes reading only on_off attribute.') return await self._read_on_off_state(*args, **kwargs) results_1 = await super(OnOff, self).read_attributes(attr_ids_1, *args, **kwargs) results_2 = await self._read_on_off_state(*args, **kwargs) # Combine results return self.combine_results(results_1, results_2) @staticmethod def combine_results(*result_lists): """Combine results from 1 or more result lists from zigbee commands.""" success_global = [] failure_global = [] for result in result_lists: if len(result) == 1: success_global.extend(result[0]) elif len(result) == 2: success_global.extend(result[0]) failure_global.extend(result[1]) if failure_global: return [success_global, failure_global] else: return [success_global] async def _update_contactor_mode(self): """Update contactor mode for _contactor_is_switch.""" _LOGGER.debug('LegrandContactorSwitchOnOff._update_contactor_mode.') await self._read_contactor_mode() async def _update_auto_status(self): """Update auto status for _auto_status.""" _LOGGER.debug('LegrandContactorSwitchOnOff._update_status.') await self._read_auto_status() async def _read_on_off_state(self, *args, **kwargs): """Read OnOff state.""" _LOGGER.debug('LegrandContactorSwitchOnOff._read_on_off_state') if not self._contactor_is_switch: _LOGGER.debug('LegrandContactorSwitchOnOff._read_on_off_state in auto mode') auto_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorAutoOnOff.cluster_id] attr_ids = [LegrandContactorAutoOnOff.ON_OFF_ID] result = await auto_cluster.read_attributes(attr_ids, *args, **kwargs) # change the attribute ID in the result/failure if result[0]: result[0][self.ON_OFF_ID] = result[0].pop(LegrandContactorAutoOnOff.ON_OFF_ID) elif result[1]: result[1][self.ON_OFF_ID] = result[1].pop(LegrandContactorAutoOnOff.ON_OFF_ID) _LOGGER.debug(f'LegrandContactorSwitchOnOff._read_on_off_state: result={result}') return result _LOGGER.debug('LegrandContactorSwitchOnOff._read_on_off_state in switch mode') attr_ids = [self.ON_OFF_ID] result = await super(OnOff, self).read_attributes(attr_ids, *args, **kwargs) _LOGGER.debug(f'LegrandContactorSwitchOnOff._read_on_off_state: result={result}') return result async def _read_auto_status(self, *args, **kwargs): """Read Auto Status.""" _LOGGER.debug('LegrandContactorSwitchOnOff._read_auto_status') auto_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorAutoOnOff.cluster_id] attr_ids = [LegrandContactorAutoOnOff.ON_OFF_ID] await auto_cluster.read_attributes(attr_ids, allow_cache=False) async def _read_contactor_mode(self): """Read Contactor mode.""" _LOGGER.debug('LegrandContactorSwitchOnOff._read_contactor_mode') mode_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorMode.cluster_id] attr_ids = [LegrandContactorMode.MODE_ID] result = await mode_cluster.read_attributes(attr_ids, allow_cache=False) _LOGGER.error(f'LegrandContactorSwitchOnOff._read_contactor_mode done: result={result}') async def command(self, command_id, *args, manufacturer=None, expect_reply=True, tsn=None): """ Legrand switch OnOff command: Redirects on, off and toggle commands to AutoOnOff cluster (id: 0xfc41) when the device is Auto mode. The on and off commands FORCE the corresponding states. The toggle command is only operationnal if the AutoOnOff cluster is in a FORCED state. It toggles the ForcedOff (resp. ForcedOn) state to the FocedOn (resp. ForcedOff) state. When the device is in Switch mode, operates as a normal OnOff cluster. FIXME: UI representation of the Automatic operation should exist. FIXME: something should execute the LegrandContactorAutoOnOff.override(AutoOverride.Automatic) to restore automatic operation after switch UI use. """ _LOGGER.debug('LegrandContactorSwitchOnOff.command') if command_id not in [self.ON_CMD_ID, self.OFF_CMD_ID, self.TOGGLE_CMD_ID]: _LOGGER.debug('LegrandContactorSwitchOnOff.command is neither on, off nor toggle.') return await super(OnOff, self).command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) await self._update_contactor_mode() if self._contactor_is_switch: _LOGGER.debug('LegrandContactorSwitchOnOff.command in switch mode.') return await super(OnOff, self).command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) if command_id == self.ON_CMD_ID: return await self._auto_override_command(AutoOverride.ForceOn, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) if command_id == self.OFF_CMD_ID: return await self._auto_override_command(AutoOverride.ForceOff, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) if command_id == self.TOGGLE_CMD_ID: await self._update_auto_status() if self._auto_status in self.TOGGLE_MAP: override_mode = self.TOGGLE_MAP[status] return await self._auto_override_command(override_mode, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) #FIXME: return UNSUPPORTED_COMMAND or Success #NOTE: actually, in auto mode, the server returns UNSUPPORTED_COMMAND so fall-through for now. return await super(OnOff, self).command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) async def _auto_override_command(self, override_mode, manufacturer=None, expect_reply=True, tsn=None): """Read Contactor mode.""" _LOGGER.debug('LegrandContactorSwitchOnOff._auto_override_command') auto_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorAutoOnOff.cluster_id] return await auto_cluster.command(LegrandContactorAutoOnOff.OVERRIDE_CMD_ID, override_mode, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) class LegrandContactor(CustomDevice): """Legrand Contactor device. The device offers two modes of operation: - the, factory default, Auto mode where an external input can control the output. - and a Switch mode where nomal operations of a controlled switch are supported. When the device is in Auto mode, its operation button selects three modes of operation: - a ForcedOff mode where the switch output is opened, - an Automatic mode where the switch output is controlled by the external output, - and a ForcedOn mode where the switch output is closed. When the device is in Switch mode, its operation button selects toggles the OnOff modes of operation: - the Off mode where the switch output is opened, - and the On mode where the switch output is closed. Two leds reflect the states ot the device. The LED on the operation button reflects the device's state. It is: - OFF when the device is (forced) OFF - slow dark blinking with OFF/BLUE colors when the device is OFF, in Auto mode with external input not active, - slow bright blinking with BLUE/GREEN colors when the device is ON, in Auto mode with external input active, - and ON with e bright GREEN color when the device is (forced) ON. The LED on the reset button reflects the association states of the device. It is: - RED when the device is not paired, - GREEN when it is in paired , while the network is still open (controller still searching devices) - OFF when the device is paired. - PURPLE when the device pairing failed (is it on timed out? not documented...) The reset button controls the pairing. 1- A long press (approx. 10s) on the reset button resets the device's pairing and is reflected by a RED led. 2- When the device pairing is reset, another long press on the reset button followed by some (a few) short presses (approx 1 every 1s) starts, and maintains, the pairing process. 3- Success of the pairing is reflected by a GREEN led. If the pairing fails, retry at step 1. 4- the led turns OFF when the controller stops the pairing process. It instanciates, in replacement, three custom clusters classes: - The LegrandContactorMode cluster (id: 0xfc01) controls the operation mode of the device. - The LegrandContactorAutoOnOff cluster (id: 0xfc41) controls the device in Auto mode. - The LegrandContactorSwitchOnOff cluster (id: OnOff cluster id) controls the device in Switch mode and acts as a proxy to LegrandContactorAutoOnOff in Auto mode. """ signature = { MODELS_INFO: [(f" {LEGRAND}", " Contactor")], ENDPOINTS: { # 1: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_PLUG_IN_UNIT, INPUT_CLUSTERS: [ Basic.cluster_id, Identify.cluster_id, Groups.cluster_id, Scenes.cluster_id, LegrandContactorSwitchOnOff.cluster_id, BinaryInput.cluster_id, ElectricalMeasurement.cluster_id, LegrandContactorMode.cluster_id, LegrandContactorAutoOnOff.cluster_id, ], OUTPUT_CLUSTERS: [ Basic.cluster_id, Scenes.cluster_id, OnOff.cluster_id, Ota.cluster_id, LegrandContactorMode.cluster_id, ], }, # 242: { PROFILE_ID: zgp.PROFILE_ID, DEVICE_TYPE: zgp.DeviceType.COMBO_BASIC, INPUT_CLUSTERS: [GreenPowerProxy.cluster_id], OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], }, }, } replacement = { ENDPOINTS: { 1: { PROFILE_ID: zha.PROFILE_ID, DEVICE_TYPE: zha.DeviceType.ON_OFF_PLUG_IN_UNIT, INPUT_CLUSTERS: [ Basic.cluster_id, Identify.cluster_id, Groups.cluster_id, Scenes.cluster_id, LegrandContactorSwitchOnOff, BinaryInput.cluster_id, ElectricalMeasurement.cluster_id, LegrandContactorMode, LegrandContactorAutoOnOff, ], OUTPUT_CLUSTERS: [ LegrandContactorSwitchOnOff, Basic.cluster_id, Scenes.cluster_id, Ota.cluster_id, LegrandContactorMode, ], }, 242: { PROFILE_ID: zgp.PROFILE_ID, DEVICE_TYPE: zgp.DeviceType.COMBO_BASIC, INPUT_CLUSTERS: [GreenPowerProxy.cluster_id], OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], }, }, } def __init__(self, *args, **kwargs): """Init.""" self.mode_bus = Bus() self.auto_status_bus = Bus() super().__init__(*args, **kwargs) ```

Additional information

Please refer to custom quirk inline documentation.

Edited: addition of mockup information

With the given mockup (which is sufficient when there's only one contactor in operation) the Reset to Auto icon is the outline variant of the mdi:toggle-switch/mdi:toggle-switch-off when the states are Forced.

Automations ```yaml alias: Legrand contactor refresh states description: '' trigger: - platform: homeassistant event: start - platform: state entity_id: switch.legrand_contactor_commutateur from: - "unknown" - "unavailable" - "on" - "off" - platform: time_pattern hours: '*' minutes: /1 seconds: '0' - platform: event event_type: legrand_contactor_refresh condition: [] action: - action: script.legrand_contactor_refresh ```
Scripts ```yaml - legrand_contactor_refresh: sequence: - action: zha_toolkit.execute data: command: attr_read ieee: sensor.legrand_contactor_courant cluster: 0xfc01 attribute: 0x000 state_id: sensor.legrand_contactor_mode allow_create: false - action: zha_toolkit.execute data: command: attr_read ieee: sensor.legrand_contactor_courant cluster: 0xfc41 attribute: 0x0000 state_id: sensor.legrand_contactor_auto_status allow_create: false - action: zha_toolkit.execute data: command: attr_read ieee: sensor.legrand_contactor_courant cluster: 0x0006 attribute: 0x0000 state_id: sensor.legrand_contactor_switch_status allow_create: false #AAU: ValueError: Could not get hass from
Templates ```yaml - sensor: - name: Legrand contactor mode # cluster: 0xFC01, attr: 0x0000 unique_id: legrand_contactor_mode device_class: enum state: unavailable - name: Legrand contactor auto status # cluster: 0xFC41, attr: 0x0000 unique_id: legrand_contactor_auto_status device_class: enum state: unavailable - name: Legrand contactor switch status # cluster: 0x0006, attr: 0x0000 unique_id: legrand_contactor_switch_status device_class: enum state: unavailable - select: - name: Legrand Contactor Mode Selector unique_id: legrand_contactor_mode_selector icon: mdi:format-list-bulleted #mdi:auto-renew options: "{{ ['Auto', 'Switch'] }}" state: > {% if (states(sensor.legrand_contactor_mode) == '[3, 0]') %} Switch {% else %} Auto {% endif %} select_option: - sequence: - action: zha_toolkit.attr_write data: ieee: sensor.legrand_contactor_courant endpoint: 1 cluster: 0xfc01 attribute: 0x0000 write_if_equal: true attr_val: > {% if (option == 'Switch') %} [3, 0] {% else %} [4, 0] {% endif %} #AAU: ValueError: Could not get hass from {% set mode = states('sensor.legrand_contactor_mode') %} {% if (mode == '[3, 0]') %} {% set state = states('sensor.legrand_contactor_switch_status') %} {% if (state == 'Bool.false') %} mdi:toggle-switch-off-outline {% elif (state == 'Bool.true') %} mdi:toggle-switch-outline {% else %} mdi:help-rhombus-outline {% endif %} {% elif (mode == '[4, 0]') %} {% set state = states('sensor.legrand_contactor_auto_status') %} {% if (state == 'AutoStatus.ForcedOff') %} mdi:toggle-switch-off-outline {% elif (state == 'AutoStatus.ForcedOn') %} mdi:toggle-switch-outline {% elif (state == 'AutoStatus.AutoOff') %} mdi:toggle-switch-off {% elif (state == 'AutoStatus.AutoOn') %} mdi:toggle-switch {% else %} mdi:help-rhombus-outline {% endif %} {% else %} mdi:help-rhombus-outline {% endif %} press: - sequence: - action: zha_toolkit.zcl_cmd data: ieee: sensor.legrand_contactor_courant endpoint: 1 cluster: 0xfc41 cmd: 0x0000 args: [ 2 ] #AAU: ValueError: Could not get hass from

PS: my apologies for the remaining french here and there.

aauzi commented 1 week ago

Hi,

after more digging in the resources available, mostly around Quirks V2. I came up with a refined custom quirk.

Custom quirk

Custom quirk ```python """Module for Legrand Contactor, Drivia with Netatmo 20AX - 230V~ - 50Hz PN: 412171 - 412191 - 199122 Documentation (en): https://assets.legrand.com/pim/NP-FT-GT/F03037EN-05%20(Connected%20contactor).pdf Documentation (fr): https://assets.legrand.com/pim/NP-FT-GT/F03037FR-05%20(Contacteur%20Connect%C3%A9).pdf Tested with PN: 199122 FW version: 0x005a45ff signature: { "node_descriptor": { "logical_type": 1, "complex_descriptor_available": 0, "user_descriptor_available": 1, "reserved": 0, "aps_flags": 0, "frequency_band": 8, "mac_capability_flags": 142, "manufacturer_code": 4129, "maximum_buffer_size": 89, "maximum_incoming_transfer_size": 63, "server_mask": 11264, "maximum_outgoing_transfer_size": 63, "descriptor_capability_field": 0 }, "endpoints": { "1": { "profile_id": "0x0104", "device_type": "0x010a", "input_clusters": [ "0x0000", "0x0003", "0x0004", "0x0005", "0x0006", "0x000f", "0x0b04", "0xfc01", "0xfc41" ], "output_clusters": [ "0x0000", "0x0005", "0x0006", "0x0019", "0xfc01" ] }, "242": { "profile_id": "0xa1e0", "device_type": "0x0066", "input_clusters": [ "0x0021" ], "output_clusters": [ "0x0021" ] } }, "manufacturer": " Legrand", "model": " Contactor", "class": "contactor.LegrandContactor" } """ import logging from enum import Enum from zigpy.profiles import zgp, zha from zigpy.quirks.v2 import QuirkBuilder, CustomDeviceV2, EntityType, EntityPlatform from zigpy.quirks import CustomCluster import zigpy.types as t from zigpy.zcl import ClusterType from zigpy.zcl.clusters.general import ( OnOff, ) # from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement from zigpy.zcl.foundation import ( BaseAttributeDefs, BaseCommandDefs, Status, ZCLAttributeDef, ZCLCommandDef, ) from zhaquirks import Bus from zhaquirks.legrand import LEGRAND, MANUFACTURER_SPECIFIC_CLUSTER_ID # decimal = 64513 _LOGGER = logging.getLogger(__name__) MANUFACTURER_SPECIFIC_CLUSTER_ID_2 = 0xFC41 # decimal = 64577 class DeviceMode(t.enum16): """Device mode.""" MODE_SWITCH = 30 MODE_AUTO = 40 class LegrandMode(Enum): Switch = [3, 0] Auto = [4, 0] class LegrandContactorMode(CustomCluster): """Legrand Mode cluster. ZHA Toolkit scan result: cluster_id: "0xfc01" # decimal = 64513 title: LegrandContactorMode name: legrand_contactor_mode attributes: "0x0000": attribute_id: "0x0000" attribute_name: mode value_type: - "0x09" - data16 - Discrete access: READ|WRITE access_acl: 3 manf_id: 4129 attribute_value: - 4 - 0 "0x0001": attribute_id: "0x0001" attribute_name: led_dark value_type: - "0x10" - Bool - Discrete access: READ|WRITE access_acl: 3 manf_id: 4129 attribute_value: 0 "0x0002": attribute_id: "0x0002" attribute_name: led_on value_type: - "0x10" - Bool - Discrete access: READ|WRITE access_acl: 3 manf_id: 4129 attribute_value: 0 commands_received: "0x03": # NOTE: no action identified. One mandatory parameter: arg[0]=2 command_id: "0x03" command_name: "3" command_arguments: not_in_zcl "0x0e": # NOTE: no action identified. Accept any parameter. command_id: "0x0e" command_name: "14" command_arguments: not_in_zcl "0x11": # NOTE: no action identified. Accept any parameter. command_id: "0x11" command_name: "17" command_arguments: not_in_zcl "0x14": # NOTE: acts as a pairing reset. No parameter needed. command_id: "0x14" command_name: "20" command_arguments: not_in_zcl commands_generated: "0x04": command_id: "0x04" command_name: "4" command_args: not_in_zcl "0x0c": command_id: "0x0c" command_name: "12" command_args: not_in_zcl "0x10": command_id: "0x10" command_name: "16" command_args: not_in_zcl """ cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID name = "LegrandContactorMode" ep_attribute = "legrand_contactor_mode" CONTACTOR_IS_SWITCH_REPORTED = "contactor_is_switch_reported" MODE_ID = 0x0000 MODES = [DeviceMode.MODE_SWITCH, DeviceMode.MODE_AUTO] class AttributeDefs(BaseAttributeDefs): """Attribute definitions.""" mode = ZCLAttributeDef( id=0x0000, type=t.data16, # DeviceMode is_manufacturer_specific=True, ) led_dark = ZCLAttributeDef( id=0x0001, type=t.Bool, # LedDarkSwitch is_manufacturer_specific=True, ) led_on = ZCLAttributeDef( id=0x0002, type=t.Bool, # LedOnSwitch is_manufacturer_specific=True, ) async def write_attributes(self, attributes, manufacturer = None): """Write attributes""" new_attributes = attributes.copy() for k in new_attributes.keys(): if k in [0, 'mode']: v = new_attributes[k] if isinstance(v, LegrandMode): if v == LegrandMode.Switch: new_v = [3,0] elif v == LegrandMode.Auto: new_v = [4,0] new_attributes[k] = new_v return await super().write_attributes(new_attributes, manufacturer) def _update_attribute(self, attrid, value): """Attribute update.""" _LOGGER.debug(f'LegrandContactorMode._update_attribute: attrid={attrid}, value={value}') super()._update_attribute(attrid, value) if attrid == self.MODE_ID and value is not None: mode = (int(value[0])*10) + int(value[1]) if mode in self.MODES: self.endpoint.device.reporting_bus.listener_event(self.CONTACTOR_IS_SWITCH_REPORTED, mode == DeviceMode.MODE_SWITCH) async def _read_mode(self): """Read mode.""" result = await self.read_attributes([self.MODE_ID], allow_cache=False) if not result[0]: return None return result[0][self.MODE_ID] class AutoStatus(t.enum8): """Auto mode status values. NOTE: Oddly enough this status does not seem to reflect all the states. One may had expected the following states: Zigby Forced Off Manually Forced Off Auto Off Auto On Manually Forced On Zigby Forced On or: Forced Off Auto Off Auto On Forced On """ ForcedOff = 0x00 ForcedOn = 0x01 Auto = 0x02 ManualOn = 0x03 class AutoOverride(t.enum8): """Auto override arguments values.""" ForceOff = 0x00 ForceOn = 0x01 Automatic = 0x02 # no override class LegrandContactorAutoStatus(Enum): """Auto mode status values for UI display""" ForcedOff = 0x00 ForcedOn = 0x01 Auto = 0x02 ManualOn = 0x03 class LegrandContactorSwitchStatus(Enum): """Switch status values for UI display""" Off = 0x00 On = 0x01 class LegrandContactorAutoOnOff(CustomCluster): """Legrand Auto OnOff cluster. ZHA Toolkit scan result: cluster_id: "0xfc41" # decimal = 64577 title: LegrandContactorAutoOnOff name: legrand_contactor_auto_on_off attributes: "0x0000": attribute_id: "0x0000" attribute_name: status value_type: - "0x30" - enum8 - Discrete access: READ|REPORT access_acl: 5 manf_id: 4129 attribute_value: 3 "0x0001": attribute_id: "0x0001" attribute_name: on_off value_type: - "0x10" - Bool - Discrete access: READ|REPORT access_acl: 5 manf_id: 4129 attribute_value: 1 "0xfffd": attribute_id: "0xfffd" attribute_name: "65533" value_type: - "0x21" - uint16_t - Analog access: READ access_acl: 1 manf_id: 4129 attribute_value: 1 commands_received: "0x00": command_id: "0x00" command_name: override command_arguments: commands_generated: "0x0a": command_id: "0x0a" command_name: "10" """ cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID_2 name = "LegrandContactorAutoOnOff" ep_attribute = "legrand_contactor_auto_on_off" AUTO_ON_OFF_REPORTED = "auto_on_off_reported" STATUS_ID = 0 ON_OFF_ID = 1 OVERRIDE_CMD_ID = 0x00 TOGGLE_MAP = {AutoStatus.ForcedOn: AutoOverride.ForceOff, AutoStatus.ManualOn: AutoOverride.ForceOff, AutoStatus.ForcedOff: AutoOverride.ForceOff} class AttributeDefs(BaseAttributeDefs): """Attribute definitions.""" status = ZCLAttributeDef( id=0x0000, type=AutoStatus, #t.enum8 is_manufacturer_specific=True, ) on_off = ZCLAttributeDef( id=0x0001, type=t.Bool, # on_off in AUTO mode is_manufacturer_specific=True, ) class ServerCommandDefs(BaseCommandDefs): """Server Command Definitions.""" override = ZCLCommandDef( id=0x00, schema={ "mode": AutoOverride, }, is_manufacturer_specific=True, ) async def turn_off(self, manufacturer=None, expect_reply=False, tsn=None): """Force Off""" _LOGGER.debug('LegrandContactorAutoOnOff.turn_off') status = await self._read_status() if status == AutoStatus.ForcedOff: _LOGGER.debug('LegrandContactorAutoOnOff.toggle is in forcedOff state.') return (None, Status.SUCCESS) return await self.command(self.OVERRIDE_CMD_ID, AutoOverride.ForceOff, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) async def turn_on(self, manufacturer=None, expect_reply=False, tsn=None): """Force On""" _LOGGER.debug('LegrandContactorAutoOnOff.turn_on') status = await self._read_status() if status == AutoStatus.ForcedOn: _LOGGER.debug('LegrandContactorAutoOnOff.toggle is in forcedOn state.') return (None, Status.SUCCESS) return await self.command(self.OVERRIDE_CMD_ID, AutoOverride.ForceOn, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) async def toggle(self, manufacturer=None, expect_reply=False, tsn=None): """Toggle ForcedOff/ForcedOn """ _LOGGER.debug('LegrandContactorAutoOnOff.toggle') status = await self._read_status() if status not in self.TOGGLE_MAP: _LOGGER.debug('LegrandContactorAutoOnOff.toggle is not in forced state.') return (None, Status.SUCCESS) auto_override = self.TOGGLE_MAP[status] return await self.command(self.OVERRIDE_CMD_ID, auto_override, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) async def _read_status(self): """Read status""" result = await self.read_attributes([self.STATUS_ID], allow_cache=False) if not result[0]: return None return result[0][self.STATUS_ID] async def _read_states(self): """Read states""" result = await self.read_attributes([self.STATUS_ID, self.ON_OFF_ID], allow_cache=False) def _update_attribute(self, attrid, value): """Attribute update.""" _LOGGER.debug(f'LegrandContactorAutoOnOff._update_attribute: attrid={attrid}, value={value}') super()._update_attribute(attrid, value) if attrid == self.ON_OFF_ID and value is not None: self.endpoint.device.reporting_bus.listener_event(self.AUTO_ON_OFF_REPORTED, value) async def command(self, command_id, *args, manufacturer=None, expect_reply=True, tsn=None): """Command:""" _LOGGER.debug(f'LegrandContactorAutoOnOff.command: id={command_id}') result = await super().command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) await self._read_states() return result class LegrandContactorSwitchOnOff(CustomCluster, OnOff): """Legrand Switch OnOff cluster When the device is in Switch mode, it operates normally the OnOff cluster. However, when the device is in Auto mode, on, off and toggle OnOff custer's commands are not supported. This class redirects them to the AutoOnOff cluster (id: 0xfc41). Similarly, it redirects on_off attribute reads. NOTE: The name and ep_attribute class attributes are NOT changed to benefit of generic OnOff cluster's entities creation: - switch - startup behavior select """ cluster_id = OnOff.cluster_id ON_OFF_ID = 0x0000 OFF_CMD_ID = 0x00 ON_CMD_ID = 0x01 TOGGLE_CMD_ID = 0x02 def __init__(self, *args, **kwargs): """Init.""" super().__init__(*args, **kwargs) self.endpoint.device.reporting_bus.add_listener(self) self._contactor_is_switch = None def contactor_is_switch_reported(self, value): """Contactor is switch reported.""" _LOGGER.debug(f'LegrandContactorSwitchOnOff.contactor_is_switch_reported: value={value}') self._contactor_is_switch = value def auto_on_off_reported(self, value): """Auto mode on_off status reported.""" _LOGGER.debug(f'LegrandContactorSwitchOnOff.on_off_reported: value={value}') if self._contactor_is_switch: _LOGGER.debug(f'LegrandContactorSwitchOnOff.on_off_reported: update switch ignored in switch mode.') return super()._update_attribute(self.ON_OFF_ID, value) async def _read_attributes(self, attr_ids, *args, **kwargs): """Read attributes. Redirects on_off attribute reads to the AutoOnOff cluster (id: 0xfc41) when the device is in Auto mode.""" _LOGGER.debug('LegrandContactorSwitchOnOff._read_attributes') attr_ids = [self.ON_OFF_ID if attr_id == 'on_off' else attr_id for attr_id in attr_ids] if not self.ON_OFF_ID in attr_ids: _LOGGER.debug('LegrandContactorSwitchOnOff.read_attributes not reading on_off attribute.') return await super()._read_attributes(attr_ids, *args, **kwargs) await self._update_contactor_mode() if not self._contactor_is_switch: await self._update_auto_states() return await super()._read_attributes(attr_ids, *args, **kwargs) async def _update_contactor_mode(self): """Update contactor mode for _contactor_is_switch.""" _LOGGER.debug('LegrandContactorSwitchOnOff._update_contactor_mode.') mode_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorMode.cluster_id] await mode_cluster._read_mode() async def _update_auto_states(self): """Update contactor auto mode status.""" _LOGGER.debug('LegrandContactorSwitchOnOff._update_auto_states.') auto_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorAutoOnOff.cluster_id] await auto_cluster._read_states() async def command(self, command_id, *args, manufacturer=None, expect_reply=True, tsn=None): """ Legrand switch OnOff command: Redirects on, off and toggle commands to AutoOnOff cluster (id: 0xfc41) when the device is Auto mode. The on and off commands FORCE the corresponding states. The toggle command is only operationnal if the AutoOnOff cluster is in a FORCED state. It toggles the ForcedOff (resp. ForcedOn) state to the FocedOn (resp. ForcedOff) state. When the device is in Switch mode, operates as a normal OnOff cluster. FIXME: UI representation of the Automatic operation should exist. FIXME: something should execute the LegrandContactorAutoOnOff.override(AutoOverride.Automatic) to restore automatic operation after switch UI use. => done with quirks-v2 """ _LOGGER.debug('LegrandContactorSwitchOnOff.command') if command_id not in [self.ON_CMD_ID, self.OFF_CMD_ID, self.TOGGLE_CMD_ID]: _LOGGER.debug('LegrandContactorSwitchOnOff.command is neither on, off nor toggle.') return await super().command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) await self._update_contactor_mode() if self._contactor_is_switch: _LOGGER.debug('LegrandContactorSwitchOnOff.command in switch mode.') return await super().command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) auto_cluster = self.endpoint.device.endpoints[1].in_clusters[LegrandContactorAutoOnOff.cluster_id] if command_id == self.ON_CMD_ID: return await auto_cluster.turn_on(manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) if command_id == self.OFF_CMD_ID: return await auto_cluster.turn_off(manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) if command_id == self.TOGGLE_CMD_ID: return await auto_cluster.toggle(manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) return await super().command(command_id, *args, manufacturer=manufacturer, expect_reply=expect_reply, tsn=tsn) class LegrandContactorV2(CustomDeviceV2): """Legrand Contactor device. The device offers two modes of operation: - the, factory default, Auto mode where an external input can control the output. - and a Switch mode where nomal operations of a controlled switch are supported. When the device is in Auto mode, its operation button selects three modes of operation: - a ForcedOff mode where the switch output is opened, - an Automatic mode where the switch output is controlled by the external output, - and a ForcedOn mode where the switch output is closed. When the device is in Switch mode, its operation button selects toggles the OnOff modes of operation: - the Off mode where the switch output is opened, - and the On mode where the switch output is closed. Two leds reflect the states ot the device. The LED on the operation button reflects the device's state. It is: - OFF when the device is (forced) OFF - slow dark blinking with OFF/BLUE colors when the device is OFF, in Auto mode with external input not active, - slow bright blinking with BLUE/GREEN colors when the device is ON, in Auto mode with external input active, - and ON with e bright GREEN color when the device is (forced) ON. The LED on the reset button reflects the association states of the device. It is: - RED when the device is not paired, - GREEN when it is in paired , while the network is still open (controller still searching devices) - OFF when the device is paired. - PURPLE when the device pairing failed (is it on timed out? not documented...) The reset button controls the pairing. 1- A long press (approx. 10s) on the reset button resets the device's pairing and is reflected by a RED led. 2- When the device pairing is reset, another long press on the reset button followed by some (a few) short presses (approx 1 every 1s) starts, and maintains, the pairing process. 3- Success of the pairing is reflected by a GREEN led. If the pairing fails, retry at step 1. 4- the led turns OFF when the controller stops the pairing process. It instanciates, in replacement, three custom clusters classes: - The LegrandContactorMode cluster (id: 0xfc01) controls the operation mode of the device. - The LegrandContactorAutoOnOff cluster (id: 0xfc41) controls the device in Auto mode. - The LegrandContactorSwitchOnOff cluster (id: OnOff cluster id) controls the device in Switch mode and acts as a proxy to LegrandContactorAutoOnOff in Auto mode. """ def __init__(self, application, ieee, nwk, replaces, quirk_metadata): """Init.""" self.reporting_bus = Bus() super().__init__(application, ieee, nwk, replaces, quirk_metadata) ( QuirkBuilder(f" {LEGRAND}", " Contactor") .device_class(LegrandContactorV2) .replaces(LegrandContactorMode) .replaces(LegrandContactorAutoOnOff) .replaces(LegrandContactorSwitchOnOff) .enum("mode", LegrandMode, LegrandContactorMode.cluster_id, ClusterType.Server, 1, EntityType.CONFIG, EntityPlatform.SELECT, False, True, 'operating_mode') .command_button("override", LegrandContactorAutoOnOff.cluster_id, (AutoOverride.Automatic, ), None, ClusterType.Server, 1, EntityType.STANDARD, False, 'reset_auto') .enum("status", LegrandContactorAutoStatus, LegrandContactorAutoOnOff.cluster_id, ClusterType.Server, 1, EntityType.DIAGNOSTIC, EntityPlatform.SENSOR, False, True, 'auto_status') #.switch("led_dark", LegrandContactorMode.cluster_id, # ClusterType.Server, 1, False, None, 0, 1, EntityPlatform.SWITCH, False, True, 'led_dark') #.switch("led_on", LegrandContactorMode.cluster_id, # ClusterType.Server, 1, False, None, 0, 1, EntityPlatform.SWITCH, False, True, 'led_on') .add_to_registry() ) ````

Additional information

The Quirks V2 allowed to create the entities I was looking for. They are usable for automations.

translation_key text
operating_mode Mode
reset_auto Reset Auto
auto_status Auto Status

I only add a ZHA-toolkit automation that polls the manufacturer specific cluster for automatic use case.

Automations ```yaml - alias: refresh legrand contactor status description: '' trigger: - platform: time_pattern hours: '*' minutes: /1 seconds: '0' condition: [] action: - sequence: - alias: 'read mode' service: zha_toolkit.execute data: command: attr_read ieee: switch.legrand_contactor_commutateur cluster: 0xFC01 attribute: 0x0000 use_cache: false allow_create: false - alias: 'read auto status' service: zha_toolkit.execute data: command: attr_read ieee: switch.legrand_contactor_commutateur cluster: 0xFC41 attribute: 0x0000 use_cache: false allow_create: false - alias: 'read on_off' service: zha_toolkit.execute data: command: attr_read ieee: switch.legrand_contactor_commutateur cluster: 0xFC41 attribute: 0x0001 use_cache: false allow_create: false - alias: legrand contactor force off description: '' trigger: - platform: sun event: sunset offset: "-01:00:00" condition: [] action: - action: switch.turn_off entity_id: switch.legrand_contactor_commutateur - alias: legrand contactor reset auto description: '' trigger: - platform: sun event: sunrise offset: "+02:00:00" condition: [] action: - action: button.press entity_id: button.legrand_contactor_reset_auto ```