package meshtastic import ( "crypto/rand" "encoding/binary" "fmt" "math" "math/big" "strconv" "strings" ) type NodeID uint32 const ( // BroadcastNodeID is the special NodeID used when broadcasting a packet to a channel. BroadcastNodeID NodeID = math.MaxUint32 // BroadcastNodeIDNoLora is a special broadcast address that excludes LoRa transmission. // Used for MQTT-only broadcasts. This is ^all with the NO_LORA flag (0x40) cleared. BroadcastNodeIDNoLora NodeID = math.MaxUint32 ^ 0x40 // ReservedNodeIDThreshold is the threshold at which NodeIDs are considered reserved. Random NodeIDs should not // be generated below this threshold. // Source: https://github.com/meshtastic/firmware/blob/d1ea58975755e146457a8345065e4ca357555275/src/mesh/NodeDB.cpp#L461 reservedNodeIDThreshold NodeID = 4 ) // ParseNodeID parses a NodeID from various string formats: // - "!abcd1234" (Meshtastic format with ! prefix) // - "0xabcd1234" (hex with 0x prefix) // - "abcd1234" (plain hex) // - "12345678" (decimal) func ParseNodeID(s string) (NodeID, error) { s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("empty node ID string") } // Handle !prefix format if strings.HasPrefix(s, "!") { s = s[1:] n, err := strconv.ParseUint(s, 16, 32) if err != nil { return 0, fmt.Errorf("invalid node ID %q: %w", s, err) } return NodeID(n), nil } // Handle 0x prefix if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") { n, err := strconv.ParseUint(s[2:], 16, 32) if err != nil { return 0, fmt.Errorf("invalid node ID %q: %w", s, err) } return NodeID(n), nil } // Try hex first if it looks like hex (contains a-f) sLower := strings.ToLower(s) if strings.ContainsAny(sLower, "abcdef") { n, err := strconv.ParseUint(s, 16, 32) if err != nil { return 0, fmt.Errorf("invalid node ID %q: %w", s, err) } return NodeID(n), nil } // Try decimal n, err := strconv.ParseUint(s, 10, 32) if err != nil { // Fall back to hex for 8-char strings if len(s) == 8 { n, err = strconv.ParseUint(s, 16, 32) if err != nil { return 0, fmt.Errorf("invalid node ID %q: %w", s, err) } return NodeID(n), nil } return 0, fmt.Errorf("invalid node ID %q: %w", s, err) } return NodeID(n), nil } // Uint32 returns the underlying uint32 value of the NodeID. func (n NodeID) Uint32() uint32 { return uint32(n) } // String converts the NodeID to a hex formatted string. // This is typically how NodeIDs are displayed in Meshtastic UIs. func (n NodeID) String() string { return fmt.Sprintf("!%08x", uint32(n)) } // Bytes converts the NodeID to a byte slice func (n NodeID) Bytes() []byte { bytes := make([]byte, 4) // uint32 is 4 bytes binary.BigEndian.PutUint32(bytes, n.Uint32()) return bytes } // DefaultLongName returns the default long node name based on the NodeID. // Source: https://github.com/meshtastic/firmware/blob/d1ea58975755e146457a8345065e4ca357555275/src/mesh/NodeDB.cpp#L382 func (n NodeID) DefaultLongName() string { bytes := make([]byte, 4) // uint32 is 4 bytes binary.BigEndian.PutUint32(bytes, n.Uint32()) return fmt.Sprintf("Meshtastic %04x", bytes[2:]) } // DefaultShortName returns the default short node name based on the NodeID. // Last two bytes of the NodeID represented in hex. // Source: https://github.com/meshtastic/firmware/blob/d1ea58975755e146457a8345065e4ca357555275/src/mesh/NodeDB.cpp#L382 func (n NodeID) DefaultShortName() string { bytes := make([]byte, 4) // uint32 is 4 bytes binary.BigEndian.PutUint32(bytes, n.Uint32()) return fmt.Sprintf("%04x", bytes[2:]) } // UnmarshalText implements encoding.TextUnmarshaler for use with config parsers like Viper. func (n *NodeID) UnmarshalText(text []byte) error { parsed, err := ParseNodeID(string(text)) if err != nil { return err } *n = parsed return nil } // MarshalText implements encoding.TextMarshaler. func (n NodeID) MarshalText() ([]byte, error) { return []byte(n.String()), nil } // IsReservedID returns true if this is a reserved or broadcast NodeID. func (n NodeID) IsReservedID() bool { return n < reservedNodeIDThreshold || n >= BroadcastNodeIDNoLora } // IsBroadcast returns true if this is any form of broadcast address. func (n NodeID) IsBroadcast() bool { return n == BroadcastNodeID || n == BroadcastNodeIDNoLora } // ToMacAddress returns a MAC address string derived from the NodeID. // This creates a locally administered unicast MAC address. func (n NodeID) ToMacAddress() string { bytes := n.Bytes() // Use 0x02 as the first octet (locally administered, unicast) // Then 0x00 as padding, followed by the 4 bytes of the NodeID return fmt.Sprintf("02:00:%02x:%02x:%02x:%02x", bytes[0], bytes[1], bytes[2], bytes[3]) } // RandomNodeID returns a randomised NodeID. // It's recommended to call this the first time a node is started and persist the result. // // Hardware meshtastic nodes first try a NodeID of the last four bytes of the BLE MAC address. If that ID is already in // use or invalid, a random NodeID is generated. // Source: https://github.com/meshtastic/firmware/blob/d1ea58975755e146457a8345065e4ca357555275/src/mesh/NodeDB.cpp#L466 func RandomNodeID() (NodeID, error) { // Generates a random uint32 between reservedNodeIDThreshold and math.MaxUint32 randomInt, err := rand.Int( rand.Reader, big.NewInt( int64(math.MaxUint32-reservedNodeIDThreshold.Uint32()), ), ) if err != nil { return NodeID(0), fmt.Errorf("reading entropy: %w", err) } r := uint32(randomInt.Uint64()) + reservedNodeIDThreshold.Uint32() return NodeID(r), nil }