mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
Merge pull request #235 from dirwin31:PR-Smaller-1
Core: Companion phone uplink, phone GPS, cellular off switch and usage stats
This commit is contained in:
@@ -12,6 +12,10 @@ openpilot/selfdrive/modeld/models/big_*_tinygrad.pkl -text
|
||||
*.woff2 -text
|
||||
*.wav -text
|
||||
openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js -text
|
||||
openpilot/starpilot/companion/ios/GalaxyBluetooth/Resources/Web/vendor/vue/vue.esm-browser.js -text
|
||||
*.apk -text
|
||||
openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64 -text
|
||||
openpilot/starpilot/soloist/pulseaudio/bin/pulseaudio -text
|
||||
|
||||
openpilot/selfdrive/car/tests/test_models_segs.txt -text
|
||||
openpilot/common/hardware/comma/updater -text
|
||||
|
||||
@@ -18,6 +18,7 @@ a.out
|
||||
.venv-linux-arm64/
|
||||
bin/
|
||||
!openpilot/starpilot/galaxy/bin/
|
||||
!openpilot/starpilot/soloist/pulseaudio/bin/
|
||||
|
||||
# created at launch for comma hardware PYTHONPATH (PC uses editable installs via pyproject.toml)
|
||||
/msgq
|
||||
@@ -44,6 +45,8 @@ bin/
|
||||
*.os-*
|
||||
*.so
|
||||
!openpilot/tools/replay/libmedia_mux.so
|
||||
!openpilot/starpilot/soloist/pulseaudio/lib/*.so
|
||||
!openpilot/starpilot/soloist/pulseaudio/modules/*.so
|
||||
*.a
|
||||
st[0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z][0-9A-Za-z]
|
||||
*.unchunked
|
||||
|
||||
@@ -25,3 +25,7 @@ The vendored `opendbc_repo/opendbc/bluepilot_lateral` angle core, codecs and For
|
||||
## RAV4 Continental ARS510 receiver
|
||||
|
||||
The optional ACC-target and fused-track receivers and DBC preserve firestar5683's Dom integration from `b220a37f4bd119e402225a7aa16a7cd84878d0af` (Sea Turtles, 2026-10-13) and its ARS510 source attribution to ds-sebastian/ars510-radar `fb5b29c2b5999f34afeb6f7ac494a73949d66c67`, copyright 2026 ARS510 radar contributors. The complete MIT permission and warranty notice is retained in THIRD_PARTY_NOTICES.md. Startup/settings ownership is adapted to the current StarPilot architecture; no original firmware binaries or safety permissions are copied.
|
||||
|
||||
## Spotify Bluetooth output
|
||||
|
||||
Spotify's Bluetooth output binds the comma's BlueALSA A2DP source to the selected speaker through a process-local ALSA configuration, leaving the system ALSA default, soundd and the warning speaker untouched. That approach is adapted from firestar5683's RoadScore Bluetooth output (`roadscore/prototype/bluetooth_output.py`, firestar5683/StarPilot `RoadScore` at `249eb88b3a397db847e4a595d7e23b7e85d329e5`). The Soloist player itself is never included: each user downloads it from Spotify, and the PulseAudio server that carries its audio is vendored unmodified from Ubuntu (see THIRD_PARTY_NOTICES.md).
|
||||
|
||||
@@ -112,3 +112,31 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
## PulseAudio server for Spotify
|
||||
|
||||
`openpilot/starpilot/soloist/pulseaudio/` contains unmodified AArch64 binaries from Ubuntu 24.04 packages: PulseAudio 16.1 (LGPL-2.1-or-later), libltdl (LGPL-2.1-or-later), libtdb (LGPL-3.0-or-later), libsoxr (LGPL-2.1-or-later), liborc and speexdsp (BSD-style). The complete copyright and license notices are in that directory's `licenses/`, package hashes in `PACKAGES.sha512`, and the corresponding source is available from Ubuntu's archive for the exact versions listed in its README. The libraries are dynamically linked and may be replaced with `update.sh`.
|
||||
|
||||
## Galaxy Android companion tunnel
|
||||
|
||||
`openpilot/starpilot/galaxy/bin/hev-socks5-tunnel_linux_arm64` is the upstream
|
||||
[hev-socks5-tunnel 2.18.0](https://github.com/heiher/hev-socks5-tunnel/releases/tag/2.18.0)
|
||||
static Linux ARM64 release, under the MIT license. Its notice is retained in
|
||||
`openpilot/starpilot/galaxy/bin/LICENSE.hev-socks5-tunnel`; the adjacent SHA-256
|
||||
file pins the downloaded artifact. The Android companion uses the Kotlin
|
||||
standard library (Apache-2.0) and the Gradle wrapper (Apache-2.0); no third-party
|
||||
UI framework is bundled.
|
||||
|
||||
## Galaxy Bluetooth companion source snapshot
|
||||
|
||||
The Galaxy Bluetooth phone bridge and iOS app from https://github.com/firestar5683/StarPilot
|
||||
(natehuby76, PRs #224/#225) are included under `openpilot/starpilot/galaxy_bluetooth/`
|
||||
and `openpilot/starpilot/companion/ios/`, with their license files (`UPSTREAM-LICENSE`, `openpilot/starpilot/companion/ios/UPSTREAM-GALAXY-LICENSE`).
|
||||
The bridge runs on openpilot's Python with PyCryptodome 3.23.0 (BSD/public-domain
|
||||
components, from opendbc). Its other dependencies are vendored unmodified, as
|
||||
pure-Python sources, in `openpilot/starpilot/galaxy_bluetooth/vendor/`:
|
||||
dbus-next 0.2.3 (MIT), ifaddr 0.2.0 (MIT) and Zeroconf 0.151.3
|
||||
(LGPL-2.1-or-later). Their license texts are in `vendor/licenses/`, and
|
||||
`vendor/VENDOR.json` records each PyPI source file and its SHA-256. Zeroconf is
|
||||
kept as separate, unmodified source files that can be replaced with another
|
||||
compatible version, as the LGPL requires.
|
||||
|
||||
@@ -10,7 +10,12 @@ $Go.import("pfeifer.dev/mapd/cereal/custom");
|
||||
# DO rename the structs
|
||||
# DON'T change the identifier (e.g. @0x81c2f05a394cf4af)
|
||||
|
||||
struct CustomReserved0 @0x81c2f05a394cf4af {
|
||||
struct StarPilotCarControl @0x81c2f05a394cf4af {
|
||||
# The first three fields are pointer slots mapd does not read.
|
||||
hudControl @0 :AnyPointer;
|
||||
steeringLimitInfo @1 :AnyPointer;
|
||||
navigation @2 :AnyPointer;
|
||||
phoneGps @3 :StarPilotCarState.Gps; # sourceMonoTime is comma receive time minus the phone's fix age
|
||||
}
|
||||
|
||||
struct CustomReserved1 @0xaedffd8f31e7b55d {
|
||||
|
||||
@@ -9,69 +9,137 @@ import (
|
||||
math "math"
|
||||
)
|
||||
|
||||
type CustomReserved0 capnp.Struct
|
||||
type StarPilotCarControl capnp.Struct
|
||||
|
||||
// CustomReserved0_TypeID is the unique identifier for the type CustomReserved0.
|
||||
const CustomReserved0_TypeID = 0x81c2f05a394cf4af
|
||||
// StarPilotCarControl_TypeID is the unique identifier for the type StarPilotCarControl.
|
||||
const StarPilotCarControl_TypeID = 0x81c2f05a394cf4af
|
||||
|
||||
func NewCustomReserved0(s *capnp.Segment) (CustomReserved0, error) {
|
||||
st, err := capnp.NewStruct(s, capnp.ObjectSize{DataSize: 0, PointerCount: 0})
|
||||
return CustomReserved0(st), err
|
||||
func NewStarPilotCarControl(s *capnp.Segment) (StarPilotCarControl, error) {
|
||||
st, err := capnp.NewStruct(s, capnp.ObjectSize{DataSize: 0, PointerCount: 4})
|
||||
return StarPilotCarControl(st), err
|
||||
}
|
||||
|
||||
func NewRootCustomReserved0(s *capnp.Segment) (CustomReserved0, error) {
|
||||
st, err := capnp.NewRootStruct(s, capnp.ObjectSize{DataSize: 0, PointerCount: 0})
|
||||
return CustomReserved0(st), err
|
||||
func NewRootStarPilotCarControl(s *capnp.Segment) (StarPilotCarControl, error) {
|
||||
st, err := capnp.NewRootStruct(s, capnp.ObjectSize{DataSize: 0, PointerCount: 4})
|
||||
return StarPilotCarControl(st), err
|
||||
}
|
||||
|
||||
func ReadRootCustomReserved0(msg *capnp.Message) (CustomReserved0, error) {
|
||||
func ReadRootStarPilotCarControl(msg *capnp.Message) (StarPilotCarControl, error) {
|
||||
root, err := msg.Root()
|
||||
return CustomReserved0(root.Struct()), err
|
||||
return StarPilotCarControl(root.Struct()), err
|
||||
}
|
||||
|
||||
func (s CustomReserved0) String() string {
|
||||
func (s StarPilotCarControl) String() string {
|
||||
str, _ := text.Marshal(0x81c2f05a394cf4af, capnp.Struct(s))
|
||||
return str
|
||||
}
|
||||
|
||||
func (s CustomReserved0) EncodeAsPtr(seg *capnp.Segment) capnp.Ptr {
|
||||
func (s StarPilotCarControl) EncodeAsPtr(seg *capnp.Segment) capnp.Ptr {
|
||||
return capnp.Struct(s).EncodeAsPtr(seg)
|
||||
}
|
||||
|
||||
func (CustomReserved0) DecodeFromPtr(p capnp.Ptr) CustomReserved0 {
|
||||
return CustomReserved0(capnp.Struct{}.DecodeFromPtr(p))
|
||||
func (StarPilotCarControl) DecodeFromPtr(p capnp.Ptr) StarPilotCarControl {
|
||||
return StarPilotCarControl(capnp.Struct{}.DecodeFromPtr(p))
|
||||
}
|
||||
|
||||
func (s CustomReserved0) ToPtr() capnp.Ptr {
|
||||
func (s StarPilotCarControl) ToPtr() capnp.Ptr {
|
||||
return capnp.Struct(s).ToPtr()
|
||||
}
|
||||
func (s CustomReserved0) IsValid() bool {
|
||||
func (s StarPilotCarControl) IsValid() bool {
|
||||
return capnp.Struct(s).IsValid()
|
||||
}
|
||||
|
||||
func (s CustomReserved0) Message() *capnp.Message {
|
||||
func (s StarPilotCarControl) Message() *capnp.Message {
|
||||
return capnp.Struct(s).Message()
|
||||
}
|
||||
|
||||
func (s CustomReserved0) Segment() *capnp.Segment {
|
||||
func (s StarPilotCarControl) Segment() *capnp.Segment {
|
||||
return capnp.Struct(s).Segment()
|
||||
}
|
||||
|
||||
// CustomReserved0_List is a list of CustomReserved0.
|
||||
type CustomReserved0_List = capnp.StructList[CustomReserved0]
|
||||
|
||||
// NewCustomReserved0 creates a new list of CustomReserved0.
|
||||
func NewCustomReserved0_List(s *capnp.Segment, sz int32) (CustomReserved0_List, error) {
|
||||
l, err := capnp.NewCompositeList(s, capnp.ObjectSize{DataSize: 0, PointerCount: 0}, sz)
|
||||
return capnp.StructList[CustomReserved0](l), err
|
||||
func (s StarPilotCarControl) HudControl() (capnp.Ptr, error) {
|
||||
return capnp.Struct(s).Ptr(0)
|
||||
}
|
||||
|
||||
// CustomReserved0_Future is a wrapper for a CustomReserved0 promised by a client call.
|
||||
type CustomReserved0_Future struct{ *capnp.Future }
|
||||
func (s StarPilotCarControl) HasHudControl() bool {
|
||||
return capnp.Struct(s).HasPtr(0)
|
||||
}
|
||||
|
||||
func (f CustomReserved0_Future) Struct() (CustomReserved0, error) {
|
||||
func (s StarPilotCarControl) SetHudControl(v capnp.Ptr) error {
|
||||
return capnp.Struct(s).SetPtr(0, v)
|
||||
}
|
||||
func (s StarPilotCarControl) SteeringLimitInfo() (capnp.Ptr, error) {
|
||||
return capnp.Struct(s).Ptr(1)
|
||||
}
|
||||
|
||||
func (s StarPilotCarControl) HasSteeringLimitInfo() bool {
|
||||
return capnp.Struct(s).HasPtr(1)
|
||||
}
|
||||
|
||||
func (s StarPilotCarControl) SetSteeringLimitInfo(v capnp.Ptr) error {
|
||||
return capnp.Struct(s).SetPtr(1, v)
|
||||
}
|
||||
func (s StarPilotCarControl) Navigation() (capnp.Ptr, error) {
|
||||
return capnp.Struct(s).Ptr(2)
|
||||
}
|
||||
|
||||
func (s StarPilotCarControl) HasNavigation() bool {
|
||||
return capnp.Struct(s).HasPtr(2)
|
||||
}
|
||||
|
||||
func (s StarPilotCarControl) SetNavigation(v capnp.Ptr) error {
|
||||
return capnp.Struct(s).SetPtr(2, v)
|
||||
}
|
||||
func (s StarPilotCarControl) PhoneGps() (StarPilotCarState_Gps, error) {
|
||||
p, err := capnp.Struct(s).Ptr(3)
|
||||
return StarPilotCarState_Gps(p.Struct()), err
|
||||
}
|
||||
|
||||
func (s StarPilotCarControl) HasPhoneGps() bool {
|
||||
return capnp.Struct(s).HasPtr(3)
|
||||
}
|
||||
|
||||
func (s StarPilotCarControl) SetPhoneGps(v StarPilotCarState_Gps) error {
|
||||
return capnp.Struct(s).SetPtr(3, capnp.Struct(v).ToPtr())
|
||||
}
|
||||
|
||||
// NewPhoneGps sets the phoneGps field to a newly
|
||||
// allocated StarPilotCarState_Gps struct, preferring placement in s's segment.
|
||||
func (s StarPilotCarControl) NewPhoneGps() (StarPilotCarState_Gps, error) {
|
||||
ss, err := NewStarPilotCarState_Gps(capnp.Struct(s).Segment())
|
||||
if err != nil {
|
||||
return StarPilotCarState_Gps{}, err
|
||||
}
|
||||
err = capnp.Struct(s).SetPtr(3, capnp.Struct(ss).ToPtr())
|
||||
return ss, err
|
||||
}
|
||||
|
||||
// StarPilotCarControl_List is a list of StarPilotCarControl.
|
||||
type StarPilotCarControl_List = capnp.StructList[StarPilotCarControl]
|
||||
|
||||
// NewStarPilotCarControl creates a new list of StarPilotCarControl.
|
||||
func NewStarPilotCarControl_List(s *capnp.Segment, sz int32) (StarPilotCarControl_List, error) {
|
||||
l, err := capnp.NewCompositeList(s, capnp.ObjectSize{DataSize: 0, PointerCount: 4}, sz)
|
||||
return capnp.StructList[StarPilotCarControl](l), err
|
||||
}
|
||||
|
||||
// StarPilotCarControl_Future is a wrapper for a StarPilotCarControl promised by a client call.
|
||||
type StarPilotCarControl_Future struct{ *capnp.Future }
|
||||
|
||||
func (f StarPilotCarControl_Future) Struct() (StarPilotCarControl, error) {
|
||||
p, err := f.Future.Ptr()
|
||||
return CustomReserved0(p.Struct()), err
|
||||
return StarPilotCarControl(p.Struct()), err
|
||||
}
|
||||
func (p StarPilotCarControl_Future) HudControl() *capnp.Future {
|
||||
return p.Future.Field(0, nil)
|
||||
}
|
||||
func (p StarPilotCarControl_Future) SteeringLimitInfo() *capnp.Future {
|
||||
return p.Future.Field(1, nil)
|
||||
}
|
||||
func (p StarPilotCarControl_Future) Navigation() *capnp.Future {
|
||||
return p.Future.Field(2, nil)
|
||||
}
|
||||
func (p StarPilotCarControl_Future) PhoneGps() StarPilotCarState_Gps_Future {
|
||||
return StarPilotCarState_Gps_Future{Future: p.Future.Field(3, nil)}
|
||||
}
|
||||
|
||||
type CustomReserved1 capnp.Struct
|
||||
@@ -3327,306 +3395,312 @@ func NewMapdOut_GpsSource_List(s *capnp.Segment, sz int32) (MapdOut_GpsSource_Li
|
||||
return capnp.NewEnumList[MapdOut_GpsSource](s, sz)
|
||||
}
|
||||
|
||||
const schema_b526ba661d550a59 = "x\xda\x94z\x0d\x90T\xd5\x95\xff9\xef\xf5L\xcf'" +
|
||||
"M\xf3\x1a\x01\x01\xdb!\x928\xc41\xc0\xc8?\x0eh" +
|
||||
"\x86\xa1g@\xa8\xeea\xde<>\x1c\x94\xaa\xdc\xe9w" +
|
||||
"\xa7\xe7\xc1\x9b\xf7\x9a\xf7^\xcfLSX \x81*\xf4" +
|
||||
"\x8f\x15\xcdj\x82)\xd9\xf8\x81\xb5\x9a\x95\x84du\x8b" +
|
||||
"XX\x0b\x14\xa9B\x17\xaa4\xa5)c\xcc\xfaQ\xd9" +
|
||||
"\xd2d\xb5\x8cY\xad5\xd9X\xbdu\xee\xed\xaf\xf9\x88" +
|
||||
"8E\xd5t\xf7\xef\x9cw\xee\xb9\xe7\x9e{\xee\xf9\xdd" +
|
||||
"\xc7r6\x7fmhE\xf3?\x8d\x81bdkjj" +
|
||||
"\x0b\xd1\xdd\x03\xef:\xc1O\x0e@t!\x16\x06\x1a\xb6" +
|
||||
".\x1ez\xfe\xab\xcfA(\x0c\xd0\xfe\x8b5;P\xbb" +
|
||||
"\xbc&\x0cj\xe1\xa7\x9f$;v\xfc\xe9\xfc\xdd\xd3h" +
|
||||
"=MZ/\x08\xad\xdb\x13\xe7\xbf;\xf8\xd5\x1f\x1d\x86" +
|
||||
"\xe8B\xa5\xa2\x05\xd8\xfe\xf0\x9aM\xa8\x9d\\#\xd5\xdd" +
|
||||
"\x10`\xa1\xed\xc5\xdc\xed?^p\xdf\x03\xa0\xb7a}" +
|
||||
"\xe1\xbc\xf3o\x8d\xdb.\xdc\xf9\xdfP\x83\xa4\xb3t\xed" +
|
||||
"\xbd\xa8\xdd\xba\x96\xbev\xac}K\x01,\xfc\xe3\xd7?" +
|
||||
"\xcaY{w|\x1f\xa2_Q\x0a\x07?\xfd\xf4+\xed" +
|
||||
"\xff\xf5\xf1\x09\xb2\xbc'q\x10\xb5\xc3\x890\x80vw" +
|
||||
"\xe2\x07\x80\x859\xcfb&s\xf1\xf2#\xd3x\xfaJ" +
|
||||
"b\x10\xb5\xffL\x90\xa7m\xbf\x8d\xab\xdd\xe1\xe1\xc7\xa6" +
|
||||
"\xd1:\x97\xd8\x81\xdakB\xcbV\xdb\xd7~6X\xfb" +
|
||||
"\x04\xe8\x0b\xb1J\xadF\xe8\xfd\x8c\xf4~\x99\x90\x8fl" +
|
||||
"G\xc0\x8a_\xfaB\x9cU\xa5^K:\xabz\xaeF" +
|
||||
"\xad\xa7\xe7k\x00\xed\xacg,\x0cX\xe8N\x9c}\xe9" +
|
||||
"\xe4[O<9%Z\x7fH\x1dD\xed\xf3\x14\xcd\xe9" +
|
||||
"/\xa9o\x02\x16\xb6\x9e\xd0\x7fs\xc3\xe8\xe5'\xa7\xf1" +
|
||||
"\xf6\xf3\xd4\x0e\xd4\xa2\xbd\xe4\xedO~\x9b~\xe8\xff?" +
|
||||
"\xf5\xd1?O\xb1\xf7aju\xc5\xdef\xc0\xc2\xce\xe7" +
|
||||
"\xde[\xf1\xdd\xcf\xdf\xfa\xc94\xf6\x9a{w\xa0\xd6\"" +
|
||||
"\xecu\xbe\xf5\xc7\xbbR\xd7n?5\xed\xa8\x83\xa5Q" +
|
||||
"/b\xb2\xc5J;\xa7\xa7\xd1\xfa\x90|C\xa15|" +
|
||||
"\xe6\x1f\x1e4\xd6t\xbf0\x8d\xd6\xef\xc8\xd6\xc7)\xd2" +
|
||||
":\xa4\xde\xf46\xbf\xe5\xba\xf3\xd3h]&\xadw\x84" +
|
||||
"\xd6G\xff\xf2\xadWn\xbeu\xd7\xc5\xc9\xab\xa2\x90\xde" +
|
||||
"\x0b\xa99\xa8]\xa6\xb9\xb6\xbf\x98\x8a\xd3\xaa\xac\x1e\xd8" +
|
||||
"\x9e\xb5_\xfb\xd1\xbfO7t/\x0d-\x1c|\xf9\xe0" +
|
||||
"\xa3\x99\xff}\xe3\xfb\x97\xa7\x1b\x9a\xb4\xde\x11Z\x8b/" +
|
||||
"\xa4\xdf|38\xf8\xca\x94\x10\xbf\xd0\xbb\x0e\xb5\xcb\xbd" +
|
||||
"b\xd8^\x91\x0c\x1d\x1dg\x1e\xbbt\xef\xff\xfc\x9a\xbc" +
|
||||
"T'o\xac\xcd\x9bP\xbb\xbc\x99\x16\xe4\xc5\xcd\xef\x01" +
|
||||
"\x16V%\xbb\xbfsl\xfb\xf7\xdf\x98f\xf4\x93};" +
|
||||
"P;\xd7G\xa3w|\xe7\xa5\x1f\xfe\xa0\xe6\x07\xffA" +
|
||||
"&\x95Ij\xc7\xfb\xd6\xa1v\xb2\x8fL>\xdd7\x06" +
|
||||
"X\xb8\xfe\x95_\x7ftC\xcf\xb3\x7f\x9e\xb2c\x9a\xf5" +
|
||||
"]\xa8\xb5\xe8\xa4\xb9X\xcf\x00V6\xdfD\xb3r+" +
|
||||
"n$\xed\x9d\xfa<\x80v\xae\x87)m\x17\x1c?\xbe" +
|
||||
"\xb1\xfe\x83\xab>\x99\xc6\xd7\x91\x81\x1d\xa8\xdd=@\xbe" +
|
||||
">\x11\xca~\xbe\xe6\xd0}\x7f\x99Fk'i\xed\x11" +
|
||||
"Z\xcb\xde_\xda\xfb\xde\x9d\xdf\xfa\xeb\x94x\xa6\x06\x06" +
|
||||
"Qc\x03\xe4\xe4\xce\x81\xfd\x80\x85\xc7\xfd\xbe\x8b/\xed" +
|
||||
"|\xe2\xaf\x93\x9d\x14\x8b~|\xe0 j?\x1b\x10\xe1" +
|
||||
"\x1a\x10\xd1\x8f\xfe\xeb\xd8\x91\x0f\xbb\x06\xff6\xcd\xf0m" +
|
||||
"w\x0c\xa2\xd6u\x07\x0d\xbf\xff\xd8\xa9\xf7\x8ccG\x0a" +
|
||||
"\x933I\xcc|\xf1\x1d\xcf\xa3\xb6\xea\x0era\xc5\x1d" +
|
||||
"?\x85\xc2\xdf\xfd\xf7P!\xcd=\xce\xeco\xa4C9" +
|
||||
"?pG\xbe\x91\x16\x1f7\xa6Y\xd6\xc9\xaeN\x88\x1f" +
|
||||
"\xfd\xdc\xe7\xde(W\xcd\x9b\xfa\x10g\xa2\xbf\xfcJ\xfa" +
|
||||
")\x9657:\xd9\\\xb0%\x9f\xe5\x00}\x88\xfa\x19" +
|
||||
"T\x00\xb46e\x13\x00\xce\xd2Z\x95\x9f\x03`Dk" +
|
||||
"U\x1e\x07\xc0\xd9Z\xab\xf2c\x00\x8cj\xad\xcay\x00" +
|
||||
"\x9c\xa3\xb5*o\x00`\xa3\xd6\xa6\x0c\x02\xa0\xa6\xb5*" +
|
||||
"/\x01`Lk\x13\x9f\xa8\xadP\xf6\x02\xa0\xa2\xb5)" +
|
||||
"\xbb\x00p\xae\xd6*~_\xa5-U\xfe\x08\x80\xf3\xb4" +
|
||||
"V\xe5W\x008_kS\xde\x05\xc0\x05\xda\x0a\xf1\xfb" +
|
||||
"jm\x95\xf2C\x00\\\xa8\xad\x12\xe3.\xd2V\x09{" +
|
||||
"\x8b\xb5\x0e\xf1\xfb\x1a\xadC\xf8\xa5\x16\x7f\x87\xb4\x0e\xe1" +
|
||||
"O\\\xeb\x10v\xaf\xd5n\x15\xf6Z\xda\xbb\x94\xd5\x08" +
|
||||
"\x805\xdaF\xe5y\x00\xac\xd56\x0a\x07\x9a\xb5\x1ee" +
|
||||
"\x07\x006i]\xc2\xb1%\xda\xad\xe2\xc1\xafh]\xc2" +
|
||||
"\xf0u\xc5\xcf\xa5\xed]\xca\x1c2\xd0\xa0mT\xee\x05" +
|
||||
"\xc0\xafj\x1b\x95?\x03\xe0\xd7\xdaS\xca\x12\x12\\\xaf" +
|
||||
"m\x15!hm\x1fP\x14\x02\x96\xb5\xef\x94_\xbe\xae" +
|
||||
"1\xe5{\x00x\x83\xc6\xc4\xa3m\x1a\x13\xc1\x0b\x17\x7f" +
|
||||
"\xd7iL9\x08\x80\xf5\xdaN\xf1y\xa36@>\x14" +
|
||||
"Lw\xcc\xb1]f\x02@\xc1\xe7\xc1\x16\xe6e8\x06" +
|
||||
"I\x16p\x8f\xd9\xf1\xaet\x9a\xdb\x84\x1bY\xceML" +
|
||||
"Z#V\xb0yh(\xec\xf3`\x12\x9ap\x9dH\xe0" +
|
||||
"\xb9B9\xc5\xb2\x89\\\x8d7\xca\x85<\xe1:$\x00" +
|
||||
"\x9f\x07\xdb,\xdfr\x9dDn\x82H\x95\x0f%\xddL" +
|
||||
"\x92CxT\x8e'4\x15\xa9*|\"\x97\xba\xa4\x8f" +
|
||||
"\xd5\xb2\x94\xe5H\xf16\x80\x82\xc7i&\x06\x87\xce " +
|
||||
"\xb0\x9c\x8c_\xf0\xd9(7x\x10@D\xfe\xe4A\x8f" +
|
||||
"\xc3\x06m\xe8\x94\xc3O6\xb6\xd5\xe7B\xce\x8dHI" +
|
||||
",\xa6\xa2L\x90e9G\xb3<{E\xcc\xbeJ\x1a" +
|
||||
".>y\x9bk\x9bI\x85\xf9\x81\xc1\xb9#TI\x13" +
|
||||
"\x83\xaa(\x0bt\x13W\xbd\xdd\x93\xc1\xaet\xb8\x18x" +
|
||||
"\x81*\x12\xddb\x8d\xf0\xcdCC>\x0fd \xba\xf9" +
|
||||
"\x10\xcb\xa1\x1d$\x99\xc3\xb7[a3\x18.\xbb\x8c\xa5" +
|
||||
"\xb8\xc5E\xe0\x0a\x14\x18R\xc7\x9c\x1dPD\xac0\x05" +
|
||||
"\x84\xd0~\x9evkFF\xb8crSH\x9c\x8cO" +
|
||||
"ke\xd8\xeeX\xb7;\xe6\xacw\xbd^>.\x1dH" +
|
||||
"Fh\xb2\x95\xb9o\xcdN\x90Z\xe1\xa2\x94\xe6n\xa8" +
|
||||
"\xa59\x07\xdb\x87-\x9b'\x86\x99\x93\xb1\x9c\x8c\xc1;" +
|
||||
"\xa5\xba\x18\xbd\x8f{>Z~\xc0\x9d\x80\x04r\xd9\xd2" +
|
||||
"\xccIs\xbb\xdb\x85N\x99\x9b\xc5\xf4\xd8\xe4\x83\xea:" +
|
||||
"\xc5\x1f\x86\x0b\x91\x9c\x97\xe6bQ\xc7\x03\xee)\x0e\xb3" +
|
||||
"\xcba\x9e\x90\x8eB\x8c%q<9a\x0e2{\xfb" +
|
||||
"<+\xeezV\x90/\xe3\xaa4CN\xf3~\xbe'" +
|
||||
"gy\xdc\xa7\xdd\x90\xc5\xa0\xc0\xe830\xb2X\x1aM" +
|
||||
".G\x9f\xc7}_\xd9\xc0\xfc-nWQc\xc2x" +
|
||||
"]\xe6\xae\x9c\xafR\xf8\xe5jVkUb'@%" +
|
||||
"\xa8L\x85V\xddUsAy\x88z1\xc4\xe6Q\xee" +
|
||||
"y\x96\xc9+\x8a\xb4j\x09\xd71\xad\xc0r'\x07\xa3" +
|
||||
"\xb4\x03\x8daf\xbac\x09\xe6\x19\x01\x0bPDO@" +
|
||||
"8\x96rMno[)\xa7R\xc46d\xfd\xa4\x9b" +
|
||||
"f\x112H\xf0&\xdfu\xfa\x90\x05\xc3\xebm\x97\x15" +
|
||||
"g-\xb0Z\x16\x0co\xe1\xe3\xc2\x05\x01\xb0`x\x9d" +
|
||||
"[=d\xd1\x14Y\xa2\xf5\x88x\x0e\xb3\xcb\x87E\xcd" +
|
||||
"4\x87\x85\x110\xaf\xcf\xb2\xdd\xa0\xe8,\xbfqC\xd6" +
|
||||
"\x97\x87\xc6Mj\x08 \x84t\xe0\xe2^\x00\xe3NT" +
|
||||
"\xd1\x18F\x05\xa3\x881$\x9c\xe3&\x00\xc3$<K" +
|
||||
"\xb8\xa2\xc4\xc4A3\x82\xfd\x00\x86M\xf88\xe1\xaa\x1a" +
|
||||
"C\x15@\xcb\x09\xfd\x80\xf0\x03\x84\x87\xeab\x18\x02\xd0" +
|
||||
"\xee\xc2\x95\x00\xc68\xe1\x87\x08\xaf\xa9\x8fa\x0d\xf5\xef" +
|
||||
"\xb8\x03\xc08@\xf8Q\xc2k\x1bbX\x0b\xa0\xdd\x83" +
|
||||
"?\x040\x8e\x12~\x8c\xf0pc\x8c\x0eh\xed!\xbc" +
|
||||
"\x17\xc08F\xf8\x09\xc2\xeb\x9abX\x07\xa0=&\xf4" +
|
||||
"O\x10~\x8a\xf0\xfa\xe6\x18\xd6\x03h'\xd1\x030\x9e" +
|
||||
"!\xfc4\xe1\x0d\xe1\x186\x00h\xcf\xe1\xe3\x00\xc6i" +
|
||||
"\xc2/\xa0\x82-\x8dJ\x0c\x1b\x01\xb4s\xb8\x1a\xc08" +
|
||||
"C\xf8ET\x10\x9bb\xd8\x04\xa0\xfd\x12\x97\x01\x18g" +
|
||||
"\x09~\x13\x15,\xf8.m\x99\x94\x0b\x9d\x8eKi\x85" +
|
||||
"\xf5\xa0`=`\xc1f\x81\x15\xe4L\x0et\xbc\x82\x82" +
|
||||
"\x8d\x84\xb9N\x86@@^\xc6\x98=E/\xee\x8bm" +
|
||||
"\xd4\x00\x0a6\x00\x16\x069\xf3,'\xd3\x0d*\xcf\x94" +
|
||||
"\xc1a\xd7\xb3\xf6\xbaN\x80\xcc\xeeJ\xa7s\x1eS\xd3" +
|
||||
"\xf9\xb2p\x94{\x81\x95f6JQ:O\x07\xe0D" +
|
||||
"k]X\x14uW[\xf5e\xa5LC\\\xc8\xcax" +
|
||||
"\xce\xb1\xc6\xc5\xec\xfc\x80\x8ddS\x96\x1d\xb6-\x1fk" +
|
||||
"@\xc1\x1a\xc0\xcea\xe6\xaf\xb7\xc6\x11AA\x04\x8c\x8c" +
|
||||
"\xf6\xf6t\xe3,\xc0>\x15\x85\x81Y\x80_\x98\x96\xd4" +
|
||||
"\xc3l\xce\x057\x1al$ksJ\xcc\x9c/\x92r" +
|
||||
">%X\xb4k\x1d5\"\xd1\x8e\x95\xd4\x87DW\xd0" +
|
||||
"\xa9\xafF\xdb\x08\x0cE\x97R/P\x13m\xd9\x05\xb0" +
|
||||
"?\xe7\xecv\xdc1'\xee\xb8\x1b\xb2~\xc1q\x13\xee" +
|
||||
"(\xf7\x18\xa8\x19\xbe\xdfqS,H\x0f\x17F\xe8/" +
|
||||
"7{\xa1\xd3\x95%\xa4\x08$AV\xe1/\xdd\x99\x85" +
|
||||
"\xcd\x15+g\xd8\xcau|\x99V\x8e\xf61\x9d\x1a\x9b" +
|
||||
"\xd5\\ \"P\xda\x96\xd1\x87\xef\x05\xd0\x1fQQ\x7f" +
|
||||
"\xaa\xb2'\xa3On\x02\xd0O\xa8\xa8\x9f\xaal\xc8\xe8" +
|
||||
"\xc9e\x00\xfaS*\xea\x17*\xbb1z\x8e4\xcf\xaa" +
|
||||
"\xa8_R\x10Cb'F_<\x08\xa0_TQ\x7f" +
|
||||
"\x95\xb6!\x8am\x18}e\x10@\x7fYE\xfdM\x05" +
|
||||
"\xcb\x9d\x0c\xf6yn\x86\xea$\xb5\x90\x95f\x1c\x10g" +
|
||||
"S\xd2\x94\x8e8\xea\xc7@\xc1&\xc0H\x96\x05\xc3\xa5" +
|
||||
"\x1c\x98]aC\x80\"\x1b\xb2\xae/**\x08{e" +
|
||||
"bS\xb4g\xbbn\xb6\x9f\x05\x1c\xbbh\x013\xbc\x92" +
|
||||
"\xbb%\x09\x84S\x96SFKAU\xff^n\xa9\xb9" +
|
||||
"@\xaf\xc3\xeak\x84\xfa]U\x0c\xa9\xbe\xbfPJ=" +
|
||||
"\x88P\xf2\x156d}\x83\xf65 \xd7\x8f\x94+\xe3" +
|
||||
"\xd3\xea:\xaa,*U\x16\xb5\xaa2\x9eT\xa9R<" +
|
||||
"E\xf8\xb3jUe\xfc\x99J\x15\xf0\x14\xe1gT\x05" +
|
||||
"\xb1X\x18\x7f\xa1R\xa1;M\xf0\x05R\x0f\xa1,\x8c" +
|
||||
"\xe7T*\xbcg\x09\xbfDx\x8d\"\x0b\xe3\x8b\xea\xf3" +
|
||||
"\x00\xc6%\xc2_'\xbcV\x95\x85\xf151\xec\xcb\x84" +
|
||||
"\xbfIx8$\x0b\xe3o\x84\xfd\xd7\x09\xff=\xe1u" +
|
||||
"\xaa,\x8c\xef\xa8T\x18\x7fO\xf8\x9f\x08\xaf\x0f\xc9\xc2" +
|
||||
"\xf8\xa1J\x85\xf1\x03\xc2?#\xbc\xa1F\x16\xc6O\xd5" +
|
||||
"\xef\x01\x18\x9f\x11\x1e\x0a)\x18m\xac\x95\x85\x11C\xbf" +
|
||||
"\x020\xeaB*\x1a1\xc2\x9b\xde\x96\x951\x1a\"\x7f" +
|
||||
"\x9a\x08\x9fOx\xf3\xe2\x186\x03hsCT\xf0g" +
|
||||
"\x13\xbe\x88\xf0Y\xef\xc4p\x16\x80\xb6 D~\xce'" +
|
||||
"\xfc:\xc2#u1\x8c\x00h-\xa1\x97\x00\x8c\xeb\x09" +
|
||||
"\xbf\x89\xf0\xd9\xf51\x9cM4,D\xf1YN\xf8-" +
|
||||
"\x84G\x1bb\x18\x05\xd0:B4\xaf[\x08\xbf\x8d\xf0" +
|
||||
"9\x91\x18\xce\x01\xd0zB\x83\x00F7\xe1}\x84k" +
|
||||
"\x8d1\xd4\x00\xb4T\xe8\xe7\x00F\x1f\xe1w\x12\x1ek" +
|
||||
"\x8aa\x0c@\x1b\x08\xd1\x81r'\xe1\xc3\x84\xcfm\x8e" +
|
||||
"\xe1\\:\xf8B\x14\x1f\x93\xf0,\xe1W-\x8a\xe1U" +
|
||||
"t\xf0\x09\xfd,\xe1\xfb\x08\x9f\xf7n\x0c\xe7\x01hy" +
|
||||
"\xe1\xcf>\xc2\x8f\x10>\x7fq\x0c\xe7\x03h\x87C\xbb" +
|
||||
"\x00\x8cC\x84\xdfO\xf8\x82\xba\x18.\x00\xd0\xee\x13\xf1" +
|
||||
"9B\xf8\x83\x84_]\x13\xc3\xab\x01\xb4\x07\x84\x9f\x0f" +
|
||||
"\x12\xfe(\xe1\x0b\xaf\x89\xe1B\x00\xed\xb8\xf0\xe7\x11\xc2" +
|
||||
"\x9f\"|Q<\x86\x8b\x00\xb4'E<O\x10~\x8a" +
|
||||
"\xf0\xc5\xd7\xc5p1\xa5g\x88\x0e\xe8g\x08?M\xf8" +
|
||||
"5\x0d1\xbc\x86\x0e>\x11\x9fg\x09?Kx\xbc1" +
|
||||
"\x86q\x00\xed\x051\xaf\xb3\x84_\"\xfc\xda\xa6\x18^" +
|
||||
"Ky(\xf0K\x84\xbfNxKs\x0c[(\x0fC" +
|
||||
"\x07\x01\x8cW\x09\x7f;\xa4\xe0\xfe1\x96\xefe#\xbc" +
|
||||
"T\x0a:\xc7X\xbe\x9f\x0f\x95~\x16<\x97\x99$\xaf" +
|
||||
"\xaa\x16\xf2\xd8IZ#\xa0ZAy[;\xc5&\x18" +
|
||||
":e\xcb5E\x80\x12\xef\xb6:\xfd\x80\xda\xdb\x92B" +
|
||||
"\xe70\xdb\xcb<\xb3l\x9d\xf4oc{\x19\xa8\xd3\x80" +
|
||||
"\xe8\x99\xdd\x16=\xafV\x0c\x14\x989j\xf9\xae\x97\x87" +
|
||||
"\xb81\xe1$\xa6\x87\xba\xccQ\x0bI(]\x98\"S" +
|
||||
"J\xb2\xa2\xdd4V\x1cs\x1d\xbe\x9d\xe5K\x07e\xdc" +
|
||||
"f\x0e\xf7\xb1\x16\x14\xac\x05,\x04\x96\xcd\x93D\x1bU" +
|
||||
"n\x96T\xca\x91Q\xac\xc0\xc8e2\xdc\x0f\xb8)\x8c" +
|
||||
"W\x9d\xe8~Q\x00\x9d\xe6Dw\xb9\x1fX#TH" +
|
||||
"\xcd~\x97\x99\xdb-S\x0d\x86\xcbBZ\x07\xeab!" +
|
||||
"\xcc\xc7\x03\x8cT\xee\x0f\x011\x02X0\xadbT\xd7" +
|
||||
"{\xee\xc8v\x96O\xc4\xb9CM\x7f\xb9\xc7(\x12O" +
|
||||
",1\xcf*\x8fF\x88/y\xa3|r\xfc\xc6X\xde" +
|
||||
"\xe06O#\x15\x7fyc\x81\x91\xca5Pq\xe4\xd2" +
|
||||
"\x9c\xd1\x92\x9d{P\x1d\x90a+3<\xc6\xf2\x09\x88" +
|
||||
"\xd8\xcc\xf71R\xb9\x94\x93O\xc7\xc7X~\xa3Yj" +
|
||||
"M\x0a\xe9b\xf3>\x89\xabT2O\x14\xfem\x1c\xe2" +
|
||||
"\x1eM\x07\xc3\xa0`\xb8\x18\x1d\xeaD@\xcd\xd1 \xe5" +
|
||||
"\x93\xa3\xe8b\xa6r@`\xa4r\x94T\xa4)\xd7q" +
|
||||
"\xb7@\xb8\xba!L\xbb#\xd9\\\xc0M\x142\x8bv" +
|
||||
"@Y&\xbb\xc8\x0d\x1c\x1d\xee1y2\x96eY\xcf" +
|
||||
"5si\xee\xa1\xc1}\xf2\xb1\"\xf9\xc2^\xbfLU" +
|
||||
"$\xbd\x15\xd1\xa6\x96\xa2N4\x09\xd1\xd5\xa2\xa9\xaa_" +
|
||||
"\x07@\xbb'\xb0\xd2\xfb\xb3\xdcKs'\x98IO\xb3" +
|
||||
"\xaa\xba\xa7\x99\xee\xf8\xa5\xbcKt\xbaN\xc0\xc7E?" +
|
||||
"\xd3$\x06_,;\xba\xb9\xcbDG\xd7\xbc\x0e`\xff" +
|
||||
"\x90\xc7\xf9\x18\xcbG\xd2V\x90/ur3\xf1d\xc5" +
|
||||
"\x8c\xba\xb1\xb0\xb9b\xa67q\xdf\x9c\xe9\x00\xabf\xfa" +
|
||||
"\xc0\x8a+\x05S\xdc\xf5\xa1Cq\x8c\x95\xfb\xc2\xbb\xa8" +
|
||||
"\xdb\x1bWQ?T\xd5\x17\xde\xbd\x12@\xdf\xa7\xa2~" +
|
||||
"DA,\xb6\x85\x87\x97\x00\xe8\x07T\xd4\x8fR[8" +
|
||||
"[\xb6\x85\xf7\xd0\xd3\x87T\xd4\xef\xaf4\"\xd1\xfb\xa8" +
|
||||
"W<\xaa\xa2~L\xc1H\x90\xcfR\x8a\x97\xdf\xed\x14" +
|
||||
"w\xd9\x10\xb1\xd5\xd2\xbe\x0e\xfb\x81W\xee\xfc\x06]\xd7" +
|
||||
".\xef\xd6]E\xe2Z]\xecg\x12\x93\xe53\x0db" +
|
||||
"\xfb\x95\x1e\xb8\xadX?\xa8z\x88\xfdp\xb3\x88\xcec" +
|
||||
"2%\x1f\xde$R\xf2\xa1]\x82d<\xb0R\x90\x8c" +
|
||||
"{\xfa\x05\xc98L*\xb5\xd1\xbb\x06\x010\x1c\xcd\x13" +
|
||||
"X\x17\xcdy\xb4S\xa3{\xe8\xb9\x86\xe8\x08=\xd7\x18" +
|
||||
"\xb5\xe8\xa3)\xcaI\xb39\xca\xaa\xc8Ia\xc4\x0d\\" +
|
||||
"o\x8c\xe5\x01\xa0\xf2=\x92\xb4\x9c\xdd\xf1\xc0\xcb9\xbb" +
|
||||
"\x0b\xe2o\xd2r\x00w\xef\xcfz\xd6\x08\xf3\xf2\x85\xe2" +
|
||||
"g\x12\xc2\x96\xb3\xbb\xe0s*j\xcc\x03\xccW\xbe\xc7" +
|
||||
"\xf3d\xa3\x10\x10\xf1c\x9e0_\xfe.\xcc\x17rN" +
|
||||
"\x9a\xe6lAd\xc8\xe2f\xc1\xe3\xbeer*\xff\x16" +
|
||||
"\xb3\x0b\xb65j9\x19\xea\x7f=\xce\x83+\xd2\x14Z" +
|
||||
"\xd2>\xd7r\x02yy0\xbb\x9c\x8d\x8cR\xe7\xdb*" +
|
||||
"\xeavU6Z\xfd\x00\xfa\xb0\x8az@\xcdqH\xa6" +
|
||||
"\xe3\x1e\x02\xb3*\xea\xfb(\x1dkd:\xe6\xf7V\x92" +
|
||||
"\xf9K\x13\xe8t\xce\x1beA\xce\x83\xcaA[\x08\xe4" +
|
||||
"\x05&\x87N\xdb\xa5\x922\x85+|\x89\x1d\xdf\xfee" +
|
||||
"\x08[_\x89\xc9\x88\xd2Z\x8eC+\xc5\xe1z\x15\xf5" +
|
||||
"\x9b\xaa\xe2\xb0\x82\xa6\xbc\\E\xfd\x96\x99\xcc\xee\x0a\x0e" +
|
||||
"\x10q\x16tE\\\xdd\x89\xd5\x10\xf1m]&\x12\xba" +
|
||||
"E&\xf4\xe2M\"\xa1\x17,\x01\x888\xae\xc3\x0b\x16" +
|
||||
"\x9d\xe5\x0e\xb3)Q\xf8x\xf9{8\xcd\xbc/\x1cr" +
|
||||
"\xc2\x15R$`\x01\xd7CX\xf5>7\x8aK\xc2\x1b" +
|
||||
"\xb2\xbe~\xa0\xcc\x9a\xf6\xa8\xd4\xf6f\x89F\xec\xabf" +
|
||||
"MyAk\xf6\x11~\xa4\x9a5\x1dV\xa9-=D" +
|
||||
"\xf8\xfd\x84\xab(i\xd3}\xea\xe3\x00\xc6\xfd\x84?\"" +
|
||||
"h\x93*i\xd3\xc3\xc2\xfe1\xc2O\x08\xda\x14\x92\xb4" +
|
||||
"\xe91\x95\xda\xd2G\x09\x7fF\xd0\xa6\x1aI\x9b\x9e\x16" +
|
||||
"v\x9e!\xfc\xb4\xa0M\xb5\x926=\xa7\x9e\x9f@\xcb" +
|
||||
"\xea\xc2\x926\x9d\x13\xac\xef\x0c\xe1\x17\x05m\xaa\x93\xb4" +
|
||||
"\xe9\x97\x82f] \xfceA\x9b\xea%m\xba,X" +
|
||||
"_\x85\xae56H\xda\xf4\x9a\xf0\xf3U\xc2\xdf&\xbc" +
|
||||
"\xa9Q\xd2\xa6\xdf\x09\x9a\xf56\xe1\x1f\x10\xde\xdc$i" +
|
||||
"\xd3\x1fTj\xdb\xdf'\xfc\x13\xc2g5K\xda\xf4\xb1" +
|
||||
"\xa0q\x9f\xa8*\xf6\x0b\xd64K\xb2\xa6\xcf\xc5\xb0\x7f" +
|
||||
"#\xf5:\xc1\x9a\"\x925\xd5\x88.?D\xdd\xf9l" +
|
||||
"\xc1\x9afK\xd6\xd4,XD\x85\xad\xcd\x89J\xd64" +
|
||||
"7\xf4\xfc\x04V\xa6\xa9\x925\xb5\x84\xc8\xcd\xeb\x08_" +
|
||||
".X\xd3\x1c\xc9\x9a\xda\x84\x9d\x1b\x08\xbfY\xb0&M" +
|
||||
"\xb2\xa6UB\xfff\xc2\xbb\x05k\x8aI\xd6\xd4%X" +
|
||||
"M\x85\x95\xcd\x9b+YSJ\xd8I\x12~\xbb`M" +
|
||||
"WI\xd6\xb4U\xb0\x8b-\x84\x7f[\xb0\xa6y\x925" +
|
||||
"\xed\x14l\xb0\xc2\xd6\xae^+Y\x13\x17\xe3\x0e\x13\x1e" +
|
||||
"\x08\xd6\xd4%Y\xd3\x1ea\xa7\xc2\xd6\x16\xad\x93\xac)" +
|
||||
"/\xf0q\xc2\x0f\x09\xd6\x94\x90\xac\xe9n\x81\x1f \xfc" +
|
||||
"\xa8`M\xdd\x925\xdd#\xfc,\xb35\x8cK\xd2\xf4" +
|
||||
"@h\x09\x80q\x94\xe0c!\x05\xc5}\xb6\xdd\xe7q" +
|
||||
"\x88\xf8~U\xf7\xca\xec1\x96\xf77;X|\x1b\xd4" +
|
||||
"\xd3)^qT\x14\x06=\xb6\x9b'\xad\x0c\x84\x87\x03" +
|
||||
"\xbf\x8c\x9a\xcc\x1f\x1et\x99\x87f\xd5\xcdv\xb9\xa6\x99" +
|
||||
"|\xda\xb1\xca\x0d\xbc\xb8\xe1\xe6&L\x95$]'C" +
|
||||
"\xd2\xf0t\xcf)\xdb\xb8\x97/iT?\xbf\x9f\xa7\xdd" +
|
||||
"\x0d\x9cy\xe5'\x86\\/\xcd\x13.\x03\xd5\x0f\xca\xa0" +
|
||||
"\xe5\xf71o7\xd1\x82\xca\xc0Y\x96\xf3y\x92\x05\x10" +
|
||||
"\xa1\xc9O\x82]\x07E\xf5\xb3\x1c\x86v\x15\x01r\xbd" +
|
||||
"`\x03g\x80\x95\xf1\x02\x8f\x0d\x0dY\xe9\x14\xba\xa6|" +
|
||||
"GTM\x102\xcc7\x02\x96\xde]=\xee\x88kr" +
|
||||
"\x9a\x05L\x98\xa8,l}\x1e\xc4\xf9\x17.RW\xa7" +
|
||||
"m\xbbc\xd5\x11\xaa,G\xe0f\x0d+\x83N\x99\xc5" +
|
||||
"\xc9\x17-S\x8dJ<\xe9b)\x9e8Y\xb6\x8dc" +
|
||||
")\xde\x93\x9e\xcdr\x93\xd9)6\x0e\xf1~\x9e\xe1\xce" +
|
||||
"D\\\xc4\xad+\x1dX\xa3\xbc2\xe1\x80\xfb6K$" +
|
||||
"z\xa0\xd3\xc9\xb0L\x95\xa9\xa2\xa0\x1b-\x9f\x0b\x11N" +
|
||||
"\x91\xf5\xa2\x1bty#\xd5\x09#\xb2\xf96\x9ap\xc2" +
|
||||
"\xcbY~\xd5@\"\xf7\xa6\x95ds\xb6\xcf\xbb\x1c\x13" +
|
||||
"\xe2\x1bl\xcb\xe4%<\x9c\xc9\xfa8\xbbr`\x14\xef" +
|
||||
"\xfcfp.\xdf<\xc3\xce\xfd\xff]I\x7f{\x91\x95" +
|
||||
"\x0aR\x1a\xceg\xc5)*\xbb\x94U\xb2-l\xeb\x17" +
|
||||
"\xa7h\xab<E\x97n\x12ma\xcb2\x80\xfd\xe9\x9c" +
|
||||
"\xe7\x11i\xcaz\xdc\xb4\xd2\x01\x074\x0bY\xd7\xf7\xad" +
|
||||
"A\x9b\x97\xceV\xc7\x14\xbb 2\xc4,\xfb\x8a\x17\xe1" +
|
||||
"\xdd\xc5+\xd7\xf2\x8d\xeb\xa4k\xe0\xd5\x00\xfa\x83*\xea" +
|
||||
"\x8fV5\x16\xc7\xfb\xab\xee\x86\x15\x94\xae?\xb9\xa3\xea" +
|
||||
"nXUd\x83u\xf2 \x80\xfe\x8c\x8a\xfa\xe9\xca5" +
|
||||
"\xf0s\xf4\xf4\xb3*\xea/W]\x03_&\xc5K*" +
|
||||
"\xea\xef+\xd8\xc9DvMJW\x9b\xe6Z\xc9\x1d7" +
|
||||
"`\xf6z\xcb\x06\x95\xfbX\x07\x0a\xd6A\xd5\xf517" +
|
||||
"\xd7[6\xf7\xa1,\xb1\x8b\xef\xad\x00\xfd\xd2-qS" +
|
||||
"\xf1MAI\x84\xdd<`\x96\xedC\xe5\x1a\xb9\xfc\x1f" +
|
||||
"6\x8a\xd7\xc83!\x06\x13\xb2\xe0J\xc1/\xbdV\xeb" +
|
||||
"\xee\x94>\x08\xeaZ^\x83\x1ej\xee\xbaU\xd4\xfb\x14" +
|
||||
",-A\x8a\xa2\x9dTQ\xbf\xbdj\x09\xb6R\x10\xb7" +
|
||||
"\xa8\xa8\x7f[\xa9\x9ar\x15\x15\x9aY\xd4\xfe/\x00\x00" +
|
||||
"\xff\xff\x00\xd1\x99X"
|
||||
const schema_b526ba661d550a59 = "x\xda\x94Z}lT\xd7\x95?\xe7\xbd\xb1\xc7\x9f\x8c" +
|
||||
"\x877\x04\x0c&\x13(\xd0\x98\xc6i\x8c\xf16\xa6I" +
|
||||
"]c\x03\xc1\x1a\x83\xc7\xc3G@A\xea\xf5\xbc\xeb\x99" +
|
||||
"\x07\xcf\xefM\xde{c{\x10\x11\x84\x82\x04YP\x93" +
|
||||
"n\xd2\x92*lC\x02R\xda\x0d-\xed\x86\x15\x1bQ" +
|
||||
"-AD\"Y\x90\x92\xaa\xa9\x9a4\xbbI\xa3JI" +
|
||||
"\xba\x89\xd2v\x13m\xb6\xdb\xe8\xad\xce\xbd\xf3\xf1\xfc\xd1" +
|
||||
"\x10\xff\xc3\xf8\xfd\xcey\xe7\x9e{\xee\xb9\xe7\x9e\xdf}" +
|
||||
"\xdc\xf1\xf5\xfao\x86\xda\x1b3\x0d\xa0$\x1f\xae\xaa\xf6" +
|
||||
"\xa3{v\xbccy?9\x00\xd1E\xe8\xef\xa8\xdb\xba" +
|
||||
"x\xe4\xf9\x15\xe7!\x14\x06\xe8h\xae\xde\x89Z[u" +
|
||||
"\x18T\xff\xa7\x1f'\xbav\xfe\xf1\xf2\x83\x93\xb5\xaa\x84" +
|
||||
"Zm\xf5^\xd4\x16W\x87\x01\xb4\xe6\xeaw\x01\xfd{" +
|
||||
"{/\x7fgx\xc5\x0f\x0fCt\x91RQ\x06\xec\xc0" +
|
||||
"p?j\xf3\xc2\xf4R4l\x87\x00\xfd\xb6\x97\xf2\xf7" +
|
||||
"\xfe\xb8\xf9\xf8#\x90l\xc3Z\xff\xb2\xf5o\xf5\xdb\xae" +
|
||||
"\xdc\xf7\xdfP\x85\xa4s\xbc\xfe!\xd4\xce\xd4\xd3\x9f\xa7" +
|
||||
"\xea\xdfR\x00\xfd\x7f\xfc\xcaGyc\xef\xce\xefA\xf4" +
|
||||
"K\x8a\x7f\xf0\x93O\xbe\xd4\xf1_\x7f:M\x96_\x9f" +
|
||||
"s\x10\xb5\x0f\xe7\x90\x0f\xef\xcf\xf9>\xa0?\xf79\xcc" +
|
||||
"d\xae^\x7fb\x86iuE\x86Q\x1b\x88\xd0\xb4\xda" +
|
||||
"~\x1bW\xfb\xc2\xd9S3h\xb5Fv\xa2v\xb7\xd0" +
|
||||
"2\xd5\x8eo~:\\\xfd4$\x17\xe1\xb4\xd97\x93" +
|
||||
"^[D\xbe\xb2\x1d\x01+~%\x17\xe1\x9c\x80:E" +
|
||||
"\xa8\xe3d\xd3B\xd4\xce6}\x19\xa0\xe3\xa5\xa6\xf10" +
|
||||
"\xa0\xdf\xd7{\xe9\xe5\xb3o=}fZ\xb4\x92\xcd\x07" +
|
||||
"Q\xe3\xcd4'\xd6\xfc5@\x7f\xeb\xe9\xe4\xeb\xb7\x8d" +
|
||||
"]?3\x83\xb7\xbcy'j\x85f\xf2\xf6'\xbfM" +
|
||||
"?\xf6\xf7\xcf|\xf4O\xd3\xecmm^S\xb1\xb7\x19" +
|
||||
"\xd0\xdfu\xfe\xdd\xf6\xef|\xf6\xd6Of\xb0\x97'{" +
|
||||
"G\x85\xbd\xee\xb7\xfe\xf0\xc0\xc0-\xdb\xcf\xcd8\xeap" +
|
||||
"i\xd4\xab\x98Xb\xa4\xad\x0b3hm%[\x86\xd0" +
|
||||
"\xca^\xfc\x87GS_\xef\xfb\xc5\x0cZ\xeb\xc8\xd6\x0e" +
|
||||
"\xa1uH]\xfd6\xbfk\xd9\xe5\x19\xb4:Ik\xa3" +
|
||||
"\xd0\xfa\xe8\x9f\xbf\xf1\xea\x9dw\xef\xbe:uU\x14\xd2" +
|
||||
"[\xde<\x17\xb5N\x9akG{s\x9cVe\xcd\x8e" +
|
||||
"\xed9\xf3\xb5\x1f\xfe\xfbLC/\xa4\xa1\x17\x92\xd1W" +
|
||||
"\x0e>\x99\xf9\xbf7\xbew}\xa6\xa1Ik\xa3\xd0Z" +
|
||||
"|%\xfd\xe6\x9b\xde\xc1W\xa7\x85x\xf9\xc2\xb5\xa8u" +
|
||||
".\x14\xc3.\x14\xc9\xd0\xd5u\xf1\xd4\xb5\x87\xfe\xe7\xd7" +
|
||||
"\xe4\xa5:\xc5\xe2\x92E\xfd\xa8u.\xa2\x05i_D" +
|
||||
"\x1b\xa73\xd1\xf7\xed\x13\xdb\xbf\xf7\xc6\x0c\xa3\xcfk\xd9" +
|
||||
"\x89Zk\x0b\x8d\xde\xf5\xed\x97\x7f\xf0\xfd\xaa\xef\xff'" +
|
||||
"\x99T\xa6\xa8U\xb5\xacEm^\x0b\x99\x8c\xb6\x8c\x03" +
|
||||
"\xfa\xb7\xbe\xfa\xeb\x8fn[\xf7\xdc\x9f\xa7\xed\x98|\xcb" +
|
||||
"n\xd4\x8e\x0a\xcd\xc3-\x19\xc0\xca\xe6\x9blVn\xc5" +
|
||||
"\x9f\x91\xf6\x8b-\xf3\x01:\xae\xb7\x84)m\x9bO\x9e" +
|
||||
"\xdcX\xfb\xc1M\x1f\xcf\xe0\xebk\xcbv\xa2\xf6\xfe2" +
|
||||
"\xf2\xf5\xe9P\xee\xb3\xaf\x1f:\xfe\xbf3h\xbdHZ" +
|
||||
"\xaf\x0b\xad\x95\xef-\xdf\xf4\xee}\xdf\xf8\xcb\xb4x\x9e" +
|
||||
"_6\x8c\xdaK\xcb\xc8\xc9\x17\x97\xed\x07\xf4\x9fr\x07" +
|
||||
"\xaf\xbe\xbc\xeb\xe9\xbfLuR,z\xd5\xf2\x83\xa85" +
|
||||
"/\x17\xe1Z.\xa2\x1f\xfd\x97\xf1#\x1f\xf6\x0c\xffu" +
|
||||
"\x86\xe1\x1f[1\x8c\xda\x8fV\xd0\xf0\xfbO\x9c{7" +
|
||||
"u\xe2\x88?5\x93\xc4\xcc\x0f\xafx\x1e\xb5\x93+\xc8" +
|
||||
"\x85\xc7W\xfc\x14\xda\xfc4w83\xbf\x9a\x0e\xe5]" +
|
||||
"\xcf\x1e\xfdjZ\xfc\xdc\x9ef9+\xb7\xa6W<\x0c" +
|
||||
"q\x97;c\\\xd5W\x0f\"~\xae~\xcac\xce\xa0" +
|
||||
"a\xda^/sz\xbbm\xcbsls\x101\xd9\xa4" +
|
||||
"\x86\x00B\x08\x10e;\x01\x92\xdfR1i*\x18E" +
|
||||
"\x8c!\x81\xc6w\x01\x92\xa6\x8a\xc9\x09\x05\xa3\x8a\x12C" +
|
||||
"\x05 \x9a'MO\xc5\xe4\x01\x05\xa3\xaa\x1aC\x15 " +
|
||||
"\xfa@?@r\x9f\x8a\xc9#\x0a\xfa\xd9\xbc\xdeKc" +
|
||||
"\x80j\x9b8\x17\x14\x9c\x0b\xe8\xbb\x1e\xe7\x8eae0" +
|
||||
"a\x8c\x1a\xdeFk\x04\xed\xb2\xccbcF\x86y\x06" +
|
||||
"\xa8\xb6U\x06sY\xdb\xe2\x1br.\x00`S\xa5\x80" +
|
||||
"\x03b\x13|\xfel\x07XN\xdfh\xe5\xf2\xde\x96B" +
|
||||
"\x8e\x03\xd0D/\x92\xebZ\x9b\xd2\x0f\x80s\xb4V\xe5" +
|
||||
"\xe7\x00\x18\xd1Z\x95\xa7\xc8\xb8\xd6\xaa\xfc\x18\x00\xa3Z" +
|
||||
"\xabr\x19\x00\xe7j\xad\xca\x1b\x00X\xaf\xb5)\xc3\x00" +
|
||||
"\xa8i\xad\xca\xcb\x00\x18\xd3\xda\xc4/j\xed\xca^\x00" +
|
||||
"T\xb46e7\x00\xce\xd3Z\xc5\xf3M\xdar\xe5\x0f" +
|
||||
"\x008_kU~\x09\x80\x0b\xb46\xe5\x1d\x00l\xd6" +
|
||||
"\xda\xc5\xf3B\xadS\xf9\x01\x00.\xd2:\xc5\xb8-Z" +
|
||||
"\xa7\xb0\xb7X\xeb\x12\xcf7k]\xc2/\xb5\xf8\x1c\xd2" +
|
||||
"\xba\x84?q\xadK\xd8\xbdE\xbb[\xd8[\xd2\xd1\xa3" +
|
||||
"\xacA\x00\xac\xd26*\xcf\x03`\xb5\xb6Q8\xd0\xa8" +
|
||||
"\xadSv\x02`\x83\xd6#\x1c[\xaa\xdd-^\xfc\x92" +
|
||||
"\xd6#\x0c/+\xfe.\xef\xe8Q\xe6\x92\x81:m\xa3" +
|
||||
"\xf2\x10\x00\xae\xd06*\x7f\x06\xc0/w\x0c(KI" +
|
||||
"p\xab\xb6U\x84\xa0\xb5c\x87\xa2\x10\xb0\xb2c\x97\xfc" +
|
||||
"\xe3+\x1aS\xbe\x0b\x80\xb7iL\xbc\xda\xa61\x11\xbc" +
|
||||
"p\xf1\xb9Fc\xcaA\x00\xac\xd5v\x89\xdf\xdb\xb5\x1d" +
|
||||
"\xe4\x83\xaf\xdb\xe3\x96i3\x1d\x00|\x97{[\x98\x93" +
|
||||
"\xe1\xe8%\x98\xc7\x1df\xc6{\xd2in\x12\x9e\xcaq" +
|
||||
"\xae\xcb\x14\xd9<2\x12v\xb97\x05\xed\xb5\xad\x08e" +
|
||||
"/\xc1\x03,\xd7\x9b\xafr\xc6\xb8\x90\xf7\xca\xb4\x06\x97" +
|
||||
"{\xdb\x0c\xd7\xb0\xad\xde\xfc$\x91*_J\xd8\x99\x04" +
|
||||
"\x87\xf0\x98\x1cOh*RU\xf8D.\xf5H\x1f\x83" +
|
||||
"\xb2\x01\xc3\x92\xe2m\x00\xbe\xc3i&)\x0e\xdd\x9eg" +
|
||||
"X\x19\xd7w\xd9\x18Oq\xcf\x83\x88|\xe4\xde:\x8b" +
|
||||
"\x0d\x9b\xd0-\x87\x9fjl\xab\xcb\x85\x9c\xa7\"%\xb1" +
|
||||
"\x98\x8a2I\x96\xe3\x1c\xf5\xf2\xec\x151\xfb\x804\\" +
|
||||
"|\xf3\x1e\xdb\xd4\x13\x0as\xbd\x14\xe7\x96P%M\xf4" +
|
||||
"\x02Q\x16h?W\x9d=S\xc1\x9et\xb8\x18x\x81" +
|
||||
"*\x12\xddb\x8c\xf2\xcd##.\xf7d \xfa\xf8\x08" +
|
||||
"\xcb\xa3\xe9%\x98\xc5\xb7\x1ba\xdd\xcb\x96]\xc6R\xdc" +
|
||||
"\xe2\"p>\x05\x86\xd41oz\x14\x11#L\x01!" +
|
||||
"t\x88\xa7\xed\xaa\xd1Qn\xe9\\\x17\x12+\xe3\xd2Z" +
|
||||
"\xa5L{\xbc\xcf\x1e\xb7\xd6\xdb\xce&>!\x1dHD" +
|
||||
"h\xb2\x95\xb9o\xcdM\x92\x1a\xe1\xa2\x94\xe6\x9eRK" +
|
||||
"s\xf6\xb6g\x0d\x93\xf7f\x99\x951\xacL\x8awK" +
|
||||
"u1\xfa w\\4\\\x8f[\x1e\x09\xe4\xb2\xa5\x99" +
|
||||
"\x95\xe6f\x9f\x0d\xdd27\x8b\xe9\xd1\xefR\x1d*>" +
|
||||
"\xa4l\x88\xe4\x9d4\x17\x8b:\xe1qG\xb1\x98Y\x0e" +
|
||||
"\xf3\xa4t\x14b,\x89\xe3\x89Is\x90\xd9;\xe8\x18" +
|
||||
"q\xdb1\xbcB\x19W\xa5\x19r\x9a\x0f\xf1\xfb\xf3\x86" +
|
||||
"\xc3]\xda\x0d9\xf4|F\xbf^*\x87\xa5\xd1\xe4r" +
|
||||
"\x0c:\xdcu\x95\x0d\xcc\xddb\xf7\x145&\x8d\xd7\xa3" +
|
||||
"\xef\xce\xbb*\x85_\xaefP\xab\x12;\x01*^e" +
|
||||
"*\xb4\xea\xb6\x9a\xf7\xcaC\xd4\x8a!6\x8fq\xc71" +
|
||||
"t^Q\xa4U\xeb\xb5-\xdd\xf0\x0c{j0J;" +
|
||||
"0\x95e\xba=\xde\xcb\x9c\x94\xc7<\x14\xd1\x13\x10\x8e" +
|
||||
"\x0f\xd8:7\xb7\xad\x92S)b\x1brn\xc2N\xb3" +
|
||||
"\x08\x19$\xb8\xdf\xb5\xadAd^v\xbdi\xb3\xe2\xac" +
|
||||
"\x05V\xcd\xbc\xec\x16>!\\\x10\x00\xf3\xb2k\xed\xe0" +
|
||||
"\x90ESd\x89\xd6#\xe2X\xcc,\x1f\x16U78" +
|
||||
"\x1a\xc9Y~\xbb8s\xe8\xd0X]:\x1d\xb5]\xb8" +
|
||||
"\x17 u\x1f\xaa\x98\xcab\xe5\x80\xd48\xf6\x03\xa4t" +
|
||||
"\xc2sX9#\xb5Q\x1c\x02H\x99\x84O`\xe5\x98" +
|
||||
"\xd4\xf2B\xdf#\xfc\x00\xe1\xa1\x9a\x18\x86\x00\xb4\x07p" +
|
||||
"\x15@j\x82\xf0C\x84W\xd5\xc6\xb0\x0a@{\x10w" +
|
||||
"\x02\xa4\x0e\x10~\x8c\xf0\xea\xba\x18V\x03hG\xf1\x07" +
|
||||
"\x00\xa9c\x84\x9f <\\\x1f\xa3&B{\x0c\x1f\x02" +
|
||||
"H\x9d \xfc4\xe15\x0d1\xac\x01\xd0N\x09\xfd\xd3" +
|
||||
"\x84\x9f#\xbc\xb61\x86\xb5\x00\xdaYt\x00R\xcf\x12" +
|
||||
"~\x81\xf0\xbap\x0c\xeb\x00\xb4\xf3\xf8\x14@\xea\x02\xe1" +
|
||||
"WP\xc1%\xf5J\x0c\xeb\x01\xb4\x17p\x0d@\xea\"" +
|
||||
"\xe1WQAl\x88a\x03\xf5L\xb8\x12 u\x89\xe0" +
|
||||
"7QA\xdf\xb5i\xcb\x0c\xd8\xd0m\xd9\x94VX\x0b" +
|
||||
"\x0a\xd6\x02\xfa&\xf3\x0c/\xafs:\xd3\xebA\xc1z" +
|
||||
"\xc2l+C /c\xcc\x9c\xa6\x17w\xc56\xaa" +
|
||||
"\x03\x05\xeb\x00\xfda\xce\xa8\x9b\xe8\x03\x95g\xca`\xd6" +
|
||||
"v\x8c\xbd\xb6\xe5!3{\xd2\xe9\xbc\xc3\xd4t\xa1," +
|
||||
"\x1c\xe3\x8eg\xa4\x99\x89R\x94.\xd0\x018\xd9Z\x0f" +
|
||||
"\x16E}A\xab\xae\xac\x94i\x88\x0bY\x19\xcf[\xc6" +
|
||||
"\x84\x98\x9d\xeb\xb1\xd1\xdc\x80a\x86M\xc3\xc5*P\xb0" +
|
||||
"\x0a\xb0;\xcb\xdc\xf5\xc6\x04\"(\x88\x80\x91\xb1M\xeb" +
|
||||
"\xfap\x0e\xe0\xa0\x8a\xc2\xc0\x9c@\x0fS\xf57z\x98" +
|
||||
"\xcdy\xef\xf6\x14\x1b\xcd\x99\x9c\x123\xef\x8a\xa4\\ " +
|
||||
"\x9a\xb0\x9e\xb5\xd4\x88D\xbbVQ\x1f\x12m\xa7S_" +
|
||||
"\x8d\xb6\x11\x18\x8a.\xa7^\xa0*\xbad7\xc0\xfe\xbc" +
|
||||
"\xb5\xc7\xb2\xc7\xad\xb8eo\xc8\xb9\xbee\xf7\xdac\xdc" +
|
||||
"a\xa0f\xf8~\xcb\x1e`^:\xeb\x8f\xd2\xbf\\\xdf" +
|
||||
"\x04\xdd\xb6,!E \x01\xb2\x0a\x7f\xe1>4\xac\xb7" +
|
||||
"\xaf\xbaQ#:\xa5q\xed\xba\x91>\x85\x81\xf61\x9d" +
|
||||
"\x1a\x9b\xd5\xbc'\"PnZ\x1f\x7f\x08 \xf9\x84\x8a" +
|
||||
"\xc9g\x02M\xeb\x19jEO\xab\x98<\x17hZ\xcf" +
|
||||
"\xae\x04H>\xa3b\xf2J\xa0i}\x814/\xa9\x98" +
|
||||
"\xbc\xa6 \x86\xc4N\x8c\xbet\x10 yU\xc5\xe4\xaf" +
|
||||
"h\x1b\xa2\xd8\x86\xd1W\x87\x01\x92\xaf\xa8\x98|S\xc1" +
|
||||
"r'\x83\x83\x8e\x9d\xa1:)\xfa\xd32a(\xf6\xa7" +
|
||||
"n\xe9\x88\xa3~\x0c\x14l\x00\x8c\xe4\x98\x97-\xe5@" +
|
||||
"S\x85\xb1\x01\x8al\xc8\xd9\xae\xa8\xa8\xb2\xdf-\x93\xaf" +
|
||||
"\xa2=\xd3\xb6sC\xcc\xe3\xd8C\x0b\x98\xe1\x95\xdc-" +
|
||||
"I <`Xe\xb4\x14T\xf5o\xe5\x96\x9a\xf7\x92" +
|
||||
"5\x18\xbc\xea\xa8\xdd\x1d`q\xb5C~)\xf5 B" +
|
||||
"\xc9\xe7o\xc8\xb9)\xda\xd7\x80<y\xa4\\\x19\x7f\xa4" +
|
||||
"\xae\xa5\xca\xa2ReQ\x03\x95\xf1\xacJ\x95\xe2\x19\xc2" +
|
||||
"\x9fS\x03\x95\xf1g*U\xc0s\x84_T\x15\xc4b" +
|
||||
"a\xfcW\x95\x0a\xdd\x05\x82\xaf\x90z\x08ea|A" +
|
||||
"\xa5\xc2{\x89\xf0k\x84W)\xb20\xbe\xa4>\x0f\x90" +
|
||||
"\xbaF\xf8o\x08\xafVea|M\x0c\xfb\x0a\xe1o" +
|
||||
"\x12\x1e\x0e\xc9\xc2\xf8\xba\xb0\xff\x1b\xc2\x7fOx\x8d*" +
|
||||
"\x0b\xe3\xefT*\x8c\xbf'\xfc\x8f\x84\xd7\x86da\xfc" +
|
||||
"P\xa5\xc2\xf8\x01\xe1\x9f\x12^W%\x0b\xe3'\xeaw" +
|
||||
"\x01R\x9f\x12\x1e\x0a)\x18\xad\xaf\x96\x85\x11C\xbf\x04" +
|
||||
"H\xd5\x84TL\xc5\x08ox[V\xc6h\x88\xfci" +
|
||||
" |\x01\xe1\x8d\x8bc\xd8\x08\xa0\xcd\x0bQ\xc1o\"" +
|
||||
"\xbc\x85\xf09\xbf\x8b\xe1\x1c\x00\xad9D~. |" +
|
||||
"\x19\xe1\x91\x9a\x18F\x00\xb4%\xa1\x97\x01R\xb7\x12\xbe" +
|
||||
"\x9a\xf0\xa6\xda\x186\x11\x9f\x0fQ|\xee \xfc.\xc2" +
|
||||
"\xa3u1\x8c\x02h]!\x9a\xd7]\x84\xdfC\xf8\xdc" +
|
||||
"H\x0c\xe7\x02h\xebB\xc3\x00\xa9>\xc2\x07\x09\xd7\xea" +
|
||||
"c\xa8\x01h\x03\xa1\x9f\x03\xa4\x06\x09\xbf\x8f\xf0XC" +
|
||||
"\x0cc\x00\xda\x8e\x10\x1d(\xf7\x11\x9e%|^c\x0c" +
|
||||
"\xe7\xd1\xc1\x17\xa2\xf8\xe8\x84\xe7\x08\xbf\xa9%\x867\xd1" +
|
||||
"\xc1'\xf4s\x84\xef#|\xfe;1\x9c\x0f\xa0\x15\x84" +
|
||||
"?\xfb\x08?B\xf8\x82\xc51\\\x00\xa0\x1d\x0e\xed\x06" +
|
||||
"H\x1d\"\xfca\xc2\x9bkb\xd8\x0c\xa0\x1d\x17\xf19" +
|
||||
"B\xf8\xa3\x84/\xac\x8a\xe1B\x00\xed\x11\xe1\xe7\xa3\x84" +
|
||||
"?I\xf8\xa2\x9bc\xb8\x08@;)\xfcy\x82\xf0g" +
|
||||
"\x08o\x89\xc7\xb0\x05@;#\xe2y\x9a\xf0s\x84/" +
|
||||
"^\x16\xc3\xc5\x94\x9e!:\xa0\x9f%\xfc\x02\xe17\xd7" +
|
||||
"\xc5\xf0f:\xf8D|\x9e#\xfc\x12\xe1\xf1\xfa\x18\xc6" +
|
||||
"\x01\xb4_\x88y]\"\xfc\x1a\xe1\xb74\xc4\xf0\x16\xca" +
|
||||
"C\x81_#\xfc7\x84/i\x8c\xe1\x12\xca\xc3\xd0A" +
|
||||
"\x80\xd4\xaf\x08\x7f;\xa4\xe0\xfeqV\xd8\xc4Fy\xa9" +
|
||||
"\x14t\x8f\xb3\xc2\x10\x1f)=\xfa\x8e\xcdt\x92\x07\xaa" +
|
||||
"\x85<v\x12\xc6(\xa8\x86W\xde\xd6V\xb1\x09\x86n" +
|
||||
"\xd9rM\x13\xa0\xc4\xfb\x8cn\xd7\xa3\xf6\xb6\xa4\xd0\x9d" +
|
||||
"e{\x99\xa3\x97\xad\x93\xfe=l/\x03u\x06\x10\x1d" +
|
||||
"\xbd\xcf\xa0\xf7\xd5\x8a\x01\x9f\xe9c\x86k;\x05\x88\xa7" +
|
||||
"&\x9d\xc4\xf4R\x8f>f \x09\xa5\x0b\xd3dJI" +
|
||||
"V\xb4\x9b\xc6\x8ac\xb6\xc5\xb7\xb3B\xe9\xa0\x8c\x9b\xcc" +
|
||||
"\xe2.V\x83\x82\xd5\x80\xbeg\x98<A\xb4Q\xe5z" +
|
||||
"I\xa5\x1c\x19\xc5\xf0R\xf9L\x86\xbb\x1e\xd7\x85\xf1\xc0" +
|
||||
"\x89\xee\x16\x05\xd0\xadOv\x97\xbb\x9e1J\x85T\x1f" +
|
||||
"\xb2\x99\xbe\xdd\xd0U/[\x16\xd2:P\x17\x0ba>" +
|
||||
"\xe1a\xa4r\xc7\x09\x88\x11@_7\x8aQ]\xef\xd8" +
|
||||
"\xa3\xdbY\xa17\xce-j\xfa\xcb=F\x91xb\x89" +
|
||||
"y\x06<\x1a%\xbe\xe4\x8c\xf1\xa9\xf1\x1bg\x85\x147" +
|
||||
"y\x1a\xa9\xf8\xcb\x1b\x0b\x8cT\xae\xaa\x8a#\x97\xe6\x8c" +
|
||||
"\x86\xec\xdc\xbd`@\xb2F&;\xce\x0a\xbd\x101\x99" +
|
||||
"\xebb\xa4rq(\xdf\x8e\x8f\xb3\xc2F\xbd\xd4\x9a\xf8" +
|
||||
"\xe9b\xf3>\x85\xabT2O\x14\xfem\x1c\xe2\x0eM" +
|
||||
"\x07\xc3\xa0`\xb8\x18\x1d\xeaD@\xcd\xd3 \xe5\x93\xa3" +
|
||||
"\xe8b\xa6r@`\xa4r\x94T\xa4\x03\xb6eo\x81" +
|
||||
"p\xb0!L\xdb\xa3\xb9\xbc\xc7u\x142\x83v@Y" +
|
||||
"&\xbb\xc8\x0d\x1c-\xee0y2\x96e9\xc7\xd6\xf3" +
|
||||
"i\xee`\x8a\xbb\xe4cE\xf2\xb9\xbd~\x99\xaaHz" +
|
||||
"+\xa2M-E\x8dh\x12\xa2kDSU\xbb\x16\x80" +
|
||||
"v\x8fg\xa4\xf7\xe7\xb8\x93\xe6\x967\x9b\x9e\xa63\xd8" +
|
||||
"\xd3\xcct\xfcR\xde\x89K8>!\xfa\x99\x061\xf8" +
|
||||
"b\xd9\xd1\xcd[):\xba\xc6\xb5\x00\xfbG\x1c\xce\xc7" +
|
||||
"Y!\x926\xbcB\xa9\x93\x9b\x8d'\xed\xb3\xea\xc6\xc2" +
|
||||
"z\xfb\xeaY\xb6o_\x9b\xed\x00\x9d\xb3}\xa1\xfdF" +
|
||||
"\xc1\x14w}hQ\x1cc\xe5\xbe\xf0\x01\xea\xf6&T" +
|
||||
"L\x1e\x0a\xf4\x85\x0f\xae\xaa\\Qb\xb1-<\xbc\x14" +
|
||||
" y@\xc5\xe41j\x0b\x9bd[x\x94\xde>\xa4" +
|
||||
"b\xf2\xe1J#\x12=N\xbd\xe21\x15\x93'\x14\x8c" +
|
||||
"x\x85\x1c\xa5x\xf9\xfbSq\x97\x8d\x10[-\xed\xeb" +
|
||||
"\xb0\xeb9\xe5\xceo\xd8\xb6\xcd\xf2n\xdd]$\xae\xc1" +
|
||||
"b?\x9b\x98\xdc1\xdb v\xdc\xe8\x85{\x8a\xf5\x83" +
|
||||
"\xaa\x87\xd8\x0fw\x8a\xe8\x9c\x92)\xf9x\xbfH\xc9\xc7" +
|
||||
"v\x0b\x92\xf1\xc8*A2\x8e\x0e\x09\x92q\x98T\xaa" +
|
||||
"\xa3\x0f\x0c\x03`8Z \xb0&\x9awh\xa7F\xef" +
|
||||
"\xa7\xf7\xea\xa2\xa3\xf4^}\xd4\xa0\x9f\x86('\xcd\xc6" +
|
||||
"(\x0b\x90\x13\x7f\xd4\xf6lg\x9c\x15\x00\xa0\xf2w$" +
|
||||
"aX{\xe2\x9e\x93\xb7\xf6\xf8\xe2\xdf\x84a\x01\xee\xd9" +
|
||||
"\x9fs\x8cQ\xe6\x14\xfc\xe2o\x02\xc2\x86\xb5\xc7w9" +
|
||||
"\x155\xe6\x00\x16*\x7f\xc7\x0bd\xc3\xf7\x88\xf81G" +
|
||||
"\x98/\xff-\xcc\xfby+Ms6 2bp\xdd" +
|
||||
"w\xb8k\xe8\x9c\xca\xbf\xc1L\xdf4\xc6\x0c+C\xfd" +
|
||||
"\xaf\xc3\xb9wC\x9aBK:h\x1b\x96'/\x0f\x02" +
|
||||
"W\xeb\xfd3]\xad\x0f\x01$\xb3*&=j\x8eC" +
|
||||
"2\x1d\xef'0\xa7br\x1f\xa5c\x95L\xc7\xc2\xde" +
|
||||
"J2\x7fa\x02\x9d\xce;c\xcc\xcb;P9h}" +
|
||||
"O^`r\xe86m*)\xd3\xb8\xc2\x17\xd8\xf1\x1d" +
|
||||
"_\x84\xb0\x0d\x96\x98\x8c(\xad\xe58\xb4R\x1cnU" +
|
||||
"1\xb9:\x10\x87v\x9a\xf2\x1d*&\xef\x9a\xcd\xecn" +
|
||||
"\xe0\x00\x11gAW\xc4\xd5\x9dX\x0d\x11\xdf\xd6\x95\"" +
|
||||
"\xa1\x97\xc8\x84^\xdc/\x12\xbay)@\xc4\xb2-\xee" +
|
||||
"\x1bt\x96[\xcc\xa4D\xe1\x13\xe5\xbf\xc3i\xe6|\xe1" +
|
||||
"\xaf+\xa9\x88\xc7<\x9e\x0ca\xe0\x9bs\x14\x97\x867" +
|
||||
"\xe4\xdc\xe4\x812k\xba_\xa5\xb67G4b_\x90" +
|
||||
"5\x15\x04\xad\xd9G\xf8\x91 k:\xacR[z\x88" +
|
||||
"\xf0\x87\x09WQ\xd2\xa6\xe3\xeaS\x00\xa9\x87\x09\x7fB" +
|
||||
"\xd0&U\xd2\xa6\xc7\x85\xfd\x13\x84\x9f\x16\xb4)$i" +
|
||||
"\xd3)\x95\xda\xd2'\x09\x7fV\xd0\xa6*I\x9b~$" +
|
||||
"\xec<K\xf8\x05A\x9b\xaa%m:\xaf^\x9eD\xcb" +
|
||||
"j\xc2\x926\xbd X\xdfE\xc2\xaf\x0a\xdaT#i" +
|
||||
"\xd3\x8b\x82f]!\xfc\x15A\x9bj%m\xba.X" +
|
||||
"_\x85\xae\xd5\xd7I\xda\xf4\x9a\xf0\xf3W\x84\xbfMx" +
|
||||
"C\xbd\xa4M\xff!h\xd6\xdb\x84\x7f@xc\x83\xa4" +
|
||||
"M\xef\xab\xd4\xb6\xbfG\xf8\xc7\x84\xcfi\x94\xb4\xe9O" +
|
||||
"\x82\xc6}\xac\xaa8$X\xd3\x1c\xc9\x9a>\x13\xc3\xfe" +
|
||||
"\x95\xd4k\x04k\x8aH\xd6T%\xba\xfc\x10u\xe7M" +
|
||||
"\x8255I\xd6\xd4(XD\x85\xad\xcd\x8dJ\xd64" +
|
||||
"/\xf4\xfc$V\xa6\xa9\x925-\x09\x91\x9b\xcb\x08\xbf" +
|
||||
"C\xb0\xa6\xb9\x925\xb5\x09;\xb7\x11~\xa7`M\x9a" +
|
||||
"dM\x9dB\xffN\xc2\xfb\x04k\x8aI\xd6\xd4#X" +
|
||||
"M\x85\x95\xcd\x9f'Y\xd3\x80\xb0\x93 \xfc^\xc1\x9a" +
|
||||
"n\x92\xaci\xab`\x17[\x08\xff\x96`M\xf3%k" +
|
||||
"\xda%\xd8`\x85\xad-\xfc\xa6dM\\\x8c\x9b%\xdc" +
|
||||
"\x13\xac\xa9G\xb2\xa6\xfb\x85\x9d\x0a[kY+YS" +
|
||||
"A\xe0\x13\x84\x1f\x12\xac\xa9W\xb2\xa6\x07\x05~\x80\xf0" +
|
||||
"c\x825\xf5I\xd6tT\xf8Yfk\x18\x97\xa4\xe9" +
|
||||
"\x91\xd0R\x80\xd41\x82O\x84\x14\x14\xf7\xd9\xe6\xa0\xc3" +
|
||||
"!\xe2\xba\x81\xee\x95\x99\xe3\xac\xe0n\xb6\xb0\xf85h" +
|
||||
"]\xb7\xf8\xc4QQ\x18v\xd8\x1e\x9e02\x10\xcez" +
|
||||
"n\x19\xd5\x99\x9b\x1d\xb6\x99\x83z\xe0f\xbb\\\xd3t" +
|
||||
">\xe3X\xe5\x06^\xdcps\x1d\xa6K\x12\xb6\x95!" +
|
||||
"ix\xa6\xf7\x94m\xdc)\x944\x82\xef\xef\xe7i{" +
|
||||
"\x03gN\xf9\x8d\x11\xdbI\xf3^\x9b\x81\xeaze\xd0" +
|
||||
"p\x07\x99\xb3\x87hAe\xe0\x1c\xcb\xbb<\xc1<\x88" +
|
||||
"\xd0\xe4\xa7\xc0\xb6\x85\xa2\xfa\x19\x16C3@\x80l\xc7" +
|
||||
"\xdb\xc0\x19`e<\xcfa##Fz\x00m]~" +
|
||||
"#\x0a\x12\x84\x0csS\x1eK\xef\x09\x8e;j\xeb\x9c" +
|
||||
"f\x01\x93&*\x0b\xdb\xa0\x03q\xfe\xb9\x8b\xd4\xd3m" +
|
||||
"\x9a\xf6x0B\x95\xe5\xf0\xec\\\xca\xc8\xa0Ufq" +
|
||||
"\xf2C\xcbt\xa3\x12O\xd8X\x8a'N\x95m\xe3X" +
|
||||
"\x8a\xf7\x94ws\\g\xe6\x00\x9b\x80\xf8\x10\xcfpk" +
|
||||
"2.\xe2\xd6\x93\xf6\x8c1^\x99\xb0\xc7]\x93\xf5\xf6" +
|
||||
"\xae\x83n+\xc32\x01SEA\x1f\x1a.\x17\"\x9c" +
|
||||
"&\xdb\x84\xb6\xd7\xe3\x8c\x06\x13Fd\xf3=4\xe1^" +
|
||||
"'o\xb8\x81\x81D\xee\xcd(\xc9\xe5M\x97\xf7X:" +
|
||||
"\xc47\x98\x86\xceKx8\x93sg\xf9\x8d{\xca\xb9" +
|
||||
"|\xe7,;\xf7\xbf\xbb\x91\xfe\xf6\"+\x15\xa44\\" +
|
||||
"\xc8\x89STv)\x9d\xb2-l\x1b\x12\xa7h\xab<" +
|
||||
"E\x97\xf7\x8b\xb6p\xc9J\x80\xfd\xe9\xbc\xe3\x10i\xca" +
|
||||
"9\\7\xd2\x1e\x07\xd4\xfd\x9c\xed\xba\xc6\xb0\xc9Kg" +
|
||||
"\xab\xa5\x8b]\x10\x19a\x86y\xc3\x8b\xf0\xbe\xe2\x95k" +
|
||||
"\xf9\xc6u\xca5\xf0\x1a\x80\xe4\xa3*&\x9f\x0c4\x16" +
|
||||
"'\x87\x02w\xc3\x0aJ\xd7\xcf\xec\x0c\xdc\x0d\xab\x8al" +
|
||||
"\xb0\xce\x1e\x04H>\xabb\xf2B\xe5\x1a\xf8<\xbd\xfd" +
|
||||
"\x9c\x8a\xc9W\x02\xd7\xc0\xd7I\xf1\x9a\x8a\xc9\xf7\x14\xec" +
|
||||
"f\"\xbb\xa6\xa4\xabIs\xad\xe4\x8e\xed1s\xbda" +
|
||||
"\x82\xca]\xac\x01\x05k p}\xcc\xf5\xf5\x86\xc9]" +
|
||||
"(K\xcc\xe2w+@\xb7tK\xdcP\xfcRP\x12" +
|
||||
"a\x1f\xf7\x98a\xbaP\xb9F.\xff\xa7\x92\xe25\xf2" +
|
||||
"l\x88\xc1\xa4,\xb8Q\xf0K\x9f\xd5\xfa\xba\xa5\x0f\x82" +
|
||||
"\xba\x96\xd7`\x1d5w}*&\x07\x15,-\xc1\x00" +
|
||||
"E;\xa1b\xf2\xde\xc0\x12l\xa5 nQ1\xf9-" +
|
||||
"%0\xe5\x00\x15\x9a]\xd4\xfe?\x00\x00\xff\xff\xd6\xbd" +
|
||||
"\x94\xef"
|
||||
|
||||
func RegisterSchema(reg *schemas.Registry) {
|
||||
reg.Register(&schemas.Schema{
|
||||
|
||||
+113
-13
@@ -1,8 +1,11 @@
|
||||
package cereal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"capnproto.org/go/capnp/v3"
|
||||
"pfeifer.dev/mapd/cereal/custom"
|
||||
@@ -15,6 +18,8 @@ const (
|
||||
externalFixMaxAge = 500_000_000 // 10 Hz source: 500 ms
|
||||
internalFixMaxAge = 2_000_000_000 // 1 Hz source: 2 s
|
||||
carFixMaxAge = 2_500_000_000 // Optional CAN observation: 2.5 s
|
||||
phoneFixMaxAge = 10_000_000_000 // Companion app in the background sends sparsely: 10 s
|
||||
gpsModeRefresh = 1_000_000_000 // Galaxy navigation setting reread interval
|
||||
maxHorizontalAccuracy = 50.0 // input sanity ceiling, not road-match qualification
|
||||
maxClockPairSkew = 1_000_000 // an interrupted pair cannot date a source event
|
||||
maxClockOffsetDrift = 1_000_000 // a larger BOOTTIME/MONOTONIC step fences queued fixes
|
||||
@@ -39,8 +44,53 @@ const (
|
||||
GpsSourceInternal
|
||||
GpsSourceExternal
|
||||
GpsSourceCar
|
||||
GpsSourcePhone
|
||||
)
|
||||
|
||||
// Galaxy navigation's GPS source setting: backup uses the phone only without
|
||||
// comma or car GPS, comma never uses it, phone prefers it whenever it has a fix.
|
||||
const (
|
||||
GpsModeBackup = "backup"
|
||||
GpsModeComma = "comma"
|
||||
GpsModePhone = "phone"
|
||||
)
|
||||
|
||||
var storagePrefix = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
func navigationSettingsPath() string {
|
||||
prefix := os.Getenv("OPENPILOT_PREFIX")
|
||||
if prefix == "" {
|
||||
return "/data/starpilot/navigation/settings.json"
|
||||
}
|
||||
if !storagePrefix.MatchString(prefix) {
|
||||
return ""
|
||||
}
|
||||
return "/data/starpilot-" + prefix + "/navigation/settings.json"
|
||||
}
|
||||
|
||||
// ReadGpsMode returns the saved choice, or backup when it is missing or invalid.
|
||||
func ReadGpsMode() string {
|
||||
path := navigationSettingsPath()
|
||||
if path == "" {
|
||||
return GpsModeBackup
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || len(raw) > 1<<20 {
|
||||
return GpsModeBackup
|
||||
}
|
||||
var settings struct {
|
||||
GpsSource string `json:"gpsSource"`
|
||||
}
|
||||
if json.Unmarshal(raw, &settings) != nil {
|
||||
return GpsModeBackup
|
||||
}
|
||||
switch settings.GpsSource {
|
||||
case GpsModeComma, GpsModePhone:
|
||||
return settings.GpsSource
|
||||
}
|
||||
return GpsModeBackup
|
||||
}
|
||||
|
||||
type GpsSample struct {
|
||||
Location log.GpsLocationData
|
||||
Source GpsSource
|
||||
@@ -60,7 +110,12 @@ type GpsSub struct {
|
||||
gpsLocation Subscriber[log.GpsLocationData]
|
||||
gpsLocationExternal Subscriber[log.GpsLocationData]
|
||||
carGps *Subscriber[custom.StarPilotCarState_Gps]
|
||||
phoneGps *Subscriber[custom.StarPilotCarState_Gps]
|
||||
car gpsCandidate
|
||||
phone gpsCandidate
|
||||
mode func() string
|
||||
modeValue string
|
||||
modeReadAt uint64
|
||||
internal gpsCandidate
|
||||
external gpsCandidate
|
||||
selected GpsSource
|
||||
@@ -83,6 +138,8 @@ func GpsFixFreshAt(source GpsSource, fixMonoTime, now uint64) bool {
|
||||
return now-fixMonoTime <= externalFixMaxAge
|
||||
case GpsSourceCar:
|
||||
return now-fixMonoTime <= carFixMaxAge
|
||||
case GpsSourcePhone:
|
||||
return now-fixMonoTime <= phoneFixMaxAge
|
||||
case GpsSourceInternal:
|
||||
return now-fixMonoTime <= internalFixMaxAge
|
||||
default:
|
||||
@@ -134,6 +191,35 @@ func (s *GpsSub) clearCandidates() {
|
||||
s.internal = gpsCandidate{}
|
||||
s.external = gpsCandidate{}
|
||||
s.car = gpsCandidate{}
|
||||
s.phone = gpsCandidate{}
|
||||
}
|
||||
|
||||
func (s *GpsSub) gpsMode(now uint64) string {
|
||||
if s.modeValue == "" || now < s.modeReadAt || now-s.modeReadAt >= gpsModeRefresh {
|
||||
read := s.mode
|
||||
if read == nil {
|
||||
read = ReadGpsMode
|
||||
}
|
||||
s.modeValue, s.modeReadAt = read(), now
|
||||
}
|
||||
return s.modeValue
|
||||
}
|
||||
|
||||
// updateWrapped admits a StarPilotCarState.Gps-shaped fix dated by its own source time.
|
||||
func (c *gpsCandidate) updateWrapped(event DecodedEvent[custom.StarPilotCarState_Gps], sensor log.GpsLocationData_SensorSource,
|
||||
clock clockSample, barrierMono, maxAge uint64) bool {
|
||||
sourceTime := event.Value.SourceMonoTime()
|
||||
if sourceTime == 0 || sourceTime > event.LogMonoTime || event.LogMonoTime > clock.monoAfter {
|
||||
return false
|
||||
}
|
||||
location, err := carGpsLocation(event.Value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
location.SetSource(sensor)
|
||||
valid := event.Valid && !math.IsNaN(float64(location.Speed())) && !math.IsInf(float64(location.Speed()), 0) && location.Speed() >= 0 &&
|
||||
!math.IsNaN(location.Altitude()) && !math.IsInf(location.Altitude(), 0)
|
||||
return c.updateWithAge(DecodedEvent[log.GpsLocationData]{Value: location, Valid: valid, LogMonoTime: sourceTime}, clock, barrierMono, maxAge)
|
||||
}
|
||||
|
||||
func (s *GpsSub) clockReady(clock clockSample) bool {
|
||||
@@ -166,7 +252,7 @@ func (s *GpsSub) ReadSample() (sample GpsSample, success bool) {
|
||||
clock = s.clock()
|
||||
}
|
||||
ready := s.clockReady(clock)
|
||||
internalNew, externalNew, carNew := false, false, false
|
||||
internalNew, externalNew, carNew, phoneNew := false, false, false, false
|
||||
if event, ok := s.gpsLocation.ReadEvent(); ok && ready {
|
||||
internalNew = s.internal.update(event, clock, s.barrierMono)
|
||||
}
|
||||
@@ -176,23 +262,29 @@ func (s *GpsSub) ReadSample() (sample GpsSample, success bool) {
|
||||
|
||||
if s.carGps != nil {
|
||||
if event, ok := s.carGps.ReadEvent(); ok && ready {
|
||||
sourceTime := event.Value.SourceMonoTime()
|
||||
if sourceTime > 0 && sourceTime <= event.LogMonoTime && event.LogMonoTime <= clock.monoAfter {
|
||||
location, err := carGpsLocation(event.Value)
|
||||
if err == nil {
|
||||
carNew = s.car.updateWithAge(DecodedEvent[log.GpsLocationData]{Value: location, Valid: event.Valid && !math.IsNaN(float64(location.Speed())) && !math.IsInf(float64(location.Speed()), 0) && location.Speed() >= 0 && !math.IsNaN(location.Altitude()) && !math.IsInf(location.Altitude(), 0), LogMonoTime: sourceTime}, clock, s.barrierMono, carFixMaxAge)
|
||||
}
|
||||
}
|
||||
carNew = s.car.updateWrapped(event, log.GpsLocationData_SensorSource_car, clock, s.barrierMono, carFixMaxAge)
|
||||
}
|
||||
}
|
||||
if s.phoneGps != nil {
|
||||
if event, ok := s.phoneGps.ReadEvent(); ok && ready {
|
||||
// The enum has no phone value; android is the closest.
|
||||
phoneNew = s.phone.updateWrapped(event, log.GpsLocationData_SensorSource_android, clock, s.barrierMono, phoneFixMaxAge)
|
||||
}
|
||||
}
|
||||
|
||||
mode := s.gpsMode(clock.boot)
|
||||
phoneFresh := ready && mode != GpsModeComma && GpsFixFreshAt(GpsSourcePhone, s.phone.fixMonoTime, clock.boot)
|
||||
source := GpsSourceNone
|
||||
if ready && GpsFixFreshAt(GpsSourceExternal, s.external.fixMonoTime, clock.boot) {
|
||||
if phoneFresh && mode == GpsModePhone {
|
||||
source = GpsSourcePhone
|
||||
} else if ready && GpsFixFreshAt(GpsSourceExternal, s.external.fixMonoTime, clock.boot) {
|
||||
source = GpsSourceExternal
|
||||
} else if ready && GpsFixFreshAt(GpsSourceInternal, s.internal.fixMonoTime, clock.boot) {
|
||||
source = GpsSourceInternal
|
||||
} else if ready && GpsFixFreshAt(GpsSourceCar, s.car.fixMonoTime, clock.boot) {
|
||||
source = GpsSourceCar
|
||||
} else if phoneFresh {
|
||||
source = GpsSourcePhone
|
||||
}
|
||||
sample.SourceChanged = source != s.selected
|
||||
if sample.SourceChanged {
|
||||
@@ -215,6 +307,10 @@ func (s *GpsSub) ReadSample() (sample GpsSample, success bool) {
|
||||
sample.Location = s.car.location
|
||||
sample.FixMonoTime = s.car.fixMonoTime
|
||||
sample.NewFix = carNew
|
||||
case GpsSourcePhone:
|
||||
sample.Location = s.phone.location
|
||||
sample.FixMonoTime = s.phone.fixMonoTime
|
||||
sample.NewFix = phoneNew
|
||||
default:
|
||||
return sample, false
|
||||
}
|
||||
@@ -234,24 +330,28 @@ func (s *GpsSub) Close() {
|
||||
if s.carGps != nil {
|
||||
s.carGps.Sub.Msgq.Close()
|
||||
}
|
||||
if s.phoneGps != nil {
|
||||
s.phoneGps.Sub.Msgq.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func GetGpsSub() (gpsSub GpsSub) {
|
||||
return GpsSub{
|
||||
carGps: optionalCarGpsSubscriber(),
|
||||
carGps: optionalWrappedGpsSubscriber("starpilotCarState", CarGpsReader),
|
||||
phoneGps: optionalWrappedGpsSubscriber("starpilotNavigation", PhoneGpsReader),
|
||||
gpsLocation: NewSubscriber("gpsLocation", GpsLocationReader, true, ms.Settings.SubscriberSettings.ShadowGpsLocation),
|
||||
gpsLocationExternal: NewSubscriber("gpsLocationExternal", GpsLocationExternalReader, true, ms.Settings.SubscriberSettings.ShadowGpsLocationExternal),
|
||||
}
|
||||
}
|
||||
|
||||
func optionalCarGpsSubscriber() (subscriber *Subscriber[custom.StarPilotCarState_Gps]) {
|
||||
func optionalWrappedGpsSubscriber(service string, reader func(log.Event) (custom.StarPilotCarState_Gps, error)) (subscriber *Subscriber[custom.StarPilotCarState_Gps]) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
subscriber = nil
|
||||
slog.Warn("Optional car GPS subscriber unavailable")
|
||||
slog.Warn("Optional GPS subscriber unavailable", "service", service)
|
||||
}
|
||||
}()
|
||||
value := NewSubscriber("starpilotCarState", CarGpsReader, true, false)
|
||||
value := NewSubscriber(service, reader, true, false)
|
||||
return &value
|
||||
}
|
||||
|
||||
|
||||
@@ -2623,7 +2623,7 @@ struct Event {
|
||||
# DO change the name of the field and struct
|
||||
# DON'T change the ID (e.g. @107)
|
||||
# DON'T change which struct it points to
|
||||
customReserved0 @107 :Custom.CustomReserved0;
|
||||
starpilotNavigation @107 :Custom.StarPilotCarControl;
|
||||
customReserved1 @108 :Custom.CustomReserved1;
|
||||
starpilotCarState @109 :Custom.StarPilotCarState;
|
||||
customReserved3 @110 :Custom.CustomReserved3;
|
||||
|
||||
+2863
-2862
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
package cereal
|
||||
|
||||
import (
|
||||
"capnproto.org/go/capnp/v3"
|
||||
"pfeifer.dev/mapd/cereal/custom"
|
||||
"pfeifer.dev/mapd/cereal/log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func phoneGpsWire(t *testing.T, source, envelope uint64) []byte {
|
||||
t.Helper()
|
||||
message, segment, err := capnp.NewMessage(capnp.SingleSegment(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, err := log.NewRootEvent(segment)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event.SetLogMonoTime(envelope)
|
||||
event.SetValid(true)
|
||||
navigation, err := event.NewStarpilotNavigation()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gps, err := navigation.NewPhoneGps()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gps.SetSourceMonoTime(source)
|
||||
gps.SetHasFix(true)
|
||||
gps.SetLatitude(41)
|
||||
gps.SetLongitude(-111)
|
||||
gps.SetAltitude(100)
|
||||
gps.SetSpeed(12)
|
||||
gps.SetBearingDeg(90)
|
||||
gps.SetHorizontalAccuracy(5)
|
||||
gps.SetVerticalAccuracy(5)
|
||||
gps.SetBearingAccuracyDeg(10)
|
||||
gps.SetSpeedAccuracy(1)
|
||||
raw, err := message.Marshal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func attachPhone(s *GpsSub, mode *string) *[][]byte {
|
||||
queue := [][]byte{}
|
||||
s.phoneGps = &Subscriber[custom.StarPilotCarState_Gps]{reader: PhoneGpsReader, maxBytes: 250 * 1024, readBytes: func() []byte {
|
||||
if len(queue) == 0 {
|
||||
return nil
|
||||
}
|
||||
value := queue[0]
|
||||
queue = queue[1:]
|
||||
return value
|
||||
}}
|
||||
s.mode = func() string { return *mode }
|
||||
return &queue
|
||||
}
|
||||
|
||||
func TestPhoneGpsFollowsTheGalaxySetting(t *testing.T) {
|
||||
now := uint64(10_000_000_000)
|
||||
mode := GpsModeBackup
|
||||
s, internal, _ := testGpsSub(&now)
|
||||
car := attachCar(s)
|
||||
phone := attachPhone(s, &mode)
|
||||
|
||||
*phone = append(*phone, phoneGpsWire(t, now-300_000_000, now))
|
||||
sample, ok := s.ReadSample()
|
||||
if !ok || sample.Source != GpsSourcePhone || sample.FixMonoTime != now-300_000_000 || sample.Location.Longitude() != -111 {
|
||||
t.Fatalf("backup without comma or car GPS: %+v %v", sample, ok)
|
||||
}
|
||||
*car = append(*car, carGpsWire(t, now-1, now, true, true))
|
||||
if sample, ok = s.ReadSample(); !ok || sample.Source != GpsSourceCar {
|
||||
t.Fatal("car ranks above the phone in backup")
|
||||
}
|
||||
*internal = append(*internal, gpsWire(t, GpsSourceInternal, now, true, true, 1, 2, 5))
|
||||
if sample, ok = s.ReadSample(); !ok || sample.Source != GpsSourceInternal {
|
||||
t.Fatal("comma ranks above the phone in backup")
|
||||
}
|
||||
|
||||
mode = GpsModePhone
|
||||
now += gpsModeRefresh
|
||||
*phone = append(*phone, phoneGpsWire(t, now-100_000_000, now))
|
||||
*internal = append(*internal, gpsWire(t, GpsSourceInternal, now, true, true, 1, 2, 5))
|
||||
if sample, ok = s.ReadSample(); !ok || sample.Source != GpsSourcePhone {
|
||||
t.Fatal("prefer phone")
|
||||
}
|
||||
now += phoneFixMaxAge
|
||||
*internal = append(*internal, gpsWire(t, GpsSourceInternal, now, true, true, 1, 2, 5))
|
||||
if sample, ok = s.ReadSample(); !ok || sample.Source != GpsSourceInternal {
|
||||
t.Fatal("prefer phone falls back to comma once the phone fix expires")
|
||||
}
|
||||
|
||||
mode = GpsModeComma
|
||||
now += gpsModeRefresh
|
||||
*phone = append(*phone, phoneGpsWire(t, now, now))
|
||||
if sample, ok = s.ReadSample(); !ok || sample.Source != GpsSourceInternal {
|
||||
t.Fatal("comma and car only keeps comma GPS")
|
||||
}
|
||||
now += internalFixMaxAge + 1
|
||||
*phone = append(*phone, phoneGpsWire(t, now, now))
|
||||
if _, ok = s.ReadSample(); ok {
|
||||
t.Fatal("comma and car only must never use the phone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhoneGpsModeIsRereadAtMostOnceASecond(t *testing.T) {
|
||||
now := uint64(10_000_000_000)
|
||||
reads := 0
|
||||
s, _, _ := testGpsSub(&now)
|
||||
s.mode = func() string { reads++; return GpsModeBackup }
|
||||
s.ReadSample()
|
||||
now += gpsModeRefresh - 1
|
||||
s.ReadSample()
|
||||
if reads != 1 {
|
||||
t.Fatalf("mode reread early: %d", reads)
|
||||
}
|
||||
now++
|
||||
s.ReadSample()
|
||||
if reads != 2 {
|
||||
t.Fatalf("mode not reread: %d", reads)
|
||||
}
|
||||
}
|
||||
@@ -45,3 +45,11 @@ func CarGpsReader(evt log.Event) (custom.StarPilotCarState_Gps, error) {
|
||||
}
|
||||
return state.Gps()
|
||||
}
|
||||
|
||||
func PhoneGpsReader(evt log.Event) (custom.StarPilotCarState_Gps, error) {
|
||||
navigation, err := evt.StarpilotNavigation()
|
||||
if err != nil {
|
||||
return custom.StarPilotCarState_Gps{}, err
|
||||
}
|
||||
return navigation.PhoneGps()
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ var ServiceQueueSize = map[string]int64{
|
||||
"selfdriveState": QUEUE_SIZE_SMALL,
|
||||
"gpsLocation": QUEUE_SIZE_SMALL,
|
||||
"starpilotCarState": QUEUE_SIZE_SMALL,
|
||||
"starpilotNavigation": QUEUE_SIZE_MEDIUM,
|
||||
"gpsLocationExternal": QUEUE_SIZE_SMALL,
|
||||
"liveLocationKalman": QUEUE_SIZE_SMALL,
|
||||
"gpsNMEA": QUEUE_SIZE_SMALL,
|
||||
|
||||
+1
-1
@@ -176,7 +176,7 @@ func (s *State) ProcessGps(sample cereal.GpsSample, success bool, load MapLoader
|
||||
pos := m.PosFromLocation(location)
|
||||
s.Position = pos
|
||||
// Unknown CAN direction or speed uncertainty cannot create confident road evidence.
|
||||
if sample.Source == cereal.GpsSourceCar && (math.IsNaN(float64(location.BearingAccuracyDeg())) || math.IsInf(float64(location.BearingAccuracyDeg()), 0) || location.BearingAccuracyDeg() <= 0 || location.BearingAccuracyDeg() >= 180 || math.IsNaN(float64(location.SpeedAccuracy())) || math.IsInf(float64(location.SpeedAccuracy()), 0) || location.SpeedAccuracy() >= 100) {
|
||||
if (sample.Source == cereal.GpsSourceCar || sample.Source == cereal.GpsSourcePhone) && (math.IsNaN(float64(location.BearingAccuracyDeg())) || math.IsInf(float64(location.BearingAccuracyDeg()), 0) || location.BearingAccuracyDeg() <= 0 || location.BearingAccuracyDeg() >= 180 || math.IsNaN(float64(location.SpeedAccuracy())) || math.IsInf(float64(location.SpeedAccuracy()), 0) || location.SpeedAccuracy() >= 100) {
|
||||
s.clearRoadDerived()
|
||||
s.roadFixMonoTime = 0
|
||||
s.RoadStatus = custom.MapdOut_SampleStatus_noMatch
|
||||
|
||||
@@ -90,6 +90,8 @@ struct StarPilotCarControl @0x81c2f05a394cf4af {
|
||||
}
|
||||
version @12 :UInt16;
|
||||
}
|
||||
|
||||
phoneGps @3 :StarPilotCarState.Gps; # sourceMonoTime is comma receive time minus the phone's fix age
|
||||
}
|
||||
struct StarPilotCarParams @0xaedffd8f31e7b55d {
|
||||
alternativeExperience @0 :Int16;
|
||||
|
||||
@@ -5,17 +5,17 @@
|
||||
|
||||
namespace capnp {
|
||||
namespace schemas {
|
||||
static const ::capnp::_::AlignedData<81> b_81c2f05a394cf4af = {
|
||||
static const ::capnp::_::AlignedData<97> b_81c2f05a394cf4af = {
|
||||
{ 0, 0, 0, 0, 5, 0, 6, 0,
|
||||
175, 244, 76, 57, 90, 240, 194, 129,
|
||||
13, 0, 0, 0, 1, 0, 0, 0,
|
||||
89, 10, 85, 29, 102, 186, 38, 181,
|
||||
3, 0, 7, 0, 0, 0, 0, 0,
|
||||
4, 0, 7, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
21, 0, 0, 0, 10, 1, 0, 0,
|
||||
37, 0, 0, 0, 55, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
85, 0, 0, 0, 175, 0, 0, 0,
|
||||
85, 0, 0, 0, 231, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
99, 117, 115, 116, 111, 109, 46, 99,
|
||||
@@ -37,28 +37,35 @@ static const ::capnp::_::AlignedData<81> b_81c2f05a394cf4af = {
|
||||
111, 0, 0, 0, 0, 0, 0, 0,
|
||||
78, 97, 118, 105, 103, 97, 116, 105,
|
||||
111, 110, 0, 0, 0, 0, 0, 0,
|
||||
12, 0, 0, 0, 3, 0, 4, 0,
|
||||
16, 0, 0, 0, 3, 0, 4, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 1, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
69, 0, 0, 0, 90, 0, 0, 0,
|
||||
97, 0, 0, 0, 90, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
68, 0, 0, 0, 3, 0, 1, 0,
|
||||
80, 0, 0, 0, 2, 0, 1, 0,
|
||||
96, 0, 0, 0, 3, 0, 1, 0,
|
||||
108, 0, 0, 0, 2, 0, 1, 0,
|
||||
1, 0, 0, 0, 1, 0, 0, 0,
|
||||
0, 0, 1, 0, 1, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
77, 0, 0, 0, 146, 0, 0, 0,
|
||||
105, 0, 0, 0, 146, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
80, 0, 0, 0, 3, 0, 1, 0,
|
||||
92, 0, 0, 0, 2, 0, 1, 0,
|
||||
108, 0, 0, 0, 3, 0, 1, 0,
|
||||
120, 0, 0, 0, 2, 0, 1, 0,
|
||||
2, 0, 0, 0, 2, 0, 0, 0,
|
||||
0, 0, 1, 0, 2, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
89, 0, 0, 0, 90, 0, 0, 0,
|
||||
117, 0, 0, 0, 90, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
88, 0, 0, 0, 3, 0, 1, 0,
|
||||
100, 0, 0, 0, 2, 0, 1, 0,
|
||||
116, 0, 0, 0, 3, 0, 1, 0,
|
||||
128, 0, 0, 0, 2, 0, 1, 0,
|
||||
3, 0, 0, 0, 3, 0, 0, 0,
|
||||
0, 0, 1, 0, 3, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
125, 0, 0, 0, 74, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
124, 0, 0, 0, 3, 0, 1, 0,
|
||||
136, 0, 0, 0, 2, 0, 1, 0,
|
||||
104, 117, 100, 67, 111, 110, 116, 114,
|
||||
111, 108, 0, 0, 0, 0, 0, 0,
|
||||
16, 0, 0, 0, 0, 0, 0, 0,
|
||||
@@ -84,6 +91,15 @@ static const ::capnp::_::AlignedData<81> b_81c2f05a394cf4af = {
|
||||
176, 52, 164, 42, 71, 245, 229, 131,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
16, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
112, 104, 111, 110, 101, 71, 112, 115,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
16, 0, 0, 0, 0, 0, 0, 0,
|
||||
45, 201, 117, 88, 170, 25, 141, 145,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
16, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, }
|
||||
@@ -92,14 +108,15 @@ static const ::capnp::_::AlignedData<81> b_81c2f05a394cf4af = {
|
||||
#if !CAPNP_LITE
|
||||
static const ::capnp::_::RawSchema* const d_81c2f05a394cf4af[] = {
|
||||
&s_83e5f5472aa434b0,
|
||||
&s_918d19aa5875c92d,
|
||||
&s_be3bc47cd3284f3e,
|
||||
&s_d28ed59fbe1870aa,
|
||||
};
|
||||
static const uint16_t m_81c2f05a394cf4af[] = {0, 2, 1};
|
||||
static const uint16_t i_81c2f05a394cf4af[] = {0, 1, 2};
|
||||
static const uint16_t m_81c2f05a394cf4af[] = {0, 2, 3, 1};
|
||||
static const uint16_t i_81c2f05a394cf4af[] = {0, 1, 2, 3};
|
||||
const ::capnp::_::RawSchema s_81c2f05a394cf4af = {
|
||||
0x81c2f05a394cf4af, b_81c2f05a394cf4af.words, 81, d_81c2f05a394cf4af, m_81c2f05a394cf4af,
|
||||
3, 3, i_81c2f05a394cf4af, nullptr, nullptr, { &s_81c2f05a394cf4af, nullptr, nullptr, 0, 0, nullptr }, false
|
||||
0x81c2f05a394cf4af, b_81c2f05a394cf4af.words, 97, d_81c2f05a394cf4af, m_81c2f05a394cf4af,
|
||||
4, 4, i_81c2f05a394cf4af, nullptr, nullptr, { &s_81c2f05a394cf4af, nullptr, nullptr, 0, 0, nullptr }, false
|
||||
};
|
||||
#endif // !CAPNP_LITE
|
||||
static const ::capnp::_::AlignedData<40> b_be3bc47cd3284f3e = {
|
||||
|
||||
@@ -440,7 +440,7 @@ struct StarPilotCarControl {
|
||||
struct Navigation;
|
||||
|
||||
struct _capnpPrivate {
|
||||
CAPNP_DECLARE_STRUCT_HEADER(81c2f05a394cf4af, 0, 3)
|
||||
CAPNP_DECLARE_STRUCT_HEADER(81c2f05a394cf4af, 0, 4)
|
||||
#if !CAPNP_LITE
|
||||
static constexpr ::capnp::_::RawBrandedSchema const* brand() { return &schema->defaultBrand; }
|
||||
#endif // !CAPNP_LITE
|
||||
@@ -1663,6 +1663,9 @@ public:
|
||||
inline bool hasNavigation() const;
|
||||
inline ::cereal::StarPilotCarControl::Navigation::Reader getNavigation() const;
|
||||
|
||||
inline bool hasPhoneGps() const;
|
||||
inline ::cereal::StarPilotCarState::Gps::Reader getPhoneGps() const;
|
||||
|
||||
private:
|
||||
::capnp::_::StructReader _reader;
|
||||
template <typename, ::capnp::Kind>
|
||||
@@ -1712,6 +1715,13 @@ public:
|
||||
inline void adoptNavigation(::capnp::Orphan< ::cereal::StarPilotCarControl::Navigation>&& value);
|
||||
inline ::capnp::Orphan< ::cereal::StarPilotCarControl::Navigation> disownNavigation();
|
||||
|
||||
inline bool hasPhoneGps();
|
||||
inline ::cereal::StarPilotCarState::Gps::Builder getPhoneGps();
|
||||
inline void setPhoneGps( ::cereal::StarPilotCarState::Gps::Reader value);
|
||||
inline ::cereal::StarPilotCarState::Gps::Builder initPhoneGps();
|
||||
inline void adoptPhoneGps(::capnp::Orphan< ::cereal::StarPilotCarState::Gps>&& value);
|
||||
inline ::capnp::Orphan< ::cereal::StarPilotCarState::Gps> disownPhoneGps();
|
||||
|
||||
private:
|
||||
::capnp::_::StructBuilder _builder;
|
||||
template <typename, ::capnp::Kind>
|
||||
@@ -1733,6 +1743,7 @@ public:
|
||||
inline ::cereal::StarPilotCarControl::HUDControl::Pipeline getHudControl();
|
||||
inline ::cereal::StarPilotCarControl::SteeringLimitInfo::Pipeline getSteeringLimitInfo();
|
||||
inline ::cereal::StarPilotCarControl::Navigation::Pipeline getNavigation();
|
||||
inline ::cereal::StarPilotCarState::Gps::Pipeline getPhoneGps();
|
||||
private:
|
||||
::capnp::AnyPointer::Pipeline _typeless;
|
||||
friend class ::capnp::PipelineHook;
|
||||
@@ -12670,6 +12681,45 @@ inline ::capnp::Orphan< ::cereal::StarPilotCarControl::Navigation> StarPilotCarC
|
||||
::capnp::bounded<2>() * ::capnp::POINTERS));
|
||||
}
|
||||
|
||||
inline bool StarPilotCarControl::Reader::hasPhoneGps() const {
|
||||
return !_reader.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS).isNull();
|
||||
}
|
||||
inline bool StarPilotCarControl::Builder::hasPhoneGps() {
|
||||
return !_builder.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS).isNull();
|
||||
}
|
||||
inline ::cereal::StarPilotCarState::Gps::Reader StarPilotCarControl::Reader::getPhoneGps() const {
|
||||
return ::capnp::_::PointerHelpers< ::cereal::StarPilotCarState::Gps>::get(_reader.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS));
|
||||
}
|
||||
inline ::cereal::StarPilotCarState::Gps::Builder StarPilotCarControl::Builder::getPhoneGps() {
|
||||
return ::capnp::_::PointerHelpers< ::cereal::StarPilotCarState::Gps>::get(_builder.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS));
|
||||
}
|
||||
#if !CAPNP_LITE
|
||||
inline ::cereal::StarPilotCarState::Gps::Pipeline StarPilotCarControl::Pipeline::getPhoneGps() {
|
||||
return ::cereal::StarPilotCarState::Gps::Pipeline(_typeless.getPointerField(3));
|
||||
}
|
||||
#endif // !CAPNP_LITE
|
||||
inline void StarPilotCarControl::Builder::setPhoneGps( ::cereal::StarPilotCarState::Gps::Reader value) {
|
||||
::capnp::_::PointerHelpers< ::cereal::StarPilotCarState::Gps>::set(_builder.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS), value);
|
||||
}
|
||||
inline ::cereal::StarPilotCarState::Gps::Builder StarPilotCarControl::Builder::initPhoneGps() {
|
||||
return ::capnp::_::PointerHelpers< ::cereal::StarPilotCarState::Gps>::init(_builder.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS));
|
||||
}
|
||||
inline void StarPilotCarControl::Builder::adoptPhoneGps(
|
||||
::capnp::Orphan< ::cereal::StarPilotCarState::Gps>&& value) {
|
||||
::capnp::_::PointerHelpers< ::cereal::StarPilotCarState::Gps>::adopt(_builder.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS), kj::mv(value));
|
||||
}
|
||||
inline ::capnp::Orphan< ::cereal::StarPilotCarState::Gps> StarPilotCarControl::Builder::disownPhoneGps() {
|
||||
return ::capnp::_::PointerHelpers< ::cereal::StarPilotCarState::Gps>::disown(_builder.getPointerField(
|
||||
::capnp::bounded<3>() * ::capnp::POINTERS));
|
||||
}
|
||||
|
||||
inline ::cereal::StarPilotCarControl::HUDControl::AudibleAlert StarPilotCarControl::HUDControl::Reader::getAudibleAlert() const {
|
||||
return _reader.getDataField< ::cereal::StarPilotCarControl::HUDControl::AudibleAlert>(
|
||||
::capnp::bounded<0>() * ::capnp::ELEMENTS);
|
||||
|
||||
@@ -15,8 +15,24 @@ SPARSE_CHUNK_FMT = struct.Struct('H2xI4x')
|
||||
AGNOS_MANIFEST_FILE = "openpilot/common/hardware/comma/agnos.json"
|
||||
|
||||
|
||||
def phone_uplink_active() -> bool:
|
||||
try:
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active as active
|
||||
except ImportError:
|
||||
# launch_chffrplus.sh runs this file directly from the old tree before PYTHONPATH is set.
|
||||
try:
|
||||
with open('/run/starpilot-companion/status.json') as f:
|
||||
state = json.load(f)
|
||||
return state.get('active') is True and 0 <= time.monotonic() - state['updated'] < 15
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return False
|
||||
return active()
|
||||
|
||||
|
||||
class StreamingDecompressor:
|
||||
def __init__(self, url: str) -> None:
|
||||
if phone_uplink_active():
|
||||
raise requests.ConnectionError("AGNOS downloads are disabled on the phone uplink")
|
||||
self.buf = b""
|
||||
|
||||
self.req = requests.get(url, stream=True, headers={'Accept-Encoding': 'identity'}, timeout=60)
|
||||
@@ -26,6 +42,9 @@ class StreamingDecompressor:
|
||||
self.sha256 = hashlib.sha256()
|
||||
|
||||
def read(self, length: int) -> bytes:
|
||||
if phone_uplink_active():
|
||||
self.req.close()
|
||||
raise requests.ConnectionError("Phone uplink activated; AGNOS download stopped")
|
||||
while len(self.buf) < length and not self.eof:
|
||||
if self.decompressor.needs_input:
|
||||
self.req.raise_for_status()
|
||||
|
||||
@@ -242,6 +242,10 @@ class Modem:
|
||||
return True
|
||||
return self._read_param("GsmRoaming") == "1"
|
||||
|
||||
def _is_cellular_disabled(self) -> bool:
|
||||
# data only: the modem stays registered and the SIM is left alone
|
||||
return self._read_param("CellularDisabled") == "1"
|
||||
|
||||
def _publish_state(self, **kwargs):
|
||||
self.S.update(kwargs)
|
||||
self.S["seconds_since_boot"] = time.monotonic()
|
||||
@@ -387,6 +391,8 @@ class Modem:
|
||||
|
||||
if reg in ("home", "roaming") and greg in ("home", "roaming"):
|
||||
self._publish_state(registration=reg)
|
||||
if self._is_cellular_disabled():
|
||||
return State.SEARCHING
|
||||
return State.CONNECTING
|
||||
|
||||
if reg != self.S.get("registration"):
|
||||
@@ -429,6 +435,9 @@ class Modem:
|
||||
if new_roaming != self._roaming_allowed:
|
||||
logging.info(f"roaming changed: {self._roaming_allowed} -> {new_roaming}")
|
||||
return True
|
||||
if self._is_cellular_disabled():
|
||||
logging.info("cellular data disabled")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_iccid(self, state):
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
from unittest import mock
|
||||
|
||||
from openpilot.common.hardware.comma.modem import Modem, State
|
||||
|
||||
|
||||
def _modem(params):
|
||||
m = Modem()
|
||||
m._read_param = lambda key: params.get(key, "") # ty: ignore[invalid-assignment] # test double
|
||||
m._publish_state = lambda **kw: m.S.update(kw) # ty: ignore[invalid-assignment] # test double
|
||||
m._atv = lambda cmd, pfx: '2,1' if cmd in ("AT+CREG?", "AT+CGREG?") else None # ty: ignore[invalid-assignment] # test double
|
||||
return m
|
||||
|
||||
|
||||
def test_searching_dials_when_enabled():
|
||||
assert _modem({})._do_searching() == State.CONNECTING
|
||||
|
||||
|
||||
def test_searching_does_not_dial_when_disabled():
|
||||
m = _modem({"CellularDisabled": "1"})
|
||||
assert m._do_searching() == State.SEARCHING
|
||||
assert m.S["registration"] == "home"
|
||||
|
||||
|
||||
def test_connected_hangs_up_when_disabled():
|
||||
params = {}
|
||||
m = _modem(params)
|
||||
m._ppp = mock.MagicMock(has_exited=lambda: False)
|
||||
m._poll = lambda: None
|
||||
m._roaming_allowed = False # as read during init
|
||||
with mock.patch("os.path.exists", return_value=True):
|
||||
assert m._do_connected() == State.CONNECTED
|
||||
params["CellularDisabled"] = "1"
|
||||
assert m._do_connected() == State.DISCONNECTING
|
||||
@@ -181,6 +181,7 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"CarParamsCache", {CLEAR_ON_MANAGER_START, BYTES}},
|
||||
{"CarParamsPersistent", {PERSISTENT, BYTES}},
|
||||
{"CarParamsPrevRoute", {PERSISTENT, BYTES}},
|
||||
{"CellularDisabled", {PERSISTENT, BOOL}},
|
||||
{"CompletedTrainingVersion", {PERSISTENT, STRING, "0"}},
|
||||
{"ControlsReady", {CLEAR_ON_MANAGER_START | CLEAR_ON_ONROAD_TRANSITION, BOOL}},
|
||||
{"CurrentBootlog", {PERSISTENT, STRING}},
|
||||
|
||||
@@ -411,6 +411,9 @@ def main(demo=False):
|
||||
# the joining model drives as small_model until the big model is ready
|
||||
model = HOOKS.attach(model, small_model, vipc_client_main.width, vipc_client_main.height)
|
||||
jetlink_model = model if model is not small_model else None
|
||||
if jetlink_model is not None:
|
||||
from openpilot.starpilot.models.jetlink_status import start_latency_writer
|
||||
start_latency_writer(jetlink_model)
|
||||
params.put_bool("ChestnutLoading", False)
|
||||
cloudlog.warning(f"models loaded in {time.monotonic() - st:.1f}s, modeld starting")
|
||||
release_compile_pool()
|
||||
|
||||
@@ -29,6 +29,10 @@ DESCRIPTIONS = {
|
||||
"Enabling Experimental mode is recommended when enabling openpilot longitudinal control alpha. " +
|
||||
"Changing this setting will restart openpilot if the car is powered on."
|
||||
),
|
||||
'cellular_disabled': tr_noop(
|
||||
"Hang up cellular data and don't redial. The modem stays registered and the SIM is untouched. " +
|
||||
"Remote access over cellular data is unavailable while this is enabled."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +98,13 @@ class DeveloperLayout(Widget):
|
||||
)
|
||||
self._on_enable_ui_debug(self._params.get_bool("ShowDebugInfo"))
|
||||
|
||||
self._cellular_disabled_toggle = toggle_item(
|
||||
lambda: tr("Disable Cellular"),
|
||||
description=lambda: tr(DESCRIPTIONS["cellular_disabled"]),
|
||||
initial_state=self._params.get_bool("CellularDisabled"),
|
||||
callback=self._on_cellular_disabled,
|
||||
)
|
||||
|
||||
self._scroller = Scroller([
|
||||
button_item("Arabesque", lambda: tr("OPEN"), callback=on_open_arabesque,
|
||||
enabled=lambda: on_open_arabesque is not None and arabesque_available()),
|
||||
@@ -105,6 +116,7 @@ class DeveloperLayout(Widget):
|
||||
self._lat_maneuver_toggle,
|
||||
self._alpha_long_toggle,
|
||||
self._ui_debug_toggle,
|
||||
self._cellular_disabled_toggle,
|
||||
], line_separator=True, spacing=0)
|
||||
|
||||
# Toggles should be not available to change in onroad state
|
||||
@@ -152,6 +164,7 @@ class DeveloperLayout(Widget):
|
||||
("LateralManeuverMode", self._lat_maneuver_toggle),
|
||||
("AlphaLongitudinalEnabled", self._alpha_long_toggle),
|
||||
("ShowDebugInfo", self._ui_debug_toggle),
|
||||
("CellularDisabled", self._cellular_disabled_toggle),
|
||||
):
|
||||
item.action_item.set_state(self._params.get_bool(key))
|
||||
|
||||
@@ -160,6 +173,9 @@ class DeveloperLayout(Widget):
|
||||
gui_app.set_show_touches(state)
|
||||
gui_app.set_show_fps(state)
|
||||
|
||||
def _on_cellular_disabled(self, state: bool):
|
||||
self._params.put_bool("CellularDisabled", state, block=True)
|
||||
|
||||
def _on_enable_adb(self, state: bool):
|
||||
self._params.put_bool("AdbEnabled", state, block=True)
|
||||
|
||||
|
||||
@@ -107,6 +107,9 @@ class DeveloperLayoutMici(NavScroller):
|
||||
self._debug_mode_toggle = BigParamControl("ui debug mode", "ShowDebugInfo",
|
||||
toggle_callback=lambda checked: (gui_app.set_show_touches(checked), gui_app.set_show_fps(checked)),
|
||||
description="Show touch locations and the UI frame rate.")
|
||||
self._cellular_off_toggle = BigParamControl("disable cellular", "CellularDisabled",
|
||||
description="Hang up cellular data and don't redial. The modem stays registered and the " +
|
||||
"SIM is untouched. Remote access over cellular data is unavailable while this is enabled.")
|
||||
|
||||
self._scroller.add_widgets([
|
||||
self._adb_toggle,
|
||||
@@ -117,6 +120,7 @@ class DeveloperLayoutMici(NavScroller):
|
||||
self._alpha_long_toggle,
|
||||
self._debug_mode_toggle,
|
||||
self._navigation_toggle,
|
||||
self._cellular_off_toggle,
|
||||
])
|
||||
|
||||
# Toggle lists
|
||||
@@ -127,6 +131,7 @@ class DeveloperLayoutMici(NavScroller):
|
||||
("AlphaLongitudinalEnabled", self._alpha_long_toggle),
|
||||
("ShowDebugInfo", self._debug_mode_toggle),
|
||||
("NavigationEnabled", self._navigation_toggle),
|
||||
("CellularDisabled", self._cellular_off_toggle),
|
||||
)
|
||||
onroad_blocked_toggles = (self._adb_toggle, self._joystick_toggle)
|
||||
release_blocked_toggles = (self._joystick_toggle, self._alpha_long_toggle, self._navigation_toggle)
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Optional parked reporting; failures never participate in driving process health."""
|
||||
from datetime import datetime, UTC
|
||||
import math
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from openpilot.starpilot.gps.source import GPS_SOURCES, select_location
|
||||
from openpilot.starpilot.gps.source import GPS_SERVICES, select_location
|
||||
from openpilot.starpilot.analytics.report import ReportClient, build_payload, fetch_branch_commit
|
||||
|
||||
SERVICES = ('deviceState', 'carParams', 'carState', 'carControl', 'selfdriveState',
|
||||
'modelV2', 'drivingModelData', 'managerState', *GPS_SOURCES)
|
||||
'modelV2', 'drivingModelData', 'managerState', *GPS_SERVICES)
|
||||
COUNTERS = ('drives', 'meters', 'seconds', 'current_months_meters', 'total_tracked_seconds',
|
||||
'total_lateral_seconds', 'total_longitudinal_seconds', 'total_aol_seconds')
|
||||
|
||||
@@ -247,7 +248,7 @@ class AnalyticsWorker:
|
||||
self.client.close()
|
||||
|
||||
|
||||
def start_optional_worker():
|
||||
def build_worker():
|
||||
from openpilot.common.hardware import COMMA_HARDWARE, HARDWARE
|
||||
if not COMMA_HARDWARE:
|
||||
return None
|
||||
@@ -257,4 +258,19 @@ def start_optional_worker():
|
||||
metadata = get_build_metadata()
|
||||
return AnalyticsWorker(Params(), messaging.SubMaster(list(SERVICES)),
|
||||
metadata={'branch': metadata.channel, 'device': HARDWARE.get_device_type(),
|
||||
'metrics': {'commit': metadata.openpilot.git_commit}}).start()
|
||||
'metrics': {'commit': metadata.openpilot.git_commit}})
|
||||
|
||||
|
||||
def main():
|
||||
# Own process so the manager's heap stays small when it forks the onroad daemons.
|
||||
worker = build_worker()
|
||||
if worker is None:
|
||||
return
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(sig, lambda *_: worker.stop_event.set())
|
||||
worker.run()
|
||||
worker.persist()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -113,12 +113,24 @@ def test_startup_onroad_never_contacts_transport():
|
||||
|
||||
def test_host_optional_hook_does_not_construct_network_worker():
|
||||
from unittest.mock import patch
|
||||
from openpilot.starpilot.analytics.runtime import start_optional_worker
|
||||
from openpilot.starpilot.analytics.runtime import build_worker
|
||||
with patch('openpilot.common.hardware.COMMA_HARDWARE', False), patch('openpilot.starpilot.analytics.runtime.AnalyticsWorker') as factory:
|
||||
assert start_optional_worker() is None
|
||||
assert build_worker() is None
|
||||
factory.assert_not_called()
|
||||
|
||||
|
||||
def test_process_saves_counters_when_stopped():
|
||||
from unittest.mock import patch
|
||||
from openpilot.starpilot.analytics import runtime
|
||||
obj, values, _source, _clock, client = worker()
|
||||
values.pop('UsageStatsState', None)
|
||||
obj.stop_event.set()
|
||||
with patch.object(runtime, 'build_worker', return_value=obj), patch('signal.signal'):
|
||||
runtime.main()
|
||||
assert 'UsageStatsState' in values
|
||||
client.close.assert_called()
|
||||
|
||||
|
||||
def test_malformed_saved_state_has_finite_sanitized_counters():
|
||||
obj, values, source, clock, client = worker()
|
||||
values['UsageStatsState'] = {'version': 1, 'counters': 'bad', 'region': {'latitude': 12.345678}, 'extra': 'x' * 100000}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Phone uplink protocol and local-only lease. Safe to import from data guards."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.galaxy.hotspot import ADDRESS, NETWORK, atomic_json, bounded_json
|
||||
|
||||
COMPANION_PROTOCOL = 1
|
||||
LEASE_SECONDS = 15
|
||||
LEASE_PATH = Path('/dev/shm/starpilot-companion.json')
|
||||
STATUS_PATH = Path('/run/starpilot-companion/status.json')
|
||||
APK_PATH = Path(__file__).parent / 'bin/starpilot-companion.apk'
|
||||
UPLOAD_DENY_HOSTS = ['commadataci.blob.core.windows.net', 'commadata2.blob.core.windows.net',
|
||||
'upload.comma.ai', 'logs.comma.ai']
|
||||
|
||||
|
||||
def fresh(value, now):
|
||||
updated = value.get('updated')
|
||||
return type(updated) in (int, float) and math.isfinite(updated) and 0 <= now - updated < LEASE_SECONDS
|
||||
|
||||
|
||||
_status_cache: dict = {}
|
||||
|
||||
|
||||
def phone_uplink_active(path=STATUS_PATH, clock=time.monotonic) -> bool:
|
||||
# Guards call this per upload chunk; re-parse only when the file is replaced (atomic_json renames).
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
key = (st.st_ino, st.st_mtime_ns, st.st_size)
|
||||
cached = _status_cache.get(str(path))
|
||||
if cached is None or cached[0] != key:
|
||||
state = bounded_json(path)
|
||||
cached = (key, state.get('active') is True, state)
|
||||
_status_cache[str(path)] = cached
|
||||
return cached[1] and fresh(cached[2], clock())
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def validate_lease(value):
|
||||
if type(value) is not dict:
|
||||
raise ValueError('Invalid companion heartbeat')
|
||||
try:
|
||||
peer = ipaddress.ip_address(value['peer'])
|
||||
subnet = ipaddress.ip_network(NETWORK)
|
||||
if peer not in subnet or peer in (subnet.network_address, subnet.broadcast_address, ipaddress.ip_address(ADDRESS)):
|
||||
raise ValueError('A Galaxy hotspot peer is required')
|
||||
if type(value['port']) is not int or not 1024 <= value['port'] <= 65535:
|
||||
raise ValueError('Invalid SOCKS port')
|
||||
if type(value['protocol']) is not int or value['protocol'] != COMPANION_PROTOCOL:
|
||||
raise ValueError('Update the companion app')
|
||||
for key in ('user', 'pass'):
|
||||
if type(value[key]) is not str or not re.fullmatch(r'[A-Za-z0-9_-]{16,128}', value[key]):
|
||||
raise ValueError('Invalid SOCKS credentials')
|
||||
if type(value['version']) is not str or not re.fullmatch(r'[0-9A-Za-z._-]{1,40}', value['version']):
|
||||
raise ValueError('Invalid app version')
|
||||
if type(value.get('bytes', 0)) is not int or not 0 <= value.get('bytes', 0) <= 2**63 - 1:
|
||||
raise ValueError('Invalid byte counter')
|
||||
except (KeyError, TypeError) as error:
|
||||
raise ValueError('Invalid companion heartbeat') from error
|
||||
return {key: value[key] for key in ('peer', 'port', 'protocol', 'user', 'pass', 'version')} | {'bytes': value.get('bytes', 0)}
|
||||
|
||||
|
||||
class CompanionRegistry:
|
||||
def __init__(self, root=None, lease_path=LEASE_PATH, status_path=STATUS_PATH, clock=time.monotonic):
|
||||
if root is None:
|
||||
from openpilot.starpilot.storage import starpilot_storage_root
|
||||
root = starpilot_storage_root() / 'companion'
|
||||
self.path = Path(root) / 'settings.json'
|
||||
self.lease_path, self.status_path, self.clock = lease_path, status_path, clock
|
||||
self.lock = threading.Lock()
|
||||
|
||||
def enabled(self):
|
||||
try:
|
||||
return bounded_json(self.path).get('enabled') is True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
def save(self, payload):
|
||||
if type(payload) is not dict or set(payload) != {'enabled'} or type(payload['enabled']) is not bool:
|
||||
raise ValueError('Expected enabled boolean')
|
||||
with self.lock:
|
||||
atomic_json(self.path, payload)
|
||||
if not payload['enabled']:
|
||||
self.lease_path.unlink(missing_ok=True)
|
||||
return self.snapshot()
|
||||
|
||||
def heartbeat(self, payload, peer, remote=False):
|
||||
if remote:
|
||||
raise PermissionError('Phone uplink is only available on the Galaxy hotspot')
|
||||
if type(payload) is not dict or set(payload) - {'port', 'protocol', 'user', 'pass', 'version', 'bytes'}:
|
||||
raise ValueError('Invalid heartbeat fields')
|
||||
lease = validate_lease({**payload, 'peer': peer})
|
||||
with self.lock:
|
||||
previous = self.lease()
|
||||
# The same hotspot peer may rotate credentials (app restart); only a different phone is refused.
|
||||
if previous and previous['peer'] != peer:
|
||||
raise FileExistsError('Another phone is sharing data')
|
||||
enabled = self.enabled()
|
||||
if enabled:
|
||||
atomic_json(self.lease_path, {**lease, 'updated': self.clock()})
|
||||
return {'ok': True, 'enabled': enabled, 'active': phone_uplink_active(self.status_path, self.clock),
|
||||
'protocol': COMPANION_PROTOCOL, 'uploadDenyHosts': UPLOAD_DENY_HOSTS}
|
||||
|
||||
def lease(self):
|
||||
try:
|
||||
value = bounded_json(self.lease_path)
|
||||
return {**validate_lease(value), 'updated': value['updated']} if fresh(value, self.clock()) else None
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
def snapshot(self):
|
||||
lease = self.lease()
|
||||
try:
|
||||
shipped = APK_PATH.with_suffix('.apk.version').read_text().strip()
|
||||
except OSError:
|
||||
shipped = ''
|
||||
active = phone_uplink_active(self.status_path, self.clock)
|
||||
reason = ''
|
||||
try:
|
||||
runtime = bounded_json(self.status_path)
|
||||
if fresh(runtime, self.clock()) and runtime.get('state') == 'error':
|
||||
reason = str(runtime.get('reason', 'Phone uplink unavailable'))[:256]
|
||||
except (OSError, ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
return {'protocol': COMPANION_PROTOCOL, 'enabled': self.enabled(), 'connected': lease is not None,
|
||||
'active': active, 'state': 'active' if active else ('error' if reason else 'waiting') if self.enabled() else 'disabled',
|
||||
'reason': reason,
|
||||
'bytes': lease['bytes'] if lease else 0, 'version': lease['version'] if lease else None,
|
||||
'appAvailable': APK_PATH.is_file(), 'shippedVersion': shipped,
|
||||
'updateAvailable': bool(lease and shipped and lease['version'] != shipped)}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Unprivileged manager process; EOF or expired heartbeats revoke root's lease."""
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.galaxy.companion import CompanionRegistry
|
||||
from openpilot.starpilot.galaxy.hotspot import STATUS_PATH, bounded_json
|
||||
|
||||
|
||||
def stop_worker(child):
|
||||
try:
|
||||
child.stdin.close()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
child.wait(timeout=12)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # The root worker independently expires its pipe lease.
|
||||
|
||||
|
||||
def main():
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.hardware import PC
|
||||
if PC or os.environ.get('OPENPILOT_PREFIX'):
|
||||
raise RuntimeError('Uplink requires the primary comma instance')
|
||||
registry = CompanionRegistry()
|
||||
stop = threading.Event()
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
signal.signal(sig, lambda *_: stop.set())
|
||||
child = None
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
hotspot = bounded_json(STATUS_PATH)
|
||||
enabled = registry.enabled() and hotspot.get('active') is True and 0 <= time.monotonic() - hotspot['updated'] < 10
|
||||
lease = registry.lease() if enabled else None
|
||||
if child is not None and (not lease or child.poll() is not None):
|
||||
stop_worker(child)
|
||||
child = None
|
||||
if lease and child is None:
|
||||
child = subprocess.Popen(['sudo', '-n', 'systemd-run', '--quiet', '--pipe', '--wait', '--collect',
|
||||
'--unit=starpilot-companion-uplink', '--property=TimeoutStopSec=8',
|
||||
'--property=KillMode=control-group', '--working-directory=' + str(BASEDIR),
|
||||
sys.executable, '-u', '-m', 'openpilot.starpilot.galaxy.companion_uplink_worker'],
|
||||
cwd=BASEDIR, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, bufsize=0)
|
||||
if child is not None:
|
||||
child.stdin.write(json.dumps({'lease': lease, 'updated': time.monotonic()}).encode() + b'\n')
|
||||
child.stdin.flush()
|
||||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
||||
if child is not None:
|
||||
stop_worker(child)
|
||||
child = None
|
||||
stop.wait(1)
|
||||
finally:
|
||||
if child is not None:
|
||||
stop_worker(child)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Root, pipe-leased phone uplink. All network changes are runtime-only.
|
||||
|
||||
hev-socks5-tunnel 2.18.0, static Linux ARM64 release.
|
||||
SHA256: e44a6965cd718d98829f344e9f300a5664d12020557127b7469bee7c8f3098b0
|
||||
Policy rules preserve every main-table connected route without modifying modem
|
||||
or Android Auto profiles, including when a dead modem default route remains.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.galaxy.companion import STATUS_PATH, fresh, validate_lease
|
||||
from openpilot.starpilot.galaxy.hotspot import atomic_json
|
||||
|
||||
INTERFACE = 'spuplink0'
|
||||
RUNTIME = STATUS_PATH.parent
|
||||
TABLE = '18082'
|
||||
BINARY = Path(__file__).parent / 'bin/hev-socks5-tunnel_linux_arm64'
|
||||
PROBE_INTERVAL = 15 # Re-probe other interfaces only this often unless the routes change.
|
||||
|
||||
|
||||
def tunnel_config(lease):
|
||||
lease = validate_lease(lease)
|
||||
# JSON is valid YAML; credentials cannot introduce additional configuration.
|
||||
return json.dumps({'tunnel': {'name': INTERFACE, 'mtu': 1500, 'ipv4': '198.18.0.1'},
|
||||
'socks5': {'address': lease['peer'], 'port': lease['port'], 'udp': 'udp',
|
||||
'username': lease['user'], 'password': lease['pass']},
|
||||
'mapdns': {'address': '198.18.0.2', 'port': 53, 'network': '198.19.0.0',
|
||||
'netmask': '255.255.0.0', 'cache-size': 10000}})
|
||||
|
||||
|
||||
def command(args, check=True):
|
||||
return subprocess.run(args, check=check, capture_output=True, text=True, timeout=3)
|
||||
|
||||
|
||||
def other_internet(routes, probe=None):
|
||||
def tcp_probe(interface):
|
||||
# Numeric probes bypass mapped DNS and bind to the original interface.
|
||||
for address in ('1.1.1.1', '8.8.8.8'):
|
||||
try:
|
||||
with socket.socket() as sock:
|
||||
sock.settimeout(0.7)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, interface.encode() + b'\0')
|
||||
sock.connect((address, 443))
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
probe = probe or tcp_probe
|
||||
devices = {r['dev'] for r in routes if r.get('dst', 'default') == 'default' and r.get('dev') not in (None, INTERFACE, 'galaxy0')}
|
||||
return any(probe(device) for device in sorted(devices))
|
||||
|
||||
|
||||
class Backend:
|
||||
def __init__(self):
|
||||
self.child = None
|
||||
self.owned = False
|
||||
|
||||
def status(self, active, reason=''):
|
||||
atomic_json(STATUS_PATH, {'active': active, 'updated': time.monotonic(),
|
||||
'state': 'active' if active else 'error' if reason else 'waiting', 'reason': reason}, mode=0o644)
|
||||
|
||||
def stop(self):
|
||||
# Keep the guard active until cleanup succeeds. The ownership marker lets a
|
||||
# replacement worker recover its own rules after SIGKILL or a tool timeout.
|
||||
failed = False
|
||||
|
||||
def cleanup(args):
|
||||
nonlocal failed
|
||||
try:
|
||||
command(args, False)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
failed = True
|
||||
|
||||
if self.owned:
|
||||
cleanup(['ip', 'rule', 'del', 'priority', '18001', 'table', TABLE])
|
||||
cleanup(['ip', 'rule', 'del', 'priority', '18000', 'lookup', 'main', 'suppress_prefixlength', '0'])
|
||||
cleanup(['resolvectl', 'revert', INTERFACE])
|
||||
cleanup(['ip', 'route', 'flush', 'table', TABLE])
|
||||
if self.child is not None:
|
||||
self.child.terminate()
|
||||
try:
|
||||
self.child.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.child.kill()
|
||||
self.child.wait(timeout=3)
|
||||
cleanup(['ip', 'link', 'delete', INTERFACE])
|
||||
cleanup(['resolvectl', 'flush-caches'])
|
||||
try:
|
||||
remaining = json.loads(command(['ip', '-j', 'rule']).stdout)
|
||||
failed |= any(rule.get('priority') in (18000, 18001) for rule in remaining)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
failed = True
|
||||
self.child = None
|
||||
if failed:
|
||||
raise RuntimeError('Uplink cleanup incomplete; worker restart will retry')
|
||||
self.owned = False
|
||||
(RUNTIME / 'owned').unlink(missing_ok=True)
|
||||
self.status(False)
|
||||
|
||||
def start(self, lease, routes):
|
||||
reserved = ipaddress.ip_network('198.18.0.0/15')
|
||||
if any(r.get('dst', 'default') != 'default' and r.get('dev') != INTERFACE and
|
||||
ipaddress.ip_network(r['dst'], strict=False).overlaps(reserved) for r in routes):
|
||||
raise RuntimeError('Mapped DNS subnet conflicts with an existing route')
|
||||
if Path('/sys/class/net/' + INTERFACE).exists():
|
||||
raise RuntimeError('Tunnel interface already exists')
|
||||
rules = json.loads(command(['ip', '-j', 'rule']).stdout)
|
||||
if any(r.get('priority') in (18000, 18001) for r in rules):
|
||||
raise RuntimeError('Policy routing priorities already in use')
|
||||
if json.loads(command(['ip', '-j', 'route', 'show', 'table', TABLE], False).stdout or '[]'):
|
||||
raise RuntimeError('Policy routing table already in use')
|
||||
config = RUNTIME / 'tunnel.json'
|
||||
atomic_json(config, json.loads(tunnel_config(lease)))
|
||||
(RUNTIME / 'owned').touch(mode=0o600)
|
||||
self.owned = True
|
||||
self.child = subprocess.Popen([str(BINARY), str(config)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
deadline = time.monotonic() + 3
|
||||
while not Path('/sys/class/net/' + INTERFACE).exists():
|
||||
if self.child.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError('Tunnel failed to start')
|
||||
time.sleep(0.05)
|
||||
command(['nmcli', 'device', 'set', INTERFACE, 'managed', 'no'])
|
||||
command(['ip', 'link', 'set', INTERFACE, 'up'])
|
||||
command(['ip', 'route', 'add', '198.18.0.0/15', 'dev', INTERFACE])
|
||||
command(['ip', 'route', 'add', 'default', 'dev', INTERFACE, 'metric', '2000', 'table', TABLE])
|
||||
if 'updated' in lease and not fresh(lease, time.monotonic()):
|
||||
raise RuntimeError('Phone lease expired during tunnel startup')
|
||||
# Publish guard before enabling routing so upload loops cannot race activation.
|
||||
self.status(True)
|
||||
command(['ip', 'rule', 'add', 'priority', '18000', 'lookup', 'main', 'suppress_prefixlength', '0'])
|
||||
command(['ip', 'rule', 'add', 'priority', '18001', 'table', TABLE])
|
||||
command(['resolvectl', 'dns', INTERFACE, '198.18.0.2'])
|
||||
command(['resolvectl', 'domain', INTERFACE, '~.'])
|
||||
command(['resolvectl', 'default-route', INTERFACE, 'yes'])
|
||||
command(['resolvectl', 'flush-caches'])
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
raise PermissionError('Companion worker requires root')
|
||||
RUNTIME.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
with (RUNTIME / 'lock').open('w') as lock:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
backend = Backend()
|
||||
if (RUNTIME / 'owned').exists():
|
||||
backend.owned = True
|
||||
backend.stop()
|
||||
stop = False
|
||||
|
||||
def stopped(*_):
|
||||
nonlocal stop
|
||||
stop = True
|
||||
|
||||
signal.signal(signal.SIGTERM, stopped)
|
||||
signal.signal(signal.SIGINT, stopped)
|
||||
buffer, current, last_input = b'', None, time.monotonic()
|
||||
message = None
|
||||
reason = ''
|
||||
probed_routes, probed_at, has_other = None, 0., False
|
||||
try:
|
||||
while not stop:
|
||||
if not select.select([sys.stdin], [], [], 1)[0]:
|
||||
if time.monotonic() - last_input > 8 or (message and message.get('lease') and not fresh(message['lease'], time.monotonic())):
|
||||
break
|
||||
continue
|
||||
data = os.read(sys.stdin.fileno(), 8192)
|
||||
if not data:
|
||||
break
|
||||
buffer += data
|
||||
if len(buffer) > 16384:
|
||||
break
|
||||
if b'\n' not in buffer:
|
||||
if time.monotonic() - last_input > 8:
|
||||
break
|
||||
continue
|
||||
while b'\n' in buffer:
|
||||
raw, buffer = buffer.split(b'\n', 1)
|
||||
message = json.loads(raw)
|
||||
last_input = time.monotonic()
|
||||
if not fresh(message, time.monotonic()):
|
||||
break
|
||||
lease = message.get('lease')
|
||||
raw_routes = command(['ip', '-j', '-4', 'route', 'show', 'table', 'main']).stdout
|
||||
routes = json.loads(raw_routes)
|
||||
usable = lease and Path('/sys/class/net/galaxy0').exists() and fresh(lease, time.monotonic())
|
||||
if usable and (raw_routes != probed_routes or time.monotonic() - probed_at > PROBE_INTERVAL):
|
||||
# Probes to dead default routes can block for seconds; avoid repeating them every heartbeat.
|
||||
has_other = other_internet(routes)
|
||||
probed_routes, probed_at = raw_routes, time.monotonic()
|
||||
usable = usable and not has_other
|
||||
identity = tuple(validate_lease(lease)[key] for key in ('peer', 'port', 'user', 'pass')) if usable else None
|
||||
if identity != current or (backend.child and backend.child.poll() is not None):
|
||||
backend.stop()
|
||||
current = None
|
||||
if identity:
|
||||
backend.start(lease, routes)
|
||||
current = identity
|
||||
backend.status(current is not None)
|
||||
print(json.dumps({'active': current is not None}), flush=True)
|
||||
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
|
||||
reason = 'Phone internet setup failed. Check the installed tunnel binary and companion service.'
|
||||
finally:
|
||||
backend.stop()
|
||||
if reason:
|
||||
backend.status(False, reason)
|
||||
(RUNTIME / 'tunnel.json').unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -8,11 +8,25 @@ from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState
|
||||
|
||||
|
||||
AA_WIFI_KEY = "AndroidAutoDedicatedWifi"
|
||||
AA_WIFI_DESCRIPTION = ("Experimental. Join the car's Wi-Fi on a second interface so the comma stays on home Wi-Fi or your " +
|
||||
"phone hotspot during wireless Android Auto. Applies from the next Android Auto connection.")
|
||||
|
||||
|
||||
class DeveloperSettingsOwner:
|
||||
def __init__(self, params, authorized, *, panda_wake=None):
|
||||
def __init__(self, params, authorized, *, panda_wake=None, android_auto=None):
|
||||
self.params = params
|
||||
self.authorized = authorized
|
||||
self.panda_wake = panda_wake
|
||||
self.android_auto = android_auto # returns the Android Auto daemon client
|
||||
|
||||
def _android_auto_enabled(self):
|
||||
if self.android_auto is None:
|
||||
return None
|
||||
try:
|
||||
return bool(self.android_auto().call("status")["status"].get("dedicated_wifi_interface"))
|
||||
except (OSError, RuntimeError, ValueError, KeyError, TypeError):
|
||||
return None # the service is off; there is nothing to configure
|
||||
|
||||
def snapshot(self):
|
||||
raw, readable = read_saved(self.params, KEY, 8)
|
||||
@@ -25,14 +39,32 @@ class DeveloperSettingsOwner:
|
||||
firmware_rows = (() if self.panda_wake is None else
|
||||
tuple(replace(item, vehicle_fingerprint=item.capability[0])
|
||||
for item in self.panda_wake.rows(parked) if item.key == "IgnoreIgnitionLine"))
|
||||
enabled = self._android_auto_enabled()
|
||||
android_auto_rows = () if enabled is None else (
|
||||
FeatureRow(AA_WIFI_KEY, "Android Auto: Stay on Wi-Fi", "On" if enabled else "Off", b"1" if enabled else b"0", ("Off", "On"),
|
||||
available=parked, reason=AA_WIFI_DESCRIPTION if parked else "Park the vehicle to change this setting.",
|
||||
default_value="On"),)
|
||||
summary = "Changes apply next drive. Panda firmware changes require a device restart." if firmware_rows else "Changes apply next drive."
|
||||
return FeatureSettingsState("developer", "Developer driving options", summary, (row, *firmware_rows), parked)
|
||||
return FeatureSettingsState("developer", "Developer driving options", summary, (row, *firmware_rows, *android_auto_rows), parked)
|
||||
|
||||
def apply(self, request):
|
||||
if request.key == "IgnoreIgnitionLine":
|
||||
return self.panda_wake is not None and self.panda_wake.apply(request)
|
||||
if request.key == AA_WIFI_KEY:
|
||||
return self._apply_android_auto(request)
|
||||
if request.key != KEY or request.value not in ("Off", "On") or request.expected not in (None, b"0", b"1"):
|
||||
return False
|
||||
result = commit_exact(self.params, key=KEY, max_bytes=8, raw=b"1" if request.value == "On" else b"0",
|
||||
expected=request.expected, authorized=self.authorized, temp_prefix=".voacc-")
|
||||
return result.committed and result.verified
|
||||
|
||||
def _apply_android_auto(self, request):
|
||||
enabled, client = self._android_auto_enabled(), self.android_auto
|
||||
if (enabled is None or client is None or request.value not in ("Off", "On") or request.expected != (b"1" if enabled else b"0") or
|
||||
not self.authorized()):
|
||||
return False
|
||||
try:
|
||||
client().call("set_dedicated_wifi", enabled=request.value == "On")
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
return False
|
||||
return self._android_auto_enabled() == (request.value == "On")
|
||||
|
||||
@@ -38,10 +38,11 @@ def validate_config(value):
|
||||
return dict(value)
|
||||
|
||||
|
||||
def atomic_json(path, value):
|
||||
def atomic_json(path, value, mode=0o600):
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, 'w') as handle:
|
||||
json.dump(value, handle)
|
||||
handle.flush()
|
||||
|
||||
@@ -4,15 +4,14 @@ import os
|
||||
import select
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.galaxy.hotspot import HotspotSettings, STATUS_PATH, atomic_json
|
||||
from openpilot.starpilot.system.pipe_worker import spawn
|
||||
|
||||
|
||||
def main():
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.hardware import PC
|
||||
if PC or os.environ.get('OPENPILOT_PREFIX'):
|
||||
raise RuntimeError('Hotspot requires the primary comma instance')
|
||||
@@ -36,13 +35,7 @@ def main():
|
||||
runtime = {'active': False, 'state': 'disabled', 'reason': ''}
|
||||
else:
|
||||
if child is None:
|
||||
child = subprocess.Popen(['sudo', '-n', 'systemd-run', '--quiet', '--pipe', '--wait', '--collect',
|
||||
'--unit=starpilot-galaxy-hotspot', '--property=TimeoutStopSec=8',
|
||||
'--property=KillMode=control-group', '--working-directory=' + str(BASEDIR),
|
||||
sys.executable, '-u', '-m',
|
||||
'openpilot.starpilot.galaxy.hotspot_worker'], cwd=BASEDIR,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
close_fds=True)
|
||||
child = spawn('starpilot-galaxy-hotspot', 'openpilot.starpilot.galaxy.hotspot_worker')
|
||||
buffer = b''
|
||||
last_reply = time.monotonic()
|
||||
runtime = {'active': False, 'state': 'starting', 'reason': 'Starting hotspot service.'}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Small nl80211 client for the one Galaxy AP interface; no wlan0 mutations."""
|
||||
"""Small nl80211 client for the Galaxy AP and Android Auto car interfaces; no wlan0 mutations."""
|
||||
import socket
|
||||
import struct
|
||||
|
||||
@@ -67,12 +67,12 @@ class WirelessInterfaces:
|
||||
'phy': int.from_bytes(row[1], 'little'), 'type': int.from_bytes(row[5], 'little')}
|
||||
for row in self.query(self.family, 5, dump=True)} # GET_INTERFACE
|
||||
|
||||
def create(self, name, primary='wlan0'):
|
||||
def create(self, name, primary='wlan0', iftype=3): # 3 = AP, 2 = station
|
||||
interfaces = self.interfaces()
|
||||
if name in interfaces or interfaces[primary]['type'] != 2: # station
|
||||
raise RuntimeError('Hotspot interface exists or primary Wi-Fi is not a client')
|
||||
raise RuntimeError('Interface exists or primary Wi-Fi is not a client')
|
||||
payload = attribute(1, struct.pack('I', interfaces[primary]['phy']))
|
||||
payload += attribute(4, name.encode() + b'\0') + attribute(5, struct.pack('I', 3)) # AP
|
||||
payload += attribute(4, name.encode() + b'\0') + attribute(5, struct.pack('I', iftype))
|
||||
self.query(self.family, 7, payload, ack=True) # NEW_INTERFACE
|
||||
|
||||
def delete(self, name):
|
||||
|
||||
@@ -5,33 +5,40 @@ EOF, ten seconds without a heartbeat, or SIGTERM tears down only our AP.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from openpilot.starpilot.galaxy.hotspot import ADDRESS, INTERFACE, NETWORK, HotspotSettings, validate_config
|
||||
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces
|
||||
from openpilot.starpilot.system.pipe_worker import claim, owned, stop_event
|
||||
|
||||
ALIAS = 'starpilot-galaxy-hotspot'
|
||||
RUNTIME = Path('/run/starpilot-galaxy-hotspot')
|
||||
STANDALONE_CHANNEL = ('g', 6)
|
||||
HOLD = 'hold'
|
||||
# wlan0 first; aa0 is Android Auto's own car link when it runs on a dedicated interface.
|
||||
STATIONS = ('wlan0', 'aa0')
|
||||
# Android Auto drops and rejoins the car between attempts; stay put through those gaps.
|
||||
STANDALONE_SETTLE_S = 30.0
|
||||
NEGOTIATING = ('AUTHENTICATING', 'ASSOCIATING', 'ASSOCIATED', '4WAY_HANDSHAKE', 'GROUP_HANDSHAKE')
|
||||
|
||||
|
||||
def radio_channel(status):
|
||||
fields = dict(line.split('=', 1) for line in status.splitlines() if '=' in line)
|
||||
# A disconnected station remains available for AA/Wi-Fi association. Do not
|
||||
# depend on an Internet connection to offer local access. Association itself
|
||||
# gets priority: pause the AP while the station is negotiating its channel.
|
||||
# gets priority: leave the AP untouched while the station negotiates its channel.
|
||||
if fields.get('wpa_state') in ('DISCONNECTED', 'INACTIVE', 'SCANNING'):
|
||||
return STANDALONE_CHANNEL
|
||||
if fields.get('wpa_state') in NEGOTIATING:
|
||||
return HOLD
|
||||
if fields.get('wpa_state') != 'COMPLETED':
|
||||
return None
|
||||
frequency = int(fields.get('freq', '0'))
|
||||
@@ -42,6 +49,32 @@ def radio_channel(status):
|
||||
return None
|
||||
|
||||
|
||||
def band_channel(number):
|
||||
return ('g' if number <= 14 else 'a'), number
|
||||
|
||||
|
||||
def dfs(channel):
|
||||
return channel[0] == 'a' and 52 <= channel[1] <= 144
|
||||
|
||||
|
||||
def choose_channel(results):
|
||||
"""Pick the AP channel from each station's radio_channel result, wlan0 first."""
|
||||
if HOLD in results:
|
||||
return HOLD
|
||||
for result in results:
|
||||
if isinstance(result, tuple) and result != STANDALONE_CHANNEL:
|
||||
return result
|
||||
return results[0] if results else None
|
||||
|
||||
|
||||
def nm_activating(state):
|
||||
"""NetworkManager device states 40-90 are the steps between disconnected and activated."""
|
||||
try:
|
||||
return 40 <= int(state.strip().split(' ', 1)[0]) < 100
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def overlapping_routes(rows):
|
||||
subnet = ipaddress.ip_network(NETWORK)
|
||||
for row in rows:
|
||||
@@ -69,19 +102,48 @@ class HotspotBackend:
|
||||
self.children = []
|
||||
self.owned = False
|
||||
self.firewall = shutil.which('iptables-legacy') or shutil.which('iptables')
|
||||
self.connected = set() # channel numbers of the stations that are connected
|
||||
self.idle = [] # stations that reported no connection; NetworkManager may be about to join one
|
||||
|
||||
def command(self, args, check=True):
|
||||
return self.run(args, check=check, text=True, capture_output=True, timeout=5)
|
||||
|
||||
def channel(self):
|
||||
return radio_channel(self.command(['/usr/sbin/wpa_cli', '-i', 'wlan0', 'status']).stdout)
|
||||
results, self.connected, self.idle = [], set(), []
|
||||
for station in STATIONS:
|
||||
if station == STATIONS[0]:
|
||||
result = radio_channel(self.command(['/usr/sbin/wpa_cli', '-i', station, 'status']).stdout)
|
||||
else:
|
||||
if not Path(f'/sys/class/net/{station}').exists():
|
||||
continue
|
||||
# Between creation and wpa_supplicant taking it, aa0 has no status yet; that is no reason to drop the AP.
|
||||
reply = self.command(['/usr/sbin/wpa_cli', '-i', station, 'status'], check=False)
|
||||
if reply.returncode != 0:
|
||||
continue
|
||||
result = radio_channel(reply.stdout)
|
||||
if result == STANDALONE_CHANNEL:
|
||||
self.idle.append(station)
|
||||
elif isinstance(result, tuple):
|
||||
self.connected.add(result[1])
|
||||
results.append(result)
|
||||
return choose_channel(results)
|
||||
|
||||
def activating(self):
|
||||
for station in self.idle:
|
||||
reply = self.command(['nmcli', '-t', '-g', 'GENERAL.STATE', 'device', 'show', station], check=False)
|
||||
if reply.returncode == 0 and nm_activating(reply.stdout):
|
||||
return True
|
||||
return False
|
||||
|
||||
def alive(self):
|
||||
return self.owned and len(self.children) == 2 and all(child.poll() is None for child in self.children)
|
||||
|
||||
def ready(self):
|
||||
def ap_status(self):
|
||||
"""Whether the AP is up, and its current channel: the driver moves it by itself to follow a station."""
|
||||
reply = self.command(['/usr/sbin/hostapd_cli', '-p', str(self.root / 'control'), '-i', INTERFACE, 'status'], check=False)
|
||||
return reply.returncode == 0 and 'state=ENABLED' in reply.stdout.splitlines()
|
||||
lines = reply.stdout.splitlines() if reply.returncode == 0 else []
|
||||
channel = next((line.split('=', 1)[1] for line in lines if line.startswith('channel=')), '')
|
||||
return {'ready': 'state=ENABLED' in lines, 'channel': int(channel) if channel.isdigit() else None}
|
||||
|
||||
def _wireless(self, method):
|
||||
client = self.interfaces()
|
||||
@@ -91,10 +153,7 @@ class HotspotBackend:
|
||||
client.close()
|
||||
|
||||
def stale_owned(self):
|
||||
try:
|
||||
return Path(f'/sys/class/net/{INTERFACE}/ifalias').read_text().strip() == ALIAS
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
return owned(INTERFACE, ALIAS)
|
||||
|
||||
def stop(self):
|
||||
for child in self.children:
|
||||
@@ -157,6 +216,9 @@ class HotspotController:
|
||||
def __init__(self, backend, clock=time.monotonic):
|
||||
self.backend, self.clock = backend, clock
|
||||
self.applied, self.retry_at, self.ready_deadline = None, 0.0, 0.0
|
||||
self.station_seen = float('-inf')
|
||||
self.attempted = None
|
||||
self.refused = set() # DFS channels hostapd would not start on; use the standalone channel instead
|
||||
|
||||
def tick(self, config):
|
||||
if not config['enabled']:
|
||||
@@ -165,27 +227,56 @@ class HotspotController:
|
||||
return {'active': False, 'state': 'disabled', 'reason': ''}
|
||||
try:
|
||||
channel = self.backend.channel()
|
||||
running = self.applied is not None and self.backend.alive()
|
||||
status = self.backend.ap_status() if running else {'ready': False, 'channel': None}
|
||||
if running and isinstance(channel, tuple) and self.applied[0] == config:
|
||||
current = status['channel']
|
||||
# The driver moves the AP onto a station's channel by itself; restarting it there would only move it back.
|
||||
if current is not None and (current == channel[1] or current in self.backend.connected):
|
||||
channel = band_channel(current)
|
||||
self.applied = (self.applied[0], channel)
|
||||
if channel == STANDALONE_CHANNEL:
|
||||
if self.clock() - self.station_seen < STANDALONE_SETTLE_S:
|
||||
channel = HOLD
|
||||
elif channel is not None:
|
||||
self.station_seen = self.clock()
|
||||
if isinstance(channel, tuple) and channel[1] in self.refused:
|
||||
channel = STANDALONE_CHANNEL
|
||||
if channel != HOLD and (not running or (dict(config), channel) != self.applied) and self.backend.activating():
|
||||
self.station_seen = self.clock()
|
||||
channel = HOLD
|
||||
if channel is None:
|
||||
self.backend.stop()
|
||||
self.applied = None
|
||||
return {'active': False, 'state': 'waiting', 'reason': 'Waiting for Wi-Fi radio readiness or a supported connection channel.'}
|
||||
desired = (dict(config), channel)
|
||||
if desired != self.applied or not self.backend.alive():
|
||||
if channel == HOLD:
|
||||
# Starting, stopping or moving the AP while a station joins makes the join fail.
|
||||
if not running or self.applied is None:
|
||||
return {'active': False, 'state': 'waiting', 'reason': 'Waiting for Wi-Fi to finish connecting.'}
|
||||
desired = self.applied
|
||||
channel = desired[1]
|
||||
if desired != self.applied or not running:
|
||||
self.backend.stop()
|
||||
self.applied = None
|
||||
if self.clock() < self.retry_at:
|
||||
return {'active': False, 'state': 'error', 'reason': 'Hotspot unavailable; retrying shortly.'}
|
||||
self.retry_at = self.clock() + 15
|
||||
self.attempted = channel
|
||||
self.backend.start(config, channel)
|
||||
self.applied = desired
|
||||
self.ready_deadline = self.clock() + 15
|
||||
return {'active': False, 'state': 'starting', 'reason': 'Starting the local Wi-Fi network.', 'channel': channel[1]}
|
||||
if not self.backend.ready():
|
||||
if not status['ready']:
|
||||
if self.clock() >= self.ready_deadline:
|
||||
raise RuntimeError('Hotspot did not become ready')
|
||||
return {'active': False, 'state': 'starting', 'reason': 'Waiting for the Wi-Fi access point.', 'channel': channel[1]}
|
||||
self.attempted = None
|
||||
return {'active': True, 'state': 'active', 'reason': '', 'channel': channel[1]}
|
||||
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError):
|
||||
if self.attempted is not None and dfs(self.attempted):
|
||||
self.refused.add(self.attempted[1])
|
||||
self.attempted = None
|
||||
self.backend.stop()
|
||||
self.applied = None
|
||||
self.retry_at = self.clock() + 15
|
||||
@@ -193,26 +284,14 @@ class HotspotController:
|
||||
|
||||
|
||||
def main():
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError('Hotspot worker requires network administration privileges')
|
||||
os.umask(0o077)
|
||||
RUNTIME.mkdir(mode=0o700, exist_ok=True)
|
||||
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != 0:
|
||||
raise RuntimeError('Unsafe hotspot runtime directory')
|
||||
fd = os.open(RUNTIME / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
fd = claim(RUNTIME, 'Hotspot worker')
|
||||
backend = HotspotBackend()
|
||||
controller = HotspotController(backend)
|
||||
stopped = False
|
||||
def stop(*_):
|
||||
nonlocal stopped
|
||||
stopped = True
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
stop = stop_event()
|
||||
config, buffer, last = None, b'', time.monotonic()
|
||||
try:
|
||||
backend.stop() # clean an interface explicitly marked as ours after a crash
|
||||
while not stopped and time.monotonic() - last < 10:
|
||||
while not stop.is_set() and time.monotonic() - last < 10:
|
||||
if select.select([sys.stdin], [], [], 2)[0]:
|
||||
data = os.read(sys.stdin.fileno(), 4097)
|
||||
if not data:
|
||||
|
||||
@@ -107,6 +107,16 @@ def direct_local_connection(peer: str, destination: str, headers) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def install_origin_allowed(peer: str, destination: str, headers, remote_transport: bool = False) -> bool:
|
||||
"""Large installs are for the tunnel or a real LAN/hotspot client, never the phone Bluetooth bridge (loopback)."""
|
||||
if remote_transport:
|
||||
return True
|
||||
try:
|
||||
return not ip_address(peer).is_loopback and direct_local_connection(peer, destination, headers)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def allowed_authority(host: str, local_address: str, port: int) -> bool:
|
||||
"""Accept only the literal address reached by this socket, or loopback's name."""
|
||||
if host == f'localhost:{port}' and local_address == '127.0.0.1':
|
||||
|
||||
@@ -452,10 +452,11 @@ def _question(row: FeatureRow, request: FeatureSettingsRequest) -> str:
|
||||
|
||||
|
||||
class SettingsGateway:
|
||||
def __init__(self, params, context: ContextSource | None = None, *, clock=time.monotonic):
|
||||
def __init__(self, params, context: ContextSource | None = None, *, clock=time.monotonic, android_auto=None):
|
||||
self.params = params
|
||||
self.context = context if context is not None else LiveContextSource(params)
|
||||
self.clock = clock
|
||||
self.android_auto = android_auto
|
||||
self.lock = threading.Lock()
|
||||
self.views: dict[str, _View] = {}
|
||||
self.intents: dict[str, _Intent] = {}
|
||||
@@ -569,7 +570,8 @@ class SettingsGateway:
|
||||
fresh = self.context.sample() if live else ctx
|
||||
return bool(fresh.parked and not fresh.editing_saved_vehicle and fresh.cp_raw == ctx.cp_raw)
|
||||
return DeveloperSettingsOwner(self.params, authorized,
|
||||
panda_wake=self._owner(ctx, live=live, session_valid=session_valid).panda_wake)
|
||||
panda_wake=self._owner(ctx, live=live, session_valid=session_valid).panda_wake,
|
||||
android_auto=self.android_auto)
|
||||
|
||||
def _state(self, page: str, ctx: AuthorityContext):
|
||||
if page == "developer":
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import json
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.galaxy.companion import CompanionRegistry, phone_uplink_active, validate_lease
|
||||
from openpilot.starpilot.galaxy.companion_uplink_worker import other_internet, tunnel_config
|
||||
from openpilot.starpilot.galaxy.hotspot import atomic_json
|
||||
|
||||
PAYLOAD = {'port': 1080, 'user': 'a' * 32, 'pass': 'b' * 32, 'version': '1.0.0', 'protocol': 1, 'bytes': 100}
|
||||
PEER = '172.31.254.10'
|
||||
|
||||
|
||||
def registry(tmp_path, now=lambda: 100):
|
||||
return CompanionRegistry(tmp_path, tmp_path / 'lease', tmp_path / 'status', clock=now)
|
||||
|
||||
|
||||
def test_opt_in_and_stale_lease(tmp_path):
|
||||
now = [100]
|
||||
owner = registry(tmp_path, lambda: now[0])
|
||||
assert not owner.heartbeat(PAYLOAD, PEER)['enabled']
|
||||
assert owner.lease() is None
|
||||
owner.save({'enabled': True})
|
||||
assert owner.heartbeat(PAYLOAD, PEER)['enabled']
|
||||
assert stat.S_IMODE(owner.lease_path.stat().st_mode) == 0o600
|
||||
assert owner.snapshot()['connected']
|
||||
assert 'pass' not in owner.snapshot()
|
||||
now[0] = 115
|
||||
assert owner.lease() is None
|
||||
now[0] = 99
|
||||
assert owner.lease() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('peer', ['127.0.0.1', '192.168.1.2', '172.31.254.1', '172.31.254.0', '172.31.254.255', '::ffff:172.31.254.10'])
|
||||
def test_only_hotspot_peers(tmp_path, peer):
|
||||
with pytest.raises(ValueError):
|
||||
registry(tmp_path).heartbeat(PAYLOAD, peer)
|
||||
|
||||
|
||||
def test_remote_and_conflict_and_disable(tmp_path):
|
||||
owner = registry(tmp_path)
|
||||
owner.save({'enabled': True})
|
||||
with pytest.raises(PermissionError):
|
||||
owner.heartbeat(PAYLOAD, PEER, remote=True)
|
||||
owner.heartbeat(PAYLOAD, PEER)
|
||||
with pytest.raises(FileExistsError):
|
||||
owner.heartbeat(PAYLOAD, '172.31.254.11')
|
||||
owner.heartbeat({**PAYLOAD, 'user': 'c' * 32}, PEER)
|
||||
assert owner.lease()['user'] == 'c' * 32
|
||||
owner.save({'enabled': False})
|
||||
assert owner.lease() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize('patch', [{'port': True}, {'port': 22}, {'protocol': 2}, {'user': 'a\n' * 20}, {'bytes': -1}, {'bytes': True}, {'version': None}])
|
||||
def test_invalid_payload(patch):
|
||||
with pytest.raises(ValueError):
|
||||
validate_lease({**PAYLOAD, 'peer': PEER, **patch})
|
||||
|
||||
|
||||
def test_route_guard(tmp_path):
|
||||
path = tmp_path / 'status'
|
||||
assert not phone_uplink_active(path, lambda: 100)
|
||||
for state, expected in [({'active': True, 'updated': 99}, True), ({'active': False, 'updated': 99}, False),
|
||||
({'active': True, 'updated': 85}, False), ({'active': True, 'updated': 101}, False),
|
||||
({'active': True, 'updated': float('nan')}, False)]:
|
||||
atomic_json(path, state)
|
||||
assert phone_uplink_active(path, lambda: 100) is expected
|
||||
|
||||
|
||||
def test_tunnel_mapped_dns_and_interface_probes():
|
||||
config = json.loads(tunnel_config({**PAYLOAD, 'peer': PEER}))
|
||||
assert config['socks5']['address'] == PEER
|
||||
assert config['socks5']['udp'] == 'udp'
|
||||
assert config['mapdns']['network'] == '198.19.0.0'
|
||||
routes = [{'dst': 'default', 'dev': 'ppp0'}, {'dst': 'default', 'dev': 'spuplink0'}, {'dst': '172.31.254.0/24', 'dev': 'galaxy0'}]
|
||||
calls = []
|
||||
assert other_internet(routes, lambda interface: calls.append(interface) or True)
|
||||
assert calls == ['ppp0']
|
||||
assert not other_internet(routes, lambda _: False)
|
||||
|
||||
|
||||
def test_backend_routing_and_cleanup_order(tmp_path, monkeypatch):
|
||||
from unittest.mock import Mock
|
||||
from openpilot.starpilot.galaxy import companion_uplink_worker as worker
|
||||
monkeypatch.setattr(worker, 'RUNTIME', tmp_path)
|
||||
monkeypatch.setattr(worker, 'STATUS_PATH', tmp_path / 'status')
|
||||
child = Mock()
|
||||
child.poll.return_value = None
|
||||
created = [False]
|
||||
calls = []
|
||||
original_exists = worker.Path.exists
|
||||
|
||||
def exists(path):
|
||||
return created[0] if str(path) == '/sys/class/net/spuplink0' else original_exists(path)
|
||||
|
||||
def spawn(*args, **kwargs):
|
||||
created[0] = True
|
||||
return child
|
||||
|
||||
def command(args, check=True):
|
||||
calls.append((args, phone_uplink_active(tmp_path / 'status')))
|
||||
return Mock(stdout='[]', returncode=0)
|
||||
|
||||
monkeypatch.setattr(worker.Path, 'exists', exists)
|
||||
monkeypatch.setattr(worker.subprocess, 'Popen', spawn)
|
||||
monkeypatch.setattr(worker, 'command', command)
|
||||
backend = worker.Backend()
|
||||
backend.start({**PAYLOAD, 'peer': PEER}, [])
|
||||
activation = next(i for i, (args, _) in enumerate(calls) if args[:4] == ['ip', 'rule', 'add', 'priority'] and '18001' in args)
|
||||
assert calls[activation][1] # Data guard is published before route activation.
|
||||
assert (tmp_path / 'owned').exists()
|
||||
assert all('ppp0' not in args and 'wlan0' not in args for args, _ in calls)
|
||||
backend.stop()
|
||||
assert not phone_uplink_active(tmp_path / 'status')
|
||||
assert not (tmp_path / 'owned').exists()
|
||||
child.terminate.assert_called_once()
|
||||
teardown = [args for args, _ in calls[activation + 1:]]
|
||||
assert teardown.index(['resolvectl', 'revert', 'spuplink0']) < teardown.index(['ip', 'link', 'delete', 'spuplink0'])
|
||||
|
||||
|
||||
def test_backend_refuses_route_collision(tmp_path, monkeypatch):
|
||||
from openpilot.starpilot.galaxy import companion_uplink_worker as worker
|
||||
monkeypatch.setattr(worker, 'RUNTIME', tmp_path)
|
||||
with pytest.raises(RuntimeError, match='conflicts'):
|
||||
worker.Backend().start({**PAYLOAD, 'peer': PEER}, [{'dst': '198.19.0.0/24', 'dev': 'wlan0'}])
|
||||
assert not (tmp_path / 'owned').exists()
|
||||
@@ -0,0 +1,70 @@
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.system.loggerd import uploader
|
||||
from openpilot.system.athena import athenad
|
||||
from openpilot.starpilot.software import fast_update
|
||||
|
||||
|
||||
def test_uploader_ignores_unmetered_override():
|
||||
instance = uploader.Uploader.__new__(uploader.Uploader)
|
||||
instance.params = Mock()
|
||||
instance.params.get_bool.return_value = True # AlwaysAllowUploads
|
||||
instance.next_file_to_upload = Mock(side_effect=AssertionError('must not select upload'))
|
||||
with patch.object(uploader, 'phone_uplink_active', return_value=True):
|
||||
assert instance.step(uploader.NetworkType.wifi, False) is None
|
||||
with pytest.raises(uploader.requests.ConnectionError, match='Phone uplink'):
|
||||
instance.do_upload('qlog', '/not-read')
|
||||
instance.next_file_to_upload.assert_not_called()
|
||||
|
||||
|
||||
def test_athena_allow_cellular_is_still_deferred():
|
||||
stop = threading.Event()
|
||||
item = athenad.UploadItem(path='/unused', url='https://upload.test', headers={}, created_at=int(athenad.datetime.now().timestamp() * 1000),
|
||||
id='phone-test', allow_cellular=True)
|
||||
queue = Mock()
|
||||
queue.get.return_value = item
|
||||
sm = Mock()
|
||||
sm.__getitem__ = Mock(return_value=SimpleNamespace(networkMetered=False, networkType=SimpleNamespace(raw=1)))
|
||||
with patch.object(athenad, 'upload_queue', queue), patch.object(athenad.messaging, 'SubMaster', return_value=sm), \
|
||||
patch.object(athenad, 'phone_uplink_active', return_value=True), \
|
||||
patch.object(athenad, 'retry_upload', side_effect=lambda *_: stop.set()) as retry, \
|
||||
patch.object(athenad, '_do_upload') as upload:
|
||||
athenad.upload_handler(stop)
|
||||
retry.assert_called_once_with(threading.get_ident(), stop, False)
|
||||
upload.assert_not_called()
|
||||
with pytest.raises(athenad.AbortTransferException):
|
||||
athenad.cb(sm, item, 0, stop, 100, 1)
|
||||
|
||||
|
||||
def test_update_fetch_rejected_and_inflight_process_stopped(tmp_path):
|
||||
with patch.object(fast_update, 'phone_uplink_active', return_value=True), patch.object(fast_update.subprocess, 'Popen') as spawn:
|
||||
with pytest.raises(fast_update.FastUpdateError, match='disabled'):
|
||||
fast_update.run(['git', 'fetch'], tmp_path)
|
||||
spawn.assert_not_called()
|
||||
# Exercise cancellation of a real process when a link changes mid-fetch.
|
||||
# Mac disallows killpg(pid, 0) after exit; use a portable child reaper here.
|
||||
def reap(process):
|
||||
process.terminate()
|
||||
process.wait(timeout=2)
|
||||
with patch.object(fast_update, 'phone_uplink_active', side_effect=[False, False, True]), \
|
||||
patch.object(fast_update, '_stop_process', side_effect=reap) as stop:
|
||||
with pytest.raises(fast_update.FastUpdateError, match='stopped'):
|
||||
fast_update.run(['python3', '-c', 'import time; time.sleep(20)', 'fetch'], tmp_path, timeout=5)
|
||||
stop.assert_called_once()
|
||||
|
||||
|
||||
def test_agnos_stream_never_starts_or_continues_over_phone():
|
||||
from openpilot.common.hardware.comma import agnos
|
||||
with patch.object(agnos, 'phone_uplink_active', return_value=True), patch.object(agnos.requests, 'get') as request:
|
||||
with pytest.raises(agnos.requests.ConnectionError, match='disabled'):
|
||||
agnos.StreamingDecompressor('https://example.test/image')
|
||||
request.assert_not_called()
|
||||
stream = agnos.StreamingDecompressor.__new__(agnos.StreamingDecompressor)
|
||||
stream.req = Mock()
|
||||
with pytest.raises(agnos.requests.ConnectionError, match='stopped'):
|
||||
stream.read(1024)
|
||||
stream.req.close.assert_called_once()
|
||||
@@ -7,7 +7,7 @@ from typing import cast
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from openpilot.starpilot.galaxy.developer_settings import DeveloperSettingsOwner
|
||||
from openpilot.starpilot.galaxy.developer_settings import AA_WIFI_KEY, DeveloperSettingsOwner
|
||||
from openpilot.starpilot.galaxy.settings import AuthorityContext, ContextSource, SettingsChanged, SettingsGateway
|
||||
from openpilot.starpilot.longitudinal.voacc import KEY, selected
|
||||
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest
|
||||
@@ -22,6 +22,19 @@ class FileParams:
|
||||
return str(self.root / key)
|
||||
|
||||
|
||||
class FakeAndroidAuto:
|
||||
def __init__(self, enabled=False, running=True):
|
||||
self.enabled, self.running = enabled, running
|
||||
|
||||
def call(self, command, **payload):
|
||||
if not self.running:
|
||||
raise RuntimeError("Android Auto service is not running (turn on Bluetooth)")
|
||||
if command == "set_dedicated_wifi":
|
||||
self.enabled = payload["enabled"]
|
||||
return {"ok": True}
|
||||
return {"ok": True, "status": {"dedicated_wifi_interface": self.enabled}}
|
||||
|
||||
|
||||
class DeveloperSettingsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
directory = tempfile.TemporaryDirectory()
|
||||
@@ -107,3 +120,23 @@ class DeveloperSettingsTest(unittest.TestCase):
|
||||
intent = preview("On")
|
||||
with self.assertRaises(SettingsChanged):
|
||||
gateway.confirm(intent, token, generation, session_valid=lambda: False)
|
||||
|
||||
def test_android_auto_stay_on_wifi_toggle(self):
|
||||
self.assertNotIn(AA_WIFI_KEY, [row.key for row in self.owner.snapshot().rows])
|
||||
service = FakeAndroidAuto()
|
||||
owner = DeveloperSettingsOwner(self.params, lambda: self.parked, android_auto=lambda: service)
|
||||
row = next(row for row in owner.snapshot().rows if row.key == AA_WIFI_KEY)
|
||||
self.assertEqual((row.value, row.choices, row.available), ("Off", ("Off", "On"), True))
|
||||
self.assertTrue(owner.apply(FeatureSettingsRequest(AA_WIFI_KEY, row.source, "On")))
|
||||
self.assertTrue(service.enabled)
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(AA_WIFI_KEY, row.source, "Off"))) # stale view
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(AA_WIFI_KEY, b"1", "maybe")))
|
||||
self.parked = False
|
||||
self.assertFalse(next(row for row in owner.snapshot().rows if row.key == AA_WIFI_KEY).available)
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(AA_WIFI_KEY, b"1", "Off")))
|
||||
self.parked = True
|
||||
self.assertTrue(owner.apply(FeatureSettingsRequest(AA_WIFI_KEY, b"1", "Off")))
|
||||
self.assertFalse(service.enabled)
|
||||
service.running = False
|
||||
self.assertNotIn(AA_WIFI_KEY, [row.key for row in owner.snapshot().rows])
|
||||
self.assertFalse(owner.apply(FeatureSettingsRequest(AA_WIFI_KEY, b"0", "On")))
|
||||
|
||||
@@ -44,4 +44,26 @@ await vm.refresh()
|
||||
assert.equal(auth, 1)
|
||||
assert.equal(vm.active, false)
|
||||
assert.equal(vm.timer, null)
|
||||
|
||||
const failing = instance()
|
||||
Object.defineProperty(failing, 'passwordValid', { get: () => GalaxyHotspot.computed.passwordValid.call(failing) })
|
||||
failing.adopt({ ...value, config: { ...value.config, enabled: false } })
|
||||
failing.draft.password = 'short'
|
||||
assert.equal(failing.passwordValid, false)
|
||||
failing.draft.password = 'a’curly-quote-pass'
|
||||
assert.equal(failing.passwordValid, false)
|
||||
failing.draft.password = ''
|
||||
assert.equal(failing.passwordValid, true)
|
||||
failing.draft.password = 'twelve-chars!'
|
||||
assert.equal(failing.passwordValid, true)
|
||||
failing.active = true
|
||||
globalThis.fetch = async (_url, options) => options?.method === 'POST'
|
||||
? { ok: false, status: 400, json: async () => ({ error: 'Use a 12–63 character password' }) }
|
||||
: { ok: true, json: async () => value }
|
||||
await failing.save()
|
||||
assert.match(failing.saveError, /12–63 character password/)
|
||||
clearTimeout(failing.timer)
|
||||
await failing.refresh()
|
||||
assert.match(failing.saveError, /12–63 character password/, 'a poll must not hide a failed save')
|
||||
failing.stop()
|
||||
console.log('Hotspot: schema, Vue template, preview exclusion, stale drafts, unmount race, credential clearing and authentication passed')
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import json
|
||||
import stat
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.galaxy.hotspot import DEFAULT, HotspotSettings, atomic_json, hotspot_name, validate_config
|
||||
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces, attribute, attributes
|
||||
from openpilot.starpilot.galaxy.hotspot_worker import HotspotBackend, HotspotController, hostapd_config, overlapping_routes, radio_channel
|
||||
from openpilot.starpilot.galaxy.hotspot_worker import (HOLD, STANDALONE_SETTLE_S, HotspotBackend, HotspotController, choose_channel,
|
||||
hostapd_config, nm_activating, overlapping_routes, radio_channel)
|
||||
|
||||
CONFIG = {'enabled': True, 'ssid': 'TheGalaxy-6b86', 'password': '1234567890123456'}
|
||||
DONGLE = 'b0c4a280b2f96b86'
|
||||
|
||||
|
||||
def backend_mock(ready=True, ap_channel=None):
|
||||
backend = Mock()
|
||||
backend.alive.return_value = True
|
||||
backend.ap_status.return_value = {'ready': ready, 'channel': ap_channel}
|
||||
backend.activating.return_value = False
|
||||
backend.connected = set()
|
||||
return backend
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', [{'enabled': 1}, {'ssid': 'bad\nssid'}, {'password': 'short'},
|
||||
{'password': 'has spaces in it'}, {'extra': True}, {'password': 'é' * 12}])
|
||||
def test_strict_settings(change):
|
||||
@@ -79,7 +91,7 @@ def test_unregistered_name_is_not_invented(dongle):
|
||||
@pytest.mark.parametrize('status,expected', [('wpa_state=COMPLETED\nfreq=5745', ('a', 149)),
|
||||
('wpa_state=COMPLETED\nfreq=2412', ('g', 1)), ('wpa_state=DISCONNECTED\nfreq=5745', ('g', 6)),
|
||||
('wpa_state=INACTIVE', ('g', 6)), ('wpa_state=SCANNING', ('g', 6)),
|
||||
('wpa_state=ASSOCIATING', None), ('wpa_state=4WAY_HANDSHAKE\nfreq=5745', None),
|
||||
('wpa_state=ASSOCIATING', HOLD), ('wpa_state=4WAY_HANDSHAKE\nfreq=5745', HOLD),
|
||||
('wpa_state=INTERFACE_DISABLED', None), ('FAIL', None),
|
||||
('wpa_state=COMPLETED\nfreq=6000', None)])
|
||||
def test_channel(status, expected):
|
||||
@@ -87,20 +99,157 @@ def test_channel(status, expected):
|
||||
|
||||
|
||||
def test_automatic_standalone_connected_and_back():
|
||||
now = [100]
|
||||
backend = Mock()
|
||||
backend.alive.return_value = backend.ready.return_value = True
|
||||
now = [100.]
|
||||
backend = backend_mock()
|
||||
controller = HotspotController(backend, clock=lambda: now[0])
|
||||
for status, expected in [('DISCONNECTED', ('g', 6)), ('COMPLETED\nfreq=5745', ('a', 149)), ('DISCONNECTED', ('g', 6))]:
|
||||
backend.channel.return_value = radio_channel('wpa_state=' + status)
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
backend.start.assert_called_with(CONFIG, expected)
|
||||
assert controller.tick(CONFIG)['active']
|
||||
now[0] += 20
|
||||
now[0] += STANDALONE_SETTLE_S + 1
|
||||
assert backend.start.call_count == 3
|
||||
assert controller.tick({**CONFIG, 'enabled': False})['state'] == 'disabled'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('results,expected', [
|
||||
([('a', 149)], ('a', 149)), ([('g', 6)], ('g', 6)), ([None], None), ([HOLD, ('a', 149)], HOLD),
|
||||
([('a', 149), HOLD], HOLD), ([('g', 1), ('a', 149)], ('g', 1)), ([('g', 6), ('a', 149)], ('a', 149)),
|
||||
([None, ('a', 149)], ('a', 149)), ([('g', 6), None], ('g', 6))])
|
||||
def test_choose_channel(results, expected):
|
||||
assert choose_channel(results) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize('state,expected', [('30 (disconnected)', False), ('50 (connecting (configuring))', True),
|
||||
('60 (connecting (need authentication))', True), ('100 (connected)', False), ('', False), ('junk', False)])
|
||||
def test_nm_activating(state, expected):
|
||||
assert nm_activating(state) == expected
|
||||
|
||||
|
||||
def test_join_leaves_running_ap_alone():
|
||||
now = [100.]
|
||||
backend = backend_mock(ap_channel=1)
|
||||
controller = HotspotController(backend, clock=lambda: now[0])
|
||||
backend.channel.return_value = ('g', 1)
|
||||
controller.tick(CONFIG)
|
||||
assert controller.tick(CONFIG)['active']
|
||||
backend.start.reset_mock()
|
||||
backend.stop.reset_mock()
|
||||
# Android Auto drops home Wi-Fi, scans, associates and joins the car on channel 149.
|
||||
for channel in (('g', 6), HOLD, ('g', 6), HOLD):
|
||||
backend.channel.return_value = channel
|
||||
now[0] += 2
|
||||
assert controller.tick(CONFIG)['active']
|
||||
backend.stop.assert_not_called()
|
||||
backend.start.assert_not_called()
|
||||
backend.ap_status.return_value = {'ready': True, 'channel': 149}
|
||||
backend.channel.return_value = ('a', 149)
|
||||
assert controller.tick(CONFIG)['channel'] == 149
|
||||
backend.stop.assert_not_called()
|
||||
backend.start.assert_not_called()
|
||||
# Only a station gone for good moves the AP to its standalone channel.
|
||||
backend.channel.return_value = ('g', 6)
|
||||
now[0] += STANDALONE_SETTLE_S - 1
|
||||
assert controller.tick(CONFIG)['active']
|
||||
backend.start.assert_not_called()
|
||||
now[0] += 2
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
backend.start.assert_called_once_with(CONFIG, ('g', 6))
|
||||
|
||||
|
||||
def test_hold_does_not_start_ap():
|
||||
backend = backend_mock()
|
||||
backend.channel.return_value = HOLD
|
||||
controller = HotspotController(backend, clock=lambda: 100)
|
||||
assert controller.tick(CONFIG)['state'] == 'waiting'
|
||||
backend.start.assert_not_called()
|
||||
backend.stop.assert_not_called()
|
||||
|
||||
|
||||
def test_backend_channel_reports_idle_stations_and_asks_networkmanager_only_for_them(tmp_path):
|
||||
def command(args, **kwargs):
|
||||
if args[0] == 'nmcli':
|
||||
return Mock(returncode=0, stdout='50 (connecting (configuring))\n')
|
||||
return Mock(returncode=0, stdout='wpa_state=DISCONNECTED\n')
|
||||
run = Mock(side_effect=command)
|
||||
backend = HotspotBackend(tmp_path, run=run, interfaces=Mock())
|
||||
assert backend.channel() == ('g', 6) and backend.idle == ['wlan0']
|
||||
assert all(call.args[0][0] != 'nmcli' for call in run.call_args_list)
|
||||
assert backend.activating()
|
||||
run.side_effect = lambda args, **kwargs: Mock(returncode=0, stdout='wpa_state=COMPLETED\nfreq=5745')
|
||||
assert backend.channel() == ('a', 149) and backend.connected == {149}
|
||||
assert not backend.activating()
|
||||
assert all(call.args[0][-1] in ('status', 'wlan0') for call in run.call_args_list)
|
||||
|
||||
|
||||
def test_backend_skips_car_station_without_status(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, 'exists', lambda self: True)
|
||||
def command(args, **kwargs):
|
||||
if args[2] == 'aa0':
|
||||
assert kwargs['check'] is False
|
||||
return Mock(returncode=255, stdout='Failed to connect to non-global ctrl_ifname: aa0')
|
||||
return Mock(returncode=0, stdout='wpa_state=COMPLETED\nfreq=2412')
|
||||
backend = HotspotBackend(tmp_path, run=Mock(side_effect=command), interfaces=Mock())
|
||||
assert backend.channel() == ('g', 1)
|
||||
|
||||
|
||||
def test_networkmanager_join_holds_the_running_ap():
|
||||
now = [100.]
|
||||
backend = backend_mock(ap_channel=1)
|
||||
controller = HotspotController(backend, clock=lambda: now[0])
|
||||
backend.channel.return_value = ('g', 1)
|
||||
controller.tick(CONFIG)
|
||||
backend.start.reset_mock()
|
||||
backend.channel.return_value = ('g', 6)
|
||||
now[0] += STANDALONE_SETTLE_S + 1
|
||||
backend.activating.return_value = True
|
||||
assert controller.tick(CONFIG)['active']
|
||||
backend.start.assert_not_called()
|
||||
backend.activating.reset_mock()
|
||||
backend.channel.return_value = ('g', 1)
|
||||
backend.activating.return_value = False
|
||||
controller.tick(CONFIG)
|
||||
backend.activating.assert_not_called() # nothing to change, so nothing to ask
|
||||
|
||||
|
||||
def test_ap_moved_by_the_driver_to_another_station_stays_there():
|
||||
backend = backend_mock(ap_channel=36)
|
||||
controller = HotspotController(backend, clock=lambda: 100)
|
||||
backend.channel.return_value = ('a', 36)
|
||||
controller.tick(CONFIG)
|
||||
backend.start.reset_mock()
|
||||
backend.stop.reset_mock()
|
||||
backend.connected = {36, 149}
|
||||
backend.ap_status.return_value = {'ready': True, 'channel': 149}
|
||||
for _ in range(3):
|
||||
assert controller.tick(CONFIG) == {'active': True, 'state': 'active', 'reason': '', 'channel': 149}
|
||||
backend.start.assert_not_called()
|
||||
backend.stop.assert_not_called()
|
||||
|
||||
|
||||
def test_dfs_channel_that_will_not_start_falls_back_to_standalone():
|
||||
now = [100.]
|
||||
backend = backend_mock(ready=False)
|
||||
controller = HotspotController(backend, clock=lambda: now[0])
|
||||
backend.channel.return_value = ('a', 100)
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
now[0] += 16
|
||||
assert controller.tick(CONFIG)['state'] == 'error'
|
||||
now[0] += 16
|
||||
backend.ap_status.return_value = {'ready': True, 'channel': 6}
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
backend.start.assert_called_with(CONFIG, ('g', 6))
|
||||
|
||||
|
||||
def test_backend_ap_status(tmp_path):
|
||||
run = Mock(return_value=Mock(returncode=0, stdout='state=ENABLED\nchannel=149\n'))
|
||||
backend = HotspotBackend(tmp_path, run=run, interfaces=Mock())
|
||||
assert backend.ap_status() == {'ready': True, 'channel': 149}
|
||||
assert run.call_count == 1
|
||||
run.return_value = Mock(returncode=255, stdout='')
|
||||
assert backend.ap_status() == {'ready': False, 'channel': None}
|
||||
|
||||
|
||||
def test_routes_and_config():
|
||||
assert not overlapping_routes([{'dst': 'default'}, {'dst': '192.168.180.0/24'}, {'dst': '172.31.254.0/24', 'dev': 'galaxy0'}])
|
||||
assert overlapping_routes([{'dst': '172.31.0.0/16', 'dev': 'wlan0'}])
|
||||
@@ -113,19 +262,18 @@ def test_routes_and_config():
|
||||
|
||||
|
||||
def test_controller_lifecycle_and_channel_recovery():
|
||||
backend = Mock()
|
||||
backend = backend_mock()
|
||||
backend.channel.return_value = ('a', 149)
|
||||
backend.alive.return_value = backend.ready.return_value = True
|
||||
now = [100]
|
||||
now = [100.]
|
||||
controller = HotspotController(backend, clock=lambda: now[0])
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
assert controller.tick(CONFIG)['active']
|
||||
backend.start.assert_called_once_with(CONFIG, ('a', 149))
|
||||
backend.channel.return_value = ('g', 6)
|
||||
backend.channel.return_value = ('a', 36)
|
||||
assert controller.tick(CONFIG)['state'] == 'error' # rate limit restarts
|
||||
now[0] += 16
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
backend.start.assert_called_with(CONFIG, ('g', 6))
|
||||
backend.start.assert_called_with(CONFIG, ('a', 36))
|
||||
backend.channel.return_value = None
|
||||
assert controller.tick(CONFIG)['state'] == 'waiting'
|
||||
assert controller.applied is None
|
||||
@@ -134,10 +282,8 @@ def test_controller_lifecycle_and_channel_recovery():
|
||||
|
||||
|
||||
def test_controller_readiness_and_failed_child():
|
||||
backend = Mock()
|
||||
backend = backend_mock(ready=False)
|
||||
backend.channel.return_value = ('a', 149)
|
||||
backend.alive.return_value = True
|
||||
backend.ready.return_value = False
|
||||
now = [0]
|
||||
controller = HotspotController(backend, clock=lambda: now[0])
|
||||
assert controller.tick(CONFIG)['state'] == 'starting'
|
||||
@@ -193,3 +339,16 @@ def test_netlink_encoding_and_refusal():
|
||||
wireless.query.assert_not_called()
|
||||
wireless.delete('galaxy0')
|
||||
wireless.query.assert_called_once_with(22, 8, attribute(3, b'\x07\0\0\0'), ack=True)
|
||||
|
||||
|
||||
def test_netlink_new_interface_payloads():
|
||||
wireless = WirelessInterfaces.__new__(WirelessInterfaces)
|
||||
wireless.interfaces = Mock(return_value={'wlan0': {'type': 2, 'phy': 0, 'index': 1}})
|
||||
wireless.query = Mock()
|
||||
wireless.family = 22
|
||||
wireless.create('galaxy0')
|
||||
ap = attribute(1, struct.pack('I', 0)) + attribute(4, b'galaxy0\0') + attribute(5, struct.pack('I', 3))
|
||||
wireless.query.assert_called_with(22, 7, ap, ack=True)
|
||||
wireless.create('aa0', iftype=2)
|
||||
station = attribute(1, struct.pack('I', 0)) + attribute(4, b'aa0\0') + attribute(5, struct.pack('I', 2))
|
||||
wireless.query.assert_called_with(22, 7, station, ack=True)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { GxNotice } from "./notice.js"
|
||||
import { GxState } from "./state.js"
|
||||
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
|
||||
|
||||
const validPassword = password => typeof password === 'string' && (password === '' || /^[\x21-\x7e]{12,63}$/.test(password))
|
||||
|
||||
export function validHotspot(value) {
|
||||
return value?.version === 1 && /^[a-f0-9]{64}$/.test(value.revision) &&
|
||||
typeof value.editable === 'boolean' && typeof value.active === 'boolean' &&
|
||||
@@ -10,15 +12,18 @@ export function validHotspot(value) {
|
||||
typeof value.reason === 'string' && value.reason.length <= 256 &&
|
||||
value.url === 'http://172.31.254.1:8082/' && typeof value.config?.enabled === 'boolean' &&
|
||||
/^TheGalaxy-[a-f0-9]{4}$/.test(value.config.ssid) &&
|
||||
typeof value.config.password === 'string' && (value.config.password === '' || /^[\x21-\x7e]{12,63}$/.test(value.config.password))
|
||||
validPassword(value.config.password)
|
||||
}
|
||||
|
||||
export const GalaxyHotspot = {
|
||||
components: { GxNotice, GxState, GalaxySettingRow },
|
||||
props: { mode: String, unauthorized: Function },
|
||||
data: () => ({ snapshot: null, draft: null, revision: '', busy: false, error: '', notice: '',
|
||||
data: () => ({ snapshot: null, draft: null, revision: '', busy: false, error: '', saveError: '', notice: '',
|
||||
showPassword: false, timer: null, controller: null, generation: 0, active: false }),
|
||||
computed: { dirty() { return JSON.stringify(this.draft) !== JSON.stringify(this.snapshot?.config) } },
|
||||
computed: {
|
||||
dirty() { return JSON.stringify(this.draft) !== JSON.stringify(this.snapshot?.config) },
|
||||
passwordValid() { return validPassword(this.draft?.password) },
|
||||
},
|
||||
mounted() { this.start() },
|
||||
beforeUnmount() { this.stop() },
|
||||
watch: { mode() { this.stop(); this.start() } },
|
||||
@@ -27,7 +32,7 @@ export const GalaxyHotspot = {
|
||||
start() { if (this.mode === 'local') { this.active = true; this.refresh() } },
|
||||
stop() {
|
||||
this.active = false; this.generation++; clearTimeout(this.timer); this.controller?.abort()
|
||||
this.snapshot = this.draft = null; this.revision = ''; this.showPassword = false; this.busy = false
|
||||
this.snapshot = this.draft = null; this.revision = ''; this.showPassword = false; this.busy = false; this.saveError = ''
|
||||
},
|
||||
adopt(value, reset = false) {
|
||||
if (!validHotspot(value)) throw new Error('Invalid hotspot status')
|
||||
@@ -53,10 +58,10 @@ export const GalaxyHotspot = {
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
if (!this.active || this.busy || !this.snapshot?.editable) return
|
||||
if (!this.active || this.busy || !this.snapshot?.editable || !this.passwordValid) return
|
||||
clearTimeout(this.timer); this.controller?.abort()
|
||||
const generation = ++this.generation
|
||||
this.controller = new AbortController(); this.busy = true; this.error = ''; this.notice = ''
|
||||
this.controller = new AbortController(); this.busy = true; this.saveError = ''; this.notice = ''
|
||||
try {
|
||||
const value = await requestJson('./api/galaxy/hotspot', { request: { signal: this.controller.signal,
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -68,7 +73,7 @@ export const GalaxyHotspot = {
|
||||
} catch (error) {
|
||||
if (!this.active || generation !== this.generation) return
|
||||
if (error.status === 401) { this.stop(); this.unauthorized(); return }
|
||||
this.error = `${error.message} If Wi-Fi disconnected, reconnect and reload to check whether settings saved.`
|
||||
this.saveError = `${error.message} If Wi-Fi disconnected, reconnect and reload to check whether settings saved.`
|
||||
} finally {
|
||||
if (this.active && generation === this.generation) { this.busy = false; this.timer = setTimeout(() => this.refresh(), 1000) }
|
||||
}
|
||||
@@ -87,7 +92,8 @@ export const GalaxyHotspot = {
|
||||
<p class="gx-note">Fixed to TheGalaxy- plus the last four characters of your comma dongle ID. Once saved, the switch and password survive restarts.</p>
|
||||
<label class="gx-field-group">Password <input class="gx-field" :type="showPassword ? 'text' : 'password'" v-model="draft.password" :disabled="busy || !snapshot.editable" maxlength="63" autocomplete="new-password" placeholder="Blank generates a password" /></label>
|
||||
<label><input type="checkbox" v-model="showPassword" :disabled="busy || !snapshot.editable" /> Show password</label>
|
||||
<div class="gx-settings__controls gx-actions"><button class="gx-btn" type="button" :disabled="!dirty || busy || !snapshot.editable" @click="save">{{ busy ? 'Saving…' : 'Save hotspot' }}</button></div>
|
||||
<p v-if="!passwordValid" class="gx-note">Use a 12–63 character password without spaces, or leave it blank to generate one.</p>
|
||||
<div class="gx-settings__controls gx-actions"><button class="gx-btn" type="button" :disabled="!dirty || busy || !snapshot.editable || !passwordValid" @click="save">{{ busy ? 'Saving…' : 'Save hotspot' }}</button></div>
|
||||
</div>
|
||||
<GxNotice v-if="!snapshot.editable">Park before changing hotspot settings.</GxNotice>
|
||||
<p>Join the network, stay connected if warned of no Internet, then open and bookmark <a :href="snapshot.url">{{ snapshot.url }}</a>.</p>
|
||||
@@ -99,6 +105,6 @@ export const GalaxyHotspot = {
|
||||
<li><i class="bi bi-arrow-repeat" aria-hidden="true"></i><span>Network or channel changes may interrupt access. Changing the password or disabling the hotspot disconnects phones.</span></li>
|
||||
<li><i class="bi bi-globe" aria-hidden="true"></i><span>The public galaxy.firestar.link address still needs Internet and its tunnel.</span></li>
|
||||
</ul>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice><GxNotice v-if="notice" tone="success">{{ notice }}</GxNotice>
|
||||
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice><GxNotice v-if="saveError" tone="danger">{{ saveError }}</GxNotice><GxNotice v-if="notice" tone="success">{{ notice }}</GxNotice>
|
||||
</section>`,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import json
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
|
||||
FIELDS = {"latitude", "longitude", "altitude", "speed", "bearing", "horizontalAccuracy", "verticalAccuracy",
|
||||
"speedAccuracy", "bearingAccuracy", "timestamp", "ageMs"}
|
||||
MAX_AGE_MS = 2000
|
||||
|
||||
|
||||
def _number(payload, key, required=False):
|
||||
value = payload.get(key)
|
||||
if value is None and not required:
|
||||
return math.nan
|
||||
if type(value) not in (int, float) or not math.isfinite(value):
|
||||
raise ValueError(f"Invalid {key}")
|
||||
return float(value)
|
||||
|
||||
|
||||
def _optional(payload, key, low, high):
|
||||
# Phones report an unknown value as null or a negative number.
|
||||
value = _number(payload, key)
|
||||
return value if low <= value < high else math.nan
|
||||
|
||||
|
||||
def _required(payload, key, low, high, *, strict_low=False):
|
||||
value = _number(payload, key, True)
|
||||
if (value <= low if strict_low else value < low) or value >= high:
|
||||
raise ValueError(f"Invalid {key}")
|
||||
return value
|
||||
|
||||
|
||||
def phone_gps(payload, now_ns):
|
||||
if not isinstance(payload, dict) or set(payload) - FIELDS:
|
||||
raise ValueError("Unexpected location fields")
|
||||
latitude, longitude = _number(payload, "latitude", True), _number(payload, "longitude", True)
|
||||
accuracy, age_ms = _number(payload, "horizontalAccuracy", True), _number(payload, "ageMs", True)
|
||||
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180) or (latitude == 0 and longitude == 0):
|
||||
raise ValueError("Invalid position")
|
||||
if not 0 < accuracy <= 10_000:
|
||||
raise ValueError("Invalid accuracy")
|
||||
if not 0 <= age_ms <= MAX_AGE_MS:
|
||||
raise ValueError("Location is too old")
|
||||
timestamp = payload.get("timestamp", 0)
|
||||
if type(timestamp) is not int or not 0 <= timestamp < 2 ** 53:
|
||||
raise ValueError("Invalid timestamp")
|
||||
return {
|
||||
"sourceMonoTime": now_ns - int(age_ms * 1e6),
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"altitude": _required(payload, "altitude", -1000, 10_000),
|
||||
"speed": _required(payload, "speed", 0, 100),
|
||||
"bearingDeg": _required(payload, "bearing", 0, 360),
|
||||
"horizontalAccuracy": accuracy,
|
||||
"verticalAccuracy": _optional(payload, "verticalAccuracy", 0, 10_000),
|
||||
"bearingAccuracyDeg": _required(payload, "bearingAccuracy", 0, 180, strict_low=True),
|
||||
"speedAccuracy": _required(payload, "speedAccuracy", 0, 100),
|
||||
"unixTimestampMillis": timestamp,
|
||||
"hasFix": True,
|
||||
}
|
||||
|
||||
|
||||
def handoff_path():
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
shm = Path("/dev/shm")
|
||||
return (shm if shm.is_dir() else Path(tempfile.gettempdir())) / "starpilot_phone_gps.json"
|
||||
|
||||
|
||||
class PhoneGpsPublisher:
|
||||
"""Hands the newest validated phone fix to navigationd, which publishes it inside starpilotNavigation."""
|
||||
|
||||
def __init__(self, path=None, mono_clock=time.monotonic_ns):
|
||||
self._path, self._mono_clock = path, mono_clock
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def publish(self, payload):
|
||||
gps = phone_gps(payload, self._mono_clock())
|
||||
path = self._path or handoff_path()
|
||||
temporary = path.with_name(path.name + ".tmp")
|
||||
with self._lock:
|
||||
temporary.write_text(json.dumps(gps))
|
||||
temporary.replace(path)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def read_phone_gps(now_ns, path=None):
|
||||
"""The handed-off fix as a Gps-like object, or None when absent, malformed or older than the phone hold."""
|
||||
from types import SimpleNamespace
|
||||
from openpilot.starpilot.gps.source import PHONE_MAX_AGE_NS, valid_fix
|
||||
try:
|
||||
with (path or handoff_path()).open("rb") as source:
|
||||
gps = json.loads(source.read(1 << 16))
|
||||
stamp = gps["sourceMonoTime"]
|
||||
if type(stamp) is not int:
|
||||
return None
|
||||
return valid_fix(SimpleNamespace(**gps), stamp, stamp, now_ns, PHONE_MAX_AGE_NS)
|
||||
except (OSError, ValueError, KeyError, TypeError):
|
||||
return None
|
||||
@@ -1,30 +1,87 @@
|
||||
import json
|
||||
import math
|
||||
import time
|
||||
|
||||
GPS_SOURCES = {"gpsLocationExternal": "ublox", "gpsLocation": "qcomdiag", "starpilotCarState": "car"}
|
||||
GPS_SOURCES = {"gpsLocationExternal": "ublox", "gpsLocation": "qcomdiag", "starpilotCarState": "car", "phoneGps": "phone"}
|
||||
GPS_PRIORITY = {"gpsLocationExternal": 2, "gpsLocation": 2, "starpilotCarState": 1, "phoneGps": 0}
|
||||
WRAPPED_SOURCES = ("starpilotCarState", "phoneGps")
|
||||
GPS_MAX_AGE_NS = 2_500_000_000
|
||||
PHONE_MAX_AGE_NS = 10_000_000_000 # phones in the background send sparsely; keep the last fix this long
|
||||
SOURCE_SERVICES = {"phoneGps": "starpilotNavigation"} # rides inside the navigation message
|
||||
GPS_SERVICES = tuple(dict.fromkeys(SOURCE_SERVICES.get(name, name) for name in GPS_SOURCES))
|
||||
# backup: phone only without comma or car GPS; comma: never the phone; phone: the phone whenever it has a fix.
|
||||
GPS_MODES = ("backup", "comma", "phone")
|
||||
SOURCE_NAMES = {"gpsLocationExternal": "comma", "gpsLocation": "comma", "starpilotCarState": "car", "phoneGps": "phone"}
|
||||
_mode: list = [-math.inf, "backup"]
|
||||
|
||||
|
||||
def gps_mode(now=None):
|
||||
"""The GPS source chosen in Galaxy navigation settings, reread at most once a second."""
|
||||
now = time.monotonic() if now is None else now
|
||||
if now - _mode[0] >= 1.:
|
||||
try:
|
||||
from openpilot.starpilot.storage import starpilot_storage_root
|
||||
with (starpilot_storage_root() / "navigation" / "settings.json").open("rb") as source:
|
||||
value = json.loads(source.read(1 << 20)).get("gpsSource", "backup")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
value = "backup"
|
||||
_mode[:] = [now, value if value in GPS_MODES else "backup"]
|
||||
return _mode[1]
|
||||
|
||||
|
||||
def gps_priority(service, mode):
|
||||
"""Rank of a source under the chosen mode, or None when it must not be used."""
|
||||
if service == "phoneGps":
|
||||
return None if mode == "comma" else 3 if mode == "phone" else 0
|
||||
return GPS_PRIORITY[service]
|
||||
|
||||
|
||||
def max_age_ns(service):
|
||||
return PHONE_MAX_AGE_NS if service == "phoneGps" else GPS_MAX_AGE_NS
|
||||
|
||||
|
||||
def valid_fix(gps, stamp, envelope, now_ns, max_age):
|
||||
if (not gps.hasFix or not 0 < stamp <= envelope <= now_ns <= stamp + max_age or
|
||||
not all(math.isfinite(v) for v in (gps.latitude, gps.longitude, gps.horizontalAccuracy)) or
|
||||
not (-90 <= gps.latitude <= 90 and -180 <= gps.longitude <= 180 and 0 < gps.horizontalAccuracy <= 25)):
|
||||
return None
|
||||
return stamp, gps
|
||||
|
||||
|
||||
def observation(sm, service, now_ns):
|
||||
try:
|
||||
gps = sm[service].gps if service == "starpilotCarState" else sm[service]
|
||||
envelope = int(sm.logMonoTime[service])
|
||||
stamp = int(gps.sourceMonoTime) if service == "starpilotCarState" else envelope
|
||||
if (not sm.valid[service] or not gps.hasFix or
|
||||
not 0 < stamp <= envelope <= now_ns <= stamp + GPS_MAX_AGE_NS or
|
||||
not all(math.isfinite(v) for v in (gps.latitude, gps.longitude, gps.horizontalAccuracy)) or
|
||||
not (-90 <= gps.latitude <= 90 and -180 <= gps.longitude <= 180 and 0 < gps.horizontalAccuracy <= 25)):
|
||||
carrier = SOURCE_SERVICES.get(service, service)
|
||||
gps = (sm[carrier].phoneGps if service == "phoneGps" else sm[carrier].gps if service in WRAPPED_SOURCES else sm[carrier])
|
||||
envelope = int(sm.logMonoTime[carrier])
|
||||
stamp = int(gps.sourceMonoTime) if service in WRAPPED_SOURCES else envelope
|
||||
if not sm.valid[carrier] or valid_fix(gps, stamp, envelope, now_ns, max_age_ns(service)) is None:
|
||||
return None
|
||||
if service != "starpilotCarState" and str(gps.source) != GPS_SOURCES[service]:
|
||||
if service not in WRAPPED_SOURCES and str(gps.source) != GPS_SOURCES[service]:
|
||||
return None
|
||||
return stamp, gps
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OverflowError, RuntimeError):
|
||||
return None
|
||||
|
||||
|
||||
def select_location(sm, now_ns):
|
||||
def select_source(sm, now_ns, mode=None, phone_fix=None):
|
||||
"""The chosen fix and where it came from: (comma|car|phone, (stamp, gps)), or None."""
|
||||
mode = mode or gps_mode()
|
||||
phone = None if mode == "comma" else phone_fix or observation(sm, "phoneGps", now_ns)
|
||||
if mode == "phone" and phone is not None:
|
||||
return "phone", phone
|
||||
native = [fix for service in ("gpsLocationExternal", "gpsLocation")
|
||||
if (fix := observation(sm, service, now_ns)) is not None]
|
||||
return max(native, key=lambda fix: fix[0]) if native else observation(sm, "starpilotCarState", now_ns)
|
||||
if native:
|
||||
return "comma", max(native, key=lambda fix: fix[0])
|
||||
if (car := observation(sm, "starpilotCarState", now_ns)) is not None:
|
||||
return "car", car
|
||||
return ("phone", phone) if phone is not None else None
|
||||
|
||||
|
||||
def select_location(sm, now_ns, mode=None, phone_fix=None):
|
||||
"""phone_fix: the navigation process's own phone fix, which it has before it publishes it."""
|
||||
selected = select_source(sm, now_ns, mode, phone_fix)
|
||||
return selected[1] if selected is not None else None
|
||||
|
||||
|
||||
def bearing(gps):
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import math
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from openpilot.starpilot.gps.phone import PhoneGpsPublisher, phone_gps, read_phone_gps
|
||||
from openpilot.starpilot.gps import source
|
||||
from openpilot.starpilot.gps.source import GPS_SERVICES, PHONE_MAX_AGE_NS, gps_mode, select_location, select_source
|
||||
from openpilot.starpilot.navigation.runtime import publish_status
|
||||
from openpilot.starpilot.navigation.status import NavigationStatusSource
|
||||
|
||||
NOW = 101_000_000_000
|
||||
PATH = Path(tempfile.gettempdir()) / "phone_gps.json"
|
||||
FIX = {"latitude": 40., "longitude": -110., "horizontalAccuracy": 5., "ageMs": 300, "altitude": 100., "speed": 20., "bearing": 90.,
|
||||
"bearingAccuracy": 10., "speedAccuracy": 1., "timestamp": 1_790_000_000_000}
|
||||
|
||||
|
||||
class Messages:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
self.valid = dict.fromkeys(GPS_SERVICES, False)
|
||||
self.logMonoTime = dict.fromkeys(GPS_SERVICES, 0)
|
||||
self.alive = dict.fromkeys(GPS_SERVICES, True)
|
||||
self.seen = dict.fromkeys(GPS_SERVICES, True)
|
||||
self.recv_time = dict.fromkeys(GPS_SERVICES, 0.)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self.data[key]
|
||||
|
||||
def update(self, _timeout):
|
||||
pass
|
||||
|
||||
def send(self, service, message):
|
||||
with log.Event.from_bytes(message.to_bytes()) as event:
|
||||
value = getattr(event, service).to_dict()
|
||||
if service == "starpilotCarState":
|
||||
value["gps"] = SimpleNamespace(**getattr(event, service).gps.to_dict())
|
||||
if service == "starpilotNavigation":
|
||||
value["phoneGps"] = SimpleNamespace(**getattr(event, service).phoneGps.to_dict())
|
||||
self.data[service] = SimpleNamespace(**value)
|
||||
self.valid[service] = event.valid
|
||||
self.logMonoTime[service] = event.logMonoTime
|
||||
self.recv_time[service] = event.logMonoTime / 1e9
|
||||
|
||||
|
||||
class Bus:
|
||||
def __init__(self, sm, now):
|
||||
self.sm, self.now = sm, now
|
||||
|
||||
def send(self, service, message):
|
||||
message.logMonoTime = self.now
|
||||
self.sm.send(service, message)
|
||||
|
||||
|
||||
def publish(sm, payload=FIX, now=NOW, tmp_path=None):
|
||||
"""Galaxy hands the fix over; navigationd publishes it inside starpilotNavigation."""
|
||||
path = (tmp_path or PATH.parent) / "phone_gps.json"
|
||||
result = PhoneGpsPublisher(path=path, mono_clock=lambda: now).publish(dict(payload))
|
||||
publish_status(Bus(sm, now), {"status": "idle"}, read_phone_gps(now, path))
|
||||
return result
|
||||
|
||||
|
||||
def test_phone_is_used_only_without_another_fix():
|
||||
sm = Messages()
|
||||
assert publish(sm) == {"ok": True}
|
||||
stamp, gps = select_location(sm, NOW)
|
||||
assert stamp == NOW - 300_000_000 and gps.longitude == -110. and gps.bearingDeg == 90.
|
||||
|
||||
car = messaging.new_message("starpilotCarState", valid=True, logMonoTime=NOW)
|
||||
car.starpilotCarState.gps = {"sourceMonoTime": NOW - 1, "latitude": 41., "longitude": -111., "horizontalAccuracy": 5., "hasFix": True}
|
||||
sm.send("starpilotCarState", car)
|
||||
assert select_location(sm, NOW)[1].longitude == -111.
|
||||
|
||||
native = messaging.new_message("gpsLocation", valid=True, logMonoTime=NOW - 10_000_000)
|
||||
native.gpsLocation = {"source": "qcomdiag", "hasFix": True, "horizontalAccuracy": 5., "longitude": -90., "latitude": 30.}
|
||||
sm.send("gpsLocation", native)
|
||||
assert select_location(sm, NOW)[1].longitude == -90.
|
||||
|
||||
|
||||
def test_phone_fix_is_held_through_sparse_updates_then_expires():
|
||||
sm = Messages()
|
||||
publish(sm)
|
||||
stamp = NOW - 300_000_000
|
||||
assert select_location(sm, NOW + 2_300_000_000) is not None
|
||||
assert select_location(sm, stamp + PHONE_MAX_AGE_NS) is not None
|
||||
assert select_location(sm, stamp + PHONE_MAX_AGE_NS + 1) is None
|
||||
|
||||
|
||||
def test_navigation_leaves_phone_empty_without_a_fresh_fix(tmp_path):
|
||||
sm = Messages()
|
||||
publish(sm, tmp_path=tmp_path)
|
||||
later = NOW + PHONE_MAX_AGE_NS + 1_000_000_000
|
||||
assert read_phone_gps(later, tmp_path / "phone_gps.json") is None
|
||||
publish_status(Bus(sm, later), {"status": "idle"}, None)
|
||||
assert select_location(sm, later) is None
|
||||
|
||||
|
||||
def test_phone_handoff_ignores_missing_and_damaged_files(tmp_path):
|
||||
assert read_phone_gps(NOW, tmp_path / "missing.json") is None
|
||||
(tmp_path / "bad.json").write_text("{")
|
||||
assert read_phone_gps(NOW, tmp_path / "bad.json") is None
|
||||
|
||||
|
||||
def test_unknown_vertical_accuracy_is_not_used():
|
||||
gps = phone_gps({**FIX, "verticalAccuracy": -1}, NOW)
|
||||
assert math.isnan(gps["verticalAccuracy"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ("altitude", "speed", "bearing", "bearingAccuracy", "speedAccuracy"))
|
||||
def test_partial_fix_is_rejected(field):
|
||||
payload = dict(FIX)
|
||||
payload.pop(field)
|
||||
with pytest.raises(ValueError, match=field):
|
||||
phone_gps(payload, NOW)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", [{"ageMs": 2500}, {"ageMs": -1}, {"latitude": 91.}, {"latitude": 0., "longitude": 0.},
|
||||
{"horizontalAccuracy": 0}, {"latitude": True}, {"extra": 1}, {"timestamp": 1.5},
|
||||
{"speed": -1}, {"altitude": None}, {"bearingAccuracy": 0}, {"speedAccuracy": -1}])
|
||||
def test_invalid_fixes_are_rejected(change):
|
||||
with pytest.raises(ValueError):
|
||||
phone_gps({**FIX, **change}, NOW)
|
||||
|
||||
|
||||
def test_inaccurate_phone_fix_is_published_but_not_selected():
|
||||
sm = Messages()
|
||||
publish(sm, {**FIX, "horizontalAccuracy": 60.})
|
||||
assert sm.valid["starpilotNavigation"] and select_location(sm, NOW) is None
|
||||
|
||||
|
||||
def test_navigation_status_ranks_phone_below_native():
|
||||
sm = Messages()
|
||||
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
||||
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
||||
status.mono_clock = lambda: NOW
|
||||
publish(sm)
|
||||
assert (status.map_position() or {})['longitude'] == -110.
|
||||
native = messaging.new_message("gpsLocation", valid=True, logMonoTime=NOW - 500_000_000)
|
||||
native.gpsLocation = {"source": "qcomdiag", "hasFix": True, "horizontalAccuracy": 5., "longitude": -90., "latitude": 30.}
|
||||
sm.send("gpsLocation", native)
|
||||
assert (status.map_position() or {})['longitude'] == -90.
|
||||
|
||||
|
||||
def native(sm):
|
||||
message = messaging.new_message("gpsLocation", valid=True, logMonoTime=NOW - 10_000_000)
|
||||
message.gpsLocation = {"source": "qcomdiag", "hasFix": True, "horizontalAccuracy": 5., "longitude": -90., "latitude": 30.}
|
||||
sm.send("gpsLocation", message)
|
||||
|
||||
|
||||
def test_gps_modes_choose_phone_first_or_never():
|
||||
sm = Messages()
|
||||
publish(sm)
|
||||
assert select_location(sm, NOW, "comma") is None
|
||||
native(sm)
|
||||
assert select_location(sm, NOW, "backup")[1].longitude == -90.
|
||||
assert select_location(sm, NOW, "comma")[1].longitude == -90.
|
||||
assert select_location(sm, NOW, "phone")[1].longitude == -110.
|
||||
assert select_location(sm, NOW + 2_300_000_000, "phone")[1].longitude == -110.
|
||||
|
||||
|
||||
def test_gps_mode_follows_navigation_settings(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("openpilot.starpilot.storage.starpilot_storage_root", lambda: tmp_path)
|
||||
monkeypatch.setattr(source, "_mode", [-math.inf, "backup"])
|
||||
assert gps_mode(10.) == "backup"
|
||||
(tmp_path / "navigation").mkdir()
|
||||
(tmp_path / "navigation" / "settings.json").write_text('{"gpsSource": "phone"}')
|
||||
assert gps_mode(10.5) == "backup"
|
||||
assert gps_mode(11.) == "phone"
|
||||
(tmp_path / "navigation" / "settings.json").write_text('{"gpsSource": "tunnel"}')
|
||||
assert gps_mode(12.) == "backup"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,longitude,name", [("backup", -90., "comma"), ("phone", -110., "phone"), ("comma", -90., "comma")])
|
||||
def test_navigation_status_reports_the_source_it_follows(mode, longitude, name):
|
||||
sm = Messages()
|
||||
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
||||
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
||||
status.mono_clock = lambda: NOW
|
||||
publish(sm)
|
||||
native(sm)
|
||||
with patch('openpilot.starpilot.navigation.status.gps_mode', return_value=mode):
|
||||
position = status.map_position()
|
||||
assert position is not None and position['longitude'] == longitude and position['source'] == name
|
||||
assert 0 < position['validForMs'] <= 2500
|
||||
|
||||
|
||||
def test_phone_map_lease_is_capped_without_shortening_source_timeout():
|
||||
sm = Messages()
|
||||
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
||||
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
||||
clock = [NOW]
|
||||
status.mono_clock = lambda: clock[0]
|
||||
status.boot_clock = lambda: clock[0] + 1_000_000_000
|
||||
publish(sm)
|
||||
with patch('openpilot.starpilot.navigation.status.gps_mode', return_value='backup'):
|
||||
position = status.map_position()
|
||||
assert position is not None and position['validForMs'] == 2500
|
||||
clock[0] += 8_000_000_000
|
||||
position = status.map_position()
|
||||
assert position is not None and position['validForMs'] == 1700
|
||||
clock[0] += 2_000_000_000
|
||||
assert status.map_position() is None
|
||||
|
||||
|
||||
def test_car_gps_ranks_against_the_phone_in_every_mode():
|
||||
sm = Messages()
|
||||
publish(sm)
|
||||
car = messaging.new_message("starpilotCarState", valid=True, logMonoTime=NOW)
|
||||
car.starpilotCarState.gps = {"sourceMonoTime": NOW - 1, "latitude": 41., "longitude": -111., "horizontalAccuracy": 5., "hasFix": True}
|
||||
sm.send("starpilotCarState", car)
|
||||
assert select_location(sm, NOW, "backup")[1].longitude == -111.
|
||||
assert select_location(sm, NOW, "comma")[1].longitude == -111.
|
||||
assert select_location(sm, NOW, "phone")[1].longitude == -110.
|
||||
with patch('openpilot.cereal.messaging.SubMaster', return_value=sm):
|
||||
status = NavigationStatusSource(mono_clock=lambda: NOW - 1_000_000_000, boot_clock=lambda: NOW)
|
||||
status.mono_clock = lambda: NOW
|
||||
for mode, name in (("backup", "car"), ("comma", "car"), ("phone", "phone")):
|
||||
with patch('openpilot.starpilot.navigation.status.gps_mode', return_value=mode):
|
||||
assert (status.map_position() or {})['source'] == name
|
||||
|
||||
|
||||
def test_selected_source_is_named():
|
||||
sm = Messages()
|
||||
assert select_source(sm, NOW) is None
|
||||
publish(sm)
|
||||
assert select_source(sm, NOW)[0] == "phone"
|
||||
car = messaging.new_message("starpilotCarState", valid=True, logMonoTime=NOW)
|
||||
car.starpilotCarState.gps = {"sourceMonoTime": NOW - 1, "latitude": 41., "longitude": -111., "horizontalAccuracy": 5., "hasFix": True}
|
||||
sm.send("starpilotCarState", car)
|
||||
assert select_source(sm, NOW)[0] == "car"
|
||||
native(sm)
|
||||
assert select_source(sm, NOW)[0] == "comma"
|
||||
assert select_source(sm, NOW, "phone")[0] == "phone"
|
||||
@@ -1 +1 @@
|
||||
{"binarySha256":"348945f48bb8d8285aeb52840b562c7194d6f62cf8dc9f4b7bf63f75a00c35b0","goVersion":"go1.25.1","schemaVersion":1,"sourceDigest":"043c74946c10c3a06a4bd6f1470e6510e429e44cc03a28bcf49f8afd6e3fc787","sourceRevision":"a441889d43c31fad17059ec09defd1f71eb0eb85","target":"linux-arm64-static","upstreamRevision":"7201c6b4b4ec1b0b9ea21daa8c05b80fdd7e01ee"}
|
||||
{"binarySha256":"327883964b0cbde92dd6c6ae03670495dd97d57869c806bb64cfbcfb73d96552","goVersion":"go1.25.1","schemaVersion":1,"sourceDigest":"8a77333de0bb02a9b3acd151a662cacaa078dbea36c1de62da790cff8ab1549c","sourceRevision":"127ddb75b6eb2861ce6327e42e12da80b318dd91","target":"linux-arm64-static","upstreamRevision":"7201c6b4b4ec1b0b9ea21daa8c05b80fdd7e01ee"}
|
||||
|
||||
Binary file not shown.
@@ -244,11 +244,14 @@ def enabled(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
# processors so the runtime and its GC workers stay off most cores.
|
||||
SHADOW_NICE = 10
|
||||
SHADOW_GOMAXPROCS = '2'
|
||||
# Soft heap cap about 2x the densest tile's live set; Go returns the rest to the OS.
|
||||
SHADOW_GOMEMLIMIT = '64MiB'
|
||||
|
||||
|
||||
def shadow_launcher(pargs: list[str], cwd: str, name: str) -> None:
|
||||
"""In the forked child before exec, so every Go thread inherits it and the manager is untouched."""
|
||||
os.environ['GOMAXPROCS'] = SHADOW_GOMAXPROCS
|
||||
os.environ['GOMEMLIMIT'] = SHADOW_GOMEMLIMIT
|
||||
try:
|
||||
os.nice(SHADOW_NICE)
|
||||
except OSError:
|
||||
|
||||
@@ -212,16 +212,19 @@ class TestShadowLifecycle(unittest.TestCase):
|
||||
parent_nice = os.nice(0)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out = Path(directory) / 'policy'
|
||||
script = f'import os; open({str(out)!r}, "w").write(f"{{os.nice(0)}} {{os.environ.get(\'GOMAXPROCS\')}}")'
|
||||
env = "{os.environ.get('GOMAXPROCS')} {os.environ.get('GOMEMLIMIT')}"
|
||||
script = f'import os; open({str(out)!r}, "w").write(f"{{os.nice(0)}} {env}")'
|
||||
child = Process(target=shadow.shadow_launcher, args=([sys.executable, '-c', script], directory, 'mapd_shadow'))
|
||||
child.start()
|
||||
child.join(10)
|
||||
self.assertEqual(child.exitcode, 0)
|
||||
nice, procs = out.read_text().split()
|
||||
nice, procs, limit = out.read_text().split()
|
||||
self.assertEqual(int(nice), min(parent_nice + shadow.SHADOW_NICE, 19))
|
||||
self.assertEqual(procs, shadow.SHADOW_GOMAXPROCS)
|
||||
self.assertEqual(limit, shadow.SHADOW_GOMEMLIMIT)
|
||||
self.assertEqual(os.nice(0), parent_nice)
|
||||
self.assertNotEqual(os.environ.get('GOMAXPROCS'), shadow.SHADOW_GOMAXPROCS)
|
||||
self.assertNotEqual(os.environ.get('GOMEMLIMIT'), shadow.SHADOW_GOMEMLIMIT)
|
||||
|
||||
def test_exited_child_backoff_and_pending_shutdown(self):
|
||||
now = [10.0]
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Read-only Jetlink status for the home icon and the Android Auto card, polled off the drawing thread."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
STALE_NS = 2_500_000_000
|
||||
# The host's processing time for the last big-model frame (excludes USB); jetlink's runtime record does not carry it
|
||||
LATENCY_RECORD = "/dev/shm/jetlink/starpilot_latency.json"
|
||||
LATENCY_FRESH_S = 3.
|
||||
RUNNING = {'ready': 'ready', 'joining': 'connecting', 'retrying': 'connecting'}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JetlinkStatus:
|
||||
device: str = ''
|
||||
state: str = 'off' # off, disconnected, connected, connecting, ready, active, unavailable
|
||||
latency_ms: float | None = None
|
||||
port: str | None = None # jetlink's port_state: 'connected' (host on the link), 'device' (plugged in), else None
|
||||
|
||||
|
||||
def _write_latency(model, path) -> None:
|
||||
timings = getattr(getattr(model, "client", None), "last_timings", None) # the big model's client while it drives
|
||||
total_us = timings[2] if isinstance(timings, (tuple, list)) and len(timings) == 3 else 0
|
||||
record = {"at": time.monotonic(), "server_ms": total_us / 1000 if type(total_us) in (int, float) and total_us > 0 else None}
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = f"{path}.tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(record, f)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def start_latency_writer(model, path=LATENCY_RECORD, period=1.):
|
||||
"""modeld, before it goes realtime, so the thread keeps normal priority and the frame loop does no file IO."""
|
||||
def run():
|
||||
while True:
|
||||
try:
|
||||
_write_latency(model, path)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(period)
|
||||
thread = threading.Thread(target=run, name="jetlink-latency", daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def read_latency(path=LATENCY_RECORD) -> float | None:
|
||||
try:
|
||||
with open(path) as f:
|
||||
record = json.load(f)
|
||||
ms, at = record.get("server_ms"), record.get("at")
|
||||
fresh = type(at) in (int, float) and 0 <= time.monotonic() - at < LATENCY_FRESH_S
|
||||
return float(ms) if fresh and type(ms) in (int, float) and math.isfinite(ms) and ms > 0 else None
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_status() -> JetlinkStatus:
|
||||
from openpilot.starpilot.models.jetlink_adapter import hooks, link_on
|
||||
if not link_on():
|
||||
return JetlinkStatus() # one file read: nothing of jetlink's loads
|
||||
status = hooks().status()
|
||||
if status is None:
|
||||
return JetlinkStatus(state='unavailable')
|
||||
if not status.enabled:
|
||||
return JetlinkStatus() # a Chestnut is fitted
|
||||
device = 'iPhone' if status.mode == 'ios' else 'Computer'
|
||||
port = status.port_state if status.port_state in ('connected', 'device') else None
|
||||
if not status.present:
|
||||
return JetlinkStatus(device, 'disconnected', port=port)
|
||||
if status.active:
|
||||
return JetlinkStatus(device, 'active', read_latency(), port)
|
||||
if status.reason:
|
||||
return JetlinkStatus(device, 'unavailable', port=port)
|
||||
return JetlinkStatus(device, RUNNING.get(status.running, 'connected'), port=port)
|
||||
|
||||
|
||||
class JetlinkStatusFeed:
|
||||
"""Polls once a second only while enabled; snapshot() never does file IO."""
|
||||
def __init__(self, read=read_status, period=1.0):
|
||||
self.read, self.period = read, period
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread = None
|
||||
self._enabled = False
|
||||
self._status = JetlinkStatus()
|
||||
self._read_at = 0
|
||||
|
||||
def set_enabled(self, enabled):
|
||||
with self._lock:
|
||||
self._enabled = enabled
|
||||
if not enabled:
|
||||
self._status = JetlinkStatus()
|
||||
if enabled and self._thread is None:
|
||||
self._thread = threading.Thread(target=self._run, name='jetlink-status', daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def poll(self):
|
||||
with self._lock:
|
||||
if not self._enabled:
|
||||
return
|
||||
try:
|
||||
status = self.read()
|
||||
except Exception:
|
||||
status = JetlinkStatus(state='unavailable')
|
||||
with self._lock:
|
||||
if self._enabled:
|
||||
self._status = status
|
||||
self._read_at = time.monotonic_ns()
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
status, read_at = self._status, self._read_at
|
||||
if status.state != 'off' and time.monotonic_ns() - read_at > STALE_NS:
|
||||
return JetlinkStatus(status.device, 'unavailable')
|
||||
return status
|
||||
|
||||
def _run(self):
|
||||
while not self._stop.is_set():
|
||||
self.poll()
|
||||
self._stop.wait(self.period)
|
||||
|
||||
def close(self):
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2)
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Jetlink's home icon and Android Auto card read one status, off the drawing thread, never keeping stale latency."""
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openpilot.starpilot.models import jetlink_adapter as adapter, jetlink_status
|
||||
from openpilot.starpilot.models.jetlink_status import JetlinkStatus, JetlinkStatusFeed, read_status
|
||||
|
||||
|
||||
def status(**kw):
|
||||
base = {'enabled': True, 'mode': 'usb', 'present': True, 'port_state': 'connected', 'active': False, 'reason': None, 'running': None}
|
||||
return NS(**{**base, **kw})
|
||||
|
||||
|
||||
def read(value, latency=24.6):
|
||||
with patch.object(adapter, 'link_on', return_value=True), \
|
||||
patch.object(adapter, 'hooks', return_value=NS(status=lambda: value)), \
|
||||
patch.object(jetlink_status, 'read_latency', return_value=latency):
|
||||
return read_status()
|
||||
|
||||
|
||||
def test_off_reads_nothing_of_jetlink():
|
||||
with patch.object(adapter, 'link_on', return_value=False), patch.object(adapter, 'hooks') as hooks:
|
||||
assert read_status() == JetlinkStatus()
|
||||
hooks.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kw,expected', [
|
||||
({'active': True, 'running': 'running'}, JetlinkStatus('Computer', 'active', 24.6, 'connected')),
|
||||
({'running': 'ready'}, JetlinkStatus('Computer', 'ready', port='connected')),
|
||||
({'running': 'joining'}, JetlinkStatus('Computer', 'connecting', port='connected')),
|
||||
({'running': 'retrying'}, JetlinkStatus('Computer', 'connecting', port='connected')),
|
||||
({'running': None}, JetlinkStatus('Computer', 'connected', port='connected')),
|
||||
({'reason': 'service stopped'}, JetlinkStatus('Computer', 'unavailable', port='connected')),
|
||||
({'present': False, 'port_state': 'device'}, JetlinkStatus('Computer', 'disconnected', port='device')),
|
||||
({'present': False, 'port_state': 'empty'}, JetlinkStatus('Computer', 'disconnected')),
|
||||
({'present': False, 'port_state': None}, JetlinkStatus('Computer', 'disconnected')),
|
||||
({'mode': 'ios', 'active': True}, JetlinkStatus('iPhone', 'active', 24.6, 'connected')),
|
||||
({'enabled': False}, JetlinkStatus()),
|
||||
])
|
||||
def test_states(kw, expected):
|
||||
assert read(status(**kw)) == expected
|
||||
|
||||
|
||||
def test_no_usable_jetlink_is_unavailable():
|
||||
assert read(None) == JetlinkStatus(state='unavailable')
|
||||
|
||||
|
||||
def test_latency_record_round_trip_and_staleness(tmp_path):
|
||||
path = str(tmp_path / 'jetlink' / 'latency.json')
|
||||
jetlink_status._write_latency(NS(client=NS(last_timings=(1, 2, 24600))), path)
|
||||
assert jetlink_status.read_latency(path) == pytest.approx(24.6)
|
||||
jetlink_status._write_latency(NS(client=None), path) # the small model drives
|
||||
assert jetlink_status.read_latency(path) is None
|
||||
with open(path, 'w') as f:
|
||||
json.dump({'at': time.monotonic() - jetlink_status.LATENCY_FRESH_S - 1, 'server_ms': 24.6}, f)
|
||||
assert jetlink_status.read_latency(path) is None
|
||||
assert jetlink_status.read_latency(str(tmp_path / 'missing.json')) is None
|
||||
|
||||
|
||||
def test_feed_polls_off_thread_expires_and_disables():
|
||||
arrived = threading.Event()
|
||||
threads = []
|
||||
def fake():
|
||||
threads.append(threading.get_ident())
|
||||
arrived.set()
|
||||
return JetlinkStatus('iPhone', 'active', 24.6, 'connected')
|
||||
feed = JetlinkStatusFeed(read=fake, period=0.01)
|
||||
try:
|
||||
assert feed.snapshot() == JetlinkStatus()
|
||||
feed.set_enabled(True)
|
||||
assert arrived.wait(2)
|
||||
assert threading.get_ident() not in threads
|
||||
assert feed.snapshot().latency_ms == 24.6
|
||||
feed.set_enabled(False)
|
||||
assert feed.snapshot() == JetlinkStatus()
|
||||
finally:
|
||||
feed.close()
|
||||
|
||||
|
||||
def test_feed_stale_and_failed_reads_drop_latency_and_port():
|
||||
feed = JetlinkStatusFeed(read=lambda: JetlinkStatus('iPhone', 'active', 24.6, 'connected'))
|
||||
feed._enabled = True
|
||||
feed.poll()
|
||||
assert feed.snapshot().port == 'connected'
|
||||
feed._read_at = 0
|
||||
assert feed.snapshot() == JetlinkStatus('iPhone', 'unavailable')
|
||||
feed.read = lambda: 1 / 0
|
||||
feed.poll()
|
||||
assert feed.snapshot() == JetlinkStatus(state='unavailable')
|
||||
@@ -23,6 +23,7 @@ import requests
|
||||
|
||||
from openpilot.starpilot.storage import starpilot_storage_root
|
||||
from openpilot.starpilot.navigation.position import LastPositionStore
|
||||
from openpilot.starpilot.gps.source import GPS_MODES
|
||||
|
||||
MAX_DOCUMENT = 256 * 1024
|
||||
MAX_RESPONSE = 8 * 1024 * 1024
|
||||
@@ -145,7 +146,7 @@ class NavigationOwner:
|
||||
try:
|
||||
info = os.stat(self.path)
|
||||
except FileNotFoundError:
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': [],
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': [], 'gpsSource': 'backup',
|
||||
**dict.fromkeys(ROUTE_AVOIDANCES, False)}
|
||||
key = (info.st_ino, info.st_mtime_ns, info.st_size)
|
||||
cached = getattr(self, '_read_cache', None)
|
||||
@@ -155,7 +156,7 @@ class NavigationOwner:
|
||||
with self.path.open('rb') as source:
|
||||
raw = source.read(MAX_DOCUMENT + 1)
|
||||
except FileNotFoundError:
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': [],
|
||||
return {'version': 1, 'revision': '0', 'enabled': False, 'token': '', 'destination': None, 'favorites': [], 'recents': [], 'gpsSource': 'backup',
|
||||
**dict.fromkeys(ROUTE_AVOIDANCES, False)}
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
@@ -175,6 +176,8 @@ class NavigationOwner:
|
||||
for setting in ROUTE_AVOIDANCES:
|
||||
if type(value.setdefault(setting, False)) is not bool:
|
||||
raise ValueError
|
||||
if value.setdefault('gpsSource', 'backup') not in GPS_MODES:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError, KeyError):
|
||||
raise ValidationError('Saved navigation settings could not be read') from None
|
||||
self._read_cache = (key, copy.deepcopy(value))
|
||||
@@ -330,6 +333,7 @@ class NavigationOwner:
|
||||
'noDestination' if document['destination'] is None else 'waitingForLocation')
|
||||
result = {key: document[key] for key in ('enabled', 'destination', 'recents', 'revision')}
|
||||
result.update({key: document[key] for key in ROUTE_AVOIDANCES})
|
||||
result['gpsSource'] = document['gpsSource']
|
||||
# Home and Work lead, then places in the order they were saved.
|
||||
result['favorites'] = sorted(document['favorites'], key=lambda row: FAVORITE_LABELS.index(row['label']) if 'label' in row else 2)
|
||||
from openpilot.starpilot.navigation.mapbox_budget import read_usage
|
||||
@@ -478,8 +482,10 @@ class NavigationOwner:
|
||||
self._tile_bytes -= len(tile)
|
||||
|
||||
def configure(self, patch: dict, expected_revision: str, authorized) -> dict:
|
||||
if not isinstance(patch, dict) or not patch or set(patch) - ({'enabled', 'token'} | ROUTE_AVOIDANCES.keys()):
|
||||
if not isinstance(patch, dict) or not patch or set(patch) - ({'enabled', 'token', 'gpsSource'} | ROUTE_AVOIDANCES.keys()):
|
||||
raise ValidationError('Unknown navigation setting')
|
||||
if 'gpsSource' in patch and patch['gpsSource'] not in GPS_MODES:
|
||||
raise ValidationError('Choose a GPS source')
|
||||
if 'enabled' in patch and type(patch['enabled']) is not bool:
|
||||
raise ValidationError('Navigation enabled must be on or off')
|
||||
if any(type(patch[key]) is not bool for key in ROUTE_AVOIDANCES if key in patch):
|
||||
@@ -493,7 +499,7 @@ class NavigationOwner:
|
||||
if any(key in patch and patch[key] != value[key] for key in ROUTE_AVOIDANCES):
|
||||
value['routeChoice'] = 0
|
||||
value.update(patch)
|
||||
result = self._change(update, expected_revision, authorized, preserve_active=set(patch) <= ROUTE_AVOIDANCES.keys())
|
||||
result = self._change(update, expected_revision, authorized, preserve_active=set(patch) <= ROUTE_AVOIDANCES.keys() | {'gpsSource'})
|
||||
if 'token' in patch:
|
||||
with self._tile_lock:
|
||||
self._tiles.clear()
|
||||
|
||||
@@ -9,14 +9,15 @@ import uuid
|
||||
from openpilot.starpilot.navigation.owner import ConflictError, NavigationOwner, ValidationError, ROUTE_AVOIDANCES
|
||||
from openpilot.starpilot.navigation.route_engine import MapboxRouteEngine
|
||||
|
||||
from openpilot.starpilot.gps.source import GPS_MAX_AGE_NS, GPS_SOURCES, bearing, select_location
|
||||
from openpilot.starpilot.gps.phone import read_phone_gps
|
||||
from openpilot.starpilot.gps.source import GPS_MAX_AGE_NS, GPS_SERVICES, bearing, select_location
|
||||
|
||||
ARRIVED_NS = 5_000_000_000 # stopped at the destination this long shows "You have arrived"
|
||||
END_ROUTE_NS = 15_000_000_000 # ...and this long ends navigation, like tapping End navigation
|
||||
|
||||
|
||||
def location(sm, now_ns: int):
|
||||
fix = select_location(sm, now_ns)
|
||||
def location(sm, now_ns: int, phone_fix=None):
|
||||
fix = select_location(sm, now_ns, phone_fix=phone_fix)
|
||||
if fix is None:
|
||||
return None
|
||||
stamp, gps = fix
|
||||
@@ -148,11 +149,19 @@ class RouteRuntime:
|
||||
self.ended_revision = revision
|
||||
|
||||
|
||||
def publish_status(pm, value: dict) -> None:
|
||||
def phone_gps_dict(phone_fix) -> dict | None:
|
||||
if phone_fix is None:
|
||||
return None
|
||||
gps = dict(vars(phone_fix[1]))
|
||||
gps["vNED"] = list(gps.get("vNED", ()))
|
||||
return gps
|
||||
|
||||
|
||||
def publish_status(pm, value: dict, phone_fix=None) -> None:
|
||||
from openpilot.cereal import messaging
|
||||
message = messaging.new_message('starpilotNavigation', valid=True)
|
||||
from openpilot.starpilot.navigation.wire import navigation_envelope
|
||||
message.starpilotNavigation = navigation_envelope(value)
|
||||
message.starpilotNavigation = {**navigation_envelope(value), **({'phoneGps': gps} if (gps := phone_gps_dict(phone_fix)) else {})}
|
||||
pm.send('starpilotNavigation', message)
|
||||
|
||||
|
||||
@@ -162,9 +171,12 @@ def main():
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
owner = NavigationOwner()
|
||||
runtime = RouteRuntime(owner)
|
||||
sm = messaging.SubMaster(['deviceState', *GPS_SOURCES])
|
||||
sm = messaging.SubMaster(['deviceState', *(service for service in GPS_SERVICES if service != 'starpilotNavigation')])
|
||||
pm = messaging.PubMaster(['starpilotNavigation'])
|
||||
rate = Ratekeeper(1.)
|
||||
# Forward new phone measurements promptly; held fixes still need only a 1 Hz heartbeat.
|
||||
rate = Ratekeeper(5.)
|
||||
next_update_ns = 0
|
||||
last_phone_stamp = 0
|
||||
try:
|
||||
while True:
|
||||
sm.update(0)
|
||||
@@ -174,8 +186,13 @@ def main():
|
||||
drive_id = (device.startedMonoTime if sm.valid['deviceState'] and device.started and
|
||||
0 < device.startedMonoTime <= device_stamp <= now <= device_stamp + 2_000_000_000 else 0)
|
||||
try:
|
||||
value = runtime.update(now, location(sm, now), drive_id)
|
||||
publish_status(pm, value)
|
||||
phone_fix = read_phone_gps(now)
|
||||
phone_stamp = phone_fix[0] if phone_fix is not None else 0
|
||||
if now >= next_update_ns or phone_stamp != last_phone_stamp:
|
||||
next_update_ns = now + 1_000_000_000
|
||||
last_phone_stamp = phone_stamp
|
||||
value = runtime.update(now, location(sm, now, phone_fix), drive_id)
|
||||
publish_status(pm, value, phone_fix)
|
||||
except (ValidationError, OSError, ValueError):
|
||||
cloudlog.warning('navigation settings or route unavailable')
|
||||
rate.keep_time()
|
||||
|
||||
@@ -4,7 +4,7 @@ from openpilot.starpilot.parked_evidence import RESUME_SKEW_NS
|
||||
|
||||
from openpilot.starpilot.navigation.wire import navigation_state
|
||||
|
||||
from openpilot.starpilot.gps.source import GPS_MAX_AGE_NS as GPS_TTL_NS, GPS_SOURCES, bearing, observation
|
||||
from openpilot.starpilot.gps.source import GPS_SERVICES, GPS_SOURCES, SOURCE_NAMES, SOURCE_SERVICES, bearing, gps_mode, gps_priority, max_age_ns, observation
|
||||
|
||||
|
||||
def boot_time_ns():
|
||||
@@ -14,7 +14,7 @@ def boot_time_ns():
|
||||
class NavigationStatusSource:
|
||||
def __init__(self, *, mono_clock=time.monotonic_ns, boot_clock=boot_time_ns):
|
||||
from openpilot.cereal import messaging
|
||||
self.sm = messaging.SubMaster(['starpilotNavigation', *GPS_SOURCES, 'deviceState'])
|
||||
self.sm = messaging.SubMaster(list(dict.fromkeys(['starpilotNavigation', *GPS_SERVICES, 'deviceState'])))
|
||||
self.mono_clock, self.boot_clock = mono_clock, boot_clock
|
||||
self.gps_after_mono_ns = mono_clock()
|
||||
self.gps_offset_ns = None
|
||||
@@ -72,23 +72,28 @@ class NavigationStatusSource:
|
||||
self.gps_offset_ns = offset
|
||||
return None
|
||||
candidates = []
|
||||
mode = gps_mode()
|
||||
for service in GPS_SOURCES:
|
||||
try:
|
||||
fix = observation(self.sm, service, after)
|
||||
priority = gps_priority(service, mode)
|
||||
fix = None if priority is None else observation(self.sm, service, after)
|
||||
if fix is None:
|
||||
continue
|
||||
stamp, gps = fix
|
||||
receipt = int(self.sm.recv_time[service] * 1e9)
|
||||
if (self.sm.seen[service] and self.sm.alive[service] and self.gps_after_mono_ns < stamp and
|
||||
0 <= after - receipt <= GPS_TTL_NS):
|
||||
carrier, ttl = SOURCE_SERVICES.get(service, service), max_age_ns(service)
|
||||
receipt = int(self.sm.recv_time[carrier] * 1e9)
|
||||
if (self.sm.seen[carrier] and self.sm.alive[carrier] and self.gps_after_mono_ns < stamp and
|
||||
0 <= after - receipt <= ttl):
|
||||
direction = bearing(gps)
|
||||
candidates.append((service != "starpilotCarState", stamp,
|
||||
{'longitude': float(gps.longitude), 'latitude': float(gps.latitude),
|
||||
candidates.append((priority, stamp,
|
||||
{'longitude': float(gps.longitude), 'latitude': float(gps.latitude), 'source': SOURCE_NAMES[service],
|
||||
**({'bearing': direction} if direction is not None else {}),
|
||||
'validForMs': min(stamp + GPS_TTL_NS - after, receipt + GPS_TTL_NS - after) / 1e6}))
|
||||
# Galaxy leases map markers for at most 2.5 seconds, even when
|
||||
# a background phone fix has a longer source timeout.
|
||||
'validForMs': min(2500., (stamp + ttl - after) / 1e6, (receipt + ttl - after) / 1e6)}))
|
||||
except (AttributeError, KeyError, TypeError, ValueError, OverflowError, RuntimeError):
|
||||
continue
|
||||
return max(candidates, default=(False, 0, None), key=lambda row: row[:2])[2]
|
||||
return max(candidates, default=(-1, 0, None), key=lambda row: row[:2])[2]
|
||||
|
||||
def close(self):
|
||||
for socket in getattr(self.sm, 'sock', {}).values():
|
||||
|
||||
@@ -21,6 +21,15 @@ def owner(tmp_path, monkeypatch):
|
||||
return NavigationOwner(tmp_path, runtime_source=lambda: None, transient_root=tmp_path / 'runtime')
|
||||
|
||||
|
||||
def test_gps_source_defaults_saves_and_rejects_unknown(owner):
|
||||
assert owner.snapshot()['gpsSource'] == 'backup'
|
||||
saved = owner.configure({'gpsSource': 'phone'}, '0', True)
|
||||
assert owner.snapshot()['gpsSource'] == 'phone'
|
||||
assert json.loads(owner.path.read_text())['gpsSource'] == 'phone'
|
||||
with pytest.raises(ValidationError):
|
||||
owner.configure({'gpsSource': 'tunnel'}, saved['revision'], True)
|
||||
|
||||
|
||||
def test_preferences_default_and_survive_restart(owner):
|
||||
assert all(owner.snapshot()[key] is False for key in ROUTE_AVOIDANCES)
|
||||
saved = owner.configure({'enabled': True, 'token': 'pk.test', 'avoidTolls': True}, '0', True)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from concurrent.futures import Future
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,6 +11,65 @@ from openpilot.starpilot.navigation.route_engine import NavigationRoute, project
|
||||
from openpilot.starpilot.navigation.runtime import RouteRuntime, location, publish_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize('changes, expected', [
|
||||
({0: 1}, [0, 5, 10]), # sparse/background measurements keep the normal heartbeat
|
||||
({index: index + 1 for index in range(11)}, list(range(11))),
|
||||
({0: 1, 2: 2, 3: 0}, [0, 2, 3, 8]), # replacement and expiry are forwarded immediately
|
||||
])
|
||||
def test_navigation_forwards_new_phone_fixes_without_waiting_for_heartbeat(monkeypatch, changes, expected):
|
||||
from openpilot.cereal import messaging
|
||||
from openpilot.starpilot.navigation import runtime as module
|
||||
|
||||
frame = [0]
|
||||
stamp = [0]
|
||||
phone = SimpleNamespace(speed=0.)
|
||||
def now():
|
||||
return 100_000_000_000 + frame[0] * 200_000_000
|
||||
|
||||
def read_phone(_now):
|
||||
stamp[0] = changes.get(frame[0], stamp[0])
|
||||
return (99_000_000_000 + stamp[0], phone) if stamp[0] else None
|
||||
|
||||
class Finished(Exception):
|
||||
pass
|
||||
|
||||
class Rate:
|
||||
def __init__(self, hz):
|
||||
assert hz == 5.
|
||||
|
||||
def keep_time(self):
|
||||
frame[0] += 1
|
||||
if frame[0] > 10:
|
||||
raise Finished
|
||||
|
||||
owner, runtime = Mock(), Mock()
|
||||
# Only device-state validity is relevant to this loop; source selection is tested separately.
|
||||
class Master:
|
||||
valid, logMonoTime = {'deviceState': False}, {'deviceState': 0}
|
||||
def update(self, _timeout):
|
||||
pass
|
||||
def __getitem__(self, _service):
|
||||
return SimpleNamespace(startedMonoTime=0)
|
||||
|
||||
published = []
|
||||
monkeypatch.setitem(sys.modules, 'openpilot.common.realtime', SimpleNamespace(Ratekeeper=Rate))
|
||||
monkeypatch.setattr(module, 'NavigationOwner', lambda: owner)
|
||||
monkeypatch.setattr(module, 'RouteRuntime', lambda _owner: runtime)
|
||||
monkeypatch.setattr(module.time, 'monotonic_ns', now)
|
||||
monkeypatch.setattr(module, 'read_phone_gps', read_phone)
|
||||
monkeypatch.setattr(module, 'location', lambda _sm, _now, fix: fix)
|
||||
monkeypatch.setattr(module, 'publish_status', lambda _pm, _value, fix: published.append((frame[0], fix)))
|
||||
monkeypatch.setattr(messaging, 'SubMaster', lambda _services: Master())
|
||||
monkeypatch.setattr(messaging, 'PubMaster', lambda _services: None)
|
||||
with pytest.raises(Finished):
|
||||
module.main()
|
||||
assert [index for index, _fix in published] == expected
|
||||
assert runtime.update.call_count == len(expected)
|
||||
if changes == {0: 1}:
|
||||
assert {fix[0] for _index, fix in published} == {99_000_000_001}, 'held fixes keep their measurement time'
|
||||
owner.position_store.flush.assert_called_once_with(force=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def route():
|
||||
return NavigationRoute({'distance': 1112., 'duration': 100., 'geometry': {'coordinates': [[0., 0.], [.01, 0.]]}, 'legs': [{'steps': [
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
|
||||
from openpilot.cereal import log, messaging
|
||||
from openpilot.starpilot.navigation.owner import NavigationOwner, ValidationError
|
||||
from openpilot.starpilot.navigation.status import GPS_SOURCES, NavigationStatusSource
|
||||
from openpilot.starpilot.navigation.status import GPS_SERVICES, GPS_SOURCES, NavigationStatusSource
|
||||
|
||||
POI = {'mapbox_id': 'poi/id', 'name': 'Coffee', 'feature_type': 'poi'}
|
||||
|
||||
@@ -24,7 +24,7 @@ class Clock:
|
||||
class Messages:
|
||||
def __init__(self, clock):
|
||||
self.clock = clock
|
||||
names = ['starpilotNavigation', *GPS_SOURCES, 'deviceState']
|
||||
names = ['starpilotNavigation', *GPS_SERVICES, 'deviceState']
|
||||
self.seen = dict.fromkeys(names, True)
|
||||
self.alive = dict.fromkeys(names, True)
|
||||
self.valid = dict.fromkeys(names, True)
|
||||
@@ -243,7 +243,7 @@ def test_cancel_during_optional_context_never_sends_provider_or_fallback(tmp_pat
|
||||
def test_map_position_lease_uses_oldest_envelope_or_receipt_and_expires(source):
|
||||
reader, sm, clock = source
|
||||
sm.valid['gpsLocation'] = False
|
||||
assert reader.map_position() == {'longitude': -87.63, 'latitude': 41.88, 'validForMs': 2480.}
|
||||
assert reader.map_position() == {'longitude': -87.63, 'latitude': 41.88, 'source': 'comma', 'validForMs': 2480.}
|
||||
sm.recv_time['gpsLocationExternal'] = (clock.mono - 500_000_000) / 1e9
|
||||
assert reader.map_position()['validForMs'] == 2000.
|
||||
clock.mono += 2_000_000_001
|
||||
|
||||
@@ -6,6 +6,8 @@ import time
|
||||
import selectors
|
||||
import signal
|
||||
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active
|
||||
|
||||
from openpilot.common.vendor_manifest import validate_revision
|
||||
from openpilot.common.prebuilt_manifest import revision_digest, valid_receipt
|
||||
|
||||
@@ -47,6 +49,9 @@ def _stop_process(process):
|
||||
|
||||
|
||||
def run(command, cwd, progress=None, *, timeout=None):
|
||||
downloading = 'fetch' in command or 'pull' in command or 'clone' in command
|
||||
if downloading and phone_uplink_active():
|
||||
raise FastUpdateError('Software downloads are disabled on the phone uplink')
|
||||
environment = dict(os.environ, GIT_LFS_SKIP_SMUDGE='1', GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/false', SSH_ASKPASS='/bin/false')
|
||||
if timeout is None:
|
||||
timeout = 120 if "fetch" in command or "reset" in command or "checkout" in command else 30
|
||||
@@ -59,6 +64,8 @@ def run(command, cwd, progress=None, *, timeout=None):
|
||||
pending = b""
|
||||
try:
|
||||
while selector.get_map():
|
||||
if downloading and phone_uplink_active():
|
||||
raise FastUpdateError('Phone uplink activated; software download stopped')
|
||||
if time.monotonic() >= deadline:
|
||||
raise subprocess.TimeoutExpired(command, timeout, output=bytes(output))
|
||||
for key, _ in selector.select(min(0.1, max(0.0, deadline - time.monotonic()))):
|
||||
|
||||
@@ -24,7 +24,7 @@ MAX_SNAPSHOT_BYTES = 256 * 1024 * 1024
|
||||
MAX_KEYS = 4096
|
||||
OPERATIONAL_KEYS = frozenset({
|
||||
'DongleId', 'HardwareSerial', 'GithubSshKeys', 'GithubUsername', 'SshEnabled',
|
||||
'GsmApn', 'GsmMetered', 'GsmRoaming', 'NetworkMetered', 'BluetoothEnabled',
|
||||
'CellularDisabled', 'GsmApn', 'GsmMetered', 'GsmRoaming', 'NetworkMetered', 'BluetoothEnabled',
|
||||
'ConnectProvider', 'PairingProvider', 'PairingEmail', 'PrimeType', 'AssistNowToken', 'SecOCKey',
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared plumbing for the root workers that unprivileged daemons lease over a pipe.
|
||||
|
||||
The daemon starts the worker with ``spawn``. EOF on the worker's stdin, a missed heartbeat or SIGTERM ends the
|
||||
lease, and the worker then removes only what it created (interfaces it marked with its own alias).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def spawn(unit: str, module: str, popen=subprocess.Popen):
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
return popen(['sudo', '-n', 'systemd-run', '--quiet', '--pipe', '--wait', '--collect', f'--unit={unit}',
|
||||
'--property=TimeoutStopSec=8', '--property=KillMode=control-group', '--working-directory=' + str(BASEDIR),
|
||||
sys.executable, '-u', '-m', module], cwd=BASEDIR,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, close_fds=True)
|
||||
|
||||
|
||||
def owned(interface: str, alias: str) -> bool:
|
||||
try:
|
||||
return Path(f'/sys/class/net/{interface}/ifalias').read_text().strip() == alias
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def claim(runtime: Path, name: str) -> int:
|
||||
"""Root check, a private runtime directory and its lock; returns the lock's file descriptor."""
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError(f'{name} requires network administration privileges')
|
||||
os.umask(0o077)
|
||||
runtime.mkdir(mode=0o700, exist_ok=True)
|
||||
if runtime.is_symlink() or runtime.stat().st_uid != 0:
|
||||
raise RuntimeError(f'Unsafe {name} runtime directory')
|
||||
fd = os.open(runtime / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return fd
|
||||
|
||||
|
||||
def stop_event() -> threading.Event:
|
||||
stop = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stop.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stop.set())
|
||||
return stop
|
||||
@@ -32,7 +32,9 @@ selected.extend(node for node in owner_ast.body if isinstance(node, ast.Assign)
|
||||
owner_class = next(node for node in owner_ast.body if isinstance(node, ast.ClassDef) and node.name == 'NavigationOwner')
|
||||
selected.append(next(node for node in owner_class.body if isinstance(node, ast.FunctionDef) and node.name == 'read'))
|
||||
namespace: dict = {'json': json, 'math': math, 'hashlib': hashlib, 'os': os, 'copy': copy, 'MAX_DOCUMENT': 256 * 1024,
|
||||
'MAX_FAVORITES': 100, 'MAX_RECENTS': 10, 'FAVORITE_LABELS': ('home', 'work')}
|
||||
'MAX_FAVORITES': 100, 'MAX_RECENTS': 10, 'FAVORITE_LABELS': ('home', 'work'),
|
||||
'GPS_MODES': next(ast.literal_eval(node.value) for node in ast.parse((ROOT / 'gps' / 'source.py').read_text()).body
|
||||
if isinstance(node, ast.Assign) and any(getattr(t, 'id', '') == 'GPS_MODES' for t in node.targets))}
|
||||
exec(compile(ast.Module(body=selected, type_ignores=[]), str(ROOT / 'navigation' / 'owner.py'), 'exec'), namespace)
|
||||
NavigationOwner = type('NavigationOwner', (), {'read': namespace['read']})
|
||||
with patch.dict(sys.modules, {'openpilot.starpilot.state_migration': migration}):
|
||||
|
||||
@@ -63,6 +63,7 @@ def test_request_survives_capability_loss_and_recovery(module, layout):
|
||||
panel._adb_toggle = Toggle()
|
||||
panel._ssh_toggle = Toggle()
|
||||
panel._ui_debug_toggle = Toggle()
|
||||
panel._cellular_disabled_toggle = Toggle()
|
||||
else:
|
||||
panel._refresh_toggles = (('AlphaLongitudinalEnabled', panel._alpha_long_toggle),)
|
||||
with patch.object(module, 'ui_state', ui):
|
||||
@@ -122,6 +123,7 @@ def test_release_preserves_request_without_advertising_passive_authority(module,
|
||||
panel._adb_toggle = Toggle()
|
||||
panel._ssh_toggle = Toggle()
|
||||
panel._ui_debug_toggle = Toggle()
|
||||
panel._cellular_disabled_toggle = Toggle()
|
||||
else:
|
||||
panel._refresh_toggles = (('AlphaLongitudinalEnabled', panel._alpha_long_toggle),)
|
||||
with patch.object(module, 'ui_state', ui):
|
||||
|
||||
@@ -957,6 +957,6 @@ def test_compact_developer_fps_is_live_onroad_but_physical_controls_keep_native_
|
||||
assert page._joystick_toggle.set_enabled.call_args.args[0]() is False
|
||||
assert page._alpha_long_toggle.set_enabled.call_args.args[0]() is False
|
||||
page._debug_mode_toggle.set_enabled.assert_not_called()
|
||||
debug.call_args.kwargs['toggle_callback'](True)
|
||||
next(c for c in debug.call_args_list if c.args[0] == "ui debug mode").kwargs['toggle_callback'](True)
|
||||
touches.assert_called_once_with(True)
|
||||
fps.assert_called_once_with(True)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import itertools
|
||||
@@ -259,6 +261,8 @@ def retry_upload(tid: int, end_event: threading.Event, increase_count: bool = Tr
|
||||
def cb(sm, item, tid, end_event: threading.Event, sz: int, cur: int) -> None:
|
||||
# Abort transfer if connection changed to metered after starting upload
|
||||
# or if athenad is shutting down to re-connect the websocket
|
||||
if phone_uplink_active():
|
||||
raise AbortTransferException
|
||||
if not item.allow_cellular:
|
||||
if (time.monotonic() - sm.recv_time['deviceState']) > DEVICE_STATE_UPDATE_INTERVAL:
|
||||
sm.update(0)
|
||||
@@ -295,7 +299,7 @@ def upload_handler(end_event: threading.Event) -> None:
|
||||
sm.update(0)
|
||||
metered = sm['deviceState'].networkMetered
|
||||
network_type = sm['deviceState'].networkType.raw
|
||||
if metered and not item.allow_cellular and not Params().get_bool("AlwaysAllowUploads"):
|
||||
if phone_uplink_active() or (metered and not item.allow_cellular and not Params().get_bool("AlwaysAllowUploads")):
|
||||
retry_upload(tid, end_event, False)
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import queue
|
||||
@@ -190,7 +192,8 @@ def hw_state_thread(end_event, hw_queue):
|
||||
if (count % int(10. / DT_HW)) == 0 or usb_changed:
|
||||
prev_usb_topology = usb_topology
|
||||
try:
|
||||
network_type = HARDWARE.get_network_type()
|
||||
phone_uplink = phone_uplink_active()
|
||||
network_type = NetworkType.cell4G if phone_uplink else HARDWARE.get_network_type()
|
||||
modem_temps = HARDWARE.get_modem_temperatures()
|
||||
if len(modem_temps) == 0 and prev_hw_state is not None:
|
||||
modem_temps = prev_hw_state.modem_temps
|
||||
@@ -202,7 +205,7 @@ def hw_state_thread(end_event, hw_queue):
|
||||
network_info=HARDWARE.get_network_info(),
|
||||
network_strength=HARDWARE.get_network_strength(network_type),
|
||||
network_stats={'wwanTx': tx, 'wwanRx': rx},
|
||||
network_metered=HARDWARE.get_network_metered(network_type),
|
||||
network_metered=phone_uplink or HARDWARE.get_network_metered(network_type),
|
||||
modem_temps=modem_temps,
|
||||
usb_state=get_usb_state(),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
@@ -12,7 +14,7 @@ from collections.abc import Iterator
|
||||
from openpilot.cereal import log
|
||||
import openpilot.cereal.messaging as messaging
|
||||
from openpilot.common.api import Api
|
||||
from openpilot.common.utils import get_upload_stream
|
||||
from openpilot.common.utils import CallbackReader, get_upload_stream
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.realtime import set_core_affinity
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
@@ -179,6 +181,11 @@ class Uploader:
|
||||
return None
|
||||
|
||||
def do_upload(self, key: str, fn: str):
|
||||
def guard(*_):
|
||||
if phone_uplink_active():
|
||||
raise requests.exceptions.ConnectionError("Phone uplink forbids log uploads")
|
||||
|
||||
guard()
|
||||
url_resp = self.api.get("v1.4/" + self.dongle_id + "/upload_url/", timeout=10, path=key, access_token=self.api.get_token())
|
||||
if url_resp.status_code == 412:
|
||||
return url_resp
|
||||
@@ -194,8 +201,8 @@ class Uploader:
|
||||
stream = None
|
||||
try:
|
||||
compress = key.endswith('.zst') and not fn.endswith('.zst')
|
||||
stream, _ = get_upload_stream(fn, compress)
|
||||
response = requests.put(url, data=stream, headers=headers, timeout=10)
|
||||
stream, length = get_upload_stream(fn, compress)
|
||||
response = requests.put(url, data=CallbackReader(stream, guard, length), headers=headers, timeout=10)
|
||||
return response
|
||||
finally:
|
||||
if stream:
|
||||
@@ -252,6 +259,8 @@ class Uploader:
|
||||
|
||||
|
||||
def step(self, network_type: int, metered: bool) -> bool | None:
|
||||
if phone_uplink_active():
|
||||
return None
|
||||
d = self.next_file_to_upload(metered)
|
||||
if d is None:
|
||||
return None
|
||||
@@ -297,7 +306,7 @@ def main(exit_event: threading.Event | None = None) -> None:
|
||||
always_allow_uploads = params.get_bool("AlwaysAllowUploads")
|
||||
offroad = params.get_bool("IsOffroad")
|
||||
network_type = sm['deviceState'].networkType if not force_wifi else NetworkType.wifi
|
||||
if network_type == NetworkType.none:
|
||||
if phone_uplink_active() or network_type == NetworkType.none:
|
||||
if allow_sleep:
|
||||
time.sleep(60 if offroad else 5)
|
||||
continue
|
||||
|
||||
@@ -229,13 +229,6 @@ def main() -> None:
|
||||
# SystemExit on sigterm
|
||||
signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(1))
|
||||
|
||||
analytics = None
|
||||
try:
|
||||
from openpilot.starpilot.analytics.runtime import start_optional_worker
|
||||
analytics = start_optional_worker()
|
||||
except Exception:
|
||||
cloudlog.warning("Usage statistics worker unavailable")
|
||||
|
||||
try:
|
||||
manager_thread()
|
||||
except Exception:
|
||||
@@ -244,11 +237,6 @@ def main() -> None:
|
||||
sentry.capture_exception()
|
||||
sentry.flush()
|
||||
finally:
|
||||
if analytics is not None:
|
||||
try:
|
||||
analytics.close()
|
||||
except Exception:
|
||||
cloudlog.warning("Usage statistics shutdown failed")
|
||||
try:
|
||||
from openpilot.starpilot.system.jetlink.lifecycle import shutdown as shutdown_jetlink
|
||||
shutdown_jetlink(Params())
|
||||
|
||||
@@ -143,6 +143,8 @@ procs = [
|
||||
enabled=platform.system() == "Linux", restart_on_exit=True),
|
||||
PythonProcess("galaxy_hotspot", "openpilot.starpilot.galaxy.hotspot_daemon", galaxy_local,
|
||||
enabled=COMMA_HARDWARE and not os.environ.get('OPENPILOT_PREFIX'), restart_on_exit=True),
|
||||
PythonProcess("companion_uplink", "openpilot.starpilot.galaxy.companion_uplink_daemon", galaxy_local,
|
||||
enabled=COMMA_HARDWARE and not os.environ.get('OPENPILOT_PREFIX'), restart_on_exit=True),
|
||||
JetlinkProcess("jetlinkd", "openpilot.starpilot.models.jetlink_adapter", jetlink_enabled, enabled=COMMA_HARDWARE,
|
||||
restart_on_exit=True),
|
||||
PythonProcess("android_autod", "openpilot.starpilot.system.android_auto.daemon", android_auto_enabled, enabled=COMMA_HARDWARE,
|
||||
@@ -174,6 +176,7 @@ procs = [
|
||||
PythonProcess("radard", "openpilot.selfdrive.controls.radard", only_onroad),
|
||||
PythonProcess("hardwared", "openpilot.system.hardware.hardwared", always_run),
|
||||
PythonProcess("modem", "openpilot.common.hardware.comma.modem", always_run, enabled=COMMA_HARDWARE),
|
||||
PythonProcess("usage_stats", "openpilot.starpilot.analytics.runtime", always_run, enabled=COMMA_HARDWARE),
|
||||
PythonProcess("tombstoned", "openpilot.system.tombstoned", always_run, enabled=not PC),
|
||||
PythonProcess("updated", "openpilot.system.updated.updated", only_offroad, enabled=not PC),
|
||||
PythonProcess("uploader", "openpilot.system.loggerd.uploader", always_run),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active
|
||||
|
||||
import os
|
||||
import re
|
||||
import datetime
|
||||
@@ -500,6 +502,8 @@ class Updater:
|
||||
init_overlay()
|
||||
self._branches_checked = False
|
||||
self.params.put("UpdaterState", "checking...", block=True)
|
||||
if phone_uplink_active():
|
||||
raise FastUpdateError("Software downloads are disabled while using phone internet")
|
||||
if not self.fetch_update():
|
||||
raise FastUpdateError("Target branch is no longer available")
|
||||
admitted()
|
||||
@@ -510,6 +514,8 @@ class Updater:
|
||||
return "reboot-requested"
|
||||
|
||||
def fetch_update(self) -> bool:
|
||||
if phone_uplink_active():
|
||||
return False
|
||||
if not self._branches_checked:
|
||||
self.check_for_update()
|
||||
if self.target_branch not in self.branches:
|
||||
@@ -647,7 +653,9 @@ def main() -> None:
|
||||
last_fetch = params.get("UpdaterLastFetchTime")
|
||||
timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3))
|
||||
user_requested_fetch = requested == UserRequest.FETCH
|
||||
if params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch:
|
||||
if phone_uplink_active():
|
||||
cloudlog.info("skipping fetch, phone uplink forbids updates")
|
||||
elif params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch:
|
||||
cloudlog.info("skipping fetch, connection metered")
|
||||
elif not download_permitted(params, manual=user_requested_fetch, check_only=requested == UserRequest.CHECK):
|
||||
cloudlog.info("skipping fetch, check-only request or automatic downloads disabled")
|
||||
|
||||
+17
-2
@@ -122,9 +122,9 @@ allow-direct-references = true
|
||||
[tool.codespell]
|
||||
quiet-level = 3
|
||||
# if you've got a short variable name that's getting flagged, add it here
|
||||
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor,centre,centres"
|
||||
ignore-words-list = "bu,ro,te,ue,alo,hda,ois,nam,nams,ned,som,parm,setts,inout,warmup,bumb,nd,sie,preints,whit,indexIn,ws,uint,grey,deque,stdio,amin,BA,LITE,atEnd,UIs,errorString,arange,FocusIn,od,tim,relA,hist,copyable,jupyter,thead,TGE,abl,lite,ser,assertIn,HomeState,cancelled,CAS,acknowledgement,writeable,noice,FoF,waitFor,centre,centres,KeyPair,aPoint"
|
||||
builtin = "clear,rare,informal,code,names,en-GB_to_en-US"
|
||||
skip = "openpilot/cereal/gen/cpp/*.capnp.c++, openpilot/cereal/gen/cpp/*.capnp.h, *.po, uv.lock, *.onnx, *.pem, */c_generated_code/*, docs/assets/*, openpilot/tools/plotjuggler/layouts/*, openpilot/selfdrive/assets/offroad/mici_fcc.html, openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/ui/assets/fonts/notices/Unifont-COPYING.txt"
|
||||
skip = "openpilot/cereal/gen/cpp/*.capnp.c++, openpilot/cereal/gen/cpp/*.capnp.h, *.po, uv.lock, *.onnx, *.pem, */c_generated_code/*, docs/assets/*, openpilot/tools/plotjuggler/layouts/*, openpilot/selfdrive/assets/offroad/mici_fcc.html, openpilot/starpilot/galaxy/web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/ui/assets/fonts/notices/Unifont-COPYING.txt, openpilot/starpilot/companion/ios/GalaxyBluetooth/Resources/Web/vendor/vue/vue.esm-browser.js, openpilot/starpilot/galaxy_bluetooth/vendor/*, openpilot/starpilot/soloist/pulseaudio/licenses/*, openpilot/starpilot/companion/android/LICENSE.gradle"
|
||||
ignore-regex = "scale\\.numer" # codespell:ignore numer
|
||||
|
||||
# https://docs.astral.sh/ruff/configuration/#using-pyprojecttoml
|
||||
@@ -150,6 +150,13 @@ lint.ignore = [
|
||||
]
|
||||
line-length = 160
|
||||
lint.flake8-implicit-str-concat.allow-multiline = false
|
||||
extend-exclude = ["openpilot/starpilot/galaxy_bluetooth/vendor"] # unmodified third-party sources
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# dbus-next declares D-Bus signatures as string annotations ('s', 'ay', 'a{sv}').
|
||||
"openpilot/starpilot/galaxy_bluetooth/bridge/server.py" = ["F722", "F821"]
|
||||
# These scripts embed Swift test sources verbatim.
|
||||
"openpilot/starpilot/companion/ios/tests/*.py" = ["E501"]
|
||||
|
||||
[tool.ruff.lint.flake8-tidy-imports.banned-api]
|
||||
"time.time".msg = "Use time.monotonic. time.time can skip due to its reference clock, you probably want a monotonic clock"
|
||||
@@ -162,6 +169,9 @@ lint.flake8-implicit-str-concat.allow-multiline = false
|
||||
[tool.ruff.format]
|
||||
quote-style = "preserve"
|
||||
|
||||
[tool.ty.src]
|
||||
exclude = ["openpilot/starpilot/galaxy_bluetooth/vendor"] # unmodified third-party sources
|
||||
|
||||
[tool.ty.environment]
|
||||
extra-paths = ["opendbc_repo", "msgq_repo", "rednose_repo", "tinygrad_repo", "teleoprtc_repo", "jetlink_repo"]
|
||||
|
||||
@@ -169,6 +179,11 @@ extra-paths = ["opendbc_repo", "msgq_repo", "rednose_repo", "tinygrad_repo", "te
|
||||
unresolved-import = "ignore" # Cython-compiled modules (.pyx)
|
||||
unresolved-attribute = "ignore" # many from capnp and Cython modules
|
||||
|
||||
[[tool.ty.overrides]]
|
||||
# dbus-next declares D-Bus signatures as string annotations ('s', 'ay', 'a{sv}').
|
||||
include = ["openpilot/starpilot/galaxy_bluetooth/bridge/server.py"]
|
||||
rules = { unresolved-reference = "ignore", invalid-syntax-in-forward-annotation = "ignore" }
|
||||
|
||||
[tool.uv]
|
||||
python-preference = "only-managed"
|
||||
default-groups = ["standalone"]
|
||||
|
||||
@@ -9,6 +9,8 @@ LARGE_TEXT_FILES = frozenset((
|
||||
# shortcut: these existing modules bypass 120 KiB; split when their responsibilities warrant it.
|
||||
'openpilot/selfdrive/selfdrived/selfdrived.py',
|
||||
'openpilot/starpilot/galaxy/server.py',
|
||||
'openpilot/starpilot/galaxy/web/js/onroad-layout.js',
|
||||
'openpilot/starpilot/companion/ios/GalaxyBluetooth/Resources/Web/js/onroad-layout.js',
|
||||
'openpilot/cereal/gen/cpp/car.capnp.c++',
|
||||
'openpilot/cereal/gen/cpp/car.capnp.h',
|
||||
'openpilot/cereal/gen/cpp/custom.capnp.c++',
|
||||
|
||||
@@ -112,7 +112,8 @@ PYTHON_FILES=()
|
||||
while IFS= read -r -d '' f; do
|
||||
if [[ -f $f ]]; then
|
||||
ALL_FILES+=("$f")
|
||||
if [[ $f == *.py ]]; then
|
||||
# Vendored third-party sources keep their upstream indentation.
|
||||
if [[ $f == *.py && $f != openpilot/starpilot/galaxy_bluetooth/vendor/* ]]; then
|
||||
PYTHON_FILES+=("$f")
|
||||
fi
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user