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

Dartboard preview

Just a preview of the dartboard project I’ve been working on in my spare time.

I finally got to the stage of the project where I was able to glue the separate pieces together and demonstrate a working example. Now to clean everything up and continue adding games!

Hardware/software breakdown: I’m using a store bought led-lit dartboard, which I have unsoldered from the factory pcb and am using one arduino to drive the led matrix, one arduino to drive the input matrix, a nodejs server running on a raspberry pi to handle game logic, and a react web app front end for the interface, which I have loaded on a fire tablet currently

This is the exact board I used: https://smile.amazon.com/dp/B077PBKL22/

Looks like it’s out of stock on Amazon now but hopefully could find elsewhere with “Viper 42-0003”

I found this one that is ~40% cheaper and in stock. Not identical on the outside but likely an exact match internally, so I would imagine all of the code/circuitry would work plug and play with this board as well: https://smile.amazon.com/dp/B08Y8QKJMD/

Motorized Roller Shade Opener

This project uses a widely available DC geared motor, ESP8226 board, and rotary encoder along with some 3D printed pieces to pull open/close a roller shade with a ball style chain.
The Wemos ESP8266 board connects to your home’s wifi and is automatically discoverable to Alexas on the same network (“Alexa, discover devices”)
The code emulates a smart bulb, so it will natively turn on/off with Alexa, but doesn’t understand open/close commands. The work around is to create a custom action with your natural open phrase, which maps to turning the device on, which is open.

It is designed to work with the ball style roller chain, like:

BOM:
Wemos D1 Mini (any ESP board would do): https://smile.amazon.com/Organizer-ESP8266-Internet-Development-Compatible/dp/B081PX9YFV/
L293D H-Bridge Motor Driver:
https://smile.amazon.com/Bridgold-16-pin-Stepper-Drivers-Controllers/dp/B07NXTWJV9/
Rotary encoder: https://smile.amazon.com/Cylewet-Encoder-15%C3%9716-5-Arduino-CYT1062/dp/B06XQTHDRR/
5v regulator: https://smile.amazon.com/Regulator-Module-Voltage-Reducer-Adjustable/dp/B08SHQHNNR/
One LED, two momentary buttons, and a DC power jack, plus some glue
Geared DC motor: https://smile.amazon.com/gp/product/B01N1JQFYX/
12v 2a power supply: https://smile.amazon.com/Chanzon-Switching-Adapter-100-240V-Transformer/dp/B07HNL5D56/
3D printed parts: https://www.thingiverse.com/thing:4779589
PCB: https://drive.google.com/file/d/1G-6Qe3Wc2Cit9iK1bnlUplWXbs-5DDUT/view?usp=sharing
Top/Bottom:

These geared worm motors come in many different RPMs, providing lots of different levels of torque. My parents had 3 roller shades that were happy using the 65 rpm motors, but the largest one required more torque and a 25 rpm motor fit the bill nicely.

Arduino sketch:
https://gist.github.com/philharlow/6397fe8275e9bda25dcbeb37e290911f

Images of the assembly:
https://imgur.com/gallery/eiK3ScN

To program the limits:
Press and hold both buttons for 2 seconds until the led starts flashing.
Use the up/down buttons to drive the shade to either the top or bottom extent.
Press and hold both buttons for 2 seconds until the led flashes.
Use the up/down buttons to drive the shade to the other extent.
Press and hold both buttons for 2 seconds.

Fusion 360 file:
https://a360.co/3wTYG56

Semi-permanent Vive Lighthouse mounts

I wanted to mount my lighthouses up high, without drilling any holes as I would soon be moving this to another room in the house. It was the ~$10 at Home Depot for the parts below1605

Materials: (for 2 poles)

(Note: I used 1/2″ conduit and as a result had to snip the power plug, but if you use 3/4″ conduit with larger rubber caps, nuts and bolts, you should be able to avoid that.)

