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.
- odycat/__init__.py +33 -0
- odycat/__main__.py +20 -0
- odycat/_al.py +67 -0
- odycat/_config.py +479 -0
- odycat/_esi.py +194 -0
- odycat/_prepare.py +182 -0
- odycat/_profiles.py +129 -0
- odycat/_scan.py +736 -0
- odycat/_shm.py +255 -0
- odycat/_version.py +14 -0
- odycat/bus.py +231 -0
- odycat/device.py +842 -0
- odycat/devices/__init__.py +71 -0
- odycat/devices/_channels.py +84 -0
- odycat/devices/amsamotion/__init__.py +8 -0
- odycat/devices/amsamotion/ec3a_io1632.py +326 -0
- odycat/devices/beckhoff/__init__.py +9 -0
- odycat/devices/beckhoff/el1809.py +143 -0
- odycat/devices/beckhoff/el2809.py +225 -0
- odycat/devices/cia401/__init__.py +9 -0
- odycat/devices/cia401/generic_io.py +136 -0
- odycat/devices/cia402/__init__.py +9 -0
- odycat/devices/cia402/servo.py +605 -0
- odycat/devices/delta/__init__.py +8 -0
- odycat/devices/delta/ax516.py +388 -0
- odycat/devices/delta/sii.py +129 -0
- odycat/devices/leadshine/__init__.py +8 -0
- odycat/devices/leadshine/ect60.py +153 -0
- odycat/devices/mdp/__init__.py +9 -0
- odycat/devices/mdp/digital_io.py +86 -0
- odycat/events.py +668 -0
- odycat/monitor.py +276 -0
- odycat/signal.py +173 -0
- odycat/testing.py +757 -0
- odycat/trace.py +400 -0
- odycat-1.0.0.dist-info/METADATA +183 -0
- odycat-1.0.0.dist-info/RECORD +44 -0
- odycat-1.0.0.dist-info/WHEEL +5 -0
- odycat-1.0.0.dist-info/entry_points.txt +2 -0
- odycat-1.0.0.dist-info/licenses/LICENSE +674 -0
- odycat-1.0.0.dist-info/licenses/LICENSES/GPL-3.0-only.txt +674 -0
- odycat-1.0.0.dist-info/licenses/LICENSES/LicenseRef-OdyCAT-Commercial.txt +15 -0
- odycat-1.0.0.dist-info/licenses/LICENSING.md +81 -0
- 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
|