Compare commits

...

3 commits

5 changed files with 2560 additions and 975 deletions

View file

@ -103,7 +103,7 @@ Usage:
- 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
- <span style="color: #00ff00;"></span> **Green**: The hackerspace is open.
- <span style="color: #ff0000;"></span> **Red**: The hackerspace is closed.
- <span style="color: #ff7f00;"></span> **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.
- <span style="color: #0000ff;"></span> **Blue**: The SpaceAPI aggregator service reported the endpoint was last reachable more than 24h ago.
- <span style="color: #000000;"></span> **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:
- <span style="color: #000000;"></span> **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.
- <span style="color: #ff00ff;"></span> **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).

View file

@ -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

View file

@ -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,119 +204,159 @@ 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();
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 (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<bool>()) {
states[pi] = SpaceState::Invalid;
Serial.println(": invalid!");
} else {
Serial.print(spaces[pi]);
time_t last = json[i]["lastSeen"].as<time_t>();
time_t last2 = last;
if (json[i]["data"]["state"]["lastchange"] != nullptr) {
last2 = json[i]["data"]["state"]["lastchange"].as<time_t>();
}
if (now - last > 24*3600) {
states[pi] = SpaceState::Outdated;
Serial.println(": outdated!");
} else {
if (json[i]["data"]["state"]["open"].as<bool>()) {
states[pi] = SpaceState::Open;
Serial.println(": open");
} else {
states[pi] = SpaceState::Closed;
Serial.println(": closed");
}
}
}
}
client.stop();
return true;
}
#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 in case the computation is somehow off!
if (power > spiffs_config.brightness_max) {
power = spiffs_config.brightness_max;
}
return power;
}
return 0;
}
#endif // FEATURE_LDR
void loop() {
if (timerExpired) {
timerExpired = false;
Serial.println("Timer expired!");
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<bool>()) {
states[pi] = SpaceState::Invalid;
Serial.println(": invalid!");
} else {
Serial.print(spaces[pi]);
time_t last = json[i]["lastSeen"].as<time_t>();
time_t last2 = last;
if (json[i]["data"]["state"]["lastchange"] != nullptr) {
last2 = json[i]["data"]["state"]["lastchange"].as<time_t>();
}
if (now - last > 24*3600) {
states[pi] = SpaceState::Outdated;
Serial.println(": outdated!");
} else {
if (json[i]["data"]["state"]["open"].as<bool>()) {
states[pi] = SpaceState::Open;
Serial.println(": open");
} else {
states[pi] = SpaceState::Closed;
Serial.println(": closed");
}
}
}
}
}
client.stop();
} // endif timerExpired
if (!error) {
uint16_t brightness = analogRead(BRIGHTNESS_PIN);
Serial.print("brightness: ");
Serial.print(brightness);
pixels.clear();
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);
Serial.print(", clamped: ");
Serial.print(clamped);
Serial.print(", power: ");
Serial.println(power);
// safeguard!
if (power > spiffs_config.brightness_max) {
power = spiffs_config.brightness_max;
}
//power = 10;
for (uint16_t i = 0; i < WS2812_LEN; ++i) {
switch(states[i]) {
case SpaceState::Closed:
pixels.setPixelColor(i, pixels.Color(power, 0, 0));
break;
case SpaceState::Open:
pixels.setPixelColor(i, pixels.Color(0, power, 0));
break;
case SpaceState::Invalid:
pixels.setPixelColor(i, pixels.Color(power, power/2, 0));
break;
case SpaceState::Outdated:
pixels.setPixelColor(i, pixels.Color(0, 0, power));
break;
default:
break;
}
}
}
pixels.show();
Serial.println();
error = !updateMap();
}
delay(10);
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
if (error && now % 10 == 0) {
pixels.setPixelColor(0, pixels.Color(0, 0, 0));
}
// Finally, update the LED chain
pixels.show();
delay(50);
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff