diff --git a/README.md b/README.md index 17b1908..48de986 100644 --- a/README.md +++ b/README.md @@ -96,14 +96,14 @@ Usage: - WS2812-compatible 5mm bulb LEDs: https://de.aliexpress.com/item/1005005003701575.html > :warning: **The vendor appears to sell LEDs with different pinouts as the same product!** - > + > > I recommend you order the LEDs before you order the PCBs so that you can make changes to the PCB if you receive a different pinout. > The PCB design in this repo assumes the following LED pin order (starting from the flat side): `Dout`, `GND`, `5V`, `Din`. - Black 5mm LED sockets: https://de.aliexpress.com/item/1005005062684329.html - ESP32-C3 Super Mini: https://de.aliexpress.com/item/1005006252882434.html - JST-ZH cables: https://de.aliexpress.com/item/1005007298855435.html (4P, Reversed) - I recommend you order a mix of 100mm, 200mm and 300mm cables, as you'll be facing a variety of distances, and having shorter cables available makes for much easier troubleshooting. - - In my order, roughly 10% of the cables had a broken wire. Not sure whether I just got a bad batch, or whether they are just bad quality in general, but I recommend you keep that in mind when ordering. + - In my order, roughly 10% of the cables had at least one broken wire. So I recommend you order a bit more than what you actually need. - Don't rely on the order of colors in the product description. I've ordered different batches, and got differently color orders each time. @@ -116,18 +116,35 @@ Usage: - Press and hold the button or short the pin header with a jumper while you press the reset button. - The ESP32 will now boot into WiFi config AP mode, where you can change the WiFi credentials and some additional parameters. - If the configured WiFi can't be found, the ESP32 will automatically boot into config AP mode. +- This feature is configurable, see `platformio.ini` on how to disable it or change the pins to use. ### Automatic Brightness Adaption -- You can plug a voltage divider with a photo resistor into the `IN` pin header on the PCB with the ESP32 on it, with the voltage divider's center tap connected to the `Din` pin. +- You can plug a voltage divider with a LDR (photoresistor) into the `IN` pin header on the PCB with the ESP32 on it, with the voltage divider's center tap connected to the `Din` pin. - You can use this to both adapt the LED brightness to the ambient light, and to turn off the LEDs below a certain ambient light level. - The thresholds can be configured via the web interface in config AP mode (see above). - Note that the brightness sensor readings are reversed: High values correspond to darker ambient light levels. +- This feature is configurable, see `platformio.ini` on how to disable it or change the pin to use. + - It is enabled by default. If you aren't using an LDR, disabled the feature flag, as otherwise your LEDs may flicker or not turn on at all, because the LDR pin is floating by default. ![Schematic of the voltage divider setup](images/brightness_sensor_schematic.png) ![Photo of a rather messy actual implementation](images/brightness_sensor.jpg) +## Meaning of the LED Colors + +- **Green**: The hackerspace is open. +- **Red**: The hackerspace is closed. +- **Orange**: The SpaceAPI aggregator service reported the endpoint to be invalid. This can either mean that the endpoint is unreachable, or that it did not validate against any of the SpaceAPI schema versions that it claims to be compliant with. +- **Blue**: The SpaceAPI aggregator service reported the endpoint was last reachable more than 24h ago. +- **Blank**: The URL of this hackerspace was not contained in the aggregator's response. Likely the URL has changed. Please check the contents of https://api.spaceapi.io, or the recently closed PRs on https://github.com/SpaceApi/directory/ for the correct endpoint and update the ESP's program accordingly. + +Apart from these per-space states, error states of the SpaceAPI map itself are reported as follows: + +- **LED 0 flashes every 10s**: The last update of the map has failed, most likely due to network issues. If this issue persists, please connect to the ESP's serial console and check the output. +- **Purple LED 0**: The ESP has just started, and has not yet contacted the SpaceAPI aggregator service. If this is shown for more than a few seconds, the ESP most likely failed to connect to the configured WiFi network. If this issue persists, check whether there is a `spaceapimap` WiFi AP in your vicinity, and connect to it to verify the WiFi configuration, or connect to the ESP's serial console and check the output. + + ## License Unless otherwise noted, the contents of this repository are licensed under the MIT License (See LICENSE). diff --git a/esp32/platformio.ini b/esp32/platformio.ini index 36a3e25..a19f930 100644 --- a/esp32/platformio.ini +++ b/esp32/platformio.ini @@ -8,7 +8,7 @@ ; Please visit documentation for the other options and examples ; https://docs.platformio.org/page/projectconf.html -[env:esp32-c3-devkitm-1] +[env:esp32-c3-devkitm-1] ;; Compatible with the ESP32-C3 Super Mini board platform = espressif32 board = esp32-c3-devkitm-1 framework = arduino @@ -21,3 +21,22 @@ lib_deps = build_flags = -D ARDUINO_USB_MODE=1 -D ARDUINO_USB_CDC_ON_BOOT=1 + + ;; + ;; Enable and disable optional features here, or change their pin mappings: + ;; + + ;; FEATURE_CONFIGAP: Always start the WifiManager access point if the pin provided here is pulled LOW during powerup. + -D FEATURE_CONFIGAP=5 + + ;; FEATURE_CONFIGAP_GND: If enabled, this pin is always driven LOW. + ;; Intended to provide a neighboring "pseudo-GND" pin next to the CONFIGAP pin for using a jumper between the two. + -D FEATURE_CONFIGAP_GND=6 + + ;; FEATURE_LDR: If enabled, read the ambient brightness via an LDR connected to the ADC through the pin provided here. + ;; The pin provided MUST be an ADC-capable pin (0-5 on the ESP32-C3 Super Mini). This enables the following features: + ;; - Turning off the LEDs below a certain (configurable) brightness. + ;; - Scaling the LED output power with the ambient brightness. + ;; - Additional parameters in the WifiManager config menu for configuring these features. + ;; Check out the README for a schematic of how to wire up the LDR. + -D FEATURE_LDR=3 diff --git a/esp32/src/main.cpp b/esp32/src/main.cpp index 738df95..79e9861 100644 --- a/esp32/src/main.cpp +++ b/esp32/src/main.cpp @@ -20,10 +20,6 @@ #define SPACEAPI_PORT 443 #define SPACEAPI_PATH "/" -#define BOOT_READ_PIN 5 -#define BOOT_GND_PIN 6 - -#define BRIGHTNESS_PIN 3 #define DEFAULT_BRIGHTNESS_THRESH_LOWER 1000 #define DEFAULT_BRIGHTNESS_THRESH_UPPER 400 #define DEFAULT_BRIGHTNESS_MIN 10 @@ -37,11 +33,11 @@ NTP ntp(wifiUdp); Adafruit_NeoPixel pixels(WS2812_LEN, WS2812_PIN, NEO_RGB | NEO_KHZ800); JsonDocument json, filter; -DeserializationError error = DeserializationError::EmptyInput; ESP32Timer ITimer0(0); volatile bool wmp_needs_config_save; volatile bool timerExpired; +bool error = false; enum SpaceState { Closed, @@ -118,18 +114,33 @@ void wmp_save_config_callback() { void setup() { + delay(3000); Serial.begin(115200); - pinMode(BOOT_READ_PIN, INPUT_PULLUP); - pinMode(BOOT_GND_PIN, OUTPUT); - digitalWrite(BOOT_GND_PIN, LOW); + Serial.println("Welcome to spaceapimap!"); + +#ifdef FEATURE_CONFIGAP + // Input pin for starting the config AP on request + pinMode(FEATURE_CONFIGAP, INPUT_PULLUP); +#endif // FEATURE_CONFIGAP +#ifdef FEATURE_CONFIGAP_GND + // "GND" on the next pin for pulling the input pin low with a jumper or button + pinMode(FEATURE_CONFIGAP_GND, OUTPUT); + digitalWrite(FEATURE_CONFIGAP_GND, LOW); +# endif // FEATURE_CONFIGAP_GND + + Serial.println("Starting SPIFFS."); if (!SPIFFS.begin(true)) { - Serial.println("SPIFFS mount failed"); - return; + Serial.println("SPIFFS mount failed. Rebooting..."); + ESP.restart(); + while (true); } loadConfig(); pixels.begin(); pixels.clear(); + // Show a purple indicator on LED 0 while in setup + pixels.setPixelColor(0, pixels.Color(DEFAULT_BRIGHTNESS_MIN, 0, DEFAULT_BRIGHTNESS_MIN)); + pixels.show(); WiFi.setHostname("spaceapimap"); WiFi.setTxPower(WIFI_POWER_15dBm); @@ -142,16 +153,22 @@ void setup() { wifiManager.setSaveConfigCallback(wmp_save_config_callback); wmp_needs_config_save = false; - WiFiManagerParameter wmp_brightness_thresh_lower("brightness_thresh_lower", "Lights out at ", String(spiffs_config.brightness_thresh_lower).c_str(), 6); +#ifdef FEATURE_LDR + WiFiManagerParameter wmp_brightness_thresh_lower("brightness_thresh_lower", "Lights out at (0..2500) ", String(spiffs_config.brightness_thresh_lower).c_str(), 6); wifiManager.addParameter(&wmp_brightness_thresh_lower); - WiFiManagerParameter wmp_brightness_thresh_upper("brightness_thresh_upper", "Max brightness at ", String(spiffs_config.brightness_thresh_upper).c_str(), 6); + WiFiManagerParameter wmp_brightness_thresh_upper("brightness_thresh_upper", "Max brightness at (0..2500)", String(spiffs_config.brightness_thresh_upper).c_str(), 6); wifiManager.addParameter(&wmp_brightness_thresh_upper); - WiFiManagerParameter wmp_brightness_min("brightness_min", "Min brightness ", String(spiffs_config.brightness_min).c_str(), 4); + WiFiManagerParameter wmp_brightness_min("brightness_min", "Min brightness (0..255)", String(spiffs_config.brightness_min).c_str(), 4); wifiManager.addParameter(&wmp_brightness_min); - WiFiManagerParameter wmp_brightness_max("brightness_max", "Max brightness (Warning: Too high might blow fuses) ", String(spiffs_config.brightness_max).c_str(), 4); + WiFiManagerParameter wmp_brightness_max("brightness_max", "Max brightness (0..255; WARNING: Too high might blow fuses) ", String(spiffs_config.brightness_max).c_str(), 4); wifiManager.addParameter(&wmp_brightness_max); +#else // FEATURE_LDR + WiFiManagerParameter wmp_brightness_max("brightness_max", "Brightness (Warning: Too high might blow fuses) ", String(spiffs_config.brightness_max).c_str(), 4); + wifiManager.addParameter(&wmp_brightness_max); +#endif // FEATURE_LDR - if (!digitalRead(BOOT_READ_PIN)) { +#ifdef FEATURE_CONFIGAP + if (!digitalRead(FEATURE_CONFIGAP)) { if (!wifiManager.startConfigPortal("spaceapimap", "12345678")) { ESP.restart(); while (true); @@ -162,11 +179,19 @@ void setup() { while (true); } } +#else // FEATURE_CONFIGAP + if (!wifiManager.autoConnect("spaceapimap", "12345678")) { + ESP.restart(); + while (true); + } +#endif // FEATURE_CONFIGAP if (wmp_needs_config_save) { +#ifdef FEATURE_LDR spiffs_config.brightness_thresh_lower = atol(wmp_brightness_thresh_lower.getValue()); spiffs_config.brightness_thresh_upper = atol(wmp_brightness_thresh_upper.getValue()); spiffs_config.brightness_min = atol(wmp_brightness_min.getValue()); +#endif // FEATURE_LDR spiffs_config.brightness_max = atol(wmp_brightness_max.getValue()); writeConfig(); } @@ -179,121 +204,152 @@ void setup() { filter[0]["data"]["state"]["open"] = true; filter[0]["data"]["state"]["lastchange"] = true; - analogSetPinAttenuation(BRIGHTNESS_PIN, ADC_11db); +#ifdef FEATURE_LDR + analogSetPinAttenuation(FEATURE_LDR, ADC_11db); +#endif // FEATURE_LDR ITimer0.attachInterruptInterval(300 * 1000 * 1000, timer0_isr); timerExpired = true; memset(states, 0, sizeof(uint8_t) * WS2812_LEN); } -void loop() { +bool updateMap() { + if (WiFi.status() != WL_CONNECTED) { + Serial.println("WiFi disconnected, reconnecting"); + WiFi.reconnect(); + for (uint8_t i = 0; i < 100 && WiFi.status() != WL_CONNECTED; ++i) { + delay(500); + } + if (WiFi.status() == WL_CONNECTED) { + Serial.println("Reconnected!"); + } else { + Serial.println("Reconnecting failed."); + return false; + } + } + ntp.update(); time_t now = ntp.epoch(); - if (timerExpired) { - timerExpired = false; - Serial.println("Timer expired!"); - if (WiFi.status() != WL_CONNECTED) { - Serial.println("WiFi disconnected, reconnecting"); - WiFi.reconnect(); - for (uint8_t i = 0; i < 100 && WiFi.status() != WL_CONNECTED; ++i) { - delay(500); + Serial.println(ntp.formattedTime("\nIt is %d.%m.%Y %H:%M UTC")); + Serial.println("Starting connection to server..."); + client.setInsecure(); + if (!client.connect(SPACEAPI_HOST, SPACEAPI_PORT)) { + Serial.println("Connection failed!"); + return false; + } + Serial.println("Connected to server!"); + client.print("GET "); + client.print(SPACEAPI_PATH); + client.println(" HTTP/1.0"); + client.print("Host: "); + client.println(SPACEAPI_HOST); + client.println("Connection: close"); + client.println(); + // Read until empty line (HTTP header/body separator) + while (client.readStringUntil('\n') != "\r"); + + DeserializationError jerror = deserializeJson(json, client, DeserializationOption::Filter(filter)); + if (jerror) { + Serial.print("deserializeJson() failed: "); + Serial.println(jerror.f_str()); + return false; + } + for (uint16_t i = 0; i < json.size(); ++i) { + int16_t pi = -1; + for (uint16_t j = 0; j < WS2812_LEN; ++j) { + if (spaces[j] == json[i]["url"]) { + pi = j; + break; } - if (WiFi.status() == WL_CONNECTED) { - Serial.println("Reconnected!"); + } + if (pi < 0) { + continue; + } + if (json[i]["data"] == nullptr || json[i]["data"]["state"] == nullptr || json[i]["data"]["state"]["open"] == nullptr || !json[i]["data"]["state"]["open"].is()) { + states[pi] = SpaceState::Invalid; + Serial.println(": invalid!"); + } else { + Serial.print(spaces[pi]); + time_t last = json[i]["lastSeen"].as(); + time_t last2 = last; + if (json[i]["data"]["state"]["lastchange"] != nullptr) { + last2 = json[i]["data"]["state"]["lastchange"].as(); + } + if (now - last > 24*3600) { + states[pi] = SpaceState::Outdated; + Serial.println(": outdated!"); } else { - Serial.println("Reconnecting failed."); - } - } - ntp.update(); - now = ntp.epoch(); - Serial.println(ntp.formattedTime("\nIt is %d.%m.%Y %H:%M UTC")); - Serial.println("Starting connection to server..."); - client.setInsecure(); - if (!client.connect(SPACEAPI_HOST, SPACEAPI_PORT)) { - Serial.println("Connection failed!"); - } else { - Serial.println("Connected to server!"); - client.print("GET "); - client.print(SPACEAPI_PATH); - client.println(" HTTP/1.0"); - client.print("Host: "); - client.println(SPACEAPI_HOST); - client.println("Connection: close"); - client.println(); - - while (client.readStringUntil('\n') != "\r"); - } - error = deserializeJson(json, client, DeserializationOption::Filter(filter)); - if (error) { - Serial.print("deserializeJson() failed: "); - Serial.println(error.f_str()); - } else { - for (uint16_t i = 0; i < json.size(); ++i) { - int16_t pi = -1; - for (uint16_t j = 0; j < WS2812_LEN; ++j) { - if (spaces[j] == json[i]["url"]) { - pi = j; - break; - } - } - if (pi < 0) { - continue; - } - if (json[i]["data"] == nullptr || json[i]["data"]["state"] == nullptr || json[i]["data"]["state"]["open"] == nullptr || !json[i]["data"]["state"]["open"].is()) { - states[pi] = SpaceState::Invalid; - Serial.println(": invalid!"); + if (json[i]["data"]["state"]["open"].as()) { + states[pi] = SpaceState::Open; + Serial.println(": open"); } else { - Serial.print(spaces[pi]); - time_t last = json[i]["lastSeen"].as(); - time_t last2 = last; - if (json[i]["data"]["state"]["lastchange"] != nullptr) { - last2 = json[i]["data"]["state"]["lastchange"].as(); - } - if (now - last > 24*3600) { - states[pi] = SpaceState::Outdated; - Serial.println(": outdated!"); - } else { - if (json[i]["data"]["state"]["open"].as()) { - states[pi] = SpaceState::Open; - Serial.println(": open"); - } else { - states[pi] = SpaceState::Closed; - Serial.println(": closed"); - } - } + states[pi] = SpaceState::Closed; + Serial.println(": closed"); } } - } - client.stop(); - } // endif timerExpired + } + client.stop(); + return true; +} - // Read brightness from LDR (0..2500 mV) and map to power level (0..255) - uint16_t brightness = analogRead(BRIGHTNESS_PIN); - pixels.clear(); +#ifdef FEATURE_LDR +uint16_t rolling_average_brightness[10]; +uint8_t rolling_average_brightness_i = 0; +uint16_t ambientBrightnessToPower() { + // Read brightness from LDR (0..2500 mV) and map to power level (0..255) and write into rolling average list + rolling_average_brightness[rolling_average_brightness_i] = analogRead(FEATURE_LDR); + rolling_average_brightness_i = (rolling_average_brightness_i+1) % (sizeof(rolling_average_brightness)/sizeof(uint16_t)); + // Compute average over readings stored in the rolling_average_brightness list + uint32_t sum = 0; + for (uint8_t i = 0; i < sizeof(rolling_average_brightness)/sizeof(uint16_t); ++i) { + sum += rolling_average_brightness[i]; + } + uint16_t brightness = sum / (sizeof(rolling_average_brightness)/sizeof(uint16_t)); + Serial.print("brightness: "); + Serial.println(brightness); if (brightness < spiffs_config.brightness_thresh_lower) { uint16_t clamped = min(max(brightness, spiffs_config.brightness_thresh_upper), spiffs_config.brightness_thresh_lower); uint8_t power = spiffs_config.brightness_min + (spiffs_config.brightness_max - spiffs_config.brightness_min) * pow(1.0f * (spiffs_config.brightness_thresh_lower - clamped) / (spiffs_config.brightness_thresh_lower - spiffs_config.brightness_thresh_upper), 2); - // safeguard! + // Safeguard in case the computation is somehow off! if (power > spiffs_config.brightness_max) { power = spiffs_config.brightness_max; } - // Set the color of each LED according to the space's state - for (uint16_t i = 0; i < WS2812_LEN; ++i) { - switch(states[i]) { - case SpaceState::Closed: - pixels.setPixelColor(i, pixels.Color(power, 0, 0)); // closed -> red - break; - case SpaceState::Open: - pixels.setPixelColor(i, pixels.Color(0, power, 0)); // open -> green - break; - case SpaceState::Invalid: - pixels.setPixelColor(i, pixels.Color(power, power/2, 0)); // invalid response -> orange - break; - case SpaceState::Outdated: - pixels.setPixelColor(i, pixels.Color(0, 0, power)); // unreachable -> blue - break; - default: - break; - } + return power; + } + return 0; +} +#endif // FEATURE_LDR + +void loop() { + if (timerExpired) { + timerExpired = false; + Serial.println("Timer expired!"); + error = !updateMap(); + } + time_t now = ntp.epoch(); + + uint16_t power = spiffs_config.brightness_max; +#ifdef FEATURE_LDR + power = ambientBrightnessToPower(); +#endif + + pixels.clear(); + // Set the color of each LED according to the space's state + for (uint16_t i = 0; i < WS2812_LEN; ++i) { + switch(states[i]) { + case SpaceState::Closed: + pixels.setPixelColor(i, pixels.Color(power, 0, 0)); // closed -> red + break; + case SpaceState::Open: + pixels.setPixelColor(i, pixels.Color(0, power, 0)); // open -> green + break; + case SpaceState::Invalid: + pixels.setPixelColor(i, pixels.Color(power, power/2, 0)); // invalid response -> orange + break; + case SpaceState::Outdated: + pixels.setPixelColor(i, pixels.Color(0, 0, power)); // unreachable -> blue + break; + default: + break; } } // Turn off the first LED every 10s in case of error @@ -302,5 +358,5 @@ void loop() { } // Finally, update the LED chain pixels.show(); - delay(10); + delay(50); }