Table of Contents

Server Status OLED Display

Displayinator v1.0

This is a device you can shove into a spare USB connector on say a server rack system to display info like IP/Hostname/etc. It has an accelerometer so it can determine orientation (for racks which have sideways USB) and a simple font compression selection so it can dynamically fit information on one line.

This is a old school project using a ATMEGA32U4. Hindsight being 20/20 I should have chosen a more modern microcontroller as the 2.5K of RAM was a significant limitation requiring excessive use of PROGMEM in places you ordinarily wouldn't.

Notes:

Loading the Arduino Bootloader

I'm using a cheap chinese USBasp (with avrdude v8.0)

avrdude will complain about older firmware but my recommendation is to ignore it. The TLDR; is the chinese firmware is modified and does some automagic stuff and in general works more reliably than the publicly available open source code version.

Use zadig to make it (USBasp) use libusb-win32

Copy avrdude v8 (The one that ships with arduino is v5 and doesn't work.) to the arduino avrdude location. (You can “Show verbose output during [X] compile [X] upload” in File→Preferences to try to upload to see where it is)

Then from Arduino, select USBasp in Tools→Programmer and burn using Tools→Burn Bootloader

Flashing the code

Choose “Arduino Micro” in Tools → Board → Arduino AVR Micros Select the right COM port in Tools → Port I use Serial Port Notifier from Helm as a helper function. Select “Arduino as ISP” in Tools → Programmer

Then download the following code and upload it.

Arduino Code: oledusbdisplayaccx1.zip

Controlling the device

When the device boots, it checks the EEPROM for stored key-value pairs and displays them on screen. I uses the accelerometer to detect orientation so it should be right side up.

The USB serial is nominally 115200.

Command structure

The command protocol is simple:

Commands

!LIST which displays the stored key-value pairs

!LIST
Key-Value Pairs:
Hostname = DESKTOP-PRYPYAT
IP1 = 10.10.22.102
IP2 = 10.10.22.61
GW = 10.10.22.1
WAN = 99.29.30.10

!VER reports the devicename and version of firmware

!VER
Displayinator v1.0

!NUKE clears all stored key-value pairs from memory and eeprom

!NUKE
All key-value pairs cleared.

!ORIENT [0..3] overrides the display orientation, but hasn't been implemented yet

!ORIENT 0
Orientation argument extracted: '0'
Orientation set to 0

KEY VALUE STORAGE

If the line starts with a '@', it will indicate that we're passing in a Key Value pair. The Key-Value pair is space delimited.

Note that this '@' symbol is not passed in.

@HAPPY GILMORE
Space index found at: 5
Key extracted: 'HAPPY', Value extracted: 'GILMORE'
Added HAPPY = GILMORE

Note only the first space is used as a delimiter, successive spaces are considered part of the Value.

@KEY Value cow
KEY Value cow, Space @: 3
Key extracted: 'KEY', Value extracted: 'Value cow'
Added KEY = Value cow

KV pairs are normally stored in flash and will be loaded on power on. Sometimes this behavior is undesired, for example rapidly changing information like CPU Load. In this case, a '^' is used to specify skipping permanent storage. Note that the caret is stored in the Key but not displayed.

@^CPU 48%
^CPU 48%, Space @: 4
Key extracted: '^CPU', Value extracted: '48%'
Updated ^CPU = 48%

Python Code: displayinatorhostip.zip

Code Tips

TrueNAS

Code: truenasserialdisplay.zip

I also wrote a bash script for TrueNAS because they removed pip. It polls version, network and storage info and updates the display every 15 seconds.

Code Dump: Arduino (32U4)

#include <Wire.h>
#include <Adafruit_MMA8451.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Fonts/TomThumb.h>
#include <EEPROM.h>
 
class MySSD1306 : public Adafruit_SSD1306 {
public:
  MySSD1306(int16_t w, int16_t h, TwoWire *twi, int8_t rst_pin)
    : Adafruit_SSD1306(w, h, twi, rst_pin) {}
 
  int16_t getCursorX() { return cursor_x; }
  int16_t getCursorY() { return cursor_y; }
};
 
// ----- EEPROM Layout Settings -----
#define MAX_KEY_LENGTH 32       // Maximum characters for a key
#define MAX_VALUE_LENGTH 32     // Maximum characters for a value
#define PAIR_SIZE (MAX_KEY_LENGTH + MAX_VALUE_LENGTH)
#define EEPROM_KEY_COUNT_OFFSET 0
#define EEPROM_KEY_VALUE_OFFSET 1  // Data begins here
// -----------------------------------
 
// Renamed enum to avoid potential conflicts
enum umopapisdn {
  UNKNOWN,
  FACE_UP,
  FACE_DOWN,
  LANDSCAPE_RIGHT,
  LANDSCAPE_LEFT,
  PORTRAIT_UP,
  PORTRAIT_DOWN
};
 
// CLI Code
#define MAX_KEYS 8  // Maximum number of key-value pairs
struct KeyValue {
  char key[MAX_KEY_LENGTH+1];
  char value[MAX_VALUE_LENGTH+1];
};
KeyValue keyValueStore[MAX_KEYS];  // Storage for key-value pairs
int keyCount = 0;                  // Current number of keys stored
int orient = 0;                    // Stores Display Orientation (0-3)
 
#define INPUT_BUFFER_SIZE 67
char inputBuffer[INPUT_BUFFER_SIZE];
int inputIndex = 0;
bool commandReady = false;
int orientChanged = 0;
 
// PROGMEM constant command strings
const char cmd_list[] PROGMEM   = "!LIST";
const char cmd_nuke[] PROGMEM   = "!NUKE";
const char cmd_ver[] PROGMEM    = "!VER";
const char cmd_orient[] PROGMEM = "!ORIENT";
 
int landscape = -1;
const float THRESHOLD = 7.0;         // Minimum m/s² value to consider an axis "dominant"
const unsigned long DEBOUNCE_DELAY = 250;  // Debounce delay in milliseconds
umopapisdn lastStableOrientation = UNKNOWN;
unsigned long lastChangeTime = 0;
 
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
#define SCREEN_ADDRESS 0x3C // I2C Addr (commonly 0x3C or 0x3D)
#define OLED_RESET     -1 
MySSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
 
#define ACCEL_ADDRESS 0x1C 
Adafruit_MMA8451 mma = Adafruit_MMA8451();
sensors_event_t event; 
 
//-------------------------------------------------- EEPROM Functions ---------------------------------------
void saveKeyValuesToEEPROM() {
  int storedKeyCount = 0;
  // Count only keys that should be stored (skip keys starting with '^')
  for (int i = 0; i < keyCount; i++) {
    if (keyValueStore[i].key[0] == '^') continue;
    storedKeyCount++;
  }
  EEPROM.update(EEPROM_KEY_COUNT_OFFSET, storedKeyCount);
 
  int storedIndex = 0;
  // Store only non-filtered key-value pairs.
  for (int i = 0; i < keyCount; i++) {
    if (keyValueStore[i].key[0] == '^') continue;
    int base = EEPROM_KEY_VALUE_OFFSET + storedIndex * PAIR_SIZE;
    // Save key as fixed-length
    for (int j = 0; j < MAX_KEY_LENGTH; j++) {
      char c = (j < (int)strlen(keyValueStore[i].key)) ? keyValueStore[i].key[j] : '\0';
      EEPROM.update(base + j, c);
    }
    // Save value as fixed-length
    for (int j = 0; j < MAX_VALUE_LENGTH; j++) {
      char c = (j < (int)strlen(keyValueStore[i].value)) ? keyValueStore[i].value[j] : '\0';
      EEPROM.update(base + MAX_KEY_LENGTH + j, c);
    }
    storedIndex++;
  }
}
 
void loadKeyValuesFromEEPROM() {
  keyCount = EEPROM.read(EEPROM_KEY_COUNT_OFFSET);
  // Validate keyCount (if invalid, reset to 0)
  if (keyCount < 0 || keyCount > MAX_KEYS) keyCount = 0;
  for (int i = 0; i < keyCount; i++) {
    int base = EEPROM_KEY_VALUE_OFFSET + i * PAIR_SIZE;
    char keyBuffer[MAX_KEY_LENGTH + 1];
    char valueBuffer[MAX_VALUE_LENGTH + 1];
    for (int j = 0; j < MAX_KEY_LENGTH; j++) {
      keyBuffer[j] = EEPROM.read(base + j);
    }
    keyBuffer[MAX_KEY_LENGTH] = '\0';
    for (int j = 0; j < MAX_VALUE_LENGTH; j++) {
      valueBuffer[j] = EEPROM.read(base + MAX_KEY_LENGTH + j);
    }
    valueBuffer[MAX_VALUE_LENGTH] = '\0';
    strncpy(keyValueStore[i].key, keyBuffer, MAX_KEY_LENGTH);
    keyValueStore[i].key[MAX_KEY_LENGTH] = '\0';
    strncpy(keyValueStore[i].value, valueBuffer, MAX_VALUE_LENGTH);
    keyValueStore[i].value[MAX_VALUE_LENGTH] = '\0';
  }
}
//-----------------------------------------------------------------------------------------------------
 
const void* activeFont = NULL;
// customPrintln() prints text and repositions the cursor when the font changes.
// If the string length exceeds 'threshold', the TomThumb font is used.
void customPrintln(const char *str, size_t threshold) {
  const void* desiredFont = (strlen(str) > threshold) ? (void*)&TomThumb : NULL;
  int currY = display.getCursorY();
  if ((currY == 0) && (!landscape)) {
    display.setCursor(0, 7);
  }
  if (activeFont != desiredFont) {
    display.setFont(desiredFont);
    activeFont = desiredFont;
    // Reposition cursor when switching fonts.
    if (desiredFont == (void*)&TomThumb) {
      display.setCursor(0, display.getCursorY() - 1);
    } else {
      display.setCursor(0, display.getCursorY() + 1);
    }
  }
  display.println(str);
}
 
//-------------------------------------------------- SETUP -------------------------------------------
void setup() {
  Serial.begin(115200);
  loadKeyValuesFromEEPROM();
 
  if (!mma.begin(ACCEL_ADDRESS)) {
    Serial.println(F("MMA8451 FAIL!"));
    for(;;);
  }
  Serial.println(F("MMA8451 OK."));
 
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 FAIL"));
    for(;;);
  }
  Serial.println(F("SSD1306 OK."));
 
  display.clearDisplay();
  display.display();
 
  mma.getEvent(&event);
  float ax = event.acceleration.x;
  float ay = event.acceleration.y;
  float az = event.acceleration.z;
  updateOrientation(ax, ay, az);
 
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  customPrintln("Displayinator", 10);
 
  display.display();
 
  // Initialize input buffer
  inputIndex = 0;
  inputBuffer[0] = '\0';
 
  mma.setRange(MMA8451_RANGE_2_G);
}
 
//-------------------------------------------------- LOOP --------------------------------------------
void loop() {
  mma.getEvent(&event);
  float ax = event.acceleration.x;
  float ay = event.acceleration.y;
  float az = event.acceleration.z;
  updateOrientation(ax, ay, az);
 
  updateDisplay();
 
  while (Serial.available()) {
    char inChar = (char)Serial.read();
    if (inChar == '\n') {
      inputBuffer[inputIndex] = '\0';
      commandReady = true;
      break;
    } else if (inChar != '\r') {
      if (inputIndex < INPUT_BUFFER_SIZE - 1) {
        inputBuffer[inputIndex++] = inChar;
        inputBuffer[inputIndex] = '\0';
      }
      // If the input buffer is full, force processing.
      if (inputIndex >= INPUT_BUFFER_SIZE - 1) {
        commandReady = true;
        break;
      }
    }
  }
 
  if (commandReady) {
    // Trim leading and trailing whitespace in the full input line.
    int start = 0;
    while (inputBuffer[start] == ' ' || inputBuffer[start] == '\t') start++;
    int end = strlen(inputBuffer) - 1;
    while (end > start && (inputBuffer[end] == ' ' || inputBuffer[end] == '\t')) {
      inputBuffer[end] = '\0';
      end--;
    }
    if (strlen(inputBuffer + start) > 0) {
      processCommand(inputBuffer + start);
    }
    inputIndex = 0;
    inputBuffer[0] = '\0';
    commandReady = false;
  }
  delay(250);
}
 
//-------------------------------------------------- DISPLAY ---------------------------------------
unsigned long lastDisplayUpdate = 0;
const unsigned long DISPLAY_UPDATE_INTERVAL = 1000;
 
void updateDisplay() {
  unsigned long now = millis();
  int sWidth = landscape ? 21 : 10;
 
  if (now - lastDisplayUpdate >= DISPLAY_UPDATE_INTERVAL) {
    lastDisplayUpdate = now;
    display.clearDisplay();
    display.setCursor(0, 0);
 
    if (keyCount == 0) {
      customPrintln("No KV data", sWidth);
    } else {
      for (int i = 0; i < keyCount; i++) {
        char displayKey[MAX_KEY_LENGTH+1];
        if (keyValueStore[i].key[0] == '^') {
          strncpy(displayKey, keyValueStore[i].key + 1, MAX_KEY_LENGTH);
          displayKey[MAX_KEY_LENGTH] = '\0';
        } else {
          strncpy(displayKey, keyValueStore[i].key, MAX_KEY_LENGTH);
          displayKey[MAX_KEY_LENGTH] = '\0';
        }
        char result[2 * MAX_KEY_LENGTH + MAX_VALUE_LENGTH + 3];
        if ((strlen(displayKey) + strlen(keyValueStore[i].value) + 1) <= (unsigned)sWidth) {
          snprintf(result, sizeof(result), "%s %s", displayKey, keyValueStore[i].value);
          customPrintln(result, sWidth);
        } else {
          customPrintln(displayKey, sWidth);
          customPrintln(keyValueStore[i].value, sWidth);
        }
      }
    }
    display.display();
  }
}
 
