Built one of these with my kid a couple of years ago and made both mistakes.
Angle: combine the two sensors with a complementary filter. The gyro is good short-term, the accelerometer long-term:
// fixed 200 Hz loop from a hardware timer, dt = 0.005
float accAngle = atan2f(ax, az) * RAD_TO_DEG;
angle = 0.98f * (angle + gy * dt) + 0.02f * accAngle;
Use a fixed loop rate from a timer, not millis() differences in a busy loop, and keep MQTT out of that loop entirely. Wilson’s DMP route works too, it just ties you to that chip’s library.
PID: start with P only and raise it until the robot oscillates around upright, then add D until the oscillation dies down, then a small I for drift. And add the motors’ deadband to the output, gear motors don’t move below a few percent duty.
Data: collect samples in the fast loop, decimate to 20 Hz, and send one message per second as an array of records with their own timestamps:
[
{"ts": 1767600000000, "values": {"bal.angle": 0.42, "bal.out": 12.5, "bal.speed": 3.1}},
{"ts": 1767600000050, "values": {"bal.angle": 0.38, "bal.out": 11.9, "bal.speed": 3.0}},
...
]
That is 1 message a second with 20 x 3 = 60 data points, inside the per-device defaults (10 messages a second, 200 points a second and 6,000 a minute). Your plan has an organisation-wide data points per minute limit too, so check Usage if other devices are busy. The ESP32 needs NTP time for ts, otherwise leave ts out. A chart over the last 24 hours shows every raw point, so the tuning curves stay sharp. Payload forms: MQTT topics and payload reference.

