--- /dev/null
+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
+
+
--- /dev/null
+-- 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")
--- /dev/null
+-- 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")
--- /dev/null
+#!/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
--- /dev/null
+-- 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