Skip to content

Position.position_percent returns 124 for a non-position raw, surfacing as current_position = -24 after a KLF200 reconnect #701

Description

@fireto

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 0xF7000xF8FF 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions