shepherd-core 2025.10.1__py3-none-any.whl → 2026.2.3__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 (69) hide show
  1. shepherd_core/config.py +1 -1
  2. shepherd_core/data_models/__init__.py +4 -2
  3. shepherd_core/data_models/base/cal_measurement.py +7 -2
  4. shepherd_core/data_models/base/calibration.py +23 -12
  5. shepherd_core/data_models/base/content.py +10 -2
  6. shepherd_core/data_models/base/shepherd.py +13 -4
  7. shepherd_core/data_models/base/wrapper.py +2 -0
  8. shepherd_core/data_models/content/__init__.py +4 -2
  9. shepherd_core/data_models/content/_external_fixtures.yaml +104 -96
  10. shepherd_core/data_models/content/_metadata_eenvs_bonito.yaml +436 -0
  11. shepherd_core/data_models/content/_metadata_eenvs_synthetic_multivariate_random_walk.yaml +164 -0
  12. shepherd_core/data_models/content/_metadata_eenvs_synthetic_on_off_markov.yaml +3280 -0
  13. shepherd_core/data_models/content/_metadata_eenvs_synthetic_on_off_windows.yaml +3260 -0
  14. shepherd_core/data_models/content/_metadata_eenvs_synthetic_static.yaml +450 -0
  15. shepherd_core/data_models/content/energy_environment.py +341 -23
  16. shepherd_core/data_models/content/energy_environment_fixture.yaml +24 -18
  17. shepherd_core/data_models/content/enum_datatypes.py +109 -0
  18. shepherd_core/data_models/content/firmware.py +50 -18
  19. shepherd_core/data_models/content/virtual_harvester_config.py +10 -93
  20. shepherd_core/data_models/content/virtual_source_config.py +21 -2
  21. shepherd_core/data_models/content/virtual_storage_config.py +7 -4
  22. shepherd_core/data_models/content/virtual_storage_fixture_creator.py +1 -1
  23. shepherd_core/data_models/content/virtual_storage_fixture_param_experiments.py +4 -4
  24. shepherd_core/data_models/experiment/experiment.py +38 -13
  25. shepherd_core/data_models/experiment/observer_features.py +17 -4
  26. shepherd_core/data_models/experiment/target_config.py +55 -7
  27. shepherd_core/data_models/task/__init__.py +13 -2
  28. shepherd_core/data_models/task/emulation.py +9 -5
  29. shepherd_core/data_models/task/firmware_mod.py +3 -1
  30. shepherd_core/data_models/task/harvest.py +2 -0
  31. shepherd_core/data_models/task/helper_paths.py +2 -2
  32. shepherd_core/data_models/task/observer_tasks.py +8 -6
  33. shepherd_core/data_models/task/programming.py +4 -2
  34. shepherd_core/data_models/task/testbed_tasks.py +8 -2
  35. shepherd_core/data_models/testbed/cape.py +2 -0
  36. shepherd_core/data_models/testbed/gpio.py +2 -0
  37. shepherd_core/data_models/testbed/mcu.py +2 -0
  38. shepherd_core/data_models/testbed/observer.py +2 -0
  39. shepherd_core/data_models/testbed/target.py +7 -5
  40. shepherd_core/data_models/testbed/target_fixture.old1 +1 -1
  41. shepherd_core/data_models/testbed/target_fixture.yaml +1 -1
  42. shepherd_core/data_models/testbed/testbed.py +17 -15
  43. shepherd_core/exit_handler.py +22 -0
  44. shepherd_core/fw_tools/converter.py +2 -2
  45. shepherd_core/fw_tools/validation.py +1 -1
  46. shepherd_core/inventory/__init__.py +23 -21
  47. shepherd_core/inventory/system.py +2 -2
  48. shepherd_core/logger.py +0 -1
  49. shepherd_core/reader.py +29 -25
  50. shepherd_core/testbed_client/cache_path.py +3 -3
  51. shepherd_core/testbed_client/client_abc_fix.py +14 -3
  52. shepherd_core/testbed_client/client_web.py +7 -5
  53. shepherd_core/testbed_client/fixtures.py +7 -7
  54. shepherd_core/version.py +1 -1
  55. shepherd_core/vsource/virtual_converter_model.py +2 -2
  56. shepherd_core/vsource/virtual_harvester_model.py +2 -2
  57. shepherd_core/vsource/virtual_harvester_simulation.py +5 -5
  58. shepherd_core/vsource/virtual_source_model.py +1 -1
  59. shepherd_core/vsource/virtual_source_simulation.py +9 -9
  60. shepherd_core/vsource/virtual_storage_models_kibam.py +3 -3
  61. shepherd_core/writer.py +16 -9
  62. {shepherd_core-2025.10.1.dist-info → shepherd_core-2026.2.3.dist-info}/METADATA +5 -3
  63. shepherd_core-2026.2.3.dist-info/RECORD +102 -0
  64. {shepherd_core-2025.10.1.dist-info → shepherd_core-2026.2.3.dist-info}/WHEEL +1 -1
  65. shepherd_core-2026.2.3.dist-info/licenses/LICENSE +21 -0
  66. shepherd_core/data_models/content/firmware_datatype.py +0 -15
  67. shepherd_core-2025.10.1.dist-info/RECORD +0 -95
  68. {shepherd_core-2025.10.1.dist-info → shepherd_core-2026.2.3.dist-info}/top_level.txt +0 -0
  69. {shepherd_core-2025.10.1.dist-info → shepherd_core-2026.2.3.dist-info}/zip-safe +0 -0
