mirror of
https://github.com/meshtastic/python.git
synced 2026-01-02 21:07:55 -05:00
Merge branch 'pr-poetry' into powermon
This commit is contained in:
@@ -21,6 +21,7 @@ from meshtastic import channel_pb2, config_pb2, portnums_pb2, remote_hardware, B
|
||||
from meshtastic.version import get_active_version
|
||||
from meshtastic.ble_interface import BLEInterface
|
||||
from meshtastic.mesh_interface import MeshInterface
|
||||
from meshtastic.power_mon import PowerMonClient
|
||||
|
||||
def onReceive(packet, interface):
|
||||
"""Callback invoked when a packet arrives"""
|
||||
@@ -1089,6 +1090,10 @@ def common():
|
||||
# We assume client is fully connected now
|
||||
onConnected(client)
|
||||
|
||||
if args.power_mon:
|
||||
PowerMonClient(args.power_mon, client)
|
||||
|
||||
|
||||
have_tunnel = platform.system() == "Linux"
|
||||
if (
|
||||
args.noproto or args.reply or (have_tunnel and args.tunnel) or args.listen
|
||||
@@ -1500,6 +1505,17 @@ def initParser():
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--power-mon",
|
||||
help="Capture any power monitor records. You must use --power-mon /dev/ttyUSBxxx to specify which port the power supply is on",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--power-stress",
|
||||
help="Perform power monitor stress testing, to capture a power consumption profile for the device (also requires --power-mon)",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
group.add_argument(
|
||||
"--ble-scan",
|
||||
help="Scan for Meshtastic BLE devices",
|
||||
|
||||
@@ -29,7 +29,7 @@ from meshtastic import (
|
||||
NODELESS_WANT_CONFIG_ID,
|
||||
ResponseHandler,
|
||||
protocols,
|
||||
publishingThread,
|
||||
publishingThread
|
||||
)
|
||||
from meshtastic.util import (
|
||||
Acknowledgment,
|
||||
@@ -40,7 +40,7 @@ from meshtastic.util import (
|
||||
stripnl,
|
||||
message_to_json,
|
||||
)
|
||||
|
||||
from meshtastic.observable import Observable
|
||||
|
||||
class MeshInterface: # pylint: disable=R0902
|
||||
"""Interface class for meshtastic devices
|
||||
@@ -71,6 +71,7 @@ class MeshInterface: # pylint: disable=R0902
|
||||
self.nodes: Optional[Dict[str,Dict]] = None # FIXME
|
||||
self.isConnected: threading.Event = threading.Event()
|
||||
self.noProto: bool = noProto
|
||||
self.onLogMessage = Observable()
|
||||
self.localNode: meshtastic.node.Node = meshtastic.node.Node(self, -1) # We fixup nodenum later
|
||||
self.myInfo: Optional[mesh_pb2.MyNodeInfo] = None # We don't have device info yet
|
||||
self.metadata: Optional[mesh_pb2.DeviceMetadata] = None # We don't have device metadata yet
|
||||
@@ -111,6 +112,10 @@ class MeshInterface: # pylint: disable=R0902
|
||||
logging.error(f"Traceback: {traceback}")
|
||||
self.close()
|
||||
|
||||
def _handleLogLine(self, line):
|
||||
"""Handle a line of log output from the device."""
|
||||
self.onLogMessage.fire(message=line)
|
||||
|
||||
def showInfo(self, file=sys.stdout) -> str: # pylint: disable=W0613
|
||||
"""Show human readable summary about this object"""
|
||||
owner = f"Owner: {self.getLongName()} ({self.getShortName()})"
|
||||
|
||||
35
meshtastic/observable.py
Normal file
35
meshtastic/observable.py
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
|
||||
class Event(object):
|
||||
"""A simple event class."""
|
||||
|
||||
class Observable(object):
|
||||
"""A class that represents an observable object.
|
||||
|
||||
To publish an event call fire(type="progress", percent=50) or whatever. It will call
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Observable object."""
|
||||
self.callbacks = []
|
||||
|
||||
def subscribe(self, callback):
|
||||
"""Subscribe to the observable object.
|
||||
|
||||
Args:
|
||||
callback (function): The callback function to be called when the event is fired.
|
||||
"""
|
||||
self.callbacks.append(callback)
|
||||
|
||||
def fire(self, **attrs):
|
||||
"""Fire the event.
|
||||
|
||||
Args:
|
||||
**attrs: Arbitrary keyword arguments to be passed to the callback functions.
|
||||
"""
|
||||
e = Event()
|
||||
e.source = self
|
||||
for k, v in attrs.items():
|
||||
setattr(e, k, v)
|
||||
for fn in self.callbacks:
|
||||
fn(e)
|
||||
143
meshtastic/power_mon.py
Normal file
143
meshtastic/power_mon.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""code logging power consumption of meshtastic devices."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import atexit
|
||||
from datetime import datetime
|
||||
|
||||
import pandas as pd
|
||||
from riden import Riden
|
||||
|
||||
from meshtastic.mesh_interface import MeshInterface
|
||||
from meshtastic.observable import Event
|
||||
|
||||
|
||||
class PowerSupply:
|
||||
"""Interface for talking to programmable bench-top power supplies.
|
||||
Currently only the Riden supplies are supported (RD6006 tested)
|
||||
"""
|
||||
|
||||
def __init__(self, portName: str = "/dev/ttyUSB0"):
|
||||
"""Initialize the PowerSupply object."""
|
||||
self.r = r = Riden(port=portName, baudrate=115200, address=1)
|
||||
logging.info(
|
||||
f"Connected to Riden power supply: model {r.type}, sn {r.sn}, firmware {r.fw}. Date/time updated."
|
||||
)
|
||||
r.set_date_time(datetime.now())
|
||||
|
||||
def powerOn(self):
|
||||
"""Power on the supply, with reasonable defaults for meshtastic devices."""
|
||||
self.r.set_i_set(
|
||||
0.300
|
||||
) # Set current limit to 300mA - hopefully enough to power any board but not break things if there is a short circuit
|
||||
|
||||
# self.r.set_v_set(3.7) # default to a nominal LiPo voltage
|
||||
self.r.set_v_set(3.3) # my WM1110 devboard header is directly connected to the 3.3V rail
|
||||
self.r.set_output(1)
|
||||
|
||||
"""Get current watts out.
|
||||
But for most applications you probably want getWattHour() instead (to prevent integration errors from accumulating).
|
||||
"""
|
||||
return self.r.get_p_out()
|
||||
|
||||
def getWattHour(self):
|
||||
"""Get current Wh out, since power was turned on."""
|
||||
# FIXME: Individual reads seem busted in the riden lib. So for now I just read everything.
|
||||
self.r.update()
|
||||
return self.r.wh
|
||||
# return self.r.get_wh()
|
||||
|
||||
def clearWattHour(self):
|
||||
"""Clear the watt-hour counter FIXME."""
|
||||
|
||||
|
||||
"""Used to match power mon log lines:
|
||||
INFO | ??:??:?? 7 [Blink] S:PM:0x00000080,reason
|
||||
"""
|
||||
logRegex = re.compile(".*S:PM:0x([0-9A-Fa-f]+),(.*)")
|
||||
|
||||
|
||||
class PowerMonClient:
|
||||
"""Client for monitoring power consumption of meshtastic devices."""
|
||||
|
||||
def __init__(self, portName: str, client: MeshInterface) -> None:
|
||||
"""Initialize the PowerMonClient object.
|
||||
|
||||
Args:
|
||||
client (MeshInterface): The MeshInterface object to monitor.
|
||||
|
||||
"""
|
||||
self.client = client
|
||||
self.state = 0 # The current power mon state bitfields
|
||||
self.columns = ["time", "power", "reason", "bitmask"]
|
||||
self.rawData = pd.DataFrame(columns=self.columns) # use time as the index
|
||||
|
||||
# for efficiency reasons we keep new data in a list - only adding to rawData when needfed
|
||||
self.newData = []
|
||||
|
||||
self.power = power = PowerSupply(portName)
|
||||
power.powerOn()
|
||||
|
||||
# Used to calculate watts over an interval
|
||||
self.prevPowerTime = datetime.now()
|
||||
self.prevWattHour = power.getWattHour()
|
||||
atexit.register(self._exitHandler)
|
||||
client.onLogMessage.subscribe(self._onLogMessage)
|
||||
|
||||
def getRawData(self) -> pd.DataFrame:
|
||||
"""Get the raw data.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: The raw data.
|
||||
|
||||
"""
|
||||
df = pd.DataFrame(self.newData, columns=self.columns)
|
||||
self.rawData = pd.concat([self.rawData, df], ignore_index=True)
|
||||
self.newData = []
|
||||
|
||||
return self.rawData
|
||||
|
||||
def _exitHandler(self) -> None:
|
||||
"""Exit handler."""
|
||||
fn = "/tmp/powermon.csv" # Find a better place
|
||||
logging.info(f"Storing PowerMon raw data in {fn}")
|
||||
self.getRawData().to_csv(fn)
|
||||
|
||||
def _onLogMessage(self, ev: Event) -> None:
|
||||
"""Callback function for handling log messages.
|
||||
|
||||
Args:
|
||||
message (str): The log message.
|
||||
|
||||
"""
|
||||
m = logRegex.match(ev.message)
|
||||
if m:
|
||||
mask = int(m.group(1), 16)
|
||||
reason = m.group(2)
|
||||
logging.debug(f"PowerMon state: 0x{mask:x}, reason: {reason}")
|
||||
if mask != self.state:
|
||||
self._storeRecord(mask, reason)
|
||||
|
||||
def _storeRecord(self, mask: int, reason: str) -> None:
|
||||
"""Store a power mon record.
|
||||
|
||||
Args:
|
||||
mask (int): The power mon state bitfields.
|
||||
reason (str): The reason for the power mon state change.
|
||||
|
||||
"""
|
||||
|
||||
now = datetime.now()
|
||||
nowWattHour = self.power.getWattHour()
|
||||
watts = (
|
||||
(nowWattHour - self.prevWattHour)
|
||||
/ (now - self.prevPowerTime).total_seconds()
|
||||
* 3600
|
||||
)
|
||||
self.prevPowerTime = now
|
||||
self.prevWattHour = nowWattHour
|
||||
self.state = mask
|
||||
|
||||
self.newData.append(
|
||||
{"time": now, "power": watts, "reason": reason, "bitmask": mask})
|
||||
# self.getRawData()
|
||||
28
meshtastic/powermon_pb2.py
Normal file
28
meshtastic/powermon_pb2.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
||||
# source: meshtastic/powermon.proto
|
||||
"""Generated protocol buffer code."""
|
||||
from google.protobuf import descriptor as _descriptor
|
||||
from google.protobuf import descriptor_pool as _descriptor_pool
|
||||
from google.protobuf import symbol_database as _symbol_database
|
||||
from google.protobuf.internal import builder as _builder
|
||||
# @@protoc_insertion_point(imports)
|
||||
|
||||
_sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19meshtastic/powermon.proto\x12\x13meshtastic.PowerMon\"\'\n\x05\x45vent\x12\x13\n\x06states\x18\x01 \x01(\x04H\x00\x88\x01\x01\x42\t\n\x07_states*\xfb\x01\n\x05State\x12\x08\n\x04None\x10\x00\x12\x11\n\rCPU_DeepSleep\x10\x01\x12\r\n\tCPU_Sleep\x10\x02\x12\r\n\tCPU_Awake\x10\x04\x12\r\n\tLora_RXOn\x10\x08\x12\r\n\tLora_TXOn\x10\x10\x12\x11\n\rLora_RXActive\x10 \x12\t\n\x05\x42T_On\x10@\x12\x0b\n\x06LED_On\x10\x80\x01\x12\x0e\n\tScreen_On\x10\x80\x02\x12\x13\n\x0eScreen_Drawing\x10\x80\x04\x12\x0c\n\x07Wifi_On\x10\x80\x08\x12\x11\n\x0cGPS_LowPower\x10\x80\x10\x12\x14\n\x0fGPS_MediumPower\x10\x80 \x12\x12\n\rGPS_HighPower\x10\x80@Bc\n\x13\x63om.geeksville.meshB\x0ePowerMonProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.powermon_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016PowerMonProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_STATE']._serialized_start=92
|
||||
_globals['_STATE']._serialized_end=343
|
||||
_globals['_EVENT']._serialized_start=50
|
||||
_globals['_EVENT']._serialized_end=89
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
84
meshtastic/powermon_pb2.pyi
Normal file
84
meshtastic/powermon_pb2.pyi
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
@generated by mypy-protobuf. Do not edit manually!
|
||||
isort:skip_file
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import google.protobuf.descriptor
|
||||
import google.protobuf.internal.enum_type_wrapper
|
||||
import google.protobuf.message
|
||||
import sys
|
||||
import typing
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
import typing as typing_extensions
|
||||
else:
|
||||
import typing_extensions
|
||||
|
||||
DESCRIPTOR: google.protobuf.descriptor.FileDescriptor
|
||||
|
||||
class _State:
|
||||
ValueType = typing.NewType("ValueType", builtins.int)
|
||||
V: typing_extensions.TypeAlias = ValueType
|
||||
|
||||
class _StateEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[_State.ValueType], builtins.type):
|
||||
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
|
||||
CPU_DeepSleep: _State.ValueType # 1
|
||||
CPU_Sleep: _State.ValueType # 2
|
||||
CPU_Awake: _State.ValueType # 4
|
||||
Lora_RXOn: _State.ValueType # 8
|
||||
Lora_TXOn: _State.ValueType # 16
|
||||
Lora_RXActive: _State.ValueType # 32
|
||||
BT_On: _State.ValueType # 64
|
||||
LED_On: _State.ValueType # 128
|
||||
Screen_On: _State.ValueType # 256
|
||||
Screen_Drawing: _State.ValueType # 512
|
||||
Wifi_On: _State.ValueType # 1024
|
||||
GPS_LowPower: _State.ValueType # 2048
|
||||
GPS_MediumPower: _State.ValueType # 4096
|
||||
GPS_HighPower: _State.ValueType # 8192
|
||||
|
||||
class State(_State, metaclass=_StateEnumTypeWrapper):
|
||||
"""Any significant power changing event in meshtastic should be tagged with a powermon state transition.
|
||||
If you are making new meshtastic features feel free to add new entries at the end of this definition.
|
||||
"""
|
||||
|
||||
CPU_DeepSleep: State.ValueType # 1
|
||||
CPU_Sleep: State.ValueType # 2
|
||||
CPU_Awake: State.ValueType # 4
|
||||
Lora_RXOn: State.ValueType # 8
|
||||
Lora_TXOn: State.ValueType # 16
|
||||
Lora_RXActive: State.ValueType # 32
|
||||
BT_On: State.ValueType # 64
|
||||
LED_On: State.ValueType # 128
|
||||
Screen_On: State.ValueType # 256
|
||||
Screen_Drawing: State.ValueType # 512
|
||||
Wifi_On: State.ValueType # 1024
|
||||
GPS_LowPower: State.ValueType # 2048
|
||||
GPS_MediumPower: State.ValueType # 4096
|
||||
GPS_HighPower: State.ValueType # 8192
|
||||
global___State = State
|
||||
|
||||
@typing.final
|
||||
class Event(google.protobuf.message.Message):
|
||||
"""
|
||||
the log messages will be short and complete (see PowerMon.Event in the protobufs for details).
|
||||
something like "PwrMon,C,0x00001234,REASON" where the hex number is the bitmask of all current states.
|
||||
(We use a bitmask for states so that if a log message gets lost it won't be fatal)
|
||||
"""
|
||||
|
||||
DESCRIPTOR: google.protobuf.descriptor.Descriptor
|
||||
|
||||
STATES_FIELD_NUMBER: builtins.int
|
||||
states: builtins.int
|
||||
"""Bitwise-OR of States"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
states: builtins.int | None = ...,
|
||||
) -> None: ...
|
||||
def HasField(self, field_name: typing.Literal["_states", b"_states", "states", b"states"]) -> builtins.bool: ...
|
||||
def ClearField(self, field_name: typing.Literal["_states", b"_states", "states", b"states"]) -> None: ...
|
||||
def WhichOneof(self, oneof_group: typing.Literal["_states", b"_states"]) -> typing.Literal["states"] | None: ...
|
||||
|
||||
global___Event = Event
|
||||
@@ -39,6 +39,7 @@ class StreamInterface(MeshInterface):
|
||||
self._wantExit = False
|
||||
|
||||
self.is_windows11 = is_windows11()
|
||||
self.cur_log_line = ""
|
||||
|
||||
# FIXME, figure out why daemon=True causes reader thread to exit too early
|
||||
self._rxThread = threading.Thread(target=self.__reader, args=(), daemon=True)
|
||||
@@ -124,6 +125,26 @@ class StreamInterface(MeshInterface):
|
||||
if self._rxThread != threading.current_thread():
|
||||
self._rxThread.join() # wait for it to exit
|
||||
|
||||
def _handleLogByte(self, b):
|
||||
"""Handle a byte that is part of a log message from the device."""
|
||||
|
||||
utf = "?" # assume we might fail
|
||||
try:
|
||||
utf = b.decode("utf-8")
|
||||
except:
|
||||
pass
|
||||
|
||||
if utf == "\r":
|
||||
pass # ignore
|
||||
elif utf == "\n":
|
||||
self._handleLogLine(self.cur_log_line)
|
||||
self.cur_log_line = ""
|
||||
else:
|
||||
self.cur_log_line += utf
|
||||
|
||||
if self.debugOut is not None:
|
||||
self.debugOut.write(utf)
|
||||
|
||||
def __reader(self):
|
||||
"""The reader thread that reads bytes from our stream"""
|
||||
logging.debug("in __reader()")
|
||||
@@ -146,11 +167,9 @@ class StreamInterface(MeshInterface):
|
||||
if ptr == 0: # looking for START1
|
||||
if c != START1:
|
||||
self._rxBuf = empty # failed to find start
|
||||
if self.debugOut is not None:
|
||||
try:
|
||||
self.debugOut.write(b.decode("utf-8"))
|
||||
except:
|
||||
self.debugOut.write("?")
|
||||
# This must be a log message from the device
|
||||
|
||||
self._handleLogByte(b)
|
||||
|
||||
elif ptr == 1: # looking for START2
|
||||
if c != START2:
|
||||
|
||||
Reference in New Issue
Block a user