//-------------------------------------------------- ORIENTATION ---------------------------------------
umopapisdn detectOrientation(float ax, float ay, float az) {
  if (az > THRESHOLD) return FACE_UP;
  if (az < -THRESHOLD) return FACE_DOWN;
  if (ax > THRESHOLD) return LANDSCAPE_RIGHT;
  if (ax < -THRESHOLD) return LANDSCAPE_LEFT;
  if (ay > THRESHOLD) return PORTRAIT_UP;
  if (ay < -THRESHOLD) return PORTRAIT_DOWN;
  return UNKNOWN;
}
 
void updateOrientation(float ax, float ay, float az) {
  umopapisdn current = detectOrientation(ax, ay, az);
  unsigned long now = millis();
 
  if (current != lastStableOrientation) {
    if (now - lastChangeTime >= DEBOUNCE_DELAY) {
      lastStableOrientation = current;
      orientChanged = 1;
      switch (current) {
        case FACE_UP:
          display.setRotation(0);
          landscape = 1;
          break;
        case FACE_DOWN:
          display.setRotation(0);
          landscape = 1;
          break;
        case LANDSCAPE_RIGHT:
          display.setRotation(0);
          landscape = 1;
          break;
        case LANDSCAPE_LEFT:
          display.setRotation(2);
          landscape = 1;
          break;
        case PORTRAIT_UP:
          display.setRotation(3);
          landscape = 0;
          break;
        case PORTRAIT_DOWN:
          display.setRotation(1);
          landscape = 0;
          break;
        default:
          display.setRotation(0);
          landscape = 1;
          break;
      }
    }
  } else {
    lastChangeTime = now;
  }
}
 
//-------------------------------------------------- COMMAND PROCESSING ---------------------------------------
void processCommand(const char *command) {
  // System commands starting with '!'
  if (command[0] == '!') {
    if (startsWithProgmem(command, cmd_list)) {
      listKeyValues();
    } else if (startsWithProgmem(command, cmd_nuke)) {
      nukeKeyValues();
    } else if (startsWithProgmem(command, cmd_ver)) {
      printVersion();
    } else if (startsWithProgmem(command, cmd_orient)) {
      processOrientCommand(command);
    } else {
      Serial.println(F("Error: bad command"));
    }
  }
  // Key-value pair commands must start with '@'
  else if (command[0] == '@') {
    // Skip '@' and following whitespace.
    const char *ptr = command + 1;
    while (*ptr == ' ' || *ptr == '\t') {
      ptr++;
    }
    int spaceIndex = -1;
    for (int i = 0; ptr[i] != '\0'; i++) {
      if (ptr[i] == ' ') {
        spaceIndex = i;
        break;
      }
    }
    Serial.print(ptr);
    Serial.print(F(", Space @: "));
    Serial.println(spaceIndex);
    if (spaceIndex == -1) {
      Serial.println(F("Error: Expected '@KEY VALUE'"));
    } else {
      char key[MAX_KEY_LENGTH+1];
      char value[MAX_VALUE_LENGTH+1];
      int i;
      // Copy at most MAX_KEY_LENGTH characters for the key.
      for (i = 0; i < spaceIndex && i < MAX_KEY_LENGTH; i++) {
        key[i] = ptr[i];
      }
      key[i] = '\0';
      int j = 0;
      // Copy at most MAX_VALUE_LENGTH characters for the value.
      for (int k = spaceIndex + 1; ptr[k] != '\0' && j < MAX_VALUE_LENGTH; k++, j++) {
        value[j] = ptr[k];
      }
      value[j] = '\0';
      Serial.print(F("Key extracted: '"));
      Serial.print(key);
      Serial.print(F("', Value extracted: '"));
      Serial.print(value);
      Serial.println(F("'"));
      setKeyValue(key, value);
    }
  } else {
    Serial.println(F("Error: Invalid command. Key-value commands must start with '@'"));
  }
}
 
