pytelem major work
This commit is contained in:
parent
c772d6e95f
commit
2d634d863d
|
@ -274,16 +274,21 @@ func (x *xBeeService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
|
|||
}
|
||||
logger.Info("connected to local xbee", "addr", x.session.LocalAddr())
|
||||
|
||||
writeJSON := json.NewEncoder(x.session)
|
||||
xbeePackets := make(chan skylab.BusEvent)
|
||||
go func(){
|
||||
decoder := json.NewDecoder(x.session)
|
||||
// these are the ways we send/recieve data. we could swap for binary format
|
||||
// TODO: buffering and/or binary encoding instead of json which is horribly ineffective.
|
||||
xbeeTxer := json.NewEncoder(x.session)
|
||||
xbeeRxer := json.NewDecoder(x.session)
|
||||
|
||||
// xbeePackets := make(chan skylab.BusEvent)
|
||||
// background task to read json packets off of the xbee and send them to the
|
||||
go func() {
|
||||
for {
|
||||
var p skylab.BusEvent
|
||||
err := decoder.Decode(&p)
|
||||
err := xbeeRxer.Decode(&p)
|
||||
if err != nil {
|
||||
logger.Error("failed to decode xbee packet")
|
||||
}
|
||||
broker.Publish("xbee", p)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
|
@ -293,7 +298,7 @@ func (x *xBeeService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
|
|||
return
|
||||
case msg := <-rxCh:
|
||||
logger.Info("got msg", "msg", msg)
|
||||
writeJSON.Encode(msg)
|
||||
xbeeTxer.Encode(msg)
|
||||
if err != nil {
|
||||
logger.Warn("error writing to xbee", "err", err)
|
||||
}
|
||||
|
@ -321,6 +326,7 @@ func (h *httpService) Start(cCtx *cli.Context, deps svcDeps) (err error) {
|
|||
|
||||
r := gotelem.TelemRouter(logger, broker, db)
|
||||
|
||||
/// TODO: use custom port if specified
|
||||
http.ListenAndServe(":8080", r)
|
||||
return
|
||||
}
|
||||
|
|
1
frame.go
1
frame.go
|
@ -35,6 +35,7 @@ const (
|
|||
// CanFilter is a basic filter for masking out data. It has an Inverted flag
|
||||
// which indicates opposite behavior (reject all packets that match Id and Mask).
|
||||
// The filter matches when (packet.Id & filter.Mask) == filter.Id
|
||||
// TODO: is this needed anymore since we are using firmware based version instead?
|
||||
type CanFilter struct {
|
||||
Id uint32
|
||||
Mask uint32
|
||||
|
|
4
http.go
4
http.go
|
@ -101,7 +101,9 @@ func apiV1(broker *Broker, db *db.TelemDb) chi.Router {
|
|||
|
||||
})
|
||||
|
||||
r.Get("/stats", func(w http.ResponseWriter, r *http.Request) {}) // v1 api stats (calls, clients, xbee connected, meta health ok)
|
||||
r.Get("/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
}) // v1 api stats (calls, clients, xbee connected, meta health ok)
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
|
@ -40,7 +40,10 @@ func Slogger(sl *slog.Logger) func(next http.Handler) http.Handler {
|
|||
}()
|
||||
|
||||
// embed the logger and the attrs for later items in the chain.
|
||||
r = r.WithContext(context.WithValue(r.Context(), SloggerAttrsKey, attrs))
|
||||
ctx := context.WithValue(r.Context(), SloggerAttrsKey, attrs)
|
||||
ctx = context.WithValue(ctx, SloggerLogKey, logger)
|
||||
// push it to the request and serve the next handler
|
||||
r = r.WithContext(ctx)
|
||||
next.ServeHTTP(ww, r)
|
||||
}
|
||||
|
||||
|
@ -64,3 +67,5 @@ func AddSlogAttr(r *http.Request, attr slog.Attr) {
|
|||
attrs = append(attrs, attr)
|
||||
|
||||
}
|
||||
|
||||
// TODO: write rest of functions
|
||||
|
|
|
@ -1,20 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="JupyterPersistentConnectionParameters">
|
||||
<option name="knownRemoteServers">
|
||||
<list>
|
||||
<JupyterConnectionParameters>
|
||||
<option name="authType" value="notebook" />
|
||||
<option name="token" value="5a7fb936e2f1eafcdefbb7fa3ea339000213214ae7e35195" />
|
||||
<option name="urlString" value="http://127.0.0.1:8888" />
|
||||
<authParams2>
|
||||
<map>
|
||||
<entry key="token" value="5a7fb936e2f1eafcdefbb7fa3ea339000213214ae7e35195" />
|
||||
</map>
|
||||
</authParams2>
|
||||
</JupyterConnectionParameters>
|
||||
</list>
|
||||
</option>
|
||||
<option name="moduleParameters">
|
||||
<map>
|
||||
<entry key="$PROJECT_DIR$/.idea/py.iml">
|
||||
|
|
|
@ -1,4 +1,11 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="Black">
|
||||
<option name="executionMode" value="BINARY" />
|
||||
<option name="pathToExecutable" value="/usr/bin/black" />
|
||||
</component>
|
||||
<component name="ProjectRootManager" version="2" project-jdk-name="Poetry (py)" project-jdk-type="Python SDK" />
|
||||
<component name="PyPackaging">
|
||||
<option name="earlyReleasesAsUpgrades" value="true" />
|
||||
</component>
|
||||
</project>
|
|
@ -2,7 +2,7 @@
|
|||
<module type="PYTHON_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="jdk" jdkName="Poetry (py)" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
<component name="TemplatesService">
|
||||
|
|
|
@ -44,7 +44,7 @@ def rip_and_tear(fname: Path, features: list[PlotFeature]):
|
|||
|
||||
for path in data[j['name']].keys():
|
||||
d = glom.glom(j['data'], path)
|
||||
ts = j['ts']
|
||||
ts = j['ts'] - 1688756556040
|
||||
data[j['name']][path].append([ts, d])
|
||||
# TODO: numpy the last list???
|
||||
return data
|
||||
|
@ -56,7 +56,7 @@ if __name__ == "__main__":
|
|||
PlotFeature("wsr_phase_current", ["phase_b_current"]),
|
||||
PlotFeature("wsr_motor_current_vector", ["iq"]),
|
||||
PlotFeature("wsr_motor_voltage_vector", ["vq"]),
|
||||
PlotFeature("wsr_bus_measurement", ["bus_current"])
|
||||
PlotFeature("wsr_velocity", ["motor_velocity"])
|
||||
]
|
||||
logs_path = Path("../../logs/")
|
||||
logfile = logs_path / "RETIME_7-2-hillstart.txt"
|
||||
|
|
2582
py/poetry.lock
generated
2582
py/poetry.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -13,7 +13,7 @@ imgui-bundle = "^0.8.5"
|
|||
numpy = "^1.24.3"
|
||||
aiohttp = "^3.8.4"
|
||||
pyside6 = "^6.5.0"
|
||||
pydantic = "^1.10.9"
|
||||
pydantic = "^2"
|
||||
pyyaml = "^6.0"
|
||||
jinja2 = "^3.1.2"
|
||||
pyqtgraph = "^0.13.3"
|
||||
|
|
|
@ -1,6 +1,14 @@
|
|||
from functools import cached_property
|
||||
|
||||
import aiohttp
|
||||
import orjson
|
||||
import threading
|
||||
from typing import Dict
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, Slot
|
||||
|
||||
from pytelem.skylab import SkylabFile
|
||||
|
||||
|
||||
# connect to websocket - create thread that handles JSON events
|
||||
class TelemetryServer(QObject):
|
||||
|
@ -9,11 +17,23 @@ class TelemetryServer(QObject):
|
|||
conn_url: str
|
||||
"Something like http://<some_ip>:8082"
|
||||
|
||||
callbacks: Dict[str, Signal]
|
||||
|
||||
def __init__(self, url: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.conn_url = url
|
||||
|
||||
|
||||
NewPacket = Signal(object)
|
||||
"""Signal that is emitted when a new packet is received in realtime. Contains the packet itself"""
|
||||
|
||||
@cached_property
|
||||
def schema(self) -> SkylabFile:
|
||||
"""Gets the Packet Schema from the server"""
|
||||
pass
|
||||
|
||||
@Slot()
|
||||
def connect(self):
|
||||
"""Attempt to connect to server"""
|
||||
|
||||
def query(self, queryparams):
|
||||
"""Query the historical data and store the result in the datastore"""
|
||||
|
||||
|
||||
|
|
|
@ -56,7 +56,7 @@ class BMSOverview(QWidget):
|
|||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMaximumWidth
|
||||
# self.setMaximumWidth()
|
||||
layout = QGridLayout()
|
||||
layout.setRowStretch(0, 80)
|
||||
layout.setRowStretch(1, 20)
|
||||
|
|
|
@ -1,56 +1,83 @@
|
|||
import random
|
||||
import sys
|
||||
import logging
|
||||
|
||||
import pyqtgraph.parametertree
|
||||
from PySide6 import QtWidgets, QtCore
|
||||
from PySide6.QtCore import QDir, Qt, QObject
|
||||
from PySide6.QtCore import QDir, Qt, QObject, Slot, Signal, QTimer
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QWidget,
|
||||
QMainWindow,
|
||||
QTreeView,
|
||||
QDockWidget,
|
||||
QDockWidget, QToolBar, QPlainTextEdit,
|
||||
)
|
||||
|
||||
from gui_log import QLogHandler
|
||||
from pytelem.widgets.smart_display import SmartDisplay
|
||||
from bms import BMSOverview
|
||||
|
||||
class QtLogger(logging.Handler, QObject):
|
||||
appendLog = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__()
|
||||
QtCore.QObject.__init__(self)
|
||||
self.widget = QtWidgets.QPlainTextEdit(parent)
|
||||
self.widget.setReadOnly(True)
|
||||
self.appendLog.connect(self.widget.appendPlainText)
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.appendLog.emit(msg)
|
||||
|
||||
|
||||
class DataStore:
|
||||
class DataStore(QObject):
|
||||
"""Stores all packets and timestamps for display and logging.
|
||||
Queries the upstreams for the packets as they come in as well as historical"""
|
||||
|
||||
|
||||
def __init__(self, remote):
|
||||
pass
|
||||
super().__init__()
|
||||
|
||||
|
||||
class MainApp(QMainWindow):
|
||||
new_data = Signal(float)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("Hey there")
|
||||
self.setWindowTitle("pyview")
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
|
||||
mb = self.menuBar()
|
||||
self.WindowMenu = mb.addMenu("Windows")
|
||||
|
||||
bms = BMSOverview()
|
||||
dw = QDockWidget('bms', self)
|
||||
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, dw)
|
||||
dw.setWidget(PacketTree())
|
||||
self.setCentralWidget(bms)
|
||||
packet_tree = QDockWidget('Packet Tree', self)
|
||||
self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, packet_tree)
|
||||
packet_tree.setWidget(PacketTreeView())
|
||||
packet_tree.hide()
|
||||
self.ShowPacketTree = packet_tree.toggleViewAction()
|
||||
self.WindowMenu.addAction(self.ShowPacketTree)
|
||||
|
||||
log_dock = QDockWidget('Application Log', self)
|
||||
self.qlogger = QLogHandler()
|
||||
self.log_box = QPlainTextEdit()
|
||||
self.log_box.setReadOnly(True)
|
||||
log_dock.setWidget(self.log_box)
|
||||
self.qlogger.bridge.log.connect(self.log_box.appendPlainText)
|
||||
self.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, log_dock)
|
||||
|
||||
self.logger = logging.Logger("Main")
|
||||
self.logger.addHandler(self.qlogger)
|
||||
self.logger.info("hi there!")
|
||||
self.ShowLog = log_dock.toggleViewAction()
|
||||
self.ShowLog.setShortcut("CTRL+L")
|
||||
self.WindowMenu.addAction(self.ShowLog)
|
||||
self.display = SmartDisplay(self, "test")
|
||||
self.new_data.connect(self.display.update_value)
|
||||
# start a qtimer to generate random data.
|
||||
self.timer = QTimer(parent=self)
|
||||
self.timer.timeout.connect(self.__random_data)
|
||||
# self.__random_data.connect(self.timer.timeout)
|
||||
self.timer.start(100)
|
||||
|
||||
self.setCentralWidget(self.display)
|
||||
|
||||
@Slot()
|
||||
def __random_data(self):
|
||||
# emit random data to the new_data
|
||||
yay = random.normalvariate(10, 1)
|
||||
self.logger.info(yay)
|
||||
self.new_data.emit(yay)
|
||||
|
||||
|
||||
|
||||
class PacketTree(QWidget):
|
||||
class PacketTreeView(QWidget):
|
||||
"""PacketView is a widget that shows a tree of packets as well as properties on them when selected."""
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None):
|
||||
|
@ -59,7 +86,7 @@ class PacketTree(QWidget):
|
|||
splitter = QtWidgets.QSplitter(self)
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
|
||||
# splitter.setOrientation(Qt.Vertical)
|
||||
# splitter.setOrientation(Qt.Vertical)
|
||||
self.tree = QTreeView()
|
||||
self.prop_table = pyqtgraph.parametertree.ParameterTree()
|
||||
splitter.addWidget(self.tree)
|
||||
|
@ -69,6 +96,10 @@ class PacketTree(QWidget):
|
|||
self.setLayout(layout)
|
||||
|
||||
|
||||
class SolverView(QWidget):
|
||||
"""Main Solver Widget/Window"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication(sys.argv)
|
||||
main_window = MainApp()
|
||||
|
|
20
py/pytelem/gui_log.py
Normal file
20
py/pytelem/gui_log.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
import sys
|
||||
import logging
|
||||
|
||||
from PySide6.QtCore import QObject, Slot, Signal
|
||||
from PySide6.QtWidgets import QPlainTextEdit
|
||||
|
||||
|
||||
class Bridge(QObject):
|
||||
log = Signal(str)
|
||||
|
||||
|
||||
class QLogHandler(logging.Handler):
|
||||
bridge = Bridge()
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def emit(self, record):
|
||||
msg = self.format(record)
|
||||
self.bridge.log.emit(msg)
|
|
@ -24,14 +24,14 @@ from numba import jit
|
|||
|
||||
|
||||
def fsolve_discrete():
|
||||
...
|
||||
"""Forward compute a route segment."""
|
||||
|
||||
|
||||
def dist_to_pos(dist: float):
|
||||
"convert a distance along the race path to a position in 3d space"
|
||||
"""convert a distance along the race path to a position in 3d space"""
|
||||
|
||||
|
||||
### All units are BASE SI (no prefix except for kilogram)
|
||||
# All units are BASE SI (no prefix except for kilogram)
|
||||
ATM_MOLAR_MASS = 0.0289644 # kg/mol
|
||||
STANDARD_TEMP = 288.15 # K
|
||||
STANDARD_PRES = 101325.0 # Pa
|
||||
|
@ -583,18 +583,18 @@ def solar_position(timestamp, latitude, longitude, elevation):
|
|||
|
||||
v = v_0 + d_psi * np.cos(np.deg2rad(epsilon))
|
||||
|
||||
alpha = np.arctan2(np.sin(np.radians(sun_longitude)) *
|
||||
np.cos(np.radians(epsilon)) -
|
||||
np.tan(np.radians(beta)) *
|
||||
np.sin(np.radians(epsilon)),
|
||||
np.cos(np.radians(sun_longitude)))
|
||||
alpha = np.arctan2(np.sin(np.deg2rad(sun_longitude)) *
|
||||
np.cos(np.deg2rad(epsilon)) -
|
||||
np.tan(np.deg2rad(beta)) *
|
||||
np.sin(np.deg2rad(epsilon)),
|
||||
np.cos(np.deg2rad(sun_longitude)))
|
||||
alpha_deg = np.rad2deg(alpha) % 360
|
||||
delta = np.arcsin(
|
||||
np.sin(np.radians(beta)) *
|
||||
np.cos(np.radians(epsilon)) +
|
||||
np.cos(np.radians(beta)) *
|
||||
np.sin(np.radians(epsilon)) *
|
||||
np.cos(np.radians(sun_longitude))
|
||||
np.sin(np.deg2rad(beta)) *
|
||||
np.cos(np.deg2rad(epsilon)) +
|
||||
np.cos(np.deg2rad(beta)) *
|
||||
np.sin(np.deg2rad(epsilon)) *
|
||||
np.cos(np.deg2rad(sun_longitude))
|
||||
)
|
||||
delta_deg = np.rad2deg(delta) % 360
|
||||
|
||||
|
@ -612,4 +612,5 @@ def solar_position(timestamp, latitude, longitude, elevation):
|
|||
alpha_prime = alpha_deg + d_alpha
|
||||
delta_prime = np.arctan2((np.sin(delta) - y * np.sin(np.radians(xi_deg))) * np.cos(np.radians(d_alpha)),
|
||||
np.cos(delta) - x * np.sin(np.radians(xi_deg)) * np.cos(np.radians(h)))
|
||||
topo_local_hour_angle_deg = h - d_alpha
|
||||
h_prime = h - d_alpha
|
||||
e_0 = np.arcsin(np.sin(latitude) * np.sin(delta) + np.cos(latitude) * np.cos(delta_prime))
|
||||
|
|
|
@ -1,114 +0,0 @@
|
|||
import time
|
||||
import numpy as np
|
||||
|
||||
from imgui_bundle import implot, imgui_knobs, imgui, immapp, hello_imgui
|
||||
import aiohttp
|
||||
import orjson
|
||||
|
||||
# Fill x and y whose plot is a heart
|
||||
vals = np.arange(0, np.pi * 2, 0.01)
|
||||
x = np.power(np.sin(vals), 3) * 16
|
||||
y = 13 * np.cos(vals) - 5 * np.cos(2 * vals) - 2 * np.cos(3 * vals) - np.cos(4 * vals)
|
||||
# Heart pulse rate and time tracking
|
||||
phase = 0
|
||||
t0 = time.time() + 0.2
|
||||
heart_pulse_rate = 80
|
||||
|
||||
|
||||
class PacketState:
|
||||
"""PacketState is the state representation for a packet. It contains metadata about the packet
|
||||
as well as a description of the packet fields. Also contains a buffer.
|
||||
"""
|
||||
|
||||
def render_tree(self):
|
||||
"""Render the Tree view entry for the packet. Only called if the packet is shown."""
|
||||
pass
|
||||
|
||||
def render_graphs(self):
|
||||
pass
|
||||
|
||||
def __init__(self, name: str, description: str | None = None):
|
||||
self.name = name
|
||||
self.description = description
|
||||
|
||||
# take the data fragment and create internal data representing it.
|
||||
|
||||
|
||||
boards = {
|
||||
"bms": {
|
||||
"bms_measurement": {
|
||||
"description": "Voltages for main battery and aux pack",
|
||||
"id": 0x10,
|
||||
"data": {
|
||||
"battery_voltage": 127.34,
|
||||
"aux_voltage": 23.456,
|
||||
"current": 1.23,
|
||||
},
|
||||
},
|
||||
"battery_status": {
|
||||
"description": "Status bits for the battery",
|
||||
"id": 0x11,
|
||||
"data": {
|
||||
"battery_state": {
|
||||
"startup": True,
|
||||
"precharge": False,
|
||||
"discharging": False,
|
||||
"lv_only": False,
|
||||
"charging": False,
|
||||
"wall_charging": False,
|
||||
"killed": False,
|
||||
}, # repeat for rest fo fields
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def gui():
|
||||
global heart_pulse_rate, phase, t0, x, y
|
||||
# Make sure that the animation is smooth
|
||||
hello_imgui.get_runner_params().fps_idling.enable_idling = False
|
||||
|
||||
t = time.time()
|
||||
phase += (t - t0) * heart_pulse_rate / (np.pi * 2)
|
||||
k = 0.8 + 0.1 * np.cos(phase)
|
||||
t0 = t
|
||||
|
||||
imgui.show_demo_window()
|
||||
main_window_flags: imgui.WindowFlags = imgui.WindowFlags_.no_collapse.value
|
||||
imgui.begin("my application", p_open=None, flags=main_window_flags)
|
||||
|
||||
imgui.text("Bloat free code")
|
||||
if implot.begin_plot("Heart", immapp.em_to_vec2(21, 21)):
|
||||
implot.plot_line("", x * k, y * k)
|
||||
implot.end_plot()
|
||||
|
||||
for board_name, board_packets in boards.items():
|
||||
if imgui.tree_node(board_name):
|
||||
for packet_name in board_packets:
|
||||
if imgui.tree_node(packet_name):
|
||||
# display description if hovered
|
||||
pkt = board_packets[packet_name]
|
||||
if imgui.is_item_hovered():
|
||||
imgui.set_tooltip(pkt["description"])
|
||||
imgui.text(f"0x{pkt['id']:03X}")
|
||||
imgui.tree_pop()
|
||||
imgui.tree_pop()
|
||||
imgui.end() # my application
|
||||
|
||||
_, heart_pulse_rate = imgui_knobs.knob("Pulse", heart_pulse_rate, 30, 180)
|
||||
|
||||
|
||||
# class State:
|
||||
# def __init__(self):
|
||||
#
|
||||
# def gui(self):
|
||||
|
||||
if __name__ == "__main__":
|
||||
immapp.run(
|
||||
gui,
|
||||
window_size=(300, 450),
|
||||
window_title="Hello!",
|
||||
with_implot=True,
|
||||
fps_idle=0,
|
||||
) # type: ignore
|
|
@ -5,7 +5,7 @@ import re
|
|||
from pathlib import Path
|
||||
from typing import Callable, Iterable, NewType, TypedDict, List, Protocol, Union, Set
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
from pydantic import field_validator, BaseModel, validator, model_validator
|
||||
from enum import Enum
|
||||
import yaml
|
||||
import jinja2
|
||||
|
@ -77,35 +77,33 @@ class SkylabField(BaseModel):
|
|||
"the name of the field. must be alphanumeric and underscores"
|
||||
type: FieldType
|
||||
"the type of the field"
|
||||
units: str | None
|
||||
units: str | None = None
|
||||
"optional descriptor of the unit representation"
|
||||
conversion: float | None
|
||||
conversion: float | None = None
|
||||
"optional conversion factor to be applied when parsing"
|
||||
bits: List[_Bits] | None
|
||||
bits: List[_Bits] | None = None
|
||||
"if the type if a bitfield, "
|
||||
|
||||
@validator("bits")
|
||||
def bits_must_exist_if_bitfield(cls, v, values):
|
||||
if v is None and "type" in values and values["type"] is FieldType.Bitfield:
|
||||
@model_validator(mode='after')
|
||||
def bits_must_exist_if_bitfield(self) -> 'SkylabField':
|
||||
if self.bits is None and self.type == FieldType.Bitfield:
|
||||
raise ValueError("bits are not present on bitfield type")
|
||||
if (
|
||||
v is not None
|
||||
and "type" in values
|
||||
and values["type"] is not FieldType.Bitfield
|
||||
):
|
||||
if self.bits is not None and self.type != FieldType.Bitfield:
|
||||
raise ValueError("bits are present on non-bitfield type")
|
||||
return v
|
||||
return self
|
||||
|
||||
@validator("name")
|
||||
def name_valid_string(cls, v: str):
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_valid_string(cls, v: str) -> str:
|
||||
if not re.match(r"^[A-Za-z0-9_]+$", v):
|
||||
return ValueError("invalid name")
|
||||
raise ValueError("invalid name")
|
||||
return v
|
||||
|
||||
@validator("name")
|
||||
def name_nonzero_length(cls, v: str):
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_nonzero_length(cls, v: str) -> str:
|
||||
if len(v) == 0:
|
||||
return ValueError("name cannot be empty string")
|
||||
raise ValueError("name cannot be empty string")
|
||||
return v
|
||||
|
||||
|
||||
|
@ -120,11 +118,11 @@ class SkylabPacket(BaseModel):
|
|||
"""Represents a CAN packet. Contains SkylabFields with information on the structure of the data."""
|
||||
|
||||
name: str
|
||||
description: str | None
|
||||
description: str | None = None
|
||||
id: int
|
||||
endian: Endian
|
||||
repeat: int | None
|
||||
offset: int | None
|
||||
repeat: int | None = None
|
||||
offset: int | None = None
|
||||
data: List[SkylabField]
|
||||
|
||||
# @validator("data")
|
||||
|
@ -134,31 +132,37 @@ class SkylabPacket(BaseModel):
|
|||
# return ValueError("Total packet size cannot exceed 8 bytes")
|
||||
# return v
|
||||
|
||||
@validator("id")
|
||||
@field_validator("id")
|
||||
@classmethod
|
||||
def id_non_negative(cls, v: int) -> int:
|
||||
if v < 0:
|
||||
raise ValueError("id must be above zero")
|
||||
return v
|
||||
|
||||
@validator("name")
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_valid_string(cls, v: str) -> str:
|
||||
if not re.match(r"^[A-Za-z0-9_]+$", v):
|
||||
raise ValueError("invalid name", v)
|
||||
return v
|
||||
|
||||
@validator("name")
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_nonzero_length(cls, v: str) -> str:
|
||||
if len(v) == 0:
|
||||
raise ValueError("name cannot be empty string")
|
||||
return v
|
||||
|
||||
@validator("offset")
|
||||
def offset_must_have_repeat(cls, v: int | None, values) -> int | None:
|
||||
if v is not None and "repeat" in values and values["repeat"] is not None:
|
||||
# TODO[pydantic]: We couldn't refactor the `validator`, please replace it by `field_validator` manually.
|
||||
# Check https://docs.pydantic.dev/dev-v2/migration/#changes-to-validators for more information.
|
||||
@model_validator(mode='after')
|
||||
def offset_must_have_repeat(self) -> "SkylabPacket":
|
||||
if self.offset is not None and self.repeat is not None:
|
||||
raise ValueError("field with offset must have repeat defined")
|
||||
return v
|
||||
|
||||
@validator("repeat")
|
||||
@field_validator("repeat")
|
||||
@classmethod
|
||||
def repeat_gt_one(cls, v: int | None):
|
||||
if v is not None and v <= 1:
|
||||
raise ValueError("repeat must be strictly greater than one")
|
||||
|
@ -180,13 +184,15 @@ class SkylabBoard(BaseModel):
|
|||
receive: List[str]
|
||||
"The packets received by this board."
|
||||
|
||||
@validator("name")
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_valid_string(cls, v: str):
|
||||
if not re.match(r"^[A-Za-z0-9_]+$", v):
|
||||
return ValueError("invalid name", v)
|
||||
return v
|
||||
|
||||
@validator("name")
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_nonzero_length(cls, v: str):
|
||||
if len(v) == 0:
|
||||
return ValueError("name cannot be empty string")
|
||||
|
@ -201,13 +207,15 @@ class SkylabBus(BaseModel):
|
|||
extended_id: bool
|
||||
"If the bus uses extended ids"
|
||||
|
||||
@validator("name")
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def name_valid_string(cls, v: str):
|
||||
if not re.match(r"^[A-Za-z0-9_]+$", v):
|
||||
return ValueError("invalid name", v)
|
||||
return v
|
||||
|
||||
@validator("baud_rate")
|
||||
@field_validator("baud_rate")
|
||||
@classmethod
|
||||
def baud_rate_supported(cls, v: int):
|
||||
if v not in [125000, 250000, 500000, 750000, 1000000]:
|
||||
raise ValueError("unsupported baud rate", v)
|
||||
|
|
0
py/pytelem/widgets/bms.py
Normal file
0
py/pytelem/widgets/bms.py
Normal file
0
py/pytelem/widgets/packet_tree.py
Normal file
0
py/pytelem/widgets/packet_tree.py
Normal file
145
py/pytelem/widgets/smart_display.py
Normal file
145
py/pytelem/widgets/smart_display.py
Normal file
|
@ -0,0 +1,145 @@
|
|||
# A simple display for numbers with optional trend_data line, histogram, min/max, and rolling average.
|
||||
from PySide6.QtCore import Qt, Slot, QSize
|
||||
from PySide6.QtGui import QAction, QFontDatabase
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QLabel, QSizePolicy, QGridLayout
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
import pyqtgraph as pg
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
class _StatsDisplay(QWidget):
|
||||
"""Helper Widget for the stats display."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
# create grid array, minimum size vertically.
|
||||
layout = QGridLayout(self)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
@Slot(float, float, float)
|
||||
def update_values(self, new_min: float, new_avg: float, new_max: float):
|
||||
|
||||
|
||||
|
||||
class SmartDisplay(QWidget):
|
||||
"""A simple numeric display with optional statistics, trends, and histogram"""
|
||||
|
||||
value: float = 0.0
|
||||
min: float = -float("inf")
|
||||
max: float = float("inf")
|
||||
avg: float = 0.0
|
||||
trend_data: List[float] = []
|
||||
histogram_data: List[float] = []
|
||||
|
||||
# TODO: settable sample count for histogram/trend in right click menu
|
||||
|
||||
def __init__(self, parent=None, title: str = None, initial_value: float = None, unit_suffix=None,
|
||||
show_histogram=False, show_trendline: bool = False, show_stats=False,
|
||||
histogram_samples=100, trend_samples=30):
|
||||
super().__init__(parent)
|
||||
self.trend_samples = trend_samples
|
||||
self.histogram_samples = histogram_samples
|
||||
layout = QVBoxLayout(self)
|
||||
if title is not None:
|
||||
self.title = title
|
||||
# create the title label
|
||||
self.title_widget = QLabel(title, self)
|
||||
self.title_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
layout.addWidget(self.title_widget)
|
||||
|
||||
number_font = QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)
|
||||
number_font.setPointSize(18)
|
||||
self.value = initial_value
|
||||
self.suffix = unit_suffix or ""
|
||||
self.value_widget = QLabel(f"{self.value}{self.suffix}", self)
|
||||
self.value_widget.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.value_widget.setFont(number_font)
|
||||
layout.addWidget(self.value_widget)
|
||||
|
||||
# histogram widget
|
||||
self.histogram_widget = pg.PlotWidget(self, title="Histogram")
|
||||
self.histogram_widget.enableAutoRange()
|
||||
self.histogram_widget.setVisible(False)
|
||||
self.histogram_graph = pg.PlotDataItem()
|
||||
self.histogram_widget.addItem(self.histogram_graph)
|
||||
|
||||
layout.addWidget(self.histogram_widget)
|
||||
|
||||
# stats display
|
||||
|
||||
# trendline display
|
||||
self.trendline_widget = pg.PlotWidget(self, title="Trend")
|
||||
self.trendline_widget.enableAutoRange()
|
||||
self.trendline_widget.setVisible(False)
|
||||
self.trendline_data = pg.PlotDataItem()
|
||||
self.trendline_widget.addItem(self.trendline_data)
|
||||
|
||||
layout.addWidget(self.trendline_widget)
|
||||
toggle_histogram = QAction("Show Histogram", self, checkable=True)
|
||||
toggle_histogram.toggled.connect(self._toggle_histogram)
|
||||
self.addAction(toggle_histogram)
|
||||
|
||||
toggle_trendline = QAction("Show Trendline", self, checkable=True)
|
||||
toggle_trendline.toggled.connect(self._toggle_trendline)
|
||||
self.addAction(toggle_trendline)
|
||||
|
||||
reset_stats = QAction("Reset Data", self)
|
||||
reset_stats.triggered.connect(self.reset_data)
|
||||
self.addAction(reset_stats)
|
||||
|
||||
# use the QWidget Actions list as the right click context menu. This is inherited by children.
|
||||
self.setContextMenuPolicy(Qt.ActionsContextMenu)
|
||||
|
||||
|
||||
def _toggle_histogram(self):
|
||||
self.histogram_widget.setVisible(not self.histogram_widget.isVisible())
|
||||
|
||||
def _toggle_trendline(self):
|
||||
self.trendline_widget.setVisible(not self.trendline_widget.isVisible())
|
||||
|
||||
def _update_view(self):
|
||||
self.trendline_data.setData(self.trend_data)
|
||||
self.value_widget.setText(f"{self.value:4g}{self.suffix}")
|
||||
if self.histogram_widget.isVisible():
|
||||
hist, bins = np.histogram(self.histogram_data)
|
||||
self.histogram_graph.setData(bins, hist, stepMode="center")
|
||||
|
||||
@Slot(float)
|
||||
def update_value(self, value: float):
|
||||
"""Update the value displayed and associated stats."""
|
||||
self.value = value
|
||||
|
||||
# update stats.
|
||||
if self.value > self.max:
|
||||
self.max = self.value
|
||||
if self.value < self.min:
|
||||
self.min = self.value
|
||||
|
||||
# update trend_data data.
|
||||
self.trend_data.append(value)
|
||||
if len(self.trend_data) > self.trend_samples:
|
||||
self.trend_data.pop(0)
|
||||
|
||||
# update histogram
|
||||
self.histogram_data.append(value)
|
||||
if len(self.histogram_data) > self.histogram_samples:
|
||||
self.histogram_data.pop(0)
|
||||
|
||||
# update average
|
||||
# noinspection PyTypeChecker
|
||||
self.avg = np.cumsum(self.trend_data) / len(self.trend_data)
|
||||
|
||||
# re-render data.
|
||||
self._update_view()
|
||||
|
||||
@Slot()
|
||||
def reset_data(self):
|
||||
"""Resets the existing data (trendline, stats, histogram)"""
|
||||
self.max = float("inf")
|
||||
self.min = -float("inf")
|
||||
self.trend_data = []
|
||||
self.histogram_data = []
|
Loading…
Reference in a new issue