Remote: match the old remote's hardware; old remote code in LEGACY

- LEGACY/remote/teensy/: old remote sketch from
  https://mangavalk.com/Hexapod/remote.git @ 492dece (reference only).
- remote/Config.h: sticks on A0-A5, Nextion on Serial1 (0/1), as in the
  old sketch; MPU6050 moved to Wire2 (25/24) since A4/A5 are Wire's pins.
- NextionUI: controls on a vscope-global "control" page added to the
  old HMI; keeps the old t1 status texts; the old page-1 b2 button (and
  serial 'U') enter Nextion upload passthrough, following the PC's baud.
- Docs: NEXTION.md, remote README wiring, CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mangavalkandClaude Opus 5.5 committed 2026-10-01 15:29:21 +02:00
1 parent 935526eab5
commit a961fa721b
18 files changed
+760 -76

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"platformio.platformio-ide"
],
"unwantedRecommendations": [
"ms-vscode.cpptools-extension-pack",
"pioarduino.pioarduino-ide"
]
}
+38 -8
View File
@@ -14,7 +14,8 @@ remote/ Teensy 4.1 firmware (PlatformIO env remote)
shared/HexapodProtocol/ wire protocol, used by robot, remote and sim
sim/ robot firmware -> hexapod_sim.dll (CMake + g++), headless smoke test
Hexapod_Simulation_Unity/ Unity 6000.6 project that loads the DLL (Assets/HexapodSim/)
LEGACY/ owner's older material for reference (remote/remote.HMI = old Nextion UI, 6 pages)
LEGACY/ owner's older material for reference (remote/remote.HMI = old Nextion UI, 6 pages;
remote/teensy/ = old remote sketch from https://mangavalk.com/Hexapod/remote.git @ 492dece)
scripts/ PlatformIO helper
```
@@ -113,7 +114,7 @@ link-loss disarm) → PacketBuilder → `radio.write` → Nextion render.
| `RemoteState` | authoritative operator state and interlocks: arm needs centered sticks, disarm clears stand, stand needs armed, calibrate needs disarmed, link-loss auto-disarm. Smooths speed scale and fades remote tilt in/out |
| `PacketBuilder` | the inverse of InputMapper: stick frame → body frame per the contract, per-submode right-stick mapping, `FLAG_WALK` with 250 ms linger (twist ignored in mode B), tilt deadzone/scale × fade, zeros motion when disarmed |
| `RadioLink` | nRF24 TX, short retries (1, 3), ack-based link status and 32-packet quality % |
| `NextionUI` | library-free Nextion driver. HMI sends `'#' len cmd [val]` frames via `printh`. Remote pushes state by component name with diffing, a 2 s full refresh, and a redraw on the Nextion 0x88 "ready" frame |
| `NextionUI` | library-free Nextion driver on `Serial1`. HMI sends `'#' len cmd [val]` frames via `printh`. Remote pushes state by component name, as `control.<name>` (the owner adds a `control` page with vscope-global components to the old HMI), with diffing, a 2 s full refresh, and a redraw on the Nextion 0x88 "ready" frame. Also writes the old HMI's `t1` status texts (`NEXTION_STATUS_TEXTS`). The old page-1 `b2` sends a standard 0x65 touch frame, which triggers `runUploadPassthrough()`: USB ↔ display bridge for Nextion Editor, following `Serial.baud()`, never returns. Serial key `U` does the same |
| `SerialConsole` | USB keystrokes that mirror every UI command plus diagnostics |
| `SmoothedValue.h` | copy of the robot's (kept separate so robot/ stays untouched) |
| `NEXTION.md` | HMI build guide: component names, txt_maxl, touch event code |
@@ -284,17 +285,46 @@ Robot (`robot/README.md` has detail):
- `Limits::stancePolygonOk()` is computed but not acted on.
Remote (`remote/README.md` has detail):
- All pins in `remote/Config.h` (radio CE 9 / CSN 10, sticks A0–A3/A6/A7,
MPU6050 on 18/19, Nextion on Serial2 7/8).
- Pins in `remote/Config.h`, taken from the old remote sketch: radio CE 9 /
CSN 10, sticks A0–A5, Nextion on Serial1 (0/1). The MPU6050 is on
**Wire2 (25/24)**, since A4/A5 = Wire's SDA/SCL are stick pins; that's
the owner's choice and needs rewiring the MPU. Which stick axis is on
which pin is still unknown.
- `STICK_INVERT[]`, calibration defaults, deadzones, expo.
- `IMU_AXIS_FROM_CHIP` / `IMU_AXIS_SIGN` depend on how the MPU6050 is
mounted in the handset.
- The Nextion must have `bauds=115200` in Program.s, and the HMI must match
`NEXTION.md`. The owner's old 6-page HMI is in `LEGACY/remote/remote.HMI`;
its component names aren't readable outside Nextion Editor. Adapting
`NextionUI` to it needs the owner's component list, or the old remote
Teensy code, which the owner will add to `LEGACY/` when found. The
compiled `.tft` is gitignored.
its component names aren't readable outside Nextion Editor. The compiled
`.tft` is gitignored.
- What the old remote sketch (`LEGACY/remote/teensy/`, last ran on a
Teensy) tells us about the real hardware:
- **Nextion on `Serial1` (pins 0/1)**, driven by the ITEAD Nextion
library, whose `nexInit()` runs at 9600. `remote/Config.h` now uses
Serial1 and expects 115200 via `bauds=` in the HMI.
- The old HMI is a *servo monitor*, not the control UI in `NEXTION.md`:
- page 1: `t1` "connected"/"not connected", and `b2` = enter
upload mode;
- page 3: sliders `h0`–`h17` showing the 18 servo positions, plus
`t1` status;
- page 4: `uploadscreen`.
None of the arm/stand/gait/mode/gyro/speed/height controls exist yet.
The owner chose to add a `control` page per `NEXTION.md`.
- `b2` → **Nextion upload passthrough**: shows `uploadscreen`, then
bridges USB ↔ Serial1 forever, so Nextion Editor can upload a `.tft`
through the remote's USB port. The new firmware keeps this.
- Sticks on **A0–A5**, contiguous (`analogInPin + i`). On the Teensy 4.1,
A4/A5 are pins 18/19 = Wire SDA/SCL, which the MPU6050 needs. The old
code had the MPU code disabled (`if (false)`). Owner's decision: keep
the sticks, move the MPU to Wire2.
- nRF24 on CE 9 / CSN 10, the same as `remote/Config.h`. It used
RF24Network on channel 90, which the new protocol doesn't.
- Stick calibration was a 10 s min/max sweep saved to EEPROM, output
0–255, with no center or deadzone.
- USB serial at 250000. It received 18 servo bytes + checksum + `0xFE`
from the old Unity `Serial.cs` (servo passthrough, forwarded over RF
to the robot) and printed `i_value,...` stick lines.
- The Unity remote stand-in's joystick axis assignment (which of axes 4/6
is forward) couldn't be recovered from the old code. Check it with the
panel's raw-axis readout.
@@ -0,0 +1,127 @@
#include <EEPROM.h>
// start reading from the first byte (address 0) of the EEPROM
unsigned int address = 0;
byte value;
// Joystick vars
const int analogInPin = A0;
int sensorValue = 0;
int outputValue = 0;
//volatile int center[6] = {0, 0, 0, 0, 0, 0};
//volatile int minvalue[6] = {1000, 1000, 1000, 1000, 1000, 1000}; //ARDUINO
//volatile int maxvalue[6] = {0, 0, 0, 0, 0, 0}; //ARDUINO
int minvalue[6] = {1000, 1000, 1000, 1000, 1000, 1000}; //TEENSY
int maxvalue[6] = {0, 0, 0, 0, 0, 0}; //TEENSY
void JoystickInit()
{
// Initialize joysticks first
value = EEPROM.read(address);
if (value != 0)
{
address++;
for (int i = 0; i < 6; i++)
{
address += sizeof(int);
EEPROM.get(address, minvalue[i]);
address += sizeof(int);
EEPROM.get(address, maxvalue[i]);
// Serial.println(minvalue[i]);
// Serial.println(maxvalue[i]);
}
}
else
{
// if eeprom empty
delay(500);
// for (int i = 0; i < 6; i++)
// {
// center[i] = analogRead(analogInPin + i);
// }
int time = 10000;
while (millis() < time)
{
for (int i = 0; i < 6; i++)
{
sensorValue = analogRead(analogInPin + i);
if (sensorValue > maxvalue[i]) {
maxvalue[i] = sensorValue;
}
if (sensorValue < minvalue[i]) {
minvalue[i] = sensorValue;
}
}
}
// for (int i = 0; i < 6; i++)
// {
// minvalue[i] = center[i] - minvalue[i];
// maxvalue[i] = (maxvalue[i] - center[i]);
// }
// write callibration to eeprom
address = 0;
EEPROM.write(address, 1);
address++;
for (int i = 0; i < 6; i++)
{
address += sizeof(int);
EEPROM.put(address, minvalue[i]);
address += sizeof(int);
EEPROM.put(address, maxvalue[i]);
// Serial.println(minvalue[i]);
// Serial.println(maxvalue[i]);
}
}
}
struct joyStickdataStruct {
float value[6];
} myJoyStickdataStruct;
joyStickdataStruct JoystickGet(bool debug = false)
{
for (int i = 0; i < 6; i++)
{
sensorValue = analogRead(analogInPin + i);
if (sensorValue > maxvalue[i])
maxvalue[i] = sensorValue;
if (sensorValue < minvalue[i])
minvalue[i] = sensorValue;
outputValue = map(sensorValue, minvalue[i], maxvalue[i], 0, 255);
outputValue = sensorValue > maxvalue[i] ? 255 : sensorValue < minvalue[i] ? 0 : outputValue;
// if (sensorValue <= center[i])
// {
// outputValue = map(sensorValue, minvalue[i], center[i], 0, 127);
// }
// else
// {
// outputValue = map(sensorValue, center[i], maxvalue[i], 128, 255);
// }
myJoyStickdataStruct.value[i] = outputValue;
if (debug)
{
Serial.print(i);
Serial.print("_");
Serial.print(myJoyStickdataStruct.value[i]);
if (i != 5)
Serial.print(",");
// Serial.print(" ");
}
}
if (debug)
Serial.println();
return myJoyStickdataStruct;
}
+64
View File
@@ -0,0 +1,64 @@
#include <SPI.h>
#include "RF24.h"
#include <RF24Network.h>
#include "TransferDataStructure.h"
// Nrf24 settings
RF24 radio(9, 10); // CE, CSN
RF24Network network(radio);
const uint16_t this_node = 00; // Address of our node in Octal format
const uint16_t other_node = 01; // Address of the other node in Octal format
void NrfInit()
{
SPI.begin();
radio.begin();
network.begin(/*channel*/ 90, /*node address*/ this_node);
// Set the PA Level low to prevent power supply related issues since this is a
// getting_started sketch, and the likelihood of close proximity of the devices. RF24_PA_MAX is default.
radio.setPALevel(RF24_PA_LOW);
}
bool NrfSendData(dataStruct2 data, bool debug = false)
//dataStruct2 NrfSendData(dataStruct2 data, bool debug = false)
{
// network.update();
if (debug)
Serial.print("Sending...");
RF24NetworkHeader header(/*to node*/ other_node);
bool ok = network.write(header, &data, sizeof(data));
if (debug)
{
if (ok)
Serial.println("ok.");
else
Serial.println("failed.");
}
return ok;
// return data;
}
dataStruct2 NrfGetData(dataStruct2 data, bool debug = false)
{
// network.update();
int timeout = millis() + 100;
while ( network.available() && timeout > millis() )
{
RF24NetworkHeader header;
network.read(header, &data, sizeof(data));
if (debug)
{
Serial.print("Received packet ");
Serial.println(data.servoPositions[0]);
}
}
return data;
}
@@ -0,0 +1,3 @@
# remote
arduino code for remote control
@@ -0,0 +1,122 @@
#include "TransferDataStructure.h"
String getValue(String data, char separator, int index)
{
int found = 0;
int strIndex[] = { 0, -1 };
int maxIndex = data.length() - 1;
for (int i = 0; i <= maxIndex && found <= index; i++) {
if (data.charAt(i) == separator || i == maxIndex) {
found++;
strIndex[0] = strIndex[1] + 1;
strIndex[1] = (i == maxIndex) ? i + 1 : i;
}
}
return found > index ? data.substring(strIndex[0], strIndex[1]) : "";
}
dataStruct2 GetSerialData(dataStruct2 &dataIn, bool debug = false)
{
if (Serial.available() && Serial.peek() != -1)
{
// String data = Serial.readStringUntil('\r');
// while (Serial.read() >= 0);
// int maxIndex = data.length() - 1;
// int index = 0;
// int next_index;
// String data_word;
// String channel;
// String angle;
// do {
// next_index = data.indexOf(',', index);
//
// data_word = data.substring(index, next_index);
// channel = data_word.substring(0, data_word.indexOf('_'));
// angle = data_word.substring(data_word.indexOf('_') + 1);
//
// if (isDigit(angle[0])) // prevent error overwriting first entry.
// dataIn.servoPositions[channel.toInt()] = angle.toFloat();
//
// index = next_index + 1;
// } while ((next_index != -1) && (next_index < maxIndex));
/*String s = Serial.readStringUntil('s');
for (int i = 0; i < 18; i++)
{
Serial.setTimeout(10);
String c = Serial.readStringUntil('_');
String v = Serial.readStringUntil(',');
String cc = Serial.readStringUntil('=');
int channel = c.toInt();
int value = v.toInt();
int crc = cc.toInt();
if ((crc == (channel) * value) && value >= 0 && value <= 180)
dataIn.servoPositions[channel] = value;*/
//byte recv[18 + 1];
if (Serial.available() >= 18 + 1 + 2 + 30)
{
bool recc = false;
byte recv[256] = {0};
//Serial.readBytesUntil(0xffffff, recv, 255);
Serial.readBytesUntil(0xff, recv, 255);
for (int i = 0; i < 255 - 19; i++)
{
// if (recv[i + 1] == 0xff && recv[i + 2] == 0xff && recv[i + 3] == 0xff)
if (recv[i + 19] == 0xfe)
{
for (int j = 0; j < 18 + 1; j++)
{
recv[j] = recv[(i) + j];
}
recc = true;
break;
}
}
//s.getBytes(recv, s.length());
byte crc = 0x00;
for (int i = 0; i < 18; i++)
{
crc += recv[i];
}
if (crc == recv[18])
{
if (recc)
{
for (int i = 0; i < 18; i++)
{
dataIn.servoPositions[i] = recv[i];
}
}
}
//}
dataIn.remotePasstroughControl = true;
if (debug)
{
Serial.println("Enabled Servo Passtrough mode!");
Serial.println("Output line: ");
for (int i = 0; i < 18; i++)
{
Serial.print(i);
Serial.print(", ");
Serial.print(dataIn.servoPositions[i]);
Serial.println("");
}
Serial.println("");
}
}
}
return dataIn;
}
@@ -0,0 +1,38 @@
#ifndef dataStruct
#define dataStruct
struct __attribute__((__packed__)) dataStruct2
{
bool STOP = false;
float walkSpeed = 10; //step size
float walkSpeed2 = 0; //gate speed
float walkRotation = 0 ;
float walkDirection = 0;
float tranform[3] = {0, 0, 0};
float tranform2[3] = {0, 0, 0};
float rotation[3] = {0, 0, 0};
bool remotePasstroughControl = 0;
float servoPositions[18] = {
90, 90, 90,
90, 90, 90,
90, 90, 90,
90, 90, 90,
90, 90, 90,
90, 90, 90
};
} dataStruct;
//template<size_t ... T> struct print_ct {
// print_ct() {
// static_assert(sizeof...(T) == -1, "");
// }
//};
//
//print_ct< sizeof(dataStruct2), alignof(dataStruct2) > v;
#endif
@@ -0,0 +1,176 @@
#include "Joysticks.h"
#include "Nrf.h"
#include "SerialPasstrough.h"
#include <Wire.h>
#include <MPU6050_tockn.h>
#include "Nextion.h"
NexPage page0 = NexPage(4, 0, "uploadscreen");
NexText t0 = NexText(1, 6, "t1");
NexText t1 = NexText(3, 22, "t1");
NexButton b2 = NexButton(1, 5, "b2");
NexSlider sliders[18] = {NexSlider(3, 1, "h0"),
NexSlider(3, 2, "h1"),
NexSlider(3, 3, "h2"),
NexSlider(3, 4, "h3"),
NexSlider(3, 5, "h4"),
NexSlider(3, 6, "h5"),
NexSlider(3, 7, "h6"),
NexSlider(3, 8, "h7"),
NexSlider(3, 9, "h8"),
NexSlider(3, 10, "h9"),
NexSlider(3, 11, "h10"),
NexSlider(3, 12, "h11"),
NexSlider(3, 13, "h12"),
NexSlider(3, 14, "h13"),
NexSlider(3, 15, "h14"),
NexSlider(3, 16, "h15"),
NexSlider(3, 17, "h16"),
NexSlider(3, 18, "h17")
};
char buffer[100] = {0};
NexTouch *nex_listen_list[] =
{
&b2,
&page0,
&t0,
&t1,
NULL
};
void b2PopCallback(void *ptr)
{
passtrough();
}
MPU6050 mpu (Wire);
dataStruct2 data;
joyStickdataStruct joystickData;
void setup() {
// Serial.begin(250000, SERIAL_8E1); // ARDUINO
Serial.begin(250000); // TEENSY
// Serial.begin(115200);
Serial1.begin(115200);
// passtrough();
nexInit();
b2.attachPush(b2PopCallback, &b2);
b2.attachPop(b2PopCallback, &b2);
// for (int i = 0; i < 18; i++)
// sliders[i] = NexSlider(3, i++, "h" + i);
// while (true)
// {
// nexLoop(nex_listen_list);
// sliders[0].setValue(30);
// }
// Initialization
// Wire.begin();
// mpu.begin();
//
// mpu.calcGyroOffsets(true, 300, 1000);
//
// // Calibration
// Serial.println("=====================================");
// Serial.println("Starting calibration...");
// mpu.calcGyroOffsets();
// Serial.println("Calibration complete!");
JoystickInit();
NrfInit();
}
void passtrough()
{
delay(100);
for (int i = 0; i < 10; i++)
{
// nexLoop(nex_listen_list);
// delay(10);
page0.show();
delay(10);
}
Serial.begin(115200);
Serial1.begin(115200);
do {
if (Serial.available()) { // If anything comes in Serial (USB),
Serial1.write(Serial.read()); // read it and send it out Serial1 (pins 0 & 1)
}
if (Serial1.available()) { // If anything comes in Serial1 (pins 0 & 1)
Serial.write(Serial1.read()); // read it and send it out Serial (USB)
}
} while (true);
}
void loop()
{
nexLoop(nex_listen_list);
network.update();
if (false)
{
mpu.update();
Serial.print("AngX = ");
Serial.print(mpu.getAngleX());
Serial.print(" / AngY = ");
Serial.print(mpu.getAngleY());
Serial.print(" / AngZ = ");
Serial.println(mpu.getAngleZ());
}
// Serial.print(" / AngX = ");
// Serial.print(mpu.getGyroAngleX());
// Serial.print(" / AngY = ");
// Serial.print(mpu.getGyroAngleY());
// Serial.print(" / AngZ = ");
// Serial.println(mpu.getGyroAngleZ());
joystickData = JoystickGet(true);
// test
if (false)
{
data.remotePasstroughControl = true;
data.servoPositions[1] = (joystickData.value[2] * 0.78f);
data.servoPositions[4] = (joystickData.value[2] * 0.78f);
data.servoPositions[7] = (joystickData.value[2] * 0.78f);
data.servoPositions[10] = 180 - (joystickData.value[2] * 0.78f);
data.servoPositions[13] = 180 - (joystickData.value[2] * 0.78f);
data.servoPositions[16] = 180 - (joystickData.value[2] * 0.78f);
}
GetSerialData(data, false);
// data = GetSerialData(data, false);
// data.STOP = true;
for (int i = 0; i < 18; i++)
sliders[i].setValue(data.servoPositions[i]);
bool ok = NrfSendData(data, false);
// data = NrfSendData(data, false);
if (ok)
t0.setText("connected");
else
t0.setText("not connected");
if (ok)
t1.setText("connected");
else
t1.setText("not connected");
//test
// data = NrfGetData(data, true);
delay(20);
}
+26 -9
View File
@@ -14,7 +14,7 @@
constexpr float TX_HZ = 100.0f;
// ---- Radio (nRF24L01) on SPI0: MOSI 11, MISO 12, SCK 13 ----
// BENCH: placeholder CE/CSN pins - match your wiring.
// CE/CSN as in the old remote sketch (LEGACY/remote/teensy/Nrf.h).
constexpr uint8_t RADIO_CE_PIN = 9;
constexpr uint8_t RADIO_CSN_PIN = 10;
// Auto-retransmit: (delay units of 250us, count). Kept short on purpose - a
@@ -31,9 +31,11 @@ constexpr uint32_t LINK_LOST_MS = 300;
// Logical axis order used everywhere in the remote code.
enum StickAxis : uint8_t { L_X = 0, L_Y, L_TWIST, R_X, R_Y, R_TWIST, NUM_AXES };
// BENCH: placeholder pins. A4/A5 (18/19) are skipped because they're the I2C
// pins the MPU6050 uses.
constexpr uint8_t STICK_PINS[NUM_AXES] = { A0, A1, A2, A3, A6, A7 };
// A0-A5 in a row, as wired for the old remote (LEGACY/remote/teensy/Joysticks.h).
// A4/A5 are also Wire's SDA/SCL - that's why the MPU6050 is on Wire2 below.
// BENCH: which physical axis is on which pin wasn't recoverable from the old
// code; reorder these so each lands on the right logical axis ('p' dump).
constexpr uint8_t STICK_PINS[NUM_AXES] = { A0, A1, A2, A3, A4, A5 };
// Each logical axis must end up: X + = pushed RIGHT, Y + = pushed FORWARD
// (away from you), TWIST + = rotated COUNTERCLOCKWISE seen from above.
@@ -67,7 +69,9 @@ constexpr float STICK_EXPO[NUM_AXES] = { 0.25f, 0.25f, 0.30f, 0.20f, 0.20f,
// One-pole low-pass on raw ADC counts (1 = no filtering). Pots are noisy.
constexpr float STICK_FILTER_ALPHA = 0.5f;
// ---- Remote IMU: MPU6050 on I2C (Wire: SDA 18, SCL 19) ----
// ---- Remote IMU: MPU6050 on the second I2C bus (Wire2: SDA2 = 25, SCL2 = 24) ----
// Not Wire (18/19): those pins are stick axes A4/A5.
#define IMU_WIRE Wire2
constexpr uint8_t IMU_ADDR = 0x68; // 0x69 if AD0 is pulled high
// Mounting remap: which chip axis (0=X,1=Y,2=Z) and sign gives each handset
// axis. Handset frame: +X = away from you, +Y = your left, +Z = up.
@@ -103,11 +107,24 @@ constexpr uint32_t WALK_LINGER_MS = 250;
// (post-deadzone, -1000..1000 scale), so the robot never arms mid-command.
constexpr int16_t ARM_CENTER_TOLERANCE = 50;
// ---- Nextion touchscreen on Serial2 (RX2 = pin 7, TX2 = pin 8) ----
#define NEXTION_SERIAL Serial2
// BENCH: Nextion ships at 9600. The HMI's Program.s must set `bauds=115200`
// (see remote/NEXTION.md) or change this to match.
// ---- Nextion touchscreen on Serial1 (RX1 = pin 0, TX1 = pin 1), as in the old remote ----
#define NEXTION_SERIAL Serial1
// BENCH: the old sketch used the ITEAD library, whose nexInit() talks at 9600
// unless it was modified. This firmware expects 115200: add `bauds=115200`
// to the HMI's Program.s (see remote/NEXTION.md) or set 9600 here.
constexpr uint32_t NEXTION_BAUD = 115200;
// Controls live on this page (remote/NEXTION.md). Its components must have
// vscope = global, so the firmware can update them whichever page is showing.
#define NEXTION_CONTROL_PAGE "control"
// The old HMI's link-status texts (pages 1 and 3, both named t1), also
// vscope = global. BENCH: use your real page names (Nextion's default
// names are page0..page5; only page 4 "uploadscreen" is known).
constexpr const char* NEXTION_STATUS_TEXTS[] = { "page1.t1", "page3.t1" };
// The old HMI's "upload" button: page 1, component id 5 (b2), sends the
// standard touch event. It puts the remote into Nextion upload passthrough.
constexpr uint8_t NEXTION_UPLOAD_BUTTON_PAGE = 1;
constexpr uint8_t NEXTION_UPLOAD_BUTTON_ID = 5;
#define NEXTION_UPLOAD_PAGE "uploadscreen"
constexpr uint32_t NEXTION_FULL_REFRESH_MS = 2000; // re-push everything periodically (self-heals after a display reset)
constexpr uint32_t NEXTION_LINK_REFRESH_MS = 250; // link-quality text update rate
constexpr uint32_t STATUS_MESSAGE_MS = 2500; // how long transient status messages stay up
+26 -10
View File
@@ -1,14 +1,25 @@
# Nextion HMI build guide
> The owner's earlier 6-page HMI is in `LEGACY/remote/remote.HMI`. This
> guide describes the minimal single page the current firmware expects.
> To reuse the old HMI instead, `NextionUI` has to be changed to match
> its component names.
Start from your existing HMI (`LEGACY/remote/remote.HMI`, the servo
monitor) and **add one page named `control`** with the components below.
Your existing pages keep working:
| Existing | What the firmware does with it |
|----------|--------------------------------|
| page 1 `t1`, page 3 `t1` | writes `connected` / `not connected`, as the old firmware did. Set both to **vscope = global**, and put their real names (`page1.t1`, …) in `NEXTION_STATUS_TEXTS` in `Config.h` |
| page 1 `b2` (id 5) | **upload mode**: keep "Send Component ID" ticked on it. The remote shows `uploadscreen` and passes USB straight through to the display, so Nextion Editor can upload over the remote's USB port. Power-cycle to leave. Serial key `U` does the same |
| page 4 `uploadscreen` | shown on entering upload mode |
| page 3 sliders `h0`–`h17` | not driven yet: the robot doesn't send servo positions back (that needs the planned telemetry channel) |
The firmware drives the display by **component name**, and the display
talks back with small custom frames sent from each button's touch event.
Build one page in Nextion Editor to match the tables below. Layout, fonts
and colors are up to you. The names and event code must match exactly.
Layout, fonts and colors are up to you. The names and event code must
match exactly.
**Set every component on `control` to vscope = global.** The firmware
writes them as `control.<name>` and keeps them current whichever page is
showing. A local component on another page can't be written from outside,
and with `bkcmd=0` the display ignores those writes silently.
The remote is authoritative. Use **plain Button** components (not
Dual-state buttons) and don't toggle anything locally in the HMI. The
@@ -24,9 +35,13 @@ bauds=115200
```
This sets the display to the `NEXTION_BAUD` in `remote/Config.h`. The
display ships at 9600, and `bauds=` persists across power cycles.
display ships at 9600, and `bauds=` persists across power cycles. The old
firmware used the ITEAD library at 9600, so your HMI probably runs at 9600
today.
Name the page `main` (page 0). The firmware never changes pages.
Name the new page `control`. The firmware never changes pages, except
into `uploadscreen` for upload mode. Add buttons on your other pages to
switch to it (`page control`).
## Components
@@ -75,8 +90,9 @@ uses **Touch Move** as well so the value tracks your finger). Frame format:
| `bCal` | `printh 23 01 43` | `C` start / save stick calibration |
| `bCalX` | `printh 23 01 51` | `Q` cancel calibration |
**Uncheck "Send Component ID"** on every component. The firmware ignores
standard touch frames, and they only add serial traffic.
**Uncheck "Send Component ID"** on every component on `control`: those
standard touch frames only add serial traffic. The one exception is the old
page 1 `b2` upload button (see the top).
Protocol details and the parser live in `NextionUI.h/.cpp`. To add a
control, pick an unused command byte, add it to `NextionUI::decodeCustom()`
+78 -26
View File
@@ -7,21 +7,26 @@ static constexpr uint16_t COL_OFF = 50712; // Nextion default light gray
static constexpr uint16_t COL_DANGER = 63488; // red
static constexpr uint16_t COL_WARN = 64512; // orange
// Serial2's default 64-byte TX buffer would make a full redraw (~300 bytes
// at 115200 baud) block the TX loop for ~25 ms. Give it room to queue.
// The default 64-byte TX buffer would make a full redraw (~400 bytes at
// 115200 baud) block the TX loop for ~35 ms. Give it room to queue.
static uint8_t tx_extra[1024];
static uint8_t rx_extra[128];
// Every control lives on NEXTION_CONTROL_PAGE with vscope = global, so it's
// addressed as page.component and can be updated whichever page is showing.
#define P NEXTION_CONTROL_PAGE "."
void NextionUI::begin() {
NEXTION_SERIAL.begin(NEXTION_BAUD);
NEXTION_SERIAL.addMemoryForWrite(tx_extra, sizeof(tx_extra));
NEXTION_SERIAL.addMemoryForRead(rx_extra, sizeof(rx_extra));
cmd("bkcmd=0"); // no success/fail replies - we never need them
cmd("bkcmd=0"); // no success/fail replies - we never need them (and a
// write to a page the HMI doesn't have is then just ignored)
force_full_ = true;
}
void NextionUI::cmd(const char* fmt, ...) {
char buf[64];
char buf[80];
va_list ap;
va_start(ap, fmt);
vsnprintf(buf, sizeof(buf), fmt, ap);
@@ -50,11 +55,31 @@ bool NextionUI::decodeCustom(UiEvent& out) {
case 'Z': out.cmd = UiCmd::ZeroGyro; break;
case 'C': out.cmd = UiCmd::CalStartSave; break;
case 'Q': out.cmd = UiCmd::CalCancel; break;
case 'U': out.cmd = UiCmd::NextionUpload; break;
default: return false;
}
return true;
}
// Standard Nextion frames (terminated FF FF FF). Two matter:
// 0x88 / 0x00 display (re)started -> redraw everything
// 0x65 page id event touch event from a component with "Send Component
// ID" ticked - how the old HMI's upload button (b2)
// reports, so it keeps working unchanged.
bool NextionUI::decodeNative(UiEvent& out) {
if (native_buf_[0] == 0x88 || native_buf_[0] == 0x00) {
force_full_ = true;
return false;
}
if (native_buf_[0] == 0x65 && native_len_ >= 4 &&
native_buf_[1] == NEXTION_UPLOAD_BUTTON_PAGE && native_buf_[2] == NEXTION_UPLOAD_BUTTON_ID) {
out.cmd = UiCmd::NextionUpload;
out.value = 0;
return true;
}
return false;
}
bool NextionUI::poll(UiEvent& out) {
while (NEXTION_SERIAL.available()) {
uint8_t b = NEXTION_SERIAL.read();
@@ -64,7 +89,7 @@ bool NextionUI::poll(UiEvent& out) {
rx_ = Rx::CustomLen;
} else {
rx_ = Rx::Native;
native_first_ = b;
native_buf_[0] = b;
native_len_ = 1;
ff_run_ = (b == 0xFF) ? 1 : 0;
}
@@ -89,12 +114,12 @@ bool NextionUI::poll(UiEvent& out) {
break;
case Rx::Native:
if (native_len_ < sizeof(native_buf_)) native_buf_[native_len_] = b;
native_len_++;
ff_run_ = (b == 0xFF) ? ff_run_ + 1 : 0;
if (ff_run_ == 3) {
// 0x88 = "Nextion ready" after power-on/reset, 0x00 = startup.
if (native_first_ == 0x88 || native_first_ == 0x00) force_full_ = true;
rx_ = Rx::Idle;
if (decodeNative(out)) return true;
} else if (native_len_ > 16) {
rx_ = Rx::Idle; // not a frame we understand
}
@@ -109,37 +134,40 @@ bool NextionUI::poll(UiEvent& out) {
void NextionUI::pushState(const UiView& v, bool full) {
const UiView& s = shown_;
if (full || v.armed != s.armed) {
cmd("bArm.txt=\"%s\"", v.armed ? "ARMED" : "DISARMED");
cmd("bArm.bco=%u", v.armed ? COL_DANGER : COL_OFF);
cmd(P "bArm.txt=\"%s\"", v.armed ? "ARMED" : "DISARMED");
cmd(P "bArm.bco=%u", v.armed ? COL_DANGER : COL_OFF);
}
if (full || v.stand != s.stand) {
cmd("bStand.txt=\"%s\"", v.stand ? "STAND" : "SIT");
cmd("bStand.bco=%u", v.stand ? COL_ON : COL_OFF);
cmd(P "bStand.txt=\"%s\"", v.stand ? "STAND" : "SIT");
cmd(P "bStand.bco=%u", v.stand ? COL_ON : COL_OFF);
}
if (full || v.gait_wave != s.gait_wave) cmd("bGait.txt=\"%s\"", v.gait_wave ? "WAVE" : "TRIPOD");
if (full || v.mode_b != s.mode_b) cmd("bMode.txt=\"%s\"", v.mode_b ? "HEADING" : "DIRECT");
if (full || v.rstick_xlate != s.rstick_xlate) cmd("bRs.txt=\"%s\"", v.rstick_xlate ? "R: MOVE" : "R: ROTATE");
if (full || v.remote_gyro != s.remote_gyro) cmd("bRGyro.bco=%u", v.remote_gyro ? COL_ON : COL_OFF);
if (full || v.robot_gyro != s.robot_gyro) cmd("bBGyro.bco=%u", v.robot_gyro ? COL_ON : COL_OFF);
if (full || v.gait_wave != s.gait_wave) cmd(P "bGait.txt=\"%s\"", v.gait_wave ? "WAVE" : "TRIPOD");
if (full || v.mode_b != s.mode_b) cmd(P "bMode.txt=\"%s\"", v.mode_b ? "HEADING" : "DIRECT");
if (full || v.rstick_xlate != s.rstick_xlate) cmd(P "bRs.txt=\"%s\"", v.rstick_xlate ? "R: MOVE" : "R: ROTATE");
if (full || v.remote_gyro != s.remote_gyro) cmd(P "bRGyro.bco=%u", v.remote_gyro ? COL_ON : COL_OFF);
if (full || v.robot_gyro != s.robot_gyro) cmd(P "bBGyro.bco=%u", v.robot_gyro ? COL_ON : COL_OFF);
if (full || v.speed_target != s.speed_target) {
cmd("hSpeed.val=%u", v.speed_target);
cmd("nSpeed.val=%u", (v.speed_target * 100 + 127) / 255);
cmd(P "hSpeed.val=%u", v.speed_target);
cmd(P "nSpeed.val=%u", (v.speed_target * 100 + 127) / 255);
}
if (full || v.height != s.height) {
cmd("hHeight.val=%d", v.height / 10 + 100); // slider 0..200, 100 = default
cmd("nHeight.val=%d", v.height / 10); // -100..100 %
cmd(P "hHeight.val=%d", v.height / 10 + 100); // slider 0..200, 100 = default
cmd(P "nHeight.val=%d", v.height / 10); // -100..100 %
}
if (full || v.calibrating != s.calibrating) {
cmd("bCal.txt=\"%s\"", v.calibrating ? "SAVE CAL" : "CAL");
cmd("bCal.bco=%u", v.calibrating ? COL_WARN : COL_OFF);
cmd(P "bCal.txt=\"%s\"", v.calibrating ? "SAVE CAL" : "CAL");
cmd(P "bCal.bco=%u", v.calibrating ? COL_WARN : COL_OFF);
}
if (full || v.message != s.message) cmd("tMsg.txt=\"%s\"", v.message);
if (full || v.message != s.message) cmd(P "tMsg.txt=\"%s\"", v.message);
}
void NextionUI::pushLink(const UiView& v) {
if (v.link_ok) cmd("tLink.txt=\"LINK %u%%\"", v.link_pct);
else cmd("tLink.txt=\"NO LINK\"");
cmd("tLink.pco=%u", v.link_ok ? COL_ON : COL_DANGER);
if (v.link_ok) cmd(P "tLink.txt=\"LINK %u%%\"", v.link_pct);
else cmd(P "tLink.txt=\"NO LINK\"");
cmd(P "tLink.pco=%u", v.link_ok ? COL_ON : COL_DANGER);
// The old HMI's own status texts, same wording as the old firmware.
for (const char* t : NEXTION_STATUS_TEXTS)
cmd("%s.txt=\"%s\"", t, v.link_ok ? "connected" : "not connected");
}
void NextionUI::render(const UiView& v, uint32_t now_ms) {
@@ -160,3 +188,27 @@ void NextionUI::render(const UiView& v, uint32_t now_ms) {
shown_ = v;
}
// ---- Upload passthrough ----
// Lets Nextion Editor upload a new .tft through the remote's USB port, with
// the display still in the case: show the upload page, then wire USB serial
// straight through to the display, forever (power-cycle to leave). Nextion
// Editor switches baud rate mid-upload; Teensy's USB serial reports the
// rate the PC asked for, so the display side follows it.
void NextionUI::runUploadPassthrough() {
cmd("page " NEXTION_UPLOAD_PAGE);
NEXTION_SERIAL.flush();
delay(100);
uint32_t baud = NEXTION_BAUD;
for (;;) {
uint32_t want = Serial.baud();
if (want >= 2400 && want != baud) {
NEXTION_SERIAL.flush();
NEXTION_SERIAL.begin(want);
baud = want;
}
while (Serial.available()) NEXTION_SERIAL.write(Serial.read());
while (NEXTION_SERIAL.available()) Serial.write(NEXTION_SERIAL.read());
}
}
+9 -3
View File
@@ -8,8 +8,9 @@
// '#' <len> <cmd> [value] e.g. printh 23 01 41 (ToggleArm)
// printh 23 02 56 + prints hSpeed.val,1
// Command bytes are listed in NEXTION.md and in NextionUI.cpp.
// Native Nextion return frames (terminated FF FF FF) are also parsed so a
// display reset (0x88 "ready") triggers a full redraw.
// Native Nextion return frames (terminated FF FF FF) are also parsed: a
// display reset (0x88 "ready") triggers a full redraw, and the old HMI's
// upload button sends a standard touch event (0x65) that's recognized.
//
// remote -> Nextion: plain instructions ("bArm.txt=\"ARMED\"" + FF FF FF).
//
@@ -23,12 +24,16 @@ public:
bool poll(UiEvent& out); // call every loop; returns one decoded event at a time
void render(const UiView& v, uint32_t now_ms);
// Nextion Editor upload through the remote's USB port. Never returns.
[[noreturn]] void runUploadPassthrough();
private:
enum class Rx : uint8_t { Idle, CustomLen, CustomBody, Native };
Rx rx_ = Rx::Idle;
uint8_t body_[4];
uint8_t body_len_ = 0, body_got_ = 0;
uint8_t native_first_ = 0, native_len_ = 0, ff_run_ = 0;
uint8_t native_buf_[8];
uint8_t native_len_ = 0, ff_run_ = 0;
UiView shown_;
bool force_full_ = true;
@@ -39,4 +44,5 @@ private:
void pushState(const UiView& v, bool full);
void pushLink(const UiView& v);
bool decodeCustom(UiEvent& out);
bool decodeNative(UiEvent& out);
};
+8 -6
View File
@@ -11,17 +11,19 @@ at 100 Hz.
From the repo root: `pio run -e remote`, flash with
`pio run -e remote -t upload`, monitor with `pio device monitor -e remote`.
## Wiring (placeholders; edit `Config.h` to match)
## Wiring (from the old remote sketch in `LEGACY/remote/teensy/`; edit `Config.h` if yours differs)
| Part | Signal | Teensy 4.1 pin |
|------|--------|----------------|
| nRF24L01 | MOSI / MISO / SCK | 11 / 12 / 13 (SPI0) |
| nRF24L01 | CE / CSN | 9 / 10 |
| nRF24L01 | VCC | 3.3 V, with a 10–100 µF cap right at the module |
| MPU6050 | SDA / SCL | 18 / 19 (Wire) |
| Nextion | TX → RX2, RX ← TX2 | 7 / 8 (Serial2), 5 V supply |
| Left stick | X / Y / twist | A0 / A1 / A2 |
| Right stick | X / Y / twist | A3 / A6 / A7 |
| MPU6050 | SDA / SCL | **25 / 24 (Wire2)**: not 18/19, those are stick pins A4/A5 |
| Nextion | TX → RX1, RX ← TX1 | 0 / 1 (Serial1), 5 V supply |
| Sticks | 6 axes | A0–A5. Which axis is on which pin isn't known yet: reorder `STICK_PINS` until the `p` dump reads right |
**MPU6050 rewiring:** the old remote had its MPU code switched off, probably
because A4/A5 double as Wire's pins. Move the MPU's SDA/SCL to pins 25/24.
Stick pots are wired 3.3 V – wiper – GND. **Never feed 5 V into a Teensy
4.x analog pin.**
@@ -101,7 +103,7 @@ display attached.
| `]` / `[` | height up / down 10% | | |
| `c` | start / save calibration | `x` | cancel calibration |
| `p` | print status (raw + mapped sticks, IMU, link, state, cal) | `d` | toggle 10 Hz packet dump |
| `R` | reset stick cal to Config defaults | | |
| `R` | reset stick cal to Config defaults | `U` | Nextion upload mode (USB ↔ display; power-cycle to leave) |
## Bench-check checklist
+13 -13
View File
@@ -9,15 +9,15 @@ static constexpr uint8_t REG_PWR_MGMT_1 = 0x6B;
static constexpr uint8_t REG_ACCEL_XOUT_H = 0x3B;
static bool writeReg(uint8_t reg, uint8_t val) {
Wire.beginTransmission(IMU_ADDR);
Wire.write(reg);
Wire.write(val);
return Wire.endTransmission() == 0;
IMU_WIRE.beginTransmission(IMU_ADDR);
IMU_WIRE.write(reg);
IMU_WIRE.write(val);
return IMU_WIRE.endTransmission() == 0;
}
bool RemoteIMU::begin() {
Wire.begin();
Wire.setClock(400000);
IMU_WIRE.begin();
IMU_WIRE.setClock(400000);
ok_ = writeReg(REG_PWR_MGMT_1, 0x00); // wake out of sleep
if (!ok_) return false;
writeReg(REG_CONFIG, 0x03); // 44 Hz DLPF: a handheld remote doesn't need more bandwidth
@@ -49,16 +49,16 @@ bool RemoteIMU::begin() {
}
bool RemoteIMU::readRaw(float a[3], float g[3]) {
Wire.beginTransmission(IMU_ADDR);
Wire.write(REG_ACCEL_XOUT_H);
if (Wire.endTransmission(false) != 0) return false;
Wire.requestFrom((int)IMU_ADDR, 14);
if (Wire.available() < 14) return false;
IMU_WIRE.beginTransmission(IMU_ADDR);
IMU_WIRE.write(REG_ACCEL_XOUT_H);
if (IMU_WIRE.endTransmission(false) != 0) return false;
IMU_WIRE.requestFrom((int)IMU_ADDR, 14);
if (IMU_WIRE.available() < 14) return false;
int16_t raw[7]; // ax ay az temp gx gy gz, big-endian
for (int k = 0; k < 7; k++) {
uint8_t hi = Wire.read(); // two statements: the order of calls inside one expression is unspecified
uint8_t lo = Wire.read();
uint8_t hi = IMU_WIRE.read(); // two statements: the order of calls inside one expression is unspecified
uint8_t lo = IMU_WIRE.read();
raw[k] = (int16_t)((hi << 8) | lo);
}
+3
View File
@@ -78,6 +78,9 @@ bool RemoteState::handle(const UiEvent& e, Sticks& stick_hw, RemoteIMU& imu, uin
setMessage("Calibration saved", now);
return true;
case UiCmd::NextionUpload:
return false; // not operator state - remote.ino handles it
case UiCmd::CalCancel:
if (!calibrating_) return false;
stick_hw.cancelCalibration();
+1
View File
@@ -22,6 +22,7 @@ bool SerialConsole::poll(UiEvent& ui, Local& local) {
case 'z': ui.cmd = UiCmd::ZeroGyro; return true;
case 'c': ui.cmd = UiCmd::CalStartSave; return true;
case 'x': ui.cmd = UiCmd::CalCancel; return true;
case 'U': ui.cmd = UiCmd::NextionUpload; return true;
case 'p': local = Local::PrintStatus; return true;
case 'd': local = Local::ToggleDump; return true;
case 'R': local = Local::ResetCal; return true;
+1
View File
@@ -23,6 +23,7 @@ enum class UiCmd : uint8_t {
ZeroGyro, // capture the handset's current attitude as "level"
CalStartSave, // start sweep calibration, or save it if running
CalCancel,
NextionUpload, // USB <-> display passthrough for Nextion Editor (handled in remote.ino)
};
struct UiEvent {
+16 -1
View File
@@ -47,12 +47,27 @@ static void dumpPacket(const CmdPacket& p) {
p.remote_roll, p.remote_pitch, p.body_height, p.speed_scale, p.flags);
}
// Nextion Editor upload through this USB port (the old remote's b2 button).
// Never returns: the TX loop stops, so the robot loses the link and
// disarms itself after LINK_TIMEOUT_SEC. Power-cycle the remote to leave.
static void enterNextionUpload() {
state.handle(UiEvent{ UiCmd::Disarm, 0 }, sticks, imu, millis());
Serial.println("Nextion upload mode: connect Nextion Editor to this USB port and upload.");
Serial.println("Power-cycle the remote to leave.");
Serial.flush();
nextion.runUploadPassthrough();
}
static void pollInputs(uint32_t now_ms) {
UiEvent ev;
while (nextion.poll(ev)) state.handle(ev, sticks, imu, now_ms);
while (nextion.poll(ev)) {
if (ev.cmd == UiCmd::NextionUpload) enterNextionUpload();
state.handle(ev, sticks, imu, now_ms);
}
SerialConsole::Local local;
while (console.poll(ev, local)) {
if (ev.cmd == UiCmd::NextionUpload) enterNextionUpload();
if (ev.cmd != UiCmd::None) {
bool ok = state.handle(ev, sticks, imu, now_ms);
if (!ok && state.message(now_ms)[0]) Serial.println(state.message(now_ms));