diff --git a/Cargo.lock b/Cargo.lock index e00f8f5..e3cd90f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ dependencies = [ [[package]] name = "akroasis" -version = "0.1.0" +version = "0.1.1" dependencies = [ "clap", "comfy-table", @@ -94,6 +94,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + [[package]] name = "argon2" version = "0.5.3" @@ -618,6 +624,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -684,6 +696,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "fjall" version = "3.1.1" @@ -716,6 +734,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "fs2" version = "0.4.3" @@ -776,6 +800,15 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -841,6 +874,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -899,8 +941,24 @@ dependencies = [ ] [[package]] -name = "koinon" +name = "kerykeion" version = "0.1.0" +dependencies = [ + "jiff", + "koinon", + "proptest", + "prost", + "prost-build", + "serde", + "snafu", + "tokio", + "toml", + "tracing", +] + +[[package]] +name = "koinon" +version = "0.1.1" dependencies = [ "blake3", "ciborium", @@ -917,7 +975,7 @@ dependencies = [ [[package]] name = "kryphos" -version = "0.1.0" +version = "0.1.1" dependencies = [ "argon2", "blake3", @@ -1034,6 +1092,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1133,6 +1197,17 @@ dependencies = [ "syn", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1184,6 +1259,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1225,6 +1310,57 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -1312,6 +1448,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1621,7 +1769,7 @@ dependencies = [ [[package]] name = "syntonia" -version = "0.1.0" +version = "0.1.1" dependencies = [ "compact_str", "csv", diff --git a/Cargo.toml b/Cargo.toml index 4941086..ba7691e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,3 +89,32 @@ rpassword = "7" # Testing tempfile = "3" + +# Mesh networking — serial +tokio-serial = { version = "5.4.5", default-features = false } +tokio-util = { version = "0.7.18", features = ["codec"] } +bytes = "1" + +# Mesh networking — BLE +btleplug = "0.12.0" +uuid = { version = "1", features = ["v4"] } + +# Mesh networking — protobuf +prost = "0.14.3" +prost-types = "0.14.3" + +# Mesh networking — crypto (pin exact: pre-release) +aes = "=0.9.0-rc.4" +ctr = "=0.10.0-rc.4" +x25519-dalek = { version = "2.0.1", features = ["static_secrets"] } + +# Mesh networking — graph +petgraph = { version = "0.8.3", features = ["serde-1"] } + +# Mesh networking — discovery +mdns-sd = "0.18.2" +rusb = "0.9.4" + +# Testing +proptest = "1" +nix = { version = "0.31.2", features = ["pty"] } diff --git a/crates/kerykeion/Cargo.toml b/crates/kerykeion/Cargo.toml new file mode 100644 index 0000000..abdb666 --- /dev/null +++ b/crates/kerykeion/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "kerykeion" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Meshtastic mesh networking integration for Akroasis" + +[lints] +workspace = true + +[dependencies] +koinon = { path = "../koinon" } +serde = { workspace = true } +snafu = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true, features = ["sync", "time", "macros"] } +jiff = { workspace = true } +toml = { workspace = true } +prost = { workspace = true } + +[build-dependencies] +prost-build = "0.14.3" + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util", "rt-multi-thread", "macros"] } +proptest = { workspace = true } diff --git a/crates/kerykeion/build.rs b/crates/kerykeion/build.rs new file mode 100644 index 0000000..3639d87 --- /dev/null +++ b/crates/kerykeion/build.rs @@ -0,0 +1,26 @@ +//! Build script for kerykeion: compiles Meshtastic protobuf definitions via prost-build. + +fn main() -> Result<(), Box> { + let proto_dir = "proto"; + let protos = [ + "proto/meshtastic/portnums.proto", + "proto/meshtastic/telemetry.proto", + "proto/meshtastic/channel.proto", + "proto/meshtastic/config.proto", + "proto/meshtastic/module_config.proto", + "proto/meshtastic/mesh.proto", + "proto/meshtastic/admin.proto", + "proto/meshtastic/storeforward.proto", + ]; + + prost_build::Config::new() + .type_attribute(".", "#[derive(serde::Serialize, serde::Deserialize)]") + .compile_protos(&protos, &[proto_dir])?; + + for proto in &protos { + println!("cargo:rerun-if-changed={proto}"); + } + println!("cargo:rerun-if-changed=build.rs"); + + Ok(()) +} diff --git a/crates/kerykeion/proto/meshtastic/admin.proto b/crates/kerykeion/proto/meshtastic/admin.proto new file mode 100644 index 0000000..9bfa686 --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/admin.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; +package meshtastic; + +import "meshtastic/mesh.proto"; +import "meshtastic/channel.proto"; +import "meshtastic/config.proto"; +import "meshtastic/module_config.proto"; + +// Administrative message for device configuration. +message AdminMessage { + oneof payload_variant { + string get_channel_request = 1; + Channel get_channel_response = 2; + string get_owner_request = 3; + User get_owner_response = 4; + Config get_config_response = 6; + ModuleConfig get_module_config_response = 8; + bool get_radio_request = 10; + bool factory_reset_device = 11; + bool exit_simulator = 12; + string note_data_received = 14; + bool reboot_ota_seconds = 16; + bool get_device_metadata_request = 17; + bool reboot_seconds = 18; + bool shutdown_seconds = 19; + Channel set_channel = 20; + Config set_config = 21; + ModuleConfig set_module_config = 22; + User set_owner = 23; + bool remove_by_nodenum = 24; + bool set_favorite_node = 25; + bool remove_favorite_node = 26; + bool set_fixed_position = 27; + bool remove_fixed_position = 28; + bool begin_edit_settings = 29; + bool commit_edit_settings = 30; + } +} diff --git a/crates/kerykeion/proto/meshtastic/channel.proto b/crates/kerykeion/proto/meshtastic/channel.proto new file mode 100644 index 0000000..1917a7d --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/channel.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +package meshtastic; + +// Channel role within the mesh. +enum Channel_Role { + DISABLED = 0; + PRIMARY = 1; + SECONDARY = 2; +} + +// Per-channel key material and settings. +message ChannelSettings { + uint32 channel_num = 1; + bytes psk = 4; + string name = 5; + uint32 id = 6; + bool uplink_enabled = 7; + bool downlink_enabled = 8; +} + +// A configured mesh channel. +message Channel { + int32 index = 1; + ChannelSettings settings = 2; + Channel_Role role = 3; +} diff --git a/crates/kerykeion/proto/meshtastic/config.proto b/crates/kerykeion/proto/meshtastic/config.proto new file mode 100644 index 0000000..cce61b4 --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/config.proto @@ -0,0 +1,74 @@ +syntax = "proto3"; +package meshtastic; + +// Device-level configuration. +message Config { + message DeviceConfig { + enum Role { + CLIENT = 0; + CLIENT_MUTE = 1; + ROUTER = 2; + ROUTER_CLIENT = 3; + REPEATER = 4; + TRACKER = 5; + SENSOR = 6; + TAK = 7; + CLIENT_HIDDEN = 8; + LOST_AND_FOUND = 9; + } + Role role = 1; + bool serial_enabled = 2; + bool debug_log_enabled = 3; + uint32 button_gpio = 4; + uint32 buzzer_gpio = 5; + bool rebroadcast_mode = 6; + uint32 node_info_broadcast_secs = 7; + bool double_tap_as_button_press = 8; + bool is_managed = 9; + bool disable_triple_click = 10; + string tzdef = 11; + bool led_heartbeat_disabled = 12; + } + + message LoRaConfig { + enum ModemPreset { + LONG_FAST = 0; + LONG_SLOW = 1; + VERY_LONG_SLOW = 2; + MEDIUM_SLOW = 3; + MEDIUM_FAST = 4; + SHORT_SLOW = 5; + SHORT_FAST = 6; + LONG_MODERATE = 7; + } + bool use_preset = 1; + ModemPreset modem_preset = 2; + uint32 bandwidth = 3; + uint32 spread_factor = 4; + uint32 coding_rate = 5; + float frequency_offset = 6; + uint32 region = 7; + uint32 hop_limit = 8; + bool tx_enabled = 9; + int32 tx_power = 10; + uint32 channel_num = 11; + bool override_duty_cycle = 12; + bool sx126x_rx_boosted_gain = 13; + float override_frequency = 14; + bool pa_fan_disabled = 15; + } + + message NetworkConfig { + bool wifi_enabled = 1; + string wifi_ssid = 3; + string wifi_psk = 4; + string ntp_server = 5; + bool eth_enabled = 6; + } + + oneof payload_variant { + DeviceConfig device = 1; + LoRaConfig lora = 6; + NetworkConfig network = 4; + } +} diff --git a/crates/kerykeion/proto/meshtastic/mesh.proto b/crates/kerykeion/proto/meshtastic/mesh.proto new file mode 100644 index 0000000..e26b165 --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/mesh.proto @@ -0,0 +1,212 @@ +syntax = "proto3"; +package meshtastic; + +import "meshtastic/portnums.proto"; +import "meshtastic/telemetry.proto"; +import "meshtastic/channel.proto"; + +// Hardware model identifiers. +enum HardwareModel { + UNSET = 0; + TLORA_V2 = 1; + TLORA_V1 = 2; + TLORA_V2_1_1P6 = 3; + TBEAM = 4; + HELTEC_V2_0 = 5; + TBEAM_V0P7 = 6; + T_ECHO = 7; + TLORA_V1_3 = 8; + RAK4631 = 9; + HELTEC_V2_1 = 10; + HELTEC_V1 = 11; + LILYGO_TBEAM_S3_CORE = 12; + RAK11200 = 13; + NANO_G1 = 14; + TLORA_V2_1_1P8 = 15; + TLORA_T3_S3 = 16; + NANO_G1_EXPLORER = 17; + NANO_G2_ULTRA = 18; + LORA_TYPE = 19; + WIPHONE = 20; + WIO_WM1110 = 21; + RAK2560 = 22; + HELTEC_HRU_3601 = 23; + STATION_G1 = 24; + STATION_G2 = 25; + LORA_RELAY_V1 = 26; + NRF52840DK = 27; + PPR = 28; + GENIEBLOCKS = 29; + NRF52_UNKNOWN = 30; + PORTDUINO = 31; + ANDROID_SIM = 32; + DIY_V1 = 33; + NRF52840_PCA10059 = 34; + DR_DEV = 35; + M5STACK = 36; + HELTEC_V3 = 37; + HELTEC_WSL_V3 = 38; + BETAFPV_2400_TX = 39; + BETAFPV_900_NANO_TX = 40; + RPI_PICO = 41; + HELTEC_WIRELESS_TRACKER = 42; + HELTEC_WIRELESS_PAPER = 43; + T_DECK = 44; + T_WATCH_S3 = 45; + PICOMPUTER_S3 = 46; + HELTEC_HT62 = 47; + EBYTE_ESP32_S3 = 48; + ESP32_S3_PICO = 49; + PRIVATE_HW = 255; +} + +// GPS position data. +message Position { + sint32 latitude_i = 1; + sint32 longitude_i = 2; + int32 altitude = 3; + uint32 time = 4; + uint32 location_source = 5; + uint32 altitude_source = 6; + uint32 timestamp = 7; + int32 timestamp_millis_adjust = 8; + int32 altitude_hae = 9; + int32 altitude_geoidal_separation = 10; + uint32 pdop = 11; + uint32 hdop = 12; + uint32 vdop = 13; + uint32 gps_accuracy = 14; + uint32 ground_speed = 15; + uint32 ground_track = 16; + uint32 fix_quality = 17; + uint32 fix_type = 18; + uint32 sats_in_view = 19; + uint32 sensor_id = 20; + uint32 next_update = 21; + uint32 seq_number = 22; + int32 precision_bits = 23; +} + +// User profile information for a mesh node. +message User { + string id = 1; + string long_name = 2; + string short_name = 3; + bytes macaddr = 4; + HardwareModel hw_model = 6; + bool is_licensed = 7; + uint32 role = 8; +} + +// Route discovery data for traceroute packets. +message RouteDiscovery { + repeated uint32 route = 2; + repeated int32 snr_towards = 3; + repeated uint32 back = 4; + repeated int32 snr_back = 5; +} + +// Routing error codes and route discovery payloads. +message Routing { + enum Error { + NONE = 0; + NO_ROUTE = 1; + GOT_NAK = 2; + TIMEOUT = 3; + NO_INTERFACE = 4; + MAX_RETRANSMIT = 5; + NO_CHANNEL = 6; + TOO_LARGE = 7; + NO_RESPONSE = 8; + DUTY_CYCLE_LIMIT = 9; + BAD_REQUEST = 32; + NOT_AUTHORIZED = 33; + } + oneof variant { + RouteDiscovery route_request = 1; + RouteDiscovery route_reply = 2; + Error error_reason = 3; + } +} + +// Application-layer data payload. +message Data { + PortNum portnum = 1; + bytes payload = 2; + bool want_response = 3; + uint32 dest = 4; + uint32 source = 5; + uint32 request_id = 6; + uint32 reply_id = 7; + bytes emoji = 8; +} + +// A packet on the mesh network. +message MeshPacket { + enum Priority { + UNSET = 0; + MIN = 1; + BACKGROUND = 10; + DEFAULT = 64; + RELIABLE = 70; + ACK = 120; + MAX = 127; + } + uint32 from = 1; + uint32 to = 2; + uint32 channel = 3; + oneof payload_variant { + Data decoded = 4; + bytes encrypted = 5; + } + uint32 id = 6; + uint32 rx_time = 7; + float rx_snr = 8; + uint32 hop_limit = 10; + bool want_ack = 11; + Priority priority = 12; + int32 rx_rssi = 13; + bool via_mqtt = 17; + uint32 hop_start = 18; +} + +// Information about a single mesh node. +message NodeInfo { + uint32 num = 1; + User user = 2; + Position position = 3; + float snr = 4; + uint32 last_heard = 5; + DeviceMetrics device_metrics = 6; + uint32 channel = 7; + bool via_mqtt = 8; + uint32 hops_away = 9; + bool is_favorite = 10; +} + +// Sent to the radio to initiate config sync. +message MyNodeInfo { + uint32 my_node_num = 1; +} + +// Message sent from the host to the radio. +message ToRadio { + oneof payload_variant { + MeshPacket packet = 1; + uint32 want_config_id = 3; + bool disconnect = 4; + } +} + +// Message received from the radio. +message FromRadio { + uint32 id = 1; + oneof payload_variant { + MeshPacket packet = 2; + MyNodeInfo my_info = 3; + NodeInfo node_info = 4; + uint32 config_complete_id = 8; + bool rebooted = 9; + Channel channel = 10; + } +} diff --git a/crates/kerykeion/proto/meshtastic/module_config.proto b/crates/kerykeion/proto/meshtastic/module_config.proto new file mode 100644 index 0000000..27f5a48 --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/module_config.proto @@ -0,0 +1,42 @@ +syntax = "proto3"; +package meshtastic; + +// Module-level configuration. +message ModuleConfig { + message MQTTConfig { + bool enabled = 1; + string address = 2; + string username = 3; + string password = 4; + bool encryption_enabled = 5; + bool json_enabled = 6; + bool tls_enabled = 7; + string root = 8; + bool proxy_to_client_enabled = 9; + bool map_reporting_enabled = 10; + } + + message SerialConfig { + bool enabled = 1; + bool echo = 2; + uint32 rxd = 3; + uint32 txd = 4; + uint32 baud = 5; + uint32 timeout = 6; + } + + message StoreForwardConfig { + bool enabled = 1; + bool heartbeat = 2; + uint32 records = 3; + uint32 history_return_max = 4; + uint32 history_return_window = 5; + bool is_server = 6; + } + + oneof payload_variant { + MQTTConfig mqtt = 1; + SerialConfig serial = 2; + StoreForwardConfig store_forward = 3; + } +} diff --git a/crates/kerykeion/proto/meshtastic/portnums.proto b/crates/kerykeion/proto/meshtastic/portnums.proto new file mode 100644 index 0000000..8388c93 --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/portnums.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +package meshtastic; + +// Application-level port numbers for Meshtastic data routing. +enum PortNum { + UNKNOWN_APP = 0; + TEXT_MESSAGE_APP = 1; + REMOTE_HARDWARE_APP = 2; + POSITION_APP = 3; + NODEINFO_APP = 4; + ROUTING_APP = 5; + ADMIN_APP = 6; + TEXT_MESSAGE_COMPRESSED_APP = 7; + WAYPOINT_APP = 8; + AUDIO_APP = 9; + DETECTION_SENSOR_APP = 10; + REPLY_APP = 32; + IP_TUNNEL_APP = 33; + SERIAL_APP = 64; + STORE_AND_FORWARD_APP = 65; + RANGE_TEST_APP = 66; + TELEMETRY_APP = 67; + ZPS_APP = 68; + SIMULATOR_APP = 69; + TRACEROUTE_APP = 70; + NEIGHBORINFO_APP = 71; + ATAK_PLUGIN = 72; + MAP_REPORT_APP = 73; + PRIVATE_APP = 256; + ATAK_FORWARDER = 257; + MAX = 511; +} diff --git a/crates/kerykeion/proto/meshtastic/storeforward.proto b/crates/kerykeion/proto/meshtastic/storeforward.proto new file mode 100644 index 0000000..2cbe8ab --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/storeforward.proto @@ -0,0 +1,50 @@ +syntax = "proto3"; +package meshtastic; + +import "meshtastic/mesh.proto"; + +// Store-and-forward server statistics. +message StoreAndForward { + enum RequestResponse { + UNSET = 0; + ROUTER_ERROR = 1; + ROUTER_HEARTBEAT = 2; + ROUTER_PING = 3; + ROUTER_PONG = 4; + ROUTER_BUSY = 5; + ROUTER_HISTORY = 6; + ROUTER_STATS = 7; + ROUTER_TEXT_DIRECT = 8; + ROUTER_TEXT_BROADCAST = 9; + CLIENT_ERROR = 100; + CLIENT_HISTORY = 101; + CLIENT_STATS = 102; + CLIENT_PONG = 103; + CLIENT_ABORT = 104; + } + + message Statistics { + uint32 messages_total = 1; + uint32 messages_saved = 2; + uint32 messages_max = 3; + uint32 up_time = 4; + uint32 requests = 5; + uint32 requests_history = 6; + bool is_heartbeat = 7; + uint32 max_packets = 8; + uint32 router_history_insert_error = 9; + } + + message History { + uint32 history_messages = 1; + uint32 window = 2; + uint32 last_request = 3; + } + + RequestResponse rr = 1; + oneof variant { + Statistics stats = 3; + History history = 4; + MeshPacket text = 5; + } +} diff --git a/crates/kerykeion/proto/meshtastic/telemetry.proto b/crates/kerykeion/proto/meshtastic/telemetry.proto new file mode 100644 index 0000000..1a7232e --- /dev/null +++ b/crates/kerykeion/proto/meshtastic/telemetry.proto @@ -0,0 +1,39 @@ +syntax = "proto3"; +package meshtastic; + +// Device hardware metrics reported via telemetry. +message DeviceMetrics { + uint32 battery_level = 1; + float voltage = 2; + float channel_utilization = 3; + float air_util_tx = 4; + uint32 uptime_seconds = 6; +} + +// Environmental sensor data. +message EnvironmentMetrics { + float temperature = 1; + float relative_humidity = 2; + float barometric_pressure = 3; + float gas_resistance = 4; + float voltage = 5; + float current = 6; + uint32 iaq = 7; + float distance = 8; + float lux = 9; + float white_lux = 10; + float ir_lux = 11; + float uv_lux = 12; + float wind_direction = 13; + float wind_speed = 14; + bool has_iaq = 15; +} + +// Container for all telemetry variants. +message Telemetry { + uint32 time = 1; + oneof variant { + DeviceMetrics device_metrics = 2; + EnvironmentMetrics environment_metrics = 3; + } +} diff --git a/crates/kerykeion/src/collector.rs b/crates/kerykeion/src/collector.rs new file mode 100644 index 0000000..763e368 --- /dev/null +++ b/crates/kerykeion/src/collector.rs @@ -0,0 +1,108 @@ +//! `MeshCollector` — kerykeion integration point for the Akroasis collection pipeline. +//! +//! `koinon::Collector` does not yet exist as a trait; `Collector` is defined locally +//! here until koinon is extended. See the Observations section in the P2-01 PR. + +use crate::{config::MeshConfig, error::Error}; + +/// Trait for Akroasis data collectors. +/// +/// Defines the minimal lifecycle interface shared across all collector crates. +/// This is a local definition pending the addition of `koinon::Collector`. +pub trait Collector: Send + Sync { + /// Returns the canonical name of this collector. + fn name(&self) -> &'static str; + + /// Returns `true` if the collector's hardware or network target is reachable. + /// + /// Used during startup to decide whether to activate the collector. + fn probe(&self) -> impl std::future::Future + Send; + + /// Starts the collection loop. + /// + /// Returns when the collector has shut down cleanly. + /// + /// # Errors + /// + /// Returns an error if the collector encounters a fatal, unrecoverable failure. + fn run(&mut self) -> impl std::future::Future> + Send; +} + +/// Meshtastic mesh networking collector. +/// +/// Manages connections to one or more Meshtastic radios, receives mesh packets, +/// maintains the node database, and forwards observations into the Akroasis pipeline. +pub struct MeshCollector { + config: MeshConfig, +} + +impl MeshCollector { + /// Creates a new `MeshCollector` with the given configuration. + #[must_use] + pub const fn new(config: MeshConfig) -> Self { + Self { config } + } +} + +impl Collector for MeshCollector { + fn name(&self) -> &'static str { + "kerykeion" + } + + async fn probe(&self) -> bool { + // WHY: stub — actual USB enumeration and TCP probe implemented in P2-02. + // Return true only if at least one connection is configured. + !self.config.connections.is_empty() + } + + async fn run(&mut self) -> Result<(), Error> { + // WHY: stub — full implementation in P2-02 (serial/TCP/BLE transports). + tracing::info!( + collector = self.name(), + "run() called (stub — not yet implemented)" + ); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ConnectionConfig, MeshConfig, StoreForwardConfig, TopologyConfig}; + + fn make_config(connections: Vec) -> MeshConfig { + MeshConfig { + connections, + channel_psk: vec![], + store_forward: StoreForwardConfig::default(), + topology: TopologyConfig::default(), + } + } + + #[test] + fn name_is_kerykeion() { + let c = MeshCollector::new(make_config(vec![])); + assert_eq!(c.name(), "kerykeion"); + } + + #[tokio::test] + async fn probe_false_when_no_connections() { + let c = MeshCollector::new(make_config(vec![])); + assert!(!c.probe().await); + } + + #[tokio::test] + async fn probe_true_when_connections_configured() { + let c = MeshCollector::new(make_config(vec![ConnectionConfig::Serial { + port: "/dev/ttyUSB0".into(), + baud: 115_200, + }])); + assert!(c.probe().await); + } + + #[tokio::test] + async fn run_returns_ok() { + let mut c = MeshCollector::new(make_config(vec![])); + assert!(c.run().await.is_ok()); + } +} diff --git a/crates/kerykeion/src/config.rs b/crates/kerykeion/src/config.rs new file mode 100644 index 0000000..0dbb3fe --- /dev/null +++ b/crates/kerykeion/src/config.rs @@ -0,0 +1,151 @@ +//! Configuration types for kerykeion mesh networking. + +use crate::types::ChannelIndex; +use serde::{Deserialize, Serialize}; + +/// Top-level kerykeion configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeshConfig { + /// Transport connections to Meshtastic radios. + pub connections: Vec, + /// Channel pre-shared keys for decryption. + pub channel_psk: Vec, + /// Store-and-forward server settings. + pub store_forward: StoreForwardConfig, + /// Topology maintenance settings. + pub topology: TopologyConfig, +} + +/// Transport backend for a single Meshtastic radio connection. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum ConnectionConfig { + /// USB serial connection. + Serial { + /// Serial port device path (e.g. `/dev/ttyUSB0`). + port: String, + /// Baud rate. Meshtastic uses `115200`. + baud: u32, + }, + /// TCP/IP connection (Meshtastic `WiFi` firmware). + Tcp { + /// Hostname or IP address. + addr: String, + /// TCP port number. Meshtastic default is `4403`. + port: u16, + }, + /// Bluetooth Low Energy GATT connection. + Ble { + /// Advertising name prefix of the target device. + device_name: String, + }, +} + +/// Pre-shared key material for a single mesh channel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelPsk { + /// Channel index this PSK applies to. + pub index: ChannelIndex, + /// Human-readable channel name. + pub name: String, + /// Raw PSK bytes. Must be 0, 16, or 32 bytes. + /// + /// - 0 bytes: no encryption (use only for public channels). + /// - 16 bytes: AES-128-CTR. + /// - 32 bytes: AES-256-CTR. + pub psk: Vec, +} + +/// Store-and-forward server configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoreForwardConfig { + /// Whether the store-and-forward feature is enabled on this node. + pub enabled: bool, + /// Maximum number of queued messages per destination node. + pub max_queue_per_dest: usize, + /// Number of seconds before a queued message expires. + pub message_ttl_secs: u64, +} + +impl Default for StoreForwardConfig { + fn default() -> Self { + Self { + enabled: false, + max_queue_per_dest: 16, + message_ttl_secs: 3600, + } + } +} + +/// Topology maintenance configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopologyConfig { + /// How often to request a traceroute, in seconds. + pub traceroute_interval_secs: u64, + /// Seconds after which a node with no packets is considered stale. + pub stale_node_timeout_secs: u64, + /// Whether to request neighbor info packets from the radio. + pub neighbor_info_enabled: bool, +} + +impl Default for TopologyConfig { + fn default() -> Self { + Self { + traceroute_interval_secs: 3600, + stale_node_timeout_secs: 7200, + neighbor_info_enabled: true, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE_TOML: &str = r#" +[[connections]] +Serial = { port = "/dev/ttyUSB0", baud = 115200 } + +[[connections]] +Tcp = { addr = "192.168.1.100", port = 4403 } + +[[channel_psk]] +index = 0 +name = "LongFast" +psk = [] + +[store_forward] +enabled = false +max_queue_per_dest = 16 +message_ttl_secs = 3600 + +[topology] +traceroute_interval_secs = 3600 +stale_node_timeout_secs = 7200 +neighbor_info_enabled = true +"#; + + #[test] + fn deserialize_mesh_config_from_toml() { + #[expect(clippy::unwrap_used, reason = "test-only: TOML is known valid")] + let cfg: MeshConfig = toml::from_str(SAMPLE_TOML).unwrap(); + assert_eq!(cfg.connections.len(), 2); + assert_eq!(cfg.channel_psk.len(), 1); + assert!(!cfg.store_forward.enabled); + assert!(cfg.topology.neighbor_info_enabled); + } + + #[test] + fn default_store_forward_config() { + let cfg = StoreForwardConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.max_queue_per_dest, 16); + } + + #[test] + fn default_topology_config() { + let cfg = TopologyConfig::default(); + assert!(cfg.neighbor_info_enabled); + assert_eq!(cfg.stale_node_timeout_secs, 7200); + } +} diff --git a/crates/kerykeion/src/connection.rs b/crates/kerykeion/src/connection.rs new file mode 100644 index 0000000..2ac6038 --- /dev/null +++ b/crates/kerykeion/src/connection.rs @@ -0,0 +1,39 @@ +//! Transport abstraction for Meshtastic radio connections. + +use crate::error::Error; + +/// Uniform interface over serial, TCP, and BLE Meshtastic transports. +/// +/// Implementations are in P2-02. This trait defines the contract only. +// WHY: Rust 2024 native async-fn-in-traits is intentional here; no async-trait crate used. +#[expect( + async_fn_in_trait, + reason = "Rust 2024 native async fn in traits is intentional; implementations are Send" +)] +pub trait MeshConnection: Send + Sync { + /// Send a raw protobuf-encoded packet to the radio. + /// + /// # Errors + /// + /// Returns [`Error::SerialIo`], [`Error::SendFailed`], or a transport-specific + /// error if the write fails. + async fn send(&mut self, packet: &[u8]) -> Result<(), Error>; + + /// Receive the next raw protobuf-encoded packet from the radio. + /// + /// # Errors + /// + /// Returns [`Error::SerialIo`], [`Error::ConnectionLost`], or a + /// transport-specific error if the read fails. + async fn recv(&mut self) -> Result, Error>; + + /// Returns `true` if the transport is currently connected. + fn is_connected(&self) -> bool; + + /// Attempt to re-establish a dropped connection. + /// + /// # Errors + /// + /// Returns a connection error if reconnection fails. + async fn reconnect(&mut self) -> Result<(), Error>; +} diff --git a/crates/kerykeion/src/error.rs b/crates/kerykeion/src/error.rs new file mode 100644 index 0000000..f9b5ed2 --- /dev/null +++ b/crates/kerykeion/src/error.rs @@ -0,0 +1,193 @@ +//! Error types for kerykeion mesh networking operations. + +use snafu::Snafu; + +/// All errors produced by kerykeion operations. +#[derive(Debug, Snafu)] +#[non_exhaustive] +pub enum Error { + /// Failed to open a serial port connection. + #[snafu(display("failed to open serial port {port}: {source}"))] + SerialConnect { + /// Underlying I/O error. + source: std::io::Error, + /// Serial port path (e.g. `/dev/ttyUSB0`). + port: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Serial read or write failed. + #[snafu(display("serial I/O error: {source}"))] + SerialIo { + /// Underlying I/O error. + source: std::io::Error, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// TCP connection to a Meshtastic node failed. + #[snafu(display("TCP connection to {addr} failed: {source}"))] + TcpConnect { + /// Underlying I/O error. + source: std::io::Error, + /// Address that was dialed. + addr: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// BLE connection to a named device failed. + #[snafu(display("BLE connection to device '{device}' failed"))] + BleConnect { + /// Device name or address. + device: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Received a frame with an invalid magic header or out-of-range length. + #[snafu(display("invalid frame: {detail}"))] + FrameDecode { + /// Human-readable description of the decode failure. + detail: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Protobuf deserialization failed. + #[snafu(display("protobuf decode error: {source}"))] + ProtobufDecode { + /// Underlying prost error. + source: prost::DecodeError, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Protobuf serialization failed. + #[snafu(display("protobuf encode error: {source}"))] + ProtobufEncode { + /// Underlying prost error. + source: prost::EncodeError, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// AES-CTR encryption or decryption failed. + #[snafu(display("encryption/decryption error: {detail}"))] + Encryption { + /// Human-readable description of the failure. + detail: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Channel index is outside the valid range `0..MAX_CHANNELS`. + #[snafu(display("channel index {index} is out of range (max {})", crate::types::MAX_CHANNELS - 1))] + InvalidChannel { + /// The invalid index that was provided. + index: u8, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Hop limit exceeds the protocol maximum. + #[snafu(display( + "hop limit {hop_limit} exceeds maximum {}", + crate::types::MAX_HOP_LIMIT + ))] + InvalidHopLimit { + /// The invalid hop limit that was provided. + hop_limit: u8, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The requested node number is not in the `NodeDb`. + #[snafu(display("node {node_num:#010x} not found in NodeDb"))] + NodeNotFound { + /// The node number that was looked up. + node_num: u32, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Config handshake with the radio did not complete. + #[snafu(display("config handshake failed: {detail}"))] + HandshakeFailed { + /// Human-readable description of the failure. + detail: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// The transport connection dropped unexpectedly. + #[snafu(display("connection lost: {detail}"))] + ConnectionLost { + /// Human-readable description of why the connection was lost. + detail: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, + + /// Packet send failed due to a routing error. + #[snafu(display("packet send failed: {detail}"))] + SendFailed { + /// Human-readable description of the routing error. + detail: String, + /// Source location for diagnostics. + #[snafu(implicit)] + location: snafu::Location, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use snafu::ResultExt as _; + + fn make_io_error() -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken") + } + + #[test] + fn serial_connect_error_chain() { + let result: Result<(), Error> = Err(make_io_error()).context(SerialConnectSnafu { + port: "/dev/ttyUSB0", + }); + #[expect(clippy::unwrap_used, reason = "test-only: we expect an error")] + let err = result.unwrap_err(); + assert!(err.to_string().contains("/dev/ttyUSB0")); + } + + #[test] + fn invalid_channel_message() { + let err = Error::InvalidChannel { + index: 9, + location: snafu::Location::new(file!(), line!(), column!()), + }; + assert!(err.to_string().contains('9')); + } + + #[test] + fn node_not_found_message() { + let err = Error::NodeNotFound { + node_num: 0xDEAD_BEEF, + location: snafu::Location::new(file!(), line!(), column!()), + }; + assert!(err.to_string().contains("0xdeadbeef")); + } +} diff --git a/crates/kerykeion/src/lib.rs b/crates/kerykeion/src/lib.rs new file mode 100644 index 0000000..783d8ab --- /dev/null +++ b/crates/kerykeion/src/lib.rs @@ -0,0 +1,115 @@ +//! κηρύκειον — Meshtastic mesh networking integration for Akroasis. +//! +//! This crate provides: +//! - Protobuf types generated from vendored Meshtastic `.proto` files +//! - Core mesh types: [`types::NodeNum`], [`types::PacketId`], [`types::ChannelIndex`] +//! - Configuration: [`config::MeshConfig`] with TOML deserialization +//! - Transport abstraction: [`connection::MeshConnection`] trait +//! - Node tracking: [`node_db::NodeDb`] +//! - Collection pipeline integration: [`collector::MeshCollector`] +//! +//! Protocol implementations (serial, TCP, BLE) are added in P2-02. + +pub mod collector; +pub mod config; +pub mod connection; +pub mod error; +pub mod node_db; +pub mod types; + +// WHY: generated protobuf code cannot be annotated; allow all lints on this module. +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + missing_docs, + dead_code, + unused +)] +pub(crate) mod proto { + include!(concat!(env!("OUT_DIR"), "/meshtastic.rs")); +} + +pub use collector::{Collector, MeshCollector}; +pub use config::{ChannelPsk, ConnectionConfig, MeshConfig, StoreForwardConfig, TopologyConfig}; +pub use error::Error; +pub use node_db::{DeviceMetrics, MeshNode, NodeDb, NodePosition, UserInfo}; +pub use types::{ + BROADCAST_ADDR, ChannelIndex, FRAME_MAGIC, MAX_CHANNELS, MAX_HOP_LIMIT, MAX_PACKET_SIZE, + NodeNum, PacketId, +}; + +#[cfg(test)] +mod tests { + use prost::Message as _; + + use crate::proto::{Data, FromRadio, MeshPacket, ToRadio, from_radio, mesh_packet, to_radio}; + + fn make_mesh_packet() -> MeshPacket { + MeshPacket { + from: 0xDEAD_BEEF, + to: 0xFFFF_FFFF, + channel: 0, + id: 0x1234_5678, + hop_limit: 3, + want_ack: false, + via_mqtt: false, + rx_snr: 4.5, + rx_rssi: -90, + hop_start: 3, + priority: 0, + rx_time: 0, + payload_variant: Some(mesh_packet::PayloadVariant::Decoded(Data { + portnum: 1, // TEXT_MESSAGE_APP + payload: b"hello mesh".to_vec(), + want_response: false, + dest: 0, + source: 0, + request_id: 0, + reply_id: 0, + emoji: vec![], + })), + } + } + + #[test] + fn mesh_packet_encode_decode_roundtrip() { + let original = make_mesh_packet(); + let mut buf = Vec::new(); + #[expect(clippy::unwrap_used, reason = "test-only: encoding known-valid packet")] + original.encode(&mut buf).unwrap(); + #[expect(clippy::unwrap_used, reason = "test-only: decoding just-encoded bytes")] + let decoded = MeshPacket::decode(buf.as_slice()).unwrap(); + assert_eq!(decoded.from, original.from); + assert_eq!(decoded.to, original.to); + assert_eq!(decoded.id, original.id); + } + + #[test] + fn to_radio_encode_decode_roundtrip() { + let original = ToRadio { + payload_variant: Some(to_radio::PayloadVariant::Packet(make_mesh_packet())), + }; + let mut buf = Vec::new(); + #[expect(clippy::unwrap_used, reason = "test-only: encoding known-valid packet")] + original.encode(&mut buf).unwrap(); + #[expect(clippy::unwrap_used, reason = "test-only: decoding just-encoded bytes")] + let decoded = ToRadio::decode(buf.as_slice()).unwrap(); + assert!(decoded.payload_variant.is_some()); + } + + #[test] + fn from_radio_encode_decode_roundtrip() { + let original = FromRadio { + id: 42, + payload_variant: Some(from_radio::PayloadVariant::Packet(make_mesh_packet())), + }; + let mut buf = Vec::new(); + #[expect(clippy::unwrap_used, reason = "test-only: encoding known-valid packet")] + original.encode(&mut buf).unwrap(); + #[expect(clippy::unwrap_used, reason = "test-only: decoding just-encoded bytes")] + let decoded = FromRadio::decode(buf.as_slice()).unwrap(); + assert_eq!(decoded.id, 42); + assert!(decoded.payload_variant.is_some()); + } +} diff --git a/crates/kerykeion/src/node_db.rs b/crates/kerykeion/src/node_db.rs new file mode 100644 index 0000000..c6d5768 --- /dev/null +++ b/crates/kerykeion/src/node_db.rs @@ -0,0 +1,204 @@ +//! In-memory database of known mesh nodes. + +use crate::types::NodeNum; +use jiff::Timestamp; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// In-memory store of all mesh nodes seen during a session. +#[derive(Debug, Default)] +pub struct NodeDb { + nodes: HashMap, + my_node: Option, +} + +/// A node observed on the mesh network. +#[derive(Debug, Clone)] +pub struct MeshNode { + /// Node number (last 4 bytes of MAC address). + pub num: NodeNum, + /// User profile if a `NODEINFO_APP` packet has been received. + pub user: Option, + /// Last known GPS position, if any. + pub position: Option, + /// Last reported device metrics (battery, channel utilization). + pub metrics: Option, + /// Time of the most recent packet from this node. + pub last_heard: Option, + /// Signal-to-noise ratio of the most recent packet, in dB. + pub snr: Option, + /// Number of hops away from the gateway node. + pub hop_count: Option, +} + +/// User profile information for a mesh node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserInfo { + /// Short unique node ID string (e.g. `!deadbeef`). + pub id: String, + /// Long display name. + pub long_name: String, + /// Short display name (up to 4 characters). + pub short_name: String, + /// Hardware model identifier. + pub hw_model: u32, + /// Whether the user holds an amateur radio licence. + pub is_licensed: bool, +} + +/// GPS position snapshot for a mesh node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodePosition { + /// Latitude in decimal degrees. + pub latitude: f64, + /// Longitude in decimal degrees. + pub longitude: f64, + /// Altitude in metres above sea level, if reported. + pub altitude: Option, + /// Time the position fix was taken. + pub timestamp: Option, +} + +/// Device hardware metrics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceMetrics { + /// Battery level as a percentage (0–100), if reported. + pub battery_level: Option, + /// Battery voltage in volts, if reported. + pub voltage: Option, + /// Fraction of airtime used by this node's channel, if reported. + pub channel_utilization: Option, + /// Fraction of airtime used for TX, if reported. + pub air_util_tx: Option, +} + +impl NodeDb { + /// Creates an empty `NodeDb`. + #[must_use] + pub fn new() -> Self { + Self { + nodes: std::collections::HashMap::new(), + my_node: None, + } + } + + /// Inserts or replaces a node record. + pub fn insert(&mut self, node: MeshNode) { + self.nodes.insert(node.num, node); + } + + /// Returns a reference to the node with the given number, if present. + #[must_use] + pub fn get(&self, num: NodeNum) -> Option<&MeshNode> { + self.nodes.get(&num) + } + + /// Removes and returns the node with the given number, if present. + pub fn remove(&mut self, num: NodeNum) -> Option { + self.nodes.remove(&num) + } + + /// Returns an iterator over all nodes in the database. + pub fn iter(&self) -> impl Iterator { + self.nodes.iter() + } + + /// Returns the number of nodes in the database. + #[must_use] + pub fn len(&self) -> usize { + self.nodes.len() + } + + /// Returns `true` if the database contains no nodes. + #[must_use] + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() + } + + /// Sets the node number of the local radio. + pub const fn set_my_node(&mut self, num: NodeNum) { + self.my_node = Some(num); + } + + /// Returns the node number of the local radio, if known. + #[must_use] + pub const fn my_node(&self) -> Option { + self.my_node + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_node(num: u32) -> MeshNode { + MeshNode { + num: NodeNum(num), + user: None, + position: None, + metrics: None, + last_heard: None, + snr: None, + hop_count: None, + } + } + + #[test] + fn insert_and_get() { + let mut db = NodeDb::new(); + db.insert(make_node(0x1234)); + assert!(db.get(NodeNum(0x1234)).is_some()); + } + + #[test] + fn remove_existing_node() { + let mut db = NodeDb::new(); + db.insert(make_node(0xABCD)); + let removed = db.remove(NodeNum(0xABCD)); + assert!(removed.is_some()); + assert!(db.get(NodeNum(0xABCD)).is_none()); + } + + #[test] + fn remove_missing_node_returns_none() { + let mut db = NodeDb::new(); + assert!(db.remove(NodeNum(0xDEAD)).is_none()); + } + + #[test] + fn iter_returns_all_nodes() { + let mut db = NodeDb::new(); + db.insert(make_node(1)); + db.insert(make_node(2)); + db.insert(make_node(3)); + assert_eq!(db.iter().count(), 3); + } + + #[test] + fn len_and_is_empty() { + let mut db = NodeDb::new(); + assert!(db.is_empty()); + db.insert(make_node(42)); + assert_eq!(db.len(), 1); + assert!(!db.is_empty()); + } + + #[test] + fn my_node_roundtrip() { + let mut db = NodeDb::new(); + assert!(db.my_node().is_none()); + db.set_my_node(NodeNum(0xCAFE_BABE)); + assert_eq!(db.my_node(), Some(NodeNum(0xCAFE_BABE))); + } + + #[test] + fn insert_replaces_existing() { + let mut db = NodeDb::new(); + db.insert(make_node(1)); + let mut updated = make_node(1); + updated.snr = Some(4.5); + db.insert(updated); + assert_eq!(db.len(), 1); + assert_eq!(db.get(NodeNum(1)).and_then(|n| n.snr), Some(4.5)); + } +} diff --git a/crates/kerykeion/src/types.rs b/crates/kerykeion/src/types.rs new file mode 100644 index 0000000..14202e4 --- /dev/null +++ b/crates/kerykeion/src/types.rs @@ -0,0 +1,136 @@ +//! Core mesh networking type primitives. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Node number: the last four bytes of the node's MAC address, encoded as a `u32`. +/// +/// `0xFFFF_FFFF` is the broadcast address. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct NodeNum(pub u32); + +/// Packet identifier: a random 32-bit value assigned by the sender. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PacketId(pub u32); + +/// Channel index in the range `0..MAX_CHANNELS`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ChannelIndex(pub u8); + +impl NodeNum { + /// Returns the broadcast node number (`0xFFFF_FFFF`). + #[must_use] + pub const fn broadcast() -> Self { + BROADCAST_ADDR + } + + /// Returns `true` if this is the broadcast address. + #[must_use] + pub const fn is_broadcast(self) -> bool { + self.0 == BROADCAST_ADDR.0 + } +} + +impl ChannelIndex { + /// Constructs a `ChannelIndex`, returning an error if `index >= MAX_CHANNELS`. + /// + /// # Errors + /// + /// Returns [`crate::error::Error::InvalidChannel`] if `index >= MAX_CHANNELS`. + pub fn new(index: u8) -> Result { + if index < MAX_CHANNELS { + Ok(Self(index)) + } else { + Err(crate::error::Error::InvalidChannel { + index, + location: snafu::Location::new(file!(), line!(), column!()), + }) + } + } +} + +impl fmt::Display for NodeNum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#010x}", self.0) + } +} + +impl fmt::Display for PacketId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#010x}", self.0) + } +} + +impl fmt::Display for ChannelIndex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Broadcast destination: all nodes on the mesh. +pub const BROADCAST_ADDR: NodeNum = NodeNum(0xFFFF_FFFF); + +/// Maximum number of channels a Meshtastic device supports. +pub const MAX_CHANNELS: u8 = 8; + +/// Maximum hop limit for a mesh packet. +pub const MAX_HOP_LIMIT: u8 = 7; + +/// Maximum protobuf payload size enforced by Meshtastic firmware. +pub const MAX_PACKET_SIZE: usize = 512; + +/// Two-byte magic header that begins every Meshtastic serial frame. +pub const FRAME_MAGIC: [u8; 2] = [0x94, 0xC3]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn broadcast_addr_value() { + assert_eq!(BROADCAST_ADDR.0, 0xFFFF_FFFF); + } + + #[test] + fn node_num_is_broadcast() { + assert!(BROADCAST_ADDR.is_broadcast()); + assert!(!NodeNum(0x1234_5678).is_broadcast()); + } + + #[test] + fn node_num_display() { + assert_eq!(NodeNum(0xABCD_1234).to_string(), "0xabcd1234"); + } + + #[test] + fn packet_id_display() { + assert_eq!(PacketId(1).to_string(), "0x00000001"); + } + + #[test] + fn channel_index_valid() { + assert!(ChannelIndex::new(0).is_ok()); + assert!(ChannelIndex::new(7).is_ok()); + } + + #[test] + fn channel_index_out_of_range() { + assert!(ChannelIndex::new(8).is_err()); + assert!(ChannelIndex::new(255).is_err()); + } + + #[test] + fn channel_index_display() { + #[expect(clippy::unwrap_used, reason = "test-only: value is known valid")] + let s = ChannelIndex::new(3).unwrap().to_string(); + assert_eq!(s, "3"); + } + + #[test] + fn frame_constants() { + assert_eq!(FRAME_MAGIC, [0x94, 0xC3]); + assert_eq!(MAX_PACKET_SIZE, 512); + assert_eq!(MAX_CHANNELS, 8); + assert_eq!(MAX_HOP_LIMIT, 7); + } +}