Repository: zigpy/zha-device-handlers
Pull request #3031 (2024-03-11 12:15:15)

Improve Legrand 064882 cable outlet heat mode
## Proposed change

Following https://github.com/zigpy/zha-device-handlers/pull/2807

Adds the device mode and the pilot mode with right types : 
- device mode  to choose between "Pilot wire" mode and "Switch" mode.
- pilot wire mode to choose the heat mode (device mode should be set to "Pilot wire" mode)

These controls/settings can be exposed to HA using this quirk. The select definition is not part of the PR
Important


> [!IMPORTANT]  
> These controls/settings are be exposed to HA with this PR. The select entities definition is not part of the PR but it should be a climate entity and it needs more work on the `zha` repository. See this PR for more information : https://github.com/zigpy/zha-device-handlers/pull/3364

However, you can still expose them using this custom quirks. See `zha` [documentation](https://www.home-assistant.io/integrations/zha/#configuration---yaml).

```python
"""Module for Legrand Cable Outlet with pilot wire functionality."""

from zigpy.quirks import CustomCluster
from zigpy.quirks.v2 import EntityType, QuirkBuilder
import zigpy.types as t
from zigpy.zcl.foundation import (
    BaseAttributeDefs,
    BaseCommandDefs,
    DataTypeId,
    ZCLAttributeDef,
    ZCLCommandDef,
)

from zhaquirks.legrand import LEGRAND, MANUFACTURER_SPECIFIC_CLUSTER_ID


class DeviceMode(t.enum16):
    """Device mode."""

    Switch = 0x0001
    PilotWire = 0x0002


class LegrandCluster(CustomCluster):
    """LegrandCluster."""

    cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID
    name = "LegrandCluster"
    ep_attribute = "legrand_cluster"

    class AttributeDefs(BaseAttributeDefs):
        """Attribute definitions for LegrandCluster."""

        device_mode = ZCLAttributeDef(
            id=0x0000,
            type=DeviceMode,
            zcl_type=DataTypeId.data16,
            is_manufacturer_specific=True,
        )
        led_dark = ZCLAttributeDef(
            id=0x0001,
            type=t.Bool,
            is_manufacturer_specific=True,
        )
        led_on = ZCLAttributeDef(
            id=0x0002,
            type=t.Bool,
            is_manufacturer_specific=True,
        )


class PilotWireMode(t.enum8):
    """Pilot Wire mode."""

    Comfort = 0x00
    ComfortMinus1 = 0x01
    ComfortMinus2 = 0x02
    Eco = 0x03
    FrostProtection = 0x04
    Off = 0x05


class LegrandCableOutletCluster(CustomCluster):
    """Legrand Cable Outlet manufacturer-specific cluster."""

    cluster_id = 0xFC40
    name = "Legrand Cable Outlet"
    ep_attribute = "legrand_cable_outlet_cluster"

    class AttributeDefs(BaseAttributeDefs):
        """Attribute definitions for LegrandCableOutletCluster."""

        pilot_wire_mode = ZCLAttributeDef(
            id=0x00,
            type=PilotWireMode,
            is_manufacturer_specific=True,
        )

    class ServerCommandDefs(BaseCommandDefs):
        """Server command definitions."""

        set_pilot_wire_mode = ZCLCommandDef(
            id=0x00,
            schema={"mode": PilotWireMode},
            is_manufacturer_specific=True,
        )

    async def write_attributes(self, attributes, manufacturer=None):
        """Write attributes to the cluster."""

        attrs = {}
        for attr, value in attributes.items():
            attr_def = self.find_attribute(attr)
            if attr_def == LegrandCableOutletCluster.AttributeDefs.pilot_wire_mode:
                await self.set_pilot_wire_mode(value, manufacturer=manufacturer)
                await super().read_attributes([attr], manufacturer=manufacturer)
            else:
                attrs[attr] = value
        return await super().write_attributes(attrs, manufacturer)


(
    QuirkBuilder(f" {LEGRAND}", " Cable outlet")
    .replaces(LegrandCluster)
    .replaces(LegrandCableOutletCluster)
    .enum(
        attribute_name=LegrandCluster.AttributeDefs.device_mode.name,
        cluster_id=LegrandCluster.cluster_id,
        enum_class=DeviceMode,
        translation_key="legrand_cable_outlet_device_mode",
        fallback_name="Device mode",
    )
    .enum(
        attribute_name=LegrandCableOutletCluster.AttributeDefs.pilot_wire_mode.name,
        cluster_id=LegrandCableOutletCluster.cluster_id,
        enum_class=PilotWireMode,
        translation_key="legrand_cable_outlet_pilot_wire_mode",
        fallback_name="Pilot Wire mode",
        entity_type=EntityType.STANDARD,
    )
    .add_to_registry()
)
```

## Additional information

## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [x] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [x] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3031?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: Patch coverage is `63.63636%` with `8 lines` in your changes are missing coverage. Please review.
> Project coverage is 87.77%. Comparing base [(`e4d0663`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/e4d0663aa4fb2f4bd0b41825f1942e3d29893375?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`dd6f739`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3031?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

| [Files](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [zhaquirks/legrand/cable\_outlet.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL2xlZ3JhbmQvY2FibGVfb3V0bGV0LnB5) | 63.63% | [8 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3031?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #3031      +/-   ##
==========================================
- Coverage   87.85%   87.77%   -0.09%     
==========================================
  Files         301      301              
  Lines        9217     9221       +4     
==========================================
- Hits         8098     8094       -4     
- Misses       1119     1127       +8     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3031?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

dmulcahey: After the cluster changes are cleaned up we should look at converting this to a v2 quirk instead
dmulcahey: > After the cluster changes are cleaned up we should look at converting this to a v2 quirk instead which would remove the need to open a PR in HA.

try this:
[legrand.patch](https://github.com/zigpy/zha-device-handlers/files/14559725/legrand.patch)


dmulcahey: > After the cluster changes are cleaned up we should look at converting this to a v2 quirk instead which would remove the need to open a PR in HA.

[legrand.patch](https://github.com/zigpy/zha-device-handlers/files/14559843/legrand.patch)


piitaya: @dmulcahey How does it work with `translation_key` ? Where the translations file should be located?
dmulcahey: > @dmulcahey How does it work with `translation_key` ? Where the translations file should be located?

Translations will default to the attribute name. Strings.json will still need a HA PR
piitaya: I updated to quirk v2.
I didn't add the `enum` to create the select entity because of multiple reason : 
1. `entity_type` seems to not work with `standard`. Also, why is it called `entity_type` instead of `entity_category`? Standard is not a category, the category should be set to `None` instead.
2. the ZCL select entity replace `_` by spaces in Home Assistant that make hard the usage of entity translations. IMO, it should convert the enum in lowercase and keep the `_`.
What do you think @dmulcahey?
dmulcahey: tests will pass after the Zigpy bump I think. 
TheJulianJES: > `entity_type` seems to not work with standard. Also, why is it called `entity_type` instead of `entity_category`? Standard is not a category, the category should be set to `None` instead

Yeah, some classes like `ZCLEnumSelectEntity` (in ZHA) override the entity category if it's `None` for now.
The main use case is to provide config entities via quirks v2. I think this would be the first case where we'd have a select entity that's not a config entity.
https://github.com/zigpy/zha-device-handlers/issues/3030 even mentions that this device could provide a climate entity in HA. This is not possible via quirks v2, but might be worth considering?
FYI, the metadata and entity type is matched a ZHA class [here](https://github.com/home-assistant/core/blob/4d77bec68157bf71775a0fb6f9dd9c0bc5b0fc43/homeassistant/components/zha/core/discovery.py#L113). 

> the ZCL select entity replace _ by spaces in Home Assistant that make hard the usage of entity translations. IMO, it should convert the enum in lowercase and keep the _.

Yeah, the enum states aren't translatable at the moment. There's this PR: https://github.com/home-assistant/core/pull/109309, but I see some issues with state restoration when coming from an old version (due to `_`/` `) for `ZHAEnumSelectEntity` (used for non-ZCL stuff like `IasWd`).
That would need to be addressed (and the test for quirks v2 to see if translation keys are provided would need to be expanded).
It's still something I plan on looking at/working on, but the enums (split between quirks and ZHA atm) would all stay the same at least. Like you mentioned, ZHA would just lower-case them for the translation keys.

> I also have an issue with tests

A helper for creating a quirk v2 device will be added.
piitaya: > https://github.com/zigpy/zha-device-handlers/issues/3030 even mentions that this device could provide a climate entity in HA. This is not possible via quirks v2, but might be worth considering?

Yes I was considering this too. I'm not sure my python level is enough for that but I can give a try 🙂

The idea is to map the 6 heat mode into 2 hvac mode and 5 presets :
- heat - splitted into 5 presets :
  - comfort preset
  - comfort-1 preset
  - comfort-2 preset
  - eco preset
  - away preset
- off
- 
I already did something similar in my home assistant qubino custom component : https://github.com/piitaya/home-assistant-qubino-wire-pilot

TheJulianJES: > Yes I was considering this too. [...] As I know, there is no standard attribute for the preset in the thermostat cluster right?

Correct, at least not one that's mapped to HA presets for the "generic thermostat implementation" in ZHA.
Some thermostats are implemented like this for presets: [homeassistant/components/zha/climate.py#L578-L667](https://github.com/home-assistant/core/blob/ff4e9eb31ecc1973c9130643be6befb46e56cd51/homeassistant/components/zha/climate.py#L578-L667) (and further below are some other examples).
The "comfort -1" and "comfort -2" presets might be an issue though?

(Also, other devices like the Aqara E1 thermostat currently just implements the preset mode (manual, auto, away) as a config select entity. Of course, it would be better if it were added to the climate entity though.)
luke7101: > > #3030 even mentions that this device could provide a climate entity in HA. This is not possible via quirks v2, but might be worth considering?
> 
> Yes I was considering this too. I'm not sure my python level is enough for that but I can give a try 🙂 As I know, there is no standard attribute for the preset in the thermostat cluster right?
> 
> The idea is to map the 6 heat mode into 2 hvac modes and 5 presets :
> 
> * heat - splitted into 5 presets :
>   
>   * comfort preset
>   * comfort-1 preset
>   * comfort-2 preset
>   * eco preset
>   * away preset
> * off
> 
> I already did something similar in my home assistant qubino custom component : https://github.com/piitaya/home-assistant-qubino-wire-pilot

Just my 2 cents :
pilot wire has 6 modes: comfort, comfort-1, comfort-2, eco, away and off (in my experience comfort-1 and -2 aren't used very often)
**The off order is a "soft off" ie the heater stops heating but it's still powered.**

Legrand 064882 cable outlet provides a "hard off" state, by toggling the switch in standard mode, that completely turns off the power (ie no power on the L wire)

It's a big advantage compared to the other devices I tried because, for example:
- you can completely switch off the power over summer to prevent your kids to turn the heater on unintentionally, convenient if the heater doesn't have a physical switch
- you can avoid electric smog when you don't absolutely need to heat (heaters have huge electric field >100V/m at 30 cm) still present in the soft off pilot wire state, and if you would like to turn it off automatically in the inter-season without toggling the circuit breaker once or twice a day !

What I'm trying to say is that you maybe should keep the 2 different off modes, mapping it for example like that;
 * heat - splitted into 4 (or 6 presets ?) :
   
   * comfort preset
   * _comfort-1 preset?_
   * _comfort-2 preset?_
   * eco preset
   * away preset
   * off preset (soft off)
 * off (hard off mode)

bemble: IMHO, it feels strange to have a climate entity for a device you can't actually set the temperature, just the mode.
On some heaters (like mine), you set up the comfort temp yourself, the solution can't be universal.
Also, that would give more intelligence than the device provides (you can't get the actual temperature for example, you don't know when it's heating or not unless you look at the consumption). That goes a little against HA mindset: stick to what the device provides. Otherwise, we should also add the total power consumption for the energy dashboard directly in the quirk.
A custom integration that handles that creates the climate for this particular device seems a better choice IMO.
luke7101: > IMHO, it feels strange to have a climate entity for a device you can't actually set the temperature, just the mode. On some heaters (like mine), you set up the comfort temp yourself, the solution can't be universal. Also, that would give more intelligence than the device provides (you can't get the actual temperature for example, you don't know when it's heating or not unless you look at the consumption). That goes a little against HA mindset: stick to what the device provides. Otherwise, we should also add the total power consumption for the energy dashboard directly in the quirk. A custom integration that handles that creates the climate for this particular device seems a better choice IMO.

My suggestion https://github.com/zigpy/zha-device-handlers/issues/3030 was inspired by the solution I'm actually using: a custom integration for the [Heatzy Pilote device](https://github.com/cyr-ius/hass-heatzy). It creates a climate entity (I agree it's weird without temperature setting!).
![Screenshot 2024-03-13 105247](https://github.com/zigpy/zha-device-handlers/assets/58976540/940578a2-f290-4152-8648-f87ef3f30802) ![Screenshot 2024-03-13 105258](https://github.com/zigpy/zha-device-handlers/assets/58976540/ab37fe6c-77e1-4a49-93fe-93678cd6599c)
![Screenshot 2024-03-13 105309](https://github.com/zigpy/zha-device-handlers/assets/58976540/1a2f27cf-a359-4a49-816a-bbcd1ee66212) ![Screenshot 2024-03-13 105318](https://github.com/zigpy/zha-device-handlers/assets/58976540/8e6a829a-54a6-4f99-a37c-68c5a8e51fc7)
I'm also using this convenient [Heatzy Pilote custom card](https://github.com/DanChaltiel/heatzy-pilote-card)
![Screenshot 2024-03-13 105855](https://github.com/zigpy/zha-device-handlers/assets/58976540/8f9092fb-180f-4f56-99cc-9e0dd38fbaaa)

It just have to be ergonomic for the end user I guess, regardless of the entity domain 😉

bemble: > It just have to be ergonomic for the end user I guess, regardless of the entity domain 😉

Having that useless big slider, a wrong information (`heating`, with this device we don't know when it's actually heating) and 2 clicks to do what the device is supposed to do (change a mode) is not what I can call ergonomic.
TBH, and as I said directly to @piitaya, if this entity would be a climate instead of a select, user should not use the climate card, just a tile card with the presets features, the climate card with this device is a non-sens to me.

luke7101: > Having that useless big slider, a wrong information (`heating`, with this device we don't know when it's actually heating) and 2 clicks to do what the device is supposed to do (change a mode) is not what I can call ergonomic. TBH, and as I said directly to @piitaya, if this entity would be a climate instead of a select, user should not use the climate card, just a tile card with the presets features, the climate card with this device is a non-sens to me.

I agree that a climate card is not designed for this case scenario, however we must be sure that giving the wrong class (ie not climate) to a device supposed to drive a heater wouldn't lead to disadvantages in the HA ecosystem, for example in the interactions with other integrations supposed to deal with climate entities (voice assistants, cards or custom cards, or custom integrations designed to work around climate management, etc...)

bemble: > I agree that a climate card is not designed for this case scenario, however we must be sure that giving the wrong class (ie not climate) to a device supposed to drive a heater wouldn't lead to disadvantages in the HA ecosystem, for example in the interactions with other integrations supposed to deal with climate entities (voice assistants, cards or custom cards, or custom integrations designed to work around climate management, etc...)

Yeah, talking with Paul I realized my only issue is finally the climate card and the more info dialog. If the auto-generated dashboard use a tile card in this situation instead of a climate, then I'm fine with it.
piitaya: I removed the thermostat cluster, the zha climate integration uses the manufacturer cluster.
luke7101: Hi, will this PR be merged some day, or is there a way to test its current progress ?
Thanks!
piitaya: I need to write tests in ha core but I don't have much time at the moment. I keep you updated 😊
luke7101: Great ! Thanks 😉
luke7101: Hi, any update on this one? Winter is coming ... 🥶
piitaya: @luke7101 Yeah exactly ! I stopped a bit since it was summer but I'm going to work on it because I'll need it too 😅
erbarraud: Hey guys, 

Any update on this ? Seems linked to the issue I'm trying to solve. [https://github.com/zigpy/zha-device-handlers/issues/3499](url)

Cheers
Erik


Guik: Hello,  
I also need this fix. 
Thanks for the great work on the project.
alepee: I'm also interested, and would be happy to help 🙁 
luke7101: Hello @piitaya, qu'est-ce que ça donne le développement ? J'ai vu des interactions et une citation ici https://github.com/zigpy/zha-device-handlers/issues/3553
piitaya: I'm busy right now so I don't have enough time for that 😅 I will try to take a look soon as I also need it for my personal usage 😅
luke7101: Hey, I saw some progress here! Where are we up to ? 😊
piitaya: Just missing some code to get the climate entity working in HA. But I'm busy with the next 2025.1 as I also work for the frontend. I hope I can have this for 2025.2 release 🙂
piitaya: I exposed the mode as a select and not a climate using zha thermostat because it was too complicated and I prefer to have a minimal working solution as many people will want to use the device with ZHA.
piitaya: I can also remove the `select` as discussed in this PR : https://github.com/zigpy/zha-device-handlers/pull/3364

I tried to create a PR to use it as a climate in ZHA but it seems to be to much work as the ZHA Climate is currently linked to the Thermostat Cluster.
luke7101: Hi! I saw some progress, is it working now ?
luke7101: Hi, is there a quirk somewhere to test ? Or we do have to wait until a reviewer takes a look ?
piitaya: The quirk is in the description on the PR. It will expose 2 `select`, not a `climate` entity.
TheJulianJES: Do note that **no** `select` entities are added with this PR. The attribute can be written using the "Manage Zigbee device" debug UI for now.

Patch:
diff --git a/zhaquirks/legrand/cable_outlet.py b/zhaquirks/legrand/cable_outlet.py
index be4b996..ffcc421 100644
--- a/zhaquirks/legrand/cable_outlet.py
+++ b/zhaquirks/legrand/cable_outlet.py
@@ -1,44 +1,26 @@
-"""Module for Legrand Cable Outlet (with pilot wire functionality)."""
+"""Module for Legrand Cable Outlet with pilot wire functionality."""
 
-from zigpy.profiles import zgp, zha
-from zigpy.quirks import CustomCluster, CustomDevice
+from typing import Any
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
 import zigpy.types as t
-from zigpy.zcl.clusters.general import (
-    Basic,
-    GreenPowerProxy,
-    Groups,
-    Identify,
-    OnOff,
-    Ota,
-    Scenes,
-)
-from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
 from zigpy.zcl.foundation import (
     BaseAttributeDefs,
     BaseCommandDefs,
-    Direction,
+    DataTypeId,
     ZCLAttributeDef,
     ZCLCommandDef,
 )
 
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-)
 from zhaquirks.legrand import LEGRAND, MANUFACTURER_SPECIFIC_CLUSTER_ID
 
-MANUFACTURER_SPECIFIC_CLUSTER_ID_2 = 0xFC40  # 64576
-
 
 class DeviceMode(t.enum16):
     """Device mode."""
 
-    PILOT_OFF = 0x0100
-    PILOT_ON = 0x0200
+    Switch = 0x0001
+    PilotWire = 0x0002
 
 
 class LegrandCluster(CustomCluster):
@@ -49,11 +31,12 @@ class LegrandCluster(CustomCluster):
     ep_attribute = "legrand_cluster"
 
     class AttributeDefs(BaseAttributeDefs):
-        """Attribute definitions."""
+        """Attribute definitions for LegrandCluster."""
 
         device_mode = ZCLAttributeDef(
             id=0x0000,
-            type=t.data16,  # DeviceMode
+            type=DeviceMode,
+            zcl_type=DataTypeId.data16,
             is_manufacturer_specific=True,
         )
         led_dark = ZCLAttributeDef(
@@ -69,22 +52,31 @@ class LegrandCluster(CustomCluster):
 
 
 class PilotWireMode(t.enum8):
-    """Pilot wire mode."""
+    """Pilot Wire mode."""
 
-    COMFORT = 0x00
-    COMFORT_MINUS_1 = 0x01
-    COMFORT_MINUS_2 = 0x02
-    ECO = 0x03
-    FROST_PROTECTION = 0x04
-    OFF = 0x05
+    Comfort = 0x00
+    ComfortMinus1 = 0x01
+    ComfortMinus2 = 0x02
+    Eco = 0x03
+    FrostProtection = 0x04
+    Off = 0x05
 
 
 class LegrandCableOutletCluster(CustomCluster):
-    """Legrand second manufacturer-specific cluster."""
+    """Legrand Cable Outlet manufacturer-specific cluster."""
+
+    cluster_id = 0xFC40
+    name = "Legrand Cable Outlet"
+    ep_attribute = "legrand_cable_outlet_cluster"
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions for LegrandCableOutletCluster."""
 
-    cluster_id = MANUFACTURER_SPECIFIC_CLUSTER_ID_2
-    name = "CableOutlet"
-    ep_attribute = "cable_outlet_cluster"
+        pilot_wire_mode = ZCLAttributeDef(
+            id=0x00,
+            type=PilotWireMode,
+            is_manufacturer_specific=True,
+        )
 
     class ServerCommandDefs(BaseCommandDefs):
         """Server command definitions."""
@@ -92,81 +84,29 @@ class LegrandCableOutletCluster(CustomCluster):
         set_pilot_wire_mode = ZCLCommandDef(
             id=0x00,
             schema={"mode": PilotWireMode},
-            direction=Direction.Client_to_Server,
             is_manufacturer_specific=True,
         )
 
-
-class Legrand064882CableOutlet(CustomDevice):
-    """Legrand 064882 Cable Outlet device."""
-
-    signature = {
-        #  <SimpleDescriptor endpoint=1 profile=260 device_type=1
-        # input_clusters=[0, 3, 4, 6, 5, 64513, 2820, 64576]
-        # output_clusters=[6, 0, 64513, 5, 25]>
-        MODELS_INFO: [(f" {LEGRAND}", " Cable outlet")],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.LEVEL_CONTROL_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Identify.cluster_id,
-                    Groups.cluster_id,
-                    OnOff.cluster_id,
-                    Scenes.cluster_id,
-                    MANUFACTURER_SPECIFIC_CLUSTER_ID,
-                    ElectricalMeasurement.cluster_id,
-                    MANUFACTURER_SPECIFIC_CLUSTER_ID_2,
-                ],
-                OUTPUT_CLUSTERS: [
-                    OnOff.cluster_id,
-                    Basic.cluster_id,
-                    MANUFACTURER_SPECIFIC_CLUSTER_ID,
-                    Scenes.cluster_id,
-                    Ota.cluster_id,
-                ],
-            },
-            #  <SimpleDescriptor endpoint=242 profile=41440 device_type=102
-            # input_clusters=[33]
-            # output_clusters=[33]>
-            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.LEVEL_CONTROL_SWITCH,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Identify.cluster_id,
-                    Groups.cluster_id,
-                    OnOff.cluster_id,
-                    Scenes.cluster_id,
-                    LegrandCluster,
-                    ElectricalMeasurement.cluster_id,
-                    LegrandCableOutletCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    OnOff.cluster_id,
-                    Basic.cluster_id,
-                    LegrandCluster,
-                    Scenes.cluster_id,
-                    Ota.cluster_id,
-                ],
-            },
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.COMBO_BASIC,
-                INPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
-                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
-            },
-        },
-    }
+    async def write_attributes(
+        self,
+        attributes: dict[str | int, Any],
+        manufacturer: int | None = None,
+        **kwargs,
+    ) -> list:
+        """Write attributes to the cluster."""
+
+        attrs = {}
+        for attr, value in attributes.items():
+            attr_def = self.find_attribute(attr)
+            if attr_def == LegrandCableOutletCluster.AttributeDefs.pilot_wire_mode:
+                await self.set_pilot_wire_mode(value, manufacturer=manufacturer)
+                await super().read_attributes([attr], manufacturer=manufacturer)
+        return await super().write_attributes(attrs, manufacturer)
+
+
+(
+    QuirkBuilder(f" {LEGRAND}", " Cable outlet")
+    .replaces(LegrandCluster)
+    .replaces(LegrandCableOutletCluster)
+    .add_to_registry()
+)


Test patch:
diff --git a/tests/test_legrand.py b/tests/test_legrand.py
index 312e9e6..48f68de 100644
--- a/tests/test_legrand.py
+++ b/tests/test_legrand.py
@@ -1,8 +1,11 @@
 """Tests for Legrand."""
 
+from unittest import mock
+
 import pytest
 
 import zhaquirks
+from zhaquirks.legrand import LEGRAND
 
 zhaquirks.setup()
 
@@ -56,3 +59,26 @@ def test_light_switch_with_neutral_signature(assert_signature_matches_quirk):
     assert_signature_matches_quirk(
         zhaquirks.legrand.switch.LightSwitchWithNeutral, signature
     )
+
+
+async def test_legrand_wire_pilot_cluster_write_attrs(zigpy_device_from_v2_quirk):
+    """Test Legrand cable outlet pilot_wire_mode attr writing."""
+
+    device = zigpy_device_from_v2_quirk(f" {LEGRAND}", " Cable outlet")
+
+    cable_cluster = device.endpoints[1].legrand_cable_outlet_cluster
+    cable_cluster._write_attributes = mock.AsyncMock()
+    cable_cluster._read_attributes = mock.AsyncMock()
+    cable_cluster.set_pilot_wire_mode = mock.AsyncMock()
+
+    # test writing read-only pilot_wire_mode attribute, should call set_pilot_wire_mode
+    await cable_cluster.write_attributes({0x00: 0x02}, manufacturer=0xFC40)
+
+    cable_cluster.set_pilot_wire_mode.assert_awaited_with(
+        0x02,
+        manufacturer=0xFC40,
+    )
+    cable_cluster._write_attributes.assert_awaited_with(
+        [],
+        manufacturer=0xFC40,
+    )