diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 93c51a572..ff03a5ede 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -186,6 +186,7 @@ codec_2_enabled codec2_sample_rate coding_rate collapse_chart +collapsed communicate_off_the_grid ### COMPASS ### compass_bearing @@ -316,6 +317,9 @@ device_configuration device_db_cache_limit device_db_cache_limit_summary device_gps +device_links +device_links_i_want_one +device_links_open_in_browser device_metrics_label_value device_metrics_log device_metrics_numeric_value @@ -433,6 +437,7 @@ event_welcome_hamvention event_welcome_open_sauce exchange_position expand_chart +expanded expires ### EXPORT ### export_configuration diff --git a/androidApp/src/main/assets/marketplaces.json b/androidApp/src/main/assets/marketplaces.json new file mode 100644 index 000000000..49feb5c99 --- /dev/null +++ b/androidApp/src/main/assets/marketplaces.json @@ -0,0 +1,126 @@ +{ + "rokland": { + "regions": [ + "AU", + "AT", + "BE", + "CA", + "DK", + "EC", + "FR", + "DE", + "IE", + "JP", + "NL", + "NZ", + "NO", + "PK", + "ES", + "SE", + "CH", + "GB", + "US" + ], + "match": "prefix" + }, + "hexaspot": { + "regions": [ + "AT", + "BE", + "BG", + "CY", + "CZ", + "DE", + "DK", + "EE", + "ES", + "FI", + "FR", + "GR", + "HR", + "HU", + "IE", + "IT", + "LT", + "LU", + "LV", + "MT", + "NL", + "NO", + "PL", + "PT", + "RO", + "SE", + "SI", + "SK" + ], + "match": "prefix" + }, + "aliexpress": { + "regions": [], + "match": "suffix" + }, + "amazon": { + "regions": [ + "AU", + "CA", + "FR", + "DE", + "IE", + "JP", + "NL", + "ES", + "SE", + "GB", + "US" + ], + "match": "suffix" + }, + "tindie": { + "regions": [ + "US", + "CA", + "GB", + "DE", + "FR", + "AU", + "NL" + ], + "match": "suffix" + }, + "muzi": { + "regions": [ + "AU", + "AT", + "BE", + "CA", + "CZ", + "DK", + "FI", + "FR", + "DE", + "HK", + "IN", + "IE", + "IL", + "IT", + "JP", + "MY", + "NL", + "NZ", + "NO", + "PL", + "PT", + "SG", + "KR", + "ES", + "SE", + "CH", + "TW", + "AE", + "GB", + "US" + ], + "match": "prefix" + } +} \ No newline at end of file diff --git a/androidApp/src/main/assets/urls.json b/androidApp/src/main/assets/urls.json new file mode 100644 index 000000000..2b02b3fe6 --- /dev/null +++ b/androidApp/src/main/assets/urls.json @@ -0,0 +1,1009 @@ +{ + "Routes": [ + { + "ShortCode": "github", + "OriginalUrl": "https://github.com/meshtastic", + "Description": "Meshtastic GitHub Organization" + }, + { + "ShortCode": "youtube", + "OriginalUrl": "https://www.youtube.com/meshtastic", + "Description": "Meshtastic YouTube Channel" + }, + { + "ShortCode": "reddit", + "OriginalUrl": "https://www.reddit.com/r/meshtastic", + "Description": "Meshtastic Reddit Community" + }, + { + "ShortCode": "docs", + "OriginalUrl": "https://meshtastic.org/docs/", + "Description": "Meshtastic Documentation" + }, + { + "ShortCode": "discord", + "OriginalUrl": "https://discord.gg/meshtastic", + "Description": "Meshtastic Discord Server" + }, + { + "ShortCode": "web", + "OriginalUrl": "https://client.meshtastic.org/", + "Description": "Meshtastic Web Client" + }, + { + "ShortCode": "flash", + "OriginalUrl": "https://flasher.meshtastic.org/", + "Description": "Meshtastic Web Flasher" + }, + { + "ShortCode": "firmware", + "OriginalUrl": "https://github.com/meshtastic/firmware", + "Description": "Meshtastic Firmware Repository" + }, + { + "ShortCode": "android", + "OriginalUrl": "https://play.google.com/store/apps/details?id=com.geeksville.mesh", + "Description": "Meshtastic Android App" + }, + { + "ShortCode": "ios", + "OriginalUrl": "https://apple.co/3Auysep", + "Description": "Meshtastic iOS App" + }, + { + "ShortCode": "rak-collection", + "OriginalUrl": "https://store.rakwireless.com/collections/meshtastic?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAKwireless Meshtastic Collection" + }, + { + "ShortCode": "rak4631", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-meshtastic-starter-kit?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh RAK4631 Starter Kit" + }, + { + "ShortCode": "rak3312", + "OriginalUrl": "https://store.rakwireless.com/products/meshtastic-starter-kit-esp32-s3-lora-sx1262?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh ESP32-S3 Starter Kit" + }, + { + "ShortCode": "rak3401-1watt", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-1w-booster-starter-kit?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh RAK3401 1W Starter Kit" + }, + { + "ShortCode": "rak_wismeshtap", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-tap?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Tap" + }, + { + "ShortCode": "rak_wismeshtag", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-tag-meshtastic-gps-lora-tracker-ip66?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Tag" + }, + { + "ShortCode": "rokland-wismesh-tag", + "OriginalUrl": "https://store.rokland.com/products/wismesh-tag-from-rakwireless-mokosmart-meshtastic-compatible-card-sized-node-us915-mhz", + "Description": "Rokland WisMesh Tag" + }, + { + "ShortCode": "hexaspot-wismesh-tag", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/wismesh-tag", + "Description": "Hexaspot WisMesh Tag" + }, + { + "ShortCode": "aliexpress-wismesh-tag", + "OriginalUrl": "https://www.aliexpress.com/item/1005009754254701.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "Aliexpress RAK WisMesh Tag" + }, + { + "ShortCode": "rak19007", + "OriginalUrl": "https://store.rakwireless.com/products/rak19007-wisblock-base-board-2nd-gen?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAKwireless RAK19007 WisBlock Base Board 2nd Gen" + }, + { + "ShortCode": "tbeam-s3-core", + "OriginalUrl": "https://lilygo.cc/products/t-beam-supreme-meshtastic", + "Description": "T-Beam Supreme" + }, + { + "ShortCode": "t-echo", + "OriginalUrl": "https://lilygo.cc/products/t-echo-meshtastic", + "Description": "T-Echo" + }, + { + "ShortCode": "t-watch-s3", + "OriginalUrl": "https://lilygo.cc/products/t-watch-s3", + "Description": "T-Watch S3" + }, + { + "ShortCode": "t-deck", + "OriginalUrl": "https://lilygo.cc/products/t-deck-meshtastic", + "Description": "T-Deck" + }, + { + "ShortCode": "tlora-t3s3-v1", + "OriginalUrl": "https://lilygo.cc/products/t3-s3-meshtastic", + "Description": "T3S3" + }, + { + "ShortCode": "heltec-mesh-node-t114", + "OriginalUrl": "https://heltec.org/project/mesh-node-t114/", + "Description": "Mesh Node T114" + }, + { + "ShortCode": "heltec-vision-master-e213", + "OriginalUrl": "https://heltec.org/project/vision-master-e213/", + "Description": "Vision Master E213" + }, + { + "ShortCode": "heltec-vision-master-e290", + "OriginalUrl": "https://heltec.org/project/vision-master-e290/", + "Description": "Vision Master E290" + }, + { + "ShortCode": "heltec-vision-master-t190", + "OriginalUrl": "https://heltec.org/project/vision-master-t190/", + "Description": "Vision Master T190" + }, + { + "ShortCode": "heltec-wireless-tracker", + "OriginalUrl": "https://heltec.org/project/wireless-tracker/", + "Description": "Wireless Tracker" + }, + { + "ShortCode": "heltec-wireless-tracker-v2", + "OriginalUrl": "https://heltec.org/project/wireless-tracker-v2/", + "Description": "Wireless Tracker V2" + }, + { + "ShortCode": "heltec-wireless-paper", + "OriginalUrl": "https://heltec.org/project/wireless-paper/", + "Description": "Wireless Paper" + }, + { + "ShortCode": "heltec-ht62-esp32c3-sx1262", + "OriginalUrl": "https://heltec.org/project/ht-ct62/", + "Description": "HT-CT62" + }, + { + "ShortCode": "wio-tracker-wm1110", + "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-1110-Dev-Kit-for-Meshtastic.html", + "Description": "Wio Tracker WM1110 Dev Kit" + }, + { + "ShortCode": "tracker-t1000-e", + "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Card-Tracker-T1000-E-for-Meshtastic-p-5913.html", + "Description": "SenseCAP Card Tracker T1000-E" + }, + { + "ShortCode": "tracker-t1000-e-aliexpress", + "OriginalUrl": "https://www.aliexpress.us/item/3256807287978389.html", + "Description": "SenseCAP Card Tracker T1000-E Aliexpress" + }, + { + "ShortCode": "tracker-t1000-e-amazon", + "OriginalUrl": "https://www.amazon.com/dp/B0DJ6KGXKB", + "Description": "SenseCAP Card Tracker T1000-E Amazon" + }, + { + "ShortCode": "seeed-sensecap-indicator", + "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Indicator-D1L-for-Meshtastic-p-6304.html", + "Description": "SenseCAP Indicator" + }, + { + "ShortCode": "station-g2", + "OriginalUrl": "https://shop.uniteng.com/product/meshtastic-mesh-device-station-edition/", + "Description": "Station G2" + }, + { + "ShortCode": "rak2560", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-meshtastic-solar-repeater?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Repeater" + }, + { + "ShortCode": "heltec-v3", + "OriginalUrl": "https://heltec.org/project/wifi-lora-32-v3/", + "Description": "LoRa32 V3" + }, + { + "ShortCode": "heltec-wsl-v3", + "OriginalUrl": "https://heltec.org/project/wireless-stick-lite-v2/", + "Description": "WSL V3" + }, + { + "ShortCode": "heltec-v4", + "OriginalUrl": "https://heltec.org/project/wifi-lora-32-v4/", + "Description": "LoRa32 V4" + }, + { + "ShortCode": "seeed-xiao-s3", + "OriginalUrl": "https://www.seeedstudio.com/Wio-SX1262-with-XIAO-ESP32S3-p-5982.html", + "Description": "XIAO ESP32-S3 + Wio-SX1262 Kit" + }, + { + "ShortCode": "tlora-t3s3-epaper", + "OriginalUrl": "https://lilygo.cc/products/t3-s3-meshtastic", + "Description": "T3S3" + }, + { + "ShortCode": "ht-ct62", + "OriginalUrl": "https://heltec.org/project/ht-ct62/", + "Description": "HT-CT62" + }, + { + "ShortCode": "seeed_xiao_nrf52840_kit", + "OriginalUrl": "https://www.seeedstudio.com/XIAO-nRF52840-Wio-SX1262-Kit-for-Meshtastic-p-6400.html", + "Description": "XIAO nRF52840 & Wio-SX1262 Kit" + }, + { + "ShortCode": "seeed_xiao_nrf52840_kit_aliexpress", + "OriginalUrl": "https://www.aliexpress.us/item/3256808574469954.html", + "Description": "XIAO nRF52840 & Wio-SX1262 Kit Aliexpress" + }, + { + "ShortCode": "thinknode_m1", + "OriginalUrl": "https://www.elecrow.com/thinknode-m1-meshtastic-lora-signal-transceiver-powered-by-nrf52840-with-154-screen-support-gps.html", + "Description": "ThinkNode M1" + }, + { + "ShortCode": "thinknode_m2", + "OriginalUrl": "https://www.elecrow.com/thinknode-m2-meshtastic-lora-signal-transceiver-powered-by-esp32-s3-with-1-3-oled-display.html", + "Description": "ThinkNode M2" + }, + { + "ShortCode": "thinknode_m3", + "OriginalUrl": "https://www.elecrow.com/thinknode-m3-meshtastic-tracker-with-gps-wifi-ble-function-for-indoor-and-outdoor-positioning.html", + "Description": "ThinkNode M3" + }, + { + "ShortCode": "thinknode_m5", + "OriginalUrl": "https://www.elecrow.com/thinknode-m5-meshtastic-lora-signal-transceiver-esp32-s3-1-54-screen-gps-function.html", + "Description": "ThinkNode M5" + }, + { + "ShortCode": "thinknode_m4", + "OriginalUrl": "https://www.elecrow.com/thinknode-m4-power-bank-lora-device-with-meshtastic-lora-tracker-function-powered-by-nrf52840.html", + "Description": "ThinkNode M4" + }, + { + "ShortCode": "thinknode_m6", + "OriginalUrl": "https://www.elecrow.com/thinknode-m6-outdoor-solar-power-for-meshtastic-powered-by-nrf52840-supports-gps.html", + "Description": "ThinkNode M6" + }, + { + "ShortCode": "heltec-mesh-pocket-10000", + "OriginalUrl": "https://heltec.org/project/meshpocket/", + "Description": "MeshPocket" + }, + { + "ShortCode": "seeed_solar_node", + "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Solar-Node-P1-Pro-for-Meshtastic-LoRa-p-6412.html", + "Description": "SenseCAP Solar Node P1 Pro" + }, + { + "ShortCode": "seeed_solar_node_aliexpress", + "OriginalUrl": "https://www.aliexpress.us/item/3256808731224053.html", + "Description": "SenseCAP Solar Node P1 Pro Aliexpress" + }, + { + "ShortCode": "seeed_solar_node_amazon", + "OriginalUrl": "https://www.amazon.com/dp/B0FMDHBWX8", + "Description": "SenseCAP Solar Node P1 Pro Amazon" + }, + { + "ShortCode": "elecrow-adv-35-tft", + "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-3-5-hmi-esp32-ai-display-for-meshtastic-320x240-ips-artificial-intelligent-screen.html", + "Description": "CrowPanel 3.5" + }, + { + "ShortCode": "elecrow-adv1-43-50-70-tft", + "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-4-3-hmi-ai-screen-for-meshtastic-esp32-800x480-ips-touch-artificial-intelligent-display-2.html", + "Description": "CrowPanel 4.3" + }, + { + "ShortCode": "elecrow-adv-24-28-tft", + "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-2-4-hmi-ai-display-for-meshtastic-esp32-320x240-ips-artificial-intelligent-touchscreen.html", + "Description": "CrowPanel 2.4" + }, + { + "ShortCode": "elecrow-adv-28-tft", + "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-2-8-hmi-ai-display-for-meshtastic-esp32-320x240-artificial-ips-intelligent-touchscreen.html", + "Description": "CrowPanel 2.8" + }, + { + "ShortCode": "elecrow-adv1-50-tft", + "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-5inch-hmi-esp32-ai-display-800x480-ips-artificial-intelligent-touch-screen-support-meshtastic.html", + "Description": "CrowPanel 5.0" + }, + { + "ShortCode": "elecrow-adv1-70-tft", + "OriginalUrl": "https://www.elecrow.com/crowpanel-advance-7-0-hmi-esp32-ai-display-800x480-artificial-intelligent-ips-touch-screen-for-meshtastic.html", + "Description": "CrowPanel 7.0" + }, + { + "ShortCode": "seeed_wio_tracker_L1", + "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-Pro-p-6454.html", + "Description": "Wio Tracker L1" + }, + { + "ShortCode": "seeed_wio_tracker_L1_aliexpress", + "OriginalUrl": "https://www.aliexpress.us/item/3256809394050623.html", + "Description": "Wio Tracker L1 Aliexpress" + }, + { + "ShortCode": "seeed_wio_tracker_L1_amazon", + "OriginalUrl": "https://www.amazon.com/dp/B0FNCS5ST1", + "Description": "Wio Tracker L1 Amazon" + }, + { + "ShortCode": "nano-g2-ultra", + "OriginalUrl": "https://shop.uniteng.com/product/meshtastic-mesh-device-nano-g2-ultra/", + "Description": "Nano G2 Ultra" + }, + { + "ShortCode": "rak11310", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-rp2040-starter-kit-for-meshtastic?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK11310" + }, + { + "ShortCode": "rokland-rak11310", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-rp2040-starter-kit-for-meshtastic?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "Rokland RAKwireless RAK11310 WisBlock RP2040 Core Module" + }, + { + "ShortCode": "station-g2-tindie", + "OriginalUrl": "https://www.tindie.com/products/neilhao/meshtastic-mesh-device-station-g2/", + "Description": "Station G2 Tindie Listing" + }, + { + "ShortCode": "nano-g2-ultra-tindie", + "OriginalUrl": "https://www.tindie.com/products/neilhao/meshtastic-mesh-device-nano-g2-ultra/", + "Description": "Nano G2 Ultra Tindie Listing" + }, + { + "ShortCode": "t-deck-plus", + "OriginalUrl": "https://lilygo.cc/products/t-deck-plus-meshtastic", + "Description": "T-Deck Plus" + }, + { + "ShortCode": "rokland-meshtastic-starter-kit", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-meshtastic-starter-kit", + "Description": "Rokland Meshtastic Starter Kit" + }, + { + "ShortCode": "rokland-t-deck-base", + "OriginalUrl": "https://store.rokland.com/products/lilygo-t-deck-portable-microcontroller-programmer-lora-915-mhz-h642?variant=41000826372179", + "Description": "Rokland T-Deck Base" + }, + { + "ShortCode": "rokland-t-deck-complete", + "OriginalUrl": "https://store.rokland.com/products/lilygo-t-deck-portable-microcontroller-programmer-lora-915-mhz-h642?variant=42122265690195", + "Description": "Rokland T-Deck Complete" + }, + { + "ShortCode": "rokland-t-deck-plus", + "OriginalUrl": "https://store.rokland.com/products/lilygo-t-deck-portable-microcontroller-programmer-lora-915-mhz-h642?variant=42283977834579", + "Description": "Rokland T-Deck Plus" + }, + { + "ShortCode": "rokland-t-echo", + "OriginalUrl": "https://store.rokland.com/products/lilygo-ttgo-meshtastic-t-echo-white-lora-sx1262-wireless-module-915mhz-nrf52840-gps-for-arduino?ref=8Bb2mUO5i-jKwt", + "Description": "Rokland T-Echo" + }, + { + "ShortCode": "rokland-t-echo-bme280", + "OriginalUrl": "https://store.rokland.com/products/lilygo-ttgo-meshtastic-t-echo-white-bme280-lora-sx1262-wireless-module-915mhz-nrf52840-gps-rtc-nfc-for-arduino?ref=8Bb2mUO5i-jKwt", + "Description": "Rokland T-Echo with BME280" + }, + { + "ShortCode": "rokland-rak19007", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-base-board-2nd-gen-rak19007-ver-b-pid-110082", + "Description": "Rokland RAKwireless RAK19007 WisBlock Base Board 2nd Gen" + }, + { + "ShortCode": "hexaspot-rak19007", + "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock-base/products/rakwireless-rak19007-wisblock-base-board-2nd-gen", + "Description": "Hexaspot RAKwireless RAK19007 WisBlock Base Board 2nd Gen" + }, + { + "ShortCode": "rokland-starter-kit", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-meshtastic-starter-kit", + "Description": "Rokland RAKwireless 4631 Starter Kit" + }, + { + "ShortCode": "hexaspot-starter-kit", + "OriginalUrl": "https://hexaspot.com/collections/wisblock-kits/products/wisblock-starter-kit-wisblock-basic-kit", + "Description": "Hexaspot RAKwireless 4631 Starter Kit" + }, + { + "ShortCode": "aliexpress-rak1921", + "OriginalUrl": "https://www.aliexpress.com/item/3256801470591730.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK1921 OLED Display (AliExpress)" + }, + { + "ShortCode": "rak1921", + "OriginalUrl": "https://store.rakwireless.com/products/rak1921-oled-display-panel?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK1921 OLED Display (RAK Store)" + }, + { + "ShortCode": "rokland-rak1921", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-oled-display-rak1921-pid-110004", + "Description": "Rokland RAK1921 WisBlock OLED Display" + }, + { + "ShortCode": "muzi-rak1921", + "OriginalUrl": "https://muzi.works/products/rak-oled-display-ssd1306", + "Description": "Muzi Works RAK1921 OLED Display SSD1306" + }, + { + "ShortCode": "aliexpress-rak14000", + "OriginalUrl": "https://www.aliexpress.com/item/3256803245280485.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK14000 E-Ink Display (AliExpress)" + }, + { + "ShortCode": "rak14000", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-epd-module-rak14000?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK14000 E-Ink Display (RAK Store)" + }, + { + "ShortCode": "rokland-rak14000", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-epd-module-rak14000-pid-110024", + "Description": "Rokland RAK14000 WisBlock E-Ink Display" + }, + { + "ShortCode": "aliexpress-rak12500", + "OriginalUrl": "https://www.aliexpress.com/item/3256802312416216.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK12500 (AliExpress)" + }, + { + "ShortCode": "rak12500", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-gnss-location-module-rak12500?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK12500 (RAK Store)" + }, + { + "ShortCode": "rak13300", + "OriginalUrl": "https://store.rakwireless.com/products/rak13300-wisblock-lpwan?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK13300 LPWAN Module (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak13002", + "OriginalUrl": "https://www.aliexpress.com/item/3256802904688489.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK13002 IO Module (AliExpress)" + }, + { + "ShortCode": "rak13002", + "OriginalUrl": "https://store.rakwireless.com/products/adapter-module-rak13002?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK13002 IO Module (RAK Store)" + }, + { + "ShortCode": "rokland-rak13002", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak13002-wisblock-io-adapter-module", + "Description": "Rokland RAK13002 WisBlock IO Adapter Module" + }, + { + "ShortCode": "muzi-rak13002", + "OriginalUrl": "https://muzi.works/products/rak-io-module", + "Description": "Muzi Works RAK13002 IO Module" + }, + { + "ShortCode": "rak6421", + "OriginalUrl": "https://store.rakwireless.com/products/meshtastic-raspberry-pi-hat-rak6421?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Pi Hat RAK6421 (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak18001", + "OriginalUrl": "https://www.aliexpress.com/item/3256802312587439.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK18001 RAK Buzzer (AliExpress)" + }, + { + "ShortCode": "rak18001", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-buzzer-module-rak18001?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK18001 RAK Buzzer (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak1901", + "OriginalUrl": "https://www.aliexpress.com/item/3256801444571922.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK1901 Temperature and Humidity Sensor (AliExpress)" + }, + { + "ShortCode": "rak1901", + "OriginalUrl": "https://store.rakwireless.com/products/rak1901-shtc3-temperature-humidity-sensor?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK1901 Temperature and Humidity Sensor (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak1902", + "OriginalUrl": "https://www.aliexpress.com/item/3256801445721072.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK-1902 Barometric Pressure Sensor (AliExpress)" + }, + { + "ShortCode": "rak1902", + "OriginalUrl": "https://store.rakwireless.com/products/rak1902-kps22hb-barometric-pressure-sensor?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK-1902 Barometric Pressure Sensor (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak1906", + "OriginalUrl": "https://www.aliexpress.com/item/3256801453209668.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK1906 Environment Sensor (AliExpress)" + }, + { + "ShortCode": "rak1906", + "OriginalUrl": "https://store.rakwireless.com/products/rak1906-bme680-environment-sensor?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK1906 Environment Sensor (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak12002", + "OriginalUrl": "https://www.aliexpress.com/item/3256803919249064.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK12002 WisBlock RTC Module (AliExpress)" + }, + { + "ShortCode": "rak12002", + "OriginalUrl": "https://store.rakwireless.com/products/rtc-module-rak12002?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK12002 WisBlock RTC Module (RAK Store)" + }, + { + "ShortCode": "rokland-rak12002", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak12002-rtc-module-micro-crystal-rv-3028-c7-pid-100032", + "Description": "Rokland RAK12002 RTC Module Micro Crystal RV-3028-C7" + }, + { + "ShortCode": "aliexpress-wismesh-pocket-v2", + "OriginalUrl": "https://www.aliexpress.com/item/3256808087883682.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Pocket V2 (AliExpress)" + }, + { + "ShortCode": "rokland-wismesh-pocket-v2", + "OriginalUrl": "https://store.rokland.com/products/wismesh-pocket", + "Description": "WisMesh Pocket V2 (Rokland)" + }, + { + "ShortCode": "hexaspot-wismesh-pocket-v2", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/wismesh-pocket-v2-ready-to-use-meshtastic-device", + "Description": "WisMesh Pocket V2 (Hexaspot)" + }, + { + "ShortCode": "wismesh-pocket-v2", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-pocket?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Pocket V2 (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-pocket-mini", + "OriginalUrl": "https://www.aliexpress.com/item/3256807998160830.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Pocket Mini (Rokland)" + }, + { + "ShortCode": "rokland-wismesh-pocket-mini", + "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-pocket-mini-all-in-one-meshtastic-handheld-915-mhz-radio-with-lora-antenna", + "Description": "WisMesh Pocket Mini (Rokland)" + }, + { + "ShortCode": "wismesh-pocket-mini", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-pocket-mini?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Pocket Mini (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak19026", + "OriginalUrl": "https://www.aliexpress.com/item/3256808063797462.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Baseboard (AliExpress)" + }, + { + "ShortCode": "rokland-rak19026", + "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-baseboard-rak19026-oled-mounted-gnss-motion-sensor-pid-115125", + "Description": "WisMesh Baseboard (Rokland)" + }, + { + "ShortCode": "rak19026", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-baseboard-rak19026?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Baseboard (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-tap", + "OriginalUrl": "https://www.aliexpress.com/item/3256808097004202.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Tap (AliExpress)" + }, + { + "ShortCode": "aliexpress-board-one", + "OriginalUrl": "https://www.aliexpress.com/item/3256802139951068.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Board ONE (AliExpress)" + }, + { + "ShortCode": "board-one", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-board-one-meshtastic-node?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Board ONE (RAK Store)" + }, + { + "ShortCode": "rokland-board-one", + "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-b1-board", + "Description": "Rokland WisMesh Board ONE (US915 MHz)" + }, + { + "ShortCode": "wismesh-repeater", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-meshtastic-solar-repeater?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Repeater (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-repeater", + "OriginalUrl": "https://www.aliexpress.com/item/3256808393658502.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Repeater (AliExpress)" + }, + { + "ShortCode": "aliexpress-wismesh-repeater-mini", + "OriginalUrl": "https://www.aliexpress.com/item/2251832722300348.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Repeater Mini (AliExpress)" + }, + { + "ShortCode": "hexaspot-wismesh-repeater-mini", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/wismesh-repeater-mini", + "Description": "WisMesh Repeater Mini (Hexaspot)" + }, + { + "ShortCode": "wismesh-repeater-mini", + "OriginalUrl": "https://store.rakwireless.com/products/wishmesh-meshtastic-solar-repeater-mini?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Repeater Mini (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-ethernet-gateway", + "OriginalUrl": "https://www.aliexpress.com/item/3256801470547683.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Ethernet MQTT Gateway (AliExpress)" + }, + { + "ShortCode": "wismesh-ethernet-gateway", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-ethernet-gateway?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Ethernet MQTT Gateway (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-wifi-gateway", + "OriginalUrl": "https://www.aliexpress.com/item/3256802139923708.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh WiFi MQTT Gateway (AliExpress)" + }, + { + "ShortCode": "wismesh-wifi-gateway", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-wifi-gateway?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh WiFi MQTT Gateway (RAK Store)" + }, + { + "ShortCode": "aliexpress-board-one-pocket", + "OriginalUrl": "https://www.aliexpress.com/item/3256802139951068.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Board ONE Pocket (AliExpress)" + }, + { + "ShortCode": "board-one-pocket", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-board-one-pocket-meshtastic-node?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK WisMesh Board ONE Pocket (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-unify-enclosure", + "OriginalUrl": "https://www.aliexpress.com/item/3256808182747014.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Unify Enclosure (AliExpress)" + }, + { + "ShortCode": "wismesh-unify-enclosure", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-unify-enclosure?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Unify Enclosure (RAK Store)" + }, + { + "ShortCode": "aliexpress-wismesh-antenna", + "OriginalUrl": "https://www.aliexpress.com/item/3256808177346156.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Antenna (AliExpress)" + }, + { + "ShortCode": "wismesh-antenna", + "OriginalUrl": "https://store.rakwireless.com/products/wismesh-antenna?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh Antenna (RAK Store)" + }, + { + "ShortCode": "muzi-rak4631", + "OriginalUrl": "https://muzi.works/products/rak-wisblock-meshtastic-starter-kit-us915", + "Description": "Muzi RAK4631 Starter Kit" + }, + { + "ShortCode": "aliexpress-rak19007", + "OriginalUrl": "https://www.aliexpress.com/item/3256803957557617.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK19007 (AliExpress)" + }, + { + "ShortCode": "aliexpress-starter-kit", + "OriginalUrl": "https://www.aliexpress.com/item/1005006901039995.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "WisMesh RAK4631 Starter Kit (AliExpress)" + }, + { + "ShortCode": "rak19003", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-base-board-rak19003?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK19003 (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak19003", + "OriginalUrl": "https://www.aliexpress.com/item/3256803225234826.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK19003 (AliExpress)" + }, + { + "ShortCode": "rak19001", + "OriginalUrl": "https://store.rakwireless.com/products/rak19001-wisblock-dual-io-base-board?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK19001 WisBlock Dual IO Base Board (RAK Store)" + }, + { + "ShortCode": "aliexpress-rak19001", + "OriginalUrl": "https://www.aliexpress.com/item/3256803962043191.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK19001 WisBlock Dual IO Base Board (AliExpress)" + }, + { + "ShortCode": "rokland-19003", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-mini-base-board-rak19003-ver-b-pid-306024", + "Description": "Rokland WisBlock Mini Base Board RAK19003 (Ver B)" + }, + { + "ShortCode": "hexaspot-19003", + "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock-base/products/rakwireless-rak19003-wisblock-mini-base-board", + "Description": "Hexaspot WisBlock Mini Base Board RAK19003 (Ver B)" + }, + { + "ShortCode": "rokland-19001", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-wisblock-dual-io-base-board-rak19001-pid-110081", + "Description": "Rokland WisBlock Dual IO Base Board RAK19001" + }, + { + "ShortCode": "hexaspot-19001", + "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock-base/products/rakwireless-rak19001-wisblock-dual-io-base-board", + "Description": "Hexaspot WisBlock Dual IO Base Board RAK19001" + }, + { + "ShortCode": "rokland-4631", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak4631-nordic-nrf52840-ble-core-module-for-lorawan-with-lora-sx1262", + "Description": "Rokland RAK4631 Nordic nRF52840 BLE Core Module for LoRaWAN with LoRa SX1262" + }, + { + "ShortCode": "hexaspot-4631", + "OriginalUrl": "https://hexaspot.com/collections/wisblock-kits/products/wisblock-meshtastic-starter-kit-eu868-the-basic-rak4631-meshtastic-kit-for-lora", + "Description": "Hexaspot RAK4631 Nordic nRF52840 BLE Core Module for LoRaWAN with LoRa SX1262" + }, + { + "ShortCode": "aliexpress-rak4631", + "OriginalUrl": "https://www.aliexpress.us/item/3256801470104151.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK4631 Nordic nRF52840 BLE Core Module (AliExpress)" + }, + { + "ShortCode": "rakwireless-4631", + "OriginalUrl": "https://store.rakwireless.com/products/rak4631-lpwan-node?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK4631 Nordic nRF52840 BLE Core Module (AliExpress)" + }, + { + "ShortCode": "rakwireless-rak11310", + "OriginalUrl": "https://store.rakwireless.com/products/rak11310-wisblock-lpwan-module?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK11310 RP2040 Core Module)" + }, + { + "ShortCode": "rakwireless-rak3312", + "OriginalUrl": "https://store.rakwireless.com/products/wisblock-core-module-rak3312-lora-wifi-ble", + "Description": "RAK3312 ESP32-S3 Core Module" + }, + { + "ShortCode": "hexaspot-rak3312", + "OriginalUrl": "https://hexaspot.com/collections/rakwireless-wisblock/products/espressif-esp32-s3-wifi-ble-dual-core-module-for-lorawan%C2%AE-with-lora-sx1262", + "Description": "Hexaspot RAK3312 ESP32-S3 Core Module" + }, + { + "ShortCode": "rokland-rak3312", + "OriginalUrl": "https://store.rokland.com/products/rak3312-espressif-esp32-s3-wifi-ble-dual-core-module-for-lorawan-with-lora-sx1262-116208", + "Description": "Rokland RAK3312 ESP32-S3 Core Module" + }, + { + "ShortCode": "rokland-rak3312-starter-kit", + "OriginalUrl": "https://store.rokland.com/products/wismesh-rak3312-starter-kit-with-meshtastic-firmware", + "Description": "Rokland RAK3312 ESP32-S3 Starter Kit" + }, + { + "ShortCode": "aliexpress-rak11310", + "OriginalUrl": "https://www.aliexpress.us/item/3256803225175784.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAK11310 RP2040 Core Module (AliExpress)" + }, + { + "ShortCode": "rokland-1901", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak1901-temperature-and-humidity-sensor-sensirion-shtc3-pid-100001", + "Description": "Rokland RAK1901 Temperature and Humidity Sensor" + }, + { + "ShortCode": "rokland-1902", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak1902-barometric-pressure-sensor-stmicroelectronics-lps22hb-100010-2-pack", + "Description": "Rokland RAK1902 Barometric Pressure Sensor" + }, + { + "ShortCode": "rokland-1906", + "OriginalUrl": "https://store.rokland.com/products/rak-wireless-rak1906-wisblock-environment-sensor-bosch-bme680", + "Description": "Rokland RAK1906 WisBlock Environment Sensor" + }, + { + "ShortCode": "aliexpress-wismesh-tap", + "OriginalUrl": "https://www.aliexpress.com/item/3256808097004202.html?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAKwireless WisMesh Tap (AliExpress)" + }, + { + "ShortCode": "rokland-wismesh-tap", + "OriginalUrl": "https://store.rokland.com/products/rakwireless-wismesh-tap-touchscreen-915-mhz-handheld-or-mountable-unit-lora-gps", + "Description": "RAKwireless WisMesh Tap (Rokland)" + }, + { + "ShortCode": "rakdap1", + "OriginalUrl": "https://store.rakwireless.com/products/daplink-tool?utm_source=website_partner&utm_medium=referral&utm_campaign=meshtastic_rak_collab", + "Description": "RAKwireless RAKDAP1 Debug and Flash Tool" + }, + { + "ShortCode": "rokland-heltec-wsl-v3", + "OriginalUrl": "https://store.rokland.com/collections/heltec-products/products/heltec-wireless-stick-litev3-902-928-mhz/", + "Description": "Rokland WSL V3" + }, + { + "ShortCode": "aliexpress-heltec-wsl-v3", + "OriginalUrl": "https://www.aliexpress.us/item/3256807466584635.htm", + "Description": "Aliexpress WSL V3" + }, + { + "ShortCode": "rokland-heltec-wireless-tracker", + "OriginalUrl": "https://store.rokland.com/collections/heltec-products/products/heltec-wireless-tracker-v1-1-wi-fi-lora-bt-gnss/", + "Description": "Rokland Wireless Tracker" + }, + { + "ShortCode": "aliexpress-heltec-wireless-tracker", + "OriginalUrl": "https://www.aliexpress.us/item/3256805495189423.html", + "Description": "Aliexpress Wireless Tracker" + }, + { + "ShortCode": "aliexpress-heltec-wireless-paper", + "OriginalUrl": "https://www.aliexpress.us/item/3256805461611876.html", + "Description": "Aliexpress Wireless Paper" + }, + { + "ShortCode": "rokland-heltec-wireless-paper", + "OriginalUrl": "https://store.rokland.com/collections/heltec-products/products/heltec-wireless-paper-wi-fi-lora-bt/", + "Description": "Rokland Wireless Paper" + }, + { + "ShortCode": "muzi-heltec-mesh-node-t114", + "OriginalUrl": "https://muzi.works/products/heltec-mesh-node-t114/", + "Description": "MuziWorks Mesh Node T114" + }, + { + "ShortCode": "aliexpress-heltec-mesh-node-t114", + "OriginalUrl": "https://www.aliexpress.com/item/1005007460963705.html", + "Description": "Aliexpress Mesh Node T114" + }, + { + "ShortCode": "aliexpress-heltec-vision-master-e213", + "OriginalUrl": "https://www.aliexpress.com/item/1005007209756502.html", + "Description": "Aliexpress Vision Master E213" + }, + { + "ShortCode": "aliexpress-heltec-vision-master-e290", + "OriginalUrl": "https://www.aliexpress.com/item/1005007234361986.html", + "Description": "Aliexpress Vision Master E290" + }, + { + "ShortCode": "aliexpress-heltec-vision-master-t190", + "OriginalUrl": "https://www.aliexpress.us/item/3256807135629435.html", + "Description": "Aliexpress Vision Master T190" + }, + { + "ShortCode": "seeed-wio-tracker-l1-oled", + "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-p-6453.html", + "Description": "Wio Tracker L1 (with OLED)" + }, + { + "ShortCode": "seeed-wio-tracker-l1-oled_aliexpress", + "OriginalUrl": "https://www.aliexpress.us/item/3256809320083189.html", + "Description": "Wio Tracker L1 (with OLED)" + }, + { + "ShortCode": "seeed_wio_tracker_L1_eink", + "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-E-ink-p-6456.html", + "Description": "Wio Tracker L1 (with E-Ink)" + }, + { + "ShortCode": "seeed_wio_tracker_L1_eink_amazon", + "OriginalUrl": "https://www.amazon.com/dp/B0FJWT5FYW", + "Description": "Wio Tracker L1 (with E-Ink) Amazon" + }, + { + "ShortCode": "seeed-wio-tracker-l1-lite", + "OriginalUrl": "https://www.seeedstudio.com/Wio-Tracker-L1-Lite-p-6455.html", + "Description": "Wio Tracker L1 Lite (no display)" + }, + { + "ShortCode": "seeed_solar_node_p1", + "OriginalUrl": "https://www.seeedstudio.com/SenseCAP-Solar-Node-P1-for-Meshtastic-LoRa-p-6425.html", + "Description": "SenseCAP Solar Node P1" + }, + { + "ShortCode": "seeed_solar_node_p1_aliexpress", + "OriginalUrl": "https://www.aliexpress.us/item/3256808731224053.html", + "Description": "SenseCAP Solar Node P1 Aliexpress" + }, + { + "ShortCode": "android-closed-test", + "OriginalUrl": "https://forms.gle/3dZCSTQWRbMSHkPd6", + "Description": "Android Closed Test Form" + }, + { + "ShortCode": "t-deck-pro", + "OriginalUrl": "https://lilygo.cc/products/t-deck-pro-meshtastic", + "Description": "LilyGo T-Deck Pro" + }, + { + "ShortCode": "rak4631_nomadstar_meteor_pro", + "OriginalUrl": "https://nomadstar.ch/meteor-pro/", + "Description": "NomadStar Meteor Pro" + }, + { + "ShortCode": "muziworks", + "OriginalUrl": "https://muzi.works/", + "Description": "muzi WORKS Homepage" + }, + { + "ShortCode": "r1-neo", + "OriginalUrl": "https://muzi.works/products/r1-neo-complete-meshtastic-device", + "Description": "muzi WORKS R1 Neo" + }, + { + "ShortCode": "muzi-base", + "OriginalUrl": "https://muzi.works/pages/base", + "Description": "muzi WORKS Base System" + }, + { + "ShortCode": "muzi-base-uno", + "OriginalUrl": "https://muzi.works/products/base-uno", + "Description": "muzi WORKS Base Uno" + }, + { + "ShortCode": "muzi-base-duo", + "OriginalUrl": "https://muzi.works/products/base-duo", + "Description": "muzi WORKS Base Duo" + }, + { + "ShortCode": "muzi-base-super-io", + "OriginalUrl": "https://muzi.works/products/super-io", + "Description": "muzi WORKS Base Super IO" + }, + { + "ShortCode": "ttc-tickets", + "OriginalUrl": "https://www.thethingsconference.com/partner-invitations/recgog1edgosiv3b8", + "Description": "The Things Conference Tickets" + }, + { + "ShortCode": "rokland-atlavox-makers-market", + "OriginalUrl": "https://store.rokland.com/products/atlavox-beacon-solar-meshtastic-node-w-n-female-antenna", + "Description": "Rokland Atlavox Makers Market" + }, + { + "ShortCode": "rokland-tlora-pager", + "OriginalUrl": "https://store.rokland.com/products/lilygo-t-lora-pager-us-915-mhz-lora-esp32-s3-handheld-aiot-programmable-development-device-k257-01", + "Description": "Rokland T-Lora Pager" + }, + { + "ShortCode": "tlora-pager", + "OriginalUrl": "https://lilygo.cc/products/t-lora-pager-meshtastic", + "Description": "T-Lora Pager" + }, + { + "ShortCode": "hexaspot", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products", + "Description": "Hexaspot Meshtastic Products" + }, + { + "ShortCode": "ew26", + "OriginalUrl": "https://meshtastic.com/ew26", + "Description": "embeddedworld26 event page" + }, + { + "ShortCode": "hexaspot-heltec-v3", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/heltec-wifi-lora-32-v3", + "Description": "Heltec V3 (Hexaspot)" + }, + { + "ShortCode": "hexaspot-heltec-v4", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/heltec-wifi-lora-32-v4", + "Description": "Heltec V4 (Hexaspot)" + }, + { + "ShortCode": "hexaspot-wireless-tracker-v2", + "OriginalUrl": "https://hexaspot.com/collections/meshtastic-products/products/heltec-wireless-tracker-v2", + "Description": "Heltec Wireless Tracker V2 (Hexaspot)" + } + ] +} \ No newline at end of file diff --git a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt index d610af483..88f471373 100644 --- a/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt +++ b/core/common/src/androidMain/kotlin/org/meshtastic/core/common/util/LocaleUtils.android.kt @@ -23,6 +23,8 @@ import java.util.Locale actual fun currentLocaleCode(): String = Locale.getDefault().language +actual fun currentRegionCode(): String = Locale.getDefault().country + actual fun currentLocaleQualifier(): String { val locale = Locale.getDefault() val country = locale.country diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt index 194478f18..3ced2718a 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MeasurementSystem.kt @@ -28,6 +28,12 @@ expect fun getSystemMeasurementSystem(): MeasurementSystem /** Returns the device's current locale as a 2-letter ISO 639-1 language code (e.g. "en", "es", "fr"). */ expect fun currentLocaleCode(): String +/** + * Returns the device's current region as a 2-letter ISO 3166-1 alpha-2 country code (e.g. "US", "DE"), or an empty + * string when the region is unknown. Used to region-filter marketplace links. + */ +expect fun currentRegionCode(): String + /** * Returns the device locale as a CMP resource qualifier string. Examples: "pt-rBR", "zh-rCN", "fr" (no region when not * specified). Use this to construct locale-qualified file resource paths like "files-$qualifier/docs/...". diff --git a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt index 4d3b1b363..e04222f8d 100644 --- a/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt +++ b/core/common/src/iosMain/kotlin/org/meshtastic/core/common/util/NoopStubs.kt @@ -42,6 +42,8 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.M actual fun currentLocaleCode(): String = "en" +actual fun currentRegionCode(): String = "" + actual fun currentLocaleQualifier(): String = "en" actual fun String?.isValidAddress(): Boolean = false diff --git a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt index 66f7dd07e..0661dfab1 100644 --- a/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt +++ b/core/common/src/jvmMain/kotlin/org/meshtastic/core/common/util/JvmPlatformUtils.kt @@ -90,6 +90,8 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem = actual fun currentLocaleCode(): String = Locale.getDefault().language +actual fun currentRegionCode(): String = Locale.getDefault().country + actual fun currentLocaleQualifier(): String { val locale = Locale.getDefault() val country = locale.country diff --git a/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSourceImpl.kt b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSourceImpl.kt new file mode 100644 index 000000000..18d6d7135 --- /dev/null +++ b/core/data/src/androidMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSourceImpl.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:OptIn(ExperimentalSerializationApi::class) + +package org.meshtastic.core.data.datasource + +import android.app.Application +import co.touchlab.kermit.Logger +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import org.koin.core.annotation.Single +import org.meshtastic.core.model.MshToMarketplace +import org.meshtastic.core.model.MshToRoute +import org.meshtastic.core.model.MshToUrlsFile + +@Single +class MshToLinksJsonDataSourceImpl(private val application: Application) : MshToLinksJsonDataSource { + + // Tolerant parser: tolerate extra fields/trailing data so a stale bundled file never crashes the import. + private val json = Json { + ignoreUnknownKeys = true + isLenient = true + exceptionsWithDebugInfo = false + } + + // The bundled assets are immutable for the install's lifetime, so parse once and reuse — these are read on the + // node-detail flow's hot path (once per hardware emission). + private val routes: List by lazy { + runCatching { application.assets.open(URLS_ASSET).use { json.decodeFromStream(it).routes } } + .onFailure { Logger.w(it) { "Unable to load $URLS_ASSET for device links" } } + .getOrDefault(emptyList()) + } + + private val marketplaces: Map by lazy { + runCatching { + application.assets.open(MARKETPLACES_ASSET).use { + json.decodeFromStream>(it) + } + } + .onFailure { + Logger.w(it) { "Unable to load $MARKETPLACES_ASSET; marketplace links won't be region-filtered" } + } + .getOrDefault(emptyMap()) + } + + override fun loadRoutes(): List = routes + + override fun loadMarketplaces(): Map = marketplaces + + private companion object { + const val URLS_ASSET = "urls.json" + const val MARKETPLACES_ASSET = "marketplaces.json" + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt index 852ac0898..03b3de2ba 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt @@ -47,4 +47,7 @@ class DeviceHardwareLocalDataSource( withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) } suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { deviceHardwareDao.count() > 0 } + + /** All known `platformioTarget` values — used to determine which msh.to links are vendor links. */ + suspend fun getAllTargets(): List = withContext(dispatchers.io) { deviceHardwareDao.getAllTargets() } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceLinkLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceLinkLocalDataSource.kt new file mode 100644 index 000000000..f63bd85e5 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceLinkLocalDataSource.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.datasource + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import org.koin.core.annotation.Single +import org.meshtastic.core.database.DatabaseProvider +import org.meshtastic.core.database.entity.DeviceLinkEntity +import org.meshtastic.core.di.CoroutineDispatchers + +@Single +class DeviceLinkLocalDataSource( + private val dbManager: DatabaseProvider, + private val dispatchers: CoroutineDispatchers, +) { + private val deviceLinkDao + get() = dbManager.currentDb.value.deviceLinkDao() + + fun observeAll(): Flow> = deviceLinkDao.observeAll() + + suspend fun getAll(): List = withContext(dispatchers.io) { deviceLinkDao.getAll() } + + suspend fun upsertAll(links: List) = + withContext(dispatchers.io) { deviceLinkDao.upsertAll(links) } + + suspend fun deleteNotIn(keep: List) = withContext(dispatchers.io) { deviceLinkDao.deleteNotIn(keep) } + + suspend fun deleteAll() = withContext(dispatchers.io) { deviceLinkDao.deleteAll() } + + suspend fun count(): Int = withContext(dispatchers.io) { deviceLinkDao.count() } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSource.kt new file mode 100644 index 000000000..74a54acb3 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/MshToLinksJsonDataSource.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.datasource + +import org.meshtastic.core.model.MshToMarketplace +import org.meshtastic.core.model.MshToRoute + +/** Reads the bundled msh.to link data: `urls.json` (short codes) and `marketplaces.json` (region metadata). */ +interface MshToLinksJsonDataSource { + /** Routes from the bundled `urls.json`, or empty if missing/malformed. */ + fun loadRoutes(): List + + /** Marketplace metadata from the bundled `marketplaces.json`, keyed by marketplace identifier. */ + fun loadMarketplaces(): Map +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 95755f1fc..0c48d6a17 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -37,6 +37,7 @@ import org.meshtastic.core.model.DeviceHardware import org.meshtastic.core.model.util.TimeConstants import org.meshtastic.core.network.DeviceHardwareRemoteDataSource import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.DeviceLinkRepository @Single class DeviceHardwareRepositoryImpl( @@ -44,6 +45,7 @@ class DeviceHardwareRepositoryImpl( private val localDataSource: DeviceHardwareLocalDataSource, private val jsonDataSource: DeviceHardwareJsonDataSource, private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource, + private val deviceLinkRepository: DeviceLinkRepository, private val dispatchers: CoroutineDispatchers, ) : DeviceHardwareRepository { @@ -136,6 +138,10 @@ class DeviceHardwareRepositoryImpl( Logger.w { "DeviceHardwareRepository: network refresh timed out after ${NETWORK_REFRESH_TIMEOUT_MS}ms" } + } else { + // Reconcile msh.to links against the freshest catalog (isVendor + orphan pruning). Runs outside + // the network timeout so a deadline can't cancel it mid-write and leave links half-reconciled. + deviceLinkRepository.reconcile() } } .onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: network refresh failed" } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcher.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcher.kt new file mode 100644 index 000000000..ce88e9917 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcher.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.repository + +import org.meshtastic.core.model.DeviceLink + +/** + * Pure matching logic for associating msh.to [DeviceLink]s with a device's `platformioTarget`. Ported from the + * Meshtastic-Apple `DeviceLinksSection` (multi-tier matching: exact vendor, product variant, marketplace), so the two + * platforms surface the same links. + */ +object DeviceLinkMatcher { + + /** + * Links relevant to [target], region-filtered and sorted with vendor/variant links first. + * + * @param links all imported links. + * @param marketplaceKeys known marketplace identifiers (from `marketplaces.json`). + * @param deviceTargets all known device `platformioTarget`s — used to exclude other devices' links. + * @param target the viewed device's `platformioTarget`. + * @param region the user's ISO 3166-1 alpha-2 region for marketplace filtering. + */ + fun match( + links: List, + marketplaceKeys: Set, + deviceTargets: Set, + target: String, + region: String, + ): List { + val variants = buildTargetVariants(target) + return links + .filter { link -> matches(link, marketplaceKeys, deviceTargets, target, variants, region) } + .sortedByDescending { it.isVendor || !isMarketplaceLink(it.shortCode, marketplaceKeys) } + } + + @Suppress("ReturnCount") + private fun matches( + link: DeviceLink, + marketplaceKeys: Set, + deviceTargets: Set, + target: String, + variants: List, + region: String, + ): Boolean { + val code = link.shortCode + + // Exact vendor match always wins. + if (code == target) return true + + // A vendor link for a different device is never shown here. + if (link.isVendor && code != target) return false + + // Variant/marketplace-suffix: "-..." or "_...". + val matchesPrefix = variants.any { code.startsWith("${it}_") || code.startsWith("$it-") } + + // Known marketplace prefix: "-" or "_". + val matchesMarketplacePrefix = + variants.any { variant -> marketplaceKeys.any { mp -> code == "$mp-$variant" || code == "${mp}_$variant" } } + + if (!matchesPrefix && !matchesMarketplacePrefix) return false + + // A prefix hit that is itself a different device's target belongs to that device, not this one. + if (matchesPrefix && code in deviceTargets && code != target) return false + + // Region filter: null regions = vendor/variant (always), empty = worldwide, else must include the region. + val regions = link.regions ?: return true + if (regions.isEmpty()) return true + return region in regions + } + + /** True when [code] carries a known marketplace prefix or suffix. */ + fun isMarketplaceLink(code: String, marketplaceKeys: Set): Boolean = + marketplaceKeyFor(code, marketplaceKeys) != null + + /** + * The marketplace identifier [code] belongs to (as a delimiter-bounded prefix `mp-`/`mp_` or suffix `-mp`/`_mp`), + * or `null` if none. This is the single source of truth for "is this a marketplace link" — used for import-time + * region tagging, sort ordering, and UI prominence — so the classifications never disagree. Delimiter bounds avoid + * mis-tagging codes that merely begin with a marketplace name (e.g. `muziworks` is NOT `muzi`). + */ + fun marketplaceKeyFor(code: String, marketplaceKeys: Set): String? = marketplaceKeys.firstOrNull { mp -> + code.startsWith("$mp-") || code.startsWith("${mp}_") || code.endsWith("-$mp") || code.endsWith("_$mp") + } + + /** + * Alternate target strings for matching. Strips a leading `rak` (e.g. `rak4631` → `4631`) to absorb msh.to naming + * inconsistencies like `rokland-4631`. + */ + fun buildTargetVariants(target: String): List { + val variants = mutableListOf(target) + if (target.startsWith("rak")) { + val stripped = target.removePrefix("rak") + if (stripped.isNotEmpty()) variants.add(stripped) + } + return variants + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImpl.kt new file mode 100644 index 000000000..b0cf2d23f --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImpl.kt @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.repository + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.koin.core.annotation.Single +import org.meshtastic.core.common.util.safeCatching +import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource +import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource +import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource +import org.meshtastic.core.database.entity.asEntity +import org.meshtastic.core.database.entity.asExternalModel +import org.meshtastic.core.model.DeviceLink +import org.meshtastic.core.model.MshToMarketplace +import org.meshtastic.core.repository.DeviceLinkRepository + +@Single +class DeviceLinkRepositoryImpl( + private val jsonDataSource: MshToLinksJsonDataSource, + private val localDataSource: DeviceLinkLocalDataSource, + private val deviceHardwareLocalDataSource: DeviceHardwareLocalDataSource, +) : DeviceLinkRepository { + + /** Guards the import so concurrent collectors don't run it more than once at a time. */ + private val importMutex = Mutex() + + override suspend fun ensureImported() { + if (localDataSource.count() > 0) return + importMutex.withLock { if (localDataSource.count() == 0) doImport() } + } + + override suspend fun reconcile() { + importMutex.withLock { doImport() } + } + + override suspend fun getLinksForTarget(platformioTarget: String, regionCode: String): List { + if (platformioTarget.isBlank()) return emptyList() + ensureImported() + val links = localDataSource.getAll().map { it.asExternalModel() } + val marketplaceKeys = jsonDataSource.loadMarketplaces().keys + val deviceTargets = deviceHardwareLocalDataSource.getAllTargets().toSet() + return DeviceLinkMatcher.match( + links = links, + marketplaceKeys = marketplaceKeys, + deviceTargets = deviceTargets, + target = platformioTarget, + region = regionCode, + ) + } + + override fun observeAllLinks(): Flow> = flow { + ensureImported() + emitAll(localDataSource.observeAll().map { entities -> entities.map { it.asExternalModel() } }) + } + + /** Loads bundled `urls.json`, classifies each short code, upserts, and prunes orphans. Mirrors Apple's import. */ + private suspend fun doImport() { + safeCatching { + val routes = jsonDataSource.loadRoutes() + if (routes.isEmpty()) { + Logger.w { "DeviceLinkRepository: no routes in bundled urls.json; skipping import" } + return@safeCatching + } + val marketplaces = jsonDataSource.loadMarketplaces() + val deviceTargets = deviceHardwareLocalDataSource.getAllTargets().toSet() + + val links = + routes.map { route -> + val isVendor = route.shortCode in deviceTargets + DeviceLink( + shortCode = route.shortCode, + originalUrl = route.originalUrl, + description = route.description, + isVendor = isVendor, + regions = if (isVendor) null else marketplaceRegions(route.shortCode, marketplaces), + ) + } + + localDataSource.upsertAll(links.map { it.asEntity() }) + localDataSource.deleteNotIn(links.map { it.shortCode }) + Logger.i { "DeviceLinkRepository: imported ${links.size} msh.to links" } + } + .onFailure { Logger.w(it) { "DeviceLinkRepository: device links import failed" } } + } + + /** + * Shipping regions for a marketplace short code, or null when it is not a marketplace link. Uses the same + * delimiter-aware classifier as the matcher/UI so a code's classification (vendor/variant vs marketplace) is + * consistent everywhere — independent of the `match` hint in `marketplaces.json`, which is unreliable in practice + * (e.g. AliExpress is declared `suffix` yet most codes use the `aliexpress-` prefix form). + */ + private fun marketplaceRegions(code: String, marketplaces: Map): List? = + DeviceLinkMatcher.marketplaceKeyFor(code, marketplaces.keys)?.let { marketplaces.getValue(it).regions } +} diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcherTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcherTest.kt new file mode 100644 index 000000000..b9b69edd2 --- /dev/null +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkMatcherTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.repository + +import org.meshtastic.core.model.DeviceLink +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for [DeviceLinkMatcher], grounded in the acceptance scenarios of the Meshtastic-Apple `010-device-mshto-links` + * spec. Mirrors the as-built `DeviceLinksSection` matching (platformioTarget, not hwModelSlug). + */ +class DeviceLinkMatcherTest { + + private val marketplaceKeys = setOf("rokland", "hexaspot", "aliexpress", "amazon", "tindie", "muzi") + + private val deviceTargets = + setOf("rak4631", "heltec-v3", "seeed_solar_node", "tbeam", "rak4631_nomadstar_meteor_pro") + + private fun link(shortCode: String, isVendor: Boolean = false, regions: List? = null) = DeviceLink( + shortCode = shortCode, + originalUrl = "https://example.com/$shortCode", + isVendor = isVendor, + regions = regions, + ) + + private fun match(links: List, target: String, region: String = "US") = + DeviceLinkMatcher.match(links, marketplaceKeys, deviceTargets, target, region).map { it.shortCode } + + @Test + fun exactVendorMatchIsIncluded() { + val result = match(listOf(link("heltec-v3", isVendor = true)), target = "heltec-v3") + assertEquals(listOf("heltec-v3"), result) + } + + @Test + fun foreignVendorLinkIsExcluded() { + // Scenario 5: rak4631_nomadstar_meteor_pro (a different device's target) must NOT show for rak4631. + val result = + match( + listOf(link("rak4631", isVendor = true), link("rak4631_nomadstar_meteor_pro", isVendor = true)), + target = "rak4631", + ) + assertEquals(listOf("rak4631"), result) + } + + @Test + fun productVariantIsIncludedAndProminent() { + val result = match(listOf(link("rak4631_epaper")), target = "rak4631") + assertEquals(listOf("rak4631_epaper"), result) + } + + @Test + fun marketplaceLinkIsRegionFiltered() { + val links = listOf(link("rokland-rak4631", regions = listOf("US", "CA"))) + assertEquals(listOf("rokland-rak4631"), match(links, target = "rak4631", region = "US")) + assertEquals(emptyList(), match(links, target = "rak4631", region = "DE")) + } + + @Test + fun rakPrefixIsStrippedForMarketplaceVariantMatch() { + // "rokland-4631" should match device "rak4631" via the rak-stripped variant "4631". + val result = match(listOf(link("rokland-4631", regions = listOf("US"))), target = "rak4631", region = "US") + assertEquals(listOf("rokland-4631"), result) + } + + @Test + fun worldwideMarketplaceShowsRegardlessOfRegion() { + val links = listOf(link("rak4631_aliexpress", regions = emptyList())) + assertEquals(listOf("rak4631_aliexpress"), match(links, target = "rak4631", region = "ZZ")) + } + + @Test + fun unrelatedLinksProduceEmptyResult() { + val links = + listOf( + link("github"), + link("heltec-v3", isVendor = true), + link("rokland-heltec-v3", regions = listOf("US")), + ) + assertEquals(emptyList(), match(links, target = "tbeam")) + } + + @Test + fun anotherDevicesTargetIsNotMatchedAsVariant() { + // "rak4631_nomadstar_meteor_pro" prefix-matches "rak4631_" but is itself a device target → excluded. + val result = match(listOf(link("rak4631_nomadstar_meteor_pro")), target = "rak4631") + assertEquals(emptyList(), result) + } + + @Test + fun vendorAndVariantSortBeforeMarketplace() { + val links = + listOf( + link("rak4631_aliexpress", regions = emptyList()), + link("rak4631", isVendor = true), + link("rokland-rak4631", regions = listOf("US")), + link("rak4631_epaper"), + ) + val result = match(links, target = "rak4631", region = "US") + // Vendor + variant first (order among them preserved from input), marketplace links after. + assertEquals(listOf("rak4631", "rak4631_epaper", "rak4631_aliexpress", "rokland-rak4631"), result) + } + + @Test + fun buildTargetVariantsStripsRakPrefix() { + assertEquals(listOf("rak4631", "4631"), DeviceLinkMatcher.buildTargetVariants("rak4631")) + assertEquals(listOf("heltec-v3"), DeviceLinkMatcher.buildTargetVariants("heltec-v3")) + // Bare "rak" strips to empty and is not added. + assertEquals(listOf("rak"), DeviceLinkMatcher.buildTargetVariants("rak")) + } + + @Test + fun isMarketplaceLinkDetectsPrefixAndSuffix() { + assertTrue(DeviceLinkMatcher.isMarketplaceLink("rokland-rak4631", marketplaceKeys)) + assertTrue(DeviceLinkMatcher.isMarketplaceLink("heltec-v3_aliexpress", marketplaceKeys)) + assertFalse(DeviceLinkMatcher.isMarketplaceLink("heltec-v3", marketplaceKeys)) + } + + @Test + fun marketplaceKeyForUsesDelimiterBounds() { + // Both prefix and suffix forms resolve to their marketplace... + assertEquals("rokland", DeviceLinkMatcher.marketplaceKeyFor("rokland-rak4631", marketplaceKeys)) + assertEquals("aliexpress", DeviceLinkMatcher.marketplaceKeyFor("aliexpress-rak1921", marketplaceKeys)) + assertEquals("aliexpress", DeviceLinkMatcher.marketplaceKeyFor("rak4631_aliexpress", marketplaceKeys)) + // ...but a code that merely begins with a marketplace name is NOT that marketplace. + assertNull(DeviceLinkMatcher.marketplaceKeyFor("muziworks", marketplaceKeys)) + assertNull(DeviceLinkMatcher.marketplaceKeyFor("heltec-v3", marketplaceKeys)) + } +} diff --git a/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt new file mode 100644 index 000000000..a76c648f2 --- /dev/null +++ b/core/data/src/jvmTest/kotlin/org/meshtastic/core/data/repository/DeviceLinkRepositoryImplTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.data.repository + +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource +import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource +import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource +import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.model.MshToMarketplace +import org.meshtastic.core.model.MshToRoute +import org.meshtastic.core.model.NetworkDeviceHardware +import org.meshtastic.core.testing.FakeDatabaseProvider +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DeviceLinkRepositoryImplTest { + + private class FakeMshToLinksJsonDataSource( + var routes: List, + var marketplaces: Map, + ) : MshToLinksJsonDataSource { + override fun loadRoutes(): List = routes + + override fun loadMarketplaces(): Map = marketplaces + } + + private val dispatcher = UnconfinedTestDispatcher() + private val dispatchers = CoroutineDispatchers(main = dispatcher, io = dispatcher, default = dispatcher) + + private lateinit var dbProvider: FakeDatabaseProvider + private lateinit var linkLocal: DeviceLinkLocalDataSource + private lateinit var hardwareLocal: DeviceHardwareLocalDataSource + private lateinit var json: FakeMshToLinksJsonDataSource + private lateinit var repository: DeviceLinkRepositoryImpl + + private val marketplaces = + mapOf( + "rokland" to MshToMarketplace(regions = listOf("US"), match = "prefix"), + "aliexpress" to MshToMarketplace(regions = emptyList(), match = "suffix"), + ) + + private fun route(shortCode: String) = + MshToRoute(shortCode = shortCode, originalUrl = "https://example.com/$shortCode", description = shortCode) + + @BeforeTest + fun setup() { + dbProvider = FakeDatabaseProvider() + linkLocal = DeviceLinkLocalDataSource(dbProvider, dispatchers) + hardwareLocal = DeviceHardwareLocalDataSource(dbProvider, dispatchers) + json = + FakeMshToLinksJsonDataSource( + routes = + listOf(route("rak4631"), route("rokland-rak4631"), route("rak4631_aliexpress"), route("github")), + marketplaces = marketplaces, + ) + repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal) + } + + @AfterTest fun tearDown() = dbProvider.close() + + private suspend fun seedDeviceTargets(vararg targets: String) { + hardwareLocal.insertAllDeviceHardware( + targets.mapIndexed { i, t -> NetworkDeviceHardware(hwModel = i + 1, platformioTarget = t) }, + ) + } + + @Test + fun importClassifiesVendorAndMarketplaceLinks() = runTest(dispatcher) { + seedDeviceTargets("rak4631", "heltec-v3") + repository.reconcile() + + val byCode = linkLocal.getAll().associateBy { it.shortCode } + assertEquals(4, byCode.size) + + // rak4631 is a known device target → vendor, no regions. + assertTrue(byCode.getValue("rak4631").isVendor) + assertNull(byCode.getValue("rak4631").regions) + + // rokland-rak4631 → prefix marketplace, region-tagged. + assertTrue(!byCode.getValue("rokland-rak4631").isVendor) + assertEquals(listOf("US"), byCode.getValue("rokland-rak4631").regions) + + // rak4631_aliexpress → suffix marketplace, worldwide (empty regions). + assertEquals(emptyList(), byCode.getValue("rak4631_aliexpress").regions) + + // github → neither vendor nor marketplace, null regions. + assertTrue(!byCode.getValue("github").isVendor) + assertNull(byCode.getValue("github").regions) + } + + @Test + fun reconcilePrunesOrphanedShortCodes() = runTest(dispatcher) { + seedDeviceTargets("rak4631") + repository.reconcile() + assertEquals(4, linkLocal.count()) + + // Drop "github" from the bundled file and reconcile again. + json.routes = json.routes.filterNot { it.shortCode == "github" } + repository.reconcile() + + val codes = linkLocal.getAll().map { it.shortCode }.toSet() + assertEquals(setOf("rak4631", "rokland-rak4631", "rak4631_aliexpress"), codes) + } + + @Test + fun aliexpressPrefixFormIsClassifiedAsWorldwideMarketplace() = runTest(dispatcher) { + // AliExpress is declared match="suffix" yet most bundled codes use the `aliexpress-` prefix form; + // import must still classify it as a (worldwide) marketplace link, not a null-region variant. + json.routes = listOf(route("rak4631"), route("aliexpress-rak4631")) + seedDeviceTargets("rak4631") + repository.reconcile() + + assertEquals(emptyList(), linkLocal.getAll().single { it.shortCode == "aliexpress-rak4631" }.regions) + } + + @Test + fun bareMarketplaceNamePrefixIsNotMistagged() = runTest(dispatcher) { + // "muziworks" merely begins with "muzi" — delimiter bounds must keep it from inheriting muzi's regions. + json = + FakeMshToLinksJsonDataSource( + routes = listOf(route("muziworks")), + marketplaces = mapOf("muzi" to MshToMarketplace(regions = listOf("US"), match = "prefix")), + ) + repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal) + seedDeviceTargets("rak4631") + repository.reconcile() + + assertNull(linkLocal.getAll().single().regions) + } + + @Test + fun ensureImportedSeedsOnlyWhenEmpty() = runTest(dispatcher) { + seedDeviceTargets("rak4631") + repository.ensureImported() + assertEquals(4, linkLocal.count()) + + // A second ensureImported with a larger bundled file must NOT re-import (table already populated). + json.routes = json.routes + route("new-code") + repository.ensureImported() + assertEquals(4, linkLocal.count()) + } +} diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/41.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/41.json new file mode 100644 index 000000000..4de2f4048 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/41.json @@ -0,0 +1,1142 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "4c27816eb6e2b8336fd9ca69b9cd373b", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "device_link", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `original_url` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, PRIMARY KEY(`short_code`))", + "fields": [ + { + "fieldPath": "shortCode", + "columnName": "short_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "originalUrl", + "columnName": "original_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkDescription", + "columnName": "link_description", + "affinity": "TEXT" + }, + { + "fieldPath": "isVendor", + "columnName": "is_vendor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regions", + "columnName": "regions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "short_code" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4c27816eb6e2b8336fd9ca69b9cd373b')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 82eb8b003..205d00f73 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -25,6 +25,7 @@ import androidx.room3.TypeConverters import androidx.room3.migration.AutoMigrationSpec import org.meshtastic.core.common.util.ioDispatcher import org.meshtastic.core.database.dao.DeviceHardwareDao +import org.meshtastic.core.database.dao.DeviceLinkDao import org.meshtastic.core.database.dao.FirmwareReleaseDao import org.meshtastic.core.database.dao.MeshLogDao import org.meshtastic.core.database.dao.NodeInfoDao @@ -33,6 +34,7 @@ import org.meshtastic.core.database.dao.QuickChatActionDao import org.meshtastic.core.database.dao.TracerouteNodePositionDao import org.meshtastic.core.database.entity.ContactSettings import org.meshtastic.core.database.entity.DeviceHardwareEntity +import org.meshtastic.core.database.entity.DeviceLinkEntity import org.meshtastic.core.database.entity.FirmwareReleaseEntity import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.MetadataEntity @@ -57,6 +59,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity ReactionEntity::class, MetadataEntity::class, DeviceHardwareEntity::class, + DeviceLinkEntity::class, FirmwareReleaseEntity::class, TracerouteNodePositionEntity::class, ], @@ -99,8 +102,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 37, to = 38), AutoMigration(from = 38, to = 39), AutoMigration(from = 39, to = 40), + AutoMigration(from = 40, to = 41), ], - version = 40, + version = 41, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) @@ -117,6 +121,8 @@ abstract class MeshtasticDatabase : RoomDatabase() { abstract fun deviceHardwareDao(): DeviceHardwareDao + abstract fun deviceLinkDao(): DeviceLinkDao + abstract fun firmwareReleaseDao(): FirmwareReleaseDao abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt index 01f61e3ee..ae188d178 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt @@ -36,6 +36,9 @@ interface DeviceHardwareDao { @Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel AND platformio_target = :target") suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity? + @Query("SELECT platformio_target FROM device_hardware") + suspend fun getAllTargets(): List + @Query("SELECT COUNT(*) FROM device_hardware") suspend fun count(): Int diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceLinkDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceLinkDao.kt new file mode 100644 index 000000000..8221a8d6a --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceLinkDao.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.room3.Dao +import androidx.room3.Query +import androidx.room3.Upsert +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.database.entity.DeviceLinkEntity + +@Dao +interface DeviceLinkDao { + @Upsert suspend fun upsertAll(links: List) + + @Query("SELECT * FROM device_link ORDER BY short_code") + fun observeAll(): Flow> + + @Query("SELECT * FROM device_link") + suspend fun getAll(): List + + @Query("DELETE FROM device_link WHERE short_code NOT IN (:keep)") + suspend fun deleteNotIn(keep: List) + + @Query("DELETE FROM device_link") + suspend fun deleteAll() + + @Query("SELECT COUNT(*) FROM device_link") + suspend fun count(): Int +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceLinkEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceLinkEntity.kt new file mode 100644 index 000000000..91a9dde6b --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DeviceLinkEntity.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.entity + +import androidx.room3.ColumnInfo +import androidx.room3.Entity +import androidx.room3.PrimaryKey +import kotlinx.serialization.Serializable +import org.meshtastic.core.model.DeviceLink + +/** A msh.to short-link, upserted from the bundled `urls.json` during the device-hardware refresh cycle. */ +@Serializable +@Entity(tableName = "device_link") +data class DeviceLinkEntity( + @PrimaryKey @ColumnInfo(name = "short_code") val shortCode: String, + @ColumnInfo(name = "original_url") val originalUrl: String, + @ColumnInfo(name = "link_description") val linkDescription: String? = null, + @ColumnInfo(name = "is_vendor") val isVendor: Boolean = false, + val regions: List? = null, +) + +fun DeviceLink.asEntity() = DeviceLinkEntity( + shortCode = shortCode, + originalUrl = originalUrl, + linkDescription = description, + isVendor = isVendor, + regions = regions, +) + +fun DeviceLinkEntity.asExternalModel() = DeviceLink( + shortCode = shortCode, + originalUrl = originalUrl, + description = linkDescription, + isVendor = isVendor, + regions = regions, +) diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceLink.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceLink.kt new file mode 100644 index 000000000..20b9197a3 --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DeviceLink.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import kotlinx.serialization.Serializable + +/** + * A msh.to short-link associated with a piece of hardware. Imported from the bundled `urls.json` (sourced from the + * meshtastic/msh.to repo). Every link resolves through the msh.to redirect service. + * + * @param shortCode the msh.to short code, e.g. `rak_wismeshtag`, `rokland-heltec-v3`. + * @param originalUrl the destination URL recorded in `urls.json` (informational; the app links to msh.to). + * @param description human-readable label shown to the user. + * @param isVendor true when [shortCode] is itself a known device `platformioTarget` (the primary vendor link). + * @param regions marketplace shipping regions (ISO 3166-1 alpha-2). `null` = vendor/variant (not region-filtered); + * empty = worldwide marketplace; non-empty = limited to the listed countries. + */ +@Serializable +data class DeviceLink( + val shortCode: String, + val originalUrl: String, + val description: String? = null, + val isVendor: Boolean = false, + val regions: List? = null, +) { + /** The user-facing link, routed through the msh.to redirect service. */ + val url: String + get() = "https://msh.to/$shortCode" +} diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MshToLinks.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MshToLinks.kt new file mode 100644 index 000000000..241a88d4b --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MshToLinks.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** Root of the bundled `urls.json` file (imported as-is from the meshtastic/msh.to repo). */ +@Serializable data class MshToUrlsFile(@SerialName("Routes") val routes: List = emptyList()) + +/** A single short-code route in `urls.json`. */ +@Serializable +data class MshToRoute( + @SerialName("ShortCode") val shortCode: String, + @SerialName("OriginalUrl") val originalUrl: String, + @SerialName("Description") val description: String? = null, +) + +/** + * Marketplace metadata from the app-maintained `marketplaces.json`. Keyed by marketplace identifier (e.g. `rokland`, + * `aliexpress`). + * + * @param regions ISO 3166-1 alpha-2 shipping regions; empty = worldwide. + * @param match how the marketplace identifier appears in a short code: `"prefix"` (e.g. `rokland-heltec-v3`) or + * `"suffix"` (e.g. `heltec-v3_aliexpress`). + */ +@Serializable data class MshToMarketplace(val regions: List = emptyList(), val match: String) diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index a0a93caf1..eeb3c3fdd 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -170,6 +170,8 @@ sealed interface SettingsRoute : Route { @Serializable data object NodeList : SettingsRoute + @Serializable data object DeviceLinks : SettingsRoute + @Serializable data object AppFunctionsSettings : SettingsRoute // endregion diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceLinkRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceLinkRepository.kt new file mode 100644 index 000000000..7bfe3a221 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DeviceLinkRepository.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +import kotlinx.coroutines.flow.Flow +import org.meshtastic.core.common.util.currentRegionCode +import org.meshtastic.core.model.DeviceLink + +/** + * Provides msh.to device links imported from the bundled `urls.json`. Mirrors the Meshtastic-Apple device-links + * feature: vendor, product-variant, and region-filtered marketplace links shown on the device hardware detail view, + * plus a full directory. + */ +interface DeviceLinkRepository { + /** Seeds the link table from the bundled JSON if it is empty (covers fresh install, data clear, radio switch). */ + suspend fun ensureImported() + + /** Re-imports the bundled JSON: upserts all links, recomputes `isVendor`, and prunes orphaned short codes. */ + suspend fun reconcile() + + /** + * Links for a device's [platformioTarget], region-filtered and sorted with vendor/variant links first. Returns an + * empty list when no links match. + */ + suspend fun getLinksForTarget(platformioTarget: String, regionCode: String = currentRegionCode()): List + + /** All imported links, sorted by short code — backs the Settings "Device Links" directory. */ + fun observeAllLinks(): Flow> +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 134be20b6..2f5b28838 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -204,6 +204,7 @@ CODEC2 sample rate Coding Rate Collapse chart + Collapsed Communicate off-the-grid with your friends and community without cell service. Bearing: %1$s @@ -340,6 +341,9 @@ Device DB cache limit Max device databases to keep on this phone Device GPS + Device Links + I want one + Open in browser %1$s: %2$s Device Metrics %1$s @@ -457,6 +461,7 @@ Welcome to Open Sauce! 🔧 Exchange position Expand chart + Expanded Expires Export configuration diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index c877d43c7..2a1b0b376 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -37,7 +37,10 @@ import org.koin.dsl.module import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource +import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource import org.meshtastic.core.model.BootloaderOtaQuirk +import org.meshtastic.core.model.MshToMarketplace +import org.meshtastic.core.model.MshToRoute import org.meshtastic.core.model.NetworkDeviceHardware import org.meshtastic.core.model.NetworkFirmwareReleases import org.meshtastic.core.network.HttpClientDefaults @@ -267,4 +270,12 @@ private fun desktopPlatformStubsModule() = module { override fun loadBootloaderOtaQuirksFromJsonAsset(): List = emptyList() } } + + single { + object : MshToLinksJsonDataSource { + override fun loadRoutes(): List = emptyList() + + override fun loadMarketplaces(): Map = emptyMap() + } + } } diff --git a/docs/en/user/nodes.md b/docs/en/user/nodes.md index 4eb7a7fbc..07ba15460 100644 --- a/docs/en/user/nodes.md +++ b/docs/en/user/nodes.md @@ -2,7 +2,7 @@ title: Nodes parent: User Guide nav_order: 4 -last_updated: 2026-05-20 +last_updated: 2026-06-02 description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions. aliases: - node-list @@ -140,6 +140,12 @@ Inline status indicators show key metrics at a glance: | Last heard | ![Last heard](../../assets/screenshots/nodes_last_heard.png) | | Distance | ![Distance](../../assets/screenshots/nodes_distance_info.png) | +### Device Links ("I want one") + +When a node's hardware is recognized, the detail view shows a collapsible **"I want one"** section linking to places to buy or learn more about that device: the vendor's product page, product variants, and regional marketplace listings (such as AliExpress, Amazon, and supported retailers), filtered to your country. Each link opens through the `msh.to` redirect service. Devices with no matching links don't show the section. + +A full, browsable directory of every link is also available under **Settings → Device Links**. + ## Related Topics - [Node Metrics](node-metrics) — detailed telemetry dashboards for each node diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceLinksSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceLinksSection.kt new file mode 100644 index 000000000..316cab7db --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceLinksSection.kt @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.component + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.DeviceLink +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.collapsed +import org.meshtastic.core.resources.device_links_i_want_one +import org.meshtastic.core.resources.device_links_open_in_browser +import org.meshtastic.core.resources.expanded +import org.meshtastic.core.ui.icon.ExpandLess +import org.meshtastic.core.ui.icon.ExpandMore +import org.meshtastic.core.ui.icon.Language +import org.meshtastic.core.ui.icon.MeshtasticIcons + +/** + * Collapsible "I want one" section listing msh.to vendor/variant and marketplace links for the viewed device. Renders + * nothing when there are no matching links. Ported from the Meshtastic-Apple `DeviceLinksSection`. + */ +@Composable +fun DeviceLinksSection(links: List, modifier: Modifier = Modifier) { + if (links.isEmpty()) return + + var expanded by rememberSaveable { mutableStateOf(false) } + val title = stringResource(Res.string.device_links_i_want_one) + val expandStateDescription = stringResource(if (expanded) Res.string.expanded else Res.string.collapsed) + + ElevatedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh), + shape = MaterialTheme.shapes.extraLarge, + ) { + Column(modifier = Modifier.padding(vertical = 16.dp).animateContentSize()) { + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(role = Role.Button) { expanded = !expanded } + .semantics { stateDescription = expandStateDescription } + .padding(horizontal = 20.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + color = colorScheme.primary, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f).semantics { heading() }, + ) + Icon( + imageVector = if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore, + contentDescription = null, + tint = colorScheme.primary, + ) + } + if (expanded) { + links.forEach { DeviceLinkRow(it) } + } + } + } +} + +@Composable +private fun DeviceLinkRow(link: DeviceLink) { + val uriHandler = LocalUriHandler.current + // Vendor and product-variant links are emphasized; marketplace links (region-tagged) are quieter. + val prominent = link.isVendor || link.regions == null + val openLabel = stringResource(Res.string.device_links_open_in_browser) + val label = link.description ?: link.shortCode + + Row( + modifier = + Modifier.fillMaxWidth() + .defaultMinSize(minHeight = 48.dp) + .clickable(role = Role.Button) { uriHandler.openUri(link.url) } + .padding(horizontal = 20.dp, vertical = 8.dp) + .semantics { contentDescription = "$openLabel: $label" }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = if (prominent) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium, + fontWeight = if (prominent) FontWeight.SemiBold else FontWeight.Normal, + color = colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = MeshtasticIcons.Language, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = colorScheme.primary, + ) + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt index bc8f7f8c6..70b79be5b 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailComponentPreviews.kt @@ -183,3 +183,30 @@ private fun NodeDetailsSectionWithDeviceHeroPreview() { Surface { NodeDetailsSection(node = node, deviceHardware = deviceHardware, reportedTarget = "heltec-v3") } } } + +@PreviewLightDark +@Composable +private fun DeviceLinksSectionPreview() { + val links = + listOf( + org.meshtastic.core.model.DeviceLink( + shortCode = "heltec-v3", + originalUrl = "https://heltec.org", + description = "Heltec V3", + isVendor = true, + ), + org.meshtastic.core.model.DeviceLink( + shortCode = "rokland-heltec-v3", + originalUrl = "https://rokland.com", + description = "Rokland", + regions = listOf("US"), + ), + org.meshtastic.core.model.DeviceLink( + shortCode = "heltec-v3_aliexpress", + originalUrl = "https://aliexpress.com", + description = "AliExpress", + regions = emptyList(), + ), + ) + AppTheme { Surface { DeviceLinksSection(links = links) } } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index a102bf08b..357e0eca3 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -41,6 +41,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.loading import org.meshtastic.feature.node.component.AdministrationSection import org.meshtastic.feature.node.component.DeviceActions +import org.meshtastic.feature.node.component.DeviceLinksSection import org.meshtastic.feature.node.component.NodeDetailsSection import org.meshtastic.feature.node.component.NotesSection import org.meshtastic.feature.node.model.NodeDetailAction @@ -109,6 +110,9 @@ fun NodeDetailList( reportedTarget = uiState.metricsState.reportedTarget, ) } + if (uiState.metricsState.deviceLinks.isNotEmpty()) { + item { DeviceLinksSection(links = uiState.metricsState.deviceLinks) } + } item { DeviceActions( node = node, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index 34c3a1b96..930479f81 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -22,16 +22,19 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onStart import org.koin.core.annotation.Single import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.DeviceLink import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.MyNodeInfo import org.meshtastic.core.model.Node import org.meshtastic.core.model.util.hasValidEnvironmentMetrics import org.meshtastic.core.model.util.isDirectSignal import org.meshtastic.core.repository.DeviceHardwareRepository +import org.meshtastic.core.repository.DeviceLinkRepository import org.meshtastic.core.repository.FirmwareReleaseRepository import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository @@ -59,6 +62,7 @@ constructor( private val meshLogRepository: MeshLogRepository, private val radioConfigRepository: RadioConfigRepository, private val deviceHardwareRepository: DeviceHardwareRepository, + private val deviceLinkRepository: DeviceLinkRepository, private val firmwareReleaseRepository: FirmwareReleaseRepository, private val nodeRequestActions: NodeRequestActions, ) : GetNodeDetailsUseCase { @@ -114,8 +118,8 @@ constructor( IdentityGroup(ourNode, myInfo, profile) } - // 3. Device Hardware — non-blocking Flow derived from stable (hwModel, pioEnv) key. - val hardwareFlow: Flow = + // 3. Device Hardware (+ msh.to links) — non-blocking Flow derived from stable (hwModel, pioEnv) key. + val hardwareAndLinksFlow: Flow>> = combine(nodeFlow, identityFlow) { node, identity -> val isLocal = node.num == identity.ourNode?.num val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null @@ -124,6 +128,13 @@ constructor( .distinctUntilChanged() .flatMapLatest { key -> deviceHardwareRepository.observeDeviceHardware(key.hwModel, key.target) } .onStart { emit(null) } + .mapLatest { hw -> + val links = + hw?.platformioTarget + ?.takeIf { it.isNotBlank() } + ?.let { deviceLinkRepository.getLinksForTarget(it) } ?: emptyList() + hw to links + } // 4. Metadata & Request Timestamps val metadataFlow = @@ -157,7 +168,7 @@ constructor( identityFlow, metadataFlow, requestsFlow, - hardwareFlow, + hardwareAndLinksFlow, ) { args: Array -> @Suppress("UNCHECKED_CAST") val node = args[NODE_INDEX] as Node @@ -165,7 +176,7 @@ constructor( val identity = args[IDENTITY_INDEX] as IdentityGroup val metadata = args[METADATA_INDEX] as MetadataGroup val requests = args[REQUESTS_INDEX] as Pair, List> - val hw = args[HARDWARE_INDEX] as DeviceHardware? + val (hw, deviceLinks) = args[HARDWARE_INDEX] as Pair> val (trReqs, niReqs) = requests val isLocal = node.num == identity.ourNode?.num @@ -179,6 +190,7 @@ constructor( node = node, isLocal = isLocal, deviceHardware = hw, + deviceLinks = deviceLinks, reportedTarget = pioEnv, isManaged = identity.profile.config?.security?.is_managed ?: false, isFahrenheit = diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index f945daf84..7e89b6604 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -18,6 +18,7 @@ package org.meshtastic.feature.node.model import org.meshtastic.core.database.entity.FirmwareRelease import org.meshtastic.core.model.DeviceHardware +import org.meshtastic.core.model.DeviceLink import org.meshtastic.core.model.MeshLog import org.meshtastic.core.model.Node import org.meshtastic.proto.Config @@ -42,6 +43,8 @@ data class MetricsState( val neighborInfoResults: List = emptyList(), val positionLogs: List = emptyList(), val deviceHardware: DeviceHardware? = null, + /** msh.to vendor/marketplace links for this device's hardware, region-filtered and sorted (vendor first). */ + val deviceLinks: List = emptyList(), val firmwareEdition: FirmwareEdition? = null, val latestStableFirmware: FirmwareRelease = FirmwareRelease(), val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index d6d74cd65..98c4dcfc5 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -49,6 +49,7 @@ import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.app_functions_settings import org.meshtastic.core.resources.app_functions_settings_summary import org.meshtastic.core.resources.bottom_nav_settings +import org.meshtastic.core.resources.device_links import org.meshtastic.core.resources.export_configuration import org.meshtastic.core.resources.filter_settings import org.meshtastic.core.resources.help_and_documentation @@ -60,6 +61,7 @@ import org.meshtastic.core.resources.wifi_devices import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog +import org.meshtastic.core.ui.icon.Device import org.meshtastic.core.ui.icon.FilterList import org.meshtastic.core.ui.icon.HelpOutline import org.meshtastic.core.ui.icon.List @@ -272,6 +274,12 @@ fun SettingsScreen( } } + ExpressiveSection(title = stringResource(Res.string.device_links)) { + ListItem(text = stringResource(Res.string.device_links), leadingIcon = MeshtasticIcons.Device) { + onNavigate(SettingsRoute.DeviceLinks) + } + } + if (appFunctionsAvailable) { ExpressiveSection(title = stringResource(Res.string.app_functions_settings)) { ListItem( diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryScreen.kt new file mode 100644 index 000000000..5b930ff1e --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryScreen.kt @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.device_links +import org.meshtastic.core.ui.component.ListItem +import org.meshtastic.core.ui.component.MainAppBar +import org.meshtastic.core.ui.icon.Language +import org.meshtastic.core.ui.icon.MeshtasticIcons + +/** Directory of every imported msh.to short code. Tapping a row opens `msh.to/{shortCode}` in the browser. */ +@Composable +fun DeviceLinkDirectoryScreen( + viewModel: DeviceLinkDirectoryViewModel, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + val links by viewModel.links.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current + + Scaffold( + modifier = modifier, + topBar = { + MainAppBar( + title = stringResource(Res.string.device_links), + canNavigateUp = true, + onNavigateUp = onNavigateUp, + ourNode = null, + showNodeChip = false, + actions = {}, + onClickChip = {}, + ) + }, + ) { paddingValues -> + LazyColumn(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + items(links, key = { it.shortCode }) { link -> + ListItem( + text = link.description ?: link.shortCode, + supportingText = "msh.to/${link.shortCode}", + trailingIcon = MeshtasticIcons.Language, + onClick = { uriHandler.openUri(link.url) }, + ) + } + } + } +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryViewModel.kt new file mode 100644 index 000000000..f7afc93bb --- /dev/null +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/DeviceLinkDirectoryViewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings + +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.StateFlow +import org.koin.core.annotation.KoinViewModel +import org.meshtastic.core.model.DeviceLink +import org.meshtastic.core.repository.DeviceLinkRepository +import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed + +/** Backs the Settings "Device Links" directory: all imported msh.to links, sorted by short code. */ +@KoinViewModel +class DeviceLinkDirectoryViewModel(deviceLinkRepository: DeviceLinkRepository) : ViewModel() { + val links: StateFlow> = deviceLinkRepository.observeAllLinks().stateInWhileSubscribed(emptyList()) +} diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt index a38c3665f..5f81667bb 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/navigation/SettingsNavigation.kt @@ -33,6 +33,7 @@ import org.meshtastic.core.navigation.SettingsRoute import org.meshtastic.feature.settings.AboutScreen import org.meshtastic.feature.settings.AdministrationScreen import org.meshtastic.feature.settings.DeviceConfigurationScreen +import org.meshtastic.feature.settings.DeviceLinkDirectoryScreen import org.meshtastic.feature.settings.ModuleConfigurationScreen import org.meshtastic.feature.settings.NodeListScreen import org.meshtastic.feature.settings.SettingsViewModel @@ -251,6 +252,12 @@ fun EntryProviderScope.settingsGraph(backStack: NavBackStack) { ) } + entry { + DeviceLinkDirectoryScreen( + viewModel = koinViewModel(), + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + ) + } entry { val viewModel: AppFunctionsSettingsViewModel = koinViewModel() AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() }) diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 1036a0891..4c34562d9 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -48,6 +48,7 @@ import org.meshtastic.core.resources.app_version import org.meshtastic.core.resources.bottom_nav_settings import org.meshtastic.core.resources.device_db_cache_limit import org.meshtastic.core.resources.device_db_cache_limit_summary +import org.meshtastic.core.resources.device_links import org.meshtastic.core.resources.help_and_documentation import org.meshtastic.core.resources.info import org.meshtastic.core.resources.modules_already_unlocked @@ -62,6 +63,7 @@ import org.meshtastic.core.ui.component.ListItem import org.meshtastic.core.ui.component.MainAppBar import org.meshtastic.core.ui.component.MeshtasticDialog import org.meshtastic.core.ui.icon.ChevronRight +import org.meshtastic.core.ui.icon.Device import org.meshtastic.core.ui.icon.FormatPaint import org.meshtastic.core.ui.icon.HelpOutline import org.meshtastic.core.ui.icon.Info @@ -213,6 +215,12 @@ fun DesktopSettingsScreen( } } + ExpressiveSection(title = stringResource(Res.string.device_links)) { + ListItem(text = stringResource(Res.string.device_links), leadingIcon = MeshtasticIcons.Device) { + onNavigate(SettingsRoute.DeviceLinks) + } + } + ExpressiveSection(title = stringResource(Res.string.wifi_devices)) { ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) { onNavigate(WifiProvisionRoute.WifiProvision())