ekfsm 0.11.0b1.post3__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.

Potentially problematic release.


This version of ekfsm might be problematic. Click here for more details.

@@ -0,0 +1,390 @@
1
+ from .generic import Device
2
+ from smbus2 import SMBus
3
+ from enum import Enum
4
+ from typing import Tuple
5
+ from ekfsm.core.components import SystemComponent
6
+ from ..exceptions import AcquisitionError
7
+ import struct
8
+
9
+
10
+ class CcuCommands(Enum):
11
+ NOP = 0x01
12
+ IMU_SAMPLES = 0x10
13
+ FAN_STATUS = 0x11
14
+ VIN_VOLTAGE = 0x12
15
+ CCU_TEMPERATURE = 0x13
16
+ CCU_HUMIDITY = 0x14
17
+ PUSH_TEMPERATURE = 0x15
18
+ SW_SHUTDOWN = 0x16
19
+ WD_TRIGGER = 0x17
20
+ IDENTIFY_FIRMWARE_TITLE = 0x80
21
+ IDENTIFY_FIRMWARE_VERSION = 0x81
22
+ LOAD_FIRMWARE_CHUNK = 0x82
23
+ LOAD_PARAMETERSET = 0x83
24
+ GET_PARAMETERSET_BEGIN = 0x84
25
+ GET_PARAMETERSET_FOLLOW = 0x85
26
+ RESTART = 0x8F
27
+
28
+
29
+ class EKFCcuUc(Device):
30
+ """
31
+ A class to communicate with I2C microcontroller on the EKF CCU.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ name: str,
37
+ parent: SystemComponent | None,
38
+ *args,
39
+ **kwargs,
40
+ ):
41
+ super().__init__(name, parent, None, *args, **kwargs)
42
+ self._i2c_addr = self.get_i2c_chip_addr()
43
+ self._i2c_bus = self.get_i2c_bus_number()
44
+ self._smbus = SMBus(self._i2c_bus)
45
+
46
+ def __str__(self) -> str:
47
+ return (
48
+ f"EKFCCU - I2C Bus/Address: {self._i2c_bus}/{hex(self._i2c_addr)}; "
49
+ f"sysfs_path: {self.sysfs_device.path if self.sysfs_device else ''}"
50
+ )
51
+
52
+ def temperature(self) -> float:
53
+ """
54
+ Get the temperature from the CCU thermal/humidity sensor.
55
+ The temperature is read once per second.
56
+
57
+ Returns
58
+ -------
59
+ float
60
+ The temperature in degrees Celsius.
61
+
62
+ Raises
63
+ ------
64
+ AcquisitionError
65
+ If the temperature cannot be read, for example, because the sensor is not working.
66
+ """
67
+ return (
68
+ self._get_signed_word_data(CcuCommands.CCU_TEMPERATURE.value, "temperature")
69
+ / 10.0
70
+ )
71
+
72
+ def humidity(self) -> float:
73
+ """
74
+ Get the relative humidity from the CCU thermal/humidity sensor.
75
+ The humidity is read once per second.
76
+
77
+ Returns
78
+ -------
79
+ float
80
+ The relative humidity in percent.
81
+
82
+ Raises
83
+ ------
84
+ AcquisitionError
85
+ If the humidity cannot be read, for example, because the sensor is not working.
86
+ """
87
+ return (
88
+ self._get_signed_word_data(CcuCommands.CCU_HUMIDITY.value, "humidity")
89
+ / 10.0
90
+ )
91
+
92
+ def vin_voltage(self) -> float:
93
+ """
94
+ Get the system input voltage from the CCU (the pimary voltage of the PSU).
95
+ The voltage is read every 100ms.
96
+
97
+ Returns
98
+ -------
99
+ float
100
+ The system input voltage in volts.
101
+
102
+ Raises
103
+ ------
104
+ AcquisitionError
105
+ If the voltage cannot be read, for example, because the ADC is not working.
106
+ """
107
+ return (
108
+ self._get_signed_word_data(CcuCommands.VIN_VOLTAGE.value, "VIN voltage")
109
+ / 10.0
110
+ )
111
+
112
+ def _get_signed_word_data(self, cmd: int, what: str) -> int:
113
+ v = self._smbus.read_word_data(self._i2c_addr, cmd)
114
+ if v == 0x8000:
115
+ raise AcquisitionError(f"cannot read {what}")
116
+ return struct.unpack("<h", struct.pack("<H", v))[0]
117
+
118
+ def fan_status(self, fan: int) -> Tuple[float, float, int]:
119
+ """
120
+ Get the status of a fan.
121
+
122
+ Parameters
123
+ ----------
124
+ fan
125
+ The fan number (0-2).
126
+
127
+ Returns
128
+ -------
129
+ Tuple[float, float, int]
130
+ The desired speed, the actual speed, and the diagnostic value.
131
+ The diagnostic value is a bitfield with the following meaning:
132
+
133
+ - bit 0: 0 = fan status is invalid, 1 = fan status is valid
134
+ - bit 1: 0 = no error detected, 1 = fan is stuck
135
+ """
136
+ data = self._smbus.read_block_data(self._i2c_addr, CcuCommands.FAN_STATUS.value)
137
+ _data = bytes(data)
138
+ desired, actual, diag = struct.unpack("<HHB", _data[fan * 5 : fan * 5 + 5])
139
+ return desired, actual, diag
140
+
141
+ def push_temperature(self, fan: int, temp: float) -> None:
142
+ """
143
+ Tell FAN controller the external temperature, usually the CPU temperature.
144
+
145
+ Parameters
146
+ ----------
147
+ fan
148
+ The fan number (0-2), or -1 to set the external temperature of all fans.
149
+
150
+ temp
151
+ The external temperature in degrees Celsius.
152
+
153
+ Important
154
+ ---------
155
+ If push_temperature is no more called for a certain time (configurable with `fan-push-tout` parameter),
156
+ the fan controller will fallback to it's default fan speed (configurable with the `fan-defrpm` parameter).
157
+
158
+ """
159
+ if fan == -1:
160
+ fan = 0xFF
161
+ data = struct.pack("<Bh", fan, int(temp * 10))
162
+ self._smbus.write_block_data(
163
+ self._i2c_addr, CcuCommands.PUSH_TEMPERATURE.value, list(data)
164
+ )
165
+
166
+ def sw_shutdown(self) -> None:
167
+ """
168
+ Tell CCU that the system is going to shutdown.
169
+ This cause the CCU's system state controller to enter shutdown state and power off the system after a certain time
170
+ (parameter `shutdn-delay`).
171
+
172
+ """
173
+ self._smbus.write_byte(self._i2c_addr, CcuCommands.SW_SHUTDOWN.value)
174
+
175
+ def wd_trigger(self) -> None:
176
+ """
177
+ Trigger the CCU's application watchdog.
178
+ This will reset the watchdog timer.
179
+
180
+ The CCU watchdog is only enabled when the parameter `wd-tout` is set to a value greater than 0. Triggering
181
+ the watchdog when the timeout is 0 will have no effect.
182
+
183
+ If the watchdog is not reset within the timeout, the CCU will power cycle the system.
184
+ """
185
+ self._smbus.write_byte(self._i2c_addr, CcuCommands.WD_TRIGGER.value)
186
+
187
+ #
188
+ # Management commands
189
+ #
190
+ def identify_firmware(self) -> Tuple[str, str]:
191
+ """
192
+ Get the firmware title and version of the CCU.
193
+
194
+ Returns
195
+ -------
196
+ Tuple[str, str]
197
+ The firmware title and version.
198
+ """
199
+ title = bytes(
200
+ self._smbus.read_block_data(
201
+ self._i2c_addr, CcuCommands.IDENTIFY_FIRMWARE_TITLE.value
202
+ )
203
+ ).decode("utf-8")
204
+ version = bytes(
205
+ self._smbus.read_block_data(
206
+ self._i2c_addr, CcuCommands.IDENTIFY_FIRMWARE_VERSION.value
207
+ )
208
+ ).decode("utf-8")
209
+ return title, version
210
+
211
+ def load_firmware(self, firmware: bytes, progress_callback=None) -> None:
212
+ """
213
+ Load firmware into the CCU.
214
+
215
+ The firmware must be the binary firmware file containing the application partition,
216
+ typically named `fw-ccu-mm-default.bin`,
217
+ where `mm` is the major version of the CCU hardware.
218
+
219
+ The download can take several minutes, that is why a progress callback can be provided.
220
+
221
+ When the download is complete and successful, the CCU will restart. To check if the firmware was loaded successfully,
222
+ call :meth:`identify_firmware()` after the restart.
223
+
224
+ Parameters
225
+ ----------
226
+ firmware
227
+ The firmware binary data.
228
+
229
+ progress_callback
230
+ A callback function that is called with the current progress in bytes.
231
+
232
+ Warning
233
+ ---------
234
+ Do not call this method at the same time from multiple threads or processes.
235
+
236
+ """
237
+ offset = 0
238
+ max_chunk_len = 28
239
+ while len(firmware) > 0:
240
+ chunk, firmware = firmware[:max_chunk_len], firmware[max_chunk_len:]
241
+ self._load_firmware_chunk(offset, len(firmware) == 0, chunk)
242
+ offset += len(chunk)
243
+ if len(firmware) != 0:
244
+ self._nop()
245
+ if progress_callback is not None:
246
+ progress_callback(offset)
247
+
248
+ def _load_firmware_chunk(self, offset: int, is_last: bool, data: bytes) -> None:
249
+ if is_last:
250
+ offset |= 0x80000000
251
+ hdr = struct.pack("<I", offset)
252
+ data = hdr + data
253
+ self._smbus.write_block_data(
254
+ self._i2c_addr, CcuCommands.LOAD_FIRMWARE_CHUNK.value, list(data)
255
+ )
256
+
257
+ def get_parameterset(self) -> str:
258
+ """
259
+ Get the CCU parameterset in JSON format.
260
+
261
+ A typical parameterset looks like this:
262
+
263
+ .. code-block:: json
264
+
265
+ {
266
+ "version": "factory",
267
+ "parameters": {
268
+ "num-fans": "2",
269
+ "fan-temp2rpm": "25:2800;50:5000;100:6700",
270
+ "fan-rpm2duty": "2800:55;5000:88;6700:100",
271
+ "fan-defrpm": "5500",
272
+ "fan-ppr": "2",
273
+ "fan-push-tout": "4000",
274
+ "pon-min-temp": "-25",
275
+ "pon-max-temp": "70",
276
+ "shutdn-delay": "120",
277
+ "wd-tout": "0",
278
+ "pwrcycle-time": "10"
279
+ },
280
+ "unsupported_parameters": [],
281
+ "missing_parameters": ["num-fans", "fan-temp2rpm", "fan-rpm2duty", "fan-defrpm", "fan-ppr", \
282
+ "fan-push-tout", "pon-min-temp", "pon-max-temp", "shutdn-delay", "wd-tout", "pwrcycle-time"],
283
+ "invalid_parameters": [],
284
+ "reboot_required": false
285
+ }
286
+
287
+ `version` is the version of the parameterset. If no parameterset has been loaded by the user, the version is `factory`,
288
+ otherwise it is the version of the loaded parameterset.
289
+
290
+ `parameters` contains the current values of all parameters of the parameterset.
291
+
292
+ `unsupported_parameters` contains the names of parameters that might have been downloaded, but
293
+ are not supported by the CCU firmware.
294
+
295
+ `missing_parameters` contains the names of parameters that have not been downloaded yet. Those parameters will
296
+ have their default values.
297
+
298
+ `invalid_parameters` contains the names of parameters that have been downloaded, but have invalid values.
299
+ Those parameters will have their default values.
300
+
301
+ `reboot_required` is a flag that indicates if a reboot is required to apply the parameterset.
302
+
303
+
304
+ Returns
305
+ -------
306
+ str
307
+ The parameterset in JSON format.
308
+
309
+ Warning
310
+ ---------
311
+ Do not call this method at the same time from multiple threads or processes.
312
+ """
313
+ json = b""
314
+ begin = True
315
+ while True:
316
+ chunk = self._get_parameterset_chunk(begin)
317
+ if len(chunk) < 32:
318
+ break
319
+ json += chunk
320
+ begin = False
321
+ return json.decode("utf-8")
322
+
323
+ def _get_parameterset_chunk(self, begin: bool) -> bytes:
324
+ data = self._smbus.read_block_data(
325
+ self._i2c_addr,
326
+ (
327
+ CcuCommands.GET_PARAMETERSET_BEGIN.value
328
+ if begin
329
+ else CcuCommands.GET_PARAMETERSET_FOLLOW.value
330
+ ),
331
+ )
332
+ return bytes(data)
333
+
334
+ def load_parameterset(self, _cfg: str) -> None:
335
+ """
336
+ Load a parameterset into the CCU.
337
+
338
+ The parameterset must be a JSON string containing the parameterset, for example:
339
+
340
+ .. code-block:: json
341
+
342
+ {
343
+ "version": "1.0.0",
344
+ "parameters": {
345
+ "fan-defrpm": "6000"
346
+ }
347
+ }
348
+
349
+
350
+ This would load a parameterset with just one parameter, the default fan speed. All other parameters will
351
+ be set to their default values.
352
+
353
+ In order to apply the parameterset, the CCU must be restarted.
354
+
355
+ Parameters
356
+ ----------
357
+ _cfg
358
+ The parameterset in JSON format.
359
+
360
+ Warning
361
+ ---------
362
+ Do not call this method at the same time from multiple threads or processes.
363
+
364
+ """
365
+ cfg = _cfg.encode("utf-8")
366
+ offset = 0
367
+ max_chunk_len = 28
368
+ while len(cfg) > 0:
369
+ chunk, cfg = cfg[:max_chunk_len], cfg[max_chunk_len:]
370
+ self._load_parameterset_chunk(offset, len(cfg) == 0, chunk)
371
+ offset += len(chunk)
372
+ self._nop()
373
+
374
+ def _load_parameterset_chunk(self, offset: int, is_last: bool, data: bytes) -> None:
375
+ if is_last:
376
+ offset |= 0x80000000
377
+ hdr = struct.pack("<I", offset)
378
+ data = hdr + data
379
+ self._smbus.write_block_data(
380
+ self._i2c_addr, CcuCommands.LOAD_PARAMETERSET.value, list(data)
381
+ )
382
+
383
+ def restart(self) -> None:
384
+ """
385
+ Restart the CCU.
386
+ """
387
+ self._smbus.write_byte(self._i2c_addr, CcuCommands.RESTART.value)
388
+
389
+ def _nop(self) -> None:
390
+ self._smbus.read_word_data(self._i2c_addr, CcuCommands.NOP.value)
@@ -0,0 +1,67 @@
1
+ from .gpio import GPIOExpander
2
+ from ekfsm.core.components import SystemComponent
3
+
4
+
5
+ class EKFSurLed(GPIOExpander):
6
+ """
7
+ A class to represent the EKF-SUR-LED devices.
8
+ """
9
+
10
+ def __init__(
11
+ self,
12
+ name: str,
13
+ parent: SystemComponent | None,
14
+ *args,
15
+ **kwargs,
16
+ ):
17
+ super().__init__(name, parent, None, *args, **kwargs)
18
+
19
+ def __str__(self) -> str:
20
+ return (
21
+ f"EKFSurLed - GPIO Number: {self.number}; "
22
+ f"sysfs_path: {self.sysfs_device.path if self.sysfs_device else ''}"
23
+ )
24
+
25
+ def set(self, led: int, color: str):
26
+ """
27
+ Set the color of a LED.
28
+
29
+ Parameters
30
+ ----------
31
+ led : int
32
+ The LED number (0 or 1).
33
+ color : str
34
+ The color of the LED.
35
+ Possible values: "off", "red", "blue", "green", "yellow", "purple", "cyan", "white"
36
+ """
37
+ # 3-color LEDs,
38
+ # 0: Red
39
+ # 1: Blue
40
+ # 2: Green
41
+
42
+ if color == "off":
43
+ state = [False, False, False]
44
+ elif color == "red":
45
+ state = [True, False, False]
46
+ elif color == "blue":
47
+ state = [False, True, False]
48
+ elif color == "green":
49
+ state = [False, False, True]
50
+ elif color == "yellow":
51
+ state = [True, True, False]
52
+ elif color == "purple":
53
+ state = [True, False, True]
54
+ elif color == "cyan":
55
+ state = [False, True, True]
56
+ elif color == "white":
57
+ state = [True, True, True]
58
+ else:
59
+ raise ValueError(f"Invalid color: {color}")
60
+
61
+ if led < 0 or led > 1:
62
+ raise ValueError(f"Invalid led number: {led}")
63
+
64
+ for i in range(3):
65
+ self.set_direction(i + 4 * led, True)
66
+ # Active low
67
+ self.set_pin(i + 4 * led, False if state[i] else True)
@@ -0,0 +1,245 @@
1
+ from typing import TYPE_CHECKING
2
+ from munch import Munch
3
+ from ekfsm.core.components import SystemComponent
4
+ from ekfsm.core.sysfs import SysFSDevice, sysfs_root
5
+ from ekfsm.exceptions import ConfigError
6
+ from pathlib import Path
7
+
8
+ if TYPE_CHECKING:
9
+ from ekfsm.core.components import HwModule
10
+
11
+
12
+ class Device(SystemComponent):
13
+ """
14
+ A generic device.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ name: str,
20
+ parent: SystemComponent | None = None,
21
+ children: list["Device"] | None = None,
22
+ *args,
23
+ **kwargs
24
+ ):
25
+ super().__init__(name)
26
+ self.parent = parent
27
+ self.device_args = kwargs
28
+ self.logger.debug(f"Device: {name} {kwargs}")
29
+
30
+ if children:
31
+ self.children = children
32
+
33
+ if not hasattr(self, "sysfs_device"):
34
+ self.sysfs_device: SysFSDevice | None = None
35
+
36
+ self._provides_attrs = kwargs.get("provides", {})
37
+
38
+ self.provides = self.__post_init__(Munch(self._provides_attrs))
39
+
40
+ def __post_init__(self, provides: Munch) -> Munch:
41
+ return self.__init_dynamic_attrs__(provides)
42
+
43
+ def __init_dynamic_attrs__(self, provides: Munch) -> Munch:
44
+
45
+ for key, fields in provides.items():
46
+ if isinstance(fields, dict):
47
+ provides[key] = self.__init_dynamic_attrs__(Munch(fields))
48
+ elif isinstance(fields, list | str):
49
+ provides[key] = Munch()
50
+
51
+ if isinstance(fields, str):
52
+ fields = [fields]
53
+
54
+ while fields:
55
+ iface = fields.pop()
56
+ if isinstance(iface, dict):
57
+ name = list(iface.keys())[0]
58
+ try:
59
+ func = list(iface.values())[0]
60
+ except IndexError:
61
+ raise ConfigError(
62
+ f"{self.name}: No function given for interface {name}."
63
+ )
64
+ if not hasattr(self, func):
65
+ raise NotImplementedError(
66
+ f"{self.name}: Function {func} for interface {name} not implemented."
67
+ )
68
+ provides[key].update({name: getattr(self, func)})
69
+ else:
70
+ if not hasattr(self, iface):
71
+ raise NotImplementedError(
72
+ f"{self.name}: Function {iface} for provider {key} not implemented."
73
+ )
74
+ provides[key].update({iface: getattr(self, iface)})
75
+
76
+ return provides
77
+
78
+ def read_sysfs_attr_bytes(self, attr: str) -> bytes | None:
79
+ if self.sysfs_device and len(attr) != 0:
80
+ return self.sysfs_device.read_attr_bytes(attr)
81
+ return None
82
+
83
+ def read_sysfs_attr_utf8(self, attr: str) -> str | None:
84
+ if self.sysfs_device and len(attr) != 0:
85
+ return self.sysfs_device.read_attr_utf8(attr)
86
+ return None
87
+
88
+ def write_sysfs_attr(self, attr: str, data: str | bytes) -> None:
89
+ if self.sysfs_device and len(attr) != 0:
90
+ return self.sysfs_device.write_attr(attr, data)
91
+ return None
92
+
93
+ @property
94
+ def hw_module(self) -> 'HwModule':
95
+ from ekfsm.core.components import HwModule
96
+
97
+ if isinstance(self.root, HwModule):
98
+ return self.root
99
+ else:
100
+ raise RuntimeError("Device is not a child of HwModule")
101
+
102
+ def get_i2c_chip_addr(self) -> int:
103
+ assert self.parent is not None
104
+
105
+ chip_addr = self.device_args.get("addr")
106
+ if chip_addr is None:
107
+ raise ConfigError(
108
+ f"{self.name}: Chip address not provided in board definition"
109
+ )
110
+
111
+ if not hasattr(self.parent, "sysfs_device") or self.parent.sysfs_device is None:
112
+ # our device is the top level device of the slot
113
+ # compute chip address from board yaml and slot attributes
114
+ slot_attributes = self.hw_module.slot.attributes
115
+
116
+ if slot_attributes is None:
117
+ raise ConfigError(
118
+ f"{self.name}: Slot attributes not provided in system configuration"
119
+ )
120
+
121
+ if not self.hw_module.is_master:
122
+ # slot coding is only used for non-master devices
123
+ if not hasattr(slot_attributes, "slot_coding"):
124
+ raise ConfigError(
125
+ f"{self.name}: Slot coding not provided in slot attributes"
126
+ )
127
+
128
+ slot_coding_mask = 0xFF
129
+
130
+ if hasattr(slot_attributes, "slot_coding_mask"):
131
+ slot_coding_mask = slot_attributes.slot_coding_mask
132
+
133
+ chip_addr |= slot_attributes.slot_coding & slot_coding_mask
134
+
135
+ return chip_addr
136
+
137
+ def get_i2c_sysfs_device(self, addr: int) -> SysFSDevice:
138
+ from ekfsm.core.components import HwModule
139
+
140
+ parent = self.parent
141
+ assert parent is not None
142
+
143
+ # if parent is a HwModule, we can get the i2c bus from the master device
144
+ if isinstance(parent, HwModule):
145
+ i2c_bus_path = self._master_i2c_bus()
146
+ else:
147
+ # otherwise the parent must be a MuxChannel
148
+ from ekfsm.devices.mux import MuxChannel
149
+
150
+ assert isinstance(parent, MuxChannel)
151
+ assert parent.sysfs_device is not None
152
+ i2c_bus_path = parent.sysfs_device.path
153
+
154
+ # search for device with addr
155
+ for entry in i2c_bus_path.iterdir():
156
+ if (
157
+ entry.is_dir()
158
+ and not (entry / "new_device").exists() # skip bus entries
159
+ and (entry / "name").exists()
160
+ ):
161
+ # for PRP devices, address is contained in firmware_node/description
162
+ if (entry / "firmware_node").exists() and (
163
+ entry / "firmware_node" / "description"
164
+ ).exists():
165
+ description = (
166
+ (entry / "firmware_node/description").read_text().strip()
167
+ )
168
+ got_addr = int(description.split(" - ")[0], 16)
169
+ if got_addr == addr:
170
+ return SysFSDevice(entry)
171
+
172
+ # for non-PRP devices, address is contained in the directory name (e.g. 2-0018)
173
+ else:
174
+ got_addr = int(entry.name.split("-")[1], 16)
175
+ if got_addr == addr:
176
+ return SysFSDevice(entry)
177
+
178
+ raise FileNotFoundError(
179
+ f"Device with address 0x{addr:x} not found in {i2c_bus_path}"
180
+ )
181
+
182
+ @staticmethod
183
+ def _master_i2c_get_config(master: "HwModule") -> dict:
184
+ if (
185
+ master.config.get("bus_masters") is not None
186
+ and master.config["bus_masters"].get("i2c") is not None
187
+ ):
188
+ return master.config["bus_masters"]["i2c"]
189
+ else:
190
+ raise ConfigError("Master definition incomplete")
191
+
192
+ def _master_i2c_bus(self) -> Path:
193
+ if self.hw_module.is_master:
194
+ # we are the master
195
+ master = self.hw_module
196
+ master_key = "MASTER_LOCAL_DEFAULT"
197
+ override_master_key = self.device_args.get("i2c_master", None)
198
+ if override_master_key is not None:
199
+ master_key = override_master_key
200
+ else:
201
+ # another board is the master
202
+ if self.hw_module.slot.master is None:
203
+ raise ConfigError(
204
+ f"{self.name}: Master board not found in slot attributes"
205
+ )
206
+
207
+ master = self.hw_module.slot.master
208
+ master_key = self.hw_module.slot.slot_type.name
209
+
210
+ i2c_masters = self._master_i2c_get_config(master)
211
+
212
+ if i2c_masters.get(master_key) is not None:
213
+ dir = sysfs_root() / Path(i2c_masters[master_key])
214
+ bus_dirs = list(dir.glob("i2c-*"))
215
+ if len(bus_dirs) == 1:
216
+ return bus_dirs[0]
217
+ elif len(bus_dirs) > 1:
218
+ raise ConfigError(f"Multiple master i2c buses found for {master_key}")
219
+ raise ConfigError(f"No master i2c bus found for {master_key}")
220
+ else:
221
+ raise ConfigError(f"Master i2c bus not found for {master_key}")
222
+
223
+ def get_i2c_bus_number(self) -> int:
224
+ """
225
+ Get the I2C bus number of the device. Works for devices that do not have a sysfs_device attribute.
226
+ """
227
+ from ekfsm.devices.mux import MuxChannel
228
+
229
+ if isinstance(self, MuxChannel):
230
+ raise RuntimeError(f"{self.name}: MuxChannel does not have a bus number")
231
+
232
+ if self.sysfs_device is None:
233
+ if self.parent is None:
234
+ raise RuntimeError(f"{self.name}: Must have a parent to get bus number")
235
+ parent_path = self.parent.sysfs_device.path
236
+ else:
237
+ parent_path = self.sysfs_device.path.parent
238
+ if parent_path.is_symlink():
239
+ parent_path = parent_path.readlink()
240
+ bus_number = parent_path.name.split("-")[1]
241
+ return int(bus_number)
242
+
243
+ def __repr__(self) -> str:
244
+ sysfs_path = getattr(self.sysfs_device, "path", "")
245
+ return f"{self.name}; sysfs_path: {sysfs_path}"