|
#include <Arduino_JSON.h> |
|
|
|
#include <Arduino.h> |
|
#include "token.h" |
|
#include <ESP8266WiFi.h> |
|
#include <ESP8266WiFiMulti.h> |
|
#include <ArduinoWebsockets.h> |
|
|
|
#define STATE_HALF 1 |
|
#define STATE_FULL 2 |
|
#define STATE_OFF 0 |
|
#define STATE_UNKNOWN -1 |
|
|
|
int last_state = STATE_UNKNOWN; |
|
long last_getStateStamp = 0; |
|
const long debounce_getState = 30 * 1000; |
|
long autooff_schedule = 0; |
|
|
|
int conseq_error_count = 0; |
|
|
|
// ----------------------------------------------------------- |
|
|
|
#define ledPin 13 |
|
#define buttonPin 14 |
|
|
|
int buttonState; // the current reading from the input pin |
|
int lastButtonState = LOW; // the previous reading from the input pin |
|
|
|
unsigned long lastDebounceTime = 0; // the last time the output pin was toggled |
|
unsigned long debounceDelay = 50; // the debounce time; increase if the output flickers |
|
|
|
void ledOn() { |
|
digitalWrite(LED_BUILTIN, LOW); // Turn the LED on (Note that LOW is the voltage level |
|
} |
|
|
|
void ledOff() { |
|
digitalWrite(LED_BUILTIN, HIGH); // Turn the LED off by making the voltage HIGH |
|
} |
|
|
|
void ledToggle() { |
|
digitalWrite(LED_BUILTIN, !digitalRead(LED_BUILTIN)); // Change the state of the LED |
|
} |
|
|
|
|
|
// ----------------------------------------------------------- |
|
|
|
ESP8266WiFiMulti WiFiMulti; |
|
websockets::WebsocketsClient webSocket; |
|
|
|
String haWsHost; |
|
String haWsPath = "/api/websocket"; |
|
uint16_t haWsPort = 443; |
|
bool haWsSecure = true; |
|
bool wsStarted = false; |
|
bool wsAuthenticated = false; |
|
int wsNextId = 1; |
|
int wsEntitySubscriptionId = 0; |
|
int wsEventSubscriptionId = 0; |
|
int pendingRequestId = 0; |
|
bool pendingRequestDone = false; |
|
bool pendingRequestSuccess = false; |
|
int last_percentage = -1; |
|
int last_ws_percentage = -1; |
|
bool wsConnectAttempted = false; |
|
unsigned long lastWsConnectAttempt = 0; |
|
|
|
const unsigned long wsAuthTimeout = 5000; |
|
const unsigned long wsCommandTimeout = 5000; |
|
const unsigned long wsStateTimeout = 3000; |
|
const unsigned long wsReconnectInterval = 5000; |
|
|
|
|
|
void setupWifi() { |
|
WiFi.mode(WIFI_STA); |
|
WiFiMulti.addAP(STASSID, STAPSK); |
|
Serial.println("setup() done connecting to ssid '" STASSID "'"); |
|
} |
|
|
|
bool clock_done = false; |
|
void clock_sync() { |
|
if (clock_done) { |
|
return; |
|
} |
|
|
|
// Set time via NTP, as required for x.509 validation |
|
configTime(3 * 3600, 0, "kr.pool.ntp.org", "time.nist.gov"); |
|
|
|
Serial.print("Waiting for NTP time sync: "); |
|
time_t now = time(nullptr); |
|
while (now < 8 * 3600 * 2) { |
|
delay(500); |
|
Serial.print("."); |
|
now = time(nullptr); |
|
} |
|
Serial.println(""); |
|
struct tm timeinfo; |
|
gmtime_r(&now, &timeinfo); |
|
Serial.print("Current time: "); |
|
Serial.print(asctime(&timeinfo)); |
|
|
|
clock_done = true; |
|
} |
|
|
|
void setup() { |
|
pinMode(LED_BUILTIN, OUTPUT); // Initialize the LED_BUILTIN pin as an output |
|
pinMode(ledPin, OUTPUT); |
|
pinMode(buttonPin, INPUT); |
|
ledOn(); |
|
|
|
analogWrite(ledPin, 128); |
|
ledOn(); |
|
|
|
Serial.begin(9600); |
|
|
|
setupWifi(); |
|
|
|
long now = millis(); |
|
lastDebounceTime = now; |
|
last_getStateStamp = now - debounce_getState; |
|
} |
|
|
|
|
|
String homeAssistantAccessToken() { |
|
String token = TOKEN; |
|
token.trim(); |
|
if (token.startsWith("Bearer ")) { |
|
token = token.substring(7); |
|
} |
|
return token; |
|
} |
|
|
|
void parseHomeAssistantUrl() { |
|
String url = HA_URL; |
|
url.trim(); |
|
|
|
String lower = url; |
|
lower.toLowerCase(); |
|
|
|
int schemeEnd = lower.indexOf("://"); |
|
int restStart = 0; |
|
if (schemeEnd >= 0) { |
|
String scheme = lower.substring(0, schemeEnd); |
|
haWsSecure = scheme == "https" || scheme == "wss"; |
|
restStart = schemeEnd + 3; |
|
} |
|
|
|
String rest = url.substring(restStart); |
|
int slash = rest.indexOf('/'); |
|
String authority = slash >= 0 ? rest.substring(0, slash) : rest; |
|
String basePath = slash >= 0 ? rest.substring(slash) : "/"; |
|
|
|
int colon = authority.lastIndexOf(':'); |
|
if (colon > 0) { |
|
haWsHost = authority.substring(0, colon); |
|
haWsPort = (uint16_t)authority.substring(colon + 1).toInt(); |
|
} else { |
|
haWsHost = authority; |
|
haWsPort = haWsSecure ? 443 : 80; |
|
} |
|
|
|
if (haWsPort == 0) { |
|
haWsPort = haWsSecure ? 443 : 80; |
|
} |
|
|
|
if (basePath.length() == 0) { |
|
basePath = "/"; |
|
} |
|
if (!basePath.endsWith("/")) { |
|
basePath += "/"; |
|
} |
|
haWsPath = basePath + "api/websocket"; |
|
} |
|
|
|
bool readPercentage(JSONVar attributes, int &percentage) { |
|
if (JSON.typeof(attributes) != "object") { |
|
return false; |
|
} |
|
if (!attributes.hasOwnProperty("percentage")) { |
|
return false; |
|
} |
|
|
|
percentage = (int)attributes["percentage"]; |
|
return true; |
|
} |
|
|
|
int stateFromPercentage(int percentage) { |
|
if (percentage >= 75) { |
|
return STATE_FULL; |
|
} |
|
|
|
if (percentage >= 50) { |
|
return STATE_HALF; |
|
} |
|
|
|
autooff_schedule = 0; // no job required. |
|
return STATE_OFF; |
|
} |
|
|
|
int stateFromHaValues(const char *state, int percentage, bool hasPercentage) { |
|
if (state == NULL) { |
|
return STATE_UNKNOWN; |
|
} |
|
|
|
if (hasPercentage) { |
|
last_percentage = percentage; |
|
last_ws_percentage = percentage; |
|
} |
|
|
|
if (strncasecmp(state, "off", 3) == 0) { |
|
autooff_schedule = 0; // no job required. |
|
return STATE_OFF; |
|
} |
|
|
|
if (strncasecmp(state, "on", 2) == 0) { |
|
if (!hasPercentage && last_ws_percentage >= 0) { |
|
percentage = last_ws_percentage; |
|
hasPercentage = true; |
|
} else if (!hasPercentage && last_percentage >= 0) { |
|
percentage = last_percentage; |
|
hasPercentage = true; |
|
} |
|
|
|
if (!hasPercentage) { |
|
return STATE_UNKNOWN; |
|
} |
|
|
|
Serial.printf("percentage: %d\n", percentage); |
|
return stateFromPercentage(percentage); |
|
} |
|
|
|
return STATE_UNKNOWN; |
|
} |
|
|
|
bool applyHaStateValues(const char *state, int percentage, bool hasPercentage) { |
|
int parsed = stateFromHaValues(state, percentage, hasPercentage); |
|
if (parsed == STATE_UNKNOWN) { |
|
Serial.println("[WS] state is unknown."); |
|
last_state = STATE_UNKNOWN; |
|
last_getStateStamp = millis(); |
|
return false; |
|
} |
|
|
|
last_state = parsed; |
|
last_getStateStamp = millis(); |
|
conseq_error_count = 0; |
|
Serial.printf("[WS] %s -> %d\n", TARGET, last_state); |
|
return true; |
|
} |
|
|
|
bool updateStateFromStateObject(JSONVar entity) { |
|
if (JSON.typeof(entity) != "object") { |
|
return false; |
|
} |
|
if (!entity.hasOwnProperty("state")) { |
|
return false; |
|
} |
|
|
|
const char *state = (const char *)entity["state"]; |
|
int percentage = -1; |
|
bool hasPercentage = false; |
|
if (entity.hasOwnProperty("attributes")) { |
|
JSONVar attributes = entity["attributes"]; |
|
hasPercentage = readPercentage(attributes, percentage); |
|
} |
|
|
|
return applyHaStateValues(state, percentage, hasPercentage); |
|
} |
|
|
|
bool updateStateFromCompactObject(JSONVar compact) { |
|
if (JSON.typeof(compact) != "object") { |
|
return false; |
|
} |
|
|
|
const char *state = NULL; |
|
bool hasState = false; |
|
if (compact.hasOwnProperty("s")) { |
|
state = (const char *)compact["s"]; |
|
hasState = true; |
|
} |
|
|
|
int percentage = -1; |
|
bool hasPercentage = false; |
|
if (compact.hasOwnProperty("a")) { |
|
JSONVar attributes = compact["a"]; |
|
hasPercentage = readPercentage(attributes, percentage); |
|
} |
|
|
|
if (hasState) { |
|
return applyHaStateValues(state, percentage, hasPercentage); |
|
} |
|
|
|
if (hasPercentage) { |
|
return applyHaStateValues("on", percentage, true); |
|
} |
|
|
|
return false; |
|
} |
|
|
|
bool sendJson(JSONVar payload, bool logPayload) { |
|
String payload_str = JSON.stringify(payload); |
|
if (logPayload) { |
|
Serial.printf("[WS] send: %s\n", payload_str.c_str()); |
|
} |
|
return webSocket.send(payload_str); |
|
} |
|
|
|
bool sendJson(JSONVar payload) { |
|
return sendJson(payload, true); |
|
} |
|
|
|
void sendAuth() { |
|
String token = homeAssistantAccessToken(); |
|
|
|
JSONVar payload; |
|
payload["type"] = "auth"; |
|
payload["access_token"] = token.c_str(); |
|
Serial.println("[WS] send auth."); |
|
sendJson(payload, false); |
|
} |
|
|
|
void subscribeStateEvents() { |
|
wsEventSubscriptionId = wsNextId++; |
|
|
|
JSONVar payload; |
|
payload["id"] = wsEventSubscriptionId; |
|
payload["type"] = "subscribe_events"; |
|
payload["event_type"] = "state_changed"; |
|
sendJson(payload); |
|
} |
|
|
|
void subscribeTargetEntity() { |
|
wsEntitySubscriptionId = wsNextId++; |
|
|
|
JSONVar entityIds; |
|
entityIds[0] = TARGET; |
|
|
|
JSONVar payload; |
|
payload["id"] = wsEntitySubscriptionId; |
|
payload["type"] = "subscribe_entities"; |
|
payload["entity_ids"] = entityIds; |
|
sendJson(payload); |
|
} |
|
|
|
void handleResultMessage(JSONVar message) { |
|
if (!message.hasOwnProperty("id")) { |
|
return; |
|
} |
|
|
|
int id = (int)message["id"]; |
|
bool success = true; |
|
if (message.hasOwnProperty("success")) { |
|
success = (bool)message["success"]; |
|
} |
|
|
|
if (id == pendingRequestId) { |
|
pendingRequestSuccess = success; |
|
pendingRequestDone = true; |
|
} |
|
|
|
if (id == wsEntitySubscriptionId && !success && wsEventSubscriptionId == 0) { |
|
Serial.println("[WS] subscribe_entities failed; falling back to state_changed events."); |
|
subscribeStateEvents(); |
|
} |
|
} |
|
|
|
void handleCompactEntityMap(JSONVar entities) { |
|
if (JSON.typeof(entities) != "object") { |
|
return; |
|
} |
|
if (!entities.hasOwnProperty(TARGET)) { |
|
return; |
|
} |
|
|
|
JSONVar targetEntity = entities[TARGET]; |
|
updateStateFromCompactObject(targetEntity); |
|
} |
|
|
|
void handleCompactEntityChange(JSONVar changes) { |
|
if (JSON.typeof(changes) != "object") { |
|
return; |
|
} |
|
if (!changes.hasOwnProperty(TARGET)) { |
|
return; |
|
} |
|
|
|
JSONVar targetChange = changes[TARGET]; |
|
if (targetChange.hasOwnProperty("+")) { |
|
JSONVar added = targetChange["+"]; |
|
updateStateFromCompactObject(added); |
|
} |
|
} |
|
|
|
void handleStateChangedEvent(JSONVar event) { |
|
if (JSON.typeof(event) != "object") { |
|
return; |
|
} |
|
if (!event.hasOwnProperty("data")) { |
|
return; |
|
} |
|
|
|
JSONVar data = event["data"]; |
|
if (!data.hasOwnProperty("entity_id")) { |
|
return; |
|
} |
|
|
|
const char *entityId = (const char *)data["entity_id"]; |
|
if (entityId == NULL || strcmp(entityId, TARGET) != 0) { |
|
return; |
|
} |
|
|
|
if (data.hasOwnProperty("new_state")) { |
|
JSONVar newState = data["new_state"]; |
|
updateStateFromStateObject(newState); |
|
} |
|
} |
|
|
|
void handleEventMessage(JSONVar message) { |
|
if (!message.hasOwnProperty("event")) { |
|
return; |
|
} |
|
|
|
JSONVar event = message["event"]; |
|
if (event.hasOwnProperty("a")) { |
|
handleCompactEntityMap(event["a"]); |
|
return; |
|
} |
|
if (event.hasOwnProperty("c")) { |
|
handleCompactEntityChange(event["c"]); |
|
return; |
|
} |
|
|
|
handleStateChangedEvent(event); |
|
} |
|
|
|
void onWsMessage(websockets::WebsocketsMessage message) { |
|
String payload = message.data(); |
|
Serial.printf("[WS] recv: %s\n", payload.c_str()); |
|
|
|
JSONVar root = JSON.parse(payload); |
|
if (JSON.typeof(root) == "undefined") { |
|
Serial.println("[WS] JSON parse failed."); |
|
return; |
|
} |
|
if (!root.hasOwnProperty("type")) { |
|
return; |
|
} |
|
|
|
const char *messageType = (const char *)root["type"]; |
|
if (messageType == NULL) { |
|
return; |
|
} |
|
|
|
if (strcmp(messageType, "auth_required") == 0) { |
|
sendAuth(); |
|
} else if (strcmp(messageType, "auth_ok") == 0) { |
|
Serial.println("[WS] authenticated."); |
|
wsAuthenticated = true; |
|
conseq_error_count = 0; |
|
subscribeTargetEntity(); |
|
} else if (strcmp(messageType, "auth_invalid") == 0) { |
|
Serial.println("[WS] authentication failed."); |
|
wsAuthenticated = false; |
|
conseq_error_count++; |
|
} else if (strcmp(messageType, "result") == 0) { |
|
handleResultMessage(root); |
|
} else if (strcmp(messageType, "event") == 0) { |
|
handleEventMessage(root); |
|
} |
|
} |
|
|
|
void onWsEvent(websockets::WebsocketsEvent event, String data) { |
|
if (event == websockets::WebsocketsEvent::ConnectionOpened) { |
|
Serial.println("[WS] connected."); |
|
wsAuthenticated = false; |
|
} else if (event == websockets::WebsocketsEvent::ConnectionClosed) { |
|
Serial.println("[WS] disconnected."); |
|
wsAuthenticated = false; |
|
pendingRequestDone = pendingRequestId != 0; |
|
pendingRequestSuccess = false; |
|
wsEntitySubscriptionId = 0; |
|
wsEventSubscriptionId = 0; |
|
} else if (event == websockets::WebsocketsEvent::GotPing) { |
|
Serial.println("[WS] got ping."); |
|
} else if (event == websockets::WebsocketsEvent::GotPong) { |
|
Serial.println("[WS] got pong."); |
|
} |
|
} |
|
|
|
bool startHaWebSocket() { |
|
if (!wsStarted) { |
|
parseHomeAssistantUrl(); |
|
webSocket.onMessage(onWsMessage); |
|
webSocket.onEvent(onWsEvent); |
|
if (haWsSecure) { |
|
webSocket.setInsecure(); |
|
} |
|
wsStarted = true; |
|
} |
|
|
|
if (webSocket.available()) { |
|
return true; |
|
} |
|
|
|
unsigned long now = millis(); |
|
if (wsConnectAttempted && now - lastWsConnectAttempt < wsReconnectInterval) { |
|
return false; |
|
} |
|
wsConnectAttempted = true; |
|
lastWsConnectAttempt = now; |
|
wsAuthenticated = false; |
|
pendingRequestDone = pendingRequestId != 0; |
|
pendingRequestSuccess = false; |
|
wsEntitySubscriptionId = 0; |
|
wsEventSubscriptionId = 0; |
|
|
|
Serial.printf("[WS] begin... %s://%s:%u%s\n", |
|
haWsSecure ? "wss" : "ws", |
|
haWsHost.c_str(), |
|
haWsPort, |
|
haWsPath.c_str()); |
|
|
|
bool connected = false; |
|
if (haWsSecure) { |
|
connected = webSocket.connectSecure(haWsHost, haWsPort, haWsPath); |
|
} else { |
|
connected = webSocket.connect(haWsHost, haWsPort, haWsPath); |
|
} |
|
|
|
if (!connected) { |
|
Serial.println("[WS] connect failed."); |
|
} |
|
|
|
return connected; |
|
} |
|
|
|
void pollHaWebSocket() { |
|
startHaWebSocket(); |
|
if (webSocket.available()) { |
|
webSocket.poll(); |
|
} |
|
} |
|
|
|
bool ensureHaReady(unsigned long timeoutMs) { |
|
unsigned long start = millis(); |
|
while (!wsAuthenticated && millis() - start < timeoutMs) { |
|
pollHaWebSocket(); |
|
delay(1); |
|
} |
|
|
|
return wsAuthenticated; |
|
} |
|
|
|
bool waitForState(unsigned long timeoutMs) { |
|
unsigned long start = millis(); |
|
while (last_state == STATE_UNKNOWN && millis() - start < timeoutMs) { |
|
pollHaWebSocket(); |
|
delay(1); |
|
} |
|
|
|
return last_state != STATE_UNKNOWN; |
|
} |
|
|
|
int getState(bool waitForFresh) { |
|
if (last_state != STATE_UNKNOWN) { |
|
return last_state; |
|
} |
|
|
|
if (!waitForFresh) { |
|
return last_state; |
|
} |
|
|
|
if (!ensureHaReady(wsAuthTimeout)) { |
|
conseq_error_count++; |
|
return STATE_UNKNOWN; |
|
} |
|
|
|
waitForState(wsStateTimeout); |
|
return last_state; |
|
} |
|
|
|
int getState() { |
|
return getState(false); |
|
} |
|
|
|
bool waitForServiceResult(unsigned long timeoutMs) { |
|
unsigned long start = millis(); |
|
while (!pendingRequestDone && millis() - start < timeoutMs) { |
|
pollHaWebSocket(); |
|
delay(1); |
|
} |
|
|
|
bool ok = pendingRequestDone && pendingRequestSuccess; |
|
pendingRequestId = 0; |
|
pendingRequestDone = false; |
|
pendingRequestSuccess = false; |
|
return ok; |
|
} |
|
|
|
void updateLocalStateAfterCommand(int state) { |
|
long now = millis(); |
|
last_getStateStamp = now; |
|
last_state = state; |
|
|
|
if (state == STATE_HALF) { |
|
last_percentage = 52; |
|
autooff_schedule = now + 30 * 60 * 1000; // 30min |
|
} else if (state == STATE_FULL) { |
|
last_percentage = 100; |
|
autooff_schedule = now + 10 * 60 * 1000; // 10min |
|
} else { |
|
last_percentage = -1; |
|
autooff_schedule = 0; |
|
} |
|
} |
|
|
|
bool setState(int state) { |
|
if (!ensureHaReady(wsAuthTimeout)) { |
|
Serial.println("[WS] not ready."); |
|
conseq_error_count++; |
|
return false; |
|
} |
|
|
|
int requestId = wsNextId++; |
|
JSONVar serviceData; |
|
serviceData["entity_id"] = TARGET; |
|
|
|
JSONVar payload; |
|
payload["id"] = requestId; |
|
payload["type"] = "call_service"; |
|
payload["domain"] = "fan"; |
|
|
|
if (state == STATE_HALF || state == STATE_FULL) { |
|
payload["service"] = "turn_on"; |
|
serviceData["percentage"] = state == STATE_HALF ? 52 : 100; |
|
} else { |
|
payload["service"] = "turn_off"; |
|
} |
|
|
|
payload["service_data"] = serviceData; |
|
|
|
pendingRequestId = requestId; |
|
pendingRequestDone = false; |
|
pendingRequestSuccess = false; |
|
|
|
if (!sendJson(payload)) { |
|
pendingRequestId = 0; |
|
conseq_error_count++; |
|
return false; |
|
} |
|
|
|
if (!waitForServiceResult(wsCommandTimeout)) { |
|
Serial.println("[WS] service call failed or timed out."); |
|
conseq_error_count++; |
|
return false; |
|
} |
|
|
|
updateLocalStateAfterCommand(state); |
|
conseq_error_count = 0; |
|
return true; // control OK. |
|
} |
|
|
|
void onButton(long now) { |
|
Serial.println("button pressed."); |
|
|
|
// last_getStateStamp = now - debounce_getState - 1; |
|
int state = getState(true); |
|
if (state == STATE_HALF) { |
|
setState(STATE_FULL); |
|
} else if (state == STATE_FULL) { |
|
setState(STATE_OFF); |
|
} else { |
|
setState(STATE_HALF); |
|
} |
|
} |
|
|
|
void readButton() { |
|
long now = millis(); |
|
int reading = digitalRead(buttonPin); |
|
|
|
if (reading != lastButtonState) { |
|
lastDebounceTime = now; |
|
} |
|
|
|
if ((now - lastDebounceTime) > debounceDelay) { |
|
if (reading != buttonState) { |
|
buttonState = reading; |
|
|
|
if (buttonState == HIGH) { |
|
onButton(now); |
|
} |
|
} |
|
} |
|
|
|
// save the reading. Next time through the loop, it'll be the lastButtonState: |
|
lastButtonState = reading; |
|
} |
|
|
|
|
|
int last_wifi_state = -1; |
|
|
|
void loop() { |
|
long now = millis(); |
|
|
|
if (conseq_error_count > 10) { |
|
ESP.reset(); |
|
return; |
|
} |
|
|
|
int wifi_state = WiFiMulti.run(); |
|
if (last_wifi_state != wifi_state) { |
|
Serial.printf("wifi state: %d -> %d\n", last_wifi_state, wifi_state); |
|
last_wifi_state = wifi_state; |
|
} |
|
|
|
if (wifi_state != WL_CONNECTED) { |
|
wsAuthenticated = false; |
|
ledToggle(); |
|
return; |
|
} |
|
|
|
int fade_value = (int)(abs((now % 8000) - 4000) * 80 / 4000 + (255-80)); |
|
analogWrite(LED_BUILTIN, fade_value); |
|
|
|
clock_sync(); |
|
pollHaWebSocket(); |
|
|
|
readButton(); |
|
|
|
if (autooff_schedule > 0 && now > autooff_schedule) { |
|
if (setState(STATE_OFF)) { |
|
digitalWrite(ledPin, 0); |
|
} else { |
|
analogWrite(ledPin, 128); |
|
} |
|
autooff_schedule = 0; |
|
} |
|
|
|
|
|
int state = getState(); |
|
if (state == STATE_OFF) { |
|
digitalWrite(ledPin, 0); |
|
} else if (state == STATE_HALF) { |
|
if (now % 1000 < 500) { |
|
analogWrite(ledPin, 50); |
|
} else { |
|
analogWrite(ledPin, 20); |
|
} |
|
} else if (state == STATE_FULL) { |
|
analogWrite(ledPin, 128); |
|
} else { |
|
if (now % 2000 < 1400) { |
|
digitalWrite(ledPin, 0); |
|
} else { |
|
digitalWrite(ledPin, 1); |
|
} |
|
} |
|
|
|
} |