Manual fixes to Python file

To see if I like the rules
This commit is contained in:
Geoffrey Frogeye 2025-05-08 18:29:03 +02:00
parent 34b545890d
commit 8179433c41
10 changed files with 612 additions and 547 deletions

272
curacao/desk/desk_mqtt.py Executable file → Normal file
View file

@ -12,7 +12,9 @@ import usb.util
class Desk:
"""
Controls my Linak desk, which is a CBD4P controller connected via USB2LIN06
Controls my Linak desk.
It is a CBD4P controller connected via USB2LIN06.
This particular combination doesn't seem to report desk height,
so it is estimated from the physical controller that does work.
"""
@ -69,24 +71,27 @@ class Desk:
# Better estimate a bit slower
SPEED = (VALUE_TOP - VALUE_BOT) / FULL_TIME * SPEED_MARGIN # unit / s
def _cmToUnit(self, height: float) -> int:
HEADER_STRUCT = struct.Struct("<BB")
REPORT_STRUCT = struct.Struct("<HH14xH36x")
def _cm_to_unit(self, height: float) -> int:
return round((height - self.HEIGHT_OFFSET) * self.HEIGHT_MULT)
def _unitToCm(self, height: int) -> float:
def _unit_to_cm(self, height: int) -> float:
return height / self.HEIGHT_MULT + self.HEIGHT_OFFSET
def _get(self, typ: int, overflow_ok: bool = False) -> bytes:
def _get(self, typ: int) -> bytes:
# Magic numbers: get class interface, HID get report
raw = self._dev.ctrl_transfer(
0xA1, 0x01, 0x300 + typ, 0, self.BUF_LEN
).tobytes()
self.log.debug(f"Received {raw.hex()}")
assert raw[0] == typ
size = raw[1]
end = 2 + size
if not overflow_ok:
assert end < self.BUF_LEN
return raw[2:end]
self.log.debug("Received %s", raw.hex())
typ, size = self.HEADER_STRUCT.unpack(raw[: self.HEADER_STRUCT.size])
start = self.HEADER_STRUCT.size
end = self.HEADER_STRUCT.size + size
if end >= self.BUF_LEN:
raise OverflowError
return raw[start:end]
# Non-implemented types:
# 1, 7: some kind of stream when the device isn't initialized?
# size reduces the faster you poll, increases when buttons are held
@ -96,7 +101,7 @@ class Desk:
buf = bytes([typ]) + buf
# The official apps pad, not that it doesn't seem to work without
buf = buf + b"\x00" * (self.BUF_LEN - len(buf))
self.log.debug(f"Sending {buf.hex()}")
self.log.debug("Sending %s", buf.hex())
# Magic numbers: set class interface, HID set report
self._dev.ctrl_transfer(0x21, 0x09, 0x300 + typ, 0, buf)
# Non-implemented types:
@ -110,9 +115,11 @@ class Desk:
def _initialize(self) -> None:
"""
Seems to take the USB2LIN06 out of "boot mode"
(name according to CBD4 Controller) which it is after reset.
Seems to take the USB2LIN06 out of "boot mode".
It is like that after reset.
Permits control and reading the report.
(name according to CBD4 Controller)
"""
buf = bytes([0x04, 0x00, 0xFB])
self._set(3, buf)
@ -122,9 +129,8 @@ class Desk:
self.log = logging.getLogger("Desk")
self._dev = usb.core.find(idVendor=Desk.VEND, idProduct=Desk.PROD)
if not self._dev:
raise ValueError(
f"Device {Desk.VEND}:" f"{Desk.PROD:04d} " f"not found!"
)
msg = f"Device {Desk.VEND}: {Desk.PROD:04d} not found!"
raise ValueError(msg)
if self._dev.is_kernel_driver_active(0):
self._dev.detach_kernel_driver(0)
@ -136,9 +142,7 @@ class Desk:
self.fetch_callback: typing.Callable[[Desk], None] | None = None
def _get_report(self) -> bytes:
raw = self._get(4)
assert len(raw) == 0x38
return raw
return self._get(4)
def _update_estimations(self) -> None:
now = time.time()
@ -192,23 +196,19 @@ class Desk:
try:
raw = self._get_report()
break
except usb.USBError as e:
self.log.error(e)
except usb.USBError:
self.log.exception("USB issue")
else:
raw = self._get_report()
# Allegedly, from decompiling:
# https://www.linak-us.com/products/controls/desk-control-basic-software/
# Never reports anything in practice
self.value = struct.unpack("<H", raw[0:2])[0]
unk = struct.unpack("<H", raw[2:4])[0]
# Most values are from examining the basic software.
# Never reports anything in practice.
# Destination is from observation.
self.value, unk, self.destination = self.REPORT_STRUCT.unpack(raw)
self.initalized = (unk & 0xF) != 0
# From observation. Reliable
self.destination = (struct.unpack("<H", raw[18:20])[0],)[0]
if self.destination != self.last_destination:
self.log.info(f"Destination changed to {self.destination:04x}")
self.log.info("Destination changed to %04x", self.destination)
self.last_destination = self.destination
self._update_estimations()
@ -224,7 +224,7 @@ class Desk:
position = max(self.VALUE_BOT, position)
position = min(self.VALUE_TOP, position)
self.log.info(f"Start moving to {position:04x}")
self.log.info("Start moving to %04x", position)
self.fetch()
while self.est_value != position:
self._move(position)
@ -234,6 +234,8 @@ class Desk:
def move_to(self, position: float) -> None:
"""
Move the desk to the given position in cm.
If any button is held during movement, the desk will stop moving,
yet this will think it's still moving, throwing off the estimates.
It's not a bug, it's a safety feature.
@ -243,7 +245,7 @@ class Desk:
"""
# Would to stop for a while before reversing course, without being able
# to read the actual height it's just too annoying to implement
return self._move_to(self._cmToUnit(position))
return self._move_to(self._cm_to_unit(position))
def stop(self) -> None:
self.log.info("Stop moving")
@ -252,128 +254,144 @@ class Desk:
def get_height_bounds(self) -> tuple[float, float]:
return (
self._unitToCm(int(self.est_value_bot)),
self._unitToCm(int(self.est_value_top)),
self._unit_to_cm(int(self.est_value_bot)),
self._unit_to_cm(int(self.est_value_top)),
)
def get_height(self) -> float | None:
if self.est_value is None:
return None
return self._unitToCm(self.est_value)
return self._unit_to_cm(self.est_value)
if __name__ == "__main__":
logging.basicConfig()
log = logging.getLogger(__name__)
class App:
NDIGITS = 1
desk = Desk()
serial = "000C-34E7"
def __init__(self) -> None:
logging.basicConfig()
self.log = logging.getLogger(__name__)
# Configure the required parameters for the MQTT broker
mqtt_settings = ha_mqtt_discoverable.Settings.MQTT(host="192.168.7.53")
ndigits = 1
target_height: float | None = None
self.desk = Desk()
self.desk.fetch_callback = self.fetch_callback
serial = "000C-34E7"
device_info = ha_mqtt_discoverable.DeviceInfo(
name="Desk",
identifiers=["Linak", serial],
manufacturer="Linak",
model="CBD4P",
suggested_area="Desk",
hw_version="77402",
sw_version="1.91",
serial_number=serial,
)
# Configure the required parameters for the MQTT broker
mqtt_settings = ha_mqtt_discoverable.Settings.MQTT(host="192.168.7.53")
self.target_height: float | None = None
common_opts = {
"device": device_info,
"icon": "mdi:desk",
"unit_of_measurement": "cm",
"device_class": "distance",
"expire_after": 10,
}
# TODO Implement proper availability in hq-mqtt-discoverable
device_info = ha_mqtt_discoverable.DeviceInfo(
name="Desk",
identifiers=["Linak", serial],
manufacturer="Linak",
model="CBD4P",
suggested_area="Desk",
hw_version="77402",
sw_version="1.91",
serial_number=serial,
)
height_info = ha_mqtt_discoverable.sensors.NumberInfo(
name="Height ",
min=desk.HEIGHT_BOT,
max=desk.HEIGHT_TOP,
mode="slider",
step=10 ** (-ndigits),
unique_id="desk_height",
**common_opts,
)
height_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_info
)
common_opts = {
"device": device_info,
"icon": "mdi:desk",
"unit_of_measurement": "cm",
"device_class": "distance",
"expire_after": 10,
}
# TODO Implement proper availability in hq-mqtt-discoverable
def height_callback(
client: paho.mqtt.client.Client,
user_data: None,
message: paho.mqtt.client.MQTTMessage,
) -> None:
global target_height
target_height = float(message.payload.decode())
log.info(f"Requested height to {target_height:.1f}")
height_info = ha_mqtt_discoverable.sensors.NumberInfo(
name="Height ",
min=self.desk.HEIGHT_BOT,
max=self.desk.HEIGHT_TOP,
mode="slider",
step=10 ** (-self.NDIGITS),
unique_id="desk_height",
**common_opts,
)
height_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_info
)
height = ha_mqtt_discoverable.sensors.Number(
height_settings, height_callback
)
self.height = ha_mqtt_discoverable.sensors.Number(
height_settings, self.height_callback
)
height_max_info = ha_mqtt_discoverable.sensors.SensorInfo(
name="Estimated height max",
unique_id="desk_height_max",
entity_category="diagnostic",
**common_opts,
)
height_max_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_max_info
)
height_max = ha_mqtt_discoverable.sensors.Sensor(height_max_settings)
height_max_info = ha_mqtt_discoverable.sensors.SensorInfo(
name="Estimated height max",
unique_id="desk_height_max",
entity_category="diagnostic",
**common_opts,
)
height_max_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_max_info
)
self.height_max = ha_mqtt_discoverable.sensors.Sensor(
height_max_settings
)
height_min_info = ha_mqtt_discoverable.sensors.SensorInfo(
name="Estimated height min",
unique_id="desk_height_min",
entity_category="diagnostic",
**common_opts,
)
height_min_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_min_info
)
height_min = ha_mqtt_discoverable.sensors.Sensor(height_min_settings)
height_min_info = ha_mqtt_discoverable.sensors.SensorInfo(
name="Estimated height min",
unique_id="desk_height_min",
entity_category="diagnostic",
**common_opts,
)
height_min_settings = ha_mqtt_discoverable.Settings(
mqtt=mqtt_settings, entity=height_min_info
)
self.height_min = ha_mqtt_discoverable.sensors.Sensor(
height_min_settings
)
last_published_state = None
self.last_published_state: tuple[float | None, float, float] | None = (
None
)
def fetch_callback(desk: Desk) -> None:
def fetch_callback(self, desk: Desk) -> None:
hcur = desk.get_height()
hmin, hmax = desk.get_height_bounds()
global last_published_state
state = hcur, hmin, hmax
if state == last_published_state:
if state == self.last_published_state:
return
last_published_state = state
self.last_published_state = state
# If none this will set as unknown
# Also readings can be a bit outside the boundaries,
# so this skips verification
if isinstance(hcur, float):
hcur = round(hcur, ndigits=ndigits)
height._update_state(hcur)
hcur = round(hcur, ndigits=self.NDIGITS)
self.height._update_state(hcur) # noqa: SLF001
height_max._update_state(round(hmax, ndigits=ndigits))
height_min._update_state(round(hmin, ndigits=ndigits))
self.height_max._update_state( # noqa: SLF001
round(hmax, ndigits=self.NDIGITS),
)
self.height_min._update_state( # noqa: SLF001
round(hmin, ndigits=self.NDIGITS),
)
desk.fetch_callback = fetch_callback
def height_callback(
self,
_client: paho.mqtt.client.Client,
_user_data: None,
message: paho.mqtt.client.MQTTMessage,
) -> None:
self.target_height = float(message.payload.decode())
self.log.info("Requested height to %.1f", self.target_height)
interval = 0.2
# Need to be rective to catch
while True:
if target_height:
temp_target_height = target_height
# Allows queuing of other instructions while moving
target_height = None
desk.move_to(temp_target_height)
else:
time.sleep(interval)
desk.fetch()
def run(self) -> None:
interval = 0.2
# Need to be rective to catch
while True:
if self.target_height:
temp_target_height = self.target_height
# Allows queuing of other instructions while moving
self.target_height = None
self.desk.move_to(temp_target_height)
else:
time.sleep(interval)
self.desk.fetch()
if __name__ == "__main__":
app = App()
app.run()