Extensible Signage Architecture
Can't account for everything but we'll try
Gabe // Jan. 19, 2018
Motivation

After laser cutting and building my information display I wanted to have a backing software architecture that met two requirements.

  1. It should be capable enough to minimize the amount of processing on the receiving software implementation.
  2. Adding additional data sources should be as easy as possible.

Architecture
Architectural Diagram
Architectural Diagram

The train sign components are written as a series Crossbar components, each with a specific task. There are the data source components such as providers for time, weather and train data for the area. These publish their cleaned textual data to a data topic, which the signage service spools and publishes periodically to the signs over MQTT.

Configuration

Configuration is handled entirely in a Thingistry namespace that the service reads from. Below are two example configurations, simple and complex.

{
  "sources": {
    "us.thingcosm.data.services.rfid2sign": {
      "fields": [
        "__all__"
      ],
      "ttl": 90
    },
    "us.thingcosm.data.services.weather": {
      "fields": [
        "temp_f",
        "temp_c",
        "hum"
      ],
      "config": {
        "incr": {
          "wind_m": [
            2,
            2,
            0,
            1,
            1,
            1
          ],
          "hum": [
            1,
            1,
            0,
            2,
            3,
            3
          ],
          "temp_f": [
            1,
            1,
            1,
            2,
            3,
            3
          ],
          "temp_c": [
            1,
            1,
            1,
            2,
            3,
            3
          ]
        },
        "colormap": {
          "1": [
            40,
            20,
            40
          ],
          "0": [
            0,
            0,
            0
          ],
          "3": [
            20,
            50,
            20
          ],
          "2": [
            40,
            20,
            0
          ],
          "4": [
            20,
            20,
            50
          ]
        }
      },
      "ttl": 600
    }
  }
}
Complex

The first example accepts from all available keys within the namespace with a TTL of 90 seconds.

"us.thingcosm.data.services.rfid2sign": {
      "fields": [
        "__all__"
      ],
      "ttl": 90
    },
Detail

The second example is slightly more involved. It selects data to read from a specific set of keys.

"us.thingcosm.data.services.weather": { "fields": [ "DATE", "TIME24" ], }
Detail 2

The next section is a configuration entry and is involved with incrementing the indices of certain colors under certain indices. In the example below the 0th index is incremented by two, the 1st index by 1 as well, and will end up the same color. The second section is what the color values at the elevated indices end up being.

"us.thingcosm.data.services.weather": { "config": { "incr": { "DATE: [ 2, 1, 0 ], }, "colormap": { "0": [ 0, 0, 0 ], "1": [ 40, 20, 40 ], "2": [ 30, 20, 10 ],
Color Math
Data Sources

Data providers are designed to be standalone publishers that either inject new information into the system or mutate existing information and publish it on a different topic.

In the example given below, the nightshift time provider publishes a dictionary containing a top level text_micro key and then the various topic subkeys and their values.

yield self.publish("us.thingcosm.data.services.nightshift", **{ "text_micro": {"DATE":str_disp_date, "TIME12": str_disp_time_12, "TIME24": str_disp_time_24} })
Data Source
Spooler

Showing the data as it comes in from the sources would be a bad viewing experience so a buffer must be kept. As the subscriber receives data from its configured topics they are kept in a dictionary in memory. Each source is configured with a time-to-live and old values are purged periodically. There is an array that gets a value popped off with each tick and the values published to the configured topics. When the array is empty, the array is repopulated from the living dictionary with the new set of values and it the loop continues publishing data.

With our commitment to keeping the receiving implementation simple the publishing step involves transforming the output text into sprites to display. This is what the sprite service is in charge of dealing with. An extracted code block follows

@inlinecallbacks
def publish(text):
    sprite_res = yield self.call("us.thingcosm.data.services.sprites.letter", string=st_text, map_incr=st_incr)
    sprite = sprite_res.get('sprites')

    # 1) A bit of preprocessing
    rows = {i:[] for i in range(0,7)}
    for letter in sprite:
        row_pos = 0
        for row in letter:
            # let's map this to the values we're going to be putting down while we're in here
            # fail to zero by default
            new_row = [st_color.get(str(j), st_color.get(0)) for j in row]
            rows[row_pos].extend(new_row)
            row_pos += 1
            
    # 2) append the sides and bottom framing (test patterns for now)
    left_color = st_color.get('sleft', [0,0,0])
    right_color = st_color.get('sright', [0,0,0])
    bottom_color = st_color.get('sbottom', [4,0,0])
    for index,row in rows.items():
        # print(row)
        row.insert(0, left_color)
        row.append(right_color)
    
    # 8x32 display and the sprites are 7x5
    rows[7] = [bottom_color]*32

    # 3) Build the snake
    snake = []
    dau = down_and_up()
    for i in range(0,32):
        for j in range(0,8):
            row = next(dau)
            snake.append(rows[row][i])


    # 4) ready for the world
    yield self.publish("us.thingcosm.data.signage.pub.%s" % self.topic_name, **{
        "snake":snake
    })
Spooler

The sprite service is called and the text is converted to a sprite array.

  1. The array gets modified in the first step to fit 8x32
  2. In the second step the values are set in the non sprite ranges
  3. In the third step we use the `down_and_up` generator to rearrange the two dimensional array into a single array that matches the ordering of the LEDs on the display board.
  4. The fourth step is publishing the output to the correct topic.
def down_and_up():
    first = False
    status = 0
    going_up = True
    while True:
        yield status
        if status == 7:
            going_up = False
            # this will make sense one day
            if first:
                first=False
            else:
                status-=1
                first=True
        elif status == 0:
            going_up = True
            # just think 01234567 and 76543210 emitted forever from the machine
            if first:
                first=False
            else:
                status+=1
                first=True
        elif going_up:
            status +=1
        elif going_up == False:
            status -=1
Down And Up

The generator that returns the snake returns the correct index to be converted into the snake. With each iteration it snakes back and forth across the array retrieving values and putting them in the correct order.

Sprites

In order to keep the processing on the target hardware low, we preprocess the data coming before it is sent. This means converting the text into RGB values on the signage service before sending it down the pipeline. The sprite services exposes an interface that converts strings to arrays, supporting multiple fonts.

font = {"x": [
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 1],
        [0, 1, 0, 1, 0],
        [0, 0, 1, 0, 1],
        [0, 1, 0, 1, 0],
        [1, 0, 0, 0, 1],
    ],
    "y": [
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [1, 0, 0, 0, 1],
        [1, 0, 0, 0, 1],
        [0, 1, 1, 1, 1],
        [0, 0, 0, 0, 1],
        [0, 1, 1, 1, 0],
    ],
    "z": [
        [0, 0, 0, 0, 0],
        [0, 0, 0, 0, 0],
        [1, 1, 1, 1, 1],
        [0, 0, 0, 1, 0],
        [0, 0, 1, 0, 0],
        [0, 1, 0, 0, 0],
        [1, 1, 1, 1, 1],
    ]
}
Sprite Font

The code block above shows an available font and sprites for the letters X, Y, and Z

Receiver

The receiver code was written in micropython for the sake of development speed.

# setup
import network
sta_if = network.WLAN(network.STA_IF)
ap_if = network.WLAN(network.AP_IF)
if not sta_if.isconnected():
    sta_if.active(True)
    sta_if.connect('lol', 'no')
    ap_if.active(False)

    while not sta_if.isconnected():
        pass


import neopixel, machine

np = neopixel.NeoPixel(machine.Pin(13), 8*32)
import time, ujson
from umqtt.simple import MQTTClient

# callbacks
def sub_cb(topic, msg):
    #print((topic, msg))
    decoded = msg.decode('utf-8')
    loaded = ujson.loads(decoded)
    kw = loaded.get('kwargs')
    snake = kw.get('snake')
    for i in range(0, 8*32):
        np[i] = snake[i]
    np.write()

# main function
def main(server="192.168.13.71"):
    c = MQTTClient("umqtt_client",server)
    c.set_callback(sub_cb)
    c.connect()
    c.subscribe(b"us.thingcosm.data.signage.pub.door.panel")
    
    while True:
        if True:
            c.wait_msg()
        else:
            c.check_msg()
            time.sleep(1)
            
    c.disconnect()

# wrap it all in a try:except    
if __name__ == "__main__":
    try:
        main()
    except Exception:
        import machine
        machine.reset()
Receiver Code

The board connects to the wifi, connects to the MQTT transport running on the crossbar.io router and then subscribes to the MQTT topic and has it setup to trigger a callback. The callback simply takes the array values that were published and shoves them down the wire to the display components.