hilsim 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hilsim/__init__.py ADDED
File without changes
hilsim/cli/commands.py ADDED
@@ -0,0 +1,20 @@
1
+ import click
2
+ from hilsim.scaffold.engine import scaffold_project
3
+
4
+ @click.group()
5
+ def cli():
6
+ """hilsim: hardware-in-the-loop simulation framework"""
7
+ pass
8
+
9
+ @cli.command()
10
+ @click.argument("project_name")
11
+ def create(project_name):
12
+ """Scaffold a new hilsim project in the current directory"""
13
+ click.echo(f"Creating project '{project_name}'...")
14
+ try:
15
+ scaffold_project(project_name)
16
+ click.echo(f"Project '{project_name}' created successfully")
17
+ except FileExistsError:
18
+ """Note: engine won't create a project that already exists
19
+ (i.e. has the same name)"""
20
+ click.echo(f"Error: directory '{project_name}' already exists", err=True)
File without changes
@@ -0,0 +1,150 @@
1
+ """Define a world state class that serves as truth for all system variables"""
2
+
3
+ import inspect
4
+ import logging
5
+ from random import gauss, random, uniform
6
+ from typing import Any
7
+
8
+ from hilsim.core.controls import Controls
9
+ from hilsim.core.world_state import WorldState
10
+
11
+ logger = logging.getLogger(__name__)
12
+ #---------------------------------------------------------------------------------------
13
+ class ActuatorBase:
14
+ """Actuator Base Class to enforce actuator class standards"""
15
+
16
+ registry: dict[str, Any] = {}
17
+
18
+ def __init_subclass__(cls, **kwargs):
19
+ super().__init_subclass__(**kwargs)
20
+ ActuatorBase.registry[cls.__name__] = cls
21
+
22
+ def __init__(
23
+ self, actuator_config: dict, world_state: WorldState, controller: Controls
24
+ ) -> None:
25
+ self.dt: float = 0.0
26
+ self.cst: float = 0.0
27
+ self.time = world_state.state["time"]
28
+ self.world_state = world_state
29
+ self.failure_sim = actuator_config["failure_sim"]
30
+ self.settings = actuator_config["settings"]
31
+ self.effects = actuator_config["effects"]
32
+ self.sim = actuator_config["settings"]["sim"]
33
+ self.commanded_state = actuator_config["settings"]["initial_state"]
34
+ self.hardware_state = actuator_config["settings"]["initial_state"]
35
+ self.name = actuator_config["settings"]["name"]
36
+ self._stuck_counter: int = 0
37
+ self._transition_state: str = "settled"
38
+ self._stuck: bool = False
39
+ self._latency_remaining: float = 0.0
40
+ self._discover_effects()
41
+ self._controller = controller
42
+
43
+ def send_command(self, target_state: str, advanced_connection: bool = True) -> dict[str,bool]:
44
+ """Send command to microcontroller for target_state"""
45
+ device_name = self.settings["device"]
46
+ logger.info("Command triggered for %s, sending command to %s", self.name, device_name)
47
+ logger.info("Command has target_state: %s", target_state)
48
+ package = self.build_command(target_state)
49
+ if advanced_connection:
50
+ confirmation = self._controller.advanced_send(device_name, package)
51
+ else:
52
+ confirmation = self._controller.send(device_name, package)
53
+
54
+ self._update_commanded_state(confirmation, target_state)
55
+ return confirmation
56
+
57
+ def _update_commanded_state(self, confirmation: dict[str, bool], commanded_state: str) -> None:
58
+ """Update the commanded state attribute"""
59
+ if confirmation["confirmed"]:
60
+ self.commanded_state = commanded_state
61
+ self._transition_state = "commanded"
62
+
63
+ def _resolve_state_transition(self) -> None:
64
+ """Determine latency and encode failure mode of hardware (i.e. actuator is stuck)"""
65
+ if self._stuck and self._stuck_counter == 0:
66
+ self._stuck_counter += 1
67
+ logger.critical("%s is stuck in state %s.", self.name, self.hardware_state)
68
+
69
+ if not self._stuck:
70
+ self._stuck_counter = 0
71
+
72
+ if self._transition_state == "settled":
73
+ return
74
+
75
+ if self._transition_state == "commanded":
76
+ self._latency_remaining = self._sample_latency()
77
+ self._stuck = self._sample_stuck()
78
+ self._transition_state = "pending_latency"
79
+
80
+ if self._transition_state == "pending_latency":
81
+ self._latency_remaining -= self.dt
82
+ if self._latency_remaining <= 0:
83
+ if self._stuck:
84
+ self._transition_state = "stuck"
85
+ logger.critical("%s is stuck", self.name)
86
+ else:
87
+ self._transition_state = "settled"
88
+ self.hardware_state = self.commanded_state
89
+ self.cst = 0
90
+
91
+ def _sample_latency(self) -> float:
92
+ """Sample latency in milliseconds
93
+ Note, all values divided by 1000 to convert millesconds to seconds to
94
+ reflect the standard of self._dt"""
95
+
96
+ dist_type = self.failure_sim["cta_latency"]["distribution"]
97
+ if dist_type == "normal":
98
+ mean_val = self.failure_sim["cta_latency"]["mean"] / 1000
99
+ std_val = self.failure_sim["cta_latency"]["std"] / 1000
100
+ return max(0, gauss(mean_val, std_val))
101
+
102
+ if dist_type == "uniform":
103
+ max_lat = self.failure_sim["cta_latency"]["max"] / 1000
104
+ min_lat = self.failure_sim["cta_latency"]["min"] / 1000
105
+ return uniform(min_lat, max_lat)
106
+
107
+ # Could raise an error here sense dist_type would be unknown
108
+ # sorta a silent failure right now
109
+ logger.warning("Unknown distribution type: %s. Setting latency to zero", dist_type)
110
+ return 0
111
+
112
+ def _sample_stuck(self) -> bool:
113
+ "Determine if in this sim case the actuator gets stuck in its current state"
114
+ if random() <= self.failure_sim["stuck_probability"]:
115
+ return True
116
+ return False
117
+
118
+ def update(self, dt: float) -> None:
119
+ """Update world state based on commands, actuator latency, and effects"""
120
+ self.cst += dt
121
+ self.dt = dt
122
+ if self.sim:
123
+ self._resolve_state_transition()
124
+ self._apply_effects()
125
+
126
+ def _discover_effects(self):
127
+ """Find developer effects"""
128
+ self._effect_methods = {
129
+ name.replace("effect_", ""): method
130
+ for name, method in inspect.getmembers(self, predicate=inspect.ismethod)
131
+ if name.startswith("effect_")
132
+ }
133
+
134
+ def _apply_effects(self) -> None:
135
+ """Apply developer defined effects"""
136
+ for variable_name, method in self._effect_methods.items():
137
+ magnitude = method()
138
+ memory_type = self._get_memory_type(variable_name)
139
+
140
+ if "p" in memory_type:
141
+ self.world_state.apply_delta(variable_name, magnitude)
142
+ else:
143
+ self.world_state.add_contributions(variable_name, self.name, magnitude)
144
+
145
+ def _get_memory_type(self, variable_name: str) -> str:
146
+ """Get the memory type of an actuator-effected variable"""
147
+ return self.effects[variable_name]["effect_types"]
148
+
149
+ #---------------------------------------------------------------------------------------
150
+
@@ -0,0 +1,115 @@
1
+ """Build instances of actuators, sensors, and set up device connections"""
2
+
3
+ import importlib
4
+ import logging
5
+ from pathlib import Path
6
+
7
+ from hilsim.core.actuators import ActuatorBase
8
+ from hilsim.core.controls import (AdvancedCommunication, Controls,
9
+ SerialInterface)
10
+ from hilsim.core.device_exceptions import DeviceConnectionError
11
+ from hilsim.core.i2c_bus import I2CBus
12
+ from hilsim.core.load_toml import LoadTOML
13
+ from hilsim.core.sensors import DriverBase, SensorBase
14
+ from hilsim.core.world_state import WorldState
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # ---------------------------------------------------------------------------------------
19
+
20
+ class BuildComponents:
21
+ """Build instances of actuators, sensors, and set up device connections"""
22
+
23
+ def __init__(self, build_ctx: dict) -> None:
24
+ self.sensor_config = build_ctx["sensor_config"]
25
+ self.device_config = build_ctx["device_config"]
26
+ self.actuator_config = build_ctx["actuator_config"]
27
+ self.world_state = build_ctx["world_state"]
28
+ self.i2c_bus = build_ctx["i2c_bus"]
29
+ self.project_root = build_ctx["project_root"]
30
+ self._auto_discover(
31
+ self.project_root / "sensors"
32
+ ) # Populates SensorBase.registry and DriverBase.Registry
33
+ self._auto_discover(
34
+ self.project_root / "actuators"
35
+ ) # Populates ActuatorBase.registry
36
+
37
+ def _auto_discover(self, directory: str) -> None:
38
+ """Import all modules in a directory to trigger the __init_subclass__ registration"""
39
+ path = Path(directory)
40
+ for module_file in path.glob("*.py"):
41
+ if module_file.name.startswith("_"):
42
+ continue
43
+ module_name = module_file.stem
44
+ try:
45
+ spec = importlib.util.spec_from_file_location(module_name, module_file)
46
+ module = importlib.util.module_from_spec(spec)
47
+ spec.loader.exec_module(module)
48
+ except Exception as e:
49
+ raise ImportError(f"failed to load module '{module_file}': {e}") from e
50
+
51
+ def _build_actuators(self) -> dict:
52
+ """Create dictonary of instances of each actuator"""
53
+ actuators = {}
54
+ for name, config in self.actuator_config.items():
55
+ driver_name = config["settings"]["driver"]
56
+ driver_class = ActuatorBase.registry[driver_name]
57
+ actuators[name] = driver_class(config, self.world_state, self.controller)
58
+
59
+ return actuators
60
+
61
+ def _build_fake_sensors_with_drivers(self):
62
+ """Construct instances of drivers"""
63
+ drivers = {}
64
+ for sensor in self.sensor_config["sensor_params"]["enabled_sensors"]:
65
+
66
+ # Query important parameters
67
+ sensor_config = self.sensor_config["sensors"][sensor]
68
+ fake_sensor_name = sensor_config["fake_sensor_name"]
69
+ driver_name = sensor_config["driver_name"]
70
+
71
+ # Create instance of fake sensor
72
+ fake_sensor_class = SensorBase.registry[fake_sensor_name]
73
+ fake_sensor = fake_sensor_class(sensor_config, self.world_state)
74
+
75
+ # Create instance of driver
76
+ driver_class = DriverBase.registry[driver_name]
77
+ drivers[sensor] = driver_class(
78
+ sensor, sensor_config, self.i2c_bus, fake_sensor
79
+ )
80
+ logger.info("Registered %s → %s", sensor, driver_class.__name__)
81
+
82
+ return drivers
83
+
84
+ def _build_devices(self) -> dict:
85
+ """Build peripherial devices and open serial connections to them"""
86
+ device_configs = self.device_config["devices"]
87
+ advanced_comms_config = self.device_config["advanced_communication"]
88
+ logger.info("building peripherial microcontrollers")
89
+
90
+ device_dict = {}
91
+ for device in self.device_config["enabled_devices"]:
92
+ sub_config = device_configs[device]
93
+ device_comms = SerialInterface(sub_config, device)
94
+ device_w_advanced_comms = AdvancedCommunication(
95
+ sub_config, advanced_comms_config, device_comms
96
+ )
97
+ connected = device_w_advanced_comms.device.connect()
98
+ if not connected:
99
+ logger.error("Error connecting to %s", device)
100
+ raise DeviceConnectionError(
101
+ device, "Failed to connect to device. See message logs."
102
+ )
103
+ logger.info("successfully connected to %s", device)
104
+ device_dict[device] = device_w_advanced_comms
105
+ return device_dict
106
+
107
+ def build_all(self) -> dict:
108
+ """Build all sensors, devices, and actuators"""
109
+ self.controller = Controls(self._build_devices()) # actuators depend on the Controller!
110
+ actuators = self._build_actuators()
111
+ sensors = self._build_fake_sensors_with_drivers()
112
+ return {"actuators": actuators, "sensors": sensors}
113
+
114
+
115
+ # ---------------------------------------------------------------------------------------
@@ -0,0 +1,351 @@
1
+ """Holds the relevant classes for controls handling including:
2
+ - RP2040Communication
3
+ - RP2040AdvancedErrorHandling
4
+ - Controls"""
5
+
6
+ import logging
7
+ import os
8
+ import time
9
+ from abc import ABC, abstractmethod
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+ from random import random
13
+
14
+ import serial
15
+ from serial import SerialException
16
+
17
+ from hilsim.core.device_exceptions import DeviceConnectionError
18
+ from hilsim.core.load_toml import LoadTOML
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ---------------------------------------------------------------------------------------
24
+ class FakeSerial:
25
+ """Fake Serial class that holds the methods of serial.Serial relevant to
26
+ the CommunicationInterface class"""
27
+
28
+ def __init__(
29
+ self, super_sim_fr: float | int, read_time: float, hb_sm: str, hb_rm: str
30
+ ) -> None:
31
+ self.failure_rate = super_sim_fr
32
+ self.read_time = read_time
33
+ self.hb_send_msg = hb_sm
34
+ self.hb_receive_msg = hb_rm
35
+ self.msg = "".encode()
36
+ self.return_msg: bytes
37
+
38
+ def write(self, msg: bytes) -> None:
39
+ """Write a message (doesn't really do much in since this is fake)"""
40
+ self.msg = msg
41
+
42
+ def readline(self) -> bytes:
43
+ """Return bytes and an expected response"""
44
+ # multiplied by 0.7 to help deal with timing uncertainty in non-RTOS
45
+ confirm_wait = random() * self.read_time * 0.7
46
+ start_time = datetime.now()
47
+ if random() < self.failure_rate:
48
+ # Don't respond right away as if you do this leads to a huge
49
+ # concatenation issue for an error string and takes forever.
50
+ time.sleep(self.read_time * 0.25)
51
+ return "".encode()
52
+ if self.hb_send_msg.encode() in self.msg:
53
+ self.return_msg = self.hb_receive_msg.encode()
54
+ else:
55
+ self.return_msg = self.msg
56
+ while True:
57
+ current_td = datetime.now() - start_time
58
+ if current_td >= timedelta(seconds=confirm_wait):
59
+ return self.return_msg
60
+
61
+ def close(self) -> None:
62
+ """fake closing the serial port—doesn't actually do anything in sim"""
63
+
64
+
65
+ # ---------------------------------------------------------------------------------------
66
+ class CommunicationInterface(ABC):
67
+ """Define abstract communication interface for serial connections"""
68
+
69
+ def __init__(self, config_dict: dict, device_name: str) -> None:
70
+ self.sim: bool = config_dict["sim"]
71
+ self.super_sim: bool = config_dict["super_sim"]
72
+ self.super_sim_fr: bool = config_dict["super_sim_fr"]
73
+ self.baud_rate = int(config_dict["baud_rate"])
74
+ self.device_id: str = config_dict["device_ID"]
75
+ self.fake_device_id: str = config_dict["fake_device_ID"]
76
+ self.time_out = float(config_dict["time_out"])
77
+ self.read_time = float(config_dict["read_time"])
78
+ self.hb_sm: str = config_dict["hb_send_msg"]
79
+ self.hb_rm: str = config_dict["hb_receive_msg"]
80
+ self.pca = True
81
+ self.connected = False
82
+ self.ser: serial.Serial | FakeSerial
83
+ self.device_name = device_name
84
+
85
+ @abstractmethod
86
+ def connect(self) -> bool:
87
+ """Enforce a connect method"""
88
+
89
+ @abstractmethod
90
+ def disconnect(self) -> None:
91
+ """Enforce a disconnect method"""
92
+
93
+ @abstractmethod
94
+ def read_and_write(
95
+ self,
96
+ send_msg: str,
97
+ receive_msg: str,
98
+ ) -> bool:
99
+ """Enforce a read and write method"""
100
+
101
+
102
+ # ---------------------------------------------------------------------------------------
103
+ class SerialInterface(CommunicationInterface):
104
+ """Directly communicate with an serial interface Chip over serial connection (usb)"""
105
+
106
+ def __init__(self, config_dict: dict, device_name: str) -> None:
107
+ logger.info("Creating %s", device_name)
108
+ super().__init__(config_dict, device_name)
109
+
110
+ def connect(self) -> bool:
111
+ """create the serial connection"""
112
+ logger.info("Initalizing serial connection for %s", self.device_name)
113
+ if not self.super_sim:
114
+ if self.sim:
115
+ device_id = self.fake_device_id
116
+ else:
117
+ device_id = self.device_id
118
+
119
+ try:
120
+ self.ser = serial.Serial(
121
+ port=device_id, baudrate=self.baud_rate, timeout=self.time_out
122
+ )
123
+ self.connected = True
124
+ return True
125
+ except SerialException as e:
126
+ logger.error(
127
+ "Error initializing serial connection to %s: %s",
128
+ self.device_name,
129
+ str(e),
130
+ )
131
+ return False
132
+ else:
133
+ self.ser = FakeSerial(
134
+ self.super_sim_fr, self.read_time, self.hb_sm, self.hb_rm
135
+ )
136
+ self.connected = True
137
+ return True
138
+
139
+ def disconnect(self) -> None:
140
+ """Disconnect from the serial port"""
141
+ try:
142
+ logger.info("Closing the serial port to %s", self.device_name)
143
+ self.ser.close()
144
+ self.connected = False
145
+ logger.info("Successfully closed the connection to %s", self.device_name)
146
+ except SerialException as e:
147
+ logger.error("Could not close serial port to %s: %s", self.device_name, e)
148
+
149
+ def read_and_write(self, send_msg: str, receive_msg: str) -> bool:
150
+ """Read and write over the serial connection"""
151
+ try:
152
+ received_data = []
153
+ logger.info("sending message '%s' to %s", send_msg, self.device_name)
154
+ self.ser.write(send_msg.encode())
155
+ start_time = datetime.now()
156
+ while datetime.now() - start_time < timedelta(seconds=self.read_time):
157
+ line = self.ser.readline().decode().strip()
158
+ received_data.append(line)
159
+ if receive_msg in line:
160
+ logger.info("received correct confirmation: '%s'", line)
161
+ return True
162
+
163
+ received_data_str = " ".join(received_data)
164
+
165
+ logger.error(
166
+ "Command '%s' sent to %s but no/incorrect confirmation: '%s'",
167
+ send_msg,
168
+ self.device_name,
169
+ received_data_str,
170
+ )
171
+ return False
172
+
173
+ except SerialException as e:
174
+ msg = f"Error writing to the serial port for {self.device_name}: {e}"
175
+ logger.error(msg)
176
+ return False
177
+
178
+
179
+ # ---------------------------------------------------------------------------------------
180
+ class AdvancedCommunication:
181
+ """Check and handle communication with the a device that is a communication
182
+ interface"""
183
+
184
+ def __init__(self, config_dict: dict, ac_dict: dict, device: CommunicationInterface) -> None:
185
+ self.retries = int(ac_dict["retries"])
186
+ self.connection_cycles = int(ac_dict["connection_cycles"])
187
+ self.power_cycles = int(ac_dict["power_cycles"])
188
+ self.hb_sm = config_dict["hb_send_msg"]
189
+ self.hb_rm = config_dict["hb_receive_msg"]
190
+ self.device = device
191
+ self.device_name = device.device_name
192
+
193
+ def __repr__(self):
194
+ return f"Instance of AdvancedCommunication for {self.device_name}"
195
+
196
+ def send(self, send_msg: str, receive_msg: str, secondary_send=False) -> bool:
197
+ """Send the initial command and check for response"""
198
+ logger.info(
199
+ "Will send '%s' to %s, expecting '%s' in return for confirmation",
200
+ send_msg,
201
+ self.device_name,
202
+ receive_msg,
203
+ )
204
+ result = self.device.read_and_write(send_msg, receive_msg)
205
+ if not result and not secondary_send:
206
+ logger.info("Continuing to connection recovery")
207
+ if not result and secondary_send:
208
+ logger.info(
209
+ "connection was recovered with heartbeat, but failed on \
210
+ a secondary send of command '%s'. No further \
211
+ recovery for this command until called again.",
212
+ send_msg,
213
+ )
214
+ return result
215
+
216
+ def _retries(self, send_msg: str, receive_msg: str) -> bool:
217
+ """retry sending the message to determine if error is transient"""
218
+
219
+ for i in range(self.retries):
220
+ logger.info("attempting retry %s", str(i + 1))
221
+ confirmed = self.device.read_and_write(send_msg, receive_msg)
222
+ if confirmed:
223
+ logger.info(
224
+ "received correct response on retry %s, %s connection re-established.",
225
+ str(i + 1),
226
+ self.device_name,
227
+ )
228
+ return True
229
+
230
+ logger.info("Max retries hit")
231
+ return False
232
+
233
+ def _software_cycle(self) -> bool:
234
+ """drop and reinitialize serial connection, check with heart beat"""
235
+ for i in range(self.connection_cycles):
236
+ logger.info("attempting software reconnection, attempt %s", str(i + 1))
237
+ self.device.disconnect()
238
+ # add bash script here
239
+ self.device.connect()
240
+ confirmed = self.device.read_and_write(self.hb_sm, self.hb_sm)
241
+ if confirmed:
242
+ logger.info(
243
+ "Connection re-established via software cycling on "
244
+ "attempt %s, heartbeat received.",
245
+ str(i + 1),
246
+ )
247
+ return True
248
+
249
+ logger.info("Max connection cycles hit")
250
+ return False
251
+
252
+ def _power_cycle(self) -> bool:
253
+ """Power cycle all usb ports chip"""
254
+ if self.device.pca:
255
+ logger.info("power cycling available")
256
+ for i in range(self.power_cycles):
257
+ try:
258
+ logger.info("power cycling USB hub, attempt %s", str(i + 1))
259
+ # input bash script here
260
+ confirmed = self.device.read_and_write(self.hb_sm, self.hb_rm)
261
+ if confirmed:
262
+ logger.info(
263
+ "Connection re-established via power cycling on attempt %s, "
264
+ "heartbeat received",
265
+ str(i + 1),
266
+ )
267
+ return True
268
+ except Exception as e:
269
+ logger.error("failed to perfrom power cycle: %s", e)
270
+ logger.info("max power cycles hit, connection not recovered")
271
+ return False
272
+
273
+ logger.info("power cycling unavailable")
274
+ return False
275
+
276
+ def advanced_connection(self, send_msg: str, receive_msg: str) -> dict[str, bool]:
277
+ """Handle errors with a heriarchy of recovery methods"""
278
+ msg_location = {
279
+ "confirmed": False,
280
+ "nominal": False,
281
+ "retries": False,
282
+ "software_cycle": False,
283
+ "power_cycle": False,
284
+ }
285
+ confirmed = self.send(send_msg, receive_msg)
286
+ if confirmed:
287
+ msg_location["nominal"] = True
288
+ msg_location["confirmed"] = True
289
+ return msg_location
290
+
291
+ confirmed = self._retries(send_msg, receive_msg)
292
+ if confirmed:
293
+ msg_location["retries"] = True
294
+ msg_location["confirmed"] = True
295
+ return msg_location
296
+
297
+ reconnected = self._software_cycle()
298
+ if reconnected:
299
+ msg_location["software_cycle"] = True
300
+ result = self.send(send_msg, receive_msg, True)
301
+ if result:
302
+ msg_location["confirmed"] = True
303
+ return msg_location
304
+
305
+ reconnected = self._power_cycle()
306
+ if reconnected:
307
+ msg_location["power_cycle"] = True
308
+ result = self.send(send_msg, receive_msg, True)
309
+ if result:
310
+ msg_location["confirmed"] = True
311
+ return msg_location
312
+ return msg_location
313
+
314
+
315
+ # ---------------------------------------------------------------------------------------
316
+ class Controls:
317
+ """Perform controls for any registered device"""
318
+
319
+ def __init__(self, device_dict: dict[str, AdvancedCommunication]) -> None:
320
+ self.devices = device_dict
321
+
322
+ def heart_beat(self, device_id: str, advanced_connection: bool =False) -> dict[str, bool] | bool:
323
+ """send heart beat request to device"""
324
+ logger.info("Sending heartbeat to %s", device_id)
325
+ confirmation: dict[str, bool] = {}
326
+
327
+ correct_device = self.devices[device_id]
328
+ send_msg = correct_device.hb_sm
329
+ receive_msg = correct_device.hb_rm
330
+
331
+ if advanced_connection:
332
+ confirmation = correct_device.advanced_connection(send_msg, receive_msg)
333
+ else:
334
+ confirmation["confirmed"] = correct_device.send(send_msg, receive_msg)
335
+
336
+ return confirmation
337
+
338
+ def advanced_send(self, device_name: str, package: str) -> dict[str, bool] | bool:
339
+ """Send a message over serial using the advanced communication
340
+ heirarchy / built in error recovery"""
341
+ correct_device = self.devices[device_name]
342
+ confirmation = correct_device.advanced_connection(package, package)
343
+ return confirmation
344
+
345
+ def send(self, device_name: str, package: str) -> bool:
346
+ """Send a message over serial with a single send, no built-in error recovery"""
347
+ correct_device = self.devices[device_name]
348
+ confirmation = correct_device.send(package, package)
349
+ return confirmation
350
+
351
+ # ---------------------------------------------------------------------------------------
@@ -0,0 +1,8 @@
1
+ class DeviceError(Exception):
2
+ """ Base for device-related errors"""
3
+ def __init__(self, sensor_id: str, message: str) -> None:
4
+ self.sensor_id = sensor_id
5
+ super().__init__(f"[{sensor_id}] {message}")
6
+
7
+ class DeviceConnectionError(DeviceError):
8
+ """Raised when a device fails to connect"""