@@ -1,6 +1,8 @@
1
1
  """Configuration related to Target Nodes (DuT)."""
2
2
 
3
+ from pathlib import Path
3
4
  from typing import Annotated
5
+ from typing import final
4
6
 
5
7
  from pydantic import Field
6
8
  from pydantic import model_validator
@@ -13,6 +15,7 @@ from shepherd_core.data_models.content.firmware import Firmware
13
15
  from shepherd_core.data_models.content.virtual_source_config import VirtualSourceConfig
14
16
  from shepherd_core.data_models.testbed.target import IdInt16
15
17
  from shepherd_core.data_models.testbed.target import Target
18
+ from shepherd_core.logger import log
16
19
 
17
20
  from .observer_features import GpioActuation
18
21
  from .observer_features import GpioTracing
@@ -23,7 +26,8 @@ from .observer_features import UartLogging
23
26
  vsrc_neutral = VirtualSourceConfig(name="neutral")
24
27
 
25
28
 
26
- class TargetConfig(ShpModel, title="Target Config"):
29
+ @final
30
+ class TargetConfig(ShpModel):
27
31
  """Configuration related to Target Nodes (DuT)."""
28
32
 
29
33
  target_IDs: Annotated[list[IdInt], Field(min_length=1, max_length=128)]
@@ -55,13 +59,40 @@ class TargetConfig(ShpModel, title="Target Config"):
55
59
  gpio_actuation: GpioActuation | None = None
56
60
  uart_logging: UartLogging | None = None
57
61
 
62
+ @model_validator(mode="after")
63
+ def validate_eenv_mapping(self) -> Self:
64
+ """Validate that a mapping between targets and EEnvs can be found."""
65
+ if self.energy_env.repetitions_ok:
66
+ return self
67
+ n_env = len(self.energy_env)
68
+ n_tgt = len(self.target_IDs)
69
+ if n_env == n_tgt:
70
+ return self
71
+ if n_env > n_tgt:
72
+ log.debug(
73
+ f"TargetConfig for {self.target_IDs} has remaining "
74
+ f"{n_env - n_tgt} EEnv-profiles -> will not be used there"
75
+ )
76
+ return self
77
+ msg = (
78
+ f"Energy-Environment of TargetConfig for tgt{self.target_IDs} was too small "
79
+ f"({n_tgt - n_env} missing). Please use a larger environment."
80
+ )
81
+ raise ValueError(msg)
82
+
58
83
  @model_validator(mode="after")
59
84
  def post_validation(self) -> Self:
60
- if not self.energy_env.valid:
61
- msg = f"EnergyEnv '{self.energy_env.name}' for target must be valid"
62
- raise ValueError(msg)
63
- for _id in self.target_IDs:
64
- target = Target(id=_id)
85
+ # trigger a reuse warning if needed
86
+ self.get_critical_paths(warn_reuse=True)
87
+ try:
88
+ self.energy_env.enforce_validity()
89
+ except ValueError as xpt:
90
+ msg = f"EnergyEnv '{self.energy_env.name}' for TargetConfig must be valid.\n{xpt}"
91
+ # note: added xpt in text because pydantic refuses to show "from xpt" part below
92
+ raise ValueError(msg) from xpt
93
+ # check IDs
94
+ for id_ in self.target_IDs:
95
+ target = Target(id=id_)
65
96
  for mcu_num in [1, 2]:
