Manual fixes to Python file
To see if I like the rules
This commit is contained in:
parent
34b545890d
commit
8179433c41
10 changed files with 612 additions and 547 deletions
272
curacao/desk/desk_mqtt.py
Executable file → Normal file
272
curacao/desk/desk_mqtt.py
Executable file → Normal 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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue