Match(ZHA quirk) a CCT LED strip's real colour-temperature range to its zigbee controller

Introduction

If you have a white LED strip that can change between warm white and cool white, and a small
control box (the “controller”) that powers it, the white color may be wrong in Home Assistant.

The strip can only make a limited range of white — for example 1800–4000 K. The controller can make
a wider range, mostly further into cool white — for example 1800–6500 K. Home Assistant builds its
color slider from the controller’s range, so one end of the slider (and sometimes both) is a color
your strip cannot make, and the middle is made by lighting the warm and the cool rows at the same
time — a dull, grayish (“muddy”) white instead of clean white.

This document shares a small extra file called a quirk. It teaches Home Assistant the real range
of your strip and translates every setting for you, inside Home Assistant, before the command
reaches the controller. You set two numbers once; after that the slider, light groups, and
automatic schedules such as Adaptive Lighting all behave correctly.

Definitions

Term Meaning
HA / Home Assistant The free home-automation software this forum is about.
Zigbee A low-power wireless protocol used by many smart-home devices.
ZHA Zigbee Home Automation — the built-in part of Home Assistant that talks to Zigbee devices.
zigpy A helper library ZHA uses internally to talk to Zigbee devices. You never touch it directly.
Quirk A small extra file that teaches Home Assistant how to handle a device it does not fully understand. (Not the same as a Home Assistant add-on / app.)
Controller The small box that supplies power to, and controls, the LED strip.
CCT Correlated Color Temperature — the “tunable white” feature: the white can shift between warm and cool.
CW / WW Cool White and Warm White — the two separate light rows inside a CCT strip.
K / Kelvin The unit for color temperature. Lower K = warmer (more orange), higher K = cooler (more blue).
Mired Micro reciprocal degree = 1,000,000 / Kelvin. Zigbee uses mireds internally; Home Assistant shows Kelvin.
Cluster A named group of related Zigbee settings and commands, for example the Color cluster.
Endpoint A numbered “port” on a Zigbee device. This controller uses endpoint 11.
IEEE address The unique hardware address of a Zigbee device, like a serial number.
Device type A code that says what kind of device something is. 0x010C means “color temperature light”.
JSON / YAML Text file formats. JSON stores each device’s color range; YAML is Home Assistant’s configuration.yaml.
0x... A number written in base 16 (hexadecimal). Cluster and device-type IDs use this form.
Adaptive Lighting A popular community custom integration (installed via HACS) that shifts white through the day.
TL;DR “Too long; didn’t read” — a short summary.

Summary — how it works, in plain words

Think of your strip as a lamp that can make only a limited set of whites (from warm to cool); the
control box that drives it can make a wider set, usually further into cool white. Home Assistant
asks the controller for its range and builds the color slider from that answer, so one end of the
slider (and sometimes both) is a color your strip cannot make, and the middle is produced by
switching on both the warm and the cool rows at once — a dull, grayish white.

This quirk fixes that. It does three simple things:

  1. It tells Home Assistant the real range of your strip, not the range of the controller.
  2. It translates every color you pick from “strip language” into “controller language”, and
    translates what the controller reports back.
  3. It remembers your strip’s range per device, so you set it once.

You only enter two numbers — the warmest and the coolest color your strip can make. After that,
everything behaves normally: the slider, light groups, and automatic schedules such as Adaptive
Lighting.

TL;DR — a custom Home Assistant file that makes the color-temperature slider match the real
range of your CCT LED strip. Set the range on the controller’s page in Home Assistant. No template
light, no scripts, and no re-pairing after setup.

Step-by-step: how to do it

Before you start: this guide assumes Home Assistant OS or Supervised with a /config folder
you can write to (for example through the Advanced SSH & Web Terminal or File Editor app),
the ZHA integration, and a Zigbee CCT controller that already works in Home Assistant. Other
installation types use a different config path. This is community (unsupported) code, so review
it first.

Step 1 — Create the quirk folder (if it does not exist yet)

Create this folder in your Home Assistant configuration:

/config/custom_zha_quirks/

Step 2 — Save the quirk file

Save the file from the Detailed explanation section below as:

/config/custom_zha_quirks/gledopto_gl_c_006p.py
  • The file matches only manufacturer = "GLEDOPTO", model = "GL-C-006P", endpoint 11. Check
    yours under the device signature; for a different controller, edit those values.
  • A second file, gledopto_gl_c_006p.json, is created automatically the first time you change a
    range. You do not have to create it by hand.
  • Upgrading from an older file? Delete any previous version of this quirk and the __pycache__
    folder in /config/custom_zha_quirks/. Two matching quirks are confusing even though they are
    not an error (the most recently registered one is applied).

Step 3 — Tell ZHA to use the folder

Add these two lines under your existing zha: block in configuration.yaml. Do not create
a second top-level zha: key — that is a YAML error. If you have no zha: block, add the one shown:

zha:
  enable_quirks: true
  custom_quirks_path: /config/custom_zha_quirks

Then check the configuration: Settings → Tools → YAML → Check configuration (older releases:
Developer Tools → YAML).

Step 4 — Restart Home Assistant, then reload ZHA

  • First time (you just changed configuration.yaml): restart Home Assistant once
    (Settings → System → ⋮ → Restart Home Assistant). Home Assistant reads configuration.yaml
    only at startup, so a ZHA reload alone will not pick up the new zha: lines.
  • After that, when you only edit the quirk file: Settings → Devices & services → ZHA →
    ⋮ (three dots) → Reload
    is enough.
  • You normally do not need to remove and re-pair the device. If the new number controls still
    do not appear after a restart, remove the device and pair it again.

Step 5 — Set your strip’s real range

  1. Open the device page of your controller (for example Living room LED strip).
  2. Open the Configuration section.
  3. Set CCT warmest (K) and CCT coldest (K) to your strip’s real values. Warmest must be
    lower than coldest (for example warmest 1800, coldest 4000).
  4. The new range is used immediately for light commands. The slider’s displayed endpoints
    (min_color_temp_kelvin / max_color_temp_kelvin) refresh after a ZHA reload.

Step 6 — Check the result

  • In Home Assistant, check Settings → System → Logs for Loaded custom quirks and for any
    import errors mentioning zhaquirks.
  • On the device page, open ⋮ → Download diagnostics and confirm quirk_applied.
  • Set the light to its warmest value: only the warm (WW) row should light. Set it to its coolest
    value: only the cool (CW) row should light. In between should be a smooth, clean white.

Optional — finding your strip’s real range

With the quirk not loaded, sweep the slider and note the low Kelvin where the cool (CW) row goes
dark (warmest_k) and the high Kelvin where the warm (WW) row goes dark (coldest_k).

Detailed explanation

The hardware

Example: GLEDOPTO GL-C-203P Pro+ (Zigbee 3.0, 2-in-1 CCT/dimmer), kept in CCT mode (the
indicator blinks slowly) and reported as GLEDOPTO GL-C-006P on endpoint 11 (0x010C), with a
generic two-row CCT strip (CW + WW). Firmware differs between units; the quirk matches on the
manufacturer and model names, so this does not matter.

Why the problem happens

The controller mixes the cool-white (CW) and warm-white (WW) rows across its native range of
153–556 mired (≈6535–1798 K). A real strip is usually narrower. Without the quirk, Home
Assistant sends the literal mired value it calculated from the Kelvin you picked, and that value
does not land on the true end of the controller’s range.

Example: a strip of 1800–4000 K. Picking 4000 K makes Home Assistant send 250 mired — correct
for 4000 K, but only about 24 % of the way from the controller’s cool end (153 mired) to its
warm end (556 mired). So roughly 76 % of the cool row and 24 % of the warm row are lit: a slightly
warm, muddy white instead of pure cool white. (Manually relabeling the slider’s min/max only changes
the text; the controller still receives the same literal value.)

The core idea: the quirk advertises a virtual range to Home Assistant (what the strip can
really do) and maps it linearly onto the controller’s native range — in both directions.

How the quirk is built

The quirk uses the modern quirks v2 QuirkBuilder API and is made of three parts:

  1. GledoptoColorRangeCluster — replaces the standard Color cluster (0x0300) on endpoint 11.
    It reports the virtual range for color_temp_physical_min / color_temp_physical_max (so the
    Home Assistant slider shows the strip’s real range) and remaps color_temperature (on reads and
    commands) and start_up_color_temperature (on reads and writes) between the two ranges.
  2. GledoptoRangeConfigCluster — a local data cluster (0xFC01) with two attributes,
    warmest_k and coldest_k. It holds the user-configured range in Kelvin.
  3. Two number entities — shown on the device page under Configuration as CCT warmest (K)
    and CCT coldest (K) (1000–10000 K, step 50).