void setKeyValue(const char *key, const char *value) {
  // Update if key exists.
  for (int i = 0; i < keyCount; i++) {
    if (strcmp(keyValueStore[i].key, key) == 0) {
      strncpy(keyValueStore[i].value, value, MAX_VALUE_LENGTH);
      keyValueStore[i].value[MAX_VALUE_LENGTH] = '\0';
      Serial.print(F("Updated "));
      Serial.print(key);
      Serial.print(F(" = "));
      Serial.println(value);
      saveKeyValuesToEEPROM();
      return;
    }
  }
  // Add new key-value pair if space is available.
  if (keyCount < MAX_KEYS) {
    strncpy(keyValueStore[keyCount].key, key, MAX_KEY_LENGTH);
    keyValueStore[keyCount].key[MAX_KEY_LENGTH] = '\0';
    strncpy(keyValueStore[keyCount].value, value, MAX_VALUE_LENGTH);
    keyValueStore[keyCount].value[MAX_VALUE_LENGTH] = '\0';
    keyCount++;
    Serial.print(F("Added "));
    Serial.print(key);
    Serial.print(F(" = "));
    Serial.println(value);
    saveKeyValuesToEEPROM();
  } else {
    Serial.println(F("Error: full"));
  }
}
 
void listKeyValues() {
  Serial.println(F("Key-Value Pairs:"));
  for (int i = 0; i < keyCount; i++) {
    Serial.print(keyValueStore[i].key);
    Serial.print(F(" = "));
    Serial.println(keyValueStore[i].value);
  }
}
 
void nukeKeyValues() {
  keyCount = 0;
  Serial.println(F("All key-value pairs cleared."));
  saveKeyValuesToEEPROM();
}
 
void printVersion() {
  Serial.println(F("Displayinator v1.0"));
}
 
void processOrientCommand(const char *command) {
  // Process orientation command if needed.
}
 
// Helper: compare a C string to a command stored in PROGMEM.
bool startsWithProgmem(const char *s, const char *progmemStr) {
  char buffer[20];
  strcpy_P(buffer, progmemStr);
  int len = strlen(buffer);
  return (strncmp(s, buffer, len) == 0);
}
 
void displayKeyValuePairs() {
  if (keyCount == 0) {
    display.println(F("No KV data"));
  } else {
    for (int i = 0; i < keyCount; i++) {
      display.print(keyValueStore[i].key);
      display.print(F(" = "));
      display.println(keyValueStore[i].value);
    }
  }
}

Code Dump: Python (Windows/Linux)

import serial
import time
import socket
import re
import sys
import netifaces
import argparse
import platform
import urllib.request
import json
 
"""
pip install pyserial netifaces
"""
 
def send_command(ser, command):
    """
    Sends a command to the serial device and returns its response.
    Clears the input buffer, sends the command with a newline,
    waits briefly, then reads available lines.
    """
    try:
        ser.reset_input_buffer()
        # Write command (append newline)
        ser.write((command + "\n").encode('utf-8'))
        time.sleep(0.5)
    except Exception as e:
        print(f"Error writing command '{command}' to port: {e}")
        return ""
 
    response = ""
    try:
        while ser.in_waiting > 0:
            line = ser.readline().decode('utf-8', errors='replace')
            response += line
    except Exception as e:
        print(f"Warning: error reading response for command '{command}': {e}")
    return response.strip()
 
def get_ips_with_gateway():
    """
    Returns a list of IPv4 addresses from interfaces that have an associated gateway.
    Requires the netifaces module.
    """
    gateways = netifaces.gateways()
    interfaces_with_gateway = set()
 
    if netifaces.AF_INET in gateways:
        for gw_info in gateways[netifaces.AF_INET]:
            if isinstance(gw_info, tuple) and len(gw_info) >= 2:
                interface = gw_info[1]
                interfaces_with_gateway.add(interface)
 
    ip_list = []
    for interface in interfaces_with_gateway:
        addrs = netifaces.ifaddresses(interface)
        if netifaces.AF_INET in addrs:
            for link in addrs[netifaces.AF_INET]:
                ip = link.get('addr')
                if ip and ip != "127.0.0.1":
                    ip_list.append(ip)
    return ip_list
 
def get_default_gateways():
    """
    Returns a deduplicated list of default IPv4 gateway addresses.
    """
    gateways = netifaces.gateways()
    gw_set = set()
    if netifaces.AF_INET in gateways:
        for gw_info in gateways[netifaces.AF_INET]:
            if isinstance(gw_info, tuple):
                gw_set.add(gw_info[0])
    return list(gw_set)
 
def get_wan_ip():
    """
    Retrieves the WAN IP address using the ipify API.
    """
    url = "https://api.ipify.org?format=json"
    try:
        with urllib.request.urlopen(url) as response:
            data = response.read().decode('utf-8')
            ip_info = json.loads(data)
            return ip_info.get("ip", "Unknown")
    except Exception as e:
        return f"Error retrieving IP: {e}"
 
