diff --git a/common/spinner.py b/common/spinner.py index 1232371d..3ef60312 100644 --- a/common/spinner.py +++ b/common/spinner.py @@ -24,6 +24,9 @@ class Spinner(): except BrokenPipeError: pass + def update_progress(self, cur, total): + self.update(str(int(100 * cur / total))) + def close(self): if self.spinner_proc is not None: try: diff --git a/installer/updater/update_agnos.json b/installer/updater/update_agnos.json new file mode 100644 index 00000000..533fb0bb --- /dev/null +++ b/installer/updater/update_agnos.json @@ -0,0 +1,18 @@ +[ + { + "name": "system", + "url": "https://commadist.azureedge.net/agnosupdate-staging/system-1e5748c84d9b48bbe599c88da63e46b7ed1c0f1245486cfbcfb05399bd16dbb6.img.xz", + "hash": "de6afb8651dc011fed8a883bac191e61d4b58e2e1c84c2717816b64f71afd3b1", + "hash_raw": "1e5748c84d9b48bbe599c88da63e46b7ed1c0f1245486cfbcfb05399bd16dbb6", + "size": 10737418240, + "sparse": true + }, + { + "name": "boot", + "url": "https://commadist.azureedge.net/agnosupdate-staging/boot-21649cb35935a92776155e7bb23e437e7e7eb0500c082df89f59a6d4a4ed639a.img.xz", + "hash": "21649cb35935a92776155e7bb23e437e7e7eb0500c082df89f59a6d4a4ed639a", + "hash_raw": "21649cb35935a92776155e7bb23e437e7e7eb0500c082df89f59a6d4a4ed639a", + "size": 14678016, + "sparse": false + } +] diff --git a/launch_chffrplus.sh b/launch_chffrplus.sh index 8fe3cbf6..e52fa6db 100755 --- a/launch_chffrplus.sh +++ b/launch_chffrplus.sh @@ -8,11 +8,6 @@ source "$BASEDIR/launch_env.sh" DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )" -function tici_init { - sudo su -c 'echo "performance" > /sys/class/devfreq/soc:qcom,memlat-cpu0/governor' - sudo su -c 'echo "performance" > /sys/class/devfreq/soc:qcom,memlat-cpu4/governor' -} - function two_init { # Wifi scan wpa_cli IFNAME=wlan0 SCAN @@ -110,6 +105,62 @@ function two_init { fi } +function tici_init { + sudo su -c 'echo "performance" > /sys/class/devfreq/soc:qcom,memlat-cpu0/governor' + sudo su -c 'echo "performance" > /sys/class/devfreq/soc:qcom,memlat-cpu4/governor' + + # set success flag for current boot slot + sudo abctl --set_success + + # Check if AGNOS update is required + if [ $(< /VERSION) != "$AGNOS_VERSION" ]; then + # Get number of slot to switch to + CUR_SLOT=$(abctl --boot_slot) + if [[ "$CUR_SLOT" == "_a" ]]; then + OTHER_SLOT="_b" + OTHER_SLOT_NUMBER="1" + else + OTHER_SLOT="_a" + OTHER_SLOT_NUMBER="0" + fi + echo "Cur slot $CUR_SLOT, target $OTHER_SLOT" + + # Get expected hashes from manifest + MANIFEST="$DIR/installer/updater/update_agnos.json" + SYSTEM_HASH_EXPECTED=$(jq -r ".[] | select(.name == \"system\") | .hash_raw" $MANIFEST) + SYSTEM_SIZE=$(jq -r ".[] | select(.name == \"system\") | .size" $MANIFEST) + BOOT_HASH_EXPECTED=$(jq -r ".[] | select(.name == \"boot\") | .hash_raw" $MANIFEST) + BOOT_SIZE=$(jq -r ".[] | select(.name == \"boot\") | .size" $MANIFEST) + echo "Expected hashes:" + echo "System: $SYSTEM_HASH_EXPECTED" + echo "Boot: $BOOT_HASH_EXPECTED" + + # Read hashes from alternate partitions, should already be flashed by updated + SYSTEM_HASH=$(dd if="/dev/disk/by-partlabel/system$OTHER_SLOT" bs=1 skip="$SYSTEM_SIZE" count=64 2>/dev/null) + BOOT_HASH=$(dd if="/dev/disk/by-partlabel/boot$OTHER_SLOT" bs=1 skip="$BOOT_SIZE" count=64 2>/dev/null) + echo "Found hashes:" + echo "System: $SYSTEM_HASH" + echo "Boot: $BOOT_HASH" + + if [[ "$SYSTEM_HASH" == "$SYSTEM_HASH_EXPECTED" && "$BOOT_HASH" == "$BOOT_HASH_EXPECTED" ]]; then + echo "Swapping active slot to $OTHER_SLOT_NUMBER" + abctl --set_active "$OTHER_SLOT_NUMBER" + + sleep 1 + sudo reboot + else + echo "Hash mismatch, downloading agnos" + if $DIR/selfdrive/hardware/tici/agnos.py $MANIFEST; then + echo "Download done, swapping active slot to $OTHER_SLOT_NUMBER" + abctl --set_active "$OTHER_SLOT_NUMBER" + fi + + sleep 1 + sudo reboot + fi + fi +} + function launch { # Remove orphaned git lock if it exists on boot [ -f "$DIR/.git/index.lock" ] && rm -f $DIR/.git/index.lock @@ -153,17 +204,17 @@ function launch { fi fi - # comma two init + # handle pythonpath + ln -sfn $(pwd) /data/pythonpath + export PYTHONPATH="$PWD" + + # hardware specific init if [ -f /EON ]; then two_init elif [ -f /TICI ]; then tici_init fi - # handle pythonpath - ln -sfn $(pwd) /data/pythonpath - export PYTHONPATH="$PWD" - # write tmux scrollback to a file tmux capture-pane -pq -S-1000 > /tmp/launch_log diff --git a/launch_env.sh b/launch_env.sh index ca8f4e8a..cb3c241d 100755 --- a/launch_env.sh +++ b/launch_env.sh @@ -10,6 +10,10 @@ if [ -z "$REQUIRED_NEOS_VERSION" ]; then export REQUIRED_NEOS_VERSION="15-1" fi +if [ -z "$AGNOS_VERSION" ]; then + export AGNOS_VERSION="0.1" +fi + if [ -z "$PASSIVE" ]; then export PASSIVE="1" fi diff --git a/release/files_common b/release/files_common index f53abcf8..3b4fba19 100644 --- a/release/files_common +++ b/release/files_common @@ -277,6 +277,7 @@ selfdrive/hardware/eon/hardware.py selfdrive/hardware/tici/__init__.py selfdrive/hardware/tici/hardware.py selfdrive/hardware/tici/pins.py +selfdrive/hardware/tici/agnos.py selfdrive/hardware/pc/__init__.py selfdrive/hardware/pc/hardware.py diff --git a/selfdrive/hardware/base.py b/selfdrive/hardware/base.py index 24163a12..75b1f4d9 100644 --- a/selfdrive/hardware/base.py +++ b/selfdrive/hardware/base.py @@ -24,6 +24,10 @@ class HardwareBase: def uninstall(self): pass + @abstractmethod + def get_os_version(self): + pass + @abstractmethod def get_sound_card_online(self): pass diff --git a/selfdrive/hardware/eon/hardware.py b/selfdrive/hardware/eon/hardware.py index 231b5ba6..66d3da29 100644 --- a/selfdrive/hardware/eon/hardware.py +++ b/selfdrive/hardware/eon/hardware.py @@ -61,6 +61,10 @@ def getprop(key): class Android(HardwareBase): + def get_os_version(self): + with open("/VERSION") as f: + return f.read().strip() + def get_sound_card_online(self): return (os.path.isfile('/proc/asound/card0/state') and open('/proc/asound/card0/state').read().strip() == 'ONLINE') diff --git a/selfdrive/hardware/pc/hardware.py b/selfdrive/hardware/pc/hardware.py index 181de110..2345c722 100644 --- a/selfdrive/hardware/pc/hardware.py +++ b/selfdrive/hardware/pc/hardware.py @@ -8,6 +8,9 @@ NetworkStrength = log.ThermalData.NetworkStrength class Pc(HardwareBase): + def get_os_version(self): + return None + def get_sound_card_online(self): return True diff --git a/selfdrive/hardware/tici/agnos.py b/selfdrive/hardware/tici/agnos.py new file mode 100755 index 00000000..749d5149 --- /dev/null +++ b/selfdrive/hardware/tici/agnos.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +import json +import lzma +import hashlib +import requests +import struct +import subprocess +import os + +from common.spinner import Spinner + + +class StreamingDecompressor: + def __init__(self, url): + self.buf = b"" + + self.req = requests.get(url, stream=True, headers={'Accept-Encoding': None}) + self.it = self.req.iter_content(chunk_size=1024 * 1024) + self.decompressor = lzma.LZMADecompressor(format=lzma.FORMAT_AUTO) + self.eof = False + self.sha256 = hashlib.sha256() + + def read(self, length): + while len(self.buf) < length: + self.req.raise_for_status() + + try: + compressed = next(self.it) + except StopIteration: + self.eof = True + break + out = self.decompressor.decompress(compressed) + self.buf += out + + result = self.buf[:length] + self.buf = self.buf[length:] + + self.sha256.update(result) + return result + + +def unsparsify(f): + magic = struct.unpack("I", f.read(4))[0] + assert(magic == 0xed26ff3a) + + # Version + major = struct.unpack("H", f.read(2))[0] + minor = struct.unpack("H", f.read(2))[0] + assert(major == 1 and minor == 0) + + # Header sizes + _ = struct.unpack("H", f.read(2))[0] + _ = struct.unpack("H", f.read(2))[0] + + block_sz = struct.unpack("I", f.read(4))[0] + _ = struct.unpack("I", f.read(4))[0] + num_chunks = struct.unpack("I", f.read(4))[0] + _ = struct.unpack("I", f.read(4))[0] + + for _ in range(num_chunks): + chunk_type = struct.unpack("H", f.read(2))[0] + _ = struct.unpack("H", f.read(2))[0] + out_blocks = struct.unpack("I", f.read(4))[0] + _ = struct.unpack("I", f.read(4))[0] + + if chunk_type == 0xcac1: # Raw + # TODO: yield in smaller chunks. Yielding only block_sz is too slow. Largest observed data chunk is 252 MB. + yield f.read(out_blocks * block_sz) + elif chunk_type == 0xcac2: # Fill + filler = f.read(4) * (block_sz // 4) + for _ in range(out_blocks): + yield filler + elif chunk_type == 0xcac3: # Don't care + yield b"" + else: + raise Exception("Unhandled sparse chunk type") + + +def flash_agnos_update(manifest_path, cloudlog, spinner=None): + update = json.load(open(manifest_path)) + + current_slot = subprocess.check_output(["abctl", "--boot_slot"], encoding='utf-8').strip() + target_slot = "_b" if current_slot == "_a" else "_a" + target_slot_number = "0" if target_slot == "_a" else "1" + + cloudlog.info(f"Current slot {current_slot}, target slot {target_slot}") + + # set target slot as unbootable + os.system(f"abctl --set_unbootable {target_slot_number}") + + for partition in update: + cloudlog.info(f"Downloading and writing {partition['name']}") + + downloader = StreamingDecompressor(partition['url']) + with open(f"/dev/disk/by-partlabel/{partition['name']}{target_slot}", 'wb') as out: + partition_size = partition['size'] + # Clear hash before flashing + out.seek(partition_size) + out.write(b"\x00" * 64) + out.seek(0) + os.sync() + + # Flash partition + if partition['sparse']: + raw_hash = hashlib.sha256() + for chunk in unsparsify(downloader): + raw_hash.update(chunk) + out.write(chunk) + + if spinner is not None: + spinner.update_progress(out.tell(), partition_size) + + if raw_hash.hexdigest().lower() != partition['hash_raw'].lower(): + raise Exception(f"Unsparse hash mismatch '{raw_hash.hexdigest().lower()}'") + else: + while not downloader.eof: + out.write(downloader.read(1024 * 1024)) + + if spinner is not None: + spinner.update_progress(out.tell(), partition_size) + + if downloader.sha256.hexdigest().lower() != partition['hash'].lower(): + raise Exception("Uncompressed hash mismatch") + + if out.tell() != partition['size']: + raise Exception("Uncompressed size mismatch") + + # Write hash after successfull flash + os.sync() + out.write(partition['hash_raw'].lower().encode()) + + cloudlog.info(f"AGNOS ready on slot {target_slot}") + + +if __name__ == "__main__": + import logging + import time + import sys + + if len(sys.argv) != 2: + print("Usage: ./agnos.py ") + exit(1) + + spinner = Spinner() + spinner.update("Updating AGNOS") + time.sleep(5) + + logging.basicConfig(level=logging.INFO) + flash_agnos_update(sys.argv[1], logging, spinner) diff --git a/selfdrive/hardware/tici/hardware.py b/selfdrive/hardware/tici/hardware.py index 4c2e7178..29e7a96c 100644 --- a/selfdrive/hardware/tici/hardware.py +++ b/selfdrive/hardware/tici/hardware.py @@ -32,6 +32,10 @@ class Tici(HardwareBase): self.nm = self.bus.get_object(NM, '/org/freedesktop/NetworkManager') self.mm = self.bus.get_object(MM, '/org/freedesktop/ModemManager1') + def get_os_version(self): + with open("/VERSION") as f: + return f.read().strip() + def get_sound_card_online(self): return True diff --git a/selfdrive/updated.py b/selfdrive/updated.py index d1eee4d3..1ddf3d78 100755 --- a/selfdrive/updated.py +++ b/selfdrive/updated.py @@ -36,14 +36,14 @@ from typing import List, Tuple, Optional from common.basedir import BASEDIR from common.params import Params -from selfdrive.hardware import EON, TICI +from selfdrive.hardware import EON, TICI, HARDWARE from selfdrive.swaglog import cloudlog from selfdrive.controls.lib.alertmanager import set_offroad_alert +from selfdrive.hardware.tici.agnos import flash_agnos_update LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock") STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging") -NEOS_VERSION = os.getenv("UPDATER_NEOS_VERSION", "/VERSION") NEOSUPDATE_DIR = os.getenv("UPDATER_NEOSUPDATE_DIR", "/data/neoupdate") OVERLAY_UPPER = os.path.join(STAGING_ROOT, "upper") @@ -214,10 +214,23 @@ def finalize_update() -> None: cloudlog.info("done finalizing overlay") -def handle_neos_update(wait_helper: WaitTimeHelper) -> None: - with open(NEOS_VERSION, "r") as f: - cur_neos = f.read().strip() +def handle_agnos_update(wait_helper): + cur_version = HARDWARE.get_os_version() + updated_version = run(["bash", "-c", r"unset AGNOS_VERSION && source launch_env.sh && \ + echo -n $AGNOS_VERSION"], OVERLAY_MERGED).strip() + cloudlog.info(f"AGNOS version check: {cur_version} vs {updated_version}") + if cur_version == updated_version: + return + + cloudlog.info(f"Beginning background installation for AGNOS {updated_version}") + + manifest_path = os.path.join(OVERLAY_MERGED, "installer/updater/update_agnos.json") + flash_agnos_update(manifest_path, cloudlog) + + +def handle_neos_update(wait_helper: WaitTimeHelper) -> None: + cur_neos = HARDWARE.get_os_version() updated_neos = run(["bash", "-c", r"unset REQUIRED_NEOS_VERSION && source launch_env.sh && \ echo -n $REQUIRED_NEOS_VERSION"], OVERLAY_MERGED).strip() @@ -294,6 +307,8 @@ def fetch_update(wait_helper: WaitTimeHelper) -> bool: if EON: handle_neos_update(wait_helper) + elif TICI: + handle_agnos_update(wait_helper) # Create the finalized, ready-to-swap update finalize_update() @@ -392,5 +407,6 @@ def main(): dismount_overlay() + if __name__ == "__main__": main()