--- /dev/null
+########################################
+Capture The Flag With Stuff Glyph Module
+########################################
+
+This is a hardware device designed to assist the `CMU KGB
+<http://www.cmukgb.org/>`_ game of `Capture The Flag With Stuff
+<http://www.cmukgb.org/activities/ctfws.php>`_.
+
+MQTT
+####
+
+Topic Tree
+==========
+
+All numbers herein are base-10 encoded and devoid of leading zeros for ease
+of parsing.
+
+Centrally-set topics:
+
+* ``ctfws/game/config`` the string ``none`` or a whitespace-separated text field:
+ * ``starttime`` -- NTP seconds indicating start state
+ * ``setupduration`` -- setup duration, in seconds
+ * ``rounds`` -- number of rounds
+ * ``roundduration`` -- seconds per round
+ * ``nflags`` -- number of flags per team
+ * any additional fields are to be ignored.
+
+* ``ctfws/game/flags`` -- whitespace-separated text field:
+ * ``red`` -- red team flag capture count (int)
+ * ``yel`` -- yellow team flag capture count (int)
+ * any additional fields are to be ignored.
+
+* ``ctfws/game/endtime`` -- a single number, denoting NTP seconds of a
+ forced game end. If this is larger than the last ``starttime`` gotten
+ in a ``config`` message, then the game is considered over.
+
+* ``ctfws/game/message`` -- Message to be displayed everywhere
+
+* ``ctfws/game/message/player`` -- Message to be displayed specifically
+ to players, if they ever come to have their own devices (e.g. apps)
+
+* ``ctfws/game/message/jail`` -- Message to be displayed specifically at
+ jail glyph units. For the moment, that's all of them, but maybe we
+ want to allow other things in the future.
+
+Messages should be set persistent so that devices that reboot or lose their
+connection will display the right thing upon reconnection.
+
+.. todo:: Flag bitmaps?
+
+ Do we want to publish a bitmap of captured flags or are we happy with
+ counts?
+
+Optionally, we may wish to grant read-only views of the above topics to a
+guest account for a hypothetical CtFwS app.
+
+Device-set topics:
+
+* ``ctfws/dev/$DEVICENAME/beat``
+ * one of ``alive``, ``beat``, or ``dead`` (LWT; no further fields)
+ * ``time`` (UNIX time, from local clock)
+ * ``ap`` (MAC addr)
+ * any additional fields are to be ignored.
+
+ The device should publish ``alive`` at gain of MQTT connectivity and
+ having registered a last will and testament to set the message ``dead``.
+ Thereafter, it should periodically publish to ``beat`` messages.
+
+ACL Configuration
+=================
+
+For example::
+
+ # global read permissions
+ pattern read ctfws/#
+
+ # master write to all ctfws parameters
+ user ctfwsmaster
+ pattern write ctfws/game/#
+
+ # Per-device permissions to post to their own sub-trees.
+ user ctfwsdev1
+ pattern write ctfws/dev/%u/#
+
+Example Command Line Usage
+==========================
+
+To watch what's going on in the world::
+
+ mosquitto_sub -h $MQTT_SERVER -u ctfwsmaster -P asdf -q 1 -t ctfws/\# -v
+
+To send MQTT messages, try variants of these. Note that in all cases, we
+set messages persistent so that devices that (re)connect mid-way into a game
+get the latest messages automatically.
+
+ * To start a game::
+
+ mosquitto_pub -h $MQTT_SERVER -u ctfwsmaster -P asdf -q 1 -t ctfws/game/flags -r -m '0 0'
+ mosquitto_pub -h $MQTT_SERVER -u ctfwsmaster -P asdf -q 1 -t ctfws/game/config -r -m `date +%s`' 900 3 900 10'
+
+ * To post information::
+
+ mosquitto_pub -h $MQTT_SERVER -u ctfwsmaster -P asdf -q 1 -t ctfws/game/flags -r -m '1 2'
+ mosquitto_pub -h $MQTT_SERVER -u ctfwsmaster -P asdf -q 1 -t ctfws/game/message -r -m 'Red team captured a flag!'
+
+ * To end a game::
+
+ mosquitto_pub -h $MQTT_SERVER -u ctfwsmaster -P asdf -q 1 -t ctfws/game/endtime -r -m `date +%s`
+
+Jail Glyph Timers
+#################
+
+At each jail glyph, we would install a device consisting of
+
+* an ESP8266 module
+* a beeper
+* a LCD (probably a small I2C graphics display or 4x20 text or similar)
+* a small lipo battery (and charging circuitry, likely)
+
+This device is not intended to be interactive in any way; turn it on and let
+it do its thing.
+
+The device would join CMU's wireless network, perform SNTP to get an
+accurate clock, and associate with a MQTT server managed by the KGB to
+receive updates about the game for display, namely:
+
+* game configuration (setup duration, N rounds M seconds long)
+* game start time
+* team scores / flag capture counts
+* game over
+
+It's likely beneficial (or at least, not harmful) for the devices to
+heartbeat into their own MQTT topics as well, and may wish to announce which
+AP they're associated with.
+
+The device should otherwise function more or less as a glorified stopwatch
+under centralized control.
+
+BOM
+===
+
+One possible instantiation, just as a baseline:
+
++---+-------------------------------------------------------------+-------+
+| 1 | NodeMCU board (ESP8266+USB serial) | 3.00 |
++---+-------------------------------------------------------------+-------+
+| 1 | 2.5Ah USB power stick | 6.00 |
++---+-------------------------------------------------------------+-------+
+| 1 | 4x20 LCD display | 7.00 |
++---+-------------------------------------------------------------+-------+
+| 1 | Buzzer | 1.00 |
++---+-------------------------------------------------------------+-------+
+| 1 | Small breadboard | 1.00 |
++---+-------------------------------------------------------------+-------+
+| | Jumper wire | 1.00 |
++---+-------------------------------------------------------------+-------+
+| | TOTAL | 19.00 |
++---+-------------------------------------------------------------+-------+
+
+Character Display
+=================
+
+Setup time display::
+
+ 0 1
+ 01234567890123456789
+ SETUP : MM:SS.s
+ NN⚑: R=0 Y=0
+ messagemessagemessag
+ START IN : MM:SS.s
+
+Steady state display::
+
+ 0 1
+ 01234567890123456789
+ ROUND r/R : MM:SS.s
+ NN⚑: R=NN Y=NN
+ messagemessagemessag
+ JAILBREAK : MM:SS.s
+
+Last round display::
+
+ 0 1
+ 01234567890123456789
+ ROUND r/R : MM:SS.s
+ NN⚑: R=NN Y=NN
+ messagemessagemessag
+ GAME END : MM:SS.s
+
+Game over::
+
+ 0 1
+ 01234567890123456789
+ GAME OVER
+ NN⚑: R=NN Y=NN
+ messagemessagemessag
+ GAME OVER
+
+Game not configured::
+
+ 0 1
+ 01234567890123456789
+ GAME NOT CONFIGURED
+
+ messagemessagemessag
+ GAME NOT CONFIGURED
+
+Configuration Files
+===================
+
+* ``nwfnet.conf`` has details of how to get connectivity to the network.
+* ``nwfnet.conf2`` sets the SNTP server to use
+* ``nwfmqtt.conf`` sets the MQTT server and credentials
--- /dev/null
+-- fields:
+-- dl_* fields are "drawn last", used to avoid writing things that are
+-- already correct on screen. They can be set to nil to force refresh.
+-- Certain things (flags, messages) are considered rare enough that we
+-- don't cache like this.
+--
+-- dl_round
+-- dl_elapsed
+-- dl_remain
+
+local function drawDScond(max, last, decisec, thresh)
+ return (max >= thresh) and ((last == nil) or math.floor(last / thresh) ~= math.floor(decisec / thresh))
+end
+
+-- Save I2C bandwidth by only drawing what we have to, to some approximation
+local function drawDS(lcd, row, col, max, last, decisec)
+ -- If we wanted to support hour-long times, we might do something like
+ -- this. As we don't, at the moment...
+ -- if drawDScond(max, last, decisec, 36000) then -- hours and all the way down
+ -- lcd:put(lcd:locate(row,col), string.format("%02d:%02d:%02d.%d",
+ -- decisec/3600, decisec/600, (decisec/10)%60, decisec%10))
+ -- else
+ if drawDScond(max, last, decisec, 600) then -- minutes, seconds, and deci
+ lcd:put(lcd:locate(row,col), string.format("%02d:%02d.%d", decisec/600, (decisec/10)%60, decisec%10))
+ elseif drawDScond(max, last, decisec, 10) then -- seconds and deci
+ lcd:put(lcd:locate(row,col+3), string.format("%02d.%d", (decisec/10)%60, decisec%10))
+ else -- just deci
+ lcd:put(lcd:locate(row,col+6), string.format("%d",decisec%10))
+ end
+end
+
+local function drawNoGame(lcd, msg)
+ local k,r; for k,r in pairs({0,3}) do
+ lcd:put(lcd:locate(r,0), " ")
+ lcd:put(lcd:locate(r,(20-#msg)/2), msg)
+ end
+end
+
+local function drawSteadyTopLine(self,rix,maxt,ela)
+ local lcd = self.lcd
+ local ctfws = self.ctfws
+ if self.dl_elapsed == nil then
+ lcd:put(lcd:locate(0,0), " ")
+ if rix == 0 then
+ lcd:put(lcd:locate(0,0), "SETUP :")
+ else
+ if ctfws.rounds >= 10
+ then lcd:put(lcd:locate(0,0), string.format("RND %2d/%2d :",rix,ctfws.rounds))
+ else lcd:put(lcd:locate(0,0), string.format("ROUND %d/%d :",rix,ctfws.rounds))
+ end
+ end
+ end
+ drawDS(lcd,0,13,maxt,self.dl_elapsed,ela); self.dl_elapsed = ela
+end
+
+local function drawSteadyBotLine(self,rix,maxt,rem)
+ local lcd = self.lcd
+ if self.dl_remain == nil then
+ lcd:put(lcd:locate(3,0), " ")
+ if rix == 0 then
+ lcd:put(lcd:locate(3,0), "START IN :")
+ elseif rix < ctfws.rounds then
+ lcd:put(lcd:locate(3,0), "JAILBREAK :")
+ else
+ lcd:put(lcd:locate(3,0), "GAME END :")
+ end
+ end
+ drawDS(lcd,3,13,maxt,self.dl_remain ,rem); self.dl_remain = rem
+end
+
+-- returns true if timers should keep going or false if we should wait for
+-- the next message or event
+local function drawTimes(self)
+ local ctfws = self.ctfws
+ local rix, maxt, ela = ctfws:times(rtctime.get)
+ if rix == nil then
+ -- XXX beep to get attention
+ drawNoGame(self.lcd, maxt)
+ return false
+ end
+ if rix ~= self.dl_round then
+ if self.dl_round ~= nil then end -- XXX beep when not forcibly reset
+ self.dl_round = rix
+ self.dl_elapsed = nil -- force redraws of times on round boundaries
+ self.dl_remain = nil
+ end
+ drawSteadyTopLine(self,rix,maxt,ela)
+ drawSteadyBotLine(self,rix,maxt,maxt-ela)
+ return true
+end
+
+local function drawFlags(self)
+ local lcd = self.lcd
+ local ctfws = self.ctfws
+ lcd:put(lcd:locate(1,0)," ")
+ if ctfws.startT then
+ local str = string.format("%d\000: R=%d Y=%d",
+ ctfws.flagsN, ctfws.flagsR, ctfws.flagsY)
+ :sub(1,20)
+ lcd:put(lcd:locate(1,(20-#str)/2), str)
+ end
+end
+
+-- Displays only when the game is not configured; useful for initial
+-- boot, perhaps.
+local function drawFlagsMessage(self, msg)
+ if self.ctfws.flagsN then return end -- NOP on game configured
+ lcd:put(lcd:locate(1,0),string.format("%-20s", msg:sub(1,20)))
+end
+
+local function drawMessage(self, msg)
+ local lcd = self.lcd
+ local mlen = (msg and #msg) or 0
+ -- XXX chirp to get attention
+ self.mtmr:unregister()
+ lcd:put(lcd:locate(2,0)," ")
+ if not msg then return end
+ if mlen <= 20
+ then lcd:put(lcd:locate(2,(20-#msg)/2),msg)
+ else
+ -- inspired by lcd:run(), but corrected
+ local ix = 1
+ local function scroller()
+ if ix <= 20 then lcd:put(lcd:locate(2,20-ix),msg:sub(1,ix))
+ elseif ix > mlen then lcd:put(lcd:locate(2,0),msg:sub(ix-19)," ")
+ else lcd:put(lcd:locate(2,0),msg:sub(ix-19,ix))
+ end
+ if ix >= mlen + 20 then ix = 1 else ix = ix + 1 end
+ end
+ self.mtmr:alarm(300, tmr.ALARM_AUTO, scroller)
+ end
+end
+
+local function reset(self)
+ self.dl_elapsed = nil
+ self.dl_remain = nil
+ self.dl_round = nil
+end
+
+return function(ctfws, lcd, tq, t)
+ self = {}
+ self.ctfws = ctfws
+ self.lcd = lcd
+ self.tq = tq
+ self.mtmr = t
+
+ self.reset = reset
+ self.drawTimes = drawTimes
+ self.drawFlags = drawFlags
+ self.drawMessage = drawMessage
+ self.drawFlagsMessage = drawFlagsMessage
+
+ -- load custom flag glyph
+ lcd:define_char(0,{ 0x1F, 0x15, 0x1B, 0x15, 0x1F, 0x10, 0x10, 0x0 })
+
+ return self
+end
--- /dev/null
+-- game logic dictionary values:
+--
+-- setupD -- deciseconds for setup round
+-- roundD -- deciseconds per round
+-- rounds* -- number of rounds of game play
+-- startT -- NTP seconds of game start
+-- endT -- NTP seconds of game end (if set)
+--
+-- flagsN* -- total flags
+-- flagsR* -- flags captured by the red team
+-- flagsY* -- flags captured by the yellow team
+--
+-- *'d fields are publicly read
+
+-- returns round index, this round duration, elapsed time
+-- round index: 0 for setup, 1-N for game play, and nil for game over / no game
+-- all return times in deciseconds
+local function times(self, nowf)
+ if self.startT == nil then
+ return nil, "GAME NOT CONFIGURED!"
+ end
+
+ if self.endT and self.endT >= self.startT then
+ return nil, "GAME OVER"
+ end
+
+ local now_sec, now_usec = nowf()
+
+ if now_sec < self.startT then
+ return nil, "START TIME IN FUTURE"
+ end
+
+ local elapsed = (now_sec - self.startT) * 10 + math.floor(now_usec / 100000)
+
+ if elapsed < self.setupD then
+ return 0, self.setupD, elapsed
+ end
+
+ elapsed = elapsed - self.setupD
+
+ local rounds = math.floor(elapsed / self.roundD)
+ if rounds >= self.rounds
+ then return nil, "TIME IS UP"
+ else -- game still in progress
+ local roundElapsed = elapsed - rounds * self.roundD
+ return rounds + 1, self.roundD, roundElapsed
+ end
+end
+
+local function config(self, st, sd, nr, rd, nf)
+ self.startT = st
+ self.setupD = sd * 10
+ self.rounds = nr
+ self.roundD = rd * 10
+ self.flagsN = nf
+end
+
+local function deconfig(self)
+ self.startT = nil
+ self.rounds = nil
+ -- leave flagsN alone for end-of-game display logic
+end
+
+local function setFlags(self, fr, fy)
+ self.flagsR = fr
+ self.flagsY = fy
+end
+
+local function setEndTime(self,t)
+ self.endT = t
+end
+
+return function()
+ local self = {}
+ self.times = times
+ self.config = config
+ self.deconfig = deconfig
+ self.setFlags = setFlags
+ self.setEndTime = setEndTime
+ return self
+end
--- /dev/null
+-- common module initialization
+cron.schedule("*/5 * * * *", function(e) dofile("nwfnet-sntp.lc").dosntp(nil) end)
+nwfnet = require "nwfnet"
+
+tq = (dofile "tq.lc")(tmr.create())
+
+-- Hardware initialization
+i2c.setup(0,2,1,i2c.SLOW) -- init i2c as per silk screen (GPIO4, GPIO5)
+lcd = dofile("lcd1602.lc")(0x27)
+
+-- Game logic modules
+ctfws = dofile("ctfws.lc")()
+ctfws:setFlags(0,0)
+
+msg_tmr = tmr.create()
+ctfws_lcd = dofile("ctfws-lcd.lc")(ctfws, lcd, tq, msg_tmr)
+ctfws_tmr = tmr.create()
+
+-- Draw the default display
+ctfws_lcd:drawTimes()
+ctfws_lcd:drawFlagsMessage("BOOT...")
+
+-- MQTT plumbing
+
+mqc, mqttUser = dofile("nwfmqtt.lc").mkclient("nwfmqtt.conf")
+local mqttBootTopic = string.format("ctfws/dev/%s/beat",mqttUser)
+mqc:lwt(mqttBootTopic,"dead",1,1)
+
+-- This is not, properly speaking, OK, but it's so convenient
+ctfws_lcd:drawMessage(string.format("I am: %s", mqttUser))
+
+local myBSSID = "00:00:00:00:00:00"
+
+local mqtt_reconn_cronentry
+local function mqtt_reconn()
+ mqtt_reconn_cronentry = cron.schedule("* * * * *", function(e)
+ mqc:close(); dofile("nwfmqtt.lc").connect(mqc,"nwfmqtt.conf")
+ end)
+ dofile("nwfmqtt.lc").connect(mqc,"nwfmqtt.conf")
+end
+
+local mqtt_beat_cronentry
+local function mqtt_beat()
+ mqtt_beat_cronentry = cron.schedule("*/5 * * * *", function(e)
+ mqc:publish(mqttBootTopic,string.format("beat %d %s",rtctime.get(),myBSSID),1,1)
+ end)
+end
+
+local function ctfws_lcd_draw_all()
+ ctfws_lcd:reset()
+ ctfws_lcd:drawFlags()
+ ctfws_lcd:drawTimes()
+end
+
+local function ctfws_start_tmr()
+ ctfws_tmr:alarm(100,tmr.ALARM_AUTO,function()
+ if not ctfws_lcd:drawTimes() then ctfws_tmr:unregister() end
+ end)
+end
+
+nwfnet.onmqtt["init"] = function(c,t,m)
+ if t == "ctfws/game/config" then
+ ctfws_tmr:unregister()
+ if not m or m == "none"
+ then ctfws:deconfig()
+ else local st, sd, nr, rd, nf = m:match("^%s*(%d+)%s+(%d+)%s+(%d+)%s+(%d+)%s+(%d+).*$")
+ if st == nil
+ then ctfws:deconfig()
+ else -- the game's afoot!
+ ctfws:config(tonumber(st), tonumber(sd), tonumber(nr),
+ tonumber(rd), tonumber(nf))
+ ctfws_start_tmr()
+ end
+ end
+ ctfws_lcd_draw_all()
+ elseif t == "ctfws/game/endtime" then
+ ctfws:setEndTime(tonumber(m))
+ ctfws_lcd_draw_all()
+ ctfws_start_tmr() -- might have been unset; restart display if so
+ elseif t == "ctfws/game/flags" then
+ if not m then ctfws:setFlags(0,0); return end
+ local fr, fy = m:match("^%s*(%d+)%s+(%d+).*$")
+ if fr ~= nil then
+ ctfws:setFlags(tonumber(fr),tonumber(fy))
+ ctfws_lcd:drawFlags()
+ end
+ elseif t:match("^ctfws/game/message") then
+ ctfws_lcd:drawMessage(m)
+ end
+end
+nwfnet.onnet["init"] = function(e,c)
+ if e == "mqttdscn" and c == mqc then
+ if mqtt_beat_cronentry then mqtt_beat_cronentry:unschedule() mqtt_beat_cronentry = nil end
+ if not mqtt_reconn_cronentry then mqtt_reconn() end
+ ctfws_lcd:drawFlagsMessage("MQTT Disconnected")
+ elseif e == "mqttconn" and c == mqc then
+ if mqtt_reconn_cronentry then mqtt_reconn_cronentry:unschedule() mqtt_reconn_cronentry = nil end
+ if not mqtt_beat_cronentry then mqtt_beat() end
+ mqc:publish(mqttBootTopic,"alive",1,1)
+ mqc:subscribe("ctfws/game/config",1)
+ mqc:subscribe("ctfws/game/endtime",1)
+ mqc:subscribe("ctfws/game/flags",1)
+ mqc:subscribe("ctfws/game/message",1) -- broadcast messages
+ mqc:subscribe("ctfws/game/message/jail",1) -- jail-specific messages
+ ctfws_lcd:drawFlagsMessage("MQTT CONNECTED")
+ elseif e == "wstagoip" then
+ if not mqtt_reconn_cronentry then mqtt_reconn() end
+ ctfws_lcd:drawFlagsMessage(string.format("DHCP %s",c.IP))
+ elseif e == "wstaconn" then
+ myBSSID = c.BSSID
+ ctfws_lcd:drawFlagsMessage(string.format("WIFI %s",c.SSID))
+ elseif e == "sntpsync" then
+ -- If we have a game configuration and just got SNTP sync, it might
+ -- be that we just lept far into the future, so go ahead and start
+ -- the game!
+ if ctfws.startT then ctfws_start_tmr() end
+ end
+end
+
+ctfws_lcd:drawFlagsMessage("CONNECTING...")
+dofile("nwfnet-diag.lc")(true)
+dofile("nwfnet-go.lc")
--- /dev/null
+#!/bin/zsh
+
+set -e -u
+
+. ./host/pushcommon.sh
+
+#dopushcompile net/nwfmqtt.lua
+#dopush examples/ctfws/conf/nwfnet.conf
+dopush examples/ctfws/conf/nwfnet.conf2
+dopush examples/ctfws/conf/nwfmqtt.conf
+#dopushcompile _external/dvv-nodemcu-thingies/lcd1602.lua
+dopushcompile examples/ctfws/ctfws.lua
+dopushcompile examples/ctfws/ctfws-lcd.lua
+dopushcompile examples/ctfws/init2.lua
+
+echo "SUCCESS"