diff --git a/tools/k5viewer/README.md b/tools/k5viewer/README.md deleted file mode 100644 index ec64ed96..00000000 --- a/tools/k5viewer/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# Quansheng K5Viewer - -K5Viewer is a lightweight viewer that displays the live screen output of the Quansheng K5 transceiver (running F4HWN firmware), transmitted via UART to your computer through a Baofeng/Kenwood-style USB-to-Serial cable. - -https://github.com/user-attachments/assets/578c8605-9820-4a2c-90e4-5a93b2be7d2b - -> [!WARNING] -> K5Viewer IS NOT A REMOTE CONTROL TOOL. - -It simply displays the live screen of the Quansheng K5 on your computer and allows you to take screenshots. -It can be useful for illustrating documentation, creating tutorials, or demonstrating how K5Viewer works — whether by screen sharing or using a video projector. -It's also a handy tool for development and debugging, especially for display-related features. - -## 🚀 Features - -- Realtime display of 128×64 monochrome screen via serial connection (UART) -- Delta frame updates to minimize bandwidth usage -- Capture screen snapshots in PNG format -- Switch background color (gray, blue, or orange) -- Toggle inverted video mode -- Toggle LCD pixel rendering mode -- Resize the window (zoom in/out) -- Display current FPS in the window title - -## 🛠️ Requirements - -- Python **3.6+** -- pip (Python package installer) - -### 📦 Install dependencies: - -If necessary, use this command to install dependencies: - -```bash -pip install pyserial pygame -``` - -## ▶️ How to Run - -1. Connect your **Quansheng K5** (running **F4HWN firmware** v4.2) to your computer using a Baofeng/Kenwood-style USB-to-Serial cable. - -2. Run the `k5viewer` with the appropriate serial port: - - ```bash - ./k5viewer.py -port /dev/ttyUSB0 # Linux - ./k5viewer.py -port /dev/cu.usbserial-xxxx # macOS - ./k5viewer.py -port COM3 # Windows - ``` - > [!NOTE] - > If no -port is provided, the script defaults to /dev/ttyUSB0. - -Alternatively, you can manually edit the script and change the `DEFAULT_PORT` variable near the top of the file: - - ```python - DEFAULT_PORT = '/dev/ttyUSB0' # Linux - # or - DEFAULT_PORT = '/dev/cu.usbserial-xxxx' # macOS - # or - DEFAULT_PORT = 'COM3' # Windows - ``` - -You can also list available serial ports to help you choose: - - ```bash - ./k5viewer.py --list-ports - ``` - -## 🎮 Controls - -| Key | Action | -|-----------|---------------------------------| -| `Q` | Quit the viewer | -| `G` | Set background to **gray** | -| `B` | Set background to **blue** | -| `O` | Set background to **orange** | -| `W` | Set background to **white** | -| `I` | Toggle **video inversion** | -| `P` | Toggle **LCD pixel mode** | -| `SPACE` | Save a screenshot as PNG | -| `UP` | Increase window size | -| `DOWN` | Decrease window size | - - -Screenshots are saved as `screenshot_YYYYMMDD_HHMMSS.png` in the same directory. - -## 📬 Contact - -If you encounter issues or have suggestions, feel free to open an issue or submit a pull request. Enjoy building with your Quansheng K5! 📡 diff --git a/tools/k5viewer/k5viewer.py b/tools/k5viewer/k5viewer.py deleted file mode 100755 index 0d36d2ab..00000000 --- a/tools/k5viewer/k5viewer.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 - -import os -import sys -import time -import datetime -import argparse - -os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" - -import pygame -import serial -from serial.tools import list_ports - -# Version -VERSION = '1.0' - -# Serial configuration -DEFAULT_PORT = '/dev/ttyUSB0' # Change if needed (/dev/cu.usbserial-11130) -BAUDRATE = 38400 -TIMEOUT = 0.5 - -# Screen configuration -WIDTH, HEIGHT = 128, 64 -FRAME_SIZE = 1024 - -# Protocol -HEADER = b'\xAA\x55' -TYPE_SCREENSHOT = b'\x01' -TYPE_DIFF = b'\x02' - -# Framebuffer -framebuffer = bytearray([0] * FRAME_SIZE) - - -COLOR_SETS = { # {key: (name, foreground, background)} - "g": ("Grey", pygame.Color(0, 0, 0), pygame.Color(202, 202, 202)), - "o": ("Orange", pygame.Color(0, 0, 0), pygame.Color(255, 193, 37)), - "b": ("Blue", pygame.Color(0, 0, 0), pygame.Color(28, 134, 228)), - "w": ("White", pygame.Color(0, 0, 0), pygame.Color(255, 255, 255)), -} - -DEFAULT_COLOR = "g" # Must be a key of "COLOR_SETS" - -def send_keepalive(ser: serial.Serial): - # Send keepalive frame - try: - ser.write(b'\x55\xAA\x00\x00') # Keepalive frame - except serial.SerialException: - pass - -def read_frame(ser: serial.Serial) -> bytearray: - global framebuffer - while True: - try: - b = ser.read(1) - except serial.SerialException as e: - #print(f"[ERROR] Serial read failed: {e}") - print("[!] Your USB serial cable is probably being used by another application such as Chirp or Chrome.") - sys.exit(1) - if not b: - return None - if b == HEADER[0:1]: - b2 = ser.read(1) - if b2 == HEADER[1:2]: - t = ser.read(1) - size_bytes = ser.read(2) - size = int.from_bytes(size_bytes, 'big') - if t == TYPE_SCREENSHOT and size == FRAME_SIZE: - payload = ser.read(FRAME_SIZE) - framebuffer = bytearray(payload) - return framebuffer - elif t == TYPE_DIFF and size % 9 == 0: - payload = ser.read(size) - framebuffer = apply_diff(framebuffer, payload) - return framebuffer - - -def apply_diff(framebuffer: bytearray, diff_payload: bytes) -> bytearray: - i = 0 - while i + 9 <= len(diff_payload): - block_index = diff_payload[i] - i += 1 - if block_index >= 128: - break - framebuffer[block_index * 8 : block_index * 8 + 8] = diff_payload[i : i + 8] - i += 8 - return framebuffer - - -def draw_frame(screen: pygame.Surface, framebuffer: bytearray, bg_color: pygame.Color, fg_color: pygame.Color, pixel_size: int = 4, pixel_lcd: int = 0) -> pygame.Surface: - def get_bit(bit_idx): - byte_idx = bit_idx // 8 - bit_pos = bit_idx % 8 - if byte_idx < len(framebuffer): - return (framebuffer[byte_idx] >> bit_pos) & 0x01 - return 0 - - screen.fill(bg_color) - bit_index = 0 - for y in range(64): - for x in range(128): - if get_bit(bit_index): - px = x * (pixel_size - 1) - py = y * pixel_size - pygame.draw.rect(screen, fg_color, (px, py, pixel_size - 1 - pixel_lcd, pixel_size - pixel_lcd)) - bit_index += 1 - - pygame.display.flip() - return pygame.display.get_surface().copy() - - -def run_viewer(args: argparse.Namespace, ser: serial.Serial): - pixel_size = 5 - pixel_lcd = 0 - pygame.init() - screen = pygame.display.set_mode((WIDTH * (pixel_size - 1), HEIGHT * pixel_size)) - base_title = f"Quansheng K5Viewer v{VERSION} by F4HWN" - pygame.display.set_caption(f"{base_title} – No data") - - fg_color, bg_color = COLOR_SETS[DEFAULT_COLOR][1:] - last_surface = None - frame_count = 0 - frame_lost = 0 - last_time = time.monotonic() - - while True: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - raise KeyboardInterrupt - elif event.type == pygame.KEYDOWN: - if event.key == pygame.K_q: - raise KeyboardInterrupt - if event.key == pygame.K_SPACE and last_surface: - filename = datetime.datetime.now().strftime("screenshot_%Y%m%d_%H%M%S.png") - pygame.image.save(last_surface, filename) - print(f"[✔] Screenshot saved: {filename}") - elif event.key == pygame.K_p: - pixel_lcd = 1 - pixel_lcd - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - elif event.key == pygame.K_i: - if bg_color == pygame.Color(0, 0, 0): - bg_color, fg_color = fg_color, pygame.Color(0, 0, 0) - else: - bg_color, fg_color = pygame.Color(0, 0, 0), bg_color - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - elif event.key == pygame.K_UP: - if pixel_size < 12: - pixel_size += 1 - #print(f"[✔] Resize: {pixel_size, (WIDTH * (pixel_size - 1)), HEIGHT * pixel_size}") - screen = pygame.display.set_mode((WIDTH * (pixel_size - 1), HEIGHT * pixel_size)) - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - elif event.key == pygame.K_DOWN: - if pixel_size > 3: - pixel_size -= 1 - #print(f"[✔] Resize: {pixel_size, (WIDTH * (pixel_size - 1)), HEIGHT * pixel_size}") - screen = pygame.display.set_mode((WIDTH * (pixel_size - 1), HEIGHT * pixel_size)) - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - pressed_key = event.unicode - if pressed_key in COLOR_SETS.keys(): - fg_color, bg_color = COLOR_SETS[pressed_key][1:] - frame = read_frame(ser) - if frame: - last_surface = draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - frame_count += 1 - now = time.monotonic() - if now - last_time >= 1.0: - fps = frame_count / (now - last_time) - pygame.display.set_caption(f"{base_title} – FPS: {fps:>04.1f}") - frame_count = 0 - last_time = now - frame_lost = 0 - else: - frame_lost = min(frame_lost + 1, 5) - if frame_lost == 5: - pygame.display.set_caption(f"{base_title} – No data") - - send_keepalive(ser) - - -def cmd_list_ports(args: argparse.Namespace): - ports = list_ports.comports() - print("Available ports:") - for port in ports: - if port.vid is None: # Skipping virtual or non-USB ports - continue - description = " - ".join(filter(None, (port.product, port.manufacturer))) - if description: - print(f"- {description} : {port.device}") - else: - print(f"- {port.device}") - - -def main(): - parser = argparse.ArgumentParser( - prog="K5Viewer", - description="A live viewer for UV-K5 radios with F4HWN firmware", - epilog="F4HWN repo: https://github.com/armel/uv-k5-firmware-custom" - ) - parser.add_argument("--list-ports", action="store_true", help="list available ports and exit") - parser.add_argument("--port", type=str, help="serial port to use (in place of 'DEFAULT_PORT')") - parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}", help="show program's version number and exit") - - args = parser.parse_args() - if args.list_ports: - cmd_list_ports(args) - exit(0) - # Running viewer - if not args.port and not DEFAULT_PORT: - print("Please specify the serial port to use or set 'DEFAULT_PORT', do 'k5viewer.py --help' for help") - exit(1) - serial_port = args.port or DEFAULT_PORT - try: - ser = serial.Serial(serial_port, BAUDRATE, timeout=TIMEOUT) - except serial.SerialException as e: - print(f"[!] Serial error: {e}") - sys.exit(1) - try: - run_viewer(args, ser) - except KeyboardInterrupt: - print("[✔] Exiting") - ser.close() - pygame.quit() - sys.exit() - - -if __name__ == "__main__": - main() \ No newline at end of file