IKEA Vidga Curtain Opener

Overview

This project uses an ESP8266, stepper motor/driver, some belts, sprockets, and pulleys to turn the IKEA Vidga curtain track system into a motorized, home automation controllable curtain opener.
The system uses MQTT in the current incarnation of the code, so you would need an MQTT server already established, but also shouldn’t be hard to adapt to another solution like fauxmoESP. My code and library is included below, but I have not done a clean up pass, and they were built for my particular setup, so expect to need to make changes. If anyone does the work of genericizing please let me know so I can share for others!

BOM

IKEA Vidga triple rail curtain track (rails, wall mounts, sliders, etc) – https://www.ikea.com/us/en/p/vidga-triple-curtain-rail-included-ceiling-fittings-white-70492886/
Wemos D1 Minihttps://amzn.to/3EEKZOZ (May be overkill, but was what I had on hand, and what the 3d printed parts currently require)
NEMA 23 Stepper motorhttps://amzn.to/3ThJYke
Stepper driverhttps://amzn.to/3ECD2Kg
Rotary encoderhttps://amzn.to/3rTc6OT
Power supply 12v 2ahttps://amzn.to/3EFPucf
Idler pulleyshttps://amzn.to/3S0z9C3
60 tooth pulleyhttps://amzn.to/3RZJMEU
GT2 belthttps://amzn.to/3yD62xR
3d printed partshttps://www.printables.com/model/294416-ikea-vidga-curtain-opener

Code

This arduino sketch depends on Encoder, AccelStepper, and my ESPHADevice (below) libraries.

#include "ESPHASDevice.h"
#include <Encoder.h>
#include <AccelStepper.h>

const String DEVICETYPE = "curtainopener"; // lowercase
const String VERSION = "v0.35";
const String EEPROMVERSION = "v0.10";
ESPHASDevice device;

EEPROMInt* currentPosition = device.AddSettingInt("currentPosition", 0);
EEPROMInt* openPosition = device.AddSettingInt("openPosition", 400);
EEPROMInt* stepsToEncoder = device.AddSettingInt("stepsToEncoder", 40);
EEPROMInt* maxSpeed = device.AddSettingInt("maxSpeed", 5000);
EEPROMInt* acceleration = device.AddSettingInt("acceleration", 3000);
EEPROMInt* minPulseWidth = device.AddSettingInt("minPulseWidth", 1);
EEPROMInt* quickUpDownThreshold = device.AddSettingInt("quickUpDownThreshold", 300);
EEPROMInt* programmingThreshold = device.AddSettingInt("programmingThreshold", 1000);
EEPROMInt* stallThreshold = device.AddSettingInt("stallThreshold", 5);

#define enablePin D0
#define dirPin D6
#define stepPin D7
// Update me!
#define openButtonPin D4
#define closeButtonPin D5
#define ledPin D8


Encoder myEnc(D1, D2);
AccelStepper stepper(1, stepPin, dirPin);

bool motorEnabled = false;
int motorCooldown = 0;
int targetPosition = 0;
int saveSettingsIn = 0;
int startingPositionOffset = 0;


int encoderPos = 0;
int lastEncoderPos = encoderPos;

bool inProgrammingMode = false;
int singleButtonHeldStartedAt = 0;
int bothButtonsHeldStartedAt = 0;
bool waitingForDepress = false;

int lastTugPosition = 0;

int unsetPosition = -100000;
int position1 = unsetPosition;
int position2 = unsetPosition;
int stalledTicks = 0;

//int connectingBlinkInterval = 3;
int programmingModeBlinkInterval = 5;

long ledCount = 0;
void updateLED(uint64_t now = 0)
{
	bool ledOn = false;
	if (inProgrammingMode)
	{
		ledOn = waitingForDepress || ledCount >= programmingModeBlinkInterval;
		if (ledCount++ > programmingModeBlinkInterval * 2)
			ledCount = 0;
	} else {
		ledOn = motorEnabled;
		ledCount = 0;
	}
	analogWrite(ledPin, ledOn ? 100 : 0);
}

void sendStatus() {
	int openPerc = 100 * clampf((float)targetPosition/(float)openPosition->Value, 0, 1);
    device.publish(device.deviceName + "/open/status", openPerc > 0 ? "1" : "0");
    device.publish(device.deviceName + "/brightness/status", String(openPerc));
}

void move(int delta, bool constrain = true)
{
	int32 position = currentPosition->Value + delta;
	if (constrain)
		position = clamp(position, 0, openPosition->Value);

	if (currentPosition->Value == position || targetPosition == position)
		return;

	delta = position - currentPosition->Value;
	if (motorEnabled == false)
		stepper.moveTo(stepper.currentPosition() + delta * stepsToEncoder->Value);
	else
		stepper.setTarget(stepper.currentPosition() + delta * stepsToEncoder->Value);
	
	targetPosition = position;
	motorEnabled = true;
	stalledTicks = 0;
}

void setTargetPosition(int position, bool constrain = true)
{
	if (constrain)
		position = clamp(position, 0, openPosition->Value);

	Serial.println("setTargetPosition position: " + String(position) + " currentPosition->Value: " + String(currentPosition->Value) + " targetPosition: " + String(targetPosition));

	if (currentPosition->Value == position || targetPosition == position)
		return;

	targetPosition = position;
	motorEnabled = true;
	stalledTicks = 0;
	Serial.println("setTargetPosition: " + String(targetPosition));

	int delta = position - currentPosition->Value;
	stepper.moveTo(stepper.currentPosition() + delta * stepsToEncoder->Value);
	
	sendStatus();
}

void onMQTTConnected() {
	sendStatus();
}

void onSettingsChanged() {
	Serial.println("onSettingsChanged");
	stepper.setMinPulseWidth(minPulseWidth->Value);
    stepper.setMaxSpeed(maxSpeed->Value);
    stepper.setAcceleration(acceleration->Value);

	// Dont move on startup
	//setTargetPosition(currentPosition->Value);
}

void setup() {
	Serial.begin(115200);

    pinMode(enablePin, OUTPUT);
    digitalWrite(enablePin, HIGH);
    pinMode(dirPin, OUTPUT);
    pinMode(stepPin, OUTPUT);

    pinMode(openButtonPin, INPUT_PULLUP);
    pinMode(closeButtonPin, INPUT_PULLUP);
    
    pinMode(ledPin, OUTPUT);
    updateLED();

    device.init(DEVICETYPE, VERSION, EEPROMVERSION);

    device.subscribe(device.deviceName + "/open", handleOpen);
    device.subscribe(device.deviceName + "/brightness", handleBrightness);
    device.subscribe(device.deviceName + "/close", handleClose);

    device.createTimer(100, true, stallCheck);
    device.createTimer(100, true, updateLED);

    device.onSettingsChanged = onSettingsChanged;
    device.onMQTTConnected = onMQTTConnected;

    delay(100);

    bool openButtonPressed = digitalRead(openButtonPin) == LOW;
    bool closeButtonPressed = digitalRead(closeButtonPin) == LOW;
    if (openButtonPressed && closeButtonPressed)
      device.resetSettings();

	Serial.println("starting up: open: " + String(openButtonPressed ? "true" : "false") + String(" closed: ") + String(closeButtonPressed ? "true" : "false"));

    //device.subscribe(DEVICETYPE + "/" + device.deviceName + "/" + displayTopic, handleDisplay);

	encoderPos = 0;
	startingPositionOffset = currentPosition->Value;
	targetPosition = currentPosition->Value;
	lastTugPosition = currentPosition->Value;
	Serial.println("starting up: startingPositionOffset: " + String(startingPositionOffset));

	//stepper.setEnablePin(enablePin);

	onSettingsChanged();
    stepper.setPinsInverted(true,true);
}

void disableMotor() {
	Serial.println("motor disabled");
	motorEnabled = false;
	stalledTicks = 0;
	targetPosition = currentPosition->Value;
	lastTugPosition = currentPosition->Value;
	motorCooldown = 10;
}


int clamp(int input, int minV, int maxV)
{
	return max(minV, min(maxV, input));
}
float clampf(float input, float minV, float maxV)
{
	return max(minV, min(maxV, input));
}

void handleOpen(String topic, String payload) {
	payload.toLowerCase();
	bool boolVal = payload == "1" || payload == "on";
	float pos = boolVal ? 1 : 0;
	setOpenPercentage(pos);
}

void handleBrightness(String topic, String payload) {
	payload.toLowerCase();
	float floatVal = clampf(payload.toInt(), 0, 100) / 100.0f;
	setOpenPercentage(floatVal);
}
void handleClose(String topic, String payload) {
	setTargetPosition(0);
}

// every 250 ms
void stallCheck(uint64_t now) {
	int moveThreshold = 2;//_max(3, abs(stepper.speed() * 0.002f));
	int32 delta = encoderPos - lastEncoderPos;

	bool opening = targetPosition > currentPosition->Value;
	bool moved = opening ? delta >= moveThreshold : delta <= -moveThreshold;
	//Serial.println("delta > threshold: " + String(delta) + " > " + String(moveThreshold));

	if (inProgrammingMode == false) {
		// motor is on
		if (motorEnabled) {
			if (moved) {
				stalledTicks = 0;
				//Serial.println("Moving!!");
			} else {
				stalledTicks++;
				Serial.println("Stalling!! " + String(stalledTicks));
				if (stalledTicks > stallThreshold->Value) {
					Serial.println("Stall detected!!");
					disableMotor();
					motorCooldown = 50;
					stepper.stop();
				}
			}
		} else { // motors is off
			bool moved = abs(delta) >= moveThreshold;
			if (moved && motorCooldown == 0) {
				//Serial.println("moved encoderPos: " + String(encoderPos) + " currentPosition->Value" + String(currentPosition->Value));
				int tugAmount = currentPosition->Value - lastTugPosition;
				int tugThreshold = 3;
				if (abs(tugAmount) >= tugThreshold) {
					if (tugAmount > 0) {
						Serial.println("opening from tug");
						setOpenPercentage(1);
					} else {
						Serial.println("closing from tug");
						setOpenPercentage(0);
					}
					lastTugPosition = currentPosition->Value;
				}
			}
		}
	}

	if (moved)
		lastEncoderPos = encoderPos;

	if (motorCooldown > 0) {
		motorCooldown--;
		if (motorCooldown == 0)
			lastTugPosition = currentPosition->Value;
	}
}

void loop()
{
	encoderPos = myEnc.read();
	currentPosition->Value = encoderPos + startingPositionOffset;

    stepper.run();
	
	device.CanDoBlockingWork = motorEnabled == false;
	device.loop();

	handleButtons();

	if (motorEnabled && stepper.distanceToGo() == 0) {
		Serial.println("Reached end! currentPosition->Value: " + String(currentPosition->Value) + " stepper.currentPosition()/stepsToEncoder " + String(stepper.currentPosition() / stepsToEncoder->Value));
		disableMotor();
	}

	if (inProgrammingMode)
		saveSettingsIn = 0;
	else if (motorEnabled) {
		saveSettingsIn = 100000;
	}
		
	digitalWrite(enablePin, motorEnabled ? LOW :HIGH);

	if (saveSettingsIn > 0)
	{
		saveSettingsIn--;
		if (saveSettingsIn == 0) {
			sendStatus();
			device.SaveSettings();
		}
	}
}

bool wasOpenButtonPressed = false;
bool wasCloseButtonPressed = false;

