Meh Belly Lint Collection

That awful moment when you realize,
THIS is YOUR circus and THOSE are YOUR monkeys.

User Tools

Site Tools


server_status_oled_display

This is an old revision of the document!


Server Status OLED Display

I'm using a cheap chinese USBasp (with avrdude v8.0), the software complains about older firmware but ignore it.

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

Copy avrdude 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)

Arduino Code: oledusbdisplayaccx1.zip Python Code: displayinatorhostip.zip

Arduino:

#include <Wire.h>
#include <Adafruit_MMA8451.h>
#include <Adafruit_Sensor.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Fonts/TomThumb.h> // TomThumb
//#include <Fonts/Org_01.h> // Org_01
//#include <Fonts/Picopixel.h> // Picopixel
#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 16       // Maximum characters for a key
#define MAX_VALUE_LENGTH 16     // 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
// -----------------------------------
 
// Use a renamed enum to avoid potential conflicts
enum Fuckme {
  UNKNOWN,
  FACE_UP,
  FACE_DOWN,
  LANDSCAPE_RIGHT,
  LANDSCAPE_LEFT,
  PORTRAIT_UP,
  PORTRAIT_DOWN
};
 
// CLI Code
#define MAX_KEYS 10  // Maximum number of key-value pairs
struct KeyValue {
  String key;
  String value;
};
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)
String inputString = "";           // Buffer for incoming serial data
bool stringComplete = false;       // Indicates if a full line has been received
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;  // Time in milliseconds to wait before confirming a change
Fuckme 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 0x3D or 0x3C
#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() {
  // Save the number of key-value pairs at address 0.
  EEPROM.update(EEPROM_KEY_COUNT_OFFSET, keyCount);
  for (int i = 0; i < keyCount; i++) {
    int base = EEPROM_KEY_VALUE_OFFSET + i * PAIR_SIZE;
    // Save key as fixed-length char array
    for (int j = 0; j < MAX_KEY_LENGTH; j++) {
      char c = (j < keyValueStore[i].key.length()) ? keyValueStore[i].key.charAt(j) : '\0';
      EEPROM.update(base + j, c);
    }
    // Save value as fixed-length char array
    for (int j = 0; j < MAX_VALUE_LENGTH; j++) {
      char c = (j < keyValueStore[i].value.length()) ? keyValueStore[i].value.charAt(j) : '\0';
      EEPROM.update(base + MAX_KEY_LENGTH + j, c);
    }
  }
}
 
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';
    keyValueStore[i].key = String(keyBuffer);
    keyValueStore[i].value = String(valueBuffer);
  }
}
//-----------------------------------------------------------------------------------------------------
 
const void* activeFont = NULL;
 
// customPrintln() prints text and only repositions if the font changes.
// If the string length exceeds 'threshold', we use the TomThumb font; otherwise, the default.
void customPrintln(const String &str, size_t threshold) {
  // Determine which font we want based on the string length.
  const void* desiredFont = (str.length() > threshold) ? (void*)&TomThumb : NULL;
 
  //Serial.print("Y:");
  //Serial.println(display.getCursorY());
 
  int currY = display.getCursorY();
  if ((currY == 0) && (!landscape)) { // weird bug
    display.setCursor(0,7);
  }
 
  // Only change font (and reposition) if it differs from the active font.
  if (activeFont != desiredFont) {
    display.setFont(desiredFont);
    activeFont = desiredFont;
 
    // Reposition cursor when the font changes cause reasons.
    // For example, when switching to TomThumb, move up by 1 pixel;
    // when switching back to default, move down by 1 pixel.
    if (desiredFont == (void*)&TomThumb) {
      //Serial.println(display.getCursorY());
      display.setCursor(0, display.getCursorY() - 1);
    } else {
      display.setCursor(0, display.getCursorY() + 1);
    }
  }
 
  // Print the string (this also moves the cursor downward).
  display.println(str);
}
 
 
//-------------------------------------------------- SETUP -------------------------------------------
void setup() {
  Serial.begin(115200);
  //while (!Serial) delay(10);     // will pause until serial console opens
 
  //Serial.println("Displayinator Boot");
 
  // Load key-value pairs from EEPROM
  loadKeyValuesFromEEPROM();
 
  if (!mma.begin(ACCEL_ADDRESS)) {
    Serial.println("MMA8451 FAIL!");
    for(;;); // Don't proceed, loop forever
  }  
  Serial.println("MMA8451 OK.");
 
  if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 FAIL"));
    for(;;); // Don't proceed, loop forever
  }
  Serial.println("SSD1306 OK.");
 
 
  display.clearDisplay();
  display.display();
 
  mma.getEvent(&event);
  float ax = event.acceleration.x; // acceleration is measured in m/s^2
  float ay = event.acceleration.y;
  float az = event.acceleration.z;
  updateOrientation(ax, ay, az);
 
  //display.setFont(&Picopixel);
  display.setTextSize(1);      // Normal 1:1 pixel scale
  display.setTextColor(SSD1306_WHITE); // Draw white text
  customPrintln(F("Displayinator"),10);
 
