mirror of
https://github.com/dragonpilot/dragonpilot.git
synced 2026-08-21 08:03:42 +08:00
openpilot v0.9.9 release (#35334)
* openpilot v0.9.9 release date: 2025-06-05T19:54:08 master commit: 8aadf02b2fd91f4e1285e18c2c7feb32d93b66f5 * AGNOS 12.4 (#35558) agnos12.4 --------- Co-authored-by: Vehicle Researcher <user@comma.ai> Co-authored-by: Maxime Desroches <desroches.maxime@gmail.com>
This commit is contained in:
@@ -16,7 +16,7 @@ import threading
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from functools import partial, total_ordering
|
||||
from queue import Queue
|
||||
from typing import cast
|
||||
from collections.abc import Callable
|
||||
@@ -53,6 +53,7 @@ MAX_RETRY_COUNT = 30 # Try for at most 5 minutes if upload fails immediately
|
||||
MAX_AGE = 31 * 24 * 3600 # seconds
|
||||
WS_FRAME_SIZE = 4096
|
||||
DEVICE_STATE_UPDATE_INTERVAL = 1.0 # in seconds
|
||||
DEFAULT_UPLOAD_PRIORITY = 99 # higher number = lower priority
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
@@ -68,13 +69,15 @@ class UploadFile:
|
||||
url: str
|
||||
headers: dict[str, str]
|
||||
allow_cellular: bool
|
||||
priority: int = DEFAULT_UPLOAD_PRIORITY
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> UploadFile:
|
||||
return cls(d.get("fn", ""), d.get("url", ""), d.get("headers", {}), d.get("allow_cellular", False))
|
||||
return cls(d.get("fn", ""), d.get("url", ""), d.get("headers", {}), d.get("allow_cellular", False), d.get("priority", DEFAULT_UPLOAD_PRIORITY))
|
||||
|
||||
|
||||
@dataclass
|
||||
@total_ordering
|
||||
class UploadItem:
|
||||
path: str
|
||||
url: str
|
||||
@@ -85,17 +88,28 @@ class UploadItem:
|
||||
current: bool = False
|
||||
progress: float = 0
|
||||
allow_cellular: bool = False
|
||||
priority: int = DEFAULT_UPLOAD_PRIORITY
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> UploadItem:
|
||||
return cls(d["path"], d["url"], d["headers"], d["created_at"], d["id"], d["retry_count"], d["current"],
|
||||
d["progress"], d["allow_cellular"])
|
||||
d["progress"], d["allow_cellular"], d["priority"])
|
||||
|
||||
def __lt__(self, other):
|
||||
if not isinstance(other, UploadItem):
|
||||
return NotImplemented
|
||||
return self.priority < other.priority
|
||||
|
||||
def __eq__(self, other):
|
||||
if not isinstance(other, UploadItem):
|
||||
return NotImplemented
|
||||
return self.priority == other.priority
|
||||
|
||||
|
||||
dispatcher["echo"] = lambda s: s
|
||||
recv_queue: Queue[str] = queue.Queue()
|
||||
send_queue: Queue[str] = queue.Queue()
|
||||
upload_queue: Queue[UploadItem] = queue.Queue()
|
||||
upload_queue: Queue[UploadItem] = queue.PriorityQueue()
|
||||
low_priority_send_queue: Queue[str] = queue.Queue()
|
||||
log_recv_queue: Queue[str] = queue.Queue()
|
||||
cancelled_uploads: set[str] = set()
|
||||
@@ -398,6 +412,7 @@ def uploadFilesToUrls(files_data: list[UploadFileDict]) -> UploadFilesToUrlRespo
|
||||
created_at=int(time.time() * 1000),
|
||||
id=None,
|
||||
allow_cellular=file.allow_cellular,
|
||||
priority=file.priority,
|
||||
)
|
||||
upload_id = hashlib.sha1(str(item).encode()).hexdigest()
|
||||
item = replace(item, id=upload_id)
|
||||
@@ -531,7 +546,7 @@ def getNetworks():
|
||||
|
||||
@dispatcher.add_method
|
||||
def takeSnapshot() -> str | dict[str, str] | None:
|
||||
from openpilot.system.camerad.snapshot.snapshot import jpeg_write, snapshot
|
||||
from openpilot.system.camerad.snapshot import jpeg_write, snapshot
|
||||
ret = snapshot()
|
||||
if ret is not None:
|
||||
def b64jpeg(x):
|
||||
@@ -761,8 +776,11 @@ def ws_manage(ws: WebSocket, end_event: threading.Event) -> None:
|
||||
# While not sending data, onroad, we can expect to time out in 7 + (7 * 2) = 21s
|
||||
# offroad, we can expect to time out in 30 + (10 * 3) = 60s
|
||||
# FIXME: TCP_USER_TIMEOUT is effectively 2x for some reason (32s), so it's mostly unused
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 16000 if onroad else 0)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 7 if onroad else 30)
|
||||
if sys.platform == 'linux':
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_USER_TIMEOUT, 16000 if onroad else 0)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 7 if onroad else 30)
|
||||
elif sys.platform == 'darwin':
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 7 if onroad else 30)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 7 if onroad else 10)
|
||||
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 2 if onroad else 3)
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from datetime import datetime, timedelta, UTC
|
||||
from openpilot.common.api import api_get
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.spinner import Spinner
|
||||
from openpilot.selfdrive.selfdrived.alertmanager import set_offroad_alert
|
||||
from openpilot.system.hardware import HARDWARE, PC
|
||||
from openpilot.system.hardware.hw import Paths
|
||||
@@ -45,6 +44,7 @@ def register(show_spinner=False) -> str | None:
|
||||
cloudlog.warning(f"missing public key: {pubkey}")
|
||||
elif dongle_id is None:
|
||||
if show_spinner:
|
||||
from openpilot.system.ui.spinner import Spinner
|
||||
spinner = Spinner()
|
||||
spinner.update("registering device")
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class TestAthenadMethods:
|
||||
self.params.put(k, v)
|
||||
self.params.put_bool("GsmMetered", True)
|
||||
|
||||
athenad.upload_queue = queue.Queue()
|
||||
athenad.upload_queue = queue.PriorityQueue()
|
||||
athenad.cur_upload_items.clear()
|
||||
athenad.cancelled_uploads.clear()
|
||||
|
||||
@@ -321,6 +321,26 @@ class TestAthenadMethods:
|
||||
assert len(items) == 1
|
||||
assert items[0]['current']
|
||||
|
||||
def test_list_upload_queue_priority(self):
|
||||
priorities = (25, 50, 99, 75, 0)
|
||||
|
||||
for i in priorities:
|
||||
fn = f'qlog_{i}.zst'
|
||||
fp = self._create_file(fn)
|
||||
item = athenad.UploadItem(
|
||||
path=fp,
|
||||
url=f"http://localhost:44444/{fn}",
|
||||
headers={},
|
||||
created_at=int(time.time()*1000),
|
||||
id='',
|
||||
allow_cellular=True,
|
||||
priority=i
|
||||
)
|
||||
athenad.upload_queue.put_nowait(item)
|
||||
|
||||
for i in sorted(priorities):
|
||||
assert athenad.upload_queue.get_nowait().priority == i
|
||||
|
||||
def test_list_upload_queue(self):
|
||||
item = athenad.UploadItem(path="qlog.zst", url="http://localhost:44444/qlog.zst", headers={},
|
||||
created_at=int(time.time()*1000), id='id', allow_cellular=True)
|
||||
|
||||
@@ -26,9 +26,6 @@ void CameraBuf::init(cl_device_id device_id, cl_context context, SpectraCamera *
|
||||
LOGD("allocated %d CL buffers", frame_buf_count);
|
||||
}
|
||||
|
||||
out_img_width = sensor->frame_width;
|
||||
out_img_height = sensor->hdr_offset > 0 ? (sensor->frame_height - sensor->hdr_offset) / 2 : sensor->frame_height;
|
||||
|
||||
// the encoder HW tells us the size it wants after setting it up.
|
||||
// TODO: VENUS_BUFFER_SIZE should give the size, but it's too small. dependent on encoder settings?
|
||||
size_t nv12_size = (out_img_width <= 1344 ? 2900 : 2346)*cam->stride;
|
||||
|
||||
@@ -32,7 +32,7 @@ public:
|
||||
VisionBuf *cur_yuv_buf;
|
||||
VisionBuf *cur_camera_buf;
|
||||
std::unique_ptr<VisionBuf[]> camera_bufs_raw;
|
||||
int out_img_width, out_img_height;
|
||||
uint32_t out_img_width, out_img_height;
|
||||
|
||||
CameraBuf() = default;
|
||||
~CameraBuf();
|
||||
|
||||
@@ -73,7 +73,7 @@ void CameraState::init(VisionIpcServer *v, cl_device_id device_id, cl_context ct
|
||||
|
||||
if (!camera.enabled) return;
|
||||
|
||||
fl_pix = camera.cc.focal_len / camera.sensor->pixel_size_mm;
|
||||
fl_pix = camera.cc.focal_len / camera.sensor->pixel_size_mm / camera.sensor->out_scale;
|
||||
set_exposure_rect();
|
||||
|
||||
dc_gain_weight = camera.sensor->dc_gain_min_weight;
|
||||
@@ -107,10 +107,10 @@ void CameraState::set_exposure_rect() {
|
||||
float fl_ref = ae_target.second;
|
||||
|
||||
ae_xywh = (Rect){
|
||||
std::max(0, camera.buf.out_img_width / 2 - (int)(fl_pix / fl_ref * xywh_ref.w / 2)),
|
||||
std::max(0, camera.buf.out_img_height / 2 - (int)(fl_pix / fl_ref * (h_ref / 2 - xywh_ref.y))),
|
||||
std::min((int)(fl_pix / fl_ref * xywh_ref.w), camera.buf.out_img_width / 2 + (int)(fl_pix / fl_ref * xywh_ref.w / 2)),
|
||||
std::min((int)(fl_pix / fl_ref * xywh_ref.h), camera.buf.out_img_height / 2 + (int)(fl_pix / fl_ref * (h_ref / 2 - xywh_ref.y)))
|
||||
std::max(0, (int)camera.buf.out_img_width / 2 - (int)(fl_pix / fl_ref * xywh_ref.w / 2)),
|
||||
std::max(0, (int)camera.buf.out_img_height / 2 - (int)(fl_pix / fl_ref * (h_ref / 2 - xywh_ref.y))),
|
||||
std::min((int)(fl_pix / fl_ref * xywh_ref.w), (int)camera.buf.out_img_width / 2 + (int)(fl_pix / fl_ref * xywh_ref.w / 2)),
|
||||
std::min((int)(fl_pix / fl_ref * xywh_ref.h), (int)camera.buf.out_img_height / 2 + (int)(fl_pix / fl_ref * (h_ref / 2 - xywh_ref.y)))
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ int build_update(uint8_t *dst, const CameraConfig cam, const SensorInfo *s, std:
|
||||
}
|
||||
|
||||
|
||||
int build_initial_config(uint8_t *dst, const CameraConfig cam, const SensorInfo *s, std::vector<uint32_t> &patches) {
|
||||
int build_initial_config(uint8_t *dst, const CameraConfig cam, const SensorInfo *s, std::vector<uint32_t> &patches, uint32_t out_width, uint32_t out_height) {
|
||||
uint8_t *start = dst;
|
||||
|
||||
// start with the every frame config
|
||||
@@ -185,12 +185,12 @@ int build_initial_config(uint8_t *dst, const CameraConfig cam, const SensorInfo
|
||||
// output size/scaling
|
||||
dst += write_cont(dst, 0xa3c, {
|
||||
0x00000003,
|
||||
((s->frame_width - 1) << 16) | (s->frame_width - 1),
|
||||
((out_width - 1) << 16) | (s->frame_width - 1),
|
||||
0x30036666,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
s->frame_width - 1,
|
||||
((s->frame_height - 1) << 16) | (s->frame_height - 1),
|
||||
((out_height - 1) << 16) | (s->frame_height - 1),
|
||||
0x30036666,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
@@ -198,12 +198,12 @@ int build_initial_config(uint8_t *dst, const CameraConfig cam, const SensorInfo
|
||||
});
|
||||
dst += write_cont(dst, 0xa68, {
|
||||
0x00000003,
|
||||
((s->frame_width/2 - 1) << 16) | (s->frame_width - 1),
|
||||
((out_width / 2 - 1) << 16) | (s->frame_width - 1),
|
||||
0x3006cccc,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
s->frame_width - 1,
|
||||
((s->frame_height/2 - 1) << 16) | (s->frame_height - 1),
|
||||
((out_height / 2 - 1) << 16) | (s->frame_height - 1),
|
||||
0x3006cccc,
|
||||
0x00000000,
|
||||
0x00000000,
|
||||
@@ -212,12 +212,12 @@ int build_initial_config(uint8_t *dst, const CameraConfig cam, const SensorInfo
|
||||
|
||||
// cropping
|
||||
dst += write_cont(dst, 0xe10, {
|
||||
s->frame_height - 1,
|
||||
s->frame_width - 1,
|
||||
out_height - 1,
|
||||
out_width - 1,
|
||||
});
|
||||
dst += write_cont(dst, 0xe30, {
|
||||
s->frame_height/2 - 1,
|
||||
s->frame_width - 1,
|
||||
out_height / 2 - 1,
|
||||
out_width - 1,
|
||||
});
|
||||
dst += write_cont(dst, 0xe18, {
|
||||
0x0ff00000,
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
#include "system/camerad/cameras/ife.h"
|
||||
#include "system/camerad/cameras/spectra.h"
|
||||
#include "system/camerad/cameras/bps_blobs.h"
|
||||
#include "third_party/linux/include/msm_media_info.h"
|
||||
|
||||
|
||||
// ************** low level camera helpers ****************
|
||||
@@ -282,18 +281,21 @@ void SpectraCamera::camera_open(VisionIpcServer *v, cl_device_id device_id, cl_c
|
||||
|
||||
if (!enabled) return;
|
||||
|
||||
buf.out_img_width = sensor->frame_width / sensor->out_scale;
|
||||
buf.out_img_height = (sensor->hdr_offset > 0 ? (sensor->frame_height - sensor->hdr_offset) / 2 : sensor->frame_height) / sensor->out_scale;
|
||||
|
||||
// size is driven by all the HW that handles frames,
|
||||
// the video encoder has certain alignment requirements in this case
|
||||
stride = VENUS_Y_STRIDE(COLOR_FMT_NV12, sensor->frame_width);
|
||||
y_height = VENUS_Y_SCANLINES(COLOR_FMT_NV12, sensor->frame_height);
|
||||
uv_height = VENUS_UV_SCANLINES(COLOR_FMT_NV12, sensor->frame_height);
|
||||
stride = VENUS_Y_STRIDE(COLOR_FMT_NV12, buf.out_img_width);
|
||||
y_height = VENUS_Y_SCANLINES(COLOR_FMT_NV12, buf.out_img_height);
|
||||
uv_height = VENUS_UV_SCANLINES(COLOR_FMT_NV12, buf.out_img_height);
|
||||
uv_offset = stride*y_height;
|
||||
yuv_size = uv_offset + stride*uv_height;
|
||||
if (cc.output_type != ISP_RAW_OUTPUT) {
|
||||
uv_offset = ALIGNED_SIZE(uv_offset, 0x1000);
|
||||
yuv_size = uv_offset + ALIGNED_SIZE(stride*uv_height, 0x1000);
|
||||
}
|
||||
assert(stride == VENUS_UV_STRIDE(COLOR_FMT_NV12, sensor->frame_width));
|
||||
assert(stride == VENUS_UV_STRIDE(COLOR_FMT_NV12, buf.out_img_width));
|
||||
assert(y_height/2 == uv_height);
|
||||
|
||||
open = true;
|
||||
@@ -646,14 +648,14 @@ void SpectraCamera::config_bps(int idx, int request_id) {
|
||||
io_cfg[1].mem_handle[0] = buf_handle_yuv[idx];
|
||||
io_cfg[1].mem_handle[1] = buf_handle_yuv[idx];
|
||||
io_cfg[1].planes[0] = (struct cam_plane_cfg){
|
||||
.width = sensor->frame_width,
|
||||
.height = sensor->frame_height,
|
||||
.width = buf.out_img_width,
|
||||
.height = buf.out_img_height,
|
||||
.plane_stride = stride,
|
||||
.slice_height = y_height,
|
||||
};
|
||||
io_cfg[1].planes[1] = (struct cam_plane_cfg){
|
||||
.width = sensor->frame_width,
|
||||
.height = sensor->frame_height/2,
|
||||
.width = buf.out_img_width,
|
||||
.height = buf.out_img_height / 2,
|
||||
.plane_stride = stride,
|
||||
.slice_height = uv_height,
|
||||
};
|
||||
@@ -738,7 +740,7 @@ void SpectraCamera::config_ife(int idx, int request_id, bool init) {
|
||||
bool is_raw = cc.output_type != ISP_IFE_PROCESSED;
|
||||
if (!is_raw) {
|
||||
if (init) {
|
||||
buf_desc[0].length = build_initial_config((unsigned char*)ife_cmd.ptr + buf_desc[0].offset, cc, sensor.get(), patches);
|
||||
buf_desc[0].length = build_initial_config((unsigned char*)ife_cmd.ptr + buf_desc[0].offset, cc, sensor.get(), patches, buf.out_img_width, buf.out_img_height);
|
||||
} else {
|
||||
buf_desc[0].length = build_update((unsigned char*)ife_cmd.ptr + buf_desc[0].offset, cc, sensor.get(), patches);
|
||||
}
|
||||
@@ -845,14 +847,14 @@ void SpectraCamera::config_ife(int idx, int request_id, bool init) {
|
||||
io_cfg[0].mem_handle[0] = buf_handle_yuv[idx];
|
||||
io_cfg[0].mem_handle[1] = buf_handle_yuv[idx];
|
||||
io_cfg[0].planes[0] = (struct cam_plane_cfg){
|
||||
.width = sensor->frame_width,
|
||||
.height = sensor->frame_height,
|
||||
.width = buf.out_img_width,
|
||||
.height = buf.out_img_height,
|
||||
.plane_stride = stride,
|
||||
.slice_height = y_height,
|
||||
};
|
||||
io_cfg[0].planes[1] = (struct cam_plane_cfg){
|
||||
.width = sensor->frame_width,
|
||||
.height = sensor->frame_height/2,
|
||||
.width = buf.out_img_width,
|
||||
.height = buf.out_img_height / 2,
|
||||
.plane_stride = stride,
|
||||
.slice_height = uv_height,
|
||||
};
|
||||
@@ -994,6 +996,9 @@ bool SpectraCamera::openSensor() {
|
||||
LOGD("-- Probing sensor %d", cc.camera_num);
|
||||
|
||||
auto init_sensor_lambda = [this](SensorInfo *s) {
|
||||
if (s->image_sensor == cereal::FrameData::ImageSensor::OS04C10 && cc.output_type == ISP_IFE_PROCESSED) {
|
||||
((OS04C10*)s)->ife_downscale_configure();
|
||||
}
|
||||
sensor.reset(s);
|
||||
return (sensors_init() == 0);
|
||||
};
|
||||
@@ -1066,8 +1071,8 @@ void SpectraCamera::configISP() {
|
||||
.data[0] = (struct cam_isp_out_port_info){
|
||||
.res_type = CAM_ISP_IFE_OUT_RES_FULL,
|
||||
.format = CAM_FORMAT_NV12,
|
||||
.width = sensor->frame_width,
|
||||
.height = sensor->frame_height + sensor->extra_height,
|
||||
.width = buf.out_img_width,
|
||||
.height = buf.out_img_height + sensor->extra_height,
|
||||
.comp_grp_id = 0x0, .split_point = 0x0, .secure_mode = 0x0,
|
||||
},
|
||||
};
|
||||
@@ -1142,8 +1147,8 @@ void SpectraCamera::configICP() {
|
||||
},
|
||||
.out_res[0] = (struct cam_icp_res_info){
|
||||
.format = 0x3, // YUV420NV12
|
||||
.width = sensor->frame_width,
|
||||
.height = sensor->frame_height,
|
||||
.width = buf.out_img_width,
|
||||
.height = buf.out_img_height,
|
||||
.fps = 20,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,17 @@ const uint32_t os04c10_analog_gains_reg[] = {
|
||||
|
||||
} // namespace
|
||||
|
||||
void OS04C10::ife_downscale_configure() {
|
||||
out_scale = 2;
|
||||
|
||||
pixel_size_mm = 0.002;
|
||||
frame_width = 2688;
|
||||
frame_height = 1520;
|
||||
exposure_time_max = 2352;
|
||||
|
||||
init_reg_array.insert(init_reg_array.end(), std::begin(ife_downscale_override_array_os04c10), std::end(ife_downscale_override_array_os04c10));
|
||||
}
|
||||
|
||||
OS04C10::OS04C10() {
|
||||
image_sensor = cereal::FrameData::ImageSensor::OS04C10;
|
||||
bayer_pattern = CAM_ISP_PATTERN_BAYER_BGBGBG;
|
||||
|
||||
@@ -88,8 +88,6 @@ const struct i2c_random_wr_payload init_array_os04c10[] = {
|
||||
{0x37c7, 0xa8},
|
||||
{0x37da, 0x11},
|
||||
{0x381f, 0x08},
|
||||
// {0x3829, 0x03},
|
||||
// {0x3832, 0x00},
|
||||
{0x3881, 0x00},
|
||||
{0x3888, 0x04},
|
||||
{0x388b, 0x00},
|
||||
@@ -332,3 +330,23 @@ const struct i2c_random_wr_payload init_array_os04c10[] = {
|
||||
{0x5104, 0x08}, {0x5105, 0xd6},
|
||||
{0x5144, 0x08}, {0x5145, 0xd6},
|
||||
};
|
||||
|
||||
const struct i2c_random_wr_payload ife_downscale_override_array_os04c10[] = {
|
||||
// OS04C10_AA_00_02_17_wAO_2688x1524_MIPI728Mbps_Linear12bit_20FPS_4Lane_MCLK24MHz
|
||||
{0x3c8c, 0x40},
|
||||
{0x3714, 0x24},
|
||||
{0x37c2, 0x04},
|
||||
{0x3662, 0x10},
|
||||
{0x37d9, 0x08},
|
||||
{0x4041, 0x07},
|
||||
{0x4008, 0x02},
|
||||
{0x4009, 0x0d},
|
||||
{0x3808, 0x0a}, {0x3809, 0x80},
|
||||
{0x380a, 0x05}, {0x380b, 0xf0},
|
||||
{0x3814, 0x01},
|
||||
{0x3816, 0x01},
|
||||
{0x380c, 0x08}, {0x380d, 0x5c}, // HTS
|
||||
{0x380e, 0x09}, {0x380f, 0x38}, // VTS
|
||||
{0x3820, 0xb0},
|
||||
{0x3821, 0x00},
|
||||
};
|
||||
|
||||
@@ -65,7 +65,6 @@ const struct i2c_random_wr_payload init_array_ox03c10[] = {
|
||||
{0x3008, 0x80}, // io_pad_sel
|
||||
|
||||
// FSIN (frame sync) with external pulses
|
||||
{0x3822, 0x33}, // wait for pulse before first frame
|
||||
{0x3009, 0x2},
|
||||
{0x3015, 0x2},
|
||||
{0x383E, 0x80},
|
||||
@@ -73,6 +72,9 @@ const struct i2c_random_wr_payload init_array_ox03c10[] = {
|
||||
{0x3882, 0x8}, {0x3883, 0x0D},
|
||||
{0x3836, 0x1F}, {0x3837, 0x40},
|
||||
|
||||
// causes issues on some devices
|
||||
//{0x3822, 0x33}, // wait for pulse before first frame
|
||||
|
||||
{0x3892, 0x44},
|
||||
{0x3823, 0x41},
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ public:
|
||||
uint32_t frame_stride;
|
||||
uint32_t frame_offset = 0;
|
||||
uint32_t extra_height = 0;
|
||||
int out_scale = 1;
|
||||
int registers_offset = -1;
|
||||
int stats_offset = -1;
|
||||
int hdr_offset = -1;
|
||||
@@ -109,6 +110,7 @@ public:
|
||||
class OS04C10 : public SensorInfo {
|
||||
public:
|
||||
OS04C10();
|
||||
void ife_downscale_configure();
|
||||
std::vector<i2c_random_wr_payload> getExposureRegisters(int exposure_time, int new_exp_g, bool dc_gain_enabled) const override;
|
||||
float getExposureScore(float desired_ev, int exp_t, int exp_g_idx, float exp_gain, int gain_idx) const override;
|
||||
int getSlaveAddress(int port) const override;
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# type: ignore
|
||||
import cereal.messaging as messaging
|
||||
|
||||
all_sockets = ['roadCameraState', 'driverCameraState', 'wideRoadCameraState']
|
||||
prev_id = [None,None,None]
|
||||
this_id = [None,None,None]
|
||||
dt = [None,None,None]
|
||||
num_skipped = [0,0,0]
|
||||
|
||||
if __name__ == "__main__":
|
||||
sm = messaging.SubMaster(all_sockets)
|
||||
while True:
|
||||
sm.update()
|
||||
|
||||
for i in range(len(all_sockets)):
|
||||
if not sm.updated[all_sockets[i]]:
|
||||
continue
|
||||
this_id[i] = sm[all_sockets[i]].frameId
|
||||
if prev_id[i] is None:
|
||||
prev_id[i] = this_id[i]
|
||||
continue
|
||||
dt[i] = this_id[i] - prev_id[i]
|
||||
if dt[i] != 1:
|
||||
num_skipped[i] += dt[i] - 1
|
||||
print(all_sockets[i] ,dt[i] - 1, num_skipped[i])
|
||||
prev_id[i] = this_id[i]
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
from tqdm import tqdm
|
||||
|
||||
from openpilot.tools.lib.logreader import LogReader
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("route", help="The route name")
|
||||
args = parser.parse_args()
|
||||
|
||||
out_path = os.path.join("jpegs", f"{args.route.replace('|', '_').replace('/', '_')}")
|
||||
os.makedirs(out_path, exist_ok=True)
|
||||
|
||||
lr = LogReader(args.route)
|
||||
|
||||
for msg in tqdm(lr):
|
||||
if msg.which() == 'thumbnail':
|
||||
with open(os.path.join(out_path, f"{msg.thumbnail.frameId}.jpg"), 'wb') as f:
|
||||
f.write(msg.thumbnail.thumbnail)
|
||||
elif msg.which() == 'navThumbnail':
|
||||
with open(os.path.join(out_path, f"nav_{msg.navThumbnail.frameId}.jpg"), 'wb') as f:
|
||||
f.write(msg.navThumbnail.thumbnail)
|
||||
@@ -3,7 +3,7 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from openpilot.selfdrive.test.helpers import with_processes
|
||||
from openpilot.system.camerad.snapshot.snapshot import get_snapshots
|
||||
from openpilot.system.camerad.snapshot import get_snapshots
|
||||
|
||||
TEST_TIME = 45
|
||||
REPEAT = 5
|
||||
|
||||
@@ -6,6 +6,19 @@ from cereal import log
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
class LPAError(RuntimeError):
|
||||
pass
|
||||
|
||||
class LPAProfileNotFoundError(LPAError):
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
iccid: str
|
||||
nickname: str
|
||||
enabled: bool
|
||||
provider: str
|
||||
|
||||
@dataclass
|
||||
class ThermalZone:
|
||||
# a zone from /sys/class/thermal/thermal_zone*
|
||||
@@ -33,6 +46,7 @@ class ThermalZone:
|
||||
class ThermalConfig:
|
||||
cpu: list[ThermalZone] | None = None
|
||||
gpu: list[ThermalZone] | None = None
|
||||
dsp: ThermalZone | None = None
|
||||
pmic: list[ThermalZone] | None = None
|
||||
memory: ThermalZone | None = None
|
||||
intake: ThermalZone | None = None
|
||||
@@ -50,6 +64,31 @@ class ThermalConfig:
|
||||
ret[f.name + "TempC"] = v.read()
|
||||
return ret
|
||||
|
||||
class LPABase(ABC):
|
||||
@abstractmethod
|
||||
def list_profiles(self) -> list[Profile]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_active_profile(self) -> Profile | None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_profile(self, iccid: str) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def download_profile(self, qr: str, nickname: str | None = None) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def nickname_profile(self, iccid: str, nickname: str) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def switch_profile(self, iccid: str) -> None:
|
||||
pass
|
||||
|
||||
class HardwareBase(ABC):
|
||||
@staticmethod
|
||||
def get_cmdline() -> dict[str, str]:
|
||||
@@ -104,6 +143,10 @@ class HardwareBase(ABC):
|
||||
def get_sim_info(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sim_lpa(self) -> LPABase:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_network_strength(self, network_type):
|
||||
pass
|
||||
@@ -130,6 +173,9 @@ class HardwareBase(ABC):
|
||||
def get_thermal_config(self):
|
||||
return ThermalConfig()
|
||||
|
||||
def set_display_power(self, on: bool):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_screen_brightness(self, percentage):
|
||||
pass
|
||||
@@ -164,6 +210,9 @@ class HardwareBase(ABC):
|
||||
def configure_modem(self):
|
||||
pass
|
||||
|
||||
def reboot_modem(self):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_networks(self):
|
||||
pass
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import time
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(prog='esim.py', description='manage eSIM profiles on your comma device', epilog='comma.ai')
|
||||
parser.add_argument('--backend', choices=['qmi', 'at'], default='qmi', help='use the specified backend, defaults to qmi')
|
||||
parser.add_argument('--switch', metavar='iccid', help='switch to profile')
|
||||
parser.add_argument('--delete', metavar='iccid', help='delete profile (warning: this cannot be undone)')
|
||||
parser.add_argument('--download', nargs=2, metavar=('qr', 'name'), help='download a profile using QR code (format: LPA:1$rsp.truphone.com$QRF-SPEEDTEST)')
|
||||
parser.add_argument('--nickname', nargs=2, metavar=('iccid', 'name'), help='update the nickname for a profile')
|
||||
args = parser.parse_args()
|
||||
|
||||
mutated = False
|
||||
lpa = HARDWARE.get_sim_lpa()
|
||||
if args.switch:
|
||||
lpa.switch_profile(args.switch)
|
||||
mutated = True
|
||||
elif args.delete:
|
||||
confirm = input('are you sure you want to delete this profile? (y/N) ')
|
||||
if confirm == 'y':
|
||||
lpa.delete_profile(args.delete)
|
||||
mutated = True
|
||||
else:
|
||||
print('cancelled')
|
||||
exit(0)
|
||||
elif args.download:
|
||||
lpa.download_profile(args.download[0], args.download[1])
|
||||
elif args.nickname:
|
||||
lpa.nickname_profile(args.nickname[0], args.nickname[1])
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
if mutated:
|
||||
HARDWARE.reboot_modem()
|
||||
# eUICC needs a small delay post-reboot before querying profiles
|
||||
time.sleep(.5)
|
||||
|
||||
profiles = lpa.list_profiles()
|
||||
print(f'\n{len(profiles)} profile{"s" if len(profiles) > 1 else ""}:')
|
||||
for p in profiles:
|
||||
print(f'- {p.iccid} (nickname: {p.nickname or "<none provided>"}) (provider: {p.provider}) - {"enabled" if p.enabled else "disabled"}')
|
||||
@@ -27,7 +27,7 @@ class TiciFanController(BaseFanController):
|
||||
if ignition != self.last_ignition:
|
||||
self.controller.reset()
|
||||
|
||||
error = 70 - cur_temp
|
||||
error = 75 - cur_temp
|
||||
fan_pwr_out = -int(self.controller.update(
|
||||
error=error,
|
||||
feedforward=np.interp(cur_temp, [60.0, 100.0], [0, -100])
|
||||
|
||||
@@ -34,6 +34,7 @@ CURRENT_TAU = 15. # 15s time constant
|
||||
TEMP_TAU = 5. # 5s time constant
|
||||
DISCONNECT_TIMEOUT = 5. # wait 5 seconds before going offroad after disconnect so you get an alert
|
||||
PANDA_STATES_TIMEOUT = round(1000 / SERVICE_LIST['pandaStates'].frequency * 1.5) # 1.5x the expected pandaState frequency
|
||||
ONROAD_CYCLE_TIME = 1 # seconds to wait offroad after requesting an onroad cycle
|
||||
|
||||
ThermalBand = namedtuple("ThermalBand", ['min_temp', 'max_temp'])
|
||||
HardwareState = namedtuple("HardwareState", ['network_type', 'network_info', 'network_strength', 'network_stats',
|
||||
@@ -170,6 +171,7 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
|
||||
onroad_conditions: dict[str, bool] = {
|
||||
"ignition": False,
|
||||
"not_onroad_cycle": True,
|
||||
}
|
||||
startup_conditions: dict[str, bool] = {}
|
||||
startup_conditions_prev: dict[str, bool] = {}
|
||||
@@ -195,6 +197,7 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
should_start_prev = False
|
||||
in_car = False
|
||||
engaged_prev = False
|
||||
offroad_cycle_count = 0
|
||||
|
||||
params = Params()
|
||||
power_monitor = PowerMonitoring()
|
||||
@@ -211,6 +214,12 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
peripheralState = sm['peripheralState']
|
||||
peripheral_panda_present = peripheralState.pandaType != log.PandaState.PandaType.unknown
|
||||
|
||||
# handle requests to cycle system started state
|
||||
if params.get_bool("OnroadCycleRequested"):
|
||||
params.put_bool("OnroadCycleRequested", False)
|
||||
offroad_cycle_count = sm.frame
|
||||
onroad_conditions["not_onroad_cycle"] = (sm.frame - offroad_cycle_count) >= ONROAD_CYCLE_TIME * SERVICE_LIST['pandaStates'].frequency
|
||||
|
||||
if sm.updated['pandaStates'] and len(pandaStates) > 0:
|
||||
|
||||
# Set ignition based on any panda connected
|
||||
@@ -231,7 +240,7 @@ def hardware_thread(end_event, hw_queue) -> None:
|
||||
cloudlog.error("panda timed out onroad")
|
||||
|
||||
# Run at 2Hz, plus either edge of ignition
|
||||
ign_edge = (started_ts is not None) != onroad_conditions["ignition"]
|
||||
ign_edge = (started_ts is not None) != all(onroad_conditions.values())
|
||||
if (sm.frame % round(SERVICE_LIST['pandaStates'].frequency * DT_HW) != 0) and not ign_edge:
|
||||
continue
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import random
|
||||
|
||||
from cereal import log
|
||||
from openpilot.system.hardware.base import HardwareBase
|
||||
from openpilot.system.hardware.base import HardwareBase, LPABase
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
NetworkStrength = log.DeviceState.NetworkStrength
|
||||
|
||||
|
||||
class Pc(HardwareBase):
|
||||
def get_os_version(self):
|
||||
return None
|
||||
@@ -41,6 +40,9 @@ class Pc(HardwareBase):
|
||||
'data_connected': False
|
||||
}
|
||||
|
||||
def get_sim_lpa(self) -> LPABase:
|
||||
raise NotImplementedError("SIM LPA not implemented for PC")
|
||||
|
||||
def get_network_strength(self, network_type):
|
||||
return NetworkStrength.unknown
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
[
|
||||
{
|
||||
"name": "xbl",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl-468f1ad6ab55e198647ff9191f91bd2918db9c0a3e27bae5673b4c5575c1254c.img.xz",
|
||||
"hash": "468f1ad6ab55e198647ff9191f91bd2918db9c0a3e27bae5673b4c5575c1254c",
|
||||
"hash_raw": "468f1ad6ab55e198647ff9191f91bd2918db9c0a3e27bae5673b4c5575c1254c",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl-6710967ca9701f205d7ab19c3a9b0dd2f547e65b3d96048b7c2b03755aafa0f1.img.xz",
|
||||
"hash": "6710967ca9701f205d7ab19c3a9b0dd2f547e65b3d96048b7c2b03755aafa0f1",
|
||||
"hash_raw": "6710967ca9701f205d7ab19c3a9b0dd2f547e65b3d96048b7c2b03755aafa0f1",
|
||||
"size": 3282256,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "d35a86e7b8ddd9279b513a6f27da1521aa0f89fb93987ea74d57d0f0bbbbd247"
|
||||
"ondevice_hash": "003a17ab1be68a696f7efe4c9938e8be511d4aacfc2f3211fc896bdc1681d089"
|
||||
},
|
||||
{
|
||||
"name": "xbl_config",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-92b675dc2862ed15c732d91d9eb307d7e852e349217db8bee8f8829db543686b.img.xz",
|
||||
"hash": "92b675dc2862ed15c732d91d9eb307d7e852e349217db8bee8f8829db543686b",
|
||||
"hash_raw": "92b675dc2862ed15c732d91d9eb307d7e852e349217db8bee8f8829db543686b",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63922cfbfdf4ab87986c4ba8f3a4df5bf28414b3f71a29ec5947336722215535.img.xz",
|
||||
"hash": "63922cfbfdf4ab87986c4ba8f3a4df5bf28414b3f71a29ec5947336722215535",
|
||||
"hash_raw": "63922cfbfdf4ab87986c4ba8f3a4df5bf28414b3f71a29ec5947336722215535",
|
||||
"size": 98124,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "623f1568072ee2d687ba8449a3d894c1c83dc4131b2e79eff35696885f70a419"
|
||||
"ondevice_hash": "2a855dd636cc94718b64bea83a44d0a31741ecaa8f72a63613ff348ec7404091"
|
||||
},
|
||||
{
|
||||
"name": "abl",
|
||||
@@ -34,51 +34,51 @@
|
||||
},
|
||||
{
|
||||
"name": "aop",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/aop-f0fcf7611d0890a72984f15a516dd37fa532dfcb70d428a8406838cf74ce23d5.img.xz",
|
||||
"hash": "f0fcf7611d0890a72984f15a516dd37fa532dfcb70d428a8406838cf74ce23d5",
|
||||
"hash_raw": "f0fcf7611d0890a72984f15a516dd37fa532dfcb70d428a8406838cf74ce23d5",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz",
|
||||
"hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9",
|
||||
"hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9",
|
||||
"size": 184364,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "bf74feca486f650589f6b7c90eab73274e35a68b5e00bfc1de0ed5f5484d4b3d"
|
||||
"ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180"
|
||||
},
|
||||
{
|
||||
"name": "devcfg",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-225b24ea7b1d2fee7f7d2da21386920ddacac2e33e9e938168436292f4eae180.img.xz",
|
||||
"hash": "225b24ea7b1d2fee7f7d2da21386920ddacac2e33e9e938168436292f4eae180",
|
||||
"hash_raw": "225b24ea7b1d2fee7f7d2da21386920ddacac2e33e9e938168436292f4eae180",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz",
|
||||
"hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620",
|
||||
"hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620",
|
||||
"size": 40336,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "70f682b59ca0fe2f197d1486bd8be7b9b7e560798ad40ddef83b9f0a2f497938"
|
||||
"ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f"
|
||||
},
|
||||
{
|
||||
"name": "boot",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-0d0d4d5a32e00b46fa36180b4a96337f2a53302d8bd0faee95f8fe1063d1e24a.img.xz",
|
||||
"hash": "0d0d4d5a32e00b46fa36180b4a96337f2a53302d8bd0faee95f8fe1063d1e24a",
|
||||
"hash_raw": "0d0d4d5a32e00b46fa36180b4a96337f2a53302d8bd0faee95f8fe1063d1e24a",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
|
||||
"hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
|
||||
"hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
|
||||
"size": 18479104,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "47b2096995578a5078e393c33108b42756009dbb361c43c508fc93cd8bda99cc"
|
||||
"ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211.img.xz",
|
||||
"hash": "fbda983c40e75d8c4784a45c687f27dd27f97f7f0e9a71b4981ec2092ff68a72",
|
||||
"hash_raw": "ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211",
|
||||
"size": 4404019200,
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
|
||||
"hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
|
||||
"hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
|
||||
"size": 5368709120,
|
||||
"sparse": true,
|
||||
"full_check": false,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "428c744458d5f35199aab96a0928876c9deeff3e753e95044291408ba244f0af",
|
||||
"ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
|
||||
"alt": {
|
||||
"hash": "ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211.img",
|
||||
"size": 4404019200
|
||||
"hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
|
||||
"size": 5368709120
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -130,25 +130,25 @@
|
||||
},
|
||||
{
|
||||
"name": "xbl",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl-468f1ad6ab55e198647ff9191f91bd2918db9c0a3e27bae5673b4c5575c1254c.img.xz",
|
||||
"hash": "468f1ad6ab55e198647ff9191f91bd2918db9c0a3e27bae5673b4c5575c1254c",
|
||||
"hash_raw": "468f1ad6ab55e198647ff9191f91bd2918db9c0a3e27bae5673b4c5575c1254c",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl-6710967ca9701f205d7ab19c3a9b0dd2f547e65b3d96048b7c2b03755aafa0f1.img.xz",
|
||||
"hash": "6710967ca9701f205d7ab19c3a9b0dd2f547e65b3d96048b7c2b03755aafa0f1",
|
||||
"hash_raw": "6710967ca9701f205d7ab19c3a9b0dd2f547e65b3d96048b7c2b03755aafa0f1",
|
||||
"size": 3282256,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "d35a86e7b8ddd9279b513a6f27da1521aa0f89fb93987ea74d57d0f0bbbbd247"
|
||||
"ondevice_hash": "003a17ab1be68a696f7efe4c9938e8be511d4aacfc2f3211fc896bdc1681d089"
|
||||
},
|
||||
{
|
||||
"name": "xbl_config",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-92b675dc2862ed15c732d91d9eb307d7e852e349217db8bee8f8829db543686b.img.xz",
|
||||
"hash": "92b675dc2862ed15c732d91d9eb307d7e852e349217db8bee8f8829db543686b",
|
||||
"hash_raw": "92b675dc2862ed15c732d91d9eb307d7e852e349217db8bee8f8829db543686b",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/xbl_config-63922cfbfdf4ab87986c4ba8f3a4df5bf28414b3f71a29ec5947336722215535.img.xz",
|
||||
"hash": "63922cfbfdf4ab87986c4ba8f3a4df5bf28414b3f71a29ec5947336722215535",
|
||||
"hash_raw": "63922cfbfdf4ab87986c4ba8f3a4df5bf28414b3f71a29ec5947336722215535",
|
||||
"size": 98124,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "623f1568072ee2d687ba8449a3d894c1c83dc4131b2e79eff35696885f70a419"
|
||||
"ondevice_hash": "2a855dd636cc94718b64bea83a44d0a31741ecaa8f72a63613ff348ec7404091"
|
||||
},
|
||||
{
|
||||
"name": "abl",
|
||||
@@ -163,14 +163,14 @@
|
||||
},
|
||||
{
|
||||
"name": "aop",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/aop-f0fcf7611d0890a72984f15a516dd37fa532dfcb70d428a8406838cf74ce23d5.img.xz",
|
||||
"hash": "f0fcf7611d0890a72984f15a516dd37fa532dfcb70d428a8406838cf74ce23d5",
|
||||
"hash_raw": "f0fcf7611d0890a72984f15a516dd37fa532dfcb70d428a8406838cf74ce23d5",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/aop-21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9.img.xz",
|
||||
"hash": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9",
|
||||
"hash_raw": "21370172e590bd4ea907a558bcd6df20dc7a6c7d38b8e62fdde18f4a512ba9e9",
|
||||
"size": 184364,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "bf74feca486f650589f6b7c90eab73274e35a68b5e00bfc1de0ed5f5484d4b3d"
|
||||
"ondevice_hash": "c1be2f4aac5b3af49b904b027faec418d05efd7bd5144eb4fdfcba602bcf2180"
|
||||
},
|
||||
{
|
||||
"name": "bluetooth",
|
||||
@@ -207,14 +207,14 @@
|
||||
},
|
||||
{
|
||||
"name": "devcfg",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-225b24ea7b1d2fee7f7d2da21386920ddacac2e33e9e938168436292f4eae180.img.xz",
|
||||
"hash": "225b24ea7b1d2fee7f7d2da21386920ddacac2e33e9e938168436292f4eae180",
|
||||
"hash_raw": "225b24ea7b1d2fee7f7d2da21386920ddacac2e33e9e938168436292f4eae180",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/devcfg-d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620.img.xz",
|
||||
"hash": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620",
|
||||
"hash_raw": "d7d7e52963bbedbbf8a7e66847579ca106a0a729ce2cf60f4b8d8ea4b535d620",
|
||||
"size": 40336,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "70f682b59ca0fe2f197d1486bd8be7b9b7e560798ad40ddef83b9f0a2f497938"
|
||||
"ondevice_hash": "17b229668b20305ff8fa3cd5f94716a3aaa1e5bf9d1c24117eff7f2f81ae719f"
|
||||
},
|
||||
{
|
||||
"name": "devinfo",
|
||||
@@ -339,62 +339,62 @@
|
||||
},
|
||||
{
|
||||
"name": "boot",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-0d0d4d5a32e00b46fa36180b4a96337f2a53302d8bd0faee95f8fe1063d1e24a.img.xz",
|
||||
"hash": "0d0d4d5a32e00b46fa36180b4a96337f2a53302d8bd0faee95f8fe1063d1e24a",
|
||||
"hash_raw": "0d0d4d5a32e00b46fa36180b4a96337f2a53302d8bd0faee95f8fe1063d1e24a",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/boot-4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef.img.xz",
|
||||
"hash": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
|
||||
"hash_raw": "4de8f892dbac3fa3fee1efe68ca76e23e75812e81a6577d00d52e2da1ef624ef",
|
||||
"size": 18479104,
|
||||
"sparse": false,
|
||||
"full_check": true,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "47b2096995578a5078e393c33108b42756009dbb361c43c508fc93cd8bda99cc"
|
||||
"ondevice_hash": "8d7094d774faa4e801e36b403a31b53b913b31d086f4dc682d2f64710c557e8a"
|
||||
},
|
||||
{
|
||||
"name": "system",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211.img.xz",
|
||||
"hash": "fbda983c40e75d8c4784a45c687f27dd27f97f7f0e9a71b4981ec2092ff68a72",
|
||||
"hash_raw": "ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211",
|
||||
"size": 4404019200,
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img.xz",
|
||||
"hash": "cccd7073d067027396f2afd49874729757db0bbbc79853a0bf2938bd356fe164",
|
||||
"hash_raw": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
|
||||
"size": 5368709120,
|
||||
"sparse": true,
|
||||
"full_check": false,
|
||||
"has_ab": true,
|
||||
"ondevice_hash": "428c744458d5f35199aab96a0928876c9deeff3e753e95044291408ba244f0af",
|
||||
"ondevice_hash": "c7707f16ce7d977748677cc354e250943b4ff6c21b9a19a492053d32397cf9ec",
|
||||
"alt": {
|
||||
"hash": "ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-ff2da7bc34a1ad8e2831a13a49685243957a51b762d8f28e14e98ac90e84d211.img",
|
||||
"size": 4404019200
|
||||
"hash": "4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/system-4bc3951f4aa3f70c53837dc2542d8b0666d37103b353fd81417cc7de1bbebe39.img",
|
||||
"size": 5368709120
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "userdata_90",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-17f46ba0d0eec10cf14804c84d9bcf4f6fa864074f1b90ff2fb4873399d64b1a.img.xz",
|
||||
"hash": "1442ae218daff960783bc493a32dd489fa787e4025c64fa3132f48b1e627647f",
|
||||
"hash_raw": "17f46ba0d0eec10cf14804c84d9bcf4f6fa864074f1b90ff2fb4873399d64b1a",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/userdata_90-a3f56051f1c05cd3a4b643fbdc55c893b074f046aaa4c0bd20fba7534542befd.img.xz",
|
||||
"hash": "1e05be99139612b92ab77fec1b073bd2e39b202ed27344259b2bea66be4a6809",
|
||||
"hash_raw": "a3f56051f1c05cd3a4b643fbdc55c893b074f046aaa4c0bd20fba7534542befd",
|
||||
"size": 96636764160,
|
||||
"sparse": true,
|
||||
"full_check": true,
|
||||
"has_ab": false,
|
||||
"ondevice_hash": "a64fea9c3a7bec11b850a55d50ceb30a032742c90aadf0ce35cdbef9349bebba"
|
||||
"ondevice_hash": "6d58b6a8b2218807c124037705413a074af00a427891819156f632e2259ac0fd"
|
||||
},
|
||||
{
|
||||
"name": "userdata_89",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-69b2404839f87b1de5c05766550ca0217d5ed566c9f2ea41c46b5a301540a3ac.img.xz",
|
||||
"hash": "332e6cf5c221690a01ebfd0582420e039c7796a8fb2d499e44a8b657dffe4af2",
|
||||
"hash_raw": "69b2404839f87b1de5c05766550ca0217d5ed566c9f2ea41c46b5a301540a3ac",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/userdata_89-02045f1d0f97b843d1b4fc23b8abb2615fefdfc88a5d7e742b67fb57e5b1e0f4.img.xz",
|
||||
"hash": "2894293c4c08041f50ffcf02020cebf72ddf6a435ae52b9f91bff16e70cc00f2",
|
||||
"hash_raw": "02045f1d0f97b843d1b4fc23b8abb2615fefdfc88a5d7e742b67fb57e5b1e0f4",
|
||||
"size": 95563022336,
|
||||
"sparse": true,
|
||||
"full_check": true,
|
||||
"has_ab": false,
|
||||
"ondevice_hash": "c560c76007fd8997e01e516ab16ff445048945a13bf830bb2a4c7f6e9d2fe5f6"
|
||||
"ondevice_hash": "bd6d103feebfad36c3e5db38638784c556f5b95b12c490bee088275361b8c3d0"
|
||||
},
|
||||
{
|
||||
"name": "userdata_30",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-ffc34d8753520e684448185db801c05bb81e23c54a499cc40a2e38b7009cdf36.img.xz",
|
||||
"hash": "8bfb1fdec9c8f96c97bcce1efbc7d502447bdb3b481cc408b24b5feef66839fd",
|
||||
"hash_raw": "ffc34d8753520e684448185db801c05bb81e23c54a499cc40a2e38b7009cdf36",
|
||||
"url": "https://commadist.azureedge.net/agnosupdate/userdata_30-26fd873d2d497a577f9aa69844c035e24b1eda1d0d6741c45c360330207336ea.img.xz",
|
||||
"hash": "c58d0ded625a46edbafdd6991e7211bf53ce20342e8a4cbcb3fa624f4e55665d",
|
||||
"hash_raw": "26fd873d2d497a577f9aa69844c035e24b1eda1d0d6741c45c360330207336ea",
|
||||
"size": 32212254720,
|
||||
"sparse": true,
|
||||
"full_check": true,
|
||||
"has_ab": false,
|
||||
"ondevice_hash": "8f840157d73fa4469aa650d668e046c9750304af84248ab83453cbd285918859"
|
||||
"ondevice_hash": "7604ff49a9b37592c3f1404cdfc8ea54b9e11d6aa49a93c1b82af0c0f5c39151"
|
||||
}
|
||||
]
|
||||
Executable → Regular
+84
-93
@@ -1,115 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
import json
|
||||
import os
|
||||
import math
|
||||
import time
|
||||
import binascii
|
||||
import requests
|
||||
import serial
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
|
||||
from openpilot.system.hardware.base import LPABase, LPAError, LPAProfileNotFoundError, Profile
|
||||
|
||||
def post(url, payload):
|
||||
print()
|
||||
print("POST to", url)
|
||||
r = requests.post(
|
||||
url,
|
||||
data=payload,
|
||||
verify=False,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Admin-Protocol": "gsma/rsp/v2.2.0",
|
||||
"charset": "utf-8",
|
||||
"User-Agent": "gsma-rsp-lpad",
|
||||
},
|
||||
)
|
||||
print("resp", r)
|
||||
print("resp text", repr(r.text))
|
||||
print()
|
||||
r.raise_for_status()
|
||||
class TiciLPA(LPABase):
|
||||
def __init__(self, interface: Literal['qmi', 'at'] = 'qmi'):
|
||||
self.env = os.environ.copy()
|
||||
self.env['LPAC_APDU'] = interface
|
||||
self.env['QMI_DEVICE'] = '/dev/cdc-wdm0'
|
||||
self.env['AT_DEVICE'] = '/dev/ttyUSB2'
|
||||
|
||||
ret = f"HTTP/1.1 {r.status_code}"
|
||||
ret += ''.join(f"{k}: {v}" for k, v in r.headers.items() if k != 'Connection')
|
||||
return ret.encode() + r.content
|
||||
self.timeout_sec = 45
|
||||
|
||||
if shutil.which('lpac') is None:
|
||||
raise LPAError('lpac not found, must be installed!')
|
||||
|
||||
class LPA:
|
||||
def __init__(self):
|
||||
self.dev = serial.Serial('/dev/ttyUSB2', baudrate=57600, timeout=1, bytesize=8)
|
||||
self.dev.reset_input_buffer()
|
||||
self.dev.reset_output_buffer()
|
||||
assert "OK" in self.at("AT")
|
||||
def list_profiles(self) -> list[Profile]:
|
||||
msgs = self._invoke('profile', 'list')
|
||||
self._validate_successful(msgs)
|
||||
return [Profile(
|
||||
iccid=p['iccid'],
|
||||
nickname=p['profileNickname'],
|
||||
enabled=p['profileState'] == 'enabled',
|
||||
provider=p['serviceProviderName']
|
||||
) for p in msgs[-1]['payload']['data']]
|
||||
|
||||
def at(self, cmd):
|
||||
print(f"==> {cmd}")
|
||||
self.dev.write(cmd.encode() + b'\r\n')
|
||||
def get_active_profile(self) -> Profile | None:
|
||||
return next((p for p in self.list_profiles() if p.enabled), None)
|
||||
|
||||
r = b""
|
||||
cnt = 0
|
||||
while b"OK" not in r and b"ERROR" not in r and cnt < 20:
|
||||
r += self.dev.read(8192).strip()
|
||||
cnt += 1
|
||||
r = r.decode()
|
||||
print(f"<== {repr(r)}")
|
||||
return r
|
||||
def delete_profile(self, iccid: str) -> None:
|
||||
self._validate_profile_exists(iccid)
|
||||
latest = self.get_active_profile()
|
||||
if latest is not None and latest.iccid == iccid:
|
||||
raise LPAError('cannot delete active profile, switch to another profile first')
|
||||
self._validate_successful(self._invoke('profile', 'delete', iccid))
|
||||
self._process_notifications()
|
||||
|
||||
def download_ota(self, qr):
|
||||
return self.at(f'AT+QESIM="ota","{qr}"')
|
||||
def download_profile(self, qr: str, nickname: str | None = None) -> None:
|
||||
msgs = self._invoke('profile', 'download', '-a', qr)
|
||||
self._validate_successful(msgs)
|
||||
new_profile = next((m for m in msgs if m['payload']['message'] == 'es8p_meatadata_parse'), None)
|
||||
if new_profile is None:
|
||||
raise LPAError('no new profile found')
|
||||
if nickname:
|
||||
self.nickname_profile(new_profile['payload']['data']['iccid'], nickname)
|
||||
self._process_notifications()
|
||||
|
||||
def download(self, qr):
|
||||
smdp = qr.split('$')[1]
|
||||
out = self.at(f'AT+QESIM="download","{qr}"')
|
||||
for _ in range(5):
|
||||
line = out.split("+QESIM: ")[1].split("\r\n\r\nOK")[0]
|
||||
def nickname_profile(self, iccid: str, nickname: str) -> None:
|
||||
self._validate_profile_exists(iccid)
|
||||
self._validate_successful(self._invoke('profile', 'nickname', iccid, nickname))
|
||||
|
||||
parts = [x.strip().strip('"') for x in line.split(',', maxsplit=4)]
|
||||
print(repr(parts))
|
||||
trans, ret, url, payloadlen, payload = parts
|
||||
assert trans == "trans" and ret == "0"
|
||||
assert len(payload) == int(payloadlen)
|
||||
def switch_profile(self, iccid: str) -> None:
|
||||
self._validate_profile_exists(iccid)
|
||||
latest = self.get_active_profile()
|
||||
if latest and latest.iccid == iccid:
|
||||
return
|
||||
self._validate_successful(self._invoke('profile', 'enable', iccid))
|
||||
self._process_notifications()
|
||||
|
||||
r = post(f"https://{smdp}/{url}", payload)
|
||||
to_send = binascii.hexlify(r).decode()
|
||||
def _invoke(self, *cmd: str):
|
||||
proc = subprocess.Popen(['sudo', '-E', 'lpac'] + list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env)
|
||||
try:
|
||||
out, err = proc.communicate(timeout=self.timeout_sec)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
proc.kill()
|
||||
raise LPAError(f"lpac {cmd} timed out after {self.timeout_sec} seconds") from e
|
||||
|
||||
chunk_len = 1400
|
||||
for i in range(math.ceil(len(to_send) / chunk_len)):
|
||||
state = 1 if (i+1)*chunk_len < len(to_send) else 0
|
||||
data = to_send[i * chunk_len : (i+1)*chunk_len]
|
||||
out = self.at(f'AT+QESIM="trans",{len(to_send)},{state},{i},{len(data)},"{data}"')
|
||||
assert "OK" in out
|
||||
messages = []
|
||||
for line in out.decode().strip().splitlines():
|
||||
if line.startswith('{'):
|
||||
message = json.loads(line)
|
||||
|
||||
if '+QESIM:"download",1' in out:
|
||||
raise Exception("profile install failed")
|
||||
elif '+QESIM:"download",0' in out:
|
||||
print("done, successfully loaded")
|
||||
break
|
||||
# lpac response format validations
|
||||
assert 'type' in message, 'expected type in message'
|
||||
assert message['type'] == 'lpa' or message['type'] == 'progress', 'expected lpa or progress message type'
|
||||
assert 'payload' in message, 'expected payload in message'
|
||||
assert 'code' in message['payload'], 'expected code in message payload'
|
||||
assert 'data' in message['payload'], 'expected data in message payload'
|
||||
|
||||
def enable(self, iccid):
|
||||
self.at(f'AT+QESIM="enable","{iccid}"')
|
||||
msg_ret_code = message['payload']['code']
|
||||
if msg_ret_code != 0:
|
||||
raise LPAError(f"lpac {' '.join(cmd)} failed with code {msg_ret_code}: <{message['payload']['message']}> {message['payload']['data']}")
|
||||
|
||||
def disable(self, iccid):
|
||||
self.at(f'AT+QESIM="disable","{iccid}"')
|
||||
messages.append(message)
|
||||
|
||||
def delete(self, iccid):
|
||||
self.at(f'AT+QESIM="delete","{iccid}"')
|
||||
if len(messages) == 0:
|
||||
raise LPAError(f"lpac {cmd} returned no messages")
|
||||
|
||||
def list_profiles(self):
|
||||
out = self.at('AT+QESIM="list"')
|
||||
return out.strip().splitlines()[1:]
|
||||
return messages
|
||||
|
||||
def _process_notifications(self) -> None:
|
||||
"""
|
||||
Process notifications stored on the eUICC, typically to activate/deactivate the profile with the carrier.
|
||||
"""
|
||||
self._validate_successful(self._invoke('notification', 'process', '-a', '-r'))
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
def _validate_profile_exists(self, iccid: str) -> None:
|
||||
if not any(p.iccid == iccid for p in self.list_profiles()):
|
||||
raise LPAProfileNotFoundError(f'profile {iccid} does not exist')
|
||||
|
||||
if "RESTART" in os.environ:
|
||||
subprocess.check_call("sudo systemctl stop ModemManager", shell=True)
|
||||
subprocess.check_call("/usr/comma/lte/lte.sh stop_blocking", shell=True)
|
||||
subprocess.check_call("/usr/comma/lte/lte.sh start", shell=True)
|
||||
while not os.path.exists('/dev/ttyUSB2'):
|
||||
time.sleep(1)
|
||||
time.sleep(3)
|
||||
|
||||
lpa = LPA()
|
||||
print(lpa.list_profiles())
|
||||
if len(sys.argv) > 1:
|
||||
lpa.download(sys.argv[1])
|
||||
print(lpa.list_profiles())
|
||||
def _validate_successful(self, msgs: list[dict]) -> None:
|
||||
assert msgs[-1]['payload']['message'] == 'success', 'expected success notification'
|
||||
|
||||
@@ -9,9 +9,11 @@ from functools import cached_property, lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from cereal import log
|
||||
from openpilot.common.util import sudo_read, sudo_write
|
||||
from openpilot.common.gpio import gpio_set, gpio_init, get_irqs_for_action
|
||||
from openpilot.system.hardware.base import HardwareBase, ThermalConfig, ThermalZone
|
||||
from openpilot.system.hardware.base import HardwareBase, LPABase, ThermalConfig, ThermalZone
|
||||
from openpilot.system.hardware.tici import iwlist
|
||||
from openpilot.system.hardware.tici.esim import TiciLPA
|
||||
from openpilot.system.hardware.tici.pins import GPIO
|
||||
from openpilot.system.hardware.tici.amplifier import Amplifier
|
||||
|
||||
@@ -61,25 +63,6 @@ MM_MODEM_ACCESS_TECHNOLOGY_UMTS = 1 << 5
|
||||
MM_MODEM_ACCESS_TECHNOLOGY_LTE = 1 << 14
|
||||
|
||||
|
||||
def sudo_write(val, path):
|
||||
try:
|
||||
with open(path, 'w') as f:
|
||||
f.write(str(val))
|
||||
except PermissionError:
|
||||
os.system(f"sudo chmod a+w {path}")
|
||||
try:
|
||||
with open(path, 'w') as f:
|
||||
f.write(str(val))
|
||||
except PermissionError:
|
||||
# fallback for debugfs files
|
||||
os.system(f"sudo su -c 'echo {val} > {path}'")
|
||||
|
||||
def sudo_read(path: str) -> str:
|
||||
try:
|
||||
return subprocess.check_output(f"sudo cat {path}", shell=True, encoding='utf8').strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def affine_irq(val, action):
|
||||
irqs = get_irqs_for_action(action)
|
||||
if len(irqs) == 0:
|
||||
@@ -198,6 +181,9 @@ class Tici(HardwareBase):
|
||||
'data_connected': modem.Get(MM_MODEM, 'State', dbus_interface=DBUS_PROPS, timeout=TIMEOUT) == MM_MODEM_STATE.CONNECTED,
|
||||
}
|
||||
|
||||
def get_sim_lpa(self) -> LPABase:
|
||||
return TiciLPA()
|
||||
|
||||
def get_imei(self, slot):
|
||||
if slot != 0:
|
||||
return ""
|
||||
@@ -297,13 +283,11 @@ class Tici(HardwareBase):
|
||||
return None
|
||||
|
||||
def get_modem_temperatures(self):
|
||||
if self.get_device_type() == "mici":
|
||||
return []
|
||||
timeout = 0.2 # Default timeout is too short
|
||||
try:
|
||||
modem = self.get_modem()
|
||||
temps = modem.Command("AT+QTEMP", math.ceil(timeout), dbus_interface=MM_MODEM, timeout=timeout)
|
||||
return list(map(int, temps.split(' ')[1].split(',')))
|
||||
return list(filter(lambda t: t != 255, map(int, temps.split(' ')[1].split(','))))
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -335,12 +319,20 @@ class Tici(HardwareBase):
|
||||
return ThermalConfig(cpu=[ThermalZone(f"cpu{i}-silver-usr") for i in range(4)] +
|
||||
[ThermalZone(f"cpu{i}-gold-usr") for i in range(4)],
|
||||
gpu=[ThermalZone("gpu0-usr"), ThermalZone("gpu1-usr")],
|
||||
dsp=ThermalZone("compute-hvx-usr"),
|
||||
memory=ThermalZone("ddr-usr"),
|
||||
pmic=[ThermalZone("pm8998_tz"), ThermalZone("pm8005_tz")],
|
||||
intake=intake,
|
||||
exhaust=exhaust,
|
||||
case=case)
|
||||
|
||||
def set_display_power(self, on):
|
||||
try:
|
||||
with open("/sys/class/backlight/panel0-backlight/bl_power", "w") as f:
|
||||
f.write("0" if on else "4")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def set_screen_brightness(self, percentage):
|
||||
try:
|
||||
with open("/sys/class/backlight/panel0-backlight/max_brightness") as f:
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import pytest
|
||||
|
||||
from openpilot.system.hardware import HARDWARE, TICI
|
||||
from openpilot.system.hardware.base import LPAProfileNotFoundError
|
||||
|
||||
# https://euicc-manual.osmocom.org/docs/rsp/known-test-profile
|
||||
# iccid is always the same for the given activation code
|
||||
TEST_ACTIVATION_CODE = 'LPA:1$rsp.truphone.com$QRF-BETTERROAMING-PMRDGIR2EARDEIT5'
|
||||
TEST_ICCID = '8944476500001944011'
|
||||
|
||||
TEST_NICKNAME = 'test_profile'
|
||||
|
||||
def cleanup():
|
||||
lpa = HARDWARE.get_sim_lpa()
|
||||
try:
|
||||
lpa.delete_profile(TEST_ICCID)
|
||||
except LPAProfileNotFoundError:
|
||||
pass
|
||||
lpa.process_notifications()
|
||||
|
||||
class TestEsim:
|
||||
|
||||
@classmethod
|
||||
def setup_class(cls):
|
||||
if not TICI:
|
||||
pytest.skip()
|
||||
cleanup()
|
||||
|
||||
@classmethod
|
||||
def teardown_class(cls):
|
||||
cleanup()
|
||||
|
||||
def test_provision_enable_disable(self):
|
||||
lpa = HARDWARE.get_sim_lpa()
|
||||
current_active = lpa.get_active_profile()
|
||||
|
||||
lpa.download_profile(TEST_ACTIVATION_CODE, TEST_NICKNAME)
|
||||
assert any(p.iccid == TEST_ICCID and p.nickname == TEST_NICKNAME for p in lpa.list_profiles())
|
||||
|
||||
lpa.enable_profile(TEST_ICCID)
|
||||
new_active = lpa.get_active_profile()
|
||||
assert new_active is not None
|
||||
assert new_active.iccid == TEST_ICCID
|
||||
assert new_active.nickname == TEST_NICKNAME
|
||||
|
||||
lpa.disable_profile(TEST_ICCID)
|
||||
new_active = lpa.get_active_profile()
|
||||
assert new_active is None
|
||||
|
||||
if current_active:
|
||||
lpa.enable_profile(current_active.iccid)
|
||||
@@ -56,10 +56,10 @@ class TestPowerDraw:
|
||||
def valid_msg_count(self, proc, msg_counts):
|
||||
msgs_received = sum(msg_counts[msg] for msg in proc.msgs)
|
||||
msgs_expected = self.get_expected_messages(proc)
|
||||
return np.core.numeric.isclose(msgs_expected, msgs_received, rtol=.02, atol=2)
|
||||
return np.isclose(msgs_expected, msgs_received, rtol=.02, atol=2)
|
||||
|
||||
def valid_power_draw(self, proc, used):
|
||||
return np.core.numeric.isclose(used, proc.power, rtol=proc.rtol, atol=proc.atol)
|
||||
return np.isclose(used, proc.power, rtol=proc.rtol, atol=proc.atol)
|
||||
|
||||
def tabulate_msg_counts(self, msgs_and_power):
|
||||
msg_counts = defaultdict(int)
|
||||
|
||||
@@ -203,7 +203,7 @@ void handle_user_flag(LoggerdState *s) {
|
||||
|
||||
// mark route for uploading
|
||||
Params params;
|
||||
std::string routes = Params().get("AthenadRecentlyViewedRoutes");
|
||||
std::string routes = params.get("AthenadRecentlyViewedRoutes");
|
||||
params.put("AthenadRecentlyViewedRoutes", routes + "," + s->logger.routeName());
|
||||
|
||||
prev_segment = s->logger.segment();
|
||||
|
||||
@@ -89,7 +89,7 @@ class Uploader:
|
||||
|
||||
def list_upload_files(self, metered: bool) -> Iterator[tuple[str, str, str]]:
|
||||
r = self.params.get("AthenadRecentlyViewedRoutes", encoding="utf8")
|
||||
requested_routes = [] if r is None else r.split(",")
|
||||
requested_routes = [] if r is None else [route for route in r.split(",") if route]
|
||||
|
||||
for logdir in listdir_by_creation(self.root):
|
||||
path = os.path.join(self.root, logdir)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import os
|
||||
import errno
|
||||
|
||||
import xattr
|
||||
|
||||
_cached_attributes: dict[tuple, bytes | None] = {}
|
||||
|
||||
def getxattr(path: str, attr_name: str) -> bytes | None:
|
||||
key = (path, attr_name)
|
||||
if key not in _cached_attributes:
|
||||
try:
|
||||
response = os.getxattr(path, attr_name)
|
||||
response = xattr.getxattr(path, attr_name)
|
||||
except OSError as e:
|
||||
# ENODATA means attribute hasn't been set
|
||||
if e.errno == errno.ENODATA:
|
||||
# ENODATA (Linux) or ENOATTR (macOS) means attribute hasn't been set
|
||||
if e.errno == errno.ENODATA or (hasattr(errno, 'ENOATTR') and e.errno == errno.ENOATTR):
|
||||
response = None
|
||||
else:
|
||||
raise
|
||||
@@ -19,4 +20,4 @@ def getxattr(path: str, attr_name: str) -> bytes | None:
|
||||
|
||||
def setxattr(path: str, attr_name: str, attr_value: bytes) -> None:
|
||||
_cached_attributes.pop((path, attr_name), None)
|
||||
return os.setxattr(path, attr_name, attr_value)
|
||||
xattr.setxattr(path, attr_name, attr_value)
|
||||
|
||||
@@ -5,10 +5,10 @@ from pathlib import Path
|
||||
|
||||
# NOTE: Do NOT import anything here that needs be built (e.g. params)
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.spinner import Spinner
|
||||
from openpilot.common.text_window import TextWindow
|
||||
from openpilot.system.hardware import HARDWARE, AGNOS
|
||||
from openpilot.common.swaglog import cloudlog, add_file_handler
|
||||
from openpilot.system.hardware import HARDWARE, AGNOS
|
||||
from openpilot.system.ui.spinner import Spinner
|
||||
from openpilot.system.ui.text import TextWindow
|
||||
from openpilot.system.version import get_build_metadata
|
||||
|
||||
MAX_CACHE_SIZE = 4e9 if "CI" in os.environ else 2e9
|
||||
@@ -88,7 +88,7 @@ def build(spinner: Spinner, dirty: bool = False, minimal: bool = False) -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
spinner = Spinner()
|
||||
spinner.update_progress(0, 100)
|
||||
build_metadata = get_build_metadata()
|
||||
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)
|
||||
with Spinner() as spinner:
|
||||
spinner.update_progress(0, 100)
|
||||
build_metadata = get_build_metadata()
|
||||
build(spinner, build_metadata.openpilot.is_dirty, minimal = AGNOS)
|
||||
|
||||
@@ -9,7 +9,6 @@ from cereal import log
|
||||
import cereal.messaging as messaging
|
||||
import openpilot.system.sentry as sentry
|
||||
from openpilot.common.params import Params, ParamKeyType
|
||||
from openpilot.common.text_window import TextWindow
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.manager.helpers import unblock_stdout, write_onroad_params, save_bootlog
|
||||
from openpilot.system.manager.process import ensure_running
|
||||
@@ -203,6 +202,8 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from openpilot.system.ui.text import TextWindow
|
||||
|
||||
unblock_stdout()
|
||||
|
||||
try:
|
||||
|
||||
@@ -96,7 +96,6 @@ class ManagerProcess(ABC):
|
||||
try:
|
||||
fn = WATCHDOG_FN + str(self.proc.pid)
|
||||
with open(fn, "rb") as f:
|
||||
# TODO: why can't pylint find struct.unpack?
|
||||
self.last_watchdog_time = struct.unpack('Q', f.read())[0]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import operator
|
||||
import platform
|
||||
|
||||
from cereal import car
|
||||
from openpilot.common.params import Params
|
||||
@@ -70,15 +71,15 @@ procs = [
|
||||
|
||||
NativeProcess("camerad", "system/camerad", ["./camerad"], driverview, enabled=not WEBCAM),
|
||||
PythonProcess("webcamerad", "tools.webcam.camerad", driverview, enabled=WEBCAM),
|
||||
NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad),
|
||||
NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad),
|
||||
NativeProcess("logcatd", "system/logcatd", ["./logcatd"], only_onroad, platform.system() != "Darwin"),
|
||||
NativeProcess("proclogd", "system/proclogd", ["./proclogd"], only_onroad, platform.system() != "Darwin"),
|
||||
PythonProcess("micd", "system.micd", iscar),
|
||||
PythonProcess("timed", "system.timed", always_run, enabled=not PC),
|
||||
|
||||
PythonProcess("modeld", "selfdrive.modeld.modeld", only_onroad),
|
||||
PythonProcess("dmonitoringmodeld", "selfdrive.modeld.dmonitoringmodeld", driverview, enabled=(WEBCAM or not PC)),
|
||||
|
||||
NativeProcess("sensord", "system/sensord", ["./sensord"], only_onroad, enabled=not PC),
|
||||
PythonProcess("sensord", "system.sensord.sensord", only_onroad, enabled=not PC),
|
||||
NativeProcess("ui", "selfdrive/ui", ["./ui"], always_run, watchdog_max_dt=(5 if not PC else None)),
|
||||
PythonProcess("soundd", "selfdrive.ui.soundd", only_onroad),
|
||||
PythonProcess("locationd", "selfdrive.locationd.locationd", only_onroad),
|
||||
@@ -94,6 +95,7 @@ procs = [
|
||||
PythonProcess("qcomgpsd", "system.qcomgpsd.qcomgpsd", qcomgps, enabled=TICI),
|
||||
PythonProcess("pandad", "selfdrive.pandad.pandad", always_run),
|
||||
PythonProcess("paramsd", "selfdrive.locationd.paramsd", only_onroad),
|
||||
PythonProcess("lagd", "selfdrive.locationd.lagd", only_onroad),
|
||||
NativeProcess("ubloxd", "system/ubloxd", ["./ubloxd"], ublox, enabled=TICI),
|
||||
PythonProcess("pigeond", "system.ubloxd.pigeond", ublox, enabled=TICI),
|
||||
PythonProcess("plannerd", "selfdrive.controls.plannerd", not_long_maneuver),
|
||||
|
||||
+20
-13
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import numpy as np
|
||||
from functools import cache
|
||||
import threading
|
||||
|
||||
from cereal import messaging
|
||||
from openpilot.common.realtime import Ratekeeper
|
||||
@@ -52,12 +53,18 @@ class Mic:
|
||||
self.sound_pressure_weighted = 0
|
||||
self.sound_pressure_level_weighted = 0
|
||||
|
||||
def update(self):
|
||||
msg = messaging.new_message('microphone', valid=True)
|
||||
msg.microphone.soundPressure = float(self.sound_pressure)
|
||||
msg.microphone.soundPressureWeighted = float(self.sound_pressure_weighted)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
msg.microphone.soundPressureWeightedDb = float(self.sound_pressure_level_weighted)
|
||||
def update(self):
|
||||
with self.lock:
|
||||
sound_pressure = self.sound_pressure
|
||||
sound_pressure_weighted = self.sound_pressure_weighted
|
||||
sound_pressure_level_weighted = self.sound_pressure_level_weighted
|
||||
|
||||
msg = messaging.new_message('microphone', valid=True)
|
||||
msg.microphone.soundPressure = float(sound_pressure)
|
||||
msg.microphone.soundPressureWeighted = float(sound_pressure_weighted)
|
||||
msg.microphone.soundPressureWeightedDb = float(sound_pressure_level_weighted)
|
||||
|
||||
self.pm.send('microphone', msg)
|
||||
self.rk.keep_time()
|
||||
@@ -69,17 +76,17 @@ class Mic:
|
||||
|
||||
Logged A-weighted equivalents are rough approximations of the human-perceived loudness.
|
||||
"""
|
||||
with self.lock:
|
||||
self.measurements = np.concatenate((self.measurements, indata[:, 0]))
|
||||
|
||||
self.measurements = np.concatenate((self.measurements, indata[:, 0]))
|
||||
while self.measurements.size >= FFT_SAMPLES:
|
||||
measurements = self.measurements[:FFT_SAMPLES]
|
||||
|
||||
while self.measurements.size >= FFT_SAMPLES:
|
||||
measurements = self.measurements[:FFT_SAMPLES]
|
||||
self.sound_pressure, _ = calculate_spl(measurements)
|
||||
measurements_weighted = apply_a_weighting(measurements)
|
||||
self.sound_pressure_weighted, self.sound_pressure_level_weighted = calculate_spl(measurements_weighted)
|
||||
|
||||
self.sound_pressure, _ = calculate_spl(measurements)
|
||||
measurements_weighted = apply_a_weighting(measurements)
|
||||
self.sound_pressure_weighted, self.sound_pressure_level_weighted = calculate_spl(measurements_weighted)
|
||||
|
||||
self.measurements = self.measurements[FFT_SAMPLES:]
|
||||
self.measurements = self.measurements[FFT_SAMPLES:]
|
||||
|
||||
@retry(attempts=7, delay=3)
|
||||
def get_stream(self, sd):
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
sensord
|
||||
@@ -1,17 +0,0 @@
|
||||
Import('env', 'arch', 'common', 'messaging')
|
||||
|
||||
sensors = [
|
||||
'sensors/i2c_sensor.cc',
|
||||
'sensors/bmx055_accel.cc',
|
||||
'sensors/bmx055_gyro.cc',
|
||||
'sensors/bmx055_magn.cc',
|
||||
'sensors/bmx055_temp.cc',
|
||||
'sensors/lsm6ds3_accel.cc',
|
||||
'sensors/lsm6ds3_gyro.cc',
|
||||
'sensors/lsm6ds3_temp.cc',
|
||||
'sensors/mmc5603nj_magn.cc',
|
||||
]
|
||||
libs = [common, messaging, 'pthread']
|
||||
if arch == "larch64":
|
||||
libs.append('i2c')
|
||||
env.Program('sensord', ['sensors_qcom2.cc'] + sensors, LIBS=libs)
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import time
|
||||
import ctypes
|
||||
import select
|
||||
import threading
|
||||
|
||||
import cereal.messaging as messaging
|
||||
from cereal.services import SERVICE_LIST
|
||||
from openpilot.common.util import sudo_write
|
||||
from openpilot.common.realtime import config_realtime_process, Ratekeeper
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.gpio import gpiochip_get_ro_value_fd, gpioevent_data
|
||||
|
||||
from openpilot.system.sensord.sensors.i2c_sensor import Sensor
|
||||
from openpilot.system.sensord.sensors.lsm6ds3_accel import LSM6DS3_Accel
|
||||
from openpilot.system.sensord.sensors.lsm6ds3_gyro import LSM6DS3_Gyro
|
||||
from openpilot.system.sensord.sensors.lsm6ds3_temp import LSM6DS3_Temp
|
||||
from openpilot.system.sensord.sensors.mmc5603nj_magn import MMC5603NJ_Magn
|
||||
|
||||
I2C_BUS_IMU = 1
|
||||
|
||||
def interrupt_loop(sensors: list[tuple[Sensor, str, bool]], event) -> None:
|
||||
pm = messaging.PubMaster([service for sensor, service, interrupt in sensors if interrupt])
|
||||
|
||||
# Requesting both edges as the data ready pulse from the lsm6ds sensor is
|
||||
# very short (75us) and is mostly detected as falling edge instead of rising.
|
||||
# So if it is detected as rising the following falling edge is skipped.
|
||||
fd = gpiochip_get_ro_value_fd("sensord", 0, 84)
|
||||
|
||||
# Configure IRQ affinity
|
||||
irq_path = "/proc/irq/336/smp_affinity_list"
|
||||
if not os.path.exists(irq_path):
|
||||
irq_path = "/proc/irq/335/smp_affinity_list"
|
||||
if os.path.exists(irq_path):
|
||||
sudo_write('1\n', irq_path)
|
||||
|
||||
offset = time.time_ns() - time.monotonic_ns()
|
||||
|
||||
poller = select.poll()
|
||||
poller.register(fd, select.POLLIN | select.POLLPRI)
|
||||
while not event.is_set():
|
||||
events = poller.poll(100)
|
||||
if not events:
|
||||
cloudlog.error("poll timed out")
|
||||
continue
|
||||
if not (events[0][1] & (select.POLLIN | select.POLLPRI)):
|
||||
cloudlog.error("no poll events set")
|
||||
continue
|
||||
|
||||
dat = os.read(fd, ctypes.sizeof(gpioevent_data)*16)
|
||||
evd = gpioevent_data.from_buffer_copy(dat)
|
||||
|
||||
cur_offset = time.time_ns() - time.monotonic_ns()
|
||||
if abs(cur_offset - offset) > 10 * 1e6: # ms
|
||||
cloudlog.warning(f"time jumped: {cur_offset} {offset}")
|
||||
offset = cur_offset
|
||||
continue
|
||||
|
||||
ts = evd.timestamp - cur_offset
|
||||
for sensor, service, interrupt in sensors:
|
||||
if interrupt:
|
||||
try:
|
||||
evt = sensor.get_event(ts)
|
||||
if not sensor.is_data_valid():
|
||||
continue
|
||||
msg = messaging.new_message(service, valid=True)
|
||||
setattr(msg, service, evt)
|
||||
pm.send(service, msg)
|
||||
except Sensor.DataNotReady:
|
||||
pass
|
||||
except Exception:
|
||||
cloudlog.exception(f"Error processing {service}")
|
||||
|
||||
|
||||
def polling_loop(sensor: Sensor, service: str, event: threading.Event) -> None:
|
||||
pm = messaging.PubMaster([service])
|
||||
rk = Ratekeeper(SERVICE_LIST[service].frequency, print_delay_threshold=None)
|
||||
while not event.is_set():
|
||||
try:
|
||||
evt = sensor.get_event()
|
||||
if not sensor.is_data_valid():
|
||||
continue
|
||||
msg = messaging.new_message(service, valid=True)
|
||||
setattr(msg, service, evt)
|
||||
pm.send(service, msg)
|
||||
except Exception:
|
||||
cloudlog.exception(f"Error in {service} polling loop")
|
||||
rk.keep_time()
|
||||
|
||||
def main() -> None:
|
||||
config_realtime_process([1, ], 1)
|
||||
|
||||
sensors_cfg = [
|
||||
(LSM6DS3_Accel(I2C_BUS_IMU), "accelerometer", True),
|
||||
(LSM6DS3_Gyro(I2C_BUS_IMU), "gyroscope", True),
|
||||
(LSM6DS3_Temp(I2C_BUS_IMU), "temperatureSensor", False),
|
||||
(MMC5603NJ_Magn(I2C_BUS_IMU), "magnetometer", False),
|
||||
]
|
||||
|
||||
# Initialize sensors
|
||||
exit_event = threading.Event()
|
||||
threads = [
|
||||
threading.Thread(target=interrupt_loop, args=(sensors_cfg, exit_event), daemon=True)
|
||||
]
|
||||
for sensor, service, interrupt in sensors_cfg:
|
||||
try:
|
||||
sensor.init()
|
||||
if not interrupt:
|
||||
# Start polling thread for sensors without interrupts
|
||||
threads.append(threading.Thread(
|
||||
target=polling_loop,
|
||||
args=(sensor, service, exit_event),
|
||||
daemon=True
|
||||
))
|
||||
except Exception:
|
||||
cloudlog.exception(f"Error initializing {service} sensor")
|
||||
|
||||
try:
|
||||
for t in threads:
|
||||
t.start()
|
||||
while any(t.is_alive() for t in threads):
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
exit_event.set()
|
||||
for t in threads:
|
||||
if t.is_alive():
|
||||
t.join()
|
||||
|
||||
for sensor, _, _ in sensors_cfg:
|
||||
try:
|
||||
sensor.shutdown()
|
||||
except Exception:
|
||||
cloudlog.exception("Error shutting down sensor")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,85 +0,0 @@
|
||||
#include "system/sensord/sensors/bmx055_accel.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
|
||||
BMX055_Accel::BMX055_Accel(I2CBus *bus) : I2CSensor(bus) {}
|
||||
|
||||
int BMX055_Accel::init() {
|
||||
int ret = verify_chip_id(BMX055_ACCEL_I2C_REG_ID, {BMX055_ACCEL_CHIP_ID});
|
||||
if (ret == -1) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
ret = set_register(BMX055_ACCEL_I2C_REG_PMU, BMX055_ACCEL_NORMAL_MODE);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// bmx055 accel has a 1.3ms wakeup time from deep suspend mode
|
||||
util::sleep_for(10);
|
||||
|
||||
// High bandwidth
|
||||
// ret = set_register(BMX055_ACCEL_I2C_REG_HBW, BMX055_ACCEL_HBW_ENABLE);
|
||||
// if (ret < 0) {
|
||||
// goto fail;
|
||||
// }
|
||||
|
||||
// Low bandwidth
|
||||
ret = set_register(BMX055_ACCEL_I2C_REG_HBW, BMX055_ACCEL_HBW_DISABLE);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
ret = set_register(BMX055_ACCEL_I2C_REG_BW, BMX055_ACCEL_BW_125HZ);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
enabled = true;
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
int BMX055_Accel::shutdown() {
|
||||
if (!enabled) return 0;
|
||||
|
||||
// enter deep suspend mode (lowest power mode)
|
||||
int ret = set_register(BMX055_ACCEL_I2C_REG_PMU, BMX055_ACCEL_DEEP_SUSPEND);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not move BMX055 ACCEL in deep suspend mode!");
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool BMX055_Accel::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
uint64_t start_time = nanos_since_boot();
|
||||
uint8_t buffer[6];
|
||||
int len = read_register(BMX055_ACCEL_I2C_REG_X_LSB, buffer, sizeof(buffer));
|
||||
assert(len == 6);
|
||||
|
||||
// 12 bit = +-2g
|
||||
float scale = 9.81 * 2.0f / (1 << 11);
|
||||
float x = -read_12_bit(buffer[0], buffer[1]) * scale;
|
||||
float y = -read_12_bit(buffer[2], buffer[3]) * scale;
|
||||
float z = read_12_bit(buffer[4], buffer[5]) * scale;
|
||||
|
||||
auto event = msg.initEvent().initAccelerometer2();
|
||||
event.setSource(cereal::SensorEventData::SensorSource::BMX055);
|
||||
event.setVersion(1);
|
||||
event.setSensor(SENSOR_ACCELEROMETER);
|
||||
event.setType(SENSOR_TYPE_ACCELEROMETER);
|
||||
event.setTimestamp(start_time);
|
||||
|
||||
float xyz[] = {x, y, z};
|
||||
auto svec = event.initAcceleration();
|
||||
svec.setV(xyz);
|
||||
svec.setStatus(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define BMX055_ACCEL_I2C_ADDR 0x18
|
||||
|
||||
// Registers of the chip
|
||||
#define BMX055_ACCEL_I2C_REG_ID 0x00
|
||||
#define BMX055_ACCEL_I2C_REG_X_LSB 0x02
|
||||
#define BMX055_ACCEL_I2C_REG_TEMP 0x08
|
||||
#define BMX055_ACCEL_I2C_REG_BW 0x10
|
||||
#define BMX055_ACCEL_I2C_REG_PMU 0x11
|
||||
#define BMX055_ACCEL_I2C_REG_HBW 0x13
|
||||
#define BMX055_ACCEL_I2C_REG_FIFO 0x3F
|
||||
|
||||
// Constants
|
||||
#define BMX055_ACCEL_CHIP_ID 0xFA
|
||||
|
||||
#define BMX055_ACCEL_HBW_ENABLE 0b10000000
|
||||
#define BMX055_ACCEL_HBW_DISABLE 0b00000000
|
||||
#define BMX055_ACCEL_DEEP_SUSPEND 0b00100000
|
||||
#define BMX055_ACCEL_NORMAL_MODE 0b00000000
|
||||
|
||||
#define BMX055_ACCEL_BW_7_81HZ 0b01000
|
||||
#define BMX055_ACCEL_BW_15_63HZ 0b01001
|
||||
#define BMX055_ACCEL_BW_31_25HZ 0b01010
|
||||
#define BMX055_ACCEL_BW_62_5HZ 0b01011
|
||||
#define BMX055_ACCEL_BW_125HZ 0b01100
|
||||
#define BMX055_ACCEL_BW_250HZ 0b01101
|
||||
#define BMX055_ACCEL_BW_500HZ 0b01110
|
||||
#define BMX055_ACCEL_BW_1000HZ 0b01111
|
||||
|
||||
class BMX055_Accel : public I2CSensor {
|
||||
uint8_t get_device_address() {return BMX055_ACCEL_I2C_ADDR;}
|
||||
public:
|
||||
BMX055_Accel(I2CBus *bus);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown();
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
#include "system/sensord/sensors/bmx055_gyro.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/util.h"
|
||||
|
||||
#define DEG2RAD(x) ((x) * M_PI / 180.0)
|
||||
|
||||
|
||||
BMX055_Gyro::BMX055_Gyro(I2CBus *bus) : I2CSensor(bus) {}
|
||||
|
||||
int BMX055_Gyro::init() {
|
||||
int ret = verify_chip_id(BMX055_GYRO_I2C_REG_ID, {BMX055_GYRO_CHIP_ID});
|
||||
if (ret == -1) return -1;
|
||||
|
||||
ret = set_register(BMX055_GYRO_I2C_REG_LPM1, BMX055_GYRO_NORMAL_MODE);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
// bmx055 gyro has a 30ms wakeup time from deep suspend mode
|
||||
util::sleep_for(50);
|
||||
|
||||
// High bandwidth
|
||||
// ret = set_register(BMX055_GYRO_I2C_REG_HBW, BMX055_GYRO_HBW_ENABLE);
|
||||
// if (ret < 0) {
|
||||
// goto fail;
|
||||
// }
|
||||
|
||||
// Low bandwidth
|
||||
ret = set_register(BMX055_GYRO_I2C_REG_HBW, BMX055_GYRO_HBW_DISABLE);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// 116 Hz filter
|
||||
ret = set_register(BMX055_GYRO_I2C_REG_BW, BMX055_GYRO_BW_116HZ);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// +- 125 deg/s range
|
||||
ret = set_register(BMX055_GYRO_I2C_REG_RANGE, BMX055_GYRO_RANGE_125);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
enabled = true;
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
int BMX055_Gyro::shutdown() {
|
||||
if (!enabled) return 0;
|
||||
|
||||
// enter deep suspend mode (lowest power mode)
|
||||
int ret = set_register(BMX055_GYRO_I2C_REG_LPM1, BMX055_GYRO_DEEP_SUSPEND);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not move BMX055 GYRO in deep suspend mode!");
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool BMX055_Gyro::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
uint64_t start_time = nanos_since_boot();
|
||||
uint8_t buffer[6];
|
||||
int len = read_register(BMX055_GYRO_I2C_REG_RATE_X_LSB, buffer, sizeof(buffer));
|
||||
assert(len == 6);
|
||||
|
||||
// 16 bit = +- 125 deg/s
|
||||
float scale = 125.0f / (1 << 15);
|
||||
float x = -DEG2RAD(read_16_bit(buffer[0], buffer[1]) * scale);
|
||||
float y = -DEG2RAD(read_16_bit(buffer[2], buffer[3]) * scale);
|
||||
float z = DEG2RAD(read_16_bit(buffer[4], buffer[5]) * scale);
|
||||
|
||||
auto event = msg.initEvent().initGyroscope2();
|
||||
event.setSource(cereal::SensorEventData::SensorSource::BMX055);
|
||||
event.setVersion(1);
|
||||
event.setSensor(SENSOR_GYRO_UNCALIBRATED);
|
||||
event.setType(SENSOR_TYPE_GYROSCOPE_UNCALIBRATED);
|
||||
event.setTimestamp(start_time);
|
||||
|
||||
float xyz[] = {x, y, z};
|
||||
auto svec = event.initGyroUncalibrated();
|
||||
svec.setV(xyz);
|
||||
svec.setStatus(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define BMX055_GYRO_I2C_ADDR 0x68
|
||||
|
||||
// Registers of the chip
|
||||
#define BMX055_GYRO_I2C_REG_ID 0x00
|
||||
#define BMX055_GYRO_I2C_REG_RATE_X_LSB 0x02
|
||||
#define BMX055_GYRO_I2C_REG_RANGE 0x0F
|
||||
#define BMX055_GYRO_I2C_REG_BW 0x10
|
||||
#define BMX055_GYRO_I2C_REG_LPM1 0x11
|
||||
#define BMX055_GYRO_I2C_REG_HBW 0x13
|
||||
#define BMX055_GYRO_I2C_REG_FIFO 0x3F
|
||||
|
||||
// Constants
|
||||
#define BMX055_GYRO_CHIP_ID 0x0F
|
||||
|
||||
#define BMX055_GYRO_HBW_ENABLE 0b10000000
|
||||
#define BMX055_GYRO_HBW_DISABLE 0b00000000
|
||||
#define BMX055_GYRO_DEEP_SUSPEND 0b00100000
|
||||
#define BMX055_GYRO_NORMAL_MODE 0b00000000
|
||||
|
||||
#define BMX055_GYRO_RANGE_2000 0b000
|
||||
#define BMX055_GYRO_RANGE_1000 0b001
|
||||
#define BMX055_GYRO_RANGE_500 0b010
|
||||
#define BMX055_GYRO_RANGE_250 0b011
|
||||
#define BMX055_GYRO_RANGE_125 0b100
|
||||
|
||||
#define BMX055_GYRO_BW_116HZ 0b0010
|
||||
|
||||
|
||||
class BMX055_Gyro : public I2CSensor {
|
||||
uint8_t get_device_address() {return BMX055_GYRO_I2C_ADDR;}
|
||||
public:
|
||||
BMX055_Gyro(I2CBus *bus);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown();
|
||||
};
|
||||
@@ -1,258 +0,0 @@
|
||||
#include "system/sensord/sensors/bmx055_magn.h"
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstdio>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/util.h"
|
||||
|
||||
static int16_t compensate_x(trim_data_t trim_data, int16_t mag_data_x, uint16_t data_rhall) {
|
||||
uint16_t process_comp_x0 = data_rhall;
|
||||
int32_t process_comp_x1 = ((int32_t)trim_data.dig_xyz1) * 16384;
|
||||
uint16_t process_comp_x2 = ((uint16_t)(process_comp_x1 / process_comp_x0)) - ((uint16_t)0x4000);
|
||||
int16_t retval = ((int16_t)process_comp_x2);
|
||||
int32_t process_comp_x3 = (((int32_t)retval) * ((int32_t)retval));
|
||||
int32_t process_comp_x4 = (((int32_t)trim_data.dig_xy2) * (process_comp_x3 / 128));
|
||||
int32_t process_comp_x5 = (int32_t)(((int16_t)trim_data.dig_xy1) * 128);
|
||||
int32_t process_comp_x6 = ((int32_t)retval) * process_comp_x5;
|
||||
int32_t process_comp_x7 = (((process_comp_x4 + process_comp_x6) / 512) + ((int32_t)0x100000));
|
||||
int32_t process_comp_x8 = ((int32_t)(((int16_t)trim_data.dig_x2) + ((int16_t)0xA0)));
|
||||
int32_t process_comp_x9 = ((process_comp_x7 * process_comp_x8) / 4096);
|
||||
int32_t process_comp_x10 = ((int32_t)mag_data_x) * process_comp_x9;
|
||||
retval = ((int16_t)(process_comp_x10 / 8192));
|
||||
retval = (retval + (((int16_t)trim_data.dig_x1) * 8)) / 16;
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static int16_t compensate_y(trim_data_t trim_data, int16_t mag_data_y, uint16_t data_rhall) {
|
||||
uint16_t process_comp_y0 = trim_data.dig_xyz1;
|
||||
int32_t process_comp_y1 = (((int32_t)trim_data.dig_xyz1) * 16384) / process_comp_y0;
|
||||
uint16_t process_comp_y2 = ((uint16_t)process_comp_y1) - ((uint16_t)0x4000);
|
||||
int16_t retval = ((int16_t)process_comp_y2);
|
||||
int32_t process_comp_y3 = ((int32_t) retval) * ((int32_t)retval);
|
||||
int32_t process_comp_y4 = ((int32_t)trim_data.dig_xy2) * (process_comp_y3 / 128);
|
||||
int32_t process_comp_y5 = ((int32_t)(((int16_t)trim_data.dig_xy1) * 128));
|
||||
int32_t process_comp_y6 = ((process_comp_y4 + (((int32_t)retval) * process_comp_y5)) / 512);
|
||||
int32_t process_comp_y7 = ((int32_t)(((int16_t)trim_data.dig_y2) + ((int16_t)0xA0)));
|
||||
int32_t process_comp_y8 = (((process_comp_y6 + ((int32_t)0x100000)) * process_comp_y7) / 4096);
|
||||
int32_t process_comp_y9 = (((int32_t)mag_data_y) * process_comp_y8);
|
||||
retval = (int16_t)(process_comp_y9 / 8192);
|
||||
retval = (retval + (((int16_t)trim_data.dig_y1) * 8)) / 16;
|
||||
|
||||
return retval;
|
||||
}
|
||||
|
||||
static int16_t compensate_z(trim_data_t trim_data, int16_t mag_data_z, uint16_t data_rhall) {
|
||||
int16_t process_comp_z0 = ((int16_t)data_rhall) - ((int16_t) trim_data.dig_xyz1);
|
||||
int32_t process_comp_z1 = (((int32_t)trim_data.dig_z3) * ((int32_t)(process_comp_z0))) / 4;
|
||||
int32_t process_comp_z2 = (((int32_t)(mag_data_z - trim_data.dig_z4)) * 32768);
|
||||
int32_t process_comp_z3 = ((int32_t)trim_data.dig_z1) * (((int16_t)data_rhall) * 2);
|
||||
int16_t process_comp_z4 = (int16_t)((process_comp_z3 + (32768)) / 65536);
|
||||
int32_t retval = ((process_comp_z2 - process_comp_z1) / (trim_data.dig_z2 + process_comp_z4));
|
||||
|
||||
/* saturate result to +/- 2 micro-tesla */
|
||||
retval = std::clamp(retval, -32767, 32767);
|
||||
|
||||
/* Conversion of LSB to micro-tesla*/
|
||||
retval = retval / 16;
|
||||
|
||||
return (int16_t)retval;
|
||||
}
|
||||
|
||||
BMX055_Magn::BMX055_Magn(I2CBus *bus) : I2CSensor(bus) {}
|
||||
|
||||
int BMX055_Magn::init() {
|
||||
uint8_t trim_x1y1[2] = {0};
|
||||
uint8_t trim_x2y2[2] = {0};
|
||||
uint8_t trim_xy1xy2[2] = {0};
|
||||
uint8_t trim_z1[2] = {0};
|
||||
uint8_t trim_z2[2] = {0};
|
||||
uint8_t trim_z3[2] = {0};
|
||||
uint8_t trim_z4[2] = {0};
|
||||
uint8_t trim_xyz1[2] = {0};
|
||||
|
||||
// suspend -> sleep
|
||||
int ret = set_register(BMX055_MAGN_I2C_REG_PWR_0, 0x01);
|
||||
if (ret < 0) {
|
||||
LOGD("Enabling power failed: %d", ret);
|
||||
goto fail;
|
||||
}
|
||||
util::sleep_for(5); // wait until the chip is powered on
|
||||
|
||||
ret = verify_chip_id(BMX055_MAGN_I2C_REG_ID, {BMX055_MAGN_CHIP_ID});
|
||||
if (ret == -1) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// Load magnetometer trim
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_X1, trim_x1y1, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_X2, trim_x2y2, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_XY2, trim_xy1xy2, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z1_LSB, trim_z1, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z2_LSB, trim_z2, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z3_LSB, trim_z3, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_Z4_LSB, trim_z4, 2);
|
||||
if (ret < 0) goto fail;
|
||||
ret = read_register(BMX055_MAGN_I2C_REG_DIG_XYZ1_LSB, trim_xyz1, 2);
|
||||
if (ret < 0) goto fail;
|
||||
|
||||
// Read trim data
|
||||
trim_data.dig_x1 = trim_x1y1[0];
|
||||
trim_data.dig_y1 = trim_x1y1[1];
|
||||
|
||||
trim_data.dig_x2 = trim_x2y2[0];
|
||||
trim_data.dig_y2 = trim_x2y2[1];
|
||||
|
||||
trim_data.dig_xy1 = trim_xy1xy2[1]; // NB: MSB/LSB swapped
|
||||
trim_data.dig_xy2 = trim_xy1xy2[0];
|
||||
|
||||
trim_data.dig_z1 = read_16_bit(trim_z1[0], trim_z1[1]);
|
||||
trim_data.dig_z2 = read_16_bit(trim_z2[0], trim_z2[1]);
|
||||
trim_data.dig_z3 = read_16_bit(trim_z3[0], trim_z3[1]);
|
||||
trim_data.dig_z4 = read_16_bit(trim_z4[0], trim_z4[1]);
|
||||
|
||||
trim_data.dig_xyz1 = read_16_bit(trim_xyz1[0], trim_xyz1[1] & 0x7f);
|
||||
assert(trim_data.dig_xyz1 != 0);
|
||||
|
||||
perform_self_test();
|
||||
|
||||
// f_max = 1 / (145us * nXY + 500us * NZ + 980us)
|
||||
// Chose NXY = 7, NZ = 12, which gives 125 Hz,
|
||||
// and has the same ratio as the high accuracy preset
|
||||
ret = set_register(BMX055_MAGN_I2C_REG_REPXY, (7 - 1) / 2);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
ret = set_register(BMX055_MAGN_I2C_REG_REPZ, 12 - 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
enabled = true;
|
||||
return 0;
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
int BMX055_Magn::shutdown() {
|
||||
if (!enabled) return 0;
|
||||
|
||||
// move to suspend mode
|
||||
int ret = set_register(BMX055_MAGN_I2C_REG_PWR_0, 0);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not move BMX055 MAGN in suspend mode!");
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool BMX055_Magn::perform_self_test() {
|
||||
uint8_t buffer[8];
|
||||
int16_t x, y;
|
||||
int16_t neg_z, pos_z;
|
||||
|
||||
// Increase z reps for less false positives (~30 Hz ODR)
|
||||
set_register(BMX055_MAGN_I2C_REG_REPXY, 1);
|
||||
set_register(BMX055_MAGN_I2C_REG_REPZ, 64 - 1);
|
||||
|
||||
// Clean existing measurement
|
||||
read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer));
|
||||
|
||||
uint8_t forced = BMX055_MAGN_FORCED;
|
||||
|
||||
// Negative current
|
||||
set_register(BMX055_MAGN_I2C_REG_MAG, forced | (uint8_t(0b10) << 6));
|
||||
util::sleep_for(100);
|
||||
|
||||
read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer));
|
||||
parse_xyz(buffer, &x, &y, &neg_z);
|
||||
|
||||
// Positive current
|
||||
set_register(BMX055_MAGN_I2C_REG_MAG, forced | (uint8_t(0b11) << 6));
|
||||
util::sleep_for(100);
|
||||
|
||||
read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer));
|
||||
parse_xyz(buffer, &x, &y, &pos_z);
|
||||
|
||||
// Put back in normal mode
|
||||
set_register(BMX055_MAGN_I2C_REG_MAG, 0);
|
||||
|
||||
int16_t diff = pos_z - neg_z;
|
||||
bool passed = (diff > 180) && (diff < 240);
|
||||
|
||||
if (!passed) {
|
||||
LOGE("self test failed: neg %d pos %d diff %d", neg_z, pos_z, diff);
|
||||
}
|
||||
|
||||
return passed;
|
||||
}
|
||||
|
||||
bool BMX055_Magn::parse_xyz(uint8_t buffer[8], int16_t *x, int16_t *y, int16_t *z) {
|
||||
bool ready = buffer[6] & 0x1;
|
||||
if (ready) {
|
||||
int16_t mdata_x = (int16_t) (((int16_t)buffer[1] << 8) | buffer[0]) >> 3;
|
||||
int16_t mdata_y = (int16_t) (((int16_t)buffer[3] << 8) | buffer[2]) >> 3;
|
||||
int16_t mdata_z = (int16_t) (((int16_t)buffer[5] << 8) | buffer[4]) >> 1;
|
||||
uint16_t data_r = (uint16_t) (((uint16_t)buffer[7] << 8) | buffer[6]) >> 2;
|
||||
assert(data_r != 0);
|
||||
|
||||
*x = compensate_x(trim_data, mdata_x, data_r);
|
||||
*y = compensate_y(trim_data, mdata_y, data_r);
|
||||
*z = compensate_z(trim_data, mdata_z, data_r);
|
||||
}
|
||||
return ready;
|
||||
}
|
||||
|
||||
|
||||
bool BMX055_Magn::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
uint64_t start_time = nanos_since_boot();
|
||||
uint8_t buffer[8];
|
||||
int16_t _x, _y, x, y, z;
|
||||
|
||||
int len = read_register(BMX055_MAGN_I2C_REG_DATAX_LSB, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
bool parsed = parse_xyz(buffer, &_x, &_y, &z);
|
||||
if (parsed) {
|
||||
|
||||
auto event = msg.initEvent().initMagnetometer();
|
||||
event.setSource(cereal::SensorEventData::SensorSource::BMX055);
|
||||
event.setVersion(2);
|
||||
event.setSensor(SENSOR_MAGNETOMETER_UNCALIBRATED);
|
||||
event.setType(SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED);
|
||||
event.setTimestamp(start_time);
|
||||
|
||||
// Move magnetometer into same reference frame as accel/gryo
|
||||
x = -_y;
|
||||
y = _x;
|
||||
|
||||
// Axis convention
|
||||
x = -x;
|
||||
y = -y;
|
||||
|
||||
float xyz[] = {(float)x, (float)y, (float)z};
|
||||
auto svec = event.initMagneticUncalibrated();
|
||||
svec.setV(xyz);
|
||||
svec.setStatus(true);
|
||||
}
|
||||
|
||||
// The BMX055 Magnetometer has no FIFO mode. Self running mode only goes
|
||||
// up to 30 Hz. Therefore we put in forced mode, and request measurements
|
||||
// at a 100 Hz. When reading the registers we have to check the ready bit
|
||||
// To verify the measurement was completed this cycle.
|
||||
set_register(BMX055_MAGN_I2C_REG_MAG, BMX055_MAGN_FORCED);
|
||||
|
||||
return parsed;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
#pragma once
|
||||
#include <tuple>
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define BMX055_MAGN_I2C_ADDR 0x10
|
||||
|
||||
// Registers of the chip
|
||||
#define BMX055_MAGN_I2C_REG_ID 0x40
|
||||
#define BMX055_MAGN_I2C_REG_PWR_0 0x4B
|
||||
#define BMX055_MAGN_I2C_REG_MAG 0x4C
|
||||
#define BMX055_MAGN_I2C_REG_DATAX_LSB 0x42
|
||||
#define BMX055_MAGN_I2C_REG_RHALL_LSB 0x48
|
||||
#define BMX055_MAGN_I2C_REG_REPXY 0x51
|
||||
#define BMX055_MAGN_I2C_REG_REPZ 0x52
|
||||
|
||||
#define BMX055_MAGN_I2C_REG_DIG_X1 0x5D
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Y1 0x5E
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z4_LSB 0x62
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z4_MSB 0x63
|
||||
#define BMX055_MAGN_I2C_REG_DIG_X2 0x64
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Y2 0x65
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z2_LSB 0x68
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z2_MSB 0x69
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z1_LSB 0x6A
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z1_MSB 0x6B
|
||||
#define BMX055_MAGN_I2C_REG_DIG_XYZ1_LSB 0x6C
|
||||
#define BMX055_MAGN_I2C_REG_DIG_XYZ1_MSB 0x6D
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z3_LSB 0x6E
|
||||
#define BMX055_MAGN_I2C_REG_DIG_Z3_MSB 0x6F
|
||||
#define BMX055_MAGN_I2C_REG_DIG_XY2 0x70
|
||||
#define BMX055_MAGN_I2C_REG_DIG_XY1 0x71
|
||||
|
||||
// Constants
|
||||
#define BMX055_MAGN_CHIP_ID 0x32
|
||||
#define BMX055_MAGN_FORCED (0b01 << 1)
|
||||
|
||||
struct trim_data_t {
|
||||
int8_t dig_x1;
|
||||
int8_t dig_y1;
|
||||
int8_t dig_x2;
|
||||
int8_t dig_y2;
|
||||
uint16_t dig_z1;
|
||||
int16_t dig_z2;
|
||||
int16_t dig_z3;
|
||||
int16_t dig_z4;
|
||||
uint8_t dig_xy1;
|
||||
int8_t dig_xy2;
|
||||
uint16_t dig_xyz1;
|
||||
};
|
||||
|
||||
|
||||
class BMX055_Magn : public I2CSensor{
|
||||
uint8_t get_device_address() {return BMX055_MAGN_I2C_ADDR;}
|
||||
trim_data_t trim_data = {0};
|
||||
bool perform_self_test();
|
||||
bool parse_xyz(uint8_t buffer[8], int16_t *x, int16_t *y, int16_t *z);
|
||||
public:
|
||||
BMX055_Magn(I2CBus *bus);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown();
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
#include "system/sensord/sensors/bmx055_temp.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "system/sensord/sensors/bmx055_accel.h"
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
|
||||
BMX055_Temp::BMX055_Temp(I2CBus *bus) : I2CSensor(bus) {}
|
||||
|
||||
int BMX055_Temp::init() {
|
||||
return verify_chip_id(BMX055_ACCEL_I2C_REG_ID, {BMX055_ACCEL_CHIP_ID}) == -1 ? -1 : 0;
|
||||
}
|
||||
|
||||
bool BMX055_Temp::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
uint64_t start_time = nanos_since_boot();
|
||||
uint8_t buffer[1];
|
||||
int len = read_register(BMX055_ACCEL_I2C_REG_TEMP, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
float temp = 23.0f + int8_t(buffer[0]) / 2.0f;
|
||||
|
||||
auto event = msg.initEvent().initTemperatureSensor();
|
||||
event.setSource(cereal::SensorEventData::SensorSource::BMX055);
|
||||
event.setVersion(1);
|
||||
event.setType(SENSOR_TYPE_AMBIENT_TEMPERATURE);
|
||||
event.setTimestamp(start_time);
|
||||
event.setTemperature(temp);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "system/sensord/sensors/bmx055_accel.h"
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
class BMX055_Temp : public I2CSensor {
|
||||
uint8_t get_device_address() {return BMX055_ACCEL_I2C_ADDR;}
|
||||
public:
|
||||
BMX055_Temp(I2CBus *bus);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown() { return 0; }
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
|
||||
#define SENSOR_ACCELEROMETER 1
|
||||
#define SENSOR_MAGNETOMETER 2
|
||||
#define SENSOR_MAGNETOMETER_UNCALIBRATED 3
|
||||
#define SENSOR_GYRO 4
|
||||
#define SENSOR_GYRO_UNCALIBRATED 5
|
||||
#define SENSOR_LIGHT 7
|
||||
|
||||
#define SENSOR_TYPE_ACCELEROMETER 1
|
||||
#define SENSOR_TYPE_GEOMAGNETIC_FIELD 2
|
||||
#define SENSOR_TYPE_GYROSCOPE 4
|
||||
#define SENSOR_TYPE_LIGHT 5
|
||||
#define SENSOR_TYPE_AMBIENT_TEMPERATURE 13
|
||||
#define SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED 14
|
||||
#define SENSOR_TYPE_MAGNETIC_FIELD SENSOR_TYPE_GEOMAGNETIC_FIELD
|
||||
#define SENSOR_TYPE_GYROSCOPE_UNCALIBRATED 16
|
||||
@@ -1,50 +0,0 @@
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
int16_t read_12_bit(uint8_t lsb, uint8_t msb) {
|
||||
uint16_t combined = (uint16_t(msb) << 8) | uint16_t(lsb & 0xF0);
|
||||
return int16_t(combined) / (1 << 4);
|
||||
}
|
||||
|
||||
int16_t read_16_bit(uint8_t lsb, uint8_t msb) {
|
||||
uint16_t combined = (uint16_t(msb) << 8) | uint16_t(lsb);
|
||||
return int16_t(combined);
|
||||
}
|
||||
|
||||
int32_t read_20_bit(uint8_t b2, uint8_t b1, uint8_t b0) {
|
||||
uint32_t combined = (uint32_t(b0) << 16) | (uint32_t(b1) << 8) | uint32_t(b2);
|
||||
return int32_t(combined) / (1 << 4);
|
||||
}
|
||||
|
||||
I2CSensor::I2CSensor(I2CBus *bus, int gpio_nr, bool shared_gpio) :
|
||||
bus(bus), gpio_nr(gpio_nr), shared_gpio(shared_gpio) {}
|
||||
|
||||
I2CSensor::~I2CSensor() {
|
||||
if (gpio_fd != -1) {
|
||||
close(gpio_fd);
|
||||
}
|
||||
}
|
||||
|
||||
int I2CSensor::read_register(uint register_address, uint8_t *buffer, uint8_t len) {
|
||||
return bus->read_register(get_device_address(), register_address, buffer, len);
|
||||
}
|
||||
|
||||
int I2CSensor::set_register(uint register_address, uint8_t data) {
|
||||
return bus->set_register(get_device_address(), register_address, data);
|
||||
}
|
||||
|
||||
int I2CSensor::init_gpio() {
|
||||
if (shared_gpio || gpio_nr == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
gpio_fd = gpiochip_get_ro_value_fd("sensord", GPIOCHIP_INT, gpio_nr);
|
||||
if (gpio_fd < 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool I2CSensor::has_interrupt_enabled() {
|
||||
return gpio_nr != 0;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <unistd.h>
|
||||
#include <vector>
|
||||
#include "cereal/gen/cpp/log.capnp.h"
|
||||
|
||||
#include "common/i2c.h"
|
||||
#include "common/gpio.h"
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "system/sensord/sensors/constants.h"
|
||||
#include "system/sensord/sensors/sensor.h"
|
||||
|
||||
int16_t read_12_bit(uint8_t lsb, uint8_t msb);
|
||||
int16_t read_16_bit(uint8_t lsb, uint8_t msb);
|
||||
int32_t read_20_bit(uint8_t b2, uint8_t b1, uint8_t b0);
|
||||
|
||||
|
||||
class I2CSensor : public Sensor {
|
||||
private:
|
||||
I2CBus *bus;
|
||||
int gpio_nr;
|
||||
bool shared_gpio;
|
||||
virtual uint8_t get_device_address() = 0;
|
||||
|
||||
public:
|
||||
I2CSensor(I2CBus *bus, int gpio_nr = 0, bool shared_gpio = false);
|
||||
~I2CSensor();
|
||||
int read_register(uint register_address, uint8_t *buffer, uint8_t len);
|
||||
int set_register(uint register_address, uint8_t data);
|
||||
int init_gpio();
|
||||
bool has_interrupt_enabled();
|
||||
virtual int init() = 0;
|
||||
virtual bool get_event(MessageBuilder &msg, uint64_t ts = 0) = 0;
|
||||
virtual int shutdown() = 0;
|
||||
|
||||
int verify_chip_id(uint8_t address, const std::vector<uint8_t> &expected_ids) {
|
||||
uint8_t chip_id = 0;
|
||||
int ret = read_register(address, &chip_id, 1);
|
||||
if (ret < 0) {
|
||||
LOGD("Reading chip ID failed: %d", ret);
|
||||
return -1;
|
||||
}
|
||||
for (int i = 0; i < expected_ids.size(); ++i) {
|
||||
if (chip_id == expected_ids[i]) return chip_id;
|
||||
}
|
||||
LOGE("Chip ID wrong. Got: %d, Expected %d", chip_id, expected_ids[0]);
|
||||
return -1;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import time
|
||||
import smbus2
|
||||
import ctypes
|
||||
from collections.abc import Iterable
|
||||
|
||||
from cereal import log
|
||||
|
||||
class Sensor:
|
||||
class SensorException(Exception):
|
||||
pass
|
||||
|
||||
class DataNotReady(SensorException):
|
||||
pass
|
||||
|
||||
def __init__(self, bus: int) -> None:
|
||||
self.bus = smbus2.SMBus(bus)
|
||||
self.source = log.SensorEventData.SensorSource.velodyne # unknown
|
||||
self.start_ts = 0.
|
||||
|
||||
def __del__(self):
|
||||
self.bus.close()
|
||||
|
||||
def read(self, addr: int, length: int) -> bytes:
|
||||
return bytes(self.bus.read_i2c_block_data(self.device_address, addr, length))
|
||||
|
||||
def write(self, addr: int, data: int) -> None:
|
||||
self.bus.write_byte_data(self.device_address, addr, data)
|
||||
|
||||
def writes(self, writes: Iterable[tuple[int, int]]) -> None:
|
||||
for addr, data in writes:
|
||||
self.write(addr, data)
|
||||
|
||||
def verify_chip_id(self, address: int, expected_ids: list[int]) -> int:
|
||||
chip_id = self.read(address, 1)[0]
|
||||
assert chip_id in expected_ids
|
||||
return chip_id
|
||||
|
||||
# Abstract methods that must be implemented by subclasses
|
||||
@property
|
||||
def device_address(self) -> int:
|
||||
raise NotImplementedError
|
||||
|
||||
def init(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def get_event(self, ts: int | None = None) -> log.SensorEventData:
|
||||
raise NotImplementedError
|
||||
|
||||
def shutdown(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def is_data_valid(self) -> bool:
|
||||
if self.start_ts == 0:
|
||||
self.start_ts = time.monotonic()
|
||||
|
||||
# unclear whether we need this...
|
||||
return (time.monotonic() - self.start_ts) > 0.5
|
||||
|
||||
# *** helpers ***
|
||||
@staticmethod
|
||||
def wait():
|
||||
# a standard small sleep
|
||||
time.sleep(0.005)
|
||||
|
||||
@staticmethod
|
||||
def parse_16bit(lsb: int, msb: int) -> int:
|
||||
return ctypes.c_int16((msb << 8) | lsb).value
|
||||
|
||||
@staticmethod
|
||||
def parse_20bit(b2: int, b1: int, b0: int) -> int:
|
||||
combined = ctypes.c_uint32((b0 << 16) | (b1 << 8) | b2).value
|
||||
return ctypes.c_int32(combined).value // (1 << 4)
|
||||
@@ -1,250 +0,0 @@
|
||||
#include "system/sensord/sensors/lsm6ds3_accel.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
|
||||
LSM6DS3_Accel::LSM6DS3_Accel(I2CBus *bus, int gpio_nr, bool shared_gpio) :
|
||||
I2CSensor(bus, gpio_nr, shared_gpio) {}
|
||||
|
||||
void LSM6DS3_Accel::wait_for_data_ready() {
|
||||
uint8_t drdy = 0;
|
||||
uint8_t buffer[6];
|
||||
|
||||
do {
|
||||
read_register(LSM6DS3_ACCEL_I2C_REG_STAT_REG, &drdy, sizeof(drdy));
|
||||
drdy &= LSM6DS3_ACCEL_DRDY_XLDA;
|
||||
} while (drdy == 0);
|
||||
|
||||
read_register(LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, buffer, sizeof(buffer));
|
||||
}
|
||||
|
||||
void LSM6DS3_Accel::read_and_avg_data(float* out_buf) {
|
||||
uint8_t drdy = 0;
|
||||
uint8_t buffer[6];
|
||||
|
||||
float scaling = 0.061f;
|
||||
if (source == cereal::SensorEventData::SensorSource::LSM6DS3TRC) {
|
||||
scaling = 0.122f;
|
||||
}
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
do {
|
||||
read_register(LSM6DS3_ACCEL_I2C_REG_STAT_REG, &drdy, sizeof(drdy));
|
||||
drdy &= LSM6DS3_ACCEL_DRDY_XLDA;
|
||||
} while (drdy == 0);
|
||||
|
||||
int len = read_register(LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
for (int j = 0; j < 3; j++) {
|
||||
out_buf[j] += (float)read_16_bit(buffer[j*2], buffer[j*2+1]) * scaling;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
out_buf[i] /= 5.0f;
|
||||
}
|
||||
}
|
||||
|
||||
int LSM6DS3_Accel::self_test(int test_type) {
|
||||
float val_st_off[3] = {0};
|
||||
float val_st_on[3] = {0};
|
||||
float test_val[3] = {0};
|
||||
uint8_t ODR_FS_MO = LSM6DS3_ACCEL_ODR_52HZ; // full scale: +-2g, ODR: 52Hz
|
||||
|
||||
// prepare sensor for self-test
|
||||
|
||||
// enable block data update and automatic increment
|
||||
int ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL3_C, LSM6DS3_ACCEL_IF_INC_BDU);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (source == cereal::SensorEventData::SensorSource::LSM6DS3TRC) {
|
||||
ODR_FS_MO = LSM6DS3_ACCEL_FS_4G | LSM6DS3_ACCEL_ODR_52HZ;
|
||||
}
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, ODR_FS_MO);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// wait for stable output, and discard first values
|
||||
util::sleep_for(100);
|
||||
wait_for_data_ready();
|
||||
read_and_avg_data(val_st_off);
|
||||
|
||||
// enable Self Test positive (or negative)
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL5_C, test_type);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// wait for stable output, and discard first values
|
||||
util::sleep_for(100);
|
||||
wait_for_data_ready();
|
||||
read_and_avg_data(val_st_on);
|
||||
|
||||
// disable sensor
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, 0);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// disable self test
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL5_C, 0);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// calculate the mg values for self test
|
||||
for (int i = 0; i < 3; i++) {
|
||||
test_val[i] = fabs(val_st_on[i] - val_st_off[i]);
|
||||
}
|
||||
|
||||
// verify test result
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if ((LSM6DS3_ACCEL_MIN_ST_LIMIT_mg > test_val[i]) ||
|
||||
(test_val[i] > LSM6DS3_ACCEL_MAX_ST_LIMIT_mg)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
int LSM6DS3_Accel::init() {
|
||||
uint8_t value = 0;
|
||||
bool do_self_test = false;
|
||||
|
||||
const char* env_lsm_selftest = std::getenv("LSM_SELF_TEST");
|
||||
if (env_lsm_selftest != nullptr && strncmp(env_lsm_selftest, "1", 1) == 0) {
|
||||
do_self_test = true;
|
||||
}
|
||||
|
||||
int ret = verify_chip_id(LSM6DS3_ACCEL_I2C_REG_ID, {LSM6DS3_ACCEL_CHIP_ID, LSM6DS3TRC_ACCEL_CHIP_ID});
|
||||
if (ret == -1) return -1;
|
||||
|
||||
if (ret == LSM6DS3TRC_ACCEL_CHIP_ID) {
|
||||
source = cereal::SensorEventData::SensorSource::LSM6DS3TRC;
|
||||
}
|
||||
|
||||
ret = self_test(LSM6DS3_ACCEL_POSITIVE_TEST);
|
||||
if (ret < 0) {
|
||||
LOGE("LSM6DS3 accel positive self-test failed!");
|
||||
if (do_self_test) goto fail;
|
||||
}
|
||||
|
||||
ret = self_test(LSM6DS3_ACCEL_NEGATIVE_TEST);
|
||||
if (ret < 0) {
|
||||
LOGE("LSM6DS3 accel negative self-test failed!");
|
||||
if (do_self_test) goto fail;
|
||||
}
|
||||
|
||||
ret = init_gpio();
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// enable continuous update, and automatic increase
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL3_C, LSM6DS3_ACCEL_IF_INC);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// TODO: set scale and bandwidth. Default is +- 2G, 50 Hz
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, LSM6DS3_ACCEL_ODR_104HZ);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_DRDY_CFG, LSM6DS3_ACCEL_DRDY_PULSE_MODE);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// enable data ready interrupt for accel on INT1
|
||||
// (without resetting existing interrupts)
|
||||
ret = read_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value |= LSM6DS3_ACCEL_INT1_DRDY_XL;
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, value);
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
int LSM6DS3_Accel::shutdown() {
|
||||
int ret = 0;
|
||||
|
||||
// disable data ready interrupt for accel on INT1
|
||||
uint8_t value = 0;
|
||||
ret = read_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value &= ~(LSM6DS3_ACCEL_INT1_DRDY_XL);
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, value);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not disable lsm6ds3 acceleration interrupt!");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// enable power-down mode
|
||||
value = 0;
|
||||
ret = read_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value &= 0x0F;
|
||||
ret = set_register(LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, value);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not power-down lsm6ds3 accelerometer!");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool LSM6DS3_Accel::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
|
||||
// INT1 shared with gyro, check STATUS_REG who triggered
|
||||
uint8_t status_reg = 0;
|
||||
read_register(LSM6DS3_ACCEL_I2C_REG_STAT_REG, &status_reg, sizeof(status_reg));
|
||||
if ((status_reg & LSM6DS3_ACCEL_DRDY_XLDA) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t buffer[6];
|
||||
int len = read_register(LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
float scale = 9.81 * 2.0f / (1 << 15);
|
||||
float x = read_16_bit(buffer[0], buffer[1]) * scale;
|
||||
float y = read_16_bit(buffer[2], buffer[3]) * scale;
|
||||
float z = read_16_bit(buffer[4], buffer[5]) * scale;
|
||||
|
||||
auto event = msg.initEvent().initAccelerometer();
|
||||
event.setSource(source);
|
||||
event.setVersion(1);
|
||||
event.setSensor(SENSOR_ACCELEROMETER);
|
||||
event.setType(SENSOR_TYPE_ACCELEROMETER);
|
||||
event.setTimestamp(ts);
|
||||
|
||||
float xyz[] = {y, -x, z};
|
||||
auto svec = event.initAcceleration();
|
||||
svec.setV(xyz);
|
||||
svec.setStatus(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define LSM6DS3_ACCEL_I2C_ADDR 0x6A
|
||||
|
||||
// Registers of the chip
|
||||
#define LSM6DS3_ACCEL_I2C_REG_DRDY_CFG 0x0B
|
||||
#define LSM6DS3_ACCEL_I2C_REG_ID 0x0F
|
||||
#define LSM6DS3_ACCEL_I2C_REG_INT1_CTRL 0x0D
|
||||
#define LSM6DS3_ACCEL_I2C_REG_CTRL1_XL 0x10
|
||||
#define LSM6DS3_ACCEL_I2C_REG_CTRL3_C 0x12
|
||||
#define LSM6DS3_ACCEL_I2C_REG_CTRL5_C 0x14
|
||||
#define LSM6DS3_ACCEL_I2C_REG_CTR9_XL 0x18
|
||||
#define LSM6DS3_ACCEL_I2C_REG_STAT_REG 0x1E
|
||||
#define LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL 0x28
|
||||
|
||||
// Constants
|
||||
#define LSM6DS3_ACCEL_CHIP_ID 0x69
|
||||
#define LSM6DS3TRC_ACCEL_CHIP_ID 0x6A
|
||||
#define LSM6DS3_ACCEL_FS_4G (0b10 << 2)
|
||||
#define LSM6DS3_ACCEL_ODR_52HZ (0b0011 << 4)
|
||||
#define LSM6DS3_ACCEL_ODR_104HZ (0b0100 << 4)
|
||||
#define LSM6DS3_ACCEL_INT1_DRDY_XL 0b1
|
||||
#define LSM6DS3_ACCEL_DRDY_XLDA 0b1
|
||||
#define LSM6DS3_ACCEL_DRDY_PULSE_MODE (1 << 7)
|
||||
#define LSM6DS3_ACCEL_IF_INC 0b00000100
|
||||
#define LSM6DS3_ACCEL_IF_INC_BDU 0b01000100
|
||||
#define LSM6DS3_ACCEL_XYZ_DEN 0b11100000
|
||||
#define LSM6DS3_ACCEL_POSITIVE_TEST 0b01
|
||||
#define LSM6DS3_ACCEL_NEGATIVE_TEST 0b10
|
||||
#define LSM6DS3_ACCEL_MIN_ST_LIMIT_mg 90.0f
|
||||
#define LSM6DS3_ACCEL_MAX_ST_LIMIT_mg 1700.0f
|
||||
|
||||
class LSM6DS3_Accel : public I2CSensor {
|
||||
uint8_t get_device_address() {return LSM6DS3_ACCEL_I2C_ADDR;}
|
||||
cereal::SensorEventData::SensorSource source = cereal::SensorEventData::SensorSource::LSM6DS3;
|
||||
|
||||
// self test functions
|
||||
int self_test(int test_type);
|
||||
void wait_for_data_ready();
|
||||
void read_and_avg_data(float* val_st_off);
|
||||
public:
|
||||
LSM6DS3_Accel(I2CBus *bus, int gpio_nr = 0, bool shared_gpio = false);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown();
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import os
|
||||
import time
|
||||
|
||||
from cereal import log
|
||||
from openpilot.system.sensord.sensors.i2c_sensor import Sensor
|
||||
|
||||
class LSM6DS3_Accel(Sensor):
|
||||
LSM6DS3_ACCEL_I2C_REG_DRDY_CFG = 0x0B
|
||||
LSM6DS3_ACCEL_I2C_REG_INT1_CTRL = 0x0D
|
||||
LSM6DS3_ACCEL_I2C_REG_CTRL1_XL = 0x10
|
||||
LSM6DS3_ACCEL_I2C_REG_CTRL3_C = 0x12
|
||||
LSM6DS3_ACCEL_I2C_REG_CTRL5_C = 0x14
|
||||
LSM6DS3_ACCEL_I2C_REG_STAT_REG = 0x1E
|
||||
LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL = 0x28
|
||||
|
||||
LSM6DS3_ACCEL_ODR_104HZ = (0b0100 << 4)
|
||||
LSM6DS3_ACCEL_INT1_DRDY_XL = 0b1
|
||||
LSM6DS3_ACCEL_DRDY_XLDA = 0b1
|
||||
LSM6DS3_ACCEL_DRDY_PULSE_MODE = (1 << 7)
|
||||
LSM6DS3_ACCEL_IF_INC = 0b00000100
|
||||
|
||||
LSM6DS3_ACCEL_ODR_52HZ = (0b0011 << 4)
|
||||
LSM6DS3_ACCEL_FS_4G = (0b10 << 2)
|
||||
LSM6DS3_ACCEL_IF_INC_BDU = 0b01000100
|
||||
LSM6DS3_ACCEL_POSITIVE_TEST = 0b01
|
||||
LSM6DS3_ACCEL_NEGATIVE_TEST = 0b10
|
||||
LSM6DS3_ACCEL_MIN_ST_LIMIT_mg = 90.0
|
||||
LSM6DS3_ACCEL_MAX_ST_LIMIT_mg = 1700.0
|
||||
|
||||
@property
|
||||
def device_address(self) -> int:
|
||||
return 0x6A
|
||||
|
||||
def init(self):
|
||||
chip_id = self.verify_chip_id(0x0F, [0x69, 0x6A])
|
||||
if chip_id == 0x6A:
|
||||
self.source = log.SensorEventData.SensorSource.lsm6ds3trc
|
||||
else:
|
||||
self.source = log.SensorEventData.SensorSource.lsm6ds3
|
||||
|
||||
# self-test
|
||||
if os.getenv("LSM_SELF_TEST") == "1":
|
||||
self.self_test(self.LSM6DS3_ACCEL_POSITIVE_TEST)
|
||||
self.self_test(self.LSM6DS3_ACCEL_NEGATIVE_TEST)
|
||||
|
||||
# actual init
|
||||
int1 = self.read(self.LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, 1)[0]
|
||||
int1 |= self.LSM6DS3_ACCEL_INT1_DRDY_XL
|
||||
self.writes((
|
||||
# Enable continuous update and automatic address increment
|
||||
(self.LSM6DS3_ACCEL_I2C_REG_CTRL3_C, self.LSM6DS3_ACCEL_IF_INC),
|
||||
# Set ODR to 104 Hz, FS to ±2g (default)
|
||||
(self.LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, self.LSM6DS3_ACCEL_ODR_104HZ),
|
||||
# Configure data ready signal to pulse mode
|
||||
(self.LSM6DS3_ACCEL_I2C_REG_DRDY_CFG, self.LSM6DS3_ACCEL_DRDY_PULSE_MODE),
|
||||
# Enable data ready interrupt on INT1 without resetting existing interrupts
|
||||
(self.LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, int1),
|
||||
))
|
||||
|
||||
def get_event(self, ts: int | None = None) -> log.SensorEventData:
|
||||
assert ts is not None # must come from the IRQ event
|
||||
|
||||
# Check if data is ready since IRQ is shared with gyro
|
||||
status_reg = self.read(self.LSM6DS3_ACCEL_I2C_REG_STAT_REG, 1)[0]
|
||||
if (status_reg & self.LSM6DS3_ACCEL_DRDY_XLDA) == 0:
|
||||
raise self.DataNotReady
|
||||
|
||||
scale = 9.81 * 2.0 / (1 << 15)
|
||||
b = self.read(self.LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, 6)
|
||||
x = self.parse_16bit(b[0], b[1]) * scale
|
||||
y = self.parse_16bit(b[2], b[3]) * scale
|
||||
z = self.parse_16bit(b[4], b[5]) * scale
|
||||
|
||||
event = log.SensorEventData.new_message()
|
||||
event.timestamp = ts
|
||||
event.version = 1
|
||||
event.sensor = 1 # SENSOR_ACCELEROMETER
|
||||
event.type = 1 # SENSOR_TYPE_ACCELEROMETER
|
||||
event.source = self.source
|
||||
a = event.init('acceleration')
|
||||
a.v = [y, -x, z]
|
||||
a.status = 1
|
||||
return event
|
||||
|
||||
def shutdown(self) -> None:
|
||||
# Disable data ready interrupt on INT1
|
||||
value = self.read(self.LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, 1)[0]
|
||||
value &= ~self.LSM6DS3_ACCEL_INT1_DRDY_XL
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_INT1_CTRL, value)
|
||||
|
||||
# Power down by clearing ODR bits
|
||||
value = self.read(self.LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, 1)[0]
|
||||
value &= 0x0F
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, value)
|
||||
|
||||
# *** self-test stuff ***
|
||||
def _wait_for_data_ready(self):
|
||||
while True:
|
||||
drdy = self.read(self.LSM6DS3_ACCEL_I2C_REG_STAT_REG, 1)[0]
|
||||
if drdy & self.LSM6DS3_ACCEL_DRDY_XLDA:
|
||||
break
|
||||
|
||||
def _read_and_avg_data(self, scaling: float) -> list[float]:
|
||||
out_buf = [0.0, 0.0, 0.0]
|
||||
for _ in range(5):
|
||||
self._wait_for_data_ready()
|
||||
b = self.read(self.LSM6DS3_ACCEL_I2C_REG_OUTX_L_XL, 6)
|
||||
for j in range(3):
|
||||
val = self.parse_16bit(b[j*2], b[j*2+1]) * scaling
|
||||
out_buf[j] += val
|
||||
return [x / 5.0 for x in out_buf]
|
||||
|
||||
def self_test(self, test_type: int) -> None:
|
||||
# Prepare sensor for self-test
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_CTRL3_C, self.LSM6DS3_ACCEL_IF_INC_BDU)
|
||||
|
||||
# Configure ODR and full scale based on sensor type
|
||||
if self.source == log.SensorEventData.SensorSource.lsm6ds3trc:
|
||||
odr_fs = self.LSM6DS3_ACCEL_FS_4G | self.LSM6DS3_ACCEL_ODR_52HZ
|
||||
scaling = 0.122 # mg/LSB for ±4g
|
||||
else:
|
||||
odr_fs = self.LSM6DS3_ACCEL_ODR_52HZ
|
||||
scaling = 0.061 # mg/LSB for ±2g
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, odr_fs)
|
||||
|
||||
# Wait for stable output
|
||||
time.sleep(0.1)
|
||||
self._wait_for_data_ready()
|
||||
val_st_off = self._read_and_avg_data(scaling)
|
||||
|
||||
# Enable self-test
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_CTRL5_C, test_type)
|
||||
|
||||
# Wait for stable output
|
||||
time.sleep(0.1)
|
||||
self._wait_for_data_ready()
|
||||
val_st_on = self._read_and_avg_data(scaling)
|
||||
|
||||
# Disable sensor and self-test
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_CTRL1_XL, 0)
|
||||
self.write(self.LSM6DS3_ACCEL_I2C_REG_CTRL5_C, 0)
|
||||
|
||||
# Calculate differences and check limits
|
||||
test_val = [abs(on - off) for on, off in zip(val_st_on, val_st_off, strict=False)]
|
||||
for val in test_val:
|
||||
if val < self.LSM6DS3_ACCEL_MIN_ST_LIMIT_mg or val > self.LSM6DS3_ACCEL_MAX_ST_LIMIT_mg:
|
||||
raise self.SensorException(f"Accelerometer self-test failed for test type {test_type}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import numpy as np
|
||||
s = LSM6DS3_Accel(1)
|
||||
s.init()
|
||||
time.sleep(0.2)
|
||||
e = s.get_event(0)
|
||||
print(e)
|
||||
print(np.linalg.norm(e.acceleration.v))
|
||||
s.shutdown()
|
||||
@@ -1,233 +0,0 @@
|
||||
#include "system/sensord/sensors/lsm6ds3_gyro.h"
|
||||
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
|
||||
#define DEG2RAD(x) ((x) * M_PI / 180.0)
|
||||
|
||||
LSM6DS3_Gyro::LSM6DS3_Gyro(I2CBus *bus, int gpio_nr, bool shared_gpio) :
|
||||
I2CSensor(bus, gpio_nr, shared_gpio) {}
|
||||
|
||||
void LSM6DS3_Gyro::wait_for_data_ready() {
|
||||
uint8_t drdy = 0;
|
||||
uint8_t buffer[6];
|
||||
|
||||
do {
|
||||
read_register(LSM6DS3_GYRO_I2C_REG_STAT_REG, &drdy, sizeof(drdy));
|
||||
drdy &= LSM6DS3_GYRO_DRDY_GDA;
|
||||
} while (drdy == 0);
|
||||
|
||||
read_register(LSM6DS3_GYRO_I2C_REG_OUTX_L_G, buffer, sizeof(buffer));
|
||||
}
|
||||
|
||||
void LSM6DS3_Gyro::read_and_avg_data(float* out_buf) {
|
||||
uint8_t drdy = 0;
|
||||
uint8_t buffer[6];
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
do {
|
||||
read_register(LSM6DS3_GYRO_I2C_REG_STAT_REG, &drdy, sizeof(drdy));
|
||||
drdy &= LSM6DS3_GYRO_DRDY_GDA;
|
||||
} while (drdy == 0);
|
||||
|
||||
int len = read_register(LSM6DS3_GYRO_I2C_REG_OUTX_L_G, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
for (int j = 0; j < 3; j++) {
|
||||
out_buf[j] += (float)read_16_bit(buffer[j*2], buffer[j*2+1]) * 70.0f;
|
||||
}
|
||||
}
|
||||
|
||||
// calculate the mg average values
|
||||
for (int i = 0; i < 3; i++) {
|
||||
out_buf[i] /= 5.0f;
|
||||
}
|
||||
}
|
||||
|
||||
int LSM6DS3_Gyro::self_test(int test_type) {
|
||||
float val_st_off[3] = {0};
|
||||
float val_st_on[3] = {0};
|
||||
float test_val[3] = {0};
|
||||
|
||||
// prepare sensor for self-test
|
||||
|
||||
// full scale: 2000dps, ODR: 208Hz
|
||||
int ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, LSM6DS3_GYRO_ODR_208HZ | LSM6DS3_GYRO_FS_2000dps);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// wait for stable output, and discard first values
|
||||
util::sleep_for(150);
|
||||
wait_for_data_ready();
|
||||
read_and_avg_data(val_st_off);
|
||||
|
||||
// enable Self Test positive (or negative)
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL5_C, test_type);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// wait for stable output, and discard first values
|
||||
util::sleep_for(50);
|
||||
wait_for_data_ready();
|
||||
read_and_avg_data(val_st_on);
|
||||
|
||||
// disable sensor
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, 0);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// disable self test
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL5_C, 0);
|
||||
if (ret < 0) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
// calculate the mg values for self test
|
||||
for (int i = 0; i < 3; i++) {
|
||||
test_val[i] = fabs(val_st_on[i] - val_st_off[i]);
|
||||
}
|
||||
|
||||
// verify test result
|
||||
for (int i = 0; i < 3; i++) {
|
||||
if ((LSM6DS3_GYRO_MIN_ST_LIMIT_mdps > test_val[i]) ||
|
||||
(test_val[i] > LSM6DS3_GYRO_MAX_ST_LIMIT_mdps)) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
int LSM6DS3_Gyro::init() {
|
||||
uint8_t value = 0;
|
||||
bool do_self_test = false;
|
||||
|
||||
const char* env_lsm_selftest = std::getenv("LSM_SELF_TEST");
|
||||
if (env_lsm_selftest != nullptr && strncmp(env_lsm_selftest, "1", 1) == 0) {
|
||||
do_self_test = true;
|
||||
}
|
||||
|
||||
int ret = verify_chip_id(LSM6DS3_GYRO_I2C_REG_ID, {LSM6DS3_GYRO_CHIP_ID, LSM6DS3TRC_GYRO_CHIP_ID});
|
||||
if (ret == -1) return -1;
|
||||
|
||||
if (ret == LSM6DS3TRC_GYRO_CHIP_ID) {
|
||||
source = cereal::SensorEventData::SensorSource::LSM6DS3TRC;
|
||||
}
|
||||
|
||||
ret = init_gpio();
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
ret = self_test(LSM6DS3_GYRO_POSITIVE_TEST);
|
||||
if (ret < 0) {
|
||||
LOGE("LSM6DS3 gyro positive self-test failed!");
|
||||
if (do_self_test) goto fail;
|
||||
}
|
||||
|
||||
ret = self_test(LSM6DS3_GYRO_NEGATIVE_TEST);
|
||||
if (ret < 0) {
|
||||
LOGE("LSM6DS3 gyro negative self-test failed!");
|
||||
if (do_self_test) goto fail;
|
||||
}
|
||||
|
||||
// TODO: set scale. Default is +- 250 deg/s
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, LSM6DS3_GYRO_ODR_104HZ);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_DRDY_CFG, LSM6DS3_GYRO_DRDY_PULSE_MODE);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// enable data ready interrupt for gyro on INT1
|
||||
// (without resetting existing interrupts)
|
||||
ret = read_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value |= LSM6DS3_GYRO_INT1_DRDY_G;
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, value);
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
int LSM6DS3_Gyro::shutdown() {
|
||||
int ret = 0;
|
||||
|
||||
// disable data ready interrupt for gyro on INT1
|
||||
uint8_t value = 0;
|
||||
ret = read_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value &= ~(LSM6DS3_GYRO_INT1_DRDY_G);
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_INT1_CTRL, value);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not disable lsm6ds3 gyroscope interrupt!");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// enable power-down mode
|
||||
value = 0;
|
||||
ret = read_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value &= 0x0F;
|
||||
ret = set_register(LSM6DS3_GYRO_I2C_REG_CTRL2_G, value);
|
||||
if (ret < 0) {
|
||||
LOGE("Could not power-down lsm6ds3 gyroscope!");
|
||||
goto fail;
|
||||
}
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
bool LSM6DS3_Gyro::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
|
||||
// INT1 shared with accel, check STATUS_REG who triggered
|
||||
uint8_t status_reg = 0;
|
||||
read_register(LSM6DS3_GYRO_I2C_REG_STAT_REG, &status_reg, sizeof(status_reg));
|
||||
if ((status_reg & LSM6DS3_GYRO_DRDY_GDA) == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t buffer[6];
|
||||
int len = read_register(LSM6DS3_GYRO_I2C_REG_OUTX_L_G, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
float scale = 8.75 / 1000.0;
|
||||
float x = DEG2RAD(read_16_bit(buffer[0], buffer[1]) * scale);
|
||||
float y = DEG2RAD(read_16_bit(buffer[2], buffer[3]) * scale);
|
||||
float z = DEG2RAD(read_16_bit(buffer[4], buffer[5]) * scale);
|
||||
|
||||
auto event = msg.initEvent().initGyroscope();
|
||||
event.setSource(source);
|
||||
event.setVersion(2);
|
||||
event.setSensor(SENSOR_GYRO_UNCALIBRATED);
|
||||
event.setType(SENSOR_TYPE_GYROSCOPE_UNCALIBRATED);
|
||||
event.setTimestamp(ts);
|
||||
|
||||
float xyz[] = {y, -x, z};
|
||||
auto svec = event.initGyroUncalibrated();
|
||||
svec.setV(xyz);
|
||||
svec.setStatus(true);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define LSM6DS3_GYRO_I2C_ADDR 0x6A
|
||||
|
||||
// Registers of the chip
|
||||
#define LSM6DS3_GYRO_I2C_REG_DRDY_CFG 0x0B
|
||||
#define LSM6DS3_GYRO_I2C_REG_ID 0x0F
|
||||
#define LSM6DS3_GYRO_I2C_REG_INT1_CTRL 0x0D
|
||||
#define LSM6DS3_GYRO_I2C_REG_CTRL2_G 0x11
|
||||
#define LSM6DS3_GYRO_I2C_REG_CTRL5_C 0x14
|
||||
#define LSM6DS3_GYRO_I2C_REG_STAT_REG 0x1E
|
||||
#define LSM6DS3_GYRO_I2C_REG_OUTX_L_G 0x22
|
||||
#define LSM6DS3_GYRO_POSITIVE_TEST (0b01 << 2)
|
||||
#define LSM6DS3_GYRO_NEGATIVE_TEST (0b11 << 2)
|
||||
|
||||
// Constants
|
||||
#define LSM6DS3_GYRO_CHIP_ID 0x69
|
||||
#define LSM6DS3TRC_GYRO_CHIP_ID 0x6A
|
||||
#define LSM6DS3_GYRO_FS_2000dps (0b11 << 2)
|
||||
#define LSM6DS3_GYRO_ODR_104HZ (0b0100 << 4)
|
||||
#define LSM6DS3_GYRO_ODR_208HZ (0b0101 << 4)
|
||||
#define LSM6DS3_GYRO_INT1_DRDY_G 0b10
|
||||
#define LSM6DS3_GYRO_DRDY_GDA 0b10
|
||||
#define LSM6DS3_GYRO_DRDY_PULSE_MODE (1 << 7)
|
||||
#define LSM6DS3_GYRO_MIN_ST_LIMIT_mdps 150000.0f
|
||||
#define LSM6DS3_GYRO_MAX_ST_LIMIT_mdps 700000.0f
|
||||
|
||||
|
||||
class LSM6DS3_Gyro : public I2CSensor {
|
||||
uint8_t get_device_address() {return LSM6DS3_GYRO_I2C_ADDR;}
|
||||
cereal::SensorEventData::SensorSource source = cereal::SensorEventData::SensorSource::LSM6DS3;
|
||||
|
||||
// self test functions
|
||||
int self_test(int test_type);
|
||||
void wait_for_data_ready();
|
||||
void read_and_avg_data(float* val_st_off);
|
||||
public:
|
||||
LSM6DS3_Gyro(I2CBus *bus, int gpio_nr = 0, bool shared_gpio = false);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown();
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import os
|
||||
import math
|
||||
import time
|
||||
|
||||
from cereal import log
|
||||
from openpilot.system.sensord.sensors.i2c_sensor import Sensor
|
||||
|
||||
class LSM6DS3_Gyro(Sensor):
|
||||
LSM6DS3_GYRO_I2C_REG_DRDY_CFG = 0x0B
|
||||
LSM6DS3_GYRO_I2C_REG_INT1_CTRL = 0x0D
|
||||
LSM6DS3_GYRO_I2C_REG_CTRL2_G = 0x11
|
||||
LSM6DS3_GYRO_I2C_REG_CTRL5_C = 0x14
|
||||
LSM6DS3_GYRO_I2C_REG_STAT_REG = 0x1E
|
||||
LSM6DS3_GYRO_I2C_REG_OUTX_L_G = 0x22
|
||||
|
||||
LSM6DS3_GYRO_ODR_104HZ = (0b0100 << 4)
|
||||
LSM6DS3_GYRO_INT1_DRDY_G = 0b10
|
||||
LSM6DS3_GYRO_DRDY_GDA = 0b10
|
||||
LSM6DS3_GYRO_DRDY_PULSE_MODE = (1 << 7)
|
||||
|
||||
LSM6DS3_GYRO_ODR_208HZ = (0b0101 << 4)
|
||||
LSM6DS3_GYRO_FS_2000dps = (0b11 << 2)
|
||||
LSM6DS3_GYRO_POSITIVE_TEST = (0b01 << 2)
|
||||
LSM6DS3_GYRO_NEGATIVE_TEST = (0b11 << 2)
|
||||
LSM6DS3_GYRO_MIN_ST_LIMIT_mdps = 150000.0
|
||||
LSM6DS3_GYRO_MAX_ST_LIMIT_mdps = 700000.0
|
||||
|
||||
@property
|
||||
def device_address(self) -> int:
|
||||
return 0x6A
|
||||
|
||||
def init(self):
|
||||
chip_id = self.verify_chip_id(0x0F, [0x69, 0x6A])
|
||||
if chip_id == 0x6A:
|
||||
self.source = log.SensorEventData.SensorSource.lsm6ds3trc
|
||||
else:
|
||||
self.source = log.SensorEventData.SensorSource.lsm6ds3
|
||||
|
||||
# self-test
|
||||
if "LSM_SELF_TEST" in os.environ:
|
||||
self.self_test(self.LSM6DS3_GYRO_POSITIVE_TEST)
|
||||
self.self_test(self.LSM6DS3_GYRO_NEGATIVE_TEST)
|
||||
|
||||
# actual init
|
||||
self.writes((
|
||||
# TODO: set scale. Default is +- 250 deg/s
|
||||
(self.LSM6DS3_GYRO_I2C_REG_CTRL2_G, self.LSM6DS3_GYRO_ODR_104HZ),
|
||||
# Configure data ready signal to pulse mode
|
||||
(self.LSM6DS3_GYRO_I2C_REG_DRDY_CFG, self.LSM6DS3_GYRO_DRDY_PULSE_MODE),
|
||||
))
|
||||
value = self.read(self.LSM6DS3_GYRO_I2C_REG_INT1_CTRL, 1)[0]
|
||||
value |= self.LSM6DS3_GYRO_INT1_DRDY_G
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_INT1_CTRL, value)
|
||||
|
||||
def get_event(self, ts: int | None = None) -> log.SensorEventData:
|
||||
assert ts is not None # must come from the IRQ event
|
||||
|
||||
# Check if gyroscope data is ready, since it's shared with accelerometer
|
||||
status_reg = self.read(self.LSM6DS3_GYRO_I2C_REG_STAT_REG, 1)[0]
|
||||
if not (status_reg & self.LSM6DS3_GYRO_DRDY_GDA):
|
||||
raise self.DataNotReady
|
||||
|
||||
b = self.read(self.LSM6DS3_GYRO_I2C_REG_OUTX_L_G, 6)
|
||||
x = self.parse_16bit(b[0], b[1])
|
||||
y = self.parse_16bit(b[2], b[3])
|
||||
z = self.parse_16bit(b[4], b[5])
|
||||
scale = (8.75 / 1000.0) * (math.pi / 180.0)
|
||||
xyz = [y * scale, -x * scale, z * scale]
|
||||
|
||||
event = log.SensorEventData.new_message()
|
||||
event.timestamp = ts
|
||||
event.version = 2
|
||||
event.sensor = 5 # SENSOR_GYRO_UNCALIBRATED
|
||||
event.type = 16 # SENSOR_TYPE_GYROSCOPE_UNCALIBRATED
|
||||
event.source = self.source
|
||||
g = event.init('gyroUncalibrated')
|
||||
g.v = xyz
|
||||
g.status = 1
|
||||
return event
|
||||
|
||||
def shutdown(self) -> None:
|
||||
# Disable data ready interrupt on INT1
|
||||
value = self.read(self.LSM6DS3_GYRO_I2C_REG_INT1_CTRL, 1)[0]
|
||||
value &= ~self.LSM6DS3_GYRO_INT1_DRDY_G
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_INT1_CTRL, value)
|
||||
|
||||
# Power down by clearing ODR bits
|
||||
value = self.read(self.LSM6DS3_GYRO_I2C_REG_CTRL2_G, 1)[0]
|
||||
value &= 0x0F
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_CTRL2_G, value)
|
||||
|
||||
# *** self-test stuff ***
|
||||
def _wait_for_data_ready(self):
|
||||
while True:
|
||||
drdy = self.read(self.LSM6DS3_GYRO_I2C_REG_STAT_REG, 1)[0]
|
||||
if drdy & self.LSM6DS3_GYRO_DRDY_GDA:
|
||||
break
|
||||
|
||||
def _read_and_avg_data(self) -> list[float]:
|
||||
out_buf = [0.0, 0.0, 0.0]
|
||||
for _ in range(5):
|
||||
self._wait_for_data_ready()
|
||||
b = self.read(self.LSM6DS3_GYRO_I2C_REG_OUTX_L_G, 6)
|
||||
for j in range(3):
|
||||
val = self.parse_16bit(b[j*2], b[j*2+1]) * 70.0 # mdps/LSB for 2000 dps
|
||||
out_buf[j] += val
|
||||
return [x / 5.0 for x in out_buf]
|
||||
|
||||
def self_test(self, test_type: int):
|
||||
# Set ODR to 208Hz, FS to 2000dps
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_CTRL2_G, self.LSM6DS3_GYRO_ODR_208HZ | self.LSM6DS3_GYRO_FS_2000dps)
|
||||
|
||||
# Wait for stable output
|
||||
time.sleep(0.15)
|
||||
self._wait_for_data_ready()
|
||||
val_st_off = self._read_and_avg_data()
|
||||
|
||||
# Enable self-test
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_CTRL5_C, test_type)
|
||||
|
||||
# Wait for stable output
|
||||
time.sleep(0.05)
|
||||
self._wait_for_data_ready()
|
||||
val_st_on = self._read_and_avg_data()
|
||||
|
||||
# Disable sensor and self-test
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_CTRL2_G, 0)
|
||||
self.write(self.LSM6DS3_GYRO_I2C_REG_CTRL5_C, 0)
|
||||
|
||||
# Calculate differences and check limits
|
||||
test_val = [abs(on - off) for on, off in zip(val_st_on, val_st_off, strict=False)]
|
||||
for val in test_val:
|
||||
if val < self.LSM6DS3_GYRO_MIN_ST_LIMIT_mdps or val > self.LSM6DS3_GYRO_MAX_ST_LIMIT_mdps:
|
||||
raise Exception(f"Gyroscope self-test failed for test type {test_type}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
s = LSM6DS3_Gyro(1)
|
||||
s.init()
|
||||
time.sleep(0.1)
|
||||
print(s.get_event(0))
|
||||
s.shutdown()
|
||||
@@ -1,37 +0,0 @@
|
||||
#include "system/sensord/sensors/lsm6ds3_temp.h"
|
||||
|
||||
#include <cassert>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
|
||||
LSM6DS3_Temp::LSM6DS3_Temp(I2CBus *bus) : I2CSensor(bus) {}
|
||||
|
||||
int LSM6DS3_Temp::init() {
|
||||
int ret = verify_chip_id(LSM6DS3_TEMP_I2C_REG_ID, {LSM6DS3_TEMP_CHIP_ID, LSM6DS3TRC_TEMP_CHIP_ID});
|
||||
if (ret == -1) return -1;
|
||||
|
||||
if (ret == LSM6DS3TRC_TEMP_CHIP_ID) {
|
||||
source = cereal::SensorEventData::SensorSource::LSM6DS3TRC;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool LSM6DS3_Temp::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
uint64_t start_time = nanos_since_boot();
|
||||
uint8_t buffer[2];
|
||||
int len = read_register(LSM6DS3_TEMP_I2C_REG_OUT_TEMP_L, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
|
||||
float scale = (source == cereal::SensorEventData::SensorSource::LSM6DS3TRC) ? 256.0f : 16.0f;
|
||||
float temp = 25.0f + read_16_bit(buffer[0], buffer[1]) / scale;
|
||||
|
||||
auto event = msg.initEvent().initTemperatureSensor();
|
||||
event.setSource(source);
|
||||
event.setVersion(1);
|
||||
event.setType(SENSOR_TYPE_AMBIENT_TEMPERATURE);
|
||||
event.setTimestamp(start_time);
|
||||
event.setTemperature(temp);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define LSM6DS3_TEMP_I2C_ADDR 0x6A
|
||||
|
||||
// Registers of the chip
|
||||
#define LSM6DS3_TEMP_I2C_REG_ID 0x0F
|
||||
#define LSM6DS3_TEMP_I2C_REG_OUT_TEMP_L 0x20
|
||||
|
||||
// Constants
|
||||
#define LSM6DS3_TEMP_CHIP_ID 0x69
|
||||
#define LSM6DS3TRC_TEMP_CHIP_ID 0x6A
|
||||
|
||||
|
||||
class LSM6DS3_Temp : public I2CSensor {
|
||||
uint8_t get_device_address() {return LSM6DS3_TEMP_I2C_ADDR;}
|
||||
cereal::SensorEventData::SensorSource source = cereal::SensorEventData::SensorSource::LSM6DS3;
|
||||
|
||||
public:
|
||||
LSM6DS3_Temp(I2CBus *bus);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown() { return 0; }
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import time
|
||||
|
||||
from cereal import log
|
||||
from openpilot.system.sensord.sensors.i2c_sensor import Sensor
|
||||
|
||||
# https://content.arduino.cc/assets/st_imu_lsm6ds3_datasheet.pdf
|
||||
class LSM6DS3_Temp(Sensor):
|
||||
@property
|
||||
def device_address(self) -> int:
|
||||
return 0x6A # Default I2C address for LSM6DS3
|
||||
|
||||
def _read_temperature(self) -> float:
|
||||
scale = 16.0 if log.SensorEventData.SensorSource.lsm6ds3 else 256.0
|
||||
data = self.read(0x20, 2)
|
||||
return 25 + (self.parse_16bit(data[0], data[1]) / scale)
|
||||
|
||||
def init(self):
|
||||
chip_id = self.verify_chip_id(0x0F, [0x69, 0x6A])
|
||||
if chip_id == 0x6A:
|
||||
self.source = log.SensorEventData.SensorSource.lsm6ds3trc
|
||||
else:
|
||||
self.source = log.SensorEventData.SensorSource.lsm6ds3
|
||||
|
||||
def get_event(self, ts: int | None = None) -> log.SensorEventData:
|
||||
event = log.SensorEventData.new_message()
|
||||
event.version = 1
|
||||
event.timestamp = int(time.monotonic() * 1e9)
|
||||
event.source = self.source
|
||||
event.temperature = self._read_temperature()
|
||||
return event
|
||||
|
||||
def shutdown(self) -> None:
|
||||
pass
|
||||
@@ -1,108 +0,0 @@
|
||||
#include "system/sensord/sensors/mmc5603nj_magn.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <vector>
|
||||
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
|
||||
MMC5603NJ_Magn::MMC5603NJ_Magn(I2CBus *bus) : I2CSensor(bus) {}
|
||||
|
||||
int MMC5603NJ_Magn::init() {
|
||||
int ret = verify_chip_id(MMC5603NJ_I2C_REG_ID, {MMC5603NJ_CHIP_ID});
|
||||
if (ret == -1) return -1;
|
||||
|
||||
// Set ODR to 0
|
||||
ret = set_register(MMC5603NJ_I2C_REG_ODR, 0);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// Set BW to 0b01 for 1-150 Hz operation
|
||||
ret = set_register(MMC5603NJ_I2C_REG_INTERNAL_1, 0b01);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
fail:
|
||||
return ret;
|
||||
}
|
||||
|
||||
int MMC5603NJ_Magn::shutdown() {
|
||||
int ret = 0;
|
||||
|
||||
// disable auto reset of measurements
|
||||
uint8_t value = 0;
|
||||
ret = read_register(MMC5603NJ_I2C_REG_INTERNAL_0, &value, 1);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
value &= ~(MMC5603NJ_CMM_FREQ_EN | MMC5603NJ_AUTO_SR_EN);
|
||||
ret = set_register(MMC5603NJ_I2C_REG_INTERNAL_0, value);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
|
||||
// set ODR to 0 to leave continuous mode
|
||||
ret = set_register(MMC5603NJ_I2C_REG_ODR, 0);
|
||||
if (ret < 0) {
|
||||
goto fail;
|
||||
}
|
||||
return ret;
|
||||
|
||||
fail:
|
||||
LOGE("Could not disable mmc5603nj auto set reset");
|
||||
return ret;
|
||||
}
|
||||
|
||||
void MMC5603NJ_Magn::start_measurement() {
|
||||
set_register(MMC5603NJ_I2C_REG_INTERNAL_0, 0b01);
|
||||
util::sleep_for(5);
|
||||
}
|
||||
|
||||
std::vector<float> MMC5603NJ_Magn::read_measurement() {
|
||||
int len;
|
||||
uint8_t buffer[9];
|
||||
len = read_register(MMC5603NJ_I2C_REG_XOUT0, buffer, sizeof(buffer));
|
||||
assert(len == sizeof(buffer));
|
||||
float scale = 1.0 / 16384.0;
|
||||
float x = (read_20_bit(buffer[6], buffer[1], buffer[0]) * scale) - 32.0;
|
||||
float y = (read_20_bit(buffer[7], buffer[3], buffer[2]) * scale) - 32.0;
|
||||
float z = (read_20_bit(buffer[8], buffer[5], buffer[4]) * scale) - 32.0;
|
||||
std::vector<float> xyz = {x, y, z};
|
||||
return xyz;
|
||||
}
|
||||
|
||||
bool MMC5603NJ_Magn::get_event(MessageBuilder &msg, uint64_t ts) {
|
||||
uint64_t start_time = nanos_since_boot();
|
||||
// SET - RESET cycle
|
||||
set_register(MMC5603NJ_I2C_REG_INTERNAL_0, MMC5603NJ_SET);
|
||||
util::sleep_for(5);
|
||||
MMC5603NJ_Magn::start_measurement();
|
||||
std::vector<float> xyz = MMC5603NJ_Magn::read_measurement();
|
||||
|
||||
set_register(MMC5603NJ_I2C_REG_INTERNAL_0, MMC5603NJ_RESET);
|
||||
util::sleep_for(5);
|
||||
MMC5603NJ_Magn::start_measurement();
|
||||
std::vector<float> reset_xyz = MMC5603NJ_Magn::read_measurement();
|
||||
|
||||
auto event = msg.initEvent().initMagnetometer();
|
||||
event.setSource(cereal::SensorEventData::SensorSource::MMC5603NJ);
|
||||
event.setVersion(1);
|
||||
event.setSensor(SENSOR_MAGNETOMETER_UNCALIBRATED);
|
||||
event.setType(SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED);
|
||||
event.setTimestamp(start_time);
|
||||
|
||||
float vals[] = {xyz[0], xyz[1], xyz[2], reset_xyz[0], reset_xyz[1], reset_xyz[2]};
|
||||
bool valid = true;
|
||||
if (std::any_of(std::begin(vals), std::end(vals), [](float val) { return val == -32.0; })) {
|
||||
valid = false;
|
||||
}
|
||||
auto svec = event.initMagneticUncalibrated();
|
||||
svec.setV(vals);
|
||||
svec.setStatus(valid);
|
||||
return true;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "system/sensord/sensors/i2c_sensor.h"
|
||||
|
||||
// Address of the chip on the bus
|
||||
#define MMC5603NJ_I2C_ADDR 0x30
|
||||
|
||||
// Registers of the chip
|
||||
#define MMC5603NJ_I2C_REG_XOUT0 0x00
|
||||
#define MMC5603NJ_I2C_REG_ODR 0x1A
|
||||
#define MMC5603NJ_I2C_REG_INTERNAL_0 0x1B
|
||||
#define MMC5603NJ_I2C_REG_INTERNAL_1 0x1C
|
||||
#define MMC5603NJ_I2C_REG_INTERNAL_2 0x1D
|
||||
#define MMC5603NJ_I2C_REG_ID 0x39
|
||||
|
||||
// Constants
|
||||
#define MMC5603NJ_CHIP_ID 0x10
|
||||
#define MMC5603NJ_CMM_FREQ_EN (1 << 7)
|
||||
#define MMC5603NJ_AUTO_SR_EN (1 << 5)
|
||||
#define MMC5603NJ_CMM_EN (1 << 4)
|
||||
#define MMC5603NJ_EN_PRD_SET (1 << 3)
|
||||
#define MMC5603NJ_SET (1 << 3)
|
||||
#define MMC5603NJ_RESET (1 << 4)
|
||||
|
||||
class MMC5603NJ_Magn : public I2CSensor {
|
||||
private:
|
||||
uint8_t get_device_address() {return MMC5603NJ_I2C_ADDR;}
|
||||
void start_measurement();
|
||||
std::vector<float> read_measurement();
|
||||
public:
|
||||
MMC5603NJ_Magn(I2CBus *bus);
|
||||
int init();
|
||||
bool get_event(MessageBuilder &msg, uint64_t ts = 0);
|
||||
int shutdown();
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import time
|
||||
|
||||
from cereal import log
|
||||
from openpilot.system.sensord.sensors.i2c_sensor import Sensor
|
||||
|
||||
# https://www.mouser.com/datasheet/2/821/Memsic_09102019_Datasheet_Rev.B-1635324.pdf
|
||||
|
||||
# Register addresses
|
||||
REG_ODR = 0x1A
|
||||
REG_INTERNAL_0 = 0x1B
|
||||
REG_INTERNAL_1 = 0x1C
|
||||
|
||||
# Control register settings
|
||||
CMM_FREQ_EN = (1 << 7)
|
||||
AUTO_SR_EN = (1 << 5)
|
||||
SET = (1 << 3)
|
||||
RESET = (1 << 4)
|
||||
|
||||
class MMC5603NJ_Magn(Sensor):
|
||||
@property
|
||||
def device_address(self) -> int:
|
||||
return 0x30
|
||||
|
||||
def init(self):
|
||||
self.verify_chip_id(0x39, [0x10, ])
|
||||
self.writes((
|
||||
(REG_ODR, 0),
|
||||
|
||||
# Set BW to 0b01 for 1-150 Hz operation
|
||||
(REG_INTERNAL_1, 0b01),
|
||||
))
|
||||
|
||||
def _read_data(self, cycle) -> list[float]:
|
||||
# start measurement
|
||||
self.write(REG_INTERNAL_0, cycle)
|
||||
self.wait()
|
||||
|
||||
# read out XYZ
|
||||
scale = 1.0 / 16384.0
|
||||
b = self.read(0x00, 9)
|
||||
return [
|
||||
(self.parse_20bit(b[6], b[1], b[0]) * scale) - 32.0,
|
||||
(self.parse_20bit(b[7], b[3], b[2]) * scale) - 32.0,
|
||||
(self.parse_20bit(b[8], b[5], b[4]) * scale) - 32.0,
|
||||
]
|
||||
|
||||
def get_event(self, ts: int | None = None) -> log.SensorEventData:
|
||||
ts = time.monotonic_ns()
|
||||
|
||||
# SET - RESET cycle
|
||||
xyz = self._read_data(SET)
|
||||
reset_xyz = self._read_data(RESET)
|
||||
vals = [*xyz, *reset_xyz]
|
||||
|
||||
event = log.SensorEventData.new_message()
|
||||
event.timestamp = ts
|
||||
event.version = 1
|
||||
event.sensor = 3 # SENSOR_MAGNETOMETER_UNCALIBRATED
|
||||
event.type = 14 # SENSOR_TYPE_MAGNETIC_FIELD_UNCALIBRATED
|
||||
event.source = log.SensorEventData.SensorSource.mmc5603nj
|
||||
|
||||
m = event.init('magneticUncalibrated')
|
||||
m.v = vals
|
||||
m.status = int(all(int(v) != -32 for v in vals))
|
||||
|
||||
return event
|
||||
|
||||
def shutdown(self) -> None:
|
||||
v = self.read(REG_INTERNAL_0, 1)[0]
|
||||
self.writes((
|
||||
# disable auto-reset of measurements
|
||||
(REG_INTERNAL_0, (v & (~(CMM_FREQ_EN | AUTO_SR_EN)))),
|
||||
|
||||
# disable continuous mode
|
||||
(REG_ODR, 0),
|
||||
))
|
||||
@@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "cereal/messaging/messaging.h"
|
||||
|
||||
class Sensor {
|
||||
public:
|
||||
int gpio_fd = -1;
|
||||
bool enabled = false;
|
||||
uint64_t start_ts = 0;
|
||||
uint64_t init_delay = 500e6; // default dealy 500ms
|
||||
|
||||
virtual ~Sensor() {}
|
||||
virtual int init() = 0;
|
||||
virtual bool get_event(MessageBuilder &msg, uint64_t ts = 0) = 0;
|
||||
virtual bool has_interrupt_enabled() = 0;
|
||||
virtual int shutdown() = 0;
|
||||
|
||||
virtual bool is_data_valid(uint64_t current_ts) {
|
||||
if (start_ts == 0) {
|
||||
start_ts = current_ts;
|
||||
}
|
||||
return (current_ts - start_ts) > init_delay;
|
||||
}
|
||||
};
|
||||
@@ -1,179 +0,0 @@
|
||||
#include <sys/resource.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <poll.h>
|
||||
#include <linux/gpio.h>
|
||||
|
||||
#include "cereal/services.h"
|
||||
#include "cereal/messaging/messaging.h"
|
||||
#include "common/i2c.h"
|
||||
#include "common/ratekeeper.h"
|
||||
#include "common/swaglog.h"
|
||||
#include "common/timing.h"
|
||||
#include "common/util.h"
|
||||
#include "system/sensord/sensors/bmx055_accel.h"
|
||||
#include "system/sensord/sensors/bmx055_gyro.h"
|
||||
#include "system/sensord/sensors/bmx055_magn.h"
|
||||
#include "system/sensord/sensors/bmx055_temp.h"
|
||||
#include "system/sensord/sensors/constants.h"
|
||||
#include "system/sensord/sensors/lsm6ds3_accel.h"
|
||||
#include "system/sensord/sensors/lsm6ds3_gyro.h"
|
||||
#include "system/sensord/sensors/lsm6ds3_temp.h"
|
||||
#include "system/sensord/sensors/mmc5603nj_magn.h"
|
||||
|
||||
#define I2C_BUS_IMU 1
|
||||
|
||||
ExitHandler do_exit;
|
||||
|
||||
void interrupt_loop(std::vector<std::tuple<Sensor *, std::string>> sensors) {
|
||||
PubMaster pm({"gyroscope", "accelerometer"});
|
||||
|
||||
int fd = -1;
|
||||
for (auto &[sensor, msg_name] : sensors) {
|
||||
if (sensor->has_interrupt_enabled()) {
|
||||
fd = sensor->gpio_fd;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
uint64_t offset = nanos_since_epoch() - nanos_since_boot();
|
||||
struct pollfd fd_list[1] = {0};
|
||||
fd_list[0].fd = fd;
|
||||
fd_list[0].events = POLLIN | POLLPRI;
|
||||
|
||||
while (!do_exit) {
|
||||
int err = poll(fd_list, 1, 100);
|
||||
if (err == -1) {
|
||||
if (errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
return;
|
||||
} else if (err == 0) {
|
||||
LOGE("poll timed out");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((fd_list[0].revents & (POLLIN | POLLPRI)) == 0) {
|
||||
LOGE("no poll events set");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read all events
|
||||
struct gpioevent_data evdata[16];
|
||||
err = HANDLE_EINTR(read(fd, evdata, sizeof(evdata)));
|
||||
if (err < 0 || err % sizeof(*evdata) != 0) {
|
||||
LOGE("error reading event data %d", err);
|
||||
continue;
|
||||
}
|
||||
|
||||
uint64_t cur_offset = nanos_since_epoch() - nanos_since_boot();
|
||||
uint64_t diff = cur_offset > offset ? cur_offset - offset : offset - cur_offset;
|
||||
if (diff > 10*1e6) { // 10ms
|
||||
LOGW("time jumped: %lu %lu", cur_offset, offset);
|
||||
offset = cur_offset;
|
||||
|
||||
// we don't have a valid timestamp since the
|
||||
// time jumped, so throw out this measurement.
|
||||
continue;
|
||||
}
|
||||
|
||||
int num_events = err / sizeof(*evdata);
|
||||
uint64_t ts = evdata[num_events - 1].timestamp - cur_offset;
|
||||
|
||||
for (auto &[sensor, msg_name] : sensors) {
|
||||
if (!sensor->has_interrupt_enabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
MessageBuilder msg;
|
||||
if (!sensor->get_event(msg, ts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sensor->is_data_valid(ts)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pm.send(msg_name.c_str(), msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void polling_loop(Sensor *sensor, std::string msg_name) {
|
||||
PubMaster pm({msg_name.c_str()});
|
||||
RateKeeper rk(msg_name, services.at(msg_name).frequency);
|
||||
while (!do_exit) {
|
||||
MessageBuilder msg;
|
||||
if (sensor->get_event(msg) && sensor->is_data_valid(nanos_since_boot())) {
|
||||
pm.send(msg_name.c_str(), msg);
|
||||
}
|
||||
rk.keepTime();
|
||||
}
|
||||
}
|
||||
|
||||
int sensor_loop(I2CBus *i2c_bus_imu) {
|
||||
// Sensor init
|
||||
std::vector<std::tuple<Sensor *, std::string>> sensors_init = {
|
||||
{new BMX055_Accel(i2c_bus_imu), "accelerometer2"},
|
||||
{new BMX055_Gyro(i2c_bus_imu), "gyroscope2"},
|
||||
{new BMX055_Magn(i2c_bus_imu), "magnetometer"},
|
||||
{new BMX055_Temp(i2c_bus_imu), "temperatureSensor2"},
|
||||
|
||||
{new LSM6DS3_Accel(i2c_bus_imu, GPIO_LSM_INT), "accelerometer"},
|
||||
{new LSM6DS3_Gyro(i2c_bus_imu, GPIO_LSM_INT, true), "gyroscope"},
|
||||
{new LSM6DS3_Temp(i2c_bus_imu), "temperatureSensor"},
|
||||
|
||||
{new MMC5603NJ_Magn(i2c_bus_imu), "magnetometer"},
|
||||
};
|
||||
|
||||
// Initialize sensors
|
||||
std::vector<std::thread> threads;
|
||||
for (auto &[sensor, msg_name] : sensors_init) {
|
||||
int err = sensor->init();
|
||||
if (err < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sensor->has_interrupt_enabled()) {
|
||||
threads.emplace_back(polling_loop, sensor, msg_name);
|
||||
}
|
||||
}
|
||||
|
||||
// increase interrupt quality by pinning interrupt and process to core 1
|
||||
setpriority(PRIO_PROCESS, 0, -18);
|
||||
util::set_core_affinity({1});
|
||||
|
||||
// TODO: get the IRQ number from gpiochip
|
||||
std::string irq_path = "/proc/irq/336/smp_affinity_list";
|
||||
if (!util::file_exists(irq_path)) {
|
||||
irq_path = "/proc/irq/335/smp_affinity_list";
|
||||
}
|
||||
std::system(util::string_format("sudo su -c 'echo 1 > %s'", irq_path.c_str()).c_str());
|
||||
|
||||
// thread for reading events via interrupts
|
||||
threads.emplace_back(&interrupt_loop, std::ref(sensors_init));
|
||||
|
||||
// wait for all threads to finish
|
||||
for (auto &t : threads) {
|
||||
t.join();
|
||||
}
|
||||
|
||||
for (auto &[sensor, msg_name] : sensors_init) {
|
||||
sensor->shutdown();
|
||||
delete sensor;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
try {
|
||||
auto i2c_bus_imu = std::make_unique<I2CBus>(I2C_BUS_IMU);
|
||||
return sensor_loop(i2c_bus_imu.get());
|
||||
} catch (std::exception &e) {
|
||||
LOGE("I2CBus init failed");
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,6 @@ from openpilot.common.timeout import Timeout
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.manager.process_config import managed_processes
|
||||
|
||||
BMX = {
|
||||
('bmx055', 'acceleration'),
|
||||
('bmx055', 'gyroUncalibrated'),
|
||||
('bmx055', 'magneticUncalibrated'),
|
||||
('bmx055', 'temperature'),
|
||||
}
|
||||
|
||||
LSM = {
|
||||
('lsm6ds3', 'acceleration'),
|
||||
('lsm6ds3', 'gyroUncalibrated'),
|
||||
@@ -30,17 +23,11 @@ MMC = {
|
||||
('mmc5603nj', 'magneticUncalibrated'),
|
||||
}
|
||||
|
||||
SENSOR_CONFIGURATIONS: list[set] = [
|
||||
BMX | LSM,
|
||||
MMC | LSM,
|
||||
BMX | LSM_C,
|
||||
MMC| LSM_C,
|
||||
]
|
||||
if HARDWARE.get_device_type() == "mici":
|
||||
SENSOR_CONFIGURATIONS = [
|
||||
LSM,
|
||||
LSM_C,
|
||||
]
|
||||
SENSOR_CONFIGURATIONS: list[set] = {
|
||||
"mici": [LSM, LSM_C],
|
||||
"tizi": [MMC | LSM, MMC | LSM_C],
|
||||
"tici": [LSM, LSM_C, MMC | LSM, MMC | LSM_C],
|
||||
}.get(HARDWARE.get_device_type(), [])
|
||||
|
||||
Sensor = log.SensorEventData.SensorSource
|
||||
SensorConfig = namedtuple('SensorConfig', ['type', 'sanity_min', 'sanity_max'])
|
||||
@@ -57,13 +44,6 @@ ALL_SENSORS = {
|
||||
SensorConfig("temperature", 0, 60),
|
||||
},
|
||||
|
||||
Sensor.bmx055: {
|
||||
SensorConfig("acceleration", 5, 15),
|
||||
SensorConfig("gyroUncalibrated", 0, .2),
|
||||
SensorConfig("magneticUncalibrated", 0, 300),
|
||||
SensorConfig("temperature", 0, 60),
|
||||
},
|
||||
|
||||
Sensor.mmc5603nj: {
|
||||
SensorConfig("magneticUncalibrated", 0, 300),
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import time
|
||||
import atexit
|
||||
|
||||
from cereal import messaging
|
||||
from openpilot.system.manager.process_config import managed_processes
|
||||
|
||||
TIMEOUT = 10*60
|
||||
|
||||
def kill():
|
||||
for proc in ['ubloxd', 'pigeond']:
|
||||
managed_processes[proc].stop(retry=True, block=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# start ubloxd
|
||||
managed_processes['ubloxd'].start()
|
||||
atexit.register(kill)
|
||||
|
||||
sm = messaging.SubMaster(['ubloxGnss'])
|
||||
|
||||
times = []
|
||||
for i in range(20):
|
||||
# start pigeond
|
||||
st = time.monotonic()
|
||||
managed_processes['pigeond'].start()
|
||||
|
||||
# wait for a >4 satellite fix
|
||||
while True:
|
||||
sm.update(0)
|
||||
msg = sm['ubloxGnss']
|
||||
if msg.which() == 'measurementReport' and sm.updated["ubloxGnss"]:
|
||||
report = msg.measurementReport
|
||||
if report.numMeas > 4:
|
||||
times.append(time.monotonic() - st)
|
||||
print(f"\033[94m{i}: Got a fix in {round(times[-1], 2)} seconds\033[0m")
|
||||
break
|
||||
|
||||
if time.monotonic() - st > TIMEOUT:
|
||||
raise TimeoutError("\033[91mFailed to get a fix in {TIMEOUT} seconds!\033[0m")
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
# stop pigeond
|
||||
managed_processes['pigeond'].stop(retry=True, block=True)
|
||||
time.sleep(20)
|
||||
|
||||
print(f"\033[92mAverage TTFF: {round(sum(times) / len(times), 2)}s\033[0m")
|
||||
@@ -476,6 +476,10 @@ kj::Array<capnp::word> UbloxMsgParser::gen_nav_sat(ubx_t::nav_sat_t *msg) {
|
||||
svs[i].setSvId(svs_data[i]->sv_id());
|
||||
svs[i].setGnssId(svs_data[i]->gnss_id());
|
||||
svs[i].setFlagsBitfield(svs_data[i]->flags());
|
||||
svs[i].setCno(svs_data[i]->cno());
|
||||
svs[i].setElevationDeg(svs_data[i]->elev());
|
||||
svs[i].setAzimuthDeg(svs_data[i]->azim());
|
||||
svs[i].setPseudorangeResidual(svs_data[i]->pr_res() * 0.1);
|
||||
}
|
||||
|
||||
return capnp::messageToFlatArray(msg_builder);
|
||||
|
||||
+2
-1
@@ -3,7 +3,8 @@
|
||||
The user interfaces here are built with [raylib](https://www.raylib.com/).
|
||||
|
||||
Quick start:
|
||||
* set `DEBUG_FPS=1` to show the FPS
|
||||
* set `SHOW_FPS=1` to show the FPS
|
||||
* set `STRICT_MODE=1` to kill the app if it drops too much below 60fps
|
||||
* set `SCALE=1.5` to scale the entire UI by 1.5x
|
||||
* https://www.raylib.com/cheatsheet/cheatsheet.html
|
||||
* https://electronstudio.github.io/raylib-python-cffi/README.html#quickstart
|
||||
|
||||
+160
-42
@@ -3,31 +3,37 @@ import os
|
||||
import time
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from importlib.resources import as_file, files
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
|
||||
DEFAULT_FPS = 60
|
||||
FPS_LOG_INTERVAL = 5 # Seconds between logging FPS drops
|
||||
FPS_DROP_THRESHOLD = 0.9 # FPS drop threshold for triggering a warning
|
||||
FPS_CRITICAL_THRESHOLD = 0.5 # Critical threshold for triggering strict actions
|
||||
|
||||
DEBUG_FPS = os.getenv("DEBUG_FPS") == '1'
|
||||
ENABLE_VSYNC = os.getenv("ENABLE_VSYNC") == "1"
|
||||
SHOW_FPS = os.getenv("SHOW_FPS") == '1'
|
||||
STRICT_MODE = os.getenv("STRICT_MODE") == '1'
|
||||
SCALE = float(os.getenv("SCALE", "1.0"))
|
||||
|
||||
DEFAULT_TEXT_SIZE = 60
|
||||
DEFAULT_TEXT_COLOR = rl.Color(200, 200, 200, 255)
|
||||
FONT_DIR = os.path.join(BASEDIR, "selfdrive/assets/fonts")
|
||||
DEFAULT_TEXT_COLOR = rl.WHITE
|
||||
|
||||
ASSETS_DIR = files("openpilot.selfdrive").joinpath("assets")
|
||||
FONT_DIR = ASSETS_DIR.joinpath("fonts")
|
||||
|
||||
|
||||
class FontWeight(IntEnum):
|
||||
BLACK = 0
|
||||
BOLD = 1
|
||||
EXTRA_BOLD = 2
|
||||
EXTRA_LIGHT = 3
|
||||
THIN = 0
|
||||
EXTRA_LIGHT = 1
|
||||
LIGHT = 2
|
||||
NORMAL = 3
|
||||
MEDIUM = 4
|
||||
NORMAL = 5
|
||||
SEMI_BOLD = 6
|
||||
THIN = 7
|
||||
SEMI_BOLD = 5
|
||||
BOLD = 6
|
||||
EXTRA_BOLD = 7
|
||||
BLACK = 8
|
||||
|
||||
|
||||
class GuiApplication:
|
||||
@@ -35,58 +41,133 @@ class GuiApplication:
|
||||
self._fonts: dict[FontWeight, rl.Font] = {}
|
||||
self._width = width
|
||||
self._height = height
|
||||
self._textures: list[rl.Texture] = []
|
||||
self._scale = SCALE
|
||||
self._scaled_width = int(self._width * self._scale)
|
||||
self._scaled_height = int(self._height * self._scale)
|
||||
self._render_texture: rl.RenderTexture | None = None
|
||||
self._textures: dict[str, rl.Texture] = {}
|
||||
self._target_fps: int = DEFAULT_FPS
|
||||
self._last_fps_log_time: float = time.monotonic()
|
||||
self._window_close_requested = False
|
||||
self._trace_log_callback = None
|
||||
|
||||
def init_window(self, title: str, fps: int=DEFAULT_FPS):
|
||||
def request_close(self):
|
||||
self._window_close_requested = True
|
||||
|
||||
def init_window(self, title: str, fps: int = DEFAULT_FPS):
|
||||
atexit.register(self.close) # Automatically call close() on exit
|
||||
|
||||
rl.set_config_flags(rl.ConfigFlags.FLAG_MSAA_4X_HINT | rl.ConfigFlags.FLAG_VSYNC_HINT)
|
||||
rl.init_window(self._width, self._height, title)
|
||||
HARDWARE.set_display_power(True)
|
||||
HARDWARE.set_screen_brightness(65)
|
||||
|
||||
self._set_log_callback()
|
||||
rl.set_trace_log_level(rl.TraceLogLevel.LOG_ALL)
|
||||
|
||||
flags = rl.ConfigFlags.FLAG_MSAA_4X_HINT
|
||||
if ENABLE_VSYNC:
|
||||
flags |= rl.ConfigFlags.FLAG_VSYNC_HINT
|
||||
rl.set_config_flags(flags)
|
||||
|
||||
rl.init_window(self._scaled_width, self._scaled_height, title)
|
||||
if self._scale != 1.0:
|
||||
rl.set_mouse_scale(1 / self._scale, 1 / self._scale)
|
||||
self._render_texture = rl.load_render_texture(self._width, self._height)
|
||||
rl.set_texture_filter(self._render_texture.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
rl.set_target_fps(fps)
|
||||
|
||||
self._target_fps = fps
|
||||
self._set_styles()
|
||||
self._load_fonts()
|
||||
|
||||
def load_texture_from_image(self, file_name: str, width: int, height: int):
|
||||
def texture(self, asset_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True):
|
||||
cache_key = f"{asset_path}_{width}_{height}_{alpha_premultiply}{keep_aspect_ratio}"
|
||||
if cache_key in self._textures:
|
||||
return self._textures[cache_key]
|
||||
|
||||
with as_file(ASSETS_DIR.joinpath(asset_path)) as fspath:
|
||||
texture_obj = self._load_texture_from_image(fspath.as_posix(), width, height, alpha_premultiply, keep_aspect_ratio)
|
||||
self._textures[cache_key] = texture_obj
|
||||
return texture_obj
|
||||
|
||||
def _load_texture_from_image(self, image_path: str, width: int, height: int, alpha_premultiply=False, keep_aspect_ratio=True):
|
||||
"""Load and resize a texture, storing it for later automatic unloading."""
|
||||
image = rl.load_image(file_name)
|
||||
rl.image_resize(image, width, height)
|
||||
image = rl.load_image(image_path)
|
||||
|
||||
if alpha_premultiply:
|
||||
rl.image_alpha_premultiply(image)
|
||||
|
||||
# Resize with aspect ratio preservation if requested
|
||||
if keep_aspect_ratio:
|
||||
orig_width = image.width
|
||||
orig_height = image.height
|
||||
|
||||
scale_width = width / orig_width
|
||||
scale_height = height / orig_height
|
||||
|
||||
# Calculate new dimensions
|
||||
scale = min(scale_width, scale_height)
|
||||
new_width = int(orig_width * scale)
|
||||
new_height = int(orig_height * scale)
|
||||
|
||||
rl.image_resize(image, new_width, new_height)
|
||||
else:
|
||||
rl.image_resize(image, width, height)
|
||||
|
||||
texture = rl.load_texture_from_image(image)
|
||||
# Set texture filtering to smooth the result
|
||||
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
|
||||
rl.unload_image(image)
|
||||
|
||||
self._textures.append(texture)
|
||||
return texture
|
||||
|
||||
def close(self):
|
||||
for texture in self._textures:
|
||||
if not rl.is_window_ready():
|
||||
return
|
||||
|
||||
for texture in self._textures.values():
|
||||
rl.unload_texture(texture)
|
||||
self._textures = {}
|
||||
|
||||
for font in self._fonts.values():
|
||||
rl.unload_font(font)
|
||||
self._fonts = {}
|
||||
|
||||
if self._render_texture is not None:
|
||||
rl.unload_render_texture(self._render_texture)
|
||||
self._render_texture = None
|
||||
|
||||
rl.close_window()
|
||||
|
||||
def render(self):
|
||||
while not rl.window_should_close():
|
||||
rl.begin_drawing()
|
||||
rl.clear_background(rl.BLACK)
|
||||
try:
|
||||
while not (self._window_close_requested or rl.window_should_close()):
|
||||
if self._render_texture:
|
||||
rl.begin_texture_mode(self._render_texture)
|
||||
rl.clear_background(rl.BLACK)
|
||||
else:
|
||||
rl.begin_drawing()
|
||||
rl.clear_background(rl.BLACK)
|
||||
|
||||
yield
|
||||
yield
|
||||
|
||||
if DEBUG_FPS:
|
||||
rl.draw_fps(10, 10)
|
||||
if self._render_texture:
|
||||
rl.end_texture_mode()
|
||||
rl.begin_drawing()
|
||||
rl.clear_background(rl.BLACK)
|
||||
src_rect = rl.Rectangle(0, 0, float(self._width), -float(self._height))
|
||||
dst_rect = rl.Rectangle(0, 0, float(self._scaled_width), float(self._scaled_height))
|
||||
rl.draw_texture_pro(self._render_texture.texture, src_rect, dst_rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
||||
|
||||
rl.end_drawing()
|
||||
self._monitor_fps()
|
||||
if SHOW_FPS:
|
||||
rl.draw_fps(10, 10)
|
||||
|
||||
def font(self, font_wight: FontWeight=FontWeight.NORMAL):
|
||||
return self._fonts[font_wight]
|
||||
rl.end_drawing()
|
||||
self._monitor_fps()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
def font(self, font_weight: FontWeight = FontWeight.NORMAL):
|
||||
return self._fonts[font_weight]
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
@@ -98,21 +179,36 @@ class GuiApplication:
|
||||
|
||||
def _load_fonts(self):
|
||||
font_files = (
|
||||
"Inter-Black.ttf",
|
||||
"Inter-Thin.ttf",
|
||||
"Inter-ExtraLight.ttf",
|
||||
"Inter-Light.ttf",
|
||||
"Inter-Regular.ttf",
|
||||
"Inter-Medium.ttf",
|
||||
"Inter-SemiBold.ttf",
|
||||
"Inter-Bold.ttf",
|
||||
"Inter-ExtraBold.ttf",
|
||||
"Inter-ExtraLight.ttf",
|
||||
"Inter-Medium.ttf",
|
||||
"Inter-Regular.ttf",
|
||||
"Inter-SemiBold.ttf",
|
||||
"Inter-Thin.ttf"
|
||||
)
|
||||
"Inter-Black.ttf",
|
||||
)
|
||||
|
||||
# Create a character set from our keyboard layouts
|
||||
from openpilot.system.ui.widgets.keyboard import KEYBOARD_LAYOUTS
|
||||
from openpilot.selfdrive.ui.onroad.hud_renderer import CRUISE_DISABLED_CHAR
|
||||
all_chars = set()
|
||||
for layout in KEYBOARD_LAYOUTS.values():
|
||||
all_chars.update(key for row in layout for key in row)
|
||||
all_chars = "".join(all_chars)
|
||||
all_chars += CRUISE_DISABLED_CHAR
|
||||
|
||||
codepoint_count = rl.ffi.new("int *", 1)
|
||||
codepoints = rl.load_codepoints(all_chars, codepoint_count)
|
||||
|
||||
for index, font_file in enumerate(font_files):
|
||||
font = rl.load_font_ex(os.path.join(FONT_DIR, font_file), 120, None, 0)
|
||||
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
self._fonts[index] = font
|
||||
with as_file(FONT_DIR.joinpath(font_file)) as fspath:
|
||||
font = rl.load_font_ex(fspath.as_posix(), 200, codepoints, codepoint_count[0])
|
||||
rl.set_texture_filter(font.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
self._fonts[index] = font
|
||||
|
||||
rl.unload_codepoints(codepoints)
|
||||
rl.gui_set_font(self._fonts[FontWeight.NORMAL])
|
||||
|
||||
def _set_styles(self):
|
||||
@@ -120,9 +216,31 @@ class GuiApplication:
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_SIZE, DEFAULT_TEXT_SIZE)
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.BLACK))
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(DEFAULT_TEXT_COLOR))
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.BACKGROUND_COLOR, rl.color_to_int(rl.Color(30, 30, 30, 255)))
|
||||
rl.gui_set_style(rl.GuiControl.DEFAULT, rl.GuiControlProperty.BASE_COLOR_NORMAL, rl.color_to_int(rl.Color(50, 50, 50, 255)))
|
||||
|
||||
def _set_log_callback(self):
|
||||
@rl.ffi.callback("void(int, char *, void *)")
|
||||
def trace_log_callback(log_level, text, args):
|
||||
try:
|
||||
text_str = rl.ffi.string(text).decode('utf-8')
|
||||
except (TypeError, UnicodeDecodeError):
|
||||
text_str = str(text)
|
||||
|
||||
if log_level == rl.TraceLogLevel.LOG_ERROR:
|
||||
cloudlog.error(f"raylib: {text_str}")
|
||||
elif log_level == rl.TraceLogLevel.LOG_WARNING:
|
||||
cloudlog.warning(f"raylib: {text_str}")
|
||||
elif log_level == rl.TraceLogLevel.LOG_INFO:
|
||||
cloudlog.info(f"raylib: {text_str}")
|
||||
elif log_level == rl.TraceLogLevel.LOG_DEBUG:
|
||||
cloudlog.debug(f"raylib: {text_str}")
|
||||
else:
|
||||
cloudlog.error(f"raylib: Unknown level {log_level}: {text_str}")
|
||||
|
||||
# Store callback reference
|
||||
self._trace_log_callback = trace_log_callback
|
||||
rl.set_trace_log_callback(self._trace_log_callback)
|
||||
|
||||
def _monitor_fps(self):
|
||||
fps = rl.get_fps()
|
||||
|
||||
|
||||
+77
-10
@@ -8,11 +8,21 @@ class ButtonStyle(IntEnum):
|
||||
PRIMARY = 1 # For main actions
|
||||
DANGER = 2 # For critical actions, like reboot or delete
|
||||
TRANSPARENT = 3 # For buttons with transparent background and border
|
||||
ACTION = 4
|
||||
|
||||
|
||||
class TextAlignment(IntEnum):
|
||||
LEFT = 0
|
||||
CENTER = 1
|
||||
RIGHT = 2
|
||||
|
||||
|
||||
ICON_PADDING = 15
|
||||
DEFAULT_BUTTON_FONT_SIZE = 60
|
||||
BUTTON_ENABLED_TEXT_COLOR = rl.Color(228, 228, 228, 255)
|
||||
BUTTON_DISABLED_TEXT_COLOR = rl.Color(228, 228, 228, 51)
|
||||
ACTION_BUTTON_FONT_SIZE = 48
|
||||
ACTION_BUTTON_TEXT_COLOR = rl.Color(0, 0, 0, 255)
|
||||
|
||||
|
||||
BUTTON_BACKGROUND_COLORS = {
|
||||
@@ -20,6 +30,7 @@ BUTTON_BACKGROUND_COLORS = {
|
||||
ButtonStyle.PRIMARY: rl.Color(70, 91, 234, 255),
|
||||
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
ButtonStyle.ACTION: rl.Color(189, 189, 189, 255),
|
||||
}
|
||||
|
||||
BUTTON_PRESSED_BACKGROUND_COLORS = {
|
||||
@@ -27,8 +38,11 @@ BUTTON_PRESSED_BACKGROUND_COLORS = {
|
||||
ButtonStyle.PRIMARY: rl.Color(48, 73, 244, 255),
|
||||
ButtonStyle.DANGER: rl.Color(255, 36, 36, 255),
|
||||
ButtonStyle.TRANSPARENT: rl.BLACK,
|
||||
ButtonStyle.ACTION: rl.Color(130, 130, 130, 255),
|
||||
}
|
||||
|
||||
_pressed_buttons: set[str] = set() # Track mouse press state globally
|
||||
|
||||
|
||||
def gui_button(
|
||||
rect: rl.Rectangle,
|
||||
@@ -38,16 +52,42 @@ def gui_button(
|
||||
button_style: ButtonStyle = ButtonStyle.NORMAL,
|
||||
is_enabled: bool = True,
|
||||
border_radius: int = 10, # Corner rounding in pixels
|
||||
text_alignment: TextAlignment = TextAlignment.CENTER,
|
||||
text_padding: int = 20, # Padding for left/right alignment
|
||||
icon=None,
|
||||
) -> int:
|
||||
button_id = f"{rect.x}_{rect.y}_{rect.width}_{rect.height}"
|
||||
result = 0
|
||||
|
||||
if button_style in (ButtonStyle.PRIMARY, ButtonStyle.DANGER) and not is_enabled:
|
||||
button_style = ButtonStyle.NORMAL
|
||||
|
||||
if button_style == ButtonStyle.ACTION and font_size == DEFAULT_BUTTON_FONT_SIZE:
|
||||
font_size = ACTION_BUTTON_FONT_SIZE
|
||||
|
||||
# Set background color based on button type
|
||||
bg_color = BUTTON_BACKGROUND_COLORS[button_style]
|
||||
if is_enabled and rl.check_collision_point_rec(rl.get_mouse_position(), rect):
|
||||
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
mouse_over = is_enabled and rl.check_collision_point_rec(rl.get_mouse_position(), rect)
|
||||
is_pressed = button_id in _pressed_buttons
|
||||
|
||||
if mouse_over:
|
||||
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
# Only this button enters pressed state
|
||||
_pressed_buttons.add(button_id)
|
||||
is_pressed = True
|
||||
|
||||
# Use pressed color when mouse is down over this button
|
||||
if is_pressed and rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
bg_color = BUTTON_PRESSED_BACKGROUND_COLORS[button_style]
|
||||
elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
|
||||
# Handle button click
|
||||
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and is_pressed:
|
||||
result = 1
|
||||
_pressed_buttons.remove(button_id)
|
||||
|
||||
# Clean up pressed state if mouse is released anywhere
|
||||
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and button_id in _pressed_buttons:
|
||||
_pressed_buttons.remove(button_id)
|
||||
|
||||
# Draw the button with rounded corners
|
||||
roundness = border_radius / (min(rect.width, rect.height) / 2)
|
||||
@@ -57,15 +97,42 @@ def gui_button(
|
||||
rl.draw_rectangle_rounded(rect, roundness, 20, rl.BLACK)
|
||||
rl.draw_rectangle_rounded_lines_ex(rect, roundness, 20, 2, rl.WHITE)
|
||||
|
||||
# Handle icon and text positioning
|
||||
font = gui_app.font(font_weight)
|
||||
# Center text in the button
|
||||
text_size = rl.measure_text_ex(font, text, font_size, 0)
|
||||
text_pos = rl.Vector2(
|
||||
rect.x + (rect.width - text_size.x) // 2, rect.y + (rect.height - text_size.y) // 2
|
||||
)
|
||||
text_pos = rl.Vector2(0, rect.y + (rect.height - text_size.y) // 2) # Vertical centering
|
||||
|
||||
# Draw the button text
|
||||
text_color = BUTTON_ENABLED_TEXT_COLOR if is_enabled else BUTTON_DISABLED_TEXT_COLOR
|
||||
rl.draw_text_ex(font, text, text_pos, font_size, 0, text_color)
|
||||
# Draw icon if provided
|
||||
if icon:
|
||||
icon_y = rect.y + (rect.height - icon.height) / 2
|
||||
if text:
|
||||
if text_alignment == TextAlignment.LEFT:
|
||||
icon_x = rect.x + text_padding
|
||||
text_pos.x = icon_x + icon.width + ICON_PADDING
|
||||
elif text_alignment == TextAlignment.CENTER:
|
||||
total_width = icon.width + ICON_PADDING + text_size.x
|
||||
icon_x = rect.x + (rect.width - total_width) / 2
|
||||
text_pos.x = icon_x + icon.width + ICON_PADDING
|
||||
else: # RIGHT
|
||||
text_pos.x = rect.x + rect.width - text_size.x - text_padding
|
||||
icon_x = text_pos.x - ICON_PADDING - icon.width
|
||||
else:
|
||||
# Center icon when no text
|
||||
icon_x = rect.x + (rect.width - icon.width) / 2
|
||||
|
||||
rl.draw_texture_v(icon, rl.Vector2(icon_x, icon_y), rl.WHITE if is_enabled else rl.Color(255, 255, 255, 100))
|
||||
else:
|
||||
# No icon, position text normally
|
||||
if text_alignment == TextAlignment.LEFT:
|
||||
text_pos.x = rect.x + text_padding
|
||||
elif text_alignment == TextAlignment.CENTER:
|
||||
text_pos.x = rect.x + (rect.width - text_size.x) // 2
|
||||
elif text_alignment == TextAlignment.RIGHT:
|
||||
text_pos.x = rect.x + rect.width - text_size.x - text_padding
|
||||
|
||||
# Draw the button text if any
|
||||
if text:
|
||||
text_color = ACTION_BUTTON_TEXT_COLOR if button_style == ButtonStyle.ACTION else BUTTON_ENABLED_TEXT_COLOR if is_enabled else BUTTON_DISABLED_TEXT_COLOR
|
||||
rl.draw_text_ex(font, text, text_pos, font_size, 0, text_color)
|
||||
|
||||
return result
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import os
|
||||
import cffi
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
|
||||
# EGL constants
|
||||
EGL_LINUX_DMA_BUF_EXT = 0x3270
|
||||
EGL_WIDTH = 0x3057
|
||||
EGL_HEIGHT = 0x3056
|
||||
EGL_LINUX_DRM_FOURCC_EXT = 0x3271
|
||||
EGL_DMA_BUF_PLANE0_FD_EXT = 0x3272
|
||||
EGL_DMA_BUF_PLANE0_OFFSET_EXT = 0x3273
|
||||
EGL_DMA_BUF_PLANE0_PITCH_EXT = 0x3274
|
||||
EGL_DMA_BUF_PLANE1_FD_EXT = 0x3275
|
||||
EGL_DMA_BUF_PLANE1_OFFSET_EXT = 0x3276
|
||||
EGL_DMA_BUF_PLANE1_PITCH_EXT = 0x3277
|
||||
EGL_NONE = 0x3038
|
||||
GL_TEXTURE0 = 0x84C0
|
||||
GL_TEXTURE_EXTERNAL_OES = 0x8D65
|
||||
|
||||
# DRM Format for NV12
|
||||
DRM_FORMAT_NV12 = 842094158
|
||||
|
||||
@dataclass
|
||||
class EGLImage:
|
||||
"""Container for EGL image and associated resources"""
|
||||
|
||||
egl_image: Any
|
||||
fd: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class EGLState:
|
||||
"""Container for all EGL-related state"""
|
||||
|
||||
initialized: bool = False
|
||||
ffi: Any = None
|
||||
egl_lib: Any = None
|
||||
gles_lib: Any = None
|
||||
|
||||
# EGL display connection - shared across all users
|
||||
display: Any = None
|
||||
|
||||
# Constants
|
||||
NO_CONTEXT: Any = None
|
||||
NO_DISPLAY: Any = None
|
||||
NO_IMAGE_KHR: Any = None
|
||||
|
||||
# Function pointers
|
||||
get_current_display: Any = None
|
||||
create_image_khr: Any = None
|
||||
destroy_image_khr: Any = None
|
||||
image_target_texture: Any = None
|
||||
get_error: Any = None
|
||||
bind_texture: Any = None
|
||||
active_texture: Any = None
|
||||
|
||||
|
||||
# Create a single instance of the state
|
||||
_egl = EGLState()
|
||||
|
||||
|
||||
def init_egl() -> bool:
|
||||
"""Initialize EGL and load necessary functions"""
|
||||
global _egl
|
||||
|
||||
# Don't re-initialize if already done
|
||||
if _egl.initialized:
|
||||
return True
|
||||
|
||||
try:
|
||||
_egl.ffi = cffi.FFI()
|
||||
_egl.ffi.cdef("""
|
||||
typedef int EGLint;
|
||||
typedef unsigned int EGLBoolean;
|
||||
typedef unsigned int EGLenum;
|
||||
typedef unsigned int GLenum;
|
||||
typedef void *EGLContext;
|
||||
typedef void *EGLDisplay;
|
||||
typedef void *EGLClientBuffer;
|
||||
typedef void *EGLImageKHR;
|
||||
typedef void *GLeglImageOES;
|
||||
|
||||
EGLDisplay eglGetCurrentDisplay(void);
|
||||
EGLint eglGetError(void);
|
||||
EGLImageKHR eglCreateImageKHR(EGLDisplay dpy, EGLContext ctx,
|
||||
EGLenum target, EGLClientBuffer buffer,
|
||||
const EGLint *attrib_list);
|
||||
EGLBoolean eglDestroyImageKHR(EGLDisplay dpy, EGLImageKHR image);
|
||||
void glEGLImageTargetTexture2DOES(GLenum target, GLeglImageOES image);
|
||||
void glBindTexture(GLenum target, unsigned int texture);
|
||||
void glActiveTexture(GLenum texture);
|
||||
""")
|
||||
|
||||
# Load libraries
|
||||
_egl.egl_lib = _egl.ffi.dlopen("libEGL.so")
|
||||
_egl.gles_lib = _egl.ffi.dlopen("libGLESv2.so")
|
||||
|
||||
# Cast NULL pointers
|
||||
_egl.NO_CONTEXT = _egl.ffi.cast("void *", 0)
|
||||
_egl.NO_DISPLAY = _egl.ffi.cast("void *", 0)
|
||||
_egl.NO_IMAGE_KHR = _egl.ffi.cast("void *", 0)
|
||||
|
||||
# Bind functions
|
||||
_egl.get_current_display = _egl.egl_lib.eglGetCurrentDisplay
|
||||
_egl.create_image_khr = _egl.egl_lib.eglCreateImageKHR
|
||||
_egl.destroy_image_khr = _egl.egl_lib.eglDestroyImageKHR
|
||||
_egl.image_target_texture = _egl.gles_lib.glEGLImageTargetTexture2DOES
|
||||
_egl.get_error = _egl.egl_lib.eglGetError
|
||||
_egl.bind_texture = _egl.gles_lib.glBindTexture
|
||||
_egl.active_texture = _egl.gles_lib.glActiveTexture
|
||||
|
||||
# Initialize EGL display once here
|
||||
_egl.display = _egl.get_current_display()
|
||||
if _egl.display == _egl.NO_DISPLAY:
|
||||
raise RuntimeError("Failed to get EGL display")
|
||||
|
||||
_egl.initialized = True
|
||||
return True
|
||||
except Exception as e:
|
||||
cloudlog.exception(f"EGL initialization failed: {e}")
|
||||
_egl.initialized = False
|
||||
return False
|
||||
|
||||
|
||||
def create_egl_image(width: int, height: int, stride: int, fd: int, uv_offset: int) -> EGLImage | None:
|
||||
assert _egl.initialized, "EGL not initialized"
|
||||
|
||||
# Duplicate fd since EGL needs it
|
||||
dup_fd = os.dup(fd)
|
||||
|
||||
# Create image attributes for EGL
|
||||
img_attrs = [
|
||||
EGL_WIDTH, width,
|
||||
EGL_HEIGHT, height,
|
||||
EGL_LINUX_DRM_FOURCC_EXT, DRM_FORMAT_NV12,
|
||||
EGL_DMA_BUF_PLANE0_FD_EXT, dup_fd,
|
||||
EGL_DMA_BUF_PLANE0_OFFSET_EXT, 0,
|
||||
EGL_DMA_BUF_PLANE0_PITCH_EXT, stride,
|
||||
EGL_DMA_BUF_PLANE1_FD_EXT, dup_fd,
|
||||
EGL_DMA_BUF_PLANE1_OFFSET_EXT, uv_offset,
|
||||
EGL_DMA_BUF_PLANE1_PITCH_EXT, stride,
|
||||
EGL_NONE
|
||||
]
|
||||
|
||||
attr_array = _egl.ffi.new("int[]", img_attrs)
|
||||
egl_image = _egl.create_image_khr(_egl.display, _egl.NO_CONTEXT, EGL_LINUX_DMA_BUF_EXT, _egl.ffi.NULL, attr_array)
|
||||
|
||||
if egl_image == _egl.NO_IMAGE_KHR:
|
||||
cloudlog.error(f"Failed to create EGL image: {_egl.get_error()}")
|
||||
os.close(dup_fd)
|
||||
return None
|
||||
|
||||
return EGLImage(egl_image=egl_image, fd=dup_fd)
|
||||
|
||||
|
||||
def destroy_egl_image(egl_image: EGLImage) -> None:
|
||||
assert _egl.initialized, "EGL not initialized"
|
||||
|
||||
_egl.destroy_image_khr(_egl.display, egl_image.egl_image)
|
||||
|
||||
# Close the duplicated fd we created in create_egl_image()
|
||||
# We need to handle OSError since the fd might already be closed
|
||||
try:
|
||||
os.close(egl_image.fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def bind_egl_image_to_texture(texture_id: int, egl_image: EGLImage) -> None:
|
||||
assert _egl.initialized, "EGL not initialized"
|
||||
|
||||
_egl.active_texture(GL_TEXTURE0)
|
||||
_egl.bind_texture(GL_TEXTURE_EXTERNAL_OES, texture_id)
|
||||
_egl.image_target_texture(GL_TEXTURE_EXTERNAL_OES, egl_image.egl_image)
|
||||
@@ -0,0 +1,232 @@
|
||||
import pyray as rl
|
||||
import time
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
|
||||
PASSWORD_MASK_CHAR = "•"
|
||||
PASSWORD_MASK_DELAY = 1.5 # Seconds to show character before masking
|
||||
|
||||
|
||||
class InputBox:
|
||||
def __init__(self, max_text_size=255, password_mode=False):
|
||||
self._max_text_size = max_text_size
|
||||
self._input_text = ""
|
||||
self._cursor_position = 0
|
||||
self._password_mode = password_mode
|
||||
self._blink_counter = 0
|
||||
self._show_cursor = False
|
||||
self._last_key_pressed = 0
|
||||
self._key_press_time = 0
|
||||
self._repeat_delay = 30
|
||||
self._repeat_rate = 4
|
||||
self._text_offset = 0
|
||||
self._visible_width = 0
|
||||
self._last_char_time = 0 # Track when last character was added
|
||||
self._masked_length = 0 # How many characters are currently masked
|
||||
|
||||
@property
|
||||
def text(self):
|
||||
return self._input_text
|
||||
|
||||
@text.setter
|
||||
def text(self, value):
|
||||
self._input_text = value[: self._max_text_size]
|
||||
self._cursor_position = len(self._input_text)
|
||||
self._update_text_offset()
|
||||
|
||||
def set_password_mode(self, password_mode):
|
||||
self._password_mode = password_mode
|
||||
|
||||
def clear(self):
|
||||
self._input_text = ''
|
||||
self._cursor_position = 0
|
||||
self._text_offset = 0
|
||||
|
||||
def set_cursor_position(self, position):
|
||||
"""Set the cursor position and reset the blink counter."""
|
||||
if 0 <= position <= len(self._input_text):
|
||||
self._cursor_position = position
|
||||
self._blink_counter = 0
|
||||
self._show_cursor = True
|
||||
self._update_text_offset()
|
||||
|
||||
def _update_text_offset(self):
|
||||
"""Ensure the cursor is visible by adjusting text offset."""
|
||||
if self._visible_width == 0:
|
||||
return
|
||||
|
||||
font = gui_app.font()
|
||||
display_text = self._get_display_text()
|
||||
padding = 10
|
||||
|
||||
if self._cursor_position > 0:
|
||||
cursor_x = rl.measure_text_ex(font, display_text[: self._cursor_position], self._font_size, 0).x
|
||||
else:
|
||||
cursor_x = 0
|
||||
|
||||
visible_width = self._visible_width - (padding * 2)
|
||||
|
||||
# Adjust offset if cursor would be outside visible area
|
||||
if cursor_x < self._text_offset:
|
||||
self._text_offset = max(0, cursor_x - padding)
|
||||
elif cursor_x > self._text_offset + visible_width:
|
||||
self._text_offset = cursor_x - visible_width + padding
|
||||
|
||||
def add_char_at_cursor(self, char):
|
||||
"""Add a character at the current cursor position."""
|
||||
if len(self._input_text) < self._max_text_size:
|
||||
self._input_text = self._input_text[: self._cursor_position] + char + self._input_text[self._cursor_position :]
|
||||
self.set_cursor_position(self._cursor_position + 1)
|
||||
|
||||
if self._password_mode:
|
||||
self._last_char_time = time.time()
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_char_before_cursor(self):
|
||||
"""Delete the character before the cursor position (backspace)."""
|
||||
if self._cursor_position > 0:
|
||||
self._input_text = self._input_text[: self._cursor_position - 1] + self._input_text[self._cursor_position :]
|
||||
self.set_cursor_position(self._cursor_position - 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def delete_char_at_cursor(self):
|
||||
"""Delete the character at the cursor position (delete)."""
|
||||
if self._cursor_position < len(self._input_text):
|
||||
self._input_text = self._input_text[: self._cursor_position] + self._input_text[self._cursor_position + 1 :]
|
||||
self.set_cursor_position(self._cursor_position)
|
||||
return True
|
||||
return False
|
||||
|
||||
def render(self, rect, color=rl.BLACK, border_color=rl.DARKGRAY, text_color=rl.WHITE, font_size=80):
|
||||
# Store dimensions for text offset calculations
|
||||
self._visible_width = rect.width
|
||||
self._font_size = font_size
|
||||
|
||||
# Handle mouse input
|
||||
self._handle_mouse_input(rect, font_size)
|
||||
|
||||
# Draw input box
|
||||
rl.draw_rectangle_rec(rect, color)
|
||||
|
||||
# Process keyboard input
|
||||
self._handle_keyboard_input()
|
||||
|
||||
# Update cursor blink
|
||||
self._blink_counter += 1
|
||||
if self._blink_counter >= 30:
|
||||
self._show_cursor = not self._show_cursor
|
||||
self._blink_counter = 0
|
||||
|
||||
# Display text
|
||||
font = gui_app.font()
|
||||
display_text = self._get_display_text()
|
||||
padding = 10
|
||||
|
||||
# Clip text within input box bounds
|
||||
buffer = 2
|
||||
rl.begin_scissor_mode(int(rect.x + padding - buffer), int(rect.y), int(rect.width - padding * 2 + buffer * 2), int(rect.height))
|
||||
rl.draw_text_ex(
|
||||
font,
|
||||
display_text,
|
||||
rl.Vector2(int(rect.x + padding - self._text_offset), int(rect.y + rect.height / 2 - font_size / 2)),
|
||||
font_size,
|
||||
0,
|
||||
text_color,
|
||||
)
|
||||
|
||||
# Draw cursor
|
||||
if self._show_cursor:
|
||||
cursor_x = rect.x + padding
|
||||
if len(display_text) > 0 and self._cursor_position > 0:
|
||||
cursor_x += rl.measure_text_ex(font, display_text[: self._cursor_position], font_size, 0).x
|
||||
|
||||
# Apply text offset to cursor position
|
||||
cursor_x -= self._text_offset
|
||||
|
||||
cursor_height = font_size + 4
|
||||
cursor_y = rect.y + rect.height / 2 - cursor_height / 2
|
||||
rl.draw_line(int(cursor_x), int(cursor_y), int(cursor_x), int(cursor_y + cursor_height), rl.WHITE)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _get_display_text(self):
|
||||
"""Get text to display, applying password masking with delay if needed."""
|
||||
if not self._password_mode:
|
||||
return self._input_text
|
||||
|
||||
# Show character at last edited position if within delay window
|
||||
masked_text = PASSWORD_MASK_CHAR * len(self._input_text)
|
||||
recent_edit = time.time() - self._last_char_time < PASSWORD_MASK_DELAY
|
||||
if recent_edit and self._input_text:
|
||||
last_pos = max(0, self._cursor_position - 1)
|
||||
if last_pos < len(self._input_text):
|
||||
return masked_text[:last_pos] + self._input_text[last_pos] + masked_text[last_pos + 1 :]
|
||||
|
||||
return masked_text
|
||||
|
||||
def _handle_mouse_input(self, rect, font_size):
|
||||
"""Handle mouse clicks to position cursor."""
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON) and rl.check_collision_point_rec(mouse_pos, rect):
|
||||
# Calculate cursor position from click
|
||||
if len(self._input_text) > 0:
|
||||
font = gui_app.font()
|
||||
display_text = self._get_display_text()
|
||||
|
||||
# Find the closest character position to the click
|
||||
relative_x = mouse_pos.x - (rect.x + 10) + self._text_offset
|
||||
best_pos = 0
|
||||
min_distance = float('inf')
|
||||
|
||||
for i in range(len(self._input_text) + 1):
|
||||
char_width = rl.measure_text_ex(font, display_text[:i], font_size, 0).x
|
||||
distance = abs(relative_x - char_width)
|
||||
if distance < min_distance:
|
||||
min_distance = distance
|
||||
best_pos = i
|
||||
|
||||
self.set_cursor_position(best_pos)
|
||||
else:
|
||||
self.set_cursor_position(0)
|
||||
|
||||
def _handle_keyboard_input(self):
|
||||
# Handle navigation keys
|
||||
key = rl.get_key_pressed()
|
||||
if key != 0:
|
||||
self._process_key(key)
|
||||
if key in (rl.KEY_LEFT, rl.KEY_RIGHT, rl.KEY_BACKSPACE, rl.KEY_DELETE):
|
||||
self._last_key_pressed = key
|
||||
self._key_press_time = 0
|
||||
|
||||
# Handle repeats for held keys
|
||||
elif self._last_key_pressed != 0:
|
||||
if rl.is_key_down(self._last_key_pressed):
|
||||
self._key_press_time += 1
|
||||
if self._key_press_time > self._repeat_delay and self._key_press_time % self._repeat_rate == 0:
|
||||
self._process_key(self._last_key_pressed)
|
||||
else:
|
||||
self._last_key_pressed = 0
|
||||
|
||||
# Handle text input
|
||||
char = rl.get_char_pressed()
|
||||
if char != 0 and char >= 32: # Filter out control characters
|
||||
self.add_char_at_cursor(chr(char))
|
||||
|
||||
def _process_key(self, key):
|
||||
if key == rl.KEY_LEFT:
|
||||
if self._cursor_position > 0:
|
||||
self.set_cursor_position(self._cursor_position - 1)
|
||||
elif key == rl.KEY_RIGHT:
|
||||
if self._cursor_position < len(self._input_text):
|
||||
self.set_cursor_position(self._cursor_position + 1)
|
||||
elif key == rl.KEY_BACKSPACE:
|
||||
self.delete_char_before_cursor()
|
||||
elif key == rl.KEY_DELETE:
|
||||
self.delete_char_at_cursor()
|
||||
elif key == rl.KEY_HOME:
|
||||
self.set_cursor_position(0)
|
||||
elif key == rl.KEY_END:
|
||||
self.set_cursor_position(len(self._input_text))
|
||||
+27
-6
@@ -10,13 +10,27 @@ def gui_label(
|
||||
color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
elide_right: bool = True
|
||||
):
|
||||
# Set font based on the provided weight
|
||||
font = gui_app.font(font_weight)
|
||||
|
||||
# Measure text size
|
||||
text_size = rl.measure_text_ex(font, text, font_size, 0)
|
||||
display_text = text
|
||||
|
||||
# Elide text to fit within the rectangle
|
||||
if elide_right and text_size.x > rect.width:
|
||||
ellipsis = "..."
|
||||
left, right = 0, len(text)
|
||||
while left < right:
|
||||
mid = (left + right) // 2
|
||||
candidate = text[:mid] + ellipsis
|
||||
candidate_size = rl.measure_text_ex(font, candidate, font_size, 0)
|
||||
if candidate_size.x <= rect.width:
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid
|
||||
display_text = text[: left - 1] + ellipsis if left > 0 else ellipsis
|
||||
text_size = rl.measure_text_ex(font, display_text, font_size, 0)
|
||||
|
||||
# Calculate horizontal position based on alignment
|
||||
text_x = rect.x + {
|
||||
@@ -33,7 +47,7 @@ def gui_label(
|
||||
}.get(alignment_vertical, 0)
|
||||
|
||||
# Draw the text in the specified rectangle
|
||||
rl.draw_text_ex(font, text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
||||
rl.draw_text_ex(font, display_text, rl.Vector2(text_x, text_y), font_size, 0, color)
|
||||
|
||||
|
||||
def gui_text_box(
|
||||
@@ -42,7 +56,8 @@ def gui_text_box(
|
||||
font_size: int = DEFAULT_TEXT_SIZE,
|
||||
color: rl.Color = DEFAULT_TEXT_COLOR,
|
||||
alignment: int = rl.GuiTextAlignment.TEXT_ALIGN_LEFT,
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP
|
||||
alignment_vertical: int = rl.GuiTextAlignmentVertical.TEXT_ALIGN_TOP,
|
||||
font_weight: FontWeight = FontWeight.NORMAL,
|
||||
):
|
||||
styles = [
|
||||
(rl.GuiControl.DEFAULT, rl.GuiControlProperty.TEXT_COLOR_NORMAL, rl.color_to_int(color)),
|
||||
@@ -52,6 +67,12 @@ def gui_text_box(
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_ALIGNMENT_VERTICAL, alignment_vertical),
|
||||
(rl.GuiControl.DEFAULT, rl.GuiDefaultProperty.TEXT_WRAP_MODE, rl.GuiTextWrapMode.TEXT_WRAP_WORD)
|
||||
]
|
||||
if font_weight != FontWeight.NORMAL:
|
||||
rl.gui_set_font(gui_app.font(font_weight))
|
||||
|
||||
with GuiStyleContext(styles):
|
||||
rl.gui_label(rect, text)
|
||||
|
||||
if font_weight != FontWeight.NORMAL:
|
||||
rl.gui_set_font(gui_app.font(FontWeight.NORMAL))
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import os
|
||||
import pyray as rl
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Callable
|
||||
from abc import ABC, abstractmethod
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
from openpilot.system.ui.lib.wrap_text import wrap_text
|
||||
from openpilot.system.ui.lib.button import gui_button
|
||||
from openpilot.system.ui.lib.toggle import Toggle
|
||||
from openpilot.system.ui.lib.toggle import WIDTH as TOGGLE_WIDTH, HEIGHT as TOGGLE_HEIGHT
|
||||
|
||||
|
||||
LINE_PADDING = 40
|
||||
LINE_COLOR = rl.GRAY
|
||||
ITEM_PADDING = 20
|
||||
ITEM_SPACING = 80
|
||||
ITEM_BASE_HEIGHT = 170
|
||||
ITEM_TEXT_FONT_SIZE = 50
|
||||
ITEM_TEXT_COLOR = rl.WHITE
|
||||
ITEM_DESC_TEXT_COLOR = rl.Color(128, 128, 128, 255)
|
||||
ITEM_DESC_FONT_SIZE = 40
|
||||
ITEM_DESC_V_OFFSET = 130
|
||||
RIGHT_ITEM_PADDING = 20
|
||||
ICON_SIZE = 80
|
||||
BUTTON_WIDTH = 250
|
||||
BUTTON_HEIGHT = 100
|
||||
BUTTON_BORDER_RADIUS = 50
|
||||
BUTTON_FONT_SIZE = 35
|
||||
BUTTON_FONT_WEIGHT = FontWeight.MEDIUM
|
||||
|
||||
|
||||
# Abstract base class for right-side items
|
||||
class RightItem(ABC):
|
||||
def __init__(self, width: int = 100):
|
||||
self.width = width
|
||||
self.enabled = True
|
||||
|
||||
@abstractmethod
|
||||
def draw(self, rect: rl.Rectangle) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_width(self) -> int:
|
||||
pass
|
||||
|
||||
|
||||
class ToggleRightItem(RightItem):
|
||||
def __init__(self, initial_state: bool = False, width: int = TOGGLE_WIDTH):
|
||||
super().__init__(width)
|
||||
self.toggle = Toggle(initial_state=initial_state)
|
||||
self.state = initial_state
|
||||
self.enabled = True
|
||||
|
||||
def draw(self, rect: rl.Rectangle) -> bool:
|
||||
if self.toggle.render(rl.Rectangle(rect.x, rect.y + (rect.height - TOGGLE_HEIGHT) / 2, self.width, TOGGLE_HEIGHT)):
|
||||
self.state = not self.state
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_width(self) -> int:
|
||||
return self.width
|
||||
|
||||
def set_state(self, state: bool):
|
||||
self.state = state
|
||||
self.toggle.set_state(state)
|
||||
|
||||
def get_state(self) -> bool:
|
||||
return self.state
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
self.enabled = enabled
|
||||
|
||||
|
||||
class ButtonRightItem(RightItem):
|
||||
def __init__(self, text: str, width: int = BUTTON_WIDTH):
|
||||
super().__init__(width)
|
||||
self.text = text
|
||||
self.enabled = True
|
||||
|
||||
def draw(self, rect: rl.Rectangle) -> bool:
|
||||
return (
|
||||
gui_button(
|
||||
rl.Rectangle(rect.x, rect.y + (rect.height - BUTTON_HEIGHT) / 2, BUTTON_WIDTH, BUTTON_HEIGHT),
|
||||
self.text,
|
||||
border_radius=BUTTON_BORDER_RADIUS,
|
||||
font_weight=BUTTON_FONT_WEIGHT,
|
||||
font_size=BUTTON_FONT_SIZE,
|
||||
is_enabled=self.enabled,
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
def get_width(self) -> int:
|
||||
return self.width
|
||||
|
||||
def set_enabled(self, enabled: bool):
|
||||
self.enabled = enabled
|
||||
|
||||
|
||||
class TextRightItem(RightItem):
|
||||
def __init__(self, text: str, color: rl.Color = ITEM_TEXT_COLOR, font_size: int = ITEM_TEXT_FONT_SIZE):
|
||||
self.text = text
|
||||
self.color = color
|
||||
self.font_size = font_size
|
||||
|
||||
font = gui_app.font(FontWeight.NORMAL)
|
||||
text_width = measure_text_cached(font, text, font_size).x
|
||||
super().__init__(int(text_width + 20))
|
||||
|
||||
def draw(self, rect: rl.Rectangle) -> bool:
|
||||
font = gui_app.font(FontWeight.NORMAL)
|
||||
text_size = measure_text_cached(font, self.text, self.font_size)
|
||||
|
||||
# Center the text in the allocated rectangle
|
||||
text_x = rect.x + (rect.width - text_size.x) / 2
|
||||
text_y = rect.y + (rect.height - text_size.y) / 2
|
||||
|
||||
rl.draw_text_ex(font, self.text, rl.Vector2(text_x, text_y), self.font_size, 0, self.color)
|
||||
return False
|
||||
|
||||
def get_width(self) -> int:
|
||||
return self.width
|
||||
|
||||
def set_text(self, text: str):
|
||||
self.text = text
|
||||
font = gui_app.font(FontWeight.NORMAL)
|
||||
text_width = measure_text_cached(font, text, self.font_size).x
|
||||
self.width = int(text_width + 20)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListItem:
|
||||
title: str
|
||||
icon: str | None = None
|
||||
description: str | None = None
|
||||
description_visible: bool = False
|
||||
rect: "rl.Rectangle | None" = None
|
||||
callback: Callable | None = None
|
||||
right_item: RightItem | None = None
|
||||
|
||||
# Cached properties for performance
|
||||
_wrapped_description: str | None = None
|
||||
_description_height: float = 0
|
||||
|
||||
def get_right_item(self) -> RightItem | None:
|
||||
return self.right_item
|
||||
|
||||
def get_item_height(self, font: rl.Font, max_width: int) -> float:
|
||||
if self.description_visible and self.description:
|
||||
if not self._wrapped_description:
|
||||
wrapped_lines = wrap_text(font, self.description, ITEM_DESC_FONT_SIZE, max_width)
|
||||
self._wrapped_description = "\n".join(wrapped_lines)
|
||||
self._description_height = len(wrapped_lines) * 20 + 10 # Line height + padding
|
||||
return ITEM_BASE_HEIGHT + self._description_height - (ITEM_BASE_HEIGHT - ITEM_DESC_V_OFFSET) + ITEM_SPACING
|
||||
return ITEM_BASE_HEIGHT
|
||||
|
||||
def get_content_width(self, total_width: int) -> int:
|
||||
if self.right_item:
|
||||
return total_width - self.right_item.get_width() - RIGHT_ITEM_PADDING
|
||||
return total_width
|
||||
|
||||
def get_right_item_rect(self, item_rect: rl.Rectangle) -> rl.Rectangle:
|
||||
if not self.right_item:
|
||||
return rl.Rectangle(0, 0, 0, 0)
|
||||
|
||||
right_width = self.right_item.get_width()
|
||||
right_x = item_rect.x + item_rect.width - right_width
|
||||
right_y = item_rect.y
|
||||
return rl.Rectangle(right_x, right_y, right_width, ITEM_BASE_HEIGHT)
|
||||
|
||||
|
||||
class ListView:
|
||||
def __init__(self, items: list[ListItem]):
|
||||
self._items: list[ListItem] = items
|
||||
self._last_dim: tuple[float, float] = (0, 0)
|
||||
self.scroll_panel = GuiScrollPanel()
|
||||
|
||||
self._font_normal = gui_app.font(FontWeight.NORMAL)
|
||||
|
||||
# Interaction state
|
||||
self._hovered_item: int = -1
|
||||
self._last_mouse_pos = rl.Vector2(0, 0)
|
||||
|
||||
self._total_height: float = 0
|
||||
self._visible_range = (0, 0)
|
||||
|
||||
def invalid_height_cache(self):
|
||||
self._last_dim = (0, 0)
|
||||
|
||||
def render(self, rect: rl.Rectangle):
|
||||
if self._last_dim != (rect.width, rect.height):
|
||||
self._update_item_rects(rect)
|
||||
self._last_dim = (rect.width, rect.height)
|
||||
|
||||
# Update layout and handle scrolling
|
||||
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, self._total_height)
|
||||
scroll_offset = self.scroll_panel.handle_scroll(rect, content_rect)
|
||||
|
||||
# Handle mouse interaction
|
||||
if self.scroll_panel.is_click_valid():
|
||||
self._handle_mouse_interaction(rect, scroll_offset)
|
||||
|
||||
# Set scissor mode for clipping
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
|
||||
# Calculate visible range for performance
|
||||
self._calculate_visible_range(rect, -scroll_offset.y)
|
||||
|
||||
# Render only visible items
|
||||
for i in range(self._visible_range[0], min(self._visible_range[1], len(self._items))):
|
||||
item = self._items[i]
|
||||
if item.rect:
|
||||
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
|
||||
self._render_item(item, adjusted_rect, i)
|
||||
|
||||
if i != len(self._items) - 1:
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(adjusted_rect.x + LINE_PADDING, adjusted_rect.y + adjusted_rect.height - 1),
|
||||
rl.Vector2(
|
||||
adjusted_rect.x + adjusted_rect.width - LINE_PADDING * 2, adjusted_rect.y + adjusted_rect.height - 1
|
||||
),
|
||||
1.0,
|
||||
LINE_COLOR,
|
||||
)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _render_item(self, item: ListItem, rect: rl.Rectangle, index: int):
|
||||
content_x = rect.x + ITEM_PADDING
|
||||
text_x = content_x
|
||||
|
||||
# Calculate available width for main content
|
||||
content_width = item.get_content_width(int(rect.width - ITEM_PADDING * 2))
|
||||
|
||||
# Draw icon if present
|
||||
if item.icon:
|
||||
icon_texture = gui_app.texture(os.path.join("icons", item.icon), ICON_SIZE, ICON_SIZE)
|
||||
rl.draw_texture(
|
||||
icon_texture, int(content_x), int(rect.y + (ITEM_BASE_HEIGHT - icon_texture.width) // 2), rl.WHITE
|
||||
)
|
||||
text_x += ICON_SIZE + ITEM_PADDING
|
||||
|
||||
# Draw main text
|
||||
text_size = measure_text_cached(self._font_normal, item.title, ITEM_TEXT_FONT_SIZE)
|
||||
item_y = rect.y + (ITEM_BASE_HEIGHT - text_size.y) // 2
|
||||
rl.draw_text_ex(self._font_normal, item.title, rl.Vector2(text_x, item_y), ITEM_TEXT_FONT_SIZE, 0, ITEM_TEXT_COLOR)
|
||||
|
||||
# Draw description if visible (adjust width for right item)
|
||||
if item.description_visible and item._wrapped_description:
|
||||
desc_y = rect.y + ITEM_DESC_V_OFFSET
|
||||
desc_max_width = int(content_width - (text_x - content_x))
|
||||
|
||||
# Re-wrap description if needed due to right item
|
||||
if (item.right_item and item.description) and not item._wrapped_description:
|
||||
wrapped_lines = wrap_text(self._font_normal, item.description, ITEM_DESC_FONT_SIZE, desc_max_width)
|
||||
item._wrapped_description = "\n".join(wrapped_lines)
|
||||
|
||||
rl.draw_text_ex(
|
||||
self._font_normal,
|
||||
item._wrapped_description,
|
||||
rl.Vector2(text_x, desc_y),
|
||||
ITEM_DESC_FONT_SIZE,
|
||||
0,
|
||||
ITEM_DESC_TEXT_COLOR,
|
||||
)
|
||||
|
||||
# Draw right item if present
|
||||
if item.right_item:
|
||||
right_rect = item.get_right_item_rect(rect)
|
||||
# Adjust for scroll offset
|
||||
right_rect.y = right_rect.y
|
||||
if item.right_item.draw(right_rect):
|
||||
# Right item was clicked/activated
|
||||
if item.callback:
|
||||
item.callback()
|
||||
|
||||
def _update_item_rects(self, container_rect: rl.Rectangle) -> None:
|
||||
current_y: float = 0.0
|
||||
self._total_height = 0
|
||||
|
||||
for item in self._items:
|
||||
content_width = item.get_content_width(int(container_rect.width - ITEM_PADDING * 2))
|
||||
item_height = item.get_item_height(self._font_normal, content_width)
|
||||
item.rect = rl.Rectangle(container_rect.x, container_rect.y + current_y, container_rect.width, item_height)
|
||||
current_y += item_height
|
||||
self._total_height += item_height
|
||||
|
||||
def _calculate_visible_range(self, rect: rl.Rectangle, scroll_offset: float):
|
||||
if not self._items:
|
||||
self._visible_range = (0, 0)
|
||||
return
|
||||
|
||||
visible_top = scroll_offset
|
||||
visible_bottom = scroll_offset + rect.height
|
||||
|
||||
start_idx = 0
|
||||
end_idx = len(self._items)
|
||||
|
||||
# Find first visible item
|
||||
for i, item in enumerate(self._items):
|
||||
if item.rect and item.rect.y + item.rect.height >= visible_top:
|
||||
start_idx = max(0, i - 1)
|
||||
break
|
||||
|
||||
# Find last visible item
|
||||
for i in range(start_idx, len(self._items)):
|
||||
item = self._items[i]
|
||||
if item.rect and item.rect.y > visible_bottom:
|
||||
end_idx = min(len(self._items), i + 2)
|
||||
break
|
||||
|
||||
self._visible_range = (start_idx, end_idx)
|
||||
|
||||
def _handle_mouse_interaction(self, rect: rl.Rectangle, scroll_offset: rl.Vector2):
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
|
||||
self._hovered_item = -1
|
||||
if not rl.check_collision_point_rec(mouse_pos, rect):
|
||||
return
|
||||
|
||||
content_mouse_y = mouse_pos.y - rect.y - scroll_offset.y
|
||||
|
||||
for i, item in enumerate(self._items):
|
||||
if item.rect:
|
||||
# Check if mouse is within this item's bounds in content space
|
||||
if (
|
||||
mouse_pos.x >= rect.x
|
||||
and mouse_pos.x <= rect.x + rect.width
|
||||
and content_mouse_y >= item.rect.y
|
||||
and content_mouse_y <= item.rect.y + item.rect.height
|
||||
):
|
||||
item_screen_y = item.rect.y + scroll_offset.y
|
||||
if item_screen_y < rect.height and item_screen_y + item.rect.height > 0:
|
||||
self._hovered_item = i
|
||||
break
|
||||
|
||||
# Handle click on main item (not right item)
|
||||
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT) and self._hovered_item >= 0:
|
||||
item = self._items[self._hovered_item]
|
||||
|
||||
# Check if click was on right item area
|
||||
if item.right_item and item.rect:
|
||||
adjusted_rect = rl.Rectangle(item.rect.x, item.rect.y + scroll_offset.y, item.rect.width, item.rect.height)
|
||||
right_rect = item.get_right_item_rect(adjusted_rect)
|
||||
if rl.check_collision_point_rec(mouse_pos, right_rect):
|
||||
# Click was handled by right item, don't process main item click
|
||||
return
|
||||
|
||||
# Toggle description visibility if item has description
|
||||
if item.description:
|
||||
item.description_visible = not item.description_visible
|
||||
# Force layout update when description visibility changes
|
||||
self._last_dim = (0, 0)
|
||||
|
||||
# Call item callback
|
||||
if item.callback:
|
||||
item.callback()
|
||||
|
||||
|
||||
# Factory functions
|
||||
def simple_item(title: str, callback: Callable | None = None) -> ListItem:
|
||||
return ListItem(title=title, callback=callback)
|
||||
|
||||
|
||||
def toggle_item(
|
||||
title: str, description: str = None, initial_state: bool = False, callback: Callable | None = None, icon: str = ""
|
||||
) -> ListItem:
|
||||
toggle = ToggleRightItem(initial_state=initial_state)
|
||||
return ListItem(title=title, description=description, right_item=toggle, icon=icon, callback=callback)
|
||||
|
||||
|
||||
def button_item(title: str, button_text: str, description: str = None, callback: Callable | None = None) -> ListItem:
|
||||
button = ButtonRightItem(text=button_text)
|
||||
return ListItem(title=title, description=description, right_item=button, callback=callback)
|
||||
|
||||
|
||||
def text_item(title: str, value: str, description: str = None, callback: Callable | None = None) -> ListItem:
|
||||
text_item = TextRightItem(text=value, color=rl.Color(170, 170, 170, 255))
|
||||
return ListItem(title=title, description=description, right_item=text_item, callback=callback)
|
||||
+146
-25
@@ -1,32 +1,61 @@
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
|
||||
# Scroll constants for smooth scrolling behavior
|
||||
MOUSE_WHEEL_SCROLL_SPEED = 30
|
||||
INERTIA_FRICTION = 0.95 # The rate at which the inertia slows down
|
||||
MIN_VELOCITY = 0.1 # Minimum velocity before stopping the inertia
|
||||
INERTIA_FRICTION = 0.92 # The rate at which the inertia slows down
|
||||
MIN_VELOCITY = 0.5 # Minimum velocity before stopping the inertia
|
||||
DRAG_THRESHOLD = 5 # Pixels of movement to consider it a drag, not a click
|
||||
BOUNCE_FACTOR = 0.2 # Elastic bounce when scrolling past boundaries
|
||||
BOUNCE_RETURN_SPEED = 0.15 # How quickly it returns from the bounce
|
||||
MAX_BOUNCE_DISTANCE = 150 # Maximum distance for bounce effect
|
||||
FLICK_MULTIPLIER = 1.8 # Multiplier for flick gestures
|
||||
VELOCITY_HISTORY_SIZE = 5 # Track velocity over multiple frames for smoother motion
|
||||
|
||||
|
||||
class ScrollState(IntEnum):
|
||||
IDLE = 0
|
||||
DRAGGING_CONTENT = 1
|
||||
DRAGGING_SCROLLBAR = 2
|
||||
BOUNCING = 3
|
||||
|
||||
|
||||
class GuiScrollPanel:
|
||||
def __init__(self, show_vertical_scroll_bar: bool = False):
|
||||
self._scroll_state: ScrollState = ScrollState.IDLE
|
||||
self._last_mouse_y: float = 0.0
|
||||
self._start_mouse_y: float = 0.0 # Track the initial mouse position for drag detection
|
||||
self._offset = rl.Vector2(0, 0)
|
||||
self._view = rl.Rectangle(0, 0, 0, 0)
|
||||
self._show_vertical_scroll_bar: bool = show_vertical_scroll_bar
|
||||
self._velocity_y = 0.0 # Velocity for inertia
|
||||
self._is_dragging: bool = False
|
||||
self._bounce_offset: float = 0.0
|
||||
self._last_frame_time = rl.get_time()
|
||||
self._velocity_history: list[float] = []
|
||||
self._last_drag_time: float = 0.0
|
||||
self._content_rect: rl.Rectangle | None = None
|
||||
self._bounds_rect: rl.Rectangle | None = None
|
||||
|
||||
def handle_scroll(self, bounds: rl.Rectangle, content: rl.Rectangle) -> rl.Vector2:
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
# Store rectangles for reference
|
||||
self._content_rect = content
|
||||
self._bounds_rect = bounds
|
||||
|
||||
# Handle dragging logic
|
||||
# Calculate time delta
|
||||
current_time = rl.get_time()
|
||||
delta_time = current_time - self._last_frame_time
|
||||
self._last_frame_time = current_time
|
||||
|
||||
# Prevent large jumps
|
||||
delta_time = min(delta_time, 0.05)
|
||||
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
max_scroll_y = max(content.height - bounds.height, 0)
|
||||
|
||||
# Start dragging on mouse press
|
||||
if rl.check_collision_point_rec(mouse_pos, bounds) and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
if self._scroll_state == ScrollState.IDLE:
|
||||
if self._scroll_state == ScrollState.IDLE or self._scroll_state == ScrollState.BOUNCING:
|
||||
self._scroll_state = ScrollState.DRAGGING_CONTENT
|
||||
if self._show_vertical_scroll_bar:
|
||||
scrollbar_width = rl.gui_get_style(rl.GuiControl.LISTVIEW, rl.GuiListViewProperty.SCROLLBAR_WIDTH)
|
||||
@@ -35,41 +64,133 @@ class GuiScrollPanel:
|
||||
self._scroll_state = ScrollState.DRAGGING_SCROLLBAR
|
||||
|
||||
self._last_mouse_y = mouse_pos.y
|
||||
self._velocity_y = 0.0 # Reset velocity when drag starts
|
||||
self._start_mouse_y = mouse_pos.y
|
||||
self._last_drag_time = current_time
|
||||
self._velocity_history = []
|
||||
self._velocity_y = 0.0
|
||||
self._bounce_offset = 0.0
|
||||
self._is_dragging = False
|
||||
|
||||
if self._scroll_state != ScrollState.IDLE:
|
||||
# Handle active dragging
|
||||
if self._scroll_state == ScrollState.DRAGGING_CONTENT or self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
|
||||
if rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
delta_y = mouse_pos.y - self._last_mouse_y
|
||||
|
||||
# Track velocity for inertia
|
||||
time_since_last_drag = current_time - self._last_drag_time
|
||||
if time_since_last_drag > 0:
|
||||
drag_velocity = delta_y / time_since_last_drag / 60.0
|
||||
self._velocity_history.append(drag_velocity)
|
||||
|
||||
if len(self._velocity_history) > VELOCITY_HISTORY_SIZE:
|
||||
self._velocity_history.pop(0)
|
||||
|
||||
self._last_drag_time = current_time
|
||||
|
||||
# Detect actual dragging
|
||||
total_drag = abs(mouse_pos.y - self._start_mouse_y)
|
||||
if total_drag > DRAG_THRESHOLD:
|
||||
self._is_dragging = True
|
||||
|
||||
if self._scroll_state == ScrollState.DRAGGING_CONTENT:
|
||||
# Add resistance at boundaries
|
||||
if (self._offset.y > 0 and delta_y > 0) or (self._offset.y < -max_scroll_y and delta_y < 0):
|
||||
delta_y *= BOUNCE_FACTOR
|
||||
|
||||
self._offset.y += delta_y
|
||||
else:
|
||||
delta_y = -delta_y
|
||||
elif self._scroll_state == ScrollState.DRAGGING_SCROLLBAR:
|
||||
scroll_ratio = content.height / bounds.height
|
||||
self._offset.y -= delta_y * scroll_ratio
|
||||
|
||||
self._last_mouse_y = mouse_pos.y
|
||||
self._velocity_y = delta_y # Update velocity during drag
|
||||
else:
|
||||
self._scroll_state = ScrollState.IDLE
|
||||
|
||||
# Handle mouse wheel scrolling
|
||||
elif rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
# Calculate flick velocity
|
||||
if self._velocity_history:
|
||||
total_weight = 0
|
||||
weighted_velocity = 0.0
|
||||
|
||||
for i, v in enumerate(self._velocity_history):
|
||||
weight = i + 1
|
||||
weighted_velocity += v * weight
|
||||
total_weight += weight
|
||||
|
||||
if total_weight > 0:
|
||||
avg_velocity = weighted_velocity / total_weight
|
||||
self._velocity_y = avg_velocity * FLICK_MULTIPLIER
|
||||
|
||||
# Check bounds
|
||||
if self._offset.y > 0 or self._offset.y < -max_scroll_y:
|
||||
self._scroll_state = ScrollState.BOUNCING
|
||||
else:
|
||||
self._scroll_state = ScrollState.IDLE
|
||||
|
||||
# Handle mouse wheel
|
||||
wheel_move = rl.get_mouse_wheel_move()
|
||||
if self._show_vertical_scroll_bar:
|
||||
self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20)
|
||||
rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view)
|
||||
else:
|
||||
self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED
|
||||
if wheel_move != 0:
|
||||
self._velocity_y = 0.0
|
||||
|
||||
if self._show_vertical_scroll_bar:
|
||||
self._offset.y += wheel_move * (MOUSE_WHEEL_SCROLL_SPEED - 20)
|
||||
rl.gui_scroll_panel(bounds, rl.ffi.NULL, content, self._offset, self._view)
|
||||
else:
|
||||
self._offset.y += wheel_move * MOUSE_WHEEL_SCROLL_SPEED
|
||||
|
||||
if self._offset.y > 0 or self._offset.y < -max_scroll_y:
|
||||
self._scroll_state = ScrollState.BOUNCING
|
||||
|
||||
# Apply inertia (continue scrolling after mouse release)
|
||||
if self._scroll_state == ScrollState.IDLE:
|
||||
self._offset.y += self._velocity_y
|
||||
self._velocity_y *= INERTIA_FRICTION # Slow down velocity over time
|
||||
if abs(self._velocity_y) > MIN_VELOCITY:
|
||||
self._offset.y += self._velocity_y
|
||||
self._velocity_y *= INERTIA_FRICTION
|
||||
|
||||
# Stop scrolling when velocity is low
|
||||
if abs(self._velocity_y) < MIN_VELOCITY:
|
||||
if self._offset.y > 0 or self._offset.y < -max_scroll_y:
|
||||
self._scroll_state = ScrollState.BOUNCING
|
||||
else:
|
||||
self._velocity_y = 0.0
|
||||
|
||||
# Ensure scrolling doesn't go beyond bounds
|
||||
max_scroll_y = max(content.height - bounds.height, 0)
|
||||
self._offset.y = max(min(self._offset.y, 0), -max_scroll_y)
|
||||
# Handle bouncing effect
|
||||
elif self._scroll_state == ScrollState.BOUNCING:
|
||||
target_y = 0.0
|
||||
if self._offset.y < -max_scroll_y:
|
||||
target_y = -max_scroll_y
|
||||
|
||||
distance = target_y - self._offset.y
|
||||
bounce_step = distance * BOUNCE_RETURN_SPEED
|
||||
self._offset.y += bounce_step
|
||||
self._velocity_y *= INERTIA_FRICTION * 0.8
|
||||
|
||||
if abs(distance) < 0.5 and abs(self._velocity_y) < MIN_VELOCITY:
|
||||
self._offset.y = target_y
|
||||
self._velocity_y = 0.0
|
||||
self._scroll_state = ScrollState.IDLE
|
||||
|
||||
# Limit bounce distance
|
||||
if self._scroll_state != ScrollState.DRAGGING_CONTENT:
|
||||
if self._offset.y > MAX_BOUNCE_DISTANCE:
|
||||
self._offset.y = MAX_BOUNCE_DISTANCE
|
||||
elif self._offset.y < -(max_scroll_y + MAX_BOUNCE_DISTANCE):
|
||||
self._offset.y = -(max_scroll_y + MAX_BOUNCE_DISTANCE)
|
||||
|
||||
return self._offset
|
||||
|
||||
def is_click_valid(self) -> bool:
|
||||
# Check if this is a click rather than a drag
|
||||
return (
|
||||
self._scroll_state == ScrollState.IDLE
|
||||
and not self._is_dragging
|
||||
and rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT)
|
||||
)
|
||||
|
||||
def get_normalized_scroll_position(self) -> float:
|
||||
"""Returns the current scroll position as a value from 0.0 to 1.0"""
|
||||
if not self._content_rect or not self._bounds_rect:
|
||||
return 0.0
|
||||
|
||||
max_scroll_y = max(self._content_rect.height - self._bounds_rect.height, 0)
|
||||
if max_scroll_y == 0:
|
||||
return 0.0
|
||||
|
||||
normalized = -self._offset.y / max_scroll_y
|
||||
return max(0.0, min(1.0, normalized))
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import pyray as rl
|
||||
import numpy as np
|
||||
from typing import Any
|
||||
|
||||
MAX_GRADIENT_COLORS = 15
|
||||
|
||||
FRAGMENT_SHADER = """
|
||||
#version 300 es
|
||||
precision mediump float;
|
||||
|
||||
in vec2 fragTexCoord;
|
||||
out vec4 finalColor;
|
||||
|
||||
uniform vec2 points[100];
|
||||
uniform int pointCount;
|
||||
uniform vec4 fillColor;
|
||||
uniform vec2 resolution;
|
||||
|
||||
uniform int useGradient;
|
||||
uniform vec2 gradientStart;
|
||||
uniform vec2 gradientEnd;
|
||||
uniform vec4 gradientColors[15];
|
||||
uniform float gradientStops[15];
|
||||
uniform int gradientColorCount;
|
||||
|
||||
vec4 getGradientColor(vec2 pos) {
|
||||
vec2 gradientDir = gradientEnd - gradientStart;
|
||||
float gradientLength = length(gradientDir);
|
||||
if (gradientLength < 0.001) return gradientColors[0];
|
||||
|
||||
vec2 normalizedDir = gradientDir / gradientLength;
|
||||
float t = clamp(dot(pos - gradientStart, normalizedDir) / gradientLength, 0.0, 1.0);
|
||||
|
||||
if (gradientColorCount <= 1) return gradientColors[0];
|
||||
for (int i = 0; i < gradientColorCount - 1; i++) {
|
||||
if (t >= gradientStops[i] && t <= gradientStops[i+1]) {
|
||||
float segmentT = (t - gradientStops[i]) / (gradientStops[i+1] - gradientStops[i]);
|
||||
return mix(gradientColors[i], gradientColors[i+1], segmentT);
|
||||
}
|
||||
}
|
||||
|
||||
return gradientColors[gradientColorCount-1];
|
||||
}
|
||||
|
||||
bool isPointInsidePolygon(vec2 p) {
|
||||
if (pointCount < 3) return false;
|
||||
int crossings = 0;
|
||||
for (int i = 0, j = pointCount - 1; i < pointCount; j = i++) {
|
||||
vec2 pi = points[i];
|
||||
vec2 pj = points[j];
|
||||
if (distance(pi, pj) < 0.001) continue;
|
||||
if (((pi.y > p.y) != (pj.y > p.y)) &&
|
||||
(p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y + 0.001) + pi.x)) {
|
||||
crossings++;
|
||||
}
|
||||
}
|
||||
return (crossings & 1) == 1;
|
||||
}
|
||||
|
||||
float distanceToEdge(vec2 p) {
|
||||
float minDist = 1000.0;
|
||||
|
||||
for (int i = 0, j = pointCount - 1; i < pointCount; j = i++) {
|
||||
vec2 edge0 = points[j];
|
||||
vec2 edge1 = points[i];
|
||||
|
||||
if (distance(edge0, edge1) < 0.0001) continue;
|
||||
|
||||
vec2 v1 = p - edge0;
|
||||
vec2 v2 = edge1 - edge0;
|
||||
float l2 = dot(v2, v2);
|
||||
|
||||
if (l2 < 0.0001) {
|
||||
float dist = length(v1);
|
||||
minDist = min(minDist, dist);
|
||||
continue;
|
||||
}
|
||||
|
||||
float t = clamp(dot(v1, v2) / l2, 0.0, 1.0);
|
||||
vec2 projection = edge0 + t * v2;
|
||||
float dist = length(p - projection);
|
||||
minDist = min(minDist, dist);
|
||||
}
|
||||
|
||||
return minDist;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 pixel = fragTexCoord * resolution;
|
||||
|
||||
// Compute pixel size for anti-aliasing
|
||||
vec2 pixelGrad = vec2(dFdx(pixel.x), dFdy(pixel.y));
|
||||
float pixelSize = length(pixelGrad);
|
||||
float aaWidth = max(0.5, pixelSize * 1.5);
|
||||
|
||||
bool inside = isPointInsidePolygon(pixel);
|
||||
if (inside) {
|
||||
finalColor = useGradient == 1 ? getGradientColor(pixel) : fillColor;
|
||||
return;
|
||||
}
|
||||
|
||||
float sd = -distanceToEdge(pixel);
|
||||
float alpha = smoothstep(-aaWidth, aaWidth, sd);
|
||||
if (alpha > 0.0){
|
||||
vec4 color = useGradient == 1 ? getGradientColor(pixel) : fillColor;
|
||||
finalColor = vec4(color.rgb, color.a * alpha);
|
||||
} else {
|
||||
finalColor = vec4(0.0);
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# Default vertex shader
|
||||
VERTEX_SHADER = """
|
||||
#version 300 es
|
||||
in vec3 vertexPosition;
|
||||
in vec2 vertexTexCoord;
|
||||
out vec2 fragTexCoord;
|
||||
uniform mat4 mvp;
|
||||
|
||||
void main() {
|
||||
fragTexCoord = vertexTexCoord;
|
||||
gl_Position = mvp * vec4(vertexPosition, 1.0);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
UNIFORM_INT = rl.ShaderUniformDataType.SHADER_UNIFORM_INT
|
||||
UNIFORM_FLOAT = rl.ShaderUniformDataType.SHADER_UNIFORM_FLOAT
|
||||
UNIFORM_VEC2 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC2
|
||||
UNIFORM_VEC4 = rl.ShaderUniformDataType.SHADER_UNIFORM_VEC4
|
||||
|
||||
|
||||
class ShaderState:
|
||||
_instance: Any = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
if ShaderState._instance is not None:
|
||||
raise Exception("This class is a singleton. Use get_instance() instead.")
|
||||
|
||||
self.initialized = False
|
||||
self.shader = None
|
||||
self.white_texture = None
|
||||
|
||||
# Shader uniform locations
|
||||
self.locations = {
|
||||
'pointCount': None,
|
||||
'fillColor': None,
|
||||
'resolution': None,
|
||||
'points': None,
|
||||
'useGradient': None,
|
||||
'gradientStart': None,
|
||||
'gradientEnd': None,
|
||||
'gradientColors': None,
|
||||
'gradientStops': None,
|
||||
'gradientColorCount': None,
|
||||
'mvp': None,
|
||||
}
|
||||
|
||||
# Pre-allocated FFI objects
|
||||
self.point_count_ptr = rl.ffi.new("int[]", [0])
|
||||
self.resolution_ptr = rl.ffi.new("float[]", [0.0, 0.0])
|
||||
self.fill_color_ptr = rl.ffi.new("float[]", [0.0, 0.0, 0.0, 0.0])
|
||||
self.use_gradient_ptr = rl.ffi.new("int[]", [0])
|
||||
self.gradient_start_ptr = rl.ffi.new("float[]", [0.0, 0.0])
|
||||
self.gradient_end_ptr = rl.ffi.new("float[]", [0.0, 0.0])
|
||||
self.color_count_ptr = rl.ffi.new("int[]", [0])
|
||||
self.gradient_colors_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS * 4)
|
||||
self.gradient_stops_ptr = rl.ffi.new("float[]", MAX_GRADIENT_COLORS)
|
||||
|
||||
def initialize(self):
|
||||
if self.initialized:
|
||||
return
|
||||
|
||||
self.shader = rl.load_shader_from_memory(VERTEX_SHADER, FRAGMENT_SHADER)
|
||||
|
||||
# Create and cache white texture
|
||||
white_img = rl.gen_image_color(2, 2, rl.WHITE)
|
||||
self.white_texture = rl.load_texture_from_image(white_img)
|
||||
rl.set_texture_filter(self.white_texture, rl.TEXTURE_FILTER_BILINEAR)
|
||||
rl.unload_image(white_img)
|
||||
|
||||
# Cache all uniform locations
|
||||
for uniform in self.locations.keys():
|
||||
self.locations[uniform] = rl.get_shader_location(self.shader, uniform)
|
||||
|
||||
# Setup default MVP matrix
|
||||
mvp_ptr = rl.ffi.new("float[16]", [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0])
|
||||
rl.set_shader_value_matrix(self.shader, self.locations['mvp'], rl.Matrix(*mvp_ptr))
|
||||
|
||||
self.initialized = True
|
||||
|
||||
def cleanup(self):
|
||||
if not self.initialized:
|
||||
return
|
||||
|
||||
if self.white_texture:
|
||||
rl.unload_texture(self.white_texture)
|
||||
self.white_texture = None
|
||||
|
||||
if self.shader:
|
||||
rl.unload_shader(self.shader)
|
||||
self.shader = None
|
||||
|
||||
self.initialized = False
|
||||
|
||||
|
||||
def _configure_shader_color(state, color, gradient, clipped_rect, original_rect):
|
||||
use_gradient = 1 if gradient else 0
|
||||
state.use_gradient_ptr[0] = use_gradient
|
||||
rl.set_shader_value(state.shader, state.locations['useGradient'], state.use_gradient_ptr, UNIFORM_INT)
|
||||
|
||||
if use_gradient:
|
||||
start = np.array(gradient['start']) * np.array([original_rect.width, original_rect.height]) + np.array([original_rect.x, original_rect.y])
|
||||
end = np.array(gradient['end']) * np.array([original_rect.width, original_rect.height]) + np.array([original_rect.x, original_rect.y])
|
||||
start = start - np.array([clipped_rect.x, clipped_rect.y])
|
||||
end = end - np.array([clipped_rect.x, clipped_rect.y])
|
||||
state.gradient_start_ptr[0:2] = start.astype(np.float32)
|
||||
state.gradient_end_ptr[0:2] = end.astype(np.float32)
|
||||
rl.set_shader_value(state.shader, state.locations['gradientStart'], state.gradient_start_ptr, UNIFORM_VEC2)
|
||||
rl.set_shader_value(state.shader, state.locations['gradientEnd'], state.gradient_end_ptr, UNIFORM_VEC2)
|
||||
|
||||
colors = gradient['colors']
|
||||
color_count = min(len(colors), MAX_GRADIENT_COLORS)
|
||||
state.color_count_ptr[0] = color_count
|
||||
for i, c in enumerate(colors[:color_count]):
|
||||
base_idx = i * 4
|
||||
state.gradient_colors_ptr[base_idx:base_idx+4] = [c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0]
|
||||
rl.set_shader_value_v(state.shader, state.locations['gradientColors'], state.gradient_colors_ptr, UNIFORM_VEC4, color_count)
|
||||
|
||||
stops = gradient.get('stops', [i / max(1, color_count - 1) for i in range(color_count)])
|
||||
stops = np.clip(stops[:color_count], 0.0, 1.0)
|
||||
state.gradient_stops_ptr[0:color_count] = stops
|
||||
rl.set_shader_value_v(state.shader, state.locations['gradientStops'], state.gradient_stops_ptr, UNIFORM_FLOAT, color_count)
|
||||
rl.set_shader_value(state.shader, state.locations['gradientColorCount'], state.color_count_ptr, UNIFORM_INT)
|
||||
else:
|
||||
color = color or rl.WHITE
|
||||
state.fill_color_ptr[0:4] = [color.r / 255.0, color.g / 255.0, color.b / 255.0, color.a / 255.0]
|
||||
rl.set_shader_value(state.shader, state.locations['fillColor'], state.fill_color_ptr, UNIFORM_VEC4)
|
||||
|
||||
def draw_polygon(origin_rect: rl.Rectangle, points: np.ndarray, color=None, gradient=None):
|
||||
"""
|
||||
Draw a complex polygon using shader-based even-odd fill rule
|
||||
|
||||
Args:
|
||||
rect: Rectangle defining the drawing area
|
||||
points: numpy array of (x,y) points defining the polygon
|
||||
color: Solid fill color (rl.Color)
|
||||
gradient: Dict with gradient parameters:
|
||||
{
|
||||
'start': (x1, y1), # Start point (normalized 0-1)
|
||||
'end': (x2, y2), # End point (normalized 0-1)
|
||||
'colors': [rl.Color], # List of colors at stops
|
||||
'stops': [float] # List of positions (0-1)
|
||||
}
|
||||
"""
|
||||
if len(points) < 3:
|
||||
return
|
||||
|
||||
state = ShaderState.get_instance()
|
||||
if not state.initialized:
|
||||
state.initialize()
|
||||
|
||||
# Find bounding box
|
||||
min_xy = np.min(points, axis=0)
|
||||
max_xy = np.max(points, axis=0)
|
||||
clip_x = max(origin_rect.x, min_xy[0])
|
||||
clip_y = max(origin_rect.y, min_xy[1])
|
||||
clip_right = min(origin_rect.x + origin_rect.width, max_xy[0])
|
||||
clip_bottom = min(origin_rect.y + origin_rect.height, max_xy[1])
|
||||
|
||||
# Check if polygon is completely off-screen
|
||||
if clip_x >= clip_right or clip_y >= clip_bottom:
|
||||
return
|
||||
|
||||
clipped_rect = rl.Rectangle(clip_x, clip_y, clip_right - clip_x, clip_bottom - clip_y)
|
||||
|
||||
# Transform points relative to the CLIPPED area
|
||||
transformed_points = points - np.array([clip_x, clip_y])
|
||||
|
||||
# Set shader values
|
||||
state.point_count_ptr[0] = len(transformed_points)
|
||||
rl.set_shader_value(state.shader, state.locations['pointCount'], state.point_count_ptr, UNIFORM_INT)
|
||||
|
||||
state.resolution_ptr[0:2] = [clipped_rect.width, clipped_rect.height]
|
||||
rl.set_shader_value(state.shader, state.locations['resolution'], state.resolution_ptr, UNIFORM_VEC2)
|
||||
|
||||
flat_points = np.ascontiguousarray(transformed_points.flatten().astype(np.float32))
|
||||
points_ptr = rl.ffi.cast("float *", flat_points.ctypes.data)
|
||||
rl.set_shader_value_v(state.shader, state.locations['points'], points_ptr, UNIFORM_VEC2, len(transformed_points))
|
||||
|
||||
_configure_shader_color(state, color, gradient, clipped_rect, origin_rect)
|
||||
|
||||
# Render
|
||||
rl.begin_shader_mode(state.shader)
|
||||
rl.draw_texture_pro(
|
||||
state.white_texture,
|
||||
rl.Rectangle(0, 0, 2, 2),
|
||||
clipped_rect,
|
||||
rl.Vector2(0, 0),
|
||||
0.0,
|
||||
rl.WHITE,
|
||||
)
|
||||
rl.end_shader_mode()
|
||||
|
||||
|
||||
def cleanup_shader_resources():
|
||||
state = ShaderState.get_instance()
|
||||
state.cleanup()
|
||||
@@ -0,0 +1,13 @@
|
||||
import pyray as rl
|
||||
|
||||
_cache: dict[int, rl.Vector2] = {}
|
||||
|
||||
|
||||
def measure_text_cached(font: rl.Font, text: str, font_size: int, spacing: int = 0) -> rl.Vector2:
|
||||
key = hash((font.texture.id, text, font_size, spacing))
|
||||
if key in _cache:
|
||||
return _cache[key]
|
||||
|
||||
result = rl.measure_text_ex(font, text, font_size, spacing)
|
||||
_cache[key] = result
|
||||
return result
|
||||
@@ -0,0 +1,54 @@
|
||||
import pyray as rl
|
||||
|
||||
ON_COLOR = rl.Color(51, 171, 76, 255)
|
||||
OFF_COLOR = rl.Color(0x39, 0x39, 0x39, 255)
|
||||
KNOB_COLOR = rl.WHITE
|
||||
WIDTH, HEIGHT = 160, 80
|
||||
BG_HEIGHT = 60
|
||||
ANIMATION_SPEED = 8.0
|
||||
|
||||
|
||||
class Toggle:
|
||||
def __init__(self, initial_state=False):
|
||||
self._state = initial_state
|
||||
self._rect = rl.Rectangle(0, 0, WIDTH, HEIGHT)
|
||||
self._progress = 1.0 if initial_state else 0.0
|
||||
self._target = self._progress
|
||||
|
||||
def handle_input(self):
|
||||
if rl.is_mouse_button_pressed(rl.MOUSE_LEFT_BUTTON):
|
||||
if rl.check_collision_point_rec(rl.get_mouse_position(), self._rect):
|
||||
self._state = not self._state
|
||||
self._target = 1.0 if self._state else 0.0
|
||||
return 1
|
||||
return 0
|
||||
|
||||
def get_state(self):
|
||||
return self._state
|
||||
|
||||
def set_state(self, state: bool):
|
||||
self._state = state
|
||||
|
||||
def update(self):
|
||||
if abs(self._progress - self._target) > 0.01:
|
||||
delta = rl.get_frame_time() * ANIMATION_SPEED
|
||||
self._progress += delta if self._progress < self._target else -delta
|
||||
self._progress = max(0.0, min(1.0, self._progress))
|
||||
|
||||
def render(self, rect: rl.Rectangle):
|
||||
self._rect.x, self._rect.y = rect.x, rect.y
|
||||
self. update()
|
||||
# Draw background
|
||||
bg_rect = rl.Rectangle(self._rect.x + 5, self._rect.y + 10, WIDTH - 10, BG_HEIGHT)
|
||||
bg_color = self._blend_color(OFF_COLOR, ON_COLOR, self._progress)
|
||||
rl.draw_rectangle_rounded(bg_rect, 1.0, 10, bg_color)
|
||||
|
||||
# Draw knob
|
||||
knob_x = self._rect.x + HEIGHT / 2 + (WIDTH - HEIGHT) * self._progress
|
||||
knob_y = self._rect.y + HEIGHT / 2
|
||||
rl.draw_circle(int(knob_x), int(knob_y), HEIGHT / 2, KNOB_COLOR)
|
||||
|
||||
return self.handle_input()
|
||||
|
||||
def _blend_color(self, c1, c2, t):
|
||||
return rl.Color(int(c1.r + (c2.r - c1.r) * t), int(c1.g + (c2.g - c1.g) * t), int(c1.b + (c2.b - c1.b) * t), 255)
|
||||
@@ -0,0 +1,716 @@
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import copy
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
from typing import TypeVar
|
||||
|
||||
from dbus_next.aio import MessageBus
|
||||
from dbus_next import BusType, Variant, Message
|
||||
from dbus_next.errors import DBusError
|
||||
from dbus_next.constants import MessageType
|
||||
try:
|
||||
from openpilot.common.params import Params
|
||||
except ImportError:
|
||||
# Params/Cythonized modules are not available in zipapp
|
||||
Params = None
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
# NetworkManager constants
|
||||
NM = "org.freedesktop.NetworkManager"
|
||||
NM_PATH = '/org/freedesktop/NetworkManager'
|
||||
NM_IFACE = 'org.freedesktop.NetworkManager'
|
||||
NM_SETTINGS_PATH = '/org/freedesktop/NetworkManager/Settings'
|
||||
NM_SETTINGS_IFACE = 'org.freedesktop.NetworkManager.Settings'
|
||||
NM_CONNECTION_IFACE = 'org.freedesktop.NetworkManager.Settings.Connection'
|
||||
NM_WIRELESS_IFACE = 'org.freedesktop.NetworkManager.Device.Wireless'
|
||||
NM_PROPERTIES_IFACE = 'org.freedesktop.DBus.Properties'
|
||||
NM_DEVICE_IFACE = "org.freedesktop.NetworkManager.Device"
|
||||
|
||||
NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT = 8
|
||||
|
||||
TETHERING_IP_ADDRESS = "192.168.43.1"
|
||||
DEFAULT_TETHERING_PASSWORD = "12345678"
|
||||
|
||||
# NetworkManager device states
|
||||
class NMDeviceState(IntEnum):
|
||||
DISCONNECTED = 30
|
||||
PREPARE = 40
|
||||
NEED_AUTH = 60
|
||||
IP_CONFIG = 70
|
||||
ACTIVATED = 100
|
||||
|
||||
class SecurityType(IntEnum):
|
||||
OPEN = 0
|
||||
WPA = 1
|
||||
WPA2 = 2
|
||||
WPA3 = 3
|
||||
UNSUPPORTED = 4
|
||||
|
||||
@dataclass
|
||||
class NetworkInfo:
|
||||
ssid: str
|
||||
strength: int
|
||||
is_connected: bool
|
||||
security_type: SecurityType
|
||||
path: str
|
||||
bssid: str
|
||||
is_saved: bool = False
|
||||
# saved_path: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class WifiManagerCallbacks:
|
||||
need_auth: Callable[[str], None] | None = None
|
||||
activated: Callable[[], None] | None = None
|
||||
forgotten: Callable[[str], None] | None = None
|
||||
networks_updated: Callable[[list[NetworkInfo]], None] | None = None
|
||||
connection_failed: Callable[[str, str], None] | None = None # Added for error feedback
|
||||
|
||||
|
||||
class WifiManager:
|
||||
def __init__(self, callbacks):
|
||||
self.callbacks: WifiManagerCallbacks = callbacks
|
||||
self.networks: list[NetworkInfo] = []
|
||||
self.bus: MessageBus = None
|
||||
self.device_path: str = ""
|
||||
self.device_proxy = None
|
||||
self.saved_connections: dict[str, str] = {}
|
||||
self.active_ap_path: str = ""
|
||||
self.scan_task: asyncio.Task | None = None
|
||||
# Set tethering ssid as "weedle" + first 4 characters of a dongle id
|
||||
self._tethering_ssid = "weedle"
|
||||
if Params is not None:
|
||||
dongle_id = Params().get("DongleId", encoding="utf-8")
|
||||
if dongle_id:
|
||||
self._tethering_ssid += "-" + dongle_id[:4]
|
||||
self.running: bool = True
|
||||
self._current_connection_ssid: str | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the DBus system bus."""
|
||||
try:
|
||||
self.bus = await MessageBus(bus_type=BusType.SYSTEM).connect()
|
||||
if not await self._find_wifi_device():
|
||||
raise ValueError("No Wi-Fi device found")
|
||||
|
||||
await self._setup_signals(self.device_path)
|
||||
self.active_ap_path = await self.get_active_access_point()
|
||||
await self.add_tethering_connection(self._tethering_ssid, DEFAULT_TETHERING_PASSWORD)
|
||||
self.saved_connections = await self._get_saved_connections()
|
||||
self.scan_task = asyncio.create_task(self._periodic_scan())
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Failed to connect to DBus: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Unexpected error during connect: {e}")
|
||||
raise
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
self.running = False
|
||||
if self.scan_task:
|
||||
self.scan_task.cancel()
|
||||
try:
|
||||
await self.scan_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if self.bus:
|
||||
self.bus.disconnect()
|
||||
|
||||
async def _request_scan(self) -> None:
|
||||
try:
|
||||
interface = self.device_proxy.get_interface(NM_WIRELESS_IFACE)
|
||||
await interface.call_request_scan({})
|
||||
except DBusError as e:
|
||||
cloudlog.warning(f"Scan request failed: {str(e)}")
|
||||
|
||||
async def get_active_access_point(self):
|
||||
try:
|
||||
props_iface = self.device_proxy.get_interface(NM_PROPERTIES_IFACE)
|
||||
ap_path = await props_iface.call_get(NM_WIRELESS_IFACE, 'ActiveAccessPoint')
|
||||
return ap_path.value
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Error fetching active access point: {str(e)}")
|
||||
return ''
|
||||
|
||||
async def forget_connection(self, ssid: str) -> bool:
|
||||
path = self.saved_connections.get(ssid)
|
||||
if not path:
|
||||
return False
|
||||
|
||||
try:
|
||||
nm_iface = await self._get_interface(NM, path, NM_CONNECTION_IFACE)
|
||||
await nm_iface.call_delete()
|
||||
|
||||
if self._current_connection_ssid == ssid:
|
||||
self._current_connection_ssid = None
|
||||
|
||||
if ssid in self.saved_connections:
|
||||
del self.saved_connections[ssid]
|
||||
|
||||
for network in self.networks:
|
||||
if network.ssid == ssid:
|
||||
network.is_saved = False
|
||||
network.is_connected = False
|
||||
break
|
||||
|
||||
# Notify UI of forgotten connection
|
||||
if self.callbacks.networks_updated:
|
||||
self.callbacks.networks_updated(copy.deepcopy(self.networks))
|
||||
|
||||
return True
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Failed to delete connection for SSID: {ssid}. Error: {e}")
|
||||
return False
|
||||
|
||||
async def activate_connection(self, ssid: str) -> bool:
|
||||
connection_path = self.saved_connections.get(ssid)
|
||||
if not connection_path:
|
||||
return False
|
||||
try:
|
||||
nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE)
|
||||
await nm_iface.call_activate_connection(connection_path, self.device_path, "/")
|
||||
return True
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Failed to activate connection {ssid}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def connect_to_network(self, ssid: str, password: str = None, bssid: str = None, is_hidden: bool = False) -> None:
|
||||
"""Connect to a selected Wi-Fi network."""
|
||||
try:
|
||||
self._current_connection_ssid = ssid
|
||||
|
||||
if ssid in self.saved_connections:
|
||||
# Forget old connection if new password provided
|
||||
if password:
|
||||
await self.forget_connection(ssid)
|
||||
await asyncio.sleep(0.2) # NetworkManager delay
|
||||
else:
|
||||
# Just activate existing connection
|
||||
await self.activate_connection(ssid)
|
||||
return
|
||||
|
||||
connection = {
|
||||
'connection': {
|
||||
'type': Variant('s', '802-11-wireless'),
|
||||
'uuid': Variant('s', str(uuid.uuid4())),
|
||||
'id': Variant('s', ssid),
|
||||
'autoconnect-retries': Variant('i', 0),
|
||||
},
|
||||
'802-11-wireless': {
|
||||
'ssid': Variant('ay', ssid.encode('utf-8')),
|
||||
'hidden': Variant('b', is_hidden),
|
||||
'mode': Variant('s', 'infrastructure'),
|
||||
},
|
||||
'ipv4': {'method': Variant('s', 'auto')},
|
||||
'ipv6': {'method': Variant('s', 'ignore')},
|
||||
}
|
||||
|
||||
if bssid:
|
||||
connection['802-11-wireless']['bssid'] = Variant('ay', bssid.encode('utf-8'))
|
||||
|
||||
if password:
|
||||
connection['802-11-wireless-security'] = {
|
||||
'key-mgmt': Variant('s', 'wpa-psk'),
|
||||
'auth-alg': Variant('s', 'open'),
|
||||
'psk': Variant('s', password),
|
||||
}
|
||||
|
||||
nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE)
|
||||
await nm_iface.call_add_and_activate_connection(connection, self.device_path, "/")
|
||||
except Exception as e:
|
||||
self._current_connection_ssid = None
|
||||
cloudlog.error(f"Error connecting to network: {e}")
|
||||
# Notify UI of failure
|
||||
if self.callbacks.connection_failed:
|
||||
self.callbacks.connection_failed(ssid, str(e))
|
||||
|
||||
def is_saved(self, ssid: str) -> bool:
|
||||
return ssid in self.saved_connections
|
||||
|
||||
async def _find_wifi_device(self) -> bool:
|
||||
nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE)
|
||||
devices = await nm_iface.get_devices()
|
||||
|
||||
for device_path in devices:
|
||||
device = await self.bus.introspect(NM, device_path)
|
||||
device_proxy = self.bus.get_proxy_object(NM, device_path, device)
|
||||
device_interface = device_proxy.get_interface(NM_DEVICE_IFACE)
|
||||
device_type = await device_interface.get_device_type() # type: ignore[attr-defined]
|
||||
if device_type == 2: # Wi-Fi device
|
||||
self.device_path = device_path
|
||||
self.device_proxy = device_proxy
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def add_tethering_connection(self, ssid: str, password: str = "12345678") -> bool:
|
||||
"""Create a WiFi tethering connection."""
|
||||
if len(password) < 8:
|
||||
print("Tethering password must be at least 8 characters")
|
||||
return False
|
||||
|
||||
try:
|
||||
# First, check if a hotspot connection already exists
|
||||
settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE)
|
||||
connection_paths = await settings_iface.call_list_connections()
|
||||
|
||||
# Look for an existing hotspot connection
|
||||
for path in connection_paths:
|
||||
try:
|
||||
settings = await self._get_connection_settings(path)
|
||||
conn_type = settings.get('connection', {}).get('type', Variant('s', '')).value
|
||||
wifi_mode = settings.get('802-11-wireless', {}).get('mode', Variant('s', '')).value
|
||||
|
||||
if conn_type == '802-11-wireless' and wifi_mode == 'ap':
|
||||
# Extract the SSID to check
|
||||
connection_ssid = self._extract_ssid(settings)
|
||||
if connection_ssid == ssid:
|
||||
return True
|
||||
except DBusError:
|
||||
continue
|
||||
|
||||
connection = {
|
||||
'connection': {
|
||||
'id': Variant('s', 'Hotspot'),
|
||||
'uuid': Variant('s', str(uuid.uuid4())),
|
||||
'type': Variant('s', '802-11-wireless'),
|
||||
'interface-name': Variant('s', 'wlan0'),
|
||||
'autoconnect': Variant('b', False),
|
||||
},
|
||||
'802-11-wireless': {
|
||||
'band': Variant('s', 'bg'),
|
||||
'mode': Variant('s', 'ap'),
|
||||
'ssid': Variant('ay', ssid.encode('utf-8')),
|
||||
},
|
||||
'802-11-wireless-security': {
|
||||
'group': Variant('as', ['ccmp']),
|
||||
'key-mgmt': Variant('s', 'wpa-psk'),
|
||||
'pairwise': Variant('as', ['ccmp']),
|
||||
'proto': Variant('as', ['rsn']),
|
||||
'psk': Variant('s', password),
|
||||
},
|
||||
'ipv4': {
|
||||
'method': Variant('s', 'shared'),
|
||||
'address-data': Variant('aa{sv}', [{'address': Variant('s', TETHERING_IP_ADDRESS), 'prefix': Variant('u', 24)}]),
|
||||
'gateway': Variant('s', TETHERING_IP_ADDRESS),
|
||||
'never-default': Variant('b', True),
|
||||
},
|
||||
'ipv6': {
|
||||
'method': Variant('s', 'ignore'),
|
||||
},
|
||||
}
|
||||
|
||||
settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE)
|
||||
new_connection = await settings_iface.call_add_connection(connection)
|
||||
print(f"Added tethering connection with path: {new_connection}")
|
||||
return True
|
||||
except DBusError as e:
|
||||
print(f"Failed to add tethering connection: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error adding tethering connection: {e}")
|
||||
return False
|
||||
|
||||
async def get_tethering_password(self) -> str:
|
||||
"""Get the current tethering password."""
|
||||
try:
|
||||
hotspot_path = self.saved_connections.get(self._tethering_ssid)
|
||||
if hotspot_path:
|
||||
conn_iface = await self._get_interface(NM, hotspot_path, NM_CONNECTION_IFACE)
|
||||
secrets = await conn_iface.call_get_secrets('802-11-wireless-security')
|
||||
if secrets and '802-11-wireless-security' in secrets:
|
||||
psk = secrets.get('802-11-wireless-security', {}).get('psk', Variant('s', '')).value
|
||||
return str(psk) if psk is not None else ""
|
||||
return ""
|
||||
except DBusError as e:
|
||||
print(f"Failed to get tethering password: {e}")
|
||||
return ""
|
||||
except Exception as e:
|
||||
print(f"Unexpected error getting tethering password: {e}")
|
||||
return ""
|
||||
|
||||
async def set_tethering_password(self, password: str) -> bool:
|
||||
"""Set the tethering password."""
|
||||
if len(password) < 8:
|
||||
cloudlog.error("Tethering password must be at least 8 characters")
|
||||
return False
|
||||
|
||||
try:
|
||||
hotspot_path = self.saved_connections.get(self._tethering_ssid)
|
||||
if not hotspot_path:
|
||||
print("No hotspot connection found")
|
||||
return False
|
||||
|
||||
# Update the connection settings with new password
|
||||
settings = await self._get_connection_settings(hotspot_path)
|
||||
if '802-11-wireless-security' not in settings:
|
||||
settings['802-11-wireless-security'] = {}
|
||||
settings['802-11-wireless-security']['psk'] = Variant('s', password)
|
||||
|
||||
# Apply changes
|
||||
conn_iface = await self._get_interface(NM, hotspot_path, NM_CONNECTION_IFACE)
|
||||
await conn_iface.call_update(settings)
|
||||
|
||||
# Check if connection is active and restart if needed
|
||||
is_active = False
|
||||
nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE)
|
||||
active_connections = await nm_iface.get_active_connections()
|
||||
|
||||
for conn_path in active_connections:
|
||||
props_iface = await self._get_interface(NM, conn_path, NM_PROPERTIES_IFACE)
|
||||
conn_id_path = await props_iface.call_get('org.freedesktop.NetworkManager.Connection.Active', 'Connection')
|
||||
if conn_id_path.value == hotspot_path:
|
||||
is_active = True
|
||||
await nm_iface.call_deactivate_connection(conn_path)
|
||||
break
|
||||
|
||||
if is_active:
|
||||
await nm_iface.call_activate_connection(hotspot_path, self.device_path, "/")
|
||||
|
||||
print("Tethering password updated successfully")
|
||||
return True
|
||||
except DBusError as e:
|
||||
print(f"Failed to set tethering password: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error setting tethering password: {e}")
|
||||
return False
|
||||
|
||||
async def is_tethering_active(self) -> bool:
|
||||
"""Check if tethering is active for the specified SSID."""
|
||||
try:
|
||||
hotspot_path = self.saved_connections.get(self._tethering_ssid)
|
||||
if not hotspot_path:
|
||||
return False
|
||||
|
||||
nm_iface = await self._get_interface(NM, NM_PATH, NM_IFACE)
|
||||
active_connections = await nm_iface.get_active_connections()
|
||||
|
||||
for conn_path in active_connections:
|
||||
props_iface = await self._get_interface(NM, conn_path, NM_PROPERTIES_IFACE)
|
||||
conn_id_path = await props_iface.call_get('org.freedesktop.NetworkManager.Connection.Active', 'Connection')
|
||||
|
||||
if conn_id_path.value == hotspot_path:
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _periodic_scan(self):
|
||||
while self.running:
|
||||
try:
|
||||
await self._request_scan()
|
||||
await asyncio.sleep(30)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Scan failed: {e}")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _setup_signals(self, device_path: str) -> None:
|
||||
rules = [
|
||||
f"type='signal',interface='{NM_PROPERTIES_IFACE}',member='PropertiesChanged',path='{device_path}'",
|
||||
f"type='signal',interface='{NM_DEVICE_IFACE}',member='StateChanged',path='{device_path}'",
|
||||
f"type='signal',interface='{NM_SETTINGS_IFACE}',member='NewConnection',path='{NM_SETTINGS_PATH}'",
|
||||
f"type='signal',interface='{NM_SETTINGS_IFACE}',member='ConnectionRemoved',path='{NM_SETTINGS_PATH}'",
|
||||
]
|
||||
for rule in rules:
|
||||
await self._add_match_rule(rule)
|
||||
|
||||
# Set up signal handlers
|
||||
self.device_proxy.get_interface(NM_PROPERTIES_IFACE).on_properties_changed(self._on_properties_changed)
|
||||
self.device_proxy.get_interface(NM_DEVICE_IFACE).on_state_changed(self._on_state_changed)
|
||||
|
||||
settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE)
|
||||
settings_iface.on_new_connection(self._on_new_connection)
|
||||
settings_iface.on_connection_removed(self._on_connection_removed)
|
||||
|
||||
def _on_properties_changed(self, interface: str, changed: dict, invalidated: list):
|
||||
# print("property changed", interface, changed, invalidated)
|
||||
if 'LastScan' in changed:
|
||||
asyncio.create_task(self._refresh_networks())
|
||||
elif interface == NM_WIRELESS_IFACE and "ActiveAccessPoint" in changed:
|
||||
new_ap_path = changed["ActiveAccessPoint"].value
|
||||
if self.active_ap_path != new_ap_path:
|
||||
self.active_ap_path = new_ap_path
|
||||
asyncio.create_task(self._refresh_networks())
|
||||
|
||||
def _on_state_changed(self, new_state: int, old_state: int, reason: int):
|
||||
print("State changed", new_state, old_state, reason)
|
||||
if new_state == NMDeviceState.ACTIVATED:
|
||||
if self.callbacks.activated:
|
||||
self.callbacks.activated()
|
||||
asyncio.create_task(self._refresh_networks())
|
||||
self._current_connection_ssid = None
|
||||
elif new_state in (NMDeviceState.DISCONNECTED, NMDeviceState.NEED_AUTH):
|
||||
for network in self.networks:
|
||||
network.is_connected = False
|
||||
|
||||
if new_state == NMDeviceState.NEED_AUTH and reason == NM_DEVICE_STATE_REASON_SUPPLICANT_DISCONNECT and self.callbacks.need_auth:
|
||||
if self._current_connection_ssid:
|
||||
self.callbacks.need_auth(self._current_connection_ssid)
|
||||
else:
|
||||
# Try to find the network from active_ap_path
|
||||
for network in self.networks:
|
||||
if network.path == self.active_ap_path:
|
||||
self.callbacks.need_auth(network.ssid)
|
||||
break
|
||||
else:
|
||||
# Couldn't identify the network that needs auth
|
||||
cloudlog.error("Network needs authentication but couldn't identify which one")
|
||||
|
||||
def _on_new_connection(self, path: str) -> None:
|
||||
"""Callback for NewConnection signal."""
|
||||
asyncio.create_task(self._add_saved_connection(path))
|
||||
|
||||
def _on_connection_removed(self, path: str) -> None:
|
||||
"""Callback for ConnectionRemoved signal."""
|
||||
for ssid, p in list(self.saved_connections.items()):
|
||||
if path == p:
|
||||
del self.saved_connections[ssid]
|
||||
|
||||
if self.callbacks.forgotten:
|
||||
self.callbacks.forgotten(ssid)
|
||||
|
||||
# Update network list to reflect the removed saved connection
|
||||
asyncio.create_task(self._refresh_networks())
|
||||
break
|
||||
|
||||
async def _add_saved_connection(self, path: str) -> None:
|
||||
"""Add a new saved connection to the dictionary."""
|
||||
try:
|
||||
settings = await self._get_connection_settings(path)
|
||||
if ssid := self._extract_ssid(settings):
|
||||
self.saved_connections[ssid] = path
|
||||
await self._refresh_networks()
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Failed to add connection {path}: {e}")
|
||||
|
||||
def _extract_ssid(self, settings: dict) -> str | None:
|
||||
"""Extract SSID from connection settings."""
|
||||
ssid_variant = settings.get('802-11-wireless', {}).get('ssid', Variant('ay', b'')).value
|
||||
return ''.join(chr(b) for b in ssid_variant) if ssid_variant else None
|
||||
|
||||
async def _add_match_rule(self, rule):
|
||||
"""Add a match rule on the bus."""
|
||||
reply = await self.bus.call(
|
||||
Message(
|
||||
message_type=MessageType.METHOD_CALL,
|
||||
destination='org.freedesktop.DBus',
|
||||
interface="org.freedesktop.DBus",
|
||||
path='/org/freedesktop/DBus',
|
||||
member='AddMatch',
|
||||
signature='s',
|
||||
body=[rule],
|
||||
)
|
||||
)
|
||||
|
||||
assert reply.message_type == MessageType.METHOD_RETURN
|
||||
return reply
|
||||
|
||||
async def _refresh_networks(self):
|
||||
"""Get a list of available networks via NetworkManager."""
|
||||
wifi_iface = self.device_proxy.get_interface(NM_WIRELESS_IFACE)
|
||||
access_points = await wifi_iface.get_access_points()
|
||||
self.active_ap_path = await self.get_active_access_point()
|
||||
network_dict = {}
|
||||
for ap_path in access_points:
|
||||
try:
|
||||
props_iface = await self._get_interface(NM, ap_path, NM_PROPERTIES_IFACE)
|
||||
properties = await props_iface.call_get_all('org.freedesktop.NetworkManager.AccessPoint')
|
||||
ssid_variant = properties['Ssid'].value
|
||||
ssid = ''.join(chr(byte) for byte in ssid_variant)
|
||||
if not ssid:
|
||||
continue
|
||||
|
||||
bssid = properties.get('HwAddress', Variant('s', '')).value
|
||||
strength = properties['Strength'].value
|
||||
flags = properties['Flags'].value
|
||||
wpa_flags = properties['WpaFlags'].value
|
||||
rsn_flags = properties['RsnFlags'].value
|
||||
existing_network = network_dict.get(ssid)
|
||||
if not existing_network or ((not existing_network.bssid and bssid) or (existing_network.strength < strength)):
|
||||
network_dict[ssid] = NetworkInfo(
|
||||
ssid=ssid,
|
||||
strength=strength,
|
||||
security_type=self._get_security_type(flags, wpa_flags, rsn_flags),
|
||||
path=ap_path,
|
||||
bssid=bssid,
|
||||
is_connected=self.active_ap_path == ap_path and self._current_connection_ssid != ssid,
|
||||
is_saved=ssid in self.saved_connections
|
||||
)
|
||||
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Error fetching networks: {e}")
|
||||
except Exception as e:
|
||||
cloudlog.error({e})
|
||||
|
||||
self.networks = sorted(
|
||||
network_dict.values(),
|
||||
key=lambda network: (
|
||||
not network.is_connected,
|
||||
-network.strength, # Higher signal strength first
|
||||
network.ssid.lower(),
|
||||
),
|
||||
)
|
||||
|
||||
if self.callbacks.networks_updated:
|
||||
self.callbacks.networks_updated(copy.deepcopy(self.networks))
|
||||
|
||||
async def _get_connection_settings(self, path):
|
||||
"""Fetch connection settings for a specific connection path."""
|
||||
try:
|
||||
settings = await self._get_interface(NM, path, NM_CONNECTION_IFACE)
|
||||
return await settings.call_get_settings()
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Failed to get settings for {path}: {str(e)}")
|
||||
return {}
|
||||
|
||||
async def _process_chunk(self, paths_chunk):
|
||||
"""Process a chunk of connection paths."""
|
||||
tasks = [self._get_connection_settings(path) for path in paths_chunk]
|
||||
return await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
async def _get_saved_connections(self) -> dict[str, str]:
|
||||
try:
|
||||
settings_iface = await self._get_interface(NM, NM_SETTINGS_PATH, NM_SETTINGS_IFACE)
|
||||
connection_paths = await settings_iface.call_list_connections()
|
||||
saved_ssids: dict[str, str] = {}
|
||||
batch_size = 20
|
||||
for i in range(0, len(connection_paths), batch_size):
|
||||
chunk = connection_paths[i : i + batch_size]
|
||||
results = await self._process_chunk(chunk)
|
||||
for path, config in zip(chunk, results, strict=True):
|
||||
if isinstance(config, dict) and '802-11-wireless' in config:
|
||||
if ssid := self._extract_ssid(config):
|
||||
saved_ssids[ssid] = path
|
||||
return saved_ssids
|
||||
except DBusError as e:
|
||||
cloudlog.error(f"Error fetching saved connections: {str(e)}")
|
||||
return {}
|
||||
|
||||
async def _get_interface(self, bus_name: str, path: str, name: str):
|
||||
introspection = await self.bus.introspect(bus_name, path)
|
||||
proxy = self.bus.get_proxy_object(bus_name, path, introspection)
|
||||
return proxy.get_interface(name)
|
||||
|
||||
def _get_security_type(self, flags: int, wpa_flags: int, rsn_flags: int) -> SecurityType:
|
||||
"""Determine the security type based on flags."""
|
||||
if flags == 0 and not (wpa_flags or rsn_flags):
|
||||
return SecurityType.OPEN
|
||||
if rsn_flags & 0x200: # SAE (WPA3 Personal)
|
||||
# TODO: support WPA3
|
||||
return SecurityType.UNSUPPORTED
|
||||
if rsn_flags: # RSN indicates WPA2 or higher
|
||||
return SecurityType.WPA2
|
||||
if wpa_flags: # WPA flags indicate WPA
|
||||
return SecurityType.WPA
|
||||
return SecurityType.UNSUPPORTED
|
||||
|
||||
|
||||
class WifiManagerWrapper:
|
||||
def __init__(self):
|
||||
self._manager: WifiManager | None = None
|
||||
self._callbacks: WifiManagerCallbacks = WifiManagerCallbacks()
|
||||
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._loop: asyncio.EventLoop | None = None
|
||||
self._running = False
|
||||
|
||||
def set_callbacks(self, callbacks: WifiManagerCallbacks):
|
||||
self._callbacks = callbacks
|
||||
|
||||
def start(self) -> None:
|
||||
if not self._running:
|
||||
self._thread.start()
|
||||
while self._thread is not None and not self._running:
|
||||
time.sleep(0.1)
|
||||
|
||||
def _run(self):
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
try:
|
||||
self._manager = WifiManager(self._callbacks)
|
||||
self._running = True
|
||||
self._loop.run_forever()
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Error in WifiManagerWrapper thread: {e}")
|
||||
finally:
|
||||
if self._loop.is_running():
|
||||
self._loop.stop()
|
||||
self._running = False
|
||||
|
||||
def shutdown(self) -> None:
|
||||
if self._running:
|
||||
if self._manager is not None and self._loop:
|
||||
shutdown_future = asyncio.run_coroutine_threadsafe(self._manager.shutdown(), self._loop)
|
||||
shutdown_future.result(timeout=3.0)
|
||||
|
||||
if self._loop and self._loop.is_running():
|
||||
self._loop.call_soon_threadsafe(self._loop.stop)
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=2.0)
|
||||
self._running = False
|
||||
|
||||
def is_saved(self, ssid: str) -> bool:
|
||||
"""Check if a network is saved."""
|
||||
return self._run_coroutine_sync(lambda manager: manager.is_saved(ssid), default=False)
|
||||
|
||||
def connect(self):
|
||||
"""Connect to DBus and start Wi-Fi scanning."""
|
||||
if not self._manager:
|
||||
return
|
||||
self._run_coroutine(self._manager.connect())
|
||||
|
||||
def forget_connection(self, ssid: str):
|
||||
"""Forget a saved Wi-Fi connection."""
|
||||
if not self._manager:
|
||||
return
|
||||
self._run_coroutine(self._manager.forget_connection(ssid))
|
||||
|
||||
def activate_connection(self, ssid: str):
|
||||
"""Activate an existing Wi-Fi connection."""
|
||||
if not self._manager:
|
||||
return
|
||||
self._run_coroutine(self._manager.activate_connection(ssid))
|
||||
|
||||
def connect_to_network(self, ssid: str, password: str = None, bssid: str = None, is_hidden: bool = False):
|
||||
"""Connect to a Wi-Fi network."""
|
||||
if not self._manager:
|
||||
return
|
||||
self._run_coroutine(self._manager.connect_to_network(ssid, password, bssid, is_hidden))
|
||||
|
||||
def _run_coroutine(self, coro):
|
||||
"""Run a coroutine in the async thread."""
|
||||
if not self._running or not self._loop:
|
||||
cloudlog.error("WifiManager thread is not running")
|
||||
return
|
||||
asyncio.run_coroutine_threadsafe(coro, self._loop)
|
||||
|
||||
def _run_coroutine_sync(self, func: Callable[[WifiManager], T], default: T) -> T:
|
||||
"""Run a function synchronously in the async thread."""
|
||||
if not self._running or not self._loop or not self._manager:
|
||||
return default
|
||||
future = concurrent.futures.Future[T]()
|
||||
|
||||
def wrapper(manager: WifiManager) -> None:
|
||||
try:
|
||||
future.set_result(func(manager))
|
||||
except Exception as e:
|
||||
future.set_exception(e)
|
||||
|
||||
try:
|
||||
self._loop.call_soon_threadsafe(wrapper, self._manager)
|
||||
return future.result(timeout=1.0)
|
||||
except Exception as e:
|
||||
cloudlog.error(f"WifiManagerWrapper property access failed: {e}")
|
||||
return default
|
||||
@@ -0,0 +1,58 @@
|
||||
import threading
|
||||
import time
|
||||
import os
|
||||
from typing import Generic, Protocol, TypeVar
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
|
||||
class RendererProtocol(Protocol):
|
||||
def render(self): ...
|
||||
|
||||
|
||||
R = TypeVar("R", bound=RendererProtocol)
|
||||
|
||||
|
||||
class BaseWindow(Generic[R]):
|
||||
def __init__(self, title: str):
|
||||
self._title = title
|
||||
self._renderer: R | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run)
|
||||
self._thread.start()
|
||||
|
||||
# wait for the renderer to be initialized
|
||||
while self._renderer is None and self._thread.is_alive():
|
||||
time.sleep(0.01)
|
||||
|
||||
def _create_renderer(self) -> R:
|
||||
raise NotImplementedError()
|
||||
|
||||
def _run(self):
|
||||
if os.getenv("CI") is not None:
|
||||
return
|
||||
gui_app.init_window(self._title)
|
||||
self._renderer = self._create_renderer()
|
||||
try:
|
||||
for _ in gui_app.render():
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
self._renderer.render()
|
||||
finally:
|
||||
gui_app.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
if self._thread.is_alive():
|
||||
self._stop_event.set()
|
||||
self._thread.join(timeout=2.0)
|
||||
if self._thread.is_alive():
|
||||
cloudlog.warning(f"Failed to join {self._title} thread")
|
||||
|
||||
def __del__(self):
|
||||
self.close()
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
@@ -0,0 +1,87 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.text_measure import measure_text_cached
|
||||
|
||||
def _break_long_word(font: rl.Font, word: str, font_size: int, max_width: int) -> list[str]:
|
||||
if not word:
|
||||
return []
|
||||
|
||||
parts = []
|
||||
remaining = word
|
||||
|
||||
while remaining:
|
||||
if measure_text_cached(font, remaining, font_size).x <= max_width:
|
||||
parts.append(remaining)
|
||||
break
|
||||
|
||||
# Binary search for the longest substring that fits
|
||||
left, right = 1, len(remaining)
|
||||
best_fit = 1
|
||||
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
substring = remaining[:mid]
|
||||
width = measure_text_cached(font, substring, font_size).x
|
||||
|
||||
if width <= max_width:
|
||||
best_fit = mid
|
||||
left = mid + 1
|
||||
else:
|
||||
right = mid - 1
|
||||
|
||||
# Add the part that fits
|
||||
parts.append(remaining[:best_fit])
|
||||
remaining = remaining[best_fit:]
|
||||
|
||||
return parts
|
||||
|
||||
|
||||
def wrap_text(font: rl.Font, text: str, font_size: int, max_width: int) -> list[str]:
|
||||
if not text or max_width <= 0:
|
||||
return []
|
||||
|
||||
words = text.split()
|
||||
if not words:
|
||||
return []
|
||||
|
||||
lines: list[str] = []
|
||||
current_line: list[str] = []
|
||||
current_width = 0
|
||||
space_width = int(measure_text_cached(font, " ", font_size).x)
|
||||
|
||||
for word in words:
|
||||
word_width = int(measure_text_cached(font, word, font_size).x)
|
||||
|
||||
# Check if word alone exceeds max width (need to break the word)
|
||||
if word_width > max_width:
|
||||
# Finish current line if it has content
|
||||
if current_line:
|
||||
lines.append(" ".join(current_line))
|
||||
current_line = []
|
||||
current_width = 0
|
||||
|
||||
# Break the long word into parts
|
||||
lines.extend(_break_long_word(font, word, font_size, max_width))
|
||||
continue
|
||||
|
||||
# Calculate width if we add this word
|
||||
needed_width = current_width
|
||||
if current_line: # Need space before word
|
||||
needed_width += space_width
|
||||
needed_width += word_width
|
||||
|
||||
# Check if word fits on current line
|
||||
if needed_width <= max_width:
|
||||
current_line.append(word)
|
||||
current_width = needed_width
|
||||
else:
|
||||
# Start new line with this word
|
||||
if current_line:
|
||||
lines.append(" ".join(current_line))
|
||||
current_line = [word]
|
||||
current_width = word_width
|
||||
|
||||
# Add remaining words
|
||||
if current_line:
|
||||
lines.append(" ".join(current_line))
|
||||
|
||||
return lines
|
||||
+6
-2
@@ -5,6 +5,7 @@ import sys
|
||||
import threading
|
||||
from enum import IntEnum
|
||||
|
||||
from openpilot.system.hardware import PC
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_label, gui_text_box
|
||||
@@ -31,7 +32,10 @@ class Reset:
|
||||
self.mode = mode
|
||||
self.reset_state = ResetState.NONE
|
||||
|
||||
def do_reset(self):
|
||||
def _do_erase(self):
|
||||
if PC:
|
||||
return
|
||||
|
||||
# Best effort to wipe NVME
|
||||
os.system(f"sudo umount {NVME}")
|
||||
os.system(f"yes | sudo mkfs.ext4 {NVME}")
|
||||
@@ -48,7 +52,7 @@ class Reset:
|
||||
|
||||
def start_reset(self):
|
||||
self.reset_state = ResetState.RESETTING
|
||||
threading.Timer(0.1, self.do_reset).start()
|
||||
threading.Timer(0.1, self._do_erase).start()
|
||||
|
||||
def render(self, rect: rl.Rectangle):
|
||||
label_rect = rl.Rectangle(rect.x + 140, rect.y, rect.width - 280, 100)
|
||||
|
||||
Executable
+349
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from enum import IntEnum
|
||||
import pyray as rl
|
||||
|
||||
from cereal import log
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_label, gui_text_box
|
||||
from openpilot.system.ui.widgets.network import WifiManagerUI, WifiManagerWrapper
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
|
||||
NetworkType = log.DeviceState.NetworkType
|
||||
|
||||
MARGIN = 50
|
||||
TITLE_FONT_SIZE = 116
|
||||
TITLE_FONT_WEIGHT = FontWeight.MEDIUM
|
||||
NEXT_BUTTON_WIDTH = 310
|
||||
BODY_FONT_SIZE = 96
|
||||
BUTTON_HEIGHT = 160
|
||||
BUTTON_SPACING = 50
|
||||
|
||||
OPENPILOT_URL = "https://openpilot.comma.ai"
|
||||
USER_AGENT = f"AGNOSSetup-{HARDWARE.get_os_version()}"
|
||||
|
||||
|
||||
class SetupState(IntEnum):
|
||||
LOW_VOLTAGE = 0
|
||||
GETTING_STARTED = 1
|
||||
NETWORK_SETUP = 2
|
||||
SOFTWARE_SELECTION = 3
|
||||
CUSTOM_URL = 4
|
||||
DOWNLOADING = 5
|
||||
DOWNLOAD_FAILED = 6
|
||||
|
||||
|
||||
class Setup:
|
||||
def __init__(self):
|
||||
self.state = SetupState.GETTING_STARTED
|
||||
self.network_check_thread = None
|
||||
self.network_connected = threading.Event()
|
||||
self.wifi_connected = threading.Event()
|
||||
self.stop_network_check_thread = threading.Event()
|
||||
self.failed_url = ""
|
||||
self.failed_reason = ""
|
||||
self.download_url = ""
|
||||
self.download_progress = 0
|
||||
self.download_thread = None
|
||||
self.wifi_manager = WifiManagerWrapper()
|
||||
self.wifi_ui = WifiManagerUI(self.wifi_manager)
|
||||
self.keyboard = Keyboard()
|
||||
self.selected_radio = None
|
||||
|
||||
self.warning = gui_app.texture("icons/warning.png", 150, 150)
|
||||
self.checkmark = gui_app.texture("icons/circled_check.png", 100, 100)
|
||||
|
||||
try:
|
||||
with open("/sys/class/hwmon/hwmon1/in1_input") as f:
|
||||
voltage = float(f.read().strip()) / 1000.0
|
||||
if voltage < 7:
|
||||
self.state = SetupState.LOW_VOLTAGE
|
||||
except (FileNotFoundError, ValueError):
|
||||
self.state = SetupState.LOW_VOLTAGE
|
||||
|
||||
def render(self, rect: rl.Rectangle):
|
||||
if self.state == SetupState.LOW_VOLTAGE:
|
||||
self.render_low_voltage(rect)
|
||||
elif self.state == SetupState.GETTING_STARTED:
|
||||
self.render_getting_started(rect)
|
||||
elif self.state == SetupState.NETWORK_SETUP:
|
||||
self.render_network_setup(rect)
|
||||
elif self.state == SetupState.SOFTWARE_SELECTION:
|
||||
self.render_software_selection(rect)
|
||||
elif self.state == SetupState.CUSTOM_URL:
|
||||
self.render_custom_url()
|
||||
elif self.state == SetupState.DOWNLOADING:
|
||||
self.render_downloading(rect)
|
||||
elif self.state == SetupState.DOWNLOAD_FAILED:
|
||||
self.render_download_failed(rect)
|
||||
|
||||
def render_low_voltage(self, rect: rl.Rectangle):
|
||||
rl.draw_texture(self.warning, int(rect.x + 150), int(rect.y + 110), rl.WHITE)
|
||||
|
||||
title_rect = rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100, rect.width - 500 - 150, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "WARNING: Low Voltage", TITLE_FONT_SIZE, rl.Color(255, 89, 79, 255), FontWeight.MEDIUM)
|
||||
|
||||
body_rect = rl.Rectangle(rect.x + 150, rect.y + 110 + 150 + 100 + TITLE_FONT_SIZE + 25, rect.width - 500 - 150, BODY_FONT_SIZE * 3)
|
||||
gui_text_box(body_rect, "Power your device in a car with a harness or proceed at your own risk.", BODY_FONT_SIZE)
|
||||
|
||||
button_width = (rect.width - MARGIN * 3) / 2
|
||||
button_y = rect.height - MARGIN - BUTTON_HEIGHT
|
||||
|
||||
if gui_button(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT), "Power off"):
|
||||
HARDWARE.shutdown()
|
||||
|
||||
if gui_button(rl.Rectangle(rect.x + MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT), "Continue"):
|
||||
self.state = SetupState.GETTING_STARTED
|
||||
|
||||
def render_getting_started(self, rect: rl.Rectangle):
|
||||
title_rect = rl.Rectangle(rect.x + 165, rect.y + 280, rect.width - 265, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Getting Started", TITLE_FONT_SIZE, font_weight=FontWeight.MEDIUM)
|
||||
|
||||
desc_rect = rl.Rectangle(rect.x + 165, rect.y + 280 + TITLE_FONT_SIZE + 90, rect.width - 500, BODY_FONT_SIZE * 3)
|
||||
gui_text_box(desc_rect, "Before we get on the road, let's finish installation and cover some details.", BODY_FONT_SIZE)
|
||||
|
||||
btn_rect = rl.Rectangle(rect.width - NEXT_BUTTON_WIDTH, 0, NEXT_BUTTON_WIDTH, rect.height)
|
||||
|
||||
ret = gui_button(btn_rect, "", button_style=ButtonStyle.PRIMARY, border_radius=0)
|
||||
triangle = gui_app.texture("images/button_continue_triangle.png", 54, int(btn_rect.height))
|
||||
rl.draw_texture_v(triangle, rl.Vector2(btn_rect.x + btn_rect.width / 2 - triangle.width / 2, btn_rect.height / 2 - triangle.height / 2), rl.WHITE)
|
||||
|
||||
if ret:
|
||||
self.state = SetupState.NETWORK_SETUP
|
||||
self.wifi_manager.request_scan()
|
||||
self.start_network_check()
|
||||
|
||||
def check_network_connectivity(self):
|
||||
while not self.stop_network_check_thread.is_set():
|
||||
if self.state == SetupState.NETWORK_SETUP:
|
||||
try:
|
||||
urllib.request.urlopen(OPENPILOT_URL, timeout=2)
|
||||
self.network_connected.set()
|
||||
if HARDWARE.get_network_type() == NetworkType.wifi:
|
||||
self.wifi_connected.set()
|
||||
else:
|
||||
self.wifi_connected.clear()
|
||||
except Exception:
|
||||
self.network_connected.clear()
|
||||
time.sleep(1)
|
||||
|
||||
def start_network_check(self):
|
||||
if self.network_check_thread is None or not self.network_check_thread.is_alive():
|
||||
self.network_check_thread = threading.Thread(target=self.check_network_connectivity, daemon=True)
|
||||
self.network_check_thread.start()
|
||||
|
||||
def close(self):
|
||||
if self.network_check_thread is not None:
|
||||
self.stop_network_check_thread.set()
|
||||
self.network_check_thread.join()
|
||||
|
||||
def render_network_setup(self, rect: rl.Rectangle):
|
||||
if self.wifi_ui.require_full_screen:
|
||||
self.wifi_ui.render(rect)
|
||||
return
|
||||
|
||||
title_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Connect to Wi-Fi", TITLE_FONT_SIZE, font_weight=FontWeight.MEDIUM)
|
||||
|
||||
wifi_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN + 25, rect.width - MARGIN * 2,
|
||||
rect.height - TITLE_FONT_SIZE - 25 - BUTTON_HEIGHT - MARGIN * 3)
|
||||
rl.draw_rectangle_rounded(wifi_rect, 0.05, 10, rl.Color(51, 51, 51, 255))
|
||||
wifi_content_rect = rl.Rectangle(wifi_rect.x + MARGIN, wifi_rect.y, wifi_rect.width - MARGIN * 2, wifi_rect.height)
|
||||
self.wifi_ui.render(wifi_content_rect)
|
||||
|
||||
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
|
||||
button_y = rect.height - BUTTON_HEIGHT - MARGIN
|
||||
|
||||
if gui_button(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT), "Back"):
|
||||
self.state = SetupState.GETTING_STARTED
|
||||
|
||||
# Check network connectivity status
|
||||
continue_enabled = self.network_connected.is_set()
|
||||
continue_text = ("Continue" if self.wifi_connected.is_set() else "Continue without Wi-Fi") if continue_enabled else "Waiting for internet"
|
||||
|
||||
if gui_button(
|
||||
rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT),
|
||||
continue_text,
|
||||
button_style=ButtonStyle.PRIMARY if continue_enabled else ButtonStyle.NORMAL,
|
||||
is_enabled=continue_enabled,
|
||||
):
|
||||
self.state = SetupState.SOFTWARE_SELECTION
|
||||
self.stop_network_check_thread.set()
|
||||
|
||||
def render_software_selection(self, rect: rl.Rectangle):
|
||||
title_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - MARGIN * 2, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Choose Software to Install", TITLE_FONT_SIZE, font_weight=FontWeight.MEDIUM)
|
||||
|
||||
radio_height = 230
|
||||
radio_spacing = 30
|
||||
|
||||
openpilot_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN * 2, rect.width - MARGIN * 2, radio_height)
|
||||
openpilot_selected = self.selected_radio == "openpilot"
|
||||
|
||||
rl.draw_rectangle_rounded(openpilot_rect, 0.1, 10, rl.Color(70, 91, 234, 255) if openpilot_selected else rl.Color(79, 79, 79, 255))
|
||||
gui_label(rl.Rectangle(openpilot_rect.x + 100, openpilot_rect.y, openpilot_rect.width - 200, radio_height), "openpilot", BODY_FONT_SIZE)
|
||||
|
||||
if openpilot_selected:
|
||||
checkmark_pos = rl.Vector2(openpilot_rect.x + openpilot_rect.width - 100 - self.checkmark.width,
|
||||
openpilot_rect.y + radio_height / 2 - self.checkmark.height / 2)
|
||||
rl.draw_texture_v(self.checkmark, checkmark_pos, rl.WHITE)
|
||||
|
||||
custom_rect = rl.Rectangle(rect.x + MARGIN, rect.y + TITLE_FONT_SIZE + MARGIN * 2 + radio_height + radio_spacing, rect.width - MARGIN * 2, radio_height)
|
||||
custom_selected = self.selected_radio == "custom"
|
||||
|
||||
rl.draw_rectangle_rounded(custom_rect, 0.1, 10, rl.Color(70, 91, 234, 255) if custom_selected else rl.Color(79, 79, 79, 255))
|
||||
gui_label(rl.Rectangle(custom_rect.x + 100, custom_rect.y, custom_rect.width - 200, radio_height), "Custom Software", BODY_FONT_SIZE)
|
||||
|
||||
if custom_selected:
|
||||
checkmark_pos = rl.Vector2(custom_rect.x + custom_rect.width - 100 - self.checkmark.width, custom_rect.y + radio_height / 2 - self.checkmark.height / 2)
|
||||
rl.draw_texture_v(self.checkmark, checkmark_pos, rl.WHITE)
|
||||
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
if rl.is_mouse_button_released(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
if rl.check_collision_point_rec(mouse_pos, openpilot_rect):
|
||||
self.selected_radio = "openpilot"
|
||||
elif rl.check_collision_point_rec(mouse_pos, custom_rect):
|
||||
self.selected_radio = "custom"
|
||||
|
||||
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
|
||||
button_y = rect.height - BUTTON_HEIGHT - MARGIN
|
||||
|
||||
if gui_button(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT), "Back"):
|
||||
self.state = SetupState.NETWORK_SETUP
|
||||
|
||||
continue_enabled = self.selected_radio is not None
|
||||
if gui_button(
|
||||
rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT),
|
||||
"Continue",
|
||||
button_style=ButtonStyle.PRIMARY,
|
||||
is_enabled=continue_enabled,
|
||||
):
|
||||
if continue_enabled:
|
||||
if self.selected_radio == "openpilot":
|
||||
self.download(OPENPILOT_URL)
|
||||
else:
|
||||
self.state = SetupState.CUSTOM_URL
|
||||
|
||||
def render_downloading(self, rect: rl.Rectangle):
|
||||
title_rect = rl.Rectangle(rect.x, rect.y + rect.height / 2 - TITLE_FONT_SIZE / 2, rect.width, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Downloading...", TITLE_FONT_SIZE, font_weight=FontWeight.MEDIUM, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
|
||||
def render_download_failed(self, rect: rl.Rectangle):
|
||||
title_rect = rl.Rectangle(rect.x + 117, rect.y + 185, rect.width - 117, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Download Failed", TITLE_FONT_SIZE, font_weight=FontWeight.MEDIUM)
|
||||
|
||||
url_rect = rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE + 67, rect.width - 117 - 100, 64)
|
||||
gui_label(url_rect, self.failed_url, 64, font_weight=FontWeight.NORMAL)
|
||||
|
||||
error_rect = rl.Rectangle(rect.x + 117, rect.y + 185 + TITLE_FONT_SIZE + 67 + 64 + 48,
|
||||
rect.width - 117 - 100, rect.height - 185 + TITLE_FONT_SIZE + 67 + 64 + 48 - BUTTON_HEIGHT - MARGIN * 2)
|
||||
gui_text_box(error_rect, self.failed_reason, BODY_FONT_SIZE)
|
||||
|
||||
button_width = (rect.width - BUTTON_SPACING - MARGIN * 2) / 2
|
||||
button_y = rect.height - BUTTON_HEIGHT - MARGIN
|
||||
|
||||
if gui_button(rl.Rectangle(rect.x + MARGIN, button_y, button_width, BUTTON_HEIGHT), "Reboot device"):
|
||||
HARDWARE.reboot()
|
||||
|
||||
if gui_button(rl.Rectangle(rect.x + MARGIN + button_width + BUTTON_SPACING, button_y, button_width, BUTTON_HEIGHT), "Start over",
|
||||
button_style=ButtonStyle.PRIMARY):
|
||||
self.state = SetupState.GETTING_STARTED
|
||||
|
||||
def render_custom_url(self):
|
||||
result = self.keyboard.render("Enter URL", "for Custom Software")
|
||||
|
||||
# Enter pressed
|
||||
if result == 1:
|
||||
url = self.keyboard.text
|
||||
self.keyboard.clear()
|
||||
if url:
|
||||
self.download(url)
|
||||
|
||||
# Cancel pressed
|
||||
elif result == 0:
|
||||
self.state = SetupState.SOFTWARE_SELECTION
|
||||
|
||||
def download(self, url: str):
|
||||
# autocomplete incomplete URLs
|
||||
if re.match("^([^/.]+)/([^/]+)$", url):
|
||||
url = f"https://installer.comma.ai/{url}"
|
||||
|
||||
self.download_url = url
|
||||
self.state = SetupState.DOWNLOADING
|
||||
|
||||
self.download_thread = threading.Thread(target=self._download_thread, daemon=True)
|
||||
self.download_thread.start()
|
||||
|
||||
def _download_thread(self):
|
||||
try:
|
||||
import tempfile
|
||||
|
||||
_, tmpfile = tempfile.mkstemp(prefix="installer_")
|
||||
|
||||
headers = {"User-Agent": USER_AGENT, "X-openpilot-serial": HARDWARE.get_serial()}
|
||||
req = urllib.request.Request(self.download_url, headers=headers)
|
||||
|
||||
with open(tmpfile, 'wb') as f, urllib.request.urlopen(req, timeout=30) as response:
|
||||
total_size = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
block_size = 8192
|
||||
|
||||
while True:
|
||||
buffer = response.read(block_size)
|
||||
if not buffer:
|
||||
break
|
||||
|
||||
downloaded += len(buffer)
|
||||
f.write(buffer)
|
||||
|
||||
if total_size:
|
||||
self.download_progress = int(downloaded * 100 / total_size)
|
||||
|
||||
is_elf = False
|
||||
with open(tmpfile, 'rb') as f:
|
||||
header = f.read(4)
|
||||
is_elf = header == b'\x7fELF'
|
||||
|
||||
if not is_elf:
|
||||
self.download_failed(self.download_url, "No custom software found at this URL.")
|
||||
return
|
||||
|
||||
os.rename(tmpfile, "/tmp/installer")
|
||||
os.chmod("/tmp/installer", 0o755)
|
||||
|
||||
with open("/tmp/installer_url", "w") as f:
|
||||
f.write(self.download_url)
|
||||
|
||||
gui_app.request_close()
|
||||
|
||||
except Exception:
|
||||
error_msg = "Ensure the entered URL is valid, and the device's internet connection is good."
|
||||
self.download_failed(self.download_url, error_msg)
|
||||
|
||||
def download_failed(self, url: str, reason: str):
|
||||
self.failed_url = url
|
||||
self.failed_reason = reason
|
||||
self.state = SetupState.DOWNLOAD_FAILED
|
||||
|
||||
|
||||
def main():
|
||||
try:
|
||||
gui_app.init_window("Setup")
|
||||
setup = Setup()
|
||||
for _ in gui_app.render():
|
||||
setup.render(rl.Rectangle(0, 0, gui_app.width, gui_app.height))
|
||||
setup.close()
|
||||
except Exception as e:
|
||||
print(f"Setup error: {e}")
|
||||
finally:
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+86
-54
@@ -1,75 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
import pyray as rl
|
||||
import os
|
||||
import select
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.window import BaseWindow
|
||||
from openpilot.system.ui.text import wrap_text
|
||||
|
||||
# Constants
|
||||
PROGRESS_BAR_WIDTH = 1000
|
||||
PROGRESS_BAR_HEIGHT = 20
|
||||
ROTATION_TIME_SECONDS = 1.0 # Time for one full circle
|
||||
MARGIN = 200
|
||||
DEGREES_PER_SECOND = 360.0 # one full rotation per second
|
||||
MARGIN_H = 100
|
||||
TEXTURE_SIZE = 360
|
||||
FONT_SIZE = 80
|
||||
FONT_SIZE = 96
|
||||
LINE_HEIGHT = 104
|
||||
DARKGRAY = (55, 55, 55, 255)
|
||||
|
||||
|
||||
def clamp(value, min_value, max_value):
|
||||
return max(min(value, max_value), min_value)
|
||||
|
||||
|
||||
def check_input_non_blocking():
|
||||
if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
|
||||
return sys.stdin.readline().strip()
|
||||
return ""
|
||||
class SpinnerRenderer:
|
||||
def __init__(self):
|
||||
self._comma_texture = gui_app.texture("images/spinner_comma.png", TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
self._spinner_texture = gui_app.texture("images/spinner_track.png", TEXTURE_SIZE, TEXTURE_SIZE, alpha_premultiply=True)
|
||||
self._rotation = 0.0
|
||||
self._progress: int | None = None
|
||||
self._wrapped_lines: list[str] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def set_text(self, text: str) -> None:
|
||||
with self._lock:
|
||||
if text.isdigit():
|
||||
self._progress = clamp(int(text), 0, 100)
|
||||
self._wrapped_lines = []
|
||||
else:
|
||||
self._progress = None
|
||||
self._wrapped_lines = wrap_text(text, FONT_SIZE, gui_app.width - MARGIN_H)
|
||||
|
||||
def render(self):
|
||||
with self._lock:
|
||||
progress = self._progress
|
||||
wrapped_lines = self._wrapped_lines
|
||||
|
||||
if wrapped_lines:
|
||||
# Calculate total height required for spinner and text
|
||||
spacing = 50
|
||||
total_height = TEXTURE_SIZE + spacing + len(wrapped_lines) * LINE_HEIGHT
|
||||
center_y = (gui_app.height - total_height) / 2.0 + TEXTURE_SIZE / 2.0
|
||||
else:
|
||||
# Center spinner vertically
|
||||
spacing = 150
|
||||
center_y = gui_app.height / 2.0
|
||||
y_pos = center_y + TEXTURE_SIZE / 2.0 + spacing
|
||||
|
||||
center = rl.Vector2(gui_app.width / 2.0, center_y)
|
||||
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
|
||||
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
|
||||
|
||||
delta_time = rl.get_frame_time()
|
||||
self._rotation = (self._rotation + DEGREES_PER_SECOND * delta_time) % 360.0
|
||||
|
||||
# Draw rotating spinner and static comma logo
|
||||
rl.draw_texture_pro(self._spinner_texture, rl.Rectangle(0, 0, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
rl.Rectangle(center.x, center.y, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
spinner_origin, self._rotation, rl.WHITE)
|
||||
rl.draw_texture_v(self._comma_texture, comma_position, rl.WHITE)
|
||||
|
||||
# Display the progress bar or text based on user input
|
||||
if progress is not None:
|
||||
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar, 1, 10, DARKGRAY)
|
||||
|
||||
bar.width *= progress / 100.0
|
||||
rl.draw_rectangle_rounded(bar, 1, 10, rl.WHITE)
|
||||
elif wrapped_lines:
|
||||
for i, line in enumerate(wrapped_lines):
|
||||
text_size = rl.measure_text_ex(gui_app.font(), line, FONT_SIZE, 0.0)
|
||||
rl.draw_text_ex(gui_app.font(), line, rl.Vector2(center.x - text_size.x / 2, y_pos + i * LINE_HEIGHT),
|
||||
FONT_SIZE, 0.0, rl.WHITE)
|
||||
|
||||
|
||||
class Spinner(BaseWindow[SpinnerRenderer]):
|
||||
def __init__(self):
|
||||
super().__init__("Spinner")
|
||||
|
||||
def _create_renderer(self):
|
||||
return SpinnerRenderer()
|
||||
|
||||
def update(self, spinner_text: str):
|
||||
if self._renderer is not None:
|
||||
self._renderer.set_text(spinner_text)
|
||||
|
||||
def update_progress(self, cur: float, total: float):
|
||||
self.update(str(round(100 * cur / total)))
|
||||
|
||||
|
||||
def main():
|
||||
gui_app.init_window("Spinner")
|
||||
|
||||
# Load textures
|
||||
comma_texture = gui_app.load_texture_from_image(os.path.join(BASEDIR, "selfdrive/assets/img_spinner_comma.png"), TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
spinner_texture = gui_app.load_texture_from_image(os.path.join(BASEDIR, "selfdrive/assets/img_spinner_track.png"), TEXTURE_SIZE, TEXTURE_SIZE)
|
||||
|
||||
# Initial values
|
||||
rotation = 0.0
|
||||
user_input = ""
|
||||
center = rl.Vector2(gui_app.width / 2.0, gui_app.height / 2.0)
|
||||
spinner_origin = rl.Vector2(TEXTURE_SIZE / 2.0, TEXTURE_SIZE / 2.0)
|
||||
comma_position = rl.Vector2(center.x - TEXTURE_SIZE / 2.0, center.y - TEXTURE_SIZE / 2.0)
|
||||
|
||||
for _ in gui_app.render():
|
||||
fps = rl.get_fps()
|
||||
if fps > 0:
|
||||
degrees_per_frame = 360.0 / (ROTATION_TIME_SECONDS * fps)
|
||||
rotation = (rotation + degrees_per_frame) % 360.0
|
||||
|
||||
# Draw rotating spinner and static comma logo
|
||||
rl.draw_texture_pro(spinner_texture, rl.Rectangle(0, 0, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
rl.Rectangle(center.x, center.y, TEXTURE_SIZE, TEXTURE_SIZE),
|
||||
spinner_origin, rotation, rl.WHITE)
|
||||
rl.draw_texture_v(comma_texture, comma_position, rl.WHITE)
|
||||
|
||||
# Read user input
|
||||
if input_str := check_input_non_blocking():
|
||||
user_input = input_str
|
||||
|
||||
# Display progress bar or text based on user input
|
||||
if user_input:
|
||||
y_pos = rl.get_screen_height() - MARGIN - PROGRESS_BAR_HEIGHT
|
||||
if user_input.isdigit():
|
||||
progress = clamp(int(user_input), 0, 100)
|
||||
bar = rl.Rectangle(center.x - PROGRESS_BAR_WIDTH / 2.0, y_pos, PROGRESS_BAR_WIDTH, PROGRESS_BAR_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar, 0.5, 10, rl.GRAY)
|
||||
|
||||
bar.width *= progress / 100.0
|
||||
rl.draw_rectangle_rounded(bar, 0.5, 10, rl.WHITE)
|
||||
else:
|
||||
text_size = rl.measure_text_ex(gui_app.font(), user_input, FONT_SIZE, 1.0)
|
||||
rl.draw_text_ex(gui_app.font(), user_input,
|
||||
rl.Vector2(center.x - text_size.x / 2, y_pos), FONT_SIZE, 1.0, rl.WHITE)
|
||||
with Spinner() as s:
|
||||
s.update("Spinner text")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+63
-36
@@ -1,16 +1,17 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import re
|
||||
import time
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.hardware import HARDWARE, PC
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.window import BaseWindow
|
||||
|
||||
MARGIN = 50
|
||||
SPACING = 50
|
||||
FONT_SIZE = 60
|
||||
LINE_HEIGHT = 64
|
||||
SPACING = 40
|
||||
FONT_SIZE = 72
|
||||
LINE_HEIGHT = 80
|
||||
BUTTON_SIZE = rl.Vector2(310, 160)
|
||||
|
||||
DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necessary.
|
||||
@@ -18,47 +19,73 @@ DEMO_TEXT = """This is a sample text that will be wrapped and scrolled if necess
|
||||
|
||||
def wrap_text(text, font_size, max_width):
|
||||
lines = []
|
||||
current_line = ""
|
||||
font = gui_app.font()
|
||||
|
||||
for word in text.split():
|
||||
test_line = current_line + word + " "
|
||||
if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
for paragraph in text.split("\n"):
|
||||
if not paragraph.strip():
|
||||
# Don't add empty lines first, ensuring wrap_text("") returns []
|
||||
if lines:
|
||||
lines.append("")
|
||||
continue
|
||||
indent = re.match(r"^\s*", paragraph).group()
|
||||
current_line = indent
|
||||
words = re.split(r"(\s+)", paragraph[len(indent):])
|
||||
while len(words):
|
||||
word = words.pop(0)
|
||||
test_line = current_line + word + (words.pop(0) if words else "")
|
||||
if rl.measure_text_ex(font, test_line, font_size, 0).x <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
lines.append(current_line)
|
||||
current_line = word + " "
|
||||
current_line = current_line.rstrip()
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word + " "
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
gui_app.init_window("Text")
|
||||
class TextWindowRenderer:
|
||||
def __init__(self, text: str):
|
||||
self._textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2)
|
||||
self._wrapped_lines = wrap_text(text, FONT_SIZE, self._textarea_rect.width - 20)
|
||||
self._content_rect = rl.Rectangle(0, 0, self._textarea_rect.width - 20, len(self._wrapped_lines) * LINE_HEIGHT)
|
||||
self._scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True)
|
||||
self._scroll_panel._offset.y = -max(self._content_rect.height - self._textarea_rect.height, 0)
|
||||
|
||||
text_content = sys.argv[1] if len(sys.argv) > 1 else DEMO_TEXT
|
||||
|
||||
textarea_rect = rl.Rectangle(MARGIN, MARGIN, gui_app.width - MARGIN * 2, gui_app.height - MARGIN * 2)
|
||||
wrapped_lines = wrap_text(text_content, FONT_SIZE, textarea_rect.width - 20)
|
||||
content_rect = rl.Rectangle(0, 0, textarea_rect.width - 20, len(wrapped_lines) * LINE_HEIGHT)
|
||||
scroll_panel = GuiScrollPanel(show_vertical_scroll_bar=True)
|
||||
|
||||
for _ in gui_app.render():
|
||||
scroll = scroll_panel.handle_scroll(textarea_rect, content_rect)
|
||||
|
||||
rl.begin_scissor_mode(int(textarea_rect.x), int(textarea_rect.y), int(textarea_rect.width), int(textarea_rect.height))
|
||||
for i, line in enumerate(wrapped_lines):
|
||||
position = rl.Vector2(textarea_rect.x + scroll.x, textarea_rect.y + scroll.y + i * LINE_HEIGHT)
|
||||
if position.y + LINE_HEIGHT < textarea_rect.y or position.y > textarea_rect.y + textarea_rect.height:
|
||||
def render(self):
|
||||
scroll = self._scroll_panel.handle_scroll(self._textarea_rect, self._content_rect)
|
||||
rl.begin_scissor_mode(int(self._textarea_rect.x), int(self._textarea_rect.y), int(self._textarea_rect.width), int(self._textarea_rect.height))
|
||||
for i, line in enumerate(self._wrapped_lines):
|
||||
position = rl.Vector2(self._textarea_rect.x + scroll.x, self._textarea_rect.y + scroll.y + i * LINE_HEIGHT)
|
||||
if position.y + LINE_HEIGHT < self._textarea_rect.y or position.y > self._textarea_rect.y + self._textarea_rect.height:
|
||||
continue
|
||||
rl.draw_text_ex(gui_app.font(), line.strip(), position, FONT_SIZE, 0, rl.WHITE)
|
||||
rl.draw_text_ex(gui_app.font(), line, position, FONT_SIZE, 0, rl.WHITE)
|
||||
rl.end_scissor_mode()
|
||||
|
||||
button_bounds = rl.Rectangle(gui_app.width - MARGIN - BUTTON_SIZE.x, gui_app.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y)
|
||||
if gui_button(button_bounds, "Reboot", button_style=ButtonStyle.TRANSPARENT):
|
||||
HARDWARE.reboot()
|
||||
button_bounds = rl.Rectangle(gui_app.width - MARGIN - BUTTON_SIZE.x - SPACING, gui_app.height - MARGIN - BUTTON_SIZE.y, BUTTON_SIZE.x, BUTTON_SIZE.y)
|
||||
ret = gui_button(button_bounds, "Exit" if PC else "Reboot", button_style=ButtonStyle.TRANSPARENT)
|
||||
if ret:
|
||||
if PC:
|
||||
gui_app.request_close()
|
||||
else:
|
||||
HARDWARE.reboot()
|
||||
return ret
|
||||
|
||||
|
||||
class TextWindow(BaseWindow[TextWindowRenderer]):
|
||||
def __init__(self, text: str):
|
||||
self._text = text
|
||||
super().__init__("Text")
|
||||
|
||||
def _create_renderer(self):
|
||||
return TextWindowRenderer(self._text)
|
||||
|
||||
def wait_for_exit(self):
|
||||
while self._thread.is_alive():
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
with TextWindow(DEMO_TEXT):
|
||||
time.sleep(30)
|
||||
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_text_box, gui_label
|
||||
from openpilot.system.ui.lib.wifi_manager import WifiManagerWrapper
|
||||
from openpilot.system.ui.widgets.network import WifiManagerUI
|
||||
|
||||
|
||||
# Constants
|
||||
MARGIN = 50
|
||||
BUTTON_HEIGHT = 160
|
||||
BUTTON_WIDTH = 400
|
||||
PROGRESS_BAR_HEIGHT = 72
|
||||
TITLE_FONT_SIZE = 80
|
||||
BODY_FONT_SIZE = 65
|
||||
BACKGROUND_COLOR = rl.BLACK
|
||||
PROGRESS_BG_COLOR = rl.Color(41, 41, 41, 255)
|
||||
PROGRESS_COLOR = rl.Color(54, 77, 239, 255)
|
||||
|
||||
|
||||
class Screen(IntEnum):
|
||||
PROMPT = 0
|
||||
WIFI = 1
|
||||
PROGRESS = 2
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self, updater_path, manifest_path):
|
||||
self.updater = updater_path
|
||||
self.manifest = manifest_path
|
||||
self.current_screen = Screen.PROMPT
|
||||
|
||||
self.progress_value = 0
|
||||
self.progress_text = "Loading..."
|
||||
self.show_reboot_button = False
|
||||
self.process = None
|
||||
self.update_thread = None
|
||||
self.wifi_manager = WifiManagerWrapper()
|
||||
self.wifi_manager_ui = WifiManagerUI(self.wifi_manager)
|
||||
|
||||
def install_update(self):
|
||||
self.current_screen = Screen.PROGRESS
|
||||
self.progress_value = 0
|
||||
self.progress_text = "Downloading..."
|
||||
self.show_reboot_button = False
|
||||
|
||||
# Start the update process in a separate thread
|
||||
self.update_thread = threading.Thread(target=self._run_update_process)
|
||||
self.update_thread.daemon = True
|
||||
self.update_thread.start()
|
||||
|
||||
def _run_update_process(self):
|
||||
# TODO: just import it and run in a thread without a subprocess
|
||||
cmd = [self.updater, "--swap", self.manifest]
|
||||
self.process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1, universal_newlines=True)
|
||||
|
||||
for line in self.process.stdout:
|
||||
parts = line.strip().split(":")
|
||||
if len(parts) == 2:
|
||||
self.progress_text = parts[0]
|
||||
try:
|
||||
self.progress_value = int(float(parts[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
exit_code = self.process.wait()
|
||||
if exit_code == 0:
|
||||
HARDWARE.reboot()
|
||||
else:
|
||||
self.progress_text = "Update failed"
|
||||
self.show_reboot_button = True
|
||||
|
||||
def render_prompt_screen(self):
|
||||
# Title
|
||||
title_rect = rl.Rectangle(MARGIN + 50, 250, gui_app.width - MARGIN * 2 - 100, TITLE_FONT_SIZE)
|
||||
gui_label(title_rect, "Update Required", TITLE_FONT_SIZE, font_weight=FontWeight.BOLD)
|
||||
|
||||
# Description
|
||||
desc_text = ("An operating system update is required. Connect your device to Wi-Fi for the fastest update experience. " +
|
||||
"The download size is approximately 1GB.")
|
||||
|
||||
desc_rect = rl.Rectangle(MARGIN + 50, 250 + TITLE_FONT_SIZE + 75, gui_app.width - MARGIN * 2 - 100, BODY_FONT_SIZE * 3)
|
||||
gui_text_box(desc_rect, desc_text, BODY_FONT_SIZE)
|
||||
|
||||
# Buttons at the bottom
|
||||
button_y = gui_app.height - MARGIN - BUTTON_HEIGHT
|
||||
button_width = (gui_app.width - MARGIN * 3) // 2
|
||||
|
||||
# WiFi button
|
||||
wifi_button_rect = rl.Rectangle(MARGIN, button_y, button_width, BUTTON_HEIGHT)
|
||||
if gui_button(wifi_button_rect, "Connect to Wi-Fi"):
|
||||
self.current_screen = Screen.WIFI
|
||||
return # Return to avoid processing other buttons after screen change
|
||||
|
||||
# Install button
|
||||
install_button_rect = rl.Rectangle(MARGIN * 2 + button_width, button_y, button_width, BUTTON_HEIGHT)
|
||||
if gui_button(install_button_rect, "Install", button_style=ButtonStyle.PRIMARY):
|
||||
self.install_update()
|
||||
return # Return to avoid further processing after action
|
||||
|
||||
def render_wifi_screen(self):
|
||||
# Draw the Wi-Fi manager UI
|
||||
wifi_rect = rl.Rectangle(MARGIN + 50, MARGIN, gui_app.width - MARGIN * 2 - 100, gui_app.height - MARGIN * 2 - BUTTON_HEIGHT - 20)
|
||||
self.wifi_manager_ui.render(wifi_rect)
|
||||
if self.wifi_manager_ui.require_full_screen:
|
||||
return
|
||||
|
||||
back_button_rect = rl.Rectangle(MARGIN, gui_app.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
if gui_button(back_button_rect, "Back"):
|
||||
self.current_screen = Screen.PROMPT
|
||||
return # Return to avoid processing other interactions after screen change
|
||||
|
||||
def render_progress_screen(self):
|
||||
title_rect = rl.Rectangle(MARGIN + 100, 330, gui_app.width - MARGIN * 2 - 200, 100)
|
||||
gui_label(title_rect, self.progress_text, 90, font_weight=FontWeight.SEMI_BOLD)
|
||||
|
||||
# Progress bar
|
||||
bar_rect = rl.Rectangle(MARGIN + 100, 330 + 100 + 100, gui_app.width - MARGIN * 2 - 200, PROGRESS_BAR_HEIGHT)
|
||||
rl.draw_rectangle_rounded(bar_rect, 0.5, 10, PROGRESS_BG_COLOR)
|
||||
|
||||
# Calculate the width of the progress chunk
|
||||
progress_width = (bar_rect.width * self.progress_value) / 100
|
||||
if progress_width > 0:
|
||||
progress_rect = rl.Rectangle(bar_rect.x, bar_rect.y, progress_width, bar_rect.height)
|
||||
rl.draw_rectangle_rounded(progress_rect, 0.5, 10, PROGRESS_COLOR)
|
||||
|
||||
# Show reboot button if needed
|
||||
if self.show_reboot_button:
|
||||
reboot_rect = rl.Rectangle(MARGIN + 100, gui_app.height - MARGIN - BUTTON_HEIGHT, BUTTON_WIDTH, BUTTON_HEIGHT)
|
||||
if gui_button(reboot_rect, "Reboot"):
|
||||
# Return True to signal main loop to exit before rebooting
|
||||
HARDWARE.reboot()
|
||||
return
|
||||
|
||||
def render(self):
|
||||
if self.current_screen == Screen.PROMPT:
|
||||
self.render_prompt_screen()
|
||||
elif self.current_screen == Screen.WIFI:
|
||||
self.render_wifi_screen()
|
||||
elif self.current_screen == Screen.PROGRESS:
|
||||
self.render_progress_screen()
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: updater.py <updater_path> <manifest_path>")
|
||||
sys.exit(1)
|
||||
|
||||
updater_path = sys.argv[1]
|
||||
manifest_path = sys.argv[2]
|
||||
|
||||
try:
|
||||
gui_app.init_window("System Update")
|
||||
updater = Updater(updater_path, manifest_path)
|
||||
for _ in gui_app.render():
|
||||
updater.render()
|
||||
finally:
|
||||
# Make sure we clean up even if there's an error
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,5 @@
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle
|
||||
from openpilot.system.ui.lib.label import gui_text_box
|
||||
|
||||
@@ -11,10 +12,9 @@ TEXT_AREA_HEIGHT_REDUCTION = 200
|
||||
BACKGROUND_COLOR = rl.Color(27, 27, 27, 255)
|
||||
|
||||
|
||||
def confirm_dialog(rect: rl.Rectangle, message: str, confirm_text: str, cancel_text: str = "Cancel") -> int:
|
||||
# Calculate dialog position and size, centered within the parent rectangle
|
||||
dialog_x = rect.x + (rect.width - DIALOG_WIDTH) / 2
|
||||
dialog_y = rect.y + (rect.height - DIALOG_HEIGHT) / 2
|
||||
def confirm_dialog(message: str, confirm_text: str, cancel_text: str = "Cancel") -> int:
|
||||
dialog_x = (gui_app.width - DIALOG_WIDTH) / 2
|
||||
dialog_y = (gui_app.height - DIALOG_HEIGHT) / 2
|
||||
dialog_rect = rl.Rectangle(dialog_x, dialog_y, DIALOG_WIDTH, DIALOG_HEIGHT)
|
||||
|
||||
# Calculate button positions at the bottom of the dialog
|
||||
@@ -27,19 +27,14 @@ def confirm_dialog(rect: rl.Rectangle, message: str, confirm_text: str, cancel_t
|
||||
yes_button = rl.Rectangle(yes_button_x, button_y, button_width, BUTTON_HEIGHT)
|
||||
|
||||
# Draw the dialog background
|
||||
rl.draw_rectangle(
|
||||
int(dialog_rect.x),
|
||||
int(dialog_rect.y),
|
||||
int(dialog_rect.width),
|
||||
int(dialog_rect.height),
|
||||
BACKGROUND_COLOR,
|
||||
)
|
||||
rl.draw_rectangle_rec(dialog_rect, BACKGROUND_COLOR)
|
||||
|
||||
# Draw the message in the dialog, centered
|
||||
text_rect = rl.Rectangle(dialog_rect.x, dialog_rect.y, dialog_rect.width, dialog_rect.height - TEXT_AREA_HEIGHT_REDUCTION)
|
||||
gui_text_box(
|
||||
text_rect,
|
||||
message,
|
||||
font_size=88,
|
||||
alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER,
|
||||
alignment_vertical=rl.GuiTextAlignmentVertical.TEXT_ALIGN_MIDDLE,
|
||||
)
|
||||
|
||||
+163
-34
@@ -1,29 +1,40 @@
|
||||
import time
|
||||
from typing import Literal
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.button import gui_button
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.button import ButtonStyle, gui_button
|
||||
from openpilot.system.ui.lib.inputbox import InputBox
|
||||
from openpilot.system.ui.lib.label import gui_label
|
||||
|
||||
KEY_FONT_SIZE = 96
|
||||
DOUBLE_CLICK_THRESHOLD = 0.5 # seconds
|
||||
DELETE_REPEAT_DELAY = 0.5
|
||||
DELETE_REPEAT_INTERVAL = 0.07
|
||||
|
||||
# Constants for special keys
|
||||
CONTENT_MARGIN = 50
|
||||
BACKSPACE_KEY = "<-"
|
||||
ENTER_KEY = "Enter"
|
||||
ENTER_KEY = "->"
|
||||
SPACE_KEY = " "
|
||||
SHIFT_KEY = "↑"
|
||||
SHIFT_DOWN_KEY = "↓"
|
||||
SHIFT_INACTIVE_KEY = "SHIFT_OFF"
|
||||
SHIFT_ACTIVE_KEY = "SHIFT_ON"
|
||||
CAPS_LOCK_KEY = "CAPS"
|
||||
NUMERIC_KEY = "123"
|
||||
SYMBOL_KEY = "#+="
|
||||
ABC_KEY = "ABC"
|
||||
|
||||
# Define keyboard layouts as a dictionary for easier access
|
||||
keyboard_layouts = {
|
||||
KEYBOARD_LAYOUTS = {
|
||||
"lowercase": [
|
||||
["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"],
|
||||
["a", "s", "d", "f", "g", "h", "j", "k", "l"],
|
||||
[SHIFT_KEY, "z", "x", "c", "v", "b", "n", "m", BACKSPACE_KEY],
|
||||
[SHIFT_INACTIVE_KEY, "z", "x", "c", "v", "b", "n", "m", BACKSPACE_KEY],
|
||||
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
|
||||
],
|
||||
"uppercase": [
|
||||
["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"],
|
||||
["A", "S", "D", "F", "G", "H", "J", "K", "L"],
|
||||
[SHIFT_DOWN_KEY, "Z", "X", "C", "V", "B", "N", "M", BACKSPACE_KEY],
|
||||
[SHIFT_ACTIVE_KEY, "Z", "X", "C", "V", "B", "N", "M", BACKSPACE_KEY],
|
||||
[NUMERIC_KEY, "/", "-", SPACE_KEY, ".", ENTER_KEY],
|
||||
],
|
||||
"numbers": [
|
||||
@@ -42,34 +53,79 @@ keyboard_layouts = {
|
||||
|
||||
|
||||
class Keyboard:
|
||||
def __init__(self, max_text_size: int = 255):
|
||||
self._layout = keyboard_layouts["lowercase"]
|
||||
self._input_text = ""
|
||||
def __init__(self, max_text_size: int = 255, min_text_size: int = 0, password_mode: bool = False, show_password_toggle: bool = False):
|
||||
self._layout_name: Literal["lowercase", "uppercase", "numbers", "specials"] = "lowercase"
|
||||
self._caps_lock = False
|
||||
self._last_shift_press_time = 0
|
||||
|
||||
self._max_text_size = max_text_size
|
||||
self._min_text_size = min_text_size
|
||||
self._input_box = InputBox(max_text_size)
|
||||
self._password_mode = password_mode
|
||||
self._show_password_toggle = show_password_toggle
|
||||
|
||||
# Backspace key repeat tracking
|
||||
self._backspace_pressed: bool = False
|
||||
self._backspace_press_time: float = 0.0
|
||||
self._backspace_last_repeat:float = 0.0
|
||||
|
||||
self._eye_open_texture = gui_app.texture("icons/eye_open.png", 81, 54)
|
||||
self._eye_closed_texture = gui_app.texture("icons/eye_closed.png", 81, 54)
|
||||
self._key_icons = {
|
||||
BACKSPACE_KEY: gui_app.texture("icons/backspace.png", 80, 80),
|
||||
SHIFT_INACTIVE_KEY: gui_app.texture("icons/shift.png", 80, 80),
|
||||
SHIFT_ACTIVE_KEY: gui_app.texture("icons/shift-fill.png", 80, 80),
|
||||
CAPS_LOCK_KEY: gui_app.texture("icons/capslock-fill.png", 80, 80),
|
||||
ENTER_KEY: gui_app.texture("icons/arrow-right.png", 80, 80),
|
||||
}
|
||||
|
||||
@property
|
||||
def text(self) -> str:
|
||||
return self._input_text
|
||||
def text(self):
|
||||
return self._input_box.text
|
||||
|
||||
def clear(self):
|
||||
self._input_text = ""
|
||||
self._layout_name = "lowercase"
|
||||
self._caps_lock = False
|
||||
self._input_box.clear()
|
||||
self._backspace_pressed = False
|
||||
|
||||
def render(self, rect, title, sub_title):
|
||||
gui_label(rl.Rectangle(rect.x, rect.y, rect.width, 95), title, 90)
|
||||
gui_label(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60), sub_title, 55, rl.GRAY)
|
||||
if gui_button(rl.Rectangle(rect.x + rect.width - 300, rect.y, 300, 100), "Cancel"):
|
||||
return -1
|
||||
def render(self, title: str, sub_title: str):
|
||||
rect = rl.Rectangle(CONTENT_MARGIN, CONTENT_MARGIN, gui_app.width - 2 * CONTENT_MARGIN, gui_app.height - 2 * CONTENT_MARGIN)
|
||||
gui_label(rl.Rectangle(rect.x, rect.y, rect.width, 95), title, 90, font_weight=FontWeight.BOLD)
|
||||
gui_label(rl.Rectangle(rect.x, rect.y + 95, rect.width, 60), sub_title, 55, font_weight=FontWeight.NORMAL)
|
||||
if gui_button(rl.Rectangle(rect.x + rect.width - 386, rect.y, 386, 125), "Cancel"):
|
||||
self.clear()
|
||||
return 0
|
||||
|
||||
# Text box for input
|
||||
rl.gui_text_box(rl.Rectangle(rect.x, rect.y + 160, rect.width, 100), self._input_text, self._max_text_size, True)
|
||||
# Draw input box and password toggle
|
||||
input_margin = 25
|
||||
input_box_rect = rl.Rectangle(rect.x + input_margin, rect.y + 160, rect.width - input_margin, 100)
|
||||
self._render_input_area(input_box_rect)
|
||||
|
||||
# Process backspace key repeat if it's held down
|
||||
if not rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
self._backspace_pressed = False
|
||||
|
||||
if self._backspace_pressed:
|
||||
current_time = time.monotonic()
|
||||
time_since_press = current_time - self._backspace_press_time
|
||||
|
||||
# After initial delay, start repeating with shorter intervals
|
||||
if time_since_press > DELETE_REPEAT_DELAY:
|
||||
time_since_last_repeat = current_time - self._backspace_last_repeat
|
||||
if time_since_last_repeat > DELETE_REPEAT_INTERVAL:
|
||||
self._input_box.delete_char_before_cursor()
|
||||
self._backspace_last_repeat = current_time
|
||||
|
||||
layout = KEYBOARD_LAYOUTS[self._layout_name]
|
||||
|
||||
h_space, v_space = 15, 15
|
||||
row_y_start = rect.y + 300 # Starting Y position for the first row
|
||||
key_height = (rect.height - 300 - 3 * v_space) / 4
|
||||
key_max_width = (rect.width - (len(self._layout[2]) - 1) * h_space) / len(self._layout[2])
|
||||
key_max_width = (rect.width - (len(layout[2]) - 1) * h_space) / len(layout[2])
|
||||
|
||||
# Iterate over the rows of keys in the current layout
|
||||
for row, keys in enumerate(self._layout):
|
||||
for row, keys in enumerate(layout):
|
||||
key_width = min((rect.width - (180 if row == 1 else 0) - h_space * (len(keys) - 1)) / len(keys), key_max_width)
|
||||
start_x = rect.x + (90 if row == 1 else 0)
|
||||
|
||||
@@ -81,24 +137,97 @@ class Keyboard:
|
||||
key_rect = rl.Rectangle(start_x, row_y_start + row * (key_height + v_space), new_width, key_height)
|
||||
start_x += new_width
|
||||
|
||||
if gui_button(key_rect, key):
|
||||
is_enabled = key != ENTER_KEY or len(self._input_box.text) >= self._min_text_size
|
||||
result = -1
|
||||
|
||||
# Check for backspace key press-and-hold
|
||||
mouse_pos = rl.get_mouse_position()
|
||||
mouse_over_key = rl.check_collision_point_rec(mouse_pos, key_rect)
|
||||
|
||||
if key == BACKSPACE_KEY and mouse_over_key:
|
||||
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
|
||||
self._backspace_pressed = True
|
||||
self._backspace_press_time = time.monotonic()
|
||||
self._backspace_last_repeat = time.monotonic()
|
||||
|
||||
if key in self._key_icons:
|
||||
if key == SHIFT_ACTIVE_KEY and self._caps_lock:
|
||||
key = CAPS_LOCK_KEY
|
||||
texture = self._key_icons[key]
|
||||
result = gui_button(key_rect, "", icon=texture, button_style=ButtonStyle.PRIMARY if key == ENTER_KEY else ButtonStyle.NORMAL, is_enabled=is_enabled)
|
||||
else:
|
||||
result = gui_button(key_rect, key, KEY_FONT_SIZE, is_enabled=is_enabled)
|
||||
|
||||
if result:
|
||||
if key == ENTER_KEY:
|
||||
return 1
|
||||
else:
|
||||
self.handle_key_press(key)
|
||||
|
||||
return 0
|
||||
return -1
|
||||
|
||||
def _render_input_area(self, input_rect: rl.Rectangle):
|
||||
if self._show_password_toggle:
|
||||
self._input_box.set_password_mode(self._password_mode)
|
||||
self._input_box.render(rl.Rectangle(input_rect.x, input_rect.y, input_rect.width - 100, input_rect.height))
|
||||
|
||||
# render eye icon
|
||||
eye_texture = self._eye_closed_texture if self._password_mode else self._eye_open_texture
|
||||
|
||||
eye_rect = rl.Rectangle(input_rect.x + input_rect.width - 90, input_rect.y, 80, input_rect.height)
|
||||
eye_x = eye_rect.x + (eye_rect.width - eye_texture.width) / 2
|
||||
eye_y = eye_rect.y + (eye_rect.height - eye_texture.height) / 2
|
||||
|
||||
rl.draw_texture_v(eye_texture, rl.Vector2(eye_x, eye_y), rl.WHITE)
|
||||
|
||||
# Handle click on eye icon
|
||||
if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(
|
||||
rl.get_mouse_position(), eye_rect
|
||||
):
|
||||
self._password_mode = not self._password_mode
|
||||
else:
|
||||
self._input_box.render(input_rect)
|
||||
|
||||
rl.draw_line_ex(
|
||||
rl.Vector2(input_rect.x, input_rect.y + input_rect.height - 2),
|
||||
rl.Vector2(input_rect.x + input_rect.width, input_rect.y + input_rect.height - 2),
|
||||
3.0, # 3 pixel thickness
|
||||
rl.Color(189, 189, 189, 255),
|
||||
)
|
||||
|
||||
def handle_key_press(self, key):
|
||||
if key in (SHIFT_DOWN_KEY, ABC_KEY):
|
||||
self._layout = keyboard_layouts["lowercase"]
|
||||
elif key == SHIFT_KEY:
|
||||
self._layout = keyboard_layouts["uppercase"]
|
||||
if key in (CAPS_LOCK_KEY, ABC_KEY):
|
||||
self._caps_lock = False
|
||||
self._layout_name = "lowercase"
|
||||
elif key == SHIFT_INACTIVE_KEY:
|
||||
self._last_shift_press_time = time.monotonic()
|
||||
self._layout_name = "uppercase"
|
||||
elif key == SHIFT_ACTIVE_KEY:
|
||||
if time.monotonic() - self._last_shift_press_time < DOUBLE_CLICK_THRESHOLD:
|
||||
self._caps_lock = True
|
||||
else:
|
||||
self._layout_name = "lowercase"
|
||||
elif key == NUMERIC_KEY:
|
||||
self._layout = keyboard_layouts["numbers"]
|
||||
self._layout_name = "numbers"
|
||||
elif key == SYMBOL_KEY:
|
||||
self._layout = keyboard_layouts["specials"]
|
||||
elif key == BACKSPACE_KEY and len(self._input_text) > 0:
|
||||
self._input_text = self._input_text[:-1]
|
||||
elif key != BACKSPACE_KEY and len(self._input_text) < self._max_text_size:
|
||||
self._input_text += key
|
||||
self._layout_name = "specials"
|
||||
elif key == BACKSPACE_KEY:
|
||||
self._input_box.delete_char_before_cursor()
|
||||
else:
|
||||
self._input_box.add_char_at_cursor(key)
|
||||
if not self._caps_lock and self._layout_name == "uppercase":
|
||||
self._layout_name = "lowercase"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
gui_app.init_window("Keyboard")
|
||||
keyboard = Keyboard(min_text_size=8, show_password_toggle=True)
|
||||
for _ in gui_app.render():
|
||||
result = keyboard.render("Keyboard", "Type here")
|
||||
if result == 1:
|
||||
print(f"You typed: {keyboard.text}")
|
||||
gui_app.request_close()
|
||||
elif result == 0:
|
||||
print("Canceled")
|
||||
gui_app.request_close()
|
||||
gui_app.close()
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
from dataclasses import dataclass
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
|
||||
import pyray as rl
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.lib.button import ButtonStyle, gui_button
|
||||
from openpilot.system.ui.lib.label import gui_label
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
from openpilot.system.ui.lib.wifi_manager import NetworkInfo, WifiManagerCallbacks, WifiManagerWrapper, SecurityType
|
||||
from openpilot.system.ui.widgets.keyboard import Keyboard
|
||||
from openpilot.system.ui.widgets.confirm_dialog import confirm_dialog
|
||||
|
||||
NM_DEVICE_STATE_NEED_AUTH = 60
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 64
|
||||
ITEM_HEIGHT = 160
|
||||
ICON_SIZE = 50
|
||||
|
||||
STRENGTH_ICONS = [
|
||||
"icons/wifi_strength_low.png",
|
||||
"icons/wifi_strength_medium.png",
|
||||
"icons/wifi_strength_high.png",
|
||||
"icons/wifi_strength_full.png",
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class StateIdle:
|
||||
action: Literal["idle"] = "idle"
|
||||
|
||||
@dataclass
|
||||
class StateConnecting:
|
||||
network: NetworkInfo
|
||||
action: Literal["connecting"] = "connecting"
|
||||
|
||||
@dataclass
|
||||
class StateNeedsAuth:
|
||||
network: NetworkInfo
|
||||
action: Literal["needs_auth"] = "needs_auth"
|
||||
|
||||
@dataclass
|
||||
class StateShowForgetConfirm:
|
||||
network: NetworkInfo
|
||||
action: Literal["show_forget_confirm"] = "show_forget_confirm"
|
||||
|
||||
@dataclass
|
||||
class StateForgetting:
|
||||
network: NetworkInfo
|
||||
action: Literal["forgetting"] = "forgetting"
|
||||
|
||||
UIState = StateIdle | StateConnecting | StateNeedsAuth | StateShowForgetConfirm | StateForgetting
|
||||
|
||||
|
||||
class WifiManagerUI:
|
||||
def __init__(self, wifi_manager: WifiManagerWrapper):
|
||||
self.state: UIState = StateIdle()
|
||||
self.btn_width: int = 200
|
||||
self.scroll_panel = GuiScrollPanel()
|
||||
self.keyboard = Keyboard(max_text_size=MAX_PASSWORD_LENGTH, min_text_size=MIN_PASSWORD_LENGTH, show_password_toggle=True)
|
||||
|
||||
self._networks: list[NetworkInfo] = []
|
||||
self._lock = Lock()
|
||||
self.wifi_manager = wifi_manager
|
||||
|
||||
self.wifi_manager.set_callbacks(
|
||||
WifiManagerCallbacks(
|
||||
need_auth = self._on_need_auth,
|
||||
activated = self._on_activated,
|
||||
forgotten = self._on_forgotten,
|
||||
networks_updated = self._on_network_updated,
|
||||
connection_failed = self._on_connection_failed
|
||||
)
|
||||
)
|
||||
self.wifi_manager.start()
|
||||
self.wifi_manager.connect()
|
||||
|
||||
def render(self, rect: rl.Rectangle):
|
||||
with self._lock:
|
||||
if not self._networks:
|
||||
gui_label(rect, "Scanning Wi-Fi networks...", 72, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
return
|
||||
|
||||
match self.state:
|
||||
case StateNeedsAuth(network):
|
||||
result = self.keyboard.render("Enter password", f"for {network.ssid}")
|
||||
if result == 1:
|
||||
password = self.keyboard.text
|
||||
self.keyboard.clear()
|
||||
|
||||
if len(password) >= MIN_PASSWORD_LENGTH:
|
||||
self.connect_to_network(network, password)
|
||||
elif result == 0:
|
||||
self.state = StateIdle()
|
||||
|
||||
case StateShowForgetConfirm(network):
|
||||
result = confirm_dialog(f'Forget Wi-Fi Network "{network.ssid}"?', "Forget")
|
||||
if result == 1:
|
||||
self.forget_network(network)
|
||||
elif result == 0:
|
||||
self.state = StateIdle()
|
||||
|
||||
case _:
|
||||
self._draw_network_list(rect)
|
||||
|
||||
@property
|
||||
def require_full_screen(self) -> bool:
|
||||
"""Check if the WiFi UI requires exclusive full-screen rendering."""
|
||||
with self._lock:
|
||||
return isinstance(self.state, (StateNeedsAuth, StateShowForgetConfirm))
|
||||
|
||||
def _draw_network_list(self, rect: rl.Rectangle):
|
||||
content_rect = rl.Rectangle(rect.x, rect.y, rect.width, len(self._networks) * ITEM_HEIGHT)
|
||||
offset = self.scroll_panel.handle_scroll(rect, content_rect)
|
||||
clicked = self.scroll_panel.is_click_valid()
|
||||
|
||||
rl.begin_scissor_mode(int(rect.x), int(rect.y), int(rect.width), int(rect.height))
|
||||
for i, network in enumerate(self._networks):
|
||||
y_offset = rect.y + i * ITEM_HEIGHT + offset.y
|
||||
item_rect = rl.Rectangle(rect.x, y_offset, rect.width, ITEM_HEIGHT)
|
||||
if not rl.check_collision_recs(item_rect, rect):
|
||||
continue
|
||||
|
||||
self._draw_network_item(item_rect, network, clicked)
|
||||
if i < len(self._networks) - 1:
|
||||
line_y = int(item_rect.y + item_rect.height - 1)
|
||||
rl.draw_line(int(item_rect.x), int(line_y), int(item_rect.x + item_rect.width), line_y, rl.LIGHTGRAY)
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
def _draw_network_item(self, rect, network: NetworkInfo, clicked: bool):
|
||||
spacing = 50
|
||||
ssid_rect = rl.Rectangle(rect.x, rect.y, rect.width - self.btn_width * 2, ITEM_HEIGHT)
|
||||
signal_icon_rect = rl.Rectangle(rect.x + rect.width - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
|
||||
security_icon_rect = rl.Rectangle(signal_icon_rect.x - spacing - ICON_SIZE, rect.y + (ITEM_HEIGHT - ICON_SIZE) / 2, ICON_SIZE, ICON_SIZE)
|
||||
|
||||
gui_label(ssid_rect, network.ssid, 55)
|
||||
|
||||
status_text = ""
|
||||
match self.state:
|
||||
case StateConnecting(network=connecting):
|
||||
if connecting.ssid == network.ssid:
|
||||
status_text = "CONNECTING..."
|
||||
case StateForgetting(network=forgetting):
|
||||
if forgetting.ssid == network.ssid:
|
||||
status_text = "FORGETTING..."
|
||||
|
||||
if status_text:
|
||||
status_text_rect = rl.Rectangle(security_icon_rect.x - 410, rect.y, 410, ITEM_HEIGHT)
|
||||
gui_label(status_text_rect, status_text, font_size=48, alignment=rl.GuiTextAlignment.TEXT_ALIGN_CENTER)
|
||||
else:
|
||||
# If the network is saved, show the "Forget" button
|
||||
if network.is_saved:
|
||||
forget_btn_rect = rl.Rectangle(security_icon_rect.x - self.btn_width - spacing,
|
||||
rect.y + (ITEM_HEIGHT - 80) / 2,
|
||||
self.btn_width,
|
||||
80,
|
||||
)
|
||||
if isinstance(self.state, StateIdle) and gui_button(forget_btn_rect, "Forget", button_style=ButtonStyle.ACTION) and clicked:
|
||||
self.state = StateShowForgetConfirm(network)
|
||||
|
||||
self._draw_status_icon(security_icon_rect, network)
|
||||
self._draw_signal_strength_icon(signal_icon_rect, network)
|
||||
|
||||
if isinstance(self.state, StateIdle) and rl.check_collision_point_rec(rl.get_mouse_position(), ssid_rect) and clicked:
|
||||
if not network.is_saved and network.security_type != SecurityType.OPEN:
|
||||
self.state = StateNeedsAuth(network)
|
||||
elif not network.is_connected:
|
||||
self.connect_to_network(network)
|
||||
|
||||
def _draw_status_icon(self, rect, network: NetworkInfo):
|
||||
"""Draw the status icon based on network's connection state"""
|
||||
icon_file = None
|
||||
if network.is_connected:
|
||||
icon_file = "icons/checkmark.png"
|
||||
elif network.security_type == SecurityType.UNSUPPORTED:
|
||||
icon_file = "icons/circled_slash.png"
|
||||
elif network.security_type != SecurityType.OPEN:
|
||||
icon_file = "icons/lock_closed.png"
|
||||
|
||||
if not icon_file:
|
||||
return
|
||||
|
||||
texture = gui_app.texture(icon_file, ICON_SIZE, ICON_SIZE)
|
||||
icon_rect = rl.Vector2(rect.x, rect.y + (ICON_SIZE - texture.height) / 2)
|
||||
rl.draw_texture_v(texture, icon_rect, rl.WHITE)
|
||||
|
||||
def _draw_signal_strength_icon(self, rect: rl.Rectangle, network: NetworkInfo):
|
||||
"""Draw the Wi-Fi signal strength icon based on network's signal strength"""
|
||||
strength_level = max(0, min(3, round(network.strength / 33.0)))
|
||||
rl.draw_texture_v(gui_app.texture(STRENGTH_ICONS[strength_level], ICON_SIZE, ICON_SIZE), rl.Vector2(rect.x, rect.y), rl.WHITE)
|
||||
|
||||
def connect_to_network(self, network: NetworkInfo, password=''):
|
||||
self.state = StateConnecting(network)
|
||||
if network.is_saved and not password:
|
||||
self.wifi_manager.activate_connection(network.ssid)
|
||||
else:
|
||||
self.wifi_manager.connect_to_network(network.ssid, password)
|
||||
|
||||
def forget_network(self, network: NetworkInfo):
|
||||
self.state = StateForgetting(network)
|
||||
network.is_saved = False
|
||||
self.wifi_manager.forget_connection(network.ssid)
|
||||
|
||||
def _on_network_updated(self, networks: list[NetworkInfo]):
|
||||
with self._lock:
|
||||
self._networks = networks
|
||||
|
||||
def _on_need_auth(self, ssid):
|
||||
with self._lock:
|
||||
network = next((n for n in self._networks if n.ssid == ssid), None)
|
||||
if network:
|
||||
self.state = StateNeedsAuth(network)
|
||||
|
||||
def _on_activated(self):
|
||||
with self._lock:
|
||||
if isinstance(self.state, StateConnecting):
|
||||
self.state = StateIdle()
|
||||
|
||||
def _on_forgotten(self, ssid):
|
||||
with self._lock:
|
||||
if isinstance(self.state, StateForgetting):
|
||||
self.state = StateIdle()
|
||||
|
||||
def _on_connection_failed(self, ssid: str, error: str):
|
||||
with self._lock:
|
||||
if isinstance(self.state, StateConnecting):
|
||||
self.state = StateIdle()
|
||||
|
||||
|
||||
|
||||
def main():
|
||||
gui_app.init_window("Wi-Fi Manager")
|
||||
wifi_manager = WifiManagerWrapper()
|
||||
wifi_ui = WifiManagerUI(wifi_manager)
|
||||
|
||||
for _ in gui_app.render():
|
||||
wifi_ui.render(rl.Rectangle(50, 50, gui_app.width - 100, gui_app.height - 100))
|
||||
|
||||
wifi_manager.shutdown()
|
||||
gui_app.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.system.ui.lib.button import gui_button, ButtonStyle, TextAlignment
|
||||
from openpilot.system.ui.lib.label import gui_label
|
||||
from openpilot.system.ui.lib.scroll_panel import GuiScrollPanel
|
||||
|
||||
|
||||
class MultiOptionDialog:
|
||||
def __init__(self, title, options, current=""):
|
||||
self._title = title
|
||||
self._options = options
|
||||
self._current = current if current in options else ""
|
||||
self._selection = self._current
|
||||
self._option_height = 80
|
||||
self._padding = 20
|
||||
self.scroll_panel = GuiScrollPanel()
|
||||
|
||||
@property
|
||||
def selection(self):
|
||||
return self._selection
|
||||
|
||||
def render(self, rect):
|
||||
title_rect = rl.Rectangle(rect.x + self._padding, rect.y + self._padding, rect.width - 2 * self._padding, 70)
|
||||
gui_label(title_rect, self._title, 70)
|
||||
|
||||
options_y_start = rect.y + 120
|
||||
options_height = len(self._options) * (self._option_height + 10)
|
||||
options_rect = rl.Rectangle(rect.x + self._padding, options_y_start, rect.width - 2 * self._padding, options_height)
|
||||
|
||||
view_rect = rl.Rectangle(
|
||||
rect.x + self._padding, options_y_start, rect.width - 2 * self._padding, rect.height - 200 - 2 * self._padding
|
||||
)
|
||||
|
||||
offset = self.scroll_panel.handle_scroll(view_rect, options_rect)
|
||||
is_click_valid = self.scroll_panel.is_click_valid()
|
||||
|
||||
rl.begin_scissor_mode(int(view_rect.x), int(view_rect.y), int(view_rect.width), int(view_rect.height))
|
||||
|
||||
for i, option in enumerate(self._options):
|
||||
y_pos = view_rect.y + i * (self._option_height + 10) + offset.y
|
||||
item_rect = rl.Rectangle(view_rect.x, y_pos, view_rect.width, self._option_height)
|
||||
|
||||
if not rl.check_collision_recs(item_rect, view_rect):
|
||||
continue
|
||||
|
||||
is_selected = option == self._selection
|
||||
button_style = ButtonStyle.PRIMARY if is_selected else ButtonStyle.NORMAL
|
||||
|
||||
if gui_button(item_rect, option, button_style=button_style, text_alignment=TextAlignment.LEFT) and is_click_valid:
|
||||
self._selection = option
|
||||
|
||||
rl.end_scissor_mode()
|
||||
|
||||
button_y = rect.y + rect.height - 80 - self._padding
|
||||
button_width = (rect.width - 3 * self._padding) / 2
|
||||
|
||||
cancel_rect = rl.Rectangle(rect.x + self._padding, button_y, button_width, 80)
|
||||
if gui_button(cancel_rect, "Cancel"):
|
||||
return 0 # Canceled
|
||||
|
||||
select_rect = rl.Rectangle(rect.x + 2 * self._padding + button_width, button_y, button_width, 80)
|
||||
has_new_selection = self._selection != "" and self._selection != self._current
|
||||
|
||||
if gui_button(select_rect, "Select", is_enabled=has_new_selection, button_style=ButtonStyle.PRIMARY):
|
||||
return 1 # Selected
|
||||
|
||||
return -1 # Still active
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
|
||||
gui_app.init_window("Multi Option Dialog Example")
|
||||
options = [f"Option {i}" for i in range(1, 11)]
|
||||
dialog = MultiOptionDialog("Choose an option", options, options[0])
|
||||
|
||||
for _ in gui_app.render():
|
||||
result = dialog.render(rl.Rectangle(100, 100, 1024, 800))
|
||||
if result >= 0:
|
||||
print(f"Selected: {dialog.selection}" if result > 0 else "Canceled")
|
||||
break
|
||||
Reference in New Issue
Block a user