From da0f5306db525514bcd160c9be0b851c444486eb Mon Sep 17 00:00:00 2001 From: Nathaniel Wesley Filardo Date: Mon, 31 Jul 2017 20:15:05 -0400 Subject: [PATCH] Initial checkin --- README.rst | 130 ++++++++++++++++++++++++++++++++++++ init2.lua | 14 ++++ init3.lua | 63 ++++++++++++++++++ pushall.sh | 53 +++++++++++++++ thermostat.lua | 174 +++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 434 insertions(+) create mode 100644 README.rst create mode 100644 init2.lua create mode 100644 init3.lua create mode 100755 pushall.sh create mode 100644 thermostat.lua diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c134a33 --- /dev/null +++ b/README.rst @@ -0,0 +1,130 @@ +Software +######## + +MQTT Topics +=========== + +See ``init3.lua`` and calls to ``logdata``, but in summary: + +* Topics set by the device: + + * ``.../boot`` -- heartbeat, boot announcement, and LWT. + * ``.../th`` -- temperature probe result + * ``.../zz`` -- temporary debug topic + +* Topics set by the user/controller: + + * ``.../fan`` -- ``on`` or ``1`` to force fan on, otherwise automatic. + * ``.../mode`` -- ``off``, ``cool``, ``heat``, or ``emht`` + * ``.../target`` -- the target temperature, in half-degrees Celsius + +.. note:: + + Temperatures are reported and consumed (in ``.../target``) in half + degrees Celsius. (Funky, ain't it?) + +Control-side +============ + +I suggest a wrapper shell script, possibly named ``thermostat.sh`` or +something, along these lines, filling in the ``...`` appropriately:: + + #!/bin/bash + mosquitto_pub -h ... -u ... -P ... -t ".../$1" -m "$2" "${@:3}" + +Then it's a matter of ``./thermostat.sh fan on``, ``./thermostat.sh mode +cool``, or ``./thermostat.sh target 50``. + +Help! My broker's down! My network's down! +============================================ + +Don't panic! If your network is still online, you can telnet in to the +device. Failing that, the serial console is still viable (best grab the +pins with your own TTL adapter, as the nodemcu board has its own voltage +regulator and will attempt to power the thermostat's 3.3V rail from USB, +potentially fighting the other voltage regulator!). Failing that, just put +the original thermostat back, yeah? + +In any case, you can simulate the receipt of a MQTT message from the Lua +interpreter prompt (or ``diag exec`` via ``telnetd``). Try one of these:: + + nwfnet.onmqtt["th"](mqc, mqttTargTopic, "60" ) + nwfnet.onmqtt["th"](mqc, mqttModeTopic, "off") + nwfnet.onmqtt["th"](mqc, mqttFanTopic , "on" ) + +Hardware +######## + +Peripheral Setup +================ + ++------+----+-----------------------------------------------------------+ +| GPIO | IX | | ++======+====+===========================================================+ +| 16 | 0 | not used but somewhat special; "XPD" | ++------+----+-----------------------------------------------------------+ +| 5 | 1 | 1-Wire | ++------+----+-----------------------------------------------------------+ +| 4 | 2 | I2C SDA | ++------+----+-----------------------------------------------------------+ +| 0 | 3 | I2C SCL / pull 0 for bootloader / bounce low to stop init | ++------+----+-----------------------------------------------------------+ +| 2 | 4 | WS2812, by necessity of hardware | ++------+----+-----------------------------------------------------------+ +| 14 | 5 | not used, but reserved for PCF IRQ | ++------+----+-----------------------------------------------------------+ +| 12 | 6 | not used | ++------+----+-----------------------------------------------------------+ +| 13 | 7 | not used | ++------+----+-----------------------------------------------------------+ +| 15 | 8 | Pull low to select boot mode | ++------+----+-----------------------------------------------------------+ + +.. note:: + + * GPIO2 (ix 4) is also the onboard LED + * GPIOs 1,3 (ixes 9,10) are used for serial UART + * GPIOs 6-11 (incl. 9,10, ixes 11,12) are used in chatting with the flash chip + +I2C Peripherals +--------------- + +We have a PCF8574A attached to us on the I2C bus at address 0x38. Its IO +lines are used as follows: + ++----+-------------------+ +| P0 | Relay 1: Fan | ++----+-------------------+ +| P1 | Relay 2: AC | ++----+-------------------+ +| P2 | Relay 3: Heat | ++----+-------------------+ +| P3 | Relay 4: Em Heat | ++----+-------------------+ +| P4 | | ++----+-------------------+ +| P5 | | ++----+-------------------+ +| P6 | | ++----+-------------------+ +| P7 | | ++----+-------------------+ + +1W Peripherals +-------------- + +We have a DS1820 temperature probe attached to the 1Wire bus. This device +calls itself 1013878a02080098 in my case. + +Internals +========= + +RTC RAM Slots +------------- + +* Slots 0 - 9 are used by the RTC itself +* Slots 10 - 20 are used by the RTC fifo for metadata +* Slots 21 - 31 are unused +* Slots 32 - 128 are used by the RTC fifo for its journal + + diff --git a/init2.lua b/init2.lua new file mode 100644 index 0000000..06d1940 --- /dev/null +++ b/init2.lua @@ -0,0 +1,14 @@ +-- It's early in boot, so we have plenty of RAM. Compile +-- the rest of the firmware from source if it's there. +dofile("compileall.lc") + +-- telnetd overlay +tcpserv = net.createServer(net.TCP, 120) +tcpserv:listen(23,function(k) + local telnetd = dofile "telnetd.lc" + telnetd.on["conn"] = function(k) k(string.format("%s [NODE-%06X]",mqcu,node.chipid())) end + telnetd.server(k) +end) + +print("Startup") +dofile("init3.lc") diff --git a/init3.lua b/init3.lua new file mode 100644 index 0000000..674cd2c --- /dev/null +++ b/init3.lua @@ -0,0 +1,63 @@ +-- local configuration +owpin = 1 +local mqttHeartTopic = "lcn/therm/boot" +local mqttHeartTick = 600000 +mqttTargTopic = "lcn/therm/target" +mqttModeTopic = "lcn/therm/mode" +mqttFanTopic = "lcn/therm/fan" +mqttPubRoot = "lcn/therm/" + +-- modules +nwfnet = require "nwfnet" +mqc, mqcu = dofile("nwfmqtt.lc").mkclient("nwfmqtt.conf") + +mqcCan = false + +-- rtcfifo conditional init +if rtcfifo.ready() == 0 then rtcfifo.prepare() end + +-- timers +tq = (dofile "tq.lc")(tmr.create()) + +-- setup peripherals +ow.setup(owpin) +i2c.setup(0,2,3,i2c.SLOW) + +-- hook registry, MQTT connection management +local mqtt_beat_cancel +local mqtt_reconn_poller +local function mqtt_reconn() + mqtt_reconn_poller = tq:queue(30000,mqtt_reconn) + mqc:close(); dofile("nwfmqtt.lc").connect(mqc,"nwfmqtt.conf") +end + +nwfnet.onnet["init"] = function(e,c) + if e == "mqttdscn" and c == mqc then + if mqtt_beat_cancel then mqtt_beat_cancel(); mqtt_beat_cancel = nil end + if not mqtt_reconn_poller then mqtt_reconn() end + mqcCan = false + elseif e == "mqttconn" and c == mqc then + if mqtt_reconn_poller then tq:dequeue(mqtt_reconn_poller); mqtt_reconn_poller = nil end + if not mqtt_beat_cancel then mqtt_beat_cancel = dofile("nwfmqtt.lc").heartbeat(mqc,mqttHeartTopic,tq,mqttHeartTick) end + mqc:publish(mqttHeartTopic,"alive",1,1) + mqc:subscribe(mqttTargTopic,1) + mqc:subscribe(mqttModeTopic,1) + mqc:subscribe(mqttFanTopic ,1) + mqcCan = true + elseif e == "wstagoip" then + if not mqtt_reconn_poller then mqtt_reconn() end + end +end + +-- data logging +function logdata(v,e,n) + local t = rtctime.get() + if mqcCan then mqc:publish(mqttPubRoot..n,sjson.encode({ ['t']=t, ['v']=v, ['e']=e }),1,1) end + if v then rtcfifo.put(t,v,e,n) end +end + +-- go online +dofile("nwfnet-go.lc") + +-- do thermostat stuff +dofile("thermostat.lc") diff --git a/pushall.sh b/pushall.sh new file mode 100755 index 0000000..3ac1181 --- /dev/null +++ b/pushall.sh @@ -0,0 +1,53 @@ +#!/bin/zsh + +set -e -u + +. ./core/host/pushcommon.sh + +pushsrc() { + dopushcompile core/util/compileall.lua + dopushlua core/net/nwfmqtt.lua + dopushlua core/util/ow-ds18b20.lua + dopushlua core/util/i2cu.lua + dopushlua thermostat.lua + dopushlua init3.lua + dopushcompile init2.lua +} + +if [ -n "${2:-}" ]; then + if [ -d $2 ]; then CONFDIR=$2 + else echo "Not a directory: $2"; exit 1 + fi +fi + +pushconf() { + if [ -z "${CONFDIR:-}" ]; then + echo "Asked to push config without specifying?" + exit 1 + fi + for f in ${CONFDIR}/*; do + dopushtext "$f" + done +} + +case "${1:-}" in + all) + pushconf + pushsrc + ./core/host/pushinit.sh + ;; + both) + pushconf + pushsrc + ;; + src) + pushsrc + ;; + conf) + pushconf + ;; + *) + echo "Please specify push mode: {conf,src,both,all}" + exit 1 + ;; +esac diff --git a/thermostat.lua b/thermostat.lua new file mode 100644 index 0000000..d2112a8 --- /dev/null +++ b/thermostat.lua @@ -0,0 +1,174 @@ +-- local configuration (TODO: pull in from json?) +local owtherm = encoder.fromHex("1013878a02080098") +local pcfaddr = 0x38 +local pcfhigh = 0xF0 +local tcPollIval = 9000 -- + 1 second for 1w read, too +local tcVWin = 10 -- longest sliding sampling window +local tcFOffDly = 180000 -- run the fan after turning of H/AC + +-- remote configuration (set by MQTT) +local tctarget = 55 +local tcmode = "off" -- "off" "cool" "heat" "emht" +local tcfan = false -- should we keep the fan on? + +-- state +local driving = false -- are we driving? +local fanOffDelayTQ = nil -- a tq object for nixing the fan +local vdenom = 0 -- window votes elapsed +local vnum = 0 -- window votes accumulated +local verr = 0 -- errors during voting window + +local function resetTempAcc() + verr = 0 + vdenom = 0 + vnum = 0 +end + +local function mkRelays(mode, drive, forcefan) + local v = 0xF -- "off" + + if drive then + if mode == "cool" then v = 0xC + elseif mode == "heat" then v = 0xA + elseif mode == "emht" then v = 0x6 + end + end + if forcefan then v = bit.band(v, 0xE) end + + return v +end + +local i2cu = require "i2cu" + +function doRelays() + local v = mkRelays(tcmode, driving, tcfan) + i2cu.writen(pcfaddr, string.char(bit.bor(pcfhigh, v))) + return v -- XXX debug +end + +nwfnet.onmqtt["th"] = function(c,t,m) + if not m then return end + if t == mqttTargTopic then + driving = false; resetTempAcc() + tctarget = tonumber(m) or tctarget + elseif t == mqttModeTopic then + driving = false; resetTempAcc() + tcmode = m + elseif t == mqttFanTopic then + nextFan = (m == "on" or m == "1") + + if fanOffDelayTQ == nil then + -- we aren't about to automate the fan off, so go ahead and let the + -- setting have immediate effect + tcfan = nextFan + else + -- we are about to turn off the fan anyway; is that what we should do? + if nextFan then -- no, keep the fan on + tq:dequeue(fanOffDelayTQ) + fanOffDelayTQ = nil + tcfan = nextFan + -- else let the callback turn it off for us + end + end + else return -- not for us? + end + + doRelays() +end + +local function startDrive() + driving = true + -- nix any future fan-off we might have had scheduled + if fanOffDelayTQ ~= nil then tq:dequeue(fanOffDelayTQ); fanOffDelayTQ = nil end +end + +local function stopDrive() + driving = false + -- if we aren't forcing the fan on, schedule it to be turned off later + if not tcfan and fanOffDelayTQ == nil then + tcfan = true + fanOffDelayTQ = tq:queue(tcFOffDly, function() + fanOffDelayTQ = nil + tcfan = false + doRelays() + end) + end +end + +local function therm_res(t) + logdata(t,0,"th") + + local m + if driving then m = "S-on" else m = "S-off" end + + -- no action needed, will have been set when mode set; we may have left + -- the polling loop active to continue to log data and all that + if tcmode == "off" then m = "Off" + elseif tcmode == "fan" then m = "Fan" + else + -- OK, maybe we need to act + + vdenom = vdenom + 1 + + -- push a little past the target in the direction indicated + local thresh = tctarget + if driving then + if tcmode == "cool" then thresh = thresh - 2 + elseif tcmode == "heat" then thresh = thresh + 2 + end + end + + -- Vote to engage or stay on + if t == nil then verr = verr + 1 + elseif tcmode == "cool" and t > thresh then vnum = vnum + 1 + elseif tcmode == "heat" and t < thresh then vnum = vnum + 1 + elseif tcmode == "emht" and t < thresh then vnum = vnum + 1 + end + + if verr >= 3 then + -- This window is definitely an error; shutdown now + m = "Error" + stopDrive() + resetTempAcc() + elseif vnum >= 7 then + -- This window is definitely voting for driving; start now + if driving + then m = "Keepon" + else m = "Drive"; startDrive() + end + resetTempAcc() + elseif vdenom >= tcVWin - 1 and vnum <= 2 then + -- This window is definitely voting to shut down + if driving + then m = "Cancel"; stopDrive() + else m = "Keepoff" + end + resetTempAcc() + elseif vdenom >= tcVWin then + -- This window has elapsed with no conclusion reached. + resetTempAcc() + end + end + + local r = doRelays() + + -- XXX debug + if mqcCan then + mqc:publish(mqttPubRoot.."zz", + sjson.encode({ ['m']=m, ['r']=r, ['h']=node.heap(), + ['f']=tempAccFan, ['ftq']=(fanOffDelayTQ ~= nil), + ['c']=vdenom, ['v']=vnum, ['e']=verr }), + 1,1) + end + +end +local function thermpoller() + dofile("ow-ds18b20.lc")(tq, owpin, owtherm, 0, + function(r) + therm_res(r) + therm_poll_cancel = tq:queue(tcPollIval,thermpoller) + end) +end + +doRelays() -- turn everything off at boot +thermpoller() -- start polling