diff --git a/.gitignore b/.gitignore index a0ad5f6ea..2690991b7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ compile_commands.json .venv/ venv/ platformio.local.ini +__pycache__/ +*.pyc diff --git a/README.md b/README.md index f8b9e5e08..6b5f73748 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,52 @@ The repeater and room server firmwares can be setup via USB in the web config to They can also be managed via LoRa in the mobile app by using the Remote Management feature. +### Local USB Configurator + +This repo also includes a local serial configurator for MeshCore repeater and room-server firmware: + +```bash +python3 tools/meshcore_configurator.py +``` + +It can configure radio settings, TX power, node name, passwords, GPS options, raw CLI commands, and ESP32 firmware updates without using Web Serial in a browser. + +Examples: + +```bash +python3 tools/meshcore_configurator.py --list-ports +python3 tools/meshcore_configurator.py --port /dev/ttyUSB0 --set radio 910.525,62.5,7,5 +python3 tools/meshcore_configurator.py --port /dev/ttyUSB0 --set tx 22 +python3 tools/meshcore_configurator.py --port /dev/ttyUSB0 --command "gps on" +python3 tools/meshcore_configurator.py --port /dev/ttyUSB0 --flash .pio/build/hammer_sx1262_repeater/firmware-merged.bin +``` + +Dependencies: + +```bash +python3 -m pip install pyserial esptool +``` + +On Linux, the user may need serial-port permissions: + +```bash +sudo usermod -aG dialout $USER +``` + +Then log out and back in. + +To build a portable Windows executable: + +```bat +tools\build_windows_exe.bat +``` + +The resulting executable is created at: + +```text +dist\meshcore-configurator.exe +``` + ## 🛠 Hardware Compatibility MeshCore is designed for devices listed in the [MeshCore Flasher](https://meshcore.io/flasher) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 876dc9c33..4187a1876 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -44,6 +44,10 @@ static uint32_t _atoi(const char* sp) { #elif defined(BLE_PIN_CODE) #include SerialBLEInterface serial_interface; + #elif defined(HAS_ETHERNET) + #include + extern SerialEthernetInterface eth_interface; + SerialEthernetInterface& serial_interface = eth_interface; #elif defined(SERIAL_RX) #include ArduinoSerialInterface serial_interface; @@ -199,6 +203,8 @@ void setup() { serial_interface.begin(TCP_PORT); #elif defined(BLE_PIN_CODE) serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin()); +#elif defined(HAS_ETHERNET) + board.setInhibitSleep(true); // prevent sleep while Ethernet is active #elif defined(SERIAL_RX) companion_serial.setPins(SERIAL_RX, SERIAL_TX); companion_serial.begin(115200); diff --git a/src/helpers/esp32/SerialEthernetInterface.cpp b/src/helpers/esp32/SerialEthernetInterface.cpp new file mode 100644 index 000000000..e824eee31 --- /dev/null +++ b/src/helpers/esp32/SerialEthernetInterface.cpp @@ -0,0 +1,184 @@ +// Ethernet support for Broken Circuit Ranch POE Ethernet +// http://www.brokencircuitranch.com +// Kent Andersen + +#ifdef HAS_ETHERNET + +#include "SerialEthernetInterface.h" +#include +#include + +bool SerialEthernetInterface::begin(SPIClass& spi, int port, int cs_pin, int rst_pin, uint8_t mac[6]) { + + // Set CS pin before anything else + Ethernet.setCsPin(cs_pin); + + // Reset W5500 if reset pin provided + if (rst_pin >= 0) { + Ethernet.setRstPin(rst_pin); + Ethernet.hardreset(); + delay(200); + } + + // Try DHCP + ETH_DEBUG_PRINTLN("Starting DHCP..."); + int result = Ethernet.begin(mac); + if (result == 0) { + ETH_DEBUG_PRINTLN("DHCP failed, using static IP 192.168.1.200"); + IPAddress staticIP(192, 168, 1, 200); + IPAddress subnet(255, 255, 255, 0); + IPAddress gateway(192, 168, 1, 1); + IPAddress dns(8, 8, 8, 8); + Ethernet.begin(mac, staticIP, subnet, gateway, dns); + } + + ETH_DEBUG_PRINTLN("Ethernet IP: %s", Ethernet.localIP().toString().c_str()); + + // Start TCP server + server = new ConcreteEthernetServer(port); + server->begin(port); + _ethInitialized = true; + + ETH_DEBUG_PRINTLN("TCP server started on port %d", port); + return true; +} + +void SerialEthernetInterface::enable() { + if (_isEnabled) return; + _isEnabled = true; + clearBuffers(); +} + +void SerialEthernetInterface::disable() { + _isEnabled = false; +} + +size_t SerialEthernetInterface::writeFrame(const uint8_t src[], size_t len) { + if (len > MAX_FRAME_SIZE) { + ETH_DEBUG_PRINTLN("writeFrame(), frame too big, len=%d", len); + return 0; + } + + if (deviceConnected && len > 0) { + if (send_queue_len >= ETH_FRAME_QUEUE_SIZE) { + ETH_DEBUG_PRINTLN("writeFrame(), send_queue is full!"); + return 0; + } + send_queue[send_queue_len].len = len; + memcpy(send_queue[send_queue_len].buf, src, len); + send_queue_len++; + return len; + } + return 0; +} + +bool SerialEthernetInterface::isWriteBusy() const { + return false; +} + +bool SerialEthernetInterface::hasReceivedFrameHeader() { + return received_frame_header.type != 0 && received_frame_header.length != 0; +} + +void SerialEthernetInterface::resetReceivedFrameHeader() { + received_frame_header.type = 0; + received_frame_header.length = 0; +} + +size_t SerialEthernetInterface::checkRecvFrame(uint8_t dest[]) { + + if (!_ethInitialized || !server) return 0; + + // Maintain DHCP lease + Ethernet.maintain(); + + // Check for new client + EthernetClient newClient = server->available(); + if (newClient) { + deviceConnected = false; + client.stop(); + client = newClient; + resetReceivedFrameHeader(); + } + + if (client.connected()) { + if (!deviceConnected) { + ETH_DEBUG_PRINTLN("Client connected"); + deviceConnected = true; + } + } else { + if (deviceConnected) { + deviceConnected = false; + ETH_DEBUG_PRINTLN("Client disconnected"); + } + } + + if (deviceConnected) { + if (send_queue_len > 0) { + _last_write = millis(); + int len = send_queue[0].len; + + uint8_t pkt[3 + len]; + pkt[0] = '>'; + pkt[1] = (len & 0xFF); + pkt[2] = (len >> 8); + memcpy(&pkt[3], send_queue[0].buf, len); + client.write(pkt, 3 + len); + + send_queue_len--; + for (int i = 0; i < send_queue_len; i++) { + send_queue[i] = send_queue[i + 1]; + } + } else { + if (!hasReceivedFrameHeader()) { + if (client.available() >= 3) { + client.readBytes(&received_frame_header.type, 1); + client.readBytes((uint8_t*)&received_frame_header.length, 2); + } + } + + if (hasReceivedFrameHeader()) { + int available = client.available(); + int frame_type = received_frame_header.type; + int frame_length = received_frame_header.length; + + if (frame_length > available) { + ETH_DEBUG_PRINTLN("Waiting for %d more bytes", frame_length - available); + return 0; + } + + if (frame_length > MAX_FRAME_SIZE) { + ETH_DEBUG_PRINTLN("Skipping oversized frame: %d bytes", frame_length); + while (frame_length > 0) { + uint8_t skip[1]; + frame_length -= client.read(skip, 1); + } + resetReceivedFrameHeader(); + return 0; + } + + if (frame_type != '<') { + ETH_DEBUG_PRINTLN("Skipping unexpected frame type: 0x%x", frame_type); + while (frame_length > 0) { + uint8_t skip[1]; + frame_length -= client.read(skip, 1); + } + resetReceivedFrameHeader(); + return 0; + } + + client.readBytes(dest, frame_length); + resetReceivedFrameHeader(); + return frame_length; + } + } + } + + return 0; +} + +bool SerialEthernetInterface::isConnected() const { + return deviceConnected; +} + +#endif diff --git a/src/helpers/esp32/SerialEthernetInterface.h b/src/helpers/esp32/SerialEthernetInterface.h new file mode 100644 index 000000000..eabf5e1cd --- /dev/null +++ b/src/helpers/esp32/SerialEthernetInterface.h @@ -0,0 +1,92 @@ +#pragma once +// Ethernet support for Broken Circuit Ranch POE Ethernet +// http://www.brokencircuitranch.com +// Kent Andersen + +#ifdef HAS_ETHERNET + +#include "../BaseSerialInterface.h" +#include +#include + +// Workaround: ESP32 Arduino core Server.h declares begin(uint16_t) as pure virtual +// but Ethernet3 only implements begin() with no args. +// This subclass satisfies the compiler by implementing the missing override. +class ConcreteEthernetServer : public EthernetServer { +public: + ConcreteEthernetServer(uint16_t port) : EthernetServer(port) {} + void begin(uint16_t port) override { EthernetServer::begin(); } +}; + +class SerialEthernetInterface : public BaseSerialInterface { + bool deviceConnected; + bool _isEnabled; + bool _ethInitialized; + unsigned long _last_write; + + ConcreteEthernetServer* server; + EthernetClient client; + + struct FrameHeader { + uint8_t type; + uint16_t length; + }; + + struct Frame { + uint8_t len; + uint8_t buf[MAX_FRAME_SIZE]; + }; + + FrameHeader received_frame_header; + + #define ETH_FRAME_QUEUE_SIZE 4 + int recv_queue_len; + Frame recv_queue[ETH_FRAME_QUEUE_SIZE]; + int send_queue_len; + Frame send_queue[ETH_FRAME_QUEUE_SIZE]; + + void clearBuffers() { recv_queue_len = 0; send_queue_len = 0; } + +public: + SerialEthernetInterface() : server(nullptr), client(EthernetClient()) { + deviceConnected = false; + _isEnabled = false; + _ethInitialized = false; + _last_write = 0; + send_queue_len = recv_queue_len = 0; + received_frame_header.type = 0; + received_frame_header.length = 0; + } + + // spi: shared SPI bus (vspi from target.cpp) + // port: TCP port to listen on + // cs_pin: W5500 chip select pin + // rst_pin: W5500 reset pin (-1 if not used) + // mac: 6-byte MAC address + bool begin(SPIClass& spi, int port, int cs_pin, int rst_pin, uint8_t mac[6]); + + void enable() override; + void disable() override; + bool isEnabled() const override { return _isEnabled; } + + bool isConnected() const override; + bool isWriteBusy() const override; + + size_t writeFrame(const uint8_t src[], size_t len) override; + size_t checkRecvFrame(uint8_t dest[]) override; + + bool hasReceivedFrameHeader(); + void resetReceivedFrameHeader(); + bool isEthernetInitialized() const { return _ethInitialized; } +}; + +#if ETH_DEBUG_LOGGING && ARDUINO + #include + #define ETH_DEBUG_PRINT(F, ...) Serial.printf("ETH: " F, ##__VA_ARGS__) + #define ETH_DEBUG_PRINTLN(F, ...) Serial.printf("ETH: " F "\n", ##__VA_ARGS__) +#else + #define ETH_DEBUG_PRINT(...) {} + #define ETH_DEBUG_PRINTLN(...) {} +#endif + +#endif diff --git a/tools/build_windows_exe.bat b/tools/build_windows_exe.bat new file mode 100644 index 000000000..08a511565 --- /dev/null +++ b/tools/build_windows_exe.bat @@ -0,0 +1,11 @@ +@echo off +setlocal + +cd /d "%~dp0\.." + +python -m pip install --upgrade pip +python -m pip install pyinstaller pyserial esptool +python -m PyInstaller --onefile --console --name meshcore-configurator tools\meshcore_configurator.py + +echo. +echo Built: dist\meshcore-configurator.exe diff --git a/tools/meshcore_configurator.py b/tools/meshcore_configurator.py new file mode 100644 index 000000000..c89860ec8 --- /dev/null +++ b/tools/meshcore_configurator.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +""" +USB serial configurator for MeshCore repeater and room-server firmware. + +This avoids the browser Web Serial configurator and talks directly to the +firmware CLI at 115200 baud. + +Examples: + python3 tools/meshcore_configurator.py + python3 tools/meshcore_configurator.py --port /dev/ttyUSB0 --command "get radio" + python3 tools/meshcore_configurator.py --port /dev/ttyUSB0 --us-preset --reboot +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Iterable + +try: + import serial + from serial.tools import list_ports +except ImportError: + serial = None + list_ports = None + +try: + import esptool +except ImportError: + esptool = None + + +DEFAULT_BAUD = 115200 +DEFAULT_TIMEOUT = 1.2 +DEFAULT_FLASH_BAUD = 460800 +DEFAULT_FLASH_ADDRESS = "0x0" +US_CANADA_RECOMMENDED = "910.525,62.5,7,5" + + +@dataclass(frozen=True) +class Setting: + key: str + label: str + kind: str = "text" + choices: tuple[str, ...] = () + help: str = "" + reboot: bool = False + + @property + def get_command(self) -> str: + return f"get {self.key}" + + def set_command(self, value: str) -> str: + return f"set {self.key} {value}" + + +SETTINGS: tuple[Setting, ...] = ( + Setting("radio", "Radio params: freq,bw,sf,cr", help="Example: 910.525,62.5,7,5", reboot=True), + Setting("freq", "Frequency MHz", help="Example: 910.525", reboot=True), + Setting("tx", "LoRa chip TX power dBm", kind="int", help="Hammer default build flag is 22 dBm"), + Setting("name", "Node name"), + Setting("lat", "Latitude", kind="float"), + Setting("lon", "Longitude", kind="float"), + Setting("guest.password", "Room guest password"), + Setting("owner.info", "Owner info", help="Use | where you want a newline"), + Setting("repeat", "Repeat packets", kind="choice", choices=("on", "off")), + Setting("af", "Airtime factor", kind="float", help="Higher means longer silent period after TX"), + Setting("txdelay", "Flood retransmit delay factor", kind="float"), + Setting("direct.txdelay", "Direct retransmit delay factor", kind="float"), + Setting("rxdelay", "Receive processing delay", kind="float"), + Setting("flood.max", "Max flood hops", kind="int", help="0-64"), + Setting("path.hash.mode", "Path hash mode", kind="choice", choices=("0", "1", "2")), + Setting("loop.detect", "Loop detection", kind="choice", choices=("off", "minimal", "moderate", "strict")), + Setting("multi.acks", "Multi-acks", kind="choice", choices=("0", "1")), + Setting("flood.advert.interval", "Flood advert interval hours", kind="int", help="0 disables, otherwise 3-168"), + Setting("advert.interval", "Zero-hop advert interval minutes", kind="int", help="0 disables, otherwise 60-240"), + Setting("int.thresh", "Interference threshold", kind="int"), + Setting("agc.reset.interval", "AGC reset interval seconds", kind="int", help="Rounded down to multiple of 4"), + Setting("allow.read.only", "Room allow read-only", kind="choice", choices=("on", "off")), + Setting("radio.rxgain", "SX126x boosted RX gain", kind="choice", choices=("on", "off")), + Setting("adc.multiplier", "Battery ADC multiplier", kind="float", help="May be unsupported on Hammer"), + Setting("bridge.enabled", "Bridge enabled", kind="choice", choices=("on", "off")), + Setting("bridge.delay", "Bridge delay ms", kind="int"), + Setting("bridge.source", "Bridge source", kind="choice", choices=("rx", "tx")), + Setting("bridge.channel", "ESPNow bridge channel", kind="int", help="1-14"), + Setting("bridge.secret", "ESPNow bridge secret"), +) + +INFO_COMMANDS: tuple[tuple[str, str], ...] = ( + ("ver", "Firmware version"), + ("board", "Board name"), + ("get role", "Firmware role"), + ("get public.key", "Public key"), + ("get bridge.type", "Bridge type"), + ("powersaving", "Power saving"), + ("clock", "Clock"), + ("stats-core", "Core stats"), + ("stats-radio", "Radio stats"), + ("stats-packets", "Packet stats"), + ("neighbors", "Neighbors"), + ("get acl", "ACL"), +) + + +class MeshCoreCLI: + def __init__(self, port: str, baud: int = DEFAULT_BAUD, timeout: float = DEFAULT_TIMEOUT): + if serial is None: + raise RuntimeError("pyserial is not installed. Install it with: python3 -m pip install pyserial") + self.port = port + self.baud = baud + self.timeout = timeout + self.ser = serial.Serial(port, baudrate=baud, timeout=timeout, write_timeout=timeout) + time.sleep(0.2) + self.drain() + + def close(self) -> None: + self.ser.close() + + def drain(self) -> str: + time.sleep(0.05) + data = self.ser.read(self.ser.in_waiting or 1) + chunks = [data] if data else [] + while self.ser.in_waiting: + chunks.append(self.ser.read(self.ser.in_waiting)) + time.sleep(0.02) + return b"".join(chunks).decode(errors="replace") + + def command(self, command: str, wait: float = 0.25) -> str: + self.drain() + self.ser.write(command.encode("utf-8") + b"\r") + self.ser.flush() + time.sleep(wait) + chunks = [] + deadline = time.monotonic() + self.timeout + while time.monotonic() < deadline: + waiting = self.ser.in_waiting + if waiting: + chunks.append(self.ser.read(waiting)) + deadline = time.monotonic() + 0.15 + else: + time.sleep(0.03) + text = b"".join(chunks).decode(errors="replace") + return clean_reply(command, text) + + def get(self, key: str) -> str: + return self.command(f"get {key}") + + def set(self, key: str, value: str) -> str: + return self.command(f"set {key} {value}") + + def password(self, value: str) -> str: + return self.command(f"password {value}") + + def reboot(self) -> str: + return self.command("reboot", wait=0.1) + + def apply_us_canada_recommended(self) -> str: + return self.set("radio", US_CANADA_RECOMMENDED) + + +def clean_reply(command: str, text: str) -> str: + lines = [] + for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw.strip() + if not line: + continue + if line == command or line.endswith(command): + continue + if line.startswith("-> "): + line = line[3:].strip() + if line.startswith(" -> "): + line = line[5:].strip() + lines.append(line) + return "\n".join(lines).strip() + + +def require_pyserial() -> None: + if serial is None: + print("Missing dependency: pyserial") + print("Install it with:") + print(" python3 -m pip install pyserial") + print() + print("On Ubuntu you may also need serial permissions:") + print(" sudo usermod -aG dialout $USER") + print("Then log out and back in.") + raise SystemExit(2) + + +def require_esptool() -> None: + if esptool is None: + print("Missing dependency: esptool") + print("Install it with:") + print(" python3 -m pip install esptool") + raise SystemExit(2) + + +def available_ports() -> list[str]: + require_pyserial() + return [p.device for p in list_ports.comports()] + + +def choose_port(explicit_port: str | None) -> str: + if explicit_port: + return explicit_port + + ports = available_ports() + if not ports: + print("No serial ports found.") + print("Plug in the Hammer and check: ls -l /dev/ttyUSB* /dev/ttyACM*") + print("If the port exists but this fails, add yourself to dialout:") + print(" sudo usermod -aG dialout $USER") + raise SystemExit(1) + if len(ports) == 1: + return ports[0] + + print("Serial ports:") + for index, port in enumerate(ports, 1): + print(f" {index}. {port}") + choice = input("Select port: ").strip() + if choice.isdigit() and 1 <= int(choice) <= len(ports): + return ports[int(choice) - 1] + return choice + + +def flash_esp32_firmware(port: str, firmware: str, baud: int = DEFAULT_FLASH_BAUD, + address: str = DEFAULT_FLASH_ADDRESS) -> None: + require_esptool() + firmware_path = Path(firmware).expanduser() + if not firmware_path.exists(): + raise FileNotFoundError(firmware) + + args = [ + "--chip", "esp32", + "--port", port, + "--baud", str(baud), + "--before", "default_reset", + "--after", "hard_reset", + "write_flash", + "-z", + address, + str(firmware_path), + ] + + print("Flashing ESP32 firmware...") + print(f"Port: {port}") + print(f"File: {firmware_path}") + print(f"Address: {address}") + print() + esptool.main(args) + + +def print_table(rows: Iterable[tuple[str, str]]) -> None: + rows = list(rows) + width = max((len(left) for left, _ in rows), default=0) + for left, right in rows: + print(f"{left:<{width}} {right}") + + +def show_overview(dev: MeshCoreCLI) -> None: + rows = [] + for command, label in INFO_COMMANDS[:7]: + rows.append((label + ":", dev.command(command) or "(no reply)")) + print_table(rows) + print() + rows = [] + for setting in SETTINGS: + reply = dev.command(setting.get_command) + if reply.startswith("> "): + reply = reply[2:] + rows.append((setting.label + ":", reply or "(unsupported/no reply)")) + print_table(rows) + + +def prompt_value(setting: Setting) -> str | None: + if setting.choices: + print(f"Choices: {', '.join(setting.choices)}") + if setting.help: + print(setting.help) + value = input(f"New value for {setting.label}: ").strip() + if not value: + return None + return value + + +def configure_setting(dev: MeshCoreCLI) -> None: + for index, setting in enumerate(SETTINGS, 1): + print(f"{index:2}. {setting.label} [{setting.key}]") + choice = input("Setting number or key: ").strip() + setting = None + if choice.isdigit() and 1 <= int(choice) <= len(SETTINGS): + setting = SETTINGS[int(choice) - 1] + else: + setting = next((item for item in SETTINGS if item.key == choice), None) + if setting is None: + print("Unknown setting.") + return + + current = dev.command(setting.get_command) + print(f"Current: {current or '(no reply)'}") + value = prompt_value(setting) + if value is None: + return + if setting.choices and value not in setting.choices: + print("Not one of the listed choices.") + return + reply = dev.command(setting.set_command(value)) + print(reply or "(no reply)") + if setting.reboot: + print("This setting needs a reboot before the radio uses it.") + + +def raw_command(dev: MeshCoreCLI) -> None: + print("Raw mode. Empty line exits.") + while True: + command = input("meshcore> ").strip() + if not command: + return + print(dev.command(command) or "(no reply)") + + +def region_menu(dev: MeshCoreCLI) -> None: + while True: + print() + print("Region menu") + print(" 1. List regions") + print(" 2. Show region") + print(" 3. Set home region") + print(" 4. Allow flood for region") + print(" 5. Deny flood for region") + print(" 6. Create region") + print(" 7. Remove region") + print(" 8. Save region changes") + print(" 9. Back") + choice = input("> ").strip() + if choice == "1": + print(dev.command("region") or dev.command("region list allowed") or "(no reply)") + elif choice == "2": + name = input("Region name or *: ").strip() + if name: + print(dev.command(f"region get {name}") or "(no reply)") + elif choice == "3": + name = input("Home region name: ").strip() + print(dev.command(f"region home {name}") if name else dev.command("region home")) + elif choice == "4": + name = input("Region name or *: ").strip() + if name: + print(dev.command(f"region allowf {name}") or "(no reply)") + elif choice == "5": + name = input("Region name or *: ").strip() + if name: + print(dev.command(f"region denyf {name}") or "(no reply)") + elif choice == "6": + name = input("New region name: ").strip() + parent = input("Parent region, blank for default: ").strip() + if name: + command = f"region put {name} {parent}".strip() + print(dev.command(command) or "(no reply)") + elif choice == "7": + name = input("Region name to remove: ").strip() + if name: + print(dev.command(f"region remove {name}") or "(no reply)") + elif choice == "8": + print(dev.command("region save") or "(no reply)") + elif choice == "9": + return + + +def gps_menu(dev: MeshCoreCLI) -> None: + while True: + print() + print("GPS menu") + print(" 1. GPS status") + print(" 2. GPS on") + print(" 3. GPS off") + print(" 4. Set node lat/lon from current GPS fix") + print(" 5. Sync clock from GPS") + print(" 6. Show GPS advert policy") + print(" 7. Advert saved lat/lon") + print(" 8. Advert live GPS location") + print(" 9. Hide location in adverts") + print(" 10. Send advert") + print(" 11. Back") + choice = input("> ").strip() + if choice == "1": + print(dev.command("gps") or "(no reply)") + elif choice == "2": + print(dev.command("gps on") or "(no reply)") + elif choice == "3": + print(dev.command("gps off") or "(no reply)") + elif choice == "4": + print(dev.command("gps setloc") or "(no reply)") + elif choice == "5": + print(dev.command("gps sync") or "(no reply)") + elif choice == "6": + print(dev.command("gps advert") or "(no reply)") + elif choice == "7": + print(dev.command("gps advert prefs") or "(no reply)") + elif choice == "8": + print(dev.command("gps advert share") or "(no reply)") + elif choice == "9": + print(dev.command("gps advert none") or "(no reply)") + elif choice == "10": + print(dev.command("advert") or "(no reply)") + elif choice == "11": + return + + +def firmware_update_menu(dev: MeshCoreCLI) -> None: + print() + print("Firmware update") + print("Use a merged ESP32 image when flashing at 0x0.") + print("For Hammer builds, PlatformIO creates:") + print(" .pio/build/hammer_sx1262_repeater/firmware-merged.bin") + print() + firmware = input("Firmware .bin path: ").strip() + if not firmware: + return + firmware = os.path.expanduser(firmware) + address = input(f"Flash address [{DEFAULT_FLASH_ADDRESS}]: ").strip() or DEFAULT_FLASH_ADDRESS + baud_text = input(f"Flash baud [{DEFAULT_FLASH_BAUD}]: ").strip() + flash_baud = int(baud_text) if baud_text else DEFAULT_FLASH_BAUD + + confirm = input(f"Flash {firmware} to {dev.port} at {address}? Type YES: ").strip() + if confirm != "YES": + print("Canceled.") + return + + dev.close() + try: + flash_esp32_firmware(dev.port, firmware, flash_baud, address) + finally: + try: + dev.ser.open() + time.sleep(0.5) + dev.drain() + except Exception: + pass + + +def interactive(dev: MeshCoreCLI) -> None: + actions: tuple[tuple[str, str, Callable[[MeshCoreCLI], None]], ...] = ( + ("1", "Show all known settings", show_overview), + ("2", "Change a setting", configure_setting), + ("3", "Apply US/Canada recommended radio preset", lambda d: print(d.apply_us_canada_recommended())), + ("4", "Change admin password", lambda d: print(d.password(input("New admin password: ").strip()))), + ("5", "Send advert", lambda d: print(d.command("advert"))), + ("6", "Send zero-hop advert", lambda d: print(d.command("advert.zerohop"))), + ("7", "Power saving on", lambda d: print(d.command("powersaving on"))), + ("8", "Power saving off", lambda d: print(d.command("powersaving off"))), + ("9", "GPS", gps_menu), + ("10", "Region management", region_menu), + ("11", "Raw command mode", raw_command), + ("12", "Firmware update", firmware_update_menu), + ("13", "Reboot", lambda d: print(d.reboot() or "Reboot command sent.")), + ) + while True: + print() + print(f"MeshCore configurator connected to {dev.port}") + for key, label, _ in actions: + print(f" {key}. {label}") + print(" q. Quit") + choice = input("> ").strip().lower() + if choice in ("q", "quit", "exit"): + return + action = next((item for item in actions if item[0] == choice), None) + if action is None: + print("Unknown choice.") + continue + action[2](dev) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Configure MeshCore firmware over USB serial.") + parser.add_argument("--port", help="Serial port, for example /dev/ttyUSB0") + parser.add_argument("--baud", type=int, default=DEFAULT_BAUD) + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + parser.add_argument("--command", action="append", help="Run one raw command and print its reply. Can be used more than once.") + parser.add_argument("--set", nargs=2, metavar=("KEY", "VALUE"), action="append", help="Set a MeshCore preference.") + parser.add_argument("--get", metavar="KEY", action="append", help="Get a MeshCore preference.") + parser.add_argument("--us-preset", action="store_true", help=f"Apply radio preset {US_CANADA_RECOMMENDED}.") + parser.add_argument("--reboot", action="store_true", help="Reboot after other commands.") + parser.add_argument("--flash", metavar="BIN", help="Flash a merged ESP32 firmware image, usually firmware-merged.bin.") + parser.add_argument("--flash-address", default=DEFAULT_FLASH_ADDRESS, help="Flash address for --flash. Use 0x0 for merged images.") + parser.add_argument("--flash-baud", type=int, default=DEFAULT_FLASH_BAUD, help="Baud rate for --flash.") + parser.add_argument("--list-ports", action="store_true", help="List serial ports and exit.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + require_pyserial() + + if args.list_ports: + ports = available_ports() + if ports: + print("\n".join(ports)) + return 0 + print("No serial ports found.") + return 1 + + port = choose_port(args.port) + if args.flash: + flash_esp32_firmware(port, args.flash, args.flash_baud, args.flash_address) + return 0 + + dev = MeshCoreCLI(port, args.baud, args.timeout) + try: + did_one_shot = False + for key in args.get or (): + did_one_shot = True + print(dev.get(key)) + for key, value in args.set or (): + did_one_shot = True + print(dev.set(key, value)) + if args.us_preset: + did_one_shot = True + print(dev.apply_us_canada_recommended()) + for command in args.command or (): + did_one_shot = True + print(dev.command(command)) + if args.reboot: + did_one_shot = True + print(dev.reboot() or "Reboot command sent.") + if not did_one_shot: + interactive(dev) + finally: + dev.close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/variants/hammer/platformio.ini b/variants/hammer/platformio.ini new file mode 100644 index 000000000..cf0bef886 --- /dev/null +++ b/variants/hammer/platformio.ini @@ -0,0 +1,185 @@ +; ============================================================ +; Broken Circuit Ranch Hammer Board Variant builds +; www.brokencircuitranch.com +; ============================================================ + +[hammer] +extends = esp32_base +board = esp32doit-devkit-v1 +build_flags = + ${esp32_base.build_flags} + -I variants/hammer + -D HAMMER_BOARD + -D HAS_SCREEN + -D ENV_INCLUDE_GPS=1 + -D PIN_GPS_RX=15 + -D PIN_GPS_TX=12 + -D GPS_BAUD_RATE=9600 + -D P_LORA_DIO_1=33 + -D P_LORA_NSS=18 + -D P_LORA_RESET=23 + -D P_LORA_BUSY=32 + -D P_LORA_SCLK=5 + -D P_LORA_MOSI=27 + -D P_LORA_MISO=19 + -D SX126X_TXEN=13 + -D SX126X_RXEN=14 + -D PIN_BOARD_SDA=21 + -D PIN_BOARD_SCL=22 + -D SX126X_DIO2_AS_RF_SWITCH=false + -D SX126X_DIO3_TCXO_VOLTAGE=1.8 + -D SX126X_CURRENT_LIMIT=140 + -D LORA_TX_POWER=22 +build_src_filter = ${esp32_base.build_src_filter} + +<../variants/hammer> +lib_deps = + ${esp32_base.lib_deps} + stevemarple/MicroNMEA @ ^2.0.6 + adafruit/Adafruit SSD1306 @ ^2.5.13 + + +; ============================================================ +; REPEATER +; ============================================================ +[env:hammer_sx1262_repeater] +extends = hammer +build_src_filter = ${hammer.build_src_filter} + +<../examples/simple_repeater/*.cpp> + + + + +build_flags = + ${hammer.build_flags} + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Hammer Repeater"' + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 +lib_deps = + ${hammer.lib_deps} + ${esp32_ota.lib_deps} + + +; ============================================================ +; ROOM SERVER +; ============================================================ +[env:hammer_sx1262_room_server] +extends = hammer +build_src_filter = ${hammer.build_src_filter} + +<../examples/simple_room_server/*.cpp> + + + + +build_flags = + ${hammer.build_flags} + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Hammer Room"' + -D ADMIN_PASSWORD='"password"' + -D ROOM_PASSWORD='"hello"' +lib_deps = + ${hammer.lib_deps} + ${esp32_ota.lib_deps} + + +; ============================================================ +; COMPANION RADIO - USB (serial connection to phone/PC) +; ============================================================ +[env:hammer_sx1262_companion_usb] +extends = hammer +build_src_filter = ${hammer.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +build_flags = + ${hammer.build_flags} + -I examples/companion_radio/ui-new + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=160 + -D MAX_GROUP_CHANNELS=8 +lib_deps = + ${hammer.lib_deps} + densaugeo/base64 @ ~1.4.0 + + +; ============================================================ +; COMPANION RADIO - BLE (Bluetooth connection to phone) +; ============================================================ +[env:hammer_sx1262_companion_ble] +extends = hammer +board_build.partitions = huge_app.csv +build_src_filter = ${hammer.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +build_flags = + ${hammer.build_flags} + -I examples/companion_radio/ui-new + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=160 + -D MAX_GROUP_CHANNELS=8 + -D BLE_PIN_CODE=123456 + -D OFFLINE_QUEUE_SIZE=256 +lib_deps = + ${hammer.lib_deps} + densaugeo/base64 @ ~1.4.0 + + +; ============================================================ +; COMPANION RADIO - ETHERNET (TCP connection over W5500) +; ============================================================ +[env:hammer_sx1262_companion_ethernet] +extends = hammer +board_build.partitions = huge_app.csv +build_src_filter = ${hammer.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-new/*.cpp> +build_flags = + ${hammer.build_flags} + -I examples/companion_radio/ui-new + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=SSD1306Display + -D MAX_CONTACTS=160 + -D MAX_GROUP_CHANNELS=8 + -D HAS_ETHERNET=1 + -D ETH_TCP_PORT=4403 +lib_deps = + ${hammer.lib_deps} + densaugeo/base64 @ ~1.4.0 + sstaub/Ethernet3 @ ^1.6.0 + + +; ============================================================ +; REPEATER + ESPNow BRIDGE +; ============================================================ +[env:hammer_sx1262_repeater_bridge_espnow] +extends = hammer +build_src_filter = ${hammer.build_src_filter} + + + + + + + +<../examples/simple_repeater/*.cpp> +build_flags = + ${hammer.build_flags} + -D RADIO_CLASS=CustomSX1262 + -D WRAPPER_CLASS=CustomSX1262Wrapper + -D DISPLAY_CLASS=SSD1306Display + -D ADVERT_NAME='"Hammer ESPNow Bridge"' + -D ADMIN_PASSWORD='"password"' + -D MAX_NEIGHBOURS=50 + -D WITH_ESPNOW_BRIDGE=1 +lib_deps = + ${hammer.lib_deps} + ${esp32_ota.lib_deps} diff --git a/variants/hammer/target.cpp b/variants/hammer/target.cpp new file mode 100644 index 000000000..9f9957dce --- /dev/null +++ b/variants/hammer/target.cpp @@ -0,0 +1,150 @@ +#include +#include "target.h" +#include "variant.h" + +#if ENV_INCLUDE_GPS + #include +#endif + +ESP32Board board; + +SPIClass vspi(VSPI); // LoRa and Eth share VSPI + +#if defined(P_LORA_SCLK) + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY, vspi); +#else + RADIO_CLASS radio = new Module(P_LORA_NSS, P_LORA_DIO_1, P_LORA_RESET, P_LORA_BUSY); +#endif + +WRAPPER_CLASS radio_driver(radio, board); + +ESP32RTCClock fallback_clock; +AutoDiscoverRTCClock rtc_clock(fallback_clock); + +#if ENV_INCLUDE_GPS + MicroNMEALocationProvider nmea = MicroNMEALocationProvider(Serial1, &rtc_clock); + HammerSensorManager sensors = HammerSensorManager(nmea); +#else + HammerSensorManager sensors; +#endif + +#ifdef DISPLAY_CLASS + DISPLAY_CLASS display; + MomentaryButton user_btn(BUTTON_PIN); +#endif + +#ifdef HAS_ETHERNET + SerialEthernetInterface eth_interface; +#endif + +bool radio_init() { + fallback_clock.begin(); + rtc_clock.begin(Wire); + +#if defined(P_LORA_SCLK) + vspi.begin(P_LORA_SCLK, P_LORA_MISO, P_LORA_MOSI, P_LORA_NSS); + if (!radio.std_init(&vspi)) return false; +#else + if (!radio.std_init()) return false; +#endif + +#ifdef HAS_ETHERNET + // Generate unique MAC from ESP32 chip ID + uint64_t chipid = ESP.getEfuseMac(); + uint8_t mac[6]; + mac[0] = 0xDE; mac[1] = 0xAD; + mac[2] = (chipid >> 32) & 0xFF; + mac[3] = (chipid >> 24) & 0xFF; + mac[4] = (chipid >> 16) & 0xFF; + mac[5] = (chipid >> 8) & 0xFF; + // vspi already initialized above — pass it to share the bus + if (eth_interface.begin(vspi, ETH_TCP_PORT, ETH_CS_PIN, ETH_RST_PIN, mac)) { + eth_interface.enable(); + } +#endif + + return true; +} + +uint32_t radio_get_rng_seed() { return radio.random(0x7FFFFFFF); } + +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr) { + radio.setFrequency(freq); + radio.setSpreadingFactor(sf); + radio.setBandwidth(bw); + radio.setCodingRate(cr); +} + +void radio_set_tx_power(uint8_t dbm) { radio.setOutputPower(dbm); } + +mesh::LocalIdentity radio_new_identity() { + RadioNoiseListener rng(radio); + return mesh::LocalIdentity(&rng); +} + +void HammerSensorManager::start_gps() { + if (!gps_active) { + _location->begin(); + gps_active = true; + } +} + +void HammerSensorManager::stop_gps() { + if (gps_active) { + gps_active = false; + _location->stop(); + } +} + +bool HammerSensorManager::begin() { + Serial1.begin(GPS_BAUD_RATE, SERIAL_8N1, PIN_GPS_RX, PIN_GPS_TX); + return true; +} + +bool HammerSensorManager::querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) { + if (requester_permissions & TELEM_PERM_LOCATION) { + telemetry.addGPS(TELEM_CHANNEL_SELF, node_lat, node_lon, node_altitude); + } + return true; +} + +void HammerSensorManager::loop() { + static long next_gps_update = 0; + + if (gps_active) { + _location->loop(); + } + + if (millis() > next_gps_update) { + if (gps_active && _location->isValid()) { + node_lat = ((double)_location->getLatitude()) / 1000000.0; + node_lon = ((double)_location->getLongitude()) / 1000000.0; + node_altitude = ((double)_location->getAltitude()) / 1000.0; + } + next_gps_update = millis() + 1000; + } +} + +int HammerSensorManager::getNumSettings() const { + return 1; +} + +const char* HammerSensorManager::getSettingName(int i) const { + return i == 0 ? "gps" : NULL; +} + +const char* HammerSensorManager::getSettingValue(int i) const { + return i == 0 ? (gps_active ? "1" : "0") : NULL; +} + +bool HammerSensorManager::setSettingValue(const char* name, const char* value) { + if (strcmp(name, "gps") == 0) { + if (strcmp(value, "0") == 0) { + stop_gps(); + } else { + start_gps(); + } + return true; + } + return false; +} diff --git a/variants/hammer/target.h b/variants/hammer/target.h new file mode 100644 index 000000000..2c99cea7a --- /dev/null +++ b/variants/hammer/target.h @@ -0,0 +1,60 @@ +#pragma once + +#define RADIOLIB_STATIC_ONLY 1 +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef DISPLAY_CLASS + #include + #include +#endif + +#ifdef HAS_ETHERNET + #include +#endif + +class HammerSensorManager : public SensorManager { + bool gps_active = false; + LocationProvider* _location; + + void start_gps(); + void stop_gps(); + +public: + HammerSensorManager(LocationProvider& location) : _location(&location) { } + bool begin() override; + bool querySensors(uint8_t requester_permissions, CayenneLPP& telemetry) override; + void loop() override; + int getNumSettings() const override; + const char* getSettingName(int i) const override; + const char* getSettingValue(int i) const override; + bool setSettingValue(const char* name, const char* value) override; + LocationProvider* getLocationProvider() override { return _location; } +}; + +extern ESP32Board board; +extern SPIClass vspi; +extern WRAPPER_CLASS radio_driver; +extern AutoDiscoverRTCClock rtc_clock; +extern HammerSensorManager sensors; + +#ifdef DISPLAY_CLASS + extern DISPLAY_CLASS display; + extern MomentaryButton user_btn; +#endif + +#ifdef HAS_ETHERNET + extern SerialEthernetInterface eth_interface; +#endif + +bool radio_init(); +uint32_t radio_get_rng_seed(); +void radio_set_params(float freq, float bw, uint8_t sf, uint8_t cr); +void radio_set_tx_power(uint8_t dbm); +mesh::LocalIdentity radio_new_identity(); diff --git a/variants/hammer/variant.h b/variants/hammer/variant.h new file mode 100644 index 000000000..21745e620 --- /dev/null +++ b/variants/hammer/variant.h @@ -0,0 +1,62 @@ +// Hammer Board Variant Header + +// OLED +#define I2C_SDA 21 +#define I2C_SCL 22 +#define HAS_SCREEN +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 64 +#define OLED_RESET -1 // No reset pin +#define OLED_I2C_ADDR 0x3C + +// GPS (u-blox) +#define GPS_RX_PIN 15 +#define GPS_TX_PIN 12 +#define GPS_UBLOX +#define GPS_BAUDRATE 9600 +#define HAS_GPS + +// Power and Buttons +#define EXT_PWR_DETECT 4 +#define BUTTON_PIN 39 +#define SECOND_BUTTON_PIN 0 // Boot button often used as second +#define BATTERY_PIN 35 +#define ADC_CHANNEL ADC1_GPIO35_CHANNEL +#define ADC_MULTIPLIER 1.85 + +// LoRa (E22 on VSPI) +#define USE_SX1262 // Primary for E22-900M30S +#define USE_SX1268 // Optional for E22-400M30S +#define SX126X_MAX_POWER 22 +#define SX126X_DIO3_TCXO_VOLTAGE 1.8 +#define TCXO_OPTIONAL + +#define SX126X_CS 18 +#define SX126X_SCK 5 +#define SX126X_MOSI 27 +#define SX126X_MISO 19 +#define SX126X_RESET 23 +#define SX126X_DIO1 33 +#define SX126X_BUSY 32 +#define SX126X_TXEN 13 +#define SX126X_RXEN 14 + +// Compatibility defines (for RadioLib) +#define P_LORA_NSS SX126X_CS +#define P_LORA_SCLK SX126X_SCK +#define P_LORA_MOSI SX126X_MOSI +#define P_LORA_MISO SX126X_MISO +#define P_LORA_RESET SX126X_RESET +#define P_LORA_DIO_1 SX126X_DIO1 +#define P_LORA_BUSY SX126X_BUSY + +// Ethernet (W5500 on VSPI - shared with LoRa) +// HAS_ETHERNET is defined per-variant in platformio.ini, not here +#define ETH_PHY_TYPE ETH_PHY_W5500 +#define ETH_CS_PIN 16 +#define ETH_RST_PIN 17 +#define ETH_INT_PIN -1 // Not connected +#define ETH_SPI_HOST VSPI_HOST // Shared with LoRa +#define ETH_SCLK SX126X_SCK // 5 +#define ETH_MOSI SX126X_MOSI // 27 +#define ETH_MISO SX126X_MISO // 19