WLED/usermods/pixels_dice_tray/pixels_dice_tray.cpp
2025-01-17 00:50:02 +00:00

537 lines
19 KiB
C++

#include <pixels_dice_interface.h> // https://github.com/axlan/arduino-pixels-dice
#include "wled.h"
#include "dice_state.h"
#include "led_effects.h"
#include "tft_menu.h"
// Set this parameter to rotate the display. 1-3 rotate by 90,180,270 degrees.
#ifndef USERMOD_PIXELS_DICE_TRAY_ROTATION
#define USERMOD_PIXELS_DICE_TRAY_ROTATION 0
#endif
// How often we are redrawing screen
#ifndef USERMOD_PIXELS_DICE_TRAY_REFRESH_RATE_MS
#define USERMOD_PIXELS_DICE_TRAY_REFRESH_RATE_MS 200
#endif
// Time with no updates before screen turns off (-1 to disable)
#ifndef USERMOD_PIXELS_DICE_TRAY_TIMEOUT_MS
#define USERMOD_PIXELS_DICE_TRAY_TIMEOUT_MS 5 * 60 * 1000
#endif
// Duration of each search for BLE devices.
#ifndef BLE_SCAN_DURATION_SEC
#define BLE_SCAN_DURATION_SEC 4
#endif
// Time between searches for BLE devices.
#ifndef BLE_TIME_BETWEEN_SCANS_SEC
#define BLE_TIME_BETWEEN_SCANS_SEC 5
#endif
#define WLED_DEBOUNCE_THRESHOLD \
50 // only consider button input of at least 50ms as valid (debouncing)
#define WLED_LONG_PRESS \
600 // long press if button is released after held for at least 600ms
#define WLED_DOUBLE_PRESS \
350 // double press if another press within 350ms after a short press
class PixelsDiceTrayUsermod : public Usermod {
private:
bool enabled = true;
DiceUpdate dice_update;
// Settings
uint32_t ble_scan_duration_sec = BLE_SCAN_DURATION_SEC;
unsigned rotation = USERMOD_PIXELS_DICE_TRAY_ROTATION;
DiceSettings dice_settings;
#if USING_TFT_DISPLAY
MenuController menu_ctrl;
#endif
static void center(String& line, uint8_t width) {
int len = line.length();
if (len < width)
for (byte i = (width - len) / 2; i > 0; i--)
line = ' ' + line;
for (byte i = line.length(); i < width; i++)
line += ' ';
}
// NOTE: THIS MOD DOES NOT SUPPORT CHANGING THE SPI PINS FROM THE UI! The
// TFT_eSPI library requires that they are compiled in.
static void SetSPIPinsFromMacros() {
#if USING_TFT_DISPLAY
spi_mosi = TFT_MOSI;
// Done in TFT library.
if (TFT_MISO == TFT_MOSI) {
spi_miso = -1;
}
spi_sclk = TFT_SCLK;
#endif
}
void UpdateDieNames(
const std::array<const std::string, MAX_NUM_DICE>& new_die_names) {
for (size_t i = 0; i < MAX_NUM_DICE; i++) {
// If the saved setting was a wildcard, and that connected to a die, use
// the new name instead of the wildcard. Saving this "locks" the name in.
bool overriden_wildcard =
new_die_names[i] == "*" && dice_update.connected_die_ids[i] != 0;
if (!overriden_wildcard &&
new_die_names[i] != dice_settings.configured_die_names[i]) {
dice_settings.configured_die_names[i] = new_die_names[i];
dice_update.connected_die_ids[i] = 0;
last_die_events[i] = pixels::RollEvent();
}
}
}
public:
PixelsDiceTrayUsermod()
#if USING_TFT_DISPLAY
: menu_ctrl(&dice_settings)
#endif
{
}
// Functions called by WLED
/*
* setup() is called once at boot. WiFi is not yet connected at this point.
* You can use it to initialize variables, sensors or similar.
*/
void setup() override {
DEBUG_PRINTLN(F("DiceTray: init"));
#if USING_TFT_DISPLAY
SetSPIPinsFromMacros();
PinManagerPinType spiPins[] = {
{spi_mosi, true}, {spi_miso, false}, {spi_sclk, true}};
if (!PinManager::allocateMultiplePins(spiPins, 3, PinOwner::HW_SPI)) {
enabled = false;
} else {
PinManagerPinType displayPins[] = {
{TFT_CS, true}, {TFT_DC, true}, {TFT_RST, true}, {TFT_BL, true}};
if (!PinManager::allocateMultiplePins(
displayPins, sizeof(displayPins) / sizeof(PinManagerPinType),
PinOwner::UM_FourLineDisplay)) {
PinManager::deallocateMultiplePins(spiPins, 3, PinOwner::HW_SPI);
enabled = false;
}
}
if (!enabled) {
DEBUG_PRINTLN(F("DiceTray: TFT Display pin allocations failed."));
return;
}
#endif
// Need to enable WiFi sleep:
// "E (1513) wifi:Error! Should enable WiFi modem sleep when both WiFi and Bluetooth are enabled!!!!!!"
noWifiSleep = false;
// Get the mode indexes that the effects are registered to.
FX_MODE_SIMPLE_D20 = strip.addEffect(255, &simple_roll, _data_FX_MODE_SIMPLE_DIE);
FX_MODE_PULSE_D20 = strip.addEffect(255, &pulse_roll, _data_FX_MODE_PULSE_DIE);
FX_MODE_CHECK_D20 = strip.addEffect(255, &check_roll, _data_FX_MODE_CHECK_DIE);
DIE_LED_MODES = {FX_MODE_SIMPLE_D20, FX_MODE_PULSE_D20, FX_MODE_CHECK_D20};
// Start a background task scanning for dice.
// On completion the discovered dice are connected to.
pixels::ScanForDice(ble_scan_duration_sec, BLE_TIME_BETWEEN_SCANS_SEC);
#if USING_TFT_DISPLAY
menu_ctrl.Init(rotation);
#endif
}
/*
* connected() is called every time the WiFi is (re)connected
* Use it to initialize network interfaces
*/
void connected() override {
// Serial.println("Connected to WiFi!");
}
/*
* loop() is called continuously. Here you can check for events, read sensors,
* etc.
*
* Tips:
* 1. You can use "if (WLED_CONNECTED)" to check for a successful network
* connection. Additionally, "if (WLED_MQTT_CONNECTED)" is available to check
* for a connection to an MQTT broker.
*
* 2. Try to avoid using the delay() function. NEVER use delays longer than 10
* milliseconds. Instead, use a timer check as shown here.
*/
void loop() override {
static long last_loop_time = 0;
static long last_die_connected_time = millis();
char mqtt_topic_buffer[MQTT_MAX_TOPIC_LEN + 16];
char mqtt_data_buffer[128];
// Check if we time interval for redrawing passes.
if (millis() - last_loop_time < USERMOD_PIXELS_DICE_TRAY_REFRESH_RATE_MS) {
return;
}
last_loop_time = millis();
// Update dice_list with the connected dice
pixels::ListDice(dice_update.dice_list);
// Get all the roll/battery updates since the last loop
pixels::GetDieRollUpdates(dice_update.roll_updates);
pixels::GetDieBatteryUpdates(dice_update.battery_updates);
// Go through list of connected die.
// TODO: Blacklist die that are connected to, but don't match the configured
// names.
std::array<bool, MAX_NUM_DICE> die_connected = {false, false};
for (auto die_id : dice_update.dice_list) {
bool matched = false;
// First check if we've already matched this ID to a connected die.
for (size_t i = 0; i < MAX_NUM_DICE; i++) {
if (die_id == dice_update.connected_die_ids[i]) {
die_connected[i] = true;
matched = true;
break;
}
}
// If this isn't already matched, check if its name matches an expected name.
if (!matched) {
auto die_name = pixels::GetDieDescription(die_id).name;
for (size_t i = 0; i < MAX_NUM_DICE; i++) {
if (0 == dice_update.connected_die_ids[i] &&
die_name == dice_settings.configured_die_names[i]) {
dice_update.connected_die_ids[i] = die_id;
die_connected[i] = true;
matched = true;
DEBUG_PRINTF_P(PSTR("DiceTray: %u (%s) connected.\n"), i,
die_name.c_str());
break;
}
}
// If it doesn't match any expected names, check if there's any wildcards to match.
if (!matched) {
auto description = pixels::GetDieDescription(die_id);
for (size_t i = 0; i < MAX_NUM_DICE; i++) {
if (dice_settings.configured_die_names[i] == "*") {
dice_update.connected_die_ids[i] = die_id;
die_connected[i] = true;
dice_settings.configured_die_names[i] = die_name;
DEBUG_PRINTF_P(PSTR("DiceTray: %u (%s) connected as wildcard.\n"),
i, die_name.c_str());
break;
}
}
}
}
}
// Clear connected die that aren't still present.
bool all_found = true;
bool none_found = true;
for (size_t i = 0; i < MAX_NUM_DICE; i++) {
if (!die_connected[i]) {
if (dice_update.connected_die_ids[i] != 0) {
dice_update.connected_die_ids[i] = 0;
last_die_events[i] = pixels::RollEvent();
DEBUG_PRINTF_P(PSTR("DiceTray: %u disconnected.\n"), i);
}
if (!dice_settings.configured_die_names[i].empty()) {
all_found = false;
}
} else {
none_found = false;
}
}
// Update last_die_events
for (const auto& roll : dice_update.roll_updates) {
for (size_t i = 0; i < MAX_NUM_DICE; i++) {
if (dice_update.connected_die_ids[i] == roll.first) {
last_die_events[i] = roll.second;
}
}
if (WLED_MQTT_CONNECTED) {
snprintf(mqtt_topic_buffer, sizeof(mqtt_topic_buffer), PSTR("%s/%s"),
mqttDeviceTopic, "dice/roll");
const char* name = pixels::GetDieDescription(roll.first).name.c_str();
snprintf(mqtt_data_buffer, sizeof(mqtt_data_buffer),
"{\"name\":\"%s\",\"state\":%d,\"val\":%d,\"time\":%d}", name,
int(roll.second.state), roll.second.current_face + 1,
roll.second.timestamp);
mqtt->publish(mqtt_topic_buffer, 0, false, mqtt_data_buffer);
}
}
#if USERMOD_PIXELS_DICE_TRAY_TIMEOUT_MS > 0 && USING_TFT_DISPLAY
// If at least one die is configured, but none are found
if (none_found) {
if (millis() - last_die_connected_time >
USERMOD_PIXELS_DICE_TRAY_TIMEOUT_MS) {
// Turn off LEDs and backlight and go to sleep.
// Since none of the wake up pins are wired up, expect to sleep
// until power cycle or reset, so don't need to handle normal
// wakeup.
bri = 0;
applyFinalBri();
menu_ctrl.EnableBacklight(false);
gpio_hold_en((gpio_num_t)TFT_BL);
gpio_deep_sleep_hold_en();
esp_deep_sleep_start();
}
} else {
last_die_connected_time = millis();
}
#endif
if (pixels::IsScanning() && all_found) {
DEBUG_PRINTF_P(PSTR("DiceTray: All dice found. Stopping search.\n"));
pixels::StopScanning();
} else if (!pixels::IsScanning() && !all_found) {
DEBUG_PRINTF_P(PSTR("DiceTray: Resuming dice search.\n"));
pixels::ScanForDice(ble_scan_duration_sec, BLE_TIME_BETWEEN_SCANS_SEC);
}
#if USING_TFT_DISPLAY
menu_ctrl.Update(dice_update);
#endif
}
/*
* addToJsonInfo() can be used to add custom entries to the /json/info part of
* the JSON API. Creating an "u" object allows you to add custom key/value
* pairs to the Info section of the WLED web UI. Below it is shown how this
* could be used for e.g. a light sensor
*/
void addToJsonInfo(JsonObject& root) override {
JsonObject user = root["u"];
if (user.isNull())
user = root.createNestedObject("u");
JsonArray lightArr = user.createNestedArray("DiceTray"); // name
lightArr.add(enabled ? F("installed") : F("disabled")); // unit
}
/*
* addToJsonState() can be used to add custom entries to the /json/state part
* of the JSON API (state object). Values in the state object may be modified
* by connected clients
*/
void addToJsonState(JsonObject& root) override {
// root["user0"] = userVar0;
}
/*
* readFromJsonState() can be used to receive data clients send to the
* /json/state part of the JSON API (state object). Values in the state object
* may be modified by connected clients
*/
void readFromJsonState(JsonObject& root) override {
// userVar0 = root["user0"] | userVar0; //if "user0" key exists in JSON,
// update, else keep old value if (root["bri"] == 255)
// Serial.println(F("Don't burn down your garage!"));
}
/*
* addToConfig() can be used to add custom persistent settings to the cfg.json
* file in the "um" (usermod) object. It will be called by WLED when settings
* are actually saved (for example, LED settings are saved) If you want to
* force saving the current state, use serializeConfig() in your loop().
*
* CAUTION: serializeConfig() will initiate a filesystem write operation.
* It might cause the LEDs to stutter and will cause flash wear if called too
* often. Use it sparingly and always in the loop, never in network callbacks!
*
* addToConfig() will also not yet add your setting to one of the settings
* pages automatically. To make that work you still have to add the setting to
* the HTML, xml.cpp and set.cpp manually.
*
* I highly recommend checking out the basics of ArduinoJson serialization and
* deserialization in order to use custom settings!
*/
void addToConfig(JsonObject& root) override {
JsonObject top = root.createNestedObject("DiceTray");
top["ble_scan_duration"] = ble_scan_duration_sec;
top["die_0"] = dice_settings.configured_die_names[0];
top["die_1"] = dice_settings.configured_die_names[1];
#if USING_TFT_DISPLAY
top["rotation"] = rotation;
JsonArray pins = top.createNestedArray("pin");
pins.add(TFT_CS);
pins.add(TFT_DC);
pins.add(TFT_RST);
pins.add(TFT_BL);
#endif
}
void appendConfigData() override {
// Slightly annoying that you can't put text before an element.
// The an item on the usermod config page has the following HTML:
// ```html
// Die 0
// <input type="hidden" name="DiceTray:die_0" value="text">
// <input type="text" name="DiceTray:die_0" value="*" style="width:250px;" oninput="check(this,'DiceTray')">
// ```
// addInfo let's you add data before or after the two input fields.
//
// To work around this, add info text to the end of the preceding item.
//
// See addInfo in wled00/data/settings_um.htm for details on what this function does.
oappend(F(
"addInfo('DiceTray:ble_scan_duration',1,'<br><br><i>Set to \"*\" to "
"connect to any die.<br>Leave Blank to disable.</i><br><i "
"class=\"warn\">Saving will replace \"*\" with die names.</i>','');"));
#if USING_TFT_DISPLAY
oappend(F("ddr=addDropdown('DiceTray','rotation');"));
oappend(F("addOption(ddr,'0 deg',0);"));
oappend(F("addOption(ddr,'90 deg',1);"));
oappend(F("addOption(ddr,'180 deg',2);"));
oappend(F("addOption(ddr,'270 deg',3);"));
oappend(F(
"addInfo('DiceTray:rotation',1,'<br><i class=\"warn\">DO NOT CHANGE "
"SPI PINS.</i><br><i class=\"warn\">CHANGES ARE IGNORED.</i>','');"));
oappend(F("addInfo('TFT:pin[]',0,'','SPI CS');"));
oappend(F("addInfo('TFT:pin[]',1,'','SPI DC');"));
oappend(F("addInfo('TFT:pin[]',2,'','SPI RST');"));
oappend(F("addInfo('TFT:pin[]',3,'','SPI BL');"));
#endif
}
/*
* readFromConfig() can be used to read back the custom settings you added
* with addToConfig(). This is called by WLED when settings are loaded
* (currently this only happens once immediately after boot)
*
* readFromConfig() is called BEFORE setup(). This means you can use your
* persistent values in setup() (e.g. pin assignments, buffer sizes), but also
* that if you want to write persistent values to a dynamic buffer, you'd need
* to allocate it here instead of in setup. If you don't know what that is,
* don't fret. It most likely doesn't affect your use case :)
*/
bool readFromConfig(JsonObject& root) override {
// we look for JSON object:
// {"DiceTray":{"rotation":0,"font_size":1}}
JsonObject top = root["DiceTray"];
if (top.isNull()) {
DEBUG_PRINTLN(F("DiceTray: No config found. (Using defaults.)"));
return false;
}
if (top.containsKey("die_0") && top.containsKey("die_1")) {
const std::array<const std::string, MAX_NUM_DICE> new_die_names{
top["die_0"], top["die_1"]};
UpdateDieNames(new_die_names);
} else {
DEBUG_PRINTLN(F("DiceTray: No die names found."));
}
#if USING_TFT_DISPLAY
unsigned new_rotation = min(top["rotation"] | rotation, 3u);
// Restore the SPI pins to their compiled in defaults.
SetSPIPinsFromMacros();
if (new_rotation != rotation) {
rotation = new_rotation;
menu_ctrl.Init(rotation);
}
// Update with any modified settings.
menu_ctrl.Redraw();
#endif
// use "return !top["newestParameter"].isNull();" when updating Usermod with
// new features
return !top["DiceTray"].isNull();
}
/**
* handleButton() can be used to override default button behaviour. Returning true
* will prevent button working in a default way.
* Replicating button.cpp
*/
#if USING_TFT_DISPLAY
bool handleButton(uint8_t b) override {
if (!enabled || b > 1 // buttons 0,1 only
|| buttonType[b] == BTN_TYPE_SWITCH || buttonType[b] == BTN_TYPE_NONE ||
buttonType[b] == BTN_TYPE_RESERVED ||
buttonType[b] == BTN_TYPE_PIR_SENSOR ||
buttonType[b] == BTN_TYPE_ANALOG ||
buttonType[b] == BTN_TYPE_ANALOG_INVERTED) {
return false;
}
unsigned long now = millis();
static bool buttonPressedBefore[2] = {false};
static bool buttonLongPressed[2] = {false};
static unsigned long buttonPressedTime[2] = {0};
static unsigned long buttonWaitTime[2] = {0};
//momentary button logic
if (!buttonLongPressed[b] && isButtonPressed(b)) { //pressed
if (!buttonPressedBefore[b]) {
buttonPressedTime[b] = now;
}
buttonPressedBefore[b] = true;
if (now - buttonPressedTime[b] > WLED_LONG_PRESS) { //long press
menu_ctrl.HandleButton(ButtonType::LONG, b);
buttonLongPressed[b] = true;
return true;
}
} else if (!isButtonPressed(b) && buttonPressedBefore[b]) { //released
long dur = now - buttonPressedTime[b];
if (dur < WLED_DEBOUNCE_THRESHOLD) {
buttonPressedBefore[b] = false;
return true;
} //too short "press", debounce
bool doublePress = buttonWaitTime[b]; //did we have short press before?
buttonWaitTime[b] = 0;
if (!buttonLongPressed[b]) { //short press
// if this is second release within 350ms it is a double press (buttonWaitTime!=0)
if (doublePress) {
menu_ctrl.HandleButton(ButtonType::DOUBLE, b);
} else {
buttonWaitTime[b] = now;
}
}
buttonPressedBefore[b] = false;
buttonLongPressed[b] = false;
}
// if 350ms elapsed since last press/release it is a short press
if (buttonWaitTime[b] && now - buttonWaitTime[b] > WLED_DOUBLE_PRESS &&
!buttonPressedBefore[b]) {
buttonWaitTime[b] = 0;
menu_ctrl.HandleButton(ButtonType::SINGLE, b);
}
return true;
}
#endif
/*
* getId() allows you to optionally give your V2 usermod an unique ID (please
* define it in const.h!). This could be used in the future for the system to
* determine whether your usermod is installed.
*/
uint16_t getId() { return USERMOD_ID_PIXELS_DICE_TRAY; }
// More methods can be added in the future, this example will then be
// extended. Your usermod will remain compatible as it does not need to
// implement all methods from the Usermod base class!
};
static PixelsDiceTrayUsermod pixels_dice_tray;
REGISTER_USERMOD(pixels_dice_tray);