Repository navigation
4.0.0 Typed and tidy
Breaking changes
-
Drop support for Python 3.10. XKNX requires Python 3.11 or newer now.
-
Remove
xknx.util. Its only member wasasyncio_timeout- a backport ofasyncio.timeoutfor Python versions not providing it - so useasyncio.timeoutdirectly. -
RawValuetakespayload_lengthas its first argument afterxknx, beforename-nameis optional now and Python doesn't allow a required argument after an optional one.RawValue(xknx, "Name", 2, ...)becomesRawValue(xknx, 2, "Name", ...). -
Sensor,NumericValueandExposeSensortakevalue_typeas their first argument afterxknx, beforename, and require it - likepayload_lengthofRawValueabove.Sensor(xknx, "Name", value_type="temperature")becomesSensor(xknx, "temperature", "Name").value_typenever had a usable default: passingNoneraisedConversionError.Notificationkeeps its optionalvalue_type, now defaulting to"string"instead ofNone. -
RemoteValueSensor,RemoteValueNumericandRemoteValueStringtakevalue_typeas their first argument afterxknxand require it.RemoteValueStringno longer defaults toDPTString; passvalue_type="string"explicitly. -
Every argument of a device after
xknxis keyword-only now.Switch(xknx, "Kitchen", "1/2/3")becomesSwitch(xknx, name="Kitchen", group_address="1/2/3"). Devices carry a lot of similar looking arguments - most of them group addresses - so a positional call was easy to get subtly wrong, and it pinned the argument order as public API. The argument order named in the entries above is therefore only about the signature; it doesn't affect how a device is constructed anymore. -
Remove
ha_device_classfromDPTBasesubclasses,RemoteValueSensor,RemoteValueByLength,SensorandNumericValue. Home Assistant shall maintain this itself. -
Remove
RemoteValue.__eq__(). It compared__dict__attributes - a leftover of the YAML config handling removed in 1.0 - and raisedAttributeErrorwhen compared to an object without a__dict__. Remote values compare by identity now, which also makes them hashable again, so they can be used in sets and as dict keys. -
All classes in
xknx.remote_valuedefine__slots__now. Setting an attribute that isn't declared by the class raisesAttributeErrorinstead of silently creating it.rv_1 = RemoteValueSwitch(xknx, group_address="1/2/3") rv_2 = RemoteValueSwitch(xknx, group_address="1/2/3") rv_1 == rv_2 # before: True (equal attributes); now: False (distinct objects) hash(rv_1) # before: TypeError: unhashable type; now: works rv_1.my_own_attribute = 1 # before: works; now: AttributeError
-
Remove
Device.__eq__()- same reasoning asRemoteValue.__eq__()above: it compared__dict__attributes and was a leftover of the YAML config handling removed in 1.0. Devices compare by identity now, which also makesDevicehashable again, so devices can be used in sets and as dict keys. The same applies toLight.red,.green,.blueand.white. -
Remove device lookup by name or index from
xknx.devices.xknx.devices["NameOfDevice"]andxknx.devices[0]are gone - device names are never checked for uniqueness, so the lookup could return any one of several devices sharing a name. Keep a reference to the device object instead, or iteratexknx.devicesto find it.device in xknx.devicestakes theDeviceobject now instead of its name. -
Devices.async_add()andDevices.async_remove()raiseValueErrorwhen the device object is already registered, or not registered at all. Adding a device twice had it receive every telegram twice and fire its callbacks twice; removing an unregistered one cancelled its tasks and unregistered its state updater before failing. -
Scene.scene_valueis aRemoteValueSceneControl(DPT 18.001) instead of aRemoteValueSceneNumber(DPT 17.001), so its value carries the learn bit next to the scene number. Telegrams on the wire are unchanged: DPT 18.001 encodes an activation to the same octet DPT 17.001 does, and decodes one back the same way. The device callback is called for received learn telegrams of the devicesscene_numbernow, not only for activations - the newScene.learn_requestedtells both apart. -
DPT 9.xxx:
value_maxis670433.28for all 2 byte float types - it was670760.96onDPT2ByteFloatand670760on most subtypes, which encode to0x7FFF. KNX v02.02.01 - Datapoint Types 03.07.02 - §3.10 reserves that pattern: "For all Datapoint Types 9.xxx, the encoded value 7FFFh shall always be used to denote invalid data", and gives the range as[-671 088,64 ... 670 433,28]. So xknx sent the invalid-data pattern as a regular value and decoded a received one to670760.96instead of rejecting it -from_knx()raisesConversionErroron0x7FFFnow, andto_knx()rejects anything above670433.28. A device sending0x7FFF- a thermostat with an unconfigured setpoint for example - is reported as a conversion error instead of a nonsensical reading. -
DPT 9.xxx lower bounds follow the same table:
DPTTemperatureDifference2Byte(9.002),DPTTemperatureA(9.003),DPTTime1(9.010) andDPTTime2(9.011) accept-671088.64instead of-670760, andDPTPartsPerMillion(9.008) - specified as0 ppm ... 670 433,28 ppm- rejects negative values now like the other subtypes with a0minimum do. -
Rename
xknx.management.management.MANAGAMENT_ACK_TIMEOUTandMANAGAMENT_CONNECTION_TIMEOUTtoMANAGEMENT_ACK_TIMEOUTandMANAGEMENT_CONNECTION_TIMEOUT- they were misspelled. -
Remove
nm_invididual_address_writefromxknx.management.procedures- the misspelled alias ofnm_individual_address_write, deprecated since 3.18.0. Usenm_individual_address_writeinstead. -
Remove
xknx.management.application_layer_enum. The module only re-exportedReturnCode, which moved toxknx.telegram.apciin 3.17.0 and has been deprecated there since. ImportReturnCodefromxknx.telegram.apci.
Features
-
Add a command line interface. Installing xknx puts an
xknxcommand on the path (also available viapython -m xknx) providingscan,group read,group writeandgroup monitorcommands. Seexknx --help. -
Devices tell the cause of their latest update in
Device.last_update, set before device updated callbacks are called.DeviceUpdate.telegramis the incoming or outgoing telegram that caused the update. Updates run later from a task keep the telegram that started it - eg. the periodic travel updates of aCover, debounced individual color updates of aLightor the counter updates of aBinarySensoraftercontext_timeout. It isNonefor updates not caused by a telegram, likeRemoteValue.update_value(). Work scheduled from a device updated callback doesn't inherit the cause - an application action started from there is attributed to its owntelegram_context(), not to the telegram that triggered the callback.last_updatealways describes the latest update, so copy it inside the callback if work scheduled from there needs the cause of this specific update. -
Telegram.contextcarries an application defined object identifying the origin of a telegram. xknx never reads it. Outgoing telegrams created insidewith xknx.telegram.telegram_context(context):- or in tasks started from it, like the auto stop of aCover- carrycontext. For incoming telegrams it may be set by a telegram received callback, which runs before devices process the telegram.DeviceUpdate.contextis the context of the causing telegram, or of the activetelegram_context()for updates not caused by a telegram. This lets an application relate a device update to its own action or to the received telegram - eg. to show which KNX device, or which user, changed a state.with telegram_context(my_context): await light.set_on() def light_updated_cb(light: Light) -> None: light.last_update.context # my_context - once the outgoing telegram was processed light.last_update.telegram # the outgoing telegram
Devices
-
The
nameof a device is optional now and can be changed after instantiation. When no name is given it defaults to the class name of the device -Sensor,NumericValueandExposeSensorappend their value type, eg."Sensor temperature". AssigningDevice.namepasses the new name down to the devicesRemoteValueinstances, so log messages and exceptions use it too - previously the name was only copied to the remote values on instantiation and assigning a new one left them stale. Renaming aClimaterenames itsmodedevice as well.light = Light(xknx, group_address_switch="1/2/3") light.name # "Light" light.name = "light.kitchen_ceiling" # eg. in Home Assistants `async_added_to_hass()`
-
Scene: add
learn()to send a telegram with the learn bit set, telling actuators to store their current state as this scene. Received learn telegrams are decoded instead of logging a "Can not process" warning, so a Scene can serve as scene actuator: restore its state from the device callback whenlearn_requestedisFalse, store it when it isTrue.
Management
- Add
dmp_interface_object_read_r,dmp_interface_object_write_r,dmp_interface_object_scan_randdmp_interface_object_verify_rtoxknx.management.procedures- KNX v02.01.02 - Management Procedures 03.05.02 - §3.27.2/§3.25.2/§3.28.2/§3.26.2, reading, writing, discovering and verifying Properties of a device's Interface Objects on an already-openP2PConnection. Read, write and verify split a request into as manyA_PropertyValue_Read/_Writeexchanges as it needs, bounded both by the 4-bitnr_of_elemfield and bymax_apdu_length- the device'sPID_MAX_APDU_LENGTH, defaulting to the 15 octets of an L_Data_Standard frame.read_ralso serves thestart_index=0element count query. A read-back that doesn't match raises the newPropertyVerificationError;scan_rreturns a list ofScannedInterfaceObject. - Add
dmp_mem_read_r_co,dmp_mem_write_r_coanddmp_mem_verify_r_cotoxknx.management.procedures— KNX v02.01.02 - Management Procedures 03.05.02 - §3.18.2/§3.16.2/§3.17.2, reading, writing and verifying a device's 64 KiBA_Memory_*address space over an already-openP2PConnection. Adddmp_user_mem_read_r_co,dmp_user_mem_write_r_coanddmp_user_mem_verify_r_co— §3.21.2/§3.19.2/§3.20.2, the same three operations for the 1 MiBA_UserMemory_*address space. All six transparently chunk transfers exceeding the wire APDU length (12/11 octets respectively for the default 15 octet frame, or the count field's own 63/15-element limit if that binds first at a larger negotiated APDU length), validate the address range doesn't overflow before sending anything, and check each response's echoed address matches what was requested. The write procedures take an optionalverifyto read back and compare each chunk immediately instead of leaving a caller-suppliedwrite_delayfor the device to finish programming, sinceA_Memory_Write/A_UserMemory_Writeare not confirmed at the application layer; a mismatch raises the newVerificationError(PropertyVerificationError's new, more general parent, so both families can be caught uniformly). dm_restart_r_coanddm_restartsupport a Master Reset now, viamaster_reset=True,erase_codeandchannel_number- KNX v02.01.02 - Management Procedures 03.05.02 - §3.7.3 DM_Restart_RCo, which covers both restart kinds as one Management Procedure distinguished by itsmpp_RestartTypeparameter. Unlike a Basic Restart, a Master Reset is confirmed at the application layer by anA_Restart_Response-PDU, so both functions return the parsedapci.RestartMasterResetResponsefor a Master Reset, orNonefor a Basic Restart as before; a non-zero error code raisesManagementConnectionError.erase_codedefaults to 01h "Confirmed Restart" and is validated to 01h-08h (KNX v02.01.02 - Management Procedures 03.05.02 - §3.7.1.2.3.1, Table 4 - the only defined values; 00h and 09h-FFh are reserved and a Management Client must not send them). The response'sprocess_timeis not merely informational - the spec mandates it as the minimum wait before the Management Client may retry communication.
Connection
- Threaded interface: a request still pending when the interface is stopped raises
CommunicationErrorinstead of never returning.stop()stopped the connection thread's loop without finishing the tasks on it, so the caller - and the executor thread blocked waiting for the result - hung forever, holding up the shutdown of the host application. Results from the connection thread are awaited withasyncio.wrap_future()now, so no executor thread is blocked, and cancelling the caller also cancels the request on the connection thread. - Threaded interface: cancelling
start()- byasyncio.timeoutfor example - stops the connection thread it already started instead of leaving it running. The connection thread's event loop is closed when the interface is stopped, andstop()waits for the thread to end in an executor instead of blocking the calling event loop. - KNX IP Secure transports discard unencrypted frames instead of passing them to their callbacks. A secure session accepts a plain frame only for the handshake -
SessionRequestoutgoing,SessionResponseincoming - and raisesIPSecureErrorwhen anything else is sent before the session is initialized. Secure routing keeps forwarding plain discovery and self description frames (SearchRequest,SearchResponse,DescriptionRequestandDescriptionResponse, extended variants included) since these services are never secured and share the multicast endpoint, but now drops every other plain frame - previously onlyRoutingIndicationwas dropped, so a plainRoutingBusyfrom any sender could still throttle outgoing telegrams. Frames that may not be encapsulated at all - a nestedSecureWrapperand the Remote Configuration and Diagnosis service family - are discarded when received inside aSecureWrapper.
Bugfixes
- Management: don't acknowledge a connected telegram from a device xknx has no open point-to-point connection with. The acknowledgement was sent before the connection was looked up, so a device that was not talking to us received a
T_Ackand was then reported as "No active point-to-point connection for received telegram". KNX v01.02.03 - Transport Layer 03.03.04 - §5.4: no style of the connection oriented state machine acknowledges anything while CLOSED - including the styles that accept incoming connections. - Management: only telegrams of a point-to-point connection are handled by an open connection to their sender. The sender address alone decided that before, so an unnumbered telegram from a device we happened to hold a connection with - a broadcast response for example - was acknowledged, never reached the broadcast context it was meant for, and could be mistaken for the response of a pending point-to-point request.
- Management: a repeated sequence number is acknowledged again instead of being acknowledged and dropped, and a sequence number that is neither the expected nor the previous one is answered with a negative acknowledgement instead of a positive one - KNX v01.02.03 - Transport Layer 03.03.04 - §5.3 A3 and A4.
apci.PropertyValueWriteis anAPCIRequest[PropertyValueResponse]now, likePropertyValueReadalready was. It carries a KNX-spec-defined response (KNX v02.01.01 - Application Layer 03.03.07 - §3.4.4.2) and was missed when the other point-to-point request services were converted, soP2PConnection.request()sent it without verifying or narrowing the response type.apci.PropertyValueRead,PropertyValueWriteandPropertyValueResponseraiseConversionErrorfor astart_indexabove 4095 instead of encoding it into the neighbouringnr_of_elemfield -to_knx()silently emitted a frame addressing a different element with a different count.
Internals
Telegramis now generic over itspayloadtype (Telegram[GroupValueWrite], etc.), defaulting toTelegrambehaving exactly as before when left unparametrized -payloadisNoneonly for that default/unparametrized case (control telegrams like ACK/Disconnect); parametrized asTelegram[SomeAPCI],payloadisSomeAPCI, neverNone.Device.process()now handsprocess_group_write()/process_group_response()/process_group_read()aTelegramnarrowed to the APCI type it already verified viaisinstance, propagated throughRemoteValue.process()and every device'sprocess_group_*override. No behavior change -RemoteValue.process()keeps its ownisinstancecheck since, unlike the management case below, nothing enforces the payload type before it's called directly.- Add
APCIRequesttoxknx.telegram.apci- anAPCIsubclass carrying the KNX-spec-defined response type of a request service as its type argument, eg.class MemoryRead(APCIRequest[MemoryResponse]).RESPONSE_TYPEis derived from that argument. All 21 point-to-point request services with a spec-defined response were converted; group communication services (GroupValueRead,GroupPropValueRead) stay plainAPCIsince they are never sent viaP2PConnection.request().request()no longer takes anexpected=argument - it infers and verifies the expected response from the payload's type and returns the correspondingly typedTelegram[ResponseType], so procedures no longer needassert isinstance(response.payload, ResponseType)(or even aNonecheck) to get a typed.payload. - Add
APCIBroadcastRequesttoxknx.telegram.apci- the broadcast counterpart ofAPCIRequest, carrying its response type the same way.IndividualAddressRead,IndividualAddressSerialRead,DomainAddressRead,DomainAddressSerialNumberRead,NetworkParameterReadandSystemNetworkParameterReadderive from it. It is a sibling ofAPCIRequest, not a subclass, so a broadcast service can not be passed toP2PConnection.request()- it would be sent over a point-to-point connection and time out. RequestResponseis now generic over the response body it awaits, eg.class Connect(RequestResponse[ConnectResponse]).start()gives way torequest(), which returns that response instead of leaving it on the instance, and raises the newRequestResponseErrorwhen none arrived or the server answered with an error status.DescriptionQueryandSearchExtendedQueryderive fromRequestResponsenow. Theirstart()andgateway_descriptorattribute are replaced byrequest_gateway_descriptor().- Broadcast communication is encapsulated in a
Broadcastclass, reached asxknx.management.broadcast.Management.send_broadcast()becomesxknx.management.broadcast.send()and theManagement.broadcast()context manager becomesxknx.management.broadcast.context(). - Add
Broadcast.request()- broadcasts a request service and yields the responses to it, eg.async for telegram in xknx.management.broadcast.request(apci.IndividualAddressRead(), timeout=3). It replaces the context,send()andreceive()trio for the common case, listening for the response type the payload declares and narrowing each telegram to it. Any number of devices may answer, so it yields untiltimeoutelapses rather than returning a single telegram likeP2PConnection.request(). BroadcastContext.receive()takes the APCI class to listen for, eg.bc_context.receive(apci.IndividualAddressResponse, timeout=3), and yields only telegrams carrying it, narrowed toTelegram[ThatAPCI], instead of everything on the broadcast channel. Called without an argument it behaves as before.BroadcastContext.receive()'s timeout is armed around the wait for the next telegram instead of around the whole iteration. Previously a caller leaving the loop early - after finding the response it was looking for - left the timeout armed on the abandoned generator, cancelling that caller once it elapsed.timeoutstill bounds the whole iteration.- Add
xknx.exceptions.VerificationError, a new parent ofPropertyVerificationErrorfor "the device's read-back data doesn't match what was expected" independent of which service produced it.dmp_mem_write_r_co/dmp_user_mem_write_r_co'sverifyanddmp_mem_verify_r_co/dmp_user_mem_verify_r_coraise it directly, so a caller can catchVerificationErroruniformly across Property and Memory operations instead ofPropertyVerificationErroronly matching the former. Non-breaking: existing code catchingPropertyVerificationErrororManagementConnectionErroris unaffected. CEMILData.flagsis aCEMIFlagsdataclass now instead of a 16 bitint, with a field per control field value:priority(newCEMIPriorityenum),repeat_on_error,system_broadcast(both named for the positive meaning; inverted on the wire),acknowledge_request,confirm_error,hop_count- which replaces the removedCEMILData.hopsproperty - and the receivedframe_type/frame_format. Frame Type and Address Type are derived when serializing, from the NPDU length and from the type of the destination address (CEMILData.address_type), soflagscan no longer disagree with the frame that is put on the wire.CEMILData(flags=...)is optional now. The bit constants moved fromCEMIFlagstoxknx.cemi.flags.xknx.secure.data_secure_asdu.block_0(),SecureData.init_from_plain_apdu()andSecureData.get_plain_apdu()takeaddress_type: CEMIAddressTypeandframe_format: CEMIFrameFormatinstead offrame_flags: int. Only those two fields of Ctrl2 ever reached the CCM input; the value on the wire and the one fed to the MAC now come from the same place.Deviceskeeps a group address index of its registered devices instead of scanning every device on every incoming telegram.Devices.devices_by_group_address()is a dict lookup now - its result is unchanged, devices are still returned in registration order and a device carrying one group address on several of itsRemoteValues is still returned once. This relies on a devices group addresses being fixed when itsRemoteValues are created, which the library guarantees - assigningRemoteValue.group_addressafterDevices.async_add()was never supported and would now leave the index stale.- Dependencies are declared in
pyproject.tomlonly - the library's own in[project.dependencies], the development tooling in thedevgroup of[dependency-groups]- and pinned, including transitive ones, inuv.lock. Therequirements/directory andtox.iniare removed; contributors need uv now:uv syncto set up,uv run pytestto test. - Git hooks are run by prek instead of pre-commit, from the same
.pre-commit-config.yaml. Install them withuv run prek install, run them withuv run prek run --all-files. ruff, ruff format, mypy and pylint are local hooks executed viauv run --frozen, so their versions come fromuv.lockalone - ruff is no longer pinned a second time in the hook config.script/run-in-env.shis removed with them. Thecheck-jsonhook is dropped - the repository tracks no JSON files. - Releases are built with
uv buildand uploaded bypypa/gh-action-pypi-publishusing PyPI Trusted Publishing instead oftwinewith a stored username and password. Distributions now carry PEP 740 attestations. - Add support and test for Python 3.15.
Thank you
A big thank you to everyone who contributed code to xknx since 3.0.0:
@alexkn, @cpha-trifork, @enkore, @farmio, @kewde, @martinhoefling, @NHanser, @opieters, @pdgendt, @philippwaller, @richardpolzer and @somdoron