void handleButtons() {
	unsigned long now = millis();

	bool openButtonPressed = digitalRead(openButtonPin) == LOW;
	bool closeButtonPressed = digitalRead(closeButtonPin) == LOW;

	int pressedCount = openButtonPressed + closeButtonPressed;
	if (pressedCount == 1) {

		bothButtonsHeldStartedAt = 0;
		if (singleButtonHeldStartedAt == 0) {
			singleButtonHeldStartedAt = now;
			//Serial.println("down " + String(openButtonPressed ? "open" : "close"));
		}
		
		unsigned long delta = now - singleButtonHeldStartedAt;
		unsigned long threshold = quickUpDownThreshold->Value;
		//Serial.println("pressed single button for: " + String(delta) + " threshold: " + String(threshold));
		bool buttonHeld = delta >= threshold;

		// Held, manually move
		if (buttonHeld && waitingForDepress == false) {
			int adjustedDelta = _max(delta - threshold, 0);
			//Serial.println("delta: " + String(delta) + " adjustedDelta: " + String(adjustedDelta));
			adjustedDelta /= 50;
			int moveAmount = clamp(adjustedDelta, 5, 50);
			//Serial.println("held or programming. adjustedDelta: " + String(adjustedDelta) + " moveAmount: " + String(moveAmount));

			if (openButtonPressed)
				move(moveAmount, inProgrammingMode == false);
			if (closeButtonPressed)
				move(-moveAmount, inProgrammingMode == false);
		}
	}
	else if (pressedCount == 2)
	{
		stepper.stop();
		singleButtonHeldStartedAt = 0;
		if (bothButtonsHeldStartedAt == 0) {
			bothButtonsHeldStartedAt = now;
		}

		unsigned long delta = now - bothButtonsHeldStartedAt;
		if (delta > programmingThreshold->Value && waitingForDepress == false) {
			waitingForDepress = true;
			if (inProgrammingMode) {
				Serial.println("programming mode tap");

				// save first pos
				if (position1 == unsetPosition)
				{
					Serial.print("Setting position 1 to: ");
					Serial.println(currentPosition->Value);
					position1 = encoderPos;// settings.currentPosition->Value;
				}
				else // second pos
				{
					Serial.print("Setting position 2 to: ");
					Serial.println(currentPosition->Value);
					position2 = encoderPos;// settings.currentPosition->Value;

					// settings.currentPosition->Value = encoderPos + startingPositionOffset;
					// now closed, pos1 = top/open
					if (position1 > position2)
					{
						openPosition->Value = position1 - position2;
						currentPosition->Value = 0;
					}
					// now open, pos2 = top/open
					else
					{
						openPosition->Value = position2 - position1;
						currentPosition->Value = openPosition->Value;
					}

					startingPositionOffset = currentPosition->Value - encoderPos;
					targetPosition = currentPosition->Value;
					lastTugPosition = currentPosition->Value;

					Serial.print("Saving new settings! openPosition: ");
					Serial.println(openPosition->Value);
					inProgrammingMode = false;
					device.SaveSettings();
				}
			} else {
				inProgrammingMode = true;
				position1 = unsetPosition;
				position2 = unsetPosition;
				Serial.println("starting programming mode");
			}
			bothButtonsHeldStartedAt = 0;
		}
	}
	else {

		// On release, check for short press
		if (inProgrammingMode == false && singleButtonHeldStartedAt > 0) {
			unsigned long delta = now - singleButtonHeldStartedAt;
			unsigned long threshold = quickUpDownThreshold->Value;
			//Serial.println("pressed single button for: " + String(delta) + " threshold: " + String(threshold));
			bool buttonHeld = delta >= threshold;

			// Held, manually move
			if (buttonHeld == false && waitingForDepress == false) {
				if (wasOpenButtonPressed) {
					Serial.println("opening from button");
					setOpenPercentage(1);
				}
				if (wasCloseButtonPressed) {
					Serial.println("closing from button");
					setOpenPercentage(0);
				}
			}
		}

		singleButtonHeldStartedAt = 0;
		bothButtonsHeldStartedAt = 0;
		waitingForDepress = false;
	}

	wasOpenButtonPressed = openButtonPressed;
	wasCloseButtonPressed = closeButtonPressed;
}

void setOpenPercentage(float percentage)
{
	int newTarget = percentage * (float)openPosition->Value;
	newTarget = clamp(newTarget, 0, openPosition->Value);

	if (currentPosition->Value < 0 && newTarget == 0)
		return;
	if (currentPosition->Value > openPosition->Value && newTarget == openPosition->Value)
		return;

	setTargetPosition(newTarget);
	//Serial.print("newTarget: ");
	//Serial.println(newTarget);
}

ESPHADevice.h

#ifndef ESPHASDevice_h
#define ESPHASDevice_h

//#define Sprintln(a) (Serial.println(a))
#define Sprintln(a)

#include <ESP8266HTTPClient.h>
#include <ESP8266WiFi.h>
#include <WiFiClient.h>
#include <ESP8266HTTPUpdateServer.h>
#include <ESP8266httpUpdate.h>
#include <WiFiClientSecure.h>
#include <ESP8266WebServer.h>
//#include <WiFiManager.h>
#include <DNSServer.h>
#include <PubSubClient.h>
#include <ArduinoJson.h>
#include <stdint.h>
#include <EEPROM.h>

struct TimerDefinition {
    unsigned long ms;
    unsigned long start;
	bool repeats;
	void (*callback)(uint64_t  now);
	TimerDefinition* next = nullptr;
	TimerDefinition () {}
    TimerDefinition (unsigned long milliSeconds, bool repeat, void (*inCallback)(uint64_t now64)) {
        ms = milliSeconds;
        callback = inCallback;
    	start = millis();
		repeats = repeat;
    }
	bool loop(unsigned long now);
};

struct MQTTSubscription {
    String topic;
	void (*callback)(String topic, String payload);
	int qos;
    MQTTSubscription (String inTopic, void (*inCallback)(String topic, String payload), int q) {
        topic = inTopic;
        callback = inCallback;
		qos = q;
    }
	virtual void WriteTopic(JsonArray& array) { array.add(topic); }
	MQTTSubscription () {}
};


class EEPROMSetting {
public:
	EEPROMSetting* Next = nullptr;
	String Name;
	int eepromSize;
	EEPROMSetting(String name) {
		Name = name;
	}

	virtual void EEPROMPut(int index) {}
	virtual void EEPROMGet(int index) {}
	virtual void WriteJson(DynamicJsonDocument& doc) {}
	virtual void UpdateFromJson(DynamicJsonDocument& doc) {}
	virtual void Reset() {}
	virtual String GetDisplayValue() {}
};
class EEPROMInt : public EEPROMSetting {
public:
	int Value;
	int DefaultValue;
	EEPROMInt(String name, int defaultValue) : EEPROMSetting(name) {
		eepromSize = sizeof(defaultValue);
		Value = defaultValue;
		DefaultValue = defaultValue;
	}
	void EEPROMPut(int index) override { EEPROM.put(index, Value); }
	void EEPROMGet(int index) override { EEPROM.get(index, Value); }
	void WriteJson(DynamicJsonDocument& doc) override { doc[Name.c_str()].set(Value); }
	void UpdateFromJson(DynamicJsonDocument& doc) override { if(doc.containsKey(Name)) Value = doc[Name.c_str()]; }
	void Reset() override { Value = DefaultValue; }
	String GetDisplayValue() { return String(Value); }
};
class EEPROMFloat : public EEPROMSetting {
public:
	float Value;
	float DefaultValue;
	EEPROMFloat(String name, float defaultValue) : EEPROMSetting(name) {
		eepromSize = sizeof(defaultValue);
		Value = defaultValue;
		DefaultValue = defaultValue;
	}
	void EEPROMPut(int index) override { EEPROM.put(index, Value); }
	void EEPROMGet(int index) override { EEPROM.get(index, Value); }
	void WriteJson(DynamicJsonDocument& doc) override { doc[Name.c_str()].set(Value); }
	void UpdateFromJson(DynamicJsonDocument& doc) override { if(doc.containsKey(Name)) Value = doc[Name.c_str()]; }
	void Reset() override { Value = DefaultValue; }
	String GetDisplayValue() { return String(Value); }
};
class EEPROMString : public EEPROMSetting {
public:
	char Value[60];
	char DefaultValue[60];
	EEPROMString(String name, char defaultValue[60]) : EEPROMSetting(name) {
		eepromSize = 60;// sizeof(defaultValue);
		strcpy(Value, defaultValue);
		strcpy(DefaultValue, defaultValue);
	}
	void EEPROMPut(int index) override { EEPROM.put(index, Value); }
	void EEPROMGet(int index) override { EEPROM.get(index, Value); }
	void WriteJson(DynamicJsonDocument& doc) override { doc[Name.c_str()].set(Value); }
	void UpdateFromJson(DynamicJsonDocument& doc) override {
		if(doc.containsKey(Name)) {
			char val[60] = "";
			String str = doc[Name.c_str()];
			str.toCharArray(val, 60);
			strcpy(Value, val);
		}
	}
	void Reset() override { strcpy(Value, DefaultValue); }
	String GetDisplayValue() { return Value; }
};
class EEPROMBool : public EEPROMSetting {
public:
	bool Value;
	bool DefaultValue;
	EEPROMBool(String name, bool defaultValue) : EEPROMSetting(name) {
		eepromSize = sizeof(defaultValue);
		Value = defaultValue;
		DefaultValue = defaultValue;
	}
	void EEPROMPut(int index) override { EEPROM.put(index, Value); }
	void EEPROMGet(int index) override { EEPROM.get(index, Value); }
	void WriteJson(DynamicJsonDocument& doc) override { doc[Name.c_str()].set(Value); }
	void UpdateFromJson(DynamicJsonDocument& doc) override { if(doc.containsKey(Name)) Value = doc[Name.c_str()]; }
	void Reset() override { Value = DefaultValue; }
	String GetDisplayValue() { return String(Value); }
};

#define maxSubscriptions 30

class MQTTEntity {
public:
	MQTTEntity* Next = nullptr;
	String Name;
	String StateTopic;
	String Type;
	String UniqueId;
	String DeviceName;
	String DeviceClass;

	MQTTEntity(String name) {
		Name = name;
		DeviceName = WiFi.macAddress();
		DeviceName.replace(":", "");
		DeviceClass = "power";
	}
	void Publish(String topic, String payload);
	void Subscribe(String topic, void (*callback)(String topic2, String payload));

	void SendStatus() {
		String payload = GetStatusPayload();
		Publish(StateTopic, payload);
	}

	void Register() {
		DynamicJsonDocument doc(512);
		ApplyConfigJson(doc);
		String payload;
		serializeJson(doc, payload);
		String configTopic = "homeassistant/" + Type + "/" + UniqueId + "/config";
		Publish(configTopic, payload);
	}

	virtual void SetType(String type) {
		Type = type;
		UniqueId = Type + "_" + DeviceName + "_" + Name;
		StateTopic = "has_device/" + UniqueId + "/stat/" + Name;
	}

	virtual void onMQTTConnected() {
		Register();
		SendStatus();
	}

	virtual String GetStatusPayload() {
		return "";
	};
	virtual void ApplyConfigJson(DynamicJsonDocument& doc) {
		doc["name"] = Name;
		doc["unique_id"] = UniqueId;
		doc["state_topic"] = StateTopic;
		doc["device_class"] = DeviceClass;
		JsonObject device  = doc.createNestedObject("device");
		device["configuration_url"] = "http://" + WiFi.localIP().toString();
		device["identifiers"] = WiFi.macAddress();
	}
};

class MQTTBinarySensor : public MQTTEntity {
public:
	bool State;

	MQTTBinarySensor(String name) : MQTTEntity(name) {
		SetType("binary_sensor");
		State = false;
	}

	String GetStatusPayload() {
		return State ? "ON" :"OFF";
	}

	virtual void SetOn(bool on) {
		if (State == on) return;
		State = on;
		SendStatus();
	}
};

class MQTTNumberSensor : public MQTTEntity {
public:
	float Value;
	String UnitOfMeasurement;

	MQTTNumberSensor(String name) : MQTTEntity(name) {
		SetType("sensor");
		Value = 0;
		UnitOfMeasurement = "%²";
	}

	String GetStatusPayload() {
		return String(Value);
	}

	void ApplyConfigJson(DynamicJsonDocument& doc) {
		MQTTEntity::ApplyConfigJson(doc);
		doc["unit_of_measurement"] = UnitOfMeasurement;
	}

	virtual void SetValue(float value) {
		if (Value == value) return;
		Value = value;
		SendStatus();
	}
};


class MQTTSwitch : public MQTTBinarySensor {
public:
	String CommandTopic;
	void (*HandleOn)(bool on);
	void (*HandleCommand)(String topic, String payload); // TODO do this ourselves
	bool subscribed = false;
	
	MQTTSwitch(String name) : MQTTBinarySensor(name) {
		SetType("switch");
		DeviceClass = "switch";
	}

	void SetType(String type) {
		MQTTBinarySensor::SetType(type);
		CommandTopic = "has_device/" + UniqueId + "/cmnd/" + Name;
	}

