Alessandro Polselli
Published © Apache-2.0

Matrix Shelly Thermostat: A Serverless Approach

A modular WiFi smart thermostat that doesn't need you to maintain any infrastructure: deploy it everywhere, integrate with everything.

IntermediateFull instructions provided3 hours16,237

Things used in this project

Hardware components

Shelly 1
×1
M5Stack - ATOM Matrix ESP32 Development Kit
×1
M5Stack - ENV Unit with Temperature Humidity Pressure Sensor (DHT12+BMP280)
×1

Software apps and online services

ESPHome
MQTT
MQTT
Node-RED
Node-RED

Story

Read more

Schematics

Matrix Shelly Thermostat

Shelly 1

M5Stack Atom Matrix

ENV Sensor (BMP280 + DHT12)

Code

matrix_shelly_thermostat.yaml

YAML
This is the YAML file to build the ESPHome firmware for the M5Atom Matrix
substitutions:
  devicename: matrix_shelly_thermostat
  upper_devicename: Matrix Shelly Thermostat
  shelly_mqtt_deviceid: shelly1-32C429
  mqtt_broker: test.mosquitto.org
  #mqtt_broker: homeassistant
  #mqtt_broker: 192.168.1.24 #black-pearl.local

mqtt:
  broker: ${mqtt_broker}
  #port: 1883
  username: ""
  password: ""
  discovery: true
  id: mqtt_client

wifi:
  networks:
#  - ssid: !secret wifi_ssid
#    password: !secret wifi_password

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: ${upper_devicename}
    password: "apolselli"

esphome:
  name: $devicename
  platform: ESP32
  board: pico32
  includes:
    - my_matrixled_display.h
  platformio_options:
    upload_speed: 115200
  on_boot:
    then:
      - light.turn_on:
          id: led1
          brightness: 50%
          effect: "Scan"
          red: 100%
          green: 100%
          blue: 0%

captive_portal:

# Enable logging
logger:

# Enable Home Assistant API
#api:

ota:

web_server:

font:
  - id: tinyfont
    file: "DejaVuSans.ttf"
    size: 7
    glyphs: '→!"%()+,-_.:°0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz'

i2c:
  - id: grove_bus
    sda: GPIO26
    scl: GPIO32
    scan: True

binary_sensor:
  - platform: gpio
    pin:
      number: GPIO39
      inverted: true
    name: ${upper_devicename} Button
    on_press:
      then:
        - lambda: |-
            auto call = id(climate1).make_call();
            call.set_away(false);
            switch(id(climate1).mode){
              case climate::CLIMATE_MODE_OFF:
                call.set_mode(climate::CLIMATE_MODE_AUTO); break;
              //case climate::CLIMATE_MODE_AUTO:
              //  call.set_mode(climate::CLIMATE_MODE_HEAT); break;
              default:
                call.set_mode(climate::CLIMATE_MODE_OFF); break;
            }
            call.perform();
    on_double_click:
      then:
        - lambda: |-
            auto call = id(climate1).make_call();
            //call.set_mode(climate::CLIMATE_MODE_AUTO);
            if (id(climate1).away)
              call.set_away(false);
            else
              call.set_away(true);
            call.perform();

climate:
  - platform: bang_bang
    name: ${upper_devicename}
    id: climate1
    sensor: temp1
    default_target_temperature_low: 21 °C
    default_target_temperature_high: 21 °C
    visual:
      min_temperature: 6 °C
      max_temperature: 30 °C
      temperature_step: 1 °C
    away_config:
      default_target_temperature_low: 6 °C
      default_target_temperature_high: 6 °C
      
    heat_action:
      - mqtt.publish:
          topic: shellies/${shelly_mqtt_deviceid}/relay/0/command
          payload: "on"
      - mqtt.publish:
          topic: matrix_shelly_thermostat/climate/matrix_shelly_thermostat/action/state
          payload: "heating"
    idle_action:
      - mqtt.publish:
          topic: shellies/${shelly_mqtt_deviceid}/relay/0/command
          payload: "off"
      - mqtt.publish:
          topic: matrix_shelly_thermostat/climate/matrix_shelly_thermostat/action/state
          payload: "idle"

sensor:
  - platform: wifi_signal
    name: ${upper_devicename} WiFi Signal
  - platform: uptime
    name: ${upper_devicename} Uptime
# ENV Unit
  - platform: bmp280
    i2c_id: grove_bus
    temperature:
      name: ${upper_devicename} BMP280 Temperature
      id: temp1
      on_value:
        then:
          - if:
              condition:
                wifi.connected
              then:
                - light.turn_on:
                    id: led1
                    effect: "Scrolling Info"
    pressure:
      name: ${upper_devicename} BMP280 Pressure
    address: 0x76
    update_interval: 10s
  - platform: dht12
    i2c_id: grove_bus
    temperature:
      name: ${upper_devicename} DHT12 Temperature
    humidity:
      name: ${upper_devicename} DHT12 Humidity

switch:
  - platform: restart
    name: ${upper_devicename} Restart