def parse_key_value_response(response):
    """
    Parses a !LIST response into a dictionary.
    Expected response format:
      Key-Value Pairs:
      Hostname = SOMETHING
      IP1 = X.X.X.X
      ...
    """
    kv_pairs = {}
    lines = response.splitlines()
    start_parsing = False
    for line in lines:
        if "Key-Value Pairs:" in line:
            start_parsing = True
            continue
        if start_parsing and line.strip():
            match = re.match(r'^\s*(\S+)\s*=\s*(\S+)\s*$', line)
            if match:
                key = match.group(1)
                value = match.group(2)
                kv_pairs[key] = value
    return kv_pairs
 
def needs_nuke(stored_kv, local_hostname, local_ips):
    """
    Compares the stored key-value pairs against the local hostname and IP addresses.
    Returns True if a difference is detected.
    """
    # Check hostname difference
    if stored_kv.get("Hostname", "") != local_hostname:
        print(f"Hostname difference detected: stored='{stored_kv.get('Hostname', None)}' vs local='{local_hostname}'")
        return True
 
    # Gather stored IPs in order: IP1, IP2, ...
    stored_ips = []
    i = 1
    while True:
        key = f"IP{i}"
        if key in stored_kv:
            stored_ips.append(stored_kv[key])
            i += 1
        else:
            break
 
    # Compare lists: if number or order of IPs is different, we consider it a difference.
    if stored_ips != local_ips:
        print(f"IP address difference detected: stored={stored_ips} vs local={local_ips}")
        return True
 
    return False
 
def main():
    # Determine default port based on the OS
    if platform.system() == "Linux":
        default_port = "/dev/ttyUSB0"
    else:
        default_port = "COM70"
 
    # Parse command-line arguments to get the serial port
    parser = argparse.ArgumentParser(description="Serial updater for Displayinator")
    parser.add_argument("--port", type=str, default=default_port,
                        help="Serial port to connect to (e.g., /dev/ttyUSB0 on Linux or COM70 on Windows)")
    args = parser.parse_args()
 
    com_port = args.port
    baud_rate = 115200
 
    # Open and configure the serial port
    try:
        ser = serial.Serial(
            port=com_port,
            baudrate=baud_rate,
            bytesize=serial.EIGHTBITS,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            timeout=2,
            write_timeout=2
        )
        ser.dtr = False
        # Allow time for Arduino reset upon opening the port
        time.sleep(5)
    except Exception as e:
        print(f"Failed to open port {com_port}. Error details: {e}")
        sys.exit(1)
 
    # Query version
    response_ver = send_command(ser, "!VER")
    print(f"Flush !VER: {response_ver}")
 
    response_ver = send_command(ser, "!VER")
    print(f"Response from !VER: {response_ver}")    
 
    if "Displayinator v1.0" not in response_ver:
        print(f"Error: Version mismatch or no valid response from device. Expected 'Displayinator v1.0' but received: '{response_ver}'")
        ser.close()
        sys.exit(1)
 
    # Query key-value pairs with !LIST
    response_list = send_command(ser, "!LIST")
    print("Response from !LIST:")
    print(response_list)
    stored_kv_pairs = parse_key_value_response(response_list)
 
    # Get the machine's hostname and IP addresses (only those with a gateway)
    local_hostname = socket.gethostname()
    local_ips = get_ips_with_gateway()
    print(f"Machine Hostname: {local_hostname}")
    print(f"Machine IP Addresses (with gateway): {', '.join(local_ips)}")
 
    # Determine if stored values differ from local values
    if needs_nuke(stored_kv_pairs, local_hostname, local_ips):
        print("Differences detected. Issuing !NUKE command to clear stored key-value pairs...")
        nuke_response = send_command(ser, "!NUKE")
        print(f"Response from !NUKE: {nuke_response}")
    else:
        print("Stored key-value pairs are up-to-date. No need to issue !NUKE.")
 
    # Update hostname regardless; after nuke it should be empty.
    # Prepend '@' to indicate key-value command.
    cmd_hostname = f"@Hostname {local_hostname}"
    print(f"Updating hostname: {cmd_hostname}")
    update_response = send_command(ser, cmd_hostname)
    print(f"Response: {update_response}")
 
    # Update IP addresses, assigning them as IP1, IP2, etc.
    for index, ip in enumerate(local_ips, start=1):
        key = f"IP{index}"
        cmd_ip = f"@{key} {ip}"
        print(f"Updating IP with command: {cmd_ip}")
        update_response = send_command(ser, cmd_ip)
        print(f"Response: {update_response}")
 
    # Update default gateways:
    default_gateways = get_default_gateways()
    print(f"Default Gateways: {', '.join(default_gateways)}")
    if len(default_gateways) == 1:
        # Only one gateway; use key "GW"
        cmd_gw = f"@GW {default_gateways[0]}"
        print(f"Updating gateway with command: {cmd_gw}")
        update_response = send_command(ser, cmd_gw)
        print(f"Response: {update_response}")
    else:
        # Multiple gateways; use keys GW1, GW2, etc.
        for index, gw in enumerate(default_gateways, start=1):
            key = f"GW{index}"
            cmd_gw = f"@{key} {gw}"
            print(f"Updating gateway with command: {cmd_gw}")
            update_response = send_command(ser, cmd_gw)
            print(f"Response: {update_response}")
 
    # Retrieve and update WAN IP address using key "WAN"
    wan_ip = get_wan_ip()
    cmd_wan = f"@WAN {wan_ip}"
    print(f"Updating WAN IP with command: {cmd_wan}")
    update_response = send_command(ser, cmd_wan)
    print(f"Response: {update_response}")
 
    # Close the serial port
    ser.close()
 