66
97
  val_fw = getattr(self, f"firmware{mcu_num}")
67
98
  has_fw = val_fw is not None
@@ -91,11 +122,28 @@ class TargetConfig(ShpModel, title="Target Config"):
91
122
  msg = f"Provided custom IDs {c_ids} not enough to cover target range {t_ids}"
92
123
  raise ValueError(msg)
93
124
  # TODO: if custom ids present, firmware must be ELF
125
+ if self.target_delays is not None:
126
+ log.warning("Feature TargetDelays is reserved for future use (not implemented).")
94
127
  if self.gpio_actuation is not None:
95
- raise NotImplementedError("Feature GpioActuation reserved for future use.")
128
+ log.warning("Feature GpioActuation is reserved for future use (not implemented).")
96
129
  return self
97
130
 
98
131
  def get_custom_id(self, target_id: int) -> int | None:
99
132
  if self.custom_IDs is not None and target_id in self.target_IDs:
100
133
  return self.custom_IDs[self.target_IDs.index(target_id)]
101
134
  return None
135
+
136
+ def get_critical_paths(self, *, warn_reuse: bool = True) -> set[Path]:
137
+ """Return all paths of non-repeatable energy profiles to warn about re-usage."""
138
+ paths: list[Path] = [
139
+ profile.data_path
140
+ for profile in self.energy_env.energy_profiles
141
+ if not profile.repetitions_ok
142
+ ]
143
+ path_set = set(paths)
144
+ if warn_reuse and len(paths) != len(path_set):
145
+ log.warning(
146
+ f"Detected re-usage of non-repeatable EnergyProfiles "
147
+ f"in EnergyEnv '{self.energy_env.name}' (TargetConfig-Level)"
148
+ )
149
+ return path_set
@@ -3,7 +3,9 @@
3
3
  These models import externally from all other model-modules!
4
4
  """
5
5
 
6
+ import pathlib
6
7
  import pickle
8
+ import sys
7
9
  from pathlib import Path
8
10
 
9
11
  import yaml
@@ -44,8 +46,17 @@ def prepare_task(config: ShpModel | Path | str, observer: str | None = None) ->
44
46
  config = Path(config)
45
47
 
46
48
  if isinstance(config, Path) and config.exists() and config.suffix.lower() == ".pickle":
47
- with config.resolve().open("rb") as shp_file:
48
- shp_dict = pickle.load(shp_file) # noqa: S301
49
+ try:
50
+ with config.resolve().open("rb") as shp_file:
51
+ shp_dict = pickle.load(shp_file) # noqa: S301
52
+ except ModuleNotFoundError as e:
53
+ # NOTE: workaround for interop-problem
54
+ # "No module named 'pathlib._local'; 'pathlib' is not a package"
55
+ log.warning("Had trouble loading pickled task -> activate pathlib-workaround")
56
+ log.warning(" -> Caught Exception: %s", e.msg)
57
+ sys.modules["pathlib._local"] = pathlib
58
+ with config.resolve().open("rb") as shp_file:
59
+ shp_dict = pickle.load(shp_file) # noqa: S301
49
60
  shp_wrap = Wrapper(**shp_dict)
50
61
  elif isinstance(config, Path) and config.exists() and config.suffix.lower() == ".yaml":
51
62
  with config.resolve().open() as shp_file:
@@ -8,6 +8,7 @@ from enum import Enum
8
8
  from pathlib import Path
9
9
  from pathlib import PurePosixPath
10
10
  from typing import Annotated
11
+ from typing import final
11
12
 
12
13
  from pydantic import Field
13
14
  from pydantic import model_validator
@@ -46,6 +47,7 @@ compressions_allowed: list = [None, "lzf", 1]
46
47
  c_translate = {"lzf": "lzf", "1": 1, "None": None, None: None}
47
48
 
48
49
 
50
+ @final
49
51
  class EmulationTask(ShpModel):
50
52
  """Configuration for the Observer in Emulation-Mode."""
51
53
 
@@ -145,7 +147,7 @@ class EmulationTask(ShpModel):
145
147
  raise ValueError("GPIO Actuation not yet implemented!")
146
148
 
147
149
  io_requested = any(
148
- _io is not None for _io in (self.gpio_actuation, self.gpio_tracing, self.uart_logging)
150
+ io_ is not None for io_ in (self.gpio_actuation, self.gpio_tracing, self.uart_logging)
149
151
  )
150
152
  if self.enable_io and not io_requested:
151
153
  log.warning("Target IO enabled, but no feature requested IO")
@@ -158,13 +160,14 @@ class EmulationTask(ShpModel):
158
160
  def from_xp(cls, xp: Experiment, tb: Testbed, tgt_id: IdInt, root_path: Path) -> Self:
159
161
  obs = tb.get_observer(tgt_id)
160
162
  tgt_cfg = xp.get_target_config(tgt_id)
163
+ tgt_idx = tgt_cfg.target_IDs.index(tgt_id)
161
164
  io_requested = any(
162
- _io is not None
163
- for _io in (tgt_cfg.gpio_actuation, tgt_cfg.gpio_tracing, tgt_cfg.uart_logging)
165
+ io_ is not None
166
+ for io_ in (tgt_cfg.gpio_actuation, tgt_cfg.gpio_tracing, tgt_cfg.uart_logging)
164
167
  )
165
168
 
166
169
  return cls(
167
- input_path=path_posix(tgt_cfg.energy_env.data_path),
170
+ input_path=path_posix(tgt_cfg.energy_env[tgt_idx].data_path),
168
171
  output_path=path_posix(root_path / f"emu_{obs.name}.h5"),
169
172
  time_start=copy.copy(xp.time_start),
170
173
  duration=xp.duration,
@@ -185,7 +188,8 @@ class EmulationTask(ShpModel):
185
188
  TODO: could be added to validator (with a switch)
186
189
  """
