From eea839929ce43ce0f1a2d90306b8ddbafb77dd9b Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 26 Sep 2026 23:17:24 +0200 Subject: [PATCH 01/82] feat(create old backend): --- {backend => backend_old}/.gitignore | 0 {backend => backend_old}/captures/IP_in_IP.cap | Bin {backend => backend_old}/captures/README.md | 0 .../captures/path_MTU_discovery.cap | Bin {backend => backend_old}/captures/telnet.cap | Bin {backend => backend_old}/cmd/config.toml | 0 {backend => backend_old}/cmd/dev-config.toml | 0 {backend => backend_old}/cmd/main.go | 0 {backend => backend_old}/cmd/orchestrator.go | 0 {backend => backend_old}/cmd/setup_transport.go | 0 {backend => backend_old}/cmd/setup_vehicle.go | 0 {backend => backend_old}/go.mod | 0 {backend => backend_old}/go.sum | 0 {backend => backend_old}/internal/common/chan.go | 0 {backend => backend_old}/internal/common/errors.go | 0 {backend => backend_old}/internal/common/io.go | 0 {backend => backend_old}/internal/common/map.go | 0 .../internal/common/moving_average.go | 0 {backend => backend_old}/internal/common/net.go | 0 {backend => backend_old}/internal/common/numbers.go | 0 .../internal/common/ring_buf.go | 0 {backend => backend_old}/internal/common/set.go | 0 {backend => backend_old}/internal/common/slice.go | 0 {backend => backend_old}/internal/common/types.go | 0 {backend => backend_old}/internal/config/config.go | 0 .../internal/config/config_types.go | 0 {backend => backend_old}/internal/flags/flags.go | 0 .../internal/pod_data/measurement.go | 0 .../internal/pod_data/models.go | 0 .../internal/pod_data/pod_data.go | 0 .../internal/pod_data/sortable_packets.go | 0 {backend => backend_old}/internal/server/config.go | 0 .../internal/update_factory/models/update.go | 0 .../internal/update_factory/update_factory.go | 0 {backend => backend_old}/internal/utils/units.go | 0 {backend => backend_old}/internal/vehicle/config.go | 0 .../internal/vehicle/models/order_data.go | 0 .../internal/vehicle/packet_parser/config.go | 0 {backend => backend_old}/package-lock.json | 0 {backend => backend_old}/package.json | 0 {backend => backend_old}/pkg/abstraction/board.go | 0 {backend => backend_old}/pkg/abstraction/broker.go | 0 {backend => backend_old}/pkg/abstraction/logger.go | 0 {backend => backend_old}/pkg/abstraction/packet.go | 0 .../pkg/abstraction/transport.go | 0 {backend => backend_old}/pkg/adj/adj.go | 0 {backend => backend_old}/pkg/adj/boards.go | 0 {backend => backend_old}/pkg/adj/git.go | 0 {backend => backend_old}/pkg/adj/models.go | 0 {backend => backend_old}/pkg/adj/validator.go | 0 {backend => backend_old}/pkg/boards/errors.go | 0 {backend => backend_old}/pkg/boards/events.go | 0 {backend => backend_old}/pkg/broker/broker.go | 0 {backend => backend_old}/pkg/broker/errors.go | 0 .../pkg/broker/topics/connection/connection_test.go | 0 .../pkg/broker/topics/connection/push.go | 0 .../pkg/broker/topics/connection/update.go | 0 .../pkg/broker/topics/data/data_test.go | 0 .../pkg/broker/topics/data/push.go | 0 .../pkg/broker/topics/data/update.go | 0 .../pkg/broker/topics/errors.go | 0 .../pkg/broker/topics/logger/enable.go | 0 .../pkg/broker/topics/logger/logger_test.go | 0 .../pkg/broker/topics/message/message_test.go | 0 .../pkg/broker/topics/message/update.go | 0 .../pkg/broker/topics/order/order_test.go | 0 .../pkg/broker/topics/order/send.go | 0 .../pkg/broker/topics/order/state.go | 0 .../topics/tests_functions/tests_functions.go | 0 {backend => backend_old}/pkg/broker/topics/topic.go | 0 {backend => backend_old}/pkg/http/handlers.go | 0 {backend => backend_old}/pkg/http/mux.go | 0 {backend => backend_old}/pkg/http/server.go | 0 {backend => backend_old}/pkg/logger/.gitignore | 0 {backend => backend_old}/pkg/logger/base/logger.go | 0 .../pkg/logger/base/logger_test.go | 0 {backend => backend_old}/pkg/logger/data/.gitignore | 0 {backend => backend_old}/pkg/logger/data/logger.go | 0 .../pkg/logger/data/logger_test.go | 0 {backend => backend_old}/pkg/logger/errors.go | 0 {backend => backend_old}/pkg/logger/file/csv.go | 0 .../pkg/logger/file/csv_test.go | 0 {backend => backend_old}/pkg/logger/logger.go | 0 {backend => backend_old}/pkg/logger/logger_test.go | 0 {backend => backend_old}/pkg/logger/order/logger.go | 0 .../pkg/logger/order/logger_test.go | 0 .../pkg/logger/protection/logger.go | 0 {backend => backend_old}/pkg/logger/state/logger.go | 0 {backend => backend_old}/pkg/logger/time.go | 0 {backend => backend_old}/pkg/logger/time_test.go | 0 {backend => backend_old}/pkg/logger/trace/trace.go | 0 .../pkg/logger/validator/validator.go | 0 .../pkg/transport/constructor.go | 0 {backend => backend_old}/pkg/transport/errors.go | 0 {backend => backend_old}/pkg/transport/keepalive.go | 0 {backend => backend_old}/pkg/transport/messages.go | 0 .../pkg/transport/network/payload.go | 0 .../pkg/transport/network/sniffer/errors.go | 0 .../pkg/transport/network/tcp/client.go | 0 .../pkg/transport/network/tcp/config.go | 0 .../pkg/transport/network/tcp/conn.go | 0 .../pkg/transport/network/tcp/server.go | 0 .../pkg/transport/network/tcp/tcp_test.go | 0 .../pkg/transport/network/udp/server.go | 0 .../pkg/transport/notifications.go | 0 .../pkg/transport/packet/data/codec.go | 0 .../pkg/transport/packet/data/converter.go | 0 .../pkg/transport/packet/data/decoder.go | 0 .../pkg/transport/packet/data/descriptors.go | 0 .../pkg/transport/packet/data/encoder.go | 0 .../pkg/transport/packet/data/errors.go | 0 .../pkg/transport/packet/data/packet.go | 0 .../pkg/transport/packet/data/value.go | 0 .../pkg/transport/packet/order/codec.go | 0 .../pkg/transport/packet/order/decoder.go | 0 .../pkg/transport/packet/order/errors.go | 0 .../pkg/transport/packet/order/packet.go | 0 .../pkg/transport/packet/protection/decoder.go | 0 .../pkg/transport/packet/protection/errors.go | 0 .../pkg/transport/packet/protection/kind.go | 0 .../pkg/transport/packet/protection/packet.go | 0 .../pkg/transport/packet/state/decoder.go | 0 .../pkg/transport/packet/state/packet.go | 0 .../pkg/transport/presentation/decoder.go | 0 .../pkg/transport/presentation/decoder_test.go | 0 .../pkg/transport/presentation/encoder.go | 0 .../pkg/transport/presentation/encoder_test.go | 0 .../pkg/transport/presentation/errors.go | 0 .../pkg/transport/session/buffer.go | 0 {backend => backend_old}/pkg/transport/transport.go | 0 {backend => backend_old}/pkg/vehicle/constructor.go | 0 {backend => backend_old}/pkg/vehicle/errors.go | 0 .../pkg/vehicle/notification.go | 0 {backend => backend_old}/pkg/vehicle/vehicle.go | 0 {backend => backend_old}/pkg/websocket/client.go | 0 {backend => backend_old}/pkg/websocket/errors.go | 0 {backend => backend_old}/pkg/websocket/pool.go | 0 {backend => backend_old}/pkg/websocket/upgrader.go | 0 {backend => backend_old}/scripts/analyze_trace.py | 0 139 files changed, 0 insertions(+), 0 deletions(-) rename {backend => backend_old}/.gitignore (100%) rename {backend => backend_old}/captures/IP_in_IP.cap (100%) rename {backend => backend_old}/captures/README.md (100%) rename {backend => backend_old}/captures/path_MTU_discovery.cap (100%) rename {backend => backend_old}/captures/telnet.cap (100%) rename {backend => backend_old}/cmd/config.toml (100%) rename {backend => backend_old}/cmd/dev-config.toml (100%) rename {backend => backend_old}/cmd/main.go (100%) rename {backend => backend_old}/cmd/orchestrator.go (100%) rename {backend => backend_old}/cmd/setup_transport.go (100%) rename {backend => backend_old}/cmd/setup_vehicle.go (100%) rename {backend => backend_old}/go.mod (100%) rename {backend => backend_old}/go.sum (100%) rename {backend => backend_old}/internal/common/chan.go (100%) rename {backend => backend_old}/internal/common/errors.go (100%) rename {backend => backend_old}/internal/common/io.go (100%) rename {backend => backend_old}/internal/common/map.go (100%) rename {backend => backend_old}/internal/common/moving_average.go (100%) rename {backend => backend_old}/internal/common/net.go (100%) rename {backend => backend_old}/internal/common/numbers.go (100%) rename {backend => backend_old}/internal/common/ring_buf.go (100%) rename {backend => backend_old}/internal/common/set.go (100%) rename {backend => backend_old}/internal/common/slice.go (100%) rename {backend => backend_old}/internal/common/types.go (100%) rename {backend => backend_old}/internal/config/config.go (100%) rename {backend => backend_old}/internal/config/config_types.go (100%) rename {backend => backend_old}/internal/flags/flags.go (100%) rename {backend => backend_old}/internal/pod_data/measurement.go (100%) rename {backend => backend_old}/internal/pod_data/models.go (100%) rename {backend => backend_old}/internal/pod_data/pod_data.go (100%) rename {backend => backend_old}/internal/pod_data/sortable_packets.go (100%) rename {backend => backend_old}/internal/server/config.go (100%) rename {backend => backend_old}/internal/update_factory/models/update.go (100%) rename {backend => backend_old}/internal/update_factory/update_factory.go (100%) rename {backend => backend_old}/internal/utils/units.go (100%) rename {backend => backend_old}/internal/vehicle/config.go (100%) rename {backend => backend_old}/internal/vehicle/models/order_data.go (100%) rename {backend => backend_old}/internal/vehicle/packet_parser/config.go (100%) rename {backend => backend_old}/package-lock.json (100%) rename {backend => backend_old}/package.json (100%) rename {backend => backend_old}/pkg/abstraction/board.go (100%) rename {backend => backend_old}/pkg/abstraction/broker.go (100%) rename {backend => backend_old}/pkg/abstraction/logger.go (100%) rename {backend => backend_old}/pkg/abstraction/packet.go (100%) rename {backend => backend_old}/pkg/abstraction/transport.go (100%) rename {backend => backend_old}/pkg/adj/adj.go (100%) rename {backend => backend_old}/pkg/adj/boards.go (100%) rename {backend => backend_old}/pkg/adj/git.go (100%) rename {backend => backend_old}/pkg/adj/models.go (100%) rename {backend => backend_old}/pkg/adj/validator.go (100%) rename {backend => backend_old}/pkg/boards/errors.go (100%) rename {backend => backend_old}/pkg/boards/events.go (100%) rename {backend => backend_old}/pkg/broker/broker.go (100%) rename {backend => backend_old}/pkg/broker/errors.go (100%) rename {backend => backend_old}/pkg/broker/topics/connection/connection_test.go (100%) rename {backend => backend_old}/pkg/broker/topics/connection/push.go (100%) rename {backend => backend_old}/pkg/broker/topics/connection/update.go (100%) rename {backend => backend_old}/pkg/broker/topics/data/data_test.go (100%) rename {backend => backend_old}/pkg/broker/topics/data/push.go (100%) rename {backend => backend_old}/pkg/broker/topics/data/update.go (100%) rename {backend => backend_old}/pkg/broker/topics/errors.go (100%) rename {backend => backend_old}/pkg/broker/topics/logger/enable.go (100%) rename {backend => backend_old}/pkg/broker/topics/logger/logger_test.go (100%) rename {backend => backend_old}/pkg/broker/topics/message/message_test.go (100%) rename {backend => backend_old}/pkg/broker/topics/message/update.go (100%) rename {backend => backend_old}/pkg/broker/topics/order/order_test.go (100%) rename {backend => backend_old}/pkg/broker/topics/order/send.go (100%) rename {backend => backend_old}/pkg/broker/topics/order/state.go (100%) rename {backend => backend_old}/pkg/broker/topics/tests_functions/tests_functions.go (100%) rename {backend => backend_old}/pkg/broker/topics/topic.go (100%) rename {backend => backend_old}/pkg/http/handlers.go (100%) rename {backend => backend_old}/pkg/http/mux.go (100%) rename {backend => backend_old}/pkg/http/server.go (100%) rename {backend => backend_old}/pkg/logger/.gitignore (100%) rename {backend => backend_old}/pkg/logger/base/logger.go (100%) rename {backend => backend_old}/pkg/logger/base/logger_test.go (100%) rename {backend => backend_old}/pkg/logger/data/.gitignore (100%) rename {backend => backend_old}/pkg/logger/data/logger.go (100%) rename {backend => backend_old}/pkg/logger/data/logger_test.go (100%) rename {backend => backend_old}/pkg/logger/errors.go (100%) rename {backend => backend_old}/pkg/logger/file/csv.go (100%) rename {backend => backend_old}/pkg/logger/file/csv_test.go (100%) rename {backend => backend_old}/pkg/logger/logger.go (100%) rename {backend => backend_old}/pkg/logger/logger_test.go (100%) rename {backend => backend_old}/pkg/logger/order/logger.go (100%) rename {backend => backend_old}/pkg/logger/order/logger_test.go (100%) rename {backend => backend_old}/pkg/logger/protection/logger.go (100%) rename {backend => backend_old}/pkg/logger/state/logger.go (100%) rename {backend => backend_old}/pkg/logger/time.go (100%) rename {backend => backend_old}/pkg/logger/time_test.go (100%) rename {backend => backend_old}/pkg/logger/trace/trace.go (100%) rename {backend => backend_old}/pkg/logger/validator/validator.go (100%) rename {backend => backend_old}/pkg/transport/constructor.go (100%) rename {backend => backend_old}/pkg/transport/errors.go (100%) rename {backend => backend_old}/pkg/transport/keepalive.go (100%) rename {backend => backend_old}/pkg/transport/messages.go (100%) rename {backend => backend_old}/pkg/transport/network/payload.go (100%) rename {backend => backend_old}/pkg/transport/network/sniffer/errors.go (100%) rename {backend => backend_old}/pkg/transport/network/tcp/client.go (100%) rename {backend => backend_old}/pkg/transport/network/tcp/config.go (100%) rename {backend => backend_old}/pkg/transport/network/tcp/conn.go (100%) rename {backend => backend_old}/pkg/transport/network/tcp/server.go (100%) rename {backend => backend_old}/pkg/transport/network/tcp/tcp_test.go (100%) rename {backend => backend_old}/pkg/transport/network/udp/server.go (100%) rename {backend => backend_old}/pkg/transport/notifications.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/codec.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/converter.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/decoder.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/descriptors.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/encoder.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/errors.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/packet.go (100%) rename {backend => backend_old}/pkg/transport/packet/data/value.go (100%) rename {backend => backend_old}/pkg/transport/packet/order/codec.go (100%) rename {backend => backend_old}/pkg/transport/packet/order/decoder.go (100%) rename {backend => backend_old}/pkg/transport/packet/order/errors.go (100%) rename {backend => backend_old}/pkg/transport/packet/order/packet.go (100%) rename {backend => backend_old}/pkg/transport/packet/protection/decoder.go (100%) rename {backend => backend_old}/pkg/transport/packet/protection/errors.go (100%) rename {backend => backend_old}/pkg/transport/packet/protection/kind.go (100%) rename {backend => backend_old}/pkg/transport/packet/protection/packet.go (100%) rename {backend => backend_old}/pkg/transport/packet/state/decoder.go (100%) rename {backend => backend_old}/pkg/transport/packet/state/packet.go (100%) rename {backend => backend_old}/pkg/transport/presentation/decoder.go (100%) rename {backend => backend_old}/pkg/transport/presentation/decoder_test.go (100%) rename {backend => backend_old}/pkg/transport/presentation/encoder.go (100%) rename {backend => backend_old}/pkg/transport/presentation/encoder_test.go (100%) rename {backend => backend_old}/pkg/transport/presentation/errors.go (100%) rename {backend => backend_old}/pkg/transport/session/buffer.go (100%) rename {backend => backend_old}/pkg/transport/transport.go (100%) rename {backend => backend_old}/pkg/vehicle/constructor.go (100%) rename {backend => backend_old}/pkg/vehicle/errors.go (100%) rename {backend => backend_old}/pkg/vehicle/notification.go (100%) rename {backend => backend_old}/pkg/vehicle/vehicle.go (100%) rename {backend => backend_old}/pkg/websocket/client.go (100%) rename {backend => backend_old}/pkg/websocket/errors.go (100%) rename {backend => backend_old}/pkg/websocket/pool.go (100%) rename {backend => backend_old}/pkg/websocket/upgrader.go (100%) rename {backend => backend_old}/scripts/analyze_trace.py (100%) diff --git a/backend/.gitignore b/backend_old/.gitignore similarity index 100% rename from backend/.gitignore rename to backend_old/.gitignore diff --git a/backend/captures/IP_in_IP.cap b/backend_old/captures/IP_in_IP.cap similarity index 100% rename from backend/captures/IP_in_IP.cap rename to backend_old/captures/IP_in_IP.cap diff --git a/backend/captures/README.md b/backend_old/captures/README.md similarity index 100% rename from backend/captures/README.md rename to backend_old/captures/README.md diff --git a/backend/captures/path_MTU_discovery.cap b/backend_old/captures/path_MTU_discovery.cap similarity index 100% rename from backend/captures/path_MTU_discovery.cap rename to backend_old/captures/path_MTU_discovery.cap diff --git a/backend/captures/telnet.cap b/backend_old/captures/telnet.cap similarity index 100% rename from backend/captures/telnet.cap rename to backend_old/captures/telnet.cap diff --git a/backend/cmd/config.toml b/backend_old/cmd/config.toml similarity index 100% rename from backend/cmd/config.toml rename to backend_old/cmd/config.toml diff --git a/backend/cmd/dev-config.toml b/backend_old/cmd/dev-config.toml similarity index 100% rename from backend/cmd/dev-config.toml rename to backend_old/cmd/dev-config.toml diff --git a/backend/cmd/main.go b/backend_old/cmd/main.go similarity index 100% rename from backend/cmd/main.go rename to backend_old/cmd/main.go diff --git a/backend/cmd/orchestrator.go b/backend_old/cmd/orchestrator.go similarity index 100% rename from backend/cmd/orchestrator.go rename to backend_old/cmd/orchestrator.go diff --git a/backend/cmd/setup_transport.go b/backend_old/cmd/setup_transport.go similarity index 100% rename from backend/cmd/setup_transport.go rename to backend_old/cmd/setup_transport.go diff --git a/backend/cmd/setup_vehicle.go b/backend_old/cmd/setup_vehicle.go similarity index 100% rename from backend/cmd/setup_vehicle.go rename to backend_old/cmd/setup_vehicle.go diff --git a/backend/go.mod b/backend_old/go.mod similarity index 100% rename from backend/go.mod rename to backend_old/go.mod diff --git a/backend/go.sum b/backend_old/go.sum similarity index 100% rename from backend/go.sum rename to backend_old/go.sum diff --git a/backend/internal/common/chan.go b/backend_old/internal/common/chan.go similarity index 100% rename from backend/internal/common/chan.go rename to backend_old/internal/common/chan.go diff --git a/backend/internal/common/errors.go b/backend_old/internal/common/errors.go similarity index 100% rename from backend/internal/common/errors.go rename to backend_old/internal/common/errors.go diff --git a/backend/internal/common/io.go b/backend_old/internal/common/io.go similarity index 100% rename from backend/internal/common/io.go rename to backend_old/internal/common/io.go diff --git a/backend/internal/common/map.go b/backend_old/internal/common/map.go similarity index 100% rename from backend/internal/common/map.go rename to backend_old/internal/common/map.go diff --git a/backend/internal/common/moving_average.go b/backend_old/internal/common/moving_average.go similarity index 100% rename from backend/internal/common/moving_average.go rename to backend_old/internal/common/moving_average.go diff --git a/backend/internal/common/net.go b/backend_old/internal/common/net.go similarity index 100% rename from backend/internal/common/net.go rename to backend_old/internal/common/net.go diff --git a/backend/internal/common/numbers.go b/backend_old/internal/common/numbers.go similarity index 100% rename from backend/internal/common/numbers.go rename to backend_old/internal/common/numbers.go diff --git a/backend/internal/common/ring_buf.go b/backend_old/internal/common/ring_buf.go similarity index 100% rename from backend/internal/common/ring_buf.go rename to backend_old/internal/common/ring_buf.go diff --git a/backend/internal/common/set.go b/backend_old/internal/common/set.go similarity index 100% rename from backend/internal/common/set.go rename to backend_old/internal/common/set.go diff --git a/backend/internal/common/slice.go b/backend_old/internal/common/slice.go similarity index 100% rename from backend/internal/common/slice.go rename to backend_old/internal/common/slice.go diff --git a/backend/internal/common/types.go b/backend_old/internal/common/types.go similarity index 100% rename from backend/internal/common/types.go rename to backend_old/internal/common/types.go diff --git a/backend/internal/config/config.go b/backend_old/internal/config/config.go similarity index 100% rename from backend/internal/config/config.go rename to backend_old/internal/config/config.go diff --git a/backend/internal/config/config_types.go b/backend_old/internal/config/config_types.go similarity index 100% rename from backend/internal/config/config_types.go rename to backend_old/internal/config/config_types.go diff --git a/backend/internal/flags/flags.go b/backend_old/internal/flags/flags.go similarity index 100% rename from backend/internal/flags/flags.go rename to backend_old/internal/flags/flags.go diff --git a/backend/internal/pod_data/measurement.go b/backend_old/internal/pod_data/measurement.go similarity index 100% rename from backend/internal/pod_data/measurement.go rename to backend_old/internal/pod_data/measurement.go diff --git a/backend/internal/pod_data/models.go b/backend_old/internal/pod_data/models.go similarity index 100% rename from backend/internal/pod_data/models.go rename to backend_old/internal/pod_data/models.go diff --git a/backend/internal/pod_data/pod_data.go b/backend_old/internal/pod_data/pod_data.go similarity index 100% rename from backend/internal/pod_data/pod_data.go rename to backend_old/internal/pod_data/pod_data.go diff --git a/backend/internal/pod_data/sortable_packets.go b/backend_old/internal/pod_data/sortable_packets.go similarity index 100% rename from backend/internal/pod_data/sortable_packets.go rename to backend_old/internal/pod_data/sortable_packets.go diff --git a/backend/internal/server/config.go b/backend_old/internal/server/config.go similarity index 100% rename from backend/internal/server/config.go rename to backend_old/internal/server/config.go diff --git a/backend/internal/update_factory/models/update.go b/backend_old/internal/update_factory/models/update.go similarity index 100% rename from backend/internal/update_factory/models/update.go rename to backend_old/internal/update_factory/models/update.go diff --git a/backend/internal/update_factory/update_factory.go b/backend_old/internal/update_factory/update_factory.go similarity index 100% rename from backend/internal/update_factory/update_factory.go rename to backend_old/internal/update_factory/update_factory.go diff --git a/backend/internal/utils/units.go b/backend_old/internal/utils/units.go similarity index 100% rename from backend/internal/utils/units.go rename to backend_old/internal/utils/units.go diff --git a/backend/internal/vehicle/config.go b/backend_old/internal/vehicle/config.go similarity index 100% rename from backend/internal/vehicle/config.go rename to backend_old/internal/vehicle/config.go diff --git a/backend/internal/vehicle/models/order_data.go b/backend_old/internal/vehicle/models/order_data.go similarity index 100% rename from backend/internal/vehicle/models/order_data.go rename to backend_old/internal/vehicle/models/order_data.go diff --git a/backend/internal/vehicle/packet_parser/config.go b/backend_old/internal/vehicle/packet_parser/config.go similarity index 100% rename from backend/internal/vehicle/packet_parser/config.go rename to backend_old/internal/vehicle/packet_parser/config.go diff --git a/backend/package-lock.json b/backend_old/package-lock.json similarity index 100% rename from backend/package-lock.json rename to backend_old/package-lock.json diff --git a/backend/package.json b/backend_old/package.json similarity index 100% rename from backend/package.json rename to backend_old/package.json diff --git a/backend/pkg/abstraction/board.go b/backend_old/pkg/abstraction/board.go similarity index 100% rename from backend/pkg/abstraction/board.go rename to backend_old/pkg/abstraction/board.go diff --git a/backend/pkg/abstraction/broker.go b/backend_old/pkg/abstraction/broker.go similarity index 100% rename from backend/pkg/abstraction/broker.go rename to backend_old/pkg/abstraction/broker.go diff --git a/backend/pkg/abstraction/logger.go b/backend_old/pkg/abstraction/logger.go similarity index 100% rename from backend/pkg/abstraction/logger.go rename to backend_old/pkg/abstraction/logger.go diff --git a/backend/pkg/abstraction/packet.go b/backend_old/pkg/abstraction/packet.go similarity index 100% rename from backend/pkg/abstraction/packet.go rename to backend_old/pkg/abstraction/packet.go diff --git a/backend/pkg/abstraction/transport.go b/backend_old/pkg/abstraction/transport.go similarity index 100% rename from backend/pkg/abstraction/transport.go rename to backend_old/pkg/abstraction/transport.go diff --git a/backend/pkg/adj/adj.go b/backend_old/pkg/adj/adj.go similarity index 100% rename from backend/pkg/adj/adj.go rename to backend_old/pkg/adj/adj.go diff --git a/backend/pkg/adj/boards.go b/backend_old/pkg/adj/boards.go similarity index 100% rename from backend/pkg/adj/boards.go rename to backend_old/pkg/adj/boards.go diff --git a/backend/pkg/adj/git.go b/backend_old/pkg/adj/git.go similarity index 100% rename from backend/pkg/adj/git.go rename to backend_old/pkg/adj/git.go diff --git a/backend/pkg/adj/models.go b/backend_old/pkg/adj/models.go similarity index 100% rename from backend/pkg/adj/models.go rename to backend_old/pkg/adj/models.go diff --git a/backend/pkg/adj/validator.go b/backend_old/pkg/adj/validator.go similarity index 100% rename from backend/pkg/adj/validator.go rename to backend_old/pkg/adj/validator.go diff --git a/backend/pkg/boards/errors.go b/backend_old/pkg/boards/errors.go similarity index 100% rename from backend/pkg/boards/errors.go rename to backend_old/pkg/boards/errors.go diff --git a/backend/pkg/boards/events.go b/backend_old/pkg/boards/events.go similarity index 100% rename from backend/pkg/boards/events.go rename to backend_old/pkg/boards/events.go diff --git a/backend/pkg/broker/broker.go b/backend_old/pkg/broker/broker.go similarity index 100% rename from backend/pkg/broker/broker.go rename to backend_old/pkg/broker/broker.go diff --git a/backend/pkg/broker/errors.go b/backend_old/pkg/broker/errors.go similarity index 100% rename from backend/pkg/broker/errors.go rename to backend_old/pkg/broker/errors.go diff --git a/backend/pkg/broker/topics/connection/connection_test.go b/backend_old/pkg/broker/topics/connection/connection_test.go similarity index 100% rename from backend/pkg/broker/topics/connection/connection_test.go rename to backend_old/pkg/broker/topics/connection/connection_test.go diff --git a/backend/pkg/broker/topics/connection/push.go b/backend_old/pkg/broker/topics/connection/push.go similarity index 100% rename from backend/pkg/broker/topics/connection/push.go rename to backend_old/pkg/broker/topics/connection/push.go diff --git a/backend/pkg/broker/topics/connection/update.go b/backend_old/pkg/broker/topics/connection/update.go similarity index 100% rename from backend/pkg/broker/topics/connection/update.go rename to backend_old/pkg/broker/topics/connection/update.go diff --git a/backend/pkg/broker/topics/data/data_test.go b/backend_old/pkg/broker/topics/data/data_test.go similarity index 100% rename from backend/pkg/broker/topics/data/data_test.go rename to backend_old/pkg/broker/topics/data/data_test.go diff --git a/backend/pkg/broker/topics/data/push.go b/backend_old/pkg/broker/topics/data/push.go similarity index 100% rename from backend/pkg/broker/topics/data/push.go rename to backend_old/pkg/broker/topics/data/push.go diff --git a/backend/pkg/broker/topics/data/update.go b/backend_old/pkg/broker/topics/data/update.go similarity index 100% rename from backend/pkg/broker/topics/data/update.go rename to backend_old/pkg/broker/topics/data/update.go diff --git a/backend/pkg/broker/topics/errors.go b/backend_old/pkg/broker/topics/errors.go similarity index 100% rename from backend/pkg/broker/topics/errors.go rename to backend_old/pkg/broker/topics/errors.go diff --git a/backend/pkg/broker/topics/logger/enable.go b/backend_old/pkg/broker/topics/logger/enable.go similarity index 100% rename from backend/pkg/broker/topics/logger/enable.go rename to backend_old/pkg/broker/topics/logger/enable.go diff --git a/backend/pkg/broker/topics/logger/logger_test.go b/backend_old/pkg/broker/topics/logger/logger_test.go similarity index 100% rename from backend/pkg/broker/topics/logger/logger_test.go rename to backend_old/pkg/broker/topics/logger/logger_test.go diff --git a/backend/pkg/broker/topics/message/message_test.go b/backend_old/pkg/broker/topics/message/message_test.go similarity index 100% rename from backend/pkg/broker/topics/message/message_test.go rename to backend_old/pkg/broker/topics/message/message_test.go diff --git a/backend/pkg/broker/topics/message/update.go b/backend_old/pkg/broker/topics/message/update.go similarity index 100% rename from backend/pkg/broker/topics/message/update.go rename to backend_old/pkg/broker/topics/message/update.go diff --git a/backend/pkg/broker/topics/order/order_test.go b/backend_old/pkg/broker/topics/order/order_test.go similarity index 100% rename from backend/pkg/broker/topics/order/order_test.go rename to backend_old/pkg/broker/topics/order/order_test.go diff --git a/backend/pkg/broker/topics/order/send.go b/backend_old/pkg/broker/topics/order/send.go similarity index 100% rename from backend/pkg/broker/topics/order/send.go rename to backend_old/pkg/broker/topics/order/send.go diff --git a/backend/pkg/broker/topics/order/state.go b/backend_old/pkg/broker/topics/order/state.go similarity index 100% rename from backend/pkg/broker/topics/order/state.go rename to backend_old/pkg/broker/topics/order/state.go diff --git a/backend/pkg/broker/topics/tests_functions/tests_functions.go b/backend_old/pkg/broker/topics/tests_functions/tests_functions.go similarity index 100% rename from backend/pkg/broker/topics/tests_functions/tests_functions.go rename to backend_old/pkg/broker/topics/tests_functions/tests_functions.go diff --git a/backend/pkg/broker/topics/topic.go b/backend_old/pkg/broker/topics/topic.go similarity index 100% rename from backend/pkg/broker/topics/topic.go rename to backend_old/pkg/broker/topics/topic.go diff --git a/backend/pkg/http/handlers.go b/backend_old/pkg/http/handlers.go similarity index 100% rename from backend/pkg/http/handlers.go rename to backend_old/pkg/http/handlers.go diff --git a/backend/pkg/http/mux.go b/backend_old/pkg/http/mux.go similarity index 100% rename from backend/pkg/http/mux.go rename to backend_old/pkg/http/mux.go diff --git a/backend/pkg/http/server.go b/backend_old/pkg/http/server.go similarity index 100% rename from backend/pkg/http/server.go rename to backend_old/pkg/http/server.go diff --git a/backend/pkg/logger/.gitignore b/backend_old/pkg/logger/.gitignore similarity index 100% rename from backend/pkg/logger/.gitignore rename to backend_old/pkg/logger/.gitignore diff --git a/backend/pkg/logger/base/logger.go b/backend_old/pkg/logger/base/logger.go similarity index 100% rename from backend/pkg/logger/base/logger.go rename to backend_old/pkg/logger/base/logger.go diff --git a/backend/pkg/logger/base/logger_test.go b/backend_old/pkg/logger/base/logger_test.go similarity index 100% rename from backend/pkg/logger/base/logger_test.go rename to backend_old/pkg/logger/base/logger_test.go diff --git a/backend/pkg/logger/data/.gitignore b/backend_old/pkg/logger/data/.gitignore similarity index 100% rename from backend/pkg/logger/data/.gitignore rename to backend_old/pkg/logger/data/.gitignore diff --git a/backend/pkg/logger/data/logger.go b/backend_old/pkg/logger/data/logger.go similarity index 100% rename from backend/pkg/logger/data/logger.go rename to backend_old/pkg/logger/data/logger.go diff --git a/backend/pkg/logger/data/logger_test.go b/backend_old/pkg/logger/data/logger_test.go similarity index 100% rename from backend/pkg/logger/data/logger_test.go rename to backend_old/pkg/logger/data/logger_test.go diff --git a/backend/pkg/logger/errors.go b/backend_old/pkg/logger/errors.go similarity index 100% rename from backend/pkg/logger/errors.go rename to backend_old/pkg/logger/errors.go diff --git a/backend/pkg/logger/file/csv.go b/backend_old/pkg/logger/file/csv.go similarity index 100% rename from backend/pkg/logger/file/csv.go rename to backend_old/pkg/logger/file/csv.go diff --git a/backend/pkg/logger/file/csv_test.go b/backend_old/pkg/logger/file/csv_test.go similarity index 100% rename from backend/pkg/logger/file/csv_test.go rename to backend_old/pkg/logger/file/csv_test.go diff --git a/backend/pkg/logger/logger.go b/backend_old/pkg/logger/logger.go similarity index 100% rename from backend/pkg/logger/logger.go rename to backend_old/pkg/logger/logger.go diff --git a/backend/pkg/logger/logger_test.go b/backend_old/pkg/logger/logger_test.go similarity index 100% rename from backend/pkg/logger/logger_test.go rename to backend_old/pkg/logger/logger_test.go diff --git a/backend/pkg/logger/order/logger.go b/backend_old/pkg/logger/order/logger.go similarity index 100% rename from backend/pkg/logger/order/logger.go rename to backend_old/pkg/logger/order/logger.go diff --git a/backend/pkg/logger/order/logger_test.go b/backend_old/pkg/logger/order/logger_test.go similarity index 100% rename from backend/pkg/logger/order/logger_test.go rename to backend_old/pkg/logger/order/logger_test.go diff --git a/backend/pkg/logger/protection/logger.go b/backend_old/pkg/logger/protection/logger.go similarity index 100% rename from backend/pkg/logger/protection/logger.go rename to backend_old/pkg/logger/protection/logger.go diff --git a/backend/pkg/logger/state/logger.go b/backend_old/pkg/logger/state/logger.go similarity index 100% rename from backend/pkg/logger/state/logger.go rename to backend_old/pkg/logger/state/logger.go diff --git a/backend/pkg/logger/time.go b/backend_old/pkg/logger/time.go similarity index 100% rename from backend/pkg/logger/time.go rename to backend_old/pkg/logger/time.go diff --git a/backend/pkg/logger/time_test.go b/backend_old/pkg/logger/time_test.go similarity index 100% rename from backend/pkg/logger/time_test.go rename to backend_old/pkg/logger/time_test.go diff --git a/backend/pkg/logger/trace/trace.go b/backend_old/pkg/logger/trace/trace.go similarity index 100% rename from backend/pkg/logger/trace/trace.go rename to backend_old/pkg/logger/trace/trace.go diff --git a/backend/pkg/logger/validator/validator.go b/backend_old/pkg/logger/validator/validator.go similarity index 100% rename from backend/pkg/logger/validator/validator.go rename to backend_old/pkg/logger/validator/validator.go diff --git a/backend/pkg/transport/constructor.go b/backend_old/pkg/transport/constructor.go similarity index 100% rename from backend/pkg/transport/constructor.go rename to backend_old/pkg/transport/constructor.go diff --git a/backend/pkg/transport/errors.go b/backend_old/pkg/transport/errors.go similarity index 100% rename from backend/pkg/transport/errors.go rename to backend_old/pkg/transport/errors.go diff --git a/backend/pkg/transport/keepalive.go b/backend_old/pkg/transport/keepalive.go similarity index 100% rename from backend/pkg/transport/keepalive.go rename to backend_old/pkg/transport/keepalive.go diff --git a/backend/pkg/transport/messages.go b/backend_old/pkg/transport/messages.go similarity index 100% rename from backend/pkg/transport/messages.go rename to backend_old/pkg/transport/messages.go diff --git a/backend/pkg/transport/network/payload.go b/backend_old/pkg/transport/network/payload.go similarity index 100% rename from backend/pkg/transport/network/payload.go rename to backend_old/pkg/transport/network/payload.go diff --git a/backend/pkg/transport/network/sniffer/errors.go b/backend_old/pkg/transport/network/sniffer/errors.go similarity index 100% rename from backend/pkg/transport/network/sniffer/errors.go rename to backend_old/pkg/transport/network/sniffer/errors.go diff --git a/backend/pkg/transport/network/tcp/client.go b/backend_old/pkg/transport/network/tcp/client.go similarity index 100% rename from backend/pkg/transport/network/tcp/client.go rename to backend_old/pkg/transport/network/tcp/client.go diff --git a/backend/pkg/transport/network/tcp/config.go b/backend_old/pkg/transport/network/tcp/config.go similarity index 100% rename from backend/pkg/transport/network/tcp/config.go rename to backend_old/pkg/transport/network/tcp/config.go diff --git a/backend/pkg/transport/network/tcp/conn.go b/backend_old/pkg/transport/network/tcp/conn.go similarity index 100% rename from backend/pkg/transport/network/tcp/conn.go rename to backend_old/pkg/transport/network/tcp/conn.go diff --git a/backend/pkg/transport/network/tcp/server.go b/backend_old/pkg/transport/network/tcp/server.go similarity index 100% rename from backend/pkg/transport/network/tcp/server.go rename to backend_old/pkg/transport/network/tcp/server.go diff --git a/backend/pkg/transport/network/tcp/tcp_test.go b/backend_old/pkg/transport/network/tcp/tcp_test.go similarity index 100% rename from backend/pkg/transport/network/tcp/tcp_test.go rename to backend_old/pkg/transport/network/tcp/tcp_test.go diff --git a/backend/pkg/transport/network/udp/server.go b/backend_old/pkg/transport/network/udp/server.go similarity index 100% rename from backend/pkg/transport/network/udp/server.go rename to backend_old/pkg/transport/network/udp/server.go diff --git a/backend/pkg/transport/notifications.go b/backend_old/pkg/transport/notifications.go similarity index 100% rename from backend/pkg/transport/notifications.go rename to backend_old/pkg/transport/notifications.go diff --git a/backend/pkg/transport/packet/data/codec.go b/backend_old/pkg/transport/packet/data/codec.go similarity index 100% rename from backend/pkg/transport/packet/data/codec.go rename to backend_old/pkg/transport/packet/data/codec.go diff --git a/backend/pkg/transport/packet/data/converter.go b/backend_old/pkg/transport/packet/data/converter.go similarity index 100% rename from backend/pkg/transport/packet/data/converter.go rename to backend_old/pkg/transport/packet/data/converter.go diff --git a/backend/pkg/transport/packet/data/decoder.go b/backend_old/pkg/transport/packet/data/decoder.go similarity index 100% rename from backend/pkg/transport/packet/data/decoder.go rename to backend_old/pkg/transport/packet/data/decoder.go diff --git a/backend/pkg/transport/packet/data/descriptors.go b/backend_old/pkg/transport/packet/data/descriptors.go similarity index 100% rename from backend/pkg/transport/packet/data/descriptors.go rename to backend_old/pkg/transport/packet/data/descriptors.go diff --git a/backend/pkg/transport/packet/data/encoder.go b/backend_old/pkg/transport/packet/data/encoder.go similarity index 100% rename from backend/pkg/transport/packet/data/encoder.go rename to backend_old/pkg/transport/packet/data/encoder.go diff --git a/backend/pkg/transport/packet/data/errors.go b/backend_old/pkg/transport/packet/data/errors.go similarity index 100% rename from backend/pkg/transport/packet/data/errors.go rename to backend_old/pkg/transport/packet/data/errors.go diff --git a/backend/pkg/transport/packet/data/packet.go b/backend_old/pkg/transport/packet/data/packet.go similarity index 100% rename from backend/pkg/transport/packet/data/packet.go rename to backend_old/pkg/transport/packet/data/packet.go diff --git a/backend/pkg/transport/packet/data/value.go b/backend_old/pkg/transport/packet/data/value.go similarity index 100% rename from backend/pkg/transport/packet/data/value.go rename to backend_old/pkg/transport/packet/data/value.go diff --git a/backend/pkg/transport/packet/order/codec.go b/backend_old/pkg/transport/packet/order/codec.go similarity index 100% rename from backend/pkg/transport/packet/order/codec.go rename to backend_old/pkg/transport/packet/order/codec.go diff --git a/backend/pkg/transport/packet/order/decoder.go b/backend_old/pkg/transport/packet/order/decoder.go similarity index 100% rename from backend/pkg/transport/packet/order/decoder.go rename to backend_old/pkg/transport/packet/order/decoder.go diff --git a/backend/pkg/transport/packet/order/errors.go b/backend_old/pkg/transport/packet/order/errors.go similarity index 100% rename from backend/pkg/transport/packet/order/errors.go rename to backend_old/pkg/transport/packet/order/errors.go diff --git a/backend/pkg/transport/packet/order/packet.go b/backend_old/pkg/transport/packet/order/packet.go similarity index 100% rename from backend/pkg/transport/packet/order/packet.go rename to backend_old/pkg/transport/packet/order/packet.go diff --git a/backend/pkg/transport/packet/protection/decoder.go b/backend_old/pkg/transport/packet/protection/decoder.go similarity index 100% rename from backend/pkg/transport/packet/protection/decoder.go rename to backend_old/pkg/transport/packet/protection/decoder.go diff --git a/backend/pkg/transport/packet/protection/errors.go b/backend_old/pkg/transport/packet/protection/errors.go similarity index 100% rename from backend/pkg/transport/packet/protection/errors.go rename to backend_old/pkg/transport/packet/protection/errors.go diff --git a/backend/pkg/transport/packet/protection/kind.go b/backend_old/pkg/transport/packet/protection/kind.go similarity index 100% rename from backend/pkg/transport/packet/protection/kind.go rename to backend_old/pkg/transport/packet/protection/kind.go diff --git a/backend/pkg/transport/packet/protection/packet.go b/backend_old/pkg/transport/packet/protection/packet.go similarity index 100% rename from backend/pkg/transport/packet/protection/packet.go rename to backend_old/pkg/transport/packet/protection/packet.go diff --git a/backend/pkg/transport/packet/state/decoder.go b/backend_old/pkg/transport/packet/state/decoder.go similarity index 100% rename from backend/pkg/transport/packet/state/decoder.go rename to backend_old/pkg/transport/packet/state/decoder.go diff --git a/backend/pkg/transport/packet/state/packet.go b/backend_old/pkg/transport/packet/state/packet.go similarity index 100% rename from backend/pkg/transport/packet/state/packet.go rename to backend_old/pkg/transport/packet/state/packet.go diff --git a/backend/pkg/transport/presentation/decoder.go b/backend_old/pkg/transport/presentation/decoder.go similarity index 100% rename from backend/pkg/transport/presentation/decoder.go rename to backend_old/pkg/transport/presentation/decoder.go diff --git a/backend/pkg/transport/presentation/decoder_test.go b/backend_old/pkg/transport/presentation/decoder_test.go similarity index 100% rename from backend/pkg/transport/presentation/decoder_test.go rename to backend_old/pkg/transport/presentation/decoder_test.go diff --git a/backend/pkg/transport/presentation/encoder.go b/backend_old/pkg/transport/presentation/encoder.go similarity index 100% rename from backend/pkg/transport/presentation/encoder.go rename to backend_old/pkg/transport/presentation/encoder.go diff --git a/backend/pkg/transport/presentation/encoder_test.go b/backend_old/pkg/transport/presentation/encoder_test.go similarity index 100% rename from backend/pkg/transport/presentation/encoder_test.go rename to backend_old/pkg/transport/presentation/encoder_test.go diff --git a/backend/pkg/transport/presentation/errors.go b/backend_old/pkg/transport/presentation/errors.go similarity index 100% rename from backend/pkg/transport/presentation/errors.go rename to backend_old/pkg/transport/presentation/errors.go diff --git a/backend/pkg/transport/session/buffer.go b/backend_old/pkg/transport/session/buffer.go similarity index 100% rename from backend/pkg/transport/session/buffer.go rename to backend_old/pkg/transport/session/buffer.go diff --git a/backend/pkg/transport/transport.go b/backend_old/pkg/transport/transport.go similarity index 100% rename from backend/pkg/transport/transport.go rename to backend_old/pkg/transport/transport.go diff --git a/backend/pkg/vehicle/constructor.go b/backend_old/pkg/vehicle/constructor.go similarity index 100% rename from backend/pkg/vehicle/constructor.go rename to backend_old/pkg/vehicle/constructor.go diff --git a/backend/pkg/vehicle/errors.go b/backend_old/pkg/vehicle/errors.go similarity index 100% rename from backend/pkg/vehicle/errors.go rename to backend_old/pkg/vehicle/errors.go diff --git a/backend/pkg/vehicle/notification.go b/backend_old/pkg/vehicle/notification.go similarity index 100% rename from backend/pkg/vehicle/notification.go rename to backend_old/pkg/vehicle/notification.go diff --git a/backend/pkg/vehicle/vehicle.go b/backend_old/pkg/vehicle/vehicle.go similarity index 100% rename from backend/pkg/vehicle/vehicle.go rename to backend_old/pkg/vehicle/vehicle.go diff --git a/backend/pkg/websocket/client.go b/backend_old/pkg/websocket/client.go similarity index 100% rename from backend/pkg/websocket/client.go rename to backend_old/pkg/websocket/client.go diff --git a/backend/pkg/websocket/errors.go b/backend_old/pkg/websocket/errors.go similarity index 100% rename from backend/pkg/websocket/errors.go rename to backend_old/pkg/websocket/errors.go diff --git a/backend/pkg/websocket/pool.go b/backend_old/pkg/websocket/pool.go similarity index 100% rename from backend/pkg/websocket/pool.go rename to backend_old/pkg/websocket/pool.go diff --git a/backend/pkg/websocket/upgrader.go b/backend_old/pkg/websocket/upgrader.go similarity index 100% rename from backend/pkg/websocket/upgrader.go rename to backend_old/pkg/websocket/upgrader.go diff --git a/backend/scripts/analyze_trace.py b/backend_old/scripts/analyze_trace.py similarity index 100% rename from backend/scripts/analyze_trace.py rename to backend_old/scripts/analyze_trace.py From 67f90152d6a1b02ae4e81a3b987b6505d8d43787 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 29 Sep 2026 00:25:42 +0200 Subject: [PATCH 02/82] feat: basic setup --- backend/.gitignore | 7 + backend/Cargo.lock | 602 +++++++++++++++++++++++++ backend/Cargo.toml | 44 ++ backend/crates/core/Cargo.toml | 46 ++ backend/crates/core/README.md | 32 ++ backend/crates/core/src/hello_world.rs | 50 ++ backend/crates/core/src/lib.rs | 3 + backend/crates/station/Cargo.toml | 22 + backend/crates/station/README.md | 17 + backend/crates/station/src/main.rs | 13 + backend/package.json | 15 + backend/readme-demo.md | 42 ++ backend/rust-toolchain.toml | 3 + backend/rustfmt.toml | 3 + 14 files changed, 899 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/Cargo.lock create mode 100644 backend/Cargo.toml create mode 100644 backend/crates/core/Cargo.toml create mode 100644 backend/crates/core/README.md create mode 100644 backend/crates/core/src/hello_world.rs create mode 100644 backend/crates/core/src/lib.rs create mode 100644 backend/crates/station/Cargo.toml create mode 100644 backend/crates/station/README.md create mode 100644 backend/crates/station/src/main.rs create mode 100644 backend/package.json create mode 100644 backend/readme-demo.md create mode 100644 backend/rust-toolchain.toml create mode 100644 backend/rustfmt.toml diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 000000000..d51409096 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +/target +/bin + +# Backend runtime output +/logger/ +*.log +*.csv diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 000000000..94186abdf --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,602 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "software-core" +version = "0.1.0" +dependencies = [ + "bytes", + "serde", + "thiserror", + "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "station" +version = "0.1.0" +dependencies = [ + "serde", + "software-core", + "thiserror", + "tokio", + "toml", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" + +[[package]] +name = "time-macros" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 000000000..d9aeeb64e --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +resolver = "3" +members = ["crates/core", "crates/station"] +default-members = ["crates/station"] + +[workspace.package] +edition = "2024" +rust-version = "1.85" +license = "MIT" +authors = [ + "Hyperloop UPV Software Subsystem", + "Javier Ribal del Río", + "Gazi Halawani", + "Pau Minguet Micó" +] +repository = "https://github.com/Hyperloop-UPV/software" + +# Shared dependency versions. Members opt in with `dep.workspace = true`. +[workspace.dependencies] +software-core = { path = "crates/core" } + +bytes = "1" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal"] } +toml = "0.9" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +[workspace.lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/backend/crates/core/Cargo.toml b/backend/crates/core/Cargo.toml new file mode 100644 index 000000000..1b38a623e --- /dev/null +++ b/backend/crates/core/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "software-core" +version = "0.1.0" +description = "Generic Hyperloop UPV building blocks: ADJ model, packet protocol and transport" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[features] +default = [] +# TCP/UDP transport (pulls in tokio) +net = ["dep:tokio"] +# ADJ download through git (dependency added when the module is written) +git = [] +# Packet sniffing through pcap (dependency added when the module is written) +pcap = ["net"] +# CSV data logger (records, sessions, subloggers) +logger = [] +# Reusable tracing/diagnostics setup (console + non-blocking file + stderr layers) +trace = ["dep:tracing-subscriber", "dep:tracing-appender"] + +[dependencies] +bytes.workspace = true +serde.workspace = true +thiserror.workspace = true +tracing.workspace = true +tokio = { workspace = true, optional = true } +tracing-subscriber = { workspace = true, optional = true } +tracing-appender = { workspace = true, optional = true } + +# `workspace = true` can't be combined with extra lints in the same table, +# so the workspace list is repeated here with `missing_docs` added: this is +# the crate meant to be reused by other projects, so its public API must be +# documented. +[lints.rust] +unsafe_code = "forbid" +missing_debug_implementations = "warn" +missing_docs = "warn" + +[lints.clippy] +all = { level = "warn", priority = -1 } +unwrap_used = "warn" +expect_used = "warn" +panic = "warn" diff --git a/backend/crates/core/README.md b/backend/crates/core/README.md new file mode 100644 index 000000000..22ced735d --- /dev/null +++ b/backend/crates/core/README.md @@ -0,0 +1,32 @@ +# software-core + +Piezas genéricas y reutilizables de Hyperloop UPV: modelo de datos, protocolo de +paquetes, lectura del ADJ y transporte de red. No depende de nada propio del +backend (`station`), así que también sirve de base para otras herramientas +como un sniffer o un simulador. + +Ver [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) para el diseño completo. + +## Módulos + +- `model` — identificadores, paquetes, valores, medidas y unidades. Siempre disponible. +- `protocol` — traduce bytes a paquetes y paquetes a bytes. Siempre disponible. +- `adj` — lee el ADJ y construye los descriptores para `protocol`. +- `net` *(feature `net`)* — conexiones TCP y UDP. +- `logger` *(feature `logger`)* — registro de datos en CSV. +- `trace` *(feature `trace`)* — configuración de trazas de diagnóstico. + +## Features + +```toml +software-core = { path = "...", features = ["net", "adj"] } +``` + +Por defecto no se activa ninguna: quien use el crate paga solo por lo que +necesita. Ver la tabla completa en `ARCHITECTURE.md`, apartado 12. + +## Ejemplo + +```rust +// Pendiente: añadir cuando exista el primer decodificador público. +``` diff --git a/backend/crates/core/src/hello_world.rs b/backend/crates/core/src/hello_world.rs new file mode 100644 index 000000000..2c8b7da23 --- /dev/null +++ b/backend/crates/core/src/hello_world.rs @@ -0,0 +1,50 @@ +//! Demo de una API documentada con eventos y contexto de `tracing`. +//! +//! El ejecutable configura el subscriber; este módulo solo emite eventos. + +/// Construye un saludo para `name`, eliminando espacios exteriores. +/// +/// Si el nombre está vacío o solo contiene espacios, saluda a `World`. +/// Emite detalles a nivel `debug` y el saludo a nivel `info`, dentro de un +/// span que identifica al destinatario. +/// +/// # Ejemplo +/// +/// ``` +/// use software_core::hello_world::greet; +/// +/// assert_eq!(greet("Hyperloop"), "Hello, Hyperloop!"); +/// assert_eq!(greet(""), "Hello, World!"); +/// ``` +#[tracing::instrument(name = "hello_world", skip(name), fields(recipient = %name))] +pub fn greet(name: &str) -> String { + let trimmed = name.trim(); + let recipient = if trimmed.is_empty() { "World" } else { trimmed }; + + tracing::debug!(used_default = trimmed.is_empty(), "destinatario preparado"); + let greeting = format!("Hello, {recipient}!"); + tracing::info!(greeting = %greeting, "saludo generado"); + greeting +} + +#[cfg(test)] +mod tests { + use super::greet; + + #[test] + fn greets_named_recipient() { + assert_eq!(greet("Hyperloop"), "Hello, Hyperloop!"); + } + + #[test] + fn trims_whitespace_and_preserves_unicode() { + assert_eq!(greet(" Lucía 🚄 \n"), "Hello, Lucía 🚄!"); + } + + #[test] + fn defaults_to_world_for_empty_or_whitespace_names() { + for name in ["", " \t\n"] { + assert_eq!(greet(name), "Hello, World!"); + } + } +} diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs new file mode 100644 index 000000000..a85bf01dd --- /dev/null +++ b/backend/crates/core/src/lib.rs @@ -0,0 +1,3 @@ +//! Componentes reutilizables de Hyperloop UPV. + +pub mod hello_world; diff --git a/backend/crates/station/Cargo.toml b/backend/crates/station/Cargo.toml new file mode 100644 index 000000000..20c403657 --- /dev/null +++ b/backend/crates/station/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "station" +version = "0.1.0" +description = "Hyperloop UPV control station backend, built on software-core" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[dependencies] +software-core = { workspace = true, features = ["net", "git"] } + +serde.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/backend/crates/station/README.md b/backend/crates/station/README.md new file mode 100644 index 000000000..00e8661df --- /dev/null +++ b/backend/crates/station/README.md @@ -0,0 +1,17 @@ +# station + +El backend de la Control Station de Hyperloop UPV: escucha a las placas del +pod, decodifica sus paquetes, los reenvía a los frontends y guarda un +registro en disco. Construido sobre [`software-core`](../core/README.md). + +Ver [`../../ARCHITECTURE.md`](../../ARCHITECTURE.md) para el diseño completo. + +## Arrancar + +```sh +cargo run -p station -- --config ./dev-config.toml +``` + +*(Pendiente: crear `dev-config.toml` y `config.toml`, portados de +`backend_old/cmd/`.)* + diff --git a/backend/crates/station/src/main.rs b/backend/crates/station/src/main.rs new file mode 100644 index 000000000..d872008d1 --- /dev/null +++ b/backend/crates/station/src/main.rs @@ -0,0 +1,13 @@ +//! Ejecutable mínimo para demostrar la integración con `software-core`. + +use tracing_subscriber::EnvFilter; + +fn main() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + tracing::info!("iniciando demo de station"); + let name = std::env::args().nth(1).unwrap_or_else(|| "World".into()); + let _greeting = software_core::hello_world::greet(&name); + tracing::info!("demo finalizada"); +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 000000000..cd65355be --- /dev/null +++ b/backend/package.json @@ -0,0 +1,15 @@ +{ + "name": "backend", + "version": "1.0.0", + "private": true, + "author": "Hyperloop UPV", + "license": "MIT", + "scripts": { + "dev": "cargo run -p station -- --config ./crates/station/dev-config.toml", + "dev:main": "cargo run -p station --release -- --config ./crates/station/config.toml", + "build": "cargo build -p station --release", + "build:ci": "cargo build --workspace --release", + "lint": "cargo clippy --workspace --all-targets -- -D warnings", + "fmt": "cargo fmt --all" + } +} diff --git a/backend/readme-demo.md b/backend/readme-demo.md new file mode 100644 index 000000000..875c83848 --- /dev/null +++ b/backend/readme-demo.md @@ -0,0 +1,42 @@ +# Demo: core → station + tracing + +Desde `software/backend`, con Rust 1.98.0 y Cargo instalados (la primera +ejecución descarga las dependencias): + +```sh +# Ejecutar: muestra el saludo y el ciclo de vida a nivel INFO. +cargo run -p station +cargo run -p station -- "Hyperloop UPV" + +# Mostrar también DEBUG del módulo, manteniendo INFO en station. +RUST_LOG=info,software_core::hello_world=debug cargo run -p station -- "Lucía" + +# Filtrar todos los eventos de esta demo (no emite errores). +RUST_LOG=error cargo run -p station + +# Tests unitarios y ejemplo de documentación ejecutable (doctest). +cargo test --workspace + +# Generar documentación HTML de la API. +cargo doc --workspace --no-deps + +# Comprobaciones de formato y lints. +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +``` + +Abre `target/doc/software_core/hello_world/index.html` para ver la API. + +- `crates/core/src/hello_world.rs`: `greet(&str) -> String`, con rustdoc, + un doctest y tres tests unitarios. Recorta espacios; un nombre vacío usa `World`. +- `crates/core/src/lib.rs`: exporta el módulo público. +- `crates/station/src/main.rs`: inicializa el subscriber una sola vez y llama a `greet`. +- `#[tracing::instrument]` crea el span `hello_world`; los eventos incluyen + el destinatario y campos estructurados como `greeting` o `used_default`. + `INFO` muestra el saludo; `DEBUG` permite inspeccionar la elección del destinatario. +- Core solo emite eventos: sin subscriber, devuelve igualmente el saludo. + Los tests comprueban ese resultado sin configurar logging global. + +Esta demo termina tras saludar; todavía no arranca servidores ni carga TOML. +Usa los comandos Cargo de arriba: los scripts `pnpm dev` están preparados para +la futura configuración del backend, que esta demo no interpreta. diff --git a/backend/rust-toolchain.toml b/backend/rust-toolchain.toml new file mode 100644 index 000000000..aa20bc990 --- /dev/null +++ b/backend/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.98.0" +components = ["rustfmt", "clippy"] diff --git a/backend/rustfmt.toml b/backend/rustfmt.toml new file mode 100644 index 000000000..4937083a4 --- /dev/null +++ b/backend/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +max_width = 100 +use_field_init_shorthand = true From dbb1c286d64e8716f0fa3d6020c2e42f3c1d17f7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 29 Sep 2026 01:24:12 +0200 Subject: [PATCH 03/82] wip: new abstraction --- backend/crates/core/src/model/id.rs | 32 ++++ backend/crates/core/src/model/measurement.rs | 162 +++++++++++++++++++ backend/crates/core/src/model/mod.rs | 21 +++ backend/crates/core/src/model/value.rs | 35 ++++ 4 files changed, 250 insertions(+) create mode 100644 backend/crates/core/src/model/id.rs create mode 100644 backend/crates/core/src/model/measurement.rs create mode 100644 backend/crates/core/src/model/mod.rs create mode 100644 backend/crates/core/src/model/value.rs diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs new file mode 100644 index 000000000..de799e506 --- /dev/null +++ b/backend/crates/core/src/model/id.rs @@ -0,0 +1,32 @@ +//! Domain identifiers. +//! +//! Defined as their own types (wrapping a `u16`) instead of using a plain +//! `u16`, so the compiler won't let an [`AdjId`] be confused with a +//! [`BoardId`] by mistake, even though both are just numbers underneath. + +/// Unique identifier of a packet, order or protection, defined by the ADJ. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct AdjId(pub u16); + +/// Unique identifier of a board, defined by the ADJ. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BoardId(pub u16); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_packet_ids_with_the_same_number_are_equal() { + assert_eq!(AdjId(1000), AdjId(1000)); + } + + #[test] + fn packet_id_and_board_id_cannot_be_compared_to_each_other() { + // This wouldn't compile if you tried it: AdjId(1) == BoardId(1) + // That's the whole point of this file: the compiler protects us. + let packet = AdjId(1); + let board = BoardId(1); + assert_eq!(packet.0, board.0); // only the inner numbers can be compared + } +} diff --git a/backend/crates/core/src/model/measurement.rs b/backend/crates/core/src/model/measurement.rs new file mode 100644 index 000000000..5ba0fe16e --- /dev/null +++ b/backend/crates/core/src/model/measurement.rs @@ -0,0 +1,162 @@ +//! Measurement descriptions. +//! +//! A [`Measurement`] describes a single field of a packet, as defined by +//! the ADJ: whether it holds a number, a boolean, or the variant of an +//! enum, and, for numeric measurements, its wire type and its safe and +//! warning ranges. This is a *description*, not a decoded value — see +//! [`super::Value`] for the value itself, once decoded. + +use crate::model::AdjId; + +/// The wire type of a numeric measurement: how many bytes it takes on the +/// wire and whether it is signed. +/// +/// This only matters while decoding. Once a number is decoded it is always +/// stored as a [`super::Value::Number`], regardless of its original wire +/// type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NumericKind { + /// Unsigned 8-bit integer. + U8, + /// Unsigned 16-bit integer. + U16, + /// Unsigned 32-bit integer. + U32, + /// Unsigned 64-bit integer. + U64, + /// Signed 8-bit integer. + I8, + /// Signed 16-bit integer. + I16, + /// Signed 32-bit integer. + I32, + /// Signed 64-bit integer. + I64, + /// 32-bit floating point number. + F32, + /// 64-bit floating point number. + F64, +} + +/// An inclusive range with an optional lower and upper bound. +/// +/// Either bound can be missing, meaning that side of the range is +/// unconstrained. For example, `Range { min: Some(0.0), max: None }` means +/// "any value of 0.0 or above". +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Range { + /// The lower bound, if any. + pub min: Option, + /// The upper bound, if any. + pub max: Option, +} + +/// What kind of data a measurement holds, and the details specific to that +/// kind. +#[derive(Debug, Clone, PartialEq)] +pub enum MeasurementKind { + /// A numeric measurement. + Numeric { + /// How the value is encoded on the wire. + wire_type: NumericKind, + }, + /// A boolean measurement. + Boolean, + /// A measurement whose value is one of a fixed set of named variants. + Enum { + /// The variants this measurement can take, in ADJ order. + options: Vec, + }, +} + +/// The description of a single field of a packet, as defined by the ADJ. +#[derive(Debug, Clone, PartialEq)] +pub struct Measurement { + /// The measurement's ADJid, e.g. `3000`. + pub id: AdjId, + /// The alias used to identify it + pub alias: String, + /// The human-readable name shown to the user. + pub name: String, + + /// The units of the measurement to be shown at tefronend + pub displa_units: String, + + /// What kind of data this measurement holds. + pub kind: MeasurementKind, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_default_range_has_no_bounds() { + let range = Range::default(); + assert_eq!(range.min, None); + assert_eq!(range.max, None); + } + + #[test] + fn two_measurements_with_the_same_data_are_equal() { + let a = Measurement { + alias: "arming".to_string(), + name: "Arming".to_string(), + kind: MeasurementKind::Boolean, + }; + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn numeric_measurements_carry_their_wire_type_and_ranges() { + let measurement = Measurement { + alias: "voltage".to_string(), + name: "Voltage".to_string(), + kind: MeasurementKind::Numeric { + wire_type: NumericKind::F32, + safe_range: Range { + min: Some(0.0), + max: Some(48.0), + }, + warning_range: Range { + min: Some(-5.0), + max: Some(53.0), + }, + }, + }; + + let MeasurementKind::Numeric { + wire_type, + safe_range, + .. + } = measurement.kind + else { + unreachable!("measurement was just constructed as Numeric above"); + }; + + assert_eq!(wire_type, NumericKind::F32); + assert_eq!(safe_range.max, Some(48.0)); + } + + #[test] + fn enum_measurements_list_their_options_in_adj_order() { + let measurement = Measurement { + id: "state".to_string(), + name: "State".to_string(), + kind: MeasurementKind::Enum { + options: vec![ + "Idle".to_string(), + "Operational".to_string(), + "Fault".to_string(), + ], + }, + }; + + let MeasurementKind::Enum { options } = measurement.kind else { + unreachable!("measurement was just constructed as Enum above"); + }; + + assert_eq!(options, vec!["Idle", "Operational", "Fault"]); + } +} diff --git a/backend/crates/core/src/model/mod.rs b/backend/crates/core/src/model/mod.rs new file mode 100644 index 000000000..50312d878 --- /dev/null +++ b/backend/crates/core/src/model/mod.rs @@ -0,0 +1,21 @@ +//! Basic domain types: identifiers, packets, values, measurements and +//! units. No I/O and no networking here, just data and the operations +//! performed on it. +//! +//! # Upcoming modules (not written yet) +//! +//! - `value` — [`Value`] and unit conversion (pod ⇄ display). +//! - `measurement` — the description of a measurement (its type, its ranges). +//! - `packet` — [`Packet`] and its variants: data, order, state order, protection. +//! - `board` — `PodData`, `Board`: a whole board with its packets. +//! +//! Add each one here with `mod name;` + `pub use name::...;` as you write +//! them, the same way it's done below with `id`. + +mod id; +mod measurement; +mod value; + +pub use id::{AdjId, BoardId}; +pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; +pub use value::Value; diff --git a/backend/crates/core/src/model/value.rs b/backend/crates/core/src/model/value.rs new file mode 100644 index 000000000..5dcd7e62d --- /dev/null +++ b/backend/crates/core/src/model/value.rs @@ -0,0 +1,35 @@ +//! Decoded values. +//! +//! [`Value`] is how the backend represents a piece of data once it has been +//! decoded, regardless of what it looked like on the wire. Even though the +//! ADJ defines several numeric wire types (`uint8`, `float32`...), once a +//! number is decoded it is always stored as an `f64`: what matters from +//! here on is the number itself, not how many bytes it took on the wire. +//! The wire type only matters while decoding, and lives in `NumericKind` +//! instead. + +/// A single decoded value, of whichever shape the measurement defines. +#[derive(Debug, Clone, PartialEq)] +pub enum Value { + /// A numeric measurement, regardless of its original wire type. + Number(f64), + /// A boolean measurement. + Bool(bool), + /// The selected variant of an enum measurement, e.g. `"Operational"`. + Enum(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_numbers_with_the_same_value_are_equal() { + assert_eq!(Value::Number(1.5), Value::Number(1.5)); + } + + #[test] + fn different_variants_are_never_equal_even_with_similar_data() { + assert_ne!(Value::Bool(true), Value::Enum("true".to_string())); + } +} From c7715162d5e891e5276707389c73099960bda90b Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 29 Sep 2026 09:08:04 +0200 Subject: [PATCH 04/82] wip: meadurement and protections --- backend/crates/core/src/model/measurement.rs | 78 +------------------- backend/crates/core/src/model/protections.rs | 0 2 files changed, 3 insertions(+), 75 deletions(-) create mode 100644 backend/crates/core/src/model/protections.rs diff --git a/backend/crates/core/src/model/measurement.rs b/backend/crates/core/src/model/measurement.rs index 5ba0fe16e..8a8176356 100644 --- a/backend/crates/core/src/model/measurement.rs +++ b/backend/crates/core/src/model/measurement.rs @@ -80,83 +80,11 @@ pub struct Measurement { pub name: String, /// The units of the measurement to be shown at tefronend - pub displa_units: String, + pub display_units: String, /// What kind of data this measurement holds. pub kind: MeasurementKind, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_default_range_has_no_bounds() { - let range = Range::default(); - assert_eq!(range.min, None); - assert_eq!(range.max, None); - } - - #[test] - fn two_measurements_with_the_same_data_are_equal() { - let a = Measurement { - alias: "arming".to_string(), - name: "Arming".to_string(), - kind: MeasurementKind::Boolean, - }; - let b = a.clone(); - assert_eq!(a, b); - } - - #[test] - fn numeric_measurements_carry_their_wire_type_and_ranges() { - let measurement = Measurement { - alias: "voltage".to_string(), - name: "Voltage".to_string(), - kind: MeasurementKind::Numeric { - wire_type: NumericKind::F32, - safe_range: Range { - min: Some(0.0), - max: Some(48.0), - }, - warning_range: Range { - min: Some(-5.0), - max: Some(53.0), - }, - }, - }; - - let MeasurementKind::Numeric { - wire_type, - safe_range, - .. - } = measurement.kind - else { - unreachable!("measurement was just constructed as Numeric above"); - }; - - assert_eq!(wire_type, NumericKind::F32); - assert_eq!(safe_range.max, Some(48.0)); - } - - #[test] - fn enum_measurements_list_their_options_in_adj_order() { - let measurement = Measurement { - id: "state".to_string(), - name: "State".to_string(), - kind: MeasurementKind::Enum { - options: vec![ - "Idle".to_string(), - "Operational".to_string(), - "Fault".to_string(), - ], - }, - }; - - let MeasurementKind::Enum { options } = measurement.kind else { - unreachable!("measurement was just constructed as Enum above"); - }; - assert_eq!(options, vec!["Idle", "Operational", "Fault"]); - } + /// The protectionst that are checked for this messurement. + pub protections: Protections, } diff --git a/backend/crates/core/src/model/protections.rs b/backend/crates/core/src/model/protections.rs new file mode 100644 index 000000000..e69de29bb From c558a1b8dd72f70bfe6c0a3864766a912b917163 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 30 Sep 2026 17:22:56 +0200 Subject: [PATCH 05/82] feat(backend): complete core::model with protections, packets and boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ADJv3 domain types for software-core: - measurement: add numeric id + alias (per ADJv3 §2), display_units, and a protections list; drop the now-superseded safe/warning ranges - protections: ProtectionKind (Above/Below/Equal/NotEqual/Range), Severity, Protection — data-carrying variants instead of a flat value array + range flag, so mismatched bound counts can't compile - packet: Packet and its variants (Data, Order, Protection, Message), ShortTimestamp with the sub-second field, MessageLevel — protections and messages are now unrelated wire formats, so state orders and the old message-as-protection-kind are gone - board: PacketKind, PacketDef, Board, PodData - fix 9 broken rustdoc intra-doc links, restore missing tests on measurement.rs, remove dead code, run cargo fmt DataPacket/OrderPacket values are positional (Vec, matched by index against PacketDef.measurements), not tagged per-value with an id. Open questions for a follow-up: whether to drop the now-unused Range type, and the exact shape of core::adj (raw ADJ JSON -> these types). --- backend/crates/core/src/lib.rs | 4 + .../src/model/Comunicaciones_Reunion01-2.pdf | Bin 0 -> 670255 bytes backend/crates/core/src/model/board.rs | 99 ++++++++ backend/crates/core/src/model/id.rs | 4 + backend/crates/core/src/model/measurement.rs | 82 ++++++- backend/crates/core/src/model/mod.rs | 30 ++- backend/crates/core/src/model/packet.rs | 230 ++++++++++++++++++ backend/crates/core/src/model/protections.rs | 171 +++++++++++++ backend/readme-demo.md | 1 - 9 files changed, 603 insertions(+), 18 deletions(-) create mode 100644 backend/crates/core/src/model/Comunicaciones_Reunion01-2.pdf create mode 100644 backend/crates/core/src/model/board.rs create mode 100644 backend/crates/core/src/model/packet.rs diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs index a85bf01dd..287b512a1 100644 --- a/backend/crates/core/src/lib.rs +++ b/backend/crates/core/src/lib.rs @@ -1,3 +1,7 @@ //! Componentes reutilizables de Hyperloop UPV. pub mod hello_world; + +/// Basic domain types: identifiers, values, measurements, protections, +/// packets and boards. +pub mod model; diff --git a/backend/crates/core/src/model/Comunicaciones_Reunion01-2.pdf b/backend/crates/core/src/model/Comunicaciones_Reunion01-2.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a1d7f34bc65782fda6a3122b31bb61327bc85839 GIT binary patch literal 670255 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, +} + +/// A single board of the vehicle, as defined by the ADJ. +#[derive(Debug, Clone, PartialEq)] +pub struct Board { + /// The board's unique id. + pub id: BoardId, + /// The board's name, e.g. `"BCU"`. + pub name: String, + /// The packets this board can send or receive. + pub packets: Vec, +} + +/// The whole vehicle, as described by the ADJ: every board and every +/// packet each one can send or receive. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct PodData { + /// Every board of the vehicle. + pub boards: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::MeasurementKind; + + fn sample_measurement() -> Measurement { + Measurement { + id: AdjId(3000), + alias: "temperature_1".to_string(), + name: "Temperature 1".to_string(), + display_units: "ºC".to_string(), + kind: MeasurementKind::Boolean, + protections: vec![], + } + } + + #[test] + fn an_empty_pod_data_has_no_boards() { + let pod_data = PodData::default(); + assert!(pod_data.boards.is_empty()); + } + + #[test] + fn a_board_groups_its_packets_and_their_measurements() { + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + packets: vec![PacketDef { + id: AdjId(1000), + name: "bcu_data".to_string(), + kind: PacketKind::Data, + measurements: vec![sample_measurement()], + }], + }; + + assert_eq!(board.packets.len(), 1); + assert_eq!(board.packets[0].measurements.len(), 1); + assert_eq!(board.packets[0].kind, PacketKind::Data); + } +} diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index de799e506..14fc8232b 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -5,6 +5,10 @@ //! [`BoardId`] by mistake, even though both are just numbers underneath. /// Unique identifier of a packet, order or protection, defined by the ADJ. +/// Ranges +/// - `[0, 511]` | `[0x0000, 0x01FF]` for special range +/// - `[512, 8191]` | `[0x0200, 0x1FFF]` for packets, orders and measurements +/// - `[8192, 65535]` | `[0x2000, 0xFFFF]` for protections: each protection is associated with a measurement, so the low 13 bits of the compound id are the measurement id, and the high 3 bits are the protection's position for that measurement (1-7; 0 is reserved for a normal, non-protection id) #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AdjId(pub u16); diff --git a/backend/crates/core/src/model/measurement.rs b/backend/crates/core/src/model/measurement.rs index 8a8176356..198bed4ec 100644 --- a/backend/crates/core/src/model/measurement.rs +++ b/backend/crates/core/src/model/measurement.rs @@ -6,6 +6,7 @@ //! warning ranges. This is a *description*, not a decoded value — see //! [`super::Value`] for the value itself, once decoded. +use super::protections::Protection; use crate::model::AdjId; /// The wire type of a numeric measurement: how many bytes it takes on the @@ -72,19 +73,90 @@ pub enum MeasurementKind { /// The description of a single field of a packet, as defined by the ADJ. #[derive(Debug, Clone, PartialEq)] pub struct Measurement { - /// The measurement's ADJid, e.g. `3000`. + /// The measurement's numeric ADJ id, e.g. `3000`. pub id: AdjId, - /// The alias used to identify it + + /// The legacy textual identifier, used by code generation. pub alias: String, + /// The human-readable name shown to the user. pub name: String, - /// The units of the measurement to be shown at tefronend + /// The name of the unit to display at the frontend, e.g. `"ºC"`. pub display_units: String, /// What kind of data this measurement holds. pub kind: MeasurementKind, - /// The protectionst that are checked for this messurement. - pub protections: Protections, + /// The protections that are checked for this measurement, in ADJ + /// order. The ADJ limits this list to 7 entries per measurement, but + /// that limit is enforced by the ADJ validator, not by this type. + pub protections: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base(kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(3000), + alias: "temperature_1".to_string(), + name: "Temperature 1".to_string(), + display_units: "ºC".to_string(), + kind, + protections: vec![], + } + } + + #[test] + fn a_numeric_measurement_carries_its_wire_type() { + let measurement = base(MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }); + + let MeasurementKind::Numeric { wire_type } = measurement.kind else { + unreachable!("measurement was just constructed as Numeric above"); + }; + + assert_eq!(wire_type, NumericKind::F32); + } + + #[test] + fn an_enum_measurement_lists_its_options_in_adj_order() { + let measurement = base(MeasurementKind::Enum { + options: vec![ + "Idle".to_string(), + "Operational".to_string(), + "Fault".to_string(), + ], + }); + + let MeasurementKind::Enum { options } = measurement.kind else { + unreachable!("measurement was just constructed as Enum above"); + }; + + assert_eq!(options, vec!["Idle", "Operational", "Fault"]); + } + + #[test] + fn two_measurements_with_the_same_data_are_equal() { + let a = base(MeasurementKind::Boolean); + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn measurements_with_different_protections_are_not_equal() { + let a = base(MeasurementKind::Boolean); + let mut b = a.clone(); + b.protections.push(Protection { + id: AdjId(1), + kind: crate::model::ProtectionKind::Above { limit: 100.0 }, + severity: crate::model::Severity::Fault, + time: std::time::Duration::ZERO, + }); + + assert_ne!(a, b); + } } diff --git a/backend/crates/core/src/model/mod.rs b/backend/crates/core/src/model/mod.rs index 50312d878..fec13b632 100644 --- a/backend/crates/core/src/model/mod.rs +++ b/backend/crates/core/src/model/mod.rs @@ -1,21 +1,27 @@ -//! Basic domain types: identifiers, packets, values, measurements and -//! units. No I/O and no networking here, just data and the operations -//! performed on it. +//! Basic domain types: identifiers, packets, values, measurements, +//! protections and boards. No I/O and no networking here, just data and +//! the operations performed on it. //! -//! # Upcoming modules (not written yet) -//! -//! - `value` — [`Value`] and unit conversion (pod ⇄ display). -//! - `measurement` — the description of a measurement (its type, its ranges). -//! - `packet` — [`Packet`] and its variants: data, order, state order, protection. -//! - `board` — `PodData`, `Board`: a whole board with its packets. -//! -//! Add each one here with `mod name;` + `pub use name::...;` as you write -//! them, the same way it's done below with `id`. +//! - `id` — `AdjId`, `BoardId`. +//! - `value` — `Value`, the result of decoding a measurement. +//! - `measurement` — `Measurement`: what a field of a packet looks like. +//! - `protections` — `Protection`: bounds declared for a measurement. +//! - `packet` — `Packet` and its variants: data, order, +//! protection packet, message. +//! - `board` — `PodData`, `Board`, `PacketDef`: the whole vehicle. +mod board; mod id; mod measurement; +mod packet; +mod protections; mod value; +pub use board::{Board, PacketDef, PacketKind, PodData}; pub use id::{AdjId, BoardId}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; +pub use packet::{ + DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, +}; +pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs new file mode 100644 index 000000000..61a3bd797 --- /dev/null +++ b/backend/crates/core/src/model/packet.rs @@ -0,0 +1,230 @@ +//! Wire packets. +//! +//! A [`Packet`] is a single message actually sent or received over the +//! wire, once decoded. This is the *instance* — as opposed to +//! [`super::Measurement`] and [`super::Protection`], which describe what a +//! packet or a protection *can* look like, or [`super::PacketDef`], which +//! describes a whole packet. + +use super::{AdjId, Value}; + +/// A short timestamp carrying only the time of day: hour, minute, second +/// and a sub-second fraction, each one byte. +/// +/// Used by [`ProtectionPacket`] and [`MessagePacket`]. Unlike a full +/// calendar timestamp, this only records *when today* something happened; +/// the date is assumed to be the day the packet was received. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ShortTimestamp { + /// The hour, 0–23. + pub hour: u8, + /// The minute, 0–59. + pub minute: u8, + /// The second, 0–59. + pub second: u8, + /// The sub-second fraction, as the raw byte on the wire. + /// + /// This byte was labeled `resv` ("reserved for alignment") at first, + /// but it is in fact used for sub-second resolution. Its exact unit + /// (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed + /// yet, so it is kept as a raw `u8` here instead of converted to + /// something like a `Duration` — convert it once the unit is settled. + pub subsecond: u8, +} + +/// A data packet: the decoded values of the measurements a board just +/// sent. +/// +/// The wire protocol is positional: values are not individually tagged +/// with their measurement's id, just written back to back in the order +/// their [`super::Measurement`]s appear in this packet's +/// [`super::PacketDef::measurements`]. `values[i]` is the decoded value of +/// `measurements[i]` in that definition — matching them up by position is +/// `protocol`'s job, this type just holds the result in that same order. +#[derive(Debug, Clone, PartialEq)] +pub struct DataPacket { + /// Which packet definition this is an instance of. + pub id: AdjId, + /// The decoded value of each measurement in the packet, positional — + /// see the struct-level docs above. + pub values: Vec, +} + +/// An order sent from the backend to a board. +/// +/// Positional, the same way [`DataPacket::values`] is: see its docs. +#[derive(Debug, Clone, PartialEq)] +pub struct OrderPacket { + /// Which order definition this is an instance of. + pub id: AdjId, + /// The value to set for each field of the order, positional. + pub fields: Vec, +} + +/// A protection packet: sent by a board when one of its declared +/// [`super::Protection`]s triggers. +/// +/// `measurement` and `protection` together are the two halves of the +/// compound wire id — decoding that split is `protocol`'s job, this type +/// just holds the result. +#[derive(Debug, Clone, PartialEq)] +pub struct ProtectionPacket { + /// The measurement this protection watches. + pub measurement: AdjId, + /// Which of that measurement's protections triggered — see + /// [`super::Protection::id`]. + pub protection: AdjId, + /// When it triggered. + pub timestamp: ShortTimestamp, + /// The value that triggered it, decoded with the same wire type as the + /// watched measurement. + pub value: Value, +} + +/// How severe a message is (reusing `general_info.json`'s `message_ids`). +/// +/// Unlike [`super::Severity`], which only distinguishes fault from warning +/// for protections, a message has two extra levels: `messages` and +/// `protections` are unrelated wire formats as of ADJv3, so this is a +/// separate type on purpose, not a reuse of [`super::Severity`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MessageLevel { + /// Stops the vehicle automatically. + Fault, + /// Flags something unexpected, without stopping the vehicle. + Warning, + /// Informational, no action implied. + Info, + /// An unrecoverable firmware error. + Panic, +} + +/// A free-text message sent by a board. +#[derive(Debug, Clone, PartialEq)] +pub struct MessagePacket { + /// How severe the message is. + pub level: MessageLevel, + /// When it was sent. + pub timestamp: ShortTimestamp, + /// Where it came from, e.g. a source file path. + pub origin: String, + /// The message's contents. + pub message: String, +} + +/// Any packet actually sent or received over the wire, once decoded. +#[derive(Debug, Clone, PartialEq)] +pub enum Packet { + /// A set of measurements sent by a board. + Data(DataPacket), + /// An order sent to a board. + Order(OrderPacket), + /// A protection that triggered. + Protection(ProtectionPacket), + /// A free-text message sent by a board. + Message(MessagePacket), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn two_timestamps_with_the_same_time_of_day_are_equal() { + let a = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 0, + }; + let b = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 0, + }; + assert_eq!(a, b); + } + + #[test] + fn two_timestamps_that_only_differ_in_subsecond_are_not_equal() { + let a = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 0, + }; + let b = ShortTimestamp { + hour: 10, + minute: 30, + second: 0, + subsecond: 128, + }; + assert_ne!(a, b); + } + + #[test] + fn a_protection_packet_carries_its_measurement_and_its_own_position_separately() { + let packet = ProtectionPacket { + measurement: AdjId(3000), + protection: AdjId(1), + timestamp: ShortTimestamp { + hour: 12, + minute: 0, + second: 5, + subsecond: 0, + }, + value: Value::Number(53.2), + }; + + assert_eq!(packet.measurement, AdjId(3000)); + assert_eq!(packet.protection, AdjId(1)); + } + + #[test] + fn message_level_has_four_distinct_values() { + let levels = [ + MessageLevel::Fault, + MessageLevel::Warning, + MessageLevel::Info, + MessageLevel::Panic, + ]; + + for (i, a) in levels.iter().enumerate() { + for (j, b) in levels.iter().enumerate() { + assert_eq!(a == b, i == j); + } + } + } + + #[test] + fn data_packet_values_are_positional_not_tagged_with_an_id() { + // values[0] is whatever measurements[0] is in this packet's + // PacketDef — there is no id stored alongside each value here. + let packet = DataPacket { + id: AdjId(1000), + values: vec![Value::Number(53.2), Value::Bool(true)], + }; + + assert_eq!(packet.values[0], Value::Number(53.2)); + assert_eq!(packet.values[1], Value::Bool(true)); + } + + #[test] + fn packet_wraps_each_kind_without_mixing_them_up() { + let data = Packet::Data(DataPacket { + id: AdjId(1000), + values: vec![], + }); + let order = Packet::Order(OrderPacket { + id: AdjId(2000), + fields: vec![], + }); + + assert_ne!(data, order); + match data { + Packet::Data(_) => {} + _ => unreachable!("this is a Packet::Data"), + } + } +} diff --git a/backend/crates/core/src/model/protections.rs b/backend/crates/core/src/model/protections.rs index e69de29bb..3159c107c 100644 --- a/backend/crates/core/src/model/protections.rs +++ b/backend/crates/core/src/model/protections.rs @@ -0,0 +1,171 @@ +//! Protection declarations. +//! +//! A [`Protection`] is a bound checked against a measurement's decoded +//! value, as declared for that measurement in the ADJ (see +//! `_measurements.json`, `protections` array). This describes the +//! *declaration*: what to watch for and how +//! severe it is. It is not the packet actually received over the wire when +//! a protection triggers; that belongs with the rest of the wire packets, +//! once `packet` exists. +//! +//! In ADJv2, a protection was just a `message` with a specific kind. +//! ADJv3 splits them into two unrelated wire formats, so from here on +//! `Protection` and a future `Message` type are not related at all. + +use crate::model::AdjId; +use std::time::Duration; + +/// What a protection checks, and the bound(s) it needs. +/// +/// Modeled as data-carrying variants instead of a flat `value: Vec` +/// plus a `range: bool` flag (as the ADJ JSON represents it), so a +/// protection with the wrong number of bounds for its kind — e.g. an +/// `Above` with two values, or a `Range` with only one — cannot be +/// represented at all. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProtectionKind { + /// Triggers when the value goes above `limit`. + Above { + /// The upper limit. + limit: f64, + }, + /// Triggers when the value goes below `limit`. + Below { + /// The lower limit. + limit: f64, + }, + /// Triggers when the value equals `target`. + Equal { + /// The value that must not occur. + target: f64, + }, + /// Triggers when the value is different from `target`. + NotEqual { + /// The value that is expected. + target: f64, + }, + /// Triggers when the value leaves the `[min, max]` interval. + /// + /// Unlike [`super::Range`], both bounds are mandatory here: the ADJ + /// always provides exactly two values for a `Range` protection, so + /// there is no "open-ended" case to represent. + Range { + /// The lower bound of the interval. + min: f64, + /// The upper bound of the interval. + max: f64, + }, +} + +/// How severe a triggered protection is. +/// +/// Only two values, unlike a message's severity: a protection is either a +/// [`Severity::Fault`], which stops the vehicle, or a [`Severity::Warning`], +/// which does not. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Severity { + /// Stops the vehicle automatically. + Fault, + /// Flags the value as unexpected, without stopping the vehicle. + Warning, +} + +/// A single protection watching a measurement, as declared in the ADJ. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Protection { + /// This protection's position within its measurement's `protections` + /// list, encoded as an [`AdjId`] in the 1–7 range. + /// + /// This is *not* an independently assigned id from the global ADJ id + /// space (`[0, 8191]`): a protection is only ever + /// addressed relative to the measurement that owns it. On the wire, + /// this position becomes the 3-bit `pos` field that, combined with the + /// owning measurement's 13-bit id, forms the compound id — that + /// combination is `protocol`'s job, not this type's. + pub id: AdjId, + /// What this protection checks and its bound(s). + pub kind: ProtectionKind, + /// How severe it is once triggered. + pub severity: Severity, + /// How long the value must stay out of bounds before this protection + /// triggers. `Duration::ZERO` (the ADJ's default) means it triggers + /// immediately. + pub time: Duration, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_range_protection_always_carries_both_bounds() { + let protection = Protection { + id: AdjId(1), + kind: ProtectionKind::Range { + min: 0.0, + max: 48.0, + }, + severity: Severity::Fault, + time: Duration::ZERO, + }; + + let ProtectionKind::Range { min, max } = protection.kind else { + unreachable!("protection was just constructed as Range above"); + }; + + assert_eq!(min, 0.0); + assert_eq!(max, 48.0); + } + + #[test] + fn two_protections_with_the_same_data_are_equal() { + let a = Protection { + id: AdjId(2), + kind: ProtectionKind::Above { limit: 100.0 }, + severity: Severity::Warning, + time: Duration::from_millis(500), + }; + let b = a; + assert_eq!(a, b); + } + + #[test] + fn zero_time_means_it_triggers_immediately() { + let protection = Protection { + id: AdjId(3), + kind: ProtectionKind::Equal { target: 0.0 }, + severity: Severity::Fault, + time: Duration::ZERO, + }; + + assert_eq!(protection.time, Duration::ZERO); + } + + #[test] + fn a_protections_id_is_its_position_not_a_global_id() { + // Two different measurements can each have a protection at + // position 1 — they are unrelated protections that happen to + // share the same "slot" id, disambiguated by their measurement. + let first_measurement_protection = Protection { + id: AdjId(1), + kind: ProtectionKind::Below { limit: -10.0 }, + severity: Severity::Warning, + time: Duration::ZERO, + }; + let second_measurement_protection = Protection { + id: AdjId(1), + kind: ProtectionKind::Above { limit: 90.0 }, + severity: Severity::Fault, + time: Duration::ZERO, + }; + + assert_eq!( + first_measurement_protection.id, + second_measurement_protection.id + ); + assert_ne!( + first_measurement_protection.kind, + second_measurement_protection.kind + ); + } +} diff --git a/backend/readme-demo.md b/backend/readme-demo.md index 875c83848..050e911ac 100644 --- a/backend/readme-demo.md +++ b/backend/readme-demo.md @@ -28,7 +28,6 @@ cargo clippy --workspace --all-targets -- -D warnings Abre `target/doc/software_core/hello_world/index.html` para ver la API. - `crates/core/src/hello_world.rs`: `greet(&str) -> String`, con rustdoc, - un doctest y tres tests unitarios. Recorta espacios; un nombre vacío usa `World`. - `crates/core/src/lib.rs`: exporta el módulo público. - `crates/station/src/main.rs`: inicializa el subscriber una sola vez y llama a `greet`. - `#[tracing::instrument]` crea el span `hello_world`; los eventos incluyen From db41226217539238ca0ab4f5ad05007ebbbed5e4 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 30 Sep 2026 17:42:20 +0200 Subject: [PATCH 06/82] fix: delete docs --- .../src/model/Comunicaciones_Reunion01-2.pdf | Bin 670255 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 backend/crates/core/src/model/Comunicaciones_Reunion01-2.pdf diff --git a/backend/crates/core/src/model/Comunicaciones_Reunion01-2.pdf b/backend/crates/core/src/model/Comunicaciones_Reunion01-2.pdf deleted file mode 100644 index a1d7f34bc65782fda6a3122b31bb61327bc85839..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 670255 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 Date: Wed, 30 Sep 2026 17:49:09 +0200 Subject: [PATCH 07/82] feat: include mac adress --- backend/crates/core/src/model/board.rs | 10 ++++++++- backend/crates/core/src/model/id.rs | 29 ++++++++++++++++++++++++++ backend/crates/core/src/model/mod.rs | 4 ++-- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 6eda926db..ceb25b0e0 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -10,7 +10,8 @@ //! left out rather than guessed — add them once the packet-level JSON //! shape is settled. -use super::{AdjId, BoardId, Measurement}; +use super::{AdjId, BoardId, MacAddress, Measurement}; +use std::net::IpAddr; /// What kind of packet a [`PacketDef`] declares. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -45,6 +46,10 @@ pub struct Board { pub id: BoardId, /// The board's name, e.g. `"BCU"`. pub name: String, + /// The board's IP address, used to open its TCP/UDP connections. + pub ip: IpAddr, + /// The board's MAC address (ADJv3 spec, section 5). + pub mac: MacAddress, /// The packets this board can send or receive. pub packets: Vec, } @@ -84,6 +89,8 @@ mod tests { let board = Board { id: BoardId(1), name: "BCU".to_string(), + ip: IpAddr::from([192, 168, 0, 10]), + mac: MacAddress([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]), packets: vec![PacketDef { id: AdjId(1000), name: "bcu_data".to_string(), @@ -95,5 +102,6 @@ mod tests { assert_eq!(board.packets.len(), 1); assert_eq!(board.packets[0].measurements.len(), 1); assert_eq!(board.packets[0].kind, PacketKind::Data); + assert_eq!(board.mac.to_string(), "00:11:22:33:44:55"); } } diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index 14fc8232b..878532e06 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -16,6 +16,29 @@ pub struct AdjId(pub u16); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BoardId(pub u16); +/// A board's physical MAC address, as 6 raw bytes. +/// +/// Kept as a fixed-size byte array instead of a `String` on purpose: a +/// malformed or miswritten address (the ADJv3 meeting notes mention a real +/// `67:67:67:67:67:67` that caused hours of debugging) should not be able +/// to reach this type at all. Parsing the usual `"aa:bb:cc:dd:ee:ff"` text +/// form is `adj`'s job, not this type's. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct MacAddress(pub [u8; 6]); + +impl std::fmt::Debug for MacAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "MacAddress({self})") + } +} + +impl std::fmt::Display for MacAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let [a, b, c, d, e, g] = self.0; + write!(f, "{a:02x}:{b:02x}:{c:02x}:{d:02x}:{e:02x}:{g:02x}") + } +} + #[cfg(test)] mod tests { use super::*; @@ -25,6 +48,12 @@ mod tests { assert_eq!(AdjId(1000), AdjId(1000)); } + #[test] + fn a_mac_address_displays_as_colon_separated_lowercase_hex() { + let mac = MacAddress([0xaa, 0xbb, 0xcc, 0x0d, 0x0e, 0xff]); + assert_eq!(mac.to_string(), "aa:bb:cc:0d:0e:ff"); + } + #[test] fn packet_id_and_board_id_cannot_be_compared_to_each_other() { // This wouldn't compile if you tried it: AdjId(1) == BoardId(1) diff --git a/backend/crates/core/src/model/mod.rs b/backend/crates/core/src/model/mod.rs index fec13b632..2ca135984 100644 --- a/backend/crates/core/src/model/mod.rs +++ b/backend/crates/core/src/model/mod.rs @@ -2,7 +2,7 @@ //! protections and boards. No I/O and no networking here, just data and //! the operations performed on it. //! -//! - `id` — `AdjId`, `BoardId`. +//! - `id` — `AdjId`, `BoardId`, `MacAddress`. //! - `value` — `Value`, the result of decoding a measurement. //! - `measurement` — `Measurement`: what a field of a packet looks like. //! - `protections` — `Protection`: bounds declared for a measurement. @@ -18,7 +18,7 @@ mod protections; mod value; pub use board::{Board, PacketDef, PacketKind, PodData}; -pub use id::{AdjId, BoardId}; +pub use id::{AdjId, BoardId, MacAddress}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, From 24e78b7c72bbe3b2feac3ffe740d11c512c6a034 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Wed, 30 Sep 2026 23:40:33 +0200 Subject: [PATCH 08/82] fix(model): rename mod.rs to model.rs --- backend/crates/core/src/{model/mod.rs => model.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/crates/core/src/{model/mod.rs => model.rs} (100%) diff --git a/backend/crates/core/src/model/mod.rs b/backend/crates/core/src/model.rs similarity index 100% rename from backend/crates/core/src/model/mod.rs rename to backend/crates/core/src/model.rs From e0989102cea537e0be513929b6ffdb73b587651c Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Fri, 2 Oct 2026 11:01:10 +0200 Subject: [PATCH 09/82] feat(ntp): implementation of the NTP server and its addition as a feature --- backend/crates/core/Cargo.toml | 7 +- backend/crates/core/src/lib.rs | 4 ++ backend/crates/core/src/ntp.rs | 122 +++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 backend/crates/core/src/ntp.rs diff --git a/backend/crates/core/Cargo.toml b/backend/crates/core/Cargo.toml index 1b38a623e..087f62a60 100644 --- a/backend/crates/core/Cargo.toml +++ b/backend/crates/core/Cargo.toml @@ -10,6 +10,8 @@ repository.workspace = true [features] default = [] +# NTP server +ntp = ["dep:tokio", "dep:ntp_usg-server", "dep:tracing-subscriber"] # TCP/UDP transport (pulls in tokio) net = ["dep:tokio"] # ADJ download through git (dependency added when the module is written) @@ -26,9 +28,10 @@ bytes.workspace = true serde.workspace = true thiserror.workspace = true tracing.workspace = true -tokio = { workspace = true, optional = true } -tracing-subscriber = { workspace = true, optional = true } +tokio = { workspace = true, optional = true, features = ["macros", "rt-multi-thread"] } +tracing-subscriber = { workspace = true, optional = true , features = ["env-filter", "fmt"]} tracing-appender = { workspace = true, optional = true } +ntp_usg-server = { version = "5.0.0", optional = true, features = ["ipv4", "tokio"] } # `workspace = true` can't be combined with extra lints in the same table, # so the workspace list is repeated here with `missing_docs` added: this is diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs index a85bf01dd..8deef0cc3 100644 --- a/backend/crates/core/src/lib.rs +++ b/backend/crates/core/src/lib.rs @@ -1,3 +1,7 @@ //! Componentes reutilizables de Hyperloop UPV. pub mod hello_world; + +/// NTP server +#[cfg(feature = "ntp")] +pub mod ntp; diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs new file mode 100644 index 000000000..3a7dea8d4 --- /dev/null +++ b/backend/crates/core/src/ntp.rs @@ -0,0 +1,122 @@ +use ntp_server::{protocol::Stratum, server::NtpServer}; +use std::{ + io, + net::{Ipv4Addr, SocketAddrV4}, +}; +use tracing::{debug, info, instrument}; + +/// Starts the NTP server +/// Arguments: +/// - `interface`: (Optional) Interface which is suposed to listen. Default interface is `0.0.0.0`. +/// - `port`: (Optional) Specific port for the server to listen. Default port is `8123`. +#[instrument(level = "info")] +pub async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { + let server = build_ntp_server(interface, port).await?; + debug!("Starting NTP server loop"); + // Any internal log of server.run() will be wrapped within this function Span + server.run().await +} + +async fn build_ntp_server(interface: Option, port: Option) -> io::Result { + let interface = interface.unwrap_or(Ipv4Addr::UNSPECIFIED); // Same as Ipv4Addr::new(0,0,0,0); + + let port = port.unwrap_or(8123); + + let socket_addr_v4 = SocketAddrV4::new(interface, port); + + // % -> use structured variables + debug!(%socket_addr_v4, "Building NTP server"); + + let server = NtpServer::builder() + .listen(socket_addr_v4.to_string()) + // Stratum indicates the server's distance from the source of the reference clock. + // This server syncronizes from a stratum 1 source, and advertises stratum 2 to its clients. + .stratum(Stratum(2)) + .build() + .await?; + + info!(%socket_addr_v4, "NTP server built successfully"); + + Ok(server) +} + +#[cfg(test)] +mod test { + + use super::*; + use ntp_server::protocol::{ + ConstPackedSizeBytes, FromBytes, Mode, Packet, TimestampFormat, ToBytes, Version, + }; + use std::time::Duration; + use tokio::net::UdpSocket; + use tracing_subscriber; + + // Inicializes the subscriber for tests + fn init_tracing_for_tests() { + let _ = tracing_subscriber::fmt() + .with_env_filter("debug") + .with_test_writer() // Ensures that the logs get showed by cargo test + .try_init(); // Avoids the test failure if there is another test in parallel + } + + #[tokio::test] + async fn ntp_server_responds_to_client_request() { + init_tracing_for_tests(); + + let server = build_ntp_server(Some(Ipv4Addr::LOCALHOST), Some(0)) + .await + .expect("NTP server should bind to an ephemeral local port"); + let server_addr = server + .local_addr() + .expect("NTP server should expose its bound address"); + let server_task = tokio::spawn(server.run()); + + let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("NTP client socket should bind to an ephemeral local port"); + let request = Packet { + transmit_timestamp: TimestampFormat { + seconds: 0xE0000000, + fraction: 0x12345678, + }, + ..Packet::default() + }; + let mut request_bytes = [0; Packet::PACKED_SIZE_BYTES]; + request + .to_bytes(&mut request_bytes) + .expect("NTP client request should serialize"); + socket + .send_to(&request_bytes, server_addr) + .await + .expect("NTP client request should be sent"); + + let mut response_bytes = [0; 2048]; + let (response_len, source_addr) = tokio::time::timeout( + Duration::from_secs(2), + socket.recv_from(&mut response_bytes), + ) + .await + .expect("NTP server should respond before timeout") + .expect("NTP response should be received"); + + assert_eq!(source_addr, server_addr); + assert_eq!(response_len, Packet::PACKED_SIZE_BYTES); + + let (response, bytes_read) = Packet::from_bytes(&response_bytes[..response_len]) + .expect("server response should be a valid NTP packet"); + assert_eq!(bytes_read, Packet::PACKED_SIZE_BYTES); + assert_eq!(response.mode, Mode::Server); + assert_eq!(response.version, Version::V4); + assert_eq!(response.stratum, Stratum(2)); + assert_eq!(response.origin_timestamp, request.transmit_timestamp); + assert_ne!(response.transmit_timestamp, TimestampFormat::default()); + + server_task.abort(); + assert!( + server_task + .await + .expect_err("server task should be aborted") + .is_cancelled() + ); + } +} From 3f8179a76a678ded63057bc5ed1fdf079cfd5575 Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Fri, 2 Oct 2026 11:01:56 +0200 Subject: [PATCH 10/82] update: update rust version from 1.85 to 1.98 --- backend/Cargo.lock | 54 ++++++++++++++++++++++++++++++++++------------ backend/Cargo.toml | 4 ++-- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 94186abdf..69d6e2bfb 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -43,9 +49,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "equivalent" @@ -87,9 +90,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" @@ -129,6 +132,27 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "ntp_usg-proto" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31316ff46fc87e03a95e55b93525a27feecfd86fab094ca887a773395f13f50a" +dependencies = [ + "byteorder", + "log", +] + +[[package]] +name = "ntp_usg-server" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0002bd55912864af88a46077abd71db2c4cda88887b46b19c31e1e9a1f289e4e" +dependencies = [ + "ntp_usg-proto", + "tokio", + "tracing", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -140,9 +164,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "once_cell" @@ -289,6 +313,7 @@ name = "software-core" version = "0.1.0" dependencies = [ "bytes", + "ntp_usg-server", "serde", "thiserror", "tokio", @@ -369,12 +394,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -384,15 +408,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -404,6 +428,7 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", "libc", "mio", "pin-project-lite", @@ -469,6 +494,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d9aeeb64e..a1161a8d8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -5,7 +5,7 @@ default-members = ["crates/station"] [workspace.package] edition = "2024" -rust-version = "1.85" +rust-version = "1.98" license = "MIT" authors = [ "Hyperloop UPV Software Subsystem", @@ -26,7 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", toml = "0.9" tracing = "0.1" tracing-appender = "0.2" -tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } [workspace.lints.rust] unsafe_code = "forbid" From 37aa08a23b2127ef41fc0d768446bc0b87a16f82 Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Sat, 3 Oct 2026 11:50:29 +0200 Subject: [PATCH 11/82] fix: requests from PR, create port const, comment test, and changes in start_ntp_server function --- backend/crates/core/src/ntp.rs | 62 +++++++++++++++++++++++++++++----- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs index 3a7dea8d4..aeab276c9 100644 --- a/backend/crates/core/src/ntp.rs +++ b/backend/crates/core/src/ntp.rs @@ -5,14 +5,39 @@ use std::{ }; use tracing::{debug, info, instrument}; +const NTP_DEFAULT_PORT: u16 = 8123; + +/// Represents the address used to start the NTP server. +/// It can be defined as an IPv4 interface + port or as a full socket address. +#[derive(Debug)] +pub enum NtpAddress { + /// Uses the interface and port separately. + Interface { + /// IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. + interface: Option, + /// Port to listen on. If `None`, the default NTP port (`8123`) is used. + port: Option, + }, + /// Uses a complete IPv4 socket address. + Socket(Option) +} + /// Starts the NTP server /// Arguments: -/// - `interface`: (Optional) Interface which is suposed to listen. Default interface is `0.0.0.0`. -/// - `port`: (Optional) Specific port for the server to listen. Default port is `8123`. +/// - `address`: The target address configuration used to bind the server. #[instrument(level = "info")] -pub async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { +pub async fn start_ntp_server(address: NtpAddress) -> io::Result<()> { + + let (interface, port) = match address { + NtpAddress::Interface {interface, port} => (interface, port), + NtpAddress::Socket(Some(socket)) => { + (Some(*socket.ip()), Some(socket.port())) + }, + NtpAddress::Socket(None) => (None, None) + }; + let server = build_ntp_server(interface, port).await?; - debug!("Starting NTP server loop"); + info!("Starting NTP server loop"); // Any internal log of server.run() will be wrapped within this function Span server.run().await } @@ -20,7 +45,7 @@ pub async fn start_ntp_server(interface: Option, port: Option) -> async fn build_ntp_server(interface: Option, port: Option) -> io::Result { let interface = interface.unwrap_or(Ipv4Addr::UNSPECIFIED); // Same as Ipv4Addr::new(0,0,0,0); - let port = port.unwrap_or(8123); + let port = port.unwrap_or(NTP_DEFAULT_PORT); let socket_addr_v4 = SocketAddrV4::new(interface, port); @@ -30,7 +55,7 @@ async fn build_ntp_server(interface: Option, port: Option) -> io: let server = NtpServer::builder() .listen(socket_addr_v4.to_string()) // Stratum indicates the server's distance from the source of the reference clock. - // This server syncronizes from a stratum 1 source, and advertises stratum 2 to its clients. + // This server synchronizes from a stratum 1 source, and advertises stratum 2 to its clients. .stratum(Stratum(2)) .build() .await?; @@ -51,45 +76,60 @@ mod test { use tokio::net::UdpSocket; use tracing_subscriber; - // Inicializes the subscriber for tests + // Initializes the subscriber for tests fn init_tracing_for_tests() { let _ = tracing_subscriber::fmt() .with_env_filter("debug") - .with_test_writer() // Ensures that the logs get showed by cargo test + .with_test_writer() // Ensures that the logs are shown by cargo test .try_init(); // Avoids the test failure if there is another test in parallel } #[tokio::test] + // Verifies if the NTP server responds correctly to a client request async fn ntp_server_responds_to_client_request() { init_tracing_for_tests(); + // Create the NTP server let server = build_ntp_server(Some(Ipv4Addr::LOCALHOST), Some(0)) .await .expect("NTP server should bind to an ephemeral local port"); + + // Get the address assigned by the OS let server_addr = server .local_addr() .expect("NTP server should expose its bound address"); + + // Start the server on a thread let server_task = tokio::spawn(server.run()); + // Bind a UDP socket to send NTP requests let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) .await .expect("NTP client socket should bind to an ephemeral local port"); + + // Build NTP request packet for testing requests let request = Packet { transmit_timestamp: TimestampFormat { + // Test data, just to verify the server responds correctly seconds: 0xE0000000, fraction: 0x12345678, }, - ..Packet::default() + ..Packet::default() // Fills the rest of the packet with the default values }; + + // Serialize the request into a fixed-size byte array. let mut request_bytes = [0; Packet::PACKED_SIZE_BYTES]; request .to_bytes(&mut request_bytes) .expect("NTP client request should serialize"); + + // Send the request socket .send_to(&request_bytes, server_addr) .await .expect("NTP client request should be sent"); + // Waiting for the server to respond let mut response_bytes = [0; 2048]; let (response_len, source_addr) = tokio::time::timeout( Duration::from_secs(2), @@ -99,9 +139,12 @@ mod test { .expect("NTP server should respond before timeout") .expect("NTP response should be received"); + // Response should come from the same server address assert_eq!(source_addr, server_addr); + // Response should have the same packet length assert_eq!(response_len, Packet::PACKED_SIZE_BYTES); + // Deserialize response and validate contents let (response, bytes_read) = Packet::from_bytes(&response_bytes[..response_len]) .expect("server response should be a valid NTP packet"); assert_eq!(bytes_read, Packet::PACKED_SIZE_BYTES); @@ -111,6 +154,7 @@ mod test { assert_eq!(response.origin_timestamp, request.transmit_timestamp); assert_ne!(response.transmit_timestamp, TimestampFormat::default()); + // Stop the server task and verify that it was cancelled. server_task.abort(); assert!( server_task From c48c98605c0d0aa3aa41fabd02d708b874f4cd49 Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Sat, 3 Oct 2026 12:48:40 +0200 Subject: [PATCH 12/82] fix: delete NtpAddress and use two functions --- backend/crates/core/src/ntp.rs | 45 +++++++++++++++------------------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs index aeab276c9..1f94d620a 100644 --- a/backend/crates/core/src/ntp.rs +++ b/backend/crates/core/src/ntp.rs @@ -7,35 +7,30 @@ use tracing::{debug, info, instrument}; const NTP_DEFAULT_PORT: u16 = 8123; -/// Represents the address used to start the NTP server. -/// It can be defined as an IPv4 interface + port or as a full socket address. -#[derive(Debug)] -pub enum NtpAddress { - /// Uses the interface and port separately. - Interface { - /// IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. - interface: Option, - /// Port to listen on. If `None`, the default NTP port (`8123`) is used. - port: Option, - }, - /// Uses a complete IPv4 socket address. - Socket(Option) +/// Starts the NTP server with an interface and a port. +/// +/// Arguments: +/// - `interface`: IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. +/// - `port`: Port to listen on. If `None`, the default NTP port (`8123`) is used. +pub async fn start_ntp(interface: Option, port: Option) -> io::Result<()> { + start_ntp_server(interface, port).await } -/// Starts the NTP server +/// Starts the NTP server with a socket. +/// /// Arguments: -/// - `address`: The target address configuration used to bind the server. -#[instrument(level = "info")] -pub async fn start_ntp_server(address: NtpAddress) -> io::Result<()> { - - let (interface, port) = match address { - NtpAddress::Interface {interface, port} => (interface, port), - NtpAddress::Socket(Some(socket)) => { - (Some(*socket.ip()), Some(socket.port())) - }, - NtpAddress::Socket(None) => (None, None) +/// - `socket`: Complete IPv4 socket address. If `None` the default `SocketAddrV4` is used (`0.0.0.0:8123`). +pub async fn start_ntp_with_socket(socket: Option) -> io::Result<()> { + let (interface, port) = match socket { + Some(socket) => (Some(*socket.ip()), Some(socket.port())), + None => (None, None) }; + start_ntp_server(interface, port).await +} + +#[instrument(level = "info")] +async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { let server = build_ntp_server(interface, port).await?; info!("Starting NTP server loop"); // Any internal log of server.run() will be wrapped within this function Span @@ -106,7 +101,7 @@ mod test { let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)) .await .expect("NTP client socket should bind to an ephemeral local port"); - + // Build NTP request packet for testing requests let request = Packet { transmit_timestamp: TimestampFormat { From 6b3429115145c338658ae4479dbe733b3868232a Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Sat, 3 Oct 2026 12:53:35 +0200 Subject: [PATCH 13/82] feat: add tracing to new functions --- backend/crates/core/src/ntp.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs index 1f94d620a..83b3487cf 100644 --- a/backend/crates/core/src/ntp.rs +++ b/backend/crates/core/src/ntp.rs @@ -8,22 +8,24 @@ use tracing::{debug, info, instrument}; const NTP_DEFAULT_PORT: u16 = 8123; /// Starts the NTP server with an interface and a port. -/// +/// /// Arguments: /// - `interface`: IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. /// - `port`: Port to listen on. If `None`, the default NTP port (`8123`) is used. +#[instrument(level = "info")] pub async fn start_ntp(interface: Option, port: Option) -> io::Result<()> { start_ntp_server(interface, port).await } /// Starts the NTP server with a socket. -/// +/// /// Arguments: /// - `socket`: Complete IPv4 socket address. If `None` the default `SocketAddrV4` is used (`0.0.0.0:8123`). +#[instrument(level = "info")] pub async fn start_ntp_with_socket(socket: Option) -> io::Result<()> { let (interface, port) = match socket { Some(socket) => (Some(*socket.ip()), Some(socket.port())), - None => (None, None) + None => (None, None), }; start_ntp_server(interface, port).await From e01eadb5195ab92db699c9aa885df03c3c8600cb Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Sat, 3 Oct 2026 13:06:02 +0200 Subject: [PATCH 14/82] fix: change function name, and explain NTP packet values --- backend/crates/core/src/ntp.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs index 83b3487cf..5cf85b712 100644 --- a/backend/crates/core/src/ntp.rs +++ b/backend/crates/core/src/ntp.rs @@ -13,8 +13,8 @@ const NTP_DEFAULT_PORT: u16 = 8123; /// - `interface`: IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. /// - `port`: Port to listen on. If `None`, the default NTP port (`8123`) is used. #[instrument(level = "info")] -pub async fn start_ntp(interface: Option, port: Option) -> io::Result<()> { - start_ntp_server(interface, port).await +pub async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { + run_ntp_server(interface, port).await } /// Starts the NTP server with a socket. @@ -22,17 +22,17 @@ pub async fn start_ntp(interface: Option, port: Option) -> io::Re /// Arguments: /// - `socket`: Complete IPv4 socket address. If `None` the default `SocketAddrV4` is used (`0.0.0.0:8123`). #[instrument(level = "info")] -pub async fn start_ntp_with_socket(socket: Option) -> io::Result<()> { +pub async fn start_ntp_server_with_socket(socket: Option) -> io::Result<()> { let (interface, port) = match socket { Some(socket) => (Some(*socket.ip()), Some(socket.port())), None => (None, None), }; - start_ntp_server(interface, port).await + run_ntp_server(interface, port).await } #[instrument(level = "info")] -async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { +async fn run_ntp_server(interface: Option, port: Option) -> io::Result<()> { let server = build_ntp_server(interface, port).await?; info!("Starting NTP server loop"); // Any internal log of server.run() will be wrapped within this function Span @@ -107,7 +107,7 @@ mod test { // Build NTP request packet for testing requests let request = Packet { transmit_timestamp: TimestampFormat { - // Test data, just to verify the server responds correctly + // Random data for test purpose only, just to verify the server responds correctly seconds: 0xE0000000, fraction: 0x12345678, }, From 2c15a65bb95098f534a62eb1c4dc8dc591c77ff9 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 6 Oct 2026 20:08:31 +0200 Subject: [PATCH 15/82] feat: adj --- backend/crates/core/src/adj.rs | 34 + .../core/src/adj/Comunicaciones_Reunion01.qmd | 356 ++++++ backend/crates/core/src/adj/error.rs | 145 +++ backend/crates/core/src/adj/info.rs | 37 + backend/crates/core/src/adj/raw.rs | 12 + backend/crates/core/src/adj/raw/loader.rs | 281 +++++ backend/crates/core/src/adj/raw/types.rs | 470 +++++++ .../crates/core/src/adj/test/adj/README.md | 291 +++++ .../crates/core/src/adj/test/adj/boards.json | 6 + .../src/adj/test/adj/boards/BLCU/BLCU.json | 12 + .../adj/boards/BLCU/BLCU_measurements.json | 34 + .../src/adj/test/adj/boards/BLCU/orders.json | 15 + .../src/adj/test/adj/boards/BLCU/packets.json | 28 + .../src/adj/test/adj/boards/BLCU/sockets.json | 14 + .../src/adj/test/adj/boards/HVBMS/HVBMS.json | 15 + .../adj/boards/HVBMS/HVBMS_measurements.json | 1102 +++++++++++++++++ .../src/adj/test/adj/boards/HVBMS/orders.json | 32 + .../adj/test/adj/boards/HVBMS/packets.json | 218 ++++ .../adj/test/adj/boards/HVBMS/sockets.json | 13 + .../core/src/adj/test/adj/boards/LCU/LCU.json | 15 + .../test/adj/boards/LCU/LCU_measurements.json | 752 +++++++++++ .../src/adj/test/adj/boards/LCU/orders.json | 142 +++ .../src/adj/test/adj/boards/LCU/packets.json | 163 +++ .../src/adj/test/adj/boards/LCU/sockets.json | 13 + .../core/src/adj/test/adj/boards/PCU/PCU.json | 15 + .../test/adj/boards/PCU/PCU_measurements.json | 304 +++++ .../src/adj/test/adj/boards/PCU/orders.json | 53 + .../src/adj/test/adj/boards/PCU/packets.json | 91 ++ .../src/adj/test/adj/boards/PCU/sockets.json | 13 + .../core/src/adj/test/adj/boards/VCU/VCU.json | 16 + .../test/adj/boards/VCU/VCU_measurements.json | 95 ++ .../src/adj/test/adj/boards/VCU/orders.json | 87 ++ .../src/adj/test/adj/boards/VCU/packets.json | 62 + .../src/adj/test/adj/boards/VCU/sockets.json | 13 + .../core/src/adj/test/adj/general_info.json | 61 + backend/crates/core/src/lib.rs | 5 + backend/crates/station/src/main.rs | 12 + 37 files changed, 5027 insertions(+) create mode 100644 backend/crates/core/src/adj.rs create mode 100644 backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd create mode 100644 backend/crates/core/src/adj/error.rs create mode 100644 backend/crates/core/src/adj/info.rs create mode 100644 backend/crates/core/src/adj/raw.rs create mode 100644 backend/crates/core/src/adj/raw/loader.rs create mode 100644 backend/crates/core/src/adj/raw/types.rs create mode 100644 backend/crates/core/src/adj/test/adj/README.md create mode 100644 backend/crates/core/src/adj/test/adj/boards.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/LCU/orders.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/LCU/packets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/PCU/orders.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/PCU/packets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/VCU/orders.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/VCU/packets.json create mode 100644 backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json create mode 100644 backend/crates/core/src/adj/test/adj/general_info.json diff --git a/backend/crates/core/src/adj.rs b/backend/crates/core/src/adj.rs new file mode 100644 index 000000000..3053f76b9 --- /dev/null +++ b/backend/crates/core/src/adj.rs @@ -0,0 +1,34 @@ +//! Parses an ADJ (Architecture Description JSON) directory into +//! [`crate::model::PodData`], plus backend-session metadata not modeled as +//! vehicle data ([`crate::adj::AdjInfo`]). +//! +//! Loading (not downloading — see `core::git` for that, a sibling module +//! this one is a client of) is not specific to `station`: a sniffer, a +//! simulator, or any other tool that reads an ADJ needs exactly the same +//! parsing. Scope for this iteration: no `sockets.json`, no +//! `period`/`period_type`/`socket` packet fields (matches +//! [`crate::model::PacketDef`]'s own omission), and no schema validation +//! (`adj::validate` is a separate, deliberately out-of-scope concern for +//! now) — this module only surfaces structural errors it cannot avoid, +//! e.g. a packet referencing a measurement alias that doesn't exist. + +mod error; +mod info; +pub mod raw; + +pub use error::{LoadError, MeasurementError, ProtectionError}; +pub use info::{AdjInfo, ProtectionTypeInfo}; +pub use raw::load_from_dir; + +use crate::model::PodData; + +/// The result of loading an ADJ: the vehicle's data (as [`crate::model`] +/// describes it) plus backend-session metadata. Built by +/// [`raw::load_from_dir`]. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Adj { + /// Every board and packet defined by the ADJ. + pub pod_data: PodData, + /// Backend-session metadata from `general_info.json`. + pub info: AdjInfo, +} diff --git a/backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd b/backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd new file mode 100644 index 000000000..fe6540072 --- /dev/null +++ b/backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd @@ -0,0 +1,356 @@ +--- +title: "ADJv3" +subtitle: Acta de la 1ª Reunión de comunicaciones +author: "Javier Ribal del Río" +date: 2026/09/16 +department: "ALL" # "SW" o "FW" +attendees: + - Javier Ribal del Río (SW-FW) (1ª y 2ª) + - Jorge Canut (FW) (1ª) + - Víctor López (FW) (1ª y 2ª) + - Aleksandr Globa (FW) (1ª y 2ª) + - Pau Minguet (SW) (2ª) + - Gazi Halawali (SW) (2ª) + - Pablo Pertusa (FW) (2ª) + - Boris Mladenov (2ª) + - Javier Lara (2ª) +--- + +## Contextualización + +El presente documento tiene como objetivo servir como especificación técnica para la implementación del `ADJv3`, partiendo de la base del `ADJv2` definido en el `README.md` del repositorio. El cambio más significativo se corresponde con la separación en forma y concepto de los `messages` y las `protections`. En el pasado, un `message` se correspondía con la información que enviaban las placas al vehículo, y las `protections` eran una implementación de los `messages`; es decir, una `protection` era un `message` con un tipo determinado. Asimismo, el documento también se corresponde con el acta de la primera reunión de comunicaciones. Debido a su larga duración, esta reunión se dividió en dos convocatorias (1ª y 2ª), celebradas los días 10 y 16 de septiembre de 2026, de ahí que el documento recoja las dos fechas y que la asistencia se indique por convocatoria. + +En el Drive del equipo hay unas diapositivas de la reunión que quizá arrojen algo de luz sobre el contenido de este texto. + +\newpage + +## Modificación de la estructura de los `measurements` y modificación de los `id` de los `packets/orders` + +Con el objetivo de dar soporte a las `protections` para cada `measurement`, el campo `id` pasa a ser un identificador único numérico común entre `packets` y `orders`. El rango de este identificador común es $[0, 8.191] = [0, 2^{13} - 1]$ ^[Podríamos definirlo como un `int13`]. Sin embargo, los `id` comprendidos en $[0, 511]$ se corresponden con los *ID* especiales: no asignados a `packets`, `orders` ni `protections`, o reservados para otros fines. + +Sin embargo, no podemos prescindir del `id` textual previamente definido, puesto que es empleado por la generación de código y no puede ser sustituido por el `id` numérico ^[Esto se debe a que actualmente la generación de código utiliza el `id` del `measurement` para generar su variable asociada]. Por ello, definimos un nuevo campo denominado `alias` que se corresponde con el antiguo campo `id` ^[Esta decisión de mantener un campo legado maximiza la compatibilidad con la `logging-view`]. Además, el campo `alias` se corresponde con el identificador para los archivos tipo `packets` y `orders`. + + + +## Incorporación de las `protections` al ADJ + +Las `protections` se desvinculan de la implementación de los `messages`, por lo que es necesario definir una nueva estructura para el paquete a enviar. + +### Estructura del paquete + +El paquete de una `protection` tiene longitud casi fija: una cabecera de 64 bits (`id` más +`timestamp`, con relleno de alineación) seguida del valor que ha disparado la protección, +cuyo tamaño depende del tipo del `measurement` vigilado. + +```{mermaid} +%%| label: fig-estructura-proteccion +%%| fig-cap: "Estructura del paquete de una protección. Las casillas van numeradas por su offset en bits. El campo `value` ocupa el mismo tamaño que el `measurement` asociado (de 8 a 64 bits); aquí se dibuja con 64." +%%| fig-width: 5.2 +%%| fig-pos: 'H' +%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% +packet-beta +0-15: "id" +16-23: "hora" +24-31: "min" +32-39: "seg" +40-47: "subseg" +48-63: "resv" +64-127: "value" +``` + +- `id` (bits 0–15): identificador numérico de la protección. +- `timestamp` (bits 16–47): instante en que salta la protección, con `hora`, `min`, `seg` y + `subseg` en 8 bits cada uno. +- `resv` (bits 48–63): 2 bytes reservados, únicamente para alinear `value` a un offset + múltiplo de 8 bytes. +- `value` (bits 64 en adelante): valor que ha excedido el umbral. Ocupa exactamente el mismo + tamaño que el `measurement` que vigila la protección (de 8 a 64 bits según su `type`), de + modo que el receptor lo decodifica con el mismo codificador que ese `measurement`. + + +### Tipos de `protection` definidos en `general_info.json` + +Los tipos de `protection` que pueden saltar se declaran en `general_info.json`, en un objeto +`protectionTypes` que asocia el nombre de cada protección a su definición: + +``` +{ + "protectionTypes": { + "": { + "range": booleano?, + "text": "", + "textTime": "?" + } + } +} +``` + +- `range`: booleano opcional, `false` por defecto. Indica cuántos límites tiene la + protección: `false` para las de un solo valor (`Above`, `Below`, `Equal`…) y `true` para + las de intervalo (`Range`), en cuyo caso los dos límites se escriben con notación de + intervalo, p. ej. `[233, 333]`. +- `text`: plantilla del mensaje que se muestr aen la control station. Los `{}` se + sustituyen por los límites definidos para la protección (un único valor, o el intervalo + `[min, max]` si `range` es `true`) y el `%`, por el valor que los ha excedido. +- `textTime`: plantilla alternativa para las protecciones de acumulación temporal + (`time_accumulation`), que saltan cuando el valor se mantiene fuera de rango durante un + intervalo dado. Se usa en lugar de `text` y el `%` se sustituye por ese tiempo máximo, no + por el valor. + + +#### Ejemplos de tipos de `protection` + +- `Above` +- `Below` +- `Equal` +- `NotEqual` ^[La protección se encuentra definida en la `ST-LIB`, por el momento no se nos ha ocurrido su propósito] +- `Range` + +### Definición de `protections` en los `measurements` + +Cada `measurement` de `_measurements.json` incorpora los cambios anteriores (el `id` +pasa a ser numérico, de rango $[0, 8.191]$, y el identificador textual antiguo se conserva +en `alias`) y, además, una lista opcional `protections` con las protecciones que vigilan esa +medida: + +``` +{ + "id": número, + "alias": "", + "name": "", + "type": "", + "podUnits": "?", + "displayUnits": "?", + "enumValues": [""]?, + "protections": [ + { + "type": "", + "value": [número] | [número, número], + "fault": booleano?, + "time": "?" + } + ]? +} +``` + +`protections` *array* limitado a 7 protecciones. + +- `type`: uno de los tipos declarados en `protectionTypes` (`Above`, `Below`, `Equal`, + `NotEqual`, `Range`). +- `value`: lista con el o los límites de la protección. Un elemento para las de un solo + valor y dos, `[min, max]`, para `Range`. +- `fault`: booleano opcional, `true` por defecto. Fija la severidad con la que se emite la + protección: `true` la envía como *fault* y `false` como *warning*. +- `time`: opcional, solo para las protecciones de acumulación temporal. Cadena con valor y + unidad (`"500ms"`, `"3s"`…) que indica cuánto tiempo debe mantenerse la medida fuera de + rango antes de que la protección salte, por defecto `0`. + +### Id de las protecciones + +\begin{quote} +\fbox{\parbox{0.92\linewidth}{\raggedright +\textbf{¿Qué es \textit{bit-packing}?}\\[4pt] +Es la técnica de meter varios campos independientes dentro de un mismo entero, cada uno +ocupando solo los bits que necesita, en vez de reservarle un byte o un campo propio. Aquí +el \texttt{uint16} del \texttt{id} empaqueta dos campos: \texttt{pos} (3 bits) e +\texttt{id\_measurement} (13 bits). Se construye desplazando y combinando con operadores a +nivel de bit --- \texttt{(pos << 13) | id\_measurement} --- y se deshace igual, en el otro +sentido: \texttt{pos = id >> 13} e \texttt{id\_measurement = id \& 0x1FFF}. La ventaja es +que no se desperdicia ni un bit; el coste es que, si un consumidor no sabe que el campo va +empaquetado, lo interpreta mal sin que nada se lo avise. +}} +\end{quote} + +Esta codificación no es exclusiva del paquete de la `protection`: como `packets`, `orders` y +`protections` comparten el mismo campo `id` de 16 bits, se aplica al `id` de **cualquier** +paquete del protocolo. Al recibir un `id`, lo primero que se mira son sus 3 bits altos +(`pos`): si valen `000`, el resto son los 13 bits del `id` normal de un `packet`, `order` o +`measurement`; si no, indican qué `protection` (`1`–`7`) del `measurement` de esos 13 bits +ha saltado: + +$$\texttt{id} = (\texttt{pos} \ll 13) \mathbin{|} \texttt{id\_measurement}$$ + +```{mermaid} +%%| label: fig-id-protection +%%| fig-cap: "Id de 16 bits compartido por packets, orders y protections: 3 bits de posición (000 = id normal) + 13 bits del id de measurement/packet/order." +%%| fig-width: 4.6 +%%| fig-pos: 'H' +%%{init: {"packet": {"bitWidth": 22, "rowHeight": 34, "bitsPerRow": 16, "showBits": true}}}%% +packet-beta +0-12: "id measurement" +13-15: "pos" +``` + +| `pos` | Significado | +|:-----:|-------------------------------------------------| +| `000` | `id` normal: `packet`, `order` o `measurement` | +| `001` | `protections[0]` | +| `010` | `protections[1]` | +| `011` | `protections[2]` | +| `100` | `protections[3]` | +| `101` | `protections[4]` | +| `110` | `protections[5]` | +| `111` | `protections[6]` | + +Con `pos` de `1` a `7` caben exactamente las 7 `protections` por `measurement` fijadas +antes; `pos = 0` deja el `id` libre para su uso normal. Como el resto del protocolo, este +`id` compuesto viaja como un único `uint16` en *little-endian* ^[`pos` no ocupa un byte +aparte: son los 3 bits más altos de ese mismo `uint16`, así que quedan repartidos en el +byte alto junto con los 5 bits más significativos de `id_measurement`.]. + + +\newpage + + +## Refactor de los `messages` + +### Estructura del `message` + +```{mermaid} +%%| label: fig-estructura-message +%%| fig-cap: "Estructura del nuevo message. origin (máx. 48 bytes) y message (máx. 320 bytes) son cadenas terminadas en 0, de longitud variable; aquí se dibujan truncadas a 4 bytes." +%%| fig-width: 5.2 +%%| fig-pos: 'H' +%%{init: {"packet": {"bitWidth": 40, "rowHeight": 34, "bitsPerRow": 10, "showBits": true}}}%% +packet-beta +0-1: "id" +2-2: "hora" +3-3: "min" +4-4: "seg" +5-5: "subseg" +6-9: "origin (txt 0)" +10-13: "message (txt 0)" +``` + +- `id` (2 bytes): tipo de mensaje (`uint16`). Reutiliza `message_ids` de `general_info.json` + (ver más abajo): `10` = `fault`, `11` = `warning`, `12` = `info` y, como tipo nuevo, + `13` = `panic`^[Ya estaba definido en la `ST-LIB` y era enmascarado como un mensaje de `fault`, ahora queda incluido en el `ADJ`]. +- `timestamp` (4 bytes): el mismo formato que en las `protections` — `hora`, `min`, `seg` y + `subseg` en un byte cada uno. +- `origin` (hasta 48 bytes, cadena terminada en `0`): de dónde sale el mensaje. Actualmente + es la ruta absoluta del fichero de origen ^[Sería mejor que fuese la ruta relativa desde + la raíz del repositorio de la placa, no la ruta absoluta del sistema en el que se + compiló.]. +- `message` (hasta 320 bytes, cadena terminada en `0`): contenido del mensaje. + +`origin` y `message` llevan tamaño máximo pero longitud variable: al llegar al byte `0` se +entiende que la cadena ha terminado. + +#### Implementación en `general_info.json` + +Reutilizamos los `message_ids` definidos en `general_info.json`, que por el momento estaban +en desuso: + +``` +{ + "message_ids": { + "fault": 10, + "warning": 11, + "info": 12, + "panic": 13 + } +} +``` + +## Adición de la MAC a la información propia de cada placa + +En vista de lo acontecido en la EHW en H11^[Hubo MAC *curiosas* como `67:67:67:67:67:67` que causaban problemas y horas de *debugging*], se va a añadir un campo `mac` que se corresponde con la `MAC` de cada placa con el objetivo de que se use en la generación de código y sea validado por el `adj-validator`. + + +## Gestión de tiempos y `SNTP` + +Se comenta la posibilidad de realizar un *POC* para mirar la viabilidad de utilizar el protocolo `SNTP` para sincronizar reloj de las placas y el backend. Boris comenta que ya estaba implementado pero que no funcionaba. + +Se ocuparán Víctor y Javier Ribal. Mirar el `README` de `SNTP` en la `ST-LIB`. + + +## Módulo `units` + +Se abandona la implementación del `ADJv2`, se desimplementa `podUnits` y su contraparte en `general_info.json`. Se debate sobre la utilidad del módulo y su practicidad. + +Se acuerda implementarlo añadiendo a cada `measurement` un factor para cada operación. + +## Asignar `id` especiales para la `BLCU` + +Se reserva el rango $[20, 29]$ para los paquetes/órdenes asociados a la `BLCU`. + +## Verificación del `ADJ` *hash* + +Se reserva el `id` 2 para la comparación de commits. + +En caso de que estemos utilizando la `VCU` como `master`, durante la conexión `TCP` compararán el *hash* del `ADJ`; si coinciden todos, se pasará al `backend` (si no, se pasará `hash = 0x00...`). + +En la comparación en el `backend`, si coinciden todos, estará bien; si no, y únicamente si está marcada una nueva opción del `backend`, *Different ADJ hash Fault*, se irá a `fault`; si no, simplemente imprimiremos un `warning`. + + +### Formato + +```{mermaid} +%%| label: fig-formato-hash-adj +%%| fig-cap: "Formato del paquete de verificación del hash del `ADJ`: `id` fijo a `2` (uint16) seguido del `commit` (uint64) del `ADJ` vigente." +%%| fig-width: 5.2 +%%| fig-pos: 'H' +%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% +packet-beta +0-15: "id (=2)" +16-79: "commit" +``` + +- `id` (16 bits): fijo a `2`, identifica este paquete como la comparación de *hash* del `ADJ`. +- `commit` (64 bits): *hash* del commit del `ADJ` que está usando la placa o el `backend`. + + + +## *Custom Keep-alive* + +Mantenemos el estilo: será el `backend` quien envíe el *keep-alive* y nos iremos a `fault` si no se recibe el `ack`. `FW` hará lo propio en las placas. + +Posibilidad de añadir un botón para desactivar el *keep-alive* del `backend`. + +## Propuesta de incorporación del `array` + +Autor: *Víctor López* + +Añadimos un atributo opcional a los measurements 'arrayLength'. + +Este atributo define la longitud de array para el measurement, si el measurement tiene protecciones, se comprobará la protección por cada elemento del array. + +Si se necesita envíar una alerta de que la protección ha detectado un fallo en un elemento del array, se envía en el campo `reserved` del mensaje de la protección el índice del array que ha fallado (empezando por el índice 0). + +Se vería así el paquete de protección entonces: + +```{mermaid} +%%| label: fig-proteccion-array +%%| fig-cap: "Estructura del paquete de una protección sobre un measurement de tipo array. El campo `resv` pasa a contener el índice del elemento que ha fallado (empezando por 0). El campo `value` ocupa el mismo tamaño que el elemento del array; aquí se dibuja con 64 bits." +%%| fig-width: 5.2 +%%| fig-pos: 'H' +%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% +packet-beta +0-15: "id" +16-23: "hora" +24-31: "min" +32-39: "seg" +40-47: "subseg" +48-63: "índice" +64-127: "value" +``` + +```{mermaid} +%%| label: fig-packet-array +%%| fig-cap: "Estructura de un paquete para un measurement que es un array de `uint16` (aquí, con 3 elementos): el `id` seguido de los elementos consecutivos." +%%| fig-width: 5.2 +%%| fig-pos: 'H' +%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% +packet-beta +0-15: "id" +16-31: "value[0]" +32-47: "value[1]" +48-63: "value[2]" +``` + +## Otros apuntes de la reunión + +Por la parte de FW, Víctor y Jorge Canut se muestran conformes con la implementación del refactor en la `ST-LIB`. Por otra parte, Javier Ribal comenta que al rehacerse el `backend` desde cero no ve ningún impedimiento en aplicar estos cambios. + +Primera reunión de Aleksandr Globa y Pablo Pertusa que se incorporan al subsistema de Firmware. + diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs new file mode 100644 index 000000000..c080b036d --- /dev/null +++ b/backend/crates/core/src/adj/error.rs @@ -0,0 +1,145 @@ +//! Errors produced while loading an ADJ. +//! +//! Layered on purpose: [`LoadError`] is the only public error type here — +//! the one a composition root's own aggregate error would `#[from]`-chain +//! into — while [`MeasurementError`] and [`ProtectionError`] stay +//! crate-private. Nothing outside this crate needs to match past +//! `LoadError` into them; `.source()` still returns `&dyn Error` for +//! logging regardless of the concrete type's visibility. + +use std::net::AddrParseError; +use std::path::PathBuf; + +/// An error produced while loading an ADJ directory into an [`crate::adj::Adj`]. +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + /// Couldn't read a file. + #[error("couldn't read {path:?}")] + ReadFile { + /// The file that couldn't be read. + path: PathBuf, + /// The underlying I/O error. + #[source] + source: std::io::Error, + }, + + /// A file's contents weren't valid JSON for the shape expected there. + #[error("couldn't parse {path:?} as JSON")] + ParseJson { + /// The file that failed to parse. + path: PathBuf, + /// The underlying JSON error. + #[source] + source: serde_json::Error, + }, + + /// A board's `board_ip` isn't a valid IP address. + #[error("board {board:?}: invalid IP address {raw:?}")] + InvalidIp { + /// The board whose IP address is invalid. + board: String, + /// The text that failed to parse. + raw: String, + /// The underlying parse error. + #[source] + source: AddrParseError, + }, + + /// A board's `mac` isn't a valid MAC address. + #[error("board {board:?}: invalid MAC address {raw:?}")] + InvalidMac { + /// The board whose MAC address is invalid. + board: String, + /// The text that failed to parse. + raw: String, + }, + + /// A board has no `mac` field at all. + #[error("board {board:?}: missing MAC address")] + MissingMac { + /// The board missing its MAC address. + board: String, + }, + + /// A packet or order declares a `type` other than `"data"` or `"order"`. + #[error("board {board:?}, packet {name:?}: unknown packet type {type_name:?}")] + UnknownPacketType { + /// The board the packet belongs to. + board: String, + /// The packet's name. + name: String, + /// The unrecognized type string. + type_name: String, + }, + + /// A packet or order references a measurement alias that doesn't exist + /// among that board's own measurements. + #[error("board {board:?}, packet {name:?}: unknown measurement alias {alias:?}")] + UnknownAlias { + /// The board the packet belongs to. + board: String, + /// The packet's name. + name: String, + /// The alias that couldn't be resolved. + alias: String, + }, + + /// A measurement couldn't be converted into [`crate::model::Measurement`]. + #[error("board {board:?}, measurement {alias:?}: {source}")] + Measurement { + /// The board the measurement belongs to. + board: String, + /// The measurement's alias. + alias: String, + /// The underlying conversion error. + #[source] + source: MeasurementError, + }, +} + +/// An error converting a raw measurement into [`crate::model::Measurement`]. +#[derive(Debug, thiserror::Error)] +pub enum MeasurementError { + /// The measurement's `type` isn't a recognized wire type, `"bool"` or + /// `"enum"`. + #[error("unknown measurement type {0:?}")] + UnknownType(String), + + /// The measurement's `type` is `"enum"` but no `enumValues` were given. + #[error("type is \"enum\" but no enumValues were given")] + MissingEnumValues, + + /// One of the measurement's declared protections failed to convert. + #[error("protection #{index}: {source}")] + Protection { + /// The protection's 1-based position within the measurement's list. + index: usize, + /// The underlying conversion error. + #[source] + source: ProtectionError, + }, +} + +/// An error converting a raw protection into [`crate::model::Protection`]. +#[derive(Debug, thiserror::Error)] +pub enum ProtectionError { + /// The protection's `type` isn't one of the five known kinds. + #[error("unknown protection type {0:?}")] + UnknownType(String), + + /// The protection's `value` array doesn't have the number of bounds its + /// `type` requires. + #[error("type {kind:?} needs {expected} bound(s), got {got}")] + WrongBoundCount { + /// The protection's type string. + kind: String, + /// How many bounds that type requires. + expected: u8, + /// How many bounds were actually given. + got: usize, + }, + + /// The protection's `time` string couldn't be parsed as a duration. + #[error("couldn't parse time {0:?}")] + InvalidTime(String), +} diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs new file mode 100644 index 000000000..02d9870b9 --- /dev/null +++ b/backend/crates/core/src/adj/info.rs @@ -0,0 +1,37 @@ +//! Backend-session metadata from `general_info.json`. +//! +//! This is not vehicle data — that's [`crate::model::PodData`] — but what +//! *this backend session* needs beyond the vehicle: known ports and +//! addresses, message-id conventions, and how to display a triggered +//! protection. + +use std::collections::HashMap; + +/// A protection type's display text, as declared in `general_info.json`'s +/// `protectionTypes`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProtectionTypeInfo { + /// Whether this protection type takes an interval (`[min, max]`) + /// instead of a single bound. + pub is_range: bool, + /// The message template shown at the control station; `{}` stands for + /// the protection's bound(s), `%` for the value that triggered it. + pub text: String, + /// The alternative template for time-accumulation protections, if one + /// was declared. + pub text_time: Option, +} + +/// Backend-session metadata parsed from `general_info.json`. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct AdjInfo { + /// Known port numbers, by name (e.g. `"TCP_SERVER"` -> `50500`). + pub ports: HashMap, + /// Known addresses, by name (e.g. `"backend"` -> `"192.168.0.9"`), kept + /// as text: nothing in this crate needs them parsed as an `IpAddr` yet. + pub addresses: HashMap, + /// Known message ids, by name (e.g. `"fault"` -> `2`). + pub message_ids: HashMap, + /// Declared protection types, by name (e.g. `"Range"`). + pub protection_types: HashMap, +} diff --git a/backend/crates/core/src/adj/raw.rs b/backend/crates/core/src/adj/raw.rs new file mode 100644 index 000000000..7347e48af --- /dev/null +++ b/backend/crates/core/src/adj/raw.rs @@ -0,0 +1,12 @@ +//! Raw ADJ JSON shapes and the directory-walking loader. +//! +//! Kept together as their own submodule — unlike `error`/`info` — because +//! this whole module is the one piece of `adj` meant to be reused as-is by +//! a future non-`station` product (a sniffer, a simulator...). The +//! individual `RawX` shapes stay private; what's reusable is the +//! [`load_from_dir`] entry point, not picking apart its internals. + +mod loader; +mod types; + +pub use loader::load_from_dir; diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs new file mode 100644 index 000000000..aec8a9934 --- /dev/null +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -0,0 +1,281 @@ +//! Walks an ADJ directory and builds an [`crate::adj::Adj`] from it. + +use super::types::{RawBoard, RawGeneralInfo, RawMeasurement, RawPacket}; +use crate::adj::error::LoadError; +use crate::adj::{Adj, AdjInfo}; +use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, PacketDef, PacketKind, PodData, +}; +use serde::de::DeserializeOwned; +use std::collections::HashMap; +use std::fs; +use std::net::IpAddr; +use std::path::Path; + +/// Loads an ADJ directory rooted at `path` (containing `general_info.json` +/// and `boards.json`) into an [`Adj`]. +/// +/// Boards are read in alphabetical order by name — not `boards.json`'s own +/// unordered map order — so [`PodData::boards`] is deterministic. A board +/// present on disk but absent from `boards.json` is never read; that isn't +/// an error. +pub fn load_from_dir(path: &Path) -> Result { + let general_info: RawGeneralInfo = read_json(&path.join("general_info.json"))?; + let board_paths: HashMap = read_json(&path.join("boards.json"))?; + + let mut entries: Vec<(&String, &String)> = board_paths.iter().collect(); + entries.sort_by_key(|(name, _)| *name); + + let mut boards = Vec::with_capacity(entries.len()); + for (name, rel_path) in entries { + boards.push(load_board(path, name, rel_path)?); + } + + Ok(Adj { + pod_data: PodData { boards }, + info: AdjInfo::from(general_info), + }) +} + +fn read_json(path: &Path) -> Result { + let text = fs::read_to_string(path).map_err(|source| LoadError::ReadFile { + path: path.to_path_buf(), + source, + })?; + serde_json::from_str(&text).map_err(|source| LoadError::ParseJson { + path: path.to_path_buf(), + source, + }) +} + +fn load_board(root: &Path, name: &str, rel_path: &str) -> Result { + let board_file = root.join(rel_path); + let raw_board: RawBoard = read_json(&board_file)?; + let board_dir = board_file + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| root.to_path_buf()); + + let mut measurements: HashMap = HashMap::new(); + for measurements_path in &raw_board.measurements { + let raws: Vec = read_json(&board_dir.join(measurements_path))?; + for raw in raws { + let alias = raw.alias.clone(); + let measurement = + Measurement::try_from(raw).map_err(|source| LoadError::Measurement { + board: name.to_string(), + alias: alias.clone(), + source, + })?; + measurements.insert(alias, measurement); + } + } + + let mut packets = Vec::new(); + for packets_path in &raw_board.packets { + let raws: Vec = read_json(&board_dir.join(packets_path))?; + for raw in raws { + packets.push(build_packet_def(name, raw, &measurements)?); + } + } + + let ip: IpAddr = raw_board + .board_ip + .parse() + .map_err(|source| LoadError::InvalidIp { + board: name.to_string(), + raw: raw_board.board_ip.clone(), + source, + })?; + + let mac_text = raw_board.mac.ok_or_else(|| LoadError::MissingMac { + board: name.to_string(), + })?; + let mac = parse_mac(&mac_text).ok_or_else(|| LoadError::InvalidMac { + board: name.to_string(), + raw: mac_text.clone(), + })?; + + Ok(Board { + id: BoardId(raw_board.board_id), + name: name.to_string(), + ip, + mac, + packets, + }) +} + +fn build_packet_def( + board: &str, + raw: RawPacket, + measurements: &HashMap, +) -> Result { + let kind = match raw.kind.as_str() { + "data" => PacketKind::Data, + "order" => PacketKind::Order, + other => { + return Err(LoadError::UnknownPacketType { + board: board.to_string(), + name: raw.name.clone(), + type_name: other.to_string(), + }); + } + }; + + let mut packet_measurements = Vec::with_capacity(raw.variables.len()); + for alias in &raw.variables { + let measurement = + measurements + .get(alias) + .cloned() + .ok_or_else(|| LoadError::UnknownAlias { + board: board.to_string(), + name: raw.name.clone(), + alias: alias.clone(), + })?; + packet_measurements.push(measurement); + } + + Ok(PacketDef { + id: AdjId(raw.id), + name: raw.name, + kind, + measurements: packet_measurements, + }) +} + +/// Parses a MAC address text form, e.g. `"aa:bb:cc:dd:ee:ff"` or the +/// unpadded `"0:0:0:0:0:0"` real ADJ fixtures actually use — each octet is +/// 1 or 2 hex digits. `None` on anything else. +fn parse_mac(raw: &str) -> Option { + let parts: Vec<&str> = raw.split(':').collect(); + if parts.len() != 6 { + return None; + } + + let mut octets = [0u8; 6]; + for (octet, part) in octets.iter_mut().zip(parts) { + if part.is_empty() || part.len() > 2 { + return None; + } + *octet = u8::from_str_radix(part, 16).ok()?; + } + Some(MacAddress(octets)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adj::ProtectionTypeInfo; + use crate::model::{PacketKind, ProtectionKind, Severity}; + use std::path::Path; + use std::time::Duration; + + fn must_load(path: &Path) -> Adj { + let Ok(adj) = load_from_dir(path) else { + unreachable!("fixture directory should load cleanly"); + }; + adj + } + + #[test] + fn mac_address_accepts_padded_and_unpadded_octets() { + assert_eq!( + parse_mac("aa:bb:cc:dd:ee:ff"), + Some(MacAddress([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])) + ); + assert_eq!(parse_mac("0:0:0:0:0:0"), Some(MacAddress([0; 6]))); + } + + #[test] + fn mac_address_rejects_malformed_text() { + assert_eq!(parse_mac("aa:bb:cc:dd:ee"), None); + assert_eq!(parse_mac("aa:bb:cc:dd:ee:ff:00"), None); + assert_eq!(parse_mac("aaa:bb:cc:dd:ee:ff"), None); + assert_eq!(parse_mac("zz:bb:cc:dd:ee:ff"), None); + } + + #[test] + fn build_packet_def_rejects_unknown_packet_type() { + let raw = RawPacket { + id: 1, + kind: "status".to_string(), + name: "Status".to_string(), + variables: vec![], + }; + assert!(matches!( + build_packet_def("VCU", raw, &HashMap::new()), + Err(LoadError::UnknownPacketType { .. }) + )); + } + + #[test] + fn build_packet_def_rejects_unknown_alias() { + let raw = RawPacket { + id: 1, + kind: "data".to_string(), + name: "Status".to_string(), + variables: vec!["missing".to_string()], + }; + assert!(matches!( + build_packet_def("VCU", raw, &HashMap::new()), + Err(LoadError::UnknownAlias { .. }) + )); + } + + #[test] + fn loads_the_sample_adj_fixture() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adj/test/adj"); + let adj = must_load(&path); + + let board_names: Vec<&str> = adj + .pod_data + .boards + .iter() + .map(|b| b.name.as_str()) + .collect(); + assert_eq!(board_names, vec!["HVBMS", "LCU", "PCU", "VCU"]); + + let Some(vcu) = adj.pod_data.boards.iter().find(|b| b.name == "VCU") else { + unreachable!("VCU should be among the loaded boards"); + }; + assert_eq!(vcu.id, BoardId(3)); + assert_eq!(vcu.ip, IpAddr::from([192, 168, 1, 3])); + assert_eq!(vcu.mac, MacAddress([0; 6])); + assert_eq!(vcu.packets.len(), 18); + assert_eq!(vcu.packets[0].kind, PacketKind::Order); + assert_eq!(vcu.packets[0].id, AdjId(600)); + assert_eq!(vcu.packets[13].kind, PacketKind::Data); + assert_eq!(vcu.packets[13].id, AdjId(625)); + + let Some(pcu) = adj.pod_data.boards.iter().find(|b| b.name == "PCU") else { + unreachable!("PCU should be among the loaded boards"); + }; + let Some(protected) = pcu + .packets + .iter() + .flat_map(|p| &p.measurements) + .find(|m| !m.protections.is_empty()) + else { + unreachable!("PCU should have a measurement with a protection"); + }; + assert_eq!(protected.protections.len(), 1); + let protection = &protected.protections[0]; + assert_eq!(protection.kind, ProtectionKind::Equal { target: 1.0 }); + assert_eq!(protection.severity, Severity::Fault); + assert_eq!(protection.time, Duration::ZERO); + assert_eq!(protection.id, AdjId(1)); + + assert_eq!(adj.info.ports["TCP_SERVER"], 50500); + assert_eq!(adj.info.addresses["backend"], "192.168.0.9"); + assert_eq!(adj.info.message_ids["fault"], 2); + assert_eq!( + adj.info.protection_types["Range"], + ProtectionTypeInfo { + is_range: true, + text: "Value out of range {} (measured: %)".to_string(), + text_time: None, + } + ); + } +} diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs new file mode 100644 index 000000000..6baa15dd2 --- /dev/null +++ b/backend/crates/core/src/adj/raw/types.rs @@ -0,0 +1,470 @@ +//! Raw ADJ JSON shapes and their conversion into `model` types. +//! +//! These shapes mirror the JSON exactly — including the fields this crate +//! intentionally doesn't model yet (`sockets`, `period`, `period_type`, +//! `socket`, `units`, `podUnits`), which are simply absent from these +//! structs and therefore ignored by serde, not rejected. + +use crate::adj::error::{MeasurementError, ProtectionError}; +use crate::adj::info::{AdjInfo, ProtectionTypeInfo}; +use crate::model::{ + AdjId, Measurement, MeasurementKind, NumericKind, Protection, ProtectionKind, Severity, +}; +use serde::Deserialize; +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawGeneralInfo { + pub(crate) ports: HashMap, + pub(crate) addresses: HashMap, + #[serde(rename = "protectionTypes", default)] + pub(crate) protection_types: HashMap, + pub(crate) message_ids: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawProtectionType { + #[serde(default)] + pub(crate) range: bool, + pub(crate) text: String, + #[serde(default, rename = "textTime")] + pub(crate) text_time: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawBoard { + pub(crate) board_id: u16, + #[serde(default)] + pub(crate) mac: Option, + pub(crate) board_ip: String, + #[serde(default)] + pub(crate) measurements: Vec, + #[serde(default)] + pub(crate) packets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawMeasurement { + pub(crate) id: u16, + pub(crate) alias: String, + pub(crate) name: String, + #[serde(rename = "type")] + pub(crate) kind: String, + #[serde(default, rename = "displayUnits")] + pub(crate) display_units: String, + #[serde(default, rename = "enumValues")] + pub(crate) enum_values: Vec, + #[serde(default)] + pub(crate) protections: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawProtection { + #[serde(rename = "type")] + pub(crate) kind: String, + pub(crate) value: Vec, + #[serde(default = "default_fault")] + pub(crate) fault: bool, + #[serde(default)] + pub(crate) time: Option, +} + +fn default_fault() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub(crate) struct RawPacket { + pub(crate) id: u16, + #[serde(rename = "type")] + pub(crate) kind: String, + pub(crate) name: String, + #[serde(default)] + pub(crate) variables: Vec, +} + +impl From for AdjInfo { + fn from(raw: RawGeneralInfo) -> Self { + AdjInfo { + ports: raw.ports, + addresses: raw.addresses, + message_ids: raw.message_ids, + protection_types: raw + .protection_types + .into_iter() + .map(|(name, kind)| { + ( + name, + ProtectionTypeInfo { + is_range: kind.range, + text: kind.text, + text_time: kind.text_time, + }, + ) + }) + .collect(), + } + } +} + +impl TryFrom for Measurement { + type Error = MeasurementError; + + fn try_from(raw: RawMeasurement) -> Result { + let kind = parse_measurement_kind(&raw.kind, raw.enum_values)?; + + let mut protections = Vec::with_capacity(raw.protections.len()); + for (zero_based, raw_protection) in raw.protections.into_iter().enumerate() { + let position = (zero_based + 1) as u16; + let protection = + Protection::try_from((raw_protection, position)).map_err(|source| { + MeasurementError::Protection { + index: position as usize, + source, + } + })?; + protections.push(protection); + } + + Ok(Measurement { + id: AdjId(raw.id), + alias: raw.alias, + name: raw.name, + display_units: raw.display_units, + kind, + protections, + }) + } +} + +impl TryFrom<(RawProtection, u16)> for Protection { + type Error = ProtectionError; + + fn try_from((raw, position): (RawProtection, u16)) -> Result { + let kind = parse_protection_kind(&raw.kind, &raw.value)?; + let time = match &raw.time { + Some(text) => parse_duration(text)?, + None => Duration::ZERO, + }; + + Ok(Protection { + id: AdjId(position), + kind, + severity: if raw.fault { + Severity::Fault + } else { + Severity::Warning + }, + time, + }) + } +} + +fn parse_measurement_kind( + kind: &str, + enum_values: Vec, +) -> Result { + let numeric = |wire_type| MeasurementKind::Numeric { wire_type }; + + match kind { + "uint8" => Ok(numeric(NumericKind::U8)), + "uint16" => Ok(numeric(NumericKind::U16)), + "uint32" => Ok(numeric(NumericKind::U32)), + "uint64" => Ok(numeric(NumericKind::U64)), + "int8" => Ok(numeric(NumericKind::I8)), + "int16" => Ok(numeric(NumericKind::I16)), + "int32" => Ok(numeric(NumericKind::I32)), + "int64" => Ok(numeric(NumericKind::I64)), + "float32" => Ok(numeric(NumericKind::F32)), + "float64" => Ok(numeric(NumericKind::F64)), + "bool" => Ok(MeasurementKind::Boolean), + "enum" if !enum_values.is_empty() => Ok(MeasurementKind::Enum { + options: enum_values, + }), + "enum" => Err(MeasurementError::MissingEnumValues), + other => Err(MeasurementError::UnknownType(other.to_string())), + } +} + +fn parse_protection_kind(kind: &str, value: &[f64]) -> Result { + fn one(kind: &str, value: &[f64]) -> Result { + match value { + [bound] => Ok(*bound), + other => Err(ProtectionError::WrongBoundCount { + kind: kind.to_string(), + expected: 1, + got: other.len(), + }), + } + } + + fn two(kind: &str, value: &[f64]) -> Result<(f64, f64), ProtectionError> { + match value { + [min, max] => Ok((*min, *max)), + other => Err(ProtectionError::WrongBoundCount { + kind: kind.to_string(), + expected: 2, + got: other.len(), + }), + } + } + + match kind { + "Above" => Ok(ProtectionKind::Above { + limit: one(kind, value)?, + }), + "Below" => Ok(ProtectionKind::Below { + limit: one(kind, value)?, + }), + "Equal" => Ok(ProtectionKind::Equal { + target: one(kind, value)?, + }), + "NotEqual" => Ok(ProtectionKind::NotEqual { + target: one(kind, value)?, + }), + "Range" => { + let (min, max) = two(kind, value)?; + Ok(ProtectionKind::Range { min, max }) + } + other => Err(ProtectionError::UnknownType(other.to_string())), + } +} + +/// Parses a duration string like `"500ms"`, `"3s"` or `"10us"`. +/// +/// Rejects non-finite or negative numbers explicitly before handing them to +/// [`Duration::from_secs_f64`], which panics on either — this reads +/// externally-authored ADJ text, so a malformed `"time"` (e.g. `"-5ms"`) +/// must become an error, not a panic. +fn parse_duration(text: &str) -> Result { + let Some(split_at) = text.find(|c: char| c.is_ascii_alphabetic()) else { + return Err(ProtectionError::InvalidTime(text.to_string())); + }; + let (number, unit) = text.split_at(split_at); + + let value: f64 = number + .parse() + .map_err(|_| ProtectionError::InvalidTime(text.to_string()))?; + + if !value.is_finite() || value < 0.0 { + return Err(ProtectionError::InvalidTime(text.to_string())); + } + + let seconds = match unit { + "s" => value, + "ms" => value / 1_000.0, + "us" => value / 1_000_000.0, + _ => return Err(ProtectionError::InvalidTime(text.to_string())), + }; + + Ok(Duration::from_secs_f64(seconds)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn raw_protection( + kind: &str, + value: Vec, + fault: bool, + time: Option<&str>, + ) -> RawProtection { + RawProtection { + kind: kind.to_string(), + value, + fault, + time: time.map(str::to_string), + } + } + + fn must_build_protection(raw: RawProtection, position: u16) -> Protection { + let Ok(protection) = Protection::try_from((raw, position)) else { + unreachable!("valid protection should convert cleanly"); + }; + protection + } + + #[test] + fn numeric_measurement_types_map_to_their_numeric_kind() { + let cases = [ + ("uint8", NumericKind::U8), + ("uint16", NumericKind::U16), + ("uint32", NumericKind::U32), + ("uint64", NumericKind::U64), + ("int8", NumericKind::I8), + ("int16", NumericKind::I16), + ("int32", NumericKind::I32), + ("int64", NumericKind::I64), + ("float32", NumericKind::F32), + ("float64", NumericKind::F64), + ]; + + for (type_name, expected) in cases { + assert!(matches!( + parse_measurement_kind(type_name, vec![]), + Ok(MeasurementKind::Numeric { wire_type }) if wire_type == expected + )); + } + } + + #[test] + fn bool_type_maps_to_boolean() { + assert!(matches!( + parse_measurement_kind("bool", vec![]), + Ok(MeasurementKind::Boolean) + )); + } + + #[test] + fn enum_type_with_values_keeps_their_order() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + assert!(matches!( + parse_measurement_kind("enum", options.clone()), + Ok(MeasurementKind::Enum { options: got }) if got == options + )); + } + + #[test] + fn enum_type_without_values_is_an_error() { + assert!(matches!( + parse_measurement_kind("enum", vec![]), + Err(MeasurementError::MissingEnumValues) + )); + } + + #[test] + fn unknown_measurement_type_is_an_error() { + assert!(matches!( + parse_measurement_kind("potato", vec![]), + Err(MeasurementError::UnknownType(t)) if t == "potato" + )); + } + + #[test] + fn one_bound_protection_kinds_take_a_single_value() { + assert!(matches!( + parse_protection_kind("Above", &[1.0]), + Ok(ProtectionKind::Above { limit }) if limit == 1.0 + )); + assert!(matches!( + parse_protection_kind("Below", &[2.0]), + Ok(ProtectionKind::Below { limit }) if limit == 2.0 + )); + assert!(matches!( + parse_protection_kind("Equal", &[3.0]), + Ok(ProtectionKind::Equal { target }) if target == 3.0 + )); + assert!(matches!( + parse_protection_kind("NotEqual", &[4.0]), + Ok(ProtectionKind::NotEqual { target }) if target == 4.0 + )); + } + + #[test] + fn range_protection_takes_two_values() { + assert!(matches!( + parse_protection_kind("Range", &[0.0, 48.0]), + Ok(ProtectionKind::Range { min, max }) if min == 0.0 && max == 48.0 + )); + } + + #[test] + fn wrong_bound_count_is_an_error() { + assert!(matches!( + parse_protection_kind("Above", &[1.0, 2.0]), + Err(ProtectionError::WrongBoundCount { + expected: 1, + got: 2, + .. + }) + )); + assert!(matches!( + parse_protection_kind("Range", &[1.0]), + Err(ProtectionError::WrongBoundCount { + expected: 2, + got: 1, + .. + }) + )); + } + + #[test] + fn unknown_protection_type_is_an_error() { + assert!(matches!( + parse_protection_kind("Potato", &[1.0]), + Err(ProtectionError::UnknownType(t)) if t == "Potato" + )); + } + + #[test] + fn fault_flag_selects_severity_default_true() { + let protection = must_build_protection(raw_protection("Equal", vec![1.0], true, None), 1); + assert_eq!(protection.severity, Severity::Fault); + + let protection = must_build_protection(raw_protection("Equal", vec![1.0], false, None), 1); + assert_eq!(protection.severity, Severity::Warning); + } + + #[test] + fn protection_id_is_its_position_not_a_global_id() { + let protection = must_build_protection(raw_protection("Equal", vec![1.0], true, None), 3); + assert_eq!(protection.id, AdjId(3)); + } + + #[test] + fn missing_time_defaults_to_zero() { + let protection = must_build_protection(raw_protection("Equal", vec![1.0], true, None), 1); + assert_eq!(protection.time, Duration::ZERO); + } + + #[test] + fn duration_strings_parse_with_their_unit() { + assert!(matches!(parse_duration("500ms"), Ok(d) if d == Duration::from_millis(500))); + assert!(matches!(parse_duration("3s"), Ok(d) if d == Duration::from_secs(3))); + assert!(matches!(parse_duration("10us"), Ok(d) if d == Duration::from_micros(10))); + } + + #[test] + fn invalid_duration_strings_are_rejected() { + assert!(matches!( + parse_duration("banana"), + Err(ProtectionError::InvalidTime(_)) + )); + assert!(matches!( + parse_duration("-5ms"), + Err(ProtectionError::InvalidTime(_)) + )); + assert!(matches!( + parse_duration("5"), + Err(ProtectionError::InvalidTime(_)) + )); + } + + #[test] + fn general_info_converts_into_adj_info() { + let mut protection_types = HashMap::new(); + protection_types.insert( + "Range".to_string(), + RawProtectionType { + range: true, + text: "Value out of range {} (measured: %)".to_string(), + text_time: None, + }, + ); + let raw = RawGeneralInfo { + ports: HashMap::from([("TCP_SERVER".to_string(), 50500u16)]), + addresses: HashMap::from([("backend".to_string(), "192.168.0.9".to_string())]), + protection_types, + message_ids: HashMap::from([("fault".to_string(), 2u16)]), + }; + + let info = AdjInfo::from(raw); + + assert_eq!(info.ports["TCP_SERVER"], 50500); + assert_eq!(info.addresses["backend"], "192.168.0.9"); + assert_eq!(info.message_ids["fault"], 2); + assert!(info.protection_types["Range"].is_range); + } +} diff --git a/backend/crates/core/src/adj/test/adj/README.md b/backend/crates/core/src/adj/test/adj/README.md new file mode 100644 index 000000000..453182930 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/README.md @@ -0,0 +1,291 @@ +# ADJ v2 Specification + +This document defines the ADJ v2 configuration format used by the Hyperloop Control Station. The configuration is organized as a distributed collection of JSON files that define boards, measurements, packets, and general system information. + +## File Structure + +The ADJ configuration is organized in the following directory structure: + +``` +adj/ +├── general_info.json # System-wide configuration +├── boards.json # Board list and path mappings +└── boards/ # Board-specific configurations + └── {board_name}/ + ├── {board_name}.json # Main board configuration + ├── {board_name}_measurements.json # Board measurements + ├── packets.json # Data packets + ├── orders.json # Order packets + └── sockets.json # Board sockets +``` + +## Data Types + +### General Info (`general_info.json`) + +System-wide configuration including ports, addresses, units, and message IDs. + +```json +{ + "ports": { + "string": "number" + }, + "addresses": { + "string": "string" + }, + "units": { + "string": "string" + }, + "message_ids": { + "string": "number" + } +} +``` + +**Example:** +```json +{ + "ports": { + "main": 8080, + "data": 8081 + }, + "addresses": { + "primary": "192.168.1.100", + "secondary": "192.168.1.101" + }, + "units": { + "pressure": "PSI", + "temperature": "°C", + "voltage": "V" + }, + "message_ids": { + "status": 1, + "data": 2 + } +} +``` + +### Board List (`boards.json`) + +Mapping of board names to their configuration file paths. + +```json +{ + "string": "string" +} +``` + +**Example:** +```json +{ + "brake_board": "boards/brake_board/brake_board.json", + "sensor_board": "boards/sensor_board/sensor_board.json" +} +``` + +### Board Configuration (`{board_name}.json`) + +Main board configuration including ID, IP address, and references to measurements, packets and sockets files. + +```json +{ + "board_id": "number", + "board_ip": "string", + "measurements": ["string"], + "packets": ["string"], + "sockets": ["string"] +} +``` + +**Field Descriptions:** +- `board_id`: Unique 32-bit unsigned integer identifier for the board +- `board_ip`: IP address string for network communication +- `measurements`: Array of measurement file paths relative to board directory +- `packets`: Array of packet file paths relative to board directory + +**Example:** +```json +{ + "board_id": 1001, + "board_ip": "192.168.1.10", + "measurements": ["brake_board_measurements.json"], + "packets": ["packets.json", "orders.json"], + "sockets":["sockets.json"] +} +``` + +### Measurements (`{board_name}_measurements.json`) + +Array of measurement definitions for data collection and monitoring. + +```json +[ + { + "id": "string", + "name": "string", + "type": "string", + "podUnits": "string?", + "displayUnits": "string?", + "enumValues": ["string"]?, + "safeRange": "[number, number]?", + "warningRange": "[number, number]?" + } +] +``` + +**Field Descriptions:** +- `id`: Unique string identifier for the measurement +- `name`: Human-readable display name +- `type`: Data type - one of: `uint8`, `uint16`, `uint32`, `uint64`, `int8`, `int16`, `int32`, `int64`, `float32`, `float64` +- `podUnits`: Optional pod-side units (references `general_info.units`) +- `displayUnits`: Optional display units (references `general_info.units`) +- `enumValues`: Optional array of enumeration values for discrete measurements +- `safeRange`: Optional two-element array `[min, max]` defining safe operating range +- `warningRange`: Optional two-element array `[min, max]` defining warning thresholds + +**Example:** +```json +[ + { + "id": "brake_pressure", + "name": "Brake Pressure", + "type": "float32", + "podUnits": "PSI", + "displayUnits": "PSI", + "safeRange": [0.0, 100.0], + "warningRange": [80.0, 95.0] + }, + { + "id": "brake_status", + "name": "Brake Status", + "type": "uint8", + "enumValues": ["released", "engaged", "error"] + } +] +``` + +### Packets (`packets.json`, `orders.json`) + +Array of packet definitions for network communication. Packets are separated by type: +- `packets.json`: Data packets for telemetry and status +- `orders.json`: Command packets for control operations + +```json +[ + { + "id": "number?", + "type": "string", + "name": "string", + "variables": ["string"], + "period_type": "us/ms?", + "period": "number?", + "socket": "string?" + } +] +``` + +**Field Descriptions:** +- `id`: Optional 32-bit unsigned integer packet identifier +- `type`: Packet type string (e.g., "data", "order", "status") +- `name`: Human-readable packet name +- `variables`: Array of variable names/measurement IDs included in this packet +- `period_type`: Optional string specifying the type of measurement for period, either microseconds +or milliseconds +- `period`: Optional number specifying the transmission period in milliseconds or microseconds for periodic packets. Can be an integer or floating-point value +- `socket`: Optinal string that should match the name of the socket you want to transmit this packet trhough + +**Example:** +```json +[ + { + "id": 2001, + "type": "data", + "name": "Brake Telemetry", + "variables": ["brake_pressure", "brake_status", "brake_temperature"], + "period_type":"ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "order", + "name": "Brake Command", + "variables": ["brake_command", "target_pressure"] + } +] +``` + +### Sockets (`sockets.json`) + +Array of socket definitions for network communication. + +```json +{ + "type": "string", + "name": "string", + "port": "number?", + "local_port":"number?", + "remote_ip": "string?", + "remote_port":"number?" + +} +``` + +**Field Descriptions:** +- `type`: Socket type string(e.g., "ServerSocket","DatagramSocket","Socket") +- `name`: Socket name +- `port`: Optional number that describes the port number used for a ServerSocket or a DatagramSocket +- `local_port`: Optional number that describes the local port number used for a Socket +- `remote_ip`: Optional string that describes the remote ip you want to connect to in a DatagramSocket or Socket +- `remote_port`: Optional number that describes the remote port number used for a Socket + +**Example:** +```json +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip":"192.168.0.9", + "port": 50400 + }, + + { + "type": "Socket", + "name": "pcu_tcp", + "local_port": 50501, + "remote_ip": "192.168.1.5", + "remote_port": 50500 + } +] +``` + +## Configuration Rules + +1. **Naming Convention**: Board directories and main configuration files must use the same name as the board key in `boards.json` + +2. **File References**: All file paths in board configurations are relative to the board's directory + +3. **Measurement References**: Packet `variables` arrays should reference measurement `id` values defined in the board's measurements files + +4. **Unit References**: Measurement `podUnits` and `displayUnits` should reference keys defined in `general_info.units` + +5. **ID Uniqueness**: Board IDs must be unique across all boards. Packet IDs should be unique within their type category. + +6. **Socket Naming**: The sockets referenced inside the packet definitions must use exact names as defined in `socket.json`. + +## Validation Notes + +- All numeric fields use standard JSON number format +- Optional fields may be omitted entirely or set to `null` +- Empty arrays are permitted for measurements and packets +- File paths use forward slashes (`/`) regardless of operating system +- IP addresses must be valid IPv4 format strings + +## Backward Compatibility + +This specification maintains compatibility with existing ADJ v1 configurations while supporting the new distributed file structure introduced in v2. The system can automatically migrate v1 configurations to the v2 format. diff --git a/backend/crates/core/src/adj/test/adj/boards.json b/backend/crates/core/src/adj/test/adj/boards.json new file mode 100644 index 000000000..9de2c650a --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards.json @@ -0,0 +1,6 @@ +{ + "VCU": "boards/VCU/VCU.json", + "LCU": "boards/LCU/LCU.json", + "PCU": "boards/PCU/PCU.json", + "HVBMS": "boards/HVBMS/HVBMS.json" +} diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json new file mode 100644 index 000000000..2c98964c1 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU.json @@ -0,0 +1,12 @@ +{ + "board_id": 27, + "board_ip": "192.168.0.27", + "measurements": ["BLCU_measurements.json"], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json new file mode 100644 index 000000000..f44182d73 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/BLCU_measurements.json @@ -0,0 +1,34 @@ +[ + { + "id": "target", + "name": "target", + "type": "enum", + "enumValues": [ + "VCU", + "HVBMS", + "LVBMS", + "PCU", + "LCU" + ] + }, + { + "id": "general_state_machine", + "name": "General State Machine", + "type": "enum", + "enumValues": [ + "Connecting", + "Operational", + "Fault" + ] + }, + { + "id": "operational_state_machine", + "name": "Operational state machine", + "type": "enum", + "enumValues": [ + "Idle", + "Flashing" + ] + } + +] diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json new file mode 100644 index 000000000..1a7167d60 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/orders.json @@ -0,0 +1,15 @@ +[ + { + "id": 701, + "type": "order", + "name": "Reset All" + }, + { + "id":700, + "type": "order", + "name":"Write Program", + "variables":[ + "target" + ] + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json new file mode 100644 index 000000000..4674afdc2 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/packets.json @@ -0,0 +1,28 @@ +[ + { + "id":3, + "type": "packet", + "name":"ack" + }, + { + "id":4, + "type": "packet", + "name":"nack" + }, + + { + "id": 1, + "type": "packet", + "name": "state_machine_state", + "variables": + [ + "general_state_machine", + "operational_state_machine" + ], + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + + } + +] diff --git a/backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json new file mode 100644 index 000000000..173e674e2 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/BLCU/sockets.json @@ -0,0 +1,14 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip":"backend", + "port": 50420 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json new file mode 100644 index 000000000..0022eb5ec --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS.json @@ -0,0 +1,15 @@ +{ + "board_id": 7, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.7", + "measurements": [ + "HVBMS_measurements.json" + ], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json new file mode 100644 index 000000000..d31af6972 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/HVBMS_measurements.json @@ -0,0 +1,1102 @@ +[ + { + "id": 2005, + "alias": "battery1_cell1", + "name": "Battery 1 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2006, + "alias": "battery1_cell2", + "name": "Battery 1 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2007, + "alias": "battery1_cell3", + "name": "Battery 1 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2008, + "alias": "battery1_cell4", + "name": "Battery 1 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2009, + "alias": "battery1_cell5", + "name": "Battery 1 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2010, + "alias": "battery1_cell6", + "name": "Battery 1 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2011, + "alias": "battery1_cell7", + "name": "Battery 1 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2012, + "alias": "battery1_cell8", + "name": "Battery 1 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2013, + "alias": "battery1_cell9", + "name": "Battery 1 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2014, + "alias": "battery1_cell10", + "name": "Battery 1 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2015, + "alias": "battery1_cell11", + "name": "Battery 1 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2016, + "alias": "battery1_cell12", + "name": "Battery 1 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2017, + "alias": "battery1_total_voltage", + "name": "Battery 1 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2018, + "alias": "battery1_temp1", + "name": "Battery 1 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2019, + "alias": "battery1_temp2", + "name": "Battery 1 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2020, + "alias": "battery1_temp3", + "name": "Battery 1 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2021, + "alias": "battery1_temp4", + "name": "Battery 1 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2022, + "alias": "battery2_cell1", + "name": "Battery 2 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2023, + "alias": "battery2_cell2", + "name": "Battery 2 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2024, + "alias": "battery2_cell3", + "name": "Battery 2 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2025, + "alias": "battery2_cell4", + "name": "Battery 2 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2026, + "alias": "battery2_cell5", + "name": "Battery 2 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2027, + "alias": "battery2_cell6", + "name": "Battery 2 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2028, + "alias": "battery2_cell7", + "name": "Battery 2 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2029, + "alias": "battery2_cell8", + "name": "Battery 2 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2030, + "alias": "battery2_cell9", + "name": "Battery 2 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2031, + "alias": "battery2_cell10", + "name": "Battery 2 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2032, + "alias": "battery2_cell11", + "name": "Battery 2 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2033, + "alias": "battery2_cell12", + "name": "Battery 2 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2034, + "alias": "battery2_total_voltage", + "name": "Battery 2 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2035, + "alias": "battery2_temp1", + "name": "Battery 2 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2036, + "alias": "battery2_temp2", + "name": "Battery 2 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2037, + "alias": "battery2_temp3", + "name": "Battery 2 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2038, + "alias": "battery2_temp4", + "name": "Battery 2 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2039, + "alias": "battery3_cell1", + "name": "Battery 3 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2040, + "alias": "battery3_cell2", + "name": "Battery 3 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2041, + "alias": "battery3_cell3", + "name": "Battery 3 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2042, + "alias": "battery3_cell4", + "name": "Battery 3 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2043, + "alias": "battery3_cell5", + "name": "Battery 3 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2044, + "alias": "battery3_cell6", + "name": "Battery 3 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2045, + "alias": "battery3_cell7", + "name": "Battery 3 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2046, + "alias": "battery3_cell8", + "name": "Battery 3 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2047, + "alias": "battery3_cell9", + "name": "Battery 3 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2048, + "alias": "battery3_cell10", + "name": "Battery 3 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2049, + "alias": "battery3_cell11", + "name": "Battery 3 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2050, + "alias": "battery3_cell12", + "name": "Battery 3 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2051, + "alias": "battery3_total_voltage", + "name": "Battery 3 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2052, + "alias": "battery3_temp1", + "name": "Battery 3 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2053, + "alias": "battery3_temp2", + "name": "Battery 3 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2054, + "alias": "battery3_temp3", + "name": "Battery 3 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2055, + "alias": "battery3_temp4", + "name": "Battery 3 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2056, + "alias": "battery4_cell1", + "name": "Battery 4 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2057, + "alias": "battery4_cell2", + "name": "Battery 4 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2058, + "alias": "battery4_cell3", + "name": "Battery 4 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2059, + "alias": "battery4_cell4", + "name": "Battery 4 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2060, + "alias": "battery4_cell5", + "name": "Battery 4 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2061, + "alias": "battery4_cell6", + "name": "Battery 4 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2062, + "alias": "battery4_cell7", + "name": "Battery 4 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2063, + "alias": "battery4_cell8", + "name": "Battery 4 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2064, + "alias": "battery4_cell9", + "name": "Battery 4 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2065, + "alias": "battery4_cell10", + "name": "Battery 4 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2066, + "alias": "battery4_cell11", + "name": "Battery 4 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2067, + "alias": "battery4_cell12", + "name": "Battery 4 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2068, + "alias": "battery4_total_voltage", + "name": "Battery 4 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2069, + "alias": "battery4_temp1", + "name": "Battery 4 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2070, + "alias": "battery4_temp2", + "name": "Battery 4 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2071, + "alias": "battery4_temp3", + "name": "Battery 4 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2072, + "alias": "battery4_temp4", + "name": "Battery 4 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2073, + "alias": "battery5_cell1", + "name": "Battery 5 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2074, + "alias": "battery5_cell2", + "name": "Battery 5 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2075, + "alias": "battery5_cell3", + "name": "Battery 5 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2076, + "alias": "battery5_cell4", + "name": "Battery 5 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2077, + "alias": "battery5_cell5", + "name": "Battery 5 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2078, + "alias": "battery5_cell6", + "name": "Battery 5 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2079, + "alias": "battery5_cell7", + "name": "Battery 5 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2080, + "alias": "battery5_cell8", + "name": "Battery 5 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2081, + "alias": "battery5_cell9", + "name": "Battery 5 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2082, + "alias": "battery5_cell10", + "name": "Battery 5 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2083, + "alias": "battery5_cell11", + "name": "Battery 5 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2084, + "alias": "battery5_cell12", + "name": "Battery 5 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2085, + "alias": "battery5_total_voltage", + "name": "Battery 5 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2086, + "alias": "battery5_temp1", + "name": "Battery 5 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2087, + "alias": "battery5_temp2", + "name": "Battery 5 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2088, + "alias": "battery5_temp3", + "name": "Battery 5 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2089, + "alias": "battery5_temp4", + "name": "Battery 5 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2090, + "alias": "battery6_cell1", + "name": "Battery 6 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2091, + "alias": "battery6_cell2", + "name": "Battery 6 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2092, + "alias": "battery6_cell3", + "name": "Battery 6 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2093, + "alias": "battery6_cell4", + "name": "Battery 6 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2094, + "alias": "battery6_cell5", + "name": "Battery 6 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2095, + "alias": "battery6_cell6", + "name": "Battery 6 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2096, + "alias": "battery6_cell7", + "name": "Battery 6 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2097, + "alias": "battery6_cell8", + "name": "Battery 6 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2098, + "alias": "battery6_cell9", + "name": "Battery 6 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2099, + "alias": "battery6_cell10", + "name": "Battery 6 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2100, + "alias": "battery6_cell11", + "name": "Battery 6 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2101, + "alias": "battery6_cell12", + "name": "Battery 6 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2102, + "alias": "battery6_total_voltage", + "name": "Battery 6 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2103, + "alias": "battery6_temp1", + "name": "Battery 6 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2104, + "alias": "battery6_temp2", + "name": "Battery 6 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2105, + "alias": "battery6_temp3", + "name": "Battery 6 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2106, + "alias": "battery6_temp4", + "name": "Battery 6 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2107, + "alias": "battery7_cell1", + "name": "Battery 7 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2108, + "alias": "battery7_cell2", + "name": "Battery 7 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2109, + "alias": "battery7_cell3", + "name": "Battery 7 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2110, + "alias": "battery7_cell4", + "name": "Battery 7 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2111, + "alias": "battery7_cell5", + "name": "Battery 7 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2112, + "alias": "battery7_cell6", + "name": "Battery 7 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2113, + "alias": "battery7_cell7", + "name": "Battery 7 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2114, + "alias": "battery7_cell8", + "name": "Battery 7 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2115, + "alias": "battery7_cell9", + "name": "Battery 7 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2116, + "alias": "battery7_cell10", + "name": "Battery 7 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2117, + "alias": "battery7_cell11", + "name": "Battery 7 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2118, + "alias": "battery7_cell12", + "name": "Battery 7 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2119, + "alias": "battery7_total_voltage", + "name": "Battery 7 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2120, + "alias": "battery7_temp1", + "name": "Battery 7 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2121, + "alias": "battery7_temp2", + "name": "Battery 7 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2122, + "alias": "battery7_temp3", + "name": "Battery 7 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2123, + "alias": "battery7_temp4", + "name": "Battery 7 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2124, + "alias": "battery8_cell1", + "name": "Battery 8 Cell 1", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2125, + "alias": "battery8_cell2", + "name": "Battery 8 Cell 2", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2126, + "alias": "battery8_cell3", + "name": "Battery 8 Cell 3", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2127, + "alias": "battery8_cell4", + "name": "Battery 8 Cell 4", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2128, + "alias": "battery8_cell5", + "name": "Battery 8 Cell 5", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2129, + "alias": "battery8_cell6", + "name": "Battery 8 Cell 6", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2130, + "alias": "battery8_cell7", + "name": "Battery 8 Cell 7", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2131, + "alias": "battery8_cell8", + "name": "Battery 8 Cell 8", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2132, + "alias": "battery8_cell9", + "name": "Battery 8 Cell 9", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2133, + "alias": "battery8_cell10", + "name": "Battery 8 Cell 10", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2134, + "alias": "battery8_cell11", + "name": "Battery 8 Cell 11", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2135, + "alias": "battery8_cell12", + "name": "Battery 8 Cell 12", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2136, + "alias": "battery8_total_voltage", + "name": "Battery 8 Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2137, + "alias": "battery8_temp1", + "name": "Battery 8 Temperature 1", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2138, + "alias": "battery8_temp2", + "name": "Battery 8 Temperature 2", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2139, + "alias": "battery8_temp3", + "name": "Battery 8 Temperature 3", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2140, + "alias": "battery8_temp4", + "name": "Battery 8 Temperature 4", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2141, + "alias": "batteries_voltage_reading", + "name": "Batteries Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2142, + "alias": "voltage_reading", + "name": "Voltage Sensor", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2143, + "alias": "current_reading", + "name": "Current Sensor", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 2144, + "alias": "sdc_status", + "name": "SDC Status", + "type": "enum", + "enumValues": [ + "ENGAGED", + "DISENGAGED" + ] + }, + { + "id": 2145, + "alias": "voltage_min", + "name": "Minimum Cell Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2146, + "alias": "voltage_max", + "name": "Maximum Cell Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 2147, + "alias": "temp_min", + "name": "Minimum Temperature", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2148, + "alias": "temp_max", + "name": "Maximum Temperature", + "type": "float32", + "displayUnits": "ºC" + }, + { + "id": 2149, + "alias": "imd_status", + "name": "IMD Status", + "type": "enum", + "enumValues": [ + "SHORTCIRCUIT", + "NORMAL", + "UNDERVOLTAGE", + "FAST_EVAL", + "EQUIPMENT_FAULT", + "GROUNDING_FAULT" + ] + }, + { + "id": 2150, + "alias": "imd_resistance", + "name": "IMD Resistance", + "type": "float32", + "displayUnits": "MOhm" + }, + { + "id": 2151, + "alias": "imd_is_ok", + "name": "IMD OK", + "type": "bool" + }, + { + "id": 2152, + "alias": "imd_duty", + "name": "IMD Duty", + "type": "float32" + }, + { + "id": 2153, + "alias": "imd_freq", + "name": "IMD Frequency", + "type": "float32" + }, + { + "id": 2154, + "alias": "contactor_precharge", + "name": "Contactor Precharge", + "type": "bool" + }, + { + "id": 2155, + "alias": "contactor_discharge", + "name": "Contactor Discharge", + "type": "bool" + }, + { + "id": 2156, + "alias": "contactor_high", + "name": "Contactor High", + "type": "bool" + }, + { + "id": 2157, + "alias": "contactor_low", + "name": "Contactor Low", + "type": "bool" + }, + { + "id": 2158, + "alias": "contactor_common_high", + "name": "Contactor Common High", + "type": "bool" + }, + { + "id": 2159, + "alias": "soc", + "name": "SOC", + "type": "float32" + }, + { + "id": 2160, + "alias": "sm_status", + "name": "State Machine Status", + "type": "enum", + "enumValues": [ + "Connecting", + "Idle", + "Ready To Precharge", + "Precharging", + "Energized", + "FAULT" + ] + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json new file mode 100644 index 000000000..fe54b536a --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/orders.json @@ -0,0 +1,32 @@ +[ + { + "type": "order", + "name": "FAULT", + "variables": [], + "id": 2000 + }, + { + "type": "order", + "name": "Open Contactors", + "variables": [], + "id": 2001 + }, + { + "type": "order", + "name": "Cell Balance", + "variables": [], + "id": 2002 + }, + { + "type": "order", + "name": "Start Precharge", + "variables": [], + "id": 2003 + }, + { + "type": "order", + "name": "Check Faults", + "variables": [], + "id": 2004 + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json new file mode 100644 index 000000000..01a841af3 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/packets.json @@ -0,0 +1,218 @@ +[ + { + "type": "data", + "name": "High Voltage System", + "variables": [ + "voltage_min", + "voltage_max", + "temp_min", + "temp_max", + "current_reading", + "voltage_reading", + "batteries_voltage_reading", + "sm_status" + ], + "id": 2161, + "period": 10, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "SOC", + "variables": [ + "soc" + ], + "id": 2162, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "SDC", + "variables": [ + "sdc_status" + ], + "id": 2163, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "High Voltage Batteries", + "variables": [ + "battery1_cell1", + "battery1_cell2", + "battery1_cell3", + "battery1_cell4", + "battery1_cell5", + "battery1_cell6", + "battery1_cell7", + "battery1_cell8", + "battery1_cell9", + "battery1_cell10", + "battery1_cell11", + "battery1_cell12", + "battery1_total_voltage", + "battery1_temp1", + "battery1_temp2", + "battery1_temp3", + "battery1_temp4", + "battery2_cell1", + "battery2_cell2", + "battery2_cell3", + "battery2_cell4", + "battery2_cell5", + "battery2_cell6", + "battery2_cell7", + "battery2_cell8", + "battery2_cell9", + "battery2_cell10", + "battery2_cell11", + "battery2_cell12", + "battery2_total_voltage", + "battery2_temp1", + "battery2_temp2", + "battery2_temp3", + "battery2_temp4", + "battery3_cell1", + "battery3_cell2", + "battery3_cell3", + "battery3_cell4", + "battery3_cell5", + "battery3_cell6", + "battery3_cell7", + "battery3_cell8", + "battery3_cell9", + "battery3_cell10", + "battery3_cell11", + "battery3_cell12", + "battery3_total_voltage", + "battery3_temp1", + "battery3_temp2", + "battery3_temp3", + "battery3_temp4", + "battery4_cell1", + "battery4_cell2", + "battery4_cell3", + "battery4_cell4", + "battery4_cell5", + "battery4_cell6", + "battery4_cell7", + "battery4_cell8", + "battery4_cell9", + "battery4_cell10", + "battery4_cell11", + "battery4_cell12", + "battery4_total_voltage", + "battery4_temp1", + "battery4_temp2", + "battery4_temp3", + "battery4_temp4", + "battery5_cell1", + "battery5_cell2", + "battery5_cell3", + "battery5_cell4", + "battery5_cell5", + "battery5_cell6", + "battery5_cell7", + "battery5_cell8", + "battery5_cell9", + "battery5_cell10", + "battery5_cell11", + "battery5_cell12", + "battery5_total_voltage", + "battery5_temp1", + "battery5_temp2", + "battery5_temp3", + "battery5_temp4", + "battery6_cell1", + "battery6_cell2", + "battery6_cell3", + "battery6_cell4", + "battery6_cell5", + "battery6_cell6", + "battery6_cell7", + "battery6_cell8", + "battery6_cell9", + "battery6_cell10", + "battery6_cell11", + "battery6_cell12", + "battery6_total_voltage", + "battery6_temp1", + "battery6_temp2", + "battery6_temp3", + "battery6_temp4", + "battery7_cell1", + "battery7_cell2", + "battery7_cell3", + "battery7_cell4", + "battery7_cell5", + "battery7_cell6", + "battery7_cell7", + "battery7_cell8", + "battery7_cell9", + "battery7_cell10", + "battery7_cell11", + "battery7_cell12", + "battery7_total_voltage", + "battery7_temp1", + "battery7_temp2", + "battery7_temp3", + "battery7_temp4", + "battery8_cell1", + "battery8_cell2", + "battery8_cell3", + "battery8_cell4", + "battery8_cell5", + "battery8_cell6", + "battery8_cell7", + "battery8_cell8", + "battery8_cell9", + "battery8_cell10", + "battery8_cell11", + "battery8_cell12", + "battery8_total_voltage", + "battery8_temp1", + "battery8_temp2", + "battery8_temp3", + "battery8_temp4" + ], + "id": 2164, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "IMD", + "variables": [ + "imd_status", + "imd_resistance", + "imd_is_ok", + "imd_duty", + "imd_freq" + ], + "id": 2165, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Contactor Status", + "variables": [ + "contactor_discharge", + "contactor_precharge", + "contactor_low", + "contactor_high", + "contactor_common_high" + ], + "id": 2166, + "period": 50, + "period_type": "ms", + "socket": "control_station_udp" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json b/backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json new file mode 100644 index 000000000..6c6e6b86f --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/HVBMS/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip": "192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json new file mode 100644 index 000000000..2617f5ce6 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU.json @@ -0,0 +1,15 @@ +{ + "board_id": 4, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.4", + "measurements": [ + "LCU_measurements.json" + ], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json new file mode 100644 index 000000000..6e1ca1ec5 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/LCU_measurements.json @@ -0,0 +1,752 @@ +[ + { + "id": 819, + "alias": "master_state_machine", + "name": "Master SM", + "type": "enum", + "enumValues": [ + "Connecting", + "Idle", + "Levitating", + "Current Control", + "Debug", + "Fault" + ] + }, + { + "id": 820, + "alias": "slave_state_machine", + "name": "Slave SM", + "type": "enum", + "enumValues": [ + "SPI Connecting", + "Idle", + "Levitating", + "Current Control", + "Debug", + "Fault" + ] + }, + { + "id": 821, + "alias": "desired_current", + "name": "Desired Current", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 822, + "alias": "fixed_vbat", + "name": "Fixed VBAT", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 823, + "alias": "desired_distance", + "name": "Desired Distance", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 824, + "alias": "lpu_id", + "name": "LPU ID", + "type": "uint32" + }, + { + "id": 825, + "alias": "fixed_pwm_duty_cycle", + "name": "Fixed PWM Duty Cycle", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 826, + "alias": "pwm_duty_cycle_1", + "name": "Duty Cycle 1", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 827, + "alias": "pwm_duty_cycle_2", + "name": "Duty Cycle 2", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 828, + "alias": "pwm_duty_cycle_3", + "name": "Duty Cycle 3", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 829, + "alias": "pwm_duty_cycle_4", + "name": "Duty Cycle 4", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 830, + "alias": "pwm_duty_cycle_5", + "name": "Duty Cycle 5", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 831, + "alias": "pwm_duty_cycle_6", + "name": "Duty Cycle 6", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 832, + "alias": "pwm_duty_cycle_7", + "name": "Duty Cycle 7", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 833, + "alias": "pwm_duty_cycle_8", + "name": "Duty Cycle 8", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 834, + "alias": "pwm_duty_cycle_9", + "name": "Duty Cycle 9", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 835, + "alias": "pwm_duty_cycle_10", + "name": "Duty Cycle 10", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 836, + "alias": "coil_current_1", + "name": "Coil Current 1", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 837, + "alias": "coil_current_2", + "name": "Coil Current 2", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 838, + "alias": "coil_current_3", + "name": "Coil Current 3", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 839, + "alias": "coil_current_4", + "name": "Coil Current 4", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 840, + "alias": "coil_current_5", + "name": "Coil Current 5", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 841, + "alias": "coil_current_6", + "name": "Coil Current 6", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 842, + "alias": "coil_current_7", + "name": "Coil Current 7", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 843, + "alias": "coil_current_8", + "name": "Coil Current 8", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 844, + "alias": "coil_current_9", + "name": "Coil Current 9", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 845, + "alias": "coil_current_10", + "name": "Coil Current 10", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 846, + "alias": "airgap_1", + "name": "Airgap 1", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 847, + "alias": "airgap_2", + "name": "Airgap 2", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 848, + "alias": "airgap_3", + "name": "Airgap 3", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 849, + "alias": "airgap_4", + "name": "Airgap 4", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 850, + "alias": "airgap_5", + "name": "Airgap 5", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 851, + "alias": "airgap_6", + "name": "Airgap 6", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 852, + "alias": "airgap_7", + "name": "Airgap 7", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 853, + "alias": "airgap_8", + "name": "Airgap 8", + "type": "float32", + "displayUnits": "mm" + }, + { + "id": 854, + "alias": "cinema_current", + "name": "Cinema Current", + "type": "float32" + }, + { + "id": 855, + "alias": "ctrl_voltage_1", + "name": "Control Voltage 1", + "type": "float32" + }, + { + "id": 856, + "alias": "ctrl_voltage_2", + "name": "Control Voltage 2", + "type": "float32" + }, + { + "id": 857, + "alias": "ctrl_voltage_3", + "name": "Control Voltage 3", + "type": "float32" + }, + { + "id": 858, + "alias": "ctrl_voltage_4", + "name": "Control Voltage 4", + "type": "float32" + }, + { + "id": 859, + "alias": "ctrl_voltage_5", + "name": "Control Voltage 5", + "type": "float32" + }, + { + "id": 860, + "alias": "ctrl_voltage_6", + "name": "Control Voltage 6", + "type": "float32" + }, + { + "id": 861, + "alias": "ctrl_voltage_7", + "name": "Control Voltage 7", + "type": "float32" + }, + { + "id": 862, + "alias": "ctrl_voltage_8", + "name": "Control Voltage 8", + "type": "float32" + }, + { + "id": 863, + "alias": "ctrl_voltage_9", + "name": "Control Voltage 9", + "type": "float32" + }, + { + "id": 864, + "alias": "ctrl_voltage_10", + "name": "Control Voltage 10", + "type": "float32" + }, + { + "id": 865, + "alias": "ctrl_gap_local_1", + "name": "Control Gap Local 1", + "type": "float32" + }, + { + "id": 866, + "alias": "ctrl_gap_local_2", + "name": "Control Gap Local 2", + "type": "float32" + }, + { + "id": 867, + "alias": "ctrl_gap_local_3", + "name": "Control Gap Local 3", + "type": "float32" + }, + { + "id": 868, + "alias": "ctrl_gap_local_4", + "name": "Control Gap Local 4", + "type": "float32" + }, + { + "id": 869, + "alias": "ctrl_state_1", + "name": "Control State 1", + "type": "float32" + }, + { + "id": 870, + "alias": "ctrl_state_2", + "name": "Control State 2", + "type": "float32" + }, + { + "id": 871, + "alias": "ctrl_state_3", + "name": "Control State 3", + "type": "float32" + }, + { + "id": 872, + "alias": "ctrl_state_4", + "name": "Control State 4", + "type": "float32" + }, + { + "id": 873, + "alias": "ctrl_state_5", + "name": "Control State 5", + "type": "float32" + }, + { + "id": 874, + "alias": "ctrl_current_ref_1", + "name": "Control Current Ref 1", + "type": "float32" + }, + { + "id": 875, + "alias": "ctrl_current_ref_2", + "name": "Control Current Ref 2", + "type": "float32" + }, + { + "id": 876, + "alias": "ctrl_current_ref_3", + "name": "Control Current Ref 3", + "type": "float32" + }, + { + "id": 877, + "alias": "ctrl_current_ref_4", + "name": "Control Current Ref 4", + "type": "float32" + }, + { + "id": 878, + "alias": "ctrl_current_ref_5", + "name": "Control Current Ref 5", + "type": "float32" + }, + { + "id": 879, + "alias": "ctrl_current_ref_6", + "name": "Control Current Ref 6", + "type": "float32" + }, + { + "id": 880, + "alias": "ctrl_current_ref_7", + "name": "Control Current Ref 7", + "type": "float32" + }, + { + "id": 881, + "alias": "ctrl_current_ref_8", + "name": "Control Current Ref 8", + "type": "float32" + }, + { + "id": 882, + "alias": "ctrl_current_ref_9", + "name": "Control Current Ref 9", + "type": "float32" + }, + { + "id": 883, + "alias": "ctrl_current_ref_10", + "name": "Control Current Ref 10", + "type": "float32" + }, + { + "id": 884, + "alias": "ctrl_fe_1", + "name": "Control Fe 1", + "type": "float32" + }, + { + "id": 885, + "alias": "ctrl_fe_2", + "name": "Control Fe 2", + "type": "float32" + }, + { + "id": 886, + "alias": "ctrl_fe_3", + "name": "Control Fe 3", + "type": "float32" + }, + { + "id": 887, + "alias": "ctrl_fa_1", + "name": "Control Fa 1", + "type": "float32" + }, + { + "id": 888, + "alias": "ctrl_fa_2", + "name": "Control Fa 2", + "type": "float32" + }, + { + "id": 889, + "alias": "ctrl_fa_3", + "name": "Control Fa 3", + "type": "float32" + }, + { + "id": 890, + "alias": "ctrl_fa_4", + "name": "Control Fa 4", + "type": "float32" + }, + { + "id": 891, + "alias": "ctrl_ef_1", + "name": "Control Ef 1", + "type": "float32" + }, + { + "id": 892, + "alias": "ctrl_ef_2", + "name": "Control Ef 2", + "type": "float32" + }, + { + "id": 893, + "alias": "ctrl_ef_3", + "name": "Control Ef 3", + "type": "float32" + }, + { + "id": 894, + "alias": "ctrl_p_1", + "name": "Control P 1", + "type": "float32" + }, + { + "id": 895, + "alias": "ctrl_p_2", + "name": "Control P 2", + "type": "float32" + }, + { + "id": 896, + "alias": "ctrl_p_3", + "name": "Control P 3", + "type": "float32" + }, + { + "id": 897, + "alias": "ctrl_r_1", + "name": "Control R 1", + "type": "float32" + }, + { + "id": 898, + "alias": "ctrl_r_2", + "name": "Control R 2", + "type": "float32" + }, + { + "id": 899, + "alias": "ctrl_r_3", + "name": "Control R 3", + "type": "float32" + }, + { + "id": 900, + "alias": "ctrl_zz_1", + "name": "Control Zz 1", + "type": "float32" + }, + { + "id": 901, + "alias": "ctrl_zz_2", + "name": "Control Zz 2", + "type": "float32" + }, + { + "id": 902, + "alias": "ctrl_zz_3", + "name": "Control Zz 3", + "type": "float32" + }, + { + "id": 903, + "alias": "ctrl_fe_l_1", + "name": "Control Fe L 1", + "type": "float32" + }, + { + "id": 904, + "alias": "ctrl_fe_l_2", + "name": "Control Fe L 2", + "type": "float32" + }, + { + "id": 905, + "alias": "ctrl_fe_l_3", + "name": "Control Fe L 3", + "type": "float32" + }, + { + "id": 906, + "alias": "ctrl_referencia", + "name": "Control Referencia", + "type": "float32" + }, + { + "id": 907, + "alias": "ctrl_i_hems_out_1", + "name": "Control I HEMS Out 1", + "type": "float32" + }, + { + "id": 908, + "alias": "ctrl_i_hems_out_2", + "name": "Control I HEMS Out 2", + "type": "float32" + }, + { + "id": 909, + "alias": "ctrl_i_hems_out_3", + "name": "Control I HEMS Out 3", + "type": "float32" + }, + { + "id": 910, + "alias": "ctrl_i_hems_out_4", + "name": "Control I HEMS Out 4", + "type": "float32" + }, + { + "id": 911, + "alias": "ctrl_a_1", + "name": "Control A 1", + "type": "float32" + }, + { + "id": 912, + "alias": "ctrl_a_2", + "name": "Control A 2", + "type": "float32" + }, + { + "id": 913, + "alias": "ctrl_a_3", + "name": "Control A 3", + "type": "float32" + }, + { + "id": 914, + "alias": "ctrl_a_4", + "name": "Control A 4", + "type": "float32" + }, + { + "id": 915, + "alias": "ctrl_a_5", + "name": "Control A 5", + "type": "float32" + }, + { + "id": 916, + "alias": "ctrl_a_6", + "name": "Control A 6", + "type": "float32" + }, + { + "id": 917, + "alias": "ctrl_a_7", + "name": "Control A 7", + "type": "float32" + }, + { + "id": 918, + "alias": "ctrl_a_8", + "name": "Control A 8", + "type": "float32" + }, + { + "id": 919, + "alias": "ctrl_ak_1", + "name": "Control Ak 1", + "type": "float32" + }, + { + "id": 920, + "alias": "ctrl_ak_2", + "name": "Control Ak 2", + "type": "float32" + }, + { + "id": 921, + "alias": "ctrl_ak_3", + "name": "Control Ak 3", + "type": "float32" + }, + { + "id": 922, + "alias": "ctrl_ak_4", + "name": "Control Ak 4", + "type": "float32" + }, + { + "id": 923, + "alias": "ctrl_bk_1", + "name": "Control Bk 1", + "type": "float32" + }, + { + "id": 924, + "alias": "ctrl_bk_2", + "name": "Control Bk 2", + "type": "float32" + }, + { + "id": 925, + "alias": "ctrl_bk_3", + "name": "Control Bk 3", + "type": "float32" + }, + { + "id": 926, + "alias": "ctrl_pi_hems_1", + "name": "Control PI HEMS 1", + "type": "float32" + }, + { + "id": 927, + "alias": "ctrl_pi_hems_2", + "name": "Control PI HEMS 2", + "type": "float32" + }, + { + "id": 928, + "alias": "ctrl_pi_hems_3", + "name": "Control PI HEMS 3", + "type": "float32" + }, + { + "id": 929, + "alias": "ctrl_pi_hems_4", + "name": "Control PI HEMS 4", + "type": "float32" + }, + { + "id": 930, + "alias": "ctrl_pi_ems_1", + "name": "Control PI EMS 1", + "type": "float32" + }, + { + "id": 931, + "alias": "ctrl_pi_ems_2", + "name": "Control PI EMS 2", + "type": "float32" + }, + { + "id": 932, + "alias": "ctrl_pi_ems_3", + "name": "Control PI EMS 3", + "type": "float32" + }, + { + "id": 933, + "alias": "ctrl_pi_ems_4", + "name": "Control PI EMS 4", + "type": "float32" + }, + { + "id": 934, + "alias": "ctrl_pi_ems_5", + "name": "Control PI EMS 5", + "type": "float32" + }, + { + "id": 935, + "alias": "ctrl_pi_ems_6", + "name": "Control PI EMS 6", + "type": "float32" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/LCU/orders.json new file mode 100644 index 000000000..ad42d50c9 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/orders.json @@ -0,0 +1,142 @@ +[ + { + "type": "order", + "name": "FAULT", + "variables": [], + "id": 800 + }, + { + "type": "order", + "name": "Stop", + "variables": [], + "id": 801 + }, + { + "type": "order", + "name": "Set Fixed VBAT", + "variables": [ + "fixed_vbat" + ], + "id": 802 + }, + { + "type": "order", + "name": "Unset Fixed VBAT", + "variables": [], + "id": 803 + }, + { + "type": "order", + "name": "Levitate", + "variables": [ + "desired_distance" + ], + "id": 804 + }, + { + "type": "order", + "name": "Levitate Ramp", + "variables": [ + "desired_distance" + ], + "id": 805 + }, + { + "type": "order", + "name": "Set Desired Distance", + "variables": [ + "desired_distance" + ], + "id": 806 + }, + { + "type": "order", + "name": "Set Desired Distance Ramp", + "variables": [ + "desired_distance" + ], + "id": 807 + }, + { + "type": "order", + "name": "Stop Ramp", + "variables": [], + "id": 808 + }, + { + "type": "order", + "name": "Current Control", + "variables": [ + "lpu_id", + "desired_current" + ], + "id": 809 + }, + { + "type": "order", + "name": "All Current Control", + "variables": [ + "desired_current" + ], + "id": 810 + }, + { + "type": "order", + "name": "Stop Current Control", + "variables": [ + "lpu_id" + ], + "id": 811 + }, + { + "type": "order", + "name": "PWM", + "variables": [ + "lpu_id", + "fixed_pwm_duty_cycle" + ], + "id": 812 + }, + { + "type": "order", + "name": "All PWM", + "variables": [ + "fixed_pwm_duty_cycle" + ], + "id": 813 + }, + { + "type": "order", + "name": "Stop PWM", + "variables": [ + "lpu_id" + ], + "id": 814 + }, + { + "type": "order", + "name": "Reset All", + "variables": [], + "id": 815 + }, + { + "type": "order", + "name": "Reset Slave", + "variables": [], + "id": 816 + }, + { + "type": "order", + "name": "Cinema", + "variables": [ + "cinema_current" + ], + "id": 817 + }, + { + "type": "order", + "name": "Stop Cinema", + "variables": [], + "id": 818 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/LCU/packets.json new file mode 100644 index 000000000..6cfaf8900 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/packets.json @@ -0,0 +1,163 @@ +[ + { + "id": 936, + "type": "data", + "name": "LPU PWM Duties", + "variables": [ + "pwm_duty_cycle_1", + "pwm_duty_cycle_2", + "pwm_duty_cycle_3", + "pwm_duty_cycle_4", + "pwm_duty_cycle_5", + "pwm_duty_cycle_6", + "pwm_duty_cycle_7", + "pwm_duty_cycle_8", + "pwm_duty_cycle_9", + "pwm_duty_cycle_10" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 937, + "type": "data", + "name": "LPU Coil Currents", + "variables": [ + "coil_current_1", + "coil_current_2", + "coil_current_3", + "coil_current_4", + "coil_current_5", + "coil_current_6", + "coil_current_7", + "coil_current_8", + "coil_current_9", + "coil_current_10" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 938, + "type": "data", + "name": "Airgaps", + "variables": [ + "airgap_1", + "airgap_2", + "airgap_3", + "airgap_4", + "airgap_5", + "airgap_6", + "airgap_7", + "airgap_8" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 939, + "type": "data", + "name": "State Machine", + "variables": [ + "master_state_machine", + "slave_state_machine" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + }, + { + "id": 940, + "type": "data", + "name": "General State", + "variables": [ + "ctrl_voltage_1", + "ctrl_voltage_2", + "ctrl_voltage_3", + "ctrl_voltage_4", + "ctrl_voltage_5", + "ctrl_voltage_6", + "ctrl_voltage_7", + "ctrl_voltage_8", + "ctrl_voltage_9", + "ctrl_voltage_10", + "ctrl_gap_local_1", + "ctrl_gap_local_2", + "ctrl_gap_local_3", + "ctrl_gap_local_4", + "ctrl_state_1", + "ctrl_state_2", + "ctrl_state_3", + "ctrl_state_4", + "ctrl_state_5", + "ctrl_current_ref_1", + "ctrl_current_ref_2", + "ctrl_current_ref_3", + "ctrl_current_ref_4", + "ctrl_current_ref_5", + "ctrl_current_ref_6", + "ctrl_current_ref_7", + "ctrl_current_ref_8", + "ctrl_current_ref_9", + "ctrl_current_ref_10", + "ctrl_fe_1", + "ctrl_fe_2", + "ctrl_fe_3", + "ctrl_fa_1", + "ctrl_fa_2", + "ctrl_fa_3", + "ctrl_fa_4", + "ctrl_ef_1", + "ctrl_ef_2", + "ctrl_ef_3", + "ctrl_p_1", + "ctrl_p_2", + "ctrl_p_3", + "ctrl_r_1", + "ctrl_r_2", + "ctrl_r_3", + "ctrl_zz_1", + "ctrl_zz_2", + "ctrl_zz_3", + "ctrl_fe_l_1", + "ctrl_fe_l_2", + "ctrl_fe_l_3", + "ctrl_referencia", + "ctrl_i_hems_out_1", + "ctrl_i_hems_out_2", + "ctrl_i_hems_out_3", + "ctrl_i_hems_out_4", + "ctrl_a_1", + "ctrl_a_2", + "ctrl_a_3", + "ctrl_a_4", + "ctrl_a_5", + "ctrl_a_6", + "ctrl_a_7", + "ctrl_a_8", + "ctrl_ak_1", + "ctrl_ak_2", + "ctrl_ak_3", + "ctrl_ak_4", + "ctrl_bk_1", + "ctrl_bk_2", + "ctrl_bk_3", + "ctrl_pi_hems_1", + "ctrl_pi_hems_2", + "ctrl_pi_hems_3", + "ctrl_pi_hems_4", + "ctrl_pi_ems_1", + "ctrl_pi_ems_2", + "ctrl_pi_ems_3", + "ctrl_pi_ems_4", + "ctrl_pi_ems_5", + "ctrl_pi_ems_6" + ], + "period_type": "ms", + "period": 10.0, + "socket": "control_station_udp" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json new file mode 100644 index 000000000..6c6e6b86f --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/LCU/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip": "192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json new file mode 100644 index 000000000..ffacc86ef --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU.json @@ -0,0 +1,15 @@ +{ + "board_id": 5, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.5", + "measurements": [ + "PCU_measurements.json" + ], + "packets": [ + "packets.json", + "orders.json" + ], + "sockets": [ + "sockets.json" + ] +} \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json new file mode 100644 index 000000000..cd68d8b95 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/PCU_measurements.json @@ -0,0 +1,304 @@ +[ + { + "id": 559, + "alias": "frequency", + "name": "Frequency Commutation hz", + "type": "uint32", + "displayUnits": "Hz" + }, + { + "id": 560, + "alias": "modulation_frequency", + "name": "Modulation Frequency", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 561, + "alias": "duty_u", + "name": "Duty U", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 562, + "alias": "duty_v", + "name": "Duty V", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 563, + "alias": "duty_w", + "name": "Duty W", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 564, + "alias": "voltage_battery_a", + "name": "Voltage Battery A", + "type": "float32" + }, + { + "id": 565, + "alias": "voltage_battery_b", + "name": "Voltage Battery B", + "type": "float32" + }, + { + "id": 566, + "alias": "v_ref", + "name": "Reference Voltage", + "type": "float32" + }, + { + "id": 567, + "alias": "frequency_to_send_svpwm", + "name": "Frequency Modulation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 568, + "alias": "frequency_to_send_pwm", + "name": "Frequency Commutation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 569, + "alias": "current_sensor_u_a", + "name": "Current Sensor U Connector A", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 570, + "alias": "current_sensor_u_b", + "name": "Current Sensor U Connector B", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 571, + "alias": "current_sensor_v_a", + "name": "Current Sensor V Connector A", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 572, + "alias": "current_sensor_v_b", + "name": "Current Sensor V Connector B", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 573, + "alias": "current_sensor_w_a", + "name": "Current Sensor W Connector A", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 574, + "alias": "current_sensor_w_b", + "name": "Current Sensor W Connector B", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 575, + "alias": "state", + "name": "State", + "type": "enum", + "enumValues": [ + "Connecting", + "Idle", + "Accelerating", + "Fault" + ] + }, + { + "id": 576, + "alias": "current_ref", + "name": "Current Ref", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 577, + "alias": "freq_to_send_svpwm", + "name": "Frequency Modulation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 578, + "alias": "freq_to_send_pwm", + "name": "Frequency Commutation hz", + "type": "float32", + "displayUnits": "Hz" + }, + { + "id": 579, + "alias": "current_Peak", + "name": "Max Current Peak", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 580, + "alias": "error_pi", + "name": "Actual Current Error", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 581, + "alias": "vf_control", + "name": "Variable frecuency control", + "type": "bool" + }, + { + "id": 582, + "alias": "slip_motor", + "name": "Slip Motor", + "type": "float32", + "displayUnits": "%" + }, + { + "id": 583, + "alias": "imu_speed_km_h", + "name": "Speed IMU", + "type": "float64", + "displayUnits": "km/h" + }, + { + "id": 584, + "alias": "imu_position_m", + "name": "IMU Position", + "type": "float32", + "displayUnits": "m" + }, + { + "id": 585, + "alias": "target_voltage", + "name": "Target Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 586, + "alias": "vmax", + "name": "Max Voltage", + "type": "float32", + "displayUnits": "V" + }, + { + "id": 587, + "alias": "speed_ref", + "name": "Reference Speed", + "type": "float32", + "displayUnits": "km/h" + }, + { + "id": 588, + "alias": "svpwm_time", + "name": "SVPWM Time", + "type": "float32", + "displayUnits": "s" + }, + { + "id": 589, + "alias": "target_speed", + "name": "Target Speed KM/H", + "type": "float32", + "displayUnits": "km/h" + }, + { + "id": 590, + "alias": "speed_error", + "name": "Speed Error", + "type": "float64", + "displayUnits": "km/h" + }, + { + "id": 591, + "alias": "actual_current_ref", + "name": "Control Current ref", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 592, + "alias": "imod", + "name": "Modulation Index", + "type": "float32" + }, + { + "id": 593, + "alias": "gd_fault_a", + "name": "GateDriver A Fault", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + }, + { + "id": 594, + "alias": "gd_fault_b", + "name": "GateDriver B Fault", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + }, + { + "id": 595, + "alias": "gd_ready_a", + "name": "GateDriver A Ready", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + }, + { + "id": 596, + "alias": "gd_ready_b", + "name": "GateDriver B Ready", + "type": "bool", + "displayUnits": "", + "protections": [ + { + "type": "Equal", + "value": [ + 1 + ], + "fault": true + } + ] + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/PCU/orders.json new file mode 100644 index 000000000..180437e93 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/orders.json @@ -0,0 +1,53 @@ +[ + { + "type": "order", + "name": "FAULT", + "variables": [], + "id": 512 + }, + { + "type": "order", + "name": "Start SVPWM", + "variables": [ + "frequency_to_send_svpwm", + "frequency_to_send_pwm", + "v_ref", + "vmax" + ], + "id": 513 + }, + { + "type": "order", + "name": "Stop Motor", + "variables": [], + "id": 514 + }, + { + "type": "order", + "name": "Send Reference Current", + "variables": [ + "freq_to_send_svpwm", + "freq_to_send_pwm", + "current_ref", + "vmax", + "vf_control" + ], + "id": 515 + }, + { + "type": "order", + "name": "Zeroing", + "variables": [], + "id": 516 + }, + { + "type": "order", + "name": "Send Reference Speed", + "variables": [ + "speed_ref", + "freq_to_send_pwm", + "vmax" + ], + "id": 517 + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/PCU/packets.json new file mode 100644 index 000000000..64a4254e3 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/packets.json @@ -0,0 +1,91 @@ +[ + { + "type": "data", + "name": "PWM", + "variables": [ + "frequency", + "modulation_frequency", + "duty_u", + "duty_v", + "duty_w" + ], + "id": 550, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Batteries Voltage", + "variables": [ + "voltage_battery_a", + "voltage_battery_b" + ], + "id": 551, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Current Sensors", + "variables": [ + "current_sensor_u_a", + "current_sensor_v_a", + "current_sensor_w_a", + "current_sensor_u_b", + "current_sensor_v_b", + "current_sensor_w_b", + "current_Peak", + "error_pi", + "target_voltage", + "svpwm_time", + "imod" + ], + "id": 552, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "State Machine States", + "variables": [ + "state" + ], + "id": 553, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Speed Data", + "variables": [ + "target_speed", + "speed_error", + "actual_current_ref", + "slip_motor", + "imu_speed_km_h", + "imu_position_m" + ], + "id": 555, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Gate Driver Reporting", + "variables": [ + "gd_fault_a", + "gd_fault_b", + "gd_ready_a", + "gd_ready_b" + ], + "id": 558, + "period_type": "ms", + "period": 16.67, + "socket": "control_station_udp" + } +] \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json new file mode 100644 index 000000000..6c6e6b86f --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/PCU/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip": "192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json new file mode 100644 index 000000000..7203dedeb --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU.json @@ -0,0 +1,16 @@ +{ + "board_id": 3, + "mac": "0:0:0:0:0:0", + "board_ip": "192.168.1.3", + "measurements": [ + "VCU_measurements.json" + ], + "packets": [ + "orders.json", + "packets.json" + ], + "sockets":[ + "sockets.json" + ] + +} \ No newline at end of file diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json new file mode 100644 index 000000000..d80dbeea9 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/VCU_measurements.json @@ -0,0 +1,95 @@ +[ + { + "id": 613, + "alias": "state", + "name": "State", + "type": "enum", + "enumValues": [ + "Idle", + "Connected", + "Maintenance", + "Precharging", + "HVActive", + "Ready", + "Propulsion", + "Static Levitation", + "Dynamic Levitation", + "Fault" + ] + }, + { + "id": 614, + "alias": "sdc_closed", + "name": "SDC Closed", + "type": "bool" + }, + { + "id": 615, + "alias": "high_pressure", + "name": "High Pressure", + "type": "float32", + "displayUnits": "bar" + }, + { + "id": 616, + "alias": "low_pressure", + "name": "Low Pressure", + "type": "float32", + "displayUnits": "bar" + }, + { + "id": 617, + "alias": "pressure_regulator_feedback", + "name": "Pressure Regulator Feedback", + "type": "float32", + "displayUnits": "bar" + }, + { + "id": 618, + "alias": "brakes_status", + "name": "Brakes Status", + "type": "enum", + "enumValues": [ + "BRAKED", + "UNBRAKED" + ] + }, + { + "id": 619, + "alias": "electrovalve_enabled", + "name": "Electrovalve Enabled", + "type": "bool" + }, + { + "id": 620, + "alias": "hvbms_connected", + "name": "HVBMS Connected", + "type": "bool" + }, + { + "id": 621, + "alias": "pcu_connected", + "name": "PCU Connected", + "type": "bool" + }, + { + "id": 622, + "alias": "lcu_connected", + "name": "LCU Connected", + "type": "bool" + }, + { + "id": 623, + "alias": "propulsion_current_reference", + "name": "Propulsion Current Reference", + "type": "float32", + "displayUnits": "A" + }, + { + "id": 624, + "alias": "levitation_target_height", + "name": "Levitation Target Height", + "type": "float32", + "displayUnits": "mm" + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/orders.json b/backend/crates/core/src/adj/test/adj/boards/VCU/orders.json new file mode 100644 index 000000000..338eb3cc8 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/orders.json @@ -0,0 +1,87 @@ +[ + { + "id": 600, + "name": "FAULT", + "type": "order", + "variables": [] + }, + { + "id": 601, + "name": "Stop", + "type": "order", + "variables": [] + }, + { + "id": 602, + "name": "Maintenance", + "type": "order", + "variables": [] + }, + { + "id": 603, + "name": "Precharge", + "type": "order", + "variables": [] + }, + { + "id": 604, + "name": "Open Contactors", + "type": "order", + "variables": [] + }, + { + "id": 605, + "name": "Unbrake", + "type": "order", + "variables": [] + }, + { + "id": 606, + "name": "Brake", + "type": "order", + "variables": [] + }, + { + "id": 607, + "name": "Static Levitation", + "type": "order", + "variables": [] + }, + { + "id": 608, + "name": "Propulsion", + "type": "order", + "variables": [] + }, + { + "id": 609, + "name": "Dynamic Levitation", + "type": "order", + "variables": [] + }, + { + "id": 610, + "name": "Propulsion Parameterized", + "type": "order", + "variables": [ + "propulsion_current_reference" + ] + }, + { + "id": 611, + "name": "Static Levitation Parameterized", + "type": "order", + "variables": [ + "levitation_target_height" + ] + }, + { + "id": 612, + "name": "Dynamic Levitation Parameterized", + "type": "order", + "variables": [ + "propulsion_current_reference", + "levitation_target_height" + ] + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/packets.json b/backend/crates/core/src/adj/test/adj/boards/VCU/packets.json new file mode 100644 index 000000000..c270f8762 --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/packets.json @@ -0,0 +1,62 @@ +[ + { + "type": "data", + "name": "VCU State", + "variables": [ + "state" + ], + "id": 625, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Pressures", + "variables": [ + "high_pressure", + "low_pressure", + "pressure_regulator_feedback" + ], + "id": 626, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Brake Status", + "variables": [ + "brakes_status" + ], + "id": 627, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Outputs", + "variables": [ + "electrovalve_enabled" + ], + "id": 628, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + }, + { + "type": "data", + "name": "Safety", + "variables": [ + "sdc_closed", + "hvbms_connected", + "pcu_connected", + "lcu_connected" + ], + "id": 629, + "period_type": "ms", + "period": 10, + "socket": "control_station_udp" + } +] diff --git a/backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json b/backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json new file mode 100644 index 000000000..20af9231b --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/boards/VCU/sockets.json @@ -0,0 +1,13 @@ +[ + { + "type": "ServerSocket", + "name": "control_station_tcp", + "port": 50500 + }, + { + "type": "DatagramSocket", + "name": "control_station_udp", + "remote_ip":"192.168.0.9", + "port": 50400 + } +] diff --git a/backend/crates/core/src/adj/test/adj/general_info.json b/backend/crates/core/src/adj/test/adj/general_info.json new file mode 100644 index 000000000..c0dfc21ba --- /dev/null +++ b/backend/crates/core/src/adj/test/adj/general_info.json @@ -0,0 +1,61 @@ +{ + "ports": { + "TCP_CLIENT": 50401, + "TCP_SERVER": 50500, + "UDP": 50400, + "SNTP": 123, + "TFTP": 69, + "BLCU_UDP": 50420, + "BLCU_TCP_CLIENT": 50421 + }, + "addresses": { + "backend": "192.168.0.9", + "hypervisor": "192.168.0.13" + }, + "protectionTypes": { + "Equal": { + "range": false, + "text": "Value is equal to {} (measured: %)" + }, + "Range": { + "range": true, + "text": "Value out of range {} (measured: %)" + }, + "Below": { + "range": false, + "text": "Value below {} (measured: %)" + } + }, + "units": { + "m/ss": "*1", + "Hz": "*1", + "MOhm": "*1000000", + "ºC": "+273.15", + "deg": "/57.2957", + "km/h": "*3.6", + "ns": "*1", + "m/s": "*1", + "rad": "*1", + "A": "*1", + "%": "/100", + "m": "*1", + "mrad": "/1000", + "KOhm": "*1000", + "V": "*1", + "mV": "/1000", + "Ohm": "*1", + "mm": "/1000", + "G": "*9.8", + "bar": "*1", + "s": "*1" + }, + "message_ids": { + "state_space": 7, + "fault": 2, + "info": 1, + "blcu_ack": 4, + "remove_state_order": 6, + "add_state_order": 5, + "warning": 3 + } +} diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs index 287b512a1..e1eda88f6 100644 --- a/backend/crates/core/src/lib.rs +++ b/backend/crates/core/src/lib.rs @@ -5,3 +5,8 @@ pub mod hello_world; /// Basic domain types: identifiers, values, measurements, protections, /// packets and boards. pub mod model; + +/// Parses an ADJ (Architecture Description JSON) directory into +/// [`model::PodData`], plus backend-session metadata not modeled as +/// vehicle data. See [`adj::load_from_dir`]. +pub mod adj; diff --git a/backend/crates/station/src/main.rs b/backend/crates/station/src/main.rs index d872008d1..27767e13f 100644 --- a/backend/crates/station/src/main.rs +++ b/backend/crates/station/src/main.rs @@ -9,5 +9,17 @@ fn main() { tracing::info!("iniciando demo de station"); let name = std::env::args().nth(1).unwrap_or_else(|| "World".into()); let _greeting = software_core::hello_world::greet(&name); + + let adj_path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../core/src/adj/test/adj"); + let adj = match software_core::adj::load_from_dir(&adj_path) { + Ok(adj) => adj, + Err(err) => { + tracing::error!(%err, "no se pudo cargar el ADJ de prueba"); + std::process::exit(1); + } + }; + tracing::info!(boards = adj.pod_data.boards.len(), "adj de prueba cargado"); + tracing::info!("demo finalizada"); } From 1e427d94fff370f15125f843199103991f4276c6 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Tue, 6 Oct 2026 21:35:07 +0200 Subject: [PATCH 16/82] Feat(backend): Implementation of protection IDs and TooManyProtections error. --- backend/crates/core/src/adj/error.rs | 5 +++++ backend/crates/core/src/adj/raw/loader.rs | 16 ++++++++++++++++ backend/crates/core/src/adj/raw/types.rs | 5 +++++ 3 files changed, 26 insertions(+) diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs index c080b036d..73d7174dd 100644 --- a/backend/crates/core/src/adj/error.rs +++ b/backend/crates/core/src/adj/error.rs @@ -100,6 +100,11 @@ pub enum LoadError { /// An error converting a raw measurement into [`crate::model::Measurement`]. #[derive(Debug, thiserror::Error)] pub enum MeasurementError { + + /// The measurement has more than 7 protections + #[error("measurement has {count} protections, maximum allowed is 7")] + TooManyProtections {count: usize}, + /// The measurement's `type` isn't a recognized wire type, `"bool"` or /// `"enum"`. #[error("unknown measurement type {0:?}")] diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index aec8a9934..cfa8b345e 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -57,6 +57,8 @@ fn load_board(root: &Path, name: &str, rel_path: &str) -> Result = HashMap::new(); + let mut protection_packets: Vec = Vec::new(); + for measurements_path in &raw_board.measurements { let raws: Vec = read_json(&board_dir.join(measurements_path))?; for raw in raws { @@ -67,6 +69,18 @@ fn load_board(root: &Path, name: &str, rel_path: &str) -> Result Result for Measurement { type Error = MeasurementError; fn try_from(raw: RawMeasurement) -> Result { + + if raw.protections.len() > 7 { + return Err(MeasurementError::TooManyProtections{count: raw.protections.len()}); + } + let kind = parse_measurement_kind(&raw.kind, raw.enum_values)?; let mut protections = Vec::with_capacity(raw.protections.len()); From 6cf3497c6260ab7c59caffa7ac663e590468853b Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Tue, 6 Oct 2026 21:50:51 +0200 Subject: [PATCH 17/82] Feat(backend): Implemented ADJ tests. --- backend/crates/core/src/adj/raw/loader.rs | 3 +++ backend/crates/core/src/adj/raw/types.rs | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index cfa8b345e..0cf05aa66 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -293,5 +293,8 @@ mod tests { text_time: None, } ); + + let protection_packet = pcu.packets.iter().find(|p| p.name.contains("_protection_")).unwrap(); + assert_eq!(protection_packet.id, AdjId(protected.id.0 + 1)); } } diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index d3dfb4acc..8188608a9 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -472,4 +472,27 @@ mod tests { assert_eq!(info.message_ids["fault"], 2); assert!(info.protection_types["Range"].is_range); } + + #[test] + fn measurement_with_too_many_protections() { + let mut raw = RawMeasurement{ + id: 100, + alias: "too_many".to_string(), + name: "Too Many Protections".to_string(), + kind: "float32".to_string(), + display_units: "V".to_string(), + enum_values: vec![], + protections: vec![], + }; + + for _ in 0..8{ + raw.protections.push(raw_protection("Equal", vec![1.0], true, None)); + } + + assert!(matches!( + Measurement::try_from(raw), + Err(MeasurementError::TooManyProtections{count:8}) + )) + } + } From 253672787952e922f3cff66444686877f0949504 Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Wed, 7 Oct 2026 14:43:45 +0200 Subject: [PATCH 18/82] feat: NTP readme --- backend/crates/core/src/ntp/README.md | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 backend/crates/core/src/ntp/README.md diff --git a/backend/crates/core/src/ntp/README.md b/backend/crates/core/src/ntp/README.md new file mode 100644 index 000000000..8fcaf15fa --- /dev/null +++ b/backend/crates/core/src/ntp/README.md @@ -0,0 +1,31 @@ +# NTP module + +NTP (**Network Time Protocol**) is a protocol used to synchronize clocks between devices over a network. In this project, it is used to synchronize the boards in the pod. + +There are two related concepts worth mentioning: + +- **SNTP (Simple Network Time Protocol):** a simplified subset of NTP intended for simpler synchronization scenarios, such as clients using a single time server. +- **NTS (Network Time Security):** an extension to NTP that adds authentication and cryptographic security to time synchronization. + +The boards in this project use **SNTP**. However, this module implements an **NTP server**, since NTP and SNTP use compatible protocol messages and are interoperable. An SNTP client can therefore communicate with this NTP server as defined by RFC 5905. + +## Implementation + +The server is implemented using the [`ntp_usg-server`](https://crates.io/crates/ntp_usg-server) crate with the **Tokio** runtime. + +The crate handles the NTP protocol logic, including request parsing and response generation, while this module is responsible for configuring and starting the server. + +The server interface and port can be configured when starting it. By default, the server listens on: + +- Interface: `0.0.0.0` +- Port: `8123` + +The standard NTP port is `123`, but this project uses `8123` as its default port. + +## Documentation + +The module documentation can be generated and opened with: + +```bash +cargo doc --open -p software-core -F ntp +``` \ No newline at end of file From e48d73b9321ea42cfbedfb04a97f327b9b28dcd3 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Thu, 8 Oct 2026 22:20:45 +0200 Subject: [PATCH 19/82] Feat(backend): Added Protection PacketKind. Made load_board public so it can be used in new tests. Added comments and changed implementation of protection ID asignation and bit mapping. Created two tests as requested (review this as Gemini was used) --- backend/crates/core/src/adj/raw/loader.rs | 10 ++-- backend/crates/core/src/adj/raw/types.rs | 64 +++++++++++++++++++++++ backend/crates/core/src/model/board.rs | 2 + 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 0cf05aa66..688f731d8 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -48,7 +48,7 @@ fn read_json(path: &Path) -> Result { }) } -fn load_board(root: &Path, name: &str, rel_path: &str) -> Result { +pub fn load_board(root: &Path, name: &str, rel_path: &str) -> Result { let board_file = root.join(rel_path); let raw_board: RawBoard = read_json(&board_file)?; let board_dir = board_file @@ -71,12 +71,16 @@ fn load_board(root: &Path, name: &str, rel_path: &str) -> Result Result { #[cfg(test)] mod tests { + use crate::adj::raw::loader::load_board; + use crate::model::PacketKind; use super::*; fn raw_protection( @@ -495,4 +497,66 @@ mod tests { )) } + #[test] + fn protection_id_generation_matches_specification() { + // Base measurement ID = 512 (Binary: 00000 0100 0000 0000) + let measurement_id: u16 = 512; + + // Test for the first protection index (idx = 0 -> pos = 1) + let idx_0: usize = 0; + let pos_1 = idx_0 as u16 + 1; + let id_0 = (pos_1 << 13) | measurement_id; + + // Expected binary pattern: [pos=001][measurement_id=0000001000000] + // 0b001_0000001000000 = 8704 + assert_eq!(id_0, 8704, "First protection packet ID must pack pos=1 into the high bits"); + + // Test for the seventh protection index (idx = 6 -> pos = 7) + let idx_6: usize = 6; + let pos_7 = idx_6 as u16 + 1; + let id_6 = (pos_7 << 13) | measurement_id; + + // Expected binary pattern: [pos=111][measurement_id=0000001000000] + // 0b111_0000001000000 = 57856 + assert_eq!(id_6, 57856, "Seventh protection packet ID must pack pos=7 into the high bits"); + } + + #[test] + fn board_incorporates_synthetic_protection_packets() { + // Arrange: Create a temporary environment or setup standard board mocks + let temp_dir = std::env::temp_dir(); + let board_name = "H12_Test_Board"; + let rel_path = "test_board.json"; + + // Ensure you create valid mock JSON configuration files in temp_dir representing: + // 1. `test_board.json` (containing 1 measurement path and 1 packet path) + // 2. A measurement file containing 1 measurement (ID: 100) with exactly 2 protections. + // 3. A packet file containing 0 standard packets to cleanly isolate the test. + + // Act + let result = load_board(&temp_dir, board_name, rel_path); + + // Assert + assert!(result.is_ok(), "Board loading failed: {:?}", result.err()); + let board = result.unwrap(); + + // Since we provided 0 standard packets and 2 protections, + // the total synthetic packets injected into the board must equal 2. + let protection_packets: Vec<_> = board.packets + .iter() + .filter(|p| p.kind == PacketKind::Protection) + .collect(); + + assert_eq!(protection_packets.len(), 2, "Board should contain exactly 2 protection packets"); + + // Verify the bit-packed properties of the injected synthetic packets + // For protection index 0 (pos = 1): (1 << 13) | 100 = 8192 | 100 = 8292 + assert_eq!(protection_packets[0].id, AdjId(8292)); + assert_eq!(protection_packets[0].name, format!("{}_mock_alias_protection_1", board_name)); + + // For protection index 1 (pos = 2): (2 << 13) | 100 = 16384 | 100 = 16484 + assert_eq!(protection_packets[1].id, AdjId(16484)); + assert_eq!(protection_packets[1].name, format!("{}_mock_alias_protection_2", board_name)); + } + } diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index ceb25b0e0..f5d2ad025 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -18,6 +18,8 @@ use std::net::IpAddr; pub enum PacketKind { /// Measurements sent by a board. Data, + /// Measurement with a protection. + Protection, /// An order sent to a board. Order, } From 2c69cd61d3a348eefe14b5a4cd62319b6b4c0137 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 8 Oct 2026 23:51:58 +0200 Subject: [PATCH 20/82] fix(backend, model): add missing type message --- backend/crates/core/src/model/board.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index f5d2ad025..1dd36927b 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -18,10 +18,12 @@ use std::net::IpAddr; pub enum PacketKind { /// Measurements sent by a board. Data, - /// Measurement with a protection. + /// Protection associated with a measurement. Protection, /// An order sent to a board. Order, + /// A message sent by a board. + Message, } /// The declaration of a single packet a board can send or receive, as From 3b7275e538293c72551d647114b37eb1a82a89d9 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Thu, 8 Oct 2026 23:52:23 +0200 Subject: [PATCH 21/82] docs(backend, adj): correction to docs --- backend/crates/core/src/adj.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backend/crates/core/src/adj.rs b/backend/crates/core/src/adj.rs index 3053f76b9..3966e677d 100644 --- a/backend/crates/core/src/adj.rs +++ b/backend/crates/core/src/adj.rs @@ -2,8 +2,7 @@ //! [`crate::model::PodData`], plus backend-session metadata not modeled as //! vehicle data ([`crate::adj::AdjInfo`]). //! -//! Loading (not downloading — see `core::git` for that, a sibling module -//! this one is a client of) is not specific to `station`: a sniffer, a +//! Loading (and if `git` feature is enabled also downloading) is not specific to `station`: a sniffer, a //! simulator, or any other tool that reads an ADJ needs exactly the same //! parsing. Scope for this iteration: no `sockets.json`, no //! `period`/`period_type`/`socket` packet fields (matches From fc40b4f0dc38c2280f5f9c8de821535d572746a6 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 01:11:57 +0200 Subject: [PATCH 22/82] feat: use serde --- backend/Cargo.toml | 1 + backend/crates/core/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d9aeeb64e..41c26b843 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -21,6 +21,7 @@ software-core = { path = "crates/core" } bytes = "1" serde = { version = "1", features = ["derive"] } +serde_json = "1" thiserror = "2" tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync", "time", "signal"] } toml = "0.9" diff --git a/backend/crates/core/Cargo.toml b/backend/crates/core/Cargo.toml index 1b38a623e..553b6996d 100644 --- a/backend/crates/core/Cargo.toml +++ b/backend/crates/core/Cargo.toml @@ -24,6 +24,7 @@ trace = ["dep:tracing-subscriber", "dep:tracing-appender"] [dependencies] bytes.workspace = true serde.workspace = true +serde_json.workspace = true thiserror.workspace = true tracing.workspace = true tokio = { workspace = true, optional = true } From d4524adea8edcd1c6df3a8a3d09db14f0077ff37 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 01:12:46 +0200 Subject: [PATCH 23/82] feat(backend, adj):include board id inside ach packet --- backend/crates/core/src/model/board.rs | 42 ++++++++++++++++++-------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 1dd36927b..00f0cce18 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -11,6 +11,7 @@ //! shape is settled. use super::{AdjId, BoardId, MacAddress, Measurement}; +use std::collections::HashMap; use std::net::IpAddr; /// What kind of packet a [`PacketDef`] declares. @@ -35,6 +36,12 @@ pub enum PacketKind { pub struct PacketDef { /// The packet's numeric ADJ id. pub id: AdjId, + /// The board this packet belongs to. A `PacketDef` is always reached + /// through its owning [`Board`] already, but carrying the id here too + /// means anything holding just a `&PacketDef` (e.g. a lookup table + /// indexed by [`AdjId`]) knows which board it is without a second, + /// separate index back to it. + pub board: BoardId, /// The human-readable name shown to the user. pub name: String, /// Whether this is data or an order. @@ -54,16 +61,20 @@ pub struct Board { pub ip: IpAddr, /// The board's MAC address (ADJv3 spec, section 5). pub mac: MacAddress, - /// The packets this board can send or receive. - pub packets: Vec, + /// The packets this board can send or receive, keyed by id — same + /// reasoning as [`PodData::boards`], the ADJ gives this list no + /// meaningful order either. + pub packets: HashMap, } /// The whole vehicle, as described by the ADJ: every board and every /// packet each one can send or receive. #[derive(Debug, Clone, PartialEq, Default)] pub struct PodData { - /// Every board of the vehicle. - pub boards: Vec, + /// Every board of the vehicle, keyed by its id — the ADJ doesn't give + /// boards any meaningful order, so iterating this in some particular + /// sequence is never something to rely on. + pub boards: HashMap, } #[cfg(test)] @@ -95,17 +106,24 @@ mod tests { name: "BCU".to_string(), ip: IpAddr::from([192, 168, 0, 10]), mac: MacAddress([0x00, 0x11, 0x22, 0x33, 0x44, 0x55]), - packets: vec![PacketDef { - id: AdjId(1000), - name: "bcu_data".to_string(), - kind: PacketKind::Data, - measurements: vec![sample_measurement()], - }], + packets: HashMap::from([( + AdjId(1000), + PacketDef { + id: AdjId(1000), + board: BoardId(1), + name: "bcu_data".to_string(), + kind: PacketKind::Data, + measurements: vec![sample_measurement()], + }, + )]), }; assert_eq!(board.packets.len(), 1); - assert_eq!(board.packets[0].measurements.len(), 1); - assert_eq!(board.packets[0].kind, PacketKind::Data); + let Some(packet) = board.packets.get(&AdjId(1000)) else { + unreachable!("the packet we just inserted should be there"); + }; + assert_eq!(packet.measurements.len(), 1); + assert_eq!(packet.kind, PacketKind::Data); assert_eq!(board.mac.to_string(), "00:11:22:33:44:55"); } } From cdf63977ecc00d7136303bf838e02df8c8874ee7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 01:13:09 +0200 Subject: [PATCH 24/82] docs(backend, adj): update docs --- backend/crates/core/src/adj/error.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs index 73d7174dd..6e173fce1 100644 --- a/backend/crates/core/src/adj/error.rs +++ b/backend/crates/core/src/adj/error.rs @@ -61,7 +61,7 @@ pub enum LoadError { board: String, }, - /// A packet or order declares a `type` other than `"data"` or `"order"`. + /// A packet or order declares a `type` other than `"data", `"order"`, `"message"` or `"protection"`. #[error("board {board:?}, packet {name:?}: unknown packet type {type_name:?}")] UnknownPacketType { /// The board the packet belongs to. @@ -100,10 +100,12 @@ pub enum LoadError { /// An error converting a raw measurement into [`crate::model::Measurement`]. #[derive(Debug, thiserror::Error)] pub enum MeasurementError { - - /// The measurement has more than 7 protections + /// The measurement has more than 7 protections. #[error("measurement has {count} protections, maximum allowed is 7")] - TooManyProtections {count: usize}, + TooManyProtections { + /// How many protections were declared. + count: usize, + }, /// The measurement's `type` isn't a recognized wire type, `"bool"` or /// `"enum"`. From 719c108a0fd9229e4deaefeac8972653b75190f9 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 01:14:03 +0200 Subject: [PATCH 25/82] feat: adj final version --- backend/Cargo.lock | 1 + backend/crates/core/src/adj/raw/loader.rs | 89 ++++++++------ backend/crates/core/src/adj/raw/types.rs | 134 +++++++++++++--------- 3 files changed, 129 insertions(+), 95 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 94186abdf..f911ba9d8 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -290,6 +290,7 @@ version = "0.1.0" dependencies = [ "bytes", "serde", + "serde_json", "thiserror", "tokio", "tracing", diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 688f731d8..4e18f30de 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -15,20 +15,16 @@ use std::path::Path; /// Loads an ADJ directory rooted at `path` (containing `general_info.json` /// and `boards.json`) into an [`Adj`]. /// -/// Boards are read in alphabetical order by name — not `boards.json`'s own -/// unordered map order — so [`PodData::boards`] is deterministic. A board -/// present on disk but absent from `boards.json` is never read; that isn't -/// an error. +/// A board present on disk but absent from `boards.json` is never read; +/// that isn't an error. pub fn load_from_dir(path: &Path) -> Result { let general_info: RawGeneralInfo = read_json(&path.join("general_info.json"))?; let board_paths: HashMap = read_json(&path.join("boards.json"))?; - let mut entries: Vec<(&String, &String)> = board_paths.iter().collect(); - entries.sort_by_key(|(name, _)| *name); - - let mut boards = Vec::with_capacity(entries.len()); - for (name, rel_path) in entries { - boards.push(load_board(path, name, rel_path)?); + let mut boards = HashMap::new(); + for (name, rel_path) in &board_paths { + let board = load_board(path, name, rel_path)?; + boards.insert(board.id, board); } Ok(Adj { @@ -37,6 +33,8 @@ pub fn load_from_dir(path: &Path) -> Result { }) } +/// Reads a JSON file at `path` and deserializes it into `T`, returning a +/// [`LoadError`] on failure. fn read_json(path: &Path) -> Result { let text = fs::read_to_string(path).map_err(|source| LoadError::ReadFile { path: path.to_path_buf(), @@ -48,16 +46,18 @@ fn read_json(path: &Path) -> Result { }) } -pub fn load_board(root: &Path, name: &str, rel_path: &str) -> Result { +pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result { + // Read the board's JSON, then its measurements and packets, and build a `Board` from it let board_file = root.join(rel_path); let raw_board: RawBoard = read_json(&board_file)?; + let board_id = BoardId(raw_board.board_id); let board_dir = board_file .parent() .map(Path::to_path_buf) .unwrap_or_else(|| root.to_path_buf()); let mut measurements: HashMap = HashMap::new(); - let mut protection_packets: Vec = Vec::new(); + let mut packets: HashMap = HashMap::new(); for measurements_path in &raw_board.measurements { let raws: Vec = read_json(&board_dir.join(measurements_path))?; @@ -70,35 +70,32 @@ pub fn load_board(root: &Path, name: &str, rel_path: &str) -> Result = read_json(&board_dir.join(packets_path))?; for raw in raws { - packets.push(build_packet_def(name, raw, &measurements)?); + let packet = build_packet_def(name, board_id, raw, &measurements)?; + packets.insert(packet.id, packet); } } - packets.extend(protection_packets); - let ip: IpAddr = raw_board .board_ip .parse() @@ -117,7 +114,7 @@ pub fn load_board(root: &Path, name: &str, rel_path: &str) -> Result Result, ) -> Result { let kind = match raw.kind.as_str() { "data" => PacketKind::Data, "order" => PacketKind::Order, + "protection" => PacketKind::Protection, + "message" => PacketKind::Message, other => { return Err(LoadError::UnknownPacketType { board: board.to_string(), @@ -158,6 +158,7 @@ fn build_packet_def( Ok(PacketDef { id: AdjId(raw.id), + board: board_id, name: raw.name, kind, measurements: packet_measurements, @@ -224,7 +225,7 @@ mod tests { variables: vec![], }; assert!(matches!( - build_packet_def("VCU", raw, &HashMap::new()), + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), Err(LoadError::UnknownPacketType { .. }) )); } @@ -238,7 +239,7 @@ mod tests { variables: vec!["missing".to_string()], }; assert!(matches!( - build_packet_def("VCU", raw, &HashMap::new()), + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), Err(LoadError::UnknownAlias { .. }) )); } @@ -248,32 +249,39 @@ mod tests { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adj/test/adj"); let adj = must_load(&path); - let board_names: Vec<&str> = adj + // `HashMap` iteration order is unspecified, so sort before comparing. + let mut board_names: Vec<&str> = adj .pod_data .boards - .iter() + .values() .map(|b| b.name.as_str()) .collect(); + board_names.sort(); assert_eq!(board_names, vec!["HVBMS", "LCU", "PCU", "VCU"]); - let Some(vcu) = adj.pod_data.boards.iter().find(|b| b.name == "VCU") else { + let Some(vcu) = adj.pod_data.boards.get(&BoardId(3)) else { unreachable!("VCU should be among the loaded boards"); }; assert_eq!(vcu.id, BoardId(3)); assert_eq!(vcu.ip, IpAddr::from([192, 168, 1, 3])); assert_eq!(vcu.mac, MacAddress([0; 6])); assert_eq!(vcu.packets.len(), 18); - assert_eq!(vcu.packets[0].kind, PacketKind::Order); - assert_eq!(vcu.packets[0].id, AdjId(600)); - assert_eq!(vcu.packets[13].kind, PacketKind::Data); - assert_eq!(vcu.packets[13].id, AdjId(625)); + let Some(order) = vcu.packets.get(&AdjId(600)) else { + unreachable!("VCU should have order 600"); + }; + assert_eq!(order.kind, PacketKind::Order); + assert_eq!(order.board, BoardId(3)); + let Some(data) = vcu.packets.get(&AdjId(625)) else { + unreachable!("VCU should have data packet 625"); + }; + assert_eq!(data.kind, PacketKind::Data); - let Some(pcu) = adj.pod_data.boards.iter().find(|b| b.name == "PCU") else { + let Some(pcu) = adj.pod_data.boards.get(&BoardId(5)) else { unreachable!("PCU should be among the loaded boards"); }; let Some(protected) = pcu .packets - .iter() + .values() .flat_map(|p| &p.measurements) .find(|m| !m.protections.is_empty()) else { @@ -298,7 +306,12 @@ mod tests { } ); - let protection_packet = pcu.packets.iter().find(|p| p.name.contains("_protection_")).unwrap(); - assert_eq!(protection_packet.id, AdjId(protected.id.0 + 1)); + // `protected` has exactly one protection (asserted above), at position 1, + // so its compound wire id is `(1 << 13) | protected.id.0`. + let Some(protection_packet) = pcu.packets.get(&AdjId((1u16 << 13) | protected.id.0)) else { + unreachable!("PCU should have a synthetic protection packet"); + }; + assert_eq!(protection_packet.kind, PacketKind::Protection); + assert_eq!(protection_packet.board, pcu.id); } } diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index ea785820e..dd95d21f9 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -112,9 +112,10 @@ impl TryFrom for Measurement { type Error = MeasurementError; fn try_from(raw: RawMeasurement) -> Result { - if raw.protections.len() > 7 { - return Err(MeasurementError::TooManyProtections{count: raw.protections.len()}); + return Err(MeasurementError::TooManyProtections { + count: raw.protections.len(), + }); } let kind = parse_measurement_kind(&raw.kind, raw.enum_values)?; @@ -268,9 +269,9 @@ fn parse_duration(text: &str) -> Result { #[cfg(test)] mod tests { + use super::*; use crate::adj::raw::loader::load_board; use crate::model::PacketKind; - use super::*; fn raw_protection( kind: &str, @@ -477,7 +478,7 @@ mod tests { #[test] fn measurement_with_too_many_protections() { - let mut raw = RawMeasurement{ + let mut raw = RawMeasurement { id: 100, alias: "too_many".to_string(), name: "Too Many Protections".to_string(), @@ -487,76 +488,95 @@ mod tests { protections: vec![], }; - for _ in 0..8{ - raw.protections.push(raw_protection("Equal", vec![1.0], true, None)); + for _ in 0..8 { + raw.protections + .push(raw_protection("Equal", vec![1.0], true, None)); } assert!(matches!( Measurement::try_from(raw), - Err(MeasurementError::TooManyProtections{count:8}) + Err(MeasurementError::TooManyProtections { count: 8 }) )) } - #[test] - fn protection_id_generation_matches_specification() { - // Base measurement ID = 512 (Binary: 00000 0100 0000 0000) - let measurement_id: u16 = 512; - - // Test for the first protection index (idx = 0 -> pos = 1) - let idx_0: usize = 0; - let pos_1 = idx_0 as u16 + 1; - let id_0 = (pos_1 << 13) | measurement_id; - - // Expected binary pattern: [pos=001][measurement_id=0000001000000] - // 0b001_0000001000000 = 8704 - assert_eq!(id_0, 8704, "First protection packet ID must pack pos=1 into the high bits"); - - // Test for the seventh protection index (idx = 6 -> pos = 7) - let idx_6: usize = 6; - let pos_7 = idx_6 as u16 + 1; - let id_6 = (pos_7 << 13) | measurement_id; - - // Expected binary pattern: [pos=111][measurement_id=0000001000000] - // 0b111_0000001000000 = 57856 - assert_eq!(id_6, 57856, "Seventh protection packet ID must pack pos=7 into the high bits"); - } - #[test] fn board_incorporates_synthetic_protection_packets() { - // Arrange: Create a temporary environment or setup standard board mocks - let temp_dir = std::env::temp_dir(); - let board_name = "H12_Test_Board"; - let rel_path = "test_board.json"; + // A scratch directory, unique per test invocation (pid + a counter) + // so parallel test runs never collide on the same path. + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let unique = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "software_core_adj_test_{}_{unique}", + std::process::id() + )); + + if std::fs::create_dir_all(&dir).is_err() { + unreachable!("should be able to create a scratch test directory"); + } - // Ensure you create valid mock JSON configuration files in temp_dir representing: - // 1. `test_board.json` (containing 1 measurement path and 1 packet path) - // 2. A measurement file containing 1 measurement (ID: 100) with exactly 2 protections. - // 3. A packet file containing 0 standard packets to cleanly isolate the test. + let board_json = r#"{ + "board_id": 99, + "mac": "aa:bb:cc:dd:ee:ff", + "board_ip": "127.0.0.1", + "measurements": ["measurements.json"], + "packets": ["packets.json"] + }"#; + let measurements_json = r#"[ + { + "id": 100, + "alias": "mock_alias", + "name": "Mock Measurement", + "type": "float32", + "protections": [ + { "type": "Equal", "value": [1.0], "fault": true }, + { "type": "Equal", "value": [2.0], "fault": true } + ] + } + ]"#; + + if std::fs::write(dir.join("test_board.json"), board_json).is_err() { + unreachable!("should be able to write the mock board file"); + } + if std::fs::write(dir.join("measurements.json"), measurements_json).is_err() { + unreachable!("should be able to write the mock measurements file"); + } + if std::fs::write(dir.join("packets.json"), "[]").is_err() { + unreachable!("should be able to write the mock packets file"); + } - // Act - let result = load_board(&temp_dir, board_name, rel_path); + let board_name = "H12_Test_Board"; + let result = load_board(&dir, board_name, "test_board.json"); + let _ = std::fs::remove_dir_all(&dir); - // Assert - assert!(result.is_ok(), "Board loading failed: {:?}", result.err()); - let board = result.unwrap(); + let board = match result { + Ok(board) => board, + Err(err) => unreachable!("board loading should succeed: {err}"), + }; - // Since we provided 0 standard packets and 2 protections, - // the total synthetic packets injected into the board must equal 2. - let protection_packets: Vec<_> = board.packets - .iter() + // We provided 0 standard packets and 2 protections, so the total + // synthetic packets injected into the board must equal 2. + let protection_count = board + .packets + .values() .filter(|p| p.kind == PacketKind::Protection) - .collect(); - - assert_eq!(protection_packets.len(), 2, "Board should contain exactly 2 protection packets"); + .count(); + assert_eq!( + protection_count, 2, + "board should contain exactly 2 protection packets" + ); - // Verify the bit-packed properties of the injected synthetic packets // For protection index 0 (pos = 1): (1 << 13) | 100 = 8192 | 100 = 8292 - assert_eq!(protection_packets[0].id, AdjId(8292)); - assert_eq!(protection_packets[0].name, format!("{}_mock_alias_protection_1", board_name)); + let Some(first) = board.packets.get(&AdjId(8292)) else { + unreachable!("first synthetic protection packet should be at id 8292"); + }; + assert_eq!(first.board, board.id); + assert_eq!(first.name, format!("{board_name}_mock_alias_protection_1")); // For protection index 1 (pos = 2): (2 << 13) | 100 = 16384 | 100 = 16484 - assert_eq!(protection_packets[1].id, AdjId(16484)); - assert_eq!(protection_packets[1].name, format!("{}_mock_alias_protection_2", board_name)); + let Some(second) = board.packets.get(&AdjId(16484)) else { + unreachable!("second synthetic protection packet should be at id 16484"); + }; + assert_eq!(second.name, format!("{board_name}_mock_alias_protection_2")); } - } From b6c387419c0a601c620009ddb40870eba4c8709a Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 01:18:44 +0200 Subject: [PATCH 26/82] feat(adj & model): add port --- backend/crates/core/src/adj/info.rs | 5 +++-- backend/crates/core/src/adj/raw/loader.rs | 4 ++-- backend/crates/core/src/adj/raw/types.rs | 10 +++++++--- backend/crates/core/src/model.rs | 4 ++-- backend/crates/core/src/model/id.rs | 12 ++++++++++++ 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs index 02d9870b9..140ef6437 100644 --- a/backend/crates/core/src/adj/info.rs +++ b/backend/crates/core/src/adj/info.rs @@ -5,6 +5,7 @@ //! addresses, message-id conventions, and how to display a triggered //! protection. +use crate::model::Port; use std::collections::HashMap; /// A protection type's display text, as declared in `general_info.json`'s @@ -25,8 +26,8 @@ pub struct ProtectionTypeInfo { /// Backend-session metadata parsed from `general_info.json`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct AdjInfo { - /// Known port numbers, by name (e.g. `"TCP_SERVER"` -> `50500`). - pub ports: HashMap, + /// Known port numbers, by name (e.g. `"TCP_SERVER"` -> `Port(50500)`). + pub ports: HashMap, /// Known addresses, by name (e.g. `"backend"` -> `"192.168.0.9"`), kept /// as text: nothing in this crate needs them parsed as an `IpAddr` yet. pub addresses: HashMap, diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 4e18f30de..21fe2107f 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -188,7 +188,7 @@ fn parse_mac(raw: &str) -> Option { mod tests { use super::*; use crate::adj::ProtectionTypeInfo; - use crate::model::{PacketKind, ProtectionKind, Severity}; + use crate::model::{PacketKind, Port, ProtectionKind, Severity}; use std::path::Path; use std::time::Duration; @@ -294,7 +294,7 @@ mod tests { assert_eq!(protection.time, Duration::ZERO); assert_eq!(protection.id, AdjId(1)); - assert_eq!(adj.info.ports["TCP_SERVER"], 50500); + assert_eq!(adj.info.ports["TCP_SERVER"], Port(50500)); assert_eq!(adj.info.addresses["backend"], "192.168.0.9"); assert_eq!(adj.info.message_ids["fault"], 2); assert_eq!( diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index dd95d21f9..a335678c5 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -8,7 +8,7 @@ use crate::adj::error::{MeasurementError, ProtectionError}; use crate::adj::info::{AdjInfo, ProtectionTypeInfo}; use crate::model::{ - AdjId, Measurement, MeasurementKind, NumericKind, Protection, ProtectionKind, Severity, + AdjId, Measurement, MeasurementKind, NumericKind, Port, Protection, ProtectionKind, Severity, }; use serde::Deserialize; use std::collections::HashMap; @@ -87,7 +87,11 @@ pub(crate) struct RawPacket { impl From for AdjInfo { fn from(raw: RawGeneralInfo) -> Self { AdjInfo { - ports: raw.ports, + ports: raw + .ports + .into_iter() + .map(|(name, port)| (name, Port(port))) + .collect(), addresses: raw.addresses, message_ids: raw.message_ids, protection_types: raw @@ -470,7 +474,7 @@ mod tests { let info = AdjInfo::from(raw); - assert_eq!(info.ports["TCP_SERVER"], 50500); + assert_eq!(info.ports["TCP_SERVER"], Port(50500)); assert_eq!(info.addresses["backend"], "192.168.0.9"); assert_eq!(info.message_ids["fault"], 2); assert!(info.protection_types["Range"].is_range); diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 2ca135984..6cfdda92d 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -2,7 +2,7 @@ //! protections and boards. No I/O and no networking here, just data and //! the operations performed on it. //! -//! - `id` — `AdjId`, `BoardId`, `MacAddress`. +//! - `id` — `AdjId`, `BoardId`, `MacAddress`, `Port`. //! - `value` — `Value`, the result of decoding a measurement. //! - `measurement` — `Measurement`: what a field of a packet looks like. //! - `protections` — `Protection`: bounds declared for a measurement. @@ -18,7 +18,7 @@ mod protections; mod value; pub use board::{Board, PacketDef, PacketKind, PodData}; -pub use id::{AdjId, BoardId, MacAddress}; +pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index 878532e06..d6a6845a5 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -16,6 +16,10 @@ pub struct AdjId(pub u16); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BoardId(pub u16); +/// A TCP/UDP port number, as declared in `general_info.json`'s `ports`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Port(pub u16); + /// A board's physical MAC address, as 6 raw bytes. /// /// Kept as a fixed-size byte array instead of a `String` on purpose: a @@ -48,6 +52,14 @@ mod tests { assert_eq!(AdjId(1000), AdjId(1000)); } + #[test] + fn a_port_and_a_board_id_cannot_be_compared_to_each_other() { + // This wouldn't compile if you tried it: Port(1) == BoardId(1) + let port = Port(50500); + let board = BoardId(50500); + assert_eq!(port.0, board.0); // only the inner numbers can be compared + } + #[test] fn a_mac_address_displays_as_colon_separated_lowercase_hex() { let mac = MacAddress([0xaa, 0xbb, 0xcc, 0x0d, 0x0e, 0xff]); From bb2c605342af45f24dbb4db04aeeea1e0915bf68 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 01:21:56 +0200 Subject: [PATCH 27/82] fix(adj): use adj id to read data from info --- backend/crates/core/src/adj/info.rs | 8 +++++--- backend/crates/core/src/adj/raw/loader.rs | 2 +- backend/crates/core/src/adj/raw/types.rs | 8 ++++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs index 140ef6437..853d1cf7a 100644 --- a/backend/crates/core/src/adj/info.rs +++ b/backend/crates/core/src/adj/info.rs @@ -5,7 +5,7 @@ //! addresses, message-id conventions, and how to display a triggered //! protection. -use crate::model::Port; +use crate::model::{AdjId, Port}; use std::collections::HashMap; /// A protection type's display text, as declared in `general_info.json`'s @@ -31,8 +31,10 @@ pub struct AdjInfo { /// Known addresses, by name (e.g. `"backend"` -> `"192.168.0.9"`), kept /// as text: nothing in this crate needs them parsed as an `IpAddr` yet. pub addresses: HashMap, - /// Known message ids, by name (e.g. `"fault"` -> `2`). - pub message_ids: HashMap, + /// Known message ids, by name (e.g. `"fault"` -> `AdjId(2)`) — these + /// live in the ADJ's own special reserved id range (`[0, 511]`, see + /// [`AdjId`]'s own docs), not a separate numbering of their own. + pub message_ids: HashMap, /// Declared protection types, by name (e.g. `"Range"`). pub protection_types: HashMap, } diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 21fe2107f..b07520e67 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -296,7 +296,7 @@ mod tests { assert_eq!(adj.info.ports["TCP_SERVER"], Port(50500)); assert_eq!(adj.info.addresses["backend"], "192.168.0.9"); - assert_eq!(adj.info.message_ids["fault"], 2); + assert_eq!(adj.info.message_ids["fault"], AdjId(2)); assert_eq!( adj.info.protection_types["Range"], ProtectionTypeInfo { diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index a335678c5..de5e68a6b 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -93,7 +93,11 @@ impl From for AdjInfo { .map(|(name, port)| (name, Port(port))) .collect(), addresses: raw.addresses, - message_ids: raw.message_ids, + message_ids: raw + .message_ids + .into_iter() + .map(|(name, id)| (name, AdjId(id))) + .collect(), protection_types: raw .protection_types .into_iter() @@ -476,7 +480,7 @@ mod tests { assert_eq!(info.ports["TCP_SERVER"], Port(50500)); assert_eq!(info.addresses["backend"], "192.168.0.9"); - assert_eq!(info.message_ids["fault"], 2); + assert_eq!(info.message_ids["fault"], AdjId(2)); assert!(info.protection_types["Range"].is_range); } From bc846217c36b68b8eaf3d5810530b38cbe44c906 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 11:47:40 +0200 Subject: [PATCH 28/82] fix(model adj): remove adj readme from test folder --- .../crates/core/src/adj/test/adj/README.md | 291 ------------------ 1 file changed, 291 deletions(-) delete mode 100644 backend/crates/core/src/adj/test/adj/README.md diff --git a/backend/crates/core/src/adj/test/adj/README.md b/backend/crates/core/src/adj/test/adj/README.md deleted file mode 100644 index 453182930..000000000 --- a/backend/crates/core/src/adj/test/adj/README.md +++ /dev/null @@ -1,291 +0,0 @@ -# ADJ v2 Specification - -This document defines the ADJ v2 configuration format used by the Hyperloop Control Station. The configuration is organized as a distributed collection of JSON files that define boards, measurements, packets, and general system information. - -## File Structure - -The ADJ configuration is organized in the following directory structure: - -``` -adj/ -├── general_info.json # System-wide configuration -├── boards.json # Board list and path mappings -└── boards/ # Board-specific configurations - └── {board_name}/ - ├── {board_name}.json # Main board configuration - ├── {board_name}_measurements.json # Board measurements - ├── packets.json # Data packets - ├── orders.json # Order packets - └── sockets.json # Board sockets -``` - -## Data Types - -### General Info (`general_info.json`) - -System-wide configuration including ports, addresses, units, and message IDs. - -```json -{ - "ports": { - "string": "number" - }, - "addresses": { - "string": "string" - }, - "units": { - "string": "string" - }, - "message_ids": { - "string": "number" - } -} -``` - -**Example:** -```json -{ - "ports": { - "main": 8080, - "data": 8081 - }, - "addresses": { - "primary": "192.168.1.100", - "secondary": "192.168.1.101" - }, - "units": { - "pressure": "PSI", - "temperature": "°C", - "voltage": "V" - }, - "message_ids": { - "status": 1, - "data": 2 - } -} -``` - -### Board List (`boards.json`) - -Mapping of board names to their configuration file paths. - -```json -{ - "string": "string" -} -``` - -**Example:** -```json -{ - "brake_board": "boards/brake_board/brake_board.json", - "sensor_board": "boards/sensor_board/sensor_board.json" -} -``` - -### Board Configuration (`{board_name}.json`) - -Main board configuration including ID, IP address, and references to measurements, packets and sockets files. - -```json -{ - "board_id": "number", - "board_ip": "string", - "measurements": ["string"], - "packets": ["string"], - "sockets": ["string"] -} -``` - -**Field Descriptions:** -- `board_id`: Unique 32-bit unsigned integer identifier for the board -- `board_ip`: IP address string for network communication -- `measurements`: Array of measurement file paths relative to board directory -- `packets`: Array of packet file paths relative to board directory - -**Example:** -```json -{ - "board_id": 1001, - "board_ip": "192.168.1.10", - "measurements": ["brake_board_measurements.json"], - "packets": ["packets.json", "orders.json"], - "sockets":["sockets.json"] -} -``` - -### Measurements (`{board_name}_measurements.json`) - -Array of measurement definitions for data collection and monitoring. - -```json -[ - { - "id": "string", - "name": "string", - "type": "string", - "podUnits": "string?", - "displayUnits": "string?", - "enumValues": ["string"]?, - "safeRange": "[number, number]?", - "warningRange": "[number, number]?" - } -] -``` - -**Field Descriptions:** -- `id`: Unique string identifier for the measurement -- `name`: Human-readable display name -- `type`: Data type - one of: `uint8`, `uint16`, `uint32`, `uint64`, `int8`, `int16`, `int32`, `int64`, `float32`, `float64` -- `podUnits`: Optional pod-side units (references `general_info.units`) -- `displayUnits`: Optional display units (references `general_info.units`) -- `enumValues`: Optional array of enumeration values for discrete measurements -- `safeRange`: Optional two-element array `[min, max]` defining safe operating range -- `warningRange`: Optional two-element array `[min, max]` defining warning thresholds - -**Example:** -```json -[ - { - "id": "brake_pressure", - "name": "Brake Pressure", - "type": "float32", - "podUnits": "PSI", - "displayUnits": "PSI", - "safeRange": [0.0, 100.0], - "warningRange": [80.0, 95.0] - }, - { - "id": "brake_status", - "name": "Brake Status", - "type": "uint8", - "enumValues": ["released", "engaged", "error"] - } -] -``` - -### Packets (`packets.json`, `orders.json`) - -Array of packet definitions for network communication. Packets are separated by type: -- `packets.json`: Data packets for telemetry and status -- `orders.json`: Command packets for control operations - -```json -[ - { - "id": "number?", - "type": "string", - "name": "string", - "variables": ["string"], - "period_type": "us/ms?", - "period": "number?", - "socket": "string?" - } -] -``` - -**Field Descriptions:** -- `id`: Optional 32-bit unsigned integer packet identifier -- `type`: Packet type string (e.g., "data", "order", "status") -- `name`: Human-readable packet name -- `variables`: Array of variable names/measurement IDs included in this packet -- `period_type`: Optional string specifying the type of measurement for period, either microseconds -or milliseconds -- `period`: Optional number specifying the transmission period in milliseconds or microseconds for periodic packets. Can be an integer or floating-point value -- `socket`: Optinal string that should match the name of the socket you want to transmit this packet trhough - -**Example:** -```json -[ - { - "id": 2001, - "type": "data", - "name": "Brake Telemetry", - "variables": ["brake_pressure", "brake_status", "brake_temperature"], - "period_type":"ms", - "period": 16.67, - "socket": "control_station_udp" - }, - { - "type": "order", - "name": "Brake Command", - "variables": ["brake_command", "target_pressure"] - } -] -``` - -### Sockets (`sockets.json`) - -Array of socket definitions for network communication. - -```json -{ - "type": "string", - "name": "string", - "port": "number?", - "local_port":"number?", - "remote_ip": "string?", - "remote_port":"number?" - -} -``` - -**Field Descriptions:** -- `type`: Socket type string(e.g., "ServerSocket","DatagramSocket","Socket") -- `name`: Socket name -- `port`: Optional number that describes the port number used for a ServerSocket or a DatagramSocket -- `local_port`: Optional number that describes the local port number used for a Socket -- `remote_ip`: Optional string that describes the remote ip you want to connect to in a DatagramSocket or Socket -- `remote_port`: Optional number that describes the remote port number used for a Socket - -**Example:** -```json -[ - { - "type": "ServerSocket", - "name": "control_station_tcp", - "port": 50500 - }, - - { - "type": "DatagramSocket", - "name": "control_station_udp", - "remote_ip":"192.168.0.9", - "port": 50400 - }, - - { - "type": "Socket", - "name": "pcu_tcp", - "local_port": 50501, - "remote_ip": "192.168.1.5", - "remote_port": 50500 - } -] -``` - -## Configuration Rules - -1. **Naming Convention**: Board directories and main configuration files must use the same name as the board key in `boards.json` - -2. **File References**: All file paths in board configurations are relative to the board's directory - -3. **Measurement References**: Packet `variables` arrays should reference measurement `id` values defined in the board's measurements files - -4. **Unit References**: Measurement `podUnits` and `displayUnits` should reference keys defined in `general_info.units` - -5. **ID Uniqueness**: Board IDs must be unique across all boards. Packet IDs should be unique within their type category. - -6. **Socket Naming**: The sockets referenced inside the packet definitions must use exact names as defined in `socket.json`. - -## Validation Notes - -- All numeric fields use standard JSON number format -- Optional fields may be omitted entirely or set to `null` -- Empty arrays are permitted for measurements and packets -- File paths use forward slashes (`/`) regardless of operating system -- IP addresses must be valid IPv4 format strings - -## Backward Compatibility - -This specification maintains compatibility with existing ADJ v1 configurations while supporting the new distributed file structure introduced in v2. The system can automatically migrate v1 configurations to the v2 format. From db66b30e7952b6f5b66ed02e56a3f217803ce439 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 11:49:21 +0200 Subject: [PATCH 29/82] docs(backend, model): mistaken comment --- backend/crates/core/src/model/board.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 00f0cce18..3692a74f9 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -44,7 +44,7 @@ pub struct PacketDef { pub board: BoardId, /// The human-readable name shown to the user. pub name: String, - /// Whether this is data or an order. + /// Whether this is data, order, protection or message. pub kind: PacketKind, /// The measurements this packet carries, in ADJ order. pub measurements: Vec, From 14c222fe4ffb35bbdd207e5c34a144344592419e Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 12:05:02 +0200 Subject: [PATCH 30/82] fix(backend, adj): change ip as an string to a Ip Addr --- backend/crates/core/src/adj/error.rs | 13 ++++++ backend/crates/core/src/adj/info.rs | 6 +-- backend/crates/core/src/adj/raw/loader.rs | 7 +++- backend/crates/core/src/adj/raw/types.rs | 48 +++++++++++++++++++---- 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs index 6e173fce1..2f6d31764 100644 --- a/backend/crates/core/src/adj/error.rs +++ b/backend/crates/core/src/adj/error.rs @@ -45,6 +45,19 @@ pub enum LoadError { source: AddrParseError, }, + /// An address in `general_info.json`'s `addresses` isn't a valid IP + /// address. + #[error("address {name:?}: invalid IP address {raw:?}")] + InvalidAddress { + /// The address's name (e.g. `"backend"`). + name: String, + /// The text that failed to parse. + raw: String, + /// The underlying parse error. + #[source] + source: AddrParseError, + }, + /// A board's `mac` isn't a valid MAC address. #[error("board {board:?}: invalid MAC address {raw:?}")] InvalidMac { diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs index 853d1cf7a..62c17fa9c 100644 --- a/backend/crates/core/src/adj/info.rs +++ b/backend/crates/core/src/adj/info.rs @@ -7,6 +7,7 @@ use crate::model::{AdjId, Port}; use std::collections::HashMap; +use std::net::IpAddr; /// A protection type's display text, as declared in `general_info.json`'s /// `protectionTypes`. @@ -28,9 +29,8 @@ pub struct ProtectionTypeInfo { pub struct AdjInfo { /// Known port numbers, by name (e.g. `"TCP_SERVER"` -> `Port(50500)`). pub ports: HashMap, - /// Known addresses, by name (e.g. `"backend"` -> `"192.168.0.9"`), kept - /// as text: nothing in this crate needs them parsed as an `IpAddr` yet. - pub addresses: HashMap, + /// Known addresses, by name (e.g. `"backend"` -> `192.168.0.9`). + pub addresses: HashMap, /// Known message ids, by name (e.g. `"fault"` -> `AdjId(2)`) — these /// live in the ADJ's own special reserved id range (`[0, 511]`, see /// [`AdjId`]'s own docs), not a separate numbering of their own. diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index b07520e67..02d4750a3 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -29,7 +29,7 @@ pub fn load_from_dir(path: &Path) -> Result { Ok(Adj { pod_data: PodData { boards }, - info: AdjInfo::from(general_info), + info: AdjInfo::try_from(general_info)?, }) } @@ -295,7 +295,10 @@ mod tests { assert_eq!(protection.id, AdjId(1)); assert_eq!(adj.info.ports["TCP_SERVER"], Port(50500)); - assert_eq!(adj.info.addresses["backend"], "192.168.0.9"); + assert_eq!( + adj.info.addresses["backend"], + IpAddr::from([192, 168, 0, 9]) + ); assert_eq!(adj.info.message_ids["fault"], AdjId(2)); assert_eq!( adj.info.protection_types["Range"], diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index de5e68a6b..0f130cb30 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -5,7 +5,7 @@ //! `socket`, `units`, `podUnits`), which are simply absent from these //! structs and therefore ignored by serde, not rejected. -use crate::adj::error::{MeasurementError, ProtectionError}; +use crate::adj::error::{LoadError, MeasurementError, ProtectionError}; use crate::adj::info::{AdjInfo, ProtectionTypeInfo}; use crate::model::{ AdjId, Measurement, MeasurementKind, NumericKind, Port, Protection, ProtectionKind, Severity, @@ -84,15 +84,29 @@ pub(crate) struct RawPacket { pub(crate) variables: Vec, } -impl From for AdjInfo { - fn from(raw: RawGeneralInfo) -> Self { - AdjInfo { +impl TryFrom for AdjInfo { + type Error = LoadError; + + fn try_from(raw: RawGeneralInfo) -> Result { + let mut addresses = HashMap::with_capacity(raw.addresses.len()); + for (name, raw_addr) in raw.addresses { + let addr = raw_addr + .parse() + .map_err(|source| LoadError::InvalidAddress { + name: name.clone(), + raw: raw_addr, + source, + })?; + addresses.insert(name, addr); + } + + Ok(AdjInfo { ports: raw .ports .into_iter() .map(|(name, port)| (name, Port(port))) .collect(), - addresses: raw.addresses, + addresses, message_ids: raw .message_ids .into_iter() @@ -112,7 +126,7 @@ impl From for AdjInfo { ) }) .collect(), - } + }) } } @@ -280,6 +294,7 @@ mod tests { use super::*; use crate::adj::raw::loader::load_board; use crate::model::PacketKind; + use std::net::IpAddr; fn raw_protection( kind: &str, @@ -476,14 +491,31 @@ mod tests { message_ids: HashMap::from([("fault".to_string(), 2u16)]), }; - let info = AdjInfo::from(raw); + let Ok(info) = AdjInfo::try_from(raw) else { + unreachable!("a well-formed RawGeneralInfo should convert cleanly"); + }; assert_eq!(info.ports["TCP_SERVER"], Port(50500)); - assert_eq!(info.addresses["backend"], "192.168.0.9"); + assert_eq!(info.addresses["backend"], IpAddr::from([192, 168, 0, 9])); assert_eq!(info.message_ids["fault"], AdjId(2)); assert!(info.protection_types["Range"].is_range); } + #[test] + fn general_info_rejects_an_invalid_address() { + let raw = RawGeneralInfo { + ports: HashMap::new(), + addresses: HashMap::from([("backend".to_string(), "not-an-ip".to_string())]), + protection_types: HashMap::new(), + message_ids: HashMap::new(), + }; + + assert!(matches!( + AdjInfo::try_from(raw), + Err(LoadError::InvalidAddress { .. }) + )); + } + #[test] fn measurement_with_too_many_protections() { let mut raw = RawMeasurement { From ae145fe6f6098dab1c61b402bc90809d13b30d0b Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Fri, 9 Oct 2026 12:25:18 +0200 Subject: [PATCH 31/82] Feat(backend): Created readMe for model crate. Half way done. --- backend/crates/core/src/model/README.md | 122 ++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 backend/crates/core/src/model/README.md diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md new file mode 100644 index 000000000..87043b080 --- /dev/null +++ b/backend/crates/core/src/model/README.md @@ -0,0 +1,122 @@ +# Model Module + +## Introduction + +This module is meant to store and organize all the different structs, enums and other data structures necessary for the backend project. + +## 1. `board.rs` +All structs and enums needed for the Pod can be found defined in this file. +### 1.1 `PacketKind` +Enum used to define the three different types of packets. + +#### `Data` +Measurement sent by a board. +#### `Protection` +Measurement with a protection. +#### `Order` +An order sent to a board. + +### 1.2 `PacketDef` +Struct used to define the structure of a packet a board can send or recieve. + +#### `id` +ID of a packet, using the AdjId type which is a u16. Structure of ID is defined by the ADJ documentation. +#### `name` +Human-readable String shown to the user to identify packet. +#### `kind` +PacketKind Enum used to classify each packet. +#### `measurements` +Vector list of measurements attached to a packet. + +### 1.3 `Board` +Struct used to identify a single board in the vehicle. + +#### `id` +Board's unique Id in u16 format. +#### `name` +Boards name. Eg. "BSU". +#### `ip` +Board's IpAddress used to open TCP/UDP connections. +#### `mac` +Board's MAC address +#### `packets` +The Vector list of packets this board can send or receive. + +### 1.4 `PodData` +Struct used to package all the structs of a vehicle. +#### `boards` +Vector list of boards in the Pod + + +## 2. `id.rs` +Defined as their own types (wrapping a `u16`) instead of using a plain `u16`, so the compiler won't let an `AdjId` be confused with a `BoardId` by mistake, even though both are just numbers underneath +### 2.1 `AdjId` +ID of an ADJ packet in u16. Structure of ID can be found in the ADJ documentation. +### 2.2 `BoardId` +Unique identifier of a board, defined by the ADJ documentation. +### 2.3 `MacAddress` +A board's physical MAC address, as 6 raw bytes. + + +## 3. `measurement.rs` +A `Measurement` describes a single field of a packet, as defined by the ADJ: whether it holds a number, a boolean, or the variant of an enum, and, for numeric measurements, its wire type and its safe and warning ranges. This is a *description*, not a decoded value — see`super::Value` for the value itself, once decoded. + +### 3.1 `NumericKind` +Enum used to classify every numerical value type a measurement can use. +- Unsigned 8-bit integer (`U8`) +- Unsigned 16-bit integer (`U16`) +- Unsigned 32-bit integer (`U32`) +- Unsigned 64-bit integer (`U64`) +- Signed 8-bit integer (`I8`) +- Signed 16-bit integer (`I16`) +- Signed 32-bit integer (`I32`) +- Signed 64-bit integer (`I64`) +- 32-bit floating point number (`F32`) +- 64-bit floating point number (`F64`) + +### 3.2 `Range` +Struct used to define a maximum and minimum value of any measurement. +#### `min` +#### `max` + +### 3.3 `MeasurementKind` +Enum used to classify the kind of measurement. +#### `Numeric` +#### `Boolean` +#### `Enum` + +### 3.4 `Measurement` +Struct used to define a measurement. +#### `id` +The measurement's numeric ADJ id. +#### `alias` +The legacy textual identifier, used by code generation +#### `name` +The human-readable string shown to the user. +#### `display_units` +Name of the unit of measurement to display to user. Eg. "ºC". +#### `kind` +Usees MeasurementKind enum to classify what type this measurement holds. +#### `protections` +Vector list of protections. Maximum of 7 per measurement. + +## 4. `packet.rs` +`Packet` is a single message actually sent or received over the wire, once decoded. This is the *instance* — as opposed to`super::Measurement` and `super::Protection`, which describe what a packet or a protection *can* look like, or `super::PacketDef`, which describes a whole packet. + +### 4.1 `ShortTimestamp` +A short timestamp carrying only the time of day: hour, minute, second and a sub-second fraction, each one byte. + +#### `hour` +Hour of Timestamp of values 0-23. +#### `minute` +Minute of Timestamp of values 0-60. +#### `second` +Second of Timestamp of values 0-60. +#### `subsecond` +The sub-second fraction, as the raw byte on the wire.Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. + +### 4.2 `DataPacket` +Struct for the decoded values of the measurements a board just sent. + +#### `id` +AdjId of packet. \ No newline at end of file From 8cae7549708d5c548db6a1fff3e08d374e98367d Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Fri, 9 Oct 2026 12:48:11 +0200 Subject: [PATCH 32/82] Feat(backend): Final model Readme --- backend/crates/core/src/model/README.md | 124 ++++++++++++++++++++++-- 1 file changed, 116 insertions(+), 8 deletions(-) diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index 87043b080..c08c5d5a5 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -101,22 +101,130 @@ Usees MeasurementKind enum to classify what type this measurement holds. Vector list of protections. Maximum of 7 per measurement. ## 4. `packet.rs` -`Packet` is a single message actually sent or received over the wire, once decoded. This is the *instance* — as opposed to`super::Measurement` and `super::Protection`, which describe what a packet or a protection *can* look like, or `super::PacketDef`, which describes a whole packet. +- `Packet` is a single message actually sent or received over the wire, once decoded. This is the *instance* — as opposed to `super::Measurement` and `super::Protection`, which describe what a packet or a protection *can* look like, or `super::PacketDef`, which describes a whole packet. ### 4.1 `ShortTimestamp` -A short timestamp carrying only the time of day: hour, minute, second and a sub-second fraction, each one byte. +A short timestamp carrying only the time of day: hour, minute, second and a sub-second fraction, each one byte. Used by `ProtectionPacket` and `MessagePacket`. Only records *when today* something happened; the date is assumed to be the day the packet was received. #### `hour` -Hour of Timestamp of values 0-23. +Hour of Timestamp of values 0–23. #### `minute` -Minute of Timestamp of values 0-60. +Minute of Timestamp of values 0–59. #### `second` -Second of Timestamp of values 0-60. +Second of Timestamp of values 0–59. #### `subsecond` -The sub-second fraction, as the raw byte on the wire.Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. +The sub-second fraction, as the raw byte on the wire. Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. ### 4.2 `DataPacket` -Struct for the decoded values of the measurements a board just sent. +Struct for the decoded values of the measurements a board just sent. Values are positional: they are not individually tagged with their measurement ID, but written in the order their `Measurement`s appear in `PacketDef::measurements`. #### `id` -AdjId of packet. \ No newline at end of file +Which packet definition this is an instance of (`AdjId`). +#### `values` +Vector list of decoded values (`Vec`) corresponding positionally to the packet definition's measurements. + +### 4.3 `OrderPacket` +An order sent from the backend to a board. Also positional, matching field definitions in order. + +#### `id` +Which order definition this is an instance of (`AdjId`). +#### `fields` +Vector list of values (`Vec`) to set for each field of the order. + +### 4.4 `ProtectionPacket` +Sent by a board when one of its declared protections triggers. + +#### `measurement` +The measurement this protection watches (`AdjId`). +#### `protection` +Which of that measurement's protections triggered (`AdjId`). +#### `timestamp` +When the protection triggered (`ShortTimestamp`). +#### `value` +The value that triggered the protection (`Value`), decoded with the same wire type as the watched measurement. + +### 4.5 `MessageLevel` +Enum indicating how severe a message is (reusing `general_info.json`'s `message_ids`). Distinct from `Severity` because message and protection formats are separate in ADJv3. + +#### `Fault` +Stops the vehicle automatically. +#### `Warning` +Flags something unexpected, without stopping the vehicle. +#### `Info` +Informational message, no action implied. +#### `Panic` +An unrecoverable firmware error. + +### 4.6 `MessagePacket` +A free-text message sent by a board. + +#### `level` +How severe the message is (`MessageLevel`). +#### `timestamp` +When the message was sent (`ShortTimestamp`). +#### `origin` +Source identifier, such as a source file path (`String`). +#### `message` +The text contents of the message (`String`). + +### 4.7 `Packet` +Enum representing any packet actually sent or received over the wire, once decoded. + +#### `Data` +Wraps a `DataPacket` (measurement data sent by a board). +#### `Order` +Wraps an `OrderPacket` (command sent to a board). +#### `Protection` +Wraps a `ProtectionPacket` (triggered protection event). +#### `Message` +Wraps a `MessagePacket` (free-text log/diagnostic message). + +## 5. `protection.rs` +A `Protection` is a bound checked against a measurement's decoded value, as declared in the ADJ (`_measurements.json`). This defines the *declaration* (what to watch for, thresholds, and severity) rather than the runtime wire packet emitted when triggered (`ProtectionPacket` in `packet.rs`). In ADJv3, protections and messages are distinct formats with separate lifecycles. + +### 5.1 `ProtectionKind` +Enum defining the condition a protection evaluates and the associated threshold bound(s). Modeled as data-carrying variants to ensure compile-time validity instead of untyped lists. + +#### `Above` +Triggers when the value exceeds `limit` (`f64`). +#### `Below` +Triggers when the value drops below `limit` (`f64`). +#### `Equal` +Triggers when the value equals forbidden `target` (`f64`). +#### `NotEqual` +Triggers when the value deviates from expected `target` (`f64`). +#### `Range` +Triggers when the value leaves the closed `[min, max]` interval (`min: f64`, `max: f64`). Both bounds are strictly required. + +### 5.2 `Severity` +Enum indicating the operational severity level of a triggered protection. + +#### `Fault` +Critical error that stops the vehicle automatically. +#### `Warning` +Flags the condition as anomalous without halting vehicle operation. + +### 5.3 `Protection` +Struct defining a single protection rule attached to a measurement. + +#### `id` +The protection's relative slot index within its parent measurement's protection list, stored as an `AdjId` in the `1–7` range. This is a local index rather than a global ID; it forms the upper 3 bits of the compound wire identifier alongside the 13-bit measurement ID. +#### `kind` +The evaluation rule and threshold bounds (`ProtectionKind`). +#### `severity` +The operational impact level when triggered (`Severity`). +#### `time` +Duration the measured value must remain out of bounds before the protection trips (`Duration`). A value of `Duration::ZERO` indicates an instantaneous trigger. + +## 6. `value.rs` +Defines how the backend represents a piece of data once decoded, regardless of its original wire representation. While the ADJ supports various integer and floating-point types on the wire (captured during decoding by `NumericKind`), all numeric values normalize to `f64` once inside the backend. + +### 6.1 `Value` +Enum representing a single decoded measurement or field value. + +#### `Number(f64)` +A numeric measurement, regardless of its original wire type (`u8`, `i32`, `f32`, etc.). +#### `Bool(bool)` +A boolean measurement (`true` or `false`). +#### `Enum(String)` +The selected variant name of an enumerated measurement (e.g. `"Operational"`). \ No newline at end of file From 579fc5690099b757e94ca7d811f3f47db8ec2590 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Fri, 9 Oct 2026 12:55:13 +0200 Subject: [PATCH 33/82] Feat(backend): Created ADJ crate README --- backend/crates/core/src/adj/README.md | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/crates/core/src/adj/README.md diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md new file mode 100644 index 000000000..1b734a1f9 --- /dev/null +++ b/backend/crates/core/src/adj/README.md @@ -0,0 +1,50 @@ +# ADJ Module (`adj`) + +This module deserializes, validates, and loads ADJ JSON fixtures into the backend domain model. + +For schema specifications, packet/measurement ID conventions, and network requirements, refer directly to the official documentation in the **ADJ repository**. + +--- + +## The `Adj` Object Structure + +Calling `load_from_dir` produces a root `Adj` instance: + +```rust +pub struct Adj { + pub pod_data: PodData, + pub info: AdjInfo, +} +``` + +```text +Adj +├── pod_data (PodData) +│ └── boards: HashMap +│ ├── measurements: HashMap +│ └── packets: HashMap +│ ├── explicit (Data, Order, Message, Protection) +│ └── synthetic (Protection alerts derived from measurements) +└── info (AdjInfo) + ├── ports: HashMap + ├── addresses: HashMap + ├── message_ids: HashMap + └── protection_types: HashMap +``` + +--- + +## Why It Is Structured This Way + +The `Adj` object enforces a clear architectural boundary between **vehicle topology** and **session configuration**: + +### 1. Separation of Concerns (`PodData` vs. `AdjInfo`) +* **`pod_data` (Vehicle Domain)**: Represents telemetry, controls, and board topology intrinsic to the physical vehicle. It contains only what exists on the vehicle bus (boards, packets, measurements). +* **`info` (Session & Tooling Domain)**: Captures session-level networking and station UI requirements (host addresses, local server port bindings, and alert format strings like `text` and `textTime`). +* **Design rationale**: The vehicle itself does not know or care about backend socket configurations or display strings. Separating them prevents UI and orchestration details from polluting the core telemetry model. + +### 2. Standalone Loader Submodule (`raw`) +The raw deserialization types and the loader logic live isolated inside `raw`[cite: 2]. This enables non-station tools (such as bus sniffers, test runners, or simulators) to parse and ingest ADJ directories without dragging along application-level UI or station state logic. + +### 3. Unified Packet Map (`packets`) +Protections defined under measurements are materialized during loading as first-class `PacketKind::Protection` packets alongside explicit data and order packets. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. \ No newline at end of file From 0f42fd922b2e4d90c75d0ebc2d34f80b77bce9d2 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 14:16:07 +0200 Subject: [PATCH 34/82] fix(backend, adj/model): remove mesuraments for orders (partial reafactor) --- backend/crates/core/src/adj/README.md | 8 +-- backend/crates/core/src/adj/raw/loader.rs | 66 +++++++++++++++-------- backend/crates/core/src/adj/raw/types.rs | 2 +- backend/crates/core/src/model/README.md | 30 ++++++----- backend/crates/core/src/model/board.rs | 51 ++++++++++++------ backend/crates/core/src/model/packet.rs | 4 +- 6 files changed, 105 insertions(+), 56 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index 1b734a1f9..33bdedb49 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -23,8 +23,10 @@ Adj │ └── boards: HashMap │ ├── measurements: HashMap │ └── packets: HashMap -│ ├── explicit (Data, Order, Message, Protection) -│ └── synthetic (Protection alerts derived from measurements) +│ ├── explicit (Data, Order, Message — declared in packets.json) +│ └── synthetic (Protection — one per declared protection, +│ derived from each measurement's own `protections`; +│ never declared directly in packets.json) └── info (AdjInfo) ├── ports: HashMap ├── addresses: HashMap @@ -47,4 +49,4 @@ The `Adj` object enforces a clear architectural boundary between **vehicle topol The raw deserialization types and the loader logic live isolated inside `raw`[cite: 2]. This enables non-station tools (such as bus sniffers, test runners, or simulators) to parse and ingest ADJ directories without dragging along application-level UI or station state logic. ### 3. Unified Packet Map (`packets`) -Protections defined under measurements are materialized during loading as first-class `PacketKind::Protection` packets alongside explicit data and order packets. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. \ No newline at end of file +Protections defined under measurements are materialized during loading as first-class `PacketKind::Protection(Measurement)` packets alongside the explicit data, order and message packets declared in `packets.json`. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. `packets.json` itself never declares a `"type": "protection"` entry directly — that shape only exists synthesized, one per protection a measurement declares, never as a free-standing declaration with its own arbitrary variable list. \ No newline at end of file diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 02d4750a3..6d6f4be3c 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -79,8 +79,7 @@ pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result, ) -> Result { let kind = match raw.kind.as_str() { - "data" => PacketKind::Data, - "order" => PacketKind::Order, - "protection" => PacketKind::Protection, + "data" => PacketKind::Data(resolve_measurements( + board, + &raw.name, + &raw.variables, + measurements, + )?), + "order" => PacketKind::Order(resolve_measurements( + board, + &raw.name, + &raw.variables, + measurements, + )?), "message" => PacketKind::Message, other => { return Err(LoadError::UnknownPacketType { @@ -142,27 +150,36 @@ fn build_packet_def( } }; - let mut packet_measurements = Vec::with_capacity(raw.variables.len()); - for alias in &raw.variables { + Ok(PacketDef { + id: AdjId(raw.id), + board: board_id, + name: raw.name, + kind, + }) +} + +/// Resolves `variables` (a packet's declared measurement aliases) against +/// `measurements`, in order. +fn resolve_measurements( + board: &str, + packet_name: &str, + variables: &[String], + measurements: &HashMap, +) -> Result, LoadError> { + let mut resolved = Vec::with_capacity(variables.len()); + for alias in variables { let measurement = measurements .get(alias) .cloned() .ok_or_else(|| LoadError::UnknownAlias { board: board.to_string(), - name: raw.name.clone(), + name: packet_name.to_string(), alias: alias.clone(), })?; - packet_measurements.push(measurement); + resolved.push(measurement); } - - Ok(PacketDef { - id: AdjId(raw.id), - board: board_id, - name: raw.name, - kind, - measurements: packet_measurements, - }) + Ok(resolved) } /// Parses a MAC address text form, e.g. `"aa:bb:cc:dd:ee:ff"` or the @@ -269,12 +286,16 @@ mod tests { let Some(order) = vcu.packets.get(&AdjId(600)) else { unreachable!("VCU should have order 600"); }; - assert_eq!(order.kind, PacketKind::Order); + let PacketKind::Order(_) = &order.kind else { + unreachable!("order 600 should be an Order"); + }; assert_eq!(order.board, BoardId(3)); let Some(data) = vcu.packets.get(&AdjId(625)) else { unreachable!("VCU should have data packet 625"); }; - assert_eq!(data.kind, PacketKind::Data); + let PacketKind::Data(_) = &data.kind else { + unreachable!("data packet 625 should be Data"); + }; let Some(pcu) = adj.pod_data.boards.get(&BoardId(5)) else { unreachable!("PCU should be among the loaded boards"); @@ -282,7 +303,7 @@ mod tests { let Some(protected) = pcu .packets .values() - .flat_map(|p| &p.measurements) + .flat_map(|p| p.kind.measurements()) .find(|m| !m.protections.is_empty()) else { unreachable!("PCU should have a measurement with a protection"); @@ -314,7 +335,10 @@ mod tests { let Some(protection_packet) = pcu.packets.get(&AdjId((1u16 << 13) | protected.id.0)) else { unreachable!("PCU should have a synthetic protection packet"); }; - assert_eq!(protection_packet.kind, PacketKind::Protection); + let PacketKind::Protection(measurement) = &protection_packet.kind else { + unreachable!("protection_packet should be a Protection"); + }; + assert_eq!(measurement, protected); assert_eq!(protection_packet.board, pcu.id); } } diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index 0f130cb30..7b02ef51c 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -599,7 +599,7 @@ mod tests { let protection_count = board .packets .values() - .filter(|p| p.kind == PacketKind::Protection) + .filter(|p| matches!(p.kind, PacketKind::Protection(_))) .count(); assert_eq!( protection_count, 2, diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index c08c5d5a5..600ed0f68 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -7,26 +7,30 @@ This module is meant to store and organize all the different structs, enums and ## 1. `board.rs` All structs and enums needed for the Pod can be found defined in this file. ### 1.1 `PacketKind` -Enum used to define the three different types of packets. +Enum used to define what kind of packet a `PacketDef` declares. Not every kind carries the same shape, so each variant holds whatever measurement data is actually meaningful for it, instead of every packet being forced through one shared field. + +#### `Data(Vec)` +Telemetry sent by a board: one value per measurement, decoded positionally in this order. +#### `Protection(Measurement)` +The single measurement one of a board's declared protections watches. Always exactly one — never a list. +#### `Order(Vec)` +An order sent to a board. Many orders take no parameters at all, so this can be empty. +#### `Message` +A free-text log line. Never carries measurement data. -#### `Data` -Measurement sent by a board. -#### `Protection` -Measurement with a protection. -#### `Order` -An order sent to a board. +`PacketKind` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its kind. ### 1.2 `PacketDef` Struct used to define the structure of a packet a board can send or recieve. #### `id` ID of a packet, using the AdjId type which is a u16. Structure of ID is defined by the ADJ documentation. +#### `board` +The owning board's `BoardId`. Carried here too (not just reachable through the owning `Board`) so anything holding just a `&PacketDef` — e.g. a lookup table indexed by `AdjId` — knows which board it belongs to without a separate index back to it. #### `name` Human-readable String shown to the user to identify packet. #### `kind` -PacketKind Enum used to classify each packet. -#### `measurements` -Vector list of measurements attached to a packet. +`PacketKind` enum: both what kind of packet this is, and the measurement shape — if any — specific to that kind. ### 1.3 `Board` Struct used to identify a single board in the vehicle. @@ -40,12 +44,12 @@ Board's IpAddress used to open TCP/UDP connections. #### `mac` Board's MAC address #### `packets` -The Vector list of packets this board can send or receive. +The packets this board can send or receive, as a `HashMap` keyed by id — the ADJ gives packets no meaningful order, so this is a map, not a list. ### 1.4 `PodData` Struct used to package all the structs of a vehicle. #### `boards` -Vector list of boards in the Pod +Every board of the vehicle, as a `HashMap` keyed by id — the ADJ doesn't give boards any meaningful order either. ## 2. `id.rs` @@ -116,7 +120,7 @@ Second of Timestamp of values 0–59. The sub-second fraction, as the raw byte on the wire. Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. ### 4.2 `DataPacket` -Struct for the decoded values of the measurements a board just sent. Values are positional: they are not individually tagged with their measurement ID, but written in the order their `Measurement`s appear in `PacketDef::measurements`. +Struct for the decoded values of the measurements a board just sent. Values are positional: they are not individually tagged with their measurement ID, but written in the order their `Measurement`s appear in this packet's `PacketKind::Data` list. #### `id` Which packet definition this is an instance of (`AdjId`). diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 3692a74f9..18cef3c3e 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -14,19 +14,38 @@ use super::{AdjId, BoardId, MacAddress, Measurement}; use std::collections::HashMap; use std::net::IpAddr; -/// What kind of packet a [`PacketDef`] declares. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +/// What kind of packet a [`PacketDef`] declares, carrying whatever +/// measurement shape — if any — is specific to that kind. Not every kind +/// has measurements, so this says so in the type instead of forcing every +/// [`PacketDef`] through one shared `measurements: Vec` field +/// regardless of whether that's meaningful for it. +#[derive(Debug, Clone, PartialEq)] pub enum PacketKind { - /// Measurements sent by a board. - Data, - /// Protection associated with a measurement. - Protection, - /// An order sent to a board. - Order, - /// A message sent by a board. + /// A board's periodic telemetry: one value per measurement, decoded + /// positionally in this order. + Data(Vec), + /// The single measurement one of a board's declared protections + /// watches. + Protection(Measurement), + /// A command sent to a board. Many orders take no parameters at all. + Order(Vec), + /// A free-text log line. Never carries measurement data. Message, } +impl PacketKind { + /// Every measurement this packet's kind references, flattened — for + /// code that doesn't care which kind this is, just what measurements + /// it touches. + pub fn measurements(&self) -> &[Measurement] { + match self { + PacketKind::Data(measurements) | PacketKind::Order(measurements) => measurements, + PacketKind::Protection(measurement) => std::slice::from_ref(measurement), + PacketKind::Message => &[], + } + } +} + /// The declaration of a single packet a board can send or receive, as /// defined by the ADJ. /// @@ -44,10 +63,9 @@ pub struct PacketDef { pub board: BoardId, /// The human-readable name shown to the user. pub name: String, - /// Whether this is data, order, protection or message. + /// Whether this is data, order, protection or message — and the + /// measurement shape specific to that kind, if any. pub kind: PacketKind, - /// The measurements this packet carries, in ADJ order. - pub measurements: Vec, } /// A single board of the vehicle, as defined by the ADJ. @@ -112,8 +130,7 @@ mod tests { id: AdjId(1000), board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data, - measurements: vec![sample_measurement()], + kind: PacketKind::Data(vec![sample_measurement()]), }, )]), }; @@ -122,8 +139,10 @@ mod tests { let Some(packet) = board.packets.get(&AdjId(1000)) else { unreachable!("the packet we just inserted should be there"); }; - assert_eq!(packet.measurements.len(), 1); - assert_eq!(packet.kind, PacketKind::Data); + let PacketKind::Data(measurements) = &packet.kind else { + unreachable!("packet should be Data"); + }; + assert_eq!(measurements.len(), 1); assert_eq!(board.mac.to_string(), "00:11:22:33:44:55"); } } diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 61a3bd797..7ac91f048 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -38,8 +38,8 @@ pub struct ShortTimestamp { /// The wire protocol is positional: values are not individually tagged /// with their measurement's id, just written back to back in the order /// their [`super::Measurement`]s appear in this packet's -/// [`super::PacketDef::measurements`]. `values[i]` is the decoded value of -/// `measurements[i]` in that definition — matching them up by position is +/// [`super::PacketKind::Data`] list. `values[i]` is the decoded value of +/// that list's `i`-th measurement — matching them up by position is /// `protocol`'s job, this type just holds the result in that same order. #[derive(Debug, Clone, PartialEq)] pub struct DataPacket { From 9451b98f2974a5e849198c94328bbd594d8afae7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 14:19:46 +0200 Subject: [PATCH 35/82] feat(backend, prtotocol): update protocol --- backend/crates/core/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/crates/core/src/lib.rs b/backend/crates/core/src/lib.rs index e1eda88f6..009b6d295 100644 --- a/backend/crates/core/src/lib.rs +++ b/backend/crates/core/src/lib.rs @@ -10,3 +10,8 @@ pub mod model; /// [`model::PodData`], plus backend-session metadata not modeled as /// vehicle data. See [`adj::load_from_dir`]. pub mod adj; + +/// Translates bytes into typed packets and back, using the shapes +/// [`adj`] declares as the schema. See [`protocol::wire`], +/// [`protocol::tcp`], [`protocol::udp`]. +pub mod protocol; From dba85ba46e2d189f7d47dd381721d8cbe166a42d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Fri, 9 Oct 2026 14:20:19 +0200 Subject: [PATCH 36/82] feat(backend, protocol): main structure --- backend/crates/core/src/protocol.rs | 24 ++ backend/crates/core/src/protocol/error.rs | 92 +++++ backend/crates/core/src/protocol/index.rs | 78 +++++ backend/crates/core/src/protocol/packet.rs | 11 + .../crates/core/src/protocol/packet/data.rs | 19 ++ .../core/src/protocol/packet/message.rs | 16 + .../crates/core/src/protocol/packet/order.rs | 18 + .../core/src/protocol/packet/protection.rs | 26 ++ backend/crates/core/src/protocol/tcp.rs | 49 +++ backend/crates/core/src/protocol/udp.rs | 17 + backend/crates/core/src/protocol/wire.rs | 316 ++++++++++++++++++ 11 files changed, 666 insertions(+) create mode 100644 backend/crates/core/src/protocol.rs create mode 100644 backend/crates/core/src/protocol/error.rs create mode 100644 backend/crates/core/src/protocol/index.rs create mode 100644 backend/crates/core/src/protocol/packet.rs create mode 100644 backend/crates/core/src/protocol/packet/data.rs create mode 100644 backend/crates/core/src/protocol/packet/message.rs create mode 100644 backend/crates/core/src/protocol/packet/order.rs create mode 100644 backend/crates/core/src/protocol/packet/protection.rs create mode 100644 backend/crates/core/src/protocol/tcp.rs create mode 100644 backend/crates/core/src/protocol/udp.rs create mode 100644 backend/crates/core/src/protocol/wire.rs diff --git a/backend/crates/core/src/protocol.rs b/backend/crates/core/src/protocol.rs new file mode 100644 index 000000000..4ee7cfdd1 --- /dev/null +++ b/backend/crates/core/src/protocol.rs @@ -0,0 +1,24 @@ +//! Translates bytes <-> typed packets ([`crate::model::Packet`] and +//! friends), using the shapes [`crate::adj`] already declared +//! (`PacketDef`, `Measurement`) as the schema. Never touches a real +//! socket — that's `core::net`'s job, which calls into this module's +//! functions and owns the actual `TcpStream`/`UdpSocket`. +//! +//! The TCP/UDP split lives in the *types* each submodule can produce: +//! [`crate::protocol::udp::decode_datagram`] can only ever return a +//! `DataPacket`, [`crate::protocol::tcp::decode_next`] only a +//! [`crate::protocol::tcp::TcpFrame`] (`Protection`/`Message`) — the +//! compiler enforces the separation the ADJ itself draws between what +//! travels over which transport, not runtime convention. `tcp`/`udp` +//! themselves are thin wrappers around [`crate::protocol::packet`], which +//! does the actual per-packet-kind work independent of transport. + +mod error; +mod index; +pub mod packet; +pub mod tcp; +pub mod udp; +pub mod wire; + +pub use error::{DecodeError, EncodeError}; +pub use index::Index; diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs new file mode 100644 index 000000000..64660e696 --- /dev/null +++ b/backend/crates/core/src/protocol/error.rs @@ -0,0 +1,92 @@ +//! Errors produced while translating bytes into packets and back. + +use crate::model::AdjId; + +/// An error decoding bytes into a packet. +#[derive(Debug, thiserror::Error)] +pub enum DecodeError { + /// No packet is declared with this id. + #[error("unknown packet id {0:?}")] + UnknownId(AdjId), + + /// Ran out of bytes before a complete value could be decoded. + #[error("not enough bytes to decode a value")] + UnexpectedEof, + + /// An I/O error while reading from a stream (TCP only — a UDP + /// datagram is decoded from an in-memory buffer, never a stream). + #[error("I/O error reading a frame")] + Io(#[from] std::io::Error), + + /// A decoded enum value's index doesn't match any of its declared + /// options. + #[error("enum value {index} is out of range for {options} option(s)")] + InvalidEnumValue { + /// The decoded index. + index: u32, + /// How many options the measurement declares. + options: usize, + }, + + /// A free-text field (`origin`/`message`) had no terminating `0` byte + /// within its maximum length. + #[error("string field wasn't terminated within {max} bytes")] + UnterminatedString { + /// The field's maximum length. + max: usize, + }, + + /// A looked-up packet exists, but isn't the kind this function expects + /// (e.g. `packet::data::decode` found an id that resolves to an + /// `Order`). + #[error("packet {id:?} isn't the expected kind")] + WrongKind { + /// The packet whose kind didn't match. + id: AdjId, + }, +} + +/// An error encoding a packet into bytes. +#[derive(Debug, thiserror::Error)] +pub enum EncodeError { + /// No packet is declared with this id. + #[error("unknown packet id {0:?}")] + UnknownId(AdjId), + + /// The order didn't provide a value for every field its declaration + /// expects. + #[error("order needs {expected} field(s), got {got}")] + WrongFieldCount { + /// How many fields the order's `PacketDef` declares. + expected: usize, + /// How many fields were actually given. + got: usize, + }, + + /// A field's [`crate::model::Value`] doesn't match the kind its + /// [`crate::model::Measurement`] declares (e.g. a `Value::Bool` for a + /// numeric field). + #[error("value doesn't match its declared type")] + TypeMismatch, + + /// A numeric value doesn't fit the wire type's representable range + /// (or is NaN). + #[error("value {value} is out of range for this wire type ({min}..={max})")] + OutOfRange { + /// The value that didn't fit. + value: f64, + /// The wire type's minimum representable value. + min: f64, + /// The wire type's maximum representable value. + max: f64, + }, + + /// A looked-up packet exists, but isn't the kind this function expects + /// (e.g. `packet::order::encode` found an id that resolves to a + /// `Data` packet). + #[error("packet {id:?} isn't the expected kind")] + WrongKind { + /// The packet whose kind didn't match. + id: AdjId, + }, +} diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs new file mode 100644 index 000000000..c23397bf8 --- /dev/null +++ b/backend/crates/core/src/protocol/index.rs @@ -0,0 +1,78 @@ +//! The `id -> PacketDef` lookup `tcp`/`udp` decode against. + +use crate::model::{AdjId, PacketDef, PodData}; +use std::collections::HashMap; + +/// A lookup table from a packet's [`AdjId`] to its declaration, built once +/// from a loaded ADJ's [`PodData`]. +/// +/// Owns its own copy of every [`PacketDef`] (cloned out of `PodData` at +/// construction) rather than borrowing, so it carries no lifetime and can +/// be shared across `tokio::spawn`ed tasks via `Arc` without a +/// fight with the borrow checker. Knows nothing about networking (no +/// `IpAddr`/`SocketAddr`) — that bookkeeping belongs to `core::net`, not +/// here. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Index { + by_id: HashMap, +} + +impl Index { + /// Builds an index over every packet declared by every board in + /// `pod_data`. + pub fn new(pod_data: &PodData) -> Self { + let by_id = pod_data + .boards + .values() + .flat_map(|board| board.packets.values()) + .map(|packet| (packet.id, packet.clone())) + .collect(); + Self { by_id } + } + + /// Looks up a packet's declaration by id. + pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { + self.by_id.get(&id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Board, BoardId, MacAddress, PacketKind}; + use std::net::IpAddr; + + fn sample_pod_data() -> PodData { + let packet = PacketDef { + id: AdjId(1000), + board: BoardId(1), + name: "bcu_data".to_string(), + kind: PacketKind::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([192, 168, 0, 10]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + PodData { + boards: HashMap::from([(board.id, board)]), + } + } + + #[test] + fn finds_a_packet_declared_on_any_board() { + let index = Index::new(&sample_pod_data()); + let Some(packet) = index.packet(AdjId(1000)) else { + unreachable!("packet 1000 was declared in the sample pod data"); + }; + assert_eq!(packet.name, "bcu_data"); + } + + #[test] + fn unknown_id_is_not_found() { + let index = Index::new(&sample_pod_data()); + assert!(index.packet(AdjId(9999)).is_none()); + } +} diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs new file mode 100644 index 000000000..b1c19e246 --- /dev/null +++ b/backend/crates/core/src/protocol/packet.rs @@ -0,0 +1,11 @@ +//! Encode/decode for one specific kind of packet, independent of which +//! transport carries it — that mapping lives in [`super::tcp`]/ +//! [`super::udp`], which are thin wrappers around these. Each kind only +//! implements whichever direction it actually needs: a `Data` packet is +//! only ever received (over UDP), an `Order` only ever sent (over TCP), +//! so there's no `encode` for the former or `decode` for the latter. + +pub mod data; +pub mod message; +pub mod order; +pub mod protection; diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs new file mode 100644 index 000000000..bc613cc8c --- /dev/null +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -0,0 +1,19 @@ +//! Decodes a `Data` packet: a board's periodic telemetry. Only ever +//! received, over UDP — a board never receives one back. + +use crate::model::DataPacket; +use crate::protocol::{DecodeError, Index}; + +/// Decodes a complete, already-delimited buffer into a [`DataPacket`], +/// resolving its field shapes against `index`. +pub fn decode(bytes: &[u8], index: &Index) -> Result { + let _ = (bytes, index); + todo!( + "read the leading 16-bit id; look up its PacketDef in `index` \ + (UnknownId if absent); match its `kind` for \ + PacketKind::Data(measurements) (WrongKind if it's some other \ + kind); decode each measurement's value positionally via \ + wire::decode_value, advancing through `bytes` using each \ + measurement's own wire width" + ) +} diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs new file mode 100644 index 000000000..88f70bd9c --- /dev/null +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -0,0 +1,16 @@ +//! Decodes a `Message` packet: a board's free-text log line. Only ever +//! received, over TCP. + +use crate::model::{MessageLevel, MessagePacket}; +use crate::protocol::DecodeError; +use std::io::Read; + +/// Decodes a message frame's remaining bytes, given the frame's +/// already-resolved severity level (`tcp::decode_next`'s job). +pub fn decode(reader: impl Read, level: MessageLevel) -> Result { + let _ = (reader, level); + todo!( + "read the timestamp, then the null-terminated `origin` (max 48 \ + bytes) and `message` (max 320 bytes) strings" + ) +} diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs new file mode 100644 index 000000000..4eb11a6cf --- /dev/null +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -0,0 +1,18 @@ +//! Encodes an `Order` packet: a command sent to a board. Only ever sent, +//! over TCP — the backend never receives one back. + +use crate::model::OrderPacket; +use crate::protocol::{EncodeError, Index}; + +/// Encodes `order` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { + let _ = (order, index); + todo!( + "look up order.id in `index` to get its PacketDef (UnknownId if \ + absent); match its `kind` for PacketKind::Order(measurements) \ + (WrongKind if it's some other kind); error with WrongFieldCount \ + if order.fields.len() doesn't match measurements.len(); encode \ + the id then each field positionally via wire::encode_value" + ) +} diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs new file mode 100644 index 000000000..e00a4acec --- /dev/null +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -0,0 +1,26 @@ +//! Decodes a `Protection` packet: a board reporting one of its declared +//! protections triggered. Only ever received, over TCP. + +use crate::model::{AdjId, ProtectionPacket}; +use crate::protocol::{DecodeError, Index}; +use std::io::Read; + +/// Decodes a protection frame's remaining bytes, given the frame's +/// already-read compound id (`tcp::decode_next`'s job), resolving the +/// watched measurement's wire type against `index`. +pub fn decode( + reader: impl Read, + compound_id: AdjId, + index: &Index, +) -> Result { + let _ = (reader, compound_id, index); + todo!( + "look up compound_id's PacketDef in `index` (already keyed by \ + this exact compound id, see adj::raw::loader); match its `kind` \ + for PacketKind::Protection(measurement) (WrongKind if it's some \ + other kind) to get the watched measurement's wire type; \ + separately split compound_id's bits into (measurement, position) \ + to fill ProtectionPacket's `measurement`/`protection` fields; \ + read the timestamp, then the value via wire::decode_value" + ) +} diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs new file mode 100644 index 000000000..af3882253 --- /dev/null +++ b/backend/crates/core/src/protocol/tcp.rs @@ -0,0 +1,49 @@ +//! Transport-level TCP framing: a board's dedicated connection carries +//! triggered protections and free-text messages from the board, and +//! orders to it. Never a `Data` packet — see the module doc on +//! [`super`]. +//! +//! Both functions here are thin: the actual per-kind decoding lives in +//! [`super::packet::protection`]/[`super::packet::message`]/ +//! [`super::packet::order`], independent of TCP. This module only adds +//! the transport-level guarantee — [`decode_next`] can only ever return +//! a [`TcpFrame`], never a `DataPacket`. + +use super::{DecodeError, EncodeError, Index, packet}; +use crate::model::{MessagePacket, OrderPacket, ProtectionPacket}; +use std::io::Read; + +/// A decoded TCP frame — everything a board can send to the backend over +/// its dedicated TCP connection. +#[derive(Debug, Clone, PartialEq)] +pub enum TcpFrame { + /// A protection triggered. + Protection(ProtectionPacket), + /// A free-text message. + Message(MessagePacket), +} + +/// Reads and decodes the next complete frame from `reader`, resolving +/// field shapes against `index`. +/// +/// Reads the frame's leading 16-bit id first, then decides which of +/// [`super::packet::protection::decode`] (the id's high 3 bits are a +/// protection's position, non-zero) or [`super::packet::message::decode`] +/// (the id matches one of `general_info.json`'s `message_ids`) to hand +/// the rest of the frame to, wrapping the result in the matching +/// [`TcpFrame`] variant. +pub fn decode_next(reader: impl Read, index: &Index) -> Result { + let _ = (reader, index); + todo!( + "read the leading 16-bit id; if its top 3 bits are non-zero, call \ + packet::protection::decode; otherwise resolve it against \ + general_info.json's message_ids (not yet reachable from `Index` \ + — needs AdjInfo too) and call packet::message::decode; wrap \ + either result in the matching TcpFrame variant" + ) +} + +/// Encodes `order` into the bytes to write to the board's connection. +pub fn encode_order(order: &OrderPacket, index: &Index) -> Result, EncodeError> { + packet::order::encode(order, index) +} diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs new file mode 100644 index 000000000..4eb0d6723 --- /dev/null +++ b/backend/crates/core/src/protocol/udp.rs @@ -0,0 +1,17 @@ +//! Transport-level UDP framing: the shared socket carries periodic +//! telemetry (`Data` packets) from any board. Receive-only — the backend +//! never sends anything over this socket. +//! +//! The actual decoding lives in [`super::packet::data`], independent of +//! UDP — this module only adds the transport-level guarantee: a UDP +//! datagram is a complete, already-delimited buffer (the OS preserves +//! datagram boundaries even on a socket shared by every board), unlike +//! [`super::tcp::decode_next`]'s stream. + +use super::{DecodeError, Index, packet}; +use crate::model::DataPacket; + +/// Decodes a single UDP datagram's bytes into a [`DataPacket`]. +pub fn decode_datagram(bytes: &[u8], index: &Index) -> Result { + packet::data::decode(bytes, index) +} diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs new file mode 100644 index 000000000..6dc151e04 --- /dev/null +++ b/backend/crates/core/src/protocol/wire.rs @@ -0,0 +1,316 @@ +//! Byte <-> [`Value`] codec for a single measurement, given its declared +//! [`MeasurementKind`]. The one piece of `protocol` with no framing or id +//! lookup — just "this type, these bytes, that value" and back. `tcp`/ +//! `udp` build their packet-level decoding on top of this. +//! +//! Every wire width here is fixed by the measurement's own `kind` (a +//! `NumericKind` always has a known byte width; booleans and enums are +//! fixed-width too), so decoding never needs to report back how many +//! bytes it consumed — the caller already has the `MeasurementKind` (from +//! the `Measurement` it's decoding) and can derive the width from it +//! directly if it needs to advance through a buffer. +//! +//! [`decode_value`]/[`encode_value`] are thin dispatchers: they only pick +//! which per-kind function to call. All the actual work lives in its own +//! public function (`decode_numeric`, `decode_boolean`, `decode_enum`...), +//! each independently implementable and testable. + +use super::{DecodeError, EncodeError}; +use crate::model::{MeasurementKind, NumericKind, Value}; + +/// Decodes a single value from the front of `bytes`, according to `kind`. +pub fn decode_value(bytes: &[u8], kind: &MeasurementKind) -> Result { + match kind { + MeasurementKind::Numeric { wire_type } => decode_numeric(bytes, *wire_type), + MeasurementKind::Boolean => decode_boolean(bytes), + MeasurementKind::Enum { options } => decode_enum(bytes, options), + } +} + +/// Encodes a single value according to `kind`. +pub fn encode_value(value: &Value, kind: &MeasurementKind) -> Result, EncodeError> { + match kind { + MeasurementKind::Numeric { wire_type } => encode_numeric(value, *wire_type), + MeasurementKind::Boolean => encode_boolean(value), + MeasurementKind::Enum { options } => encode_enum(value, options), + } +} + +/// Decodes a numeric value of the given wire type, little-endian. +pub fn decode_numeric(bytes: &[u8], wire_type: NumericKind) -> Result { + fn take(bytes: &[u8]) -> Result<[u8; N], DecodeError> { + bytes + .get(..N) + .and_then(|slice| slice.try_into().ok()) + .ok_or(DecodeError::UnexpectedEof) + } + + let number: f64 = match wire_type { + NumericKind::U8 => f64::from(u8::from_le_bytes(take::<1>(bytes)?)), + NumericKind::U16 => f64::from(u16::from_le_bytes(take::<2>(bytes)?)), + NumericKind::U32 => f64::from(u32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::U64 => u64::from_le_bytes(take::<8>(bytes)?) as f64, + NumericKind::I8 => f64::from(i8::from_le_bytes(take::<1>(bytes)?)), + NumericKind::I16 => f64::from(i16::from_le_bytes(take::<2>(bytes)?)), + NumericKind::I32 => f64::from(i32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::I64 => i64::from_le_bytes(take::<8>(bytes)?) as f64, + NumericKind::F32 => f64::from(f32::from_le_bytes(take::<4>(bytes)?)), + NumericKind::F64 => f64::from_le_bytes(take::<8>(bytes)?), + }; + + Ok(Value::Number(number)) +} + +/// Encodes a numeric value as the given wire type, little-endian. +/// +/// Errors with [`EncodeError::OutOfRange`] if `value` doesn't fit the +/// wire type's representable range (this also rejects NaN, which compares +/// false to every bound). +pub fn encode_numeric(value: &Value, wire_type: NumericKind) -> Result, EncodeError> { + let Value::Number(number) = value else { + return Err(EncodeError::TypeMismatch); + }; + let number = *number; + + let (min, max) = numeric_range(wire_type); + if !(min..=max).contains(&number) { + return Err(EncodeError::OutOfRange { + value: number, + min, + max, + }); + } + + Ok(match wire_type { + NumericKind::U8 => vec![number as u8], // no need to call `to_le_bytes` for a single byte + NumericKind::U16 => (number as u16).to_le_bytes().to_vec(), + NumericKind::U32 => (number as u32).to_le_bytes().to_vec(), + NumericKind::U64 => (number as u64).to_le_bytes().to_vec(), + NumericKind::I8 => (number as i8).to_le_bytes().to_vec(), + NumericKind::I16 => (number as i16).to_le_bytes().to_vec(), + NumericKind::I32 => (number as i32).to_le_bytes().to_vec(), + NumericKind::I64 => (number as i64).to_le_bytes().to_vec(), + NumericKind::F32 => (number as f32).to_le_bytes().to_vec(), + NumericKind::F64 => number.to_le_bytes().to_vec(), + }) +} + +/// The `[min, max]` range a wire type can represent, as `f64` bounds. +fn numeric_range(wire_type: NumericKind) -> (f64, f64) { + match wire_type { + NumericKind::U8 => (f64::from(u8::MIN), f64::from(u8::MAX)), + NumericKind::U16 => (f64::from(u16::MIN), f64::from(u16::MAX)), + NumericKind::U32 => (f64::from(u32::MIN), f64::from(u32::MAX)), + NumericKind::U64 => (u64::MIN as f64, u64::MAX as f64), + NumericKind::I8 => (f64::from(i8::MIN), f64::from(i8::MAX)), + NumericKind::I16 => (f64::from(i16::MIN), f64::from(i16::MAX)), + NumericKind::I32 => (f64::from(i32::MIN), f64::from(i32::MAX)), + NumericKind::I64 => (i64::MIN as f64, i64::MAX as f64), + NumericKind::F32 => (f64::from(f32::MIN), f64::from(f32::MAX)), + NumericKind::F64 => (f64::MIN, f64::MAX), + } +} + +/// Decodes a boolean value. +/// +/// Wire width isn't settled yet — not specified anywhere in the ADJv3 +/// spec read so far. Confirm against firmware before implementing. +pub fn decode_boolean(bytes: &[u8]) -> Result { + let _ = bytes; + todo!("confirm the wire width firmware uses for a boolean measurement") +} + +/// Encodes a boolean value. +pub fn encode_boolean(value: &Value) -> Result, EncodeError> { + let _ = value; + todo!("confirm the wire width firmware uses for a boolean measurement") +} + +/// Decodes an enum value: a single `uint8` byte, the index into +/// `options`. Returns the option's text, not its index — see +/// [`encode_enum`] for why that's not symmetric. +pub fn decode_enum(bytes: &[u8], options: &[String]) -> Result { + let &index = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + let Some(option) = options.get(usize::from(index)) else { + return Err(DecodeError::InvalidEnumValue { + index: u32::from(index), + options: options.len(), + }); + }; + Ok(Value::Enum(option.clone())) +} + +/// Encodes an enum value as a single `uint8` byte. +/// +/// `value` is the already-selected index (e.g. a frontend dropdown +/// already resolved to its position in `options`), **not** the option's +/// text — unlike [`decode_enum`], which produces the text. Errors with +/// [`EncodeError::OutOfRange`] if the index isn't a whole number or +/// doesn't fall within `options`. +pub fn encode_enum(value: &Value, options: &[String]) -> Result, EncodeError> { + let Value::Number(index) = value else { + return Err(EncodeError::TypeMismatch); + }; + let index = *index; + let max = options.len().saturating_sub(1) as f64; + + let in_range = !options.is_empty() && index.fract() == 0.0 && (0.0..=max).contains(&index); + if !in_range { + return Err(EncodeError::OutOfRange { + value: index, + min: 0.0, + max, + }); + } + + Ok(vec![index as u8]) // no need to call `to_le_bytes` for a single byte +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_an_unsigned_integer_little_endian() { + let Ok(value) = decode_numeric(&[0x34, 0x12], NumericKind::U16) else { + unreachable!("decoding a well-formed U16 should succeed"); + }; + assert_eq!(value, Value::Number(0x1234 as f64)); + } + + #[test] + fn decodes_a_signed_integer_little_endian() { + let Ok(value) = decode_numeric(&[0xFF], NumericKind::I8) else { + unreachable!("decoding a well-formed I8 should succeed"); + }; + assert_eq!(value, Value::Number(-1.0)); + } + + #[test] + fn decodes_floats_little_endian() { + let bytes = 1.5f32.to_le_bytes(); + let Ok(value) = decode_numeric(&bytes, NumericKind::F32) else { + unreachable!("decoding a well-formed F32 should succeed"); + }; + assert_eq!(value, Value::Number(1.5)); + } + + #[test] + fn decode_numeric_rejects_short_input() { + assert!(matches!( + decode_numeric(&[0x00], NumericKind::U32), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn numeric_round_trips_through_encode_then_decode() { + let value = Value::Number(1234.0); + let Ok(bytes) = encode_numeric(&value, NumericKind::U16) else { + unreachable!("encoding a well-formed value should succeed"); + }; + let Ok(decoded) = decode_numeric(&bytes, NumericKind::U16) else { + unreachable!("decoding what we just encoded should succeed"); + }; + assert_eq!(decoded, value); + } + + #[test] + fn encode_numeric_rejects_the_wrong_value_variant() { + assert!(matches!( + encode_numeric(&Value::Bool(true), NumericKind::U8), + Err(EncodeError::TypeMismatch) + )); + } + + #[test] + fn encode_numeric_rejects_out_of_range_values() { + assert!(matches!( + encode_numeric(&Value::Number(300.0), NumericKind::U8), + Err(EncodeError::OutOfRange { .. }) + )); + assert!(matches!( + encode_numeric(&Value::Number(-1.0), NumericKind::U8), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_numeric_accepts_the_exact_boundary_values() { + assert!(encode_numeric(&Value::Number(0.0), NumericKind::U8).is_ok()); + assert!(encode_numeric(&Value::Number(255.0), NumericKind::U8).is_ok()); + } + + #[test] + fn encode_numeric_rejects_nan() { + assert!(matches!( + encode_numeric(&Value::Number(f64::NAN), NumericKind::F32), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn decodes_an_enum_value_by_index_into_its_text() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + let Ok(value) = decode_enum(&[1], &options) else { + unreachable!("decoding a valid enum index should succeed"); + }; + assert_eq!(value, Value::Enum("Fault".to_string())); + } + + #[test] + fn decode_enum_rejects_an_out_of_range_index() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + decode_enum(&[5], &options), + Err(DecodeError::InvalidEnumValue { + index: 5, + options: 1 + }) + )); + } + + #[test] + fn decode_enum_rejects_empty_input() { + assert!(matches!( + decode_enum(&[], &[]), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn encodes_an_enum_value_from_its_already_selected_index() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + let Ok(bytes) = encode_enum(&Value::Number(1.0), &options) else { + unreachable!("encoding a valid index should succeed"); + }; + assert_eq!(bytes, vec![1]); + } + + #[test] + fn encode_enum_rejects_an_out_of_range_index() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + encode_enum(&Value::Number(1.0), &options), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_enum_rejects_a_fractional_index() { + let options = vec!["Idle".to_string(), "Fault".to_string()]; + assert!(matches!( + encode_enum(&Value::Number(0.5), &options), + Err(EncodeError::OutOfRange { .. }) + )); + } + + #[test] + fn encode_enum_rejects_the_wrong_value_variant() { + let options = vec!["Idle".to_string()]; + assert!(matches!( + encode_enum(&Value::Enum("Idle".to_string()), &options), + Err(EncodeError::TypeMismatch) + )); + } +} From 42f7c1ea6bb485e45ab536cae7454cc865315d37 Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Sat, 10 Oct 2026 17:17:17 +0200 Subject: [PATCH 37/82] docs: minor changes and clarifications --- backend/crates/core/src/ntp/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/ntp/README.md b/backend/crates/core/src/ntp/README.md index 8fcaf15fa..aa75db9be 100644 --- a/backend/crates/core/src/ntp/README.md +++ b/backend/crates/core/src/ntp/README.md @@ -1,11 +1,11 @@ # NTP module -NTP (**Network Time Protocol**) is a protocol used to synchronize clocks between devices over a network. In this project, it is used to synchronize the boards in the pod. +NTP (**Network Time Protocol**) is a protocol used to synchronize clocks between devices over a network. In this project, it is used to synchronize the boards in the pod, with the *backend*. There are two related concepts worth mentioning: - **SNTP (Simple Network Time Protocol):** a simplified subset of NTP intended for simpler synchronization scenarios, such as clients using a single time server. -- **NTS (Network Time Security):** an extension to NTP that adds authentication and cryptographic security to time synchronization. +- **NTS (Network Time Security):** an extension to NTP that adds authentication and cryptographic security to time synchronization (not used in this project). The boards in this project use **SNTP**. However, this module implements an **NTP server**, since NTP and SNTP use compatible protocol messages and are interoperable. An SNTP client can therefore communicate with this NTP server as defined by RFC 5905. @@ -27,5 +27,5 @@ The standard NTP port is `123`, but this project uses `8123` as its default port The module documentation can be generated and opened with: ```bash -cargo doc --open -p software-core -F ntp +cargo doc --open -p software-core -F ntp --no-deps ``` \ No newline at end of file From c2b9b871281f2a5bf39f86c3a4110854181a27fa Mon Sep 17 00:00:00 2001 From: Pau Minguet Date: Sat, 10 Oct 2026 17:21:59 +0200 Subject: [PATCH 38/82] docs: add tracing use to readme --- backend/crates/core/src/ntp/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/crates/core/src/ntp/README.md b/backend/crates/core/src/ntp/README.md index aa75db9be..682784f52 100644 --- a/backend/crates/core/src/ntp/README.md +++ b/backend/crates/core/src/ntp/README.md @@ -11,7 +11,7 @@ The boards in this project use **SNTP**. However, this module implements an **NT ## Implementation -The server is implemented using the [`ntp_usg-server`](https://crates.io/crates/ntp_usg-server) crate with the **Tokio** runtime. +The server is implemented using the [`ntp_usg-server`](https://crates.io/crates/ntp_usg-server) crate with the **Tokio** runtime. It also uses [`tracing`](https://crates.io/crates/tracing) crate for logging. The crate handles the NTP protocol logic, including request parsing and response generation, while this module is responsible for configuring and starting the server. From ab39194a138c6513b8b2b56485d6d2411b3ad25d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 17:25:02 +0200 Subject: [PATCH 39/82] fix(backend, adj): remove acta comunicaciones --- .../core/src/adj/Comunicaciones_Reunion01.qmd | 356 ------------------ 1 file changed, 356 deletions(-) delete mode 100644 backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd diff --git a/backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd b/backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd deleted file mode 100644 index fe6540072..000000000 --- a/backend/crates/core/src/adj/Comunicaciones_Reunion01.qmd +++ /dev/null @@ -1,356 +0,0 @@ ---- -title: "ADJv3" -subtitle: Acta de la 1ª Reunión de comunicaciones -author: "Javier Ribal del Río" -date: 2026/09/16 -department: "ALL" # "SW" o "FW" -attendees: - - Javier Ribal del Río (SW-FW) (1ª y 2ª) - - Jorge Canut (FW) (1ª) - - Víctor López (FW) (1ª y 2ª) - - Aleksandr Globa (FW) (1ª y 2ª) - - Pau Minguet (SW) (2ª) - - Gazi Halawali (SW) (2ª) - - Pablo Pertusa (FW) (2ª) - - Boris Mladenov (2ª) - - Javier Lara (2ª) ---- - -## Contextualización - -El presente documento tiene como objetivo servir como especificación técnica para la implementación del `ADJv3`, partiendo de la base del `ADJv2` definido en el `README.md` del repositorio. El cambio más significativo se corresponde con la separación en forma y concepto de los `messages` y las `protections`. En el pasado, un `message` se correspondía con la información que enviaban las placas al vehículo, y las `protections` eran una implementación de los `messages`; es decir, una `protection` era un `message` con un tipo determinado. Asimismo, el documento también se corresponde con el acta de la primera reunión de comunicaciones. Debido a su larga duración, esta reunión se dividió en dos convocatorias (1ª y 2ª), celebradas los días 10 y 16 de septiembre de 2026, de ahí que el documento recoja las dos fechas y que la asistencia se indique por convocatoria. - -En el Drive del equipo hay unas diapositivas de la reunión que quizá arrojen algo de luz sobre el contenido de este texto. - -\newpage - -## Modificación de la estructura de los `measurements` y modificación de los `id` de los `packets/orders` - -Con el objetivo de dar soporte a las `protections` para cada `measurement`, el campo `id` pasa a ser un identificador único numérico común entre `packets` y `orders`. El rango de este identificador común es $[0, 8.191] = [0, 2^{13} - 1]$ ^[Podríamos definirlo como un `int13`]. Sin embargo, los `id` comprendidos en $[0, 511]$ se corresponden con los *ID* especiales: no asignados a `packets`, `orders` ni `protections`, o reservados para otros fines. - -Sin embargo, no podemos prescindir del `id` textual previamente definido, puesto que es empleado por la generación de código y no puede ser sustituido por el `id` numérico ^[Esto se debe a que actualmente la generación de código utiliza el `id` del `measurement` para generar su variable asociada]. Por ello, definimos un nuevo campo denominado `alias` que se corresponde con el antiguo campo `id` ^[Esta decisión de mantener un campo legado maximiza la compatibilidad con la `logging-view`]. Además, el campo `alias` se corresponde con el identificador para los archivos tipo `packets` y `orders`. - - - -## Incorporación de las `protections` al ADJ - -Las `protections` se desvinculan de la implementación de los `messages`, por lo que es necesario definir una nueva estructura para el paquete a enviar. - -### Estructura del paquete - -El paquete de una `protection` tiene longitud casi fija: una cabecera de 64 bits (`id` más -`timestamp`, con relleno de alineación) seguida del valor que ha disparado la protección, -cuyo tamaño depende del tipo del `measurement` vigilado. - -```{mermaid} -%%| label: fig-estructura-proteccion -%%| fig-cap: "Estructura del paquete de una protección. Las casillas van numeradas por su offset en bits. El campo `value` ocupa el mismo tamaño que el `measurement` asociado (de 8 a 64 bits); aquí se dibuja con 64." -%%| fig-width: 5.2 -%%| fig-pos: 'H' -%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% -packet-beta -0-15: "id" -16-23: "hora" -24-31: "min" -32-39: "seg" -40-47: "subseg" -48-63: "resv" -64-127: "value" -``` - -- `id` (bits 0–15): identificador numérico de la protección. -- `timestamp` (bits 16–47): instante en que salta la protección, con `hora`, `min`, `seg` y - `subseg` en 8 bits cada uno. -- `resv` (bits 48–63): 2 bytes reservados, únicamente para alinear `value` a un offset - múltiplo de 8 bytes. -- `value` (bits 64 en adelante): valor que ha excedido el umbral. Ocupa exactamente el mismo - tamaño que el `measurement` que vigila la protección (de 8 a 64 bits según su `type`), de - modo que el receptor lo decodifica con el mismo codificador que ese `measurement`. - - -### Tipos de `protection` definidos en `general_info.json` - -Los tipos de `protection` que pueden saltar se declaran en `general_info.json`, en un objeto -`protectionTypes` que asocia el nombre de cada protección a su definición: - -``` -{ - "protectionTypes": { - "": { - "range": booleano?, - "text": "", - "textTime": "?" - } - } -} -``` - -- `range`: booleano opcional, `false` por defecto. Indica cuántos límites tiene la - protección: `false` para las de un solo valor (`Above`, `Below`, `Equal`…) y `true` para - las de intervalo (`Range`), en cuyo caso los dos límites se escriben con notación de - intervalo, p. ej. `[233, 333]`. -- `text`: plantilla del mensaje que se muestr aen la control station. Los `{}` se - sustituyen por los límites definidos para la protección (un único valor, o el intervalo - `[min, max]` si `range` es `true`) y el `%`, por el valor que los ha excedido. -- `textTime`: plantilla alternativa para las protecciones de acumulación temporal - (`time_accumulation`), que saltan cuando el valor se mantiene fuera de rango durante un - intervalo dado. Se usa en lugar de `text` y el `%` se sustituye por ese tiempo máximo, no - por el valor. - - -#### Ejemplos de tipos de `protection` - -- `Above` -- `Below` -- `Equal` -- `NotEqual` ^[La protección se encuentra definida en la `ST-LIB`, por el momento no se nos ha ocurrido su propósito] -- `Range` - -### Definición de `protections` en los `measurements` - -Cada `measurement` de `_measurements.json` incorpora los cambios anteriores (el `id` -pasa a ser numérico, de rango $[0, 8.191]$, y el identificador textual antiguo se conserva -en `alias`) y, además, una lista opcional `protections` con las protecciones que vigilan esa -medida: - -``` -{ - "id": número, - "alias": "", - "name": "", - "type": "", - "podUnits": "?", - "displayUnits": "?", - "enumValues": [""]?, - "protections": [ - { - "type": "", - "value": [número] | [número, número], - "fault": booleano?, - "time": "?" - } - ]? -} -``` - -`protections` *array* limitado a 7 protecciones. - -- `type`: uno de los tipos declarados en `protectionTypes` (`Above`, `Below`, `Equal`, - `NotEqual`, `Range`). -- `value`: lista con el o los límites de la protección. Un elemento para las de un solo - valor y dos, `[min, max]`, para `Range`. -- `fault`: booleano opcional, `true` por defecto. Fija la severidad con la que se emite la - protección: `true` la envía como *fault* y `false` como *warning*. -- `time`: opcional, solo para las protecciones de acumulación temporal. Cadena con valor y - unidad (`"500ms"`, `"3s"`…) que indica cuánto tiempo debe mantenerse la medida fuera de - rango antes de que la protección salte, por defecto `0`. - -### Id de las protecciones - -\begin{quote} -\fbox{\parbox{0.92\linewidth}{\raggedright -\textbf{¿Qué es \textit{bit-packing}?}\\[4pt] -Es la técnica de meter varios campos independientes dentro de un mismo entero, cada uno -ocupando solo los bits que necesita, en vez de reservarle un byte o un campo propio. Aquí -el \texttt{uint16} del \texttt{id} empaqueta dos campos: \texttt{pos} (3 bits) e -\texttt{id\_measurement} (13 bits). Se construye desplazando y combinando con operadores a -nivel de bit --- \texttt{(pos << 13) | id\_measurement} --- y se deshace igual, en el otro -sentido: \texttt{pos = id >> 13} e \texttt{id\_measurement = id \& 0x1FFF}. La ventaja es -que no se desperdicia ni un bit; el coste es que, si un consumidor no sabe que el campo va -empaquetado, lo interpreta mal sin que nada se lo avise. -}} -\end{quote} - -Esta codificación no es exclusiva del paquete de la `protection`: como `packets`, `orders` y -`protections` comparten el mismo campo `id` de 16 bits, se aplica al `id` de **cualquier** -paquete del protocolo. Al recibir un `id`, lo primero que se mira son sus 3 bits altos -(`pos`): si valen `000`, el resto son los 13 bits del `id` normal de un `packet`, `order` o -`measurement`; si no, indican qué `protection` (`1`–`7`) del `measurement` de esos 13 bits -ha saltado: - -$$\texttt{id} = (\texttt{pos} \ll 13) \mathbin{|} \texttt{id\_measurement}$$ - -```{mermaid} -%%| label: fig-id-protection -%%| fig-cap: "Id de 16 bits compartido por packets, orders y protections: 3 bits de posición (000 = id normal) + 13 bits del id de measurement/packet/order." -%%| fig-width: 4.6 -%%| fig-pos: 'H' -%%{init: {"packet": {"bitWidth": 22, "rowHeight": 34, "bitsPerRow": 16, "showBits": true}}}%% -packet-beta -0-12: "id measurement" -13-15: "pos" -``` - -| `pos` | Significado | -|:-----:|-------------------------------------------------| -| `000` | `id` normal: `packet`, `order` o `measurement` | -| `001` | `protections[0]` | -| `010` | `protections[1]` | -| `011` | `protections[2]` | -| `100` | `protections[3]` | -| `101` | `protections[4]` | -| `110` | `protections[5]` | -| `111` | `protections[6]` | - -Con `pos` de `1` a `7` caben exactamente las 7 `protections` por `measurement` fijadas -antes; `pos = 0` deja el `id` libre para su uso normal. Como el resto del protocolo, este -`id` compuesto viaja como un único `uint16` en *little-endian* ^[`pos` no ocupa un byte -aparte: son los 3 bits más altos de ese mismo `uint16`, así que quedan repartidos en el -byte alto junto con los 5 bits más significativos de `id_measurement`.]. - - -\newpage - - -## Refactor de los `messages` - -### Estructura del `message` - -```{mermaid} -%%| label: fig-estructura-message -%%| fig-cap: "Estructura del nuevo message. origin (máx. 48 bytes) y message (máx. 320 bytes) son cadenas terminadas en 0, de longitud variable; aquí se dibujan truncadas a 4 bytes." -%%| fig-width: 5.2 -%%| fig-pos: 'H' -%%{init: {"packet": {"bitWidth": 40, "rowHeight": 34, "bitsPerRow": 10, "showBits": true}}}%% -packet-beta -0-1: "id" -2-2: "hora" -3-3: "min" -4-4: "seg" -5-5: "subseg" -6-9: "origin (txt 0)" -10-13: "message (txt 0)" -``` - -- `id` (2 bytes): tipo de mensaje (`uint16`). Reutiliza `message_ids` de `general_info.json` - (ver más abajo): `10` = `fault`, `11` = `warning`, `12` = `info` y, como tipo nuevo, - `13` = `panic`^[Ya estaba definido en la `ST-LIB` y era enmascarado como un mensaje de `fault`, ahora queda incluido en el `ADJ`]. -- `timestamp` (4 bytes): el mismo formato que en las `protections` — `hora`, `min`, `seg` y - `subseg` en un byte cada uno. -- `origin` (hasta 48 bytes, cadena terminada en `0`): de dónde sale el mensaje. Actualmente - es la ruta absoluta del fichero de origen ^[Sería mejor que fuese la ruta relativa desde - la raíz del repositorio de la placa, no la ruta absoluta del sistema en el que se - compiló.]. -- `message` (hasta 320 bytes, cadena terminada en `0`): contenido del mensaje. - -`origin` y `message` llevan tamaño máximo pero longitud variable: al llegar al byte `0` se -entiende que la cadena ha terminado. - -#### Implementación en `general_info.json` - -Reutilizamos los `message_ids` definidos en `general_info.json`, que por el momento estaban -en desuso: - -``` -{ - "message_ids": { - "fault": 10, - "warning": 11, - "info": 12, - "panic": 13 - } -} -``` - -## Adición de la MAC a la información propia de cada placa - -En vista de lo acontecido en la EHW en H11^[Hubo MAC *curiosas* como `67:67:67:67:67:67` que causaban problemas y horas de *debugging*], se va a añadir un campo `mac` que se corresponde con la `MAC` de cada placa con el objetivo de que se use en la generación de código y sea validado por el `adj-validator`. - - -## Gestión de tiempos y `SNTP` - -Se comenta la posibilidad de realizar un *POC* para mirar la viabilidad de utilizar el protocolo `SNTP` para sincronizar reloj de las placas y el backend. Boris comenta que ya estaba implementado pero que no funcionaba. - -Se ocuparán Víctor y Javier Ribal. Mirar el `README` de `SNTP` en la `ST-LIB`. - - -## Módulo `units` - -Se abandona la implementación del `ADJv2`, se desimplementa `podUnits` y su contraparte en `general_info.json`. Se debate sobre la utilidad del módulo y su practicidad. - -Se acuerda implementarlo añadiendo a cada `measurement` un factor para cada operación. - -## Asignar `id` especiales para la `BLCU` - -Se reserva el rango $[20, 29]$ para los paquetes/órdenes asociados a la `BLCU`. - -## Verificación del `ADJ` *hash* - -Se reserva el `id` 2 para la comparación de commits. - -En caso de que estemos utilizando la `VCU` como `master`, durante la conexión `TCP` compararán el *hash* del `ADJ`; si coinciden todos, se pasará al `backend` (si no, se pasará `hash = 0x00...`). - -En la comparación en el `backend`, si coinciden todos, estará bien; si no, y únicamente si está marcada una nueva opción del `backend`, *Different ADJ hash Fault*, se irá a `fault`; si no, simplemente imprimiremos un `warning`. - - -### Formato - -```{mermaid} -%%| label: fig-formato-hash-adj -%%| fig-cap: "Formato del paquete de verificación del hash del `ADJ`: `id` fijo a `2` (uint16) seguido del `commit` (uint64) del `ADJ` vigente." -%%| fig-width: 5.2 -%%| fig-pos: 'H' -%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% -packet-beta -0-15: "id (=2)" -16-79: "commit" -``` - -- `id` (16 bits): fijo a `2`, identifica este paquete como la comparación de *hash* del `ADJ`. -- `commit` (64 bits): *hash* del commit del `ADJ` que está usando la placa o el `backend`. - - - -## *Custom Keep-alive* - -Mantenemos el estilo: será el `backend` quien envíe el *keep-alive* y nos iremos a `fault` si no se recibe el `ack`. `FW` hará lo propio en las placas. - -Posibilidad de añadir un botón para desactivar el *keep-alive* del `backend`. - -## Propuesta de incorporación del `array` - -Autor: *Víctor López* - -Añadimos un atributo opcional a los measurements 'arrayLength'. - -Este atributo define la longitud de array para el measurement, si el measurement tiene protecciones, se comprobará la protección por cada elemento del array. - -Si se necesita envíar una alerta de que la protección ha detectado un fallo en un elemento del array, se envía en el campo `reserved` del mensaje de la protección el índice del array que ha fallado (empezando por el índice 0). - -Se vería así el paquete de protección entonces: - -```{mermaid} -%%| label: fig-proteccion-array -%%| fig-cap: "Estructura del paquete de una protección sobre un measurement de tipo array. El campo `resv` pasa a contener el índice del elemento que ha fallado (empezando por 0). El campo `value` ocupa el mismo tamaño que el elemento del array; aquí se dibuja con 64 bits." -%%| fig-width: 5.2 -%%| fig-pos: 'H' -%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% -packet-beta -0-15: "id" -16-23: "hora" -24-31: "min" -32-39: "seg" -40-47: "subseg" -48-63: "índice" -64-127: "value" -``` - -```{mermaid} -%%| label: fig-packet-array -%%| fig-cap: "Estructura de un paquete para un measurement que es un array de `uint16` (aquí, con 3 elementos): el `id` seguido de los elementos consecutivos." -%%| fig-width: 5.2 -%%| fig-pos: 'H' -%%{init: {"packet": {"bitWidth": 15, "rowHeight": 34, "bitsPerRow": 32, "showBits": true}}}%% -packet-beta -0-15: "id" -16-31: "value[0]" -32-47: "value[1]" -48-63: "value[2]" -``` - -## Otros apuntes de la reunión - -Por la parte de FW, Víctor y Jorge Canut se muestran conformes con la implementación del refactor en la `ST-LIB`. Por otra parte, Javier Ribal comenta que al rehacerse el `backend` desde cero no ve ningún impedimiento en aplicar estos cambios. - -Primera reunión de Aleksandr Globa y Pablo Pertusa que se incorporan al subsistema de Firmware. - From 4ea86f26e63268068205968607b419f039ac7a46 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 17:32:59 +0200 Subject: [PATCH 40/82] fix(backend, adj): make adj::raw not a public module --- backend/crates/core/src/adj.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/crates/core/src/adj.rs b/backend/crates/core/src/adj.rs index 3966e677d..839f4fa4a 100644 --- a/backend/crates/core/src/adj.rs +++ b/backend/crates/core/src/adj.rs @@ -13,7 +13,7 @@ mod error; mod info; -pub mod raw; +mod raw; pub use error::{LoadError, MeasurementError, ProtectionError}; pub use info::{AdjInfo, ProtectionTypeInfo}; From db3a1602e9a85723952ac5bd6af81c50c4e40623 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 17:44:22 +0200 Subject: [PATCH 41/82] fix(backend, ntp): add port form `model` at `ntp` --- backend/crates/core/src/ntp.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/backend/crates/core/src/ntp.rs b/backend/crates/core/src/ntp.rs index 5cf85b712..7de54a6e5 100644 --- a/backend/crates/core/src/ntp.rs +++ b/backend/crates/core/src/ntp.rs @@ -1,3 +1,4 @@ +use crate::model::Port; use ntp_server::{protocol::Stratum, server::NtpServer}; use std::{ io, @@ -5,26 +6,26 @@ use std::{ }; use tracing::{debug, info, instrument}; -const NTP_DEFAULT_PORT: u16 = 8123; +const NTP_DEFAULT_PORT: Port = Port(8123); -/// Starts the NTP server with an interface and a port. +/// Starts the NTP server with an interface and a port. /// /// Arguments: /// - `interface`: IPv4 interface to bind to. If `None`, the default interface (`0.0.0.0`) is used. /// - `port`: Port to listen on. If `None`, the default NTP port (`8123`) is used. #[instrument(level = "info")] -pub async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { +pub async fn start_ntp_server(interface: Option, port: Option) -> io::Result<()> { run_ntp_server(interface, port).await } -/// Starts the NTP server with a socket. +/// Starts the NTP server with a socket. /// /// Arguments: /// - `socket`: Complete IPv4 socket address. If `None` the default `SocketAddrV4` is used (`0.0.0.0:8123`). #[instrument(level = "info")] pub async fn start_ntp_server_with_socket(socket: Option) -> io::Result<()> { let (interface, port) = match socket { - Some(socket) => (Some(*socket.ip()), Some(socket.port())), + Some(socket) => (Some(*socket.ip()), Some(Port(socket.port()))), None => (None, None), }; @@ -32,19 +33,22 @@ pub async fn start_ntp_server_with_socket(socket: Option) -> io::R } #[instrument(level = "info")] -async fn run_ntp_server(interface: Option, port: Option) -> io::Result<()> { +async fn run_ntp_server(interface: Option, port: Option) -> io::Result<()> { let server = build_ntp_server(interface, port).await?; info!("Starting NTP server loop"); // Any internal log of server.run() will be wrapped within this function Span server.run().await } -async fn build_ntp_server(interface: Option, port: Option) -> io::Result { +async fn build_ntp_server( + interface: Option, + port: Option, +) -> io::Result { let interface = interface.unwrap_or(Ipv4Addr::UNSPECIFIED); // Same as Ipv4Addr::new(0,0,0,0); let port = port.unwrap_or(NTP_DEFAULT_PORT); - let socket_addr_v4 = SocketAddrV4::new(interface, port); + let socket_addr_v4 = SocketAddrV4::new(interface, port.0); // % -> use structured variables debug!(%socket_addr_v4, "Building NTP server"); @@ -87,7 +91,7 @@ mod test { init_tracing_for_tests(); // Create the NTP server - let server = build_ntp_server(Some(Ipv4Addr::LOCALHOST), Some(0)) + let server = build_ntp_server(Some(Ipv4Addr::LOCALHOST), Some(Port(0))) .await .expect("NTP server should bind to an ephemeral local port"); From bb6f5a51bf2dd824df65e0d2e6537f931ef2a50a Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Sat, 10 Oct 2026 21:03:08 +0200 Subject: [PATCH 42/82] Feat(backend, protocol): Implemented Order Encoding. --- .../crates/core/src/protocol/packet/order.rs | 50 +++++++++++++++---- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 4eb11a6cf..3b5eac356 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -1,18 +1,48 @@ //! Encodes an `Order` packet: a command sent to a board. Only ever sent, //! over TCP — the backend never receives one back. -use crate::model::OrderPacket; -use crate::protocol::{EncodeError, Index}; +use crate::model::{OrderPacket, PacketKind}; +use crate::protocol::{wire, EncodeError, Index}; /// Encodes `order` into the bytes to write to a board's connection, /// resolving its field shapes against `index`. pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { - let _ = (order, index); - todo!( - "look up order.id in `index` to get its PacketDef (UnknownId if \ - absent); match its `kind` for PacketKind::Order(measurements) \ - (WrongKind if it's some other kind); error with WrongFieldCount \ - if order.fields.len() doesn't match measurements.len(); encode \ - the id then each field positionally via wire::encode_value" - ) + + //Unwrap PacketDef for kind + let packet_def = index + .packet(order.id) + .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + + //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements + let measurements = match &packet_def.kind{ + PacketKind::Order(measurements) => measurements, + _ => return Err(EncodeError::WrongKind {id:order.id}) + }; + + //Check if length of list of fields matches number of measurements. + if order.fields.len() != measurements.len(){ + return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.fields.len()}) + } + + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&order.id.0.to_be_bytes());//Header prefix of byte stream to 16 bit big-endian + + //Encoding to bytes + for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement + let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind + bytes.extend(field_bytes);// Adds to byte stream + } + + Ok(bytes) // Return bytes } + + + +// todo!( +// "look up order.id in `index` to get its PacketDef (UnknownId if \ +// absent); match its `kind` for PacketKind::Order(measurements) \ +// (WrongKind if it's some other kind); error with WrongFieldCount \ +// if order.fields.len() doesn't match measurements.len(); encode \ +// the id then each field positionally via wire::encode_value" +// ) \ No newline at end of file From 9a097fd40dfc5ee206905c2c292989643743b3cf Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Sat, 10 Oct 2026 23:02:35 +0200 Subject: [PATCH 43/82] Feat(backend, protocol): Changed byte encoding in Order.rs to Little-Endian instead of Big-Endian. --- backend/crates/core/src/protocol/packet/order.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 3b5eac356..16fa74c02 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -26,7 +26,7 @@ pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError let mut bytes = Vec::new(); - bytes.extend_from_slice(&order.id.0.to_be_bytes());//Header prefix of byte stream to 16 bit big-endian + bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian //Encoding to bytes for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement From 553e3dd7f940642af4062f82b5f0cc948facf224 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 11 Oct 2026 23:47:17 +0200 Subject: [PATCH 44/82] feat(backend, protocol): function that returns the number of bytes that require each numeric kind --- backend/crates/core/src/protocol/wire.rs | 53 ++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs index 6dc151e04..8809c7126 100644 --- a/backend/crates/core/src/protocol/wire.rs +++ b/backend/crates/core/src/protocol/wire.rs @@ -6,9 +6,9 @@ //! Every wire width here is fixed by the measurement's own `kind` (a //! `NumericKind` always has a known byte width; booleans and enums are //! fixed-width too), so decoding never needs to report back how many -//! bytes it consumed — the caller already has the `MeasurementKind` (from -//! the `Measurement` it's decoding) and can derive the width from it -//! directly if it needs to advance through a buffer. +//! bytes it consumed — [`byte_width`] derives it straight from the +//! `MeasurementKind`, for a caller that needs to advance through a buffer +//! or know how much to read off a stream before decoding. //! //! [`decode_value`]/[`encode_value`] are thin dispatchers: they only pick //! which per-kind function to call. All the actual work lives in its own @@ -36,6 +36,23 @@ pub fn encode_value(value: &Value, kind: &MeasurementKind) -> Result, En } } +/// How many bytes `kind` takes on the wire. +pub fn byte_width(kind: &MeasurementKind) -> usize { + match kind { + MeasurementKind::Numeric { wire_type } => match wire_type { + NumericKind::U8 | NumericKind::I8 => 1, + NumericKind::U16 | NumericKind::I16 => 2, + NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, + NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, + }, + // Not yet confirmed against firmware — see `decode_boolean`. + MeasurementKind::Boolean => { + todo!("confirm the wire width firmware uses for a boolean measurement") + } + MeasurementKind::Enum { .. } => 1, + } +} + /// Decodes a numeric value of the given wire type, little-endian. pub fn decode_numeric(bytes: &[u8], wire_type: NumericKind) -> Result { fn take(bytes: &[u8]) -> Result<[u8; N], DecodeError> { @@ -170,6 +187,36 @@ pub fn encode_enum(value: &Value, options: &[String]) -> Result, EncodeE mod tests { use super::*; + #[test] + fn byte_width_matches_each_numeric_kinds_wire_size() { + let cases = [ + (NumericKind::U8, 1), + (NumericKind::I8, 1), + (NumericKind::U16, 2), + (NumericKind::I16, 2), + (NumericKind::U32, 4), + (NumericKind::I32, 4), + (NumericKind::F32, 4), + (NumericKind::U64, 8), + (NumericKind::I64, 8), + (NumericKind::F64, 8), + ]; + + for (wire_type, expected) in cases { + assert_eq!(byte_width(&MeasurementKind::Numeric { wire_type }), expected); + } + } + + #[test] + fn byte_width_of_an_enum_is_always_one() { + assert_eq!( + byte_width(&MeasurementKind::Enum { + options: vec!["Idle".to_string(), "Fault".to_string()] + }), + 1 + ); + } + #[test] fn decodes_an_unsigned_integer_little_endian() { let Ok(value) = decode_numeric(&[0x34, 0x12], NumericKind::U16) else { From 0edcd3ae5ac5cf28d807392279679560ca100d4d Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 00:18:37 +0200 Subject: [PATCH 45/82] fix(backend, adj): add methods to ADJID to separate get messurement Id from protection id --- backend/crates/core/src/adj/raw/loader.rs | 9 ++++--- backend/crates/core/src/model/README.md | 10 ++++--- backend/crates/core/src/model/id.rs | 33 +++++++++++++++++++++++ backend/crates/core/src/model/packet.rs | 26 +++++++++--------- 4 files changed, 58 insertions(+), 20 deletions(-) diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 6d6f4be3c..6f252d48c 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -71,9 +71,12 @@ pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result` keyed by id — the A Defined as their own types (wrapping a `u16`) instead of using a plain `u16`, so the compiler won't let an `AdjId` be confused with a `BoardId` by mistake, even though both are just numbers underneath ### 2.1 `AdjId` ID of an ADJ packet in u16. Structure of ID can be found in the ADJ documentation. + +For a protection's compound id (the high 3 bits are the protection's position, the low 13 bits the watched measurement's id — see the struct's own docs for the full range table), `AdjId` exposes the split as two methods instead of leaving every caller to redo the shift/mask: +- `protection_position() -> u8`: the high 3 bits (`0` for a normal, non-protection id). +- `measurement_id() -> AdjId`: the id with its high 3 bits zeroed out — the watched measurement's own id. ### 2.2 `BoardId` Unique identifier of a board, defined by the ADJ documentation. ### 2.3 `MacAddress` @@ -138,10 +142,8 @@ Vector list of values (`Vec`) to set for each field of the order. ### 4.4 `ProtectionPacket` Sent by a board when one of its declared protections triggers. -#### `measurement` -The measurement this protection watches (`AdjId`). -#### `protection` -Which of that measurement's protections triggered (`AdjId`). +#### `id` +The compound wire id this protection triggered under (`AdjId`). Use `id.measurement_id()` for the measurement it watches and `id.protection_position()` for which of that measurement's protections this is (see `Protection::id`). Kept as a single field rather than storing both halves separately — they're never independent of each other, so a second field would only add a second place for them to disagree. #### `timestamp` When the protection triggered (`ShortTimestamp`). #### `value` diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index d6a6845a5..a8278993d 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -12,6 +12,24 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct AdjId(pub u16); +impl AdjId { + /// The high 3 bits: `0` for a normal `packet`/`order`/`measurement` + /// id, `1`-`7` for which of the watched measurement's protections this + /// is. + pub fn protection_position(self) -> u8 { + (self.0 >> 13) as u8 + } + + /// The watched measurement's id: this id with its high 3 bits (the + /// protection's position) zeroed out. + /// + /// For a normal, non-protection id (`protection_position() == 0`), + /// this is just the id itself. + pub fn measurement_id(self) -> AdjId { + AdjId(self.0 & 0x1FFF) + } +} + /// Unique identifier of a board, defined by the ADJ. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BoardId(pub u16); @@ -74,4 +92,19 @@ mod tests { let board = BoardId(1); assert_eq!(packet.0, board.0); // only the inner numbers can be compared } + + #[test] + fn a_normal_ids_position_is_zero_and_it_is_its_own_measurement_id() { + let id = AdjId(3000); + assert_eq!(id.protection_position(), 0); + assert_eq!(id.measurement_id(), AdjId(3000)); + } + + #[test] + fn a_protections_compound_id_splits_into_position_and_measurement() { + // (3 << 13) | 100 = 24576 | 100 = 24676 + let compound = AdjId((3u16 << 13) | 100); + assert_eq!(compound.protection_position(), 3); + assert_eq!(compound.measurement_id(), AdjId(100)); + } } diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 7ac91f048..7cba9ae47 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -64,16 +64,16 @@ pub struct OrderPacket { /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. /// -/// `measurement` and `protection` together are the two halves of the -/// compound wire id — decoding that split is `protocol`'s job, this type -/// just holds the result. +/// `id` is the compound wire id: [`AdjId::measurement_id`] gives the +/// watched measurement, [`AdjId::protection_position`] which of that +/// measurement's protections triggered (see [`super::Protection::id`]). +/// Kept as a single field rather than storing both halves separately — +/// they're never independent of each other, so there's nothing a second +/// field would add beyond a second place for them to disagree. #[derive(Debug, Clone, PartialEq)] pub struct ProtectionPacket { - /// The measurement this protection watches. - pub measurement: AdjId, - /// Which of that measurement's protections triggered — see - /// [`super::Protection::id`]. - pub protection: AdjId, + /// The compound id this protection triggered under. + pub id: AdjId, /// When it triggered. pub timestamp: ShortTimestamp, /// The value that triggered it, decoded with the same wire type as the @@ -164,10 +164,10 @@ mod tests { } #[test] - fn a_protection_packet_carries_its_measurement_and_its_own_position_separately() { + fn a_protection_packets_id_splits_into_its_measurement_and_its_own_position() { + // (1 << 13) | 3000 = 8192 | 3000 = 11192 let packet = ProtectionPacket { - measurement: AdjId(3000), - protection: AdjId(1), + id: AdjId((1u16 << 13) | 3000), timestamp: ShortTimestamp { hour: 12, minute: 0, @@ -177,8 +177,8 @@ mod tests { value: Value::Number(53.2), }; - assert_eq!(packet.measurement, AdjId(3000)); - assert_eq!(packet.protection, AdjId(1)); + assert_eq!(packet.id.measurement_id(), AdjId(3000)); + assert_eq!(packet.id.protection_position(), 1); } #[test] From a44ab1f977fbc89c88deabdc5d7b3fab2c7c34b4 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 19:34:16 +0200 Subject: [PATCH 46/82] chore(github): remove template issues --- .github/ISSUE_TEMPLATE/bug.md | 28 ---------------------------- .github/ISSUE_TEMPLATE/feature.md | 17 ----------------- .github/ISSUE_TEMPLATE/task.md | 17 ----------------- 3 files changed, 62 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug.md delete mode 100644 .github/ISSUE_TEMPLATE/feature.md delete mode 100644 .github/ISSUE_TEMPLATE/task.md diff --git a/.github/ISSUE_TEMPLATE/bug.md b/.github/ISSUE_TEMPLATE/bug.md deleted file mode 100644 index 941c8a099..000000000 --- a/.github/ISSUE_TEMPLATE/bug.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug -about: Notify that an error has ben found on the app and what happened. -title: '' -labels: Bug -assignees: '' - ---- - -**Bug Description** -What is happening? - -**Steps to reproduce** -How do I replicate the issue, what where you doing? - -**Screenshots** -Provide screenshots, videos, links, etc. - -**Context** -What is your environment? - -Fill the following information and add any relevant information: -*OS*: Windows, Mac, Linux, Android, iOS... -*Browser*: Chrome, Opera, Firefox, Safari... -*App Version*: v1.0.0 - -**Notes** -Extra information relevant to the issue. diff --git a/.github/ISSUE_TEMPLATE/feature.md b/.github/ISSUE_TEMPLATE/feature.md deleted file mode 100644 index 6aee6e0d5..000000000 --- a/.github/ISSUE_TEMPLATE/feature.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Feature -about: Propose a feature to be added to the application. -title: '' -labels: Feature -assignees: '' - ---- - -**Feature description** -What is the feature supposed to do, what will it include? - -**Possible solutions** -What are the possible ways to implement this feature that you can think of? - -**Notes** -What context / extra information is required to understand the proposal? diff --git a/.github/ISSUE_TEMPLATE/task.md b/.github/ISSUE_TEMPLATE/task.md deleted file mode 100644 index 8a717356d..000000000 --- a/.github/ISSUE_TEMPLATE/task.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Task -about: Tasks are units of work assigned to members to be completed. -title: '' -labels: Task -assignees: '' - ---- - -**Description** -Brief description of the task and important details - -**Related** -Related Issues, PRs, Documents, etc. - -**Notes** -Extra information that might be good to know about it From 2607edb06de37c4dc11d280c841a0589074dacc7 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 20:14:11 +0200 Subject: [PATCH 47/82] chore(repo): configure recomended extension --- .vscode/extensions.json | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .vscode/extensions.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..37006f2d8 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "rust-lang.rust-analyzer", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "ms-python.python", + "tamasfe.even-better-toml" + ] +} From 04a4c013e964f247575d12d37de36a60597e6a5e Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 20:14:27 +0200 Subject: [PATCH 48/82] chore(repo, backend): configure debugger --- .vscode/launch.json | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .vscode/launch.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..62e3453a9 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "lldb", + "request": "launch", + "name": "Debug Backend (Rust)", + "program": "${workspaceFolder}/backend/target/debug/station", + "args": [], + "cwd": "${workspaceFolder}/backend/crates/station", + "preLaunchTask": "cargo build (station)", + "sourceLanguages": ["rust"] + } + ] +} From 424749d04375e41764c0a58af3301b8ed943c760 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 20:15:00 +0200 Subject: [PATCH 49/82] chore(repo): configure lint --- .vscode/settings.json | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9ef398bb3..6f381d6a0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -50,5 +50,33 @@ "editor.codeActionsOnSave": { "source.fixAll.rust-analyzer": "explicit" } + }, + "rust-analyzer.linkedProjects": ["backend/Cargo.toml"], + "rust-analyzer.check.command": "clippy", + "rust-analyzer.check.extraArgs": ["--all-targets"], + + // ESLint: each frontend/* package has its own flat eslint.config.js + "eslint.workingDirectories": [{ "mode": "auto" }], + + // Python: blcu-programming is the only Python project, venv created by + // its postinstall script (blcu-programming/scripts/setup.mjs) + "python.defaultInterpreterPath": "${workspaceFolder}/blcu-programming/.venv/bin/python", + + // Keep VS Code from indexing/watching generated and vendored trees + "files.watcherExclude": { + "**/node_modules/**": true, + "**/target/**": true, + "**/dist/**": true, + "**/.venv/**": true, + "**/.venv-build/**": true, + "**/.turbo/**": true + }, + "search.exclude": { + "**/node_modules": true, + "**/target": true, + "**/dist": true, + "**/.venv": true, + "**/.venv-build": true, + "**/.turbo": true } } From d8cade58f71ae800b56e29f33bd78c202190fce6 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 22:01:23 +0200 Subject: [PATCH 50/82] chore: set up repo for vscode --- .vscode/launch.json | 2 +- .vscode/tasks.json | 165 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 .vscode/tasks.json diff --git a/.vscode/launch.json b/.vscode/launch.json index 62e3453a9..617eff252 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "program": "${workspaceFolder}/backend/target/debug/station", "args": [], "cwd": "${workspaceFolder}/backend/crates/station", - "preLaunchTask": "cargo build (station)", + "preLaunchTask": "Build Backend (Rust)", "sourceLanguages": ["rust"] } ] diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..a2168caa4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,165 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Testing View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "testing-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Competition View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "competition-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Flashing View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "flashing-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Logging View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "logging-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run ADJ View", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "adj-view"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["run", "--package", "station"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Run Python (BLCU Programming)", + "type": "shell", + "command": "pnpm", + "args": ["dev", "--filter", "blcu-programming"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "presentation": { + "reveal": "always", + "panel": "dedicated" + } + }, + { + "label": "Build App", + "type": "shell", + "command": "pnpm", + "args": ["run", "build"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": "build" + }, + { + "label": "Build Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["build", "--package", "station"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "group": "build" + }, + { + "label": "Test App", + "type": "shell", + "command": "pnpm", + "args": ["run", "test"], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "Test Backend (Rust)", + "type": "shell", + "command": "cargo test ${input:rustTestScope} ${input:rustTestFilter}", + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "group": "test" + } + ], + "inputs": [ + { + "id": "rustTestScope", + "type": "pickString", + "description": "¿Qué crate del backend querés testear?", + "options": [ + { "label": "Todo el workspace", "value": "--workspace" }, + { "label": "software-core", "value": "-p software-core" }, + { "label": "station", "value": "-p station" } + ], + "default": "--workspace" + }, + { + "id": "rustTestFilter", + "type": "promptString", + "description": "Filtro de test/módulo dentro del crate elegido (ej. adj::tests, vacío = todos)", + "default": "" + } + ] +} From dfcbcc00ba943c2e32deb19949104b4cc11ae4f5 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 22:12:44 +0200 Subject: [PATCH 51/82] chore: rust --- .vscode/tasks.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a2168caa4..10489440d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -141,6 +141,28 @@ }, "problemMatcher": ["$rustc"], "group": "test" + }, + { + "label": "Clippy Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": ["$rustc"], + "group": "test" + }, + { + "label": "Format Backend (Rust)", + "type": "shell", + "command": "cargo", + "args": ["fmt", "--all"], + "options": { + "cwd": "${workspaceFolder}/backend" + }, + "problemMatcher": [], + "group": "none" } ], "inputs": [ From 83cde99df6d0e2f30bff04ccf6a3393c14126725 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 22:27:47 +0200 Subject: [PATCH 52/82] chore(workflows): rust tests --- .github/workflows/rust-tests.yaml | 35 +++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/rust-tests.yaml diff --git a/.github/workflows/rust-tests.yaml b/.github/workflows/rust-tests.yaml new file mode 100644 index 000000000..a64b89ebe --- /dev/null +++ b/.github/workflows/rust-tests.yaml @@ -0,0 +1,35 @@ +name: Rust Tests + +on: + pull_request: + branches: + - main + - develop + - production + - "backend/**" + paths: + - "backend/**" + - ".github/workflows/rust-tests.yaml" + +jobs: + test: + name: Test (${{ matrix.crate }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + crate: [software-core, station] + steps: + - uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + + - name: Run tests + working-directory: backend + run: cargo test --package ${{ matrix.crate }} From edd0f8c68bf67e938fcf22f8df52ad21f6ee5740 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 22:32:45 +0200 Subject: [PATCH 53/82] chore(.gitignore): exclude vscode settings --- .gitignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index bc66fec72..35fee63ab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,11 @@ # Monorepo / IDE node_modules/ .turbo/ -.vscode/ +.vscode/* +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json .DS_Store *.code-workspace From bb210e7c8a84ec285e0592536f97a7e61dd96050 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sat, 10 Oct 2026 22:34:29 +0200 Subject: [PATCH 54/82] test: workflow --- .github/workflows/rust-tests.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/rust-tests.yaml b/.github/workflows/rust-tests.yaml index a64b89ebe..6ab44a9ce 100644 --- a/.github/workflows/rust-tests.yaml +++ b/.github/workflows/rust-tests.yaml @@ -2,11 +2,6 @@ name: Rust Tests on: pull_request: - branches: - - main - - develop - - production - - "backend/**" paths: - "backend/**" - ".github/workflows/rust-tests.yaml" From d5e7e4b5c72a0be5a7daa802b8ced26328985dd9 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:36:08 +0200 Subject: [PATCH 55/82] Feat(backend, protocol): Removed todos and left as comments. Implemented Decoding of data. --- .../crates/core/src/protocol/packet/data.rs | 72 ++++++++++++++++--- .../crates/core/src/protocol/packet/order.rs | 4 +- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index bc613cc8c..5f2fa1d86 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,19 +1,69 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::DataPacket; -use crate::protocol::{DecodeError, Index}; +use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. pub fn decode(bytes: &[u8], index: &Index) -> Result { - let _ = (bytes, index); - todo!( - "read the leading 16-bit id; look up its PacketDef in `index` \ - (UnknownId if absent); match its `kind` for \ - PacketKind::Data(measurements) (WrongKind if it's some other \ - kind); decode each measurement's value positionally via \ - wire::decode_value, advancing through `bytes` using each \ - measurement's own wire width" - ) + if bytes.len() < 2 { + // If less than 2 bytes return error + return Err(DecodeError::UnexpectedEof); + } + + let id: [u8; 2] = bytes[0..2] // Recieve first two bytes + .try_into() // Tries to make it into desired type + .map_err(|_| DecodeError::UnexpectedEof)?; // If fails, return error + + let adj_id = AdjId(u16::from_le_bytes(id)); // Parse into u16 and make it AdjId type + + let packet_def = index.get(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; // Get packet definition from index + + let measurements = match &packet_def.kind { + // Get measurements from packet definition + PacketKind::Data(measurements) => measurements, // If packet kind is data, return measurements + _ => return Err(DecodeError::WrongKind { id: adj_id }), // If not, return Error + }; + + let mut values = Vec::with_capacity(measurements.len()); // Create vectir list of values same size as measurements + + let mut pointer = 2; // Pointer to after ID in bytes + + for measurement in measurements { + let width = match &measurement.kind { // Set width of measurement according to the type. + MeasurementKind::Numeric { wire_type } => match wire_type { + NumericKind::U8 | NumericKind::I8 => 1, + NumericKind::U16 | NumericKind::I16 => 2, + NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, + NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, + }, + MeasurementKind::Boolean => 1, + MeasurementKind::Enum { .. } => 1 + }; + + let next = pointer + width; + + if bytes.len() < next { + // Check if there is enough bytes left + return Err(DecodeError::UnexpectedEof); + } + + let field = &bytes[pointer..next]; // Size of field (bytes between pointer and next position) + let value = wire::decode_value(field, &measurement.kind)?; // Decode value from field size and measurement width + values.push(value); //Add to list of values + + pointer = next; // Next position + } + + Ok(DataPacket { id: adj_id, values }) // Return data packet } + +// +// "read the leading 16-bit id; look up its PacketDef in `index` \ +// (UnknownId if absent); match its `kind` for \ +// PacketKind::Data(measurements) (WrongKind if it's some other \ +// kind); decode each measurement's value positionally via \ +// wire::decode_value, advancing through `bytes` using each \ +// measurement's own wire width" +// diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 16fa74c02..1f6c98109 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -39,10 +39,10 @@ pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError -// todo!( +// // "look up order.id in `index` to get its PacketDef (UnknownId if \ // absent); match its `kind` for PacketKind::Order(measurements) \ // (WrongKind if it's some other kind); error with WrongFieldCount \ // if order.fields.len() doesn't match measurements.len(); encode \ // the id then each field positionally via wire::encode_value" -// ) \ No newline at end of file +// \ No newline at end of file From e8180bde43d6173c8b84f8c26ec3875e0b0c1771 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:50:23 +0200 Subject: [PATCH 56/82] Feat(backend, protocol): used new function to retrieve the width of a measurement in data.rs decoding data function. --- backend/crates/core/src/protocol/packet/data.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index 5f2fa1d86..a649a9db8 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,7 +1,7 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::model::{AdjId, DataPacket, PacketKind}; use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], @@ -31,16 +31,7 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { let mut pointer = 2; // Pointer to after ID in bytes for measurement in measurements { - let width = match &measurement.kind { // Set width of measurement according to the type. - MeasurementKind::Numeric { wire_type } => match wire_type { - NumericKind::U8 | NumericKind::I8 => 1, - NumericKind::U16 | NumericKind::I16 => 2, - NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, - NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, - }, - MeasurementKind::Boolean => 1, - MeasurementKind::Enum { .. } => 1 - }; + let width = wire::byte_width(&measurement.kind); let next = pointer + width; From 9670faef7ca239a156e6b858dc707564666df82e Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:36:08 +0200 Subject: [PATCH 57/82] Feat(backend, protocol): Removed todos and left as comments. Implemented Decoding of data. --- .../crates/core/src/protocol/packet/data.rs | 72 ++++++++++++++++--- .../crates/core/src/protocol/packet/order.rs | 4 +- 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index bc613cc8c..5f2fa1d86 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,19 +1,69 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::DataPacket; -use crate::protocol::{DecodeError, Index}; +use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. pub fn decode(bytes: &[u8], index: &Index) -> Result { - let _ = (bytes, index); - todo!( - "read the leading 16-bit id; look up its PacketDef in `index` \ - (UnknownId if absent); match its `kind` for \ - PacketKind::Data(measurements) (WrongKind if it's some other \ - kind); decode each measurement's value positionally via \ - wire::decode_value, advancing through `bytes` using each \ - measurement's own wire width" - ) + if bytes.len() < 2 { + // If less than 2 bytes return error + return Err(DecodeError::UnexpectedEof); + } + + let id: [u8; 2] = bytes[0..2] // Recieve first two bytes + .try_into() // Tries to make it into desired type + .map_err(|_| DecodeError::UnexpectedEof)?; // If fails, return error + + let adj_id = AdjId(u16::from_le_bytes(id)); // Parse into u16 and make it AdjId type + + let packet_def = index.get(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; // Get packet definition from index + + let measurements = match &packet_def.kind { + // Get measurements from packet definition + PacketKind::Data(measurements) => measurements, // If packet kind is data, return measurements + _ => return Err(DecodeError::WrongKind { id: adj_id }), // If not, return Error + }; + + let mut values = Vec::with_capacity(measurements.len()); // Create vectir list of values same size as measurements + + let mut pointer = 2; // Pointer to after ID in bytes + + for measurement in measurements { + let width = match &measurement.kind { // Set width of measurement according to the type. + MeasurementKind::Numeric { wire_type } => match wire_type { + NumericKind::U8 | NumericKind::I8 => 1, + NumericKind::U16 | NumericKind::I16 => 2, + NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, + NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, + }, + MeasurementKind::Boolean => 1, + MeasurementKind::Enum { .. } => 1 + }; + + let next = pointer + width; + + if bytes.len() < next { + // Check if there is enough bytes left + return Err(DecodeError::UnexpectedEof); + } + + let field = &bytes[pointer..next]; // Size of field (bytes between pointer and next position) + let value = wire::decode_value(field, &measurement.kind)?; // Decode value from field size and measurement width + values.push(value); //Add to list of values + + pointer = next; // Next position + } + + Ok(DataPacket { id: adj_id, values }) // Return data packet } + +// +// "read the leading 16-bit id; look up its PacketDef in `index` \ +// (UnknownId if absent); match its `kind` for \ +// PacketKind::Data(measurements) (WrongKind if it's some other \ +// kind); decode each measurement's value positionally via \ +// wire::decode_value, advancing through `bytes` using each \ +// measurement's own wire width" +// diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs index 16fa74c02..1f6c98109 100644 --- a/backend/crates/core/src/protocol/packet/order.rs +++ b/backend/crates/core/src/protocol/packet/order.rs @@ -39,10 +39,10 @@ pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError -// todo!( +// // "look up order.id in `index` to get its PacketDef (UnknownId if \ // absent); match its `kind` for PacketKind::Order(measurements) \ // (WrongKind if it's some other kind); error with WrongFieldCount \ // if order.fields.len() doesn't match measurements.len(); encode \ // the id then each field positionally via wire::encode_value" -// ) \ No newline at end of file +// \ No newline at end of file From abf0e44291373a331f8ae302cdf71e1ce447ecc1 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 15:50:23 +0200 Subject: [PATCH 58/82] Feat(backend, protocol): used new function to retrieve the width of a measurement in data.rs decoding data function. --- backend/crates/core/src/protocol/packet/data.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data.rs index 5f2fa1d86..a649a9db8 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data.rs @@ -1,7 +1,7 @@ //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, MeasurementKind, NumericKind, PacketKind}; +use crate::model::{AdjId, DataPacket, PacketKind}; use crate::protocol::{DecodeError, Index, wire}; /// Decodes a complete, already-delimited buffer into a [`DataPacket`], @@ -31,16 +31,7 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { let mut pointer = 2; // Pointer to after ID in bytes for measurement in measurements { - let width = match &measurement.kind { // Set width of measurement according to the type. - MeasurementKind::Numeric { wire_type } => match wire_type { - NumericKind::U8 | NumericKind::I8 => 1, - NumericKind::U16 | NumericKind::I16 => 2, - NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, - NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, - }, - MeasurementKind::Boolean => 1, - MeasurementKind::Enum { .. } => 1 - }; + let width = wire::byte_width(&measurement.kind); let next = pointer + width; From c3b2939f1b72f9153e7c660d75cf1407ed379226 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:14:16 +0200 Subject: [PATCH 59/82] Feat(backend, protocol): Refactored to merge data.rs and order.rs to data_order.rs --- backend/crates/core/src/model.rs | 2 +- backend/crates/core/src/model/packet.rs | 21 +++---- backend/crates/core/src/protocol/packet.rs | 3 +- .../packet/{data.rs => data_order.rs} | 56 +++++++++++++++++-- .../crates/core/src/protocol/packet/order.rs | 48 ---------------- 5 files changed, 60 insertions(+), 70 deletions(-) rename backend/crates/core/src/protocol/packet/{data.rs => data_order.rs} (51%) delete mode 100644 backend/crates/core/src/protocol/packet/order.rs diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 6cfdda92d..ccef505c5 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,7 +21,7 @@ pub use board::{Board, PacketDef, PacketKind, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, + DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, DataOrderPacket }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 7ac91f048..54727d403 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -32,7 +32,7 @@ pub struct ShortTimestamp { pub subsecond: u8, } -/// A data packet: the decoded values of the measurements a board just +/// A Data/Order packet: the decoded values of the measurements a board just /// sent. /// /// The wire protocol is positional: values are not individually tagged @@ -41,25 +41,18 @@ pub struct ShortTimestamp { /// [`super::PacketKind::Data`] list. `values[i]` is the decoded value of /// that list's `i`-th measurement — matching them up by position is /// `protocol`'s job, this type just holds the result in that same order. + #[derive(Debug, Clone, PartialEq)] -pub struct DataPacket { - /// Which packet definition this is an instance of. +pub struct DataOrderPacket{ pub id: AdjId, - /// The decoded value of each measurement in the packet, positional — - /// see the struct-level docs above. - pub values: Vec, + pub values: Vec, } /// An order sent from the backend to a board. /// /// Positional, the same way [`DataPacket::values`] is: see its docs. -#[derive(Debug, Clone, PartialEq)] -pub struct OrderPacket { - /// Which order definition this is an instance of. - pub id: AdjId, - /// The value to set for each field of the order, positional. - pub fields: Vec, -} +pub type OrderPacket = DataOrderPacket; +pub type DataPacket = DataOrderPacket; /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. @@ -218,7 +211,7 @@ mod tests { }); let order = Packet::Order(OrderPacket { id: AdjId(2000), - fields: vec![], + values: vec![], }); assert_ne!(data, order); diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index b1c19e246..7f357df8f 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -5,7 +5,6 @@ //! only ever received (over UDP), an `Order` only ever sent (over TCP), //! so there's no `encode` for the former or `decode` for the latter. -pub mod data; pub mod message; -pub mod order; pub mod protection; +pub mod data_order; diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data_order.rs similarity index 51% rename from backend/crates/core/src/protocol/packet/data.rs rename to backend/crates/core/src/protocol/packet/data_order.rs index a649a9db8..e9cc5e210 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -1,12 +1,58 @@ +use crate::model::{AdjId, DataPacket, PacketKind, Value, DataOrderPacket}; +use crate::protocol::{wire, EncodeError, Index, DecodeError}; + + +//! Encodes an `Order` packet: a command sent to a board. Only ever sent, +//! over TCP — the backend never receives one back. + +/// Encodes `order` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode(order: &DataOrderPacket, index: &Index) -> Result, EncodeError> { + + //Unwrap PacketDef for kind + let packet_def = index + .packet(order.id) + .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + + //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements + let measurements = match &packet_def.kind{ + PacketKind::Order(measurements) => measurements, + _ => return Err(EncodeError::WrongKind {id:order.id}) + }; + + //Check if length of list of fields matches number of measurements. + if order.values.len() != measurements.len(){ + return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.values.len()}) + } + + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian + + //Encoding to bytes + for (field,measurement) in order.values.iter().zip(measurements){ //For each measurement + let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind + bytes.extend(field_bytes);// Adds to byte stream + } + + Ok(bytes) // Return bytes +} + + +// +// "look up order.id in `index` to get its PacketDef (UnknownId if \ +// absent); match its `kind` for PacketKind::Order(measurements) \ +// (WrongKind if it's some other kind); error with WrongFieldCount \ +// if order.fields.len() doesn't match measurements.len(); encode \ +// the id then each field positionally via wire::encode_value" +// + //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, PacketKind}; -use crate::protocol::{DecodeError, Index, wire}; - /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. -pub fn decode(bytes: &[u8], index: &Index) -> Result { +pub fn decode(bytes: &[u8], index: &Index) -> Result, DecodeError> { if bytes.len() < 2 { // If less than 2 bytes return error return Err(DecodeError::UnexpectedEof); @@ -57,4 +103,4 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// +// \ No newline at end of file diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs deleted file mode 100644 index 1f6c98109..000000000 --- a/backend/crates/core/src/protocol/packet/order.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Encodes an `Order` packet: a command sent to a board. Only ever sent, -//! over TCP — the backend never receives one back. - -use crate::model::{OrderPacket, PacketKind}; -use crate::protocol::{wire, EncodeError, Index}; - -/// Encodes `order` into the bytes to write to a board's connection, -/// resolving its field shapes against `index`. -pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { - - //Unwrap PacketDef for kind - let packet_def = index - .packet(order.id) - .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash - - //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements - let measurements = match &packet_def.kind{ - PacketKind::Order(measurements) => measurements, - _ => return Err(EncodeError::WrongKind {id:order.id}) - }; - - //Check if length of list of fields matches number of measurements. - if order.fields.len() != measurements.len(){ - return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.fields.len()}) - } - - let mut bytes = Vec::new(); - - bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian - - //Encoding to bytes - for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement - let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind - bytes.extend(field_bytes);// Adds to byte stream - } - - Ok(bytes) // Return bytes -} - - - -// -// "look up order.id in `index` to get its PacketDef (UnknownId if \ -// absent); match its `kind` for PacketKind::Order(measurements) \ -// (WrongKind if it's some other kind); error with WrongFieldCount \ -// if order.fields.len() doesn't match measurements.len(); encode \ -// the id then each field positionally via wire::encode_value" -// \ No newline at end of file From d9d2b4df61feadb354d258631a0bbf3c6f142441 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:20:31 +0200 Subject: [PATCH 60/82] Feat(backend, protocol): Created tests for data_order.rs. --- .../core/src/protocol/packet/data_order.rs | 204 +++++++++++++++++- 1 file changed, 203 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index e9cc5e210..eaa4cb84d 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -103,4 +103,206 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result, Dec // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// \ No newline at end of file +// + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PacketKind, PodData, Value, + }; + use std::collections::HashMap; + use std::net::IpAddr; + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: format!("field_{id}"), + name: format!("Field {id}"), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn sample_packet_def(id: AdjId, kind: PacketKind) -> PacketDef { + PacketDef { + id, + board: BoardId(1), + name: "TestPacket".into(), + kind, + } + } + + fn make_test_index(defs: Vec) -> Index { + let packets: HashMap = defs.into_iter().map(|p| (p.id, p)).collect(); + let board = Board { + id: BoardId(1), + name: "TestBoard".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets, + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + Index::new(&pod_data) + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_success_little_endian_and_positional_fields() { + let order_id = AdjId(1001); + let packet_def = sample_packet_def( + order_id, + PacketKind::Order(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let packet = DataOrderPacket { + id: order_id, + values: vec![Value::Number(300.0), Value::Bool(true)], + }; + + let encoded = encode(&packet, &index).expect("encoding should succeed"); + + // 1001 in little-endian u16: [0xE9, 0x03] + // 300 in little-endian u16: [0x2C, 0x01] + // true: [0x01] + assert_eq!(encoded, vec![0xE9, 0x03, 0x2C, 0x01, 0x01]); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let packet = DataOrderPacket { + id: AdjId(9999), + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_kind() { + let data_id = AdjId(2001); + let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); + + let index = make_test_index(vec![packet_def]); + let packet = DataOrderPacket { + id: data_id, + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::WrongKind { id: data_id }); + } + + #[test] + fn encode_fails_on_wrong_field_count() { + let order_id = AdjId(1002); + let packet_def = sample_packet_def( + order_id, + PacketKind::Order(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), + sample_measurement(2, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), + ]), + ); + + let index = make_test_index(vec![packet_def]); + let packet = DataOrderPacket { + id: order_id, + values: vec![Value::Number(10.0)], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!( + err, + EncodeError::WrongFieldCount { + expected: 2, + got: 1, + } + ); + } + + // --- DECODE TESTS --- + + #[test] + fn decode_success_little_endian_and_values() { + let data_id = AdjId(3001); + let packet_def = sample_packet_def( + data_id, + PacketKind::Data(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + // 3001 in little-endian u16: [0xB9, 0x0B] + // 500 in little-endian u16: [0xF4, 0x01] + // false: [0x00] + let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; + + let decoded = decode(&raw_bytes, &index).expect("decoding should succeed"); + + assert_eq!(decoded.id, data_id); + assert_eq!( + decoded.values, + vec![Value::Number(500.0), Value::Bool(false)] + ); + } + + #[test] + fn decode_fails_when_buffer_is_shorter_than_header() { + let index = make_test_index(vec![]); + let err = decode(&[0x01], &index).unwrap_err(); + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unexpected_eof_in_payload() { + let data_id = AdjId(3002); + let packet_def = sample_packet_def( + data_id, + PacketKind::Data(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U32 }), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let raw_bytes = [0xBA, 0x0B, 0x01, 0x02]; + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let raw_bytes = [0x88, 0x13]; + + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); + } + + #[test] + fn decode_fails_on_wrong_kind() { + let order_id = AdjId(4001); + let packet_def = sample_packet_def(order_id, PacketKind::Order(vec![])); + + let index = make_test_index(vec![packet_def]); + let raw_bytes = [0xA1, 0x0F]; + + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::WrongKind { id: order_id }); + } +} \ No newline at end of file From 71258e56a0c9d36a49d9b649bc28f363b37be26f Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:40:58 +0200 Subject: [PATCH 61/82] Feat(backend, protocol): Created ReadMe for the protocol directory. --- backend/crates/core/src/protocol/README.md | 157 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 backend/crates/core/src/protocol/README.md diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md new file mode 100644 index 000000000..1d81566f4 --- /dev/null +++ b/backend/crates/core/src/protocol/README.md @@ -0,0 +1,157 @@ +# Protocol Module + +This module handles encoding and decoding of packets for communication with hardware boards over TCP and UDP transports. It implements the ADJ protocol for telemetry data, commands, messages, and protection events. + +## Architecture + +The protocol module follows a layered architecture: + +``` +┌─────────────────────────────────────────┐ +│ Transport Layer │ +│ (tcp.rs, udp.rs) │ +├─────────────────────────────────────────┤ +│ Packet Layer │ +│ (packet/data_order.rs, │ +│ packet/message.rs, │ +│ packet/protection.rs) │ +├─────────────────────────────────────────┤ +│ Wire Layer │ +│ (wire.rs) │ +└─────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +protocol/ +├── index.rs # Packet definition lookup table +├── error.rs # Decode and encode error types +├── wire.rs # Low-level byte ↔ value conversion +├── packet.rs # Packet-level encoding/decoding module +│ ├── data_order.rs # Data (telemetry) & Order (command) packets +│ ├── message.rs # Free-text log message packets (stub) +│ └── protection.rs # Protection trigger packets (stub) +├── tcp.rs # TCP transport framing (stub) +└── udp.rs # UDP transport framing +``` + +## Modules + +### `index.rs` - Packet Definition Lookup + +Provides a fast lookup table from packet IDs (`AdjId`) to their declarations (`PacketDef`). Built once from loaded ADJ configuration data (`PodData`), it owns cloned copies of packet definitions so it can be shared across async tasks via `Arc`. + +**Key types:** +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` + +### `error.rs` - Error Types + +Defines errors that can occur during packet encoding and decoding. + +**Key types:** +- `DecodeError` - Errors when converting bytes to packets + - `UnknownId` - No packet declared with this ID + - `UnexpectedEof` - Ran out of bytes during decode + - `Io` - I/O error reading from stream (TCP only) + - `InvalidEnumValue` - Enum index out of range + - `UnterminatedString` - String field not null-terminated + - `WrongKind` - Packet exists but isn't expected kind + +- `EncodeError` - Errors when converting packets to bytes + - `UnknownId` - No packet declared with this ID + - `WrongFieldCount` - Missing or extra fields in order + - `TypeMismatch` - Value doesn't match declared type + - `OutOfRange` - Numeric value doesn't fit wire type + - `WrongKind` - Packet exists but isn't expected kind + +### `wire.rs` - Low-Level Byte Codec + +Handles conversion between raw bytes and `Value` types for individual measurements, based on their declared `MeasurementKind`. All numeric types use little-endian encoding. + +**Key functions:** +- `decode_value(bytes, kind)` - Decode a single value from bytes +- `encode_value(value, kind)` - Encode a single value to bytes +- `byte_width(kind)` - Get the wire width in bytes for a measurement kind + +**Supported types:** +- **Numeric** (U8, U16, U32, U64, I8, I16, I32, I64, F32, F64) - ✓ Implemented +- **Enum** (single byte index) - ✓ Implemented +- **Boolean** - ⚠ Stub (wire width not yet confirmed with firmware) + +### `packet/` - Packet-Level Encoding/Decoding + +Contains per-packet-kind encoding and decoding logic, independent of transport. + +#### `data_order.rs` + +Handles two packet kinds: +- **Order packets** (commands sent to boards) - Encoded only, sent over TCP +- **Data packets** (periodic telemetry from boards) - Decoded only, received over UDP + +**Key functions:** +- `encode(order, index)` - Encode an Order packet into bytes +- `decode(bytes, index)` - Decode a Data packet from bytes + +#### `message.rs` ⚠ Stub + +Decodes free-text log messages from boards over TCP. Not yet implemented. + +#### `protection.rs` ⚠ Stub + +Decodes protection trigger events from boards over TCP. Not yet implemented. + +### `tcp.rs` - TCP Transport Framing ⚠ Partial + +Handles the dedicated TCP connection to each board. A TCP connection carries: +- Protection events (board → backend) +- Free-text messages (board → backend) +- Order commands (backend → board) + +**Key types:** +- `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) + +**Key functions:** +- `decode_next(reader, index)` - ⚠ Stub - Decode the next frame from a TCP stream +- `encode_order(order, index)` - ✓ Implemented - Encode an order for sending + +### `udp.rs` - UDP Transport Framing + +Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete, already-delimited buffers containing Data packets. + +**Key functions:** +- `decode_datagram(bytes, index)` - ✓ Implemented - Decode a UDP datagram into a DataPacket + +## Packet Types Summary + +| Packet Type | Direction | Transport | Status | +|-------------|-----------|-----------|--------| +| Data (telemetry) | Board → Backend | UDP | ✓ Implemented | +| Order (command) | Backend → Board | TCP | ✓ Implemented | +| Message (log) | Board → Backend | TCP | ⚠ Stub | +| Protection (event) | Board → Backend | TCP | ⚠ Stub | + +## Implementation Status + +- ✅ **Data packets** - Full encode/decode with tests +- ✅ **Order packets** - Full encode/decode with tests +- ✅ **Wire-level numeric types** - All numeric kinds supported with tests +- ✅ **Wire-level enum types** - Full encode/decode with tests +- ⚠️ **Wire-level boolean types** - Stub (awaiting firmware confirmation) +- ⚠️ **Message packets** - Stub (decode not implemented) +- ⚠️ **Protection packets** - Stub (decode not implemented) +- ⚠️ **TCP framing** - Partial (decode_next not implemented) + +## Testing + +The module includes comprehensive unit tests for: +- Wire-level encoding/decoding for all numeric types +- Enum encoding/decoding with boundary checks +- Order packet encoding with field validation +- Data packet decoding with error handling +- Error propagation for unknown IDs, wrong kinds, and type mismatches + +Run tests with: +```bash +cargo test -p core protocol +``` From 60dfd9c8ade7b5e3220d6fd57be3e2dfbbe85ac7 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:14:16 +0200 Subject: [PATCH 62/82] Feat(backend, protocol): Refactored to merge data.rs and order.rs to data_order.rs --- backend/crates/core/src/model.rs | 2 +- backend/crates/core/src/model/packet.rs | 21 +++---- backend/crates/core/src/protocol/packet.rs | 3 +- .../packet/{data.rs => data_order.rs} | 56 +++++++++++++++++-- .../crates/core/src/protocol/packet/order.rs | 48 ---------------- 5 files changed, 60 insertions(+), 70 deletions(-) rename backend/crates/core/src/protocol/packet/{data.rs => data_order.rs} (51%) delete mode 100644 backend/crates/core/src/protocol/packet/order.rs diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 6cfdda92d..ccef505c5 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,7 +21,7 @@ pub use board::{Board, PacketDef, PacketKind, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, + DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, DataOrderPacket }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 7cba9ae47..4c249fb53 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -32,7 +32,7 @@ pub struct ShortTimestamp { pub subsecond: u8, } -/// A data packet: the decoded values of the measurements a board just +/// A Data/Order packet: the decoded values of the measurements a board just /// sent. /// /// The wire protocol is positional: values are not individually tagged @@ -41,25 +41,18 @@ pub struct ShortTimestamp { /// [`super::PacketKind::Data`] list. `values[i]` is the decoded value of /// that list's `i`-th measurement — matching them up by position is /// `protocol`'s job, this type just holds the result in that same order. + #[derive(Debug, Clone, PartialEq)] -pub struct DataPacket { - /// Which packet definition this is an instance of. +pub struct DataOrderPacket{ pub id: AdjId, - /// The decoded value of each measurement in the packet, positional — - /// see the struct-level docs above. - pub values: Vec, + pub values: Vec, } /// An order sent from the backend to a board. /// /// Positional, the same way [`DataPacket::values`] is: see its docs. -#[derive(Debug, Clone, PartialEq)] -pub struct OrderPacket { - /// Which order definition this is an instance of. - pub id: AdjId, - /// The value to set for each field of the order, positional. - pub fields: Vec, -} +pub type OrderPacket = DataOrderPacket; +pub type DataPacket = DataOrderPacket; /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. @@ -218,7 +211,7 @@ mod tests { }); let order = Packet::Order(OrderPacket { id: AdjId(2000), - fields: vec![], + values: vec![], }); assert_ne!(data, order); diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index b1c19e246..7f357df8f 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -5,7 +5,6 @@ //! only ever received (over UDP), an `Order` only ever sent (over TCP), //! so there's no `encode` for the former or `decode` for the latter. -pub mod data; pub mod message; -pub mod order; pub mod protection; +pub mod data_order; diff --git a/backend/crates/core/src/protocol/packet/data.rs b/backend/crates/core/src/protocol/packet/data_order.rs similarity index 51% rename from backend/crates/core/src/protocol/packet/data.rs rename to backend/crates/core/src/protocol/packet/data_order.rs index a649a9db8..e9cc5e210 100644 --- a/backend/crates/core/src/protocol/packet/data.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -1,12 +1,58 @@ +use crate::model::{AdjId, DataPacket, PacketKind, Value, DataOrderPacket}; +use crate::protocol::{wire, EncodeError, Index, DecodeError}; + + +//! Encodes an `Order` packet: a command sent to a board. Only ever sent, +//! over TCP — the backend never receives one back. + +/// Encodes `order` into the bytes to write to a board's connection, +/// resolving its field shapes against `index`. +pub fn encode(order: &DataOrderPacket, index: &Index) -> Result, EncodeError> { + + //Unwrap PacketDef for kind + let packet_def = index + .packet(order.id) + .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + + //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements + let measurements = match &packet_def.kind{ + PacketKind::Order(measurements) => measurements, + _ => return Err(EncodeError::WrongKind {id:order.id}) + }; + + //Check if length of list of fields matches number of measurements. + if order.values.len() != measurements.len(){ + return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.values.len()}) + } + + let mut bytes = Vec::new(); + + bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian + + //Encoding to bytes + for (field,measurement) in order.values.iter().zip(measurements){ //For each measurement + let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind + bytes.extend(field_bytes);// Adds to byte stream + } + + Ok(bytes) // Return bytes +} + + +// +// "look up order.id in `index` to get its PacketDef (UnknownId if \ +// absent); match its `kind` for PacketKind::Order(measurements) \ +// (WrongKind if it's some other kind); error with WrongFieldCount \ +// if order.fields.len() doesn't match measurements.len(); encode \ +// the id then each field positionally via wire::encode_value" +// + //! Decodes a `Data` packet: a board's periodic telemetry. Only ever //! received, over UDP — a board never receives one back. -use crate::model::{AdjId, DataPacket, PacketKind}; -use crate::protocol::{DecodeError, Index, wire}; - /// Decodes a complete, already-delimited buffer into a [`DataPacket`], /// resolving its field shapes against `index`. -pub fn decode(bytes: &[u8], index: &Index) -> Result { +pub fn decode(bytes: &[u8], index: &Index) -> Result, DecodeError> { if bytes.len() < 2 { // If less than 2 bytes return error return Err(DecodeError::UnexpectedEof); @@ -57,4 +103,4 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result { // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// +// \ No newline at end of file diff --git a/backend/crates/core/src/protocol/packet/order.rs b/backend/crates/core/src/protocol/packet/order.rs deleted file mode 100644 index 1f6c98109..000000000 --- a/backend/crates/core/src/protocol/packet/order.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Encodes an `Order` packet: a command sent to a board. Only ever sent, -//! over TCP — the backend never receives one back. - -use crate::model::{OrderPacket, PacketKind}; -use crate::protocol::{wire, EncodeError, Index}; - -/// Encodes `order` into the bytes to write to a board's connection, -/// resolving its field shapes against `index`. -pub fn encode(order: &OrderPacket, index: &Index) -> Result, EncodeError> { - - //Unwrap PacketDef for kind - let packet_def = index - .packet(order.id) - .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash - - //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements - let measurements = match &packet_def.kind{ - PacketKind::Order(measurements) => measurements, - _ => return Err(EncodeError::WrongKind {id:order.id}) - }; - - //Check if length of list of fields matches number of measurements. - if order.fields.len() != measurements.len(){ - return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.fields.len()}) - } - - let mut bytes = Vec::new(); - - bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian - - //Encoding to bytes - for (field,measurement) in order.fields.iter().zip(measurements){ //For each measurement - let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind - bytes.extend(field_bytes);// Adds to byte stream - } - - Ok(bytes) // Return bytes -} - - - -// -// "look up order.id in `index` to get its PacketDef (UnknownId if \ -// absent); match its `kind` for PacketKind::Order(measurements) \ -// (WrongKind if it's some other kind); error with WrongFieldCount \ -// if order.fields.len() doesn't match measurements.len(); encode \ -// the id then each field positionally via wire::encode_value" -// \ No newline at end of file From e6587150b3aaaf00dbab67550f46b897ba38a3e4 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:20:31 +0200 Subject: [PATCH 63/82] Feat(backend, protocol): Created tests for data_order.rs. --- .../core/src/protocol/packet/data_order.rs | 204 +++++++++++++++++- 1 file changed, 203 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index e9cc5e210..eaa4cb84d 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -103,4 +103,206 @@ pub fn decode(bytes: &[u8], index: &Index) -> Result, Dec // kind); decode each measurement's value positionally via \ // wire::decode_value, advancing through `bytes` using each \ // measurement's own wire width" -// \ No newline at end of file +// + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PacketKind, PodData, Value, + }; + use std::collections::HashMap; + use std::net::IpAddr; + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: format!("field_{id}"), + name: format!("Field {id}"), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn sample_packet_def(id: AdjId, kind: PacketKind) -> PacketDef { + PacketDef { + id, + board: BoardId(1), + name: "TestPacket".into(), + kind, + } + } + + fn make_test_index(defs: Vec) -> Index { + let packets: HashMap = defs.into_iter().map(|p| (p.id, p)).collect(); + let board = Board { + id: BoardId(1), + name: "TestBoard".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets, + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + Index::new(&pod_data) + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_success_little_endian_and_positional_fields() { + let order_id = AdjId(1001); + let packet_def = sample_packet_def( + order_id, + PacketKind::Order(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let packet = DataOrderPacket { + id: order_id, + values: vec![Value::Number(300.0), Value::Bool(true)], + }; + + let encoded = encode(&packet, &index).expect("encoding should succeed"); + + // 1001 in little-endian u16: [0xE9, 0x03] + // 300 in little-endian u16: [0x2C, 0x01] + // true: [0x01] + assert_eq!(encoded, vec![0xE9, 0x03, 0x2C, 0x01, 0x01]); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let packet = DataOrderPacket { + id: AdjId(9999), + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_kind() { + let data_id = AdjId(2001); + let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); + + let index = make_test_index(vec![packet_def]); + let packet = DataOrderPacket { + id: data_id, + values: vec![], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!(err, EncodeError::WrongKind { id: data_id }); + } + + #[test] + fn encode_fails_on_wrong_field_count() { + let order_id = AdjId(1002); + let packet_def = sample_packet_def( + order_id, + PacketKind::Order(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), + sample_measurement(2, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), + ]), + ); + + let index = make_test_index(vec![packet_def]); + let packet = DataOrderPacket { + id: order_id, + values: vec![Value::Number(10.0)], + }; + + let err = encode(&packet, &index).unwrap_err(); + assert_eq!( + err, + EncodeError::WrongFieldCount { + expected: 2, + got: 1, + } + ); + } + + // --- DECODE TESTS --- + + #[test] + fn decode_success_little_endian_and_values() { + let data_id = AdjId(3001); + let packet_def = sample_packet_def( + data_id, + PacketKind::Data(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), + sample_measurement(2, MeasurementKind::Boolean), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + // 3001 in little-endian u16: [0xB9, 0x0B] + // 500 in little-endian u16: [0xF4, 0x01] + // false: [0x00] + let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; + + let decoded = decode(&raw_bytes, &index).expect("decoding should succeed"); + + assert_eq!(decoded.id, data_id); + assert_eq!( + decoded.values, + vec![Value::Number(500.0), Value::Bool(false)] + ); + } + + #[test] + fn decode_fails_when_buffer_is_shorter_than_header() { + let index = make_test_index(vec![]); + let err = decode(&[0x01], &index).unwrap_err(); + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unexpected_eof_in_payload() { + let data_id = AdjId(3002); + let packet_def = sample_packet_def( + data_id, + PacketKind::Data(vec![ + sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U32 }), + ]), + ); + + let index = make_test_index(vec![packet_def]); + + let raw_bytes = [0xBA, 0x0B, 0x01, 0x02]; + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::UnexpectedEof); + } + + #[test] + fn decode_fails_on_unknown_id() { + let index = make_test_index(vec![]); + let raw_bytes = [0x88, 0x13]; + + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); + } + + #[test] + fn decode_fails_on_wrong_kind() { + let order_id = AdjId(4001); + let packet_def = sample_packet_def(order_id, PacketKind::Order(vec![])); + + let index = make_test_index(vec![packet_def]); + let raw_bytes = [0xA1, 0x0F]; + + let err = decode(&raw_bytes, &index).unwrap_err(); + assert_eq!(err, DecodeError::WrongKind { id: order_id }); + } +} \ No newline at end of file From 8218e7476abb5dce583baa2a58cc929df3081dc1 Mon Sep 17 00:00:00 2001 From: Gazi Al Saad Halawani Date: Mon, 12 Oct 2026 16:40:58 +0200 Subject: [PATCH 64/82] Feat(backend, protocol): Created ReadMe for the protocol directory. --- backend/crates/core/src/protocol/README.md | 157 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 backend/crates/core/src/protocol/README.md diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md new file mode 100644 index 000000000..1d81566f4 --- /dev/null +++ b/backend/crates/core/src/protocol/README.md @@ -0,0 +1,157 @@ +# Protocol Module + +This module handles encoding and decoding of packets for communication with hardware boards over TCP and UDP transports. It implements the ADJ protocol for telemetry data, commands, messages, and protection events. + +## Architecture + +The protocol module follows a layered architecture: + +``` +┌─────────────────────────────────────────┐ +│ Transport Layer │ +│ (tcp.rs, udp.rs) │ +├─────────────────────────────────────────┤ +│ Packet Layer │ +│ (packet/data_order.rs, │ +│ packet/message.rs, │ +│ packet/protection.rs) │ +├─────────────────────────────────────────┤ +│ Wire Layer │ +│ (wire.rs) │ +└─────────────────────────────────────────┘ +``` + +## Directory Structure + +``` +protocol/ +├── index.rs # Packet definition lookup table +├── error.rs # Decode and encode error types +├── wire.rs # Low-level byte ↔ value conversion +├── packet.rs # Packet-level encoding/decoding module +│ ├── data_order.rs # Data (telemetry) & Order (command) packets +│ ├── message.rs # Free-text log message packets (stub) +│ └── protection.rs # Protection trigger packets (stub) +├── tcp.rs # TCP transport framing (stub) +└── udp.rs # UDP transport framing +``` + +## Modules + +### `index.rs` - Packet Definition Lookup + +Provides a fast lookup table from packet IDs (`AdjId`) to their declarations (`PacketDef`). Built once from loaded ADJ configuration data (`PodData`), it owns cloned copies of packet definitions so it can be shared across async tasks via `Arc`. + +**Key types:** +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` + +### `error.rs` - Error Types + +Defines errors that can occur during packet encoding and decoding. + +**Key types:** +- `DecodeError` - Errors when converting bytes to packets + - `UnknownId` - No packet declared with this ID + - `UnexpectedEof` - Ran out of bytes during decode + - `Io` - I/O error reading from stream (TCP only) + - `InvalidEnumValue` - Enum index out of range + - `UnterminatedString` - String field not null-terminated + - `WrongKind` - Packet exists but isn't expected kind + +- `EncodeError` - Errors when converting packets to bytes + - `UnknownId` - No packet declared with this ID + - `WrongFieldCount` - Missing or extra fields in order + - `TypeMismatch` - Value doesn't match declared type + - `OutOfRange` - Numeric value doesn't fit wire type + - `WrongKind` - Packet exists but isn't expected kind + +### `wire.rs` - Low-Level Byte Codec + +Handles conversion between raw bytes and `Value` types for individual measurements, based on their declared `MeasurementKind`. All numeric types use little-endian encoding. + +**Key functions:** +- `decode_value(bytes, kind)` - Decode a single value from bytes +- `encode_value(value, kind)` - Encode a single value to bytes +- `byte_width(kind)` - Get the wire width in bytes for a measurement kind + +**Supported types:** +- **Numeric** (U8, U16, U32, U64, I8, I16, I32, I64, F32, F64) - ✓ Implemented +- **Enum** (single byte index) - ✓ Implemented +- **Boolean** - ⚠ Stub (wire width not yet confirmed with firmware) + +### `packet/` - Packet-Level Encoding/Decoding + +Contains per-packet-kind encoding and decoding logic, independent of transport. + +#### `data_order.rs` + +Handles two packet kinds: +- **Order packets** (commands sent to boards) - Encoded only, sent over TCP +- **Data packets** (periodic telemetry from boards) - Decoded only, received over UDP + +**Key functions:** +- `encode(order, index)` - Encode an Order packet into bytes +- `decode(bytes, index)` - Decode a Data packet from bytes + +#### `message.rs` ⚠ Stub + +Decodes free-text log messages from boards over TCP. Not yet implemented. + +#### `protection.rs` ⚠ Stub + +Decodes protection trigger events from boards over TCP. Not yet implemented. + +### `tcp.rs` - TCP Transport Framing ⚠ Partial + +Handles the dedicated TCP connection to each board. A TCP connection carries: +- Protection events (board → backend) +- Free-text messages (board → backend) +- Order commands (backend → board) + +**Key types:** +- `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) + +**Key functions:** +- `decode_next(reader, index)` - ⚠ Stub - Decode the next frame from a TCP stream +- `encode_order(order, index)` - ✓ Implemented - Encode an order for sending + +### `udp.rs` - UDP Transport Framing + +Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete, already-delimited buffers containing Data packets. + +**Key functions:** +- `decode_datagram(bytes, index)` - ✓ Implemented - Decode a UDP datagram into a DataPacket + +## Packet Types Summary + +| Packet Type | Direction | Transport | Status | +|-------------|-----------|-----------|--------| +| Data (telemetry) | Board → Backend | UDP | ✓ Implemented | +| Order (command) | Backend → Board | TCP | ✓ Implemented | +| Message (log) | Board → Backend | TCP | ⚠ Stub | +| Protection (event) | Board → Backend | TCP | ⚠ Stub | + +## Implementation Status + +- ✅ **Data packets** - Full encode/decode with tests +- ✅ **Order packets** - Full encode/decode with tests +- ✅ **Wire-level numeric types** - All numeric kinds supported with tests +- ✅ **Wire-level enum types** - Full encode/decode with tests +- ⚠️ **Wire-level boolean types** - Stub (awaiting firmware confirmation) +- ⚠️ **Message packets** - Stub (decode not implemented) +- ⚠️ **Protection packets** - Stub (decode not implemented) +- ⚠️ **TCP framing** - Partial (decode_next not implemented) + +## Testing + +The module includes comprehensive unit tests for: +- Wire-level encoding/decoding for all numeric types +- Enum encoding/decoding with boundary checks +- Order packet encoding with field validation +- Data packet decoding with error handling +- Error propagation for unknown IDs, wrong kinds, and type mismatches + +Run tests with: +```bash +cargo test -p core protocol +``` From b615ec814970816e2efcf4decc4dbe1d9f7c3a59 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:52:32 +0200 Subject: [PATCH 65/82] fix(backend, model): use phantom type to diferenciate betwen orders and data packets --- backend/crates/core/src/model.rs | 3 +- backend/crates/core/src/model/README.md | 46 +++++++---- backend/crates/core/src/model/packet.rs | 103 +++++++++++++++++++----- 3 files changed, 118 insertions(+), 34 deletions(-) diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index ccef505c5..54754b45b 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,7 +21,8 @@ pub use board::{Board, PacketDef, PacketKind, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - DataPacket, MessageLevel, MessagePacket, OrderPacket, Packet, ProtectionPacket, ShortTimestamp, DataOrderPacket + Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, + ProtectionPacket, ShortTimestamp, }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index fb85173ea..dc1d58f49 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -123,23 +123,39 @@ Second of Timestamp of values 0–59. #### `subsecond` The sub-second fraction, as the raw byte on the wire. Its exact unit (e.g. milliseconds, or 1/256ths of a second) hasn't been confirmed yet, so it is kept as a raw `u8` here instead of converted to something like a `Duration` — convert it once the unit is settled. -### 4.2 `DataPacket` -Struct for the decoded values of the measurements a board just sent. Values are positional: they are not individually tagged with their measurement ID, but written in the order their `Measurement`s appear in this packet's `PacketKind::Data` list. +### 4.2 `DataOrderPacket` — a phantom type +`DataPacket` and `OrderPacket` hold the exact same shape (`id: AdjId`, `values: Vec`, both positional — see below), so they're both built on one generic struct, `DataOrderPacket`, instead of being copy-pasted. The two used to just be `type` aliases for the very same concrete struct (`type OrderPacket = DataOrderPacket; type DataPacket = DataOrderPacket;`) — which meant they weren't actually distinct types at all: nothing stopped a `DataPacket` just decoded off the UDP socket from being passed to `protocol::tcp::encode_order` (which wants an *order* to send) by mistake, the compiler had no way to tell them apart. -#### `id` -Which packet definition this is an instance of (`AdjId`). -#### `values` -Vector list of decoded values (`Vec`) corresponding positionally to the packet definition's measurements. +`Kind` fixes that. It's a *phantom* type parameter — `DataOrderPacket` doesn't actually store a `Kind` value (there's nothing to store, see `Data`/`Order` below), it only exists so the compiler tracks which one a given packet was built as: +```rust +pub struct DataOrderPacket { + pub id: AdjId, + pub values: Vec, + _marker: PhantomData, +} +``` +`_marker` is private, so a `DataOrderPacket` can't be built with struct-literal syntax from outside `packet.rs` — use `DataOrderPacket::new(id, values)` (or, through the aliases below, `DataPacket::new(...)`/`OrderPacket::new(...)`) instead. + +#### `Data` / `Order` +Two empty marker structs — the only two types `Kind` is ever instantiated with. Neither is ever actually constructed; they only ever appear as a type argument. That's what makes the whole thing *phantom*: the type parameter has no representation in the compiled struct, it's erased after type-checking, the same way `id.rs` (§2) uses distinct `AdjId`/`BoardId` types so the compiler won't confuse two different kinds of `u16`. -### 4.3 `OrderPacket` -An order sent from the backend to a board. Also positional, matching field definitions in order. +#### `DataPacket` / `OrderPacket` +```rust +pub type DataPacket = DataOrderPacket; +pub type OrderPacket = DataOrderPacket; +``` +Now genuinely different types: a function that takes an `OrderPacket` cannot be called with a `DataPacket`, even though both just wrap the same `(AdjId, Vec)` underneath — the mix-up is a compile error instead of a bug waiting to happen. `DataOrderPacket`'s own doc comment has a `compile_fail` doctest proving exactly that (run by `cargo test`, like any other doctest). + +`DataPacket` holds the decoded values of the measurements a board just sent — positional, not individually tagged with their measurement ID, just written in the order their `Measurement`s appear in this packet's `PacketKind::Data` list. + +`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketKind::Order`'s measurement list. #### `id` -Which order definition this is an instance of (`AdjId`). -#### `fields` -Vector list of values (`Vec`) to set for each field of the order. +Which packet/order definition this is an instance of (`AdjId`). +#### `values` +Vector list of decoded values (`Vec`) corresponding positionally to the packet definition's measurements. -### 4.4 `ProtectionPacket` +### 4.3 `ProtectionPacket` Sent by a board when one of its declared protections triggers. #### `id` @@ -149,7 +165,7 @@ When the protection triggered (`ShortTimestamp`). #### `value` The value that triggered the protection (`Value`), decoded with the same wire type as the watched measurement. -### 4.5 `MessageLevel` +### 4.4 `MessageLevel` Enum indicating how severe a message is (reusing `general_info.json`'s `message_ids`). Distinct from `Severity` because message and protection formats are separate in ADJv3. #### `Fault` @@ -161,7 +177,7 @@ Informational message, no action implied. #### `Panic` An unrecoverable firmware error. -### 4.6 `MessagePacket` +### 4.5 `MessagePacket` A free-text message sent by a board. #### `level` @@ -173,7 +189,7 @@ Source identifier, such as a source file path (`String`). #### `message` The text contents of the message (`String`). -### 4.7 `Packet` +### 4.6 `Packet` Enum representing any packet actually sent or received over the wire, once decoded. #### `Data` diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 4c249fb53..ca766809a 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -7,6 +7,7 @@ //! describes a whole packet. use super::{AdjId, Value}; +use std::marker::PhantomData; /// A short timestamp carrying only the time of day: hour, minute, second /// and a sub-second fraction, each one byte. @@ -32,8 +33,22 @@ pub struct ShortTimestamp { pub subsecond: u8, } +/// Tags a [`DataOrderPacket`] as a board's periodic telemetry +/// ([`DataPacket`]) rather than a command sent to one. +/// +/// Carries no data — see [`DataOrderPacket`] for why it exists at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Data; + +/// Tags a [`DataOrderPacket`] as a command sent to a board +/// ([`OrderPacket`]) rather than telemetry received from one. +/// +/// Carries no data — see [`DataOrderPacket`] for why it exists at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Order; + /// A Data/Order packet: the decoded values of the measurements a board just -/// sent. +/// sent, or that an order is about to send to one. /// /// The wire protocol is positional: values are not individually tagged /// with their measurement's id, just written back to back in the order @@ -41,18 +56,58 @@ pub struct ShortTimestamp { /// [`super::PacketKind::Data`] list. `values[i]` is the decoded value of /// that list's `i`-th measurement — matching them up by position is /// `protocol`'s job, this type just holds the result in that same order. - +/// +/// `Kind` is a phantom type parameter (either [`Data`] or [`Order`], never +/// anything else — see [`DataPacket`]/[`OrderPacket`]): the struct stores +/// no `Kind` value, nothing to store (`Data`/`Order` carry no data), it +/// only exists so the compiler can tell a [`DataPacket`] apart from an +/// [`OrderPacket`], the same way [`super::AdjId`]/[`super::BoardId`] +/// (`id.rs`) are distinct types so the compiler won't let one be confused +/// with the other, even though both are just a `u16` underneath. Before +/// this, [`DataPacket`] and [`OrderPacket`] were both plain aliases for +/// the same concrete struct — nothing stopped a `DataPacket`, decoded off +/// the UDP socket, from being passed to [`crate::protocol::tcp::encode_order`] +/// by mistake; that mix-up is now a compile error: +/// +/// ```compile_fail +/// use software_core::model::{AdjId, DataPacket, OrderPacket}; +/// +/// fn send_order(_order: OrderPacket) {} +/// +/// let data = DataPacket::new(AdjId(1000), vec![]); +/// send_order(data); // doesn't compile: `DataPacket` isn't an `OrderPacket` +/// ``` #[derive(Debug, Clone, PartialEq)] -pub struct DataOrderPacket{ +pub struct DataOrderPacket { + /// The packet's id. pub id: AdjId, - pub values: Vec, + /// Each measurement's decoded value, in the same order as the + /// matching [`super::PacketKind::Data`]/[`super::PacketKind::Order`] + /// list. + pub values: Vec, + _marker: PhantomData, +} + +impl DataOrderPacket { + /// Builds a packet tagged as `Kind`, e.g. `DataPacket::new(id, values)` + /// or `OrderPacket::new(id, values)`. + pub fn new(id: AdjId, values: Vec) -> Self { + Self { + id, + values, + _marker: PhantomData, + } + } } /// An order sent from the backend to a board. /// /// Positional, the same way [`DataPacket::values`] is: see its docs. -pub type OrderPacket = DataOrderPacket; -pub type DataPacket = DataOrderPacket; +pub type OrderPacket = DataOrderPacket; +/// A set of measurements sent by a board. +/// +/// Positional, the same way [`DataOrderPacket::values`] is: see its docs. +pub type DataPacket = DataOrderPacket; /// A protection packet: sent by a board when one of its declared /// [`super::Protection`]s triggers. @@ -194,10 +249,7 @@ mod tests { fn data_packet_values_are_positional_not_tagged_with_an_id() { // values[0] is whatever measurements[0] is in this packet's // PacketDef — there is no id stored alongside each value here. - let packet = DataPacket { - id: AdjId(1000), - values: vec![Value::Number(53.2), Value::Bool(true)], - }; + let packet = DataPacket::new(AdjId(1000), vec![Value::Number(53.2), Value::Bool(true)]); assert_eq!(packet.values[0], Value::Number(53.2)); assert_eq!(packet.values[1], Value::Bool(true)); @@ -205,14 +257,8 @@ mod tests { #[test] fn packet_wraps_each_kind_without_mixing_them_up() { - let data = Packet::Data(DataPacket { - id: AdjId(1000), - values: vec![], - }); - let order = Packet::Order(OrderPacket { - id: AdjId(2000), - values: vec![], - }); + let data = Packet::Data(DataPacket::new(AdjId(1000), vec![])); + let order = Packet::Order(OrderPacket::new(AdjId(2000), vec![])); assert_ne!(data, order); match data { @@ -220,4 +266,25 @@ mod tests { _ => unreachable!("this is a Packet::Data"), } } + + #[test] + fn two_data_packets_with_the_same_id_and_values_are_equal() { + let a = DataPacket::new(AdjId(1000), vec![Value::Number(1.0)]); + let b = DataPacket::new(AdjId(1000), vec![Value::Number(1.0)]); + assert_eq!(a, b); + } + + #[test] + fn an_order_packet_and_a_data_packet_built_from_the_same_id_and_values_are_different_types() { + // This is the whole point of tagging `DataOrderPacket` with a + // phantom `Kind`: `DataPacket` and `OrderPacket` used to be plain + // aliases for the same concrete struct, so nothing here would even + // have been a type error before. There's no runtime assertion to + // make for that — see this module's `compile_fail` doctest on + // `DataOrderPacket`, which proves the mix-up doesn't compile. + let data = DataPacket::new(AdjId(1000), vec![]); + let order = OrderPacket::new(AdjId(1000), vec![]); + assert_eq!(data.id, order.id); + assert_eq!(data.values, order.values); + } } From fc7216b2da11fe7901c4f9f4eb7c669787d13011 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:58:00 +0200 Subject: [PATCH 66/82] fix(backend, protocol): changes due to unification of data_order --- backend/crates/core/src/protocol/packet.rs | 1 + backend/crates/core/src/protocol/tcp.rs | 2 +- backend/crates/core/src/protocol/udp.rs | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 7f357df8f..7d17cc623 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -5,6 +5,7 @@ //! only ever received (over UDP), an `Order` only ever sent (over TCP), //! so there's no `encode` for the former or `decode` for the latter. +pub mod data_order; pub mod message; pub mod protection; pub mod data_order; diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index af3882253..5adf7edd8 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -45,5 +45,5 @@ pub fn decode_next(reader: impl Read, index: &Index) -> Result Result, EncodeError> { - packet::order::encode(order, index) + packet::data_order::encode(order, index) } diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs index 4eb0d6723..35e918ce0 100644 --- a/backend/crates/core/src/protocol/udp.rs +++ b/backend/crates/core/src/protocol/udp.rs @@ -13,5 +13,5 @@ use crate::model::DataPacket; /// Decodes a single UDP datagram's bytes into a [`DataPacket`]. pub fn decode_datagram(bytes: &[u8], index: &Index) -> Result { - packet::data::decode(bytes, index) + packet::data_order::decode(bytes, index) } From 45642f856d66ca659c471b6e6055c4cd1da58f10 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:58:29 +0200 Subject: [PATCH 67/82] fix(backend, protocol): implement partial eq manually for decode error (due to IO error) --- backend/crates/core/src/protocol/error.rs | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs index 64660e696..1ffd3b638 100644 --- a/backend/crates/core/src/protocol/error.rs +++ b/backend/crates/core/src/protocol/error.rs @@ -46,8 +46,33 @@ pub enum DecodeError { }, } +impl PartialEq for DecodeError { + // `Io`'s `std::io::Error` doesn't implement `PartialEq`, so this can't + // be derived — compare by `ErrorKind` instead for that variant. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::UnknownId(a), Self::UnknownId(b)) => a == b, + (Self::UnexpectedEof, Self::UnexpectedEof) => true, + (Self::Io(a), Self::Io(b)) => a.kind() == b.kind(), + ( + Self::InvalidEnumValue { + index: a_index, + options: a_options, + }, + Self::InvalidEnumValue { + index: b_index, + options: b_options, + }, + ) => a_index == b_index && a_options == b_options, + (Self::UnterminatedString { max: a }, Self::UnterminatedString { max: b }) => a == b, + (Self::WrongKind { id: a }, Self::WrongKind { id: b }) => a == b, + _ => false, + } + } +} + /// An error encoding a packet into bytes. -#[derive(Debug, thiserror::Error)] +#[derive(Debug, thiserror::Error, PartialEq)] pub enum EncodeError { /// No packet is declared with this id. #[error("unknown packet id {0:?}")] From 7f6364467f77feeb179b6f1a4fbc456b4460e1e4 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:59:01 +0200 Subject: [PATCH 68/82] docs(backend, protocol): update docs --- backend/crates/core/src/protocol/tcp.rs | 21 ++++++++++----------- backend/crates/core/src/protocol/udp.rs | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index 5adf7edd8..9d4169087 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -5,7 +5,7 @@ //! //! Both functions here are thin: the actual per-kind decoding lives in //! [`super::packet::protection`]/[`super::packet::message`]/ -//! [`super::packet::order`], independent of TCP. This module only adds +//! [`super::packet::data_order`], independent of TCP. This module only adds //! the transport-level guarantee — [`decode_next`] can only ever return //! a [`TcpFrame`], never a `DataPacket`. @@ -27,19 +27,18 @@ pub enum TcpFrame { /// field shapes against `index`. /// /// Reads the frame's leading 16-bit id first, then decides which of -/// [`super::packet::protection::decode`] (the id's high 3 bits are a -/// protection's position, non-zero) or [`super::packet::message::decode`] -/// (the id matches one of `general_info.json`'s `message_ids`) to hand -/// the rest of the frame to, wrapping the result in the matching -/// [`TcpFrame`] variant. +/// [`super::packet::protection::decode`] ([`crate::model::AdjId::protection_position`] +/// is non-zero) or [`super::packet::message::decode`] (the id matches one +/// of `general_info.json`'s `message_ids`) to hand the rest of the frame +/// to, wrapping the result in the matching [`TcpFrame`] variant. pub fn decode_next(reader: impl Read, index: &Index) -> Result { let _ = (reader, index); todo!( - "read the leading 16-bit id; if its top 3 bits are non-zero, call \ - packet::protection::decode; otherwise resolve it against \ - general_info.json's message_ids (not yet reachable from `Index` \ - — needs AdjInfo too) and call packet::message::decode; wrap \ - either result in the matching TcpFrame variant" + "read the leading 16-bit id; if its AdjId::protection_position() is \ + non-zero, call packet::protection::decode; otherwise resolve it \ + against general_info.json's message_ids (not yet reachable from \ + `Index` — needs AdjInfo too) and call packet::message::decode; \ + wrap either result in the matching TcpFrame variant" ) } diff --git a/backend/crates/core/src/protocol/udp.rs b/backend/crates/core/src/protocol/udp.rs index 35e918ce0..05c44c3c0 100644 --- a/backend/crates/core/src/protocol/udp.rs +++ b/backend/crates/core/src/protocol/udp.rs @@ -2,7 +2,7 @@ //! telemetry (`Data` packets) from any board. Receive-only — the backend //! never sends anything over this socket. //! -//! The actual decoding lives in [`super::packet::data`], independent of +//! The actual decoding lives in [`super::packet::data_order`], independent of //! UDP — this module only adds the transport-level guarantee: a UDP //! datagram is a complete, already-delimited buffer (the OS preserves //! datagram boundaries even on a socket shared by every board), unlike From 5a99aa40279ca5092873662b000b9eeaf5f590b5 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 17:59:31 +0200 Subject: [PATCH 69/82] fix(backend, protocol): format --- backend/crates/core/src/protocol/wire.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs index 8809c7126..89bf317e1 100644 --- a/backend/crates/core/src/protocol/wire.rs +++ b/backend/crates/core/src/protocol/wire.rs @@ -203,7 +203,10 @@ mod tests { ]; for (wire_type, expected) in cases { - assert_eq!(byte_width(&MeasurementKind::Numeric { wire_type }), expected); + assert_eq!( + byte_width(&MeasurementKind::Numeric { wire_type }), + expected + ); } } From 3e13e0985e904d3828e98e1a8d854371b82a25f2 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 18:00:17 +0200 Subject: [PATCH 70/82] fix(backend, protocol): remove duplicated data_order exportation --- backend/crates/core/src/protocol/packet.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 7d17cc623..4bf19cc13 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -8,4 +8,3 @@ pub mod data_order; pub mod message; pub mod protection; -pub mod data_order; From 618e696adbd7f63217cf3225dca955c22d451e43 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 19:23:05 +0200 Subject: [PATCH 71/82] fix(backend, model): include kind --- backend/crates/core/src/model.rs | 4 ++-- backend/crates/core/src/model/README.md | 11 +++++++++ backend/crates/core/src/model/packet.rs | 30 ++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 54754b45b..04f933cb3 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -21,8 +21,8 @@ pub use board::{Board, PacketDef, PacketKind, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, - ProtectionPacket, ShortTimestamp, + Data, DataOrderPacket, DataPacket, KindTag, MessageLevel, MessagePacket, Order, OrderPacket, + Packet, ProtectionPacket, ShortTimestamp, }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index dc1d58f49..aa088648c 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -139,6 +139,17 @@ pub struct DataOrderPacket { #### `Data` / `Order` Two empty marker structs — the only two types `Kind` is ever instantiated with. Neither is ever actually constructed; they only ever appear as a type argument. That's what makes the whole thing *phantom*: the type parameter has no representation in the compiled struct, it's erased after type-checking, the same way `id.rs` (§2) uses distinct `AdjId`/`BoardId` types so the compiler won't confuse two different kinds of `u16`. +#### `KindTag` +The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `KindTag` is how code that's generic over `Kind` answers that anyway, by asking the question of the *type* instead of the *value*: +```rust +pub trait KindTag { + fn matches(kind: &PacketKind) -> bool; +} +``` +`Data`/`Order` each implement it by checking which `PacketKind` (§1.1) variant they stand for (`matches!(kind, PacketKind::Data(_))` / `PacketKind::Order(_)`). This is the only thing `KindTag` decides — once a `PacketDef`'s kind is confirmed to match, the actual measurement list still comes from the existing `PacketKind::measurements()` (§1.1), not from `KindTag` itself. + +`protocol::packet::data_order`'s `encode`/`decode` use this to be generic over `Kind`: written once, they work for both `DataPacket` and `OrderPacket` instead of needing a near-duplicate pair of functions per kind. + #### `DataPacket` / `OrderPacket` ```rust pub type DataPacket = DataOrderPacket; diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index ca766809a..56b9c60e8 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -6,7 +6,7 @@ //! packet or a protection *can* look like, or [`super::PacketDef`], which //! describes a whole packet. -use super::{AdjId, Value}; +use super::{AdjId, PacketKind, Value}; use std::marker::PhantomData; /// A short timestamp carrying only the time of day: hour, minute, second @@ -47,6 +47,34 @@ pub struct Data; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct Order; +/// Bridges a [`DataOrderPacket`]'s phantom `Kind` to the runtime +/// [`PacketKind`] variant it stands for. +/// +/// `protocol::packet::data_order`'s `encode`/`decode` are generic over +/// `Kind`: they're written once and work for both [`Data`] and [`Order`], +/// instead of needing a near-identical copy per kind. This trait is the +/// one yes/no question that generic code can't answer on its own — +/// "is this `PacketDef`'s kind actually the one I was asked to +/// encode/decode?" — everything after that (pulling out the +/// measurements) is already handled by [`PacketKind::measurements`], +/// kind-agnostic, so there's no need to redo that here. +pub trait KindTag { + /// Whether `kind` is the variant this `Kind` stands for. + fn matches(kind: &PacketKind) -> bool; +} + +impl KindTag for Data { + fn matches(kind: &PacketKind) -> bool { + matches!(kind, PacketKind::Data(_)) + } +} + +impl KindTag for Order { + fn matches(kind: &PacketKind) -> bool { + matches!(kind, PacketKind::Order(_)) + } +} + /// A Data/Order packet: the decoded values of the measurements a board just /// sent, or that an order is about to send to one. /// From d47baea1b06b93c3c7944a2160402da1c98401d0 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 19:24:13 +0200 Subject: [PATCH 72/82] feat(backend, protocol): implement boolean --- backend/crates/core/src/protocol/wire.rs | 74 +++++++++++++++++++----- 1 file changed, 61 insertions(+), 13 deletions(-) diff --git a/backend/crates/core/src/protocol/wire.rs b/backend/crates/core/src/protocol/wire.rs index 89bf317e1..9ebe6ab8b 100644 --- a/backend/crates/core/src/protocol/wire.rs +++ b/backend/crates/core/src/protocol/wire.rs @@ -45,10 +45,7 @@ pub fn byte_width(kind: &MeasurementKind) -> usize { NumericKind::U32 | NumericKind::I32 | NumericKind::F32 => 4, NumericKind::U64 | NumericKind::I64 | NumericKind::F64 => 8, }, - // Not yet confirmed against firmware — see `decode_boolean`. - MeasurementKind::Boolean => { - todo!("confirm the wire width firmware uses for a boolean measurement") - } + MeasurementKind::Boolean => 1, MeasurementKind::Enum { .. } => 1, } } @@ -128,19 +125,20 @@ fn numeric_range(wire_type: NumericKind) -> (f64, f64) { } } -/// Decodes a boolean value. -/// -/// Wire width isn't settled yet — not specified anywhere in the ADJv3 -/// spec read so far. Confirm against firmware before implementing. +/// Decodes a boolean value: a single byte, zero for `false`, any other +/// value for `true`. pub fn decode_boolean(bytes: &[u8]) -> Result { - let _ = bytes; - todo!("confirm the wire width firmware uses for a boolean measurement") + let &byte = bytes.first().ok_or(DecodeError::UnexpectedEof)?; + Ok(Value::Bool(byte != 0)) } -/// Encodes a boolean value. +/// Encodes a boolean value as a single byte: `0x00` for `false`, `0x01` +/// for `true`. pub fn encode_boolean(value: &Value) -> Result, EncodeError> { - let _ = value; - todo!("confirm the wire width firmware uses for a boolean measurement") + let Value::Bool(value) = value else { + return Err(EncodeError::TypeMismatch); + }; + Ok(vec![u8::from(*value)]) } /// Decodes an enum value: a single `uint8` byte, the index into @@ -220,6 +218,56 @@ mod tests { ); } + #[test] + fn byte_width_of_a_boolean_is_one() { + assert_eq!(byte_width(&MeasurementKind::Boolean), 1); + } + + #[test] + fn decodes_a_zero_byte_as_false() { + let Ok(value) = decode_boolean(&[0x00]) else { + unreachable!("decoding a zero byte should succeed"); + }; + assert_eq!(value, Value::Bool(false)); + } + + #[test] + fn decodes_any_nonzero_byte_as_true() { + let Ok(value) = decode_boolean(&[0xFF]) else { + unreachable!("decoding a nonzero byte should succeed"); + }; + assert_eq!(value, Value::Bool(true)); + } + + #[test] + fn decode_boolean_rejects_empty_input() { + assert!(matches!( + decode_boolean(&[]), + Err(DecodeError::UnexpectedEof) + )); + } + + #[test] + fn boolean_round_trips_through_encode_then_decode() { + for value in [true, false] { + let Ok(bytes) = encode_boolean(&Value::Bool(value)) else { + unreachable!("encoding a well-formed boolean should succeed"); + }; + let Ok(decoded) = decode_boolean(&bytes) else { + unreachable!("decoding what we just encoded should succeed"); + }; + assert_eq!(decoded, Value::Bool(value)); + } + } + + #[test] + fn encode_boolean_rejects_the_wrong_value_variant() { + assert!(matches!( + encode_boolean(&Value::Number(1.0)), + Err(EncodeError::TypeMismatch) + )); + } + #[test] fn decodes_an_unsigned_integer_little_endian() { let Ok(value) = decode_numeric(&[0x34, 0x12], NumericKind::U16) else { From 87c015931e37c1cb9205319a74d9581917155a53 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 19:25:01 +0200 Subject: [PATCH 73/82] fix(backend, protocol): solve issues and add comments --- .../core/src/protocol/packet/data_order.rs | 342 ++++++++++++------ 1 file changed, 230 insertions(+), 112 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index eaa4cb84d..d4643a050 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -1,120 +1,129 @@ -use crate::model::{AdjId, DataPacket, PacketKind, Value, DataOrderPacket}; -use crate::protocol::{wire, EncodeError, Index, DecodeError}; - - -//! Encodes an `Order` packet: a command sent to a board. Only ever sent, -//! over TCP — the backend never receives one back. - -/// Encodes `order` into the bytes to write to a board's connection, +//! Encodes and decodes a positional Data/Order packet — see +//! [`crate::model::DataOrderPacket`] for why both share this one wire +//! shape under a phantom `Kind`. +//! +//! [`encode`]/[`decode`] are generic over that `Kind` via +//! [`crate::model::KindTag`], so they're written once and work for either +//! [`crate::model::Data`] or [`crate::model::Order`] — the backend itself +//! only ever calls `encode::` (via [`super::super::tcp`]) and +//! `decode::` (via [`super::super::udp`]), but that restriction +//! lives at the transport layer, not here. [`crate::model::KindTag`] only +//! answers whether a `PacketDef`'s kind matches `Kind` at all; the actual +//! measurement list comes from [`crate::model::PacketKind::measurements`] +//! either way. + +use crate::model::{AdjId, DataOrderPacket, KindTag}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; + +/// Encodes `packet` into the bytes to write to a board's connection, /// resolving its field shapes against `index`. -pub fn encode(order: &DataOrderPacket, index: &Index) -> Result, EncodeError> { - - //Unwrap PacketDef for kind +pub fn encode( + packet: &DataOrderPacket, + index: &Index, +) -> Result, EncodeError> { + // Look up the packet definition for this packet's id let packet_def = index - .packet(order.id) - .ok_or(EncodeError::UnknownId(order.id))?;// Question mark for error propagation and prevent crash + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; - //Check if packetdef is PacketKind::Order(measurement). Also returns the list of measurements - let measurements = match &packet_def.kind{ - PacketKind::Order(measurements) => measurements, - _ => return Err(EncodeError::WrongKind {id:order.id}) - }; + // Check that the packet is an order or data, as expected by the generic KindTag + if !Kind::matches(&packet_def.kind) { + return Err(EncodeError::WrongKind { id: packet.id }); + } + + // Get the measurements for this packet + let measurements = packet_def.kind.measurements(); - //Check if length of list of fields matches number of measurements. - if order.values.len() != measurements.len(){ - return Err(EncodeError::WrongFieldCount {expected:measurements.len(),got:order.values.len()}) + // Check that the number of values that we want to encode matches the number of measurements in the packet definition + if packet.values.len() != measurements.len() { + return Err(EncodeError::WrongFieldCount { + expected: measurements.len(), + got: packet.values.len(), + }); } + // Encode the packet id as little-endian u16 let mut bytes = Vec::new(); + bytes.extend_from_slice(&packet.id.0.to_le_bytes()); - bytes.extend_from_slice(&order.id.0.to_le_bytes());//Header prefix of byte stream to 16 bit little-endian + // for each value in the packet encode it according to the corresponding measurements + for (field, measurement) in packet.values.iter().zip(measurements) { + // Encode the field value according to the measurement's kind + let field_bytes = wire::encode_value(field, &measurement.kind)?; - //Encoding to bytes - for (field,measurement) in order.values.iter().zip(measurements){ //For each measurement - let field_bytes = wire::encode_value(field,&measurement.kind)?;// Encode field Value into bytes according to MeasurementKind - bytes.extend(field_bytes);// Adds to byte stream + // Append the encoded field bytes to the output vector + bytes.extend(field_bytes); } - Ok(bytes) // Return bytes + Ok(bytes) } - -// -// "look up order.id in `index` to get its PacketDef (UnknownId if \ -// absent); match its `kind` for PacketKind::Order(measurements) \ -// (WrongKind if it's some other kind); error with WrongFieldCount \ -// if order.fields.len() doesn't match measurements.len(); encode \ -// the id then each field positionally via wire::encode_value" -// - -//! Decodes a `Data` packet: a board's periodic telemetry. Only ever -//! received, over UDP — a board never receives one back. - -/// Decodes a complete, already-delimited buffer into a [`DataPacket`], -/// resolving its field shapes against `index`. -pub fn decode(bytes: &[u8], index: &Index) -> Result, DecodeError> { +/// Decodes a complete, already-delimited buffer into a +/// [`DataOrderPacket`], resolving its field shapes against `index`. +pub fn decode( + bytes: &[u8], + index: &Index, +) -> Result, DecodeError> { + // The first two bytes are the packet id, in little-endian u16 if bytes.len() < 2 { - // If less than 2 bytes return error return Err(DecodeError::UnexpectedEof); } - let id: [u8; 2] = bytes[0..2] // Recieve first two bytes - .try_into() // Tries to make it into desired type - .map_err(|_| DecodeError::UnexpectedEof)?; // If fails, return error - - let adj_id = AdjId(u16::from_le_bytes(id)); // Parse into u16 and make it AdjId type + // generate the packet id from the first two bytes + let id: [u8; 2] = bytes[0..2] + .try_into() + .map_err(|_| DecodeError::UnexpectedEof)?; - let packet_def = index.get(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; // Get packet definition from index + // generate ADJ id from the little-endian bytes + let adj_id = AdjId(u16::from_le_bytes(id)); - let measurements = match &packet_def.kind { - // Get measurements from packet definition - PacketKind::Data(measurements) => measurements, // If packet kind is data, return measurements - _ => return Err(DecodeError::WrongKind { id: adj_id }), // If not, return Error - }; + // Look up the packet definition for this id if not found return an UnknownId error + let packet_def = index.packet(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; - let mut values = Vec::with_capacity(measurements.len()); // Create vectir list of values same size as measurements + // Check that the packet is an order or data, as expected by the generic KindTag + if !Kind::matches(&packet_def.kind) { + return Err(DecodeError::WrongKind { id: adj_id }); + } + // Get the measurements for this packet + let measurements = packet_def.kind.measurements(); - let mut pointer = 2; // Pointer to after ID in bytes + // Prepare a vector to hold the decoded values and a pointer to track the current position in the byte slice + let mut values = Vec::with_capacity(measurements.len()); + let mut pointer = 2; + // For each measurement, decode the corresponding bytes from the input slice for measurement in measurements { let width = wire::byte_width(&measurement.kind); - let next = pointer + width; + // Check if there are enough bytes to decode the field if bytes.len() < next { - // Check if there is enough bytes left return Err(DecodeError::UnexpectedEof); } - let field = &bytes[pointer..next]; // Size of field (bytes between pointer and next position) - let value = wire::decode_value(field, &measurement.kind)?; // Decode value from field size and measurement width - values.push(value); //Add to list of values + // Slice the bytes for the current field and decode it according to the measurement's kind + let field = &bytes[pointer..next]; + let value = wire::decode_value(field, &measurement.kind)?; // Append the decoded value to the values vector + values.push(value); - pointer = next; // Next position + pointer = next; } - Ok(DataPacket { id: adj_id, values }) // Return data packet + Ok(DataOrderPacket::new(adj_id, values)) } -// -// "read the leading 16-bit id; look up its PacketDef in `index` \ -// (UnknownId if absent); match its `kind` for \ -// PacketKind::Data(measurements) (WrongKind if it's some other \ -// kind); decode each measurement's value positionally via \ -// wire::decode_value, advancing through `bytes` using each \ -// measurement's own wire width" -// - #[cfg(test)] mod tests { use super::*; use crate::model::{ - Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, - PacketKind, PodData, Value, + Board, BoardId, Data, DataPacket, MacAddress, Measurement, MeasurementKind, NumericKind, + Order, OrderPacket, PacketDef, PacketKind, PodData, Value, }; use std::collections::HashMap; use std::net::IpAddr; + // Auxiliar function to create a sample measurement for testing + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { Measurement { id: AdjId(id), @@ -158,35 +167,43 @@ mod tests { let packet_def = sample_packet_def( order_id, PacketKind::Order(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), - sample_measurement(2, MeasurementKind::Boolean), + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), ]), ); let index = make_test_index(vec![packet_def]); - let packet = DataOrderPacket { - id: order_id, - values: vec![Value::Number(300.0), Value::Bool(true)], - }; + let packet = OrderPacket::new(order_id, vec![Value::Number(300.0), Value::Number(1.0)]); - let encoded = encode(&packet, &index).expect("encoding should succeed"); + let Ok(encoded) = encode(&packet, &index) else { + unreachable!("encoding a well-formed order should succeed"); + }; // 1001 in little-endian u16: [0xE9, 0x03] // 300 in little-endian u16: [0x2C, 0x01] - // true: [0x01] + // enum index 1: [0x01] assert_eq!(encoded, vec![0xE9, 0x03, 0x2C, 0x01, 0x01]); } #[test] fn encode_fails_on_unknown_id() { let index = make_test_index(vec![]); - let packet = DataOrderPacket { - id: AdjId(9999), - values: vec![], - }; + let packet = OrderPacket::new(AdjId(9999), vec![]); - let err = encode(&packet, &index).unwrap_err(); + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); } @@ -196,12 +213,11 @@ mod tests { let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); let index = make_test_index(vec![packet_def]); - let packet = DataOrderPacket { - id: data_id, - values: vec![], - }; + let packet = OrderPacket::new(data_id, vec![]); - let err = encode(&packet, &index).unwrap_err(); + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet kind should fail"); + }; assert_eq!(err, EncodeError::WrongKind { id: data_id }); } @@ -211,18 +227,27 @@ mod tests { let packet_def = sample_packet_def( order_id, PacketKind::Order(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), - sample_measurement(2, MeasurementKind::Numeric { wire_type: NumericKind::U8 }), + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ), + sample_measurement( + 2, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ), ]), ); let index = make_test_index(vec![packet_def]); - let packet = DataOrderPacket { - id: order_id, - values: vec![Value::Number(10.0)], - }; + let packet = OrderPacket::new(order_id, vec![Value::Number(10.0)]); - let err = encode(&packet, &index).unwrap_err(); + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding with the wrong field count should fail"); + }; assert_eq!( err, EncodeError::WrongFieldCount { @@ -240,8 +265,18 @@ mod tests { let packet_def = sample_packet_def( data_id, PacketKind::Data(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U16 }), - sample_measurement(2, MeasurementKind::Boolean), + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Enum { + options: vec!["Off".to_string(), "On".to_string()], + }, + ), ]), ); @@ -249,22 +284,26 @@ mod tests { // 3001 in little-endian u16: [0xB9, 0x0B] // 500 in little-endian u16: [0xF4, 0x01] - // false: [0x00] + // enum index 0: [0x00] let raw_bytes = [0xB9, 0x0B, 0xF4, 0x01, 0x00]; - let decoded = decode(&raw_bytes, &index).expect("decoding should succeed"); + let Ok(decoded) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a well-formed data packet should succeed"); + }; assert_eq!(decoded.id, data_id); assert_eq!( decoded.values, - vec![Value::Number(500.0), Value::Bool(false)] + vec![Value::Number(500.0), Value::Enum("Off".to_string())] ); } #[test] fn decode_fails_when_buffer_is_shorter_than_header() { let index = make_test_index(vec![]); - let err = decode(&[0x01], &index).unwrap_err(); + let Err(err) = decode::(&[0x01], &index) else { + unreachable!("decoding a buffer shorter than the header should fail"); + }; assert_eq!(err, DecodeError::UnexpectedEof); } @@ -273,15 +312,20 @@ mod tests { let data_id = AdjId(3002); let packet_def = sample_packet_def( data_id, - PacketKind::Data(vec![ - sample_measurement(1, MeasurementKind::Numeric { wire_type: NumericKind::U32 }), - ]), + PacketKind::Data(vec![sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U32, + }, + )]), ); let index = make_test_index(vec![packet_def]); let raw_bytes = [0xBA, 0x0B, 0x01, 0x02]; - let err = decode(&raw_bytes, &index).unwrap_err(); + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding a truncated payload should fail"); + }; assert_eq!(err, DecodeError::UnexpectedEof); } @@ -290,7 +334,9 @@ mod tests { let index = make_test_index(vec![]); let raw_bytes = [0x88, 0x13]; - let err = decode(&raw_bytes, &index).unwrap_err(); + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding an unknown id should fail"); + }; assert_eq!(err, DecodeError::UnknownId(AdjId(5000))); } @@ -302,7 +348,79 @@ mod tests { let index = make_test_index(vec![packet_def]); let raw_bytes = [0xA1, 0x0F]; - let err = decode(&raw_bytes, &index).unwrap_err(); + let Err(err) = decode::(&raw_bytes, &index) else { + unreachable!("decoding the wrong packet kind should fail"); + }; assert_eq!(err, DecodeError::WrongKind { id: order_id }); } -} \ No newline at end of file + + // --- ROUND-TRIP TESTS --- + // + // `encode`/`decode` are generic over `Kind`, so the same `PacketDef` + // works for both directions here — that genericity is the whole + // point of giving `DataOrderPacket` a phantom `Kind` in the first + // place, instead of writing one hardcoded pair of functions per kind. + // + // Enum fields are left out of both: `encode_enum` takes an + // already-selected index (`Value::Number`) but `decode_enum` returns + // the option's text (`Value::Enum`) — see `wire.rs` — so an enum + // field's `Value` is never expected to round-trip unchanged, only its + // underlying byte. + + fn numeric_measurements() -> Vec { + vec![ + sample_measurement( + 1, + MeasurementKind::Numeric { + wire_type: NumericKind::U16, + }, + ), + sample_measurement( + 2, + MeasurementKind::Numeric { + wire_type: NumericKind::I8, + }, + ), + ] + } + + #[test] + fn an_order_packet_round_trips_through_encode_then_decode() { + let id = AdjId(6001); + let index = make_test_index(vec![sample_packet_def( + id, + PacketKind::Order(numeric_measurements()), + )]); + let original = OrderPacket::new(id, vec![Value::Number(1234.0), Value::Number(-5.0)]); + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding this well-formed order should succeed"); + }; + let Ok(decoded) = decode::(&bytes, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.values, original.values); + } + + #[test] + fn a_data_packet_round_trips_through_encode_then_decode() { + let id = AdjId(6002); + let index = make_test_index(vec![sample_packet_def( + id, + PacketKind::Data(numeric_measurements()), + )]); + let original = DataPacket::new(id, vec![Value::Number(42.0), Value::Number(-1.0)]); + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding this well-formed data packet should succeed"); + }; + let Ok(decoded) = decode::(&bytes, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded.id, original.id); + assert_eq!(decoded.values, original.values); + } +} From 1a2127f29a8baf91aa0fc9018618b3f1edd00124 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 21:22:47 +0200 Subject: [PATCH 74/82] feat(backend, protocol): protection --- .../core/src/protocol/packet/protection.rs | 425 +++++++++++++++++- 1 file changed, 411 insertions(+), 14 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index e00a4acec..af6eb53f5 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -1,26 +1,423 @@ //! Decodes a `Protection` packet: a board reporting one of its declared -//! protections triggered. Only ever received, over TCP. +//! protections triggered. Only ever received, over TCP. Also provides +//! `encode`, purely so tests can build well-formed frames without +//! hand-written byte arrays — nothing in the real backend calls it, since +//! a protection never travels backend -> board. +//! Structure +//! ``` +//! | 0-1 | 2 | 3 | 4 | 5 | 6-9 | 10-13 | 14-17 | 18-21 | 22-25 | 26-29 | 30-33 | +//! |-------|-------|-------|-------|-------|-------|-------|-------| 30-33 | 34-37 | 38-41 | 42-45 | +//! | id | hora | min | seg | subseg| resv | resv | value | value | value | value | value | +//! | 34-37 | 38-41 | 42-45 | +//! |-------| 34-37 | 38-41 | 42-45 | +//! | value | value | value | +//! ``` +//! -use crate::model::{AdjId, ProtectionPacket}; -use crate::protocol::{DecodeError, Index}; +use crate::model::{AdjId, PacketKind, ProtectionPacket, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index, wire}; use std::io::Read; /// Decodes a protection frame's remaining bytes, given the frame's /// already-read compound id (`tcp::decode_next`'s job), resolving the /// watched measurement's wire type against `index`. pub fn decode( - reader: impl Read, + mut reader: impl Read, compound_id: AdjId, index: &Index, ) -> Result { - let _ = (reader, compound_id, index); - todo!( - "look up compound_id's PacketDef in `index` (already keyed by \ - this exact compound id, see adj::raw::loader); match its `kind` \ - for PacketKind::Protection(measurement) (WrongKind if it's some \ - other kind) to get the watched measurement's wire type; \ - separately split compound_id's bits into (measurement, position) \ - to fill ProtectionPacket's `measurement`/`protection` fields; \ - read the timestamp, then the value via wire::decode_value" - ) + // Look up the packet definition for this compound id, and ensure it's a + // protection packet. If not, the frame is malformed and we can't decode it. + let packet_def = index + .packet(compound_id) + .ok_or(DecodeError::UnknownId(compound_id))?; + + let PacketKind::Protection(measurement) = &packet_def.kind else { + return Err(DecodeError::WrongKind { id: compound_id }); + }; + + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + // padding that only exist to align `value` to an 8-byte offset — read + // and discarded, there's nowhere to put them in `ProtectionPacket`. + let mut header = [0u8; 6]; + reader.read_exact(&mut header)?; + let timestamp = ShortTimestamp { + hour: header[0], + minute: header[1], + second: header[2], + subsecond: header[3], + }; + + // Read the `value` bytes, whose length is determined by the measurement's wire type, and decode them into a `Value`. + let mut value_bytes = vec![0u8; wire::byte_width(&measurement.kind)]; + reader.read_exact(&mut value_bytes)?; + let value = wire::decode_value(&value_bytes, &measurement.kind)?; + + Ok(ProtectionPacket { + id: compound_id, + timestamp, + value, + }) +} + +/// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects. +/// +/// Nothing in the real backend calls this — per the ADJ spec, protection +/// packets only ever travel board -> backend. This exists purely so +/// tests can build well-formed frames without hand-written byte arrays, +/// and to prove `decode` recovers exactly what was encoded. +/// +/// Like `decode`, doesn't write the leading compound id (`packet.id`) — +/// `decode` doesn't read it either, since that's resolved one level up, +/// by whatever dispatches to this function in the first place. +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketKind::Protection(measurement) = &packet_def.kind else { + return Err(EncodeError::WrongKind { id: packet.id }); + }; + + // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` + let header = [ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + 0, // resv — decode discards these, nothing to round-trip + 0, // resv + ]; + let mut bytes = header.to_vec(); + + bytes.extend(wire::encode_value(&packet.value, &measurement.kind)?); + + Ok(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, NumericKind, PacketDef, + PodData, Value, + }; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + fn sample_measurement(id: u16, kind: MeasurementKind) -> Measurement { + Measurement { + id: AdjId(id), + alias: "mock_alias".to_string(), + name: "Mock Measurement".to_string(), + display_units: String::new(), + kind, + protections: vec![], + } + } + + fn index_with_protection(measurement: Measurement, position: u16) -> (Index, AdjId) { + let compound_id = AdjId((position << 13) | measurement.id.0); + let packet = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_protection".to_string(), + kind: PacketKind::Protection(measurement), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + (Index::new(&pod_data), compound_id) + } + + #[test] + fn decodes_a_numeric_protections_timestamp_and_value() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let mut bytes = vec![12, 30, 5, 0, 0, 0]; // hora, min, seg, subseg, resv(2) + bytes.extend(53.2f32.to_le_bytes()); + let reader = Cursor::new(bytes); + + let Ok(packet) = decode(reader, compound_id, &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + assert_eq!(packet.id.measurement_id(), AdjId(100)); + assert_eq!(packet.id.protection_position(), 1); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.timestamp.minute, 30); + assert_eq!(packet.timestamp.second, 5); + assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); + } + + #[test] + fn unknown_compound_id_is_an_error() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, _) = index_with_protection(measurement, 1); + let unknown_id = AdjId((2 << 13) | 100); + + assert!(matches!( + decode(Cursor::new(vec![]), unknown_id, &index), + Err(DecodeError::UnknownId(id)) if id == unknown_id + )); + } + + #[test] + fn a_non_protection_packet_at_that_id_is_the_wrong_kind() { + let measurement = sample_measurement( + 200, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let compound_id = AdjId(200); // pos = 0: a normal, non-protection id + let packet = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_data".to_string(), + kind: PacketKind::Data(vec![measurement]), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data); + + assert!(matches!( + decode(Cursor::new(vec![]), compound_id, &index), + Err(DecodeError::WrongKind { id }) if id == compound_id + )); + } + + #[test] + fn truncated_frame_is_unexpected_eof() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + // Header alone, no value bytes at all. + let reader = Cursor::new(vec![0u8; 6]); + + assert!(matches!( + decode(reader, compound_id, &index), + Err(DecodeError::Io(_)) + )); + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_then_decode_round_trips_timestamp_and_value() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let original = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 12, + minute: 30, + second: 5, + subsecond: 0, + }, + value: Value::Number(f64::from(53.2f32)), + }; + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding a well-formed protection packet should succeed"); + }; + let Ok(decoded) = decode(Cursor::new(bytes), compound_id, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded, original); + } + + #[test] + fn encode_zeroes_the_reserved_bytes() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 1, + minute: 2, + second: 3, + subsecond: 4, + }, + value: Value::Number(7.0), + }; + + let Ok(bytes) = encode(&packet, &index) else { + unreachable!("encoding a well-formed protection packet should succeed"); + }; + + assert_eq!(bytes[4], 0); + assert_eq!(bytes[5], 0); + } + + #[test] + fn encode_fails_on_unknown_id() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, _) = index_with_protection(measurement, 1); + let unknown_id = AdjId((2 << 13) | 100); + + let packet = ProtectionPacket { + id: unknown_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(0.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(unknown_id)); + } + + #[test] + fn encode_fails_on_wrong_kind() { + let measurement = sample_measurement( + 200, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let compound_id = AdjId(200); // pos = 0: a normal, non-protection id + let packet_def = PacketDef { + id: compound_id, + board: BoardId(1), + name: "mock_data".to_string(), + kind: PacketKind::Data(vec![measurement]), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(0.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding the wrong packet kind should fail"); + }; + assert_eq!(err, EncodeError::WrongKind { id: compound_id }); + } + + #[test] + fn encode_bubbles_up_type_mismatch() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Bool(true), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding a value of the wrong variant should fail"); + }; + assert_eq!(err, EncodeError::TypeMismatch); + } + + #[test] + fn encode_bubbles_up_out_of_range() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + let packet = ProtectionPacket { + id: compound_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + value: Value::Number(300.0), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an out-of-range value should fail"); + }; + assert!(matches!(err, EncodeError::OutOfRange { .. })); + } } From 7e632fbe688eb34b316b5e19c08c6a18c0343283 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 22:51:13 +0200 Subject: [PATCH 75/82] feat(backend, protection): optional id --- .../core/src/protocol/packet/protection.rs | 111 +++++++++++++----- 1 file changed, 80 insertions(+), 31 deletions(-) diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index af6eb53f5..b3d99b2d0 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -3,29 +3,45 @@ //! `encode`, purely so tests can build well-formed frames without //! hand-written byte arrays — nothing in the real backend calls it, since //! a protection never travels backend -> board. -//! Structure -//! ``` -//! | 0-1 | 2 | 3 | 4 | 5 | 6-9 | 10-13 | 14-17 | 18-21 | 22-25 | 26-29 | 30-33 | -//! |-------|-------|-------|-------|-------|-------|-------|-------| 30-33 | 34-37 | 38-41 | 42-45 | -//! | id | hora | min | seg | subseg| resv | resv | value | value | value | value | value | -//! | 34-37 | 38-41 | 42-45 | -//! |-------| 34-37 | 38-41 | 42-45 | -//! | value | value | value | -//! ``` //! +//! Wire structure (whole frame, including the leading id): +//! ```text +//! | byte(s) | 0-1 | 2 | 3 | 4 | 5 | 6-7 | 8.. | +//! |---------|-----|------|-----|-----|--------|------|--------------| +//! | field | id | hora | min | seg | subseg | resv | value | +//! ``` +//! `id` (2 bytes) is normally read by a TCP dispatcher before it even +//! knows to call [`decode`] (it needs the id first, to decide this is a +//! protection and not a message) — when that's the case, pass it as +//! `Some`, and `reader` should start right at byte 2 above. Otherwise +//! pass `None`: `reader` should then start at byte 0, id included, and +//! `decode` reads it itself — all of this frame's byte parsing stays in +//! this file either way, never split out into whatever dispatches here. +//! `resv` (2 bytes) only exists to align `value` to an 8-byte offset; +//! it's read and discarded, never stored. `value`'s width is whatever +//! [`crate::protocol::wire::byte_width`] says for the watched +//! measurement's kind — not fixed, unlike every other field here. use crate::model::{AdjId, PacketKind, ProtectionPacket, ShortTimestamp}; use crate::protocol::{DecodeError, EncodeError, Index, wire}; use std::io::Read; -/// Decodes a protection frame's remaining bytes, given the frame's -/// already-read compound id (`tcp::decode_next`'s job), resolving the -/// watched measurement's wire type against `index`. +/// Decodes a protection frame, resolving the watched measurement's wire +/// type against `index`. +/// +/// `compound_id`: `Some` if the caller already read it (see this +/// module's doc comment); `None` to have `decode` read it itself off +/// `reader`'s first 2 bytes. pub fn decode( mut reader: impl Read, - compound_id: AdjId, + compound_id: Option, index: &Index, ) -> Result { + let compound_id = match compound_id { + Some(id) => id, + None => read_id(&mut reader)?, + }; + // Look up the packet definition for this compound id, and ensure it's a // protection packet. If not, the frame is malformed and we can't decode it. let packet_def = index @@ -60,16 +76,22 @@ pub fn decode( }) } -/// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects. +/// Reads a raw little-endian 16-bit id off the front of `reader`. The +/// one piece of byte parsing [`decode`]/[`encode`] share, so it only +/// lives here once instead of wherever else might need an `AdjId`. +fn read_id(reader: &mut impl Read) -> Result { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes)?; + Ok(AdjId(u16::from_le_bytes(bytes))) +} + +/// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects +/// — a complete, self-contained frame, leading id included. /// /// Nothing in the real backend calls this — per the ADJ spec, protection /// packets only ever travel board -> backend. This exists purely so /// tests can build well-formed frames without hand-written byte arrays, /// and to prove `decode` recovers exactly what was encoded. -/// -/// Like `decode`, doesn't write the leading compound id (`packet.id`) — -/// `decode` doesn't read it either, since that's resolved one level up, -/// by whatever dispatches to this function in the first place. pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError> { let packet_def = index .packet(packet.id) @@ -79,17 +101,17 @@ pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, Encod return Err(EncodeError::WrongKind { id: packet.id }); }; + let mut bytes = packet.id.0.to_le_bytes().to_vec(); // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` - let header = [ + // — decode discards these, nothing to round-trip. + bytes.extend([ packet.timestamp.hour, packet.timestamp.minute, packet.timestamp.second, packet.timestamp.subsecond, - 0, // resv — decode discards these, nothing to round-trip - 0, // resv - ]; - let mut bytes = header.to_vec(); - + 0, + 0, + ]); bytes.extend(wire::encode_value(&packet.value, &measurement.kind)?); Ok(bytes) @@ -152,7 +174,7 @@ mod tests { bytes.extend(53.2f32.to_le_bytes()); let reader = Cursor::new(bytes); - let Ok(packet) = decode(reader, compound_id, &index) else { + let Ok(packet) = decode(reader, Some(compound_id), &index) else { unreachable!("decoding a well-formed protection frame should succeed"); }; @@ -164,6 +186,30 @@ mod tests { assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); } + #[test] + fn decode_reads_the_id_itself_when_not_given_one() { + let measurement = sample_measurement( + 100, + MeasurementKind::Numeric { + wire_type: NumericKind::F32, + }, + ); + let (index, compound_id) = index_with_protection(measurement, 1); + + // Full frame, id included — nobody's pre-read it for us. + let mut bytes = compound_id.0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0, 0, 0]); // hora, min, seg, subseg, resv(2) + bytes.extend(53.2f32.to_le_bytes()); + + let Ok(packet) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + assert_eq!(packet.id, compound_id); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.value, Value::Number(f64::from(53.2f32))); + } + #[test] fn unknown_compound_id_is_an_error() { let measurement = sample_measurement( @@ -176,7 +222,7 @@ mod tests { let unknown_id = AdjId((2 << 13) | 100); assert!(matches!( - decode(Cursor::new(vec![]), unknown_id, &index), + decode(Cursor::new(vec![]), Some(unknown_id), &index), Err(DecodeError::UnknownId(id)) if id == unknown_id )); } @@ -209,7 +255,7 @@ mod tests { let index = Index::new(&pod_data); assert!(matches!( - decode(Cursor::new(vec![]), compound_id, &index), + decode(Cursor::new(vec![]), Some(compound_id), &index), Err(DecodeError::WrongKind { id }) if id == compound_id )); } @@ -228,7 +274,7 @@ mod tests { let reader = Cursor::new(vec![0u8; 6]); assert!(matches!( - decode(reader, compound_id, &index), + decode(reader, Some(compound_id), &index), Err(DecodeError::Io(_)) )); } @@ -259,7 +305,9 @@ mod tests { let Ok(bytes) = encode(&original, &index) else { unreachable!("encoding a well-formed protection packet should succeed"); }; - let Ok(decoded) = decode(Cursor::new(bytes), compound_id, &index) else { + // `encode` writes the id too, so `decode` can read it back itself — + // proves the full frame round-trips, not just the body. + let Ok(decoded) = decode(Cursor::new(bytes), None, &index) else { unreachable!("decoding what we just encoded should succeed"); }; @@ -291,8 +339,9 @@ mod tests { unreachable!("encoding a well-formed protection packet should succeed"); }; - assert_eq!(bytes[4], 0); - assert_eq!(bytes[5], 0); + // bytes[0..2] = id, [2..6] = timestamp, [6..8] = resv. + assert_eq!(bytes[6], 0); + assert_eq!(bytes[7], 0); } #[test] From 57197879777782a5a4b551bb208f8136690cff73 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:04:59 +0200 Subject: [PATCH 76/82] fix(backend, adj-model): read messages id as a proper package --- backend/crates/core/src/adj/README.md | 26 ++++++++++++- backend/crates/core/src/adj/info.rs | 20 +++++++++- backend/crates/core/src/adj/raw/types.rs | 38 ++++++++++++++---- backend/crates/core/src/model/README.md | 7 ++++ backend/crates/core/src/model/packet.rs | 49 ++++++++++++++++++++++++ 5 files changed, 131 insertions(+), 9 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index 33bdedb49..f1bda8b73 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -30,10 +30,34 @@ Adj └── info (AdjInfo) ├── ports: HashMap ├── addresses: HashMap - ├── message_ids: HashMap + ├── message_ids: HashMap -- name -> id, straight from the ADJ + ├── message_levels: HashMap -- id -> severity, the reverse └── protection_types: HashMap ``` +`message_ids` is the ADJ's own direction: a name (e.g. `"fault"`) to its +id. Not every entry is one of `model::MessageLevel`'s four severities — +other reserved names (e.g. `add_state_order`) share this same map. + +`message_levels` is the direction decoding actually needs: a wire id +(all a decoder ever has) straight to its severity, with no name in +between. It's the reverse of `message_ids`, restricted to whichever of +`MessageLevel::ALL`'s four names `message_ids` declares — built once in +`adj::raw::types`'s `TryFrom for AdjInfo`, right after +`message_ids` itself, so it can never drift out of sync with it: + +```rust +let message_levels: HashMap = MessageLevel::ALL + .into_iter() + .filter_map(|level| message_ids.get(level.name()).map(|&id| (id, level))) + .collect(); +``` + +Which ADJ name corresponds to which `MessageLevel` is **not** decided +here — `level.name()` (see `model/README.md` §4.4) is the one place +that association is spelled out, so `adj` doesn't hardcode +`"fault"`/`"warning"`/`"info"`/`"panic"` itself. + --- ## Why It Is Structured This Way diff --git a/backend/crates/core/src/adj/info.rs b/backend/crates/core/src/adj/info.rs index 62c17fa9c..57ddc175e 100644 --- a/backend/crates/core/src/adj/info.rs +++ b/backend/crates/core/src/adj/info.rs @@ -5,7 +5,7 @@ //! addresses, message-id conventions, and how to display a triggered //! protection. -use crate::model::{AdjId, Port}; +use crate::model::{AdjId, MessageLevel, Port}; use std::collections::HashMap; use std::net::IpAddr; @@ -34,7 +34,25 @@ pub struct AdjInfo { /// Known message ids, by name (e.g. `"fault"` -> `AdjId(2)`) — these /// live in the ADJ's own special reserved id range (`[0, 511]`, see /// [`AdjId`]'s own docs), not a separate numbering of their own. + /// This is the ADJ's own direction: a severity's *name* to its id. + /// Not every entry here is one of [`MessageLevel`]'s four severities + /// — other reserved names (e.g. `add_state_order`) share this same + /// map — see [`MessageLevel::name`] for which ones are. pub message_ids: HashMap, + /// The reverse of [`AdjInfo::message_ids`], and only for the four + /// entries that actually are a [`MessageLevel`]: a wire id straight + /// to its severity. + /// + /// Decoding a free-text log frame only ever has the raw id off the + /// wire, not its name — without this, every decoder would have to + /// search `message_ids` by value (or re-derive this same reverse + /// map itself) just to answer "which severity is id 2?". Built once + /// here, when the ADJ is loaded (see `adj::raw::types`'s + /// `TryFrom for AdjInfo`), + /// from `message_ids` and [`MessageLevel::ALL`]/[`MessageLevel::name`] + /// — never constructed by hand, and never out of sync with + /// `message_ids` since it's derived from it. + pub message_levels: HashMap, /// Declared protection types, by name (e.g. `"Range"`). pub protection_types: HashMap, } diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index 7b02ef51c..3942a3bc7 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -8,7 +8,8 @@ use crate::adj::error::{LoadError, MeasurementError, ProtectionError}; use crate::adj::info::{AdjInfo, ProtectionTypeInfo}; use crate::model::{ - AdjId, Measurement, MeasurementKind, NumericKind, Port, Protection, ProtectionKind, Severity, + AdjId, Measurement, MeasurementKind, MessageLevel, NumericKind, Port, Protection, + ProtectionKind, Severity, }; use serde::Deserialize; use std::collections::HashMap; @@ -100,6 +101,21 @@ impl TryFrom for AdjInfo { addresses.insert(name, addr); } + let message_ids: HashMap = raw + .message_ids + .into_iter() + .map(|(name, id)| (name, AdjId(id))) + .collect(); + + // The reverse of `message_ids`, restricted to the four names + // that are actually a `MessageLevel` — see `AdjInfo::message_levels`'s + // own doc comment for why this is worth precomputing here rather + // than leaving every caller to search `message_ids` by value. + let message_levels: HashMap = MessageLevel::ALL + .into_iter() + .filter_map(|level| message_ids.get(level.name()).map(|&id| (id, level))) + .collect(); + Ok(AdjInfo { ports: raw .ports @@ -107,11 +123,8 @@ impl TryFrom for AdjInfo { .map(|(name, port)| (name, Port(port))) .collect(), addresses, - message_ids: raw - .message_ids - .into_iter() - .map(|(name, id)| (name, AdjId(id))) - .collect(), + message_ids, + message_levels, protection_types: raw .protection_types .into_iter() @@ -488,7 +501,12 @@ mod tests { ports: HashMap::from([("TCP_SERVER".to_string(), 50500u16)]), addresses: HashMap::from([("backend".to_string(), "192.168.0.9".to_string())]), protection_types, - message_ids: HashMap::from([("fault".to_string(), 2u16)]), + message_ids: HashMap::from([ + ("fault".to_string(), 2u16), + // Not a `MessageLevel` — shares `message_ids` with the + // four severities, but shouldn't appear in `message_levels`. + ("add_state_order".to_string(), 5u16), + ]), }; let Ok(info) = AdjInfo::try_from(raw) else { @@ -498,7 +516,13 @@ mod tests { assert_eq!(info.ports["TCP_SERVER"], Port(50500)); assert_eq!(info.addresses["backend"], IpAddr::from([192, 168, 0, 9])); assert_eq!(info.message_ids["fault"], AdjId(2)); + assert_eq!(info.message_ids["add_state_order"], AdjId(5)); assert!(info.protection_types["Range"].is_range); + + // `message_levels` is the reverse of `message_ids`, but only for + // the one entry that's actually a `MessageLevel`. + assert_eq!(info.message_levels.len(), 1); + assert_eq!(info.message_levels[&AdjId(2)], MessageLevel::Fault); } #[test] diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index aa088648c..8d1e190d0 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -188,6 +188,13 @@ Informational message, no action implied. #### `Panic` An unrecoverable firmware error. +`MessageLevel` also exposes: +- `ALL: [MessageLevel; 4]` — every severity, for code that needs to go through each one (e.g. building a lookup from a loaded ADJ's `message_ids`). +- `name(self) -> &'static str` — this severity's key in `general_info.json`'s `message_ids` (`"fault"`, `"warning"`, `"info"`, `"panic"`). +- `from_name(name: &str) -> Option` — the inverse of `name()`; `None` if `name` isn't one of the four. + +This is deliberately where the name↔severity association lives — in `model`, not `adj` or `protocol` — since it's domain knowledge about the severity itself. `adj::AdjInfo::message_levels` (see `adj/README.md`) calls into `name()`/`ALL` to build its id→severity lookup rather than hardcoding `"fault"`/`"warning"`/`"info"`/`"panic"` itself. + ### 4.5 `MessagePacket` A free-text message sent by a board. diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 56b9c60e8..d53c83df3 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -175,6 +175,41 @@ pub enum MessageLevel { Panic, } +impl MessageLevel { + /// All four severities, in no particular order — for code that needs + /// to go through each one, e.g. building a lookup from a loaded + /// ADJ's `general_info.json` `message_ids` (see [`MessageLevel::name`]). + pub const ALL: [MessageLevel; 4] = [ + MessageLevel::Fault, + MessageLevel::Warning, + MessageLevel::Info, + MessageLevel::Panic, + ]; + + /// This severity's key in `general_info.json`'s `message_ids` map + /// (ADJv3: `"fault"`, `"warning"`, `"info"`, `"panic"`). That map + /// only gives a severity's *name*, not which `MessageLevel` it is — + /// this is the one place that association is spelled out, so + /// nothing else has to hardcode these four strings again. + pub fn name(self) -> &'static str { + match self { + MessageLevel::Fault => "fault", + MessageLevel::Warning => "warning", + MessageLevel::Info => "info", + MessageLevel::Panic => "panic", + } + } + + /// The inverse of [`MessageLevel::name`]: the severity named `name`, + /// or `None` if `name` isn't one of the four (e.g. `general_info.json` + /// declares other, unrelated reserved names in that same map). + pub fn from_name(name: &str) -> Option { + MessageLevel::ALL + .into_iter() + .find(|level| level.name() == name) + } +} + /// A free-text message sent by a board. #[derive(Debug, Clone, PartialEq)] pub struct MessagePacket { @@ -273,6 +308,20 @@ mod tests { } } + #[test] + fn each_message_levels_name_matches_the_adj_and_is_distinct() { + let names: Vec<&str> = MessageLevel::ALL.iter().map(|level| level.name()).collect(); + assert_eq!(names, vec!["fault", "warning", "info", "panic"]); + } + + #[test] + fn from_name_is_the_inverse_of_name() { + for level in MessageLevel::ALL { + assert_eq!(MessageLevel::from_name(level.name()), Some(level)); + } + assert_eq!(MessageLevel::from_name("not_a_severity"), None); + } + #[test] fn data_packet_values_are_positional_not_tagged_with_an_id() { // values[0] is whatever measurements[0] is in this packet's From e3aa05ef6a9f13972f8565eec636c14ff0e672f0 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:26:07 +0200 Subject: [PATCH 77/82] fix(backend, adj, model): define messages packets in the same way as protections and datapackets --- backend/crates/core/src/adj/README.md | 24 ++++++++++--- backend/crates/core/src/adj/error.rs | 10 ++++++ backend/crates/core/src/adj/raw/loader.rs | 41 +++++++++++++++++++++-- backend/crates/core/src/model/README.md | 8 ++--- backend/crates/core/src/model/board.rs | 10 +++--- backend/crates/core/src/model/id.rs | 7 ++++ backend/crates/core/src/model/packet.rs | 11 ++++-- 7 files changed, 95 insertions(+), 16 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index f1bda8b73..815cbb6e1 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -23,7 +23,10 @@ Adj │ └── boards: HashMap │ ├── measurements: HashMap │ └── packets: HashMap -│ ├── explicit (Data, Order, Message — declared in packets.json) +│ ├── explicit (Data, Order, Message — declared in packets.json; +│ │ a "message"-type packet's own `name` must be one of +│ │ MessageLevel::ALL's four severities, resolved via +│ │ MessageLevel::from_name — UnknownMessageSeverity if not) │ └── synthetic (Protection — one per declared protection, │ derived from each measurement's own `protections`; │ never declared directly in packets.json) @@ -54,9 +57,22 @@ let message_levels: HashMap = MessageLevel::ALL ``` Which ADJ name corresponds to which `MessageLevel` is **not** decided -here — `level.name()` (see `model/README.md` §4.4) is the one place -that association is spelled out, so `adj` doesn't hardcode -`"fault"`/`"warning"`/`"info"`/`"panic"` itself. +here — `level.name()`/`MessageLevel::from_name` (see `model/README.md` +§4.4) is the one place that association is spelled out, so `adj` +doesn't hardcode `"fault"`/`"warning"`/`"info"`/`"panic"` itself. + +**One layer up, `protocol::Index` is what actually turns +`message_levels` into something a decoder can look up a packet by.** +`Index::new(pod_data, message_levels)` takes `message_levels` as +`Option<&HashMap>` — `None` for callers with no +`AdjInfo` at all (most tests in this crate, which only build a bare +`PodData`) — and, for each entry, synthesizes a +`PacketDef { kind: PacketKind::Message(level), board: BoardId(0), .. }` +into the very same `AdjId -> PacketDef` table every other packet lives +in. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own +docs) because a severity isn't owned by any one board — any board's +free-text log frame can reuse the same `"fault"` id. See +`protocol/README.md`'s `index.rs` section for the full picture. --- diff --git a/backend/crates/core/src/adj/error.rs b/backend/crates/core/src/adj/error.rs index 2f6d31764..53fcf1bed 100644 --- a/backend/crates/core/src/adj/error.rs +++ b/backend/crates/core/src/adj/error.rs @@ -85,6 +85,16 @@ pub enum LoadError { type_name: String, }, + /// A `"type": "message"` packet's `name` isn't one of + /// [`crate::model::MessageLevel`]'s four severities. + #[error("board {board:?}, packet {name:?}: not a known message severity")] + UnknownMessageSeverity { + /// The board declaring this message packet. + board: String, + /// The packet's name — the text that failed to match a severity. + name: String, + }, + /// A packet or order references a measurement alias that doesn't exist /// among that board's own measurements. #[error("board {board:?}, packet {name:?}: unknown measurement alias {alias:?}")] diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 6f252d48c..53390d252 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -4,7 +4,7 @@ use super::types::{RawBoard, RawGeneralInfo, RawMeasurement, RawPacket}; use crate::adj::error::LoadError; use crate::adj::{Adj, AdjInfo}; use crate::model::{ - AdjId, Board, BoardId, MacAddress, Measurement, PacketDef, PacketKind, PodData, + AdjId, Board, BoardId, MacAddress, Measurement, MessageLevel, PacketDef, PacketKind, PodData, }; use serde::de::DeserializeOwned; use std::collections::HashMap; @@ -143,7 +143,16 @@ fn build_packet_def( &raw.variables, measurements, )?), - "message" => PacketKind::Message, + // The packet's own `name` must be one of `MessageLevel`'s four + // severities (`"fault"`/`"warning"`/`"info"`/`"panic"`) — that's + // the only way a `"message"`-type packet in `packets.json` says + // which severity it is; nothing else in its JSON shape does. + "message" => PacketKind::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { + LoadError::UnknownMessageSeverity { + board: board.to_string(), + name: raw.name.clone(), + } + })?), other => { return Err(LoadError::UnknownPacketType { board: board.to_string(), @@ -264,6 +273,34 @@ mod tests { )); } + #[test] + fn build_packet_def_resolves_a_message_packets_severity_from_its_name() { + let raw = RawPacket { + id: 2, + kind: "message".to_string(), + name: "fault".to_string(), + variables: vec![], + }; + let Ok(packet) = build_packet_def("VCU", BoardId(3), raw, &HashMap::new()) else { + unreachable!("\"fault\" is a known message severity"); + }; + assert_eq!(packet.kind, PacketKind::Message(MessageLevel::Fault)); + } + + #[test] + fn build_packet_def_rejects_a_message_whose_name_isnt_a_severity() { + let raw = RawPacket { + id: 2, + kind: "message".to_string(), + name: "not_a_severity".to_string(), + variables: vec![], + }; + assert!(matches!( + build_packet_def("VCU", BoardId(3), raw, &HashMap::new()), + Err(LoadError::UnknownMessageSeverity { .. }) + )); + } + #[test] fn loads_the_sample_adj_fixture() { let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/adj/test/adj"); diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index 8d1e190d0..eaa09367e 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -15,8 +15,8 @@ Telemetry sent by a board: one value per measurement, decoded positionally in th The single measurement one of a board's declared protections watches. Always exactly one — never a list. #### `Order(Vec)` An order sent to a board. Many orders take no parameters at all, so this can be empty. -#### `Message` -A free-text log line. Never carries measurement data. +#### `Message(MessageLevel)` +A free-text log line of this severity. Never carries measurement data — the severity is the only thing that tells one `Message` `PacketDef` apart from another. Lives here, on `PacketKind`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what kind of packet is this" as a `Data` packet's measurement list is. `PacketKind` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its kind. @@ -198,8 +198,8 @@ This is deliberately where the name↔severity association lives — in `model`, ### 4.5 `MessagePacket` A free-text message sent by a board. -#### `level` -How severe the message is (`MessageLevel`). +#### `id` +The wire id this message was sent under — one of the severities declared in `general_info.json`'s `message_ids`. Not a `MessageLevel` directly, the same way `ProtectionPacket::id` isn't a `Protection`: resolving it to a severity means looking it up (`PacketKind::Message`/`protocol::Index`), not storing a second, redundant copy of it here. #### `timestamp` When the message was sent (`ShortTimestamp`). #### `origin` diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index 18cef3c3e..b00e3a82c 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -10,7 +10,7 @@ //! left out rather than guessed — add them once the packet-level JSON //! shape is settled. -use super::{AdjId, BoardId, MacAddress, Measurement}; +use super::{AdjId, BoardId, MacAddress, Measurement, MessageLevel}; use std::collections::HashMap; use std::net::IpAddr; @@ -29,8 +29,10 @@ pub enum PacketKind { Protection(Measurement), /// A command sent to a board. Many orders take no parameters at all. Order(Vec), - /// A free-text log line. Never carries measurement data. - Message, + /// A free-text log line of this severity. Never carries measurement + /// data — the severity is the only thing that tells one `Message` + /// `PacketDef` apart from another. + Message(MessageLevel), } impl PacketKind { @@ -41,7 +43,7 @@ impl PacketKind { match self { PacketKind::Data(measurements) | PacketKind::Order(measurements) => measurements, PacketKind::Protection(measurement) => std::slice::from_ref(measurement), - PacketKind::Message => &[], + PacketKind::Message(_) => &[], } } } diff --git a/backend/crates/core/src/model/id.rs b/backend/crates/core/src/model/id.rs index a8278993d..948e71b98 100644 --- a/backend/crates/core/src/model/id.rs +++ b/backend/crates/core/src/model/id.rs @@ -31,6 +31,13 @@ impl AdjId { } /// Unique identifier of a board, defined by the ADJ. +/// +/// `BoardId(0)` is reserved: no real board uses it. [`crate::protocol::Index`] +/// uses it as the `board` for a synthesized [`super::PacketDef`] that +/// isn't owned by any one board at all — a message severity, a +/// session-wide id any board's free-text log frame can reuse. Not +/// validated anywhere; just a convention, the same way `0` is excluded +/// from [`AdjId`]'s own special range above. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct BoardId(pub u16); diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index d53c83df3..948d77834 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -213,8 +213,15 @@ impl MessageLevel { /// A free-text message sent by a board. #[derive(Debug, Clone, PartialEq)] pub struct MessagePacket { - /// How severe the message is. - pub level: MessageLevel, + /// The wire id this message was sent under — one of the severities + /// declared in `general_info.json`'s `message_ids`. + /// + /// Not a [`MessageLevel`] directly, the same way [`ProtectionPacket::id`] + /// isn't a [`super::Protection`]: this is what was actually on the + /// wire, and resolving it to a severity means looking it up (see + /// [`PacketKind::Message`]/[`crate::protocol::Index`]), not storing + /// a second, redundant copy of it here. + pub id: AdjId, /// When it was sent. pub timestamp: ShortTimestamp, /// Where it came from, e.g. a source file path. From cfa789cb909f615be79eb2edbdbfb12923c1d031 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:56:12 +0200 Subject: [PATCH 78/82] feat(backend, protocol): allow index to index packets --- backend/crates/core/src/protocol/index.rs | 64 ++++++++++++++++--- .../core/src/protocol/packet/data_order.rs | 2 +- .../core/src/protocol/packet/protection.rs | 6 +- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs index c23397bf8..117802a3a 100644 --- a/backend/crates/core/src/protocol/index.rs +++ b/backend/crates/core/src/protocol/index.rs @@ -1,10 +1,11 @@ //! The `id -> PacketDef` lookup `tcp`/`udp` decode against. -use crate::model::{AdjId, PacketDef, PodData}; +use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketKind, PodData}; use std::collections::HashMap; /// A lookup table from a packet's [`AdjId`] to its declaration, built once -/// from a loaded ADJ's [`PodData`]. +/// from a loaded ADJ's [`PodData`] — and, optionally, its message +/// severities too. /// /// Owns its own copy of every [`PacketDef`] (cloned out of `PodData` at /// construction) rather than borrowing, so it carries no lifetime and can @@ -19,18 +20,47 @@ pub struct Index { impl Index { /// Builds an index over every packet declared by every board in - /// `pod_data`. - pub fn new(pod_data: &PodData) -> Self { - let by_id = pod_data + /// `pod_data`, plus one synthesized [`PacketDef`] per entry in + /// `message_levels` (an ADJ's `general_info.json`-derived + /// [`crate::adj::AdjInfo::message_levels`]). + /// + /// `message_levels` is `Option` rather than a plain reference so + /// that callers with no [`crate::adj::AdjInfo`] at hand — most tests + /// in this crate, which only ever build a bare [`PodData`] — can + /// just pass `None` instead of having to construct an empty map. + /// + /// A message severity isn't declared by any one board (any board's + /// free-text log frame can reuse the same `"fault"` id), so its + /// synthesized `PacketDef` gets the reserved `BoardId(0)` — no real + /// board uses that id — instead of a real one. + pub fn new(pod_data: &PodData, message_levels: Option<&HashMap>) -> Self { + let mut by_id: HashMap = pod_data .boards .values() .flat_map(|board| board.packets.values()) .map(|packet| (packet.id, packet.clone())) .collect(); + + if let Some(message_levels) = message_levels { + for (&id, &level) in message_levels { + by_id.insert( + id, + PacketDef { + id, + board: BoardId(0), + name: level.name().to_string(), + kind: PacketKind::Message(level), + }, + ); + } + } + Self { by_id } } - /// Looks up a packet's declaration by id. + /// Looks up a packet's declaration by id — a message severity + /// resolves through here too, as a synthesized `PacketDef` with + /// `kind: PacketKind::Message(level)`; see this type's doc comment. pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { self.by_id.get(&id) } @@ -63,7 +93,7 @@ mod tests { #[test] fn finds_a_packet_declared_on_any_board() { - let index = Index::new(&sample_pod_data()); + let index = Index::new(&sample_pod_data(), None); let Some(packet) = index.packet(AdjId(1000)) else { unreachable!("packet 1000 was declared in the sample pod data"); }; @@ -72,7 +102,25 @@ mod tests { #[test] fn unknown_id_is_not_found() { - let index = Index::new(&sample_pod_data()); + let index = Index::new(&sample_pod_data(), None); assert!(index.packet(AdjId(9999)).is_none()); } + + #[test] + fn a_message_severity_resolves_as_a_synthesized_packet_def() { + let message_levels = HashMap::from([(AdjId(2), MessageLevel::Fault)]); + let index = Index::new(&sample_pod_data(), Some(&message_levels)); + + let Some(packet) = index.packet(AdjId(2)) else { + unreachable!("the \"fault\" severity was declared at id 2"); + }; + assert_eq!(packet.kind, PacketKind::Message(MessageLevel::Fault)); + assert_eq!(packet.board, BoardId(0)); + } + + #[test] + fn with_no_message_levels_given_only_board_packets_resolve() { + let index = Index::new(&sample_pod_data(), None); + assert_eq!(index.packet(AdjId(2)), None); + } } diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index d4643a050..c01971d35 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -156,7 +156,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - Index::new(&pod_data) + Index::new(&pod_data, None) } // --- ENCODE TESTS --- diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index b3d99b2d0..29d171356 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -157,7 +157,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - (Index::new(&pod_data), compound_id) + (Index::new(&pod_data, None), compound_id) } #[test] @@ -252,7 +252,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - let index = Index::new(&pod_data); + let index = Index::new(&pod_data, None); assert!(matches!( decode(Cursor::new(vec![]), Some(compound_id), &index), @@ -397,7 +397,7 @@ mod tests { let pod_data = PodData { boards: HashMap::from([(board.id, board)]), }; - let index = Index::new(&pod_data); + let index = Index::new(&pod_data, None); let packet = ProtectionPacket { id: compound_id, From cc65fbef2ab669ee88c3a69b346464e7d6bbfa63 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Mon, 12 Oct 2026 23:59:03 +0200 Subject: [PATCH 79/82] feat(backend, protocol): messages decode & encode --- backend/crates/core/src/protocol/README.md | 102 +++- backend/crates/core/src/protocol/error.rs | 18 + backend/crates/core/src/protocol/packet.rs | 19 +- .../crates/core/src/protocol/packet/README.md | 90 ++++ .../core/src/protocol/packet/message.rs | 459 +++++++++++++++++- 5 files changed, 648 insertions(+), 40 deletions(-) create mode 100644 backend/crates/core/src/protocol/packet/README.md diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md index 1d81566f4..a62b38c26 100644 --- a/backend/crates/core/src/protocol/README.md +++ b/backend/crates/core/src/protocol/README.md @@ -30,8 +30,8 @@ protocol/ ├── wire.rs # Low-level byte ↔ value conversion ├── packet.rs # Packet-level encoding/decoding module │ ├── data_order.rs # Data (telemetry) & Order (command) packets -│ ├── message.rs # Free-text log message packets (stub) -│ └── protection.rs # Protection trigger packets (stub) +│ ├── message.rs # Free-text log message packets +│ └── protection.rs # Protection trigger packets ├── tcp.rs # TCP transport framing (stub) └── udp.rs # UDP transport framing ``` @@ -42,8 +42,14 @@ protocol/ Provides a fast lookup table from packet IDs (`AdjId`) to their declarations (`PacketDef`). Built once from loaded ADJ configuration data (`PodData`), it owns cloned copies of packet definitions so it can be shared across async tasks via `Arc`. +A message severity is not a separate lookup bolted onto `Index` — it's just another `PacketDef`. `Index::new(pod_data, message_levels)` takes `message_levels` as `Option<&HashMap>` (an ADJ's `adj::AdjInfo::message_levels` — `None` for callers with no `AdjInfo` at all, e.g. most tests here) and, for each entry, synthesizes a `PacketDef { kind: PacketKind::Message(level), board: BoardId(0), .. }` into the very same `AdjId -> PacketDef` table as every board's own packets. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own docs) because a severity isn't owned by any one board. Resolving a message is then `Index::packet(id)` + matching `PacketKind::Message(level)`, exactly like `packet::protection` already does for `PacketKind::Protection`. + **Key types:** -- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` +- `Index` - HashMap-based lookup from `AdjId` to `PacketDef` (synthesized `Message` entries included) + +**Key functions:** +- `Index::new(pod_data, message_levels)` - Build an index from a loaded ADJ's `PodData` and, optionally, its `AdjInfo::message_levels` +- `Index::packet(id)` - Look up a packet's declaration by id — `Data`, `Order`, `Protection` or `Message` alike ### `error.rs` - Error Types @@ -56,6 +62,7 @@ Defines errors that can occur during packet encoding and decoding. - `Io` - I/O error reading from stream (TCP only) - `InvalidEnumValue` - Enum index out of range - `UnterminatedString` - String field not null-terminated + - `InvalidUtf8` - String field's bytes weren't valid UTF-8 - `WrongKind` - Packet exists but isn't expected kind - `EncodeError` - Errors when converting packets to bytes @@ -64,6 +71,8 @@ Defines errors that can occur during packet encoding and decoding. - `TypeMismatch` - Value doesn't match declared type - `OutOfRange` - Numeric value doesn't fit wire type - `WrongKind` - Packet exists but isn't expected kind + - `StringTooLong` - String field + terminator doesn't fit the max length + - `EmbeddedNulByte` - String field contains a `\0` byte ### `wire.rs` - Low-Level Byte Codec @@ -77,29 +86,73 @@ Handles conversion between raw bytes and `Value` types for individual measuremen **Supported types:** - **Numeric** (U8, U16, U32, U64, I8, I16, I32, I64, F32, F64) - ✓ Implemented - **Enum** (single byte index) - ✓ Implemented -- **Boolean** - ⚠ Stub (wire width not yet confirmed with firmware) +- **Boolean** (single byte, zero = `false`, nonzero = `true`) - ✓ Implemented ### `packet/` - Packet-Level Encoding/Decoding -Contains per-packet-kind encoding and decoding logic, independent of transport. +Contains per-packet-kind encoding and decoding logic, independent of +transport. **Hard rule for this directory: all byte-level parsing for a +packet kind lives in its own file here, never in `tcp.rs`/`udp.rs`.** +See **[`packet/README.md`](packet/README.md)** for the full design +rationale (the `Option` pattern below, and exactly what `tcp.rs` +is and isn't allowed to do). #### `data_order.rs` -Handles two packet kinds: -- **Order packets** (commands sent to boards) - Encoded only, sent over TCP -- **Data packets** (periodic telemetry from boards) - Decoded only, received over UDP +Handles the positional Data/Order wire shape shared by both packet kinds +(see `model::DataOrderPacket`'s phantom `Kind`). `encode`/`decode` are +generic over `Kind`, written once for both: +- **Order packets** (commands sent to boards) - sent over TCP +- **Data packets** (periodic telemetry from boards) - received over UDP + +Self-contained both ways: they always read/write the leading 2-byte id +themselves (there's no dispatch ambiguity to resolve first here). The +backend itself only ever calls `encode` with an Order packet (via +`tcp::encode_order`) and `decode::` (via `udp::decode_datagram`) - +that direction restriction lives at the transport layer, not here. **Key functions:** -- `encode(order, index)` - Encode an Order packet into bytes -- `decode(bytes, index)` - Decode a Data packet from bytes +- `encode(packet, index)` - Encode a Data or Order packet into bytes (`Kind` inferred from `packet`'s type) +- `decode::(bytes, index)` - Decode bytes into a Data or Order packet + +#### `message.rs` + +Decodes free-text log messages from boards over TCP (decode only — in +production this only ever travels board → backend). `encode` also +exists, but purely for test symmetry; nothing in the real backend calls +it. + +Unlike `data_order.rs`, a TCP dispatcher needs this frame's leading id +*before* it even knows to call this file (to tell a message apart from +a protection) — so, same as `protection.rs`, `id` is `Option`: +`Some` if the caller already read it (and `reader` starts right after +the id), `None` to have `decode` read it itself. Either way, `id` is +resolved against `index` to confirm it's actually a declared +`PacketKind::Message` (`UnknownId`/`WrongKind` otherwise) — but unlike +`protection.rs`, the severity itself is never stored on the decoded +`MessagePacket`; it's metadata about the id, not about this particular +instance, so look it up again via `index.packet(id)` if it's needed. -#### `message.rs` ⚠ Stub +**Key functions:** +- `decode(reader, id: Option, index)` - Decode a message frame, resolving its id itself if not given one +- `encode(packet, index)` - Test-only: encode a complete `MessagePacket` frame, id included + +#### `protection.rs` -Decodes free-text log messages from boards over TCP. Not yet implemented. +Decodes protection trigger events from boards over TCP (decode only — +in production this only ever travels board → backend). `encode` also +exists, but purely for test symmetry; nothing in the real backend calls +it. -#### `protection.rs` ⚠ Stub +Same `Option` pattern as `message.rs`, for the same reason: a TCP +dispatcher needs the leading id first to tell a protection apart from a +message. `compound_id: Option` - `Some` if the caller already +read it, `None` to have `decode` read it itself off `reader`'s first 2 +bytes. -Decodes protection trigger events from boards over TCP. Not yet implemented. +**Key functions:** +- `decode(reader, compound_id: Option, index)` - Decode a protection frame, resolving its id itself if not given one +- `encode(packet, index)` - Test-only: encode a complete `ProtectionPacket` frame, id included ### `tcp.rs` - TCP Transport Framing ⚠ Partial @@ -128,8 +181,8 @@ Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete |-------------|-----------|-----------|--------| | Data (telemetry) | Board → Backend | UDP | ✓ Implemented | | Order (command) | Backend → Board | TCP | ✓ Implemented | -| Message (log) | Board → Backend | TCP | ⚠ Stub | -| Protection (event) | Board → Backend | TCP | ⚠ Stub | +| Message (log) | Board → Backend | TCP | ✓ Implemented (decode is the only real direction; `encode` is test-only) | +| Protection (event) | Board → Backend | TCP | ✓ Implemented (decode is the only real direction; `encode` is test-only) | ## Implementation Status @@ -137,19 +190,20 @@ Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete - ✅ **Order packets** - Full encode/decode with tests - ✅ **Wire-level numeric types** - All numeric kinds supported with tests - ✅ **Wire-level enum types** - Full encode/decode with tests -- ⚠️ **Wire-level boolean types** - Stub (awaiting firmware confirmation) -- ⚠️ **Message packets** - Stub (decode not implemented) -- ⚠️ **Protection packets** - Stub (decode not implemented) +- ✅ **Wire-level boolean types** - Single byte, confirmed with firmware +- ✅ **Message packets** - decode implemented, with tests (encode exists for test symmetry only) +- ✅ **Protection packets** - decode implemented, with tests (encode exists for test symmetry only) - ⚠️ **TCP framing** - Partial (decode_next not implemented) ## Testing The module includes comprehensive unit tests for: -- Wire-level encoding/decoding for all numeric types -- Enum encoding/decoding with boundary checks -- Order packet encoding with field validation -- Data packet decoding with error handling -- Error propagation for unknown IDs, wrong kinds, and type mismatches +- Wire-level encoding/decoding for all numeric, boolean and enum types +- Order/Data packet encoding and decoding, including round trips +- Message/Protection packet decoding (timestamps, strings, values) and + their test-only encoding, including round trips +- Error propagation for unknown IDs, wrong kinds, type mismatches, + unterminated/invalid strings, and out-of-range values Run tests with: ```bash diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs index 1ffd3b638..b08a065a6 100644 --- a/backend/crates/core/src/protocol/error.rs +++ b/backend/crates/core/src/protocol/error.rs @@ -36,6 +36,10 @@ pub enum DecodeError { max: usize, }, + /// A free-text field's bytes weren't valid UTF-8. + #[error("string field wasn't valid UTF-8: {0:?}")] + InvalidUtf8(Vec), + /// A looked-up packet exists, but isn't the kind this function expects /// (e.g. `packet::data::decode` found an id that resolves to an /// `Order`). @@ -65,6 +69,7 @@ impl PartialEq for DecodeError { }, ) => a_index == b_index && a_options == b_options, (Self::UnterminatedString { max: a }, Self::UnterminatedString { max: b }) => a == b, + (Self::InvalidUtf8(a), Self::InvalidUtf8(b)) => a == b, (Self::WrongKind { id: a }, Self::WrongKind { id: b }) => a == b, _ => false, } @@ -114,4 +119,17 @@ pub enum EncodeError { /// The packet whose kind didn't match. id: AdjId, }, + + /// A string field's content plus its terminator wouldn't fit in the + /// wire format's maximum length. + #[error("string content plus its terminator doesn't fit in {max} byte(s)")] + StringTooLong { + /// The field's maximum length, terminator included. + max: usize, + }, + + /// A string field contains an embedded NUL byte, which would be + /// misread as an early terminator on the wire. + #[error("string contains an embedded NUL byte")] + EmbeddedNulByte, } diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 7f357df8f..11b2847a8 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -1,10 +1,19 @@ //! Encode/decode for one specific kind of packet, independent of which //! transport carries it — that mapping lives in [`super::tcp`]/ -//! [`super::udp`], which are thin wrappers around these. Each kind only -//! implements whichever direction it actually needs: a `Data` packet is -//! only ever received (over UDP), an `Order` only ever sent (over TCP), -//! so there's no `encode` for the former or `decode` for the latter. +//! [`super::udp`], which are thin wrappers around these. +//! +//! [`data_order`]'s `encode`/`decode` are generic over `Kind` +//! ([`crate::model::KindTag`]), so they work for both `Data` and `Order` +//! alike; the backend itself only ever exercises one direction per kind +//! (an order is only ever sent, data only ever received), but that's a +//! restriction `tcp`/`udp` apply, not something baked into the codec. +//! +//! [`message`]/[`protection`] are genuinely one-directional in +//! production — a board only ever sends these, never receives them — +//! but both also expose an `encode`, purely so tests can build +//! well-formed frames without hand-written byte arrays; see each +//! module's own doc comment. +pub mod data_order; pub mod message; pub mod protection; -pub mod data_order; diff --git a/backend/crates/core/src/protocol/packet/README.md b/backend/crates/core/src/protocol/packet/README.md new file mode 100644 index 000000000..fce1586c9 --- /dev/null +++ b/backend/crates/core/src/protocol/packet/README.md @@ -0,0 +1,90 @@ +# `packet/` — Per-Packet-Kind Codecs + +Each file here owns **all** the byte-level parsing for one packet kind — +how many bytes a field takes, in what order, what terminates a string, +what the leading id means. That parsing never gets split out into +`tcp.rs`/`udp.rs` (the transport layer) or anywhere else. A transport +module is only ever allowed to do the one thing it structurally has no +choice about — see "The dispatcher's one unavoidable read" below — and +then hands the rest straight to one of these files. + +## `data_order.rs` + +Self-contained both ways: `encode`/`decode` always read/write the +leading 2-byte id themselves, since there's no dispatch ambiguity to +resolve first — a UDP datagram is unambiguously `Data`, and an order is +only ever something the backend already decided to send. Generic over +`Kind` (`model::KindTag`), written once for both `Data` and `Order` — +see its own module doc and `model/README.md`'s §4.2 for why. + +## `message.rs` / `protection.rs` + +These two are different: over TCP, a `Protection` frame and a `Message` +frame share the same leading 16-bit id space, so **something** has to +read that id before it even knows which of the two decoders to call. +That something is `tcp::decode_next` (still unimplemented). To keep that +reality from leaking actual decoding logic into `tcp.rs`, both files +take their id as `Option` — the exact same shape: + +```rust +// protection.rs +pub fn decode(reader: impl Read, compound_id: Option, index: &Index) -> Result; +pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, EncodeError>; + +// message.rs +pub fn decode(reader: impl Read, id: Option, index: &Index) -> Result; +pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeError>; +``` + +- **`Some(known_value)`** — the caller already read it, typically + because it had to read the leading id anyway to decide which of these + two functions to call in the first place. In this case `reader` must + already be positioned *right after* the id (byte 2 onward). +- **`None`** — the caller doesn't know it and isn't going to try. In + this case `reader` must start at byte 0 of the frame, id included, and + `decode` reads those 2 bytes itself. Either way, the id is then + resolved against `index` — `index.packet(id)` + matching + `PacketKind::Protection`/`PacketKind::Message` — to confirm it's + actually the declared kind (`UnknownId`/`WrongKind` otherwise). A + message's severity is just another `PacketDef` by the time it gets + here (see `index.rs`'s own docs) — there's no separate lookup for it. + +One real difference between the two: `ProtectionPacket` stores its +`id` *and* the triggering `value` (which needs the watched measurement's +wire type, from `PacketKind::Protection(measurement)`, to decode at +all). `MessagePacket` only stores `id` — its severity is metadata +*about* that id, not part of the frame's own byte layout, so `decode` +only uses `PacketKind::Message(level)` to confirm the kind and discards +`level` immediately; nothing about `timestamp`/`origin`/`message`'s +bytes depends on which severity it is. Look the severity up again via +`index.packet(id)` if it's needed later — it's never stored redundantly +on the decoded instance. + +Either way, **every byte of this frame is parsed inside the file that +owns that packet kind** — `tcp.rs` never touches a timestamp, a `resv` +byte, a measurement's wire width, or a NUL-terminated string. `encode` +always writes the leading id back out too (from `packet.id`, after +confirming via `index` it resolves to the right kind), so its output is +a complete, self-contained frame — feed it straight into +`decode(reader, None, index)` and get the same packet back; that's +exactly what each file's round-trip test does. + +### The dispatcher's one unavoidable read + +`tcp::decode_next` still has to read the raw 2-byte id itself once, to +decide *which* of `protection::decode`/`message::decode` to call — there +is no way around this for a non-seekable stream: once those bytes are +consumed, there's no handing them to the other function's `None` path +instead. That single mechanical "read a `u16`" is the one piece of +byte-handling that's expected to exist in `tcp.rs`. It is not decoding — +nothing is validated or interpreted by it beyond "is the top bit of +`AdjId::protection_position()` set" — and its result is immediately +handed to the matching function as `Some(id)`, which then resolves it +against `index` itself and does everything else. + +## Tests + +Every file's test module proves both `Some` and `None` work, plus a +round trip through its own `encode`/`decode` (`protection.rs`, +`message.rs`) or through `encode::`/`decode::` +(`data_order.rs`). See each file's own tests for the exact byte layouts. diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs index 88f70bd9c..eb9f1986b 100644 --- a/backend/crates/core/src/protocol/packet/message.rs +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -1,16 +1,453 @@ //! Decodes a `Message` packet: a board's free-text log line. Only ever -//! received, over TCP. +//! received, over TCP. Also provides `encode`, purely so tests can build +//! well-formed frames without hand-written byte arrays — nothing in the +//! real backend calls it, since a message never travels backend -> board. +//! +//! Wire structure (whole frame, including the leading id): +//! ```text +//! | byte(s) | 0-1 | 2 | 3 | 4 | 5 | 6.. | .. | +//! |---------|-----|------|-----|-----|--------|-----------|-----------| +//! | field | id | hora | min | seg | subseg | origin\0 | message\0 | +//! ``` +//! `id` (2 bytes) is normally read by a TCP dispatcher before it even +//! knows to call [`decode`] (it needs the id first, to decide this is a +//! message and not a protection) — when that's the case, pass it as +//! `Some`, and `reader` should start right at byte 2 above. Otherwise +//! pass `None`: `reader` should then start at byte 0, id included, and +//! `decode` reads it itself — all of this frame's byte parsing stays in +//! this file either way, never split out into whatever dispatches here. +//! Unlike [`super::protection`], there's no `resv` padding — the +//! timestamp is followed directly by `origin`, a NUL-terminated string +//! of at most `ORIGIN_MAX` bytes (terminator included), then `message`, +//! at most `MESSAGE_MAX` bytes the same way. Both are variable-length — +//! there's no fixed byte range for either. +//! +//! Unlike [`super::protection`] too, [`MessagePacket`] doesn't carry its +//! own severity — `id` is resolved against `index` to confirm it's +//! actually a declared [`crate::model::PacketKind::Message`] (so a +//! `WrongKind`/`UnknownId` frame is rejected), but the severity itself +//! is never stored redundantly on the decoded instance; look it up via +//! `index.packet(id)` again if it's needed. -use crate::model::{MessageLevel, MessagePacket}; -use crate::protocol::DecodeError; +use crate::model::{AdjId, MessagePacket, PacketKind, ShortTimestamp}; +use crate::protocol::{DecodeError, EncodeError, Index}; use std::io::Read; -/// Decodes a message frame's remaining bytes, given the frame's -/// already-resolved severity level (`tcp::decode_next`'s job). -pub fn decode(reader: impl Read, level: MessageLevel) -> Result { - let _ = (reader, level); - todo!( - "read the timestamp, then the null-terminated `origin` (max 48 \ - bytes) and `message` (max 320 bytes) strings" - ) +// `origin`/`message` max lengths, content + NUL terminator included +// (docu_backend/Comunicaciones_Reunion01.qmd, lines 207-237). +const ORIGIN_MAX: usize = 48; +const MESSAGE_MAX: usize = 320; + +/// Decodes a message frame, resolving its id against `index` to confirm +/// it's actually a declared message. +/// +/// `id`: `Some` if the caller already read it (see this module's doc +/// comment); `None` to have `decode` read it itself off `reader`'s first +/// 2 bytes. +pub fn decode( + mut reader: impl Read, + id: Option, + index: &Index, +) -> Result { + let id = match id { + Some(id) => id, + None => read_id(&mut reader)?, + }; + + // Look up the packet definition for this id, and ensure it's a + // message packet. If not, the frame is malformed and we can't decode it. + let packet_def = index.packet(id).ok_or(DecodeError::UnknownId(id))?; + + let PacketKind::Message(_) = &packet_def.kind else { + return Err(DecodeError::WrongKind { id }); + }; + + let mut header = [0u8; 4]; + reader.read_exact(&mut header)?; + let timestamp = ShortTimestamp { + hour: header[0], + minute: header[1], + second: header[2], + subsecond: header[3], + }; + + let origin = read_null_terminated_string(&mut reader, ORIGIN_MAX)?; + let message = read_null_terminated_string(&mut reader, MESSAGE_MAX)?; + + Ok(MessagePacket { + id, + timestamp, + origin, + message, + }) +} + +/// Reads a raw little-endian 16-bit id off the front of `reader`. The +/// one piece of byte parsing [`decode`]/[`encode`] share, so it only +/// lives here once instead of wherever else might need an `AdjId`. +fn read_id(reader: &mut impl Read) -> Result { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes)?; + Ok(AdjId(u16::from_le_bytes(bytes))) +} + +/// Reads bytes up to the first `0x00` (exclusive) or `max` bytes total, +/// whichever comes first — [`DecodeError::UnterminatedString`] if no +/// terminator shows up within `max` bytes. +fn read_null_terminated_string(reader: &mut impl Read, max: usize) -> Result { + // Byte by byte via `read_exact`, not `.bytes()` — that iterator warns + // under clippy for being unbuffered, and `reader` here can be any + // `impl Read`, not necessarily a buffered one. + let mut bytes = Vec::new(); + for _ in 0..max { + let mut byte = [0u8; 1]; + reader.read_exact(&mut byte)?; + if byte[0] == 0 { + return String::from_utf8(bytes).map_err(|e| DecodeError::InvalidUtf8(e.into_bytes())); + } + bytes.push(byte[0]); + } + Err(DecodeError::UnterminatedString { max }) +} + +/// Encodes a [`MessagePacket`] back into the bytes [`decode`] expects — +/// a complete, self-contained frame, leading id included. +/// +/// Nothing in the real backend calls this — per the ADJ spec, message +/// packets only ever travel board -> backend. This exists purely so +/// tests can build well-formed frames without hand-written byte arrays, +/// and to prove `decode` recovers exactly what was encoded. +pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeError> { + let packet_def = index + .packet(packet.id) + .ok_or(EncodeError::UnknownId(packet.id))?; + + let PacketKind::Message(_) = &packet_def.kind else { + return Err(EncodeError::WrongKind { id: packet.id }); + }; + + let mut bytes = packet.id.0.to_le_bytes().to_vec(); + bytes.extend([ + packet.timestamp.hour, + packet.timestamp.minute, + packet.timestamp.second, + packet.timestamp.subsecond, + ]); + + write_null_terminated_string(&mut bytes, &packet.origin, ORIGIN_MAX)?; + write_null_terminated_string(&mut bytes, &packet.message, MESSAGE_MAX)?; + + Ok(bytes) +} + +/// Appends `value` to `bytes` as a NUL-terminated string, erroring if it +/// can't be represented that way: an embedded `\0` would be misread as +/// an early terminator, and the content plus its terminator must still +/// fit within `max` bytes. +fn write_null_terminated_string( + bytes: &mut Vec, + value: &str, + max: usize, +) -> Result<(), EncodeError> { + if value.contains('\0') { + return Err(EncodeError::EmbeddedNulByte); + } + if value.len() >= max { + return Err(EncodeError::StringTooLong { max }); + } + + bytes.extend_from_slice(value.as_bytes()); + bytes.push(0); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{Board, BoardId, MacAddress, MessageLevel, PacketDef, PodData}; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + /// Builds an `Index` declaring one message severity: `id` as + /// `MessageLevel::Fault`, exactly as `protocol::Index::new` would + /// synthesize it from a loaded ADJ's `message_levels`. + fn index_with_message(id: AdjId, level: MessageLevel) -> Index { + let message_levels = HashMap::from([(id, level)]); + Index::new(&PodData::default(), Some(&message_levels)) + } + + #[test] + fn decodes_timestamp_and_both_strings() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let mut bytes = vec![12, 30, 5, 0]; // hour, minute, second, subsecond + bytes.extend(b"main.c"); + bytes.push(0); + bytes.extend(b"something went wrong"); + bytes.push(0); + + let Ok(packet) = decode(Cursor::new(bytes), Some(AdjId(11)), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.id, AdjId(11)); + assert_eq!(packet.timestamp.hour, 12); + assert_eq!(packet.timestamp.minute, 30); + assert_eq!(packet.timestamp.second, 5); + assert_eq!(packet.origin, "main.c"); + assert_eq!(packet.message, "something went wrong"); + } + + #[test] + fn decode_treats_a_leading_terminator_as_an_empty_string() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(packet) = decode(Cursor::new(bytes), Some(AdjId(12)), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.origin, ""); + assert_eq!(packet.message, ""); + } + + #[test] + fn decode_fails_when_origin_has_no_terminator_within_max() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let mut bytes = vec![0, 0, 0, 0]; + bytes.extend(vec![b'a'; ORIGIN_MAX]); // no terminator anywhere + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(10)), &index) else { + unreachable!("decoding an unterminated origin should fail"); + }; + assert_eq!(err, DecodeError::UnterminatedString { max: ORIGIN_MAX }); + } + + #[test] + fn decode_fails_when_message_has_no_terminator_within_max() { + let index = index_with_message(AdjId(13), MessageLevel::Panic); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0); // origin: empty, terminated + bytes.extend(vec![b'a'; MESSAGE_MAX]); // message: no terminator + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(13)), &index) else { + unreachable!("decoding an unterminated message should fail"); + }; + assert_eq!(err, DecodeError::UnterminatedString { max: MESSAGE_MAX }); + } + + #[test] + fn decode_fails_on_invalid_utf8() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let mut bytes = vec![0, 0, 0, 0]; + bytes.push(0x80); // lone continuation byte: not valid UTF-8 on its own + bytes.push(0); + + let Err(err) = decode(Cursor::new(bytes), Some(AdjId(12)), &index) else { + unreachable!("decoding invalid UTF-8 should fail"); + }; + assert_eq!(err, DecodeError::InvalidUtf8(vec![0x80])); + } + + #[test] + fn decode_fails_on_truncated_header() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let Err(err) = decode(Cursor::new(vec![0, 0]), Some(AdjId(12)), &index) else { + unreachable!("decoding a truncated header should fail"); + }; + assert!(matches!(err, DecodeError::Io(_))); + } + + #[test] + fn decode_reads_the_id_itself_when_not_given_one() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let mut bytes = AdjId(11).0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0]); // hour, minute, second, subsecond + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(packet) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + assert_eq!(packet.id, AdjId(11)); + } + + #[test] + fn decode_fails_on_an_unknown_id() { + let index = index_with_message(AdjId(11), MessageLevel::Warning); + let bytes = 9999u16.to_le_bytes().to_vec(); + + let Err(err) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding an unknown message id should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn decode_fails_when_the_id_resolves_to_a_non_message_packet() { + let data_id = AdjId(2000); + let packet = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + kind: PacketKind::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet.id, packet)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let bytes = data_id.0.to_le_bytes().to_vec(); + let Err(err) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding an id that resolves to a Data packet should fail"); + }; + assert_eq!(err, DecodeError::WrongKind { id: data_id }); + } + + // --- ENCODE TESTS --- + + #[test] + fn encode_then_decode_round_trips_timestamp_and_strings() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let original = MessagePacket { + id: AdjId(10), + timestamp: ShortTimestamp { + hour: 23, + minute: 59, + second: 58, + subsecond: 200, + }, + origin: "board/src/main.c".to_string(), + message: "overcurrent detected".to_string(), + }; + + let Ok(bytes) = encode(&original, &index) else { + unreachable!("encoding a well-formed message packet should succeed"); + }; + // `encode` writes the id too, so `decode` can read it back itself — + // proves the full frame round-trips, not just the body. + let Ok(decoded) = decode(Cursor::new(bytes), None, &index) else { + unreachable!("decoding what we just encoded should succeed"); + }; + + assert_eq!(decoded, original); + } + + #[test] + fn encode_fails_on_unknown_id() { + let index = index_with_message(AdjId(10), MessageLevel::Fault); + let packet = MessagePacket { + id: AdjId(9999), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: String::new(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an unknown id should fail"); + }; + assert_eq!(err, EncodeError::UnknownId(AdjId(9999))); + } + + #[test] + fn encode_fails_on_wrong_kind() { + let data_id = AdjId(2000); + let packet_def = PacketDef { + id: data_id, + board: BoardId(1), + name: "bcu_data".to_string(), + kind: PacketKind::Data(vec![]), + }; + let board = Board { + id: BoardId(1), + name: "BCU".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let index = Index::new(&pod_data, None); + + let packet = MessagePacket { + id: data_id, + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: String::new(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding into a non-message packet's id should fail"); + }; + assert_eq!(err, EncodeError::WrongKind { id: data_id }); + } + + #[test] + fn encode_rejects_a_too_long_origin() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let packet = MessagePacket { + id: AdjId(12), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: "a".repeat(ORIGIN_MAX), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding an origin with no room for its terminator should fail"); + }; + assert_eq!(err, EncodeError::StringTooLong { max: ORIGIN_MAX }); + + let fits = MessagePacket { + origin: "a".repeat(ORIGIN_MAX - 1), + ..packet + }; + assert!(encode(&fits, &index).is_ok()); + } + + #[test] + fn encode_rejects_an_embedded_nul_byte() { + let index = index_with_message(AdjId(12), MessageLevel::Info); + let packet = MessagePacket { + id: AdjId(12), + timestamp: ShortTimestamp { + hour: 0, + minute: 0, + second: 0, + subsecond: 0, + }, + origin: "ab\0cd".to_string(), + message: String::new(), + }; + + let Err(err) = encode(&packet, &index) else { + unreachable!("encoding a string with an embedded NUL byte should fail"); + }; + assert_eq!(err, EncodeError::EmbeddedNulByte); + } } From 2a11b67e0bd7f83905851e71e8cedd54be027828 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 13 Oct 2026 00:05:47 +0200 Subject: [PATCH 80/82] fix(backend, protocol): remove duplicated function --- backend/crates/core/src/protocol/packet.rs | 17 +++++++++++++++++ .../crates/core/src/protocol/packet/message.rs | 11 +---------- .../core/src/protocol/packet/protection.rs | 11 +---------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index 11b2847a8..d0f49d3b8 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -17,3 +17,20 @@ pub mod data_order; pub mod message; pub mod protection; + +use crate::model::AdjId; +use crate::protocol::DecodeError; +use std::io::Read; + +/// Reads a raw little-endian 16-bit id off the front of `reader`. +/// +/// Shared by [`message`]/[`protection`] — the only two kinds whose +/// `decode` ever needs to read its own leading id, when the caller +/// doesn't already know it (see each module's own doc comment on why). +/// [`data_order`] doesn't need this: it reads its id a different way, +/// straight out of an in-memory byte slice, never a `Read` stream. +fn read_id(reader: &mut impl Read) -> Result { + let mut bytes = [0u8; 2]; + reader.read_exact(&mut bytes)?; + Ok(AdjId(u16::from_le_bytes(bytes))) +} diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs index eb9f1986b..b1b776a62 100644 --- a/backend/crates/core/src/protocol/packet/message.rs +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -51,7 +51,7 @@ pub fn decode( ) -> Result { let id = match id { Some(id) => id, - None => read_id(&mut reader)?, + None => super::read_id(&mut reader)?, }; // Look up the packet definition for this id, and ensure it's a @@ -82,15 +82,6 @@ pub fn decode( }) } -/// Reads a raw little-endian 16-bit id off the front of `reader`. The -/// one piece of byte parsing [`decode`]/[`encode`] share, so it only -/// lives here once instead of wherever else might need an `AdjId`. -fn read_id(reader: &mut impl Read) -> Result { - let mut bytes = [0u8; 2]; - reader.read_exact(&mut bytes)?; - Ok(AdjId(u16::from_le_bytes(bytes))) -} - /// Reads bytes up to the first `0x00` (exclusive) or `max` bytes total, /// whichever comes first — [`DecodeError::UnterminatedString`] if no /// terminator shows up within `max` bytes. diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index 29d171356..fdb36768d 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -39,7 +39,7 @@ pub fn decode( ) -> Result { let compound_id = match compound_id { Some(id) => id, - None => read_id(&mut reader)?, + None => super::read_id(&mut reader)?, }; // Look up the packet definition for this compound id, and ensure it's a @@ -76,15 +76,6 @@ pub fn decode( }) } -/// Reads a raw little-endian 16-bit id off the front of `reader`. The -/// one piece of byte parsing [`decode`]/[`encode`] share, so it only -/// lives here once instead of wherever else might need an `AdjId`. -fn read_id(reader: &mut impl Read) -> Result { - let mut bytes = [0u8; 2]; - reader.read_exact(&mut bytes)?; - Ok(AdjId(u16::from_le_bytes(bytes))) -} - /// Encodes a [`ProtectionPacket`] back into the bytes [`decode`] expects /// — a complete, self-contained frame, leading id included. /// From 80d34b3d19569b1bcf792be6dba0c46f52b98bea Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 13 Oct 2026 00:19:37 +0200 Subject: [PATCH 81/82] feat(backend, protocol): tcp decode --- backend/crates/core/src/protocol/README.md | 10 +- backend/crates/core/src/protocol/packet.rs | 13 +- .../crates/core/src/protocol/packet/README.md | 20 ++- backend/crates/core/src/protocol/tcp.rs | 141 ++++++++++++++++-- 4 files changed, 154 insertions(+), 30 deletions(-) diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md index a62b38c26..400359608 100644 --- a/backend/crates/core/src/protocol/README.md +++ b/backend/crates/core/src/protocol/README.md @@ -32,7 +32,7 @@ protocol/ │ ├── data_order.rs # Data (telemetry) & Order (command) packets │ ├── message.rs # Free-text log message packets │ └── protection.rs # Protection trigger packets -├── tcp.rs # TCP transport framing (stub) +├── tcp.rs # TCP transport framing └── udp.rs # UDP transport framing ``` @@ -154,18 +154,20 @@ bytes. - `decode(reader, compound_id: Option, index)` - Decode a protection frame, resolving its id itself if not given one - `encode(packet, index)` - Test-only: encode a complete `ProtectionPacket` frame, id included -### `tcp.rs` - TCP Transport Framing ⚠ Partial +### `tcp.rs` - TCP Transport Framing Handles the dedicated TCP connection to each board. A TCP connection carries: - Protection events (board → backend) - Free-text messages (board → backend) - Order commands (backend → board) +`decode_next` reads the frame's leading 16-bit id itself (via `packet::read_id`, `pub(super)` so `tcp` — a sibling of `packet`, not a descendant — can reach it), then dispatches purely on the raw id: `id.0 >= 8192` (`1 << 13` — a protection's compound id always has its high 3 bits set, see `AdjId`'s own docs) goes to `packet::protection::decode(reader, Some(id), index)`, anything below that to `packet::message::decode(reader, Some(id), index)`. Neither branch does its own lookup first — each `decode` already resolves `UnknownId`/`WrongKind` against `index` itself. + **Key types:** - `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) **Key functions:** -- `decode_next(reader, index)` - ⚠ Stub - Decode the next frame from a TCP stream +- `decode_next(reader, index)` - ✓ Implemented - Decode the next frame from a TCP stream - `encode_order(order, index)` - ✓ Implemented - Encode an order for sending ### `udp.rs` - UDP Transport Framing @@ -193,7 +195,7 @@ Handles the shared UDP socket for periodic telemetry. UDP datagrams are complete - ✅ **Wire-level boolean types** - Single byte, confirmed with firmware - ✅ **Message packets** - decode implemented, with tests (encode exists for test symmetry only) - ✅ **Protection packets** - decode implemented, with tests (encode exists for test symmetry only) -- ⚠️ **TCP framing** - Partial (decode_next not implemented) +- ✅ **TCP framing** - `decode_next` implemented and tested (dispatches to protection/message by `id.0 >= 8192`) ## Testing diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index d0f49d3b8..df7842292 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -26,10 +26,15 @@ use std::io::Read; /// /// Shared by [`message`]/[`protection`] — the only two kinds whose /// `decode` ever needs to read its own leading id, when the caller -/// doesn't already know it (see each module's own doc comment on why). -/// [`data_order`] doesn't need this: it reads its id a different way, -/// straight out of an in-memory byte slice, never a `Read` stream. -fn read_id(reader: &mut impl Read) -> Result { +/// doesn't already know it (see each module's own doc comment on why) — +/// and by [`super::tcp::decode_next`], which needs the id *before* it +/// can even decide which of the two to call. `pub(super)` (visible to +/// `protocol` and everything under it) rather than private, for that +/// last caller: `tcp` is `packet`'s sibling, not its descendant, so +/// private wouldn't reach it. [`data_order`] doesn't need this: it reads +/// its id a different way, straight out of an in-memory byte slice, +/// never a `Read` stream. +pub(super) fn read_id(reader: &mut impl Read) -> Result { let mut bytes = [0u8; 2]; reader.read_exact(&mut bytes)?; Ok(AdjId(u16::from_le_bytes(bytes))) diff --git a/backend/crates/core/src/protocol/packet/README.md b/backend/crates/core/src/protocol/packet/README.md index fce1586c9..668e8532b 100644 --- a/backend/crates/core/src/protocol/packet/README.md +++ b/backend/crates/core/src/protocol/packet/README.md @@ -22,9 +22,9 @@ see its own module doc and `model/README.md`'s §4.2 for why. These two are different: over TCP, a `Protection` frame and a `Message` frame share the same leading 16-bit id space, so **something** has to read that id before it even knows which of the two decoders to call. -That something is `tcp::decode_next` (still unimplemented). To keep that -reality from leaking actual decoding logic into `tcp.rs`, both files -take their id as `Option` — the exact same shape: +That something is `tcp::decode_next`. To keep that reality from leaking +actual decoding logic into `tcp.rs`, both files take their id as +`Option` — the exact same shape: ```rust // protection.rs @@ -76,11 +76,15 @@ decide *which* of `protection::decode`/`message::decode` to call — there is no way around this for a non-seekable stream: once those bytes are consumed, there's no handing them to the other function's `None` path instead. That single mechanical "read a `u16`" is the one piece of -byte-handling that's expected to exist in `tcp.rs`. It is not decoding — -nothing is validated or interpreted by it beyond "is the top bit of -`AdjId::protection_position()` set" — and its result is immediately -handed to the matching function as `Some(id)`, which then resolves it -against `index` itself and does everything else. +byte-handling that's expected to exist in `tcp.rs` — it calls the exact +same private `read_id` helper `protection.rs`/`message.rs` use for their +own `None` path (defined once in `packet.rs`, `pub(super)` so `tcp.rs` +can reach it too, rather than duplicated a third time). It is not +decoding — nothing is validated or interpreted by it beyond a single +`id.0 >= 8192` comparison (a protection's compound id always has its +high 3 bits set, see `AdjId`'s own docs) — and its result is +immediately handed to the matching function as `Some(id)`, which then +resolves it against `index` itself and does everything else. ## Tests diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index 9d4169087..a2d645944 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -26,23 +26,136 @@ pub enum TcpFrame { /// Reads and decodes the next complete frame from `reader`, resolving /// field shapes against `index`. /// -/// Reads the frame's leading 16-bit id first, then decides which of -/// [`super::packet::protection::decode`] ([`crate::model::AdjId::protection_position`] -/// is non-zero) or [`super::packet::message::decode`] (the id matches one -/// of `general_info.json`'s `message_ids`) to hand the rest of the frame -/// to, wrapping the result in the matching [`TcpFrame`] variant. -pub fn decode_next(reader: impl Read, index: &Index) -> Result { - let _ = (reader, index); - todo!( - "read the leading 16-bit id; if its AdjId::protection_position() is \ - non-zero, call packet::protection::decode; otherwise resolve it \ - against general_info.json's message_ids (not yet reachable from \ - `Index` — needs AdjInfo too) and call packet::message::decode; \ - wrap either result in the matching TcpFrame variant" - ) +/// Reads the frame's leading 16-bit id first — the one piece of byte +/// handling this function does itself, since it has no way to decide +/// which of [`super::packet::protection::decode`]/ +/// [`super::packet::message::decode`] to call otherwise (see +/// `packet/README.md`'s "dispatcher's one unavoidable read"). A +/// protection's compound id always has its high 3 bits set to a nonzero +/// position (see [`crate::model::AdjId`]'s own docs on its ranges), so +/// `id.0 >= 8192` (`1 << 13`) is a protection; anything below that hands +/// off to `message::decode` instead — `message::decode` resolves it +/// against `index` itself (`UnknownId`/`WrongKind` if it isn't actually +/// a declared message), so there's no separate lookup to do here first. +/// Either call's result is wrapped in the matching [`TcpFrame`] variant. +pub fn decode_next(mut reader: impl Read, index: &Index) -> Result { + let id = packet::read_id(&mut reader)?; + + if id.0 >= 8192 { + let protection = packet::protection::decode(reader, Some(id), index)?; + Ok(TcpFrame::Protection(protection)) + } else { + let message = packet::message::decode(reader, Some(id), index)?; + Ok(TcpFrame::Message(message)) + } } /// Encodes `order` into the bytes to write to the board's connection. pub fn encode_order(order: &OrderPacket, index: &Index) -> Result, EncodeError> { packet::data_order::encode(order, index) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{ + AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, MessageLevel, NumericKind, + PacketDef, PacketKind, PodData, ShortTimestamp, Value, + }; + use std::collections::HashMap; + use std::io::Cursor; + use std::net::IpAddr; + + /// A board with one protection (position 1 on measurement 100, wire + /// type `U8`) and one declared message severity (`id` 10 = `Fault`). + fn sample_index() -> Index { + let measurement = Measurement { + id: AdjId(100), + alias: "temp".to_string(), + name: "Temp".to_string(), + display_units: String::new(), + kind: MeasurementKind::Numeric { + wire_type: NumericKind::U8, + }, + protections: vec![], + }; + let compound_id = AdjId((1u16 << 13) | measurement.id.0); + let packet_def = PacketDef { + id: compound_id, + board: BoardId(1), + name: "temp_protection".to_string(), + kind: PacketKind::Protection(measurement), + }; + let board = Board { + id: BoardId(1), + name: "MOCK".to_string(), + ip: IpAddr::from([127, 0, 0, 1]), + mac: MacAddress([0; 6]), + packets: HashMap::from([(packet_def.id, packet_def)]), + }; + let pod_data = PodData { + boards: HashMap::from([(board.id, board)]), + }; + let message_levels = HashMap::from([(AdjId(10), MessageLevel::Fault)]); + Index::new(&pod_data, Some(&message_levels)) + } + + #[test] + fn decode_next_dispatches_a_protection_frame() { + let index = sample_index(); + let compound_id = AdjId((1u16 << 13) | 100); + + let mut bytes = compound_id.0.to_le_bytes().to_vec(); + bytes.extend([12, 30, 5, 0, 0, 0]); // hora, min, seg, subseg, resv(2) + bytes.push(42); // value, U8 + + let Ok(frame) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding a well-formed protection frame should succeed"); + }; + + let TcpFrame::Protection(protection) = frame else { + unreachable!("a non-zero protection_position should dispatch to Protection"); + }; + assert_eq!(protection.id, compound_id); + assert_eq!(protection.value, Value::Number(42.0)); + } + + #[test] + fn decode_next_dispatches_a_message_frame() { + let index = sample_index(); + + let mut bytes = AdjId(10).0.to_le_bytes().to_vec(); + bytes.extend([1, 2, 3, 4]); // hora, min, seg, subseg + bytes.push(0); // origin: empty + bytes.push(0); // message: empty + + let Ok(frame) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding a well-formed message frame should succeed"); + }; + + let TcpFrame::Message(message) = frame else { + unreachable!("a zero protection_position should dispatch to Message"); + }; + assert_eq!(message.id, AdjId(10)); + assert_eq!( + message.timestamp, + ShortTimestamp { + hour: 1, + minute: 2, + second: 3, + subsecond: 4, + } + ); + } + + #[test] + fn decode_next_propagates_an_unknown_message_id() { + let index = sample_index(); + let bytes = 9999u16.to_le_bytes().to_vec(); + + let Err(err) = decode_next(Cursor::new(bytes), &index) else { + unreachable!("decoding an id that's neither a protection nor a message should fail"); + }; + assert_eq!(err, DecodeError::UnknownId(AdjId(9999))); + } +} From 8d7b6b52337ad91420375f710886d4ec3564ef3b Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Tue, 13 Oct 2026 14:15:12 +0200 Subject: [PATCH 82/82] refactor(backend): change PacketKind for Pakcet .type --- backend/crates/core/src/adj/README.md | 4 +- backend/crates/core/src/adj/raw/loader.rs | 34 ++++----- backend/crates/core/src/adj/raw/types.rs | 6 +- backend/crates/core/src/model.rs | 6 +- backend/crates/core/src/model/README.md | 30 ++++---- backend/crates/core/src/model/board.rs | 26 +++---- backend/crates/core/src/model/packet.rs | 36 ++++----- backend/crates/core/src/protocol.rs | 2 +- backend/crates/core/src/protocol/README.md | 18 ++--- backend/crates/core/src/protocol/error.rs | 18 ++--- backend/crates/core/src/protocol/index.rs | 13 ++-- backend/crates/core/src/protocol/packet.rs | 12 +-- .../crates/core/src/protocol/packet/README.md | 18 ++--- .../core/src/protocol/packet/data_order.rs | 76 +++++++++---------- .../core/src/protocol/packet/message.rs | 28 +++---- .../core/src/protocol/packet/protection.rs | 26 +++---- backend/crates/core/src/protocol/tcp.rs | 11 +-- 17 files changed, 183 insertions(+), 181 deletions(-) diff --git a/backend/crates/core/src/adj/README.md b/backend/crates/core/src/adj/README.md index 815cbb6e1..c167bdfbc 100644 --- a/backend/crates/core/src/adj/README.md +++ b/backend/crates/core/src/adj/README.md @@ -67,7 +67,7 @@ doesn't hardcode `"fault"`/`"warning"`/`"info"`/`"panic"` itself. `Option<&HashMap>` — `None` for callers with no `AdjInfo` at all (most tests in this crate, which only build a bare `PodData`) — and, for each entry, synthesizes a -`PacketDef { kind: PacketKind::Message(level), board: BoardId(0), .. }` +`PacketDef { packet_type: PacketType::Message(level), board: BoardId(0), .. }` into the very same `AdjId -> PacketDef` table every other packet lives in. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own docs) because a severity isn't owned by any one board — any board's @@ -89,4 +89,4 @@ The `Adj` object enforces a clear architectural boundary between **vehicle topol The raw deserialization types and the loader logic live isolated inside `raw`[cite: 2]. This enables non-station tools (such as bus sniffers, test runners, or simulators) to parse and ingest ADJ directories without dragging along application-level UI or station state logic. ### 3. Unified Packet Map (`packets`) -Protections defined under measurements are materialized during loading as first-class `PacketKind::Protection(Measurement)` packets alongside the explicit data, order and message packets declared in `packets.json`. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. `packets.json` itself never declares a `"type": "protection"` entry directly — that shape only exists synthesized, one per protection a measurement declares, never as a free-standing declaration with its own arbitrary variable list. \ No newline at end of file +Protections defined under measurements are materialized during loading as first-class `PacketType::Protection(Measurement)` packets alongside the explicit data, order and message packets declared in `packets.json`. Downstream networking layers can route and handle every wire event uniformly via `Board::packets` without needing specialized traversal logic for nested measurement alarms. `packets.json` itself never declares a `"type": "protection"` entry directly — that shape only exists synthesized, one per protection a measurement declares, never as a free-standing declaration with its own arbitrary variable list. \ No newline at end of file diff --git a/backend/crates/core/src/adj/raw/loader.rs b/backend/crates/core/src/adj/raw/loader.rs index 53390d252..32216fc2b 100644 --- a/backend/crates/core/src/adj/raw/loader.rs +++ b/backend/crates/core/src/adj/raw/loader.rs @@ -4,7 +4,7 @@ use super::types::{RawBoard, RawGeneralInfo, RawMeasurement, RawPacket}; use crate::adj::error::LoadError; use crate::adj::{Adj, AdjInfo}; use crate::model::{ - AdjId, Board, BoardId, MacAddress, Measurement, MessageLevel, PacketDef, PacketKind, PodData, + AdjId, Board, BoardId, MacAddress, Measurement, MessageLevel, PacketDef, PacketType, PodData, }; use serde::de::DeserializeOwned; use std::collections::HashMap; @@ -82,7 +82,7 @@ pub(crate) fn load_board(root: &Path, name: &str, rel_path: &str) -> Result, ) -> Result { - let kind = match raw.kind.as_str() { - "data" => PacketKind::Data(resolve_measurements( + let packet_type = match raw.packet_type.as_str() { + "data" => PacketType::Data(resolve_measurements( board, &raw.name, &raw.variables, measurements, )?), - "order" => PacketKind::Order(resolve_measurements( + "order" => PacketType::Order(resolve_measurements( board, &raw.name, &raw.variables, @@ -147,7 +147,7 @@ fn build_packet_def( // severities (`"fault"`/`"warning"`/`"info"`/`"panic"`) — that's // the only way a `"message"`-type packet in `packets.json` says // which severity it is; nothing else in its JSON shape does. - "message" => PacketKind::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { + "message" => PacketType::Message(MessageLevel::from_name(&raw.name).ok_or_else(|| { LoadError::UnknownMessageSeverity { board: board.to_string(), name: raw.name.clone(), @@ -166,7 +166,7 @@ fn build_packet_def( id: AdjId(raw.id), board: board_id, name: raw.name, - kind, + packet_type, }) } @@ -217,7 +217,7 @@ fn parse_mac(raw: &str) -> Option { mod tests { use super::*; use crate::adj::ProtectionTypeInfo; - use crate::model::{PacketKind, Port, ProtectionKind, Severity}; + use crate::model::{PacketType, Port, ProtectionKind, Severity}; use std::path::Path; use std::time::Duration; @@ -249,7 +249,7 @@ mod tests { fn build_packet_def_rejects_unknown_packet_type() { let raw = RawPacket { id: 1, - kind: "status".to_string(), + packet_type: "status".to_string(), name: "Status".to_string(), variables: vec![], }; @@ -263,7 +263,7 @@ mod tests { fn build_packet_def_rejects_unknown_alias() { let raw = RawPacket { id: 1, - kind: "data".to_string(), + packet_type: "data".to_string(), name: "Status".to_string(), variables: vec!["missing".to_string()], }; @@ -277,21 +277,21 @@ mod tests { fn build_packet_def_resolves_a_message_packets_severity_from_its_name() { let raw = RawPacket { id: 2, - kind: "message".to_string(), + packet_type: "message".to_string(), name: "fault".to_string(), variables: vec![], }; let Ok(packet) = build_packet_def("VCU", BoardId(3), raw, &HashMap::new()) else { unreachable!("\"fault\" is a known message severity"); }; - assert_eq!(packet.kind, PacketKind::Message(MessageLevel::Fault)); + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); } #[test] fn build_packet_def_rejects_a_message_whose_name_isnt_a_severity() { let raw = RawPacket { id: 2, - kind: "message".to_string(), + packet_type: "message".to_string(), name: "not_a_severity".to_string(), variables: vec![], }; @@ -326,14 +326,14 @@ mod tests { let Some(order) = vcu.packets.get(&AdjId(600)) else { unreachable!("VCU should have order 600"); }; - let PacketKind::Order(_) = &order.kind else { + let PacketType::Order(_) = &order.packet_type else { unreachable!("order 600 should be an Order"); }; assert_eq!(order.board, BoardId(3)); let Some(data) = vcu.packets.get(&AdjId(625)) else { unreachable!("VCU should have data packet 625"); }; - let PacketKind::Data(_) = &data.kind else { + let PacketType::Data(_) = &data.packet_type else { unreachable!("data packet 625 should be Data"); }; @@ -343,7 +343,7 @@ mod tests { let Some(protected) = pcu .packets .values() - .flat_map(|p| p.kind.measurements()) + .flat_map(|p| p.packet_type.measurements()) .find(|m| !m.protections.is_empty()) else { unreachable!("PCU should have a measurement with a protection"); @@ -375,7 +375,7 @@ mod tests { let Some(protection_packet) = pcu.packets.get(&AdjId((1u16 << 13) | protected.id.0)) else { unreachable!("PCU should have a synthetic protection packet"); }; - let PacketKind::Protection(measurement) = &protection_packet.kind else { + let PacketType::Protection(measurement) = &protection_packet.packet_type else { unreachable!("protection_packet should be a Protection"); }; assert_eq!(measurement, protected); diff --git a/backend/crates/core/src/adj/raw/types.rs b/backend/crates/core/src/adj/raw/types.rs index 3942a3bc7..bac40de9f 100644 --- a/backend/crates/core/src/adj/raw/types.rs +++ b/backend/crates/core/src/adj/raw/types.rs @@ -79,7 +79,7 @@ fn default_fault() -> bool { pub(crate) struct RawPacket { pub(crate) id: u16, #[serde(rename = "type")] - pub(crate) kind: String, + pub(crate) packet_type: String, pub(crate) name: String, #[serde(default)] pub(crate) variables: Vec, @@ -306,7 +306,7 @@ fn parse_duration(text: &str) -> Result { mod tests { use super::*; use crate::adj::raw::loader::load_board; - use crate::model::PacketKind; + use crate::model::PacketType; use std::net::IpAddr; fn raw_protection( @@ -623,7 +623,7 @@ mod tests { let protection_count = board .packets .values() - .filter(|p| matches!(p.kind, PacketKind::Protection(_))) + .filter(|p| matches!(p.packet_type, PacketType::Protection(_))) .count(); assert_eq!( protection_count, 2, diff --git a/backend/crates/core/src/model.rs b/backend/crates/core/src/model.rs index 04f933cb3..75cdb319e 100644 --- a/backend/crates/core/src/model.rs +++ b/backend/crates/core/src/model.rs @@ -17,12 +17,12 @@ mod packet; mod protections; mod value; -pub use board::{Board, PacketDef, PacketKind, PodData}; +pub use board::{Board, PacketDef, PacketType, PodData}; pub use id::{AdjId, BoardId, MacAddress, Port}; pub use measurement::{Measurement, MeasurementKind, NumericKind, Range}; pub use packet::{ - Data, DataOrderPacket, DataPacket, KindTag, MessageLevel, MessagePacket, Order, OrderPacket, - Packet, ProtectionPacket, ShortTimestamp, + Data, DataOrderPacket, DataPacket, MessageLevel, MessagePacket, Order, OrderPacket, Packet, + PacketTypeTag, ProtectionPacket, ShortTimestamp, }; pub use protections::{Protection, ProtectionKind, Severity}; pub use value::Value; diff --git a/backend/crates/core/src/model/README.md b/backend/crates/core/src/model/README.md index eaa09367e..20db8642d 100644 --- a/backend/crates/core/src/model/README.md +++ b/backend/crates/core/src/model/README.md @@ -6,8 +6,8 @@ This module is meant to store and organize all the different structs, enums and ## 1. `board.rs` All structs and enums needed for the Pod can be found defined in this file. -### 1.1 `PacketKind` -Enum used to define what kind of packet a `PacketDef` declares. Not every kind carries the same shape, so each variant holds whatever measurement data is actually meaningful for it, instead of every packet being forced through one shared field. +### 1.1 `PacketType` +Enum used to define what type of packet a `PacketDef` declares. Not every type carries the same shape, so each variant holds whatever measurement data is actually meaningful for it, instead of every packet being forced through one shared field. #### `Data(Vec)` Telemetry sent by a board: one value per measurement, decoded positionally in this order. @@ -16,9 +16,9 @@ The single measurement one of a board's declared protections watches. Always exa #### `Order(Vec)` An order sent to a board. Many orders take no parameters at all, so this can be empty. #### `Message(MessageLevel)` -A free-text log line of this severity. Never carries measurement data — the severity is the only thing that tells one `Message` `PacketDef` apart from another. Lives here, on `PacketKind`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what kind of packet is this" as a `Data` packet's measurement list is. +A free-text log line of this severity. Never carries measurement data — the severity is the only thing that tells one `Message` `PacketDef` apart from another. Lives here, on `PacketType`, rather than as a separate field on `PacketDef`: a message's severity is as much part of "what type of packet is this" as a `Data` packet's measurement list is. -`PacketKind` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its kind. +`PacketType` also exposes a `measurements()` method that flattens any variant into a `&[Measurement]` slice, for code that just wants every measurement a packet references regardless of its type. ### 1.2 `PacketDef` Struct used to define the structure of a packet a board can send or recieve. @@ -29,8 +29,8 @@ ID of a packet, using the AdjId type which is a u16. Structure of ID is defined The owning board's `BoardId`. Carried here too (not just reachable through the owning `Board`) so anything holding just a `&PacketDef` — e.g. a lookup table indexed by `AdjId` — knows which board it belongs to without a separate index back to it. #### `name` Human-readable String shown to the user to identify packet. -#### `kind` -`PacketKind` enum: both what kind of packet this is, and the measurement shape — if any — specific to that kind. +#### `packet_type` +`PacketType` enum: both what type of packet this is, and the measurement shape — if any — specific to that type. ### 1.3 `Board` Struct used to identify a single board in the vehicle. @@ -139,16 +139,16 @@ pub struct DataOrderPacket { #### `Data` / `Order` Two empty marker structs — the only two types `Kind` is ever instantiated with. Neither is ever actually constructed; they only ever appear as a type argument. That's what makes the whole thing *phantom*: the type parameter has no representation in the compiled struct, it's erased after type-checking, the same way `id.rs` (§2) uses distinct `AdjId`/`BoardId` types so the compiler won't confuse two different kinds of `u16`. -#### `KindTag` -The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `KindTag` is how code that's generic over `Kind` answers that anyway, by asking the question of the *type* instead of the *value*: +#### `PacketTypeTag` +The compiler erases `Kind` after type-checking, so nothing at runtime can look at a `DataOrderPacket` and ask "is this actually a `Data` one?" — `PacketTypeTag` is how code that's generic over `Kind` answers that anyway, by asking the question of the *type* instead of the *value*: ```rust -pub trait KindTag { - fn matches(kind: &PacketKind) -> bool; +pub trait PacketTypeTag { + fn matches(packet_type: &PacketType) -> bool; } ``` -`Data`/`Order` each implement it by checking which `PacketKind` (§1.1) variant they stand for (`matches!(kind, PacketKind::Data(_))` / `PacketKind::Order(_)`). This is the only thing `KindTag` decides — once a `PacketDef`'s kind is confirmed to match, the actual measurement list still comes from the existing `PacketKind::measurements()` (§1.1), not from `KindTag` itself. +`Data`/`Order` each implement it by checking which `PacketType` (§1.1) variant they stand for (`matches!(packet_type, PacketType::Data(_))` / `PacketType::Order(_)`). This is the only thing `PacketTypeTag` decides — once a `PacketDef`'s type is confirmed to match, the actual measurement list still comes from the existing `PacketType::measurements()` (§1.1), not from `PacketTypeTag` itself. -`protocol::packet::data_order`'s `encode`/`decode` use this to be generic over `Kind`: written once, they work for both `DataPacket` and `OrderPacket` instead of needing a near-duplicate pair of functions per kind. +`protocol::packet::data_order`'s `encode`/`decode` use this to be generic over `Kind`: written once, they work for both `DataPacket` and `OrderPacket` instead of needing a near-duplicate pair of functions per type. #### `DataPacket` / `OrderPacket` ```rust @@ -157,9 +157,9 @@ pub type OrderPacket = DataOrderPacket; ``` Now genuinely different types: a function that takes an `OrderPacket` cannot be called with a `DataPacket`, even though both just wrap the same `(AdjId, Vec)` underneath — the mix-up is a compile error instead of a bug waiting to happen. `DataOrderPacket`'s own doc comment has a `compile_fail` doctest proving exactly that (run by `cargo test`, like any other doctest). -`DataPacket` holds the decoded values of the measurements a board just sent — positional, not individually tagged with their measurement ID, just written in the order their `Measurement`s appear in this packet's `PacketKind::Data` list. +`DataPacket` holds the decoded values of the measurements a board just sent — positional, not individually tagged with their measurement ID, just written in the order their `Measurement`s appear in this packet's `PacketType::Data` list. -`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketKind::Order`'s measurement list. +`OrderPacket` holds an order sent from the backend to a board — positional the same way, matching `PacketType::Order`'s measurement list. #### `id` Which packet/order definition this is an instance of (`AdjId`). @@ -199,7 +199,7 @@ This is deliberately where the name↔severity association lives — in `model`, A free-text message sent by a board. #### `id` -The wire id this message was sent under — one of the severities declared in `general_info.json`'s `message_ids`. Not a `MessageLevel` directly, the same way `ProtectionPacket::id` isn't a `Protection`: resolving it to a severity means looking it up (`PacketKind::Message`/`protocol::Index`), not storing a second, redundant copy of it here. +The wire id this message was sent under — one of the severities declared in `general_info.json`'s `message_ids`. Not a `MessageLevel` directly, the same way `ProtectionPacket::id` isn't a `Protection`: resolving it to a severity means looking it up (`PacketType::Message`/`protocol::Index`), not storing a second, redundant copy of it here. #### `timestamp` When the message was sent (`ShortTimestamp`). #### `origin` diff --git a/backend/crates/core/src/model/board.rs b/backend/crates/core/src/model/board.rs index b00e3a82c..2dc56b6b7 100644 --- a/backend/crates/core/src/model/board.rs +++ b/backend/crates/core/src/model/board.rs @@ -14,13 +14,13 @@ use super::{AdjId, BoardId, MacAddress, Measurement, MessageLevel}; use std::collections::HashMap; use std::net::IpAddr; -/// What kind of packet a [`PacketDef`] declares, carrying whatever -/// measurement shape — if any — is specific to that kind. Not every kind +/// What type of packet a [`PacketDef`] declares, carrying whatever +/// measurement shape — if any — is specific to that type. Not every type /// has measurements, so this says so in the type instead of forcing every /// [`PacketDef`] through one shared `measurements: Vec` field /// regardless of whether that's meaningful for it. #[derive(Debug, Clone, PartialEq)] -pub enum PacketKind { +pub enum PacketType { /// A board's periodic telemetry: one value per measurement, decoded /// positionally in this order. Data(Vec), @@ -35,15 +35,15 @@ pub enum PacketKind { Message(MessageLevel), } -impl PacketKind { - /// Every measurement this packet's kind references, flattened — for - /// code that doesn't care which kind this is, just what measurements +impl PacketType { + /// Every measurement this packet's type references, flattened — for + /// code that doesn't care which type this is, just what measurements /// it touches. pub fn measurements(&self) -> &[Measurement] { match self { - PacketKind::Data(measurements) | PacketKind::Order(measurements) => measurements, - PacketKind::Protection(measurement) => std::slice::from_ref(measurement), - PacketKind::Message(_) => &[], + PacketType::Data(measurements) | PacketType::Order(measurements) => measurements, + PacketType::Protection(measurement) => std::slice::from_ref(measurement), + PacketType::Message(_) => &[], } } } @@ -66,8 +66,8 @@ pub struct PacketDef { /// The human-readable name shown to the user. pub name: String, /// Whether this is data, order, protection or message — and the - /// measurement shape specific to that kind, if any. - pub kind: PacketKind, + /// measurement shape specific to that type, if any. + pub packet_type: PacketType, } /// A single board of the vehicle, as defined by the ADJ. @@ -132,7 +132,7 @@ mod tests { id: AdjId(1000), board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![sample_measurement()]), + packet_type: PacketType::Data(vec![sample_measurement()]), }, )]), }; @@ -141,7 +141,7 @@ mod tests { let Some(packet) = board.packets.get(&AdjId(1000)) else { unreachable!("the packet we just inserted should be there"); }; - let PacketKind::Data(measurements) = &packet.kind else { + let PacketType::Data(measurements) = &packet.packet_type else { unreachable!("packet should be Data"); }; assert_eq!(measurements.len(), 1); diff --git a/backend/crates/core/src/model/packet.rs b/backend/crates/core/src/model/packet.rs index 948d77834..c3dda408d 100644 --- a/backend/crates/core/src/model/packet.rs +++ b/backend/crates/core/src/model/packet.rs @@ -6,7 +6,7 @@ //! packet or a protection *can* look like, or [`super::PacketDef`], which //! describes a whole packet. -use super::{AdjId, PacketKind, Value}; +use super::{AdjId, PacketType, Value}; use std::marker::PhantomData; /// A short timestamp carrying only the time of day: hour, minute, second @@ -48,30 +48,30 @@ pub struct Data; pub struct Order; /// Bridges a [`DataOrderPacket`]'s phantom `Kind` to the runtime -/// [`PacketKind`] variant it stands for. +/// [`PacketType`] variant it stands for. /// /// `protocol::packet::data_order`'s `encode`/`decode` are generic over /// `Kind`: they're written once and work for both [`Data`] and [`Order`], -/// instead of needing a near-identical copy per kind. This trait is the +/// instead of needing a near-identical copy per type. This trait is the /// one yes/no question that generic code can't answer on its own — -/// "is this `PacketDef`'s kind actually the one I was asked to +/// "is this `PacketDef`'s type actually the one I was asked to /// encode/decode?" — everything after that (pulling out the -/// measurements) is already handled by [`PacketKind::measurements`], -/// kind-agnostic, so there's no need to redo that here. -pub trait KindTag { - /// Whether `kind` is the variant this `Kind` stands for. - fn matches(kind: &PacketKind) -> bool; +/// measurements) is already handled by [`PacketType::measurements`], +/// type-agnostic, so there's no need to redo that here. +pub trait PacketTypeTag { + /// Whether `packet_type` is the variant this `Kind` stands for. + fn matches(packet_type: &PacketType) -> bool; } -impl KindTag for Data { - fn matches(kind: &PacketKind) -> bool { - matches!(kind, PacketKind::Data(_)) +impl PacketTypeTag for Data { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Data(_)) } } -impl KindTag for Order { - fn matches(kind: &PacketKind) -> bool { - matches!(kind, PacketKind::Order(_)) +impl PacketTypeTag for Order { + fn matches(packet_type: &PacketType) -> bool { + matches!(packet_type, PacketType::Order(_)) } } @@ -81,7 +81,7 @@ impl KindTag for Order { /// The wire protocol is positional: values are not individually tagged /// with their measurement's id, just written back to back in the order /// their [`super::Measurement`]s appear in this packet's -/// [`super::PacketKind::Data`] list. `values[i]` is the decoded value of +/// [`super::PacketType::Data`] list. `values[i]` is the decoded value of /// that list's `i`-th measurement — matching them up by position is /// `protocol`'s job, this type just holds the result in that same order. /// @@ -110,7 +110,7 @@ pub struct DataOrderPacket { /// The packet's id. pub id: AdjId, /// Each measurement's decoded value, in the same order as the - /// matching [`super::PacketKind::Data`]/[`super::PacketKind::Order`] + /// matching [`super::PacketType::Data`]/[`super::PacketType::Order`] /// list. pub values: Vec, _marker: PhantomData, @@ -219,7 +219,7 @@ pub struct MessagePacket { /// Not a [`MessageLevel`] directly, the same way [`ProtectionPacket::id`] /// isn't a [`super::Protection`]: this is what was actually on the /// wire, and resolving it to a severity means looking it up (see - /// [`PacketKind::Message`]/[`crate::protocol::Index`]), not storing + /// [`PacketType::Message`]/[`crate::protocol::Index`]), not storing /// a second, redundant copy of it here. pub id: AdjId, /// When it was sent. diff --git a/backend/crates/core/src/protocol.rs b/backend/crates/core/src/protocol.rs index 4ee7cfdd1..e89fe6064 100644 --- a/backend/crates/core/src/protocol.rs +++ b/backend/crates/core/src/protocol.rs @@ -11,7 +11,7 @@ //! compiler enforces the separation the ADJ itself draws between what //! travels over which transport, not runtime convention. `tcp`/`udp` //! themselves are thin wrappers around [`crate::protocol::packet`], which -//! does the actual per-packet-kind work independent of transport. +//! does the actual per-packet-type work independent of transport. mod error; mod index; diff --git a/backend/crates/core/src/protocol/README.md b/backend/crates/core/src/protocol/README.md index 400359608..f107da5d2 100644 --- a/backend/crates/core/src/protocol/README.md +++ b/backend/crates/core/src/protocol/README.md @@ -42,7 +42,7 @@ protocol/ Provides a fast lookup table from packet IDs (`AdjId`) to their declarations (`PacketDef`). Built once from loaded ADJ configuration data (`PodData`), it owns cloned copies of packet definitions so it can be shared across async tasks via `Arc`. -A message severity is not a separate lookup bolted onto `Index` — it's just another `PacketDef`. `Index::new(pod_data, message_levels)` takes `message_levels` as `Option<&HashMap>` (an ADJ's `adj::AdjInfo::message_levels` — `None` for callers with no `AdjInfo` at all, e.g. most tests here) and, for each entry, synthesizes a `PacketDef { kind: PacketKind::Message(level), board: BoardId(0), .. }` into the very same `AdjId -> PacketDef` table as every board's own packets. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own docs) because a severity isn't owned by any one board. Resolving a message is then `Index::packet(id)` + matching `PacketKind::Message(level)`, exactly like `packet::protection` already does for `PacketKind::Protection`. +A message severity is not a separate lookup bolted onto `Index` — it's just another `PacketDef`. `Index::new(pod_data, message_levels)` takes `message_levels` as `Option<&HashMap>` (an ADJ's `adj::AdjInfo::message_levels` — `None` for callers with no `AdjInfo` at all, e.g. most tests here) and, for each entry, synthesizes a `PacketDef { packet_type: PacketType::Message(level), board: BoardId(0), .. }` into the very same `AdjId -> PacketDef` table as every board's own packets. `BoardId(0)` (reserved, not validated — see `AdjId`/`BoardId`'s own docs) because a severity isn't owned by any one board. Resolving a message is then `Index::packet(id)` + matching `PacketType::Message(level)`, exactly like `packet::protection` already does for `PacketType::Protection`. **Key types:** - `Index` - HashMap-based lookup from `AdjId` to `PacketDef` (synthesized `Message` entries included) @@ -63,14 +63,14 @@ Defines errors that can occur during packet encoding and decoding. - `InvalidEnumValue` - Enum index out of range - `UnterminatedString` - String field not null-terminated - `InvalidUtf8` - String field's bytes weren't valid UTF-8 - - `WrongKind` - Packet exists but isn't expected kind + - `WrongPacketType` - Packet exists but isn't expected type - `EncodeError` - Errors when converting packets to bytes - `UnknownId` - No packet declared with this ID - `WrongFieldCount` - Missing or extra fields in order - `TypeMismatch` - Value doesn't match declared type - `OutOfRange` - Numeric value doesn't fit wire type - - `WrongKind` - Packet exists but isn't expected kind + - `WrongPacketType` - Packet exists but isn't expected type - `StringTooLong` - String field + terminator doesn't fit the max length - `EmbeddedNulByte` - String field contains a `\0` byte @@ -90,16 +90,16 @@ Handles conversion between raw bytes and `Value` types for individual measuremen ### `packet/` - Packet-Level Encoding/Decoding -Contains per-packet-kind encoding and decoding logic, independent of +Contains per-packet-type encoding and decoding logic, independent of transport. **Hard rule for this directory: all byte-level parsing for a -packet kind lives in its own file here, never in `tcp.rs`/`udp.rs`.** +packet type lives in its own file here, never in `tcp.rs`/`udp.rs`.** See **[`packet/README.md`](packet/README.md)** for the full design rationale (the `Option` pattern below, and exactly what `tcp.rs` is and isn't allowed to do). #### `data_order.rs` -Handles the positional Data/Order wire shape shared by both packet kinds +Handles the positional Data/Order wire shape shared by both packet types (see `model::DataOrderPacket`'s phantom `Kind`). `encode`/`decode` are generic over `Kind`, written once for both: - **Order packets** (commands sent to boards) - sent over TCP @@ -128,7 +128,7 @@ a protection) — so, same as `protection.rs`, `id` is `Option`: `Some` if the caller already read it (and `reader` starts right after the id), `None` to have `decode` read it itself. Either way, `id` is resolved against `index` to confirm it's actually a declared -`PacketKind::Message` (`UnknownId`/`WrongKind` otherwise) — but unlike +`PacketType::Message` (`UnknownId`/`WrongPacketType` otherwise) — but unlike `protection.rs`, the severity itself is never stored on the decoded `MessagePacket`; it's metadata about the id, not about this particular instance, so look it up again via `index.packet(id)` if it's needed. @@ -161,7 +161,7 @@ Handles the dedicated TCP connection to each board. A TCP connection carries: - Free-text messages (board → backend) - Order commands (backend → board) -`decode_next` reads the frame's leading 16-bit id itself (via `packet::read_id`, `pub(super)` so `tcp` — a sibling of `packet`, not a descendant — can reach it), then dispatches purely on the raw id: `id.0 >= 8192` (`1 << 13` — a protection's compound id always has its high 3 bits set, see `AdjId`'s own docs) goes to `packet::protection::decode(reader, Some(id), index)`, anything below that to `packet::message::decode(reader, Some(id), index)`. Neither branch does its own lookup first — each `decode` already resolves `UnknownId`/`WrongKind` against `index` itself. +`decode_next` reads the frame's leading 16-bit id itself (via `packet::read_id`, `pub(super)` so `tcp` — a sibling of `packet`, not a descendant — can reach it), then dispatches purely on the raw id: `id.0 >= 8192` (`1 << 13` — a protection's compound id always has its high 3 bits set, see `AdjId`'s own docs) goes to `packet::protection::decode(reader, Some(id), index)`, anything below that to `packet::message::decode(reader, Some(id), index)`. Neither branch does its own lookup first — each `decode` already resolves `UnknownId`/`WrongPacketType` against `index` itself. **Key types:** - `TcpFrame` - Enum of frames that can be received over TCP (Protection, Message) @@ -204,7 +204,7 @@ The module includes comprehensive unit tests for: - Order/Data packet encoding and decoding, including round trips - Message/Protection packet decoding (timestamps, strings, values) and their test-only encoding, including round trips -- Error propagation for unknown IDs, wrong kinds, type mismatches, +- Error propagation for unknown IDs, wrong packet types, type mismatches, unterminated/invalid strings, and out-of-range values Run tests with: diff --git a/backend/crates/core/src/protocol/error.rs b/backend/crates/core/src/protocol/error.rs index b08a065a6..b6de39dea 100644 --- a/backend/crates/core/src/protocol/error.rs +++ b/backend/crates/core/src/protocol/error.rs @@ -40,12 +40,12 @@ pub enum DecodeError { #[error("string field wasn't valid UTF-8: {0:?}")] InvalidUtf8(Vec), - /// A looked-up packet exists, but isn't the kind this function expects + /// A looked-up packet exists, but isn't the type this function expects /// (e.g. `packet::data::decode` found an id that resolves to an /// `Order`). - #[error("packet {id:?} isn't the expected kind")] - WrongKind { - /// The packet whose kind didn't match. + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. id: AdjId, }, } @@ -70,7 +70,7 @@ impl PartialEq for DecodeError { ) => a_index == b_index && a_options == b_options, (Self::UnterminatedString { max: a }, Self::UnterminatedString { max: b }) => a == b, (Self::InvalidUtf8(a), Self::InvalidUtf8(b)) => a == b, - (Self::WrongKind { id: a }, Self::WrongKind { id: b }) => a == b, + (Self::WrongPacketType { id: a }, Self::WrongPacketType { id: b }) => a == b, _ => false, } } @@ -111,12 +111,12 @@ pub enum EncodeError { max: f64, }, - /// A looked-up packet exists, but isn't the kind this function expects + /// A looked-up packet exists, but isn't the type this function expects /// (e.g. `packet::order::encode` found an id that resolves to a /// `Data` packet). - #[error("packet {id:?} isn't the expected kind")] - WrongKind { - /// The packet whose kind didn't match. + #[error("packet {id:?} isn't the expected type")] + WrongPacketType { + /// The packet whose type didn't match. id: AdjId, }, diff --git a/backend/crates/core/src/protocol/index.rs b/backend/crates/core/src/protocol/index.rs index 117802a3a..0f9d5a5bd 100644 --- a/backend/crates/core/src/protocol/index.rs +++ b/backend/crates/core/src/protocol/index.rs @@ -1,6 +1,6 @@ //! The `id -> PacketDef` lookup `tcp`/`udp` decode against. -use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketKind, PodData}; +use crate::model::{AdjId, BoardId, MessageLevel, PacketDef, PacketType, PodData}; use std::collections::HashMap; /// A lookup table from a packet's [`AdjId`] to its declaration, built once @@ -49,7 +49,7 @@ impl Index { id, board: BoardId(0), name: level.name().to_string(), - kind: PacketKind::Message(level), + packet_type: PacketType::Message(level), }, ); } @@ -60,7 +60,8 @@ impl Index { /// Looks up a packet's declaration by id — a message severity /// resolves through here too, as a synthesized `PacketDef` with - /// `kind: PacketKind::Message(level)`; see this type's doc comment. + /// `packet_type: PacketType::Message(level)`; see this type's doc + /// comment. pub fn packet(&self, id: AdjId) -> Option<&PacketDef> { self.by_id.get(&id) } @@ -69,7 +70,7 @@ impl Index { #[cfg(test)] mod tests { use super::*; - use crate::model::{Board, BoardId, MacAddress, PacketKind}; + use crate::model::{Board, BoardId, MacAddress, PacketType}; use std::net::IpAddr; fn sample_pod_data() -> PodData { @@ -77,7 +78,7 @@ mod tests { id: AdjId(1000), board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![]), + packet_type: PacketType::Data(vec![]), }; let board = Board { id: BoardId(1), @@ -114,7 +115,7 @@ mod tests { let Some(packet) = index.packet(AdjId(2)) else { unreachable!("the \"fault\" severity was declared at id 2"); }; - assert_eq!(packet.kind, PacketKind::Message(MessageLevel::Fault)); + assert_eq!(packet.packet_type, PacketType::Message(MessageLevel::Fault)); assert_eq!(packet.board, BoardId(0)); } diff --git a/backend/crates/core/src/protocol/packet.rs b/backend/crates/core/src/protocol/packet.rs index df7842292..3e061e44d 100644 --- a/backend/crates/core/src/protocol/packet.rs +++ b/backend/crates/core/src/protocol/packet.rs @@ -1,12 +1,12 @@ -//! Encode/decode for one specific kind of packet, independent of which +//! Encode/decode for one specific type of packet, independent of which //! transport carries it — that mapping lives in [`super::tcp`]/ //! [`super::udp`], which are thin wrappers around these. //! //! [`data_order`]'s `encode`/`decode` are generic over `Kind` -//! ([`crate::model::KindTag`]), so they work for both `Data` and `Order` -//! alike; the backend itself only ever exercises one direction per kind -//! (an order is only ever sent, data only ever received), but that's a -//! restriction `tcp`/`udp` apply, not something baked into the codec. +//! ([`crate::model::PacketTypeTag`]), so they work for both `Data` and +//! `Order` alike; the backend itself only ever exercises one direction per +//! type (an order is only ever sent, data only ever received), but that's +//! a restriction `tcp`/`udp` apply, not something baked into the codec. //! //! [`message`]/[`protection`] are genuinely one-directional in //! production — a board only ever sends these, never receives them — @@ -24,7 +24,7 @@ use std::io::Read; /// Reads a raw little-endian 16-bit id off the front of `reader`. /// -/// Shared by [`message`]/[`protection`] — the only two kinds whose +/// Shared by [`message`]/[`protection`] — the only two types whose /// `decode` ever needs to read its own leading id, when the caller /// doesn't already know it (see each module's own doc comment on why) — /// and by [`super::tcp::decode_next`], which needs the id *before* it diff --git a/backend/crates/core/src/protocol/packet/README.md b/backend/crates/core/src/protocol/packet/README.md index 668e8532b..b55d7556b 100644 --- a/backend/crates/core/src/protocol/packet/README.md +++ b/backend/crates/core/src/protocol/packet/README.md @@ -1,6 +1,6 @@ -# `packet/` — Per-Packet-Kind Codecs +# `packet/` — Per-Packet-Type Codecs -Each file here owns **all** the byte-level parsing for one packet kind — +Each file here owns **all** the byte-level parsing for one packet type — how many bytes a field takes, in what order, what terminates a string, what the leading id means. That parsing never gets split out into `tcp.rs`/`udp.rs` (the transport layer) or anywhere else. A transport @@ -14,7 +14,7 @@ Self-contained both ways: `encode`/`decode` always read/write the leading 2-byte id themselves, since there's no dispatch ambiguity to resolve first — a UDP datagram is unambiguously `Data`, and an order is only ever something the backend already decided to send. Generic over -`Kind` (`model::KindTag`), written once for both `Data` and `Order` — +`Kind` (`model::PacketTypeTag`), written once for both `Data` and `Order` — see its own module doc and `model/README.md`'s §4.2 for why. ## `message.rs` / `protection.rs` @@ -44,27 +44,27 @@ pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeEr this case `reader` must start at byte 0 of the frame, id included, and `decode` reads those 2 bytes itself. Either way, the id is then resolved against `index` — `index.packet(id)` + matching - `PacketKind::Protection`/`PacketKind::Message` — to confirm it's - actually the declared kind (`UnknownId`/`WrongKind` otherwise). A + `PacketType::Protection`/`PacketType::Message` — to confirm it's + actually the declared type (`UnknownId`/`WrongPacketType` otherwise). A message's severity is just another `PacketDef` by the time it gets here (see `index.rs`'s own docs) — there's no separate lookup for it. One real difference between the two: `ProtectionPacket` stores its `id` *and* the triggering `value` (which needs the watched measurement's -wire type, from `PacketKind::Protection(measurement)`, to decode at +wire type, from `PacketType::Protection(measurement)`, to decode at all). `MessagePacket` only stores `id` — its severity is metadata *about* that id, not part of the frame's own byte layout, so `decode` -only uses `PacketKind::Message(level)` to confirm the kind and discards +only uses `PacketType::Message(level)` to confirm the type and discards `level` immediately; nothing about `timestamp`/`origin`/`message`'s bytes depends on which severity it is. Look the severity up again via `index.packet(id)` if it's needed later — it's never stored redundantly on the decoded instance. Either way, **every byte of this frame is parsed inside the file that -owns that packet kind** — `tcp.rs` never touches a timestamp, a `resv` +owns that packet type** — `tcp.rs` never touches a timestamp, a `resv` byte, a measurement's wire width, or a NUL-terminated string. `encode` always writes the leading id back out too (from `packet.id`, after -confirming via `index` it resolves to the right kind), so its output is +confirming via `index` it resolves to the right type), so its output is a complete, self-contained frame — feed it straight into `decode(reader, None, index)` and get the same packet back; that's exactly what each file's round-trip test does. diff --git a/backend/crates/core/src/protocol/packet/data_order.rs b/backend/crates/core/src/protocol/packet/data_order.rs index c01971d35..caca99161 100644 --- a/backend/crates/core/src/protocol/packet/data_order.rs +++ b/backend/crates/core/src/protocol/packet/data_order.rs @@ -3,21 +3,21 @@ //! shape under a phantom `Kind`. //! //! [`encode`]/[`decode`] are generic over that `Kind` via -//! [`crate::model::KindTag`], so they're written once and work for either -//! [`crate::model::Data`] or [`crate::model::Order`] — the backend itself -//! only ever calls `encode::` (via [`super::super::tcp`]) and -//! `decode::` (via [`super::super::udp`]), but that restriction -//! lives at the transport layer, not here. [`crate::model::KindTag`] only -//! answers whether a `PacketDef`'s kind matches `Kind` at all; the actual -//! measurement list comes from [`crate::model::PacketKind::measurements`] -//! either way. - -use crate::model::{AdjId, DataOrderPacket, KindTag}; +//! [`crate::model::PacketTypeTag`], so they're written once and work for +//! either [`crate::model::Data`] or [`crate::model::Order`] — the backend +//! itself only ever calls `encode::` (via [`super::super::tcp`]) +//! and `decode::` (via [`super::super::udp`]), but that restriction +//! lives at the transport layer, not here. [`crate::model::PacketTypeTag`] +//! only answers whether a `PacketDef`'s type matches `Kind` at all; the +//! actual measurement list comes from +//! [`crate::model::PacketType::measurements`] either way. + +use crate::model::{AdjId, DataOrderPacket, PacketTypeTag}; use crate::protocol::{DecodeError, EncodeError, Index, wire}; /// Encodes `packet` into the bytes to write to a board's connection, /// resolving its field shapes against `index`. -pub fn encode( +pub fn encode( packet: &DataOrderPacket, index: &Index, ) -> Result, EncodeError> { @@ -26,13 +26,13 @@ pub fn encode( .packet(packet.id) .ok_or(EncodeError::UnknownId(packet.id))?; - // Check that the packet is an order or data, as expected by the generic KindTag - if !Kind::matches(&packet_def.kind) { - return Err(EncodeError::WrongKind { id: packet.id }); + // Check that the packet is an order or data, as expected by the generic PacketTypeTag + if !Kind::matches(&packet_def.packet_type) { + return Err(EncodeError::WrongPacketType { id: packet.id }); } // Get the measurements for this packet - let measurements = packet_def.kind.measurements(); + let measurements = packet_def.packet_type.measurements(); // Check that the number of values that we want to encode matches the number of measurements in the packet definition if packet.values.len() != measurements.len() { @@ -60,7 +60,7 @@ pub fn encode( /// Decodes a complete, already-delimited buffer into a /// [`DataOrderPacket`], resolving its field shapes against `index`. -pub fn decode( +pub fn decode( bytes: &[u8], index: &Index, ) -> Result, DecodeError> { @@ -80,12 +80,12 @@ pub fn decode( // Look up the packet definition for this id if not found return an UnknownId error let packet_def = index.packet(adj_id).ok_or(DecodeError::UnknownId(adj_id))?; - // Check that the packet is an order or data, as expected by the generic KindTag - if !Kind::matches(&packet_def.kind) { - return Err(DecodeError::WrongKind { id: adj_id }); + // Check that the packet is an order or data, as expected by the generic PacketTypeTag + if !Kind::matches(&packet_def.packet_type) { + return Err(DecodeError::WrongPacketType { id: adj_id }); } // Get the measurements for this packet - let measurements = packet_def.kind.measurements(); + let measurements = packet_def.packet_type.measurements(); // Prepare a vector to hold the decoded values and a pointer to track the current position in the byte slice let mut values = Vec::with_capacity(measurements.len()); @@ -117,7 +117,7 @@ mod tests { use super::*; use crate::model::{ Board, BoardId, Data, DataPacket, MacAddress, Measurement, MeasurementKind, NumericKind, - Order, OrderPacket, PacketDef, PacketKind, PodData, Value, + Order, OrderPacket, PacketDef, PacketType, PodData, Value, }; use std::collections::HashMap; use std::net::IpAddr; @@ -135,12 +135,12 @@ mod tests { } } - fn sample_packet_def(id: AdjId, kind: PacketKind) -> PacketDef { + fn sample_packet_def(id: AdjId, packet_type: PacketType) -> PacketDef { PacketDef { id, board: BoardId(1), name: "TestPacket".into(), - kind, + packet_type, } } @@ -166,7 +166,7 @@ mod tests { let order_id = AdjId(1001); let packet_def = sample_packet_def( order_id, - PacketKind::Order(vec![ + PacketType::Order(vec![ sample_measurement( 1, MeasurementKind::Numeric { @@ -208,17 +208,17 @@ mod tests { } #[test] - fn encode_fails_on_wrong_kind() { + fn encode_fails_on_wrong_packet_type() { let data_id = AdjId(2001); - let packet_def = sample_packet_def(data_id, PacketKind::Data(vec![])); + let packet_def = sample_packet_def(data_id, PacketType::Data(vec![])); let index = make_test_index(vec![packet_def]); let packet = OrderPacket::new(data_id, vec![]); let Err(err) = encode(&packet, &index) else { - unreachable!("encoding the wrong packet kind should fail"); + unreachable!("encoding the wrong packet type should fail"); }; - assert_eq!(err, EncodeError::WrongKind { id: data_id }); + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); } #[test] @@ -226,7 +226,7 @@ mod tests { let order_id = AdjId(1002); let packet_def = sample_packet_def( order_id, - PacketKind::Order(vec![ + PacketType::Order(vec![ sample_measurement( 1, MeasurementKind::Numeric { @@ -264,7 +264,7 @@ mod tests { let data_id = AdjId(3001); let packet_def = sample_packet_def( data_id, - PacketKind::Data(vec![ + PacketType::Data(vec![ sample_measurement( 1, MeasurementKind::Numeric { @@ -312,7 +312,7 @@ mod tests { let data_id = AdjId(3002); let packet_def = sample_packet_def( data_id, - PacketKind::Data(vec![sample_measurement( + PacketType::Data(vec![sample_measurement( 1, MeasurementKind::Numeric { wire_type: NumericKind::U32, @@ -341,17 +341,17 @@ mod tests { } #[test] - fn decode_fails_on_wrong_kind() { + fn decode_fails_on_wrong_packet_type() { let order_id = AdjId(4001); - let packet_def = sample_packet_def(order_id, PacketKind::Order(vec![])); + let packet_def = sample_packet_def(order_id, PacketType::Order(vec![])); let index = make_test_index(vec![packet_def]); let raw_bytes = [0xA1, 0x0F]; let Err(err) = decode::(&raw_bytes, &index) else { - unreachable!("decoding the wrong packet kind should fail"); + unreachable!("decoding the wrong packet type should fail"); }; - assert_eq!(err, DecodeError::WrongKind { id: order_id }); + assert_eq!(err, DecodeError::WrongPacketType { id: order_id }); } // --- ROUND-TRIP TESTS --- @@ -359,7 +359,7 @@ mod tests { // `encode`/`decode` are generic over `Kind`, so the same `PacketDef` // works for both directions here — that genericity is the whole // point of giving `DataOrderPacket` a phantom `Kind` in the first - // place, instead of writing one hardcoded pair of functions per kind. + // place, instead of writing one hardcoded pair of functions per type. // // Enum fields are left out of both: `encode_enum` takes an // already-selected index (`Value::Number`) but `decode_enum` returns @@ -389,7 +389,7 @@ mod tests { let id = AdjId(6001); let index = make_test_index(vec![sample_packet_def( id, - PacketKind::Order(numeric_measurements()), + PacketType::Order(numeric_measurements()), )]); let original = OrderPacket::new(id, vec![Value::Number(1234.0), Value::Number(-5.0)]); @@ -409,7 +409,7 @@ mod tests { let id = AdjId(6002); let index = make_test_index(vec![sample_packet_def( id, - PacketKind::Data(numeric_measurements()), + PacketType::Data(numeric_measurements()), )]); let original = DataPacket::new(id, vec![Value::Number(42.0), Value::Number(-1.0)]); diff --git a/backend/crates/core/src/protocol/packet/message.rs b/backend/crates/core/src/protocol/packet/message.rs index b1b776a62..ec20e16e3 100644 --- a/backend/crates/core/src/protocol/packet/message.rs +++ b/backend/crates/core/src/protocol/packet/message.rs @@ -24,12 +24,12 @@ //! //! Unlike [`super::protection`] too, [`MessagePacket`] doesn't carry its //! own severity — `id` is resolved against `index` to confirm it's -//! actually a declared [`crate::model::PacketKind::Message`] (so a -//! `WrongKind`/`UnknownId` frame is rejected), but the severity itself -//! is never stored redundantly on the decoded instance; look it up via -//! `index.packet(id)` again if it's needed. +//! actually a declared [`crate::model::PacketType::Message`] (so a +//! `WrongPacketType`/`UnknownId` frame is rejected), but the severity +//! itself is never stored redundantly on the decoded instance; look it up +//! via `index.packet(id)` again if it's needed. -use crate::model::{AdjId, MessagePacket, PacketKind, ShortTimestamp}; +use crate::model::{AdjId, MessagePacket, PacketType, ShortTimestamp}; use crate::protocol::{DecodeError, EncodeError, Index}; use std::io::Read; @@ -58,8 +58,8 @@ pub fn decode( // message packet. If not, the frame is malformed and we can't decode it. let packet_def = index.packet(id).ok_or(DecodeError::UnknownId(id))?; - let PacketKind::Message(_) = &packet_def.kind else { - return Err(DecodeError::WrongKind { id }); + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id }); }; let mut header = [0u8; 4]; @@ -113,8 +113,8 @@ pub fn encode(packet: &MessagePacket, index: &Index) -> Result, EncodeEr .packet(packet.id) .ok_or(EncodeError::UnknownId(packet.id))?; - let PacketKind::Message(_) = &packet_def.kind else { - return Err(EncodeError::WrongKind { id: packet.id }); + let PacketType::Message(_) = &packet_def.packet_type else { + return Err(EncodeError::WrongPacketType { id: packet.id }); }; let mut bytes = packet.id.0.to_le_bytes().to_vec(); @@ -284,7 +284,7 @@ mod tests { id: data_id, board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![]), + packet_type: PacketType::Data(vec![]), }; let board = Board { id: BoardId(1), @@ -302,7 +302,7 @@ mod tests { let Err(err) = decode(Cursor::new(bytes), None, &index) else { unreachable!("decoding an id that resolves to a Data packet should fail"); }; - assert_eq!(err, DecodeError::WrongKind { id: data_id }); + assert_eq!(err, DecodeError::WrongPacketType { id: data_id }); } // --- ENCODE TESTS --- @@ -356,13 +356,13 @@ mod tests { } #[test] - fn encode_fails_on_wrong_kind() { + fn encode_fails_on_wrong_packet_type() { let data_id = AdjId(2000); let packet_def = PacketDef { id: data_id, board: BoardId(1), name: "bcu_data".to_string(), - kind: PacketKind::Data(vec![]), + packet_type: PacketType::Data(vec![]), }; let board = Board { id: BoardId(1), @@ -391,7 +391,7 @@ mod tests { let Err(err) = encode(&packet, &index) else { unreachable!("encoding into a non-message packet's id should fail"); }; - assert_eq!(err, EncodeError::WrongKind { id: data_id }); + assert_eq!(err, EncodeError::WrongPacketType { id: data_id }); } #[test] diff --git a/backend/crates/core/src/protocol/packet/protection.rs b/backend/crates/core/src/protocol/packet/protection.rs index fdb36768d..d3a4e1e4e 100644 --- a/backend/crates/core/src/protocol/packet/protection.rs +++ b/backend/crates/core/src/protocol/packet/protection.rs @@ -22,7 +22,7 @@ //! [`crate::protocol::wire::byte_width`] says for the watched //! measurement's kind — not fixed, unlike every other field here. -use crate::model::{AdjId, PacketKind, ProtectionPacket, ShortTimestamp}; +use crate::model::{AdjId, PacketType, ProtectionPacket, ShortTimestamp}; use crate::protocol::{DecodeError, EncodeError, Index, wire}; use std::io::Read; @@ -48,8 +48,8 @@ pub fn decode( .packet(compound_id) .ok_or(DecodeError::UnknownId(compound_id))?; - let PacketKind::Protection(measurement) = &packet_def.kind else { - return Err(DecodeError::WrongKind { id: compound_id }); + let PacketType::Protection(measurement) = &packet_def.packet_type else { + return Err(DecodeError::WrongPacketType { id: compound_id }); }; // `hora`/`min`/`seg`/`subseg` (1 byte each), then 2 bytes of `resv` @@ -88,8 +88,8 @@ pub fn encode(packet: &ProtectionPacket, index: &Index) -> Result, Encod .packet(packet.id) .ok_or(EncodeError::UnknownId(packet.id))?; - let PacketKind::Protection(measurement) = &packet_def.kind else { - return Err(EncodeError::WrongKind { id: packet.id }); + let PacketType::Protection(measurement) = &packet_def.packet_type else { + return Err(EncodeError::WrongPacketType { id: packet.id }); }; let mut bytes = packet.id.0.to_le_bytes().to_vec(); @@ -136,7 +136,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "mock_protection".to_string(), - kind: PacketKind::Protection(measurement), + packet_type: PacketType::Protection(measurement), }; let board = Board { id: BoardId(1), @@ -219,7 +219,7 @@ mod tests { } #[test] - fn a_non_protection_packet_at_that_id_is_the_wrong_kind() { + fn a_non_protection_packet_at_that_id_is_the_wrong_packet_type() { let measurement = sample_measurement( 200, MeasurementKind::Numeric { @@ -231,7 +231,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "mock_data".to_string(), - kind: PacketKind::Data(vec![measurement]), + packet_type: PacketType::Data(vec![measurement]), }; let board = Board { id: BoardId(1), @@ -247,7 +247,7 @@ mod tests { assert!(matches!( decode(Cursor::new(vec![]), Some(compound_id), &index), - Err(DecodeError::WrongKind { id }) if id == compound_id + Err(DecodeError::WrongPacketType { id }) if id == compound_id )); } @@ -364,7 +364,7 @@ mod tests { } #[test] - fn encode_fails_on_wrong_kind() { + fn encode_fails_on_wrong_packet_type() { let measurement = sample_measurement( 200, MeasurementKind::Numeric { @@ -376,7 +376,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "mock_data".to_string(), - kind: PacketKind::Data(vec![measurement]), + packet_type: PacketType::Data(vec![measurement]), }; let board = Board { id: BoardId(1), @@ -402,9 +402,9 @@ mod tests { }; let Err(err) = encode(&packet, &index) else { - unreachable!("encoding the wrong packet kind should fail"); + unreachable!("encoding the wrong packet type should fail"); }; - assert_eq!(err, EncodeError::WrongKind { id: compound_id }); + assert_eq!(err, EncodeError::WrongPacketType { id: compound_id }); } #[test] diff --git a/backend/crates/core/src/protocol/tcp.rs b/backend/crates/core/src/protocol/tcp.rs index a2d645944..d4becf3fb 100644 --- a/backend/crates/core/src/protocol/tcp.rs +++ b/backend/crates/core/src/protocol/tcp.rs @@ -3,7 +3,7 @@ //! orders to it. Never a `Data` packet — see the module doc on //! [`super`]. //! -//! Both functions here are thin: the actual per-kind decoding lives in +//! Both functions here are thin: the actual per-type decoding lives in //! [`super::packet::protection`]/[`super::packet::message`]/ //! [`super::packet::data_order`], independent of TCP. This module only adds //! the transport-level guarantee — [`decode_next`] can only ever return @@ -35,8 +35,9 @@ pub enum TcpFrame { /// position (see [`crate::model::AdjId`]'s own docs on its ranges), so /// `id.0 >= 8192` (`1 << 13`) is a protection; anything below that hands /// off to `message::decode` instead — `message::decode` resolves it -/// against `index` itself (`UnknownId`/`WrongKind` if it isn't actually -/// a declared message), so there's no separate lookup to do here first. +/// against `index` itself (`UnknownId`/`WrongPacketType` if it isn't +/// actually a declared message), so there's no separate lookup to do here +/// first. /// Either call's result is wrapped in the matching [`TcpFrame`] variant. pub fn decode_next(mut reader: impl Read, index: &Index) -> Result { let id = packet::read_id(&mut reader)?; @@ -60,7 +61,7 @@ mod tests { use super::*; use crate::model::{ AdjId, Board, BoardId, MacAddress, Measurement, MeasurementKind, MessageLevel, NumericKind, - PacketDef, PacketKind, PodData, ShortTimestamp, Value, + PacketDef, PacketType, PodData, ShortTimestamp, Value, }; use std::collections::HashMap; use std::io::Cursor; @@ -84,7 +85,7 @@ mod tests { id: compound_id, board: BoardId(1), name: "temp_protection".to_string(), - kind: PacketKind::Protection(measurement), + packet_type: PacketType::Protection(measurement), }; let board = Board { id: BoardId(1),