mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
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>
202 lines
6.3 KiB
Python
202 lines
6.3 KiB
Python
#!/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()
|