From 29167736dbf7863677dcc257febc50f1c8883483 Mon Sep 17 00:00:00 2001 From: Robin Mueller Date: Mon, 3 Jun 2024 15:18:23 +0200 Subject: [PATCH 1/2] Integration of the mini simulator into the sat-rs example --- images/minisim-arch/minisim-arch.graphml | 260 +++++++ images/minisim-arch/minisim-arch.png | Bin 0 -> 100060 bytes satrs-example/Cargo.toml | 3 + satrs-example/README.md | 24 +- satrs-example/pytmtc/.gitignore | 134 ++++ satrs-example/pytmtc/main.py | 199 +---- satrs-example/pytmtc/pyproject.toml | 27 + .../pytmtc/{pus_tm.py => pytmtc/__init__.py} | 0 satrs-example/pytmtc/{ => pytmtc}/common.py | 0 satrs-example/pytmtc/pytmtc/config.py | 47 ++ satrs-example/pytmtc/pytmtc/hk.py | 42 + satrs-example/pytmtc/pytmtc/hk_common.py | 16 + satrs-example/pytmtc/pytmtc/mgms.py | 45 ++ satrs-example/pytmtc/pytmtc/mode.py | 31 + satrs-example/pytmtc/{ => pytmtc}/pus_tc.py | 102 +-- satrs-example/pytmtc/pytmtc/pus_tm.py | 93 +++ satrs-example/pytmtc/requirements.txt | 3 +- satrs-example/pytmtc/tc_definitions.py | 38 - satrs-example/pytmtc/tests/__init__.py | 0 satrs-example/pytmtc/tests/test_tc_mods.py | 48 ++ satrs-example/src/acs/mgm.rs | 538 +++++++++++-- satrs-example/src/config.rs | 13 +- satrs-example/src/eps/mod.rs | 195 +++++ satrs-example/src/eps/pcdu.rs | 722 ++++++++++++++++++ satrs-example/src/hk.rs | 36 +- satrs-example/src/interface/mod.rs | 1 + satrs-example/src/interface/sim_client_udp.rs | 420 ++++++++++ satrs-example/src/lib.rs | 6 +- satrs-example/src/main.rs | 315 ++++++-- satrs-example/src/pus/action.rs | 2 +- satrs-example/src/pus/hk.rs | 12 + satrs-example/src/pus/mod.rs | 6 +- satrs-example/src/requests.rs | 5 +- satrs-minisim/Cargo.toml | 2 + satrs-minisim/README.md | 32 + satrs-minisim/src/acs.rs | 103 ++- satrs-minisim/src/controller.rs | 54 +- satrs-minisim/src/eps.rs | 31 +- satrs-minisim/src/lib.rs | 299 +++++--- satrs-minisim/src/main.rs | 10 +- satrs-minisim/src/udp.rs | 63 +- satrs/src/power.rs | 298 ++++++-- 42 files changed, 3578 insertions(+), 697 deletions(-) create mode 100644 images/minisim-arch/minisim-arch.graphml create mode 100644 images/minisim-arch/minisim-arch.png create mode 100644 satrs-example/pytmtc/pyproject.toml rename satrs-example/pytmtc/{pus_tm.py => pytmtc/__init__.py} (100%) rename satrs-example/pytmtc/{ => pytmtc}/common.py (100%) create mode 100644 satrs-example/pytmtc/pytmtc/config.py create mode 100644 satrs-example/pytmtc/pytmtc/hk.py create mode 100644 satrs-example/pytmtc/pytmtc/hk_common.py create mode 100644 satrs-example/pytmtc/pytmtc/mgms.py create mode 100644 satrs-example/pytmtc/pytmtc/mode.py rename satrs-example/pytmtc/{ => pytmtc}/pus_tc.py (60%) create mode 100644 satrs-example/pytmtc/pytmtc/pus_tm.py delete mode 100644 satrs-example/pytmtc/tc_definitions.py create mode 100644 satrs-example/pytmtc/tests/__init__.py create mode 100644 satrs-example/pytmtc/tests/test_tc_mods.py create mode 100644 satrs-example/src/eps/mod.rs create mode 100644 satrs-example/src/eps/pcdu.rs create mode 100644 satrs-example/src/interface/sim_client_udp.rs create mode 100644 satrs-minisim/README.md diff --git a/images/minisim-arch/minisim-arch.graphml b/images/minisim-arch/minisim-arch.graphml new file mode 100644 index 0000000..6eff044 --- /dev/null +++ b/images/minisim-arch/minisim-arch.graphml @@ -0,0 +1,260 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Simulation + + + + + + + + + + + PCDU + + + + + + + + + + + Magnetometer + + + + + + + + + + + Magnetorquer + + + + + + + + + + + SimController + + + + + + + + + + + UDP TC Receiver + + + + + + + + + + + UDP TM Sender + + + + + + + + + + + Client + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + schedule_event + + + + + + + + + + + + + + SimRequest + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SimReply + + + + + + + + + + + + + + SimRequest in UDP + + + + + + + + + + + + + + SimReply in UDP + + + + + + + + + + + + + step + + + + + + + + + diff --git a/images/minisim-arch/minisim-arch.png b/images/minisim-arch/minisim-arch.png new file mode 100644 index 0000000000000000000000000000000000000000..8e384ad42d016e906b26e6f0e8c01ecc8b9ff76e GIT binary patch literal 100060 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 literal 0 HcmV?d00001 diff --git a/satrs-example/Cargo.toml b/satrs-example/Cargo.toml index b22904b..3749cff 100644 --- a/satrs-example/Cargo.toml +++ b/satrs-example/Cargo.toml @@ -27,6 +27,9 @@ serde_json = "1" path = "../satrs" features = ["test_util"] +[dependencies.satrs-minisim] +path = "../satrs-minisim" + [dependencies.satrs-mib] version = "0.1.1" path = "../satrs-mib" diff --git a/satrs-example/README.md b/satrs-example/README.md index 3447a0d..b661423 100644 --- a/satrs-example/README.md +++ b/satrs-example/README.md @@ -48,16 +48,17 @@ It is recommended to use a virtual environment to do this. To set up one in the you can use `python3 -m venv venv` on Unix systems or `py -m venv venv` on Windows systems. After doing this, you can check the [venv tutorial](https://docs.python.org/3/tutorial/venv.html) on how to activate the environment and then use the following command to install the required -dependency: +dependency interactively: ```sh -pip install -r requirements.txt +pip install -e . ``` Alternatively, if you would like to use the GUI functionality provided by `tmtccmd`, you can also install it manually with ```sh +pip install -e . pip install tmtccmd[gui] ``` @@ -72,3 +73,22 @@ the `simpleclient`: ``` You can also simply call the script without any arguments to view the command tree. + +## Adding the mini simulator application + +This example application features a few device handlers. The +[`satrs-minisim`](https://egit.irs.uni-stuttgart.de/rust/sat-rs/src/branch/main/satrs-minisim) +can be used to simulate the physical devices managed by these device handlers. + +The example application will attempt communication with the mini simulator on UDP port 7303. +If this works, the device handlers will use communication interfaces dedicated to the communication +with the mini simulator. Otherwise, they will be replaced by dummy interfaces which either +return constant values or behave like ideal devices. + +In summary, you can use the following command command to run the mini-simulator first: + +```sh +cargo run -p satrs-minisim +``` + +and then start the example using `cargo run -p satrs-example`. diff --git a/satrs-example/pytmtc/.gitignore b/satrs-example/pytmtc/.gitignore index d994678..008bdd0 100644 --- a/satrs-example/pytmtc/.gitignore +++ b/satrs-example/pytmtc/.gitignore @@ -1,3 +1,4 @@ +/tmtc_conf.json __pycache__ /venv @@ -7,3 +8,136 @@ __pycache__ /seqcnt.txt /.tmtc-history.txt + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# PyCharm +.idea diff --git a/satrs-example/pytmtc/main.py b/satrs-example/pytmtc/main.py index a90a011..d8a55b8 100755 --- a/satrs-example/pytmtc/main.py +++ b/satrs-example/pytmtc/main.py @@ -3,27 +3,17 @@ import logging import sys import time -from typing import Any, Optional -from prompt_toolkit.history import History -from prompt_toolkit.history import FileHistory -from spacepackets.ccsds import PacketId, PacketType import tmtccmd -from spacepackets.ecss import PusTelemetry, PusVerificator -from spacepackets.ecss.pus_17_test import Service17Tm -from spacepackets.ecss.pus_1_verification import UnpackParams, Service1Tm -from spacepackets.ccsds.time import CdsShortTimestamp +from spacepackets.ecss import PusVerificator -from tmtccmd import TcHandlerBase, ProcedureParamsWrapper +from tmtccmd import ProcedureParamsWrapper from tmtccmd.core.base import BackendRequest from tmtccmd.pus import VerificationWrapper -from tmtccmd.tmtc import CcsdsTmHandler, GenericApidHandlerBase -from tmtccmd.com import ComInterface +from tmtccmd.tmtc import CcsdsTmHandler from tmtccmd.config import ( - CmdTreeNode, default_json_path, SetupParams, - HookBase, params_to_procedure_conversion, ) from tmtccmd.config import PreArgsParsingWrapper, SetupWrapper @@ -33,193 +23,20 @@ from tmtccmd.logging.pus import ( RawTmtcTimedLogWrapper, TimedLogWhen, ) -from tmtccmd.tmtc import ( - TcQueueEntryType, - ProcedureWrapper, - TcProcedureType, - FeedWrapper, - SendCbParams, - DefaultPusQueueHelper, - QueueWrapper, -) -from spacepackets.seqcount import FileSeqCountProvider, PusFileSeqCountProvider -from tmtccmd.util.obj_id import ObjectIdDictT +from spacepackets.seqcount import PusFileSeqCountProvider -import pus_tc -from common import Apid, EventU32 +from pytmtc.config import SatrsConfigHook +from pytmtc.pus_tc import TcHandler +from pytmtc.pus_tm import PusHandler _LOGGER = logging.getLogger() -class SatRsConfigHook(HookBase): - def __init__(self, json_cfg_path: str): - super().__init__(json_cfg_path=json_cfg_path) - - def get_communication_interface(self, com_if_key: str) -> Optional[ComInterface]: - from tmtccmd.config.com import ( - create_com_interface_default, - create_com_interface_cfg_default, - ) - - assert self.cfg_path is not None - packet_id_list = [] - for apid in Apid: - packet_id_list.append(PacketId(PacketType.TM, True, apid)) - cfg = create_com_interface_cfg_default( - com_if_key=com_if_key, - json_cfg_path=self.cfg_path, - space_packet_ids=packet_id_list, - ) - assert cfg is not None - return create_com_interface_default(cfg) - - def get_command_definitions(self) -> CmdTreeNode: - """This function should return the root node of the command definition tree.""" - return pus_tc.create_cmd_definition_tree() - - def get_cmd_history(self) -> Optional[History]: - """Optionlly return a history class for the past command paths which will be used - when prompting a command path from the user in CLI mode.""" - return FileHistory(".tmtc-history.txt") - - def get_object_ids(self) -> ObjectIdDictT: - from tmtccmd.config.objects import get_core_object_ids - - return get_core_object_ids() - - -class PusHandler(GenericApidHandlerBase): - def __init__( - self, - file_logger: logging.Logger, - verif_wrapper: VerificationWrapper, - raw_logger: RawTmtcTimedLogWrapper, - ): - super().__init__(None) - self.file_logger = file_logger - self.raw_logger = raw_logger - self.verif_wrapper = verif_wrapper - - def handle_tm(self, apid: int, packet: bytes, _user_args: Any): - try: - pus_tm = PusTelemetry.unpack( - packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE - ) - except ValueError as e: - _LOGGER.warning("Could not generate PUS TM object from raw data") - _LOGGER.warning(f"Raw Packet: [{packet.hex(sep=',')}], REPR: {packet!r}") - raise e - service = pus_tm.service - if service == 1: - tm_packet = Service1Tm.unpack( - data=packet, params=UnpackParams(CdsShortTimestamp.TIMESTAMP_SIZE, 1, 2) - ) - res = self.verif_wrapper.add_tm(tm_packet) - if res is None: - _LOGGER.info( - f"Received Verification TM[{tm_packet.service}, {tm_packet.subservice}] " - f"with Request ID {tm_packet.tc_req_id.as_u32():#08x}" - ) - _LOGGER.warning( - f"No matching telecommand found for {tm_packet.tc_req_id}" - ) - else: - self.verif_wrapper.log_to_console(tm_packet, res) - self.verif_wrapper.log_to_file(tm_packet, res) - elif service == 3: - _LOGGER.info("No handling for HK packets implemented") - _LOGGER.info(f"Raw packet: 0x[{packet.hex(sep=',')}]") - pus_tm = PusTelemetry.unpack( - packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE - ) - if pus_tm.subservice == 25: - if len(pus_tm.source_data) < 8: - raise ValueError("No addressable ID in HK packet") - json_str = pus_tm.source_data[8:] - _LOGGER.info(json_str) - elif service == 5: - tm_packet = PusTelemetry.unpack( - packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE - ) - src_data = tm_packet.source_data - event_u32 = EventU32.unpack(src_data) - _LOGGER.info( - f"Received event packet. Source APID: {Apid(tm_packet.apid)!r}, Event: {event_u32}" - ) - if event_u32.group_id == 0 and event_u32.unique_id == 0: - _LOGGER.info("Received test event") - elif service == 17: - tm_packet = Service17Tm.unpack( - packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE - ) - if tm_packet.subservice == 2: - self.file_logger.info("Received Ping Reply TM[17,2]") - _LOGGER.info("Received Ping Reply TM[17,2]") - else: - self.file_logger.info( - f"Received Test Packet with unknown subservice {tm_packet.subservice}" - ) - _LOGGER.info( - f"Received Test Packet with unknown subservice {tm_packet.subservice}" - ) - else: - _LOGGER.info( - f"The service {service} is not implemented in Telemetry Factory" - ) - tm_packet = PusTelemetry.unpack( - packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE - ) - self.raw_logger.log_tm(pus_tm) - - -class TcHandler(TcHandlerBase): - def __init__( - self, - seq_count_provider: FileSeqCountProvider, - verif_wrapper: VerificationWrapper, - ): - super(TcHandler, self).__init__() - self.seq_count_provider = seq_count_provider - self.verif_wrapper = verif_wrapper - self.queue_helper = DefaultPusQueueHelper( - queue_wrapper=QueueWrapper.empty(), - tc_sched_timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE, - seq_cnt_provider=seq_count_provider, - pus_verificator=self.verif_wrapper.pus_verificator, - default_pus_apid=None, - ) - - def send_cb(self, send_params: SendCbParams): - entry_helper = send_params.entry - if entry_helper.is_tc: - if entry_helper.entry_type == TcQueueEntryType.PUS_TC: - pus_tc_wrapper = entry_helper.to_pus_tc_entry() - raw_tc = pus_tc_wrapper.pus_tc.pack() - _LOGGER.info(f"Sending {pus_tc_wrapper.pus_tc}") - send_params.com_if.send(raw_tc) - elif entry_helper.entry_type == TcQueueEntryType.LOG: - log_entry = entry_helper.to_log_entry() - _LOGGER.info(log_entry.log_str) - - def queue_finished_cb(self, info: ProcedureWrapper): - if info.proc_type == TcProcedureType.TREE_COMMANDING: - def_proc = info.to_tree_commanding_procedure() - _LOGGER.info(f"Queue handling finished for command {def_proc.cmd_path}") - - def feed_cb(self, info: ProcedureWrapper, wrapper: FeedWrapper): - q = self.queue_helper - q.queue_wrapper = wrapper.queue_wrapper - if info.proc_type == TcProcedureType.TREE_COMMANDING: - def_proc = info.to_tree_commanding_procedure() - assert def_proc.cmd_path is not None - pus_tc.pack_pus_telecommands(q, def_proc.cmd_path) - - def main(): add_colorlog_console_logger(_LOGGER) tmtccmd.init_printout(False) - hook_obj = SatRsConfigHook(json_cfg_path=default_json_path()) + hook_obj = SatrsConfigHook(json_cfg_path=default_json_path()) parser_wrapper = PreArgsParsingWrapper() parser_wrapper.create_default_parent_parser() parser_wrapper.create_default_parser() diff --git a/satrs-example/pytmtc/pyproject.toml b/satrs-example/pytmtc/pyproject.toml new file mode 100644 index 0000000..fcb4a32 --- /dev/null +++ b/satrs-example/pytmtc/pyproject.toml @@ -0,0 +1,27 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "pytmtc" +description = "Python TMTC client for OPS-SAT" +readme = "README.md" +version = "0.1.0" +requires-python = ">=3.8" +authors = [ + {name = "Robin Mueller", email = "robin.mueller.m@gmail.com"}, +] +dependencies = [ + "tmtccmd~=8.0", + "pydantic~=2.7" +] + +[tool.setuptools.packages] +find = {} + +[tool.ruff] +extend-exclude = ["archive"] +[tool.ruff.lint] +ignore = ["E501"] +[tool.ruff.lint.extend-per-file-ignores] +"__init__.py" = ["F401"] diff --git a/satrs-example/pytmtc/pus_tm.py b/satrs-example/pytmtc/pytmtc/__init__.py similarity index 100% rename from satrs-example/pytmtc/pus_tm.py rename to satrs-example/pytmtc/pytmtc/__init__.py diff --git a/satrs-example/pytmtc/common.py b/satrs-example/pytmtc/pytmtc/common.py similarity index 100% rename from satrs-example/pytmtc/common.py rename to satrs-example/pytmtc/pytmtc/common.py diff --git a/satrs-example/pytmtc/pytmtc/config.py b/satrs-example/pytmtc/pytmtc/config.py new file mode 100644 index 0000000..6647769 --- /dev/null +++ b/satrs-example/pytmtc/pytmtc/config.py @@ -0,0 +1,47 @@ +from typing import Optional +from prompt_toolkit.history import FileHistory, History +from spacepackets.ccsds import PacketId, PacketType +from tmtccmd import HookBase +from tmtccmd.com import ComInterface +from tmtccmd.config import CmdTreeNode +from tmtccmd.util.obj_id import ObjectIdDictT + +from pytmtc.common import Apid +from pytmtc.pus_tc import create_cmd_definition_tree + + +class SatrsConfigHook(HookBase): + def __init__(self, json_cfg_path: str): + super().__init__(json_cfg_path=json_cfg_path) + + def get_communication_interface(self, com_if_key: str) -> Optional[ComInterface]: + from tmtccmd.config.com import ( + create_com_interface_default, + create_com_interface_cfg_default, + ) + + assert self.cfg_path is not None + packet_id_list = [] + for apid in Apid: + packet_id_list.append(PacketId(PacketType.TM, True, apid)) + cfg = create_com_interface_cfg_default( + com_if_key=com_if_key, + json_cfg_path=self.cfg_path, + space_packet_ids=packet_id_list, + ) + assert cfg is not None + return create_com_interface_default(cfg) + + def get_command_definitions(self) -> CmdTreeNode: + """This function should return the root node of the command definition tree.""" + return create_cmd_definition_tree() + + def get_cmd_history(self) -> Optional[History]: + """Optionlly return a history class for the past command paths which will be used + when prompting a command path from the user in CLI mode.""" + return FileHistory(".tmtc-history.txt") + + def get_object_ids(self) -> ObjectIdDictT: + from tmtccmd.config.objects import get_core_object_ids + + return get_core_object_ids() diff --git a/satrs-example/pytmtc/pytmtc/hk.py b/satrs-example/pytmtc/pytmtc/hk.py new file mode 100644 index 0000000..6e8aa71 --- /dev/null +++ b/satrs-example/pytmtc/pytmtc/hk.py @@ -0,0 +1,42 @@ +import logging +import struct +from spacepackets.ecss.pus_3_hk import Subservice +from spacepackets.ecss import PusTm + +from pytmtc.common import AcsId, Apid +from pytmtc.mgms import handle_mgm_hk_report + + +_LOGGER = logging.getLogger(__name__) + + +def handle_hk_packet(pus_tm: PusTm): + if len(pus_tm.source_data) < 4: + raise ValueError("no unique ID in HK packet") + unique_id = struct.unpack("!I", pus_tm.source_data[:4])[0] + if ( + pus_tm.subservice == Subservice.TM_HK_REPORT + or pus_tm.subservice == Subservice.TM_DIAGNOSTICS_REPORT + ): + if len(pus_tm.source_data) < 8: + raise ValueError("no set ID in HK packet") + set_id = struct.unpack("!I", pus_tm.source_data[4:8])[0] + handle_hk_report(pus_tm, unique_id, set_id) + _LOGGER.warning( + f"handling for HK packet with subservice {pus_tm.subservice} not implemented yet" + ) + + +def handle_hk_report(pus_tm: PusTm, unique_id: int, set_id: int): + hk_data = pus_tm.source_data[8:] + if pus_tm.apid == Apid.ACS: + if unique_id == AcsId.MGM_0: + handle_mgm_hk_report(pus_tm, set_id, hk_data) + else: + _LOGGER.warning( + f"handling for HK report with unique ID {unique_id} not implemented yet" + ) + else: + _LOGGER.warning( + f"handling for HK report with apid {pus_tm.apid} not implemented yet" + ) diff --git a/satrs-example/pytmtc/pytmtc/hk_common.py b/satrs-example/pytmtc/pytmtc/hk_common.py new file mode 100644 index 0000000..bb9890a --- /dev/null +++ b/satrs-example/pytmtc/pytmtc/hk_common.py @@ -0,0 +1,16 @@ +import struct + +from spacepackets.ecss import PusService, PusTc +from spacepackets.ecss.pus_3_hk import Subservice + + +def create_request_one_shot_hk_cmd(apid: int, unique_id: int, set_id: int) -> PusTc: + app_data = bytearray() + app_data.extend(struct.pack("!I", unique_id)) + app_data.extend(struct.pack("!I", set_id)) + return PusTc( + service=PusService.S3_HOUSEKEEPING, + subservice=Subservice.TC_GENERATE_ONE_PARAMETER_REPORT, + apid=apid, + app_data=app_data, + ) diff --git a/satrs-example/pytmtc/pytmtc/mgms.py b/satrs-example/pytmtc/pytmtc/mgms.py new file mode 100644 index 0000000..d420b3e --- /dev/null +++ b/satrs-example/pytmtc/pytmtc/mgms.py @@ -0,0 +1,45 @@ +import logging +import struct +import enum +from typing import List +from spacepackets.ecss import PusTm +from tmtccmd.tmtc import DefaultPusQueueHelper + +from pytmtc.common import AcsId, Apid +from pytmtc.hk_common import create_request_one_shot_hk_cmd +from pytmtc.mode import handle_set_mode_cmd + + +_LOGGER = logging.getLogger(__name__) + + +class SetId(enum.IntEnum): + SENSOR_SET = 0 + + +def create_mgm_cmds(q: DefaultPusQueueHelper, cmd_path: List[str]): + assert len(cmd_path) >= 3 + if cmd_path[2] == "hk": + if cmd_path[3] == "one_shot_hk": + q.add_log_cmd("Sending HK one shot request") + q.add_pus_tc( + create_request_one_shot_hk_cmd(Apid.ACS, AcsId.MGM_0, SetId.SENSOR_SET) + ) + + if cmd_path[2] == "mode": + if cmd_path[3] == "set_mode": + handle_set_mode_cmd(q, "MGM 0", cmd_path[4], Apid.ACS, AcsId.MGM_0) + + +def handle_mgm_hk_report(pus_tm: PusTm, set_id: int, hk_data: bytes): + if set_id == SetId.SENSOR_SET: + if len(hk_data) != 13: + raise ValueError(f"invalid HK data length, expected 13, got {len(hk_data)}") + data_valid = hk_data[0] + mgm_x = struct.unpack("!f", hk_data[1:5])[0] + mgm_y = struct.unpack("!f", hk_data[5:9])[0] + mgm_z = struct.unpack("!f", hk_data[9:13])[0] + _LOGGER.info( + f"received MGM HK set in uT: Valid {data_valid} X {mgm_x} Y {mgm_y} Z {mgm_z}" + ) + pass diff --git a/satrs-example/pytmtc/pytmtc/mode.py b/satrs-example/pytmtc/pytmtc/mode.py new file mode 100644 index 0000000..918fdb1 --- /dev/null +++ b/satrs-example/pytmtc/pytmtc/mode.py @@ -0,0 +1,31 @@ +import struct +from spacepackets.ecss import PusTc +from tmtccmd.pus.s200_fsfw_mode import Mode, Subservice +from tmtccmd.tmtc import DefaultPusQueueHelper + + +def create_set_mode_cmd(apid: int, unique_id: int, mode: int, submode: int) -> PusTc: + app_data = bytearray() + app_data.extend(struct.pack("!I", unique_id)) + app_data.extend(struct.pack("!I", mode)) + app_data.extend(struct.pack("!H", submode)) + return PusTc( + service=200, + subservice=Subservice.TC_MODE_COMMAND, + apid=apid, + app_data=app_data, + ) + + +def handle_set_mode_cmd( + q: DefaultPusQueueHelper, target_str: str, mode_str: str, apid: int, unique_id: int +): + if mode_str == "off": + q.add_log_cmd(f"Sending Mode OFF to {target_str}") + q.add_pus_tc(create_set_mode_cmd(apid, unique_id, Mode.OFF, 0)) + elif mode_str == "on": + q.add_log_cmd(f"Sending Mode ON to {target_str}") + q.add_pus_tc(create_set_mode_cmd(apid, unique_id, Mode.ON, 0)) + elif mode_str == "normal": + q.add_log_cmd(f"Sending Mode NORMAL to {target_str}") + q.add_pus_tc(create_set_mode_cmd(apid, unique_id, Mode.NORMAL, 0)) diff --git a/satrs-example/pytmtc/pus_tc.py b/satrs-example/pytmtc/pytmtc/pus_tc.py similarity index 60% rename from satrs-example/pytmtc/pus_tc.py rename to satrs-example/pytmtc/pytmtc/pus_tc.py index b0febdc..6e1570c 100644 --- a/satrs-example/pytmtc/pus_tc.py +++ b/satrs-example/pytmtc/pytmtc/pus_tc.py @@ -1,33 +1,69 @@ import datetime -import struct import logging from spacepackets.ccsds import CdsShortTimestamp from spacepackets.ecss import PusTelecommand +from spacepackets.seqcount import FileSeqCountProvider +from tmtccmd import ProcedureWrapper, TcHandlerBase from tmtccmd.config import CmdTreeNode -from tmtccmd.pus.tc.s200_fsfw_mode import Mode -from tmtccmd.tmtc import DefaultPusQueueHelper +from tmtccmd.pus import VerificationWrapper +from tmtccmd.tmtc import ( + DefaultPusQueueHelper, + FeedWrapper, + QueueWrapper, + SendCbParams, + TcProcedureType, + TcQueueEntryType, +) from tmtccmd.pus.s11_tc_sched import create_time_tagged_cmd -from tmtccmd.pus.s200_fsfw_mode import Subservice as ModeSubservice -from common import AcsId, Apid +from pytmtc.common import Apid +from pytmtc.mgms import create_mgm_cmds _LOGGER = logging.getLogger(__name__) -def create_set_mode_cmd( - apid: int, unique_id: int, mode: int, submode: int -) -> PusTelecommand: - app_data = bytearray() - app_data.extend(struct.pack("!I", unique_id)) - app_data.extend(struct.pack("!I", mode)) - app_data.extend(struct.pack("!H", submode)) - return PusTelecommand( - service=200, - subservice=ModeSubservice.TC_MODE_COMMAND, - apid=apid, - app_data=app_data, - ) +class TcHandler(TcHandlerBase): + def __init__( + self, + seq_count_provider: FileSeqCountProvider, + verif_wrapper: VerificationWrapper, + ): + super(TcHandler, self).__init__() + self.seq_count_provider = seq_count_provider + self.verif_wrapper = verif_wrapper + self.queue_helper = DefaultPusQueueHelper( + queue_wrapper=QueueWrapper.empty(), + tc_sched_timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE, + seq_cnt_provider=seq_count_provider, + pus_verificator=self.verif_wrapper.pus_verificator, + default_pus_apid=None, + ) + + def send_cb(self, send_params: SendCbParams): + entry_helper = send_params.entry + if entry_helper.is_tc: + if entry_helper.entry_type == TcQueueEntryType.PUS_TC: + pus_tc_wrapper = entry_helper.to_pus_tc_entry() + raw_tc = pus_tc_wrapper.pus_tc.pack() + _LOGGER.info(f"Sending {pus_tc_wrapper.pus_tc}") + send_params.com_if.send(raw_tc) + elif entry_helper.entry_type == TcQueueEntryType.LOG: + log_entry = entry_helper.to_log_entry() + _LOGGER.info(log_entry.log_str) + + def queue_finished_cb(self, info: ProcedureWrapper): + if info.proc_type == TcProcedureType.TREE_COMMANDING: + def_proc = info.to_tree_commanding_procedure() + _LOGGER.info(f"Queue handling finished for command {def_proc.cmd_path}") + + def feed_cb(self, info: ProcedureWrapper, wrapper: FeedWrapper): + q = self.queue_helper + q.queue_wrapper = wrapper.queue_wrapper + if info.proc_type == TcProcedureType.TREE_COMMANDING: + def_proc = info.to_tree_commanding_procedure() + assert def_proc.cmd_path is not None + pack_pus_telecommands(q, def_proc.cmd_path) def create_cmd_definition_tree() -> CmdTreeNode: @@ -112,32 +148,4 @@ def pack_pus_telecommands(q: DefaultPusQueueHelper, cmd_path: str): if cmd_path_list[0] == "acs": assert len(cmd_path_list) >= 2 if cmd_path_list[1] == "mgms": - assert len(cmd_path_list) >= 3 - if cmd_path_list[2] == "hk": - if cmd_path_list[3] == "one_shot_hk": - q.add_log_cmd("Sending HK one shot request") - # TODO: Fix - # q.add_pus_tc( - # create_request_one_hk_command( - # make_addressable_id(Apid.ACS, AcsId.MGM_SET) - # ) - # ) - if cmd_path_list[2] == "mode": - if cmd_path_list[3] == "set_mode": - handle_set_mode_cmd( - q, "MGM 0", cmd_path_list[4], Apid.ACS, AcsId.MGM_0 - ) - - -def handle_set_mode_cmd( - q: DefaultPusQueueHelper, target_str: str, mode_str: str, apid: int, unique_id: int -): - if mode_str == "off": - q.add_log_cmd(f"Sending Mode OFF to {target_str}") - q.add_pus_tc(create_set_mode_cmd(apid, unique_id, Mode.OFF, 0)) - elif mode_str == "on": - q.add_log_cmd(f"Sending Mode ON to {target_str}") - q.add_pus_tc(create_set_mode_cmd(apid, unique_id, Mode.ON, 0)) - elif mode_str == "normal": - q.add_log_cmd(f"Sending Mode NORMAL to {target_str}") - q.add_pus_tc(create_set_mode_cmd(apid, unique_id, Mode.NORMAL, 0)) + create_mgm_cmds(q, cmd_path_list) diff --git a/satrs-example/pytmtc/pytmtc/pus_tm.py b/satrs-example/pytmtc/pytmtc/pus_tm.py new file mode 100644 index 0000000..ed55212 --- /dev/null +++ b/satrs-example/pytmtc/pytmtc/pus_tm.py @@ -0,0 +1,93 @@ +import logging +from typing import Any +from spacepackets.ccsds.time import CdsShortTimestamp +from spacepackets.ecss import PusTm +from spacepackets.ecss.pus_17_test import Service17Tm +from spacepackets.ecss.pus_1_verification import Service1Tm, UnpackParams +from tmtccmd.logging.pus import RawTmtcTimedLogWrapper +from tmtccmd.pus import VerificationWrapper +from tmtccmd.tmtc import GenericApidHandlerBase + +from pytmtc.common import Apid, EventU32 +from pytmtc.hk import handle_hk_packet + + +_LOGGER = logging.getLogger(__name__) + + +class PusHandler(GenericApidHandlerBase): + def __init__( + self, + file_logger: logging.Logger, + verif_wrapper: VerificationWrapper, + raw_logger: RawTmtcTimedLogWrapper, + ): + super().__init__(None) + self.file_logger = file_logger + self.raw_logger = raw_logger + self.verif_wrapper = verif_wrapper + + def handle_tm(self, apid: int, packet: bytes, _user_args: Any): + try: + pus_tm = PusTm.unpack( + packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE + ) + except ValueError as e: + _LOGGER.warning("Could not generate PUS TM object from raw data") + _LOGGER.warning(f"Raw Packet: [{packet.hex(sep=',')}], REPR: {packet!r}") + raise e + service = pus_tm.service + if service == 1: + tm_packet = Service1Tm.unpack( + data=packet, params=UnpackParams(CdsShortTimestamp.TIMESTAMP_SIZE, 1, 2) + ) + res = self.verif_wrapper.add_tm(tm_packet) + if res is None: + _LOGGER.info( + f"Received Verification TM[{tm_packet.service}, {tm_packet.subservice}] " + f"with Request ID {tm_packet.tc_req_id.as_u32():#08x}" + ) + _LOGGER.warning( + f"No matching telecommand found for {tm_packet.tc_req_id}" + ) + else: + self.verif_wrapper.log_to_console(tm_packet, res) + self.verif_wrapper.log_to_file(tm_packet, res) + elif service == 3: + pus_tm = PusTm.unpack( + packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE + ) + handle_hk_packet(pus_tm) + elif service == 5: + tm_packet = PusTm.unpack( + packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE + ) + src_data = tm_packet.source_data + event_u32 = EventU32.unpack(src_data) + _LOGGER.info( + f"Received event packet. Source APID: {Apid(tm_packet.apid)!r}, Event: {event_u32}" + ) + if event_u32.group_id == 0 and event_u32.unique_id == 0: + _LOGGER.info("Received test event") + elif service == 17: + tm_packet = Service17Tm.unpack( + packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE + ) + if tm_packet.subservice == 2: + self.file_logger.info("Received Ping Reply TM[17,2]") + _LOGGER.info("Received Ping Reply TM[17,2]") + else: + self.file_logger.info( + f"Received Test Packet with unknown subservice {tm_packet.subservice}" + ) + _LOGGER.info( + f"Received Test Packet with unknown subservice {tm_packet.subservice}" + ) + else: + _LOGGER.info( + f"The service {service} is not implemented in Telemetry Factory" + ) + tm_packet = PusTm.unpack( + packet, timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE + ) + self.raw_logger.log_tm(pus_tm) diff --git a/satrs-example/pytmtc/requirements.txt b/satrs-example/pytmtc/requirements.txt index 325615c..9c558e3 100644 --- a/satrs-example/pytmtc/requirements.txt +++ b/satrs-example/pytmtc/requirements.txt @@ -1,2 +1 @@ -tmtccmd == 8.0.0rc2 -# -e git+https://github.com/robamu-org/tmtccmd@97e5e51101a08b21472b3ddecc2063359f7e307a#egg=tmtccmd +. diff --git a/satrs-example/pytmtc/tc_definitions.py b/satrs-example/pytmtc/tc_definitions.py deleted file mode 100644 index 74fbff8..0000000 --- a/satrs-example/pytmtc/tc_definitions.py +++ /dev/null @@ -1,38 +0,0 @@ -from tmtccmd.config import OpCodeEntry, TmtcDefinitionWrapper, CoreServiceList -from tmtccmd.config.globals import get_default_tmtc_defs - -from common import HkOpCodes - - -def tc_definitions() -> TmtcDefinitionWrapper: - defs = get_default_tmtc_defs() - srv_5 = OpCodeEntry() - srv_5.add("0", "Event Test") - defs.add_service( - name=CoreServiceList.SERVICE_5.value, - info="PUS Service 5 Event", - op_code_entry=srv_5, - ) - srv_17 = OpCodeEntry() - srv_17.add("ping", "Ping Test") - srv_17.add("trigger_event", "Trigger Event") - defs.add_service( - name=CoreServiceList.SERVICE_17_ALT, - info="PUS Service 17 Test", - op_code_entry=srv_17, - ) - srv_3 = OpCodeEntry() - srv_3.add(HkOpCodes.GENERATE_ONE_SHOT, "Generate AOCS one shot HK") - defs.add_service( - name=CoreServiceList.SERVICE_3, - info="PUS Service 3 Housekeeping", - op_code_entry=srv_3, - ) - srv_11 = OpCodeEntry() - srv_11.add("0", "Scheduled TC Test") - defs.add_service( - name=CoreServiceList.SERVICE_11, - info="PUS Service 11 TC Scheduling", - op_code_entry=srv_11, - ) - return defs diff --git a/satrs-example/pytmtc/tests/__init__.py b/satrs-example/pytmtc/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/satrs-example/pytmtc/tests/test_tc_mods.py b/satrs-example/pytmtc/tests/test_tc_mods.py new file mode 100644 index 0000000..0b56bde --- /dev/null +++ b/satrs-example/pytmtc/tests/test_tc_mods.py @@ -0,0 +1,48 @@ +from unittest import TestCase + +from spacepackets.ccsds import CdsShortTimestamp +from tmtccmd.tmtc import DefaultPusQueueHelper, QueueEntryHelper +from tmtccmd.tmtc.queue import QueueWrapper + +from pytmtc.config import SatrsConfigHook +from pytmtc.pus_tc import pack_pus_telecommands + + +class TestTcModules(TestCase): + def setUp(self): + self.hook = SatrsConfigHook(json_cfg_path="tmtc_conf.json") + self.queue_helper = DefaultPusQueueHelper( + queue_wrapper=QueueWrapper.empty(), + tc_sched_timestamp_len=CdsShortTimestamp.TIMESTAMP_SIZE, + seq_cnt_provider=None, + pus_verificator=None, + default_pus_apid=None, + ) + + def test_cmd_tree_creation_works_without_errors(self): + cmd_defs = self.hook.get_command_definitions() + self.assertIsNotNone(cmd_defs) + + def test_ping_cmd_generation(self): + pack_pus_telecommands(self.queue_helper, "/test/ping") + queue_entry = self.queue_helper.queue_wrapper.queue.popleft() + entry_helper = QueueEntryHelper(queue_entry) + log_queue = entry_helper.to_log_entry() + self.assertEqual(log_queue.log_str, "Sending PUS ping telecommand") + queue_entry = self.queue_helper.queue_wrapper.queue.popleft() + entry_helper.entry = queue_entry + pus_tc_entry = entry_helper.to_pus_tc_entry() + self.assertEqual(pus_tc_entry.pus_tc.service, 17) + self.assertEqual(pus_tc_entry.pus_tc.subservice, 1) + + def test_event_trigger_generation(self): + pack_pus_telecommands(self.queue_helper, "/test/trigger_event") + queue_entry = self.queue_helper.queue_wrapper.queue.popleft() + entry_helper = QueueEntryHelper(queue_entry) + log_queue = entry_helper.to_log_entry() + self.assertEqual(log_queue.log_str, "Triggering test event") + queue_entry = self.queue_helper.queue_wrapper.queue.popleft() + entry_helper.entry = queue_entry + pus_tc_entry = entry_helper.to_pus_tc_entry() + self.assertEqual(pus_tc_entry.pus_tc.service, 17) + self.assertEqual(pus_tc_entry.pus_tc.subservice, 128) diff --git a/satrs-example/src/acs/mgm.rs b/satrs-example/src/acs/mgm.rs index d50bc6d..b75e126 100644 --- a/satrs-example/src/acs/mgm.rs +++ b/satrs-example/src/acs/mgm.rs @@ -1,52 +1,126 @@ use derive_new::new; use satrs::hk::{HkRequest, HkRequestVariant}; +use satrs::power::{PowerSwitchInfo, PowerSwitcherCommandSender}; use satrs::queue::{GenericSendError, GenericTargetedMessagingError}; -use satrs::spacepackets::ecss::hk; -use satrs::spacepackets::ecss::tm::{PusTmCreator, PusTmSecondaryHeader}; -use satrs::spacepackets::SpHeader; -use satrs_example::{DeviceMode, TimeStampHelper}; +use satrs_example::{DeviceMode, TimestampHelper}; +use satrs_minisim::acs::lis3mdl::{ + MgmLis3MdlReply, MgmLis3RawValues, FIELD_LSB_PER_GAUSS_4_SENS, GAUSS_TO_MICROTESLA_FACTOR, +}; +use satrs_minisim::acs::MgmRequestLis3Mdl; +use satrs_minisim::eps::PcduSwitch; +use satrs_minisim::{SerializableSimMsgPayload, SimReply, SimRequest}; +use std::fmt::Debug; use std::sync::mpsc::{self}; use std::sync::{Arc, Mutex}; +use std::time::Duration; use satrs::mode::{ ModeAndSubmode, ModeError, ModeProvider, ModeReply, ModeRequest, ModeRequestHandler, }; use satrs::pus::{EcssTmSender, PusTmVariant}; use satrs::request::{GenericMessage, MessageMetadata, UniqueApidTargetId}; -use satrs_example::config::components::PUS_MODE_SERVICE; +use satrs_example::config::components::{NO_SENDER, PUS_MODE_SERVICE}; +use crate::hk::PusHkHelper; use crate::pus::hk::{HkReply, HkReplyVariant}; use crate::requests::CompositeRequest; use serde::{Deserialize, Serialize}; -const GAUSS_TO_MICROTESLA_FACTOR: f32 = 100.0; -// This is the selected resoltion for the STM LIS3MDL device for the 4 Gauss sensitivity setting. -const FIELD_LSB_PER_GAUSS_4_SENS: f32 = 1.0 / 6842.0; +pub const NR_OF_DATA_AND_CFG_REGISTERS: usize = 14; + +// Register adresses to access various bytes from the raw reply. +pub const X_LOWBYTE_IDX: usize = 9; +pub const Y_LOWBYTE_IDX: usize = 11; +pub const Z_LOWBYTE_IDX: usize = 13; + +#[derive(Debug, Copy, Clone, Serialize, Deserialize)] +#[repr(u32)] +pub enum SetId { + SensorData = 0, +} + +#[derive(Default, Debug, PartialEq, Eq)] +pub enum TransitionState { + #[default] + Idle, + PowerSwitching, + Done, +} pub trait SpiInterface { - type Error; + type Error: Debug; fn transfer(&mut self, tx: &[u8], rx: &mut [u8]) -> Result<(), Self::Error>; } #[derive(Default)] pub struct SpiDummyInterface { - pub dummy_val_0: i16, - pub dummy_val_1: i16, - pub dummy_val_2: i16, + pub dummy_values: MgmLis3RawValues, } impl SpiInterface for SpiDummyInterface { type Error = (); fn transfer(&mut self, _tx: &[u8], rx: &mut [u8]) -> Result<(), Self::Error> { - rx[0..2].copy_from_slice(&self.dummy_val_0.to_be_bytes()); - rx[2..4].copy_from_slice(&self.dummy_val_1.to_be_bytes()); - rx[4..6].copy_from_slice(&self.dummy_val_2.to_be_bytes()); + rx[X_LOWBYTE_IDX..X_LOWBYTE_IDX + 2].copy_from_slice(&self.dummy_values.x.to_le_bytes()); + rx[Y_LOWBYTE_IDX..Y_LOWBYTE_IDX + 2].copy_from_slice(&self.dummy_values.y.to_be_bytes()); + rx[Z_LOWBYTE_IDX..Z_LOWBYTE_IDX + 2].copy_from_slice(&self.dummy_values.z.to_be_bytes()); Ok(()) } } +pub struct SpiSimInterface { + pub sim_request_tx: mpsc::Sender, + pub sim_reply_rx: mpsc::Receiver, +} + +impl SpiInterface for SpiSimInterface { + type Error = (); + + // Right now, we only support requesting sensor data and not configuration of the sensor. + fn transfer(&mut self, _tx: &[u8], rx: &mut [u8]) -> Result<(), Self::Error> { + let mgm_sensor_request = MgmRequestLis3Mdl::RequestSensorData; + if let Err(e) = self + .sim_request_tx + .send(SimRequest::new_with_epoch_time(mgm_sensor_request)) + { + log::error!("failed to send MGM LIS3 request: {}", e); + } + match self.sim_reply_rx.recv_timeout(Duration::from_millis(50)) { + Ok(sim_reply) => { + let sim_reply_lis3 = MgmLis3MdlReply::from_sim_message(&sim_reply) + .expect("failed to parse LIS3 reply"); + rx[X_LOWBYTE_IDX..X_LOWBYTE_IDX + 2] + .copy_from_slice(&sim_reply_lis3.raw.x.to_le_bytes()); + rx[Y_LOWBYTE_IDX..Y_LOWBYTE_IDX + 2] + .copy_from_slice(&sim_reply_lis3.raw.y.to_le_bytes()); + rx[Z_LOWBYTE_IDX..Z_LOWBYTE_IDX + 2] + .copy_from_slice(&sim_reply_lis3.raw.z.to_le_bytes()); + } + Err(e) => { + log::warn!("MGM LIS3 SIM reply timeout: {}", e); + } + } + Ok(()) + } +} + +pub enum SpiSimInterfaceWrapper { + Dummy(SpiDummyInterface), + Sim(SpiSimInterface), +} + +impl SpiInterface for SpiSimInterfaceWrapper { + type Error = (); + + fn transfer(&mut self, tx: &[u8], rx: &mut [u8]) -> Result<(), Self::Error> { + match self { + SpiSimInterfaceWrapper::Dummy(dummy) => dummy.transfer(tx, rx), + SpiSimInterfaceWrapper::Sim(sim_if) => sim_if.transfer(tx, rx), + } + } +} + #[derive(Default, Debug, Copy, Clone, Serialize, Deserialize)] pub struct MgmData { pub valid: bool, @@ -57,61 +131,85 @@ pub struct MgmData { pub struct MpscModeLeafInterface { pub request_rx: mpsc::Receiver>, - pub reply_tx_to_pus: mpsc::Sender>, - pub reply_tx_to_parent: mpsc::Sender>, + pub reply_to_pus_tx: mpsc::Sender>, + pub reply_to_parent_tx: mpsc::SyncSender>, +} + +#[derive(Default)] +pub struct BufWrapper { + tx_buf: [u8; 32], + rx_buf: [u8; 32], + tm_buf: [u8; 32], +} + +pub struct ModeHelpers { + current: ModeAndSubmode, + target: Option, + requestor_info: Option, + transition_state: TransitionState, +} + +impl Default for ModeHelpers { + fn default() -> Self { + Self { + current: ModeAndSubmode::new(DeviceMode::Off as u32, 0), + target: Default::default(), + requestor_info: Default::default(), + transition_state: Default::default(), + } + } } /// Example MGM device handler strongly based on the LIS3MDL MEMS device. #[derive(new)] #[allow(clippy::too_many_arguments)] -pub struct MgmHandlerLis3Mdl { +pub struct MgmHandlerLis3Mdl< + ComInterface: SpiInterface, + TmSender: EcssTmSender, + SwitchHelper: PowerSwitchInfo + PowerSwitcherCommandSender, +> { id: UniqueApidTargetId, dev_str: &'static str, mode_interface: MpscModeLeafInterface, - composite_request_receiver: mpsc::Receiver>, - hk_reply_sender: mpsc::Sender>, + composite_request_rx: mpsc::Receiver>, + hk_reply_tx: mpsc::Sender>, + switch_helper: SwitchHelper, tm_sender: TmSender, - com_interface: ComInterface, + pub com_interface: ComInterface, shared_mgm_set: Arc>, - #[new(value = "ModeAndSubmode::new(satrs_example::DeviceMode::Off as u32, 0)")] - mode_and_submode: ModeAndSubmode, + #[new(value = "PusHkHelper::new(id)")] + hk_helper: PusHkHelper, #[new(default)] - tx_buf: [u8; 12], + mode_helpers: ModeHelpers, #[new(default)] - rx_buf: [u8; 12], + bufs: BufWrapper, #[new(default)] - tm_buf: [u8; 16], - #[new(default)] - stamp_helper: TimeStampHelper, + stamp_helper: TimestampHelper, } -impl MgmHandlerLis3Mdl { +impl< + ComInterface: SpiInterface, + TmSender: EcssTmSender, + SwitchHelper: PowerSwitchInfo + PowerSwitcherCommandSender, + > MgmHandlerLis3Mdl +{ pub fn periodic_operation(&mut self) { self.stamp_helper.update_from_now(); // Handle requests. self.handle_composite_requests(); self.handle_mode_requests(); + if let Some(target_mode_submode) = self.mode_helpers.target { + self.handle_mode_transition(target_mode_submode); + } if self.mode() == DeviceMode::Normal as u32 { log::trace!("polling LIS3MDL sensor {}", self.dev_str); - // Communicate with the device. - let result = self.com_interface.transfer(&self.tx_buf, &mut self.rx_buf); - assert!(result.is_ok()); - // Actual data begins on the second byte, similarly to how a lot of SPI devices behave. - let x_raw = i16::from_be_bytes(self.rx_buf[1..3].try_into().unwrap()); - let y_raw = i16::from_be_bytes(self.rx_buf[3..5].try_into().unwrap()); - let z_raw = i16::from_be_bytes(self.rx_buf[5..7].try_into().unwrap()); - // Simple scaling to retrieve the float value, assuming a sensor resolution of - let mut mgm_guard = self.shared_mgm_set.lock().unwrap(); - mgm_guard.x = x_raw as f32 * GAUSS_TO_MICROTESLA_FACTOR * FIELD_LSB_PER_GAUSS_4_SENS; - mgm_guard.y = y_raw as f32 * GAUSS_TO_MICROTESLA_FACTOR * FIELD_LSB_PER_GAUSS_4_SENS; - mgm_guard.z = z_raw as f32 * GAUSS_TO_MICROTESLA_FACTOR * FIELD_LSB_PER_GAUSS_4_SENS; - drop(mgm_guard); + self.poll_sensor(); } } pub fn handle_composite_requests(&mut self) { loop { - match self.composite_request_receiver.try_recv() { + match self.composite_request_rx.try_recv() { Ok(ref msg) => match &msg.message { CompositeRequest::Hk(hk_request) => { self.handle_hk_request(&msg.requestor_info, hk_request) @@ -139,34 +237,33 @@ impl MgmHandlerLis3Mdl { - self.hk_reply_sender - .send(GenericMessage::new( - *requestor_info, - HkReply::new(hk_request.unique_id, HkReplyVariant::Ack), - )) - .expect("failed to send HK reply"); - let sec_header = PusTmSecondaryHeader::new( - 3, - hk::Subservice::TmHkPacket as u8, - 0, - 0, - self.stamp_helper.stamp(), - ); let mgm_snapshot = *self.shared_mgm_set.lock().unwrap(); - // Use binary serialization here. We want the data to be tightly packed. - self.tm_buf[0] = mgm_snapshot.valid as u8; - self.tm_buf[1..5].copy_from_slice(&mgm_snapshot.x.to_be_bytes()); - self.tm_buf[5..9].copy_from_slice(&mgm_snapshot.y.to_be_bytes()); - self.tm_buf[9..13].copy_from_slice(&mgm_snapshot.z.to_be_bytes()); - let hk_tm = PusTmCreator::new( - SpHeader::new_from_apid(self.id.apid), - sec_header, - &self.tm_buf[0..12], - true, - ); - self.tm_sender - .send_tm(self.id.id(), PusTmVariant::Direct(hk_tm)) - .expect("failed to send HK TM"); + if let Ok(hk_tm) = self.hk_helper.generate_hk_report_packet( + self.stamp_helper.stamp(), + SetId::SensorData as u32, + &mut |hk_buf| { + hk_buf[0] = mgm_snapshot.valid as u8; + hk_buf[1..5].copy_from_slice(&mgm_snapshot.x.to_be_bytes()); + hk_buf[5..9].copy_from_slice(&mgm_snapshot.y.to_be_bytes()); + hk_buf[9..13].copy_from_slice(&mgm_snapshot.z.to_be_bytes()); + Ok(13) + }, + &mut self.bufs.tm_buf, + ) { + // TODO: If sending the TM fails, we should also send a failure reply. + self.tm_sender + .send_tm(self.id.id(), PusTmVariant::Direct(hk_tm)) + .expect("failed to send HK TM"); + self.hk_reply_tx + .send(GenericMessage::new( + *requestor_info, + HkReply::new(hk_request.unique_id, HkReplyVariant::Ack), + )) + .expect("failed to send HK reply"); + } else { + // TODO: Send back failure reply. Need result code for this. + log::error!("TM buffer too small to generate HK data"); + } } HkRequestVariant::EnablePeriodic => todo!(), HkRequestVariant::DisablePeriodic => todo!(), @@ -199,20 +296,91 @@ impl MgmHandlerLis3Mdl ModeProvider - for MgmHandlerLis3Mdl -{ - fn mode_and_submode(&self) -> ModeAndSubmode { - self.mode_and_submode + pub fn poll_sensor(&mut self) { + // Communicate with the device. This is actually how to read the data from the LIS3 device + // SPI interface. + self.com_interface + .transfer( + &self.bufs.tx_buf[0..NR_OF_DATA_AND_CFG_REGISTERS + 1], + &mut self.bufs.rx_buf[0..NR_OF_DATA_AND_CFG_REGISTERS + 1], + ) + .expect("failed to transfer data"); + let x_raw = i16::from_le_bytes( + self.bufs.rx_buf[X_LOWBYTE_IDX..X_LOWBYTE_IDX + 2] + .try_into() + .unwrap(), + ); + let y_raw = i16::from_le_bytes( + self.bufs.rx_buf[Y_LOWBYTE_IDX..Y_LOWBYTE_IDX + 2] + .try_into() + .unwrap(), + ); + let z_raw = i16::from_le_bytes( + self.bufs.rx_buf[Z_LOWBYTE_IDX..Z_LOWBYTE_IDX + 2] + .try_into() + .unwrap(), + ); + // Simple scaling to retrieve the float value, assuming the best sensor resolution. + let mut mgm_guard = self.shared_mgm_set.lock().unwrap(); + mgm_guard.x = x_raw as f32 * GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS; + mgm_guard.y = y_raw as f32 * GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS; + mgm_guard.z = z_raw as f32 * GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS; + mgm_guard.valid = true; + drop(mgm_guard); + } + + pub fn handle_mode_transition(&mut self, target_mode_submode: ModeAndSubmode) { + if target_mode_submode.mode() == DeviceMode::On as u32 + || target_mode_submode.mode() == DeviceMode::Normal as u32 + { + if self.mode_helpers.transition_state == TransitionState::Idle { + let result = self + .switch_helper + .send_switch_on_cmd(MessageMetadata::new(0, self.id.id()), PcduSwitch::Mgm); + if result.is_err() { + // Could not send switch command.. still continue with transition. + log::error!("failed to send switch on command"); + } + self.mode_helpers.transition_state = TransitionState::PowerSwitching; + } + if self.mode_helpers.transition_state == TransitionState::PowerSwitching + && self + .switch_helper + .is_switch_on(PcduSwitch::Mgm) + .expect("switch info error") + { + self.mode_helpers.transition_state = TransitionState::Done; + } + if self.mode_helpers.transition_state == TransitionState::Done { + self.mode_helpers.current = self.mode_helpers.target.unwrap(); + self.handle_mode_reached(self.mode_helpers.requestor_info) + .expect("failed to handle mode reached"); + self.mode_helpers.transition_state = TransitionState::Idle; + } + } } } -impl ModeRequestHandler - for MgmHandlerLis3Mdl +impl< + ComInterface: SpiInterface, + TmSender: EcssTmSender, + SwitchHelper: PowerSwitchInfo + PowerSwitcherCommandSender, + > ModeProvider for MgmHandlerLis3Mdl +{ + fn mode_and_submode(&self) -> ModeAndSubmode { + self.mode_helpers.current + } +} + +impl< + ComInterface: SpiInterface, + TmSender: EcssTmSender, + SwitchHelper: PowerSwitchInfo + PowerSwitcherCommandSender, + > ModeRequestHandler for MgmHandlerLis3Mdl { type Error = ModeError; + fn start_transition( &mut self, requestor: MessageMetadata, @@ -223,8 +391,18 @@ impl ModeRequestHandler self.dev_str, mode_and_submode ); - self.mode_and_submode = mode_and_submode; - self.handle_mode_reached(Some(requestor))?; + self.mode_helpers.current = mode_and_submode; + if mode_and_submode.mode() == DeviceMode::Off as u32 { + self.shared_mgm_set.lock().unwrap().valid = false; + self.handle_mode_reached(Some(requestor))?; + } else if mode_and_submode.mode() == DeviceMode::Normal as u32 + || mode_and_submode.mode() == DeviceMode::On as u32 + { + // TODO: Write helper method for the struct? Might help for other handlers as well.. + self.mode_helpers.transition_state = TransitionState::Idle; + self.mode_helpers.requestor_info = Some(requestor); + self.mode_helpers.target = Some(mode_and_submode); + } Ok(()) } @@ -232,7 +410,7 @@ impl ModeRequestHandler log::info!( "{} announcing mode: {:?}", self.dev_str, - self.mode_and_submode + self.mode_and_submode() ); } @@ -240,11 +418,15 @@ impl ModeRequestHandler &mut self, requestor: Option, ) -> Result<(), Self::Error> { + self.mode_helpers.target = None; self.announce_mode(requestor, false); if let Some(requestor) = requestor { + if requestor.sender_id() == NO_SENDER { + return Ok(()); + } if requestor.sender_id() != PUS_MODE_SERVICE.id() { log::warn!( - "can not send back mode reply to sender {}", + "can not send back mode reply to sender {:x}", requestor.sender_id() ); } else { @@ -266,7 +448,7 @@ impl ModeRequestHandler ); } self.mode_interface - .reply_tx_to_pus + .reply_to_pus_tx .send(GenericMessage::new(requestor, reply)) .map_err(|_| GenericTargetedMessagingError::Send(GenericSendError::RxDisconnected))?; Ok(()) @@ -280,3 +462,193 @@ impl ModeRequestHandler Ok(()) } } + +#[cfg(test)] +mod tests { + use std::sync::{mpsc, Arc}; + + use satrs::{ + mode::{ModeReply, ModeRequest}, + power::SwitchStateBinary, + request::{GenericMessage, UniqueApidTargetId}, + tmtc::PacketAsVec, + }; + use satrs_example::config::components::Apid; + use satrs_minisim::acs::lis3mdl::MgmLis3RawValues; + + use crate::{eps::TestSwitchHelper, pus::hk::HkReply, requests::CompositeRequest}; + + use super::*; + + #[derive(Default)] + pub struct TestSpiInterface { + pub call_count: u32, + pub next_mgm_data: MgmLis3RawValues, + } + + impl SpiInterface for TestSpiInterface { + type Error = (); + + fn transfer(&mut self, _tx: &[u8], rx: &mut [u8]) -> Result<(), Self::Error> { + rx[X_LOWBYTE_IDX..X_LOWBYTE_IDX + 2] + .copy_from_slice(&self.next_mgm_data.x.to_le_bytes()); + rx[Y_LOWBYTE_IDX..Y_LOWBYTE_IDX + 2] + .copy_from_slice(&self.next_mgm_data.y.to_le_bytes()); + rx[Z_LOWBYTE_IDX..Z_LOWBYTE_IDX + 2] + .copy_from_slice(&self.next_mgm_data.z.to_le_bytes()); + self.call_count += 1; + Ok(()) + } + } + + pub struct MgmTestbench { + pub mode_request_tx: mpsc::Sender>, + pub mode_reply_rx_to_pus: mpsc::Receiver>, + pub mode_reply_rx_to_parent: mpsc::Receiver>, + pub composite_request_tx: mpsc::Sender>, + pub hk_reply_rx: mpsc::Receiver>, + pub tm_rx: mpsc::Receiver, + pub handler: + MgmHandlerLis3Mdl, TestSwitchHelper>, + } + + impl MgmTestbench { + pub fn new() -> Self { + let (request_tx, request_rx) = mpsc::channel(); + let (reply_tx_to_pus, reply_rx_to_pus) = mpsc::channel(); + let (reply_tx_to_parent, reply_rx_to_parent) = mpsc::sync_channel(5); + let mode_interface = MpscModeLeafInterface { + request_rx, + reply_to_pus_tx: reply_tx_to_pus, + reply_to_parent_tx: reply_tx_to_parent, + }; + let (composite_request_tx, composite_request_rx) = mpsc::channel(); + let (hk_reply_tx, hk_reply_rx) = mpsc::channel(); + let (tm_tx, tm_rx) = mpsc::channel::(); + let shared_mgm_set = Arc::default(); + Self { + mode_request_tx: request_tx, + mode_reply_rx_to_pus: reply_rx_to_pus, + mode_reply_rx_to_parent: reply_rx_to_parent, + composite_request_tx, + tm_rx, + hk_reply_rx, + handler: MgmHandlerLis3Mdl::new( + UniqueApidTargetId::new(Apid::Acs as u16, 1), + "TEST_MGM", + mode_interface, + composite_request_rx, + hk_reply_tx, + TestSwitchHelper::default(), + tm_tx, + TestSpiInterface::default(), + shared_mgm_set, + ), + } + } + } + + #[test] + fn test_basic_handler() { + let mut testbench = MgmTestbench::new(); + assert_eq!(testbench.handler.com_interface.call_count, 0); + assert_eq!( + testbench.handler.mode_and_submode().mode(), + DeviceMode::Off as u32 + ); + assert_eq!(testbench.handler.mode_and_submode().submode(), 0_u16); + testbench.handler.periodic_operation(); + // Handler is OFF, no changes expected. + assert_eq!(testbench.handler.com_interface.call_count, 0); + assert_eq!( + testbench.handler.mode_and_submode().mode(), + DeviceMode::Off as u32 + ); + assert_eq!(testbench.handler.mode_and_submode().submode(), 0_u16); + } + + #[test] + fn test_normal_handler() { + let mut testbench = MgmTestbench::new(); + testbench + .mode_request_tx + .send(GenericMessage::new( + MessageMetadata::new(0, PUS_MODE_SERVICE.id()), + ModeRequest::SetMode(ModeAndSubmode::new(DeviceMode::Normal as u32, 0)), + )) + .expect("failed to send mode request"); + testbench.handler.periodic_operation(); + assert_eq!( + testbench.handler.mode_and_submode().mode(), + DeviceMode::Normal as u32 + ); + assert_eq!(testbench.handler.mode_and_submode().submode(), 0); + + // Verify power switch handling. + let mut switch_requests = testbench.handler.switch_helper.switch_requests.borrow_mut(); + assert_eq!(switch_requests.len(), 1); + let switch_req = switch_requests.pop_front().expect("no switch request"); + assert_eq!(switch_req.target_state, SwitchStateBinary::On); + assert_eq!(switch_req.switch_id, PcduSwitch::Mgm); + let mut switch_info_requests = testbench + .handler + .switch_helper + .switch_info_requests + .borrow_mut(); + assert_eq!(switch_info_requests.len(), 1); + let switch_info_req = switch_info_requests.pop_front().expect("no switch request"); + assert_eq!(switch_info_req, PcduSwitch::Mgm); + + let mode_reply = testbench + .mode_reply_rx_to_pus + .try_recv() + .expect("no mode reply generated"); + match mode_reply.message { + ModeReply::ModeReply(mode) => { + assert_eq!(mode.mode(), DeviceMode::Normal as u32); + assert_eq!(mode.submode(), 0); + } + _ => panic!("unexpected mode reply"), + } + // The device should have been polled once. + assert_eq!(testbench.handler.com_interface.call_count, 1); + let mgm_set = *testbench.handler.shared_mgm_set.lock().unwrap(); + assert!(mgm_set.x < 0.001); + assert!(mgm_set.y < 0.001); + assert!(mgm_set.z < 0.001); + assert!(mgm_set.valid); + } + + #[test] + fn test_normal_handler_mgm_set_conversion() { + let mut testbench = MgmTestbench::new(); + let raw_values = MgmLis3RawValues { + x: 1000, + y: -1000, + z: 1000, + }; + testbench.handler.com_interface.next_mgm_data = raw_values; + testbench + .mode_request_tx + .send(GenericMessage::new( + MessageMetadata::new(0, PUS_MODE_SERVICE.id()), + ModeRequest::SetMode(ModeAndSubmode::new(DeviceMode::Normal as u32, 0)), + )) + .expect("failed to send mode request"); + testbench.handler.periodic_operation(); + let mgm_set = *testbench.handler.shared_mgm_set.lock().unwrap(); + let expected_x = + raw_values.x as f32 * GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS; + let expected_y = + raw_values.y as f32 * GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS; + let expected_z = + raw_values.z as f32 * GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS; + let x_diff = (mgm_set.x - expected_x).abs(); + let y_diff = (mgm_set.y - expected_y).abs(); + let z_diff = (mgm_set.z - expected_z).abs(); + assert!(x_diff < 0.001, "x diff too large: {}", x_diff); + assert!(y_diff < 0.001, "y diff too large: {}", y_diff); + assert!(z_diff < 0.001, "z diff too large: {}", z_diff); + assert!(mgm_set.valid); + } +} diff --git a/satrs-example/src/config.rs b/satrs-example/src/config.rs index d84ad2e..5608bf3 100644 --- a/satrs-example/src/config.rs +++ b/satrs-example/src/config.rs @@ -122,7 +122,7 @@ pub mod mode_err { } pub mod components { - use satrs::request::UniqueApidTargetId; + use satrs::{request::UniqueApidTargetId, ComponentId}; use strum::EnumIter; #[derive(Copy, Clone, PartialEq, Eq, EnumIter)] @@ -132,6 +132,7 @@ pub mod components { Acs = 3, Cfdp = 4, Tmtc = 5, + Eps = 6, } // Component IDs for components with the PUS APID. @@ -150,6 +151,11 @@ pub mod components { Mgm0 = 0, } + #[derive(Copy, Clone, PartialEq, Eq)] + pub enum EpsId { + Pcdu = 0, + } + #[derive(Copy, Clone, PartialEq, Eq)] pub enum TmtcId { UdpServer = 0, @@ -172,10 +178,13 @@ pub mod components { UniqueApidTargetId::new(Apid::Sched as u16, 0); pub const MGM_HANDLER_0: UniqueApidTargetId = UniqueApidTargetId::new(Apid::Acs as u16, AcsId::Mgm0 as u32); + pub const PCDU_HANDLER: UniqueApidTargetId = + UniqueApidTargetId::new(Apid::Eps as u16, EpsId::Pcdu as u32); pub const UDP_SERVER: UniqueApidTargetId = UniqueApidTargetId::new(Apid::Tmtc as u16, TmtcId::UdpServer as u32); pub const TCP_SERVER: UniqueApidTargetId = UniqueApidTargetId::new(Apid::Tmtc as u16, TmtcId::TcpServer as u32); + pub const NO_SENDER: ComponentId = ComponentId::MAX; } pub mod pool { @@ -224,7 +233,7 @@ pub mod pool { pub mod tasks { pub const FREQ_MS_UDP_TMTC: u64 = 200; - pub const FREQ_MS_EVENT_HANDLING: u64 = 400; pub const FREQ_MS_AOCS: u64 = 500; pub const FREQ_MS_PUS_STACK: u64 = 200; + pub const SIM_CLIENT_IDLE_DELAY_MS: u64 = 5; } diff --git a/satrs-example/src/eps/mod.rs b/satrs-example/src/eps/mod.rs new file mode 100644 index 0000000..351cf76 --- /dev/null +++ b/satrs-example/src/eps/mod.rs @@ -0,0 +1,195 @@ +use derive_new::new; +use std::{cell::RefCell, collections::VecDeque, sync::mpsc, time::Duration}; + +use satrs::{ + power::{ + PowerSwitchInfo, PowerSwitcherCommandSender, SwitchRequest, SwitchState, SwitchStateBinary, + }, + queue::GenericSendError, + request::{GenericMessage, MessageMetadata}, +}; +use satrs_minisim::eps::{PcduSwitch, SwitchMapWrapper}; +use thiserror::Error; + +use self::pcdu::SharedSwitchSet; + +pub mod pcdu; + +#[derive(new, Clone)] +pub struct PowerSwitchHelper { + switcher_tx: mpsc::SyncSender>, + shared_switch_set: SharedSwitchSet, +} + +#[derive(Debug, Error, Copy, Clone, PartialEq, Eq)] +pub enum SwitchCommandingError { + #[error("send error: {0}")] + Send(#[from] GenericSendError), +} + +#[derive(Debug, Error, Copy, Clone, PartialEq, Eq)] +pub enum SwitchInfoError { + /// This is a configuration error which should not occur. + #[error("switch ID not in map")] + SwitchIdNotInMap(PcduSwitch), + #[error("switch set invalid")] + SwitchSetInvalid, +} + +impl PowerSwitchInfo for PowerSwitchHelper { + type Error = SwitchInfoError; + + fn switch_state( + &self, + switch_id: PcduSwitch, + ) -> Result { + let switch_set = self + .shared_switch_set + .lock() + .expect("failed to lock switch set"); + if !switch_set.valid { + return Err(SwitchInfoError::SwitchSetInvalid); + } + + if let Some(state) = switch_set.switch_map.get(&switch_id) { + return Ok(*state); + } + Err(SwitchInfoError::SwitchIdNotInMap(switch_id)) + } + + fn switch_delay_ms(&self) -> Duration { + // Here, we could set device specific switch delays theoretically. Set it to this value + // for now. + Duration::from_millis(1000) + } +} + +impl PowerSwitcherCommandSender for PowerSwitchHelper { + type Error = SwitchCommandingError; + + fn send_switch_on_cmd( + &self, + requestor_info: satrs::request::MessageMetadata, + switch_id: PcduSwitch, + ) -> Result<(), Self::Error> { + self.switcher_tx + .send_switch_on_cmd(requestor_info, switch_id)?; + Ok(()) + } + + fn send_switch_off_cmd( + &self, + requestor_info: satrs::request::MessageMetadata, + switch_id: PcduSwitch, + ) -> Result<(), Self::Error> { + self.switcher_tx + .send_switch_off_cmd(requestor_info, switch_id)?; + Ok(()) + } +} + +#[derive(new)] +pub struct SwitchRequestInfo { + pub requestor_info: MessageMetadata, + pub switch_id: PcduSwitch, + pub target_state: satrs::power::SwitchStateBinary, +} + +// Test switch helper which can be used for unittests. +pub struct TestSwitchHelper { + pub switch_requests: RefCell>, + pub switch_info_requests: RefCell>, + pub switch_delay_request_count: u32, + pub next_switch_delay: Duration, + pub switch_map: RefCell, + pub switch_map_valid: bool, +} + +impl Default for TestSwitchHelper { + fn default() -> Self { + Self { + switch_requests: Default::default(), + switch_info_requests: Default::default(), + switch_delay_request_count: Default::default(), + next_switch_delay: Duration::from_millis(1000), + switch_map: Default::default(), + switch_map_valid: true, + } + } +} + +impl PowerSwitchInfo for TestSwitchHelper { + type Error = SwitchInfoError; + + fn switch_state( + &self, + switch_id: PcduSwitch, + ) -> Result { + let mut switch_info_requests_mut = self.switch_info_requests.borrow_mut(); + switch_info_requests_mut.push_back(switch_id); + if !self.switch_map_valid { + return Err(SwitchInfoError::SwitchSetInvalid); + } + let switch_map_mut = self.switch_map.borrow_mut(); + if let Some(state) = switch_map_mut.0.get(&switch_id) { + return Ok(*state); + } + Err(SwitchInfoError::SwitchIdNotInMap(switch_id)) + } + + fn switch_delay_ms(&self) -> Duration { + self.next_switch_delay + } +} + +impl PowerSwitcherCommandSender for TestSwitchHelper { + type Error = SwitchCommandingError; + + fn send_switch_on_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: PcduSwitch, + ) -> Result<(), Self::Error> { + let mut switch_requests_mut = self.switch_requests.borrow_mut(); + switch_requests_mut.push_back(SwitchRequestInfo { + requestor_info, + switch_id, + target_state: SwitchStateBinary::On, + }); + // By default, the test helper immediately acknowledges the switch request by setting + // the appropriate switch state in the internal switch map. + let mut switch_map_mut = self.switch_map.borrow_mut(); + if let Some(switch_state) = switch_map_mut.0.get_mut(&switch_id) { + *switch_state = SwitchState::On; + } + Ok(()) + } + + fn send_switch_off_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: PcduSwitch, + ) -> Result<(), Self::Error> { + let mut switch_requests_mut = self.switch_requests.borrow_mut(); + switch_requests_mut.push_back(SwitchRequestInfo { + requestor_info, + switch_id, + target_state: SwitchStateBinary::Off, + }); + // By default, the test helper immediately acknowledges the switch request by setting + // the appropriate switch state in the internal switch map. + let mut switch_map_mut = self.switch_map.borrow_mut(); + if let Some(switch_state) = switch_map_mut.0.get_mut(&switch_id) { + *switch_state = SwitchState::Off; + } + Ok(()) + } +} + +#[allow(dead_code)] +impl TestSwitchHelper { + // Helper function which can be used to force a switch to another state for test purposes. + pub fn set_switch_state(&mut self, switch: PcduSwitch, state: SwitchState) { + self.switch_map.get_mut().0.insert(switch, state); + } +} diff --git a/satrs-example/src/eps/pcdu.rs b/satrs-example/src/eps/pcdu.rs new file mode 100644 index 0000000..908bfb2 --- /dev/null +++ b/satrs-example/src/eps/pcdu.rs @@ -0,0 +1,722 @@ +use std::{ + cell::RefCell, + collections::VecDeque, + sync::{mpsc, Arc, Mutex}, +}; + +use derive_new::new; +use num_enum::{IntoPrimitive, TryFromPrimitive}; +use satrs::{ + hk::{HkRequest, HkRequestVariant}, + mode::{ModeAndSubmode, ModeError, ModeProvider, ModeReply, ModeRequestHandler}, + power::SwitchRequest, + pus::{EcssTmSender, PusTmVariant}, + queue::{GenericSendError, GenericTargetedMessagingError}, + request::{GenericMessage, MessageMetadata, UniqueApidTargetId}, + spacepackets::ByteConversionError, +}; +use satrs_example::{ + config::components::{NO_SENDER, PUS_MODE_SERVICE}, + DeviceMode, TimestampHelper, +}; +use satrs_minisim::{ + eps::{ + PcduReply, PcduRequest, PcduSwitch, SwitchMap, SwitchMapBinaryWrapper, SwitchMapWrapper, + }, + SerializableSimMsgPayload, SimReply, SimRequest, +}; +use serde::{Deserialize, Serialize}; + +use crate::{ + acs::mgm::MpscModeLeafInterface, + hk::PusHkHelper, + pus::hk::{HkReply, HkReplyVariant}, + requests::CompositeRequest, +}; + +pub trait SerialInterface { + type Error: core::fmt::Debug; + + /// Send some data via the serial interface. + fn send(&self, data: &[u8]) -> Result<(), Self::Error>; + /// Receive all replies received on the serial interface so far. This function takes a closure + /// and call its for each received packet, passing the received packet into it. + fn try_recv_replies( + &self, + f: ReplyHandler, + ) -> Result<(), Self::Error>; +} + +#[derive(new)] +pub struct SerialInterfaceToSim { + pub sim_request_tx: mpsc::Sender, + pub sim_reply_rx: mpsc::Receiver, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, TryFromPrimitive, IntoPrimitive)] +#[repr(u32)] +pub enum SetId { + SwitcherSet = 0, +} + +impl SerialInterface for SerialInterfaceToSim { + type Error = (); + + fn send(&self, data: &[u8]) -> Result<(), Self::Error> { + let request: PcduRequest = serde_json::from_slice(data).expect("expected a PCDU request"); + self.sim_request_tx + .send(SimRequest::new_with_epoch_time(request)) + .expect("failed to send request to simulation"); + Ok(()) + } + + fn try_recv_replies( + &self, + mut f: ReplyHandler, + ) -> Result<(), Self::Error> { + loop { + match self.sim_reply_rx.try_recv() { + Ok(reply) => { + let reply = serde_json::to_string(&reply).unwrap(); + f(reply.as_bytes()); + } + Err(e) => match e { + mpsc::TryRecvError::Empty => break, + mpsc::TryRecvError::Disconnected => { + log::warn!("sim reply sender has disconnected"); + break; + } + }, + } + } + Ok(()) + } +} + +#[derive(Default)] +pub struct SerialInterfaceDummy { + // Need interior mutability here for both fields. + pub switch_map: RefCell, + pub reply_deque: RefCell>, +} + +impl SerialInterface for SerialInterfaceDummy { + type Error = (); + + fn send(&self, data: &[u8]) -> Result<(), Self::Error> { + let pcdu_req: PcduRequest = serde_json::from_slice(data).unwrap(); + let switch_map_mut = &mut self.switch_map.borrow_mut().0; + match pcdu_req { + PcduRequest::SwitchDevice { switch, state } => { + match switch_map_mut.entry(switch) { + std::collections::hash_map::Entry::Occupied(mut val) => { + *val.get_mut() = state; + } + std::collections::hash_map::Entry::Vacant(vacant) => { + vacant.insert(state); + } + }; + } + PcduRequest::RequestSwitchInfo => { + let mut reply_deque_mut = self.reply_deque.borrow_mut(); + reply_deque_mut.push_back(SimReply::new(&PcduReply::SwitchInfo( + switch_map_mut.clone(), + ))); + } + }; + Ok(()) + } + + fn try_recv_replies( + &self, + mut f: ReplyHandler, + ) -> Result<(), Self::Error> { + if self.reply_queue_empty() { + return Ok(()); + } + loop { + let reply = self.get_next_reply_as_string(); + f(reply.as_bytes()); + if self.reply_queue_empty() { + break; + } + } + Ok(()) + } +} + +impl SerialInterfaceDummy { + fn get_next_reply_as_string(&self) -> String { + let mut reply_deque_mut = self.reply_deque.borrow_mut(); + let next_reply = reply_deque_mut.pop_front().unwrap(); + serde_json::to_string(&next_reply).unwrap() + } + + fn reply_queue_empty(&self) -> bool { + self.reply_deque.borrow().is_empty() + } +} + +pub enum SerialSimInterfaceWrapper { + Dummy(SerialInterfaceDummy), + Sim(SerialInterfaceToSim), +} + +impl SerialInterface for SerialSimInterfaceWrapper { + type Error = (); + + fn send(&self, data: &[u8]) -> Result<(), Self::Error> { + match self { + SerialSimInterfaceWrapper::Dummy(dummy) => dummy.send(data), + SerialSimInterfaceWrapper::Sim(sim) => sim.send(data), + } + } + + fn try_recv_replies( + &self, + f: ReplyHandler, + ) -> Result<(), Self::Error> { + match self { + SerialSimInterfaceWrapper::Dummy(dummy) => dummy.try_recv_replies(f), + SerialSimInterfaceWrapper::Sim(sim) => sim.try_recv_replies(f), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum OpCode { + RegularOp = 0, + PollAndRecvReplies = 1, +} + +#[derive(Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SwitchSet { + pub valid: bool, + pub switch_map: SwitchMap, +} + +pub type SharedSwitchSet = Arc>; + +/// Example PCDU device handler. +#[derive(new)] +#[allow(clippy::too_many_arguments)] +pub struct PcduHandler { + id: UniqueApidTargetId, + dev_str: &'static str, + mode_interface: MpscModeLeafInterface, + composite_request_rx: mpsc::Receiver>, + hk_reply_tx: mpsc::Sender>, + switch_request_rx: mpsc::Receiver>, + tm_sender: TmSender, + pub com_interface: ComInterface, + shared_switch_map: Arc>, + #[new(value = "PusHkHelper::new(id)")] + hk_helper: PusHkHelper, + #[new(value = "ModeAndSubmode::new(satrs_example::DeviceMode::Off as u32, 0)")] + mode_and_submode: ModeAndSubmode, + #[new(default)] + stamp_helper: TimestampHelper, + #[new(value = "[0; 256]")] + tm_buf: [u8; 256], +} + +impl PcduHandler { + pub fn periodic_operation(&mut self, op_code: OpCode) { + match op_code { + OpCode::RegularOp => { + self.stamp_helper.update_from_now(); + // Handle requests. + self.handle_composite_requests(); + self.handle_mode_requests(); + self.handle_switch_requests(); + // Poll the switch states and/or telemetry regularly here. + if self.mode() == DeviceMode::Normal as u32 || self.mode() == DeviceMode::On as u32 + { + self.handle_periodic_commands(); + } + } + OpCode::PollAndRecvReplies => { + self.poll_and_handle_replies(); + } + } + } + + pub fn handle_composite_requests(&mut self) { + loop { + match self.composite_request_rx.try_recv() { + Ok(ref msg) => match &msg.message { + CompositeRequest::Hk(hk_request) => { + self.handle_hk_request(&msg.requestor_info, hk_request) + } + // TODO: This object does not have actions (yet).. Still send back completion failure + // reply. + CompositeRequest::Action(_action_req) => {} + }, + + Err(e) => { + if e != mpsc::TryRecvError::Empty { + log::warn!( + "{}: failed to receive composite request: {:?}", + self.dev_str, + e + ); + } else { + break; + } + } + } + } + } + + pub fn handle_hk_request(&mut self, requestor_info: &MessageMetadata, hk_request: &HkRequest) { + match hk_request.variant { + HkRequestVariant::OneShot => { + if hk_request.unique_id == SetId::SwitcherSet as u32 { + if let Ok(hk_tm) = self.hk_helper.generate_hk_report_packet( + self.stamp_helper.stamp(), + SetId::SwitcherSet as u32, + &mut |hk_buf| { + // Send TM down as JSON. + let switch_map_snapshot = self + .shared_switch_map + .lock() + .expect("failed to lock switch map") + .clone(); + let switch_map_json = serde_json::to_string(&switch_map_snapshot) + .expect("failed to serialize switch map"); + if switch_map_json.len() > hk_buf.len() { + log::error!("switch map JSON too large for HK buffer"); + return Err(ByteConversionError::ToSliceTooSmall { + found: hk_buf.len(), + expected: switch_map_json.len(), + }); + } + Ok(switch_map_json.len()) + }, + &mut self.tm_buf, + ) { + self.tm_sender + .send_tm(self.id.id(), PusTmVariant::Direct(hk_tm)) + .expect("failed to send HK TM"); + self.hk_reply_tx + .send(GenericMessage::new( + *requestor_info, + HkReply::new(hk_request.unique_id, HkReplyVariant::Ack), + )) + .expect("failed to send HK reply"); + } + } + } + HkRequestVariant::EnablePeriodic => todo!(), + HkRequestVariant::DisablePeriodic => todo!(), + HkRequestVariant::ModifyCollectionInterval(_) => todo!(), + } + } + + pub fn handle_periodic_commands(&self) { + let pcdu_req = PcduRequest::RequestSwitchInfo; + let pcdu_req_ser = serde_json::to_string(&pcdu_req).unwrap(); + if let Err(_e) = self.com_interface.send(pcdu_req_ser.as_bytes()) { + log::warn!("polling PCDU switch info failed"); + } + } + + pub fn handle_mode_requests(&mut self) { + loop { + // TODO: Only allow one set mode request per cycle? + match self.mode_interface.request_rx.try_recv() { + Ok(msg) => { + let result = self.handle_mode_request(msg); + // TODO: Trigger event? + if result.is_err() { + log::warn!( + "{}: mode request failed with error {:?}", + self.dev_str, + result.err().unwrap() + ); + } + } + Err(e) => { + if e != mpsc::TryRecvError::Empty { + log::warn!("{}: failed to receive mode request: {:?}", self.dev_str, e); + } else { + break; + } + } + } + } + } + + pub fn handle_switch_requests(&mut self) { + loop { + match self.switch_request_rx.try_recv() { + Ok(switch_req) => match PcduSwitch::try_from(switch_req.message.switch_id()) { + Ok(pcdu_switch) => { + let pcdu_req = PcduRequest::SwitchDevice { + switch: pcdu_switch, + state: switch_req.message.target_state(), + }; + let pcdu_req_ser = serde_json::to_string(&pcdu_req).unwrap(); + self.com_interface + .send(pcdu_req_ser.as_bytes()) + .expect("failed to send switch request to PCDU"); + } + Err(e) => todo!("failed to convert switch ID {:?} to typed PCDU switch", e), + }, + Err(e) => match e { + mpsc::TryRecvError::Empty => break, + mpsc::TryRecvError::Disconnected => { + log::warn!("switch request receiver has disconnected"); + break; + } + }, + }; + } + } + + pub fn poll_and_handle_replies(&mut self) { + if let Err(e) = self.com_interface.try_recv_replies(|reply| { + let sim_reply: SimReply = serde_json::from_slice(reply).expect("invalid reply format"); + let pcdu_reply = PcduReply::from_sim_message(&sim_reply).expect("invalid reply format"); + match pcdu_reply { + PcduReply::SwitchInfo(switch_info) => { + let switch_map_wrapper = + SwitchMapWrapper::from_binary_switch_map_ref(&switch_info); + let mut shared_switch_map = self + .shared_switch_map + .lock() + .expect("failed to lock switch map"); + shared_switch_map.switch_map = switch_map_wrapper.0; + shared_switch_map.valid = true; + } + } + }) { + log::warn!("receiving PCDU replies failed: {:?}", e); + } + } +} + +impl ModeProvider + for PcduHandler +{ + fn mode_and_submode(&self) -> ModeAndSubmode { + self.mode_and_submode + } +} + +impl ModeRequestHandler + for PcduHandler +{ + type Error = ModeError; + fn start_transition( + &mut self, + requestor: MessageMetadata, + mode_and_submode: ModeAndSubmode, + ) -> Result<(), satrs::mode::ModeError> { + log::info!( + "{}: transitioning to mode {:?}", + self.dev_str, + mode_and_submode + ); + self.mode_and_submode = mode_and_submode; + if mode_and_submode.mode() == DeviceMode::Off as u32 { + self.shared_switch_map.lock().unwrap().valid = false; + } + self.handle_mode_reached(Some(requestor))?; + Ok(()) + } + + fn announce_mode(&self, _requestor_info: Option, _recursive: bool) { + log::info!( + "{} announcing mode: {:?}", + self.dev_str, + self.mode_and_submode + ); + } + + fn handle_mode_reached( + &mut self, + requestor: Option, + ) -> Result<(), Self::Error> { + self.announce_mode(requestor, false); + if let Some(requestor) = requestor { + if requestor.sender_id() == NO_SENDER { + return Ok(()); + } + if requestor.sender_id() != PUS_MODE_SERVICE.id() { + log::warn!( + "can not send back mode reply to sender {}", + requestor.sender_id() + ); + } else { + self.send_mode_reply(requestor, ModeReply::ModeReply(self.mode_and_submode()))?; + } + } + Ok(()) + } + + fn send_mode_reply( + &self, + requestor: MessageMetadata, + reply: ModeReply, + ) -> Result<(), Self::Error> { + if requestor.sender_id() != PUS_MODE_SERVICE.id() { + log::warn!( + "can not send back mode reply to sender {}", + requestor.sender_id() + ); + } + self.mode_interface + .reply_to_pus_tx + .send(GenericMessage::new(requestor, reply)) + .map_err(|_| GenericTargetedMessagingError::Send(GenericSendError::RxDisconnected))?; + Ok(()) + } + + fn handle_mode_info( + &mut self, + _requestor_info: MessageMetadata, + _info: ModeAndSubmode, + ) -> Result<(), Self::Error> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::mpsc; + + use satrs::{ + mode::ModeRequest, power::SwitchStateBinary, request::GenericMessage, tmtc::PacketAsVec, + }; + use satrs_example::config::components::{Apid, MGM_HANDLER_0}; + use satrs_minisim::eps::SwitchMapBinary; + + use super::*; + + #[derive(Default)] + pub struct SerialInterfaceTest { + pub inner: SerialInterfaceDummy, + pub send_queue: RefCell>>, + pub reply_queue: RefCell>, + } + + impl SerialInterface for SerialInterfaceTest { + type Error = (); + + fn send(&self, data: &[u8]) -> Result<(), Self::Error> { + let mut send_queue_mut = self.send_queue.borrow_mut(); + send_queue_mut.push_back(data.to_vec()); + self.inner.send(data) + } + + fn try_recv_replies( + &self, + mut f: ReplyHandler, + ) -> Result<(), Self::Error> { + if self.inner.reply_queue_empty() { + return Ok(()); + } + loop { + let reply = self.inner.get_next_reply_as_string(); + self.reply_queue.borrow_mut().push_back(reply.clone()); + f(reply.as_bytes()); + if self.inner.reply_queue_empty() { + break; + } + } + Ok(()) + } + } + + pub struct PcduTestbench { + pub mode_request_tx: mpsc::Sender>, + pub mode_reply_rx_to_pus: mpsc::Receiver>, + pub mode_reply_rx_to_parent: mpsc::Receiver>, + pub composite_request_tx: mpsc::Sender>, + pub hk_reply_rx: mpsc::Receiver>, + pub tm_rx: mpsc::Receiver, + pub switch_request_tx: mpsc::Sender>, + pub handler: PcduHandler>, + } + + impl PcduTestbench { + pub fn new() -> Self { + let (mode_request_tx, mode_request_rx) = mpsc::channel(); + let (mode_reply_tx_to_pus, mode_reply_rx_to_pus) = mpsc::channel(); + let (mode_reply_tx_to_parent, mode_reply_rx_to_parent) = mpsc::sync_channel(5); + let mode_interface = MpscModeLeafInterface { + request_rx: mode_request_rx, + reply_to_pus_tx: mode_reply_tx_to_pus, + reply_to_parent_tx: mode_reply_tx_to_parent, + }; + let (composite_request_tx, composite_request_rx) = mpsc::channel(); + let (hk_reply_tx, hk_reply_rx) = mpsc::channel(); + let (tm_tx, tm_rx) = mpsc::channel::(); + let (switch_request_tx, switch_reqest_rx) = mpsc::channel(); + let shared_switch_map = Arc::new(Mutex::new(SwitchSet::default())); + Self { + mode_request_tx, + mode_reply_rx_to_pus, + mode_reply_rx_to_parent, + composite_request_tx, + hk_reply_rx, + tm_rx, + switch_request_tx, + handler: PcduHandler::new( + UniqueApidTargetId::new(Apid::Eps as u16, 0), + "TEST_PCDU", + mode_interface, + composite_request_rx, + hk_reply_tx, + switch_reqest_rx, + tm_tx, + SerialInterfaceTest::default(), + shared_switch_map, + ), + } + } + + pub fn verify_switch_info_req_was_sent(&self, expected_queue_len: usize) { + // Check that there is now communication happening. + let mut send_queue_mut = self.handler.com_interface.send_queue.borrow_mut(); + assert_eq!(send_queue_mut.len(), expected_queue_len); + let packet_sent = send_queue_mut.pop_front().unwrap(); + drop(send_queue_mut); + let pcdu_req: PcduRequest = serde_json::from_slice(&packet_sent).unwrap(); + assert_eq!(pcdu_req, PcduRequest::RequestSwitchInfo); + } + + pub fn verify_switch_req_was_sent( + &self, + expected_queue_len: usize, + switch_id: PcduSwitch, + target_state: SwitchStateBinary, + ) { + // Check that there is now communication happening. + let mut send_queue_mut = self.handler.com_interface.send_queue.borrow_mut(); + assert_eq!(send_queue_mut.len(), expected_queue_len); + let packet_sent = send_queue_mut.pop_front().unwrap(); + drop(send_queue_mut); + let pcdu_req: PcduRequest = serde_json::from_slice(&packet_sent).unwrap(); + assert_eq!( + pcdu_req, + PcduRequest::SwitchDevice { + switch: switch_id, + state: target_state + } + ) + } + + pub fn verify_switch_reply_received( + &self, + expected_queue_len: usize, + expected_map: SwitchMapBinary, + ) { + // Check that a switch reply was read back. + let mut reply_received_mut = self.handler.com_interface.reply_queue.borrow_mut(); + assert_eq!(reply_received_mut.len(), expected_queue_len); + let reply_received = reply_received_mut.pop_front().unwrap(); + let sim_reply: SimReply = serde_json::from_str(&reply_received).unwrap(); + let pcdu_reply = PcduReply::from_sim_message(&sim_reply).unwrap(); + assert_eq!(pcdu_reply, PcduReply::SwitchInfo(expected_map)); + } + } + + #[test] + fn test_basic_handler() { + let mut testbench = PcduTestbench::new(); + assert_eq!(testbench.handler.com_interface.send_queue.borrow().len(), 0); + assert_eq!( + testbench.handler.com_interface.reply_queue.borrow().len(), + 0 + ); + assert_eq!( + testbench.handler.mode_and_submode().mode(), + DeviceMode::Off as u32 + ); + assert_eq!(testbench.handler.mode_and_submode().submode(), 0_u16); + testbench.handler.periodic_operation(OpCode::RegularOp); + testbench + .handler + .periodic_operation(OpCode::PollAndRecvReplies); + // Handler is OFF, no changes expected. + assert_eq!(testbench.handler.com_interface.send_queue.borrow().len(), 0); + assert_eq!( + testbench.handler.com_interface.reply_queue.borrow().len(), + 0 + ); + assert_eq!( + testbench.handler.mode_and_submode().mode(), + DeviceMode::Off as u32 + ); + assert_eq!(testbench.handler.mode_and_submode().submode(), 0_u16); + } + + #[test] + fn test_normal_mode() { + let mut testbench = PcduTestbench::new(); + testbench + .mode_request_tx + .send(GenericMessage::new( + MessageMetadata::new(0, PUS_MODE_SERVICE.id()), + ModeRequest::SetMode(ModeAndSubmode::new(DeviceMode::Normal as u32, 0)), + )) + .expect("failed to send mode request"); + let switch_map_shared = testbench.handler.shared_switch_map.lock().unwrap(); + assert!(!switch_map_shared.valid); + drop(switch_map_shared); + testbench.handler.periodic_operation(OpCode::RegularOp); + testbench + .handler + .periodic_operation(OpCode::PollAndRecvReplies); + // Check correctness of mode. + assert_eq!( + testbench.handler.mode_and_submode().mode(), + DeviceMode::Normal as u32 + ); + assert_eq!(testbench.handler.mode_and_submode().submode(), 0); + + testbench.verify_switch_info_req_was_sent(1); + testbench.verify_switch_reply_received(1, SwitchMapBinaryWrapper::default().0); + + let switch_map_shared = testbench.handler.shared_switch_map.lock().unwrap(); + assert!(switch_map_shared.valid); + drop(switch_map_shared); + } + + #[test] + fn test_switch_request_handling() { + let mut testbench = PcduTestbench::new(); + testbench + .mode_request_tx + .send(GenericMessage::new( + MessageMetadata::new(0, PUS_MODE_SERVICE.id()), + ModeRequest::SetMode(ModeAndSubmode::new(DeviceMode::Normal as u32, 0)), + )) + .expect("failed to send mode request"); + testbench + .switch_request_tx + .send(GenericMessage::new( + MessageMetadata::new(0, MGM_HANDLER_0.id()), + SwitchRequest::new(0, SwitchStateBinary::On), + )) + .expect("failed to send switch request"); + testbench.handler.periodic_operation(OpCode::RegularOp); + testbench + .handler + .periodic_operation(OpCode::PollAndRecvReplies); + + testbench.verify_switch_req_was_sent(2, PcduSwitch::Mgm, SwitchStateBinary::On); + testbench.verify_switch_info_req_was_sent(1); + let mut switch_map = SwitchMapBinaryWrapper::default().0; + *switch_map + .get_mut(&PcduSwitch::Mgm) + .expect("switch state setting failed") = SwitchStateBinary::On; + testbench.verify_switch_reply_received(1, switch_map); + + let switch_map_shared = testbench.handler.shared_switch_map.lock().unwrap(); + assert!(switch_map_shared.valid); + drop(switch_map_shared); + } +} diff --git a/satrs-example/src/hk.rs b/satrs-example/src/hk.rs index 0852d04..bfad5e8 100644 --- a/satrs-example/src/hk.rs +++ b/satrs-example/src/hk.rs @@ -1,7 +1,9 @@ use derive_new::new; use satrs::hk::UniqueId; use satrs::request::UniqueApidTargetId; -use satrs::spacepackets::ByteConversionError; +use satrs::spacepackets::ecss::hk; +use satrs::spacepackets::ecss::tm::{PusTmCreator, PusTmSecondaryHeader}; +use satrs::spacepackets::{ByteConversionError, SpHeader}; #[derive(Debug, new, Copy, Clone)] pub struct HkUniqueId { @@ -33,3 +35,35 @@ impl HkUniqueId { Ok(8) } } + +#[derive(new)] +pub struct PusHkHelper { + component_id: UniqueApidTargetId, +} + +impl PusHkHelper { + pub fn generate_hk_report_packet< + 'a, + 'b, + HkWriter: FnMut(&mut [u8]) -> Result, + >( + &self, + timestamp: &'a [u8], + set_id: u32, + hk_data_writer: &mut HkWriter, + buf: &'b mut [u8], + ) -> Result, ByteConversionError> { + let sec_header = + PusTmSecondaryHeader::new(3, hk::Subservice::TmHkPacket as u8, 0, 0, timestamp); + buf[0..4].copy_from_slice(&self.component_id.unique_id.to_be_bytes()); + buf[4..8].copy_from_slice(&set_id.to_be_bytes()); + let (_, second_half) = buf.split_at_mut(8); + let hk_data_len = hk_data_writer(second_half)?; + Ok(PusTmCreator::new( + SpHeader::new_from_apid(self.component_id.apid), + sec_header, + &buf[0..8 + hk_data_len], + true, + )) + } +} diff --git a/satrs-example/src/interface/mod.rs b/satrs-example/src/interface/mod.rs index d10d73f..efe9b69 100644 --- a/satrs-example/src/interface/mod.rs +++ b/satrs-example/src/interface/mod.rs @@ -1,3 +1,4 @@ //! This module contains all component related to the direct interface of the example. +pub mod sim_client_udp; pub mod tcp; pub mod udp; diff --git a/satrs-example/src/interface/sim_client_udp.rs b/satrs-example/src/interface/sim_client_udp.rs new file mode 100644 index 0000000..16db261 --- /dev/null +++ b/satrs-example/src/interface/sim_client_udp.rs @@ -0,0 +1,420 @@ +use std::{ + collections::HashMap, + net::{Ipv4Addr, SocketAddr, SocketAddrV4, UdpSocket}, + sync::mpsc, + time::Duration, +}; + +use satrs::pus::HandlingStatus; +use satrs_minisim::{ + udp::SIM_CTRL_PORT, SerializableSimMsgPayload, SimComponent, SimMessageProvider, SimReply, + SimRequest, +}; +use satrs_minisim::{SimCtrlReply, SimCtrlRequest}; + +struct SimReplyMap(pub HashMap>); + +pub fn create_sim_client(sim_request_rx: mpsc::Receiver) -> Option { + match SimClientUdp::new( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, SIM_CTRL_PORT)), + sim_request_rx, + ) { + Ok(sim_client) => { + log::info!("simulator client connection success"); + return Some(sim_client); + } + Err(e) => { + log::warn!("sim client creation error: {}", e); + } + } + None +} + +#[derive(thiserror::Error, Debug)] +pub enum SimClientCreationError { + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("timeout when trying to connect to sim UDP server")] + Timeout, + #[error("invalid ping reply when trying connection to UDP sim server")] + InvalidReplyJsonError(#[from] serde_json::Error), + #[error("invalid sim reply, not pong reply as expected: {0:?}")] + ReplyIsNotPong(SimReply), +} + +pub struct SimClientUdp { + udp_client: UdpSocket, + simulator_addr: SocketAddr, + sim_request_rx: mpsc::Receiver, + reply_map: SimReplyMap, + reply_buf: [u8; 4096], +} + +impl SimClientUdp { + pub fn new( + simulator_addr: SocketAddr, + sim_request_rx: mpsc::Receiver, + ) -> Result { + let mut reply_buf: [u8; 4096] = [0; 4096]; + let mut udp_client = UdpSocket::bind("127.0.0.1:0")?; + udp_client.set_read_timeout(Some(Duration::from_millis(100)))?; + Self::attempt_connection(&mut udp_client, simulator_addr, &mut reply_buf)?; + udp_client.set_nonblocking(true)?; + Ok(Self { + udp_client, + simulator_addr, + sim_request_rx, + reply_map: SimReplyMap(HashMap::new()), + reply_buf, + }) + } + + pub fn attempt_connection( + udp_client: &mut UdpSocket, + simulator_addr: SocketAddr, + reply_buf: &mut [u8], + ) -> Result<(), SimClientCreationError> { + let sim_req = SimRequest::new_with_epoch_time(SimCtrlRequest::Ping); + let sim_req_json = serde_json::to_string(&sim_req).expect("failed to serialize SimRequest"); + udp_client.send_to(sim_req_json.as_bytes(), simulator_addr)?; + match udp_client.recv(reply_buf) { + Ok(reply_len) => { + let sim_reply: SimReply = serde_json::from_slice(&reply_buf[0..reply_len])?; + if sim_reply.component() != SimComponent::SimCtrl { + return Err(SimClientCreationError::ReplyIsNotPong(sim_reply)); + } + let sim_ctrl_reply = + SimCtrlReply::from_sim_message(&sim_reply).expect("invalid SIM reply"); + match sim_ctrl_reply { + SimCtrlReply::InvalidRequest(_) => { + panic!("received invalid request reply from UDP sim server") + } + SimCtrlReply::Pong => Ok(()), + } + } + Err(e) => { + if e.kind() == std::io::ErrorKind::TimedOut + || e.kind() == std::io::ErrorKind::WouldBlock + { + Err(SimClientCreationError::Timeout) + } else { + Err(SimClientCreationError::Io(e)) + } + } + } + } + + pub fn operation(&mut self) -> HandlingStatus { + let mut no_sim_requests_handled = true; + let mut no_data_from_udp_server_received = true; + loop { + match self.sim_request_rx.try_recv() { + Ok(request) => { + let request_json = + serde_json::to_string(&request).expect("failed to serialize SimRequest"); + if let Err(e) = self + .udp_client + .send_to(request_json.as_bytes(), self.simulator_addr) + { + log::error!("error sending data to UDP SIM server: {}", e); + break; + } else { + no_sim_requests_handled = false; + } + } + Err(e) => match e { + mpsc::TryRecvError::Empty => { + break; + } + mpsc::TryRecvError::Disconnected => { + log::warn!("SIM request sender disconnected"); + break; + } + }, + } + } + loop { + match self.udp_client.recv(&mut self.reply_buf) { + Ok(recvd_bytes) => { + no_data_from_udp_server_received = false; + let sim_reply_result: serde_json::Result = + serde_json::from_slice(&self.reply_buf[0..recvd_bytes]); + match sim_reply_result { + Ok(sim_reply) => { + if let Some(sender) = self.reply_map.0.get(&sim_reply.component()) { + sender.send(sim_reply).expect("failed to send SIM reply"); + } else { + log::warn!( + "no recipient for SIM reply from component {:?}", + sim_reply.component() + ); + } + } + Err(e) => { + log::warn!("failed to deserialize SIM reply: {}", e); + } + } + } + Err(e) => { + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut + { + break; + } + log::error!("error receiving data from UDP SIM server: {}", e); + break; + } + } + } + if no_sim_requests_handled && no_data_from_udp_server_received { + return HandlingStatus::Empty; + } + HandlingStatus::HandledOne + } + + pub fn add_reply_recipient( + &mut self, + component: SimComponent, + reply_sender: mpsc::Sender, + ) { + self.reply_map.0.insert(component, reply_sender); + } +} + +#[cfg(test)] +pub mod tests { + use std::{ + collections::HashMap, + net::{SocketAddr, UdpSocket}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + time::Duration, + }; + + use satrs_minisim::{ + eps::{PcduReply, PcduRequest}, + SerializableSimMsgPayload, SimComponent, SimCtrlReply, SimCtrlRequest, SimMessageProvider, + SimReply, SimRequest, + }; + + use super::SimClientUdp; + + struct UdpSimTestServer { + udp_server: UdpSocket, + request_tx: mpsc::Sender, + reply_rx: mpsc::Receiver, + last_sender: Option, + stop_signal: Arc, + recv_buf: [u8; 1024], + } + + impl UdpSimTestServer { + pub fn new( + request_tx: mpsc::Sender, + reply_rx: mpsc::Receiver, + stop_signal: Arc, + ) -> Self { + let udp_server = UdpSocket::bind("127.0.0.1:0").expect("creating UDP server failed"); + udp_server + .set_nonblocking(true) + .expect("failed to set UDP server to non-blocking"); + Self { + udp_server, + request_tx, + reply_rx, + last_sender: None, + stop_signal, + recv_buf: [0; 1024], + } + } + + pub fn operation(&mut self) { + loop { + let mut no_sim_replies_handled = true; + let mut no_data_received = true; + if self.stop_signal.load(Ordering::Relaxed) { + break; + } + if let Some(last_sender) = self.last_sender { + loop { + match self.reply_rx.try_recv() { + Ok(sim_reply) => { + let sim_reply_json = serde_json::to_string(&sim_reply) + .expect("failed to serialize SimReply"); + self.udp_server + .send_to(sim_reply_json.as_bytes(), last_sender) + .expect("failed to send reply to client from UDP server"); + no_sim_replies_handled = false; + } + Err(e) => match e { + mpsc::TryRecvError::Empty => break, + mpsc::TryRecvError::Disconnected => { + panic!("reply sender disconnected") + } + }, + } + } + } + + loop { + match self.udp_server.recv_from(&mut self.recv_buf) { + Ok((read_bytes, from)) => { + let sim_request: SimRequest = + serde_json::from_slice(&self.recv_buf[0..read_bytes]) + .expect("failed to deserialize SimRequest"); + if sim_request.component() == SimComponent::SimCtrl { + // For a ping, we perform the reply handling here directly + let sim_ctrl_request = + SimCtrlRequest::from_sim_message(&sim_request) + .expect("failed to convert SimRequest to SimCtrlRequest"); + match sim_ctrl_request { + SimCtrlRequest::Ping => { + no_data_received = false; + self.last_sender = Some(from); + let sim_reply = SimReply::new(&SimCtrlReply::Pong); + let sim_reply_json = serde_json::to_string(&sim_reply) + .expect("failed to serialize SimReply"); + self.udp_server + .send_to(sim_reply_json.as_bytes(), from) + .expect( + "failed to send reply to client from UDP server", + ); + } + }; + } + // Forward each SIM request for testing purposes. + self.request_tx + .send(sim_request) + .expect("failed to send request"); + } + Err(e) => { + if e.kind() != std::io::ErrorKind::WouldBlock + && e.kind() != std::io::ErrorKind::TimedOut + { + panic!("UDP server error: {}", e); + } + break; + } + } + } + if no_sim_replies_handled && no_data_received { + std::thread::sleep(Duration::from_millis(5)); + } + } + } + + pub fn local_addr(&self) -> SocketAddr { + self.udp_server.local_addr().unwrap() + } + } + + #[test] + fn basic_connection_test() { + let (server_sim_request_tx, server_sim_request_rx) = mpsc::channel(); + let (_server_sim_reply_tx, server_sim_reply_rx) = mpsc::channel(); + let stop_signal = Arc::new(AtomicBool::new(false)); + let mut udp_server = UdpSimTestServer::new( + server_sim_request_tx, + server_sim_reply_rx, + stop_signal.clone(), + ); + let server_addr = udp_server.local_addr(); + let (_client_sim_req_tx, client_sim_req_rx) = mpsc::channel(); + // Need to spawn the simulator UDP server before calling the client constructor. + let jh0 = std::thread::spawn(move || { + udp_server.operation(); + }); + // Creating the client also performs the connection test. + SimClientUdp::new(server_addr, client_sim_req_rx).unwrap(); + let sim_request = server_sim_request_rx + .recv_timeout(Duration::from_millis(50)) + .expect("no SIM request received"); + let ping_request = SimCtrlRequest::from_sim_message(&sim_request) + .expect("failed to create SimCtrlRequest"); + assert_eq!(ping_request, SimCtrlRequest::Ping); + // Stop the server. + stop_signal.store(true, Ordering::Relaxed); + jh0.join().unwrap(); + } + + #[test] + fn basic_request_reply_test() { + let (server_sim_request_tx, server_sim_request_rx) = mpsc::channel(); + let (server_sim_reply_tx, sever_sim_reply_rx) = mpsc::channel(); + let stop_signal = Arc::new(AtomicBool::new(false)); + let mut udp_server = UdpSimTestServer::new( + server_sim_request_tx, + sever_sim_reply_rx, + stop_signal.clone(), + ); + let server_addr = udp_server.local_addr(); + let (client_sim_req_tx, client_sim_req_rx) = mpsc::channel(); + let (client_pcdu_reply_tx, client_pcdu_reply_rx) = mpsc::channel(); + // Need to spawn the simulator UDP server before calling the client constructor. + let jh0 = std::thread::spawn(move || { + udp_server.operation(); + }); + + // Creating the client also performs the connection test. + let mut client = SimClientUdp::new(server_addr, client_sim_req_rx).unwrap(); + client.add_reply_recipient(SimComponent::Pcdu, client_pcdu_reply_tx); + + let sim_request = server_sim_request_rx + .recv_timeout(Duration::from_millis(50)) + .expect("no SIM request received"); + let ping_request = SimCtrlRequest::from_sim_message(&sim_request) + .expect("failed to create SimCtrlRequest"); + assert_eq!(ping_request, SimCtrlRequest::Ping); + + let pcdu_req = PcduRequest::RequestSwitchInfo; + client_sim_req_tx + .send(SimRequest::new_with_epoch_time(pcdu_req)) + .expect("send failed"); + client.operation(); + + // Check that the request arrives properly at the server. + let sim_request = server_sim_request_rx + .recv_timeout(Duration::from_millis(50)) + .expect("no SIM request received"); + let req_recvd_on_server = + PcduRequest::from_sim_message(&sim_request).expect("failed to create SimCtrlRequest"); + matches!(req_recvd_on_server, PcduRequest::RequestSwitchInfo); + + // We inject the reply ourselves. + let pcdu_reply = PcduReply::SwitchInfo(HashMap::new()); + server_sim_reply_tx + .send(SimReply::new(&pcdu_reply)) + .expect("sending PCDU reply failed"); + + // Now we verify that the reply is sent by the UDP server back to the client, and then + // forwarded by the clients internal map. + let mut pcdu_reply_received = false; + for _ in 0..3 { + client.operation(); + + match client_pcdu_reply_rx.try_recv() { + Ok(sim_reply) => { + assert_eq!(sim_reply.component(), SimComponent::Pcdu); + let pcdu_reply_from_client = PcduReply::from_sim_message(&sim_reply) + .expect("failed to create PcduReply"); + assert_eq!(pcdu_reply_from_client, pcdu_reply); + pcdu_reply_received = true; + break; + } + Err(e) => match e { + mpsc::TryRecvError::Empty => std::thread::sleep(Duration::from_millis(10)), + mpsc::TryRecvError::Disconnected => panic!("reply sender disconnected"), + }, + } + } + if !pcdu_reply_received { + panic!("no reply received"); + } + + // Stop the server. + stop_signal.store(true, Ordering::Relaxed); + jh0.join().unwrap(); + } +} diff --git a/satrs-example/src/lib.rs b/satrs-example/src/lib.rs index a224fe5..889bdc5 100644 --- a/satrs-example/src/lib.rs +++ b/satrs-example/src/lib.rs @@ -9,12 +9,12 @@ pub enum DeviceMode { Normal = 2, } -pub struct TimeStampHelper { +pub struct TimestampHelper { stamper: CdsTime, time_stamp: [u8; 7], } -impl TimeStampHelper { +impl TimestampHelper { pub fn stamp(&self) -> &[u8] { &self.time_stamp } @@ -29,7 +29,7 @@ impl TimeStampHelper { } } -impl Default for TimeStampHelper { +impl Default for TimestampHelper { fn default() -> Self { Self { stamper: CdsTime::now_with_u16_days().expect("creating time stamper failed"), diff --git a/satrs-example/src/main.rs b/satrs-example/src/main.rs index 02138c5..317e3f0 100644 --- a/satrs-example/src/main.rs +++ b/satrs-example/src/main.rs @@ -1,4 +1,5 @@ mod acs; +mod eps; mod events; mod hk; mod interface; @@ -7,6 +8,10 @@ mod pus; mod requests; mod tmtc; +use crate::eps::pcdu::{ + PcduHandler, SerialInterfaceDummy, SerialInterfaceToSim, SerialSimInterfaceWrapper, +}; +use crate::eps::PowerSwitchHelper; use crate::events::EventHandler; use crate::interface::udp::DynamicUdpTmHandler; use crate::pus::stack::PusStack; @@ -16,15 +21,21 @@ use log::info; use pus::test::create_test_service_dynamic; use satrs::hal::std::tcp_server::ServerConfig; use satrs::hal::std::udp_server::UdpTcServer; -use satrs::request::GenericMessage; +use satrs::pus::HandlingStatus; +use satrs::request::{GenericMessage, MessageMetadata}; use satrs::tmtc::{PacketSenderWithSharedPool, SharedPacketPool}; use satrs_example::config::pool::{create_sched_tc_pool, create_static_pools}; use satrs_example::config::tasks::{ - FREQ_MS_AOCS, FREQ_MS_EVENT_HANDLING, FREQ_MS_PUS_STACK, FREQ_MS_UDP_TMTC, + FREQ_MS_AOCS, FREQ_MS_PUS_STACK, FREQ_MS_UDP_TMTC, SIM_CLIENT_IDLE_DELAY_MS, }; use satrs_example::config::{OBSW_SERVER_ADDR, PACKET_ID_VALIDATOR, SERVER_PORT}; +use satrs_example::DeviceMode; -use crate::acs::mgm::{MgmHandlerLis3Mdl, MpscModeLeafInterface, SpiDummyInterface}; +use crate::acs::mgm::{ + MgmHandlerLis3Mdl, MpscModeLeafInterface, SpiDummyInterface, SpiSimInterface, + SpiSimInterfaceWrapper, +}; +use crate::interface::sim_client_udp::create_sim_client; use crate::interface::tcp::{SyncTcpTmSource, TcpTask}; use crate::interface::udp::{StaticUdpTmHandler, UdpTmtcServer}; use crate::logger::setup_logger; @@ -36,12 +47,14 @@ use crate::pus::scheduler::{create_scheduler_service_dynamic, create_scheduler_s use crate::pus::test::create_test_service_static; use crate::pus::{PusTcDistributor, PusTcMpscRouter}; use crate::requests::{CompositeRequest, GenericRequestRouter}; -use satrs::mode::ModeRequest; +use satrs::mode::{Mode, ModeAndSubmode, ModeRequest}; use satrs::pus::event_man::EventRequestWithToken; use satrs::spacepackets::{time::cds::CdsTime, time::TimeWriter}; -use satrs_example::config::components::{MGM_HANDLER_0, TCP_SERVER, UDP_SERVER}; +use satrs_example::config::components::{ + MGM_HANDLER_0, NO_SENDER, PCDU_HANDLER, TCP_SERVER, UDP_SERVER, +}; use std::net::{IpAddr, SocketAddr}; -use std::sync::mpsc; +use std::sync::{mpsc, Mutex}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::Duration; @@ -60,9 +73,20 @@ fn static_tmtc_pool_main() { let tm_sink_tx_sender = PacketSenderWithSharedPool::new(tm_sink_tx.clone(), shared_tm_pool_wrapper.clone()); + let (sim_request_tx, sim_request_rx) = mpsc::channel(); + let (mgm_sim_reply_tx, mgm_sim_reply_rx) = mpsc::channel(); + let (pcdu_sim_reply_tx, pcdu_sim_reply_rx) = mpsc::channel(); + let mut opt_sim_client = create_sim_client(sim_request_rx); + let (mgm_handler_composite_tx, mgm_handler_composite_rx) = - mpsc::channel::>(); - let (mgm_handler_mode_tx, mgm_handler_mode_rx) = mpsc::channel::>(); + mpsc::sync_channel::>(10); + let (pcdu_handler_composite_tx, pcdu_handler_composite_rx) = + mpsc::sync_channel::>(30); + + let (mgm_handler_mode_tx, mgm_handler_mode_rx) = + mpsc::sync_channel::>(5); + let (pcdu_handler_mode_tx, pcdu_handler_mode_rx) = + mpsc::sync_channel::>(5); // Some request are targetable. This map is used to retrieve sender handles based on a target ID. let mut request_map = GenericRequestRouter::default(); @@ -72,6 +96,12 @@ fn static_tmtc_pool_main() { request_map .mode_router_map .insert(MGM_HANDLER_0.id(), mgm_handler_mode_tx); + request_map + .composite_router_map + .insert(PCDU_HANDLER.id(), pcdu_handler_composite_tx); + request_map + .mode_router_map + .insert(PCDU_HANDLER.id(), pcdu_handler_mode_tx.clone()); // This helper structure is used by all telecommand providers which need to send telecommands // to the TC source. @@ -195,26 +225,76 @@ fn static_tmtc_pool_main() { ); let (mgm_handler_mode_reply_to_parent_tx, _mgm_handler_mode_reply_to_parent_rx) = - mpsc::channel(); + mpsc::sync_channel(5); + + let shared_switch_set = Arc::new(Mutex::default()); + let (switch_request_tx, switch_request_rx) = mpsc::sync_channel(20); + let switch_helper = PowerSwitchHelper::new(switch_request_tx, shared_switch_set.clone()); - let dummy_spi_interface = SpiDummyInterface::default(); let shared_mgm_set = Arc::default(); - let mode_leaf_interface = MpscModeLeafInterface { + let mgm_mode_leaf_interface = MpscModeLeafInterface { request_rx: mgm_handler_mode_rx, - reply_tx_to_pus: pus_mode_reply_tx, - reply_tx_to_parent: mgm_handler_mode_reply_to_parent_tx, + reply_to_pus_tx: pus_mode_reply_tx.clone(), + reply_to_parent_tx: mgm_handler_mode_reply_to_parent_tx, + }; + + let mgm_spi_interface = if let Some(sim_client) = opt_sim_client.as_mut() { + sim_client.add_reply_recipient(satrs_minisim::SimComponent::MgmLis3Mdl, mgm_sim_reply_tx); + SpiSimInterfaceWrapper::Sim(SpiSimInterface { + sim_request_tx: sim_request_tx.clone(), + sim_reply_rx: mgm_sim_reply_rx, + }) + } else { + SpiSimInterfaceWrapper::Dummy(SpiDummyInterface::default()) }; let mut mgm_handler = MgmHandlerLis3Mdl::new( MGM_HANDLER_0, "MGM_0", - mode_leaf_interface, + mgm_mode_leaf_interface, mgm_handler_composite_rx, - pus_hk_reply_tx, - tm_sink_tx, - dummy_spi_interface, + pus_hk_reply_tx.clone(), + switch_helper.clone(), + tm_sink_tx.clone(), + mgm_spi_interface, shared_mgm_set, ); + let (pcdu_handler_mode_reply_to_parent_tx, _pcdu_handler_mode_reply_to_parent_rx) = + mpsc::sync_channel(10); + let pcdu_mode_leaf_interface = MpscModeLeafInterface { + request_rx: pcdu_handler_mode_rx, + reply_to_pus_tx: pus_mode_reply_tx, + reply_to_parent_tx: pcdu_handler_mode_reply_to_parent_tx, + }; + let pcdu_serial_interface = if let Some(sim_client) = opt_sim_client.as_mut() { + sim_client.add_reply_recipient(satrs_minisim::SimComponent::Pcdu, pcdu_sim_reply_tx); + SerialSimInterfaceWrapper::Sim(SerialInterfaceToSim::new( + sim_request_tx.clone(), + pcdu_sim_reply_rx, + )) + } else { + SerialSimInterfaceWrapper::Dummy(SerialInterfaceDummy::default()) + }; + + let mut pcdu_handler = PcduHandler::new( + PCDU_HANDLER, + "PCDU", + pcdu_mode_leaf_interface, + pcdu_handler_composite_rx, + pus_hk_reply_tx, + switch_request_rx, + tm_sink_tx, + pcdu_serial_interface, + shared_switch_set, + ); + // The PCDU is a critical component which should be in normal mode immediately. + pcdu_handler_mode_tx + .send(GenericMessage::new( + MessageMetadata::new(0, NO_SENDER), + ModeRequest::SetMode(ModeAndSubmode::new(DeviceMode::Normal as Mode, 0)), + )) + .expect("sending initial mode request failed"); + info!("Starting TMTC and UDP task"); let jh_udp_tmtc = thread::Builder::new() .name("SATRS tmtc-udp".to_string()) @@ -247,14 +327,20 @@ fn static_tmtc_pool_main() { }) .unwrap(); - info!("Starting event handling task"); - let jh_event_handling = thread::Builder::new() - .name("sat-rs events".to_string()) - .spawn(move || loop { - event_handler.periodic_operation(); - thread::sleep(Duration::from_millis(FREQ_MS_EVENT_HANDLING)); - }) - .unwrap(); + let mut opt_jh_sim_client = None; + if let Some(mut sim_client) = opt_sim_client { + info!("Starting UDP sim client task"); + opt_jh_sim_client = Some( + thread::Builder::new() + .name("sat-rs sim adapter".to_string()) + .spawn(move || loop { + if sim_client.operation() == HandlingStatus::Empty { + std::thread::sleep(Duration::from_millis(SIM_CLIENT_IDLE_DELAY_MS)); + } + }) + .unwrap(), + ); + } info!("Starting AOCS thread"); let jh_aocs = thread::Builder::new() @@ -265,10 +351,26 @@ fn static_tmtc_pool_main() { }) .unwrap(); + info!("Starting EPS thread"); + let jh_eps = thread::Builder::new() + .name("sat-rs eps".to_string()) + .spawn(move || loop { + // TODO: We should introduce something like a fixed timeslot helper to allow a more + // declarative API. It would also be very useful for the AOCS task. + pcdu_handler.periodic_operation(eps::pcdu::OpCode::RegularOp); + thread::sleep(Duration::from_millis(50)); + pcdu_handler.periodic_operation(eps::pcdu::OpCode::PollAndRecvReplies); + thread::sleep(Duration::from_millis(50)); + pcdu_handler.periodic_operation(eps::pcdu::OpCode::PollAndRecvReplies); + thread::sleep(Duration::from_millis(300)); + }) + .unwrap(); + info!("Starting PUS handler thread"); let jh_pus_handler = thread::Builder::new() .name("sat-rs pus".to_string()) .spawn(move || loop { + event_handler.periodic_operation(); pus_stack.periodic_operation(); thread::sleep(Duration::from_millis(FREQ_MS_PUS_STACK)); }) @@ -283,10 +385,13 @@ fn static_tmtc_pool_main() { jh_tm_funnel .join() .expect("Joining TM Funnel thread failed"); - jh_event_handling - .join() - .expect("Joining Event Manager thread failed"); + if let Some(jh_sim_client) = opt_jh_sim_client { + jh_sim_client + .join() + .expect("Joining SIM client thread failed"); + } jh_aocs.join().expect("Joining AOCS thread failed"); + jh_eps.join().expect("Joining EPS thread failed"); jh_pus_handler .join() .expect("Joining PUS handler thread failed"); @@ -295,22 +400,38 @@ fn static_tmtc_pool_main() { #[allow(dead_code)] fn dyn_tmtc_pool_main() { let (tc_source_tx, tc_source_rx) = mpsc::channel(); - let (tm_funnel_tx, tm_funnel_rx) = mpsc::channel(); + let (tm_sink_tx, tm_sink_rx) = mpsc::channel(); let (tm_server_tx, tm_server_rx) = mpsc::channel(); + let (sim_request_tx, sim_request_rx) = mpsc::channel(); + let (mgm_sim_reply_tx, mgm_sim_reply_rx) = mpsc::channel(); + let (pcdu_sim_reply_tx, pcdu_sim_reply_rx) = mpsc::channel(); + let mut opt_sim_client = create_sim_client(sim_request_rx); + // Some request are targetable. This map is used to retrieve sender handles based on a target ID. let (mgm_handler_composite_tx, mgm_handler_composite_rx) = - mpsc::channel::>(); - let (mgm_handler_mode_tx, mgm_handler_mode_rx) = mpsc::channel::>(); + mpsc::sync_channel::>(5); + let (pcdu_handler_composite_tx, pcdu_handler_composite_rx) = + mpsc::sync_channel::>(10); + let (mgm_handler_mode_tx, mgm_handler_mode_rx) = + mpsc::sync_channel::>(5); + let (pcdu_handler_mode_tx, pcdu_handler_mode_rx) = + mpsc::sync_channel::>(10); // Some request are targetable. This map is used to retrieve sender handles based on a target ID. let mut request_map = GenericRequestRouter::default(); request_map .composite_router_map - .insert(MGM_HANDLER_0.raw(), mgm_handler_composite_tx); + .insert(MGM_HANDLER_0.id(), mgm_handler_composite_tx); request_map .mode_router_map - .insert(MGM_HANDLER_0.raw(), mgm_handler_mode_tx); + .insert(MGM_HANDLER_0.id(), mgm_handler_mode_tx); + request_map + .composite_router_map + .insert(PCDU_HANDLER.id(), pcdu_handler_composite_tx); + request_map + .mode_router_map + .insert(PCDU_HANDLER.id(), pcdu_handler_mode_tx.clone()); // Create event handling components // These sender handles are used to send event requests, for example to enable or disable @@ -319,7 +440,7 @@ fn dyn_tmtc_pool_main() { let (event_request_tx, event_request_rx) = mpsc::channel::(); // The event task is the core handler to perform the event routing and TM handling as specified // in the sat-rs documentation. - let mut event_handler = EventHandler::new(tm_funnel_tx.clone(), event_rx, event_request_rx); + let mut event_handler = EventHandler::new(tm_sink_tx.clone(), event_rx, event_request_rx); let (pus_test_tx, pus_test_rx) = mpsc::channel(); let (pus_event_tx, pus_event_rx) = mpsc::channel(); @@ -342,30 +463,30 @@ fn dyn_tmtc_pool_main() { }; let pus_test_service = - create_test_service_dynamic(tm_funnel_tx.clone(), event_tx.clone(), pus_test_rx); + create_test_service_dynamic(tm_sink_tx.clone(), event_tx.clone(), pus_test_rx); let pus_scheduler_service = create_scheduler_service_dynamic( - tm_funnel_tx.clone(), + tm_sink_tx.clone(), tc_source_tx.clone(), pus_sched_rx, create_sched_tc_pool(), ); let pus_event_service = - create_event_service_dynamic(tm_funnel_tx.clone(), pus_event_rx, event_request_tx); + create_event_service_dynamic(tm_sink_tx.clone(), pus_event_rx, event_request_tx); let pus_action_service = create_action_service_dynamic( - tm_funnel_tx.clone(), + tm_sink_tx.clone(), pus_action_rx, request_map.clone(), pus_action_reply_rx, ); let pus_hk_service = create_hk_service_dynamic( - tm_funnel_tx.clone(), + tm_sink_tx.clone(), pus_hk_rx, request_map.clone(), pus_hk_reply_rx, ); let pus_mode_service = create_mode_service_dynamic( - tm_funnel_tx.clone(), + tm_sink_tx.clone(), pus_mode_rx, request_map, pus_mode_reply_rx, @@ -381,7 +502,7 @@ fn dyn_tmtc_pool_main() { let mut tmtc_task = TcSourceTaskDynamic::new( tc_source_rx, - PusTcDistributor::new(tm_funnel_tx.clone(), pus_router), + PusTcDistributor::new(tm_sink_tx.clone(), pus_router), ); let sock_addr = SocketAddr::new(IpAddr::V4(OBSW_SERVER_ADDR), SERVER_PORT); @@ -410,28 +531,77 @@ fn dyn_tmtc_pool_main() { ) .expect("tcp server creation failed"); - let mut tm_funnel = TmSinkDynamic::new(sync_tm_tcp_source, tm_funnel_rx, tm_server_tx); + let mut tm_funnel = TmSinkDynamic::new(sync_tm_tcp_source, tm_sink_rx, tm_server_tx); + + let shared_switch_set = Arc::new(Mutex::default()); + let (switch_request_tx, switch_request_rx) = mpsc::sync_channel(20); + let switch_helper = PowerSwitchHelper::new(switch_request_tx, shared_switch_set.clone()); let (mgm_handler_mode_reply_to_parent_tx, _mgm_handler_mode_reply_to_parent_rx) = - mpsc::channel(); - let dummy_spi_interface = SpiDummyInterface::default(); + mpsc::sync_channel(5); let shared_mgm_set = Arc::default(); let mode_leaf_interface = MpscModeLeafInterface { request_rx: mgm_handler_mode_rx, - reply_tx_to_pus: pus_mode_reply_tx, - reply_tx_to_parent: mgm_handler_mode_reply_to_parent_tx, + reply_to_pus_tx: pus_mode_reply_tx.clone(), + reply_to_parent_tx: mgm_handler_mode_reply_to_parent_tx, + }; + + let mgm_spi_interface = if let Some(sim_client) = opt_sim_client.as_mut() { + sim_client.add_reply_recipient(satrs_minisim::SimComponent::MgmLis3Mdl, mgm_sim_reply_tx); + SpiSimInterfaceWrapper::Sim(SpiSimInterface { + sim_request_tx: sim_request_tx.clone(), + sim_reply_rx: mgm_sim_reply_rx, + }) + } else { + SpiSimInterfaceWrapper::Dummy(SpiDummyInterface::default()) }; let mut mgm_handler = MgmHandlerLis3Mdl::new( MGM_HANDLER_0, "MGM_0", mode_leaf_interface, mgm_handler_composite_rx, - pus_hk_reply_tx, - tm_funnel_tx, - dummy_spi_interface, + pus_hk_reply_tx.clone(), + switch_helper.clone(), + tm_sink_tx.clone(), + mgm_spi_interface, shared_mgm_set, ); + let (pcdu_handler_mode_reply_to_parent_tx, _pcdu_handler_mode_reply_to_parent_rx) = + mpsc::sync_channel(10); + let pcdu_mode_leaf_interface = MpscModeLeafInterface { + request_rx: pcdu_handler_mode_rx, + reply_to_pus_tx: pus_mode_reply_tx, + reply_to_parent_tx: pcdu_handler_mode_reply_to_parent_tx, + }; + let pcdu_serial_interface = if let Some(sim_client) = opt_sim_client.as_mut() { + sim_client.add_reply_recipient(satrs_minisim::SimComponent::Pcdu, pcdu_sim_reply_tx); + SerialSimInterfaceWrapper::Sim(SerialInterfaceToSim::new( + sim_request_tx.clone(), + pcdu_sim_reply_rx, + )) + } else { + SerialSimInterfaceWrapper::Dummy(SerialInterfaceDummy::default()) + }; + let mut pcdu_handler = PcduHandler::new( + PCDU_HANDLER, + "PCDU", + pcdu_mode_leaf_interface, + pcdu_handler_composite_rx, + pus_hk_reply_tx, + switch_request_rx, + tm_sink_tx, + pcdu_serial_interface, + shared_switch_set, + ); + // The PCDU is a critical component which should be in normal mode immediately. + pcdu_handler_mode_tx + .send(GenericMessage::new( + MessageMetadata::new(0, NO_SENDER), + ModeRequest::SetMode(ModeAndSubmode::new(DeviceMode::Normal as Mode, 0)), + )) + .expect("sending initial mode request failed"); + info!("Starting TMTC and UDP task"); let jh_udp_tmtc = thread::Builder::new() .name("sat-rs tmtc-udp".to_string()) @@ -464,14 +634,20 @@ fn dyn_tmtc_pool_main() { }) .unwrap(); - info!("Starting event handling task"); - let jh_event_handling = thread::Builder::new() - .name("sat-rs events".to_string()) - .spawn(move || loop { - event_handler.periodic_operation(); - thread::sleep(Duration::from_millis(FREQ_MS_EVENT_HANDLING)); - }) - .unwrap(); + let mut opt_jh_sim_client = None; + if let Some(mut sim_client) = opt_sim_client { + info!("Starting UDP sim client task"); + opt_jh_sim_client = Some( + thread::Builder::new() + .name("sat-rs sim adapter".to_string()) + .spawn(move || loop { + if sim_client.operation() == HandlingStatus::Empty { + std::thread::sleep(Duration::from_millis(SIM_CLIENT_IDLE_DELAY_MS)); + } + }) + .unwrap(), + ); + } info!("Starting AOCS thread"); let jh_aocs = thread::Builder::new() @@ -482,11 +658,27 @@ fn dyn_tmtc_pool_main() { }) .unwrap(); + info!("Starting EPS thread"); + let jh_eps = thread::Builder::new() + .name("sat-rs eps".to_string()) + .spawn(move || loop { + // TODO: We should introduce something like a fixed timeslot helper to allow a more + // declarative API. It would also be very useful for the AOCS task. + pcdu_handler.periodic_operation(eps::pcdu::OpCode::RegularOp); + thread::sleep(Duration::from_millis(50)); + pcdu_handler.periodic_operation(eps::pcdu::OpCode::PollAndRecvReplies); + thread::sleep(Duration::from_millis(50)); + pcdu_handler.periodic_operation(eps::pcdu::OpCode::PollAndRecvReplies); + thread::sleep(Duration::from_millis(300)); + }) + .unwrap(); + info!("Starting PUS handler thread"); let jh_pus_handler = thread::Builder::new() .name("sat-rs pus".to_string()) .spawn(move || loop { pus_stack.periodic_operation(); + event_handler.periodic_operation(); thread::sleep(Duration::from_millis(FREQ_MS_PUS_STACK)); }) .unwrap(); @@ -500,10 +692,13 @@ fn dyn_tmtc_pool_main() { jh_tm_funnel .join() .expect("Joining TM Funnel thread failed"); - jh_event_handling - .join() - .expect("Joining Event Manager thread failed"); + if let Some(jh_sim_client) = opt_jh_sim_client { + jh_sim_client + .join() + .expect("Joining SIM client thread failed"); + } jh_aocs.join().expect("Joining AOCS thread failed"); + jh_eps.join().expect("Joining EPS thread failed"); jh_pus_handler .join() .expect("Joining PUS handler thread failed"); diff --git a/satrs-example/src/pus/action.rs b/satrs-example/src/pus/action.rs index 03d362c..238f1c5 100644 --- a/satrs-example/src/pus/action.rs +++ b/satrs-example/src/pus/action.rs @@ -341,7 +341,7 @@ mod tests { let (tm_funnel_tx, tm_funnel_rx) = mpsc::channel(); let (pus_action_tx, pus_action_rx) = mpsc::channel(); let (action_reply_tx, action_reply_rx) = mpsc::channel(); - let (action_req_tx, action_req_rx) = mpsc::channel(); + let (action_req_tx, action_req_rx) = mpsc::sync_channel(10); let verif_reporter = TestVerificationReporter::new(owner_id); let mut generic_req_router = GenericRequestRouter::default(); generic_req_router diff --git a/satrs-example/src/pus/hk.rs b/satrs-example/src/pus/hk.rs index 0092241..c0d21bf 100644 --- a/satrs-example/src/pus/hk.rs +++ b/satrs-example/src/pus/hk.rs @@ -12,6 +12,7 @@ use satrs::pus::{ PusPacketHandlingError, PusReplyHandler, PusServiceHelper, PusTcToRequestConverter, }; use satrs::request::{GenericMessage, UniqueApidTargetId}; +use satrs::res_code::ResultU16; use satrs::spacepackets::ecss::tc::PusTcReader; use satrs::spacepackets::ecss::{hk, PusPacket, PusServiceId}; use satrs::tmtc::{PacketAsVec, PacketSenderWithSharedPool}; @@ -32,8 +33,10 @@ pub struct HkReply { } #[derive(Clone, PartialEq, Debug)] +#[allow(dead_code)] pub enum HkReplyVariant { Ack, + Failed(ResultU16), } #[derive(Default)] @@ -69,6 +72,15 @@ impl PusReplyHandler for HkReplyHandler { .completion_success(tm_sender, started_token, time_stamp) .expect("sending completion success verification failed"); } + HkReplyVariant::Failed(failure_code) => { + verification_handler + .completion_failure( + tm_sender, + started_token, + FailParams::new(time_stamp, &failure_code, &[]), + ) + .expect("sending completion success verification failed"); + } }; Ok(true) } diff --git a/satrs-example/src/pus/mod.rs b/satrs-example/src/pus/mod.rs index f305308..b2e7f8a 100644 --- a/satrs-example/src/pus/mod.rs +++ b/satrs-example/src/pus/mod.rs @@ -19,7 +19,7 @@ use satrs::tmtc::{PacketAsVec, PacketInPool}; use satrs::ComponentId; use satrs_example::config::components::PUS_ROUTING_SERVICE; use satrs_example::config::{tmtc_err, CustomPusServiceId}; -use satrs_example::TimeStampHelper; +use satrs_example::TimestampHelper; use std::fmt::Debug; use std::sync::mpsc::{self, Sender}; @@ -53,7 +53,7 @@ pub struct PusTcDistributor { pub tm_sender: TmSender, pub verif_reporter: VerificationReporter, pub pus_router: PusTcMpscRouter, - stamp_helper: TimeStampHelper, + stamp_helper: TimestampHelper, } impl PusTcDistributor { @@ -66,7 +66,7 @@ impl PusTcDistributor { PUS_ROUTING_SERVICE.apid, ), pus_router, - stamp_helper: TimeStampHelper::default(), + stamp_helper: TimestampHelper::default(), } } diff --git a/satrs-example/src/requests.rs b/satrs-example/src/requests.rs index 445e05e..316a486 100644 --- a/satrs-example/src/requests.rs +++ b/satrs-example/src/requests.rs @@ -28,8 +28,9 @@ pub enum CompositeRequest { pub struct GenericRequestRouter { pub id: ComponentId, // All messages which do not have a dedicated queue. - pub composite_router_map: HashMap>>, - pub mode_router_map: HashMap>>, + pub composite_router_map: + HashMap>>, + pub mode_router_map: HashMap>>, } impl Default for GenericRequestRouter { diff --git a/satrs-minisim/Cargo.toml b/satrs-minisim/Cargo.toml index cf5848a..6b8c32a 100644 --- a/satrs-minisim/Cargo.toml +++ b/satrs-minisim/Cargo.toml @@ -11,6 +11,8 @@ serde_json = "1" log = "0.4" thiserror = "1" fern = "0.5" +strum = { version = "0.26", features = ["derive"] } +num_enum = "0.7" humantime = "2" [dependencies.asynchronix] diff --git a/satrs-minisim/README.md b/satrs-minisim/README.md new file mode 100644 index 0000000..42f949d --- /dev/null +++ b/satrs-minisim/README.md @@ -0,0 +1,32 @@ +sat-rs minisim +====== + +This crate contains a mini-simulator based on the open-source discrete-event simulation framework +[asynchronix](https://github.com/asynchronics/asynchronix). + +Right now, this crate is primarily used together with the +[`satrs-example` application](https://egit.irs.uni-stuttgart.de/rust/sat-rs/src/branch/main/satrs-example) +to simulate the devices connected to the example application. + +You can simply run this application using + +```sh +cargo run +``` + +or + +```sh +cargo run -p satrs-minisim +``` + +in the workspace. The mini simulator uses the UDP port 7303 to exchange simulation requests and +simulation replies with any other application. + +The simulator was designed in a modular way to be scalable and adaptable to other communication +schemes. This might allow it to serve a mini-simulator for other example applications which +still have similar device handlers. + +The following graph shows the high-level architecture of the mini-simulator. + +Mini simulator architecture diff --git a/satrs-minisim/src/acs.rs b/satrs-minisim/src/acs.rs index 77b8cb0..2403487 100644 --- a/satrs-minisim/src/acs.rs +++ b/satrs-minisim/src/acs.rs @@ -6,14 +6,17 @@ use asynchronix::{ }; use satrs::power::SwitchStateBinary; use satrs_minisim::{ - acs::{MgmReply, MgmSensorValues, MgtDipole, MgtHkSet, MgtReply, MGT_GEN_MAGNETIC_FIELD}, + acs::{ + lis3mdl::MgmLis3MdlReply, MgmReplyCommon, MgmReplyProvider, MgmSensorValuesMicroTesla, + MgtDipole, MgtHkSet, MgtReply, MGT_GEN_MAGNETIC_FIELD, + }, SimReply, }; use crate::time::current_millis; -// Earth magnetic field varies between -30 uT and 30 uT -const AMPLITUDE_MGM: f32 = 0.03; +// Earth magnetic field varies between roughly -30 uT and 30 uT +const AMPLITUDE_MGM_UT: f32 = 30.0; // Lets start with a simple frequency here. const FREQUENCY_MGM: f32 = 1.0; const PHASE_X: f32 = 0.0; @@ -23,38 +26,37 @@ const PHASE_Z: f32 = 0.2; /// Simple model for a magnetometer where the measure magnetic fields are modeled with sine waves. /// -/// Please note that that a more realistic MGM model wouold include the following components -/// which are not included here to simplify the model: -/// -/// 1. It would probably generate signed [i16] values which need to be converted to SI units -/// because it is a digital sensor -/// 2. It would sample the magnetic field at a high fixed rate. This might not be possible for -/// a general purpose OS, but self self-sampling at a relatively high rate (20-40 ms) might -/// stil lbe possible. -pub struct MagnetometerModel { +/// An ideal sensor would sample the magnetic field at a high fixed rate. This might not be +/// possible for a general purpose OS, but self self-sampling at a relatively high rate (20-40 ms) +/// might still be possible and is probably sufficient for many OBSW needs. +pub struct MagnetometerModel { pub switch_state: SwitchStateBinary, pub periodicity: Duration, - pub external_mag_field: Option, + pub external_mag_field: Option, pub reply_sender: mpsc::Sender, + pub phatom: std::marker::PhantomData, } -impl MagnetometerModel { - pub fn new(periodicity: Duration, reply_sender: mpsc::Sender) -> Self { +impl MagnetometerModel { + pub fn new_for_lis3mdl(periodicity: Duration, reply_sender: mpsc::Sender) -> Self { Self { switch_state: SwitchStateBinary::Off, periodicity, external_mag_field: None, reply_sender, + phatom: std::marker::PhantomData, } } +} +impl MagnetometerModel { pub async fn switch_device(&mut self, switch_state: SwitchStateBinary) { self.switch_state = switch_state; } pub async fn send_sensor_values(&mut self, _: (), scheduler: &Scheduler) { self.reply_sender - .send(SimReply::new(MgmReply { + .send(ReplyProvider::create_mgm_reply(MgmReplyCommon { switch_state: self.switch_state, sensor_values: self.calculate_current_mgm_tuple(current_millis(scheduler.time())), })) @@ -63,23 +65,23 @@ impl MagnetometerModel { // Devices like magnetorquers generate a strong magnetic field which overrides the default // model for the measured magnetic field. - pub async fn apply_external_magnetic_field(&mut self, field: MgmSensorValues) { + pub async fn apply_external_magnetic_field(&mut self, field: MgmSensorValuesMicroTesla) { self.external_mag_field = Some(field); } - fn calculate_current_mgm_tuple(&self, time_ms: u64) -> MgmSensorValues { + fn calculate_current_mgm_tuple(&self, time_ms: u64) -> MgmSensorValuesMicroTesla { if SwitchStateBinary::On == self.switch_state { if let Some(ext_field) = self.external_mag_field { return ext_field; } let base_sin_val = 2.0 * PI * FREQUENCY_MGM * (time_ms as f32 / 1000.0); - return MgmSensorValues { - x: AMPLITUDE_MGM * (base_sin_val + PHASE_X).sin(), - y: AMPLITUDE_MGM * (base_sin_val + PHASE_Y).sin(), - z: AMPLITUDE_MGM * (base_sin_val + PHASE_Z).sin(), + return MgmSensorValuesMicroTesla { + x: AMPLITUDE_MGM_UT * (base_sin_val + PHASE_X).sin(), + y: AMPLITUDE_MGM_UT * (base_sin_val + PHASE_Y).sin(), + z: AMPLITUDE_MGM_UT * (base_sin_val + PHASE_Z).sin(), }; } - MgmSensorValues { + MgmSensorValuesMicroTesla { x: 0.0, y: 0.0, z: 0.0, @@ -87,13 +89,13 @@ impl MagnetometerModel { } } -impl Model for MagnetometerModel {} +impl Model for MagnetometerModel {} pub struct MagnetorquerModel { switch_state: SwitchStateBinary, torquing: bool, torque_dipole: MgtDipole, - pub gen_magnetic_field: Output, + pub gen_magnetic_field: Output, reply_sender: mpsc::Sender, } @@ -146,14 +148,14 @@ impl MagnetorquerModel { pub fn send_housekeeping_data(&mut self) { self.reply_sender - .send(SimReply::new(MgtReply::Hk(MgtHkSet { + .send(SimReply::new(&MgtReply::Hk(MgtHkSet { dipole: self.torque_dipole, torquing: self.torquing, }))) .unwrap(); } - fn calc_magnetic_field(&self, _: MgtDipole) -> MgmSensorValues { + fn calc_magnetic_field(&self, _: MgtDipole) -> MgmSensorValuesMicroTesla { // Simplified model: Just returns some fixed magnetic field for now. // Later, we could make this more fancy by incorporating the commanded dipole. MGT_GEN_MAGNETIC_FIELD @@ -179,9 +181,12 @@ pub mod tests { use satrs::power::SwitchStateBinary; use satrs_minisim::{ - acs::{MgmReply, MgmRequest, MgtDipole, MgtHkSet, MgtReply, MgtRequest}, + acs::{ + lis3mdl::{self, MgmLis3MdlReply}, + MgmRequestLis3Mdl, MgtDipole, MgtHkSet, MgtReply, MgtRequest, + }, eps::PcduSwitch, - SerializableSimMsgPayload, SimMessageProvider, SimRequest, SimTarget, + SerializableSimMsgPayload, SimComponent, SimMessageProvider, SimRequest, }; use crate::{eps::tests::switch_device_on, test_helpers::SimTestbench}; @@ -189,7 +194,7 @@ pub mod tests { #[test] fn test_basic_mgm_request() { let mut sim_testbench = SimTestbench::new(); - let request = SimRequest::new_with_epoch_time(MgmRequest::RequestSensorData); + let request = SimRequest::new_with_epoch_time(MgmRequestLis3Mdl::RequestSensorData); sim_testbench .send_request(request) .expect("sending MGM request failed"); @@ -198,13 +203,13 @@ pub mod tests { let sim_reply = sim_testbench.try_receive_next_reply(); assert!(sim_reply.is_some()); let sim_reply = sim_reply.unwrap(); - assert_eq!(sim_reply.target(), SimTarget::Mgm); - let reply = MgmReply::from_sim_message(&sim_reply) + assert_eq!(sim_reply.component(), SimComponent::MgmLis3Mdl); + let reply = MgmLis3MdlReply::from_sim_message(&sim_reply) .expect("failed to deserialize MGM sensor values"); - assert_eq!(reply.switch_state, SwitchStateBinary::Off); - assert_eq!(reply.sensor_values.x, 0.0); - assert_eq!(reply.sensor_values.y, 0.0); - assert_eq!(reply.sensor_values.z, 0.0); + assert_eq!(reply.common.switch_state, SwitchStateBinary::Off); + assert_eq!(reply.common.sensor_values.x, 0.0); + assert_eq!(reply.common.sensor_values.y, 0.0); + assert_eq!(reply.common.sensor_values.z, 0.0); } #[test] @@ -212,7 +217,7 @@ pub mod tests { let mut sim_testbench = SimTestbench::new(); switch_device_on(&mut sim_testbench, PcduSwitch::Mgm); - let mut request = SimRequest::new_with_epoch_time(MgmRequest::RequestSensorData); + let mut request = SimRequest::new_with_epoch_time(MgmRequestLis3Mdl::RequestSensorData); sim_testbench .send_request(request) .expect("sending MGM request failed"); @@ -221,12 +226,12 @@ pub mod tests { let mut sim_reply_res = sim_testbench.try_receive_next_reply(); assert!(sim_reply_res.is_some()); let mut sim_reply = sim_reply_res.unwrap(); - assert_eq!(sim_reply.target(), SimTarget::Mgm); - let first_reply = MgmReply::from_sim_message(&sim_reply) + assert_eq!(sim_reply.component(), SimComponent::MgmLis3Mdl); + let first_reply = MgmLis3MdlReply::from_sim_message(&sim_reply) .expect("failed to deserialize MGM sensor values"); sim_testbench.step_by(Duration::from_millis(50)); - request = SimRequest::new_with_epoch_time(MgmRequest::RequestSensorData); + request = SimRequest::new_with_epoch_time(MgmRequestLis3Mdl::RequestSensorData); sim_testbench .send_request(request) .expect("sending MGM request failed"); @@ -236,8 +241,24 @@ pub mod tests { assert!(sim_reply_res.is_some()); sim_reply = sim_reply_res.unwrap(); - let second_reply = MgmReply::from_sim_message(&sim_reply) + let second_reply = MgmLis3MdlReply::from_sim_message(&sim_reply) .expect("failed to deserialize MGM sensor values"); + let x_conv_back = second_reply.raw.x as f32 + * lis3mdl::FIELD_LSB_PER_GAUSS_4_SENS + * lis3mdl::GAUSS_TO_MICROTESLA_FACTOR as f32; + let y_conv_back = second_reply.raw.y as f32 + * lis3mdl::FIELD_LSB_PER_GAUSS_4_SENS + * lis3mdl::GAUSS_TO_MICROTESLA_FACTOR as f32; + let z_conv_back = second_reply.raw.z as f32 + * lis3mdl::FIELD_LSB_PER_GAUSS_4_SENS + * lis3mdl::GAUSS_TO_MICROTESLA_FACTOR as f32; + let diff_x = (second_reply.common.sensor_values.x - x_conv_back).abs(); + assert!(diff_x < 0.01, "diff x too large: {}", diff_x); + let diff_y = (second_reply.common.sensor_values.y - y_conv_back).abs(); + assert!(diff_y < 0.01, "diff y too large: {}", diff_y); + let diff_z = (second_reply.common.sensor_values.z - z_conv_back).abs(); + assert!(diff_z < 0.01, "diff z too large: {}", diff_z); + // assert_eq!(second_reply.raw_reply, SwitchStateBinary::On); // Check that the values are changing. assert!(first_reply != second_reply); } diff --git a/satrs-minisim/src/controller.rs b/satrs-minisim/src/controller.rs index 9255932..4fe1495 100644 --- a/satrs-minisim/src/controller.rs +++ b/satrs-minisim/src/controller.rs @@ -5,10 +5,10 @@ use asynchronix::{ time::{Clock, MonotonicTime, SystemClock}, }; use satrs_minisim::{ - acs::{MgmRequest, MgtRequest}, + acs::{lis3mdl::MgmLis3MdlReply, MgmRequestLis3Mdl, MgtRequest}, eps::PcduRequest, - SerializableSimMsgPayload, SimCtrlReply, SimCtrlRequest, SimMessageProvider, SimReply, - SimRequest, SimRequestError, SimTarget, + SerializableSimMsgPayload, SimComponent, SimCtrlReply, SimCtrlRequest, SimMessageProvider, + SimReply, SimRequest, SimRequestError, }; use crate::{ @@ -16,13 +16,20 @@ use crate::{ eps::PcduModel, }; +const WARNING_FOR_STALE_DATA: bool = false; + +const SIM_CTRL_REQ_WIRETAPPING: bool = false; +const MGM_REQ_WIRETAPPING: bool = false; +const PCDU_REQ_WIRETAPPING: bool = false; +const MGT_REQ_WIRETAPPING: bool = false; + // The simulation controller processes requests and drives the simulation. pub struct SimController { pub sys_clock: SystemClock, pub request_receiver: mpsc::Receiver, pub reply_sender: mpsc::Sender, pub simulation: Simulation, - pub mgm_addr: Address, + pub mgm_addr: Address>, pub pcdu_addr: Address, pub mgt_addr: Address, } @@ -33,7 +40,7 @@ impl SimController { request_receiver: mpsc::Receiver, reply_sender: mpsc::Sender, simulation: Simulation, - mgm_addr: Address, + mgm_addr: Address>, pcdu_addr: Address, mgt_addr: Address, ) -> Self { @@ -67,14 +74,14 @@ impl SimController { loop { match self.request_receiver.try_recv() { Ok(request) => { - if request.timestamp < old_timestamp { + if request.timestamp < old_timestamp && WARNING_FOR_STALE_DATA { log::warn!("stale data with timestamp {:?} received", request.timestamp); } - if let Err(e) = match request.target() { - SimTarget::SimCtrl => self.handle_ctrl_request(&request), - SimTarget::Mgm => self.handle_mgm_request(&request), - SimTarget::Mgt => self.handle_mgt_request(&request), - SimTarget::Pcdu => self.handle_pcdu_request(&request), + if let Err(e) = match request.component() { + SimComponent::SimCtrl => self.handle_ctrl_request(&request), + SimComponent::MgmLis3Mdl => self.handle_mgm_request(&request), + SimComponent::Mgt => self.handle_mgt_request(&request), + SimComponent::Pcdu => self.handle_pcdu_request(&request), } { self.handle_invalid_request_with_valid_target(e, &request) } @@ -91,19 +98,26 @@ impl SimController { fn handle_ctrl_request(&mut self, request: &SimRequest) -> Result<(), SimRequestError> { let sim_ctrl_request = SimCtrlRequest::from_sim_message(request)?; + if SIM_CTRL_REQ_WIRETAPPING { + log::info!("received sim ctrl request: {:?}", sim_ctrl_request); + } match sim_ctrl_request { SimCtrlRequest::Ping => { self.reply_sender - .send(SimReply::new(SimCtrlReply::Pong)) + .send(SimReply::new(&SimCtrlReply::Pong)) .expect("sending reply from sim controller failed"); } } Ok(()) } + fn handle_mgm_request(&mut self, request: &SimRequest) -> Result<(), SimRequestError> { - let mgm_request = MgmRequest::from_sim_message(request)?; + let mgm_request = MgmRequestLis3Mdl::from_sim_message(request)?; + if MGM_REQ_WIRETAPPING { + log::info!("received MGM request: {:?}", mgm_request); + } match mgm_request { - MgmRequest::RequestSensorData => { + MgmRequestLis3Mdl::RequestSensorData => { self.simulation.send_event( MagnetometerModel::send_sensor_values, (), @@ -116,6 +130,9 @@ impl SimController { fn handle_pcdu_request(&mut self, request: &SimRequest) -> Result<(), SimRequestError> { let pcdu_request = PcduRequest::from_sim_message(request)?; + if PCDU_REQ_WIRETAPPING { + log::info!("received PCDU request: {:?}", pcdu_request); + } match pcdu_request { PcduRequest::RequestSwitchInfo => { self.simulation @@ -134,6 +151,9 @@ impl SimController { fn handle_mgt_request(&mut self, request: &SimRequest) -> Result<(), SimRequestError> { let mgt_request = MgtRequest::from_sim_message(request)?; + if MGT_REQ_WIRETAPPING { + log::info!("received MGT request: {:?}", mgt_request); + } match mgt_request { MgtRequest::ApplyTorque { duration, dipole } => self.simulation.send_event( MagnetorquerModel::apply_torque, @@ -156,11 +176,11 @@ impl SimController { ) { log::warn!( "received invalid {:?} request: {:?}", - request.target(), + request.component(), error ); self.reply_sender - .send(SimReply::new(SimCtrlReply::from(error))) + .send(SimReply::new(&SimCtrlReply::from(error))) .expect("sending reply from sim controller failed"); } } @@ -183,7 +203,7 @@ mod tests { let sim_reply = sim_testbench.try_receive_next_reply(); assert!(sim_reply.is_some()); let sim_reply = sim_reply.unwrap(); - assert_eq!(sim_reply.target(), SimTarget::SimCtrl); + assert_eq!(sim_reply.component(), SimComponent::SimCtrl); let reply = SimCtrlReply::from_sim_message(&sim_reply) .expect("failed to deserialize MGM sensor values"); assert_eq!(reply, SimCtrlReply::Pong); diff --git a/satrs-minisim/src/eps.rs b/satrs-minisim/src/eps.rs index ebbeb4e..c07e290 100644 --- a/satrs-minisim/src/eps.rs +++ b/satrs-minisim/src/eps.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::mpsc, time::Duration}; +use std::{sync::mpsc, time::Duration}; use asynchronix::{ model::{Model, Output}, @@ -6,14 +6,14 @@ use asynchronix::{ }; use satrs::power::SwitchStateBinary; use satrs_minisim::{ - eps::{PcduReply, PcduSwitch, SwitchMap}, + eps::{PcduReply, PcduSwitch, SwitchMapBinaryWrapper}, SimReply, }; pub const SWITCH_INFO_DELAY_MS: u64 = 10; pub struct PcduModel { - pub switcher_map: SwitchMap, + pub switcher_map: SwitchMapBinaryWrapper, pub mgm_switch: Output, pub mgt_switch: Output, pub reply_sender: mpsc::Sender, @@ -21,12 +21,8 @@ pub struct PcduModel { impl PcduModel { pub fn new(reply_sender: mpsc::Sender) -> Self { - let mut switcher_map = HashMap::new(); - switcher_map.insert(PcduSwitch::Mgm, SwitchStateBinary::Off); - switcher_map.insert(PcduSwitch::Mgt, SwitchStateBinary::Off); - Self { - switcher_map, + switcher_map: Default::default(), mgm_switch: Output::new(), mgt_switch: Output::new(), reply_sender, @@ -44,7 +40,7 @@ impl PcduModel { } pub fn send_switch_info(&mut self) { - let reply = SimReply::new(PcduReply::SwitchInfo(self.switcher_map.clone())); + let reply = SimReply::new(&PcduReply::SwitchInfo(self.switcher_map.0.clone())); self.reply_sender.send(reply).unwrap(); } @@ -54,6 +50,7 @@ impl PcduModel { ) { let val = self .switcher_map + .0 .get_mut(&switch_and_target_state.0) .unwrap_or_else(|| panic!("switch {:?} not found", switch_and_target_state.0)); *val = switch_and_target_state.1; @@ -76,7 +73,8 @@ pub(crate) mod tests { use std::time::Duration; use satrs_minisim::{ - eps::PcduRequest, SerializableSimMsgPayload, SimMessageProvider, SimRequest, SimTarget, + eps::{PcduRequest, SwitchMapBinary}, + SerializableSimMsgPayload, SimComponent, SimMessageProvider, SimRequest, }; use crate::test_helpers::SimTestbench; @@ -105,14 +103,11 @@ pub(crate) mod tests { switch_device(sim_testbench, switch, SwitchStateBinary::On); } - pub(crate) fn get_all_off_switch_map() -> SwitchMap { - let mut switcher_map = SwitchMap::new(); - switcher_map.insert(super::PcduSwitch::Mgm, super::SwitchStateBinary::Off); - switcher_map.insert(super::PcduSwitch::Mgt, super::SwitchStateBinary::Off); - switcher_map + pub(crate) fn get_all_off_switch_map() -> SwitchMapBinary { + SwitchMapBinaryWrapper::default().0 } - fn check_switch_state(sim_testbench: &mut SimTestbench, expected_switch_map: &SwitchMap) { + fn check_switch_state(sim_testbench: &mut SimTestbench, expected_switch_map: &SwitchMapBinary) { let request = SimRequest::new_with_epoch_time(PcduRequest::RequestSwitchInfo); sim_testbench .send_request(request) @@ -122,7 +117,7 @@ pub(crate) mod tests { let sim_reply = sim_testbench.try_receive_next_reply(); assert!(sim_reply.is_some()); let sim_reply = sim_reply.unwrap(); - assert_eq!(sim_reply.target(), SimTarget::Pcdu); + assert_eq!(sim_reply.component(), SimComponent::Pcdu); let pcdu_reply = PcduReply::from_sim_message(&sim_reply) .expect("failed to deserialize PCDU switch info"); match pcdu_reply { @@ -157,7 +152,7 @@ pub(crate) mod tests { let sim_reply = sim_testbench.try_receive_next_reply(); assert!(sim_reply.is_some()); let sim_reply = sim_reply.unwrap(); - assert_eq!(sim_reply.target(), SimTarget::Pcdu); + assert_eq!(sim_reply.component(), SimComponent::Pcdu); let pcdu_reply = PcduReply::from_sim_message(&sim_reply) .expect("failed to deserialize PCDU switch info"); match pcdu_reply { diff --git a/satrs-minisim/src/lib.rs b/satrs-minisim/src/lib.rs index 2762326..b3b7d44 100644 --- a/satrs-minisim/src/lib.rs +++ b/satrs-minisim/src/lib.rs @@ -1,19 +1,18 @@ use asynchronix::time::MonotonicTime; +use num_enum::{IntoPrimitive, TryFromPrimitive}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; -pub const SIM_CTRL_UDP_PORT: u16 = 7303; - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum SimTarget { +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub enum SimComponent { SimCtrl, - Mgm, + MgmLis3Mdl, Mgt, Pcdu, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SimMessage { - pub target: SimTarget, + pub target: SimComponent, pub payload: String, } @@ -37,10 +36,10 @@ pub enum SimMessageType { pub trait SerializableSimMsgPayload: Serialize + DeserializeOwned + Sized { - const TARGET: SimTarget; + const TARGET: SimComponent; fn from_sim_message(sim_message: &P) -> Result> { - if sim_message.target() == Self::TARGET { + if sim_message.component() == Self::TARGET { return Ok(serde_json::from_str(sim_message.payload())?); } Err(SimMessageError::TargetRequestMissmatch(sim_message.clone())) @@ -49,7 +48,7 @@ pub trait SerializableSimMsgPayload: pub trait SimMessageProvider: Serialize + DeserializeOwned + Clone + Sized { fn msg_type(&self) -> SimMessageType; - fn target(&self) -> SimTarget; + fn component(&self) -> SimComponent; fn payload(&self) -> &String; fn from_raw_data(data: &[u8]) -> serde_json::Result { serde_json::from_slice(data) @@ -78,7 +77,7 @@ impl SimRequest { } impl SimMessageProvider for SimRequest { - fn target(&self) -> SimTarget { + fn component(&self) -> SimComponent { self.inner.target } fn payload(&self) -> &String { @@ -91,25 +90,25 @@ impl SimMessageProvider for SimRequest { } /// A generic simulation reply type. Right now, the payload data is expected to be -/// JSON, which might be changed inthe future. +/// JSON, which might be changed in the future. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SimReply { inner: SimMessage, } impl SimReply { - pub fn new>(serializable_reply: T) -> Self { + pub fn new>(serializable_reply: &T) -> Self { Self { inner: SimMessage { target: T::TARGET, - payload: serde_json::to_string(&serializable_reply).unwrap(), + payload: serde_json::to_string(serializable_reply).unwrap(), }, } } } impl SimMessageProvider for SimReply { - fn target(&self) -> SimTarget { + fn component(&self) -> SimComponent { self.inner.target } fn payload(&self) -> &String { @@ -126,7 +125,7 @@ pub enum SimCtrlRequest { } impl SerializableSimMsgPayload for SimCtrlRequest { - const TARGET: SimTarget = SimTarget::SimCtrl; + const TARGET: SimComponent = SimComponent::SimCtrl; } pub type SimReplyError = SimMessageError; @@ -151,7 +150,7 @@ pub enum SimCtrlReply { } impl SerializableSimMsgPayload for SimCtrlReply { - const TARGET: SimTarget = SimTarget::SimCtrl; + const TARGET: SimComponent = SimComponent::SimCtrl; } impl From for SimCtrlReply { @@ -162,19 +161,82 @@ impl From for SimCtrlReply { pub mod eps { use super::*; + use satrs::power::{SwitchState, SwitchStateBinary}; use std::collections::HashMap; + use strum::{EnumIter, IntoEnumIterator}; - use satrs::power::SwitchStateBinary; + pub type SwitchMap = HashMap; + pub type SwitchMapBinary = HashMap; - pub type SwitchMap = HashMap; + pub struct SwitchMapWrapper(pub SwitchMap); + pub struct SwitchMapBinaryWrapper(pub SwitchMapBinary); - #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] + #[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + Serialize, + Deserialize, + Hash, + EnumIter, + IntoPrimitive, + TryFromPrimitive, + )] + #[repr(u16)] pub enum PcduSwitch { Mgm = 0, Mgt = 1, } - #[derive(Debug, Copy, Clone, Serialize, Deserialize)] + impl Default for SwitchMapBinaryWrapper { + fn default() -> Self { + let mut switch_map = SwitchMapBinary::default(); + for entry in PcduSwitch::iter() { + switch_map.insert(entry, SwitchStateBinary::Off); + } + Self(switch_map) + } + } + + impl Default for SwitchMapWrapper { + fn default() -> Self { + let mut switch_map = SwitchMap::default(); + for entry in PcduSwitch::iter() { + switch_map.insert(entry, SwitchState::Unknown); + } + Self(switch_map) + } + } + + impl SwitchMapWrapper { + pub fn new_with_init_switches_off() -> Self { + let mut switch_map = SwitchMap::default(); + for entry in PcduSwitch::iter() { + switch_map.insert(entry, SwitchState::Off); + } + Self(switch_map) + } + + pub fn from_binary_switch_map_ref(switch_map: &SwitchMapBinary) -> Self { + Self( + switch_map + .iter() + .map(|(key, value)| (*key, SwitchState::from(*value))) + .collect(), + ) + } + } + + #[derive(Debug, Copy, Clone)] + #[repr(u8)] + pub enum PcduRequestId { + SwitchDevice = 0, + RequestSwitchInfo = 1, + } + + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PcduRequest { SwitchDevice { switch: PcduSwitch, @@ -184,16 +246,17 @@ pub mod eps { } impl SerializableSimMsgPayload for PcduRequest { - const TARGET: SimTarget = SimTarget::Pcdu; + const TARGET: SimComponent = SimComponent::Pcdu; } - #[derive(Debug, Clone, Serialize, Deserialize)] + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum PcduReply { - SwitchInfo(SwitchMap), + // Ack, + SwitchInfo(SwitchMapBinary), } impl SerializableSimMsgPayload for PcduReply { - const TARGET: SimTarget = SimTarget::Pcdu; + const TARGET: SimComponent = SimComponent::Pcdu; } } @@ -204,40 +267,116 @@ pub mod acs { use super::*; + pub trait MgmReplyProvider: Send + 'static { + fn create_mgm_reply(common: MgmReplyCommon) -> SimReply; + } + #[derive(Debug, Copy, Clone, Serialize, Deserialize)] - pub enum MgmRequest { + pub enum MgmRequestLis3Mdl { RequestSensorData, } - impl SerializableSimMsgPayload for MgmRequest { - const TARGET: SimTarget = SimTarget::Mgm; + impl SerializableSimMsgPayload for MgmRequestLis3Mdl { + const TARGET: SimComponent = SimComponent::MgmLis3Mdl; } // Normally, small magnetometers generate their output as a signed 16 bit raw format or something // similar which needs to be converted to a signed float value with physical units. We will - // simplify this now and generate the signed float values directly. + // simplify this now and generate the signed float values directly. The unit is micro tesla. #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] - pub struct MgmSensorValues { + pub struct MgmSensorValuesMicroTesla { pub x: f32, pub y: f32, pub z: f32, } #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] - pub struct MgmReply { + pub struct MgmReplyCommon { pub switch_state: SwitchStateBinary, - pub sensor_values: MgmSensorValues, + pub sensor_values: MgmSensorValuesMicroTesla, } - impl SerializableSimMsgPayload for MgmReply { - const TARGET: SimTarget = SimTarget::Mgm; - } - - pub const MGT_GEN_MAGNETIC_FIELD: MgmSensorValues = MgmSensorValues { - x: 0.03, - y: -0.03, - z: 0.03, + pub const MGT_GEN_MAGNETIC_FIELD: MgmSensorValuesMicroTesla = MgmSensorValuesMicroTesla { + x: 30.0, + y: -30.0, + z: 30.0, }; + pub const ALL_ONES_SENSOR_VAL: i16 = 0xffff_u16 as i16; + + pub mod lis3mdl { + use super::*; + + // Field data register scaling + pub const GAUSS_TO_MICROTESLA_FACTOR: u32 = 100; + pub const FIELD_LSB_PER_GAUSS_4_SENS: f32 = 1.0 / 6842.0; + pub const FIELD_LSB_PER_GAUSS_8_SENS: f32 = 1.0 / 3421.0; + pub const FIELD_LSB_PER_GAUSS_12_SENS: f32 = 1.0 / 2281.0; + pub const FIELD_LSB_PER_GAUSS_16_SENS: f32 = 1.0 / 1711.0; + + #[derive(Default, Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] + pub struct MgmLis3RawValues { + pub x: i16, + pub y: i16, + pub z: i16, + } + + #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] + pub struct MgmLis3MdlReply { + pub common: MgmReplyCommon, + // Raw sensor values which are transmitted by the LIS3 device in little-endian + // order. + pub raw: MgmLis3RawValues, + } + + impl MgmLis3MdlReply { + pub fn new(common: MgmReplyCommon) -> Self { + match common.switch_state { + SwitchStateBinary::Off => Self { + common, + raw: MgmLis3RawValues { + x: ALL_ONES_SENSOR_VAL, + y: ALL_ONES_SENSOR_VAL, + z: ALL_ONES_SENSOR_VAL, + }, + }, + SwitchStateBinary::On => { + let mut raw_reply: [u8; 7] = [0; 7]; + let raw_x: i16 = (common.sensor_values.x + / (GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS)) + .round() as i16; + let raw_y: i16 = (common.sensor_values.y + / (GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS)) + .round() as i16; + let raw_z: i16 = (common.sensor_values.z + / (GAUSS_TO_MICROTESLA_FACTOR as f32 * FIELD_LSB_PER_GAUSS_4_SENS)) + .round() as i16; + // The first byte is a dummy byte. + raw_reply[1..3].copy_from_slice(&raw_x.to_be_bytes()); + raw_reply[3..5].copy_from_slice(&raw_y.to_be_bytes()); + raw_reply[5..7].copy_from_slice(&raw_z.to_be_bytes()); + Self { + common, + raw: MgmLis3RawValues { + x: raw_x, + y: raw_y, + z: raw_z, + }, + } + } + } + } + } + + impl SerializableSimMsgPayload for MgmLis3MdlReply { + const TARGET: SimComponent = SimComponent::MgmLis3Mdl; + } + + impl MgmReplyProvider for MgmLis3MdlReply { + fn create_mgm_reply(common: MgmReplyCommon) -> SimReply { + SimReply::new(&Self::new(common)) + } + } + } // Simple model using i16 values. #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -262,7 +401,7 @@ pub mod acs { } impl SerializableSimMsgPayload for MgtRequest { - const TARGET: SimTarget = SimTarget::Mgt; + const TARGET: SimComponent = SimComponent::Mgt; } #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -279,78 +418,12 @@ pub mod acs { } impl SerializableSimMsgPayload for MgtReply { - const TARGET: SimTarget = SimTarget::Mgm; + const TARGET: SimComponent = SimComponent::MgmLis3Mdl; } } pub mod udp { - use std::{ - net::{SocketAddr, UdpSocket}, - time::Duration, - }; - - use thiserror::Error; - - use crate::{SimReply, SimRequest}; - - #[derive(Error, Debug)] - pub enum ReceptionError { - #[error("IO error: {0}")] - Io(#[from] std::io::Error), - #[error("Serde JSON error: {0}")] - SerdeJson(#[from] serde_json::Error), - } - - pub struct SimUdpClient { - socket: UdpSocket, - pub reply_buf: [u8; 4096], - } - - impl SimUdpClient { - pub fn new( - server_addr: &SocketAddr, - non_blocking: bool, - read_timeot_ms: Option, - ) -> std::io::Result { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_nonblocking(non_blocking)?; - socket - .connect(server_addr) - .expect("could not connect to server addr"); - if let Some(read_timeout) = read_timeot_ms { - // Set a read timeout so the test does not hang on failures. - socket.set_read_timeout(Some(Duration::from_millis(read_timeout)))?; - } - Ok(Self { - socket, - reply_buf: [0; 4096], - }) - } - - pub fn set_nonblocking(&self, non_blocking: bool) -> std::io::Result<()> { - self.socket.set_nonblocking(non_blocking) - } - - pub fn set_read_timeout(&self, read_timeout_ms: u64) -> std::io::Result<()> { - self.socket - .set_read_timeout(Some(Duration::from_millis(read_timeout_ms))) - } - - pub fn send_request(&self, sim_request: &SimRequest) -> std::io::Result { - self.socket.send( - &serde_json::to_vec(sim_request).expect("conversion of request to vector failed"), - ) - } - - pub fn recv_raw(&mut self) -> std::io::Result { - self.socket.recv(&mut self.reply_buf) - } - - pub fn recv_sim_reply(&mut self) -> Result { - let read_len = self.recv_raw()?; - Ok(serde_json::from_slice(&self.reply_buf[0..read_len])?) - } - } + pub const SIM_CTRL_PORT: u16 = 7303; } #[cfg(test)] @@ -363,7 +436,7 @@ pub mod tests { } impl SerializableSimMsgPayload for DummyRequest { - const TARGET: SimTarget = SimTarget::SimCtrl; + const TARGET: SimComponent = SimComponent::SimCtrl; } #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -372,13 +445,13 @@ pub mod tests { } impl SerializableSimMsgPayload for DummyReply { - const TARGET: SimTarget = SimTarget::SimCtrl; + const TARGET: SimComponent = SimComponent::SimCtrl; } #[test] fn test_basic_request() { let sim_request = SimRequest::new_with_epoch_time(DummyRequest::Ping); - assert_eq!(sim_request.target(), SimTarget::SimCtrl); + assert_eq!(sim_request.component(), SimComponent::SimCtrl); assert_eq!(sim_request.msg_type(), SimMessageType::Request); let dummy_request = DummyRequest::from_sim_message(&sim_request).expect("deserialization failed"); @@ -387,8 +460,8 @@ pub mod tests { #[test] fn test_basic_reply() { - let sim_reply = SimReply::new(DummyReply::Pong); - assert_eq!(sim_reply.target(), SimTarget::SimCtrl); + let sim_reply = SimReply::new(&DummyReply::Pong); + assert_eq!(sim_reply.component(), SimComponent::SimCtrl); assert_eq!(sim_reply.msg_type(), SimMessageType::Reply); let dummy_request = DummyReply::from_sim_message(&sim_reply).expect("deserialization failed"); diff --git a/satrs-minisim/src/main.rs b/satrs-minisim/src/main.rs index 59701f7..4b6c240 100644 --- a/satrs-minisim/src/main.rs +++ b/satrs-minisim/src/main.rs @@ -3,7 +3,8 @@ use asynchronix::simulation::{Mailbox, SimInit}; use asynchronix::time::{MonotonicTime, SystemClock}; use controller::SimController; use eps::PcduModel; -use satrs_minisim::{SimReply, SimRequest, SIM_CTRL_UDP_PORT}; +use satrs_minisim::udp::SIM_CTRL_PORT; +use satrs_minisim::{SimReply, SimRequest}; use std::sync::mpsc; use std::thread; use std::time::{Duration, SystemTime}; @@ -30,7 +31,8 @@ fn create_sim_controller( request_receiver: mpsc::Receiver, ) -> SimController { // Instantiate models and their mailboxes. - let mgm_model = MagnetometerModel::new(Duration::from_millis(50), reply_sender.clone()); + let mgm_model = + MagnetometerModel::new_for_lis3mdl(Duration::from_millis(50), reply_sender.clone()); let mgm_mailbox = Mailbox::new(); let mgm_addr = mgm_mailbox.address(); @@ -112,9 +114,9 @@ fn main() { }); let mut udp_server = - SimUdpServer::new(SIM_CTRL_UDP_PORT, request_sender, reply_receiver, 200, None) + SimUdpServer::new(SIM_CTRL_PORT, request_sender, reply_receiver, 200, None) .expect("could not create UDP request server"); - log::info!("starting UDP server on port {}", SIM_CTRL_UDP_PORT); + log::info!("starting UDP server on port {}", SIM_CTRL_PORT); // This thread manages the simulator UDP server. let udp_tc_thread = thread::spawn(move || { udp_server.run(); diff --git a/satrs-minisim/src/udp.rs b/satrs-minisim/src/udp.rs index ad50672..e177547 100644 --- a/satrs-minisim/src/udp.rs +++ b/satrs-minisim/src/udp.rs @@ -150,6 +150,7 @@ impl SimUdpServer { mod tests { use std::{ io::ErrorKind, + net::{SocketAddr, UdpSocket}, sync::{ atomic::{AtomicBool, Ordering}, mpsc, Arc, @@ -159,7 +160,6 @@ mod tests { use satrs_minisim::{ eps::{PcduReply, PcduRequest}, - udp::{ReceptionError, SimUdpClient}, SimCtrlReply, SimCtrlRequest, SimReply, SimRequest, }; @@ -171,8 +171,57 @@ mod tests { // Wait time to ensure even possibly laggy systems like CI servers can run the tests. const SERVER_WAIT_TIME_MS: u64 = 50; + #[derive(thiserror::Error, Debug)] + pub enum ReceptionError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + #[error("Serde JSON error: {0}")] + SerdeJson(#[from] serde_json::Error), + } + + pub struct SimUdpTestClient { + socket: UdpSocket, + pub reply_buf: [u8; 4096], + } + + impl SimUdpTestClient { + pub fn new( + server_addr: &SocketAddr, + non_blocking: bool, + read_timeot_ms: Option, + ) -> std::io::Result { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.set_nonblocking(non_blocking)?; + socket + .connect(server_addr) + .expect("could not connect to server addr"); + if let Some(read_timeout) = read_timeot_ms { + // Set a read timeout so the test does not hang on failures. + socket.set_read_timeout(Some(Duration::from_millis(read_timeout)))?; + } + Ok(Self { + socket, + reply_buf: [0; 4096], + }) + } + + pub fn send_request(&self, sim_request: &SimRequest) -> std::io::Result { + self.socket.send( + &serde_json::to_vec(sim_request).expect("conversion of request to vector failed"), + ) + } + + pub fn recv_raw(&mut self) -> std::io::Result { + self.socket.recv(&mut self.reply_buf) + } + + pub fn recv_sim_reply(&mut self) -> Result { + let read_len = self.recv_raw()?; + Ok(serde_json::from_slice(&self.reply_buf[0..read_len])?) + } + } struct UdpTestbench { - client: SimUdpClient, + client: SimUdpTestClient, stop_signal: Arc, request_receiver: mpsc::Receiver, reply_sender: mpsc::Sender, @@ -197,7 +246,7 @@ mod tests { let server_addr = server.server_addr()?; Ok(( Self { - client: SimUdpClient::new( + client: SimUdpTestClient::new( &server_addr, client_non_blocking, client_read_timeout_ms, @@ -295,7 +344,7 @@ mod tests { .send_request(&SimRequest::new_with_epoch_time(SimCtrlRequest::Ping)) .expect("sending request failed"); - let sim_reply = SimReply::new(PcduReply::SwitchInfo(get_all_off_switch_map())); + let sim_reply = SimReply::new(&PcduReply::SwitchInfo(get_all_off_switch_map())); udp_testbench.send_reply(&sim_reply); udp_testbench.check_next_sim_reply(&sim_reply); @@ -320,7 +369,7 @@ mod tests { .expect("sending request failed"); // Send a reply to the server, ensure it gets forwarded to the client. - let sim_reply = SimReply::new(PcduReply::SwitchInfo(get_all_off_switch_map())); + let sim_reply = SimReply::new(&PcduReply::SwitchInfo(get_all_off_switch_map())); udp_testbench.send_reply(&sim_reply); std::thread::sleep(Duration::from_millis(SERVER_WAIT_TIME_MS)); @@ -339,7 +388,7 @@ mod tests { let server_thread = std::thread::spawn(move || udp_server.run()); // Send a reply to the server. The client is not connected, so it won't get forwarded. - let sim_reply = SimReply::new(PcduReply::SwitchInfo(get_all_off_switch_map())); + let sim_reply = SimReply::new(&PcduReply::SwitchInfo(get_all_off_switch_map())); udp_testbench.send_reply(&sim_reply); std::thread::sleep(Duration::from_millis(10)); @@ -366,7 +415,7 @@ mod tests { let server_thread = std::thread::spawn(move || udp_server.run()); // The server only caches up to 3 replies. - let sim_reply = SimReply::new(SimCtrlReply::Pong); + let sim_reply = SimReply::new(&SimCtrlReply::Pong); for _ in 0..4 { udp_testbench.send_reply(&sim_reply); } diff --git a/satrs/src/power.rs b/satrs/src/power.rs index 1e1fda1..cb2648a 100644 --- a/satrs/src/power.rs +++ b/satrs/src/power.rs @@ -1,22 +1,17 @@ +use core::time::Duration; + +use derive_new::new; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +#[cfg(feature = "std")] +#[allow(unused_imports)] +pub use std_mod::*; -/// Generic trait for a device capable of switching itself on or off. -pub trait PowerSwitch { - type Error; - - fn switch_on(&mut self) -> Result<(), Self::Error>; - fn switch_off(&mut self) -> Result<(), Self::Error>; - - fn is_switch_on(&self) -> bool { - self.switch_state() == SwitchState::On - } - - fn switch_state(&self) -> SwitchState; -} +use crate::request::MessageMetadata; #[derive(Debug, Eq, PartialEq, Copy, Clone)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] pub enum SwitchState { Off = 0, On = 1, @@ -26,6 +21,7 @@ pub enum SwitchState { #[derive(Debug, Eq, PartialEq, Copy, Clone)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] pub enum SwitchStateBinary { Off = 0, On = 1, @@ -63,76 +59,254 @@ impl From for SwitchState { pub type SwitchId = u16; /// Generic trait for a device capable of turning on and off switches. -pub trait PowerSwitcherCommandSender { - type Error; +pub trait PowerSwitcherCommandSender> { + type Error: core::fmt::Debug; - fn send_switch_on_cmd(&mut self, switch_id: SwitchId) -> Result<(), Self::Error>; - fn send_switch_off_cmd(&mut self, switch_id: SwitchId) -> Result<(), Self::Error>; + fn send_switch_on_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: SwitchType, + ) -> Result<(), Self::Error>; + fn send_switch_off_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: SwitchType, + ) -> Result<(), Self::Error>; } -pub trait PowerSwitchInfo { - type Error; +pub trait PowerSwitchInfo { + type Error: core::fmt::Debug; /// Retrieve the switch state - fn get_switch_state(&mut self, switch_id: SwitchId) -> Result; + fn switch_state(&self, switch_id: SwitchType) -> Result; - fn get_is_switch_on(&mut self, switch_id: SwitchId) -> Result { - Ok(self.get_switch_state(switch_id)? == SwitchState::On) + fn is_switch_on(&self, switch_id: SwitchType) -> Result { + Ok(self.switch_state(switch_id)? == SwitchState::On) } /// The maximum delay it will take to change a switch. /// /// This may take into account the time to send a command, wait for it to be executed, and /// see the switch changed. - fn switch_delay_ms(&self) -> u32; + fn switch_delay_ms(&self) -> Duration; +} + +#[derive(new)] +pub struct SwitchRequest { + switch_id: SwitchId, + target_state: SwitchStateBinary, +} + +impl SwitchRequest { + pub fn switch_id(&self) -> SwitchId { + self.switch_id + } + + pub fn target_state(&self) -> SwitchStateBinary { + self.target_state + } +} + +#[cfg(feature = "std")] +pub mod std_mod { + use std::sync::mpsc; + + use crate::{ + queue::GenericSendError, + request::{GenericMessage, MessageMetadata}, + }; + + use super::*; + + pub type MpscSwitchCmdSender = mpsc::Sender>; + pub type MpscSwitchCmdSenderBounded = mpsc::SyncSender>; + + impl> PowerSwitcherCommandSender for MpscSwitchCmdSender { + type Error = GenericSendError; + + fn send_switch_on_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: SwitchType, + ) -> Result<(), Self::Error> { + self.send(GenericMessage::new( + requestor_info, + SwitchRequest::new(switch_id.into(), SwitchStateBinary::On), + )) + .map_err(|_| GenericSendError::RxDisconnected) + } + + fn send_switch_off_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: SwitchType, + ) -> Result<(), Self::Error> { + self.send(GenericMessage::new( + requestor_info, + SwitchRequest::new(switch_id.into(), SwitchStateBinary::Off), + )) + .map_err(|_| GenericSendError::RxDisconnected) + } + } + + impl> PowerSwitcherCommandSender for MpscSwitchCmdSenderBounded { + type Error = GenericSendError; + + fn send_switch_on_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: SwitchType, + ) -> Result<(), Self::Error> { + self.try_send(GenericMessage::new( + requestor_info, + SwitchRequest::new(switch_id.into(), SwitchStateBinary::On), + )) + .map_err(|e| match e { + mpsc::TrySendError::Full(_) => GenericSendError::QueueFull(None), + mpsc::TrySendError::Disconnected(_) => GenericSendError::RxDisconnected, + }) + } + + fn send_switch_off_cmd( + &self, + requestor_info: MessageMetadata, + switch_id: SwitchType, + ) -> Result<(), Self::Error> { + self.try_send(GenericMessage::new( + requestor_info, + SwitchRequest::new(switch_id.into(), SwitchStateBinary::Off), + )) + .map_err(|e| match e { + mpsc::TrySendError::Full(_) => GenericSendError::QueueFull(None), + mpsc::TrySendError::Disconnected(_) => GenericSendError::RxDisconnected, + }) + } + } } #[cfg(test)] mod tests { - #![allow(dead_code)] + // TODO: Add unittests for PowerSwitcherCommandSender impls for mpsc. + + use std::sync::mpsc::{self, TryRecvError}; + + use crate::{queue::GenericSendError, request::GenericMessage, ComponentId}; + use super::*; - use std::boxed::Box; - struct Pcdu { - switch_rx: std::sync::mpsc::Receiver<(SwitchId, u16)>, + const TEST_REQ_ID: u32 = 2; + const TEST_SENDER_ID: ComponentId = 5; + + const TEST_SWITCH_ID: u16 = 0x1ff; + + fn common_checks(request: &GenericMessage) { + assert_eq!(request.requestor_info.sender_id(), TEST_SENDER_ID); + assert_eq!(request.requestor_info.request_id(), TEST_REQ_ID); + assert_eq!(request.message.switch_id(), TEST_SWITCH_ID); } - #[derive(Eq, PartialEq)] - enum DeviceState { - OFF, - SwitchingPower, - ON, - SETUP, - IDLE, - } - struct MyComplexDevice { - power_switcher: Box>, - power_info: Box>, - switch_id: SwitchId, - some_state: u16, - dev_state: DeviceState, - mode: u32, - submode: u16, + #[test] + fn test_comand_switch_sending_mpsc_regular_on_cmd() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::channel::>(); + switch_cmd_tx + .send_switch_on_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ) + .expect("sending switch cmd failed"); + let request = switch_cmd_rx + .recv() + .expect("receiving switch request failed"); + common_checks(&request); + assert_eq!(request.message.target_state(), SwitchStateBinary::On); } - impl MyComplexDevice { - pub fn periodic_op(&mut self) { - // .. mode command coming in - let mode = 1; - if mode == 1 { - if self.dev_state == DeviceState::OFF { - self.power_switcher - .send_switch_on_cmd(self.switch_id) - .expect("sending siwthc cmd failed"); - self.dev_state = DeviceState::SwitchingPower; - } - if self.dev_state == DeviceState::SwitchingPower { - if self.power_info.get_is_switch_on(0).unwrap() { - self.dev_state = DeviceState::ON; - self.mode = 1; - } - } - } - } + #[test] + fn test_comand_switch_sending_mpsc_regular_off_cmd() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::channel::>(); + switch_cmd_tx + .send_switch_off_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ) + .expect("sending switch cmd failed"); + let request = switch_cmd_rx + .recv() + .expect("receiving switch request failed"); + common_checks(&request); + assert_eq!(request.message.target_state(), SwitchStateBinary::Off); + } + + #[test] + fn test_comand_switch_sending_mpsc_regular_rx_disconnected() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::channel::>(); + drop(switch_cmd_rx); + let result = switch_cmd_tx.send_switch_off_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ); + assert!(result.is_err()); + matches!(result.unwrap_err(), GenericSendError::RxDisconnected); + } + + #[test] + fn test_comand_switch_sending_mpsc_sync_on_cmd() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::sync_channel::>(3); + switch_cmd_tx + .send_switch_on_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ) + .expect("sending switch cmd failed"); + let request = switch_cmd_rx + .recv() + .expect("receiving switch request failed"); + common_checks(&request); + assert_eq!(request.message.target_state(), SwitchStateBinary::On); + } + + #[test] + fn test_comand_switch_sending_mpsc_sync_off_cmd() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::sync_channel::>(3); + switch_cmd_tx + .send_switch_off_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ) + .expect("sending switch cmd failed"); + let request = switch_cmd_rx + .recv() + .expect("receiving switch request failed"); + common_checks(&request); + assert_eq!(request.message.target_state(), SwitchStateBinary::Off); + } + + #[test] + fn test_comand_switch_sending_mpsc_sync_rx_disconnected() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::sync_channel::>(1); + drop(switch_cmd_rx); + let result = switch_cmd_tx.send_switch_off_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ); + assert!(result.is_err()); + matches!(result.unwrap_err(), GenericSendError::RxDisconnected); + } + + #[test] + fn test_comand_switch_sending_mpsc_sync_queue_full() { + let (switch_cmd_tx, switch_cmd_rx) = mpsc::sync_channel::>(1); + let mut result = switch_cmd_tx.send_switch_off_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ); + assert!(result.is_ok()); + result = switch_cmd_tx.send_switch_off_cmd( + MessageMetadata::new(TEST_REQ_ID, TEST_SENDER_ID), + TEST_SWITCH_ID, + ); + assert!(result.is_err()); + matches!(result.unwrap_err(), GenericSendError::QueueFull(None)); + matches!(switch_cmd_rx.try_recv(), Err(TryRecvError::Empty)); } } -- 2.43.0 From 2e5d6a5c4154289571109f36b89cf9c52b23ef70 Mon Sep 17 00:00:00 2001 From: Robin Mueller Date: Mon, 3 Jun 2024 15:22:27 +0200 Subject: [PATCH 2/2] update example changelog --- satrs-example/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/satrs-example/CHANGELOG.md b/satrs-example/CHANGELOG.md index 68e54a2..4f3d67c 100644 --- a/satrs-example/CHANGELOG.md +++ b/satrs-example/CHANGELOG.md @@ -7,3 +7,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). # [unreleased] + +# [v0.1.1] 2024-02-21 + +satrs v0.2.0-rc.0 +satrs-mib v0.1.1 + +# [v0.1.0] 2024-02-13 + +satrs v0.1.1 +satrs-mib v0.1.0 -- 2.43.0