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:
prabhaavp
2026-10-20 18:16:33 -04:00
committed by GitHub
71 changed files with 5673 additions and 3368 deletions
+4
View File
@@ -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
+3
View File
@@ -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
+4
View File
@@ -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).
+28
View File
@@ -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.
+6 -1
View File
@@ -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 {
+405 -331
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+125
View File
@@ -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)
}
}
+8
View File
@@ -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()
}
+1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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;
+34 -17
View File
@@ -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 = {
+51 -1
View File
@@ -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);
+19
View File
@@ -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()
+9
View File
@@ -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
+1
View File
@@ -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}},
+3
View File
@@ -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)
+20 -4
View File
@@ -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}
+137
View File
@@ -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")
+2 -1
View File
@@ -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()
+2 -9
View File
@@ -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):
+106 -27
View File
@@ -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:
+10
View File
@@ -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':
+4 -2
View File
@@ -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')
+173 -14
View File
@@ -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)
+15 -9
View File
@@ -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>`,
}
+99
View File
@@ -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
+68 -11
View File
@@ -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')
+10 -4
View File
@@ -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()
+26 -9
View File
@@ -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()
+15 -10
View File
@@ -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()))):
+1 -1
View File
@@ -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',
})
+49
View File
@@ -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)
+5 -1
View File
@@ -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
+5 -2
View File
@@ -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(),
)
+13 -4
View File
@@ -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
-12
View File
@@ -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),
+9 -1
View File
@@ -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
View File
@@ -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"]
+2
View File
@@ -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++',
+2 -1
View File
@@ -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