2x 1/2″ EMT conduit x 10′
4x 1/2″ EMT conduit strap
2x Wood plate (2 1/2″ x 4 x 1/2″)
2x 5/8″ Rubber leg tips
2x 3/4″ Rubber leg tips
4x 3/8-16 Nut
2x 3/8-16 Bolt
12x 1/2″ wood screws
~10″ 3/32″ Nylon edging (optional)

Picture for one pole:1533

Tools:

Drill
Drill bit (3/8″)
Hammer
Fish tape
Zip ties
Metal files (round and flat)
Knife/scissors

Build It

Start by cutting the conduit about an inch short of you ceiling height. Then mark a point 3 1/4″ from each end.

Drill a 3/4″ long opening in the conduit at each end, centered on the point you marked, taking care to make sure both holes are in same orientation.

File and smooth the opening, since you’ll be pulling the power cord through.

Attach some tape to the end of the fish tape, feed that into one drilled hole and out the other.

I used some snips to clip off the back side of the power plug to a 45ish* angle, to allow it to fit in the 1/2″ conduit.

Use zip ties to help pull the power cord through the conduit.

Cap the top side with a rubber cap. Then thread two nuts onto the bolt, and put the other rubber boot over the bolt’s head.

Place the top end of the conduit against the wall, and use a hammer to sink the nuts into the conduit.

Attach the wooden plate to the top end of the conduit with the straps. Then attach the lighthouse mount to the wooden plate.

Place the conduit into it’s position, and thread the nut out until its nice and tensioned. I found flexing the conduit would allow me to back the bolt out a little more and create a tighter fit.1605

SmartKegerator v2 Installation Guide

Project source here
This project has been heavily modified to take advantage of the raspberry pi 2’s additional horsepower. If you are looking for the raspberry pi v1 code, look around version 62 in the repository. Version 70 and on are unlikely to work on the original pi, although I have not tried.

[Updated 10/4/2016: These instructions have been updated and now work with a clean raspbianPIXEL image]

Installation:

Install a new copy of raspbian onto an sd card.

On your first boot, configure these:
Expand Filesystem
Boot to Desktop
Enable Camera
Configure keyboard (if not in UK) and timezone
Finish > Reboot

1) Update raspbian

sudo apt-get update
sudo apt-get upgrade
sudo rpi-update

2) Install requisites

sudo apt-get install qtcreator cmake libopencv-dev mplayer subversion qt4-dev-tools libqt4-dev libqt4-dev-bin qt4-qmake

3) Configure QtCreator

Launch QtCreator from start menu > Programming > QT Creator

Once in QT Creator:
Tools > Options > Build & Run > Qt Versions > Add > navigate to or paste: /usr/bin/qmake-qt4
Tools > Options > Build & Run > Compilers > Add > pick GCC
Then set compiler path : /usr/bin/arm-linux-gnueabihf-gcc-4.9
Click OK

Qt Creator seems to think that we are going to deploy on a remote target, to fix this :
Help > About Plugins
Uncheck Device Support > Remote Linux
Click Close and reopen Qt Creator

Tools > Options > Build & Run > Kits > Desktop-Qt4 4.8.6 (qt4)
Compiler: GCC
Debugger : /usr/bin/gdb

4) Compile facial recognition libraries

cd ~
git clone https://github.com/bytefish/libfacerec
cd libfacerec
sudo cmake .
sudo make

5) Compile QWT

cd ~
mkdir qwt
cd qwt
svn co svn://svn.code.sf.net/p/qwt/code/branches/qwt-6.1 ./
qmake qwt.pro
sudo make
sudo make install
cd lib/
sudo cp * /usr/lib/

6) Compile WiringPi

cd ~
git clone git://git.drogon.net/wiringPi
cd wiringPi/
./build

7) Get SmartKegerator source

cd ~
mkdir qt
cd qt/
mkdir SmartKegerator
cd SmartKegerator/
svn co https://subversion.assembla.com/svn/smartkegerator/trunk ./

8) Edit config files