if __name__ == '__main__':
    main()

Code Dump: Bash (TrueNAS Scale)

#!/bin/bash
# Script for TrueNAS Scale 24 to interface with a USB serial display.
# Advanced operations: validate device response, gather network and storage info,
# update key-value pairs on the display, and periodically refresh pool usage if changed.
#
# Modify the serial port based on the device it shows up in dmesg
# launch with 'nohup ./truenasserialdisplay.sh &'
 
SERIAL_PORT="/dev/ttyACM0"
BAUD=115200
 
# Delay settings (in seconds)
SLEEP_BOOT=3
SLEEP_AFTER_CMD=1
UPDATE_INTERVAL=15
 
# Function to send a command to the device and read back the response.
# Usage: send_cmd "COMMAND"
send_cmd() {
  local cmd="$1"
  printf "%s\n" "$cmd" >&3
  sleep "$SLEEP_AFTER_CMD"
  read -t 1 -r DEVICE_RESPONSE <&3
  echo "Device response: $DEVICE_RESPONSE"
}
 
# Configure the serial port (8N1, no parity)
stty -F "$SERIAL_PORT" "$BAUD" cs8 -cstopb -parenb
 
# Open the serial port for reading and writing (using file descriptor 3)
exec 3<> "$SERIAL_PORT"
if [ $? -ne 0 ]; then
  echo "Failed to open $SERIAL_PORT"
  exit 1
fi
 
echo "Waiting ${SLEEP_BOOT} seconds for system boot up..."
sleep "$SLEEP_BOOT"
 
send_cmd "!VER"
 
# Flush any initial device data
read -t 1 -r DEVICE_RESPONSE <&3
echo "2nd Flush of Device Buffers: $DEVICE_RESPONSE"
 
echo "Querying Device"
# Send the "!VER" command to the device (system command remains unchanged)
send_cmd "!VER"
 
# Validate that the response starts with "Displayinator"
if [[ $DEVICE_RESPONSE != Displayinator* ]]; then
  echo "Error: Device did not report a valid version. Got: $DEVICE_RESPONSE"
  exec 3>&-
  exit 1
fi
 
# Get OS version from /etc/version
if [[ -f /etc/version ]]; then
  OS_VERSION=$(cat /etc/version)
  echo "OS version from /etc/version: $OS_VERSION"
else
  echo "File /etc/version not found."
  exec 3>&-
  exit 1
fi
 
# Get non-loopback IPv4 addresses
mapfile -t IP_ADDRESSES < <(ip -4 addr show | grep -v "127.0.0.1" | grep "inet " | awk '{print $2}' | cut -d'/' -f1)
# Get primary gateway (assumes default route)
GATEWAY=$(ip route | awk '/^default/ {print $3; exit}')
 
echo "IP Addresses: ${IP_ADDRESSES[*]}"
echo "Gateway: $GATEWAY"
 
# Automatically determine the pool names using zpool.
readarray -t POOLS < <(zpool list -H -o name)
if [ ${#POOLS[@]} -eq 0 ]; then
  echo "No ZFS pools found."
  exec 3>&-
  exit 1
fi
echo "Pools found: ${POOLS[*]}"
 
# Get ZFS pool info using zfs list for all detected pools.
POOL_INFO=$(zfs list -H -o name,used,available "${POOLS[@]}")
 
# Function to convert human-readable size (e.g., 2.66G) to bytes.
convert_to_bytes() {
    local value="$1"
    local number unit multiplier
    number=$(echo "$value" | sed -E 's/([^0-9.]*)([0-9.]+)([A-Za-z]*)/\2/')
    unit=$(echo "$value" | sed -E 's/([^0-9.]*)([0-9.]+)([A-Za-z]*)/\3/' | tr '[:lower:]' '[:upper:]')
    case "$unit" in
        T) multiplier=1099511627776 ;;
        G) multiplier=1073741824 ;;
        M) multiplier=1048576 ;;
        K) multiplier=1024 ;;
        *) multiplier=1 ;;
    esac
    awk -v num="$number" -v mult="$multiplier" 'BEGIN { printf "%.0f", num * mult }'
}
 
# Function to convert bytes to a human-readable format.
human_readable() {
    local bytes="$1"
    awk -v bytes="$bytes" 'BEGIN {
      if (bytes >= 1099511627776) {
        printf "%.2fT", bytes/1099511627776;
      } else if (bytes >= 1073741824) {
        printf "%.2fG", bytes/1073741824;
      } else if (bytes >= 1048576) {
        printf "%.2fM", bytes/1048576;
      } else if (bytes >= 1024) {
        printf "%.2fK", bytes/1024;
      } else {
        printf "%dB", bytes;
      }
    }'
}
 
# Process each pool line and prepare storage info lines.
# Expected POOL_INFO output per line: "poolname used available"
STORAGE_LINES=()
while read -r pool used avail; do
  used_bytes=$(convert_to_bytes "$used")
  avail_bytes=$(convert_to_bytes "$avail")
  total_bytes=$(( used_bytes + avail_bytes ))
  human_used=$(human_readable "$used_bytes")
  human_total=$(human_readable "$total_bytes")
  # Prepend a caret to mark this key as temporary
  STORAGE_LINES+=("^$pool $human_used/$human_total")
done <<< "$POOL_INFO"
 
# Send initial key-value pairs to the device (each key-value command now starts with '@')
 
# OS version (TrueNAS version)
send_cmd "@TrueNAS $OS_VERSION"
 
# IP addresses: if multiple, number them; if one, use unnumbered key.
if [ ${#IP_ADDRESSES[@]} -gt 1 ]; then
  index=1
  for ip in "${IP_ADDRESSES[@]}"; do
    send_cmd "@IP${index} ${ip}"
    ((index++))
  done
elif [ ${#IP_ADDRESSES[@]} -eq 1 ]; then
  send_cmd "@IP ${IP_ADDRESSES[0]}"
fi
 
# Primary gateway.
send_cmd "@GW $GATEWAY"
 
# Storage info for each pool (e.g., "@^boot-pool 2.66G/12.11G")
for line in "${STORAGE_LINES[@]}"; do
  send_cmd "@$line"
done
 
# Store the initial storage info for each pool in an associative array.
declare -A PREV_STORAGE
for line in "${STORAGE_LINES[@]}"; do
    key=$(echo "$line" | awk '{print $1}')
    value=$(echo "$line" | cut -d' ' -f2-)
    PREV_STORAGE["$key"]="$value"
done
 
echo "Initial storage values set. Entering update loop..."
 
#echo EXITING for DEBUG!
#exec 3>&-
#exit 0
 
# Trap to close the serial port on exit.
cleanup() {
  echo "Cleaning up..."
  exec 3>&-
  exit 0
}
trap cleanup SIGINT SIGTERM
 
# Infinite loop: every 15 seconds, re-check pool usage and update device if changed.
while true; do
  sleep "$UPDATE_INTERVAL"
  for pool in "${POOLS[@]}"; do
    pool_info=$(zfs list -H -o name,used,available "$pool")
    cur_pool=$(echo "$pool_info" | awk '{print $1}')
    used=$(echo "$pool_info" | awk '{print $2}')
    avail=$(echo "$pool_info" | awk '{print $3}')
    used_bytes=$(convert_to_bytes "$used")
    avail_bytes=$(convert_to_bytes "$avail")
    total_bytes=$(( used_bytes + avail_bytes ))
    human_used=$(human_readable "$used_bytes")
    human_total=$(human_readable "$total_bytes")
    new_value="$human_used/$human_total"
 
    # Caret-prefixed key for temporary display
    key="^$cur_pool"
 
    # If the new value is different from the stored value, update the display.
    if [[ "${PREV_STORAGE[$key]}" != "$new_value" ]]; then
      echo "Updating pool $cur_pool: was ${PREV_STORAGE[$key]}, now $new_value"
      send_cmd "@$key $new_value"
      PREV_STORAGE["$key"]="$new_value"
    fi
  done
done