| #!/usr/bin/env python3 |
| # Copyright 2023 The ChromiumOS Authors |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Uploads an image on to an FPMCU dev board. |
| |
| # Requirements |
| |
| Install the Python dependencies in your chroot: |
| |
| (chroot) emerge pillow |
| |
| Build the EC image with debug mode enabled: |
| |
| (chroot) platform/ec $ make BOARD=$BOARD EXTRA_CFLAGS=-DCONFIG_CMD_FPSENSOR_DEBUG |
| |
| # Usage |
| |
| Start servod with the board that is being tested: |
| |
| (chroot) sudo servod --board ${BOARD} |
| |
| Flash the debug EC image onto FPMCU: |
| |
| (chroot) ./util/flash_ec --board=$BOARD --image=./build/$BOARD/ec.bin |
| |
| Upload the image file: |
| |
| (chroot) ./util/fpmcu_upload.py -i /tmp/img1.png -b ${BOARD} |
| |
| Run unit tests |
| |
| (chroot) python -m unittest util/fpmcu_upload.py |
| """ |
| |
| import argparse |
| from contextlib import ExitStack |
| from dataclasses import dataclass |
| from enum import Enum |
| import io |
| import logging |
| import subprocess |
| import sys |
| from typing import List, Optional, Tuple |
| import unittest |
| |
| # pylint: disable=import-error |
| from PIL import Image |
| |
| |
| # pylint:enable=import-error |
| |
| |
| class SensorByteOrder(Enum): |
| """Storage order of bytes returned by the sensor""" |
| |
| ROW_MAJOR = 1 |
| COLUMN_MAJOR = 2 |
| |
| |
| # TODO(b/267803007): Refactor this section of the code to remove duplication |
| # with run_device_tests.py |
| @dataclass |
| class BoardConfig: |
| """Board-specific configuration.""" |
| |
| name: str |
| servo_uart_name: str |
| sensor_height: int |
| sensor_width: int |
| sensor_byte_order: SensorByteOrder |
| |
| |
| BLOONCHIPPER_CONFIG = BoardConfig( |
| name="bloonchipper", |
| servo_uart_name="raw_fpmcu_console_uart_pty", |
| sensor_width=160, |
| sensor_height=160, |
| sensor_byte_order=SensorByteOrder.ROW_MAJOR, |
| ) |
| |
| DARTMONKEY_CONFIG = BoardConfig( |
| name="dartmonkey", |
| servo_uart_name="raw_fpmcu_console_uart_pty", |
| sensor_width=56, |
| sensor_height=192, |
| sensor_byte_order=SensorByteOrder.COLUMN_MAJOR, |
| ) |
| |
| BOARD_CONFIGS = { |
| "bloonchipper": BLOONCHIPPER_CONFIG, |
| "dartmonkey": DARTMONKEY_CONFIG, |
| } |
| |
| |
| def get_console(board_config: BoardConfig) -> Optional[str]: |
| """Get the name of the console for a given board.""" |
| cmd = [ |
| "dut-control", |
| board_config.servo_uart_name, |
| ] |
| logging.debug('Running command: "%s"', " ".join(cmd)) |
| |
| with subprocess.Popen(cmd, stdout=subprocess.PIPE) as proc: |
| for line in io.TextIOWrapper(proc.stdout): # type: ignore[arg-type] |
| logging.debug(line) |
| pty = line.split(":") |
| if len(pty) == 2 and pty[0] == board_config.servo_uart_name: |
| return pty[1].strip() |
| |
| return None |
| |
| |
| def load_image_from_png(image_path: str) -> Image: |
| """Load a PNG image from file""" |
| if not image_path.endswith(".png"): |
| raise ValueError( |
| f"Input file must be png, sorry. Input was {image_path}" |
| ) |
| |
| return Image.open(image_path) |
| |
| |
| def pixel_value_to_hex(value: int) -> str: |
| """Convert an 8-bit pixel value to a hex string of two characters""" |
| if value < 0 or value > 255: |
| raise ValueError(f"Pixel value {value} outside range 0-255") |
| return f"{value:02x}" |
| |
| |
| def image_to_hex_str(image: Image, byte_order: SensorByteOrder) -> str: |
| """Convert an 8-bit image to a string of hex characters""" |
| image_str = "" |
| num_cols, num_rows = image.size |
| if byte_order == SensorByteOrder.ROW_MAJOR: |
| for row in range(num_rows): |
| for col in range(num_cols): |
| image_str += pixel_value_to_hex(image.getpixel((col, row))) |
| elif byte_order == SensorByteOrder.COLUMN_MAJOR: |
| for col in range(num_cols): |
| for row in range(num_rows): |
| image_str += pixel_value_to_hex(image.getpixel((col, row))) |
| |
| return image_str |
| |
| |
| def split_hex_str(hex_str: str, chunk_size: int) -> List[Tuple[int, str]]: |
| """Split a string with hex values into chunks with chunk_size values. |
| |
| hex_str is expected to contain hex values represented with two |
| characters each. |
| chunk_size is the number of hex values so the output string will contain |
| 2 x chunk_size characters. |
| |
| Returns a list of tuples (offset, hex_str). |
| """ |
| |
| if len(hex_str) % 2: |
| raise ValueError("Input string does not represent hex values") |
| |
| chunks = [] |
| offset = 0 |
| while 2 * offset < len(hex_str): |
| chunks.append((offset, hex_str[2 * offset : 2 * (offset + chunk_size)])) |
| offset += chunk_size |
| |
| return chunks |
| |
| |
| def get_fpupload_cmds(board_config: BoardConfig, image: Image) -> List[str]: |
| """Return the sequence of commands to upload the image on the FPMCU""" |
| |
| # Check that image has the correct size for the board selected. |
| expected_size = (board_config.sensor_height, board_config.sensor_width) |
| if image.size != expected_size: |
| raise ValueError( |
| f"Image size is {image.size} but expected {expected_size}" |
| ) |
| |
| logging.info("Image size: %s", image.size) |
| |
| # Convert the image as a string of hex values, then break it into chunks |
| image_str = image_to_hex_str(image, board_config.sensor_byte_order) |
| |
| payload_size = 20 |
| cmds = [] |
| for offset, payload in split_hex_str(image_str, payload_size): |
| cmds.append(f"fpupload {offset} {payload}\n") |
| return cmds |
| |
| |
| def main(): |
| """Load a PNG image from file and transfer it to the FPMCU""" |
| |
| parser = argparse.ArgumentParser() |
| |
| default_board = "bloonchipper" |
| parser.add_argument( |
| "--board", |
| "-b", |
| help="Board (default: " + default_board + ")", |
| default=default_board, |
| ) |
| parser.add_argument( |
| "--image", |
| "-i", |
| help="Image file in PNG", |
| default="", |
| ) |
| parser.add_argument( |
| "--echo", |
| help="Debug: whether or not to echo back the input image.", |
| action="store_true", |
| ) |
| |
| args = parser.parse_args() |
| board_config = BOARD_CONFIGS[args.board] |
| |
| image = load_image_from_png(args.image) |
| fpupload_cmds = get_fpupload_cmds(board_config, image) |
| |
| with ExitStack() as stack: |
| console = stack.enter_context( |
| open( # pylint:disable=consider-using-with |
| get_console(board_config), "wb+", buffering=0 |
| ) |
| ) |
| for cmd in fpupload_cmds: |
| console.write(cmd.encode()) |
| |
| if args.echo: |
| console.write("fpdownload".encode()) |
| |
| |
| if __name__ == "__main__": |
| sys.exit(main()) |
| |
| |
| # Disable docstrings for unit tests |
| # pylint: disable=missing-module-docstring |
| # pylint: disable=missing-class-docstring |
| # pylint: disable=missing-function-docstring |
| class TestLoadImageFromPng(unittest.TestCase): |
| def test_invalid_file_extension(self): |
| with self.assertRaises(ValueError): |
| load_image_from_png("not_a_png.jpg") |
| |
| def test_empty_file_path(self): |
| with self.assertRaises(ValueError): |
| load_image_from_png("") |
| |
| |
| class TestPixelValueToHex(unittest.TestCase): |
| def test_value_below_zero(self): |
| with self.assertRaises(ValueError): |
| pixel_value_to_hex(-1) |
| |
| def test_value_above_255(self): |
| with self.assertRaises(ValueError): |
| pixel_value_to_hex(256) |
| |
| def test_valid_values(self): |
| self.assertEqual(pixel_value_to_hex(0), "00") |
| self.assertEqual(pixel_value_to_hex(1), "01") |
| self.assertEqual(pixel_value_to_hex(10), "0a") |
| self.assertEqual(pixel_value_to_hex(255), "ff") |
| |
| |
| class TestImageToHexStr(unittest.TestCase): |
| # Use a small image with width = 5 and height = 3 |
| def test_row_major_order(self): |
| image = Image.new("P", (5, 3)) |
| image.putpixel((0, 1), 255) |
| image_str = image_to_hex_str(image, SensorByteOrder.ROW_MAJOR) |
| self.assertEqual(len(image_str), 30) |
| self.assertEqual(image_str[:2], "00") |
| self.assertEqual(image_str[10:12], "ff") |
| |
| def test_col_major_order(self): |
| image = Image.new("P", (5, 3)) |
| image.putpixel((0, 1), 255) |
| image_str = image_to_hex_str(image, SensorByteOrder.COLUMN_MAJOR) |
| self.assertEqual(len(image_str), 30) |
| self.assertEqual(image_str[:2], "00") |
| self.assertEqual(image_str[2:4], "ff") |
| |
| |
| class TestSplitHexStr(unittest.TestCase): |
| def test_invalid_hex_str(self): |
| with self.assertRaises(ValueError): |
| split_hex_str("123", 4) |
| |
| def test_one_chunk(self): |
| res = split_hex_str("12345678", 4) |
| self.assertEqual(len(res), 1) |
| self.assertEqual(res[0], (0, "12345678")) |
| |
| def test_one_partial_chunk(self): |
| res = split_hex_str("123456", 4) |
| self.assertEqual(len(res), 1) |
| self.assertEqual(res[0], (0, "123456")) |
| |
| def test_two_chunks(self): |
| res = split_hex_str("12345678", 2) |
| self.assertEqual(len(res), 2) |
| self.assertEqual(res[0], (0, "1234")) |
| self.assertEqual(res[1], (2, "5678")) |
| |
| def test_two_partial_chunks(self): |
| res = split_hex_str("12345678", 3) |
| self.assertEqual(len(res), 2) |
| self.assertEqual(res[0], (0, "123456")) |
| self.assertEqual(res[1], (3, "78")) |
| |
| |
| class TestGetFpuploadCmds(unittest.TestCase): |
| def test_invalid_size(self): |
| image = Image.new("P", (10, 20)) |
| with self.assertRaises(ValueError): |
| get_fpupload_cmds(DARTMONKEY_CONFIG, image) |
| |
| def test_valid_size(self): |
| image = Image.new("P", (192, 56)) |
| cmds = get_fpupload_cmds(DARTMONKEY_CONFIG, image) |
| self.assertEqual(len(cmds), 538) |
| self.assertEqual(cmds[0].split()[0], "fpupload") |
| self.assertEqual(cmds[0].split()[1], "0") |
| self.assertEqual(cmds[1].split()[0], "fpupload") |
| self.assertEqual(cmds[1].split()[1], "20") |
| # The image has 10752 pixels which requires 538 commands for chunk size 20. |
| self.assertEqual(cmds[537].split()[0], "fpupload") |
| # 537 commands * offset 20 = 10740 final offset. |
| self.assertEqual(cmds[537].split()[1], "10740") |