The Zigbee attribute cache always stores the controller’s native mired values. Conversion to
and from the strip’s range happens only at the cluster API boundary, so device reports, database
loads and polling all agree; changing a number affects light commands immediately.

The mired mapping

The native calibration constants are:

Constant Mireds Kelvin
NATIVE_MIN_MIREDS 153 ≈6535 K (only the CW row lit)
NATIVE_MAX_MIREDS 556 ≈1798 K (only the WW row lit)

The virtual range comes from your strip setting: vmin = 1000000 / coldest_k,
vmax = 1000000 / warmest_k. The conversion is linear in both directions, rounded to a whole mired:

  • _virtual_to_native: ratio = (mireds − vmin) / (vmax − vmin), then
    native = 153 + ratio * (556 − 153)
  • _native_to_virtual: ratio = (mireds − 153) / (556 − 153), then
    virtual = vmin + ratio * (vmax − vmin)

In both cases the ratio is clamped to 0–1. The remap is applied at the cluster’s API boundary:
command() (outgoing move_to_color_temp), write_attributes() (outgoing writes), and get() /
read_attributes() (incoming values, so the slider, the start-up color number and ZHA’s periodic
polling all see the strip’s range). The cache itself always holds native mired values.

zigpy 2.x detail: ZHA passes the color temperature as the keyword color_temp_mireds, not as
the positional args[0], so the quirk remaps both forms.

Per-device configuration (the JSON file)

Each value you set from the device page is persisted by zigpy in the Zigbee database, so it
survives restarts on its own. The gledopto_gl_c_006p.json file next to the quirk is a seed +
best-effort mirror
: it seeds any attribute you have not set from the UI, and is updated for
values you do set, keyed by the device’s IEEE address:

{
  "default": { "warmest_k": 1800, "coldest_k": 4000 },
  "devices": {
    "aa:bb:cc:dd:ee:ff:00:11": { "warmest_k": 2200, "coldest_k": 6500 }
  }
}
  • warmest_k is the warm (lowest K) end of the strip; coldest_k is the cool (highest K) end.
  • default seeds attributes that were never set from the UI.
  • The stored Zigbee attribute is authoritative per attribute. A hand edit is used only for an
    attribute that was never set from the UI; after you set it once, the stored value wins (to
    re-seed an already-set attribute, remove and re-add the device).
  • Values are clamped to 1000–10000 K. If warmest_k is higher than coldest_k, the two are sorted
    before use; if they are equal, the controller’s native range (153–556 mired) is used instead.
  • The stored attribute is the source of truth: if the JSON mirror cannot be written (for example a
    read-only folder), the failure is only logged. An unreadable or corrupt file is moved aside to
    gledopto_gl_c_006p.json.corrupt-<timestamp> before a new one is written.

Compatibility and caveats

  • Matched devices: the quirk applies to every device reporting manufacturer GLEDOPTO and
    model GL-C-006P (endpoint 11), not just one unit. Any such device gets the default 1800/4000 K
    seed until you set its range, so check each device.
  • Mode changes: keep the controller in CCT mode. Changing its mode changes the reported
    model, which makes the quirk stop matching and removes the two number controls.
  • Calibration: the native constants (153/556 mired) were measured on one unit. If your
    controller differs, adjust NATIVE_MIN_MIREDS / NATIVE_MAX_MIREDS in the file.
  • Groups: this remaps the individual light entity, so Home Assistant light groups (which
    command each member) benefit. ZHA Zigbee groups (multicast direct to devices) do not go
    through the quirk — do not use them for color temperature.
  • Range changes: a new range is used immediately for light commands and for the start-up color
    value. The slider’s displayed endpoints (and the start-up number’s min/max) update after a ZHA
    reload or a re-interview (Reconfigure).
  • Unsupported code: this is a community quirk, not part of Home Assistant or ZHA. It relies on
    internal APIs that can change between releases. Tested on HA Core 2026.9.1 with zha-quirks 2.2.1 /
    zigpy 2.1.0. Review it before use and keep a backup of configuration.yaml.

Troubleshooting