187
190
  all_ok = any(self.input_path.is_relative_to(path) for path in paths)
188
- all_ok &= any(self.output_path.is_relative_to(path) for path in paths)
191
+ if self.output_path is not None:
192
+ all_ok &= any(self.output_path.is_relative_to(path) for path in paths)
189
193
  return all_ok
190
194
 
191
195
 
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from pathlib import PurePosixPath
6
6
  from typing import Annotated
7
7
  from typing import TypedDict
8
+ from typing import final
8
9
 
9
10
  from pydantic import Field
10
11
  from pydantic import model_validator
@@ -14,8 +15,8 @@ from typing_extensions import Unpack
14
15
 
15
16
  from shepherd_core.data_models.base.content import IdInt
16
17
  from shepherd_core.data_models.base.shepherd import ShpModel
18
+ from shepherd_core.data_models.content.enum_datatypes import FirmwareDType
17
19
  from shepherd_core.data_models.content.firmware import Firmware
18
- from shepherd_core.data_models.content.firmware import FirmwareDType
19
20
  from shepherd_core.data_models.content.firmware import FirmwareStr
20
21
  from shepherd_core.data_models.experiment.experiment import Experiment
21
22
  from shepherd_core.data_models.testbed import Testbed
@@ -26,6 +27,7 @@ from shepherd_core.logger import log
26
27
  from .helper_paths import path_posix
27
28
 
28
29
 
30
+ @final
29
31
  class FirmwareModTask(ShpModel):
30
32
  """Config for Task that adds the custom ID to the firmware & stores it into a file."""
31
33
 
@@ -6,6 +6,7 @@ from datetime import timedelta
6
6
  from pathlib import Path
7
7
  from pathlib import PurePosixPath
8
8
  from typing import Annotated
9
+ from typing import final
9
10
 
10
11
  from pydantic import Field
11
12
  from pydantic import model_validator
@@ -20,6 +21,7 @@ from shepherd_core.data_models.experiment.observer_features import SystemLogging
20
21
  from .emulation import Compression
21
22
 
22
23
 
24
+ @final
23
25
  class HarvestTask(ShpModel):
24
26
  """Config for the Observer in Harvest-Mode to record IV data from a harvesting-source."""
25
27
 
