odycat 1.0.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.
Files changed (44) hide show
  1. odycat/__init__.py +33 -0
  2. odycat/__main__.py +20 -0
  3. odycat/_al.py +67 -0
  4. odycat/_config.py +479 -0
  5. odycat/_esi.py +194 -0
  6. odycat/_prepare.py +182 -0
  7. odycat/_profiles.py +129 -0
  8. odycat/_scan.py +736 -0
  9. odycat/_shm.py +255 -0
  10. odycat/_version.py +14 -0
  11. odycat/bus.py +231 -0
  12. odycat/device.py +842 -0
  13. odycat/devices/__init__.py +71 -0
  14. odycat/devices/_channels.py +84 -0
  15. odycat/devices/amsamotion/__init__.py +8 -0
  16. odycat/devices/amsamotion/ec3a_io1632.py +326 -0
  17. odycat/devices/beckhoff/__init__.py +9 -0
  18. odycat/devices/beckhoff/el1809.py +143 -0
  19. odycat/devices/beckhoff/el2809.py +225 -0
  20. odycat/devices/cia401/__init__.py +9 -0
  21. odycat/devices/cia401/generic_io.py +136 -0
  22. odycat/devices/cia402/__init__.py +9 -0
  23. odycat/devices/cia402/servo.py +605 -0
  24. odycat/devices/delta/__init__.py +8 -0
  25. odycat/devices/delta/ax516.py +388 -0
  26. odycat/devices/delta/sii.py +129 -0
  27. odycat/devices/leadshine/__init__.py +8 -0
  28. odycat/devices/leadshine/ect60.py +153 -0
  29. odycat/devices/mdp/__init__.py +9 -0
  30. odycat/devices/mdp/digital_io.py +86 -0
  31. odycat/events.py +668 -0
  32. odycat/monitor.py +276 -0
  33. odycat/signal.py +173 -0
  34. odycat/testing.py +757 -0
  35. odycat/trace.py +400 -0
  36. odycat-1.0.0.dist-info/METADATA +183 -0
  37. odycat-1.0.0.dist-info/RECORD +44 -0
  38. odycat-1.0.0.dist-info/WHEEL +5 -0
  39. odycat-1.0.0.dist-info/entry_points.txt +2 -0
  40. odycat-1.0.0.dist-info/licenses/LICENSE +674 -0
  41. odycat-1.0.0.dist-info/licenses/LICENSES/GPL-3.0-only.txt +674 -0
  42. odycat-1.0.0.dist-info/licenses/LICENSES/LicenseRef-OdyCAT-Commercial.txt +15 -0
  43. odycat-1.0.0.dist-info/licenses/LICENSING.md +81 -0
  44. odycat-1.0.0.dist-info/top_level.txt +1 -0
