├── .gitignore ├── stls ├── case.stl └── cover.stl ├── images ├── blinds.png ├── render.png ├── web-1.png ├── buttons.jpg ├── nodemcu.jpg └── breakout_board.jpg ├── data ├── blinds.css ├── blinds.js └── index.html ├── README.md └── smarterblinds.ino /.gitignore: -------------------------------------------------------------------------------- 1 | *.code-workspace 2 | -------------------------------------------------------------------------------- /stls/case.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/stls/case.stl -------------------------------------------------------------------------------- /stls/cover.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/stls/cover.stl -------------------------------------------------------------------------------- /images/blinds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/images/blinds.png -------------------------------------------------------------------------------- /images/render.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/images/render.png -------------------------------------------------------------------------------- /images/web-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/images/web-1.png -------------------------------------------------------------------------------- /images/buttons.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/images/buttons.jpg -------------------------------------------------------------------------------- /images/nodemcu.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/images/nodemcu.jpg -------------------------------------------------------------------------------- /images/breakout_board.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikivanov/smarterblinds/HEAD/images/breakout_board.jpg -------------------------------------------------------------------------------- /data/blinds.css: -------------------------------------------------------------------------------- 1 | div.centerContainer { 2 | margin: 0; 3 | position: absolute; 4 | top: 50%; 5 | left: 50%; 6 | -ms-transform: translate(-50%, -50%); 7 | transform: translate(-50%, -50%); 8 | width: 95vw; 9 | font-size: 20px; 10 | text-align: center; 11 | vertical-align: middle; 12 | } 13 | 14 | div.centerContainer > div { 15 | margin: 20px; 16 | } 17 | 18 | div.controlButton { 19 | border: 1px solid gray; 20 | height: 200px; 21 | background-color: lightgray; 22 | user-select: none; 23 | -moz-user-select: none; 24 | -khtml-user-select: none; 25 | -webkit-user-select: none; 26 | -o-user-select: none; 27 | } 28 | 29 | div.controlButton.pressed { 30 | background-color: gainsboro; 31 | } 32 | 33 | .material-icons.md-96 { 34 | font-size: 96px; 35 | padding-top: 48px; 36 | } 37 | 38 | div.middlePane > div { 39 | display: inline-block; 40 | margin: 20px; 41 | width: 120px; 42 | } 43 | div.middlePane > div > span, div.middlePane > div > input { 44 | display: block; 45 | } 46 | 47 | div.middlePane > div > span { 48 | height: 40px; 49 | vertical-align: middle; 50 | text-align: center; 51 | line-height: 40px; 52 | } 53 | 54 | div.middlePane > div > input { 55 | font-size: 16px; 56 | width: 120px; 57 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 |

3 | 4 | 5 | 6 |

7 | Blinds-To-Go sells motorized roller blinds controllable via a wand with two buttons, which connects to the blinds over Micro-USB. Unfortunately, it does not offer any smart functionality, but having a Micro-USB connection makes it very hackable. Hooking up the wand to a breakout board shows that Up and Down buttons short D+ / D- pins to ground. This project replaces the wand with an ESP8266 microcontroller to allow raising blinds on schedule as well as remote control via the phone / browser. 8 | 9 | 10 | I'm not associated with Blinds-To-Go and I'm not responsible for any damage caused to or by your blinds. With that being said, hack away! 11 | 12 | # Description 13 | Once you flash your microcontroller with the provided code and plug it in, it will: 14 | * Attempt to connect to WiFi. If this is the first time you're starting it up, or if the existing WiFi configuration is no longer valid, it will host an unsecure SmarterBlinds WiFi hotspot. Connect to it via your phone, go through captive portal and connect it to your WiFi. 15 | * Once WiFi is connected, the controller will start an mDNS service so you can access it at http://smarterblinds.local If you don't have mDNS, you'll have to figure out the assigned IP from your router. 16 | * The web page has simple up and down controls to mimic the wand button behavior - you have to press and hold the buttons for about a second for the blinds to go up or down. The web page also allows you to set a time for each day to raise the blinds to the `Favorite 1` position (the position when you double-click the Up button). 17 | * You need to set your time offset and DST setting. Refresh the page to verify that controller time matches your clock. 18 | * ESP8266 does not have a real time clock, but since we have WiFi we can keep time using NTP. This allows the controller to match current time against the specified schedule. 19 | * ESP8266 needs constant USB power. The good news is that it will also keep your blinds fully charged. 20 | 21 | # Ingredients 22 | * [NodeMCU ESP8266 board](images/nodemcu.jpg) 23 | * [USB Type A breakout board](images/breakout_board.jpg) 24 | * [2x 12mm Waterproof Momentary N.O. Push Button Switch](images/buttons.jpg) 25 | * 10x M3 6mm bolts 26 | 27 | # Setup 28 | * In the `stls` folder, you'll find a case that will fit an ESP8266 board, a USB breakout board and 2 buttons. It can be 3D printed without supports. 29 | * At the top of the Arduino sketch, you can see (and change, if you'd like) the pins in use: 30 | ``` 31 | #define UP_PIN 4 32 | #define DOWN_PIN 5 33 | #define BUTTON_UP_PIN 12 34 | #define BUTTON_DOWN_PIN 14 35 | ``` 36 | * Solder the pins to the USB breakout board as follows: 37 | * UP_PIN to D+ 38 | * DOWN_PIN to D- 39 | * Vin to VBus 40 | * Gnd to Gnd 41 | * Solder the buttons to the pins as follows (polarity doesn't matter): 42 | * Up Button to BUTTON_UP_PIN 43 | * Down Button to BUTTON_DOWN_PIN 44 | * Other contact of each button can be soldered together and then soldered to GND. 45 | * Test everything first, and if all is well, secure everything with M3 bolts. -------------------------------------------------------------------------------- /data/blinds.js: -------------------------------------------------------------------------------- 1 | $(document).ready(function() { 2 | 3 | $("#upButton").mousedown(function() { 4 | onUpButtonPressed(); 5 | }); 6 | $("#upButton").on('touchstart', function(){ 7 | onUpButtonPressed(); 8 | }); 9 | $("#upButton").mouseup(function() { 10 | onUpButtonReleased(); 11 | }); 12 | $("#upButton").on('touchend', function(){ 13 | onUpButtonReleased(); 14 | }); 15 | $("#downButton").mousedown(function() { 16 | onDownButtonPressed(); 17 | }); 18 | $("#downButton").on('touchstart', function(){ 19 | onDownButtonPressed(); 20 | }); 21 | $("#downButton").mouseup(function() { 22 | onDownButtonReleased(); 23 | }); 24 | $("#downButton").on('touchend', function(){ 25 | onDownButtonReleased(); 26 | }); 27 | 28 | setupMiddlePanes(); 29 | }); 30 | 31 | const daysOfWeek = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; 32 | function setupMiddlePanes() { 33 | const middleContainer = $("div.middlePane"); 34 | middleContainer.empty(); 35 | for (const dayOfWeek of daysOfWeek) { 36 | const innerContainer = $("
"); 37 | innerContainer.append("" + dayOfWeek + ""); 38 | innerContainer.append(""); 39 | middleContainer.append(innerContainer); 40 | } 41 | 42 | $.get('/settings', function(settings) { 43 | if (settings && settings.schedule) { 44 | for (const dow of Object.keys(settings.schedule)) { 45 | const input = $("div.middlePane input[dow=" + dow + "]"); 46 | const value = settings.schedule[dow]; 47 | if (value) { 48 | let valueString = ''; 49 | if (value.localHours <= 9) { 50 | valueString += '0'; 51 | } 52 | valueString += value.localHours; 53 | valueString += ":"; 54 | if (value.localMinutes <= 9) { 55 | valueString += '0'; 56 | } 57 | valueString += value.localMinutes; 58 | input.val(valueString); 59 | } else { 60 | input.val(null); 61 | } 62 | } 63 | 64 | $("div.timezonePane select option[timeZoneId='" 65 | + settings.timezoneId + "'] ").prop("selected", true); 66 | 67 | $("#dstCheckbox").prop("checked", settings.dst); 68 | $("#servertTime").text("Controller time is " + settings.currentTime); 69 | } 70 | attachHandlers(); 71 | 72 | }); 73 | } 74 | 75 | function attachHandlers() { 76 | const inputs = $("div.middlePane input"); 77 | inputs.change(function() { 78 | sendSettings(); 79 | }); 80 | $("div.timezonePane select").change(function() { 81 | sendSettings(); 82 | }); 83 | $("#dstCheckbox").change(function() { 84 | sendSettings(); 85 | }); 86 | } 87 | 88 | function sendSettings() { 89 | const inputs = $("div.middlePane input"); 90 | const settings = { schedule: {}}; 91 | for (const input of inputs) { 92 | const key = $(input).attr("dow"); 93 | const date = input.valueAsDate; 94 | let value = null; 95 | if (date) { 96 | value = { 97 | localHours: date.getUTCHours(), 98 | localMinutes: date.getUTCMinutes() 99 | }; 100 | } 101 | settings.schedule[key] = value; 102 | } 103 | settings.offsetHours = parseInt($("div.timezonePane select").children("option:selected").val()); 104 | settings.timezoneId = parseInt($("div.timezonePane select").children("option:selected").attr('timeZoneId')); 105 | settings.dst = $("#dstCheckbox").prop("checked") == true; 106 | 107 | postJson("/settings", settings); 108 | } 109 | 110 | function onUpButtonPressed() { 111 | $("#upButton").addClass("pressed"); 112 | postData("/up", {pressed: true}); 113 | } 114 | 115 | function onUpButtonReleased() { 116 | $("#upButton").removeClass("pressed"); 117 | postData("/up", {pressed: false}); 118 | } 119 | 120 | function onDownButtonPressed() { 121 | $("#downButton").addClass("pressed"); 122 | postData("/down", {pressed: true}); 123 | } 124 | 125 | function onDownButtonReleased() { 126 | $("#downButton").removeClass("pressed"); 127 | postData("/down", {pressed: false}); 128 | } 129 | 130 | function postData(url, data) { 131 | $.post(url, data); 132 | } 133 | 134 | function postJson(url, data) { 135 | if(typeof data == 'object') { 136 | data = JSON.stringify(data); 137 | } 138 | $.post(url, data, null, 'json'); 139 | } -------------------------------------------------------------------------------- /smarterblinds.ino: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include //Local DNS Server used for redirecting all requests to the configuration portal 5 | #include //Local WebServer used to serve the configuration portal 6 | #include 7 | #include 8 | #include 9 | #include 10 | 11 | #define UP_PIN 4 12 | #define DOWN_PIN 5 13 | #define BUTTON_UP_PIN 12 14 | #define BUTTON_DOWN_PIN 14 15 | 16 | WiFiUDP ntpUDP; 17 | NTPClient timeClient(ntpUDP); 18 | DynamicJsonDocument config(1024); 19 | ESP8266WebServer server(80); 20 | 21 | void setup() { 22 | pinMode(UP_PIN, OUTPUT); 23 | pinMode(DOWN_PIN, OUTPUT); 24 | digitalWrite(UP_PIN, HIGH); 25 | digitalWrite(DOWN_PIN, HIGH); 26 | pinMode(BUTTON_UP_PIN, INPUT_PULLUP); 27 | pinMode(BUTTON_DOWN_PIN, INPUT_PULLUP); 28 | 29 | delay(10); 30 | Serial.begin(115200); 31 | 32 | if(!SPIFFS.begin()){ 33 | Serial.println("An Error has occurred while mounting SPIFFS"); 34 | return; 35 | } 36 | 37 | Serial.println("Starting WiFi manager"); 38 | 39 | WiFiManager wifiManager; 40 | wifiManager.autoConnect("SmarterBlinds"); 41 | 42 | Serial.println("Connected to WiFi"); 43 | Serial.println("Starting mDNS"); 44 | 45 | if (MDNS.begin("smarterblinds")) { 46 | Serial.println("mDNS responder started"); 47 | } 48 | 49 | timeClient.begin(); 50 | Serial.println("NTP client started"); 51 | 52 | File configFile = SPIFFS.open("/config.txt", "r"); 53 | if (configFile) { 54 | Serial.println("Config file found"); 55 | deserializeJson(config, configFile); 56 | configFile.close(); 57 | initTimeClientFromConfig(); 58 | } 59 | 60 | server.on("/", handleRoot); 61 | server.on("/index.html", handleRoot); 62 | server.on("/blinds.js", handleJS); 63 | server.on("/blinds.css", handleCSS); 64 | server.on("/settings", handleSettings); 65 | server.on("/up", handleUp); 66 | server.on("/down", handleDown); 67 | 68 | server.begin(); 69 | 70 | Serial.println("Server started"); 71 | } 72 | 73 | void initTimeClientFromConfig() { 74 | int offsetHours = config["offsetHours"].as(); 75 | bool dst = config["dst"].as(); 76 | if (dst) { 77 | offsetHours = offsetHours + 1; 78 | } 79 | int offsetSeconds = offsetHours * 60 * 60; 80 | timeClient.setTimeOffset(offsetSeconds); 81 | } 82 | 83 | int desiredDirection = 0; 84 | int lastDirection = 0; 85 | bool upLatched = false; 86 | bool downLatched = false; 87 | void loop(void) { 88 | desiredDirection = 0; 89 | timeClient.update(); 90 | 91 | MDNS.update(); 92 | 93 | server.handleClient(); 94 | 95 | int upButtonVal = digitalRead(BUTTON_UP_PIN); 96 | int downButtonVal = digitalRead(BUTTON_DOWN_PIN); 97 | 98 | if (upButtonVal == LOW || upLatched) { 99 | desiredDirection = 1; 100 | } 101 | 102 | if (downButtonVal == LOW || downLatched) { 103 | desiredDirection = -1; 104 | } 105 | 106 | if (desiredDirection != lastDirection) { 107 | Serial.println(String("New direction ") + desiredDirection + " last direction " + lastDirection); 108 | if (desiredDirection == 0) { 109 | digitalWrite(UP_PIN, HIGH); 110 | digitalWrite(DOWN_PIN, HIGH); 111 | } else if (desiredDirection == 1) { 112 | digitalWrite(UP_PIN, LOW); 113 | digitalWrite(DOWN_PIN, HIGH); 114 | } else if (desiredDirection == -1) { 115 | digitalWrite(UP_PIN, HIGH); 116 | digitalWrite(DOWN_PIN, LOW); 117 | } 118 | delay(100); 119 | } 120 | 121 | lastDirection = desiredDirection; 122 | checkSchedule(); 123 | } 124 | 125 | char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}; 126 | void checkSchedule() { 127 | const char* currentDoW = daysOfTheWeek[timeClient.getDay()]; 128 | if (config["schedule"][currentDoW] != NULL) { 129 | //Serial.println(String(currentDoW) + " " + timeClient.getHours() + " " + timeClient.getMinutes() + " " + String(config["schedule"][currentDoW]["localHours"].as()) + " " + String(config["schedule"][currentDoW]["localMinutes"].as())); 130 | if (timeClient.getHours() == config["schedule"][currentDoW]["localHours"].as()) { 131 | if (timeClient.getMinutes() == config["schedule"][currentDoW]["localMinutes"].as()) { 132 | if (timeClient.getSeconds() >= 0 && timeClient.getSeconds() <= 2) { 133 | Serial.println("Schedule match"); 134 | digitalWrite(UP_PIN, LOW); 135 | delay(100); 136 | digitalWrite(UP_PIN, HIGH); 137 | delay(100); 138 | digitalWrite(UP_PIN, LOW); 139 | delay(100); 140 | digitalWrite(UP_PIN, HIGH); 141 | delay(100); 142 | 143 | delay(3000); 144 | } 145 | } 146 | } 147 | } 148 | } 149 | 150 | void handleSettings() { 151 | Serial.println("Settings"); 152 | if (server.method() == HTTP_POST) { 153 | Serial.println("Got settings from browser"); 154 | Serial.println(server.arg("plain")); 155 | File configFile = SPIFFS.open("/config.txt", "w"); 156 | configFile.print(server.arg("plain")); 157 | deserializeJson(config, server.arg("plain")); 158 | configFile.close(); 159 | initTimeClientFromConfig(); 160 | server.send(200, "text/plain", "OK"); 161 | } 162 | else if (server.method() == HTTP_GET) { 163 | Serial.println("Sent settings to the browser"); 164 | String output; 165 | Serial.println("Sending currentTimeMS " + timeClient.getFormattedTime()); 166 | DynamicJsonDocument configCopy(1024); 167 | configCopy = config; 168 | configCopy["currentTime"] = timeClient.getFormattedTime(); 169 | serializeJson(configCopy, output); 170 | server.send(200, "text/json", output); 171 | Serial.println(output); 172 | } 173 | } 174 | 175 | void handleUp() { 176 | bool pressed = server.arg(0) == "true"; 177 | if (pressed) { 178 | desiredDirection = 1; 179 | upLatched = true; 180 | } else { 181 | desiredDirection = 0; 182 | upLatched = false; 183 | } 184 | server.send(200, "text/plain", "OK"); 185 | } 186 | 187 | void handleDown() { 188 | bool pressed = server.arg(0) == "true"; 189 | if (pressed) { 190 | desiredDirection = -1; 191 | downLatched = true; 192 | } else { 193 | desiredDirection = 0; 194 | downLatched = false; 195 | } 196 | server.send(200, "text/plain", "OK"); 197 | } 198 | 199 | void handleRoot() { 200 | handleFileRead("/index.html"); 201 | } 202 | 203 | void handleJS() { 204 | handleFileRead("/blinds.js"); 205 | } 206 | 207 | void handleCSS() { 208 | handleFileRead("/blinds.css"); 209 | } 210 | 211 | String getContentType(String filename) { // convert the file extension to the MIME type 212 | if (filename.endsWith(".html")) return "text/html"; 213 | else if (filename.endsWith(".css")) return "text/css"; 214 | else if (filename.endsWith(".js")) return "application/javascript"; 215 | else if (filename.endsWith(".ico")) return "image/x-icon"; 216 | return "text/plain"; 217 | } 218 | 219 | bool handleFileRead(String path) { // send the right file to the client (if it exists) 220 | Serial.println("handleFileRead: " + path); 221 | if (path.endsWith("/")) path += "index.html"; // If a folder is requested, send the index file 222 | String contentType = getContentType(path); // Get the MIME type 223 | if (SPIFFS.exists(path)) { // If the file exists 224 | File file = SPIFFS.open(path, "r"); // Open it 225 | size_t sent = server.streamFile(file, contentType); // And send it to the client 226 | file.close(); // Then close the file again 227 | return true; 228 | } 229 | Serial.println("\tFile Not Found"); 230 | return false; // If the file doesn't exist, return false 231 | } 232 | -------------------------------------------------------------------------------- /data/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 11 | 12 | 13 |
14 |
15 |
16 | 17 | keyboard_arrow_up 18 | 19 |
20 |
21 |
22 |
23 |
24 | 108 |
109 | 110 |
111 | 112 |
113 |
114 |
115 | 116 | keyboard_arrow_down 117 | 118 |
119 |
120 |
121 | 122 | 123 | --------------------------------------------------------------------------------