add bed cooler project.

This commit is contained in:
2026-06-28 13:15:53 -05:00
commit 0f2a03044c
46 changed files with 3597 additions and 0 deletions

295
src/core/auth.cpp Normal file
View File

@@ -0,0 +1,295 @@
#include "app.h"
bool hasRole(const String &roles, const String &role) {
if (role.length() == 0) return true;
int start = 0;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
if (roles.substring(start, end) == role) return true;
start = end + 1;
}
return false;
}
bool validName(const String &s) {
if (s.length() == 0 || s.length() > 31) return false;
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
if (!(isalnum(c) || c == '_' || c == '-' || c == '.')) return false;
}
return true;
}
String cleanRoles(const String &roles) {
String out;
String known = allRoles();
int start = 0;
while (start <= (int)known.length()) {
int end = known.indexOf('|', start);
if (end < 0) end = known.length();
String role = known.substring(start, end);
if (role.length() && hasRole(roles, role)) {
if (out.length()) out += "|";
out += role;
}
start = end + 1;
}
return out;
}
bool isKnownRole(const String &role) {
return role == "PUBLIC" || hasRole(allRoles(), role);
}
bool isSystemRole(const String &role) {
return role == "Sysadmin" || role == "UserAdmin" || role == "WebUIConnect" || role == "Debugger";
}
String allRoles() {
String roles = "Sysadmin|UserAdmin|WebUIConnect|Debugger";
String custom = prefString("roles", "");
int start = 0;
while (start <= (int)custom.length()) {
int end = custom.indexOf('|', start);
if (end < 0) end = custom.length();
String role = custom.substring(start, end);
if (role.length() && !hasRole(roles, role)) roles += "|" + role;
start = end + 1;
if (!custom.length()) break;
}
return roles;
}
String rolesJson() {
String roles = allRoles();
String out = "\"roles\":[";
int start = 0;
bool first = true;
while (start <= (int)roles.length()) {
int end = roles.indexOf('|', start);
if (end < 0) end = roles.length();
String role = roles.substring(start, end);
if (role.length()) {
if (!first) out += ",";
out += "{\"name\":\"" + jsonEscape(role) + "\",\"system\":" + String(isSystemRole(role) ? "true" : "false") + "}";
first = false;
}
start = end + 1;
}
out += "]";
return out;
}
bool addCustomRole(const String &role) {
if (!validName(role) || isKnownRole(role)) return false;
String custom = prefString("roles", "");
if (custom.length()) custom += "|";
custom += role;
prefs.putString("roles", custom);
return true;
}
bool deleteCustomRole(const String &role) {
if (!validName(role) || isSystemRole(role) || !hasRole(prefString("roles", ""), role)) return false;
String custom = prefString("roles", "");
String kept;
int start = 0;
while (start <= (int)custom.length()) {
int end = custom.indexOf('|', start);
if (end < 0) end = custom.length();
String item = custom.substring(start, end);
if (item.length() && item != role) {
if (kept.length()) kept += "|";
kept += item;
}
start = end + 1;
}
prefs.putString("roles", kept);
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) users[i].roles = cleanRoles(users[i].roles);
saveUsers(users, count);
for (size_t i = 0; i < API_DEF_COUNT + CUSTOM_API_DEF_COUNT; i++) {
ApiDef *api = i < API_DEF_COUNT ? &apiDefs[i] : &customApiDefs[i - API_DEF_COUNT];
if (configuredRole(api) == role) {
String fallback = api->publicByDefault ? "PUBLIC" : api->defaultRole;
String key = "acl";
key += api->method[0];
for (size_t j = 0; j < strlen(api->path); j++) {
char c = api->path[j];
if (isalnum(c)) key += c;
}
prefs.putString(key.substring(0, 15).c_str(), fallback);
}
}
return true;
}
static String defaultUsers() {
return String(DEFAULT_ADMIN) + "\t" + passwordHash("") + "\tSysadmin|UserAdmin|WebUIConnect|Debugger\t1\n";
}
static String usersText() {
String text = prefString("users", "");
return text.length() ? text : defaultUsers();
}
size_t parseUsers(User *users, size_t maxUsers) {
String text = usersText();
size_t count = 0;
int start = 0;
while (start < (int)text.length() && count < maxUsers) {
int end = text.indexOf('\n', start);
if (end < 0) end = text.length();
String line = text.substring(start, end);
int a = line.indexOf('\t');
int b = line.indexOf('\t', a + 1);
int c = line.indexOf('\t', b + 1);
if (a > 0 && b > a) {
users[count].name = line.substring(0, a);
users[count].passwordHash = line.substring(a + 1, b);
users[count].roles = cleanRoles(c > b ? line.substring(b + 1, c) : line.substring(b + 1));
users[count].active = c > b ? line.substring(c + 1).toInt() != 0 : true;
count++;
}
start = end + 1;
}
return count;
}
void saveUsers(User *users, size_t count) {
String text;
for (size_t i = 0; i < count; i++) {
text += users[i].name + "\t" + users[i].passwordHash + "\t" + cleanRoles(users[i].roles) + "\t" + String(users[i].active ? "1" : "0") + "\n";
}
prefs.putString("users", text);
}
bool findUser(const String &name, User &user) {
User users[8];
size_t count = parseUsers(users, MAX_USERS);
for (size_t i = 0; i < count; i++) {
if (users[i].name == name) {
user = users[i];
return true;
}
}
return false;
}
String createToken(const User &user) {
String value = sha256(String(esp_random(), HEX) + ":" + user.name + ":" + String(millis()));
int slot = 0;
uint32_t oldest = tokens[0].lastSeen;
for (int i = 0; i < (int)MAX_TOKENS; i++) {
if (!tokens[i].value.length()) {
slot = i;
break;
}
if (tokens[i].lastSeen < oldest) {
oldest = tokens[i].lastSeen;
slot = i;
}
}
tokens[slot].value = value;
tokens[slot].user = user.name;
tokens[slot].roles = user.roles;
tokens[slot].lastSeen = millis();
return value;
}
static String bearerToken() {
String auth = server.header("Authorization");
if (auth.startsWith("Bearer ")) return auth.substring(7);
if (server.hasHeader("X-Auth-Token")) return server.header("X-Auth-Token");
if (server.hasArg("token")) return server.arg("token");
return "";
}
static String requestMethodName();
Token *currentToken() {
String value = bearerToken();
if (!value.length()) return nullptr;
for (size_t i = 0; i < MAX_TOKENS; i++) {
if (tokens[i].value == value) {
tokens[i].lastSeen = millis();
return &tokens[i];
}
}
appendLog(LOG_SECURITY_AUDIT, "invalid bearer token for " + requestMethodName() + " " + server.uri());
return nullptr;
}
static String apiKey(const String &path, const String &method) {
String key = "acl";
key += method[0];
for (size_t i = 0; i < path.length(); i++) {
char c = path[i];
if (isalnum(c)) key += c;
}
return key.substring(0, 15);
}
ApiDef *findApi(const String &path, const String &method) {
for (size_t i = 0; i < API_DEF_COUNT; i++) {
if (path == apiDefs[i].path && method == apiDefs[i].method) return &apiDefs[i];
}
for (size_t i = 0; i < CUSTOM_API_DEF_COUNT; i++) {
if (path == customApiDefs[i].path && method == customApiDefs[i].method) return &customApiDefs[i];
}
return nullptr;
}
String configuredRole(ApiDef *api) {
if (!api) return "";
String key = apiKey(api->path, api->method);
return prefString(key.c_str(), api->publicByDefault ? "PUBLIC" : api->defaultRole);
}
static String requestMethodName() {
switch (server.method()) {
case HTTP_GET:
return "GET";
case HTTP_POST:
return "POST";
case HTTP_DELETE:
return "DELETE";
case HTTP_PUT:
return "PUT";
case HTTP_PATCH:
return "PATCH";
default:
return "";
}
}
bool authorize() {
String path = server.uri();
String method = requestMethodName();
ApiDef *api = findApi(path, method);
String required = configuredRole(api);
if (required == "PUBLIC" || (api && api->publicByDefault && !required.length())) return true;
Token *token = currentToken();
if (!token) {
if (!bearerToken().length()) appendLog(LOG_SECURITY_AUDIT, "authentication required for " + method + " " + path);
sendJson(401, jsonError("Authentication required"));
return false;
}
User user;
if (!findUser(token->user, user) || !user.active) {
appendLog(LOG_SECURITY_AUDIT, "inactive or unknown user token for " + token->user + " on " + method + " " + path);
sendJson(401, jsonError("User is inactive"));
return false;
}
token->roles = user.roles;
if (!hasRole(user.roles, required)) {
appendLog(LOG_SECURITY_AUDIT, "authorization denied for " + user.name + " on " + method + " " + path + " requires " + required);
sendJson(403, jsonError("Missing role " + required));
return false;
}
return true;
}

