From 8cf6e2fc8db43467c19ad4b3c56de125c5b9c02e Mon Sep 17 00:00:00 2001
From: MarekZegare4 Welcome to the MeshCore documentation. Below are a few quick start guides. If you find a mistake in any of our documentation, or find something is missing, please feel free to open a pull request for us to review. This document provides an overview of CLI commands that can be sent to MeshCore Repeaters, Room Servers and Sensors. Usage: - Note: No reply is sent. Usage: - Note: No reply is sent. Usage: - Note: No reply is sent. Usage: - Usage: - Usage: - Parameters: - Usage: - Usage: - Usage: - Usage: - Serial Only: Yes Warning: This is destructive! Usage: - Note: The output of this command is limited to the 8 most recent adverts. Note: Each line is encoded as Usage: - Parameters: - Note: You can remove all neighbors by sending a space character as the prefix. The space indicates an empty prefix, which matches all existing neighbors. Usage: - Usage: Usage: - Serial Only: Yes Usage: Serial Only: Yes Usage: Serial Only: Yes Usage: Usage: Usage: Usage: Serial Only: Yes Usage: Usage: Usage: - Parameters: - Set by build flag: Default: Note: Requires reboot to apply Usage: - Parameters: - Set by build flag: Default: Varies by board Notes: This setting only controls the power level of the LoRa chip. Some nodes have an additional power amplifier stage which increases the total output. Refer to the node's manual for the correct setting to use. Setting a value too high may violate the laws in your country. Usage: - Parameters: - Note: This is not saved to preferences and will clear on reboot Usage: - Parameters: - Default: Note: Requires reboot to apply Serial Only: Usage: - Parameters: - Default: Temporary Note: If you upgraded from an older version to 1.14.1 without erasing flash, this setting is Usage: - Parameters: - Notes: - This controls the external LoRa FEM receive-path LNA where the board supports it. - This is separate from Usage: - Parameters: - Set by build flag: Default: Varies by board Note: Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary. Usage: - Set by build flag: Default: Parameters: - Usage: - Set by build flag: Default: Parameters: - Usage: - Parameters: - Serial Only: - Note: Requires reboot to take effect after setting Usage: - Parameters: - Set by build flag: Default: Note: Command reply echoes the updated password for confirmation. Note: Any node using this password will be added to the admin ACL list. Usage: - Parameters: - Set by build flag: Default: Usage: - Parameters: - Default: Note: Note: Requires firmware 1.12+ Usage: - Parameters: - Default: Note: Returns \"Error: unsupported by this board\" if hardware doesn't support it Usage: Usage: Usage: Usage: - Parameters: - Default: Note: When enabled, device enters sleep mode between radio transmissions Usage: - Parameters: - Default: Usage: - Parameters: - Default: Note: the 'path.hash.mode' sets the low-level ID/hash encoding size used when the repeater adverts. This setting has no impact on what packet ID/hash size this repeater forwards, all sizes should be forwarded on firmware >= 1.14. This feature was added in firmware 1.14 Temporary Note: adverts with ID/hash sizes of 2 or 3 bytes may have limited flood propagation in your network while this feature is new as v1.13.0 firmware and older will drop packets with multibyte path ID/hashes as only 1-byte hashes are supported. Consider your install base of firmware >=1.14 has reached a criticality for effective network flooding before implementing higher ID/hash sizes. Usage: - Parameters: - Default: Note: When it is enabled, repeaters will now reject flood packets which look like they are in a loop. This has been happening recently in some meshes when there is just a single 'bad' repeater firmware out there (probably some forked or custom firmware). If the payload is messed with, then forwarded, the same packet ends up causing a packet storm, repeated up to the max 64 hops. This feature was added in firmware 1.14 Example: If preference is Usage: - Parameters: - Default: Note: When multiple nearby repeaters all hear the same flood packet, each waits a random amount of time before retransmitting to avoid simultaneous collisions. This factor scales the size of that random window. Higher values reduce collision risk at the cost of added latency. Usage: - Parameters: - Default: Note: Same collision-avoidance random window as Usage: - Parameters: - Default: Note: When enabled, repeaters that received a flood packet with a weak signal are held in a delay queue before processing, while those that received it with a strong signal process it immediately. This gives strong-signal paths forwarding priority. By the time weak-signal nodes process their copy, the packet may have already propagated and will be suppressed as a duplicate, reducing redundant retransmissions. Usage: - Parameters: - Default: Examples: - Note: Added in firmware v1.15.0 Deprecated as of firmware v1.15.0. Use Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Description: When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Note: An alternative to Usage: - Parameters: - Default: Usage: - Parameters: - Note: Removes the entry when Usage: - Serial Only: Yes Usage: - Parameters: - Default: Usage: - Parameters: - Note: Note: Indentation creates parent-child relationships (max 8 levels) Note: Usage: - Usage: - Parameters: - Note: Setting on wildcard Usage: - Parameters: - Note: Setting on wildcard Usage: - Parameters: - Usage: - Parameters: - Usage: - Parameters: - Usage: - Parameters: - Usage: - Parameters (tokens): Space-separated. A logical cursor starts at the wildcard Behavior: Each created region defaults to flood-allowed (same as Existing regions: Limits: Repeater serial accepts one line up to 160 characters. For larger trees, split across multiple Example \u2014 linear chain (each token becomes a child of the previous): Example \u2014 branched tree (equivalent to Example \u2014 error and partial state: The reply is Example \u2014 flat list (each region a child of Usage: - Parameters: - Note: Must remove all child regions before the region can be removed Usage: - Serial Only: Yes Parameters: - Note: Requires firmware 1.12+ Usage: - Serial Only: For firmware older than 1.12.0 Example 1: Using F Flag with Named Public Region Explanation: - Creates a region named Example 2: Using Wildcard with F Flag Explanation: - Creates a wildcard region Example 3: Using Wildcard Without F Flag Explanation: - Creates a wildcard region Example 4: Nested Public Region with F Flag Explanation: - Creates Example 5: Wildcard with Nested Public Regions Explanation: - Creates wildcard region Usage: - Parameters: - Default: Note: Output format: - Usage: - Usage: - Usage: - Parameters: - Default: Usage: Parameters: - Note: Output format: Usage: - Parameters: - Usage: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Usage: - Parameters: - Default: Varies by board Usage: Usage: Usage: Note: Returns an error on boards without power management support. Usage: Note: Returns an error on boards without power management support. Usage: Note: Returns an error on boards without power management support. Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the Usage: - Output: - Notes: - Available on repeater and room server firmware only. Companion radio ethernet firmware does not expose a CLI. - The Ethernet interface obtains an IP address via DHCP automatically on boot. - A TCP server listens on port 23 (default) for CLI connections. - Connect with any TCP client (e.g. NOTE: This document is still in development. Some information may be inaccurate. This document provides a comprehensive guide for communicating with MeshCore devices over Bluetooth Low Energy (BLE). It is platform-agnostic and can be used for Android, iOS, Python, JavaScript, or any other platform that supports BLE. Please see the following repos for existing MeshCore Companion Protocol libraries. All secrets, hashes, and cryptographic values shown in this guide are example values only. MeshCore Companion devices expose a BLE service with the following UUIDs: Scan for Devices Connect to GATT Discover Services and Characteristics Enable Notifications Send Initial Commands Note: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff. When writing commands to the RX characteristic, specify the write type: Platform-specific: Recommendation: Use write with response for reliability. The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like Critical: Commands must be sent in the correct sequence: After Connection: Command-Response Matching: For reliable operation, implement a command queue. Queue Structure: Error Handling: The MeshCore protocol uses a binary format with the following structure: Most packets follow this format: The first byte indicates the packet type (see Response Parsing). Purpose: Initialize communication with the device. Must be sent first after connection. Command Format: Example (hex): Response: Purpose: Query device information. Command Format: Example (hex): Response: Purpose: Retrieve information about a specific channel. Command Format: Example (get channel 1): Response: Purpose: Create or update a channel on the device. Command Format: Total Length: 50 bytes Channel Index: - Index 0: Reserved for public channels (no secret) - Indices 1-7: Available for private channels Channel Name: - UTF-8 encoded - Maximum 32 bytes - Padded with null bytes (0x00) if shorter Secret Field (16 bytes): - For private channels: 16-byte secret - For public channels: All zeros (0x00) Example (create channel \"YourChannelName\" at index 1 with secret): Note: The 32-byte secret variant is unsupported and returns Response: Purpose: Send a text message to a channel. Command Format: Timestamp: Unix timestamp in seconds (32-bit unsigned integer, little-endian) Example (send \"Hello\" to channel 1 at timestamp 1234567890): Response: Purpose: Send a binary datagram to a channel. Unlike channel text messages, datagrams carry no built-in sender identity and no timestamp \u2014 applications needing either must encode them inside the binary payload. Command Format: Example (flood, Data Type / Transport Mapping: - Limits: - Maximum payload length is Response: Inbound datagrams are delivered to the host via To register a new application, submit a PR adding a row to the table in docs/number_allocations.md. Internal sub-formats within an allocated application ID are owned by that application and are not tracked in MeshCore firmware or this document. Inbound group datagrams (radio-level Frame Format ( Path bytes are not forwarded: Only Path Length semantics differ between send and receive: In other words, the meaning of Note: The device may also emit Parsing Pseudocode: Purpose: Request the next queued message from the device. Command Format: Example (hex): Response: - Note: Poll this command periodically to retrieve queued messages. The device may also send Purpose: Query device battery voltage and storage usage. Command Format: Example (hex): Response: Messages are received via the TX characteristic (notifications). The device sends: Contact Messages: Notifications: Standard Format ( V3 Format ( Parsing Pseudocode: Standard Format ( V3 Format ( Parsing Pseudocode: Use the Important: - Messages are limited to 133 characters per MeshCore specification - Long messages should be split into chunks - Include a chunk indicator (e.g., \"[1/3] message text\") This document uses a spec-level naming convention ( Byte values are authoritative; names are aliases. When reading firmware source, PACKET_OK (0x00): PACKET_ERROR (0x01): PACKET_CHANNEL_INFO (0x12): Note: The device returns the 16-byte channel secret in this response. PACKET_DEVICE_INFO (0x0D): Parsing Pseudocode: PACKET_BATTERY (0x0C): Parsing Pseudocode: PACKET_SELF_INFO (0x05): Parsing Pseudocode: PACKET_MSG_SENT (0x06): PACKET_ACK (0x82): Note: Error codes may vary by firmware version. Always check byte 1 of BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer. Use command queue to prevent concurrent commands Asynchronous Messages: Validate frame length before decoding Response Matching: Match responses to commands by expected packet type: Timeout Handling: Consider longer timeout for channel operations Error Recovery: Store last connected device address for quick reconnection Secret Management: Never log or transmit secrets in plain text Message Handling: Implement message deduplication to avoid displaying the same message twice Channel Management: Error Handling: This document explains how to build and view the MeshCore documentation locally. A list of frequently-asked questions and answers for MeshCore A: MeshCore is a multi-platform system for enabling secure text-based communications utilizing LoRa radio hardware. It can be used for Off-Grid Communication, Emergency Response & Disaster Recovery, Outdoor Activities, Tactical Security including law enforcement and private security and also IoT sensor networks. (source) MeshCore is free and open source: Some more advanced, but optional features are available on T-Deck if you register your device for a key to unlock. On the MeshCore smartphone clients for Android and iOS/iPadOS, you can unlock the wait timer for repeater and room server remote management over RF feature. These features are completely optional and aren't needed for the core messaging experience. They're like super bonus features and to help the developers continue to work on these amazing features, they may charge a small fee for an unlock code to utilize the advanced features. Anyone is able to build anything they like on top of MeshCore without paying anything. A: Everything you need for MeshCore is available at: You need LoRa hardware devices to run MeshCore firmware as clients or server (repeater and room server). MeshCore is available on a variety of 433MHz, 868MHz and 915MHz LoRa devices. For example, Lilygo T-Deck, T-Pager, RAK Wireless WisBlock RAK4631 devices (e.g. 19003, 19007, 19026), Heltec V3, Xiao S3 WIO, Xiao C3, Heltec T114, Station G2, Nano G2 Ultra, Seeed Studio T1000-E. More devices are being added regularly. For an up-to-date list of supported devices, please go to https://flasher.meshcore.io To use MeshCore without using a phone as the client interface, you can run MeshCore on a LilyGo T-Deck, T-Deck Plus, T-Pager, T-Watch, or T-Display Pro. MeshCore Ultra firmware running on these devices is a complete off-grid secure communication solution. MeshCore has four firmware types that are not available on other LoRa systems. MeshCore has the following: Companion radios are for connecting to the Android app or web app as a messenger client. There are two different companion radio firmware versions: BLE Companion BLE Companion firmware runs on a supported LoRa device and connects to a smart device running the Android or iOS MeshCore client over BLE https://meshcore.io USB Serial Companion USB Serial Companion firmware runs on a supported LoRa device and connects to a smart device or a computer over USB Serial running the MeshCore web client https://app.meshcore.nz Repeaters are used to extend the range of a MeshCore network. Repeater firmware runs on the same devices that run client firmware. A repeater's job is to forward MeshCore packets to the destination device. It does not forward or retransmit every packet it receives, unlike other LoRa mesh systems. A repeater can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app. A room server is a simple BBS server for sharing posts. T-Deck devices running MeshCore firmware or a BLE Companion client connected to a smartphone running the MeshCore app can connect to a room server. Room servers store message history on them and push the stored messages to users. Room servers allow roaming users to come back later and retrieve message history. With channels, messages are either received when it's sent, or not received and missed if the channel user is out of range. Room servers are different and more like email servers where you can come back later and get your emails from your mail server. A room server can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app. When a client logs into a room server, the client will receive the previously 32 unseen messages. Although room server can also repeat with the command line command The recommendation is to run repeater and room server on separate devices for the best experience. A: If you have one supported device, flash the BLE Companion firmware and use your device as a client. You can connect to the device using the Android or iOS client via Bluetooth. You can start communicating with other MeshCore users near you. If you have two supported devices, and there are not many MeshCore users near you, flash both to BLE Companion firmware so you can use your devices to communicate with your nearby friends and family. If you have two supported devices, and there are other MeshCore users nearby, you can flash one of your devices with BLE Companion firmware and flash another supported device to repeater firmware. Place the repeater high above ground to extend your MeshCore network's reach. After you flashed the latest firmware onto your repeater device, keep the device connected to your computer via USB serial, use the console feature on the web flasher and set the frequency for your region or country, so your client can remote administer the repeater or room server over RF: The repeater and room server CLI reference is here: https://docs.meshcore.io/cli_commands If you have more supported devices, you can use your additional devices with the room server firmware. A: All radio firmware versions (e.g. for Heltec V3, RAK, T-1000E, etc.) are free and open source developed by Scott at Ripple Radios. The native Android and iOS client uses the freemium model and is developed by Liam Cottle, developer of meshtastic map at meshtastic.liamcottle.net on GitHub and reticulum-meshchat on GitHub. The T-Deck firmware is free to download and most features are available without cost. To support the firmware developer, you can pay for a registration key to unlock your T-Deck for deeper map zoom and remote server administration over RF using the T-Deck. You do not need to pay for the registration to use your T-Deck for direct messaging and connecting to repeaters and room servers. A: It supports the 868MHz range in the UK/EU and the 915MHz range in New Zealand, Australia, and the USA. Countries and regions in these two frequency ranges are also supported. Use the smartphone client or the repeater setup feature on the web flasher to set your radios' RF settings by choosing the preset for your regions. Recently, as of October 2025, many regions have moved to the \"narrow\" setting, aka using BW62.5 and a lower SF number (instead of the original SF11). For example, USA/Canada (Recommended) preset is 910.525MHz, SF7, BW62.5, CR5. After extensive testing, many regions have switched or about to switch over to BW62.5 and SF7, 8, or 9. Narrower bandwidth setting and lower SF setting allow MeshCore's radio signals to fit between interference in the ISM band, provide for a lower noise floor, better SNR, and faster transmissions. If you have consensus from your community in your region to update your region's preset recommendation, please post your update request on the #meshcore-app channel on the MeshCore Discord server to let Liam Cottle know. A: Advert means to advertise yourself on the network. In Reticulum terms it would be to announce. In Meshtastic terms it would be the node sending its node info. MeshCore allows you to manually broadcast your name, position and public encryption key, which is also signed to prevent spoofing. When you click the advert button, it broadcasts that data over LoRa. MeshCore calls that an Advert. There's two ways to advert, \"zero hop\" and \"flood\". MeshCore clients only advertise themselves when the user initiates it. A repeater sends a flood advert once every 12 hours by default. This interval can be configured using the following command: The separate A: Internally the firmware has maximum limit of 64 hops. In real world settings it will be difficult to get close to the limit due to the environments and timing as packets travel further and further. We want to hear how far your MeshCore conversations go. A: When MeshCore is flashed onto a LoRa device for the first time, it is necessary to set the server device's frequency to make it utilize the frequency that is legal in your country or region. Repeater or room server can be administered with one of the options below: Connect the server device using a USB cable to a computer running Chrome on https://flasher.meshcore.io, then use the Use a MeshCore smartphone client to remotely administer servers via LoRa. A T-Deck running unlocked/registered MeshCore firmware. Remote server administration is enabled through registering your T-Deck with Ripple Radios. It is one of the ways to support MeshCore development. You can register your T-Deck at: https://buymeacoffee.com/ripplebiz/e/249834 A: While not required, with location set for a repeater it will show up on the MeshCore map in the future. Set location with the following command: You can get the latitude and longitude from Google Maps by right-clicking the location you are at on the map. A: The default admin password to a repeater and room server is A: The default guest password to a room server is A: You can issue these commands to get or set a repeater's private key using a USB serial connection. Reboot the repeater after A: You can generate a new private key and specify the first byte of its public key here: https://gessaman.com/mc-keygen Having multiple repeaters with the same first byte ID does not negatively affect the mesh or its functionality. Flood and pathed packets will still reach their destinations. First byte ID collision makes traceroute and path analysis harder because these tools don't know exactly which of the two (or more) colliding repeaters is the one in the path. Best practice is when you set up a new repeater, choose a public key that is not in use. If it is not possible to find a unique first byte for your repeater's public key, choose one that is unique within about 10 miles (16 km) to minimize collision with nearby repeaters. A: This may be due to the SX1262 radio's auto gain control feature. You can use this command to periodically reset its AGC. The This is a very low-cost operation. AGC reset is done by simply setting A: The observer instruction is available here: https://analyzer.letsmesh.net/observer/onboard A: The original MeshCore protocol design uses the first byte of a repeater's public key to denote the repeater in a path. And with 1 byte for each repeater in the path, MeshCore packets can travel as many as 64 hops. However, with 1 byte, there are only 254 unique IDs (exclude 00 and FF which are reserved). Many meshes group have multiple repeaters with the same first byte in their public keys. Packets continue to pass through repeaters and the mesh is not harmed in any way. It does make it harder for tools to analyze paths with duplicated repeater IDs. Firmware version 1.14 and newer introduces the ability for repeaters to advert with 1-, 2-, or 3-byte adverts. Companions can also send out channel and direct messages with 1-, 2-, or 3-byte path. Adverts and messages sent in 1-byte path is compatible with repeater firmware older or newer than 1.14. They will travel up to 64 hops. 2-byte adverts and messages will travel up to 32 hops. 3-byte adverts and messages will travel up to 21 hops. Repeaters running firmware 1.14+ repeat packets sent with 1-, 2-, or 3-byte path hash. Repeaters on firmware older than 1.14 only repeat 1-byte path hash packets and silently drop 2- and 3-byte packets. The original packet sender determines the path hash size. The most common original sender is a companion app. The other common original sender is a repeater, when it broadcasts its advert. As of firmware version 1.14 and MeshCore app version 1.41.0, in the MeshCore app, you can set your companion's message path hash size in Until your regional mesh has the vast majority of the repeaters updated to 1.14+ firmware, it is recommended to keep your companion at the default 1-byte because pre-1.14 repeaters will silently drop messages with larger path hashes. This CLI command Usage: It is safe to set your 1.14+ repeaters to mode 1 or 2. A longer path hash helps tools like the LetsMesh.net Analyzer and MeshMapper disambiguate repeaters more reliably. With only 1 byte, the chance of different repeaters having the same first byte in their public key is high, making it harder to tell them apart in mesh network analysis. Since this only affects adverts, there's no downside. 2- and 3-byte adverts don't travel as far as 1-byte adverts, but it is not important for MeshCore nodes to hear a repeater's advert that is 21 or 32 hops away. You should move to send 2-byte or 3-byte channel and direct messages when the vast majority of the repeaters in your regional mesh are updated to firmware version 1.14 or newer. Setting your repeater's A: Yes, it is available on https://buymeacoffee.com/ripplebiz/ultra-v7-7-guide-meshcore-users A: A: For T-Deck Plus, the GPS baud rate should be set to 38400. Also, some T-Deck Plus devices were found to have the GPS module installed upside down, with the GPS antenna facing down instead of up. If your T-Deck Plus still doesn't get any satellite lock after setting the baud rate to 38400, you might need to open the device to check the GPS orientation. GPS on T-Deck is always enabled. You can skip the \"GPS clock sync\" and the T-Deck will continue to try to get a GPS lock. You can go to the Source A: The OG (non-Plus) T-Deck doesn't come with a GPS. If you added a GPS to your OG T-Deck, please refer to the manual of your GPS to see what baud rate it requires. Alternatively, you can try to set the baud rate from 9600, 19200, etc., and up to 115200 to see which one works. A: Users have had no issues using 16GB or 32GB SD cards. Format the SD card to FAT32. A: T-Deck uses the same key the smartphone apps use but in base64 There is no The smartphone app key is in hex: Source A: You need map tiles. You can get pre-downloaded map tiles here (a good way to support development): Another way to download map tiles is to use this Python script to get the tiles in the areas you want: https://github.com/fistulareffigy/MTD-Script There is also a modified script that adds additional error handling and parallel downloads: https://github.com/TheBestJohn/MTD-Script Once you have the tiles downloaded, copy the A: You can download, install, and use the T-Deck firmware for free, but it has some features (map zoom, server administration) that are enabled if you purchase an unlock code for \\$10 per T-Deck device. Unlock page: https://buymeacoffee.com/ripplebiz/e/249834 A: Space is tight on T-Deck's screen, so the information is a bit cryptic. The format is : See here for packet-type: https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.h#L19 Source A: You can customize the sounds on the T-Deck, by placing A: 'Import from Clipboard' is for importing a contact via a file named 'clipboard.txt' on the SD card. The opposite, is in the Identity screen, the 'Card to Clipboard' menu, which writes to 'clipboard.txt' so you can share yourself (call these 'biz cards', that start with \"meshcore://...\") A: To capture a screenshot on a T-Deck, long press the top-left corner of the screen. The screenshot is saved to the microSD card, if one is inserted into the device. A: BW is bandwidth - width of frequency spectrum that is used for transmission SF is spreading factor - how much should the communication spread in time CR is coding rate - from: https://www.thethingsnetwork.org/docs/lorawan/fec-and-code-rate TL;DR: default CR to 5 for good stable links. If it is not a solid link and is intermittent, change CR to 7 or 8. Forward Error Correction is a process of adding redundant bits to the data to be transmitted. During the transmission, data may get corrupted by interference (changes from 0 to 1 / 1 to 0). These error correction bits are used at the receivers for restoring corrupted bits. The Code Rate of a forward error correction expresses the proportion of bits in a data stream that actually carry useful information. There are 4 code rates used in LoRaWAN: 4/5 4/6 5/7 4/8 For example, if the code rate is 5/7, for every 5 bits of useful information, the coder generates a total of 7 bits of data, of which 2 bits are redundant. Making the bandwidth 2x wider (from BW125 to BW250) allows you to send 2x more bytes in the same time. Making the spreading factor 1 step lower (from SF10 to SF9) allows you to send 2x more bytes in the same time. Lowering the spreading factor makes it more difficult for the gateway to receive a transmission, as it will be more sensitive to noise. You could compare this to two people talking in a noisy place (a bar for example). If you\u2019re far from each other, you have to talk slow (SF10), but if you\u2019re close, you can talk faster (SF7) So, it's a balancing act between speed of the transmission and resistance to noise. The Things Network is mainly focused on LoRaWAN, but the LoRa low-level stuff still checks out for any LoRa project A: No, MeshCore clients do not repeat. This is the core of MeshCore's messaging-first design. This is to avoid devices flooding the airwaves and create endless collisions, so messages sent aren't received. In MeshCore, only repeaters and room servers with A: If you used to reach a node through a repeater and the repeater is no longer reachable, the client will send the message using the existing (but now broken) known path, the message will fail after 3 retries, and the app will reset the path and send the message as flood on the last retry by default. This can be turned off in settings. If the destination is reachable directly or through another repeater, the new path will be used going forward. Or you can set the path manually if you know a specific repeater to use to reach that destination. In the case if users are moving around frequently, and the paths are breaking, they just see the phone client retries and revert to flood to attempt to re-establish a path. Routes are stored in sender's contact list. When you send a message the first time, the message first gets to your destination by flood routing. When your destination node gets the message, it will send back a delivery report to the sender with all repeaters that the original message went through. This delivery report is flood-routed back to you the sender and is a basis for future direct path. When you send the next message, the path will get embedded into the packet and be evaluated by repeaters. If the hop and address of the repeater matches, it will retransmit the message, otherwise it will not retransmit, hence minimizing utilization. Source A: Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the Source A: The smartphone app key is in hex: T-Deck uses the same key but in base64: The third character is the capital letter Source A: Most of the firmware is freely available. Everything is open source except the T-Deck firmware and Liam's native mobile apps. Firmware repo: https://github.com/meshcore-dev/MeshCore A: Provide your honest feedback on GitHub and on MeshCore Discord server. Spread the word of MeshCore to your friends and communities; help them get started with MeshCore. Support Scott's MeshCore development at https://buymeacoffee.com/ripplebiz. Support Liam Cottle's smartphone client development by unlocking the server administration wait gate with in-app purchase Support Rastislav Vysoky (recrof)'s flasher website and the map website development through PayPal or Revolut A: See instructions here: https://discord.com/channels/826570251612323860/1330643963501351004/1341826372120608769 Build instructions for MeshCore: For Windows, first install WSL and Python+pip via: https://plainenglish.io/blog/setting-up-python-on-windows-subsystem-for-linux-wsl-26510f1b2d80 (Linux, Windows+WSL) In the terminal/shell: Mac: python3 should be already installed. Then it should be the same for all platforms: open platformio.ini and in then you'll find A: Liam Cottle's MeshCore web client and MeshCore JavaScript library are open source under MIT license. Web client: https://github.com/liamcottle/meshcore-web Javascript: https://github.com/liamcottle/meshcore.js A: ATAK is not currently on MeshCore's roadmap. MeshCore would not be best suited to ATAK because MeshCore: MeshCore clients would need to reset path constantly and flood traffic across the network which could lead to lots of collisions with something as chatty as ATAK. This could change in the future if MeshCore develops a client firmware that repeats. Source A: To add a BLE Companion radio, connect to the BLE Companion radio from the MeshCore smartphone app. In the app, tap the To add a Repeater or Room Server to the map, go to the Contact List, tap the You can use the same companion (same public key) that you used to add your repeaters or room servers to remove them from the Internet Map. A: Yes. Below are the instructions to flash firmware onto a supported LoRa device using a Raspberry Pi over USB serial. Instructions for nRF devices like RAK, T1000-E, T114 are immediately after the ESP instructions For ESP-based devices (e.g. Heltec V3) you need: Instructions for nRF devices: For nRF devices (e.g. RAK, Heltec T114) you need the following: To manage a repeater or room server connected to a Pi over USB serial using shell commands, you need to install To start managing your USB serial-connected device using picocom, use the following command: From here, reference repeater and room server command line commands in the MeshCore docs here: A: Yes, there are many. MeshCore's protocol is open source using the MIT license. The MIT license and the open source protocol makes it very easy for the MeshCore community to build new firmware for radios, applications on mobile devices, map tools, and analysis tools, and integration with other projects like Home Assistant. As new MeshCore community projects become available on a weekly basis, we have stopped tracking them here in this FAQ. samuk maintains a very exhaustive list of MeshCore community project at https://github.com/samuk/awesome-meshcore/blob/main/README.md. samuk accepts PRs and merges them regularly. A: Yes, the same iOS and Android client is also available for Windows and Mac. You can find them together with the Android APK here: https://files.liamcottle.net/MeshCore Both the Windows and Mac versions of the client app are fully unlocked and are free to use. A: Here is a list of MeshCore comparison resources: A: You can get the epoch time on https://www.epochconverter.com and use it to set your T-Deck clock. For a repeater and room server, the admin can use a T-Deck to remotely set their clock (clock sync), or use the A: You can't connect to a device running repeater firmware via Bluetooth. You can connect to devices running the BLE companion firmware via Bluetooth using the Android app. A: Make sure that you flashed the Bluetooth companion firmware and not the USB-only companion firmware. A: The default Bluetooth pairing code is A: Heltec V3 has a very small coil antenna on its PCB for Wi-Fi and Bluetooth connectivity. It has a very short range, only a few feet. It is possible to remove the coil antenna and replace it with a 31mm wire. The BT range is much improved with the modification. A: Separately, starting in firmware version 1.7.0, there is a CLI Rescue mode. If your device has a user button (e.g. some RAK, T114), you can activate the rescue mode by holding down the user button of the device within 8 seconds of boot. Then you can use the 'Console' on https://flasher.meshcore.io A: If the usb port doesn't have the right ownership for this task, the process fails with the following error: Allow the browser user on it: A: The steps below work on both Android and iOS as nRF has made both apps' user interface the same on both platforms: A: You can flash this safer bootloader to the Wio Tracker L1 Pro https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX After this bootloader is flashed onto the device, you can trigger an over-the-air update using Bluetooth by holding the button next to the D-Pad and then clicking the reset button. Then follow the same OTA update instructions above. You can skip the A: For ESP32-based devices (e.g. Heltec V3): A: Yes, developer Refer to https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX for the latest information. Currently, the following boards are supported: A: Yes, it is on the MeshCore GitHub repo here: https://github.com/meshcore-dev/MeshCore/tree/main/logo A: Channel: Contact: Where A: Wi-Fi firmware requires you to compile it yourself, as you need to set the Wi-Fi SSID and password. Edit WIFI_SSID and WIFI_PWD in A: For companion radios, you can set these radios' transmit power in the smartphone app. For repeater and room server radios, you can set their transmit power using the command line command \u26a0\ufe0f WARNING: Set these values at your own risk. Incorrect power settings can permanently damage your radio hardware. A: MeshCore supports Ethernet on RAK4631 boards using the RAK13800 WisBlock Ethernet module (based on the W5100S chip). Hardware required: - RAK4631 WisBlock Core - RAK19007 or RAK19018 WisBlock Base Board (with an available IO slot) - RAK13800 WisBlock Ethernet module - Ethernet cable connected to a network with a DHCP server Firmware: Flash one of the Ethernet-enabled firmware variants: - Connecting: - The device obtains an IP address via DHCP automatically on boot. - For repeaters and room servers, connect to the device on TCP port 23 using any TCP client (e.g. Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command. 115200 baud, 8N1, no flow control. Standard KISS framing per the KA9Q/K3MC specification. The type byte is split into two nibbles: Maximum unescaped frame size: 512 bytes. Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes. Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07). Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; The TNC implements p-persistent CSMA for half-duplex operation: In full-duplex mode, CSMA is bypassed and packets transmit after TXDELAY. MeshCore-specific functionality uses the standard KISS SetHardware command. The first byte of SetHardware data is a sub-command. Standard KISS clients ignore these frames. Response codes use the high-bit convention: The TNC sends these SetHardware frames without a preceding request: TxDone (0xF8): Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped. RxMeta (0xF9): Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame. All values little-endian. All values little-endian. All values little-endian. The modem recalibrates the noise floor every 2 seconds with an AGC reset every 30 seconds. All values little-endian. All values little-endian. All values little-endian. Returns Sends an Use Data returned in CayenneLPP format. See CayenneLPP documentation for parsing. The nRF52 Power Management module provides battery protection features to prevent over-discharge, minimise likelihood of brownout and flash corruption conditions existing, and enable safe voltage-based recovery. Shutdown reason codes (stored in GPREGRET2): Notes: - \"Implemented\" reflects Phase 1 (boot lockout + shutdown reason capture). - User power-off on Heltec T114 does not enable LPCOMP wake. - VBUS detection is used to skip boot lockout on external power, and VBUS wake is configured alongside LPCOMP when supported hardware exposes VBUS to the nRF52. The power management functionality is integrated into the A static constructor with priority 101 in This ensures we capture the true reset reason before any initialisation code runs. To enable power management on a board variant: Enable in platformio.ini: Define configuration in variant.h: Implement in board .cpp file: ```cpp #ifdef NRF52_POWER_MANAGEMENT const PowerMgtConfig power_config = { .lpcomp_ain_channel = PWRMGT_LPCOMP_AIN, .lpcomp_refsel = PWRMGT_LPCOMP_REFSEL, .voltage_bootlock = PWRMGT_VOLTAGE_BOOTLOCK }; void MyBoard::initiateShutdown(uint8_t reason) { // Board-specific shutdown preparation (e.g., disable peripherals) bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || reason == SHUTDOWN_REASON_BOOT_PROTECT); } #endif void MyBoard::begin() { NRF52Board::begin(); // or NRF52BoardDCDC::begin() // ... board setup ... #ifdef NRF52_POWER_MANAGEMENT checkBootVoltage(&power_config); #endif } ``` For user-initiated shutdowns, The LPCOMP (Low Power Comparator) is configured to: - Monitor the specified AIN channel (0-7 corresponding to P0.02-P0.05, P0.28-P0.31) - Compare against VDD fraction reference (REFSEL: 0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) - Detect UP events (voltage rising above threshold) - Use 50mV hysteresis for noise immunity - Wake the device from SYSTEMOFF when triggered VBUS wake is enabled via the POWER peripheral USBDETECTED event whenever LPCOMP Reference Selection (PWRMGT_LPCOMP_REFSEL): Important: For boards with a voltage divider on the battery sense pin, LPCOMP measures the divided voltage. Use: The power management code checks whether SoftDevice is enabled and uses the appropriate API: - When SD enabled: This ensures compatibility regardless of BLE stack state. Power management status can be queried via the CLI: On boards without power management enabled, all commands except When This document lists unique numbers/identifiers used in various MeshCore protocol payloads. The To make sure multiple applications can function without interfering with each other, the table below is for reserving various ranges of data-type values. Just modify this table, adding a row, then submit a PR to have it authorised/merged. NOTE: the range FF00 - FFFF is for use while you're developing, doing POC, and for these you don't need to request to use/allocate. Once you have a working app/project, you need to be able to demonstrate it exists/works, and THEN request type IDs. So, just use the testing/dev range while developing, then request IDs before you transition to publishing your project. (add rows, inside the range 0100 - FEFF for custom apps) This document describes the MeshCore packet format. This is the protocol level packet structure used in MeshCore firmware v1.12.0 NOTE: see the Payloads documentation for more information about the content of specific payload types. Bit 0 means the lowest bit (1s place) Hash size codes: Examples: Inside each MeshCore Packet is a payload, identified by the payload type in the packet header. The types of payloads are: This document defines the structure of each of these payload types. NOTE: all 16 and 32-bit integer fields are Little Endian. This kind of payload notifies receivers that a node exists, and gives information about the node Appdata Appdata Flags An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the \"extra\" payload (see Returned Path) instead of as a separate acknowledgement packet. CLI commands do not cause acknowledgement responses, neither discrete nor extra. Returned path, request, response, and plain text messages are all formatted in the same way. See the subsection for more details about the ciphertext's associated plaintext representation. Returned path messages provide a description of the route a packet took from the original author. Receivers will send returned path messages to the author of the original message. For the common chat/server helpers in Gets information about the node, possibly including the following: Not defined in Not defined in Not defined in Not defined in Not defined in Not defined in Response contents are opaque application data. There is no single generic response envelope beyond the encrypted payload wrapper shown above. txt_type The plaintext contained in the ciphertext matches the format described in plain text message. Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be The sender name is unverified message text. Group messages contain no sender signature, so any channel-key holder can choose any sender name. The data contained in the ciphertext uses the format below: Custom packets have no defined format. This document provides an overview of QR Code formats that can be used for sharing MeshCore channels and contacts. The formats described below are supported by the MeshCore mobile app. Example URL: Parameters: Example URL: Parameters: Binary frame structures for companion radio stats commands. All multi-byte integers use little-endian byte order. The The Total Frame Size: 11 bytes Total Frame Size: 14 bytes Total Frame Size: 26 bytes (legacy) or 30 bytes (includes Below are the commands you can enter into the Terminal Chat clients: Set the LoRa frequency. Example: set freq 915.8 Sets LoRa transmit power in dBm. Sets your advertisement name. Sets your advertisement map latitude. (decimal degrees) Sets your advertisement map longitude. (decimal degrees) Sets the transmit duty cycle limit (1-100%). Example: Sets the transmit air-time-factor. Deprecated \u2014 use Set the device clock using UNIX epoch seconds. Example: time 1738242833 Sends an advertisement packet Displays current time per device's clock. Shows the device version and firmware build date. Displays your 'business card', for others to manually import Imports the given card to your contacts. List all contacts by most recent. (optional {n}, is the last n by advertisement date) Shows the name of current recipient contact. (for subsequent 'send' commands) Sets the recipient to the first matching contact (in 'list') by the name prefix. (ie. you don't have to type whole name) Sends the text message (as DM) to current recipient. Resets the path to current recipient, for new path discovery. Sends the text message to the built-in 'public' group channel Branch: Odchylenia od propozycji (stan faktyczny): - Akcji Filter\u2026 w menu nie ma \u2014 duplikowa\u0142a cykl Ekran \u0142\u0105czy w sobie trzy osobne tryby o w\u0142asnych listach, widokach szczeg\u00f3\u0142\u00f3w i popupach: Stan ekranu trzyma 15+ p\u00f3l ( Ale to nie jest jedna o\u015b \u2014 to trzy wci\u015bni\u0119te w jeden liniowy cykl: Konsekwencje: - u\u017cytkownik \u201escrolluje po kategoriach\u201d na \u015blepo \u2014 nie widzi wszystkich naraz, musi cyklowa\u0107, by trafi\u0107 w to, czego szuka; - kombinacje s\u0105 niemo\u017cliwe: nie da si\u0119 zobaczy\u0107 \u201eulubionych repeater\u00f3w posortowanych po dystansie\" ani \u201erepeater\u00f3w posortowanych po czasie\u201d \u2014 model wymusza dok\u0142adnie jeden stan z siedmiu; - Ten sam w\u0119ze\u0142 oferuje inny zestaw akcji w zale\u017cno\u015bci od tego, gdzie na niego patrzysz. Ping jest osi\u0105galny tylko ze szczeg\u00f3\u0142\u00f3w, Discover tylko z listy. Brak sp\u00f3jnego modelu \u201eprzytrzymaj = menu akcji\u201d. W przeciwie\u0144stwie do reszty popup\u00f3w, ping-menu: - ma wiersze tylko-do-odczytu (RTT / SNR), wi\u0119c po\u0142yka To dzia\u0142a, ale jest to czwarty, niestandardowy wzorzec interakcji w jednym narz\u0119dziu. Trzy zasady przewodnie: (a) jedno menu akcji wsz\u0119dzie, (b) filtr i sortowanie jako osobne, jawne osie, (c) jeden wzorzec listy. Zamiast jednego cyklu 7-stanowego \u2014 dwie niezale\u017cne osie wybierane z menu (nie przez \u015blepe cyklowanie): Filtr (o\u015b \u201eco pokazujemy\") i sort (o\u015b \u201ew jakiej kolejno\u015bci\") s\u0105 od siebie niezale\u017cne i \u0142\u0105cz\u0105 si\u0119 dowolnie: Sterowanie (zatwierdzone): - Dzi\u0119ki temu \u201eulubione repeatery po dystansie\" staje si\u0119 mo\u017cliwe, a w nag\u0142\u00f3wku wida\u0107 oba wymiary, np. Jeden To samo menu na li\u015bcie i w szczeg\u00f3\u0142ach, w obu \u017ar\u00f3d\u0142ach (Zapisane / Skan). Pozycje niedost\u0119pne s\u0105 pomijane (jak ju\u017c teraz robi Zatwierdzone: zamiast osobnego pod-ekranu Discover \u2014 jeden komponent listy/szczeg\u00f3\u0142\u00f3w/menu/ping nap\u0119dzany prze\u0142\u0105cznikiem \u017ar\u00f3d\u0142a: To nie jest dos\u0142owne zlanie dw\u00f3ch list w jedn\u0105 tablic\u0119 (dane maj\u0105 r\u00f3\u017cny kszta\u0142t \u2014 kontakt ma GPS, wynik skanu ma sygna\u0142), tylko jedna \u015bcie\u017cka interakcji nad dwoma \u017ar\u00f3d\u0142ami. Wyb\u00f3r \u017ar\u00f3d\u0142a = \u201eDiscover scan\" w menu (uruchamia Kolejno\u015b\u0107 implementacji: 1 \u2192 2 \u2192 3 \u2192 4. Etapy 1\u20132 daj\u0105 najwi\u0119ksz\u0105 popraw\u0119 \u201euporz\u0105dkowania\" przy najmniejszym ryzyku; 3\u20134 domykaj\u0105 sp\u00f3jno\u015b\u0107 (jedna lista, jedno menu wsz\u0119dzie). Go back This is a developer guide to the reusable building blocks behind the Everything below lives under Single-TU only. These fragments compile only as part of Every screen implements That's the whole contract. Steps 1, 3 and 4 are compiler-checked (a mismatch won't link); only a forgotten step 2 can slip through \u2014 every screen pointer is nullptr-initialised in The constructor takes Drawing helpers (all clip/measure for you): Standalone scroll indicators ( All follow the same shape: a Two layouts share every grid: ABC (one key per letter) and T9 (phone-keypad multi-tap \u2014 repeated Enter within Geo ( Coordinates are int32 degrees \u00d7 1e6 everywhere (GPS, contacts, trail, prefs). The message tags are State stores: Message reply prefix: Small glyphs are authored as ASCII art and packed at compile time ( Draw with Icons are drawn from a fixed priority-ordered table ( Screens with a Hold-Enter context menu (Nodes, Bot, Admin, Diagnostics, \u2026) pass Keys arrive as the Use the prev/next convention for value changes so the rotary encoder and the D-pad agree: Each physical button is a Two mechanisms keep input responsive when Device settings live in one The The shared \"active target\" (Locator/Nav destination) is set through Then: Branch: Renderowanie mapy: Problemy: - D\u0142ugi scroll \u2014 na OLED wida\u0107 ~4 wiersze naraz, wi\u0119c do \u201eReset trail\" trzeba przewin\u0105\u0107 przez ca\u0142\u0105 list\u0119; - Dwa wzorce interakcji w jednym menu \u2014 cz\u0119\u015b\u0107 pozycji reaguje na LEFT/RIGHT (ustawienia), cz\u0119\u015b\u0107 na Enter (akcje). Enter na wierszu ustawie\u0144 nic sensownego nie robi \u2014 tylko zamyka i otwiera menu od nowa ( Uwagi: - Komentarz przy kroku 4 ( G\u00f3rne menu kr\u00f3tkie (akcje), ustawienia i operacje na pliku w podmenu: Korzy\u015bci: - g\u00f3rne menu to ~5 pozycji, bez scrolla na OLED; - jeden wzorzec na poziom: g\u00f3rny i \u201eTrail file\" = Enter-akcje; \u201eSettings\" = warto\u015bci cyklowane LEFT/RIGHT \u2014 bez mieszania w jednym widoku; - destrukcyjny Wariant minimalny (mniej kodu): zosta\u0107 przy jednej li\u015bcie, ale pogrupowa\u0107 (ustawienia \u2192 akcje \u2192 plik), Wydzieli\u0107 ma\u0142y Czysto refaktoryzacyjne \u2014 bez zmiany wygl\u0105du mapy. Wynik wizualnie identyczny, logika kr\u00f3tsza i \u0142atwiejsza do utrzymania. Etap 1 = najwi\u0119ksza poprawa \u201euporz\u0105dkowania popupu\" (g\u0142\u00f3wna pro\u015bba). Etapy 2\u20133 = czyszczenie logiki mapy/siatki bez zmiany wygl\u0105du. Joystick-only UX constraints: 4 directions + Enter + Back. No text entry except inside KeyboardWidget. Everything else navigable with cursor + press. Status legend: \ud83d\udccb planned \u00b7 \ud83d\udea7 in progress \u00b7 \u2705 done \u00b7 \u274c rejected/deferred Hold Enter on the MESSAGE mode-select screen (DM / Channels / Rooms) opens a 1-item context menu \"Mark all read\". Acts on the currently highlighted mode and shows a brief confirmation alert. Implementation: - New Phase 1 \u2705 (storage + read-only render + grid nav) Phase 2 \u2705 (pin from Contact options menu in QuickMsg + slot picker submenu) - Enter on a filled tile opens that contact's DM directly - Cancel from a Favourites-opened DM returns to the home screen Phase 3 \u2705 (in-place pin picker on empty tile: upstream-favourited contacts first, then recent DM contacts deduped; selecting a contact that's already pinned elsewhere moves it to the new slot) Follow-up done: OLED unread-badge overlap fixed \u2014 badge and name share the same baseline, drawTextEllipsized's max width subtracts badge width + a 3 px gap so names shorten to \"Nam\u2026\" before the digit. A 2\u00d73 grid (six slots) of pinned contacts on its own home page, between Clock and Messages. Joystick picks a tile, Enter opens the existing DM conversation or sends a pre-set quick reply. Data model: - New field in NodePrefs: Pinning UX: - In QuickMsg DM list, long-press on a contact \u2192 context menu \u2192 \"Pin to dial\" \u2192 asks which of the 6 slots - Unpin via the same menu (only shown when contact is already pinned) Schema bump: add Render layout (250\u00d7122 landscape e-ink): Joystick navigation is natural with 6 tiles (UP/DOWN between rows, LEFT/RIGHT within row). Phase 1 \u2705 (storage + sampling + Summary view + G indicator in status bar) Phase 2 \u2705 (auto-fit Map view with cos(lat) aspect compensation; LEFT/RIGHT cycles views) - Summary scrolls on short panels (OLED) so hint stops overlapping - Status-bar G blinks at the same cadence as A (forces 1 s home refresh) - Default sampling 30 s + 5 m min-delta (was 60 s + 25 m \u2014 too sparse on foot) - \"Avg speed\" replaces \"Speed\"; Time uses RTC so it ticks every render - Stop \u2192 start creates a new segment; the map doesn't bridge dead time, and total distance skips segment boundaries Phase 3 \u2705 (per-point list view with HH:MM local time + delta-from-previous; segment-start rows show \"start\" instead of a delta) Phase 4 \u2705 (Hold-Enter popup grows Save / Load / Reset / Export GPX / Export saved entries. Single flash slot at /trail (binary header with magic+version+count+accumulated_ms then raw TrailPoint records). GPX 1.1 dump goes over USB Serial; \"Export GPX\" streams the live RAM ring, \"Export saved\" streams the flash file straight to USB without touching the live ring. Segments respect SEG_START boundaries. Alert reflects BLE-app-collision state) Phase 5 \u2705 (Settings + actions consolidated into a single Hold-Enter popup \u2014 Min dist + Units cycled with LEFT/RIGHT (popup stays open), plus Start/Stop tracking and Reset action items. Short Enter never toggles \u2014 both start and stop go through the popup, so a stray tap can never change tracking state. View counter (N/3) lives in the title bar; the bottom hint row is gone, content fills the freed space. Sampling cadence fixed at 1 s, GPS upd setting also removed; both rely on the sensor manager's defaults) Polish \u2705 (map view: filled/open dot markers around segment breaks; \"Waiting for GPS fix\" status when started without a lock; capacity bumped to 512 points; elapsed/avg-speed run on millis() instead of RTC so they tick even before GPS time is synced) Tools \u203a Breadcrumb. Periodically samples Logging is a runtime state, not a settings value. User starts/stops from the Tools \u203a Breadcrumb screen. Once active, sampling continues in the background regardless of which screen is shown, and a Settings only control sampling cadence and the min-distance gate \u2014 they don't enable/disable the feature. Storage model \u2014 RAM ring with explicit save Rationale: auto-off only blanks the display, the firmware keeps running, so the RAM trail survives every idle scenario. Typical use is a single trip start\u2192stop while wearing the device; persisting across reboots is rarely wanted. RAM-only avoids ~1400 flash writes/day and the LittleFS wear that comes with continuous logging. Snapshot slots on flash (user-initiated only): - UI screens (LEFT/RIGHT cycles): 1. Summary \u2014 total distance (km), elapsed time (h:mm), point count, current speed (from last 2 samples), GPS fix indicator 2. Trail map \u2014 ASCII bounding-box plot. Auto-fit the polygon, current position marked Joystick actions: - Enter \u2192 toggle live logging on/off (status bar shows Statistics computed on the fly walking the ring: - Total distance: sum of Haversine(p[i], p[i-1]) - Elapsed time: ts[last] - ts[first] - Current speed: dist(last, prev) / (ts[last] - ts[prev]) Settings: - Settings \u203a GPS \u203a Breadcrumb interval: 30 s / 1 min / 5 min / 15 min (default 1 min) \u2014 only the cadence; logging on/off is a Tools toggle - Settings \u203a GPS \u203a Breadcrumb min delta: 5 m / 25 m / 100 m (skip near-stationary samples to keep the ring densely populated with real movement) - Export format: GPX (standard for GPS tracks; OSMAnd / Garmin compatible) Schema impact: new prefs fields Edge cases: - No GPS fix: skip sampling, status indicator dims - Low-batt shutdown: optional auto-save to slot 0 (one write) before powerdown - Memory: 3 KB RAM is negligible on this MCU; if RAM ever tightens, drop to 128 entries \u2705 Shipped (branch Shared helpers extracted: The original design spec is kept below as a record. Turns Solo from a comms device into a basic GPS navigator. Mark the current position with a short label, then later get bearing + distance back to it \u2014 ideal for off-grid use (car, camp, trailhead, water source). Storage \u2014 dedicated flash file Marking \u2014 from the GPS or Trail screen: Hold Enter \u2192 \"Mark here\". - Requires a GPS fix; otherwise alert \"No GPS fix\". - Opens the existing Visible on the trail Map \u2014 waypoints render on the existing Map view as a distinct marker (e.g. a hollow diamond or a small flag) so they show in context with the recorded track: - Fold waypoint coords into the map's bounding box so off-track waypoints stay in frame. Today Trail workflow integration \u2014 waypoints live inside the Trail screen, not a separate Tools entry, because marking points of interest happens while you are recording: - Mark: Trail \u2192 Hold Enter \u2192 \"Mark here\" (new action-menu row). Opens the keyboard for the label, saves the current fix. Works whether or not tracking is active \u2014 a waypoint is independent of trail recording state. - Manage / navigate: Trail \u2192 Hold Enter \u2192 \"Waypoints\" \u2192 a PopupMenu list of saved waypoints (label + distance). Selecting one: - Enter \u2192 fullscreen nav (see below). - Hold Enter \u2192 Rename / Delete (later: Share over mesh). - Waypoints persist across trail Reset / reboot (separate Navigation view \u2014 two bearings, no compass needed The L1 has no magnetometer, so we can't show \"you are facing X\". Instead the nav view shows two absolute bearings and lets the user do the comparison \u2014 robust against GPS jitter, no relative-turn maths: Reading it: target is at 145\u00b0, I'm travelling at 90\u00b0 \u2192 I need to bear right. When standing still (course undefined) the Hdg line shows Course over ground (COG) \u2014 a small This COG value can later back a standalone \"heading\" trail view if wanted, but the two-bearing nav view already covers the practical need. Shared nav view \u2014 also navigate to a node \u2b50 The nav view's target is just Node target is the contact's COG source must be decoupled from trail recording. Deriving the heading from The COG ring is time-sampled with guards, not raw displacement-gated: push every GPS fix at the normal poll cadence (~1 s) into a short rolling window (a few seconds of fixes), and compute the heading as the bearing across the window (oldest\u2192newest), optionally low-pass smoothed. Time-based sampling gives a steadier, averaged course than bearing between just two points, and tracks slow movement without lagging. Two guards keep it honest: - Gross-error rejection \u2014 drop a fix before it enters the window if it implies an impossible jump (implied speed over a sane cap, e.g. > ~50 m/s between consecutive fixes), or if the provider exposes a usable validity/HDOP signal. One bad fix shouldn't swing the heading. - Minimum displacement over the window \u2014 only emit a heading once the total movement across the window exceeds a small threshold (a few m). Below that the user is effectively stationary: hold the last good heading, and show Threshold(s) can be fixed constants to start; expose in Settings later if it proves worth tuning. Open question: whether the nav view should also be reachable as a 4th Map overlay state (cycle a \"highlighted\" waypoint with LEFT/RIGHT on the Map) or stay list-driven only. Start list-driven; add map cycling later if wanted. Shipped: the Waypoints list always begins with a synthetic Trail start row whenever a trail exists, opening the shared nav view to the first recorded point. No new storage. (Navigates to the start point, not progressive nearest-point following \u2014 adequate for \"get me back\".) Superseded by the shared nav view described under Waypoints above: navigate to a node = open that nav view with the contact's last-advert Today the trail Map is north-up. Optionally orient it to the current course (COG, same source as the compass tape) so the travel direction is up \u2014 a Trail action-menu toggle Orientation: North-up / Heading-up. Lightweight approach: rotate the already-fitted map around its centre by Caveats that make it more than a one-line toggle: - COG-only heading (no magnetometer) \u2014 undefined while stationary, so hold the last good course or fall back to north-up; it can't be heading-up when you're standing still. - Jitter \u2014 rotating the whole map by raw COG makes it shake; needs heading smoothing / hysteresis (only re-rotate past ~10\u201315\u00b0 of change). - Fit \u2014 rotated content overflows the rectangle. Refitting to the rotated bbox makes the scale \"breathe\" as you turn; alternative is to accept minor edge clipping. - Grid \u2014 the axis-aligned scale grid becomes diagonal; drop it in heading-up mode or rewrite it. - e-ink \u2014 a rotating map ghosts badly and refreshes slowly; this is really an OLED feature, flag it as degraded on e-ink. A fuller position-centred navigator (you fixed at screen centre, fixed/preset zoom, pan) is a larger separate feature; start with the rotate-the-fit version if pursued. On branch \u2705 Done \u2014 hardware duty-cycle RX (\"Pwr save\") - Uses the SX126x's own RX duty-cycle ( History: an earlier attempt used a software CAD state machine (scan \u2192 warm-sleep window \u2192 on-detect full RX, with \u2705 Done \u2014 Adaptive Power Control (\"Auto pwr\") - \u23f8 To return to (not done) - Current measurement \u2014 never taken (no PPK2/meter to hand). Reliability is confirmed in use; the actual mA win is still unquantified. Do this first. - APC sample targeting \u2014 a direct ACK's SNR is the last hop of the return path (sound on symmetric/direct links); a flood echo is the first hop from us to a repeater, which is exactly what our TX power controls. Both feed one shared controller; per-source weighting or direct-only (0-hop) gating could be explored. Original analysis (kept for context): Goal: multiply battery life without losing functionality. The dominant draw on this node is the radio in continuous RX (always-on Inspired by ZephCore (Zephyr MeshCore port, https://github.com/liquidraver/ZephCore), whose battery edge comes from radio/peripheral power technique, not the kernel: Explicitly out of scope: GPS power gating. ZephCore powers the GNSS only during a fix; this device is used as a live navigator + trail recorder, so GPS stays continuously powered. Don't gate it. Cross-check: as of the v1.16 upstream merge, MeshCore now ships native NRF52 companion power-saving (PR #1238, docs/nrf52_power_management.md). The companion loop now sleeps via Sequencing: hardware duty-cycle RX and APC are both done and in field test on On-device repeater for the Solo companion, scoped to the SX1262 boards (Wio Tracker L1 OLED/e-ink, GAT562 30S). All on Configurable in Settings \u203a System \u203a SOS: - Target: channel index or DM contact - Message template (uses placeholders) Trigger: Hold Back + Hold Enter for 3 s on any screen \u2192 confirmation popup (\"Send SOS?\") \u2192 Enter to send. Sends with The practical need is covered by ping rather than a dedicated screen: in Nearby Nodes, a node's detail view \u2192 Hold Enter \u2192 Ping sends a direct mesh ping and shows RTT + SNR (own and remote), repeatable on demand. Available from both the stored-node detail and the active-discovery detail. The original idea below (a Tools \u203a Range Test screen with continuous 5 s pinging and a 30-sample sparkline) was not built \u2014 kept as a possible future enhancement on top of the existing ping. Tools \u203a Range Test: - Pick a node from contacts/nearby - Enter starts pinging every 5 s, logs RTT + RSSI + SNR (ring ~30) - Display shows current values + 30-sample sparkline (block characters) - Enter stops; Hold Enter for context menu (reset, change target) Settings \u203a Sound \u203a Quiet Hours: - Enable on/off - Start HH (LEFT/RIGHT to change, 24 h) - End HH When within window: buzzer set to \"off\" (overrides setting), display brightness \u2192 0. Restores prefs values when window ends. Time source: rtc_clock. Toggleable in Settings \u203a Home Pages. Lists channels with: name, unread count, last message age. Enter opens the channel. Sort by recency by default; LEFT/RIGHT toggles to alphabetical. QuickMsg DM list: a 4-th sort mode (currently sorted by message count). LEFT/RIGHT on the list header cycles: name | message-count | recency | distance. Distance uses GPS pos from contact's last advert. Tools \u203a Stats: - Battery voltage 60-min sparkline - RSSI of last 30 received packets - Noise floor current - Free heap (if available) Read-only. UP/DOWN switches between metrics. Bottom shows current value as text. Realised as a command bot rather than a trigger/reply table: with Commands ON, a DM is scanned for Shipped: the lock screen shows a total unread badge ( Settings \u203a Profile: Indoor / Outdoor / Expedition. Each pre-fills: - Auto-off seconds - GPS interval - Auto-advert interval - Brightness Single Enter applies. Stored as Settings \u203a System \u203a Batt Calibration: edit 5 voltage breakpoints used to convert mV \u2192 %. UP/DOWN selects breakpoint, LEFT/RIGHT changes voltage in 50 mV steps. Helps users with non-standard LiPos report accurate %. Tools \u203a Display Test: full-screen grid + bars + Lemon glyph dump. Useful for verifying driver/font changes after flashing. From channel view, Hold Enter \u2192 \"Who's online?\". Sends 0-hop discovery to channel members, collects responses for 10 s, shows a list with RSSI. Similar to existing Nearby active discovery but scoped to a channel. Captured for later triage. None designed in detail yet (DM delivery status has since shipped \u2014 see below). \u2705 Shipped (in Original spec: Show whether an outgoing direct message reached the recipient, using the ACK that MeshCore already produces (no protocol change). Per-message status glyph at the end of each outgoing DM row in the history: - Data model (RAM only, no schema bump \u2014 DM history already lives in RAM): - Wiring: - On send: store Edge cases: - Sends with no path / Auto-send Buzzer + alert when within X m of the active nav target (waypoint / node / backtrack). Closes the loop on the navigator \u2014 you no longer have to stare at the distance readout. Radius configurable (e.g. 20/50/100 m). Tools entry showing current lat/lon (optionally MGRS/UTM grid ref), fix quality (sats / HDOP if the provider exposes it), altitude, and a one-press share to a channel/DM. Complements the nav suite with an at-a-glance position readout. Render the device's own contact (or a channel) as a QR on the display so a phone can import it without the companion app. The QR payload format already exists ( Computed from GPS position + RTC date \u2014 pure math, no extra hardware. A Clock dashboard field or a small Tools readout. Useful for planning outdoor activity. A received command (from a paired contact, or a dedicated channel keyword) makes the node play a locator tone for a few seconds. Helps find a dropped/misplaced device. Gate behind a setting to avoid abuse. Stop counting elapsed/avg-speed (and optionally skip sampling) when stationary, so \"moving time\" and average speed reflect actual travel. Reuses the COG ring's min-displacement gate to detect standing still. Quick toggle for an inverted / minimum-brightness scheme for night use, separate from the brightness levels. On e-ink flag as degraded (inversion ghosts). Simple Tools utility \u2014 count-up stopwatch and a count-down timer with a buzzer at zero. Joystick: Enter start/stop, Hold Enter reset. If the GPS provider exposes altitude, add total ascent + current elevation to the trail Summary and Cute but niche. Skip unless explicitly requested. Marginal real-world gain (2 ms between notes during melody playback only), high risk on the PWM peripheral. Wio Tracker L1 doesn't have a haptic motor. N/A. After #3, re-prioritise the backlog with the user. Pass through wio-unified after commit Both group-channel receive paths now check the result of Unknown-secret packets no longer pollute the offline queue, UI history or trigger the bot with a bogus Defensive Fixed (local override): Fixed: the save loop now skips unused slots (all-zero secret) instead of writing every slot up to When the companion app reads the last message from the offline queue, all on-device badges disappear. Previously discussed and a fix was reverted as \"intended sync behaviour\" \u2014 keep as known limitation; document or restrict to \"Favourites Dial badges only\". The reset is now gated on Re-checked: The 31-byte name slot in Left as-is: the framework always renders before forwarding input, so the fragile invariant doesn't fire in practice. Worth a refactor only if the call order ever changes. Pub-key line is skipped entirely when Point (0, 0) is a legitimate location (Gulf of Guinea). Corner case but a logic error. Left for a future pass with a proper GPS-validity bool. Scan detail view ( A Re-checked: Two fallback \"?\" sender names now use a plain Re-checked: Fix status after this pass: Go back Two optional hardware add-ons, both auto-detected and both entirely optional \u2014 a build with them enabled runs exactly the same with nothing plugged in. Two of the newer boards also ship with their own built-in keypad instead \u2014 see Built-in keyboards. Plug it into the second I2C bus (the Grove connector on the Wio Tracker L1; see Wiring for the Heltec boards). The firmware probes for it once at boot \u2014 nothing to enable in Settings. The same bus is scanned for environment sensors, so a CardKB and a sensor can share it. Printable characters insert straight at the cursor, bypassing the on-screen grid completely. The alphabet and T9/ABC settings do not apply \u2014 a real keyboard sends the right character already, so typing is always plain Latin ASCII regardless of what Settings \u203a Keyboard is set to. Fn+Esc rather than the adjacent Fn+Backspace on purpose: Fn and Backspace sit next to each other on CardKB's layout and would be far too easy to hit by accident. See Screen Lock for the physical button equivalent. Settings \u203a Keyboard \u203a Ext. KB picks how the on-screen keyboard behaves while a CardKB is doing the typing. Compact is designed to need no joystick at all, which makes it the right choice on a board where CardKB is the only input device \u2014 for example a Heltec V3/V4 with no joystick soldered on. In that case set it once and forget it. Cursor mode and the accent / placeholder popups draw their own visible feedback, so they behave identically in both modes. Four direction contacts plus a separate Back button. Each contact simply shorts its pin to ground \u2014 the firmware enables the internal pull-ups, so no external resistors are needed. Neither board ships with a joystick or a keyboard header, so both are soldered to free GPIOs. V3 and V4 are pin-compatible per Heltec's documentation and the solo builds use the same assignment for both \u2014 confirmed working on real V4 hardware; still worth checking against your own V3 module before soldering. Everything above lives in the Experimental \u2014 newly-added board support, not the CardKB/joystick add-ons above. Both keypads are TCA8418-based and share one polling path, entirely independent of the CardKB code \u2014 a board can have either, or neither. Neither keypad follows CardKB's exact Fn-shortcut table (Fn+Enter, Fn+letter accent popups, Tab, Fn+Esc lock) \u2014 see each board's own Go back A full-screen clock page on the home screen. Shows the current time and date, with up to three configurable data fields below. Time is synchronized from GPS or via the companion app. Timezone offset is applied from Settings \u203a System. If no time source is available, the screen shows \"! No time sync\" with a hint to enable GPS or connect the app. Up to three data fields are shown below the date separator. Each field displays a label and a value on the same line. Sensor fields show Hold Enter (or press the Context menu key) on the Clock page to open the Dashboard Config screen, where each of the three field slots can be cycled with LEFT/RIGHT. Press Enter (short press) on the Clock page to open Clock Tools, a small menu with three time utilities. Cancel backs out one level (tool \u2192 menu \u2192 home). The same menu also has an entry under Tools \u203a System, so it's reachable without the Clock page. A wake alarm with an optional repeat. Rows: Hour, Minute, Repeat and Armed. Enter on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); Enter on Repeat cycles OFF \u2192 Daily \u2192 Weekdays \u2192 Weekends \u2192 OFF; Enter on Armed toggles ON/OFF. The configured time is shown next to the Alarm menu row when armed, and the setting persists across reboots. While an alarm is armed a bell icon signals it in two places: the top-left corner of the Clock page itself, and the top status bar of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only \u2014 the exact alarm time is on the Alarm row inside Clock Tools. The alarm is scheduled as an absolute fire instant, so it is robust to clock re-syncs \u2014 the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). With Repeat set to OFF (the default) the alarm disarms itself after firing once, same as before; with a repeat pattern set, it stays armed and re-schedules itself for the next matching day instead. The alarm only fires while the device is awake (it keeps running with the display off or locked). It cannot wake the device from a full Shutdown (the CPU and RAM are powered down), and needs a valid time source \u2014 it stays pending until the clock is synced. A large HH:MM:SS readout with one digit underlined. LEFT/RIGHT moves the cursor one digit at a time, Up/Down changes the digit under it (minute/second tens cap at 5, hours at 23), and Enter starts the countdown. While running it shows H:MM:SS \u2014 Enter stops it, Cancel returns to the menu and leaves it counting. When it reaches zero the device rings, even if you have navigated to another screen. Enter starts/stops; Up/Down resets when stopped; Cancel returns to the menu and leaves it running. When the alarm or timer fires the device plays a melody (overriding mute) and shows an alert. Any key silences it; otherwise it stops on its own after a minute. E-ink note: the live timer/stopwatch readouts would thrash a slow e-paper panel if redrawn every second, so on e-ink they refresh only coarsely (and immediately on any key press). The underlying timing is exact regardless, and the countdown's buzzer always fires on time. Go back A dedicated home page showing a grid of up to 6 pinned contacts for quick access. The layout adapts to the display orientation: Navigate tiles with UP / DOWN / LEFT / RIGHT. Pressing a directional key at the edge of the grid switches to the adjacent home page instead of wrapping. Enter on a filled tile \u2014 opens that contact's DM directly. Enter on an empty tile ( Filled tiles show an unread message count in the top-right corner when there are unread DMs from that contact. The contact name is ellipsized to make room for the badge. If a pinned contact is removed from the contacts list \u2014 explicitly, or auto-evicted to make room when the table is full \u2014 its slot is freed automatically and goes back to an empty From the Favourites Dial \u2014 press Enter on an empty tile ( Select a contact to pin it to that slot. From a DM conversation \u2014 Hold Enter \u203a context menu \u203a Pin to dial, then choose a slot from the slot picker (Slot 1\u20136, showing the current occupant name or \"empty\"). If the selected contact is already pinned in another slot, it is moved to the new slot automatically. Open the contact's DM, Hold Enter \u203a context menu \u203a Unpin (slot N). The position of the Favourites Dial in the home page navigation sequence can be changed in Settings \u203a Home Pages \u2014 press LEFT / RIGHT on the Favourites entry to move it earlier or later. Go back The Messages screen is split into three modes \u2014 DMs, Channels, and Rooms \u2014 selectable with UP/DOWN on the mode-select screen. Each mode shows the corresponding list of conversations with unread counters. Press Enter on a contact or channel to open its history, then press Enter again (or select the [+ send] button, anchored at the right edge of the history) to compose a message. Choose between: While typing, UP from the top letter row enters cursor mode (LEFT/RIGHT move the insertion point; UP/DOWN jump to start/end, then continue on to the special row / letter grid if pressed again once already there; Enter/Cancel exit immediately from anywhere) so you can edit or insert in the middle of what you've typed instead of only at the end. Hold Enter on a Latin letter with accented variants (e.g. a, e, c, n, o, s, z\u2026) instead opens a one-row popup of that letter's accents \u2014 LEFT/RIGHT to pick, Enter to insert, Cancel to dismiss. See the on-screen keyboard section of the UI framework guide for the full key set (Shift, T9 multi-tap, Cyrillic/Greek). The keyboard supports placeholders that insert live data at send time: Sensor placeholders appear automatically in the placeholder picker when the corresponding sensor is active. Posting to a room server requires a login handshake first, so the device can log in on its own \u2014 no phone app needed. The first time you press Enter on a room, a password prompt opens automatically; type the room's password and press the \u2713 key (leave it empty and submit for open / no-password rooms). Once the login succeeds the room's chat opens automatically \u2014 no second Enter needed (as long as you're still on that room in the list). The on-screen keyboard's default (Latin) page is ASCII only. Typing accented or non-Latin characters \u2014 Polish, Czech, Slovak, German, French, Spanish, Portuguese or Nordic diacritics, Cyrillic, or Greek \u2014 needs Settings \u203a Keyboard \u203a Alphabet set to the matching language first; the keyboard's #@/abc key then cycles Latin \u2192 that alphabet \u2192 Symbols \u2192 Latin. A password containing characters outside whatever's currently enabled can still be set from the phone app \u2014 the device stores and replays it byte-for-byte. Messages are drawn as chat bubbles sized to fit their content, anchored right for your own outgoing messages and left for incoming ones (like a typical messenger), with the sender name and a compact age indicator ( Short Enter on a message opens it in fullscreen. Hold Enter \u2014 on a history row or in fullscreen \u2014 opens the same options menu: Reply, plus Navigate / Save waypoint when the message contains a location (see Fullscreen message view). You don't need to open the message first. Navigate between messages with LEFT (newer) and RIGHT (older). Long messages scroll with UP/DOWN. If the message is a reply addressed to someone ( Hold Enter in fullscreen opens the options menu. It always offers Reply for an incoming message, and when the message contains a location it adds two more: A location is any Hold Enter on a contact entry opens a context menu: When Pin to dial is selected, a slot picker opens (Slot 1\u20136 showing current occupant name or \"empty\"). Choosing a slot that already holds another contact moves the new contact there. In the Rooms list the context menu instead offers: Hold Enter on a channel entry opens a context menu: Joining a new community channel, or creating one to share with others, no longer needs the phone app. The Channels list ends with a \"+ Add channel\" row \u2014 press Enter on it to pick a channel type, or use Edit from the context menu above to change an existing channel's name or secret (Edit skips the type picker and opens the Name/Secret form directly). + Add channel first asks which type of channel to create \u2014 the same three types the phone app offers: Select [Save] to commit. The secret can't be redisplayed once saved (only the derived key is kept) \u2014 editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password. Hold Enter on the DM / Channels / Rooms mode-select screen to clear all unread counters for the highlighted category at once. Go back Screen lock prevents accidental keypresses. While locked the display turns off and all input is ignored. Hold Back and press Enter three times within 3 seconds. The sequence works in both directions \u2014 the same combination locks and unlocks. On boards with an optional CardKB (I2C keyboard) attached, a single Fn+Esc does the same thing, in either direction \u2014 no repetition needed, since Fn+Esc is already a deliberate two-key combo. Esc rather than the adjacent Backspace, since Fn and Backspace sit right next to each other on CardKB's layout and would be too easy to hit by accident. If the display is off when the sequence begins, it turns on automatically so the hint is visible. Each press in the physical sequence extends the display-on timer by 5 seconds. The hint popup at the bottom of the lock screen guides through the physical sequence: If no press is made for 3 seconds, the counter resets. A brief press of any button wakes the display and shows the lock screen. It displays: The display turns off again automatically after 5 seconds of inactivity (or 2 seconds immediately after locking). Enable Auto-lock in Settings \u203a Display to lock the device automatically whenever the display turns off due to auto-off timeout. With auto-lock on, the device is always locked after the screen goes dark \u2014 no manual lock needed. Go back All settings are saved to flash and restored on next boot. Settings are organised into collapsible sections. Press Enter on a section header to expand or collapse it \u2014 all sections start collapsed for faster navigation. Press LEFT/RIGHT to change a value, or Enter for toggle items. Press Cancel/Back to save and return to the home screen. Melody 1 and Melody 2 are custom sequences editable in Tools \u203a Ringtone Editor. Lists all available home screen pages. For each entry: Settings and Messages are always visible and cannot be disabled. The repeater mode and its flood filters live on their own screen \u2014 see Tools \u203a Repeater. Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid QWERTY; the layout has always been alphabetical, so it is now named ABC. European Latin-diacritic letters (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic, etc.) aren't separate alphabet pages \u2014 instead, Hold Enter on a plain Latin letter that has accented variants ( Up to 10 quick reply templates (Q1\u2013Q10). Press Enter on a slot to open the keyboard editor. Supports the same placeholders as the main keyboard ( Go back The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, the remote bot, auto-advert, live location sharing, locator, compass, clock tools (alarm / timer / stopwatch), device diagnostics, repeater mode, and remote admin. Tools are grouped into collapsible Location / Comms / System sections \u2014 the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with UP/DOWN, press Enter on a section header to expand or collapse it, or on a tool to open it. Browse nodes that have recently advertised on the mesh. Filter (which nodes) and sort (in what order) are independent axes and combine freely. Filter by category with LEFT/RIGHT (one coherent axis \u2014 type only): Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. A node that is broadcasting its position via Live Share is marked with a \u2666 diamond beside its name in the list (the same marker the map uses), and its detail shows Hold Enter opens the same Options menu everywhere (list and detail), in a fixed order \u2014 only the actions that apply appear: Filtering stays on the list itself (LEFT/RIGHT cycles the type), so there is no separate Filter action in the menu. Sort is adjusted in place: highlight the Sort row and tap LEFT/RIGHT to flip the list (and its right-hand column) between distance and last-heard without closing the menu \u2014 the same in-popup pattern as Trail's settings. The row appears only while browsing stored nodes (live-scan rows carry signal, not distance). Filter and sort are independent and persist across re-entry to the screen. Selecting Ping opens the Ping popup: Use Enter on the popup\u2019s [!TIP] Combined with Auto-Advert on the other device, Nearby Nodes becomes a passive location tracker \u2014 as long as the tracked device periodically broadcasts its GPS position, you can see its current distance and bearing without any manual interaction on either end. Options \u2192 Discover scan sends a Because it is the same list, all the same keys apply \u2014 UP/DOWN to navigate, Enter for detail, Hold Enter for the Options menu (where Rescan repeats the scan and Ping works exactly as on stored nodes). Records your route in a RAM ring buffer (up to 512 points, sampled every 1 s). The track is simplified as it's recorded \u2014 a long straight stretch is kept as just its two endpoints while curves keep their detail (bounded to within the Min dist tolerance of the real path), so the buffer covers a far longer route than a flat point budget would suggest. Tracking runs in the background \u2014 a blinking G appears in the status bar. The trail survives display auto-off but is lost on reboot unless saved to flash first. [!TIP] The Map view is also reachable directly from the home carousel \u2014 the Map page shows a live mini-preview (your position, trail, and tracked contacts) with a north marker and a bottom-left scale tick. The status line below reads A GPS fix indicator also sits in the top status bar, alongside the trail/auto-advert/repeater icons \u2014 boxed (lit) once the receiver has a valid fix, a plain glyph while still searching. It only appears on boards with GPS hardware and while GPS is turned on in Settings; it's hidden the rest of the time rather than sitting there empty. Cycle views with LEFT / RIGHT: Hold Enter opens the action menu. It is two-level \u2014 a short main menu, plus Trail file\u2026 and Settings\u2026 submenus. Cancel/Back in a submenu returns to the main menu. Main menu: Trail file\u2026 (only the operations that apply right now appear): Settings\u2026 (values cycle with LEFT/RIGHT or Enter; shown only where they apply): (Trail file\u2026 appears only when a live or saved trail exists. Mark here needs a GPS fix; Waypoints is always available.) Auto-pause \u2014 when set, a recording trail automatically pauses after the device has stayed within ~15 m of one spot for the chosen delay: the elapsed timer and point sampling both freeze, and the map line breaks across the idle gap. It resumes on its own as soon as you move again. This keeps a stop (a break, a meal, parking) out of your distance and average-speed stats without you having to remember to stop and restart tracking. A paused trail is still \"on\" (the G marker keeps blinking) \u2014 the Summary Status row shows Auto-save \u2014 with this on (default off), the live trail is written to flash automatically when the device powers off, so a low-battery auto-shutdown no longer discards the whole route. It saves to the same Hold Enter \u2192 Track back retraces the trail you just recorded, back to where you started \u2014 useful for returning the same way in poor visibility or unfamiliar ground. It reuses the navigation view (distance + two absolute bearings; see Waypoints \u203a Navigating), but instead of a single fixed target it walks the recorded breadcrumbs in reverse: it snaps onto the route at the nearest recorded point, guides you to it, then automatically advances to the next earlier point as you reach each one (within ~20 m). The header shows how many points remain ( A waypoint is a saved spot \u2014 your car, camp, a water source \u2014 that you can navigate back to later. Waypoints are independent of the trail: they live in their own flash file ( Dropping a waypoint \u2014 Hold Enter \u2192 Mark here. This captures the current GPS fix and opens the on-screen keyboard for a short label (up to 11 characters \u2014 e.g. GPS averaging \u2014 with Settings \u2192 Mark avg set (5 / 10 / 30 s), Mark here doesn't snapshot a single fix; it samples the GPS once a second for that window and stores the mean position, for a steadier mark than one instantaneous reading (handy for a precise spot \u2014 a cache, a car, a trailhead). A short screen shows the time left and the sample count while it runs; Cancel aborts. When the window closes it opens the label keyboard as usual. With Mark avg = OFF (the default) marking is instant. Adding by coordinates \u2014 open Hold Enter \u2192 Waypoints and select the + Add by coords row (always the last entry in the list). This creates a waypoint without being there \u2014 no GPS fix required (handy for a meeting point or a spot read off a map). It opens a small form with three editable rows plus Save: On the map \u2014 saved waypoints show on the Trail Map view as a hollow diamond with the label's first two characters beside it (enough to tell nearby waypoints apart). Waypoints and your current GPS position are drawn continuously \u2014 even with no trail recording in progress \u2014 so the Map view doubles as a live \"you + your marks\" view, not just a recorded-track plot. With no trail, the view auto-fits to your waypoints and position. While a trail exists, the view frames the recorded route instead, and any waypoint that falls outside it is clamped to the nearest map edge \u2014 a distant mark can't blow up the scale and squash the trail. Navigating \u2014 Hold Enter \u2192 Waypoints opens the list (each row shows the label and live distance). The list always begins with a synthetic Trail start row whenever a trail exists, so you can backtrack to where you began without having marked it. Select a row and press Enter to open the navigation view: There is no magnetometer, so the screen shows two absolute bearings and you compare them: target at 145\u00b0, travelling at 90\u00b0 \u2192 bear right. The Hdg line is derived from GPS movement (see Compass) and reads Managing \u2014 Hold Enter on a waypoint row offers Rename / Delete / Send / Set as target (the Trail start row is navigate-only). Set as target pins the waypoint as the active Locator/Nav target in one step (see Locator). Delete removes one at a time; there is no bulk clear. Sharing \u2014 Send hands the waypoint to the Messages screen: pick a contact or channel, and the message is pre-filled as Easiest \u2014 Solo GPX Downloader (browser-based, no install): Script \u2014 Then on the device: Tools \u203a Trail \u2192 Hold Enter \u2192 Export (live) or Export (saved). Manual fallback \u2014 open a serial terminal at 115200 baud and capture the stream by hand: Saved waypoints are included in the export as GPX [!NOTE] If the companion app is connected via BLE, the export is safe \u2014 BLE and USB operate independently. If connected via USB, disconnect the app before exporting. Periodically broadcasts a 0-hop advert with your GPS position. Configurable interval: OFF / 30 s / 1 min / 2 min / 5 min / 10 min / 30 min / 1 h. A blinking A appears in the status bar while active. [!TIP] Audible connection heartbeat \u2014 the device chirps each time it receives an advert from any node (sound chosen in Settings \u203a Sound \u203a AD sound). With Auto-Advert running on both ends (e.g. two people on a hike), each hearing the other's periodic advert becomes a hands-free \"in range\" beep \u2014 no need to look at the screen. It fires for every received advert, so in a busy mesh it can get chatty; choose Share your live position over the mesh as ordinary chat messages, and put other people who do the same on your map. A position is sent as a This is independent of Auto-Advert and runs alongside it: Auto-Advert announces your presence as a 0-hop beacon for Nearby Nodes, while Live Share sends your position to a specific channel or contact you choose. The tool holds both directions of sharing in one flat list. Navigate with UP/DOWN, change a value with LEFT/RIGHT (or Enter); Cancel/Back saves and returns to Tools. How auto-share decides to send. With Auto share on, the device checks a few times a minute: it transmits when you've moved at least Move metres and at least Min gap has passed since the last send \u2014 so a stationary device stays silent unless a Heartbeat is set. It also sends once immediately when you enable sharing (or change the target), so the other end gets a fresh fix right away. Receiving. With Track loc on, incoming One-shot share. To send your position once without enabling auto-share, use Tools \u203a Trail \u2192 Hold Enter \u2192 Share my pos \u2014 it builds a A single geofence that beeps and shows an alert when you cross into or out of a radius. The target can be a saved waypoint (a fixed place \u2014 \"tell me when I'm back at camp\") or a live contact (a person sharing their position via Live Share \u2014 \"alert me when my friend gets near / falls behind\"). A waypoint target is a snapshot (coordinate + label copied), so it keeps working even if you later edit that waypoint; a contact target follows the person's latest shared position. Deleting the target's waypoint, or the target contact being removed from the contacts list, clears the Locator target back to Navigate with UP/DOWN, change a value with LEFT/RIGHT (or Enter); Cancel/Back saves and returns to Tools. Crossing alert. When armed with a target, the device watches its own GPS fix and fires the alert (a short melody plus an on-screen message) the moment you cross the radius, according to Mode. The wording adapts to the target \u2014 Following a person. Pick a favourite (or any contact with a known position) as the target and the geofence tracks the distance between you and them, so it works even while both of you move. The position is resolved with a fixed precedence: an active live Proximity beeper. With Beeper on, the device also ticks while you're inside the radius and shortens the gap between ticks the closer you get to the target \u2014 slow near the edge, rapid near the centre \u2014 like a homing beeper guiding you to the exact spot. It's silent outside the radius. Because the beeper is its own opt-in toggle, turning it on overrides the global buzzer mute (Settings \u203a Sound \u203a Buzzer) \u2014 it's an explicit \"I want to hear this\". Since homing only makes sense while you're approaching a target, the Beeper row appears only in Arrive or Both mode \u2014 it's hidden in Leave-only mode, and stays silent there even if it was switched on earlier. Otherwise it's independent of the crossing alert (which does follow the mute), so you can use either or both. Setting the target from anywhere. Besides this screen's picker, the same active target can be set in one step with Set as target from Nearby Nodes' or Waypoints' own Hold Enter menu \u2014 handy so you don't need a detour through Tools. Picking from this screen's picker saves on exit (so LEFT/RIGHT cycling stays cheap); the per-item shortcuts save immediately and confirm with a On the map. Whatever the active target is \u2014 person or waypoint \u2014 it's drawn as a flag marker on both the home Map preview and the full Trail Map, on top of any waypoint/contact it overlaps and folded into the frame so it never sits off-screen. This shows even when the Alert master switch is off, so a target you set purely to navigate to still appears. [!TIP] Mark the spot first with Tools \u203a Trail \u2192 Hold Enter \u2192 Mark here (or + Add by coords), then set it as the Locator target. A heads-up GPS compass. The L1 has no magnetometer, so the heading is the course over ground \u2014 derived from how your GPS position moves over the last few seconds. The display is a horizontal heading tape: a fixed travel-direction pointer sits at the centre and the N..E..S..W scale scrolls underneath it as you turn, so whatever is under the pointer is your current course. A large numeric readout below shows that course in degrees and cardinal (e.g. Because the heading comes from movement, it only updates while you are actually moving: standing still shows move to set heading (and navigation's Hdg line reads A step sequencer for composing custom notification melodies. Two slots \u2014 Melody 1 and Melody 2 \u2014 switchable from within the editor. Each melody supports up to 32 notes: Navigation in the editor: Options menu: Melodies can be assigned in Settings \u203a Sound (global default) or overridden per contact or channel from the Messages screen context menu. Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). Multiple trigger phrases can be packed into one Trigger field, comma-separated (e.g. The screen is a circular tab carousel, the same style as Tools \u203a Nearby Nodes' filter tabs: LEFT/RIGHT switches between the Channel / Room / Direct / Other tabs (opens on Channel), UP/DOWN moves between the rows within the active tab, and Enter acts on the selected row (LEFT/RIGHT is reserved entirely for tab-switching, so every row's value is changed via Enter, not by cycling it in place). Each target has its own Enable toggle on its own tab, and they're fully independent \u2014 you can run only a channel bot, only a room bot, only DM, or any combination, with no need to also switch on the others. Each target also has its own Commands toggle (see below) \u2014 DM, channel and room can each independently answer The DM, channel and room triggers are independent, so you can run e.g. an away-message ( The header shows a running count of auto-replies sent since boot, alongside the tab bar. Room posting requires a login. The room bot reuses whatever session the device already has with that room server (Messages \u203a Rooms \u203a Login\u2026, or a password saved from an earlier login/the phone app) \u2014 it has no way to prompt for a password itself in the background. If the saved password stops working, the room bot just silently stops posting there, the same as a manual post would; log back in from Messages to fix it. Throttle. DM auto-replies are rate-limited per contact (10 s), so a second sender is never starved while one contact is on cooldown. The channel and room bots each keep their own single 10 s cooldown and won't echo a message identical to their own reply (so two bots running the same reply text on one channel/room can't ping-pong); the cooldown caps any residual back-and-forth. Quiet hours suppress the push (trigger) replies between the configured local hours; a window where from is later than to wraps past midnight. Commands are a pull (explicitly requested), so they answer even during quiet hours. With a tab's Commands ON, a message beginning with Several commands can be combined in one message \u2014 Each target's Commands toggle is independent \u2014 e.g. answer A separate Actions toggle, nested under Commands (Commands must be ON for Actions to do anything) \u2014 these commands change the device's own behaviour, not just report on it, so they default OFF and are kept independent of the read-only Commands toggle: Actions combine with Commands and each other in one message the same way \u2014 On boards with user GPIO (see GPIO under System tools below), the same Actions gate also covers A circular tab carousel of live device and mesh stats, refreshed once a second (same tab idiom as Remote Bot / Nodes). LEFT/RIGHT switches tab; UP/DOWN scrolls within it on a small OLED \u2014 on a larger e-ink display a tab's rows all fit at once. Live tab rows: The packet counters, Forwarded, Errors and RXPS wd s/h are cumulative since boot. On the Live tab, Hold Enter opens a one-item Reset counters menu (Back dismisses it); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. Cancel/Back returns to the Tools list. The counters make the repeater behaviour observable: Forwarded confirms the node is actually relaying (not just configured to), and Pool free / Queue show whether forwarding is exhausting the packet pool. See Tools \u203a Repeater for the relaying options. Board-specific \u2014 currently Wio Tracker L1 only. Four otherwise-unused pins (GPIO1-GPIO4) are exposed for general-purpose use. Each pin gets its own row showing its current mode; Enter (or LEFT/RIGHT) cycles it through OFF \u2192 Input \u2192 Output and back to OFF \u2014 GPIO1 and GPIO2 additionally step through Analog between Output and OFF (GPIO3/GPIO4 have no ADC channel, so their cycle skips it). Switching a pin's mode shows a brief confirmation ( Once a pin is set to Output, a second State row appears right underneath it \u2014 Enter toggles it ON/OFF, with its own confirmation. The direction (Mode row) and the on/off state (State row) are deliberately separate: changing one never surprises you by also changing the other. The same 4 pins are reachable remotely via the Remote Bot's Turns the companion into a packet repeater while it keeps working as a normal companion \u2014 no separate firmware. By default, enabling it switches the radio to a dedicated repeater profile rather than relaying on whatever network you're chatting on (see Network below) \u2014 that matches the MeshCore community norm of repeaters sitting on a standard channel, not a private one. Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, and its flood-filter options together; live forwarding stats are on Tools \u203a Diagnostics. Navigate with UP/DOWN; change a value with LEFT/RIGHT (or Enter for toggles). Cancel/Back saves and returns to Tools. The five flood filters are opt-in (default OFF, so a plain repeater is unaffected) and act on flood traffic only \u2014 on a direct route this node is the named next hop, so it never drops those. Same network vs. separate network. With Network = Current (or a Custom profile set equal to your companion settings) the repeater stays on your own network \u2014 you keep messaging while relaying. With a different Custom profile the device moves entirely onto that network while relaying (a single radio can't be on two at once) and returns to your companion network when the repeater is switched off. The profile also re-applies after a reboot if the repeater was left on. While the repeater is on, a \u00bb indicator appears in the status bar (same blink convention as the auto-advert and trail markers) so you can tell it's relaying at a glance. Two radio settings are also overridden while relaying and restored afterwards: Settings \u203a Radio \u203a Pwr save is forced off (a repeater must listen continuously) and Auto pwr is forced off (a repeater holds full TX power for consistent relay reach). Both show Live forwarding stats \u2014 Forwarded, Pool free, Queue \u2014 are shown on Tools \u203a Diagnostics (this screen is config-only). Send commands to a repeater/room server you have admin permission on \u2014 the on-device equivalent of the companion app's repeater-admin feature. See CLI Commands for the full command grammar. (Admin only manages remote nodes; this device's own name, radio, TX power and reboot live in Settings \u2014 see below.) Enter on a row does one of four things, depending on the field: - Name / Owner info first fetch the node's current value, then open the keyboard pre-filled with it to edit \u2014 submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind. - Radio and Routing rows are typed, not free text: Repeat is an ON/OFF toggle; Advert interval / Flood advert interval / Max hops / TX power are number steppers (LEFT/RIGHT to adjust, within that field's valid range); Frequency uses the same digit-by-digit cursor editor as Settings' own Radio screen (LEFT/RIGHT moves between digits, UP/DOWN changes the selected one); Bandwidth / Spreading factor / Coding rate step through their valid discrete LoRa values with LEFT/RIGHT. All four Radio-tuple fields (Frequency/Bandwidth/SF/Coding rate) fetch and re-send the same underlying [!WARNING] This screen can run destructive commands on the remote node \u2014 Passwords are remembered across reboots, the same self-healing behaviour as room logins in Messages: after a successful admin login the password is saved on the device, so picking that node again \u2014 even after a power cycle \u2014 logs back in silently. If a saved password stops working (e.g. it was changed on the node), the failed login forgets it, so the next pick prompts for a new one. A correct password that just lacks admin permission is left alone \u2014 retyping the same one wouldn't change the outcome. Some commands are marked Serial Only in the CLI reference \u2014 those reject a remote CLI request and only work over that node's own USB serial connection. Admin doesn't manage the companion itself \u2014 its own settings live in Settings: Radio (preset / freq / SF / BW / CR) and TX power in the Radio section, and Name and Reboot in the System section. Send advert is the home ADVERT page. Welcome to the MeshCore documentation. Below are a few quick start guides. If you find a mistake in any of our documentation, or find something is missing, please feel free to open a pull request for us to review. This document provides an overview of CLI commands that can be sent to MeshCore Repeaters, Room Servers and Sensors. Usage: - Note: No reply is sent. Usage: - Note: No reply is sent. Usage: - Note: No reply is sent. Usage: - Usage: - Usage: - Parameters: - Usage: - Usage: - Usage: - Usage: - Serial Only: Yes Warning: This is destructive! Usage: - Note: The output of this command is limited to the 8 most recent adverts. Note: Each line is encoded as Usage: - Parameters: - Note: You can remove all neighbors by sending a space character as the prefix. The space indicates an empty prefix, which matches all existing neighbors. Usage: - Usage: Usage: - Serial Only: Yes Usage: Serial Only: Yes Usage: Serial Only: Yes Usage: Usage: Usage: Usage: Serial Only: Yes Usage: Usage: Usage: - Parameters: - Set by build flag: Default: Note: Requires reboot to apply Usage: - Parameters: - Set by build flag: Default: Varies by board Notes: This setting only controls the power level of the LoRa chip. Some nodes have an additional power amplifier stage which increases the total output. Refer to the node's manual for the correct setting to use. Setting a value too high may violate the laws in your country. Usage: - Parameters: - Note: This is not saved to preferences and will clear on reboot Usage: - Parameters: - Default: Note: Requires reboot to apply Serial Only: Usage: - Parameters: - Default: Temporary Note: If you upgraded from an older version to 1.14.1 without erasing flash, this setting is Usage: - Parameters: - Notes: - This controls the external LoRa FEM receive-path LNA where the board supports it. - This is separate from Usage: - Parameters: - Set by build flag: Default: Varies by board Note: Advertised names can use up to 23 bytes when location is included and 31 bytes otherwise. Emoji and Unicode characters may take more than one byte. Names that exceed the available advert space are truncated at a valid UTF-8 code point boundary. Usage: - Set by build flag: Default: Parameters: - Usage: - Set by build flag: Default: Parameters: - Usage: - Parameters: - Serial Only: - Note: Requires reboot to take effect after setting Usage: - Parameters: - Set by build flag: Default: Note: Command reply echoes the updated password for confirmation. Note: Any node using this password will be added to the admin ACL list. Usage: - Parameters: - Set by build flag: Default: Usage: - Parameters: - Default: Note: Note: Requires firmware 1.12+ Usage: - Parameters: - Default: Note: Returns \"Error: unsupported by this board\" if hardware doesn't support it Usage: Usage: Usage: Usage: - Parameters: - Default: Note: When enabled, device enters sleep mode between radio transmissions Usage: - Parameters: - Default: Usage: - Parameters: - Default: Note: the 'path.hash.mode' sets the low-level ID/hash encoding size used when the repeater adverts. This setting has no impact on what packet ID/hash size this repeater forwards, all sizes should be forwarded on firmware >= 1.14. This feature was added in firmware 1.14 Temporary Note: adverts with ID/hash sizes of 2 or 3 bytes may have limited flood propagation in your network while this feature is new as v1.13.0 firmware and older will drop packets with multibyte path ID/hashes as only 1-byte hashes are supported. Consider your install base of firmware >=1.14 has reached a criticality for effective network flooding before implementing higher ID/hash sizes. Usage: - Parameters: - Default: Note: When it is enabled, repeaters will now reject flood packets which look like they are in a loop. This has been happening recently in some meshes when there is just a single 'bad' repeater firmware out there (probably some forked or custom firmware). If the payload is messed with, then forwarded, the same packet ends up causing a packet storm, repeated up to the max 64 hops. This feature was added in firmware 1.14 Example: If preference is Usage: - Parameters: - Default: Note: When multiple nearby repeaters all hear the same flood packet, each waits a random amount of time before retransmitting to avoid simultaneous collisions. This factor scales the size of that random window. Higher values reduce collision risk at the cost of added latency. Usage: - Parameters: - Default: Note: Same collision-avoidance random window as Usage: - Parameters: - Default: Note: When enabled, repeaters that received a flood packet with a weak signal are held in a delay queue before processing, while those that received it with a strong signal process it immediately. This gives strong-signal paths forwarding priority. By the time weak-signal nodes process their copy, the packet may have already propagated and will be suppressed as a duplicate, reducing redundant retransmissions. Usage: - Parameters: - Default: Examples: - Note: Added in firmware v1.15.0 Deprecated as of firmware v1.15.0. Use Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Description: When enabled, the radio performs a hardware Channel Activity Detection scan before transmitting and defers if the channel is busy. Runs independently of Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Note: An alternative to Usage: - Parameters: - Default: Usage: - Parameters: - Note: Removes the entry when Usage: - Serial Only: Yes Usage: - Parameters: - Default: Usage: - Parameters: - Note: Note: Indentation creates parent-child relationships (max 8 levels) Note: Usage: - Usage: - Parameters: - Note: Setting on wildcard Usage: - Parameters: - Note: Setting on wildcard Usage: - Parameters: - Usage: - Parameters: - Usage: - Parameters: - Usage: - Parameters: - Usage: - Parameters (tokens): Space-separated. A logical cursor starts at the wildcard Behavior: Each created region defaults to flood-allowed (same as Existing regions: Limits: Repeater serial accepts one line up to 160 characters. For larger trees, split across multiple Example \u2014 linear chain (each token becomes a child of the previous): Example \u2014 branched tree (equivalent to Example \u2014 error and partial state: The reply is Example \u2014 flat list (each region a child of Usage: - Parameters: - Note: Must remove all child regions before the region can be removed Usage: - Serial Only: Yes Parameters: - Note: Requires firmware 1.12+ Usage: - Serial Only: For firmware older than 1.12.0 Example 1: Using F Flag with Named Public Region Explanation: - Creates a region named Example 2: Using Wildcard with F Flag Explanation: - Creates a wildcard region Example 3: Using Wildcard Without F Flag Explanation: - Creates a wildcard region Example 4: Nested Public Region with F Flag Explanation: - Creates Example 5: Wildcard with Nested Public Regions Explanation: - Creates wildcard region Usage: - Parameters: - Default: Note: Output format: - Usage: - Usage: - Usage: - Parameters: - Default: Usage: Parameters: - Note: Output format: Usage: - Parameters: - Usage: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Default: Usage: - Parameters: - Usage: - Parameters: - Default: Varies by board Usage: Usage: Usage: Note: Returns an error on boards without power management support. Usage: Note: Returns an error on boards without power management support. Usage: Note: Returns an error on boards without power management support. Ethernet support is available on RAK4631 boards with a RAK13800 (W5100S) Ethernet module. Use the Usage: - Output: - Notes: - Available on repeater and room server firmware only. Companion radio ethernet firmware does not expose a CLI. - The Ethernet interface obtains an IP address via DHCP automatically on boot. - A TCP server listens on port 23 (default) for CLI connections. - Connect with any TCP client (e.g. NOTE: This document is still in development. Some information may be inaccurate. This document provides a comprehensive guide for communicating with MeshCore devices over Bluetooth Low Energy (BLE). It is platform-agnostic and can be used for Android, iOS, Python, JavaScript, or any other platform that supports BLE. Please see the following repos for existing MeshCore Companion Protocol libraries. All secrets, hashes, and cryptographic values shown in this guide are example values only. MeshCore Companion devices expose a BLE service with the following UUIDs: Scan for Devices Connect to GATT Discover Services and Characteristics Enable Notifications Send Initial Commands Note: MeshCore devices may disconnect after periods of inactivity. Implement auto-reconnect logic with exponential backoff. When writing commands to the RX characteristic, specify the write type: Platform-specific: Recommendation: Use write with response for reliability. The default BLE MTU is 23 bytes (20 bytes payload). For larger commands like Critical: Commands must be sent in the correct sequence: After Connection: Command-Response Matching: For reliable operation, implement a command queue. Queue Structure: Error Handling: The MeshCore protocol uses a binary format with the following structure: Most packets follow this format: The first byte indicates the packet type (see Response Parsing). Purpose: Initialize communication with the device. Must be sent first after connection. Command Format: Example (hex): Response: Purpose: Query device information. Command Format: Example (hex): Response: Purpose: Retrieve information about a specific channel. Command Format: Example (get channel 1): Response: Purpose: Create or update a channel on the device. Command Format: Total Length: 50 bytes Channel Index: - Index 0: Reserved for public channels (no secret) - Indices 1-7: Available for private channels Channel Name: - UTF-8 encoded - Maximum 32 bytes - Padded with null bytes (0x00) if shorter Secret Field (16 bytes): - For private channels: 16-byte secret - For public channels: All zeros (0x00) Example (create channel \"YourChannelName\" at index 1 with secret): Note: The 32-byte secret variant is unsupported and returns Response: Purpose: Send a text message to a channel. Command Format: Timestamp: Unix timestamp in seconds (32-bit unsigned integer, little-endian) Example (send \"Hello\" to channel 1 at timestamp 1234567890): Response: Purpose: Send a binary datagram to a channel. Unlike channel text messages, datagrams carry no built-in sender identity and no timestamp \u2014 applications needing either must encode them inside the binary payload. Command Format: Example (flood, Data Type / Transport Mapping: - Limits: - Maximum payload length is Response: Inbound datagrams are delivered to the host via To register a new application, submit a PR adding a row to the table in docs/number_allocations.md. Internal sub-formats within an allocated application ID are owned by that application and are not tracked in MeshCore firmware or this document. Inbound group datagrams (radio-level Frame Format ( Path bytes are not forwarded: Only Path Length semantics differ between send and receive: In other words, the meaning of Note: The device may also emit Parsing Pseudocode: Purpose: Request the next queued message from the device. Command Format: Example (hex): Response: - Note: Poll this command periodically to retrieve queued messages. The device may also send Purpose: Query device battery voltage and storage usage. Command Format: Example (hex): Response: Messages are received via the TX characteristic (notifications). The device sends: Contact Messages: Notifications: Standard Format ( V3 Format ( Parsing Pseudocode: Standard Format ( V3 Format ( Parsing Pseudocode: Use the Important: - Messages are limited to 133 characters per MeshCore specification - Long messages should be split into chunks - Include a chunk indicator (e.g., \"[1/3] message text\") This document uses a spec-level naming convention ( Byte values are authoritative; names are aliases. When reading firmware source, PACKET_OK (0x00): PACKET_ERROR (0x01): PACKET_CHANNEL_INFO (0x12): Note: The device returns the 16-byte channel secret in this response. PACKET_DEVICE_INFO (0x0D): Parsing Pseudocode: PACKET_BATTERY (0x0C): Parsing Pseudocode: PACKET_SELF_INFO (0x05): Parsing Pseudocode: PACKET_MSG_SENT (0x06): PACKET_ACK (0x82): Note: Error codes may vary by firmware version. Always check byte 1 of BLE implementations enqueue and deliver one protocol frame per BLE write/notification at the firmware layer. Use command queue to prevent concurrent commands Asynchronous Messages: Validate frame length before decoding Response Matching: Match responses to commands by expected packet type: Timeout Handling: Consider longer timeout for channel operations Error Recovery: Store last connected device address for quick reconnection Secret Management: Never log or transmit secrets in plain text Message Handling: Implement message deduplication to avoid displaying the same message twice Channel Management: Error Handling: This document explains how to build and view the MeshCore documentation locally. A list of frequently-asked questions and answers for MeshCore A: MeshCore is a multi-platform system for enabling secure text-based communications utilizing LoRa radio hardware. It can be used for Off-Grid Communication, Emergency Response & Disaster Recovery, Outdoor Activities, Tactical Security including law enforcement and private security and also IoT sensor networks. (source) MeshCore is free and open source: Some more advanced, but optional features are available on T-Deck if you register your device for a key to unlock. On the MeshCore smartphone clients for Android and iOS/iPadOS, you can unlock the wait timer for repeater and room server remote management over RF feature. These features are completely optional and aren't needed for the core messaging experience. They're like super bonus features and to help the developers continue to work on these amazing features, they may charge a small fee for an unlock code to utilize the advanced features. Anyone is able to build anything they like on top of MeshCore without paying anything. A: Everything you need for MeshCore is available at: You need LoRa hardware devices to run MeshCore firmware as clients or server (repeater and room server). MeshCore is available on a variety of 433MHz, 868MHz and 915MHz LoRa devices. For example, Lilygo T-Deck, T-Pager, RAK Wireless WisBlock RAK4631 devices (e.g. 19003, 19007, 19026), Heltec V3, Xiao S3 WIO, Xiao C3, Heltec T114, Station G2, Nano G2 Ultra, Seeed Studio T1000-E. More devices are being added regularly. For an up-to-date list of supported devices, please go to https://flasher.meshcore.io To use MeshCore without using a phone as the client interface, you can run MeshCore on a LilyGo T-Deck, T-Deck Plus, T-Pager, T-Watch, or T-Display Pro. MeshCore Ultra firmware running on these devices is a complete off-grid secure communication solution. MeshCore has four firmware types that are not available on other LoRa systems. MeshCore has the following: Companion radios are for connecting to the Android app or web app as a messenger client. There are two different companion radio firmware versions: BLE Companion BLE Companion firmware runs on a supported LoRa device and connects to a smart device running the Android or iOS MeshCore client over BLE https://meshcore.io USB Serial Companion USB Serial Companion firmware runs on a supported LoRa device and connects to a smart device or a computer over USB Serial running the MeshCore web client https://app.meshcore.nz Repeaters are used to extend the range of a MeshCore network. Repeater firmware runs on the same devices that run client firmware. A repeater's job is to forward MeshCore packets to the destination device. It does not forward or retransmit every packet it receives, unlike other LoRa mesh systems. A repeater can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app. A room server is a simple BBS server for sharing posts. T-Deck devices running MeshCore firmware or a BLE Companion client connected to a smartphone running the MeshCore app can connect to a room server. Room servers store message history on them and push the stored messages to users. Room servers allow roaming users to come back later and retrieve message history. With channels, messages are either received when it's sent, or not received and missed if the channel user is out of range. Room servers are different and more like email servers where you can come back later and get your emails from your mail server. A room server can be remotely administered using a T-Deck running the MeshCore firmware with remote administration features unlocked, or from a BLE Companion client connected to a smartphone running the MeshCore app. When a client logs into a room server, the client will receive the previously 32 unseen messages. Although room server can also repeat with the command line command The recommendation is to run repeater and room server on separate devices for the best experience. A: If you have one supported device, flash the BLE Companion firmware and use your device as a client. You can connect to the device using the Android or iOS client via Bluetooth. You can start communicating with other MeshCore users near you. If you have two supported devices, and there are not many MeshCore users near you, flash both to BLE Companion firmware so you can use your devices to communicate with your nearby friends and family. If you have two supported devices, and there are other MeshCore users nearby, you can flash one of your devices with BLE Companion firmware and flash another supported device to repeater firmware. Place the repeater high above ground to extend your MeshCore network's reach. After you flashed the latest firmware onto your repeater device, keep the device connected to your computer via USB serial, use the console feature on the web flasher and set the frequency for your region or country, so your client can remote administer the repeater or room server over RF: The repeater and room server CLI reference is here: https://docs.meshcore.io/cli_commands If you have more supported devices, you can use your additional devices with the room server firmware. A: All radio firmware versions (e.g. for Heltec V3, RAK, T-1000E, etc.) are free and open source developed by Scott at Ripple Radios. The native Android and iOS client uses the freemium model and is developed by Liam Cottle, developer of meshtastic map at meshtastic.liamcottle.net on GitHub and reticulum-meshchat on GitHub. The T-Deck firmware is free to download and most features are available without cost. To support the firmware developer, you can pay for a registration key to unlock your T-Deck for deeper map zoom and remote server administration over RF using the T-Deck. You do not need to pay for the registration to use your T-Deck for direct messaging and connecting to repeaters and room servers. A: It supports the 868MHz range in the UK/EU and the 915MHz range in New Zealand, Australia, and the USA. Countries and regions in these two frequency ranges are also supported. Use the smartphone client or the repeater setup feature on the web flasher to set your radios' RF settings by choosing the preset for your regions. Recently, as of October 2025, many regions have moved to the \"narrow\" setting, aka using BW62.5 and a lower SF number (instead of the original SF11). For example, USA/Canada (Recommended) preset is 910.525MHz, SF7, BW62.5, CR5. After extensive testing, many regions have switched or about to switch over to BW62.5 and SF7, 8, or 9. Narrower bandwidth setting and lower SF setting allow MeshCore's radio signals to fit between interference in the ISM band, provide for a lower noise floor, better SNR, and faster transmissions. If you have consensus from your community in your region to update your region's preset recommendation, please post your update request on the #meshcore-app channel on the MeshCore Discord server to let Liam Cottle know. A: Advert means to advertise yourself on the network. In Reticulum terms it would be to announce. In Meshtastic terms it would be the node sending its node info. MeshCore allows you to manually broadcast your name, position and public encryption key, which is also signed to prevent spoofing. When you click the advert button, it broadcasts that data over LoRa. MeshCore calls that an Advert. There's two ways to advert, \"zero hop\" and \"flood\". MeshCore clients only advertise themselves when the user initiates it. A repeater sends a flood advert once every 12 hours by default. This interval can be configured using the following command: The separate A: Internally the firmware has maximum limit of 64 hops. In real world settings it will be difficult to get close to the limit due to the environments and timing as packets travel further and further. We want to hear how far your MeshCore conversations go. A: When MeshCore is flashed onto a LoRa device for the first time, it is necessary to set the server device's frequency to make it utilize the frequency that is legal in your country or region. Repeater or room server can be administered with one of the options below: Connect the server device using a USB cable to a computer running Chrome on https://flasher.meshcore.io, then use the Use a MeshCore smartphone client to remotely administer servers via LoRa. A T-Deck running unlocked/registered MeshCore firmware. Remote server administration is enabled through registering your T-Deck with Ripple Radios. It is one of the ways to support MeshCore development. You can register your T-Deck at: https://buymeacoffee.com/ripplebiz/e/249834 A: While not required, with location set for a repeater it will show up on the MeshCore map in the future. Set location with the following command: You can get the latitude and longitude from Google Maps by right-clicking the location you are at on the map. A: The default admin password to a repeater and room server is A: The default guest password to a room server is A: You can issue these commands to get or set a repeater's private key using a USB serial connection. Reboot the repeater after A: You can generate a new private key and specify the first byte of its public key here: https://gessaman.com/mc-keygen Having multiple repeaters with the same first byte ID does not negatively affect the mesh or its functionality. Flood and pathed packets will still reach their destinations. First byte ID collision makes traceroute and path analysis harder because these tools don't know exactly which of the two (or more) colliding repeaters is the one in the path. Best practice is when you set up a new repeater, choose a public key that is not in use. If it is not possible to find a unique first byte for your repeater's public key, choose one that is unique within about 10 miles (16 km) to minimize collision with nearby repeaters. A: This may be due to the SX1262 radio's auto gain control feature. You can use this command to periodically reset its AGC. The This is a very low-cost operation. AGC reset is done by simply setting A: The observer instruction is available here: https://analyzer.letsmesh.net/observer/onboard A: The original MeshCore protocol design uses the first byte of a repeater's public key to denote the repeater in a path. And with 1 byte for each repeater in the path, MeshCore packets can travel as many as 64 hops. However, with 1 byte, there are only 254 unique IDs (exclude 00 and FF which are reserved). Many meshes group have multiple repeaters with the same first byte in their public keys. Packets continue to pass through repeaters and the mesh is not harmed in any way. It does make it harder for tools to analyze paths with duplicated repeater IDs. Firmware version 1.14 and newer introduces the ability for repeaters to advert with 1-, 2-, or 3-byte adverts. Companions can also send out channel and direct messages with 1-, 2-, or 3-byte path. Adverts and messages sent in 1-byte path is compatible with repeater firmware older or newer than 1.14. They will travel up to 64 hops. 2-byte adverts and messages will travel up to 32 hops. 3-byte adverts and messages will travel up to 21 hops. Repeaters running firmware 1.14+ repeat packets sent with 1-, 2-, or 3-byte path hash. Repeaters on firmware older than 1.14 only repeat 1-byte path hash packets and silently drop 2- and 3-byte packets. The original packet sender determines the path hash size. The most common original sender is a companion app. The other common original sender is a repeater, when it broadcasts its advert. As of firmware version 1.14 and MeshCore app version 1.41.0, in the MeshCore app, you can set your companion's message path hash size in Until your regional mesh has the vast majority of the repeaters updated to 1.14+ firmware, it is recommended to keep your companion at the default 1-byte because pre-1.14 repeaters will silently drop messages with larger path hashes. This CLI command Usage: It is safe to set your 1.14+ repeaters to mode 1 or 2. A longer path hash helps tools like the LetsMesh.net Analyzer and MeshMapper disambiguate repeaters more reliably. With only 1 byte, the chance of different repeaters having the same first byte in their public key is high, making it harder to tell them apart in mesh network analysis. Since this only affects adverts, there's no downside. 2- and 3-byte adverts don't travel as far as 1-byte adverts, but it is not important for MeshCore nodes to hear a repeater's advert that is 21 or 32 hops away. You should move to send 2-byte or 3-byte channel and direct messages when the vast majority of the repeaters in your regional mesh are updated to firmware version 1.14 or newer. Setting your repeater's A: Yes, it is available on https://buymeacoffee.com/ripplebiz/ultra-v7-7-guide-meshcore-users A: A: For T-Deck Plus, the GPS baud rate should be set to 38400. Also, some T-Deck Plus devices were found to have the GPS module installed upside down, with the GPS antenna facing down instead of up. If your T-Deck Plus still doesn't get any satellite lock after setting the baud rate to 38400, you might need to open the device to check the GPS orientation. GPS on T-Deck is always enabled. You can skip the \"GPS clock sync\" and the T-Deck will continue to try to get a GPS lock. You can go to the Source A: The OG (non-Plus) T-Deck doesn't come with a GPS. If you added a GPS to your OG T-Deck, please refer to the manual of your GPS to see what baud rate it requires. Alternatively, you can try to set the baud rate from 9600, 19200, etc., and up to 115200 to see which one works. A: Users have had no issues using 16GB or 32GB SD cards. Format the SD card to FAT32. A: T-Deck uses the same key the smartphone apps use but in base64 There is no The smartphone app key is in hex: Source A: You need map tiles. You can get pre-downloaded map tiles here (a good way to support development): Another way to download map tiles is to use this Python script to get the tiles in the areas you want: https://github.com/fistulareffigy/MTD-Script There is also a modified script that adds additional error handling and parallel downloads: https://github.com/TheBestJohn/MTD-Script Once you have the tiles downloaded, copy the A: You can download, install, and use the T-Deck firmware for free, but it has some features (map zoom, server administration) that are enabled if you purchase an unlock code for \\$10 per T-Deck device. Unlock page: https://buymeacoffee.com/ripplebiz/e/249834 A: Space is tight on T-Deck's screen, so the information is a bit cryptic. The format is : See here for packet-type: https://github.com/meshcore-dev/MeshCore/blob/main/src/Packet.h#L19 Source A: You can customize the sounds on the T-Deck, by placing A: 'Import from Clipboard' is for importing a contact via a file named 'clipboard.txt' on the SD card. The opposite, is in the Identity screen, the 'Card to Clipboard' menu, which writes to 'clipboard.txt' so you can share yourself (call these 'biz cards', that start with \"meshcore://...\") A: To capture a screenshot on a T-Deck, long press the top-left corner of the screen. The screenshot is saved to the microSD card, if one is inserted into the device. A: BW is bandwidth - width of frequency spectrum that is used for transmission SF is spreading factor - how much should the communication spread in time CR is coding rate - from: https://www.thethingsnetwork.org/docs/lorawan/fec-and-code-rate TL;DR: default CR to 5 for good stable links. If it is not a solid link and is intermittent, change CR to 7 or 8. Forward Error Correction is a process of adding redundant bits to the data to be transmitted. During the transmission, data may get corrupted by interference (changes from 0 to 1 / 1 to 0). These error correction bits are used at the receivers for restoring corrupted bits. The Code Rate of a forward error correction expresses the proportion of bits in a data stream that actually carry useful information. There are 4 code rates used in LoRaWAN: 4/5 4/6 5/7 4/8 For example, if the code rate is 5/7, for every 5 bits of useful information, the coder generates a total of 7 bits of data, of which 2 bits are redundant. Making the bandwidth 2x wider (from BW125 to BW250) allows you to send 2x more bytes in the same time. Making the spreading factor 1 step lower (from SF10 to SF9) allows you to send 2x more bytes in the same time. Lowering the spreading factor makes it more difficult for the gateway to receive a transmission, as it will be more sensitive to noise. You could compare this to two people talking in a noisy place (a bar for example). If you\u2019re far from each other, you have to talk slow (SF10), but if you\u2019re close, you can talk faster (SF7) So, it's a balancing act between speed of the transmission and resistance to noise. The Things Network is mainly focused on LoRaWAN, but the LoRa low-level stuff still checks out for any LoRa project A: No, MeshCore clients do not repeat. This is the core of MeshCore's messaging-first design. This is to avoid devices flooding the airwaves and create endless collisions, so messages sent aren't received. In MeshCore, only repeaters and room servers with A: If you used to reach a node through a repeater and the repeater is no longer reachable, the client will send the message using the existing (but now broken) known path, the message will fail after 3 retries, and the app will reset the path and send the message as flood on the last retry by default. This can be turned off in settings. If the destination is reachable directly or through another repeater, the new path will be used going forward. Or you can set the path manually if you know a specific repeater to use to reach that destination. In the case if users are moving around frequently, and the paths are breaking, they just see the phone client retries and revert to flood to attempt to re-establish a path. Routes are stored in sender's contact list. When you send a message the first time, the message first gets to your destination by flood routing. When your destination node gets the message, it will send back a delivery report to the sender with all repeaters that the original message went through. This delivery report is flood-routed back to you the sender and is a basis for future direct path. When you send the next message, the path will get embedded into the packet and be evaluated by repeaters. If the hop and address of the repeater matches, it will retransmit the message, otherwise it will not retransmit, hence minimizing utilization. Source A: Yes, group channels are A to B, so there is no defined path. They have to flood. Repeaters can however deny flood traffic up to some hop limit, with the Source A: The smartphone app key is in hex: T-Deck uses the same key but in base64: The third character is the capital letter Source A: Most of the firmware is freely available. Everything is open source except the T-Deck firmware and Liam's native mobile apps. Firmware repo: https://github.com/meshcore-dev/MeshCore A: Provide your honest feedback on GitHub and on MeshCore Discord server. Spread the word of MeshCore to your friends and communities; help them get started with MeshCore. Support Scott's MeshCore development at https://buymeacoffee.com/ripplebiz. Support Liam Cottle's smartphone client development by unlocking the server administration wait gate with in-app purchase Support Rastislav Vysoky (recrof)'s flasher website and the map website development through PayPal or Revolut A: See instructions here: https://discord.com/channels/826570251612323860/1330643963501351004/1341826372120608769 Build instructions for MeshCore: For Windows, first install WSL and Python+pip via: https://plainenglish.io/blog/setting-up-python-on-windows-subsystem-for-linux-wsl-26510f1b2d80 (Linux, Windows+WSL) In the terminal/shell: Mac: python3 should be already installed. Then it should be the same for all platforms: open platformio.ini and in then you'll find A: Liam Cottle's MeshCore web client and MeshCore JavaScript library are open source under MIT license. Web client: https://github.com/liamcottle/meshcore-web Javascript: https://github.com/liamcottle/meshcore.js A: ATAK is not currently on MeshCore's roadmap. MeshCore would not be best suited to ATAK because MeshCore: MeshCore clients would need to reset path constantly and flood traffic across the network which could lead to lots of collisions with something as chatty as ATAK. This could change in the future if MeshCore develops a client firmware that repeats. Source A: To add a BLE Companion radio, connect to the BLE Companion radio from the MeshCore smartphone app. In the app, tap the To add a Repeater or Room Server to the map, go to the Contact List, tap the You can use the same companion (same public key) that you used to add your repeaters or room servers to remove them from the Internet Map. A: Yes. Below are the instructions to flash firmware onto a supported LoRa device using a Raspberry Pi over USB serial. Instructions for nRF devices like RAK, T1000-E, T114 are immediately after the ESP instructions For ESP-based devices (e.g. Heltec V3) you need: Instructions for nRF devices: For nRF devices (e.g. RAK, Heltec T114) you need the following: To manage a repeater or room server connected to a Pi over USB serial using shell commands, you need to install To start managing your USB serial-connected device using picocom, use the following command: From here, reference repeater and room server command line commands in the MeshCore docs here: A: Yes, there are many. MeshCore's protocol is open source using the MIT license. The MIT license and the open source protocol makes it very easy for the MeshCore community to build new firmware for radios, applications on mobile devices, map tools, and analysis tools, and integration with other projects like Home Assistant. As new MeshCore community projects become available on a weekly basis, we have stopped tracking them here in this FAQ. samuk maintains a very exhaustive list of MeshCore community project at https://github.com/samuk/awesome-meshcore/blob/main/README.md. samuk accepts PRs and merges them regularly. A: Yes, the same iOS and Android client is also available for Windows and Mac. You can find them together with the Android APK here: https://files.liamcottle.net/MeshCore Both the Windows and Mac versions of the client app are fully unlocked and are free to use. A: Here is a list of MeshCore comparison resources: A: You can get the epoch time on https://www.epochconverter.com and use it to set your T-Deck clock. For a repeater and room server, the admin can use a T-Deck to remotely set their clock (clock sync), or use the A: You can't connect to a device running repeater firmware via Bluetooth. You can connect to devices running the BLE companion firmware via Bluetooth using the Android app. A: Make sure that you flashed the Bluetooth companion firmware and not the USB-only companion firmware. A: The default Bluetooth pairing code is A: Heltec V3 has a very small coil antenna on its PCB for Wi-Fi and Bluetooth connectivity. It has a very short range, only a few feet. It is possible to remove the coil antenna and replace it with a 31mm wire. The BT range is much improved with the modification. A: Separately, starting in firmware version 1.7.0, there is a CLI Rescue mode. If your device has a user button (e.g. some RAK, T114), you can activate the rescue mode by holding down the user button of the device within 8 seconds of boot. Then you can use the 'Console' on https://flasher.meshcore.io A: If the usb port doesn't have the right ownership for this task, the process fails with the following error: Allow the browser user on it: A: The steps below work on both Android and iOS as nRF has made both apps' user interface the same on both platforms: A: You can flash this safer bootloader to the Wio Tracker L1 Pro https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX After this bootloader is flashed onto the device, you can trigger an over-the-air update using Bluetooth by holding the button next to the D-Pad and then clicking the reset button. Then follow the same OTA update instructions above. You can skip the A: For ESP32-based devices (e.g. Heltec V3): A: Yes, developer Refer to https://github.com/oltaco/Adafruit_nRF52_Bootloader_OTAFIX for the latest information. Currently, the following boards are supported: A: Yes, it is on the MeshCore GitHub repo here: https://github.com/meshcore-dev/MeshCore/tree/main/logo A: Channel: Contact: Where A: Wi-Fi firmware requires you to compile it yourself, as you need to set the Wi-Fi SSID and password. Edit WIFI_SSID and WIFI_PWD in A: For companion radios, you can set these radios' transmit power in the smartphone app. For repeater and room server radios, you can set their transmit power using the command line command \u26a0\ufe0f WARNING: Set these values at your own risk. Incorrect power settings can permanently damage your radio hardware. A: MeshCore supports Ethernet on RAK4631 boards using the RAK13800 WisBlock Ethernet module (based on the W5100S chip). Hardware required: - RAK4631 WisBlock Core - RAK19007 or RAK19018 WisBlock Base Board (with an available IO slot) - RAK13800 WisBlock Ethernet module - Ethernet cable connected to a network with a DHCP server Firmware: Flash one of the Ethernet-enabled firmware variants: - Connecting: - The device obtains an IP address via DHCP automatically on boot. - For repeaters and room servers, connect to the device on TCP port 23 using any TCP client (e.g. Standard KISS TNC firmware for MeshCore LoRa radios. Compatible with any KISS client (Direwolf, APRSdroid, YAAC, etc.) for sending and receiving raw packets. MeshCore-specific extensions (cryptography, radio configuration, telemetry) are available through the standard SetHardware (0x06) command. 115200 baud, 8N1, no flow control. Standard KISS framing per the KA9Q/K3MC specification. The type byte is split into two nibbles: Maximum unescaped frame size: 512 bytes. Data frames carry raw packet data only, with no metadata prepended. The Data command payload is limited to 255 bytes to match the MeshCore maximum transmission unit (MAX_TRANS_UNIT); frames larger than 255 bytes are silently dropped. The KISS specification recommends at least 1024 bytes for general-purpose TNCs; this modem is intended for MeshCore packets only, whose protocol MTU is 255 bytes. Only one packet may be pending for radio transmission at a time. If the host sends a second Data frame before the first has completed, the modem responds with Error (0xF1) and TxBusy (0x07). Outbound frames are encoded into a 2-slot queue and flushed when serial output space is available; The TNC implements p-persistent CSMA for half-duplex operation: In full-duplex mode, CSMA is bypassed and packets transmit after TXDELAY. MeshCore-specific functionality uses the standard KISS SetHardware command. The first byte of SetHardware data is a sub-command. Standard KISS clients ignore these frames. Response codes use the high-bit convention: The TNC sends these SetHardware frames without a preceding request: TxDone (0xF8): Sent after radio transmission completes. Contains a single byte: 0x01 for success, 0x00 for failure. Delivery to the host may be delayed under serial backpressure but is not dropped. RxMeta (0xF9): Sent after each standard data frame (type 0x00) with SNR (1 byte, signed, value x4) and RSSI (1 byte, signed, dBm). Queued with the data frame; omitted if the data frame cannot be queued. Enabled by default; toggle with SetSignalReport. Standard KISS clients ignore this frame. All values little-endian. All values little-endian. All values little-endian. The modem recalibrates the noise floor every 2 seconds with an AGC reset every 30 seconds. All values little-endian. All values little-endian. All values little-endian. Returns Sends an Use Data returned in CayenneLPP format. See CayenneLPP documentation for parsing. The nRF52 Power Management module provides battery protection features to prevent over-discharge, minimise likelihood of brownout and flash corruption conditions existing, and enable safe voltage-based recovery. Shutdown reason codes (stored in GPREGRET2): Notes: - \"Implemented\" reflects Phase 1 (boot lockout + shutdown reason capture). - User power-off on Heltec T114 does not enable LPCOMP wake. - VBUS detection is used to skip boot lockout on external power, and VBUS wake is configured alongside LPCOMP when supported hardware exposes VBUS to the nRF52. The power management functionality is integrated into the A static constructor with priority 101 in This ensures we capture the true reset reason before any initialisation code runs. To enable power management on a board variant: Enable in platformio.ini: Define configuration in variant.h: Implement in board .cpp file: ```cpp #ifdef NRF52_POWER_MANAGEMENT const PowerMgtConfig power_config = { .lpcomp_ain_channel = PWRMGT_LPCOMP_AIN, .lpcomp_refsel = PWRMGT_LPCOMP_REFSEL, .voltage_bootlock = PWRMGT_VOLTAGE_BOOTLOCK }; void MyBoard::initiateShutdown(uint8_t reason) { // Board-specific shutdown preparation (e.g., disable peripherals) bool enable_lpcomp = (reason == SHUTDOWN_REASON_LOW_VOLTAGE || reason == SHUTDOWN_REASON_BOOT_PROTECT); } #endif void MyBoard::begin() { NRF52Board::begin(); // or NRF52BoardDCDC::begin() // ... board setup ... #ifdef NRF52_POWER_MANAGEMENT checkBootVoltage(&power_config); #endif } ``` For user-initiated shutdowns, The LPCOMP (Low Power Comparator) is configured to: - Monitor the specified AIN channel (0-7 corresponding to P0.02-P0.05, P0.28-P0.31) - Compare against VDD fraction reference (REFSEL: 0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) - Detect UP events (voltage rising above threshold) - Use 50mV hysteresis for noise immunity - Wake the device from SYSTEMOFF when triggered VBUS wake is enabled via the POWER peripheral USBDETECTED event whenever LPCOMP Reference Selection (PWRMGT_LPCOMP_REFSEL): Important: For boards with a voltage divider on the battery sense pin, LPCOMP measures the divided voltage. Use: The power management code checks whether SoftDevice is enabled and uses the appropriate API: - When SD enabled: This ensures compatibility regardless of BLE stack state. Power management status can be queried via the CLI: On boards without power management enabled, all commands except When This document lists unique numbers/identifiers used in various MeshCore protocol payloads. The To make sure multiple applications can function without interfering with each other, the table below is for reserving various ranges of data-type values. Just modify this table, adding a row, then submit a PR to have it authorised/merged. NOTE: the range FF00 - FFFF is for use while you're developing, doing POC, and for these you don't need to request to use/allocate. Once you have a working app/project, you need to be able to demonstrate it exists/works, and THEN request type IDs. So, just use the testing/dev range while developing, then request IDs before you transition to publishing your project. (add rows, inside the range 0100 - FEFF for custom apps) This document describes the MeshCore packet format. This is the protocol level packet structure used in MeshCore firmware v1.12.0 NOTE: see the Payloads documentation for more information about the content of specific payload types. Bit 0 means the lowest bit (1s place) Hash size codes: Examples: Inside each MeshCore Packet is a payload, identified by the payload type in the packet header. The types of payloads are: This document defines the structure of each of these payload types. NOTE: all 16 and 32-bit integer fields are Little Endian. This kind of payload notifies receivers that a node exists, and gives information about the node Appdata Appdata Flags An acknowledgement that a message was received. Note that for returned path messages, an acknowledgement can be sent in the \"extra\" payload (see Returned Path) instead of as a separate acknowledgement packet. CLI commands do not cause acknowledgement responses, neither discrete nor extra. Returned path, request, response, and plain text messages are all formatted in the same way. See the subsection for more details about the ciphertext's associated plaintext representation. Returned path messages provide a description of the route a packet took from the original author. Receivers will send returned path messages to the author of the original message. For the common chat/server helpers in Gets information about the node, possibly including the following: Not defined in Not defined in Not defined in Not defined in Not defined in Not defined in Response contents are opaque application data. There is no single generic response envelope beyond the encrypted payload wrapper shown above. txt_type The plaintext contained in the ciphertext matches the format described in plain text message. Specifically, it consists of a four byte timestamp, a flags byte, and the message. The flags byte will generally be The sender name is unverified message text. Group messages contain no sender signature, so any channel-key holder can choose any sender name. The data contained in the ciphertext uses the format below: Custom packets have no defined format. This document provides an overview of QR Code formats that can be used for sharing MeshCore channels and contacts. The formats described below are supported by the MeshCore mobile app. Example URL: Parameters: Example URL: Parameters: Binary frame structures for companion radio stats commands. All multi-byte integers use little-endian byte order. The The Total Frame Size: 11 bytes Total Frame Size: 14 bytes Total Frame Size: 26 bytes (legacy) or 30 bytes (includes Below are the commands you can enter into the Terminal Chat clients: Set the LoRa frequency. Example: set freq 915.8 Sets LoRa transmit power in dBm. Sets your advertisement name. Sets your advertisement map latitude. (decimal degrees) Sets your advertisement map longitude. (decimal degrees) Sets the transmit duty cycle limit (1-100%). Example: Sets the transmit air-time-factor. Deprecated \u2014 use Set the device clock using UNIX epoch seconds. Example: time 1738242833 Sends an advertisement packet Displays current time per device's clock. Shows the device version and firmware build date. Displays your 'business card', for others to manually import Imports the given card to your contacts. List all contacts by most recent. (optional {n}, is the last n by advertisement date) Shows the name of current recipient contact. (for subsequent 'send' commands) Sets the recipient to the first matching contact (in 'list') by the name prefix. (ie. you don't have to type whole name) Sends the text message (as DM) to current recipient. Resets the path to current recipient, for new path discovery. Sends the text message to the built-in 'public' group channel Branch: Odchylenia od propozycji (stan faktyczny): - Akcji Filter\u2026 w menu nie ma \u2014 duplikowa\u0142a cykl Ekran \u0142\u0105czy w sobie trzy osobne tryby o w\u0142asnych listach, widokach szczeg\u00f3\u0142\u00f3w i popupach: Stan ekranu trzyma 15+ p\u00f3l ( Ale to nie jest jedna o\u015b \u2014 to trzy wci\u015bni\u0119te w jeden liniowy cykl: Konsekwencje: - u\u017cytkownik \u201escrolluje po kategoriach\u201d na \u015blepo \u2014 nie widzi wszystkich naraz, musi cyklowa\u0107, by trafi\u0107 w to, czego szuka; - kombinacje s\u0105 niemo\u017cliwe: nie da si\u0119 zobaczy\u0107 \u201eulubionych repeater\u00f3w posortowanych po dystansie\" ani \u201erepeater\u00f3w posortowanych po czasie\u201d \u2014 model wymusza dok\u0142adnie jeden stan z siedmiu; - Ten sam w\u0119ze\u0142 oferuje inny zestaw akcji w zale\u017cno\u015bci od tego, gdzie na niego patrzysz. Ping jest osi\u0105galny tylko ze szczeg\u00f3\u0142\u00f3w, Discover tylko z listy. Brak sp\u00f3jnego modelu \u201eprzytrzymaj = menu akcji\u201d. W przeciwie\u0144stwie do reszty popup\u00f3w, ping-menu: - ma wiersze tylko-do-odczytu (RTT / SNR), wi\u0119c po\u0142yka To dzia\u0142a, ale jest to czwarty, niestandardowy wzorzec interakcji w jednym narz\u0119dziu. Trzy zasady przewodnie: (a) jedno menu akcji wsz\u0119dzie, (b) filtr i sortowanie jako osobne, jawne osie, (c) jeden wzorzec listy. Zamiast jednego cyklu 7-stanowego \u2014 dwie niezale\u017cne osie wybierane z menu (nie przez \u015blepe cyklowanie): Filtr (o\u015b \u201eco pokazujemy\") i sort (o\u015b \u201ew jakiej kolejno\u015bci\") s\u0105 od siebie niezale\u017cne i \u0142\u0105cz\u0105 si\u0119 dowolnie: Sterowanie (zatwierdzone): - Dzi\u0119ki temu \u201eulubione repeatery po dystansie\" staje si\u0119 mo\u017cliwe, a w nag\u0142\u00f3wku wida\u0107 oba wymiary, np. Jeden To samo menu na li\u015bcie i w szczeg\u00f3\u0142ach, w obu \u017ar\u00f3d\u0142ach (Zapisane / Skan). Pozycje niedost\u0119pne s\u0105 pomijane (jak ju\u017c teraz robi Zatwierdzone: zamiast osobnego pod-ekranu Discover \u2014 jeden komponent listy/szczeg\u00f3\u0142\u00f3w/menu/ping nap\u0119dzany prze\u0142\u0105cznikiem \u017ar\u00f3d\u0142a: To nie jest dos\u0142owne zlanie dw\u00f3ch list w jedn\u0105 tablic\u0119 (dane maj\u0105 r\u00f3\u017cny kszta\u0142t \u2014 kontakt ma GPS, wynik skanu ma sygna\u0142), tylko jedna \u015bcie\u017cka interakcji nad dwoma \u017ar\u00f3d\u0142ami. Wyb\u00f3r \u017ar\u00f3d\u0142a = \u201eDiscover scan\" w menu (uruchamia Kolejno\u015b\u0107 implementacji: 1 \u2192 2 \u2192 3 \u2192 4. Etapy 1\u20132 daj\u0105 najwi\u0119ksz\u0105 popraw\u0119 \u201euporz\u0105dkowania\" przy najmniejszym ryzyku; 3\u20134 domykaj\u0105 sp\u00f3jno\u015b\u0107 (jedna lista, jedno menu wsz\u0119dzie). Go back This is a developer guide to the reusable building blocks behind the Everything below lives under Single-TU only. These fragments compile only as part of Every screen implements That's the whole contract. Steps 1, 3 and 4 are compiler-checked (a mismatch won't link); only a forgotten step 2 can slip through \u2014 every screen pointer is nullptr-initialised in The constructor takes Drawing helpers (all clip/measure for you): Standalone scroll indicators ( All follow the same shape: a Two layouts share every grid: ABC (one key per letter) and T9 (phone-keypad multi-tap \u2014 repeated Enter within Geo ( Coordinates are int32 degrees \u00d7 1e6 everywhere (GPS, contacts, trail, prefs). The message tags are State stores: Message reply prefix: Small glyphs are authored as ASCII art and packed at compile time ( Draw with Icons are drawn from a fixed priority-ordered table ( Screens with a Hold-Enter context menu (Nodes, Bot, Admin, Diagnostics, \u2026) pass Keys arrive as the Use the prev/next convention for value changes so the rotary encoder and the D-pad agree: Each physical button is a Two mechanisms keep input responsive when Device settings live in one The The shared \"active target\" (Locator/Nav destination) is set through Then: Branch: Renderowanie mapy: Problemy: - D\u0142ugi scroll \u2014 na OLED wida\u0107 ~4 wiersze naraz, wi\u0119c do \u201eReset trail\" trzeba przewin\u0105\u0107 przez ca\u0142\u0105 list\u0119; - Dwa wzorce interakcji w jednym menu \u2014 cz\u0119\u015b\u0107 pozycji reaguje na LEFT/RIGHT (ustawienia), cz\u0119\u015b\u0107 na Enter (akcje). Enter na wierszu ustawie\u0144 nic sensownego nie robi \u2014 tylko zamyka i otwiera menu od nowa ( Uwagi: - Komentarz przy kroku 4 ( G\u00f3rne menu kr\u00f3tkie (akcje), ustawienia i operacje na pliku w podmenu: Korzy\u015bci: - g\u00f3rne menu to ~5 pozycji, bez scrolla na OLED; - jeden wzorzec na poziom: g\u00f3rny i \u201eTrail file\" = Enter-akcje; \u201eSettings\" = warto\u015bci cyklowane LEFT/RIGHT \u2014 bez mieszania w jednym widoku; - destrukcyjny Wariant minimalny (mniej kodu): zosta\u0107 przy jednej li\u015bcie, ale pogrupowa\u0107 (ustawienia \u2192 akcje \u2192 plik), Wydzieli\u0107 ma\u0142y Czysto refaktoryzacyjne \u2014 bez zmiany wygl\u0105du mapy. Wynik wizualnie identyczny, logika kr\u00f3tsza i \u0142atwiejsza do utrzymania. Etap 1 = najwi\u0119ksza poprawa \u201euporz\u0105dkowania popupu\" (g\u0142\u00f3wna pro\u015bba). Etapy 2\u20133 = czyszczenie logiki mapy/siatki bez zmiany wygl\u0105du. Joystick-only UX constraints: 4 directions + Enter + Back. No text entry except inside KeyboardWidget. Everything else navigable with cursor + press. Status legend: \ud83d\udccb planned \u00b7 \ud83d\udea7 in progress \u00b7 \u2705 done \u00b7 \u274c rejected/deferred Hold Enter on the MESSAGE mode-select screen (DM / Channels / Rooms) opens a 1-item context menu \"Mark all read\". Acts on the currently highlighted mode and shows a brief confirmation alert. Implementation: - New Phase 1 \u2705 (storage + read-only render + grid nav) Phase 2 \u2705 (pin from Contact options menu in QuickMsg + slot picker submenu) - Enter on a filled tile opens that contact's DM directly - Cancel from a Favourites-opened DM returns to the home screen Phase 3 \u2705 (in-place pin picker on empty tile: upstream-favourited contacts first, then recent DM contacts deduped; selecting a contact that's already pinned elsewhere moves it to the new slot) Follow-up done: OLED unread-badge overlap fixed \u2014 badge and name share the same baseline, drawTextEllipsized's max width subtracts badge width + a 3 px gap so names shorten to \"Nam\u2026\" before the digit. A 2\u00d73 grid (six slots) of pinned contacts on its own home page, between Clock and Messages. Joystick picks a tile, Enter opens the existing DM conversation or sends a pre-set quick reply. Data model: - New field in NodePrefs: Pinning UX: - In QuickMsg DM list, long-press on a contact \u2192 context menu \u2192 \"Pin to dial\" \u2192 asks which of the 6 slots - Unpin via the same menu (only shown when contact is already pinned) Schema bump: add Render layout (250\u00d7122 landscape e-ink): Joystick navigation is natural with 6 tiles (UP/DOWN between rows, LEFT/RIGHT within row). Phase 1 \u2705 (storage + sampling + Summary view + G indicator in status bar) Phase 2 \u2705 (auto-fit Map view with cos(lat) aspect compensation; LEFT/RIGHT cycles views) - Summary scrolls on short panels (OLED) so hint stops overlapping - Status-bar G blinks at the same cadence as A (forces 1 s home refresh) - Default sampling 30 s + 5 m min-delta (was 60 s + 25 m \u2014 too sparse on foot) - \"Avg speed\" replaces \"Speed\"; Time uses RTC so it ticks every render - Stop \u2192 start creates a new segment; the map doesn't bridge dead time, and total distance skips segment boundaries Phase 3 \u2705 (per-point list view with HH:MM local time + delta-from-previous; segment-start rows show \"start\" instead of a delta) Phase 4 \u2705 (Hold-Enter popup grows Save / Load / Reset / Export GPX / Export saved entries. Single flash slot at /trail (binary header with magic+version+count+accumulated_ms then raw TrailPoint records). GPX 1.1 dump goes over USB Serial; \"Export GPX\" streams the live RAM ring, \"Export saved\" streams the flash file straight to USB without touching the live ring. Segments respect SEG_START boundaries. Alert reflects BLE-app-collision state) Phase 5 \u2705 (Settings + actions consolidated into a single Hold-Enter popup \u2014 Min dist + Units cycled with LEFT/RIGHT (popup stays open), plus Start/Stop tracking and Reset action items. Short Enter never toggles \u2014 both start and stop go through the popup, so a stray tap can never change tracking state. View counter (N/3) lives in the title bar; the bottom hint row is gone, content fills the freed space. Sampling cadence fixed at 1 s, GPS upd setting also removed; both rely on the sensor manager's defaults) Polish \u2705 (map view: filled/open dot markers around segment breaks; \"Waiting for GPS fix\" status when started without a lock; capacity bumped to 512 points; elapsed/avg-speed run on millis() instead of RTC so they tick even before GPS time is synced) Tools \u203a Breadcrumb. Periodically samples Logging is a runtime state, not a settings value. User starts/stops from the Tools \u203a Breadcrumb screen. Once active, sampling continues in the background regardless of which screen is shown, and a Settings only control sampling cadence and the min-distance gate \u2014 they don't enable/disable the feature. Storage model \u2014 RAM ring with explicit save Rationale: auto-off only blanks the display, the firmware keeps running, so the RAM trail survives every idle scenario. Typical use is a single trip start\u2192stop while wearing the device; persisting across reboots is rarely wanted. RAM-only avoids ~1400 flash writes/day and the LittleFS wear that comes with continuous logging. Snapshot slots on flash (user-initiated only): - UI screens (LEFT/RIGHT cycles): 1. Summary \u2014 total distance (km), elapsed time (h:mm), point count, current speed (from last 2 samples), GPS fix indicator 2. Trail map \u2014 ASCII bounding-box plot. Auto-fit the polygon, current position marked Joystick actions: - Enter \u2192 toggle live logging on/off (status bar shows Statistics computed on the fly walking the ring: - Total distance: sum of Haversine(p[i], p[i-1]) - Elapsed time: ts[last] - ts[first] - Current speed: dist(last, prev) / (ts[last] - ts[prev]) Settings: - Settings \u203a GPS \u203a Breadcrumb interval: 30 s / 1 min / 5 min / 15 min (default 1 min) \u2014 only the cadence; logging on/off is a Tools toggle - Settings \u203a GPS \u203a Breadcrumb min delta: 5 m / 25 m / 100 m (skip near-stationary samples to keep the ring densely populated with real movement) - Export format: GPX (standard for GPS tracks; OSMAnd / Garmin compatible) Schema impact: new prefs fields Edge cases: - No GPS fix: skip sampling, status indicator dims - Low-batt shutdown: optional auto-save to slot 0 (one write) before powerdown - Memory: 3 KB RAM is negligible on this MCU; if RAM ever tightens, drop to 128 entries \u2705 Shipped (branch Shared helpers extracted: The original design spec is kept below as a record. Turns Solo from a comms device into a basic GPS navigator. Mark the current position with a short label, then later get bearing + distance back to it \u2014 ideal for off-grid use (car, camp, trailhead, water source). Storage \u2014 dedicated flash file Marking \u2014 from the GPS or Trail screen: Hold Enter \u2192 \"Mark here\". - Requires a GPS fix; otherwise alert \"No GPS fix\". - Opens the existing Visible on the trail Map \u2014 waypoints render on the existing Map view as a distinct marker (e.g. a hollow diamond or a small flag) so they show in context with the recorded track: - Fold waypoint coords into the map's bounding box so off-track waypoints stay in frame. Today Trail workflow integration \u2014 waypoints live inside the Trail screen, not a separate Tools entry, because marking points of interest happens while you are recording: - Mark: Trail \u2192 Hold Enter \u2192 \"Mark here\" (new action-menu row). Opens the keyboard for the label, saves the current fix. Works whether or not tracking is active \u2014 a waypoint is independent of trail recording state. - Manage / navigate: Trail \u2192 Hold Enter \u2192 \"Waypoints\" \u2192 a PopupMenu list of saved waypoints (label + distance). Selecting one: - Enter \u2192 fullscreen nav (see below). - Hold Enter \u2192 Rename / Delete (later: Share over mesh). - Waypoints persist across trail Reset / reboot (separate Navigation view \u2014 two bearings, no compass needed The L1 has no magnetometer, so we can't show \"you are facing X\". Instead the nav view shows two absolute bearings and lets the user do the comparison \u2014 robust against GPS jitter, no relative-turn maths: Reading it: target is at 145\u00b0, I'm travelling at 90\u00b0 \u2192 I need to bear right. When standing still (course undefined) the Hdg line shows Course over ground (COG) \u2014 a small This COG value can later back a standalone \"heading\" trail view if wanted, but the two-bearing nav view already covers the practical need. Shared nav view \u2014 also navigate to a node \u2b50 The nav view's target is just Node target is the contact's COG source must be decoupled from trail recording. Deriving the heading from The COG ring is time-sampled with guards, not raw displacement-gated: push every GPS fix at the normal poll cadence (~1 s) into a short rolling window (a few seconds of fixes), and compute the heading as the bearing across the window (oldest\u2192newest), optionally low-pass smoothed. Time-based sampling gives a steadier, averaged course than bearing between just two points, and tracks slow movement without lagging. Two guards keep it honest: - Gross-error rejection \u2014 drop a fix before it enters the window if it implies an impossible jump (implied speed over a sane cap, e.g. > ~50 m/s between consecutive fixes), or if the provider exposes a usable validity/HDOP signal. One bad fix shouldn't swing the heading. - Minimum displacement over the window \u2014 only emit a heading once the total movement across the window exceeds a small threshold (a few m). Below that the user is effectively stationary: hold the last good heading, and show Threshold(s) can be fixed constants to start; expose in Settings later if it proves worth tuning. Open question: whether the nav view should also be reachable as a 4th Map overlay state (cycle a \"highlighted\" waypoint with LEFT/RIGHT on the Map) or stay list-driven only. Start list-driven; add map cycling later if wanted. Shipped: the Waypoints list always begins with a synthetic Trail start row whenever a trail exists, opening the shared nav view to the first recorded point. No new storage. (Navigates to the start point, not progressive nearest-point following \u2014 adequate for \"get me back\".) Superseded by the shared nav view described under Waypoints above: navigate to a node = open that nav view with the contact's last-advert Today the trail Map is north-up. Optionally orient it to the current course (COG, same source as the compass tape) so the travel direction is up \u2014 a Trail action-menu toggle Orientation: North-up / Heading-up. Lightweight approach: rotate the already-fitted map around its centre by Caveats that make it more than a one-line toggle: - COG-only heading (no magnetometer) \u2014 undefined while stationary, so hold the last good course or fall back to north-up; it can't be heading-up when you're standing still. - Jitter \u2014 rotating the whole map by raw COG makes it shake; needs heading smoothing / hysteresis (only re-rotate past ~10\u201315\u00b0 of change). - Fit \u2014 rotated content overflows the rectangle. Refitting to the rotated bbox makes the scale \"breathe\" as you turn; alternative is to accept minor edge clipping. - Grid \u2014 the axis-aligned scale grid becomes diagonal; drop it in heading-up mode or rewrite it. - e-ink \u2014 a rotating map ghosts badly and refreshes slowly; this is really an OLED feature, flag it as degraded on e-ink. A fuller position-centred navigator (you fixed at screen centre, fixed/preset zoom, pan) is a larger separate feature; start with the rotate-the-fit version if pursued. On branch \u2705 Done \u2014 hardware duty-cycle RX (\"Pwr save\") - Uses the SX126x's own RX duty-cycle ( History: an earlier attempt used a software CAD state machine (scan \u2192 warm-sleep window \u2192 on-detect full RX, with \u2705 Done \u2014 Adaptive Power Control (\"Auto pwr\") - \u23f8 To return to (not done) - Current measurement \u2014 never taken (no PPK2/meter to hand). Reliability is confirmed in use; the actual mA win is still unquantified. Do this first. - APC sample targeting \u2014 a direct ACK's SNR is the last hop of the return path (sound on symmetric/direct links); a flood echo is the first hop from us to a repeater, which is exactly what our TX power controls. Both feed one shared controller; per-source weighting or direct-only (0-hop) gating could be explored. Original analysis (kept for context): Goal: multiply battery life without losing functionality. The dominant draw on this node is the radio in continuous RX (always-on Inspired by ZephCore (Zephyr MeshCore port, https://github.com/liquidraver/ZephCore), whose battery edge comes from radio/peripheral power technique, not the kernel: Explicitly out of scope: GPS power gating. ZephCore powers the GNSS only during a fix; this device is used as a live navigator + trail recorder, so GPS stays continuously powered. Don't gate it. Cross-check: as of the v1.16 upstream merge, MeshCore now ships native NRF52 companion power-saving (PR #1238, docs/nrf52_power_management.md). The companion loop now sleeps via Sequencing: hardware duty-cycle RX and APC are both done and in field test on On-device repeater for the Solo companion, scoped to the SX1262 boards (Wio Tracker L1 OLED/e-ink, GAT562 30S). All on Configurable in Settings \u203a System \u203a SOS: - Target: channel index or DM contact - Message template (uses placeholders) Trigger: Hold Back + Hold Enter for 3 s on any screen \u2192 confirmation popup (\"Send SOS?\") \u2192 Enter to send. Sends with The practical need is covered by ping rather than a dedicated screen: in Nearby Nodes, a node's detail view \u2192 Hold Enter \u2192 Ping sends a direct mesh ping and shows RTT + SNR (own and remote), repeatable on demand. Available from both the stored-node detail and the active-discovery detail. The original idea below (a Tools \u203a Range Test screen with continuous 5 s pinging and a 30-sample sparkline) was not built \u2014 kept as a possible future enhancement on top of the existing ping. Tools \u203a Range Test: - Pick a node from contacts/nearby - Enter starts pinging every 5 s, logs RTT + RSSI + SNR (ring ~30) - Display shows current values + 30-sample sparkline (block characters) - Enter stops; Hold Enter for context menu (reset, change target) Settings \u203a Sound \u203a Quiet Hours: - Enable on/off - Start HH (LEFT/RIGHT to change, 24 h) - End HH When within window: buzzer set to \"off\" (overrides setting), display brightness \u2192 0. Restores prefs values when window ends. Time source: rtc_clock. Toggleable in Settings \u203a Home Pages. Lists channels with: name, unread count, last message age. Enter opens the channel. Sort by recency by default; LEFT/RIGHT toggles to alphabetical. QuickMsg DM list: a 4-th sort mode (currently sorted by message count). LEFT/RIGHT on the list header cycles: name | message-count | recency | distance. Distance uses GPS pos from contact's last advert. Tools \u203a Stats: - Battery voltage 60-min sparkline - RSSI of last 30 received packets - Noise floor current - Free heap (if available) Read-only. UP/DOWN switches between metrics. Bottom shows current value as text. Realised as a command bot rather than a trigger/reply table: with Commands ON, a DM is scanned for Shipped: the lock screen shows a total unread badge ( Settings \u203a Profile: Indoor / Outdoor / Expedition. Each pre-fills: - Auto-off seconds - GPS interval - Auto-advert interval - Brightness Single Enter applies. Stored as Settings \u203a System \u203a Batt Calibration: edit 5 voltage breakpoints used to convert mV \u2192 %. UP/DOWN selects breakpoint, LEFT/RIGHT changes voltage in 50 mV steps. Helps users with non-standard LiPos report accurate %. Tools \u203a Display Test: full-screen grid + bars + Lemon glyph dump. Useful for verifying driver/font changes after flashing. From channel view, Hold Enter \u2192 \"Who's online?\". Sends 0-hop discovery to channel members, collects responses for 10 s, shows a list with RSSI. Similar to existing Nearby active discovery but scoped to a channel. Captured for later triage. None designed in detail yet (DM delivery status has since shipped \u2014 see below). \u2705 Shipped (in Original spec: Show whether an outgoing direct message reached the recipient, using the ACK that MeshCore already produces (no protocol change). Per-message status glyph at the end of each outgoing DM row in the history: - Data model (RAM only, no schema bump \u2014 DM history already lives in RAM): - Wiring: - On send: store Edge cases: - Sends with no path / Auto-send Buzzer + alert when within X m of the active nav target (waypoint / node / backtrack). Closes the loop on the navigator \u2014 you no longer have to stare at the distance readout. Radius configurable (e.g. 20/50/100 m). Tools entry showing current lat/lon (optionally MGRS/UTM grid ref), fix quality (sats / HDOP if the provider exposes it), altitude, and a one-press share to a channel/DM. Complements the nav suite with an at-a-glance position readout. Render the device's own contact (or a channel) as a QR on the display so a phone can import it without the companion app. The QR payload format already exists ( Computed from GPS position + RTC date \u2014 pure math, no extra hardware. A Clock dashboard field or a small Tools readout. Useful for planning outdoor activity. A received command (from a paired contact, or a dedicated channel keyword) makes the node play a locator tone for a few seconds. Helps find a dropped/misplaced device. Gate behind a setting to avoid abuse. Stop counting elapsed/avg-speed (and optionally skip sampling) when stationary, so \"moving time\" and average speed reflect actual travel. Reuses the COG ring's min-displacement gate to detect standing still. Quick toggle for an inverted / minimum-brightness scheme for night use, separate from the brightness levels. On e-ink flag as degraded (inversion ghosts). Simple Tools utility \u2014 count-up stopwatch and a count-down timer with a buzzer at zero. Joystick: Enter start/stop, Hold Enter reset. If the GPS provider exposes altitude, add total ascent + current elevation to the trail Summary and Cute but niche. Skip unless explicitly requested. Marginal real-world gain (2 ms between notes during melody playback only), high risk on the PWM peripheral. Wio Tracker L1 doesn't have a haptic motor. N/A. After #3, re-prioritise the backlog with the user. Pass through wio-unified after commit Both group-channel receive paths now check the result of Unknown-secret packets no longer pollute the offline queue, UI history or trigger the bot with a bogus Defensive Fixed (local override): Fixed: the save loop now skips unused slots (all-zero secret) instead of writing every slot up to When the companion app reads the last message from the offline queue, all on-device badges disappear. Previously discussed and a fix was reverted as \"intended sync behaviour\" \u2014 keep as known limitation; document or restrict to \"Favourites Dial badges only\". The reset is now gated on Re-checked: The 31-byte name slot in Left as-is: the framework always renders before forwarding input, so the fragile invariant doesn't fire in practice. Worth a refactor only if the call order ever changes. Pub-key line is skipped entirely when Point (0, 0) is a legitimate location (Gulf of Guinea). Corner case but a logic error. Left for a future pass with a proper GPS-validity bool. Scan detail view ( A Re-checked: Two fallback \"?\" sender names now use a plain Re-checked: Fix status after this pass: Go back Two optional hardware add-ons, both auto-detected and both entirely optional \u2014 a build with them enabled runs exactly the same with nothing plugged in. Two of the newer boards also ship with their own built-in keypad instead \u2014 see Built-in keyboards. Plug it into the second I2C bus (the Grove connector on the Wio Tracker L1; see Wiring for the Heltec boards). The firmware probes for it once at boot \u2014 nothing to enable in Settings. The same bus is scanned for environment sensors, so a CardKB and a sensor can share it. Printable characters insert straight at the cursor, bypassing the on-screen grid completely. The alphabet and T9/ABC settings do not apply \u2014 a real keyboard sends the right character already, so typing is always plain Latin ASCII regardless of what Settings \u203a Keyboard is set to. Fn+Esc rather than the adjacent Fn+Backspace on purpose: Fn and Backspace sit next to each other on CardKB's layout and would be far too easy to hit by accident. See Screen Lock for the physical button equivalent. Settings \u203a Keyboard \u203a Ext. KB picks how the on-screen keyboard behaves while a CardKB is doing the typing. Compact is designed to need no joystick at all, which makes it the right choice on a board where CardKB is the only input device \u2014 for example a Heltec V3/V4 with no joystick soldered on. In that case set it once and forget it. Cursor mode and the accent / placeholder popups draw their own visible feedback, so they behave identically in both modes. Four direction contacts plus a fifth \"press\" contact. Each contact simply shorts its pin to ground \u2014 the firmware enables the internal pull-ups, so no external resistors are needed. Neither board ships with a joystick or a keyboard header, so both are soldered to free GPIOs. V3 and V4 are pin-compatible per Heltec's documentation and the solo builds use the same assignment for both \u2014 confirmed working on real V4 hardware; still worth checking against your own V3 module before soldering. Everything above lives in the Experimental \u2014 newly-added board support, not the CardKB/joystick add-ons above. Both keypads are TCA8418-based and share one polling path, entirely independent of the CardKB code \u2014 a board can have either, or neither. Neither keypad follows CardKB's exact Fn-shortcut table (Fn+Enter, Fn+letter accent popups, Tab, Fn+Esc lock) \u2014 see each board's own Go back A full-screen clock page on the home screen. Shows the current time and date, with up to three configurable data fields below. Time is synchronized from GPS or via the companion app. Timezone offset is applied from Settings \u203a System. If no time source is available, the screen shows \"! No time sync\" with a hint to enable GPS or connect the app. Up to three data fields are shown below the date separator. Each field displays a label and a value on the same line. Sensor fields show Hold Enter (or press the Context menu key) on the Clock page to open the Dashboard Config screen, where each of the three field slots can be cycled with LEFT/RIGHT. Press Enter (short press) on the Clock page to open Clock Tools, a small menu with three time utilities. Cancel backs out one level (tool \u2192 menu \u2192 home). The same menu also has an entry under Tools \u203a System, so it's reachable without the Clock page. A wake alarm with an optional repeat. Rows: Hour, Minute, Repeat and Armed. Enter on Hour or Minute opens the digit editor (LEFT/RIGHT moves between the tens/units, UP/DOWN changes the digit); Enter on Repeat cycles OFF \u2192 Daily \u2192 Weekdays \u2192 Weekends \u2192 OFF; Enter on Armed toggles ON/OFF. The configured time is shown next to the Alarm menu row when armed, and the setting persists across reboots. While an alarm is armed a bell icon signals it in two places: the top-left corner of the Clock page itself, and the top status bar of the other home pages (the status bar is hidden on the Clock page, which is why the clock face carries its own indicator). The bell is icon-only \u2014 the exact alarm time is on the Alarm row inside Clock Tools. The alarm is scheduled as an absolute fire instant, so it is robust to clock re-syncs \u2014 the mesh (every inbound packet), the companion app, GPS and the CLI can all jump the device clock at any moment. A correction that moves the clock a little still fires at the right wall-clock time; a jump that skips over the alarm time still fires (late). With Repeat set to OFF (the default) the alarm disarms itself after firing once, same as before; with a repeat pattern set, it stays armed and re-schedules itself for the next matching day instead. The alarm only fires while the device is awake (it keeps running with the display off or locked). It cannot wake the device from a full Shutdown (the CPU and RAM are powered down), and needs a valid time source \u2014 it stays pending until the clock is synced. A large HH:MM:SS readout with one digit underlined. LEFT/RIGHT moves the cursor one digit at a time, Up/Down changes the digit under it (minute/second tens cap at 5, hours at 23), and Enter starts the countdown. While running it shows H:MM:SS \u2014 Enter stops it, Cancel returns to the menu and leaves it counting. When it reaches zero the device rings, even if you have navigated to another screen. Enter starts/stops; Up/Down resets when stopped; Cancel returns to the menu and leaves it running. When the alarm or timer fires the device plays a melody (overriding mute) and shows an alert. Any key silences it; otherwise it stops on its own after a minute. E-ink note: the live timer/stopwatch readouts would thrash a slow e-paper panel if redrawn every second, so on e-ink they refresh only coarsely (and immediately on any key press). The underlying timing is exact regardless, and the countdown's buzzer always fires on time. Go back A dedicated home page showing a grid of up to 6 pinned contacts for quick access. The layout adapts to the display orientation: Navigate tiles with UP / DOWN / LEFT / RIGHT. Pressing a directional key at the edge of the grid switches to the adjacent home page instead of wrapping. Enter on a filled tile \u2014 opens that contact's DM directly. Enter on an empty tile ( Filled tiles show an unread message count in the top-right corner when there are unread DMs from that contact. The contact name is ellipsized to make room for the badge. If a pinned contact is removed from the contacts list \u2014 explicitly, or auto-evicted to make room when the table is full \u2014 its slot is freed automatically and goes back to an empty From the Favourites Dial \u2014 press Enter on an empty tile ( Select a contact to pin it to that slot. From a DM conversation \u2014 Hold Enter \u203a context menu \u203a Pin to dial, then choose a slot from the slot picker (Slot 1\u20136, showing the current occupant name or \"empty\"). If the selected contact is already pinned in another slot, it is moved to the new slot automatically. Open the contact's DM, Hold Enter \u203a context menu \u203a Unpin (slot N). The position of the Favourites Dial in the home page navigation sequence can be changed in Settings \u203a Home Pages \u2014 press LEFT / RIGHT on the Favourites entry to move it earlier or later. Go back The Messages screen is split into three modes \u2014 DMs, Channels, and Rooms \u2014 selectable with UP/DOWN on the mode-select screen. Each mode shows the corresponding list of conversations with unread counters. Press Enter on a contact or channel to open its history, then press Enter again (or select the [+ send] button, anchored at the right edge of the history) to compose a message. Choose between: While typing, UP from the top letter row enters cursor mode (LEFT/RIGHT move the insertion point; UP/DOWN jump to start/end, then continue on to the special row / letter grid if pressed again once already there; Enter/Cancel exit immediately from anywhere) so you can edit or insert in the middle of what you've typed instead of only at the end. Hold Enter on a Latin letter with accented variants (e.g. a, e, c, n, o, s, z\u2026) instead opens a one-row popup of that letter's accents \u2014 LEFT/RIGHT to pick, Enter to insert, Cancel to dismiss. See the on-screen keyboard section of the UI framework guide for the full key set (Shift, T9 multi-tap, Cyrillic/Greek). The keyboard supports placeholders that insert live data at send time: Sensor placeholders appear automatically in the placeholder picker when the corresponding sensor is active. Posting to a room server requires a login handshake first, so the device can log in on its own \u2014 no phone app needed. The first time you press Enter on a room, a password prompt opens automatically; type the room's password and press the \u2713 key (leave it empty and submit for open / no-password rooms). Once the login succeeds the room's chat opens automatically \u2014 no second Enter needed (as long as you're still on that room in the list). The on-screen keyboard's default (Latin) page is ASCII only. Typing accented or non-Latin characters \u2014 Polish, Czech, Slovak, German, French, Spanish, Portuguese or Nordic diacritics, Cyrillic, or Greek \u2014 needs Settings \u203a Keyboard \u203a Alphabet set to the matching language first; the keyboard's #@/abc key then cycles Latin \u2192 that alphabet \u2192 Symbols \u2192 Latin. A password containing characters outside whatever's currently enabled can still be set from the phone app \u2014 the device stores and replays it byte-for-byte. Messages are drawn as chat bubbles sized to fit their content, anchored right for your own outgoing messages and left for incoming ones (like a typical messenger), with the sender name and a compact age indicator ( Short Enter on a message opens it in fullscreen. Hold Enter \u2014 on a history row or in fullscreen \u2014 opens the same options menu: Reply, plus Navigate / Save waypoint when the message contains a location (see Fullscreen message view). You don't need to open the message first. Navigate between messages with LEFT (newer) and RIGHT (older). Long messages scroll with UP/DOWN. If the message is a reply addressed to someone ( Hold Enter in fullscreen opens the options menu. It always offers Reply for an incoming message, and when the message contains a location it adds two more: A location is any Hold Enter on a contact entry opens a context menu: When Pin to dial is selected, a slot picker opens (Slot 1\u20136 showing current occupant name or \"empty\"). Choosing a slot that already holds another contact moves the new contact there. In the Rooms list the context menu instead offers: Hold Enter on a channel entry opens a context menu: Joining a new community channel, or creating one to share with others, no longer needs the phone app. The Channels list ends with a \"+ Add channel\" row \u2014 press Enter on it to pick a channel type, or use Edit from the context menu above to change an existing channel's name or secret (Edit skips the type picker and opens the Name/Secret form directly). + Add channel first asks which type of channel to create \u2014 the same three types the phone app offers: Select [Save] to commit. The secret can't be redisplayed once saved (only the derived key is kept) \u2014 editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password. Hold Enter on the DM / Channels / Rooms mode-select screen to clear all unread counters for the highlighted category at once. Go back Screen lock prevents accidental keypresses. While locked the display turns off and all input is ignored. Hold Back and press Enter three times within 3 seconds. The sequence works in both directions \u2014 the same combination locks and unlocks. On boards with an optional CardKB (I2C keyboard) attached, a single Fn+Esc does the same thing, in either direction \u2014 no repetition needed, since Fn+Esc is already a deliberate two-key combo. Esc rather than the adjacent Backspace, since Fn and Backspace sit right next to each other on CardKB's layout and would be too easy to hit by accident. If the display is off when the sequence begins, it turns on automatically so the hint is visible. Each press in the physical sequence extends the display-on timer by 5 seconds. The hint popup at the bottom of the lock screen guides through the physical sequence: If no press is made for 3 seconds, the counter resets. A brief press of any button wakes the display and shows the lock screen. It displays: The display turns off again automatically after 5 seconds of inactivity (or 2 seconds immediately after locking). Enable Auto-lock in Settings \u203a Display to lock the device automatically whenever the display turns off due to auto-off timeout. With auto-lock on, the device is always locked after the screen goes dark \u2014 no manual lock needed. Go back All settings are saved to flash and restored on next boot. Settings are organised into collapsible sections. Press Enter on a section header to expand or collapse it \u2014 all sections start collapsed for faster navigation. Press LEFT/RIGHT to change a value, or Enter for toggle items. Press Cancel/Back to save and return to the home screen. Melody 1 and Melody 2 are custom sequences editable in Tools \u203a Ringtone Editor. Lists all available home screen pages. For each entry: Settings and Messages are always visible and cannot be disabled. The repeater mode and its flood filters live on their own screen \u2014 see Tools \u203a Repeater. Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid QWERTY; the layout has always been alphabetical, so it is now named ABC. European Latin-diacritic letters (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic, etc.) aren't separate alphabet pages \u2014 instead, Hold Enter on a plain Latin letter that has accented variants ( Up to 10 quick reply templates (Q1\u2013Q10). Press Enter on a slot to open the keyboard editor. Supports the same placeholders as the main keyboard ( Go back The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, the remote bot, auto-advert, live location sharing, locator, compass, clock tools (alarm / timer / stopwatch), device diagnostics, repeater mode, and remote admin. Tools are grouped into collapsible Location / Comms / System sections \u2014 the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with UP/DOWN, press Enter on a section header to expand or collapse it, or on a tool to open it. Browse nodes that have recently advertised on the mesh. Filter (which nodes) and sort (in what order) are independent axes and combine freely. Filter by category with LEFT/RIGHT (one coherent axis \u2014 type only): Select a node to see its coordinates, distance, bearing with cardinal direction, type, and last-heard time. A node that is broadcasting its position via Live Share is marked with a \u2666 diamond beside its name in the list (the same marker the map uses), and its detail shows Hold Enter opens the same Options menu everywhere (list and detail), in a fixed order \u2014 only the actions that apply appear: Filtering stays on the list itself (LEFT/RIGHT cycles the type), so there is no separate Filter action in the menu. Sort is adjusted in place: highlight the Sort row and tap LEFT/RIGHT to flip the list (and its right-hand column) between distance and last-heard without closing the menu \u2014 the same in-popup pattern as Trail's settings. The row appears only while browsing stored nodes (live-scan rows carry signal, not distance). Filter and sort are independent and persist across re-entry to the screen. Selecting Ping opens the Ping popup: Use Enter on the popup\u2019s [!TIP] Combined with Auto-Advert on the other device, Nearby Nodes becomes a passive location tracker \u2014 as long as the tracked device periodically broadcasts its GPS position, you can see its current distance and bearing without any manual interaction on either end. Options \u2192 Discover scan sends a Because it is the same list, all the same keys apply \u2014 UP/DOWN to navigate, Enter for detail, Hold Enter for the Options menu (where Rescan repeats the scan and Ping works exactly as on stored nodes). Records your route in a RAM ring buffer (up to 512 points, sampled every 1 s). The track is simplified as it's recorded \u2014 a long straight stretch is kept as just its two endpoints while curves keep their detail (bounded to within the Min dist tolerance of the real path), so the buffer covers a far longer route than a flat point budget would suggest. Tracking runs in the background \u2014 a blinking G appears in the status bar. The trail survives display auto-off but is lost on reboot unless saved to flash first. [!TIP] The Map view is also reachable directly from the home carousel \u2014 the Map page shows a live mini-preview (your position, trail, and tracked contacts) with a north marker and a bottom-left scale tick. The status line below reads A GPS fix indicator also sits in the top status bar, alongside the trail/auto-advert/repeater icons \u2014 boxed (lit) once the receiver has a valid fix, a plain glyph while still searching. It only appears on boards with GPS hardware and while GPS is turned on in Settings; it's hidden the rest of the time rather than sitting there empty. Cycle views with LEFT / RIGHT: Hold Enter opens the action menu. It is two-level \u2014 a short main menu, plus Trail file\u2026 and Settings\u2026 submenus. Cancel/Back in a submenu returns to the main menu. Main menu: Trail file\u2026 (only the operations that apply right now appear): Settings\u2026 (values cycle with LEFT/RIGHT or Enter; shown only where they apply): (Trail file\u2026 appears only when a live or saved trail exists. Mark here needs a GPS fix; Waypoints is always available.) Auto-pause \u2014 when set, a recording trail automatically pauses after the device has stayed within ~15 m of one spot for the chosen delay: the elapsed timer and point sampling both freeze, and the map line breaks across the idle gap. It resumes on its own as soon as you move again. This keeps a stop (a break, a meal, parking) out of your distance and average-speed stats without you having to remember to stop and restart tracking. A paused trail is still \"on\" (the G marker keeps blinking) \u2014 the Summary Status row shows Auto-save \u2014 with this on (default off), the live trail is written to flash automatically when the device powers off, so a low-battery auto-shutdown no longer discards the whole route. It saves to the same Hold Enter \u2192 Track back retraces the trail you just recorded, back to where you started \u2014 useful for returning the same way in poor visibility or unfamiliar ground. It reuses the navigation view (distance + two absolute bearings; see Waypoints \u203a Navigating), but instead of a single fixed target it walks the recorded breadcrumbs in reverse: it snaps onto the route at the nearest recorded point, guides you to it, then automatically advances to the next earlier point as you reach each one (within ~20 m). The header shows how many points remain ( A waypoint is a saved spot \u2014 your car, camp, a water source \u2014 that you can navigate back to later. Waypoints are independent of the trail: they live in their own flash file ( Dropping a waypoint \u2014 Hold Enter \u2192 Mark here. This captures the current GPS fix and opens the on-screen keyboard for a short label (up to 11 characters \u2014 e.g. GPS averaging \u2014 with Settings \u2192 Mark avg set (5 / 10 / 30 s), Mark here doesn't snapshot a single fix; it samples the GPS once a second for that window and stores the mean position, for a steadier mark than one instantaneous reading (handy for a precise spot \u2014 a cache, a car, a trailhead). A short screen shows the time left and the sample count while it runs; Cancel aborts. When the window closes it opens the label keyboard as usual. With Mark avg = OFF (the default) marking is instant. Adding by coordinates \u2014 open Hold Enter \u2192 Waypoints and select the + Add by coords row (always the last entry in the list). This creates a waypoint without being there \u2014 no GPS fix required (handy for a meeting point or a spot read off a map). It opens a small form with three editable rows plus Save: On the map \u2014 saved waypoints show on the Trail Map view as a hollow diamond with the label's first two characters beside it (enough to tell nearby waypoints apart). Waypoints and your current GPS position are drawn continuously \u2014 even with no trail recording in progress \u2014 so the Map view doubles as a live \"you + your marks\" view, not just a recorded-track plot. With no trail, the view auto-fits to your waypoints and position. While a trail exists, the view frames the recorded route instead, and any waypoint that falls outside it is clamped to the nearest map edge \u2014 a distant mark can't blow up the scale and squash the trail. Navigating \u2014 Hold Enter \u2192 Waypoints opens the list (each row shows the label and live distance). The list always begins with a synthetic Trail start row whenever a trail exists, so you can backtrack to where you began without having marked it. Select a row and press Enter to open the navigation view: There is no magnetometer, so the screen shows two absolute bearings and you compare them: target at 145\u00b0, travelling at 90\u00b0 \u2192 bear right. The Hdg line is derived from GPS movement (see Compass) and reads Managing \u2014 Hold Enter on a waypoint row offers Rename / Delete / Send / Set as target (the Trail start row is navigate-only). Set as target pins the waypoint as the active Locator/Nav target in one step (see Locator). Delete removes one at a time; there is no bulk clear. Sharing \u2014 Send hands the waypoint to the Messages screen: pick a contact or channel, and the message is pre-filled as Easiest \u2014 Solo GPX Downloader (browser-based, no install): Script \u2014 Then on the device: Tools \u203a Trail \u2192 Hold Enter \u2192 Export (live) or Export (saved). Manual fallback \u2014 open a serial terminal at 115200 baud and capture the stream by hand: Saved waypoints are included in the export as GPX [!NOTE] If the companion app is connected via BLE, the export is safe \u2014 BLE and USB operate independently. If connected via USB, disconnect the app before exporting. Periodically broadcasts a 0-hop advert with your GPS position. Configurable interval: OFF / 30 s / 1 min / 2 min / 5 min / 10 min / 30 min / 1 h. A blinking A appears in the status bar while active. [!TIP] Audible connection heartbeat \u2014 the device chirps each time it receives an advert from any node (sound chosen in Settings \u203a Sound \u203a AD sound). With Auto-Advert running on both ends (e.g. two people on a hike), each hearing the other's periodic advert becomes a hands-free \"in range\" beep \u2014 no need to look at the screen. It fires for every received advert, so in a busy mesh it can get chatty; choose Share your live position over the mesh as ordinary chat messages, and put other people who do the same on your map. A position is sent as a This is independent of Auto-Advert and runs alongside it: Auto-Advert announces your presence as a 0-hop beacon for Nearby Nodes, while Live Share sends your position to a specific channel or contact you choose. The tool holds both directions of sharing in one flat list. Navigate with UP/DOWN, change a value with LEFT/RIGHT (or Enter); Cancel/Back saves and returns to Tools. How auto-share decides to send. With Auto share on, the device checks a few times a minute: it transmits when you've moved at least Move metres and at least Min gap has passed since the last send \u2014 so a stationary device stays silent unless a Heartbeat is set. It also sends once immediately when you enable sharing (or change the target), so the other end gets a fresh fix right away. Receiving. With Track loc on, incoming One-shot share. To send your position once without enabling auto-share, use Tools \u203a Trail \u2192 Hold Enter \u2192 Share my pos \u2014 it builds a A single geofence that beeps and shows an alert when you cross into or out of a radius. The target can be a saved waypoint (a fixed place \u2014 \"tell me when I'm back at camp\") or a live contact (a person sharing their position via Live Share \u2014 \"alert me when my friend gets near / falls behind\"). A waypoint target is a snapshot (coordinate + label copied), so it keeps working even if you later edit that waypoint; a contact target follows the person's latest shared position. Deleting the target's waypoint, or the target contact being removed from the contacts list, clears the Locator target back to Navigate with UP/DOWN, change a value with LEFT/RIGHT (or Enter); Cancel/Back saves and returns to Tools. Crossing alert. When armed with a target, the device watches its own GPS fix and fires the alert (a short melody plus an on-screen message) the moment you cross the radius, according to Mode. The wording adapts to the target \u2014 Following a person. Pick a favourite (or any contact with a known position) as the target and the geofence tracks the distance between you and them, so it works even while both of you move. The position is resolved with a fixed precedence: an active live Proximity beeper. With Beeper on, the device also ticks while you're inside the radius and shortens the gap between ticks the closer you get to the target \u2014 slow near the edge, rapid near the centre \u2014 like a homing beeper guiding you to the exact spot. It's silent outside the radius. Because the beeper is its own opt-in toggle, turning it on overrides the global buzzer mute (Settings \u203a Sound \u203a Buzzer) \u2014 it's an explicit \"I want to hear this\". Since homing only makes sense while you're approaching a target, the Beeper row appears only in Arrive or Both mode \u2014 it's hidden in Leave-only mode, and stays silent there even if it was switched on earlier. Otherwise it's independent of the crossing alert (which does follow the mute), so you can use either or both. Setting the target from anywhere. Besides this screen's picker, the same active target can be set in one step with Set as target from Nearby Nodes' or Waypoints' own Hold Enter menu \u2014 handy so you don't need a detour through Tools. Picking from this screen's picker saves on exit (so LEFT/RIGHT cycling stays cheap); the per-item shortcuts save immediately and confirm with a On the map. Whatever the active target is \u2014 person or waypoint \u2014 it's drawn as a flag marker on both the home Map preview and the full Trail Map, on top of any waypoint/contact it overlaps and folded into the frame so it never sits off-screen. This shows even when the Alert master switch is off, so a target you set purely to navigate to still appears. [!TIP] Mark the spot first with Tools \u203a Trail \u2192 Hold Enter \u2192 Mark here (or + Add by coords), then set it as the Locator target. A heads-up GPS compass. The L1 has no magnetometer, so the heading is the course over ground \u2014 derived from how your GPS position moves over the last few seconds. The display is a horizontal heading tape: a fixed travel-direction pointer sits at the centre and the N..E..S..W scale scrolls underneath it as you turn, so whatever is under the pointer is your current course. A large numeric readout below shows that course in degrees and cardinal (e.g. Because the heading comes from movement, it only updates while you are actually moving: standing still shows move to set heading (and navigation's Hdg line reads A step sequencer for composing custom notification melodies. Two slots \u2014 Melody 1 and Melody 2 \u2014 switchable from within the editor. Each melody supports up to 32 notes: Navigation in the editor: Options menu: Melodies can be assigned in Settings \u203a Sound (global default) or overridden per contact or channel from the Messages screen context menu. Automatically replies to incoming messages that contain a configured trigger word (case-insensitive, contains match). Multiple trigger phrases can be packed into one Trigger field, comma-separated (e.g. The screen is a circular tab carousel, the same style as Tools \u203a Nearby Nodes' filter tabs: LEFT/RIGHT switches between the Channel / Room / Direct / Other tabs (opens on Channel), UP/DOWN moves between the rows within the active tab, and Enter acts on the selected row (LEFT/RIGHT is reserved entirely for tab-switching, so every row's value is changed via Enter, not by cycling it in place). Each target has its own Enable toggle on its own tab, and they're fully independent \u2014 you can run only a channel bot, only a room bot, only DM, or any combination, with no need to also switch on the others. Each target also has its own Commands toggle (see below) \u2014 DM, channel and room can each independently answer The DM, channel and room triggers are independent, so you can run e.g. an away-message ( The header shows a running count of auto-replies sent since boot, alongside the tab bar. Room posting requires a login. The room bot reuses whatever session the device already has with that room server (Messages \u203a Rooms \u203a Login\u2026, or a password saved from an earlier login/the phone app) \u2014 it has no way to prompt for a password itself in the background. If the saved password stops working, the room bot just silently stops posting there, the same as a manual post would; log back in from Messages to fix it. Throttle. DM auto-replies are rate-limited per contact (10 s), so a second sender is never starved while one contact is on cooldown. The channel and room bots each keep their own single 10 s cooldown and won't echo a message identical to their own reply (so two bots running the same reply text on one channel/room can't ping-pong); the cooldown caps any residual back-and-forth. Quiet hours suppress the push (trigger) replies between the configured local hours; a window where from is later than to wraps past midnight. Commands are a pull (explicitly requested), so they answer even during quiet hours. With a tab's Commands ON, a message beginning with Several commands can be combined in one message \u2014 Each target's Commands toggle is independent \u2014 e.g. answer A separate Actions toggle, nested under Commands (Commands must be ON for Actions to do anything) \u2014 these commands change the device's own behaviour, not just report on it, so they default OFF and are kept independent of the read-only Commands toggle: Actions combine with Commands and each other in one message the same way \u2014 On boards with user GPIO (see GPIO under System tools below), the same Actions gate also covers A circular tab carousel of live device and mesh stats, refreshed once a second (same tab idiom as Remote Bot / Nodes). LEFT/RIGHT switches tab; UP/DOWN scrolls within it on a small OLED \u2014 on a larger e-ink display a tab's rows all fit at once. Live tab rows: The packet counters, Forwarded, Errors and RXPS wd s/h are cumulative since boot. On the Live tab, Hold Enter opens a one-item Reset counters menu (Back dismisses it); the live readings (noise, RSSI/SNR, pool, queue, uptime) are not affected. Cancel/Back returns to the Tools list. The counters make the repeater behaviour observable: Forwarded confirms the node is actually relaying (not just configured to), and Pool free / Queue show whether forwarding is exhausting the packet pool. See Tools \u203a Repeater for the relaying options. Board-specific \u2014 currently Wio Tracker L1 only. Four otherwise-unused pins (GPIO1-GPIO4) are exposed for general-purpose use. Each pin gets its own row showing its current mode; Enter (or LEFT/RIGHT) cycles it through OFF \u2192 Input \u2192 Output and back to OFF \u2014 GPIO1 and GPIO2 additionally step through Analog between Output and OFF (GPIO3/GPIO4 have no ADC channel, so their cycle skips it). Switching a pin's mode shows a brief confirmation ( Once a pin is set to Output, a second State row appears right underneath it \u2014 Enter toggles it ON/OFF, with its own confirmation. The direction (Mode row) and the on/off state (State row) are deliberately separate: changing one never surprises you by also changing the other. The same 4 pins are reachable remotely via the Remote Bot's Turns the companion into a packet repeater while it keeps working as a normal companion \u2014 no separate firmware. By default, enabling it switches the radio to a dedicated repeater profile rather than relaying on whatever network you're chatting on (see Network below) \u2014 that matches the MeshCore community norm of repeaters sitting on a standard channel, not a private one. Loop-detection and an advert flood-depth cap are always applied. This screen keeps the toggle, the network/profile, and its flood-filter options together; live forwarding stats are on Tools \u203a Diagnostics. Navigate with UP/DOWN; change a value with LEFT/RIGHT (or Enter for toggles). Cancel/Back saves and returns to Tools. The five flood filters are opt-in (default OFF, so a plain repeater is unaffected) and act on flood traffic only \u2014 on a direct route this node is the named next hop, so it never drops those. Same network vs. separate network. With Network = Current (or a Custom profile set equal to your companion settings) the repeater stays on your own network \u2014 you keep messaging while relaying. With a different Custom profile the device moves entirely onto that network while relaying (a single radio can't be on two at once) and returns to your companion network when the repeater is switched off. The profile also re-applies after a reboot if the repeater was left on. While the repeater is on, a \u00bb indicator appears in the status bar (same blink convention as the auto-advert and trail markers) so you can tell it's relaying at a glance. Two radio settings are also overridden while relaying and restored afterwards: Settings \u203a Radio \u203a Pwr save is forced off (a repeater must listen continuously) and Auto pwr is forced off (a repeater holds full TX power for consistent relay reach). Both show Live forwarding stats \u2014 Forwarded, Pool free, Queue \u2014 are shown on Tools \u203a Diagnostics (this screen is config-only). Send commands to a repeater/room server you have admin permission on \u2014 the on-device equivalent of the companion app's repeater-admin feature. See CLI Commands for the full command grammar. (Admin only manages remote nodes; this device's own name, radio, TX power and reboot live in Settings \u2014 see below.) Enter on a row does one of four things, depending on the field: - Name / Owner info first fetch the node's current value, then open the keyboard pre-filled with it to edit \u2014 submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind. - Radio and Routing rows are typed, not free text: Repeat is an ON/OFF toggle; Advert interval / Flood advert interval / Max hops / TX power are number steppers (LEFT/RIGHT to adjust, within that field's valid range); Frequency uses the same digit-by-digit cursor editor as Settings' own Radio screen (LEFT/RIGHT moves between digits, UP/DOWN changes the selected one); Bandwidth / Spreading factor / Coding rate step through their valid discrete LoRa values with LEFT/RIGHT. All four Radio-tuple fields (Frequency/Bandwidth/SF/Coding rate) fetch and re-send the same underlying [!WARNING] This screen can run destructive commands on the remote node \u2014 Passwords are remembered across reboots, the same self-healing behaviour as room logins in Messages: after a successful admin login the password is saved on the device, so picking that node again \u2014 even after a power cycle \u2014 logs back in silently. If a saved password stops working (e.g. it was changed on the node), the failed login forgets it, so the next pick prompts for a new one. A correct password that just lacks admin permission is left alone \u2014 retyping the same one wouldn't change the outcome. Some commands are marked Serial Only in the CLI reference \u2014 those reject a remote CLI request and only work over that node's own USB serial connection. Admin doesn't manage the companion itself \u2014 its own settings live in Settings: Radio (preset / freq / SF / BW / CR) and TX power in the Radio section, and Name and Reboot in the System section. Send advert is the home ADVERT page.
"},{"location":"cli_commands/","title":"CLI Commands","text":"
"},{"location":"cli_commands/#operational","title":"Operational","text":""},{"location":"cli_commands/#reboot-the-node","title":"Reboot the node","text":"
rebootpoweroff, or - shutdownclkrebootclock syncclocktime <epoch_seconds>epoch_seconds: Unix epoch timeadvertadvert.zerohopstart otaeraseneighbors{pubkey-prefix}:{timestamp}:{snr*4}neighbor.remove <pubkey_prefix>pubkey_prefix: The public key of the node to remove from the neighbors list. This can be a short prefix or the full key. All neighbors matching the provided prefix will be removed.discover.neighborsclear statsstats-corestats-radiostats-packetslog startlog stoplog eraselogverboardget radio - set radio <freq>,<bw>,<sf>,<cr>freq: Frequency in MHz - bw: Bandwidth in kHz - sf: Spreading factor (5-12) - cr: Coding rate (5-8)LORA_FREQ, LORA_BW, LORA_SF, LORA_CR869.525,250,11,5get tx - set tx <dbm>dbm: Power level in dBm (1-22)LORA_TX_POWERtempradio <freq>,<bw>,<sf>,<cr>,<timeout_mins>freq: Frequency in MHz (300-2500) - bw: Bandwidth in kHz (7.8-500) - sf: Spreading factor (5-12) - cr: Coding rate (5-8) - timeout_mins: Duration in minutes (must be > 0)get freq - set freq <frequency>frequency: Frequency in MHz869.525set freq <frequency>get radio.rxgain - set radio.rxgain <state>state: on|offonoff because of #2118get radio.fem.rxgain - set radio.fem.rxgain <state>state: on|offradio.rxgain, which controls the radio chip receive gain mode.get name - set name <name>name: Node nameADVERT_NAMEget lat - set lat <degrees>ADVERT_LAT0degrees: Latitude in degreesget lon - set lon <degrees>ADVERT_LON0degrees: Longitude in degreesget prv.key - set prv.key <private_key>private_key: Private key in hex format (64 hex characters)get prv.key: Yes - set prv.key: Nopassword <new_password>new_password: New admin passwordADMIN_PASSWORDpasswordget guest.password - set guest.password <password>password: Guest passwordROOM_PASSWORD (Room Server only)<blank>get owner.info - set owner.info <text>text: Owner information text<blank>| characters are translated to newlinesget adc.multiplier - set adc.multiplier <value>value: ADC multiplier (0.0-10.0)0.0 (value defined by board)get public.keyverget rolepowersaving - powersaving on - powersaving offon: enable power saving - off: disable power savingoffget repeat - set repeat <state>state: on|offonget path.hash.mode - set path.hash.mode <value>value: Path hash size (0-2) - 0: 1 Byte hash size (256 unique ids)[64 max flood] - 1: 2 Byte hash size (65,536 unique ids)[32 max flood] - 2: 3 Byte hash size (16,777,216 unique ids)[21 max flood] - 3: DO NOT USE (Reserved) 0get loop.detect - set loop.detect <state>state: - off: no loop detection is performed - minimal: packets are dropped if repeater's ID/hash appears 4 or more times (1-byte), 2 or more (2-byte), 1 or more (3-byte) - moderate: packets are dropped if repeater's ID/hash appears 2 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte) - strict: packets are dropped if repeater's ID/hash appears 1 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte)offloop.detect minimal, and a 1-byte path size packet is received, the repeater will see if its own ID/hash is already in the path. If it's already encoded 4 times, it will reject the packet. If the packet uses 2-byte path size, and repeater's own ID/hash is already encoded 2 times, it rejects. If the packet uses 3-byte path size, and the repeater's own ID/hash is already encoded 1 time, it rejects. get txdelay - set txdelay <value>value: Transmit delay factor (0-2)0.50 disables the window entirely.get direct.txdelay - set direct.txdelay <value>value: Direct transmit delay factor (0-2)0.2txdelay, but applied to direct (non-flood, routed) traffic. The default is lower because direct packets are addressed to a specific next hop, so far fewer nodes compete to retransmit them.get rxdelay - set rxdelay <value>value: Receive delay base (0-20)0.0get dutycycle - set dutycycle <value>value: Duty cycle percentage (1-100)50% (equivalent to airtime factor 1.0)set dutycycle 100 \u2014 no duty cycle limit - set dutycycle 50 \u2014 50% duty cycle (default) - set dutycycle 10 \u2014 10% duty cycle - set dutycycle 1 \u2014 1% duty cycle (strictest EU requirement)get/set dutycycle instead.get af - set af <value>value: Airtime factor (0-9). After each transmission, the repeater enforces a silent period of approximately the on-air transmission time multiplied by the value. This results in a long-term duty cycle of roughly 1 divided by (1 plus the value). For example: - af = 1 \u2192 ~50% duty - af = 2 \u2192 ~33% duty - af = 3 \u2192 ~25% duty - af = 9 \u2192 ~10% duty You are responsible for choosing a value that is appropriate for your jurisdiction and channel plan (for example EU 868 Mhz 10% duty cycle regulation).1.0get int.thresh - set int.thresh <value>value: Interference threshold value0.0get cad - set cad <on|off>int.thresh \u2014 either, both, or none may be active.on|off: Enable or disable hardware CADoffget agc.reset.interval - set agc.reset.interval <value>value: Interval in seconds rounded down to a multiple of 4 (17 becomes 16). 0 to disable.0.0get multi.acks - set multi.acks <state>state: 0 (disable) or 1 (enable)0get flood.advert.interval - set flood.advert.interval <hours>hours: Interval in hours (3-168)12 (Repeater) - 0 (Sensor)get advert.interval - set advert.interval <minutes>minutes: Interval in minutes rounded down to the nearest multiple of 2 (61 becomes 60) (60-240)0get flood.max - set flood.max <value>value: Maximum flood hop count (0-64)64get flood.max.unscoped - set flood.max.unscoped <value>value: Maximum flood hop count (0-64) for a packet without a scope (no region set)64 - (0xFF indicates it hasn't been set, will track flood.max until it is.)region denyf *, setting flood.max.unscoped to a lower value such as 3 would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region.get flood.max.advert - set flood.max.advert <value>value: Maximum flood hop count (0-64) for an advert packet8setperm <pubkey> <permissions>pubkey: Companion public key - permissions: - 0: Guest - 1: Read-only - 2: Read-write - 3: Adminpermissions is omittedget aclget allow.read.only - set allow.read.only <state>state: on (enable) or off (disable)offregion load - region load <name> [flood_flag]name: A name of a region. * represents the wildcard regionflood_flag: Optional F to allow floodingregion load with an empty name will not work remotely (it's interactive)region saveregion allowf <name>name: Region name (or * for wildcard)* allows packets without region transport codesregion denyf <name>name: Region name (or * for wildcard)* drops packets without region transport codesregion get <name>name: Region name (or * for wildcard)region home - region home <name>name: Region nameregion default - region default {name|<null>}name: Region name, or to reset/clear"},{"location":"cli_commands/#create-a-new-region","title":"Create a new region","text":"region put <name> [parent_name]name: Region name - parent_name: Parent region name (optional, defaults to wildcard)region def <token> [<token> ...]*.
name \u2014 Create name as a child of the current cursor (equivalent to region put name with the cursor as parent). Cursor moves to name.name|jump (or name,jump) \u2014 Create name as a child of the current cursor, then move the cursor to jump (must already exist on the node, or have been created earlier in this command). jump is not the parent of name; use this form to pop back up and start another branch.region put). The reply is the resulting region tree (same format as bare region); review it before running region save to persist. On error, the reply is Err - ... and any regions placed before the failure remain on the node, just like a partial chain of region put.region def does not clear the existing tree \u2014 if a name already exists, its parent is updated to the current cursor; otherwise a new region is created. To start from scratch, region remove the unwanted regions first.region def commands; the cursor resets to * between commands, so lead the next command with child|ancestor to reposition. Each token splits at most once on | \u2014 region def a|b|c|d is not a flat-list shorthand; see the flat-list example below.region def a b c d e\nregion save\nregion put a, region put b a, region put c b, region put d c, region put e b, region put f e):region def a b c d|b e f\nregion save\nregion def a b c|nope d\nErr - unknown jump: nope. a, b, and c were placed before the failure; d was not. Run region to inspect, then re-run with a corrected jump or repair with region remove / region put.*). Use |* after each token to pop the cursor back to the root before the next token:
"},{"location":"cli_commands/#remove-a-region","title":"Remove a region","text":"region def a|* b|* c|* d|* e|* f\nregion save\nregion remove <name>name: Region nameregion list <filter>filter: allowed|deniedregionregion load\n#Europe F\n<blank line to end region load>\nregion save\n#Europe with flooding enabled - Packets from this region will be flooded to other nodesregion load \n* F\n<blank line to end region load>\nregion save\n* with flooding enabled - Enables flooding for all regions automatically - Applies only to packets without transport codesregion load \n*\n<blank line to end region load>\nregion save\n* without flooding - This region exists but doesn't affect packet distribution - Used as a default/empty regionregion load \n#Europe F\n #UK\n #London\n #Manchester\n #France\n #Paris\n #Lyon\n<blank line to end region load>\nregion save\n#Europe region with flooding enabled - Adds nested child regions (#UK, #France) - All nested regions inherit the flooding flag from parentregion load \n* F\n #NorthAmerica\n #USA\n #NewYork\n #California\n #Canada\n #Ontario\n #Quebec\n<blank line to end region load>\nregion save\n* with flooding enabled - Adds nested #NorthAmerica hierarchy - Enables flooding for all child regions automatically - Useful for global networks with specific regional rulesgps - gps <state>state: on|offoffoff when the GPS hardware is disabled - on, {active|deactivated}, {fix|no fix}, {sat count} sats when the GPS hardware is enabledgps syncgps setlocgps advert - gps advert <policy>policy: none|share|prefs - none: don't include location in adverts - share: share gps location (from SensorManager) - prefs: location stored in node's lat and lon settingsprefssensor list [start]start: Optional starting index (defaults to 0)<var_name>=<value>\\nsensor get <key> - sensor set <key> <value>key: Sensor setting name - value: The value to set the sensor toget bridge.typeget bridge.enabled - set bridge.enabled <state>state: on|offoffget bridge.delay - set bridge.delay <ms>ms: Delay in milliseconds (0-10000)500get bridge.source - set bridge.source <source>source: - logRx: bridges received packets - logTx: bridges transmitted packetslogTxget bridge.baud - set bridge.baud <rate>rate: Baud rate (9600, 19200, 38400, 57600, or 115200)115200get bridge.channel - set bridge.channel <channel>channel: Channel number (1-14)get bridge.secret - set bridge.secret <secret>secret: ESP-NOW bridge secret, up to 15 charactersget bootloader.verget pwrmgt.supportget pwrmgt.sourceget pwrmgt.bootreasonget pwrmgt.bootmv_ethernet firmware variants (e.g. RAK_4631_repeater_ethernet) to enable this feature.eth.statusETH: <ip>:<port> when connected (e.g. ETH: 192.168.1.50:23) - ETH: not connected when Ethernet is not activenc, PuTTY) to access the same CLI available over serial.
"},{"location":"companion_protocol/#important-security-note","title":"Important Security Note","text":"
"},{"location":"companion_protocol/#table-of-contents","title":"Table of Contents","text":"
"},{"location":"companion_protocol/#ble-connection","title":"BLE Connection","text":""},{"location":"companion_protocol/#service-and-characteristics","title":"Service and Characteristics","text":"
"},{"location":"companion_protocol/#connection-steps","title":"Connection Steps","text":"6E400001-B5A3-F393-E0A9-E50E24DCCA9E6E400002-B5A3-F393-E0A9-E50E24DCCA9E6E400003-B5A3-F393-E0A9-E50E24DCCA9E
6E400001-B5A3-F393-E0A9-E50E24DCCA9E6E400002-B5A3-F393-E0A9-E50E24DCCA9E
6E400003-B5A3-F393-E0A9-E50E24DCCA9E
CMD_APP_START to identify your app to firmware and get radio settingsCMD_DEVICE_QUERY to fetch device info and negotiate supported protocol versionsCMD_SET_DEVICE_TIME to set the firmware clockCMD_GET_CONTACTS to fetch all contactsCMD_GET_CHANNEL multiple times to fetch all channel slotsCMD_SYNC_NEXT_MESSAGE to fetch the next message stored in firmwarePUSH_CODE_MSG_WAITING or PUSH_CODE_ADVERT
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT or WRITE_TYPE_NO_RESPONSECBCharacteristicWriteType.withResponse or .withoutResponsewrite_gatt_char() with response=True or FalseSET_CHANNEL (50 bytes), you may need to:
"},{"location":"companion_protocol/#command-sequencing","title":"Command Sequencing","text":"
gatt.requestMtu(512)peripheral.maximumWriteValueLength(for:)
"},{"location":"companion_protocol/#command-queue-management","title":"Command Queue Management","text":"
CMD_GET_CHANNEL \u2192 RESP_CODE_CHANNEL_INFO)
"},{"location":"companion_protocol/#packet-structure","title":"Packet Structure","text":"
[Packet Type (1 byte)] [Data (variable length)]\nByte 0: 0x01\nBytes 1-7: Reserved (currently ignored by firmware)\nBytes 8+: Application name (UTF-8, optional)\n01 00 00 00 00 00 00 00 6d 63 63 6c 69\nPACKET_SELF_INFO (0x05)Byte 0: 0x16\nByte 1: 0x03\n16 03\nPACKET_DEVICE_INFO (0x0D) with device informationByte 0: 0x1F\nByte 1: Channel Index (0-7)\n1F 01\nPACKET_CHANNEL_INFO (0x12) with channel detailsByte 0: 0x20\nByte 1: Channel Index (0-7)\nBytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)\nBytes 34-49: Secret (16 bytes)\n20 01 53 4D 53 00 00 ... (name padded to 32 bytes)\n [16 bytes of secret]\nPACKET_ERROR.PACKET_OK (0x00) on success, PACKET_ERROR (0x01) on failureByte 0: 0x03\nByte 1: 0x00\nByte 2: Channel Index (0-7)\nBytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)\nBytes 7+: Message Text (UTF-8, variable length)\n03 00 01 D2 02 96 49 48 65 6C 6C 6F\nPACKET_MSG_SENT (0x06) on successByte 0: 0x3E\nByte 1: Channel Index (0-7)\nByte 2: Path Length (0xFF = flood, otherwise actual path length)\nBytes 3 .. 2+path_len: Path (omitted when path_len == 0xFF)\nNext 2 bytes (little-endian): Data Type (`data_type`, uint16)\nRemaining bytes: Binary payload (variable length)\nDATA_TYPE_DEV, payload A1 B2 C3, channel 1):3E 01 FF FF FF A1 B2 C3\n0x0000 (DATA_TYPE_RESERVED) is invalid and rejected with PACKET_ERROR. - 0xFFFF (DATA_TYPE_DEV) is the developer namespace for experimenting and developing apps. - Values 0x0001\u20130xFFFE are available for registered application/community namespaces. See the Registered data_type values table below.MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 163 bytes. - Larger payloads are rejected with PACKET_ERROR (ERR_CODE_ILLEGAL_ARG).PACKET_OK (0x00) on success, or PACKET_ERROR (0x01) with one of: - ERR_CODE_NOT_FOUND (2) \u2014 unknown channel_idx - ERR_CODE_ILLEGAL_ARG (6) \u2014 invalid path_len, reserved data_type (0x0000), or payload larger than MAX_CHANNEL_DATA_LENGTH - ERR_CODE_TABLE_FULL (3) \u2014 outbound send queue is full; retry laterRESP_CODE_CHANNEL_DATA_RECV (0x1B); see Receive Channel Data Datagram.data_type values","text":"data_type is an application identifier, not a payload-format identifier. Each registered value identifies an application that owns its own internal payload schemas. The firmware does not inspect payload contents \u2014 data_type is transported opaquely.DATA_TYPE_RESERVED Reserved; invalid on send 0x0001 \u2013 0x00FF \u2014 Reserved for internal use 0x0100 \u2013 0xFEFF \u2014 Registered application namespaces (see number_allocations.md) 0xFF00 \u2013 0xFFFE \u2014 Testing/development; no registration required 0xFFFF DATA_TYPE_DEV Developer/experimental namespace PAYLOAD_TYPE_GRP_DATA, 0x06) are forwarded to the host as RESP_CODE_CHANNEL_DATA_RECV notifications.RESP_CODE_CHANNEL_DATA_RECV, 0x1B):Byte 0: 0x1B (packet type)\nByte 1: SNR (signed int8, scaled \u00d74 \u2014 divide by 4.0 to recover dB)\nBytes 2-3: Reserved (clients MUST ignore)\nByte 4: Channel Index (0-7)\nByte 5: Path Length (actual path length when flooded, otherwise 0xFF for direct)\nBytes 6-7: Data Type (uint16 little-endian)\nByte 8: Data Length\nBytes 9 .. 8+data_len: Payload\npath_len is reported in the receive frame \u2014 the path itself is not copied to the host. There are no path bytes between byte 5 and the data_type field at bytes 6\u20137, regardless of path_len.path_len = 0xFF path_len \u2260 0xFF Send Flood the network Direct route; the encoded path follows (low 6 bits = hash count, top 2 bits + 1 = hash size; on-wire byte count = hash_count \u00d7 hash_size) Receive Packet arrived via direct route Packet was flooded; this is the encoded pkt->path_len field as observed (no path bytes follow) 0xFF is inverted between the two directions, and on receive the field carries metadata only \u2014 never a routable path. path_len is an encoded byte (see Packet::isValidPathLen / Packet::writePath in src/Packet.cpp), not a raw byte count.PACKET_MESSAGES_WAITING (0x83) to notify the host that datagrams are queued; poll with CMD_SYNC_NEXT_MESSAGE (0x0A) to retrieve them.
"},{"location":"companion_protocol/#7-get-message","title":"7. Get Message","text":"def parse_channel_data_recv(data):\n if len(data) < 9:\n return None\n snr_byte = data[1]\n snr = (snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0\n channel_idx = data[4]\n path_len = data[5]\n data_type = int.from_bytes(data[6:8], 'little')\n data_len = data[8]\n if 9 + data_len > len(data):\n return None\n payload = data[9:9 + data_len]\n return {\n 'snr': snr,\n 'channel_idx': channel_idx,\n 'path_len': path_len,\n 'data_type': data_type,\n 'payload': bytes(payload),\n }\nByte 0: 0x0A\n0A\nPACKET_CHANNEL_MSG_RECV (0x08) or PACKET_CHANNEL_MSG_RECV_V3 (0x11) for channel messages - PACKET_CONTACT_MSG_RECV (0x07) or PACKET_CONTACT_MSG_RECV_V3 (0x10) for contact messages - PACKET_CHANNEL_DATA_RECV (0x1B) for channel data datagrams - PACKET_NO_MORE_MSGS (0x0A) if no messages availablePACKET_MESSAGES_WAITING (0x83) as a notification when messages are available.Byte 0: 0x14\n14\nPACKET_BATTERY (0x0C) with battery millivolts and storage information
"},{"location":"companion_protocol/#channel-lifecycle","title":"Channel Lifecycle","text":"
8b3387e9c5cdea6ac9e5edbaa115cd72
sha256(\"#test\")#test has the key: 9cd8fcf22a47333b591d96a2b848b73f
"},{"location":"companion_protocol/#message-handling","title":"Message Handling","text":""},{"location":"companion_protocol/#receiving-messages","title":"Receiving Messages","text":"
CMD_SET_CHANNEL with name and a 16-byte secret
CMD_GET_CHANNEL with channel indexRESP_CODE_CHANNEL_INFO response
CMD_SET_CHANNEL with empty name and all-zero secret
"},{"location":"companion_protocol/#contact-message-format","title":"Contact Message Format","text":"PACKET_CHANNEL_MSG_RECV (0x08) - Standard formatPACKET_CHANNEL_MSG_RECV_V3 (0x11) - Version 3 with SNRPACKET_CONTACT_MSG_RECV (0x07) - Standard formatPACKET_CONTACT_MSG_RECV_V3 (0x10) - Version 3 with SNRPACKET_MESSAGES_WAITING (0x83) - Indicates messages are queuedPACKET_CONTACT_MSG_RECV, 0x07):Byte 0: 0x07 (packet type)\nBytes 1-6: Public Key Prefix (6 bytes, hex)\nByte 7: Path Length\nByte 8: Text Type\nBytes 9-12: Timestamp (32-bit little-endian)\nBytes 13-16: Signature (4 bytes, only if txt_type == 2)\nBytes 17+: Message Text (UTF-8)\nPACKET_CONTACT_MSG_RECV_V3, 0x10):Byte 0: 0x10 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nBytes 4-9: Public Key Prefix (6 bytes, hex)\nByte 10: Path Length\nByte 11: Text Type\nBytes 12-15: Timestamp (32-bit little-endian)\nBytes 16-19: Signature (4 bytes, only if txt_type == 2)\nBytes 20+: Message Text (UTF-8)\n
"},{"location":"companion_protocol/#channel-message-format","title":"Channel Message Format","text":"def parse_contact_message(data):\n packet_type = data[0]\n offset = 1\n\n # Check for V3 format\n if packet_type == 0x10: # V3\n snr_byte = data[offset]\n snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n offset += 3 # Skip SNR + reserved\n\n pubkey_prefix = data[offset:offset+6].hex()\n offset += 6\n\n path_len = data[offset]\n txt_type = data[offset + 1]\n offset += 2\n\n timestamp = int.from_bytes(data[offset:offset+4], 'little')\n offset += 4\n\n # If txt_type == 2, skip 4-byte signature\n if txt_type == 2:\n offset += 4\n\n message = data[offset:].decode('utf-8')\n\n return {\n 'pubkey_prefix': pubkey_prefix,\n 'path_len': path_len,\n 'txt_type': txt_type,\n 'timestamp': timestamp,\n 'message': message,\n 'snr': snr if packet_type == 0x10 else None\n }\nPACKET_CHANNEL_MSG_RECV, 0x08):Byte 0: 0x08 (packet type)\nByte 1: Channel Index (0-7)\nByte 2: Path Length\nByte 3: Text Type\nBytes 4-7: Timestamp (32-bit little-endian)\nBytes 8+: Message Text (UTF-8)\nPACKET_CHANNEL_MSG_RECV_V3, 0x11):Byte 0: 0x11 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nByte 4: Channel Index (0-7)\nByte 5: Path Length\nByte 6: Text Type\nBytes 7-10: Timestamp (32-bit little-endian)\nBytes 11+: Message Text (UTF-8)\n
"},{"location":"companion_protocol/#sending-messages","title":"Sending Messages","text":"def parse_channel_message(data):\n packet_type = data[0]\n offset = 1\n\n # Check for V3 format\n if packet_type == 0x11: # V3\n snr_byte = data[offset]\n snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n offset += 3 # Skip SNR + reserved\n\n channel_idx = data[offset]\n path_len = data[offset + 1]\n txt_type = data[offset + 2]\n timestamp = int.from_bytes(data[offset+3:offset+7], 'little')\n message = data[offset+7:].decode('utf-8')\n\n return {\n 'channel_idx': channel_idx,\n 'timestamp': timestamp,\n 'message': message,\n 'snr': snr if packet_type == 0x11 else None\n }\nSEND_CHANNEL_MESSAGE command (see Commands).PACKET_*) for bytes the firmware sends back to the host. In the firmware source these same values are split across two #define families by purpose:
RESP_CODE_* \u2014 direct replies to a command (e.g. RESP_CODE_CHANNEL_DATA_RECV = PACKET_CHANNEL_DATA_RECV = 0x1B).PUSH_CODE_* \u2014 asynchronous notifications not tied to a specific command (e.g. PUSH_CODE_MSG_WAITING = PACKET_MESSAGES_WAITING = 0x83).RESP_CODE_X / PUSH_CODE_X correspond to this doc's PACKET_X of the same numeric value.Byte 0: 0x00\nBytes 1-4: Optional value (32-bit little-endian integer)\nByte 0: 0x01\nByte 1: Error code (optional)\nByte 0: 0x12\nByte 1: Channel Index\nBytes 2-33: Channel Name (32 bytes, null-terminated)\nBytes 34-49: Secret (16 bytes)\nByte 0: 0x0D\nByte 1: Firmware Version (uint8)\nBytes 2+: Variable length based on firmware version\n\nFor firmware version >= 3:\nByte 2: Max Contacts Raw (uint8, actual = value * 2)\nByte 3: Max Channels (uint8)\nBytes 4-7: BLE PIN (32-bit little-endian)\nBytes 8-19: Firmware Build (12 bytes, UTF-8, null-padded)\nBytes 20-59: Model (40 bytes, UTF-8, null-padded)\nBytes 60-79: Version (20 bytes, UTF-8, null-padded)\nByte 80: Client repeat enabled/preferred (firmware v9+)\nByte 81: Path hash mode (firmware v10+)\ndef parse_device_info(data):\n if len(data) < 2:\n return None\n\n fw_ver = data[1]\n info = {'fw_ver': fw_ver}\n\n if fw_ver >= 3 and len(data) >= 80:\n info['max_contacts'] = data[2] * 2\n info['max_channels'] = data[3]\n info['ble_pin'] = int.from_bytes(data[4:8], 'little')\n info['fw_build'] = data[8:20].decode('utf-8').rstrip('\\x00').strip()\n info['model'] = data[20:60].decode('utf-8').rstrip('\\x00').strip()\n info['ver'] = data[60:80].decode('utf-8').rstrip('\\x00').strip()\n\n return info\nByte 0: 0x0C\nBytes 1-2: Battery Voltage (16-bit little-endian, millivolts)\nBytes 3-6: Used Storage (32-bit little-endian, KB)\nBytes 7-10: Total Storage (32-bit little-endian, KB)\ndef parse_battery(data):\n if len(data) < 3:\n return None\n\n mv = int.from_bytes(data[1:3], 'little')\n info = {'battery_mv': mv}\n\n if len(data) >= 11:\n info['used_kb'] = int.from_bytes(data[3:7], 'little')\n info['total_kb'] = int.from_bytes(data[7:11], 'little')\n\n return info\nByte 0: 0x05\nByte 1: Advertisement Type\nByte 2: TX Power\nByte 3: Max TX Power\nBytes 4-35: Public Key (32 bytes, hex)\nBytes 36-39: Advertisement Latitude (32-bit little-endian, divided by 1e6)\nBytes 40-43: Advertisement Longitude (32-bit little-endian, divided by 1e6)\nByte 44: Multi ACKs\nByte 45: Advertisement Location Policy\nByte 46: Telemetry Mode (bitfield)\nByte 47: Manual Add Contacts (bool)\nBytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)\nBytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)\nByte 56: Radio Spreading Factor\nByte 57: Radio Coding Rate\nBytes 58+: Device Name (UTF-8, variable length, no null terminator required)\ndef parse_self_info(data):\n if len(data) < 36:\n return None\n\n offset = 1\n info = {\n 'adv_type': data[offset],\n 'tx_power': data[offset + 1],\n 'max_tx_power': data[offset + 2],\n 'public_key': data[offset + 3:offset + 35].hex()\n }\n offset += 35\n\n lat = int.from_bytes(data[offset:offset+4], 'little') / 1e6\n lon = int.from_bytes(data[offset+4:offset+8], 'little') / 1e6\n info['adv_lat'] = lat\n info['adv_lon'] = lon\n offset += 8\n\n info['multi_acks'] = data[offset]\n info['adv_loc_policy'] = data[offset + 1]\n telemetry_mode = data[offset + 2]\n info['telemetry_mode_env'] = (telemetry_mode >> 4) & 0b11\n info['telemetry_mode_loc'] = (telemetry_mode >> 2) & 0b11\n info['telemetry_mode_base'] = telemetry_mode & 0b11\n info['manual_add_contacts'] = data[offset + 3] > 0\n offset += 4\n\n freq = int.from_bytes(data[offset:offset+4], 'little') / 1000.0\n bw = int.from_bytes(data[offset+4:offset+8], 'little') / 1000.0\n info['radio_freq'] = freq\n info['radio_bw'] = bw\n info['radio_sf'] = data[offset + 8]\n info['radio_cr'] = data[offset + 9]\n offset += 10\n\n if offset < len(data):\n name_bytes = data[offset:]\n info['name'] = name_bytes.decode('utf-8').rstrip('\\x00').strip()\n\n return info\nByte 0: 0x06\nByte 1: Route Flag (0 = direct, 1 = flood)\nBytes 2-5: Tag / Expected ACK (4 bytes, little-endian)\nBytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)\n
"},{"location":"companion_protocol/#error-codes","title":"Error Codes","text":"Byte 0: 0x82\nBytes 1-6: ACK Code (6 bytes, hex)\nPACKET_ERROR (0x01) carries a single-byte error code in byte 1. Values match the ERR_CODE_* constants defined in examples/companion_radio/MyMesh.cpp:ERR_CODE_UNSUPPORTED_CMD Unknown or unsupported command byte / sub-command 2 ERR_CODE_NOT_FOUND Target not found (channel, contact, message, etc.) 3 ERR_CODE_TABLE_FULL Internal queue or table is full \u2014 retry later 4 ERR_CODE_BAD_STATE Operation not valid in current device state (e.g. iterator already running) 5 ERR_CODE_FILE_IO_ERROR Filesystem or storage I/O failure 6 ERR_CODE_ILLEGAL_ARG Invalid argument (bad length, out-of-range value, reserved field, etc.) PACKET_ERROR response, and treat unknown codes as generic errors.
"},{"location":"companion_protocol/#response-handling","title":"Response Handling","text":"
"},{"location":"companion_protocol/#example-implementation-flow","title":"Example Implementation Flow","text":""},{"location":"companion_protocol/#initialization","title":"Initialization","text":"PACKET_MESSAGES_WAITING (0x83) by polling GET_MESSAGE command
APP_START \u2192 PACKET_SELF_INFODEVICE_QUERY \u2192 PACKET_DEVICE_INFOGET_CHANNEL \u2192 PACKET_CHANNEL_INFOSET_CHANNEL \u2192 PACKET_OK or PACKET_ERRORSEND_CHANNEL_MESSAGE \u2192 PACKET_MSG_SENTGET_MESSAGE \u2192 PACKET_CHANNEL_MSG_RECV, PACKET_CONTACT_MSG_RECV, PACKET_CHANNEL_DATA_RECV, or PACKET_NO_MORE_MSGSSEND_CHANNEL_DATA \u2192 PACKET_OK or PACKET_ERRORGET_BATTERY \u2192 PACKET_BATTERYSET_CHANNEL may need 1-2 seconds)PACKET_ERROR: Log error code, clear current command
"},{"location":"companion_protocol/#creating-a-private-channel","title":"Creating a Private Channel","text":"# 1. Scan for MeshCore device\ndevice = scan_for_device(\"MeshCore\")\n\n# 2. Connect to BLE GATT\ngatt = connect_to_device(device)\n\n# 3. Discover services and characteristics\nservice = discover_service(gatt, \"6E400001-B5A3-F393-E0A9-E50E24DCCA9E\")\nrx_char = discover_characteristic(service, \"6E400002-B5A3-F393-E0A9-E50E24DCCA9E\")\ntx_char = discover_characteristic(service, \"6E400003-B5A3-F393-E0A9-E50E24DCCA9E\")\n\n# 4. Enable notifications on TX characteristic\nenable_notifications(tx_char, on_notification_received)\n\n# 5. Send AppStart command\nsend_command(rx_char, build_app_start())\nwait_for_response(PACKET_SELF_INFO)\n
"},{"location":"companion_protocol/#sending-a-message","title":"Sending a Message","text":"# 1. Generate 16-byte secret\nsecret_16_bytes = generate_secret(16) # Use CSPRNG\nsecret_hex = secret_16_bytes.hex()\n\n# 2. Build SET_CHANNEL command\nchannel_name = \"YourChannelName\"\nchannel_index = 1 # Use 1-7 for private channels\ncommand = build_set_channel(channel_index, channel_name, secret_16_bytes)\n\n# 3. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_OK)\n\n# 4. Store secret locally\nstore_channel_secret(channel_index, secret_hex)\n
"},{"location":"companion_protocol/#receiving-messages_1","title":"Receiving Messages","text":"# 1. Build channel message command\nchannel_index = 1\nmessage = \"Hello, MeshCore!\"\ntimestamp = int(time.time())\ncommand = build_channel_message(channel_index, message, timestamp)\n\n# 2. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_MSG_SENT)\n
"},{"location":"companion_protocol/#best-practices","title":"Best Practices","text":"def on_notification_received(data):\n packet_type = data[0]\n\n if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:\n message = parse_channel_message(data)\n handle_channel_message(message)\n elif packet_type == PACKET_MESSAGES_WAITING:\n # Poll for messages\n send_command(rx_char, build_get_message())\n
"},{"location":"companion_protocol/#troubleshooting","title":"Troubleshooting","text":""},{"location":"companion_protocol/#connection-issues","title":"Connection Issues","text":"CMD_SYNC_NEXT_MESSAGE when PUSH_CODE_MSG_WAITING is received
RESP_CODE_ERR responses appropriately
"},{"location":"companion_protocol/#command-issues","title":"Command Issues","text":"
"},{"location":"companion_protocol/#message-issues","title":"Message Issues","text":"
"},{"location":"docs/","title":"Local Documentation","text":"GET_MESSAGE command periodicallypip install mkdocs\npip install mkdocs-material\n
"},{"location":"faq/","title":"Frequently Asked Questions","text":"mkdocs serve - Start the live-reloading docs server.mkdocs build - Build the documentation site.
"},{"location":"faq/#1-introduction","title":"1. Introduction","text":""},{"location":"faq/#11-q-what-is-meshcore","title":"1.1. Q: What is MeshCore?","text":"
path.hash.mode do on a repeater?
"},{"location":"faq/#124-repeater","title":"1.2.4. Repeater","text":"set repeat on, it is not recommended nor encouraged. A room server with repeat set to on lacks the full set of repeater and remote administration features that are only available in the repeater firmware.set freq {frequency}
set flood.advert.interval {hours}set advert.interval {minutes} command controls the local zero-hop advert timer.
console feature to connect to the deviceset lat <GPS Lat>set lon <GPS Lon>password. Use the following command to change the admin password:password {new-password}hello. Use the following command to change the guest password:set guest.password {guest-password}get prv.key to print a repeater's private key on the serial console set prv.key <hex> to set a repeater's private key on the serial consoleset prv.key <hex> command for the new private key to take effect.set agc.reset.interval <number><number> unit is in seconds and is incremented by 4. set agc.reset.interval 4 works well to cure deafness.state = STATE_IDLE; in function RadioLibWrapper::resetAGC() in RadioLibWrappers.cppSettings (gear icon), Experimental Settings.path.hash.mode do on a repeater?","text":"path.hash.mode only controls the path hash size used in a repeater's own advert broadcasts. It does NOT affect which packets the repeater forwards. A repeater with firmware 1.14+ always forward 1-, 2-, and 3-byte packets regardless of this setting.set path.hash.mode {0|1|2}:\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 path.hash.mode \u2502 Advert path hash size \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 0 \u2502 1 byte (default) \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 1 \u2502 2 bytes \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 2 \u2502 3 bytes \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \npath.hash.mode to 1 (for 2-byte path hash) or 2 (for 3-byte path hash) now helps the community gauge to how many repeaters have updated to 1.14+. Please work with your MeshCore community together to decide when to switch to 2-byte path or 3-byte path for channel and direct messages.
"},{"location":"faq/#43-q-why-is-my-t-deck-plus-not-getting-any-satellite-lock","title":"4.3. Q: Why is my T-Deck Plus not getting any satellite lock?","text":"GPS Info screen; you should see the Sentences: counter increasing if the baud rate is correct.izOH6cXN6mrJ5e26oRXNcg=== key on the T-Deck's hardware keyboard. You can use the on-screen software keyboard to enter =. Tap the text box to enable the on-screen software keyboard. The third character is the capital letter O (Oh), not zero 08b3387e9c5cdea6ac9e5edbaa115cd72
\\tiles folder to the root of your T-Deck's SD card.{hops} l:{packet-length}({payload-len}) t:{packet-type} snr:{n} rssi:{n}#define PAYLOAD_TYPE_REQ 0x00 // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)\n#define PAYLOAD_TYPE_RESPONSE 0x01 // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)\n#define PAYLOAD_TYPE_TXT_MSG 0x02 // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)\n#define PAYLOAD_TYPE_ACK 0x03 // a simple ack #define PAYLOAD_TYPE_ADVERT 0x04 // a node advertising its Identity\n#define PAYLOAD_TYPE_GRP_TXT 0x05 // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, \"name: msg\")\n#define PAYLOAD_TYPE_GRP_DATA 0x06 // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: data_type, data_len, blob)\n#define PAYLOAD_TYPE_ANON_REQ 0x07 // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)\n#define PAYLOAD_TYPE_PATH 0x08 // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)\n.mp3 files onto the root dir of the SD card. The files are:
"},{"location":"faq/#413-q-what-is-the-import-from-clipboard-feature-on-the-t-deck-and-is-there-a-way-to-manually-add-nodes-without-having-to-receive-adverts","title":"4.13. Q: What is the 'Import from Clipboard' feature on the t-deck and is there a way to manually add nodes without having to receive adverts?","text":"startup.mp3error.mp3alert.mp3new-advert.mp3existing-advert.mp3set repeat on repeat.set flood.max CLI command. Administrators of repeaters get to set the rules of their repeaters.8b3387e9c5cdea6ac9e5edbaa115cd72izOH6cXN6mrJ5e26oRXNcg==O, not zero 0.sudo apt update\nsudo apt install libpython3-dev\nsudo apt install python3-venv\npython3 -m venv meshcore\ncd meshcore && source bin/activate\npip install -U platformio\ngit clone https://github.com/ripplebiz/MeshCore.git\ncd MeshCore\n[arduino_base] edit the LORA_FREQ=867.5 save, then run:pio run -e RAK_4631_Repeater\nfirmware.zip in .pio/build/RAK_4631_Repeater
3 dot menu icon at the top right corner, then tap Internet Map. Tap the 3 dot menu icon again and choose Add me to the Map3 dot next to the Repeater or Room Server you want to add to the Internet Map, tap Share, then tap Upload to Internet Map.
Heltec_V3_companion_radio_ble-v1.7.1-165fb33.bin
Heltec_v3_companion_radio_usb-v1.7.1-165fb33-merged.bin
https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin
wget https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin to download the firmware file for your device type or the version you need: USB, BLE, Repeater, Room Server, merged bin or non-merged bin.
wget --user-agent=\"Mozilla/5.0\" --content-disposition \"https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_usb-v1.7.1-165fb33.bin\"ttyXXXX device path on your Raspberry Pi.
/dev directory and run the ls command to find your device path./dev/ttyUSB0 for ESP devices.
pip install esptool --break-system-packages
esptool.py -p /dev/ttyUSB0 --chip esp32-s3 write_flash 0x10000 <non-merged_firmware>.bin
esptool.py -p /dev/ttyUSB0 --chip esp32-s3 write_flash 0x00000 <merged_firmware>.bin
RAK_4631_companion_radio_ble-v1.7.1-165fb33.ziphttps://flasher.meshcore.io/releases/download/companion-v1.7.1/RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip
wget https://flasher.meshcore.io/releases/download/companion-v1.7.1/RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip to download the firmware file for your device type or the version you need: USB, BLE, Repeater, Room Server, ZIP file only.ttyXXXX device path on your Raspberry Pi.
/dev directory and run the ls command to find your device path./dev/ttyACM0 for nRF devices.
pip install adafruit-nrfutil --break-system-packages
adafruit-nrfutil --verbose dfu serial --package RAK_4631_companion_radio_usb-v1.7.1-165fb33.zip -p /dev/ttyACM0 -b 115200 --singlebank --touch 1200picocom. To install picocom, run the following command:
sudo apt install picocom
picocom -b 115200 /dev/ttyUSB0 --imap lfcrlf
"},{"location":"faq/#514-q-are-there-projects-built-around-meshcore","title":"5.14. Q: Are there projects built around MeshCore?","text":"
"},{"location":"faq/#6-troubleshooting","title":"6. Troubleshooting","text":""},{"location":"faq/#61-q-my-client-says-another-client-or-a-repeater-or-a-room-server-was-last-seen-many-many-days-ago","title":"6.1. Q: My client says another client or a repeater or a room server was last seen many, many days ago.","text":""},{"location":"faq/#62-q-a-repeater-or-a-client-or-a-room-server-i-expect-to-see-on-my-discover-list-on-t-deck-or-contact-list-on-a-smart-device-client-are-not-listed","title":"6.2. Q: A repeater or a client or a room server I expect to see on my discover list (on T-Deck) or contact list (on a smart device client) are not listed.","text":"
time command in the USB serial console with the server device connected.123456
flash_erase*.uf2 file for your device on https://flasher.meshcore.io
Flash_erase-nRF32_softdevice_v6.uf2Flash_erase-nRF52_softdevice_v7.uf2Console and select the serial port for your connected deviceNetworkError: Failed to execute 'open' on 'SerialPort': Failed to open serial port.# setfacl -m u:YOUR_USER_HERE:rw /dev/ttyUSB0
"},{"location":"faq/#711-q-can-i-update-seeed-studio-wio-tracker-l1-pro-using-ota","title":"7.1.1 Q: Can I update Seeed Studio Wio Tracker L1 Pro using OTA?","text":"nrf dfu, the app's full name is nRF Device Firmware Updatestart ota and hit enter.OK to confirm the repeater device is now in OTA modeSettings in the top-right cornerPacket receipt notifications, and change Number of Packets to 10 for RAK, 8 for T114. 8 also works for RAK.OTA on the device againForce Scanning in the DFU appUpload to begin OTA update
start ota instruction and start the update using the DFU app.
"},{"location":"faq/#73-q-is-there-a-way-to-lower-the-chance-of-a-failed-ota-device-firmware-update-dfu","title":"7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?","text":"Heltec_v3_repeater-v1.6.2-4449fd3.bin, no \"merged\" in the file name).start ota and hit enter.OK to confirm the repeater device is now in OTA mode.start ota on an ESP32-based device starts a Wi-Fi hotspot named MeshCore OTA.che aporeps has an enhanced OTA DFU bootloader for nRF52 based devices. With this bootloader, if it detects that the application firmware is invalid, it falls back to OTA DFU mode so you can attempt to flash again to recover. This bootloader has other changes to make the OTA DFU process more fault tolerant.
"},{"location":"faq/#74-q-are-the-meshcore-logo-and-font-available","title":"7.4. Q: Are the MeshCore logo and font available?","text":"meshcore://channel/add?name=<name>&secret=<secret>meshcore://contact/add?name=<name>&public_key=<secret>&type=<type>&type is:
"},{"location":"faq/#76-q-how-do-i-connect-to-the-companion-via-wi-fi-eg-using-a-heltec-v3","title":"7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?","text":"chat = 1repeater = 2room = 3sensor = 4./variants/heltec_v3/platformio.ini and then flash it to your device.set tx. You can get their current value using command line command get txRAK_4631_repeater_ethernet - Repeater with Ethernet CLI access - RAK_4631_room_server_ethernet - Room server with Ethernet CLI access - RAK_4631_companion_radio_ethernet - Companion radio over Ethernet (replaces BLE)nc <ip> 23 or PuTTY in raw mode). This gives you the same CLI available over serial/USB. - For companion radio firmware, the Ethernet interface replaces BLE as the transport to companion apps. Connect on TCP port 5000 (same as the WiFi companion radio). - Use the eth.status CLI command to check connection status and see the assigned IP address.0xC0 FEND Frame delimiter 0xDB FESC Escape character 0xDC TFEND Escaped FEND (FESC + TFEND = 0xC0) 0xDD TFESC Escaped FESC (FESC + TFESC = 0xDB)
"},{"location":"kiss_modem_protocol/#type-byte","title":"Type Byte","text":"\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 FEND \u2502 Type Byte \u2502 Data (escaped)\u2502 FEND \u2502\n\u2502 0xC0 \u2502 1 byte \u2502 0-510 bytes \u2502 0xC0 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n0x00 Raw packet Queue packet for transmission (one pending at a time) TXDELAY 0x01 Delay (1 byte) Transmitter keyup delay in 10ms units (default: 50 = 500ms) Persistence 0x02 P (1 byte) CSMA persistence parameter 0-255 (default: 63) SlotTime 0x03 Interval (1 byte) CSMA slot interval in 10ms units (default: 10 = 100ms) TXtail 0x04 Delay (1 byte) Post-TX hold time in 10ms units (default: 0) FullDuplex 0x05 Mode (1 byte) 0 = half duplex, nonzero = full duplex (default: 0) SetHardware 0x06 Sub-command + data MeshCore extensions (see below) Return 0xFF - Exit KISS mode (no-op)"},{"location":"kiss_modem_protocol/#tnc-to-host","title":"TNC to Host","text":"Type Value Data Description Data 0x00 Raw packet Received packet from radio loop() never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses.
"},{"location":"kiss_modem_protocol/#request-sub-commands-host-to-tnc","title":"Request Sub-commands (Host to TNC)","text":"Sub-command Value Data GetIdentity \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 FEND \u2502 0x06 \u2502 Sub-command \u2502 Data (escaped)\u2502 FEND \u2502\n\u2502 0xC0 \u2502 \u2502 1 byte \u2502 variable \u2502 0xC0 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n0x01 - GetRandom 0x02 Length (1 byte, 1-64) VerifySignature 0x03 PubKey (32) + Signature (64) + Data SignData 0x04 Data to sign EncryptData 0x05 Key (32) + Plaintext DecryptData 0x06 Key (32) + MAC (2) + Ciphertext KeyExchange 0x07 Remote PubKey (32) Hash 0x08 Data to hash SetRadio 0x09 Freq (4) + BW (4) + SF (1) + CR (1) SetTxPower 0x0A Power dBm (1) GetRadio 0x0B - GetTxPower 0x0C - GetCurrentRssi 0x0D - IsChannelBusy 0x0E - GetAirtime 0x0F Packet length (1) GetNoiseFloor 0x10 - GetVersion 0x11 - GetStats 0x12 - GetBattery 0x13 - GetMCUTemp 0x14 - GetSensors 0x15 Permissions (1) GetDeviceName 0x16 - Ping 0x17 - Reboot 0x18 - SetSignalReport 0x19 Enable (1): 0x00=disable, nonzero=enable GetSignalReport 0x1A -"},{"location":"kiss_modem_protocol/#response-sub-commands-tnc-to-host","title":"Response Sub-commands (TNC to Host)","text":"response = command | 0x80. Generic and unsolicited responses use the 0xF0+ range.0x81 PubKey (32) Random 0x82 Random bytes (1-64) Verify 0x83 Result (1): 0x00=invalid, 0x01=valid Signature 0x84 Signature (64) Encrypted 0x85 MAC (2) + Ciphertext Decrypted 0x86 Plaintext SharedSecret 0x87 Shared secret (32) Hash 0x88 SHA-256 hash (32) Radio 0x8B Freq (4) + BW (4) + SF (1) + CR (1) TxPower 0x8C Power dBm (1) CurrentRssi 0x8D RSSI dBm (1, signed) ChannelBusy 0x8E Result (1): 0x00=clear, 0x01=busy Airtime 0x8F Milliseconds (4) NoiseFloor 0x90 dBm (2, signed) Version 0x91 Version (1) + Reserved (1) Stats 0x92 RX (4) + TX (4) + Errors (4) Battery 0x93 Millivolts (2) MCUTemp 0x94 Temperature (2, signed) Sensors 0x95 CayenneLPP payload DeviceName 0x96 Name (variable, UTF-8) Pong 0x97 - SignalReport 0x9A Status (1): 0x00=disabled, 0x01=enabled OK 0xF0 - Error 0xF1 Error code (1) TxDone 0xF8 Result (1): 0x00=failed, 0x01=success RxMeta 0xF9 SNR (1) + RSSI (1)"},{"location":"kiss_modem_protocol/#error-codes","title":"Error Codes","text":"Code Value Description InvalidLength 0x01 Request data too short InvalidParam 0x02 Invalid parameter value NoCallback 0x03 Feature not available MacFailed 0x04 MAC verification failed UnknownCmd 0x05 Unknown sub-command EncryptFailed 0x06 Encryption failed TxBusy 0x07 Radio TX busy, or host output queue full"},{"location":"kiss_modem_protocol/#unsolicited-events","title":"Unsolicited Events","text":"NoCallback error if the board does not support temperature readings.OK response, flushes serial, then reboots the device. The host should expect the connection to drop.0x01 Base (battery) 1 0x02 Location (GPS) 2 0x04 Environment (temp, humidity, pressure) 0x07 for all permissions.
"},{"location":"nrf52_power_management/","title":"nRF52 Power Management","text":""},{"location":"nrf52_power_management/#overview","title":"Overview","text":"
"},{"location":"nrf52_power_management/#voltage-wake-lpcomp-vbus","title":"Voltage Wake (LPCOMP + VBUS)","text":"
"},{"location":"nrf52_power_management/#early-boot-register-capture","title":"Early Boot Register Capture","text":"
"},{"location":"nrf52_power_management/#shutdown-reason-tracking","title":"Shutdown Reason Tracking","text":"xiao_nrf52) Yes Yes Yes RAK4631 (rak4631) Yes Yes Yes Heltec T114 (heltec_t114) Yes Yes Yes GAT562 Mesh Watch13 Yes Yes Yes Promicro nRF52840 No No No RAK WisMesh Tag No No No Heltec Mesh Solar No No No LilyGo T-Echo / T-Echo Lite No No No SenseCAP Solar Yes Yes Yes WIO Tracker L1 / L1 E-Ink No No No WIO WM1110 No No No Mesh Pocket No No No Nano G2 Ultra No No No ThinkNode M1/M3/M6 No No No T1000-E No No No Ikoka Nano/Stick/Handheld (nRF) No No No Keepteen LT1 No No No Minewsemi ME25LS01 No No No NRF52Board base class in src/helpers/NRF52Board.cpp. Board variants provide hardware-specific configuration via a PowerMgtConfig struct and override initiateShutdown(uint8_t reason) to perform board-specific power-down work and conditionally enable voltage wake (LPCOMP + VBUS).NRF52Board.cpp captures the RESETREAS and GPREGRET2 registers before: - SystemInit() (priority 102) - which clears RESETREAS - Static C++ constructors (default priority 65535)
ini -D NRF52_POWER_MANAGEMENTc #define PWRMGT_VOLTAGE_BOOTLOCK 3300 // Won't boot below this voltage (mV) #define PWRMGT_LPCOMP_AIN 7 // AIN channel for voltage sensing #define PWRMGT_LPCOMP_REFSEL 2 // REFSEL (0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) if (enable_lpcomp) {\n configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel);\n }\n\n enterSystemOff(reason);\npowerOff() remains board-specific. Power management only arms LPCOMP for automated shutdown reasons (boot protection/low voltage).
"},{"location":"nrf52_power_management/#voltage-wake-configuration","title":"Voltage Wake Configuration","text":"cpp #ifdef NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; #endifconfigureVoltageWake() is used. This requires USB VBUS to be routed to the nRF52 (typical on nRF52840 boards with native USB).VBAT_threshold \u2248 (VDD * fraction) * divider_scale, where divider_scale = (Rtop + Rbottom) / Rbottom (e.g., 2.0 for 1M/1M, 2.5 for 1.5M/1M, 3.0 for XIAO).sd_power_* functions - When SD disabled: Direct register access (NRF_POWER->*)get pwrmgt.support Returns \"supported\" or \"unsupported\" get pwrmgt.source Returns current power source - \"battery\" or \"external\" (5V/USB power) get pwrmgt.bootreason Returns reset and shutdown reason strings get pwrmgt.bootmv Returns boot voltage in millivolts get pwrmgt.support return:
"},{"location":"nrf52_power_management/#debug-output","title":"Debug Output","text":"ERROR: Power management not supported\nMESH_DEBUG=1 is enabled, the power management module outputs:
"},{"location":"nrf52_power_management/#phase-2-planned","title":"Phase 2 (Planned)","text":"DEBUG: PWRMGT: Reset = Wake from LPCOMP (0x20000); Shutdown = Low Voltage (0x4C)\nDEBUG: PWRMGT: Boot voltage = 3450 mV (threshold = 3300 mV)\nDEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)\n
"},{"location":"nrf52_power_management/#references","title":"References","text":"
"},{"location":"number_allocations/","title":"Number Allocations","text":"PAYLOAD_TYPE_GRP_DATA payloads have a 16-bit data-type field, which identifies which application the packet is for.
"},{"location":"packet_format/#version-1-packet-format","title":"Version 1 Packet Format","text":"0xYY indicates YY in hex notation.0bYY indicates YY in binary notation.0000000XX0000000[header][transport_codes(optional)][path_length][path][payload]\n
"},{"location":"packet_format/#packet-format_1","title":"Packet Format","text":"Field Size (bytes) Description header 1 Contains routing type, payload type, and payload version transport_codes 4 (optional) 2x 16-bit transport codes (if ROUTE_TYPE_TRANSPORT_*) path_length 1 Encodes path hash size in bits 6-7 and hop count in bits 0-5 path up to 64 (
0bVVPPPPRR - V=Version - P=PayloadType - R=RouteType
0x00/0b00 - ROUTE_TYPE_TRANSPORT_FLOOD - Flood Routing + Transport Codes0x01/0b01 - ROUTE_TYPE_FLOOD - Flood Routing0x02/0b10 - ROUTE_TYPE_DIRECT - Direct Routing0x03/0b11 - ROUTE_TYPE_TRANSPORT_DIRECT - Direct Routing + Transport Codes
0x00/0b0000 - PAYLOAD_TYPE_REQ - Request (destination/source hashes + MAC)0x01/0b0001 - PAYLOAD_TYPE_RESPONSE - Response to REQ or ANON_REQ0x02/0b0010 - PAYLOAD_TYPE_TXT_MSG - Plain text message0x03/0b0011 - PAYLOAD_TYPE_ACK - Acknowledgment0x04/0b0100 - PAYLOAD_TYPE_ADVERT - Node advertisement0x05/0b0101 - PAYLOAD_TYPE_GRP_TXT - Group text message (unverified)0x06/0b0110 - PAYLOAD_TYPE_GRP_DATA - Group datagram (unverified)0x07/0b0111 - PAYLOAD_TYPE_ANON_REQ - Anonymous request0x08/0b1000 - PAYLOAD_TYPE_PATH - Returned path0x09/0b1001 - PAYLOAD_TYPE_TRACE - Trace a path, collecting SNR for each hop0x0A/0b1010 - PAYLOAD_TYPE_MULTIPART - Packet is part of a sequence of packets0x0B/0b1011 - PAYLOAD_TYPE_CONTROL - Control packet data (unencrypted)0x0C/0b1100 - reserved0x0D/0b1101 - reserved0x0E/0b1110 - reserved0x0F/0b1111 - PAYLOAD_TYPE_RAW_CUSTOM - Custom packet (raw bytes, custom encryption)
0x00/0b00 - v1 - 1-byte src/dest hashes, 2-byte MAC0x01/0b01 - v2 - Future version (e.g., 2-byte hashes, 4-byte MAC)0x02/0b10 - v3 - Future version0x03/0b11 - v4 - Future versiontransport_codes - 4 bytes (optional)
ROUTE_TYPE_TRANSPORT_FLOOD and ROUTE_TYPE_TRANSPORT_DIRECTtransport_code_1 - 2 bytes - uint16_t - calculated from region scopetransport_code_2 - 2 bytes - uint16_t - reservedpath_length - 1 byte - Encoded path metadata
0-63)
0b00: 1-byte path hashes0b01: 2-byte path hashes0b10: 3-byte path hashes0b11: reserved / unsupportedpath - hop_count * hash_size bytes - Path to use for Direct Routing or flood path tracking
MAX_PATH_SIZEpath_lengthpayload - variable length - Payload Data
MAX_PACKET_PAYLOADpayload sizes larger than 184MAX_PATH_SIZE) Stores hop_count * hash_size bytes of path data if applicable payload up to 184 (MAX_PACKET_PAYLOAD) Data for the provided Payload Type 0x03 Route Type Flood, Direct, etc 2-5 0x3C Payload Type Request, Response, ACK, etc 6-7 0xC0 Payload Version Versioning of the payload format"},{"location":"packet_format/#route-types","title":"Route Types","text":"Value Name Description 0x00 ROUTE_TYPE_TRANSPORT_FLOOD Flood Routing + Transport Codes 0x01 ROUTE_TYPE_FLOOD Flood Routing 0x02 ROUTE_TYPE_DIRECT Direct Routing 0x03 ROUTE_TYPE_TRANSPORT_DIRECT Direct Routing + Transport Codes"},{"location":"packet_format/#path-length-encoding","title":"Path Length Encoding","text":"path_length is not a raw byte count. It packs both hash size and hop count:0-63) 6-7 Hash Size Code Stored as hash_size - 1 0b00 1 byte Legacy / default mode 0b01 2 bytes Supported in current firmware 0b10 3 bytes Supported in current firmware 0b11 4 bytes Reserved / invalid
"},{"location":"packet_format/#payload-types","title":"Payload Types","text":"Value Name Description 0x00: zero-hop packet, no path bytes0x05: 5 hops using 1-byte hashes, so path is 5 bytes0x45: 5 hops using 2-byte hashes, so path is 10 bytes0x8A: 10 hops using 3-byte hashes, so path is 30 bytes0x00 PAYLOAD_TYPE_REQ Request (destination/source hashes + MAC) 0x01 PAYLOAD_TYPE_RESPONSE Response to REQ or ANON_REQ 0x02 PAYLOAD_TYPE_TXT_MSG Plain text message 0x03 PAYLOAD_TYPE_ACK Acknowledgment 0x04 PAYLOAD_TYPE_ADVERT Node advertisement 0x05 PAYLOAD_TYPE_GRP_TXT Group text message (unverified) 0x06 PAYLOAD_TYPE_GRP_DATA Group datagram (unverified) 0x07 PAYLOAD_TYPE_ANON_REQ Anonymous request 0x08 PAYLOAD_TYPE_PATH Returned path 0x09 PAYLOAD_TYPE_TRACE Trace a path, collecting SNR for each hop 0x0A PAYLOAD_TYPE_MULTIPART Packet is part of a sequence of packets 0x0B PAYLOAD_TYPE_CONTROL Control packet data (unencrypted) 0x0C reserved reserved 0x0D reserved reserved 0x0E reserved reserved 0x0F PAYLOAD_TYPE_RAW_CUSTOM Custom packet (raw bytes, custom encryption)"},{"location":"packet_format/#payload-versions","title":"Payload Versions","text":"Value Version Description 0x00 1 1-byte src/dest hashes, 2-byte MAC 0x01 2 Future version (e.g., 2-byte hashes, 4-byte MAC) 0x02 3 Future version 0x03 4 Future version"},{"location":"payloads/","title":"Payload Format","text":"
"},{"location":"payloads/#node-advertisement","title":"Node advertisement","text":"0x01 is chat node advert is for a chat node 0x02 is repeater advert is for a repeater 0x03 is room server advert is for a room server 0x04 is sensor advert is for a sensor server 0x10 has location appdata contains lat/long information 0x20 has feature 1 Reserved for future use. 0x40 has feature 2 Reserved for future use. 0x80 has name appdata contains a node name"},{"location":"payloads/#acknowledgement","title":"Acknowledgement","text":"BaseChatMesh, the current request type values are:0x01 get stats get stats of repeater or room server 0x02 keepalive keep-alive request used for maintained connections"},{"location":"payloads/#get-stats","title":"Get stats","text":"
"},{"location":"payloads/#get-telemetry-data","title":"Get telemetry data","text":"BaseChatMesh. Sensor- and application-specific request payloads may be implemented by higher-level firmware.BaseChatMesh.BaseChatMesh.BaseChatMesh.BaseChatMesh.BaseChatMesh.0x00 plain text message the plain text of the message 0x01 CLI command the command text of the message 0x02 signed plain text message first four bytes is sender pubkey prefix, followed by plain text message"},{"location":"payloads/#anonymous-request","title":"Anonymous request","text":"Field Size (bytes) Description destination hash 1 first byte of destination node public key public key 32 sender's Ed25519 public key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details"},{"location":"payloads/#room-server-login","title":"Room server login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) sync timestamp 4 sender's \"sync messages SINCE x\" timestamp password rest of message password for room"},{"location":"payloads/#repeatersensor-login","title":"Repeater/Sensor login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) password rest of message password for repeater/sensor"},{"location":"payloads/#repeater-regions-request","title":"Repeater - Regions request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x01 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-owner-info-request","title":"Repeater - Owner info request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x02 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-clock-and-status-request","title":"Repeater - Clock and status request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x03 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#group-text-message","title":"Group text message","text":"Field Size (bytes) Description channel hash 1 first byte of SHA256 of channel's shared key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details 0x00 because it is a \"plain text message\". The message will be of the form <sender name>: <message body> (eg., user123: I'm on my way).meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72\n
"},{"location":"qr_codes/#add-contact","title":"Add Contact","text":"name: Channel name (URL-encoded)secret: 16-byte secret represented as 32 hex charactersregion_scope: Region Scope (optional, URL-encoded if provided)
meshcore://contact/add?name=Example+Contact&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1&type=1\n
"},{"location":"stats_binary_frames/","title":"Stats Binary Frame Structures","text":"name: Contact name (URL-encoded if needed)public_key: 32-byte public key represented as 64 hex characterstype: numeric contact type
1: Companion2: Repeater3: Room Server4: SensorCMD_GET_STATS 56 Get statistics (2-byte command: code + sub-type)"},{"location":"stats_binary_frames/#stats-sub-types","title":"Stats Sub-Types","text":"CMD_GET_STATS command uses a 2-byte frame structure: - Byte 0: CMD_GET_STATS (56) - Byte 1: Stats sub-type: - STATS_TYPE_CORE (0) - Get core device statistics - STATS_TYPE_RADIO (1) - Get radio statistics - STATS_TYPE_PACKETS (2) - Get packet statisticsRESP_CODE_STATS 24 Statistics response (2-byte response: code + sub-type)"},{"location":"stats_binary_frames/#stats-response-sub-types","title":"Stats Response Sub-Types","text":"RESP_CODE_STATS response uses a 2-byte header structure: - Byte 0: RESP_CODE_STATS (24) - Byte 1: Stats sub-type (matches command sub-type): - STATS_TYPE_CORE (0) - Core device statistics response - STATS_TYPE_RADIO (1) - Radio statistics response - STATS_TYPE_PACKETS (2) - Packet statistics response0x18 (24) - 1 1 uint8_t stats_type Always 0x00 (STATS_TYPE_CORE) - 2 2 uint16_t battery_mv Battery voltage in millivolts 0 - 65,535 4 4 uint32_t uptime_secs Device uptime in seconds 0 - 4,294,967,295 8 2 uint16_t errors Error flags bitmask - 10 1 uint8_t queue_len Outbound packet queue length 0 - 255"},{"location":"stats_binary_frames/#example-structure-cc","title":"Example Structure (C/C++)","text":"
"},{"location":"stats_binary_frames/#resp_code_stats-stats_type_radio-24-1","title":"RESP_CODE_STATS + STATS_TYPE_RADIO (24, 1)","text":"struct StatsCore {\n uint8_t response_code; // 0x18\n uint8_t stats_type; // 0x00 (STATS_TYPE_CORE)\n uint16_t battery_mv;\n uint32_t uptime_secs;\n uint16_t errors;\n uint8_t queue_len;\n} __attribute__((packed));\n0x18 (24) - 1 1 uint8_t stats_type Always 0x01 (STATS_TYPE_RADIO) - 2 2 int16_t noise_floor Radio noise floor in dBm -140 to +10 4 1 int8_t last_rssi Last received signal strength in dBm -128 to +127 5 1 int8_t last_snr SNR scaled by 4 Divide by 4.0 for dB 6 4 uint32_t tx_air_secs Cumulative transmit airtime in seconds 0 - 4,294,967,295 10 4 uint32_t rx_air_secs Cumulative receive airtime in seconds 0 - 4,294,967,295"},{"location":"stats_binary_frames/#example-structure-cc_1","title":"Example Structure (C/C++)","text":"
"},{"location":"stats_binary_frames/#resp_code_stats-stats_type_packets-24-2","title":"RESP_CODE_STATS + STATS_TYPE_PACKETS (24, 2)","text":"struct StatsRadio {\n uint8_t response_code; // 0x18\n uint8_t stats_type; // 0x01 (STATS_TYPE_RADIO)\n int16_t noise_floor;\n int8_t last_rssi;\n int8_t last_snr; // Divide by 4.0 to get actual SNR in dB\n uint32_t tx_air_secs;\n uint32_t rx_air_secs;\n} __attribute__((packed));\nrecv_errors)0x18 (24) - 1 1 uint8_t stats_type Always 0x02 (STATS_TYPE_PACKETS) - 2 4 uint32_t recv Total packets received 0 - 4,294,967,295 6 4 uint32_t sent Total packets sent 0 - 4,294,967,295 10 4 uint32_t flood_tx Packets sent via flood routing 0 - 4,294,967,295 14 4 uint32_t direct_tx Packets sent via direct routing 0 - 4,294,967,295 18 4 uint32_t flood_rx Packets received via flood routing 0 - 4,294,967,295 22 4 uint32_t direct_rx Packets received via direct routing 0 - 4,294,967,295 26 4 uint32_t recv_errors Receive/CRC errors (RadioLib); present only in 30-byte frame 0 - 4,294,967,295"},{"location":"stats_binary_frames/#notes","title":"Notes","text":"
"},{"location":"stats_binary_frames/#example-structure-cc_2","title":"Example Structure (C/C++)","text":"recv = flood_rx + direct_rxsent = flood_tx + direct_txrecv_errors at offset 26.
"},{"location":"stats_binary_frames/#command-usage-example-python","title":"Command Usage Example (Python)","text":"struct StatsPackets {\n uint8_t response_code; // 0x18\n uint8_t stats_type; // 0x02 (STATS_TYPE_PACKETS)\n uint32_t recv;\n uint32_t sent;\n uint32_t flood_tx;\n uint32_t direct_tx;\n uint32_t flood_rx;\n uint32_t direct_rx;\n uint32_t recv_errors; // present when frame size is 30\n} __attribute__((packed));\n
"},{"location":"stats_binary_frames/#response-parsing-example-python","title":"Response Parsing Example (Python)","text":"# Send CMD_GET_STATS command\ndef send_get_stats_core(serial_interface):\n \"\"\"Send command to get core stats\"\"\"\n cmd = bytes([56, 0]) # CMD_GET_STATS (56) + STATS_TYPE_CORE (0)\n serial_interface.write(cmd)\n\ndef send_get_stats_radio(serial_interface):\n \"\"\"Send command to get radio stats\"\"\"\n cmd = bytes([56, 1]) # CMD_GET_STATS (56) + STATS_TYPE_RADIO (1)\n serial_interface.write(cmd)\n\ndef send_get_stats_packets(serial_interface):\n \"\"\"Send command to get packet stats\"\"\"\n cmd = bytes([56, 2]) # CMD_GET_STATS (56) + STATS_TYPE_PACKETS (2)\n serial_interface.write(cmd)\n
"},{"location":"stats_binary_frames/#command-usage-example-javascripttypescript","title":"Command Usage Example (JavaScript/TypeScript)","text":"import struct\n\ndef parse_stats_core(frame):\n \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_CORE frame (11 bytes)\"\"\"\n response_code, stats_type, battery_mv, uptime_secs, errors, queue_len = \\\n struct.unpack('<B B H I H B', frame)\n assert response_code == 24 and stats_type == 0, \"Invalid response type\"\n return {\n 'battery_mv': battery_mv,\n 'uptime_secs': uptime_secs,\n 'errors': errors,\n 'queue_len': queue_len\n }\n\ndef parse_stats_radio(frame):\n \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_RADIO frame (14 bytes)\"\"\"\n response_code, stats_type, noise_floor, last_rssi, last_snr, tx_air_secs, rx_air_secs = \\\n struct.unpack('<B B h b b I I', frame)\n assert response_code == 24 and stats_type == 1, \"Invalid response type\"\n return {\n 'noise_floor': noise_floor,\n 'last_rssi': last_rssi,\n 'last_snr': last_snr / 4.0, # Unscale SNR\n 'tx_air_secs': tx_air_secs,\n 'rx_air_secs': rx_air_secs\n }\n\ndef parse_stats_packets(frame):\n \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_PACKETS frame (26 or 30 bytes)\"\"\"\n assert len(frame) >= 26, \"STATS_TYPE_PACKETS frame too short\"\n response_code, stats_type, recv, sent, flood_tx, direct_tx, flood_rx, direct_rx = \\\n struct.unpack('<B B I I I I I I', frame[:26])\n assert response_code == 24 and stats_type == 2, \"Invalid response type\"\n result = {\n 'recv': recv,\n 'sent': sent,\n 'flood_tx': flood_tx,\n 'direct_tx': direct_tx,\n 'flood_rx': flood_rx,\n 'direct_rx': direct_rx\n }\n if len(frame) >= 30:\n (recv_errors,) = struct.unpack('<I', frame[26:30])\n result['recv_errors'] = recv_errors\n return result\n
"},{"location":"stats_binary_frames/#response-parsing-example-javascripttypescript","title":"Response Parsing Example (JavaScript/TypeScript)","text":"// Send CMD_GET_STATS command\nconst CMD_GET_STATS = 56;\nconst STATS_TYPE_CORE = 0;\nconst STATS_TYPE_RADIO = 1;\nconst STATS_TYPE_PACKETS = 2;\n\nfunction sendGetStatsCore(serialInterface: SerialPort): void {\n const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_CORE]);\n serialInterface.write(cmd);\n}\n\nfunction sendGetStatsRadio(serialInterface: SerialPort): void {\n const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_RADIO]);\n serialInterface.write(cmd);\n}\n\nfunction sendGetStatsPackets(serialInterface: SerialPort): void {\n const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_PACKETS]);\n serialInterface.write(cmd);\n}\n
"},{"location":"stats_binary_frames/#field-size-considerations","title":"Field Size Considerations","text":"interface StatsCore {\n battery_mv: number;\n uptime_secs: number;\n errors: number;\n queue_len: number;\n}\n\ninterface StatsRadio {\n noise_floor: number;\n last_rssi: number;\n last_snr: number;\n tx_air_secs: number;\n rx_air_secs: number;\n}\n\ninterface StatsPackets {\n recv: number;\n sent: number;\n flood_tx: number;\n direct_tx: number;\n flood_rx: number;\n direct_rx: number;\n recv_errors?: number; // present when frame is 30 bytes\n}\n\nfunction parseStatsCore(buffer: ArrayBuffer): StatsCore {\n const view = new DataView(buffer);\n const response_code = view.getUint8(0);\n const stats_type = view.getUint8(1);\n if (response_code !== 24 || stats_type !== 0) {\n throw new Error('Invalid response type');\n }\n return {\n battery_mv: view.getUint16(2, true),\n uptime_secs: view.getUint32(4, true),\n errors: view.getUint16(8, true),\n queue_len: view.getUint8(10)\n };\n}\n\nfunction parseStatsRadio(buffer: ArrayBuffer): StatsRadio {\n const view = new DataView(buffer);\n const response_code = view.getUint8(0);\n const stats_type = view.getUint8(1);\n if (response_code !== 24 || stats_type !== 1) {\n throw new Error('Invalid response type');\n }\n return {\n noise_floor: view.getInt16(2, true),\n last_rssi: view.getInt8(4),\n last_snr: view.getInt8(5) / 4.0, // Unscale SNR\n tx_air_secs: view.getUint32(6, true),\n rx_air_secs: view.getUint32(10, true)\n };\n}\n\nfunction parseStatsPackets(buffer: ArrayBuffer): StatsPackets {\n const view = new DataView(buffer);\n if (buffer.byteLength < 26) {\n throw new Error('STATS_TYPE_PACKETS frame too short');\n }\n const response_code = view.getUint8(0);\n const stats_type = view.getUint8(1);\n if (response_code !== 24 || stats_type !== 2) {\n throw new Error('Invalid response type');\n }\n const result: StatsPackets = {\n recv: view.getUint32(2, true),\n sent: view.getUint32(6, true),\n flood_tx: view.getUint32(10, true),\n direct_tx: view.getUint32(14, true),\n flood_rx: view.getUint32(18, true),\n direct_rx: view.getUint32(22, true)\n };\n if (buffer.byteLength >= 30) {\n result.recv_errors = view.getUint32(26, true);\n }\n return result;\n}\n
"},{"location":"terminal_chat_cli/","title":"Terminal Chat CLI","text":"set freq {frequency}\nset tx {tx-power-dbm}\nset name {name}\nset lat {latitude}\nset lon {longitude}\nset dutycycle {percent}\nset dutycycle 10 for 10%.set af {air-time-factor}\nset dutycycle instead.time {epoch-secs}\nadvert\nclock\nver\ncard\nimport {card}\nlist {n}\nto\nto {name-prefix}\nsend {text}\nreset path\npublic {text}\nrefactor/nearby-nodes (zmergowany do main) Plik \u017ar\u00f3d\u0142owy: NearbyScreen.h Status: zaimplementowane \u2014 dokument zachowany jako zapis analizy/decyzji.LEFT/RIGHT po typie, wi\u0119c filtr zosta\u0142 wy\u0142\u0105cznie na li\u015bcie (sekcja 3.1 zak\u0142ada\u0142a Filter\u2026 te\u017c w menu). - Sort nie jest togglem przez Enter, lecz zmienia si\u0119 in-place przez LEFT/RIGHT na pod\u015bwietlonym wierszu w popupie (wzorzec ustawie\u0144 Trail), a wiersz pojawia si\u0119 tylko dla \u017ar\u00f3d\u0142a Zapisane (skan nie ma dystansu). - Filtr i sort utrzymuj\u0105 si\u0119 mi\u0119dzy wej\u015bciami na ekran (nie s\u0105 resetowane).NearbyScreen\n\u251c\u2500\u2500 LIST (kontakty zapisane w mesh) \u2190 tryb domy\u015blny\n\u2502 \u251c\u2500\u2500 filtr cyklowany LEFT/RIGHT (7 stan\u00f3w)\n\u2502 \u251c\u2500\u2500 DETAIL (Enter) \u2192 Lat/Lon/Dist/Type/Seen\n\u2502 \u2502 \u2514\u2500\u2500 _opts popup (Hold Enter): Navigate / Ping / Save waypoint\n\u2502 \u2502 \u2514\u2500\u2500 _ping_menu popup\n\u2502 \u251c\u2500\u2500 NAV view (pe\u0142noekranowa nawigacja)\n\u2502 \u2514\u2500\u2500 _ctx_menu popup (Hold Enter): Discover / Navigate / Save waypoint\n\u2502\n\u2514\u2500\u2500 DISCOVER (skan na \u017cywo NODE_DISCOVER_REQ) \u2190 osobny pod-ekran\n \u251c\u2500\u2500 lista wynik\u00f3w (karty 2-liniowe)\n \u2502 Hold Enter = ponowny skan (brak menu!)\n \u2514\u2500\u2500 DETAIL (Enter) \u2192 pubkey / RSSI / SNR / status\n \u2514\u2500\u2500 _ping_menu popup (Hold Enter = od razu ping, bez Options)\n_detail, _nav, _discover_mode, _ddetail, _pinging, _filter, dwa komplety _sel/_scroll, trzy bufory wynik\u00f3w ping\u2026) i trzy instancje PopupMenu (_ctx_menu, _opts, _ping_menu).LEFT/RIGHT przewija 7 stan\u00f3w:Fav \u00b7 ALL \u00b7 Comp \u00b7 Rpt \u00b7 Room \u00b7 Snsr \u00b7 TIME\nFav filtr ulubionych (flaga ci.flags & 1) ALL/Comp/Rpt/Room/Snsr filtr po typie w\u0119z\u0142a TIME sortowanie (po lastmod zamiast po dystansie) TIME jako \u201efiltr\" jest myl\u0105ce \u2014 zmienia kolejno\u015b\u0107, nie zawarto\u015b\u0107; - etykieta w nag\u0142\u00f3wku (NEARBY[TIME]) nie m\u00f3wi, \u017ce to sort._ctx_menu) Detail kontaktu (_opts) Detail discover Navigate \u2713 \u2713 \u2014 Save waypoint \u2713 \u2713 \u2014 Ping \u2014 \u2713 \u2713 Discover \u2713 \u2014 \u2014 KEY_CONTEXT_MENU) Lista nearby otwiera menu kontekstowe Detail kontaktu otwiera menu Options Lista discover ponowny skan (\u017cadnego menu) Detail discover od razu Ping (pomija Options) _ping_menu to bespoke widget","text":"UP/DOWN; - przebudowuje si\u0119 w trakcie (rebuildPingMenu) gdy przychodz\u0105 wyniki; - zostaje otwarte po SELECTED (reszta popup\u00f3w si\u0119 zamyka); - ma w\u0142asny handlePingMenuInput z trybem allow_enter_to_open.renderDiscover/handleInputDiscover/renderDiscoverDetail to niemal r\u00f3wnoleg\u0142a kopia logiki listy i szczeg\u00f3\u0142\u00f3w nearby (osobne _dsel, _dscroll, _d_visible, w\u0142asne rysowanie kart). Discover i Nearby robi\u0105 to samo \u2014 pokazuj\u0105 list\u0119 w\u0119z\u0142\u00f3w z mo\u017cliwo\u015bci\u0105 wej\u015bcia w szczeg\u00f3\u0142y i pingowania \u2014 ale dwoma osobnymi \u015bcie\u017ckami kodu.FILTR (jedna o\u015b \u2014 typ w\u0119z\u0142a, z Ulubionymi) SORT (prze\u0142\u0105cznik)\n \u2022 Wszystkie \u2022 Dystans (domy\u015blnie)\n \u2022 Ulubione \u2022 Ostatnio s\u0142yszane\n \u2022 Companion\n \u2022 Repeater\n \u2022 Room\n \u2022 Sensor\nLEFT/RIGHT = szybki cykl tylko po filtrze-typie (jedna sp\u00f3jna o\u015b, znany gest; bez \u201eTIME\" zanieczyszczaj\u0105cego cykl); - Sort = prze\u0142\u0105cznik w menu akcji (Dystans \u2194 Ostatnio s\u0142yszane), trzymany niezale\u017cnie od filtra; - Filter\u2026 dost\u0119pny te\u017c w menu akcji (odkrywalno\u015b\u0107 \u2014 ca\u0142a lista widoczna naraz, nie tylko cyklowanie).NEARBY \u00b7 Rpt \u00b7 \u2193dist.PopupMenu (zamiast _ctx_menu + _opts), pozycje zale\u017cne od kontekstu, ale kolejno\u015b\u0107 i nazwy sta\u0142e:Hold Enter \u2192 Options\n \u2022 Navigate (gdy w\u0119ze\u0142 ma GPS)\n \u2022 Ping (gdy znamy pubkey)\n \u2022 Save waypoint (gdy w\u0119ze\u0142 ma GPS)\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n \u2022 Filter\u2026 (podmenu z 3.1)\n \u2022 Sort\u2026 (toggle z 3.1)\n \u2022 Discover scan (uruchamia skan na \u017cywo = prze\u0142\u0105cza \u017ar\u00f3d\u0142o)\nhas_node), ale nigdy nie zmieniaj\u0105 kolejno\u015bci ani nazw. \u201eHold Enter = menu akcji\" \u2014 bez wyj\u0105tk\u00f3w. Ping zawsze przez Options (znika \u201eod razu Ping\" z detalu Discover); rescan to pozycja menu, nie ukryty Hold Enter.
renderDiscover*, niesp\u00f3jne Hold Enter i po\u0142owa p\u00f3l stanu);DiscoverResult).NODE_DISCOVER_REQ i prze\u0142\u0105cza widok na wyniki) oraz powr\u00f3t do Zapisanych przez Cancel.TIME znika z cyklu; LEFT/RIGHT cykluj\u0105 tylko typ+Fav; sort jako stan + toggle niskie 2 Scal _ctx_menu i _opts w jedno menu akcji o sta\u0142ej kolejno\u015bci; dodaj Filter\u2026/Sort\u2026 niskie 3 Ujednoli\u0107 Hold Enter w Discover (menu zamiast bezpo\u015bredniego rescan/ping; Ping zawsze przez Options) \u015brednie 4 Scal list\u0119 Discover z list\u0105 Nearby w jeden komponent z prze\u0142\u0105cznikiem \u017ar\u00f3d\u0142a wy\u017csze
"},{"location":"design/solo_ui_framework/","title":"Solo UI framework \u2014 a guide for adding features","text":"LEFT/RIGHT = szybki cykl filtra-typu; Sort jako toggle w menu; Filter\u2026 te\u017c w menu dla odkrywalno\u015bci. (Nie chowamy wszystkiego do menu \u2014 zachowujemy szybki gest.)companion_radio solo firmware UI (the ui-new screens). It is not a user manual \u2014 for what each screen does, see solo_features. The goal here is so that adding a new screen or feature means wiring together existing helpers, not reinventing list scrolling, text wrapping, or persistence.examples/companion_radio/ unless a path says otherwise. The screen fragments (ui-new/*.h) are all #included, in order, into one translation unit (ui-new/UITask.cpp) \u2014 so a static inline helper in an earlier header is visible to later ones. Header-include order in UITask.cpp therefore matters; new screens go near the others.UITask.cpp. Some define external-linkage symbols at file scope (e.g. NearbyScreen::FILTER_LABELS), so including a fragment from a second .cpp is a duplicate-symbol link error. Anything genuinely shared across TUs must live in a real header (icons.h, GeoUtils.h, DisplayDriver.h), not a screen fragment.UIScreen (src/helpers/ui/UIScreen.h):class UIScreen {\npublic:\n virtual int render(DisplayDriver& display) = 0; // returns ms until the next render\n virtual bool handleInput(char c) { return false; }\n virtual void poll() { }\n virtual void onShow() { } // reset per-visit state\n};\n
"},{"location":"design/solo_ui_framework/#wiring-a-screen-into-uitask","title":"Wiring a screen into UITask","text":"render() draws one frame and returns how long until it wants to be drawn again, in milliseconds. Return a big number (2000) for a static screen, a small one (50\u2013200) while something animates or a popup is open. This return value is the main lever for the e-ink cost/latency trade-off \u2014 see \u00a79. UITask owns startFrame()/endFrame(); render() must not call them.handleInput(c) gets one key (KEY_*, see \u00a77). Return true if consumed.poll() runs every loop tick regardless of focus \u2014 rare, for background housekeeping (e.g. the shutdown button).onShow() is called by setCurrScreen() every time the screen becomes current \u2014 override it to reset per-visit state (_sel = 0, _dirty = false, sub-views). Default no-op for screens that keep state across visits. Because it's invoked centrally, a navigator can't forget to reset on show.
UIScreen* my_screen; member in UITask.h (near the others).UITask::begin() (UITask.cpp): my_screen = new MyScreen(this, \u2026);onShow() runs inside setCurrScreen): cpp void UITask::gotoMyScreen() { setCurrScreen(my_screen); } Only screens needing a parameter at entry add a typed call after it (e.g. gotoRingtoneEditor \u2192 selectSlot(slot), gotoMapScreen \u2192 showMapView()).ToolsScreen.h (add an Action enum value, a row in the right section table, and a dispatch() case).UITask.h and setCurrScreen() bails on null, so a missed new is an inert no-op rather than a null deref.UITask* task plus whatever it needs (NodePrefs*, KeyboardWidget*, \u2026); the task back-pointer is how a screen calls shared services (_task->showAlert(...), _task->waypoints(), \u2026).DisplayDriver (src/helpers/ui/DisplayDriver.h) abstracts OLED vs e-ink and, crucially, font scale: landscape e-ink renders text at 2\u00d7, so never hard-code pixel sizes \u2014 derive everything from these:getLineHeight() pixel rows per text line (8 at 1\u00d7, 16 at 2\u00d7) lineStep() row pitch = line height + gap; use for row y stepping getCharWidth() / getTextWidth(s) advance width; getTextWidth is font-accurate headerH() / listStart() title-bar height / first content row y listVisible(itemH) how many rows fit below the header valCol() conventional x for a right-hand value column width() / height() panel size in px isEink() true only on landscape e-ink; branch on this, not on pixel counts
"},{"location":"design/solo_ui_framework/#3-lists-drawlist","title":"3. Lists \u2014 drawCenteredHeader(title, menu_hint=false, menu_open=false) \u2014 plain centered title + separator.drawInvertedHeader(label, menu_hint=false, menu_open=false) \u2014 filled title bar (used by detail views).menu_hint: pass true on a screen with a Hold-Enter context menu to reserve a \u2261 glyph (menuHintWidth()/drawContextMenuHint()) in the header, so the menu is discoverable without already knowing the shortcut; menu_open highlights it while the menu is actually up.drawSelectionRow(x, y, w, h, sel) \u2014 the highlight bar behind a list row.drawTextEllipsized(x, y, max_w, str) \u2014 truncates with \u2026; use this for any user string (names, labels) so long/UTF-8 text can't overrun.drawTextCentered(mid_x, y, str).translateUTF8ToBlocks(dst, src, n) \u2014 map UTF-8 to the panel's glyph set for display only. Never run text through it before sending it over the air or storing it (it is lossy) \u2014 see the reply-prefix note in \u00a75.drawList","text":"drawList (ui-new/icons.h) is the workhorse for any scrolling list. It computes the visible window from font metrics, keeps sel in view, reserves the scrollbar column, draws each visible row through your callback, and draws the indicator:drawList(display, count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {\n drawRowSelection(display, y, sel, reserve); // canonical highlight bar\n display.drawTextEllipsized(2, y, display.width() - reserve - 4, items[idx].name);\n});\nreserve is the width the scrollbar took (0 when the list fits) \u2014 subtract it from any right-aligned content so nothing slides under the indicator. The row callback owns its own selection bar: drawRowSelection(d, y, sel, reserve) (ui-new/icons.h) draws the standard one (full row minus reserve, one pixel short); call display.drawSelectionRow() directly only when a row needs a non-standard geometry (full-width, custom height). For the fold-in-place pattern (sections that expand/collapse) use AccordionList instead (ui-new/AccordionList.h) \u2014 same idea, two callbacks (header + item).drawScrollIndicator, \u2026Px) and the reserve calculator (scrollIndicatorReserve) are exposed for hand-laid lists.PopupMenu PopupMenu.h a modal action menu over any screen AccordionList AccordionList.h collapsible sectioned lists (Tools, Settings) KeyboardWidget KeyboardWidget.h on-screen text entry DigitEditor DigitEditor.h scroll-edit one number, digit by digit FullscreenMsgView FullscreenMsgView.h scrollable full-message reader + word wrap NavView NavView.h bearing/distance/ETA \"navigate to a point\" view begin(...) to open, an active flag, a handleInput(c) returning a small Result enum, and a render()/draw(). Typical embedding:if (_menu.active) { // popup eats input while open\n auto r = _menu.handleInput(c);\n if (r == PopupMenu::SELECTED) runAction(_menu.selectedIndex());\n return true;\n}\n...\n_menu.begin(\"Options\", 6); // open it\n_menu.addItem(\"Navigate\"); _menu.addItem(\"Ping\");\n_menu.active = true;\nKeyboardWidget additionally supports placeholders ({loc}, {time}, sensor tokens) via addPlaceholder() / clearPlaceholders(); the shared kbAddSensorPlaceholders() (ui-new/SensorPlaceholders.h) adds only the tokens the board's sensors actually provide. Expand them with expandMsg() at send time.KB_T9_TIMEOUT_MS cycles a cell's letter group, then its digit). Page 0 is no longer hardcoded to Latin: NodePrefs::keyboard_main_alphabet/keyboard_alt_alphabet (Settings > Keyboard's Main/Additional rows) each pick a script \u2014 Latin, Cyrillic, or Greek \u2014 for page 0 and page 1 respectively (KeyboardWidget::mainScript()/ altScript()); equal values collapse to a single script + Symbols (2 pages instead of 3, see hasAltAlphabet()). scriptCellStr()/scriptT9GroupStr() dispatch each script to its own ABC grid (KB_CHARS/KB_CYRILLIC_CHARS/ KB_GREEK_CHARS) and T9 group table (KB_T9_GROUPS/KB_T9_GROUPS_CYRILLIC/ _GREEK) so the two layouts always offer the same letters regardless of which page they're on. Latin-diacritic letters (Polish, Czech, German, etc.) aren't alt-alphabet pages \u2014 they're reached by Hold-Enter on whichever page currently shows Latin instead (see KB_ACCENT_VARIANTS below). Shift is one-shot by default (capitalises the next letter, including whichever candidate a T9 multi-tap cycle settles on) or Hold-Enter to toggle caps-lock; Hold-Clear erases the whole field. UP from the top letter row enters cursor mode (LEFT/RIGHT move the insertion point; UP/DOWN jump to start/end, then \u2014 pressed again once already at that boundary \u2014 continue on to the special row / letter grid, the same destinations the plain grid wrap used to reach directly) so edits/inserts can target any point in the typed text, not just the end; Enter/Cancel exit immediately from anywhere. Hold-Enter on a Latin-page letter cell with accented variants instead opens the accent popup: one horizontal row of KB_ACCENT_VARIANTS[group] (a UTF-8 string per base letter, same shape as a T9 group string), LEFT/RIGHT to pick, Enter to insert via the shared insertGlyph() helper, Cancel to dismiss. Holding a letter with no variants, or any T9/alt-alphabet/symbols cell, is a no-op.FullscreenMsgView::wrapLines() is a standalone pixel-accurate word-wrapper (O(n), variable-width-font aware) reusable by any multi-line layout; it writes into the shared s_wrap_trans / s_wrap_lines scratch (single-threaded render, never held across a yield \u2014 see \u00a79).GeoUtils.h, namespace geo, all pure/header-inline):
haversineKm(lat1,lon1,lat2,lon2), bearingDeg(...), bearingCardinal(deg).fmtDist(buf,n,km,imperial) \u2014 \"850m\"/\"2.3km\" or feet/miles.fmtAgeShort(buf,n,now,ts) \u2014 compact \"12s\"/\"5m\"/\"3h\"/\"2d\" tag, \"\" for unknown. This is the one age formatter \u2014 don't reimplement the s/m/h ladder.parseLatLon(text, lat, lon, label?, n?) \u2014 pull a lat,lon out of message text; reads the [WAY] label if tagged.parseLocShare(text, lat, lon) \u2014 true only for an explicit [LOC] share.LOCATION_MSG_TAG ([LOC], the sender's own live position) and WAYPOINT_MSG_TAG ([WAY], a saved point to share); both stay human-readable on clients that don't know them.TrailStore (Trail.h, GPS breadcrumb ring + GPX export), LiveTrackStore (LiveTrack.h, RAM table of others' [LOC] positions, expiring), WaypointStore (Waypoint.h, persisted saved points). Reach them via the task (_task->trail(), _task->liveTrack(), _task->waypoints()).msgReplyBody(text, nick?, n?) (FullscreenMsgView.h) parses a leading @[nick] reply marker, returning the body and optionally the addressee. Use it instead of re-scanning for @[. The stored/sent prefix is raw UTF-8 (it goes over the air) \u2014 never transliterate it.ui-new/icons.h):MINI_ICON(ICON_FOO, 5,\n packRow(\"..#..\"),\n packRow(\".###.\"),\n packRow(\"#####\"));\nminiIconDraw(display, x, topY, ICON_FOO) (auto-scaled & centered), miniIconDrawTop (exact placement), or the boxed/slot variants (drawBoxedIcon = lit when active, drawSlotIcon = plain). Bigger page glyphs use BIG_ICON / bigIconDraw. The home status bar composes these right-to-left with a blinkOn() cadence for \"leave it on and forget\" broadcasts (auto-advert, Live Share, trail, repeater) \u2014 follow that pattern when adding an indicator: always shown on e-ink, blinking on OLED.HomeScreen::renderBatteryIndicator(), UITask.cpp); once the row runs out of horizontal space the loop just stops, so the lowest-priority icons silently drop first rather than the whole bar crushing the node name. A blinking icon still reserves its width on the off-phase of its blink, so the row's layout can't visibly shift width as icons blink in and out.menu_hint=true to their header call (see \u00a72) so a \u2261 glyph advertises the menu; KEY_CONTEXT_MENU (Hold-Enter) opens it.KEY_* codes in UIScreen.h: KEY_UP/DOWN/LEFT/RIGHT, KEY_ENTER, KEY_CANCEL, KEY_CONTEXT_MENU (the \"Hold Enter\" menu key).keyIsPrev(c) (LEFT or encoder-prev) and keyIsNext(c) (RIGHT or encoder-next). KEY_CANCEL and KEY_CONTEXT_MENU stay screen-specific. Joystick rotation is handled upstream (rotateJoystickKey) \u2014 screens see already-rotated keys.handleInput","text":"MomentaryButton (src/helpers/ui/MomentaryButton.h); UITask::begin() must call begin() on every one (the joystick directions and Back included, not just the user button) \u2014 that sets pinMode and, where enabled, claims the interrupt. UITask::loop() polls each button, maps its event to a KEY_* code, and dispatches it to the current screen.endFrame() blocks the loop for a slow e-ink refresh:
"},{"location":"design/solo_ui_framework/#8-persistence","title":"8. Persistence","text":"-D BUTTON_USE_INTERRUPTS, e-ink boards). A GPIO interrupt latches each press/release edge into a per-button ring buffer with its own timestamp, so taps that land during a refresh aren't lost; check() replays them afterwards. The nRF52 has only 8 GPIOTE channels (the radio takes one) \u2014 if none is free a button silently falls back to polling, so it still works, just without mid-refresh capture.loop() drains all pending events from the buttons into a small key FIFO, applies the whole burst (handleInput per key), then redraws once. So three joystick flicks captured during one refresh move the selection three steps for the cost of a single panel update, instead of collapsing into an ignored multi-click or one-step-per-refresh. Buttons created with multiclick=false therefore emit one discrete CLICK per release; multiclick=true buttons still report double/triple-click.NodePrefs struct (NodePrefs.h), saved via the_mesh.savePrefs() and loaded by DataStore.cpp. Rules when adding a field:
NodePrefs::SCHEMA_SENTINEL. Serialization is binary-positional, so order is the on-disk format; never insert in the middle.rd(...) in DataStore::loadPrefsInt() and a file.write(...) in savePrefs(), in the same position, and clamp on load (an upgrader's file lacks the field and reads stray bytes \u2014 clamp to a sane default). Saves are atomic (temp-file + rename), so a crash mid-save can't corrupt settings._dirty convention: a multi-field editor screen mutates _node_prefs live for instant feedback but only persists once, on exit, gated by a _dirty flag \u2014 so LEFT/RIGHT value-cycling doesn't thrash flash. Set _dirty = true at each edit site, then on the exit path call _task->savePrefsIfDirty(_dirty) (UITask) \u2014 it saves once iff dirty and clears the flag, so every screen's save-on-exit reads the same and the \"did we touch flash?\" decision lives in one place. A one-shot action from a popup (no exit hook) calls the_mesh.savePrefs() immediately. Follow whichever matches your screen.UITask::setTarget() (defines it), setTargetNow() (defines + saves + toast), or clearTarget() \u2014 one definition used by the Locator screen, the map, and the Nearby/Waypoints \"Set as target\" actions. Resolve a person's current position with resolvePersonPos() (live [LOC] share, else last-advertised fix).
"},{"location":"design/solo_ui_framework/#10-worked-example-a-new-tools-screen","title":"10. Worked example \u2014 a new Tools screen","text":"s_wrap_*) is safe as long as it's never held across a yield. Don't add scratch that outlives one render().endFrame() on e-ink stalls the main loop for hundreds of ms. Keep render()'s return value honest so the panel isn't redrawn more than needed, and don't depend on loop() cadence for timing that must be exact (the ringtone player moved to a hardware timer for this reason).UITask::onContactRemoved() / onChannelRemoved(). New per-contact or per-channel state should clear there too._task->showAlert(\"msg\", duration_ms) overlays a transient banner over any screen; no redraw plumbing needed.strncpy+NUL or snprintf; treat every name/label as untrusted-length and render through drawTextEllipsized.// ui-new/MyToolScreen.h \u2014 included by UITask.cpp near the other screens\n#pragma once\n#include \"icons.h\" // drawList + mini-icons\n#include \"../NodePrefs.h\"\n\nclass MyToolScreen : public UIScreen {\n UITask* _task;\n NodePrefs* _prefs;\n int _sel = 0, _scroll = 0;\n bool _dirty = false;\n static const int ROWS = 3;\npublic:\n MyToolScreen(UITask* t, NodePrefs* p) : _task(t), _prefs(p) {}\n void onShow() override { _sel = 0; _scroll = 0; _dirty = false; }\n\n int render(DisplayDriver& d) override {\n d.setTextSize(1);\n d.drawCenteredHeader(\"MY TOOL\");\n drawList(d, ROWS, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {\n drawRowSelection(d, y, sel, reserve);\n d.setCursor(4, y);\n d.print(i == 0 ? \"Alpha\" : i == 1 ? \"Bravo\" : \"Charlie\");\n });\n return 500;\n }\n\n bool handleInput(char c) override {\n if (c == KEY_CANCEL) {\n _task->savePrefsIfDirty(_dirty); // saves once iff dirty, then clears\n _task->gotoToolsScreen();\n return true;\n }\n if (c == KEY_UP) { _sel = (_sel + ROWS - 1) % ROWS; return true; }\n if (c == KEY_DOWN) { _sel = (_sel + 1) % ROWS; return true; }\n if (keyIsPrev(c) || keyIsNext(c) || c == KEY_ENTER) {\n /* mutate _prefs\u2026, set _dirty = true */ return true;\n }\n return false;\n }\n};\n#include \"MyToolScreen.h\" in UITask.cpp, add the member + constructor + gotoMyToolScreen() (\u00a71), and add a row in ToolsScreen.h. Done \u2014 scrolling, the scrollbar, font scaling, e-ink pacing and persistence batching all come from the framework.refactor/trail-screen (zmergowany do main) Plik \u017ar\u00f3d\u0142owy: TrailScreen.h Status: zaimplementowane \u2014 dokument zachowany jako zapis analizy/decyzji. Aktualny opis funkcji od strony u\u017cytkownika: tools_screen.md \u203a GPS Trail.TrailScreen\n\u251c\u2500\u2500 3 widoki (LEFT/RIGHT): Summary \u00b7 Map \u00b7 List\n\u251c\u2500\u2500 popup akcji (Hold Enter) \u2014 JEDNA p\u0142aska lista, do 12 pozycji\n\u2502 \u251c\u2500\u2500 ustawienia (LEFT/RIGHT cykluje w miejscu): Min dist \u00b7 Readout \u00b7 Grid\n\u2502 \u251c\u2500\u2500 toggle: Start/Stop tracking\n\u2502 \u251c\u2500\u2500 waypointy: Mark here \u00b7 Waypoints \u00b7 Clear waypoints\n\u2502 \u2514\u2500\u2500 trail: Save \u00b7 Load \u00b7 Export(live) \u00b7 Export(saved) \u00b7 Reset\n\u251c\u2500\u2500 pod-ekrany waypoint\u00f3w (nak\u0142adane na widoki):\n\u2502 \u251c\u2500\u2500 WP_LIST (lista + dystanse; Trail-start + \u201e+ Add by coords\")\n\u2502 \u251c\u2500\u2500 WP_NAV (navview)\n\u2502 \u251c\u2500\u2500 WP_ADD (formularz lat/lon/label)\n\u2502 \u2514\u2500\u2500 _wp_ctx popup: Rename \u00b7 Delete \u00b7 Send\n\u2514\u2500\u2500 KeyboardWidget (label / lat / lon) \u2014 nak\u0142adka pe\u0142noekranowa\nrenderMap() (\u2248140 linii) + renderGrid() (\u2248130 linii) + 7 funkcji rysuj\u0105cych markery.openActionMenu() (TrailScreen.h:363) buduje jedno menu, kt\u00f3re miesza cztery r\u00f3\u017cne klasy pozycji:reopenAt, TrailScreen.h:581); - Brak kontekstu widoku \u2014 Grid (dotyczy tylko mapy) i Readout (dotyczy tylko Summary) s\u0105 widoczne zawsze, te\u017c tam, gdzie nie maj\u0105 efektu; - Grid ma dwie \u015bcie\u017cki \u2014 i LEFT/RIGHT (:221) i Enter (:234) robi\u0105 to samo; lekko myli.renderGrid dostaje 11 skalarnych parametr\u00f3w \u2014 brak wsp\u00f3lnej projekcji","text":"renderMap liczy projekcj\u0119 (lokalne lambdy projectLL/project, :816), a renderGrid (:876) dostaje 11 osobnych liczb (area_*, min/max_lat, min_lon, lon_scale_geo, scale, off_*) i powtarza t\u0119 sam\u0105 matematyk\u0119 projekcji r\u0119cznie w p\u0119tli (:988, :992). To samo r\u00f3wnanie \u017cyje w trzech miejscach. Ka\u017cda zmiana modelu mapy wymaga edycji w kilku miejscach naraz.renderGrid wybiera krok siatki w czterech nast\u0119puj\u0105cych po sobie korektach (:912\u2013:952): 1. najwi\u0119kszy krok \u2264 target_m, 2. zwi\u0119kszaj a\u017c odst\u0119p pikseli \u2265 MIN_GRID_PX (22 px), 3. zmniejszaj a\u017c zmieszcz\u0105 si\u0119 \u22652 interwa\u0142y, 4. zwi\u0119kszaj a\u017c liczba linii \u2264 MAX_GRID_LINES (40).:937) m\u00f3wi o \u201estatic buffers (40\u00d740 = ~1600 intersections)\" \u2014 takich bufor\u00f3w ju\u017c nie ma; p\u0119tla rysuje na bie\u017c\u0105co z continue-guardami (:986\u20131002). Cap 40 ogranicza dzi\u015b tylko liczb\u0119 iteracji p\u0119tli (wydajno\u015b\u0107), nie chroni \u017cadnego bufora. Komentarz wprowadza w b\u0142\u0105d. - Kroki 2 i 3 mog\u0105 sobie przeczy\u0107 na bardzo ma\u0142ych ekranach (MIN_GRID_PX = 22 vs shorter_px/2, gdy shorter_px < 44). Nie powoduje b\u0142\u0119du, ale \u201eostateczny\" krok bywa wtedy przypadkowy.
"},{"location":"design/trail_redesign/#3-propozycja-uporzadkowania","title":"3. Propozycja uporz\u0105dkowania","text":""},{"location":"design/trail_redesign/#31-popup-dwa-poziomy-zamiast-jednej-paskiej-listy","title":"3.1 Popup: dwa poziomy zamiast jednej p\u0142askiej listy","text":"_act_map[16] z komentarzem \u201e12 used today; pad\" \u2014 r\u0119czne pilnowanie rozmiaru; pushAction ju\u017c to zabezpiecza, wi\u0119c magiczna 16 jest zb\u0119dna.:852, :1011).Hold Enter \u2192 Trail\n \u2022 Start / Stop tracking\n \u2022 Mark here\n \u2022 Waypoints\u2026 \u2192 istniej\u0105cy WP_LIST\n \u2022 Trail file\u2026 \u2192 Save / Load / Export (live) / Export (saved) / Reset\n \u2022 Settings\u2026 \u2192 Min dist \u00b7 Readout \u00b7 Grid (LEFT/RIGHT w miejscu)\nReset przeniesiony do \u201eTrail file\u2026\", dalej od przypadkowego Entera; - (opcjonalnie) Grid pokazywa\u0107 tylko gdy aktywny jest widok Map, a Readout tylko przy Summary \u2014 menu zale\u017cne od kontekstu widoku.Reset na sam d\u00f3\u0142, usun\u0105\u0107 podw\u00f3jn\u0105 \u015bcie\u017ck\u0119 Grid. Mniej porz\u0105dku ni\u017c podmenu, ale ta\u0144sze.MapProjection liczony raz w renderMap i przekazywany do renderGrid oraz marker\u00f3w:struct MapProjection {\n int32_t min_lat, max_lat, min_lon;\n float lon_scale_geo, scale;\n int off_x, off_y, area_x, area_y, area_w, area_h;\n void project(int32_t lat, int32_t lon, int& px, int& py) const;\n};\n
renderGrid(display, proj) zamiast 11 parametr\u00f3w;proj.project(...).
MapProjection; renderGrid i markery przez projekcj\u0119 \u015brednie (czysty refactor) 3 Upro\u015b\u0107 wyb\u00f3r kroku siatki; popraw nieaktualne komentarze; sprz\u0105tnij _act_map niskie
"},{"location":"development/roadmap/","title":"Feature roadmap","text":"Trail file\u2026 i Settings\u2026 jako podmenu.Grid widoczny w Settings tylko na widoku Map, Readout tylko na Summary.MapProjection + uproszczenie siatki (etap 2 i 3 razem).UITask::clearAllDMUnread() \u2014 memset over _dm_unread_table - MessagesScreen::clearAllChannelUnread() already existed - UITask::clearRoomUnread() already existed - Title is a static const char* table (PopupMenu stores the title pointer verbatim \u2014 locals would dangle) - Zero schema impact, all counters live in RAMuint8_t favourite_contacts[6][6] \u2014 first 6 bytes of each contact's pub_key (enough to disambiguate locally) - Lookup at render time: walk contacts, match prefix, render name + unread badge - Empty slot renders as \"+\" placeholder; Enter on empty opens a contact picker (existing UI)favourite_contacts to NodePrefs, bump SCHEMA_SENTINEL low byte.\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n\u2551 Favourites \u2551\n\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\n\u2551 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2551\n\u2551 \u2502Alice \u2502 \u2502Bob 3 \u2502 \u2502 + \u2502 \u2551\n\u2551 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2551\n\u2551 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2551\n\u2551 \u2502Carol \u2502 \u2502 + \u2502 \u2502 + \u2502 \u2551\n\u2551 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n(lat, lon, ts) into a RAM ring buffer; user explicitly saves snapshots to flash.G indicator appears in the status bar (analogous to A for auto-advert). A reboot resets the active state to off; the RAM trail is also lost on reboot unless saved to a flash slot first.
BreadcrumbEntry[BC_RAM_CAP] in UITask (or a dedicated component). Each entry int32_t lat_1e6, int32_t lon_1e6, uint32_t ts = 12 B. Cap = 256 \u2192 3 KB RAM. nRF52840 (256 KB RAM) has plenty of headroom./breadcrumb.0, /breadcrumb.1, /breadcrumb.2 \u2014 three named slots - Each file: small header (count, start_ts, end_ts, total_distance_m) + entry array - Written only on explicit \"Save trail\" action \u2014 zero background writes, zero wear concern - Optional: auto-save to slot 0 on detected low-battery shutdown (single write before going dark)X, start marked *. UP/DOWN zoom, LEFT/RIGHT pan when zoomed. 3. Last N entries list \u2014 scroll through recent points with timestamp + delta from previous.* when active, like auto-advert A) - Back \u2192 exit - Hold Enter \u2192 context menu: - \"Reset trail\" \u2014 clear RAM ring - \"Save trail \u2192 slot N\" \u2014 snapshot RAM into chosen flash slot - \"Load trail \u2190 slot N\" \u2014 restore from chosen slot into RAM ring - \"Export over USB\" \u2014 dump live RAM trail as KML/GPX over serialuint8_t breadcrumb_interval_idx, uint8_t breadcrumb_min_delta_idx. Sentinel bump. The slot files are separate from prefs.feat/waypoints-nav). The whole navigation suite landed. Notable deltas from the original spec that follows:
/waypoints (16 max), independent of trail recording and kept across Reset trail.navview::draw(...) reused by waypoints, Trail-start backtrack, Nearby-node nav and message-location nav. Shows distance + To: + Hdg: (two absolute bearings), honouring the global Units setting.units_imperial + trail_show_pace prefs (schema 0xC0DE0006); the old combined trail_units_idx retired.[WAY]lat,lon label; a received location ({loc} text or a [WAY] share) offers Navigate / Save waypoint from both the message list row and the fullscreen view. Backed by a shared geo::parseLatLon.geo:: (haversineKm/bearingDeg/bearingCardinal/fmtDist/parseLatLon) in GeoUtils.h; 1-px gfx::drawLine/drawCircle in GfxUtils.h; one UITask::currentLocation() GPS accessor./waypoints (separate from prefs, like /trail). Fixed table, no schema-sentinel impact:struct Waypoint {\n int32_t lat_1e6, lon_1e6; // saved fix\n uint32_t ts; // when marked (RTC)\n char label[12]; // short name, NUL-terminated (\"CAR\", \"CAMP\", \"H2O\"\u2026)\n};\nstatic const int WAYPOINT_MAX = 16; // 16 \u00d7 24 B = 384 B file\nKeyboardWidget to type the label (\u226411 chars). Empty input auto-labels WP<n>. - Saves the current fix + label, appends to the file.renderMap() derives the box from TrailStore::boundingBox(); extend it to also span the waypoint table (and handle the \"waypoints but empty trail\" case \u2014 map still renders). - Generalise the project() lambda to take raw (lat, lon) instead of a TrailPoint& so the same projection draws both track points and waypoints. - Marker shows the label's first character beside it when there's room (122 px is tight with many waypoints); the full label lives in the list / nav view./waypoints file), unlike the RAM trail ring. CAMP \u2190 waypoint label\n 1.4 km \u2190 distance to target\n To: 145\u00b0 SE \u2190 absolute bearing target-from-me (haversine/bearingDeg)\n Hdg: 090\u00b0 E \u2190 my course over ground, derived from recent GPS movement\n-- instead of a stale value.TrailStore helper: bool currentCourse(int& deg) walks back from the newest fix until the cumulative distance from it exceeds a threshold (~10\u201315 m so GPS noise doesn't dominate) and returns the bearing from that older fix to the newest. Returns false (\u2192 --) when there isn't enough recent movement. Refreshed ~1 s. bearingDeg / bearingCardinal are currently private statics in NearbyScreen; lift them into a shared header (or TrailStore) so both screens use them.(lat, lon, label) \u2014 it doesn't care whether that came from a waypoint, the trail start (Backtrack), or a node's last-known advert position. Make it a small reusable component (NavView / drawNavTo(display, target_lat, target_lon, label)) and wire it in from Nearby Nodes too: node detail \u2192 \"Navigate\" \u2192 same To/Hdg/distance screen, retargeting the selected node. This folds the backlog \"Compass to contact\" idea into one screen and means Nearby stops being a static snapshot \u2014 you can actually walk toward a person.gps_lat/gps_lon from its last advert (already read in NearbyScreen). It's a last-known fix, not live, so the label could show the advert age (e.g. Alice (5m)); pair it with Auto-Advert on the other device for a moving target.TrailStore only works while a trail is actively being recorded \u2014 but node/waypoint navigation shouldn't require the user to start trail logging. Fix: a tiny independent COG ring in UITask (\u22484 fixes), maintained on every GPS poll regardless of trail state. The trail ring stays a separate concern, so the Hdg line is available everywhere (waypoints, nodes, backtrack).-- until the window has cleared the threshold at least once. This is what stops a standing user from getting a spinning bearing.gps_lat/gps_lon as the target. No separate compass screen needed; the \"two absolute bearings (To / Hdg)\" approach replaces the relative-heading arrow this entry originally assumed. Kept here only as a cross-reference.-cog (rotate each projected point before drawing). Point-glyph markers rotate cleanly; label text stays upright; the north arrow then points to actual north instead of straight up.feat/power-saving \u2014 two independent toggles under Settings \u203a Radio, both default OFF. Under field testing; not yet merged.SetRxDutyCycle, datasheet 13.1.7) via RadioLib startReceiveDutyCycleAuto(preamble, 8): the chip's sequencer cycles RX\u2194sleep, latches a preamble and stays in RX to receive the packet (RX_DONE on DIO1). No MCU state machine \u2014 recvRaw() reads the packet exactly as in continuous RX. armRecv() arms duty-cycle when power-save is on, else a normal startReceive(); loop() re-arms only on a toggle. Falls back to continuous RX if the modem doesn't support duty-cycle (non-SX126x). state stays STATE_RX so the dispatcher's not-in-RX watchdog never trips. - Duty-cycle engages when the configured preamble \u2265 2\u00b78+1 symbols. At SF\u22648 the preamble is 32 \u2192 full duty-cycle; at SF9\u201312 it is 16 \u2192 RadioLib transparently stays on continuous RX (no power saving on the slow SFs). - Companion: rx_powersave pref (schema 0xC0DE0009), Settings \u203a Radio \u203a \"Pwr save\", applied at boot (MyMesh) and on change (UITask). Noise-floor sampling is skipped while on (chip is asleep most of the time) \u2014 the radio page shows \"Noise floor: n/a\".standbyXOSC/burst windows). It fought the hardware \u2014 querying a warm-sleeping chip from checkSend() gave a phantom-busy channel that stalled TX for ~4 s, and ACKs dropped in the scan gaps. Replaced wholesale by the hardware duty-cycle above, which fixed both.tx_power_dbm becomes a ceiling; APC drives the radio's actual power within [APC_MIN_DBM \u22129, ceiling] to hold the link margin near a target. Lives in MyMesh (applyApc + apcSampleSnr/apcOnFailure controller), tx_apc pref. - Two feedback sources, both the reverse link (no protocol change): - Direct messages \u2014 ACK SNR (onAckRecv); missed ACK (onSendTimeout) = lost confirmation. - Channel/flood messages \u2014 no ACK exists, so we hash each originated flood (apcTrackFloodSend in sendFloodScoped, hash excludes the path) and listen in filterRecvFloodPacket for a repeater rebroadcasting it; the heard echo's SNR is a sample, and no echo within ~6 s counts as a lost confirmation. This is what lets a channel send recover after APC trimmed power below what the repeaters can hear (previously it could strand channel TX at the floor). - Margin is measured above the per-SF demod floor (\u22127.5 \u2212 2.5\u00b7(SF\u22127) dB) so one target works across SF7\u201312. Each SNR sample is smoothed with an EWMA (\u03b1=0.4); power steps proportionally to the error (capped \u00b12 dB) with a \u00b12 dB deadband to avoid hunting; the EWMA is nudged by each step so it doesn't re-trigger on a stale sample. - Lost confirmation \u2192 step up +4 dB; 2 consecutive losses \u2192 jump to the ceiling (ramp gradually first since a loss is an ambiguous power signal). Any confirmation clears the streak. Live power shown on the radio page + name bar.startReceive() with boosted gain in RadioLibWrappers.cpp); the MCU already sleeps between iterations (sd_app_evt_wait()/WFE in NRF52Board.cpp), so the framework is not the bottleneck \u2014 the radio is. The win is framework-agnostic and can land in this Arduino tree while keeping the Solo UI and upstream sync.
startReceiveDutyCycle. Tradeoff: slightly higher receive latency / a small sensitivity hit \u2014 acceptable for a companion, must be a toggle/setting so users who want lowest latency can keep continuous RX.tx_power_dbm dynamically when link quality (SNR/RSSI of acks) allows; raise it back when needed.board.sleep(0) whenever hasPendingWork() is false \u2014 but that only covers MCU idle (it does not touch the radio, which is the real draw), and there is no on/off toggle because not-sleeping would only waste power. The same merge also brought the preamble 16\u219232 bump for SF<9, which is what makes the hardware RX duty-cycle viable at SF8 (it needs \u2265 2\u00b78+1 = 17 preamble symbols to latch). Prefer adopting/extending upstream work over a parallel implementation. Note ZephCore's licence before copying any code verbatim (architecture inspiration is fine).feat/power-saving; see the status block at the top of this entry for what's left (PPK2 current measurement, multi-hop APC gating).feature/companion-repeater-presets.
"},{"location":"development/roadmap/#sos-broadcast","title":"SOS broadcast","text":"client_repeat, Tools \u203a Repeater) \u2014 the companion relays flood/direct traffic, still working as a normal companion. By default it switches to a dedicated band on enable (see profile note below) rather than relaying on whatever network it's chatting on. MyMesh::allowPacketForward gates it; loop detection (isRepeatLooped, ported from simple_repeater) and an advert flood-depth cap are always applied. Packet pool bumped 16\u219232 to match the repeater workload (a too-small pool starved channel/DM reception once relaying queued retransmits).Network: Current/Custom, repeater_use_profile + repeater_freq/bw/sf/cr). Custom switches the radio to a preset/manual profile when the repeater is enabled and restores the companion's params when disabled (user-chosen revert-on-disable); a profile equal to the companion = \"same network\", a different one = drop onto a separate repeater network. MyMesh::applyRepeaterRadio() is the single decision point, called at boot and on every toggle/edit; repeaterProfileValid() gates it. Schema sentinel 0xC0DE0010. Custom is the default: a never-configured device (or one upgrading from a pre-0x10 file, which has no saved profile) turns the profile on and seeds repeater_sf/bw/cr from LORA_SF/BW/CR plus a band-matched repeater_freq \u2014 relaying on the same network the operator is chatting on isn't the MeshCore community norm, so \"Current\" stays opt-in. defaultRepeaterFreqForBand() (NodePrefs.h) buckets the companion's own freq into whichever of the three license-exempt bands MeshCore's app-driven repeat toggle historically restricted to (433.000 / 869.495 / 918.000 MHz \u2014 see repeat_freq_ranges in MyMesh.cpp), so the seeded default can't land outside what's legal for wherever the companion's own network already is. LORA_FREQ/BW/SF/CR fallback #defines moved from MyMesh.h to NodePrefs.h so DataStore.cpp's migration code can see them too.getRetransmitDelay); own sends pass their own delay to sendFlood, so the companion's own traffic is never slowed. Widens the overhear window.-128 sentinel = off, so an upgraded prefs file can't read as \"filter at 0 dB\").Dispatcher::suppressQueuedDuplicate, wantsOverhearSuppress hook). MeshCore had no overhear-cancel before \u2014 the unused removeOutboundByIdx/getOutboundByIdx finally have a caller. Packet hash ignores the path for non-TRACE, so our copy and the peer's relayed copy hash equal. Pairs with Yield (longer delay \u2192 wider window to hear a peer).Dispatcher::n_recv_by_type/n_sent_by_type), uptime, heap + stack (new DeviceDiag helper, nRF52 linker-symbol/sbrk heap + FreeRTOS stack high-water), noise floor, RSSI/SNR, pool free, outbound queue, Forwarded (Mesh::n_forwarded \u2014 actual retransmits; backed out on overhear cancel so it reflects what hits the air), and Errors (Dispatcher ERR_EVENT_* flags decoded to F/C/R). Hold Enter opens a one-item \"Reset counters\" menu (Back dismisses) \u2014 resetStats made virtual; Mesh override also clears n_forwarded.client_repeat is on (effective pref && !client_repeat via applyPowerSave() / apcActive()), applied at boot, on the on-device toggle, and on the app's CMD_SET_RADIO_PARAMS; Settings shows -- and blocks the toggle, preserving the user's pref for when the repeater goes off. A blinking \u00bb status-bar indicator (ICON_REPEATER) shows relaying at a glance.0xC0DE000E (four knobs), 0xC0DE000F (suppress-dup), 0xC0DE0010 (radio profile), with stray-byte clamps for upgraders.CMD_SET_RADIO_PARAMS was commented out (not deleted) to match the on-device toggle's any-frequency behaviour \u2014 undecided whether that gate was UX-only or regulatory.{loc} and {batt} filled. 30 s cooldown.!word tokens and answered with live node data via expandMsg \u2014 !batt/!loc/!time/!temp/!status/!ping/!help, plus !hops (per-message hop count via getPathHashCount(), direct if heard directly). Multiple commands in one message are merged into a single |-joined reply (one transmission/throttle/counter tick) via the shared botScanCommands. Works in DMs (per-contact throttle, ignores quiet hours \u2014 a pull) and on the bot's monitored channel (broadcast: per-channel cooldown, respects quiet hours). Toggled independently of the trigger bot. See MyMeshBot.h tryBotCommand / tryBotChannelCommand / botCommandReply.<n> unread, summing DM + channel + room counters) below the time. Reuses the existing unread counters; no schema change.uint8_t profile_idx with hardcoded value tables.main). A per-message marker sits at the end of each outgoing DM row in the history and in the fullscreen message view. Deltas from the spec that follows: - Pending isn't a single \u00b7/\u2026 \u2014 it draws one square dot per send attempt, so an auto-resent message shows its retry count at a glance. - Delivered / failed use drawn scalable mini-icons (\u2713 / \u2717) that scale with the font, not glyph-font characters \u2014 legible on every layout (see icons.h, authored as compile-time ASCII-art). - Adds DM auto-resend + incoming dedup, plus a channel \"relayed into mesh\" marker (\u2713 when a repeater echo confirms the channel message went out).sendMessage() returns expected_ack + est_timeout; the ACK arrives via onAckRecv / isAckPending \u2014 the same mechanism APC and ping already consume.\u00b7 / \u2026 \u2014 sent, awaiting ACK (pending) - \u2713 \u2014 delivered to the recipient (ACK matched) - \u2717 / ! \u2014 timed out, no confirmationDmHistEntry gains uint8_t ack_status (0=incoming/none, 1=pending, 2=delivered, 3=failed) and uint32_t ack_tag (the expected_ack CRC).ack_tag = expected_ack, ack_status = pending, record the send time + est_timeout. - On ACK: route onAckRecv(ack_crc) to the UI; find the entry whose ack_tag matches and set it delivered (single shared callback, like onPingResult). - Timeout: in the UI loop, a pending entry older than est_timeout \u2192 failed.expected_ack == 0 (and channel messages, which have no ACK) can't be confirmed \u2192 show plain \"sent\" (\u2192) and no delivery state. - Glyph rendering: prefer a Lemon-font check; on the plain ASCII font fall back to drawn 1-px marks or letters so it reads on the OLED.{loc} every N minutes to a chosen channel or DM contact \u2014 group trip tracking. Builds on the existing auto-advert cadence pattern and {loc} expansion. A status-bar indicator (like A/G) while active; off by default.docs/qr_codes.md); this is the on-device render side.<ele> tags to the GPX export. Skip cleanly when no altitude is available.
321d769e. Grouped by severity. Listed but not yet fixed.onChannelMessageRecv / onChannelDataRecv \u2014 guard for findChannelIdx == -1","text":"MyMesh.cpp:561-602, MyMesh.cpp:619-625findChannelIdx() before continuing:int idx = findChannelIdx(channel);\nif (idx < 0) {\n MESH_DEBUG_PRINTLN(\"...: unknown channel secret \u2014 dropping message\");\n return;\n}\nuint8_t channel_idx = (uint8_t)idx;\nidx=255.addChannelMsg guards against bogus index","text":"MessagesScreen.h:414-433if (ch_idx >= MAX_GROUP_CHANNELS) return; at function entry \u2014 prevents ring-buffer pollution in case any future caller forgets the upstream guard. With C1 fixed this should never trigger, but the cost is zero.findChannelIdx scans all-zero secret in uninitialised slots","text":"BaseChatMesh.cpp:908findChannelIdx() now returns -1 immediately when the queried secret is all-zero, so a corrupted/empty channel can't match an unused all-zero slot. Complements the load-side skip already in loadChannels().saveChannels writes all 40 slots to /channels2","text":"DataStore.cpp:687MAX_GROUP_CHANNELS, so the file holds only the channels actually configured (was always ~2.7 KB). loadChannels() already compacted empty entries on read, so the loaded result is unchanged \u2014 only on-flash size and write wear drop.msgRead(0) wipes the whole DM unread table","text":"UITask.cpp:1403-1410if (msgcount == 0) {\n memset(_dm_unread_table, 0, sizeof(_dm_unread_table));\n ((MessagesScreen*)messages_screen)->clearAllChannelUnread();\n}\nMessagesScreen.h, KeyboardWidget.hChHistEntry::text was 140 B and DmHistEntry::text only 80 B, while the keyboard capped input at 139 B \u2014 all below MeshCore's MAX_TEXT_LEN (160 B). Channel messages embed the sender as \"Name: body\" in the payload, so the prefix ate into the 140 and clipped the tail; DMs over ~80 B were cut outright; and Polish text (2 bytes per accented char) roughly halved the visible limit. Fixed: history + fullscreen/preview copies sized to MAX_TEXT_LEN + 1, keyboard cap raised to 160 with per-field maxima kept on the smaller stores (custom_msgs, bot reply). Full-length messages now compose, send, store and display intact.loadPrefsInt scopes trail_units_idx reset to the 0xC0DE0003 jump","text":"DataStore.cpp:326-343sentinel == 0xC0DE0003 so newer mismatches (e.g. 0xC0DE0004 \u2192 0xC0DE0005, which both saved the field correctly) no longer clobber the user's choice.CMD_SET_DEFAULT_FLOOD_SCOPE off-by-one \u2014 not a bug","text":"MyMesh.cpp:2143default_scope_name is declared char[31] (not 32), so n < 31 correctly admits the maximum 30-character string + NUL. The audit entry was a misread.strlen on cmd_frame without null-termination \u2014 replaced with strnlen","text":"MyMesh.cpp:2140-2147CMD_SET_DEFAULT_FLOOD_SCOPE doesn't have to be NUL-terminated by the sender. Switched to strnlen(\u2026, 31) so the search can't run past the field into the 16-byte key (or beyond the frame).PopupMenu._cap updated only in render()","text":"PopupMenu.h:37-44, 77-90NearbyScreen.h:448-451max_chars < 4 instead of feeding a negative length to strncpy. (Lived in renderDiscoverDetail before the one-list refactor; now in renderScanDetail.)expandMsg GPS validity test treats (0, 0) as invalid","text":"MyMeshBot.h:95, 132, 182sensors.node_lat != 0.0 || sensors.node_lon != 0.0 // proxy for \"valid GPS\"\n%.1f","text":"NearbyScreen.h:467-470, 330-332SNR: %.1f dB, Rem: %.1f dB) and the ping popup keep the 0.25 dB resolution. (After the one-list refactor the scan list cards show RSSI in the right column, not SNR.)_count cast to uint16_t","text":"Trail.h:27static_assert(CAPACITY <= 0xFFFF, \u2026) next to the CAPACITY definition now fails the build if it is ever grown past what the uint16_t save-header count can hold, instead of silently truncating. Safe today (CAPACITY=512).strstr on truncated 199-char buffer \u2014 not reachable","text":"MyMeshBot.h:11BOT_SCRATCH is 200 and MAX_TEXT_LEN is 160, so an incoming message never reaches the 199-char truncation point \u2014 the scratch buffer (used by the centralised botTriggerMatches()) always holds the whole message. No fix needed.strncpy(\"?\", buf, sizeof(buf)) replaced with strcpy","text":"MessagesScreen.h:785, 858strcpy so we don't memset 21 unused bytes for a one-character string.MessagesScreen.h:1279-1287rlen is clamped to 20 before building \"RE:\" + nick, so the title is \u226423 chars and fits title[24] with no overflow. A nick longer than 20 chars is shown truncated, but that's an intentional fit-to-header limit (the OLED header only fits ~21 chars anyway), not a bug.
"},{"location":"solo_features/external_keyboard/","title":"External keyboard","text":""},{"location":"solo_features/external_keyboard/#external-keyboard-joystick","title":"External Keyboard & Joystick","text":"findChannelIdx == -1 guarded at both channel-recv paths; addChannelMsg defends against bogus indextrail_units_idx reset scoped to the 0xC0DE0003 jumpstrnlen instead of strlen on default scope namerenderDiscoverDetail skips pub-key line on very narrow displays\"?\" sender no longer memsets through strncpyrenderGrid now picks a round labelled step (1m\u2026100km / 10ft\u2026100mi) nearest ~1/3 of the shorter side and enforces a MIN_GRID_PX floor, so the grid can never silently vanish on an elongated trail. TrailScreen.h:635default_scope_name[31])BaseChatMesh (findChannelIdx should iterate num_channels, not MAX_GROUP_CHANNELS; saveChannels should stop at the first uninitialised slot) or a local override
"},{"location":"solo_features/external_keyboard/#support-by-device","title":"Support by device","text":"Device CardKB Wired joystick Seeed Wio Tracker L1 (OLED) \u2705 Grove connector onboard Seeed Wio Tracker L1 (E-ink) \u2705 Grove connector onboard GAT562 30S Mesh Kit \u2014 onboard Heltec V3 (experimental) \u2705 solder to free GPIOs \u2705 solder to free GPIOs Heltec V4 (experimental) \u2705 solder to free GPIOs \u2705 solder to free GPIOs M5Stack Cardputer ADV (experimental) \u2014 built-in keyboard instead, see below LilyGO T-Echo Lite + KeyShield (experimental) \u2014 built-in keypad instead, see below"},{"location":"solo_features/external_keyboard/#cardkb","title":"CardKB","text":"0x5F), for typing messages, names and labels without walking the on-screen letter grid.
"},{"location":"solo_features/external_keyboard/#wiring-heltec-v3-v4","title":"Wiring (Heltec V3 / V4)","text":"Wire1) \u2014 not the OLED's 17/18 CardKB SCL 4 Joystick UP 23 Joystick DOWN 6 Joystick LEFT 47 Joystick RIGHT 48 Back button 33 required when the joystick is enabled Centre / Enter 0 the onboard PRG button \u2014 nothing to wire [env:Heltec_v3_companion_solo_dual] / [env:heltec_v4_companion_solo_dual] blocks in variants/heltec_v3/platformio.ini and variants/heltec_v4/platformio.ini, with comments explaining which pins are safe to reuse. To build a CardKB-only device, comment out the joystick block and set Ext. KB to Compact.
platformio.ini / keyboard driver under variants/ for its current keymap.
"},{"location":"solo_features/clock_screen/clock_screen/#data-fields","title":"Data fields","text":"OLED E-Ink 3.92V) Batt % Batt Battery percentage using LiPo curve anchored at the low-battery threshold Temperature Temp \u00b0C from onboard sensor Humidity Hum % from onboard sensor Pressure Pres hPa from onboard sensor GPS GPS lat lon decimal degrees, or no fix Altitude Alt metres from onboard sensor (GPS or barometric) Luminosity Lux lux from onboard sensor CO\u2082 CO2 ppm from onboard sensor Contacts Nodes Total contacts in the mesh Messages Msgs Total unread message count -- when the sensor is not connected or has no data.
"},{"location":"solo_features/favourites_dial/favourites_dial/#navigation","title":"Navigation","text":"+) \u2014 opens a contact picker to fill the slot.+ tile.+). A picker opens showing:
OLED E-Ink
{time} current time (HH:MM) always {loc} GPS coordinates always (\"no GPS\" if no fix) {temp} temperature sensor connected {hum} humidity sensor connected {pres} barometric pressure sensor connected {alt} altitude sensor connected {lux} luminosity sensor connected {co2} CO\u2082 concentration sensor connected {time} and {loc} are always shown.
3m, 2h, >1d) in the top-right corner of each bubble. The list runs newest at the bottom \u2014 opening a history starts you at the latest message, and scrolling up goes further into the past.@[nick]), a To: nick bar is shown below the sender name and the body is displayed without the address prefix.
lat,lon pair in the text \u2014 exactly what the {loc} placeholder inserts \u2014 so you can navigate to anything a contact shares. A [WAY]lat,lon label share also carries a name, used as the waypoint label. This works on DMs and channel messages, incoming or outgoing.
Field Notes Name Up to 31 characters Secret LEFT/RIGHT toggles between two entry modes; Enter opens the keyboard for whichever is selected test); the channel's name and secret are both derived from it (name becomes #test, secret is the first 16 bytes of sha256(\"#test\")). A topic-based public group chat \u2014 anyone who types the same topic elsewhere ends up on the same channel \u2014 separate from the default Public channel.
00\u20260) is rejected (\"Invalid secret\") \u2014 that value is reserved internally to mark an empty channel slot.
None disables the sound for this event. Channel Melody built-in / Melody 1 / Melody 2 / None Notification sound for incoming channel messages. None disables the sound for this event. AD sound built-in / Melody 1 / Melody 2 / None Sound played whenever an advert is received from any node \u2014 pairs with Auto-Advert as an audible \"in range\" heartbeat (see Tools \u203a Auto-Advert). None disables the sound for this event. AD scope All / Zero-hop Filters the AD sound so it plays for every advert or only for local zero-hop adverts.
--) while the repeater is on \u2014 a repeater must listen continuously; your setting is restored when the repeater is switched off. A background watchdog recovers automatically if the duty-cycle sequencer ever gets stuck (soft re-arm, then a full radio reset) \u2014 see Tools \u203a Diagnostics for the recovery counts. Auto pwr ON / OFF Adaptive Power Control. Lowers actual TX power on strong links to save energy, ramping back up \u2014 to the TX Pwr ceiling \u2014 on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). Suppressed (shown as --) while the repeater is on \u2014 a repeater holds full TX power for consistent relay reach; your setting is restored when the repeater is switched off. OLED E-Ink !gps fix bot request. OFF (default) matches earlier releases: GPS runs continuously whenever enabled. The GPS status icon blinks while napping between fixes. Units Metric / Imperial Global unit system for every distance/speed shown in Tools (Nearby Nodes, Trail, navigate-to-point). Metric: m / km, km/h, min/km. Imperial: ft / mi, mph, min/mi Reboot action (Enter) Restarts this device. Pending setting changes are saved first. Last row, so it isn't the default-selected one"},{"location":"solo_features/settings_screen/settings_screen/#keyboard","title":"Keyboard","text":"Setting Options Notes Layout ABC / T9 On-screen keyboard style. ABC: an a-b-c\u2026z grid, one key per letter (the original layout). T9: phone-keypad multi-tap \u2014 each key is labelled with its digit and a letter group (e.g. 2abc); repeated Enter presses cycle through the letters and then the digit itself. Applies to whichever script page is active (see Main/Additional below), not just Latin. Main Latin / Cyrillic / Greek Which script the keyboard opens on by default. Latin (default) matches earlier releases; pick Cyrillic or Greek here instead to make that script the one you land on every time, with Latin becoming the one reached via cycling (see Additional below) instead of the other way round. Additional Latin / Cyrillic / Greek The second script added to the same #@/abc key's cycle (Main \u2192 Additional \u2192 Symbols \u2192 Main) \u2014 no separate key to switch scripts. Setting Additional to the same script as Main drops the cycle back to just that script plus Symbols (no second script page at all). Greek covers the 24-letter alphabet plus final sigma (\u03c2) but not the tonos stress accents used in proper Modern Greek spelling. Every script's letters render natively \u2014 the display font (a single unified Unicode font used everywhere on-screen) covers all of them, no separate toggle needed. a c d e i l n o r s t u y z) opens a one-row popup of its accents (e.g. holding a offers \u00e1 \u00e0 \u00e2 \u00e3 \u00e4 \u00e5 \u0105); LEFT/RIGHT picks, Enter inserts it, Cancel dismisses with no change. Holding a letter with no accented variants (e.g. b) does nothing. Works on whichever page is currently showing Latin, whether that's Main or Additional.{time}, {loc}, and sensor placeholders when connected).Sharing pos: with the share age and whether it's DM-verified or channel-only.NODE_DISCOVER_REQ scan) Ping row to send a direct mesh ping to that node. The popup then shows the RTT and SNR values on the next lines, and can be used again immediately for another ping.NODE_DISCOVER_REQ. Repeaters, sensors and room servers within zero-hop range respond immediately with name, type and signal data. This is not a separate screen \u2014 it is the same list switched to a live-scan source: the right-hand column shows RSSI instead of distance, and node detail shows the public key, signal data and contact status.
"},{"location":"solo_features/tools_screen/tools_screen/#gps-trail","title":"GPS Trail","text":"OLED E-Ink Track:N (tracked-node count) and, when you have a fix and at least one tracked contact, an arrow + distance to the nearest one (e.g. Track:3 \u2192120m). If a Locator/Nav target is set it's drawn as a flag marker (see Locator). Press Enter to open the full Trail Map; Hold Enter shares your position (see Live Share); Back returns home.start; scroll with UP/DOWN OLED E-Ink [LOC] message \u2014 pick a contact or channel (see Live Share) Trail file\u2026 Open the file submenu (below) Settings\u2026 Open the settings submenu (below) /trail) Load trail Restore flash trail into RAM Export (live) Stream live RAM trail as GPX 1.1 over USB Serial Export (saved) Stream saved flash trail as GPX 1.1 over USB Serial Reset trail Clear RAM ring and elapsed time paused. The stop is detected with its own coarse movement gate, independent of Min dist, so GPS jitter while you're parked doesn't keep it awake./trail file as the manual Trail file\u2026 \u2192 Save, and only writes when the trail actually has points \u2014 an empty trail can't overwrite a previously saved one. Off by default so a normal shutdown doesn't silently overwrite a saved trail you meant to keep.Back: 12 pt), reading Trail start on the final leg; arriving there shows Back at start and exits. Cancel leaves track-back at any time. It needs a trail with at least two points and a GPS fix; it doesn't require tracking to still be running./waypoints), survive a reboot, and are not cleared by Reset trail. Up to 16 can be stored \u2014 the Waypoints list header shows how many are in use (e.g. WAYPOINTS 3/16).CAR, CAMP, H2O). Leaving it blank auto-names it WP1, WP2, \u2026 Marking works whether or not the trail is being recorded; it needs a GPS fix (otherwise it reports No GPS fix).
WP<n>).
OLED E-Ink CAMP \u2190 target label\n 1.4 km \u2190 distance to target\n To: 145\u00b0 SE \u2190 absolute bearing to the target\n Hdg: 090\u00b0 E \u2190 your current course over ground (-- when stationary)\n-- until you move.[WAY]<lat>,<lon> <label> (e.g. [WAY]37.42123,-122.08456 CAR) for you to confirm or edit before sending. On the receiving device, opening that message and Hold Enter \u2192 Navigate / Save waypoint turns it back into a navigable point (see Messages \u203a Fullscreen message view). The format is plain text, so it stays readable on other firmware and the phone app.
tools/trail_export.py (auto-detects the port, captures from <?xml to </gpx>, writes a timestamped file under tools/gpx/):uv run tools/trail_export.py\n
cat /dev/tty.usbmodem* > track.gpx (stop with Ctrl-C after the dump finishes)<?xml to </gpx> into a .gpx file<wpt> elements (with their label as <name>), alongside the track \u2014 so they show as pins in OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc. Either way, the resulting file imports into all of those.None in Settings \u203a Sound \u203a AD sound to silence just this event, or set Settings \u203a Sound \u203a Advert scope to Zero-hop to limit it to local adverts only. You can also set Settings \u203a Sound \u203a Buzzer to OFF (or Auto, which mutes while a companion app is connected) to silence all buzzer output.[LOC]<lat>,<lon> message \u2014 the same coordinate format waypoints use, so it stays readable on other firmware and the phone app (it just looks like a coordinate to anything that doesn't know the tag).[LOC] shares (DM, monitored channels, and room-server posts) and pin those senders on the map / in Nearby. Off by default. Auto share ON / OFF Periodically broadcast your own position to the target below while you move. To channel or contact Enter opens the Messages recipient chooser to pick the target channel or DM contact. Move 50 / 100 / 250 / 500 m Movement gate \u2014 only send after you've moved at least this far since the last share. Min gap 30 s / 1 / 2 / 5 min Minimum time between sends, so fast movement can't flood the channel. Heartbeat OFF / 5 / 15 min Optional keep-alive: re-send even while stationary, so the other end knows you're still there. [LOC] messages update a small live table (up to 16 nodes, entries expire ~20 min after the last update). DM shares are keyed by the sender's public key (reliable); channel and room-server shares are keyed by name (best-effort, since channel names are unsigned and a room post only carries a short sender prefix). Tracked nodes appear on the Trail Map as a filled diamond with the first two characters of their name, and in Nearby Nodes with their live distance/bearing.[LOC] message and hands it to the Messages screen to pick a recipient. There's also a shortcut from the home Map page: Hold Enter sends an immediate position update to your Live Share target while auto-sharing is on (toast Position shared), or opens the recipient picker if it isn't \u2014 so you never broadcast to a default channel by accident.none instead of leaving it pointed at something that's gone.@ prefix, plus a compact age tag (e.g. @Bob (5m)) when the position is last-advertised rather than a live share. Shows none until set. Radius 50 / 100 / 250 / 500 m / 1 km Geofence size. Mode Arrive / Leave / Both Which crossing fires the alert \u2014 entering the radius, leaving it, or both. Beeper ON / OFF Optional homing tone \u2014 shown only in Arrive / Both modes (see below). Arrived / Left for a waypoint, Near / Away for a person. The edge has a little hysteresis so a fix hovering right on the boundary doesn't chatter, and the first reading after arming only seeds the in/out state \u2014 it won't fire spuriously just because you armed it while already inside.[LOC] share wins, and with no current share it falls back to the contact's last-advertised GPS position \u2014 so a rarely-updating but stationary node (a repeater, or someone who shared a fix once) still works as a target. You can arm it ahead of time \u2014 choosing a favourite locks onto their identity (pubkey), and the alert starts working as soon as a position is known. Live following requires a DM share (a channel share carries no stable identity to lock onto); the last-advertised fallback works for any contact regardless.Target set toast.145\u00b0 SE).--). Gross GPS jumps are rejected so a single bad fix can't swing the heading. The heading source runs whenever there's a GPS fix \u2014 recording a trail is not required.
hi,hello there,yo) \u2014 matching any one of them is enough; spaces around each phrase are trimmed, so hi, hello there and hi,hello there behave the same. The bot has three independent targets \u2014 DM, a monitored Channel, and a monitored Room \u2014 each with its own trigger/reply pair.! queries or stay quiet, same as Enable.(none) if none exist yet), regardless of Enable. Enter opens the full channel picker (the same one Live Share's To row uses). Commands ON / OFF \u2014 Enter toggles. Answer ! query commands on the monitored channel, independent of the other two tabs' Commands settings. Trigger Independent trigger for the monitored channel. * means reply to every channel message \u2014 bounded by the per-channel cooldown, but use sparingly on a busy channel. Reply Reply text for channel messages; supports the same placeholders as Direct's Reply."},{"location":"solo_features/tools_screen/tools_screen/#room-tab","title":"Room tab","text":"Setting Description Enable ON / OFF \u2014 Enter toggles. Independent of which room is picked below. Room Which room server the bot posts to \u2014 always shows the last-picked room (or (none) if you have none yet), regardless of Enable. Enter opens the full room picker. Picking a room you've never logged into prompts for its password right there \u2014 the bot can't post to a room it has no working login for, so this is the moment to set one up. Commands ON / OFF \u2014 Enter toggles. Answer ! query commands on the monitored room, independent of the other two tabs' Commands settings. Trigger Independent trigger for the monitored room. * means reply to every post in the room. Reply Reply text for room posts; supports the same placeholders as Direct's Reply."},{"location":"solo_features/tools_screen/tools_screen/#direct-tab","title":"Direct tab","text":"Setting Description Enable ON / OFF \u2014 Enter toggles. Enables DM listening. DM allow All / Fav \u2014 Enter toggles. Who the DM bot (trigger-reply and commands) responds to. All (default): any DM sender. Fav: only contacts you've starred (the same star Settings \u203a Contacts filters on) \u2014 use this to keep a public bot from being spammed by strangers while it still answers people you trust. Commands ON / OFF \u2014 Enter toggles. Answer ! query commands (see below) in DMs. Trigger Word or phrase that activates the DM reply (case-insensitive). A lone * means reply to every DM (away mode) and is shown as (any msg). Enter opens the keyboard. Reply Reply text for DMs; supports {time}, {loc}, {name}, {hops} and sensor placeholders. Enter opens the keyboard."},{"location":"solo_features/tools_screen/tools_screen/#other-tab","title":"Other tab","text":"Setting Description Quiet from Enter opens a stepper (value shown bracketed, e.g. [14:00]) \u2014 UP/DOWN steps the hour, Enter/Cancel confirms. Local-time window start; set from = to (OFF) to disable quiet hours entirely. Applies to all three targets' trigger-replies. Quiet to Same stepper; window end. *) in DMs while the channel or room reacts only to a specific keyword (or vice-versa).{name} (the triggering sender's name) and {hops} (direct or N hops) are only meaningful when replying to an actual incoming message, so \u2014 unlike {time}/{loc}/the sensor placeholders \u2014 they're offered only while editing a Reply field here, not on the general message-compose keyboard.! on that target is answered with live node data, independent of the trigger:!ping pong !batt battery voltage !loc GPS coordinates (or no GPS) !time local time HH:MM !temp temperature (or n/a if no sensor) !hops how many hops the command message took to reach the node (direct if heard directly) !status combined battery / location / time !help list of available commands !batt !time !hops is answered with a single 4.10V | 14:30 | 3 hops reply (one transmission). A message with no recognised command falls through to the trigger bot.!ping in DMs but stay quiet on a busy public channel. Channel and room replies are broadcast/posted to everyone there, so unlike DM commands they respect quiet hours and use their own shared cooldown. DM commands use the per-contact throttle and the DM allow scope above.!buzz [seconds] Sounds the buzzer as a find-me signal \u2014 default 5s, capped at 30s. Sounds even if the buzzer is muted in Settings (that's the point of a find-me signal). !gps on / !gps off Enables/disables GPS, same effect as the Home page's GPS toggle. !gps fix [seconds] Single-shot location: turns GPS on if it wasn't already, waits for a stabilised fix (HDOP \u2264 2.0, or \u22658 satellites on GPS hardware that doesn't report HDOP, averaged over 10s), sends the position, then restores GPS to whatever state it was in before. Replies in two parts \u2014 an immediate GPS: acquiring fix... ack, then the position (or GPS: no fix (timeout) / a partial fix) as a follow-up message up to seconds later (default 90s, clamped to 15-300s) \u2014 raise it under poor sky view, where 90s isn't always enough to reach the HDOP/satellite bar. Only one !gps fix can be in flight at a time; a second one gets GPS: fix already pending. !advert Sends an advert immediately, same as the Home page's manual advert action. !batt !gps on answers with 4.10V | GPS: on in a single reply. With Actions OFF for a target, !buzz/!gps/!gps fix/!advert are silently ignored (no reply, no effect) exactly like any other unrecognised command, and !help's reply doesn't mention them.!gpio1..!gpio4.d hh:mm:ss) Total rx/tx All received / transmitted packets, summed across the categories below Msg Text and group-text packets, rx/tx Advert Advert packets, rx/tx Ack/Path Ack, path-return and trace packets, rx/tx Other Everything else (requests, responses, control, raw, \u2026), rx/tx Forwarded Packets this node actually re-transmitted as a repeater (reflects overhear suppression, if on) Heap free Free / total heap Stack free Current task's minimum-ever stack headroom Noise floor Live radio noise floor (dBm) RSSI/SNR Signal strength / signal-to-noise of the last received packet Pool free Free entries in the packet pool Queue Packets waiting in the outbound queue Errors Radio error flags since boot/reset \u2014 OK, or tokens F (queue full), C (CAD timeout), R (RX-start timeout) RXPS wd s/h RX duty-cycle watchdog recovery count, soft/hard \u2014 how many times the background watchdog has re-armed (soft) or fully reset (hard) a stuck duty-cycle sequencer. Stays 0/0 unless Settings \u203a Radio \u203a Pwr save is on and something actually went wrong. GPIO1: Input, GPIO1: Output, \u2026).
Input (High) / Input (Low), refreshed continuously.1650mV. Has no State row \u2014 it's read-only.Output on the Mode row; the actual ON/OFF value lives on the State row below it.!gpio1..!gpio4 commands (see Actions under Remote Bot below) \u2014 both paths read/write the same underlying state, so the Tools screen and the bot never disagree. A bare !gpio1 reports the pin's current mode and reading (gpio1: out on, gpio1: in on, or gpio1: 1650mV in Analog mode); !gpio1 on/!gpio1 off only takes effect if that pin is currently set to Output here (otherwise the bot replies \"not output\", including when the pin is in Analog mode).-- in Settings while the repeater is on.
Tab Rows System Name, Owner info, Admin password Radio Frequency, Bandwidth, Spreading factor, Coding rate, TX power Routing Repeat, Advert interval, Flood advert interval, Max hops Actions Send advert, Send zero-hop advert, Sync clock, Reboot, Custom command... password CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with admin-level permission unlocks the next step \u2014 anything less shows \"Not admin on this node\".radio value together \u2014 editing any one of them still only overwrites that one, the other three round-trip unchanged. Enter sends the change; Cancel discards it and returns to the row list without sending anything. - Admin password has no fetch (there's no way to read a password back) \u2014 it opens straight to a blank keyboard. - Actions (Reboot, Send advert, \u2026) send immediately, no editing step. - Custom command... (last row of Actions) opens the same free-text entry for anything not covered above \u2014 up to 160 characters, see the linked reference for the full grammar. The keyboard's {} key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it. 4. Read the reply \u2014 the text reply opens in a scrollable view (UP/DOWN to scroll, Cancel/Enter to go back to the category tabs).reboot, erase, a new admin password, and others. That's the same capability the phone app's repeater-admin feature already exposes, not a new risk, but double-check the value and the target before sending.
"},{"location":"cli_commands/","title":"CLI Commands","text":"
"},{"location":"cli_commands/#operational","title":"Operational","text":""},{"location":"cli_commands/#reboot-the-node","title":"Reboot the node","text":"
rebootpoweroff, or - shutdownclkrebootclock syncclocktime <epoch_seconds>epoch_seconds: Unix epoch timeadvertadvert.zerohopstart otaeraseneighbors{pubkey-prefix}:{timestamp}:{snr*4}neighbor.remove <pubkey_prefix>pubkey_prefix: The public key of the node to remove from the neighbors list. This can be a short prefix or the full key. All neighbors matching the provided prefix will be removed.discover.neighborsclear statsstats-corestats-radiostats-packetslog startlog stoplog eraselogverboardget radio - set radio <freq>,<bw>,<sf>,<cr>freq: Frequency in MHz - bw: Bandwidth in kHz - sf: Spreading factor (5-12) - cr: Coding rate (5-8)LORA_FREQ, LORA_BW, LORA_SF, LORA_CR869.525,250,11,5get tx - set tx <dbm>dbm: Power level in dBm (1-22)LORA_TX_POWERtempradio <freq>,<bw>,<sf>,<cr>,<timeout_mins>freq: Frequency in MHz (300-2500) - bw: Bandwidth in kHz (7.8-500) - sf: Spreading factor (5-12) - cr: Coding rate (5-8) - timeout_mins: Duration in minutes (must be > 0)get freq - set freq <frequency>frequency: Frequency in MHz869.525set freq <frequency>get radio.rxgain - set radio.rxgain <state>state: on|offonoff because of #2118get radio.fem.rxgain - set radio.fem.rxgain <state>state: on|offradio.rxgain, which controls the radio chip receive gain mode.get name - set name <name>name: Node nameADVERT_NAMEget lat - set lat <degrees>ADVERT_LAT0degrees: Latitude in degreesget lon - set lon <degrees>ADVERT_LON0degrees: Longitude in degreesget prv.key - set prv.key <private_key>private_key: Private key in hex format (64 hex characters)get prv.key: Yes - set prv.key: Nopassword <new_password>new_password: New admin passwordADMIN_PASSWORDpasswordget guest.password - set guest.password <password>password: Guest passwordROOM_PASSWORD (Room Server only)<blank>get owner.info - set owner.info <text>text: Owner information text<blank>| characters are translated to newlinesget adc.multiplier - set adc.multiplier <value>value: ADC multiplier (0.0-10.0)0.0 (value defined by board)get public.keyverget rolepowersaving - powersaving on - powersaving offon: enable power saving - off: disable power savingoffget repeat - set repeat <state>state: on|offonget path.hash.mode - set path.hash.mode <value>value: Path hash size (0-2) - 0: 1 Byte hash size (256 unique ids)[64 max flood] - 1: 2 Byte hash size (65,536 unique ids)[32 max flood] - 2: 3 Byte hash size (16,777,216 unique ids)[21 max flood] - 3: DO NOT USE (Reserved) 0get loop.detect - set loop.detect <state>state: - off: no loop detection is performed - minimal: packets are dropped if repeater's ID/hash appears 4 or more times (1-byte), 2 or more (2-byte), 1 or more (3-byte) - moderate: packets are dropped if repeater's ID/hash appears 2 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte) - strict: packets are dropped if repeater's ID/hash appears 1 or more times (1-byte), 1 or more (2-byte), 1 or more (3-byte)offloop.detect minimal, and a 1-byte path size packet is received, the repeater will see if its own ID/hash is already in the path. If it's already encoded 4 times, it will reject the packet. If the packet uses 2-byte path size, and repeater's own ID/hash is already encoded 2 times, it rejects. If the packet uses 3-byte path size, and the repeater's own ID/hash is already encoded 1 time, it rejects. get txdelay - set txdelay <value>value: Transmit delay factor (0-2)0.50 disables the window entirely.get direct.txdelay - set direct.txdelay <value>value: Direct transmit delay factor (0-2)0.2txdelay, but applied to direct (non-flood, routed) traffic. The default is lower because direct packets are addressed to a specific next hop, so far fewer nodes compete to retransmit them.get rxdelay - set rxdelay <value>value: Receive delay base (0-20)0.0get dutycycle - set dutycycle <value>value: Duty cycle percentage (1-100)50% (equivalent to airtime factor 1.0)set dutycycle 100 \u2014 no duty cycle limit - set dutycycle 50 \u2014 50% duty cycle (default) - set dutycycle 10 \u2014 10% duty cycle - set dutycycle 1 \u2014 1% duty cycle (strictest EU requirement)get/set dutycycle instead.get af - set af <value>value: Airtime factor (0-9). After each transmission, the repeater enforces a silent period of approximately the on-air transmission time multiplied by the value. This results in a long-term duty cycle of roughly 1 divided by (1 plus the value). For example: - af = 1 \u2192 ~50% duty - af = 2 \u2192 ~33% duty - af = 3 \u2192 ~25% duty - af = 9 \u2192 ~10% duty You are responsible for choosing a value that is appropriate for your jurisdiction and channel plan (for example EU 868 Mhz 10% duty cycle regulation).1.0get int.thresh - set int.thresh <value>value: Interference threshold value0.0get cad - set cad <on|off>int.thresh \u2014 either, both, or none may be active.on|off: Enable or disable hardware CADoffget agc.reset.interval - set agc.reset.interval <value>value: Interval in seconds rounded down to a multiple of 4 (17 becomes 16). 0 to disable.0.0get multi.acks - set multi.acks <state>state: 0 (disable) or 1 (enable)0get flood.advert.interval - set flood.advert.interval <hours>hours: Interval in hours (3-168)12 (Repeater) - 0 (Sensor)get advert.interval - set advert.interval <minutes>minutes: Interval in minutes rounded down to the nearest multiple of 2 (61 becomes 60) (60-240)0get flood.max - set flood.max <value>value: Maximum flood hop count (0-64)64get flood.max.unscoped - set flood.max.unscoped <value>value: Maximum flood hop count (0-64) for a packet without a scope (no region set)64 - (0xFF indicates it hasn't been set, will track flood.max until it is.)region denyf *, setting flood.max.unscoped to a lower value such as 3 would allow for local unscoped messages to propagate, while preventing noisy neighbors from flooding a local region.get flood.max.advert - set flood.max.advert <value>value: Maximum flood hop count (0-64) for an advert packet8setperm <pubkey> <permissions>pubkey: Companion public key - permissions: - 0: Guest - 1: Read-only - 2: Read-write - 3: Adminpermissions is omittedget aclget allow.read.only - set allow.read.only <state>state: on (enable) or off (disable)offregion load - region load <name> [flood_flag]name: A name of a region. * represents the wildcard regionflood_flag: Optional F to allow floodingregion load with an empty name will not work remotely (it's interactive)region saveregion allowf <name>name: Region name (or * for wildcard)* allows packets without region transport codesregion denyf <name>name: Region name (or * for wildcard)* drops packets without region transport codesregion get <name>name: Region name (or * for wildcard)region home - region home <name>name: Region nameregion default - region default {name|<null>}name: Region name, or to reset/clear"},{"location":"cli_commands/#create-a-new-region","title":"Create a new region","text":"region put <name> [parent_name]name: Region name - parent_name: Parent region name (optional, defaults to wildcard)region def <token> [<token> ...]*.
name \u2014 Create name as a child of the current cursor (equivalent to region put name with the cursor as parent). Cursor moves to name.name|jump (or name,jump) \u2014 Create name as a child of the current cursor, then move the cursor to jump (must already exist on the node, or have been created earlier in this command). jump is not the parent of name; use this form to pop back up and start another branch.region put). The reply is the resulting region tree (same format as bare region); review it before running region save to persist. On error, the reply is Err - ... and any regions placed before the failure remain on the node, just like a partial chain of region put.region def does not clear the existing tree \u2014 if a name already exists, its parent is updated to the current cursor; otherwise a new region is created. To start from scratch, region remove the unwanted regions first.region def commands; the cursor resets to * between commands, so lead the next command with child|ancestor to reposition. Each token splits at most once on | \u2014 region def a|b|c|d is not a flat-list shorthand; see the flat-list example below.region def a b c d e\nregion save\nregion put a, region put b a, region put c b, region put d c, region put e b, region put f e):region def a b c d|b e f\nregion save\nregion def a b c|nope d\nErr - unknown jump: nope. a, b, and c were placed before the failure; d was not. Run region to inspect, then re-run with a corrected jump or repair with region remove / region put.*). Use |* after each token to pop the cursor back to the root before the next token:
"},{"location":"cli_commands/#remove-a-region","title":"Remove a region","text":"region def a|* b|* c|* d|* e|* f\nregion save\nregion remove <name>name: Region nameregion list <filter>filter: allowed|deniedregionregion load\n#Europe F\n<blank line to end region load>\nregion save\n#Europe with flooding enabled - Packets from this region will be flooded to other nodesregion load \n* F\n<blank line to end region load>\nregion save\n* with flooding enabled - Enables flooding for all regions automatically - Applies only to packets without transport codesregion load \n*\n<blank line to end region load>\nregion save\n* without flooding - This region exists but doesn't affect packet distribution - Used as a default/empty regionregion load \n#Europe F\n #UK\n #London\n #Manchester\n #France\n #Paris\n #Lyon\n<blank line to end region load>\nregion save\n#Europe region with flooding enabled - Adds nested child regions (#UK, #France) - All nested regions inherit the flooding flag from parentregion load \n* F\n #NorthAmerica\n #USA\n #NewYork\n #California\n #Canada\n #Ontario\n #Quebec\n<blank line to end region load>\nregion save\n* with flooding enabled - Adds nested #NorthAmerica hierarchy - Enables flooding for all child regions automatically - Useful for global networks with specific regional rulesgps - gps <state>state: on|offoffoff when the GPS hardware is disabled - on, {active|deactivated}, {fix|no fix}, {sat count} sats when the GPS hardware is enabledgps syncgps setlocgps advert - gps advert <policy>policy: none|share|prefs - none: don't include location in adverts - share: share gps location (from SensorManager) - prefs: location stored in node's lat and lon settingsprefssensor list [start]start: Optional starting index (defaults to 0)<var_name>=<value>\\nsensor get <key> - sensor set <key> <value>key: Sensor setting name - value: The value to set the sensor toget bridge.typeget bridge.enabled - set bridge.enabled <state>state: on|offoffget bridge.delay - set bridge.delay <ms>ms: Delay in milliseconds (0-10000)500get bridge.source - set bridge.source <source>source: - logRx: bridges received packets - logTx: bridges transmitted packetslogTxget bridge.baud - set bridge.baud <rate>rate: Baud rate (9600, 19200, 38400, 57600, or 115200)115200get bridge.channel - set bridge.channel <channel>channel: Channel number (1-14)get bridge.secret - set bridge.secret <secret>secret: ESP-NOW bridge secret, up to 15 charactersget bootloader.verget pwrmgt.supportget pwrmgt.sourceget pwrmgt.bootreasonget pwrmgt.bootmv_ethernet firmware variants (e.g. RAK_4631_repeater_ethernet) to enable this feature.eth.statusETH: <ip>:<port> when connected (e.g. ETH: 192.168.1.50:23) - ETH: not connected when Ethernet is not activenc, PuTTY) to access the same CLI available over serial.
"},{"location":"companion_protocol/#important-security-note","title":"Important Security Note","text":"
"},{"location":"companion_protocol/#table-of-contents","title":"Table of Contents","text":"
"},{"location":"companion_protocol/#ble-connection","title":"BLE Connection","text":""},{"location":"companion_protocol/#service-and-characteristics","title":"Service and Characteristics","text":"
"},{"location":"companion_protocol/#connection-steps","title":"Connection Steps","text":"6E400001-B5A3-F393-E0A9-E50E24DCCA9E6E400002-B5A3-F393-E0A9-E50E24DCCA9E6E400003-B5A3-F393-E0A9-E50E24DCCA9E
6E400001-B5A3-F393-E0A9-E50E24DCCA9E6E400002-B5A3-F393-E0A9-E50E24DCCA9E
6E400003-B5A3-F393-E0A9-E50E24DCCA9E
CMD_APP_START to identify your app to firmware and get radio settingsCMD_DEVICE_QUERY to fetch device info and negotiate supported protocol versionsCMD_SET_DEVICE_TIME to set the firmware clockCMD_GET_CONTACTS to fetch all contactsCMD_GET_CHANNEL multiple times to fetch all channel slotsCMD_SYNC_NEXT_MESSAGE to fetch the next message stored in firmwarePUSH_CODE_MSG_WAITING or PUSH_CODE_ADVERT
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT or WRITE_TYPE_NO_RESPONSECBCharacteristicWriteType.withResponse or .withoutResponsewrite_gatt_char() with response=True or FalseSET_CHANNEL (50 bytes), you may need to:
"},{"location":"companion_protocol/#command-sequencing","title":"Command Sequencing","text":"
gatt.requestMtu(512)peripheral.maximumWriteValueLength(for:)
"},{"location":"companion_protocol/#command-queue-management","title":"Command Queue Management","text":"
CMD_GET_CHANNEL \u2192 RESP_CODE_CHANNEL_INFO)
"},{"location":"companion_protocol/#packet-structure","title":"Packet Structure","text":"
[Packet Type (1 byte)] [Data (variable length)]\nByte 0: 0x01\nBytes 1-7: Reserved (currently ignored by firmware)\nBytes 8+: Application name (UTF-8, optional)\n01 00 00 00 00 00 00 00 6d 63 63 6c 69\nPACKET_SELF_INFO (0x05)Byte 0: 0x16\nByte 1: 0x03\n16 03\nPACKET_DEVICE_INFO (0x0D) with device informationByte 0: 0x1F\nByte 1: Channel Index (0-7)\n1F 01\nPACKET_CHANNEL_INFO (0x12) with channel detailsByte 0: 0x20\nByte 1: Channel Index (0-7)\nBytes 2-33: Channel Name (32 bytes, UTF-8, null-padded)\nBytes 34-49: Secret (16 bytes)\n20 01 53 4D 53 00 00 ... (name padded to 32 bytes)\n [16 bytes of secret]\nPACKET_ERROR.PACKET_OK (0x00) on success, PACKET_ERROR (0x01) on failureByte 0: 0x03\nByte 1: 0x00\nByte 2: Channel Index (0-7)\nBytes 3-6: Timestamp (32-bit little-endian Unix timestamp, seconds)\nBytes 7+: Message Text (UTF-8, variable length)\n03 00 01 D2 02 96 49 48 65 6C 6C 6F\nPACKET_MSG_SENT (0x06) on successByte 0: 0x3E\nByte 1: Channel Index (0-7)\nByte 2: Path Length (0xFF = flood, otherwise actual path length)\nBytes 3 .. 2+path_len: Path (omitted when path_len == 0xFF)\nNext 2 bytes (little-endian): Data Type (`data_type`, uint16)\nRemaining bytes: Binary payload (variable length)\nDATA_TYPE_DEV, payload A1 B2 C3, channel 1):3E 01 FF FF FF A1 B2 C3\n0x0000 (DATA_TYPE_RESERVED) is invalid and rejected with PACKET_ERROR. - 0xFFFF (DATA_TYPE_DEV) is the developer namespace for experimenting and developing apps. - Values 0x0001\u20130xFFFE are available for registered application/community namespaces. See the Registered data_type values table below.MAX_CHANNEL_DATA_LENGTH = MAX_FRAME_SIZE - 9 = 163 bytes. - Larger payloads are rejected with PACKET_ERROR (ERR_CODE_ILLEGAL_ARG).PACKET_OK (0x00) on success, or PACKET_ERROR (0x01) with one of: - ERR_CODE_NOT_FOUND (2) \u2014 unknown channel_idx - ERR_CODE_ILLEGAL_ARG (6) \u2014 invalid path_len, reserved data_type (0x0000), or payload larger than MAX_CHANNEL_DATA_LENGTH - ERR_CODE_TABLE_FULL (3) \u2014 outbound send queue is full; retry laterRESP_CODE_CHANNEL_DATA_RECV (0x1B); see Receive Channel Data Datagram.data_type values","text":"data_type is an application identifier, not a payload-format identifier. Each registered value identifies an application that owns its own internal payload schemas. The firmware does not inspect payload contents \u2014 data_type is transported opaquely.DATA_TYPE_RESERVED Reserved; invalid on send 0x0001 \u2013 0x00FF \u2014 Reserved for internal use 0x0100 \u2013 0xFEFF \u2014 Registered application namespaces (see number_allocations.md) 0xFF00 \u2013 0xFFFE \u2014 Testing/development; no registration required 0xFFFF DATA_TYPE_DEV Developer/experimental namespace PAYLOAD_TYPE_GRP_DATA, 0x06) are forwarded to the host as RESP_CODE_CHANNEL_DATA_RECV notifications.RESP_CODE_CHANNEL_DATA_RECV, 0x1B):Byte 0: 0x1B (packet type)\nByte 1: SNR (signed int8, scaled \u00d74 \u2014 divide by 4.0 to recover dB)\nBytes 2-3: Reserved (clients MUST ignore)\nByte 4: Channel Index (0-7)\nByte 5: Path Length (actual path length when flooded, otherwise 0xFF for direct)\nBytes 6-7: Data Type (uint16 little-endian)\nByte 8: Data Length\nBytes 9 .. 8+data_len: Payload\npath_len is reported in the receive frame \u2014 the path itself is not copied to the host. There are no path bytes between byte 5 and the data_type field at bytes 6\u20137, regardless of path_len.path_len = 0xFF path_len \u2260 0xFF Send Flood the network Direct route; the encoded path follows (low 6 bits = hash count, top 2 bits + 1 = hash size; on-wire byte count = hash_count \u00d7 hash_size) Receive Packet arrived via direct route Packet was flooded; this is the encoded pkt->path_len field as observed (no path bytes follow) 0xFF is inverted between the two directions, and on receive the field carries metadata only \u2014 never a routable path. path_len is an encoded byte (see Packet::isValidPathLen / Packet::writePath in src/Packet.cpp), not a raw byte count.PACKET_MESSAGES_WAITING (0x83) to notify the host that datagrams are queued; poll with CMD_SYNC_NEXT_MESSAGE (0x0A) to retrieve them.
"},{"location":"companion_protocol/#7-get-message","title":"7. Get Message","text":"def parse_channel_data_recv(data):\n if len(data) < 9:\n return None\n snr_byte = data[1]\n snr = (snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0\n channel_idx = data[4]\n path_len = data[5]\n data_type = int.from_bytes(data[6:8], 'little')\n data_len = data[8]\n if 9 + data_len > len(data):\n return None\n payload = data[9:9 + data_len]\n return {\n 'snr': snr,\n 'channel_idx': channel_idx,\n 'path_len': path_len,\n 'data_type': data_type,\n 'payload': bytes(payload),\n }\nByte 0: 0x0A\n0A\nPACKET_CHANNEL_MSG_RECV (0x08) or PACKET_CHANNEL_MSG_RECV_V3 (0x11) for channel messages - PACKET_CONTACT_MSG_RECV (0x07) or PACKET_CONTACT_MSG_RECV_V3 (0x10) for contact messages - PACKET_CHANNEL_DATA_RECV (0x1B) for channel data datagrams - PACKET_NO_MORE_MSGS (0x0A) if no messages availablePACKET_MESSAGES_WAITING (0x83) as a notification when messages are available.Byte 0: 0x14\n14\nPACKET_BATTERY (0x0C) with battery millivolts and storage information
"},{"location":"companion_protocol/#channel-lifecycle","title":"Channel Lifecycle","text":"
8b3387e9c5cdea6ac9e5edbaa115cd72
sha256(\"#test\")#test has the key: 9cd8fcf22a47333b591d96a2b848b73f
"},{"location":"companion_protocol/#message-handling","title":"Message Handling","text":""},{"location":"companion_protocol/#receiving-messages","title":"Receiving Messages","text":"
CMD_SET_CHANNEL with name and a 16-byte secret
CMD_GET_CHANNEL with channel indexRESP_CODE_CHANNEL_INFO response
CMD_SET_CHANNEL with empty name and all-zero secret
"},{"location":"companion_protocol/#contact-message-format","title":"Contact Message Format","text":"PACKET_CHANNEL_MSG_RECV (0x08) - Standard formatPACKET_CHANNEL_MSG_RECV_V3 (0x11) - Version 3 with SNRPACKET_CONTACT_MSG_RECV (0x07) - Standard formatPACKET_CONTACT_MSG_RECV_V3 (0x10) - Version 3 with SNRPACKET_MESSAGES_WAITING (0x83) - Indicates messages are queuedPACKET_CONTACT_MSG_RECV, 0x07):Byte 0: 0x07 (packet type)\nBytes 1-6: Public Key Prefix (6 bytes, hex)\nByte 7: Path Length\nByte 8: Text Type\nBytes 9-12: Timestamp (32-bit little-endian)\nBytes 13-16: Signature (4 bytes, only if txt_type == 2)\nBytes 17+: Message Text (UTF-8)\nPACKET_CONTACT_MSG_RECV_V3, 0x10):Byte 0: 0x10 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nBytes 4-9: Public Key Prefix (6 bytes, hex)\nByte 10: Path Length\nByte 11: Text Type\nBytes 12-15: Timestamp (32-bit little-endian)\nBytes 16-19: Signature (4 bytes, only if txt_type == 2)\nBytes 20+: Message Text (UTF-8)\n
"},{"location":"companion_protocol/#channel-message-format","title":"Channel Message Format","text":"def parse_contact_message(data):\n packet_type = data[0]\n offset = 1\n\n # Check for V3 format\n if packet_type == 0x10: # V3\n snr_byte = data[offset]\n snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n offset += 3 # Skip SNR + reserved\n\n pubkey_prefix = data[offset:offset+6].hex()\n offset += 6\n\n path_len = data[offset]\n txt_type = data[offset + 1]\n offset += 2\n\n timestamp = int.from_bytes(data[offset:offset+4], 'little')\n offset += 4\n\n # If txt_type == 2, skip 4-byte signature\n if txt_type == 2:\n offset += 4\n\n message = data[offset:].decode('utf-8')\n\n return {\n 'pubkey_prefix': pubkey_prefix,\n 'path_len': path_len,\n 'txt_type': txt_type,\n 'timestamp': timestamp,\n 'message': message,\n 'snr': snr if packet_type == 0x10 else None\n }\nPACKET_CHANNEL_MSG_RECV, 0x08):Byte 0: 0x08 (packet type)\nByte 1: Channel Index (0-7)\nByte 2: Path Length\nByte 3: Text Type\nBytes 4-7: Timestamp (32-bit little-endian)\nBytes 8+: Message Text (UTF-8)\nPACKET_CHANNEL_MSG_RECV_V3, 0x11):Byte 0: 0x11 (packet type)\nByte 1: SNR (signed byte, multiplied by 4)\nBytes 2-3: Reserved\nByte 4: Channel Index (0-7)\nByte 5: Path Length\nByte 6: Text Type\nBytes 7-10: Timestamp (32-bit little-endian)\nBytes 11+: Message Text (UTF-8)\n
"},{"location":"companion_protocol/#sending-messages","title":"Sending Messages","text":"def parse_channel_message(data):\n packet_type = data[0]\n offset = 1\n\n # Check for V3 format\n if packet_type == 0x11: # V3\n snr_byte = data[offset]\n snr = ((snr_byte if snr_byte < 128 else snr_byte - 256) / 4.0)\n offset += 3 # Skip SNR + reserved\n\n channel_idx = data[offset]\n path_len = data[offset + 1]\n txt_type = data[offset + 2]\n timestamp = int.from_bytes(data[offset+3:offset+7], 'little')\n message = data[offset+7:].decode('utf-8')\n\n return {\n 'channel_idx': channel_idx,\n 'timestamp': timestamp,\n 'message': message,\n 'snr': snr if packet_type == 0x11 else None\n }\nSEND_CHANNEL_MESSAGE command (see Commands).PACKET_*) for bytes the firmware sends back to the host. In the firmware source these same values are split across two #define families by purpose:
RESP_CODE_* \u2014 direct replies to a command (e.g. RESP_CODE_CHANNEL_DATA_RECV = PACKET_CHANNEL_DATA_RECV = 0x1B).PUSH_CODE_* \u2014 asynchronous notifications not tied to a specific command (e.g. PUSH_CODE_MSG_WAITING = PACKET_MESSAGES_WAITING = 0x83).RESP_CODE_X / PUSH_CODE_X correspond to this doc's PACKET_X of the same numeric value.Byte 0: 0x00\nBytes 1-4: Optional value (32-bit little-endian integer)\nByte 0: 0x01\nByte 1: Error code (optional)\nByte 0: 0x12\nByte 1: Channel Index\nBytes 2-33: Channel Name (32 bytes, null-terminated)\nBytes 34-49: Secret (16 bytes)\nByte 0: 0x0D\nByte 1: Firmware Version (uint8)\nBytes 2+: Variable length based on firmware version\n\nFor firmware version >= 3:\nByte 2: Max Contacts Raw (uint8, actual = value * 2)\nByte 3: Max Channels (uint8)\nBytes 4-7: BLE PIN (32-bit little-endian)\nBytes 8-19: Firmware Build (12 bytes, UTF-8, null-padded)\nBytes 20-59: Model (40 bytes, UTF-8, null-padded)\nBytes 60-79: Version (20 bytes, UTF-8, null-padded)\nByte 80: Client repeat enabled/preferred (firmware v9+)\nByte 81: Path hash mode (firmware v10+)\ndef parse_device_info(data):\n if len(data) < 2:\n return None\n\n fw_ver = data[1]\n info = {'fw_ver': fw_ver}\n\n if fw_ver >= 3 and len(data) >= 80:\n info['max_contacts'] = data[2] * 2\n info['max_channels'] = data[3]\n info['ble_pin'] = int.from_bytes(data[4:8], 'little')\n info['fw_build'] = data[8:20].decode('utf-8').rstrip('\\x00').strip()\n info['model'] = data[20:60].decode('utf-8').rstrip('\\x00').strip()\n info['ver'] = data[60:80].decode('utf-8').rstrip('\\x00').strip()\n\n return info\nByte 0: 0x0C\nBytes 1-2: Battery Voltage (16-bit little-endian, millivolts)\nBytes 3-6: Used Storage (32-bit little-endian, KB)\nBytes 7-10: Total Storage (32-bit little-endian, KB)\ndef parse_battery(data):\n if len(data) < 3:\n return None\n\n mv = int.from_bytes(data[1:3], 'little')\n info = {'battery_mv': mv}\n\n if len(data) >= 11:\n info['used_kb'] = int.from_bytes(data[3:7], 'little')\n info['total_kb'] = int.from_bytes(data[7:11], 'little')\n\n return info\nByte 0: 0x05\nByte 1: Advertisement Type\nByte 2: TX Power\nByte 3: Max TX Power\nBytes 4-35: Public Key (32 bytes, hex)\nBytes 36-39: Advertisement Latitude (32-bit little-endian, divided by 1e6)\nBytes 40-43: Advertisement Longitude (32-bit little-endian, divided by 1e6)\nByte 44: Multi ACKs\nByte 45: Advertisement Location Policy\nByte 46: Telemetry Mode (bitfield)\nByte 47: Manual Add Contacts (bool)\nBytes 48-51: Radio Frequency (32-bit little-endian, divided by 1000.0)\nBytes 52-55: Radio Bandwidth (32-bit little-endian, divided by 1000.0)\nByte 56: Radio Spreading Factor\nByte 57: Radio Coding Rate\nBytes 58+: Device Name (UTF-8, variable length, no null terminator required)\ndef parse_self_info(data):\n if len(data) < 36:\n return None\n\n offset = 1\n info = {\n 'adv_type': data[offset],\n 'tx_power': data[offset + 1],\n 'max_tx_power': data[offset + 2],\n 'public_key': data[offset + 3:offset + 35].hex()\n }\n offset += 35\n\n lat = int.from_bytes(data[offset:offset+4], 'little') / 1e6\n lon = int.from_bytes(data[offset+4:offset+8], 'little') / 1e6\n info['adv_lat'] = lat\n info['adv_lon'] = lon\n offset += 8\n\n info['multi_acks'] = data[offset]\n info['adv_loc_policy'] = data[offset + 1]\n telemetry_mode = data[offset + 2]\n info['telemetry_mode_env'] = (telemetry_mode >> 4) & 0b11\n info['telemetry_mode_loc'] = (telemetry_mode >> 2) & 0b11\n info['telemetry_mode_base'] = telemetry_mode & 0b11\n info['manual_add_contacts'] = data[offset + 3] > 0\n offset += 4\n\n freq = int.from_bytes(data[offset:offset+4], 'little') / 1000.0\n bw = int.from_bytes(data[offset+4:offset+8], 'little') / 1000.0\n info['radio_freq'] = freq\n info['radio_bw'] = bw\n info['radio_sf'] = data[offset + 8]\n info['radio_cr'] = data[offset + 9]\n offset += 10\n\n if offset < len(data):\n name_bytes = data[offset:]\n info['name'] = name_bytes.decode('utf-8').rstrip('\\x00').strip()\n\n return info\nByte 0: 0x06\nByte 1: Route Flag (0 = direct, 1 = flood)\nBytes 2-5: Tag / Expected ACK (4 bytes, little-endian)\nBytes 6-9: Suggested Timeout (32-bit little-endian, milliseconds)\n
"},{"location":"companion_protocol/#error-codes","title":"Error Codes","text":"Byte 0: 0x82\nBytes 1-6: ACK Code (6 bytes, hex)\nPACKET_ERROR (0x01) carries a single-byte error code in byte 1. Values match the ERR_CODE_* constants defined in examples/companion_radio/MyMesh.cpp:ERR_CODE_UNSUPPORTED_CMD Unknown or unsupported command byte / sub-command 2 ERR_CODE_NOT_FOUND Target not found (channel, contact, message, etc.) 3 ERR_CODE_TABLE_FULL Internal queue or table is full \u2014 retry later 4 ERR_CODE_BAD_STATE Operation not valid in current device state (e.g. iterator already running) 5 ERR_CODE_FILE_IO_ERROR Filesystem or storage I/O failure 6 ERR_CODE_ILLEGAL_ARG Invalid argument (bad length, out-of-range value, reserved field, etc.) PACKET_ERROR response, and treat unknown codes as generic errors.
"},{"location":"companion_protocol/#response-handling","title":"Response Handling","text":"
"},{"location":"companion_protocol/#example-implementation-flow","title":"Example Implementation Flow","text":""},{"location":"companion_protocol/#initialization","title":"Initialization","text":"PACKET_MESSAGES_WAITING (0x83) by polling GET_MESSAGE command
APP_START \u2192 PACKET_SELF_INFODEVICE_QUERY \u2192 PACKET_DEVICE_INFOGET_CHANNEL \u2192 PACKET_CHANNEL_INFOSET_CHANNEL \u2192 PACKET_OK or PACKET_ERRORSEND_CHANNEL_MESSAGE \u2192 PACKET_MSG_SENTGET_MESSAGE \u2192 PACKET_CHANNEL_MSG_RECV, PACKET_CONTACT_MSG_RECV, PACKET_CHANNEL_DATA_RECV, or PACKET_NO_MORE_MSGSSEND_CHANNEL_DATA \u2192 PACKET_OK or PACKET_ERRORGET_BATTERY \u2192 PACKET_BATTERYSET_CHANNEL may need 1-2 seconds)PACKET_ERROR: Log error code, clear current command
"},{"location":"companion_protocol/#creating-a-private-channel","title":"Creating a Private Channel","text":"# 1. Scan for MeshCore device\ndevice = scan_for_device(\"MeshCore\")\n\n# 2. Connect to BLE GATT\ngatt = connect_to_device(device)\n\n# 3. Discover services and characteristics\nservice = discover_service(gatt, \"6E400001-B5A3-F393-E0A9-E50E24DCCA9E\")\nrx_char = discover_characteristic(service, \"6E400002-B5A3-F393-E0A9-E50E24DCCA9E\")\ntx_char = discover_characteristic(service, \"6E400003-B5A3-F393-E0A9-E50E24DCCA9E\")\n\n# 4. Enable notifications on TX characteristic\nenable_notifications(tx_char, on_notification_received)\n\n# 5. Send AppStart command\nsend_command(rx_char, build_app_start())\nwait_for_response(PACKET_SELF_INFO)\n
"},{"location":"companion_protocol/#sending-a-message","title":"Sending a Message","text":"# 1. Generate 16-byte secret\nsecret_16_bytes = generate_secret(16) # Use CSPRNG\nsecret_hex = secret_16_bytes.hex()\n\n# 2. Build SET_CHANNEL command\nchannel_name = \"YourChannelName\"\nchannel_index = 1 # Use 1-7 for private channels\ncommand = build_set_channel(channel_index, channel_name, secret_16_bytes)\n\n# 3. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_OK)\n\n# 4. Store secret locally\nstore_channel_secret(channel_index, secret_hex)\n
"},{"location":"companion_protocol/#receiving-messages_1","title":"Receiving Messages","text":"# 1. Build channel message command\nchannel_index = 1\nmessage = \"Hello, MeshCore!\"\ntimestamp = int(time.time())\ncommand = build_channel_message(channel_index, message, timestamp)\n\n# 2. Send command\nsend_command(rx_char, command)\nresponse = wait_for_response(PACKET_MSG_SENT)\n
"},{"location":"companion_protocol/#best-practices","title":"Best Practices","text":"def on_notification_received(data):\n packet_type = data[0]\n\n if packet_type == PACKET_CHANNEL_MSG_RECV or packet_type == PACKET_CHANNEL_MSG_RECV_V3:\n message = parse_channel_message(data)\n handle_channel_message(message)\n elif packet_type == PACKET_MESSAGES_WAITING:\n # Poll for messages\n send_command(rx_char, build_get_message())\n
"},{"location":"companion_protocol/#troubleshooting","title":"Troubleshooting","text":""},{"location":"companion_protocol/#connection-issues","title":"Connection Issues","text":"CMD_SYNC_NEXT_MESSAGE when PUSH_CODE_MSG_WAITING is received
RESP_CODE_ERR responses appropriately
"},{"location":"companion_protocol/#command-issues","title":"Command Issues","text":"
"},{"location":"companion_protocol/#message-issues","title":"Message Issues","text":"
"},{"location":"docs/","title":"Local Documentation","text":"GET_MESSAGE command periodicallypip install mkdocs\npip install mkdocs-material\n
"},{"location":"faq/","title":"Frequently Asked Questions","text":"mkdocs serve - Start the live-reloading docs server.mkdocs build - Build the documentation site.
"},{"location":"faq/#1-introduction","title":"1. Introduction","text":""},{"location":"faq/#11-q-what-is-meshcore","title":"1.1. Q: What is MeshCore?","text":"
path.hash.mode do on a repeater?
"},{"location":"faq/#124-repeater","title":"1.2.4. Repeater","text":"set repeat on, it is not recommended nor encouraged. A room server with repeat set to on lacks the full set of repeater and remote administration features that are only available in the repeater firmware.set freq {frequency}
set flood.advert.interval {hours}set advert.interval {minutes} command controls the local zero-hop advert timer.
console feature to connect to the deviceset lat <GPS Lat>set lon <GPS Lon>password. Use the following command to change the admin password:password {new-password}hello. Use the following command to change the guest password:set guest.password {guest-password}get prv.key to print a repeater's private key on the serial console set prv.key <hex> to set a repeater's private key on the serial consoleset prv.key <hex> command for the new private key to take effect.set agc.reset.interval <number><number> unit is in seconds and is incremented by 4. set agc.reset.interval 4 works well to cure deafness.state = STATE_IDLE; in function RadioLibWrapper::resetAGC() in RadioLibWrappers.cppSettings (gear icon), Experimental Settings.path.hash.mode do on a repeater?","text":"path.hash.mode only controls the path hash size used in a repeater's own advert broadcasts. It does NOT affect which packets the repeater forwards. A repeater with firmware 1.14+ always forward 1-, 2-, and 3-byte packets regardless of this setting.set path.hash.mode {0|1|2}:\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 path.hash.mode \u2502 Advert path hash size \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 0 \u2502 1 byte (default) \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 1 \u2502 2 bytes \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 2 \u2502 3 bytes \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \npath.hash.mode to 1 (for 2-byte path hash) or 2 (for 3-byte path hash) now helps the community gauge to how many repeaters have updated to 1.14+. Please work with your MeshCore community together to decide when to switch to 2-byte path or 3-byte path for channel and direct messages.
"},{"location":"faq/#43-q-why-is-my-t-deck-plus-not-getting-any-satellite-lock","title":"4.3. Q: Why is my T-Deck Plus not getting any satellite lock?","text":"GPS Info screen; you should see the Sentences: counter increasing if the baud rate is correct.izOH6cXN6mrJ5e26oRXNcg=== key on the T-Deck's hardware keyboard. You can use the on-screen software keyboard to enter =. Tap the text box to enable the on-screen software keyboard. The third character is the capital letter O (Oh), not zero 08b3387e9c5cdea6ac9e5edbaa115cd72
\\tiles folder to the root of your T-Deck's SD card.{hops} l:{packet-length}({payload-len}) t:{packet-type} snr:{n} rssi:{n}#define PAYLOAD_TYPE_REQ 0x00 // request (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)\n#define PAYLOAD_TYPE_RESPONSE 0x01 // response to REQ or ANON_REQ (prefixed with dest/src hashes, MAC) (enc data: timestamp, blob)\n#define PAYLOAD_TYPE_TXT_MSG 0x02 // a plain text message (prefixed with dest/src hashes, MAC) (enc data: timestamp, text)\n#define PAYLOAD_TYPE_ACK 0x03 // a simple ack #define PAYLOAD_TYPE_ADVERT 0x04 // a node advertising its Identity\n#define PAYLOAD_TYPE_GRP_TXT 0x05 // an (unverified) group text message (prefixed with channel hash, MAC) (enc data: timestamp, \"name: msg\")\n#define PAYLOAD_TYPE_GRP_DATA 0x06 // an (unverified) group datagram (prefixed with channel hash, MAC) (enc data: data_type, data_len, blob)\n#define PAYLOAD_TYPE_ANON_REQ 0x07 // generic request (prefixed with dest_hash, ephemeral pub_key, MAC) (enc data: ...)\n#define PAYLOAD_TYPE_PATH 0x08 // returned path (prefixed with dest/src hashes, MAC) (enc data: path, extra)\n.mp3 files onto the root dir of the SD card. The files are:
"},{"location":"faq/#413-q-what-is-the-import-from-clipboard-feature-on-the-t-deck-and-is-there-a-way-to-manually-add-nodes-without-having-to-receive-adverts","title":"4.13. Q: What is the 'Import from Clipboard' feature on the t-deck and is there a way to manually add nodes without having to receive adverts?","text":"startup.mp3error.mp3alert.mp3new-advert.mp3existing-advert.mp3set repeat on repeat.set flood.max CLI command. Administrators of repeaters get to set the rules of their repeaters.8b3387e9c5cdea6ac9e5edbaa115cd72izOH6cXN6mrJ5e26oRXNcg==O, not zero 0.sudo apt update\nsudo apt install libpython3-dev\nsudo apt install python3-venv\npython3 -m venv meshcore\ncd meshcore && source bin/activate\npip install -U platformio\ngit clone https://github.com/ripplebiz/MeshCore.git\ncd MeshCore\n[arduino_base] edit the LORA_FREQ=867.5 save, then run:pio run -e RAK_4631_Repeater\nfirmware.zip in .pio/build/RAK_4631_Repeater
3 dot menu icon at the top right corner, then tap Internet Map. Tap the 3 dot menu icon again and choose Add me to the Map3 dot next to the Repeater or Room Server you want to add to the Internet Map, tap Share, then tap Upload to Internet Map.
Heltec_V3_companion_radio_ble-v1.7.1-165fb33.bin
Heltec_v3_companion_radio_usb-v1.7.1-165fb33-merged.bin
https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin
wget https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_ble-v1.7.1-165fb33.bin to download the firmware file for your device type or the version you need: USB, BLE, Repeater, Room Server, merged bin or non-merged bin.
wget --user-agent=\"Mozilla/5.0\" --content-disposition \"https://flasher.meshcore.io/releases/download/companion-v1.7.1/Heltec_v3_companion_radio_usb-v1.7.1-165fb33.bin\"ttyXXXX device path on your Raspberry Pi.
/dev directory and run the ls command to find your device path./dev/ttyUSB0 for ESP devices.
pip install esptool --break-system-packages
esptool.py -p /dev/ttyUSB0 --chip esp32-s3 write_flash 0x10000 <non-merged_firmware>.bin
esptool.py -p /dev/ttyUSB0 --chip esp32-s3 write_flash 0x00000 <merged_firmware>.bin
RAK_4631_companion_radio_ble-v1.7.1-165fb33.ziphttps://flasher.meshcore.io/releases/download/companion-v1.7.1/RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip
wget https://flasher.meshcore.io/releases/download/companion-v1.7.1/RAK_4631_companion_radio_ble-v1.7.1-165fb33.zip to download the firmware file for your device type or the version you need: USB, BLE, Repeater, Room Server, ZIP file only.ttyXXXX device path on your Raspberry Pi.
/dev directory and run the ls command to find your device path./dev/ttyACM0 for nRF devices.
pip install adafruit-nrfutil --break-system-packages
adafruit-nrfutil --verbose dfu serial --package RAK_4631_companion_radio_usb-v1.7.1-165fb33.zip -p /dev/ttyACM0 -b 115200 --singlebank --touch 1200picocom. To install picocom, run the following command:
sudo apt install picocom
picocom -b 115200 /dev/ttyUSB0 --imap lfcrlf
"},{"location":"faq/#514-q-are-there-projects-built-around-meshcore","title":"5.14. Q: Are there projects built around MeshCore?","text":"
"},{"location":"faq/#6-troubleshooting","title":"6. Troubleshooting","text":""},{"location":"faq/#61-q-my-client-says-another-client-or-a-repeater-or-a-room-server-was-last-seen-many-many-days-ago","title":"6.1. Q: My client says another client or a repeater or a room server was last seen many, many days ago.","text":""},{"location":"faq/#62-q-a-repeater-or-a-client-or-a-room-server-i-expect-to-see-on-my-discover-list-on-t-deck-or-contact-list-on-a-smart-device-client-are-not-listed","title":"6.2. Q: A repeater or a client or a room server I expect to see on my discover list (on T-Deck) or contact list (on a smart device client) are not listed.","text":"
time command in the USB serial console with the server device connected.123456
flash_erase*.uf2 file for your device on https://flasher.meshcore.io
Flash_erase-nRF32_softdevice_v6.uf2Flash_erase-nRF52_softdevice_v7.uf2Console and select the serial port for your connected deviceNetworkError: Failed to execute 'open' on 'SerialPort': Failed to open serial port.# setfacl -m u:YOUR_USER_HERE:rw /dev/ttyUSB0
"},{"location":"faq/#711-q-can-i-update-seeed-studio-wio-tracker-l1-pro-using-ota","title":"7.1.1 Q: Can I update Seeed Studio Wio Tracker L1 Pro using OTA?","text":"nrf dfu, the app's full name is nRF Device Firmware Updatestart ota and hit enter.OK to confirm the repeater device is now in OTA modeSettings in the top-right cornerPacket receipt notifications, and change Number of Packets to 10 for RAK, 8 for T114. 8 also works for RAK.OTA on the device againForce Scanning in the DFU appUpload to begin OTA update
start ota instruction and start the update using the DFU app.
"},{"location":"faq/#73-q-is-there-a-way-to-lower-the-chance-of-a-failed-ota-device-firmware-update-dfu","title":"7.3. Q: Is there a way to lower the chance of a failed OTA device firmware update (DFU)?","text":"Heltec_v3_repeater-v1.6.2-4449fd3.bin, no \"merged\" in the file name).start ota and hit enter.OK to confirm the repeater device is now in OTA mode.start ota on an ESP32-based device starts a Wi-Fi hotspot named MeshCore OTA.che aporeps has an enhanced OTA DFU bootloader for nRF52 based devices. With this bootloader, if it detects that the application firmware is invalid, it falls back to OTA DFU mode so you can attempt to flash again to recover. This bootloader has other changes to make the OTA DFU process more fault tolerant.
"},{"location":"faq/#74-q-are-the-meshcore-logo-and-font-available","title":"7.4. Q: Are the MeshCore logo and font available?","text":"meshcore://channel/add?name=<name>&secret=<secret>meshcore://contact/add?name=<name>&public_key=<secret>&type=<type>&type is:
"},{"location":"faq/#76-q-how-do-i-connect-to-the-companion-via-wi-fi-eg-using-a-heltec-v3","title":"7.6. Q: How do I connect to the companion via Wi-Fi, e.g. using a Heltec V3?","text":"chat = 1repeater = 2room = 3sensor = 4./variants/heltec_v3/platformio.ini and then flash it to your device.set tx. You can get their current value using command line command get txRAK_4631_repeater_ethernet - Repeater with Ethernet CLI access - RAK_4631_room_server_ethernet - Room server with Ethernet CLI access - RAK_4631_companion_radio_ethernet - Companion radio over Ethernet (replaces BLE)nc <ip> 23 or PuTTY in raw mode). This gives you the same CLI available over serial/USB. - For companion radio firmware, the Ethernet interface replaces BLE as the transport to companion apps. Connect on TCP port 5000 (same as the WiFi companion radio). - Use the eth.status CLI command to check connection status and see the assigned IP address.0xC0 FEND Frame delimiter 0xDB FESC Escape character 0xDC TFEND Escaped FEND (FESC + TFEND = 0xC0) 0xDD TFESC Escaped FESC (FESC + TFESC = 0xDB)
"},{"location":"kiss_modem_protocol/#type-byte","title":"Type Byte","text":"\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 FEND \u2502 Type Byte \u2502 Data (escaped)\u2502 FEND \u2502\n\u2502 0xC0 \u2502 1 byte \u2502 0-510 bytes \u2502 0xC0 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n0x00 Raw packet Queue packet for transmission (one pending at a time) TXDELAY 0x01 Delay (1 byte) Transmitter keyup delay in 10ms units (default: 50 = 500ms) Persistence 0x02 P (1 byte) CSMA persistence parameter 0-255 (default: 63) SlotTime 0x03 Interval (1 byte) CSMA slot interval in 10ms units (default: 10 = 100ms) TXtail 0x04 Delay (1 byte) Post-TX hold time in 10ms units (default: 0) FullDuplex 0x05 Mode (1 byte) 0 = half duplex, nonzero = full duplex (default: 0) SetHardware 0x06 Sub-command + data MeshCore extensions (see below) Return 0xFF - Exit KISS mode (no-op)"},{"location":"kiss_modem_protocol/#tnc-to-host","title":"TNC to Host","text":"Type Value Data Description Data 0x00 Raw packet Received packet from radio loop() never blocks on writes. Radio TX state advances independently of host read speed. TxDone is retained until it can be queued. If the outbound queue is full, the modem responds with Error (0xF1) and TxBusy (0x07). Hosts should read serial promptly to avoid delayed responses.
"},{"location":"kiss_modem_protocol/#request-sub-commands-host-to-tnc","title":"Request Sub-commands (Host to TNC)","text":"Sub-command Value Data GetIdentity \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 FEND \u2502 0x06 \u2502 Sub-command \u2502 Data (escaped)\u2502 FEND \u2502\n\u2502 0xC0 \u2502 \u2502 1 byte \u2502 variable \u2502 0xC0 \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n0x01 - GetRandom 0x02 Length (1 byte, 1-64) VerifySignature 0x03 PubKey (32) + Signature (64) + Data SignData 0x04 Data to sign EncryptData 0x05 Key (32) + Plaintext DecryptData 0x06 Key (32) + MAC (2) + Ciphertext KeyExchange 0x07 Remote PubKey (32) Hash 0x08 Data to hash SetRadio 0x09 Freq (4) + BW (4) + SF (1) + CR (1) SetTxPower 0x0A Power dBm (1) GetRadio 0x0B - GetTxPower 0x0C - GetCurrentRssi 0x0D - IsChannelBusy 0x0E - GetAirtime 0x0F Packet length (1) GetNoiseFloor 0x10 - GetVersion 0x11 - GetStats 0x12 - GetBattery 0x13 - GetMCUTemp 0x14 - GetSensors 0x15 Permissions (1) GetDeviceName 0x16 - Ping 0x17 - Reboot 0x18 - SetSignalReport 0x19 Enable (1): 0x00=disable, nonzero=enable GetSignalReport 0x1A -"},{"location":"kiss_modem_protocol/#response-sub-commands-tnc-to-host","title":"Response Sub-commands (TNC to Host)","text":"response = command | 0x80. Generic and unsolicited responses use the 0xF0+ range.0x81 PubKey (32) Random 0x82 Random bytes (1-64) Verify 0x83 Result (1): 0x00=invalid, 0x01=valid Signature 0x84 Signature (64) Encrypted 0x85 MAC (2) + Ciphertext Decrypted 0x86 Plaintext SharedSecret 0x87 Shared secret (32) Hash 0x88 SHA-256 hash (32) Radio 0x8B Freq (4) + BW (4) + SF (1) + CR (1) TxPower 0x8C Power dBm (1) CurrentRssi 0x8D RSSI dBm (1, signed) ChannelBusy 0x8E Result (1): 0x00=clear, 0x01=busy Airtime 0x8F Milliseconds (4) NoiseFloor 0x90 dBm (2, signed) Version 0x91 Version (1) + Reserved (1) Stats 0x92 RX (4) + TX (4) + Errors (4) Battery 0x93 Millivolts (2) MCUTemp 0x94 Temperature (2, signed) Sensors 0x95 CayenneLPP payload DeviceName 0x96 Name (variable, UTF-8) Pong 0x97 - SignalReport 0x9A Status (1): 0x00=disabled, 0x01=enabled OK 0xF0 - Error 0xF1 Error code (1) TxDone 0xF8 Result (1): 0x00=failed, 0x01=success RxMeta 0xF9 SNR (1) + RSSI (1)"},{"location":"kiss_modem_protocol/#error-codes","title":"Error Codes","text":"Code Value Description InvalidLength 0x01 Request data too short InvalidParam 0x02 Invalid parameter value NoCallback 0x03 Feature not available MacFailed 0x04 MAC verification failed UnknownCmd 0x05 Unknown sub-command EncryptFailed 0x06 Encryption failed TxBusy 0x07 Radio TX busy, or host output queue full"},{"location":"kiss_modem_protocol/#unsolicited-events","title":"Unsolicited Events","text":"NoCallback error if the board does not support temperature readings.OK response, flushes serial, then reboots the device. The host should expect the connection to drop.0x01 Base (battery) 1 0x02 Location (GPS) 2 0x04 Environment (temp, humidity, pressure) 0x07 for all permissions.
"},{"location":"nrf52_power_management/","title":"nRF52 Power Management","text":""},{"location":"nrf52_power_management/#overview","title":"Overview","text":"
"},{"location":"nrf52_power_management/#voltage-wake-lpcomp-vbus","title":"Voltage Wake (LPCOMP + VBUS)","text":"
"},{"location":"nrf52_power_management/#early-boot-register-capture","title":"Early Boot Register Capture","text":"
"},{"location":"nrf52_power_management/#shutdown-reason-tracking","title":"Shutdown Reason Tracking","text":"xiao_nrf52) Yes Yes Yes RAK4631 (rak4631) Yes Yes Yes Heltec T114 (heltec_t114) Yes Yes Yes GAT562 Mesh Watch13 Yes Yes Yes Promicro nRF52840 No No No RAK WisMesh Tag No No No Heltec Mesh Solar No No No LilyGo T-Echo / T-Echo Lite No No No SenseCAP Solar Yes Yes Yes WIO Tracker L1 / L1 E-Ink No No No WIO WM1110 No No No Mesh Pocket No No No Nano G2 Ultra No No No ThinkNode M1/M3/M6 No No No T1000-E No No No Ikoka Nano/Stick/Handheld (nRF) No No No Keepteen LT1 No No No Minewsemi ME25LS01 No No No NRF52Board base class in src/helpers/NRF52Board.cpp. Board variants provide hardware-specific configuration via a PowerMgtConfig struct and override initiateShutdown(uint8_t reason) to perform board-specific power-down work and conditionally enable voltage wake (LPCOMP + VBUS).NRF52Board.cpp captures the RESETREAS and GPREGRET2 registers before: - SystemInit() (priority 102) - which clears RESETREAS - Static C++ constructors (default priority 65535)
ini -D NRF52_POWER_MANAGEMENTc #define PWRMGT_VOLTAGE_BOOTLOCK 3300 // Won't boot below this voltage (mV) #define PWRMGT_LPCOMP_AIN 7 // AIN channel for voltage sensing #define PWRMGT_LPCOMP_REFSEL 2 // REFSEL (0-6=1/8..7/8, 7=ARef, 8-15=1/16..15/16) if (enable_lpcomp) {\n configureVoltageWake(power_config.lpcomp_ain_channel, power_config.lpcomp_refsel);\n }\n\n enterSystemOff(reason);\npowerOff() remains board-specific. Power management only arms LPCOMP for automated shutdown reasons (boot protection/low voltage).
"},{"location":"nrf52_power_management/#voltage-wake-configuration","title":"Voltage Wake Configuration","text":"cpp #ifdef NRF52_POWER_MANAGEMENT void initiateShutdown(uint8_t reason) override; #endifconfigureVoltageWake() is used. This requires USB VBUS to be routed to the nRF52 (typical on nRF52840 boards with native USB).VBAT_threshold \u2248 (VDD * fraction) * divider_scale, where divider_scale = (Rtop + Rbottom) / Rbottom (e.g., 2.0 for 1M/1M, 2.5 for 1.5M/1M, 3.0 for XIAO).sd_power_* functions - When SD disabled: Direct register access (NRF_POWER->*)get pwrmgt.support Returns \"supported\" or \"unsupported\" get pwrmgt.source Returns current power source - \"battery\" or \"external\" (5V/USB power) get pwrmgt.bootreason Returns reset and shutdown reason strings get pwrmgt.bootmv Returns boot voltage in millivolts get pwrmgt.support return:
"},{"location":"nrf52_power_management/#debug-output","title":"Debug Output","text":"ERROR: Power management not supported\nMESH_DEBUG=1 is enabled, the power management module outputs:
"},{"location":"nrf52_power_management/#phase-2-planned","title":"Phase 2 (Planned)","text":"DEBUG: PWRMGT: Reset = Wake from LPCOMP (0x20000); Shutdown = Low Voltage (0x4C)\nDEBUG: PWRMGT: Boot voltage = 3450 mV (threshold = 3300 mV)\nDEBUG: PWRMGT: LPCOMP wake configured (AIN7, ref=3/8 VDD)\n
"},{"location":"nrf52_power_management/#references","title":"References","text":"
"},{"location":"number_allocations/","title":"Number Allocations","text":"PAYLOAD_TYPE_GRP_DATA payloads have a 16-bit data-type field, which identifies which application the packet is for.
"},{"location":"packet_format/#version-1-packet-format","title":"Version 1 Packet Format","text":"0xYY indicates YY in hex notation.0bYY indicates YY in binary notation.0000000XX0000000[header][transport_codes(optional)][path_length][path][payload]\n
"},{"location":"packet_format/#packet-format_1","title":"Packet Format","text":"Field Size (bytes) Description header 1 Contains routing type, payload type, and payload version transport_codes 4 (optional) 2x 16-bit transport codes (if ROUTE_TYPE_TRANSPORT_*) path_length 1 Encodes path hash size in bits 6-7 and hop count in bits 0-5 path up to 64 (
0bVVPPPPRR - V=Version - P=PayloadType - R=RouteType
0x00/0b00 - ROUTE_TYPE_TRANSPORT_FLOOD - Flood Routing + Transport Codes0x01/0b01 - ROUTE_TYPE_FLOOD - Flood Routing0x02/0b10 - ROUTE_TYPE_DIRECT - Direct Routing0x03/0b11 - ROUTE_TYPE_TRANSPORT_DIRECT - Direct Routing + Transport Codes
0x00/0b0000 - PAYLOAD_TYPE_REQ - Request (destination/source hashes + MAC)0x01/0b0001 - PAYLOAD_TYPE_RESPONSE - Response to REQ or ANON_REQ0x02/0b0010 - PAYLOAD_TYPE_TXT_MSG - Plain text message0x03/0b0011 - PAYLOAD_TYPE_ACK - Acknowledgment0x04/0b0100 - PAYLOAD_TYPE_ADVERT - Node advertisement0x05/0b0101 - PAYLOAD_TYPE_GRP_TXT - Group text message (unverified)0x06/0b0110 - PAYLOAD_TYPE_GRP_DATA - Group datagram (unverified)0x07/0b0111 - PAYLOAD_TYPE_ANON_REQ - Anonymous request0x08/0b1000 - PAYLOAD_TYPE_PATH - Returned path0x09/0b1001 - PAYLOAD_TYPE_TRACE - Trace a path, collecting SNR for each hop0x0A/0b1010 - PAYLOAD_TYPE_MULTIPART - Packet is part of a sequence of packets0x0B/0b1011 - PAYLOAD_TYPE_CONTROL - Control packet data (unencrypted)0x0C/0b1100 - reserved0x0D/0b1101 - reserved0x0E/0b1110 - reserved0x0F/0b1111 - PAYLOAD_TYPE_RAW_CUSTOM - Custom packet (raw bytes, custom encryption)
0x00/0b00 - v1 - 1-byte src/dest hashes, 2-byte MAC0x01/0b01 - v2 - Future version (e.g., 2-byte hashes, 4-byte MAC)0x02/0b10 - v3 - Future version0x03/0b11 - v4 - Future versiontransport_codes - 4 bytes (optional)
ROUTE_TYPE_TRANSPORT_FLOOD and ROUTE_TYPE_TRANSPORT_DIRECTtransport_code_1 - 2 bytes - uint16_t - calculated from region scopetransport_code_2 - 2 bytes - uint16_t - reservedpath_length - 1 byte - Encoded path metadata
0-63)
0b00: 1-byte path hashes0b01: 2-byte path hashes0b10: 3-byte path hashes0b11: reserved / unsupportedpath - hop_count * hash_size bytes - Path to use for Direct Routing or flood path tracking
MAX_PATH_SIZEpath_lengthpayload - variable length - Payload Data
MAX_PACKET_PAYLOADpayload sizes larger than 184MAX_PATH_SIZE) Stores hop_count * hash_size bytes of path data if applicable payload up to 184 (MAX_PACKET_PAYLOAD) Data for the provided Payload Type 0x03 Route Type Flood, Direct, etc 2-5 0x3C Payload Type Request, Response, ACK, etc 6-7 0xC0 Payload Version Versioning of the payload format"},{"location":"packet_format/#route-types","title":"Route Types","text":"Value Name Description 0x00 ROUTE_TYPE_TRANSPORT_FLOOD Flood Routing + Transport Codes 0x01 ROUTE_TYPE_FLOOD Flood Routing 0x02 ROUTE_TYPE_DIRECT Direct Routing 0x03 ROUTE_TYPE_TRANSPORT_DIRECT Direct Routing + Transport Codes"},{"location":"packet_format/#path-length-encoding","title":"Path Length Encoding","text":"path_length is not a raw byte count. It packs both hash size and hop count:0-63) 6-7 Hash Size Code Stored as hash_size - 1 0b00 1 byte Legacy / default mode 0b01 2 bytes Supported in current firmware 0b10 3 bytes Supported in current firmware 0b11 4 bytes Reserved / invalid
"},{"location":"packet_format/#payload-types","title":"Payload Types","text":"Value Name Description 0x00: zero-hop packet, no path bytes0x05: 5 hops using 1-byte hashes, so path is 5 bytes0x45: 5 hops using 2-byte hashes, so path is 10 bytes0x8A: 10 hops using 3-byte hashes, so path is 30 bytes0x00 PAYLOAD_TYPE_REQ Request (destination/source hashes + MAC) 0x01 PAYLOAD_TYPE_RESPONSE Response to REQ or ANON_REQ 0x02 PAYLOAD_TYPE_TXT_MSG Plain text message 0x03 PAYLOAD_TYPE_ACK Acknowledgment 0x04 PAYLOAD_TYPE_ADVERT Node advertisement 0x05 PAYLOAD_TYPE_GRP_TXT Group text message (unverified) 0x06 PAYLOAD_TYPE_GRP_DATA Group datagram (unverified) 0x07 PAYLOAD_TYPE_ANON_REQ Anonymous request 0x08 PAYLOAD_TYPE_PATH Returned path 0x09 PAYLOAD_TYPE_TRACE Trace a path, collecting SNR for each hop 0x0A PAYLOAD_TYPE_MULTIPART Packet is part of a sequence of packets 0x0B PAYLOAD_TYPE_CONTROL Control packet data (unencrypted) 0x0C reserved reserved 0x0D reserved reserved 0x0E reserved reserved 0x0F PAYLOAD_TYPE_RAW_CUSTOM Custom packet (raw bytes, custom encryption)"},{"location":"packet_format/#payload-versions","title":"Payload Versions","text":"Value Version Description 0x00 1 1-byte src/dest hashes, 2-byte MAC 0x01 2 Future version (e.g., 2-byte hashes, 4-byte MAC) 0x02 3 Future version 0x03 4 Future version"},{"location":"payloads/","title":"Payload Format","text":"
"},{"location":"payloads/#node-advertisement","title":"Node advertisement","text":"0x01 is chat node advert is for a chat node 0x02 is repeater advert is for a repeater 0x03 is room server advert is for a room server 0x04 is sensor advert is for a sensor server 0x10 has location appdata contains lat/long information 0x20 has feature 1 Reserved for future use. 0x40 has feature 2 Reserved for future use. 0x80 has name appdata contains a node name"},{"location":"payloads/#acknowledgement","title":"Acknowledgement","text":"BaseChatMesh, the current request type values are:0x01 get stats get stats of repeater or room server 0x02 keepalive keep-alive request used for maintained connections"},{"location":"payloads/#get-stats","title":"Get stats","text":"
"},{"location":"payloads/#get-telemetry-data","title":"Get telemetry data","text":"BaseChatMesh. Sensor- and application-specific request payloads may be implemented by higher-level firmware.BaseChatMesh.BaseChatMesh.BaseChatMesh.BaseChatMesh.BaseChatMesh.0x00 plain text message the plain text of the message 0x01 CLI command the command text of the message 0x02 signed plain text message first four bytes is sender pubkey prefix, followed by plain text message"},{"location":"payloads/#anonymous-request","title":"Anonymous request","text":"Field Size (bytes) Description destination hash 1 first byte of destination node public key public key 32 sender's Ed25519 public key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details"},{"location":"payloads/#room-server-login","title":"Room server login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) sync timestamp 4 sender's \"sync messages SINCE x\" timestamp password rest of message password for room"},{"location":"payloads/#repeatersensor-login","title":"Repeater/Sensor login","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) password rest of message password for repeater/sensor"},{"location":"payloads/#repeater-regions-request","title":"Repeater - Regions request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x01 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-owner-info-request","title":"Repeater - Owner info request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x02 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#repeater-clock-and-status-request","title":"Repeater - Clock and status request","text":"Field Size (bytes) Description timestamp 4 sender time (unix timestamp) req type 1 0x03 (request sub type) reply path len 1 path len for reply reply path (variable) reply path"},{"location":"payloads/#group-text-message","title":"Group text message","text":"Field Size (bytes) Description channel hash 1 first byte of SHA256 of channel's shared key cipher MAC 2 MAC for encrypted data in next field ciphertext rest of payload encrypted message, see below for details 0x00 because it is a \"plain text message\". The message will be of the form <sender name>: <message body> (eg., user123: I'm on my way).meshcore://channel/add?name=Public&secret=8b3387e9c5cdea6ac9e5edbaa115cd72\n
"},{"location":"qr_codes/#add-contact","title":"Add Contact","text":"name: Channel name (URL-encoded)secret: 16-byte secret represented as 32 hex charactersregion_scope: Region Scope (optional, URL-encoded if provided)
meshcore://contact/add?name=Example+Contact&public_key=9cd8fcf22a47333b591d96a2b848b73f457b1bb1a3ea2453a885f9e5787765b1&type=1\n
"},{"location":"stats_binary_frames/","title":"Stats Binary Frame Structures","text":"name: Contact name (URL-encoded if needed)public_key: 32-byte public key represented as 64 hex characterstype: numeric contact type
1: Companion2: Repeater3: Room Server4: SensorCMD_GET_STATS 56 Get statistics (2-byte command: code + sub-type)"},{"location":"stats_binary_frames/#stats-sub-types","title":"Stats Sub-Types","text":"CMD_GET_STATS command uses a 2-byte frame structure: - Byte 0: CMD_GET_STATS (56) - Byte 1: Stats sub-type: - STATS_TYPE_CORE (0) - Get core device statistics - STATS_TYPE_RADIO (1) - Get radio statistics - STATS_TYPE_PACKETS (2) - Get packet statisticsRESP_CODE_STATS 24 Statistics response (2-byte response: code + sub-type)"},{"location":"stats_binary_frames/#stats-response-sub-types","title":"Stats Response Sub-Types","text":"RESP_CODE_STATS response uses a 2-byte header structure: - Byte 0: RESP_CODE_STATS (24) - Byte 1: Stats sub-type (matches command sub-type): - STATS_TYPE_CORE (0) - Core device statistics response - STATS_TYPE_RADIO (1) - Radio statistics response - STATS_TYPE_PACKETS (2) - Packet statistics response0x18 (24) - 1 1 uint8_t stats_type Always 0x00 (STATS_TYPE_CORE) - 2 2 uint16_t battery_mv Battery voltage in millivolts 0 - 65,535 4 4 uint32_t uptime_secs Device uptime in seconds 0 - 4,294,967,295 8 2 uint16_t errors Error flags bitmask - 10 1 uint8_t queue_len Outbound packet queue length 0 - 255"},{"location":"stats_binary_frames/#example-structure-cc","title":"Example Structure (C/C++)","text":"
"},{"location":"stats_binary_frames/#resp_code_stats-stats_type_radio-24-1","title":"RESP_CODE_STATS + STATS_TYPE_RADIO (24, 1)","text":"struct StatsCore {\n uint8_t response_code; // 0x18\n uint8_t stats_type; // 0x00 (STATS_TYPE_CORE)\n uint16_t battery_mv;\n uint32_t uptime_secs;\n uint16_t errors;\n uint8_t queue_len;\n} __attribute__((packed));\n0x18 (24) - 1 1 uint8_t stats_type Always 0x01 (STATS_TYPE_RADIO) - 2 2 int16_t noise_floor Radio noise floor in dBm -140 to +10 4 1 int8_t last_rssi Last received signal strength in dBm -128 to +127 5 1 int8_t last_snr SNR scaled by 4 Divide by 4.0 for dB 6 4 uint32_t tx_air_secs Cumulative transmit airtime in seconds 0 - 4,294,967,295 10 4 uint32_t rx_air_secs Cumulative receive airtime in seconds 0 - 4,294,967,295"},{"location":"stats_binary_frames/#example-structure-cc_1","title":"Example Structure (C/C++)","text":"
"},{"location":"stats_binary_frames/#resp_code_stats-stats_type_packets-24-2","title":"RESP_CODE_STATS + STATS_TYPE_PACKETS (24, 2)","text":"struct StatsRadio {\n uint8_t response_code; // 0x18\n uint8_t stats_type; // 0x01 (STATS_TYPE_RADIO)\n int16_t noise_floor;\n int8_t last_rssi;\n int8_t last_snr; // Divide by 4.0 to get actual SNR in dB\n uint32_t tx_air_secs;\n uint32_t rx_air_secs;\n} __attribute__((packed));\nrecv_errors)0x18 (24) - 1 1 uint8_t stats_type Always 0x02 (STATS_TYPE_PACKETS) - 2 4 uint32_t recv Total packets received 0 - 4,294,967,295 6 4 uint32_t sent Total packets sent 0 - 4,294,967,295 10 4 uint32_t flood_tx Packets sent via flood routing 0 - 4,294,967,295 14 4 uint32_t direct_tx Packets sent via direct routing 0 - 4,294,967,295 18 4 uint32_t flood_rx Packets received via flood routing 0 - 4,294,967,295 22 4 uint32_t direct_rx Packets received via direct routing 0 - 4,294,967,295 26 4 uint32_t recv_errors Receive/CRC errors (RadioLib); present only in 30-byte frame 0 - 4,294,967,295"},{"location":"stats_binary_frames/#notes","title":"Notes","text":"
"},{"location":"stats_binary_frames/#example-structure-cc_2","title":"Example Structure (C/C++)","text":"recv = flood_rx + direct_rxsent = flood_tx + direct_txrecv_errors at offset 26.
"},{"location":"stats_binary_frames/#command-usage-example-python","title":"Command Usage Example (Python)","text":"struct StatsPackets {\n uint8_t response_code; // 0x18\n uint8_t stats_type; // 0x02 (STATS_TYPE_PACKETS)\n uint32_t recv;\n uint32_t sent;\n uint32_t flood_tx;\n uint32_t direct_tx;\n uint32_t flood_rx;\n uint32_t direct_rx;\n uint32_t recv_errors; // present when frame size is 30\n} __attribute__((packed));\n
"},{"location":"stats_binary_frames/#response-parsing-example-python","title":"Response Parsing Example (Python)","text":"# Send CMD_GET_STATS command\ndef send_get_stats_core(serial_interface):\n \"\"\"Send command to get core stats\"\"\"\n cmd = bytes([56, 0]) # CMD_GET_STATS (56) + STATS_TYPE_CORE (0)\n serial_interface.write(cmd)\n\ndef send_get_stats_radio(serial_interface):\n \"\"\"Send command to get radio stats\"\"\"\n cmd = bytes([56, 1]) # CMD_GET_STATS (56) + STATS_TYPE_RADIO (1)\n serial_interface.write(cmd)\n\ndef send_get_stats_packets(serial_interface):\n \"\"\"Send command to get packet stats\"\"\"\n cmd = bytes([56, 2]) # CMD_GET_STATS (56) + STATS_TYPE_PACKETS (2)\n serial_interface.write(cmd)\n
"},{"location":"stats_binary_frames/#command-usage-example-javascripttypescript","title":"Command Usage Example (JavaScript/TypeScript)","text":"import struct\n\ndef parse_stats_core(frame):\n \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_CORE frame (11 bytes)\"\"\"\n response_code, stats_type, battery_mv, uptime_secs, errors, queue_len = \\\n struct.unpack('<B B H I H B', frame)\n assert response_code == 24 and stats_type == 0, \"Invalid response type\"\n return {\n 'battery_mv': battery_mv,\n 'uptime_secs': uptime_secs,\n 'errors': errors,\n 'queue_len': queue_len\n }\n\ndef parse_stats_radio(frame):\n \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_RADIO frame (14 bytes)\"\"\"\n response_code, stats_type, noise_floor, last_rssi, last_snr, tx_air_secs, rx_air_secs = \\\n struct.unpack('<B B h b b I I', frame)\n assert response_code == 24 and stats_type == 1, \"Invalid response type\"\n return {\n 'noise_floor': noise_floor,\n 'last_rssi': last_rssi,\n 'last_snr': last_snr / 4.0, # Unscale SNR\n 'tx_air_secs': tx_air_secs,\n 'rx_air_secs': rx_air_secs\n }\n\ndef parse_stats_packets(frame):\n \"\"\"Parse RESP_CODE_STATS + STATS_TYPE_PACKETS frame (26 or 30 bytes)\"\"\"\n assert len(frame) >= 26, \"STATS_TYPE_PACKETS frame too short\"\n response_code, stats_type, recv, sent, flood_tx, direct_tx, flood_rx, direct_rx = \\\n struct.unpack('<B B I I I I I I', frame[:26])\n assert response_code == 24 and stats_type == 2, \"Invalid response type\"\n result = {\n 'recv': recv,\n 'sent': sent,\n 'flood_tx': flood_tx,\n 'direct_tx': direct_tx,\n 'flood_rx': flood_rx,\n 'direct_rx': direct_rx\n }\n if len(frame) >= 30:\n (recv_errors,) = struct.unpack('<I', frame[26:30])\n result['recv_errors'] = recv_errors\n return result\n
"},{"location":"stats_binary_frames/#response-parsing-example-javascripttypescript","title":"Response Parsing Example (JavaScript/TypeScript)","text":"// Send CMD_GET_STATS command\nconst CMD_GET_STATS = 56;\nconst STATS_TYPE_CORE = 0;\nconst STATS_TYPE_RADIO = 1;\nconst STATS_TYPE_PACKETS = 2;\n\nfunction sendGetStatsCore(serialInterface: SerialPort): void {\n const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_CORE]);\n serialInterface.write(cmd);\n}\n\nfunction sendGetStatsRadio(serialInterface: SerialPort): void {\n const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_RADIO]);\n serialInterface.write(cmd);\n}\n\nfunction sendGetStatsPackets(serialInterface: SerialPort): void {\n const cmd = new Uint8Array([CMD_GET_STATS, STATS_TYPE_PACKETS]);\n serialInterface.write(cmd);\n}\n
"},{"location":"stats_binary_frames/#field-size-considerations","title":"Field Size Considerations","text":"interface StatsCore {\n battery_mv: number;\n uptime_secs: number;\n errors: number;\n queue_len: number;\n}\n\ninterface StatsRadio {\n noise_floor: number;\n last_rssi: number;\n last_snr: number;\n tx_air_secs: number;\n rx_air_secs: number;\n}\n\ninterface StatsPackets {\n recv: number;\n sent: number;\n flood_tx: number;\n direct_tx: number;\n flood_rx: number;\n direct_rx: number;\n recv_errors?: number; // present when frame is 30 bytes\n}\n\nfunction parseStatsCore(buffer: ArrayBuffer): StatsCore {\n const view = new DataView(buffer);\n const response_code = view.getUint8(0);\n const stats_type = view.getUint8(1);\n if (response_code !== 24 || stats_type !== 0) {\n throw new Error('Invalid response type');\n }\n return {\n battery_mv: view.getUint16(2, true),\n uptime_secs: view.getUint32(4, true),\n errors: view.getUint16(8, true),\n queue_len: view.getUint8(10)\n };\n}\n\nfunction parseStatsRadio(buffer: ArrayBuffer): StatsRadio {\n const view = new DataView(buffer);\n const response_code = view.getUint8(0);\n const stats_type = view.getUint8(1);\n if (response_code !== 24 || stats_type !== 1) {\n throw new Error('Invalid response type');\n }\n return {\n noise_floor: view.getInt16(2, true),\n last_rssi: view.getInt8(4),\n last_snr: view.getInt8(5) / 4.0, // Unscale SNR\n tx_air_secs: view.getUint32(6, true),\n rx_air_secs: view.getUint32(10, true)\n };\n}\n\nfunction parseStatsPackets(buffer: ArrayBuffer): StatsPackets {\n const view = new DataView(buffer);\n if (buffer.byteLength < 26) {\n throw new Error('STATS_TYPE_PACKETS frame too short');\n }\n const response_code = view.getUint8(0);\n const stats_type = view.getUint8(1);\n if (response_code !== 24 || stats_type !== 2) {\n throw new Error('Invalid response type');\n }\n const result: StatsPackets = {\n recv: view.getUint32(2, true),\n sent: view.getUint32(6, true),\n flood_tx: view.getUint32(10, true),\n direct_tx: view.getUint32(14, true),\n flood_rx: view.getUint32(18, true),\n direct_rx: view.getUint32(22, true)\n };\n if (buffer.byteLength >= 30) {\n result.recv_errors = view.getUint32(26, true);\n }\n return result;\n}\n
"},{"location":"terminal_chat_cli/","title":"Terminal Chat CLI","text":"set freq {frequency}\nset tx {tx-power-dbm}\nset name {name}\nset lat {latitude}\nset lon {longitude}\nset dutycycle {percent}\nset dutycycle 10 for 10%.set af {air-time-factor}\nset dutycycle instead.time {epoch-secs}\nadvert\nclock\nver\ncard\nimport {card}\nlist {n}\nto\nto {name-prefix}\nsend {text}\nreset path\npublic {text}\nrefactor/nearby-nodes (zmergowany do main) Plik \u017ar\u00f3d\u0142owy: NearbyScreen.h Status: zaimplementowane \u2014 dokument zachowany jako zapis analizy/decyzji.LEFT/RIGHT po typie, wi\u0119c filtr zosta\u0142 wy\u0142\u0105cznie na li\u015bcie (sekcja 3.1 zak\u0142ada\u0142a Filter\u2026 te\u017c w menu). - Sort nie jest togglem przez Enter, lecz zmienia si\u0119 in-place przez LEFT/RIGHT na pod\u015bwietlonym wierszu w popupie (wzorzec ustawie\u0144 Trail), a wiersz pojawia si\u0119 tylko dla \u017ar\u00f3d\u0142a Zapisane (skan nie ma dystansu). - Filtr i sort utrzymuj\u0105 si\u0119 mi\u0119dzy wej\u015bciami na ekran (nie s\u0105 resetowane).NearbyScreen\n\u251c\u2500\u2500 LIST (kontakty zapisane w mesh) \u2190 tryb domy\u015blny\n\u2502 \u251c\u2500\u2500 filtr cyklowany LEFT/RIGHT (7 stan\u00f3w)\n\u2502 \u251c\u2500\u2500 DETAIL (Enter) \u2192 Lat/Lon/Dist/Type/Seen\n\u2502 \u2502 \u2514\u2500\u2500 _opts popup (Hold Enter): Navigate / Ping / Save waypoint\n\u2502 \u2502 \u2514\u2500\u2500 _ping_menu popup\n\u2502 \u251c\u2500\u2500 NAV view (pe\u0142noekranowa nawigacja)\n\u2502 \u2514\u2500\u2500 _ctx_menu popup (Hold Enter): Discover / Navigate / Save waypoint\n\u2502\n\u2514\u2500\u2500 DISCOVER (skan na \u017cywo NODE_DISCOVER_REQ) \u2190 osobny pod-ekran\n \u251c\u2500\u2500 lista wynik\u00f3w (karty 2-liniowe)\n \u2502 Hold Enter = ponowny skan (brak menu!)\n \u2514\u2500\u2500 DETAIL (Enter) \u2192 pubkey / RSSI / SNR / status\n \u2514\u2500\u2500 _ping_menu popup (Hold Enter = od razu ping, bez Options)\n_detail, _nav, _discover_mode, _ddetail, _pinging, _filter, dwa komplety _sel/_scroll, trzy bufory wynik\u00f3w ping\u2026) i trzy instancje PopupMenu (_ctx_menu, _opts, _ping_menu).LEFT/RIGHT przewija 7 stan\u00f3w:Fav \u00b7 ALL \u00b7 Comp \u00b7 Rpt \u00b7 Room \u00b7 Snsr \u00b7 TIME\nFav filtr ulubionych (flaga ci.flags & 1) ALL/Comp/Rpt/Room/Snsr filtr po typie w\u0119z\u0142a TIME sortowanie (po lastmod zamiast po dystansie) TIME jako \u201efiltr\" jest myl\u0105ce \u2014 zmienia kolejno\u015b\u0107, nie zawarto\u015b\u0107; - etykieta w nag\u0142\u00f3wku (NEARBY[TIME]) nie m\u00f3wi, \u017ce to sort._ctx_menu) Detail kontaktu (_opts) Detail discover Navigate \u2713 \u2713 \u2014 Save waypoint \u2713 \u2713 \u2014 Ping \u2014 \u2713 \u2713 Discover \u2713 \u2014 \u2014 KEY_CONTEXT_MENU) Lista nearby otwiera menu kontekstowe Detail kontaktu otwiera menu Options Lista discover ponowny skan (\u017cadnego menu) Detail discover od razu Ping (pomija Options) _ping_menu to bespoke widget","text":"UP/DOWN; - przebudowuje si\u0119 w trakcie (rebuildPingMenu) gdy przychodz\u0105 wyniki; - zostaje otwarte po SELECTED (reszta popup\u00f3w si\u0119 zamyka); - ma w\u0142asny handlePingMenuInput z trybem allow_enter_to_open.renderDiscover/handleInputDiscover/renderDiscoverDetail to niemal r\u00f3wnoleg\u0142a kopia logiki listy i szczeg\u00f3\u0142\u00f3w nearby (osobne _dsel, _dscroll, _d_visible, w\u0142asne rysowanie kart). Discover i Nearby robi\u0105 to samo \u2014 pokazuj\u0105 list\u0119 w\u0119z\u0142\u00f3w z mo\u017cliwo\u015bci\u0105 wej\u015bcia w szczeg\u00f3\u0142y i pingowania \u2014 ale dwoma osobnymi \u015bcie\u017ckami kodu.FILTR (jedna o\u015b \u2014 typ w\u0119z\u0142a, z Ulubionymi) SORT (prze\u0142\u0105cznik)\n \u2022 Wszystkie \u2022 Dystans (domy\u015blnie)\n \u2022 Ulubione \u2022 Ostatnio s\u0142yszane\n \u2022 Companion\n \u2022 Repeater\n \u2022 Room\n \u2022 Sensor\nLEFT/RIGHT = szybki cykl tylko po filtrze-typie (jedna sp\u00f3jna o\u015b, znany gest; bez \u201eTIME\" zanieczyszczaj\u0105cego cykl); - Sort = prze\u0142\u0105cznik w menu akcji (Dystans \u2194 Ostatnio s\u0142yszane), trzymany niezale\u017cnie od filtra; - Filter\u2026 dost\u0119pny te\u017c w menu akcji (odkrywalno\u015b\u0107 \u2014 ca\u0142a lista widoczna naraz, nie tylko cyklowanie).NEARBY \u00b7 Rpt \u00b7 \u2193dist.PopupMenu (zamiast _ctx_menu + _opts), pozycje zale\u017cne od kontekstu, ale kolejno\u015b\u0107 i nazwy sta\u0142e:Hold Enter \u2192 Options\n \u2022 Navigate (gdy w\u0119ze\u0142 ma GPS)\n \u2022 Ping (gdy znamy pubkey)\n \u2022 Save waypoint (gdy w\u0119ze\u0142 ma GPS)\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n \u2022 Filter\u2026 (podmenu z 3.1)\n \u2022 Sort\u2026 (toggle z 3.1)\n \u2022 Discover scan (uruchamia skan na \u017cywo = prze\u0142\u0105cza \u017ar\u00f3d\u0142o)\nhas_node), ale nigdy nie zmieniaj\u0105 kolejno\u015bci ani nazw. \u201eHold Enter = menu akcji\" \u2014 bez wyj\u0105tk\u00f3w. Ping zawsze przez Options (znika \u201eod razu Ping\" z detalu Discover); rescan to pozycja menu, nie ukryty Hold Enter.
renderDiscover*, niesp\u00f3jne Hold Enter i po\u0142owa p\u00f3l stanu);DiscoverResult).NODE_DISCOVER_REQ i prze\u0142\u0105cza widok na wyniki) oraz powr\u00f3t do Zapisanych przez Cancel.TIME znika z cyklu; LEFT/RIGHT cykluj\u0105 tylko typ+Fav; sort jako stan + toggle niskie 2 Scal _ctx_menu i _opts w jedno menu akcji o sta\u0142ej kolejno\u015bci; dodaj Filter\u2026/Sort\u2026 niskie 3 Ujednoli\u0107 Hold Enter w Discover (menu zamiast bezpo\u015bredniego rescan/ping; Ping zawsze przez Options) \u015brednie 4 Scal list\u0119 Discover z list\u0105 Nearby w jeden komponent z prze\u0142\u0105cznikiem \u017ar\u00f3d\u0142a wy\u017csze
"},{"location":"design/solo_ui_framework/","title":"Solo UI framework \u2014 a guide for adding features","text":"LEFT/RIGHT = szybki cykl filtra-typu; Sort jako toggle w menu; Filter\u2026 te\u017c w menu dla odkrywalno\u015bci. (Nie chowamy wszystkiego do menu \u2014 zachowujemy szybki gest.)companion_radio solo firmware UI (the ui-new screens). It is not a user manual \u2014 for what each screen does, see solo_features. The goal here is so that adding a new screen or feature means wiring together existing helpers, not reinventing list scrolling, text wrapping, or persistence.examples/companion_radio/ unless a path says otherwise. The screen fragments (ui-new/*.h) are all #included, in order, into one translation unit (ui-new/UITask.cpp) \u2014 so a static inline helper in an earlier header is visible to later ones. Header-include order in UITask.cpp therefore matters; new screens go near the others.UITask.cpp. Some define external-linkage symbols at file scope (e.g. NearbyScreen::FILTER_LABELS), so including a fragment from a second .cpp is a duplicate-symbol link error. Anything genuinely shared across TUs must live in a real header (icons.h, GeoUtils.h, DisplayDriver.h), not a screen fragment.UIScreen (src/helpers/ui/UIScreen.h):class UIScreen {\npublic:\n virtual int render(DisplayDriver& display) = 0; // returns ms until the next render\n virtual bool handleInput(char c) { return false; }\n virtual void poll() { }\n virtual void onShow() { } // reset per-visit state\n};\n
"},{"location":"design/solo_ui_framework/#wiring-a-screen-into-uitask","title":"Wiring a screen into UITask","text":"render() draws one frame and returns how long until it wants to be drawn again, in milliseconds. Return a big number (2000) for a static screen, a small one (50\u2013200) while something animates or a popup is open. This return value is the main lever for the e-ink cost/latency trade-off \u2014 see \u00a79. UITask owns startFrame()/endFrame(); render() must not call them.handleInput(c) gets one key (KEY_*, see \u00a77). Return true if consumed.poll() runs every loop tick regardless of focus \u2014 rare, for background housekeeping (e.g. the shutdown button).onShow() is called by setCurrScreen() every time the screen becomes current \u2014 override it to reset per-visit state (_sel = 0, _dirty = false, sub-views). Default no-op for screens that keep state across visits. Because it's invoked centrally, a navigator can't forget to reset on show.
UIScreen* my_screen; member in UITask.h (near the others).UITask::begin() (UITask.cpp): my_screen = new MyScreen(this, \u2026);onShow() runs inside setCurrScreen): cpp void UITask::gotoMyScreen() { setCurrScreen(my_screen); } Only screens needing a parameter at entry add a typed call after it (e.g. gotoRingtoneEditor \u2192 selectSlot(slot), gotoMapScreen \u2192 showMapView()).ToolsScreen.h (add an Action enum value, a row in the right section table, and a dispatch() case).UITask.h and setCurrScreen() bails on null, so a missed new is an inert no-op rather than a null deref.UITask* task plus whatever it needs (NodePrefs*, KeyboardWidget*, \u2026); the task back-pointer is how a screen calls shared services (_task->showAlert(...), _task->waypoints(), \u2026).DisplayDriver (src/helpers/ui/DisplayDriver.h) abstracts OLED vs e-ink and, crucially, font scale: landscape e-ink renders text at 2\u00d7, so never hard-code pixel sizes \u2014 derive everything from these:getLineHeight() pixel rows per text line (8 at 1\u00d7, 16 at 2\u00d7) lineStep() row pitch = line height + gap; use for row y stepping getCharWidth() / getTextWidth(s) advance width; getTextWidth is font-accurate headerH() / listStart() title-bar height / first content row y listVisible(itemH) how many rows fit below the header valCol() conventional x for a right-hand value column width() / height() panel size in px isEink() true only on landscape e-ink; branch on this, not on pixel counts
"},{"location":"design/solo_ui_framework/#3-lists-drawlist","title":"3. Lists \u2014 drawCenteredHeader(title, menu_hint=false, menu_open=false) \u2014 plain centered title + separator.drawInvertedHeader(label, menu_hint=false, menu_open=false) \u2014 filled title bar (used by detail views).menu_hint: pass true on a screen with a Hold-Enter context menu to reserve a \u2261 glyph (menuHintWidth()/drawContextMenuHint()) in the header, so the menu is discoverable without already knowing the shortcut; menu_open highlights it while the menu is actually up.drawSelectionRow(x, y, w, h, sel) \u2014 the highlight bar behind a list row.drawTextEllipsized(x, y, max_w, str) \u2014 truncates with \u2026; use this for any user string (names, labels) so long/UTF-8 text can't overrun.drawTextCentered(mid_x, y, str).translateUTF8ToBlocks(dst, src, n) \u2014 map UTF-8 to the panel's glyph set for display only. Never run text through it before sending it over the air or storing it (it is lossy) \u2014 see the reply-prefix note in \u00a75.drawList","text":"drawList (ui-new/icons.h) is the workhorse for any scrolling list. It computes the visible window from font metrics, keeps sel in view, reserves the scrollbar column, draws each visible row through your callback, and draws the indicator:drawList(display, count, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {\n drawRowSelection(display, y, sel, reserve); // canonical highlight bar\n display.drawTextEllipsized(2, y, display.width() - reserve - 4, items[idx].name);\n});\nreserve is the width the scrollbar took (0 when the list fits) \u2014 subtract it from any right-aligned content so nothing slides under the indicator. The row callback owns its own selection bar: drawRowSelection(d, y, sel, reserve) (ui-new/icons.h) draws the standard one (full row minus reserve, one pixel short); call display.drawSelectionRow() directly only when a row needs a non-standard geometry (full-width, custom height). For the fold-in-place pattern (sections that expand/collapse) use AccordionList instead (ui-new/AccordionList.h) \u2014 same idea, two callbacks (header + item).drawScrollIndicator, \u2026Px) and the reserve calculator (scrollIndicatorReserve) are exposed for hand-laid lists.PopupMenu PopupMenu.h a modal action menu over any screen AccordionList AccordionList.h collapsible sectioned lists (Tools, Settings) KeyboardWidget KeyboardWidget.h on-screen text entry DigitEditor DigitEditor.h scroll-edit one number, digit by digit FullscreenMsgView FullscreenMsgView.h scrollable full-message reader + word wrap NavView NavView.h bearing/distance/ETA \"navigate to a point\" view begin(...) to open, an active flag, a handleInput(c) returning a small Result enum, and a render()/draw(). Typical embedding:if (_menu.active) { // popup eats input while open\n auto r = _menu.handleInput(c);\n if (r == PopupMenu::SELECTED) runAction(_menu.selectedIndex());\n return true;\n}\n...\n_menu.begin(\"Options\", 6); // open it\n_menu.addItem(\"Navigate\"); _menu.addItem(\"Ping\");\n_menu.active = true;\nKeyboardWidget additionally supports placeholders ({loc}, {time}, sensor tokens) via addPlaceholder() / clearPlaceholders(); the shared kbAddSensorPlaceholders() (ui-new/SensorPlaceholders.h) adds only the tokens the board's sensors actually provide. Expand them with expandMsg() at send time.KB_T9_TIMEOUT_MS cycles a cell's letter group, then its digit). Page 0 is no longer hardcoded to Latin: NodePrefs::keyboard_main_alphabet/keyboard_alt_alphabet (Settings > Keyboard's Main/Additional rows) each pick a script \u2014 Latin, Cyrillic, or Greek \u2014 for page 0 and page 1 respectively (KeyboardWidget::mainScript()/ altScript()); equal values collapse to a single script + Symbols (2 pages instead of 3, see hasAltAlphabet()). scriptCellStr()/scriptT9GroupStr() dispatch each script to its own ABC grid (KB_CHARS/KB_CYRILLIC_CHARS/ KB_GREEK_CHARS) and T9 group table (KB_T9_GROUPS/KB_T9_GROUPS_CYRILLIC/ _GREEK) so the two layouts always offer the same letters regardless of which page they're on. Latin-diacritic letters (Polish, Czech, German, etc.) aren't alt-alphabet pages \u2014 they're reached by Hold-Enter on whichever page currently shows Latin instead (see KB_ACCENT_VARIANTS below). Shift is one-shot by default (capitalises the next letter, including whichever candidate a T9 multi-tap cycle settles on) or Hold-Enter to toggle caps-lock; Hold-Clear erases the whole field. UP from the top letter row enters cursor mode (LEFT/RIGHT move the insertion point; UP/DOWN jump to start/end, then \u2014 pressed again once already at that boundary \u2014 continue on to the special row / letter grid, the same destinations the plain grid wrap used to reach directly) so edits/inserts can target any point in the typed text, not just the end; Enter/Cancel exit immediately from anywhere. Hold-Enter on a Latin-page letter cell with accented variants instead opens the accent popup: one horizontal row of KB_ACCENT_VARIANTS[group] (a UTF-8 string per base letter, same shape as a T9 group string), LEFT/RIGHT to pick, Enter to insert via the shared insertGlyph() helper, Cancel to dismiss. Holding a letter with no variants, or any T9/alt-alphabet/symbols cell, is a no-op.FullscreenMsgView::wrapLines() is a standalone pixel-accurate word-wrapper (O(n), variable-width-font aware) reusable by any multi-line layout; it writes into the shared s_wrap_trans / s_wrap_lines scratch (single-threaded render, never held across a yield \u2014 see \u00a79).GeoUtils.h, namespace geo, all pure/header-inline):
haversineKm(lat1,lon1,lat2,lon2), bearingDeg(...), bearingCardinal(deg).fmtDist(buf,n,km,imperial) \u2014 \"850m\"/\"2.3km\" or feet/miles.fmtAgeShort(buf,n,now,ts) \u2014 compact \"12s\"/\"5m\"/\"3h\"/\"2d\" tag, \"\" for unknown. This is the one age formatter \u2014 don't reimplement the s/m/h ladder.parseLatLon(text, lat, lon, label?, n?) \u2014 pull a lat,lon out of message text; reads the [WAY] label if tagged.parseLocShare(text, lat, lon) \u2014 true only for an explicit [LOC] share.LOCATION_MSG_TAG ([LOC], the sender's own live position) and WAYPOINT_MSG_TAG ([WAY], a saved point to share); both stay human-readable on clients that don't know them.TrailStore (Trail.h, GPS breadcrumb ring + GPX export), LiveTrackStore (LiveTrack.h, RAM table of others' [LOC] positions, expiring), WaypointStore (Waypoint.h, persisted saved points). Reach them via the task (_task->trail(), _task->liveTrack(), _task->waypoints()).msgReplyBody(text, nick?, n?) (FullscreenMsgView.h) parses a leading @[nick] reply marker, returning the body and optionally the addressee. Use it instead of re-scanning for @[. The stored/sent prefix is raw UTF-8 (it goes over the air) \u2014 never transliterate it.ui-new/icons.h):MINI_ICON(ICON_FOO, 5,\n packRow(\"..#..\"),\n packRow(\".###.\"),\n packRow(\"#####\"));\nminiIconDraw(display, x, topY, ICON_FOO) (auto-scaled & centered), miniIconDrawTop (exact placement), or the boxed/slot variants (drawBoxedIcon = lit when active, drawSlotIcon = plain). Bigger page glyphs use BIG_ICON / bigIconDraw. The home status bar composes these right-to-left with a blinkOn() cadence for \"leave it on and forget\" broadcasts (auto-advert, Live Share, trail, repeater) \u2014 follow that pattern when adding an indicator: always shown on e-ink, blinking on OLED.HomeScreen::renderBatteryIndicator(), UITask.cpp); once the row runs out of horizontal space the loop just stops, so the lowest-priority icons silently drop first rather than the whole bar crushing the node name. A blinking icon still reserves its width on the off-phase of its blink, so the row's layout can't visibly shift width as icons blink in and out.menu_hint=true to their header call (see \u00a72) so a \u2261 glyph advertises the menu; KEY_CONTEXT_MENU (Hold-Enter) opens it.KEY_* codes in UIScreen.h: KEY_UP/DOWN/LEFT/RIGHT, KEY_ENTER, KEY_CANCEL, KEY_CONTEXT_MENU (the \"Hold Enter\" menu key).keyIsPrev(c) (LEFT or encoder-prev) and keyIsNext(c) (RIGHT or encoder-next). KEY_CANCEL and KEY_CONTEXT_MENU stay screen-specific. Joystick rotation is handled upstream (rotateJoystickKey) \u2014 screens see already-rotated keys.handleInput","text":"MomentaryButton (src/helpers/ui/MomentaryButton.h); UITask::begin() must call begin() on every one (the joystick directions and Back included, not just the user button) \u2014 that sets pinMode and, where enabled, claims the interrupt. UITask::loop() polls each button, maps its event to a KEY_* code, and dispatches it to the current screen.endFrame() blocks the loop for a slow e-ink refresh:
"},{"location":"design/solo_ui_framework/#8-persistence","title":"8. Persistence","text":"-D BUTTON_USE_INTERRUPTS, e-ink boards). A GPIO interrupt latches each press/release edge into a per-button ring buffer with its own timestamp, so taps that land during a refresh aren't lost; check() replays them afterwards. The nRF52 has only 8 GPIOTE channels (the radio takes one) \u2014 if none is free a button silently falls back to polling, so it still works, just without mid-refresh capture.loop() drains all pending events from the buttons into a small key FIFO, applies the whole burst (handleInput per key), then redraws once. So three joystick flicks captured during one refresh move the selection three steps for the cost of a single panel update, instead of collapsing into an ignored multi-click or one-step-per-refresh. Buttons created with multiclick=false therefore emit one discrete CLICK per release; multiclick=true buttons still report double/triple-click.NodePrefs struct (NodePrefs.h), saved via the_mesh.savePrefs() and loaded by DataStore.cpp. Rules when adding a field:
NodePrefs::SCHEMA_SENTINEL. Serialization is binary-positional, so order is the on-disk format; never insert in the middle.rd(...) in DataStore::loadPrefsInt() and a file.write(...) in savePrefs(), in the same position, and clamp on load (an upgrader's file lacks the field and reads stray bytes \u2014 clamp to a sane default). Saves are atomic (temp-file + rename), so a crash mid-save can't corrupt settings._dirty convention: a multi-field editor screen mutates _node_prefs live for instant feedback but only persists once, on exit, gated by a _dirty flag \u2014 so LEFT/RIGHT value-cycling doesn't thrash flash. Set _dirty = true at each edit site, then on the exit path call _task->savePrefsIfDirty(_dirty) (UITask) \u2014 it saves once iff dirty and clears the flag, so every screen's save-on-exit reads the same and the \"did we touch flash?\" decision lives in one place. A one-shot action from a popup (no exit hook) calls the_mesh.savePrefs() immediately. Follow whichever matches your screen.UITask::setTarget() (defines it), setTargetNow() (defines + saves + toast), or clearTarget() \u2014 one definition used by the Locator screen, the map, and the Nearby/Waypoints \"Set as target\" actions. Resolve a person's current position with resolvePersonPos() (live [LOC] share, else last-advertised fix).
"},{"location":"design/solo_ui_framework/#10-worked-example-a-new-tools-screen","title":"10. Worked example \u2014 a new Tools screen","text":"s_wrap_*) is safe as long as it's never held across a yield. Don't add scratch that outlives one render().endFrame() on e-ink stalls the main loop for hundreds of ms. Keep render()'s return value honest so the panel isn't redrawn more than needed, and don't depend on loop() cadence for timing that must be exact (the ringtone player moved to a hardware timer for this reason).UITask::onContactRemoved() / onChannelRemoved(). New per-contact or per-channel state should clear there too._task->showAlert(\"msg\", duration_ms) overlays a transient banner over any screen; no redraw plumbing needed.strncpy+NUL or snprintf; treat every name/label as untrusted-length and render through drawTextEllipsized.// ui-new/MyToolScreen.h \u2014 included by UITask.cpp near the other screens\n#pragma once\n#include \"icons.h\" // drawList + mini-icons\n#include \"../NodePrefs.h\"\n\nclass MyToolScreen : public UIScreen {\n UITask* _task;\n NodePrefs* _prefs;\n int _sel = 0, _scroll = 0;\n bool _dirty = false;\n static const int ROWS = 3;\npublic:\n MyToolScreen(UITask* t, NodePrefs* p) : _task(t), _prefs(p) {}\n void onShow() override { _sel = 0; _scroll = 0; _dirty = false; }\n\n int render(DisplayDriver& d) override {\n d.setTextSize(1);\n d.drawCenteredHeader(\"MY TOOL\");\n drawList(d, ROWS, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {\n drawRowSelection(d, y, sel, reserve);\n d.setCursor(4, y);\n d.print(i == 0 ? \"Alpha\" : i == 1 ? \"Bravo\" : \"Charlie\");\n });\n return 500;\n }\n\n bool handleInput(char c) override {\n if (c == KEY_CANCEL) {\n _task->savePrefsIfDirty(_dirty); // saves once iff dirty, then clears\n _task->gotoToolsScreen();\n return true;\n }\n if (c == KEY_UP) { _sel = (_sel + ROWS - 1) % ROWS; return true; }\n if (c == KEY_DOWN) { _sel = (_sel + 1) % ROWS; return true; }\n if (keyIsPrev(c) || keyIsNext(c) || c == KEY_ENTER) {\n /* mutate _prefs\u2026, set _dirty = true */ return true;\n }\n return false;\n }\n};\n#include \"MyToolScreen.h\" in UITask.cpp, add the member + constructor + gotoMyToolScreen() (\u00a71), and add a row in ToolsScreen.h. Done \u2014 scrolling, the scrollbar, font scaling, e-ink pacing and persistence batching all come from the framework.refactor/trail-screen (zmergowany do main) Plik \u017ar\u00f3d\u0142owy: TrailScreen.h Status: zaimplementowane \u2014 dokument zachowany jako zapis analizy/decyzji. Aktualny opis funkcji od strony u\u017cytkownika: tools_screen.md \u203a GPS Trail.TrailScreen\n\u251c\u2500\u2500 3 widoki (LEFT/RIGHT): Summary \u00b7 Map \u00b7 List\n\u251c\u2500\u2500 popup akcji (Hold Enter) \u2014 JEDNA p\u0142aska lista, do 12 pozycji\n\u2502 \u251c\u2500\u2500 ustawienia (LEFT/RIGHT cykluje w miejscu): Min dist \u00b7 Readout \u00b7 Grid\n\u2502 \u251c\u2500\u2500 toggle: Start/Stop tracking\n\u2502 \u251c\u2500\u2500 waypointy: Mark here \u00b7 Waypoints \u00b7 Clear waypoints\n\u2502 \u2514\u2500\u2500 trail: Save \u00b7 Load \u00b7 Export(live) \u00b7 Export(saved) \u00b7 Reset\n\u251c\u2500\u2500 pod-ekrany waypoint\u00f3w (nak\u0142adane na widoki):\n\u2502 \u251c\u2500\u2500 WP_LIST (lista + dystanse; Trail-start + \u201e+ Add by coords\")\n\u2502 \u251c\u2500\u2500 WP_NAV (navview)\n\u2502 \u251c\u2500\u2500 WP_ADD (formularz lat/lon/label)\n\u2502 \u2514\u2500\u2500 _wp_ctx popup: Rename \u00b7 Delete \u00b7 Send\n\u2514\u2500\u2500 KeyboardWidget (label / lat / lon) \u2014 nak\u0142adka pe\u0142noekranowa\nrenderMap() (\u2248140 linii) + renderGrid() (\u2248130 linii) + 7 funkcji rysuj\u0105cych markery.openActionMenu() (TrailScreen.h:363) buduje jedno menu, kt\u00f3re miesza cztery r\u00f3\u017cne klasy pozycji:reopenAt, TrailScreen.h:581); - Brak kontekstu widoku \u2014 Grid (dotyczy tylko mapy) i Readout (dotyczy tylko Summary) s\u0105 widoczne zawsze, te\u017c tam, gdzie nie maj\u0105 efektu; - Grid ma dwie \u015bcie\u017cki \u2014 i LEFT/RIGHT (:221) i Enter (:234) robi\u0105 to samo; lekko myli.renderGrid dostaje 11 skalarnych parametr\u00f3w \u2014 brak wsp\u00f3lnej projekcji","text":"renderMap liczy projekcj\u0119 (lokalne lambdy projectLL/project, :816), a renderGrid (:876) dostaje 11 osobnych liczb (area_*, min/max_lat, min_lon, lon_scale_geo, scale, off_*) i powtarza t\u0119 sam\u0105 matematyk\u0119 projekcji r\u0119cznie w p\u0119tli (:988, :992). To samo r\u00f3wnanie \u017cyje w trzech miejscach. Ka\u017cda zmiana modelu mapy wymaga edycji w kilku miejscach naraz.renderGrid wybiera krok siatki w czterech nast\u0119puj\u0105cych po sobie korektach (:912\u2013:952): 1. najwi\u0119kszy krok \u2264 target_m, 2. zwi\u0119kszaj a\u017c odst\u0119p pikseli \u2265 MIN_GRID_PX (22 px), 3. zmniejszaj a\u017c zmieszcz\u0105 si\u0119 \u22652 interwa\u0142y, 4. zwi\u0119kszaj a\u017c liczba linii \u2264 MAX_GRID_LINES (40).:937) m\u00f3wi o \u201estatic buffers (40\u00d740 = ~1600 intersections)\" \u2014 takich bufor\u00f3w ju\u017c nie ma; p\u0119tla rysuje na bie\u017c\u0105co z continue-guardami (:986\u20131002). Cap 40 ogranicza dzi\u015b tylko liczb\u0119 iteracji p\u0119tli (wydajno\u015b\u0107), nie chroni \u017cadnego bufora. Komentarz wprowadza w b\u0142\u0105d. - Kroki 2 i 3 mog\u0105 sobie przeczy\u0107 na bardzo ma\u0142ych ekranach (MIN_GRID_PX = 22 vs shorter_px/2, gdy shorter_px < 44). Nie powoduje b\u0142\u0119du, ale \u201eostateczny\" krok bywa wtedy przypadkowy.
"},{"location":"design/trail_redesign/#3-propozycja-uporzadkowania","title":"3. Propozycja uporz\u0105dkowania","text":""},{"location":"design/trail_redesign/#31-popup-dwa-poziomy-zamiast-jednej-paskiej-listy","title":"3.1 Popup: dwa poziomy zamiast jednej p\u0142askiej listy","text":"_act_map[16] z komentarzem \u201e12 used today; pad\" \u2014 r\u0119czne pilnowanie rozmiaru; pushAction ju\u017c to zabezpiecza, wi\u0119c magiczna 16 jest zb\u0119dna.:852, :1011).Hold Enter \u2192 Trail\n \u2022 Start / Stop tracking\n \u2022 Mark here\n \u2022 Waypoints\u2026 \u2192 istniej\u0105cy WP_LIST\n \u2022 Trail file\u2026 \u2192 Save / Load / Export (live) / Export (saved) / Reset\n \u2022 Settings\u2026 \u2192 Min dist \u00b7 Readout \u00b7 Grid (LEFT/RIGHT w miejscu)\nReset przeniesiony do \u201eTrail file\u2026\", dalej od przypadkowego Entera; - (opcjonalnie) Grid pokazywa\u0107 tylko gdy aktywny jest widok Map, a Readout tylko przy Summary \u2014 menu zale\u017cne od kontekstu widoku.Reset na sam d\u00f3\u0142, usun\u0105\u0107 podw\u00f3jn\u0105 \u015bcie\u017ck\u0119 Grid. Mniej porz\u0105dku ni\u017c podmenu, ale ta\u0144sze.MapProjection liczony raz w renderMap i przekazywany do renderGrid oraz marker\u00f3w:struct MapProjection {\n int32_t min_lat, max_lat, min_lon;\n float lon_scale_geo, scale;\n int off_x, off_y, area_x, area_y, area_w, area_h;\n void project(int32_t lat, int32_t lon, int& px, int& py) const;\n};\n
renderGrid(display, proj) zamiast 11 parametr\u00f3w;proj.project(...).
MapProjection; renderGrid i markery przez projekcj\u0119 \u015brednie (czysty refactor) 3 Upro\u015b\u0107 wyb\u00f3r kroku siatki; popraw nieaktualne komentarze; sprz\u0105tnij _act_map niskie
"},{"location":"development/roadmap/","title":"Feature roadmap","text":"Trail file\u2026 i Settings\u2026 jako podmenu.Grid widoczny w Settings tylko na widoku Map, Readout tylko na Summary.MapProjection + uproszczenie siatki (etap 2 i 3 razem).UITask::clearAllDMUnread() \u2014 memset over _dm_unread_table - MessagesScreen::clearAllChannelUnread() already existed - UITask::clearRoomUnread() already existed - Title is a static const char* table (PopupMenu stores the title pointer verbatim \u2014 locals would dangle) - Zero schema impact, all counters live in RAMuint8_t favourite_contacts[6][6] \u2014 first 6 bytes of each contact's pub_key (enough to disambiguate locally) - Lookup at render time: walk contacts, match prefix, render name + unread badge - Empty slot renders as \"+\" placeholder; Enter on empty opens a contact picker (existing UI)favourite_contacts to NodePrefs, bump SCHEMA_SENTINEL low byte.\u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557\n\u2551 Favourites \u2551\n\u2560\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2563\n\u2551 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2551\n\u2551 \u2502Alice \u2502 \u2502Bob 3 \u2502 \u2502 + \u2502 \u2551\n\u2551 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2551\n\u2551 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2510 \u2551\n\u2551 \u2502Carol \u2502 \u2502 + \u2502 \u2502 + \u2502 \u2551\n\u2551 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2518 \u2551\n\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n(lat, lon, ts) into a RAM ring buffer; user explicitly saves snapshots to flash.G indicator appears in the status bar (analogous to A for auto-advert). A reboot resets the active state to off; the RAM trail is also lost on reboot unless saved to a flash slot first.
BreadcrumbEntry[BC_RAM_CAP] in UITask (or a dedicated component). Each entry int32_t lat_1e6, int32_t lon_1e6, uint32_t ts = 12 B. Cap = 256 \u2192 3 KB RAM. nRF52840 (256 KB RAM) has plenty of headroom./breadcrumb.0, /breadcrumb.1, /breadcrumb.2 \u2014 three named slots - Each file: small header (count, start_ts, end_ts, total_distance_m) + entry array - Written only on explicit \"Save trail\" action \u2014 zero background writes, zero wear concern - Optional: auto-save to slot 0 on detected low-battery shutdown (single write before going dark)X, start marked *. UP/DOWN zoom, LEFT/RIGHT pan when zoomed. 3. Last N entries list \u2014 scroll through recent points with timestamp + delta from previous.* when active, like auto-advert A) - Back \u2192 exit - Hold Enter \u2192 context menu: - \"Reset trail\" \u2014 clear RAM ring - \"Save trail \u2192 slot N\" \u2014 snapshot RAM into chosen flash slot - \"Load trail \u2190 slot N\" \u2014 restore from chosen slot into RAM ring - \"Export over USB\" \u2014 dump live RAM trail as KML/GPX over serialuint8_t breadcrumb_interval_idx, uint8_t breadcrumb_min_delta_idx. Sentinel bump. The slot files are separate from prefs.feat/waypoints-nav). The whole navigation suite landed. Notable deltas from the original spec that follows:
/waypoints (16 max), independent of trail recording and kept across Reset trail.navview::draw(...) reused by waypoints, Trail-start backtrack, Nearby-node nav and message-location nav. Shows distance + To: + Hdg: (two absolute bearings), honouring the global Units setting.units_imperial + trail_show_pace prefs (schema 0xC0DE0006); the old combined trail_units_idx retired.[WAY]lat,lon label; a received location ({loc} text or a [WAY] share) offers Navigate / Save waypoint from both the message list row and the fullscreen view. Backed by a shared geo::parseLatLon.geo:: (haversineKm/bearingDeg/bearingCardinal/fmtDist/parseLatLon) in GeoUtils.h; 1-px gfx::drawLine/drawCircle in GfxUtils.h; one UITask::currentLocation() GPS accessor./waypoints (separate from prefs, like /trail). Fixed table, no schema-sentinel impact:struct Waypoint {\n int32_t lat_1e6, lon_1e6; // saved fix\n uint32_t ts; // when marked (RTC)\n char label[12]; // short name, NUL-terminated (\"CAR\", \"CAMP\", \"H2O\"\u2026)\n};\nstatic const int WAYPOINT_MAX = 16; // 16 \u00d7 24 B = 384 B file\nKeyboardWidget to type the label (\u226411 chars). Empty input auto-labels WP<n>. - Saves the current fix + label, appends to the file.renderMap() derives the box from TrailStore::boundingBox(); extend it to also span the waypoint table (and handle the \"waypoints but empty trail\" case \u2014 map still renders). - Generalise the project() lambda to take raw (lat, lon) instead of a TrailPoint& so the same projection draws both track points and waypoints. - Marker shows the label's first character beside it when there's room (122 px is tight with many waypoints); the full label lives in the list / nav view./waypoints file), unlike the RAM trail ring. CAMP \u2190 waypoint label\n 1.4 km \u2190 distance to target\n To: 145\u00b0 SE \u2190 absolute bearing target-from-me (haversine/bearingDeg)\n Hdg: 090\u00b0 E \u2190 my course over ground, derived from recent GPS movement\n-- instead of a stale value.TrailStore helper: bool currentCourse(int& deg) walks back from the newest fix until the cumulative distance from it exceeds a threshold (~10\u201315 m so GPS noise doesn't dominate) and returns the bearing from that older fix to the newest. Returns false (\u2192 --) when there isn't enough recent movement. Refreshed ~1 s. bearingDeg / bearingCardinal are currently private statics in NearbyScreen; lift them into a shared header (or TrailStore) so both screens use them.(lat, lon, label) \u2014 it doesn't care whether that came from a waypoint, the trail start (Backtrack), or a node's last-known advert position. Make it a small reusable component (NavView / drawNavTo(display, target_lat, target_lon, label)) and wire it in from Nearby Nodes too: node detail \u2192 \"Navigate\" \u2192 same To/Hdg/distance screen, retargeting the selected node. This folds the backlog \"Compass to contact\" idea into one screen and means Nearby stops being a static snapshot \u2014 you can actually walk toward a person.gps_lat/gps_lon from its last advert (already read in NearbyScreen). It's a last-known fix, not live, so the label could show the advert age (e.g. Alice (5m)); pair it with Auto-Advert on the other device for a moving target.TrailStore only works while a trail is actively being recorded \u2014 but node/waypoint navigation shouldn't require the user to start trail logging. Fix: a tiny independent COG ring in UITask (\u22484 fixes), maintained on every GPS poll regardless of trail state. The trail ring stays a separate concern, so the Hdg line is available everywhere (waypoints, nodes, backtrack).-- until the window has cleared the threshold at least once. This is what stops a standing user from getting a spinning bearing.gps_lat/gps_lon as the target. No separate compass screen needed; the \"two absolute bearings (To / Hdg)\" approach replaces the relative-heading arrow this entry originally assumed. Kept here only as a cross-reference.-cog (rotate each projected point before drawing). Point-glyph markers rotate cleanly; label text stays upright; the north arrow then points to actual north instead of straight up.feat/power-saving \u2014 two independent toggles under Settings \u203a Radio, both default OFF. Under field testing; not yet merged.SetRxDutyCycle, datasheet 13.1.7) via RadioLib startReceiveDutyCycleAuto(preamble, 8): the chip's sequencer cycles RX\u2194sleep, latches a preamble and stays in RX to receive the packet (RX_DONE on DIO1). No MCU state machine \u2014 recvRaw() reads the packet exactly as in continuous RX. armRecv() arms duty-cycle when power-save is on, else a normal startReceive(); loop() re-arms only on a toggle. Falls back to continuous RX if the modem doesn't support duty-cycle (non-SX126x). state stays STATE_RX so the dispatcher's not-in-RX watchdog never trips. - Duty-cycle engages when the configured preamble \u2265 2\u00b78+1 symbols. At SF\u22648 the preamble is 32 \u2192 full duty-cycle; at SF9\u201312 it is 16 \u2192 RadioLib transparently stays on continuous RX (no power saving on the slow SFs). - Companion: rx_powersave pref (schema 0xC0DE0009), Settings \u203a Radio \u203a \"Pwr save\", applied at boot (MyMesh) and on change (UITask). Noise-floor sampling is skipped while on (chip is asleep most of the time) \u2014 the radio page shows \"Noise floor: n/a\".standbyXOSC/burst windows). It fought the hardware \u2014 querying a warm-sleeping chip from checkSend() gave a phantom-busy channel that stalled TX for ~4 s, and ACKs dropped in the scan gaps. Replaced wholesale by the hardware duty-cycle above, which fixed both.tx_power_dbm becomes a ceiling; APC drives the radio's actual power within [APC_MIN_DBM \u22129, ceiling] to hold the link margin near a target. Lives in MyMesh (applyApc + apcSampleSnr/apcOnFailure controller), tx_apc pref. - Two feedback sources, both the reverse link (no protocol change): - Direct messages \u2014 ACK SNR (onAckRecv); missed ACK (onSendTimeout) = lost confirmation. - Channel/flood messages \u2014 no ACK exists, so we hash each originated flood (apcTrackFloodSend in sendFloodScoped, hash excludes the path) and listen in filterRecvFloodPacket for a repeater rebroadcasting it; the heard echo's SNR is a sample, and no echo within ~6 s counts as a lost confirmation. This is what lets a channel send recover after APC trimmed power below what the repeaters can hear (previously it could strand channel TX at the floor). - Margin is measured above the per-SF demod floor (\u22127.5 \u2212 2.5\u00b7(SF\u22127) dB) so one target works across SF7\u201312. Each SNR sample is smoothed with an EWMA (\u03b1=0.4); power steps proportionally to the error (capped \u00b12 dB) with a \u00b12 dB deadband to avoid hunting; the EWMA is nudged by each step so it doesn't re-trigger on a stale sample. - Lost confirmation \u2192 step up +4 dB; 2 consecutive losses \u2192 jump to the ceiling (ramp gradually first since a loss is an ambiguous power signal). Any confirmation clears the streak. Live power shown on the radio page + name bar.startReceive() with boosted gain in RadioLibWrappers.cpp); the MCU already sleeps between iterations (sd_app_evt_wait()/WFE in NRF52Board.cpp), so the framework is not the bottleneck \u2014 the radio is. The win is framework-agnostic and can land in this Arduino tree while keeping the Solo UI and upstream sync.
startReceiveDutyCycle. Tradeoff: slightly higher receive latency / a small sensitivity hit \u2014 acceptable for a companion, must be a toggle/setting so users who want lowest latency can keep continuous RX.tx_power_dbm dynamically when link quality (SNR/RSSI of acks) allows; raise it back when needed.board.sleep(0) whenever hasPendingWork() is false \u2014 but that only covers MCU idle (it does not touch the radio, which is the real draw), and there is no on/off toggle because not-sleeping would only waste power. The same merge also brought the preamble 16\u219232 bump for SF<9, which is what makes the hardware RX duty-cycle viable at SF8 (it needs \u2265 2\u00b78+1 = 17 preamble symbols to latch). Prefer adopting/extending upstream work over a parallel implementation. Note ZephCore's licence before copying any code verbatim (architecture inspiration is fine).feat/power-saving; see the status block at the top of this entry for what's left (PPK2 current measurement, multi-hop APC gating).feature/companion-repeater-presets.
"},{"location":"development/roadmap/#sos-broadcast","title":"SOS broadcast","text":"client_repeat, Tools \u203a Repeater) \u2014 the companion relays flood/direct traffic, still working as a normal companion. By default it switches to a dedicated band on enable (see profile note below) rather than relaying on whatever network it's chatting on. MyMesh::allowPacketForward gates it; loop detection (isRepeatLooped, ported from simple_repeater) and an advert flood-depth cap are always applied. Packet pool bumped 16\u219232 to match the repeater workload (a too-small pool starved channel/DM reception once relaying queued retransmits).Network: Current/Custom, repeater_use_profile + repeater_freq/bw/sf/cr). Custom switches the radio to a preset/manual profile when the repeater is enabled and restores the companion's params when disabled (user-chosen revert-on-disable); a profile equal to the companion = \"same network\", a different one = drop onto a separate repeater network. MyMesh::applyRepeaterRadio() is the single decision point, called at boot and on every toggle/edit; repeaterProfileValid() gates it. Schema sentinel 0xC0DE0010. Custom is the default: a never-configured device (or one upgrading from a pre-0x10 file, which has no saved profile) turns the profile on and seeds repeater_sf/bw/cr from LORA_SF/BW/CR plus a band-matched repeater_freq \u2014 relaying on the same network the operator is chatting on isn't the MeshCore community norm, so \"Current\" stays opt-in. defaultRepeaterFreqForBand() (NodePrefs.h) buckets the companion's own freq into whichever of the three license-exempt bands MeshCore's app-driven repeat toggle historically restricted to (433.000 / 869.495 / 918.000 MHz \u2014 see repeat_freq_ranges in MyMesh.cpp), so the seeded default can't land outside what's legal for wherever the companion's own network already is. LORA_FREQ/BW/SF/CR fallback #defines moved from MyMesh.h to NodePrefs.h so DataStore.cpp's migration code can see them too.getRetransmitDelay); own sends pass their own delay to sendFlood, so the companion's own traffic is never slowed. Widens the overhear window.-128 sentinel = off, so an upgraded prefs file can't read as \"filter at 0 dB\").Dispatcher::suppressQueuedDuplicate, wantsOverhearSuppress hook). MeshCore had no overhear-cancel before \u2014 the unused removeOutboundByIdx/getOutboundByIdx finally have a caller. Packet hash ignores the path for non-TRACE, so our copy and the peer's relayed copy hash equal. Pairs with Yield (longer delay \u2192 wider window to hear a peer).Dispatcher::n_recv_by_type/n_sent_by_type), uptime, heap + stack (new DeviceDiag helper, nRF52 linker-symbol/sbrk heap + FreeRTOS stack high-water), noise floor, RSSI/SNR, pool free, outbound queue, Forwarded (Mesh::n_forwarded \u2014 actual retransmits; backed out on overhear cancel so it reflects what hits the air), and Errors (Dispatcher ERR_EVENT_* flags decoded to F/C/R). Hold Enter opens a one-item \"Reset counters\" menu (Back dismisses) \u2014 resetStats made virtual; Mesh override also clears n_forwarded.client_repeat is on (effective pref && !client_repeat via applyPowerSave() / apcActive()), applied at boot, on the on-device toggle, and on the app's CMD_SET_RADIO_PARAMS; Settings shows -- and blocks the toggle, preserving the user's pref for when the repeater goes off. A blinking \u00bb status-bar indicator (ICON_REPEATER) shows relaying at a glance.0xC0DE000E (four knobs), 0xC0DE000F (suppress-dup), 0xC0DE0010 (radio profile), with stray-byte clamps for upgraders.CMD_SET_RADIO_PARAMS was commented out (not deleted) to match the on-device toggle's any-frequency behaviour \u2014 undecided whether that gate was UX-only or regulatory.{loc} and {batt} filled. 30 s cooldown.!word tokens and answered with live node data via expandMsg \u2014 !batt/!loc/!time/!temp/!status/!ping/!help, plus !hops (per-message hop count via getPathHashCount(), direct if heard directly). Multiple commands in one message are merged into a single |-joined reply (one transmission/throttle/counter tick) via the shared botScanCommands. Works in DMs (per-contact throttle, ignores quiet hours \u2014 a pull) and on the bot's monitored channel (broadcast: per-channel cooldown, respects quiet hours). Toggled independently of the trigger bot. See MyMeshBot.h tryBotCommand / tryBotChannelCommand / botCommandReply.<n> unread, summing DM + channel + room counters) below the time. Reuses the existing unread counters; no schema change.uint8_t profile_idx with hardcoded value tables.main). A per-message marker sits at the end of each outgoing DM row in the history and in the fullscreen message view. Deltas from the spec that follows: - Pending isn't a single \u00b7/\u2026 \u2014 it draws one square dot per send attempt, so an auto-resent message shows its retry count at a glance. - Delivered / failed use drawn scalable mini-icons (\u2713 / \u2717) that scale with the font, not glyph-font characters \u2014 legible on every layout (see icons.h, authored as compile-time ASCII-art). - Adds DM auto-resend + incoming dedup, plus a channel \"relayed into mesh\" marker (\u2713 when a repeater echo confirms the channel message went out).sendMessage() returns expected_ack + est_timeout; the ACK arrives via onAckRecv / isAckPending \u2014 the same mechanism APC and ping already consume.\u00b7 / \u2026 \u2014 sent, awaiting ACK (pending) - \u2713 \u2014 delivered to the recipient (ACK matched) - \u2717 / ! \u2014 timed out, no confirmationDmHistEntry gains uint8_t ack_status (0=incoming/none, 1=pending, 2=delivered, 3=failed) and uint32_t ack_tag (the expected_ack CRC).ack_tag = expected_ack, ack_status = pending, record the send time + est_timeout. - On ACK: route onAckRecv(ack_crc) to the UI; find the entry whose ack_tag matches and set it delivered (single shared callback, like onPingResult). - Timeout: in the UI loop, a pending entry older than est_timeout \u2192 failed.expected_ack == 0 (and channel messages, which have no ACK) can't be confirmed \u2192 show plain \"sent\" (\u2192) and no delivery state. - Glyph rendering: prefer a Lemon-font check; on the plain ASCII font fall back to drawn 1-px marks or letters so it reads on the OLED.{loc} every N minutes to a chosen channel or DM contact \u2014 group trip tracking. Builds on the existing auto-advert cadence pattern and {loc} expansion. A status-bar indicator (like A/G) while active; off by default.docs/qr_codes.md); this is the on-device render side.<ele> tags to the GPX export. Skip cleanly when no altitude is available.
321d769e. Grouped by severity. Listed but not yet fixed.onChannelMessageRecv / onChannelDataRecv \u2014 guard for findChannelIdx == -1","text":"MyMesh.cpp:561-602, MyMesh.cpp:619-625findChannelIdx() before continuing:int idx = findChannelIdx(channel);\nif (idx < 0) {\n MESH_DEBUG_PRINTLN(\"...: unknown channel secret \u2014 dropping message\");\n return;\n}\nuint8_t channel_idx = (uint8_t)idx;\nidx=255.addChannelMsg guards against bogus index","text":"MessagesScreen.h:414-433if (ch_idx >= MAX_GROUP_CHANNELS) return; at function entry \u2014 prevents ring-buffer pollution in case any future caller forgets the upstream guard. With C1 fixed this should never trigger, but the cost is zero.findChannelIdx scans all-zero secret in uninitialised slots","text":"BaseChatMesh.cpp:908findChannelIdx() now returns -1 immediately when the queried secret is all-zero, so a corrupted/empty channel can't match an unused all-zero slot. Complements the load-side skip already in loadChannels().saveChannels writes all 40 slots to /channels2","text":"DataStore.cpp:687MAX_GROUP_CHANNELS, so the file holds only the channels actually configured (was always ~2.7 KB). loadChannels() already compacted empty entries on read, so the loaded result is unchanged \u2014 only on-flash size and write wear drop.msgRead(0) wipes the whole DM unread table","text":"UITask.cpp:1403-1410if (msgcount == 0) {\n memset(_dm_unread_table, 0, sizeof(_dm_unread_table));\n ((MessagesScreen*)messages_screen)->clearAllChannelUnread();\n}\nMessagesScreen.h, KeyboardWidget.hChHistEntry::text was 140 B and DmHistEntry::text only 80 B, while the keyboard capped input at 139 B \u2014 all below MeshCore's MAX_TEXT_LEN (160 B). Channel messages embed the sender as \"Name: body\" in the payload, so the prefix ate into the 140 and clipped the tail; DMs over ~80 B were cut outright; and Polish text (2 bytes per accented char) roughly halved the visible limit. Fixed: history + fullscreen/preview copies sized to MAX_TEXT_LEN + 1, keyboard cap raised to 160 with per-field maxima kept on the smaller stores (custom_msgs, bot reply). Full-length messages now compose, send, store and display intact.loadPrefsInt scopes trail_units_idx reset to the 0xC0DE0003 jump","text":"DataStore.cpp:326-343sentinel == 0xC0DE0003 so newer mismatches (e.g. 0xC0DE0004 \u2192 0xC0DE0005, which both saved the field correctly) no longer clobber the user's choice.CMD_SET_DEFAULT_FLOOD_SCOPE off-by-one \u2014 not a bug","text":"MyMesh.cpp:2143default_scope_name is declared char[31] (not 32), so n < 31 correctly admits the maximum 30-character string + NUL. The audit entry was a misread.strlen on cmd_frame without null-termination \u2014 replaced with strnlen","text":"MyMesh.cpp:2140-2147CMD_SET_DEFAULT_FLOOD_SCOPE doesn't have to be NUL-terminated by the sender. Switched to strnlen(\u2026, 31) so the search can't run past the field into the 16-byte key (or beyond the frame).PopupMenu._cap updated only in render()","text":"PopupMenu.h:37-44, 77-90NearbyScreen.h:448-451max_chars < 4 instead of feeding a negative length to strncpy. (Lived in renderDiscoverDetail before the one-list refactor; now in renderScanDetail.)expandMsg GPS validity test treats (0, 0) as invalid","text":"MyMeshBot.h:95, 132, 182sensors.node_lat != 0.0 || sensors.node_lon != 0.0 // proxy for \"valid GPS\"\n%.1f","text":"NearbyScreen.h:467-470, 330-332SNR: %.1f dB, Rem: %.1f dB) and the ping popup keep the 0.25 dB resolution. (After the one-list refactor the scan list cards show RSSI in the right column, not SNR.)_count cast to uint16_t","text":"Trail.h:27static_assert(CAPACITY <= 0xFFFF, \u2026) next to the CAPACITY definition now fails the build if it is ever grown past what the uint16_t save-header count can hold, instead of silently truncating. Safe today (CAPACITY=512).strstr on truncated 199-char buffer \u2014 not reachable","text":"MyMeshBot.h:11BOT_SCRATCH is 200 and MAX_TEXT_LEN is 160, so an incoming message never reaches the 199-char truncation point \u2014 the scratch buffer (used by the centralised botTriggerMatches()) always holds the whole message. No fix needed.strncpy(\"?\", buf, sizeof(buf)) replaced with strcpy","text":"MessagesScreen.h:785, 858strcpy so we don't memset 21 unused bytes for a one-character string.MessagesScreen.h:1279-1287rlen is clamped to 20 before building \"RE:\" + nick, so the title is \u226423 chars and fits title[24] with no overflow. A nick longer than 20 chars is shown truncated, but that's an intentional fit-to-header limit (the OLED header only fits ~21 chars anyway), not a bug.
"},{"location":"solo_features/external_keyboard/","title":"External keyboard","text":""},{"location":"solo_features/external_keyboard/#external-keyboard-joystick","title":"External Keyboard & Joystick","text":"findChannelIdx == -1 guarded at both channel-recv paths; addChannelMsg defends against bogus indextrail_units_idx reset scoped to the 0xC0DE0003 jumpstrnlen instead of strlen on default scope namerenderDiscoverDetail skips pub-key line on very narrow displays\"?\" sender no longer memsets through strncpyrenderGrid now picks a round labelled step (1m\u2026100km / 10ft\u2026100mi) nearest ~1/3 of the shorter side and enforces a MIN_GRID_PX floor, so the grid can never silently vanish on an elongated trail. TrailScreen.h:635default_scope_name[31])BaseChatMesh (findChannelIdx should iterate num_channels, not MAX_GROUP_CHANNELS; saveChannels should stop at the first uninitialised slot) or a local override
"},{"location":"solo_features/external_keyboard/#support-by-device","title":"Support by device","text":"Device CardKB Wired joystick Seeed Wio Tracker L1 (OLED) \u2705 Grove connector onboard Seeed Wio Tracker L1 (E-ink) \u2705 Grove connector onboard GAT562 30S Mesh Kit \u2014 onboard Heltec V3 (experimental) \u2705 solder to free GPIOs \u2705 solder to free GPIOs Heltec V4 (experimental) \u2705 solder to free GPIOs \u2705 solder to free GPIOs M5Stack Cardputer ADV (experimental) \u2014 built-in keyboard instead, see below LilyGO T-Echo Lite + KeyShield (experimental) \u2014 built-in keypad instead, see below"},{"location":"solo_features/external_keyboard/#cardkb","title":"CardKB","text":"0x5F), for typing messages, names and labels without walking the on-screen letter grid.
"},{"location":"solo_features/external_keyboard/#wiring-heltec-v3-v4","title":"Wiring (Heltec V3 / V4)","text":"Wire1) \u2014 not the OLED's 17/18 CardKB SCL 4 Joystick UP 23 Joystick DOWN 6 Joystick LEFT 47 Joystick RIGHT 48 Joystick press \u2014 Enter 33 the stick's own fifth contact; required when the joystick is enabled Back 0 the onboard PRG button \u2014 nothing to wire [env:Heltec_v3_companion_solo_dual] / [env:heltec_v4_companion_solo_dual] blocks in variants/heltec_v3/platformio.ini and variants/heltec_v4/platformio.ini, with comments explaining which pins are safe to reuse. To build a CardKB-only device, comment out the joystick block and set Ext. KB to Compact.
platformio.ini / keyboard driver under variants/ for its current keymap.
"},{"location":"solo_features/clock_screen/clock_screen/#data-fields","title":"Data fields","text":"OLED E-Ink 3.92V) Batt % Batt Battery percentage using LiPo curve anchored at the low-battery threshold Temperature Temp \u00b0C from onboard sensor Humidity Hum % from onboard sensor Pressure Pres hPa from onboard sensor GPS GPS lat lon decimal degrees, or no fix Altitude Alt metres from onboard sensor (GPS or barometric) Luminosity Lux lux from onboard sensor CO\u2082 CO2 ppm from onboard sensor Contacts Nodes Total contacts in the mesh Messages Msgs Total unread message count -- when the sensor is not connected or has no data.
"},{"location":"solo_features/favourites_dial/favourites_dial/#navigation","title":"Navigation","text":"+) \u2014 opens a contact picker to fill the slot.+ tile.+). A picker opens showing:
OLED E-Ink
{time} current time (HH:MM) always {loc} GPS coordinates always (\"no GPS\" if no fix) {temp} temperature sensor connected {hum} humidity sensor connected {pres} barometric pressure sensor connected {alt} altitude sensor connected {lux} luminosity sensor connected {co2} CO\u2082 concentration sensor connected {time} and {loc} are always shown.
3m, 2h, >1d) in the top-right corner of each bubble. The list runs newest at the bottom \u2014 opening a history starts you at the latest message, and scrolling up goes further into the past.@[nick]), a To: nick bar is shown below the sender name and the body is displayed without the address prefix.
lat,lon pair in the text \u2014 exactly what the {loc} placeholder inserts \u2014 so you can navigate to anything a contact shares. A [WAY]lat,lon label share also carries a name, used as the waypoint label. This works on DMs and channel messages, incoming or outgoing.
Field Notes Name Up to 31 characters Secret LEFT/RIGHT toggles between two entry modes; Enter opens the keyboard for whichever is selected test); the channel's name and secret are both derived from it (name becomes #test, secret is the first 16 bytes of sha256(\"#test\")). A topic-based public group chat \u2014 anyone who types the same topic elsewhere ends up on the same channel \u2014 separate from the default Public channel.
00\u20260) is rejected (\"Invalid secret\") \u2014 that value is reserved internally to mark an empty channel slot.
None disables the sound for this event. Channel Melody built-in / Melody 1 / Melody 2 / None Notification sound for incoming channel messages. None disables the sound for this event. AD sound built-in / Melody 1 / Melody 2 / None Sound played whenever an advert is received from any node \u2014 pairs with Auto-Advert as an audible \"in range\" heartbeat (see Tools \u203a Auto-Advert). None disables the sound for this event. AD scope All / Zero-hop Filters the AD sound so it plays for every advert or only for local zero-hop adverts.
--) while the repeater is on \u2014 a repeater must listen continuously; your setting is restored when the repeater is switched off. A background watchdog recovers automatically if the duty-cycle sequencer ever gets stuck (soft re-arm, then a full radio reset) \u2014 see Tools \u203a Diagnostics for the recovery counts. Auto pwr ON / OFF Adaptive Power Control. Lowers actual TX power on strong links to save energy, ramping back up \u2014 to the TX Pwr ceiling \u2014 on weak or lost links. Link quality comes from direct-message ACK SNR and, for channel messages (no ACK), from hearing a repeater rebroadcast your packet. The radio page / name bar shows the live power. Default OFF (fixed TX power). Suppressed (shown as --) while the repeater is on \u2014 a repeater holds full TX power for consistent relay reach; your setting is restored when the repeater is switched off. OLED E-Ink !gps fix bot request. OFF (default) matches earlier releases: GPS runs continuously whenever enabled. The GPS status icon blinks while napping between fixes. Units Metric / Imperial Global unit system for every distance/speed shown in Tools (Nearby Nodes, Trail, navigate-to-point). Metric: m / km, km/h, min/km. Imperial: ft / mi, mph, min/mi Reboot action (Enter) Restarts this device. Pending setting changes are saved first. Last row, so it isn't the default-selected one"},{"location":"solo_features/settings_screen/settings_screen/#keyboard","title":"Keyboard","text":"Setting Options Notes Layout ABC / T9 On-screen keyboard style. ABC: an a-b-c\u2026z grid, one key per letter (the original layout). T9: phone-keypad multi-tap \u2014 each key is labelled with its digit and a letter group (e.g. 2abc); repeated Enter presses cycle through the letters and then the digit itself. Applies to whichever script page is active (see Main/Additional below), not just Latin. Main Latin / Cyrillic / Greek Which script the keyboard opens on by default. Latin (default) matches earlier releases; pick Cyrillic or Greek here instead to make that script the one you land on every time, with Latin becoming the one reached via cycling (see Additional below) instead of the other way round. Additional Latin / Cyrillic / Greek The second script added to the same #@/abc key's cycle (Main \u2192 Additional \u2192 Symbols \u2192 Main) \u2014 no separate key to switch scripts. Setting Additional to the same script as Main drops the cycle back to just that script plus Symbols (no second script page at all). Greek covers the 24-letter alphabet plus final sigma (\u03c2) but not the tonos stress accents used in proper Modern Greek spelling. Every script's letters render natively \u2014 the display font (a single unified Unicode font used everywhere on-screen) covers all of them, no separate toggle needed. a c d e i l n o r s t u y z) opens a one-row popup of its accents (e.g. holding a offers \u00e1 \u00e0 \u00e2 \u00e3 \u00e4 \u00e5 \u0105); LEFT/RIGHT picks, Enter inserts it, Cancel dismisses with no change. Holding a letter with no accented variants (e.g. b) does nothing. Works on whichever page is currently showing Latin, whether that's Main or Additional.{time}, {loc}, and sensor placeholders when connected).Sharing pos: with the share age and whether it's DM-verified or channel-only.NODE_DISCOVER_REQ scan) Ping row to send a direct mesh ping to that node. The popup then shows the RTT and SNR values on the next lines, and can be used again immediately for another ping.NODE_DISCOVER_REQ. Repeaters, sensors and room servers within zero-hop range respond immediately with name, type and signal data. This is not a separate screen \u2014 it is the same list switched to a live-scan source: the right-hand column shows RSSI instead of distance, and node detail shows the public key, signal data and contact status.
"},{"location":"solo_features/tools_screen/tools_screen/#gps-trail","title":"GPS Trail","text":"OLED E-Ink Track:N (tracked-node count) and, when you have a fix and at least one tracked contact, an arrow + distance to the nearest one (e.g. Track:3 \u2192120m). If a Locator/Nav target is set it's drawn as a flag marker (see Locator). Press Enter to open the full Trail Map; Hold Enter shares your position (see Live Share); Back returns home.start; scroll with UP/DOWN OLED E-Ink [LOC] message \u2014 pick a contact or channel (see Live Share) Trail file\u2026 Open the file submenu (below) Settings\u2026 Open the settings submenu (below) /trail) Load trail Restore flash trail into RAM Export (live) Stream live RAM trail as GPX 1.1 over USB Serial Export (saved) Stream saved flash trail as GPX 1.1 over USB Serial Reset trail Clear RAM ring and elapsed time paused. The stop is detected with its own coarse movement gate, independent of Min dist, so GPS jitter while you're parked doesn't keep it awake./trail file as the manual Trail file\u2026 \u2192 Save, and only writes when the trail actually has points \u2014 an empty trail can't overwrite a previously saved one. Off by default so a normal shutdown doesn't silently overwrite a saved trail you meant to keep.Back: 12 pt), reading Trail start on the final leg; arriving there shows Back at start and exits. Cancel leaves track-back at any time. It needs a trail with at least two points and a GPS fix; it doesn't require tracking to still be running./waypoints), survive a reboot, and are not cleared by Reset trail. Up to 16 can be stored \u2014 the Waypoints list header shows how many are in use (e.g. WAYPOINTS 3/16).CAR, CAMP, H2O). Leaving it blank auto-names it WP1, WP2, \u2026 Marking works whether or not the trail is being recorded; it needs a GPS fix (otherwise it reports No GPS fix).
WP<n>).
OLED E-Ink CAMP \u2190 target label\n 1.4 km \u2190 distance to target\n To: 145\u00b0 SE \u2190 absolute bearing to the target\n Hdg: 090\u00b0 E \u2190 your current course over ground (-- when stationary)\n-- until you move.[WAY]<lat>,<lon> <label> (e.g. [WAY]37.42123,-122.08456 CAR) for you to confirm or edit before sending. On the receiving device, opening that message and Hold Enter \u2192 Navigate / Save waypoint turns it back into a navigable point (see Messages \u203a Fullscreen message view). The format is plain text, so it stays readable on other firmware and the phone app.
tools/trail_export.py (auto-detects the port, captures from <?xml to </gpx>, writes a timestamped file under tools/gpx/):uv run tools/trail_export.py\n
cat /dev/tty.usbmodem* > track.gpx (stop with Ctrl-C after the dump finishes)<?xml to </gpx> into a .gpx file<wpt> elements (with their label as <name>), alongside the track \u2014 so they show as pins in OsmAnd, Garmin BaseCamp, GPX Studio, Google Earth, etc. Either way, the resulting file imports into all of those.None in Settings \u203a Sound \u203a AD sound to silence just this event, or set Settings \u203a Sound \u203a Advert scope to Zero-hop to limit it to local adverts only. You can also set Settings \u203a Sound \u203a Buzzer to OFF (or Auto, which mutes while a companion app is connected) to silence all buzzer output.[LOC]<lat>,<lon> message \u2014 the same coordinate format waypoints use, so it stays readable on other firmware and the phone app (it just looks like a coordinate to anything that doesn't know the tag).[LOC] shares (DM, monitored channels, and room-server posts) and pin those senders on the map / in Nearby. Off by default. Auto share ON / OFF Periodically broadcast your own position to the target below while you move. To channel or contact Enter opens the Messages recipient chooser to pick the target channel or DM contact. Move 50 / 100 / 250 / 500 m Movement gate \u2014 only send after you've moved at least this far since the last share. Min gap 30 s / 1 / 2 / 5 min Minimum time between sends, so fast movement can't flood the channel. Heartbeat OFF / 5 / 15 min Optional keep-alive: re-send even while stationary, so the other end knows you're still there. [LOC] messages update a small live table (up to 16 nodes, entries expire ~20 min after the last update). DM shares are keyed by the sender's public key (reliable); channel and room-server shares are keyed by name (best-effort, since channel names are unsigned and a room post only carries a short sender prefix). Tracked nodes appear on the Trail Map as a filled diamond with the first two characters of their name, and in Nearby Nodes with their live distance/bearing.[LOC] message and hands it to the Messages screen to pick a recipient. There's also a shortcut from the home Map page: Hold Enter sends an immediate position update to your Live Share target while auto-sharing is on (toast Position shared), or opens the recipient picker if it isn't \u2014 so you never broadcast to a default channel by accident.none instead of leaving it pointed at something that's gone.@ prefix, plus a compact age tag (e.g. @Bob (5m)) when the position is last-advertised rather than a live share. Shows none until set. Radius 50 / 100 / 250 / 500 m / 1 km Geofence size. Mode Arrive / Leave / Both Which crossing fires the alert \u2014 entering the radius, leaving it, or both. Beeper ON / OFF Optional homing tone \u2014 shown only in Arrive / Both modes (see below). Arrived / Left for a waypoint, Near / Away for a person. The edge has a little hysteresis so a fix hovering right on the boundary doesn't chatter, and the first reading after arming only seeds the in/out state \u2014 it won't fire spuriously just because you armed it while already inside.[LOC] share wins, and with no current share it falls back to the contact's last-advertised GPS position \u2014 so a rarely-updating but stationary node (a repeater, or someone who shared a fix once) still works as a target. You can arm it ahead of time \u2014 choosing a favourite locks onto their identity (pubkey), and the alert starts working as soon as a position is known. Live following requires a DM share (a channel share carries no stable identity to lock onto); the last-advertised fallback works for any contact regardless.Target set toast.145\u00b0 SE).--). Gross GPS jumps are rejected so a single bad fix can't swing the heading. The heading source runs whenever there's a GPS fix \u2014 recording a trail is not required.
hi,hello there,yo) \u2014 matching any one of them is enough; spaces around each phrase are trimmed, so hi, hello there and hi,hello there behave the same. The bot has three independent targets \u2014 DM, a monitored Channel, and a monitored Room \u2014 each with its own trigger/reply pair.! queries or stay quiet, same as Enable.(none) if none exist yet), regardless of Enable. Enter opens the full channel picker (the same one Live Share's To row uses). Commands ON / OFF \u2014 Enter toggles. Answer ! query commands on the monitored channel, independent of the other two tabs' Commands settings. Trigger Independent trigger for the monitored channel. * means reply to every channel message \u2014 bounded by the per-channel cooldown, but use sparingly on a busy channel. Reply Reply text for channel messages; supports the same placeholders as Direct's Reply."},{"location":"solo_features/tools_screen/tools_screen/#room-tab","title":"Room tab","text":"Setting Description Enable ON / OFF \u2014 Enter toggles. Independent of which room is picked below. Room Which room server the bot posts to \u2014 always shows the last-picked room (or (none) if you have none yet), regardless of Enable. Enter opens the full room picker. Picking a room you've never logged into prompts for its password right there \u2014 the bot can't post to a room it has no working login for, so this is the moment to set one up. Commands ON / OFF \u2014 Enter toggles. Answer ! query commands on the monitored room, independent of the other two tabs' Commands settings. Trigger Independent trigger for the monitored room. * means reply to every post in the room. Reply Reply text for room posts; supports the same placeholders as Direct's Reply."},{"location":"solo_features/tools_screen/tools_screen/#direct-tab","title":"Direct tab","text":"Setting Description Enable ON / OFF \u2014 Enter toggles. Enables DM listening. DM allow All / Fav \u2014 Enter toggles. Who the DM bot (trigger-reply and commands) responds to. All (default): any DM sender. Fav: only contacts you've starred (the same star Settings \u203a Contacts filters on) \u2014 use this to keep a public bot from being spammed by strangers while it still answers people you trust. Commands ON / OFF \u2014 Enter toggles. Answer ! query commands (see below) in DMs. Trigger Word or phrase that activates the DM reply (case-insensitive). A lone * means reply to every DM (away mode) and is shown as (any msg). Enter opens the keyboard. Reply Reply text for DMs; supports {time}, {loc}, {name}, {hops} and sensor placeholders. Enter opens the keyboard."},{"location":"solo_features/tools_screen/tools_screen/#other-tab","title":"Other tab","text":"Setting Description Quiet from Enter opens a stepper (value shown bracketed, e.g. [14:00]) \u2014 UP/DOWN steps the hour, Enter/Cancel confirms. Local-time window start; set from = to (OFF) to disable quiet hours entirely. Applies to all three targets' trigger-replies. Quiet to Same stepper; window end. *) in DMs while the channel or room reacts only to a specific keyword (or vice-versa).{name} (the triggering sender's name) and {hops} (direct or N hops) are only meaningful when replying to an actual incoming message, so \u2014 unlike {time}/{loc}/the sensor placeholders \u2014 they're offered only while editing a Reply field here, not on the general message-compose keyboard.! on that target is answered with live node data, independent of the trigger:!ping pong !batt battery voltage !loc GPS coordinates (or no GPS) !time local time HH:MM !temp temperature (or n/a if no sensor) !hops how many hops the command message took to reach the node (direct if heard directly) !status combined battery / location / time !help list of available commands !batt !time !hops is answered with a single 4.10V | 14:30 | 3 hops reply (one transmission). A message with no recognised command falls through to the trigger bot.!ping in DMs but stay quiet on a busy public channel. Channel and room replies are broadcast/posted to everyone there, so unlike DM commands they respect quiet hours and use their own shared cooldown. DM commands use the per-contact throttle and the DM allow scope above.!buzz [seconds] Sounds the buzzer as a find-me signal \u2014 default 5s, capped at 30s. Sounds even if the buzzer is muted in Settings (that's the point of a find-me signal). !gps on / !gps off Enables/disables GPS, same effect as the Home page's GPS toggle. !gps fix [seconds] Single-shot location: turns GPS on if it wasn't already, waits for a stabilised fix (HDOP \u2264 2.0, or \u22658 satellites on GPS hardware that doesn't report HDOP, averaged over 10s), sends the position, then restores GPS to whatever state it was in before. Replies in two parts \u2014 an immediate GPS: acquiring fix... ack, then the position (or GPS: no fix (timeout) / a partial fix) as a follow-up message up to seconds later (default 90s, clamped to 15-300s) \u2014 raise it under poor sky view, where 90s isn't always enough to reach the HDOP/satellite bar. Only one !gps fix can be in flight at a time; a second one gets GPS: fix already pending. !advert Sends an advert immediately, same as the Home page's manual advert action. !batt !gps on answers with 4.10V | GPS: on in a single reply. With Actions OFF for a target, !buzz/!gps/!gps fix/!advert are silently ignored (no reply, no effect) exactly like any other unrecognised command, and !help's reply doesn't mention them.!gpio1..!gpio4.d hh:mm:ss) Total rx/tx All received / transmitted packets, summed across the categories below Msg Text and group-text packets, rx/tx Advert Advert packets, rx/tx Ack/Path Ack, path-return and trace packets, rx/tx Other Everything else (requests, responses, control, raw, \u2026), rx/tx Forwarded Packets this node actually re-transmitted as a repeater (reflects overhear suppression, if on) Heap free Free / total heap Stack free Current task's minimum-ever stack headroom Noise floor Live radio noise floor (dBm) RSSI/SNR Signal strength / signal-to-noise of the last received packet Pool free Free entries in the packet pool Queue Packets waiting in the outbound queue Errors Radio error flags since boot/reset \u2014 OK, or tokens F (queue full), C (CAD timeout), R (RX-start timeout) RXPS wd s/h RX duty-cycle watchdog recovery count, soft/hard \u2014 how many times the background watchdog has re-armed (soft) or fully reset (hard) a stuck duty-cycle sequencer. Stays 0/0 unless Settings \u203a Radio \u203a Pwr save is on and something actually went wrong. GPIO1: Input, GPIO1: Output, \u2026).
Input (High) / Input (Low), refreshed continuously.1650mV. Has no State row \u2014 it's read-only.Output on the Mode row; the actual ON/OFF value lives on the State row below it.!gpio1..!gpio4 commands (see Actions under Remote Bot below) \u2014 both paths read/write the same underlying state, so the Tools screen and the bot never disagree. A bare !gpio1 reports the pin's current mode and reading (gpio1: out on, gpio1: in on, or gpio1: 1650mV in Analog mode); !gpio1 on/!gpio1 off only takes effect if that pin is currently set to Output here (otherwise the bot replies \"not output\", including when the pin is in Analog mode).-- in Settings while the repeater is on.
Tab Rows System Name, Owner info, Admin password Radio Frequency, Bandwidth, Spreading factor, Coding rate, TX power Routing Repeat, Advert interval, Flood advert interval, Max hops Actions Send advert, Send zero-hop advert, Sync clock, Reboot, Custom command... password CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with admin-level permission unlocks the next step \u2014 anything less shows \"Not admin on this node\".radio value together \u2014 editing any one of them still only overwrites that one, the other three round-trip unchanged. Enter sends the change; Cancel discards it and returns to the row list without sending anything. - Admin password has no fetch (there's no way to read a password back) \u2014 it opens straight to a blank keyboard. - Actions (Reboot, Send advert, \u2026) send immediately, no editing step. - Custom command... (last row of Actions) opens the same free-text entry for anything not covered above \u2014 up to 160 characters, see the linked reference for the full grammar. The keyboard's {} key doubles as command completion here: it lists commands matching whatever's typed since the last space (narrowing as you type), and picking one completes that word instead of just inserting after it. 4. Read the reply \u2014 the text reply opens in a scrollable view (UP/DOWN to scroll, Cancel/Enter to go back to the category tabs).reboot, erase, a new admin password, and others. That's the same capability the phone app's repeater-admin feature already exposes, not a new risk, but double-check the value and the target before sending.
Four direction contacts plus a separate Back button. Each contact simply shorts -its pin to ground — the firmware enables the internal pull-ups, so no external -resistors are needed.
+Four direction contacts plus a fifth "press" contact. Each contact simply +shorts its pin to ground — the firmware enables the internal pull-ups, so no +external resistors are needed.