-
-
Notifications
You must be signed in to change notification settings - Fork 23
/
patchappinfo.py
425 lines (358 loc) · 14.4 KB
/
patchappinfo.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
#!/usr/bin/env python
#
# SOURCE : https://github.com/ankostis/Freematics/blob/dev/firmware_v5/telelogger/patchappinfos.py
#
"""
A *platformio* script to engrave app-infos by patching espressif32 images.
**Rational**
This engraving is necessary for discerning images/pertitions when OTA-upgrading,
since *platformio* does not preserve `espidf` framework behavior when
`arduino` framework is also enabled.
**Image Infos**
The infos extracted by `_collect_app_infos(env)` try to mimic ESP-IDF behavior,
as listed below (in precedance order):
- **project-name:**
- no patching if `custom_appinfos_patch_appname` custom option is truthy
(emulates a *negated* [`CONFIG_APP_EXCLUDE_PROJECT_NAME_VAR` cppdefine
from ESP_IDF](https://docs.espressif.com/projects/esp-idf/en/v4.4.1/esp32/api-reference/kconfig.html#config-app-exclude-project-name-var))
[default: true]
- `custom_prog_name` custom option
(emulates `PROJECT_NAME` cppdefine from ESP-IDF)
- git-relative dirs of project root(`${PROJECT_SRC_DIR}`)
(deviates from ESP-IDF)
- last dir-name of project root (`${PROJECT_SRC_DIR}`)
(deviates from ESP-IDF)
- `"firmware"`
- appends `{user}@{host}` suffix into *project-name* if `custom_appinfos_patch_builder`
custom option is truthy
[default: true]
- **project-version:**
- no patching if `custom_appinfos_patch_appver` custom option is truthy
(emulates a *negated* [`CONFIG_APP_EXCLUDE_PROJECT_VER_VAR` cppdefine
from ESP_IDF](https://docs.espressif.com/projects/esp-idf/en/v4.4.1/esp32/api-reference/kconfig.html#config-app-exclude-project-ver-var))
[default: true]
- `custom_prog_version` custom option
(emulates `PROJECT_VERSION` cppdefine from ESP-IDF)
- `${PROJECT_PATH}/version.txt` file in project root
- git describe (mimics ESP-IDF)
- `"1"` (mimics ESP-IDF)
- does not respect ESP-IDF's *Kconfigs*:
- `CONFIG_APP_PROJECT_VER_FROM_CONFIG`
- `CONFIG_APP_PROJECT_VER`
- see: https://docs.espressif.com/projects/esp-idf/en/v4.4.1/esp32/api-reference/system/system.html#app-version
- **build date/time:**
- no patching if `custom_appinfos_patch_timestamp` custom option is truthy
(emulates [the `CONFIG_APP_COMPILE_TIME_DATE` cppdefine
from ESP_IDF](https://docs.espressif.com/projects/esp-idf/en/v4.4.1/esp32/api-reference/kconfig.html#config-app-exclude-project-ver-var))
[default: true]
- `custom_build_date/custom_build_time` custom options
(deviates ESP-IDF);
- Python's `datetime.now()` (taken *after* firmware has been build,
at the time the image is *post-processed*)
- does not respect ESP-IDF's *Kconfigs*.
- Boolean config-options are considered false if defined but
are matched,case-insensitively, in :data:`FALSE_VALUES` by :func:`_get_bool_option()`.
- Patching-offsets were calculated from:
https://docs.espressif.com/projects/esp-idf/en/v4.4.1/esp32/api-reference/system/app_image_format.html#_CPPv414esp_app_desc_t
- TODO: mimic more behavior from ESP-IDF (eg Kconfigs).
"""
#%%
import hashlib
import io
import operator
import shutil
import struct
import functools
import subprocess
import sys
import typing
from collections import namedtuple
from pathlib import Path
from datetime import datetime
from typing import BinaryIO, Iterator, Tuple
import progname
#: Git command to show relative-dir of the current project, to use as project-name.
GIT_RELATIVE_DIR_CMD = "git rev-parse --show-prefix".split()
#: values for config-options translated case-insensitively as `False`.
FALSE_VALUES = {*"false off no 0".split()}
#: to convert names into bytes
ENCODING = "utf-8"
#: Where app-name/version & build-date/time are stored in the image?
ESP_APP_DESC_OFFSET = 0x30
#: Start traversing segments while checksuming from this file postion.
FIRST_SEGMENT_OFFSET = 0x18
## Magic constants from `esp_app_format.h`
ESP_IMAGE_HEADER_MAGIC = 0xE9
ESP_APP_DESC_MAGIC_WORD = 0xABCD5432
#: Initial state for the checksum routine.
ESP_CHECKSUM_MAGIC = 0xEF
#: Algo factory for clalculating the image's new checksum after patching.
FILE_HASHING_ALGO = hashlib.sha256
#: The length (in bytes) of the hash256 at image's EOF.
FILE_HASH_LENGTH = 32
#%%
from platformio.compat import MISSING
def _get_bool_option(env, option: str, default=MISSING) -> bool:
"""
Translates custom-options in `platform.ini` as booleans
:param default:
applies only if `option` undefined (ie missing from `platform.io`),
translated through :data:`FALSE_VALUES`.
:return:
- if undefined (ie `option` missing): `default` if given, exception otherwise
- if `option` defined but without any value specified: `True`
- otherwise: the value for `option`.
If option value (or `default`) matches any of :data:`FALSE_VALUES`
(case-insensitevly), `False` returned.
"""
value = env.GetProjectOption(option, default)
if value == "": # something like `-Dfoo`
return True
return value.lower() not in FALSE_VALUES if isinstance(value, str) else value
def git_relative_dir() -> str:
try:
return (
subprocess.check_output(GIT_RELATIVE_DIR_CMD, universal_newlines=True)
.strip()
.strip("/")
)
except Exception as ex:
## Logging source of each app-ingo
pass
# sys.stderr.write(
# f"could not read project-name from `{GIT_RELATIVE_DIR_CMD}`"
# f" due to {type(ex).__name__}: {ex}\n"
# )
def get_project_name(env) -> Tuple[str, str]:
"""
:return: 2-tuple of (project, source-label)
Note, didn't reuse :func:`get_program_name()` bc app-infos can accomodate
relative git-path of project-root as `appname`.
"""
branch = ""
try:
branch = subprocess.check_output(
"git branch --show-current".split(), universal_newlines=True
).strip()
except Exception as ex:
branch = "UNKNOWN"
return progname.fallback_get(
(
lambda: "unified-un52",
"custom_option",
),
(lambda: git_relative_dir(), "git_relative_dir"),
(
lambda: env.Dir(env.get("PROJECT_SRC_DIR")).get_path_elements()[-1].name,
"project_dir",
),
)
AppInfos = namedtuple("AppInfos", "appname, appver, date, time")
def _collect_app_infos(env) -> Tuple[AppInfos, AppInfos]:
bool_option = functools.partial(_get_bool_option, env)
appname = appver = date = time = None
appname_src = appver_src = date_src = time_src = None
if bool_option("custom_appinfos_patch_appname", True):
appname, appname_src = get_project_name(env)
## TODO: parse var-value as False if off/false/no/0.
if bool_option("custom_appinfos_patch_appver", True):
appver, appver_src = progname.get_program_ver(env)
## TODO: parse var-value as False if off/false/no/0.
if bool_option("custom_appinfos_patch_timestamp", True):
date = env.GetProjectOption("custom_build_date", None)
time = env.GetProjectOption("custom_build_time", None)
if not date or not time:
now = datetime.now(datetime.utcnow().astimezone().tzinfo)
if not date:
date = now.strftime("%d %b %Y")
date_src = "python"
else:
date_src = "custom_option"
if not time:
time = now.strftime("%H:%M:%S%z")
time_src = "python"
else:
time_src = "custom_option"
return AppInfos(appname, appver, date, time), AppInfos(
appname_src, appver_src, date_src, time_src
)
#%%
def chunks(
fd: BinaryIO, length=None, *, chunk_size=io.DEFAULT_BUFFER_SIZE
) -> Iterator[bytes]:
"""
:param length:
the total number of bytes to consume; if `None`, exhaust stream,
if negative, yield until that many bytes before EOF.
:param chunk_size:
the maximum length of each chunk (the last one maybe shorter)
:return:
an iterator over the chunks of the file
"""
if length is None:
yield from iter(lambda: fd.read(chunk_size), b"")
else:
if length < 0:
fsize = Path(fd.name).stat().st_size
length = fsize - fd.tell() + length # negative `length` added!
consumed = 0
for chunk in iter(lambda: fd.read(min(chunk_size, length - consumed)), b""):
yield chunk
consumed += len(chunk)
def digest_stream(
fd: BinaryIO, digester_factory, length, chunk_size=io.DEFAULT_BUFFER_SIZE
) -> bytes:
"""
:param length:
how many bytes to digest from the start of the file; if not positive,
digesting stops that many bytes before EOF (eg 0 means all file).
"""
digester = digester_factory()
for chunk in chunks(fd, length, chunk_size=chunk_size):
digester.update(chunk)
return digester.digest()
#%%
def checksum_segment(fd, state, segment_ix) -> int:
size = struct.unpack("<4xI", fd.read(8))[0]
bdata = fd.read(size)
if len(bdata) < size:
raise ValueError(
f"Premature EOF of segment({segment_ix})@{len(bdata)} != {size}!"
)
return functools.reduce(operator.xor, bdata, state)
def align_file_position(f, size):
"""Align the position in the file to the next block of specified size"""
align = (size - 1) - (f.tell() % size)
f.seek(align, 1)
def checksum_image(fd: BinaryIO, nsegments: int, patch=None) -> int:
fd.seek(FIRST_SEGMENT_OFFSET)
state = ESP_CHECKSUM_MAGIC
for i in range(nsegments):
state = checksum_segment(fd, state, i)
# print(f" +--seg({i} out of {nsegments}): 0x{state:02x}")
align_file_position(fd, 16)
stored = ord(fd.read(1))
if patch:
print(f" +--checksum@0x{fd.tell():08x}): 0x{stored:02x} --> 0x{state:02x}")
fd.seek(-1, 1)
fd.write(struct.pack("B", state))
else:
if state != stored:
raise ValueError(
f"{fd.name}: image's checksum(0x{stored:02x}) != 0x{state:02x}!"
)
return state
def hash_image(fd: BinaryIO, patch=None):
fd.seek(0)
hash = digest_stream(fd, FILE_HASHING_ALGO, -FILE_HASH_LENGTH)
assert len(hash) == FILE_HASH_LENGTH, (hash, FILE_HASH_LENGTH)
# After digesting hash, file positioned immediately before the hash-bytes.
if patch:
print(
f" +--{FILE_HASHING_ALGO().name:10}({len(hash)}bytes@0x{fd.tell():08x}):"
f" {hash.hex()}"
)
fd.write(hash)
else:
stored = fd.read()
if hash != stored:
print(
f"{fd.name}: image's hash({stored.hex()}) != {hash.hex()}!",
file=sys.stderr,
)
Image = namedtuple("Image", "nsegments, is_hashed, checksum, hash, infos")
def load_and_verify_image(fd: BinaryIO) -> Image:
"""Trimmed down from *esptool.py* and reading the specs."""
fd.seek(0x00)
(header_magic, nsegments, is_hashed) = struct.unpack("BB21xB", fd.read(24))
if header_magic != ESP_IMAGE_HEADER_MAGIC:
print(
f"{fd.name}: image's ESP_IMAGE_HEADER_MAGIC(0x{header_magic:02x}) != "
f"0x{ESP_IMAGE_HEADER_MAGIC:02x}!",
file=sys.stderr,
)
fd.seek(0x20)
app_magic = struct.unpack("<I", fd.read(4))[0]
if app_magic != ESP_APP_DESC_MAGIC_WORD:
print(
f"E{fd.name}: image's ESP_APP_DESC_MAGIC_WORD(0x{app_magic:02x}) != "
f"0x{ESP_APP_DESC_MAGIC_WORD:02x}!",
file=sys.stderr,
)
chk = checksum_image(fd, nsegments)
hash = ""
if is_hashed:
hash = hash_image(fd)
fd.seek(ESP_APP_DESC_OFFSET)
(appver, name, time, date) = struct.unpack("32s 32s 16s 16s", fd.read(96))
return Image(nsegments, is_hashed, chk, hash, AppInfos(name, appver, date, time))
#%%
def patch_bytestring(
fd: BinaryIO, label: str, seek: int, length: int, data: str, source: str
):
bdata = struct.pack(f"{length}sb", data.encode(ENCODING), 0)
print(
f" +--{label:10}({length + 1}bytes@0x{seek:08x} from {source:14}): |{data.strip()}|"
)
fd.seek(seek)
fd.write(bdata)
def patch_bytestring_with_infos(
fd: BinaryIO, app_infos: AppInfos, sources: AppInfos
) -> bool:
if any(app_infos):
if app_infos.appver:
patch_bytestring(fd, "app_ver", 0x30, 30, app_infos.appver, sources.appver)
if app_infos.appname:
patch_bytestring(
fd, "app_name", 0x50, 30, app_infos.appname, sources.appname
)
if app_infos.date or app_infos.time:
patch_bytestring(fd, "build_time", 0x70, 14, app_infos.time, sources.time)
patch_bytestring(fd, "build_date", 0x80, 14, app_infos.date, sources.date)
return True
else:
print(" +--no image-infos enabled to patch!")
def patch_app_infos(img_fpath, app_infos: AppInfos, sources: AppInfos):
print(f"Patching app-infos --> {img_fpath}:")
with io.open(img_fpath, "r+b") as fd:
img = load_and_verify_image(fd)
is_patched = patch_bytestring_with_infos(fd, app_infos, sources)
if is_patched:
checksum_image(fd, img.nsegments, patch=True)
if img.is_hashed:
hash_image(fd, patch=True)
def PatchAppInfos(source, target, env):
img_fpath = source[0].path
## DEBUG: keep a copy of unpatched image.
# shutil.copy(img_fpath, img_fpath + ".OK")
app_infos, sources = _collect_app_infos(env)
patch_app_infos(img_fpath, app_infos, sources)
def install_patch_app_infos(env):
patch_action = env.AddCustomTarget(
"patchappinfos",
"$BUILD_DIR/${PROGNAME}.bin",
PatchAppInfos,
title="Engarve image with app-infos",
description="Patch `${PROGNAME}.bin` with project name/version & build timestamp",
always_build=True,
)
env.AddPostAction("$BUILD_DIR/${PROGNAME}.bin", patch_action)
env.Depends(target="upload", dependency=patch_action)
def DumpAppInfos(source, target, env):
img_fpath = source[0].path
with io.open(img_fpath, "rb") as fd:
img = load_and_verify_image(fd)
print(img.infos._asdict())
env.Default(patch_action)
## TODO: make DumpAppInfos a new CLI
patch_action = env.AddCustomTarget(
"appinfos",
"$BUILD_DIR/${PROGNAME}.bin",
DumpAppInfos,
title="Dump app-infos in image",
description="Dump project name/version & build timestamp engraved in `${PROGNAME}.bin`",
always_build=True,
)
if __name__ == "SCons.Script":
Import("env")
install_patch_app_infos(env)