Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion data-src/appversion
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.1.0
3.2.0
20 changes: 11 additions & 9 deletions data-src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
<meta name="apple-mobile-web-app-title" content="ESPSomfy RTS App">
<meta name="apple-mobile-web-app-status-bar-style" content="black">

<link rel="stylesheet" href="main.css?v=3.1.0c" type="text/css" />
<link rel="stylesheet" href="widgets.css?v=3.1.0c" type="text/css" />
<link rel="stylesheet" href="icons.css?v=3.1.0c" type="text/css" />
<link rel="stylesheet" href="main.css?v=3.2.0c" type="text/css" />
<link rel="stylesheet" href="widgets.css?v=3.2.0c" type="text/css" />
<link rel="stylesheet" href="icons.css?v=3.2.0c" type="text/css" />
<link rel="icon" type="image/png" href="favicon.png" />

<!-- iPad retina icon -->
Expand Down Expand Up @@ -114,7 +114,7 @@
rel="apple-touch-startup-image">


<script type="text/javascript" src="index.js?v=3.1.0c"></script>
<script type="text/javascript" src="index.js?v=3.2.0c"></script>
</head>
<body>
<div id="divContainer" class="container main" data-auth="false">
Expand Down Expand Up @@ -1027,7 +1027,7 @@ <h1 style="text-align: center;"><img src="icon.png" style="width:50px;float:left
</div>
</div>
<div id="divFrameLog" class="subtab-content frame-log" style="display:none;margin:0px;padding:0px;">
<div class="frame-header"><span>Key</span><span>Address</span><span>Command</span><span>Code</span><span>RSSI</span><span>Bits</span><span style="text-align:center;width:77px;">Time</span></div>
<div class="frame-header"><span>Key</span><span>Address</span><span>Command</span><span>Code</span><span>RSSI</span><span>Bits</span><span style="text-align:center;width:77px;">Time</span><span>Raw</span></div>
<div id="divFrames" class="frame-list"></div>
<div class="button-container" style="text-align:center">
<button type="button" class="btnCopyFrame" style="display:inline-block;width:44%;" onclick="somfy.framesToClipboard();">Copy</button>
Expand Down Expand Up @@ -1061,21 +1061,23 @@ <h1 style="text-align: center;"><img src="icon.png" style="width:127px;margin-le
<label for="fldPinEntry" style="margin-top:7px;">Enter Pin</label>
</div>
</div>
<div id="divLoginPassword" style="display:none;" onkeyup="if (event.code === 'Enter') security.login();">
<form id="frmUnauthLogin" action="/login" method="post" onsubmit="security.login(event); return false;">
<div id="divLoginPassword" style="display:none;">
<div class="field-group">
<input id="fldLoginUsername" name="username" type="text" data-bind="login.username" length=32 placeholder="Username" />
<input id="fldLoginUsername" name="username" type="text" autocomplete="username" data-bind="login.username" length=32 placeholder="Username" />
<label for="fldLoginUsername">Username</label>
</div>
<div class="field-group">
<input id="fldLoginPassword" name="password" type="password" data-bind="login.password" length=32 placeholder="Password" />
<input id="fldLoginPassword" name="password" type="password" autocomplete="current-password" data-bind="login.password" length=32 placeholder="Password" />
<label for="fldLoginPassword">Password</label>
</div>
</div>
<div style="text-align:center;"><span id="spanLoginMessage" style="color:red"></span></div>
<div id="loginButtons" class="button-container" style="display:none;text-align:center;">
<button id="btnLogin" type="button" onclick="security.login();" style="display:inline-block;width:42%;">Login</button>
<button id="btnLogin" type="submit" style="display:inline-block;width:42%;">Login</button>
<button id="btnCancelLogin" type="button" onclick="security.cancelLogin();" style="display:none;width:42%;">Cancel</button>
</div>
</form>
</div>
</div>
<script type="text/javascript">
Expand Down
38 changes: 27 additions & 11 deletions data-src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1232,7 +1232,8 @@ class Security {
document.getElementById('divUnauthenticated').style.display = 'none';
document.getElementById('divContainer').dispatchEvent(evt);
}
login() {
login(evt) {
if (evt && evt.preventDefault) evt.preventDefault();
console.log('Logging in...');
let pnl = document.getElementById('divUnauthenticated');
let msg = pnl.querySelector('#spanLoginMessage');
Expand All @@ -1245,7 +1246,7 @@ class Security {
for (let i = 0; i < 4; i++) {
pin += sec.pin[`d${i}`];
}
if (pin.length !== 4) return;
if (pin.length !== 4) return false;
break;
case 2:
break;
Expand All @@ -1256,6 +1257,16 @@ class Security {
else {
console.log(log);
if (log.success) {
if (sec.type === 2 && window.PasswordCredential && navigator.credentials) {
try {
const cred = new PasswordCredential({
id: sec.username,
password: sec.password,
name: sec.username
});
navigator.credentials.store(cred);
} catch (e) { /* ignore; browsers without support fall back to the form-submit heuristic */ }
}
if (typeof socket === 'undefined' || !socket) (async () => { await initSockets(); })();
//ui.setMode(mode);

Expand All @@ -1264,21 +1275,22 @@ class Security {
document.getElementById('divContainer').setAttribute('data-auth', true);
this.apiKey = log.apiKey;
this.authenticated = true;
let evt = new CustomEvent('afterlogin', { detail: { authenticated: true } });
document.getElementById('divContainer').dispatchEvent(evt);
let evt2 = new CustomEvent('afterlogin', { detail: { authenticated: true } });
document.getElementById('divContainer').dispatchEvent(evt2);
}
else
msg.innerHTML = log.msg;
}
});
return false;
}
}
var security = new Security();

// let appVersion = 'v3.1.0'; // Default placeholder
// let appVersion = 'v3.2.0'; // Default placeholder
async function getAppVersion() {
try {
const response = await fetch('/appversion');
const response = await fetch('/appversion?v='+Date.now());
if (!response.ok) throw new Error('File not found');

const data = await response.text();
Expand All @@ -1288,14 +1300,14 @@ async function getAppVersion() {
// Trigger any UI updates here
} catch (error) {
console.error("Error loading App version:", error);
appVersion = 'v3.1.0'; // Default placeholder
appVersion = 'v3.2.0'; // Default placeholder
}
return appVersion;
}

class General {
initialized = false;
appVersion = getAppVersion();
initialized = false;
appVersion = '';
reloadApp = false;
init() {
if (this.initialized) return;
Expand Down Expand Up @@ -1472,7 +1484,10 @@ class General {
}
});
}
setAppVersion() { document.getElementById('spanAppVersion').innerText = this.appVersion; }
async setAppVersion() {
this.appVersion = await getAppVersion();
document.getElementById('spanAppVersion').innerText = this.appVersion;
}
setTimeZones() {
let dd = document.getElementById('selTimeZone');
dd.length = 0;
Expand Down Expand Up @@ -2938,7 +2953,8 @@ class Somfy {
proto = '-V';
break;
}
let html = `<span>${frame.encKey}</span><span>${frame.address}</span><span>${frame.command}<sup>${frame.stepSize ? frame.stepSize : ''}</sup></span><span>${frame.rcode}</span><span>${frame.rssi}dBm</span><span>${frame.bits}${proto}</span><span>${fnFmtTime(frame.time)}</span><div class="frame-pulses">`;
let rawCmdHex = (typeof frame.rawCmd === 'number') ? `0x${frame.rawCmd.toString(16).toUpperCase()}` : '';
let html = `<span>${frame.encKey}</span><span>${frame.address}</span><span>${frame.command}<sup>${frame.stepSize ? frame.stepSize : ''}</sup></span><span>${frame.rcode}</span><span>${frame.rssi}dBm</span><span>${frame.bits}${proto}</span><span>${fnFmtTime(frame.time)}</span><span>${rawCmdHex}</span><div class="frame-pulses">`;
for (let i = 0; i < frame.pulses.length; i++) {
if (i !== 0) html += ',';
html += `${frame.pulses[i]}`;
Expand Down
8 changes: 4 additions & 4 deletions data-src/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<h1 style="text-align: center;"><img src="icon.png" style="width:127px;margin-left:1px;margin-top:-10px;" /></h1>
<div id="divLoginPnl" class="login-content" style="position:relative;">
<div style="max-width:270px;margin:0px auto;">
<form id="frmLogin" action="/login" method="post" class="login-form">
<form id="frmLogin" action="/login" method="post" class="login-form" onsubmit="general.login(event); return false;">
<input id="fldPin" type="hidden" name="pin">
<div id="divPinSecurity" style="display:none;">
<div class="field-group" style="text-align:center;">
Expand All @@ -29,17 +29,17 @@ <h1 style="text-align: center;"><img src="icon.png" style="width:127px;margin-le
</div>
<div id="divPasswordSecurity" style="display:none;">
<div class="field-group">
<input id="fldUsername" name="username" type="text" data-bind="security.username" length=32 placeholder="Username">
<input id="fldUsername" name="username" type="text" autocomplete="username" data-bind="security.username" length=32 placeholder="Username">
<label for="fldUsername">Username</label>
</div>
<div class="field-group">
<input id="fldPassword" name="password" type="password" data-bind="security.password" length=32 placeholder="Password">
<input id="fldPassword" name="password" type="password" autocomplete="current-password" data-bind="security.password" length=32 placeholder="Password">
<label for="fldPassword">Password</label>
</div>
</div>
<div style="text-align:center;"><span id="spanLoginMessage" style="color:red"></span></div>
<div class="button-container">
<button id="btnLogin" type="button" value="Submit" onclick="general.login();">
<button id="btnLogin" type="submit" value="Submit">
Login
</button>
</div>
Expand Down
5 changes: 5 additions & 0 deletions data-src/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -832,6 +832,11 @@ div.frame-header > span {
text-align: right;
white-space:nowrap;
}
div.frame-row > span:nth-child(8),
div.frame-header > span:nth-child(8) {
width: 40px;
text-align: center;
}

div.frame-list > div:nth-child(2n+1) {
background: beige;
Expand Down
59 changes: 59 additions & 0 deletions docs/KNOWN_ISSUES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Known Issues

## Concurrent mutation during chunked `/controller` response

**Status:** open
**Introduced:** 2026-04-19, alongside the chunked-response conversion of `/controller` (see [`ControllerChunker` in src/Web.cpp](../src/Web.cpp) and the original crash report about `cbuf::resize` aborting `async_tcp`).

### Summary

The chunked streaming of `/controller` lazily reads `somfy.rooms`, `somfy.shades`, `somfy.groups`, and `somfy.repeaters` across many `async_tcp` callback invocations as the client's TCP send window opens. If another task mutates those arrays mid-stream, the rendered JSON can be internally inconsistent.

### Why this is strictly worse than the pre-chunked code

The old `handleController` ran the serializers synchronously inside a single request-handler invocation, so the full JSON was built in memory *before* any bytes were sent. Window of exposure: a few milliseconds.

The new `ControllerChunker` reads state as the response drains. On a slow link or under backpressure, that window is hundreds of ms to seconds.

### Concrete failure modes

1. A shade is deleted mid-stream (e.g. via `/deleteShade`) — it may appear in the `shades` array but be missing from a later group's `linkedShades`, or vice versa.
2. `lastRollingCode` / position fields change between the `shades` pass and a later group's `linkedShades` pass — the client sees two values for the same shade in one document.
3. A group's `linkedShades` list is mutated while the chunker iterates inside `S_GROUPS` — an entry is skipped or emitted twice.

### Fix options (pick one later)

- **(a) Document and accept.** In practice users rarely mutate shades while the config UI is loading. Zero code change.
- **(b) FreeRTOS mutex around `somfy` reads/writes.** Acquire for the full duration of the chunked response and in every mutating path (RF RX, MQTT, web handlers). Cleanest but wide-reaching.
- **(c) Up-front snapshot.** At handler start, copy the subset of `somfy` state the response will serialize into the `ControllerChunker`. Defeats part of the memory benefit — a full snapshot of shades + groups is close in size to the old growing cbuf. Could be reduced by snapshotting only minimal fields (IDs, names, rolling codes) and reading the rest live.

### Related

- Same exposure exists in any other endpoint converted to chunked responses next (`/discovery`, `/shades`). Resolve this issue before expanding the pattern.

## Silent truncation of large websocket events

**Status:** open
**Location:** [`JsonSockEvent` in src/WResp.cpp](../src/WResp.cpp), buffer defined at [src/Sockets.cpp:45-46](../src/Sockets.cpp#L45-L46) as `g_response[MAX_SOCK_RESPONSE]` = 2048 bytes.

### Summary

Socket events are built into a fixed 2 KB static buffer. On overflow, [`JsonSockEvent::_safecat`](../src/WResp.cpp) logs an error and returns without appending — the event is sent truncated, producing malformed socket.io text that the client drops silently.

Unlike the `/controller` HTTP crash, this path does **not** abort — there is no growing cbuf and no `new[]` on the send path. Per-client frame allocations inside `AsyncWebSocket` are bounded by the 2 KB buffer size and have their own overflow guard (queue drop / client disconnect).

### Concrete failure modes

1. A single event serializing a fully-populated shade (~1.3–1.5 KB for a shade with all `SOMFY_MAX_LINKED_REMOTES` = 7 populated) gets close to the 2 KB limit. Any additional fields or long names push it over and the JSON is silently cut mid-value.
2. Any event that loops over a collection (e.g. frequency-scan results, batch emits in `Somfy.cpp` around lines 1870–1975) can exceed 2 KB depending on size, with no indication to the client beyond the ESP-side `ESP_LOGE` line.

### Fix options (pick one later)

- **(a) Fail loud.** Keep truncation but emit a sentinel/error frame so the client knows the event was lost, instead of sending a malformed one.
- **(b) Split large events across frames.** Use the socket.io ack/chunk pattern to send an event in multiple frames when it wouldn't fit. Requires matching client-side reassembly.
- **(c) Raise `MAX_SOCK_RESPONSE`.** Cheapest, but just pushes the limit — doesn't eliminate the failure mode.

### Related

- Not the same code path as the `/controller` crash. Solve independently.
- Worth grepping for `JsonSockEvent` usages that iterate collections (see references in `Somfy.cpp`, `ESPNetwork.cpp`, `GitOTA.cpp`) to identify the most at-risk events.
2 changes: 1 addition & 1 deletion src/ConfigSettings.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
#include "WResp.h"

#ifndef FW_VERSION
#define FW_VERSION "v3.1.0" // Fallback if app_version.py script fails
#define FW_VERSION "v3.2.0" // Fallback if app_version.py script fails
#endif

enum class conn_types_t : byte {
Expand Down
11 changes: 9 additions & 2 deletions src/Somfy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,8 @@ void somfy_frame_t::decodeFrame(byte* frame) {
this->checksum = decoded[1] & 0b1111;
this->encKey = decoded[0];
// Lets first determine the protocol.
this->cmd = (somfy_commands)((decoded[1] >> 4));
this->rawCmd = decoded[1] >> 4;
this->cmd = (somfy_commands)(this->rawCmd);
if(this->cmd == somfy_commands::RTWProto) {
if(this->encKey >= 160) {
this->proto = radio_proto::RTS;
Expand All @@ -173,7 +174,7 @@ void somfy_frame_t::decodeFrame(byte* frame) {
}
else if(this->encKey >= 133) {
this->proto = radio_proto::RTW;
this->cmd = this->encKey == 133 ? somfy_commands::My : (somfy_commands)(this->encKey - 133);
this->cmd = (somfy_commands)(this->encKey - 132);
}
}
else this->proto = radio_proto::RTS;
Expand Down Expand Up @@ -4429,6 +4430,11 @@ bool Transceiver::receive(somfy_rx_t *rx) {
//Serial.printf("Processing receive %d\n", rx_queue.length);
rx_queue.pop(rx);
this->frame.decodeFrame(rx);
if(this->frame.valid) {
ESP_LOGI(TAG, "RX ADDR:%d CMD:%s RAW_CMD:0x%X KEY:0x%02X PROTO:%u",
this->frame.remoteAddress, translateSomfyCommand(this->frame.cmd).c_str(),
this->frame.rawCmd, this->frame.encKey, (uint8_t)this->frame.proto);
}
this->emitFrame(&this->frame, rx);
return this->frame.valid;
}
Expand All @@ -4442,6 +4448,7 @@ void Transceiver::emitFrame(somfy_frame_t *frame, somfy_rx_t *rx) {
json->addElem("address", (uint32_t)frame->remoteAddress);
json->addElem("rcode", (uint32_t)frame->rollingCode);
json->addElem("command", translateSomfyCommand(frame->cmd).c_str());
json->addElem("rawCmd", frame->rawCmd);
json->addElem("rssi", (int32_t)frame->rssi);
json->addElem("bits", rx->bit_length);
json->addElem("proto", static_cast<uint8_t>(frame->proto));
Expand Down
1 change: 1 addition & 0 deletions src/Somfy.h
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ struct somfy_frame_t {
uint8_t bitLength = 56;
uint16_t pulseCount = 0;
uint8_t stepSize = 0;
uint8_t rawCmd = 0;
void print();
void encode80BitFrame(byte *frame, uint8_t repeat);
byte calc80Checksum(byte b0, byte b1, byte b2);
Expand Down
11 changes: 11 additions & 0 deletions src/WResp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ void JsonSockEvent::_safecat(const char *val, bool escape) {
else strcat(this->buff, val);
if(escape) strcat(this->buff, "\"");
}
void BufferedJsonFormatter::setBuffer(char *b, size_t sz) {
this->buff = b;
this->buffSize = sz;
this->buff[0] = 0;
this->_nocomma = true;
this->_objects = 0;
this->_arrays = 0;
}
size_t BufferedJsonFormatter::length() const {
return strlen(this->buff);
}
void AsyncJsonResp::beginResponse(AsyncWebServerRequest *request, char *buff, size_t buffSize) {
this->_request = request;
this->buff = buff;
Expand Down
5 changes: 5 additions & 0 deletions src/WResp.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ class JsonFormatter {
void addElem(const char *name, const char *val);
void addElem(const char* name, uint64_t lval);
};
class BufferedJsonFormatter : public JsonFormatter {
public:
void setBuffer(char *b, size_t sz);
size_t length() const;
};
class AsyncJsonResp : public JsonFormatter {
protected:
void _safecat(const char *val, bool escape = false) override;
Expand Down
Loading
Loading