├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── hiburn ├── __init__.py ├── actions.py ├── config.py ├── serial_over_telnet.py ├── u_boot_client.py ├── utils.py └── ymodem.py ├── hiburn_app.py ├── images ├── hiburn200.png └── hiburn_social.png └── tests └── test_ymodem.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Try GH Actions 4 | 5 | # Controls when the action will run. Triggers the workflow on push or pull request 6 | # events but only for the master branch 7 | on: 8 | pull_request: 9 | branches: [ master ] 10 | 11 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 12 | jobs: 13 | # This workflow contains a single job called "check_style" 14 | check_style: 15 | # The type of runner that the job will run on 16 | runs-on: ubuntu-latest 17 | # Steps represent a sequence of tasks that will be executed as part of the job 18 | steps: 19 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 20 | - name: Check out repository 21 | uses: actions/checkout@v2 22 | - name: GitHub Action for Flake8 23 | uses: cclauss/Find-Python-syntax-errors-action@master 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 OpenHisiIpCam team 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | run-tests: 3 | PYTHONPATH=$(CURDIR) pytest-3 -r p tests/ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | hiburn 3 |

4 | 5 |

HiBurn

6 | 7 | --- 8 | 9 |

Deploy automation tool for HiSilicon`s ip camera modules

10 |

Part of OpenHisiIpCam project

11 | 12 | ## :pencil: Table of Contents 13 | - [About](#about) 14 | - [Installation](#installation) 15 | - [Usage](#usage) 16 | 17 | ## :eyeglasses: About 18 | To deploy custom firmware (Kernel & RootFS images) onto a HiSilicon camera you usually need to do a number of actions: reset the camera's power, "catch" U-Boot console, configure network, launch TFTP server etc. It becomes especially irritating when you do it over and over again. 19 | The tool is intended to automate this routine. All you need is to launch **hiburn**, reset camera's power and press Enter. We believe it may save your time and nerves =) 20 | 21 | ## :cd: Installation 22 | 23 | The tool is written on python3 and needs (obviously) python3 as well, as a few packages from PyPI. 24 | 25 | Assuming you are on some deb base GNU/Linux (like Debian or Ubuntu), you can satisfy deps following way: 26 | ```console 27 | foo@bar:~$ sudo apt-get install python3 python3-serial python3-pip 28 | foo@bar:~$ sudo pip3 install tftpy 29 | ``` 30 | ## :hammer: Usage 31 | 32 | The actual description of capabilities and options you may get via `./hiburn_app.py --help`: 33 | 34 | ```console 35 | foo@bar:~/hiburn$ ./hiburn_app.py --help 36 | usage: hiburn_app.py [-h] [--verbose] (--serial V | --serial-over-telnet V) 37 | [--no-fetch] [--reset-cmd RESET_CMD] 38 | [--net-device_ip V] [--net-host_ip_mask V] 39 | [--mem-start_addr V] [--mem-alignment V] 40 | [--mem-linux_size V] [--mem-uboot_size V] 41 | [--linux_console V] 42 | {printenv,ping,download,upload,boot} ... 43 | 44 | optional arguments: 45 | -h, --help Show this help message and exit 46 | --verbose, -v Print debug output 47 | --serial V Serial port 'port[:baudrate[:DPS]]' 48 | --serial-over-telnet V Serial-over-telnet endpoint '[host:]port' 49 | --no-fetch, -n Assume U-Boot's console is already fetched 50 | --reset-cmd RESET_CMD Shell command to reset device's power 51 | --net-device_ip V Target IP address, default: 192.168.10.101 52 | --net-host_ip_mask V Host IP address and mask's length, default: 192.168.10.2/24 53 | --mem-start_addr V RAM start address, default: 0x80000000 54 | --mem-alignment V RAM alignment for uploading, default: 64K 55 | --mem-linux_size V Amount of RAM for Linux, default: 256M 56 | --mem-uboot_size V , default: 512K 57 | --linux_console V Linux load console, default: ttyAMA0,115200 58 | 59 | Action: 60 | {printenv,ping,download,upload,boot} 61 | printenv Print U-Boot environment variables 62 | ping Configure network on device and ping host 63 | download Download data from device's RAM via TFTP 64 | upload Upload data to device's RAM via TFTP 65 | boot Upload Kernel and RootFS images into device's RAM and boot it 66 | ``` 67 | 68 | ### Examples 69 | 70 | There is an example command to upload images into device's memory via TFTP and boot it 71 | 72 | ```console 73 | foo@bar:~/hiburn$ ./hiburn_app.py --serial /dev/ttyCAM1:115200 --net-device_ip 192.168.10.101 --net-host_ip_mask 192.168.10.2/24 --mem-start_addr 0x80000000 --mem-linux_size 256M boot --uimage /path/to/my/kernel/uImage --rootfs /path/yo/my/rootfs.squashfs` 74 | ``` 75 | 76 | ### Notes 77 | - Since U-Boot usually connects to default TFTP server's port (69) you will need to be a root (or find some workaround like `authbind`). Another option is ```--ymodem```-mode for uploading via serial port. 78 | - Existing commands write into your device's RAM only; its flash stays pristine. So the device won't turn into a brick if something goes wrong - just reset it. 79 | 80 | *The tool is written on Python and it should be easy to check sources and fix/modify it for your needs :smirk:* 81 | -------------------------------------------------------------------------------- /hiburn/__init__.py: -------------------------------------------------------------------------------- 1 | from .u_boot_client import UBootClient 2 | 3 | __all__ = [ 4 | UBootClient 5 | ] 6 | -------------------------------------------------------------------------------- /hiburn/actions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import ipaddress 3 | import os 4 | from . import utils 5 | from . import ymodem 6 | 7 | 8 | # ------------------------------------------------------------------------------------------------- 9 | class Action: 10 | @classmethod 11 | def _run(cls, client, config, args): 12 | return cls(client, config).run(args) 13 | 14 | def __init__(self, client, config): 15 | self.client = client 16 | self.config = config 17 | 18 | @classmethod 19 | def add_arguments(cls, parser): 20 | pass 21 | 22 | def run(self, args): 23 | raise NotImplementedError() 24 | 25 | # some helper methods are below 26 | @property 27 | def host_ip(self): 28 | return ipaddress.ip_interface(self.config["net"]["host_ip_mask"]).ip 29 | 30 | @property 31 | def host_netmask(self): 32 | return ipaddress.ip_interface(self.config["net"]["host_ip_mask"]).netmask 33 | 34 | @property 35 | def device_ip(self): 36 | return ipaddress.ip_address(self.config["net"]["device_ip"]) 37 | 38 | def configure_network(self): 39 | """ Common method to configure network on target device 40 | """ 41 | self.client.setenv( 42 | ipaddr=self.device_ip, 43 | serverip=self.host_ip, 44 | netmask=self.host_netmask 45 | ) 46 | 47 | def upload_files(self, *args): 48 | utils.upload_files_via_tftp(self.client, args, listen_ip=str(self.host_ip)) 49 | 50 | def upload_y_files(self, *args): 51 | for fname, addr in args: 52 | with open(fname, "rb") as f: 53 | data = f.read() 54 | self.client.loady(addr, data) 55 | 56 | 57 | def add_actions(parser, *actions): 58 | subparsers = parser.add_subparsers(title="Action") 59 | for action in actions: 60 | action_parser = subparsers.add_parser(action.__name__, 61 | help=action.__doc__.strip() if action.__doc__ else None 62 | ) 63 | action.add_arguments(action_parser) 64 | action_parser.set_defaults(action=action._run) 65 | 66 | 67 | # ------------------------------------------------------------------------------------------------- 68 | class printenv(Action): 69 | """ Print U-Boot environment variables 70 | """ 71 | def run(self, args): 72 | result = self.client.printenv() 73 | print("\n".join(result)) 74 | 75 | 76 | # ------------------------------------------------------------------------------------------------- 77 | class ping(Action): 78 | """ Configure network on device and ping host 79 | """ 80 | def run(self, args): 81 | self.configure_network() 82 | result = self.client.ping(self.host_ip)[-1] 83 | if not result.endswith("is alive"): 84 | raise RuntimeError("network is unavailable") 85 | print("Network is fine") 86 | 87 | 88 | # ------------------------------------------------------------------------------------------------- 89 | class download(Action): 90 | """ Download data from device's RAM via TFTP 91 | """ 92 | @classmethod 93 | def add_arguments(cls, parser): 94 | parser.add_argument("--dst", type=str, default="./dump", help="Destination file") 95 | parser.add_argument("--addr", type=utils.hsize2int, required=True, help="Address to start downloading from") 96 | parser.add_argument("--size", type=utils.hsize2int, required=True, help="Amount of bytes to be downloaded") 97 | 98 | def run(self, args): 99 | self.configure_network() 100 | utils.download_files_via_tftp(self.client, ( 101 | (args.dst, args.addr, args.size), 102 | ), listen_ip=str(self.host_ip)) 103 | 104 | 105 | # ------------------------------------------------------------------------------------------------- 106 | class upload(Action): 107 | """ Upload data to device's RAM via TFTP 108 | """ 109 | @classmethod 110 | def add_arguments(cls, parser): 111 | parser.add_argument("--src", type=str, required=True, help="File to be uploaded") 112 | parser.add_argument("--addr", type=utils.hsize2int, required=True, help="Destination address in device's memory") 113 | 114 | def run(self, args): 115 | self.configure_network() 116 | self.upload_files((args.src, args.addr)) 117 | 118 | 119 | # ------------------------------------------------------------------------------------------------- 120 | class boot(Action): 121 | """ Upload Kernel and RootFS images into device's RAM and boot it 122 | """ 123 | @classmethod 124 | def add_arguments(cls, parser): 125 | parser.add_argument("--uimage", type=str, required=True, help="Kernel UImage file") 126 | parser.add_argument("--rootfs", type=str, required=True, help="RootFS image file") 127 | parser.add_argument("--upload-addr", type=utils.hsize2int, 128 | help="Start address to upload into") 129 | parser.add_argument("--initrd-size", type=utils.hsize2int, 130 | help="Amount of RAM for initrd (actual size of RootFS image file by default)") 131 | parser.add_argument("--no-wait", action="store_true", 132 | help="Don't wait end of serial output and exit immediately after sending 'bootm' command") 133 | parser.add_argument("--ymodem", action="store_true", 134 | help="Upload via serial (ymodem protocol)") 135 | 136 | bootargs_group = parser.add_argument_group("bootargs", "Kernel's boot arguments") 137 | bootargs_group.add_argument("--bootargs-ip", metavar="IP", type=str, 138 | help="Literal value for `ip=` parameter") 139 | bootargs_group.add_argument("--bootargs-ip-gw", metavar="IP",type=str, 140 | help="Value for of `ip=` parameter") 141 | bootargs_group.add_argument("--bootargs-ip-hostname", metavar="HOSTNAME", type=str, 142 | help="Value for of `ip=` parameter") 143 | bootargs_group.add_argument("--bootargs-ip-dns1", metavar="IP", type=str, 144 | help="Value for of `ip=` parameter") 145 | bootargs_group.add_argument("--bootargs-ip-dns2", metavar="IP", type=str, 146 | help="Value for of `ip=` parameter") 147 | 148 | def get_bootargs_ip(self, args): 149 | if args.bootargs_ip is not None: 150 | return args.bootargs_ip 151 | fmt = "{client_ip}:{server_ip}:{gw_ip}:{netmask}:{hostname}:{device}:{autoconf}:{dns0_ip}:{dns1_ip}:{ntp0_ip}" 152 | return fmt.format( 153 | client_ip=self.device_ip, 154 | server_ip=self.host_ip, 155 | gw_ip=args.bootargs_ip_gw or self.host_ip, 156 | netmask=self.host_netmask, 157 | hostname=args.bootargs_ip_hostname or "camera1", 158 | device="", 159 | autoconf="off", 160 | dns0_ip=args.bootargs_ip_dns1 or self.host_ip, 161 | dns1_ip=args.bootargs_ip_dns2 or "", 162 | ntp0_ip="" 163 | ) 164 | 165 | def run(self, args): 166 | uimage_size = os.path.getsize(args.uimage) 167 | rootfs_size = os.path.getsize(args.rootfs) if args.initrd_size is None else args.initrd_size 168 | 169 | alignment = self.config["mem"]["alignment"] 170 | if args.upload_addr is None: 171 | mem_end_addr = self.config["mem"]["start_addr"] + self.config["mem"]["linux_size"] 172 | rootfs_addr = utils.align_address_down(alignment, mem_end_addr - rootfs_size) 173 | uimage_addr = utils.align_address_down(alignment, rootfs_addr - uimage_size) 174 | else: 175 | uimage_addr = utils.align_address_up(alignment, args.upload_addr) # to ensure alignment 176 | rootfs_addr = utils.align_address_up(alignment, uimage_addr + uimage_size) 177 | 178 | logging.info("Kernel uImage upload addr {:#x}; RootFS image upload addr {:#x}".format( 179 | uimage_addr, rootfs_addr 180 | )) 181 | 182 | if args.ymodem: 183 | self.upload_y_files((args.uimage, uimage_addr), (args.rootfs, rootfs_addr)) 184 | else: 185 | self.configure_network() 186 | self.upload_files((args.uimage, uimage_addr), (args.rootfs, rootfs_addr)) 187 | 188 | bootargs = "" 189 | bootargs += "mem={} ".format(self.config["mem"]["linux_size"]) 190 | bootargs += "console={} ".format(self.config["linux_console"]) 191 | bootargs += "ip=" + self.get_bootargs_ip(args) + " " 192 | 193 | bootargs += "mtdparts=hi_sfc:512k(boot) " 194 | bootargs += "root=/dev/ram0 ro initrd={:#x},{}".format(rootfs_addr, rootfs_size) 195 | 196 | logging.info("Load kernel with bootargs: {}".format(bootargs)) 197 | 198 | self.client.setenv(bootargs=bootargs) 199 | resp = self.client.bootm(uimage_addr, wait=(not args.no_wait)) 200 | if resp is None: 201 | print("'bootm' command has been sent. Hopefully booting is going on well...") 202 | else: 203 | print( 204 | "Output ended with next lines:\n" + 205 | "... {} lines above\n".format(len(resp)) + 206 | "----------------------------------------\n" + 207 | "\n".join(" {}".format(l.strip()) for l in resp[-10:]) + 208 | "\n----------------------------------------" 209 | ) 210 | 211 | 212 | # ------------------------------------------------------------------------------------------------- 213 | class download_sf(Action): 214 | """ Download data from device's SPI flasg via TFTP 215 | """ 216 | @classmethod 217 | def add_arguments(cls, parser): 218 | parser.add_argument("--probe", type=str, required=True, help="'sf probe' arguments") 219 | parser.add_argument("--size", type=utils.hsize2int, required=True, help="Amount of bytes to be downloaded") 220 | parser.add_argument("--offset", type=utils.hsize2int, default=0, help="Flash offset") 221 | parser.add_argument("--dst", type=str, default="./dump.bin", help="Destination file") 222 | parser.add_argument("--addr", type=utils.hsize2int, help="Devices's RAM address read data from flash into") 223 | 224 | def run(self, args): 225 | DEFAULT_MEM_ADDR = self.config["mem"]["start_addr"] + (1 << 20) # 1Mb 226 | 227 | self.configure_network() 228 | self.client.sf_probe(args.probe) 229 | 230 | mem_addr = DEFAULT_MEM_ADDR if args.addr is None else args.addr 231 | logging.info("Read {} bytes from {} offset of SPI flash into memory at {}...".format(args.size, args.offset, mem_addr)) 232 | self.client.sf_read(mem_addr, args.offset, args.size) 233 | 234 | utils.download_files_via_tftp(self.client, ( 235 | (args.dst, mem_addr, args.size), 236 | ), listen_ip=str(self.host_ip)) 237 | 238 | 239 | # ------------------------------------------------------------------------------------------------- 240 | class upload_y(Action): 241 | """ Upload data to device's RAM via serial (ymodem) 242 | """ 243 | @classmethod 244 | def add_arguments(cls, parser): 245 | pass 246 | # parser.add_argument("--src", type=str, required=True, help="File to be uploaded") 247 | # parser.add_argument("--addr", type=utils.hsize2int, required=True, help="Destination address in device's memory") 248 | 249 | def run(self, args): 250 | self.client.loady(b"bla bla bla!") 251 | -------------------------------------------------------------------------------- /hiburn/config.py: -------------------------------------------------------------------------------- 1 | 2 | import copy 3 | import json 4 | import logging 5 | from . import utils 6 | 7 | 8 | # ------------------------------------------------------------------------------------------------- 9 | def _update_config_by_args(config, args, prefix=""): 10 | for k, v in config.items(): 11 | arg_name = prefix + k.replace("-", "_") 12 | 13 | if isinstance(v, dict): 14 | _update_config_by_args(v, args, arg_name + "_") 15 | continue 16 | 17 | arg_val = args.get(arg_name) 18 | if arg_val is not None: 19 | config[k] = arg_val 20 | 21 | 22 | # ------------------------------------------------------------------------------------------------- 23 | def _add_args_from_config_desc(parser, config_desc, prefix="--"): 24 | for key, val in config_desc.items(): 25 | arg_name = prefix + key 26 | 27 | if isinstance(val, dict): 28 | _add_args_from_config_desc(parser, val, arg_name + "-") 29 | continue 30 | 31 | if isinstance(val, tuple): # tuple contains: value, type, help 32 | parser.add_argument(arg_name, type=val[1], metavar="V", 33 | help="{}, default: {}".format(val[2], val[0])) 34 | else: 35 | t = utils.str2bool if isinstance(val, bool) else type(val) 36 | parser.add_argument(arg_name, type=t, metavar="V", 37 | help="{}, default: {}".format(type(val).__name__, val)) 38 | 39 | 40 | # ------------------------------------------------------------------------------------------------- 41 | def _update_config(dst, src, config_desc, path=""): 42 | for key, new_val in src.items(): 43 | orig_val = dst.get(key) 44 | field_desc = config_desc.get(key) 45 | if isinstance(new_val, dict): 46 | _update_config(orig_val, new_val, field_desc, "{}/{}".format(path, key)) 47 | else: 48 | if (type(field_desc) is tuple) and (type(new_val) is str): 49 | dst[key] = field_desc[1](new_val) # perform conversion 50 | else: 51 | dst[key] = type(field_desc)(new_val) 52 | logging.debug("Set {}={} from config file".format(key, dst[key])) 53 | 54 | 55 | # ------------------------------------------------------------------------------------------------- 56 | def _create_config_from_desc(config_desc): 57 | res = {} 58 | for key, val in config_desc.items(): 59 | if isinstance(val, tuple): # tuple contains: value, type, help 60 | res[key] = val[1](val[0]) 61 | elif isinstance(val, dict): 62 | res[key] = _create_config_from_desc(val) 63 | else: 64 | res[key] = val 65 | return res 66 | 67 | 68 | # ------------------------------------------------------------------------------------------------- 69 | def add_arguments_from_config_desc(parser, config_desc, read_from_file=False): 70 | parser.add_argument("--config", "-C", type=str, metavar="PATH", help="Config path") 71 | _add_args_from_config_desc(parser, config_desc) 72 | 73 | 74 | # ------------------------------------------------------------------------------------------------- 75 | def get_config_from_args(args, config_desc): 76 | config = _create_config_from_desc(config_desc) 77 | 78 | if args.config is not None: 79 | logging.debug("Update default config by user's one '{}'".format(args.config)) 80 | with open(args.config, "r") as f: 81 | user_config = json.load(f) 82 | _update_config(config, user_config, config_desc) 83 | 84 | _update_config_by_args(config, vars(args)) 85 | return config 86 | -------------------------------------------------------------------------------- /hiburn/serial_over_telnet.py: -------------------------------------------------------------------------------- 1 | from telnetlib import Telnet 2 | 3 | 4 | class SerialOverTelnet: 5 | def __str__(self): 6 | return f"SerialOverTelnet({self.host}:{self.port})" 7 | 8 | def __init__(self, host, port): 9 | self.host = host 10 | self.port = port 11 | self.conn = Telnet(host=self.host, port=self.port) 12 | self._timeout = None 13 | self._buff = None 14 | 15 | def readline(self): 16 | return self.conn.read_until(b"\n", timeout=self._timeout) 17 | 18 | def read(self, size): # TODO: do the same but better! 19 | data = b"" 20 | while len(data) < size: 21 | if not self._buff: 22 | self._buff = self.conn.read_some() 23 | chunk_size = min(size - len(data), len(self._buff)) 24 | data = data + self._buff[:chunk_size] 25 | self._buff = self._buff[chunk_size:] 26 | return data 27 | 28 | def write(self, data): 29 | self.conn.write(data) 30 | 31 | def reset_input_buffer(self): 32 | self.conn.read_very_eager() # drop all read data 33 | 34 | @property 35 | def timeout(self): 36 | return self._timeout 37 | 38 | @timeout.setter 39 | def timeout(self, timeout): 40 | self._timeout = timeout 41 | -------------------------------------------------------------------------------- /hiburn/u_boot_client.py: -------------------------------------------------------------------------------- 1 | import serial 2 | import logging 3 | import time 4 | from . import ymodem 5 | 6 | 7 | ENCODING = "ascii" 8 | LF = b"\n" 9 | CTRL_C = b"\x03" 10 | PROMPTS = ("hisilicon #", "Zview #", "xmtech #", "hi3516dv300 #", "hi3519a #", "U-Boot>", "hi3516d #", "XiaoYi#", "hi3516cv500 #", "16dv300 #") 11 | READ_TIMEOUT = 0.5 12 | 13 | 14 | def bytes_to_string(line): 15 | return line.decode(ENCODING, errors="replace").rstrip("\r\n") 16 | 17 | 18 | class UBootClient: 19 | @classmethod 20 | def create_with_serial(cls, **kwargs): 21 | return cls(serial.Serial(**kwargs)) 22 | 23 | @classmethod 24 | def create_with_serial_over_telnet(cls, host, port): 25 | from .serial_over_telnet import SerialOverTelnet 26 | return cls(SerialOverTelnet(host, port)) 27 | 28 | def __init__(self, conn, prompts=PROMPTS): 29 | self.s = conn 30 | self.s.timeout = READ_TIMEOUT 31 | self.prompts = prompts 32 | logging.debug("UBootClient for {} constructed".format(self.s)) 33 | 34 | def _is_prompt(self, line): 35 | for prompt in self.prompts: 36 | if line.startswith(prompt): 37 | return True 38 | return False 39 | 40 | def _readline(self, raw=False): 41 | line = self.s.readline() 42 | if line: 43 | logging.debug("<< {}".format(line)) 44 | return line if raw else bytes_to_string(line) 45 | 46 | def _write(self, data): 47 | if isinstance(data, str): 48 | data = data.encode(ENCODING) 49 | self.s.write(data) 50 | logging.debug(">> {}".format(data)) 51 | 52 | def fetch_console(self): 53 | """ Wait for running U-Boot and try to enter console mode 54 | """ 55 | 56 | self.s.reset_input_buffer() 57 | 58 | logging.debug("Wait for U-Boot printable output...") 59 | while not self._readline().isprintable(): 60 | pass 61 | 62 | logging.debug("Wait for prompt...") 63 | while True: 64 | self._write(CTRL_C) 65 | if self._is_prompt(self._readline()): 66 | break 67 | 68 | logging.debug("Prompt received") 69 | 70 | while True: 71 | if self._readline().strip() in self.prompts: 72 | break 73 | 74 | logging.info("U-Boot console is fetched") 75 | 76 | def write_command(self, cmd): 77 | self._write(cmd + "\n") 78 | echoed = self._readline() 79 | if not echoed.endswith(cmd): 80 | raise RuntimeError("echoed data '{}' doesn't match input '{}'".format(echoed, cmd)) 81 | 82 | def read_response(self, timeout=None, raw=False): 83 | """ Read lines from serial port till prompt line is received or timeout exceeded 84 | """ 85 | 86 | response = [] 87 | if timeout is not None: 88 | logging.debug("Read response with timeout={}...".format(timeout)) 89 | self.s.timeout = timeout 90 | 91 | while True: 92 | line = self._readline(raw=True) 93 | if (not line) and (timeout is not None): 94 | break # readline timeout exceeded 95 | line = bytes_to_string(line) 96 | if line.strip() in self.prompts: 97 | break # prompt line is received 98 | response.append(line) 99 | 100 | self.s.timeout = READ_TIMEOUT # restore original timeout 101 | return response 102 | 103 | # simple wraps for U-Boot commands are below 104 | def printenv(self): 105 | self.write_command("printenv") 106 | return self.read_response() 107 | 108 | def setenv(self, **kwargs): 109 | for k, v in kwargs.items(): 110 | sv = str(v) 111 | sv = sv.replace(";", "\;") 112 | self.write_command("setenv {} {}".format(k, sv)) 113 | self.read_response() 114 | 115 | def ping(self, addr): 116 | self.write_command("ping {}".format(addr)) 117 | return self.read_response() 118 | 119 | def tftp(self, addr, file_name, size=None): 120 | if size is None: # host -> device 121 | self.write_command("tftp {:#x} {}".format(addr, file_name)) 122 | else: # device -> host 123 | self.write_command("tftp {:#x} {} {:#x}".format(addr, file_name, size)) 124 | return self.read_response() 125 | 126 | def bootm(self, uimage_addr, wait=True): 127 | self.write_command("bootm {:#x}".format(uimage_addr)) 128 | if not wait: 129 | return 130 | return self.read_response(timeout=5) 131 | 132 | def sf_probe(self, args): 133 | self.write_command("sf probe {}".format(args)) 134 | return self.read_response() 135 | 136 | def sf_read(self, dst_addr, flash_offset, size): 137 | self.write_command("sf read {:#x} {:#x}".format(dst_addr, flash_offset, size)) 138 | return self.read_response() 139 | 140 | def loady(self, addr, data, long=True): 141 | self.write_command("loady {:#x}".format(addr)) 142 | self._readline() 143 | ymodem.YModem(self.s).transmit(data, long=long) 144 | return self.read_response() 145 | -------------------------------------------------------------------------------- /hiburn/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | 5 | # ------------------------------------------------------------------------------------------------- 6 | _INT_BASES = {"0b": 2, "0o": 8, "0x": 16} 7 | _HSIZE_SUFFIXES = {"b": 1, "k": 1 << 10, "m": 1 << 20, "g": 1 << 30} 8 | 9 | 10 | def str2bool(val: str): 11 | return val.lower() in ("y", "yes", "on", "true", "1") 12 | 13 | 14 | def str2int(val: str): 15 | return int(val, base=_INT_BASES.get(val[:2].lower(), 10)) 16 | 17 | 18 | def hsize2int(val: str): 19 | if val[-1].isalpha(): 20 | mul = _HSIZE_SUFFIXES.get(val[-1].lower()) 21 | if mul is None: 22 | raise ValueError("Couldn't parse {}".format(val)) 23 | return mul * str2int(val[:-1]) 24 | else: 25 | return str2int(val) 26 | 27 | 28 | # ------------------------------------------------------------------------------------------------- 29 | def str2serial_kwargs(val): 30 | """ Convert string to a dict applicable as kwargs for serial.Serial 31 | Format: port[:baudrate[:DPS]] 32 | default baudrate is 115200, default DPS is '8N1' 33 | """ 34 | 35 | splited = val.split(":") 36 | port = splited[0] 37 | baudrate = 115200 38 | dps = "8N1" 39 | 40 | if len(splited) > 1 and splited[1]: 41 | baudrate = int(splited[1]) 42 | 43 | if len(splited) > 2 and splited[2]: 44 | dps = splited[2] 45 | 46 | return dict( 47 | port=port, 48 | baudrate=baudrate, 49 | bytesize=int(dps[0]), 50 | parity=dps[1], 51 | stopbits=float(dps[2:]) 52 | ) 53 | 54 | 55 | # ------------------------------------------------------------------------------------------------- 56 | def str2endpoint(val): 57 | """ Convert string to (host, port) tuple 58 | Format: [host:]port 59 | default host is 'localhost' 60 | """ 61 | 62 | splited = val.split(":") 63 | if len(splited) > 2: 64 | raise RuntimeError("Invalid argument") 65 | if len(splited) == 2: 66 | return (splited[0], int(splited[1])) 67 | return ("localhost", int(splited[0])) 68 | 69 | 70 | # ------------------------------------------------------------------------------------------------- 71 | def align_address_down(alignment, addr): 72 | return addr // alignment * alignment 73 | 74 | 75 | # ------------------------------------------------------------------------------------------------- 76 | def align_address_up(alignment, addr): 77 | return align_address_down(alignment, addr + alignment - 1) 78 | 79 | 80 | # ------------------------------------------------------------------------------------------------- 81 | TFTP_SERVER_DEFAULT_PORT = 69 82 | 83 | 84 | class TftpContext: 85 | """ Context manager for TFTP server 86 | """ 87 | 88 | def __enter__(self): 89 | self.thread.start() 90 | return self 91 | 92 | def __exit__(self, *args, **kwargs): 93 | self.server.stop() 94 | self.thread.join() 95 | 96 | def __init__(self, root_dir, listen_ip, listen_port=TFTP_SERVER_DEFAULT_PORT): 97 | import tftpy 98 | import threading 99 | 100 | logging.getLogger("tftpy").setLevel(logging.WARN) 101 | 102 | self.server = tftpy.TftpServer(root_dir) 103 | 104 | def run(): 105 | self.server.listen(listen_ip, listen_port) 106 | 107 | self.thread = threading.Thread(target=run) 108 | 109 | 110 | # ------------------------------------------------------------------------------------------------- 111 | def upload_files_via_tftp(u_boot_client, files_and_addrs, listen_ip, listen_port=TFTP_SERVER_DEFAULT_PORT): 112 | import tempfile 113 | import shutil 114 | 115 | with tempfile.TemporaryDirectory() as tmpdir: 116 | num = 0 117 | with TftpContext(tmpdir, listen_ip=listen_ip, listen_port=listen_port): 118 | for filename, addr in files_and_addrs: 119 | logging.info("Upload '{}' via TFTP to address {:#x}".format(filename, addr)) 120 | tmp_filename = os.path.join(tmpdir, str(num)) 121 | num += 1 122 | shutil.copyfile(filename, tmp_filename) 123 | u_boot_client.tftp(addr, tmp_filename) 124 | 125 | 126 | # ------------------------------------------------------------------------------------------------- 127 | def download_files_via_tftp(uboot, files_addrs_sizes, listen_ip, listen_port=TFTP_SERVER_DEFAULT_PORT): 128 | import tempfile 129 | import shutil 130 | 131 | with tempfile.TemporaryDirectory() as tmpdir: 132 | num = 0 133 | with TftpContext(tmpdir, listen_ip=listen_ip, listen_port=listen_port): 134 | for filename, addr, size in files_addrs_sizes: 135 | logging.info("Download {} bytes from {:#x} to '{}' via TFTP".format(size, addr, filename)) 136 | tmp_filename = os.path.join(tmpdir, str(num)) 137 | num += 1 138 | uboot.tftp(addr, tmp_filename, size) 139 | shutil.copyfile(tmp_filename, filename) 140 | -------------------------------------------------------------------------------- /hiburn/ymodem.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import binascii 3 | 4 | # http://pauillac.inria.fr/~doligez/zmodem/ymodem.txt 5 | 6 | # SOH = b"\x01" 7 | # EOT = b"\x04" 8 | # ACK = b"\x06" 9 | # NAK = b"\x15" 10 | # # 18H 11 | # # 43H 12 | 13 | 14 | # COUNTER = 0xFF # to start from 0x00 15 | 16 | 17 | # def read(serial, size): 18 | # data = serial.read(size) 19 | # logging.debug("<< {}".format(data.hex())) 20 | # return data 21 | 22 | 23 | # def write(serial, data): 24 | # serial.write(data) 25 | # logging.debug(">> {}".format(data.hex())) 26 | 27 | 28 | # def send_chunk(serial, data): 29 | # global COUNTER 30 | # assert len(data) <= 128 31 | 32 | # COUNTER = (COUNTER + 1) % (0xFF + 1) 33 | 34 | # data = data + b"\0" * (128 - len(data)) 35 | # cksum = sum(int(b) for b in data) & 0xFF 36 | # num = COUNTER 37 | 38 | # frame = SOH + bytes([num, ~num & 0xFF]) + data + bytes([cksum]) 39 | 40 | # while True: 41 | # write(serial, frame) 42 | # if read(serial, 1) == ACK: 43 | # logging.debug("Fame {} is ACKed by receiver".format(num)) 44 | # break 45 | # logging.debug("Retry to send frame {}...".format(num)) 46 | 47 | 48 | # def ymodem_transmit(serial, data=b"hey device!"): 49 | # global COUNTER 50 | # COUNTER = 0xFF 51 | 52 | # logging.info("YMODEM waits for handshake... (it may be about 10-20 seconds)") 53 | # while True: 54 | # handshake = read(serial, 1) 55 | # if handshake == NAK: 56 | # break 57 | 58 | # logging.info("YMODEM got appropriate handshake, start transmission...") 59 | # send_chunk(serial, b"/some/file/path\0" + str(len(data)).encode("ascii")) 60 | 61 | # total_bytes = len(data) 62 | # sent_bytes = 0 63 | # perc = 0 64 | # while data: 65 | # chunk_size = min(len(data), 128) 66 | # send_chunk(serial, data[:chunk_size]) 67 | # data = data[chunk_size:] 68 | 69 | # sent_bytes += chunk_size 70 | # if (int(sent_bytes/total_bytes * 100) > perc): 71 | # perc = int(sent_bytes/total_bytes * 100) 72 | # logging.info("Sent {} bytes of {} ({}%)".format(sent_bytes, total_bytes, perc)) 73 | 74 | # while True: 75 | # write(serial, EOT) 76 | # resp = read(serial, 1) 77 | # if resp == ACK: 78 | # break 79 | 80 | # logging.info("YMODEM finished") 81 | 82 | 83 | 84 | class YModem: 85 | SOH = b"\x01" 86 | STX = b"\x02" 87 | EOT = b"\x04" 88 | ACK = b"\x06" 89 | NAK = b"\x15" 90 | CAN = b"\x18" 91 | C = b"\x43" 92 | 93 | MAX_RETRIES = 50 94 | SHORT_PAYLOAD_SIZE = 128 95 | LONG_PAYLOAD_SIZE = 1024 96 | 97 | class Stat: 98 | def __init__(self, total_bytes): 99 | self.total_bytes = total_bytes 100 | self.sent_bytes = 0 101 | self.sent_perc = 0 102 | 103 | def on_sent(self, bytes_count): 104 | self.sent_bytes += bytes_count 105 | perc = int(self.sent_bytes/self.total_bytes * 100) 106 | if (perc > self.sent_perc): 107 | self.sent_perc = perc 108 | logging.debug("Sent {} bytes of {} ({}%)".format(self.sent_bytes, self.total_bytes, self.sent_perc)) 109 | 110 | @staticmethod 111 | def crc16(data): 112 | val = binascii.crc_hqx(data, 0) 113 | return bytes([val >> 8, 0xFF & val]) 114 | 115 | @staticmethod 116 | def checksum(data): 117 | val = sum(int(b) for b in data) & 0xFF 118 | return bytes([val]) 119 | 120 | def __init__(self, serial): 121 | self.serial = serial 122 | self.counter = 0 123 | self.retry_counter = 0 124 | self.stat = None 125 | 126 | def send_data(self, data, long=False, crc16=False): 127 | PAYLOAD_SIZE = self.LONG_PAYLOAD_SIZE if long else self.SHORT_PAYLOAD_SIZE 128 | 129 | head = self.STX if long else self.SOH 130 | while data: 131 | chunk_size = min(len(data), PAYLOAD_SIZE) 132 | num = self.counter & 0xFF 133 | padding = b"\0" * (PAYLOAD_SIZE - chunk_size) 134 | payload = data[:chunk_size] + padding 135 | tail = self.crc16(payload) if crc16 else self.checksum(payload) 136 | 137 | self.counter += 1 138 | frame = head + bytes([num, ~num & 0xFF]) + payload + tail 139 | self.send_frame(frame) 140 | 141 | data = data[chunk_size:] 142 | 143 | if self.stat is not None: 144 | self.stat.on_sent(chunk_size) 145 | 146 | def send_eot(self): 147 | while True: 148 | self.serial.write(self.EOT) 149 | if self.serial.read(1) == self.ACK: 150 | break 151 | 152 | def send_frame(self, frame): 153 | while self.retry_counter < self.MAX_RETRIES: 154 | self.serial.write(frame) 155 | if self.serial.read(1) == self.ACK: 156 | self.retry_counter = 0 157 | return 158 | logging.debug("Retry to send frame {}...".format(frame[:3])) 159 | self.retry_counter += 1 160 | 161 | raise RuntimeError("Could not send frame {}... after {} retires".format(frame[:3], self.retry_counter)) 162 | 163 | 164 | def transmit(self, data, file_path="", long=False): 165 | logging.info("YMODEM waits for handshake... (it may be about 10-20 seconds)") 166 | 167 | crc = False 168 | while True: 169 | handshake = self.serial.read(1) 170 | if handshake == self.C: 171 | crc = True 172 | break 173 | if handshake == self.NAK: 174 | break 175 | 176 | logging.info("YMODEM got handshake, start transmission...") 177 | self.send_data( 178 | data=file_path.encode("ascii") + b"\0" + str(len(data)).encode("ascii"), 179 | long=long, 180 | crc16=crc 181 | ) 182 | 183 | self.stat = self.Stat(len(data)) 184 | self.send_data(data=data, long=long, crc16=crc) 185 | logging.info("YMODEM all {} bytes of data has been transmitted".format(self.stat.total_bytes)) 186 | self.stat = None 187 | 188 | self.send_eot() 189 | logging.info("YMODEM finished") 190 | 191 | -------------------------------------------------------------------------------- /hiburn_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import logging 3 | import argparse 4 | import json 5 | import os 6 | import subprocess 7 | from hiburn.u_boot_client import UBootClient 8 | from hiburn.config import add_arguments_from_config_desc, get_config_from_args 9 | from hiburn import utils 10 | from hiburn import actions 11 | 12 | 13 | 14 | # ------------------------------------------------------------------------------------------------- 15 | DEFAULT_CONFIG_DESC = { 16 | "net": { 17 | "device_ip": ("192.168.10.101", str, "Target IP address"), 18 | "host_ip_mask": ("192.168.10.2/24", str, "Host IP address and mask's length") 19 | }, 20 | "mem": { 21 | "start_addr": ("0x80000000", utils.hsize2int, "RAM start address"), 22 | "alignment": ("64K", utils.hsize2int, "RAM alignment for uploading"), 23 | "linux_size": ("256M", utils.hsize2int, "Amount of RAM for Linux"), 24 | "uboot_size": ("512K", utils.hsize2int, ""), 25 | }, 26 | "linux_console": ("ttyAMA0,115200", str, "Linux load console") 27 | } 28 | 29 | 30 | # ------------------------------------------------------------------------------------------------- 31 | def reset_power(cmd=None): 32 | if cmd is None: 33 | print("Please, swith OFF the device's power and press Enter") 34 | input() 35 | print("Please, swith ON the device's power") 36 | else: 37 | logging.debug("Run '{}' shell command to reset power...".format(cmd)) 38 | subprocess.check_call(cmd, shell=True) 39 | 40 | 41 | # ------------------------------------------------------------------------------------------------- 42 | def main(): 43 | parser = argparse.ArgumentParser() 44 | parser.add_argument("--verbose", "-v", action="store_true", 45 | help="Print debug output" 46 | ) 47 | 48 | mutexg = parser.add_mutually_exclusive_group(required=True) 49 | mutexg.add_argument("--serial", type=utils.str2serial_kwargs, metavar="V", 50 | help="Serial port 'port[:baudrate[:DPS]]'") 51 | mutexg.add_argument("--serial-over-telnet", type=utils.str2endpoint, metavar="V", 52 | help="Serial-over-telnet endpoint '[host:]port'") 53 | 54 | parser.add_argument("--no-fetch", "-n", action="store_true", 55 | help="Assume U-Boot's console is already fetched" 56 | ) 57 | parser.add_argument("--reset-cmd", type=str, 58 | help="Shell command to reset device's power" 59 | ) 60 | 61 | add_arguments_from_config_desc(parser, DEFAULT_CONFIG_DESC) 62 | actions.add_actions(parser, 63 | actions.printenv, 64 | actions.ping, 65 | actions.download, 66 | actions.download_sf, 67 | actions.upload, 68 | actions.upload_y, 69 | actions.boot 70 | ) 71 | 72 | args = parser.parse_args() 73 | logging.basicConfig(level=(logging.DEBUG if args.verbose else logging.INFO)) 74 | config = get_config_from_args(args, DEFAULT_CONFIG_DESC) 75 | 76 | if args.serial is not None: 77 | client = UBootClient.create_with_serial(**args.serial) 78 | else: 79 | client = UBootClient.create_with_serial_over_telnet(*args.serial_over_telnet) 80 | 81 | if not args.no_fetch: 82 | reset_power(args.reset_cmd) 83 | client.fetch_console() 84 | 85 | if hasattr(args, "action"): 86 | args.action(client, config, args) 87 | else: 88 | print("Nothing to do here...") 89 | 90 | 91 | if __name__ == "__main__": 92 | main() 93 | -------------------------------------------------------------------------------- /images/hiburn200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenHisiIpCam/hiburn/71d8ab3c5a87401a60cf125d441e25f8b7d3282c/images/hiburn200.png -------------------------------------------------------------------------------- /images/hiburn_social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenHisiIpCam/hiburn/71d8ab3c5a87401a60cf125d441e25f8b7d3282c/images/hiburn_social.png -------------------------------------------------------------------------------- /tests/test_ymodem.py: -------------------------------------------------------------------------------- 1 | from hiburn.ymodem import YModem 2 | import logging 3 | 4 | 5 | logging.basicConfig(level=logging.DEBUG) 6 | 7 | 8 | class FakeSerial: 9 | def __init__(self, outgoing=b""): 10 | self.incoming = [] 11 | self.outgoing = outgoing 12 | 13 | def write(self, data): 14 | self.incoming.append(data) 15 | 16 | def read(self, size): 17 | s = min(len(self.outgoing), size) 18 | data = self.outgoing[:s] 19 | self.outgoing = self.outgoing[s:] 20 | return data 21 | 22 | 23 | # ------------------------------------------------------------------------------------------------- 24 | def test_basic(): 25 | # C NAK ACK 26 | serial = FakeSerial(outgoing=(b"\x43" * 10 + b"\x15" + b"\x06" * 3)) 27 | 28 | ym = YModem(serial) 29 | ym.transmit(b"hello serial", file_path="/my/data/path") 30 | 31 | assert len(serial.outgoing) == 0 32 | assert serial.incoming[0] == (b"\x01\x00\xff/my/data/path\x0012" + b"\x00" * 112 + b"\x1d") 33 | assert serial.incoming[1] == (b"\x01\x01\xfehello serial" + b"\x00" * 116 + b"\xb4") 34 | assert serial.incoming[2] == (b"\x04") 35 | 36 | --------------------------------------------------------------------------------