├── web ├── public │ ├── .keep │ ├── favicon.ico │ ├── favicon.png │ └── assets │ │ └── js │ │ ├── app │ │ ├── index.js │ │ ├── config.js │ │ ├── message-limit.js │ │ ├── __tests__ │ │ │ ├── message-limit.test.js │ │ │ ├── short-info-satellites.test.js │ │ │ ├── dom-environment.test.js │ │ │ ├── role-helpers.test.js │ │ │ ├── map-auto-fit-settings.test.js │ │ │ ├── node-modem-metadata.test.js │ │ │ ├── document-stub.js │ │ │ ├── node-snapshot-normalizer.test.js │ │ │ └── chat-log-highlights.test.js │ │ ├── map-auto-fit-settings.js │ │ ├── short-info-satellites.js │ │ ├── node-modem-metadata.js │ │ └── settings.js │ │ └── background.js ├── package.json ├── app.sh ├── app.rb ├── views │ ├── map.erb │ ├── nodes.erb │ ├── shared │ │ ├── _chat_panel.erb │ │ ├── _map_panel.erb │ │ └── _instances_table.erb │ ├── index.erb │ ├── chat.erb │ ├── federation.erb │ ├── charts.erb │ └── node_detail.erb ├── lib │ └── potato_mesh │ │ ├── application │ │ └── errors.rb │ │ └── meta.rb ├── Gemfile ├── spec │ ├── logging_spec.rb │ ├── spec_helper.rb │ ├── networking_spec.rb │ └── worker_pool_spec.rb └── Dockerfile ├── app ├── ios │ ├── Flutter │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── AppFrameworkInfo.plist │ ├── Runner │ │ ├── Runner-Bridging-Header.h │ │ ├── Assets.xcassets │ │ │ ├── LaunchImage.imageset │ │ │ │ ├── LaunchImage.png │ │ │ │ ├── LaunchImage@2x.png │ │ │ │ ├── LaunchImage@3x.png │ │ │ │ ├── README.md │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ ├── Icon-App-20x20@1x.png │ │ │ │ ├── Icon-App-20x20@2x.png │ │ │ │ ├── Icon-App-20x20@3x.png │ │ │ │ ├── Icon-App-29x29@1x.png │ │ │ │ ├── Icon-App-29x29@2x.png │ │ │ │ ├── Icon-App-29x29@3x.png │ │ │ │ ├── Icon-App-40x40@1x.png │ │ │ │ ├── Icon-App-40x40@2x.png │ │ │ │ ├── Icon-App-40x40@3x.png │ │ │ │ ├── Icon-App-50x50@1x.png │ │ │ │ ├── Icon-App-50x50@2x.png │ │ │ │ ├── Icon-App-57x57@1x.png │ │ │ │ ├── Icon-App-57x57@2x.png │ │ │ │ ├── Icon-App-60x60@2x.png │ │ │ │ ├── Icon-App-60x60@3x.png │ │ │ │ ├── Icon-App-72x72@1x.png │ │ │ │ ├── Icon-App-72x72@2x.png │ │ │ │ ├── Icon-App-76x76@1x.png │ │ │ │ ├── Icon-App-76x76@2x.png │ │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ │ └── Contents.json │ │ │ └── LaunchBackground.imageset │ │ │ │ ├── background.png │ │ │ │ └── Contents.json │ │ ├── AppDelegate.swift │ │ ├── Base.lproj │ │ │ ├── Main.storyboard │ │ │ └── LaunchScreen.storyboard │ │ └── Info.plist │ ├── Runner.xcodeproj │ │ └── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ ├── RunnerTests │ │ └── RunnerTests.swift │ ├── .gitignore │ └── Podfile ├── assets │ ├── icon.png │ ├── icon-splash.png │ └── icon-launcher.png ├── android │ ├── gradle │ │ └── wrapper │ │ │ ├── gradle-wrapper.jar │ │ │ └── gradle-wrapper.properties │ ├── app │ │ ├── src │ │ │ ├── main │ │ │ │ ├── res │ │ │ │ │ ├── drawable │ │ │ │ │ │ ├── background.png │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── drawable-hdpi │ │ │ │ │ │ ├── splash.png │ │ │ │ │ │ ├── android12splash.png │ │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ │ ├── drawable-mdpi │ │ │ │ │ │ ├── splash.png │ │ │ │ │ │ ├── android12splash.png │ │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ │ ├── drawable-xhdpi │ │ │ │ │ │ ├── splash.png │ │ │ │ │ │ ├── android12splash.png │ │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ │ ├── drawable-xxhdpi │ │ │ │ │ │ ├── splash.png │ │ │ │ │ │ ├── android12splash.png │ │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ │ ├── drawable-v21 │ │ │ │ │ │ ├── background.png │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── drawable-xxxhdpi │ │ │ │ │ │ ├── splash.png │ │ │ │ │ │ ├── android12splash.png │ │ │ │ │ │ └── ic_launcher_foreground.png │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ │ └── ic_launcher.png │ │ │ │ │ ├── values │ │ │ │ │ │ ├── colors.xml │ │ │ │ │ │ └── styles.xml │ │ │ │ │ ├── drawable-night-hdpi │ │ │ │ │ │ └── android12splash.png │ │ │ │ │ ├── drawable-night-mdpi │ │ │ │ │ │ └── android12splash.png │ │ │ │ │ ├── drawable-night-xhdpi │ │ │ │ │ │ └── android12splash.png │ │ │ │ │ ├── drawable-night-xxhdpi │ │ │ │ │ │ └── android12splash.png │ │ │ │ │ ├── drawable-night-xxxhdpi │ │ │ │ │ │ └── android12splash.png │ │ │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ │ │ └── ic_launcher.xml │ │ │ │ │ ├── values-v31 │ │ │ │ │ │ └── styles.xml │ │ │ │ │ ├── values-night-v31 │ │ │ │ │ │ └── styles.xml │ │ │ │ │ └── values-night │ │ │ │ │ │ └── styles.xml │ │ │ │ ├── kotlin │ │ │ │ │ └── net │ │ │ │ │ │ └── potatomesh │ │ │ │ │ │ └── reader │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── AndroidManifest.xml │ │ │ ├── debug │ │ │ │ └── AndroidManifest.xml │ │ │ └── profile │ │ │ │ └── AndroidManifest.xml │ │ └── build.gradle.kts │ ├── gradle.properties │ ├── .gitignore │ ├── build.gradle.kts │ ├── settings.gradle.kts │ ├── potato_mesh_reader_android.iml │ └── gradlew.bat ├── README.md ├── debug.sh ├── .gitignore ├── release.sh ├── pubspec.yaml ├── test │ ├── theme_preference_store_test.dart │ ├── notification_client_test.dart │ ├── notification_sender_test.dart │ ├── widget_test.dart │ └── cache_test.dart ├── analysis_options.yaml └── lib │ └── dart_plugin_registrant.dart ├── matrix ├── .gitignore ├── Config.toml ├── Cargo.toml ├── docker-entrypoint.sh └── Dockerfile ├── scrot-0.1.png ├── scrot-0.2.png ├── scrot-0.3.png ├── scrot-0.4.png ├── scrot-0.5.png ├── tests ├── mesh.db └── test_version_sync.py ├── data ├── .gitignore ├── requirements.txt ├── mesh.sh ├── migrations │ ├── 20250206_add_encrypted_message_columns.sql │ ├── 20250310_add_message_reply_and_emoji_columns.sql │ ├── 20250301_add_lora_columns.sql │ └── 20250305_extend_environment_telemetry.sql ├── __init__.py ├── ingestors.sql ├── neighbors.sql ├── instances.sql ├── positions.sql ├── traces.sql ├── messages.sql ├── mesh.py ├── nodes.sql ├── Dockerfile └── telemetry.sql ├── .github ├── workflows │ ├── README.md │ ├── codeql.yml │ ├── javascript.yml │ ├── python.yml │ ├── mobile.yml │ ├── ruby.yml │ └── rust.yml └── dependabot.yml ├── .codecov.yml ├── .dockerignore ├── docker-compose.prod.yml ├── docker-compose.dev.yml ├── .gitignore ├── Dockerfile └── .env.example /web/public/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /app/ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include "Generated.xcconfig" 2 | -------------------------------------------------------------------------------- /matrix/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | coverage.lcov 3 | bridge_state.json 4 | -------------------------------------------------------------------------------- /app/ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /scrot-0.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/scrot-0.1.png -------------------------------------------------------------------------------- /scrot-0.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/scrot-0.2.png -------------------------------------------------------------------------------- /scrot-0.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/scrot-0.3.png -------------------------------------------------------------------------------- /scrot-0.4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/scrot-0.4.png -------------------------------------------------------------------------------- /scrot-0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/scrot-0.5.png -------------------------------------------------------------------------------- /tests/mesh.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/tests/mesh.db -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | *.db 2 | *.db-wal 3 | *.db-shm 4 | *.backup 5 | *.copy 6 | *.log 7 | -------------------------------------------------------------------------------- /app/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/assets/icon.png -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/web/public/favicon.png -------------------------------------------------------------------------------- /app/assets/icon-splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/assets/icon-splash.png -------------------------------------------------------------------------------- /app/assets/icon-launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/assets/icon-launcher.png -------------------------------------------------------------------------------- /app/android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable/background.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-hdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-hdpi/splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-mdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-mdpi/splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xhdpi/splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xxhdpi/splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-v21/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-v21/background.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xxxhdpi/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xxxhdpi/splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-hdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-hdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-mdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-mdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #111417 4 | -------------------------------------------------------------------------------- /app/android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError 2 | android.useAndroidX=true 3 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xhdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xhdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xxhdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xxhdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xxxhdpi/android12splash.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-night-hdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-night-hdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-night-mdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-night-mdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-night-xhdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-night-xxhdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-night-xxxhdpi/android12splash.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/android/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/background.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/l5yth/potato-mesh/HEAD/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /data/requirements.txt: -------------------------------------------------------------------------------- 1 | # Production dependencies 2 | meshtastic>=2.5.0 3 | protobuf>=5.27.2 4 | 5 | # Development dependencies (optional) 6 | black>=24.8.0 7 | pytest>=8.3.0 8 | pytest-cov>=5.0.0 9 | -------------------------------------------------------------------------------- /app/android/app/src/main/kotlin/net/potatomesh/reader/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package net.potatomesh.reader 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity : FlutterActivity() 6 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # Meshtastic Reader 2 | 3 | Meshtastic Reader – read-only PotatoMesh chat client for Android and iOS. 4 | 5 | ## Setup 6 | 7 | ```bash 8 | cd app 9 | flutter create . 10 | flutter pub get 11 | flutter run 12 | ``` 13 | -------------------------------------------------------------------------------- /app/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | zipStoreBase=GRADLE_USER_HOME 4 | zipStorePath=wrapper/dists 5 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip 6 | -------------------------------------------------------------------------------- /app/android/.gitignore: -------------------------------------------------------------------------------- 1 | /.gradle 2 | /captures/ 3 | /local.properties 4 | GeneratedPluginRegistrant.java 5 | .cxx/ 6 | 7 | # Remember to never publicly share your keystore. 8 | # See https://flutter.dev/to/reference-keystore 9 | key.properties 10 | **/*.keystore 11 | **/*.jks 12 | -------------------------------------------------------------------------------- /app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/ios/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | import XCTest 4 | 5 | class RunnerTests: XCTestCase { 6 | 7 | func testExample() { 8 | // If you add code to the Runner application, consider adding tests here. 9 | // See https://developer.apple.com/documentation/xctest for more information about using XCTest. 10 | } 11 | 12 | } 13 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /app/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchBackground.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "background.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import Flutter 2 | import UIKit 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | GeneratedPluginRegistrant.register(with: self) 11 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "LaunchImage.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "LaunchImage@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "LaunchImage@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/debug.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | export GIT_TAG="$(git describe --tags --abbrev=0)" 4 | export GIT_COMMITS="$(git rev-list --count ${GIT_TAG}..HEAD)" 5 | export GIT_SHA="$(git rev-parse --short=9 HEAD)" 6 | export GIT_DIRTY="$(git diff --quiet --ignore-submodules HEAD || echo true || echo false)" 7 | flutter clean 8 | flutter pub get 9 | flutter run \ 10 | --dart-define=GIT_TAG="${GIT_TAG}" \ 11 | --dart-define=GIT_COMMITS="${GIT_COMMITS}" \ 12 | --dart-define=GIT_SHA="${GIT_SHA}" \ 13 | --dart-define=GIT_DIRTY="${GIT_DIRTY}" \ 14 | --device-id 38151FDJH00D4C 15 | 16 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # FVM Version Cache 3 | .fvm/ 4 | 5 | # Scaffholding 6 | .dart_tool/ 7 | .flutter-plugins-dependencies 8 | .fvmrc 9 | .idea/ 10 | .metadata 11 | linux/ 12 | macos/ 13 | potato_mesh_reader.iml 14 | pubspec.lock 15 | web/ 16 | windows/ 17 | # Android/Gradle outputs 18 | android/.gradle/ 19 | android/app/build/ 20 | 21 | # iOS/Xcode outputs 22 | ios/Pods/ 23 | ios/.symlinks/ 24 | ios/Flutter/ephemeral/ 25 | ios/Flutter/App.framework 26 | ios/Flutter/Flutter.framework 27 | ios/Flutter/flutter_export_environment.sh 28 | ios/Flutter/Generated.xcconfig 29 | ios/Runner/GeneratedPluginRegistrant.* 30 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows 2 | 3 | ## Workflows 4 | 5 | - **`docker.yml`** - Build and push Docker images to GHCR 6 | - **`codeql.yml`** - Security scanning 7 | - **`python.yml`** - Python ingestor pipeline 8 | - **`ruby.yml`** - Ruby Sinatra app testing 9 | - **`javascript.yml`** - Frontend test suite 10 | - **`mobile.yml`** - Flutter mobile tests with coverage reporting 11 | - **`release.yml`** - Tag-triggered Flutter release builds for Android and iOS 12 | 13 | ## Usage 14 | 15 | ```bash 16 | # Build locally 17 | docker-compose build 18 | 19 | # Deploy 20 | docker-compose up -d 21 | ``` 22 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "potato-mesh", 3 | "version": "0.5.9", 4 | "type": "module", 5 | "private": true, 6 | "scripts": { 7 | "test": "mkdir -p reports coverage && NODE_V8_COVERAGE=coverage node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=reports/javascript-junit.xml && node ./scripts/export-coverage.js" 8 | }, 9 | "devDependencies": { 10 | "istanbul-lib-coverage": "^3.2.2", 11 | "istanbul-lib-report": "^3.0.1", 12 | "istanbul-reports": "^3.2.0", 13 | "v8-to-istanbul": "^9.3.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/android/build.gradle.kts: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | val newBuildDir: Directory = 9 | rootProject.layout.buildDirectory 10 | .dir("../../build") 11 | .get() 12 | rootProject.layout.buildDirectory.value(newBuildDir) 13 | 14 | subprojects { 15 | val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) 16 | project.layout.buildDirectory.value(newSubprojectBuildDir) 17 | } 18 | subprojects { 19 | project.evaluationDependsOn(":app") 20 | } 21 | 22 | tasks.register("clean") { 23 | delete(rootProject.layout.buildDirectory) 24 | } 25 | -------------------------------------------------------------------------------- /app/ios/.gitignore: -------------------------------------------------------------------------------- 1 | **/dgph 2 | *.mode1v3 3 | *.mode2v3 4 | *.moved-aside 5 | *.pbxuser 6 | *.perspectivev3 7 | **/*sync/ 8 | .sconsign.dblite 9 | .tags* 10 | **/.vagrant/ 11 | **/DerivedData/ 12 | Icon? 13 | **/Pods/ 14 | **/.symlinks/ 15 | profile 16 | xcuserdata 17 | **/.generated/ 18 | Flutter/App.framework 19 | Flutter/Flutter.framework 20 | Flutter/Flutter.podspec 21 | Flutter/Generated.xcconfig 22 | Flutter/ephemeral/ 23 | Flutter/app.flx 24 | Flutter/app.zip 25 | Flutter/flutter_assets/ 26 | Flutter/flutter_export_environment.sh 27 | ServiceDefinitions.json 28 | Runner/GeneratedPluginRegistrant.* 29 | 30 | # Exceptions to above rules. 31 | !default.mode1v3 32 | !default.mode2v3 33 | !default.pbxuser 34 | !default.perspectivev3 35 | -------------------------------------------------------------------------------- /.codecov.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | coverage: 16 | status: 17 | project: 18 | default: 19 | target: 99% 20 | threshold: 1% 21 | -------------------------------------------------------------------------------- /matrix/Config.toml: -------------------------------------------------------------------------------- 1 | [potatomesh] 2 | # Base domain (with or without trailing slash) 3 | base_url = "https://potatomesh.net" 4 | # Poll interval in seconds 5 | poll_interval_secs = 60 6 | 7 | [matrix] 8 | # Homeserver base URL (client API) without trailing slash 9 | homeserver = "https://matrix.dod.ngo" 10 | # Appservice access token (from your registration.yaml) 11 | as_token = "INVALID_TOKEN_NOT_WORKING" 12 | # Server name (domain) part of Matrix user IDs 13 | server_name = "dod.ngo" 14 | # Room ID to send into (must be joined by the appservice / puppets) 15 | room_id = "!sXabOBXbVObAlZQEUs:c-base.org" # "#potato-bridge:c-base.org" 16 | 17 | [state] 18 | # Where to persist last seen message id (optional but recommended) 19 | state_file = "bridge_state.json" 20 | 21 | -------------------------------------------------------------------------------- /web/app.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright © 2025-26 l5yth & contributors 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -euo pipefail 17 | 18 | bundle install 19 | 20 | exec bundle exec ruby app.rb -p 41447 -o 0.0.0.0 21 | -------------------------------------------------------------------------------- /web/app.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | require_relative "lib/potato_mesh/application" 18 | 19 | PotatoMesh::Application.run! if $PROGRAM_NAME == __FILE__ 20 | -------------------------------------------------------------------------------- /web/views/map.erb: -------------------------------------------------------------------------------- 1 | 16 |
17 | <%= erb :"shared/_map_panel", locals: { full_screen: true } %> 18 |
19 | -------------------------------------------------------------------------------- /web/views/nodes.erb: -------------------------------------------------------------------------------- 1 | 16 |
17 | <%= erb :"shared/_nodes_table", locals: { full_screen: true } %> 18 |
19 | -------------------------------------------------------------------------------- /data/mesh.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright © 2025-26 l5yth & contributors 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -euo pipefail 17 | 18 | python -m venv .venv 19 | source .venv/bin/activate 20 | pip install -U meshtastic black pytest 21 | exec python mesh.py 22 | -------------------------------------------------------------------------------- /data/migrations/20250206_add_encrypted_message_columns.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | -- Add support for encrypted messages to the existing schema. 16 | BEGIN; 17 | ALTER TABLE messages ADD COLUMN encrypted TEXT; 18 | COMMIT; 19 | -------------------------------------------------------------------------------- /web/views/shared/_chat_panel.erb: -------------------------------------------------------------------------------- 1 | 16 | <% chat_classes = ["chat-panel"] 17 | chat_classes << "chat-panel--full" if defined?(full_screen) && full_screen %> 18 |
" aria-label="Chat log">
19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | version: 2 16 | updates: 17 | - package-ecosystem: "ruby" 18 | directory: "/web" 19 | schedule: 20 | interval: "weekly" 21 | - package-ecosystem: "python" 22 | directory: "/" 23 | schedule: 24 | interval: "weekly" 25 | -------------------------------------------------------------------------------- /app/android/settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | val flutterSdkPath = 3 | run { 4 | val properties = java.util.Properties() 5 | file("local.properties").inputStream().use { properties.load(it) } 6 | val flutterSdkPath = properties.getProperty("flutter.sdk") 7 | require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } 8 | flutterSdkPath 9 | } 10 | 11 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 12 | 13 | repositories { 14 | google() 15 | mavenCentral() 16 | gradlePluginPortal() 17 | } 18 | } 19 | 20 | plugins { 21 | id("dev.flutter.flutter-plugin-loader") version "1.0.0" 22 | id("com.android.application") version "8.11.1" apply false 23 | id("org.jetbrains.kotlin.android") version "2.2.20" apply false 24 | } 25 | 26 | include(":app") 27 | -------------------------------------------------------------------------------- /web/lib/potato_mesh/application/errors.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | module PotatoMesh 18 | module App 19 | # Raised when a remote instance fails to provide valid federation data. 20 | class InstanceFetchError < StandardError; end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 0.5.9 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 0.5.9 23 | MinimumOSVersion 24 | 14.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /web/views/index.erb: -------------------------------------------------------------------------------- 1 | 16 |
17 | <% unless private_mode %> 18 | <%= erb :"shared/_chat_panel", locals: { full_screen: false } %> 19 | <% end %> 20 | <%= erb :"shared/_map_panel", locals: { full_screen: false } %> 21 |
22 | 23 | <%= erb :"shared/_nodes_table", locals: { full_screen: false } %> 24 | -------------------------------------------------------------------------------- /data/migrations/20250310_add_message_reply_and_emoji_columns.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | -- Extend the messages table to capture reply relationships and emoji reactions. 16 | 17 | BEGIN; 18 | ALTER TABLE messages ADD COLUMN reply_id INTEGER; 19 | ALTER TABLE messages ADD COLUMN emoji TEXT; 20 | CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id); 21 | COMMIT; 22 | -------------------------------------------------------------------------------- /data/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Data utilities for the Potato Mesh synchronisation daemon. 16 | 17 | The ``data.mesh`` module exposes helpers for reading Meshtastic node and 18 | message information before forwarding it to the accompanying web application. 19 | """ 20 | 21 | VERSION = "0.5.9" 22 | """Semantic version identifier shared with the dashboard and front-end.""" 23 | 24 | __version__ = VERSION 25 | -------------------------------------------------------------------------------- /data/ingestors.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | PRAGMA journal_mode=WAL; 16 | 17 | CREATE TABLE IF NOT EXISTS ingestors ( 18 | node_id TEXT PRIMARY KEY, 19 | start_time INTEGER NOT NULL, 20 | last_seen_time INTEGER NOT NULL, 21 | version TEXT, 22 | lora_freq INTEGER, 23 | modem_preset TEXT 24 | ); 25 | 26 | CREATE INDEX IF NOT EXISTS idx_ingestors_last_seen ON ingestors(last_seen_time); 27 | -------------------------------------------------------------------------------- /data/migrations/20250301_add_lora_columns.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | -- Extend the nodes and messages tables with LoRa metadata columns. 16 | 17 | BEGIN; 18 | ALTER TABLE nodes ADD COLUMN lora_freq INTEGER; 19 | ALTER TABLE nodes ADD COLUMN modem_preset TEXT; 20 | ALTER TABLE messages ADD COLUMN lora_freq INTEGER; 21 | ALTER TABLE messages ADD COLUMN modem_preset TEXT; 22 | ALTER TABLE messages ADD COLUMN channel_name TEXT; 23 | COMMIT; 24 | -------------------------------------------------------------------------------- /web/views/chat.erb: -------------------------------------------------------------------------------- 1 | 16 | <% if private_mode %> 17 |
18 |

Chat is unavailable while private mode is enabled.

19 |
20 | <% else %> 21 |
22 | <%= erb :"shared/_chat_panel", locals: { full_screen: true } %> 23 |
24 | <% end %> 25 | -------------------------------------------------------------------------------- /app/release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | export GIT_TAG="$(git describe --tags --abbrev=0)" 6 | export GIT_COMMITS="$(git rev-list --count ${GIT_TAG}..HEAD)" 7 | export GIT_SHA="$(git rev-parse --short=9 HEAD)" 8 | export GIT_DIRTY="$(git diff --quiet --ignore-submodules HEAD || echo true || echo false)" 9 | flutter clean 10 | flutter pub get 11 | flutter build apk --release \ 12 | --dart-define=GIT_TAG="${GIT_TAG}" \ 13 | --dart-define=GIT_COMMITS="${GIT_COMMITS}" \ 14 | --dart-define=GIT_SHA="${GIT_SHA}" \ 15 | --dart-define=GIT_DIRTY="${GIT_DIRTY}" 16 | 17 | if [ "$GIT_COMMITS" -eq 0 ]; then 18 | TAG_NAME="$GIT_TAG" 19 | else 20 | TAG_NAME="${GIT_TAG}+${GIT_COMMITS}.g${GIT_SHA}" 21 | fi 22 | 23 | if [ "$GIT_DIRTY" = "true" ]; then 24 | TAG_NAME="${TAG_NAME}.dirty" 25 | fi 26 | 27 | export APK_DIR="build/app/outputs/flutter-apk" 28 | mv -v "${APK_DIR}/app-release.apk" "${APK_DIR}/potatomesh-reader-android-${TAG_NAME}.apk" 29 | (cd "${APK_DIR}" && sha256sum "potatomesh-reader-android-${TAG_NAME}.apk" > "potatomesh-reader-android-${TAG_NAME}.apk.sha256sum") 30 | 31 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | 5 | # Documentation 6 | README.md 7 | CHANGELOG.md 8 | *.md 9 | 10 | # Docker files 11 | docker-compose*.yml 12 | .dockerignore 13 | 14 | # Environment files 15 | .env* 16 | !.env.example 17 | 18 | # Logs 19 | *.log 20 | logs/ 21 | 22 | # Runtime data 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage/ 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Dependency directories 34 | node_modules/ 35 | vendor/ 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Output of 'npm pack' 44 | *.tgz 45 | 46 | # Yarn Integrity file 47 | .yarn-integrity 48 | 49 | # dotenv environment variables file 50 | .env 51 | 52 | # IDE files 53 | .vscode/ 54 | .idea/ 55 | *.swp 56 | *.swo 57 | *~ 58 | 59 | # OS generated files 60 | .DS_Store 61 | .DS_Store? 62 | ._* 63 | .Spotlight-V100 64 | .Trashes 65 | ehthumbs.db 66 | Thumbs.db 67 | 68 | # Test files 69 | tests/ 70 | spec/ 71 | test_* 72 | *_test.py 73 | *_spec.rb 74 | 75 | # Development files 76 | ai_docs/ 77 | -------------------------------------------------------------------------------- /web/Gemfile: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | source "https://rubygems.org" 16 | 17 | gem "sinatra", "~> 4.0" 18 | gem "erb", "~> 4.0" 19 | gem "sqlite3", "~> 1.7" 20 | gem "rackup", "~> 2.2" 21 | gem "puma", "~> 7.0" 22 | gem "prometheus-client" 23 | 24 | group :test do 25 | gem "rspec", "~> 3.12" 26 | gem "rack-test", "~> 2.1" 27 | gem "rufo", "~> 0.18.1" 28 | gem "simplecov", "~> 0.22", require: false 29 | gem "simplecov_json_formatter", "~> 0.1", require: false 30 | gem "rspec_junit_formatter", "~> 0.6", require: false 31 | end 32 | -------------------------------------------------------------------------------- /web/views/federation.erb: -------------------------------------------------------------------------------- 1 | 16 |
17 |
18 |
19 | <%= erb :"shared/_map_panel", locals: { full_screen: true } %> 20 |
21 | <%= erb :"shared/_instances_table" %> 22 |
23 |
24 | 28 | -------------------------------------------------------------------------------- /web/views/charts.erb: -------------------------------------------------------------------------------- 1 | 16 |
17 |
18 |

Network telemetry trends

19 |

Aggregated telemetry snapshots from every node in the past week.

20 |
21 |
22 |

Loading aggregated telemetry charts…

23 |
24 |
25 | 29 | -------------------------------------------------------------------------------- /data/neighbors.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | CREATE TABLE IF NOT EXISTS neighbors ( 16 | node_id TEXT NOT NULL, 17 | neighbor_id TEXT NOT NULL, 18 | snr REAL, 19 | rx_time INTEGER NOT NULL, 20 | PRIMARY KEY (node_id, neighbor_id), 21 | FOREIGN KEY (node_id) REFERENCES nodes(node_id) ON DELETE CASCADE, 22 | FOREIGN KEY (neighbor_id) REFERENCES nodes(node_id) ON DELETE CASCADE 23 | ); 24 | 25 | CREATE INDEX IF NOT EXISTS idx_neighbors_rx_time ON neighbors(rx_time); 26 | CREATE INDEX IF NOT EXISTS idx_neighbors_neighbor_id ON neighbors(neighbor_id); 27 | -------------------------------------------------------------------------------- /app/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: potato_mesh_reader 2 | description: Meshtastic Reader — read-only view for PotatoMesh messages. 3 | publish_to: "none" 4 | version: 0.5.9 5 | 6 | environment: 7 | sdk: ">=3.4.0 <4.0.0" 8 | 9 | dependencies: 10 | flutter: 11 | sdk: flutter 12 | http: ^1.2.0 13 | package_info_plus: ^9.0.0 14 | flutter_svg: ^2.0.10+1 15 | url_launcher: ^6.3.1 16 | shared_preferences: ^2.3.2 17 | flutter_local_notifications: ^19.5.0 18 | workmanager: ^0.9.0+3 19 | shared_preferences_android: any 20 | shared_preferences_foundation: any 21 | 22 | dev_dependencies: 23 | flutter_test: 24 | sdk: flutter 25 | flutter_lints: ^6.0.0 26 | flutter_launcher_icons: ^0.14.4 27 | flutter_native_splash: ^2.4.1 28 | 29 | flutter: 30 | uses-material-design: true 31 | assets: 32 | - assets/ 33 | 34 | flutter_launcher_icons: 35 | android: true 36 | ios: true 37 | image_path: assets/icon-launcher.png 38 | remove_alpha_ios: true 39 | adaptive_icon_background: "#111417" 40 | adaptive_icon_foreground: assets/icon-launcher.png 41 | 42 | flutter_native_splash: 43 | color: "#111417" 44 | image: assets/icon-splash.png 45 | android_12: 46 | color: "#111417" 47 | image: assets/icon.png 48 | -------------------------------------------------------------------------------- /matrix/Cargo.toml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | [package] 16 | name = "potatomesh-matrix-bridge" 17 | version = "0.5.9" 18 | edition = "2021" 19 | 20 | [dependencies] 21 | tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } 22 | reqwest = { version = "0.12", features = ["json", "rustls-tls"] } 23 | serde = { version = "1", features = ["derive"] } 24 | serde_json = "1" 25 | toml = "0.9" 26 | anyhow = "1" 27 | tracing = "0.1" 28 | tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } 29 | urlencoding = "2" 30 | 31 | [dev-dependencies] 32 | tempfile = "3" 33 | mockito = "1" 34 | serial_test = "3" 35 | -------------------------------------------------------------------------------- /web/public/assets/js/app/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { readAppConfig } from './config.js'; 18 | import { initializeApp } from './main.js'; 19 | import { DEFAULT_CONFIG, mergeConfig } from './settings.js'; 20 | 21 | export { DEFAULT_CONFIG, mergeConfig } from './settings.js'; 22 | 23 | /** 24 | * Bootstraps the application once the DOM is ready by reading configuration 25 | * data and delegating to ``initializeApp``. 26 | * 27 | * @returns {void} 28 | */ 29 | document.addEventListener('DOMContentLoaded', () => { 30 | const rawConfig = readAppConfig(); 31 | const config = mergeConfig(rawConfig); 32 | initializeApp(config); 33 | }); 34 | -------------------------------------------------------------------------------- /data/instances.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | PRAGMA journal_mode=WAL; 16 | 17 | CREATE TABLE IF NOT EXISTS instances ( 18 | id TEXT PRIMARY KEY, 19 | domain TEXT NOT NULL, 20 | pubkey TEXT NOT NULL, 21 | name TEXT, 22 | version TEXT, 23 | channel TEXT, 24 | frequency TEXT, 25 | latitude REAL, 26 | longitude REAL, 27 | last_update_time INTEGER, 28 | is_private BOOLEAN NOT NULL DEFAULT 0, 29 | nodes_count INTEGER, 30 | contact_link TEXT, 31 | signature TEXT 32 | ); 33 | 34 | CREATE UNIQUE INDEX IF NOT EXISTS idx_instances_domain ON instances(domain); 35 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/values-v31/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/values-night-v31/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 18 | 21 | 22 | -------------------------------------------------------------------------------- /matrix/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Copyright © 2025-26 l5yth & contributors 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -e 17 | 18 | # Default state file path from Config.toml unless overridden. 19 | STATE_FILE="${STATE_FILE:-/app/bridge_state.json}" 20 | STATE_DIR="$(dirname "$STATE_FILE")" 21 | 22 | # Ensure state directory exists and is writable by the non-root user without 23 | # touching the read-only config bind mount. 24 | if [ ! -d "$STATE_DIR" ]; then 25 | mkdir -p "$STATE_DIR" 26 | fi 27 | 28 | # Best-effort ownership fix; ignore if the underlying volume is read-only. 29 | chown potatomesh:potatomesh "$STATE_DIR" 2>/dev/null || true 30 | touch "$STATE_FILE" 2>/dev/null || true 31 | chown potatomesh:potatomesh "$STATE_FILE" 2>/dev/null || true 32 | 33 | exec gosu potatomesh potatomesh-matrix-bridge "$@" 34 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /app/android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 13 | 19 | 22 | 23 | -------------------------------------------------------------------------------- /app/test/theme_preference_store_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright © 2025-26 l5yth & contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'package:flutter/material.dart'; 16 | import 'package:flutter_test/flutter_test.dart'; 17 | import 'package:potato_mesh_reader/main.dart'; 18 | import 'package:shared_preferences/shared_preferences.dart'; 19 | 20 | void main() { 21 | TestWidgetsFlutterBinding.ensureInitialized(); 22 | 23 | setUp(() { 24 | SharedPreferences.setMockInitialValues({}); 25 | }); 26 | 27 | test('ThemePreferenceStore persists and restores theme mode', () async { 28 | final store = ThemePreferenceStore(); 29 | final initial = await store.load(); 30 | expect(initial, ThemeMode.system); 31 | 32 | await store.save(ThemeMode.dark); 33 | final loaded = await store.load(); 34 | 35 | expect(loaded, ThemeMode.dark); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /app/test/notification_client_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright © 2025-26 l5yth & contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 16 | import 'package:flutter_test/flutter_test.dart'; 17 | import 'package:potato_mesh_reader/main.dart'; 18 | 19 | void main() { 20 | test('uses drawable name for Android init and notifications', () { 21 | final client = LocalNotificationClient(); 22 | 23 | final androidInit = client.buildAndroidInitializationSettings(); 24 | expect(androidInit.defaultIcon, 'ic_mesh_notification'); 25 | 26 | final details = client.notificationDetailsForTest(); 27 | final androidDetails = details.android as AndroidNotificationDetails; 28 | expect(androidDetails.icon, 'ic_mesh_notification'); 29 | expect(androidDetails.category, AndroidNotificationCategory.message); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Production overrides for docker-compose.yml 16 | services: 17 | web: 18 | build: 19 | context: . 20 | dockerfile: web/Dockerfile 21 | target: production 22 | environment: 23 | DEBUG: 0 24 | restart: always 25 | 26 | web-bridge: 27 | build: 28 | context: . 29 | dockerfile: web/Dockerfile 30 | target: production 31 | environment: 32 | DEBUG: 0 33 | restart: always 34 | 35 | ingestor: 36 | build: 37 | context: . 38 | dockerfile: data/Dockerfile 39 | target: production 40 | environment: 41 | DEBUG: 0 42 | restart: always 43 | 44 | ingestor-bridge: 45 | build: 46 | context: . 47 | dockerfile: data/Dockerfile 48 | target: production 49 | environment: 50 | DEBUG: 0 51 | restart: always 52 | -------------------------------------------------------------------------------- /app/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at https://dart.dev/lints. 17 | # 18 | # Instead of disabling a lint rule for the entire project in the 19 | # section below, it can also be suppressed for a single line of code 20 | # or a specific dart file by using the `// ignore: name_of_lint` and 21 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 22 | # producing the lint. 23 | rules: 24 | # avoid_print: false # Uncomment to disable the `avoid_print` rule 25 | # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 26 | 27 | # Additional information about this file can be found at 28 | # https://dart.dev/guides/language/analysis-options 29 | -------------------------------------------------------------------------------- /data/positions.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | CREATE TABLE IF NOT EXISTS positions ( 16 | id INTEGER PRIMARY KEY, 17 | node_id TEXT, 18 | node_num INTEGER, 19 | rx_time INTEGER NOT NULL, 20 | rx_iso TEXT NOT NULL, 21 | position_time INTEGER, 22 | to_id TEXT, 23 | latitude REAL, 24 | longitude REAL, 25 | altitude REAL, 26 | location_source TEXT, 27 | precision_bits INTEGER, 28 | sats_in_view INTEGER, 29 | pdop REAL, 30 | ground_speed REAL, 31 | ground_track REAL, 32 | snr REAL, 33 | rssi INTEGER, 34 | hop_limit INTEGER, 35 | bitfield INTEGER, 36 | payload_b64 TEXT 37 | ); 38 | 39 | CREATE INDEX IF NOT EXISTS idx_positions_rx_time ON positions(rx_time); 40 | CREATE INDEX IF NOT EXISTS idx_positions_node_id ON positions(node_id); 41 | -------------------------------------------------------------------------------- /data/traces.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | CREATE TABLE IF NOT EXISTS traces ( 16 | id INTEGER PRIMARY KEY, 17 | request_id INTEGER, 18 | src INTEGER, 19 | dest INTEGER, 20 | rx_time INTEGER NOT NULL, 21 | rx_iso TEXT NOT NULL, 22 | rssi INTEGER, 23 | snr REAL, 24 | elapsed_ms INTEGER 25 | ); 26 | 27 | CREATE TABLE IF NOT EXISTS trace_hops ( 28 | id INTEGER PRIMARY KEY, 29 | trace_id INTEGER NOT NULL, 30 | hop_index INTEGER NOT NULL, 31 | node_id INTEGER NOT NULL, 32 | FOREIGN KEY(trace_id) REFERENCES traces(id) ON DELETE CASCADE 33 | ); 34 | 35 | CREATE INDEX IF NOT EXISTS idx_traces_rx_time ON traces(rx_time); 36 | CREATE INDEX IF NOT EXISTS idx_traces_request ON traces(request_id); 37 | CREATE INDEX IF NOT EXISTS idx_trace_hops_trace ON trace_hops(trace_id); 38 | CREATE INDEX IF NOT EXISTS idx_trace_hops_node ON trace_hops(node_id); 39 | -------------------------------------------------------------------------------- /web/public/assets/js/app/config.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * CSS selector used to locate the embedded configuration element. 19 | * 20 | * @type {string} 21 | */ 22 | const CONFIG_SELECTOR = '[data-app-config]'; 23 | 24 | /** 25 | * Read and parse the serialized application configuration from the DOM. 26 | * 27 | * @returns {Object} Parsed configuration object or an empty object when unavailable. 28 | */ 29 | export function readAppConfig() { 30 | const el = document.querySelector(CONFIG_SELECTOR); 31 | if (!el) { 32 | return {}; 33 | } 34 | const raw = el.getAttribute('data-app-config') || ''; 35 | if (!raw) { 36 | return {}; 37 | } 38 | try { 39 | const parsed = JSON.parse(raw); 40 | return typeof parsed === 'object' && parsed !== null ? parsed : {}; 41 | } catch (err) { 42 | console.error('Failed to parse application configuration', err); 43 | return {}; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /matrix/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM rust:1.91-bookworm AS builder 16 | 17 | WORKDIR /app 18 | 19 | COPY matrix/Cargo.toml matrix/Cargo.lock ./ 20 | COPY matrix/src ./src 21 | 22 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 23 | --mount=type=cache,target=/usr/local/cargo/git \ 24 | cargo build --release --locked 25 | 26 | FROM debian:bookworm-slim AS runtime 27 | 28 | RUN apt-get update \ 29 | && apt-get install -y --no-install-recommends ca-certificates gosu \ 30 | && rm -rf /var/lib/apt/lists/* 31 | 32 | RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin potatomesh 33 | 34 | WORKDIR /app 35 | 36 | COPY --from=builder /app/target/release/potatomesh-matrix-bridge /usr/local/bin/potatomesh-matrix-bridge 37 | COPY matrix/Config.toml /app/Config.example.toml 38 | COPY matrix/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh 39 | 40 | RUN chmod +x /usr/local/bin/docker-entrypoint.sh 41 | 42 | ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] 43 | -------------------------------------------------------------------------------- /data/messages.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | CREATE TABLE IF NOT EXISTS messages ( 16 | id INTEGER PRIMARY KEY, 17 | rx_time INTEGER NOT NULL, 18 | rx_iso TEXT NOT NULL, 19 | from_id TEXT, 20 | to_id TEXT, 21 | channel INTEGER, 22 | portnum TEXT, 23 | text TEXT, 24 | encrypted TEXT, 25 | snr REAL, 26 | rssi INTEGER, 27 | hop_limit INTEGER, 28 | lora_freq INTEGER, 29 | modem_preset TEXT, 30 | channel_name TEXT, 31 | reply_id INTEGER, 32 | emoji TEXT 33 | ); 34 | 35 | CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time); 36 | CREATE INDEX IF NOT EXISTS idx_messages_from_id ON messages(from_id); 37 | CREATE INDEX IF NOT EXISTS idx_messages_to_id ON messages(to_id); 38 | CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel); 39 | CREATE INDEX IF NOT EXISTS idx_messages_portnum ON messages(portnum); 40 | CREATE INDEX IF NOT EXISTS idx_messages_reply_id ON messages(reply_id); 41 | -------------------------------------------------------------------------------- /data/mesh.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Copyright © 2025-26 l5yth & contributors 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | """Backward-compatible entry point for the mesh ingestor daemon.""" 17 | 18 | from __future__ import annotations 19 | 20 | import importlib 21 | import sys 22 | from pathlib import Path 23 | 24 | try: 25 | from . import mesh_ingestor as _mesh_ingestor 26 | except ImportError: 27 | if __package__ in {None, ""}: 28 | package_dir = Path(__file__).resolve().parent 29 | project_root = str(package_dir.parent) 30 | if project_root not in sys.path: 31 | sys.path.insert(0, project_root) 32 | _mesh_ingestor = importlib.import_module("data.mesh_ingestor") 33 | else: 34 | raise 35 | 36 | # Expose the refactored mesh ingestor module under the legacy name so existing 37 | # imports (``import data.mesh as mesh``) continue to work. Attribute access and 38 | # monkeypatching operate directly on the shared module instance. 39 | sys.modules[__name__] = _mesh_ingestor 40 | 41 | 42 | if __name__ == "__main__": 43 | _mesh_ingestor.main() 44 | -------------------------------------------------------------------------------- /web/public/assets/js/app/message-limit.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Maximum number of chat messages that the API can return in a single request. 19 | * @type {number} 20 | */ 21 | export const MESSAGE_LIMIT = 1000; 22 | 23 | /** 24 | * Normalise a candidate limit for the messages API to remain within supported bounds. 25 | * 26 | * The API clamps responses to {@link MESSAGE_LIMIT}, so this helper ensures the 27 | * frontend always requests an allowed value while defaulting to the upper bound 28 | * when callers omit or provide invalid data. 29 | * 30 | * @param {*} limit Candidate limit value supplied by the caller. 31 | * @returns {number} Safe, positive limit capped at {@link MESSAGE_LIMIT}. 32 | */ 33 | export function normaliseMessageLimit(limit) { 34 | const parsed = Number.parseFloat(limit); 35 | if (!Number.isFinite(parsed) || parsed <= 0) { 36 | return MESSAGE_LIMIT; 37 | } 38 | const floored = Math.floor(parsed); 39 | if (floored <= 0) { 40 | return MESSAGE_LIMIT; 41 | } 42 | return Math.min(floored, MESSAGE_LIMIT); 43 | } 44 | -------------------------------------------------------------------------------- /app/ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /app/android/potato_mesh_reader_android.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /app/android/app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("com.android.application") 3 | id("kotlin-android") 4 | // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. 5 | id("dev.flutter.flutter-gradle-plugin") 6 | } 7 | 8 | android { 9 | namespace = "net.potatomesh.reader" 10 | compileSdk = flutter.compileSdkVersion 11 | ndkVersion = flutter.ndkVersion 12 | 13 | compileOptions { 14 | sourceCompatibility = JavaVersion.VERSION_17 15 | targetCompatibility = JavaVersion.VERSION_17 16 | isCoreLibraryDesugaringEnabled = true 17 | } 18 | 19 | kotlinOptions { 20 | jvmTarget = JavaVersion.VERSION_17.toString() 21 | } 22 | 23 | defaultConfig { 24 | // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). 25 | applicationId = "net.potatomesh.reader" 26 | // You can update the following values to match your application needs. 27 | // For more information, see: https://flutter.dev/to/review-gradle-config. 28 | minSdk = flutter.minSdkVersion 29 | targetSdk = flutter.targetSdkVersion 30 | versionCode = flutter.versionCode 31 | versionName = flutter.versionName 32 | } 33 | 34 | buildTypes { 35 | release { 36 | // TODO: Add your own signing config for the release build. 37 | // Signing with the debug keys for now, so `flutter run --release` works. 38 | signingConfig = signingConfigs.getByName("debug") 39 | } 40 | } 41 | } 42 | 43 | flutter { 44 | source = "../.." 45 | } 46 | 47 | dependencies { 48 | coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") 49 | } 50 | -------------------------------------------------------------------------------- /data/migrations/20250305_extend_environment_telemetry.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | -- Extend the telemetry table with additional environment metrics. 16 | 17 | BEGIN; 18 | ALTER TABLE telemetry ADD COLUMN gas_resistance REAL; 19 | ALTER TABLE telemetry ADD COLUMN current REAL; 20 | ALTER TABLE telemetry ADD COLUMN iaq INTEGER; 21 | ALTER TABLE telemetry ADD COLUMN distance REAL; 22 | ALTER TABLE telemetry ADD COLUMN lux REAL; 23 | ALTER TABLE telemetry ADD COLUMN white_lux REAL; 24 | ALTER TABLE telemetry ADD COLUMN ir_lux REAL; 25 | ALTER TABLE telemetry ADD COLUMN uv_lux REAL; 26 | ALTER TABLE telemetry ADD COLUMN wind_direction INTEGER; 27 | ALTER TABLE telemetry ADD COLUMN wind_speed REAL; 28 | ALTER TABLE telemetry ADD COLUMN weight REAL; 29 | ALTER TABLE telemetry ADD COLUMN wind_gust REAL; 30 | ALTER TABLE telemetry ADD COLUMN wind_lull REAL; 31 | ALTER TABLE telemetry ADD COLUMN radiation REAL; 32 | ALTER TABLE telemetry ADD COLUMN rainfall_1h REAL; 33 | ALTER TABLE telemetry ADD COLUMN rainfall_24h REAL; 34 | ALTER TABLE telemetry ADD COLUMN soil_moisture INTEGER; 35 | ALTER TABLE telemetry ADD COLUMN soil_temperature REAL; 36 | COMMIT; 37 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # Development overrides for docker-compose.yml 16 | services: 17 | web: 18 | environment: 19 | DEBUG: 1 20 | volumes: 21 | - ./web:/app 22 | - ./data:/app/.local/share/potato-mesh 23 | - ./.config/potato-mesh:/app/.config/potato-mesh 24 | - /app/vendor/bundle 25 | 26 | web-bridge: 27 | environment: 28 | DEBUG: 1 29 | volumes: 30 | - ./web:/app 31 | - ./data:/app/.local/share/potato-mesh 32 | - ./.config/potato-mesh:/app/.config/potato-mesh 33 | - /app/vendor/bundle 34 | ports: 35 | - "41447:41447" 36 | - "9292:9292" 37 | 38 | ingestor: 39 | environment: 40 | DEBUG: 1 41 | volumes: 42 | - ./data:/app 43 | - ./data:/app/.local/share/potato-mesh 44 | - ./.config/potato-mesh:/app/.config/potato-mesh 45 | - /app/.local 46 | - /dev:/dev 47 | 48 | ingestor-bridge: 49 | environment: 50 | DEBUG: 1 51 | volumes: 52 | - ./data:/app 53 | - ./data:/app/.local/share/potato-mesh 54 | - ./.config/potato-mesh:/app/.config/potato-mesh 55 | - /app/.local 56 | - /dev:/dev 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | Gemfile.lock 49 | .ruby-version 50 | .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | .rubocop-https?--* 57 | 58 | # Python cache directories 59 | __pycache__/ 60 | .coverage 61 | coverage/ 62 | coverage.xml 63 | htmlcov/ 64 | reports/ 65 | 66 | # AI planning and documentation 67 | ai_docs/ 68 | *.log 69 | 70 | # Generated credentials for the instance 71 | web/.config 72 | 73 | # JavaScript dependencies 74 | node_modules/ 75 | web/node_modules/ 76 | 77 | # Debug symbols 78 | ignored.txt 79 | -------------------------------------------------------------------------------- /web/spec/logging_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | require "spec_helper" 18 | require "potato_mesh/logging" 19 | 20 | describe PotatoMesh::Logging do 21 | describe ".formatter" do 22 | it "generates structured log entries" do 23 | timestamp = Time.utc(2024, 1, 2, 3, 4, 5, 678_000) 24 | formatted = described_class.formatter("DEBUG", timestamp, "potato-mesh", "hello") 25 | 26 | expect(formatted).to eq("[2024-01-02T03:04:05.678Z] [potato-mesh] [debug] hello\n") 27 | end 28 | end 29 | 30 | describe ".log" do 31 | it "passes structured metadata to the logger" do 32 | logger = instance_double(Logger) 33 | 34 | expect(logger).to receive(:debug).with("context=test foo=\"bar\" hello") 35 | 36 | described_class.log(logger, :debug, "hello", context: "test", foo: "bar") 37 | end 38 | end 39 | 40 | describe ".logger_for" do 41 | it "returns the logger from an object with settings" do 42 | container = Class.new do 43 | def settings 44 | Struct.new(:logger).new(:logger) 45 | end 46 | end 47 | 48 | expect(described_class.logger_for(container.new)).to eq(:logger) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /data/nodes.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | PRAGMA journal_mode=WAL; 16 | 17 | CREATE TABLE IF NOT EXISTS nodes ( 18 | node_id TEXT PRIMARY KEY, 19 | num INTEGER, 20 | short_name TEXT, 21 | long_name TEXT, 22 | macaddr TEXT, 23 | hw_model TEXT, 24 | role TEXT, 25 | public_key TEXT, 26 | is_unmessagable BOOLEAN, 27 | is_favorite BOOLEAN, 28 | hops_away INTEGER, 29 | snr REAL, 30 | last_heard INTEGER, 31 | first_heard INTEGER, 32 | battery_level REAL, 33 | voltage REAL, 34 | channel_utilization REAL, 35 | air_util_tx REAL, 36 | uptime_seconds INTEGER, 37 | position_time INTEGER, 38 | location_source TEXT, 39 | precision_bits INTEGER, 40 | latitude REAL, 41 | longitude REAL, 42 | altitude REAL, 43 | lora_freq INTEGER, 44 | modem_preset TEXT 45 | ); 46 | 47 | CREATE INDEX IF NOT EXISTS idx_nodes_last_heard ON nodes(last_heard); 48 | CREATE INDEX IF NOT EXISTS idx_nodes_hw_model ON nodes(hw_model); 49 | CREATE INDEX IF NOT EXISTS idx_nodes_latlon ON nodes(latitude, longitude); 50 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: "CodeQL Advanced" 16 | 17 | on: 18 | push: 19 | branches: [ "main" ] 20 | pull_request: 21 | branches: [ "main" ] 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze (${{ matrix.language }}) 26 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 27 | permissions: 28 | security-events: write 29 | packages: read 30 | actions: read 31 | contents: read 32 | 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | include: 37 | - language: python 38 | build-mode: none 39 | - language: rust 40 | build-mode: none 41 | - language: ruby 42 | build-mode: none 43 | - language: javascript-typescript 44 | build-mode: none 45 | steps: 46 | - name: Checkout repository 47 | uses: actions/checkout@v5 48 | - name: Initialize CodeQL 49 | uses: github/codeql-action/init@v4 50 | with: 51 | languages: ${{ matrix.language }} 52 | build-mode: ${{ matrix.build-mode }} 53 | - name: Perform CodeQL Analysis 54 | uses: github/codeql-action/analyze@v4 55 | with: 56 | category: "/language:${{matrix.language}}" 57 | -------------------------------------------------------------------------------- /app/lib/dart_plugin_registrant.dart: -------------------------------------------------------------------------------- 1 | // Copyright © 2025-26 l5yth & contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'dart:io'; 16 | 17 | import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 18 | import 'package:shared_preferences_android/shared_preferences_android.dart'; 19 | import 'package:shared_preferences_foundation/shared_preferences_foundation.dart'; 20 | 21 | /// Minimal plugin registrant for background isolates. 22 | /// 23 | /// The Workmanager-provided background Flutter engine does not automatically 24 | /// invoke the app's plugin registrant, so we register only the plugins needed 25 | /// by our background task (notifications and shared preferences). 26 | class DartPluginRegistrant { 27 | static bool _initialized = false; 28 | 29 | static void ensureInitialized() { 30 | if (_initialized) return; 31 | if (Platform.isAndroid) { 32 | try { 33 | AndroidFlutterLocalNotificationsPlugin.registerWith(); 34 | } catch (_) {} 35 | try { 36 | SharedPreferencesAndroid.registerWith(); 37 | } catch (_) {} 38 | } else if (Platform.isIOS) { 39 | try { 40 | IOSFlutterLocalNotificationsPlugin.registerWith(); 41 | } catch (_) {} 42 | try { 43 | SharedPreferencesFoundation.registerWith(); 44 | } catch (_) {} 45 | } 46 | _initialized = true; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/message-limit.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | 20 | import { MESSAGE_LIMIT, normaliseMessageLimit } from '../message-limit.js'; 21 | 22 | test('normaliseMessageLimit defaults to the message limit for invalid input', () => { 23 | assert.equal(normaliseMessageLimit(undefined), MESSAGE_LIMIT); 24 | assert.equal(normaliseMessageLimit(null), MESSAGE_LIMIT); 25 | assert.equal(normaliseMessageLimit(''), MESSAGE_LIMIT); 26 | assert.equal(normaliseMessageLimit('abc'), MESSAGE_LIMIT); 27 | assert.equal(normaliseMessageLimit(-100), MESSAGE_LIMIT); 28 | assert.equal(normaliseMessageLimit(0), MESSAGE_LIMIT); 29 | assert.equal(normaliseMessageLimit(Number.POSITIVE_INFINITY), MESSAGE_LIMIT); 30 | }); 31 | 32 | test('normaliseMessageLimit clamps numeric input to the upper bound', () => { 33 | assert.equal(normaliseMessageLimit(MESSAGE_LIMIT + 1), MESSAGE_LIMIT); 34 | assert.equal(normaliseMessageLimit(MESSAGE_LIMIT * 2), MESSAGE_LIMIT); 35 | }); 36 | 37 | test('normaliseMessageLimit accepts positive finite values', () => { 38 | assert.equal(normaliseMessageLimit(250), 250); 39 | assert.equal(normaliseMessageLimit('750'), 750); 40 | assert.equal(normaliseMessageLimit(42.9), 42); 41 | }); 42 | -------------------------------------------------------------------------------- /.github/workflows/javascript.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: JavaScript 16 | 17 | on: 18 | push: 19 | branches: [ "main" ] 20 | pull_request: 21 | branches: [ "main" ] 22 | paths: 23 | - 'web/**' 24 | - 'tests/**' 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | frontend: 31 | runs-on: ubuntu-latest 32 | defaults: 33 | run: 34 | working-directory: web 35 | steps: 36 | - uses: actions/checkout@v5 37 | - name: Set up Node.js 22 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: '22' 41 | - name: Install dependencies 42 | run: npm ci 43 | - name: Run JavaScript tests 44 | run: npm test 45 | - name: Upload coverage to Codecov 46 | if: always() 47 | uses: codecov/codecov-action@v5 48 | with: 49 | token: ${{ secrets.CODECOV_TOKEN }} 50 | files: web/reports/javascript-coverage.json 51 | flags: frontend 52 | name: frontend 53 | fail_ci_if_error: false 54 | env: 55 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 56 | - name: Upload test results to Codecov 57 | uses: codecov/test-results-action@v1 58 | with: 59 | token: ${{ secrets.CODECOV_TOKEN }} 60 | files: web/reports/javascript-junit.xml 61 | flags: frontend 62 | -------------------------------------------------------------------------------- /web/views/shared/_map_panel.erb: -------------------------------------------------------------------------------- 1 | 16 | <% map_classes = ["map-panel"] 17 | map_classes << "map-panel--full" if defined?(full_screen) && full_screen %> 18 |
" id="mapPanel"> 19 |
20 |
21 | 43 |
44 |
45 | -------------------------------------------------------------------------------- /app/ios/Runner/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Potato Mesh Reader 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | PotatoMesh Reader 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleVersion 24 | $(FLUTTER_BUILD_NUMBER) 25 | LSRequiresIPhoneOS 26 | 27 | UILaunchStoryboardName 28 | LaunchScreen 29 | UIMainStoryboardFile 30 | Main 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | CADisableMinimumFrameDurationOnPhone 45 | 46 | UIApplicationSupportsIndirectInputEvents 47 | 48 | UIStatusBarHidden 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /web/views/node_detail.erb: -------------------------------------------------------------------------------- 1 | 16 | <% reference_json = node_reference_json || "{}" 17 | short_display = node_page_short_name || "Loading" 18 | long_display = node_page_long_name 19 | identifier_display = node_page_identifier || "" %> 20 |
" 25 | > 26 |
27 |

28 | <%= Rack::Utils.escape_html(short_display) %> 29 | <% if long_display %> 30 | <%= Rack::Utils.escape_html(long_display) %> 31 | <% end %> 32 | <% if identifier_display && !identifier_display.empty? %> 33 | [<%= Rack::Utils.escape_html(identifier_display) %>] 34 | <% end %> 35 |

36 |
37 |

Loading node details…

38 | 41 |
42 | 46 | -------------------------------------------------------------------------------- /app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | {"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} -------------------------------------------------------------------------------- /app/ios/Podfile: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | platform :ios, "14.0" 16 | 17 | ENV["COCOAPODS_DISABLE_STATS"] = "true" 18 | 19 | project "Runner", { 20 | "Debug" => :debug, 21 | "Profile" => :release, 22 | "Release" => :release, 23 | } 24 | 25 | def flutter_root 26 | generated_xcode_build_settings_path = File.expand_path(File.join("..", "Flutter", "Generated.xcconfig"), __FILE__) 27 | unless File.exist?(generated_xcode_build_settings_path) 28 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 29 | end 30 | 31 | File.foreach(generated_xcode_build_settings_path) do |line| 32 | matches = line.match(/FLUTTER_ROOT=(.*)/) 33 | return matches[1].strip if matches 34 | end 35 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Run flutter pub get and try again." 36 | end 37 | 38 | require File.expand_path(File.join("packages", "flutter_tools", "bin", "podhelper"), flutter_root) 39 | 40 | flutter_ios_podfile_setup 41 | 42 | target "Runner" do 43 | use_frameworks! 44 | use_modular_headers! 45 | 46 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 47 | end 48 | 49 | post_install do |installer| 50 | installer.pods_project.targets.each do |target| 51 | flutter_additional_ios_build_settings(target) 52 | target.build_configurations.each do |config| 53 | config.build_settings["IPHONEOS_DEPLOYMENT_TARGET"] = "14.0" 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/short-info-satellites.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | 20 | import { renderSatsInViewBadge, resolveSatsInView, __testUtils } from '../short-info-satellites.js'; 21 | 22 | const { toPositiveInteger } = __testUtils; 23 | 24 | test('resolveSatsInView inspects aliases and nested payloads', () => { 25 | assert.equal(resolveSatsInView({ sats_in_view: '3.6' }), 4); 26 | assert.equal(resolveSatsInView({ position: { satsInView: 5 } }), 5); 27 | assert.equal(resolveSatsInView({ rawSources: { position: { sats_in_view: 9 } } }), 9); 28 | assert.equal(resolveSatsInView({ satsInView: 0 }), null); 29 | assert.equal(resolveSatsInView(null), null); 30 | }); 31 | 32 | test('renderSatsInViewBadge returns markup only for positive counts', () => { 33 | const html = renderSatsInViewBadge({ satsInView: 6 }); 34 | assert.match(html, /short-info-sats/); 35 | assert.ok(html.includes('satellite-icon.svg')); 36 | assert.match(html, />6 { 43 | assert.equal(toPositiveInteger('7.2'), 7); 44 | assert.equal(toPositiveInteger(''), null); 45 | assert.equal(toPositiveInteger(-3), null); 46 | assert.equal(toPositiveInteger(NaN), null); 47 | }); 48 | -------------------------------------------------------------------------------- /web/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | require "simplecov" 18 | require "simplecov_json_formatter" 19 | 20 | SimpleCov.formatters = SimpleCov::Formatter::MultiFormatter.new( 21 | [ 22 | SimpleCov::Formatter::SimpleFormatter, 23 | SimpleCov::Formatter::HTMLFormatter, 24 | SimpleCov::Formatter::JSONFormatter, 25 | ], 26 | ) 27 | 28 | SimpleCov.start do 29 | enable_coverage :branch 30 | add_filter "/spec/" 31 | end 32 | 33 | require "tmpdir" 34 | require "fileutils" 35 | 36 | ENV["RACK_ENV"] = "test" 37 | ENV["INSTANCE_DOMAIN"] ||= "spec.mesh.test" 38 | 39 | SPEC_TMPDIR = Dir.mktmpdir("potato-mesh-spec-") 40 | ENV["XDG_DATA_HOME"] = File.join(SPEC_TMPDIR, "xdg-data") 41 | ENV["XDG_CONFIG_HOME"] = File.join(SPEC_TMPDIR, "xdg-config") 42 | 43 | FileUtils.mkdir_p(ENV["XDG_DATA_HOME"]) 44 | FileUtils.mkdir_p(ENV["XDG_CONFIG_HOME"]) 45 | 46 | require_relative "../app" 47 | 48 | require "rack/test" 49 | require "rspec" 50 | 51 | RSpec.configure do |config| 52 | config.expect_with :rspec do |expectations| 53 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 54 | end 55 | 56 | config.mock_with :rspec do |mocks| 57 | mocks.verify_partial_doubles = true 58 | end 59 | 60 | config.shared_context_metadata_behavior = :apply_to_host_groups 61 | 62 | config.include Rack::Test::Methods 63 | 64 | config.after(:suite) do 65 | FileUtils.remove_entry(SPEC_TMPDIR) if File.directory?(SPEC_TMPDIR) 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /web/spec/networking_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | require "spec_helper" 18 | 19 | RSpec.describe PotatoMesh::Application do 20 | describe ".canonicalize_configured_instance_domain" do 21 | subject(:canonicalize) { described_class.canonicalize_configured_instance_domain(input) } 22 | 23 | context "with an IPv6 URL" do 24 | let(:input) { "http://[::1]" } 25 | 26 | it "retains brackets around the literal" do 27 | expect(canonicalize).to eq("[::1]") 28 | end 29 | end 30 | 31 | context "with an IPv6 URL including a non-default port" do 32 | let(:input) { "http://[::1]:8080" } 33 | 34 | it "keeps the literal bracketed and appends the port" do 35 | expect(canonicalize).to eq("[::1]:8080") 36 | end 37 | end 38 | 39 | context "with a bare IPv6 literal" do 40 | let(:input) { "::1" } 41 | 42 | it "wraps the literal in brackets" do 43 | expect(canonicalize).to eq("[::1]") 44 | end 45 | end 46 | 47 | context "with a bare IPv6 literal and port" do 48 | let(:input) { "::1:9000" } 49 | 50 | it "wraps the literal in brackets and preserves the port" do 51 | expect(canonicalize).to eq("[::1]:9000") 52 | end 53 | end 54 | 55 | context "with an IPv4 literal" do 56 | let(:input) { "http://127.0.0.1" } 57 | 58 | it "returns the literal without brackets" do 59 | expect(canonicalize).to eq("127.0.0.1") 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /web/public/assets/js/app/map-auto-fit-settings.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const MINIMUM_AUTO_FIT_RANGE_KM = 0.25; 18 | const AUTO_FIT_PADDING_FRACTION = 0.02; 19 | 20 | /** 21 | * Resolve auto-fit bounds configuration for the active map constraints. 22 | * 23 | * @param {{ hasDistanceLimit: boolean, maxDistanceKm: number | null }} options 24 | * - ``hasDistanceLimit`` indicates whether a maximum display radius is enforced. 25 | * - ``maxDistanceKm`` provides the configured maximum distance in kilometres. 26 | * @returns {{ paddingFraction: number, minimumRangeKm: number }} 27 | * Bounds options suitable for ``computeBoundsForPoints``. 28 | */ 29 | export function resolveAutoFitBoundsConfig({ hasDistanceLimit, maxDistanceKm } = {}) { 30 | const effectiveMaxDistance = Number.isFinite(maxDistanceKm) && maxDistanceKm > 0 31 | ? maxDistanceKm 32 | : null; 33 | 34 | if (!hasDistanceLimit || !effectiveMaxDistance) { 35 | return { 36 | paddingFraction: AUTO_FIT_PADDING_FRACTION, 37 | minimumRangeKm: MINIMUM_AUTO_FIT_RANGE_KM 38 | }; 39 | } 40 | 41 | const minimumRange = Math.min(MINIMUM_AUTO_FIT_RANGE_KM, effectiveMaxDistance); 42 | const resolvedMinimumRange = Number.isFinite(minimumRange) && minimumRange > 0 43 | ? minimumRange 44 | : MINIMUM_AUTO_FIT_RANGE_KM; 45 | return { 46 | paddingFraction: AUTO_FIT_PADDING_FRACTION, 47 | minimumRangeKm: resolvedMinimumRange 48 | }; 49 | } 50 | 51 | export const __testUtils = { 52 | MINIMUM_AUTO_FIT_RANGE_KM, 53 | AUTO_FIT_PADDING_FRACTION 54 | }; 55 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Python 16 | 17 | on: 18 | push: 19 | branches: [ "main" ] 20 | pull_request: 21 | branches: [ "main" ] 22 | paths: 23 | - 'data/**' 24 | - 'tests/**' 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | ingestor: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v5 34 | - name: Set up Python 3.13 35 | uses: actions/setup-python@v5 36 | with: 37 | python-version: "3.13" 38 | - name: Install dependencies 39 | run: | 40 | python -m pip install --upgrade pip 41 | pip install black pytest pytest-cov meshtastic 42 | - name: Test with pytest and coverage 43 | run: | 44 | mkdir -p reports 45 | pytest --cov=data --cov-report=term --cov-report=xml:reports/python-coverage.xml --junitxml=reports/python-junit.xml 46 | - name: Upload coverage to Codecov 47 | if: always() 48 | uses: codecov/codecov-action@v5 49 | with: 50 | token: ${{ secrets.CODECOV_TOKEN }} 51 | files: reports/python-coverage.xml 52 | flags: python-ingestor 53 | fail_ci_if_error: false 54 | name: python-ingestor 55 | env: 56 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 57 | - name: Upload test results to Codecov 58 | uses: codecov/test-results-action@v1 59 | with: 60 | token: ${{ secrets.CODECOV_TOKEN }} 61 | files: reports/python-junit.xml 62 | flags: python-ingestor 63 | - name: Lint with black 64 | run: | 65 | black --check ./ 66 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/dom-environment.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | 20 | import { createDomEnvironment } from './dom-environment.js'; 21 | 22 | test('dom environment supports class queries and innerHTML setter', () => { 23 | const env = createDomEnvironment({ includeBody: true }); 24 | const { document, createElement, cleanup } = env; 25 | 26 | const parent = createElement('div'); 27 | const child = createElement('span'); 28 | child.classList.add('leaflet-tile'); 29 | child.setAttribute('data-test', 'ok'); 30 | parent.appendChild(child); 31 | 32 | const matches = parent.querySelectorAll('.leaflet-tile'); 33 | assert.equal(matches.length, 1); 34 | assert.equal(matches[0], child); 35 | 36 | const target = createElement('div'); 37 | target.innerHTML = 'hello'; 38 | assert.match(target.innerHTML, /hello/); 39 | 40 | const fragment = document.createDocumentFragment(); 41 | fragment.replaceChildren(createElement('p')); 42 | const container = createElement('section'); 43 | const decorated = createElement('span'); 44 | decorated.setAttribute('data-id', '123'); 45 | decorated.classList.add('foo'); 46 | container.appendChild(decorated); 47 | assert.match(container.innerHTML, /data-id="123"/); 48 | assert.match(container.innerHTML, /class="foo"/); 49 | container.replaceChildren(createElement('div')); // cover non-fragment path 50 | container.childNodes.push({}); // cover empty serialization branch 51 | assert.ok(container.innerHTML.includes(' { 29 | assert.equal(translateRoleId(0), 'CLIENT'); 30 | assert.equal(translateRoleId(' 11 '), 'ROUTER_LATE'); 31 | assert.equal(translateRoleId('0'), 'CLIENT'); 32 | assert.equal(translateRoleId('99'), '99'); 33 | assert.equal(translateRoleId(''), ''); 34 | assert.equal(translateRoleId(null), null); 35 | }); 36 | 37 | test('normalizeRole enforces a non-empty canonical string', () => { 38 | assert.equal(normalizeRole('client'), 'client'); 39 | assert.equal(normalizeRole(' CLIENT_MUTE '), 'CLIENT_MUTE'); 40 | assert.equal(normalizeRole(''), 'CLIENT'); 41 | assert.equal(normalizeRole(undefined), 'CLIENT'); 42 | }); 43 | 44 | test('role key and color lookups prefer known values with uppercase fallback', () => { 45 | assert.equal(getRoleKey('client'), 'CLIENT'); 46 | assert.equal(getRoleColor('client'), getRoleColor('CLIENT')); 47 | assert.equal(getRoleKey('custom-role'), 'custom-role'); 48 | assert.equal(getRoleColor('custom-role'), getRoleColor('CLIENT')); 49 | }); 50 | 51 | test('render priority uses canonical role keys and defaults to zero for unknowns', () => { 52 | assert.equal(getRoleRenderPriority('ROUTER'), getRoleRenderPriority(2)); 53 | assert.equal(getRoleRenderPriority('custom-role'), 0); 54 | }); 55 | -------------------------------------------------------------------------------- /.github/workflows/mobile.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Mobile 16 | 17 | on: 18 | push: 19 | branches: [ "main" ] 20 | pull_request: 21 | branches: [ "main" ] 22 | paths: 23 | - 'app/**' 24 | - 'tests/**' 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | flutter: 31 | strategy: 32 | matrix: 33 | os: 34 | - ubuntu-latest 35 | - macos-latest 36 | runs-on: ${{ matrix.os }} 37 | defaults: 38 | run: 39 | working-directory: ./app 40 | steps: 41 | - uses: actions/checkout@v5 42 | - name: Set up Flutter 43 | uses: subosito/flutter-action@v2 44 | with: 45 | channel: stable 46 | cache: true 47 | - name: Install dependencies 48 | run: flutter pub get 49 | - name: Run Flutter tests with coverage 50 | run: flutter test --coverage 51 | - name: Check formatting 52 | run: dart format --set-exit-if-changed . 53 | - name: Analyze Dart code 54 | run: flutter analyze 55 | - name: Build Android debug APK 56 | if: matrix.os == 'ubuntu-latest' 57 | run: flutter build apk --debug 58 | - name: Build iOS debug IPA 59 | if: matrix.os == 'macos-latest' 60 | run: flutter build ipa --debug --no-codesign 61 | - name: Upload coverage to Codecov 62 | if: always() 63 | uses: codecov/codecov-action@v5 64 | with: 65 | token: ${{ secrets.CODECOV_TOKEN }} 66 | files: coverage/lcov.info 67 | flags: flutter-mobile 68 | name: flutter-mobile 69 | fail_ci_if_error: false 70 | env: 71 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 72 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/map-auto-fit-settings.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | 20 | import { resolveAutoFitBoundsConfig, __testUtils } from '../map-auto-fit-settings.js'; 21 | 22 | const { MINIMUM_AUTO_FIT_RANGE_KM, AUTO_FIT_PADDING_FRACTION } = __testUtils; 23 | 24 | test('resolveAutoFitBoundsConfig returns defaults without a distance limit', () => { 25 | const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: false, maxDistanceKm: null }); 26 | assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION); 27 | assert.equal(config.minimumRangeKm, MINIMUM_AUTO_FIT_RANGE_KM); 28 | }); 29 | 30 | test('resolveAutoFitBoundsConfig constrains minimum range by the limit radius', () => { 31 | const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: true, maxDistanceKm: 2 }); 32 | assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION); 33 | assert.ok(config.minimumRangeKm >= MINIMUM_AUTO_FIT_RANGE_KM); 34 | assert.ok(config.minimumRangeKm <= 2); 35 | }); 36 | 37 | test('resolveAutoFitBoundsConfig respects small distance limits', () => { 38 | const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: true, maxDistanceKm: 0.1 }); 39 | assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION); 40 | assert.equal(config.minimumRangeKm, 0.1); 41 | }); 42 | 43 | test('resolveAutoFitBoundsConfig tolerates invalid input', () => { 44 | const config = resolveAutoFitBoundsConfig({ hasDistanceLimit: true, maxDistanceKm: -5 }); 45 | assert.equal(config.paddingFraction, AUTO_FIT_PADDING_FRACTION); 46 | assert.equal(config.minimumRangeKm, MINIMUM_AUTO_FIT_RANGE_KM); 47 | }); 48 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Ruby 16 | 17 | on: 18 | push: 19 | branches: [ "main" ] 20 | pull_request: 21 | branches: [ "main" ] 22 | paths: 23 | - 'web/**' 24 | - 'tests/**' 25 | 26 | permissions: 27 | contents: read 28 | 29 | jobs: 30 | sinatra: 31 | defaults: 32 | run: 33 | working-directory: ./web 34 | runs-on: ubuntu-latest 35 | strategy: 36 | matrix: 37 | ruby-version: ['3.3', '3.4'] 38 | 39 | steps: 40 | - uses: actions/checkout@v5 41 | - name: Set up Ruby 42 | uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby-version }} 45 | bundler-cache: true 46 | working-directory: ./web 47 | - name: Set up dependencies 48 | run: bundle install 49 | - name: Run tests 50 | run: | 51 | mkdir -p tmp/test-results 52 | bundle exec rspec \ 53 | --require rspec_junit_formatter \ 54 | --format progress \ 55 | --format RspecJunitFormatter \ 56 | --out tmp/test-results/rspec.xml 57 | - name: Upload test results to Codecov 58 | uses: codecov/test-results-action@v1 59 | with: 60 | token: ${{ secrets.CODECOV_TOKEN }} 61 | files: ./web/tmp/test-results/rspec.xml 62 | flags: sinatra-${{ matrix.ruby-version }} 63 | - name: Upload coverage to Codecov 64 | uses: codecov/codecov-action@v5 65 | with: 66 | token: ${{ secrets.CODECOV_TOKEN }} 67 | fail_ci_if_error: false 68 | flags: sinatra-${{ matrix.ruby-version }} 69 | env: 70 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 71 | - name: Run rufo 72 | run: bundle exec rufo --check . 73 | -------------------------------------------------------------------------------- /data/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.6 2 | # Copyright © 2025-26 l5yth & contributors 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | ARG TARGETOS=linux 17 | ARG PYTHON_VERSION=3.12.6 18 | 19 | # Linux production image 20 | FROM python:${PYTHON_VERSION}-alpine AS production-linux 21 | 22 | ENV PYTHONDONTWRITEBYTECODE=1 \ 23 | PYTHONUNBUFFERED=1 24 | 25 | WORKDIR /app 26 | 27 | COPY data/requirements.txt ./ 28 | RUN set -eux; \ 29 | apk add --no-cache \ 30 | tzdata \ 31 | curl \ 32 | libstdc++ \ 33 | libgcc; \ 34 | apk add --no-cache --virtual .build-deps \ 35 | gcc \ 36 | musl-dev \ 37 | linux-headers \ 38 | build-base; \ 39 | python -m pip install --no-cache-dir -r requirements.txt; \ 40 | apk del .build-deps 41 | 42 | COPY data /app/data 43 | RUN addgroup -S potatomesh && \ 44 | adduser -S potatomesh -G potatomesh && \ 45 | adduser potatomesh dialout && \ 46 | chown -R potatomesh:potatomesh /app 47 | 48 | USER potatomesh 49 | 50 | ENV CONNECTION=/dev/ttyACM0 \ 51 | CHANNEL_INDEX=0 \ 52 | DEBUG=0 \ 53 | ALLOWED_CHANNELS="" \ 54 | HIDDEN_CHANNELS="" \ 55 | INSTANCE_DOMAIN="" \ 56 | API_TOKEN="" 57 | 58 | CMD ["python", "-m", "data.mesh"] 59 | 60 | # Windows production image 61 | FROM python:${PYTHON_VERSION}-windowsservercore-ltsc2022 AS production-windows 62 | 63 | SHELL ["cmd", "/S", "/C"] 64 | 65 | ENV PYTHONDONTWRITEBYTECODE=1 66 | ENV PYTHONUNBUFFERED=1 67 | 68 | WORKDIR /app 69 | 70 | COPY data/requirements.txt ./ 71 | RUN python -m pip install --no-cache-dir -r requirements.txt 72 | 73 | COPY data /app/data 74 | 75 | USER ContainerUser 76 | 77 | ENV CONNECTION=/dev/ttyACM0 \ 78 | CHANNEL_INDEX=0 \ 79 | DEBUG=0 \ 80 | ALLOWED_CHANNELS="" \ 81 | HIDDEN_CHANNELS="" \ 82 | INSTANCE_DOMAIN="" \ 83 | API_TOKEN="" 84 | 85 | CMD ["python", "-m", "data.mesh"] 86 | 87 | FROM production-${TARGETOS} AS production 88 | -------------------------------------------------------------------------------- /data/telemetry.sql: -------------------------------------------------------------------------------- 1 | -- Copyright © 2025-26 l5yth & contributors 2 | -- 3 | -- Licensed under the Apache License, Version 2.0 (the "License"); 4 | -- you may not use this file except in compliance with the License. 5 | -- You may obtain a copy of the License at 6 | -- 7 | -- http://www.apache.org/licenses/LICENSE-2.0 8 | -- 9 | -- Unless required by applicable law or agreed to in writing, software 10 | -- distributed under the License is distributed on an "AS IS" BASIS, 11 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | -- See the License for the specific language governing permissions and 13 | -- limitations under the License. 14 | 15 | CREATE TABLE IF NOT EXISTS telemetry ( 16 | id INTEGER PRIMARY KEY, 17 | node_id TEXT, 18 | node_num INTEGER, 19 | from_id TEXT, 20 | to_id TEXT, 21 | rx_time INTEGER NOT NULL, 22 | rx_iso TEXT NOT NULL, 23 | telemetry_time INTEGER, 24 | channel INTEGER, 25 | portnum TEXT, 26 | hop_limit INTEGER, 27 | snr REAL, 28 | rssi INTEGER, 29 | bitfield INTEGER, 30 | payload_b64 TEXT, 31 | battery_level REAL, 32 | voltage REAL, 33 | channel_utilization REAL, 34 | air_util_tx REAL, 35 | uptime_seconds INTEGER, 36 | temperature REAL, 37 | relative_humidity REAL, 38 | barometric_pressure REAL, 39 | gas_resistance REAL, 40 | current REAL, 41 | iaq INTEGER, 42 | distance REAL, 43 | lux REAL, 44 | white_lux REAL, 45 | ir_lux REAL, 46 | uv_lux REAL, 47 | wind_direction INTEGER, 48 | wind_speed REAL, 49 | weight REAL, 50 | wind_gust REAL, 51 | wind_lull REAL, 52 | radiation REAL, 53 | rainfall_1h REAL, 54 | rainfall_24h REAL, 55 | soil_moisture INTEGER, 56 | soil_temperature REAL 57 | ); 58 | 59 | CREATE INDEX IF NOT EXISTS idx_telemetry_rx_time ON telemetry(rx_time); 60 | CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id); 61 | CREATE INDEX IF NOT EXISTS idx_telemetry_time ON telemetry(telemetry_time); 62 | -------------------------------------------------------------------------------- /tests/test_version_sync.py: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Ensure version identifiers stay synchronised across all packages.""" 16 | 17 | from __future__ import annotations 18 | 19 | import json 20 | import re 21 | import sys 22 | from pathlib import Path 23 | 24 | REPO_ROOT = Path(__file__).resolve().parents[1] 25 | if str(REPO_ROOT) not in sys.path: 26 | sys.path.insert(0, str(REPO_ROOT)) 27 | 28 | import data 29 | 30 | 31 | def _ruby_fallback_version() -> str: 32 | config_path = REPO_ROOT / "web" / "lib" / "potato_mesh" / "config.rb" 33 | contents = config_path.read_text(encoding="utf-8") 34 | inside = False 35 | for line in contents.splitlines(): 36 | stripped = line.strip() 37 | if stripped.startswith("def version_fallback"): 38 | inside = True 39 | continue 40 | if inside and stripped == "end": 41 | break 42 | if inside: 43 | literal = re.search(r"['\"](?P[^'\"]+)['\"]", stripped) 44 | if literal: 45 | return literal.group("version") 46 | raise AssertionError("Unable to locate version_fallback definition in config.rb") 47 | 48 | 49 | def _javascript_package_version() -> str: 50 | package_path = REPO_ROOT / "web" / "package.json" 51 | data = json.loads(package_path.read_text(encoding="utf-8")) 52 | version = data.get("version") 53 | if isinstance(version, str): 54 | return version 55 | raise AssertionError("package.json does not expose a string version") 56 | 57 | 58 | def test_version_identifiers_match_across_languages() -> None: 59 | """Guard against version drift between Python, Ruby, and JavaScript.""" 60 | 61 | python_version = getattr(data, "__version__", None) 62 | assert ( 63 | isinstance(python_version, str) and python_version 64 | ), "data.__version__ missing" 65 | 66 | ruby_version = _ruby_fallback_version() 67 | javascript_version = _javascript_package_version() 68 | 69 | assert python_version == ruby_version == javascript_version 70 | -------------------------------------------------------------------------------- /app/test/notification_sender_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright © 2025-26 l5yth & contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'package:flutter_test/flutter_test.dart'; 16 | import 'package:potato_mesh_reader/main.dart'; 17 | 18 | class _StubRepository extends MeshRepository { 19 | _StubRepository(this.node) : super(); 20 | 21 | final MeshNode? node; 22 | 23 | @override 24 | MeshNode? findNode(String domain, String nodeId) { 25 | return node; 26 | } 27 | } 28 | 29 | MeshMessage _buildMessage({ 30 | required int id, 31 | required String nodeId, 32 | String text = 'hello', 33 | }) { 34 | final rx = DateTime.utc(2024, 1, 1, 12, id); 35 | return MeshMessage( 36 | id: id, 37 | rxTime: rx, 38 | rxIso: rx.toIso8601String(), 39 | fromId: nodeId, 40 | nodeId: nodeId, 41 | toId: '^', 42 | channel: 1, 43 | channelName: 'Main', 44 | portnum: 'TEXT', 45 | text: text, 46 | rssi: -50, 47 | snr: 1.0, 48 | hopLimit: 1, 49 | ); 50 | } 51 | 52 | void main() { 53 | setUp(() { 54 | NodeShortNameCache.instance.clear(); 55 | }); 56 | 57 | test('prefers node long name when resolving sender', () { 58 | const node = MeshNode( 59 | nodeId: '!NODE1', 60 | shortName: 'N1', 61 | longName: 'Verbose Node', 62 | ); 63 | final repo = _StubRepository(node); 64 | final sender = repo.resolveNotificationSender( 65 | domain: 'potatomesh.net', 66 | message: _buildMessage(id: 1, nodeId: '!NODE1'), 67 | ); 68 | 69 | expect(sender.longName, 'Verbose Node'); 70 | expect(sender.shortName, 'N1'); 71 | expect(sender.preferredName, 'Verbose Node'); 72 | }); 73 | 74 | test('falls back to short identifier when metadata is missing', () { 75 | final repo = _StubRepository(null); 76 | final sender = repo.resolveNotificationSender( 77 | domain: 'potatomesh.net', 78 | message: _buildMessage(id: 2, nodeId: '!NODE2'), 79 | ); 80 | 81 | expect(sender.longName, isNull); 82 | expect(sender.shortName, 'ODE2'); 83 | expect(sender.preferredName, 'ODE2'); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /app/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 10 | 19 | 23 | 27 | 28 | 29 | 30 | 31 | 32 | 34 | 37 | 38 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app/android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/node-modem-metadata.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { describe, it } from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay, __testUtils } from '../node-modem-metadata.js'; 20 | 21 | describe('node-modem-metadata', () => { 22 | it('extracts modem preset and frequency from mixed payloads', () => { 23 | const payload = { 24 | modem_preset: ' MediumFast ', 25 | lora_freq: '915', 26 | }; 27 | assert.deepEqual(extractModemMetadata(payload), { modemPreset: 'MediumFast', loraFreq: 915 }); 28 | }); 29 | 30 | it('falls back across naming conventions when extracting metadata', () => { 31 | const payload = { 32 | modemPreset: 'LongSlow', 33 | frequency: 868, 34 | }; 35 | assert.deepEqual(extractModemMetadata(payload), { modemPreset: 'LongSlow', loraFreq: 868 }); 36 | }); 37 | 38 | it('ignores invalid modem metadata entries', () => { 39 | assert.deepEqual(extractModemMetadata({ modem_preset: ' ', lora_freq: 'NaN' }), { 40 | modemPreset: null, 41 | loraFreq: null, 42 | }); 43 | }); 44 | 45 | it('formats positive frequencies with MHz suffix', () => { 46 | assert.equal(formatLoraFrequencyMHz(915), '915MHz'); 47 | assert.equal(formatLoraFrequencyMHz(867.5), '867.5MHz'); 48 | assert.equal(formatLoraFrequencyMHz('433.1234'), '433.123MHz'); 49 | assert.equal(formatLoraFrequencyMHz(null), null); 50 | }); 51 | 52 | it('combines preset and frequency for overlay display', () => { 53 | assert.equal(formatModemDisplay('MediumFast', 868), 'MediumFast (868MHz)'); 54 | assert.equal(formatModemDisplay('ShortSlow', null), 'ShortSlow'); 55 | assert.equal(formatModemDisplay(null, 433), '433MHz'); 56 | assert.equal(formatModemDisplay(undefined, undefined), null); 57 | }); 58 | 59 | it('exposes trimmed string helper for targeted assertions', () => { 60 | const { toTrimmedString } = __testUtils; 61 | assert.equal(toTrimmedString(' hello '), 'hello'); 62 | assert.equal(toTrimmedString(''), null); 63 | assert.equal(toTrimmedString(null), null); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Rust 16 | 17 | on: 18 | push: 19 | branches: [ "main" ] 20 | pull_request: 21 | branches: [ "main" ] 22 | paths: 23 | - '.github/**' 24 | - 'matrix/**' 25 | - 'tests/**' 26 | 27 | permissions: 28 | contents: read 29 | 30 | jobs: 31 | matrix: 32 | runs-on: ubuntu-latest 33 | 34 | steps: 35 | - uses: actions/checkout@v5 36 | - name: Install Rust toolchain 37 | uses: dtolnay/rust-toolchain@stable 38 | - name: Cache Cargo registry 39 | uses: actions/cache@v4 40 | with: 41 | path: | 42 | ~/.cargo/registry 43 | ~/.cargo/git 44 | ./matrix/target 45 | key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.toml', '**/Cargo.lock') }} 46 | restore-keys: | 47 | ${{ runner.os }}-cargo- 48 | - name: Show rustc version 49 | run: rustc --version 50 | - name: Install llvm-tools-preview component 51 | run: rustup component add llvm-tools-preview --toolchain stable 52 | - name: Install cargo-llvm-cov 53 | working-directory: ./matrix 54 | run: cargo install cargo-llvm-cov --locked 55 | - name: Check formatting 56 | working-directory: ./matrix 57 | run: cargo fmt --all -- --check 58 | - name: Clippy lint 59 | working-directory: ./matrix 60 | run: cargo clippy --all-targets --all-features -- -D warnings 61 | - name: Build 62 | working-directory: ./matrix 63 | run: cargo build --all --all-features 64 | - name: Test 65 | working-directory: ./matrix 66 | run: cargo test --all --all-features --verbose 67 | - name: Run tests with coverage 68 | working-directory: ./matrix 69 | run: | 70 | cargo llvm-cov --all-features --workspace --lcov --output-path coverage.lcov 71 | - name: Upload coverage to Codecov 72 | uses: codecov/codecov-action@v5 73 | with: 74 | token: ${{ secrets.CODECOV_TOKEN }} 75 | files: ./matrix/coverage.lcov 76 | flags: matrix-bridge 77 | name: matrix-bridge 78 | fail_ci_if_error: false 79 | -------------------------------------------------------------------------------- /web/public/assets/js/app/short-info-satellites.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Coerce a candidate value into a positive integer satellite count. 19 | * 20 | * @param {*} value Raw candidate value. 21 | * @returns {number|null} Rounded positive integer or ``null``. 22 | */ 23 | function toPositiveInteger(value) { 24 | if (value == null || value === '') return null; 25 | const num = typeof value === 'number' ? value : Number(value); 26 | if (!Number.isFinite(num) || num <= 0) return null; 27 | return Math.round(num); 28 | } 29 | 30 | /** 31 | * Extract the satellite count from a node-like payload. 32 | * 33 | * @param {*} info Node payload potentially containing satellite metadata. 34 | * @returns {number|null} Satellite count when present and positive. 35 | */ 36 | export function resolveSatsInView(info) { 37 | if (!info || typeof info !== 'object') { 38 | return toPositiveInteger(info); 39 | } 40 | const candidates = [ 41 | info.satsInView, 42 | info.sats_in_view, 43 | info.position?.satsInView, 44 | info.position?.sats_in_view, 45 | info.rawSources?.position?.satsInView, 46 | info.rawSources?.position?.sats_in_view, 47 | ]; 48 | for (const candidate of candidates) { 49 | const count = toPositiveInteger(candidate); 50 | if (count != null) { 51 | return count; 52 | } 53 | } 54 | return null; 55 | } 56 | 57 | const ICON_PATH = '/assets/img/satellite-icon.svg'; 58 | 59 | /** 60 | * Render a short-info overlay row describing visible satellites. 61 | * 62 | * @param {*} info Node payload providing satellite metadata. 63 | * @returns {string} HTML snippet or an empty string when unavailable. 64 | */ 65 | export function renderSatsInViewBadge(info) { 66 | const count = resolveSatsInView(info); 67 | if (count == null) { 68 | return ''; 69 | } 70 | return [ 71 | '', 72 | ``, 75 | `${count}`, 76 | '', 77 | ].join(''); 78 | } 79 | 80 | export const __testUtils = { 81 | toPositiveInteger, 82 | }; 83 | -------------------------------------------------------------------------------- /app/test/widget_test.dart: -------------------------------------------------------------------------------- 1 | // This is a basic Flutter widget test. 2 | // 3 | // To perform an interaction with a widget in your test, use the WidgetTester 4 | // utility in the flutter_test package. For example, you can send tap and scroll 5 | // gestures. You can also use WidgetTester to find child widgets in the widget 6 | // tree, read text, and verify that the values of widget properties are correct. 7 | 8 | import 'package:flutter_test/flutter_test.dart'; 9 | import 'package:http/http.dart' as http; 10 | import 'package:shared_preferences/shared_preferences.dart'; 11 | 12 | import 'package:potato_mesh_reader/main.dart'; 13 | 14 | void main() { 15 | setUp(() { 16 | SharedPreferences.setMockInitialValues({}); 17 | NodeShortNameCache.instance.clear(); 18 | }); 19 | 20 | testWidgets('renders messages from fetcher and refreshes list', 21 | (WidgetTester tester) async { 22 | final sampleMessages = [ 23 | MeshMessage( 24 | id: 1, 25 | rxTime: null, 26 | rxIso: '2025-01-01T00:00:00Z', 27 | fromId: '!nodeA', 28 | toId: '^all', 29 | channel: 1, 30 | channelName: 'TEST', 31 | portnum: 'TEXT_MESSAGE_APP', 32 | text: 'hello world', 33 | rssi: -100, 34 | snr: -5.0, 35 | hopLimit: 3, 36 | ), 37 | MeshMessage( 38 | id: 2, 39 | rxTime: null, 40 | rxIso: '2025-01-01T01:00:00Z', 41 | fromId: '!nodeB', 42 | toId: '^all', 43 | channel: 1, 44 | channelName: 'TEST', 45 | portnum: 'TEXT_MESSAGE_APP', 46 | text: 'second message', 47 | rssi: -90, 48 | snr: -4.0, 49 | hopLimit: 3, 50 | ), 51 | ]; 52 | 53 | var fetchCount = 0; 54 | Future> mockFetcher({ 55 | http.Client? client, 56 | String domain = 'potatomesh.net', 57 | }) async { 58 | final idx = fetchCount >= sampleMessages.length 59 | ? sampleMessages.length - 1 60 | : fetchCount; 61 | fetchCount += 1; 62 | return [sampleMessages[idx]]; 63 | } 64 | 65 | Future bootstrapper({ProgressCallback? onProgress}) async { 66 | onProgress?.call(const BootstrapProgress(stage: 'loading instances')); 67 | return BootstrapResult( 68 | instances: const [], 69 | nodes: const [], 70 | messages: sampleMessages, 71 | selectedDomain: 'potatomesh.net', 72 | ); 73 | } 74 | 75 | await tester.pumpWidget( 76 | PotatoMeshReaderApp( 77 | fetcher: mockFetcher, 78 | bootstrapper: bootstrapper, 79 | ), 80 | ); 81 | await tester.pumpAndSettle(); 82 | 83 | expect(find.textContaining('PotatoMesh Reader'), findsOneWidget); 84 | expect(find.textContaining('[--:--]'), findsWidgets); 85 | expect(find.byType(ChatLine), findsNWidgets(2)); 86 | expect(find.textContaining('hello world'), findsOneWidget); 87 | expect(find.textContaining('#TEST'), findsWidgets); 88 | expect(find.textContaining(''), findsOneWidget); 89 | expect(find.textContaining('second message'), findsOneWidget); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /web/lib/potato_mesh/meta.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | require_relative "config" 18 | require_relative "sanitizer" 19 | 20 | module PotatoMesh 21 | # Helper functions used to generate SEO metadata and formatted values. 22 | module Meta 23 | module_function 24 | 25 | # Format a distance in kilometres without trailing decimal precision when unnecessary. 26 | # 27 | # @param distance [Numeric] distance in kilometres. 28 | # @return [String] formatted kilometre value. 29 | def formatted_distance_km(distance) 30 | format("%.1f", distance).sub(/\.0\z/, "") 31 | end 32 | 33 | # Construct the meta description string displayed to search engines and social previews. 34 | # 35 | # @param private_mode [Boolean] whether private mode is enabled. 36 | # @return [String] generated description text. 37 | def description(private_mode:) 38 | site = Sanitizer.sanitized_site_name 39 | channel = Sanitizer.sanitized_channel 40 | frequency = Sanitizer.sanitized_frequency 41 | contact = Sanitizer.sanitized_contact_link 42 | 43 | summary = "Live Meshtastic mesh map for #{site}" 44 | if channel.empty? && frequency.empty? 45 | summary += "." 46 | elsif channel.empty? 47 | summary += " tuned to #{frequency}." 48 | elsif frequency.empty? 49 | summary += " on #{channel}." 50 | else 51 | summary += " on #{channel} (#{frequency})." 52 | end 53 | 54 | activity_sentence = if private_mode 55 | "Track nodes and coverage in real time." 56 | else 57 | "Track nodes, messages, and coverage in real time." 58 | end 59 | 60 | sentences = [summary, activity_sentence] 61 | if (distance = Sanitizer.sanitized_max_distance_km) 62 | sentences << "Shows nodes within roughly #{formatted_distance_km(distance)} km of the map center." 63 | end 64 | sentences << "Join the community in #{contact} via chat." if contact 65 | 66 | sentences.join(" ") 67 | end 68 | 69 | # Build a hash of meta configuration values used by templating layers. 70 | # 71 | # @param private_mode [Boolean] whether private mode is enabled. 72 | # @return [Hash] structured metadata for templates. 73 | def configuration(private_mode:) 74 | site = Sanitizer.sanitized_site_name 75 | { 76 | title: site, 77 | name: site, 78 | description: description(private_mode: private_mode), 79 | }.freeze 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /app/test/cache_test.dart: -------------------------------------------------------------------------------- 1 | // Copyright © 2025-26 l5yth & contributors 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | import 'package:flutter_test/flutter_test.dart'; 16 | import 'package:http/http.dart' as http; 17 | import 'package:http/testing.dart'; 18 | import 'package:potato_mesh_reader/main.dart'; 19 | import 'package:shared_preferences/shared_preferences.dart'; 20 | 21 | void main() { 22 | TestWidgetsFlutterBinding.ensureInitialized(); 23 | 24 | setUp(() { 25 | SharedPreferences.setMockInitialValues({}); 26 | NodeShortNameCache.instance.clear(); 27 | }); 28 | 29 | test('NodeShortNameCache fetches and memoizes short names', () async { 30 | var calls = 0; 31 | final client = MockClient((request) async { 32 | calls += 1; 33 | expect(request.url.path, '/api/nodes/!cache-test'); 34 | return http.Response('{"short_name":"NODE"}', 200); 35 | }); 36 | 37 | final first = await NodeShortNameCache.instance.shortNameFor( 38 | domain: 'cache.test', 39 | nodeId: '!cache-test', 40 | client: client, 41 | ); 42 | final second = await NodeShortNameCache.instance.shortNameFor( 43 | domain: 'cache.test', 44 | nodeId: '!cache-test', 45 | client: client, 46 | ); 47 | 48 | expect(first, 'NODE'); 49 | expect(second, 'NODE'); 50 | expect(calls, 1, reason: 'memoises results per domain/id'); 51 | }); 52 | 53 | test('NodeShortNameCache falls back to padded suffix', () { 54 | expect(NodeShortNameCache.fallbackShortName('!ab'), ' ab'); 55 | expect(NodeShortNameCache.fallbackShortName('!abcdef'), 'cdef'); 56 | expect(NodeShortNameCache.fallbackShortName(''), '????'); 57 | }); 58 | 59 | test('InstanceVersionCache fetches and caches version payloads', () async { 60 | var calls = 0; 61 | final client = MockClient((request) async { 62 | calls += 1; 63 | expect(request.url.path, '/version'); 64 | return http.Response( 65 | '{"name":"BerlinMesh","config":{"channel":"#MediumFast","frequency":"868MHz","instanceDomain":"potatomesh.net"}}', 66 | 200, 67 | ); 68 | }); 69 | 70 | final first = await InstanceVersionCache.instance 71 | .fetch(domain: 'version.test', client: client); 72 | final second = await InstanceVersionCache.instance 73 | .fetch(domain: 'version.test', client: client); 74 | 75 | expect(first?.summary, contains('BerlinMesh')); 76 | expect(first?.summary, contains('#MediumFast')); 77 | expect(calls, 1, reason: 'cache should avoid duplicate network calls'); 78 | expect(second?.summary, first?.summary); 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/document-stub.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Minimal document implementation that exposes the subset of behaviour needed 19 | * by the front-end modules during unit tests. 20 | */ 21 | class DocumentStub { 22 | /** 23 | * Instantiate a new stub with a clean internal state. 24 | */ 25 | constructor() { 26 | this.reset(); 27 | } 28 | 29 | /** 30 | * Clear tracked configuration elements and registered event listeners. 31 | * 32 | * @returns {void} 33 | */ 34 | reset() { 35 | this.configElement = null; 36 | this.listeners = new Map(); 37 | } 38 | 39 | /** 40 | * Provide an element that will be returned by ``querySelector`` when the 41 | * configuration selector is requested. 42 | * 43 | * @param {?Element} element DOM node exposing ``getAttribute``. 44 | * @returns {void} 45 | */ 46 | setConfigElement(element) { 47 | this.configElement = element; 48 | } 49 | 50 | /** 51 | * Return the registered configuration element when the matching selector is 52 | * provided. 53 | * 54 | * @param {string} selector CSS selector requested by the module under test. 55 | * @returns {?Element} Config element or ``null`` when unavailable. 56 | */ 57 | querySelector(selector) { 58 | if (selector === '[data-app-config]') { 59 | return this.configElement; 60 | } 61 | return null; 62 | } 63 | 64 | /** 65 | * Register an event handler, mirroring the DOM ``addEventListener`` API. 66 | * 67 | * @param {string} event Event identifier. 68 | * @param {Function} handler Callback invoked when ``dispatchEvent`` is 69 | * called. 70 | * @returns {void} 71 | */ 72 | addEventListener(event, handler) { 73 | this.listeners.set(event, handler); 74 | } 75 | 76 | /** 77 | * Trigger a previously registered listener. 78 | * 79 | * @param {string} event Event identifier used when registering the handler. 80 | * @returns {void} 81 | */ 82 | dispatchEvent(event) { 83 | const handler = this.listeners.get(event); 84 | if (handler) { 85 | handler(); 86 | } 87 | } 88 | } 89 | 90 | export const documentStub = new DocumentStub(); 91 | 92 | /** 93 | * Reset the shared stub between test cases to avoid state bleed. 94 | * 95 | * @returns {void} 96 | */ 97 | export function resetDocumentStub() { 98 | documentStub.reset(); 99 | } 100 | 101 | globalThis.document = documentStub; 102 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # NOTE: This Dockerfile is kept for backward compatibility. The canonical build 16 | # instructions live in `web/Dockerfile`; keep the two files in sync. 17 | 18 | # Main application builder stage 19 | FROM ruby:3.3-alpine AS builder 20 | 21 | # Ensure native extensions are built against musl libc rather than 22 | # using glibc precompiled binaries (which fail on Alpine). 23 | ENV BUNDLE_FORCE_RUBY_PLATFORM=true 24 | 25 | # Install build dependencies and SQLite3 26 | RUN apk add --no-cache \ 27 | build-base \ 28 | sqlite-dev \ 29 | linux-headers \ 30 | pkgconfig 31 | 32 | # Set working directory 33 | WORKDIR /app 34 | 35 | # Copy Gemfile and install dependencies 36 | COPY web/Gemfile web/Gemfile.lock* ./ 37 | 38 | # Install gems with SQLite3 support 39 | RUN bundle config set --local force_ruby_platform true && \ 40 | bundle config set --local without 'development test' && \ 41 | bundle install --jobs=4 --retry=3 42 | 43 | # Production stage 44 | FROM ruby:3.3-alpine AS production 45 | 46 | # Install runtime dependencies 47 | RUN apk add --no-cache \ 48 | sqlite \ 49 | tzdata \ 50 | curl 51 | 52 | # Create non-root user 53 | RUN addgroup -g 1000 -S potatomesh && \ 54 | adduser -u 1000 -S potatomesh -G potatomesh 55 | 56 | # Set working directory 57 | WORKDIR /app 58 | 59 | # Copy installed gems from builder stage 60 | COPY --from=builder /usr/local/bundle /usr/local/bundle 61 | 62 | # Copy application code (exclude Dockerfile from web directory) 63 | COPY --chown=potatomesh:potatomesh web/app.rb web/app.sh web/Gemfile web/Gemfile.lock* web/spec/ ./ 64 | COPY --chown=potatomesh:potatomesh web/public ./public 65 | COPY --chown=potatomesh:potatomesh web/views/ ./views/ 66 | 67 | # Copy SQL schema files from data directory 68 | COPY --chown=potatomesh:potatomesh data/*.sql /data/ 69 | 70 | # Create data directory for SQLite database 71 | RUN mkdir -p /app/data /app/.local/share/potato-mesh && \ 72 | chown -R potatomesh:potatomesh /app/data /app/.local 73 | 74 | # Switch to non-root user 75 | USER potatomesh 76 | 77 | # Expose port 78 | EXPOSE 41447 79 | 80 | # Default environment variables (can be overridden by host) 81 | ENV APP_ENV=production \ 82 | RACK_ENV=production \ 83 | SITE_NAME="PotatoMesh Demo" \ 84 | INSTANCE_DOMAIN="potato.example.com" \ 85 | CHANNEL="#LongFast" \ 86 | FREQUENCY="915MHz" \ 87 | MAP_CENTER="38.761944,-27.090833" \ 88 | MAX_DISTANCE=42 \ 89 | CONTACT_LINK="#potatomesh:dod.ngo" \ 90 | DEBUG=0 91 | 92 | # Start the application 93 | CMD ["ruby", "app.rb", "-p", "41447", "-o", "0.0.0.0"] 94 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # PotatoMesh Environment Configuration 2 | # Copy this file to .env and customize for your setup 3 | 4 | # ============================================================================= 5 | # REQUIRED SETTINGS 6 | # ============================================================================= 7 | 8 | # Public domain name for this PotatoMesh instance (required for webapp) 9 | # Provide a hostname (with optional port) that resolves to the web service. 10 | # Example: mesh.example.org or mesh.example.org:41447 11 | INSTANCE_DOMAIN="mesh.example.org" 12 | 13 | # API authentication token (required for ingestor communication) 14 | # Generate a secure token: openssl rand -hex 32 15 | API_TOKEN="your-secure-api-token-here" 16 | 17 | # Meshtastic connection target (required for ingestor) 18 | # Common serial paths: 19 | # - Linux: /dev/ttyACM0, /dev/ttyUSB0 20 | # - macOS: /dev/cu.usbserial-* 21 | # - Windows (WSL): /dev/ttyS* 22 | # You may also provide an IP:PORT pair (e.g. 192.168.1.20:4403) or a 23 | # Bluetooth address (e.g. ED:4D:9E:95:CF:60). 24 | CONNECTION="/dev/ttyACM0" 25 | 26 | # ============================================================================= 27 | # SITE CUSTOMIZATION 28 | # ============================================================================= 29 | 30 | # Your mesh network name 31 | SITE_NAME="My Meshtastic Network" 32 | 33 | # Default Meshtastic channel 34 | CHANNEL="#LongFast" 35 | 36 | # Default frequency for your region 37 | # Common frequencies: 868MHz (Europe), 915MHz (US), 433MHz (Worldwide) 38 | FREQUENCY="915MHz" 39 | 40 | # Map center coordinates (latitude, longitude) 41 | # Berlin, Germany: 52.502889, 13.404194 42 | # Denver, Colorado: 39.7392, -104.9903 43 | # London, UK: 51.5074, -0.1278 44 | MAP_CENTER="38.761944,-27.090833" 45 | 46 | # Maximum distance to show nodes (kilometers) 47 | MAX_DISTANCE=42 48 | 49 | # ============================================================================= 50 | # OPTIONAL INTEGRATIONS 51 | # ============================================================================= 52 | 53 | # Community chat link or Matrix room for your community (optional) 54 | # Matrix aliases (e.g. #meshtastic-berlin:matrix.org) will be linked via matrix.to automatically. 55 | CONTACT_LINK="#potatomesh:dod.ngo" 56 | 57 | # Enable or disable PotatoMesh federation features (1=enabled, 0=disabled) 58 | FEDERATION=1 59 | 60 | # Hide public mesh messages from unauthenticated visitors (1=hidden, 0=public) 61 | PRIVATE=0 62 | 63 | 64 | # ============================================================================= 65 | # ADVANCED SETTINGS 66 | # ============================================================================= 67 | 68 | # Debug mode (0=off, 1=on) 69 | DEBUG=0 70 | 71 | # Default map zoom override 72 | # MAP_ZOOM=15 73 | 74 | # Docker image architecture (linux-amd64, linux-arm64, linux-armv7) 75 | POTATOMESH_IMAGE_ARCH="linux-amd64" 76 | 77 | # Docker image tag (use "latest" for the newest release or pin to vX.Y) 78 | POTATOMESH_IMAGE_TAG="latest" 79 | 80 | # Docker Compose networking profile 81 | # Leave unset for Linux hosts (default host networking). 82 | # Set to "bridge" on Docker Desktop (macOS/Windows) if host networking 83 | # is unavailable. 84 | # COMPOSE_PROFILES="bridge" 85 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/node-snapshot-normalizer.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | 20 | import { normalizeNodeSnapshot, normalizeNodeCollection, __testUtils } from '../node-snapshot-normalizer.js'; 21 | 22 | const { normalizeNumber, normalizeString } = __testUtils; 23 | 24 | test('normalizeNodeSnapshot synchronises telemetry aliases', () => { 25 | const node = { 26 | node_id: '!test', 27 | channel: '56.2', 28 | airUtil: 13.5, 29 | battery_level: 45.5, 30 | relativeHumidity: 24.3, 31 | lastHeard: '1234', 32 | }; 33 | 34 | const normalised = normalizeNodeSnapshot(node); 35 | 36 | assert.equal(normalised.nodeId, '!test'); 37 | assert.equal(normalised.channel_utilization, 56.2); 38 | assert.equal(normalised.channelUtilization, 56.2); 39 | assert.equal(normalised.channel, 56.2); 40 | assert.equal(normalised.air_util_tx, 13.5); 41 | assert.equal(normalised.airUtilTx, 13.5); 42 | assert.equal(normalised.airUtil, 13.5); 43 | assert.equal(normalised.battery, 45.5); 44 | assert.equal(normalised.batteryLevel, 45.5); 45 | assert.equal(normalised.relative_humidity, 24.3); 46 | assert.equal(normalised.humidity, 24.3); 47 | assert.equal(normalised.last_heard, 1234); 48 | }); 49 | 50 | test('normalizeNodeCollection applies canonical forms to all nodes', () => { 51 | const nodes = [ 52 | { short_name: ' AAA ', voltage: '3.7' }, 53 | { shortName: 'BBB', uptime_seconds: '3600', airUtilTx: '5.5' }, 54 | ]; 55 | 56 | normalizeNodeCollection(nodes); 57 | 58 | assert.equal(nodes[0].shortName, 'AAA'); 59 | assert.equal(nodes[0].short_name, 'AAA'); 60 | assert.equal(nodes[0].voltage, 3.7); 61 | assert.equal(nodes[1].uptime, 3600); 62 | assert.equal(nodes[1].air_util_tx, 5.5); 63 | }); 64 | 65 | test('normalizeNodeSnapshot maps numeric roles to canonical identifiers', () => { 66 | const roleNode = { role: '12', node_id: '!role' }; 67 | const numberRoleNode = { role: 12, nodeId: '!number-role' }; 68 | 69 | normalizeNodeCollection([roleNode, numberRoleNode]); 70 | 71 | assert.equal(roleNode.role, 'CLIENT_BASE'); 72 | assert.equal(numberRoleNode.role, 'CLIENT_BASE'); 73 | }); 74 | 75 | test('normaliser helpers coerce primitive values consistently', () => { 76 | assert.equal(normalizeNumber('42.1'), 42.1); 77 | assert.equal(normalizeNumber('not-a-number'), null); 78 | assert.equal(normalizeNumber(Infinity), null); 79 | 80 | assert.equal(normalizeString(' hello '), 'hello'); 81 | assert.equal(normalizeString(''), null); 82 | assert.equal(normalizeString(null), null); 83 | }); 84 | -------------------------------------------------------------------------------- /web/public/assets/js/background.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | (function () { 18 | 'use strict'; 19 | 20 | /** 21 | * Resolve the background colour that should be applied to the document. 22 | * 23 | * @returns {?string} CSS colour string or ``null`` if resolution fails. 24 | */ 25 | function resolveBackgroundColor() { 26 | if (!document.body) { 27 | return null; 28 | } 29 | 30 | var color = ''; 31 | try { 32 | var styles = window.getComputedStyle(document.body); 33 | if (styles) { 34 | color = styles.getPropertyValue('--bg'); 35 | if (color) { 36 | color = color.trim(); 37 | } 38 | } 39 | } catch (err) { 40 | color = ''; 41 | } 42 | 43 | if (!color) { 44 | color = document.body.classList.contains('dark') ? '#0e1418' : '#f6f3ee'; 45 | } 46 | 47 | return color; 48 | } 49 | 50 | /** 51 | * Apply the resolved background colour to the page root elements. 52 | * 53 | * @returns {void} 54 | */ 55 | function applyBackground() { 56 | var color = resolveBackgroundColor(); 57 | if (!color) { 58 | return; 59 | } 60 | 61 | document.documentElement.style.backgroundColor = color; 62 | document.documentElement.style.backgroundImage = 'none'; 63 | document.body.style.backgroundColor = color; 64 | document.body.style.backgroundImage = 'none'; 65 | } 66 | 67 | /** 68 | * Initialize the background helper once the DOM is ready. 69 | * 70 | * @returns {void} 71 | */ 72 | function init() { 73 | applyBackground(); 74 | } 75 | 76 | function bootstrap() { 77 | document.removeEventListener('DOMContentLoaded', init); 78 | if (document.readyState === 'loading') { 79 | document.addEventListener('DOMContentLoaded', init); 80 | } else { 81 | init(); 82 | } 83 | } 84 | 85 | bootstrap(); 86 | 87 | window.addEventListener('themechange', applyBackground); 88 | 89 | /** 90 | * Testing hooks exposing background helpers. 91 | * 92 | * @type {{ 93 | * applyBackground: function(): void, 94 | * resolveBackgroundColor: function(): (?string), 95 | * __testHooks: { 96 | * bootstrap: function(): void, 97 | * init: function(): void 98 | * } 99 | * }} 100 | */ 101 | window.__potatoBackground = { 102 | applyBackground: applyBackground, 103 | resolveBackgroundColor: resolveBackgroundColor, 104 | __testHooks: { 105 | bootstrap: bootstrap, 106 | init: init 107 | } 108 | }; 109 | })(); 110 | -------------------------------------------------------------------------------- /web/public/assets/js/app/node-modem-metadata.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Convert arbitrary input into a trimmed string representation. 19 | * 20 | * @param {*} value Candidate value. 21 | * @returns {string|null} Trimmed string or ``null`` when empty. 22 | */ 23 | function toTrimmedString(value) { 24 | if (value == null) return null; 25 | const stringValue = String(value).trim(); 26 | return stringValue.length > 0 ? stringValue : null; 27 | } 28 | 29 | /** 30 | * Normalize modem-related metadata from a node-shaped record. 31 | * 32 | * @param {*} source Arbitrary payload that may contain modem attributes. 33 | * @returns {{ modemPreset: (string|null), loraFreq: (number|null) }} Normalized modem metadata. 34 | */ 35 | export function extractModemMetadata(source) { 36 | if (!source || typeof source !== 'object') { 37 | return { modemPreset: null, loraFreq: null }; 38 | } 39 | 40 | const presetCandidate = 41 | source.modemPreset ?? source.modem_preset ?? source.modempreset ?? source.ModemPreset ?? null; 42 | const modemPreset = toTrimmedString(presetCandidate); 43 | 44 | const freqCandidate = source.loraFreq ?? source.lora_freq ?? source.frequency ?? null; 45 | const parsedFreq = Number(freqCandidate); 46 | const loraFreq = Number.isFinite(parsedFreq) && parsedFreq > 0 ? parsedFreq : null; 47 | 48 | return { modemPreset, loraFreq }; 49 | } 50 | 51 | /** 52 | * Format a numeric LoRa frequency in MHz with up to three fractional digits. 53 | * 54 | * @param {*} value Numeric frequency in MHz. 55 | * @returns {string|null} Formatted frequency with units or ``null`` when invalid. 56 | */ 57 | export function formatLoraFrequencyMHz(value) { 58 | const numeric = typeof value === 'number' ? value : Number(value); 59 | if (!Number.isFinite(numeric) || numeric <= 0) { 60 | return null; 61 | } 62 | 63 | const formatter = new Intl.NumberFormat('en-US', { 64 | minimumFractionDigits: 0, 65 | maximumFractionDigits: 3, 66 | }); 67 | 68 | return `${formatter.format(numeric)}MHz`; 69 | } 70 | 71 | /** 72 | * Produce a combined modem preset and frequency description suitable for overlays. 73 | * 74 | * @param {*} preset Raw modem preset value. 75 | * @param {*} frequency Raw frequency value expressed in MHz. 76 | * @returns {string|null} Human-readable description or ``null`` when no data available. 77 | */ 78 | export function formatModemDisplay(preset, frequency) { 79 | const presetText = toTrimmedString(preset); 80 | const freqText = formatLoraFrequencyMHz(frequency); 81 | 82 | if (!presetText && !freqText) { 83 | return null; 84 | } 85 | 86 | if (presetText && freqText) { 87 | return `${presetText} (${freqText})`; 88 | } 89 | 90 | return presetText ?? freqText; 91 | } 92 | 93 | export const __testUtils = { 94 | toTrimmedString, 95 | }; 96 | -------------------------------------------------------------------------------- /web/views/shared/_instances_table.erb: -------------------------------------------------------------------------------- 1 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 |
Name Domain Contact Version Channel Frequency Active Nodes (24h) Latitude Longitude Last Update
34 |
35 | -------------------------------------------------------------------------------- /web/spec/worker_pool_spec.rb: -------------------------------------------------------------------------------- 1 | # Copyright © 2025-26 l5yth & contributors 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | # frozen_string_literal: true 16 | 17 | require "spec_helper" 18 | require "timeout" 19 | 20 | RSpec.describe PotatoMesh::App::WorkerPool do 21 | def with_pool(size: 2, queue: 2) 22 | pool = PotatoMesh::App::WorkerPool.new(size: size, max_queue: queue, name: "spec-pool") 23 | yield pool 24 | ensure 25 | pool&.shutdown(timeout: 0.5) 26 | end 27 | 28 | describe "#schedule" do 29 | it "executes jobs asynchronously and exposes their return values" do 30 | with_pool do |pool| 31 | task = pool.schedule { 21 + 21 } 32 | expect(task.wait(timeout: 1)).to eq(42) 33 | end 34 | end 35 | 36 | it "propagates exceptions raised by the job block" do 37 | with_pool do |pool| 38 | task = pool.schedule { raise ArgumentError, "boom" } 39 | expect { task.wait(timeout: 1) }.to raise_error(ArgumentError, "boom") 40 | end 41 | end 42 | 43 | it "raises an error when the queue is saturated" do 44 | with_pool(size: 1, queue: 1) do |pool| 45 | gate = Queue.new 46 | first_task = pool.schedule { gate.pop; :first } 47 | 48 | Timeout.timeout(1) do 49 | sleep 0.01 until gate.num_waiting.positive? 50 | end 51 | 52 | second_task = pool.schedule { gate.pop; :second } 53 | 54 | expect do 55 | pool.schedule { :third } 56 | end.to raise_error(described_class::QueueFullError) 57 | 58 | gate << nil 59 | gate << nil 60 | expect(first_task.wait(timeout: 1)).to eq(:first) 61 | expect(second_task.wait(timeout: 1)).to eq(:second) 62 | end 63 | end 64 | end 65 | 66 | describe "#shutdown" do 67 | it "prevents new work from being scheduled" do 68 | pool = described_class.new(size: 1, max_queue: 1, name: "spec-pool") 69 | pool.shutdown(timeout: 0.5) 70 | 71 | expect do 72 | pool.schedule { :after_shutdown } 73 | end.to raise_error(described_class::ShutdownError) 74 | ensure 75 | pool.shutdown(timeout: 0.5) 76 | end 77 | end 78 | 79 | describe PotatoMesh::App::WorkerPool::Task do 80 | it "raises a timeout when the job exceeds the provided deadline" do 81 | with_pool do |pool| 82 | task = pool.schedule { sleep 0.1; :done } 83 | expect do 84 | task.wait(timeout: 0.01) 85 | end.to raise_error(PotatoMesh::App::WorkerPool::TaskTimeoutError) 86 | expect(task.wait(timeout: 1)).to eq(:done) 87 | end 88 | end 89 | 90 | it "reports completion status" do 91 | with_pool do |pool| 92 | task = pool.schedule { :result } 93 | expect(task.complete?).to be(false) 94 | expect(task.wait(timeout: 1)).to eq(:result) 95 | expect(task.complete?).to be(true) 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /web/public/assets/js/app/__tests__/chat-log-highlights.test.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import test from 'node:test'; 18 | import assert from 'node:assert/strict'; 19 | 20 | import { formatPositionHighlights, formatTelemetryHighlights } from '../chat-log-highlights.js'; 21 | 22 | test('formatTelemetryHighlights includes formatted numeric metrics', () => { 23 | const highlights = formatTelemetryHighlights({ 24 | temperature: 21.44, 25 | relative_humidity: 54.27, 26 | }); 27 | 28 | assert.deepEqual(highlights, [ 29 | { label: 'Temperature', value: '21.4°C' }, 30 | { label: 'Humidity', value: '54.3%' }, 31 | ]); 32 | }); 33 | 34 | test('formatTelemetryHighlights prefers nested telemetry when top-level values are stale', () => { 35 | const highlights = formatTelemetryHighlights({ 36 | channel_utilization: 0, 37 | device_metrics: { channelUtilization: 0.561 }, 38 | }); 39 | 40 | assert.deepEqual(highlights, [ 41 | { label: 'Channel Util', value: '0.561%' }, 42 | ]); 43 | }); 44 | 45 | test('formatPositionHighlights renders coordinate and movement data', () => { 46 | const highlights = formatPositionHighlights({ 47 | latitude: 52.1234567, 48 | longitude: 13.7654321, 49 | altitude: 150.5, 50 | accuracy: 3.2, 51 | speed: 1.234, 52 | heading: 181.6, 53 | satellites: 7, 54 | }); 55 | 56 | assert.deepEqual(highlights, [ 57 | { label: 'Lat', value: '52.12346' }, 58 | { label: 'Lon', value: '13.76543' }, 59 | { label: 'Alt', value: '150.5m' }, 60 | { label: 'Accuracy', value: '3.2m' }, 61 | { label: 'Speed', value: '1.2 m/s' }, 62 | { label: 'Heading', value: '182°' }, 63 | { label: 'Sats', value: '7' }, 64 | ]); 65 | }); 66 | 67 | test('formatPositionHighlights normalises integer microdegree fields', () => { 68 | const highlights = formatPositionHighlights({ 69 | position: { 70 | latitude_i: 52_123_456, 71 | longitude_i: 13_765_432, 72 | }, 73 | }); 74 | 75 | assert.deepEqual(highlights.slice(0, 2), [ 76 | { label: 'Lat', value: '52.12346' }, 77 | { label: 'Lon', value: '13.76543' }, 78 | ]); 79 | }); 80 | 81 | test('formatters return empty arrays when payloads are missing', () => { 82 | assert.deepEqual(formatTelemetryHighlights(null), []); 83 | assert.deepEqual(formatPositionHighlights(undefined), []); 84 | assert.deepEqual(formatPositionHighlights({}), []); 85 | }); 86 | 87 | test('formatPositionHighlights omits zero-valued movement metrics while keeping coordinates', () => { 88 | const highlights = formatPositionHighlights({ 89 | latitude: 0, 90 | longitude: 0, 91 | altitude: 0, 92 | speed: '0', 93 | accuracy: 0, 94 | }); 95 | 96 | assert.deepEqual(highlights, [ 97 | { label: 'Lat', value: '0.00000' }, 98 | { label: 'Lon', value: '0.00000' }, 99 | ]); 100 | }); 101 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.6 2 | # Copyright © 2025-26 l5yth & contributors 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | # Main application builder stage 17 | FROM ruby:3.3-alpine AS builder 18 | 19 | # Ensure native extensions are built against musl libc rather than 20 | # using glibc precompiled binaries (which fail on Alpine). 21 | ENV BUNDLE_FORCE_RUBY_PLATFORM=true 22 | 23 | # Install build dependencies and SQLite3 24 | RUN apk add --no-cache \ 25 | build-base \ 26 | sqlite-dev \ 27 | linux-headers \ 28 | pkgconfig 29 | 30 | # Set working directory 31 | WORKDIR /app 32 | 33 | # Copy Gemfile and install dependencies 34 | COPY web/Gemfile web/Gemfile.lock* ./ 35 | 36 | # Install gems with SQLite3 support 37 | RUN bundle config set --local force_ruby_platform true && \ 38 | bundle config set --local without 'development test' && \ 39 | bundle install --jobs=4 --retry=3 40 | 41 | # Production stage 42 | FROM ruby:3.3-alpine AS production 43 | 44 | # Install runtime dependencies 45 | RUN apk add --no-cache \ 46 | sqlite \ 47 | tzdata \ 48 | curl 49 | 50 | # Create non-root user 51 | RUN addgroup -g 1000 -S potatomesh && \ 52 | adduser -u 1000 -S potatomesh -G potatomesh 53 | 54 | # Set working directory 55 | WORKDIR /app 56 | 57 | # Copy installed gems from builder stage 58 | COPY --from=builder /usr/local/bundle /usr/local/bundle 59 | 60 | # Copy application code (excluding the Dockerfile which is not required at runtime) 61 | COPY --chown=potatomesh:potatomesh web/app.rb ./ 62 | COPY --chown=potatomesh:potatomesh web/app.sh ./ 63 | COPY --chown=potatomesh:potatomesh web/Gemfile ./ 64 | COPY --chown=potatomesh:potatomesh web/Gemfile.lock* ./ 65 | COPY --chown=potatomesh:potatomesh web/lib ./lib 66 | COPY --chown=potatomesh:potatomesh web/spec ./spec 67 | COPY --chown=potatomesh:potatomesh web/public ./public 68 | COPY --chown=potatomesh:potatomesh web/views ./views 69 | COPY --chown=potatomesh:potatomesh web/scripts ./scripts 70 | 71 | # Copy SQL schema files from data directory 72 | COPY --chown=potatomesh:potatomesh data/*.sql /data/ 73 | 74 | # Create data and configuration directories with correct ownership 75 | RUN mkdir -p /app/.local/share/potato-mesh \ 76 | && mkdir -p /app/.config/potato-mesh/well-known \ 77 | && chown -R potatomesh:potatomesh /app/.local/share /app/.config 78 | 79 | # Switch to non-root user 80 | USER potatomesh 81 | 82 | # Expose port 83 | EXPOSE 41447 84 | 85 | # Default environment variables (can be overridden by host) 86 | ENV RACK_ENV=production \ 87 | APP_ENV=production \ 88 | XDG_DATA_HOME=/app/.local/share \ 89 | XDG_CONFIG_HOME=/app/.config \ 90 | SITE_NAME="PotatoMesh Demo" \ 91 | CHANNEL="#LongFast" \ 92 | FREQUENCY="915MHz" \ 93 | MAP_CENTER="38.761944,-27.090833" \ 94 | MAP_ZOOM="" \ 95 | MAX_DISTANCE=42 \ 96 | CONTACT_LINK="#potatomesh:dod.ngo" \ 97 | DEBUG=0 98 | 99 | # Start the application 100 | CMD ["ruby", "app.rb", "-p", "41447", "-o", "0.0.0.0"] 101 | -------------------------------------------------------------------------------- /app/ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /web/public/assets/js/app/settings.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright © 2025-26 l5yth & contributors 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Default configuration values applied when the server omits a field. 19 | * 20 | * @type {{ 21 | * refreshMs: number, 22 | * refreshIntervalSeconds: number, 23 | * chatEnabled: boolean, 24 | * channel: string, 25 | * frequency: string, 26 | * contactLink: string, 27 | * contactLinkUrl: string | null, 28 | * mapCenter: { lat: number, lon: number }, 29 | * mapZoom: number | null, 30 | * maxDistanceKm: number, 31 | * tileFilters: { light: string, dark: string } 32 | * }} 33 | */ 34 | export const DEFAULT_CONFIG = { 35 | refreshMs: 60_000, 36 | refreshIntervalSeconds: 60, 37 | chatEnabled: true, 38 | channel: '#LongFast', 39 | frequency: '915MHz', 40 | contactLink: '#potatomesh:dod.ngo', 41 | contactLinkUrl: 'https://matrix.to/#/#potatomesh:dod.ngo', 42 | mapCenter: { lat: 38.761944, lon: -27.090833 }, 43 | mapZoom: null, 44 | maxDistanceKm: 42, 45 | tileFilters: { 46 | light: 'grayscale(1) saturate(0) brightness(0.92) contrast(1.05)', 47 | dark: 'grayscale(1) invert(1) brightness(0.9) contrast(1.08)' 48 | } 49 | }; 50 | 51 | /** 52 | * Merge raw configuration data from the DOM with the defaults. 53 | * 54 | * @param {Object} raw Partial configuration read from ``readAppConfig``. 55 | * @returns {typeof DEFAULT_CONFIG} Fully populated configuration object. 56 | */ 57 | export function mergeConfig(raw) { 58 | const config = { ...DEFAULT_CONFIG, ...(raw || {}) }; 59 | config.mapCenter = { 60 | lat: Number(raw?.mapCenter?.lat ?? DEFAULT_CONFIG.mapCenter.lat), 61 | lon: Number(raw?.mapCenter?.lon ?? DEFAULT_CONFIG.mapCenter.lon) 62 | }; 63 | config.tileFilters = { 64 | light: raw?.tileFilters?.light || DEFAULT_CONFIG.tileFilters.light, 65 | dark: raw?.tileFilters?.dark || DEFAULT_CONFIG.tileFilters.dark 66 | }; 67 | const refreshIntervalSeconds = Number( 68 | raw?.refreshIntervalSeconds ?? DEFAULT_CONFIG.refreshIntervalSeconds 69 | ); 70 | config.refreshIntervalSeconds = Number.isFinite(refreshIntervalSeconds) 71 | ? refreshIntervalSeconds 72 | : DEFAULT_CONFIG.refreshIntervalSeconds; 73 | const refreshMs = Number(raw?.refreshMs ?? config.refreshIntervalSeconds * 1000); 74 | config.refreshMs = Number.isFinite(refreshMs) ? refreshMs : DEFAULT_CONFIG.refreshMs; 75 | config.chatEnabled = Boolean(raw?.chatEnabled ?? DEFAULT_CONFIG.chatEnabled); 76 | config.channel = raw?.channel || DEFAULT_CONFIG.channel; 77 | config.frequency = raw?.frequency || DEFAULT_CONFIG.frequency; 78 | config.contactLink = raw?.contactLink || DEFAULT_CONFIG.contactLink; 79 | config.contactLinkUrl = raw?.contactLinkUrl ?? DEFAULT_CONFIG.contactLinkUrl; 80 | const maxDistance = Number(raw?.maxDistanceKm ?? DEFAULT_CONFIG.maxDistanceKm); 81 | config.maxDistanceKm = Number.isFinite(maxDistance) 82 | ? maxDistance 83 | : DEFAULT_CONFIG.maxDistanceKm; 84 | const mapZoomValue = raw?.mapZoom; 85 | if (mapZoomValue == null || mapZoomValue === '') { 86 | config.mapZoom = null; 87 | } else { 88 | const zoom = Number(mapZoomValue); 89 | config.mapZoom = Number.isFinite(zoom) && zoom > 0 ? zoom : null; 90 | } 91 | return config; 92 | } 93 | --------------------------------------------------------------------------------