sudo nano ~/qt/SmartKegerator/config.txt

Make sure all of the paths are correct for your system. If you’re using a fresh copy of raspbian and followed each step here, you shouldn’t need to edit anything.

9) Run it!

Open QTCreator and File > Open Project > /home/pi/qt/SmartKegerator/SmartKegerator.pro

Click Yes when it asks about environment settings.

Click the green arrow on the bottom-left to start building. The first build will take a while. If everything worked you should see the application open! If you get errors, try google or come back here and post a comment.

9) Disable the screen saver

sudo nano /etc/lightdm/lightdm.conf

scroll down until you see

[SeatDefaults]
#xserver-command=X

Remove the starting ‘#’ from ‘#xserver-command=X” and add ” -s 0 -dpms” to the end so you have:

[SeatDefaults]
xserver-command=X -s 0 -dpms

ctrl-x, y, enter to save

To use the Mimo-720S

See this post about configuring the touchscreen and kernel

Slic3r to Davnici XYZWare conversion

Converting gcode files generated with slic3r to work with the davinci printer’s native software XYZWare was a little tedious, so I wrote a simple C# app to automate the process. For this app to work you must be using a modified slic3r ini file

Just drag your gcode file onto the app and it will pop out a file of the same name but with a .3w extension, overwriting any conflict.

C# Source code is here if you’d like to compile it yourself as a new console app. You shouldn’t need to make any modifications, but you’re also welcome to.

EXE download for those who can’t compile themselves.

Hope this helps someone!

BMW F3x/F8x Escort radar detector hardwire with OEM mute button and HUD notification

I wanted to hardwire my radar detector, but I wanted a mute button and alert led, something improved upon my last idea.

I decided to use one of the disabled buttons on the BMW F3x/F8x’s headlight module for the mute button. I create my own button contacts using copper tape to put over the top of the original module’s contacts. I also 3D printed an alert symbol that flashes in my HUD, but this is only noticeable at night, currently.

I’ve included links to the materials I used, but there are many cheaper alternatives if you shop around!

Stuff needed:
Radar detector
Hardwire kit (Amazon – $26 or make your own)
soldering equipment
small drill bit + drill

for mute button:
kapton tape (Amazon – $9)
copper tape (Amazon – $6)
2-pin connector + wire (Amazon 10pack – $6)

for hud notification:
HUD notification housing (Thingiverse)
Notication led (any 5mm led, here’s what I used)
Small piece of paper
2-pin connector + wire (Amazon 10pack – $6)

Instructions:

Hardwire kit modifications: (only if you’re doing a mute button or remote led)
Remove the existing led and mutton button from the circuit board. Use a small drill bit to drill new holes for each lead, and connect a wire and connector to each spot.

I also found that when using the red led hardwire kit, I had to add a resistor once I removed the signal led. The blue led hardwire kit already had this resistor oddly enough.

Remote notification symbol:

1) Print the housing. I used 0.3mm thickness and 20% infill.

2) Place the led of choice into the back plate, bend the leads appropriately to fit, and solder a thin wire onto the leads.

3) Cut a small piece of paper to fit inside the housing, covering the whole left by the alert symbol, This will help diffuse the light, as well as hold the symbol in place.

4) Using some glue (I used 3 dots of superglue) place the symbol on the paper, using tweezers to perfectly align the symbol with even borders all around.

Hud Symbol

5) Snap the cover onto the back plate and use some sticky back tape to affix to the hud. You’ll need to do some testing to position it right.

Mute button instructions:

1) Follow this guide to remove the tabs blocking the OEM button from depressing:
http://f30.bimmerpost.com/forums/showthread.php?t=877278
(Pro-tip, when removing the module, press down on the top of the clips from inside, until it’s about 3/4″ out. Then you should just barely be able to see the clip from the outside. Push down from the outside on the part of the clip you can see and it will come out nice and easy)

