diff --git a/.gitignore b/.gitignore index 03f4a3c..7155b61 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ .pio +include/config.h diff --git a/README.md b/README.md new file mode 100644 index 0000000..80f93e9 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# OBS-WebSocket Tally Light + +Quick Facts: + +* runs on ESP8266 and similar +* supports Authentication (but doesn't require it) +* uses industry-standard colours +* automatically reconnects if disconnected +* provides debug output over serial +* works with and without studio mode + +## Dependencies + +* `platformio` set up on your machine +* an instance of OBS running the websockets plugin +* some wifi network over which the OBS instance is reachable + +## Setup + +1. copy `include/config.example,h` to `include/config.h` +2. edit `include/config.h` to match your wifi and obs settings +3. connect your ESP8266 board using USB +4. run `pio run -t upload` to install the tally light onto your board + +If flashing succeeds, your LED strip should blink twice, then switch to +tally light mode. + +The serial console is configured to use _115200_ baud. + +## Colours + +### Red + +The configured source is currently live (visible in program). + +### Green + +Only available in studio mode. + +The configured source is currenly in preview. + +### Off + +The configured source is currenly not visible. + +### Purple + +Authentication failed, either by a missing or wrong password. The light +will automatically restart after 10 seconds. diff --git a/include/config.example.h b/include/config.example.h new file mode 100644 index 0000000..f9ffc2d --- /dev/null +++ b/include/config.example.h @@ -0,0 +1,53 @@ +/* + OBS-Websocket-Tally + + Please note all pin numbers use the ESP8266 pin numbers. Take a look + at your breakout boards documentation to find out which label on the + board corresponds to which ESP8266 pin. +*/ + +// How many LEDs do you have connected? +#define LED_COUNT 10 + +// Choose anything between 0 (off) and 255 (supernova-bright) +#define LED_BRIGHTNESS 100 + +// Which pin is your LED strip connected to. The default is labeled D6 +// on NodeMCU, D0 on Wemos D1 mini. +#define LED_PIN 12 + +// Configure your wifi credentials in here +#define WIFI_SSID "my wifi name" +#define WIFI_PASS "really secure password" + +// The IP address of the machine running OBS Studio +#define OBS_HOST "172.19.138.143" + +// The port on which the websockets plugin listens. Default is 4444 +#define OBS_PORT 4444 + +/* + If your websockets plugin requires authentication, set it here. + The Tally light will automatically restart if the password is wrong + (or unset but needed), but silently ignores if you set a password + without needing it. +*/ +//#define OBS_PASS "" + +/* + The name of the source as set in OBS. The tally light will perform + a exact match on that value, so please make sure you enter your + source name exactly as spelled inside OBS. +*/ +#define OBS_SOURCE "ATEM" + +/* + If defined, you'll get a status LED if the tally light is connected + to the configured OBS instance. The light will be on if the light + is able to receive information from OBS. If there's no connection + or authentication has failed. the light will be off. + + The default uses the onboard LED on the NodeMCU. On Wemos D1 mini + this pin is labeled D6. +*/ +#define STATUS_LED 16 diff --git a/src/main.cpp b/src/main.cpp index 17453dc..b23467f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,20 +1,11 @@ -#define LED_COUNT 10 -#define LED_BRIGHTNESS 100 -#define LED_PIN 12 +#include -#define WIFI_SSID "" -#define WIFI_PASS "" - -#define OBS_HOST "" -#define OBS_PORT 4444 -//#define OBS_PASS "" - -#define OBS_SOURCE "ATEM" +static_assert(sizeof(WIFI_SSID) > 1, "WIFI_SSID is empty"); +static_assert(sizeof(OBS_HOST) > 1, "OBS_HOST is empty"); +static_assert(sizeof(OBS_SOURCE) > 1, "OBS_SOURCE is empty"); #define FASTLED_ESP8266_RAW_PIN_ORDER -// END CONFIGURATION - #include #include #include @@ -27,6 +18,8 @@ CRGB leds[LED_COUNT]; WebSocketsClient webSocket; #ifdef OBS_PASS +static_assert(sizeof(OBS_PASS) > 1, "OBS_PASS must be non-empty if defined"); + #include #define HASH_SIZE 32 @@ -35,6 +28,7 @@ SHA256 sha256; bool is_currently_live = false; bool is_currently_preview = false; +bool is_currently_connected = false; void set_program() { Serial.println("[Tally] PROGRAM"); @@ -73,23 +67,19 @@ void handleWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) { We can't set the LEDs to "error", because if someone quits OBS, we will get disconnected. */ + is_currently_connected = false; break; case WStype_CONNECTED: Serial.printf("[WS] connected to %s\n", payload); -#ifdef OBS_PASS // Find out if we need authentication webSocket.sendTXT("{\"request-type\":\"GetAuthRequired\",\"message-id\":\"1\"}"); -#else - webSocket.sendTXT("{\"request-type\":\"GetCurrentScene\",\"message-id\":\"3\"}"); - webSocket.sendTXT("{\"request-type\":\"GetPreviewScene\",\"message-id\":\"4\"}"); -#endif break; case WStype_TEXT: { Serial.printf("[WS] %s\n", payload); - StaticJsonDocument<5000> doc; + StaticJsonDocument<10000> doc; DeserializationError error = deserializeJson(doc, payload); if (error) { @@ -98,59 +88,77 @@ void handleWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) { break; } -#ifdef OBS_PASS if (doc.containsKey("authRequired")) { - Serial.println("[OBS] auth requested"); + if (doc["authRequired"]) { +#ifdef OBS_PASS + Serial.println("[OBS] auth requested"); - sha256.reset(); - sha256.update(OBS_PASS, strlen(OBS_PASS)); - const char* salt = doc["salt"]; - sha256.update(salt, strlen(salt)); - char value[HASH_SIZE]; - sha256.finalize(value, HASH_SIZE); + sha256.reset(); + sha256.update(OBS_PASS, strlen(OBS_PASS)); + const char* salt = doc["salt"]; + sha256.update(salt, strlen(salt)); + char value[HASH_SIZE]; + sha256.finalize(value, HASH_SIZE); - Serial.print("[OBS] sha256 authentication hash is: "); - for (size_t i = 0; i < HASH_SIZE; i++) { - Serial.print(static_cast(value[i]), HEX); + Serial.print("[OBS] sha256 authentication hash is: "); + for (size_t i = 0; i < HASH_SIZE; i++) { + Serial.print(static_cast(value[i]), HEX); + } + Serial.println(); + + int encodedLength = Base64.encodedLength(HASH_SIZE); + char encodedPassSaltHash[encodedLength]; + Base64.encode(encodedPassSaltHash, value, HASH_SIZE); + + const char* challenge = doc["challenge"]; + sha256.reset(); + sha256.update(encodedPassSaltHash, encodedLength); + sha256.update(challenge, strlen(challenge)); + sha256.finalize(value, HASH_SIZE); + + Serial.print("[OBS] sha256 challenge hash is: "); + for (size_t i = 0; i < HASH_SIZE; i++) { + Serial.print(static_cast(value[i]), HEX); + } + Serial.println(); + + char encodedAuthString[encodedLength]; + Base64.encode(encodedAuthString, value, HASH_SIZE); + + String authRequest = String("{\"request-type\":\"Authenticate\",\"message-id\":\"2\",\"auth\":\"") + encodedAuthString + "\"}"; + webSocket.sendTXT(authRequest); +#else + Serial.println("[OBS] auth requested, but not configured!"); + set_error(); + delay(10000); + ESP.restart(); +#endif + } else { +#ifdef OBS_PASS + Serial.println("[OBS] auth configured, but not needed"); +#endif + webSocket.sendTXT("{\"request-type\":\"GetCurrentScene\",\"message-id\":\"3\"}"); + webSocket.sendTXT("{\"request-type\":\"GetPreviewScene\",\"message-id\":\"4\"}"); + is_currently_connected = true; } - Serial.println(); - - int encodedLength = Base64.encodedLength(HASH_SIZE); - char encodedPassSaltHash[encodedLength]; - Base64.encode(encodedPassSaltHash, value, HASH_SIZE); - - const char* challenge = doc["challenge"]; - sha256.reset(); - sha256.update(encodedPassSaltHash, encodedLength); - sha256.update(challenge, strlen(challenge)); - sha256.finalize(value, HASH_SIZE); - - Serial.print("[OBS] sha256 challenge hash is: "); - for (size_t i = 0; i < HASH_SIZE; i++) { - Serial.print(static_cast(value[i]), HEX); - } - Serial.println(); - - char encodedAuthString[encodedLength]; - Base64.encode(encodedAuthString, value, HASH_SIZE); - - String authRequest = String("{\"request-type\":\"Authenticate\",\"message-id\":\"2\",\"auth\":\"") + encodedAuthString + "\"}"; - webSocket.sendTXT(authRequest); break; +#ifdef OBS_PASS } else if (doc.containsKey("message-id") && doc["message-id"] == "2") { if (strcmp(doc["status"], "ok") == 0) { Serial.println("[OBS] authentication successful"); webSocket.sendTXT("{\"request-type\":\"GetCurrentScene\",\"message-id\":\"3\"}"); webSocket.sendTXT("{\"request-type\":\"GetPreviewScene\",\"message-id\":\"4\"}"); + is_currently_connected = true; } else { Serial.println("[OBS] authentication FAILED"); set_error(); + delay(10000); + ESP.restart(); } break; - } #endif - if (doc.containsKey("sources")) { + } else if (doc.containsKey("sources")) { bool my_source_in_current_event = false; for (uint8_t i = 0; i < doc["sources"].size(); i++) { @@ -180,6 +188,7 @@ void handleWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) { Serial.println("[OBS] quit"); is_currently_preview = false; is_currently_live = false; + is_currently_connected = false; } else if (strcmp(doc["update-type"], "StudioModeSwitched") == 0 && !doc["new-state"]) { Serial.println("[OBS] studio mode disabled"); is_currently_preview = false; @@ -210,6 +219,14 @@ void handleWebSocketEvent(WStype_t type, uint8_t * payload, size_t length) { case WStype_FRAGMENT_FIN: break; } + +#ifdef STATUS_LED + if (is_currently_connected) { + digitalWrite(STATUS_LED, LOW); + } else { + digitalWrite(STATUS_LED, HIGH); + } +#endif } void setup() { @@ -220,6 +237,10 @@ void setup() { FastLED.addLeds(leds, LED_COUNT); FastLED.setBrightness(LED_BRIGHTNESS); +#ifdef STATUS_LED + pinMode(STATUS_LED, OUTPUT); +#endif + set_error(); delay(100); set_idle();