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:
- The board is a clone of an Arduino Micro, and I2C is connnected to the OLED (SSD1315) and the Accelerometer (MMA8451).
- The OLED's I2C address 0x3C, and I'm using adafruit's 1306 driver, mainly for display rotation support.
- The Accelerometer's address is 0x1C and the adafruit MMA8541 driver is basic but functional. Note that the more advanced features (e.g. tap to click) built into the chip are unavailable with this library.
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 are prepended with an '!' and terminated with a newline '\n'.
- Key-Value pairs are prepended with a '@', space ' ' delimited and terminated with a newline '\n'
- You can also indicate not to store values in EEPROM by putting a caret '^' after the @ symbol
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
- The 32u4 is sloooow by modern standards, so after you send a command, I wait up to half a second before checking for a response.
- It also likes to reset when you connect via serial so I wait 3 to 5 seconds for it to boot up before poking it.
- Python raises DTR, and for whatever reason it breaks things, so I set ser.dtr = False.
- You never know whats in a serial buffer when you connect so send a '\n' and read the buffer to make sure everything is sane when you connect.
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


