You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add DALI integration with dimmable LED driver support - #187347
This is the first pull request for a new DALI integration, deliberately kept
small per the "keep the first pull request
small"
guidance. It adds a hub integration that drives a DALI bus directly through a
USB interface and, in this first PR, exposes dimmable LED control gear
(device type 6) as light entities.
Scope of this PR:
USB-discovered and manual-serial config flow for the DALI-USB interface, with
an optional step to power the DALI bus from the interface.
A 30-second polling coordinator that discovers control gear on the bus and
reads each gear's actual level and min/max level.
light entities with brightness control, honouring the gear's dimming
curve (logarithmic vs. linear) when converting between Home Assistant
brightness and DALI arc-power levels.
Reconnect handling when the USB interface is unplugged and replugged.
To keep this first PR small it ships only the light platform; diagnostics,
repair flows, and the other entity platforms follow in the PRs below. The
integration targets the Silver quality scale in this PR.
About DALI and python-dali
DALI (Digital Addressable Lighting Interface) is the open IEC 62386 lighting
control standard. It is vendor-neutral, and the DALI Alliance certifies a large
catalogue of interoperable products from many manufacturers
(https://www.dali-alliance.org/products). This integration speaks the standard
itself rather than one vendor's cloud or gateway, so it is not limited to a
single brand of luminaire.
It builds on the python-dali library (IEC 62386 control gear, control
devices and drivers). A substantial amount of upstream work went into python-dali to make this integration possible — an asyncio-native unified
driver send() API, DT6 LED and DT8 colour queries, DT1 self-contained
emergency support with memory bank 208, IEC 62386-306 general sensors, the DiiA
memory-bank decoders (Parts 251/252/253), and stricter typing. Those capabilities
are introduced to Home Assistant incrementally in the follow-up PRs below.
How this differs from the Lunatone and Sunricher DALI integrations
Home Assistant already has two DALI-adjacent integrations, both of which talk to
a networked DALI gateway over IP:
Lunatone — a REST client for the Lunatone DALI-2 IoT / IoT4 gateway and
DALI-2 Displays (local_polling).
Sunricher DALI — a client for the Sunricher SR-GW-EDA network DALI gateway
(local_push).
This DALI integration is different: it drives a DALI-USB interface plugged
directly into the Home Assistant host and speaks the raw IEC 62386 protocol on
the bus through python-dali (dependencies: ["usb"], USB VID/PID discovery).
There is no network gateway or vendor cloud in between.
Testing
Tested against a Lunatone DALI-USB2 interface with multiple Tridonic
devices on the DALI bus.
Follow-up PRs
The complete integration — the final state after all of the follow-up PRs below —
is already available for early testers as a HACS custom integration at tridonic-com/hacs-dali.
To keep reviews manageable, functionality lands incrementally after this PR:
PR 3 — DT8 colour control gear (colour temperature and xy colour).
PR 4 — control-gear and light-source diagnostics (memory banks 205/206),
luminaire maintenance / rated life (memory bank 207) and active energy (memory
bank 202).
PR 5 — DT1 self-contained emergency lighting (memory bank 208).
PR 6 — DALI-2 control/input devices: occupancy and pushbutton events.
PR 7 — DALI-2 Part 306 general sensors (temperature, humidity, CO₂,
illuminance, …).
Type of change
Dependency upgrade
Bugfix (non-breaking change which fixes an issue)
New integration (thank you!)
New feature (which adds functionality to an existing integration)
Deprecation (breaking change to happen in the future)
Breaking change (fix/feature causing existing functionality to break)
Code quality improvements to existing code or addition of tests
The reason will be displayed to describe this comment to others. Learn more.
When adding new integrations, limit included platforms to a single platform. While we appreciate the effort, reviewing larger than necessary PRs slows down the review process. Please reduce this PR to a single platform. See the review process for more details.
CI Upload: ✅ Trusted Publisher attestation found (GitHub).
Release Pipeline: ✅ OIDC via Trusted Publisher attestation (GitHub); automated CI upload verified by PyPI.
Security: ☑️ Baseline scan found nothing obvious in setup.py, pyproject.toml, dali/__init__.py, dali/driver/usb.py, dali/driver/hid.py, dali/driver/serial.py. This is not a security review — only the cheap checks were run.
PR Link: ❌ PR description must link to the source repository at https://github.com/sde1000/python-dali. A PyPI page link is not sufficient. The description links to contributors.home-assistant.io, the tridonic-com/hacs-dali fork, the home-assistant.io docs PR, and the brands PR, but not to github.com/sde1000/python-dali itself.
Async Safe: ✅ New dependency reviewed in full. The async surfaces (dali/driver/hid.py, dali/driver/serial.py) use non-blocking I/O correctly: dali/driver/hid.py opens the device fd with os.O_NONBLOCK and integrates it via asyncio.get_running_loop().add_reader(...), and dali/driver/serial.py is built on asyncio.Protocol/transport.write(). No sync requests/socket/subprocess/time.sleep calls were found reachable from async def bodies.
FYI: python-dali is dual licensed: GPL-3.0 and LGPL-3.0. PyPi listing has "LGPL-3.0-or-later". Does this need to be changed on PyPi?
It has been changed on PyPi now.
CI Upload: ✅ Trusted Publisher attestation found (GitHub).
Release Pipeline: ✅ OIDC via Trusted Publisher attestation (GitHub); automated CI upload verified by PyPI.
Security: ☑️ Baseline scan found nothing obvious in setup.py, pyproject.toml, dali/__init__.py. Install is a plain setuptools.build_meta build with no custom hooks, and the inspected modules contain no network calls or dynamic code execution. This is not a security review — only the cheap checks were run.
Async Safe: ✅ No blocking calls found reachable from async def methods. The library is async-native (asyncio, serialx for serial transport); the HID driver (dali/driver/hid.py) uses os.read/os.write on a file descriptor opened with O_NONBLOCK and registered via asyncio.get_running_loop().add_reader, which is a standard non-blocking integration pattern rather than a blocking call.
Register transport cleanup before awaiting driver connection
homeassistant/components/dali/__init__.py:39
Register transport cleanup before awaiting driver.connect(). The driver opens the serial transport before awaiting its handshake. Cancellation during that handshake bypasses the exception handler and never reaches the unload registration, leaving the port open. Registering the coordinator's cleanup first lets config-entry setup cleanup handle cancellation and unexpected exceptions too.
Preserve scheduled bus refresh when publishing gear updates
Preserve the scheduled bus-wide refresh when publishing a single-gear update. async_set_updated_data() cancels the pending poll and starts another 30-second interval. Commands issued less than 30 seconds apart to one light can therefore indefinitely prevent polling other gear and discovering new devices. Notify listeners without resetting the polling timer.
Add cannot_connect translation for USB discovery aborts
homeassistant/components/dali/strings.json:4
Add cannot_connect to the abort translations. async_step_usb() aborts with this reason when probing fails, but the existing config.error.cannot_connect entry only covers form errors. Without config.abort.cannot_connect, USB discovery failures lack their intended explanation.
- Serialise the periodic bus scan with single-gear command updates so a
mid-scan read cannot publish a stale level over a just-sent command.
- Report full arc power as full brightness instead of the minimum-level value.
- Handle a selected serial port disappearing before the form is submitted.
- Skip the control-device instance scan on connect, as only control gear is used.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Connect with apply_settings=False so probing a USB-discovered interface
cannot depower a bus its integrated supply is already driving. This needs
the non-mutating probe added in python-dali 0.16; the requirement bump
follows once that release is published.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tests.common was moved to the pytest_homeassistant package on dev; follow
the move so the tests collect against current dev.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0.16 adds connect(apply_settings=False), which the config flow uses to
probe a discovered interface without writing its settings. A guard test
asserts the pinned driver exposes the argument so an incompatible pin
fails the tests instead of raising TypeError during setup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add the config.abort.cannot_connect translation used by USB discovery.
- Remove the unused _unique_id_from_port config-flow helper.
- Correct the README to describe the five-minute full-bus scan and the
dimmable-LED (device type 6) only support.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
async_set_updated_data cancels and reschedules the periodic refresh, so
frequent commands to one light could indefinitely postpone scanning and
discovering the other gear. Publish the optimistic update via the data
attribute and listeners instead, leaving the scan deadline intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The coordinator update succeeds even when a gear stops responding, so its
built-in logging never reports per-gear outages. Track the addresses seen
in both the periodic scan and single-gear update paths and log a gear
going unavailable and recovering once each, distinguishing a recovery
from a first discovery, to back the Silver log-when-unavailable rule.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The serial transport is opened during connect(), so registering its
cleanup only after a successful connect leaks it when connect is
cancelled. Register the cleanup on the entry before connecting so a
failed or cancelled connect, first refresh, or platform setup all close
the transport.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed setup (for example the interface unplugged on restart) never
assigns runtime_data, yet the device-removal API still calls the removal
callback. Guard the access so a stale device can be removed instead of
raising AttributeError.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The local candidate window and request-dependent tie-break could map a
reported brightness back to a different level than it came from, e.g. with
logarithmic dimming and min_level 96. Choose the lowest level whose
readback is closest to the request over the whole range, making the
mapping a stable right inverse of the readback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dropped QueryDimmingCurve response defaulted linear_dimming to False,
flipping a linear gear to the logarithmic conversion and corrupting both
the reported brightness and later commands. Treat a missing reply as a
query miss so the existing known-gear retry re-reads it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A deleted light left its address in the platform tracking set, so gear
responding again at that address was not recreated until a reload. Clear
the address from the async_removed_from_registry hook so the gear is
rediscovered on the next scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dropped QueryMinLevel reply reset a known minimum to 1, and a
framing-error QueryDimmingCurve reply (BackwardFrameError) was decoded
via as_integer, both skewing the brightness conversion. Keep the cached
minimum for known gear, reject framing-error dimming-curve replies so the
known-gear retry runs, and give the fake driver the error flag real
responses carry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two manual flows for the same serial port can both pass the initial
duplicate check and wait at the bus-power step; manual ports have no
unique ID for the flow manager to deduplicate, so the second could still
create a competing entry. Re-run the duplicate check before creating the
entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The minimum-level shortcut mapped any level at or below min_level to
brightness 1, breaking the logarithmic readback and its round trip at a
raised minimum (for min_level 200, level 200 read back as 1 while its
curve value is 58). Apply the shortcut only to linear dimming.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Submitting the bus-power step unchanged defaulted to on, which would add a
second supply on an externally powered bus. Enabling the integrated supply
now requires an explicit opt-in.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The linear readback rescaled the curve by the configured minimum level, so
the same level reported different brightness depending on the minimum and a
command below the minimum drove the gear brighter than requested. The
minimum level only limits output (IEC 62386-207): the readback now depends
only on the level and dimming curve, and the command path clamps to the
minimum.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cover the config-flow path where the interface descriptor cannot be read
(raises OSError -> cannot_connect) for both USB discovery and the manual
flow, and the coordinator's optimistic_level override that shows a
just-sent brightness before the next scan reconciles it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dropped or out-of-range QueryMaxLevel response reset a known maximum to
None, lifting the command clamp so a full-power request could drive a gear
above its rated ceiling until the next successful read. Keep the cached
maximum for gear already seen, mirroring the minimum-level handling, and
default to no cap only for newly found gear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_level_or_mask and the MASK_LEVEL constant were carried into this first PR
but never called here; the actual-level read uses _response_value. Remove
the dead helper and its single-constant module to keep the initial platform
minimal; a later PR that needs the MASK handling reintroduces it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A light.turn_on without a brightness drove an already-dimmed gear to full
output. Fall back to the gear's current nonzero level instead, so repeated
turn-on calls leave a dimmed light where it was; an off or unknown light
still turns on to full.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The log-when-unavailable quality-scale rule expects availability changes at
info level. Lower the "stopped responding" message from warning to info so
it pairs with the "responding again" message.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Use a SerialPortSelector for the manual port step instead of a hand-built
enum. The selector is what marks DALI as a serial-port integration, so the
ESPHome device's serial-proxy setup wizard now offers DALI; the enum listed
the same ports but kept DALI out of that wizard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
hassfest derives SERIAL_PORT_SELECTOR_INTEGRATIONS from config flows that
use SerialPortSelector, so regenerate usb.py now that DALI does.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow two tree-wide core changes:
- Define DaliConfigEntry in coordinator.py and import it from there rather
than re-exporting it through the package __init__.
- Return None from async_setup_entry/async_unload_entry and unload with
async_unload_platforms_or_raise instead of returning a bool.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Device types are a static property of a control gear, but
_query_gear_once re-queried them on every single-gear refresh,
including the one triggered after each light command. A known gear now
reuses the device types from the last scan and only queries them for a
newly discovered gear, cutting the per-command bus traffic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A single control gear rejecting a frame (a collision, arbitration loss,
or the bus sagging under the extra frames a multi-type gear needs) raised
TransmissionError, which the periodic scan caught at the loop level and
turned into a whole-update failure. The scan walks addresses in order, so
one noisy gear aborted discovery of every higher-addressed gear after it,
leaving them undiscovered while the lower addresses kept their last-known
state.
Treat a TransmissionError for one gear as a per-gear miss, the same as a
DALISequenceError, so the scan continues to the rest of the bus. A
genuinely bus-wide fault now surfaces as every gear missing, taking the
lights unavailable and recovering on the next good scan, instead of a
single UpdateFailed. OSError/TimeoutError still fail the update so a lost
interface is handled as before.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This branch has not been deployed
No deployments
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Breaking change
Proposed change
This is the first pull request for a new DALI integration, deliberately kept
small per the "keep the first pull request
small"
guidance. It adds a hub integration that drives a DALI bus directly through a
USB interface and, in this first PR, exposes dimmable LED control gear
(device type 6) as
lightentities.Scope of this PR:
an optional step to power the DALI bus from the interface.
reads each gear's actual level and min/max level.
lightentities with brightness control, honouring the gear's dimmingcurve (logarithmic vs. linear) when converting between Home Assistant
brightness and DALI arc-power levels.
To keep this first PR small it ships only the
lightplatform; diagnostics,repair flows, and the other entity platforms follow in the PRs below. The
integration targets the Silver quality scale in this PR.
About DALI and
python-daliDALI (Digital Addressable Lighting Interface) is the open IEC 62386 lighting
control standard. It is vendor-neutral, and the DALI Alliance certifies a large
catalogue of interoperable products from many manufacturers
(https://www.dali-alliance.org/products). This integration speaks the standard
itself rather than one vendor's cloud or gateway, so it is not limited to a
single brand of luminaire.
It builds on the
python-dalilibrary (IEC 62386 control gear, controldevices and drivers). A substantial amount of upstream work went into
python-dalito make this integration possible — an asyncio-native unifieddriver
send()API, DT6 LED and DT8 colour queries, DT1 self-containedemergency support with memory bank 208, IEC 62386-306 general sensors, the DiiA
memory-bank decoders (Parts 251/252/253), and stricter typing. Those capabilities
are introduced to Home Assistant incrementally in the follow-up PRs below.
How this differs from the Lunatone and Sunricher DALI integrations
Home Assistant already has two DALI-adjacent integrations, both of which talk to
a networked DALI gateway over IP:
DALI-2 Displays (
local_polling).(
local_push).This DALI integration is different: it drives a DALI-USB interface plugged
directly into the Home Assistant host and speaks the raw IEC 62386 protocol on
the bus through
python-dali(dependencies: ["usb"], USB VID/PID discovery).There is no network gateway or vendor cloud in between.
Testing
Tested against a Lunatone DALI-USB2 interface with multiple Tridonic
devices on the DALI bus.
Follow-up PRs
The complete integration — the final state after all of the follow-up PRs below —
is already available for early testers as a HACS custom integration at
tridonic-com/hacs-dali.
To keep reviews manageable, functionality lands incrementally after this PR:
sensors, fade-time configuration entities, identify/rescan buttons; raises the
integration to Gold, including the short-address assignment and
address-collision repairs. https://github.com/tridonic-com/core/tree/dali-pr2-identity-memory-banks
is currently rebased on PR1. The branches for the remaining PRs in https://github.com/tridonic-com/core/branches need rebasing.
luminaire maintenance / rated life (memory bank 207) and active energy (memory
bank 202).
illuminance, …).
Type of change
Additional information
Checklist
ruff format homeassistant tests)If user exposed functionality or configuration variables are added/changed:
If the code communicates with devices, web services, or third-party tools:
Updated and included derived files by running:
python3 -m script.hassfest.requirements_all.txt.Updated by running
python3 -m script.gen_requirements_all.To help with the load of incoming pull requests: