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:
- It tells Home Assistant the real range of your strip, not the range of the controller.
- It translates every color you pick from “strip language” into “controller language”, and
translates what the controller reports back. - 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
/configfolder
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 readsconfiguration.yaml
only at startup, so a ZHA reload alone will not pick up the newzha: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
- Open the device page of your controller (for example Living room LED strip).
- Open the Configuration section.
- 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). - 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 quirksand for any
import errors mentioningzhaquirks. - 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:
GledoptoColorRangeCluster— replaces the standardColorcluster (0x0300) on endpoint 11.
It reports the virtual range forcolor_temp_physical_min/color_temp_physical_max(so the
Home Assistant slider shows the strip’s real range) and remapscolor_temperature(on reads and
commands) andstart_up_color_temperature(on reads and writes) between the two ranges.GledoptoRangeConfigCluster— a local data cluster (0xFC01) with two attributes,
warmest_kandcoldest_k. It holds the user-configured range in Kelvin.- Two
numberentities — 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 positionalargs[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_kis the warm (lowest K) end of the strip;coldest_kis the cool (highest K) end.defaultseeds 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_kis higher thancoldest_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
GLEDOPTOand
modelGL-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, adjustNATIVE_MIN_MIREDS/NATIVE_MAX_MIREDSin 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 ofconfiguration.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()
)