	void onMQTTConnected() {
		MQTTBinarySensor::onMQTTConnected();
		if (subscribed) return;
		Subscribe(CommandTopic.c_str(), HandleCommand);
		subscribed = true;
	}

	void ApplyConfigJson(DynamicJsonDocument& doc) {
		MQTTBinarySensor::ApplyConfigJson(doc);
		doc["command_topic"] = CommandTopic;
	}

	void SetOn(bool on) {
		if (State == on) return;
		MQTTBinarySensor::SetOn(on);
		HandleOn(on);
	}
};


class ESPHASDevice
{
  public:
    ESPHASDevice();
	
    void init(String type, String version, String eepromVersion = "");
	void loop();

    void subscribe(String topic, void (*callback)(String topic2, String payload), int qos = 0);
    void publish(String topic, String payload, bool retain = false);

	String callURL(String server, int port, String url);
	String callServer(String path);

	String getURLStringParameter(String name, String defaultVal = "");
	int getURLIntParameter(String name, int defaultVal = -1);

	void sendResponse(String text);
	void sendHtml(String html);

	bool (*onHandleHttpCall)();
	bool (*onHandleMQTT)(String topic, String payload);
	void (*onMQTTConnecting)();
	void (*onMQTTConnected)();
	void (*onMQTTDisconnected)(bool wasConnected);
	void (*onWifiConnected)();
	void (*onWifiConnecting)();
	void (*onWifiDisconnected)();
	void (*onSettingsChanged)();
	String (*onPageNotFound)();

	ESP8266WebServer* httpServer;
	ESP8266HTTPUpdateServer* httpUpdater;
    PubSubClient* pubSubClient;
	WiFiClient espClient;
	bool inited;

	

    void createTimer(unsigned long ms, bool repeats, void (*callback)(uint64_t now));
	TimerDefinition *timers = nullptr;
	void timerLoop();

	////////////////////////////////////////////////////////////
	// Configuration
	////////////////////////////////////////////////////////////
    String deviceName;
	String deviceType;
	String firmwareVersion;
	uint8_t eepromVersion;
    String pubSubClientId;

	////////////////////////////////////////////////////////////
	// Instance
	////////////////////////////////////////////////////////////

    EEPROMString* NameSetting;
/*	template<typename T>
	T* AddSetting(T* setting);
*/
	EEPROMInt* AddSettingInt(String name, int defaultVal);
	EEPROMFloat* AddSettingFloat(String name, float defaultVal);
	EEPROMString* AddSettingString(String name, char defaultVal[60]);
	EEPROMBool* AddSettingBool(String name, bool defaultVal);
	

    MQTTEntity* FirstEntity = nullptr;
	void AddEntity(MQTTEntity* entity);
	MQTTSwitch* GetSwitch(String entityName);
	MQTTBinarySensor* AddBinarySensor(String name);
	MQTTNumberSensor* AddNumberSensor(String name);
	MQTTSwitch* AddSwitch(String name, void (*callback)(bool on));
	void RefreshMQTTEntities();

	void SaveSettings();
	void sendSettingsJson();
	void sendTopics();
	void setSettingsJson(String json);
	void resetSettings();
	void loadSettings();

    void handleMQTT(String topic, String payload);

	bool CanDoBlockingWork = true;
	
    const char* ssid = "********";
    const char* password = "********";
    const char* mqtt_server_address = "****.local";
    MQTTSubscription subscriptions[maxSubscriptions];
    int subscriptionCount = 0;

    void setup_wifi();
	
	void connectMQTT();

    void registerMQTT();
	void handleHttpCall();

	static uint64_t millis64() {
		static uint32_t low32, high32;
		uint32_t new_low32 = millis();
		if (new_low32 < low32) high32++;
		low32 = new_low32;
		return (uint64_t) high32 << 32 | low32;
	}
};


#endif

ESPHADevice.cpp

#include <ESPHASDevice.h>

////////////////////////////////////////////////////////////
// HTML
////////////////////////////////////////////////////////////

/*
const char index_html[] PROGMEM = R"rawliteral(
<!DOCTYPE HTML><html>
<head>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    html {
     font-family: Arial;
     display: inline-block;
     margin: 0px auto;
     text-align: center;
    }
    h2 { font-size: 3.0rem; }
    p { font-size: 3.0rem; }
    .units { font-size: 1.2rem; }
    .dht-labels{
      font-size: 1.5rem;
      vertical-align:middle;
      padding-bottom: 15px;
    }
  </style>
</head>
<body>
  <h2>ESP8266 DHT Server</h2>
  <p>
    <span class="dht-labels">Temperature</span> 
    <span id="temperature">%TEMPERATURE%</span>
    <sup class="units">°C</sup>
  </p>
  <p>
    <span class="dht-labels">Humidity</span>
    <span id="humidity">%HUMIDITY%</span>
    <sup class="units">%</sup>
  </p>
</body>
<script>
setInterval(function ( ) {
  var xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      document.getElementById("temperature").innerHTML = this.responseText;
    }
  };
  xhttp.open("GET", "/temperature", true);
  xhttp.send();
}, 10000 ) ;

setInterval(function ( ) {
  var xhttp = new XMLHttpRequest();
  xhttp.onreadystatechange = function() {
    if (this.readyState == 4 && this.status == 200) {
      document.getElementById("humidity").innerHTML = this.responseText;
    }
  };
  xhttp.open("GET", "/humidity", true);
  xhttp.send();
}, 10000 ) ;
</script>
</html>)rawliteral";
*/

const char* fwHost = "****.local";
const char* fwHostFingerprint = "********";
ESPHASDevice* instance;