2) Next, we’ll make our own button contacts so the radar detector can tell you’ve pushed the button. Start by removing the black silicone/rubber. (Watch out for the clear grease over the radial contacts in the center of the board. Try not to touch it!):

3) Lay kapton tape over the existing contacts. I chose the top button, and to put a contact on both sides, but really one side should be fine.

4) Lay 2 strips of copper tape per contact, using the existing contact as a guide. You want the two strips really close so they’ll join when the button depresses, but not touching!

5) Solder your wire onto the copper strips. If you do both sides, make sure it’s wired so that either side closing would complete the circuit

6) Push the silicone back over our solder job. I found using some jumper wire worked well if I wiggled and rotated it while gently pushing inside each of the 4 nubs as seen below

Hardwire:
1) Take down the panels from the driver and passenger sides by removing the 4 bolts on each side and gently tugging down.
2) Find the cigarette lighter plug on the passenger side and install the taps.

3) I used some heavy gauge wire to feed the power and ground wires from the drivers side to the passenger side through the spot the arrow is pointing at, with the cigarette lighter plug circled.

4) On the driver’s side, run the hardwire module up behind the headlight mode switch (that you modified if you did the mute button) like so (terrible drawing, I know):

5) Pop out the panel on the left of the dash, and pull down the rubber door seal from the frame.
6) Run the cable through the open panel and up to the spot where you mount your detector. Start with the plug where it should be and start tucking the cable into the trim as you go, working back down the a-pillar. To be even safer, you can run the cable behind the airbag by removing the trim pieces altogether.
7) Reinstall the rubber trim, panel on the left of the dash, connect your optional mute button and/or remote led, put the vent back in and you’re done!

Drive safe!

Pi-o-lantern

Recently browsing hack-a-day I came across Michal Janyst’s project where he used an arduino with some cheap (~$3) led matrix displays to animate eyeballs on a jack-o-lantern.

I really liked the idea and decided I wanted to try and drive the pupils using motion detection from the raspberry pi camera module! Unfortunately I only had a day to work on it, and I’ve never done motion detection before, so the learning began!

Today is Halloween and the effective deadline for the project, and while I’m not happy with the framerate I’m getting in python, I’m quite happy with what I was able to do!

I want to move the python to c++ for next year, which should triple the framerate or so.

Items used:

Cheap plastic pumpkin – Target – $3

2x MAX7219 LED matrix boards – eBay – $2.18
(I actually ordered mine from Amazon – $6.58 to make use of prime shipping)

Longer pi camera cable – Ebay $9-$27

Raspberry pi + camera

Source:

https://github.com/philharlow/Pi-o-lantern

Setup:

Coming soon!

Smart Kegerator

Discovery Channel Canada’s “Daily Planet” segment: https://www.youtube.com/watch?v=ENPVkKDqYiE (very similar to the walkthrough above)

Update 7/31/2015:

New installation guide has been posted here:
https://philsprojects.wordpress.com/2015/07/31/smartkegerator-v2-installation-guide/

 

The purpose of this project was to allow my roommates and friends to be able to drink as much beer from the kegerator as they’d like, without guesstimating who owes what when it comes time to refill the kegs. The system uses two flow meters in the beer lines to detect when beer is poured, and once a pour has started, the raspi camera module turns on to run facial recognition (disabled when I shot this video) and charge the appropriate user for their beer. I wanted the system to be as passive as possible, requiring zero human interaction (no RFID cards, no selecting the user every time) but to record all pours and how much was poured. So far the system is running great, minus that accidental spill because my tap handles are too close together 🙂

Eventually I’d like to use weight sensors under each keg to get a better estimation of keg volume, and use a liquid probe thermometer to better estimate the temperature of the beer instead of the air. I’m also moving to a NOIR pi camera module after finding it had much better low light performance, and I’ll probably add some IR lamps to help illuminate the face for recognition when the lights are off. I also need to mount the camera a bit better, ideally higher and a littler further behind the tower, but the flex arm tripod worked great for this first implementation.

I’m also working on a new UI that will probably look something like this:

