├── .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 |
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 |
--------------------------------------------------------------------------------