Skip to content

Instantly share code, notes, and snippets.

@lfuelling
Created July 8, 2024 01:21
Show Gist options
  • Save lfuelling/9bda7aa6fabea271b7dfd3d05fc6c4cb to your computer and use it in GitHub Desktop.
Save lfuelling/9bda7aa6fabea271b7dfd3d05fc6c4cb to your computer and use it in GitHub Desktop.
ESP32 (C6-Zero or comparable) Storz & Bickel Volcano Exporter
/*
* volcano_exporter
*/
#include "BLEDevice.h"
#include <Arduino.h>
#include <WiFi.h>
// Replace with your network credentials
const char *ssid = "MY_NETWORK";
const char *password = "MY_PASSWORD";
typedef enum {
DEVICE_STATE_INIT,
DEVICE_STATE_CONNECTING_WIFI,
DEVICE_STATE_CONNECTING_BLE,
DEVICE_STATE_IDLE,
DEVICE_STATE_FETCHING_WIFI,
DEVICE_STATE_FETCHING_BLE,
DEVICE_STATE_ERROR
} DeviceState;
struct VolcanoMetrics {
bool connected = false;
bool heaterActive = false;
bool airActive = false;
uint32_t currentTemperature = -1;
uint32_t selectedTemperature = -1;
uint32_t operationHours = -1;
uint16_t autoShutoffTime = -1;
uint16_t ledBrightness = -1;
uint32_t serialNumber = -1;
String deviceFirmwareVersion = "";
String bleFirmwareVersion = "";
String bleAddress = "";
};
NetworkServer server(80);
TaskHandle_t ledTaskHandle = NULL;
DeviceState state = DEVICE_STATE_INIT;
VolcanoMetrics deviceMetrics = VolcanoMetrics();
void registerMetricsUpdates(BLERemoteService *service,
BLEUUID characteristicId,
notify_callback callback,
std::function<void(BLERemoteCharacteristic *characteristic)> readInitialValue) {
BLERemoteCharacteristic *characteristic = service->getCharacteristic(characteristicId);
if (characteristic == nullptr) {
Serial.println("[ERROR] Failed to find characteristic!");
return;
}
if (characteristic == nullptr) {
Serial.println("[ERROR] Characteristic is null!");
return;
}
if (characteristic->canRead()) {
readInitialValue(characteristic);
} else {
Serial.print("[WARNING] Unable to read ");
Serial.print(characteristic->getUUID().toString());
Serial.println("!");
}
if (characteristic->canNotify()) {
characteristic->registerForNotify(callback);
} else {
Serial.print("[WARNING] Unable to notify for ");
Serial.print(characteristic->getUUID().toString());
Serial.println("!");
}
}
String getMetricLabels(VolcanoMetrics metrics) {
return "{addr=\"" + metrics.bleAddress + "\"," + "dev_fw=\"" + metrics.deviceFirmwareVersion + "\"," + "ble_fw=\"" + metrics.bleFirmwareVersion + "\"," + "serial=\"" + metrics.serialNumber + "\"}";
}
String getMetricString(String label, String help, String type, String value, VolcanoMetrics metrics) {
return "# HELP " + label + " " + help + "\n" + "# TYPE " + label + " " + type + "\n" + label + getMetricLabels(metrics) + " " + value + "\n";
}
String getGaugeMetricString(String label, String help, String value, VolcanoMetrics metrics) {
return getMetricString(label, help, "gauge", value, metrics);
}
String buildMetricsString() {
String result = "";
if (deviceMetrics.currentTemperature != -1) {
result += getGaugeMetricString("volcano_current_temp_celsius",
"The current temperature in Celsius.",
String(deviceMetrics.currentTemperature), deviceMetrics);
}
if (deviceMetrics.selectedTemperature != -1) {
result += getGaugeMetricString("volcano_selected_temp_celsius",
"The selected temperature in Celsius.",
String(deviceMetrics.selectedTemperature), deviceMetrics);
}
if (deviceMetrics.operationHours != -1) {
result += getMetricString("volcano_operation_hours",
"Device operation hours.",
"counter",
String(deviceMetrics.operationHours), deviceMetrics);
}
if (deviceMetrics.autoShutoffTime != -1) {
result += getGaugeMetricString("volcano_auto_shutoff_time",
"Auto shutoff time in seconds(?).",
String(deviceMetrics.autoShutoffTime), deviceMetrics);
}
if (deviceMetrics.ledBrightness != -1) {
result += getGaugeMetricString("volcano_led_brightness",
"LED Brightness.",
String(deviceMetrics.ledBrightness), deviceMetrics);
}
result += getGaugeMetricString("volcano_heat_active",
"Boolean (0 or 1) showing whether the heat is currently active.",
String(deviceMetrics.heaterActive ? "1" : "0"), deviceMetrics);
result += getGaugeMetricString("volcano_air_active",
"Boolean (0 or 1) showing whether the air pump is currently active.",
String(deviceMetrics.airActive ? "1" : "0"), deviceMetrics);
return result;
}
void ledIteration(int i) {
// DEVICE LAYOUT IS GRB, NOT RGB!!!
switch (state) {
case DEVICE_STATE_CONNECTING_WIFI:
neopixelWrite(RGB_BUILTIN, i, 0, i);
break;
case DEVICE_STATE_CONNECTING_BLE:
neopixelWrite(RGB_BUILTIN, 0, 0, i);
break;
case DEVICE_STATE_IDLE:
neopixelWrite(RGB_BUILTIN, i, 0, 0);
break;
case DEVICE_STATE_FETCHING_WIFI:
neopixelWrite(RGB_BUILTIN, 0, max(i, 8), max(i, 8));
break;
case DEVICE_STATE_FETCHING_BLE:
neopixelWrite(RGB_BUILTIN, i, max(i, 8), max(i, 8));
break;
case DEVICE_STATE_ERROR:
neopixelWrite(RGB_BUILTIN, 0, i, 0);
case DEVICE_STATE_INIT:
default:
neopixelWrite(RGB_BUILTIN, i, i, i);
}
}
void ledLoop(void *param) {
while (1) {
for (int i = 1; i < 16; i++) {
ledIteration(i);
vTaskDelay(pdMS_TO_TICKS(80));
}
for (int i = 1; i < 16; i++) {
ledIteration(17 - i);
vTaskDelay(pdMS_TO_TICKS(80));
}
}
}
void connectVolcano(BLEAdvertisedDevice advertisedDevice) {
Serial.print("[INFO] Found Volcano ");
Serial.print(advertisedDevice.getAddress().toString().c_str());
Serial.println("");
BLEClient *pClient = BLEDevice::createClient();
Serial.println("[DEBUG] Created client…");
pClient->connect(&advertisedDevice);
Serial.println("[DEBUG] Connected to server…");
pClient->setMTU(517);
BLERemoteService *service1 = pClient->getService(BLEUUID("10110000-5354-4F52-5A26-4249434B454C"));
if (service1 == nullptr) {
Serial.println("[ERROR] Failed to find service1!");
} else {
// current temperature
registerMetricsUpdates(
service1, BLEUUID("10110001-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
uint32_t value = 0;
if (length == 4) {
value = ((uint32_t)pData[3] << 24) | ((uint32_t)pData[2] << 16) | ((uint32_t)pData[1] << 8) | ((uint32_t)pData[0]);
deviceMetrics.currentTemperature = value / 10;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.currentTemperature = characteristic->readUInt32() / 10;
});
// selected temperature
registerMetricsUpdates(
service1, BLEUUID("10110003-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
uint32_t value = 0;
if (length == 4) {
value = ((uint32_t)pData[3] << 24) | ((uint32_t)pData[2] << 16) | ((uint32_t)pData[1] << 8) | ((uint32_t)pData[0]);
deviceMetrics.selectedTemperature = value / 10;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.selectedTemperature = characteristic->readUInt32() / 10;
});
// operation hours
registerMetricsUpdates(
service1, BLEUUID("10110015-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
uint32_t value = 0;
if (length == 4) {
value = ((uint32_t)pData[3] << 24) | ((uint32_t)pData[2] << 16) | ((uint32_t)pData[1] << 8) | ((uint32_t)pData[0]);
deviceMetrics.operationHours = value;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.operationHours = characteristic->readUInt32();
});
// led brightness
registerMetricsUpdates(
service1, BLEUUID("10110005-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
uint16_t value = 0;
if (length == 2) {
value = (((uint16_t)pData[1] << 8) | ((uint16_t)pData[0]));
deviceMetrics.ledBrightness = value;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.ledBrightness = characteristic->readUInt16();
});
// auto shutoff time
registerMetricsUpdates(
service1, BLEUUID("1011000d-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
uint16_t value = 0;
if (length == 2) {
value = (((uint16_t)pData[1] << 8) | ((uint16_t)pData[0]));
deviceMetrics.autoShutoffTime = value;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.autoShutoffTime = characteristic->readUInt16();
});
}
BLERemoteService *service2 = pClient->getService(BLEUUID("10100000-5354-4F52-5A26-4249434B454C"));
if (service2 == nullptr) {
Serial.println("[ERROR] Failed to find service2!");
} else {
// serial number
registerMetricsUpdates(
service2, BLEUUID("10100008-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
uint32_t value = 0;
if (length == 4) {
value = ((uint32_t)pData[3] << 24) | ((uint32_t)pData[2] << 16) | ((uint32_t)pData[1] << 8) | ((uint32_t)pData[0]);
deviceMetrics.serialNumber = value;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.serialNumber = characteristic->readUInt32();
});
// firmware version
registerMetricsUpdates(
service2, BLEUUID("10100003-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
String value = "";
for (int i = 0; i < length; i++) {
value += (char)pData[i];
}
deviceMetrics.deviceFirmwareVersion = value;
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.deviceFirmwareVersion = characteristic->readValue();
});
// ble firmware version
registerMetricsUpdates(
service2, BLEUUID("10100004-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
String value = "";
for (int i = 0; i < length; i++) {
value += (char)pData[i];
}
deviceMetrics.bleFirmwareVersion = value;
},
[](BLERemoteCharacteristic *characteristic) {
deviceMetrics.bleFirmwareVersion = characteristic->readValue();
});
// heat/air status
registerMetricsUpdates(
service2, BLEUUID("1010000c-5354-4f52-5a26-4249434b454c"),
[](BLERemoteCharacteristic *characteristic, uint8_t *pData, size_t length, bool isNotify) {
String value = "";
for (int i = 0; i < length; i++) {
value += (char)pData[i];
}
if (value.length() >= 2) {
uint8_t heaterValue = value[0];
bool heaterStatus = (heaterValue & 0x0020) != 0;
deviceMetrics.heaterActive = heaterStatus;
uint8_t airValue = value[1];
bool airPumpStatus = (airValue & 0x0030) != 0;
deviceMetrics.airActive = airPumpStatus;
} else {
Serial.println("[WARNING] Invalid data length!");
}
},
[](BLERemoteCharacteristic *characteristic) {
String value = characteristic->readValue();
if (value.length() >= 2) {
uint8_t heaterValue = value[0];
bool heaterStatus = (heaterValue & 0x0020) != 0;
deviceMetrics.heaterActive = heaterStatus;
uint8_t airValue = value[1];
bool airPumpStatus = (airValue & 0x0030) != 0;
deviceMetrics.airActive = airPumpStatus;
} else {
Serial.println("[WARNING] Invalid data length!");
}
});
}
deviceMetrics.connected = true;
}
void setup() {
Serial.begin(115200);
Serial.println("[INFO] Booted!");
// Start ledLoop
xTaskCreatePinnedToCore(ledLoop, "ledLoop", 10000, NULL, 8, &ledTaskHandle, 1);
state = DEVICE_STATE_CONNECTING_BLE;
Serial.println("[INFO] Initializing BLE…");
BLEDevice::init("volcano_exporter");
BLEScan *pBLEScan = BLEDevice::getScan();
pBLEScan->setInterval(1349);
pBLEScan->setWindow(449);
pBLEScan->setActiveScan(true);
while (!deviceMetrics.connected) {
BLEScanResults *scanResults = pBLEScan->start(5, false);
for (int i = 0; i < scanResults->getCount(); i++) {
BLEAdvertisedDevice advertisedDevice = scanResults->getDevice(i);
String deviceName = advertisedDevice.getName().c_str();
if (deviceName.startsWith("S&B")) {
connectVolcano(advertisedDevice);
// break out of the loop!
break;
}
}
}
state = DEVICE_STATE_CONNECTING_WIFI;
Serial.println("[INFO] Initializing WiFi…");
WiFi.setHostname("volcano_exporter");
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
vTaskDelay(pdMS_TO_TICKS(1000));
Serial.println("[DEBUG] (still) connecting to WiFi…");
}
Serial.println("[INFO] Connected to WiFi!");
Serial.print("[INFO] IP address: ");
Serial.println(WiFi.localIP());
server.begin();
}
void handleAvailableClient() {
NetworkClient client = server.accept();
if (client) {
state = DEVICE_STATE_FETCHING_WIFI;
String currentLine = "";
String header = "";
while (client.connected()) {
if (client.available()) { // check if bytes are available
char c = client.read();
header += c;
if (c == '\n') { // if the byte is a newline character
// if the current line is blank, you got two newline characters in a row.
// that's the end of the client HTTP request, so send a response:
if (currentLine.length() == 0) {
Serial.print("[INFO] Request: " + header.substring(0, header.indexOf('\n')));
if (header.indexOf("GET /metrics") >= 0) {
if (deviceMetrics.connected) {
Serial.println(" -> 200, metrics!");
String metricsString = buildMetricsString();
client.println("HTTP/1.1 200 OK");
client.println("Content-type: text/plain; version=0.0.4; charset=utf-8");
client.println("Connection: close");
client.println("Content-Length: " + String(metricsString.length()));
client.println();
client.println(metricsString);
} else {
Serial.println(" -> 500, error!");
String response = "It bork.";
state = DEVICE_STATE_ERROR;
client.println("HTTP/1.1 500 Internal Server Error");
client.println("Content-type: text/plain");
client.println("Connection: close");
client.println("Content-Length: " + String(response.length()));
client.println();
client.println(response);
}
} else {
String nonMetricsResponse = "<html><body><p>Please see <a href=\"/metrics\">/metrics</a> for data.</p></body></html>";
Serial.println(" -> 302, redirect!");
client.println("HTTP/1.1 302 Found");
client.println("Content-type: text/html");
client.println("Connection: close");
client.println("Location: /metrics");
client.println("Content-Length: " + String(nonMetricsResponse.length()));
client.println();
client.println(nonMetricsResponse);
}
// The HTTP response ends with another blank line
client.println();
// Break out of the while loop
break;
} else { // if you got a newline, then clear currentLine
currentLine = "";
}
} else if (c != '\r') { // if you got anything else but a carriage return character,
currentLine += c; // add it to the end of the currentLine
}
}
}
client.stop();
}
}
void loop() {
if (state != DEVICE_STATE_ERROR) {
if (state != DEVICE_STATE_IDLE) {
state = DEVICE_STATE_IDLE;
}
handleAvailableClient();
vTaskDelay(pdMS_TO_TICKS(50));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment