Skip to content

Commit 3768e35

Browse files
committed
Merge branch 'master' of https://github.com/tobiasguyer/gaggimate into all_features
2 parents 9c14225 + 670d05a commit 3768e35

21 files changed

Lines changed: 451 additions & 190 deletions

.clabot

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"szymonmilewski",
3636
"stephenwilley",
3737
"Marbit911",
38-
"khanguyen74"
38+
"khanguyen74",
39+
"Clinteastman"
3940
],
4041
"message": "We require contributors to sign our Contributor License Agreement, and we don't have yours on file. In order for us to review and merge your code, please contact @jniebuhr (mdwasp) on Discord to get yourself added."
4142
}

lib/NanoPbComm/src/Endpoint.cpp

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ static const char *ENDPOINT_TAG = "Endpoint";
88

99
Endpoint::Endpoint(Transport &transport) : _transport(transport) {
1010
_mutex = xSemaphoreCreateRecursiveMutex();
11-
_rxQueue = xQueueCreate(RX_QUEUE_DEPTH, sizeof(gm::Payload));
11+
_rxQueue = xQueueCreate(RX_QUEUE_DEPTH, sizeof(DispatchEvent));
1212
if (_mutex == nullptr || _rxQueue == nullptr)
1313
ESP_LOGE(ENDPOINT_TAG, "Failed to allocate endpoint resources (out of memory)");
1414
}
@@ -42,10 +42,16 @@ void Endpoint::begin() {
4242

4343
void Endpoint::dispatchTaskFn(void *arg) {
4444
auto *self = static_cast<Endpoint *>(arg);
45-
gm::Payload payload;
45+
DispatchEvent event;
4646
for (;;) {
47-
if (xQueueReceive(self->_rxQueue, &payload, portMAX_DELAY) == pdTRUE)
48-
self->dispatch(payload);
47+
if (xQueueReceive(self->_rxQueue, &event, portMAX_DELAY) != pdTRUE)
48+
continue;
49+
if (event.isConnection) {
50+
if (self->_connHandler)
51+
self->_connHandler(event.connected);
52+
} else {
53+
self->dispatch(event.payload);
54+
}
4955
}
5056
}
5157

@@ -232,8 +238,11 @@ void Endpoint::handleData(const uint8_t *data, size_t length) {
232238
if (static_cast<pb_size_t>(uxQueueSpacesAvailable(_rxQueue)) < n) {
233239
accepted = false;
234240
} else {
235-
for (pb_size_t i = 0; i < n; i++)
236-
xQueueSend(_rxQueue, &_rxFrame.payloads[i], 0);
241+
for (pb_size_t i = 0; i < n; i++) {
242+
DispatchEvent event;
243+
event.payload = _rxFrame.payloads[i];
244+
xQueueSend(_rxQueue, &event, 0);
245+
}
237246
}
238247
}
239248

@@ -264,9 +273,15 @@ void Endpoint::handleConnection(bool connected) {
264273
_queue.clear();
265274
unlock();
266275

267-
if (_rxQueue)
268-
xQueueReset(_rxQueue); // drop any inbound payloads from a previous session
269-
270-
if (_connHandler)
271-
_connHandler(connected); // e.g. push SystemInfo on connect
276+
if (_rxQueue) {
277+
// Drop queued payloads from the previous session, then serialize the
278+
// application connection callback with payload dispatch. A payload
279+
// already executing finishes before this event, so it cannot mutate
280+
// per-session application state after the callback resets it.
281+
xQueueReset(_rxQueue);
282+
DispatchEvent event;
283+
event.isConnection = true;
284+
event.connected = connected;
285+
xQueueSend(_rxQueue, &event, 0);
286+
}
272287
}

lib/NanoPbComm/src/Endpoint.h

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@
2727
* typed handlers -- no run-time type erasure.
2828
*
2929
* Threading: decode + ACK/dedup + the send pump run on the transport's callback
30-
* thread, but registered handlers are invoked on a dedicated dispatch task (fed
31-
* by an inbound payload queue) so slow application callbacks never block the BLE
32-
* host task. If the inbound queue is full the frame is left un-ACKed, which
30+
* thread, but registered handlers and connection callbacks are invoked on a
31+
* dedicated dispatch task (fed by an inbound event queue) so slow application
32+
* callbacks never block the BLE host task. Serializing both event types also
33+
* prevents payload handlers from crossing a connection-session boundary. If
34+
* the inbound queue is full the frame is left un-ACKed, which
3335
* back-pressures the sender into retransmitting. Queue + in-flight state are
3436
* guarded by a mutex; handlers run with the mutex released, so a handler may
3537
* call send() re-entrantly.
@@ -122,8 +124,16 @@ class Endpoint {
122124

123125
ConnectionHandler _connHandler = nullptr;
124126

125-
// Inbound payloads decoded on the transport thread, drained by the dispatch
126-
// task so handlers never run on the BLE host task.
127+
struct DispatchEvent {
128+
gm::Payload payload{};
129+
bool isConnection = false;
130+
bool connected = false;
131+
};
132+
133+
// Inbound payloads and connection changes originate on the transport
134+
// thread, then drain through one dispatch task. A connection change resets
135+
// the queue before its event is inserted, so no queued payload from the old
136+
// session can run after the new session callback.
127137
QueueHandle_t _rxQueue = nullptr;
128138
TaskHandle_t _dispatchTask = nullptr;
129139

lib/NanoPbComm/src/GaggiMateClient.h

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,8 @@
77
#include <Arduino.h>
88
#include <functional>
99

10-
/**
11-
* Display-side protocol facade.
12-
*
13-
* Owns a BLE client transport + Endpoint and exposes semantic send methods and
14-
* typed response callbacks. The connect sequence is asynchronous: the link is
15-
* established via connectToServer(), and the controller's SystemInfo arrives as
16-
* a pushed message (onSystemInfo) which is when capability-dependent setup
17-
* should run.
18-
*/
10+
// Display-side protocol facade: owns transport + Endpoint, exposes semantic sends and typed response callbacks.
11+
// Connecting is async: capability-dependent setup belongs in onSystemInfo, pushed by the controller after connect.
1912
class GaggiMateClient {
2013
public:
2114
using ConnectionCallback = std::function<void(bool connected)>;
@@ -43,19 +36,18 @@ class GaggiMateClient {
4336
bool isConnected() const { return _endpoint.isConnected(); }
4437
void disconnect() { _transport.disconnect(); }
4538

46-
// BLE round-trip latency (ms) measured by the reliability layer (send -> ACK).
47-
// EWMA-smoothed; refreshed at least every ~2s by the keep-alive ping plus on
48-
// every control update. hasLatency() is false until the first ACK of a link.
39+
// Forget the paired controller so the display can pair to a different one.
40+
void clearBonds() { _transport.clearBonds(); }
41+
42+
// EWMA-smoothed send->ACK round-trip (ms); hasLatency() is false until the first ACK of a link.
4943
uint32_t getLatencyMs() const { return _endpoint.latencyMs(); }
5044
uint32_t getLastLatencyMs() const { return _endpoint.lastLatencyMs(); }
5145
bool hasLatency() const { return _endpoint.hasLatency(); }
5246

53-
// Tight connection interval (responsive control) while active; relaxed when
54-
// idle to give the shared radio back to Wi-Fi.
47+
// Tight connection interval while active; relaxed when idle to give the shared radio back to Wi-Fi.
5548
void setLowLatency(bool active) { _transport.setLowLatency(active); }
5649

57-
// Native NimBLE client handle, used by ControllerOTA / status RSSI (OTA uses
58-
// its own BLE service, independent of this protocol).
50+
// Native NimBLE client handle for ControllerOTA / status RSSI (OTA uses its own BLE service).
5951
NimBLEClient *getClient() const { return _transport.getNativeClient(); }
6052

6153
// Build a payload without sending (compose your own batch, then send()).
@@ -69,8 +61,7 @@ class GaggiMateClient {
6961
gm::Payload buildAutotune(uint32_t testTime, uint32_t samples, uint32_t heaterWattage);
7062
gm::Payload buildPressureScale(float scale);
7163
gm::Payload buildTare();
72-
// Pack channel/brightness pairs into one LedControl payload; entries beyond
73-
// the schema's per-message cap (LedControl.channels max_count) are dropped.
64+
// Pack channel/brightness pairs into one LedControl payload; entries beyond the schema's max_count are dropped.
7465
gm::Payload buildLedControl(const LedChannelCommand *channels, size_t count);
7566

7667
// Commands (display -> controller)
@@ -85,17 +76,14 @@ class GaggiMateClient {
8576
void sendPressureScale(float scale);
8677
void sendThermostatControl(float boilerLowPass, float groupLowPass);
8778
void tare();
88-
// Drive several LED channels in one message (avoids per-channel sends that
89-
// the outbound queue would coalesce down to a single channel).
79+
// Drive several LED channels in one message; per-channel sends would coalesce down to a single channel.
9080
void sendLedControl(const LedChannelCommand *channels, size_t count);
9181

92-
// Send a pre-built payload / batch of payloads (one frame). Compose batches
93-
// from build*() helpers -- e.g. the display's delta-based control update.
82+
// Send a pre-built payload / batch of payloads (one frame), composed from the build*() helpers.
9483
void send(const gm::Payload &payload) { _endpoint.send(payload); }
9584
void sendBatch(const gm::Payload *payloads, size_t count) { _endpoint.sendBatch(payloads, count); }
9685

97-
// Fired when the connected controller is missing the framed-comms
98-
// characteristics (old / incompatible firmware); link is kept for OTA.
86+
// Fired when the controller lacks the framed-comms characteristics (old firmware); link is kept for OTA.
9987
void onIncompatibleController(IncompatibleCallback cb) { _incompatibleCb = std::move(cb); }
10088

10189
// Response registrations (controller -> display)

lib/NanoPbComm/src/GaggiMateComm.h

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33

44
#include <cstdint>
55

6-
// Public protocol vocabulary shared by GaggiMateClient and GaggiMateServer.
7-
// Firmware code only ever sees these plain types -- never the nanopb structs.
6+
// Public protocol vocabulary shared by GaggiMateClient and GaggiMateServer; firmware never sees the nanopb structs.
87

98
// Pump control mode. Integer values match gaggimate_PumpMode in the schema.
109
enum class PumpControlMode : uint8_t {
@@ -19,9 +18,7 @@ enum class BoilerControlMode : uint8_t {
1918
Pressure = 1, // setpoint is a target pressure in bar
2019
};
2120

22-
// Per-component commands, used to drive several components atomically in one
23-
// frame and to detect changes (callers can compare against the last value sent
24-
// and only transmit what actually changed).
21+
// Per-component commands: drive several components atomically in one frame, comparable so callers send only deltas.
2522
struct BoilerCommand {
2623
uint8_t index = 0;
2724
BoilerControlMode mode = BoilerControlMode::Temperature;
@@ -46,27 +43,22 @@ struct RelayCommand {
4643
bool operator==(const RelayCommand &o) const { return index == o.index && open == o.open; }
4744
bool operator!=(const RelayCommand &o) const { return !(*this == o); }
4845
};
49-
// One LED output's target brightness. Several are packed into a single
50-
// LedControl message so a multi-channel update can't be split (and coalesced)
51-
// into separate frames.
46+
// One LED output's target brightness; packed into a single LedControl message so multi-channel updates can't be split.
5247
struct LedChannelCommand {
5348
uint8_t channel = 0;
5449
uint8_t brightness = 0;
5550
bool operator==(const LedChannelCommand &o) const { return channel == o.channel && brightness == o.brightness; }
5651
bool operator!=(const LedChannelCommand &o) const { return !(*this == o); }
5752
};
5853

59-
// Error codes. Values match the gaggimate.ErrorCode enum and the codes the old
60-
// string protocol used, so existing firmware comparisons keep working.
54+
// Error codes; values match gaggimate.ErrorCode and the old string protocol so existing comparisons keep working.
6155
constexpr int ERROR_CODE_NONE = 0;
6256
constexpr int ERROR_CODE_COMM_SEND = 1;
6357
constexpr int ERROR_CODE_COMM_RCV = 2;
6458
constexpr int ERROR_CODE_PROTO_ERR = 3;
6559
constexpr int ERROR_CODE_RUNAWAY = 4;
6660
constexpr int ERROR_CODE_TIMEOUT = 5;
67-
// Autotune hit its test-duration window without detecting a reaction. The
68-
// controller skips the NVS PID persist; the display surfaces it without a
69-
// watchdog-disconnect UX. Distinct from the generic TIMEOUT.
61+
// Autotune saw no reaction within its test window (distinct from TIMEOUT): no PID persist, no watchdog-disconnect UX.
7062
constexpr int ERROR_CODE_AUTOTUNE_TIMEOUT = 6;
7163

7264
#endif // GAGGIMATE_COMM_H

lib/NanoPbComm/src/GaggiMateServer.cpp

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ void GaggiMateServer::init(const String &deviceName, const String &hardware, con
1010
setSystemInfo(hardware, version, capabilities);
1111
registerHandlers();
1212
_endpoint.onConnection([this](bool connected) {
13+
_sentSystemInfoAfterHandshake = false;
1314
if (connected)
1415
pushSystemInfo();
1516
});
@@ -112,10 +113,7 @@ gm::Payload GaggiMateServer::buildError(int code) {
112113
return p;
113114
}
114115

115-
// Telemetry (sensor / volumetric / ToF) is sent fire-and-forget: it is
116-
// high-rate and self-refreshing, so a dropped sample is replaced by the next
117-
// one. This avoids the constant ACK chatter on the high-rate path. Button /
118-
// autotune-result / error / system-info stay reliable.
116+
// Telemetry (sensor / volumetric / ToF) is fire-and-forget: self-refreshing, so skip ACK chatter; the rest stays reliable.
119117
void GaggiMateServer::sendSensorData(float temperature, float pressure, float puckFlow, float pumpFlow, float puckResistance, float temperature2,
120118
float pumpPower, float heaterPower) {
121119
_endpoint.sendUnreliable(buildSensorData(temperature, pressure, puckFlow, pumpFlow, puckResistance, temperature2, pumpPower, heaterPower));
@@ -135,6 +133,14 @@ void GaggiMateServer::sendError(int code) { _endpoint.send(buildError(code)); }
135133

136134
void GaggiMateServer::registerHandlers() {
137135
_endpoint.on(gaggimate_Payload_ping_tag, [this](const gm::Payload &) {
136+
// A SystemInfo notification sent synchronously from the BLE subscribe
137+
// callback can beat the client's notification handler. Once a ping has
138+
// crossed the framed protocol, the link is fully established; resend
139+
// SystemInfo once so reliable delivery starts from a usable session.
140+
if (!_sentSystemInfoAfterHandshake) {
141+
_sentSystemInfoAfterHandshake = true;
142+
pushSystemInfo();
143+
}
138144
if (_pingCb)
139145
_pingCb();
140146
});

lib/NanoPbComm/src/GaggiMateServer.h

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,8 @@
77
#include <Arduino.h>
88
#include <functional>
99

10-
/**
11-
* Controller-side protocol facade.
12-
*
13-
* Owns a BLE server transport + Endpoint and exposes semantic send methods and
14-
* typed command callbacks. Pushes SystemInfo to the display on connect.
15-
*/
10+
// Controller-side protocol facade: owns transport + Endpoint, exposes semantic sends and typed command callbacks;
11+
// pushes SystemInfo to the display on connect.
1612
class GaggiMateServer {
1713
public:
1814
using PingCallback = std::function<void()>;
@@ -37,8 +33,7 @@ class GaggiMateServer {
3733

3834
void setSystemInfo(const String &hardware, const String &version, const gm::DeviceCapabilities &capabilities);
3935

40-
// Build a payload without sending (compose your own batch, then send()).
41-
// sendSensorData reports boiler 0; the wire format supports several boilers.
36+
// Build a payload without sending; sendSensorData reports boiler 0 (the wire format supports several).
4237
gm::Payload buildSensorData(float temperature, float pressure, float puckFlow, float pumpFlow, float puckResistance, float temperature2,
4338
float pumpPower = 0.0f, float heaterPower = 0.0f);
4439
gm::Payload buildButtonState(uint8_t index, bool pressed);
@@ -56,10 +51,12 @@ class GaggiMateServer {
5651
void sendTofMeasurement(uint32_t distance);
5752
void sendError(int code);
5853

59-
// Drop the current BLE link. The ping watchdog calls this so the display
60-
// sees a real disconnect instead of having to interpret an in-band error.
54+
// Drop the BLE link; the ping watchdog uses this so the display sees a real disconnect, not an in-band error.
6155
void disconnect() { _transport.disconnect(); }
6256

57+
// Forget the paired display and advertise openly again (re-pairing escape hatch, e.g. after a screen swap).
58+
void clearBonds() { _transport.clearBonds(); }
59+
6360
// Send a pre-built payload / batch of payloads (one frame).
6461
void send(const gm::Payload &payload) { _endpoint.send(payload); }
6562
void sendBatch(const gm::Payload *payloads, size_t count) { _endpoint.sendBatch(payloads, count); }
@@ -87,6 +84,10 @@ class GaggiMateServer {
8784
BleServerTransport _transport;
8885
Endpoint _endpoint;
8986
gm::SystemInfo _systemInfo = gaggimate_SystemInfo_init_zero;
87+
// The BLE subscribe callback can run before the client has finished
88+
// installing its notification handler. The first received ping is the
89+
// application-level proof that the new session is ready in both directions.
90+
bool _sentSystemInfoAfterHandshake = false;
9091

9192
PingCallback _pingCb;
9293
BoilerCallback _boilerCb;
@@ -102,8 +103,7 @@ class GaggiMateServer {
102103
void registerHandlers();
103104
void pushSystemInfo();
104105

105-
// Drives the endpoint send pump / retransmit independently of the
106-
// controller's (slow, 250ms) main loop, on the NimBLE core.
106+
// Drives the endpoint send pump / retransmit on the NimBLE core, independent of the slow 250ms main loop.
107107
TaskHandle_t _taskHandle = nullptr;
108108
static void pumpTask(void *arg);
109109
};

lib/NanoPbComm/src/Messages.h

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,8 @@
11
#ifndef NANOPBCOMM_MESSAGES_H
22
#define NANOPBCOMM_MESSAGES_H
33

4-
// Short, namespaced aliases for the package-prefixed nanopb types so library
5-
// internals read cleanly (gm::Payload instead of gaggimate_Payload). These are
6-
// used only inside NanoPbComm -- the high-level GaggiMateClient/Server API
7-
// exposes plain C++ types, so firmware code never sees nanopb structs.
8-
//
9-
// Tag/descriptor/init macros (gaggimate_*_tag, gaggimate_*_msg,
10-
// gaggimate_*_init_zero) are macros, not types, so they are referenced with
11-
// their full names where needed.
4+
// NanoPbComm-internal aliases for nanopb types (gm::Payload vs gaggimate_Payload); firmware never sees nanopb structs.
5+
// The gaggimate_*_tag/_msg/_init_zero macros are not types, so they keep their full names.
126

137
#include "gaggimate.pb.h"
148

lib/NanoPbComm/src/Protocol.h

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,7 @@
77
// Shared protocol UUIDs and helpers used by both ends.
88
namespace gm_proto {
99

10-
// BLE service + characteristics. A single TX/RX pair carries framed nanopb
11-
// datagrams (replacing the old one-characteristic-per-message design). The
12-
// service UUID is kept so the display scan logic is unchanged.
10+
// BLE service + chars: one TX/RX pair carries framed nanopb datagrams; the service UUID predates the framed protocol.
1311
static constexpr const char *SERVICE_UUID = "e75bc5b6-ff6e-4337-9d31-0c128f2e6e68";
1412
// Controller (server) -> display (client) notifications.
1513
static constexpr const char *TX_CHAR_UUID = "87654321-4321-8765-4321-cba987654321";
@@ -18,9 +16,7 @@ static constexpr const char *RX_CHAR_UUID = "12345678-1234-5678-1234-123456789ab
1816
// Legacy read-only system-info characteristic (JSON), kept for external readers.
1917
static constexpr const char *INFO_CHAR_UUID = "f8d7203b-e00c-48e2-83ba-37ff49cdba74";
2018

21-
// Protocol/schema version. Bump on any breaking change to gaggimate.proto so a
22-
// display talking to an out-of-date controller (or vice versa) can detect the
23-
// mismatch. Carried in SystemInfo.protocol_version.
19+
// Bump on any breaking gaggimate.proto change; carried in SystemInfo.protocol_version for mismatch detection.
2420
static constexpr uint32_t PROTOCOL_VERSION = 3;
2521

2622
// Outbound priorities (higher wins in the queue).
@@ -31,13 +27,10 @@ enum Priority : uint8_t {
3127
PRIO_HIGH = 200, // ping, error
3228
};
3329

34-
// Per-family device-index space for the coalescing key. Keeps keys dense so the
35-
// queue's reverse-lookup table stays small.
30+
// Per-family device-index space for the coalescing key; keeps keys dense so the queue's reverse-lookup stays small.
3631
static constexpr uint16_t MAX_DEVICES = 8;
3732

38-
// Coalescing key: a (message family, device index) pair so repeated updates for
39-
// the same component collapse to the latest value. Device-less messages map to
40-
// index 0. Keys stay below which_content_max * MAX_DEVICES.
33+
// Coalescing key (family, device index): repeated updates for one component collapse to the latest value.
4134
inline uint16_t coalescingKey(const gm::Payload &p) {
4235
uint16_t index = 0;
4336
switch (p.which_content) {

0 commit comments

Comments
 (0)