Mock up UI v2

Mock up UI v2

The source is currently avilable at https://www.assembla.com/code/smartkegerator/subversion/nodes/ but it’s certainly not finished or polished.

The UI is written in QT and C++, using python scripts for the gpio flow interrupts (the c++ interrupts seemed unreliable,but python’s implementation worked perfectly) and the C loldht script found on these forums to read the temp from the temp/humidity sensor.

I’ve made a quick and dirty fritzing image to illustrate the connections, but I’m not very good with frizing so I’ve included text as well.
KegeratorSchematic
On each flow meter, I connected the red to the pi’s 3.3v, black to ground, and the yellow pin to GPIO #23 and GPIO #24 for left and right sides respectively.
The temperature sensor, looking at the front of it (the side with the holes) the pins go left to right 1-4. Pin 1 to GPIO #17, so that I can turn it on/off by writing power out on pin 17. Pin 2 to GPIO #4. Pin 3 to ground. And lastly put a 4.7k ohm resistor between pins 1 and 2.

Parts:
Rasperry pi
http://www.amazon.com/RASPBERRY-MODEL-756-8308-Raspberry-Pi/dp/B009SQQF9C/ref=sr_1_1?ie=UTF8&qid=1394450356&sr=8-1&keywords=raspberry+pi

Mimo 720S display
http://www.amazon.com/Powered-Slide-out-Touch-Screen-Monitor/dp/B002QFP4Z8/ref=sr_1_1?ie=UTF8&qid=1394450430&sr=8-1&keywords=mimo+720s

Powered USB Hub
http://www.amazon.com/gp/product/B005A0B3FG/ref=oh_details_o05_s01_i00?ie=UTF8&psc=1

DHT22 temp/humidity sensor
https://www.adafruit.com/products/385

Flow meters
http://www.adafruit.com/products/828

Pi Camera module (NOIR seems ideal for low light)
http://www.amazon.com/Raspberry-Pi-Camera-Filter-Vision/dp/B00G76YEU8/ref=sr_1_1?ie=UTF8&qid=1394450401&sr=8-1&keywords=raspberry+pi+noir

100cm SPI cable (raspi camera cable)
http://www.ebay.com/itm/141129552486?var=440222309796&ssPageName=STRK:MEWNX:IT&_trksid=p3984.m1497.l2649

Camera housing
http://www.amazon.com/gp/product/B00E1UOXMQ/ref=oh_details_o06_s00_i00?ie=UTF8&psc=1

Wide-angle lens
http://www.amazon.com/gp/product/B009NED5E2/ref=oh_details_o00_s00_i00?ie=UTF8&psc=1

Techflex cable sleeve(I think it makes the raspi camera cable better looking)
http://www.amazon.com/gp/product/B007VT6HSC/ref=oh_details_o02_s00_i00?ie=UTF8&psc=1

For the gpio breakout:
10 pin ribbon cable
http://www.ebay.com/itm/281152427395?ssPageName=STRK:MEWNX:IT&_trksid=p3984.m1497.l2649

10 pin connectors
http://www.ebay.com/itm/171047381713?ssPageName=STRK:MEWNX:IT&_trksid=p3984.m1497.l2649

protoboard from radioshack
http://www.radioshack.com/product/index.jsp?productId=2104052

References:
Mimo 720S solution – http://www.raspberrypi.org/phpBB3/viewtopic.php?t=27781
Raspberry pi camera to opencv/facial recognition – http://thinkrpi.wordpress.com/opencv-and-pi-camera-board/

More info coming soon!

Pandora controlling Google chrome plugin

I recently moved away from the OCR method of reading data from pandora, and moved to a google chrome plugin. This has the downside of requiring chrome to be running, but it allows infinitely more control.

I’ll do a better writeup soon, but following files make up the plugin:

manifest.json
http://snipt.org/MaPj4

background.js
http://snipt.org/MaQf9

pandoraController.js
http://snipt.org/MaOc8