feat: display Columba logo on RNode OLED via external framebuffer

Add support for displaying the Columba constellation logo on RNode's
OLED display when connected. The logo is sent via KISS protocol using
the external framebuffer commands (CMD_FB_EXT, CMD_FB_WRITE).

Changes:
- Add conversion script to render icon to 64x64 monochrome bitmap
- Add columba_logo.py with 512-byte framebuffer data
- Add framebuffer methods to ColumbaRNodeInterface
- Auto-display logo after successful RNode connection
- Enable by default via enable_framebuffer config option

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
torlando-tech
2025-12-05 17:08:15 -05:00
parent 358d5dd49c
commit d75bd2d77f
5 changed files with 326 additions and 0 deletions

43
python/columba_logo.py Normal file
View File

@@ -0,0 +1,43 @@
"""
Columba logo framebuffer data for RNode external display.
Format: 64x64 monochrome bitmap, 512 bytes
8 pixels per byte, MSB first, row-major order
Generated by scripts/convert_icon_to_framebuffer.py
"""
columba_fb_data = [
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x1e, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x0f, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x00,
0x01, 0xe0, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x03, 0xf8, 0x00,
0x00, 0x0f, 0x00, 0x00, 0x00, 0x07, 0x1c, 0x00, 0x00, 0x01, 0xe0, 0x00, 0x00, 0x1e, 0x0e, 0x00,
0x00, 0x00, 0x3c, 0x00, 0x00, 0x38, 0x07, 0x00, 0x00, 0x00, 0x07, 0x98, 0x00, 0xf0, 0x03, 0xc0,
0x00, 0x00, 0x00, 0xfc, 0x01, 0xc0, 0x00, 0xe0, 0x00, 0x00, 0x00, 0x3c, 0x37, 0x80, 0x00, 0xf0,
0x00, 0x00, 0x00, 0x1f, 0xfe, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x60,
0x00, 0x00, 0x00, 0x00, 0x38, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x80, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x70, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]

View File

@@ -519,6 +519,7 @@ class ReticulumWrapper:
"st_alock": iface.get("st_alock"),
"lt_alock": iface.get("lt_alock"),
"mode": iface.get("mode", "full"),
"enable_framebuffer": iface.get("enable_framebuffer", True), # Display Columba logo on RNode
}
log_info("ReticulumWrapper", "_create_config_file",
f"RNode config stored for ColumbaRNodeInterface: {self._pending_rnode_config['target_device_name']}")

View File

