mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
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:
43
python/columba_logo.py
Normal file
43
python/columba_logo.py
Normal 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,
|
||||
]
|
||||
@@ -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']}")
|
||||
|
||||
@@ -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
|
||||
|
||||
BIN
scripts/columba_logo_preview.png
Normal file
BIN
scripts/columba_logo_preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 294 B |
201
scripts/convert_icon_to_framebuffer.py
Normal file
201
scripts/convert_icon_to_framebuffer.py
Normal 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()
|
||||
Reference in New Issue
Block a user