EEPROMInt* ESPHASDevice::AddSettingInt(String name, int defaultVal) {
	EEPROMInt* val = new EEPROMInt(name, defaultVal);
	EEPROMSetting* lastSetting = (EEPROMSetting*)NameSetting;
	while (lastSetting->Next != nullptr) lastSetting = lastSetting->Next;
	lastSetting->Next = (EEPROMSetting*)val;
	return val;

}
EEPROMFloat* ESPHASDevice::AddSettingFloat(String name, float defaultVal) {
	EEPROMFloat* val = new EEPROMFloat(name, defaultVal);
	EEPROMSetting* lastSetting = (EEPROMSetting*)NameSetting;
	while (lastSetting->Next != nullptr) lastSetting = lastSetting->Next;
	lastSetting->Next = (EEPROMSetting*)val;
	return val;

}
EEPROMString* ESPHASDevice::AddSettingString(String name, char defaultVal[60]) {
	EEPROMString* val = new EEPROMString(name, defaultVal);
	EEPROMSetting* lastSetting = (EEPROMSetting*)NameSetting;
	while (lastSetting->Next != nullptr) lastSetting = lastSetting->Next;
	lastSetting->Next = (EEPROMSetting*)val;
	return val;

}
EEPROMBool* ESPHASDevice::AddSettingBool(String name, bool defaultVal) {
	EEPROMBool* val = new EEPROMBool(name, defaultVal);
	EEPROMSetting* lastSetting = (EEPROMSetting*)NameSetting;
	while (lastSetting->Next != nullptr) lastSetting = lastSetting->Next;
	lastSetting->Next = (EEPROMSetting*)val;
	return val;
}

bool TimerDefinition::loop(unsigned long now) {
	unsigned long elapsed = now - start;
	if (elapsed >= ms) {
		callback(ESPHASDevice::millis64());
		start += ms;
		//elapsed = now - start;
		return true;
	}
	return false;
}

MQTTBinarySensor* ESPHASDevice::AddBinarySensor(String name) {
	MQTTBinarySensor* entity = new MQTTBinarySensor(name);
	AddEntity(entity);
	return entity;
}

MQTTNumberSensor* ESPHASDevice::AddNumberSensor(String name) {
	MQTTNumberSensor* entity = new MQTTNumberSensor(name);
	AddEntity(entity);
	return entity;
}

MQTTSwitch* ESPHASDevice::AddSwitch(String name, void (*callback)(bool on)) {
	MQTTSwitch* entity = new MQTTSwitch(name);
	AddEntity(entity);
	entity->HandleCommand = [](String topic, String payload) {
		MQTTSwitch* entity = instance->GetSwitch(topic);
		if (entity) entity->SetOn(payload == "ON");
	};
	entity->HandleOn = callback;
	return entity;
}

void ESPHASDevice::AddEntity(MQTTEntity* entity) {
	if (FirstEntity) {
		MQTTEntity* lastEntity = FirstEntity;
		while (lastEntity->Next != nullptr) lastEntity = lastEntity->Next;
		lastEntity->Next = entity;
	} else {
		FirstEntity = entity;
	}
}

MQTTSwitch* ESPHASDevice::GetSwitch(String commandTopic) {
	MQTTEntity* nextEntity = FirstEntity;
	while (nextEntity != nullptr) {
		if (nextEntity->Type == "switch") {
			MQTTSwitch* switchEntity = (MQTTSwitch*)nextEntity;
			if (switchEntity->CommandTopic == commandTopic)
				return switchEntity;
		}
		nextEntity = nextEntity->Next;
	}
	return nullptr;
}


void MQTTEntity::Publish(String topic, String payload) {
	instance->publish(topic, payload);
}
void MQTTEntity::Subscribe(String topic, void (*callback)(String topic2, String payload)) {
	instance->subscribe(topic, callback);
}

/*
template<typename T>
T* ESPHASDevice::AddSetting(T* setting) {
	EEPROMSetting* s = (EEPROMSetting*)NameSetting;
	while (s->Next != nullptr) s->Next;
	s->Next = (EEPROMSetting*)setting;
	return setting;
}
*/
int EPROM_MEMORY_SIZE = 512;
void ESPHASDevice::SaveSettings() {
	Serial.println("Saving settings");
  	EEPROM.begin(EPROM_MEMORY_SIZE);
	EEPROM.put<uint8_t>(0, eepromVersion);

	EEPROMSetting* s = (EEPROMSetting*)NameSetting;
	int index = 1;
	while (s != nullptr) {
		Sprintln("Writing setting: " + String(index));
		s->EEPROMPut(index);
		index += s->eepromSize;
		s = s->Next;
		
	}
	EEPROM.commit();
	EEPROM.end();
}

void ESPHASDevice::loadSettings() {
	uint8_t settingsVer = 0;
  	EEPROM.begin(EPROM_MEMORY_SIZE);
	EEPROM.get(0, settingsVer );
	EEPROM.end();

	/*Serial.print("versionAsInt: ");
	Serial.println(eepromVersion);
	Serial.print("settingsVer: ");
	Serial.println(settingsVer);*/

	if (settingsVer == eepromVersion) {
		Serial.println("Same version! loading settings");

  		EEPROM.begin(EPROM_MEMORY_SIZE);
		Sprintln("Looping settings");
		EEPROMSetting* s = (EEPROMSetting*)NameSetting;
		int index = 1;
		while (s != nullptr) {
			Sprintln("Read at: " + String(index));
			s->EEPROMGet(index);
			index += s->eepromSize;
			s = s->Next;
		}
		EEPROM.end();
		Sprintln("finished loop");
		deviceName = String(NameSetting->Value);
		Serial.println(deviceName);
	} else {
		Serial.println("different version! saving settings");
		SaveSettings();
	}
}

String ESPHASDevice::callURL(String server, int port, String url)
{
	HTTPClient httpClient;
	httpClient.begin(server, port, url);
	int httpCode = httpClient.GET();
	if(httpCode == 200)
	{
		String payload = httpClient.getString();
		Serial.println("callURL("+server+":"+port+"/"+url+") received: " + payload);
		return payload;
	}
	else if (httpCode == 0)
		Serial.print("callURL("+server+":"+port+"/"+url+") failed, no connection or no HTTP server.");
	return "";
}


////////////////////////////////////////////////////////////
// HTTP Handling
////////////////////////////////////////////////////////////