Symptom Cause / fix
The CCT warmest/coldest (K) controls do not appear ZHA was not restarted/reloaded after the change, or the quirk did not match.
The quirk does not load Check the QuirkBuilder(MANUFACTURER, MODEL) values and the custom_quirks_path setting; look for Loaded custom quirks or import errors in the logs.
The slider’s endpoints do not change Commands already use the new range; the displayed endpoints refresh after a ZHA reload (or a re-interview via Reconfigure).
An edited quirk file is not picked up Restart Home Assistant. Deleting the __pycache__ folder is rarely needed.

The full quirk file

Save this as /config/custom_zha_quirks/gledopto_gl_c_006p.py:

"""Custom ZHA quirk for GLEDOPTO GL-C-006P / GL-C-203P (CCT mode).

Remaps the connected LED strip's CCT range onto the controller's native mixing
range, so HA's color-temperature slider endpoints drive CW-only / WW-only
instead of leaving both channels partially lit.

The strip's CCT range is editable from the device page in Home Assistant:
two Configuration numbers, "CCT warmest (K)" and "CCT coldest (K)".

Persistence: each value lives in zigpy's attribute cache and is persisted by
zigpy into the Zigbee database (appdb), so it survives a restart on its own.
The Zigbee database is authoritative **per attribute**: a value you have set
from the UI is restored from the DB, while an attribute you never set falls
back to the sibling JSON seed file `gledopto_gl_c_006p.json`. That file is a
seed + best-effort mirror (updated for values written from the UI, only for
that device); a hand edit of an attribute that was never set from the UI is
picked up on the next start:

    {
      "default": {"warmest_k": 1800, "coldest_k": 4000},
      "devices": {
        "aa:bb:cc:dd:ee:ff:00:11": {"warmest_k": 2200, "coldest_k": 6500}
      }
    }

warmest_k = warmest (lowest Kelvin) end of the strip
coldest_k = coldest (highest Kelvin) end of the strip

Design note: the zigpy attribute cache always holds the controller's NATIVE
values, no matter whether they came from the device, the database or a write.
Conversion to and from the strip's VIRTUAL range happens only at the cluster
API boundary (`get`, `read_attributes`, `write_attributes`, `command`). This is
what Home Assistant reads (slider bounds, the start-up colour number, polling),
so HA always sees the strip's range; raw zigpy attribute *events* still carry
the native value, which is fine because the only HA Color subscriber (the
start-up colour number) ignores the event value and re-reads through `get()`.
"""

import asyncio
import contextlib
import json
import logging
import os
import threading
import time

import zigpy.types as t
from zigpy.typing import UNDEFINED
from zigpy.zcl.clusters.lighting import Color
from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef

from zhaquirks import LocalDataCluster
from zhaquirks.builder import QuirkBuilder
from zhaquirks.clusters import CustomCluster

_LOGGER = logging.getLogger(__name__)

MANUFACTURER = "GLEDOPTO"
MODEL = "GL-C-006P"

RANGE_CONFIG_FILE = os.path.join(
    os.path.dirname(os.path.abspath(__file__)), "gledopto_gl_c_006p.json"
)

# Controller native mixing range (mireds) - calibrated from no-quirk boundary tests
NATIVE_MIN_MIREDS = 153  # 6535 K - only CW row lit
NATIVE_MAX_MIREDS = 556  # 1798 K - only WW row lit

DEFAULT_WARMEST_K = 1800
DEFAULT_COLDEST_K = 4000

KELVIN_MIN = 1000
KELVIN_MAX = 10000

# Attributes that are expressed in the strip's (virtual) mired range.
_COLOR_TEMP_ATTR_IDS = frozenset(
    {
        Color.AttributeDefs.color_temperature.id,
        Color.AttributeDefs.start_up_color_temperature.id,
    }
)

# ... of which only the start-up attribute is writable (color_temperature is
# read-only, access="rps", and is driven through move_to_color_temp instead).
_WRITABLE_COLOR_TEMP_ATTR_IDS = frozenset(
    {Color.AttributeDefs.start_up_color_temperature.id}
)

_MISSING = object()

# Serialises the JSON read-modify-write in `_persist_range`.
_CONFIG_LOCK = threading.Lock()


def _coerce_kelvin(value, default):
    """Coerce an arbitrary JSON/UI value into a sane Kelvin integer."""
    try:
        kelvin = int(round(float(value)))
    except (TypeError, ValueError, OverflowError):
        return default
    return max(KELVIN_MIN, min(KELVIN_MAX, kelvin))


def _kelvin_to_mireds(warmest_k, coldest_k):
    """Return (min_mireds, max_mireds) for a strip range in Kelvin."""
    warmest_k = _coerce_kelvin(warmest_k, DEFAULT_WARMEST_K)
    coldest_k = _coerce_kelvin(coldest_k, DEFAULT_COLDEST_K)
    min_mireds = round(1000000 / coldest_k)
    max_mireds = round(1000000 / warmest_k)
    if min_mireds > max_mireds:
        min_mireds, max_mireds = max_mireds, min_mireds
    if min_mireds == max_mireds:
        # Degenerate range (warmest_k == coldest_k): fall back to the native range.
        return NATIVE_MIN_MIREDS, NATIVE_MAX_MIREDS
    return min_mireds, max_mireds


def _backup_corrupt_config():
    """Move an unreadable config aside so a later write cannot silently erase it."""
    backup = f"{RANGE_CONFIG_FILE}.corrupt-{time.time_ns()}"
    try:
        os.replace(RANGE_CONFIG_FILE, backup)
        _LOGGER.warning("Backed up unreadable config to %s", backup)
    except OSError as err:
        _LOGGER.warning("Could not back up %s: %s", RANGE_CONFIG_FILE, err)


def _read_config_file():
    """Read and normalise the JSON range config.

    Pure (no side effects). Returns `{}` when the file does not exist (nothing to
    seed yet) and `None` when it exists but is unreadable or not a JSON object.
    """
    try:
        with open(RANGE_CONFIG_FILE, encoding="utf-8") as fptr:
            data = json.load(fptr)
    except FileNotFoundError:
        return {}
    except (OSError, ValueError) as err:
        _LOGGER.warning("%s is unreadable (%s)", RANGE_CONFIG_FILE, err)
        return None
    if not isinstance(data, dict):
        _LOGGER.warning("%s: expected a JSON object", RANGE_CONFIG_FILE)
        return None
    devices = data.get("devices")
    if isinstance(devices, dict):
        data["devices"] = {str(ieee).lower(): entry for ieee, entry in devices.items()}
    return data


def _range_kelvin_for(ieee):
    """Return (warmest_k, coldest_k) for a device from the config file."""
    config = _read_config_file()
    if config is None:
        config = {}
    entry = None
    devices = config.get("devices")
    if isinstance(devices, dict) and ieee is not None:
        entry = devices.get(str(ieee).lower())
    if not isinstance(entry, dict):
        entry = config.get("default")
    if not isinstance(entry, dict):
        entry = {}
    return (
        _coerce_kelvin(entry.get("warmest_k"), DEFAULT_WARMEST_K),
        _coerce_kelvin(entry.get("coldest_k"), DEFAULT_COLDEST_K),
    )


def _write_config_file(data):
    """Write the config atomically (temp file + replace). Raises OSError."""
    tmp_path = f"{RANGE_CONFIG_FILE}.tmp"
    try:
        with open(tmp_path, "w", encoding="utf-8") as fptr:
            json.dump(data, fptr, indent=2, sort_keys=True)
            fptr.flush()
            os.fsync(fptr.fileno())
        os.replace(tmp_path, RANGE_CONFIG_FILE)
    finally:
        with contextlib.suppress(OSError):
            os.remove(tmp_path)


def _persist_range(ieee, warmest_k, coldest_k):
    """Merge one device's range into the config and write it atomically."""
    if ieee is None:
        return
    with _CONFIG_LOCK:
        config = _read_config_file()
        if config is None:
            # Preserve the unreadable file before replacing it with a fresh one.
            _backup_corrupt_config()
            config = {}
        devices = config.get("devices")
        if not isinstance(devices, dict):
            devices = {}
            config["devices"] = devices
        devices[str(ieee).lower()] = {
            "warmest_k": _coerce_kelvin(warmest_k, DEFAULT_WARMEST_K),
            "coldest_k": _coerce_kelvin(coldest_k, DEFAULT_COLDEST_K),
        }
        _write_config_file(config)


class GledoptoRangeConfigCluster(LocalDataCluster):
    """Local cluster holding the user-configurable CCT range (Kelvin)."""

    cluster_id = 0xFC01
    ep_attribute = "gledopto_range_config"

    class AttributeDefs(BaseAttributeDefs):
        """Attribute definitions for the local range-config cluster."""

        warmest_k = ZCLAttributeDef(id=0x0000, type=t.uint16_t)
        coldest_k = ZCLAttributeDef(id=0x0001, type=t.uint16_t)

    def __init__(self, *args, **kwargs):
        """Initialise the cluster and seed the JSON default range."""
        super().__init__(*args, **kwargs)
        warmest_k, coldest_k = _range_kelvin_for(
            getattr(self.endpoint.device, "ieee", None)
        )
        self._DEFAULT_VALUES = {
            self.AttributeDefs.warmest_k.id: warmest_k,
            self.AttributeDefs.coldest_k.id: coldest_k,
        }

    @property
    def mireds_range(self):
        """Return the configured (min_mireds, max_mireds)."""
        warmest_k = self.get(self.AttributeDefs.warmest_k) or DEFAULT_WARMEST_K
        coldest_k = self.get(self.AttributeDefs.coldest_k) or DEFAULT_COLDEST_K
        return _kelvin_to_mireds(warmest_k, coldest_k)

    async def write_attributes(
        self,
        attributes,
        manufacturer=UNDEFINED,
        **kwargs,
    ):
        """Update the local cache, then mirror the range into the JSON seed file.

        The value itself is already persisted to the Zigbee database by zigpy
        (the attribute cache update emits an event appdb stores), so a failure
        to write the JSON mirror only logs a warning and does not fail the
        user's write.
        """
        result = await super().write_attributes(
            attributes, manufacturer=manufacturer, **kwargs
        )
        try:
            await asyncio.get_running_loop().run_in_executor(
                None,
                _persist_range,
                self.endpoint.device.ieee,
                self.get(self.AttributeDefs.warmest_k),
                self.get(self.AttributeDefs.coldest_k),
            )
        except Exception as err:  # noqa: BLE001 - the mirror must never fail a write
            _LOGGER.warning(
                "Could not write %s: %s (the value is kept in the Zigbee database)",
                RANGE_CONFIG_FILE,
                err,
            )
        return result


class GledoptoColorRangeCluster(CustomCluster, Color):
    """Color cluster that remaps strip mireds onto the controller native range."""

    def _virtual_range(self):
        """Return the configured strip (min_mireds, max_mireds)."""
        config = self.endpoint.in_clusters.get(GledoptoRangeConfigCluster.cluster_id)
        if config is not None:
            return config.mireds_range
        return _kelvin_to_mireds(DEFAULT_WARMEST_K, DEFAULT_COLDEST_K)

    @staticmethod
    def _virtual_to_native(mireds, vmin, vmax):
        """Map a strip (virtual) mired value onto the controller native range."""
        if mireds is None:
            return None
        span = vmax - vmin
        ratio = 0.0 if span == 0 else (mireds - vmin) / span
        ratio = max(0.0, min(1.0, ratio))
        return round(
            NATIVE_MIN_MIREDS + ratio * (NATIVE_MAX_MIREDS - NATIVE_MIN_MIREDS)
        )

    @staticmethod
    def _native_to_virtual(mireds, vmin, vmax):
        """Map a controller (native) mired value onto the strip range."""
        if mireds is None:
            return None
        ratio = (mireds - NATIVE_MIN_MIREDS) / (NATIVE_MAX_MIREDS - NATIVE_MIN_MIREDS)
        ratio = max(0.0, min(1.0, ratio))
        return round(vmin + ratio * (vmax - vmin))

    def get(self, key, default=None):
        """Return the virtual value for colour-temperature attributes."""
        attr = self.find_attribute(key)
        if attr.id == Color.AttributeDefs.color_temp_physical_min.id:
            return self._virtual_range()[0]
        if attr.id == Color.AttributeDefs.color_temp_physical_max.id:
            return self._virtual_range()[1]
        value = super().get(key, _MISSING)
        if value is _MISSING:
            return default
        # 0 means "not configured" for these attributes; do not remap it.
        if value in (None, 0) or attr.id not in _COLOR_TEMP_ATTR_IDS:
            return value
        return self._native_to_virtual(value, *self._virtual_range())

    async def read_attributes(
        self,
        attributes,
        allow_cache=False,
        only_cache=False,
        manufacturer=UNDEFINED,
        **kwargs,
    ):
        """Return virtual values to callers while the cache keeps native ones."""
        success, failure = await super().read_attributes(
            attributes,
            allow_cache=allow_cache,
            only_cache=only_cache,
            manufacturer=manufacturer,
            **kwargs,
        )
        for attr in list(success):
            try:
                attr_def = self.find_attribute(attr)
            except KeyError:
                continue
            value = success[attr]
            if attr_def.id == Color.AttributeDefs.color_temp_physical_min.id:
                success[attr] = self._virtual_range()[0]
            elif attr_def.id == Color.AttributeDefs.color_temp_physical_max.id:
                success[attr] = self._virtual_range()[1]
            elif value not in (None, 0) and attr_def.id in _COLOR_TEMP_ATTR_IDS:
                success[attr] = self._native_to_virtual(value, *self._virtual_range())
        return success, failure

    async def write_attributes(
        self,
        attributes,
        manufacturer=UNDEFINED,
        *,
        update_cache=True,
        **kwargs,
    ):
        """Remap writes from the strip range onto the controller native range."""
        converted = dict(attributes)
        for attr, value in attributes.items():
            # 0 means "not configured" for these attributes; do not remap it.
            if value in (None, 0):
                continue
            try:
                attr_def = self.find_attribute(attr)
            except KeyError:
                continue
            if attr_def.id in _WRITABLE_COLOR_TEMP_ATTR_IDS:
                converted[attr] = self._virtual_to_native(value, *self._virtual_range())
        return await super().write_attributes(
            converted, manufacturer=manufacturer, update_cache=update_cache, **kwargs
        )

    async def command(
        self,
        command_id,
        *args,
        manufacturer=None,
        expect_reply=True,
        **kwargs,
    ):
        """Remap outgoing colour-temperature commands onto native mireds.

        Zigpy 2.x passes the values as keyword arguments, not as positional
        arguments, so both forms are handled. Only `move_to_color_temp` is used
        by Home Assistant; `move_color_temp` / `step_color_temp` are remapped too
        for the raw cluster-command service.
        """
        kwargs = dict(kwargs)
        if command_id == Color.ServerCommandDefs.move_to_color_temp.id:
            vmin, vmax = self._virtual_range()
            if args:
                args = (self._virtual_to_native(args[0], vmin, vmax), *args[1:])
            if "color_temp_mireds" in kwargs:
                kwargs["color_temp_mireds"] = self._virtual_to_native(
                    kwargs["color_temp_mireds"], vmin, vmax
                )
        elif command_id in (
            Color.ServerCommandDefs.move_color_temp.id,
            Color.ServerCommandDefs.step_color_temp.id,
        ):
            vmin, vmax = self._virtual_range()
            for name in ("color_temp_min_mireds", "color_temp_max_mireds"):
                if name in kwargs:
                    kwargs[name] = self._virtual_to_native(kwargs[name], vmin, vmax)
        return await super().command(
            command_id,
            *args,
            manufacturer=manufacturer,
            expect_reply=expect_reply,
            **kwargs,
        )


(
    QuirkBuilder(MANUFACTURER, MODEL)
    .replaces(GledoptoColorRangeCluster, endpoint_id=11)
    .adds(GledoptoRangeConfigCluster, endpoint_id=11)
    .number(
        attribute_name=GledoptoRangeConfigCluster.AttributeDefs.warmest_k.name,
        cluster_id=GledoptoRangeConfigCluster.cluster_id,
        endpoint_id=11,
        min_value=KELVIN_MIN,
        max_value=KELVIN_MAX,
        step=50,
        unit="K",
        translation_key="cct_warmest_k",
        fallback_name="CCT warmest (K)",
    )
    .number(
        attribute_name=GledoptoRangeConfigCluster.AttributeDefs.coldest_k.name,
        cluster_id=GledoptoRangeConfigCluster.cluster_id,
        endpoint_id=11,
        min_value=KELVIN_MIN,
        max_value=KELVIN_MAX,
        step=50,
        unit="K",
        translation_key="cct_coldest_k",
        fallback_name="CCT coldest (K)",
    )
    .add_to_registry()
)
1 Like