@@ -1,6 +1,6 @@
1
1
  r"""Helper FN to avoid unwanted behavior.
2
2
 
3
- On windows Path("\xyz") gets transformed to "/xyz", but not on linux.
3
+ On WinOS Path("\xyz") gets transformed to "/xyz", but not on linux.
4
4
  When sending an experiment via fastapi, this bug gets triggered.
5
5
  """
6
6
 
@@ -10,6 +10,6 @@ from pathlib import Path
10
10
  def path_posix(path: Path) -> Path:
11
11
  r"""Help Linux to get from "\xyz" to "/xyz".
12
12
 
13
- This isn't a problem on windows and gets triggered when sending XP via fastapi.
13
+ This isn't a problem on WinOS and gets triggered when sending experiments via fastapi.
14
14
  """
15
15
  return Path(path.as_posix().replace("\\", "/"))
@@ -5,6 +5,7 @@ from datetime import datetime
5
5
  from pathlib import Path
6
6
  from pathlib import PurePosixPath
7
7
  from typing import Annotated
8
+ from typing import final
8
9
 
9
10
  from pydantic import validate_call
10
11
  from typing_extensions import Self
@@ -22,6 +23,7 @@ from .helper_paths import path_posix
22
23
  from .programming import ProgrammingTask
23
24
 
24
25
 
26
+ @final
25
27
  class ObserverTasks(ShpModel):
26
28
  """Collection of tasks for selected observer included in experiment."""
27
29
 
@@ -60,7 +62,7 @@ class ObserverTasks(ShpModel):
60
62
  if xp_folder is None:
61
63
  xp_folder = xp.folder_name() # moved a layer up for consistent naming
62
64
  root_path = tb.data_on_observer / "experiments" / xp_folder
63
- fw_paths = [root_path / f"fw{_i}_{obs.name}.hex" for _i in [1, 2]]
65
+ fw_paths = [root_path / f"fw{i_}_{obs.name}.hex" for i_ in [1, 2]]
64
66
 
