diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ce7be8..e2d5bdb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,9 +29,12 @@ 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 LSSensor_Uno ./ + arduino-cli compile ./src/Arduino + arduino-cli compile ./src/ESP32 diff --git a/.github/workflows/deployment.yml b/.github/workflows/deployment.yml index 014d7f8..07433eb 100644 --- a/.github/workflows/deployment.yml +++ b/.github/workflows/deployment.yml @@ -1,4 +1,4 @@ -name: Deploy LS Sensor on Environment +name: Deploy Arduino LS Sensor on Environment on: workflow_dispatch: @@ -18,10 +18,10 @@ permissions: jobs: deployment: - uses: Zefek/GithubActions/.github/workflows/arduino-build-deploy.yml@v1 + uses: Zefek/GithubActions/.github/workflows/arduino-build-deploy.yml@c2872a07ea6ff49f3105cf3b8faef0e7169236c7 with: environment: ${{ inputs.environment }} serial_port: ${{ inputs.serial_port }} - device_name: LSSensor - source_dir: "." + device_name: LSSensorArduino + source_dir: "./src/Arduino" secrets: inherit diff --git a/.github/workflows/deployment_esp32.yml b/.github/workflows/deployment_esp32.yml new file mode 100644 index 0000000..7b5867e --- /dev/null +++ b/.github/workflows/deployment_esp32.yml @@ -0,0 +1,98 @@ +name: Deploy LS Sensor ESP32 on Environment + +on: + workflow_dispatch: + inputs: + serial_port: + required: false + type: string + environment: + required: true + type: choice + options: + - Home + +permissions: + contents: read + +jobs: + deployment: + runs-on: [self-hosted, iot] + environment: ${{ inputs.environment }} + env: + REPO: ${{ vars.REPO }} + steps: + + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install required board core + run: | + & "$Env:RUNNER_TOOL_CACHE\arduino-cli\arduino-cli.exe" --config-file config.yaml core update-index + + + - uses: zefek/GithubActions/Actions/deployConfig@c2872a07ea6ff49f3105cf3b8faef0e7169236c7 + with: + repo: ${{ env.REPO }} + auth: ${{ secrets.REPO_TOKEN }} + dest: ${{ runner.temp }}\secrets + device: LSSensorESP32 + outdir: ${{ github.workspace }}\src\ESP32 + + - name: Compile Arduino project + run: | + & "$Env:RUNNER_TOOL_CACHE\arduino-cli\arduino-cli.exe" --config-file config.yaml compile ./src/ESP32 --output-dir build --build-property "compiler.cpp.extra_flags=-DFW_VERSION=${{ github.run_number }}" + + - name: Upload firmware to Arduino + if: ${{ github.event.inputs.serial_port != '' }} + run: | + $port = "${{ github.event.inputs.serial_port }}" + & "$Env:RUNNER_TOOL_CACHE\arduino-cli\arduino-cli.exe" --config-file config.yaml upload ./src/ESP32 --port $port --input-dir build + + - name: Uložení firmware + if: ${{ github.event.inputs.serial_port == '' }} + shell: powershell + env: + FIRMWARE_TARGET_PATH: ${{ vars.FIRMWARE_TARGET_PATH }} + FW_VERSION: ${{ github.run_number }} + GIT_SHA: ${{ github.sha }} + run: | + $ErrorActionPreference = 'Stop' + if (-not $env:FIRMWARE_TARGET_PATH) { throw "FIRMWARE_TARGET_PATH variable is not set" } + + $buildDir = Join-Path $env:GITHUB_WORKSPACE 'build' + $version = $env:FW_VERSION + + # Aplikační image pro OTA (ESP32 nahrává *.ino.bin, ne bootloader/partitions) + $appBin = Get-ChildItem -Path $buildDir -Filter '*.ino.bin' -File | Select-Object -First 1 + if (-not $appBin) { throw "Application firmware (*.ino.bin) not found in $buildDir" } + + # 1) Historie: celý build do podadresáře pojmenovaného verzí + $versionDir = Join-Path $env:FIRMWARE_TARGET_PATH $version + New-Item -ItemType Directory -Path $versionDir -Force | Out-Null + Copy-Item -Path "$buildDir\*" -Destination $versionDir -Recurse -Force + + # 2) OTA: pevná cesta na poslední firmware + manifest + if (-not (Test-Path $env:FIRMWARE_TARGET_PATH)) { + New-Item -ItemType Directory -Path $env:FIRMWARE_TARGET_PATH -Force | Out-Null + } + $latestBin = Join-Path $env:FIRMWARE_TARGET_PATH 'firmware.bin' + Copy-Item -Path $appBin.FullName -Destination $latestBin -Force + + $hash = (Get-FileHash -Path $latestBin -Algorithm SHA256).Hash.ToLower() + $size = (Get-Item $latestBin).Length + + # Jednoduchý key=value manifest – ESP32 ho rozparsuje bez JSON knihovny + $manifest = @( + "version=$version" + "file=firmware.bin" + "size=$size" + "sha256=$hash" + "commit=$($env:GIT_SHA)" + ) + $manifestPath = Join-Path $env:FIRMWARE_TARGET_PATH 'manifest.txt' + # LF konce řádků a bez BOM – snazší parsování na zařízení + [System.IO.File]::WriteAllText($manifestPath, ($manifest -join "`n") + "`n") + + Write-Host "Firmware verze $version uložen do $versionDir; OTA: $latestBin (manifest $manifestPath)" + diff --git a/.gitignore b/.gitignore index 0e56cf2..53526fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ config.h +secret.h diff --git a/LSSensor.ino b/src/Arduino/Arduino.ino similarity index 95% rename from LSSensor.ino rename to src/Arduino/Arduino.ino index b607bdc..2652c59 100644 --- a/LSSensor.ino +++ b/src/Arduino/Arduino.ino @@ -26,6 +26,7 @@ int wattMetter2Counter = 0; unsigned long lastSendToMQTT = 0; unsigned long lastTime = 0; bool closeRequired = false; +bool initialZeroSent = false; #pragma pack(push, 1) struct DiagData { @@ -143,8 +144,12 @@ void loop() { detachInterrupt(digitalPinToInterrupt(LSSensorPIN2)); if(Connect()) { - sprintf(data, "{\"V\":%d,\"S\":%d}", 0, 0); - mqttClient.Publish(ELCONSUMPTION, data); + if(!initialZeroSent) + { + sprintf(data, "{\"V\":%d,\"S\":%d}", 0, 0); + mqttClient.Publish(ELCONSUMPTION, data); + initialZeroSent = true; + } sprintf(data, "{\"V\":%d,\"S\":%d}", wattMetter1Counter, wattMetter2Counter); mqttClient.Publish(ELCONSUMPTION, data); @@ -156,7 +161,6 @@ void loop() { currentDiagData.rssi = espDrv.GetRssi(); mqttClient.Publish(LSSENSOR_DIAG, (const uint8_t*)¤tDiagData, sizeof(DiagData), false); currentDiagData.loopMaxMs = 0; - mqttClient.Disconnect(); } lastSendToMQTT = currentMillis; attachInterrupt(digitalPinToInterrupt(LSSensorPIN1), WattMetter1Received, RISING); 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/sketch.yaml b/src/Arduino/sketch.yaml similarity index 66% rename from sketch.yaml rename to src/Arduino/sketch.yaml index c4c7be5..6810ade 100644 --- a/sketch.yaml +++ b/src/Arduino/sketch.yaml @@ -4,5 +4,5 @@ profiles: platforms: - platform: arduino:avr (1.8.7) libraries: - - dir: MQTTESP8266 -default_profile: LSSensor_Uno + - dir: ../../MQTTESP8266 +default_profile: LSSensor_Uno \ No newline at end of file diff --git a/src/ESP32/ESP32.ino b/src/ESP32/ESP32.ino new file mode 100644 index 0000000..3a1bdd2 --- /dev/null +++ b/src/ESP32/ESP32.ino @@ -0,0 +1,223 @@ +#include +#include +#include +#include "time.h" +#include "esp_task_wdt.h" +#include "config.h" +#include "secret.h" +#include "ota.h" + +#ifndef FW_VERSION +#define FW_VERSION 0 +#endif +#define LSSensorPIN1 32 +#define LSSensorPIN2 33 +#define PIN_PULL INPUT +#define PIN_EDGE RISING +#define PULSE_DEBOUNCE_MS 84 +#define PIN_DIAG_INTERVAL_MS 500UL +#define PULSE_INTERVAL 60000UL +#define DIAG_INTERVAL 300000UL +#define WDT_TIMEOUT_S 90 +#define WIFI_CONNECT_TIMEOUT_MS 15000UL +#define TIME_SYNC_TIMEOUT_MS 15000UL +#define MQTT_TLS_PORT 8883 +#define TIME_VALID_THRESHOLD 1700000000UL + +WiFiClientSecure net; +PubSubClient mqtt(net); + +volatile uint32_t counter1 = 0; +volatile uint32_t counter2 = 0; +volatile uint32_t lastTime1 = 0; +volatile uint32_t lastTime2 = 0; +portMUX_TYPE mux = portMUX_INITIALIZER_UNLOCKED; + +char data[32]; +unsigned long lastPulseSend = 0; +unsigned long lastDiagSend = 0; +unsigned long lastPinDiag = 0; +bool initialZeroSent = false; +bool timeSynced = false; + +#pragma pack(push, 1) +struct DiagData { + uint32_t uptime; + uint16_t freeRamKb; + uint16_t wifiReconn; + uint16_t mqttFailCount; + uint8_t resetReason; + uint16_t loopMaxMs; + int8_t rssi; + uint16_t fwVersion; + uint16_t otaFailCount; +}; +#pragma pack(pop) +static_assert(sizeof(DiagData) == 18, "DiagData wire layout must stay 18 bytes"); + +DiagData currentDiagData; +uint16_t otaFailures = 0; + +uint16_t heapKb() +{ + return (uint16_t)(ESP.getFreeHeap() / 1024); +} + +void IRAM_ATTR WattMeter1Received() +{ + uint32_t t = millis(); + portENTER_CRITICAL_ISR(&mux); + if(t - lastTime1 > PULSE_DEBOUNCE_MS) + { + counter1++; + lastTime1 = t; + } + portEXIT_CRITICAL_ISR(&mux); +} + +void IRAM_ATTR WattMeter2Received() +{ + uint32_t t = millis(); + portENTER_CRITICAL_ISR(&mux); + if(t - lastTime2 > PULSE_DEBOUNCE_MS) + { + counter2++; + lastTime2 = t; + } + portEXIT_CRITICAL_ISR(&mux); +} + +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(WiFi.status() != WL_CONNECTED) + { + if(currentDiagData.wifiReconn < 65535) + { + 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) + { + return false; + } + if(!timeSynced) + { + timeSynced = SyncTime(); + if(!timeSynced) + { + return false; + } + } + if(!mqtt.connected()) + { + bool ok = mqtt.connect("WattMeter", MQTTUsername, MQTTPassword); + if(!ok && currentDiagData.mqttFailCount < 65535) + { + currentDiagData.mqttFailCount++; + } + return ok; + } + return true; +} + +void setup() { + currentDiagData.resetReason = (uint8_t)esp_reset_reason(); + Serial.begin(115200); + pinMode(LSSensorPIN1, PIN_PULL); + pinMode(LSSensorPIN2, PIN_PULL); + WiFi.mode(WIFI_STA); + WiFi.begin(WifiSSID, WifiPassword); + net.setCACert(MQTTCACert); + mqtt.setServer(MQTTHost, MQTT_TLS_PORT); + mqtt.setBufferSize(256); + mqtt.setKeepAlive(60); + attachInterrupt(digitalPinToInterrupt(LSSensorPIN1), WattMeter1Received, PIN_EDGE); + attachInterrupt(digitalPinToInterrupt(LSSensorPIN2), WattMeter2Received, PIN_EDGE); + 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() { + unsigned long currentMillis = millis(); + esp_task_wdt_reset(); + mqtt.loop(); + + bool pulseDue = !initialZeroSent || currentMillis - lastPulseSend >= PULSE_INTERVAL; + bool diagDue = currentMillis - lastDiagSend >= DIAG_INTERVAL; + + if(pulseDue || diagDue) + { + portENTER_CRITICAL(&mux); + uint32_t c1 = counter1; + uint32_t c2 = counter2; + portEXIT_CRITICAL(&mux); + + bool connected = Connect(); + + if(pulseDue) + { + if(connected) + { + if(!initialZeroSent) + { + sprintf(data, "{\"V\":%lu,\"S\":%lu}", 0UL, 0UL); + mqtt.publish(ELCONSUMPTION, data); + initialZeroSent = true; + } + sprintf(data, "{\"V\":%lu,\"S\":%lu}", (unsigned long)c1, (unsigned long)c2); + mqtt.publish(ELCONSUMPTION, data); + } + lastPulseSend = currentMillis; + } + + if(diagDue) + { + if(connected) + { + currentDiagData.uptime = currentMillis / 60000UL; + currentDiagData.freeRamKb = heapKb(); + currentDiagData.rssi = (int8_t)WiFi.RSSI(); + currentDiagData.fwVersion = (uint16_t)FW_VERSION; + currentDiagData.otaFailCount = otaFailures; + mqtt.publish(LSSENSOR_DIAG, (const uint8_t*)¤tDiagData, sizeof(DiagData), false); + currentDiagData.loopMaxMs = 0; + } + lastDiagSend = currentMillis; + } + } + + otaLoop(); + + unsigned long iterDur = millis() - currentMillis; + if(iterDur > currentDiagData.loopMaxMs) + { + currentDiagData.loopMaxMs = (iterDur > 65535UL) ? 65535 : (uint16_t)iterDur; + } +} diff --git a/src/ESP32/config_default.h b/src/ESP32/config_default.h new file mode 100644 index 0000000..e7e6f4f --- /dev/null +++ b/src/ESP32/config_default.h @@ -0,0 +1,2 @@ +#define ELCONSUMPTION "ELCONSUMPTION" +#define LSSENSOR_DIAG "LSSENSOR_DIAG" 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..bcae739 --- /dev/null +++ b/src/ESP32/ota.h @@ -0,0 +1,3 @@ +#pragma once + +void otaLoop(); diff --git a/src/ESP32/secret_default.h b/src/ESP32/secret_default.h new file mode 100644 index 0000000..90a79e5 --- /dev/null +++ b/src/ESP32/secret_default.h @@ -0,0 +1,18 @@ +#define WifiSSID "WifiSSID" +#define WifiPassword "WifiPassword" +#define MQTTUsername "MQTTUsername" +#define MQTTPassword "MQTTPassword" +#define MQTTHost "MQTTHost" +#define MQTTCACert \ +"-----BEGIN CERTIFICATE-----\n" \ +"REPLACE_WITH_BROKER_CA_CERT_PEM\n" \ +"-----END CERTIFICATE-----\n" +#define OtaUrl "https://OtaHost/lssensor.bin" +#define OtaUser "OtaUser" +#define OtaPassword "OtaPassword" +#define OTA_CHECK_INTERVAL_MS 60000 +#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..3bad03d --- /dev/null +++ b/src/ESP32/sketch.yaml @@ -0,0 +1,11 @@ +profiles: + LSSensor_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) + port_config: + baudrate: 115200 +default_profile: LSSensor_ESP32