diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c2ed4d0..c03f91b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,9 +29,48 @@ jobs: - name: Set configs run: | - cp config_default.h config.h - cp secret_default.h secret.h + cp ./src/Arduino/config_default.h ./src/Arduino/config.h + cp ./src/Arduino/secret_default.h ./src/Arduino/secret.h + cp ./src/ESP32/config_default.h ./src/ESP32/config.h + cp ./src/ESP32/secret_default.h ./src/ESP32/secret.h - - name: Compile Arduino project + - name: Compile run: | - arduino-cli compile --profile Garage_UNO ./ + arduino-cli compile ./src/Arduino + arduino-cli compile ./src/ESP32 + + - name: Install cppcheck + run: | + sudo apt-get update + sudo apt-get install -y cppcheck + + - name: Static analysis (ESP32) + run: | + cppcheck --version + cppcheck \ + --enable=warning,style,performance,portability \ + --language=c++ \ + --std=c++20 \ + --platform=unix32 \ + --inline-suppr \ + --suppress=missingInclude \ + --suppress=missingIncludeSystem \ + --suppress=constParameterCallback:./src/ESP32/ESP32.ino \ + --error-exitcode=1 \ + -I ./src/ESP32 \ + ./src/ESP32/ESP32.ino ./src/ESP32/crypto.cpp ./src/ESP32/ota.cpp + + - name: Static analysis (Arduino) + run: | + cppcheck \ + --enable=warning,performance,portability \ + --language=c++ \ + --std=c++11 \ + --platform=avr8 \ + --inline-suppr \ + -D PROGMEM= \ + --suppress=missingInclude \ + --suppress=missingIncludeSystem \ + --error-exitcode=1 \ + -I ./src/Arduino \ + ./src/Arduino/Arduino.ino ./src/Arduino/sha256.cpp diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index baa3d90..81a475b 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -23,5 +23,5 @@ jobs: environment: ${{ inputs.environment }} serial_port: ${{ inputs.serial_port }} device_name: Garage - source_dir: "." + source_dir: "./src/Arduino" secrets: inherit diff --git a/.gitignore b/.gitignore index a7fbec4..2a05eac 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ secret.h +config.h +/tests/__pycache__ diff --git a/Garage.ino b/src/Arduino/Arduino.ino similarity index 100% rename from Garage.ino rename to src/Arduino/Arduino.ino diff --git a/config_default.h b/src/Arduino/config_default.h similarity index 100% rename from config_default.h rename to src/Arduino/config_default.h diff --git a/secret_default.h b/src/Arduino/secret_default.h similarity index 100% rename from secret_default.h rename to src/Arduino/secret_default.h diff --git a/sha256.cpp b/src/Arduino/sha256.cpp similarity index 100% rename from sha256.cpp rename to src/Arduino/sha256.cpp diff --git a/sha256.h b/src/Arduino/sha256.h similarity index 100% rename from sha256.h rename to src/Arduino/sha256.h diff --git a/sketch.yaml b/src/Arduino/sketch.yaml similarity index 85% rename from sketch.yaml rename to src/Arduino/sketch.yaml index 687fc4f..e0f7f05 100644 --- a/sketch.yaml +++ b/src/Arduino/sketch.yaml @@ -5,5 +5,5 @@ profiles: - platform: arduino:avr (1.8.7) libraries: - AM2302-Sensor (1.4.0) - - dir: MQTTESP8266 + - dir: ../../MQTTESP8266 default_profile: Garage_UNO diff --git a/src/ESP32/ESP32.ino b/src/ESP32/ESP32.ino new file mode 100644 index 0000000..69efb81 --- /dev/null +++ b/src/ESP32/ESP32.ino @@ -0,0 +1,692 @@ +#include +#include +#include +#include +#include +#include "time.h" +#include "esp_task_wdt.h" +#include "esp_random.h" +#include "esp_timer.h" +#include "config.h" +#include "secret.h" +#include "crypto.h" +#include "ota.h" + +#ifndef FW_VERSION +#define FW_VERSION 0 +#endif + +#define SENSOR_CHANNEL 4 +#define DEFAULT_SENSOR_ID 39033 + +#define DOORSWITCH_PIN 22 +#define DOORBUTTON_PIN 23 +#define DOORFLASH_PIN 21 +#define TEMPERATURE_SENSOR_PIN 19 + +#define MQTT_CLIENT_ID "GarageESP32" +#define MQTT_TLS_PORT 8883 +#define WDT_TIMEOUT_S 90 +#define WIFI_CONNECT_TIMEOUT_MS 15000UL +#define TIME_SYNC_TIMEOUT_MS 15000UL +#define TIME_VALID_THRESHOLD 1700000000UL +#define MQTT_BACKOFF_MAX_MS 60000UL +#define MQTT_KEEPALIVE_S 60 +#define STATE_PAYLOAD_LEN 32 +#define DOORBUTTON_ACTIVE LOW +#define DOORBUTTON_IDLE (DOORBUTTON_ACTIVE == LOW ? HIGH : LOW) + +#define T_FULL_MS 16000UL +#define T_FULL_MARGIN_MS 1000UL +#define T_LEAD_DEFAULT_MS 1500UL +#define FLASH_TIMEOUT_MS 1200UL +#define REVERSE_MARGIN_MS 2000UL +#define REED_DEBOUNCE_MS 50UL +#define MOVE_PUBLISH_INTERVAL 1000UL + +#define DOOR_PULSE_MS 500UL +#define TEMPERATURE_INTERVAL 60000UL +#define DIAG_INTERVAL 900000UL + +#define GARAGE_STATUS_OPENED 1 +#define GARAGE_STATUS_EXPIRED 2 +#define GARAGE_STATUS_BADSIG 3 + +void MQTTMessageReceive(char* topic, uint8_t* payload, unsigned int length); +void PublishDoorState(bool force = false); + +enum DoorState { DoorUnknown, DoorClosed, DoorOpening, DoorOpen, DoorClosing, DoorStopped }; + +#pragma pack(push, 1) +struct DiagData { + uint32_t uptime; + uint16_t freeRamKb; + uint16_t wifiReconn; + uint16_t mqttReconn; + uint8_t sensorErr; + uint8_t resetReason; + uint16_t loopMaxMs; + uint16_t doorCycles; + int8_t rssi; + uint16_t fwVersion; + uint16_t otaFailCount; + uint16_t lastTravelMs; + uint16_t lastLeadMs; +}; +#pragma pack(pop) +static_assert(sizeof(DiagData) == 25, "DiagData wire layout must stay 25 bytes"); + +WiFiClientSecure net; +PubSubClient mqtt(net); +Preferences preferences; +AM2302::AM2302_Sensor am2302{ TEMPERATURE_SENSOR_PIN }; + +unsigned long currentMillis = 0; +unsigned long doorPulseStart = 0; +unsigned long mqttConnectionTimeout = 0; +unsigned long mqttLastConnectionTry = 0; +unsigned long temperatureHumidityReadMillis = 0; +unsigned long lastDiagSendMillis = 0; +unsigned long lastStatePublish = 0; + +bool doorSignal = false; +bool doorPulseActive = false; +bool timeSynced = false; + +DoorState doorState = DoorUnknown; +int8_t lastDirection = 0; +unsigned long travelMs = 0; +unsigned long travelBase = 0; +unsigned long movementOrigin = 0; +bool movementOriginValid = false; +bool moving = false; +bool awaitingReed = false; +bool travelFromClosed = false; +unsigned long leadMs = T_LEAD_DEFAULT_MS; +uint16_t measuredTravelMs = 0; +uint16_t measuredLeadMs = 0; + +volatile unsigned long lastFlashMillis = 0; +bool flashActive = false; +unsigned long burstStartMillis = 0; + +int reedStable = HIGH; +int reedRaw = HIGH; +unsigned long reedRawChangeAt = 0; + +char temperatureData[20]; +char lastPayload[STATE_PAYLOAD_LEN] = ""; +uint16_t sensorId = 0; + +DiagData currentDiagData = { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }; +uint16_t otaFailures = 0; + +uint8_t signingKey[GARAGE_KEY_LEN]; +struct OpenSlot { + uint8_t nonce[GARAGE_NONCE_LEN]; + uint32_t issuedAt; + uint32_t correlationId; + bool valid; +}; +OpenSlot openSlot = { { 0 }, 0, 0, false }; +bool challengePending = false; +bool responsePending = false; +uint8_t reqBuf[4]; +uint8_t respBuf[4 + GARAGE_SIG_LEN]; + +static uint32_t readLE32(const uint8_t* p) { + return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); +} + +static void writeLE32(uint8_t* p, uint32_t v) { + p[0] = (uint8_t)v; + p[1] = (uint8_t)(v >> 8); + p[2] = (uint8_t)(v >> 16); + p[3] = (uint8_t)(v >> 24); +} + +static uint8_t hexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return 0; +} + +static bool isHexChar(char c) { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +static bool parseSigningKey() { + const char* h = SigningKeyHex; + size_t len = strnlen(h, GARAGE_KEY_LEN * 2 + 2); + if (len != (size_t)(GARAGE_KEY_LEN * 2)) { + Serial.printf("SigningKey: delka %u znaku, ocekavano %u\n", (unsigned)len, (unsigned)(GARAGE_KEY_LEN * 2)); + return false; + } + for (uint8_t i = 0; i < GARAGE_KEY_LEN * 2; i++) { + if (!isHexChar(h[i])) { + Serial.printf("SigningKey: nehexadecimalni znak na pozici %u\n", (unsigned)i); + return false; + } + } + for (uint8_t i = 0; i < GARAGE_KEY_LEN; i++) { + signingKey[i] = (hexNibble(h[i * 2]) << 4) | hexNibble(h[i * 2 + 1]); + } + return true; +} + +static bool cryptoSelfTest() { + uint8_t mac[32]; + hmac_sha256(reinterpret_cast("Jefe"), 4, + reinterpret_cast("what do ya want for nothing?"), 28, mac); + const uint8_t expected[8] = { 0x5b, 0xdc, 0xc1, 0x46, 0xbf, 0x60, 0x75, 0x4e }; + for (uint8_t i = 0; i < 8; i++) { + if (mac[i] != expected[i]) return false; + } + return true; +} + +void IRAM_ATTR OnDoorFlash() { + lastFlashMillis = millis(); +} + +static unsigned long currentTravel() +{ + if(!moving || !movementOriginValid) + { + return travelMs; + } + long delta = (long)(lastFlashMillis - movementOrigin); + if(delta <= 0) + { + return travelBase; + } + unsigned long elapsed = (unsigned long)delta; + if(lastDirection > 0) + { + unsigned long t = travelBase + elapsed; + return t > T_FULL_MS ? T_FULL_MS : t; + } + if(lastDirection < 0) + { + return elapsed >= travelBase ? 0 : travelBase - elapsed; + } + return travelBase; +} + +static int positionPercent() +{ + if(doorState == DoorUnknown) + { + return -1; + } + return (int)((currentTravel() * 100UL) / T_FULL_MS); +} + +static const char* motionText() +{ + if(!moving) + { + return "Stop"; + } + if(lastDirection > 0) + { + return "Opening"; + } + if(lastDirection < 0) + { + return "Closing"; + } + return "Move"; +} + +void PublishDoorState(bool force) +{ + char payload[STATE_PAYLOAD_LEN]; + snprintf(payload, sizeof payload, "%s;%s;%d", reedStable == HIGH ? "Closed" : "Open", motionText(), positionPercent()); + lastStatePublish = currentMillis; + if(!force && strcmp(payload, lastPayload) == 0) + { + return; + } + strlcpy(lastPayload, payload, sizeof lastPayload); + mqtt.publish(GARAGE_STATE, payload, true); +} + +void OnMovementStart(unsigned long firstFlashAt) +{ + burstStartMillis = firstFlashAt; + travelBase = travelMs; + travelFromClosed = false; + awaitingReed = false; + movementOriginValid = false; + moving = true; + + if(doorState == DoorClosed) + { + lastDirection = 1; + doorState = DoorOpening; + travelBase = 0; + awaitingReed = true; + } + else if(doorState == DoorOpen) + { + lastDirection = -1; + doorState = DoorClosing; + movementOrigin = firstFlashAt + leadMs; + movementOriginValid = true; + } + else if(doorState == DoorStopped && lastDirection != 0) + { + lastDirection = lastDirection > 0 ? -1 : 1; + doorState = lastDirection > 0 ? DoorOpening : DoorClosing; + movementOrigin = firstFlashAt + leadMs; + movementOriginValid = true; + } + else + { + lastDirection = 0; + doorState = DoorUnknown; + } + PublishDoorState(); +} + +void OnMovementEnd(unsigned long lastFlashAt) +{ + moving = false; + if(movementOriginValid) + { + long delta = (long)(lastFlashAt - movementOrigin); + unsigned long elapsed = delta > 0 ? (unsigned long)delta : 0; + if(lastDirection > 0) + { + travelMs = travelBase + elapsed; + if(travelMs > T_FULL_MS) + { + travelMs = T_FULL_MS; + } + if(travelFromClosed) + { + measuredTravelMs = elapsed > 65535UL ? 65535 : (uint16_t)elapsed; + } + } + else if(lastDirection < 0) + { + if(elapsed > travelBase + REVERSE_MARGIN_MS) + { + travelMs = T_FULL_MS; + lastDirection = 1; + currentDiagData.sensorErr |= 0x02; + } + else + { + travelMs = elapsed >= travelBase ? 0 : travelBase - elapsed; + } + } + } + movementOriginValid = false; + awaitingReed = false; + travelFromClosed = false; + + if(reedStable == HIGH) + { + travelMs = 0; + doorState = DoorClosed; + } + else if(lastDirection == 0) + { + doorState = DoorUnknown; + } + else if(travelMs + T_FULL_MARGIN_MS >= T_FULL_MS) + { + travelMs = T_FULL_MS; + doorState = DoorOpen; + } + else + { + doorState = DoorStopped; + } + PublishDoorState(); +} + +void OnReedChanged(unsigned long edgeAt) +{ + if(reedStable == HIGH) + { + travelMs = 0; + travelBase = 0; + movementOriginValid = false; + awaitingReed = false; + travelFromClosed = false; + doorState = DoorClosed; + } + else + { + if(currentDiagData.doorCycles < 0xFFFF) + { + currentDiagData.doorCycles++; + } + if(awaitingReed) + { + awaitingReed = false; + movementOrigin = edgeAt; + movementOriginValid = true; + travelBase = 0; + travelFromClosed = true; + long lead = (long)(edgeAt - burstStartMillis); + if(lead > 0) + { + leadMs = (unsigned long)lead; + measuredLeadMs = lead > 65535L ? 65535 : (uint16_t)lead; + } + } + } + PublishDoorState(); +} + +static void publishChallenge(uint32_t r) { + uint8_t buf[4 + GARAGE_NONCE_LEN]; + writeLE32(buf, r); + memcpy(buf + 4, openSlot.nonce, GARAGE_NONCE_LEN); + mqtt.publish(GARAGE_OPEN_CHALLENGE, buf, sizeof(buf), false); +} + +static void publishResult(uint32_t r, uint8_t status) { + uint8_t buf[5]; + writeLE32(buf, r); + buf[4] = status; + mqtt.publish(GARAGE_OPEN_RESULT, buf, sizeof(buf), false); +} + +static void processHandshake() { + if (challengePending) { + challengePending = false; + uint32_t r = readLE32(reqBuf); + esp_fill_random(openSlot.nonce, GARAGE_NONCE_LEN); + openSlot.issuedAt = currentMillis; + openSlot.correlationId = r; + openSlot.valid = true; + publishChallenge(r); + } + if (responsePending) { + responsePending = false; + uint32_t r = readLE32(respBuf); + uint8_t status = GARAGE_STATUS_EXPIRED; + if (openSlot.valid + && (currentMillis - openSlot.issuedAt) <= GARAGE_OPEN_TTL + && r == openSlot.correlationId) { + uint8_t msg[4 + GARAGE_NONCE_LEN + 4]; + writeLE32(msg, r); + memcpy(msg + 4, openSlot.nonce, GARAGE_NONCE_LEN); + memcpy(msg + 4 + GARAGE_NONCE_LEN, "open", 4); + uint8_t mac[32]; + hmac_sha256(signingKey, GARAGE_KEY_LEN, msg, sizeof(msg), mac); + uint8_t diff = 0; + for (uint8_t i = 0; i < GARAGE_SIG_LEN; i++) { + diff |= mac[i] ^ respBuf[4 + i]; + } + if (diff == 0) { + doorSignal = true; + openSlot.valid = false; + status = GARAGE_STATUS_OPENED; + } else { + status = GARAGE_STATUS_BADSIG; + } + } + publishResult(r, status); + } +} + +void MQTTMessageReceive(char* topic, uint8_t* payload, unsigned int length) +{ + if(strcmp(topic, GARAGE_OPEN_REQUEST) == 0 && length >= 4) + { + memcpy(reqBuf, payload, 4); + challengePending = true; + } + else if(strcmp(topic, GARAGE_OPEN_RESPONSE) == 0 && length == sizeof(respBuf)) + { + memcpy(respBuf, payload, sizeof(respBuf)); + responsePending = true; + } +} + +bool SyncTime() +{ + configTime(0, 0, "pool.ntp.org", "time.nist.gov"); + unsigned long start = millis(); + time_t now = time(nullptr); + while(now < TIME_VALID_THRESHOLD && millis() - start < TIME_SYNC_TIMEOUT_MS) + { + esp_task_wdt_reset(); + delay(200); + now = time(nullptr); + } + return now >= TIME_VALID_THRESHOLD; +} + +bool Connect() +{ + if(mqtt.connected()) + { + return true; + } + if(currentMillis - mqttLastConnectionTry < mqttConnectionTimeout) + { + return false; + } + mqttLastConnectionTry = currentMillis; + if(WiFi.status() != WL_CONNECTED) + { + if(currentDiagData.wifiReconn < 0xFFFF) + { + currentDiagData.wifiReconn++; + } + WiFi.begin(WifiSSID, WifiPassword); + unsigned long start = millis(); + while(WiFi.status() != WL_CONNECTED && millis() - start < WIFI_CONNECT_TIMEOUT_MS) + { + esp_task_wdt_reset(); + delay(100); + } + } + if(WiFi.status() != WL_CONNECTED) + { + mqttConnectionTimeout = min(mqttConnectionTimeout * 2 + random(0, 5000), MQTT_BACKOFF_MAX_MS); + return false; + } + if(!timeSynced) + { + timeSynced = SyncTime(); + if(!timeSynced) + { + mqttConnectionTimeout = min(mqttConnectionTimeout * 2 + random(0, 5000), MQTT_BACKOFF_MAX_MS); + return false; + } + } + if(currentDiagData.mqttReconn < 0xFFFF) + { + currentDiagData.mqttReconn++; + } + if(!mqtt.connect(MQTT_CLIENT_ID, MQTTUsername, MQTTPassword)) + { + mqttConnectionTimeout = min(mqttConnectionTimeout * 2 + random(0, 5000), MQTT_BACKOFF_MAX_MS); + return false; + } + mqtt.subscribe(GARAGE_OPEN_REQUEST, 1); + mqtt.subscribe(GARAGE_OPEN_RESPONSE, 1); + PublishDoorState(true); + mqttConnectionTimeout = 0; + return true; +} + +void sendDiag() +{ + currentDiagData.uptime = (uint32_t)(esp_timer_get_time() / 60000000LL); + currentDiagData.freeRamKb = (uint16_t)(ESP.getFreeHeap() / 1024); + currentDiagData.rssi = (int8_t)WiFi.RSSI(); + currentDiagData.fwVersion = (uint16_t)FW_VERSION; + currentDiagData.otaFailCount = otaFailures; + currentDiagData.lastTravelMs = measuredTravelMs; + currentDiagData.lastLeadMs = measuredLeadMs; + mqtt.publish(GARAGE_DIAG, reinterpret_cast(¤tDiagData), sizeof(DiagData), false); + currentDiagData.loopMaxMs = 0; + currentDiagData.sensorErr = 0; +} + +bool OtaAllowed() +{ + return doorState == DoorClosed + && !moving + && !doorPulseActive + && !doorSignal + && !openSlot.valid + && !challengePending + && !responsePending; +} + +void setup() { + currentDiagData.resetReason = (uint8_t)esp_reset_reason(); + + gpio_set_level((gpio_num_t)DOORBUTTON_PIN, DOORBUTTON_IDLE); + pinMode(DOORBUTTON_PIN, OUTPUT); + digitalWrite(DOORBUTTON_PIN, DOORBUTTON_IDLE); + + pinMode(DOORSWITCH_PIN, INPUT_PULLUP); + pinMode(DOORFLASH_PIN, INPUT_PULLUP); + attachInterrupt(digitalPinToInterrupt(DOORFLASH_PIN), OnDoorFlash, FALLING); + + Serial.begin(115200); + + preferences.begin("garage", false); + sensorId = preferences.getUShort("sensorId", 0); + if (sensorId == 0xFFFF || sensorId == 0) + { + sensorId = DEFAULT_SENSOR_ID; + preferences.putUShort("sensorId", sensorId); + } + + WiFi.mode(WIFI_STA); + WiFi.begin(WifiSSID, WifiPassword); + net.setCACert(MQTTCACert); + mqtt.setServer(MQTTHost, MQTT_TLS_PORT); + mqtt.setCallback(MQTTMessageReceive); + mqtt.setBufferSize(256); + mqtt.setKeepAlive(MQTT_KEEPALIVE_S); + + if (am2302.begin()) + { + delay(3000); + } + + reedRaw = digitalRead(DOORSWITCH_PIN); + reedStable = reedRaw; + doorState = reedStable == HIGH ? DoorClosed : DoorUnknown; + randomSeed(esp_random()); + + if (!parseSigningKey()) { + Serial.println("SigningKey INVALID"); + } + Serial.println(cryptoSelfTest() ? "HMAC selftest OK" : "HMAC selftest FAIL"); + + esp_task_wdt_config_t wdtConfig = { + .timeout_ms = WDT_TIMEOUT_S * 1000, + .idle_core_mask = 0, + .trigger_panic = true + }; + esp_task_wdt_reconfigure(&wdtConfig); + esp_task_wdt_add(NULL); + Serial.println("Setup OK"); +} + +void loop() { + currentMillis = millis(); + esp_task_wdt_reset(); + + if(!mqtt.connected()) + { + Connect(); + } + mqtt.loop(); + + if(digitalRead(DOORFLASH_PIN) == LOW) + { + lastFlashMillis = currentMillis; + } + unsigned long flashAt = lastFlashMillis; + bool nowFlashing = flashAt != 0 && currentMillis - flashAt < FLASH_TIMEOUT_MS; + + int raw = digitalRead(DOORSWITCH_PIN); + if(raw != reedRaw) + { + reedRaw = raw; + reedRawChangeAt = currentMillis; + } + else if(reedStable != reedRaw && currentMillis - reedRawChangeAt >= REED_DEBOUNCE_MS) + { + reedStable = reedRaw; + OnReedChanged(reedRawChangeAt); + } + + if(nowFlashing && !flashActive) + { + flashActive = true; + OnMovementStart(flashAt); + } + else if(!nowFlashing && flashActive) + { + flashActive = false; + OnMovementEnd(flashAt); + } + + if(moving && currentMillis - lastStatePublish >= MOVE_PUBLISH_INTERVAL) + { + PublishDoorState(); + } + + processHandshake(); + + if(doorSignal && !doorPulseActive) + { + digitalWrite(DOORBUTTON_PIN, DOORBUTTON_ACTIVE); + doorPulseStart = currentMillis; + doorPulseActive = true; + doorSignal = false; + } + if(doorPulseActive && currentMillis - doorPulseStart >= DOOR_PULSE_MS) + { + digitalWrite(DOORBUTTON_PIN, DOORBUTTON_IDLE); + doorPulseActive = false; + } + + if(currentMillis - temperatureHumidityReadMillis > TEMPERATURE_INTERVAL) + { + uint8_t status = am2302.read(); + if(status != AM2302::AM2302_READ_OK) + { + currentDiagData.sensorErr |= 0x01; + } + int temperature = (int)lroundf(am2302.get_Temperature() * 10); + int humidity = (int)lroundf(am2302.get_Humidity()); + sprintf(temperatureData, "%u;%d;%d;%d", sensorId, temperature, humidity, SENSOR_CHANNEL); + mqtt.publish(GARAGE_TEMPERATURE, temperatureData); + temperatureHumidityReadMillis = currentMillis; + } + + if(currentMillis - lastDiagSendMillis > DIAG_INTERVAL) + { + sendDiag(); + lastDiagSendMillis = currentMillis; + } + + if(OtaAllowed()) + { + otaLoop(); + } + + unsigned long loopDuration = millis() - currentMillis; + if(loopDuration > currentDiagData.loopMaxMs) + { + currentDiagData.loopMaxMs = (loopDuration > 65535UL) ? 65535 : (uint16_t)loopDuration; + } +} diff --git a/src/ESP32/config_default.h b/src/ESP32/config_default.h new file mode 100644 index 0000000..ae52466 --- /dev/null +++ b/src/ESP32/config_default.h @@ -0,0 +1,11 @@ +#define GARAGE_STATE "GARAGE_STATE" +#define GARAGE_TEMPERATURE "GARAGE_TEMPERATURE" +#define GARAGE_DIAG "GARAGE_DIAG" +#define GARAGE_OPEN_REQUEST "GARAGE_OPEN_REQUEST" +#define GARAGE_OPEN_CHALLENGE "GARAGE_OPEN_CHALLENGE" +#define GARAGE_OPEN_RESPONSE "GARAGE_OPEN_RESPONSE" +#define GARAGE_OPEN_RESULT "GARAGE_OPEN_RESULT" +#define GARAGE_NONCE_LEN 8 +#define GARAGE_KEY_LEN 16 +#define GARAGE_SIG_LEN 16 +#define GARAGE_OPEN_TTL 20000UL diff --git a/src/ESP32/crypto.cpp b/src/ESP32/crypto.cpp new file mode 100644 index 0000000..66865d7 --- /dev/null +++ b/src/ESP32/crypto.cpp @@ -0,0 +1,8 @@ +#include "crypto.h" +#include + +void hmac_sha256(const uint8_t* key, size_t keylen, const uint8_t* msg, size_t msglen, uint8_t out[32]) +{ + const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_hmac(info, key, keylen, msg, msglen, out); +} diff --git a/src/ESP32/crypto.h b/src/ESP32/crypto.h new file mode 100644 index 0000000..72a34a8 --- /dev/null +++ b/src/ESP32/crypto.h @@ -0,0 +1,9 @@ +#ifndef CRYPTO_H +#define CRYPTO_H + +#include +#include + +void hmac_sha256(const uint8_t* key, size_t keylen, const uint8_t* msg, size_t msglen, uint8_t out[32]); + +#endif diff --git a/src/ESP32/ota.cpp b/src/ESP32/ota.cpp new file mode 100644 index 0000000..dac0258 --- /dev/null +++ b/src/ESP32/ota.cpp @@ -0,0 +1,78 @@ +#include "ota.h" +#include +#include +#include +#include +#include "time.h" +#include "config.h" +#include "secret.h" + +#ifndef OTA_CHECK_INTERVAL_MS +#define OTA_CHECK_INTERVAL_MS (60UL * 60UL * 1000UL) +#endif +#ifndef FW_VERSION +#define FW_VERSION 0 +#endif +#define OTA_TIME_VALID_THRESHOLD 1700000000UL + +extern uint16_t otaFailures; + +unsigned long otaLastCheck = 0; +bool otaFirstRun = true; + +void doOTA() +{ + Serial.printf("OTA: kontrola z %s (verze %d)\n", OtaUrl, (int)FW_VERSION); + WiFiClientSecure client; + client.setCACert(OtaRootCA); + + HTTPUpdate updater(30000); + updater.setAuthorization(OtaUser, OtaPassword); + updater.onStart([]() { esp_task_wdt_reset(); }); + updater.onEnd([]() { esp_task_wdt_reset(); }); + updater.onProgress([](int cur, int total) { esp_task_wdt_reset(); }); + updater.onError([](int err) { Serial.printf("OTA: chyba %d\n", err); }); + updater.rebootOnUpdate(true); + + t_httpUpdate_return ret = updater.update(client, OtaUrl, String((int)FW_VERSION)); + + switch(ret) + { + case HTTP_UPDATE_FAILED: + Serial.printf("OTA: SELHALA (%d): %s\n", updater.getLastError(), updater.getLastErrorString().c_str()); + if(otaFailures < 65535) + { + otaFailures++; + } + break; + case HTTP_UPDATE_NO_UPDATES: + Serial.println("OTA: firmware je aktualni."); + break; + case HTTP_UPDATE_OK: + Serial.println("OTA: OK."); + break; + } +} + +void otaLoop() +{ + if(!otaFirstRun && millis() - otaLastCheck < OTA_CHECK_INTERVAL_MS) + { + return; + } + if(WiFi.status() != WL_CONNECTED) + { + return; + } + if(time(nullptr) < OTA_TIME_VALID_THRESHOLD) + { + return; + } + if(otaFirstRun) + { + Serial.printf("OTA: interval kontroly %lu ms\n", (unsigned long)OTA_CHECK_INTERVAL_MS); + } + otaFirstRun = false; + otaLastCheck = millis(); + doOTA(); +} diff --git a/src/ESP32/ota.h b/src/ESP32/ota.h new file mode 100644 index 0000000..5b30e8e --- /dev/null +++ b/src/ESP32/ota.h @@ -0,0 +1,6 @@ +#ifndef OTA_H +#define OTA_H + +void otaLoop(); + +#endif diff --git a/src/ESP32/secret_default.h b/src/ESP32/secret_default.h new file mode 100644 index 0000000..d60cdab --- /dev/null +++ b/src/ESP32/secret_default.h @@ -0,0 +1,19 @@ +#define WifiSSID "WifiSSID" +#define WifiPassword "Password" +#define MQTTUsername "UserName" +#define MQTTPassword "Password" +#define MQTTHost "Host" +#define SigningKeyHex "000102030405060708090a0b0c0d0e0f" +#define MQTTCACert \ +"-----BEGIN CERTIFICATE-----\n" \ +"REPLACE_WITH_BROKER_CA_CERT_PEM\n" \ +"-----END CERTIFICATE-----\n" +#define OtaUrl "https://OtaHost/garage.bin" +#define OtaUser "OtaUser" +#define OtaPassword "OtaPassword" +#define OTA_CHECK_INTERVAL_MS 3600000 +#define OtaRootCA R"EOF( +-----BEGIN CERTIFICATE----- +REPLACE_WITH_OTA_SERVER_ROOT_CA_PEM +-----END CERTIFICATE----- +)EOF" diff --git a/src/ESP32/sketch.yaml b/src/ESP32/sketch.yaml new file mode 100644 index 0000000..868c9bf --- /dev/null +++ b/src/ESP32/sketch.yaml @@ -0,0 +1,12 @@ +profiles: + Garage_ESP32: + fqbn: esp32:esp32:esp32:PartitionScheme=default + platforms: + - platform: esp32:esp32 (3.2.0) + platform_index_url: https://raw.githubusercontent.com/espressif/arduino-esp32/gh-pages/package_esp32_index.json + libraries: + - PubSubClient (2.8.0) + - AM2302-Sensor (1.4.0) + port_config: + baudrate: 115200 +default_profile: Garage_ESP32 \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..d6d470e --- /dev/null +++ b/tests/README.md @@ -0,0 +1,46 @@ +# Testy + +Ruční testy proti živému zařízení. Nejsou součástí CI – potřebují běžící desku, +broker a vyplněný `secret.h`. + +## garage_open_test.py + +Zastupuje OdectyStat v secure-open handshaku a ověřuje, že firmware povel +správně přijme i odmítne. Podpisový klíč, credentials, CA i názvy topiců si čte +z `../src/ESP32/config.h` a `secret.h`, takže se nic nezadává ručně. + +``` +pip install paho-mqtt + +python garage_open_test.py # platný podpis -> OPENED +python garage_open_test.py --badsig # prohozený bit -> BADSIG +python garage_open_test.py --expire # odpověď až po TTL -> EXPIRED +python garage_open_test.py --replay # stejný podpis 2x -> podruhé EXPIRED +``` + +Návratový kód je 0, když status odpovídá očekávání. Jiný sketch se dá zvolit +přes `--sketch-dir`. + +**Posílá skutečný povel k otevření vrat.** Spouštět jen proti sketchi, který má +v `config.h` testovací topicy – proti produkčním názvům by povel dorazil do +garáže. Zkontroluj si to před prvním během. + +## Simulace stavového automatu bez hardwaru + +Polohu vrat lze odladit dvěma propojkami na GND: + +- **`DOORFLASH` na GND** = maják bliká; délka přidržení = délka jízdy +- **`DOORSWITCH` na GND** = vrata nejsou zavřená; odpojení = reed hlásí zavřeno + +Průběh se sleduje na retained topicu `GARAGE_STATE` ve tvaru +`;;`. + +Ověřit se dá zejména: + +- střídání směru při každém rozjezdu (`Opening` -> `Closing` -> `Opening`) +- předblik – rozjezd z `Closed` nezačne počítat, dokud nepustí reed +- detekce reverzace – zavírání delší než ujetá vzdálenost skončí na 100 % + a nastaví bit `0x02` v `sensorErr` + +Naměřené `lastTravelMs` a `lastLeadMs` chodí v `GARAGE_DIAG` a slouží +ke kalibraci `T_FULL_MS` a `T_LEAD_DEFAULT_MS`. diff --git a/tests/garage_open_test.py b/tests/garage_open_test.py new file mode 100644 index 0000000..38e74b5 --- /dev/null +++ b/tests/garage_open_test.py @@ -0,0 +1,188 @@ +"""Testovaci podepisovatel pro secure-open handshake garaze. + +Zastupuje OdectyStat a overuje, ze firmware handshake spravne prijme i odmitne. +Konfiguraci, credentials i podpisovy klic cte primo z config.h / secret.h +prislusneho sketche, nic se nezadava rucne a nic se nevypisuje na obrazovku. + +Pouziti: + python garage_open_test.py # platny podpis -> OPENED + python garage_open_test.py --badsig # prohozeny bit -> BADSIG + python garage_open_test.py --expire # odpoved az po TTL -> EXPIRED + python garage_open_test.py --replay # stejny podpis 2x -> podruhe EXPIRED + +Vyzaduje paho-mqtt (pip install paho-mqtt). + +POZOR: posila povel k otevreni vrat. Spoustet jen proti sketchi, ktery ma +v config.h testovaci topicy, jinak povel dorazi na produkcni garaz. +""" + +import argparse +import hashlib +import hmac +import os +import re +import secrets +import ssl +import struct +import sys +import tempfile +import threading +import time + +import paho.mqtt.client as mqtt + +DEFAULT_SKETCH_DIR = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src", "ESP32") +) + +STATUS = {1: "OPENED", 2: "EXPIRED", 3: "BADSIG"} + + +def parse_defines(path): + """Vytahne #define NAME hodnota. + + Zvlada obe formy, ktere se v secret.h vyskytuji: + - viceradkove retezcove konkatenace s backslash pokracovanim + - surove retezce R"DELIM( ... )DELIM" pres vic radku + """ + with open(path, encoding="utf-8") as fh: + text = fh.read() + out = {} + + raw_re = re.compile(r'#define\s+(\w+)\s+R"([^("]*)\((.*?)\)\2"', re.S) + for m in raw_re.finditer(text): + out[m.group(1)] = m.group(3).strip() + "\n" + text = raw_re.sub("", text) + + text = re.sub(r"\\\r?\n", " ", text) + for line in text.splitlines(): + m = re.match(r'\s*#define\s+(\w+)\s+(.*)', line) + if not m: + continue + name, rest = m.group(1), m.group(2).strip() + parts = re.findall(r'"((?:[^"\\]|\\.)*)"', rest) + if parts: + out[name] = "".join(parts).replace("\\n", "\n").replace('\\"', '"') + elif rest: + out[name] = rest + return out + + +def sign(key, correlation_id, nonce): + msg = struct.pack(" request R={state['r']} ({payload.hex(' ')})") + + def on_message(client, userdata, msg): + if msg.topic == t_chal: + if len(msg.payload) != 4 + nonce_len: + print(f"!! challenge ma {len(msg.payload)} B, cekano {4 + nonce_len}") + return + r, = struct.unpack(" response {len(out)} B sig={state['sig'].hex(' ')}") + + elif msg.topic == t_res: + if len(msg.payload) != 5: + print(f"!! result ma {len(msg.payload)} B, cekano 5") + return + r, status = struct.unpack("