/*  customPrintln(F("Displayinator"),10);
  customPrintln(F("123456789012345678901234567890"),10);
  customPrintln(F("123.456.789.012"),10);
  customPrintln(F("COW"),10);
*/
  display.display();
  inputString.reserve(20);     // Reserve memory for the input string
 
 
 
  mma.setRange(MMA8451_RANGE_2_G);
 
  //Serial.print("ACCRANGE = ");  Serial.print(2 << mma.getRange());  Serial.println("G");
}
 
//-------------------------------------------------- LOOP --------------------------------------------
void loop() {
  /* Get a new sensor event */ 
 
  mma.getEvent(&event);
  float ax = event.acceleration.x; // acceleration is measured in m/s^2
  float ay = event.acceleration.y;
  float az = event.acceleration.z;
  updateOrientation(ax, ay, az);
 
  updateDisplay();
 
  while (Serial.available()) { // Nonblocking serial input: read available characters one at a time
    char inChar = (char)Serial.read();
    //Serial.print(inChar);
    if (inChar == '\n') {  // Newline signals end of command
      //Serial.print("[LF]");
      stringComplete = true;
      break; // Process this command after breaking out
    } else if (inChar != '\r') { // Ignore carriage return
      inputString += inChar;
    } else {
      //Serial.print("[CR]");
    }
  }
 
  // If a complete command was received, process it
  if (stringComplete) {
    inputString.trim();
    if (inputString.length() > 0) {
      //Serial.println(inputString);
      processCommand(inputString);
    }
    // Reset buffer for the next command
    inputString = "";
    stringComplete = false;
  }
  delay(250);
}
 
//-------------------------------------------------- DISPLAY ---------------------------------------
unsigned long lastDisplayUpdate = 0;         // Tracks the last display update time
const unsigned long DISPLAY_UPDATE_INTERVAL = 1000; // Update interval in milliseconds
 
void updateDisplay() {
  unsigned long now = millis();
  int sWidth = 10;
  if (landscape) {
    sWidth = 21;
  } else {
    sWidth = 10;
  }
 
  if (now - lastDisplayUpdate >= DISPLAY_UPDATE_INTERVAL) {
    lastDisplayUpdate = now;
 
    display.clearDisplay();
    display.setCursor(0, 0);
 
    // Display key-value pairs
    if (keyCount == 0) {
      customPrintln(F("No KV data"),sWidth);
    } else {
      for (int i = 0; i < keyCount; i++) {
        if ((keyValueStore[i].key.length() + keyValueStore[i].value.length() + 1) <= sWidth) {
          String result = keyValueStore[i].key + ":" + keyValueStore[i].value;
          customPrintln(result,sWidth);
        } else {
          customPrintln(keyValueStore[i].key,sWidth);
          customPrintln(keyValueStore[i].value,sWidth);
        }
      }
    }
 
    display.display();
  }
}
 
