diff --git a/python/columba_logo.py b/python/columba_logo.py new file mode 100644 index 0000000..6645a93 --- /dev/null +++ b/python/columba_logo.py @@ -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, +] diff --git a/python/reticulum_wrapper.py b/python/reticulum_wrapper.py index 5c3fa64..aa062fd 100644 --- a/python/reticulum_wrapper.py +++ b/python/reticulum_wrapper.py @@ -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']}") diff --git a/python/rnode_interface.py b/python/rnode_interface.py index d36ea20..344897d 100644 --- a/python/rnode_interface.py +++ b/python/rnode_interface.py @@ -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 diff --git a/scripts/columba_logo_preview.png b/scripts/columba_logo_preview.png new file mode 100644 index 0000000..eb66b19 Binary files /dev/null and b/scripts/columba_logo_preview.png differ diff --git a/scripts/convert_icon_to_framebuffer.py b/scripts/convert_icon_to_framebuffer.py new file mode 100644 index 0000000..12b5a6a --- /dev/null +++ b/scripts/convert_icon_to_framebuffer.py @@ -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()