From cdfe2169452c2e367080fc021f09db93a89e7a21 Mon Sep 17 00:00:00 2001 From: dentra Date: Fri, 9 Aug 2024 17:46:15 +0300 Subject: [PATCH] settings: switch to preact, aupdate mqtt preset --- components/settings/__init__.py | 78 +++- components/settings/cpp.py | 4 +- components/settings/mqtt_component_accessor.h | 107 +++++ components/settings/presets.py | 47 ++- components/settings/settings.cpp | 39 +- components/settings/settings.h | 20 +- components/settings/settings.html | 395 ++---------------- components/settings/settings.js | 318 ++++++++++++++ 8 files changed, 618 insertions(+), 390 deletions(-) create mode 100644 components/settings/mqtt_component_accessor.h create mode 100644 components/settings/settings.js diff --git a/components/settings/__init__.py b/components/settings/__init__.py index 7b67878..bfcfe6f 100644 --- a/components/settings/__init__.py +++ b/components/settings/__init__.py @@ -4,14 +4,21 @@ import esphome.config_validation as cv from esphome import core from esphome.components import web_server, web_server_base -from esphome.const import CONF_ID, CONF_LAMBDA +from esphome.const import ( + CONF_AUTH, + CONF_ID, + CONF_LAMBDA, + CONF_PASSWORD, + CONF_USERNAME, +) from . import const, cpp, presets, var CODEOWNERS = ["@dentra"] -AUTO_LOAD = ["web_server_base", "json", "nvs"] +AUTO_LOAD = ["web_server_base", "json", "nvs", "dtu"] DEPENDENCIES = ["wifi"] +CONF_BASE_URL = "base_url" CONF_SETTINGS_VARIABLES = "settings_variables" CONF_VARIABLES = "variables" @@ -162,13 +169,46 @@ def variable_shorthand(config): cv.All(VARIABLE_DATA, variable_shorthand), ) -CONFIG_SCHEMA = cv.Schema( + +def _web_menu_schema(config): + schema = cv.Schema(config) + try: + from esphome.components import web_menu + + schema = schema.extend( + {cv.GenerateID(web_menu.CONF_WEB_MENU_ID): cv.use_id(web_menu.WebMenu)} + ) + except ImportError: + pass + + return schema + + +async def _web_menu_add_item(config, var, name): + try: + from esphome.components import web_menu + + if menu := await web_menu.get_web_menu_variable(config): + cg.add(menu.add_item(var.get_base_url(), name)) + cg.add(var.set_menu_url(menu.get_base_url())) + except ImportError: + pass + + +CONFIG_SCHEMA = _web_menu_schema( { cv.GenerateID(): cv.declare_id(cpp.Settings), cv.GenerateID(web_server_base.CONF_WEB_SERVER_BASE_ID): cv.use_id( web_server_base.WebServerBase, ), - cv.Required(CONF_VARIABLES): cv.Schema( + cv.Optional(CONF_BASE_URL): cv.string_strict, + cv.Optional(CONF_AUTH): cv.Schema( + { + cv.Required(CONF_USERNAME): cv.All(cv.string_strict, cv.Length(min=1)), + cv.Required(CONF_PASSWORD): cv.All(cv.string_strict, cv.Length(min=1)), + } + ), + cv.Optional(CONF_VARIABLES, default={}): cv.Schema( { cv.valid_name: cv.All( VARIABLE_SCHEMA, @@ -184,14 +224,17 @@ def variable_shorthand(config): ) +def _add_resource(filename: str, resurce_name: str = ""): + if not resurce_name: + resurce_name = filename.replace(".", "_").upper() + path = f"{os.path.dirname(__file__)}/{filename}" + with open(file=path, encoding="utf-8") as file: + web_server.add_resource_as_progmem(resurce_name, file.read()) + + # Run with low priority so that all initilization be doing first -@core.coroutine_with_priority(-200.0) +@core.coroutine_with_priority(-1000.0) async def to_code(config): - # for key, val in CORE.config["mqtt"].items(): - # print("to_code", key, val) - # # for k, v in val.items(): - # # print(" ", k, v) - presets.presets_init(config.get(CONF_PRESETS), config[CONF_VARIABLES]) web = await cg.get_variable(config[web_server_base.CONF_WEB_SERVER_BASE_ID]) @@ -204,9 +247,14 @@ async def to_code(config): # last step, loading settings await cpp.add_on_load(settings, vars, config.get(CONF_LAMBDA, None)) - path = f"{os.path.dirname(__file__)}/settings.html" - with open(file=path, encoding="utf-8") as html_file: - web_server.add_resource_as_progmem("SETTINGS_HTML", html_file.read()) + _add_resource("settings.html") + _add_resource("settings.js") + + if CONF_BASE_URL in config: + cg.add(settings.set_base_url(config[CONF_BASE_URL])) + + await _web_menu_add_item(config, settings, "Settings") - if 'web_server' not in core.CORE.config: - cg.add(settings.set_base_url("/")) + if CONF_AUTH in config: + cg.add(settings.set_username(config[CONF_AUTH][CONF_USERNAME])) + cg.add(settings.set_password(config[CONF_AUTH][CONF_PASSWORD])) diff --git a/components/settings/cpp.py b/components/settings/cpp.py index 51456b2..447b001 100644 --- a/components/settings/cpp.py +++ b/components/settings/cpp.py @@ -217,7 +217,9 @@ async def _setter(v: var.Var, nvs: cg.MockObj): sav = cg.MockObj("sav") if not callable(setter): - lam: core.Lambda = await cg.process_lambda(setter, []) + lam: cpp.LambdaExpression = await cg.process_lambda( + setter, parameters=[], capture="" + ) if len(lam.content.strip()) == 0: return None setter = cg.RawStatement(lam.content) diff --git a/components/settings/mqtt_component_accessor.h b/components/settings/mqtt_component_accessor.h new file mode 100644 index 0000000..7532dcf --- /dev/null +++ b/components/settings/mqtt_component_accessor.h @@ -0,0 +1,107 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_MQTT + +#include "esphome/core/application.h" +#include "esphome/components/mqtt/mqtt_client.h" + +#include "esphome/components/dtu/dtu.h" + +namespace esphome { +namespace settings { +class MQTTClientComponentAccessor : public mqtt::MQTTClientComponent { + public: + MQTTClientComponentAccessor() = delete; + + static MQTTClientComponentAccessor *make(mqtt::MQTTClientComponent *ref) { + return reinterpret_cast(ref); + } + + const std::string topic_prefix() const { + auto s = this->get_topic_prefix(); + if (!s.empty() && App.is_name_add_mac_suffix_enabled()) { + auto suffix = dtu::get_mac_suffix(); + if (str_endswith(s, suffix)) { + s.erase(s.size() - suffix.size()); + } + } + return s; + } + void topic_prefix(const std::string &topic_prefix) { + auto s = dtu::str_trim(topic_prefix, trim_pred_); + if (!s.empty() && App.is_name_add_mac_suffix_enabled()) { + auto suffix = dtu::get_mac_suffix(); + if (!str_endswith(s, suffix)) { + s.append(suffix); + } + } + this->set_topic_prefix(s); + } + + const std::string log_message_topic() const { return this->unprefix_(this->log_message_.topic); } + void log_message_topic(const std::string &topic) { + this->set_log_message_template(this->mk_topic_(topic, this->log_message_)); + } + + const std::string last_will_topic() const { return this->unprefix_(this->last_will_.topic); } + void last_will_topic(const std::string &topic) { this->set_last_will(this->mk_topic_(topic, this->last_will_)); } + + const std::string birth_message_topic() const { return this->unprefix_(this->birth_message_.topic); } + void birth_message_topic(const std::string &topic) { + this->set_birth_message(this->mk_topic_(topic, this->birth_message_)); + } + + const std::string shutdown_message_topic() const { return this->unprefix_(this->shutdown_message_.topic); } + void shutdown_message_topic(const std::string &topic) { + this->set_shutdown_message(this->mk_topic_(topic, this->shutdown_message_)); + } + + protected: + inline static bool trim_pred_(const char c) { return std::isspace(c) || c == '/'; } + + const std::string unprefix_(const std::string &topic) const { + auto s = topic; + if (!s.empty()) { + auto prefix = this->get_topic_prefix(); + prefix.append(1, '/'); + if (str_startswith(s, prefix)) { + s.erase(0, prefix.length()); + } else { + s.insert(s.cbegin(), '/'); + } + } + return s; + } + + // make topic with appending topic prefix. + mqtt::MQTTMessage mk_topic_(const std::string &topic, const mqtt::MQTTMessage &src) { + auto s = dtu::str_rtrim(topic, trim_pred_); + dtu::str_ltrim_ref(s); // trim only spaces to check slash later + if (!s.empty()) { + // check that user wants his own prefix + if (s.front() == '/') { + dtu::str_ltrim_ref(s, trim_pred_); + } else { + s.insert(s.cbegin(), '/'); + s.insert(0, this->get_topic_prefix()); + } + } + + return mqtt::MQTTMessage{ + .topic = s, + .payload = src.payload, + .qos = src.qos, + .retain = src.retain, + }; + } +}; + +inline MQTTClientComponentAccessor *mqtt_access(mqtt::MQTTClientComponent *ref) { + return MQTTClientComponentAccessor::make(ref); +} + +} // namespace settings +} // namespace esphome + +#endif diff --git a/components/settings/presets.py b/components/settings/presets.py index 4eb7f19..7ca38dc 100644 --- a/components/settings/presets.py +++ b/components/settings/presets.py @@ -215,8 +215,47 @@ }, "mqtt_topic_prefix": { const.CONF_VAR_TYPE: var.VT_STR, - const.CONF_VAR_GETTER: lambda _, o: o.get_topic_prefix(), - const.CONF_VAR_SETTER: lambda _, o: o.set_topic_prefix, + const.CONF_VAR_HELP: "The prefix used for all MQTT messages. Set to empty to disable publishing or subscribing of any MQTT topic unless it is explicitly configured", + const.CONF_VAR_GETTER: lambda _, o: _mqtt_access(o).topic_prefix(), + const.CONF_VAR_SETTER: lambda _, o: _mqtt_access(o).topic_prefix, + }, + "mqtt_birth_message": { + const.CONF_VAR_TYPE: var.VT_STR, + const.CONF_VAR_HELP: "The message to send when a connection to the broker is established", + const.CONF_VAR_GETTER: lambda _, o: _mqtt_access( + o + ).birth_message_topic(), + const.CONF_VAR_SETTER: lambda _, o: _mqtt_access( + o + ).birth_message_topic, + }, + "mqtt_will_message": { + const.CONF_VAR_TYPE: var.VT_STR, + const.CONF_VAR_HELP: "The message to send when the MQTT connection is dropped", + const.CONF_VAR_GETTER: lambda _, o: _mqtt_access( + o + ).last_will_topic(), + const.CONF_VAR_SETTER: lambda _, o: _mqtt_access(o).last_will_topic, + }, + "mqtt_shutdown_message": { + const.CONF_VAR_TYPE: var.VT_STR, + const.CONF_VAR_HELP: "The message to send when the node shuts down and the connection is closed cleanly", + const.CONF_VAR_GETTER: lambda _, o: _mqtt_access( + o, + ).shutdown_message_topic(), + const.CONF_VAR_SETTER: lambda _, o: _mqtt_access( + o + ).shutdown_message_topic, + }, + "mqtt_log_topic": { + const.CONF_VAR_TYPE: var.VT_STR, + const.CONF_VAR_HELP: " The topic to send MQTT log messages to. Set to empty for disable logging", + const.CONF_VAR_GETTER: lambda _, o: _mqtt_access( + o + ).log_message_topic(), + const.CONF_VAR_SETTER: lambda _, o: _mqtt_access( + o + ).log_message_topic, }, "mqtt_reboot_timeout": { const.CONF_VAR_TYPE: var.VT_TIMEOUT, @@ -344,6 +383,10 @@ def _wifi_network_setter(index: int, c: dict, wifi_comp: cg.MockObj) -> cg.State return cgp.CodeBlock(None, None, ss) +def _mqtt_access(mqtt_comp: cg.MockObj): + return cg.MockObj("settings::mqtt_access", "->")(mqtt_comp) + + def _get_presets(cpresets: list[str]) -> OrderedDict[str, Any]: presets = OrderedDict() diff --git a/components/settings/settings.cpp b/components/settings/settings.cpp index 9d8834a..3a46f93 100644 --- a/components/settings/settings.cpp +++ b/components/settings/settings.cpp @@ -52,6 +52,10 @@ bool Settings::canHandle(AsyncWebServerRequest *request) { void Settings::handleRequest(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) SETTINGS_TRACE(TAG, "Handle request method %u, url: %s", request->method(), request->url().c_str()); + if (!request->authenticate(this->username_, this->password_)) { + return request->requestAuthentication(); + } + if (request->method() == HTTP_POST) { if (request->url() == this->url_("reset")) { this->handle_reset_(request); @@ -66,9 +70,15 @@ void Settings::handleRequest(AsyncWebServerRequest *request) { // NOLINT(readab } if (request->url() == this->url_("settings.json")) { - this->handle_load_(request); + this->handle_json_(request); return; } + + if (request->url() == this->url_("settings.js")) { + this->handle_js_(request); + return; + } + #ifdef USE_ARDUINO // arduino returns String but not std::string if (!request->url().endsWith("/")) @@ -80,10 +90,17 @@ void Settings::handleRequest(AsyncWebServerRequest *request) { // NOLINT(readab return; } - this->handle_base_(request); + this->handle_html_(request); +} + +void Settings::handle_js_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) + auto *response = request->beginResponse_P(200, "text/javascript", ESPHOME_WEBSERVER_SETTINGS_JS, + ESPHOME_WEBSERVER_SETTINGS_JS_SIZE); + response->addHeader("Content-Encoding", "gzip"); + request->send(response); } -void Settings::handle_base_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) +void Settings::handle_html_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) auto *response = request->beginResponse_P(200, "text/html", ESPHOME_WEBSERVER_SETTINGS_HTML, ESPHOME_WEBSERVER_SETTINGS_HTML_SIZE); response->addHeader("Content-Encoding", "gzip"); @@ -144,12 +161,13 @@ std::string Settings::get_json_value_(const VarInfo &v) const { } } -void Settings::handle_load_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) +void Settings::handle_json_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) SETTINGS_TRACE(TAG, "Handle json..."); this->nvs_.open(NVS_NS); auto s = JsonWriter(request->beginResponseStream("application/json")); s.start_object(); s.add_kv("t", App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name()); + s.add_kv("m", this->menu_url_); s.start_array("v"); bool is_first = true; // NOLINT(misc-const-correctness) for (auto const &x : this->items_) { @@ -262,7 +280,7 @@ bool Settings::save_pv_(const VarInfo &x, const char *param) const { } void Settings::handle_save_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) - SETTINGS_TRACE(TAG, "Saving changes..."); + ESP_LOGD(TAG, "Saving changes..."); this->nvs_.open(NVS_NS, true); size_t changes = 0; @@ -290,13 +308,18 @@ void Settings::handle_save_(AsyncWebServerRequest *request) { // NOLINT(readabi } void Settings::handle_reset_(AsyncWebServerRequest *request) { // NOLINT(readability-non-const-parameter) - global_preferences->reset(); - this->reboot_(); + ESP_LOGD(TAG, "Resetting device to factory..."); + this->redirect_home_(request); + + if (request->arg("confirm") == "on") { + global_preferences->reset(); + this->reboot_(); + } } void Settings::reboot_() { - this->set_timeout(1000, []() { App.safe_reboot(); }); + this->set_timeout(100, []() { App.safe_reboot(); }); } void Settings::load(void (*on_load)(const nvs_flash::NvsFlash &nvs)) { diff --git a/components/settings/settings.h b/components/settings/settings.h index 5cb8b0c..5eb21e0 100644 --- a/components/settings/settings.h +++ b/components/settings/settings.h @@ -10,6 +10,9 @@ extern const uint8_t ESPHOME_WEBSERVER_SETTINGS_HTML[] PROGMEM; extern const size_t ESPHOME_WEBSERVER_SETTINGS_HTML_SIZE; +extern const uint8_t ESPHOME_WEBSERVER_SETTINGS_JS[] PROGMEM; +extern const size_t ESPHOME_WEBSERVER_SETTINGS_JS_SIZE; + namespace esphome { namespace settings { @@ -67,10 +70,7 @@ class Settings : public AsyncWebHandler, public Component { void dump_config() override; void setup() override; - float get_setup_priority() const override { - // After WiFi - return setup_priority::WIFI - 1.0f; - } + float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } bool canHandle(AsyncWebServerRequest *request) override; void handleRequest(AsyncWebServerRequest *request) override; @@ -80,14 +80,22 @@ class Settings : public AsyncWebHandler, public Component { void load(void (*on_load)(const nvs_flash::NvsFlash &nvs)); void set_base_url(const char *value) { this->base_url_ = value; } + const char *get_base_url() const { return this->base_url_.c_str(); } + void set_username(const char *username) { this->username_ = username; } + void set_password(const char *password) { this->password_ = password; } + void set_menu_url(const char *value) { this->menu_url_ = value; } protected: nvs_flash::NvsFlash nvs_; + const char *menu_url_{}; + const char *username_{}; + const char *password_{}; std::string base_url_{"/settings"}; web_server_base::WebServerBase *base_{}; std::vector items_; - void handle_base_(AsyncWebServerRequest *request); - void handle_load_(AsyncWebServerRequest *request); + void handle_html_(AsyncWebServerRequest *request); + void handle_js_(AsyncWebServerRequest *request); + void handle_json_(AsyncWebServerRequest *request); void handle_save_(AsyncWebServerRequest *request); void handle_reset_(AsyncWebServerRequest *request); void redirect_home_(AsyncWebServerRequest *request); diff --git a/components/settings/settings.html b/components/settings/settings.html index ba78d5b..0178107 100644 --- a/components/settings/settings.html +++ b/components/settings/settings.html @@ -1,360 +1,39 @@ - - - - - - - - - - - -
-
Loading settings. Please wait…
- - - - - - - - - - - -
- -
-
Factory Reset confirmation
-

- CAUTION: All credentials, global variables, counters and saved states stored in - non-volatile memory will be lost with no chance of recovering them. -

-

- This will also reset the Wi-Fi settings, which will make the device offline. - You'll need to be in close proximity to your device to configure it again using a - built-in WiFi access point and captive portal. -

-

Do you still really want to proceed?

-

- - Yes, I confirm that I am aware of the effect. -

-
- - -
-
-
- - - \ No newline at end of file + + + + + + + + + +
+
Loading settings. Please wait…
+
+ + diff --git a/components/settings/settings.js b/components/settings/settings.js new file mode 100644 index 0000000..938c885 --- /dev/null +++ b/components/settings/settings.js @@ -0,0 +1,318 @@ +// https://github.com/mujahidfa/preact-htm-signals-standalone +import { + html, + render, + signal, + Component, + effect, + computed, +} from "https://cdn.jsdelivr.net/npm/preact-htm-signals-standalone/dist/standalone.js" + +// import { html, render, Component } from "https://esm.sh/htm/preact" +// import { signal, useSignal, computed, effect } from "https://esm.sh/@preact/signals" + +function transformSections(source) { + const defaults = { + value: "", + min: "", + max: "", + help: "", + } + + const types = { + 1: "int", + 2: "int", + 3: "int", + 4: "int", + 5: "int", + 6: "int", + 7: "int", + 8: "int", + 9: "str", + 10: "bool", + 11: "float", + 12: "float", + 13: "password", + 14: "mac", + 15: "int", + 16: "timeout", + } + + const sections = new Map() + sections.set("", []) // empty section is always first + for (const val of source) { + const section = val.section || "" + for (const def in defaults) { + if (!(def in val)) { + val[def] = defaults[def] + } + } + val.type = types[val.type] || "str" + if ((val.name || "").length == 0) { + val.name = val.key + } + const help = val.help || "" + if (help.length > 0 && !help.endsWith(".")) { + val.help = help + "." + } + var current = sections.get(section) + if (!current) { + current = [] + sections.set(section, current) + } + val.id = val.key + current.push(val) + } + + return sections +} + +const rfc_dialog = signal(false) +const changes = signal([]) +const cchanges = computed(() => changes.value.length) + +function FormFields(props) { + function FormSection(props) { + if (props.name.length > 0) { + return html`
+ ${props.name} + ${props.fields} +
` + } + return props.fields + } + + class FormField extends Component { + inputs = { + str: (props, onChange) => + html` 0} + aria-invalid=${this.state.invalid} + value=${this.state.value} + onChange=${onChange} + />`, + password: (props, onChange) => + html``, + mac: (props, onChange) => + html``, + bool: (props, onChange) => + html``, + int: (props, onChange) => + html``, + timeout: (props, onChange) => + html``, + float: (props, onChange) => + html``, + } + + constructor(props) { + super(props) + this.state = { value: props.value, name: "" } + } + + onChange(e) { + const target = e.target + const valid = target.validity.valid + const value = target.type == "checkbox" ? target.checked : target.value + const id = this.props.id + const name = valid && this.props.value != value ? id : "" + const new_changes = changes.value.filter((v) => v != id) + if (name) { + new_changes.push(id) + } + changes.value = new_changes + this.setState({ value: value, name: name, invalid: !valid }) + } + + render(props) { + return html`` + } + } + + const res = [] + for (const [section, vars] of props.sections) { + if (vars.length > 0) { + const fields = vars.map((item) => html`<${FormField} ...${item} />`) + res.push(html`<${FormSection} name="${section}" fields="${fields}" />`) + } + } + return res +} + +class FactoryResetConfirm extends Component { + form = null + setForm = (form) => (this.form = form) + + onChange = (e) => { + this.setState({ checked: !this.state.checked }) + } + + onCancel = (e) => { + e.preventDefault() + rfc_dialog.value = false + this.setState({ checked: false }) + } + + onSubmit = (e) => { + if (this.form) { + this.form.submit() + rfc_dialog.value = false + this.setState({ checked: false }) + } + } + + render(_, { checked }) { + return html` +
+ +
+
Factory Reset confirmation
+

+ CAUTION: All credentials, global variables, counters and saved states stored in non-volatile memory will + be lost with no chance of recovering them. +

+

+ This will also reset the Wi-Fi settings, which will make the device offline. You'll need to be in close + proximity to your device to configure it again using a built-in WiFi access point and captive portal. +

+

+ + Yes, I confirm that I am aware of the effect. +

+
+ + +
+
+
+
+ ` + } +} + +class AppForm extends Component { + form = null + + constructor(props) { + super(props) + } + + setForm = (form) => (this.form = form) + + onSubmit = (e) => { + e.preventDefault() + if (this.form && this.form.checkValidity()) { + this.form.submit() + } + } + + onMainMenu = (e) => { + e.preventDefault() + window.location.href = this.props.menu + } + + onFactoryReset = (e) => { + e.preventDefault() + rfc_dialog.value = true + } + + shouldComponentUpdate = (nextProps, nextState) => { + return false + } + + submitButton = (props) => { + return html`` + } + + render(props) { + return html`
+
+ <${FormFields} sections=${props.sections} /> +
+ <${this.submitButton} /> + + ${props.menu && html``} +
+
+
` + } +} + +function App(props) { + return html`
+

${props.title}

+ <${AppForm} sections=${props.sections} menu=${props.menu} /> + <${FactoryResetConfirm} /> +
` +} + +const data = await (await fetch("settings.json")).json() +document.title = data.t + " settings" +render(html`<${App} title=${document.title} sections=${transformSections(data.v)} menu=${data.m} />`, document.body)