light:
  - platform: neopixelbus
    type: GRB
    pin: GPIO27
    num_leds: 25
    name: ${upper_devicename} Led Matrix
    id: led1
    color_correct: [50%, 50%, 50%]
    effects:
      - addressable_rainbow:
      - addressable_color_wipe:
      - addressable_scan:
      - addressable_twinkle:
      - addressable_random_twinkle:
      - addressable_fireworks:
      - addressable_lambda:
          name: "Scrolling Info"
          update_interval: 100ms
          lambda: |-
            static char text[64] = ".";
            static uint16_t run = 0;
            static int width;
            static int fullwidth = 32;
            static int x_offset;
            static int baseline;
            static int height;
            static auto matrixled = new MatrixLedDisplay();
            matrixled->set_addr_light((light::AddressableLight *) &it);
            matrixled->set_width(5);
            matrixled->set_height(5);

            //matrixled->set_rotation(display::DISPLAY_ROTATION_270_DEGREES); 
            
            // Set TEXT (once per scroll)
            if ((run % fullwidth) == 0){
              if (!id(mqtt_client).is_connected())
                sprintf(text, "NO MQTT %.1f°C", id(temp1).state);
              else if ((id(climate1).mode == climate::CLIMATE_MODE_OFF) || (id(climate1).action == climate::CLIMATE_ACTION_OFF) || id(climate1).away)
                sprintf(text, "%.1f°C", id(temp1).state);
              else
                sprintf(text, "%.1f → %.1f°C", id(temp1).state, id(climate1).target_temperature);
              
              id(tinyfont)->measure(text, &width, &x_offset, &baseline, &height);
              fullwidth = width + 5;
              
              //ESP_LOGD("main", "Displaying \"%s\"", text);
              run = 0;
            }
            
            // Set COLOR (once per refresh: this gives immediate user feedback)
            if (!id(mqtt_client).is_connected())
              matrixled->set_fg_color(light::ESPColor(255, 255, 0, 0));       // YELLOW (NO MQTT)
            else if (id(climate1).away)
              matrixled->set_fg_color(light::ESPColor(0, 0, 255, 0));         // BLUE   (away)
            else if (id(climate1).mode == climate::CLIMATE_MODE_OFF)
              matrixled->set_fg_color(light::ESPColor(255, 255, 255, 0));     // WHITE  (off)
            else {
              if (id(climate1).action != climate::CLIMATE_ACTION_OFF)
                matrixled->set_fg_color(light::ESPColor(255, 0, 0, 0));       // RED    (heating)
              else
                matrixled->set_fg_color(light::ESPColor(0, 255, 0, 0));       // GREEN  (idle)
            }
            
            matrixled->clear();
            matrixled->printf(5 + width/2 - run % fullwidth, matrixled->get_height()/2, id(tinyfont), COLOR_ON, TextAlign::CENTER, text);
            run++;

display:

matrix_shelly_thermostat.json