void handleNotFound()
{
	ESP8266WebServer* httpServer = instance->httpServer;
	String msg = "File Not Found<br/><br/>";
	msg += "<b>URI:</b> " + httpServer->uri() + "</br>";
	msg += "<b>Method:</b> ";
	msg += (httpServer->method() == HTTP_GET)?"GET":"POST";
	msg += "<br/><b>Arguments:</b> ";
	msg += httpServer->args();
	msg += "<br/>";
	for (uint8_t i=0; i<httpServer->args(); i++)
		msg += " " + httpServer->argName(i) + ": " + httpServer->arg(i) + "<br/>";
	msg += "<br/><b>Type:</b> ";
	msg += instance->deviceType;
	msg += "<br/><b>Name:</b> ";
	msg += instance->deviceName;
	msg += "<br/><b>Version:</b> ";
	msg += instance->firmwareVersion;

	msg += "<br/><br/><b><a href='/update'>Update</a></b><br/>";

	msg += "<br/><b>Subscriptions:</b><br>";
	for (int i=0; i<instance->subscriptionCount; i++) {
		String topic = instance->subscriptions[i].topic;
		msg += topic + "<br/>";
	}

	msg += "<br/><b>EEPROM:</b><br>";
	
	EEPROMSetting* s = (EEPROMSetting*)instance->NameSetting;
	while (s != nullptr) {
		msg += s->Name + " = " + s->GetDisplayValue() + "<br/>";
		s = s->Next;
	}

	msg += "<br/><b>Entities:</b><br>";
	MQTTEntity* nextEntity = instance->FirstEntity;
	while (nextEntity != nullptr) {
		msg += nextEntity->Name + " - " + nextEntity->GetStatusPayload() + "<br/>";
		nextEntity = nextEntity->Next;
	}


	msg += "<hr/><b>Custom:</b><br>";
	if (instance->onPageNotFound)
		msg += instance->onPageNotFound();
	
	httpServer->send(404, "text/html", msg);
}

////////////////////////////////////////////////////////////
// Setup
////////////////////////////////////////////////////////////

ESPHASDevice::ESPHASDevice() {
	Serial.begin(115200);
	httpServer = new ESP8266WebServer(80);
	httpUpdater = new ESP8266HTTPUpdateServer();
	httpUpdater->setup(httpServer);
	
    char name[60] = "";
    WiFi.macAddress().toCharArray(name, 60);

    Sprintln("copnstructor()");
	NameSetting = new EEPROMString("name", name);
    Sprintln((int)NameSetting);
	inited = true;
}
void ESPHASDevice::setup_wifi() {

	delay(10);
	// We start by connecting to a WiFi network
	Serial.println();
	Serial.print("Connecting to ");
	Serial.println(ssid);

	WiFi.hostname(pubSubClientId);
	WiFi.mode(WIFI_STA);
	WiFi.begin(ssid, password);

	while (WiFi.status() != WL_CONNECTED) {
		delay(500);
		Serial.print(".");
	}

	randomSeed(micros());

	Serial.println("");
	Serial.println("WiFi connected");
	Serial.println("IP address: ");
	Serial.println(WiFi.localIP());
}

void ESPHASDevice::registerMQTT() {
	DynamicJsonDocument doc(1024);
	doc["type"] = deviceType;
	doc["name"] = deviceName;
	doc["mac"] = WiFi.macAddress();
	doc["ip"] = WiFi.localIP().toString();
	doc["version"] = firmwareVersion;
	
	String payload;
	serializeJson(doc, payload);
	
	publish(deviceType + "/register", payload);

	//Serial.print("registered: ");
	Sprintln(subscriptionCount);
	for (int i=0; i<subscriptionCount; i++) {
		
		String topic = subscriptions[i].topic;
		//Serial.print("Subscribing to: ");
		Sprintln(topic);
		pubSubClient->subscribe(topic.c_str(), subscriptions[i].qos);
	}

	if (FirstEntity)
		subscribe("homeassistant/status", [](String topic, String payload) {
			if (payload == "online")
				instance->RefreshMQTTEntities();
		});

	RefreshMQTTEntities();
}

void ESPHASDevice::RefreshMQTTEntities() {
	MQTTEntity* nextEntity = FirstEntity;
	while (nextEntity != nullptr) {
		nextEntity->onMQTTConnected();
		nextEntity = nextEntity->Next;
	}

}

void ESPHASDevice::createTimer(unsigned long ms, bool repeats, void (*callback)(uint64_t now)) {
	Serial.println("Adding timer. ms: " + String(ms));
	TimerDefinition* newTimer = new TimerDefinition(ms, repeats, callback);

	if (timers == nullptr)
		timers = newTimer;
	else {
		TimerDefinition* lastTimer = timers;
		while (lastTimer != nullptr && lastTimer->next != nullptr) lastTimer = lastTimer->next;
		lastTimer->next = newTimer;
	}
}

void ESPHASDevice::timerLoop() {
	unsigned long now = millis();
	TimerDefinition* def = timers;
	TimerDefinition* last = timers;
	while (def != nullptr) {
		if (def->loop(now) && def->repeats == false) {
			last->next = def->next;
			delete(def);
			def = last->next;
		} else {
			last = def;
			def = def->next;
		}
	}
}

void ESPHASDevice::subscribe(String topic, void (*callback)(String topic2, String payload), int qos) {
	//Serial.println("Adding subscription to: " + topic);
	subscriptions[subscriptionCount++] = MQTTSubscription(topic, callback, qos);
	if (pubSubClient->connected())
	{
		//Serial.print("Subscribing to: ");
		Sprintln(topic);
		pubSubClient->subscribe(topic.c_str(), qos);
	}
}

void ESPHASDevice::handleMQTT(String topic, String payload) {
	Serial.print("Handling sub for: ");
	Serial.println(topic);
	for (int i=0; i<subscriptionCount; i++) {
		String subTopic = subscriptions[i].topic;
		// TODO: check for wildcards
		if (topic == subTopic) {
			if (subscriptions[i].callback)
				subscriptions[i].callback(topic, payload);
		}
	}
}

void ESPHASDevice::publish(String topic, String payload, bool retained) {
	pubSubClient->publish_P(topic.c_str(), (uint8_t*)payload.c_str(), payload.length(), retained);
}

void mqttCallback(char* topic, uint8_t* payloadBytes, unsigned int length) {
	payloadBytes[length] = '\0'; // Make payload a string by NULL terminating it.
    String payloadStr = String((char *)payloadBytes);

	String topicStr = String(topic);
	instance->handleMQTT(topicStr, payloadStr);
}

