Skip to content

Commit 947acb8

Browse files
author
Maksym Shtembuliak
committed
Release 0.1.10: add unified device configuration
1 parent f58521a commit 947acb8

23 files changed

Lines changed: 1600 additions & 76 deletions

‎.github/workflows/ci.yml‎

Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -19,29 +19,14 @@ jobs:
1919
run: python -m pip install "esphome==${ESPHOME_VERSION}" pyyaml
2020
- name: Host tests
2121
run: make -C host_tests test
22+
- name: Protocol cross-check
23+
run: python host_tests/cross_check.py
2224
- name: Validate YAML and docs links
23-
run: |
24-
python - <<'PY'
25-
from pathlib import Path
26-
import re
27-
import yaml
28-
29-
class Loader(yaml.SafeLoader):
30-
pass
31-
Loader.add_constructor("!secret", lambda loader, node: loader.construct_scalar(node))
32-
for path in sorted(Path("examples").glob("*.yaml")):
33-
if path.name == "secrets.yaml":
34-
raise SystemExit("examples/secrets.yaml must not be committed")
35-
yaml.load(path.read_text(), Loader=Loader)
36-
37-
link_re = re.compile(r"\[[^\]]+\]\(([^)]+)\)")
38-
for path in [Path("README.md"), Path("README.uk.md"), *Path("docs").glob("*.md")]:
39-
for match in link_re.finditer(path.read_text()):
40-
target = match.group(1).split("#", 1)[0]
41-
if not target or "://" in target:
42-
continue
43-
if not (path.parent / target).exists():
44-
raise SystemExit(f"{path}: broken link {target}")
45-
PY
46-
- name: Compile release presets
47-
run: python tools/build_web_release.py --compile --local-source --version ci
25+
run: python tools/validate_repo.py
26+
- name: Compile release presets and YAML-only profiles
27+
run: >-
28+
python tools/build_web_release.py
29+
--compile
30+
--compile-yaml-profiles
31+
--local-source
32+
--version ci

