Skip to content

Instantly share code, notes, and snippets.

@conoro
Last active February 24, 2026 08:34
Show Gist options
  • Select an option

  • Save conoro/72f83955f74b47ad87ceecc8344e63d7 to your computer and use it in GitHub Desktop.

Select an option

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)
// 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);
# 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