68
src/core/device.cpp Normal file
View File

@@ -0,0 +1,68 @@
#include "app.h"
#include <ESPmDNS.h>
#include <LittleFS.h>
void applyLed() {
uint8_t pct = constrain(ledBrightness, 0, 100);
uint8_t duty = map(pct, 0, 100, 0, 255);
if (ledInverted) duty = 255 - duty;
analogWrite(LED_BUILTIN, duty);
}
void factoryReset() {
prefs.clear();
LittleFS.remove(LOG_FILE_PATH);
appendLog(LOG_SECURITY_AUDIT, "factory reset requested");
}
void checkFactoryResetPin() {
pinMode(FACTORY_RESET_PIN, INPUT_PULLUP);
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) return;
uint32_t start = millis();
while (millis() - start < FACTORY_RESET_HOLD_MS) {
delay(100);
if (digitalRead(FACTORY_RESET_PIN) != FACTORY_RESET_ACTIVE_LEVEL) {
return;
}
}
factoryReset();
ESP.restart();
}
bool connectWifi() {
String ssid = prefString("wifiSsid", "");
String pass = prefString("wifiPass", "");
if (!ssid.length()) return false;
String name = deviceName();
WiFi.mode(WIFI_STA);
WiFi.setHostname(name.c_str());
if (!networkDhcp) {
IPAddress ip;
IPAddress gateway;
IPAddress subnet;
IPAddress dns1;
IPAddress dns2;
bool ok = ip.fromString(networkIp) && gateway.fromString(networkGateway) && subnet.fromString(networkSubnet);
if (ok) {
if (!networkDns1.length() || !dns1.fromString(networkDns1)) dns1 = gateway;
if (!networkDns2.length() || !dns2.fromString(networkDns2)) dns2 = IPAddress(0, 0, 0, 0);
if (!WiFi.config(ip, gateway, subnet, dns1, dns2)) appendLog(LOG_WARN, "static IP configuration failed; continuing with DHCP");
} else {
appendLog(LOG_WARN, "static IP configuration is incomplete or invalid; continuing with DHCP");
}
}
WiFi.begin(ssid.c_str(), pass.c_str());
appendLog(LOG_INFO, "connecting to WiFi " + ssid);
uint32_t start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
delay(250);
}
if (WiFi.status() == WL_CONNECTED) {
MDNS.begin(name.c_str());
appendLog(LOG_INFO, "WiFi connected " + WiFi.localIP().toString());
return true;
}
appendLog(LOG_WARN, "WiFi connection failed; entering setup AP");
return false;
}

43
src/core/logging.cpp Normal file
View File

@@ -0,0 +1,43 @@
#include "app.h"
#include <LittleFS.h>
#include <time.h>
static String timestamp() {
time_t now = time(nullptr);
struct tm tm;
localtime_r(&now, &tm);
char buffer[20];
strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M:%S", &tm);
return String(buffer);
}
void appendLog(LogLevel level, const String &message) {
if (level > currentLogLevel) return;
const char *names[] = {"ERROR", "WARN", "SecurityAudit", "INFO", "DEBUG"};
String line = timestamp() + " " + names[level] + " " + message + "\n";
if (!LittleFS.exists(LOG_DIR)) LittleFS.mkdir(LOG_DIR);
File f = LittleFS.open(LOG_FILE_PATH, "a");
if (f) {
f.print(line);
f.close();
}
f = LittleFS.open(LOG_FILE_PATH, "r");
if (!f) return;
size_t size = f.size();
if (size <= maxLogBytes) {
f.close();
return;
}
size_t keep = maxLogBytes > 1024 ? maxLogBytes - 1024 : maxLogBytes;
f.seek(size - keep, SeekSet);
String tail = f.readString();
f.close();
int newline = tail.indexOf('\n');
if (newline >= 0) tail = tail.substring(newline + 1);
f = LittleFS.open(LOG_FILE_PATH, "w");
if (f) {
f.print(tail);
f.close();
}
}

117
src/core/state.cpp Normal file
View File

