Skip to content

Commit 9c1919a

Browse files
committed
adds missing files
1 parent 1740d96 commit 9c1919a

2 files changed

Lines changed: 405 additions & 0 deletions

File tree

src/artisanlib/orbiter.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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

Comments
 (0)