This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
tuya-device-handlers is a Python library of "quirks" for Tuya devices. It patches tuya_sharing.CustomerDevice instances (functions, status_range, local_strategy) and exposes per-platform definitions/wrappers so that Home Assistant's Tuya integration sees a corrected/normalised device. There is no CLI or runtime entry point — the library is consumed entirely by HA Core's homeassistant.components.tuya, which pins it as a requirement in its manifest.
HA Core imports specific symbols from specific paths. Treat these as a stable contract — renames or moves require a coordinated bump of HA's pinned version.
tuya_device_handlers.devices.TUYA_QUIRKS_REGISTRYandtuya_device_handlers.devices.register_tuya_quirks— used bycoordinator.py. HA callsregister_tuya_quirks(<config>/tuya_quirks)at setup andTUYA_QUIRKS_REGISTRY.initialise_device_quirk(device)per device. (Note: HA importsTUYA_QUIRKS_REGISTRYfrom.devices, not the top-level package — the re-export indevices/__init__.pyis load-bearing.)tuya_device_handlers.device_wrapper.DeviceWrapper— used byentity.pyas the generic type for_read_wrapper/_async_send_wrapper_updates.tuya_device_handlers.definition.<platform>— every platform module exposes<Platform>Definition(e.g.ClimateDefinition,SensorDefinition) andget_default_definition(...). HA platforms callget_default_definition(...)as the fallback when no quirk overrides; quirks return the same dataclass type.tuya_device_handlers.device_wrapper.<platform>andtuya_device_handlers.device_wrapper.common— concrete wrapper classes (e.g.ElectricityCurrentRawWrapper,WindDirectionEnumWrapper,DPCodeTypeInformationWrapper) that HA platforms reference by name.tuya_device_handlers.helpers.homeassistant—TuyaClimateHVACMode,TuyaUnitOfTemperature,TuyaSensorDeviceClass, etc. HA maps these to its own enums; quirks use them so they don't import HA directly.
register_tuya_quirks(custom_quirks_path) and QuirksRegistry.purge_custom_quirks(...) exist specifically to let HA users drop ad-hoc quirk files into <HA config>/tuya_quirks/ and have them reloaded without restarting the integration. purge_custom_quirks filters by quirk_file.is_relative_to(...) — that is why DeviceQuirk.__init__ captures the caller's filename via inspect.currentframe().f_back. Reload semantics depend on this provenance being correct, so each quirk file should produce exactly one DeviceQuirk() chain at module top level.
Poetry. Python 3.13 and 3.14 are both supported (CI runs both).
poetry install— set up dev environment.poetry run pytest --cov tuya_device_handlers tests— full test suite with coverage.poetry run pytest tests/path/to/test_file.py::test_name— single test.poetry run ty check src tests— type-check with ty.poetry run ruff check .— lint with ruff.poetry run ruff format --check .— check formatting.poetry run pylint src/tuya_device_handlers— lint with pylint.poetry run yamllint .— lint YAML files.poetry run codespell— check for common misspellings.poetry run prek install— install pre-commit hooks.poetry run prek run --all-files— run all pre-commit hooks on all files.poetry run prek run ruff-check --all-files— run a single hook on all files.
Ruff config lives in pyproject.toml (line-length 80, isort with force-sort-within-sections, mccabe max-complexity 10). Type checking uses ty.
src/tuya_device_handlers/__init__.py exposes TUYA_QUIRKS_REGISTRY, the single QuirksRegistry instance consumers import. The registry is a singleton: QuirksRegistry.__new__ reuses the class-level instance, and __init__ is guarded to not wipe _quirks on re-instantiation (see commit e4cf692). Treat re-instantiation as a no-op — never reset state by constructing a new registry.
QuirksRegistry (src/tuya_device_handlers/registry.py) maps product_id → DeviceQuirkProtocol. The protocol has three keys: original_function/original_local_strategy/original_status_range (snapshots taken at apply time), quirk_file/quirk_file_line (provenance for diagnostics), and initialise_device(device) (the mutation step).
purge_custom_quirks(custom_quirks_root) filters quirks by quirk_file.is_relative_to(...) — this is what makes user-supplied quirks reloadable without restarting the host. Built-in quirks are not affected.
src/tuya_device_handlers/devices/__init__.py — register_tuya_quirks(custom_quirks_path=None):
- Purge previously-loaded custom quirks (by file path).
pkgutil.walk_packagesover thedevices/subpackage: importing each module registers its quirks as a side effect (the module body runs theDeviceQuirk()...register(TUYA_QUIRKS_REGISTRY)chain).- If
custom_quirks_pathis set, walk that directory and import each module viaimportlib.utilso user quirks register the same way.
Quirk discovery is therefore import-driven. A new quirk file under src/tuya_device_handlers/devices/<category>/ is picked up automatically — there is no manifest.
src/tuya_device_handlers/builder/device_quirk.py — DeviceQuirk is a fluent builder:
(
DeviceQuirk()
.applies_to(product_id="...")
.add_dpid_integer(dpid=..., dpcode="...", dpmode=DPMode.READ | DPMode.WRITE, ...)
.remove_dpid(dpid=..., dpcode="...")
.register(TUYA_QUIRKS_REGISTRY)
)__init__ captures the caller's filename/lineno via inspect.currentframe().f_back for quirk_file* — each quirk file should produce exactly one DeviceQuirk() chain at module top level so provenance points at the right line.
initialise_device snapshots the device's original maps then walks _datapoint_definitions. For each entry it adds/removes from device.function (WRITE flag), device.status_range (READ flag), and device.local_strategy (only when device.support_local). A None value means "remove this dpid/dpcode entirely".
DPMode is an IntFlag (READ=1, WRITE=2) so combine with |. DPType is a StrEnum and has a forgiving try_parse for ill-formed cloud values (see const.py).
devices/<two-letter-category>/<category>_<product_id_lowercased>.py. Categories follow Tuya's official codes (e.g. cl curtain, cz plug/socket, wk thermostat) — the __init__.py of each category links to the relevant Tuya developer doc.
These two layers exist because the cloud-side device shape (datapoints) doesn't map 1:1 to Home Assistant entities.
definition/— per-platform dataclasses (TuyaClimateDefinition, etc.) the host integration consumes to build HA entities. All inherit fromBaseEntityQuirk(base.py) with a singlekeyfield.device_wrapper/—DeviceWrapper[T]+ typedDPCodeWrapper[T]subclasses (DPCodeBooleanWrapper,DPCodeIntegerWrapper, …) that encapsulate the read/write conversion between a raw DPCode value and the HA-facing value.read_device_statusand_convert_value_to_raw_valueare the override points;find_dpcode(device, code, prefer_function=...)is the standard lookup. Override these in a quirk file when a device needs custom scaling/encoding (see theCustomIntegerTypeDefinitionexample in devices/wk/wk_iayz2wk1th0cmlml.py).raw_data_model.py— parsers for base64-encoded RAW DPs (e.g.ElectricityData.from_byteshandles legacy/v01/v02 layouts).
helpers/homeassistant.py re-exports HA enums (TuyaSensorDeviceClass, TuyaEntityCategory, TuyaUnitOfTemperature, …) so quirks don't import Home Assistant directly. Keep new HA-shaped constants here.
tests/ mirrors src/tuya_device_handlers/. Per-device JSON fixtures live under tests/fixtures/devices/ named <category>_<product_id>.json — these capture real CustomerDevice payloads. Snapshot tests use syrupy (__snapshots__/ directories). tests/conftest.py provides mock_device (a CustomerDevice Mock pre-populated with demo_* DPs covering every DPType) and filled_quirks_registry (calls register_tuya_quirks() once per module).
When adding a new device quirk: add a fixture JSON, add the quirk module under devices/<category>/, and add tests under tests/devices/<category>/.