@@ -0,0 +1,117 @@
#include "app.h"
#include <mbedtls/md.h>
const char *APP_VERSION = "0.1.0";
const char *PROJECT_NAME_VALUE = PROJECT_NAME;
const char *DEFAULT_ADMIN = "admin";
const char *DEFAULT_UPDATE_URL = "http://example.com/update.tslpkg";
const uint32_t FACTORY_RESET_HOLD_MS = 10000;
const size_t MAX_USERS = 8;
const size_t MAX_TOKENS = 8;
const bool DEFAULT_LED_INVERTED = true;
const byte DNS_PORT = 53;
const IPAddress SETUP_AP_IP(192, 168, 1, 1);
const IPAddress SETUP_AP_GATEWAY(192, 168, 1, 1);
const IPAddress SETUP_AP_SUBNET(255, 255, 255, 0);
const char *WWW_ROOT = "/www";
const char *LOG_DIR = "/log";
const char *LOG_FILE_PATH = "/log/logs.txt";
WebServer server(80);
DNSServer dnsServer;
Preferences prefs;
Token tokens[8];
LogLevel currentLogLevel = LOG_INFO;
size_t maxLogBytes = 50 * 1024;
bool setupMode = false;
uint8_t ledBrightness = 0;
bool ledInverted = DEFAULT_LED_INVERTED;
String updateUrl = DEFAULT_UPDATE_URL;
String networkHostname;
bool networkDhcp = true;
String networkIp;
String networkGateway;
String networkSubnet;
String networkDns1;
String networkDns2;
String chipId() {
uint64_t mac = ESP.getEfuseMac();
char buf[13];
snprintf(buf, sizeof(buf), "%04X%08X", (uint16_t)(mac >> 32), (uint32_t)mac);
return String(buf);
}
String defaultDeviceName() {
String suffix = chipId().substring(6);
String base = PROJECT_NAME_VALUE;
base.trim();
for (size_t i = 0; i < base.length(); i++) {
char c = base[i];
if (!(isalnum(c) || c == '-')) base.setCharAt(i, '-');
}
while (base.startsWith("-")) base.remove(0, 1);
while (base.endsWith("-")) base.remove(base.length() - 1);
if (!base.length()) base = "ESP32C3";
const size_t maxHostnameLength = 31;
size_t maxBaseLength = maxHostnameLength - 1 - suffix.length();
if (base.length() > maxBaseLength) base = base.substring(0, maxBaseLength);
while (base.endsWith("-")) base.remove(base.length() - 1);
return base + "-" + suffix;
}
String deviceName() {
return networkHostname.length() ? networkHostname : defaultDeviceName();
}
String sha256(const String &input) {
uint8_t digest[32];
mbedtls_md_context_t ctx;
mbedtls_md_init(&ctx);
mbedtls_md_setup(&ctx, mbedtls_md_info_from_type(MBEDTLS_MD_SHA256), 0);
mbedtls_md_starts(&ctx);
mbedtls_md_update(&ctx, (const unsigned char *)input.c_str(), input.length());
mbedtls_md_finish(&ctx, digest);
mbedtls_md_free(&ctx);
char out[65];
for (int i = 0; i < 32; i++) snprintf(out + (i * 2), 3, "%02x", digest[i]);
out[64] = 0;
return String(out);
}
String passwordHash(const String &password) {
return sha256(chipId() + ":" + password);
}
String prefString(const char *key, const String &fallback) {
return prefs.isKey(key) ? prefs.getString(key, fallback) : fallback;
}
void loadSettings() {
prefs.begin("boiler", false);
if (!prefs.getBool("ledPolV2", false)) {
prefs.putBool("ledInv", DEFAULT_LED_INVERTED);
prefs.putBool("ledPolV2", true);
}
uint8_t rawLogLevel = prefs.getUChar("logLevel", LOG_INFO);
if (!prefs.getBool("logLevelV2", false)) {
if (prefs.isKey("logLevel") && rawLogLevel >= 2) rawLogLevel++;
rawLogLevel = constrain(rawLogLevel, 0, 4);
prefs.putUChar("logLevel", rawLogLevel);
prefs.putBool("logLevelV2", true);
}
currentLogLevel = (LogLevel)constrain(rawLogLevel, 0, 4);
maxLogBytes = prefs.getUInt("logMax", 50 * 1024);
ledInverted = prefs.getBool("ledInv", DEFAULT_LED_INVERTED);
ledBrightness = constrain(prefs.getUChar("ledBright", 0), 0, 100);
updateUrl = prefString("updateUrl", DEFAULT_UPDATE_URL);
networkHostname = prefString("netHost", "");
networkDhcp = prefs.getBool("netDhcp", true);
networkIp = prefString("netIp", "");
networkGateway = prefString("netGw", "");
networkSubnet = prefString("netMask", "");
networkDns1 = prefString("netDns1", "");
networkDns2 = prefString("netDns2", "");
}