Summary
After every KLF200 reconnect, all six of my cover nodes report
current_position = -24 in Home Assistant, and they hold it until the node
next reports a real position — 33 s in one case, nearly 2 h in another.
-24 is the arithmetic signature of a non-position raw reaching
100 - position_percent:
>>> raw = bytes([0xF7, 0xFF]) # Parameter.UNKNOWN_VALUE
>>> int(raw[0] / 2 + 0.5) # Position.to_percent
124
>>> 100 - 124 # homeassistant/components/velux/cover.py
-24
to_percent() reads only raw[0], so every raw from 0xF700 to 0xF8FF
maps to 124. All of them are above Parameter.MAX (0xC800), so none of them
is a position.
The other sentinels leak the same way, just less visibly: CURRENT (0xD200)
and TARGET (0xD100) give position_percent = 105, and IGNORE (0xD400)
gives 106.
Environment
- Home Assistant 2026.8.1, which pins
pyvlx==0.2.36
- KLF200 gateway, 6 exterior venetian blind nodes (Somfy io),
device_class: blind
What I measured
A sweep of 10 days of Home Assistant state history, 2026-08-07 to 2026-08-17:
- 36 out-of-range readings, every one exactly
-24
- each one directly follows the entity going
unavailable and back, that is, a
KLF200 reconnect
- all 6 nodes inside the same 100 ms
- 6 reconnects in the window, so 6 x 6 = 36
- no other out-of-range value appears at all
How long the value survives depends only on when something next moves:
| reconnect |
-24 persisted |
| 2026-08-12 13:09 |
33 s — a cover moved right afterwards |
| 2026-08-17 08:16 |
about 1 h 49 min |
known was True at the time
homeassistant/components/velux/cover.py already guards both
current_cover_position and is_closed with if not position.known: return None, so on the path I can read this should surface as None, not as -24.
It does not. At the moment -24 was recorded the entity state was open, not
unknown. So is_closed returned False, which means position.known was
True. Since known compares against one single value:
@property
def known(self) -> bool:
return self.raw != self.from_int(Position.UNKNOWN_VALUE)
the raw must be inside 0xF700–0xF8FF but not exactly 0xF7FF.
I could not identify which path seeds such a raw, and I do not want to guess
in a bug report. Parameter.__init__ routes through from_raw(), which maps
anything above MAX to UNKNOWN_VALUE, and NodeUpdater skips non-concrete
positions through _is_concrete_position(). The Position.position int setter
does not validate, so that is the shape of path I would look at first, but I
have not proven it. I am happy to run a patched build or turn on pyvlx debug
logging and wait for the next reconnect, which happens every few days here.
Why this is worse than an unknown
The value is not only cosmetic. It is a plausible number that compares as
smaller than any real position, so it passes range tests that None would
fail. A Home Assistant automation of mine waited for the blinds to shut with:
{{ state_attr('cover.x','current_position') | int(100) <= 2 }}
-24 satisfies that at once, so the automation carried on while the blinds
were still travelling and set the slat tilt at the wrong moment. With None
the int(100) default would have caught it and the wait would have held.
Suggested direction
Stop position_percent from returning a percentage for a raw that is not a
position, so the value cannot leak whatever path produced the raw. Either raise,
or return None, or widen known to the whole non-concrete range:
@property
def known(self) -> bool:
return self.to_int(self.raw) <= Parameter.MAX
That last one would make the guard that Home Assistant already has fire for
every non-concrete raw instead of only the exact sentinel.
One caveat on that sketch: CURRENT, TARGET and IGNORE are command
sentinels as well as read values, so widening known may affect code that
sends them. You will know better than I do whether the guard belongs in known
or in position_percent.
Summary
After every KLF200 reconnect, all six of my cover nodes report
current_position = -24in Home Assistant, and they hold it until the nodenext reports a real position — 33 s in one case, nearly 2 h in another.
-24is the arithmetic signature of a non-position raw reaching100 - position_percent:to_percent()reads onlyraw[0], so every raw from0xF700to0xF8FFmaps to 124. All of them are above
Parameter.MAX(0xC800), so none of themis a position.
The other sentinels leak the same way, just less visibly:
CURRENT(0xD200)and
TARGET(0xD100) giveposition_percent = 105, andIGNORE(0xD400)gives 106.
Environment
pyvlx==0.2.36device_class: blindWhat I measured
A sweep of 10 days of Home Assistant state history, 2026-08-07 to 2026-08-17:
-24unavailableand back, that is, aKLF200 reconnect
How long the value survives depends only on when something next moves:
-24persistedknownwas True at the timehomeassistant/components/velux/cover.pyalready guards bothcurrent_cover_positionandis_closedwithif not position.known: return None, so on the path I can read this should surface asNone, not as-24.It does not. At the moment
-24was recorded the entity state wasopen, notunknown. Sois_closedreturnedFalse, which meansposition.knownwasTrue. Sinceknowncompares against one single value:the raw must be inside
0xF700–0xF8FFbut not exactly0xF7FF.I could not identify which path seeds such a raw, and I do not want to guess
in a bug report.
Parameter.__init__routes throughfrom_raw(), which mapsanything above
MAXtoUNKNOWN_VALUE, andNodeUpdaterskips non-concretepositions through
_is_concrete_position(). ThePosition.positionint setterdoes not validate, so that is the shape of path I would look at first, but I
have not proven it. I am happy to run a patched build or turn on
pyvlxdebuglogging and wait for the next reconnect, which happens every few days here.
Why this is worse than an unknown
The value is not only cosmetic. It is a plausible number that compares as
smaller than any real position, so it passes range tests that
Nonewouldfail. A Home Assistant automation of mine waited for the blinds to shut with:
{{ state_attr('cover.x','current_position') | int(100) <= 2 }}-24satisfies that at once, so the automation carried on while the blindswere still travelling and set the slat tilt at the wrong moment. With
Nonethe
int(100)default would have caught it and the wait would have held.Suggested direction
Stop
position_percentfrom returning a percentage for a raw that is not aposition, so the value cannot leak whatever path produced the raw. Either raise,
or return
None, or widenknownto the whole non-concrete range:That last one would make the guard that Home Assistant already has fire for
every non-concrete raw instead of only the exact sentinel.
One caveat on that sketch:
CURRENT,TARGETandIGNOREare commandsentinels as well as read values, so widening
knownmay affect code thatsends them. You will know better than I do whether the guard belongs in
knownor in
position_percent.