IQ.Pilot Release Commit @ 661a2de
This commit is contained in:
58
iqpilot/common/auto_units.py
Normal file
58
iqpilot/common/auto_units.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import time
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.iqpilot.common.geo_regions import UNKNOWN_REGION, region_for_position, region_is_metric
|
||||
|
||||
CHECK_INTERVAL = 10.0
|
||||
CONFIRMATIONS = 3
|
||||
|
||||
|
||||
class AutoUnits:
|
||||
def __init__(self, params: Params | None = None):
|
||||
self.params = params or Params()
|
||||
self._next_check = 0.0
|
||||
self._candidate = UNKNOWN_REGION
|
||||
self._confirmations = 0
|
||||
|
||||
def _position(self) -> tuple[float, float, bool]:
|
||||
from openpilot.selfdrive.ui.lib.nav_helpers import current_or_last_gps_position
|
||||
|
||||
lat, lon, _, valid = current_or_last_gps_position(self.params)
|
||||
return lat, lon, valid
|
||||
|
||||
def update(self, now: float | None = None) -> None:
|
||||
if not self.params.get_bool("IQAutoUnits"):
|
||||
self._candidate = UNKNOWN_REGION
|
||||
self._confirmations = 0
|
||||
return
|
||||
|
||||
now = time.monotonic() if now is None else now
|
||||
if now < self._next_check:
|
||||
return
|
||||
self._next_check = now + CHECK_INTERVAL
|
||||
|
||||
lat, lon, valid = self._position()
|
||||
region = region_for_position(lat, lon) if valid else UNKNOWN_REGION
|
||||
if region == UNKNOWN_REGION:
|
||||
self._confirmations = 0
|
||||
return
|
||||
|
||||
if region != self._candidate:
|
||||
self._candidate = region
|
||||
self._confirmations = 1
|
||||
return
|
||||
|
||||
self._confirmations += 1
|
||||
if self._confirmations < CONFIRMATIONS:
|
||||
return
|
||||
|
||||
if region == self.params.get("IQAutoUnitsRegion"):
|
||||
return
|
||||
|
||||
self.params.put("IQAutoUnitsRegion", region)
|
||||
|
||||
metric = region_is_metric(region)
|
||||
if metric != self.params.get_bool("IsMetric"):
|
||||
self.params.put_bool("IsMetric", metric)
|
||||
cloudlog.warning(f"auto units: {region} detected, switching to {'km/h' if metric else 'mph'}")
|
||||
140
iqpilot/common/geo_regions.py
Normal file
140
iqpilot/common/geo_regions.py
Normal file
@@ -0,0 +1,140 @@
|
||||
MPH_REGIONS = ("US", "GB", "LR")
|
||||
METRIC_REGION = "METRIC"
|
||||
UNKNOWN_REGION = ""
|
||||
|
||||
_US_CONUS = [
|
||||
(-123.32, 49.00), (-117.03, 49.00), (-110.00, 49.00), (-104.05, 49.00), (-97.23, 49.00), (-95.15, 49.00),
|
||||
(-95.15, 49.38), (-94.82, 49.30), (-94.68, 48.77), (-93.85, 48.63), (-93.35, 48.62), (-92.72, 48.54),
|
||||
(-92.30, 48.24), (-91.55, 48.10), (-90.84, 48.24), (-89.99, 48.02), (-89.60, 48.02), (-89.10, 48.32),
|
||||
(-88.40, 48.30), (-87.00, 47.80), (-85.60, 47.15), (-84.60, 46.75), (-84.42, 46.56), (-84.30, 46.49),
|
||||
(-84.12, 46.28), (-83.90, 46.05), (-83.40, 45.75), (-82.90, 45.05), (-82.55, 44.00), (-82.42, 43.00),
|
||||
(-82.70, 42.47), (-82.93, 42.34), (-83.00, 42.33), (-83.05, 42.32), (-83.075, 42.312), (-83.13, 42.25),
|
||||
(-83.15, 42.18), (-83.11, 42.10), (-83.09, 42.02),
|
||||
(-82.50, 41.70), (-81.50, 42.00), (-80.20, 42.40), (-79.06, 42.85), (-79.05, 43.27), (-78.00, 43.45),
|
||||
(-77.00, 43.65), (-76.40, 44.10), (-75.80, 44.50), (-74.75, 45.00), (-73.35, 45.01), (-71.50, 45.01),
|
||||
(-71.29, 45.30), (-70.90, 45.30), (-70.72, 45.42), (-70.31, 45.86), (-70.05, 46.44), (-69.99, 46.70),
|
||||
(-69.24, 47.46), (-68.90, 47.20), (-68.38, 47.29), (-67.79, 47.07), (-67.78, 45.94), (-67.42, 45.60),
|
||||
(-67.03, 44.80), (-68.00, 44.30), (-69.06, 43.80), (-70.20, 43.60), (-70.80, 42.85), (-70.00, 41.90),
|
||||
(-70.00, 41.55), (-71.20, 41.30), (-72.00, 41.05), (-73.90, 40.55), (-74.20, 39.60), (-75.05, 38.45),
|
||||
(-75.90, 37.05), (-75.50, 35.20), (-78.50, 33.85), (-80.90, 32.00), (-81.40, 30.70), (-80.03, 26.80),
|
||||
(-80.15, 25.15), (-81.20, 24.55), (-82.00, 26.40), (-82.80, 27.80), (-83.00, 29.15), (-84.30, 29.90),
|
||||
(-85.30, 29.65), (-87.50, 30.25), (-89.00, 29.15), (-89.40, 28.95), (-91.30, 29.10), (-93.80, 29.65),
|
||||
(-95.00, 29.10), (-97.10, 27.80), (-97.14, 25.96), (-98.30, 26.05), (-99.10, 26.40), (-99.50, 27.60),
|
||||
(-100.40, 28.50), (-101.40, 29.77), (-102.30, 29.88), (-102.90, 29.30), (-103.30, 29.00), (-104.37, 29.56),
|
||||
(-104.68, 30.13), (-105.30, 30.80), (-105.85, 31.30), (-106.15, 31.50), (-106.30, 31.68), (-106.45, 31.755),
|
||||
(-106.53, 31.786), (-108.21, 31.783), (-108.21, 31.33), (-111.07, 31.33), (-114.72, 32.72),
|
||||
(-117.13, 32.53), (-118.40, 33.75), (-119.80, 34.40), (-120.65, 35.10), (-121.90, 36.60), (-122.52, 37.78),
|
||||
(-123.75, 39.40), (-124.20, 40.45), (-124.15, 42.00), (-124.05, 43.35), (-123.95, 46.25), (-124.75, 48.40),
|
||||
(-123.30, 48.25), (-123.15, 48.70),
|
||||
]
|
||||
|
||||
_US_ALASKA = [
|
||||
(-141.00, 70.20), (-141.00, 60.30), (-139.05, 60.35), (-137.45, 58.95), (-136.47, 59.63), (-135.03, 59.57),
|
||||
(-134.30, 58.90), (-133.40, 58.20), (-132.20, 56.90), (-130.60, 56.20), (-130.01, 54.80), (-131.80, 54.70),
|
||||
(-133.80, 55.90), (-136.60, 58.20), (-140.00, 59.70), (-145.00, 60.00), (-149.20, 59.10), (-152.30, 57.30),
|
||||
(-155.20, 55.60), (-160.00, 54.60), (-164.50, 54.40), (-162.00, 57.50), (-165.00, 60.20), (-167.50, 62.50),
|
||||
(-164.00, 64.50), (-168.10, 65.60), (-166.00, 68.30), (-161.00, 70.30), (-156.50, 71.40), (-150.00, 70.50),
|
||||
]
|
||||
|
||||
_US_ALEUTIANS_EAST = [(-180.00, 51.00), (-158.50, 51.00), (-158.50, 56.00), (-180.00, 56.00)]
|
||||
_US_ALEUTIANS_WEST = [(172.00, 51.00), (180.00, 51.00), (180.00, 54.00), (172.00, 54.00)]
|
||||
_US_HAWAII = [(-160.50, 18.80), (-154.70, 18.80), (-154.70, 22.30), (-160.50, 22.30)]
|
||||
_US_PUERTO_RICO = [(-67.35, 17.85), (-64.55, 17.85), (-64.55, 18.55), (-67.35, 18.55)]
|
||||
_US_MARIANAS = [(144.50, 13.10), (146.20, 13.10), (146.20, 20.60), (144.50, 20.60)]
|
||||
_US_SAMOA = [(-171.20, -14.60), (-168.10, -14.60), (-168.10, -11.00), (-171.20, -11.00)]
|
||||
|
||||
_GB_BRITAIN = [
|
||||
(-5.72, 50.07), (-4.20, 50.32), (-3.41, 50.62), (-2.45, 50.52), (-1.80, 50.72), (-0.90, 50.77),
|
||||
(0.58, 50.85), (1.35, 51.13), (1.38, 51.38), (1.15, 51.79), (1.35, 51.95), (1.75, 52.48),
|
||||
(1.30, 52.94), (0.49, 52.94), (0.34, 53.15), (-0.08, 53.57), (-0.08, 54.12), (-0.61, 54.49),
|
||||
(-1.18, 54.69), (-1.38, 54.91), (-1.50, 55.13), (-2.00, 55.77), (-2.52, 56.00), (-2.62, 56.28),
|
||||
(-2.47, 56.55), (-2.21, 56.96), (-2.08, 57.14), (-1.77, 57.50), (-2.00, 57.70), (-2.96, 57.68),
|
||||
(-3.90, 57.60), (-4.22, 57.48), (-4.05, 57.81), (-3.85, 58.01), (-3.65, 58.12), (-3.09, 58.44),
|
||||
(-3.01, 58.67), (-3.35, 58.62), (-3.52, 58.60), (-4.99, 58.62), (-5.05, 58.45), (-5.16, 57.90), (-5.70, 57.72),
|
||||
(-5.72, 57.28), (-5.83, 57.00), (-5.72, 56.65), (-5.47, 56.41), (-5.79, 55.60), (-5.62, 55.31),
|
||||
(-4.82, 55.64), (-4.63, 55.46), (-4.85, 55.24), (-5.12, 54.84), (-4.86, 54.63), (-4.44, 54.87),
|
||||
(-4.05, 54.83), (-3.26, 54.98), (-3.05, 54.90), (-3.50, 54.72), (-3.23, 54.07), (-3.05, 53.82),
|
||||
(-3.40, 53.34), (-3.83, 53.33), (-4.63, 53.42), (-4.72, 53.28), (-4.35, 53.12), (-4.76, 52.80),
|
||||
(-4.06, 52.72), (-4.09, 52.41), (-4.66, 52.09), (-5.31, 51.88), (-5.06, 51.70), (-4.70, 51.67),
|
||||
(-4.30, 51.62), (-3.95, 51.56), (-3.70, 51.48), (-3.17, 51.45), (-2.99, 51.55), (-2.67, 51.62),
|
||||
(-2.48, 51.72), (-2.30, 51.85), (-2.70, 51.50), (-2.98, 51.35), (-3.00, 51.20), (-3.47, 51.21),
|
||||
(-4.12, 51.21), (-4.55, 50.83), (-5.08, 50.42), (-5.48, 50.21),
|
||||
]
|
||||
|
||||
_GB_NORTHERN_IRELAND = [
|
||||
(-6.03, 54.05), (-6.28, 54.10), (-6.65, 54.17), (-6.86, 54.33), (-7.16, 54.34), (-7.31, 54.12),
|
||||
(-7.62, 54.14), (-8.00, 54.31), (-8.18, 54.47), (-8.20, 54.52), (-7.90, 54.55), (-7.85, 54.72), (-7.55, 54.75),
|
||||
(-7.44, 54.94), (-7.25, 55.06), (-6.95, 55.22), (-6.50, 55.25), (-6.25, 55.31), (-6.03, 55.22),
|
||||
(-5.43, 54.62), (-5.53, 54.24),
|
||||
]
|
||||
|
||||
_GB_ISLE_OF_MAN = [(-4.85, 54.03), (-4.30, 54.03), (-4.30, 54.42), (-4.85, 54.42)]
|
||||
_GB_CHANNEL_ISLANDS = [(-2.75, 49.15), (-1.95, 49.15), (-1.95, 49.80), (-2.75, 49.80)]
|
||||
_GB_ISLE_OF_WIGHT = [(-1.60, 50.55), (-1.05, 50.55), (-1.05, 50.80), (-1.60, 50.80)]
|
||||
_GB_OUTER_HEBRIDES = [(-7.75, 56.75), (-6.05, 56.75), (-6.05, 58.55), (-7.75, 58.55)]
|
||||
_GB_INNER_HEBRIDES = [(-7.00, 55.45), (-5.55, 55.45), (-5.55, 57.85), (-7.00, 57.85)]
|
||||
_GB_ORKNEY = [(-3.50, 58.70), (-2.35, 58.70), (-2.35, 59.45), (-3.50, 59.45)]
|
||||
_GB_SHETLAND = [(-1.85, 59.80), (-0.65, 59.80), (-0.65, 60.90), (-1.85, 60.90)]
|
||||
|
||||
_LR_LIBERIA = [
|
||||
(-11.46, 6.77), (-11.30, 6.95), (-11.16, 7.15), (-11.05, 7.40), (-10.85, 7.75), (-10.60, 8.00),
|
||||
(-10.28, 8.49), (-9.70, 8.54), (-9.35, 7.80),
|
||||
(-8.85, 7.40), (-8.48, 7.55), (-8.30, 6.90), (-7.95, 6.20), (-7.60, 5.20), (-7.40, 4.55),
|
||||
(-7.74, 4.33), (-8.46, 4.61), (-9.06, 4.97), (-9.52, 5.36), (-10.08, 5.85), (-10.40, 6.11),
|
||||
(-10.83, 6.27),
|
||||
]
|
||||
|
||||
_REGION_RINGS = {
|
||||
"US": (_US_CONUS, _US_ALASKA, _US_ALEUTIANS_EAST, _US_ALEUTIANS_WEST, _US_HAWAII, _US_PUERTO_RICO,
|
||||
_US_MARIANAS, _US_SAMOA),
|
||||
"GB": (_GB_BRITAIN, _GB_NORTHERN_IRELAND, _GB_ISLE_OF_MAN, _GB_CHANNEL_ISLANDS, _GB_ISLE_OF_WIGHT,
|
||||
_GB_OUTER_HEBRIDES, _GB_INNER_HEBRIDES, _GB_ORKNEY, _GB_SHETLAND),
|
||||
"LR": (_LR_LIBERIA,),
|
||||
}
|
||||
|
||||
|
||||
def _bounded(rings):
|
||||
out = []
|
||||
for ring in rings:
|
||||
lons = [p[0] for p in ring]
|
||||
lats = [p[1] for p in ring]
|
||||
out.append(((min(lons), min(lats), max(lons), max(lats)), ring))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
_REGIONS = tuple((region, _bounded(rings)) for region, rings in _REGION_RINGS.items())
|
||||
|
||||
|
||||
def _point_in_ring(lat: float, lon: float, ring) -> bool:
|
||||
inside = False
|
||||
count = len(ring)
|
||||
j = count - 1
|
||||
for i in range(count):
|
||||
lon_i, lat_i = ring[i]
|
||||
lon_j, lat_j = ring[j]
|
||||
if (lat_i > lat) != (lat_j > lat):
|
||||
crossing = (lon_j - lon_i) * (lat - lat_i) / (lat_j - lat_i) + lon_i
|
||||
if lon < crossing:
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def valid_position(lat: float, lon: float) -> bool:
|
||||
return abs(lat) <= 90.0 and abs(lon) <= 180.0 and (abs(lat) > 1e-4 or abs(lon) > 1e-4)
|
||||
|
||||
|
||||
def region_for_position(lat: float, lon: float) -> str:
|
||||
if not valid_position(lat, lon):
|
||||
return UNKNOWN_REGION
|
||||
|
||||
for region, rings in _REGIONS:
|
||||
for (min_lon, min_lat, max_lon, max_lat), ring in rings:
|
||||
if min_lon <= lon <= max_lon and min_lat <= lat <= max_lat and _point_in_ring(lat, lon, ring):
|
||||
return region
|
||||
|
||||
return METRIC_REGION
|
||||
|
||||
|
||||
def region_is_metric(region: str) -> bool:
|
||||
return bool(region) and region not in MPH_REGIONS
|
||||
@@ -121,19 +121,13 @@ def _install_signal_handlers() -> None:
|
||||
signal.signal(signal.SIGTERM, _handle_shutdown_signal)
|
||||
|
||||
|
||||
class _QuietSpinner:
|
||||
def update(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
def close(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_vendor_runtime() -> None:
|
||||
# verify-only: a hash-mismatched binary is quarantined, never replaced from
|
||||
# the network — the updater restores the checked-in one
|
||||
try:
|
||||
VendorMapdInstaller(_QuietSpinner()).check_and_download()
|
||||
VendorMapdInstaller().verify()
|
||||
except Exception:
|
||||
cloudlog.exception("iq_maps: vendor runtime install/download failed")
|
||||
cloudlog.exception("iq_maps: vendor runtime verification failed")
|
||||
|
||||
params = Params()
|
||||
mem_params = Params("/dev/shm/params") if platform.system() != "Darwin" else params
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Maintainer utility: pin a new pfeiferj/mapd release tag and refresh the checked-in
|
||||
binary hash. Not used at runtime.
|
||||
Maintainer utility: pin a new mapd release tag and refresh the checked-in binary
|
||||
hash. Not used at runtime. Binaries come from the gitlvb teal/mapd CI (built
|
||||
against teal/gomsgq) — drop the artifact at third_party/mapd_pfeiferj/mapd, then
|
||||
run this so the hash pin moves in the same commit.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
@@ -2,36 +2,30 @@
|
||||
"""
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos
|
||||
|
||||
Provisions the `mapd` routing binary authored by Jacob Pfeifer (github.com/pfeiferj/mapd).
|
||||
The binary itself is his work; this module only fetches, verifies and stages it on-device.
|
||||
Verifies the vendored `mapd` routing binary authored by Jacob Pfeifer
|
||||
(github.com/pfeiferj/mapd), built from the gitlvb teal/mapd fork against
|
||||
teal/gomsgq. The only accepted binary is the checked-in one matching the pinned
|
||||
hash; nothing is ever downloaded at runtime. Jacob's stock release build embeds
|
||||
a 15-reader msgq header layout — on this fork (NUM_READERS=32) its registration
|
||||
writes land inside other processes' reader slots, so a wrong binary is
|
||||
quarantined rather than left where manager could start it.
|
||||
"""
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
import time
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
from cereal import messaging
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.spinner import Spinner
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
from openpilot.system.version import is_prebuilt
|
||||
from openpilot.iqpilot.iq_maps import VENDOR_MAPD_BIN_DIR, VENDOR_MAPD_PATH
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.iqpilot.iq_maps import VENDOR_MAPD_PATH
|
||||
import openpilot.system.sentry as sentry
|
||||
|
||||
VENDOR_RELEASE_TAG = "v2.0.6"
|
||||
VENDOR_RELEASE_URL = f"https://github.com/pfeiferj/mapd/releases/download/{VENDOR_RELEASE_TAG}/mapd"
|
||||
VENDOR_RELEASE_TAG = "v2.0.6-iq1"
|
||||
|
||||
_VERSION_PARAM = "MapdVersion"
|
||||
_HASH_FILE = os.path.join(BASEDIR, "iqpilot", "iq_maps", "tests", "mapd_hash")
|
||||
_HTTP_TIMEOUT_S = 60
|
||||
_FETCH_ATTEMPTS = 5
|
||||
_NET_PROBE_ATTEMPTS = 10
|
||||
_NET_PROBE_INTERVAL_S = 2
|
||||
QUARANTINE_PATH = VENDOR_MAPD_PATH + ".quarantined"
|
||||
|
||||
|
||||
def sha256_of_file(path: str) -> str:
|
||||
@@ -48,45 +42,58 @@ def stamp_vendor_version(version: str, params: Params | None = None) -> None:
|
||||
|
||||
|
||||
class VendorMapdInstaller:
|
||||
def __init__(self, spinner_ref: Spinner):
|
||||
def __init__(self, spinner_ref: Spinner | None = None, params: Params | None = None):
|
||||
self._spinner = spinner_ref
|
||||
self._params = Params()
|
||||
self._params = params if params is not None else Params()
|
||||
|
||||
# --- externally consumed surface -----------------------------------------
|
||||
def get_installed_version(self) -> str:
|
||||
return str(self._params.get(_VERSION_PARAM) or "")
|
||||
|
||||
@staticmethod
|
||||
def ensure_directories_exist() -> None:
|
||||
for directory in (Paths.mapd_root(), VENDOR_MAPD_BIN_DIR):
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
def verify(self) -> bool:
|
||||
"""True iff the on-disk binary matches the pinned hash; quarantines a wrong one."""
|
||||
expected = self._expected_hash()
|
||||
if not expected:
|
||||
cloudlog.error("iq_maps: pinned mapd hash missing, vendor binary cannot be verified")
|
||||
return False
|
||||
|
||||
def check_and_download(self) -> None:
|
||||
if not self._binary_up_to_date():
|
||||
self._provision()
|
||||
|
||||
def non_prebuilt_install(self) -> None:
|
||||
if self._on_metered_link():
|
||||
self._say("Metered connection detected — offline maps engine will not download here.")
|
||||
time.sleep(5)
|
||||
return
|
||||
if not os.path.isfile(VENDOR_MAPD_PATH):
|
||||
# the binary is a tracked file: the updater/bundle restores it
|
||||
self._say("Offline maps engine missing; it will be restored by the next update.")
|
||||
self._params.remove(_VERSION_PARAM)
|
||||
return False
|
||||
|
||||
try:
|
||||
self.ensure_directories_exist()
|
||||
if self._binary_up_to_date():
|
||||
self._say("Offline maps engine already present and current.")
|
||||
time.sleep(0.1)
|
||||
return
|
||||
current = sha256_of_file(VENDOR_MAPD_PATH)
|
||||
except OSError:
|
||||
cloudlog.exception("iq_maps: vendor mapd unreadable")
|
||||
return False
|
||||
|
||||
if self._block_until_online():
|
||||
self._say(f"Retrieving offline maps engine [{self.get_installed_version() or 'none'}] -> [{VENDOR_RELEASE_TAG}]")
|
||||
time.sleep(0.1)
|
||||
self._provision()
|
||||
self._spinner.close()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._announce_failure(exc)
|
||||
if current == expected:
|
||||
stamp_vendor_version(VENDOR_RELEASE_TAG, self._params)
|
||||
try:
|
||||
os.remove(QUARANTINE_PATH)
|
||||
except OSError:
|
||||
pass
|
||||
self._say(f"Offline maps engine verified [{VENDOR_RELEASE_TAG}]")
|
||||
return True
|
||||
|
||||
# a foreign binary — e.g. a stock release download from the retired fetch
|
||||
# path — must never run: quarantine it where manager can't start it
|
||||
cloudlog.error(f"iq_maps: vendor mapd hash {current[:12]} != pinned {expected[:12]}, quarantining")
|
||||
self._say("Offline maps engine failed verification; quarantined until the next update.")
|
||||
try:
|
||||
os.replace(VENDOR_MAPD_PATH, QUARANTINE_PATH)
|
||||
except OSError:
|
||||
cloudlog.exception("iq_maps: vendor mapd quarantine failed")
|
||||
return False
|
||||
self._params.remove(_VERSION_PARAM)
|
||||
try:
|
||||
raise RuntimeError(f"vendor mapd hash mismatch quarantined: {current}")
|
||||
except RuntimeError as exc:
|
||||
sentry.init(sentry.SentryProject.SELFDRIVE)
|
||||
sentry.capture_exception(exc)
|
||||
return False
|
||||
|
||||
# --- internal ------------------------------------------------------------
|
||||
def _expected_hash(self) -> str:
|
||||
try:
|
||||
with open(_HASH_FILE) as f:
|
||||
@@ -94,88 +101,13 @@ class VendorMapdInstaller:
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _binary_up_to_date(self) -> bool:
|
||||
if not os.path.exists(VENDOR_MAPD_PATH):
|
||||
return False
|
||||
if self.get_installed_version() != VENDOR_RELEASE_TAG:
|
||||
return False
|
||||
reference = self._expected_hash()
|
||||
if not reference:
|
||||
return True
|
||||
try:
|
||||
return sha256_of_file(VENDOR_MAPD_PATH) == reference
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def _provision(self) -> None:
|
||||
self.ensure_directories_exist()
|
||||
if self._retrieve_binary():
|
||||
stamp_vendor_version(VENDOR_RELEASE_TAG, self._params)
|
||||
|
||||
def _retrieve_binary(self) -> bool:
|
||||
staging = Path(f"{VENDOR_MAPD_PATH}.part")
|
||||
last_error: Exception | None = None
|
||||
for attempt in range(1, _FETCH_ATTEMPTS + 1):
|
||||
try:
|
||||
with requests.get(VENDOR_RELEASE_URL, stream=True, timeout=_HTTP_TIMEOUT_S) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(staging, "wb") as out:
|
||||
for chunk in resp.iter_content(chunk_size=1 << 16):
|
||||
out.write(chunk)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.chmod(staging, os.lstat(staging).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
staging.replace(VENDOR_MAPD_PATH)
|
||||
return True
|
||||
except requests.exceptions.RequestException as exc:
|
||||
last_error = exc
|
||||
self._say(f"offline maps fetch attempt {attempt}/{_FETCH_ATTEMPTS} did not complete ({exc})")
|
||||
time.sleep(0.5)
|
||||
staging.unlink(missing_ok=True)
|
||||
logging.error("offline maps engine could not be fetched after %d attempts: %s", _FETCH_ATTEMPTS, last_error)
|
||||
return False
|
||||
|
||||
def _on_metered_link(self) -> bool:
|
||||
sm = messaging.SubMaster(["deviceState"])
|
||||
return bool(sm["deviceState"].networkMetered)
|
||||
|
||||
def _block_until_online(self) -> bool:
|
||||
for i in range(1, _NET_PROBE_ATTEMPTS + 1):
|
||||
self._say(f"Waiting for a usable network connection... [{i}/{_NET_PROBE_ATTEMPTS}]")
|
||||
if self._link_reachable():
|
||||
return True
|
||||
time.sleep(_NET_PROBE_INTERVAL_S)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _link_reachable() -> bool:
|
||||
try:
|
||||
requests.head(VENDOR_RELEASE_URL, timeout=10, allow_redirects=True)
|
||||
return True
|
||||
except requests.exceptions.RequestException as exc:
|
||||
logging.debug("network probe failed: %s", exc)
|
||||
return False
|
||||
|
||||
def _announce_failure(self, exc: Exception) -> None:
|
||||
for remaining in range(5, 0, -1):
|
||||
self._say(f"Offline maps engine unavailable; navigation stays online-only. Boot continues in {remaining}s...")
|
||||
time.sleep(1)
|
||||
logging.exception("vendor mapd install failed")
|
||||
sentry.init(sentry.SentryProject.SELFDRIVE)
|
||||
sentry.capture_exception(exc)
|
||||
|
||||
def _say(self, text: str) -> None:
|
||||
self._spinner.update(text)
|
||||
if self._spinner is not None:
|
||||
self._spinner.update(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
spinner = Spinner()
|
||||
installer = VendorMapdInstaller(spinner)
|
||||
installer.ensure_directories_exist()
|
||||
if is_prebuilt():
|
||||
spinner.update(f"[DEBUG] Prebuilt build; vendor mapd install skipped. "
|
||||
f"target [{VENDOR_RELEASE_TAG}], param [{installer.get_installed_version()}]")
|
||||
stamp_vendor_version(VENDOR_RELEASE_TAG)
|
||||
else:
|
||||
spinner.update(f"Verifying vendor mapd install. prebuilt [{is_prebuilt()}]")
|
||||
installer.non_prebuilt_install()
|
||||
ok = VendorMapdInstaller(spinner).verify()
|
||||
spinner.close()
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
0
iqpilot/konn3kt/canlive/__init__.py
Normal file
0
iqpilot/konn3kt/canlive/__init__.py
Normal file
108
iqpilot/konn3kt/canlive/canlived.py
Executable file
108
iqpilot/konn3kt/canlive/canlived.py
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
canlived — live CAN bridge to konn3kt.
|
||||
|
||||
Streams the device's live CAN bus to the konn3kt server so it can be viewed remotely
|
||||
in Cabana (via a local ZMQ proxy on the laptop). This is the remote analogue of running
|
||||
`./cereal/messaging/bridge` locally: instead of re-publishing CAN over a LAN ZMQ socket,
|
||||
canlived opens its OWN websocket to konn3kt and forwards the raw capnp `Event` frames.
|
||||
|
||||
It is deliberately a separate daemon (not part of hephaestusd's control websocket):
|
||||
* hephaestusd's send path fragments everything as TEXT frames through one queue, which
|
||||
cannot carry binary capnp and would head-of-line-block the control plane at 100Hz.
|
||||
* a dedicated socket means CAN traffic and control traffic never contend.
|
||||
|
||||
Lifecycle: the manager launches canlived only while the `CanLiveStreaming` param is set.
|
||||
hephaestusd sets/clears that param via startCanLive/stopCanLive, which the konn3kt server
|
||||
calls when the first viewer connects / the last viewer disconnects. So canlived runs only
|
||||
during an active debug session — no idle connections, no battery/data cost otherwise.
|
||||
|
||||
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
|
||||
from websocket import ABNF, create_connection
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from openpilot.common.api import Api
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
# Cabana's live "Device" stream subscribes only to "can", so that's all we forward to
|
||||
# match the local experience exactly. (sendcan/TX is not shown by the live device view.)
|
||||
CAN_SERVICES = ["can"]
|
||||
|
||||
# Reconnect backoff bounds (seconds).
|
||||
RECONNECT_MIN = 1.0
|
||||
RECONNECT_MAX = 10.0
|
||||
|
||||
|
||||
def _api_host() -> str:
|
||||
# Same host hephaestusd talks to; force the websocket scheme.
|
||||
host = os.getenv("HEPHAESTUS_HOST") or os.getenv("KONN3KT_API_HOST") or "wss://api-iqlabs.konn3kt.com"
|
||||
host = host.rstrip("/")
|
||||
if host.startswith("https://"):
|
||||
host = "wss://" + host[len("https://"):]
|
||||
elif host.startswith("http://"):
|
||||
host = "ws://" + host[len("http://"):]
|
||||
return host
|
||||
|
||||
|
||||
def _stream_once(dongle_id: str, ws_uri: str, token: str, exit_event: threading.Event) -> None:
|
||||
"""Open one websocket and pump CAN until it drops or we're asked to exit."""
|
||||
ws = create_connection(ws_uri, cookie="jwt=" + token, enable_multithread=True, timeout=30.0)
|
||||
cloudlog.info("canlived: connected to %s", ws_uri)
|
||||
try:
|
||||
# Blocking receive with a short timeout so we periodically re-check exit_event and
|
||||
# the socket stays responsive to shutdown even when the bus is quiet.
|
||||
socks = [messaging.sub_sock(s, conflate=False, timeout=100) for s in CAN_SERVICES]
|
||||
while not exit_event.is_set():
|
||||
got_any = False
|
||||
for sock in socks:
|
||||
while True:
|
||||
raw = sock.receive(non_blocking=True)
|
||||
if raw is None:
|
||||
break
|
||||
got_any = True
|
||||
# Forward the exact capnp Event bytes as a single binary frame. canlived owns
|
||||
# this socket, so there is no fragmentation/interleaving to worry about.
|
||||
ws.send_frame(ABNF.create_frame(raw, ABNF.OPCODE_BINARY, 1))
|
||||
if not got_any:
|
||||
# Nothing pending across any sub — yield briefly instead of busy-spinning.
|
||||
exit_event.wait(0.005)
|
||||
finally:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main(exit_event: threading.Event | None = None) -> None:
|
||||
if exit_event is None:
|
||||
exit_event = threading.Event()
|
||||
|
||||
params = Params()
|
||||
dongle_id = params.get("DongleId", encoding="utf-8")
|
||||
if not dongle_id:
|
||||
cloudlog.error("canlived: no DongleId, cannot stream")
|
||||
return
|
||||
|
||||
api = Api(dongle_id)
|
||||
host = _api_host()
|
||||
ws_uri = f"{host}/ws/can/{dongle_id}"
|
||||
|
||||
backoff = RECONNECT_MIN
|
||||
while not exit_event.is_set():
|
||||
try:
|
||||
token = api.get_token(expiry_hours=1)
|
||||
_stream_once(dongle_id, ws_uri, token, exit_event)
|
||||
backoff = RECONNECT_MIN # clean disconnect, reset backoff
|
||||
except Exception as e:
|
||||
cloudlog.exception("canlived: stream error: %s", e)
|
||||
exit_event.wait(backoff)
|
||||
backoff = min(backoff * 2, RECONNECT_MAX)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
iqpilot/tests/test_auto_units.py
Normal file
91
iqpilot/tests/test_auto_units.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.iqpilot.common.auto_units import CONFIRMATIONS, AutoUnits
|
||||
|
||||
SEATTLE = (47.6062, -122.3321)
|
||||
BERLIN = (52.5200, 13.4050)
|
||||
LONDON = (51.5074, -0.1278)
|
||||
|
||||
|
||||
class StubAutoUnits(AutoUnits):
|
||||
def __init__(self, params):
|
||||
super().__init__(params)
|
||||
self.position = (0.0, 0.0, False)
|
||||
|
||||
def _position(self):
|
||||
return self.position
|
||||
|
||||
|
||||
def settle(auto_units, position, count=CONFIRMATIONS, start=0.0):
|
||||
auto_units.position = position
|
||||
for i in range(count):
|
||||
auto_units.update(now=start + i * 100.0)
|
||||
return start + count * 100.0
|
||||
|
||||
|
||||
class TestAutoUnits:
|
||||
def setup_method(self):
|
||||
self.params = Params()
|
||||
self.params.put_bool("IQAutoUnits", True)
|
||||
self.params.remove("IQAutoUnitsRegion")
|
||||
self.params.put_bool("IsMetric", False)
|
||||
self.auto_units = StubAutoUnits(self.params)
|
||||
|
||||
def test_no_fix_does_nothing(self):
|
||||
settle(self.auto_units, (0.0, 0.0, False))
|
||||
assert self.params.get("IQAutoUnitsRegion") is None
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
def test_disabled_does_nothing(self):
|
||||
self.params.put_bool("IQAutoUnits", False)
|
||||
settle(self.auto_units, (*BERLIN, True))
|
||||
assert self.params.get("IQAutoUnitsRegion") is None
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
def test_metric_region_switches_to_metric(self):
|
||||
settle(self.auto_units, (*BERLIN, True))
|
||||
assert self.params.get("IQAutoUnitsRegion") == "METRIC"
|
||||
assert self.params.get_bool("IsMetric")
|
||||
|
||||
def test_mph_region_stays_imperial(self):
|
||||
settle(self.auto_units, (*SEATTLE, True))
|
||||
assert self.params.get("IQAutoUnitsRegion") == "US"
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
def test_uk_stays_imperial(self):
|
||||
self.params.put_bool("IsMetric", True)
|
||||
settle(self.auto_units, (*LONDON, True))
|
||||
assert self.params.get("IQAutoUnitsRegion") == "GB"
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
def test_border_crossing_switches_units(self):
|
||||
now = settle(self.auto_units, (*SEATTLE, True))
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
settle(self.auto_units, (*BERLIN, True), start=now)
|
||||
assert self.params.get("IQAutoUnitsRegion") == "METRIC"
|
||||
assert self.params.get_bool("IsMetric")
|
||||
|
||||
def test_manual_override_is_kept_within_a_region(self):
|
||||
now = settle(self.auto_units, (*SEATTLE, True))
|
||||
self.params.put_bool("IsMetric", True)
|
||||
|
||||
settle(self.auto_units, (*SEATTLE, True), start=now)
|
||||
assert self.params.get_bool("IsMetric")
|
||||
|
||||
def test_unconfirmed_region_is_not_applied(self):
|
||||
settle(self.auto_units, (*BERLIN, True), count=CONFIRMATIONS - 1)
|
||||
assert self.params.get("IQAutoUnitsRegion") is None
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
def test_flapping_region_resets_confirmations(self):
|
||||
now = 0.0
|
||||
for position in (BERLIN, SEATTLE, BERLIN, SEATTLE):
|
||||
now = settle(self.auto_units, (*position, True), count=1, start=now)
|
||||
assert self.params.get("IQAutoUnitsRegion") is None
|
||||
assert not self.params.get_bool("IsMetric")
|
||||
|
||||
def test_rate_limited(self):
|
||||
self.auto_units.position = (*BERLIN, True)
|
||||
for _ in range(CONFIRMATIONS * 4):
|
||||
self.auto_units.update(now=1.0)
|
||||
assert self.params.get("IQAutoUnitsRegion") is None
|
||||
165
iqpilot/tests/test_geo_regions.py
Normal file
165
iqpilot/tests/test_geo_regions.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import pytest
|
||||
|
||||
from openpilot.iqpilot.common.geo_regions import METRIC_REGION, UNKNOWN_REGION, region_for_position, region_is_metric
|
||||
|
||||
US_POINTS = [
|
||||
(47.6062, -122.3321, "Seattle"),
|
||||
(42.3314, -83.0458, "Detroit"),
|
||||
(42.8864, -78.8784, "Buffalo"),
|
||||
(25.7617, -80.1918, "Miami"),
|
||||
(29.7604, -95.3698, "Houston"),
|
||||
(32.7157, -117.1611, "San Diego"),
|
||||
(34.0522, -118.2437, "Los Angeles"),
|
||||
(61.2181, -149.9003, "Anchorage"),
|
||||
(58.3019, -134.4197, "Juneau"),
|
||||
(64.8378, -147.7164, "Fairbanks"),
|
||||
(21.3069, -157.8583, "Honolulu"),
|
||||
(18.4655, -66.1057, "San Juan"),
|
||||
(13.4757, 144.7489, "Guam"),
|
||||
(44.9778, -93.2650, "Minneapolis"),
|
||||
(40.7128, -74.0060, "New York"),
|
||||
(41.8781, -87.6298, "Chicago"),
|
||||
(31.7900, -106.4300, "El Paso"),
|
||||
(26.2034, -98.2300, "McAllen"),
|
||||
(44.8016, -68.7712, "Bangor"),
|
||||
(48.7519, -122.4787, "Bellingham"),
|
||||
(46.8772, -96.7898, "Fargo"),
|
||||
(48.6023, -93.4093, "International Falls"),
|
||||
(46.4953, -84.3453, "Sault Ste. Marie MI"),
|
||||
(47.1211, -88.5694, "Houghton"),
|
||||
(41.6528, -83.5379, "Toledo"),
|
||||
(42.1370, -83.1930, "Trenton MI"),
|
||||
(39.7392, -104.9903, "Denver"),
|
||||
(33.4484, -112.0740, "Phoenix"),
|
||||
(30.3322, -81.6557, "Jacksonville"),
|
||||
(42.3601, -71.0589, "Boston"),
|
||||
(38.9072, -77.0369, "Washington DC"),
|
||||
]
|
||||
|
||||
GB_POINTS = [
|
||||
(51.5074, -0.1278, "London"),
|
||||
(54.5973, -5.9301, "Belfast"),
|
||||
(55.8642, -4.2518, "Glasgow"),
|
||||
(51.4816, -3.1791, "Cardiff"),
|
||||
(51.4545, -2.5879, "Bristol"),
|
||||
(53.4084, -2.9916, "Liverpool"),
|
||||
(53.4808, -2.2426, "Manchester"),
|
||||
(55.9533, -3.1883, "Edinburgh"),
|
||||
(52.4862, -1.8904, "Birmingham"),
|
||||
(53.8008, -1.5491, "Leeds"),
|
||||
(57.4778, -4.2247, "Inverness"),
|
||||
(57.1497, -2.0943, "Aberdeen"),
|
||||
(56.4620, -2.9707, "Dundee"),
|
||||
(58.6373, -3.0689, "John o' Groats"),
|
||||
(54.1509, -4.4814, "Douglas"),
|
||||
(49.1858, -2.1064, "St Helier"),
|
||||
(55.0000, -7.3200, "Derry"),
|
||||
(54.3438, -7.6315, "Enniskillen"),
|
||||
(54.1751, -6.3402, "Newry"),
|
||||
(54.4783, -8.0906, "Belleek"),
|
||||
(54.5973, -7.3095, "Omagh"),
|
||||
(55.2053, -6.6570, "Portrush"),
|
||||
(58.2090, -6.3890, "Stornoway"),
|
||||
(58.9809, -2.9605, "Kirkwall"),
|
||||
(60.1546, -1.1494, "Lerwick"),
|
||||
(50.7184, -3.5339, "Exeter"),
|
||||
(50.3755, -4.1427, "Plymouth"),
|
||||
(52.6309, 1.2974, "Norwich"),
|
||||
(54.9783, -1.6178, "Newcastle"),
|
||||
(51.8642, -2.2382, "Gloucester"),
|
||||
(52.4140, -4.0810, "Aberystwyth"),
|
||||
(51.6214, -3.9436, "Swansea"),
|
||||
(50.6938, -1.3040, "Newport IoW"),
|
||||
]
|
||||
|
||||
LR_POINTS = [
|
||||
(6.3005, -10.7969, "Monrovia"),
|
||||
(4.3750, -7.7169, "Harper"),
|
||||
(6.9956, -9.4722, "Gbarnga"),
|
||||
(6.0667, -8.1333, "Zwedru"),
|
||||
(8.4219, -9.7478, "Voinjama"),
|
||||
(5.8808, -10.0467, "Buchanan"),
|
||||
(5.0100, -9.0400, "Greenville"),
|
||||
(7.3500, -8.7200, "Ganta"),
|
||||
]
|
||||
|
||||
METRIC_POINTS = [
|
||||
(49.2827, -123.1207, "Vancouver"),
|
||||
(48.4284, -123.3656, "Victoria"),
|
||||
(43.6532, -79.3832, "Toronto"),
|
||||
(42.3149, -83.0364, "Windsor"),
|
||||
(46.5136, -84.3358, "Sault Ste. Marie ON"),
|
||||
(42.9745, -82.4066, "Sarnia"),
|
||||
(43.2557, -79.8711, "Hamilton"),
|
||||
(42.9849, -81.2453, "London ON"),
|
||||
(45.5019, -73.5674, "Montreal"),
|
||||
(45.4765, -75.7013, "Gatineau"),
|
||||
(46.8139, -71.2080, "Quebec City"),
|
||||
(46.0878, -64.7782, "Moncton"),
|
||||
(44.6488, -63.5752, "Halifax"),
|
||||
(49.8951, -97.1384, "Winnipeg"),
|
||||
(51.0447, -114.0719, "Calgary"),
|
||||
(53.5461, -113.4938, "Edmonton"),
|
||||
(52.1332, -106.6700, "Saskatoon"),
|
||||
(50.6745, -120.3273, "Kamloops"),
|
||||
(32.5149, -117.0382, "Tijuana"),
|
||||
(31.7000, -106.4700, "Ciudad Juarez"),
|
||||
(25.6866, -100.3161, "Monterrey"),
|
||||
(27.5060, -99.5075, "Nuevo Laredo"),
|
||||
(19.4326, -99.1332, "Mexico City"),
|
||||
(53.3498, -6.2603, "Dublin"),
|
||||
(51.8985, -8.4756, "Cork"),
|
||||
(53.2707, -9.0568, "Galway"),
|
||||
(54.9503, -7.7345, "Letterkenny"),
|
||||
(54.0000, -6.4000, "Dundalk"),
|
||||
(54.2489, -6.9683, "Monaghan"),
|
||||
(54.2766, -8.4761, "Sligo"),
|
||||
(54.6538, -8.1096, "Donegal"),
|
||||
(52.5200, 13.4050, "Berlin"),
|
||||
(52.2297, 21.0122, "Warsaw"),
|
||||
(48.8566, 2.3522, "Paris"),
|
||||
(60.1699, 24.9384, "Helsinki"),
|
||||
(50.4501, 30.5234, "Kyiv"),
|
||||
(-33.8688, 151.2093, "Sydney"),
|
||||
(35.6762, 139.6503, "Tokyo"),
|
||||
(8.4844, -13.2299, "Freetown"),
|
||||
(7.8767, -11.1875, "Kenema"),
|
||||
(8.2783, -10.5733, "Kailahun"),
|
||||
(9.6412, -13.5784, "Conakry"),
|
||||
(7.7562, -8.8179, "Nzerekore"),
|
||||
(7.4125, -7.5539, "Man"),
|
||||
(5.3600, -4.0083, "Abidjan"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lat, lon, name", US_POINTS)
|
||||
def test_us_positions(lat, lon, name):
|
||||
assert region_for_position(lat, lon) == "US", name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lat, lon, name", GB_POINTS)
|
||||
def test_gb_positions(lat, lon, name):
|
||||
assert region_for_position(lat, lon) == "GB", name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lat, lon, name", LR_POINTS)
|
||||
def test_lr_positions(lat, lon, name):
|
||||
assert region_for_position(lat, lon) == "LR", name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lat, lon, name", METRIC_POINTS)
|
||||
def test_metric_positions(lat, lon, name):
|
||||
assert region_for_position(lat, lon) == METRIC_REGION, name
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lat, lon", [(0.0, 0.0), (0.0, 0.00001), (91.0, 10.0), (10.0, 181.0)])
|
||||
def test_invalid_positions(lat, lon):
|
||||
assert region_for_position(lat, lon) == UNKNOWN_REGION
|
||||
|
||||
|
||||
def test_region_is_metric():
|
||||
assert not region_is_metric("US")
|
||||
assert not region_is_metric("GB")
|
||||
assert not region_is_metric("LR")
|
||||
assert region_is_metric(METRIC_REGION)
|
||||
assert not region_is_metric(UNKNOWN_REGION)
|
||||
Reference in New Issue
Block a user