@@ -55,6 +55,13 @@ class KISS:
CMD_RESET = 0x55
CMD_ERROR = 0x90
# External framebuffer (display)
CMD_FB_EXT = 0x41 # Enable/disable external framebuffer
CMD_FB_WRITE = 0x43 # Write framebuffer data
# Framebuffer constants
FB_BYTES_PER_LINE = 8 # 64 pixels / 8 bits per byte
# Detection
DETECT_REQ = 0x73
DETECT_RESP = 0x46
@@ -214,6 +221,10 @@ class ColumbaRNodeInterface:
self.r_stat_rssi = None
self.r_stat_snr = None
# External framebuffer (display) settings
self.enable_framebuffer = config.get("enable_framebuffer", False)
self.framebuffer_enabled = False
# Read thread
self._read_thread = None
self._running = False
@@ -348,6 +359,9 @@ class ColumbaRNodeInterface:
self.interface_ready = True
self.online = True
RNS.log(f"RNode '{self.name}' is online", RNS.LOG_INFO)
# Display Columba logo on RNode if enabled
self._display_logo()
else:
raise IOError("Radio configuration validation failed")
@@ -496,6 +510,73 @@ class ColumbaRNodeInterface:
raise IOError(f"Write failed after {max_retries} attempts: {last_error}")
# -------------------------------------------------------------------------
# External Framebuffer (Display) Methods
# -------------------------------------------------------------------------
def enable_external_framebuffer(self):
"""Enable external framebuffer mode on RNode display."""
kiss_command = bytes([KISS.FEND, KISS.CMD_FB_EXT, 0x01, KISS.FEND])
self._write(kiss_command)
self.framebuffer_enabled = True
RNS.log(f"{self} External framebuffer enabled", RNS.LOG_DEBUG)
def disable_external_framebuffer(self):
"""Disable external framebuffer, return to normal RNode UI."""
kiss_command = bytes([KISS.FEND, KISS.CMD_FB_EXT, 0x00, KISS.FEND])
self._write(kiss_command)
self.framebuffer_enabled = False
RNS.log(f"{self} External framebuffer disabled", RNS.LOG_DEBUG)
def write_framebuffer(self, line, line_data):
"""Write 8 bytes of pixel data to a specific line (0-63).
Args:
line: Line number (0-63)
line_data: 8 bytes of pixel data (64 pixels, 1 bit per pixel)
"""
if line < 0 or line > 63:
raise ValueError(f"Line must be 0-63, got {line}")
if len(line_data) != KISS.FB_BYTES_PER_LINE:
raise ValueError(f"Line data must be {KISS.FB_BYTES_PER_LINE} bytes")
data = bytes([line]) + line_data
escaped = KISS.escape(data)
kiss_command = bytes([KISS.FEND, KISS.CMD_FB_WRITE]) + escaped + bytes([KISS.FEND])
self._write(kiss_command)
def display_image(self, imagedata):
"""Send a 64x64 monochrome image to RNode display.
Args:
imagedata: List or bytes of 512 bytes (64 lines x 8 bytes per line)
"""
if len(imagedata) != 512:
raise ValueError(f"Image data must be 512 bytes, got {len(imagedata)}")
for line in range(64):
line_start = line * KISS.FB_BYTES_PER_LINE
line_end = line_start + KISS.FB_BYTES_PER_LINE
line_data = bytes(imagedata[line_start:line_end])
self.write_framebuffer(line, line_data)
RNS.log(f"{self} Sent 64x64 image to RNode framebuffer", RNS.LOG_DEBUG)
def _display_logo(self):
"""Display the Columba logo on RNode if framebuffer is enabled."""
if not self.enable_framebuffer:
return
try:
from columba_logo import columba_fb_data
self.display_image(columba_fb_data)
self.enable_external_framebuffer()
RNS.log(f"{self} Displayed Columba logo on RNode", RNS.LOG_DEBUG)
except ImportError:
RNS.log(f"{self} columba_logo module not found, skipping logo display", RNS.LOG_WARNING)
except Exception as e:
RNS.log(f"{self} Failed to display logo: {e}", RNS.LOG_WARNING)
def _read_loop(self):
"""Background thread for reading and parsing KISS frames."""
in_frame = False

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 B

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Convert Columba constellation icon to RNode framebuffer format.
This script renders the constellation design (nodes and connecting lines)
from ic_launcher_foreground.xml to a 64x64 monochrome bitmap suitable
for RNode's external framebuffer display.
Output: python/columba_logo.py with columba_fb_data byte array (512 bytes)
"""
from PIL import Image, ImageDraw
import os
# Original coordinates from ic_launcher_foreground.xml
# Viewport: 512x512, with group transform: scale(0.65) pivot(256,256) translate(6,-10)
# Node centers (solid circles) - coordinates in original 512x512 space
NODES = [
{"center": (406.658, 182.257), "radius": 9}, # Node 1 (top right)
{"center": (356.658, 138.257), "radius": 10.837}, # Node 2 (upper right)
{"center": (270.658, 193.257), "radius": 6.5}, # Node 3 (center)
{"center": (214.658, 403.257), "radius": 6.5}, # Node 4 (bottom)
{"center": (93.506, 128.312), "radius": 5}, # Node 5 (top left)
{"center": (230.655, 178.527), "radius": 5.34}, # Node 6 (left of center)
]
# Connecting lines (stroke width 6 in original)
LINES = [
((93.53, 128.431), (271.29, 193.077)), # Node 5 to Node 3
((270.75, 193.487), (216.617, 402.841)), # Node 3 to Node 4
((270.923, 193.326), (356.069, 139.108)), # Node 3 to Node 2
((405.781, 182.043), (356.85, 138.219)), # Node 1 to Node 2
]
# Transform parameters from the drawable
SCALE = 0.65
PIVOT = (256, 256)
TRANSLATE = (6, -10)
def get_bounding_box():
"""Calculate bounding box of all elements."""
all_points = []
for node in NODES:
cx, cy = node["center"]
r = node["radius"]
all_points.extend([(cx - r, cy - r), (cx + r, cy + r)])
for (x1, y1), (x2, y2) in LINES:
all_points.extend([(x1, y1), (x2, y2)])
xs = [p[0] for p in all_points]
ys = [p[1] for p in all_points]
return min(xs), min(ys), max(xs), max(ys)
def render_constellation(size=64, padding=4):
"""Render the constellation to an image.
Instead of using the original transform, we calculate a new transform
that centers and scales the design to fill the target size.
"""
# Create black background
img = Image.new('1', (size, size), 0) # 1-bit, black background
draw = ImageDraw.Draw(img)
# Get bounding box in original coordinates
min_x, min_y, max_x, max_y = get_bounding_box()
orig_width = max_x - min_x
orig_height = max_y - min_y
# Calculate scale to fit in target size with padding
available = size - 2 * padding
scale = min(available / orig_width, available / orig_height)
# Calculate offset to center
scaled_width = orig_width * scale
scaled_height = orig_height * scale
offset_x = (size - scaled_width) / 2 - min_x * scale
offset_y = (size - scaled_height) / 2 - min_y * scale
def transform(x, y):
return x * scale + offset_x, y * scale + offset_y
# Draw connecting lines first (so nodes appear on top)
line_width = max(1, int(2)) # Thin lines for 64px
for (x1, y1), (x2, y2) in LINES:
sx1, sy1 = transform(x1, y1)
sx2, sy2 = transform(x2, y2)
draw.line([(sx1, sy1), (sx2, sy2)], fill=1, width=line_width)
# Draw nodes (solid circles)
for node in NODES:
cx, cy = node["center"]
r = node["radius"]
scx, scy = transform(cx, cy)
# Scale radius, ensure minimum size of 1 pixel
sr = max(1.5, r * scale * 0.8)
# Draw filled ellipse
bbox = [scx - sr, scy - sr, scx + sr, scy + sr]
draw.ellipse(bbox, fill=1)
return img
def image_to_framebuffer(img):
"""Convert a 1-bit PIL image to RNode framebuffer format.
Format: 64 lines of 8 bytes each, MSB first, row-major.
Each byte represents 8 horizontal pixels.
"""
assert img.size == (64, 64), f"Expected 64x64, got {img.size}"
assert img.mode == '1', f"Expected 1-bit mode, got {img.mode}"
fb_data = []
for y in range(64):
for x_byte in range(8): # 8 bytes per row
byte_val = 0
for bit in range(8):
x = x_byte * 8 + bit
pixel = img.getpixel((x, y))
if pixel: # White pixel = 1
byte_val |= (1 << (7 - bit)) # MSB first
fb_data.append(byte_val)
return fb_data
def format_as_python(fb_data, var_name="columba_fb_data"):
"""Format the byte array as a Python module."""
lines = [
'"""',
'Columba logo framebuffer data for RNode external display.',
'',
'Format: 64x64 monochrome bitmap, 512 bytes',
' 8 pixels per byte, MSB first, row-major order',
'',
'Generated by scripts/convert_icon_to_framebuffer.py',
'"""',
'',
f'{var_name} = [',
]
# Format in rows of 16 bytes for readability (2 rows of display)
for i in range(0, len(fb_data), 16):
chunk = fb_data[i:i+16]
hex_str = ", ".join(f"0x{b:02x}" for b in chunk)
lines.append(f" {hex_str},")
lines.append(']')
lines.append('')
return '\n'.join(lines)
def main():
# Render the constellation
print("Rendering constellation icon at 64x64...")
img = render_constellation(64)
# Save preview (for debugging)
script_dir = os.path.dirname(os.path.abspath(__file__))
preview_path = os.path.join(script_dir, "columba_logo_preview.png")
# Scale up for better visibility
preview = img.resize((256, 256), Image.NEAREST)
preview.save(preview_path)
print(f"Preview saved to: {preview_path}")
# Convert to framebuffer format
print("Converting to framebuffer format...")
fb_data = image_to_framebuffer(img)
assert len(fb_data) == 512, f"Expected 512 bytes, got {len(fb_data)}"
# Generate Python module
python_code = format_as_python(fb_data)
# Write output
output_path = os.path.join(script_dir, "..", "python", "columba_logo.py")
output_path = os.path.normpath(output_path)
with open(output_path, 'w') as f:
f.write(python_code)
print(f"Output written to: {output_path}")
print(f"Total bytes: {len(fb_data)}")
# Print some stats
white_pixels = sum(bin(b).count('1') for b in fb_data)
total_pixels = 64 * 64
print(f"White pixels: {white_pixels}/{total_pixels} ({100*white_pixels/total_pixels:.1f}%)")
if __name__ == "__main__":
main()