65
67
  return cls(
66
68
  observer=obs.name,
@@ -97,9 +99,9 @@ class ObserverTasks(ShpModel):
97
99
  def is_contained(self, paths: AbstractSet[PurePosixPath]) -> bool:
98
100
  """Limit paths to allowed directories."""
99
101
  all_ok = any(self.root_path.is_relative_to(path) for path in paths)
100
- all_ok &= self.fw1_mod.is_contained(paths)
101
- all_ok &= self.fw2_mod.is_contained(paths)
102
- all_ok &= self.fw1_prog.is_contained(paths)
103
- all_ok &= self.fw2_prog.is_contained(paths)
104
- all_ok &= self.emulation.is_contained(paths)
102
+ all_ok &= self.fw1_mod is None or self.fw1_mod.is_contained(paths)
103
+ all_ok &= self.fw2_mod is None or self.fw2_mod.is_contained(paths)
104
+ all_ok &= self.fw1_prog is None or self.fw1_prog.is_contained(paths)
105
+ all_ok &= self.fw2_prog is None or self.fw2_prog.is_contained(paths)
106
+ all_ok &= self.emulation is None or self.emulation.is_contained(paths)
105
107
  return all_ok
@@ -4,6 +4,7 @@ from collections.abc import Set as AbstractSet
4
4
  from pathlib import Path
5
5
  from pathlib import PurePosixPath
6
6
  from typing import Annotated
7
+ from typing import final
7
8
 
8
9
  from pydantic import Field
9
10
  from pydantic import model_validator
@@ -13,8 +14,8 @@ from typing_extensions import Self
13
14
  from shepherd_core.data_models.base.content import IdInt
14
15
  from shepherd_core.data_models.base.content import SafeStr
15
16
  from shepherd_core.data_models.base.shepherd import ShpModel
17
+ from shepherd_core.data_models.content.enum_datatypes import FirmwareDType
16
18
  from shepherd_core.data_models.content.firmware import suffix_to_DType
17
- from shepherd_core.data_models.content.firmware_datatype import FirmwareDType
18
19
  from shepherd_core.data_models.experiment.experiment import Experiment
19
20
  from shepherd_core.data_models.testbed.cape import TargetPort
20
21
  from shepherd_core.data_models.testbed.mcu import ProgrammerProtocol
@@ -24,6 +25,7 @@ from shepherd_core.data_models.testbed.testbed import Testbed
24
25
  from .helper_paths import path_posix
25
26
 
26
27
 
28
+ @final
27
29
  class ProgrammingTask(ShpModel):
28
30
  """Config for a Task programming the selected target."""
29
31
 
@@ -31,7 +33,7 @@ class ProgrammingTask(ShpModel):
31
33
  target_port: TargetPort = TargetPort.A
32
34
  mcu_port: MCUPort = 1
33
35
  mcu_type: SafeStr
34
- """ ⤷ must be either "nrf52" or "msp430" ATM, TODO: clean xp to tasks"""
36
+ """ ⤷ must be either "nrf52" or "msp430" ATM, TODO: clean Experiment to tasks"""
35
37
  voltage: Annotated[float, Field(ge=1, lt=5)] = 3
36
38
  datarate: Annotated[int, Field(gt=0, le=1_000_000)] = 200_000
37
39
  protocol: ProgrammerProtocol
@@ -4,6 +4,7 @@ from pathlib import Path
4
4
  from pathlib import PurePosixPath
5
5
  from typing import TYPE_CHECKING
6
6
  from typing import Annotated
7
+ from typing import final
7
8
 
8
9
  from pydantic import Field
9
10
  from pydantic import validate_call
@@ -20,6 +21,7 @@ if TYPE_CHECKING:
20
21
  from collections.abc import Set as AbstractSet
21
22
 
22
23
 
24
+ @final
23
25
  class TestbedTasks(ShpModel):
24
26
  """Collection of tasks for all observers included in experiment."""
25
27
 
@@ -35,7 +37,7 @@ class TestbedTasks(ShpModel):
35
37
 
36
38
  tgt_ids = xp.get_target_ids()
37
39
  xp_folder = xp.folder_name()
38
- obs_tasks = [ObserverTasks.from_xp(xp, xp_folder, tb, _id) for _id in tgt_ids]
40
+ obs_tasks = [ObserverTasks.from_xp(xp, xp_folder, tb, id_) for id_ in tgt_ids]
39
41
  return cls(
40
42
  name=xp.name,
41
43
  observer_tasks=obs_tasks,
@@ -60,7 +62,11 @@ class TestbedTasks(ShpModel):
60
62
  return values
61
63
 
62
64
  def is_contained(self) -> bool:
63
- """Limit paths to allowed directories."""
65
+ """Limit paths to allowed directories.
66
+
67
+ This is the central checking point for the webserver.
68
+ """
69
+ # TODO: load paths from config
64
70
  paths_allowed: AbstractSet[PurePosixPath] = {
65
71
  PurePosixPath("/var/shepherd/"),
66
72
  PurePosixPath("/tmp/"), # noqa: S108
@@ -4,6 +4,7 @@ from datetime import date
4
4
  from datetime import datetime
5
5
  from enum import Enum
6
6
  from typing import Any
7
+ from typing import final
7
8
 
8
9
  from pydantic import Field
9
10
  from pydantic import model_validator
@@ -22,6 +23,7 @@ class TargetPort(str, Enum):
22
23
  B = b = "B"
23
24
 
24
25
 
26
+ @final
25
27
  class Cape(ShpModel, title="Shepherd-Cape"):
26
28
  """meta-data representation of a testbed-component (physical object)."""
27
29
 
@@ -3,6 +3,7 @@
3
3
  from enum import Enum
4
4
  from typing import Annotated
5
5
  from typing import Any
6
+ from typing import final
6
7
 
7
8
  from pydantic import Field
8
9
  from pydantic import StringConstraints
@@ -24,6 +25,7 @@ class Direction(str, Enum):
24
25
  Bidirectional = IO = "IO"
25
26
 
26
27
 
28
+ @final
27
29
  class GPIO(ShpModel, title="GPIO of Observer Node"):
28
30
  """meta-data representation of a testbed-component."""
29
31
 
@@ -3,6 +3,7 @@
3
3
  from enum import Enum
4
4
  from typing import Annotated
5
5
  from typing import Any
6
+ from typing import final
6
7
 
7
8
  from pydantic import Field
8
9
  from pydantic import model_validator
@@ -23,6 +24,7 @@ class ProgrammerProtocol(str, Enum):
23
24
  UART = uart = "UART"
24
25
 
25
26
 
27
+ @final
26
28
  class MCU(ShpModel, title="Microcontroller of the Target Node"):
27
29
  """meta-data representation of a testbed-component (physical object)."""
28
30
 
@@ -3,6 +3,7 @@
3
3
  from datetime import datetime
4
4
  from typing import Annotated
5
5
  from typing import Any
6
+ from typing import final
6
7
 
7
8
  from pydantic import Field
8
9
  from pydantic import IPvAnyAddress
@@ -26,6 +27,7 @@ MACStr = Annotated[
26
27
  ]
27
28
 
28
29
 
30
+ @final
29
31
  class Observer(ShpModel, title="Shepherd-Sheep"):
30
32
  """meta-data representation of a testbed-component (physical object)."""
31
33
 
@@ -3,6 +3,7 @@
3
3
  from datetime import datetime
4
4
  from typing import Annotated
5
5
  from typing import Any
6
+ from typing import final
6
7
 
7
8
  from pydantic import Field
8
9
  from pydantic import model_validator
@@ -20,6 +21,7 @@ IdInt16 = Annotated[int, Field(ge=0, lt=2**16)]
20
21
  MCUPort = Annotated[int, Field(ge=1, le=2)]
21
22
 
22
23
 
24
+ @final
23
25
  class Target(ShpModel, title="Target Node (DuT)"):
24
26
  """meta-data representation of a testbed-component (physical object)."""
25
27
 
@@ -49,12 +51,12 @@ class Target(ShpModel, title="Target Node (DuT)"):
49
51
  values, _ = tb_client.try_completing_model(cls.__name__, values)
50
52
 
51
53
  # post correction
52
- for _mcu in ["mcu1", "mcu2"]:
53
- if isinstance(values.get(_mcu), str):
54
- values[_mcu] = MCU(name=values[_mcu])
54
+ for mcu in ["mcu1", "mcu2"]:
55
+ if isinstance(values.get(mcu), str):
56
+ values[mcu] = MCU(name=values[mcu])
55
57
  # ⤷ this will raise if default is faulty
56
- elif isinstance(values.get(_mcu), dict):
57
- values[_mcu] = MCU(**values[_mcu])
58
+ elif isinstance(values.get(mcu), dict):
59
+ values[mcu] = MCU(**values[mcu])
58
60
  if values.get("testbed_id") is None:
59
61
  values["testbed_id"] = values.get("id") % 2**16
60
62
 
@@ -3,7 +3,7 @@
3
3
  # https://github.com/orgua/shepherd-v2-planning/blob/main/doc_testbed/Target_pre-deployment-tests.xlsx
4
4
  - datatype: target
5
5
  parameters:
6
- id: 6 # Outer ID - selected by user for XP - can be rearranged
6
+ id: 6 # Outer ID - selected by user for Experiment - can be rearranged
7
7
  name: nRF52_FRAM_001 # inner ID - used to link all parts together
8
8
  version: v1.0
9
9
  description: nRF52 as MCU + Radio, MSP430FR as SPI-FRAM or additional MCU
@@ -3,7 +3,7 @@
3
3
  # https://github.com/orgua/shepherd-v2-planning/blob/main/doc_testbed/Target_pre-deployment-tests.xlsx
4
4
  - datatype: target
5
5
  parameters:
6
- id: 2 # Outer ID - selected by user for XP - can be rearranged
6
+ id: 2 # Outer ID - selected by user for experiment - can be rearranged
7
7
  name: nRF52_FRAM_1392_377 # inner ID - used to link all parts together
8
8
  version: v1.3
9
9
  description: nRF52 as MCU + Radio, MSP430FR as SPI-FRAM or additional MCU
@@ -4,6 +4,7 @@ from datetime import timedelta
4
4
  from pathlib import Path
5
5
  from typing import Annotated
6
6
  from typing import Any
7
+ from typing import final
7
8
 
8
9
  from pydantic import Field
9
10
  from pydantic import HttpUrl
@@ -22,6 +23,7 @@ from .observer import Observer
22
23
  duration_5min = timedelta(minutes=5)
23
24
 
24
25
 
26
+ @final
25
27
  class Testbed(ShpModel):
26
28
  """meta-data representation of a testbed-component (physical object)."""
27
29
 
@@ -61,17 +63,17 @@ class Testbed(ShpModel):
61
63
  capes = []
62
64
  targets = []
63
65
  eth_ports = []
64
- for _obs in self.observers:
65
- observers.append(_obs.id)
66
- ips.append(_obs.ip)
67
- macs.append(_obs.mac)
68
- if _obs.cape is not None:
69
- capes.append(_obs.cape)
70
- if _obs.target_a is not None:
71
- targets.append(_obs.target_a)
72
- if _obs.target_b is not None:
73
- targets.append(_obs.target_b)
74
- eth_ports.append(_obs.eth_port)
66
+ for obs in self.observers:
67
+ observers.append(obs.id)
68
+ ips.append(obs.ip)
69
+ macs.append(obs.mac)
70
+ if obs.cape is not None:
71
+ capes.append(obs.cape)
72
+ if obs.target_a is not None:
73
+ targets.append(obs.target_a)
74
+ if obs.target_b is not None:
75
+ targets.append(obs.target_b)
76
+ eth_ports.append(obs.eth_port)
75
77
  if len(observers) > len(set(observers)):
76
78
  raise ValueError("Observers used more than once in Testbed")
77
79
  if len(ips) > len(set(ips)):
@@ -91,11 +93,11 @@ class Testbed(ShpModel):
91
93
  return self
92
94
 
93
95
  def get_observer(self, target_id: int) -> Observer:
94
- for _observer in self.observers:
95
- if not _observer.active or not _observer.cape.active:
96
+ for obs in self.observers:
97
+ if not obs.active or not obs.cape.active:
96
98
  # skip decommissioned setups
97
99
  continue
98
- if _observer.has_target(target_id):
99
- return _observer
100
+ if obs.has_target(target_id):
101
+ return obs
100
102
  msg = f"Target-ID {target_id} was not found in Testbed '{self.name}'"
101
103
  raise ValueError(msg)
@@ -0,0 +1,22 @@
1
+ """Generalized exit handler for Shepherd."""
2
+
3
+ import signal
4
+ import sys
5
+ from collections.abc import Callable
6
+ from types import FrameType
7
+
8
+ from .logger import log
9
+
10
+
11
+ def exit_gracefully(_signum: int, _frame: FrameType | None) -> None:
12
+ """Usual exit handler for single-processing applications."""
13
+ log.warning("Exiting!")
14
+ sys.exit(0)
15
+
16
+
17
+ def activate_exit_handler(custom: Callable = exit_gracefully) -> None:
18
+ """Register the provided exit handler or use the default one."""
19
+ signal.signal(signal.SIGTERM, custom)
20
+ signal.signal(signal.SIGINT, custom)
21
+ if hasattr(signal, "SIGALRM"):
22
+ signal.signal(signal.SIGALRM, custom)
@@ -8,7 +8,7 @@ from pathlib import Path
8
8
  import zstandard as zstd
9
9
  from pydantic import validate_call
10
10
 
11
- from shepherd_core.data_models.content.firmware_datatype import FirmwareDType
11
+ from shepherd_core.data_models.content.enum_datatypes import FirmwareDType
12
12
 
13
13
  from .converter_elf import elf_to_hex
14
14
  from .validation import is_elf
@@ -98,7 +98,7 @@ def extract_firmware(data: str | Path, data_type: FirmwareDType, file_path: Path
98
98
  elif data_type == FirmwareDType.path_hex:
99
99
  file = file_path.with_suffix(".hex")
100
100
  else:
101
- msg = "FW-Extraction failed due to unknown datatype '{data_type}'"
101
+ msg = f"FW-Extraction failed due to unknown datatype '{data_type}'"
102
102
  raise ValueError(msg)
103
103
  if not file.parent.exists():
104
104
  file.parent.mkdir(parents=True)
@@ -13,7 +13,7 @@ from intelhex import IntelHex
13
13
  from intelhex import IntelHexError
14
14
  from pydantic import validate_call
15
15
 
16
- from shepherd_core.data_models.content.firmware_datatype import FirmwareDType
16
+ from shepherd_core.data_models.content.enum_datatypes import FirmwareDType
17
17
  from shepherd_core.logger import log
18
18
 
19
19
  from .converter_elf import elf_to_hex