JSON
This is the Node-Red flow & dashboard JSON file
[
    {
        "id": "3fd94120.26662e",
        "type": "tab",
        "label": "Matrix Shelly Thermostat",
        "disabled": false,
        "info": ""
    },
    {
        "id": "f8dccf8f.c4f73",
        "type": "ui_template",
        "z": "3fd94120.26662e",
        "group": "810b9f10.b5cba",
        "name": "Nest",
        "order": 1,
        "width": "0",
        "height": "0",
        "format": "<div id=\"thermostat\"></div>\n\n<style>\n\n@import url(http://fonts.googleapis.com/css?family=Open+Sans:300);\n\n#thermostat {\n margin: 0 auto;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\n.dial {\n    -webkit-user-select: none;\n    -moz-user-select: none;\n    -ms-user-select: none;\n    user-select: none;\n}\n.dial.away .dial__ico__leaf {\n    visibility: hidden;\n}\n.dial.away .dial__lbl--target {\n    visibility: hidden;\n}\n.dial.away .dial__lbl--target--half {\n    visibility: hidden;\n}\n.dial.away .dial__lbl--away {\n    opacity: 1;\n}\n.dial .dial__shape {\n    -webkit-transition: fill 0.5s;\n    transition: fill 0.5s;\n}\n.dial path.dial__ico__leaf {\n    fill: #13EB13;\n    opacity: 0;\n    -webkit-transition: opacity 0.5s;\n    transition: opacity 0.5s;\n    pointer-events: none;\n}\n.dial.has-leaf .dial__ico__leaf {\n    display: block;\n    opacity: 1;\n    pointer-events: initial;\n}\n.dial__editableIndicator {\n    fill-rule: evenodd;\n    opacity: 0;\n    -webkit-transition: opacity 0.5s;\n    transition: opacity 0.5s;\n}\n.dial--edit path.dial__editableIndicator {\n    fill: white;\n}\n.dial--edit .dial__editableIndicator {\n    opacity: 1;\n}\n.dial--state--off .dial__shape {\n    fill: #3d3c3c;\n}\n.dial--state--heating .dial__shape {\n    fill: #E36304;\n}\n.dial--state--cooling .dial__shape {\n    fill: #007AF1;\n}\n.dial .dial__ticks path {\n    fill: rgba(255, 255, 255, 0.3);\n}\n.dial .dial__ticks path.active {\n    fill: rgba(255, 255, 255, 0.8);\n}\n.dial text {\n    fill: white;\n    text-anchor: middle;\n    font-family: Helvetica, sans-serif;\n    alignment-baseline: central;\n}\n.dial__lbl--target {\n    font-size: 120px;\n    font-weight: bold;\n}\n.dial__lbl--target--half {\n    font-size: 40px;\n    font-weight: bold;\n    opacity: 0;\n    -webkit-transition: opacity 0.1s;\n    transition: opacity 0.1s;\n}\n.dial__lbl--target--half.shown {\n    opacity: 1;\n    -webkit-transition: opacity 0s;\n    transition: opacity 0s;\n}\n.dial__lbl--ambient {\n    font-size: 22px;\n    font-weight: bold;\n}\n.dial__lbl--away {\n    font-size: 72px;\n    font-weight: bold;\n    opacity: 0;\n    pointer-events: none;\n}\n#controls {\n    font-family: Open Sans;\n    background-color: rgba(255, 255, 255, 0.25);\n    padding: 20px;\n    border-radius: 5px;\n    position: absolute;\n    left: 50%;\n    -webkit-transform: translatex(-50%);\n    transform: translatex(-50%);\n    margin-top: 20px;\n}\n#controls label {\n    text-align: left;\n    display: block;\n}\n#controls label span {\n    display: inline-block;\n    width: 200px;\n    text-align: right;\n    font-size: 0.8em;\n    text-transform: uppercase;\n}\n#controls p {\n    margin: 0;\n    margin-bottom: 1em;\n    padding-bottom: 1em;\n    border-bottom: 2px solid #ccc;\n}\n</style>\n<script>\n    var thermostatDial = (function() {\n\t\n\t/*\n\t * Utility functions\n\t */\n\t\n\t// Create an element with proper SVG namespace, optionally setting its attributes and appending it to another element\n\tfunction createSVGElement(tag,attributes,appendTo) {\n\t\tvar element = document.createElementNS('http://www.w3.org/2000/svg',tag);\n\t\tattr(element,attributes);\n\t\tif (appendTo) {\n\t\t\tappendTo.appendChild(element);\n\t\t}\n\t\treturn element;\n\t}\n\t\n\t// Set attributes for an element\n\tfunction attr(element,attrs) {\n\t\tfor (var i in attrs) {\n\t\t\telement.setAttribute(i,attrs[i]);\n\t\t}\n\t}\n\t\n\t// Rotate a cartesian point about given origin by X degrees\n\tfunction rotatePoint(point, angle, origin) {\n\t\tvar radians = angle * Math.PI/180;\n\t\tvar x = point[0]-origin[0];\n\t\tvar y = point[1]-origin[1];\n\t\tvar x1 = x*Math.cos(radians) - y*Math.sin(radians) + origin[0];\n\t\tvar y1 = x*Math.sin(radians) + y*Math.cos(radians) + origin[1];\n\t\treturn [x1,y1];\n\t}\n\t\n\t// Rotate an array of cartesian points about a given origin by X degrees\n\tfunction rotatePoints(points, angle, origin) {\n\t\treturn points.map(function(point) {\n\t\t\treturn rotatePoint(point, angle, origin);\n\t\t});\n\t}\n\t\n\t// Given an array of points, return an SVG path string representing the shape they define\n\tfunction pointsToPath(points) {\n\t\treturn points.map(function(point, iPoint) {\n\t\t\treturn (iPoint>0?'L':'M') + point[0] + ' ' + point[1];\n\t\t}).join(' ')+'Z';\n\t}\n\t\n\tfunction circleToPath(cx, cy, r) {\n\t\treturn [\n\t\t\t\"M\",cx,\",\",cy,\n\t\t\t\"m\",0-r,\",\",0,\n\t\t\t\"a\",r,\",\",r,0,1,\",\",0,r*2,\",\",0,\n\t\t\t\"a\",r,\",\",r,0,1,\",\",0,0-r*2,\",\",0,\n\t\t\t\"z\"\n\t\t].join(' ').replace(/\\s,\\s/g,\",\");\n\t}\n\t\n\tfunction donutPath(cx,cy,rOuter,rInner) {\n\t\treturn circleToPath(cx,cy,rOuter) + \" \" + circleToPath(cx,cy,rInner);\n\t}\n\t\n\t// Restrict a number to a min + max range\n\tfunction restrictToRange(val,min,max) {\n\t\tif (val < min) return min;\n\t\tif (val > max) return max;\n\t\treturn val;\n\t}\n\t\n\t// Round a number to the nearest 0.5\n\tfunction roundHalf(num) {\n\t\treturn Math.round(num*2)/2;\n\t}\n\t\n\tfunction setClass(el, className, state) {\n\t\tel.classList[state ? 'add' : 'remove'](className);\n\t}\n\t\n\t/*\n\t * The \"MEAT\"\n\t */\n\n\treturn function(targetElement, options) {\n\t\tvar self = this;\n\t\t\n\t\t/*\n\t\t * Options\n\t\t */\n\t\toptions = options || {};\n\t\toptions = {\n\t\t\tdiameter: options.diameter || 400,\n\t\t\tminValue: options.minValue || 10, // Minimum value for target temperature\n\t\t\tmaxValue: options.maxValue || 30, // Maximum value for target temperature\n\t\t\tnumTicks: options.numTicks || 200, // Number of tick lines to display around the dial\n\t\t\tonSetTargetTemperature: options.onSetTargetTemperature || function() {}, // Function called when new target temperature set by the dial\n\t\t};\n\t\t\n\t\t/*\n\t\t * Properties - calculated from options in many cases\n\t\t */\n\t\tvar properties = {\n\t\t\ttickDegrees: 300, // Degrees of the dial that should be covered in tick lines\n\t\t\trangeValue: options.maxValue - options.minValue,\n\t\t\tradius: options.diameter/2,\n\t\t\tticksOuterRadius: options.diameter / 30,\n\t\t\tticksInnerRadius: options.diameter / 8,\n\t\t\thvac_states: ['off', 'heating', 'cooling'],\n\t\t\tdragLockAxisDistance: 15,\n\t\t}\n\t\tproperties.lblAmbientPosition = [properties.radius, properties.ticksOuterRadius-(properties.ticksOuterRadius-properties.ticksInnerRadius)/2]\n\t\tproperties.offsetDegrees = 180-(360-properties.tickDegrees)/2;\n\t\t\n\t\t/*\n\t\t * Object state\n\t\t */\n\t\tvar state = {\n\t\t\ttarget_temperature: options.minValue,\n\t\t\tambient_temperature: options.minValue,\n\t\t\thvac_state: properties.hvac_states[0],\n\t\t\thas_leaf: false,\n\t\t\taway: false\n\t\t};\n\t\t\n\t\t/*\n\t\t * Property getter / setters\n\t\t */\n\t\tObject.defineProperty(this,'target_temperature',{\n\t\t\tget: function() {\n\t\t\t\treturn state.target_temperature;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.target_temperature = restrictTargetTemperature(+val);\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'ambient_temperature',{\n\t\t\tget: function() {\n\t\t\t\treturn state.ambient_temperature;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.ambient_temperature = roundHalf(+val);\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'hvac_state',{\n\t\t\tget: function() {\n\t\t\t\treturn state.hvac_state;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tif (properties.hvac_states.indexOf(val)>=0) {\n\t\t\t\t\tstate.hvac_state = val;\n\t\t\t\t\trender();\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t\tfunction str2bool(strvalue){\n          return (strvalue && typeof strvalue == 'string') ? (strvalue.toLowerCase() == 'true') : (strvalue == true);\n        }\n\t\tObject.defineProperty(this,'has_leaf',{\n\t\t\tget: function() {\n\t\t\t\treturn state.has_leaf;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.has_leaf = !!str2bool(val);\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\tObject.defineProperty(this,'away',{\n\t\t\tget: function() {\n\t\t\t\treturn state.away;\n\t\t\t},\n\t\t\tset: function(val) {\n\t\t\t\tstate.away = !!str2bool(val);\n\t\t\t\trender();\n\t\t\t}\n\t\t});\n\t\t\n\t\t/*\n\t\t * SVG\n\t\t */\n\t\tvar svg = createSVGElement('svg',{\n\t\t\twidth: '100%', //options.diameter+'px',\n\t\t\theight: '100%', //options.diameter+'px',\n\t\t\tviewBox: '0 0 '+options.diameter+' '+options.diameter,\n\t\t\tclass: 'dial'\n\t\t},targetElement);\n\t\t// CIRCULAR DIAL\n\t\tvar circle = createSVGElement('circle',{\n\t\t\tcx: properties.radius,\n\t\t\tcy: properties.radius,\n\t\t\tr: properties.radius,\n\t\t\tclass: 'dial__shape'\n\t\t},svg);\n\t\t// EDITABLE INDICATOR\n\t\tvar editCircle = createSVGElement('path',{\n\t\t\td: donutPath(properties.radius,properties.radius,properties.radius-4,properties.radius-8),\n\t\t\tclass: 'dial__editableIndicator',\n\t\t},svg);\n\t\t\n\t\t/*\n\t\t * Ticks\n\t\t */\n\t\tvar ticks = createSVGElement('g',{\n\t\t\tclass: 'dial__ticks'\t\n\t\t},svg);\n\t\tvar tickPoints = [\n\t\t\t[properties.radius-1, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1, properties.ticksInnerRadius],\n\t\t\t[properties.radius-1, properties.ticksInnerRadius]\n\t\t];\n\t\tvar tickPointsLarge = [\n\t\t\t[properties.radius-1.5, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1.5, properties.ticksOuterRadius],\n\t\t\t[properties.radius+1.5, properties.ticksInnerRadius+20],\n\t\t\t[properties.radius-1.5, properties.ticksInnerRadius+20]\n\t\t];\n\t\tvar theta = properties.tickDegrees/options.numTicks;\n\t\tvar tickArray = [];\n\t\tfor (var iTick=0; iTick<options.numTicks; iTick++) {\n\t\t\ttickArray.push(createSVGElement('path',{d:pointsToPath(tickPoints)},ticks));\n\t\t};\n\t\t\n\t\t/*\n\t\t * Labels\n\t\t */\n\t\tvar lblTarget = createSVGElement('text',{\n\t\t\tx: properties.radius,\n\t\t\ty: properties.radius,\n\t\t\tclass: 'dial__lbl dial__lbl--target'\n\t\t},svg);\n\t\tvar lblTarget_text = document.createTextNode('');\n\t\tlblTarget.appendChild(lblTarget_text);\n\t\t//\n\t\tvar lblTargetHalf = createSVGElement('text',{\n\t\t\tx: properties.radius + properties.radius/2.5,\n\t\t\ty: properties.radius - properties.radius/8,\n\t\t\tclass: 'dial__lbl dial__lbl--target--half'\n\t\t},svg);\n\t\tvar lblTargetHalf_text = document.createTextNode('5');\n\t\tlblTargetHalf.appendChild(lblTargetHalf_text);\n\t\t//\n\t\tvar lblAmbient = createSVGElement('text',{\n\t\t\tclass: 'dial__lbl dial__lbl--ambient'\n\t\t},svg);\n\t\tvar lblAmbient_text = document.createTextNode('');\n\t\tlblAmbient.appendChild(lblAmbient_text);\n\t\t//\n\t\tvar lblAway = createSVGElement('text',{\n\t\t\tx: properties.radius,\n\t\t\ty: properties.radius,\n\t\t\tclass: 'dial__lbl dial__lbl--away'\n\t\t},svg);\n\t\tvar lblAway_text = document.createTextNode('AWAY');\n\t\tlblAway.appendChild(lblAway_text);\n\t\t//\n\t\tvar icoLeaf = createSVGElement('path',{\n\t\t\tclass: 'dial__ico__leaf'\n\t\t},svg);\n\t\t\n\t\t/*\n\t\t * LEAF\n\t\t */\n\t\tvar leafScale = properties.radius/5/100;\n\t\tvar leafDef = [\"M\", 3, 84, \"c\", 24, 17, 51, 18, 73, -6, \"C\", 100, 52, 100, 22, 100, 4, \"c\", -13, 15, -37, 9, -70, 19, \"C\", 4, 32, 0, 63, 0, 76, \"c\", 6, -7, 18, -17, 33, -23, 24, -9, 34, -9, 48, -20, -9, 10, -20, 16, -43, 24, \"C\", 22, 63, 8, 78, 3, 84, \"z\"].map(function(x) {\n\t\t\treturn isNaN(x) ? x : x*leafScale;\n\t\t}).join(' ');\n\t\tvar translate = [properties.radius-(leafScale*100*0.5),properties.radius*1.5]\n\t\tvar icoLeaf = createSVGElement('path',{\n\t\t\tclass: 'dial__ico__leaf',\n\t\t\td: leafDef,\n\t\t\ttransform: 'translate('+translate[0]+','+translate[1]+')'\n\t\t},svg);\n\t\t\t\n\t\t/*\n\t\t * RENDER\n\t\t */\n\t\tfunction render() {\n\t\t\trenderAway();\n\t\t\trenderHvacState();\n\t\t\trenderTicks();\n\t\t\trenderTargetTemperature();\n\t\t\trenderAmbientTemperature();\n\t\t\trenderLeaf();\n\t\t}\n\t\trender();\n\n\t\t/*\n\t\t * RENDER - ticks\n\t\t */\n\t\tfunction renderTicks() {\n\t\t\tvar vMin, vMax;\n\t\t\tif (self.away) {\n\t\t\t\tvMin = self.ambient_temperature;\n\t\t\t\tvMax = vMin;\n\t\t\t} else {\n\t\t\t\tvMin = Math.min(self.ambient_temperature, self.target_temperature);\n\t\t\t\tvMax = Math.max(self.ambient_temperature, self.target_temperature);\n\t\t\t}\n\t\t\tvar min = restrictToRange(Math.round((vMin-options.minValue)/properties.rangeValue * options.numTicks),0,options.numTicks-1);\n\t\t\tvar max = restrictToRange(Math.round((vMax-options.minValue)/properties.rangeValue * options.numTicks),0,options.numTicks-1);\n\t\t\t//\n\t\t\ttickArray.forEach(function(tick,iTick) {\n\t\t\t\tvar isLarge = iTick==min || iTick==max;\n\t\t\t\tvar isActive = iTick >= min && iTick <= max;\n\t\t\t\tattr(tick,{\n\t\t\t\t\td: pointsToPath(rotatePoints(isLarge ? tickPointsLarge: tickPoints,iTick*theta-properties.offsetDegrees,[properties.radius, properties.radius])),\n\t\t\t\t\tclass: isActive ? 'active' : ''\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\t\n\t\t/*\n\t\t * RENDER - ambient temperature\n\t\t */\n\t\tfunction renderAmbientTemperature() {\n\t\t\tlblAmbient_text.nodeValue = Math.floor(self.ambient_temperature);\n\t\t\tif (self.ambient_temperature%1!=0) {\n\t\t\t\tlblAmbient_text.nodeValue += '⁵';\n\t\t\t}\n\t\t\tvar peggedValue = restrictToRange(self.ambient_temperature, options.minValue, options.maxValue);\n\t\t\tdegs = properties.tickDegrees * (peggedValue-options.minValue)/properties.rangeValue - properties.offsetDegrees;\n\t\t\tif (peggedValue > self.target_temperature) {\n\t\t\t\tdegs += 8;\n\t\t\t} else {\n\t\t\t\tdegs -= 8;\n\t\t\t}\n\t\t\tvar pos = rotatePoint(properties.lblAmbientPosition,degs,[properties.radius, properties.radius]);\n\t\t\tattr(lblAmbient,{\n\t\t\t\tx: pos[0],\n\t\t\t\ty: pos[1]\n\t\t\t});\n\t\t}\n\n\t\t/*\n\t\t * RENDER - target temperature\n\t\t */\n\t\tfunction renderTargetTemperature() {\n\t\t\tlblTarget_text.nodeValue = Math.floor(self.target_temperature);\n\t\t\tsetClass(lblTargetHalf,'shown',self.target_temperature%1!=0);\n\t\t}\n\t\t\n\t\t/*\n\t\t * RENDER - leaf\n\t\t */\n\t\tfunction renderLeaf() {\n\t\t\tsetClass(svg,'has-leaf',self.has_leaf);\n\t\t}\n\t\t\n\t\t/*\n\t\t * RENDER - HVAC state\n\t\t */\n\t\tfunction renderHvacState() {\n\t\t\tArray.prototype.slice.call(svg.classList).forEach(function(c) {\n\t\t\t\tif (c.match(/^dial--state--/)) {\n\t\t\t\t\tsvg.classList.remove(c);\n\t\t\t\t};\n\t\t\t});\n\t\t\tsvg.classList.add('dial--state--'+self.hvac_state);\n\t\t}\n\t\t\n\t\t/*\n\t\t * RENDER - away\n\t\t */\n\t\tfunction renderAway() {\n\t\t\tsvg.classList[self.away ? 'add' : 'remove']('away');\n\t\t}\n\t\t\n\t\t/*\n\t\t * Drag to control\n\t\t */\n\t\tvar _drag = {\n\t\t\tinProgress: false,\n\t\t\tstartPoint: null,\n\t\t\tstartTemperature: 0,\n\t\t\tlockAxis: undefined\n\t\t};\n\t\t\n\t\tfunction eventPosition(ev) {\n\t\t\tif (ev.targetTouches && ev.targetTouches.length) {\n\t\t\t\treturn [ev.targetTouches[0].clientX, ev.targetTouches[0].clientY];\n\t\t\t} else {\n\t\t\t\treturn [ev.x, ev.y];\n\t\t\t};\n\t\t}\n\t\t\n\t\tvar startDelay;\n\t\tfunction dragStart(ev) {\n\t\t\tstartDelay = setTimeout(function() {\n\t\t\t\tsetClass(svg, 'dial--edit', true);\n\t\t\t\t_drag.inProgress = true;\n\t\t\t\t_drag.startPoint = eventPosition(ev);\n\t\t\t\t_drag.startTemperature = self.target_temperature || options.minValue;\n\t\t\t\t_drag.lockAxis = undefined;\n\t\t\t},1000);\n\t\t};\n\t\t\n\t\tfunction dragEnd (ev) {\n\t\t\tclearTimeout(startDelay);\n\t\t\tsetClass(svg, 'dial--edit', false);\n\t\t\tif (!_drag.inProgress) return;\n\t\t\t_drag.inProgress = false;\n\t\t\tif (self.target_temperature != _drag.startTemperature) {\n\t\t\t\tif (typeof options.onSetTargetTemperature == 'function') {\n\t\t\t\t\toptions.onSetTargetTemperature(self.target_temperature);\n\t\t\t\t};\n\t\t\t};\n\t\t};\n\t\t\n\t\tfunction dragMove(ev) {\n\t\t\tev.preventDefault();\n\t\t\tif (!_drag.inProgress) return;\n\t\t\tvar evPos = eventPosition(ev);\n\t\t\tvar dy = _drag.startPoint[1]-evPos[1];\n\t\t\tvar dx = evPos[0] - _drag.startPoint[0];\n\t\t\tvar dxy;\n\t\t\tif (_drag.lockAxis == 'x') {\n\t\t\t\tdxy = dx;\n\t\t\t} else if (_drag.lockAxis == 'y') {\n\t\t\t\tdxy = dy;\n\t\t\t} else if (Math.abs(dy) > properties.dragLockAxisDistance) {\n\t\t\t\t_drag.lockAxis = 'y';\n\t\t\t\tdxy = dy;\n\t\t\t} else if (Math.abs(dx) > properties.dragLockAxisDistance) {\n\t\t\t\t_drag.lockAxis = 'x';\n\t\t\t\tdxy = dx;\n\t\t\t} else {\n\t\t\t\tdxy = (Math.abs(dy) > Math.abs(dx)) ? dy : dx;\n\t\t\t};\n\t\t\tvar dValue = (dxy*getSizeRatio())/(options.diameter)*properties.rangeValue;\n\t\t\tself.target_temperature = roundHalf(_drag.startTemperature+dValue);\n\t\t}\n\t\t\n\t\tsvg.addEventListener('mousedown',dragStart);\n\t\tsvg.addEventListener('touchstart',dragStart);\n\t\t\n\t\tsvg.addEventListener('mouseup',dragEnd);\n\t\tsvg.addEventListener('mouseleave',dragEnd);\n\t\tsvg.addEventListener('touchend',dragEnd);\n\t\t\n\t\tsvg.addEventListener('mousemove',dragMove);\n\t\tsvg.addEventListener('touchmove',dragMove);\n\t\t//\n\t\t\n\t\t/*\n\t\t * Helper functions\n\t\t */\n\t\tfunction restrictTargetTemperature(t) {\n\t\t\treturn restrictToRange(roundHalf(t),options.minValue,options.maxValue);\n\t\t}\n\t\t\n\t\tfunction angle(point) {\n\t\t\tvar dx = point[0] - properties.radius;\n\t\t\tvar dy = point[1] - properties.radius;\n\t\t\tvar theta = Math.atan(dx/dy) / (Math.PI/180);\n\t\t\tif (point[0]>=properties.radius && point[1] < properties.radius) {\n\t\t\t\ttheta = 90-theta - 90;\n\t\t\t} else if (point[0]>=properties.radius && point[1] >= properties.radius) {\n\t\t\t\ttheta = 90-theta + 90;\n\t\t\t} else if (point[0]<properties.radius && point[1] >= properties.radius) {\n\t\t\t\ttheta = 90-theta + 90;\n\t\t\t} else if (point[0]<properties.radius && point[1] < properties.radius) {\n\t\t\t\ttheta = 90-theta+270;\n\t\t\t}\n\t\t\treturn theta;\n\t\t};\n\t\t\n\t\tfunction getSizeRatio() {\n\t\t\treturn options.diameter / targetElement.clientWidth;\n\t\t}\n\t\t\n\t};\n})();\n\n/* ==== */\nvar initializing = true;\n\n(function(scope) {\n    var nest = new thermostatDial(document.getElementById('thermostat'),{\n    \tonSetTargetTemperature: function(v) {\n    \t    var p = {\n    \t        \"ambient_temperature\":nest.ambient_temperature,\n    \t        \"target_temperature\":v,\n    \t        \"hvac_state\":nest.hvac_state,\n    \t        \"has_leaf\": nest.has_leaf,\n    \t        \"away\":nest.away\n    \t    };\n    \t\tscope.send({topic: \"target_temperature\", payload: p});\n    \t}\n    });\n    \n    scope.$watch('msg', function(data) {\n        if (initializing) {\n            initializing = false;\n            } else {\n            nest.ambient_temperature = data.payload.ambient_temperature || 0;\n            nest.target_temperature = data.payload.target_temperature || 0;\n            nest.hvac_state = data.payload.hvac_state || \"off\";\n            nest.has_leaf = data.payload.has_leaf || false;\n            nest.away = data.payload.away || false;\n        }\n        \n    });\n})(scope);\n\n</script>",
        "storeOutMessages": true,
        "fwdInMessages": false,
        "templateScope": "local",
        "x": 1330,
        "y": 140,
        "wires": [
            [
                "467aaa78.95f1f4",
                "1f12ab40.b87ff5"
            ]
        ]
    },
    {
        "id": "a72f2ad4.69d9e8",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "ambient_temperature",
        "func": "msg.topic = 'ambient_temperature';\nglobal.set(\"nest1_ambient_temperature\", msg.payload);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 1000,
        "y": 20,
        "wires": [
            [
                "628609c9.8ce888"
            ]
        ]
    },
    {
        "id": "80cc63be.fe86",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "target_temperature",
        "func": "msg.topic = 'target_temperature';\nglobal.set(\"nest1_target_temperature\",msg.payload);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 990,
        "y": 60,
        "wires": [
            [
                "628609c9.8ce888"
            ]
        ]
    },
    {
        "id": "e02e558f.b9f008",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "hvac_state",
        "func": "msg.topic = \"hvac_state\";\nglobal.set(\"nest1_hvac_state\",msg.payload);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 970,
        "y": 260,
        "wires": [
            [
                "628609c9.8ce888"
            ]
        ]
    },
    {
        "id": "191a74f4.d7ca9b",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "has_leaf",
        "func": "msg.topic = \"has_leaf\";\nglobal.set(\"nest1_has_leaf\", msg.payload);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 960,
        "y": 100,
        "wires": [
            [
                "628609c9.8ce888"
            ]
        ]
    },
    {
        "id": "99e356c2.1432e8",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "away",
        "func": "msg.topic = \"away\";\nglobal.set(\"nest1_away\", msg.payload);\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 950,
        "y": 140,
        "wires": [
            [
                "628609c9.8ce888"
            ]
        ]
    },
    {
        "id": "1f12ab40.b87ff5",
        "type": "debug",
        "z": "3fd94120.26662e",
        "name": "",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "true",
        "x": 1470,
        "y": 140,
        "wires": []
    },
    {
        "id": "628609c9.8ce888",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "Data",
        "func": "if (msg.topic == \"target_temperature\") {\n    global.set(\"nest1_target_temperature\",msg.payload);\n}\nmsg.topic = \"update\";\nvar data = {\n    'ambient_temperature': global.get(\"nest1_ambient_temperature\") || 20,\n    'target_temperature': global.get(\"nest1_target_temperature\") || 21,\n    'hvac_state': global.get(\"nest1_hvac_state\") || 'off',\n    'has_leaf': global.get(\"nest1_has_leaf\") || 'false',\n    'away': global.get(\"nest1_away\") || 'false'\n}\nmsg.payload = data;\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 1150,
        "y": 140,
        "wires": [
            [
                "f8dccf8f.c4f73"
            ]
        ]
    },
    {
        "id": "467aaa78.95f1f4",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "set target_temperature_low",
        "func": "if (msg.topic == \"target_temperature\") {\n    global.set(\"nest1_target_temperature\",msg.payload.target_temperature);\n    msg.payload = msg.payload.target_temperature\n    return msg;\n}\n",
        "outputs": 1,
        "noerr": 0,
        "x": 1400,
        "y": 180,
        "wires": [
            [
                "b8fd7215.833f5",
                "fcccd903.16da38"
            ]
        ]
    },
    {
        "id": "f890d1b8.e03",
        "type": "mqtt in",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/current_temperature/state",
        "qos": "0",
        "datatype": "auto",
        "broker": "af93d7b.6b97c28",
        "x": 290,
        "y": 20,
        "wires": [
            [
                "a72f2ad4.69d9e8"
            ]
        ]
    },
    {
        "id": "7c36c986.3da338",
        "type": "mqtt in",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/target_temperature_low/state",
        "qos": "0",
        "datatype": "auto",
        "broker": "af93d7b.6b97c28",
        "x": 300,
        "y": 60,
        "wires": [
            [
                "80cc63be.fe86",
                "1a1a896f.46daf7"
            ]
        ]
    },
    {
        "id": "b8bc5278.7b53c",
        "type": "mqtt in",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/mode/state",
        "qos": "0",
        "datatype": "auto",
        "broker": "af93d7b.6b97c28",
        "x": 250,
        "y": 180,
        "wires": [
            [
                "1039ce32.169602"
            ]
        ]
    },
    {
        "id": "77db7367.2b3a6c",
        "type": "mqtt in",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/away/state",
        "qos": "0",
        "datatype": "auto",
        "broker": "af93d7b.6b97c28",
        "x": 250,
        "y": 140,
        "wires": [
            [
                "c5b84615.f848f8"
            ]
        ]
    },
    {
        "id": "c5b84615.f848f8",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "ON -> true / OFF -> false",
        "func": "if(msg.payload == \"ON\") msg.payload = \"true\";\nelse msg.payload = \"false\";\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 750,
        "y": 140,
        "wires": [
            [
                "99e356c2.1432e8"
            ]
        ]
    },
    {
        "id": "b8fd7215.833f5",
        "type": "mqtt out",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/target_temperature_low/command",
        "qos": "0",
        "retain": "true",
        "broker": "af93d7b.6b97c28",
        "x": 1880,
        "y": 180,
        "wires": []
    },
    {
        "id": "fcccd903.16da38",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "set target_temperature_high",
        "func": "//msg.payload = msg.payload + 1;\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 1400,
        "y": 220,
        "wires": [
            [
                "8303538e.e2b22",
                "7524b3a4.e4e01c"
            ]
        ]
    },
    {
        "id": "8303538e.e2b22",
        "type": "mqtt out",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/target_temperature_high/command",
        "qos": "0",
        "retain": "true",
        "broker": "af93d7b.6b97c28",
        "x": 1890,
        "y": 220,
        "wires": []
    },
    {
        "id": "37d88490.f68b2c",
        "type": "mqtt in",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/action/state",
        "qos": "0",
        "datatype": "auto",
        "broker": "af93d7b.6b97c28",
        "x": 250,
        "y": 220,
        "wires": [
            [
                "3f722918.13d6a6",
                "bd136b24.f423d8"
            ]
        ]
    },
    {
        "id": "3f722918.13d6a6",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "idle -> off / heating -> heating",
        "func": "if(msg.payload == \"idle\") msg.payload = \"off\";\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 760,
        "y": 260,
        "wires": [
            [
                "e02e558f.b9f008"
            ]
        ]
    },
    {
        "id": "1039ce32.169602",
        "type": "ui_text",
        "z": "3fd94120.26662e",
        "group": "810b9f10.b5cba",
        "order": 0,
        "width": 0,
        "height": 0,
        "name": "",
        "label": "Mode",
        "format": "{{msg.payload | uppercase}}",
        "layout": "row-spread",
        "x": 690,
        "y": 180,
        "wires": []
    },
    {
        "id": "bd136b24.f423d8",
        "type": "ui_text",
        "z": "3fd94120.26662e",
        "group": "810b9f10.b5cba",
        "order": 1,
        "width": 0,
        "height": 0,
        "name": "",
        "label": "Action",
        "format": "{{msg.payload | uppercase}}",
        "layout": "row-spread",
        "x": 690,
        "y": 220,
        "wires": []
    },
    {
        "id": "1a1a896f.46daf7",
        "type": "function",
        "z": "3fd94120.26662e",
        "name": "target_temperature < 20",
        "func": "if(msg.payload < 20)\n    msg.payload = true;\nreturn msg;",
        "outputs": 1,
        "noerr": 0,
        "x": 750,
        "y": 100,
        "wires": [
            [
                "191a74f4.d7ca9b"
            ]
        ]
    },
    {
        "id": "7524b3a4.e4e01c",
        "type": "template",
        "z": "3fd94120.26662e",
        "name": "Set mode auto",
        "field": "payload",
        "fieldType": "msg",
        "format": "handlebars",
        "syntax": "mustache",
        "template": "auto",
        "output": "str",
        "x": 1360,
        "y": 260,
        "wires": [
            [
                "a961f18d.16979"
            ]
        ]
    },
    {
        "id": "a961f18d.16979",
        "type": "mqtt out",
        "z": "3fd94120.26662e",
        "name": "",
        "topic": "matrix_shelly_thermostat/climate/matrix_shelly_thermostat/mode/command",
        "qos": "0",
        "retain": "true",
        "broker": "af93d7b.6b97c28",
        "x": 1830,
        "y": 260,
        "wires": []
    },
    {
        "id": "810b9f10.b5cba",
        "type": "ui_group",
        "z": "",
        "name": "Nest",
        "tab": "35fb077c.ddc6c8",
        "order": 1,
        "disp": false,
        "width": "7",
        "collapse": false
    },
    {
        "id": "af93d7b.6b97c28",
        "type": "mqtt-broker",
        "z": "",
        "name": "",
        "broker": "test.mosquitto.org",
        "port": "1883",
        "clientid": "",
        "usetls": false,
        "compatmode": true,
        "keepalive": "60",
        "cleansession": true,
        "birthTopic": "",
        "birthQos": "0",
        "birthRetain": "false",
        "birthPayload": "",
        "closeTopic": "",
        "closeQos": "0",
        "closeRetain": "false",
        "closePayload": "",
        "willTopic": "",
        "willQos": "0",
        "willRetain": "false",
        "willPayload": ""
    },
    {
        "id": "35fb077c.ddc6c8",
        "type": "ui_tab",
        "z": "",
        "name": "Matrix Shelly Thermostat",
        "icon": "dashboard",
        "order": 3
    }
]

Credits

Alessandro Polselli

Alessandro Polselli

3 projects • 16 followers

Comments