Compare commits
371 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c78c77309e | |||
| 724696cd3c | |||
| cf6c93c50a | |||
| eee72b4c65 | |||
| 736eb064bf | |||
| 9e6968aa04 | |||
| 8904fc7208 | |||
| 02d3325d0d | |||
| 3a08f52fd4 | |||
| 3a9c14e946 | |||
| b15f6450bd | |||
| 2879bf6147 | |||
| 36f12ebded | |||
| 98f6cf3745 | |||
| 35e4d7fa6c | |||
| 0fe2502309 | |||
| a8cac76491 | |||
| 2df17be673 | |||
| 71fa8c17a4 | |||
| 698a69dc03 | |||
| 8765d0f4cc | |||
| c683dbd8f1 | |||
| b8e113d269 | |||
| e963248e37 | |||
| 68d2250afc | |||
| f4cca2fb1d | |||
| 0f3961f3a2 | |||
| 08130350d7 | |||
| c1dea86dd1 | |||
| e1b8d6ec97 | |||
| 3541b1bbac | |||
| ef6c1e0898 | |||
| eb89ad1d50 | |||
| 5ef976b3b5 | |||
| 7b4f90d646 | |||
| e1a4900dc0 | |||
| 7fc253cc88 | |||
| 445a18a83f | |||
| 8273c416b5 | |||
| b44c69f411 | |||
| 6492058a04 | |||
| 5670ab0067 | |||
| 98a9197cb1 | |||
| 55da952bfd | |||
| 9b4742951d | |||
| 43f3d439ba | |||
| ed294decfb | |||
| 5f54a2cd1b | |||
| e1536c49b1 | |||
| bfa62523df | |||
| 6813a72767 | |||
| dbd3a40bdc | |||
| 3009036565 | |||
| 80f4fd5218 | |||
| 321d4b9775 | |||
| 1c183d7e67 | |||
| a1e6f6967c | |||
| f34c2a92c3 | |||
| f1478722b0 | |||
| c867225073 | |||
| 06285a02ff | |||
| e92d215582 | |||
| d61eaaaff1 | |||
| ecde5e1109 | |||
| f548f95e29 | |||
| 9957e1fc1f | |||
| e38d03a32e | |||
| ea657a964a | |||
| 760d8e1db3 | |||
| 880df84828 | |||
| 815534d409 | |||
| becfbedc99 | |||
| 7da4e68384 | |||
| 5ea6b17b16 | |||
| 3707acb124 | |||
| 51d6210920 | |||
| 27695d372a | |||
| d06a4c1861 | |||
| 6a31d304d3 | |||
| 26fdf74d69 | |||
| b1de1b4bf0 | |||
| 3c26ce2d93 | |||
| cba1e5950c | |||
| 743ef7f124 | |||
| 13d3a107da | |||
| dfdcafb071 | |||
| c46a5ffa2f | |||
| a15f5e94da | |||
| 9d1251f21e | |||
| 0ca96ce5b1 | |||
| 33b3b04294 | |||
| b72ade1387 | |||
| 3fc038682c | |||
| 9e5b360f28 | |||
| 89360c8bc2 | |||
| 28df5a97e8 | |||
| 2e767730fd | |||
| b7fe5331d0 | |||
| 61fb39b9f8 | |||
| 9e18b99e9d | |||
| 2e4f26d7cd | |||
| 6e81fdfd46 | |||
| 5d03f99040 | |||
| 6a7dd981a2 | |||
| d68f755677 | |||
| 264d2bcc9a | |||
| e88281b7b6 | |||
| 3a11e35a7a | |||
| 8dd385beed | |||
| 2328848400 | |||
| 29e551d6cb | |||
| 531b85b8aa | |||
| 3d956596a5 | |||
| 0287de1862 | |||
| 4dd472e3c3 | |||
| bdd0d3dee2 | |||
| 11a4f9a373 | |||
| acbe4dcc49 | |||
| 9b99eadcf7 | |||
| ed0e6b6554 | |||
| 6d258154a0 | |||
| 09a83c4f22 | |||
| 5486ae8ccb | |||
| fc67d8d5aa | |||
| 225d07b440 | |||
| bac82dc9e8 | |||
| 8682e6ea67 | |||
| 30a1a36ee4 | |||
| 3fe5cdf55d | |||
| 9ada4ea601 | |||
| 945a118853 | |||
| 7a823654df | |||
| 425229fce8 | |||
| c4b3971bdd | |||
| 01d4f002f8 | |||
| 9331b02f94 | |||
| 27f7932db9 | |||
| 4a39a7cd46 | |||
| a50c0d0b2d | |||
| 3f1bd54889 | |||
| 91759f531a | |||
| 87708e2684 | |||
| 73e5b80232 | |||
| 28a5913ba3 | |||
| 091c754584 | |||
| cf27c59c84 | |||
| 119590434a | |||
| dc82414447 | |||
| 23f29f2cda | |||
| d66b16a4e8 | |||
| d6647f4701 | |||
| 72448f67d0 | |||
| bc5f299350 | |||
| 6d97ad6855 | |||
| 87b0fd6fc7 | |||
| 1fbe1823cb | |||
| 4a066e08dc | |||
| f941f0dbfa | |||
| 94ed729985 | |||
| 8296f44a4e | |||
| 4e70fd2a5e | |||
| ca02197414 | |||
| 352a6c427e | |||
| 5f9259e41f | |||
| 75ae903b99 | |||
| 8892823337 | |||
| e738664f89 | |||
| e37616fa15 | |||
| 6e2abfeb2d | |||
| b62c1c7338 | |||
| 17379394bd | |||
| 2763d83fe4 | |||
| 77018dc358 | |||
| 1f6b2dacf9 | |||
| 21c58d4e13 | |||
| 3af97ff6dd | |||
| 703d5a1ec4 | |||
| d2a6fbe182 | |||
| e801a497f8 | |||
| e92a66ff28 | |||
| 6900e5c3db | |||
| 966a8d0d2c | |||
| 3ec3b05fb8 | |||
| 14a93e9bf5 | |||
| c229b0369e | |||
| 9f332e93be | |||
| b472ea8c70 | |||
| a67c6d81c3 | |||
| 91ae4dab90 | |||
| 08ac60a408 | |||
| d4da34fcf7 | |||
| 74840d3baf | |||
| 4a72fbd1ad | |||
| dbe0a5411b | |||
| dc3325ec46 | |||
| a92e57bb64 | |||
| e21f3106d0 | |||
| 0dcb5f05f0 | |||
| f501d11ec6 | |||
| dfcf13a97b | |||
| ccd23c4b81 | |||
| 00636c9084 | |||
| accec1681b | |||
| 67238468ce | |||
| bc5b12f1ef | |||
| c09af98bef | |||
| ae32e76563 | |||
| 5572c9ee75 | |||
| f6cc000788 | |||
| 75b0d198bc | |||
| 1947cd9f3e | |||
| f1d93bd5e8 | |||
| f63d50f0da | |||
| eb597b6c68 | |||
| efe21c4e87 | |||
| 38fece3313 | |||
| 3af3cce606 | |||
| 026ec6f7de | |||
| eb50249b93 | |||
| ca6058eccd | |||
| 99c0ab7e22 | |||
| 2950a9a687 | |||
| 1b3de54873 | |||
| 20a9ef3c2b | |||
| a741e12ad1 | |||
| e54f30d6fb | |||
| e1d23ad2c7 | |||
| f07993b367 | |||
| 0e5f1a45c4 | |||
| f10aeaeba8 | |||
| 00e4f52d75 | |||
| 3ea2e4763e | |||
| 94d9afe8b1 | |||
| 7db3a12723 | |||
| fcf10b4a73 | |||
| 7f353490cf | |||
| 46683e0ec2 | |||
| 4e368d562d | |||
| 38f6e42796 | |||
| f56c28a27d | |||
| 92d3009eb4 | |||
| 7a4ac9ae9b | |||
| f8d00caae0 | |||
| e03d80b71f | |||
| b7d0db8d1c | |||
| 6ae3f612ae | |||
| 40d3941aab | |||
| e53c493e78 | |||
| 54e0dae172 | |||
| 066aba7c5d | |||
| 5e446207c6 | |||
| 609d0c8dbc | |||
| 820bac0db0 | |||
| d3c7d8e43a | |||
| 0c1e163b88 | |||
| d0d6a34fb5 | |||
| 16ce1359d7 | |||
| 9fe4a3710d | |||
| 8611adab1f | |||
| 7d457cb863 | |||
| 297516fc80 | |||
| 6b6a881c7a | |||
| 8ef8a38495 | |||
| ddcda4ba5a | |||
| 5cfe45b953 | |||
| b572314ae9 | |||
| e97fb9bd24 | |||
| 1c9c089a53 | |||
| cb3b5a84eb | |||
| a4bbeffddc | |||
| 37ec8f2f05 | |||
| 39cd6d5514 | |||
| 44eb4fad58 | |||
| 1a209cbcfc | |||
| 33a8f34463 | |||
| ce8e8f0d5b | |||
| aa2d0f1927 | |||
| 0e074fd806 | |||
| 0757c8e53a | |||
| add4731d05 | |||
| 7dc162d968 | |||
| 8ba4bbfbc5 | |||
| cac6abfef1 | |||
| 5354acb1d3 | |||
| fae416fb34 | |||
| 69433b6d89 | |||
| ea3b9609fc | |||
| 20a9939314 | |||
| c7b7deb0f6 | |||
| 82e04e8090 | |||
| f299608296 | |||
| 7dcec5b4ee | |||
| e4684b585a | |||
| 8386f262e1 | |||
| 45cd8a56a3 | |||
| 754f8a6c62 | |||
| c4f54efd77 | |||
| 637e08d22c | |||
| 32dc0fca22 | |||
| b5aa294fc1 | |||
| 26516baf67 | |||
| 4879b136f8 | |||
| bdd7fc0cdd | |||
| 5ea044af10 | |||
| 9d20be1c06 | |||
| 9436c2d45a | |||
| 17e55e96bb | |||
| e4cfbb57b4 | |||
| d9f9ff58b4 | |||
| a059f1be45 | |||
| 9e46f8b44c | |||
| a934781009 | |||
| 5fe6738f25 | |||
| c1bcf261d7 | |||
| b570539a2d | |||
| 89a14c2719 | |||
| 4ad01ed43c | |||
| ffaa4033ae | |||
| 1a4fd1b477 | |||
| e1555ce380 | |||
| c7933d363b | |||
| 08ffb978cf | |||
| c5ec60638c | |||
| 75ec3b6116 | |||
| 45c9823c6f | |||
| 45658a7612 | |||
| a14833494e | |||
| 457b44de3a | |||
| 36d4a10396 | |||
| 77566b0fe1 | |||
| 7633327f45 | |||
| 6b4b2d7ce6 | |||
| 10b63e0df2 | |||
| ba6d751346 | |||
| 96d222a580 | |||
| 01ad8471cc | |||
| 2b826757cb | |||
| 9bf649e2c6 | |||
| c7a2bf9a95 | |||
| 82adbd761b | |||
| 9a8bdf00dc | |||
| 8b30342113 | |||
| 817c60a155 | |||
| f08e86cf97 | |||
| a6bb9490a1 | |||
| e4e8bfa4ef | |||
| d1e45fc2ba | |||
| 32fa96431e | |||
| 1e9508d401 | |||
| 36697c6e61 | |||
| c9145c99d3 | |||
| 6b6d9caeeb | |||
| d0e3767db6 | |||
| f9cb0c80a5 | |||
| a26d14bd46 | |||
| 0ef2194fb0 | |||
| 3664ae34cd | |||
| 1b94442ab6 | |||
| 3ae14781f0 | |||
| ecc496f2af | |||
| 87b25655d0 | |||
| c47a4cb622 | |||
| a30fc439f3 | |||
| afcc4db405 | |||
| 87bcb6a6a3 | |||
| 68bb031bb6 | |||
| c4f5c7b171 | |||
| 2bce14224d | |||
| fd305fd55b | |||
| d0dd805244 | |||
| 8668564464 |
@@ -0,0 +1,3 @@
|
||||
github: [zjs81]
|
||||
custom:
|
||||
- "https://paypal.me/zjs82"
|
||||
@@ -33,6 +33,9 @@ migrate_working_dir/
|
||||
pubspec.lock
|
||||
/build/
|
||||
/coverage/
|
||||
# fvm project files
|
||||
.fvm/
|
||||
.fvmrc
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
@@ -84,6 +87,10 @@ keystore.properties
|
||||
# IDE
|
||||
.vscode/launch.json
|
||||
.vscode/settings.json
|
||||
.contextstream/
|
||||
|
||||
# Cloudflare Wrangler
|
||||
.wrangler
|
||||
|
||||
# Claude Code local working dir (worktrees, jobs, settings)
|
||||
.claude/
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## BLE Frames & Protocol Notes
|
||||
- Nordic UART Service (NUS) UUIDs: Service `6e400001-b5a3-f393-e0a9-e50e24dcca9e`, RX `6e400002-b5a3-f393-e0a9-e50e24dcca9e`, TX `6e400003-b5a3-f393-e0a9-e50e24dcca9e`.
|
||||
- Discovery: scans for device name prefix `MeshCore-` and filters by `platformName`/`advertisementData.advName`.
|
||||
- Discovery: scans for device names matching known prefixes and filters by `platformName`/`advertisementData.advName`.
|
||||
- Frames are capped at `maxFrameSize = 172` bytes; byte 0 is the command/response/push code. I/O is `MeshCoreConnector.sendFrame` and `MeshCoreConnector.receivedFrames`.
|
||||
- Command codes (to device): `cmdAppStart`=1, `cmdSendTxtMsg`=2, `cmdSendChannelTxtMsg`=3, `cmdGetContacts`=4, `cmdGetDeviceTime`=5, `cmdSetDeviceTime`=6, `cmdSendSelfAdvert`=7, `cmdSetAdvertName`=8, `cmdAddUpdateContact`=9, `cmdSyncNextMessage`=10, `cmdSetRadioParams`=11, `cmdSetRadioTxPower`=12, `cmdResetPath`=13, `cmdSetAdvertLatLon`=14, `cmdRemoveContact`=15, `cmdShareContact`=16, `cmdExportContact`=17, `cmdImportContact`=18, `cmdReboot`=19, `cmdSendLogin`=26, `cmdGetChannel`=31, `cmdSetChannel`=32, `cmdGetRadioSettings`=57.
|
||||
- Response codes (from device): `respCodeOk`=0, `respCodeErr`=1, `respCodeContactsStart`=2, `respCodeContact`=3, `respCodeEndOfContacts`=4, `respCodeSelfInfo`=5, `respCodeSent`=6, `respCodeContactMsgRecv`=7, `respCodeChannelMsgRecv`=8, `respCodeCurrTime`=9, `respCodeNoMoreMessages`=10, `respCodeContactMsgRecvV3`=16, `respCodeChannelMsgRecvV3`=17, `respCodeChannelInfo`=18, `respCodeRadioSettings`=25.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MeshCore Open - Flutter Client
|
||||
|
||||
Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||
Open-source Flutter client for MeshCore LoRa mesh networking devices. Connects to MeshCore-compatible radios over **BLE, TCP, or USB serial** and provides direct/channel chat, contact and channel management, on-map node tracking, repeater administration, and on-device message translation.
|
||||
|
||||
## Build Commands
|
||||
|
||||
@@ -17,6 +17,9 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||
# Build iOS
|
||||
~/flutter/bin/flutter build ios
|
||||
|
||||
# Build versioned web release (uses build_pipe)
|
||||
~/flutter/bin/dart run build_pipe
|
||||
|
||||
# Run static analysis
|
||||
~/flutter/bin/flutter analyze
|
||||
|
||||
@@ -28,43 +31,132 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||
|
||||
```
|
||||
lib/
|
||||
├── main.dart # App entry point, MaterialApp setup with Provider
|
||||
├── connector/
|
||||
│ └── meshcore_connector.dart # BLE communication layer (MeshCoreConnector)
|
||||
├── screens/
|
||||
│ ├── scanner_screen.dart # BLE device scanning (home screen)
|
||||
│ ├── device_screen.dart # Connected device hub with navigation
|
||||
│ ├── chat_screen.dart # Chat interface (placeholder)
|
||||
│ ├── contacts_screen.dart # Contacts list (placeholder)
|
||||
│ └── settings_screen.dart # Device info and app settings
|
||||
└── widgets/
|
||||
└── device_tile.dart # Device list item with signal strength
|
||||
├── main.dart # Entry point: MultiProvider wiring, locale + theme, initial route
|
||||
├── connector/ # Unified BLE/TCP/USB transport layer
|
||||
│ ├── meshcore_connector.dart # Central state holder + ChangeNotifier (all transports)
|
||||
│ ├── meshcore_connector_tcp.dart # TCP transport helper
|
||||
│ ├── meshcore_connector_usb.dart # USB serial transport helper
|
||||
│ ├── meshcore_protocol.dart # Frame size + version constants
|
||||
│ └── meshcore_uuids.dart # Nordic UART UUIDs + scan name prefixes
|
||||
├── models/ # Plain data classes (Contact, Channel, Message, Community, …)
|
||||
├── services/ # ChangeNotifier services + IO services (retry, translation, ML, …)
|
||||
├── storage/ # SharedPreferences-backed stores, scoped per device key
|
||||
├── helpers/ # Pure utilities (Smaz compression, GIF parsing, scroll helpers, path hop resolution)
|
||||
├── utils/ # Platform / IO / UX utilities (logger, GPX export, dialogs)
|
||||
├── theme/ # MeshPalette (defined, not yet wired in main.dart)
|
||||
├── l10n/ # ARB localization for 18 locales
|
||||
├── icons/ # Custom icon widgets
|
||||
├── widgets/ # Reusable widgets (AppBar, BatteryUi, QR, jump-to-bottom, …)
|
||||
└── screens/ # ~26 screens — see Screens section below
|
||||
```
|
||||
|
||||
## Screens
|
||||
|
||||
All screens are fully implemented (no remaining placeholders).
|
||||
|
||||
### Connection / Scanning
|
||||
| Screen | Purpose |
|
||||
|---|---|
|
||||
| `scanner_screen.dart` | BLE device scan and connect — main entry point |
|
||||
| `tcp_screen.dart` | Connect to a MeshCore device over TCP/IP |
|
||||
| `usb_screen.dart` | Connect to a MeshCore device over USB serial |
|
||||
| `discovery_screen.dart` | Browse all discovered (non-contact) mesh nodes |
|
||||
| `chrome_required_screen.dart` | Web gate for non-Chrome browsers (BLE unavailable) |
|
||||
|
||||
### Chat / Messaging
|
||||
| Screen | Purpose |
|
||||
|---|---|
|
||||
| `chat_screen.dart` | Direct (private) messaging with a contact |
|
||||
| `channel_chat_screen.dart` | Group messaging inside a named channel |
|
||||
| `channels_screen.dart` | List and manage channels (add/edit/delete) |
|
||||
| `channel_message_path_screen.dart` | Hop-by-hop route a channel message took, with map overlay |
|
||||
|
||||
### Contacts / Neighbors
|
||||
| Screen | Purpose |
|
||||
|---|---|
|
||||
| `contacts_screen.dart` | Full contacts list with previews and management |
|
||||
| `neighbors_screen.dart` | Nodes directly heard by the connected radio (one-hop) |
|
||||
|
||||
### Repeater Management
|
||||
| Screen | Purpose |
|
||||
|---|---|
|
||||
| `repeater_hub_screen.dart` | Top-level repeater hub; navigates to sub-screens |
|
||||
| `repeater_status_screen.dart` | Live status of a managed repeater node |
|
||||
| `repeater_cli_screen.dart` | Raw command-line interface to a repeater |
|
||||
| `repeater_settings_screen.dart` | Full radio/node settings editor for a repeater |
|
||||
|
||||
### Map / Location
|
||||
| Screen | Purpose |
|
||||
|---|---|
|
||||
| `map_screen.dart` | Main map view of contacts/nodes with live GPS positions |
|
||||
| `line_of_sight_map_screen.dart` | Terrain LOS analysis between configurable endpoints |
|
||||
| `path_trace_map.dart` | Animates the hop path a direct message traveled |
|
||||
| `map_cache_screen.dart` | Download/clear offline map tile cache |
|
||||
| `community_qr_scanner_screen.dart` | Scan QR to join a mesh community/channel |
|
||||
|
||||
### Settings / Debug / Diagnostics
|
||||
| Screen | Purpose |
|
||||
|---|---|
|
||||
| `settings_screen.dart` | Connected device settings: radio params, identity, GPS |
|
||||
| `app_settings_screen.dart` | App preferences: theme, units, map source, notifications |
|
||||
| `app_debug_log_screen.dart` | In-app log viewer (app-layer messages) |
|
||||
| `ble_debug_log_screen.dart` | In-app log viewer (raw BLE frame traffic) |
|
||||
| `companion_radio_stats_screen.dart` | RF stats (RSSI, SNR, packet counts) for paired radio |
|
||||
| `telemetry_screen.dart` | Battery / sensor / environmental telemetry for a contact |
|
||||
|
||||
## Architecture
|
||||
|
||||
### State Management
|
||||
- **Provider** with `ChangeNotifier` pattern
|
||||
- `MeshCoreConnector` is the central state holder for BLE connection
|
||||
- Screens use `Consumer<MeshCoreConnector>` for reactive UI updates
|
||||
|
||||
`Provider` with `ChangeNotifier`. `main.dart` wires a `MultiProvider` with the following:
|
||||
|
||||
| Provider | Role |
|
||||
|---|---|
|
||||
| `MeshCoreConnector` | Active transport (BLE/TCP/USB), connection state, frame I/O |
|
||||
| `MessageRetryService` | ACK tracking and retry scheduling with backoff |
|
||||
| `PathHistoryService` | Per-contact routing history (LRU cache, 50 contacts) |
|
||||
| `AppSettingsService` | App preferences (theme, units, locale, notifications) |
|
||||
| `BleDebugLogService` | Raw BLE frame log buffer |
|
||||
| `AppDebugLogService` | Structured app log buffer |
|
||||
| `ChatTextScaleService` | Pinch-to-zoom text scale for chat screens |
|
||||
| `TranslationService` | On-device LLM translation (llamadart) |
|
||||
| `UiViewStateService` | Contacts/channels sort/filter/search state |
|
||||
| `TimeoutPredictionService` | ML linear regression for ACK timeout prediction |
|
||||
| `StorageService` | Path history + delivery observation persistence |
|
||||
| `MapTileCacheService` | OSM tile pre-cache |
|
||||
|
||||
Screens consume these via `Consumer<T>` (or `context.watch<T>()` / `context.read<T>()`) for reactive UI.
|
||||
|
||||
### Storage / Persistence
|
||||
|
||||
All stores in `lib/storage/` use `PrefsManager` (a `SharedPreferences` singleton initialized in `main()`). Most stores **scope keys by the first 10 hex chars of the connected device's public key**, so per-radio data is isolated.
|
||||
|
||||
| Store | Persists |
|
||||
|---|---|
|
||||
| `message_store`, `channel_message_store` | Direct + channel messages |
|
||||
| `contact_store`, `contact_discovery_store` | Known + discovered contacts |
|
||||
| `channel_store`, `channel_order_store`, `channel_settings_store` | Channels, display order, per-channel Smaz toggle |
|
||||
| `community_store` | Communities (32-byte shared secrets) |
|
||||
| `contact_group_store`, `contact_settings_store` | Groups, per-contact Smaz toggle |
|
||||
| `unread_store` | Per-contact unread counts (debounced writes) |
|
||||
|
||||
GGUF translation models are stored as files (not SharedPreferences) via `translation_file_store`.
|
||||
|
||||
### Theming
|
||||
- Material 3 design (`useMaterial3: true`)
|
||||
- System-based dark/light mode (`ThemeMode.system`)
|
||||
- Blue color scheme seed
|
||||
- `lib/theme/mesh_theme.dart` defines a warm-dark `MeshPalette` (phosphor-green accents) but is **not currently wired** in `main.dart` — available for a future redesign
|
||||
|
||||
## BLE Protocol
|
||||
### Localization
|
||||
|
||||
### Nordic UART Service (NUS)
|
||||
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **RX Characteristic**: `6e400002-b5a3-f393-e0a9-e50e24dcca9e` (Write to device)
|
||||
- **TX Characteristic**: `6e400003-b5a3-f393-e0a9-e50e24dcca9e` (Notify from device)
|
||||
18 locales supported via Flutter's standard ARB pipeline (`lib/l10n/`): en, de, es, fr, it, pt, ru, uk, bg, hu, ja, ko, nl, pl, sk, sl, sv, zh. Language override comes from `AppSettingsService.settings.languageOverride`. Use the `context.l10n` extension (`lib/l10n/l10n.dart`) for translated strings; contact-type names live in `contact_localization.dart`.
|
||||
|
||||
### Device Discovery
|
||||
- Scans for devices with name prefix `MeshCore-`
|
||||
- Filters by `platformName` or `advertisementData.advName`
|
||||
## Transports
|
||||
|
||||
### Connection States
|
||||
`MeshCoreConnector` unifies all three transports under one `ChangeNotifier`. There is **no shared base class** — selection is via the `MeshCoreTransportType { bluetooth, usb, tcp }` enum, and BLE/TCP/USB share the same connection-state enum, send/receive API, and frame protocol.
|
||||
|
||||
### Connection State
|
||||
```dart
|
||||
enum MeshCoreConnectionState {
|
||||
disconnected,
|
||||
@@ -75,28 +167,137 @@ enum MeshCoreConnectionState {
|
||||
}
|
||||
```
|
||||
|
||||
### Frame I/O
|
||||
- **Send**: `MeshCoreConnector.sendFrame(Uint8List data)`
|
||||
- **Receive**: `MeshCoreConnector.receivedFrames` stream of `Uint8List`
|
||||
### Frame I/O (all transports)
|
||||
- **Send**: `MeshCoreConnector.sendFrame(Uint8List data, {String? channelSendQueueId, bool expectsGenericAck})`
|
||||
- **Receive**: `Stream<Uint8List> get receivedFrames`
|
||||
- **Protocol constants** (`meshcore_protocol.dart`): `maxFrameSize = 172`, `maxTextPayloadBytes = 160`, `appProtocolVersion = 4`
|
||||
|
||||
### BLE — Nordic UART Service (NUS)
|
||||
- **Service UUID**: `6e400001-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **RX Characteristic** (write to device): `6e400002-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **TX Characteristic** (notify from device): `6e400003-b5a3-f393-e0a9-e50e24dcca9e`
|
||||
- **Discovery**: scans for devices whose name starts with `MeshCore-`, `Whisper-`, `WisCore-`, `Seeed`, `Lilygo`, `HT-`, or `LowMesh_MC_` (filters on both `platformName` and `advertisementData.advName`)
|
||||
- **Linux**: `linux_ble_pairing_service.dart` falls back to `bluetoothctl` when BlueZ agent prompts fail
|
||||
|
||||
### TCP
|
||||
- Manual host/port entry, persisted via `AppSettingsService` (`tcpServerAddress`, `tcpServerPort`)
|
||||
- UI hint: `192.168.40.10` / port `5000`
|
||||
- Disabled on web (`PlatformInfo.isWeb`)
|
||||
- API: `MeshCoreConnector.connectTcp(host: ..., port: ...)`
|
||||
|
||||
### USB Serial (flserial)
|
||||
- Default baud rate: `115200`
|
||||
- Port enumeration: `MeshCoreConnector.listUsbPorts()`
|
||||
- COBS-framed packets via `usb_serial_frame_codec.dart`
|
||||
- macOS device-name resolution via `ioreg` (`utils/macos_usb_device_names.dart`)
|
||||
- API: `MeshCoreConnector.connectUsb(portName: ..., baudRate: 115200)`
|
||||
|
||||
## Dependencies
|
||||
|
||||
App version: `9.5.0+13` — Dart SDK constraint: `^3.9.2`
|
||||
|
||||
**Connectivity**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| flutter_blue_plus | ^2.1.0 | BLE communication |
|
||||
| provider | ^6.1.5+1 | State management |
|
||||
| cupertino_icons | ^1.0.8 | iOS-style icons |
|
||||
| flutter_blue_plus | ^2.1.0 | BLE scanning, connecting, and UART data transfer |
|
||||
| flutter_blue_plus_platform_interface | ^9.0.2 | Platform-interface layer required by flutter_blue_plus |
|
||||
| flserial | git (MeshEnvy fork) | USB serial transport for wired device connections (TODO: upstream pending) |
|
||||
|
||||
**State / Storage**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| provider | ^6.1.5+1 | ChangeNotifier-based state management across screens |
|
||||
| shared_preferences | ^2.2.2 | Persistent key-value storage for user settings |
|
||||
| path_provider | ^2.1.5 | Locates platform-appropriate directories for file I/O |
|
||||
|
||||
**Crypto**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| crypto | ^3.0.3 | SHA/HMAC hashing used in message authentication |
|
||||
| pointycastle | ^4.0.0 | AES encryption/decryption for channel and direct messages |
|
||||
| uuid | ^4.3.3 | Generates UUIDs for message and contact identity |
|
||||
|
||||
**Maps & Location**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| flutter_map | ^8.2.2 | Interactive tile map for node positions and path traces |
|
||||
| latlong2 | ^0.9.1 | LatLng coordinate type used throughout map and GPS code |
|
||||
| gpx | ^2.3.0 | Export node paths as GPX track files |
|
||||
|
||||
**UI**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| material_symbols_icons | ^4.2928.1 | Extended Material Symbols icon set (line-of-sight, etc.) |
|
||||
| flutter_svg | ^2.0.10+1 | Renders SVG assets (custom icons such as LoS indicator) |
|
||||
| cached_network_image | ^3.4.1 | Caches map tile images downloaded over the network |
|
||||
| flutter_cache_manager | ^3.4.1 | Underlying cache manager used by cached_network_image |
|
||||
| flutter_linkify | ^6.0.0 | Auto-detects and makes URLs tappable in chat messages |
|
||||
| mobile_scanner | ^7.1.4 | QR/barcode scanning for contact and channel import |
|
||||
| qr_flutter | ^4.1.0 | Generates QR codes for sharing contacts and channels |
|
||||
| cupertino_icons | ^1.0.8 | iOS-style icon font (bundled for completeness) |
|
||||
| characters | ^1.4.0 | Unicode-aware string operations for message text handling |
|
||||
|
||||
**Notifications / Background**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| flutter_local_notifications | ^22.0.0 | Shows local push notifications for incoming messages |
|
||||
| flutter_foreground_task | ^9.2.0 | Keeps the app alive in background to maintain BLE/USB connection |
|
||||
|
||||
**ML / AI**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| ml_algo | ^16.0.0 | OLS regression used in `timeout_prediction_service.dart` to predict message ACK timeouts |
|
||||
| ml_dataframe | ^1.0.0 | DataFrame input format required by ml_algo |
|
||||
| llamadart | ^0.8.0 | On-device LLM inference used in `translation_service.dart` for message translation |
|
||||
| flutter_langdetect | ^0.0.1 | Detects a message's source language in `translation_service.dart` before translating |
|
||||
|
||||
**Misc**
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| http | ^1.2.0 | Fetches tile URLs and any remote API calls |
|
||||
| url_launcher | ^6.3.0 | Opens URLs in the system browser from linkified chat text |
|
||||
| share_plus | ^13.1.0 | Shares files (e.g. exported GPX tracks) via the system share sheet |
|
||||
| package_info_plus | ^10.1.0 | Reads app version/build number displayed in settings |
|
||||
| web | ^1.1.1 | Web-platform APIs for USB serial and browser detection on Flutter Web |
|
||||
| intl | any | Internationalization and locale formatting (required by flutter_localizations) |
|
||||
| build_pipe | ^0.3.1 | CI/CD build pipeline configuration (web release builds with versioned assets) |
|
||||
|
||||
## Platform Configuration
|
||||
|
||||
### Android (`android/app/src/main/AndroidManifest.xml`)
|
||||
- `BLUETOOTH`, `BLUETOOTH_ADMIN` (API 30 and below)
|
||||
- `BLUETOOTH_SCAN`, `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (API 31+)
|
||||
- `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` (for BLE scanning)
|
||||
- `INTERNET` (map tiles, translation model downloads)
|
||||
- `BLUETOOTH`, `BLUETOOTH_ADMIN` (API ≤ 30)
|
||||
- `BLUETOOTH_SCAN` (with `neverForLocation`), `BLUETOOTH_CONNECT`, `BLUETOOTH_ADVERTISE` (API 31+)
|
||||
- `ACCESS_FINE_LOCATION`, `ACCESS_COARSE_LOCATION` (BLE scanning on API ≤ 30)
|
||||
- `POST_NOTIFICATIONS` (API 33+)
|
||||
- `FOREGROUND_SERVICE`, `FOREGROUND_SERVICE_CONNECTED_DEVICE` (background BLE/USB connection)
|
||||
- `WAKE_LOCK`
|
||||
- `CAMERA` (QR scanning, declared as optional feature)
|
||||
- USB host hardware feature (optional)
|
||||
|
||||
`flutter_foreground_task` registers a `ForegroundService` with `foregroundServiceType="connectedDevice"` and `stopWithTask="false"`.
|
||||
|
||||
**Build config (`android/app/build.gradle.kts`)**: `applicationId = com.meshcore.meshcore_open`, NDK `29.0.14206865`, Java 8 core-library desugaring (`desugar_jdk_libs:2.1.4`), release signing via `key.properties` (debug fallback).
|
||||
|
||||
### iOS (`ios/Runner/Info.plist`)
|
||||
- `NSBluetoothAlwaysUsageDescription`
|
||||
- `NSBluetoothPeripheralUsageDescription`
|
||||
- `NSBluetoothAlwaysUsageDescription`, `NSBluetoothPeripheralUsageDescription`
|
||||
- `NSCameraUsageDescription` (QR scanning to join communities)
|
||||
- Background modes: `bluetooth-central`
|
||||
- `LSApplicationQueriesSchemes`: `http`, `https`
|
||||
|
||||
### Web (`web/`)
|
||||
PWA scaffold present but boilerplate (`manifest.json` and `index.html` are unmodified Flutter defaults). BLE is unsupported in browsers; TCP and Web Serial USB may work in Chrome only. `ChromeRequiredScreen` gates non-Chrome web users. Versioned releases are produced via `build_pipe` (`?v=<pubspec version>` cache busting, no service worker).
|
||||
|
||||
### Desktop
|
||||
`linux/`, `windows/`, and `macos/` directories are present as Flutter scaffolds. No app-specific native config has been added; BLE on desktop has not been validated.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
@@ -123,14 +324,14 @@ enum MeshCoreConnectionState {
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `lib/connector/meshcore_connector.dart` | All BLE logic - scanning, connecting, data transfer |
|
||||
| `lib/screens/scanner_screen.dart` | Entry point UI, device list |
|
||||
| `lib/main.dart` | App configuration, theme, Provider setup |
|
||||
| `pubspec.yaml` | Dependencies and project metadata |
|
||||
|
||||
## Placeholder Screens
|
||||
|
||||
The following screens are implemented as placeholders and need full implementation:
|
||||
- `chat_screen.dart` - Mesh chat functionality
|
||||
- `contacts_screen.dart` - Contact management
|
||||
- `settings_screen.dart` - Radio settings, node identity, location (partially implemented)
|
||||
| `lib/main.dart` | App configuration, MultiProvider setup, theme, locale, initial route |
|
||||
| `lib/connector/meshcore_connector.dart` | Unified BLE/TCP/USB transport state holder |
|
||||
| `lib/connector/meshcore_protocol.dart` | Frame size limits and protocol version |
|
||||
| `lib/connector/meshcore_uuids.dart` | NUS UUIDs and BLE scan name prefixes |
|
||||
| `lib/services/app_settings_service.dart` | App-wide settings (`AppSettings` JSON in SharedPreferences) |
|
||||
| `lib/services/storage_service.dart` | Path history + delivery observation persistence |
|
||||
| `lib/services/message_retry_service.dart` | ACK tracking + retry scheduling |
|
||||
| `lib/services/translation_service.dart` | On-device LLM translation (llamadart) |
|
||||
| `lib/storage/prefs_manager.dart` | SharedPreferences singleton initialized in `main()` |
|
||||
| `lib/screens/scanner_screen.dart` | Home screen — BLE scan and connect |
|
||||
| `pubspec.yaml` | Dependencies and project metadata (current version `9.5.0+13`) |
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# How to contribute to Meshcore Open
|
||||
|
||||
Before submitting any pull requests (PR), please review the following information.
|
||||
|
||||
Unsolicited PRs without previous discussion or open issues may be
|
||||
rejected. As may changes that are too broad (i.e. 100 files changed) or that
|
||||
cover too many separate changes. If the changes are clearly AI generated they
|
||||
may also be rejected. [See more](#ai-use)
|
||||
|
||||
## First Step Checklist
|
||||
|
||||
### **Did you find a bug?**
|
||||
|
||||
* **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/zjs81/meshcore-open/issues).
|
||||
|
||||
* If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/zjs81/meshcore-open/issues/new).
|
||||
Be sure to include a **title and clear description**, as much relevant
|
||||
information as possible, and a **code sample** or an **executable test case**
|
||||
demonstrating the expected behavior that is not occurring. You can also include
|
||||
screenshots or video.
|
||||
|
||||
* DO NOT start work and submit a PR at this time, please discuss the issue and
|
||||
your implementation plan first.
|
||||
|
||||
### **Did you fix whitespace, format code, or make a purely cosmetic patch?**
|
||||
|
||||
Changes that are cosmetic in nature and do not add anything substantial to the
|
||||
stability, functionality, or testability of the application will generally not
|
||||
be accepted.
|
||||
|
||||
### **Do you intend to add a new feature or change an existing one?**
|
||||
|
||||
* Suggest your change in a new issue as a feature request.
|
||||
|
||||
* DO NOT start work and submit a PR at this time, please discuss the change and
|
||||
your implementation plan first.
|
||||
|
||||
* After it is generally decided that the feature or change fits the goals of the
|
||||
project you can start work or open a PR if you have already started.
|
||||
|
||||
## Submitting your patch
|
||||
|
||||
* All changes should be based on the `dev` branch. When creating your PR please
|
||||
be sure to change the target to merge into dev, and when starting work on a new
|
||||
branch be sure to start on latest `dev`.
|
||||
|
||||
* Ensure the PR description clearly describes the problem and solution. Include
|
||||
the relevant issue number if applicable.
|
||||
|
||||
* The PR should contain **one commit** only, the commit message should have a
|
||||
clear title followed by a new line and then brief description if needed. PR with
|
||||
multiple commits will be squashed into one before merging if required. See
|
||||
[Git Mastery](https://git-mastery.org/lessons/commitMessage/) for more
|
||||
information on good commit messages.
|
||||
|
||||
* **Before committing changes** on your branch, be sure to run both
|
||||
`dart format .` and `flutter analyze`. The continuous development checks will
|
||||
fail if issues here are not addressed before hand.
|
||||
|
||||
## AI-use
|
||||
|
||||
Everyone loves some help, AI agents are a tool in many of our belts. The project
|
||||
is not anti-AI.
|
||||
|
||||
There are some limits to acceptable use however. Generally:
|
||||
|
||||
* All code generated by AI should be thoroughly reviewed by the contributor.
|
||||
* The changes should be tightly controlled to not change anything out of scope
|
||||
for the patch, bug fix, etc.
|
||||
* The contributor should have a good understanding of what the code does and how
|
||||
the application works in order to effectively be able to manage the agent.
|
||||
@@ -6,6 +6,8 @@ Open-source Flutter client for MeshCore LoRa mesh networking devices.
|
||||
|
||||
MeshCore Open is a cross-platform mobile application for communicating with MeshCore LoRa mesh network devices via Bluetooth Low Energy (BLE). The app enables long-range, off-grid communication through peer-to-peer messaging, public channels, and mesh networking capabilities.
|
||||
|
||||
**Website:** [meshcoreopen.org](https://meshcoreopen.org/)
|
||||
|
||||
<a href="http://apps.obtainium.imranr.dev/redirect.html?r=obtainium://add/https://github.com/zjs81/meshcore-open">
|
||||
<img src="assets/badges/badge_obtainium.png" height="80" align="center" alt="Get it on Obtainium"/>
|
||||
</a>
|
||||
@@ -46,7 +48,7 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
- **Live Map View**: Real-time visualization of mesh network nodes on an interactive map
|
||||
- **Node Filtering**: Filter by node type (chat, repeater, sensor) and time range
|
||||
- **Location Sharing**: Share GPS coordinates and custom markers with contacts
|
||||
- **Offline Maps**: Download map tiles for offline use in remote areas
|
||||
- **Offline Maps**: Download map tiles for offline use in remote areas (with [StadiaMaps](https://stadiamaps.com/pricing/) Free Subscription API-Key)
|
||||
- **MGRS Coordinates**: Support for Military Grid Reference System coordinate format
|
||||
|
||||
### Device Management
|
||||
@@ -92,12 +94,12 @@ MeshCore Open is a cross-platform mobile application for communicating with Mesh
|
||||
|---------|---------|
|
||||
| flutter_blue_plus | Bluetooth Low Energy communication |
|
||||
| provider | State management |
|
||||
| sqflite | Local database storage |
|
||||
| shared_preferences | Local key-value storage (scoped per device) |
|
||||
| flutter_map | Interactive map display |
|
||||
| latlong2 | Geographic coordinate handling |
|
||||
| flutter_local_notifications | Background notification support |
|
||||
| smaz | Message compression |
|
||||
| pointycastle | Cryptographic operations |
|
||||
| llamadart | On-device LLM message translation |
|
||||
| intl | Internationalization and date formatting |
|
||||
|
||||
## Getting Started
|
||||
@@ -150,7 +152,8 @@ lib/
|
||||
├── main.dart # App entry point
|
||||
├── connector/
|
||||
│ ├── meshcore_connector.dart # BLE communication & state management
|
||||
│ └── meshcore_protocol.dart # Protocol definitions & frame parsing
|
||||
│ ├── meshcore_protocol.dart # Protocol definitions & frame parsing
|
||||
│ └── meshcore_uuids.dart # Device names and IDs (add prefixes here!)
|
||||
├── screens/
|
||||
│ ├── scanner_screen.dart # Device scanning (home screen)
|
||||
│ ├── contacts_screen.dart # Contact list
|
||||
@@ -184,7 +187,16 @@ lib/
|
||||
|
||||
### Device Discovery
|
||||
|
||||
Devices are discovered by scanning for BLE advertisements with the name prefix `MeshCore-`
|
||||
Devices are discovered by scanning for BLE advertisements with known MeshCore device name prefixes. These are currently:
|
||||
- `MeshCore-`
|
||||
- `Whisper-`
|
||||
- `WisCore-`
|
||||
- `HT-`
|
||||
- `LowMesh_MC_`
|
||||
- `NRF52`
|
||||
|
||||
New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
|
||||
|
||||
|
||||
### Message Format
|
||||
|
||||
@@ -212,6 +224,10 @@ Messages are transmitted as binary frames using a custom protocol optimized for
|
||||
|
||||
This is an open-source project. Contributions are welcome!
|
||||
|
||||
## SWHID and Archive badge
|
||||
[](https://archive.softwareheritage.org/browse/origin/?origin_url=https://github.com/zjs81/meshcore-open)
|
||||
[](https://archive.softwareheritage.org/swh:1:dir:d37a80b06359730864150ad2aeadd46cce9abd55;origin=https://github.com/zjs81/meshcore-open;visit=swh:1:snp:47656c4b55ab40a689ff8d2f045196725f05096b;anchor=swh:1:rev:0fe250230905fdd05dbedc0f546736990beacf53)
|
||||
|
||||
### Development Guidelines
|
||||
|
||||
- Follow the Flutter style guide
|
||||
|
||||
@@ -43,9 +43,22 @@ android {
|
||||
// arguments += listOf("-DANDROID_STL=c++_shared")
|
||||
// }
|
||||
// }
|
||||
// ndk {
|
||||
// abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
|
||||
// }
|
||||
// arm64-v8a only, deliberately.
|
||||
//
|
||||
// * ONNX Runtime (flutter_onnxruntime, used by the AEIC-SE image codec)
|
||||
// ships a per-ABI .so. arm64-v8a alone costs ~18 MB of APK; a
|
||||
// universal APK carrying armeabi-v7a and x86_64 as well costs ~56 MB.
|
||||
// * llamadart only declares android-arm64 and android-x64 backends in
|
||||
// pubspec.yaml's `hooks.user_defines`, so an armeabi-v7a build already
|
||||
// has no translation backend at all.
|
||||
// * The image codec needs ~2.7 GiB peak resident, which no 32-bit
|
||||
// address space can provide regardless of ABI.
|
||||
//
|
||||
// Consequence: this APK will not install on 32-bit-only ARM devices or on
|
||||
// x86_64 emulators. For emulator work, temporarily add "x86_64" here.
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a")
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -67,6 +80,13 @@ android {
|
||||
} else {
|
||||
signingConfigs.getByName("debug")
|
||||
}
|
||||
// ONNX Runtime resolves its Java classes from native code by name.
|
||||
// Without these rules R8 renames them and the process SIGABRTs with
|
||||
// "java_class == null" the instant the codec runs a model.
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ONNX Runtime looks its Java classes up from native code with FindClass /
|
||||
# GetMethodID, by literal name. R8 renames them, the lookup returns null, and
|
||||
# the process dies with:
|
||||
#
|
||||
# JNI DETECTED ERROR IN APPLICATION: java_class == null
|
||||
# at art::JNI<false>::GetMethodID
|
||||
# from convertToTensorInfo -> Java_ai_onnxruntime_OrtSession_run
|
||||
#
|
||||
# It is a hard SIGABRT in native code, so nothing in Dart can catch it: the app
|
||||
# vanishes the moment the codec touches the model. Keep the whole package —
|
||||
# these are the types the native layer reflects on to build tensors and read
|
||||
# results back.
|
||||
-keep class ai.onnxruntime.** { *; }
|
||||
-keepclassmembers class ai.onnxruntime.** { *; }
|
||||
-dontwarn ai.onnxruntime.**
|
||||
|
||||
# The Flutter plugin's platform-channel handler, reached the same way.
|
||||
-keep class com.masicai.flutteronnxruntime.** { *; }
|
||||
-dontwarn com.masicai.flutteronnxruntime.**
|
||||
@@ -22,7 +22,7 @@
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false"/>
|
||||
|
||||
<application
|
||||
android:label="meshcore_open"
|
||||
android:label="MeshCore Open"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<service
|
||||
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 183 KiB |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground>
|
||||
<inset
|
||||
android:drawable="@drawable/ic_launcher_foreground"
|
||||
android:inset="16%" />
|
||||
</foreground>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#9F9D95</color>
|
||||
</resources>
|
||||
@@ -1,3 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||
android.builtInKotlin=false
|
||||
# This newDsl flag was added automatically by Flutter migrator
|
||||
android.newDsl=false
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
After Width: | Height: | Size: 669 KiB |
@@ -21,7 +21,12 @@ The MeshCore BLE protocol implements a binary frame-based communication system u
|
||||
|
||||
### Connection Flow
|
||||
|
||||
1. **Scan** for devices with name prefix `MeshCore-`
|
||||
1. **Scan** for devices with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
|
||||
- `MeshCore-`
|
||||
- `Whisper-`
|
||||
- `WisCore-`
|
||||
- `HT-`
|
||||
- `LowMesh_MC_`
|
||||
2. **Connect** with 15-second timeout
|
||||
3. **Request MTU** of 185 bytes (falls back to default if unsupported)
|
||||
4. **Discover services** and locate NUS characteristics
|
||||
|
||||
@@ -14,7 +14,8 @@ MeshCore Open is an open-source Flutter client for MeshCore LoRa mesh networking
|
||||
8. [Notifications](notifications.md) - System notifications, unread badges, and notification preferences
|
||||
9. [Repeater Management](repeater-management.md) - Repeater hub, status, CLI, telemetry, and neighbors
|
||||
10. [Additional Features](additional-features.md) - GIF picker, localization, debug logs, SMAZ compression, and more
|
||||
11. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
|
||||
11. [Routing Paths](routing-paths.md) - Path encoding, validation, device capability detection, and storage
|
||||
12. [BLE Protocol & Data Layer](ble-protocol.md) - Technical reference for the communication protocol and data architecture
|
||||
|
||||
## App Overview
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ A bottom sheet with a search field and a grid of GIF thumbnails.
|
||||
### How to Access
|
||||
App Settings → Appearance → Language
|
||||
|
||||
### Supported Languages (15)
|
||||
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian
|
||||
### Supported Languages (18)
|
||||
English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian, Hungarian, Japanese, Korean
|
||||
|
||||
### How It Works
|
||||
- All UI strings go through Flutter's ARB localization system
|
||||
@@ -183,5 +183,80 @@ An ML-based service that predicts expected delivery timeouts:
|
||||
- Applies a **1.5x safety margin** to raw predictions (the actual timeout issued is 1.5× the model's predicted delivery time)
|
||||
- Features with zero variance are automatically excluded from training
|
||||
- Blends per-contact statistics with ML predictions
|
||||
- Falls back to `3000 + 3000 × pathLength` ms when insufficient data
|
||||
- Falls back to `3000 + 3000 × pathLength` ms when insufficient data. Note: `pathLength` here refers to the stored hop count in the app's model/storage (number of hops), not the on-air encoded byte length.
|
||||
- Observations are persisted to storage via a 2-second debounced timer (observations within 2s of app termination may be lost)
|
||||
|
||||
---
|
||||
|
||||
## On-Device Message Translation
|
||||
|
||||
### What It Is
|
||||
An optional on-device translation service powered by an embedded LLM (llamadart, running GGUF models). Translation runs entirely on-device — no data leaves the app.
|
||||
|
||||
### How to Access
|
||||
Tap the translate button on any received message. On first use, the GGUF model file is downloaded and cached locally.
|
||||
|
||||
### How It Works
|
||||
- Model files are managed by `TranslationFileStore`; download progress is shown in-place
|
||||
- Before translating, the source language is automatically detected using the `flutter_langdetect` package. If the detected language already matches the target language, translation is skipped
|
||||
- Translation runs via `TranslationService` using the llamadart CPU backend (arm64 and x64 on Android)
|
||||
- Translated text is shown in `TranslatedMessageContent` as an inline overlay on the original message bubble
|
||||
- Each translation is cached; re-tapping shows the cached result without re-running inference
|
||||
|
||||
---
|
||||
|
||||
## Emoji Reactions
|
||||
|
||||
### How to Access
|
||||
Long-press a message bubble in any direct or channel chat, then select a reaction emoji.
|
||||
|
||||
### What the User Sees
|
||||
An emoji picker inline with common reactions. Selected reactions appear below the message bubble with a count.
|
||||
|
||||
### How It Works
|
||||
- Implemented via `emoji_picker.dart` and `reaction_helper.dart`
|
||||
- Reactions are transmitted as a special message type visible to all participants with MeshCore Open
|
||||
|
||||
---
|
||||
|
||||
## Linkification
|
||||
|
||||
### What It Does
|
||||
URLs and `meshcore://` URIs in received messages are automatically detected and rendered as tappable links.
|
||||
|
||||
### How It Works
|
||||
- Powered by the `flutter_linkify` package via `link_handler.dart`
|
||||
- Tapping a URL opens the system browser; tapping a `meshcore://` URI imports the contact
|
||||
|
||||
---
|
||||
|
||||
## GPX Export
|
||||
|
||||
### How to Access
|
||||
Settings → Export section (three options: Export Repeaters, Export Contacts, Export All).
|
||||
|
||||
### What It Does
|
||||
Exports contacts with GPS coordinates to a `.gpx` file via the OS share sheet. Not available on web.
|
||||
|
||||
---
|
||||
|
||||
## Pinch-to-Zoom Chat Text
|
||||
|
||||
### What It Does
|
||||
Users can pinch to scale all chat text up or down within a session.
|
||||
|
||||
### How It Works
|
||||
- Implemented via `ChatTextScaleService` and `ChatZoomWrapper`
|
||||
- Scale range: 0.8× to 1.8×
|
||||
- The chosen scale persists across the session via the service
|
||||
|
||||
---
|
||||
|
||||
## Background Service (Android)
|
||||
|
||||
### What It Does
|
||||
On Android, a foreground service (`background_service.dart`) keeps the BLE connection and message handling alive when the app is in the background. On other platforms this is a no-op.
|
||||
|
||||
### User Impact
|
||||
- A persistent notification appears while the service is running
|
||||
- Messages are received and retry logic continues even when the app is not in the foreground
|
||||
|
||||
@@ -33,7 +33,6 @@ RX (device → host): [0x3E][len_lo][len_hi][payload...]
|
||||
- Length: 2-byte little-endian, payload only
|
||||
- Max payload: 172 bytes
|
||||
- TCP: `tcpNoDelay: true` (Nagle disabled), writes serialized to prevent interleaving
|
||||
- USB: 10ms post-write delay between frames
|
||||
|
||||
## Connection State Machine
|
||||
|
||||
@@ -49,8 +48,16 @@ enum MeshCoreConnectionState {
|
||||
|
||||
## BLE Connection Lifecycle
|
||||
|
||||
1. **Scan** with keyword filters `["MeshCore-", "Whisper-"]`
|
||||
2. **Connect** with 15-second timeout
|
||||
1. **Scan** with known name prefixes (defined in `MeshCoreUuids.deviceNamePrefixes`):
|
||||
- `MeshCore-`
|
||||
- `Whisper-`
|
||||
- `WisCore-`
|
||||
- `Seeed`
|
||||
- `Lilygo`
|
||||
- `HT-`
|
||||
- `LowMesh_MC_`
|
||||
- `NRF52`
|
||||
2. **Connect** with 15-second timeout (6 seconds on Linux)
|
||||
3. **Request MTU** 185 bytes (non-web only)
|
||||
4. **Discover services** and locate NUS
|
||||
5. **Enable TX notifications** (up to 3 attempts on native)
|
||||
@@ -74,7 +81,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
|
||||
| Max path size | 64 bytes | Maximum path data |
|
||||
| Max name size | 32 bytes | Maximum node name |
|
||||
| Max text payload | 160 bytes | Firmware `MAX_TEXT_LEN` |
|
||||
| App protocol version | 3 | Sent in device query |
|
||||
| App protocol version | 4 | Sent in device query |
|
||||
| Contact frame size | 148 bytes | Fixed-size contact record |
|
||||
|
||||
## Command Codes (App → Device)
|
||||
@@ -109,13 +116,17 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
|
||||
| 32 | CMD_SET_CHANNEL | Set channel name and PSK |
|
||||
| 36 | CMD_SEND_TRACE_PATH | Request path trace |
|
||||
| 38 | CMD_SET_OTHER_PARAMS | Set misc parameters |
|
||||
| 39 | CMD_GET_TELEMETRY_REQ | Request sensor telemetry |
|
||||
| 39 | CMD_SEND_TELEMETRY_REQ | Request sensor telemetry |
|
||||
| 40 | CMD_GET_CUSTOM_VAR | Get custom variables |
|
||||
| 41 | CMD_SET_CUSTOM_VAR | Set a custom variable |
|
||||
| 50 | CMD_SEND_BINARY_REQ | Send binary request |
|
||||
| 54 | CMD_SET_FLOOD_SCOPE | Set flood routing scope (v8+) |
|
||||
| 55 | CMD_SEND_CONTROL_DATA | Send control data (e.g. zero-hop discovery, v8+) |
|
||||
| 56 | CMD_GET_STATS | Request companion radio stats |
|
||||
| 57 | CMD_SEND_ANON_REQ | Send anonymous request |
|
||||
| 58 | CMD_SET_AUTO_ADD_CONFIG | Set auto-add configuration |
|
||||
| 59 | CMD_GET_AUTO_ADD_CONFIG | Get auto-add configuration |
|
||||
| 61 | CMD_SET_PATH_HASH_MODE | Set path hash width (bytes per hop) |
|
||||
|
||||
## Response / Push Codes (Device → App)
|
||||
|
||||
@@ -139,6 +150,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
|
||||
| 17 | RESP_CODE_CHANNEL_MSG_RECV_V3 | Incoming channel message (v3) |
|
||||
| 18 | RESP_CODE_CHANNEL_INFO | Channel definition |
|
||||
| 21 | RESP_CODE_CUSTOM_VARS | Custom variables |
|
||||
| 24 | RESP_CODE_STATS | Companion radio stats |
|
||||
| 25 | RESP_CODE_AUTO_ADD_CONFIG | Auto-add flags |
|
||||
| 0x80 | PUSH_CODE_ADVERT | Known contact re-seen |
|
||||
| 0x81 | PUSH_CODE_PATH_UPDATED | Better path found; carries the 32-byte public key of the updated contact |
|
||||
@@ -152,6 +164,7 @@ On unexpected disconnection, auto-reconnect with exponential backoff:
|
||||
| 0x8A | PUSH_CODE_NEW_ADVERT | New node discovered |
|
||||
| 0x8B | PUSH_CODE_TELEMETRY_RESPONSE | Sensor telemetry data |
|
||||
| 0x8C | PUSH_CODE_BINARY_RESPONSE | Binary data response |
|
||||
| 0x8E | PUSH_CODE_CONTROL_DATA | Control data push (e.g. zero-hop discovery response) |
|
||||
|
||||
## Data Models
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Channels are broadcast group-chat spaces secured by a 16-byte pre-shared key (PSK). Any device with the same channel index and PSK will receive and decrypt channel messages. Unlike direct messages, channel messages are broadcast to the entire mesh.
|
||||
|
||||
Up to 8 channels (indices 0–7) can be active simultaneously on one device.
|
||||
The number of active channels is determined by the firmware (default 40); the device reports its actual limit at login.
|
||||
|
||||
## How to Access
|
||||
|
||||
@@ -17,7 +17,7 @@ QuickSwitchBar tab 1 (middle) from any main screen.
|
||||
| Public | Globe | Green | Fixed well-known PSK; any device can join |
|
||||
| Hashtag | Hash tag | Blue | PSK derived from the hashtag name via SHA-256; discoverable by convention |
|
||||
| Private | Lock | Blue | Random PSK; requires out-of-band sharing of the 32-hex key |
|
||||
| Community | Groups/Tag | Purple | PSK derived via HMAC-SHA256 from a community's shared secret |
|
||||
| Community | Groups/Tag | Magenta | PSK derived via HMAC-SHA256 from a community's shared secret |
|
||||
|
||||
## Channels List Screen
|
||||
|
||||
@@ -26,13 +26,12 @@ QuickSwitchBar tab 1 (middle) from any main screen.
|
||||
- **Search bar** with live text filtering (300ms debounce)
|
||||
- **Sort/filter button**
|
||||
- **Scrollable list of channel cards**, each showing:
|
||||
- Type icon with color coding (purple badge overlay for community channels)
|
||||
- Type icon with color coding (magenta badge overlay for community channels)
|
||||
- Channel name (or "Channel N" if unnamed)
|
||||
- Subtitle: "Public channel", "Hashtag channel", "Private channel", or "Community channel - {name}"
|
||||
- Unread badge (if messages are unread)
|
||||
- Drag handle (when manual sort is active)
|
||||
- **"+" FAB** to add a new channel
|
||||
- **Overflow menu**: Disconnect, Manage Communities (only shown when at least one community exists), Settings
|
||||
- **Overflow menu**: Disconnect, Manage Communities, Settings
|
||||
|
||||
If no channels exist, an empty state with an "Add Public Channel" shortcut is shown. If a search produces no results, a separate "no results" empty state with a search-off icon is shown.
|
||||
|
||||
@@ -60,7 +59,7 @@ Tap the "+" FAB to open a dialog with six options:
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| Edit | Change name, PSK (with a dice icon to generate a random PSK), or SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit) |
|
||||
| Edit | Change name, PSK (with a dice icon to generate a random PSK), SMAZ compression toggle (compresses outgoing messages to allow longer text within the byte limit), or Cyr2Lat encoding toggle (transliterates Cyrillic to Latin for compatibility) |
|
||||
| Mute / Unmute | Toggle push notification suppression for this channel |
|
||||
| Delete | Remove the channel from the device (confirmation required) |
|
||||
|
||||
@@ -70,9 +69,9 @@ Tap a channel card to open the channel chat screen.
|
||||
|
||||
### App Bar
|
||||
|
||||
- Type icon (public/private/hashtag)
|
||||
- Type icon: globe for public channels, tag (#) for all other channel types
|
||||
- Channel name
|
||||
- Subtitle: "{type} - {N} unread"
|
||||
- Subtitle: "{Public|Private} • {N} unread" (e.g., "Public • 3 unread")
|
||||
|
||||
### Message Display
|
||||
|
||||
@@ -101,8 +100,7 @@ Tap a channel card to open the channel chat screen.
|
||||
|
||||
### Message Path Viewing
|
||||
|
||||
- **Mobile**: Tap a message bubble to view its routing path
|
||||
- **Desktop**: Long-press/right-click → "Path" (tapping the bubble does nothing on desktop)
|
||||
- **All platforms**: Long-press (or right-click on desktop) a message bubble → "Path"
|
||||
- Opens the Channel Message Path Screen (see [Additional Features](additional-features.md))
|
||||
|
||||
### Context Actions (Long-Press / Right-Click)
|
||||
@@ -110,20 +108,12 @@ Tap a channel card to open the channel chat screen.
|
||||
| Action | Availability | Description |
|
||||
|---|---|---|
|
||||
| Reply | All messages | Triggers reply mode |
|
||||
| Path | Desktop only | Opens message path view |
|
||||
| Path | All messages | Opens message path view |
|
||||
| Add Reaction | Incoming messages only | Opens emoji picker (cannot react to your own messages) |
|
||||
| Copy | All messages | Copies text to clipboard |
|
||||
| Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread |
|
||||
| Delete | All messages | Removes locally (not from mesh) |
|
||||
|
||||
### Message Path Viewing
|
||||
|
||||
Tap a message bubble to open the Channel Message Path Screen, which shows:
|
||||
- Each hop in the path as a visual chain
|
||||
- Known contacts identified by name at each hop
|
||||
- Observed vs. declared hop counts
|
||||
- Alternative path variants (if received via multiple paths)
|
||||
- Map view buttons for geographic path visualization
|
||||
|
||||
## Communities
|
||||
|
||||
Communities are a layer above channels that provide a private namespace.
|
||||
@@ -151,7 +141,7 @@ From the channels screen overflow menu → "Manage Communities". Opens a draggab
|
||||
- **Tap a community** to directly show its QR code for sharing
|
||||
- **Popup menu** per community:
|
||||
- **Show QR** — displays the QR code for sharing with new members
|
||||
- **Delete** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
|
||||
- **Leave Community** — removes the community locally and deletes all associated device channels (confirmation dialog warns how many channels will be removed)
|
||||
|
||||
## How Channels Differ from Direct Messages
|
||||
|
||||
|
||||
@@ -18,17 +18,15 @@ From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
|
||||
|
||||
- **Title**: Contact name
|
||||
- **Subtitle**: Current routing path label (e.g., "2 hops", "flood (auto)", "direct (forced)") and unread count. Tapping the subtitle shows the full path details.
|
||||
- **Action buttons**:
|
||||
- **Routing mode** (waves icon): Switch between Auto, Direct, and Flood routing
|
||||
- **Path management** (timeline icon): View recent paths with hop count, round-trip time, age, and success count. Paths are color-coded by direct repeater (green/yellow/red/blue for ranked repeaters, grey for unknown). Tap a path to activate it (the device verifies and confirms via snackbar), long-press to view full path details, set custom paths, or force flood mode. A warning banner appears when history reaches 100 entries.
|
||||
- **Info** (info icon): Contact info dialog showing type, path, GPS coordinates, public key, and SMAZ compression toggle
|
||||
- **Action button**:
|
||||
- **Overflow menu** (⋮ icon): Contains Routing, Info, Telemetry, Settings, and Clear Chat. Routing opens the routing sheet where you can switch between Auto, Direct, and Flood routing and manage recent paths (hop count, round-trip time, age, success count, color-coded by repeater). Info shows a dialog with contact type, path, GPS coordinates, and public key.
|
||||
|
||||
### Message List
|
||||
|
||||
- Scrollable list with newest messages at the bottom
|
||||
- **Outgoing messages**: Right-aligned, primary color background. **Failed messages** change to a red-toned error container background
|
||||
- **Incoming messages**: Left-aligned, grey background with a colored avatar (initial letter or first emoji of sender name; color is deterministic from a hash of the sender name)
|
||||
- Bubble width capped at 65% of screen width
|
||||
- Bubble width capped at 72% of screen width
|
||||
- Hyperlinks rendered as tappable green underlined text
|
||||
- **Pinch-to-zoom**: Two-finger zoom (0.8x–1.8x) and double-tap to reset
|
||||
- **Jump to bottom**: Floating button appears when scrolled away from the bottom
|
||||
@@ -37,6 +35,7 @@ From the Contacts screen, tap any Chat-type contact to open the ChatScreen.
|
||||
### Input Bar
|
||||
|
||||
- **GIF button** (left): Opens GIF picker bottom sheet
|
||||
- **Translation button** (optional, between GIF and text field): Shown only when translation is enabled in App Settings. Tap to configure outgoing-message translation language and on/off toggle.
|
||||
- **Text field** (center): Auto-capitalization, enforces UTF-8 byte limit in real-time
|
||||
- **Send button** (right): Submits the message
|
||||
- On desktop: Enter/Numpad Enter also submits
|
||||
@@ -66,8 +65,8 @@ Outgoing messages display a status indicator:
|
||||
|
||||
When enabled in App Settings, additional metadata appears inside each bubble:
|
||||
- Timestamp (HH:MM)
|
||||
- Retry count (e.g., "Retry 2 of 4")
|
||||
- Status icon
|
||||
- Retry count (e.g., "Retry 2 of 4") — only shown for outgoing messages where at least one retry has occurred
|
||||
- Status icon (outgoing only)
|
||||
- Round-trip time in seconds (if delivered)
|
||||
|
||||
## Message Length Limits
|
||||
@@ -86,7 +85,7 @@ When a direct message is sent:
|
||||
|
||||
1. The app computes an expected ACK hash: `SHA256([timestamp][attempt][text][selfPubKey])[0:4]` — matching the firmware's hash calculation. If SMAZ compression is enabled, the compressed text (not the original) is hashed
|
||||
2. On device acknowledgment (`RESP_CODE_SENT`), the message transitions to "sent" and a timeout timer starts
|
||||
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise `3000 + 3000 × path_length` milliseconds (15000ms for flood)
|
||||
3. **Timeout duration**: Preferably from the ML timeout prediction service; otherwise from the device's own `est_timeout` in `RESP_CODE_SENT` (clamped to the physics range); otherwise calculated from LoRa airtime physics: `500 + (airtime × 6 + 250) × (pathLength + 1)` ms for direct paths, `500 + 16 × airtime` ms for flood (airtime is estimated from the radio's current spreading factor, bandwidth, and coding rate). The result is capped at 45 seconds.
|
||||
4. On timeout, the message is retried with **exponential backoff**: `1000 × 2^retryCount` ms (1s, 2s, 4s, 8s, 16s...)
|
||||
5. **Max retries**: Configurable (default 5, range 2–10)
|
||||
6. After max retries, the message is marked "failed" — but a **30-second grace window** remains during which a late ACK can still resolve the message to "delivered"
|
||||
@@ -113,8 +112,10 @@ Add emoji reactions to incoming messages (not your own):
|
||||
| Action | Availability | Description |
|
||||
|---|---|---|
|
||||
| Add reaction | Incoming messages only | Opens emoji picker |
|
||||
| View path | Mobile: tap bubble directly; Desktop: long-press/right-click menu | Shows message routing path |
|
||||
| View path | All platforms: long-press/right-click menu | Shows message routing path |
|
||||
| Copy | All messages | Copies text to clipboard |
|
||||
| Translate | Incoming messages only (when translation is enabled and not yet translated) | Translates the message on-demand using the on-device model |
|
||||
| Mark as Unread | Incoming messages only | Marks this message and all subsequent incoming messages as unread |
|
||||
| Delete | All messages | Removes locally (not from mesh) |
|
||||
| Retry | Failed outgoing messages | Re-sends the message |
|
||||
| Open chat with sender | Room server chats | Opens 1:1 chat with the message sender |
|
||||
|
||||
@@ -6,18 +6,17 @@ The Contacts screen is the primary hub for managing mesh nodes your radio has a
|
||||
|
||||
## How to Access
|
||||
|
||||
- Automatically shown after connecting to a device
|
||||
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens
|
||||
- QuickSwitchBar tab 0 (leftmost) from Channels or Map screens (Channels is shown first after connecting)
|
||||
- Back navigation from Chat or Settings screens
|
||||
|
||||
## Contact Types
|
||||
|
||||
| Type | Avatar Color | Icon | Description |
|
||||
|---|---|---|---|
|
||||
| Chat | Blue | Chat bubble | Another user's mesh radio |
|
||||
| Repeater | Orange | Cell tower | A mesh repeater/relay node |
|
||||
| Room | Purple | Group | A room server for group chat |
|
||||
| Sensor | Green | Sensors | A sensor device |
|
||||
| Chat | Blue | Initials / emoji | Another user's mesh radio |
|
||||
| Repeater | Amber | Cell tower | A mesh repeater/relay node |
|
||||
| Room | Magenta | Meeting room | A room server for group chat |
|
||||
| Sensor | Teal | Sensors | A sensor device |
|
||||
|
||||
## Contact List
|
||||
|
||||
@@ -73,41 +72,42 @@ Groups are stored per radio identity (scoped by public key).
|
||||
|
||||
| Action | Availability | Description |
|
||||
|---|---|---|
|
||||
| Path Trace / Ping | Repeaters, Rooms (always); Chat if `pathLength > 0` | Opens PathTraceMapScreen. Label shows "Ping" when no path bytes are known, "Path Trace" otherwise |
|
||||
| Ping | Repeaters only | Opens PathTraceMapScreen targeting the repeater |
|
||||
| Path Trace | Rooms (always); Chat/Sensor only if `pathLength > 0` | Opens PathTraceMapScreen. For rooms, label shows "Ping" when no path bytes are known, "Path Trace" when path bytes are available |
|
||||
| Manage Repeater | Repeaters only | Login dialog → RepeaterHubScreen |
|
||||
| Room Login | Rooms only | Login dialog → ChatScreen |
|
||||
| Room Management | Rooms only | Login dialog → RepeaterHubScreen (management mode) |
|
||||
| Open Chat | Chat/Sensor | Same as single tap |
|
||||
| Add/Remove Favorite | All types | Toggles the favorite flag |
|
||||
| Share Contact | All types | Copies `meshcore://<hex>` URI to clipboard |
|
||||
| Share Contact | All types | Requests advert from device → copies `meshcore://<hex>` URI to clipboard |
|
||||
| Share Contact Zero-Hop | All types | Broadcasts the contact's advertisement one hop |
|
||||
| Delete Contact | All types | Confirmation dialog → removes from device and clears messages |
|
||||
|
||||
## App Bar Menus
|
||||
|
||||
The Contacts screen has **two separate popup menus** in the app bar:
|
||||
The Contacts screen has a single **three-dot overflow menu** (`⋮`) in the app bar:
|
||||
|
||||
**Antenna icon menu** (contact sharing):
|
||||
- Discovered Contacts — opens the DiscoveryScreen
|
||||
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
|
||||
- *(divider)*
|
||||
- Zero-Hop Advert — broadcasts your advertisement to immediately adjacent nodes
|
||||
- Flood Advert — broadcasts across the full mesh network
|
||||
- Copy Advert to Clipboard — copies your `meshcore://<hex>` URI for sharing externally
|
||||
- Add Contact from Clipboard — reads a `meshcore://<hex>` URI from clipboard and imports it
|
||||
|
||||
**Three-dot overflow menu**:
|
||||
- *(divider)*
|
||||
- Disconnect — disconnects from the device
|
||||
- Discovered Contacts — opens the DiscoveryScreen
|
||||
- Settings — opens the Settings screen
|
||||
|
||||
A **floating action button** (person-add icon) provides a shortcut sheet to "Add Contact from Clipboard" or "Discovered Contacts".
|
||||
|
||||
## Adding Contacts
|
||||
|
||||
### Automatic (Passive)
|
||||
When the radio hears an advertisement, the contact appears automatically if auto-add is enabled for that type (configurable in Settings → Contact Settings).
|
||||
|
||||
### Import from Clipboard
|
||||
Antenna menu → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
|
||||
Overflow menu (or the FAB shortcut) → "Add Contact from Clipboard". Reads a `meshcore://<hex>` URI from clipboard and imports it to the device.
|
||||
|
||||
### Import from Discovered Contacts
|
||||
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Add, Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms, Favorites), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
|
||||
Overflow menu → "Discovered Contacts". Shows nodes heard passively that haven't been added yet. Tap to immediately import (no confirmation dialog), or long-press for more options (Copy URI, Delete). The Discovery screen has its own search bar, type filters (Users, Repeaters, Rooms), and sort options (Last Seen, A-Z). An overflow "Delete All" option clears all discovered contacts.
|
||||
|
||||
## Contact Sharing Format
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ All contacts with known GPS coordinates are plotted:
|
||||
| Room | Purple | Meeting room |
|
||||
| Sensor | Orange | Sensors |
|
||||
|
||||
Node name labels appear automatically at zoom level 12 and above.
|
||||
Node name labels appear automatically at zoom level 14 and above.
|
||||
|
||||
### Shared Map Pins (Flag Icons)
|
||||
Location pins shared in chat messages are displayed as flags:
|
||||
@@ -35,9 +35,9 @@ Location pins shared in chat messages are displayed as flags:
|
||||
|
||||
Tap a pin to see its info. Options to "Hide" (session only) or "Remove" (persistent).
|
||||
|
||||
### Predicted / Guessed Locations (Semi-Transparent)
|
||||
### Predicted / Guessed Locations
|
||||
|
||||
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as semi-transparent markers with a `not_listed_location` icon, visually distinct from confirmed-location markers.
|
||||
Many contacts on the mesh don't have GPS hardware, so the map has no explicit coordinates for them. Instead of leaving these contacts invisible, the app **infers an approximate position** by analyzing the repeater path the contact's messages travel through. These inferred positions are displayed as markers with a `not_listed_location` icon and a muted grey or colored border, visually distinct from confirmed-location markers.
|
||||
|
||||
#### Why guessed locations exist
|
||||
|
||||
@@ -55,19 +55,19 @@ In a mesh network, every message hops through one or more repeaters on its way t
|
||||
|
||||
5. **Compute the estimated position**:
|
||||
- **Single anchor**: The contact is placed on a small circle (330m radius) around the repeater. The angle on the circle is deterministic — derived from an FNV-1a hash of the contact's public key — so the same contact always appears at the same offset, preventing markers from stacking on top of each other.
|
||||
- **Two or more anchors**: The position is the average (centroid) of all anchor coordinates, with a smaller offset radius (80–120m) applied for visual separation.
|
||||
- **Two or more anchors**: The position is a weighted average of all anchor coordinates (each subsequent anchor weighted at half the previous one, biasing toward the first), with a smaller offset radius (120m for 2 anchors, 80m for 3+) applied for visual separation.
|
||||
|
||||
6. **Assign confidence level**:
|
||||
- **High confidence** (2+ anchors): Displayed at 55% opacity.
|
||||
- **Low confidence** (1 anchor): Displayed at 30% opacity.
|
||||
- **High confidence** (2+ anchors): The marker border uses the node's type color (brighter border).
|
||||
- **Low confidence** (1 anchor): The marker border is rendered in a muted grey.
|
||||
|
||||
7. **Cache the result**: The computation is cached using a key derived from the contact's paths, anchor positions, path-history version, and radio parameters. The cache is only invalidated when any of these inputs change, avoiding recomputation on every UI rebuild.
|
||||
|
||||
#### How to read guessed locations on the map
|
||||
|
||||
- **Semi-transparent marker** with a `not_listed_location` icon: This is a guessed position, not a confirmed GPS fix.
|
||||
- **More opaque** (55%): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
|
||||
- **More transparent** (30%): Lower confidence — based on a single repeater anchor only.
|
||||
- **Marker with `not_listed_location` icon**: This is a guessed position, not a confirmed GPS fix.
|
||||
- **Colored border** (type color): Higher confidence — the contact was seen through 2 or more repeaters with known positions.
|
||||
- **Grey border**: Lower confidence — based on a single repeater anchor only.
|
||||
- Coordinates shown in the marker info dialog are prefixed with `~` to indicate they are estimated.
|
||||
- Guessed locations can be toggled on/off in the map filter dialog (FAB → "Guessed locations" toggle).
|
||||
|
||||
@@ -88,10 +88,10 @@ Shows a bottom sheet with:
|
||||
- **Set as my location**: Updates your device's advertised location
|
||||
|
||||
### Filter Dialog (FAB)
|
||||
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts.
|
||||
Toggle visibility of: chat nodes, repeaters, other nodes, guessed locations, discovery contacts, overlapping markers (stacked markers at similar coordinates), and shared map pins (flag markers).
|
||||
Additional filters:
|
||||
- **Key prefix filter**: Show only contacts whose public key starts with a given prefix
|
||||
- **Last-seen time slider**: From 1 hour to "all time"
|
||||
- **Last-seen time slider**: Exponential scale from near-zero to 6 months, with "all time" at the top end
|
||||
|
||||
### Legend Card (Top-Right)
|
||||
Shows node count and pin count. Tappable to expand a legend of all marker types.
|
||||
@@ -110,9 +110,16 @@ A map with a polyline showing the route from your node through repeater hops to
|
||||
- **Green circles**: Hops with known GPS coordinates
|
||||
- **Orange circles** (`~HH`): Inferred positions (no GPS but deducible from contacts)
|
||||
- **Red endpoint**: Target contact with known GPS
|
||||
- **Purple semi-transparent endpoint**: Target with guessed position
|
||||
- **Magenta endpoint**: Target with guessed position
|
||||
|
||||
A legend card at the bottom lists each hop pair with SNR quality icons and total path distance.
|
||||
A bottom panel shows each hop pair with SNR quality icons and total path distance. When multiple observed paths are available, a **Single / Combined** toggle appears at the top of the map. In Combined view, all paths are overlaid; shared segments are highlighted with a white halo and a path count badge appears on shared nodes.
|
||||
|
||||
The bottom panel also provides **packet animation controls**:
|
||||
- **Animation toggle** (on/off)
|
||||
- **Step back / Play / Step forward / Replay** buttons
|
||||
- **Follow packet lock** — keeps the map camera centered on the moving packet dot
|
||||
- **Speed selector** (0.5×, 1×, 2×, 4×)
|
||||
- A live **"Hop x of y · from → to"** label that tracks the active segment
|
||||
|
||||
### How It Works
|
||||
Sends a trace request frame over the mesh. The repeater network traces the path hop-by-hop and returns per-hop SNR data. For hops without GPS, positions are inferred by averaging GPS coordinates of contacts sharing that last-hop byte.
|
||||
@@ -125,17 +132,17 @@ Sends a trace request frame over the mesh. The repeater network traces the path
|
||||
From the main map, tap the terrain/antenna icon.
|
||||
|
||||
### What the User Sees
|
||||
A full-screen map with a collapsible control panel containing:
|
||||
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow)
|
||||
- **Status**: Clear (green) or blocked (red) with distance and minimum clearance
|
||||
- **Options panel**: Node toggles, endpoint dropdowns, antenna height sliders (0–400 ft), Run LOS button
|
||||
A full-screen map with a draggable bottom sheet containing:
|
||||
- **Elevation profile chart**: Terrain fill (green), LOS beam line (white), radio horizon line (yellow); obstruction points are marked as clickable dots on the chart
|
||||
- **Status summary**: Clear (green), Marginal (amber, within 5 m of obstruction), or Blocked (red) with distance and clearance/obstruction amount
|
||||
- **Options section** (collapsible): Node toggles, endpoint dropdowns, antenna height sliders (0–400 ft), Run LOS button
|
||||
|
||||
### Key Interactions
|
||||
- **Long-press the map** to add custom endpoints (orange pushpin markers, renameable/deleteable)
|
||||
- **Long-press the map** to add custom endpoints (pushpin markers, renameable/deleteable)
|
||||
- **Tap a marker** to select it as Point A or B; LOS runs automatically when both are set
|
||||
- **Antenna heights** are adjustable for both endpoints
|
||||
- **Map line** between endpoints is colored green (clear) or red (blocked)
|
||||
- Terrain elevation is fetched from the Open-Meteo API (21–81 sample points, cached 24 hours)
|
||||
- **Map line** between endpoints is colored green (clear), amber (marginal), or red (blocked)
|
||||
- Terrain elevation is fetched from the Open-Meteo API (21, 41, or 81 sample points depending on link distance, cached 24 hours)
|
||||
- K-factor is adjusted per radio frequency from a baseline of 4/3 at 915 MHz
|
||||
|
||||
---
|
||||
@@ -149,7 +156,7 @@ Settings → App Settings → Map Display → Offline Map Cache
|
||||
- Map with a blue polygon overlay showing previously selected cache bounds
|
||||
- Bounding box coordinates card
|
||||
- **Cache Area** controls: "Use Current View" and Clear buttons
|
||||
- **Zoom Range** slider (3–18) with estimated tile count
|
||||
- **Zoom Range** range slider (3–18, dual-handle for min and max) with estimated tile count
|
||||
- **Download progress** bar (when downloading)
|
||||
- **Download Tiles** and **Clear Cache** buttons
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
The app follows this general flow:
|
||||
|
||||
```
|
||||
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Contacts Screen
|
||||
Launch → Scanner Screen → [Connect via BLE/USB/TCP] → Channels Screen
|
||||
```
|
||||
|
||||
After connecting, the three main screens (Contacts, Channels, Map) are accessible via a persistent bottom navigation bar called the **QuickSwitchBar**.
|
||||
@@ -22,42 +22,27 @@ The QuickSwitchBar is a Material 3 `NavigationBar` with a frosted-glass visual t
|
||||
|
||||
Tapping a tab replaces the current screen with a subtle fade + slight horizontal nudge transition (220ms forward, 200ms reverse). The back button is suppressed on all three main screens — navigation between them is flat, not stacked. All icons use outline variants (`people_outline`, `tag`, `map_outlined`) following Material 3 conventions.
|
||||
|
||||
## Device Screen
|
||||
## Disconnection
|
||||
|
||||
The Device Screen is a transitional hub that shows after connection. In practice, the app navigates directly to Contacts after connecting, but the Device Screen is reachable via the QuickSwitchBar.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
**App Bar**:
|
||||
- Left: Battery indicator chip (tappable — toggles between percentage and voltage display). Icon changes based on level: `battery_unknown` when data unavailable, `battery_alert` (orange) at 15% or below, `battery_full` otherwise
|
||||
- Left-aligned title (`centerTitle: false`): Two-line layout — small grey "MeshCore" label above the device name in bold
|
||||
- Right: Disconnect button (`bluetooth_disabled` crossed-out icon) and Settings button (tune icon)
|
||||
|
||||
**Body**:
|
||||
- **Connection Card**: Device avatar, device name, device ID, "Connected" chip, and battery chip
|
||||
- **Quick Switch** section: The QuickSwitchBar widget for navigating to Contacts/Channels/Map
|
||||
|
||||
### Disconnection
|
||||
|
||||
- The disconnect button shows a confirmation dialog before disconnecting
|
||||
- The disconnect button (available in the overflow menu of each main screen) shows a confirmation dialog before disconnecting
|
||||
- If the device disconnects unexpectedly, the app automatically navigates back to the Scanner screen (fires after the current frame completes via a post-frame callback)
|
||||
- This auto-navigation behavior (`DisconnectNavigationMixin`) is shared across all main screens
|
||||
|
||||
## Theme and Locale
|
||||
|
||||
- **Theme mode** is user-configurable in App Settings (System / Light / Dark) — not locked to system
|
||||
- **Language** can be overridden to one of 15 supported languages, or follow the system locale
|
||||
- **Language** can be overridden to one of 18 supported languages, or follow the system locale
|
||||
- On web, if a non-Chromium browser is detected, the app shows a `ChromeRequiredScreen` instead of the Scanner (Web Bluetooth requires Chromium)
|
||||
|
||||
## Full Navigation Graph
|
||||
|
||||
```
|
||||
ScannerScreen (root, always on stack)
|
||||
├─ [BLE connect] → push → ContactsScreen
|
||||
├─ [TCP FAB] → push → TcpScreen
|
||||
│ └─ [TCP connected] → pushReplacement → ContactsScreen
|
||||
└─ [USB FAB] → push → UsbScreen
|
||||
└─ [USB connected] → pushReplacement → ContactsScreen
|
||||
├─ [BLE connect] → push → ChannelsScreen
|
||||
├─ [TCP icon button] → push → TcpScreen
|
||||
│ └─ [TCP connected] → pushReplacement → ChannelsScreen
|
||||
└─ [USB icon button] → push → UsbScreen
|
||||
└─ [USB connected] → pushReplacement → ChannelsScreen
|
||||
|
||||
ContactsScreen (selected=0)
|
||||
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||
@@ -75,9 +60,9 @@ ChannelsScreen (selected=1)
|
||||
MapScreen (selected=2)
|
||||
├─ [quick-switch 0] → pushReplacement → ContactsScreen
|
||||
├─ [quick-switch 1] → pushReplacement → ChannelsScreen
|
||||
├─ [radar button] → push → PathTraceMapScreen
|
||||
├─ [terrain button] → push → LineOfSightMapScreen
|
||||
└─ [long-press] → share marker / set location
|
||||
├─ [radar menu item] → enters in-map path trace mode (push → PathTraceMapScreen after path is built)
|
||||
├─ [terrain menu item] → push → LineOfSightMapScreen
|
||||
└─ [long-press] → share marker sheet
|
||||
|
||||
Settings (push from any main screen)
|
||||
└─ [App Settings] → push → AppSettingsScreen
|
||||
|
||||
@@ -22,7 +22,7 @@ MeshCore Open provides both **system notifications** (push-style OS alerts) and
|
||||
|
||||
### 3. Advertisement Notifications
|
||||
- **Triggered when**: A new node is discovered on the mesh for the first time
|
||||
- **Title**: "New [type] discovered" (e.g., "New chat node discovered")
|
||||
- **Title**: "New [type] discovered" (e.g., "New Chat discovered")
|
||||
- **Body**: Contact's name
|
||||
- **Priority**: Default
|
||||
- **Android channel**: `adverts`
|
||||
@@ -43,7 +43,7 @@ Red numeric badges appear throughout the UI:
|
||||
- **Contacts list**: Each contact row shows a red pill badge (e.g., "3") for unread messages
|
||||
- **Channels list**: Each channel row shows an unread badge
|
||||
- **Chat screen subtitle**: Shows unread count inline
|
||||
- Badges cap at "99+" for display
|
||||
- Badges cap at "9999+" for display
|
||||
|
||||
### How Unread Counts Work
|
||||
|
||||
@@ -73,7 +73,7 @@ There is no per-contact muting.
|
||||
|
||||
The notification system prevents notification storms:
|
||||
- **Minimum interval**: 3 seconds between individual notifications
|
||||
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`): "MeshCore Activity — 2 messages, 1 channel message, 3 new nodes". Note: batch summaries are Android-only; on Apple platforms individual notifications are shown
|
||||
- **Batch window**: If multiple notifications arrive within 5 seconds, they are combined into a single summary notification on a fourth Android channel (`batch_summary`). The title is "MeshCore Activity" and the body lists the grouped counts (e.g., "2 messages, 1 channel message, 3 new nodes"). Batch summaries are Android-only; queued notifications that overflow the batch window are silently dropped on other platforms
|
||||
|
||||
## Notification Clearing
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ From the Contacts screen:
|
||||
- Password field with show/hide toggle
|
||||
- "Save password" checkbox (persists for future logins). If a saved password exists, it is pre-filled and the checkbox is pre-checked, making login one-tap
|
||||
- Routing mode selector and "Manage Paths" link are available directly in the dialog (configure routing before login)
|
||||
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password stops immediately after the first attempt — only timeouts trigger retries
|
||||
- After 5 failed attempts, further login attempts are blocked
|
||||
- Auto-retries up to 5 times on timeout, showing progress ("Attempt 2 of 5"). A wrong password (explicit failure response) stops immediately — only timeouts trigger retries
|
||||
- If auto-clock-sync is enabled for this repeater (configured in Repeater Settings), a `clock sync` command is sent automatically on successful login
|
||||
|
||||
---
|
||||
|
||||
@@ -28,15 +28,17 @@ The central management screen showing:
|
||||
|
||||
- **Header card**: Repeater name, short public key, path label, GPS coordinates (if known)
|
||||
- **Battery chemistry selector**: NMC / LiFePO4 / LiPo (saved per repeater)
|
||||
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" based on contact type:
|
||||
- **Management tool cards** (full-width cards with chevron arrows, not a grid). Title dynamically shows "Repeater Management" or "Room Management" (admin) or "Repeater Guest" / "Room Guest" (guest) based on contact type and login result:
|
||||
|
||||
| Card | Destination |
|
||||
|---|---|
|
||||
| Status | Repeater Status Screen |
|
||||
| Telemetry | Telemetry Screen |
|
||||
| CLI | Repeater CLI Screen |
|
||||
| Neighbors | Neighbors Screen |
|
||||
| Settings | Repeater Settings Screen |
|
||||
| Card | Destination | Visibility |
|
||||
|---|---|---|
|
||||
| Status | Repeater Status Screen | All users |
|
||||
| Telemetry | Telemetry Screen | All users |
|
||||
| Neighbors | Neighbors Screen | All users |
|
||||
| CLI | Repeater CLI Screen | Admin only |
|
||||
| Settings | Repeater Settings Screen | Admin only |
|
||||
|
||||
The battery chemistry selector and CLI/Settings cards are hidden from guest users.
|
||||
|
||||
---
|
||||
|
||||
@@ -47,26 +49,28 @@ The central management screen showing:
|
||||
Three information cards:
|
||||
|
||||
**System Information**:
|
||||
- Battery percentage
|
||||
- Uptime
|
||||
- Queue length
|
||||
- Error flags
|
||||
- Battery percentage and voltage (e.g. "85% / 3.95V"), using the battery chemistry set in the hub screen
|
||||
- Clock at login time
|
||||
- Uptime (days/hours/minutes/seconds)
|
||||
- Queue length
|
||||
- Debug flags (error event count)
|
||||
|
||||
**Radio Statistics**:
|
||||
- Last RSSI and SNR
|
||||
- Noise floor
|
||||
- TX and RX airtime
|
||||
- TX airtime and RX airtime
|
||||
|
||||
**Packet Statistics**:
|
||||
- Packets sent, received, and duplicates
|
||||
- Broken down by flood vs. direct
|
||||
- Packets sent and received, each broken down by flood vs. direct
|
||||
- Duplicates, broken down by flood vs. direct
|
||||
- Channel utilization (% of uptime used by TX + RX)
|
||||
|
||||
### Key Interactions
|
||||
- Auto-queries the repeater on open; shows a loading spinner until data arrives
|
||||
- On timeout: red snackbar error. On success: data appears with a green snackbar confirmation
|
||||
- Pull-to-refresh or refresh button to re-query
|
||||
- On timeout: red snackbar error. On success: data appears in-place (no extra snackbar)
|
||||
- Pull-to-refresh or refresh button in the app bar to re-query
|
||||
- Routing mode popup and path management dialog in app bar (these controls appear on **all** management sub-screens, not just Status)
|
||||
- Accepts both binary `RESP_CODE_STATUS_RESPONSE` frames and legacy JSON text responses
|
||||
|
||||
---
|
||||
|
||||
@@ -76,7 +80,7 @@ A terminal-style interface for sending commands directly to the repeater.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
- **Quick-command bar** (horizontal scroll): Shortcut buttons for common commands (get name, get radio, get tx, neighbors, ver, advert, clock)
|
||||
- **Quick-command bar** (horizontal scroll): Shortcut buttons for 9 common commands (advert, get name, get radio, get tx, discover.neighbors, neighbors, ver, clock, clock sync)
|
||||
- **Command history list**: Sent commands in primary color, responses in secondary color
|
||||
- **Input bar**: Up/down history arrows, monospace text field with `> ` prefix, send button
|
||||
|
||||
@@ -85,16 +89,20 @@ A terminal-style interface for sending commands directly to the repeater.
|
||||
- Type a command and press send (or Enter on desktop)
|
||||
- Up/down arrows navigate through command history
|
||||
- Quick-command buttons populate and send common commands
|
||||
- Bug report icon: Shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
|
||||
- Overflow menu (three-dot icon): "Debug next command" option shows raw frame debug info for the next typed command (shows error snackbar if input field is empty)
|
||||
- Help icon: Opens a scrollable reference of all known CLI commands. Tapping any command populates the input field immediately
|
||||
- Clear icon: Wipes the command/response history
|
||||
- Failed/timed-out commands are automatically retried once
|
||||
|
||||
### Available CLI Commands
|
||||
|
||||
**General**: `advert`, `reboot`, `clock`, `password`, `ver`, `clear stats`
|
||||
The in-app help reference (help icon) documents all known commands. Categories:
|
||||
|
||||
**Settings**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
|
||||
**General**: `advert`, `advert.zerohop`, `reboot`, `clock`, `clock sync`, `password`, `ver`, `clear stats`, `erase`, `poweroff`, `shutdown`, `clkreboot`, `start ota`, `time`, `board`, `discover.neighbors`, `powersaving`, `stats-packets`, `stats-radio`, `stats-core`
|
||||
|
||||
**Get**: `get name`, `get role`, `get public.key`, `get prv.key`, `get repeat`, `get tx`, `get freq`, `get radio`, `get radio.rxgain`, `get af`, `get dutycycle`, `get int.thresh`, `get agc.reset.interval`, `get multi.acks`, `get allow.read.only`, `get advert.interval`, `get flood.advert.interval`, `get guest.password`, `get lat`, `get lon`, `get rxdelay`, `get txdelay`, `get direct.txdelay`, `get flood.max`, `get owner.info`, `get path.hash.mode`, `get loop.detect`, `get acl`, `get bridge.*`, `get adc.multiplier`, `get bootloader.ver`
|
||||
|
||||
**Set**: `set name`, `set af`, `set tx`, `set repeat`, `set allow.read.only`, `set flood.max`, `set int.thresh`, `set agc.reset.interval`, `set multi.acks`, `set advert.interval`, `set flood.advert.interval`, `set guest.password`, `set lat`, `set lon`, `set freq`, `set radio`, `set rxdelay`, `set txdelay`, `set direct.txdelay`, `set radio.rxgain`, `set dutycycle`, `set loop.detect`, `set path.hash.mode`, `set owner.info`, `set prv.key`, `set bridge.*`, `set adc.multiplier`, `tempradio`, `setperm`
|
||||
|
||||
**Bridge**: `get bridge.type`
|
||||
|
||||
@@ -102,9 +110,13 @@ A terminal-style interface for sending commands directly to the repeater.
|
||||
|
||||
**Neighbors**: `neighbors`, `neighbor.remove`
|
||||
|
||||
**Region Management**: `region`, `region load/get/put/remove/allowf/denyf/home/save`
|
||||
**Power Management**: `get pwrmgt.support`, `get pwrmgt.source`, `get pwrmgt.bootreason`, `get pwrmgt.bootmv`
|
||||
|
||||
**GPS**: `gps`, `gps on/off/sync/setloc/advert`
|
||||
**Sensors**: `sensor get {key}`, `sensor set {key} {value}`, `sensor list [start]`
|
||||
|
||||
**GPS Management**: `gps`, `gps {on|off}`, `gps sync`, `gps setloc`, `gps advert`, `gps advert {none|share|prefs}`
|
||||
|
||||
**Region Management**: `region`, `region load`, `region get`, `region put`, `region remove`, `region allowf`, `region denyf`, `region home`, `region save`, `region default`, `region list allowed`, `region list denied`
|
||||
|
||||
---
|
||||
|
||||
@@ -147,40 +159,63 @@ A card titled "Repeater's Neighbors - N" listing each neighbor as:
|
||||
|
||||
### What the User Sees
|
||||
|
||||
Five configuration cards:
|
||||
Nine configuration cards, each with its own per-field refresh button(s):
|
||||
|
||||
**1. Basic Settings**
|
||||
- Name field
|
||||
- Admin password field
|
||||
- Guest password field
|
||||
- Admin password field (write-only; always sent when non-empty)
|
||||
- Guest password field (write-only; always sent when non-empty)
|
||||
|
||||
**2. Radio Settings**
|
||||
- Frequency (MHz)
|
||||
- TX Power (dBm)
|
||||
- TX Power (dBm) — has its own independent refresh button
|
||||
- Bandwidth dropdown (kHz)
|
||||
- Spreading Factor (SF5–SF12)
|
||||
- Coding Rate (4/5–4/8)
|
||||
- RX Gain boost toggle
|
||||
|
||||
**3. Location Settings**
|
||||
- Latitude and longitude fields
|
||||
- Latitude and longitude fields, each with an independent refresh button
|
||||
|
||||
**4. Features**
|
||||
- Packet forwarding toggle
|
||||
- Guest access toggle
|
||||
- Packet forwarding toggle (`set repeat`)
|
||||
- Guest access toggle (`set allow.read.only`)
|
||||
- Multi-ACKs toggle (`set multi.acks`)
|
||||
- Auto clock sync after login toggle (local app setting only, not sent to repeater)
|
||||
|
||||
**5. Advertisement Settings**
|
||||
**5. Network Health**
|
||||
- Loop detection dropdown (off / minimal / moderate / strict; `set loop.detect`)
|
||||
- Duty cycle slider (1–100%; `set dutycycle`)
|
||||
|
||||
**6. Advertisement Settings**
|
||||
- Local advert interval slider (60–240 minutes) with enable/disable toggle
|
||||
- Flood advert interval slider (3–168 hours) with enable/disable toggle
|
||||
- Flood max hops slider (0–64; `set flood.max`)
|
||||
|
||||
**6. Danger Zone** (red-styled card)
|
||||
- Reboot repeater
|
||||
- Erase filesystem (serial-only warning)
|
||||
**7. Owner Info**
|
||||
- Multi-line text field for operator contact info (`set owner.info`); newlines sent as `|`
|
||||
|
||||
**8. Actions** (one-tap, no save needed)
|
||||
- Send Advertisement (`advert`)
|
||||
- Send Zero-Hop Advertisement (`advert.zerohop`)
|
||||
- Clock Sync (`clock sync`)
|
||||
|
||||
**9. Advanced** (collapsed by default)
|
||||
- Path hash mode dropdown (0–2; `set path.hash.mode`)
|
||||
- TX delay field (`set txdelay`)
|
||||
- Direct TX delay field (`set direct.txdelay`)
|
||||
- Interference threshold field (`set int.thresh`)
|
||||
- AGC reset interval slider (0–240s in multiples of 4; `set agc.reset.interval`)
|
||||
|
||||
**Danger Zone** (red-styled card)
|
||||
- Reboot repeater (sends `reboot` with confirmation dialog)
|
||||
- Erase filesystem (serial-only; shows a confirmation dialog, then an informational snackbar — no command is sent over the air)
|
||||
|
||||
### Key Interactions
|
||||
- **Settings are NOT auto-fetched on open**. Only name and location are pre-filled from locally cached contact data. You must tap each section's refresh button to fetch live values from the repeater
|
||||
- TX Power has its own separate refresh button, independent from the main Radio Settings refresh
|
||||
- Save button appears when changes are detected
|
||||
- Settings are sent sequentially with 200ms delays between commands (fire-and-forget, no per-command acknowledgment wait)
|
||||
- Validation prevents invalid values (e.g., frequency range, LoRa parameter compatibility)
|
||||
- **Settings are NOT auto-fetched on open**. Name is pre-filled from cached contact data. Each section has its own refresh button to fetch live values from the repeater
|
||||
- TX Power, RX Gain, latitude, longitude, and advanced fields each have independent inline refresh buttons
|
||||
- Save button in app bar appears when any change is detected; failed commands keep those fields dirty for retry
|
||||
- Settings are sent sequentially with 200ms delays between commands; firmware responses are checked and partial failures are reported in a snackbar
|
||||
- Some changes (e.g. radio frequency) require a reboot; the firmware response triggers an orange "reboot needed" snackbar
|
||||
- Advertisement interval sliders reset to defaults when re-enabled (local: 60 min, flood: 3 hours)
|
||||
- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access. It is effectively non-functional when connected wirelessly
|
||||
- **Erase Filesystem** does NOT send any command over the air — tapping it only shows a snackbar explaining the operation requires physical serial access
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Routing Paths
|
||||
|
||||
This page covers how MeshCore Open represents, selects, validates, and stores routing paths in the UI and data layer.
|
||||
|
||||
## Path Routing
|
||||
|
||||
MeshCore supports variable-length multi-byte routing paths so the app can scale from small meshes to very large node sets.
|
||||
|
||||
### Hash Width and Multi-Byte Paths
|
||||
|
||||
The device capability determines the hash width (number of bytes per hop):
|
||||
|
||||
| Width | Max Unique Nodes | Typical Use |
|
||||
|-------|-----------------|-------------|
|
||||
| 1 byte | 256 | Single-byte node IDs |
|
||||
| 2 bytes | 65,536 | Medium meshes |
|
||||
| 3 bytes | 16.7M | Large networks |
|
||||
| 4 bytes | 4.3G | Very large meshes / future-proofing |
|
||||
|
||||
### Device Capability Detection
|
||||
|
||||
On device connection, the app reads the firmware capability to set `pathHashByteWidth`:
|
||||
|
||||
```dart
|
||||
// Read from device info response (offset 81)
|
||||
final modeRaw = firmwareBytes.length >= 82
|
||||
? (firmwareBytes[81] & 0xFF)
|
||||
: 0;
|
||||
final mode = modeRaw.clamp(0, 3);
|
||||
_pathHashByteWidth = mode + 1; // 1, 2, 3, or 4 bytes per hop
|
||||
```
|
||||
|
||||
The connector reads a single-mode byte and clamps to `0..3`, so the supported hop width is `1..4` bytes. UI code also clamps widths when rendering (typically to `1..4`) so 4-byte hops are handled end-to-end in the current codebase.
|
||||
|
||||
### Path Data Structure
|
||||
|
||||
Paths in messages and storage consist of:
|
||||
|
||||
- **`pathLength` (model/storage)**: Hop count (number of hops). Negative values (e.g. `-1`) are used as a flood sentinel.
|
||||
- **On-air `path_len` byte**: A packed byte that encodes hop count + hash width and is decoded into `pathLength` + `pathBytes` when parsing frames.
|
||||
- **`pathBytes`**: Raw bytes of the path (concatenated hop prefixes), grouped by `pathHashByteWidth`.
|
||||
- **`hopCount`**: Derived display value computed from bytes and width: `(byteCount + hashByteWidth - 1) ~/ hashByteWidth`.
|
||||
- **Example**: With `pathHashByteWidth=2`, a 3-hop path has 6 bytes (`pathBytes.length = 6`) and `pathLength = 3`:
|
||||
- `pathBytes = [0xA1, 0xA2, 0xB1, 0xB2, 0xC1, 0xC2]`
|
||||
- Hops: `[0xA1A2]`, `[0xB1B2]`, `[0xC1C2]`
|
||||
|
||||
### Hop Count Calculation
|
||||
|
||||
Convert path byte length to hop count:
|
||||
|
||||
```dart
|
||||
int hopCount = (byteCount + hashByteWidth - 1) ~/ hashByteWidth;
|
||||
```
|
||||
|
||||
Use this consistently when displaying hop counts in UI. Do not treat `pathLength` as a hop count when the path uses multi-byte hop hashes.
|
||||
|
||||
### Path Usage in Different Message Types
|
||||
|
||||
- **Direct messages**: Extract path from decrypted payload to trace sender route.
|
||||
- **Channel messages**: Decrypt hop-by-hop routing chain from payload; the header carries the encoded byte length for the path blob, not the derived hop count.
|
||||
- **Contact storage**: Path length in byte 32, raw path bytes in bytes 33-96, grouped by detected `pathHashByteWidth`.
|
||||
|
||||
### UI Hop Count Display
|
||||
|
||||
⚠️ **Important**: In screens like "Channel Message Path", prefer the actual decoded `pathBytes` hop count over `pathLength` metadata:
|
||||
|
||||
```dart
|
||||
// Preferred: use actual observed path length
|
||||
final effectiveHopCount = (pathBytes.length + width - 1) ~/ width;
|
||||
|
||||
// Avoid: using encoded byte length as if it were a hop count
|
||||
// pathLength is bytes; converting it twice causes inflated counts
|
||||
```
|
||||
|
||||
Example scenario:
|
||||
- Radio header reports `pathLength: 32` bytes
|
||||
- Decoded path bytes: `[0xAB, 0xCD]` (2 bytes = 1 hop with width=2)
|
||||
- **Display**: "1 hop" (from `pathBytes`), not "32 hops" (which would double-count the encoded length)
|
||||
|
||||
@@ -28,9 +28,11 @@ The BLE Scanner is the app's home screen, displayed immediately on launch.
|
||||
|
||||
**Device List**: When no devices are found, shows a large Bluetooth icon with a prompt. The prompt text is dynamic: "Searching for devices..." while actively scanning, or "Tap Scan to search" when idle. When devices are found, shows a scrollable list of `DeviceTile` widgets.
|
||||
|
||||
**Bottom FAB Row**: Up to three floating action buttons:
|
||||
- **USB** button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
|
||||
- **TCP/IP** button - Opens TCP connection screen (all non-web platforms)
|
||||
**App Bar Actions**: Icon buttons in the top-right corner of the app bar:
|
||||
- **USB** icon button - Opens USB connection screen (Android, Windows, Linux, macOS, Chrome web only)
|
||||
- **TCP/IP** icon button - Opens TCP connection screen (all non-web platforms)
|
||||
|
||||
**Bottom FAB**: A single floating action button:
|
||||
- **BLE Scan** button - Toggles BLE scanning on/off; shows a spinner when scanning. **Disabled** (greyed out, not tappable) when Bluetooth is off
|
||||
|
||||
### Device Tile
|
||||
@@ -51,7 +53,7 @@ Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon s
|
||||
|
||||
### How Scanning Works
|
||||
|
||||
- Filters for devices with names starting with `MeshCore-` or `Whisper-`
|
||||
- Filters for devices advertising the Nordic UART Service UUID (so community forks with non-standard names are still found). Known name prefixes used by stock firmware builds for reference: `MeshCore-`, `Whisper-`, `WisCore-`, `Seeed`, `Lilygo`, `HT-`, `LowMesh_MC_`, `NRF52`
|
||||
- Uses low-latency scan mode on Android
|
||||
- Scans for 10 seconds then auto-stops
|
||||
- On iOS/macOS, waits for BLE adapter initialization before starting
|
||||
@@ -61,11 +63,11 @@ Note: The weak (-80 to -90 dBm) and poor (< -90 dBm) tiers share the same icon s
|
||||
|
||||
Tap a device tile or its Connect button:
|
||||
1. The connector stops scanning and transitions to "connecting"
|
||||
2. Connects to the device with a 15-second timeout
|
||||
2. Connects to the device with a 15-second timeout (6 seconds on Linux)
|
||||
3. Requests MTU 185 bytes for optimal throughput
|
||||
4. Discovers BLE services and locates the Nordic UART Service
|
||||
5. Subscribes to TX notifications for receiving data
|
||||
6. On success, automatically navigates to the Contacts screen
|
||||
6. On success, automatically navigates to the Channels screen
|
||||
7. On failure, shows a red error snackbar
|
||||
|
||||
---
|
||||
@@ -74,7 +76,7 @@ Tap a device tile or its Connect button:
|
||||
|
||||
### How to Access
|
||||
|
||||
From the Scanner screen, tap the **USB** FAB button.
|
||||
From the Scanner screen, tap the **USB** icon button in the app bar.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
@@ -82,15 +84,15 @@ From the Scanner screen, tap the **USB** FAB button.
|
||||
- A list of detected USB serial ports, each showing:
|
||||
- Friendly display name
|
||||
- Raw port name (subtitle, only shown when it differs from the display name)
|
||||
- "Connect" button
|
||||
- FABs at the bottom to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
|
||||
- Chevron trailing icon (the entire tile is tappable to connect)
|
||||
- Transport switcher buttons (outlined, not FABs) to switch to BLE or TCP (these use `pushReplacement`, so back navigation returns to Scanner, not between USB/TCP)
|
||||
|
||||
### Key Interactions
|
||||
|
||||
- On desktop (Windows, Linux, macOS): ports are polled every 2 seconds for hot-plug detection (polling pauses while connecting/connected)
|
||||
- On mobile: tap the "Scan" FAB to manually refresh
|
||||
- Tap a port or its Connect button to connect
|
||||
- On successful connection, navigates to Contacts screen
|
||||
- Tap a port tile to connect
|
||||
- On successful connection, navigates to Channels screen
|
||||
- On connection failure, the port list automatically refreshes
|
||||
- Platform-specific error messages for common USB failures (permission denied, device missing, device detached, device busy, driver missing, port invalid, timeout, and more)
|
||||
|
||||
@@ -100,7 +102,7 @@ From the Scanner screen, tap the **USB** FAB button.
|
||||
|
||||
### How to Access
|
||||
|
||||
From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||
From the Scanner screen, tap the **TCP/IP** icon button in the app bar.
|
||||
|
||||
### What the User Sees
|
||||
|
||||
@@ -108,7 +110,7 @@ From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||
- **Host address** text field
|
||||
- **Port number** text field
|
||||
- **Connect** button
|
||||
- FABs at the bottom to switch to USB or BLE
|
||||
- Transport switcher buttons (outlined, not FABs) to switch to USB or BLE
|
||||
|
||||
### Key Interactions
|
||||
|
||||
@@ -119,6 +121,6 @@ From the Scanner screen, tap the **TCP/IP** FAB button.
|
||||
- Validation errors are shown as red snackbars
|
||||
- The Connect button shows a spinner and "Connecting..." label while in progress
|
||||
- The status bar shows the specific host:port being connected to (e.g., "Connecting to 192.168.1.1:5000")
|
||||
- On success, navigates to Contacts screen and saves the host/port to settings
|
||||
- On success, navigates to Channels screen and saves the host/port to settings
|
||||
- On connection, the status bar shows the active TCP endpoint (e.g., "Connected to 192.168.1.1:5000")
|
||||
- Error messages for timeout, unsupported platform, and connection failures
|
||||
|
||||
@@ -12,12 +12,13 @@ Settings are only accessible while a device is connected.
|
||||
The settings screen is a scrollable list of cards:
|
||||
|
||||
1. [Device Info](#device-info)
|
||||
2. [App Settings](#app-settings) (link to sub-screen)
|
||||
3. [Node Settings](#node-settings)
|
||||
4. [Actions](#actions)
|
||||
5. [Debug](#debug)
|
||||
2. [Node Settings](#node-settings)
|
||||
3. [Location](#location)
|
||||
4. [App Settings](#app-settings) (link to sub-screen)
|
||||
5. [Actions](#actions)
|
||||
6. [Export](#export)
|
||||
7. [About](#about)
|
||||
7. [Debug](#debug)
|
||||
8. [About](#about)
|
||||
|
||||
---
|
||||
|
||||
@@ -40,46 +41,6 @@ Battery shows an alert icon and orange text when at 15% or below. The toggle onl
|
||||
|
||||
---
|
||||
|
||||
## App Settings
|
||||
|
||||
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
|
||||
|
||||
### Appearance
|
||||
- **Theme**: System / Light / Dark
|
||||
- **Language**: System default or one of 15 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian)
|
||||
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
|
||||
|
||||
### Notifications
|
||||
- **Master enable/disable**: Requests OS permission when enabling
|
||||
- **Message notifications**: New direct message alerts
|
||||
- **Channel message notifications**: New channel message alerts
|
||||
- **Advertisement notifications**: New node discovery alerts
|
||||
|
||||
### Messaging
|
||||
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
|
||||
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
|
||||
- Max Route Weight (1–10, default 5, integer steps)
|
||||
- Initial Route Weight (0.5–5.0, default 3.0)
|
||||
- Success Increment (0.1–2.0, default 0.5, 0.1 steps)
|
||||
- Failure Decrement (0.1–2.0, default 0.2, 0.1 steps)
|
||||
- Max Message Retries (2–10, default 5)
|
||||
|
||||
### Battery
|
||||
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
|
||||
|
||||
### Map Display
|
||||
- **Show Repeaters**: Toggle repeater markers on map
|
||||
- **Show Chat Nodes**: Toggle chat node markers
|
||||
- **Show Other Nodes**: Toggle room/sensor markers
|
||||
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
|
||||
- **Units**: Metric / Imperial
|
||||
- **Offline Map Cache**: Navigate to tile download screen
|
||||
|
||||
### Debug
|
||||
- **App Debug Logging**: Enable the in-app debug log
|
||||
|
||||
---
|
||||
|
||||
## Node Settings
|
||||
|
||||
These settings are sent directly to the connected device firmware.
|
||||
@@ -91,7 +52,7 @@ These settings are sent directly to the connected device firmware.
|
||||
|
||||
### Radio Settings
|
||||
Opens a dialog pre-populated with the device's current radio settings. Contains:
|
||||
- **Preset dropdown**: 19 regional presets — selecting a preset immediately fills all fields below. Full list: Australia, Australia (Narrow), Australia SA/WA/QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, Switzerland, USA Arizona, USA/Canada, Vietnam, Off-Grid 433, Off-Grid 869, Off-Grid 918
|
||||
- **Preset dropdown**: Regional presets — selecting a preset immediately fills all fields below. Includes presets for Australia, Australia (Narrow), Australia (Mid), Australia SA WA QLD, Czech Republic, EU 433MHz, EU/UK (Long Range), EU/UK (Medium Range), EU/UK (Narrow), New Zealand, New Zealand (Narrow), Portugal 433, Portugal 869, numerous Russia city presets, Switzerland, USA Arizona, USA/Canada, and Vietnam
|
||||
- **Frequency** (MHz): Free text, validated 300–2500 MHz
|
||||
- **Bandwidth**: Dropdown (7.8 / 10.4 / 15.6 / 20.8 / 31.25 / 41.7 / 62.5 / 125 / 250 / 500 kHz)
|
||||
- **Spreading Factor**: SF5–SF12
|
||||
@@ -99,6 +60,13 @@ Opens a dialog pre-populated with the device's current radio settings. Contains:
|
||||
- **TX Power** (dBm): Validated 0 to device max (typically 22 dBm)
|
||||
- **Client Repeat** toggle: Only shown on firmware v9+; requires frequency to be exactly 433.000, 869.000, or 918.000 MHz (the Off-Grid presets). Save is blocked with a warning if enabled on other frequencies
|
||||
|
||||
### Companion Radio Stats
|
||||
Opens the RF statistics screen (RSSI, SNR, packet counts) for the paired radio. Only enabled when connected to a device that supports companion radio stats.
|
||||
|
||||
---
|
||||
|
||||
## Location
|
||||
|
||||
### Location
|
||||
Opens a dialog pre-populated with the device's current coordinates (if known):
|
||||
- Latitude and longitude fields (decimal, 6 decimal places). If only one field is provided, the other uses the device's current value
|
||||
@@ -115,8 +83,72 @@ Five toggles controlling which node types are auto-added when heard:
|
||||
- Auto-add Sensors
|
||||
- Overwrite Oldest (when contact list is full)
|
||||
|
||||
### Privacy Mode
|
||||
Opens a confirmation dialog with three buttons: Cancel, Enable, and Disable. Both states can be set from the same dialog regardless of current state. A snackbar confirms which state was applied. When on, the node stops broadcasting its location in advertisements.
|
||||
### Privacy
|
||||
Opens a dialog with controls for how the node shares telemetry and location data:
|
||||
- **Advert Location**: Toggle whether the node broadcasts its location in advertisements
|
||||
- **Multi-Ack**: Toggle multi-ack delivery confirmations
|
||||
- **Telemetry Base Mode**: Deny All / Allow by Contact / Allow All
|
||||
- **Telemetry Location Mode**: Deny All / Allow by Contact / Allow All
|
||||
- **Telemetry Environment Mode**: Deny All / Allow by Contact / Allow All
|
||||
|
||||
Settings take effect when saved. A snackbar confirms the update.
|
||||
|
||||
---
|
||||
|
||||
## App Settings
|
||||
|
||||
A dedicated sub-screen for app-level preferences (nothing here is sent to the device). All settings persist locally via SharedPreferences.
|
||||
|
||||
### Appearance
|
||||
- **Theme**: System / Light / Dark
|
||||
- **Language**: System default or one of 18 languages (English, French, Spanish, German, Polish, Slovenian, Portuguese, Italian, Chinese, Swedish, Dutch, Slovak, Bulgarian, Russian, Ukrainian, Hungarian, Japanese, Korean)
|
||||
|
||||
### Notifications
|
||||
- **Master enable/disable**: Requests OS permission when enabling
|
||||
- **Message notifications**: New direct message alerts
|
||||
- **Channel message notifications**: New channel message alerts
|
||||
- **Advertisement notifications**: New node discovery alerts
|
||||
|
||||
### Messaging
|
||||
- **Clear Path on Max Retry**: Erases the stored routing path after all retries fail
|
||||
- **Jump to Oldest Unread**: When opening a chat, scrolls to the oldest unread message instead of the newest
|
||||
- **Auto Route Rotation**: Enables weighted routing algorithm. When enabled, expands to show five slider sub-settings (hidden when off):
|
||||
- Max Route Weight (1–10, default 5, integer steps)
|
||||
- Initial Route Weight (0.5–5.0, default 3.0)
|
||||
- Success Increment (0.1–2.0, default 0.5, 0.1 steps)
|
||||
- Failure Decrement (0.1–2.0, default 0.2, 0.1 steps)
|
||||
- Max Message Retries (2–10, default 5)
|
||||
- **Enable Message Tracing**: Shows path trace overlays and extra metadata on messages
|
||||
|
||||
### Battery
|
||||
- **Battery Chemistry**: NMC / LiFePO4 / LiPo (per device, used to calibrate percentage from voltage)
|
||||
|
||||
### Map Display
|
||||
- **Show Repeaters**: Toggle repeater markers on map
|
||||
- **Show Chat Nodes**: Toggle chat node markers
|
||||
- **Show Other Nodes**: Toggle room/sensor markers
|
||||
- **Time Filter**: All time / Last 1h / Last 6h / Last 24h / Last week
|
||||
- **Units**: Metric / Imperial
|
||||
- **Raster Tile Source**: Sets the MAP theme and with that from where to get the map tile data:
|
||||
- OpenStreetMap (Auto/Standard/Dark) is provided by the free OpenStreetMap tile server. (Can only be used for live view or already cached.)
|
||||
- Stamen Terrain / AlidadeSmooth Dark / Outdoors / OSM Bright are [StadiaMaps.com raster tile maps](https://stadiamaps.com/products/maps/interactive-basemaps/) for which you can choose an Hosted Endpoint (Worldwide / Europe hosted) and have to provide the API key to your subscription. (You can cache these maps for offline Map usage.)
|
||||
There will be no account provided by meshcore-open. You will have to get you own subscription. StadiaMaps offers a [free](https://stadiamaps.com/pricing/) subscription to download up to 200'000 Standart Raster Basemap tiles.
|
||||
- **Offline Map Cache**: Navigate to tile download screen
|
||||
|
||||
### Translation
|
||||
Not shown on web. Controls on-device message translation powered by a locally-downloaded ML model:
|
||||
- **Enable Translation**: Translates incoming messages into the selected target language
|
||||
- **Translate Composer**: Translates outgoing messages from the target language back before sending
|
||||
- **Target Language**: Language to translate into (searchable list; defaults to the app language)
|
||||
- **Downloaded Model**: Dropdown to select among already-downloaded translation models
|
||||
- **Preset Model**: Download a curated preset model with one tap
|
||||
- **Custom Model URL**: Enter a URL to download a custom GGUF-format model; shows download progress and a cancel button
|
||||
|
||||
### Cyrillic-to-Latin (Cyr2Lat)
|
||||
Controls character substitution profiles used to render Cyrillic text in Latin characters. A dropdown selects the active profile; Add, Edit, and Delete buttons manage the profile list (the last remaining profile cannot be deleted). Each profile stores a JSON character map.
|
||||
|
||||
### Debug
|
||||
- **App Debug Logging**: Enable the in-app debug log
|
||||
|
||||
---
|
||||
|
||||
@@ -126,10 +158,24 @@ One-tap device operations:
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| Send Advertisement | Floods the mesh with your node's advertisement |
|
||||
| Sync Time | Sends current Unix timestamp to the device |
|
||||
| Refresh Contacts | Re-requests the full contact list |
|
||||
| Reboot Device | Confirmation dialog → reboots the device (shown in orange) |
|
||||
| Reboot Device | Confirmation dialog → reboots the device (shown in warning color) |
|
||||
| Delete All Paths | Confirmation dialog → clears all stored routing paths (shown in alert color) |
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
Three GPX export options (not available on web):
|
||||
|
||||
| Option | Exports |
|
||||
|---|---|
|
||||
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
|
||||
| Export Contacts | Chat contacts with GPS coordinates |
|
||||
| Export All | All contacts with GPS coordinates |
|
||||
|
||||
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
|
||||
|
||||
---
|
||||
|
||||
@@ -150,20 +196,6 @@ Structured log entries (Info / Warning / Error), with tag, message, and timestam
|
||||
|
||||
---
|
||||
|
||||
## Export
|
||||
|
||||
Three GPX export options (not available on web):
|
||||
|
||||
| Option | Exports |
|
||||
|---|---|
|
||||
| Export Repeaters | Repeaters and Rooms with GPS coordinates |
|
||||
| Export Contacts | Chat contacts with GPS coordinates |
|
||||
| Export All | All contacts with GPS coordinates |
|
||||
|
||||
Each creates a `.gpx` file and opens the OS share sheet. Feedback via snackbar for four outcomes: success, no contacts with coordinates, feature not available (web), or error.
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
Shows the standard Flutter about dialog with app name, version, and legal notice.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store-connect</string>
|
||||
<key>teamID</key>
|
||||
<string>X74VJ4UAST</string>
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
<key>destination</key>
|
||||
<string>export</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -20,7 +20,5 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0</string>
|
||||
<key>MinimumOSVersion</key>
|
||||
<string>13.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
platform :ios, '15.5'
|
||||
platform :ios, '16.4'
|
||||
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
@@ -32,5 +32,8 @@ end
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '16.4'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,145 +1,22 @@
|
||||
PODS:
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_foreground_task (0.0.1):
|
||||
- Flutter
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- GoogleDataTransport (10.1.0):
|
||||
- nanopb (~> 3.30910.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- GoogleMLKit/BarcodeScanning (7.0.0):
|
||||
- GoogleMLKit/MLKitCore
|
||||
- MLKitBarcodeScanning (~> 6.0.0)
|
||||
- GoogleMLKit/MLKitCore (7.0.0):
|
||||
- MLKitCommon (~> 12.0.0)
|
||||
- GoogleToolboxForMac/Defines (4.2.1)
|
||||
- GoogleToolboxForMac/Logger (4.2.1):
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
|
||||
- GoogleToolboxForMac/Defines (= 4.2.1)
|
||||
- GoogleUtilities/Environment (8.1.0):
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Logger (8.1.0):
|
||||
- GoogleUtilities/Environment
|
||||
- GoogleUtilities/Privacy
|
||||
- GoogleUtilities/Privacy (8.1.0)
|
||||
- GoogleUtilities/UserDefaults (8.1.0):
|
||||
- GoogleUtilities/Logger
|
||||
- GoogleUtilities/Privacy
|
||||
- GTMSessionFetcher/Core (3.5.0)
|
||||
- MLImage (1.0.0-beta6)
|
||||
- MLKitBarcodeScanning (6.0.0):
|
||||
- MLKitCommon (~> 12.0)
|
||||
- MLKitVision (~> 8.0)
|
||||
- MLKitCommon (12.0.0):
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLKitVision (8.0.0):
|
||||
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
|
||||
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
|
||||
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
|
||||
- MLImage (= 1.0.0-beta6)
|
||||
- MLKitCommon (~> 12.0)
|
||||
- mobile_scanner (6.0.2):
|
||||
- Flutter
|
||||
- GoogleMLKit/BarcodeScanning (~> 7.0.0)
|
||||
- nanopb (3.30910.0):
|
||||
- nanopb/decode (= 3.30910.0)
|
||||
- nanopb/encode (= 3.30910.0)
|
||||
- nanopb/decode (3.30910.0)
|
||||
- nanopb/encode (3.30910.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- PromisesObjC (2.4.0)
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- flutter_foreground_task (from `.symlinks/plugins/flutter_foreground_task/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
- GoogleUtilities
|
||||
- GTMSessionFetcher
|
||||
- MLImage
|
||||
- MLKitBarcodeScanning
|
||||
- MLKitCommon
|
||||
- MLKitVision
|
||||
- nanopb
|
||||
- PromisesObjC
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
flutter_foreground_task:
|
||||
:path: ".symlinks/plugins/flutter_foreground_task/ios"
|
||||
flutter_local_notifications:
|
||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: 20a08bfeaa0f7804d524858d3d8744bcc1b6dbc3
|
||||
flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89
|
||||
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleMLKit: eff9e23ec1d90ea4157a1ee2e32a4f610c5b3318
|
||||
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
|
||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
|
||||
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
|
||||
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
|
||||
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
|
||||
mobile_scanner: af8f71879eaba2bbcb4d86c6a462c3c0e7f23036
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
||||
PODFILE CHECKSUM: 570da2a631486c6bd6496bed1e605e63e2471be5
|
||||
PODFILE CHECKSUM: e42b502c78c33aa1ed9d42eaea8960ce2139504b
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -52,6 +53,7 @@
|
||||
718BC7DCCFC5C370705C12E5 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -67,6 +69,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
9A698254711B63C3940A64CB /* libPods-Runner.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -85,6 +88,7 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -179,13 +183,16 @@
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */,
|
||||
F0D7F2413C6E4B7A9B1C2D3E /* Fix Native Asset Minimum OS */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -219,6 +226,9 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -282,23 +292,6 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
B788CEDB957A87EE8AC593BB /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
DE3B2E091393835C0B38492E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -321,6 +314,22 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
F0D7F2413C6E4B7A9B1C2D3E /* Fix Native Asset Minimum OS */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}",
|
||||
);
|
||||
name = "Fix Native Asset Minimum OS";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "set -e\nFRAMEWORKS_DIR=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"\nMIN_OS=\"${IPHONEOS_DEPLOYMENT_TARGET}\"\nif [ ! -d \"$FRAMEWORKS_DIR\" ] || [ -z \"$MIN_OS\" ]; then\n exit 0\nfi\nfind \"$FRAMEWORKS_DIR\" -maxdepth 2 -name Info.plist | while read -r plist; do\n bundle_id=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' \"$plist\" 2>/dev/null || true)\n case \"$bundle_id\" in\n io.flutter.flutter.native-assets.*)\n /usr/libexec/PlistBuddy -c \"Set :MinimumOSVersion $MIN_OS\" \"$plist\" 2>/dev/null || \\\n /usr/libexec/PlistBuddy -c \"Add :MinimumOSVersion string $MIN_OS\" \"$plist\"\n ;;\n esac\ndone\n";
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -414,7 +423,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -429,7 +438,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = X74VJ4UAST;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -540,7 +551,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -591,7 +602,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.4;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = iphoneos;
|
||||
@@ -608,7 +619,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = X74VJ4UAST;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -630,7 +643,9 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = X74VJ4UAST;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@@ -679,6 +694,20 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCLocalSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = FlutterGeneratedPluginSwiftPackage;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,24 @@
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Run Prepare Flutter Framework Script"
|
||||
scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||
BuildableName = "Runner.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
||||
@@ -2,12 +2,15 @@ import Flutter
|
||||
import UIKit
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@@ -22,8 +24,48 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app uses the camera to scan QR codes for joining communities.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>This app lets you pick a photo to compress and send over the mesh.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
<false/>
|
||||
<key>UISceneConfigurations</key>
|
||||
<dict>
|
||||
<key>UIWindowSceneSessionRoleApplication</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>UISceneClassName</key>
|
||||
<string>UIWindowScene</string>
|
||||
<key>UISceneConfigurationName</key>
|
||||
<string>flutter</string>
|
||||
<key>UISceneDelegateClassName</key>
|
||||
<string>FlutterSceneDelegate</string>
|
||||
<key>UISceneStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
@@ -41,24 +83,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
</array>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>This app uses Bluetooth to communicate with MeshCore devices.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>This app uses the camera to scan QR codes for joining communities.</string>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -19,6 +19,7 @@ class MeshCoreUsbManager {
|
||||
String? get activePortKey => _activePortKey;
|
||||
String? get activePortDisplayLabel => _activePortLabel ?? _activePortKey;
|
||||
bool get isConnected => _service.isConnected;
|
||||
Object? get lastError => _service.lastError;
|
||||
Stream<Uint8List> get frameStream => _service.frameStream;
|
||||
|
||||
// --- Configuration ---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
// Buffer Reader - sequential binary data reader with pointer tracking
|
||||
@@ -206,6 +207,8 @@ const int cmdSendTelemetryReq = 39;
|
||||
const int cmdGetCustomVar = 40;
|
||||
const int cmdSetCustomVar = 41;
|
||||
const int cmdSendBinaryReq = 50;
|
||||
const int cmdSetFloodScope = 54;
|
||||
const int cmdSendControlData = 55;
|
||||
const int cmdGetStats = 56;
|
||||
const int cmdSendAnonReq = 57;
|
||||
const int cmdSetAutoAddConfig = 58;
|
||||
@@ -224,6 +227,18 @@ const int reqTypeGetTelemetry = 0x03;
|
||||
const int reqTypeGetAccessList = 0x05;
|
||||
const int reqTypeGetNeighbors = 0x06;
|
||||
|
||||
Uint8List buildTelemetryBinaryPayload() {
|
||||
// Room servers/repeaters read byte 1 as an inverse telemetry permission mask.
|
||||
// Zero means "request every telemetry field allowed for this contact".
|
||||
return Uint8List.fromList([reqTypeGetTelemetry, 0x00, 0x00, 0x00, 0x00]);
|
||||
}
|
||||
|
||||
const int anonReqTypeRegions = 0x01;
|
||||
|
||||
// Control data sub-types used by MeshCore discovery packets.
|
||||
const int controlSubtypeDiscoverReq = 0x08;
|
||||
const int controlSubtypeDiscoverResp = 0x09;
|
||||
|
||||
// Repeater response codes
|
||||
const int respServerLoginOk = 0;
|
||||
|
||||
@@ -266,6 +281,7 @@ const int pushCodeTraceData = 0x89;
|
||||
const int pushCodeNewAdvert = 0x8A;
|
||||
const int pushCodeTelemetryResponse = 0x8B;
|
||||
const int pushCodeBinaryResponse = 0x8C;
|
||||
const int pushCodeControlData = 0x8E;
|
||||
|
||||
// Contact/advertisement types
|
||||
const int advTypeChat = 1;
|
||||
@@ -320,7 +336,7 @@ const int maxPathSize = 64;
|
||||
const int pathHashSize = 1;
|
||||
const int maxNameSize = 32;
|
||||
const int maxFrameSize = 172;
|
||||
const int appProtocolVersion = 3;
|
||||
const int appProtocolVersion = 4;
|
||||
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
|
||||
const int maxTextPayloadBytes = 160;
|
||||
const int _sendTextMsgOverheadBytes =
|
||||
@@ -451,8 +467,13 @@ String pubKeyToHex(Uint8List pubKey) {
|
||||
|
||||
// Helper to convert hex string to public key
|
||||
Uint8List hexToPubKey(String hex) {
|
||||
if (hex.length != pubKeySize * 2) {
|
||||
throw FormatException(
|
||||
'Public key hex must be ${pubKeySize * 2} chars, got ${hex.length}',
|
||||
);
|
||||
}
|
||||
final result = Uint8List(pubKeySize);
|
||||
for (int i = 0; i < pubKeySize && i * 2 + 1 < hex.length; i++) {
|
||||
for (int i = 0; i < pubKeySize; i++) {
|
||||
result[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
|
||||
}
|
||||
return result;
|
||||
@@ -566,9 +587,9 @@ Uint8List buildGetStatsFrame(int statsType) {
|
||||
return Uint8List.fromList([cmdGetStats, statsType & 0xFF]);
|
||||
}
|
||||
|
||||
/// Path hash width on air: [61][0][mode], mode 0..2 → (mode+1) bytes per hop hash.
|
||||
/// Path hash width on air: [61][0][mode], mode 0..3 → (mode+1) bytes per hop hash.
|
||||
Uint8List buildSetPathHashModeFrame(int mode) {
|
||||
final m = mode.clamp(0, 2);
|
||||
final m = mode.clamp(0, 3).toInt();
|
||||
return Uint8List.fromList([cmdSetPathHashMode, 0, m]);
|
||||
}
|
||||
|
||||
@@ -720,26 +741,20 @@ Uint8List buildUpdateContactPathFrame(
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(timestamp);
|
||||
|
||||
if ((lat == null || lon == null) && lastModified != null) {
|
||||
// If lat/lon not provided, write zeros
|
||||
writer.writeInt32LE(0);
|
||||
writer.writeInt32LE(0);
|
||||
} else {
|
||||
// Latitude and Longitude are expected in degrees, convert to int by multiplying by 1e6
|
||||
// Latitude
|
||||
final latitude = lat ?? 0.0;
|
||||
writer.writeInt32LE((latitude * 1e6).round());
|
||||
|
||||
// Longitude
|
||||
final longitude = lon ?? 0.0;
|
||||
writer.writeInt32LE((longitude * 1e6).round());
|
||||
}
|
||||
|
||||
// Optional [Lat x4, Lon x4][timestamp x4] tail per the doc comment above.
|
||||
// Emit 8 bytes of position (zero-filled when only lastModified is provided)
|
||||
// followed by an optional 4-byte timestamp. Earlier code emitted the
|
||||
// position block twice, which corrupted the tail and caused the firmware
|
||||
// to parse the second lat as the timestamp. See #427.
|
||||
final hasLocation = lat != null && lon != null;
|
||||
if (hasLocation || lastModified != null) {
|
||||
writer.writeInt32LE(hasLocation ? (lat * 1e6).round() : 0);
|
||||
writer.writeInt32LE(hasLocation ? (lon * 1e6).round() : 0);
|
||||
if (lastModified != null) {
|
||||
// Last modified
|
||||
final lastModifiedTimestamp = lastModified.millisecondsSinceEpoch ~/ 1000;
|
||||
writer.writeUInt32LE(lastModifiedTimestamp);
|
||||
}
|
||||
}
|
||||
|
||||
return writer.toBytes();
|
||||
}
|
||||
@@ -861,6 +876,67 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List buildSendControlDataFrame(Uint8List payload) {
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendControlData);
|
||||
writer.writeBytes(payload);
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List buildDiscoveryRequestPayload(
|
||||
int tag, {
|
||||
bool prefixOnly = false,
|
||||
int typeMask = 1 << advTypeRepeater,
|
||||
}) {
|
||||
final writer = BufferWriter();
|
||||
// The high bit must be set for CMD_SEND_CONTROL_DATA; DISCOVER_REQ uses
|
||||
// subtype 0x8, with the low bit selecting short/full public keys in replies.
|
||||
writer.writeByte(
|
||||
(controlSubtypeDiscoverReq << 4) | (prefixOnly ? 0x01 : 0x00),
|
||||
);
|
||||
writer.writeByte(typeMask);
|
||||
writer.writeUInt32LE(tag);
|
||||
writer.writeUInt32LE(0); // since=0 asks nearby nodes for any recent advert.
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
Uint8List _reversePathByHop(Uint8List path, int pathHashWidth) {
|
||||
if (path.isEmpty) return Uint8List(0);
|
||||
final width = pathHashWidth.clamp(1, 4).toInt();
|
||||
if (path.length % width != 0) {
|
||||
return Uint8List.fromList(path.reversed.toList());
|
||||
}
|
||||
|
||||
final reversed = Uint8List(path.length);
|
||||
final hops = path.length ~/ width;
|
||||
for (var i = 0; i < hops; i++) {
|
||||
final from = (hops - 1 - i) * width;
|
||||
reversed.setRange(i * width, (i + 1) * width, path, from);
|
||||
}
|
||||
return reversed;
|
||||
}
|
||||
|
||||
// Build CMD_SEND_ANON_REQ frame.
|
||||
// Payload format for regions: [anon_req_type][reply_path_len][reply_path...].
|
||||
Uint8List buildSendAnonReqFrame(
|
||||
Uint8List repeaterPubKey, {
|
||||
required int requestType,
|
||||
Uint8List? replyPath,
|
||||
int replyHopCount = 0,
|
||||
int pathHashWidth = pathHashSize,
|
||||
}) {
|
||||
final width = pathHashWidth.clamp(1, 4).toInt();
|
||||
final path = replyPath ?? Uint8List(0);
|
||||
final encodedPathLen = ((width - 1) << 6) | (replyHopCount & 0x3F);
|
||||
final writer = BufferWriter();
|
||||
writer.writeByte(cmdSendAnonReq);
|
||||
writer.writeBytes(repeaterPubKey);
|
||||
writer.writeByte(requestType);
|
||||
writer.writeByte(encodedPathLen);
|
||||
writer.writeBytes(_reversePathByHop(path, width));
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
//Build a trace request frame
|
||||
//[cmd][tag x4][auth x4][flag][payload]
|
||||
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
|
||||
@@ -951,7 +1027,22 @@ Uint8List buildSendTelemetryReq(Uint8List? pubKey) {
|
||||
writer.writeBytes(Uint8List(3)); // reserved bytes
|
||||
writer.writeBytes(pubKey);
|
||||
} else {
|
||||
writer.writeBytes(Uint8List(4)); // reserved bytes
|
||||
writer.writeBytes(Uint8List(3)); // reserved bytes
|
||||
}
|
||||
return writer.toBytes();
|
||||
}
|
||||
|
||||
//Build CMD_SET_FLOOD_SCOPE
|
||||
// Format: [cmd][scope]
|
||||
Uint8List buildSetFloodScopeFrame(String region) {
|
||||
if (region == '') {
|
||||
// reset scope
|
||||
return Uint8List.fromList([cmdSetFloodScope, 0]);
|
||||
}
|
||||
|
||||
final name = region.startsWith('#') ? region : '#$region';
|
||||
final hash = crypto.sha256.convert(utf8.encode(name)).bytes;
|
||||
final scope = Uint8List.fromList(hash.sublist(0, 16));
|
||||
|
||||
return Uint8List.fromList([cmdSetFloodScope, 0, ...scope]);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,18 @@ class MeshCoreUuids {
|
||||
static const String rxCharacteristic = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
static const String txCharacteristic = "6e400003-b5a3-f393-e0a9-e50e24dcca9e";
|
||||
|
||||
/// Known advertised-name prefixes used by stock MeshCore firmware builds.
|
||||
/// Discovery no longer filters on these (it filters on the [service] UUID so
|
||||
/// that community forks with custom names are still found); kept for
|
||||
/// reference and possible future display heuristics.
|
||||
static const List<String> deviceNamePrefixes = [
|
||||
"MeshCore-",
|
||||
"Whisper-",
|
||||
"WisCore-",
|
||||
"Seeed",
|
||||
"Lilygo",
|
||||
"HT-",
|
||||
"LowMesh_MC_",
|
||||
"NRF52",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -96,6 +96,34 @@ class CayenneLpp {
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case lppDigitalInput:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppDigitalOutput:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppAnalogInput:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 100,
|
||||
});
|
||||
break;
|
||||
case lppAnalogOutput:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE() / 100,
|
||||
});
|
||||
break;
|
||||
case lppGenericSensor:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
@@ -131,6 +159,17 @@ class CayenneLpp {
|
||||
'value': buffer.readUInt8() / 2,
|
||||
});
|
||||
break;
|
||||
case lppAccelerometer:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'x': buffer.readInt16BE() / 1000,
|
||||
'y': buffer.readInt16BE() / 1000,
|
||||
'z': buffer.readInt16BE() / 1000,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
@@ -138,6 +177,13 @@ class CayenneLpp {
|
||||
'value': buffer.readUInt16BE() / 10,
|
||||
});
|
||||
break;
|
||||
case lppAltitude:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppVoltage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
@@ -152,6 +198,13 @@ class CayenneLpp {
|
||||
'value': buffer.readInt16BE() / 1000,
|
||||
});
|
||||
break;
|
||||
case lppFrequency:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE(),
|
||||
});
|
||||
break;
|
||||
case lppPercentage:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
@@ -173,6 +226,56 @@ class CayenneLpp {
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppDistance:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE() / 1000,
|
||||
});
|
||||
break;
|
||||
case lppEnergy:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE() / 1000,
|
||||
});
|
||||
break;
|
||||
case lppDirection:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt16BE(),
|
||||
});
|
||||
break;
|
||||
case lppUnixTime:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt32BE(),
|
||||
});
|
||||
break;
|
||||
case lppGyrometer:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'x': buffer.readInt16BE() / 100,
|
||||
'y': buffer.readInt16BE() / 100,
|
||||
'z': buffer.readInt16BE() / 100,
|
||||
},
|
||||
});
|
||||
break;
|
||||
case lppColour:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'red': buffer.readUInt8(),
|
||||
'green': buffer.readUInt8(),
|
||||
'blue': buffer.readUInt8(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
case lppGps:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
@@ -184,6 +287,24 @@ class CayenneLpp {
|
||||
},
|
||||
});
|
||||
break;
|
||||
case lppSwitch:
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': buffer.readUInt8(),
|
||||
});
|
||||
break;
|
||||
case lppPolyline:
|
||||
final size = buffer.readUInt8();
|
||||
telemetry.add({
|
||||
'channel': channel,
|
||||
'type': type,
|
||||
'value': {
|
||||
'size': size,
|
||||
'data': _bytesToHex(_readPolylinePayload(buffer, size)),
|
||||
},
|
||||
});
|
||||
break;
|
||||
default:
|
||||
return telemetry;
|
||||
}
|
||||
@@ -216,6 +337,19 @@ class CayenneLpp {
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case lppDigitalInput:
|
||||
channelData['values']['digitalInput'] = buffer.readUInt8();
|
||||
break;
|
||||
case lppDigitalOutput:
|
||||
channelData['values']['digitalOutput'] = buffer.readUInt8();
|
||||
break;
|
||||
case lppAnalogInput:
|
||||
channelData['values']['analogInput'] = buffer.readInt16BE() / 100.0;
|
||||
break;
|
||||
case lppAnalogOutput:
|
||||
channelData['values']['analogOutput'] =
|
||||
buffer.readInt16BE() / 100.0;
|
||||
break;
|
||||
case lppGenericSensor:
|
||||
channelData['values']['generic'] = buffer.readUInt32BE();
|
||||
break;
|
||||
@@ -231,15 +365,29 @@ class CayenneLpp {
|
||||
case lppRelativeHumidity:
|
||||
channelData['values']['humidity'] = buffer.readUInt8() / 2.0;
|
||||
break;
|
||||
case lppAccelerometer:
|
||||
channelData['values']['accelerometer'] = {
|
||||
'x': buffer.readInt16BE() / 1000.0,
|
||||
'y': buffer.readInt16BE() / 1000.0,
|
||||
'z': buffer.readInt16BE() / 1000.0,
|
||||
};
|
||||
break;
|
||||
case lppBarometricPressure:
|
||||
channelData['values']['pressure'] = buffer.readUInt16BE() / 10.0;
|
||||
break;
|
||||
case lppAltitude:
|
||||
// MeshCore encodes standalone barometric altitude as LPP type 121.
|
||||
channelData['values']['altitude'] = buffer.readInt16BE();
|
||||
break;
|
||||
case lppVoltage:
|
||||
channelData['values']['voltage'] = buffer.readInt16BE() / 100.0;
|
||||
break;
|
||||
case lppCurrent:
|
||||
channelData['values']['current'] = buffer.readInt16BE() / 1000.0;
|
||||
break;
|
||||
case lppFrequency:
|
||||
channelData['values']['frequency'] = buffer.readUInt32BE();
|
||||
break;
|
||||
case lppPercentage:
|
||||
channelData['values']['percentage'] = buffer.readUInt8();
|
||||
break;
|
||||
@@ -249,6 +397,32 @@ class CayenneLpp {
|
||||
case lppPower:
|
||||
channelData['values']['power'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppDistance:
|
||||
channelData['values']['distance'] = buffer.readUInt32BE() / 1000.0;
|
||||
break;
|
||||
case lppEnergy:
|
||||
channelData['values']['energy'] = buffer.readUInt32BE() / 1000.0;
|
||||
break;
|
||||
case lppDirection:
|
||||
channelData['values']['direction'] = buffer.readUInt16BE();
|
||||
break;
|
||||
case lppUnixTime:
|
||||
channelData['values']['time'] = buffer.readUInt32BE();
|
||||
break;
|
||||
case lppGyrometer:
|
||||
channelData['values']['gyrometer'] = {
|
||||
'x': buffer.readInt16BE() / 100.0,
|
||||
'y': buffer.readInt16BE() / 100.0,
|
||||
'z': buffer.readInt16BE() / 100.0,
|
||||
};
|
||||
break;
|
||||
case lppColour:
|
||||
channelData['values']['colour'] = {
|
||||
'red': buffer.readUInt8(),
|
||||
'green': buffer.readUInt8(),
|
||||
'blue': buffer.readUInt8(),
|
||||
};
|
||||
break;
|
||||
case lppGps:
|
||||
channelData['values']['gps'] = {
|
||||
'latitude': buffer.readInt24BE() / 10000.0,
|
||||
@@ -256,22 +430,48 @@ class CayenneLpp {
|
||||
'altitude': buffer.readInt24BE() / 100.0,
|
||||
};
|
||||
break;
|
||||
// Add more types as needed...
|
||||
case lppSwitch:
|
||||
channelData['values']['switch'] = buffer.readUInt8() != 0;
|
||||
break;
|
||||
case lppPolyline:
|
||||
final size = buffer.readUInt8();
|
||||
channelData['values']['polyline'] = {
|
||||
'size': size,
|
||||
'data': _bytesToHex(_readPolylinePayload(buffer, size)),
|
||||
};
|
||||
break;
|
||||
default:
|
||||
//Stopped parsing to avoid misalignment
|
||||
return channels.values.toList();
|
||||
// Stop parsing to avoid losing alignment on an unknown LPP type.
|
||||
return _sortedChannelValues(channels);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
return _sortedChannelValues(channels);
|
||||
} catch (e) {
|
||||
// Handle parsing errors, possibly due to malformed data
|
||||
appLogger.error('Error parsing Cayenne LPP data: $e');
|
||||
return <
|
||||
Map<String, dynamic>
|
||||
>[]; // Return an empty list on error to avoid crashing the app
|
||||
// Preserve any fields parsed before the malformed value.
|
||||
return _sortedChannelValues(channels);
|
||||
}
|
||||
}
|
||||
|
||||
static Uint8List _readPolylinePayload(BufferReader buffer, int size) {
|
||||
final declaredPayloadSize = size > 0 ? size - 1 : 0;
|
||||
final availablePayloadSize = declaredPayloadSize <= buffer.remaining
|
||||
? declaredPayloadSize
|
||||
: buffer.remaining;
|
||||
return buffer.readBytes(availablePayloadSize);
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _sortedChannelValues(
|
||||
Map<int, Map<String, dynamic>> channels,
|
||||
) {
|
||||
final channelsOut = channels.values.toList();
|
||||
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
|
||||
return channelsOut;
|
||||
}
|
||||
|
||||
static String _bytesToHex(Uint8List bytes) {
|
||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,25 @@ class ChatScrollController extends ScrollController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Jumps toward an off-screen message so that lazy ListView.builder builds
|
||||
/// items near it. Only visible + cacheExtent items have real heights, so we
|
||||
/// use proportion of maxScrollExtent (itself an estimate from built items'
|
||||
/// avg height). Call [onJumped] on the next frame to ensureVisible/scroll
|
||||
/// to the exact target.
|
||||
void jumpToEstimatedOffset({
|
||||
required int unreadCount,
|
||||
required int totalMessages,
|
||||
required VoidCallback onJumped,
|
||||
}) {
|
||||
if (!hasClients || totalMessages == 0) return;
|
||||
final maxExtent = position.maxScrollExtent;
|
||||
final jumpOffset = maxExtent * (unreadCount / totalMessages);
|
||||
if (jumpOffset > 100) {
|
||||
jumpTo(jumpOffset);
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => onJumped());
|
||||
}
|
||||
|
||||
void scrollToBottomIfAtBottom() {
|
||||
// Only scroll if jump button is NOT showing (i.e., already at bottom)
|
||||
if (!showJumpToBottom.value && hasClients && position.maxScrollExtent > 0) {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
|
||||
IconData contactTypeIcon(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Icons.chat;
|
||||
case advTypeRepeater:
|
||||
return Icons.cell_tower;
|
||||
case advTypeRoom:
|
||||
return Icons.group;
|
||||
case advTypeSensor:
|
||||
return Icons.sensors;
|
||||
default:
|
||||
return Icons.device_unknown;
|
||||
}
|
||||
}
|
||||
|
||||
Color contactTypeColor(int type) {
|
||||
switch (type) {
|
||||
case advTypeChat:
|
||||
return Colors.blue;
|
||||
case advTypeRepeater:
|
||||
return Colors.orange;
|
||||
case advTypeRoom:
|
||||
return Colors.purple;
|
||||
case advTypeSensor:
|
||||
return Colors.green;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Color colorForName(String name) {
|
||||
const colors = [
|
||||
Colors.blue,
|
||||
Colors.green,
|
||||
Colors.orange,
|
||||
Colors.purple,
|
||||
Colors.pink,
|
||||
Colors.teal,
|
||||
Colors.indigo,
|
||||
Colors.cyan,
|
||||
Colors.amber,
|
||||
Colors.deepOrange,
|
||||
];
|
||||
return colors[name.hashCode.abs() % colors.length];
|
||||
}
|
||||
|
||||
String firstCharacterOrEmoji(String name) {
|
||||
if (name.isEmpty) return '?';
|
||||
final emoji = firstEmoji(name);
|
||||
if (emoji != null) return emoji;
|
||||
final runes = name.runes.toList();
|
||||
if (runes.isEmpty) return '?';
|
||||
return String.fromCharCode(runes[0]).toUpperCase();
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
class Cyr2Lat {
|
||||
static Map<String, String> _charMap = {
|
||||
'А': 'A',
|
||||
'В': 'B',
|
||||
'Е': 'E',
|
||||
'Ё': 'E',
|
||||
'З': '3',
|
||||
'К': 'K',
|
||||
'М': 'M',
|
||||
'Н': 'H',
|
||||
'О': 'O',
|
||||
'Р': 'P',
|
||||
'С': 'C',
|
||||
'Т': 'T',
|
||||
'Х': 'X',
|
||||
'Ь': 'b',
|
||||
'а': 'a',
|
||||
'е': 'e',
|
||||
'ё': 'e',
|
||||
'о': 'o',
|
||||
'р': 'p',
|
||||
'с': 'c',
|
||||
'у': 'y',
|
||||
'х': 'x',
|
||||
};
|
||||
|
||||
static final RegExp _prefixRegExp = RegExp(r'\@\[[\S\s]+\] ');
|
||||
|
||||
static void setCharMap(Map<String, String> charMap) {
|
||||
_charMap = Map.from(charMap);
|
||||
}
|
||||
|
||||
static String encode(String text) {
|
||||
if (text.isEmpty) return text;
|
||||
final buffer = StringBuffer();
|
||||
|
||||
final senderName = extractSenderName(text);
|
||||
final msgText = removeSenderName(text);
|
||||
|
||||
for (final rune in msgText.runes) {
|
||||
final char = String.fromCharCode(rune);
|
||||
buffer.write(_charMap[char] ?? char);
|
||||
}
|
||||
|
||||
return senderName + buffer.toString();
|
||||
}
|
||||
|
||||
static String removeSenderName(String text) {
|
||||
final match = _prefixRegExp.matchAsPrefix(text);
|
||||
if (match != null) {
|
||||
return text.substring(match.end);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
static String extractSenderName(String text) {
|
||||
final match = _prefixRegExp.matchAsPrefix(text);
|
||||
if (match != null) {
|
||||
return match.group(0) ?? '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
class GifHelper {
|
||||
/// Parse a known GIF format, which can be any of:
|
||||
/// g:GIFID
|
||||
/// https://media.giphy.com/media/GIFID/giphy.gif
|
||||
/// https://giphy.com/gifs/Optional-title-with-dashes-GIFID
|
||||
///
|
||||
/// GIFID is a Giphy GIF ID. The https:// is optional (and
|
||||
/// can also be http://). The giphy.com/gifs form can also
|
||||
/// include a trailing slash.
|
||||
///
|
||||
/// Returns null if text is not a valid GIF format
|
||||
static String? parseGif(String text) {
|
||||
final trimmed = text.trim();
|
||||
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
|
||||
if (match != null) {
|
||||
return match.group(1);
|
||||
}
|
||||
final directUrlMatch = RegExp(
|
||||
r'^(?:https?:\/\/)?media\.giphy\.com\/media\/([A-Za-z0-9_-]+)\/giphy\.gif$',
|
||||
).firstMatch(trimmed);
|
||||
if (directUrlMatch != null) {
|
||||
return directUrlMatch.group(1);
|
||||
}
|
||||
// Giphy understands page URLs with just the ID, or any string and a
|
||||
// dash before the ID, and redirects to a page with a dash-separated
|
||||
// title, a dash, and the ID. IDs in this form *probably* can't
|
||||
// contain dashes.
|
||||
final pageMatch = RegExp(
|
||||
r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$',
|
||||
).firstMatch(trimmed);
|
||||
return pageMatch?.group(1);
|
||||
}
|
||||
|
||||
/// Encode a GIF in a format that parseGif() can parse.
|
||||
static String encodeGif(String gifId) {
|
||||
return 'g:$gifId';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../l10n/l10n.dart';
|
||||
import '../widgets/image_send_codec_binding.dart';
|
||||
import '../widgets/image_send_preview_sheet.dart';
|
||||
|
||||
/// Shared "attach an image" flow, used by both the direct-message and channel
|
||||
/// chat screens so the two surfaces cannot drift apart.
|
||||
///
|
||||
/// Picks a photo, then shows [ImageSendPreviewSheet] so the user sees the
|
||||
/// packet count and airtime *before* committing to a send that may occupy the
|
||||
/// channel for seconds. Returns null if the user backed out at either step.
|
||||
///
|
||||
/// The caller owns the actual transmission: this only produces the payload.
|
||||
Future<ImageSendPreviewResult?> pickAndPreviewImage({
|
||||
required BuildContext context,
|
||||
required ImageSendCodec codec,
|
||||
ImagePicker? picker,
|
||||
}) async {
|
||||
final XFile? picked;
|
||||
try {
|
||||
picked = await (picker ?? ImagePicker()).pickImage(
|
||||
source: ImageSource.gallery,
|
||||
// The codec centre-crops to 512x512 anyway, so there is nothing to gain
|
||||
// from decoding a 12 MP original -- but stay well above 512 so the crop
|
||||
// still has detail to work with.
|
||||
maxWidth: 2048,
|
||||
maxHeight: 2048,
|
||||
);
|
||||
} on Exception catch (e) {
|
||||
debugPrint('image pick failed: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (picked == null) return null; // user cancelled the picker
|
||||
|
||||
final Uint8List bytes;
|
||||
int originalBytes;
|
||||
try {
|
||||
bytes = await picked.readAsBytes();
|
||||
originalBytes = bytes.length;
|
||||
if (!kIsWeb) {
|
||||
// length() is the on-disk size, which is what we want to show against the
|
||||
// transmitted size; fall back to the in-memory length if it fails.
|
||||
try {
|
||||
originalBytes = await File(picked.path).length();
|
||||
} on FileSystemException {
|
||||
// keep the in-memory length
|
||||
}
|
||||
}
|
||||
} on Exception catch (e) {
|
||||
debugPrint('image read failed: $e');
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.chat_imagePickFailed)),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!context.mounted) return null;
|
||||
|
||||
return showImageSendPreviewSheet(
|
||||
context: context,
|
||||
imageBytes: bytes,
|
||||
originalFileBytes: originalBytes,
|
||||
codec: codec,
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import '../utils/platform_info.dart';
|
||||
import '../helpers/snack_bar_builder.dart';
|
||||
|
||||
class LinkHandler {
|
||||
static TextStyle defaultLinkStyle(BuildContext context, TextStyle base) {
|
||||
@@ -19,6 +21,7 @@ class LinkHandler {
|
||||
required String text,
|
||||
required TextStyle style,
|
||||
TextStyle? linkStyle,
|
||||
VoidCallback? onSecondaryTap,
|
||||
}) {
|
||||
final effectiveLinkStyle = linkStyle ?? defaultLinkStyle(context, style);
|
||||
const options = LinkifyOptions(humanize: false, defaultToHttps: false);
|
||||
@@ -26,7 +29,7 @@ class LinkHandler {
|
||||
void onOpen(LinkableElement link) => handleLinkTap(context, link.url);
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
return SelectableLinkify(
|
||||
final linkify = SelectableLinkify(
|
||||
text: text,
|
||||
style: style,
|
||||
linkStyle: effectiveLinkStyle,
|
||||
@@ -34,6 +37,14 @@ class LinkHandler {
|
||||
linkifiers: linkifiers,
|
||||
onOpen: onOpen,
|
||||
);
|
||||
if (onSecondaryTap == null) return linkify;
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
if (event.buttons & kSecondaryMouseButton != 0) onSecondaryTap();
|
||||
},
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: linkify,
|
||||
);
|
||||
}
|
||||
return Linkify(
|
||||
text: text,
|
||||
@@ -93,21 +104,19 @@ class LinkHandler {
|
||||
final uri = Uri.parse(url);
|
||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.chat_couldNotOpenLink(url)),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
showDismissibleSnackBar(
|
||||
context,
|
||||
content: Text(context.l10n.chat_invalidLink),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import '../models/contact.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class PathHelper {
|
||||
static String formatPathHex(List<int> pathBytes) {
|
||||
@@ -8,24 +10,82 @@ class PathHelper {
|
||||
.join(',');
|
||||
}
|
||||
|
||||
static String resolvePathNames(
|
||||
List<int> pathBytes,
|
||||
List<Contact> allContacts,
|
||||
) {
|
||||
return pathBytes
|
||||
.map((b) {
|
||||
final hex = b.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
static String hopHex(int byte) {
|
||||
return byte.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
static String formatHopHex(List<int> hopBytes) {
|
||||
return hopBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join();
|
||||
}
|
||||
|
||||
static String? hopName(int byte, List<Contact> allContacts) {
|
||||
final matches = allContacts
|
||||
.where(
|
||||
(c) =>
|
||||
c.publicKey.first == b &&
|
||||
c.publicKey.isNotEmpty &&
|
||||
c.publicKey.first == byte &&
|
||||
(c.type == advTypeRepeater || c.type == advTypeRoom),
|
||||
)
|
||||
.toList();
|
||||
if (matches.isEmpty) return hex;
|
||||
if (matches.isEmpty) return null;
|
||||
if (matches.length == 1) return matches.first.name;
|
||||
return matches.map((c) => c.name).join(' | ');
|
||||
})
|
||||
.join(' \u2192 ');
|
||||
}
|
||||
|
||||
static List<Uint8List> splitPathBytes(
|
||||
List<int> pathBytes,
|
||||
int hashByteWidth,
|
||||
) {
|
||||
if (pathBytes.isEmpty) return const [];
|
||||
|
||||
final width = hashByteWidth.clamp(1, 4).toInt();
|
||||
final hops = <Uint8List>[];
|
||||
for (int i = 0; i < pathBytes.length; i += width) {
|
||||
final endIdx = (i + width).clamp(0, pathBytes.length).toInt();
|
||||
final hopBytes = pathBytes.sublist(i, endIdx);
|
||||
if (hopBytes.isNotEmpty) {
|
||||
hops.add(Uint8List.fromList(hopBytes));
|
||||
}
|
||||
}
|
||||
return hops;
|
||||
}
|
||||
|
||||
/// Resolves path bytes to contact names, supporting multi-byte hash widths.
|
||||
///
|
||||
/// Groups path bytes according to [hashByteWidth]:
|
||||
/// - 1: Single byte per hop (256 unique nodes)
|
||||
/// - 2: Two bytes per hop (65K unique nodes)
|
||||
/// - 3: Three bytes per hop (16M unique nodes)
|
||||
/// - 4: Four bytes per hop (4.3G unique nodes)
|
||||
static String resolvePathNames(
|
||||
List<int> pathBytes,
|
||||
List<Contact> allContacts,
|
||||
int hashByteWidth,
|
||||
) {
|
||||
if (pathBytes.isEmpty) return '';
|
||||
|
||||
final parts = <String>[];
|
||||
|
||||
for (final hopBytes in splitPathBytes(pathBytes, hashByteWidth)) {
|
||||
final hex = formatHopHex(hopBytes);
|
||||
|
||||
final matches = allContacts.where((c) {
|
||||
if (c.publicKey.length < hopBytes.length) return false;
|
||||
if (c.type != advTypeRepeater && c.type != advTypeRoom) return false;
|
||||
return listEquals(c.publicKey.sublist(0, hopBytes.length), hopBytes);
|
||||
}).toList();
|
||||
|
||||
if (matches.isEmpty) {
|
||||
parts.add(hex);
|
||||
} else if (matches.length == 1) {
|
||||
parts.add(matches.first.name);
|
||||
} else {
|
||||
parts.add(matches.map((c) => c.name).join(' | '));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' \u2192 ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import 'path_helper.dart';
|
||||
import '../models/contact.dart';
|
||||
|
||||
class PathHopResolver {
|
||||
const PathHopResolver._();
|
||||
|
||||
static List<Contact?> resolve({
|
||||
required List<int> pathBytes,
|
||||
required List<Contact> contacts,
|
||||
LatLng? endpoint,
|
||||
bool resolveFromEnd = false,
|
||||
int pathHashByteWidth = 1,
|
||||
}) {
|
||||
final width = pathHashByteWidth.clamp(1, 4).toInt();
|
||||
final candidatesByPrefix = <String, List<Contact>>{};
|
||||
for (final contact in contacts) {
|
||||
if (contact.publicKey.length < width) continue;
|
||||
if (contact.type != advTypeRepeater && contact.type != advTypeRoom) {
|
||||
continue;
|
||||
}
|
||||
final prefix = PathHelper.formatHopHex(
|
||||
contact.publicKey.sublist(0, width),
|
||||
);
|
||||
candidatesByPrefix.putIfAbsent(prefix, () => <Contact>[]).add(contact);
|
||||
}
|
||||
for (final candidates in candidatesByPrefix.values) {
|
||||
candidates.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
}
|
||||
|
||||
final hops = PathHelper.splitPathBytes(pathBytes, width);
|
||||
final resolved = List<Contact?>.filled(hops.length, null);
|
||||
final indexes = resolveFromEnd
|
||||
? List<int>.generate(hops.length, (i) => hops.length - 1 - i)
|
||||
: List<int>.generate(hops.length, (i) => i);
|
||||
final distance = Distance();
|
||||
var previousPosition = endpoint;
|
||||
|
||||
for (final index in indexes) {
|
||||
final candidates =
|
||||
candidatesByPrefix[PathHelper.formatHopHex(hops[index])];
|
||||
if (candidates == null || candidates.isEmpty) continue;
|
||||
|
||||
var bestIndex = 0;
|
||||
if (previousPosition != null && candidates.length > 1) {
|
||||
double? nearestDistance;
|
||||
for (var i = 0; i < candidates.length; i++) {
|
||||
final position = _positionOf(candidates[i]);
|
||||
if (position == null) continue;
|
||||
final candidateDistance = distance(previousPosition, position);
|
||||
if (nearestDistance == null || candidateDistance < nearestDistance) {
|
||||
nearestDistance = candidateDistance;
|
||||
bestIndex = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final contact = candidates.removeAt(bestIndex);
|
||||
resolved[index] = contact;
|
||||
previousPosition = _positionOf(contact) ?? previousPosition;
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static LatLng? _positionOf(Contact contact) {
|
||||
if (!contact.hasLocation ||
|
||||
contact.latitude == null ||
|
||||
contact.longitude == null) {
|
||||
return null;
|
||||
}
|
||||
return LatLng(contact.latitude!, contact.longitude!);
|
||||
}
|
||||
}
|
||||
@@ -109,4 +109,9 @@ class ReactionHelper {
|
||||
|
||||
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
|
||||
}
|
||||
|
||||
/// Encode a reaction message that parseReaction() can parse.
|
||||
static String encodeReaction(String hash, String emojiIndex) {
|
||||
return 'r:$hash:$emojiIndex';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// showDismissibleSnackBar shows a [SnackBar] with tap to dismiss
|
||||
// all other properties are default and optional
|
||||
void showDismissibleSnackBar(
|
||||
BuildContext context, {
|
||||
Key? key,
|
||||
required Widget content,
|
||||
Color? backgroundColor,
|
||||
double? elevation,
|
||||
EdgeInsetsGeometry? margin,
|
||||
EdgeInsetsGeometry? padding,
|
||||
double? width,
|
||||
ShapeBorder? shape,
|
||||
HitTestBehavior? hitTestBehavior,
|
||||
SnackBarBehavior? behavior,
|
||||
SnackBarAction? action,
|
||||
double? actionOverflowThreshold,
|
||||
bool? showCloseIcon,
|
||||
Color? closeIconColor,
|
||||
Duration? duration,
|
||||
bool? persist,
|
||||
Animation<double>? animation,
|
||||
void Function()? onVisible,
|
||||
DismissDirection? dismissDirection,
|
||||
Clip? clipBehavior,
|
||||
}) {
|
||||
// Callers often reach here after an async gap; the context may already be
|
||||
// unmounted, or deactivated (popped but not yet disposed) — ancestor
|
||||
// lookups on a deactivated element throw. Showing nothing is the right
|
||||
// outcome in both cases.
|
||||
if (!context.mounted) return;
|
||||
var isActive = true;
|
||||
assert(() {
|
||||
isActive = (context as Element).debugIsActive;
|
||||
return true;
|
||||
}());
|
||||
if (!isActive) return;
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) return;
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
key: key,
|
||||
content: GestureDetector(
|
||||
onTap: () => messenger.hideCurrentSnackBar(),
|
||||
child: content,
|
||||
),
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: elevation,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
width: width,
|
||||
shape: shape,
|
||||
hitTestBehavior: hitTestBehavior,
|
||||
behavior: behavior,
|
||||
action: action,
|
||||
actionOverflowThreshold: actionOverflowThreshold,
|
||||
showCloseIcon: showCloseIcon,
|
||||
closeIconColor: closeIconColor,
|
||||
duration: duration ?? const Duration(seconds: 4),
|
||||
persist: persist,
|
||||
animation: animation,
|
||||
onVisible: onVisible,
|
||||
dismissDirection: dismissDirection ?? DismissDirection.down,
|
||||
clipBehavior: clipBehavior ?? Clip.hardEdge,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,14 @@ import 'package:flutter/services.dart';
|
||||
|
||||
class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
||||
final int maxBytes;
|
||||
final String Function(String)? encoder;
|
||||
|
||||
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
|
||||
const Utf8LengthLimitingTextInputFormatter(this.maxBytes, {this.encoder});
|
||||
|
||||
int _effectiveByteLength(String text) {
|
||||
final effective = encoder != null ? encoder!(text) : text;
|
||||
return utf8.encode(effective).length;
|
||||
}
|
||||
|
||||
@override
|
||||
TextEditingValue formatEditUpdate(
|
||||
@@ -13,8 +19,7 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
||||
TextEditingValue newValue,
|
||||
) {
|
||||
if (maxBytes <= 0) return oldValue;
|
||||
final bytes = utf8.encode(newValue.text);
|
||||
if (bytes.length <= maxBytes) return newValue;
|
||||
if (_effectiveByteLength(newValue.text) <= maxBytes) return newValue;
|
||||
|
||||
final truncated = _truncateToMaxBytes(newValue.text, maxBytes);
|
||||
return TextEditingValue(
|
||||
@@ -25,6 +30,14 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
|
||||
}
|
||||
|
||||
String _truncateToMaxBytes(String text, int limit) {
|
||||
if (encoder != null) {
|
||||
final runes = text.runes.toList();
|
||||
while (runes.isNotEmpty &&
|
||||
_effectiveByteLength(String.fromCharCodes(runes)) > maxBytes) {
|
||||
runes.removeLast();
|
||||
}
|
||||
return String.fromCharCodes(runes);
|
||||
}
|
||||
final buffer = StringBuffer();
|
||||
var used = 0;
|
||||
for (final rune in text.runes) {
|
||||
|
||||
@@ -39,6 +39,8 @@
|
||||
"common_notAvailable": "—",
|
||||
"common_voltageValue": "{volts} В",
|
||||
"common_percentValue": "{percent}%",
|
||||
"common_autoRefresh": "Автообновление",
|
||||
"common_interval": "Интервал",
|
||||
"scanner_title": "MeshCore Open",
|
||||
"scanner_scanning": "Поиск устройств...",
|
||||
"scanner_connecting": "Подключение...",
|
||||
@@ -75,12 +77,16 @@
|
||||
"settings_locationIntervalInvalid": "Интервал должен составлять не менее 60 секунд и не более 86400 секунд.",
|
||||
"settings_latitude": "Широта",
|
||||
"settings_longitude": "Долгота",
|
||||
"settings_autoZeroHopAdvertOnGpsUpdate": "Авто-объявление без хопов при обновлении GPS",
|
||||
"settings_autoZeroHopAdvertOnGpsUpdateSubtitle": "Когда GPS-местоположение меняется, отправлять объявление без хопов (требуется геопозиция в объявлении).",
|
||||
"settings_privacyMode": "Режим конфиденциальности",
|
||||
"settings_privacyModeSubtitle": "Скрыть имя/позицию в анонсировании",
|
||||
"settings_privacyModeToggle": "Включите режим конфиденциальности, чтобы скрыть свое имя и местоположение в анонсировании.",
|
||||
"settings_privacyModeEnabled": "Режим конфиденциальности включен",
|
||||
"settings_privacyModeDisabled": "Режим конфиденциальности выключен",
|
||||
"settings_actions": "Действия",
|
||||
"settings_deleteAllPaths": "Delete All Paths",
|
||||
"settings_deleteAllPathsSubtitle": "Clear all path data from contacts.",
|
||||
"settings_sendAdvertisement": "Отправить анонсирование",
|
||||
"settings_sendAdvertisementSubtitle": "Отправить анонсирование о присутствии сейчас",
|
||||
"settings_advertisementSent": "Анонсирование отправлено",
|
||||
@@ -186,6 +192,19 @@
|
||||
"appSettings_last6Hours": "Последние 6 часов",
|
||||
"appSettings_last24Hours": "Последние 24 часа",
|
||||
"appSettings_lastWeek": "Последнюю неделю",
|
||||
"appSettings_rasterTileSource": "Источник растровых тайлов",
|
||||
"appSettings_stadiaEndpoint": "Конечная точка Stadia",
|
||||
"appSettings_stadiaApiKey": "Ключ API Stadia",
|
||||
"appSettings_stadiaApiKeyRequired": "Требуется для использования Stadia Maps",
|
||||
"appSettings_stadiaApiKeyConfigured": "Настроено: {maskedKey}",
|
||||
"@appSettings_stadiaApiKeyConfigured": {
|
||||
"placeholders": {
|
||||
"maskedKey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_stadiaApiKeyDialogDescription": "Введите свой ключ API Stadia Maps. Приложение использует его для запросов растровых тайлов.",
|
||||
"appSettings_offlineMapCache": "Кэш офлайн-карты",
|
||||
"appSettings_noAreaSelected": "Область не выбрана",
|
||||
"appSettings_areaSelectedZoom": "Область выбрана (масштаб {minZoom}–{maxZoom})",
|
||||
@@ -229,11 +248,8 @@
|
||||
"channels_searchChannels": "Поиск каналов...",
|
||||
"channels_noChannelsFound": "Каналы не найдены",
|
||||
"channels_channelIndex": "Канал {index}",
|
||||
"channels_hashtagChannel": "Хэштег-канал",
|
||||
"channels_public": "Публичный",
|
||||
"channels_private": "Приватный",
|
||||
"channels_publicChannel": "Публичный канал",
|
||||
"channels_privateChannel": "Приватный канал",
|
||||
"channels_editChannel": "Изменить канал",
|
||||
"channels_muteChannel": "Отключить уведомления канала",
|
||||
"channels_unmuteChannel": "Включить уведомления канала",
|
||||
@@ -252,6 +268,22 @@
|
||||
"channels_channelAdded": "Канал \"{name}\" добавлен",
|
||||
"channels_editChannelTitle": "Изменить канал {index}",
|
||||
"channels_smazCompression": "Сжатие SMAZ",
|
||||
"channels_cyr2latCompression": "Сжатие Cyr2Lat",
|
||||
"channels_cyr2latCompressionDscr": "Заменяет некоторые кириллические символы на латиницу при отправке.",
|
||||
"channels_cyr2latSettingsHeading": "Настройка Cyr2Lat",
|
||||
"channels_cyr2latSettingsSubheading": "Список замен",
|
||||
"channels_cyr2latSettingsDscr": "Редактировать JSON-конфигурацию замены символов",
|
||||
"channels_cyr2latSettingsDialogHint": "JSON-карта замен",
|
||||
"channels_cyr2latSettingsDialogWrongJSON": "Некорректный JSON: {error}",
|
||||
"settings_cyr2latProfileAdd": "Добавить профиль Cyr2Lat",
|
||||
"settings_cyr2latProfileName": "Название профиля",
|
||||
"settings_cyr2latProfileNameEmpty": "Название профиля не может быть пустым",
|
||||
"settings_cyr2latProfileAdded": "Профиль добавлен",
|
||||
"settings_cyr2latProfileUpdated": "Профиль успешно обновлен",
|
||||
"settings_cyr2latProfileEdit": "Редактировать профиль Cyr2Lat",
|
||||
"settings_cyr2latProfileDelete": "Удалить профиль Cyr2Lat",
|
||||
"settings_cyr2latProfileDeleted": "Профиль успешно удален",
|
||||
"settings_cyr2latProfileDeleteDscr": "Вы действительно хотите удалить профиль \"{name}\"?",
|
||||
"channels_channelUpdated": "Канал \"{name}\" обновлён",
|
||||
"channels_publicChannelAdded": "Публичный канал добавлен",
|
||||
"channels_sortBy": "Сортировка",
|
||||
@@ -358,6 +390,8 @@
|
||||
"chat_direct": "Прямой",
|
||||
"chat_poiShared": "Точка интереса отправлена",
|
||||
"chat_unread": "Непрочитанных: {count}",
|
||||
"chat_markAsUnread": "Пометить как непрочитанные",
|
||||
"chat_newMessages": "Новые сообщения",
|
||||
"map_title": "Карта нод",
|
||||
"map_noNodesWithLocation": "Нет нод с данными о местоположении",
|
||||
"map_nodesNeedGps": "Ноды должны передавать свои GPS-координаты, чтобы отображаться на карте",
|
||||
@@ -419,6 +453,56 @@
|
||||
"mapCache_downloadTilesButton": "Загрузить плитки",
|
||||
"mapCache_clearCacheButton": "Очистить кэш",
|
||||
"mapCache_failedDownloads": "Неудачных загрузок: {count}",
|
||||
"mapCache_cachedTilesLabel": "Cached tiles",
|
||||
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
|
||||
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
|
||||
"@mapCache_bulkDownloadDisabledForSource": {
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
|
||||
"@mapCache_bulkDownloadDisabledInConfig": {
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summarySource": "Source: {source}",
|
||||
"@mapCache_summarySource": {
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
|
||||
"@mapCache_summaryCachedTilesForSource": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
|
||||
"@mapCache_summaryCachedInSelection": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
|
||||
"@mapCache_summaryApproxCacheSize": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_boundsLabel": "С {north}, Ю {south}, В {east}, З {west}",
|
||||
"time_justNow": "Только что",
|
||||
"time_minutesAgo": "{minutes} мин назад",
|
||||
@@ -669,6 +753,43 @@
|
||||
"telemetry_voltageValue": "{volts}В",
|
||||
"telemetry_currentValue": "{amps}А",
|
||||
"telemetry_temperatureValue": "{celsius}°C / {fahrenheit}°F",
|
||||
"telemetry_digitalInputLabel": "Цифровой вход",
|
||||
"telemetry_digitalOutputLabel": "Цифровой выход",
|
||||
"telemetry_analogInputLabel": "Аналоговый вход",
|
||||
"telemetry_analogOutputLabel": "Аналоговый выход",
|
||||
"telemetry_genericLabel": "Общий датчик",
|
||||
"telemetry_luminosityLabel": "Освещённость",
|
||||
"telemetry_presenceLabel": "Присутствие",
|
||||
"telemetry_humidityLabel": "Влажность",
|
||||
"telemetry_accelerometerLabel": "Акселерометр",
|
||||
"telemetry_pressureLabel": "Давление",
|
||||
"telemetry_altitudeLabel": "Высота",
|
||||
"telemetry_frequencyLabel": "Частота",
|
||||
"telemetry_percentageLabel": "Процент",
|
||||
"telemetry_concentrationLabel": "Концентрация",
|
||||
"telemetry_powerLabel": "Мощность",
|
||||
"telemetry_distanceLabel": "Расстояние",
|
||||
"telemetry_energyLabel": "Энергия",
|
||||
"telemetry_directionLabel": "Направление",
|
||||
"telemetry_timeLabel": "Время",
|
||||
"telemetry_gyrometerLabel": "Гирометр",
|
||||
"telemetry_colourLabel": "Цвет",
|
||||
"telemetry_gpsLabel": "GPS",
|
||||
"telemetry_switchLabel": "Переключатель",
|
||||
"telemetry_polylineLabel": "Полилиния",
|
||||
"telemetry_altitudeValue": "{meters} м",
|
||||
"telemetry_frequencyValue": "{hertz} Гц",
|
||||
"telemetry_pressureValue": "{hpa} гПа",
|
||||
"telemetry_luminosityValue": "{lux} лк",
|
||||
"telemetry_powerValue": "{watts} Вт",
|
||||
"telemetry_distanceValue": "{meters} м",
|
||||
"telemetry_energyValue": "{kilowattHours} кВт⋅ч",
|
||||
"telemetry_directionValue": "{degrees}°",
|
||||
"telemetry_concentrationValue": "{ppm} ppm",
|
||||
"telemetry_percentageValue": "{percent}%",
|
||||
"telemetry_analogValue": "{value}",
|
||||
"telemetry_autoFetchQuantity": "Количество запросов",
|
||||
"telemetry_error": "Не удалось получить данные",
|
||||
"neighbors_receivedData": "Полученные данные о соседях",
|
||||
"neighbors_requestTimedOut": "Время ожидания данных о соседях истекло.",
|
||||
"neighbors_errorLoading": "Ошибка загрузки соседей: {error}",
|
||||
@@ -1162,13 +1283,6 @@
|
||||
"contact_clearChat": "Очистить чат",
|
||||
"contact_lastSeen": "Последний раз видели",
|
||||
"contact_teleBaseSubtitle": "Разрешить обмен уровнем заряда батареи и базовой телеметрией",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeight": "Максимальный допустимый вес маршрута",
|
||||
"appSettings_maxRouteWeightSubtitle": "Максимальный вес, который может быть перевезён по определённому маршруту при успешных доставках.",
|
||||
"appSettings_initialRouteWeightSubtitle": "Начальный вес для новых, только что открытых маршрутов",
|
||||
@@ -1181,7 +1295,6 @@
|
||||
"appSettings_maxMessageRetriesSubtitle": "Количество попыток повторной отправки сообщения перед тем, как пометить его как неудачное.",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_telemetryModeUpdated": "Режим телеметрии обновлен",
|
||||
"settings_multiAck": "Мульти-ACK: {value}",
|
||||
"map_showOverlaps": "Перекрытия ключа повтора",
|
||||
"map_runTraceWithReturnPath": "Вернуться обратно по тому же пути",
|
||||
"@radioStats_noiseFloor": {
|
||||
@@ -1247,6 +1360,37 @@
|
||||
"radioStats_stripWaiting": "Получение данных о радио…",
|
||||
"radioStats_settingsTile": "Статистика радиовещания",
|
||||
"radioStats_settingsSubtitle": "Уровень шума, RSSI, SNR и время передачи",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_enableSubtitle": "Переводить входящие сообщения и позволять предварительный перевод перед отправкой.",
|
||||
"translation_composerTitle": "Переводить перед отправкой",
|
||||
"translation_title": "Перевод",
|
||||
"translation_enableTitle": "Включить перевод",
|
||||
"translation_composerSubtitle": "Управляет исходным состоянием значка перевода, предоставляемого редактором.",
|
||||
"translation_autoIncomingTitle": "Автоматически переводить сообщения",
|
||||
"translation_autoIncomingSubtitle": "Автоматически переводит сообщения для уведомлений, а также для чатов и каналов.",
|
||||
"translation_translateMessage": "Перевести сообщение",
|
||||
"translation_targetLanguage": "Целевой язык",
|
||||
"translation_useAppLanguage": "Используйте язык приложения",
|
||||
"translation_downloadedModelLabel": "Загруженная модель",
|
||||
"translation_presetModelLabel": "Предопределенная модель от Hugging Face",
|
||||
"translation_manualUrlLabel": "Ссылка на руководство",
|
||||
"translation_downloadModel": "Скачать модель",
|
||||
"translation_downloading": "Загрузка...",
|
||||
"translation_stop": "Прекратите",
|
||||
"translation_working": "Работа...",
|
||||
"translation_mergingChunks": "Объединение скачанных фрагментов в один финальный файл...",
|
||||
"translation_downloadedModels": "Загруженные модели",
|
||||
"translation_deleteModel": "Удалить модель",
|
||||
"translation_modelDownloaded": "Модель перевода загружена.",
|
||||
"translation_downloadStopped": "Процесс загрузки был прерван.",
|
||||
"translation_downloadFailed": "Не удалось скачать: {error}",
|
||||
"translation_enterUrlFirst": "Сначала введите URL модели.",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
@@ -1254,10 +1398,737 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_translateBeforeSending": "Перевести перед отправкой",
|
||||
"translation_composerEnabledHint": "Сообщения будут переведены перед отправкой.",
|
||||
"translation_messageTranslation": "Перевод сообщения",
|
||||
"translation_composerDisabledHint": "Отправляйте сообщения на языке, в котором они были изначально набраны.",
|
||||
"translation_translateTo": "Перевести на {language}",
|
||||
"translation_translationOptions": "Варианты перевода",
|
||||
"translation_systemLanguage": "Язык системы",
|
||||
"scanner_linuxPairingShowPin": "Показать PIN",
|
||||
"scanner_linuxPairingPinPrompt": "Введите PIN‑код для {deviceName} (оставьте пустым, если нет).",
|
||||
"scanner_linuxPairingHidePin": "Скрыть PIN",
|
||||
"scanner_linuxPairingPinTitle": "PIN‑код сопряжения Bluetooth",
|
||||
"repeater_cliQuickDiscovery": "Обнаружить Соседей",
|
||||
"repeater_cliQuickClockSync": "Синхронизация часов"
|
||||
"repeater_cliQuickClockSync": "Синхронизация часов",
|
||||
"@repeater_clockSyncAfterLogin": {
|
||||
"description": "Repeater setting: auto sync device clock after successful login"
|
||||
},
|
||||
"@repeater_clockSyncAfterLoginSubtitle": {
|
||||
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
|
||||
},
|
||||
"repeater_clockSyncAfterLogin": "Синхронизация часов после входа в систему",
|
||||
"repeater_clockSyncAfterLoginSubtitle": "Автоматически отправлять сообщение \"синхронизация времени\" после успешной авторизации.",
|
||||
"chat_sendMessage": "Отправить сообщение",
|
||||
"repeater_guest": "Информация о ретрансляторе",
|
||||
"room_guest": "Информация о сервере",
|
||||
"repeater_guestTools": "Инструменты для гостей",
|
||||
"common_done": "Готово",
|
||||
"background_serviceTitle": "MeshCore работает",
|
||||
"background_serviceText": "Поддерживает BLE-соединение",
|
||||
"appSettings_translationModelDeleted": "Удалено {name}",
|
||||
"@appSettings_translationModelDeleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_translationModelDeleteFailed": "Не удалось удалить: {error}",
|
||||
"@appSettings_translationModelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"channels_channelUpdateFailed": "Не удалось обновить канал: {error}",
|
||||
"@channels_channelUpdateFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"map_type": "Тип",
|
||||
"map_path": "Путь",
|
||||
"map_location": "Местоположение",
|
||||
"map_estLocation": "Прибл. местоположение",
|
||||
"map_publicKey": "Публичный ключ",
|
||||
"map_publicKeyPrefixHint": "напр. ab12",
|
||||
"contact_typeChat": "Чат",
|
||||
"contact_typeRepeater": "Ретранслятор",
|
||||
"contact_typeRoom": "Комната",
|
||||
"contact_typeSensor": "Датчик",
|
||||
"contact_typeUnknown": "Неизвестно",
|
||||
"channels_via": "через {path}",
|
||||
"chat_score": "Оценка",
|
||||
"settings_multiAck": "Несколько подтверждений",
|
||||
"map_sharedAt": "Поделено",
|
||||
"@losBlockedSpotChip": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@losSelectedObstructionDetails": {
|
||||
"placeholders": {
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceFromA": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceFromB": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losBlockedSpotsHint": "Щелкните по заблокированной области, чтобы выделить ее на карте.",
|
||||
"losBlockedSpotsTitle": "Зарезервированные места",
|
||||
"losSelectedObstructionTitle": "Выбранный объект, препятствующий движению",
|
||||
"losBlockedSpotChip": "{distance} {distanceUnit} • {obstruction} {heightUnit}",
|
||||
"losSelectedObstructionDetails": "Blocked by {obstruction} {heightUnit}, {distanceFromA} from A and {distanceFromB} from B ({distanceUnit}).",
|
||||
"repeater_rxGain": "Увеличенная эффективность RX",
|
||||
"repeater_rxGainHelper": "Более высокая чувствительность, больший ток потребления (только для SX1262/SX1268)",
|
||||
"repeater_refreshRxGain": "Обновите усиление RX",
|
||||
"repeater_multiAcks": "Несколько подтверждений",
|
||||
"repeater_multiAcksSubtitle": "Обеспечьте доставку сообщений по нескольким каналам для повышения эффективности.",
|
||||
"repeater_refreshMultiAcks": "Обновление нескольких подтверждений",
|
||||
"repeater_networkHealth": "Состояние сети",
|
||||
"repeater_loopDetect": "Обнаружение циклов",
|
||||
"repeater_loopDetectHelper": "Создайте пакеты данных, которые выглядят как циклы маршрутизации.",
|
||||
"repeater_loopDetectOff": "Отключено",
|
||||
"repeater_loopDetectMinimal": "Минимальный",
|
||||
"repeater_loopDetectModerate": "Умеренный",
|
||||
"repeater_loopDetectStrict": "Строгий",
|
||||
"repeater_dutyCycle": "Цикл работы",
|
||||
"repeater_dutyCycleHelper": "Максимальный процент времени, выделенного на трансляцию.",
|
||||
"repeater_dutyCyclePercent": "{percent}%",
|
||||
"@repeater_dutyCyclePercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_ownerInfo": "Информация о операторе",
|
||||
"repeater_ownerInfoHelper": "Общая метаинформация для этого ретранслятора",
|
||||
"repeater_refreshOwnerInfo": "Обновить информацию о операторе",
|
||||
"repeater_floodMax": "Максимальное количество прыжков при наводнении",
|
||||
"repeater_floodMaxHelper": "Максимальное количество пакетов, которые могут быть отправлены в одном потоке (0-64)",
|
||||
"repeater_advancedSettings": "Продвинутый",
|
||||
"repeater_advancedSettingsSubtitle": "Регуляторы для опытных операторов",
|
||||
"repeater_pathHashMode": "Режим хеширования пути",
|
||||
"repeater_pathHashModeHelper": "Байты, используемые для кодирования идентификатора этого ретранслятора в тегах flood-маршрута/обнаружения циклов. 0 = 1 байт (256 идентификаторов, до 64 переходов), 1 = 2 байта (65 000 идентификаторов, до 32 переходов), 2 = 3 байта (16 миллионов идентификаторов, до 21 перехода). Прошивки до v1.14 всегда использовали 1-байтовые маршруты; v1.14 и новее можно настроить на 2- или 3-байтовые маршруты.",
|
||||
"repeater_txDelay": "Задержка в работе системы Flood TX",
|
||||
"repeater_txDelayHelper": "Передача с увеличенным интервалом для трафика во время наводнения, в качестве коэффициента, умножающего время передачи пакета (от 0 до 2, по умолчанию 0,5). Более высокое значение означает меньшее количество столкновений, но более медленную передачу.",
|
||||
"repeater_directTxDelay": "Прямая задержка сигнала TX",
|
||||
"repeater_directTxDelayHelper": "Передача промежуточных данных для прямого (немассового) трафика, в качестве коэффициента, равного времени передачи пакета (от 0 до 2, по умолчанию 0,3).",
|
||||
"repeater_intThresh": "Пороговое значение помех",
|
||||
"repeater_intThreshHelper": "Порог устанавливается для калибровки уровня шума радио, чтобы оно отсеивало помехи, превышающие этот уровень. Значение \"0\" означает отключение – используйте только в случае, если вы наблюдаете ошибки при приеме сигнала в шумном диапазоне.",
|
||||
"repeater_agcResetInterval": "Интервал сброса AGC",
|
||||
"repeater_agcResetIntervalHelper": "Как часто следует сбрасывать автоматическую регулировку усиления радио, чтобы вернуться к нормальному состоянию после заклинивания? Интервал сброса составляет несколько секунд, кратный 4. Отключение периодического сброса осуществляется с помощью параметра 0.",
|
||||
"repeater_actionsTitle": "Действия",
|
||||
"repeater_sendAdvert": "Отправить объявление о наводнении",
|
||||
"repeater_sendAdvertSubtitle": "Разместите рекламу о наводнении в эфире по всей сети.",
|
||||
"repeater_sendAdvertZeroHop": "Опубликуйте рекламу, не требующую промежуточного распространения.",
|
||||
"repeater_sendAdvertZeroHopSubtitle": "Разместите рекламу, распространяемую одним способом (без использования ретрансляторов).",
|
||||
"repeater_clockSync": "Синхронизировать время сейчас",
|
||||
"repeater_clockSyncSubtitle": "Установите время на вашем телефоне, чтобы оно совпадало со временем ретранслятора.",
|
||||
"repeater_actionSucceeded": "{action} succeeded",
|
||||
"@repeater_actionSucceeded": {
|
||||
"placeholders": {
|
||||
"action": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_actionFailed": "{action} failed: {error}",
|
||||
"@repeater_actionFailed": {
|
||||
"placeholders": {
|
||||
"action": {
|
||||
"type": "String"
|
||||
},
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_settingsSavedRebootNeeded": "Настройки сохранены — перезагрузите ретранслятор, чтобы применить их.",
|
||||
"repeater_settingsPartialFailure": "Некоторые настройки не удалось применить: {failures}",
|
||||
"@repeater_settingsPartialFailure": {
|
||||
"placeholders": {
|
||||
"failures": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@common_percentValue": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@settings_aboutVersion": {
|
||||
"placeholders": {
|
||||
"version": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@telemetry_temperatureValue": {
|
||||
"placeholders": {
|
||||
"celsius": {
|
||||
"type": "String"
|
||||
},
|
||||
"fahrenheit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channelPath_timeWithDate": {
|
||||
"placeholders": {
|
||||
"day": {
|
||||
"type": "int"
|
||||
},
|
||||
"month": {
|
||||
"type": "int"
|
||||
},
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channelPath_timeOnly": {
|
||||
"placeholders": {
|
||||
"time": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channelPath_selectedPathLabel": {
|
||||
"placeholders": {
|
||||
"label": {
|
||||
"type": "String"
|
||||
},
|
||||
"prefixes": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_getCategory": "Получить значения",
|
||||
"repeater_powerMgmt": "Управление энергопотреблением",
|
||||
"repeater_sensors": "Датчики",
|
||||
"repeater_cliHelpPowerOff": "Отключает устройство. (ожидается отсутствие ответа).",
|
||||
"repeater_cliHelpClkReboot": "Сбрасывает часы до известной эпохи и перезапускает устройство.",
|
||||
"repeater_cliHelpAdvertZeroHop": "Отправляет рекламу, распространяемую только среди ближайших соседей (без промежуточных узлов).",
|
||||
"repeater_cliHelpStartOta": "Запускает обновление прошивки по воздуху на поддерживаемых устройствах.",
|
||||
"repeater_cliHelpTime": "Устанавливает время устройства в соответствии с заданными секундами от начала эпохи Unix. Время не может сброситься назад.",
|
||||
"repeater_cliHelpBoard": "Отображает информацию о производителе платы / идентификатор аппаратного обеспечения.",
|
||||
"repeater_cliHelpDiscoverNeighbors": "Отправляет запрос на обнаружение соседних узлов. (Только для ретранслятора)",
|
||||
"repeater_cliHelpPowersavingOnOff": "Включает или выключает режим экономии энергии (если он поддерживается).",
|
||||
"repeater_cliHelpPowersaving": "Показывает, включен ли режим экономии энергии.",
|
||||
"repeater_cliHelpErase": "(Только для серийного использования) Форматирует файловую систему устройства. Удаляет все настройки и контакты.",
|
||||
"repeater_cliHelpSetDutyCycle": "Устанавливает максимальный допустимый цикл передачи данных в процентах (от 1 до 100). Внутренне корректирует коэффициент времени передачи.",
|
||||
"repeater_cliHelpSetPrvKey": "(Только для серийного использования) Заменяет приватный ключ, идентифицирующий устройство. Требуется перезагрузка для применения. Генерирует новый публичный ключ.",
|
||||
"repeater_cliHelpSetRadioRxGain": "(Только для SX126x) Переключает усиление RX для повышения чувствительности при больших токах потребления.",
|
||||
"repeater_cliHelpSetOwnerInfo": "Указывает строку с контактной информацией владельца, которая должна быть включена в объявления. Используйте '|' для переносов строк.",
|
||||
"repeater_cliHelpSetPathHashMode": "Устанавливает режим хеширования пути. 0 = устаревший, 1 = стандартный, 2 = строгий. Влияет на то, как определяются маршруты.",
|
||||
"repeater_cliHelpSetLoopDetect": "Устанавливает чувствительность обнаружения циклов маршрутизации: \"выключено\", \"минимальная\", \"умеренная\" или \"строгая\".",
|
||||
"repeater_cliHelpSetFreq": "(Только для настройки) Быстро устанавливает только частоту. Требуется перезагрузка. Рекомендуется использовать функцию \"настройка радио\" для полного набора параметров.",
|
||||
"repeater_cliHelpSetBridgeChannel": "(Только для моста ESPNow) Устанавливает канал Wi-Fi (от 1 до 14), используемый мостом.",
|
||||
"repeater_cliHelpGetName": "Отображает имя настроенного узла.",
|
||||
"repeater_cliHelpGetRole": "Отображает роль прошивки (ретранслятор, сервер для комнаты и т.д.).",
|
||||
"repeater_cliHelpGetPublicKey": "Отображает открытый ключ устройства.",
|
||||
"repeater_cliHelpGetPrvKey": "(Только для серийного использования) Отображает приватный ключ устройства. Рассматривайте его как секретную информацию.",
|
||||
"repeater_cliHelpGetRepeat": "Отображает, включена ли функция перенаправления пакетов (функция ретранслятора) или нет.",
|
||||
"repeater_cliHelpGetTx": "Отображает текущую мощность передатчика в дБм.",
|
||||
"repeater_cliHelpGetFreq": "Отображает настроенную частоту радиосигнала в мегагерцах.",
|
||||
"repeater_cliHelpGetRadio": "Отображает все параметры радиосигнала: частоту, полосу пропускания, коэффициент модуляции, скорость кодирования.",
|
||||
"repeater_cliHelpGetRadioRxGain": "(Только для SX126x) Отображает состояние усиления сигнала на входе RX.",
|
||||
"repeater_cliHelpGetAf": "Отображает текущий коэффициент времени эфира.",
|
||||
"repeater_cliHelpGetDutyCycle": "Отображает текущий допустимый цикл работы в процентах.",
|
||||
"repeater_cliHelpGetIntThresh": "Отображает порог помех в децибелах.",
|
||||
"repeater_cliHelpGetAgcResetInterval": "Отображает интервал сброса автоматической регулировки усиления в секундах.",
|
||||
"repeater_cliHelpGetMultiAcks": "Показывает, включен ли режим двойной подтверждения (1) или выключен (0).",
|
||||
"repeater_cliHelpGetAllowReadOnly": "Отображает, разрешен ли доступ для чтения только для гостей.",
|
||||
"repeater_cliHelpGetAdvertInterval": "Отображает продолжительность рекламного блока в минутах.",
|
||||
"repeater_cliHelpGetFloodAdvertInterval": "Отображает интервал времени показа рекламного ролика в часах.",
|
||||
"repeater_cliHelpGetGuestPassword": "Отображает установленный пароль для гостя.",
|
||||
"repeater_cliHelpGetLat": "Отображает заданную широту.",
|
||||
"repeater_cliHelpGetLon": "Отображает заданную долготу.",
|
||||
"repeater_cliHelpGetRxDelay": "Отображает базовое значение задержки.",
|
||||
"repeater_cliHelpGetTxDelay": "Отображает коэффициент задержки при работе в режиме затопления.",
|
||||
"repeater_cliHelpGetDirectTxDelay": "Отображает коэффициент задержки в режиме прямого подключения.",
|
||||
"repeater_cliHelpGetFloodMax": "Отображает максимальное количество переходов при затоплении.",
|
||||
"repeater_cliHelpGetOwnerInfo": "Отображает строку с контактной информацией владельца.",
|
||||
"repeater_cliHelpGetPathHashMode": "Отображает режим работы с хэшем пути (0/1/2).",
|
||||
"repeater_cliHelpGetLoopDetect": "Отображает чувствительность к обнаружению циклов.",
|
||||
"repeater_cliHelpGetAcl": "(Только для серий) Перечисляет записи управления доступом на ретрансляторе.",
|
||||
"repeater_cliHelpGetBridgeEnabled": "Показывает, включена ли функция моста.",
|
||||
"repeater_cliHelpGetBridgeDelay": "Отображает задержку в миллисекундах.",
|
||||
"repeater_cliHelpGetBridgeSource": "Отображает, какие пакеты RX или TX передаются через мост.",
|
||||
"repeater_cliHelpGetBridgeBaud": "(Только для интерфейса RS232) Отображает скорость передачи данных на интерфейсе RS232.",
|
||||
"repeater_cliHelpGetBridgeChannel": "(Только для моста ESPNow) Отображает канал WiFi, используемый мостом.",
|
||||
"repeater_cliHelpGetBridgeSecret": "(Только для моста ESPNow) Отображает общий секрет, используемый мостом.",
|
||||
"repeater_cliHelpGetBootloaderVer": "(Только для NRF52) Отображает версию загрузчика.",
|
||||
"repeater_cliHelpGetAdcMultiplier": "Отображает коэффициент умножения аналого-цифрового преобразователя (масштабирование напряжения от батареи).",
|
||||
"repeater_cliHelpGetPwrMgtSupport": "Сообщает, есть ли у совета поддержки функций управления питанием.",
|
||||
"repeater_cliHelpGetPwrMgtSource": "Отображает текущий источник питания: внешний или аккумулятор.",
|
||||
"repeater_cliHelpGetPwrMgtBootReason": "Отображает последние причины сброса и выключения.",
|
||||
"repeater_cliHelpGetPwrMgtBootMv": "Отображает напряжение батареи при запуске системы в милливольтах (мВ).",
|
||||
"repeater_cliHelpSensorGet": "Считывает пользовательское значение для датчика по указанному ключу.",
|
||||
"repeater_cliHelpSensorSet": "Создает пользовательские настройки для датчика.",
|
||||
"repeater_cliHelpSensorList": "Перечисляет все пользовательские настройки датчиков, разбитые на страницы с возможностью указания начального индекса.",
|
||||
"repeater_cliHelpRegionDefault": "Отображает текущий область действия по умолчанию.",
|
||||
"repeater_cliHelpRegionDefaultSet": "Устанавливает значение региона по умолчанию. Используйте \"<null>\", чтобы сбросить значение.",
|
||||
"repeater_cliHelpRegionListAllowed": "Перечисляет регионы, где разрешено движение транспорта во время наводнений.",
|
||||
"repeater_cliHelpRegionListDenied": "Перечисляет регионы, где запрещено движение транспорта во время наводнений.",
|
||||
"repeater_cliHelpStatsPackets": "(Только для серийной версии) Отображает статистику на уровне пакетов.",
|
||||
"repeater_cliHelpStatsRadio": "(Только для серий) Отображает статистику радио.",
|
||||
"repeater_cliHelpStatsCore": "(Только для серийного оборудования) Отображает основные статистические данные прошивки.",
|
||||
"settings_companionDebugLogSubtitle": "Команды, ответы и необработанные данные, используемые для протоколов BLE, TCP и USB.",
|
||||
"repeater_chanUtil": "Использование канала",
|
||||
"settings_companionDebugLog": "Журнал отладки (для сопутствующего приложения)",
|
||||
"@routing_lastWorked": {
|
||||
"placeholders": {
|
||||
"when": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@routing_deliveryCounts": {
|
||||
"placeholders": {
|
||||
"successes": {
|
||||
"type": "int"
|
||||
},
|
||||
"failures": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathEditor_hopCounter": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathEditor_invalidTokens": {
|
||||
"placeholders": {
|
||||
"tokens": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channels_communityShortId": {
|
||||
"placeholders": {
|
||||
"id": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messageStatus_pending": "Отправка",
|
||||
"common_undo": "Отменить",
|
||||
"messageStatus_delivered": "Доставлено",
|
||||
"messageStatus_sent": "Отправлено",
|
||||
"messageStatus_failed": "Не удалось отправить",
|
||||
"messageStatus_repeated": "Услышал несколько раз",
|
||||
"contacts_moreOptions": "Больше вариантов",
|
||||
"contacts_searchOpen": "Найти контакты",
|
||||
"contacts_searchClose": "Закрыть поиск",
|
||||
"routing_title": "Маршрутизация",
|
||||
"routing_modeAuto": "Авто",
|
||||
"routing_modeFlood": "Наводнение",
|
||||
"routing_modeManual": "Инструкция",
|
||||
"routing_modeAutoHint": "Автоматически выбирает наиболее известный путь, и если такой путь неизвестен, использует алгоритм поиска пути.",
|
||||
"routing_modeFloodHint": "Передача сигнала через все ретрансляторы. Самый надежный способ, но требует больше времени на передачу.",
|
||||
"routing_modeManualHint": "Всегда следует точно по указанному вами маршруту.",
|
||||
"routing_currentRoute": "Текущий маршрут",
|
||||
"routing_directNoHops": "Прямое соединение – без использования ретрансляторов",
|
||||
"routing_noPathYet": "Пока нет пути. Следующее сообщение будет отправлено до тех пор, пока не будет обнаружен маршрут.",
|
||||
"routing_floodBroadcast": "Транслируется через все ретрансляторы",
|
||||
"routing_editPath": "Изменить путь",
|
||||
"routing_forgetPath": "Забудьте о маршруте",
|
||||
"routing_knownPaths": "Известные маршруты",
|
||||
"routing_knownPathsHint": "Создайте маршрут для переключения на этот пункт.",
|
||||
"routing_inUse": "В эксплуатации",
|
||||
"routing_qualityStrong": "Сильный первый скачок",
|
||||
"routing_qualityGood": "Хорошее начало",
|
||||
"routing_qualityFair": "Первый хороший урожай",
|
||||
"routing_qualityWorked": "Осуществлено",
|
||||
"routing_qualityFlood": "Узнал из новостей, распространяющихся в интернете.",
|
||||
"routing_qualityUntested": "Непроверенный",
|
||||
"routing_neverWorked": "никогда не было подтверждено",
|
||||
"routing_floodDelivery": "Доставка при затоплении",
|
||||
"pathEditor_title": "Создать маршрут",
|
||||
"pathEditor_hopCounter": "{count} из 64 хмеля",
|
||||
"pathEditor_noHops": "На данный момент хмель еще не добавлен. Чтобы добавить его, нажмите на соответствующие кнопки ниже в нужном порядке, или сохраните рецепт без хмеля, чтобы отправить его напрямую.",
|
||||
"pathEditor_addHops": "Добавляйте хмель в соответствии с указанным порядком.",
|
||||
"pathEditor_searchRepeaters": "Поиск повторителей",
|
||||
"pathEditor_advancedHex": "Продвинутый уровень: прямой путь в шестнадцатеричном формате",
|
||||
"pathEditor_hexLabel": "Префиксы шестнадцатеричной системы",
|
||||
"pathEditor_hexHelper": "Два шестнадцатеричных символа на каждом шаге, разделенные запятыми.",
|
||||
"pathEditor_invalidTokens": "Неверно: {tokens}",
|
||||
"routing_lastWorked": "worked {when}",
|
||||
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
|
||||
"pathEditor_tooManyHops": "Максимальное количество ингредиентов – 64",
|
||||
"pathEditor_usePath": "Используйте этот путь",
|
||||
"pathEditor_removeHop": "Удалить хмель",
|
||||
"pathEditor_unknownHop": "Неизвестный ретранслятор",
|
||||
"map_zoomIn": "Увеличить масштаб",
|
||||
"map_zoomOut": "Увеличить масштаб",
|
||||
"map_centerMap": "Карта центра",
|
||||
"chrome_bluetoothRequiresChromium": "Для работы Web Bluetooth требуется браузер на основе Chromium.",
|
||||
"channels_communityShortId": "Идентификатор: {id}...",
|
||||
"pathTrace_legendGpsConfirmed": "GPS подтверждено",
|
||||
"pathTrace_legendInferred": "Выведенная позиция",
|
||||
"@pathMap_hopOf": {
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_observedPaths": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_alternate": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_hopCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_gpsCount": {
|
||||
"placeholders": {
|
||||
"confirmed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_sharedNodeCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_partialAnimation": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"map_searchHint": "Поиск по имени или ID узла",
|
||||
"map_online": "Онлайн",
|
||||
"scanner_bluetoothWebUnsupported": "Bluetooth недоступен в браузере. Подключитесь через USB.",
|
||||
"map_activity": "Активность",
|
||||
"map_recent": "Недавно",
|
||||
"map_stale": "Устаревший",
|
||||
"map_visible": "Видимый",
|
||||
"map_hidden": "Скрытый",
|
||||
"map_centerOnNode": "Центрировать на узле",
|
||||
"map_details": "Детали",
|
||||
"map_noGps": "Без GPS",
|
||||
"map_noResults": "Не найдено соответствующих узлов",
|
||||
"pathMap_viewSingle": "Одиночный",
|
||||
"pathMap_viewCombined": "Объединённые",
|
||||
"pathMap_play": "Воспроизвести",
|
||||
"pathMap_pause": "Пауза",
|
||||
"pathMap_replay": "Повтор",
|
||||
"pathMap_stepBack": "Предыдущий хоп",
|
||||
"pathMap_stepForward": "Следующий хоп",
|
||||
"pathMap_animationOn": "Показать анимацию пакета",
|
||||
"pathMap_animationOff": "Скрыть анимацию пакета",
|
||||
"pathMap_hopOf": "Хоп {current} из {total}",
|
||||
"pathMap_observedPaths": "Наблюдаемые маршруты: {count}",
|
||||
"pathMap_primary": "Основной",
|
||||
"pathMap_alternate": "Альт {index}",
|
||||
"pathMap_hopCount": "{count, plural, one{{count} хоп} few{{count} хопа} many{{count} хопов} other{{count} хопов}}",
|
||||
"pathMap_legendShared": "Общий сегмент",
|
||||
"pathMap_legendEstimated": "Расчётный сегмент",
|
||||
"pathMap_sharedNodeCount": "Используется в {count} маршрутах",
|
||||
"pathMap_partialAnimation": "{count, plural, one{{count} хоп не имеет координат — показанный путь неполный} few{{count} хопа не имеют координат — показанный путь неполный} many{{count} хопов не имеют координат — показанный путь неполный} other{{count} хопов не имеют координат — показанный путь неполный}}",
|
||||
"pathMap_showAllPaths": "Показать всё",
|
||||
"pathMap_hidePath": "Скрыть путь",
|
||||
"pathMap_collapsePanel": "Скрыть панель",
|
||||
"pathMap_showPath": "Показать маршрут",
|
||||
"pathMap_expandPanel": "Расширить панель",
|
||||
"pathMap_noLocation": "Нет координат",
|
||||
"pathMap_followPacket": "Следить за пакетом",
|
||||
"pathMap_unfollowPacket": "Не следить за пакетом",
|
||||
"pathMap_gpsCount": "{confirmed}/{total} GPS",
|
||||
"@settings_deleteRegionConfirm": {
|
||||
"placeholders": {
|
||||
"region": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channels_regionSetTo": {
|
||||
"placeholders": {
|
||||
"region": {
|
||||
"type": "String",
|
||||
"example": "de-mitte"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@chat_imagePickFailed": {
|
||||
"description": "Shown when picking or reading a photo to send fails"
|
||||
},
|
||||
"@repeater_pubKeyPrefixHelper": {
|
||||
"placeholders": {
|
||||
"tries": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_packetsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_range": {
|
||||
"placeholders": {
|
||||
"min": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_longSendBody": {
|
||||
"placeholders": {
|
||||
"duration": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_sentConfirmation": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_sendFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_sendingProgress": {
|
||||
"placeholders": {
|
||||
"sent": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_senderPrefix": {
|
||||
"placeholders": {
|
||||
"prefix": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_incoming": {
|
||||
"placeholders": {
|
||||
"received": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_incomplete": {
|
||||
"placeholders": {
|
||||
"received": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_awaiting": {
|
||||
"placeholders": {
|
||||
"bytes": {
|
||||
"type": "int"
|
||||
},
|
||||
"packets": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_secondsValue": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_minutesSecondsValue": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "String"
|
||||
},
|
||||
"seconds": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_regionSettings": "Регионы",
|
||||
"settings_regionSettingsSubtitle": "Управление сохранёнными регионами",
|
||||
"settings_regionManagement_screenTitle": "Управление регионами",
|
||||
"settings_regionNameHint": "Введите название региона",
|
||||
"settings_regionAddRegion": "Добавить регион",
|
||||
"settings_regionFetchRegions": "Извлекать регионы с ретрансляторов",
|
||||
"settings_regionFetchRegionsFail": "Не были найдены никакие регионы",
|
||||
"settings_regionFetchRegionsAlreadyExists": "Этот регион уже был добавлен",
|
||||
"settings_regionName": "Название региона",
|
||||
"settings_regionDeleted": "Регион удалён",
|
||||
"settings_deleteRegion": "Удалить регион",
|
||||
"settings_deleteRegionConfirm": "Удалить \"{region}\" из списка регионов?",
|
||||
"settings_infoHardware": "Оборудование",
|
||||
"settings_infoFirmware": "Производственное программное обеспечение",
|
||||
"repeater_pathHashModeOption0": "0 - 1 байт",
|
||||
"repeater_pathHashModeOption1": "1 - 2 байта",
|
||||
"repeater_pathHashModeOption2": "2 - 3 байта",
|
||||
"repeater_pathHashModeOption3": "3 - 4 байта",
|
||||
"appSettings_batteryLipoHv": "LiPo HV (3,0–4,35 В)",
|
||||
"channels_regionSetTo": "РЕГИОН: {region}",
|
||||
"channels_regionNotSet": "Регион: отсутствует",
|
||||
"channels_regionSelect_Title": "Выберите регион",
|
||||
"channels_clearRegion": "Чистый регион",
|
||||
"chat_sendImage": "Отправьте изображение",
|
||||
"chat_imagePickFailed": "Не удалось открыть это изображение",
|
||||
"chat_receivedGif": "Получен GIF",
|
||||
"repeater_keySettings": "Изменить ключи идентичности",
|
||||
"repeater_keySettingsSubtitle": "Измените пару публичных/частных ключей",
|
||||
"repeater_prvKey": "Личный ключ",
|
||||
"repeater_prvKeyHelper": "Для повторителя генерируется новый приватный ключ — шестнадцатеричная строка из 128 символов.",
|
||||
"repeater_generatePrvKey": "Сгенерируйте случайный ключ-пару",
|
||||
"repeater_stopGeneratingPrvKey": "Прервать поиск ключной пары",
|
||||
"repeater_pubKey": "Публичный ключ",
|
||||
"repeater_pubKeyHelper": "Это публичный ключ, который соответствует сгенерированному приватному ключу. Вы не можете установить его напрямую.",
|
||||
"repeater_pubKeyPrefix": "Желаемый префикс",
|
||||
"repeater_pubKeyPrefixHelper": "Найдите публичный ключ, начинающийся этими шестнадцатеричными цифрами. Ожидаемое количество попыток: {tries}.",
|
||||
"imageMessages_enableTitle": "Включить изображения в сообщения",
|
||||
"imageMessages_enableSubtitle": "Отправьте изображения через сеть. Требуется однократная загрузка шаблона изображения.",
|
||||
"imageMessages_modelSectionTitle": "Модель изображения",
|
||||
"imageMessages_downloadModel": "Скачать",
|
||||
"imageMessages_cancelDownload": "Отмена",
|
||||
"imageMessages_removeModel": "Удалить модель",
|
||||
"imageMessages_modelReady": "Готов",
|
||||
"imageMessages_modelNotPublished": "Пока не опубликовано — эта сборка не может скачать это.",
|
||||
"imageMessages_downloadFailed": "Модель изображения не удалось скачать.",
|
||||
"imageMessages_autoProcessTitle": "Автоматически обрабатывать изображения",
|
||||
"imageMessages_autoProcessSubtitle": "Примерно каждое изображение реконструируйте сразу после его поступления. Для этого на каждый раз используется около 2 ГБ памяти; отключите реконструкцию касанием.",
|
||||
"imageSend_title": "Отправьте изображение",
|
||||
"imageSend_cropNote": "Меняется размер на 512 × 512 · соотношение сторон не сохраняется",
|
||||
"imageSend_originalSize": "Оригинал",
|
||||
"imageSend_onAirSize": "В эфире",
|
||||
"imageSend_quality": "Качество",
|
||||
"imageSend_qualityStandard": "Стандарт",
|
||||
"imageSend_qualityHigh": "Высота",
|
||||
"imageSend_packetsLabel": "Пакеты",
|
||||
"imageSend_airtimeLabel": "Время на эфире",
|
||||
"imageSend_sizeLabel": "Пакет",
|
||||
"imageSend_packetsCount": "{count} {count, plural, one{пакет} few{пакета} many{пакетов} other{пакета}}",
|
||||
"imageSend_range": "{min}–{max}",
|
||||
"imageSend_unknownValue": "—",
|
||||
"imageSend_radioUnknownTitle": "Настройки радио неизвестны",
|
||||
"imageSend_radioUnknownBody": "Подключитесь к устройству, чтобы можно было рассчитать время эфирного времени.",
|
||||
"imageSend_longSendTitle": "Долгая передача",
|
||||
"imageSend_longSendBody": "Это будет поддерживать канал примерно {duration}.",
|
||||
"imageSend_floodNote": "Руководство по маршрутизации потоков: каждый ретрансатор в зоне перераспределяет каждый пакет, поэтому канал остаётся занятным дольше, чем это.",
|
||||
"imageSend_parityTitle": "Пакет восстановления",
|
||||
"imageSend_paritySubtitle": "Один дополнительный пакет. Групповые сообщения не подтверждаются, поэтому это позволяет получателю восстановить изображение, если один пакет потерян.",
|
||||
"imageSend_send": "Отправить",
|
||||
"imageSend_cancel": "Отмена",
|
||||
"imageSend_encodeFailed": "Это изображение не может быть закодировано.",
|
||||
"imageSend_codecDownloading": "Модель изображения всё ещё загружается.",
|
||||
"imageSend_codecUnavailable": "Отправка изображения на этом устройстве недоступна.",
|
||||
"imageSend_codecDisabled": "В настройках отключены сообщения с изображениями.",
|
||||
"imageSend_deviceUnsupported": "Это радио не может отправлять пакеты изображений. Подключите устройство, работающее на прошивке-компаньоне версии 13 или выше.",
|
||||
"imageSend_directMessagesUnsupported": "Изображения передаются как групповые данные, поэтому их можно отправлять только в канал, а не в личное сообщение.",
|
||||
"imageSend_tooLarge": "Изображение было закодировано в больше пакетов, чем позволяет формат сетки.",
|
||||
"imageSend_sentConfirmation": "Изображение отправлено как {count} {count, plural, =1{пакет} other{пакетов}}.",
|
||||
"imageSend_sendFailed": "Изображение не может быть отправлено: {error}",
|
||||
"imageSend_sendingProgress": "Отправка изображения — пакет {sent} из {total}",
|
||||
"receivedImage_senderPrefix": "Узел {prefix}",
|
||||
"receivedImage_incoming": "{received} из {total} пакетов",
|
||||
"receivedImage_queued": "Ожидание расшифровки",
|
||||
"receivedImage_tapToDecode": "Нажмите для декодирования",
|
||||
"receivedImage_decoding": "Реконструкция… примерно за 1 секунду",
|
||||
"receivedImage_incomplete": "Отсутствует изображение — {received} из {total} пакетов пришло",
|
||||
"receivedImage_corrupt": "Изображение не может быть восстановлено",
|
||||
"receivedImage_decoderMissing": "Получен изображение — декодирование изображения неправильное",
|
||||
"receivedImage_evicted": "Изображение больше не сохраняется",
|
||||
"receivedImage_retry": "Попробуйте снова",
|
||||
"receivedImage_decodeAgain": "Декодируйте снова",
|
||||
"receivedImage_openSettings": "Настройте",
|
||||
"receivedImage_tapToProcess": "Нажмите для обработки",
|
||||
"receivedImage_awaiting": "{bytes} байт · {packets} {packets, plural, =1{пакет} other{пакетов}}",
|
||||
"imageSend_secondsValue": "{seconds} секунд",
|
||||
"imageSend_minutesSecondsValue": "{minutes} м {seconds} с"
|
||||
}
|
||||
@@ -34,6 +34,8 @@
|
||||
"common_remove": "移除",
|
||||
"common_enable": "启用",
|
||||
"common_disable": "禁用",
|
||||
"common_autoRefresh": "自动刷新",
|
||||
"common_interval": "间隔",
|
||||
"common_reboot": "重启",
|
||||
"common_loading": "正在加载...",
|
||||
"common_notAvailable": "—",
|
||||
@@ -79,7 +81,7 @@
|
||||
"scanner_stop": "停止",
|
||||
"scanner_scan": "扫描",
|
||||
"device_quickSwitch": "快速切换",
|
||||
"device_meshcore": "MeshCore",
|
||||
"device_meshcore": "网格核心",
|
||||
"settings_title": "设置",
|
||||
"settings_deviceInfo": "设备信息",
|
||||
"settings_appSettings": "应用设置",
|
||||
@@ -103,12 +105,16 @@
|
||||
"settings_locationIntervalInvalid": "间隔时间必须至少为 60 秒,但不超过 86400 秒。",
|
||||
"settings_latitude": "纬度",
|
||||
"settings_longitude": "经度",
|
||||
"settings_autoZeroHopAdvertOnGpsUpdate": "GPS 更新时自动发送零跳广告",
|
||||
"settings_autoZeroHopAdvertOnGpsUpdateSubtitle": "当 GPS 位置变化时,发送零跳广告(需要在广告中包含位置)。",
|
||||
"settings_privacyMode": "隐私模式",
|
||||
"settings_privacyModeSubtitle": "在广告中隐藏姓名/位置",
|
||||
"settings_privacyModeToggle": "切换隐私模式以在广告中隐藏姓名和位置,保护个人信息。",
|
||||
"settings_privacyModeEnabled": "隐私模式已启用",
|
||||
"settings_privacyModeDisabled": "隐私模式已关闭",
|
||||
"settings_actions": "操作",
|
||||
"settings_deleteAllPaths": "Delete All Paths",
|
||||
"settings_deleteAllPathsSubtitle": "Clear all path data from contacts.",
|
||||
"settings_sendAdvertisement": "发送广播",
|
||||
"settings_sendAdvertisementSubtitle": "立即发送广播",
|
||||
"settings_advertisementSent": "已发送广播",
|
||||
@@ -250,6 +256,19 @@
|
||||
"appSettings_last6Hours": "过去6小时",
|
||||
"appSettings_last24Hours": "过去24小时",
|
||||
"appSettings_lastWeek": "上周",
|
||||
"appSettings_rasterTileSource": "栅格瓦片源",
|
||||
"appSettings_stadiaEndpoint": "Stadia 端点",
|
||||
"appSettings_stadiaApiKey": "Stadia API 密钥",
|
||||
"appSettings_stadiaApiKeyRequired": "使用 Stadia Maps 时必需",
|
||||
"appSettings_stadiaApiKeyConfigured": "已配置:{maskedKey}",
|
||||
"@appSettings_stadiaApiKeyConfigured": {
|
||||
"placeholders": {
|
||||
"maskedKey": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_stadiaApiKeyDialogDescription": "请输入你的 Stadia Maps API 密钥。该应用会使用它来请求栅格瓦片。",
|
||||
"appSettings_offlineMapCache": "离线地图缓存",
|
||||
"appSettings_noAreaSelected": "未选择任何区域",
|
||||
"appSettings_areaSelectedZoom": "已选择区域(缩放 {minZoom} - {maxZoom})",
|
||||
@@ -352,11 +371,8 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"channels_hashtagChannel": "标签频道",
|
||||
"channels_public": "公共",
|
||||
"channels_private": "私有",
|
||||
"channels_publicChannel": "公共频道",
|
||||
"channels_privateChannel": "私有频道",
|
||||
"channels_editChannel": "编辑频道",
|
||||
"channels_muteChannel": "静音频道",
|
||||
"channels_unmuteChannel": "取消静音频道",
|
||||
@@ -403,6 +419,22 @@
|
||||
}
|
||||
},
|
||||
"channels_smazCompression": "SMAZ 压缩",
|
||||
"channels_cyr2latCompression": "Cyr2Lat 压缩",
|
||||
"channels_cyr2latCompressionDscr": "发送时将一些西里尔字符替换为拉丁字符。",
|
||||
"channels_cyr2latSettingsHeading": "Cyr2Lat 設定",
|
||||
"channels_cyr2latSettingsSubheading": "替換清單",
|
||||
"channels_cyr2latSettingsDscr": "編輯 JSON 字元替換設定檔",
|
||||
"channels_cyr2latSettingsDialogHint": "JSON 替換映射表",
|
||||
"channels_cyr2latSettingsDialogWrongJSON": "JSON 格式錯誤:{error}",
|
||||
"settings_cyr2latProfileAdd": "新增 Cyr2Lat 設定檔",
|
||||
"settings_cyr2latProfileName": "設定檔名稱",
|
||||
"settings_cyr2latProfileNameEmpty": "設定檔名稱不能為空",
|
||||
"settings_cyr2latProfileAdded": "設定檔已成功新增",
|
||||
"settings_cyr2latProfileUpdated": "設定檔已成功更新",
|
||||
"settings_cyr2latProfileEdit": "編輯 Cyr2Lat 設定檔",
|
||||
"settings_cyr2latProfileDelete": "刪除 Cyr2Lat 設定檔",
|
||||
"settings_cyr2latProfileDeleted": "設定檔已成功刪除",
|
||||
"settings_cyr2latProfileDeleteDscr": "您確定要刪除設定檔 \"{name}\" 嗎?",
|
||||
"channels_channelUpdated": "频道 \"{name}\" 已更新",
|
||||
"@channels_channelUpdated": {
|
||||
"placeholders": {
|
||||
@@ -414,7 +446,7 @@
|
||||
"channels_publicChannelAdded": "已添加公共频道",
|
||||
"channels_sortBy": "排序方式",
|
||||
"channels_sortManual": "手动",
|
||||
"channels_sortAZ": "A-Z",
|
||||
"channels_sortAZ": "A到Z",
|
||||
"channels_sortLatestMessages": "最新消息",
|
||||
"channels_sortUnread": "未读",
|
||||
"channels_createPrivateChannel": "创建私有频道",
|
||||
@@ -779,6 +811,56 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_cachedTilesLabel": "Cached tiles",
|
||||
"mapCache_cachedTileSummaryLabel": "Cached tile summary",
|
||||
"mapCache_bulkDownloadDisabledForSource": "Offline bulk downloads are disabled for {source}.",
|
||||
"@mapCache_bulkDownloadDisabledForSource": {
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_bulkDownloadDisabledInConfig": "Offline bulk downloads are disabled for {source} in this app configuration.",
|
||||
"@mapCache_bulkDownloadDisabledInConfig": {
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summarySource": "Source: {source}",
|
||||
"@mapCache_summarySource": {
|
||||
"placeholders": {
|
||||
"source": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summaryCachedTilesForSource": "Cached tiles for source: {count}",
|
||||
"@mapCache_summaryCachedTilesForSource": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summaryCachedInSelection": "Cached in selected area/zoom: {count}",
|
||||
"@mapCache_summaryCachedInSelection": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_summaryApproxCacheSize": "Approx cache size: {size}",
|
||||
"@mapCache_summaryApproxCacheSize": {
|
||||
"placeholders": {
|
||||
"size": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"mapCache_boundsLabel": "北 {north}, 南 {south}, 东 {east}, 西 {west}",
|
||||
"@mapCache_boundsLabel": {
|
||||
"placeholders": {
|
||||
@@ -1022,7 +1104,7 @@
|
||||
"repeater_guestPasswordHelper": "只读访问密码",
|
||||
"repeater_radioSettings": "无线电设置",
|
||||
"repeater_frequencyMhz": "频率 (MHz)",
|
||||
"repeater_frequencyHelper": "300-2500 MHz",
|
||||
"repeater_frequencyHelper": "300-2500 兆赫",
|
||||
"repeater_txPower": "TX 功率",
|
||||
"repeater_txPowerHelper": "1-30 dBm",
|
||||
"repeater_bandwidth": "带宽",
|
||||
@@ -1089,6 +1171,81 @@
|
||||
},
|
||||
"repeater_confirm": "确认",
|
||||
"repeater_settingsSaved": "设置保存成功",
|
||||
"repeater_rxGain": "增强的 RX 增益",
|
||||
"repeater_rxGainHelper": "更高的灵敏度,更大的电流消耗(仅适用于 SX1262/SX1268)",
|
||||
"repeater_refreshRxGain": "重新启动增强型 RX 功能",
|
||||
"repeater_multiAcks": "多重确认",
|
||||
"repeater_multiAcksSubtitle": "通过多个路径确认消息,以提高传递效率。",
|
||||
"repeater_refreshMultiAcks": "刷新多个确认",
|
||||
"repeater_networkHealth": "网络健康",
|
||||
"repeater_loopDetect": "循环检测",
|
||||
"repeater_loopDetectHelper": "创建看起来像路由环的“洪水包”",
|
||||
"repeater_loopDetectOff": "离开",
|
||||
"repeater_loopDetectMinimal": "最少",
|
||||
"repeater_loopDetectModerate": "适度的",
|
||||
"repeater_loopDetectStrict": "严格",
|
||||
"repeater_dutyCycle": "工作周期",
|
||||
"repeater_dutyCycleHelper": "最大可使用的空闲时间百分比",
|
||||
"repeater_dutyCyclePercent": "{percent}%",
|
||||
"@repeater_dutyCyclePercent": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_ownerInfo": "运营商信息",
|
||||
"repeater_ownerInfoHelper": "此复播器的公共元数据",
|
||||
"repeater_refreshOwnerInfo": "刷新操作员信息",
|
||||
"repeater_floodMax": "最大跳跃次数",
|
||||
"repeater_floodMaxHelper": "一个洪水包中,最大可以传输的跳数 (0-64)",
|
||||
"repeater_advancedSettings": "高级",
|
||||
"repeater_advancedSettingsSubtitle": "高级操作员使用的调节旋钮",
|
||||
"repeater_pathHashMode": "路径哈希模式",
|
||||
"repeater_pathHashModeHelper": "用于在洪泛路径/环路检测标签中编码此中继器 ID 的字节数。0=1 字节(256 个 ID,最多 64 跳),1=2 字节(65K 个 ID,最多 32 跳),2=3 字节(16M 个 ID,最多 21 跳)。v1.14 之前的固件始终使用 1 字节路径;v1.14 及更新版本可配置为 2 或 3 字节路径。",
|
||||
"repeater_txDelay": "洪水(德克萨斯州)延误",
|
||||
"repeater_txDelayHelper": "对于洪水流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.5)的倍数。 较高的值意味着更少的冲突,但传输速度会变慢。",
|
||||
"repeater_directTxDelay": "直接的 TX 延迟",
|
||||
"repeater_directTxDelayHelper": "对于直接(非广播)流量,重新传输间隔应设置为包的传输时间(0-2,默认值为0.3)的倍数。",
|
||||
"repeater_intThresh": "干扰阈值",
|
||||
"repeater_intThreshHelper": "将阈值传递给射频信号的噪声水平校准,使其能够拒绝高于该水平的干扰。 0 表示禁用——只有在您在嘈杂频段中看到 RX 错误时才启用。",
|
||||
"repeater_agcResetInterval": "AGC 恢复间隔",
|
||||
"repeater_agcResetIntervalHelper": "为了从失控的增益状态中恢复,应该多久重置收音机的自动增益控制?设置为“秒”,每次重置间隔为4秒。将此选项设置为“0”将禁用周期性重置。",
|
||||
"repeater_actionsTitle": "行动",
|
||||
"repeater_sendAdvert": "发布防洪广告",
|
||||
"repeater_sendAdvertSubtitle": "通过网络播放防洪广告",
|
||||
"repeater_sendAdvertZeroHop": "发送零跳广告",
|
||||
"repeater_sendAdvertZeroHopSubtitle": "进行单跳广告广播(不使用中继)",
|
||||
"repeater_clockSync": "现在同步时钟",
|
||||
"repeater_clockSyncSubtitle": "将手机的时间设置为与中继器同步",
|
||||
"repeater_actionSucceeded": "{action} 成功",
|
||||
"@repeater_actionSucceeded": {
|
||||
"placeholders": {
|
||||
"action": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_actionFailed": "{action} 失败:{error}",
|
||||
"@repeater_actionFailed": {
|
||||
"placeholders": {
|
||||
"action": {
|
||||
"type": "String"
|
||||
},
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_settingsSavedRebootNeeded": "设置已保存 — 重启发射器以应用",
|
||||
"repeater_settingsPartialFailure": "部分设置失败:{failures}",
|
||||
"@repeater_settingsPartialFailure": {
|
||||
"placeholders": {
|
||||
"failures": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repeater_errorSavingSettings": "保存设置时出错:{error}",
|
||||
"@repeater_errorSavingSettings": {
|
||||
"placeholders": {
|
||||
@@ -1100,11 +1257,9 @@
|
||||
"repeater_refreshBasicSettings": "刷新基本设置",
|
||||
"repeater_refreshRadioSettings": "刷新无线电设置",
|
||||
"repeater_refreshTxPower": "刷新 TX 功率",
|
||||
"repeater_refreshLocationSettings": "刷新位置设置",
|
||||
"repeater_refreshPacketForwarding": "刷新包转发",
|
||||
"repeater_refreshGuestAccess": "刷新访客权限",
|
||||
"repeater_refreshPrivacyMode": "刷新隐私模式",
|
||||
"repeater_refreshAdvertisementSettings": "刷新广播设置",
|
||||
"repeater_refreshed": "{label} 已刷新",
|
||||
"@repeater_refreshed": {
|
||||
"placeholders": {
|
||||
@@ -1222,6 +1377,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"telemetry_digitalInputLabel": "数字输入",
|
||||
"telemetry_digitalOutputLabel": "数字输出",
|
||||
"telemetry_analogInputLabel": "模拟输入",
|
||||
"telemetry_analogOutputLabel": "模拟输出",
|
||||
"telemetry_genericLabel": "通用传感器",
|
||||
"telemetry_luminosityLabel": "照度",
|
||||
"telemetry_presenceLabel": "存在检测",
|
||||
"telemetry_humidityLabel": "湿度",
|
||||
"telemetry_accelerometerLabel": "加速度计",
|
||||
"telemetry_pressureLabel": "气压",
|
||||
"telemetry_altitudeLabel": "高度",
|
||||
"telemetry_frequencyLabel": "频率",
|
||||
"telemetry_percentageLabel": "百分比",
|
||||
"telemetry_concentrationLabel": "浓度",
|
||||
"telemetry_powerLabel": "功率",
|
||||
"telemetry_distanceLabel": "距离",
|
||||
"telemetry_energyLabel": "能量",
|
||||
"telemetry_directionLabel": "方向",
|
||||
"telemetry_timeLabel": "时间",
|
||||
"telemetry_gyrometerLabel": "陀螺仪",
|
||||
"telemetry_colourLabel": "颜色",
|
||||
"telemetry_gpsLabel": "GPS",
|
||||
"telemetry_switchLabel": "开关",
|
||||
"telemetry_polylineLabel": "折线",
|
||||
"telemetry_altitudeValue": "{meters} m",
|
||||
"telemetry_frequencyValue": "{hertz} Hz",
|
||||
"telemetry_pressureValue": "{hpa} hPa",
|
||||
"telemetry_luminosityValue": "{lux} lx",
|
||||
"telemetry_powerValue": "{watts} W",
|
||||
"telemetry_distanceValue": "{meters} m",
|
||||
"telemetry_energyValue": "{kilowattHours} kWh",
|
||||
"telemetry_directionValue": "{degrees}°",
|
||||
"telemetry_concentrationValue": "{ppm} ppm",
|
||||
"telemetry_percentageValue": "{percent}%",
|
||||
"telemetry_analogValue": "{value}",
|
||||
"telemetry_autoFetchQuantity": "请求次数",
|
||||
"telemetry_error": "无法获取数据",
|
||||
"telemetry_noData": "暂无遥测数据",
|
||||
"telemetry_channelTitle": "频道 {channel}",
|
||||
"@telemetry_channelTitle": {
|
||||
@@ -1247,7 +1439,7 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"telemetry_voltageValue": "{volts}V",
|
||||
"telemetry_voltageValue": "{volts}伏",
|
||||
"@telemetry_voltageValue": {
|
||||
"placeholders": {
|
||||
"volts": {
|
||||
@@ -1541,7 +1733,7 @@
|
||||
"listFilter_sortBy": "排序方式",
|
||||
"listFilter_latestMessages": "最新消息",
|
||||
"listFilter_heardRecently": "最近听到",
|
||||
"listFilter_az": "A-Z",
|
||||
"listFilter_az": "A到Z",
|
||||
"listFilter_filters": "筛选",
|
||||
"listFilter_all": "全部",
|
||||
"listFilter_users": "用户",
|
||||
@@ -1554,7 +1746,7 @@
|
||||
"pathTrace_notAvailable": "无法获取路径信息。",
|
||||
"pathTrace_refreshTooltip": "刷新路径追踪",
|
||||
"contacts_pathTrace": "路径追踪",
|
||||
"contacts_ping": "Ping",
|
||||
"contacts_ping": "乒",
|
||||
"contacts_repeaterPathTrace": "Trace 转发节点",
|
||||
"contacts_repeaterPing": "Ping 转发节点",
|
||||
"contacts_roomPathTrace": "Trace 房间服务器",
|
||||
@@ -1927,13 +2119,6 @@
|
||||
"contact_settings": "联系人设置",
|
||||
"contact_teleLocSubtitle": "允许共享位置数据",
|
||||
"contact_telemetry": "遥测数据",
|
||||
"@settings_multiAck": {
|
||||
"placeholders": {
|
||||
"value": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_maxRouteWeight": "最大路径重量",
|
||||
"appSettings_initialRouteWeightSubtitle": "新发现路径的初始重量",
|
||||
"appSettings_initialRouteWeight": "初始路线权重",
|
||||
@@ -1945,7 +2130,7 @@
|
||||
"appSettings_maxMessageRetries": "最大消息重试次数",
|
||||
"appSettings_maxMessageRetriesSubtitle": "在将消息标记为失败之前,允许尝试的次数",
|
||||
"path_routeWeight": "{weight}/{max}",
|
||||
"settings_multiAck": "多重ACK:{value}",
|
||||
"settings_multiAck": "多重ACK",
|
||||
"settings_telemetryModeUpdated": "遥测模式已更新",
|
||||
"map_showOverlaps": "重复键重叠",
|
||||
"map_runTraceWithReturnPath": "沿着相同的路径返回",
|
||||
@@ -2012,6 +2197,37 @@
|
||||
"radioStats_stripWaiting": "正在获取收音机数据…",
|
||||
"radioStats_settingsTile": "广播统计数据",
|
||||
"radioStats_settingsSubtitle": "噪声水平、RSSI、信噪比和空中时间",
|
||||
"@translation_downloadFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_title": "翻译",
|
||||
"translation_enableSubtitle": "翻译收到的消息,并允许在发送前进行翻译。",
|
||||
"translation_composerTitle": "在发送之前进行翻译",
|
||||
"translation_enableTitle": "启用翻译功能",
|
||||
"translation_composerSubtitle": "控制作曲家翻译图标的默认状态。",
|
||||
"translation_autoIncomingTitle": "自动翻译消息",
|
||||
"translation_autoIncomingSubtitle": "自动为通知以及聊天或频道翻译消息。",
|
||||
"translation_translateMessage": "翻译消息",
|
||||
"translation_targetLanguage": "目标语言",
|
||||
"translation_useAppLanguage": "使用应用程序语言",
|
||||
"translation_downloadedModelLabel": "下载的模型",
|
||||
"translation_presetModelLabel": "预设的 Hugging Face 模型",
|
||||
"translation_downloadModel": "下载模型",
|
||||
"translation_manualUrlLabel": "手动模型网址",
|
||||
"translation_downloading": "正在下载...",
|
||||
"translation_working": "工作中...",
|
||||
"translation_stop": "停止",
|
||||
"translation_mergingChunks": "将下载的片段合并成最终文件...",
|
||||
"translation_downloadedModels": "下载的模型",
|
||||
"translation_deleteModel": "删除模型",
|
||||
"translation_modelDownloaded": "翻译模型已下载。",
|
||||
"translation_downloadStopped": "下载已停止。",
|
||||
"translation_downloadFailed": "下载失败:{error}",
|
||||
"translation_enterUrlFirst": "首先,请输入模型的 URL。",
|
||||
"@scanner_linuxPairingPinPrompt": {
|
||||
"placeholders": {
|
||||
"deviceName": {
|
||||
@@ -2019,10 +2235,602 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner_linuxPairingShowPin": "显示 PIN码",
|
||||
"scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN(如果没有,请留空)。",
|
||||
"scanner_linuxPairingPinTitle": "蓝牙配对 PIN",
|
||||
"scanner_linuxPairingPinPrompt": "输入 {deviceName} 的 PIN 码(如果为空,则留空)。",
|
||||
"scanner_linuxPairingHidePin": "隐藏 PIN",
|
||||
"scanner_linuxPairingShowPin": "显示PIN码",
|
||||
"@translation_translateTo": {
|
||||
"placeholders": {
|
||||
"language": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"translation_composerDisabledHint": "使用原始的打字方式发送消息。",
|
||||
"translation_messageTranslation": "消息翻译",
|
||||
"translation_composerEnabledHint": "消息将在发送前进行翻译。",
|
||||
"translation_translateBeforeSending": "在发送前进行翻译",
|
||||
"translation_translateTo": "翻译成 {language}",
|
||||
"translation_translationOptions": "翻译选项",
|
||||
"translation_systemLanguage": "系统语言",
|
||||
"repeater_cliQuickDiscovery": "发现邻居",
|
||||
"repeater_cliQuickClockSync": "同步时钟"
|
||||
"repeater_cliQuickClockSync": "同步时钟",
|
||||
"@repeater_clockSyncAfterLogin": {
|
||||
"description": "Repeater setting: auto sync device clock after successful login"
|
||||
},
|
||||
"@repeater_clockSyncAfterLoginSubtitle": {
|
||||
"description": "Repeater setting subtitle: describes the clock sync after login behavior"
|
||||
},
|
||||
"repeater_clockSyncAfterLogin": "登录后,自动同步时钟",
|
||||
"repeater_clockSyncAfterLoginSubtitle": "在成功登录后,自动发送“时钟同步”指令。",
|
||||
"repeater_guestTools": "访客工具",
|
||||
"repeater_guest": "重复器信息",
|
||||
"chat_sendMessage": "发送消息",
|
||||
"room_guest": "服务器信息",
|
||||
"repeater_getCategory": "获取值",
|
||||
"repeater_powerMgmt": "电源管理",
|
||||
"repeater_sensors": "传感器",
|
||||
"repeater_cliHelpPowerOff": "关闭设备。(不应有任何响应)",
|
||||
"repeater_cliHelpClkReboot": "将时钟重置为已知的时间点,并重启设备。",
|
||||
"repeater_cliHelpAdvertZeroHop": "发送无中继广告(仅限于邻居)。",
|
||||
"repeater_cliHelpStartOta": "在支持的板上启动通过空中进行固件更新。",
|
||||
"repeater_cliHelpTime": "将设备时钟设置为给定的 Unix 纪元秒。时钟不能倒退。",
|
||||
"repeater_cliHelpBoard": "显示制造商/硬件标识。",
|
||||
"repeater_cliHelpDiscoverNeighbors": "向附近的邻居发送节点发现请求。(仅限中继器)",
|
||||
"repeater_cliHelpPowersaving": "显示节能模式是否已开启或已关闭。",
|
||||
"repeater_cliHelpPowersavingOnOff": "启用或禁用节能模式(如果支持)。",
|
||||
"repeater_cliHelpErase": "(仅适用于序列模式)格式化设备的文件系统。清除所有设置和联系人。",
|
||||
"repeater_cliHelpSetDutyCycle": "设定允许的最大传输时段百分比(1-100)。内部调整空闲时间因子。",
|
||||
"repeater_cliHelpSetPrvKey": "(仅适用于序列号)替换设备身份私钥。需要重启才能应用。生成一个新的公钥。",
|
||||
"repeater_cliHelpSetRadioRxGain": "(仅适用于 SX126x 芯片) 启用增强型 RX 增益,以在较高电流下提高灵敏度。",
|
||||
"repeater_cliHelpSetOwnerInfo": "设置广告中包含的联系人信息字符串。使用 '|' 作为换行符。",
|
||||
"repeater_cliHelpSetPathHashMode": "设置路径哈希模式。 0 = 传统模式,1 = 标准模式,2 = 严格模式。 影响路由路径的匹配方式。",
|
||||
"repeater_cliHelpSetLoopDetect": "设置路由环检测的灵敏度:关闭、低、中、或高。",
|
||||
"repeater_cliHelpSetFreq": "(仅限串行模式)快速设置频率。需要重启。 建议使用“设置收音机参数”功能,以便设置完整的收音机参数。",
|
||||
"repeater_cliHelpSetBridgeChannel": "(仅适用于 ESPNow 桥)设置桥使用的 WiFi 频道(1-14)。",
|
||||
"repeater_cliHelpGetName": "显示配置的节点名称。",
|
||||
"repeater_cliHelpGetRole": "显示固件的功能(如:中继器、房间服务器等)。",
|
||||
"repeater_cliHelpGetPublicKey": "显示设备的公钥。",
|
||||
"repeater_cliHelpGetPrvKey": "(仅适用于序列号)显示设备的私钥。请将其视为机密信息。",
|
||||
"repeater_cliHelpGetRepeat": "显示数据包转发(作为中继器)是否已启用或已禁用。",
|
||||
"repeater_cliHelpGetTx": "显示当前的发射功率(以dBm为单位)。",
|
||||
"repeater_cliHelpGetFreq": "显示配置的射频频率(以兆赫兹为单位)。",
|
||||
"repeater_cliHelpGetRadio": "显示完整的无线电参数:频率、带宽、扩频因子、编码速率。",
|
||||
"repeater_cliHelpGetRadioRxGain": "(仅适用于 SX126x 模块)显示 RX 放大器的状态。",
|
||||
"repeater_cliHelpGetAf": "显示当前的空闲时间系数。",
|
||||
"repeater_cliHelpGetDutyCycle": "显示当前允许的占空比(以百分比表示)。",
|
||||
"repeater_cliHelpGetIntThresh": "显示信道干扰阈值(以dB为单位)。",
|
||||
"repeater_cliHelpGetAgcResetInterval": "显示 AGC 重置的间隔时间(以秒为单位)。",
|
||||
"repeater_cliHelpGetMultiAcks": "显示双重确认模式是否已启用(1)或已禁用(0)。",
|
||||
"repeater_cliHelpGetAllowReadOnly": "显示是否允许访客仅限查看权限。",
|
||||
"repeater_cliHelpGetAdvertInterval": "显示本地广告的时间间隔,单位为分钟。",
|
||||
"repeater_cliHelpGetFloodAdvertInterval": "显示洪水广告的播放时间间隔,以小时为单位。",
|
||||
"repeater_cliHelpGetGuestPassword": "显示已配置的访客密码。",
|
||||
"repeater_cliHelpGetLat": "显示已配置的纬度。",
|
||||
"repeater_cliHelpGetLon": "显示已配置的经度。",
|
||||
"repeater_cliHelpGetRxDelay": "显示 rxdelay 的基本值。",
|
||||
"repeater_cliHelpGetTxDelay": "显示洪水模式下的传输延迟系数。",
|
||||
"repeater_cliHelpGetDirectTxDelay": "显示直接模式下的时延系数。",
|
||||
"repeater_cliHelpGetFloodMax": "显示最大洪水传播次数。",
|
||||
"repeater_cliHelpGetOwnerInfo": "显示所有者的联系信息。",
|
||||
"repeater_cliHelpGetPathHashMode": "显示哈希模式(0/1/2)。",
|
||||
"repeater_cliHelpGetLoopDetect": "显示循环检测的灵敏度。",
|
||||
"repeater_cliHelpGetAcl": "(仅适用于序列号)列出复用器上的访问控制条目。",
|
||||
"repeater_cliHelpGetBridgeEnabled": "显示桥是否已启用。",
|
||||
"repeater_cliHelpGetBridgeDelay": "显示桥梁延迟的时间,单位为毫秒。",
|
||||
"repeater_cliHelpGetBridgeSource": "显示桥接设备是否接收或发送 RX 或 TX 类型的数据包。",
|
||||
"repeater_cliHelpGetBridgeBaud": "(仅限 RS232 桥)显示桥的波特率。",
|
||||
"repeater_cliHelpGetBridgeChannel": "(仅适用于 ESPNow 桥)显示桥的 WiFi 通道。",
|
||||
"repeater_cliHelpGetBridgeSecret": "(仅适用于 ESPNow 桥)显示桥的共享密钥。",
|
||||
"repeater_cliHelpGetBootloaderVer": "(仅适用于NRF52)显示引导程序版本。",
|
||||
"repeater_cliHelpGetAdcMultiplier": "显示 ADC 乘数(电池电压缩放)。",
|
||||
"repeater_cliHelpGetPwrMgtSupport": "报告董事会是否支持电源管理功能。",
|
||||
"repeater_cliHelpGetPwrMgtSource": "显示当前的电源:外部电源或电池。",
|
||||
"repeater_cliHelpGetPwrMgtBootReason": "显示最近的重置和关闭原因。",
|
||||
"repeater_cliHelpGetPwrMgtBootMv": "显示启动时的电池电压,单位为毫伏 (mV)。",
|
||||
"repeater_cliHelpSensorGet": "通过按键读取自定义传感器设置。",
|
||||
"repeater_cliHelpSensorSet": "编写自定义传感器设置。",
|
||||
"repeater_cliHelpSensorList": "列出所有自定义传感器设置,并按可选的起始索引进行分页显示。",
|
||||
"repeater_cliHelpRegionDefault": "显示当前默认的区域范围。",
|
||||
"repeater_cliHelpRegionDefaultSet": "设置默认的区域范围。使用 \"<null>\" 可以清除。",
|
||||
"repeater_cliHelpRegionListAllowed": "列出允许洪水交通的区域。",
|
||||
"repeater_cliHelpRegionListDenied": "列出禁止洪水交通的区域。",
|
||||
"repeater_cliHelpStatsPackets": "(仅显示序列信息)显示数据包级别的统计信息。",
|
||||
"repeater_cliHelpStatsRadio": "(仅显示序列信息)显示收音机相关统计数据。",
|
||||
"repeater_cliHelpStatsCore": "(仅显示序列号)显示核心固件统计信息。",
|
||||
"common_done": "Done",
|
||||
"background_serviceTitle": "MeshCore running",
|
||||
"background_serviceText": "Keeping BLE connected",
|
||||
"appSettings_translationModelDeleted": "Deleted {name}",
|
||||
"@appSettings_translationModelDeleted": {
|
||||
"placeholders": {
|
||||
"name": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appSettings_translationModelDeleteFailed": "Failed to delete: {error}",
|
||||
"@appSettings_translationModelDeleteFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"channels_channelUpdateFailed": "Failed to update channel: {error}",
|
||||
"@channels_channelUpdateFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"map_type": "Type",
|
||||
"map_path": "Path",
|
||||
"map_location": "Location",
|
||||
"map_estLocation": "Est. Location",
|
||||
"map_publicKey": "Public Key",
|
||||
"map_publicKeyPrefixHint": "e.g. ab12",
|
||||
"contact_typeChat": "Chat",
|
||||
"contact_typeRepeater": "Repeater",
|
||||
"contact_typeRoom": "Room",
|
||||
"contact_typeSensor": "Sensor",
|
||||
"contact_typeUnknown": "Unknown",
|
||||
"channels_via": "via {path}",
|
||||
"chat_score": "Score",
|
||||
"map_sharedAt": "已分享",
|
||||
"@losBlockedSpotChip": {
|
||||
"placeholders": {
|
||||
"distance": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@losSelectedObstructionDetails": {
|
||||
"placeholders": {
|
||||
"obstruction": {
|
||||
"type": "String"
|
||||
},
|
||||
"heightUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceFromA": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceUnit": {
|
||||
"type": "String"
|
||||
},
|
||||
"distanceFromB": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"losBlockedSpotsTitle": "被占用区域",
|
||||
"losBlockedSpotsHint": "点击地图上的某个被遮盖的区域,以突出显示该区域。",
|
||||
"losSelectedObstructionTitle": "选择性阻碍",
|
||||
"losBlockedSpotChip": "{distance} {distanceUnit} • {obstruction} {heightUnit}",
|
||||
"losSelectedObstructionDetails": "Blocked by {obstruction} {heightUnit}, {distanceFromA} from A and {distanceFromB} from B ({distanceUnit}).",
|
||||
"chat_markAsUnread": "标记为未读",
|
||||
"settings_companionDebugLog": "调试日志",
|
||||
"chat_newMessages": "新的消息",
|
||||
"settings_companionDebugLogSubtitle": "BLE/TCP/USB 协议、响应和原始数据",
|
||||
"repeater_chanUtil": "频道利用率",
|
||||
"@routing_lastWorked": {
|
||||
"placeholders": {
|
||||
"when": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@routing_deliveryCounts": {
|
||||
"placeholders": {
|
||||
"successes": {
|
||||
"type": "int"
|
||||
},
|
||||
"failures": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathEditor_hopCounter": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathEditor_invalidTokens": {
|
||||
"placeholders": {
|
||||
"tokens": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channels_communityShortId": {
|
||||
"placeholders": {
|
||||
"id": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"messageStatus_sent": "发送",
|
||||
"common_undo": "撤销",
|
||||
"messageStatus_delivered": "已送达",
|
||||
"messageStatus_pending": "发送",
|
||||
"messageStatus_failed": "发送失败",
|
||||
"messageStatus_repeated": "多次听到",
|
||||
"contacts_moreOptions": "更多选择",
|
||||
"contacts_searchOpen": "搜索联系人",
|
||||
"contacts_searchClose": "高级搜索",
|
||||
"routing_title": "路由",
|
||||
"routing_modeAuto": "汽车",
|
||||
"routing_modeFlood": "洪水",
|
||||
"routing_modeManual": "手册",
|
||||
"routing_modeAutoHint": "自动选择已知最佳路径,当没有已知路径时,则进行“洪水”搜索。",
|
||||
"routing_modeFloodHint": "通过所有中继站进行广播。 这种方式最可靠,但占用更多的时间。",
|
||||
"routing_modeManualHint": "总是按照您设置的路径进行导航。",
|
||||
"routing_currentRoute": "当前路线",
|
||||
"routing_directNoHops": "直接连接— 无中继跳",
|
||||
"routing_noPathYet": "目前还没有找到路径。直到找到路径,才会收到后续消息。",
|
||||
"routing_floodBroadcast": "通过所有中继器进行广播",
|
||||
"routing_editPath": "编辑路径",
|
||||
"routing_forgetPath": "忘记原路",
|
||||
"routing_knownPaths": "已知的路径",
|
||||
"routing_knownPathsHint": "点击该路径以切换到它。",
|
||||
"routing_inUse": "使用中",
|
||||
"routing_qualityStrong": "强劲的初始阶段",
|
||||
"routing_qualityGood": "不错的开端",
|
||||
"routing_qualityFair": "第一次尝试,结果良好",
|
||||
"routing_qualityWorked": "已完成",
|
||||
"routing_qualityFlood": "通过新闻报道",
|
||||
"routing_qualityUntested": "未经测试",
|
||||
"routing_lastWorked": "工作于 {when}",
|
||||
"routing_neverWorked": "从未得到证实",
|
||||
"routing_floodDelivery": "洪水配送",
|
||||
"pathEditor_title": "构建路径",
|
||||
"pathEditor_noHops": "目前还没有添加任何啤酒花。点击下面的“添加”按钮,按顺序添加,或者直接保存,不添加任何啤酒花。",
|
||||
"pathEditor_addHops": "按照顺序添加啤酒花",
|
||||
"pathEditor_searchRepeaters": "重复搜索",
|
||||
"pathEditor_advancedHex": "高级:原始十六进制路径",
|
||||
"pathEditor_hexLabel": "十六进制前缀",
|
||||
"pathEditor_hexHelper": "每次跳跃,使用两个十六进制字符,用逗号分隔。",
|
||||
"pathEditor_invalidTokens": "无效:{tokens}",
|
||||
"pathEditor_tooManyHops": "最多 64 个跳跃",
|
||||
"pathEditor_usePath": "请使用此路径",
|
||||
"pathEditor_removeHop": "去除啤酒花",
|
||||
"routing_deliveryCounts": "{successes} delivered, {failures} failed",
|
||||
"pathEditor_hopCounter": "{count} of 64 hops",
|
||||
"pathEditor_unknownHop": "未知的重复器",
|
||||
"map_zoomIn": "放大",
|
||||
"map_zoomOut": "放大",
|
||||
"map_centerMap": "中心地图",
|
||||
"chrome_bluetoothRequiresChromium": "Web Bluetooth 需要 Chromium 浏览器",
|
||||
"channels_communityShortId": "ID:{id}...",
|
||||
"pathTrace_legendGpsConfirmed": "通过GPS确认",
|
||||
"pathTrace_legendInferred": "推测的位置",
|
||||
"@pathMap_hopOf": {
|
||||
"placeholders": {
|
||||
"current": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_observedPaths": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_alternate": {
|
||||
"placeholders": {
|
||||
"index": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_hopCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_gpsCount": {
|
||||
"placeholders": {
|
||||
"confirmed": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_sharedNodeCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@pathMap_partialAnimation": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"map_online": "在线",
|
||||
"scanner_bluetoothWebUnsupported": "浏览器不支持蓝牙,请改用 USB 连接。",
|
||||
"map_activity": "活动",
|
||||
"map_searchHint": "搜索节点名称或ID",
|
||||
"map_recent": "最近",
|
||||
"map_visible": "可见",
|
||||
"map_stale": "过时",
|
||||
"map_hidden": "已隐藏",
|
||||
"map_centerOnNode": "以节点为中心",
|
||||
"map_details": "详细信息",
|
||||
"map_noGps": "无 GPS",
|
||||
"map_noResults": "未找到匹配的节点",
|
||||
"pathMap_viewSingle": "单条",
|
||||
"pathMap_viewCombined": "综合",
|
||||
"pathMap_play": "播放",
|
||||
"pathMap_pause": "暂停",
|
||||
"pathMap_replay": "重播",
|
||||
"pathMap_stepBack": "上一跳",
|
||||
"pathMap_stepForward": "下一跳",
|
||||
"pathMap_animationOn": "显示数据包动画",
|
||||
"pathMap_animationOff": "隐藏数据包动画",
|
||||
"pathMap_hopOf": "第 {current} 跳,共 {total} 跳",
|
||||
"pathMap_observedPaths": "观测到的路径:{count}",
|
||||
"pathMap_primary": "主路径",
|
||||
"pathMap_alternate": "备用 {index}",
|
||||
"pathMap_hopCount": "{count, plural, =1{1 跳} other{{count} 跳}}",
|
||||
"pathMap_legendShared": "共享路段",
|
||||
"pathMap_legendEstimated": "估算路段",
|
||||
"pathMap_sharedNodeCount": "已被 {count} 条路径使用",
|
||||
"pathMap_showAllPaths": "显示全部",
|
||||
"pathMap_hidePath": "隐藏路径",
|
||||
"pathMap_showPath": "显示路径",
|
||||
"pathMap_collapsePanel": "收起面板",
|
||||
"pathMap_expandPanel": "展开面板",
|
||||
"pathMap_noLocation": "无位置",
|
||||
"pathMap_followPacket": "锁定视图跟随数据包",
|
||||
"pathMap_unfollowPacket": "解锁视图跟随",
|
||||
"pathMap_gpsCount": "{confirmed}/{total} GPS",
|
||||
"pathMap_partialAnimation": "{count, plural, =1{1 跳无位置信息 — 显示的路径不完整} other{{count} 跳无位置信息 — 显示的路径不完整}}",
|
||||
"@settings_deleteRegionConfirm": {
|
||||
"placeholders": {
|
||||
"region": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@channels_regionSetTo": {
|
||||
"placeholders": {
|
||||
"region": {
|
||||
"type": "String",
|
||||
"example": "de-mitte"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@chat_imagePickFailed": {
|
||||
"description": "Shown when picking or reading a photo to send fails"
|
||||
},
|
||||
"@repeater_pubKeyPrefixHelper": {
|
||||
"placeholders": {
|
||||
"tries": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_packetsCount": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_range": {
|
||||
"placeholders": {
|
||||
"min": {
|
||||
"type": "String"
|
||||
},
|
||||
"max": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_longSendBody": {
|
||||
"placeholders": {
|
||||
"duration": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_sentConfirmation": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_sendFailed": {
|
||||
"placeholders": {
|
||||
"error": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_sendingProgress": {
|
||||
"placeholders": {
|
||||
"sent": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_senderPrefix": {
|
||||
"placeholders": {
|
||||
"prefix": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_incoming": {
|
||||
"placeholders": {
|
||||
"received": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_incomplete": {
|
||||
"placeholders": {
|
||||
"received": {
|
||||
"type": "int"
|
||||
},
|
||||
"total": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@receivedImage_awaiting": {
|
||||
"placeholders": {
|
||||
"bytes": {
|
||||
"type": "int"
|
||||
},
|
||||
"packets": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_secondsValue": {
|
||||
"placeholders": {
|
||||
"seconds": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"@imageSend_minutesSecondsValue": {
|
||||
"placeholders": {
|
||||
"minutes": {
|
||||
"type": "String"
|
||||
},
|
||||
"seconds": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings_regionSettings": "地区",
|
||||
"settings_regionSettingsSubtitle": "管理已存储的区域",
|
||||
"settings_regionManagement_screenTitle": "区域管理",
|
||||
"settings_regionNameHint": "区域名称",
|
||||
"settings_regionAddRegion": "增加地区",
|
||||
"settings_regionFetchRegions": "从中继器获取区域",
|
||||
"settings_regionFetchRegionsFail": "未发现任何地区",
|
||||
"settings_regionFetchRegionsAlreadyExists": "该地区已被添加",
|
||||
"settings_regionName": "地区名称",
|
||||
"settings_regionDeleted": "区域已删除",
|
||||
"settings_deleteRegion": "删除区域",
|
||||
"settings_deleteRegionConfirm": "从 {region} 列表中删除?",
|
||||
"settings_infoHardware": "硬件",
|
||||
"settings_infoFirmware": "固件",
|
||||
"repeater_pathHashModeOption0": "0 - 1 字节",
|
||||
"repeater_pathHashModeOption1": "1 - 2 字节",
|
||||
"repeater_pathHashModeOption2": "2 - 3 字节",
|
||||
"repeater_pathHashModeOption3": "3 - 4 字节",
|
||||
"appSettings_batteryLipoHv": "锂多压电源(3.0-4.35伏)",
|
||||
"channels_regionSetTo": "地区:{region}",
|
||||
"channels_regionNotSet": "地区:无",
|
||||
"channels_regionSelect_Title": "选择一个地区",
|
||||
"channels_clearRegion": "无碍区域",
|
||||
"chat_sendImage": "发送图片",
|
||||
"chat_imagePickFailed": "无法打开该图片",
|
||||
"chat_receivedGif": "收到一个GIF",
|
||||
"repeater_keySettings": "更改身份密钥",
|
||||
"repeater_keySettingsSubtitle": "更换公钥/私钥对",
|
||||
"repeater_prvKey": "私钥",
|
||||
"repeater_prvKeyHelper": "中继器的新私钥,为一个128位的十六进制字符串。",
|
||||
"repeater_generatePrvKey": "生成一个随机的密钥对",
|
||||
"repeater_stopGeneratingPrvKey": "中断密钥对搜索",
|
||||
"repeater_pubKey": "公钥",
|
||||
"repeater_pubKeyHelper": "这是与生成的私钥配对的公钥。您不能直接设置此内容。",
|
||||
"repeater_pubKeyPrefix": "期望的前缀",
|
||||
"repeater_pubKeyPrefixHelper": "找到一组以这些十六进制数字开头的公钥。预计尝试次数:{tries}",
|
||||
"imageMessages_enableTitle": "启用图片消息",
|
||||
"imageMessages_enableSubtitle": "通过网格发送图像。需要一次性下载图像模型。",
|
||||
"imageMessages_modelSectionTitle": "图像模型",
|
||||
"imageMessages_downloadModel": "下载",
|
||||
"imageMessages_cancelDownload": "取消",
|
||||
"imageMessages_removeModel": "移除模型",
|
||||
"imageMessages_modelReady": "准备就绪",
|
||||
"imageMessages_modelNotPublished": "尚未发布——此版本无法下载该内容。",
|
||||
"imageMessages_downloadFailed": "图像模型无法下载。",
|
||||
"imageMessages_autoProcessTitle": "自动处理图像",
|
||||
"imageMessages_autoProcessSubtitle": "每次图像到达后立即重建,每次需要约2GB的内存;若想关闭重建功能,可通过点击取消开启。",
|
||||
"imageSend_title": "发送图片",
|
||||
"imageSend_cropNote": "调整大小至512 × 512 · 宽高比未保留",
|
||||
"imageSend_originalSize": "原文",
|
||||
"imageSend_onAirSize": "直播中",
|
||||
"imageSend_quality": "质量",
|
||||
"imageSend_qualityStandard": "标准",
|
||||
"imageSend_qualityHigh": "高",
|
||||
"imageSend_packetsLabel": "数据包",
|
||||
"imageSend_airtimeLabel": "播出时间",
|
||||
"imageSend_sizeLabel": "有效载荷",
|
||||
"imageSend_packetsCount": "{count} {count, plural, =1{个数据包} other{个数据包}}",
|
||||
"imageSend_range": "{min}–{max}",
|
||||
"imageSend_unknownValue": "—",
|
||||
"imageSend_radioUnknownTitle": "无线电设置未知",
|
||||
"imageSend_radioUnknownBody": "连接设备以计算播出时间。",
|
||||
"imageSend_longSendTitle": "长距离传输",
|
||||
"imageSend_longSendBody": "这将维持频道大约 {duration} 时间。",
|
||||
"imageSend_floodNote": "洪水路由:每个中继站都会转发每个数据包,因此信道比这段时间更长地处于繁忙状态。",
|
||||
"imageSend_parityTitle": "恢复包",
|
||||
"imageSend_paritySubtitle": "多一个数据包。群发消息不被确认,因此如果某个数据包丢失,接收方可以重新构建图像。",
|
||||
"imageSend_send": "发送",
|
||||
"imageSend_cancel": "取消",
|
||||
"imageSend_encodeFailed": "此图像无法编码。",
|
||||
"imageSend_codecDownloading": "图像模型仍在下载中。",
|
||||
"imageSend_codecUnavailable": "此设备无法发送图片。",
|
||||
"imageSend_codecDisabled": "图片消息在设置中已关闭。",
|
||||
"imageSend_deviceUnsupported": "此无线电无法发送图像数据包。请连接运行伴侣固件13或更高版本的设备。",
|
||||
"imageSend_directMessagesUnsupported": "图片作为群组数据传输,因此只能发送到频道,而不能直接发送给个人。",
|
||||
"imageSend_tooLarge": "该图像编码的数据包数量超过了网格格式所允许的范围。",
|
||||
"imageSend_sentConfirmation": "图像已作为 {count} {count, plural, =1{个数据包} other{个数据包}} 发送。",
|
||||
"imageSend_sendFailed": "无法发送图片:{error}",
|
||||
"imageSend_sendingProgress": "发送图像 — 已发送 {sent} 包含 {total}",
|
||||
"receivedImage_senderPrefix": "节点 {prefix}",
|
||||
"receivedImage_incoming": "已收到的 {received} 个包含在 {total} 个包裹中",
|
||||
"receivedImage_queued": "等待解码",
|
||||
"receivedImage_tapToDecode": "点击解锁",
|
||||
"receivedImage_decoding": "重建……大约1秒",
|
||||
"receivedImage_incomplete": "图片不完整 — {received} 已收到 {total} 个数据包",
|
||||
"receivedImage_corrupt": "图像无法重建",
|
||||
"receivedImage_decoderMissing": "收到图片 — 图像解码失败",
|
||||
"receivedImage_evicted": "图片已不再存储",
|
||||
"receivedImage_retry": "再试一次",
|
||||
"receivedImage_decodeAgain": "重新解码",
|
||||
"receivedImage_openSettings": "建立",
|
||||
"receivedImage_tapToProcess": "点击处理",
|
||||
"receivedImage_awaiting": "{bytes} 字节 · {packets} {packets, plural, =1{个数据包} other{个数据包}}",
|
||||
"imageSend_secondsValue": "{seconds} 秒",
|
||||
"imageSend_minutesSecondsValue": "{minutes} 分 {seconds} 秒"
|
||||
}
|
||||