odycat/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-OdyCAT-Commercial
2
+ # Copyright (c) 2026 Ocean Octane S.L.U.
3
+ # odycat/__init__.py
4
+ """
5
+ OdyCAT — EtherCAT framework for Linux.
6
+
7
+ Bridges a real-time C-based IgH EtherCAT master engine and a high-level
8
+ Python library using a lock-free, zero-wait shared memory architecture.
9
+
10
+ The core is imported from here; the rest from its module::
11
+
12
+ from odycat import Bus, Device, Events
13
+ from odycat.events import MultiPoll
14
+ from odycat.device import DeviceLostError, EngineStoppedError
15
+ from odycat.devices.leadshine import ECT60 # devices, by vendor
16
+
17
+ https://odycat.org · developed by Ocean Octane, distributed and supported by
18
+ Yoctovision S.L.U. OdyCAT is a registered trademark of Ocean Octane S.L.U.,
19
+ used under licence by Yoctovision. Licensed under the GPL v3, or under a
20
+ commercial licence from Yoctovision S.L.U. (see LICENSING.md).
21
+ """
22
+
23
+ from ._version import __version__
24
+
25
+ __author__ = "Javier Martínez García"
26
+ __copyright__ = "Copyright (c) 2026 Ocean Octane S.L.U."
27
+ __license__ = "GPL-3.0-only OR LicenseRef-OdyCAT-Commercial"
28
+
29
+ from .bus import Bus
30
+ from .device import Device
31
+ from .events import Events
32
+
33
+ __all__ = ["Bus", "Device", "Events"]
odycat/__main__.py ADDED
@@ -0,0 +1,20 @@
1
+ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-OdyCAT-Commercial
2
+ # Copyright (c) 2026 Ocean Octane S.L.U.
3
+ """`python3 -m odycat`: says how to start the engine, which is not Python."""
4
+
5
+ import sys
6
+
7
+ sys.exit("""The OdyCAT engine is the odycat command (a compiled program, not Python):
8
+
9
+ sudo odycat machine.json
10
+
11
+ A config that names device classes ("device": "leadshine.ECT60") is written
12
+ out in full first, as your user:
13
+
14
+ odycat-config expand config.json -o machine.json
15
+
16
+ It is installed apart from this package, root-owned, from the source:
17
+
18
+ make -C engine && sudo make -C engine install # /usr/local/bin/odycat
19
+
20
+ In a repository checkout it can also run from engine/odycat.""")
odycat/_al.py ADDED
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-OdyCAT-Commercial
2
+ # Copyright (c) 2026 Ocean Octane S.L.U.
3
+ # odycat/_al.py
4
+ """
5
+ Internal: EtherCAT application-layer (AL) states and status codes, worded
6
+ as the engine prints them (and close to what `dmesg` shows from IgH).
7
+ """
8
+
9
+ AL_STATE_NAMES = {1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP"}
10
+ AL_ERROR = 0x10 # in the AL state: the slave refused a transition
11
+
12
+ # AL status codes (ETG.1000.6), register 0x0134
13
+ AL_STATUS_CODES = {
14
+ 0x0001: "Unspecified error", 0x0002: "No memory",
15
+ 0x0011: "Invalid requested state change", 0x0012: "Unknown requested state",
16
+ 0x0013: "Bootstrap not supported", 0x0014: "No valid firmware",
17
+ 0x0015: "Invalid mailbox configuration", 0x0016: "Invalid mailbox configuration",
18
+ 0x0017: "Invalid sync manager configuration", 0x0018: "No valid inputs available",
19
+ 0x0019: "No valid outputs", 0x001A: "Synchronization error",
20
+ 0x001B: "Sync manager watchdog", 0x001C: "Invalid sync manager types",
21
+ 0x001D: "Invalid output configuration", 0x001E: "Invalid input configuration",
22
+ 0x001F: "Invalid watchdog configuration", 0x0020: "Slave needs cold start",
23
+ 0x0021: "Slave needs INIT", 0x0022: "Slave needs PREOP",
24
+ 0x0023: "Slave needs SAFEOP", 0x0024: "Invalid input mapping",
25
+ 0x0025: "Invalid output mapping", 0x0026: "Inconsistent settings",
26
+ 0x0027: "Freerun not supported", 0x0028: "Synchronization not supported",
27
+ 0x0029: "Freerun needs 3 buffer mode", 0x002A: "Background watchdog",
28
+ 0x002B: "No valid inputs and outputs", 0x002C: "Fatal sync error",
29
+ 0x002D: "No sync error", 0x0030: "Invalid DC SYNC configuration",
30
+ 0x0031: "Invalid DC latch configuration", 0x0032: "PLL error",
31
+ 0x0033: "DC sync IO error", 0x0034: "DC sync timeout error",
32
+ 0x0035: "DC invalid sync cycle time", 0x0036: "DC Sync0 cycle time",
33
+ 0x0037: "DC Sync1 cycle time", 0x0041: "Mailbox AoE", 0x0042: "Mailbox EoE",
34
+ 0x0043: "Mailbox CoE", 0x0044: "Mailbox FoE", 0x0045: "Mailbox SoE",
35
+ 0x004F: "Mailbox VoE", 0x0050: "EEPROM no access", 0x0051: "EEPROM error",
36
+ 0x0060: "Slave restarted locally",
37
+ }
38
+
39
+
40
+ def al_state_name(al_state: int) -> str:
41
+ """"SAFEOP", "SAFEOP + error", "-" before the engine read it."""
42
+ if not al_state:
43
+ return "-"
44
+ name = AL_STATE_NAMES.get(al_state & 0x0F, "?")
45
+ return f"{name} + error" if al_state & AL_ERROR else name
46
+
47
+
48
+ def al_status_text(code: int) -> str:
49
+ """The meaning of an AL status code."""
50
+ if code in AL_STATUS_CODES:
51
+ return AL_STATUS_CODES[code]
52
+ return "vendor specific" if code >= 0x8000 else "unknown code"
53
+
54
+
55
+ def describe(al_state: int, online: bool, al_code: int) -> str:
56
+ """How a slave that is not operational stands: 'offline', 'in SAFEOP +
57
+ error, AL status 0x0027 (Freerun not supported)'. In OP without being
58
+ operational: back in OP for less than the engine's hold time (500 ms)."""
59
+ if not online:
60
+ return "offline"
61
+ if al_state == 8:
62
+ text = "in OP for less than 500 ms (it counts as back once it stays)"
63
+ else:
64
+ text = f"in {al_state_name(al_state)}"
65
+ if al_code:
66
+ text += f", AL status 0x{al_code:04X} ({al_status_text(al_code)})"
67
+ return text
odycat/_config.py ADDED
@@ -0,0 +1,479 @@
1
+ # SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-OdyCAT-Commercial
2
+ # Copyright (c) 2026 Ocean Octane S.L.U.
3
+ # odycat/_config.py
4
+ """
5
+ Config discovery — reads the config region created by the C engine.
6
+
7
+ The engine keeps its shared-memory regions as files on tmpfs in its run
8
+ directory, /run/odycat by default (ODYCAT_RUN_DIR overrides it, for the
9
+ engine and for Python alike). The config region, <run dir>/<bus>_config,
10
+ holds the raw config.json with a 4-byte length prefix:
11
+ [ uint32 json_len ] [ json_len bytes of UTF-8 JSON ]
12
+
13
+ This module provides:
14
+ - the run directory and the region paths
15
+ - opening a region, refusing one that the engine did not create
16
+ - auto-detect bus (scan <run dir>/*_config)
17
+ - read + parse config from the config region
18
+ - per-slave lookup by position
19
+ """
20
+
21
+ import errno
22
+ import glob
23
+ import json
24
+ import mmap
25
+ import os
26
+ import stat
27
+ import struct
28
+ from typing import Optional
29
+
30
+ from odycat import _shm
31
+
32
+ # Module-level cache keyed by bus name: (config region inode, config).
33
+ # The inode changes when the engine is restarted, which invalidates the entry.
34
+ _config_cache: dict[str, tuple[int, dict]] = {}
35
+
36
+ RUN_DIR_DEFAULT = "/run/odycat"
37
+
38
+ SHM_GROUP_HINT = (
39
+ "The engine gives its shared memory to the 'odycat' group (mode 0660, "
40
+ "or the config's 'shm_group'). Add this user to it: "
41
+ "sudo usermod -aG odycat $USER, then log in again."
42
+ )
43
+
44
+
45
+ def run_dir() -> str:
46
+ """The engine's run directory: $ODYCAT_RUN_DIR, else /run/odycat."""
47
+ return os.environ.get("ODYCAT_RUN_DIR") or RUN_DIR_DEFAULT
48
+
49
+
50
+ def _check_region(fd: int, path: str) -> None:
51
+ """Refuse a region the engine did not create: it must be a regular file,
52
+ closed to other users, owned by root or by the owner of the run
53
+ directory (the user the engine runs as), in a directory only its owner
54
+ can write to. Another local user cannot then feed a script fake inputs
55
+ or collect its outputs through a planted file."""
56
+ st = os.fstat(fd)
57
+ folder = os.path.dirname(path)
58
+ if not stat.S_ISREG(st.st_mode):
59
+ raise RuntimeError(f"OdyCAT: {path} is not a regular file; refusing to use it")
60
+ if st.st_mode & 0o007:
61
+ raise RuntimeError(f"OdyCAT: {path} is open to all users (mode "
62
+ f"{st.st_mode & 0o777:04o}), so an OdyCAT engine did not "
63
+ f"create it; refusing to use it")
64
+ d = os.stat(folder)
65
+ if d.st_mode & 0o022:
66
+ raise RuntimeError(f"OdyCAT: the run directory {folder} is writable by other "
67
+ f"users (mode {d.st_mode & 0o777:04o}); refusing to use it. "
68
+ f"Only its owner may create files there: chmod 0750 {folder}")
69
+ if st.st_uid not in (0, d.st_uid):
70
+ raise RuntimeError(f"OdyCAT: {path} belongs to uid {st.st_uid}, not to the "
71
+ f"engine (root, or uid {d.st_uid} who owns {folder}); "
72
+ f"refusing to use it")
73
+
74
+
75
+ def open_shm(path: str, flags: int) -> int:
76
+ """os.open() for an engine region (no symlinks), checked with
77
+ _check_region, with a clear error when access is denied."""
78
+ try:
79
+ fd = os.open(path, flags | os.O_NOFOLLOW | os.O_CLOEXEC)
80
+ except PermissionError:
81
+ raise RuntimeError(f"OdyCAT: no permission to open {path}. {SHM_GROUP_HINT}") from None
82
+ except OSError as e:
83
+ if e.errno == errno.ELOOP:
84
+ raise RuntimeError(f"OdyCAT: {path} is a symbolic link; refusing to use it") from None
85
+ raise
86
+ try:
87
+ _check_region(fd, path)
88
+ except BaseException:
89
+ os.close(fd)
90
+ raise
91
+ return fd
92
+
93
+
94
+ def detect_bus() -> str:
95
+ """Auto-detect the running OdyCAT bus by scanning <run dir>/*_config."""
96
+ folder = run_dir()
97
+ matches = glob.glob(os.path.join(folder, "*_config"))
98
+ if len(matches) == 1:
99
+ # e.g. "/run/odycat/bus0_config" → "bus0"
100
+ basename = os.path.basename(matches[0])
101
+ return basename.removesuffix("_config")
102
+ if len(matches) == 0:
103
+ raise RuntimeError(
104
+ f"OdyCAT: no running engine detected (no *_config in {folder}). "
105
+ f"Start the C engine first, or pass bus= explicitly. An engine "
106
+ f"started with ODYCAT_RUN_DIR needs the same ODYCAT_RUN_DIR here."
107
+ )
108
+ buses = [os.path.basename(m).removesuffix("_config") for m in matches]
109
+ raise RuntimeError(
110
+ f"OdyCAT: multiple engines detected ({', '.join(buses)}). "
111
+ f"Pass bus= explicitly to select one."
112
+ )
113
+
114
+
115
+ def _data_shm_path(bus: str) -> str:
116
+ return os.path.join(run_dir(), f"{bus}_shared")
117
+
118
+
119
+ def _config_shm_path(bus: str) -> str:
120
+ return os.path.join(run_dir(), f"{bus}_config")
121
+
122
+
123
+ def _trace_shm_path(bus: str) -> str:
124
+ return os.path.join(run_dir(), f"{bus}_trace")
125
+
126
+
127
+ def read_config_region(bus: str) -> tuple[dict, dict]:
128
+ """(engine information, parsed config) from the config region, read
129
+ now (no cache)."""
130
+ path = _config_shm_path(bus)
131
+ fd = open_shm(path, os.O_RDONLY)
132
+ try:
133
+ mm = mmap.mmap(fd, 0, access=mmap.ACCESS_READ)
134
+ finally:
135
+ os.close(fd)
136
+ try:
137
+ info, raw = _shm.parse_config_region(mm, path)
138
+ finally:
139
+ mm.close()
140
+ return info, json.loads(raw.decode("utf-8"))
141
+
142
+
143
+ def engine_info(bus: Optional[str] = None) -> dict:
144
+ """Which engine runs and which config it loaded, from the config region:
145
+ engine_version, engine_git, config_sha256 (of the file as loaded),
146
+ engine_pid, support_end (YYYYMM or None), start_realtime_ns, instance_id
147
+ (random, new at every start) and layout_version."""
148
+ if bus is None:
149
+ bus = detect_bus()
150
+ return read_config_region(bus)[0]
151
+
152
+
153
+ def check_engine_running(buf, path: str, bus: str) -> None:
154
+ """Refuse to attach to a region whose engine is gone: it was killed or
155
+ crashed (its process no longer exists), or it stopped running cycles.
156
+ An engine that is still starting is fine.
157
+
158
+ A script in a container (its own PID namespace) does not see the engine's
159
+ process, but sees its cycles: an invisible pid with fresh cycles is a
160
+ running engine. Only an invisible pid without cycles is refused."""
161
+ pid = engine_info(bus)["engine_pid"]
162
+ down = _shm.engine_down(buf)
163
+ if pid and down and not _shm.pid_alive(pid):
164
+ raise RuntimeError(
165
+ f"OdyCAT: {path} was left by an engine that is no longer running "
166
+ f"(pid {pid}). Start the engine again; from a container, attach once "
167
+ f"the engine runs the bus.")
168
+ if down and down != _shm.ENGINE_STARTING:
169
+ raise RuntimeError(f"OdyCAT: the engine of bus '{bus}' {down}.")
170
+
171
+
172
+ def read_config(bus: Optional[str] = None) -> dict:
173
+ """Read and parse the config JSON from the engine's config region.
174
+
175
+ Results are cached per bus until the engine is restarted.
176
+ """
177
+ if bus is None:
178
+ bus = detect_bus()
179
+
180
+ path = _config_shm_path(bus)
181
+ try:
182
+ ino = os.stat(path).st_ino
183
+ except FileNotFoundError:
184
+ raise RuntimeError(
185
+ f"OdyCAT engine for bus '{bus}' not running "
186
+ f"(no config region at {path})"
187
+ ) from None
188
+
189
+ cached = _config_cache.get(bus)
190
+ if cached is not None and cached[0] == ino:
191
+ return cached[1]
192
+
193
+ config = read_config_region(bus)[1]
194
+ _config_cache[bus] = (ino, config)
195
+ return config
196
+
197
+
198
+ def _as_int(v, default: int = 0) -> int:
199
+ if v is None:
200
+ return default
201
+ return int(v, 0) if isinstance(v, str) else int(v)
202
+
203
+
204
+ def slave_address(slave: dict) -> tuple[int, int]:
205
+ """(alias, position) of a config slave entry.
206
+
207
+ Same semantics as IgH / `ethercat slaves`: alias 0 means position is the
208
+ absolute ring position; otherwise position is the offset from the slave
209
+ carrying that station alias (and defaults to 0).
210
+ """
211
+ return _as_int(slave.get("alias")), _as_int(slave.get("position"))
212
+
213
+
214
+ def _describe(slave: dict) -> str:
215
+ alias, pos = slave_address(slave)
216
+ return f"{alias}:{pos} ({slave.get('label', '?')})"
217
+
218
+
219
+ def find_slave(cfg: dict, position: Optional[int] = None, *,
220
+ alias: int = 0, label: Optional[str] = None) -> dict:
221
+ """Find a slave entry by label, or by (alias, position)."""
222
+ slaves = cfg.get("slaves", [])
223
+ if label is not None:
224
+ matches = [s for s in slaves if s.get("label") == label]
225
+ what = f"label '{label}'"
226
+ else:
227
+ if position is None:
228
+ position = 0 if alias else None
229
+ if position is None:
230
+ raise ValueError("OdyCAT: pass a position, an alias, or a label")
231
+ matches = [s for s in slaves if slave_address(s) == (alias, position)]
232
+ what = f"address {alias}:{position}"
233
+ if len(matches) == 1:
234
+ return matches[0]
235
+ if matches:
236
+ raise ValueError(f"OdyCAT: {what} matches {len(matches)} slaves in config")
237
+ raise KeyError(
238
+ f"OdyCAT: no slave with {what} in config "
239
+ f"(available: {', '.join(_describe(s) for s in slaves)})"
240
+ )
241
+
242
+
243
+ def slave_config(position: Optional[int] = None, bus: Optional[str] = None, *,
244
+ alias: int = 0, label: Optional[str] = None) -> dict:
245
+ """Look up a slave's config block by label, or by (alias, position).
246
+
247
+ Returns the slave dict from config.json (with txpdo, rxpdo, etc.).
248
+ Raises KeyError if no slave matches.
249
+ """
250
+ return find_slave(read_config(bus), position, alias=alias, label=label)
251
+
252
+
253
+ def _entry_is_padding(ent: dict) -> bool:
254
+ idx = ent.get("index")
255
+ if idx is None:
256
+ return False
257
+ if isinstance(idx, str):
258
+ try:
259
+ return int(idx, 0) == 0
260
+ except ValueError:
261
+ return False
262
+ return int(idx) == 0
263
+
264
+
265
+ # The value types of PDO entries and SDOs (the config's "type"), with the
266
+ # struct code Python decodes them with. The engine has the same table.
267
+ TYPES = {
268
+ "bool": "?", "u8": "B", "i8": "b", "u16": "H", "i16": "h",
269
+ "u32": "I", "i32": "i", "u64": "Q", "i64": "q", "f32": "f", "f64": "d",
270
+ }
271
+ TYPE_BITS = {name: struct.calcsize(code) * 8 for name, code in TYPES.items()}
272
+
273
+
274
+ def _entry_bits(ent: dict) -> int:
275
+ """Bits of an entry: its "type", or "bits" for a bit field or a gap. A
276
+ value of a whole type's size is given by its type, not by "bits"."""
277
+ typ = ent.get("type")
278
+ if typ is not None:
279
+ if typ not in TYPE_BITS:
280
+ raise ValueError(f"Unknown entry type {typ!r}: use one of {', '.join(TYPES)}")
281
+ return TYPE_BITS[typ]
282
+ bits = ent.get("bits")
283
+ if bits is None:
284
+ raise ValueError(f"Entry requires 'type' or 'bits': {ent}")
285
+ bits = int(bits)
286
+ if bits in (8, 16, 32, 64) and not _entry_is_padding(ent):
287
+ raise ValueError(f"Entry {ent.get('label') or ent.get('index')} has \"bits\": {bits}: "
288
+ f"give a value of that size its type (\"type\": \"u{bits}\", "
289
+ f"\"i{bits}\"...)")
290
+ return bits
291
+
292
+
293
+ def _pdo_chunks(pdo: dict):
294
+ """Yield (entries, bits) for each byte-aligned run of PDO entries.
295
+
296
+ Mirrors the engine: a run starts on a byte boundary and ends at the next
297
+ one, so sub-byte entries (e.g. 1-bit channels) are grouped into whole
298
+ bytes. Runs made only of padding entries are not mirrored into SHM.
299
+ """
300
+ run: list[dict] = []
301
+ bits = 0
302
+ for ent in pdo.get("entries", []):
303
+ run.append(ent)
304
+ bits += _entry_bits(ent)
305
+ if bits % 8 == 0:
306
+ if not all(_entry_is_padding(e) for e in run):
307
+ yield run, bits
308
+ run, bits = [], 0
309
+ if bits:
310
+ raise ValueError(f"PDO entries do not end on a byte boundary ({bits} trailing bits)")
311
+
312
+
313
+ def pdo_format_from_entries(pdo: dict) -> str:
314
+ """Build a Python struct format string from PDO entries.
315
+
316
+ A run holding one typed entry uses its type's code. Runs of packed
317
+ sub-byte entries are exposed as raw bytes ("B" per byte), so device
318
+ classes decode the individual bits.
319
+ """
320
+ codes: list[str] = []
321
+ for run, bits in _pdo_chunks(pdo):
322
+ if len(run) == 1 and run[0].get("type") is not None:
323
+ codes.append(TYPES[run[0]["type"]])
324
+ else:
325
+ codes.append("B" * (bits // 8))
326
+ return "=" + "".join(codes) if codes else ""
327
+
328
+
329
+ def pdo_size_from_entries(pdo: dict) -> int:
330
+ """Compute the SHM byte size of a PDO block (same rules as the engine)."""
331
+ return sum(bits for _, bits in _pdo_chunks(pdo)) // 8
332
+
333
+
334
+ # Config key of each direction: one PDO or a list of PDOs.
335
+ _DIRECTION_KEYS = {"in": "txpdo", "out": "rxpdo"}
336
+
337
+
338
+ def slave_pdos(slave: dict, direction: str) -> list[tuple[str, dict]]:
339
+ """PDOs of one direction ("in" or "out") as (name, pdo), in wire order.
340
+
341
+ `txpdo`/`rxpdo` hold one PDO object or a list of them. PDOs without
342
+ entries are left out, as the engine does not assign them. Names are
343
+ "txpdo", "txpdo[2]", ...
344
+ """
345
+ key = _DIRECTION_KEYS[direction]
346
+ blk = slave.get(key) or {}
347
+ if isinstance(blk, list):
348
+ pdos = [(f"{key}[{i}]", p) for i, p in enumerate(blk)]
349
+ else:
350
+ pdos = [(key, blk)]
351
+ return [(name, p) for name, p in pdos if p.get("entries")]
352
+
353
+
354
+ def direction_pdo(slave: dict, direction: str) -> dict:
355
+ """All entries of one direction as a single PDO-like block.
356
+
357
+ The engine packs a direction as one run of entries across its PDOs, so
358
+ byte alignment, shm size and struct format are those of this block.
359
+ """
360
+ return {"entries": [e for _, p in slave_pdos(slave, direction)
361
+ for e in p["entries"]]}
362
+
363
+
364
+ def slave_pdo_sizes(slave: dict) -> tuple[int, int]:
365
+ """(input bytes, output bytes) a slave occupies in shm."""
366
+ return (pdo_size_from_entries(direction_pdo(slave, "in")),
367
+ pdo_size_from_entries(direction_pdo(slave, "out")))
368
+
369
+
370
+ def direction_format(slave: dict, direction: str) -> str:
371
+ """struct format of one direction's shm bytes."""
372
+ return pdo_format_from_entries(direction_pdo(slave, direction))
373
+
374
+
375
+ def _align_up_8(v: int) -> int:
376
+ return (v + 7) & ~7
377
+
378
+
379
+ def resolve_slave_shm_offset(cfg: dict, position: Optional[int] = None, *,
380
+ alias: int = 0, label: Optional[str] = None) -> int:
381
+ """Byte offset of a slave block in the data shm, as the engine places it:
382
+ - header at [0 .. _shm.DATA_HEADER_SIZE)
383
+ - first slave starts at _shm.DATA_HEADER_SIZE
384
+ - each block follows the previous one, aligned to 8 bytes
385
+ """
386
+ target = find_slave(cfg, position, alias=alias, label=label)
387
+ next_offset = _shm.DATA_HEADER_SIZE
388
+ for sl in cfg.get("slaves", []):
389
+ off = _align_up_8(next_offset)
390
+ tx_size, rx_size = slave_pdo_sizes(sl)
391
+ end = off + tx_size + rx_size
392
+ next_offset = _align_up_8(end)
393
+
394
+ if sl is target:
395
+ return off
396
+ raise AssertionError("unreachable")
397
+
398
+
399
+ def pdo_entry_layout(pdo: dict, bit_base: int = 0) -> tuple[list[dict], int]:
400
+ """Lay out the entries of one PDO block as they sit in SHM.
401
+
402
+ Mirrors the engine's byte-aligned runs: runs made only of padding are
403
+ not mirrored into SHM, so they do not advance the bit cursor. Padding
404
+ entries are omitted. Returns (fields, bits used); each field holds
405
+ label, index, subindex, bits, type and bit_offset (from bit_base).
406
+ """
407
+ fields: list[dict] = []
408
+ run: list[dict] = []
409
+ run_bits = 0
410
+ cursor = bit_base
411
+ for ent in pdo.get("entries", []):
412
+ run.append(ent)
413
+ run_bits += _entry_bits(ent)
414
+ if run_bits % 8:
415
+ continue
416
+ if not all(_entry_is_padding(e) for e in run):
417
+ for e in run:
418
+ bits = _entry_bits(e)
419
+ if not _entry_is_padding(e):
420
+ index = _as_int(e.get("index"))
421
+ sub = _as_int(e.get("subindex"))
422
+ fields.append({
423
+ "label": e.get("label") or f"0x{index:04X}:{sub}",
424
+ "index": f"0x{index:04X}",
425
+ "subindex": sub,
426
+ "bits": bits,
427
+ "type": e.get("type"),
428
+ "bit_offset": cursor,
429
+ })
430
+ cursor += bits
431
+ run, run_bits = [], 0
432
+ return fields, cursor - bit_base
433
+
434
+
435
+ def slave_entry_layout(slave: dict) -> tuple[list[dict], list[dict]]:
436
+ """(inputs, outputs) entry layout of a slave, bit offsets from its shm block.
437
+
438
+ Each direction is laid out as one run across its PDOs, as in the engine;
439
+ outputs start right after the inputs. Each field also names its PDO
440
+ ("block": "txpdo", "txpdo[1]", "rxpdo", ...).
441
+ """
442
+ result = []
443
+ bit = 0
444
+ for direction in ("in", "out"):
445
+ names = [name for name, p in slave_pdos(slave, direction)
446
+ for e in p["entries"] if not _entry_is_padding(e)]
447
+ fields, n = pdo_entry_layout(direction_pdo(slave, direction), bit)
448
+ for f, name in zip(fields, names):
449
+ f["block"] = name
450
+ result.append(fields)
451
+ bit += n
452
+ return result[0], result[1]
453
+
454
+
455
+ def _entry_struct(field: dict) -> Optional[struct.Struct]:
456
+ """struct for a byte-aligned typed entry, else None."""
457
+ typ = field.get("type")
458
+ if typ not in TYPES or field["bit_offset"] % 8:
459
+ return None
460
+ return struct.Struct("<" + TYPES[typ])
461
+
462
+
463
+ def decode_entry(buf, field: dict, bit_base: int = 0):
464
+ """Decode one entry from buf; its bit_offset is relative to bit_base.
465
+
466
+ Byte-aligned typed entries decode as their type; other entries are
467
+ unsigned bit fields (1-bit entries decode to bool).
468
+ """
469
+ bit = bit_base + field["bit_offset"]
470
+ bits = field["bits"]
471
+ st = _entry_struct(field)
472
+ if st is not None and bit % 8 == 0:
473
+ v = st.unpack_from(buf, bit // 8)[0]
474
+ return v if not isinstance(v, bytes) else v.hex()
475
+ first = bit // 8
476
+ last = (bit + bits + 7) // 8
477
+ raw = int.from_bytes(bytes(buf[first:last]), "little") >> (bit % 8)
478
+ v = raw & ((1 << bits) - 1)
479
+ return bool(v) if bits == 1 else v