Last active
February 24, 2026 08:34
-
-
Save conoro/72f83955f74b47ad87ceecc8344e63d7 to your computer and use it in GitHub Desktop.
BTHome/Home Assistant compatible Temperature Sensor using Espruino on NodeConfEU 2018 Badge (Pixl.js)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // NodeConf EU 2018 Badge - BTHome Temperature + Clock Display | |
| // ============================================================ | |
| // Broadcasts temperature + button events to Home Assistant | |
| // via BTHome v2 over BLE advertisements. | |
| // | |
| // Time sync: At boot and hourly, the badge scans for a nearby | |
| // ESP32 beacon named "badge-time-sync" that broadcasts the | |
| // current Unix timestamp in BLE manufacturer data. No BLE | |
| // connections needed - purely passive scanning. | |
| // | |
| // BTN1 (top-left) = Toggle °C / °F | |
| // BTN2 (top-right) = Cycle backlight colour | |
| // BTN3 (bottom-left) = Backlight off | |
| // BTN4 (bottom-right) = Force refresh | |
| // | |
| // Home Assistant auto-discovers BTHome sensors. | |
| var NC = require("nodeconfeu2018"); | |
| // --- State --- | |
| var useFahrenheit = false; | |
| var colourIndex = 0; | |
| var tempC = 0; | |
| var timeIsSet = false; | |
| var scanning = false; | |
| var syncRetries = 0; | |
| var syncRetryTimer; | |
| // --- Time sync via BLE scan --- | |
| // Scans for the ESP32 beacon and reads timestamp from | |
| // manufacturer data: [0xFF, 0xFF, ts0, ts1, ts2, ts3] | |
| function syncTime() { | |
| scanning = true; | |
| updateDisplay(); | |
| try { | |
| NRF.findDevices(function(devices) { | |
| scanning = false; | |
| try { | |
| //print("sync: " + devices.length + " devices"); | |
| for (var i = 0; i < devices.length; i++) { | |
| var d = devices[i]; | |
| // Match name prefix (ESP-IDF may truncate long names) | |
| if (!d.name || d.name.indexOf("badge-time") !== 0) continue; | |
| //print("sync: found " + d.name + | |
| // " mfr=" + (typeof d.manufacturer) + | |
| // " mfrData=" + (typeof d.manufacturerData)); | |
| var ts = 0; | |
| // Format A: manufacturer is a number (company ID), | |
| // actual data bytes are in manufacturerData | |
| if (typeof d.manufacturer === "number" && d.manufacturerData) { | |
| var m = d.manufacturerData; | |
| var len = m.byteLength || m.length || 0; | |
| if (len >= 4) { | |
| ts = m[0] + (m[1] * 256) + (m[2] * 65536) + (m[3] * 16777216); | |
| } | |
| } | |
| // Format B: manufacturer is DataView/array containing | |
| // [companyLo, companyHi, ts0, ts1, ts2, ts3] | |
| if (ts === 0 && d.manufacturer && typeof d.manufacturer !== "number") { | |
| var m = d.manufacturer; | |
| var len = m.byteLength || m.length || 0; | |
| if (len >= 6 && m[0] === 0xFF && m[1] === 0xFF) { | |
| ts = m[2] + (m[3] * 256) + (m[4] * 65536) + (m[5] * 16777216); | |
| } else if (len >= 4) { | |
| ts = m[0] + (m[1] * 256) + (m[2] * 65536) + (m[3] * 16777216); | |
| } | |
| } | |
| //print("sync: ts=" + ts); | |
| if (ts > 1700000000) { | |
| setTime(ts); | |
| timeIsSet = true; | |
| syncRetries = 0; | |
| updateDisplay(); | |
| return; | |
| } | |
| } | |
| // Beacon not found — retry with increasing delays | |
| if (!timeIsSet && syncRetries < 5) { | |
| var delays = [15000, 30000, 60000, 120000, 300000]; | |
| //print("sync: retry " + syncRetries + " in " + (delays[syncRetries] / 1000) + "s"); | |
| if (syncRetryTimer) clearTimeout(syncRetryTimer); | |
| syncRetryTimer = setTimeout(syncTime, delays[syncRetries]); | |
| syncRetries++; | |
| } | |
| } catch(e) { print("syncTime cb: " + e); } | |
| updateDisplay(); | |
| }, 5000); | |
| } catch(e) { | |
| scanning = false; | |
| print("syncTime: " + e); | |
| } | |
| } | |
| // --- Colour palette for backlight cycling --- | |
| // SN3218 register order per zone: [B, G, R] | |
| var colours = [ | |
| { name: "Cyan", bgr: [180, 180, 0] }, | |
| { name: "Blue", bgr: [255, 0, 0] }, | |
| { name: "Purple", bgr: [200, 0, 180] }, | |
| { name: "Magenta", bgr: [80, 0, 255] }, | |
| { name: "Red", bgr: [0, 0, 255] }, | |
| { name: "Orange", bgr: [0, 80, 255] }, | |
| { name: "Yellow", bgr: [0, 200, 255] }, | |
| { name: "Green", bgr: [0, 255, 0] }, | |
| { name: "White", bgr: [200, 200, 200] }, | |
| ]; | |
| // --- Backlight control --- | |
| function setBacklight(bgr) { | |
| NC.backlight([ | |
| bgr[0], bgr[1], bgr[2], | |
| bgr[0], bgr[1], bgr[2], | |
| bgr[0], bgr[1], bgr[2], | |
| bgr[0], bgr[1], bgr[2] | |
| ]); | |
| } | |
| function backlightOff() { | |
| NC.backlight(); | |
| colourIndex = -1; | |
| } | |
| function cycleColour() { | |
| colourIndex++; | |
| if (colourIndex >= colours.length) colourIndex = 0; | |
| setBacklight(colours[colourIndex].bgr); | |
| updateDisplay(); | |
| } | |
| // --- Temperature --- | |
| function readTemp() { | |
| tempC = E.getTemperature(); | |
| } | |
| function getDisplayTemp() { | |
| if (useFahrenheit) { | |
| return (tempC * 9 / 5 + 32).toFixed(1); | |
| } | |
| return tempC.toFixed(1); | |
| } | |
| function getUnit() { | |
| return useFahrenheit ? "F" : "C"; | |
| } | |
| // --- Time formatting --- | |
| function getTimeStr() { | |
| if (scanning) return "Sync.."; | |
| if (!timeIsSet) return "--:--"; | |
| var d = new Date(); | |
| var h = d.getHours(); | |
| var m = d.getMinutes(); | |
| return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m; | |
| } | |
| // --- Display rendering --- | |
| // Layout (128 x 64): | |
| // ┌────────────────────────────┐ | |
| // │ BTHome 14:30 82% │ <- status bar (bitmap font) | |
| // │────────────────────────────│ | |
| // │ │ | |
| // │ 21.5 °C │ <- large temp (vector 28) | |
| // │ │ | |
| // │────────────────────────────│ | |
| // │ LED:Cyan [°C/F] [LED] │ <- button hints | |
| // └────────────────────────────┘ | |
| function updateDisplay() { | |
| var temp = getDisplayTemp(); | |
| var unit = getUnit(); | |
| var timeStr = getTimeStr(); | |
| var w = g.getWidth(); // 128 | |
| var h = g.getHeight(); // 64 | |
| g.clear(); | |
| // --- Top status bar --- | |
| g.setFontBitmap(); | |
| g.setFontAlign(-1, -1); | |
| g.drawString("BTHome", 0, 0); | |
| // Time centred in status bar | |
| g.setFontAlign(0, -1); | |
| g.drawString(timeStr, w / 2, 0); | |
| // Battery right-aligned | |
| g.setFontAlign(1, -1); | |
| g.drawString(E.getBattery() + "%", w - 1, 0); | |
| // Divider | |
| g.drawLine(0, 9, w - 1, 9); | |
| // --- Temperature (centre of screen) --- | |
| g.setFontVector(28); | |
| g.setFontAlign(1, 0); // right-align the number | |
| var cx = w / 2 + 8; // number ends here | |
| g.drawString(temp, cx, 34); | |
| // Unit label to the right of the number | |
| g.setFontVector(14); | |
| g.setFontAlign(-1, -1); | |
| g.drawString("o", cx + 1, 20); | |
| g.setFontVector(18); | |
| g.drawString(unit, cx + 9, 22); | |
| // --- Bottom bar --- | |
| g.drawLine(0, h - 10, w - 1, h - 10); | |
| g.setFontBitmap(); | |
| g.setFontAlign(-1, 1); | |
| var colName = (colourIndex >= 0) ? colours[colourIndex].name : "Off"; | |
| g.drawString("LED:" + colName, 0, h - 1); | |
| g.setFontAlign(1, 1); | |
| g.drawString("[" + unit + "] [LED]", w - 1, h - 1); | |
| g.flip(); | |
| } | |
| // --- BTHome advertising --- | |
| var slowTimeout; | |
| function updateAdvertising(buttonState) { | |
| NRF.setAdvertising(require("BTHome").getAdvertisement([ | |
| { | |
| type: "temperature", | |
| v: tempC | |
| }, | |
| { | |
| type: "battery", | |
| v: E.getBattery() | |
| }, | |
| { | |
| type: "button_event", | |
| v: buttonState | |
| } | |
| ]), { | |
| name: "NCEU Badge", | |
| interval: (buttonState !== "none") ? 20 : 2000, | |
| manufacturer: false | |
| }); | |
| if (slowTimeout) clearTimeout(slowTimeout); | |
| slowTimeout = setTimeout(function() { | |
| slowTimeout = undefined; | |
| updateAdvertising("none"); | |
| }, 60000); | |
| } | |
| // --- Main update loop --- | |
| function tick() { | |
| readTemp(); | |
| updateDisplay(); | |
| updateAdvertising("none"); | |
| } | |
| // --- Button handlers --- | |
| // BTN1 = Toggle C/F | |
| setWatch(function() { | |
| useFahrenheit = !useFahrenheit; | |
| updateDisplay(); | |
| updateAdvertising("press"); | |
| }, BTN1, { edge: "falling", repeat: true, debounce: 50 }); | |
| // BTN2 = Cycle backlight colour | |
| setWatch(function() { | |
| cycleColour(); | |
| updateAdvertising("press"); | |
| }, BTN2, { edge: "falling", repeat: true, debounce: 50 }); | |
| // BTN3 = Backlight off | |
| setWatch(function() { | |
| backlightOff(); | |
| updateDisplay(); | |
| updateAdvertising("press"); | |
| }, BTN3, { edge: "falling", repeat: true, debounce: 50 }); | |
| // BTN4 = Force refresh | |
| setWatch(function() { | |
| tick(); | |
| updateAdvertising("press"); | |
| }, BTN4, { edge: "falling", repeat: true, debounce: 50 }); | |
| // --- Startup --- | |
| NRF.setTxPower(4); | |
| colourIndex = 0; | |
| setBacklight(colours[colourIndex].bgr); | |
| // If uploaded via Web IDE with "Set Current Time" enabled, | |
| // the IDE will have called setTime() already | |
| if (getTime() > 1700000000) timeIsSet = true; | |
| tick(); | |
| // Sync time on boot (after a short delay for BLE to settle) | |
| setTimeout(syncTime, 3000); | |
| // Re-sync time every hour (reset retry counter) | |
| setInterval(function() { syncRetries = 0; syncTime(); }, 3600000); | |
| // Update display every 10 seconds (temp + clock) | |
| setInterval(tick, 10000); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # ESP32 BLE Time Beacon for NodeConf EU 2018 Badge | |
| # ================================================= | |
| # Runs on an M5Stack ATOM Matrix (ESP32). | |
| # Gets time from Home Assistant, then broadcasts it | |
| # continuously as BLE manufacturer data. No connections. | |
| # The badge scans for this beacon at boot + hourly. | |
| esphome: | |
| name: badge-time-sync | |
| friendly_name: "Badge Time Sync" | |
| esp32: | |
| board: m5stack-atom | |
| framework: | |
| type: esp-idf | |
| wifi: | |
| ssid: !secret wifi_ssid | |
| password: !secret wifi_password | |
| api: | |
| ota: | |
| platform: esphome | |
| logger: | |
| # --- Pull time from Home Assistant --- | |
| time: | |
| - platform: homeassistant | |
| id: ha_time | |
| # --- BLE stack (no tracker, no proxy, no server - just the base) --- | |
| esp32_ble: | |
| # --- Broadcast timestamp as BLE manufacturer data every 30s --- | |
| # Manufacturer data: company ID 0xFFFF (test/dev) + 4-byte LE timestamp | |
| # The badge scans for name "badge-time-sync" and reads the timestamp. | |
| interval: | |
| - interval: 30s | |
| then: | |
| - lambda: |- | |
| if (!id(ha_time).now().is_valid()) return; | |
| static bool adv_started = false; | |
| uint32_t ts = id(ha_time).now().timestamp; | |
| uint8_t manuf[] = { | |
| 0xFF, 0xFF, | |
| (uint8_t)(ts & 0xFF), | |
| (uint8_t)((ts >> 8) & 0xFF), | |
| (uint8_t)((ts >> 16) & 0xFF), | |
| (uint8_t)((ts >> 24) & 0xFF) | |
| }; | |
| esp_ble_adv_data_t adv_data = {}; | |
| adv_data.set_scan_rsp = false; | |
| adv_data.include_name = true; | |
| adv_data.include_txpower = false; | |
| adv_data.manufacturer_len = sizeof(manuf); | |
| adv_data.p_manufacturer_data = manuf; | |
| adv_data.flag = (ESP_BLE_ADV_FLAG_GEN_DISC | ESP_BLE_ADV_FLAG_BREDR_NOT_SPT); | |
| esp_ble_gap_set_device_name("badge-time-sync"); | |
| esp_ble_gap_config_adv_data(&adv_data); | |
| if (!adv_started) { | |
| esp_ble_adv_params_t adv_params = {}; | |
| adv_params.adv_int_min = 0x100; | |
| adv_params.adv_int_max = 0x200; | |
| adv_params.adv_type = ADV_TYPE_NONCONN_IND; | |
| adv_params.own_addr_type = BLE_ADDR_TYPE_PUBLIC; | |
| adv_params.channel_map = ADV_CHNL_ALL; | |
| adv_params.adv_filter_policy = ADV_FILTER_ALLOW_SCAN_ANY_CON_ANY; | |
| esp_ble_gap_start_advertising(&adv_params); | |
| adv_started = true; | |
| ESP_LOGI("time_beacon", "BLE time beacon started"); | |
| } | |
| ESP_LOGI("time_beacon", "Broadcasting timestamp: %lu", (unsigned long)ts); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment