mirror of
https://github.com/Laxilef/OTGateway.git
synced 2025-12-25 17:43:35 +05:00
Compare commits
14 Commits
1.5.3
...
ae58cfecb4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae58cfecb4 | ||
|
|
1f72286be7 | ||
|
|
40767fbb99 | ||
|
|
cb34e073a7 | ||
|
|
8ba73093af | ||
|
|
1965ca671e | ||
|
|
0d1873ec77 | ||
|
|
38ec56fb33 | ||
|
|
bb7c3eeba3 | ||
|
|
0c778d4c7f | ||
|
|
2e5e5e59a8 | ||
|
|
e1623e7b63 | ||
|
|
80b91d9a01 | ||
|
|
25b70e4db5 |
BIN
build/firmware_nodemcu_8266_1.5.3.elf
Normal file
BIN
build/firmware_nodemcu_8266_1.5.3.elf
Normal file
Binary file not shown.
BIN
build/Алгоритм от OTThing + График.zip
Normal file
BIN
build/Алгоритм от OTThing + График.zip
Normal file
Binary file not shown.
@@ -48,6 +48,10 @@ let paths = {
|
||||
{
|
||||
src: 'src_data/images/*.*',
|
||||
dest: 'data/static/images/'
|
||||
},
|
||||
{
|
||||
src: 'src_data/*.txt',
|
||||
dest: 'data/static/'
|
||||
}
|
||||
],
|
||||
pages: {
|
||||
|
||||
@@ -16,14 +16,16 @@ public:
|
||||
float Kn = 0.0;
|
||||
float Kk = 0.0;
|
||||
float Kt = 0.0;
|
||||
float Ke = 1.3;
|
||||
|
||||
Equitherm() = default;
|
||||
|
||||
// kn, kk, kt
|
||||
Equitherm(float new_kn, float new_kk, float new_kt) {
|
||||
// kn, kk, kt, Ke
|
||||
Equitherm(float new_kn, float new_kk, float new_kt, float new_ke) {
|
||||
Kn = new_kn;
|
||||
Kk = new_kk;
|
||||
Kt = new_kt;
|
||||
Ke = new_ke;
|
||||
}
|
||||
|
||||
// лимит выходной величины
|
||||
@@ -34,7 +36,7 @@ public:
|
||||
|
||||
// возвращает новое значение при вызове
|
||||
datatype getResult() {
|
||||
datatype output = getResultN() + getResultK() + getResultT();
|
||||
datatype output = getResultN() + Kk + getResultT();
|
||||
output = constrain(output, _minOut, _maxOut); // ограничиваем выход
|
||||
return output;
|
||||
}
|
||||
@@ -43,21 +45,30 @@ private:
|
||||
unsigned short _minOut = 20, _maxOut = 90;
|
||||
|
||||
// температура контура отопления в зависимости от наружной температуры
|
||||
// datatype getResultN() {
|
||||
// Kntemp = Kn*3.3; //Подгонка под типовые кривые
|
||||
// float tempDiff = targetTemp - outdoorTemp;
|
||||
// if (tempDiff < 0) tempDiff = 0;
|
||||
// float T_rad = targetTemp + pow(Kntemp * tempDiff, 1.0 / Ke);
|
||||
// return T_rad;
|
||||
// }
|
||||
datatype getResultN() {
|
||||
float a = (-0.21 * Kn) - 0.06; // a = -0,21k — 0,06
|
||||
float b = (6.04 * Kn) + 1.98; // b = 6,04k + 1,98
|
||||
float c = (-5.06 * Kn) + 18.06; // с = -5,06k + 18,06
|
||||
float x = (-0.2 * outdoorTemp) + 5; // x = -0.2*t1 + 5
|
||||
return (a * x * x) + (b * x) + c; // Tn = ax2 + bx + c
|
||||
}
|
||||
float tempDiff = targetTemp - outdoorTemp;
|
||||
if (tempDiff < 0) {
|
||||
tempDiff = 0;
|
||||
}
|
||||
float minOutside = targetTemp - (_maxOut - targetTemp) / Kn;
|
||||
float c1 = (_maxOut - targetTemp) / pow(targetTemp - minOutside, 1.0 / Ke);
|
||||
float T_rad = targetTemp + c1 * pow(tempDiff, 1.0 / Ke) ;
|
||||
if (T_rad > _maxOut) {
|
||||
T_rad = _maxOut;
|
||||
}
|
||||
|
||||
// поправка на желаемую комнатную температуру
|
||||
datatype getResultK() {
|
||||
return (targetTemp - 20) * Kk;
|
||||
}
|
||||
return T_rad;
|
||||
}
|
||||
|
||||
// Расчет поправки (ошибки) термостата
|
||||
datatype getResultT() {
|
||||
return constrain((targetTemp - indoorTemp), -3, 3) * Kt;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -84,7 +84,7 @@ board_build.ldscript = eagle.flash.4m1m.ld
|
||||
;platform_packages =
|
||||
; framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32.git#3.0.5
|
||||
; framework-arduinoespressif32-libs @ https://github.com/espressif/esp32-arduino-lib-builder/releases/download/idf-release_v5.1/esp32-arduino-libs-idf-release_v5.1-33fbade6.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.11/platform-espressif32.zip
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/53.03.12/platform-espressif32.zip
|
||||
platform_packages =
|
||||
board_build.partitions = esp32_partitions.csv
|
||||
lib_deps =
|
||||
@@ -164,10 +164,10 @@ board_build.ldscript = ${esp8266_defaults.board_build.ldscript}
|
||||
build_type = ${esp8266_defaults.build_type}
|
||||
build_flags =
|
||||
${esp8266_defaults.build_flags}
|
||||
-D DEFAULT_OT_IN_GPIO=13
|
||||
-D DEFAULT_OT_OUT_GPIO=15
|
||||
-D DEFAULT_OT_IN_GPIO=4
|
||||
-D DEFAULT_OT_OUT_GPIO=5
|
||||
-D DEFAULT_SENSOR_OUTDOOR_GPIO=12
|
||||
-D DEFAULT_SENSOR_INDOOR_GPIO=4
|
||||
-D DEFAULT_SENSOR_INDOOR_GPIO=14
|
||||
-D DEFAULT_STATUS_LED_GPIO=2
|
||||
-D DEFAULT_OT_RX_LED_GPIO=16
|
||||
|
||||
|
||||
@@ -14,12 +14,12 @@ ap_password = otgateway123456
|
||||
sta_ssid =
|
||||
sta_password =
|
||||
|
||||
portal_login = admin
|
||||
portal_password = admin
|
||||
portal_login =
|
||||
portal_password =
|
||||
|
||||
mqtt_enabled = false
|
||||
mqtt_server =
|
||||
mqtt_port = 1883
|
||||
mqtt_user =
|
||||
mqtt_password =
|
||||
mqtt_prefix = opentherm
|
||||
mqtt_prefix = otgateway
|
||||
@@ -886,6 +886,29 @@ public:
|
||||
return this->publish(this->makeConfigTopic(FPSTR(HA_ENTITY_NUMBER), F("equitherm_k_factor")).c_str(), doc);
|
||||
}
|
||||
|
||||
bool publishInputEquithermFactorK(bool enabledByDefault = true) {
|
||||
JsonDocument doc;
|
||||
doc[FPSTR(HA_AVAILABILITY)][FPSTR(HA_TOPIC)] = this->statusTopic.c_str();
|
||||
doc[FPSTR(HA_ENABLED_BY_DEFAULT)] = enabledByDefault;
|
||||
doc[FPSTR(HA_UNIQUE_ID)] = this->getObjectIdWithPrefix(F("equitherm_e"));
|
||||
doc[FPSTR(HA_OBJECT_ID)] = doc[FPSTR(HA_UNIQUE_ID)];
|
||||
doc[FPSTR(HA_ENTITY_CATEGORY)] = FPSTR(HA_ENTITY_CATEGORY_CONFIG);
|
||||
doc[FPSTR(HA_NAME)] = F("Equitherm Exponent E");
|
||||
doc[FPSTR(HA_ICON)] = F("mdi:alpha-e-circle-outline");
|
||||
doc[FPSTR(HA_STATE_TOPIC)] = this->settingsTopic.c_str();
|
||||
doc[FPSTR(HA_VALUE_TEMPLATE)] = F("{{ value_json.equitherm.e_factor|float(0)|round(2) }}");
|
||||
doc[FPSTR(HA_COMMAND_TOPIC)] = this->setSettingsTopic.c_str();
|
||||
doc[FPSTR(HA_COMMAND_TEMPLATE)] = F("{\"equitherm\": {\"e_factor\" : {{ value }}}}");
|
||||
doc[FPSTR(HA_MIN)] = 1;
|
||||
doc[FPSTR(HA_MAX)] = 2;
|
||||
doc[FPSTR(HA_STEP)] = 0.01f;
|
||||
doc[FPSTR(HA_MODE)] = FPSTR(HA_MODE_BOX);
|
||||
doc[FPSTR(HA_EXPIRE_AFTER)] = this->expireAfter;
|
||||
doc.shrinkToFit();
|
||||
|
||||
return this->publish(this->makeConfigTopic(FPSTR(HA_ENTITY_NUMBER), F("equitherm_k_factor")).c_str(), doc);
|
||||
}
|
||||
|
||||
bool publishInputEquithermFactorT(bool enabledByDefault = true) {
|
||||
JsonDocument doc;
|
||||
doc[FPSTR(HA_AVAILABILITY)][0][FPSTR(HA_TOPIC)] = this->statusTopic.c_str();
|
||||
|
||||
@@ -70,8 +70,8 @@ public:
|
||||
this->prevPubVarsTime = 0;
|
||||
}
|
||||
|
||||
inline void rebuildHaEntity(uint8_t sensorId, Sensors::Settings& prevSettings) {
|
||||
this->queueRebuildingHaEntities[sensorId] = prevSettings;
|
||||
inline void reconfigureSensor(uint8_t sensorId, Sensors::Settings& prevSettings) {
|
||||
this->queueReconfigureSensors[sensorId] = prevSettings;
|
||||
}
|
||||
|
||||
protected:
|
||||
@@ -81,7 +81,7 @@ protected:
|
||||
MqttWriter* writer = nullptr;
|
||||
UnitSystem currentUnitSystem = UnitSystem::METRIC;
|
||||
bool currentHomeAssistantDiscovery = false;
|
||||
std::unordered_map<uint8_t, Sensors::Settings> queueRebuildingHaEntities;
|
||||
std::unordered_map<uint8_t, Sensors::Settings> queueReconfigureSensors;
|
||||
unsigned short readyForSendTime = 30000;
|
||||
unsigned long lastReconnectTime = 0;
|
||||
unsigned long connectedTime = 0;
|
||||
@@ -276,8 +276,8 @@ protected:
|
||||
this->publishNonStaticHaEntities();
|
||||
}
|
||||
|
||||
|
||||
for (auto& [sensorId, prevSettings] : this->queueRebuildingHaEntities) {
|
||||
// rebuilding ha configs
|
||||
for (auto& [sensorId, prevSettings] : this->queueReconfigureSensors) {
|
||||
Log.sinfoln(FPSTR(L_MQTT_HA), F("Rebuilding config for sensor #%hhu '%s'"), sensorId, prevSettings.name);
|
||||
|
||||
// delete old config
|
||||
@@ -297,15 +297,6 @@ protected:
|
||||
this->haHelper->deleteSignalQualityDynamicSensor(prevSettings);
|
||||
this->haHelper->deleteDynamicSensor(prevSettings, Sensors::ValueType::TEMPERATURE);
|
||||
break;
|
||||
|
||||
case Sensors::Type::MANUAL:
|
||||
this->client->unsubscribe(
|
||||
this->haHelper->getDeviceTopic(
|
||||
F("sensors"),
|
||||
Sensors::makeObjectId(prevSettings.name).c_str(),
|
||||
F("set")
|
||||
).c_str()
|
||||
);
|
||||
|
||||
default:
|
||||
this->haHelper->deleteDynamicSensor(prevSettings, Sensors::ValueType::PRIMARY);
|
||||
@@ -333,26 +324,51 @@ protected:
|
||||
this->haHelper->publishSignalQualityDynamicSensor(sSettings, false);
|
||||
this->haHelper->publishDynamicSensor(sSettings, Sensors::ValueType::TEMPERATURE, settings.system.unitSystem);
|
||||
break;
|
||||
|
||||
case Sensors::Type::MANUAL:
|
||||
this->client->subscribe(
|
||||
this->haHelper->getDeviceTopic(
|
||||
F("sensors"),
|
||||
Sensors::makeObjectId(prevSettings.name).c_str(),
|
||||
F("set")
|
||||
).c_str()
|
||||
);
|
||||
|
||||
default:
|
||||
this->haHelper->publishDynamicSensor(sSettings, Sensors::ValueType::PRIMARY, settings.system.unitSystem);
|
||||
}
|
||||
}
|
||||
this->queueRebuildingHaEntities.clear();
|
||||
|
||||
} else if (this->currentHomeAssistantDiscovery) {
|
||||
this->currentHomeAssistantDiscovery = false;
|
||||
}
|
||||
|
||||
// reconfigure manual sensors
|
||||
for (auto& [sensorId, prevSettings] : this->queueReconfigureSensors) {
|
||||
// unsubscribe from old topic
|
||||
if (strlen(prevSettings.name) && prevSettings.enabled) {
|
||||
if (prevSettings.type == Sensors::Type::MANUAL) {
|
||||
this->client->unsubscribe(
|
||||
this->haHelper->getDeviceTopic(
|
||||
F("sensors"),
|
||||
Sensors::makeObjectId(prevSettings.name).c_str(),
|
||||
F("set")
|
||||
).c_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Sensors::hasEnabledAndValid(sensorId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// subscribe to new topic
|
||||
auto& sSettings = Sensors::settings[sensorId];
|
||||
if (sSettings.type == Sensors::Type::MANUAL) {
|
||||
this->client->subscribe(
|
||||
this->haHelper->getDeviceTopic(
|
||||
F("sensors"),
|
||||
Sensors::makeObjectId(sSettings.name).c_str(),
|
||||
F("set")
|
||||
).c_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// clear queue
|
||||
this->queueReconfigureSensors.clear();
|
||||
|
||||
if (this->newConnection) {
|
||||
this->newConnection = false;
|
||||
}
|
||||
@@ -366,6 +382,26 @@ protected:
|
||||
|
||||
this->client->subscribe(this->haHelper->getDeviceTopic(F("settings/set")).c_str());
|
||||
this->client->subscribe(this->haHelper->getDeviceTopic(F("state/set")).c_str());
|
||||
|
||||
// subscribe to manual sensors
|
||||
for (uint8_t sensorId = 0; sensorId <= Sensors::getMaxSensorId(); sensorId++) {
|
||||
if (!Sensors::hasEnabledAndValid(sensorId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
auto& sSettings = Sensors::settings[sensorId];
|
||||
if (sSettings.type != Sensors::Type::MANUAL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this->client->subscribe(
|
||||
this->haHelper->getDeviceTopic(
|
||||
F("sensors"),
|
||||
Sensors::makeObjectId(sSettings.name).c_str(),
|
||||
F("set")
|
||||
).c_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onDisconnect() {
|
||||
@@ -513,15 +549,6 @@ protected:
|
||||
this->haHelper->publishSignalQualityDynamicSensor(sSettings, false);
|
||||
this->haHelper->publishDynamicSensor(sSettings, Sensors::ValueType::TEMPERATURE, settings.system.unitSystem);
|
||||
break;
|
||||
|
||||
case Sensors::Type::MANUAL:
|
||||
this->client->subscribe(
|
||||
this->haHelper->getDeviceTopic(
|
||||
F("sensors"),
|
||||
Sensors::makeObjectId(sSettings.name).c_str(),
|
||||
F("set")
|
||||
).c_str()
|
||||
);
|
||||
|
||||
default:
|
||||
this->haHelper->publishDynamicSensor(sSettings, Sensors::ValueType::PRIMARY, settings.system.unitSystem);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
//#define PORTAL_CACHE "max-age=86400"
|
||||
#define PORTAL_CACHE nullptr
|
||||
#ifdef ARDUINO_ARCH_ESP8266
|
||||
#include <ESP8266mDNS.h>
|
||||
#include <ESP8266WebServer.h>
|
||||
#include <Updater.h>
|
||||
using WebServer = ESP8266WebServer;
|
||||
#else
|
||||
#include <ESPmDNS.h>
|
||||
#include <WebServer.h>
|
||||
#include <Update.h>
|
||||
#endif
|
||||
@@ -53,7 +55,7 @@ protected:
|
||||
bool webServerEnabled = false;
|
||||
bool dnsServerEnabled = false;
|
||||
unsigned long webServerChangeState = 0;
|
||||
unsigned long dnsServerChangeState = 0;
|
||||
bool mDnsState = false;
|
||||
|
||||
#if defined(ARDUINO_ARCH_ESP32)
|
||||
const char* getTaskName() override {
|
||||
@@ -616,7 +618,7 @@ protected:
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
tMqtt->rebuildHaEntity(sensorId, prevSettings);
|
||||
tMqtt->reconfigureSensor(sensorId, prevSettings);
|
||||
fsSensorsSettings.update();
|
||||
}
|
||||
});
|
||||
@@ -858,6 +860,7 @@ protected:
|
||||
}
|
||||
});
|
||||
|
||||
this->webServer->serveStatic("/robots.txt", LittleFS, "/static/robots.txt", PORTAL_CACHE);
|
||||
this->webServer->serveStatic("/favicon.ico", LittleFS, "/static/images/favicon.ico", PORTAL_CACHE);
|
||||
this->webServer->serveStatic("/static", LittleFS, "/static", PORTAL_CACHE);
|
||||
}
|
||||
@@ -872,6 +875,16 @@ protected:
|
||||
this->startWebServer();
|
||||
Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Started: AP up or STA connected"));
|
||||
|
||||
// Enabling mDNS
|
||||
if (!this->mDnsState && settings.portal.mdns) {
|
||||
if (MDNS.begin(networkSettings.hostname)) {
|
||||
MDNS.addService("http", "tcp", 80);
|
||||
this->mDnsState = true;
|
||||
|
||||
Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS enabled and service added"));
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef ARDUINO_ARCH_ESP8266
|
||||
::optimistic_yield(1000);
|
||||
#endif
|
||||
@@ -880,13 +893,29 @@ protected:
|
||||
this->stopWebServer();
|
||||
Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("Stopped: AP and STA down"));
|
||||
|
||||
// Disabling mDNS
|
||||
if (this->mDnsState) {
|
||||
MDNS.end();
|
||||
this->mDnsState = false;
|
||||
|
||||
Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS disabled"));
|
||||
}
|
||||
|
||||
#ifdef ARDUINO_ARCH_ESP8266
|
||||
::optimistic_yield(1000);
|
||||
#endif
|
||||
}
|
||||
|
||||
// Disabling mDNS if disabled in settings
|
||||
if (this->mDnsState && !settings.portal.mdns) {
|
||||
MDNS.end();
|
||||
this->mDnsState = false;
|
||||
|
||||
Log.straceln(FPSTR(L_PORTAL_WEBSERVER), F("mDNS disabled"));
|
||||
}
|
||||
|
||||
// dns server
|
||||
if (!this->stateDnsServer() && this->stateWebServer() && network->isApEnabled() && network->hasApClients() && millis() - this->dnsServerChangeState >= this->changeStateInterval) {
|
||||
if (!this->stateDnsServer() && !network->isConnected() && network->isApEnabled() && this->stateWebServer()) {
|
||||
this->startDnsServer();
|
||||
Log.straceln(FPSTR(L_PORTAL_DNSSERVER), F("Started: AP up"));
|
||||
|
||||
@@ -894,9 +923,9 @@ protected:
|
||||
::optimistic_yield(1000);
|
||||
#endif
|
||||
|
||||
} else if (this->stateDnsServer() && (!network->isApEnabled() || !this->stateWebServer())) {
|
||||
} else if (this->stateDnsServer() && (network->isConnected() || !network->isApEnabled() || !this->stateWebServer())) {
|
||||
this->stopDnsServer();
|
||||
Log.straceln(FPSTR(L_PORTAL_DNSSERVER), F("Stopped: AP down"));
|
||||
Log.straceln(FPSTR(L_PORTAL_DNSSERVER), F("Stopped: AP down/STA connected"));
|
||||
|
||||
#ifdef ARDUINO_ARCH_ESP8266
|
||||
::optimistic_yield(1000);
|
||||
@@ -1006,7 +1035,6 @@ protected:
|
||||
|
||||
this->dnsServer->start(53, "*", network->getApIp());
|
||||
this->dnsServerEnabled = true;
|
||||
this->dnsServerChangeState = millis();
|
||||
}
|
||||
|
||||
void stopDnsServer() {
|
||||
@@ -1017,6 +1045,5 @@ protected:
|
||||
//this->dnsServer->processNextRequest();
|
||||
this->dnsServer->stop();
|
||||
this->dnsServerEnabled = false;
|
||||
this->dnsServerChangeState = millis();
|
||||
}
|
||||
};
|
||||
@@ -172,6 +172,7 @@ protected:
|
||||
etRegulator.setLimits(minTemp, maxTemp);
|
||||
etRegulator.Kn = settings.equitherm.n_factor;
|
||||
etRegulator.Kk = settings.equitherm.k_factor;
|
||||
etRegulator.Ke = settings.equitherm.e_factor;
|
||||
etRegulator.targetTemp = targetTemp;
|
||||
etRegulator.outdoorTemp = outdoorTemp;
|
||||
float etResult = etRegulator.getResult();
|
||||
|
||||
@@ -49,6 +49,7 @@ struct Settings {
|
||||
bool auth = false;
|
||||
char login[13] = DEFAULT_PORTAL_LOGIN;
|
||||
char password[33] = DEFAULT_PORTAL_PASSWORD;
|
||||
bool mdns = true;
|
||||
} portal;
|
||||
|
||||
struct {
|
||||
@@ -136,6 +137,7 @@ struct Settings {
|
||||
float n_factor = 0.7f;
|
||||
float k_factor = 3.0f;
|
||||
float t_factor = 2.0f;
|
||||
float e_factor = 1.3f;
|
||||
} equitherm;
|
||||
|
||||
struct {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_HOSTNAME
|
||||
#define DEFAULT_HOSTNAME "opentherm"
|
||||
#define DEFAULT_HOSTNAME ""
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_AP_SSID
|
||||
@@ -111,7 +111,7 @@
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_MQTT_PREFIX
|
||||
#define DEFAULT_MQTT_PREFIX "opentherm"
|
||||
#define DEFAULT_MQTT_PREFIX ""
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_OT_IN_GPIO
|
||||
|
||||
12
src/main.cpp
12
src/main.cpp
@@ -102,6 +102,12 @@ void setup() {
|
||||
break;
|
||||
}
|
||||
|
||||
// generate hostname if it is empty
|
||||
if (!strlen(networkSettings.hostname)) {
|
||||
strcpy(networkSettings.hostname, getChipId("otgateway-").c_str());
|
||||
fsNetworkSettings.update();
|
||||
}
|
||||
|
||||
network = (new NetworkMgr)
|
||||
->setHostname(networkSettings.hostname)
|
||||
->setStaCredentials(
|
||||
@@ -148,6 +154,12 @@ void setup() {
|
||||
break;
|
||||
}
|
||||
|
||||
// generate mqtt prefix if it is empty
|
||||
if (!strlen(settings.mqtt.prefix)) {
|
||||
strcpy(settings.mqtt.prefix, getChipId("otgateway_").c_str());
|
||||
fsSettings.update();
|
||||
}
|
||||
|
||||
// Logs settings
|
||||
if (!settings.system.serial.enabled) {
|
||||
Serial.end();
|
||||
|
||||
@@ -73,6 +73,7 @@ const char S_DNS[] PROGMEM = "dns";
|
||||
const char S_DT[] PROGMEM = "dt";
|
||||
const char S_D_FACTOR[] PROGMEM = "d_factor";
|
||||
const char S_D_MULTIPLIER[] PROGMEM = "d_multiplier";
|
||||
const char S_E_FACTOR[] PROGMEM = "e_factor";
|
||||
const char S_EMERGENCY[] PROGMEM = "emergency";
|
||||
const char S_ENABLED[] PROGMEM = "enabled";
|
||||
const char S_ENV[] PROGMEM = "env";
|
||||
@@ -122,6 +123,7 @@ const char S_MAX_MODULATION[] PROGMEM = "maxModulation";
|
||||
const char S_MAX_POWER[] PROGMEM = "maxPower";
|
||||
const char S_MAX_TEMP[] PROGMEM = "maxTemp";
|
||||
const char S_MAX_TEMP_SYNC_WITH_TARGET_TEMP[] PROGMEM = "maxTempSyncWithTargetTemp";
|
||||
const char S_MDNS[] PROGMEM = "mdns";
|
||||
const char S_MEMBER_ID[] PROGMEM = "memberId";
|
||||
const char S_MIN[] PROGMEM = "min";
|
||||
const char S_MIN_FREE[] PROGMEM = "minFree";
|
||||
|
||||
53
src/utils.h
53
src/utils.h
@@ -1,5 +1,37 @@
|
||||
#include <Arduino.h>
|
||||
|
||||
String getChipId(const char* prefix = nullptr, const char* suffix = nullptr) {
|
||||
String chipId;
|
||||
chipId.reserve(
|
||||
6
|
||||
+ (prefix != nullptr ? strlen(prefix) : 0)
|
||||
+ (suffix != nullptr ? strlen(suffix) : 0)
|
||||
);
|
||||
|
||||
if (prefix != nullptr) {
|
||||
chipId.concat(prefix);
|
||||
}
|
||||
|
||||
uint32_t cid = 0;
|
||||
#if defined(ARDUINO_ARCH_ESP8266)
|
||||
cid = ESP.getChipId();
|
||||
#elif defined(ARDUINO_ARCH_ESP32)
|
||||
// https://github.com/espressif/arduino-esp32/blob/master/libraries/ESP32/examples/ChipID/GetChipID/GetChipID.ino
|
||||
for (uint8_t i = 0; i < 17; i = i + 8) {
|
||||
cid |= ((ESP.getEfuseMac() >> (40 - i)) & 0xff) << i;
|
||||
}
|
||||
#endif
|
||||
|
||||
chipId += String(cid, HEX);
|
||||
|
||||
if (suffix != nullptr) {
|
||||
chipId.concat(suffix);
|
||||
}
|
||||
|
||||
chipId.trim();
|
||||
return chipId;
|
||||
}
|
||||
|
||||
bool isLeapYear(short year) {
|
||||
if (year % 4 != 0) {
|
||||
return false;
|
||||
@@ -408,6 +440,7 @@ void settingsToJson(const Settings& src, JsonVariant dst, bool safe = false) {
|
||||
portal[FPSTR(S_AUTH)] = src.portal.auth;
|
||||
portal[FPSTR(S_LOGIN)] = src.portal.login;
|
||||
portal[FPSTR(S_PASSWORD)] = src.portal.password;
|
||||
portal[FPSTR(S_MDNS)] = src.portal.mdns;
|
||||
|
||||
auto opentherm = dst[FPSTR(S_OPENTHERM)].to<JsonObject>();
|
||||
opentherm[FPSTR(S_UNIT_SYSTEM)] = static_cast<uint8_t>(src.opentherm.unitSystem);
|
||||
@@ -469,6 +502,7 @@ void settingsToJson(const Settings& src, JsonVariant dst, bool safe = false) {
|
||||
equitherm[FPSTR(S_ENABLED)] = src.equitherm.enabled;
|
||||
equitherm[FPSTR(S_N_FACTOR)] = roundf(src.equitherm.n_factor, 3);
|
||||
equitherm[FPSTR(S_K_FACTOR)] = roundf(src.equitherm.k_factor, 3);
|
||||
equitherm[FPSTR(S_E_FACTOR)] = roundf(src.equitherm.e_factor, 3);
|
||||
equitherm[FPSTR(S_T_FACTOR)] = roundf(src.equitherm.t_factor, 3);
|
||||
|
||||
auto pid = dst[FPSTR(S_PID)].to<JsonObject>();
|
||||
@@ -675,6 +709,15 @@ bool jsonToSettings(const JsonVariantConst src, Settings& dst, bool safe = false
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (src[FPSTR(S_PORTAL)][FPSTR(S_MDNS)].is<bool>()) {
|
||||
bool value = src[FPSTR(S_PORTAL)][FPSTR(S_MDNS)].as<bool>();
|
||||
|
||||
if (value != dst.portal.mdns) {
|
||||
dst.portal.mdns = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// opentherm
|
||||
if (!src[FPSTR(S_OPENTHERM)][FPSTR(S_UNIT_SYSTEM)].isNull()) {
|
||||
@@ -1058,6 +1101,14 @@ bool jsonToSettings(const JsonVariantConst src, Settings& dst, bool safe = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!src[FPSTR(S_EQUITHERM)][FPSTR(S_E_FACTOR)].isNull()) {
|
||||
float value = src[FPSTR(S_EQUITHERM)][FPSTR(S_E_FACTOR)].as<float>();
|
||||
|
||||
if (value >= 1 && value <= 2 && fabsf(value - dst.equitherm.e_factor) > 0.0001f) {
|
||||
dst.equitherm.e_factor = roundf(value, 3);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!src[FPSTR(S_EQUITHERM)][FPSTR(S_T_FACTOR)].isNull()) {
|
||||
float value = src[FPSTR(S_EQUITHERM)][FPSTR(S_T_FACTOR)].as<float>();
|
||||
|
||||
@@ -1791,7 +1842,7 @@ bool jsonToSensorSettings(const uint8_t sensorId, const JsonVariantConst src, Se
|
||||
if (!src[FPSTR(S_FACTOR)].isNull()) {
|
||||
float value = src[FPSTR(S_FACTOR)].as<float>();
|
||||
|
||||
if (value > 0.09f && value <= 10.0f && fabsf(value - dst.factor) > 0.0001f) {
|
||||
if (value > 0.09f && value <= 100.0f && fabsf(value - dst.factor) > 0.0001f) {
|
||||
dst.factor = roundf(value, 3);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -296,7 +296,8 @@
|
||||
"portal": {
|
||||
"login": "Login",
|
||||
"password": "Password",
|
||||
"auth": "Require authentication"
|
||||
"auth": "Require authentication",
|
||||
"mdns": "Use mDNS"
|
||||
},
|
||||
|
||||
"system": {
|
||||
@@ -341,9 +342,14 @@
|
||||
"equitherm": {
|
||||
"n": "N factor",
|
||||
"k": "K factor",
|
||||
"e": "Exponent E",
|
||||
"t": {
|
||||
"title": "T factor",
|
||||
"note": "Not used if PID is enabled"
|
||||
},
|
||||
"chart": {
|
||||
"radiatorTemp": "Radiator Temperature (°C)",
|
||||
"outdoorTemp": "Outdoor Temperature (°C)"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -296,7 +296,8 @@
|
||||
"portal": {
|
||||
"login": "Login",
|
||||
"password": "Password",
|
||||
"auth": "Richiede autenticazione"
|
||||
"auth": "Richiede autenticazione",
|
||||
"mdns": "Usa mDNS"
|
||||
},
|
||||
|
||||
"system": {
|
||||
@@ -341,9 +342,14 @@
|
||||
"equitherm": {
|
||||
"n": "Fattore N",
|
||||
"k": "Fattore K",
|
||||
"e": "Esponente E",
|
||||
"t": {
|
||||
"title": "Fattore T",
|
||||
"note": "Non usato se PID è attivato"
|
||||
},
|
||||
"chart": {
|
||||
"radiatorTemp": "Temperatura Del Radiatore (°C)",
|
||||
"outdoorTemp": "Outdoor Temperature (°C)"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -296,7 +296,8 @@
|
||||
"portal": {
|
||||
"login": "Логин",
|
||||
"password": "Пароль",
|
||||
"auth": "Требовать аутентификацию"
|
||||
"auth": "Требовать аутентификацию",
|
||||
"mdns": "Использовать mDNS"
|
||||
},
|
||||
|
||||
"system": {
|
||||
@@ -341,9 +342,14 @@
|
||||
"equitherm": {
|
||||
"n": "Коэффициент N",
|
||||
"k": "Коэффициент K",
|
||||
"e": "Экспонента E",
|
||||
"t": {
|
||||
"title": "Коэффициент T",
|
||||
"note": "Не используется, если ПИД включен"
|
||||
},
|
||||
"chart": {
|
||||
"radiatorTemp": "Температура радиатора (°C)",
|
||||
"outdoorTemp": "Наружная температура (°C)"
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
<label>
|
||||
<span data-i18n>sensors.correction.factor</span>
|
||||
<input type="number" inputmode="decimal" name="factor" min="0.01" max="10" step="0.01" required>
|
||||
<input type="number" inputmode="decimal" name="factor" min="0.01" max="100" step="0.01" required>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
<input type="checkbox" name="portal[auth]" value="true">
|
||||
<span data-i18n>settings.portal.auth</span>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="checkbox" name="portal[mdns]" value="true">
|
||||
<span data-i18n>settings.portal.mdns</span>
|
||||
</label>
|
||||
<br />
|
||||
|
||||
<button type="submit" data-i18n>button.save</button>
|
||||
@@ -256,9 +261,10 @@
|
||||
</details>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b data-i18n>settings.section.equitherm</b></summary>
|
||||
<canvas id="equithermChart" width="400" height="200"></canvas>
|
||||
<div>
|
||||
<div id="equitherm-settings-busy" aria-busy="true"></div>
|
||||
<form action="/api/settings" id="equitherm-settings" class="hidden">
|
||||
@@ -268,7 +274,7 @@
|
||||
<span data-i18n>settings.enable</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<div class="grid">
|
||||
<label>
|
||||
<span data-i18n>settings.equitherm.n</span>
|
||||
@@ -280,6 +286,11 @@
|
||||
<input type="number" inputmode="decimal" name="equitherm[k_factor]" min="0" max="10" step="0.01" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n>settings.equitherm.e</span>
|
||||
<input type="number" inputmode="decimal" name="equitherm[e_factor]" min="1" max="2" step="0.01" required>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span data-i18n>settings.equitherm.t.title</span>
|
||||
<input type="number" inputmode="decimal" name="equitherm[t_factor]" min="0" max="10" step="0.01" required>
|
||||
@@ -294,6 +305,8 @@
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b data-i18n>settings.section.pid</b></summary>
|
||||
<div>
|
||||
@@ -751,6 +764,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/app.js?{BUILD_TIME}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
const lang = new Lang(document.getElementById('lang'));
|
||||
@@ -773,6 +787,7 @@
|
||||
setCheckboxValue("[name='portal[auth]']", data.portal.auth);
|
||||
setInputValue("[name='portal[login]']", data.portal.login);
|
||||
setInputValue("[name='portal[password]']", data.portal.password);
|
||||
setCheckboxValue("[name='portal[mdns]']", data.portal.mdns);
|
||||
setBusy('#portal-settings-busy', '#portal-settings', false);
|
||||
|
||||
// Opentherm
|
||||
@@ -879,6 +894,7 @@
|
||||
setCheckboxValue("[name='equitherm[enabled]']", data.equitherm.enabled);
|
||||
setInputValue("[name='equitherm[n_factor]']", data.equitherm.n_factor);
|
||||
setInputValue("[name='equitherm[k_factor]']", data.equitherm.k_factor);
|
||||
setInputValue("[name='equitherm[e_factor]']", data.equitherm.e_factor);
|
||||
setInputValue("[name='equitherm[t_factor]']", data.equitherm.t_factor);
|
||||
setBusy('#equitherm-settings-busy', '#equitherm-settings', false);
|
||||
|
||||
@@ -903,6 +919,9 @@
|
||||
setInputValue("[name='pid[deadband][thresholdHigh]']", data.pid.deadband.thresholdHigh);
|
||||
setInputValue("[name='pid[deadband][thresholdLow]']", data.pid.deadband.thresholdLow);
|
||||
setBusy('#pid-settings-busy', '#pid-settings', false);
|
||||
|
||||
|
||||
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -955,6 +974,179 @@
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
//График
|
||||
let equithermChart;
|
||||
|
||||
async function initChart() {
|
||||
try {
|
||||
const response = await fetch("/api/settings", {
|
||||
cache: "no-cache",
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Response not valid');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
//График переменные
|
||||
const targetTemp = result?.heating?.target_temp ?? 24;
|
||||
const maxOut = result?.heating?.maxTemp ?? 90;
|
||||
const Kn = result?.equitherm?.n_factor ?? 1;
|
||||
const Ke = result?.equitherm?.e_factor ?? 1.3;
|
||||
const Kk = result?.equitherm?.k_factor ?? 0;
|
||||
|
||||
function calculateTRad(targetTemp, outdoorTemp, maxOut, Kn, Ke, Kk) {
|
||||
let tempDiff = targetTemp - outdoorTemp;
|
||||
if (tempDiff < 0) {
|
||||
tempDiff = 0;
|
||||
}
|
||||
const minOutside = targetTemp - (maxOut - targetTemp) / Kn;
|
||||
let base = targetTemp - minOutside;
|
||||
if (base <= 0) {
|
||||
base = 0.0001;
|
||||
}
|
||||
const c1 = (maxOut - targetTemp) / Math.pow(base, 1.0 / Ke);
|
||||
let T_rad = targetTemp + c1 * Math.pow(tempDiff, 1.0 / Ke) + Kk;
|
||||
return Math.min(T_rad, maxOut);
|
||||
}
|
||||
|
||||
function generateChartData(targetTemp, maxOut, Kn, Ke, Kk) {
|
||||
const outdoorTemps = [];
|
||||
const predictedTRad = [];
|
||||
|
||||
for (let temp = 25; temp >= -30; temp -= 1) {
|
||||
outdoorTemps.push(temp);
|
||||
predictedTRad.push(calculateTRad(targetTemp, temp, maxOut, Kn, Ke, Kk).toFixed(1));
|
||||
}
|
||||
return { outdoorTemps, predictedTRad };
|
||||
}
|
||||
|
||||
// Стартовые данные
|
||||
const { outdoorTemps, predictedTRad } = generateChartData(targetTemp, maxOut, Kn, Ke, Kk);
|
||||
|
||||
// Создаем график
|
||||
const ctx = document.getElementById('equithermChart').getContext('2d');
|
||||
// Create gradient for the line
|
||||
const canvasHeight = ctx.canvas.height;
|
||||
const gradient = ctx.createLinearGradient(0, canvasHeight, 0, 0); // Adjust x1, y1, x2, y2 for direction
|
||||
gradient.addColorStop(0, 'rgba(75, 192, 192, 1)');
|
||||
gradient.addColorStop(0.5, 'rgba(255, 99, 132, 1)');
|
||||
|
||||
|
||||
equithermChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: outdoorTemps,
|
||||
datasets: [{
|
||||
label: 'Температура Радиатора (°C)',
|
||||
borderColor: gradient, // Use gradient instead of solid color
|
||||
borderWidth: 1,
|
||||
fill: false,
|
||||
tension: 0.1,
|
||||
pointRadius: 2, // Reduce dot size (default is 3)
|
||||
pointHoverRadius: 4,
|
||||
data: predictedTRad
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Наружная температура (°C)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Температура Радиатора (°C)'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Показ формы
|
||||
document.getElementById('equitherm-settings-busy').classList.add('hidden');
|
||||
document.getElementById('equitherm-settings').classList.remove('hidden');
|
||||
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление графика
|
||||
function updateChart(formData) {
|
||||
if (!equithermChart) return;
|
||||
|
||||
fetch("/api/settings", {
|
||||
cache: "no-cache",
|
||||
credentials: "include"
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
const targetTemp = result?.heating?.target_temp ?? 24;
|
||||
const maxOut = result?.heating?.maxTemp ?? 90;
|
||||
const Kn = parseFloat(formData.get('equitherm[n_factor]')) || 1;
|
||||
const Ke = parseFloat(formData.get('equitherm[e_factor]')) || 1.3;
|
||||
const Kk = parseFloat(formData.get('equitherm[k_factor]')) || 0;
|
||||
|
||||
function calculateTRad(targetTemp, outdoorTemp, maxOut, Kn, Ke, Kk) {
|
||||
let tempDiff = targetTemp - outdoorTemp;
|
||||
if (tempDiff < 0) {
|
||||
tempDiff = 0;
|
||||
}
|
||||
const minOutside = targetTemp - (maxOut - targetTemp) / Kn;
|
||||
let base = targetTemp - minOutside;
|
||||
if (base <= 0) {
|
||||
base = 0.0001;
|
||||
}
|
||||
const c1 = (maxOut - targetTemp) / Math.pow(base, 1.0 / Ke);
|
||||
let T_rad = targetTemp + c1 * Math.pow(tempDiff, 1.0 / Ke) + Kk;
|
||||
return Math.min(T_rad, maxOut);
|
||||
}
|
||||
|
||||
const outdoorTemps = [];
|
||||
const predictedTRad = [];
|
||||
for (let temp = 25; temp >= -30; temp -= 1) {
|
||||
outdoorTemps.push(temp);
|
||||
predictedTRad.push(calculateTRad(targetTemp, temp, maxOut, Kn, Ke, Kk).toFixed(1));
|
||||
}
|
||||
|
||||
equithermChart.data.labels = outdoorTemps;
|
||||
equithermChart.data.datasets[0].data = predictedTRad;
|
||||
equithermChart.update();
|
||||
})
|
||||
.catch(error => console.log(error));
|
||||
}
|
||||
|
||||
// Слушаем отправку
|
||||
const form = document.getElementById('equitherm-settings');
|
||||
form.addEventListener('submit', (e) => {
|
||||
|
||||
const formData = new FormData(form);
|
||||
updateChart(formData);
|
||||
});
|
||||
|
||||
// Слушаем кнопку сохранить
|
||||
const equithermSection = document.querySelector('details');
|
||||
const saveButton = equithermSection.querySelector('button[data-i18n="button.save"]');
|
||||
saveButton.addEventListener('click', () => {
|
||||
const form = document.getElementById('equitherm-settings');
|
||||
const formData = new FormData(form);
|
||||
updateChart(formData);
|
||||
});
|
||||
|
||||
// инициализируем
|
||||
initChart();
|
||||
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
2
src_data/robots.txt
Normal file
2
src_data/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -132,10 +132,14 @@ const setupNetworkScanForm = (formSelector, tableSelector) => {
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
let row = tbody.insertRow(-1);
|
||||
row.classList.add("network");
|
||||
row.setAttribute('data-ssid', result[i].hidden ? '' : result[i].ssid);
|
||||
row.onclick = () => {
|
||||
const input = document.querySelector('input#sta-ssid');
|
||||
const ssid = this.getAttribute('data-ssid');
|
||||
row.dataset.ssid = result[i].hidden ? '' : result[i].ssid;
|
||||
row.insertCell().textContent = `#${i + 1}`;
|
||||
|
||||
const nameCell = row.insertCell();
|
||||
nameCell.innerHTML = result[i].hidden ? `<i>${result[i].bssid}</i>` : result[i].ssid;
|
||||
nameCell.onclick = (event) => {
|
||||
const input = document.querySelector("[name='sta[ssid]']");
|
||||
const ssid = event.target.parentNode.dataset.ssid;
|
||||
if (!input || !ssid) {
|
||||
return;
|
||||
}
|
||||
@@ -144,9 +148,6 @@ const setupNetworkScanForm = (formSelector, tableSelector) => {
|
||||
input.focus();
|
||||
};
|
||||
|
||||
row.insertCell().textContent = `#${i + 1}`;
|
||||
row.insertCell().innerHTML = result[i].hidden ? `<i>${result[i].bssid}</i>` : result[i].ssid;
|
||||
|
||||
// info cell
|
||||
let infoCell = row.insertCell();
|
||||
|
||||
@@ -165,7 +166,7 @@ const setupNetworkScanForm = (formSelector, tableSelector) => {
|
||||
}
|
||||
|
||||
let signalQualityContainer = document.createElement("span");
|
||||
signalQualityContainer.setAttribute('data-tooltip', `${result[i].signalQuality}%`);
|
||||
signalQualityContainer.dataset.tooltip = `${result[i].signalQuality}%`;
|
||||
signalQualityContainer.appendChild(signalQualityIcon);
|
||||
infoCell.appendChild(signalQualityContainer);
|
||||
|
||||
@@ -192,7 +193,7 @@ const setupNetworkScanForm = (formSelector, tableSelector) => {
|
||||
}
|
||||
|
||||
let authContainer = document.createElement("span");
|
||||
authContainer.setAttribute('data-tooltip', (result[i].auth in authList) ? authList[result[i].auth] : "unknown");
|
||||
authContainer.dataset.tooltip = (result[i].auth in authList) ? authList[result[i].auth] : "unknown";
|
||||
authContainer.appendChild(authIcon);
|
||||
infoCell.appendChild(authContainer);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user