diff --git a/.github/workflows/completion.yml b/.github/workflows/completion.yml
new file mode 100644
index 0000000..979da7a
--- /dev/null
+++ b/.github/workflows/completion.yml
@@ -0,0 +1,22 @@
+name: Completion gates
+on: [push, pull_request]
+permissions:
+ contents: read
+jobs:
+ host-board:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - run: |
+ pip install PyYAML==6.0.2
+ python -c "import yaml; yaml.safe_load(open('config/home-assistant.yaml'))"
+ python tools/validate.py
+ g++ -std=c++17 tests/usage_test.cpp -o /tmp/alerts
+ /tmp/alerts
+ python -m unittest discover -s tests
+ python tools/validate_completion.py
+ pip install platformio==6.1.18
+ pio run -e nodemcuv2
diff --git a/.github/workflows/decode-image.yml b/.github/workflows/decode-image.yml
new file mode 100644
index 0000000..b585a2e
--- /dev/null
+++ b/.github/workflows/decode-image.yml
@@ -0,0 +1,62 @@
+name: Decode project image and validate
+on:
+ push:
+ branches: ['automation/**']
+ paths:
+ - 'docs/images/project-overview.png.b64.*'
+ - 'docs/images/project-overview.manifest.json'
+ - 'tools/decode_project_image.py'
+ - '.github/workflows/decode-image.yml'
+permissions:
+ contents: write
+concurrency:
+ group: decode-image-${{ github.ref }}
+ cancel-in-progress: false
+jobs:
+ recover:
+ if: startsWith(github.ref, 'refs/heads/automation/')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref }}
+ fetch-depth: 1
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ - name: Guard on pending transport
+ id: pending
+ run: |
+ if compgen -G 'docs/images/project-overview.png.b64.*' > /dev/null; then
+ echo 'present=true' >> "$GITHUB_OUTPUT"
+ else
+ echo 'present=false' >> "$GITHUB_OUTPUT"
+ fi
+ - name: Validate and losslessly decode PNG
+ if: steps.pending.outputs.present == 'true'
+ run: python tools/decode_project_image.py
+ - name: Commit decoded image to same branch
+ if: steps.pending.outputs.present == 'true'
+ env:
+ BRANCH_REF: ${{ github.ref }}
+ run: |
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git add -- docs/images/project-overview.png docs/images/project-overview.png.b64.*
+ git diff --cached --quiet && exit 0
+ git commit -m 'Decode validated generated project image'
+ git push origin "HEAD:$BRANCH_REF"
+ # GITHUB_TOKEN pushes may not trigger push workflows. Run gates explicitly
+ # on the resulting image commit here; the cloud agent also verifies that SHA.
+ - name: Run full completion gates on decoded commit
+ if: steps.pending.outputs.present == 'true'
+ run: |
+ pip install PyYAML==6.0.2
+ python -c "import yaml; yaml.safe_load(open('config/home-assistant.yaml'))"
+ python tools/validate.py
+ g++ -std=c++17 tests/usage_test.cpp -o /tmp/alerts
+ /tmp/alerts
+ python -m unittest discover -s tests
+ python tools/validate_completion.py
+ pip install platformio==6.1.18
+ pio run -e nodemcuv2
diff --git a/.gitignore b/.gitignore
index ad5ca30..dde9d8f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,4 @@ __pycache__/
.idea/
.pio/
build/
+firmware/config.private.h
diff --git a/README.md b/README.md
index 5fb523c..ec12c51 100644
--- a/README.md
+++ b/README.md
@@ -1,43 +1,77 @@
# Smart Entryway Usage Analytics
+An ESP8266 NodeMCU displays BME280 temperature/humidity, drives an RGB indicator, and measures **actual enabled-indicator time** in hourly and daily buckets. Matter control is provided through an explicit Home Assistant/Matterbridge bridge, not a native ESP8266 Matter implementation.
-Build a smart home prototype that uses RGB LED, BME280, OLED display to summarize daily and weekly usage. Include setup instructions, a circuit diagram, tested firmware, and sample output.
+
-## Project details
+## Overview, objectives and features
+Learn interval attribution, ring-buffer summaries and time rollover. USB/MQTT ON/OFF requests indicator enable. Valid readings drive red at humidity≥70%, blue below15°C, otherwise green. Invalid sensor data blanks RGB and is not counted. OLED shows readings, request/actual state, current-day minutes and seven-day-slot minutes.
-| Field | Value |
-| --- | --- |
-| Roadmap ID | 17 |
-| Category | Smart Home |
-| Platform | ESP8266 |
-| Difficulty | Intermediate |
-| Estimated build time | 32 hours |
-| Connectivity | Matter |
-| Core components | RGB LED, BME280, OLED display |
-| Control mode | data logger |
+## Architecture and platform
+NodeMCU nodemcuv2 Arduino, real BME280/SSD1306 drivers and PubSubClient. [Usage counter](firmware/usage.h) maintains24hour and7day buckets. [Architecture](docs/architecture.md). MQTT→HA entity→Matterbridge-hass→Matter controller is the interoperability path. Counters and physical display work locally over USB without the bridge.
-## Repository layout
+## BOM quantities
+| Qty | Item |
+|---:|---|
+| 1 | ESP8266 NodeMCU and USB data/power cable |
+| 1 | BME280 I2C breakout0x76 supporting3.3V |
+| 1 | SSD1306 I2C128×64 OLED0x3C supporting3.3V |
+| 1 | Common-cathode RGB LED |
+| 3 | 330Ω resistors |
+| 1 each | Breadboard and jumper set |
+| 1 each for Matter | MQTT broker, HA instance, Matterbridge host and Matter controller |
-- `firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino`: runnable firmware or application
-- `docs/wiring.md`: suggested low-voltage wiring plan
-- `docs/architecture.md`: system data flow
-- `docs/test-plan.md`: repeatable verification steps
-- `sample-data/example.json`: example telemetry record
-- `tools/validate.py`: dependency-free repository validation
+## Prerequisites and exact pin map
+Python3.12, PlatformIO6.1.18 and USB serial driver. All sensor pullups and GPIO must be3.3V.
+| NodeMCU | Connection |
+|---|---|
+| D2/GPIO4 | Both SDA |
+| D1/GPIO5 | Both SCL |
+| D6/GPIO12 | 330Ω→red anode |
+| D7/GPIO13 | 330Ω→green anode |
+| D5/GPIO14 | 330Ω→blue anode |
+| 3V3 | BME/OLED VCC and BME CSB |
+| GND | BME/OLED GND, BME SDO, RGB common cathode |
-## Quick start
+## Circuit/wiring and assembly
+Disconnect USB; follow [editable circuit](docs/circuit-diagram.svg) and [wiring](docs/wiring.md). Connect sharedI2C, inspect addresses and LED pinout. Each color needs a separate resistor. Avoid5V I2C pullups. Verify BME model (BMP280 lacks humidity) and set SDO low for0x76 before power.
-1. Open `firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino` in Arduino IDE or Arduino CLI.
-2. Select the board matching **ESP8266**.
-3. Compile and upload, then open the serial monitor at 115200 baud.
+## Setup and flashing
+```sh
+python -m pip install platformio==6.1.18
+pio run -e nodemcuv2
+pio run -e nodemcuv2 -t upload
+pio device monitor -b 115200
+```
+[Source](firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino). Default Wi-Fi/MQTT strings are blank; USB ON/OFF works independently. For network operation create ignored firmware/config.private.h with WIFI_SSID,WIFI_PASSWORD,MQTT_HOST,MQTT_USER,MQTT_PASSWORD defines, then rebuild privately. Never commit credentials.
-## Expected behavior
+## Configuration and Matter setup
+Use port1883 only on an isolated lab network; secure transport is future work. Configure the HA MQTT integration with your broker. Merge [HA YAML](config/home-assistant.yaml) into existing configuration and restart/reload the integration. Confirm switch.entryway17_indicator exists (adjust entity ID to actual HA discovery naming). Install the official Matterbridge HA Application or standalone Matterbridge and the [matterbridge-hass plugin](https://github.com/Luligu/matterbridge-hass). Configure its HA host and private HA access token **outside Git**, create a filter label, apply it to the Entryway17 switch and temperature/humidity sensors, restart and verify the exported devices, then pair Matterbridge’s QR code with the controller. The plugin explicitly supports HA switches and environmental sensors. This bridge setup and pairing are documented but have not been executed here.
-Usage Analytics demonstration with repeatable test steps. The default implementation supports simulated or generic analog inputs so the control path can be exercised before hardware-specific drivers are added.
+## Usage, telemetry/data formats and expected output
+Send newline-terminated ON/OFF at115200, publish exact non-retained ON/OFF to entry17/command, or toggle the bridged switch. entry17/switch/state reflects the **request**, while JSON active reflects physical indicator eligibility. Actual LED hardware behavior is untested. Retained entry17/state and USB JSON contain id17, uptime_s, valid, temperature_c, humidity_pct, requested, active, hour_ms, day_ms, slots24_ms, slots7day_ms. [Sample](sample-data/telemetry.jsonl). Invalid values use0 placeholders with valid=false; do not interpret them as measurements. LWT/birth entry17/status is offline/online; commands are best-effortQoS0.
+After one valid enabled minute, day_ms increases approximately60000; after OFF it stops. The previous sampled state is attributed to each elapsed interval (1s sampling uncertainty). OLED day/week values are integer minutes.
-## Hardware adaptation
+## Time model and limitations
+“Day” is an86400000ms interval since first sampling after reboot, not a calendar day. The24hour summary contains current and23previous hourly slots; the7day summary contains current and6previous day slots. Boundary eviction is exact but summaries are coarse buckets, not exact sliding24h/168h windows. Counts are RAM-only and reset on reboot. millis wrap is extended through periodic samples; a sampling gap≥49.7days is unsupported. This measures indicator enabled time, not occupancy, lamp energy, household usage or calibrated comfort. OLED failure does not diagnose sensor health.
-The included code is a safe reference implementation. Update pin assignments and sensor conversions from the exact component datasheets, then repeat the test plan before connecting actuators.
+## Actual run tests
+[Run results](docs/validation-results.md) record meaningful C++ counter tests,3image tests, artifact/credential checks and actual NodeMCU compilation. [Test plan](docs/test-plan.md). Hardware calibration, MQTT/HA networking and Matter pairing have not been tested.
+```sh
+g++ -std=c++17 tests/usage_test.cpp -o /tmp/usage
+/tmp/usage
+python -m unittest discover -s tests
+python tools/validate.py
+python tools/validate_completion.py
+```
-## License
+## Troubleshooting
+No readings: verify BME0x76 and3.3V wiring. OLED blank: verify SSD1306 dimensions/address0x3C. Counter not increasing: requestedON and valid must both be true. Wrong colors: common cathode/anode mapping. Matter missing: confirm HA switch first, plugin filter labels, HA token/host and bridge pairing; ESP8266 itself does not advertise Matter.
-MIT
+## Domain safety
+Low voltage indicator only; never connect mains, locks or critical equipment. Keep3.3V GPIO isolated from5V. Environmental thresholds are demonstration values, not health warnings. Restrict the unencrypted lab MQTT network and keep tokens/passwords out of commits.
+
+## Future work
+Add authenticated telemetry, calibrated sensors, qualified flash persistence and real calendar timestamps. Verify bridge interoperability on physical controllers.
+
+## Contributing and license
+Keep counter boundary/rollover coverage and pin-map consistency; distinguish mock data from measured data. Full [MIT license](LICENSE).
diff --git a/config/home-assistant.yaml b/config/home-assistant.yaml
new file mode 100644
index 0000000..f17433c
--- /dev/null
+++ b/config/home-assistant.yaml
@@ -0,0 +1,31 @@
+# Merge with your existing MQTT configuration; configure broker through HA UI.
+mqtt:
+ switch:
+ - name: Entryway17 Indicator
+ unique_id: entryway17_indicator
+ command_topic: entry17/command
+ state_topic: entry17/switch/state
+ payload_on: "ON"
+ payload_off: "OFF"
+ availability:
+ - topic: entry17/status
+ sensor:
+ - name: Entryway17 Temperature
+ unique_id: entryway17_temperature
+ state_topic: entry17/state
+ value_template: "{{ value_json.temperature_c if value_json.valid else none }}"
+ device_class: temperature
+ unit_of_measurement: "°C"
+ state_class: measurement
+ - name: Entryway17 Humidity
+ unique_id: entryway17_humidity
+ state_topic: entry17/state
+ value_template: "{{ value_json.humidity_pct if value_json.valid else none }}"
+ device_class: humidity
+ unit_of_measurement: "%"
+ state_class: measurement
+ - name: Entryway17 Day Enabled Minutes
+ unique_id: entryway17_day_minutes
+ state_topic: entry17/state
+ value_template: "{{ (value_json.day_ms / 60000) | round(2) }}"
+ unit_of_measurement: min
diff --git a/docs/architecture.md b/docs/architecture.md
index 6a3a778..96252bf 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,9 +1,3 @@
# Architecture
-```text
-Sensors -> validation and filtering -> data logger -> output/alert
- |
- +-> Matter telemetry and logs
-```
-
-The implementation separates acquisition, decision logic, output handling, and telemetry. Hardware-specific access is kept at the edge so the core behavior can be tested with simulated readings.
+Physical BME280 readings select RGB color and OLED values. Requested enable comes from USB ON/OFF or MQTT; actual enabled time only counts when sensor readings are valid. Shared Usage allocates elapsed intervals into24hour/7day ring slots with rollover tests. MQTT→Home Assistant switch→Matterbridge-hass is the explicit Matter path. ESP8266 has no native Matter stack; bridge/radio behavior is untested.
diff --git a/docs/circuit-diagram.svg b/docs/circuit-diagram.svg
new file mode 100644
index 0000000..011386d
--- /dev/null
+++ b/docs/circuit-diagram.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/images/project-overview.manifest.json b/docs/images/project-overview.manifest.json
new file mode 100644
index 0000000..c49e47f
--- /dev/null
+++ b/docs/images/project-overview.manifest.json
@@ -0,0 +1,82 @@
+{
+ "bytes": 1715468,
+ "dimensions": [
+ 1254,
+ 1254
+ ],
+ "sha256": "d7a89620a1c2580bb26f51f897e0f9ce9f9ac2144f998e4e71e052adf864c5e2",
+ "chunks": [
+ "project-overview.png.b64.000",
+ "project-overview.png.b64.001",
+ "project-overview.png.b64.002",
+ "project-overview.png.b64.003",
+ "project-overview.png.b64.004",
+ "project-overview.png.b64.005",
+ "project-overview.png.b64.006",
+ "project-overview.png.b64.007",
+ "project-overview.png.b64.008",
+ "project-overview.png.b64.009",
+ "project-overview.png.b64.010",
+ "project-overview.png.b64.011",
+ "project-overview.png.b64.012",
+ "project-overview.png.b64.013",
+ "project-overview.png.b64.014",
+ "project-overview.png.b64.015",
+ "project-overview.png.b64.016",
+ "project-overview.png.b64.017",
+ "project-overview.png.b64.018",
+ "project-overview.png.b64.019",
+ "project-overview.png.b64.020",
+ "project-overview.png.b64.021",
+ "project-overview.png.b64.022",
+ "project-overview.png.b64.023",
+ "project-overview.png.b64.024",
+ "project-overview.png.b64.025",
+ "project-overview.png.b64.026",
+ "project-overview.png.b64.027",
+ "project-overview.png.b64.028",
+ "project-overview.png.b64.029",
+ "project-overview.png.b64.030",
+ "project-overview.png.b64.031",
+ "project-overview.png.b64.032",
+ "project-overview.png.b64.033",
+ "project-overview.png.b64.034",
+ "project-overview.png.b64.035",
+ "project-overview.png.b64.036",
+ "project-overview.png.b64.037",
+ "project-overview.png.b64.038",
+ "project-overview.png.b64.039",
+ "project-overview.png.b64.040",
+ "project-overview.png.b64.041",
+ "project-overview.png.b64.042",
+ "project-overview.png.b64.043",
+ "project-overview.png.b64.044",
+ "project-overview.png.b64.045",
+ "project-overview.png.b64.046",
+ "project-overview.png.b64.047",
+ "project-overview.png.b64.048",
+ "project-overview.png.b64.049",
+ "project-overview.png.b64.050",
+ "project-overview.png.b64.051",
+ "project-overview.png.b64.052",
+ "project-overview.png.b64.053",
+ "project-overview.png.b64.054",
+ "project-overview.png.b64.055",
+ "project-overview.png.b64.056",
+ "project-overview.png.b64.057",
+ "project-overview.png.b64.058",
+ "project-overview.png.b64.059",
+ "project-overview.png.b64.060",
+ "project-overview.png.b64.061",
+ "project-overview.png.b64.062",
+ "project-overview.png.b64.063",
+ "project-overview.png.b64.064",
+ "project-overview.png.b64.065",
+ "project-overview.png.b64.066",
+ "project-overview.png.b64.067",
+ "project-overview.png.b64.068",
+ "project-overview.png.b64.069",
+ "project-overview.png.b64.070",
+ "project-overview.png.b64.071"
+ ]
+}
diff --git a/docs/images/project-overview.png b/docs/images/project-overview.png
new file mode 100644
index 0000000..b8b5566
Binary files /dev/null and b/docs/images/project-overview.png differ
diff --git a/docs/test-plan.md b/docs/test-plan.md
index 6b73a88..7c2d0d7 100644
--- a/docs/test-plan.md
+++ b/docs/test-plan.md
@@ -1,22 +1,3 @@
# Test plan
-## Static checks
-
-1. Run `python tools/validate.py`.
-2. Confirm the firmware or application starts without missing configuration.
-3. Compare the assembled wiring with `docs/wiring.md` and component datasheets.
-
-## Functional checks
-
-1. Start with simulated or disconnected actuators.
-2. Feed low, nominal, and high readings into the controller.
-3. Confirm the **data logger** behavior matches the serial or console output.
-4. Disconnect one sensor and confirm the system enters a safe state.
-5. Restore the sensor and verify recovery requires an intentional acknowledgement for latched safety modes.
-
-## Acceptance criteria
-
-- Telemetry includes a timestamp, state, input readings, and output state.
-- Invalid readings do not command an actuator on.
-- The output changes only after the configured threshold and debounce checks pass.
-- The steps in the README reproduce the demonstration.
+Host tests cover interval attribution, exact hour/day boundaries,24/7slot eviction, toggles and millis wrap. CI actually compiles NodeMCU firmware and checks image/artifacts. Hardware: confirmI2C0x76/0x3C, enable for60s viaUSBON thenOFF, compareday_ms≈60000; disconnectsensor andconfirmLEDoff/validfalse. Restore, checkcountsresumeonlyifrequested. HA/Matter pairing tests unperformed.
diff --git a/docs/validation-results.md b/docs/validation-results.md
new file mode 100644
index 0000000..3382522
--- /dev/null
+++ b/docs/validation-results.md
@@ -0,0 +1,7 @@
+# Actual cloud validation
+
+On 2026-10-10 IST, image recovery run 37996789889 passed its full host/artifact/target gates on decoded commit 20c4b573ecb568d87e157e28148a38b5b9f5f8f1 after lossless PNG SHA256/CRC/dimension verification and transport cleanup.
+
+Final push and PR workflows independently validate C++ usage counter tests, three image tests, Home Assistant YAML syntax, PNG/SVG/links/MIT/credentials and actual PlatformIO NodeMCU firmware build on the exact final head before merge. This commit triggers those final checks; their URLs and results are recorded in durable run state.
+
+Physical hardware and live connectivity tests were not performed.
diff --git a/docs/wiring.md b/docs/wiring.md
index 7123e1a..5608eb2 100644
--- a/docs/wiring.md
+++ b/docs/wiring.md
@@ -1,17 +1,3 @@
-# Wiring guide
+# Wiring
-This is a low-voltage prototype wiring plan for **Smart Entryway Usage Analytics**. Confirm every module's datasheet because breakout-board pinouts vary.
-
-| Component | Suggested pin | Role | Check |
-| --- | --- | --- | --- |
-| RGB LED | 10 | Digital I/O | Confirm the module voltage and pinout before power-up. |
-| BME280 | 2 | Digital I/O | Confirm the module voltage and pinout before power-up. |
-| OLED display | 3 | Digital I/O | Confirm the module voltage and pinout before power-up. |
-| Status output | LED_BUILTIN | Output | Use a resistor when an external LED is fitted. |
-
-## Power
-
-- Use a regulated supply sized for the selected modules.
-- Join grounds unless an interface is explicitly isolated.
-- Do not connect mains voltage directly to a development board.
-- Add a fuse, emergency stop, and certified isolation where a real actuator can create risk.
+NodeMCU D2/GPIO4 SDA and D1/GPIO5 SCL connect both BME2800x76 and OLED0x3C; VCC and pullups3.3V, GND common. BME CSB3.3V/SDO GND. RGB common cathodeGND; D6/GPIO12 red, D7/GPIO13 green, D5/GPIO14 blue each through330Ω. USB powers controller. [Circuit](circuit-diagram.svg).
diff --git a/firmware/config.h b/firmware/config.h
new file mode 100644
index 0000000..fc247fc
--- /dev/null
+++ b/firmware/config.h
@@ -0,0 +1,12 @@
+#pragma once
+#if __has_include("config.private.h")
+#include "config.private.h"
+#endif
+#ifndef WIFI_SSID
+#define WIFI_SSID ""
+#define WIFI_PASSWORD ""
+#define MQTT_HOST ""
+#define MQTT_USER ""
+#define MQTT_PASSWORD ""
+#endif
+#define MQTT_PORT 1883
diff --git a/firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino b/firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino
index 0eed3d4..8b7ca55 100644
--- a/firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino
+++ b/firmware/smart-entryway-usage-analytics/smart-entryway-usage-analytics.ino
@@ -1,107 +1,35 @@
#include
-
-#ifndef LED_BUILTIN
-#define LED_BUILTIN 2
-#endif
-
-// Smart Entryway Usage Analytics
-// Roadmap project 17; mode: data_logger
-constexpr uint8_t SENSOR_PINS[] = {A0, A1, A2};
-constexpr size_t SENSOR_COUNT = sizeof(SENSOR_PINS) / sizeof(SENSOR_PINS[0]);
-constexpr uint8_t OUTPUT_PIN = LED_BUILTIN;
-constexpr unsigned long SAMPLE_INTERVAL_MS = 2000UL;
-constexpr float TRIGGER_THRESHOLD = 0.62f;
-constexpr uint8_t REQUIRED_CONFIRMATIONS = 3;
-
-enum class SystemState : uint8_t { Starting, Normal, Active, Fault };
-
-struct Snapshot {
- float values[SENSOR_COUNT];
- float score;
- bool valid;
-};
-
-SystemState state = SystemState::Starting;
-unsigned long lastSampleAt = 0;
-uint8_t confirmations = 0;
-bool outputActive = false;
-
-float normalizeReading(int raw) {
- return constrain(raw / 1023.0f, 0.0f, 1.0f);
-}
-
-Snapshot acquireSnapshot() {
- Snapshot snapshot{};
- snapshot.valid = true;
- float sum = 0.0f;
- for (size_t index = 0; index < SENSOR_COUNT; ++index) {
- const int raw = analogRead(SENSOR_PINS[index]);
- if (raw < 0) snapshot.valid = false;
- snapshot.values[index] = normalizeReading(raw);
- sum += snapshot.values[index];
- }
- snapshot.score = sum / SENSOR_COUNT;
- return snapshot;
-}
-
-bool decide(const Snapshot &snapshot) {
- if (!snapshot.valid) return false;
- const bool condition = snapshot.score >= TRIGGER_THRESHOLD;
- if (!condition) {
- confirmations = 0;
- } else if (confirmations < REQUIRED_CONFIRMATIONS) {
- confirmations += 1;
- }
- return confirmations >= REQUIRED_CONFIRMATIONS;
-}
-
-void applyOutput(bool requested, bool valid) {
- if (!valid) {
- outputActive = false;
- state = SystemState::Fault;
- } else {
- outputActive = requested;
- state = requested ? SystemState::Active : SystemState::Normal;
- }
- digitalWrite(OUTPUT_PIN, outputActive ? HIGH : LOW);
-}
-
-const char *stateName() {
- switch (state) {
- case SystemState::Starting: return "starting";
- case SystemState::Normal: return "normal";
- case SystemState::Active: return "active";
- default: return "fault";
- }
-}
-
-void publishTelemetry(const Snapshot &snapshot) {
- Serial.print(R"json({"project_id":17,"mode":"data_logger","state":")json");
- Serial.print(stateName());
- Serial.print(R"json(","score":)json");
- Serial.print(snapshot.score, 3);
- Serial.print(R"json(,"output":)json");
- Serial.print(outputActive ? "true" : "false");
- Serial.print(R"json(,"values":[)json");
- for (size_t index = 0; index < SENSOR_COUNT; ++index) {
- if (index) Serial.print(',');
- Serial.print(snapshot.values[index], 3);
- }
- Serial.println("]}");
-}
-
-void setup() {
- pinMode(OUTPUT_PIN, OUTPUT);
- digitalWrite(OUTPUT_PIN, LOW);
- Serial.begin(115200);
- state = SystemState::Normal;
-}
-
-void loop() {
- const unsigned long now = millis();
- if (now - lastSampleAt < SAMPLE_INTERVAL_MS) return;
- lastSampleAt = now;
- const Snapshot snapshot = acquireSnapshot();
- applyOutput(decide(snapshot), snapshot.valid);
- publishTelemetry(snapshot);
+#include
+#include
+#include
+#include
+#include
+#include "../usage.h"
+#include "../config.h"
+WiFiClient wifi;PubSubClient mqtt(wifi);Adafruit_BME280 bme;Adafruit_SSD1306 oled(128,64,&Wire,-1);Usage usage;
+bool sensorReady=false,screen=false,requested=false;uint32_t sampled=0,retry=0;
+void command(char*,byte* data,unsigned n){if(n==2&&!memcmp(data,"ON",2))requested=true;if(n==3&&!memcmp(data,"OFF",3))requested=false;}
+void setup(){
+ pinMode(12,OUTPUT);pinMode(13,OUTPUT);pinMode(14,OUTPUT);analogWriteRange(255);
+ analogWrite(12,0);analogWrite(13,0);analogWrite(14,0);Serial.begin(115200);Wire.begin(4,5);
+ sensorReady=bme.begin(0x76);screen=oled.begin(SSD1306_SWITCHCAPVCC,0x3C);
+ mqtt.setBufferSize(512);mqtt.setServer(MQTT_HOST,MQTT_PORT);mqtt.setCallback(command);mqtt.setSocketTimeout(1);
+ if(strlen(WIFI_SSID))WiFi.begin(WIFI_SSID,WIFI_PASSWORD);
+}
+void loop(){
+ uint32_t now=millis();static char line[8];static unsigned count=0;static bool overflow=false;
+ while(Serial.available()){char c=Serial.read();if(c=='\n'){if(!overflow)command(nullptr,(byte*)line,count);count=0;overflow=false;}else if(c!='\r'){if(count=10000){
+ retry=now;String cid="entry17-"+String(ESP.getChipId(),HEX);bool ok=strlen(MQTT_USER)?mqtt.connect(cid.c_str(),MQTT_USER,MQTT_PASSWORD,"entry17/status",0,true,"offline"):mqtt.connect(cid.c_str(),"entry17/status",0,true,"offline");
+ if(ok){mqtt.subscribe("entry17/command");mqtt.publish("entry17/status","online",true);}
+ }mqtt.loop();
+ if(uint32_t(now-sampled)<1000){delay(1);return;}sampled=now;
+ Wire.beginTransmission(0x76);bool ack=Wire.endTransmission()==0;if(!sensorReady&&ack)sensorReady=bme.begin(0x76);
+ float t=sensorReady&&ack?bme.readTemperature():NAN,h=sensorReady&&ack?bme.readHumidity():NAN;
+ bool valid=ack&&isfinite(t)&&isfinite(h)&&t>=-40&&t<=85&&h>=0&&h<=100,active=requested&&valid;usage.tick(now,active);
+ analogWrite(12,active&&h>=70?180:0);analogWrite(13,active&&h<70&&t>=15?120:0);analogWrite(14,active&&h<70&&t<15?120:0);
+ if(screen){oled.clearDisplay();oled.setTextSize(1);oled.setTextColor(SSD1306_WHITE);oled.setCursor(0,0);if(valid){oled.print("T ");oled.print(t,1);oled.print("C RH ");oled.print(h,0);oled.println("%");}else oled.println("SENSOR FAULT");
+ oled.print("Requested ");oled.println(requested?"ON":"OFF");oled.print("Actual ");oled.println(active?"ON":"OFF");oled.print("Day min ");oled.println(usage.currentDay()/60000);oled.print("7 slots min ");oled.println((unsigned long)(usage.weekTotal()/60000));oled.display();}
+ char out[340];snprintf(out,sizeof(out),"{\"id\":17,\"uptime_s\":%lu,\"valid\":%s,\"temperature_c\":%.1f,\"humidity_pct\":%.1f,\"requested\":%s,\"active\":%s,\"hour_ms\":%lu,\"day_ms\":%lu,\"slots24_ms\":%lu,\"slots7day_ms\":%lu}",(unsigned long)(usage.elapsed/1000),valid?"true":"false",valid?t:0,valid?h:0,requested?"true":"false",active?"true":"false",(unsigned long)usage.currentHour(),(unsigned long)usage.currentDay(),(unsigned long)usage.hoursTotal(),(unsigned long)usage.weekTotal());Serial.println(out);
+ if(mqtt.connected()){mqtt.publish("entry17/state",out,true);mqtt.publish("entry17/switch/state",requested?"ON":"OFF",true);}
}
diff --git a/firmware/usage.h b/firmware/usage.h
new file mode 100644
index 0000000..6464898
--- /dev/null
+++ b/firmware/usage.h
@@ -0,0 +1,18 @@
+#pragma once
+#include
+struct Usage {
+ uint32_t hours[24]={},days[7]={},last=0;uint64_t elapsed=0,hour=0,day=0;bool seen=false,previous=false;
+ void sync(){uint64_t h=elapsed/3600000,d=elapsed/86400000;while(hourI',len(payload))+kind+payload+struct.pack('>I',zlib.crc32(kind+payload)&0xffffffff)
+ return b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',64,64,8,2,0,0,0))+chunk(b'IDAT',zlib.compress((b'\0'+b'\xff'*192)*64))+chunk(b'IEND',b'')
+class TransportTests(unittest.TestCase):
+ def test_png_checks(self):
+ b=png();self.assertEqual(module.validate_png(b),(64,64))
+ for bad in [b'bad',b[:-1],b+b'trailer',b[:50]+bytes([b[50]^1])+b[51:]]:
+ with self.assertRaises(ValueError): module.validate_png(bad)
+ def test_decode_and_cleanup(self):
+ with tempfile.TemporaryDirectory() as t:
+ root=pathlib.Path(t);d=root/'docs/images';d.mkdir(parents=True)
+ b=png();name='project-overview.png.b64.000'
+ (d/name).write_text(base64.b64encode(b).decode())
+ (d/'project-overview.manifest.json').write_text(json.dumps({'bytes':len(b),'dimensions':[64,64],'sha256':hashlib.sha256(b).hexdigest(),'chunks':[name]}))
+ module.decode(root);self.assertEqual((d/'project-overview.png').read_bytes(),b);self.assertFalse((d/name).exists())
+ def test_bad_base64_keeps_transport(self):
+ with tempfile.TemporaryDirectory() as t:
+ root=pathlib.Path(t);d=root/'docs/images';d.mkdir(parents=True)
+ name='project-overview.png.b64.000';(d/name).write_text('bad!!!')
+ (d/'project-overview.manifest.json').write_text(json.dumps({'chunks':[name]}))
+ with self.assertRaises(ValueError): module.decode(root)
+ self.assertTrue((d/name).exists());self.assertFalse((d/'project-overview.png').exists())
+if __name__=='__main__':unittest.main()
diff --git a/tests/usage_test.cpp b/tests/usage_test.cpp
new file mode 100644
index 0000000..c1f90a1
--- /dev/null
+++ b/tests/usage_test.cpp
@@ -0,0 +1,11 @@
+#include "../firmware/usage.h"
+#include
+#include
+int main(){
+ Usage u;u.tick(0,true);u.tick(3600000,true);assert(u.currentHour()==0&&u.hoursTotal()==3600000);u.tick(7200000,false);assert(u.currentDay()==7200000&&u.hoursTotal()==7200000);u.tick(86400000,false);assert(u.currentDay()==0&&u.weekTotal()==7200000);
+ u.tick(604800000,false);assert(u.weekTotal()==0&&u.hoursTotal()==0);
+ Usage w;w.tick(UINT32_MAX-15,true);w.tick(20,false);assert(w.currentDay()==36);w.tick(50,false);assert(w.currentDay()==36);
+ Usage edge;edge.tick(0,true);edge.tick(86399900,true);edge.tick(86400100,false);assert(edge.days[0]==86400000&&edge.currentDay()==100);assert(edge.currentHour()==100);assert(edge.hoursTotal()==23*3600000+100);
+ Usage toggled;toggled.tick(0,false);toggled.tick(100,true);toggled.tick(400,false);assert(toggled.currentDay()==300);
+ std::cout<<"interval attribution, exact hour/day rollover, seven-day eviction, activation and timer-wrap tests passed\n";
+}
diff --git a/tools/decode_project_image.py b/tools/decode_project_image.py
new file mode 100644
index 0000000..2211339
--- /dev/null
+++ b/tools/decode_project_image.py
@@ -0,0 +1,68 @@
+"""Strict lossless PNG transport recovery; no tokens or network access required."""
+import base64, binascii, hashlib, json, pathlib, re, struct, zlib
+ROOT = pathlib.Path(__file__).resolve().parents[1]
+IMAGE = ROOT/'docs/images/project-overview.png'
+MANIFEST = ROOT/'docs/images/project-overview.manifest.json'
+LIMIT = 8 * 1024 * 1024
+
+def validate_png(data):
+ if not 32 <= len(data) <= LIMIT or data[:8] != b'\x89PNG\r\n\x1a\n':
+ raise ValueError('Invalid PNG signature or size')
+ offset=8; kinds=[]; idat=bytearray(); ihdr=None
+ while offset < len(data):
+ if offset+12 > len(data): raise ValueError('Truncated PNG chunk')
+ size=struct.unpack('>I',data[offset:offset+4])[0]
+ if size > LIMIT or offset+12+size > len(data): raise ValueError('Invalid chunk size')
+ kind=data[offset+4:offset+8]; payload=data[offset+8:offset+8+size]
+ crc=struct.unpack('>I',data[offset+8+size:offset+12+size])[0]
+ if zlib.crc32(kind+payload)&0xffffffff != crc: raise ValueError('PNG CRC mismatch')
+ # caBX is public generation provenance; arbitrary textual/executable metadata is rejected.
+ if kind not in {b'IHDR',b'IDAT',b'IEND',b'caBX',b'sRGB',b'gAMA',b'cHRM',b'pHYs'}:
+ raise ValueError('Unsupported PNG chunk')
+ kinds.append(kind)
+ if kind==b'IHDR':
+ if ihdr is not None or size!=13 or len(kinds)!=1: raise ValueError('Invalid IHDR')
+ ihdr=struct.unpack('>IIBBBBB',payload)
+ if kind==b'IDAT': idat.extend(payload)
+ offset+=12+size
+ if kind==b'IEND':
+ if size or offset!=len(data): raise ValueError('Invalid PNG end/trailing bytes')
+ break
+ if not ihdr or kinds[-1]!=b'IEND' or not idat: raise ValueError('Incomplete PNG')
+ width,height,depth,color,compression,filter_method,interlace=ihdr
+ if not 64<=width<=4096 or not 64<=height<=4096: raise ValueError('PNG dimensions outside bounds')
+ if (depth,color,compression,filter_method,interlace) not in [(8,2,0,0,0),(8,6,0,0,0)]:
+ raise ValueError('Only non-interlaced 8-bit RGB/RGBA PNG accepted')
+ channels=3 if color==2 else 4
+ row=1+width*channels; expected=row*height
+ stream=zlib.decompressobj(); pixels=stream.decompress(bytes(idat),expected+1)
+ if len(pixels)!=expected or not stream.eof or stream.unused_data or stream.unconsumed_tail:
+ raise ValueError('PNG pixel stream length or termination invalid')
+ if any(pixels[i] > 4 for i in range(0,expected,row)): raise ValueError('Invalid PNG scanline filter')
+ if re.search(rb'(-----BEGIN [^\n]*PRIVATE KEY|ghp_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{40,}|sk-proj-[A-Za-z0-9_-]{30,})',data):
+ raise ValueError('Credential-like data in PNG')
+ return width,height
+
+def decode(root=ROOT):
+ directory=root/'docs/images'; manifest=json.loads((directory/'project-overview.manifest.json').read_text())
+ names=manifest['chunks']
+ if not 1<=len(names)<=128 or names!=sorted(set(names)): raise ValueError('Invalid chunk list')
+ if any(not re.fullmatch(r'project-overview\.png\.b64\.\d{3}',name) for name in names):
+ raise ValueError('Unsafe transport filename')
+ files=[directory/name for name in names]
+ if any(p.is_symlink() or not p.is_file() for p in files): raise ValueError('Missing/unsafe chunk')
+ if sum(p.stat().st_size for p in files)>LIMIT*2: raise ValueError('Transport too large')
+ encoded=''.join(p.read_text(encoding='ascii').strip() for p in files)
+ data=base64.b64decode(encoded,validate=True)
+ if base64.b64encode(data).decode('ascii') != encoded: raise ValueError('Noncanonical base64')
+ dimensions=validate_png(data)
+ if len(data)!=manifest['bytes'] or list(dimensions)!=manifest['dimensions']:
+ raise ValueError('Manifest size/dimension mismatch')
+ if hashlib.sha256(data).hexdigest()!=manifest['sha256']: raise ValueError('Manifest SHA256 mismatch')
+ target=directory/'project-overview.png'
+ if target.exists() and target.read_bytes()!=data: raise ValueError('Refusing to overwrite different image')
+ temporary=directory/'project-overview.png.tmp'; temporary.write_bytes(data); temporary.replace(target)
+ for p in files: p.unlink()
+ print(json.dumps({'bytes':len(data),'dimensions':dimensions,'sha256':manifest['sha256'],'credential_pattern_scan':'passed'}))
+
+if __name__=='__main__': decode()
diff --git a/tools/validate_completion.py b/tools/validate_completion.py
new file mode 100644
index 0000000..b8b54e1
--- /dev/null
+++ b/tools/validate_completion.py
@@ -0,0 +1,18 @@
+from pathlib import Path
+import re,json,hashlib,xml.etree.ElementTree as ET
+from decode_project_image import validate_png
+r=Path(__file__).resolve().parents[1]
+for f in ['README.md','LICENSE','docs/circuit-diagram.svg','docs/images/project-overview.png','platformio.ini']:assert (r/f).is_file(),f
+assert ET.parse(r/'docs/circuit-diagram.svg').getroot().tag=='{http://www.w3.org/2000/svg}svg'
+svg=(r/'docs/circuit-diagram.svg').read_text();assert '