‎.github/workflows/release.yml‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,31 @@ jobs:
2828
run: python -m pip install "esphome==${ESPHOME_VERSION}" pyyaml
2929
- name: Host tests
3030
run: make -C host_tests test
31+
- name: Protocol cross-check
32+
run: python host_tests/cross_check.py
33+
- name: Validate YAML and docs links
34+
run: python tools/validate_repo.py
3135
- name: Build web release
32-
run: python tools/build_web_release.py --compile --local-source --version "${GITHUB_REF_NAME}"
36+
run: >-
37+
python tools/build_web_release.py
38+
--compile
39+
--compile-yaml-profiles
40+
--local-source
41+
--version "${GITHUB_REF_NAME}"
42+
- name: Generate checksums
43+
run: >-
44+
sha256sum
45+
dist/web-release/web/firmware/*.bin
46+
dist/web-release/web/manifest-*.json
47+
> dist/web-release/SHA256SUMS
3348
- name: Upload release artifacts
3449
uses: softprops/action-gh-release@v3
3550
with:
51+
generate_release_notes: true
3652
files: |
3753
dist/web-release/web/firmware/*.bin
3854
dist/web-release/web/manifest-*.json
55+
dist/web-release/SHA256SUMS
3956
- name: Upload Pages artifact
4057
uses: actions/upload-pages-artifact@v5
4158
with:

‎CHANGELOG.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,54 @@ All notable changes to this project are documented here. The format is based on
44
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
55
[Semantic Versioning](https://semver.org/).
66

7+
## [0.1.10] - 2026-07-10
8+
9+
### Added
10+
- **Unified local configuration page.** `/` now combines Wi-Fi provisioning,
11+
Home Assistant endpoint settings, complete inverter UART framing and web OTA.
12+
It is available on both the normal LAN address and `192.168.4.1` in setup mode.
13+
- **Complete persisted UART framing.** ESP8266 and ESP32 now persist data bits,
14+
parity and stop bits together with baud rate. Existing baud-only preferences
15+
migrate automatically. On BK72xx, UART settings remain build-time-only and can
16+
be changed only by rebuilding and reflashing the firmware.
17+
- **Hardware identity overview.** The page shows the compiled TX/RX and RS485
18+
direction/status/communication LED pins, collector PN and ESPHome hostname.
19+
Build-time fields remain read-only until a platform-safe boot-time
20+
hardware-profile mechanism is available.
21+
- **Interactive Wi-Fi scan.** Nearby networks retain the stock captive-portal
22+
behavior: SSID, a signal-strength indicator and lock state are displayed, and
23+
selecting a row copies its SSID into the Wi-Fi form. Exact RSSI is available
24+
from the indicator tooltip. The configured network remains visible and is
25+
clearly marked as connected or currently unavailable.
26+
- **Configurable collector PN.** The default PN remains protected by a checked
27+
toggle. Disabling it allows a validated custom PN; re-enabling it restores the
28+
default. Identity changes are persisted and applied after a safe restart.
29+
- **Local page translations.** English, Ukrainian and Russian can be selected on
30+
the device page. The browser remembers the selected language.
31+
- **ESPHome Dashboard adoption.** Ready-made ESP8266, ESP32 and BK72xx builds
32+
publish project metadata and a board-specific `dashboard_import` package over
33+
mDNS. The Dashboard can create a local YAML for a device flashed from a
34+
prebuilt binary; no server-specific ownership key is required.
35+
- **UART / RS485 diagnostics.** The local page shows passive live counters for
36+
transmitted bytes and requests, total physical receive bytes and the age of
37+
the last TX/RX activity. Developer-only response/stale counters remain in the
38+
diagnostics JSON. Counters reset at boot and never influence transport behavior.
39+
- **Dark and light themes.** The local page uses the dark theme by default and
40+
remembers the user's theme toggle alongside the language preference.
41+
42+
### Fixed
43+
- The always-on local web server is initialized only after the network stack is
44+
ready. Starting AsyncTCP from the component's pre-Wi-Fi setup priority caused
45+
an ESP32 boot loop; captive-portal registration remains available before Wi-Fi.
46+
47+
### Changed
48+
- Collector identity, Home Assistant endpoint and UART framing now have separate
49+
save actions. PN changes require explicit browser confirmation and explain the
50+
Home Assistant re-add requirement.
51+
- UART persistence wording now explicitly states that settings are stored in
52+
non-volatile device memory and used after every restart.
53+
- `BRIDGE_VERSION` bumped to `0.1.10`.
54+
755
## [0.1.8] - 2026-07-04
856

957
### Fixed
@@ -114,6 +162,8 @@ Home Assistant integration. Highlights:
114162
- Status LED indication and the `AT+VDTU` virtual-bridge capability probe.
115163
- Web installer (GitHub Pages) for the ESP8266 and ESP32 presets.
116164

165+
[0.1.10]: https://github.com/groove-max/esp-eybond-collector/compare/v0.1.8...v0.1.10
166+
[0.1.8]: https://github.com/groove-max/esp-eybond-collector/compare/v0.1.7...v0.1.8
117167
[0.1.7]: https://github.com/groove-max/esp-eybond-collector/compare/v0.1.6...v0.1.7
118168
[0.1.6]: https://github.com/groove-max/esp-eybond-collector/compare/v0.1.5...v0.1.6
119169
[0.1.5]: https://github.com/groove-max/esp-eybond-collector/compare/v0.1.4...v0.1.5

‎README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,31 @@ For the custom ESP bridge, EyeBond Local also exposes **Change inverter UART spe
180180

181181
The bridge can use normal ESPHome Wi-Fi setup and captive portal behavior. When it opens the setup portal, its temporary Wi-Fi network is named like its virtual collector PN, for example `V00000...`.
182182

183+
Open `http://<collector-ip>/` on the normal local network. The unified page
184+
configures Wi-Fi, the Home Assistant endpoint and inverter UART, and can upload
185+
an OTA firmware image. The same page is available at `http://192.168.4.1/` while
186+
connected to the setup network.
187+
It also shows the collector PN, compiled pin mapping and hostname. Nearby Wi-Fi
188+
networks remain selectable and include a signal indicator, exact RSSI tooltip
189+
and lock state; the configured network is marked even when it is temporarily
190+
unavailable. The PN can be switched from its protected default to a validated
191+
custom value. Changing it restarts the collector and requires removing and
192+
re-adding an existing Home Assistant device. The page is available
193+
in English, Ukrainian and Russian. It uses a dark theme by default and remembers
194+
the light/dark toggle. Passive UART / RS485 diagnostics show TX/RX byte counters,
195+
request count and the age of the last activity.
196+
ESP8266/ESP32 builds can change and persist the complete UART framing there.
197+
BK72xx shows UART as read-only because that platform requires a YAML change and
198+
reflash. Custom YAML builds enable the page with `web_configuration: true`
199+
under `eybond_collector:` and must also include `captive_portal:`.
200+
201+
Ready-made board profiles also support ESPHome Dashboard adoption. A device
202+
flashed from a prebuilt binary advertises its project, board and package URL over
203+
mDNS, allowing the Dashboard to create a local YAML. This uses ESPHome
204+
`dashboard_import`; there is no Dashboard-specific pairing key to enter on the
205+
collector page. The Dashboard host must be able to receive the device's mDNS
206+
announcements.
207+
183208
EyeBond Local can also change collector Wi-Fi settings for the bridge. If the new credentials are wrong, the bridge should fall back to its setup portal instead of becoming permanently unreachable.
184209

185210
The nearby-network list can show only the current network on the first read. Refresh after a few seconds to see the full scan result.

‎README.uk.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,33 @@ select:
180180

181181
Міст підтримує звичайне налаштування Wi-Fi через ESPHome і сторінку налаштування. Коли міст відкриває сторінку налаштування, його тимчасова Wi-Fi-мережа має назву як віртуальний PN колектора, наприклад `V00000...`.
182182

183+
У звичайній локальній мережі відкрийте `http://<ip-колектора>/`. Єдина сторінка
184+
налаштовує Wi-Fi, endpoint Home Assistant та UART інвертора, а також дозволяє
185+
завантажити OTA-прошивку. Після підключення до точки доступу налаштування вона доступна
186+
за `http://192.168.4.1/`. На цій
187+
сторінці можна налаштувати endpoint Home Assistant і параметри UART інвертора.
188+
Там також показано PN колектора, піни поточної збірки та hostname. Типовий PN
189+
захищено перемикачем; після його вимкнення можна задати власний валідний PN.
190+
Його зміна перезапускає колектор і потребує видалити та повторно додати вже
191+
створений пристрій у Home Assistant.
192+
Список доступних Wi-Fi-мереж показує індикатор сигналу й ознаку захисту;
193+
точний RSSI доступний у підказці, натискання мережі копіює її SSID у форму, а
194+
налаштована мережа залишається позначеною навіть за тимчасової відсутності зв’язку.
195+
Сторінка має англійську, українську та російську мови.
196+
За замовчуванням використовується темна тема; перемикач світлої/темної теми
197+
запам’ятовує вибір. Пасивна діагностика UART / RS485 показує загальні лічильники
198+
TX/RX, кількість запитів та час останньої активності.
199+
На ESP8266/ESP32 зберігається повний UART framing.
200+
Для BK72xx параметри UART доступні лише для перегляду: швидкість змінюється в
201+
YAML із повторним прошиванням. У власному YAML сторінка вмикається через
202+
`web_configuration: true` у `eybond_collector:` і потребує секції
203+
`captive_portal:`.
204+
205+
Готові профілі плат також підтримують додавання до ESPHome Dashboard. Пристрій,
206+
прошитий готовим бінарником, публікує через mDNS проєкт, тип плати та URL пакета,
207+
після чого Dashboard може створити локальний YAML. Окремого ключа прив’язки до
208+
Dashboard немає. Сервер Dashboard має отримувати mDNS-повідомлення пристрою.
209+
183210
EyeBond Local також може змінювати Wi-Fi-налаштування моста. Якщо нові дані неправильні, міст має перейти на сторінку налаштування, а не зникнути назавжди.
184211

185212
Список мереж може спочатку показати тільки поточну мережу. Оновіть через кілька секунд, щоб побачити повний результат сканування.

‎components/eybond_collector/__init__.py‎

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import esphome.final_validate as fv
1414
from esphome import pins
1515
from esphome.components import uart
16-
from esphome.const import CONF_ID
16+
from esphome.const import CONF_ID, CONF_NUMBER, CONF_RX_PIN, CONF_TX_PIN, CONF_UART_ID
1717
from esphome.core import CORE
1818

1919
CODEOWNERS = ["@groove-max"]
@@ -33,6 +33,7 @@
3333
CONF_STATUS_LED_PIN = "status_led_pin"
3434
CONF_COM_LED_PIN = "com_led_pin"
3535
CONF_BLE_PROVISIONING = "ble_provisioning"
36+
CONF_WEB_CONFIGURATION = "web_configuration"
3637

3738
eybond_collector_ns = cg.esphome_ns.namespace("eybond_collector")
3839
EybondCollector = eybond_collector_ns.class_("EybondCollector", cg.Component, uart.UARTDevice)
@@ -68,6 +69,7 @@ def _validate_pn(value):
6869
cv.Optional(CONF_STATUS_LED_PIN): pins.gpio_output_pin_schema,
6970
cv.Optional(CONF_COM_LED_PIN): pins.gpio_output_pin_schema,
7071
cv.Optional(CONF_BLE_PROVISIONING, default=False): cv.boolean,
72+
cv.Optional(CONF_WEB_CONFIGURATION, default=False): cv.boolean,
7173
}
7274
)
7375
.extend(cv.COMPONENT_SCHEMA)
@@ -77,20 +79,24 @@ def _validate_pn(value):
7779

7880

7981
def _final_validate(config):
80-
"""ble_provisioning needs ESP32 BLE and the esp32_ble_server GATT server."""
81-
if not config.get(CONF_BLE_PROVISIONING):
82-
return config
83-
if not CORE.is_esp32:
84-
raise cv.Invalid(
85-
"ble_provisioning is only supported on ESP32 (it needs on-chip BLE); "
86-
"ESP8266 and BK72xx/LibreTiny have no usable BLE server."
87-
)
8882
full_config = fv.full_config.get()
89-
if "esp32_ble_server" not in full_config:
83+
if config.get(CONF_BLE_PROVISIONING):
84+
if not CORE.is_esp32:
85+
raise cv.Invalid(
86+
"ble_provisioning is only supported on ESP32 (it needs on-chip BLE); "
87+
"ESP8266 and BK72xx/LibreTiny have no usable BLE server."
88+
)
89+
if "esp32_ble_server" not in full_config:
90+
raise cv.Invalid(
91+
"ble_provisioning requires an 'esp32_ble_server:' entry in your YAML — "
92+
"it provides the BLE GATT server the bridge attaches to. Add an empty "
93+
"'esp32_ble_server:' block (see examples/esp32-ble.yaml)."
94+
)
95+
if config.get(CONF_WEB_CONFIGURATION) and "captive_portal" not in full_config:
9096
raise cv.Invalid(
91-
"ble_provisioning requires an 'esp32_ble_server:' entry in your YAML — "
92-
"it provides the BLE GATT server the bridge attaches to. Add an empty "
93-
"'esp32_ble_server:' block (see examples/esp32-ble.yaml)."
97+
"web_configuration requires a 'captive_portal:' entry; the settings "
98+
"page reuses its stock Wi-Fi setup handlers on both the normal LAN "
99+
"and the fallback access point"
94100
)
95101
return config
96102

@@ -103,6 +109,21 @@ async def to_code(config):
103109
await cg.register_component(var, config)
104110
await uart.register_uart_device(var, config)
105111

112+
# Keep the generated UART component authoritative. These values are only
113+
# metadata for the local configuration page; arbitrary pin remapping is not
114+
# universally reloadable by ESPHome's platform UART implementations.
115+
uart_tx_pin = -1
116+
uart_rx_pin = -1
117+
for uart_config in CORE.config.get("uart", []):
118+
if uart_config.get(CONF_ID) != config[CONF_UART_ID]:
119+
continue
120+
if CONF_TX_PIN in uart_config:
121+
uart_tx_pin = uart_config[CONF_TX_PIN][CONF_NUMBER]
122+
if CONF_RX_PIN in uart_config:
123+
uart_rx_pin = uart_config[CONF_RX_PIN][CONF_NUMBER]
124+
break
125+
cg.add(var.set_uart_pin_numbers(uart_tx_pin, uart_rx_pin))
126+
106127
if CONF_PN in config:
107128
cg.add(var.set_pn(config[CONF_PN]))
108129
cg.add(var.set_udp_port(config[CONF_UDP_PORT]))
@@ -126,3 +147,6 @@ async def to_code(config):
126147
if config[CONF_BLE_PROVISIONING]:
127148
cg.add_define("USE_EYBOND_BLE")
128149
cg.add(var.set_ble_provisioning(True))
150+
if config[CONF_WEB_CONFIGURATION]:
151+
cg.add_define("USE_EYBOND_WEB_CONFIG")
152+
cg.add(var.set_web_configuration(True))

‎components/eybond_collector/core.cpp‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ constexpr size_t HEARTBEAT_PN_LEN = 14;
1010
constexpr size_t MAX_UART_RESPONSE = 512;
1111
} // namespace
1212

13+
bool valid_collector_pn(const std::string &value) {
14+
if (value.size() != 14 && value.size() != 18) return false;
15+
if (value[0] < 'A' || value[0] > 'Z') return false;
16+
for (size_t i = 1; i < value.size(); i++) {
17+
if (value[i] < '0' || value[i] > '9') return false;
18+
}
19+
return true;
20+
}
21+
1322
bool parse_uart_settings(const std::string &value, UartSettings *out) {
1423
// Split on commas into up to 4 fields: baud, data, stop, parity.
1524
std::string fields[4];
@@ -39,10 +48,16 @@ bool parse_uart_settings(const std::string &value, UartSettings *out) {
3948
UartSettings settings;
4049
settings.baud = baud;
4150
if (count >= 2 && !fields[1].empty()) {
42-
settings.data_bits = static_cast<uint8_t>(std::atoi(fields[1].c_str()));
51+
if (fields[1].size() != 1 || fields[1][0] < '5' || fields[1][0] > '8') {
52+
return false;
53+
}
54+
settings.data_bits = static_cast<uint8_t>(fields[1][0] - '0');
4355
}
4456
if (count >= 3 && !fields[2].empty()) {
45-
settings.stop_bits = static_cast<uint8_t>(std::atoi(fields[2].c_str()));
57+
if (fields[2] != "1" && fields[2] != "2") {
58+
return false;
59+
}
60+
settings.stop_bits = static_cast<uint8_t>(fields[2][0] - '0');
4661
}
4762
if (count >= 4 && !fields[3].empty()) {
4863
std::string parity = fields[3];
@@ -53,8 +68,10 @@ bool parse_uart_settings(const std::string &value, UartSettings *out) {
5368
settings.parity = UartParity::EVEN;
5469
} else if (parity == "ODD" || parity == "O") {
5570
settings.parity = UartParity::ODD;
56-
} else {
71+
} else if (parity == "NONE" || parity == "N") {
5772
settings.parity = UartParity::NONE;
73+
} else {
74+
return false;
5875
}
5976
}
6077
*out = settings;
@@ -100,14 +117,30 @@ bool parse_server_endpoint(const std::string &value, std::string *host, uint16_t
100117
return false;
101118
}
102119
}
103-
if (parsed_port == 0 || host_text.empty()) {
120+
if (!valid_server_endpoint(host_text, static_cast<uint16_t>(parsed_port))) {
104121
return false;
105122
}
106123
*host = host_text;
107124
*port = static_cast<uint16_t>(parsed_port);
108125
return true;
109126
}
110127

128+
bool valid_server_endpoint(const std::string &host, uint16_t port) {
129+
// PersistedEndpoint stores a NUL-terminated host in char[64]. Reject values
130+
// that would otherwise be silently truncated after appearing to save.
131+
if (port == 0 || host.empty() || host.size() > 63 || host.front() == '.' || host.back() == '.') {
132+
return false;
133+
}
134+
for (const char ch : host) {
135+
// Underscore is intentionally accepted: known factory endpoints include
136+
// names such as dtu_ess.eybond.com.
137+
if (!(std::isalnum(static_cast<unsigned char>(ch)) || ch == '.' || ch == '-' || ch == '_')) {
138+
return false;
139+
}
140+
}
141+
return true;
142+
}
143+
111144
CollectorCore::CollectorCore(Actions *actions, CollectorProfile profile, CoreConfig config)
112145
: actions_(actions), profile_(std::move(profile)), config_(config) {
113146
splitter_.on_at_line = [this](const std::string &line) { this->handle_at_line_(line); };

0 commit comments

Comments
 (0)