void handleUpdateSettings(String topic, String payload) {
    instance->setSettingsJson(payload);
}
// hack
void ESPHASDevice::setSettingsJson(String json) {
	Sprintln("updateSettingsJson()");
	DynamicJsonDocument doc(512);
	deserializeJson(doc, json);
	EEPROMSetting* s = (EEPROMSetting*)NameSetting;
	while (s != nullptr) {
		s->UpdateFromJson(doc);
		s = s->Next;
	}
	SaveSettings();
	// hack
	sendSettingsJson();

	if (onSettingsChanged)
		onSettingsChanged();
}

void handleGetSettings(String topic, String payload) {
    instance->sendSettingsJson();
}
void ESPHASDevice::sendSettingsJson() {
	Sprintln("sendSettingsJson()");
	DynamicJsonDocument doc(512);
	EEPROMSetting* s = (EEPROMSetting*)NameSetting;
	while (s != nullptr) {
		s->WriteJson(doc);
		s = s->Next;
	}
	String payload;
	serializeJson(doc, payload);
	Serial.println(payload);
	publish(WiFi.macAddress() + "/settings", payload);
}
void handleResetSettings(String topic, String payload) {
    instance->resetSettings();
}
void ESPHASDevice::resetSettings() {
	Sprintln("resetSettingsJson()");
	// Skip resetting the name
	EEPROMSetting* s = (EEPROMSetting*)NameSetting->Next;
	while (s != nullptr) {
		s->Reset();
		s = s->Next;
	}
	SaveSettings();
	// hack
	sendSettingsJson();
}
void handleLoadSettings(String topic, String payload) {
    instance->loadSettings();
}
void handleTopics(String topic, String payload) {
	instance->sendTopics();
}

void ESPHASDevice::sendTopics() {
	Sprintln("sendTopics()");
	const size_t CAPACITY = JSON_ARRAY_SIZE(4*maxSubscriptions);
	// allocate the memory for the document
	StaticJsonDocument<CAPACITY> doc;
	JsonArray array = doc.to<JsonArray>();
	for (int i=0; i<subscriptionCount; i++) {
		subscriptions[i].WriteTopic(array);
	}
	String payload;
	serializeJson(doc, payload);
	Serial.println(payload);
	publish(WiFi.macAddress() + "/topics", payload);
}

void reboot(String topic, String payload) {
	//ESP.restart();
	// let the watchdog reboot us
	while (true) {}
}

void update(String topic, String payload) {
	
	String url = "http://" + String(fwHost) + "/firmware/" + instance->deviceType + ".ino.bin";// payload;
	if (payload.length() > 5)
		url = "http://" + String(fwHost) + payload;
	Serial.print("connecting to ");
	Serial.println(url);

	// will reboot here if succesful
	t_httpUpdate_return ret = ESPhttpUpdate.update(url, "", fwHostFingerprint);

	switch(ret) {
	case HTTP_UPDATE_FAILED:
		Serial.printf("HTTP_UPDATE_FAILD Error (%d): %s", ESPhttpUpdate.getLastError(), ESPhttpUpdate.getLastErrorString().c_str());
		Serial.println("");
		break;

	case HTTP_UPDATE_NO_UPDATES:
		Serial.println("HTTP_UPDATE_NO_UPDATES");
		break;
	}
	Serial.println("Updating failed");
	Serial.println(ret);
}

void ESPHASDevice::init(String type, String version, String eepromV) {
	instance = this;
	deviceType = type;
	pubSubClientId = deviceType + "-" + WiFi.macAddress();
	
	firmwareVersion = version;
	eepromVersion = 0;
	for (int i=0; i<eepromV.length(); i++) eepromVersion += (int)eepromV[i];
	EEPROMSetting* s = (EEPROMSetting*)NameSetting;
	while (s != nullptr) {
		for (int i=0; i<s->Name.length(); i++) eepromVersion += (int)s->Name[i];
		s = s->Next;
	}

	Serial.begin(115200);

	//Serial.print("firmware: ");
	Sprintln(firmwareVersion);

	loadSettings();

	setup_wifi();
	pubSubClient = new PubSubClient(espClient);
	pubSubClient->setServer(mqtt_server_address, 1883);
	pubSubClient->setCallback(mqttCallback);
	//httpServer->on("/", handleRoot);
	httpServer->onNotFound(handleNotFound);
	httpServer->begin();
	
    subscribe(WiFi.macAddress() + "/setSettings", handleUpdateSettings);
    subscribe(WiFi.macAddress() + "/getSettings", handleGetSettings);
    subscribe(WiFi.macAddress() + "/resetSettings", handleResetSettings);
    subscribe(WiFi.macAddress() + "/getTopics", handleTopics);
    subscribe(WiFi.macAddress() + "/reboot", reboot);

    subscribe(WiFi.macAddress() + "/update", update);
    subscribe(deviceType + "/update", update);
	
    Sprintln("init done()");
}


////////////////////////////////////////////////////////////
// reconnect
////////////////////////////////////////////////////////////

bool wasConnected = true;
unsigned long lastMqttAttempt = millis();
void ESPHASDevice::connectMQTT() {
	unsigned long now = millis();
	unsigned long elapsed = now - lastMqttAttempt;
	if (elapsed >= 10000) {
		Serial.print("Attempting MQTT connection...");
		if (onMQTTConnecting)
			onMQTTConnecting();
		// Create a random client ID
		lastMqttAttempt = now;
		// Attempt to connect
		if (pubSubClient->connect(pubSubClientId.c_str(), "mqttm", "mqttm")) {
			Serial.println("connected");
			registerMQTT();
			wasConnected = true;
			if (onMQTTConnected)
				onMQTTConnected();
		}
		else {
			Serial.print("failed, rc=");
			Serial.print(pubSubClient->state());
			Serial.println(" try again in 10 seconds");
			
			if (onMQTTDisconnected)
				onMQTTDisconnected(wasConnected);
			wasConnected = false;
		}
	}
}

////////////////////////////////////////////////////////////
// Loop
////////////////////////////////////////////////////////////

void ESPHASDevice::loop()
{
	if (!pubSubClient->connected()) {
		if (CanDoBlockingWork)
			connectMQTT();
	}
	pubSubClient->loop();
	httpServer->handleClient();
	timerLoop();
}

I need to clean up those libraries to make them more shareable, as they are currently special built for my personal use, but wanted to get this out first.

Advertisement

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s