Browse Source

Add Hammer support and local configurator

pull/2435/head
Kent Andersen 2 months ago
parent
commit
a21fea639f
  1. 2
      .gitignore
  2. 46
      README.md
  3. 6
      examples/companion_radio/main.cpp
  4. 184
      src/helpers/esp32/SerialEthernetInterface.cpp
  5. 92
      src/helpers/esp32/SerialEthernetInterface.h
  6. 11
      tools/build_windows_exe.bat
  7. 536
      tools/meshcore_configurator.py
  8. 185
      variants/hammer/platformio.ini
  9. 150
      variants/hammer/target.cpp
  10. 60
      variants/hammer/target.h
  11. 62
      variants/hammer/variant.h

2
.gitignore

@ -17,3 +17,5 @@ compile_commands.json
.venv/
venv/
platformio.local.ini
__pycache__/
*.pyc

46
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)

6
examples/companion_radio/main.cpp

@ -44,6 +44,10 @@ static uint32_t _atoi(const char* sp) {
#elif defined(BLE_PIN_CODE)
#include <helpers/esp32/SerialBLEInterface.h>
SerialBLEInterface serial_interface;
#elif defined(HAS_ETHERNET)
#include <helpers/esp32/SerialEthernetInterface.h>
extern SerialEthernetInterface eth_interface;
SerialEthernetInterface& serial_interface = eth_interface;
#elif defined(SERIAL_RX)
#include <helpers/ArduinoSerialInterface.h>
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);

184
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 <Ethernet3.h>
#include <SPI.h>
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

92
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 <Ethernet3.h>
#include <SPI.h>
// 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 <Arduino.h>
#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

11
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

536
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())

185
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>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.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>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.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}
+<helpers/esp32/*.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../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}
+<helpers/esp32/*.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../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}
+<helpers/esp32/*.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../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}
+<helpers/bridges/ESPNowBridge.cpp>
+<helpers/ui/SSD1306Display.cpp>
+<helpers/ui/MomentaryButton.cpp>
+<../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}

150
variants/hammer/target.cpp

@ -0,0 +1,150 @@
#include <Arduino.h>
#include "target.h"
#include "variant.h"
#if ENV_INCLUDE_GPS
#include <helpers/sensors/MicroNMEALocationProvider.h>
#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;
}

60
variants/hammer/target.h

@ -0,0 +1,60 @@
#pragma once
#define RADIOLIB_STATIC_ONLY 1
#include <RadioLib.h>
#include <helpers/radiolib/RadioLibWrappers.h>
#include <helpers/ESP32Board.h>
#include <helpers/radiolib/CustomSX1262Wrapper.h>
#include <helpers/radiolib/CustomSX1268Wrapper.h>
#include <helpers/AutoDiscoverRTCClock.h>
#include <helpers/SensorManager.h>
#include <helpers/sensors/LocationProvider.h>
#ifdef DISPLAY_CLASS
#include <helpers/ui/SSD1306Display.h>
#include <helpers/ui/MomentaryButton.h>
#endif
#ifdef HAS_ETHERNET
#include <helpers/esp32/SerialEthernetInterface.h>
#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();

62
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
Loading…
Cancel
Save