|
| 1 | +# |
| 2 | +# ABOUT |
| 3 | +# Oribter support for Artisan |
| 4 | + |
| 5 | +# LICENSE |
| 6 | +# This program or module is free software: you can redistribute it and/or |
| 7 | +# modify it under the terms of the GNU General Public License as published |
| 8 | +# by the Free Software Foundation, either version 2 of the License, or |
| 9 | +# version 3 of the License, or (at your option) any later versison. It is |
| 10 | +# provided for educational purposes and is distributed in the hope that |
| 11 | +# it will be useful, but WITHOUT ANY WARRANTY; without even the implied |
| 12 | +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See |
| 13 | +# the GNU General Public License for more details. |
| 14 | + |
| 15 | +# AUTHOR |
| 16 | +# Marko Luther, 2026 |
| 17 | + |
| 18 | + |
| 19 | +import asyncio |
| 20 | +import logging |
| 21 | + |
| 22 | + |
| 23 | +from collections.abc import Callable |
| 24 | +from typing import override, Final, TypedDict, TYPE_CHECKING |
| 25 | + |
| 26 | +if TYPE_CHECKING: |
| 27 | + from artisanlib.atypes import SerialSettings # pylint: disable=unused-import |
| 28 | + |
| 29 | +from artisanlib.async_comm import AsyncComm, IteratorReader |
| 30 | + |
| 31 | +_log: Final[logging.Logger] = logging.getLogger(__name__) |
| 32 | + |
| 33 | + |
| 34 | + |
| 35 | + |
| 36 | +class State(TypedDict, total=False): |
| 37 | + Connected:int # connection status (0:disconnected, 1:connected) |
| 38 | + BT:float # bean temperature |
| 39 | + ET:float # environmental temperature |
| 40 | + IT:float # inlet temperature |
| 41 | + DT:float # drum temperature |
| 42 | + Air:float # air pressure |
| 43 | + Drum:int # drum speed (50-90 RPM) |
| 44 | + Damper:int # air damper setting (0-15) |
| 45 | + Heater:int # heater power (0-10; 0-3200W) |
| 46 | + Sound:int # |
| 47 | + RoR:float # rate-of-rise |
| 48 | + |
| 49 | + |
| 50 | + |
| 51 | + |
| 52 | +class Orbiter(AsyncComm): |
| 53 | + |
| 54 | + HEADER:Final[bytes] = b'\xFF\xFF' |
| 55 | + TIME:Final[bytes] = b'\x00\x00' |
| 56 | + EVENT:Final[bytes] = b'\x00' |
| 57 | + CMD_INIT:Final[bytes] = b'\x01' |
| 58 | + CMD_SYNC:Final[bytes] = b'\x00' |
| 59 | + |
| 60 | + __slots__ = [ 'send_timeout', '_logging', '_connected', '_new_readings_available', '_BT', '_ET', '_IT', '_DT', '_air', '_drum', '_damper', |
| 61 | + '_heater', '_sound', '_RoR', '_master_control', |
| 62 | + '_SERIAL', '_FW_VERSION', '_PCB_VERSION', '_DASHBOARD_STATUS', '_MODEL', '_MODEL_NUM' ] |
| 63 | + |
| 64 | + def __init__(self, serial:'SerialSettings', |
| 65 | + connected_handler:Callable[[], None]|None = None, |
| 66 | + disconnected_handler:Callable[[], None]|None = None) -> None: |
| 67 | + |
| 68 | + super().__init__(serial=serial, connected_handler=connected_handler, disconnected_handler=disconnected_handler) |
| 69 | + |
| 70 | + # configuration |
| 71 | + self.send_timeout:Final[float] = 0.6 # in seconds |
| 72 | + self._logging = False # if True device communication is logged |
| 73 | + |
| 74 | + # current readings |
| 75 | + self._connected:int = 0 # connection status (0:disconnected, 1:connected) |
| 76 | + self._new_readings_available:asyncio.Event = asyncio.Event() |
| 77 | + #- |
| 78 | + self._BT:float = -1 # bean temperature |
| 79 | + self._ET:float = -1 # environmental temperature |
| 80 | + self._IT:float = -1 # inlet temperature |
| 81 | + self._DT:float = -1 # drum temperature |
| 82 | + self._air:float = -1 # air pressure |
| 83 | + self._drum:int = -1 # drum speed (50-90 RPM) |
| 84 | + self._damper:int = -1 # air damper setting (0-15) |
| 85 | + self._heater:int = -1 # heater power (0-10; 0-3200W) |
| 86 | + self._sound:int = -1 # sound recognition |
| 87 | + self._RoR:float = -1 # rate-of-rise |
| 88 | + self._master_control:int = 0 # master control |
| 89 | + |
| 90 | + # machine spec |
| 91 | + self._SERIAL:str = '' # 7 bytes |
| 92 | + self._FW_VERSION:str = '' # 7 bytes |
| 93 | + self._PCB_VERSION:int = 0 # 1 byte |
| 94 | + self._DASHBOARD_STATUS:bytes = b'\x00\x00' # 2 bytes |
| 95 | + self._MODEL:str = '' # 2 bytes |
| 96 | + self._MODEL_NUM:int = 0 # 1 byte |
| 97 | + |
| 98 | + @override |
| 99 | + def setLogging(self, b:bool) -> None: |
| 100 | + self._logging = b |
| 101 | + super().setLogging(b) |
| 102 | + |
| 103 | + # external API to access machine state |
| 104 | + |
| 105 | + # getBT triggers fetching a complete set of new readings |
| 106 | + def getBT(self) -> float: |
| 107 | + if not self._new_readings_available.is_set(): |
| 108 | + self.send_sync_await() |
| 109 | + self._new_readings_available.clear() |
| 110 | + return self._BT |
| 111 | + def getET(self) -> float: |
| 112 | + return self._ET |
| 113 | + def getIT(self) -> float: |
| 114 | + return self._IT |
| 115 | + def getDT(self) -> float: |
| 116 | + return self._DT |
| 117 | + def getAir(self) -> float: |
| 118 | + return self._air |
| 119 | + def getDrum(self) -> int: |
| 120 | + return self._drum |
| 121 | + def getDamper(self) -> int: |
| 122 | + return self._damper |
| 123 | + def getHeater(self) -> int: |
| 124 | + return self._heater |
| 125 | + def getSound(self) -> int: |
| 126 | + return self._sound |
| 127 | + def getRoR(self) -> float: |
| 128 | + return self._RoR |
| 129 | + def getMasterControl(self) -> float: |
| 130 | + return self._master_control |
| 131 | + |
| 132 | + def resetReadings(self) -> None: |
| 133 | + self._connected = 0 |
| 134 | + self._BT = -1 |
| 135 | + self._ET = -1 |
| 136 | + self._IT = -1 |
| 137 | + self._DT = -1 |
| 138 | + self._air = -1 |
| 139 | + self._drum = -1 |
| 140 | + self._damper = -1 |
| 141 | + self._heater = -1 |
| 142 | + self._sound = -1 |
| 143 | + self._RoR = -1 |
| 144 | + |
| 145 | + |
| 146 | + # message decoder |
| 147 | + |
| 148 | + def register_reading(self, target:bytes, data:bytes) -> None: |
| 149 | + pass |
| 150 | + |
| 151 | + # asyncio read implementation |
| 152 | + |
| 153 | + # https://www.oreilly.com/library/view/using-asyncio-in/9781492075325/ch04.html |
| 154 | + @override |
| 155 | + async def read_msg(self, stream: asyncio.StreamReader|IteratorReader) -> None: |
| 156 | + # await first header byte |
| 157 | + await stream.readuntil(self.HEADER[0:1]) |
| 158 | + # check for the second header byte |
| 159 | + if await stream.readexactly(1) == self.HEADER[1:2]: |
| 160 | + cmd = await stream.readexactly(1) |
| 161 | + if cmd[0] == 0: # sync data (total 28 bytes) |
| 162 | + if self._logging: |
| 163 | + _log.debug('Orbiter CMD sync data') |
| 164 | + data = await stream.readexactly(25) |
| 165 | + if self._logging: |
| 166 | + _log.debug('Orbiter data: %s', data) |
| 167 | + # check CRC |
| 168 | + if self.crc(cmd[0:1] + data[:24]) == data[24]: |
| 169 | + try: |
| 170 | + self._BT = int.from_bytes(data[7:9], 'little', signed=True) |
| 171 | + self._IT = int.from_bytes(data[9:11], 'little', signed=True) |
| 172 | + self._DT = int.from_bytes(data[11:13], 'little', signed=True) |
| 173 | + self._ET = int.from_bytes(data[13:15], 'little', signed=True) |
| 174 | + self._RoR = int.from_bytes(data[15:17], 'little', signed=True) / 4. |
| 175 | + self._air = int.from_bytes(data[17:19], 'little', signed=True) |
| 176 | + self._heater = data[19] |
| 177 | + self._drum = data[20] |
| 178 | + self._damper = data[21] |
| 179 | + self._sound = data[22] |
| 180 | + self._master_control = data[23] |
| 181 | + self._new_readings_available.set() |
| 182 | + except Exception as e: |
| 183 | + _log.exception(e) |
| 184 | + else: |
| 185 | + _log.debug('Orbiter CRC failed') |
| 186 | + elif cmd[0] == 1: # init ack (total 28 bytes) |
| 187 | + if self._logging: |
| 188 | + _log.debug('Orbiter CMD init ack') |
| 189 | + data = await stream.readexactly(25) |
| 190 | + if self._logging: |
| 191 | + _log.debug('Orbiter ack init data: %s', data) |
| 192 | + # check CRC |
| 193 | + if self.crc(cmd[0:1] + data[:24]) == data[24]: |
| 194 | + self._SERIAL = data[1:8].decode('ascii') |
| 195 | + self._FW_VERSION = data[8:15].decode('ascii') |
| 196 | + self._PCB_VERSION = data[15] |
| 197 | + self._DASHBOARD_STATUS = data[17:19] |
| 198 | + self._MODEL = data[19:21].decode('ascii') |
| 199 | + self._MODEL_NUM = data[21] |
| 200 | + self._sound = data[22] |
| 201 | + self._master_control = data[23] |
| 202 | + _log.debug("Orbiter SERIAL: '%s'", self._SERIAL) |
| 203 | + _log.debug("Orbiter FW_VERSION: '%s'", self._FW_VERSION) |
| 204 | + _log.debug('Orbiter PCB_VERSION: %s', self._PCB_VERSION) |
| 205 | + _log.debug('Orbiter DASHBOARD_STATUS: %s', self._DASHBOARD_STATUS) |
| 206 | + _log.debug('Orbiter MODEL: %s', self._MODEL) |
| 207 | + _log.debug('Orbiter MODEL_NUM: %s', self._MODEL_NUM) |
| 208 | + _log.debug('Orbiter _sound: %s', self._sound) |
| 209 | + _log.debug('Orbiter _master_control: %s', self._master_control) |
| 210 | + else: |
| 211 | + _log.debug('Orbiter CRC failed: %s != %s', self.crc(cmd[0:1] + data[:24]), data[24]) |
| 212 | + |
| 213 | + |
| 214 | + # send message interface |
| 215 | + |
| 216 | + @staticmethod |
| 217 | + def crc(data:bytes) -> int: |
| 218 | + crc = 0 |
| 219 | + for b in data: |
| 220 | + crc = crc ^ b |
| 221 | + return crc |
| 222 | + |
| 223 | + # message encoder (HEADER-CMD-PARA-DATA-TIME-EVENT-CRC) |
| 224 | + def create_msg(self, cmd:bytes, data:bytes, param:bytes) -> bytes: |
| 225 | + if len(data) == 0: |
| 226 | + data = b'\x00\x00' |
| 227 | + if len(data) == 1: |
| 228 | + data = b'\x00' + data |
| 229 | + data = data[:2] # data is exactly 2 bytes |
| 230 | + payload = cmd[:1] + param + data + self.TIME + self.EVENT |
| 231 | + crc:int = self.crc(payload) |
| 232 | + return self.HEADER + payload + crc.to_bytes(1, 'little') |
| 233 | + |
| 234 | + # data byte order: LSB last (little-endian); eg. data=b'\x07\x00' equals 7 |
| 235 | + def send_msg(self, cmd:bytes, data:bytes = b'\x00\x00', param:bytes = b'\x00') -> None: |
| 236 | + # send via socket |
| 237 | + self.send(self.create_msg(cmd, data, param)) |
| 238 | + |
| 239 | + def send_msg_await(self, event:asyncio.Event, timeout:float, cmd:bytes, data:bytes = b'\x00\x00', param:bytes = b'\x00') -> None: |
| 240 | + # send via socket |
| 241 | + self.send_await(self.create_msg(cmd, data, param), event, timeout) |
| 242 | + |
| 243 | + # |
| 244 | + |
| 245 | + def send_init(self) -> None: |
| 246 | + self.send_msg(self.CMD_INIT) |
| 247 | + |
| 248 | + def send_sync(self) -> None: |
| 249 | + self.send_msg(self.CMD_SYNC) |
| 250 | + |
| 251 | + def send_sync_await(self) -> None: |
| 252 | + self.send_msg_await(self._new_readings_available, self.send_timeout, self.CMD_SYNC) |
| 253 | + |
| 254 | + # |
| 255 | + |
| 256 | + @override |
| 257 | + def start(self, connect_timeout:float=5) -> None: |
| 258 | + super().start(connect_timeout) |
| 259 | + |
| 260 | +# @override |
| 261 | +# def stop(self) -> None: |
| 262 | +# super().stop() |
| 263 | + |
| 264 | + |
| 265 | +def main() -> None: |
| 266 | + import time |
| 267 | + from artisanlib.atypes import SerialSettings |
| 268 | + serial = SerialSettings( |
| 269 | + port = '/dev/slave', |
| 270 | + baudrate = 115200, |
| 271 | + bytesize = 8, |
| 272 | + stopbits = 1, |
| 273 | + parity = 'N', |
| 274 | + timeout = 0.5) |
| 275 | + orbiter = Orbiter(serial) |
| 276 | + orbiter.start() |
| 277 | + for _ in range(4): |
| 278 | + print('>>> hallo') |
| 279 | + val:int = 7 |
| 280 | + orbiter.send_msg(b'\x0D', val.to_bytes(2, 'little')) # set power to 7 |
| 281 | + time.sleep(1) |
| 282 | + print('BT', orbiter.getBT()) |
| 283 | + time.sleep(1) |
| 284 | + orbiter.stop() |
| 285 | + time.sleep(1) |
| 286 | + #print('thread alive?',orbiter._thread.is_alive()) |
| 287 | + |
| 288 | +if __name__ == '__main__': |
| 289 | + main() |
0 commit comments