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 +0 -0
- hilsim/cli/commands.py +20 -0
- hilsim/core/__init__.py +0 -0
- hilsim/core/actuators.py +150 -0
- hilsim/core/builders.py +115 -0
- hilsim/core/controls.py +351 -0
- hilsim/core/device_exceptions.py +8 -0
- hilsim/core/entry.py +145 -0
- hilsim/core/file_manager.py +113 -0
- hilsim/core/i2c_bus.py +61 -0
- hilsim/core/i2c_exceptions.py +10 -0
- hilsim/core/load_toml.py +14 -0
- hilsim/core/sensor_exceptions.py +17 -0
- hilsim/core/sensor_manager.py +128 -0
- hilsim/core/sensor_tracker.py +124 -0
- hilsim/core/sensors.py +170 -0
- hilsim/core/world_state.py +44 -0
- hilsim/scaffold/__init__.py +0 -0
- hilsim/scaffold/engine.py +26 -0
- hilsim/scaffold/templates/actuator_config.toml.jinja +126 -0
- hilsim/scaffold/templates/actuators/actuator_template.py.jinja +60 -0
- hilsim/scaffold/templates/device_config.toml.jinja +109 -0
- hilsim/scaffold/templates/main.py.jinja +61 -0
- hilsim/scaffold/templates/sensor_config.toml.jinja +122 -0
- hilsim/scaffold/templates/sensors/sensor_template.py.jinja +157 -0
- hilsim/scaffold/templates/world_state.toml.jinja +15 -0
- hilsim-0.1.0.dist-info/METADATA +67 -0
- hilsim-0.1.0.dist-info/RECORD +32 -0
- hilsim-0.1.0.dist-info/WHEEL +5 -0
- hilsim-0.1.0.dist-info/entry_points.txt +2 -0
- hilsim-0.1.0.dist-info/licenses/LICENSE +9 -0
- hilsim-0.1.0.dist-info/top_level.txt +1 -0
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)
|
hilsim/core/__init__.py
ADDED
|
File without changes
|
hilsim/core/actuators.py
ADDED
|
@@ -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
|
+
|
hilsim/core/builders.py
ADDED
|
@@ -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
|
+
# ---------------------------------------------------------------------------------------
|
hilsim/core/controls.py
ADDED
|
@@ -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"""
|