//-------------------------------------------------- ORIENTATION ---------------------------------------
Fuckme 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) {
  Fuckme current = detectOrientation(ax, ay, az);
  unsigned long now = millis();
 
  if (current != lastStableOrientation) {
    if (now - lastChangeTime >= DEBOUNCE_DELAY) {
      lastStableOrientation = current;
      orientChanged = 1;
      Serial.print("REORIENT: ");
      switch (current) {
        case FACE_UP:         
          Serial.println("FACE_UP"); 
          break;
        case FACE_DOWN:       
          Serial.println("FACE_DOWN"); 
          break;
        case LANDSCAPE_RIGHT: 
          Serial.println("LANDSCAPE_RIGHT"); 
          display.setRotation(0);
          landscape = 1;
          break;
        case LANDSCAPE_LEFT:  
          Serial.println("LANDSCAPE_LEFT"); 
          display.setRotation(2);
          landscape = 1;
          break;
        case PORTRAIT_UP:     
          Serial.println("PORTRAIT_UP"); 
          display.setRotation(3);
          landscape = 0;
          break;
        case PORTRAIT_DOWN:   
          Serial.println("PORTRAIT_DOWN"); 
          display.setRotation(1);
          landscape = 0;
          break;
        default:
          Serial.println("UNKNOWN"); 
          break;
      }
    }
  } else {
    lastChangeTime = now;
  }
}
 
//-------------------------------------------------- COMMAND PROCESSING ---------------------------------------
void processCommand(String command) {
  // Check if command is special (starts with '!')
  if (command.startsWith("!")) {
    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: Unknown command"));
    }
  } else {
    // Assume key-value pair in the form "KEY VALUE"
    int spaceIndex = command.indexOf(' ');
    Serial.print(F("Space index found at: "));
    Serial.println(spaceIndex);
    if (spaceIndex == -1) {
      Serial.println(F("Error: Command format incorrect. Expected 'KEY VALUE'"));
    } else {
      String key = command.substring(0, spaceIndex);
      String value = command.substring(spaceIndex + 1);
      key.trim();
      value.trim();
      Serial.print(F("Key extracted: '"));
      Serial.print(key);
      Serial.print(F("', Value extracted: '"));
      Serial.print(value);
      Serial.println(F("'"));
      setKeyValue(key, value);
    }
  }
}
 
void setKeyValue(String key, String value) {
  // Update if key exists
  for (int i = 0; i < keyCount; i++) {
    if (keyValueStore[i].key == key) {
      keyValueStore[i].value = value;
      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) {
    keyValueStore[keyCount].key = key;
    keyValueStore[keyCount].value = value;
    keyCount++;
    Serial.print(F("Added "));
    Serial.print(key);
    Serial.print(F(" = "));
    Serial.println(value);
    saveKeyValuesToEEPROM();
  } else {
    Serial.println(F("Error: Key store is 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;  // Clear the store by resetting the count
  Serial.println(F("All key-value pairs cleared."));
  saveKeyValuesToEEPROM();
}
 
void printVersion() {
  Serial.println(F("Displayinator v1.0"));
}
 
void processOrientCommand(String command) {
  int spaceIndex = command.indexOf(' ');
  if (spaceIndex == -1) {
    Serial.println(F("Error: !ORIENT requires an argument (0-3)"));
    return;
  }
  String arg = command.substring(spaceIndex + 1);
  arg.trim();
  Serial.print(F("Orientation argument extracted: '"));
  Serial.print(arg);
  Serial.println(F("'"));
  int newOrient = arg.toInt();
  if (newOrient < 0 || newOrient > 3) {
    Serial.println(F("Error: Orientation must be between 0 and 3."));
  } else {
    orient = newOrient;
    Serial.print(F("Orientation set to "));
    Serial.println(orient);
  }
}
 
// Helper function: compare a String to a command stored in PROGMEM.
bool startsWithProgmem(const String &s, const char *progmemStr) {
  char buffer[20]; // Adjust size if commands grow longer
  strcpy_P(buffer, progmemStr);
  return s.startsWith(buffer);
}
 
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);
    }
  }
}

Python:

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"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
    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()
server_status_oled_display.1742540909.txt.gz · Last modified: 2025/03/21 07:08 by kenson

Donate Powered by PHP Valid HTML5 Valid CSS Driven by DokuWiki