Compare commits
No commits in common. "ace65fb21a3a42c7cbff6f70d76f704e520be181" and "d34a1c287b9208a4221f95be31074e88d2f3af9b" have entirely different histories.
ace65fb21a
...
d34a1c287b
|
@ -0,0 +1,54 @@
|
||||||
|
import serial
|
||||||
|
import logging
|
||||||
|
from data import read_file_one_line
|
||||||
|
|
||||||
|
# dbus configuration
|
||||||
|
|
||||||
|
CONNECTION = 'Modbus RTU'
|
||||||
|
PRODUCT_NAME = 'FIAMM 48TL Series Battery'
|
||||||
|
PRODUCT_ID = 0xB012 # assigned by victron
|
||||||
|
DEVICE_INSTANCE = 1
|
||||||
|
SERVICE_NAME_PREFIX = 'com.victronenergy.battery.'
|
||||||
|
|
||||||
|
|
||||||
|
# driver configuration
|
||||||
|
|
||||||
|
SOFTWARE_VERSION = '3.0.0'
|
||||||
|
UPDATE_INTERVAL = 2000 # milliseconds
|
||||||
|
LOG_LEVEL = logging.INFO
|
||||||
|
#LOG_LEVEL = logging.DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
# battery config
|
||||||
|
|
||||||
|
V_MAX = 54.2
|
||||||
|
V_MIN = 42
|
||||||
|
R_STRING_MIN = 0.125
|
||||||
|
R_STRING_MAX = 0.250
|
||||||
|
I_MAX_PER_STRING = 15
|
||||||
|
AH_PER_STRING = 40
|
||||||
|
|
||||||
|
# modbus configuration
|
||||||
|
|
||||||
|
BASE_ADDRESS = 999
|
||||||
|
NO_OF_REGISTERS = 56
|
||||||
|
MAX_SLAVE_ADDRESS = 25
|
||||||
|
|
||||||
|
|
||||||
|
# RS 485 configuration
|
||||||
|
|
||||||
|
PARITY = serial.PARITY_ODD
|
||||||
|
TIMEOUT = 0.1 # seconds
|
||||||
|
BAUD_RATE = 115200
|
||||||
|
BYTE_SIZE = 8
|
||||||
|
STOP_BITS = 1
|
||||||
|
MODE = 'rtu'
|
||||||
|
|
||||||
|
# InnovEnergy IOT configuration
|
||||||
|
|
||||||
|
INSTALLATION_NAME = read_file_one_line('/data/innovenergy/openvpn/installation-name')
|
||||||
|
INNOVENERGY_SERVER_IP = '10.2.0.1'
|
||||||
|
INNOVENERGY_SERVER_PORT = 8134
|
||||||
|
INNOVENERGY_PROTOCOL_VERSION = '48TL200V3'
|
||||||
|
|
||||||
|
|
Binary file not shown.
|
@ -0,0 +1,160 @@
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
from data import LedState, BatteryStatus
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable, List, Iterable, Union, AnyStr, Any
|
||||||
|
|
||||||
|
|
||||||
|
def read_bool(base_register, bit):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], bool]
|
||||||
|
|
||||||
|
# TODO: explain base register offset
|
||||||
|
register = base_register + int(bit/16)
|
||||||
|
bit = bit % 16
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> bool
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
return value & (1 << bit) > 0
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_float(register, scale_factor=1.0, offset=0.0):
|
||||||
|
# type: (int, float, float) -> Callable[[BatteryStatus], float]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> float
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
|
||||||
|
if value >= 0x8000: # convert to signed int16
|
||||||
|
value -= 0x10000 # fiamm stores their integers signed AND with sign-offset @#%^&!
|
||||||
|
|
||||||
|
return (value + offset) * scale_factor
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_registers(register, count):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], List[int]]
|
||||||
|
|
||||||
|
start = register - cfg.BASE_ADDRESS
|
||||||
|
end = start + count
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> List[int]
|
||||||
|
return [x for x in status.modbus_data[start:end]]
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def comma_separated(values):
|
||||||
|
# type: (Iterable[str]) -> str
|
||||||
|
return ", ".join(set(values))
|
||||||
|
|
||||||
|
|
||||||
|
def count_bits(base_register, nb_of_registers, nb_of_bits, first_bit=0):
|
||||||
|
# type: (int, int, int, int) -> Callable[[BatteryStatus], int]
|
||||||
|
|
||||||
|
get_registers = read_registers(base_register, nb_of_registers)
|
||||||
|
end_bit = first_bit + nb_of_bits
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
|
||||||
|
registers = get_registers(status)
|
||||||
|
bin_registers = [bin(x)[-1:1:-1] for x in registers] # reverse the bits in each register so that bit0 is to the left
|
||||||
|
str_registers = [str(x).ljust(16, "0") for x in bin_registers] # add leading zeroes, so all registers are 16 chars long
|
||||||
|
bit_string = ''.join(str_registers) # join them, one long string of 0s and 1s
|
||||||
|
filtered_bits = bit_string[first_bit:end_bit] # take the first nb_of_bits bits starting at first_bit
|
||||||
|
|
||||||
|
return filtered_bits.count('1') # count 1s
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_led_state(register, led):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], int]
|
||||||
|
|
||||||
|
read_lo = read_bool(register, led * 2)
|
||||||
|
read_hi = read_bool(register, led * 2 + 1)
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
|
||||||
|
lo = read_lo(status)
|
||||||
|
hi = read_hi(status)
|
||||||
|
|
||||||
|
if hi:
|
||||||
|
if lo:
|
||||||
|
return LedState.blinking_fast
|
||||||
|
else:
|
||||||
|
return LedState.blinking_slow
|
||||||
|
else:
|
||||||
|
if lo:
|
||||||
|
return LedState.on
|
||||||
|
else:
|
||||||
|
return LedState.off
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyShadowingNames
|
||||||
|
def unit(unit):
|
||||||
|
# type: (unicode) -> Callable[[unicode], unicode]
|
||||||
|
|
||||||
|
def get_text(v):
|
||||||
|
# type: (unicode) -> unicode
|
||||||
|
return "{0}{1}".format(str(v), unit)
|
||||||
|
|
||||||
|
return get_text
|
||||||
|
|
||||||
|
|
||||||
|
def const(constant):
|
||||||
|
# type: (any) -> Callable[[any], any]
|
||||||
|
def get(*args):
|
||||||
|
return constant
|
||||||
|
return get
|
||||||
|
|
||||||
|
|
||||||
|
def mean(numbers):
|
||||||
|
# type: (List[Union[float,int]]) -> float
|
||||||
|
return float(sum(numbers)) / len(numbers)
|
||||||
|
|
||||||
|
|
||||||
|
def first(ts, default=None):
|
||||||
|
return next((t for t in ts), default)
|
||||||
|
|
||||||
|
|
||||||
|
def bitfields_to_str(lists):
|
||||||
|
# type: (List[List[int]]) -> str
|
||||||
|
|
||||||
|
def or_lists():
|
||||||
|
# type: () -> Iterable[int]
|
||||||
|
|
||||||
|
length = len(first(lists))
|
||||||
|
n_lists = len(lists)
|
||||||
|
|
||||||
|
for i in range(0, length):
|
||||||
|
e = 0
|
||||||
|
for l in range(0, n_lists):
|
||||||
|
e = e | lists[l][i]
|
||||||
|
yield e
|
||||||
|
|
||||||
|
hexed = [
|
||||||
|
'{0:0>4X}'.format(x)
|
||||||
|
for x in or_lists()
|
||||||
|
]
|
||||||
|
|
||||||
|
return ' '.join(hexed)
|
||||||
|
|
||||||
|
|
||||||
|
def pack_string(string):
|
||||||
|
# type: (AnyStr) -> Any
|
||||||
|
data = string.encode('UTF-8')
|
||||||
|
return struct.pack('B', len(data)) + data
|
||||||
|
|
Binary file not shown.
|
@ -0,0 +1,125 @@
|
||||||
|
import config as cfg
|
||||||
|
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable, List, Optional, AnyStr, Union, Any
|
||||||
|
|
||||||
|
|
||||||
|
class LedState(object):
|
||||||
|
"""
|
||||||
|
from page 6 of the '48TLxxx ModBus Protocol doc'
|
||||||
|
"""
|
||||||
|
off = 0
|
||||||
|
on = 1
|
||||||
|
blinking_slow = 2
|
||||||
|
blinking_fast = 3
|
||||||
|
|
||||||
|
|
||||||
|
class LedColor(object):
|
||||||
|
green = 0
|
||||||
|
amber = 1
|
||||||
|
blue = 2
|
||||||
|
red = 3
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSignal(object):
|
||||||
|
|
||||||
|
def __init__(self, dbus_path, get_value_or_const, unit=''):
|
||||||
|
# type: (str, Union[Callable[[],Any],Any], Optional[AnyStr] )->None
|
||||||
|
|
||||||
|
self.get_value_or_const = get_value_or_const
|
||||||
|
self.dbus_path = dbus_path
|
||||||
|
self.unit = unit
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self):
|
||||||
|
try:
|
||||||
|
return self.get_value_or_const() # callable
|
||||||
|
except:
|
||||||
|
return self.get_value_or_const # value
|
||||||
|
|
||||||
|
|
||||||
|
class BatterySignal(object):
|
||||||
|
|
||||||
|
def __init__(self, dbus_path, aggregate, get_value, unit=''):
|
||||||
|
# type: (str, Callable[[List[any]],any], Callable[[BatteryStatus],any], Optional[AnyStr] )->None
|
||||||
|
"""
|
||||||
|
A Signal holds all information necessary for the handling of a
|
||||||
|
certain datum (e.g. voltage) published by the battery.
|
||||||
|
|
||||||
|
:param dbus_path: str
|
||||||
|
object_path on DBus where the datum needs to be published
|
||||||
|
|
||||||
|
:param aggregate: Iterable[any] -> any
|
||||||
|
function that combines the values of multiple batteries into one.
|
||||||
|
e.g. sum for currents, or mean for voltages
|
||||||
|
|
||||||
|
:param get_value: (BatteryStatus) -> any
|
||||||
|
function to extract the datum from the modbus record,
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.dbus_path = dbus_path
|
||||||
|
self.aggregate = aggregate
|
||||||
|
self.get_value = get_value
|
||||||
|
self.unit = unit
|
||||||
|
|
||||||
|
|
||||||
|
class Battery(object):
|
||||||
|
|
||||||
|
""" Data record to hold hardware and firmware specs of the battery """
|
||||||
|
|
||||||
|
def __init__(self, slave_address, hardware_version, firmware_version, bms_version, ampere_hours):
|
||||||
|
# type: (int, str, str, str, int) -> None
|
||||||
|
self.slave_address = slave_address
|
||||||
|
self.hardware_version = hardware_version
|
||||||
|
self.firmware_version = firmware_version
|
||||||
|
self.bms_version = bms_version
|
||||||
|
self.ampere_hours = ampere_hours
|
||||||
|
self.n_strings = int(ampere_hours/cfg.AH_PER_STRING)
|
||||||
|
self.i_max = self.n_strings * cfg.I_MAX_PER_STRING
|
||||||
|
self.v_min = cfg.V_MIN
|
||||||
|
self.v_max = cfg.V_MAX
|
||||||
|
self.r_int_min = cfg.R_STRING_MIN / self.n_strings
|
||||||
|
self.r_int_max = cfg.R_STRING_MAX / self.n_strings
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'slave address = {0}\nhardware version = {1}\nfirmware version = {2}\nbms version = {3}\nampere hours = {4}'.format(
|
||||||
|
self.slave_address, self.hardware_version, self.firmware_version, self.bms_version, str(self.ampere_hours))
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryStatus(object):
|
||||||
|
"""
|
||||||
|
record holding the current status of a battery
|
||||||
|
"""
|
||||||
|
def __init__(self, battery, modbus_data):
|
||||||
|
# type: (Battery, List[int]) -> None
|
||||||
|
|
||||||
|
self.battery = battery
|
||||||
|
self.modbus_data = modbus_data
|
||||||
|
|
||||||
|
def serialize(self):
|
||||||
|
# type: () -> str
|
||||||
|
|
||||||
|
b = self.battery
|
||||||
|
|
||||||
|
s = cfg.INNOVENERGY_PROTOCOL_VERSION + '\n'
|
||||||
|
s += cfg.INSTALLATION_NAME + '\n'
|
||||||
|
s += str(b.slave_address) + '\n'
|
||||||
|
s += b.hardware_version + '\n'
|
||||||
|
s += b.firmware_version + '\n'
|
||||||
|
s += b.bms_version + '\n'
|
||||||
|
s += str(b.ampere_hours) + '\n'
|
||||||
|
|
||||||
|
for d in self.modbus_data:
|
||||||
|
s += str(d) + '\n'
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def read_file_one_line(file_name):
|
||||||
|
|
||||||
|
with open(file_name, 'r') as file:
|
||||||
|
return file.read().replace('\n', '').replace('\r', '').strip()
|
||||||
|
|
Binary file not shown.
|
@ -0,0 +1,354 @@
|
||||||
|
#!/usr/bin/python2 -u
|
||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import gobject
|
||||||
|
import signals
|
||||||
|
import config as cfg
|
||||||
|
|
||||||
|
from dbus.mainloop.glib import DBusGMainLoop
|
||||||
|
from pymodbus.client.sync import ModbusSerialClient as Modbus
|
||||||
|
from pymodbus.exceptions import ModbusException, ModbusIOException
|
||||||
|
from pymodbus.other_message import ReportSlaveIdRequest
|
||||||
|
from pymodbus.pdu import ExceptionResponse
|
||||||
|
from pymodbus.register_read_message import ReadInputRegistersResponse
|
||||||
|
from data import BatteryStatus, BatterySignal, Battery, ServiceSignal
|
||||||
|
from python_libs.ie_dbus.dbus_service import DBusService
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable, List, Iterable, NoReturn
|
||||||
|
|
||||||
|
|
||||||
|
RESET_REGISTER = 0x2087
|
||||||
|
|
||||||
|
|
||||||
|
def init_modbus(tty):
|
||||||
|
# type: (str) -> Modbus
|
||||||
|
|
||||||
|
logging.debug('initializing Modbus')
|
||||||
|
|
||||||
|
return Modbus(
|
||||||
|
port='/dev/' + tty,
|
||||||
|
method=cfg.MODE,
|
||||||
|
baudrate=cfg.BAUD_RATE,
|
||||||
|
stopbits=cfg.STOP_BITS,
|
||||||
|
bytesize=cfg.BYTE_SIZE,
|
||||||
|
timeout=cfg.TIMEOUT,
|
||||||
|
parity=cfg.PARITY)
|
||||||
|
|
||||||
|
|
||||||
|
def init_udp_socket():
|
||||||
|
# type: () -> socket
|
||||||
|
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
s.setblocking(False)
|
||||||
|
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def report_slave_id(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> str
|
||||||
|
|
||||||
|
slave = str(slave_address)
|
||||||
|
|
||||||
|
logging.debug('requesting slave id from node ' + slave)
|
||||||
|
|
||||||
|
with modbus:
|
||||||
|
|
||||||
|
request = ReportSlaveIdRequest(unit=slave_address)
|
||||||
|
response = modbus.execute(request)
|
||||||
|
|
||||||
|
if response is ExceptionResponse or issubclass(type(response), ModbusException):
|
||||||
|
raise Exception('failed to get slave id from ' + slave + ' : ' + str(response))
|
||||||
|
|
||||||
|
return response.identifier
|
||||||
|
|
||||||
|
|
||||||
|
def identify_battery(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> Battery
|
||||||
|
|
||||||
|
logging.info('identifying battery...')
|
||||||
|
|
||||||
|
hardware_version, bms_version, ampere_hours = parse_slave_id(modbus, slave_address)
|
||||||
|
firmware_version = read_firmware_version(modbus, slave_address)
|
||||||
|
|
||||||
|
specs = Battery(
|
||||||
|
slave_address=slave_address,
|
||||||
|
hardware_version=hardware_version,
|
||||||
|
firmware_version=firmware_version,
|
||||||
|
bms_version=bms_version,
|
||||||
|
ampere_hours=ampere_hours)
|
||||||
|
|
||||||
|
logging.info('battery identified:\n{0}'.format(str(specs)))
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def identify_batteries(modbus):
|
||||||
|
# type: (Modbus) -> List[Battery]
|
||||||
|
|
||||||
|
def _identify_batteries():
|
||||||
|
slave_address = 0
|
||||||
|
n_missing = -255
|
||||||
|
|
||||||
|
while n_missing < 3:
|
||||||
|
slave_address += 1
|
||||||
|
try:
|
||||||
|
yield identify_battery(modbus, slave_address)
|
||||||
|
n_missing = 0
|
||||||
|
except Exception as e:
|
||||||
|
logging.info('failed to identify battery at {0} : {1}'.format(str(slave_address), str(e)))
|
||||||
|
n_missing += 1
|
||||||
|
|
||||||
|
logging.info('giving up searching for further batteries')
|
||||||
|
|
||||||
|
batteries = list(_identify_batteries()) # dont be lazy!
|
||||||
|
|
||||||
|
n = len(batteries)
|
||||||
|
logging.info('found ' + str(n) + (' battery' if n == 1 else ' batteries'))
|
||||||
|
|
||||||
|
return batteries
|
||||||
|
|
||||||
|
|
||||||
|
def parse_slave_id(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> (str, str, int)
|
||||||
|
|
||||||
|
slave_id = report_slave_id(modbus, slave_address)
|
||||||
|
|
||||||
|
sid = re.sub(r'[^\x20-\x7E]', '', slave_id) # remove weird special chars
|
||||||
|
|
||||||
|
match = re.match('(?P<hw>48TL(?P<ah>[0-9]+)) *(?P<bms>.*)', sid)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
raise Exception('no known battery found')
|
||||||
|
|
||||||
|
return match.group('hw').strip(), match.group('bms').strip(), int(match.group('ah').strip())
|
||||||
|
|
||||||
|
|
||||||
|
def read_firmware_version(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> str
|
||||||
|
|
||||||
|
logging.debug('reading firmware version')
|
||||||
|
|
||||||
|
with modbus:
|
||||||
|
|
||||||
|
response = read_modbus_registers(modbus, slave_address, base_address=1054, count=1)
|
||||||
|
register = response.registers[0]
|
||||||
|
|
||||||
|
return '{0:0>4X}'.format(register)
|
||||||
|
|
||||||
|
|
||||||
|
def read_modbus_registers(modbus, slave_address, base_address=cfg.BASE_ADDRESS, count=cfg.NO_OF_REGISTERS):
|
||||||
|
# type: (Modbus, int, int, int) -> ReadInputRegistersResponse
|
||||||
|
|
||||||
|
logging.debug('requesting modbus registers {0}-{1}'.format(base_address, base_address + count))
|
||||||
|
|
||||||
|
return modbus.read_input_registers(
|
||||||
|
address=base_address,
|
||||||
|
count=count,
|
||||||
|
unit=slave_address)
|
||||||
|
|
||||||
|
|
||||||
|
def read_battery_status(modbus, battery):
|
||||||
|
# type: (Modbus, Battery) -> BatteryStatus
|
||||||
|
"""
|
||||||
|
Read the modbus registers containing the battery's status info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging.debug('reading battery status')
|
||||||
|
|
||||||
|
with modbus:
|
||||||
|
data = read_modbus_registers(modbus, battery.slave_address)
|
||||||
|
return BatteryStatus(battery, data.registers)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_values_on_dbus(service, battery_signals, battery_statuses):
|
||||||
|
# type: (DBusService, Iterable[BatterySignal], Iterable[BatteryStatus]) -> ()
|
||||||
|
|
||||||
|
publish_individuals(service, battery_signals, battery_statuses)
|
||||||
|
publish_aggregates(service, battery_signals, battery_statuses)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_aggregates(service, signals, battery_statuses):
|
||||||
|
# type: (DBusService, Iterable[BatterySignal], Iterable[BatteryStatus]) -> ()
|
||||||
|
|
||||||
|
for s in signals:
|
||||||
|
if s.aggregate is None:
|
||||||
|
continue
|
||||||
|
values = [s.get_value(battery_status) for battery_status in battery_statuses]
|
||||||
|
value = s.aggregate(values)
|
||||||
|
service.own_properties.set(s.dbus_path, value, s.unit)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_individuals(service, signals, battery_statuses):
|
||||||
|
# type: (DBusService, Iterable[BatterySignal], Iterable[BatteryStatus]) -> ()
|
||||||
|
|
||||||
|
for signal in signals:
|
||||||
|
for battery_status in battery_statuses:
|
||||||
|
address = battery_status.battery.slave_address
|
||||||
|
dbus_path = '/_Battery/' + str(address) + signal.dbus_path
|
||||||
|
value = signal.get_value(battery_status)
|
||||||
|
service.own_properties.set(dbus_path, value, signal.unit)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_service_signals(service, signals):
|
||||||
|
# type: (DBusService, Iterable[ServiceSignal]) -> NoReturn
|
||||||
|
|
||||||
|
for signal in signals:
|
||||||
|
service.own_properties.set(signal.dbus_path, signal.value, signal.unit)
|
||||||
|
|
||||||
|
|
||||||
|
def upload_status_to_innovenergy(sock, statuses):
|
||||||
|
# type: (socket, Iterable[BatteryStatus]) -> bool
|
||||||
|
|
||||||
|
logging.debug('upload status')
|
||||||
|
|
||||||
|
try:
|
||||||
|
for s in statuses:
|
||||||
|
sock.sendto(s.serialize(), (cfg.INNOVENERGY_SERVER_IP, cfg.INNOVENERGY_SERVER_PORT))
|
||||||
|
except:
|
||||||
|
logging.debug('FAILED')
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def print_usage():
|
||||||
|
print ('Usage: ' + __file__ + ' <serial device>')
|
||||||
|
print ('Example: ' + __file__ + ' ttyUSB0')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cmdline_args(argv):
|
||||||
|
# type: (List[str]) -> str
|
||||||
|
|
||||||
|
if len(argv) == 0:
|
||||||
|
logging.info('missing command line argument for tty device')
|
||||||
|
print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return argv[0]
|
||||||
|
|
||||||
|
|
||||||
|
def reset_batteries(modbus, batteries):
|
||||||
|
# type: (Modbus, Iterable[Battery]) -> NoReturn
|
||||||
|
|
||||||
|
logging.info('Resetting batteries...')
|
||||||
|
|
||||||
|
for battery in batteries:
|
||||||
|
|
||||||
|
result = modbus.write_registers(RESET_REGISTER, [1], unit=battery.slave_address)
|
||||||
|
|
||||||
|
# expecting a ModbusIOException (timeout)
|
||||||
|
# BMS can no longer reply because it is already reset
|
||||||
|
success = isinstance(result, ModbusIOException)
|
||||||
|
|
||||||
|
outcome = 'successfully' if success else 'FAILED to'
|
||||||
|
logging.info('Battery {0} {1} reset'.format(str(battery.slave_address), outcome))
|
||||||
|
|
||||||
|
logging.info('Shutting down fz-sonick driver')
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
alive = True # global alive flag, watchdog_task clears it, update_task sets it
|
||||||
|
|
||||||
|
|
||||||
|
def create_update_task(modbus, service, batteries):
|
||||||
|
# type: (Modbus, DBusService, Iterable[Battery]) -> Callable[[],bool]
|
||||||
|
"""
|
||||||
|
Creates an update task which runs the main update function
|
||||||
|
and resets the alive flag
|
||||||
|
"""
|
||||||
|
_socket = init_udp_socket()
|
||||||
|
_signals = signals.init_battery_signals()
|
||||||
|
|
||||||
|
def update_task():
|
||||||
|
# type: () -> bool
|
||||||
|
|
||||||
|
global alive
|
||||||
|
|
||||||
|
logging.debug('starting update cycle')
|
||||||
|
|
||||||
|
if service.own_properties.get('/ResetBatteries').value == 1:
|
||||||
|
reset_batteries(modbus, batteries)
|
||||||
|
|
||||||
|
statuses = [read_battery_status(modbus, battery) for battery in batteries]
|
||||||
|
|
||||||
|
publish_values_on_dbus(service, _signals, statuses)
|
||||||
|
upload_status_to_innovenergy(_socket, statuses)
|
||||||
|
|
||||||
|
logging.debug('finished update cycle\n')
|
||||||
|
|
||||||
|
alive = True
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
return update_task
|
||||||
|
|
||||||
|
|
||||||
|
def create_watchdog_task(main_loop):
|
||||||
|
# type: (DBusGMainLoop) -> Callable[[],bool]
|
||||||
|
"""
|
||||||
|
Creates a Watchdog task that monitors the alive flag.
|
||||||
|
The watchdog kills the main loop if the alive flag is not periodically reset by the update task.
|
||||||
|
Who watches the watchdog?
|
||||||
|
"""
|
||||||
|
def watchdog_task():
|
||||||
|
# type: () -> bool
|
||||||
|
|
||||||
|
global alive
|
||||||
|
|
||||||
|
if alive:
|
||||||
|
logging.debug('watchdog_task: update_task is alive')
|
||||||
|
alive = False
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.info('watchdog_task: killing main loop because update_task is no longer alive')
|
||||||
|
main_loop.quit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
return watchdog_task
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
# type: (List[str]) -> ()
|
||||||
|
|
||||||
|
logging.basicConfig(level=cfg.LOG_LEVEL)
|
||||||
|
logging.info('starting ' + __file__)
|
||||||
|
|
||||||
|
tty = parse_cmdline_args(argv)
|
||||||
|
modbus = init_modbus(tty)
|
||||||
|
|
||||||
|
batteries = identify_batteries(modbus)
|
||||||
|
|
||||||
|
if len(batteries) <= 0:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
service = DBusService(service_name=cfg.SERVICE_NAME_PREFIX + tty)
|
||||||
|
|
||||||
|
service.own_properties.set('/ResetBatteries', value=False, writable=True) # initial value = False
|
||||||
|
|
||||||
|
main_loop = gobject.MainLoop()
|
||||||
|
|
||||||
|
service_signals = signals.init_service_signals(batteries)
|
||||||
|
publish_service_signals(service, service_signals)
|
||||||
|
|
||||||
|
update_task = create_update_task(modbus, service, batteries)
|
||||||
|
update_task() # run it right away, so that all props are initialized before anyone can ask
|
||||||
|
watchdog_task = create_watchdog_task(main_loop)
|
||||||
|
|
||||||
|
gobject.timeout_add(cfg.UPDATE_INTERVAL * 2, watchdog_task, priority = gobject.PRIORITY_LOW) # add watchdog first
|
||||||
|
gobject.timeout_add(cfg.UPDATE_INTERVAL, update_task, priority = gobject.PRIORITY_LOW) # call update once every update_interval
|
||||||
|
|
||||||
|
logging.info('starting gobject.MainLoop')
|
||||||
|
main_loop.run()
|
||||||
|
logging.info('gobject.MainLoop was shut down')
|
||||||
|
|
||||||
|
sys.exit(0xFF) # reaches this only on error
|
||||||
|
|
||||||
|
|
||||||
|
main(sys.argv[1:])
|
Binary file not shown.
|
@ -0,0 +1,202 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from traceback import print_exc
|
||||||
|
from os import _exit as os_exit
|
||||||
|
from os import statvfs
|
||||||
|
import logging
|
||||||
|
from functools import update_wrapper
|
||||||
|
import dbus
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VEDBUS_INVALID = dbus.Array([], signature=dbus.Signature('i'), variant_level=1)
|
||||||
|
|
||||||
|
# Use this function to make sure the code quits on an unexpected exception. Make sure to use it
|
||||||
|
# when using gobject.idle_add and also gobject.timeout_add.
|
||||||
|
# Without this, the code will just keep running, since gobject does not stop the mainloop on an
|
||||||
|
# exception.
|
||||||
|
# Example: gobject.idle_add(exit_on_error, myfunc, arg1, arg2)
|
||||||
|
def exit_on_error(func, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
print 'exit_on_error: there was an exception. Printing stacktrace will be tryed and then exit'
|
||||||
|
print_exc()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# sys.exit() is not used, since that throws an exception, which does not lead to a program
|
||||||
|
# halt when used in a dbus callback, see connection.py in the Python/Dbus libraries, line 230.
|
||||||
|
os_exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
__vrm_portal_id = None
|
||||||
|
def get_vrm_portal_id():
|
||||||
|
# For the CCGX, the definition of the VRM Portal ID is that it is the mac address of the onboard-
|
||||||
|
# ethernet port (eth0), stripped from its colons (:) and lower case.
|
||||||
|
|
||||||
|
# nice coincidence is that this also works fine when running on your (linux) development computer.
|
||||||
|
|
||||||
|
global __vrm_portal_id
|
||||||
|
|
||||||
|
if __vrm_portal_id:
|
||||||
|
return __vrm_portal_id
|
||||||
|
|
||||||
|
# Assume we are on linux
|
||||||
|
import fcntl, socket, struct
|
||||||
|
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', 'eth0'[:15]))
|
||||||
|
__vrm_portal_id = ''.join(['%02x' % ord(char) for char in info[18:24]])
|
||||||
|
|
||||||
|
return __vrm_portal_id
|
||||||
|
|
||||||
|
|
||||||
|
# See VE.Can registers - public.docx for definition of this conversion
|
||||||
|
def convert_vreg_version_to_readable(version):
|
||||||
|
def str_to_arr(x, length):
|
||||||
|
a = []
|
||||||
|
for i in range(0, len(x), length):
|
||||||
|
a.append(x[i:i+length])
|
||||||
|
return a
|
||||||
|
|
||||||
|
x = "%x" % version
|
||||||
|
x = x.upper()
|
||||||
|
|
||||||
|
if len(x) == 5 or len(x) == 3 or len(x) == 1:
|
||||||
|
x = '0' + x
|
||||||
|
|
||||||
|
a = str_to_arr(x, 2);
|
||||||
|
|
||||||
|
# remove the first 00 if there are three bytes and it is 00
|
||||||
|
if len(a) == 3 and a[0] == '00':
|
||||||
|
a.remove(0);
|
||||||
|
|
||||||
|
# if we have two or three bytes now, and the first character is a 0, remove it
|
||||||
|
if len(a) >= 2 and a[0][0:1] == '0':
|
||||||
|
a[0] = a[0][1];
|
||||||
|
|
||||||
|
result = ''
|
||||||
|
for item in a:
|
||||||
|
result += ('.' if result != '' else '') + item
|
||||||
|
|
||||||
|
|
||||||
|
result = 'v' + result
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_free_space(path):
|
||||||
|
result = -1
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = statvfs(path)
|
||||||
|
result = s.f_frsize * s.f_bavail # Number of free bytes that ordinary users
|
||||||
|
except Exception, ex:
|
||||||
|
logger.info("Error while retrieving free space for path %s: %s" % (path, ex))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_load_averages():
|
||||||
|
c = read_file('/proc/loadavg')
|
||||||
|
return c.split(' ')[:3]
|
||||||
|
|
||||||
|
|
||||||
|
# Returns False if it cannot find a machine name. Otherwise returns the string
|
||||||
|
# containing the name
|
||||||
|
def get_machine_name():
|
||||||
|
c = read_file('/proc/device-tree/model')
|
||||||
|
|
||||||
|
if c != False:
|
||||||
|
return c.strip('\x00')
|
||||||
|
|
||||||
|
return read_file('/etc/venus/machine')
|
||||||
|
|
||||||
|
|
||||||
|
# Returns False if it cannot open the file. Otherwise returns its rstripped contents
|
||||||
|
def read_file(path):
|
||||||
|
content = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
content = f.read().rstrip()
|
||||||
|
except Exception, ex:
|
||||||
|
logger.debug("Error while reading %s: %s" % (path, ex))
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_dbus_value(value):
|
||||||
|
if value is None:
|
||||||
|
return VEDBUS_INVALID
|
||||||
|
if isinstance(value, float):
|
||||||
|
return dbus.Double(value, variant_level=1)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return dbus.Boolean(value, variant_level=1)
|
||||||
|
if isinstance(value, int):
|
||||||
|
return dbus.Int32(value, variant_level=1)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return dbus.String(value, variant_level=1)
|
||||||
|
if isinstance(value, unicode):
|
||||||
|
return dbus.String(value, variant_level=1)
|
||||||
|
if isinstance(value, list):
|
||||||
|
if len(value) == 0:
|
||||||
|
# If the list is empty we cannot infer the type of the contents. So assume unsigned integer.
|
||||||
|
# A (signed) integer is dangerous, because an empty list of signed integers is used to encode
|
||||||
|
# an invalid value.
|
||||||
|
return dbus.Array([], signature=dbus.Signature('u'), variant_level=1)
|
||||||
|
return dbus.Array([wrap_dbus_value(x) for x in value], variant_level=1)
|
||||||
|
if isinstance(value, long):
|
||||||
|
return dbus.Int64(value, variant_level=1)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Wrapping the keys of the dictionary causes D-Bus errors like:
|
||||||
|
# 'arguments to dbus_message_iter_open_container() were incorrect,
|
||||||
|
# assertion "(type == DBUS_TYPE_ARRAY && contained_signature &&
|
||||||
|
# *contained_signature == DBUS_DICT_ENTRY_BEGIN_CHAR) || (contained_signature == NULL ||
|
||||||
|
# _dbus_check_is_valid_signature (contained_signature))" failed in file ...'
|
||||||
|
return dbus.Dictionary({(k, wrap_dbus_value(v)) for k, v in value.items()}, variant_level=1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
dbus_int_types = (dbus.Int32, dbus.UInt32, dbus.Byte, dbus.Int16, dbus.UInt16, dbus.UInt32, dbus.Int64, dbus.UInt64)
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_dbus_value(val):
|
||||||
|
"""Converts D-Bus values back to the original type. For example if val is of type DBus.Double,
|
||||||
|
a float will be returned."""
|
||||||
|
if isinstance(val, dbus_int_types):
|
||||||
|
return int(val)
|
||||||
|
if isinstance(val, dbus.Double):
|
||||||
|
return float(val)
|
||||||
|
if isinstance(val, dbus.Array):
|
||||||
|
v = [unwrap_dbus_value(x) for x in val]
|
||||||
|
return None if len(v) == 0 else v
|
||||||
|
if isinstance(val, (dbus.Signature, dbus.String)):
|
||||||
|
return unicode(val)
|
||||||
|
# Python has no byte type, so we convert to an integer.
|
||||||
|
if isinstance(val, dbus.Byte):
|
||||||
|
return int(val)
|
||||||
|
if isinstance(val, dbus.ByteArray):
|
||||||
|
return "".join([str(x) for x in val])
|
||||||
|
if isinstance(val, (list, tuple)):
|
||||||
|
return [unwrap_dbus_value(x) for x in val]
|
||||||
|
if isinstance(val, (dbus.Dictionary, dict)):
|
||||||
|
# Do not unwrap the keys, see comment in wrap_dbus_value
|
||||||
|
return dict([(x, unwrap_dbus_value(y)) for x, y in val.items()])
|
||||||
|
if isinstance(val, dbus.Boolean):
|
||||||
|
return bool(val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
class reify(object):
|
||||||
|
""" Decorator to replace a property of an object with the calculated value,
|
||||||
|
to make it concrete. """
|
||||||
|
def __init__(self, wrapped):
|
||||||
|
self.wrapped = wrapped
|
||||||
|
update_wrapper(self, wrapped)
|
||||||
|
def __get__(self, inst, objtype=None):
|
||||||
|
if inst is None:
|
||||||
|
return self
|
||||||
|
v = self.wrapped(inst)
|
||||||
|
setattr(inst, self.wrapped.__name__, v)
|
||||||
|
return v
|
Binary file not shown.
|
@ -0,0 +1,496 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import dbus.service
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import os
|
||||||
|
import weakref
|
||||||
|
from ve_utils import wrap_dbus_value, unwrap_dbus_value
|
||||||
|
|
||||||
|
# vedbus contains three classes:
|
||||||
|
# VeDbusItemImport -> use this to read data from the dbus, ie import
|
||||||
|
# VeDbusItemExport -> use this to export data to the dbus (one value)
|
||||||
|
# VeDbusService -> use that to create a service and export several values to the dbus
|
||||||
|
|
||||||
|
# Code for VeDbusItemImport is copied from busitem.py and thereafter modified.
|
||||||
|
# All projects that used busitem.py need to migrate to this package. And some
|
||||||
|
# projects used to define there own equivalent of VeDbusItemExport. Better to
|
||||||
|
# use VeDbusItemExport, or even better the VeDbusService class that does it all for you.
|
||||||
|
|
||||||
|
# TODOS
|
||||||
|
# 1 check for datatypes, it works now, but not sure if all is compliant with
|
||||||
|
# com.victronenergy.BusItem interface definition. See also the files in
|
||||||
|
# tests_and_examples. And see 'if type(v) == dbus.Byte:' on line 102. Perhaps
|
||||||
|
# something similar should also be done in VeDbusBusItemExport?
|
||||||
|
# 2 Shouldn't VeDbusBusItemExport inherit dbus.service.Object?
|
||||||
|
# 7 Make hard rules for services exporting data to the D-Bus, in order to make tracking
|
||||||
|
# changes possible. Does everybody first invalidate its data before leaving the bus?
|
||||||
|
# And what about before taking one object away from the bus, instead of taking the
|
||||||
|
# whole service offline?
|
||||||
|
# They should! And after taking one value away, do we need to know that someone left
|
||||||
|
# the bus? Or we just keep that value in invalidated for ever? Result is that we can't
|
||||||
|
# see the difference anymore between an invalidated value and a value that was first on
|
||||||
|
# the bus and later not anymore. See comments above VeDbusItemImport as well.
|
||||||
|
# 9 there are probably more todos in the code below.
|
||||||
|
|
||||||
|
# Some thoughts with regards to the data types:
|
||||||
|
#
|
||||||
|
# Text from: http://dbus.freedesktop.org/doc/dbus-python/doc/tutorial.html#data-types
|
||||||
|
# ---
|
||||||
|
# Variants are represented by setting the variant_level keyword argument in the
|
||||||
|
# constructor of any D-Bus data type to a value greater than 0 (variant_level 1
|
||||||
|
# means a variant containing some other data type, variant_level 2 means a variant
|
||||||
|
# containing a variant containing some other data type, and so on). If a non-variant
|
||||||
|
# is passed as an argument but introspection indicates that a variant is expected,
|
||||||
|
# it'll automatically be wrapped in a variant.
|
||||||
|
# ---
|
||||||
|
#
|
||||||
|
# Also the different dbus datatypes, such as dbus.Int32, and dbus.UInt32 are a subclass
|
||||||
|
# of Python int. dbus.String is a subclass of Python standard class unicode, etcetera
|
||||||
|
#
|
||||||
|
# So all together that explains why we don't need to explicitly convert back and forth
|
||||||
|
# between the dbus datatypes and the standard python datatypes. Note that all datatypes
|
||||||
|
# in python are objects. Even an int is an object.
|
||||||
|
|
||||||
|
# The signature of a variant is 'v'.
|
||||||
|
|
||||||
|
# Export ourselves as a D-Bus service.
|
||||||
|
class VeDbusService(object):
|
||||||
|
def __init__(self, servicename, bus=None):
|
||||||
|
# dict containing the VeDbusItemExport objects, with their path as the key.
|
||||||
|
self._dbusobjects = {}
|
||||||
|
self._dbusnodes = {}
|
||||||
|
|
||||||
|
# dict containing the onchange callbacks, for each object. Object path is the key
|
||||||
|
self._onchangecallbacks = {}
|
||||||
|
|
||||||
|
# Connect to session bus whenever present, else use the system bus
|
||||||
|
self._dbusconn = bus or (dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus())
|
||||||
|
|
||||||
|
# make the dbus connection available to outside, could make this a true property instead, but ach..
|
||||||
|
self.dbusconn = self._dbusconn
|
||||||
|
|
||||||
|
# Register ourselves on the dbus, trigger an error if already in use (do_not_queue)
|
||||||
|
self._dbusname = dbus.service.BusName(servicename, self._dbusconn, do_not_queue=True)
|
||||||
|
|
||||||
|
# Add the root item that will return all items as a tree
|
||||||
|
self._dbusnodes['/'] = self._create_tree_export(self._dbusconn, '/', self._get_tree_dict)
|
||||||
|
|
||||||
|
logging.info("registered ourselves on D-Bus as %s" % servicename)
|
||||||
|
|
||||||
|
def _get_tree_dict(self, path, get_text=False):
|
||||||
|
logging.debug("_get_tree_dict called for %s" % path)
|
||||||
|
r = {}
|
||||||
|
px = path
|
||||||
|
if not px.endswith('/'):
|
||||||
|
px += '/'
|
||||||
|
for p, item in self._dbusobjects.items():
|
||||||
|
if p.startswith(px):
|
||||||
|
v = item.GetText() if get_text else wrap_dbus_value(item.local_get_value())
|
||||||
|
r[p[len(px):]] = v
|
||||||
|
logging.debug(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
# To force immediate deregistering of this dbus service and all its object paths, explicitly
|
||||||
|
# call __del__().
|
||||||
|
def __del__(self):
|
||||||
|
for node in self._dbusnodes.values():
|
||||||
|
node.__del__()
|
||||||
|
self._dbusnodes.clear()
|
||||||
|
for item in self._dbusobjects.values():
|
||||||
|
item.__del__()
|
||||||
|
self._dbusobjects.clear()
|
||||||
|
if self._dbusname:
|
||||||
|
self._dbusname.__del__() # Forces call to self._bus.release_name(self._name), see source code
|
||||||
|
self._dbusname = None
|
||||||
|
|
||||||
|
# @param callbackonchange function that will be called when this value is changed. First parameter will
|
||||||
|
# be the path of the object, second the new value. This callback should return
|
||||||
|
# True to accept the change, False to reject it.
|
||||||
|
def add_path(self, path, value, description="", writeable=False,
|
||||||
|
onchangecallback=None, gettextcallback=None):
|
||||||
|
|
||||||
|
if onchangecallback is not None:
|
||||||
|
self._onchangecallbacks[path] = onchangecallback
|
||||||
|
|
||||||
|
item = VeDbusItemExport(
|
||||||
|
self._dbusconn, path, value, description, writeable,
|
||||||
|
self._value_changed, gettextcallback, deletecallback=self._item_deleted)
|
||||||
|
|
||||||
|
spl = path.split('/')
|
||||||
|
for i in range(2, len(spl)):
|
||||||
|
subPath = '/'.join(spl[:i])
|
||||||
|
if subPath not in self._dbusnodes and subPath not in self._dbusobjects:
|
||||||
|
self._dbusnodes[subPath] = self._create_tree_export(self._dbusconn, subPath, self._get_tree_dict)
|
||||||
|
self._dbusobjects[path] = item
|
||||||
|
logging.debug('added %s with start value %s. Writeable is %s' % (path, value, writeable))
|
||||||
|
|
||||||
|
# Add the mandatory paths, as per victron dbus api doc
|
||||||
|
def add_mandatory_paths(self, processname, processversion, connection,
|
||||||
|
deviceinstance, productid, productname, firmwareversion, hardwareversion, connected):
|
||||||
|
self.add_path('/Mgmt/ProcessName', processname)
|
||||||
|
self.add_path('/Mgmt/ProcessVersion', processversion)
|
||||||
|
self.add_path('/Mgmt/Connection', connection)
|
||||||
|
|
||||||
|
# Create rest of the mandatory objects
|
||||||
|
self.add_path('/DeviceInstance', deviceinstance)
|
||||||
|
self.add_path('/ProductId', productid)
|
||||||
|
self.add_path('/ProductName', productname)
|
||||||
|
self.add_path('/FirmwareVersion', firmwareversion)
|
||||||
|
self.add_path('/HardwareVersion', hardwareversion)
|
||||||
|
self.add_path('/Connected', connected)
|
||||||
|
|
||||||
|
def _create_tree_export(self, bus, objectPath, get_value_handler):
|
||||||
|
return VeDbusTreeExport(bus, objectPath, get_value_handler)
|
||||||
|
|
||||||
|
# Callback function that is called from the VeDbusItemExport objects when a value changes. This function
|
||||||
|
# maps the change-request to the onchangecallback given to us for this specific path.
|
||||||
|
def _value_changed(self, path, newvalue):
|
||||||
|
if path not in self._onchangecallbacks:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return self._onchangecallbacks[path](path, newvalue)
|
||||||
|
|
||||||
|
def _item_deleted(self, path):
|
||||||
|
self._dbusobjects.pop(path)
|
||||||
|
for np in self._dbusnodes.keys():
|
||||||
|
if np != '/':
|
||||||
|
for ip in self._dbusobjects:
|
||||||
|
if ip.startswith(np + '/'):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self._dbusnodes[np].__del__()
|
||||||
|
self._dbusnodes.pop(np)
|
||||||
|
|
||||||
|
def __getitem__(self, path):
|
||||||
|
return self._dbusobjects[path].local_get_value()
|
||||||
|
|
||||||
|
def __setitem__(self, path, newvalue):
|
||||||
|
self._dbusobjects[path].local_set_value(newvalue)
|
||||||
|
|
||||||
|
def __delitem__(self, path):
|
||||||
|
self._dbusobjects[path].__del__() # Invalidates and then removes the object path
|
||||||
|
assert path not in self._dbusobjects
|
||||||
|
|
||||||
|
def __contains__(self, path):
|
||||||
|
return path in self._dbusobjects
|
||||||
|
|
||||||
|
"""
|
||||||
|
Importing basics:
|
||||||
|
- If when we power up, the D-Bus service does not exist, or it does exist and the path does not
|
||||||
|
yet exist, still subscribe to a signal: as soon as it comes online it will send a signal with its
|
||||||
|
initial value, which VeDbusItemImport will receive and use to update local cache. And, when set,
|
||||||
|
call the eventCallback.
|
||||||
|
- If when we power up, save it
|
||||||
|
- When using get_value, know that there is no difference between services (or object paths) that don't
|
||||||
|
exist and paths that are invalid (= empty array, see above). Both will return None. In case you do
|
||||||
|
really want to know ifa path exists or not, use the exists property.
|
||||||
|
- When a D-Bus service leaves the D-Bus, it will first invalidate all its values, and send signals
|
||||||
|
with that update, and only then leave the D-Bus. (or do we need to subscribe to the NameOwnerChanged-
|
||||||
|
signal!?!) To be discussed and make sure. Not really urgent, since all existing code that uses this
|
||||||
|
class already subscribes to the NameOwnerChanged signal, and subsequently removes instances of this
|
||||||
|
class.
|
||||||
|
|
||||||
|
Read when using this class:
|
||||||
|
Note that when a service leaves that D-Bus without invalidating all its exported objects first, for
|
||||||
|
example because it is killed, VeDbusItemImport doesn't have a clue. So when using VeDbusItemImport,
|
||||||
|
make sure to also subscribe to the NamerOwnerChanged signal on bus-level. Or just use dbusmonitor,
|
||||||
|
because that takes care of all of that for you.
|
||||||
|
"""
|
||||||
|
class VeDbusItemImport(object):
|
||||||
|
## Constructor
|
||||||
|
# @param bus the bus-object (SESSION or SYSTEM).
|
||||||
|
# @param serviceName the dbus-service-name (string), for example 'com.victronenergy.battery.ttyO1'
|
||||||
|
# @param path the object-path, for example '/Dc/V'
|
||||||
|
# @param eventCallback function that you want to be called on a value change
|
||||||
|
# @param createSignal only set this to False if you use this function to one time read a value. When
|
||||||
|
# leaving it to True, make sure to also subscribe to the NameOwnerChanged signal
|
||||||
|
# elsewhere. See also note some 15 lines up.
|
||||||
|
def __init__(self, bus, serviceName, path, eventCallback=None, createsignal=True):
|
||||||
|
# TODO: is it necessary to store _serviceName and _path? Isn't it
|
||||||
|
# stored in the bus_getobjectsomewhere?
|
||||||
|
self._serviceName = serviceName
|
||||||
|
self._path = path
|
||||||
|
self._match = None
|
||||||
|
# TODO: _proxy is being used in settingsdevice.py, make a getter for that
|
||||||
|
self._proxy = bus.get_object(serviceName, path, introspect=False)
|
||||||
|
self.eventCallback = eventCallback
|
||||||
|
|
||||||
|
assert eventCallback is None or createsignal == True
|
||||||
|
if createsignal:
|
||||||
|
self._match = self._proxy.connect_to_signal(
|
||||||
|
"PropertiesChanged", weak_functor(self._properties_changed_handler))
|
||||||
|
|
||||||
|
# store the current value in _cachedvalue. When it doesn't exists set _cachedvalue to
|
||||||
|
# None, same as when a value is invalid
|
||||||
|
self._cachedvalue = None
|
||||||
|
try:
|
||||||
|
v = self._proxy.GetValue()
|
||||||
|
except dbus.exceptions.DBusException:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._cachedvalue = unwrap_dbus_value(v)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if self._match != None:
|
||||||
|
self._match.remove()
|
||||||
|
self._match = None
|
||||||
|
self._proxy = None
|
||||||
|
|
||||||
|
def _refreshcachedvalue(self):
|
||||||
|
self._cachedvalue = unwrap_dbus_value(self._proxy.GetValue())
|
||||||
|
|
||||||
|
## Returns the path as a string, for example '/AC/L1/V'
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
## Returns the dbus service name as a string, for example com.victronenergy.vebus.ttyO1
|
||||||
|
@property
|
||||||
|
def serviceName(self):
|
||||||
|
return self._serviceName
|
||||||
|
|
||||||
|
## Returns the value of the dbus-item.
|
||||||
|
# the type will be a dbus variant, for example dbus.Int32(0, variant_level=1)
|
||||||
|
# this is not a property to keep the name consistant with the com.victronenergy.busitem interface
|
||||||
|
# returns None when the property is invalid
|
||||||
|
def get_value(self):
|
||||||
|
return self._cachedvalue
|
||||||
|
|
||||||
|
## Writes a new value to the dbus-item
|
||||||
|
def set_value(self, newvalue):
|
||||||
|
r = self._proxy.SetValue(wrap_dbus_value(newvalue))
|
||||||
|
|
||||||
|
# instead of just saving the value, go to the dbus and get it. So we have the right type etc.
|
||||||
|
if r == 0:
|
||||||
|
self._refreshcachedvalue()
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
## Returns the text representation of the value.
|
||||||
|
# For example when the value is an enum/int GetText might return the string
|
||||||
|
# belonging to that enum value. Another example, for a voltage, GetValue
|
||||||
|
# would return a float, 12.0Volt, and GetText could return 12 VDC.
|
||||||
|
#
|
||||||
|
# Note that this depends on how the dbus-producer has implemented this.
|
||||||
|
def get_text(self):
|
||||||
|
return self._proxy.GetText()
|
||||||
|
|
||||||
|
## Returns true of object path exists, and false if it doesn't
|
||||||
|
@property
|
||||||
|
def exists(self):
|
||||||
|
# TODO: do some real check instead of this crazy thing.
|
||||||
|
r = False
|
||||||
|
try:
|
||||||
|
r = self._proxy.GetValue()
|
||||||
|
r = True
|
||||||
|
except dbus.exceptions.DBusException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
## callback for the trigger-event.
|
||||||
|
# @param eventCallback the event-callback-function.
|
||||||
|
@property
|
||||||
|
def eventCallback(self):
|
||||||
|
return self._eventCallback
|
||||||
|
|
||||||
|
@eventCallback.setter
|
||||||
|
def eventCallback(self, eventCallback):
|
||||||
|
self._eventCallback = eventCallback
|
||||||
|
|
||||||
|
## Is called when the value of the imported bus-item changes.
|
||||||
|
# Stores the new value in our local cache, and calls the eventCallback, if set.
|
||||||
|
def _properties_changed_handler(self, changes):
|
||||||
|
if "Value" in changes:
|
||||||
|
changes['Value'] = unwrap_dbus_value(changes['Value'])
|
||||||
|
self._cachedvalue = changes['Value']
|
||||||
|
if self._eventCallback:
|
||||||
|
# The reason behind this try/except is to prevent errors silently ending up the an error
|
||||||
|
# handler in the dbus code.
|
||||||
|
try:
|
||||||
|
self._eventCallback(self._serviceName, self._path, changes)
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
os._exit(1) # sys.exit() is not used, since that also throws an exception
|
||||||
|
|
||||||
|
|
||||||
|
class VeDbusTreeExport(dbus.service.Object):
|
||||||
|
def __init__(self, bus, objectPath, get_value_handler):
|
||||||
|
dbus.service.Object.__init__(self, bus, objectPath)
|
||||||
|
self._get_value_handler = get_value_handler
|
||||||
|
logging.debug("VeDbusTreeExport %s has been created" % objectPath)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
# self._get_path() will raise an exception when retrieved after the call to .remove_from_connection,
|
||||||
|
# so we need a copy.
|
||||||
|
path = self._get_path()
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
self.remove_from_connection()
|
||||||
|
logging.debug("VeDbusTreeExport %s has been removed" % path)
|
||||||
|
|
||||||
|
def _get_path(self):
|
||||||
|
if len(self._locations) == 0:
|
||||||
|
return None
|
||||||
|
return self._locations[0][1]
|
||||||
|
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='v')
|
||||||
|
def GetValue(self):
|
||||||
|
value = self._get_value_handler(self._get_path())
|
||||||
|
return dbus.Dictionary(value, signature=dbus.Signature('sv'), variant_level=1)
|
||||||
|
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='v')
|
||||||
|
def GetText(self):
|
||||||
|
return self._get_value_handler(self._get_path(), True)
|
||||||
|
|
||||||
|
def local_get_value(self):
|
||||||
|
return self._get_value_handler(self.path)
|
||||||
|
|
||||||
|
|
||||||
|
class VeDbusItemExport(dbus.service.Object):
|
||||||
|
## Constructor of VeDbusItemExport
|
||||||
|
#
|
||||||
|
# Use this object to export (publish), values on the dbus
|
||||||
|
# Creates the dbus-object under the given dbus-service-name.
|
||||||
|
# @param bus The dbus object.
|
||||||
|
# @param objectPath The dbus-object-path.
|
||||||
|
# @param value Value to initialize ourselves with, defaults to None which means Invalid
|
||||||
|
# @param description String containing a description. Can be called over the dbus with GetDescription()
|
||||||
|
# @param writeable what would this do!? :).
|
||||||
|
# @param callback Function that will be called when someone else changes the value of this VeBusItem
|
||||||
|
# over the dbus. First parameter passed to callback will be our path, second the new
|
||||||
|
# value. This callback should return True to accept the change, False to reject it.
|
||||||
|
def __init__(self, bus, objectPath, value=None, description=None, writeable=False,
|
||||||
|
onchangecallback=None, gettextcallback=None, deletecallback=None):
|
||||||
|
dbus.service.Object.__init__(self, bus, objectPath)
|
||||||
|
self._onchangecallback = onchangecallback
|
||||||
|
self._gettextcallback = gettextcallback
|
||||||
|
self._value = value
|
||||||
|
self._description = description
|
||||||
|
self._writeable = writeable
|
||||||
|
self._deletecallback = deletecallback
|
||||||
|
|
||||||
|
# To force immediate deregistering of this dbus object, explicitly call __del__().
|
||||||
|
def __del__(self):
|
||||||
|
# self._get_path() will raise an exception when retrieved after the
|
||||||
|
# call to .remove_from_connection, so we need a copy.
|
||||||
|
path = self._get_path()
|
||||||
|
if path == None:
|
||||||
|
return
|
||||||
|
if self._deletecallback is not None:
|
||||||
|
self._deletecallback(path)
|
||||||
|
self.local_set_value(None)
|
||||||
|
self.remove_from_connection()
|
||||||
|
logging.debug("VeDbusItemExport %s has been removed" % path)
|
||||||
|
|
||||||
|
def _get_path(self):
|
||||||
|
if len(self._locations) == 0:
|
||||||
|
return None
|
||||||
|
return self._locations[0][1]
|
||||||
|
|
||||||
|
## Sets the value. And in case the value is different from what it was, a signal
|
||||||
|
# will be emitted to the dbus. This function is to be used in the python code that
|
||||||
|
# is using this class to export values to the dbus.
|
||||||
|
# set value to None to indicate that it is Invalid
|
||||||
|
def local_set_value(self, newvalue):
|
||||||
|
if self._value == newvalue:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._value = newvalue
|
||||||
|
|
||||||
|
changes = {}
|
||||||
|
changes['Value'] = wrap_dbus_value(newvalue)
|
||||||
|
changes['Text'] = self.GetText()
|
||||||
|
self.PropertiesChanged(changes)
|
||||||
|
|
||||||
|
def local_get_value(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
# ==== ALL FUNCTIONS BELOW THIS LINE WILL BE CALLED BY OTHER PROCESSES OVER THE DBUS ====
|
||||||
|
|
||||||
|
## Dbus exported method SetValue
|
||||||
|
# Function is called over the D-Bus by other process. It will first check (via callback) if new
|
||||||
|
# value is accepted. And it is, stores it and emits a changed-signal.
|
||||||
|
# @param value The new value.
|
||||||
|
# @return completion-code When successful a 0 is return, and when not a -1 is returned.
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', in_signature='v', out_signature='i')
|
||||||
|
def SetValue(self, newvalue):
|
||||||
|
if not self._writeable:
|
||||||
|
return 1 # NOT OK
|
||||||
|
|
||||||
|
newvalue = unwrap_dbus_value(newvalue)
|
||||||
|
|
||||||
|
if newvalue == self._value:
|
||||||
|
return 0 # OK
|
||||||
|
|
||||||
|
# call the callback given to us, and check if new value is OK.
|
||||||
|
if (self._onchangecallback is None or
|
||||||
|
(self._onchangecallback is not None and self._onchangecallback(self.__dbus_object_path__, newvalue))):
|
||||||
|
|
||||||
|
self.local_set_value(newvalue)
|
||||||
|
return 0 # OK
|
||||||
|
|
||||||
|
return 2 # NOT OK
|
||||||
|
|
||||||
|
## Dbus exported method GetDescription
|
||||||
|
#
|
||||||
|
# Returns the a description.
|
||||||
|
# @param language A language code (e.g. ISO 639-1 en-US).
|
||||||
|
# @param length Lenght of the language string.
|
||||||
|
# @return description
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', in_signature='si', out_signature='s')
|
||||||
|
def GetDescription(self, language, length):
|
||||||
|
return self._description if self._description is not None else 'No description given'
|
||||||
|
|
||||||
|
## Dbus exported method GetValue
|
||||||
|
# Returns the value.
|
||||||
|
# @return the value when valid, and otherwise an empty array
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='v')
|
||||||
|
def GetValue(self):
|
||||||
|
return wrap_dbus_value(self._value)
|
||||||
|
|
||||||
|
## Dbus exported method GetText
|
||||||
|
# Returns the value as string of the dbus-object-path.
|
||||||
|
# @return text A text-value. '---' when local value is invalid
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='s')
|
||||||
|
def GetText(self):
|
||||||
|
if self._value is None:
|
||||||
|
return '---'
|
||||||
|
|
||||||
|
# Default conversion from dbus.Byte will get you a character (so 'T' instead of '84'), so we
|
||||||
|
# have to convert to int first. Note that if a dbus.Byte turns up here, it must have come from
|
||||||
|
# the application itself, as all data from the D-Bus should have been unwrapped by now.
|
||||||
|
if self._gettextcallback is None and type(self._value) == dbus.Byte:
|
||||||
|
return str(int(self._value))
|
||||||
|
|
||||||
|
if self._gettextcallback is None and self.__dbus_object_path__ == '/ProductId':
|
||||||
|
return "0x%X" % self._value
|
||||||
|
|
||||||
|
if self._gettextcallback is None:
|
||||||
|
return str(self._value)
|
||||||
|
|
||||||
|
return self._gettextcallback(self.__dbus_object_path__, self._value)
|
||||||
|
|
||||||
|
## The signal that indicates that the value has changed.
|
||||||
|
# Other processes connected to this BusItem object will have subscribed to the
|
||||||
|
# event when they want to track our state.
|
||||||
|
@dbus.service.signal('com.victronenergy.BusItem', signature='a{sv}')
|
||||||
|
def PropertiesChanged(self, changes):
|
||||||
|
pass
|
||||||
|
|
||||||
|
## This class behaves like a regular reference to a class method (eg. self.foo), but keeps a weak reference
|
||||||
|
## to the object which method is to be called.
|
||||||
|
## Use this object to break circular references.
|
||||||
|
class weak_functor:
|
||||||
|
def __init__(self, f):
|
||||||
|
self._r = weakref.ref(f.__self__)
|
||||||
|
self._f = weakref.ref(f.__func__)
|
||||||
|
|
||||||
|
def __call__(self, *args, **kargs):
|
||||||
|
r = self._r()
|
||||||
|
f = self._f()
|
||||||
|
if r == None or f == None:
|
||||||
|
return
|
||||||
|
f(r, *args, **kargs)
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,54 @@
|
||||||
|
from logging import getLogger
|
||||||
|
from python_libs.ie_utils.mixins import Disposable, RequiresMainLoop, Record
|
||||||
|
from python_libs.ie_dbus.private.dbus_daemon import DBusDaemon
|
||||||
|
from python_libs.ie_dbus.private.own_properties import OwnProperties
|
||||||
|
from python_libs.ie_dbus.private.remote_properties import RemoteProperties
|
||||||
|
from python_libs.ie_dbus.private.ve_constants import SERVICE_PREFIX
|
||||||
|
from python_libs.ie_dbus.private.settings import Settings
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Union, AnyStr, NoReturn, List
|
||||||
|
|
||||||
|
|
||||||
|
def _enforce_ve_prefix(service_name_filter):
|
||||||
|
if not service_name_filter.startswith(SERVICE_PREFIX):
|
||||||
|
raise ValueError('service_name_filter must start with ' + SERVICE_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
SESSION_BUS = 0
|
||||||
|
SYSTEM_BUS = 1
|
||||||
|
|
||||||
|
|
||||||
|
class DBusService(Record, Disposable, RequiresMainLoop):
|
||||||
|
|
||||||
|
def __init__(self, service_name=None, device_instance=1, connection_type_or_address=SYSTEM_BUS):
|
||||||
|
# type: (str, int, Union[int, AnyStr]) -> NoReturn
|
||||||
|
|
||||||
|
service_name = service_name if service_name.startswith(SERVICE_PREFIX) else SERVICE_PREFIX + service_name
|
||||||
|
|
||||||
|
self._daemon = DBusDaemon(connection_type_or_address)
|
||||||
|
self.remote_properties = RemoteProperties(self._daemon)
|
||||||
|
self.own_properties = OwnProperties(self._daemon)
|
||||||
|
self.own_properties.set('/DeviceInstance', device_instance) # must be set before request_name, sigh
|
||||||
|
|
||||||
|
self.settings = Settings(self._daemon, self.remote_properties)
|
||||||
|
self.name = service_name
|
||||||
|
|
||||||
|
if service_name is not None:
|
||||||
|
self._bus_name = self._daemon.request_name(service_name)
|
||||||
|
_log.info('service name is ' + service_name)
|
||||||
|
|
||||||
|
_log.info('id is ' + self.bus_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_services(self):
|
||||||
|
# type: () -> List[unicode]
|
||||||
|
return [s.name for s in self._daemon.services]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bus_id(self):
|
||||||
|
# type: () -> unicode
|
||||||
|
return self._daemon.bus_id
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,22 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from python_libs.ie_utils.mixins import Record
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import AnyStr
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInfo(Record):
|
||||||
|
|
||||||
|
# noinspection PyShadowingBuiltins
|
||||||
|
def __init__(self, name, id, pid, proc_name, cmd):
|
||||||
|
# type: (AnyStr, AnyStr, int, str, str) -> ServiceInfo
|
||||||
|
|
||||||
|
self.proc_name = proc_name
|
||||||
|
self.name = name
|
||||||
|
self.id = id
|
||||||
|
self.cmd = cmd
|
||||||
|
self.pid = pid
|
Binary file not shown.
|
@ -0,0 +1,185 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from _dbus_bindings import Connection, MethodCallMessage, SignalMessage, BUS_DAEMON_NAME, \
|
||||||
|
BUS_DAEMON_PATH, BUS_DAEMON_IFACE, NAME_FLAG_DO_NOT_QUEUE, Message, HANDLER_RESULT_HANDLED
|
||||||
|
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import dbus_string, dbus_uint32
|
||||||
|
from python_libs.ie_dbus.private.message_types import DBusException
|
||||||
|
from python_libs.ie_utils.mixins import Disposable
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import List, Optional, Iterable, Callable, Union, NoReturn, AnyStr, Any
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import DbusType
|
||||||
|
|
||||||
|
|
||||||
|
class DbusConnection(Disposable):
|
||||||
|
"""
|
||||||
|
A collection of stateless functions operating on a Connection object
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, connection_type_or_address):
|
||||||
|
# type: (Union[int, AnyStr]) -> NoReturn
|
||||||
|
|
||||||
|
self._address = connection_type_or_address
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
self._connection = Connection._new_for_bus(connection_type_or_address) # it's not disposable
|
||||||
|
self.chain_disposable(self._connection.close, 'connection ' + self._connection.get_unique_name())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bus_id(self):
|
||||||
|
return self._connection.get_unique_name()
|
||||||
|
|
||||||
|
def fork(self):
|
||||||
|
return DbusConnection(self._address)
|
||||||
|
|
||||||
|
def get_ids_and_service_names(self):
|
||||||
|
# type: () -> Iterable[unicode]
|
||||||
|
|
||||||
|
# noinspection PyTypeChecker
|
||||||
|
return map(unicode, self.call_daemon_method('ListNames')[0])
|
||||||
|
|
||||||
|
def get_service_names(self):
|
||||||
|
# type: () -> Iterable[AnyStr]
|
||||||
|
|
||||||
|
return (
|
||||||
|
unicode(name)
|
||||||
|
for name
|
||||||
|
in self.get_ids_and_service_names()
|
||||||
|
if not name.startswith(':')
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_service_ids(self):
|
||||||
|
# type: () -> Iterable[AnyStr]
|
||||||
|
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
for name in self.get_ids_and_service_names() if name.startswith(':'))
|
||||||
|
|
||||||
|
# noinspection PyBroadException
|
||||||
|
def get_pid_of_service(self, service_name):
|
||||||
|
# type: (AnyStr) -> Optional[int]
|
||||||
|
try:
|
||||||
|
reply = self.call_daemon_method('GetConnectionUnixProcessID', dbus_string(service_name))
|
||||||
|
return int(reply[0])
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_id_of_service(self, service_name):
|
||||||
|
# type: (AnyStr) -> AnyStr
|
||||||
|
reply = self.call_daemon_method('GetNameOwner', dbus_string(service_name))
|
||||||
|
return unicode(reply[0])
|
||||||
|
|
||||||
|
def call_method(self, service_name, object_path, interface, member, *args):
|
||||||
|
# type: (AnyStr, AnyStr, Optional[str], str, List[Any]) -> List[Any]
|
||||||
|
|
||||||
|
msg = MethodCallMessage(service_name, object_path, interface, member)
|
||||||
|
|
||||||
|
for arg in args:
|
||||||
|
msg.append(arg)
|
||||||
|
|
||||||
|
reply = self._connection.send_message_with_reply_and_block(msg) # with py3 we could use asyncio here
|
||||||
|
DBusException.raise_if_error_reply(reply)
|
||||||
|
|
||||||
|
return reply.get_args_list() # TODO: utf8_strings=True ?
|
||||||
|
|
||||||
|
def send_message(self, msg):
|
||||||
|
# type: (Message) -> NoReturn
|
||||||
|
|
||||||
|
self._connection.send_message(msg)
|
||||||
|
|
||||||
|
def call_daemon_method(self, method_name, *args):
|
||||||
|
# type: (AnyStr, Iterable[DbusType])-> List[any]
|
||||||
|
|
||||||
|
return self.call_method(BUS_DAEMON_NAME, BUS_DAEMON_PATH, BUS_DAEMON_IFACE, method_name, *args)
|
||||||
|
|
||||||
|
def request_name(self, service_name):
|
||||||
|
# type: (AnyStr) -> Disposable
|
||||||
|
|
||||||
|
_log.debug('requesting bus name ' + service_name)
|
||||||
|
|
||||||
|
self.call_daemon_method('RequestName', dbus_string(service_name), dbus_uint32(NAME_FLAG_DO_NOT_QUEUE))
|
||||||
|
|
||||||
|
def dispose():
|
||||||
|
self.call_daemon_method('ReleaseName', dbus_string(service_name))
|
||||||
|
|
||||||
|
return self.create_dependent_disposable(dispose, 'bus name ' + service_name)
|
||||||
|
|
||||||
|
def broadcast_signal(self, object_path, interface, member, *args):
|
||||||
|
# type: (AnyStr, AnyStr, AnyStr, List[Any]) -> NoReturn
|
||||||
|
|
||||||
|
msg = SignalMessage(object_path, interface, member)
|
||||||
|
for arg in args:
|
||||||
|
msg.append(arg)
|
||||||
|
|
||||||
|
self._connection.send_message(msg)
|
||||||
|
|
||||||
|
def add_message_callback(self, callback, filter_rule, fork=True):
|
||||||
|
# type: (Callable[[Message], NoReturn], AnyStr, Optional[bool]) -> Disposable
|
||||||
|
if fork:
|
||||||
|
return self._add_message_callback_fork(callback, filter_rule)
|
||||||
|
else:
|
||||||
|
return self._add_message_callback_no_fork(callback, filter_rule)
|
||||||
|
|
||||||
|
def _add_message_callback_no_fork(self, callback, filter_rule): # TODO: forking for incoming method calls
|
||||||
|
# type: (Callable[[Message], NoReturn], AnyStr) -> Disposable
|
||||||
|
|
||||||
|
def dispatch(_, msg):
|
||||||
|
# type: (Connection, Message) -> int
|
||||||
|
|
||||||
|
#_log.info(' ####### got message type=' + str(msg.get_type()) + ' ' + msg.get_path() + '/' + msg.get_member())
|
||||||
|
callback(msg)
|
||||||
|
#_log.debug('DONE')
|
||||||
|
return HANDLER_RESULT_HANDLED
|
||||||
|
|
||||||
|
msg_filter = self._add_message_filter(dispatch)
|
||||||
|
match = self._add_match(filter_rule)
|
||||||
|
|
||||||
|
def dispose():
|
||||||
|
match.dispose()
|
||||||
|
msg_filter.dispose()
|
||||||
|
|
||||||
|
return self.create_dependent_disposable(dispose)
|
||||||
|
|
||||||
|
def _add_message_callback_fork(self, callback, filter_rule):
|
||||||
|
# type: (Callable[[Message], NoReturn], AnyStr) -> Disposable
|
||||||
|
|
||||||
|
forked = self.fork()
|
||||||
|
_log.debug('forked connection ' + forked.bus_id)
|
||||||
|
|
||||||
|
def dispatch(_, msg):
|
||||||
|
# type: (Connection, Message) -> int
|
||||||
|
|
||||||
|
# _log.debug('got message type=' + str(msg.get_type()) + ' ' + msg.get_path() + '/' + msg.get_member())
|
||||||
|
callback(msg)
|
||||||
|
return HANDLER_RESULT_HANDLED
|
||||||
|
|
||||||
|
forked._add_message_filter(dispatch)
|
||||||
|
forked._add_match(filter_rule)
|
||||||
|
|
||||||
|
return self.create_dependent_disposable(forked)
|
||||||
|
|
||||||
|
def _add_message_filter(self, callback):
|
||||||
|
# type: (Callable[[Connection, Message], int]) -> Disposable
|
||||||
|
|
||||||
|
_log.debug('added filter on ' + self.bus_id)
|
||||||
|
self._connection.add_message_filter(callback)
|
||||||
|
|
||||||
|
def dispose():
|
||||||
|
self._connection.remove_message_filter(callback)
|
||||||
|
|
||||||
|
return self.create_dependent_disposable(dispose, 'message filter on ' + self.bus_id)
|
||||||
|
|
||||||
|
def _add_match(self, filter_rule):
|
||||||
|
# type: (AnyStr) -> Disposable
|
||||||
|
|
||||||
|
self.call_daemon_method('AddMatch', dbus_string(filter_rule))
|
||||||
|
|
||||||
|
_log.debug('added match_rule: ' + filter_rule)
|
||||||
|
|
||||||
|
def dispose():
|
||||||
|
self.call_daemon_method('RemoveMatch', dbus_string(filter_rule))
|
||||||
|
|
||||||
|
return self.create_dependent_disposable(dispose, 'Match ' + filter_rule)
|
Binary file not shown.
|
@ -0,0 +1,273 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from _dbus_bindings import Message, ErrorMessage, BUS_DAEMON_NAME, BUS_DAEMON_PATH, BUS_DAEMON_IFACE
|
||||||
|
from python_libs.ie_dbus.private.datatypes import ServiceInfo
|
||||||
|
from python_libs.ie_dbus.private.dbus_connection import DbusConnection
|
||||||
|
from python_libs.ie_dbus.private.message_types import MatchedMessage, MessageFilter, ResolvedMessage
|
||||||
|
from python_libs.ie_utils.mixins import Disposable, RequiresMainLoop
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
NONE = '<none>'
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable, List, Optional, Iterable, Union, AnyStr, NoReturn, Any, Dict
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import DbusType
|
||||||
|
|
||||||
|
|
||||||
|
class DBusDaemon(Disposable, RequiresMainLoop):
|
||||||
|
|
||||||
|
_services = None # type: Dict[str, ServiceInfo]
|
||||||
|
|
||||||
|
def __init__(self, connection_type_or_address):
|
||||||
|
# type: (Union[int, AnyStr]) -> NoReturn
|
||||||
|
|
||||||
|
self._dbus = DbusConnection(connection_type_or_address)
|
||||||
|
# self._dbus.add_message_callback(lambda _: None, 'type=method_call', fork=False) # sink method calls, TODO
|
||||||
|
|
||||||
|
self._name_changed = self.subscribe_to_signal_message(
|
||||||
|
self._on_name_owner_changed,
|
||||||
|
sender_id=BUS_DAEMON_NAME,
|
||||||
|
object_path=BUS_DAEMON_PATH,
|
||||||
|
interface=BUS_DAEMON_IFACE,
|
||||||
|
member='NameOwnerChanged')
|
||||||
|
|
||||||
|
self._services = self._init_services()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bus_id(self):
|
||||||
|
# type: () -> AnyStr
|
||||||
|
return self._dbus.bus_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def services(self):
|
||||||
|
# type: () -> Iterable[ServiceInfo]
|
||||||
|
return self._services.itervalues()
|
||||||
|
|
||||||
|
def subscribe_to_signal_message(
|
||||||
|
self,
|
||||||
|
callback,
|
||||||
|
sender_id='*',
|
||||||
|
sender_name='*',
|
||||||
|
object_path='*',
|
||||||
|
interface='*',
|
||||||
|
member='*',
|
||||||
|
signature='*'):
|
||||||
|
# type: (Callable[[MatchedMessage], None], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr]) -> Disposable
|
||||||
|
|
||||||
|
message_filter = MessageFilter(
|
||||||
|
message_type='signal',
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
object_path=object_path,
|
||||||
|
interface=interface,
|
||||||
|
member=member,
|
||||||
|
signature=signature)
|
||||||
|
|
||||||
|
def dispatch(msg):
|
||||||
|
# type: (Message) -> NoReturn
|
||||||
|
|
||||||
|
resolved_msg = self._resolve_message(msg)
|
||||||
|
matched = message_filter.match_message(resolved_msg)
|
||||||
|
|
||||||
|
if matched is not None:
|
||||||
|
callback(matched)
|
||||||
|
|
||||||
|
return self._dbus.add_message_callback(dispatch, message_filter.filter_rule)
|
||||||
|
|
||||||
|
def subscribe_to_method_call_message(
|
||||||
|
self,
|
||||||
|
callback,
|
||||||
|
sender_id='*',
|
||||||
|
sender_name='*',
|
||||||
|
object_path='*',
|
||||||
|
interface='*',
|
||||||
|
member='*',
|
||||||
|
signature='*',
|
||||||
|
destination_id='*',
|
||||||
|
destination_name='*'):
|
||||||
|
# type: (Callable[[MatchedMessage], Any], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[AnyStr], Optional[bool]) -> Disposable
|
||||||
|
|
||||||
|
message_filter = MessageFilter(
|
||||||
|
message_type='method_call',
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
object_path=object_path,
|
||||||
|
interface=interface,
|
||||||
|
member=member,
|
||||||
|
signature=signature,
|
||||||
|
destination_id=destination_id,
|
||||||
|
destination_name=destination_name) # TODO: eavesdrop logic
|
||||||
|
|
||||||
|
def dispatch(msg):
|
||||||
|
# type: (Message) -> NoReturn
|
||||||
|
|
||||||
|
if msg.get_type() != 1:
|
||||||
|
return
|
||||||
|
|
||||||
|
resolved_msg = self._resolve_message(msg)
|
||||||
|
matched = message_filter.match_message(resolved_msg)
|
||||||
|
|
||||||
|
if matched is None:
|
||||||
|
reply = ErrorMessage(msg, 'com.victronenergy.method_call_refused', 'refused')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
result = callback(matched)
|
||||||
|
except Exception as e:
|
||||||
|
# _log.debug('method_call threw an exception ' + str(e))
|
||||||
|
# traceback.print_exc()
|
||||||
|
reply = matched.create_error_reply(e)
|
||||||
|
else:
|
||||||
|
reply = matched.create_method_reply(result)
|
||||||
|
|
||||||
|
self._dbus.send_message(reply)
|
||||||
|
|
||||||
|
return self._dbus.add_message_callback(dispatch, message_filter.filter_rule, fork=False)
|
||||||
|
|
||||||
|
def request_name(self, service_name):
|
||||||
|
# type: (AnyStr) -> Disposable
|
||||||
|
|
||||||
|
return self._dbus.request_name(service_name)
|
||||||
|
|
||||||
|
def call_method(self, service_name, object_path, interface, member, *args):
|
||||||
|
# type: (AnyStr, AnyStr, AnyStr, AnyStr, Iterable[DbusType]) -> List[Any]
|
||||||
|
|
||||||
|
return self._dbus.call_method(service_name, object_path, interface, member, *args)
|
||||||
|
|
||||||
|
def broadcast_signal(self, object_path, interface, member, *args):
|
||||||
|
# type: (AnyStr, AnyStr, AnyStr, List[DbusType]) -> NoReturn
|
||||||
|
|
||||||
|
self._dbus.broadcast_signal(object_path, interface, member, *args)
|
||||||
|
|
||||||
|
def get_service_names_of_id(self, service_id):
|
||||||
|
# type: (str) -> List[AnyStr]
|
||||||
|
|
||||||
|
if service_id is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
s.name
|
||||||
|
for s in self.services
|
||||||
|
if s.id == service_id
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_id_for_service_name(self, service_name):
|
||||||
|
# type: (AnyStr) -> Optional[AnyStr]
|
||||||
|
|
||||||
|
return next((s.id for s in self.services if s.name == service_name), None)
|
||||||
|
|
||||||
|
def exists_service_with_name(self, service_name):
|
||||||
|
# type: (AnyStr) -> bool
|
||||||
|
|
||||||
|
return self.get_id_for_service_name(service_name) is not None
|
||||||
|
|
||||||
|
def _resolve_message(self, msg):
|
||||||
|
# type: (Message) -> ResolvedMessage
|
||||||
|
|
||||||
|
sender_id, sender_names = self._resolve_name(msg.get_sender())
|
||||||
|
destination_id, destination_names = self._resolve_name(msg.get_destination())
|
||||||
|
|
||||||
|
return ResolvedMessage(msg, sender_id, sender_names, destination_id, destination_names)
|
||||||
|
|
||||||
|
# noinspection PyShadowingBuiltins
|
||||||
|
def _resolve_name(self, name):
|
||||||
|
# type: (str) -> (str, List[str])
|
||||||
|
|
||||||
|
if name is None:
|
||||||
|
id = NONE
|
||||||
|
names = []
|
||||||
|
elif name.startswith(':'):
|
||||||
|
id = name
|
||||||
|
names = self.get_service_names_of_id(name)
|
||||||
|
else:
|
||||||
|
id = self.get_id_for_service_name(name)
|
||||||
|
names = [name]
|
||||||
|
|
||||||
|
return id, names
|
||||||
|
|
||||||
|
def _on_name_owner_changed(self, msg):
|
||||||
|
# type: (MatchedMessage) -> NoReturn
|
||||||
|
|
||||||
|
(name, old_id, new_id) = msg.arguments
|
||||||
|
|
||||||
|
old_id = old_id.strip()
|
||||||
|
new_id = new_id.strip()
|
||||||
|
name = name.strip()
|
||||||
|
|
||||||
|
if name.startswith(':'):
|
||||||
|
name = None
|
||||||
|
|
||||||
|
added = old_id == '' and new_id != ''
|
||||||
|
changed = old_id != '' and new_id != ''
|
||||||
|
removed = old_id != '' and new_id == ''
|
||||||
|
|
||||||
|
# 'changed' is dispatched as 'removed' followed by 'added'
|
||||||
|
|
||||||
|
if removed or changed:
|
||||||
|
self._services.pop(old_id, None)
|
||||||
|
|
||||||
|
if added or changed:
|
||||||
|
service = self._create_service(name, new_id)
|
||||||
|
self._services[new_id] = service
|
||||||
|
|
||||||
|
# noinspection PyShadowingBuiltins
|
||||||
|
def _init_services(self):
|
||||||
|
# type: () -> Dict[str, ServiceInfo]
|
||||||
|
|
||||||
|
services = dict()
|
||||||
|
|
||||||
|
names_and_ids = self._dbus.get_ids_and_service_names()
|
||||||
|
|
||||||
|
ids = set([i for i in names_and_ids if i.startswith(':')])
|
||||||
|
names = [n for n in names_and_ids if not n.startswith(':')]
|
||||||
|
|
||||||
|
for service_name in names:
|
||||||
|
service = self._create_service(service_name)
|
||||||
|
services[service.id] = service
|
||||||
|
ids.discard(service.id)
|
||||||
|
|
||||||
|
self._services = services # UGLY, because _create_service below references it.
|
||||||
|
|
||||||
|
for id in ids:
|
||||||
|
services[id] = self._create_service(id=id)
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
def _search_service_name_by_pid(self, pid):
|
||||||
|
# type: (int) -> Optional[AnyStr]
|
||||||
|
return next((s.name for s in self.services if s.pid == pid and s.name != NONE), NONE)
|
||||||
|
|
||||||
|
# noinspection PyShadowingBuiltins
|
||||||
|
def _create_service(self, name=None, id=None):
|
||||||
|
# type: (Optional[AnyStr], Optional[AnyStr]) -> ServiceInfo
|
||||||
|
|
||||||
|
id = id or self._dbus.get_id_of_service(name)
|
||||||
|
pid = self._dbus.get_pid_of_service(id)
|
||||||
|
proc = self._get_process_name_of_pid(pid)
|
||||||
|
cmd = self._get_commandline_of_pid(pid)
|
||||||
|
name = name or self._search_service_name_by_pid(pid)
|
||||||
|
|
||||||
|
return ServiceInfo(name, id, pid, proc, cmd)
|
||||||
|
|
||||||
|
# noinspection PyBroadException
|
||||||
|
@staticmethod
|
||||||
|
def _get_process_name_of_pid(service_pid):
|
||||||
|
# type: (int) -> str
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open('/proc/{0}/comm'.format(service_pid)) as proc:
|
||||||
|
return proc.read().replace('\0', ' ').rstrip()
|
||||||
|
except Exception as _:
|
||||||
|
return '<unknown>'
|
||||||
|
|
||||||
|
# noinspection PyBroadException
|
||||||
|
@staticmethod
|
||||||
|
def _get_commandline_of_pid(service_pid):
|
||||||
|
# type: (int) -> str
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open('/proc/{0}/cmdline'.format(service_pid)) as proc:
|
||||||
|
return proc.read().replace('\0', ' ').rstrip()
|
||||||
|
except Exception as _:
|
||||||
|
return '<unknown>'
|
Binary file not shown.
|
@ -0,0 +1,139 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
import dbus
|
||||||
|
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Any, Union, Dict
|
||||||
|
DbusString = Union[dbus.String, dbus.UTF8String, dbus.ObjectPath, dbus.Signature]
|
||||||
|
DbusInt = Union[dbus.Int16, dbus.Int32, dbus.Int64]
|
||||||
|
DbusDouble = dbus.Double
|
||||||
|
DbusBool = dbus.Boolean
|
||||||
|
|
||||||
|
DbusStringVariant = DbusString # TODO: variant_level constraint ?
|
||||||
|
DbusIntVariant = DbusInt
|
||||||
|
DbusDoubleVariant = DbusDouble
|
||||||
|
DbusBoolVariant = DbusBool
|
||||||
|
|
||||||
|
DbusValue = Union[DbusString, DbusInt, DbusDouble, DbusBool, DBUS_NONE]
|
||||||
|
DbusVariant = Union[DbusStringVariant, DbusIntVariant, DbusDoubleVariant, DbusBoolVariant, DBUS_NONE]
|
||||||
|
|
||||||
|
DbusTextDict = dbus.Dictionary
|
||||||
|
DbusVariantDict = dbus.Dictionary
|
||||||
|
|
||||||
|
DbusType = Union[DbusValue, DbusVariant, DbusVariantDict, DbusTextDict]
|
||||||
|
|
||||||
|
DBUS_NONE = dbus.Array([], signature=dbus.Signature('i'), variant_level=1) # DEFINED by victron
|
||||||
|
|
||||||
|
MAX_INT16 = 2 ** 15 - 1
|
||||||
|
MAX_INT32 = 2 ** 31 - 1
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_uint32(value):
|
||||||
|
# type: (int) -> dbus.UInt32
|
||||||
|
if value < 0:
|
||||||
|
raise Exception('cannot convert negative value to UInt32')
|
||||||
|
|
||||||
|
return dbus.UInt32(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_int(value):
|
||||||
|
# type: (Union[int, long]) -> Union[dbus.Int16, dbus.Int32, dbus.Int64]
|
||||||
|
abs_value = abs(value)
|
||||||
|
if abs_value < MAX_INT16:
|
||||||
|
return dbus.Int16(value)
|
||||||
|
elif abs_value < MAX_INT32:
|
||||||
|
return dbus.Int32(value)
|
||||||
|
else:
|
||||||
|
return dbus.Int64(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_string(value):
|
||||||
|
# type: (Union[str, unicode]) -> DbusString
|
||||||
|
if isinstance(value, unicode):
|
||||||
|
return dbus.UTF8String(value)
|
||||||
|
else:
|
||||||
|
return dbus.String(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_double(value):
|
||||||
|
# type: (float) -> DbusDouble
|
||||||
|
return dbus.Double(value)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_bool(value):
|
||||||
|
# type: (bool) -> DbusBool
|
||||||
|
return dbus.Boolean(value)
|
||||||
|
|
||||||
|
|
||||||
|
# VARIANTS
|
||||||
|
|
||||||
|
def dbus_int_variant(value):
|
||||||
|
# type: (Union[int, long]) -> DbusIntVariant
|
||||||
|
abs_value = abs(value)
|
||||||
|
if abs_value < MAX_INT16:
|
||||||
|
return dbus.Int16(value, variant_level=1)
|
||||||
|
elif abs_value < MAX_INT32:
|
||||||
|
return dbus.Int32(value, variant_level=1)
|
||||||
|
else:
|
||||||
|
return dbus.Int64(value, variant_level=1)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_string_variant(value):
|
||||||
|
# type: (Union[str, unicode]) -> DbusStringVariant
|
||||||
|
if isinstance(value, unicode):
|
||||||
|
return dbus.UTF8String(value, variant_level=1)
|
||||||
|
else:
|
||||||
|
return dbus.String(value, variant_level=1)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_double_variant(value):
|
||||||
|
# type: (float) -> DbusDoubleVariant
|
||||||
|
return dbus.Double(value, variant_level=1)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_bool_variant(value):
|
||||||
|
# type: (bool) -> DbusBoolVariant
|
||||||
|
return dbus.Boolean(value, variant_level=1)
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_variant(value):
|
||||||
|
# type: (Any) -> DbusVariant
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return DBUS_NONE
|
||||||
|
if isinstance(value, float):
|
||||||
|
return dbus_double_variant(value)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return dbus_bool_variant(value)
|
||||||
|
if isinstance(value, (int, long)):
|
||||||
|
return dbus_int_variant(value)
|
||||||
|
if isinstance(value, (str, unicode)):
|
||||||
|
return dbus_string_variant(value)
|
||||||
|
# TODO: container types
|
||||||
|
|
||||||
|
raise TypeError('unsupported python type: ' + str(type(value)) + ' ' + str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def dbus_value(value):
|
||||||
|
# type: (Any) -> DbusVariant
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return DBUS_NONE
|
||||||
|
if isinstance(value, float):
|
||||||
|
return dbus_double(value)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return dbus_bool(value)
|
||||||
|
if isinstance(value, (int, long)):
|
||||||
|
return dbus_int(value)
|
||||||
|
if isinstance(value, (str, unicode)):
|
||||||
|
return dbus_string_variant(value)
|
||||||
|
# TODO: container types
|
||||||
|
|
||||||
|
raise TypeError('unsupported python type: ' + str(type(value)) + ' ' + str(value))
|
||||||
|
|
||||||
|
|
||||||
|
|
Binary file not shown.
|
@ -0,0 +1,259 @@
|
||||||
|
from fnmatch import fnmatch as glob
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from _dbus_bindings import ErrorMessage, Message, MethodReturnMessage
|
||||||
|
from python_libs.ie_utils.mixins import Record
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import List, Optional, Iterable, AnyStr, NoReturn, Any
|
||||||
|
|
||||||
|
|
||||||
|
class MessageType(object):
|
||||||
|
|
||||||
|
invalid = 0
|
||||||
|
method_call = 1
|
||||||
|
method_return = 2
|
||||||
|
error = 3
|
||||||
|
signal = 4
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(message_type):
|
||||||
|
# type: (int) -> str
|
||||||
|
|
||||||
|
if message_type == 1:
|
||||||
|
return 'method_call'
|
||||||
|
if message_type == 2:
|
||||||
|
return 'method_return'
|
||||||
|
if message_type == 3:
|
||||||
|
return 'error'
|
||||||
|
if message_type == 4:
|
||||||
|
return 'signal'
|
||||||
|
|
||||||
|
return 'invalid'
|
||||||
|
|
||||||
|
|
||||||
|
class DBusMessage(Record):
|
||||||
|
|
||||||
|
def __init__(self, msg, sender_id, destination_id):
|
||||||
|
# type: (Message, str, str) -> NoReturn
|
||||||
|
|
||||||
|
self.sender_id = sender_id
|
||||||
|
self.destination_id = destination_id
|
||||||
|
self._msg = msg
|
||||||
|
|
||||||
|
@property
|
||||||
|
def expects_reply(self):
|
||||||
|
# type: () -> bool
|
||||||
|
return not self._msg.get_no_reply()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def message_type(self):
|
||||||
|
# type: () -> int
|
||||||
|
return int(self._msg.get_type())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reply_serial(self):
|
||||||
|
# type: () -> int
|
||||||
|
return int(self._msg.get_reply_serial())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def object_path(self):
|
||||||
|
# type: () -> str
|
||||||
|
return str(self._msg.get_path())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def interface(self):
|
||||||
|
# type: () -> str
|
||||||
|
return str(self._msg.get_interface())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def arguments(self):
|
||||||
|
# type: () -> List[Any]
|
||||||
|
return self._msg.get_args_list(utf8_strings=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def signature(self):
|
||||||
|
# type: () -> str
|
||||||
|
return str(self._msg.get_signature())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def serial(self):
|
||||||
|
# type: () -> int
|
||||||
|
return int(self._msg.get_serial())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def member(self):
|
||||||
|
# type: () -> str
|
||||||
|
return str(self._msg.get_member())
|
||||||
|
|
||||||
|
def create_method_reply(self, *args):
|
||||||
|
# type: (List[any]) -> MethodReturnMessage
|
||||||
|
|
||||||
|
if self.message_type != MessageType.method_call:
|
||||||
|
raise Exception('cannot create a reply for a message that is not a method call')
|
||||||
|
|
||||||
|
reply = MethodReturnMessage(self._msg)
|
||||||
|
|
||||||
|
for arg in args:
|
||||||
|
reply.append(arg)
|
||||||
|
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def create_error_reply(self, exception):
|
||||||
|
# type: (Exception) -> ErrorMessage
|
||||||
|
|
||||||
|
if self.message_type != MessageType.method_call:
|
||||||
|
raise Exception('cannot create an error reply for a message that is not a method call')
|
||||||
|
|
||||||
|
return ErrorMessage(self._msg, 'com.victronenergy.' + exception.__class__.__name__, exception.message) # TODO prefix
|
||||||
|
|
||||||
|
|
||||||
|
class ResolvedMessage(DBusMessage):
|
||||||
|
|
||||||
|
def __init__(self, msg, sender_id, sender_names, destination_id, destination_names):
|
||||||
|
# type: (Message, str, List[str], str, List[str]) -> NoReturn
|
||||||
|
|
||||||
|
super(ResolvedMessage, self).__init__(msg, sender_id, destination_id)
|
||||||
|
|
||||||
|
self.sender_names = sender_names
|
||||||
|
self.destination_names = destination_names
|
||||||
|
|
||||||
|
|
||||||
|
class MatchedMessage(DBusMessage):
|
||||||
|
|
||||||
|
def __init__(self, resolved_msg, sender_name, destination_name):
|
||||||
|
# type: (ResolvedMessage, str, str) -> NoReturn
|
||||||
|
|
||||||
|
super(MatchedMessage, self).__init__(resolved_msg._msg, resolved_msg.sender_id, resolved_msg.destination_id)
|
||||||
|
|
||||||
|
self.sender_name = sender_name
|
||||||
|
self.destination_name = destination_name
|
||||||
|
|
||||||
|
|
||||||
|
class MessageFilter(Record):
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_type='*',
|
||||||
|
sender_id='*',
|
||||||
|
sender_name='*',
|
||||||
|
object_path='*',
|
||||||
|
interface='*',
|
||||||
|
member='*',
|
||||||
|
signature='*',
|
||||||
|
destination_id='*',
|
||||||
|
destination_name='*',
|
||||||
|
eavesdrop=False):
|
||||||
|
|
||||||
|
# type: (Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[bool]) -> NoReturn
|
||||||
|
|
||||||
|
self.signature = signature
|
||||||
|
self.message_type = message_type
|
||||||
|
|
||||||
|
self.member = member
|
||||||
|
self.interface = interface
|
||||||
|
self.object_path = object_path
|
||||||
|
|
||||||
|
self.sender_id = sender_id
|
||||||
|
self.sender_name = sender_name
|
||||||
|
self.destination_id = destination_id
|
||||||
|
self.destination_name = destination_name
|
||||||
|
|
||||||
|
self.eavesdrop = eavesdrop
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def create_filter_rule(
|
||||||
|
message_type='*',
|
||||||
|
sender_id='*',
|
||||||
|
sender_name='*',
|
||||||
|
object_path='*',
|
||||||
|
interface='*',
|
||||||
|
member='*',
|
||||||
|
destination_id='*',
|
||||||
|
eavesdrop=False):
|
||||||
|
# type: (Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],Optional[AnyStr],bool) -> AnyStr
|
||||||
|
|
||||||
|
rules = []
|
||||||
|
|
||||||
|
def rule(key, value):
|
||||||
|
if '*' not in value and '?' not in value:
|
||||||
|
rules.append("%s='%s'" % (key, value))
|
||||||
|
|
||||||
|
rule('type', message_type)
|
||||||
|
rule('sender', sender_id if sender_name == '*' and sender_id != '*' else sender_name)
|
||||||
|
rule('destination', destination_id)
|
||||||
|
rule('eavesdrop', 'true' if eavesdrop else 'false')
|
||||||
|
rule('path', object_path) # TODO: endswith *, object namespace
|
||||||
|
rule('interface', interface)
|
||||||
|
rule('member', member)
|
||||||
|
|
||||||
|
return ','.join(rules)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filter_rule(self):
|
||||||
|
# type: () -> AnyStr
|
||||||
|
|
||||||
|
return self.create_filter_rule(
|
||||||
|
message_type=self.message_type,
|
||||||
|
sender_id=self.sender_id,
|
||||||
|
sender_name=self.sender_name,
|
||||||
|
object_path=self.object_path,
|
||||||
|
interface=self.interface,
|
||||||
|
member=self.member,
|
||||||
|
destination_id=self.destination_id,
|
||||||
|
eavesdrop=self.eavesdrop)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_matching_name(names, name_filter):
|
||||||
|
# type: (Iterable[AnyStr], AnyStr) -> Optional[AnyStr]
|
||||||
|
|
||||||
|
matching_names = (
|
||||||
|
name
|
||||||
|
for name
|
||||||
|
in names
|
||||||
|
if glob(name, name_filter)
|
||||||
|
)
|
||||||
|
|
||||||
|
return next(matching_names, None)
|
||||||
|
|
||||||
|
def match_message(self, msg):
|
||||||
|
# type: (ResolvedMessage) -> Optional[MatchedMessage]
|
||||||
|
|
||||||
|
match = \
|
||||||
|
glob(msg.object_path, self.object_path) and \
|
||||||
|
glob(msg.interface or '<none>', self.interface) and \
|
||||||
|
glob(msg.member, self.member) and \
|
||||||
|
glob(msg.signature, self.signature) and \
|
||||||
|
glob(msg.sender_id, self.sender_id) and \
|
||||||
|
glob(msg.destination_id or '<none>', self.destination_id)
|
||||||
|
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
|
||||||
|
sender_name = self._get_matching_name(msg.sender_names, self.sender_name)
|
||||||
|
if sender_name is None and self.sender_name != '*': # sender might not have a well known name
|
||||||
|
return None
|
||||||
|
|
||||||
|
destination_name = self._get_matching_name(msg.destination_names, self.destination_name)
|
||||||
|
if destination_name is None and self.destination_name != '*':
|
||||||
|
return None
|
||||||
|
|
||||||
|
return MatchedMessage(msg, sender_name, destination_name)
|
||||||
|
|
||||||
|
|
||||||
|
class DBusException(Exception):
|
||||||
|
|
||||||
|
def __init__(self, message):
|
||||||
|
super(Exception, self).__init__(message)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def raise_if_error_reply(cls, reply):
|
||||||
|
# type: (Message) -> Message
|
||||||
|
|
||||||
|
if isinstance(reply, ErrorMessage):
|
||||||
|
raise DBusException(reply.get_error_name())
|
||||||
|
else:
|
||||||
|
return reply
|
Binary file not shown.
|
@ -0,0 +1,177 @@
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
import dbus
|
||||||
|
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import dbus_variant, dbus_string
|
||||||
|
from python_libs.ie_dbus.private.dbus_daemon import DBusDaemon
|
||||||
|
from python_libs.ie_dbus.private.message_types import MatchedMessage
|
||||||
|
from python_libs.ie_dbus.private.ve_constants import GET_TEXT, INTERFACE_BUS_ITEM, PROPERTIES_CHANGED, GET_VALUE, SET_VALUE
|
||||||
|
from python_libs.ie_utils.mixins import Disposable, Record
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Optional, AnyStr, NoReturn, Dict, Any
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import DbusVariant, DbusString, DbusVariantDict, DbusType
|
||||||
|
|
||||||
|
|
||||||
|
class OwnProperty(Record):
|
||||||
|
|
||||||
|
def __init__(self, value, unit='', writable=False):
|
||||||
|
|
||||||
|
str_value = round(value, 2) if isinstance(value, float) else value
|
||||||
|
|
||||||
|
self.text = unicode(str_value) + unit
|
||||||
|
self.value = value
|
||||||
|
self.unit = unit
|
||||||
|
self.writable = writable
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dbus_dict(self):
|
||||||
|
# type: () -> dbus.Dictionary
|
||||||
|
d = {
|
||||||
|
dbus.String('Text'): dbus_variant(self.text),
|
||||||
|
dbus.String('Value'): dbus_variant(self.value)
|
||||||
|
}
|
||||||
|
return dbus.Dictionary(d, signature='sv')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dbus_value(self):
|
||||||
|
# type: () -> DbusVariant
|
||||||
|
return dbus_variant(self.value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dbus_text(self):
|
||||||
|
# type: () -> DbusString
|
||||||
|
return dbus_string(self.text)
|
||||||
|
|
||||||
|
def update_value(self, value):
|
||||||
|
# type: (any) -> OwnProperty
|
||||||
|
return OwnProperty(value, self.unit, self.writable)
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
yield self.value
|
||||||
|
yield self.text
|
||||||
|
|
||||||
|
|
||||||
|
class OwnProperties(Disposable):
|
||||||
|
|
||||||
|
_own_properties = None # type: Dict[AnyStr, OwnProperty]
|
||||||
|
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
def __init__(self, daemon):
|
||||||
|
# type: (DBusDaemon) -> NoReturn
|
||||||
|
|
||||||
|
self._daemon = daemon
|
||||||
|
self._own_properties = dict()
|
||||||
|
self._method_call_subs = self._daemon.subscribe_to_method_call_message(self._on_method_called) # no filter whatsoever
|
||||||
|
|
||||||
|
def get(self, object_path):
|
||||||
|
# type: (AnyStr) -> OwnProperty
|
||||||
|
return self._own_properties[object_path]
|
||||||
|
|
||||||
|
def set(self, object_path, value, unit='', writable=False):
|
||||||
|
# type: (AnyStr, any, Optional[AnyStr], Optional[bool]) -> bool
|
||||||
|
|
||||||
|
prop = OwnProperty(value, unit, writable)
|
||||||
|
|
||||||
|
if object_path in self._own_properties:
|
||||||
|
if self._own_properties[object_path] == prop:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._own_properties[object_path] = prop
|
||||||
|
# object_path, interface, member, *args):
|
||||||
|
self._daemon.broadcast_signal(
|
||||||
|
object_path,
|
||||||
|
INTERFACE_BUS_ITEM,
|
||||||
|
PROPERTIES_CHANGED,
|
||||||
|
prop.dbus_dict)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_method_called(self, message):
|
||||||
|
# type: (MatchedMessage) -> Any
|
||||||
|
|
||||||
|
# _log.info(str(message.sender_name) + '(' + str(message.sender_id) + ') asked ' + message.member + ' ' + message.object_path)
|
||||||
|
|
||||||
|
if message.member == GET_VALUE:
|
||||||
|
return self._on_get_value_called(message)
|
||||||
|
elif message.member == GET_TEXT:
|
||||||
|
return self._on_get_text_called(message)
|
||||||
|
elif message.member == SET_VALUE:
|
||||||
|
return self._on_set_value_called(message)
|
||||||
|
|
||||||
|
def _on_set_value_called(self, message):
|
||||||
|
# type: (MatchedMessage) -> bool
|
||||||
|
|
||||||
|
path = message.object_path
|
||||||
|
|
||||||
|
if path not in self._own_properties:
|
||||||
|
raise Exception('property ' + path + ' does not exist')
|
||||||
|
|
||||||
|
prop = self._own_properties[path]
|
||||||
|
if not prop.writable:
|
||||||
|
raise Exception('property ' + path + ' is read-only')
|
||||||
|
|
||||||
|
value = message.arguments[0]
|
||||||
|
|
||||||
|
if prop.value == value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
prop = prop.update_value(value)
|
||||||
|
self._own_properties[path] = prop
|
||||||
|
|
||||||
|
# object_path, interface, member, *args):
|
||||||
|
self._daemon.broadcast_signal(
|
||||||
|
path,
|
||||||
|
INTERFACE_BUS_ITEM,
|
||||||
|
PROPERTIES_CHANGED,
|
||||||
|
prop.dbus_dict)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_get_value_called(self, message):
|
||||||
|
# type: (MatchedMessage) -> DbusType
|
||||||
|
|
||||||
|
path = message.object_path
|
||||||
|
|
||||||
|
if path in self._own_properties:
|
||||||
|
return self._own_properties[path].dbus_value
|
||||||
|
|
||||||
|
if path.endswith('/'): # "Tree Export"
|
||||||
|
values = {
|
||||||
|
dbus.String(k.lstrip('/')): dbus_variant(p.value)
|
||||||
|
for (k, p)
|
||||||
|
in self._own_properties.iteritems()
|
||||||
|
if k.startswith(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dbus.Dictionary(values, signature='sv', variant_level=1) # variant for tree export !!
|
||||||
|
|
||||||
|
raise Exception('property ' + path + ' does not exist')
|
||||||
|
|
||||||
|
def _on_get_text_called(self, message):
|
||||||
|
# type: (MatchedMessage) -> DbusType
|
||||||
|
|
||||||
|
path = message.object_path
|
||||||
|
|
||||||
|
if path in self._own_properties:
|
||||||
|
return self._own_properties[message.object_path].dbus_text
|
||||||
|
|
||||||
|
if path.endswith('/'): # "Tree Export"
|
||||||
|
values = {
|
||||||
|
dbus.String(k.lstrip('/')): dbus.String(p.text)
|
||||||
|
for (k, p)
|
||||||
|
in self._own_properties.iteritems()
|
||||||
|
if k.startswith(path)
|
||||||
|
}
|
||||||
|
return dbus.Dictionary(values, signature='ss', variant_level=1) # variant for tree export !!
|
||||||
|
|
||||||
|
raise Exception('property ' + path + ' does not exist')
|
||||||
|
|
||||||
|
def __contains__(self, object_path):
|
||||||
|
# type: (AnyStr) -> bool
|
||||||
|
return object_path in self._own_properties
|
Binary file not shown.
|
@ -0,0 +1,166 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import dbus_variant
|
||||||
|
from python_libs.ie_utils.mixins import Disposable, Record
|
||||||
|
from python_libs.ie_dbus.private.dbus_daemon import DBusDaemon
|
||||||
|
from python_libs.ie_dbus.private.message_types import MatchedMessage
|
||||||
|
from python_libs.ie_dbus.private.ve_constants import GET_TEXT, INTERFACE_BUS_ITEM, PROPERTIES_CHANGED, GET_VALUE, SERVICE_PREFIX, SET_VALUE
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
_UNKNOWN_TEXT = '<UNKNOWN_TEXT>'
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import List, AnyStr, NoReturn, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteProperty(Record):
|
||||||
|
|
||||||
|
def __init__(self, value, text):
|
||||||
|
|
||||||
|
self.text = text
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dbus_dict(dbus_dict):
|
||||||
|
value = dbus_dict['Value']
|
||||||
|
text = dbus_dict['Text']
|
||||||
|
return RemoteProperty(value, text)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteProperties(Disposable):
|
||||||
|
|
||||||
|
_remote_properties = None # type: Dict[AnyStr, RemoteProperty]
|
||||||
|
|
||||||
|
def __init__(self, daemon):
|
||||||
|
# type: (DBusDaemon) -> NoReturn
|
||||||
|
|
||||||
|
self._daemon = daemon
|
||||||
|
self._remote_properties = dict()
|
||||||
|
|
||||||
|
# noinspection PyBroadException
|
||||||
|
def available_properties(self, service_name):
|
||||||
|
# type: (unicode) -> List[unicode]
|
||||||
|
|
||||||
|
if not self._daemon.exists_service_with_name(service_name):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
paths = self._call_remote(service_name=service_name, object_path='/', member=GET_TEXT)[0].keys()
|
||||||
|
except Exception as _:
|
||||||
|
return []
|
||||||
|
else:
|
||||||
|
return ['/' + str(path) for path in paths]
|
||||||
|
|
||||||
|
def exists(self, combined_path):
|
||||||
|
# type: (AnyStr) -> bool
|
||||||
|
|
||||||
|
service_name, object_path, combined_path = self._parse_combined_path(combined_path)
|
||||||
|
return object_path in self.available_properties(service_name)
|
||||||
|
|
||||||
|
def get(self, combined_path):
|
||||||
|
# type: (AnyStr) -> RemoteProperty
|
||||||
|
|
||||||
|
service_name, object_path, combined_path = self._parse_combined_path(combined_path)
|
||||||
|
|
||||||
|
if combined_path in self._remote_properties:
|
||||||
|
cached = self._remote_properties[combined_path]
|
||||||
|
|
||||||
|
# a cached prop might have an unknown text, because its value has been written before,
|
||||||
|
# but it has never read or updated via property-changed
|
||||||
|
|
||||||
|
if cached.text != _UNKNOWN_TEXT:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
text = self._get_text(service_name, object_path)
|
||||||
|
self._remote_properties[combined_path] = RemoteProperty(cached.value, text)
|
||||||
|
|
||||||
|
return self._remote_properties[combined_path]
|
||||||
|
|
||||||
|
prop = self._get_property(service_name, object_path)
|
||||||
|
self._remote_properties[combined_path] = prop
|
||||||
|
self._subscribe_to_property_changed(service_name, object_path)
|
||||||
|
|
||||||
|
return prop
|
||||||
|
|
||||||
|
def set(self, combined_path, value):
|
||||||
|
# type: (AnyStr, any) -> bool
|
||||||
|
|
||||||
|
service_name, object_path, combined_path = self._parse_combined_path(combined_path)
|
||||||
|
|
||||||
|
if combined_path in self._remote_properties:
|
||||||
|
if self._remote_properties[combined_path].value == value:
|
||||||
|
return False # property already has the requested value => nothing to do
|
||||||
|
else:
|
||||||
|
self._subscribe_to_property_changed(service_name, object_path)
|
||||||
|
|
||||||
|
result = self._call_remote(service_name, object_path, SET_VALUE, dbus_variant(value))[0]
|
||||||
|
|
||||||
|
if result != 0:
|
||||||
|
raise Exception(service_name + ' refused to set value of ' + object_path + ' to ' + str(value))
|
||||||
|
|
||||||
|
self._remote_properties[combined_path] = RemoteProperty(value, _UNKNOWN_TEXT)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _subscribe_to_property_changed(self, service_name, object_path):
|
||||||
|
# type: (unicode, unicode) -> NoReturn
|
||||||
|
|
||||||
|
def callback(msg):
|
||||||
|
# type: (MatchedMessage) -> NoReturn
|
||||||
|
prop = RemoteProperty.from_dbus_dict(msg.arguments[0])
|
||||||
|
key = msg.sender_name+msg.object_path
|
||||||
|
self._remote_properties[key] = prop
|
||||||
|
|
||||||
|
signal = self._daemon.subscribe_to_signal_message(
|
||||||
|
callback=callback,
|
||||||
|
sender_name=service_name,
|
||||||
|
object_path=object_path,
|
||||||
|
interface=INTERFACE_BUS_ITEM, # TODO: <- this could be removed to make it more robust, in theory
|
||||||
|
member=PROPERTIES_CHANGED) # TODO: OTOH, don't fix if it is not broken
|
||||||
|
|
||||||
|
self.chain_disposable(signal, 'signal subscription on ' + self._daemon.bus_id + ' ' + service_name + object_path)
|
||||||
|
|
||||||
|
def _get_value(self, service_name, object_path):
|
||||||
|
# type: (unicode, unicode) -> any
|
||||||
|
|
||||||
|
return self._call_remote(service_name, object_path, GET_VALUE)[0]
|
||||||
|
|
||||||
|
def _get_text(self, service_name, object_path):
|
||||||
|
# type: (unicode, unicode) -> unicode
|
||||||
|
|
||||||
|
result = self._call_remote(service_name, object_path, GET_TEXT)[0]
|
||||||
|
return unicode(result)
|
||||||
|
|
||||||
|
def _get_property(self, service_name, object_path):
|
||||||
|
# type: (unicode, unicode) -> RemoteProperty
|
||||||
|
|
||||||
|
value = self._get_value(service_name, object_path)
|
||||||
|
text = self._get_text(service_name, object_path)
|
||||||
|
|
||||||
|
return RemoteProperty(value, text)
|
||||||
|
|
||||||
|
def _call_remote(self, service_name, object_path, member, *args):
|
||||||
|
# type: (unicode, unicode, unicode, List[Any]) -> List[Any]
|
||||||
|
|
||||||
|
return self._daemon.call_method(service_name, object_path, INTERFACE_BUS_ITEM, member, *args)
|
||||||
|
|
||||||
|
def _parse_combined_path(self, combined_path):
|
||||||
|
# type: (str) -> (unicode,unicode,unicode)
|
||||||
|
|
||||||
|
service_name, object_path = combined_path.lstrip('/').split('/', 1)
|
||||||
|
|
||||||
|
if service_name == '':
|
||||||
|
raise Exception('Failed to parse service name. \ncombined_path must be of the form "service_name/path/to/property"')
|
||||||
|
if object_path == '':
|
||||||
|
raise Exception('Failed to parse object path. \ncombined_path must be of the form "service_name/path/to/property"')
|
||||||
|
|
||||||
|
service_name = service_name if service_name.startswith(SERVICE_PREFIX) else SERVICE_PREFIX + service_name
|
||||||
|
|
||||||
|
if not self._daemon.exists_service_with_name(service_name):
|
||||||
|
raise Exception('there is no service with the name "' + service_name + '" on the bus')
|
||||||
|
|
||||||
|
object_path = '/' + object_path
|
||||||
|
|
||||||
|
return unicode(service_name), unicode(object_path), unicode(service_name + object_path)
|
Binary file not shown.
|
@ -0,0 +1,89 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from python_libs.ie_dbus.private.dbus_types import dbus_string, dbus_int_variant, dbus_string_variant, dbus_double_variant, dbus_variant
|
||||||
|
from python_libs.ie_utils.mixins import Record
|
||||||
|
from python_libs.ie_dbus.private.dbus_daemon import DBusDaemon
|
||||||
|
from python_libs.ie_dbus.private.remote_properties import RemoteProperties
|
||||||
|
from python_libs.ie_dbus.private.ve_constants import SETTINGS_SERVICE, SETTINGS_INTERFACE, SETTINGS_PREFIX
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Union, NoReturn, Optional, AnyStr
|
||||||
|
|
||||||
|
|
||||||
|
def prepend_settings_prefix(path):
|
||||||
|
# type: (AnyStr) -> any
|
||||||
|
|
||||||
|
path = '/' + path.lstrip('/')
|
||||||
|
path = path if path.startswith(SETTINGS_PREFIX) else SETTINGS_PREFIX + path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(Record):
|
||||||
|
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
def __init__(self, daemon, remote_properties):
|
||||||
|
# type: (DBusDaemon, RemoteProperties) -> NoReturn
|
||||||
|
|
||||||
|
self._daemon = daemon
|
||||||
|
self._remote_properties = remote_properties
|
||||||
|
|
||||||
|
# noinspection PyShadowingBuiltins
|
||||||
|
|
||||||
|
def add_setting(self, path, default_value, min=None, max=None, silent=False):
|
||||||
|
# type: (AnyStr, Union[unicode, int, float], Union[int, float, None], Union[int, float, None], Optional[bool]) -> NoReturn
|
||||||
|
|
||||||
|
path = prepend_settings_prefix(path)
|
||||||
|
|
||||||
|
if isinstance(default_value, int):
|
||||||
|
item_type = 'i'
|
||||||
|
elif isinstance(default_value, float):
|
||||||
|
item_type = 'f'
|
||||||
|
elif isinstance(default_value, (str, unicode)):
|
||||||
|
item_type = 's'
|
||||||
|
else:
|
||||||
|
raise Exception('Unsupported Settings Type')
|
||||||
|
|
||||||
|
reply = self._daemon.call_method(
|
||||||
|
SETTINGS_SERVICE, # service_name
|
||||||
|
'/', # object_path
|
||||||
|
SETTINGS_INTERFACE, # interface
|
||||||
|
'AddSilentSetting' if silent else 'AddSetting', # member,
|
||||||
|
dbus_string(''), # "group", not used
|
||||||
|
dbus_string(path),
|
||||||
|
dbus_variant(default_value),
|
||||||
|
dbus_string(item_type),
|
||||||
|
dbus_int_variant(min or 0),
|
||||||
|
dbus_int_variant(max or 0))
|
||||||
|
|
||||||
|
if reply[0] != 0:
|
||||||
|
raise Exception('failed to add setting ' + path)
|
||||||
|
|
||||||
|
def exists(self, path):
|
||||||
|
# type: (unicode) -> bool
|
||||||
|
|
||||||
|
path = prepend_settings_prefix(path)
|
||||||
|
return path in self.available_settings
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
# type: (unicode) -> Union[unicode, int, float]
|
||||||
|
|
||||||
|
path = prepend_settings_prefix(path)
|
||||||
|
return self._remote_properties.get(SETTINGS_SERVICE + path).value
|
||||||
|
|
||||||
|
def set(self, path, value):
|
||||||
|
# type: (unicode, Union[unicode, int, float]) -> NoReturn
|
||||||
|
|
||||||
|
path = prepend_settings_prefix(path)
|
||||||
|
self._remote_properties.set(SETTINGS_SERVICE + path, value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_settings(self):
|
||||||
|
# type: () -> [unicode]
|
||||||
|
return self._remote_properties.available_properties(SETTINGS_SERVICE)
|
||||||
|
|
||||||
|
def __contains__(self, path):
|
||||||
|
# type: (unicode) -> bool
|
||||||
|
return self.exists(path)
|
Binary file not shown.
|
@ -0,0 +1,11 @@
|
||||||
|
|
||||||
|
SERVICE_PREFIX = 'com.victronenergy.'
|
||||||
|
VE_SERVICE_FILTER = SERVICE_PREFIX + '*'
|
||||||
|
INTERFACE_BUS_ITEM = SERVICE_PREFIX + 'BusItem'
|
||||||
|
PROPERTIES_CHANGED = 'PropertiesChanged'
|
||||||
|
GET_VALUE = 'GetValue'
|
||||||
|
SET_VALUE = 'SetValue'
|
||||||
|
GET_TEXT = 'GetText'
|
||||||
|
SETTINGS_SERVICE = 'com.victronenergy.settings'
|
||||||
|
SETTINGS_INTERFACE = 'com.victronenergy.Settings'
|
||||||
|
SETTINGS_PREFIX = '/Settings'
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,73 @@
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import NoReturn, Optional
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MovingAverageFilter(object):
|
||||||
|
|
||||||
|
def __init__(self, length=30, initial_value=0):
|
||||||
|
# type: (int, float) -> NoReturn
|
||||||
|
|
||||||
|
self.value = initial_value
|
||||||
|
self.length = length
|
||||||
|
|
||||||
|
def update(self, value, length=None):
|
||||||
|
# type: (float, int) -> float
|
||||||
|
|
||||||
|
if length is not None:
|
||||||
|
self.length = length
|
||||||
|
|
||||||
|
self.value = (self.value * self.length + value) / (self.length + 1)
|
||||||
|
|
||||||
|
_log.debug('real value: ' + str(value) + ', filtered value: ' + str(self.value))
|
||||||
|
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class DebounceFilter(object):
|
||||||
|
|
||||||
|
def __init__(self, initial_state=None, max_inertia=10):
|
||||||
|
# type: (Optional[bool], Optional[int]) -> NoReturn
|
||||||
|
|
||||||
|
self._max_inertia = max_inertia
|
||||||
|
self._inertia = max_inertia
|
||||||
|
self._state = initial_state
|
||||||
|
|
||||||
|
def reset(self, state=None, max_inertia=None):
|
||||||
|
# type: (Optional[bool], Optional[int]) -> bool
|
||||||
|
|
||||||
|
self._max_inertia = max_inertia or self._max_inertia
|
||||||
|
self._inertia = self._max_inertia
|
||||||
|
self._state = state or self._state
|
||||||
|
|
||||||
|
_log.debug('debounce filter reset: state={0}, inertia={1}'.format(self._state, self._inertia))
|
||||||
|
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def flip(self):
|
||||||
|
# type: () -> bool
|
||||||
|
self._state = not self._state
|
||||||
|
self._inertia = self._max_inertia
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
def update(self, new_state, max_inertia=None):
|
||||||
|
# type: (bool, int) -> bool
|
||||||
|
|
||||||
|
if max_inertia is not None and max_inertia != self._max_inertia:
|
||||||
|
return self.reset(new_state, max_inertia)
|
||||||
|
|
||||||
|
if new_state != self._state:
|
||||||
|
if self._inertia > 0:
|
||||||
|
self._inertia = self._inertia - 1
|
||||||
|
else:
|
||||||
|
self.flip()
|
||||||
|
else:
|
||||||
|
self._inertia = min(self._inertia + 1, self._max_inertia)
|
||||||
|
|
||||||
|
_log.debug('debounce filter update: state={0}, inertia={1}'.format(self._state, self._inertia))
|
||||||
|
|
||||||
|
return self._state
|
Binary file not shown.
|
@ -0,0 +1,30 @@
|
||||||
|
from logging import getLogger
|
||||||
|
import traceback
|
||||||
|
import gobject
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable, NoReturn
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_on_main_loop(update_action, update_period):
|
||||||
|
# type: (Callable[[],NoReturn], int) -> NoReturn
|
||||||
|
|
||||||
|
main_loop = gobject.MainLoop()
|
||||||
|
|
||||||
|
def update(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
update_action()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
_log.error(e.message)
|
||||||
|
traceback.print_exc()
|
||||||
|
main_loop.quit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
gobject.timeout_add(update_period, update)
|
||||||
|
main_loop.run()
|
Binary file not shown.
|
@ -0,0 +1,115 @@
|
||||||
|
from logging import getLogger
|
||||||
|
from _dbus_glib_bindings import DBusGMainLoop
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable, NoReturn, List, AnyStr, Optional, Union
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def nop(*_args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def memoize(fn):
|
||||||
|
|
||||||
|
attr_name = '_memoized_' + fn.__name__
|
||||||
|
|
||||||
|
def _memoized(self):
|
||||||
|
if not hasattr(self, attr_name):
|
||||||
|
setattr(self, attr_name, fn(self))
|
||||||
|
return getattr(self, attr_name)
|
||||||
|
|
||||||
|
return _memoized
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyAttributeOutsideInit
|
||||||
|
class Disposable(object):
|
||||||
|
|
||||||
|
_dispose_actions = None # type: List[Callable[[],NoReturn]]
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, typ, value, tb):
|
||||||
|
self.dispose()
|
||||||
|
|
||||||
|
def dispose(self):
|
||||||
|
# type: () -> NoReturn
|
||||||
|
|
||||||
|
while self._dispose_actions:
|
||||||
|
dispose = self._dispose_actions.pop()
|
||||||
|
dispose()
|
||||||
|
|
||||||
|
for k, v in self.__dict__.iteritems():
|
||||||
|
if isinstance(v, Disposable) and v._dispose_actions:
|
||||||
|
_log.debug('disposing ' + type(self).__name__ + '.' + k)
|
||||||
|
v.dispose()
|
||||||
|
|
||||||
|
def chain_disposable(self, dispose, message=None):
|
||||||
|
# type: (Union[Callable[[],None],Disposable], Optional[AnyStr]) -> NoReturn
|
||||||
|
|
||||||
|
if self._dispose_actions is None:
|
||||||
|
self._dispose_actions = []
|
||||||
|
|
||||||
|
if isinstance(dispose, Disposable):
|
||||||
|
dispose = dispose.dispose
|
||||||
|
|
||||||
|
if message is None:
|
||||||
|
self._dispose_actions.append(dispose)
|
||||||
|
return
|
||||||
|
|
||||||
|
def dispose_with_log_msg():
|
||||||
|
_log.debug('disposing ' + message)
|
||||||
|
dispose()
|
||||||
|
|
||||||
|
# _log.debug('new disposable ' + message)
|
||||||
|
self._dispose_actions.append(dispose_with_log_msg)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, dispose_action, message=None):
|
||||||
|
# type: (Union[Callable[[],None],Disposable], Optional[AnyStr]) -> Disposable
|
||||||
|
|
||||||
|
disposable = Disposable()
|
||||||
|
disposable.chain_disposable(dispose_action, message)
|
||||||
|
return disposable
|
||||||
|
|
||||||
|
def create_dependent_disposable(self, dispose_action, message=None):
|
||||||
|
# type: (Union[Callable[[],None],Disposable], Optional[AnyStr]) -> Disposable
|
||||||
|
|
||||||
|
disposable = Disposable.create(dispose_action, message)
|
||||||
|
self.chain_disposable(disposable)
|
||||||
|
return disposable
|
||||||
|
|
||||||
|
|
||||||
|
class Record(object):
|
||||||
|
|
||||||
|
@memoize
|
||||||
|
def __str__(self):
|
||||||
|
return self.__class__.__name__ + ' ' + unicode(vars(self))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.__str__()
|
||||||
|
|
||||||
|
@memoize
|
||||||
|
def __hash__(self):
|
||||||
|
return self.__str__().__hash__()
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
# TODO: improve, iterable vars are not correctly handled
|
||||||
|
return str(other) == str(self)
|
||||||
|
|
||||||
|
# make readonly
|
||||||
|
def __setattr__(self, key, value):
|
||||||
|
# type: (str, any) -> NoReturn
|
||||||
|
|
||||||
|
if not key.startswith('_') and hasattr(self, key): # disallow redefining
|
||||||
|
raise ValueError(key + ' is read-only' + str(dir()))
|
||||||
|
|
||||||
|
super(Record, self).__setattr__(key, value)
|
||||||
|
|
||||||
|
|
||||||
|
class RequiresMainLoop(object):
|
||||||
|
|
||||||
|
main_loop = DBusGMainLoop(set_as_default=True) # initialized only once for all subclasses that need it
|
Binary file not shown.
|
@ -0,0 +1,44 @@
|
||||||
|
from logging import getLogger
|
||||||
|
import re
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
_log = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def make2way(dic):
|
||||||
|
# type: (Dict) -> Dict
|
||||||
|
for k, v in dic.items():
|
||||||
|
dic[v] = k
|
||||||
|
|
||||||
|
return dic
|
||||||
|
|
||||||
|
|
||||||
|
def invert_dict(src_dic):
|
||||||
|
# type: (Dict) -> Dict
|
||||||
|
dic = dict()
|
||||||
|
|
||||||
|
for k, v in src_dic.items():
|
||||||
|
dic[v] = k
|
||||||
|
|
||||||
|
return dic
|
||||||
|
|
||||||
|
|
||||||
|
def enum_file_name_of(path):
|
||||||
|
# type: (str) -> Dict[int,str]
|
||||||
|
|
||||||
|
"""
|
||||||
|
This is kinda hacky, but it works :)
|
||||||
|
The enum file must contain a single enum however!
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = path[0:-1] if path.endswith('.pyc') else path
|
||||||
|
pattern = re.compile(r"^\s*(\w+)\s*=\s*(\d+)", re.M)
|
||||||
|
with open(path, "r") as f:
|
||||||
|
return {
|
||||||
|
int(m[1]): m[0]
|
||||||
|
for m
|
||||||
|
in pattern.findall(f.read())
|
||||||
|
}
|
Binary file not shown.
|
@ -0,0 +1,30 @@
|
||||||
|
# Copyright 2019 Ram Rachum and collaborators.
|
||||||
|
# This program is distributed under the MIT license.
|
||||||
|
'''
|
||||||
|
PySnooper - Never use print for debugging again
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
import pysnooper
|
||||||
|
|
||||||
|
@pysnooper.snoop()
|
||||||
|
def your_function(x):
|
||||||
|
...
|
||||||
|
|
||||||
|
A log will be written to stderr showing the lines executed and variables
|
||||||
|
changed in the decorated function.
|
||||||
|
|
||||||
|
For more information, see https://github.com/cool-RR/PySnooper
|
||||||
|
'''
|
||||||
|
|
||||||
|
from .tracer import Tracer as snoop
|
||||||
|
from .variables import Attrs, Exploding, Indices, Keys
|
||||||
|
import collections
|
||||||
|
|
||||||
|
__VersionInfo = collections.namedtuple('VersionInfo',
|
||||||
|
('major', 'minor', 'micro'))
|
||||||
|
|
||||||
|
__version__ = '0.4.0'
|
||||||
|
__version_info__ = __VersionInfo(*(map(int, __version__.split('.'))))
|
||||||
|
|
||||||
|
del collections, __VersionInfo # Avoid polluting the namespace
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Copyright 2019 Ram Rachum and collaborators.
|
||||||
|
# This program is distributed under the MIT license.
|
||||||
|
'''Python 2/3 compatibility'''
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import os
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
import datetime as datetime_module
|
||||||
|
|
||||||
|
PY3 = (sys.version_info[0] == 3)
|
||||||
|
PY2 = not PY3
|
||||||
|
|
||||||
|
if hasattr(abc, 'ABC'):
|
||||||
|
ABC = abc.ABC
|
||||||
|
else:
|
||||||
|
class ABC(object):
|
||||||
|
"""Helper class that provides a standard way to create an ABC using
|
||||||
|
inheritance.
|
||||||
|
"""
|
||||||
|
__metaclass__ = abc.ABCMeta
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
||||||
|
if hasattr(os, 'PathLike'):
|
||||||
|
PathLike = os.PathLike
|
||||||
|
else:
|
||||||
|
class PathLike(ABC):
|
||||||
|
"""Abstract base class for implementing the file system path protocol."""
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def __fspath__(self):
|
||||||
|
"""Return the file system path representation of the object."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __subclasshook__(cls, subclass):
|
||||||
|
return (
|
||||||
|
hasattr(subclass, '__fspath__') or
|
||||||
|
# Make a concession for older `pathlib` versions:g
|
||||||
|
(hasattr(subclass, 'open') and
|
||||||
|
'path' in subclass.__name__.lower())
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
iscoroutinefunction = inspect.iscoroutinefunction
|
||||||
|
except AttributeError:
|
||||||
|
iscoroutinefunction = lambda whatever: False # Lolz
|
||||||
|
|
||||||
|
try:
|
||||||
|
isasyncgenfunction = inspect.isasyncgenfunction
|
||||||
|
except AttributeError:
|
||||||
|
isasyncgenfunction = lambda whatever: False # Lolz
|
||||||
|
|
||||||
|
|
||||||
|
if PY3:
|
||||||
|
string_types = (str,)
|
||||||
|
text_type = str
|
||||||
|
else:
|
||||||
|
string_types = (basestring,)
|
||||||
|
text_type = unicode
|
||||||
|
|
||||||
|
try:
|
||||||
|
from collections import abc as collections_abc
|
||||||
|
except ImportError: # Python 2.7
|
||||||
|
import collections as collections_abc
|
||||||
|
|
||||||
|
if sys.version_info[:2] >= (3, 6):
|
||||||
|
time_isoformat = datetime_module.time.isoformat
|
||||||
|
else:
|
||||||
|
def time_isoformat(time, timespec='microseconds'):
|
||||||
|
assert isinstance(time, datetime_module.time)
|
||||||
|
if timespec != 'microseconds':
|
||||||
|
raise NotImplementedError
|
||||||
|
result = '{:02d}:{:02d}:{:02d}.{:06d}'.format(
|
||||||
|
time.hour, time.minute, time.second, time.microsecond
|
||||||
|
)
|
||||||
|
assert len(result) == 15
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def timedelta_format(timedelta):
|
||||||
|
time = (datetime_module.datetime.min + timedelta).time()
|
||||||
|
return time_isoformat(time, timespec='microseconds')
|
||||||
|
|
||||||
|
def timedelta_parse(s):
|
||||||
|
hours, minutes, seconds, microseconds = map(
|
||||||
|
int,
|
||||||
|
s.replace('.', ':').split(':')
|
||||||
|
)
|
||||||
|
return datetime_module.timedelta(hours=hours, minutes=minutes,
|
||||||
|
seconds=seconds,
|
||||||
|
microseconds=microseconds)
|
||||||
|
|
|
@ -0,0 +1,498 @@
|
||||||
|
# Copyright 2019 Ram Rachum and collaborators.
|
||||||
|
# This program is distributed under the MIT license.
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import inspect
|
||||||
|
import opcode
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import collections
|
||||||
|
import datetime as datetime_module
|
||||||
|
import itertools
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from .variables import CommonVariable, Exploding, BaseVariable
|
||||||
|
from . import utils, pycompat
|
||||||
|
if pycompat.PY2:
|
||||||
|
from io import open
|
||||||
|
|
||||||
|
|
||||||
|
ipython_filename_pattern = re.compile('^<ipython-input-([0-9]+)-.*>$')
|
||||||
|
|
||||||
|
|
||||||
|
def get_local_reprs(frame, watch=(), custom_repr=(), max_length=None, normalize=False):
|
||||||
|
code = frame.f_code
|
||||||
|
vars_order = (code.co_varnames + code.co_cellvars + code.co_freevars +
|
||||||
|
tuple(frame.f_locals.keys()))
|
||||||
|
|
||||||
|
result_items = [(key, utils.get_shortish_repr(value, custom_repr,
|
||||||
|
max_length, normalize))
|
||||||
|
for key, value in frame.f_locals.items()]
|
||||||
|
result_items.sort(key=lambda key_value: vars_order.index(key_value[0]))
|
||||||
|
result = collections.OrderedDict(result_items)
|
||||||
|
|
||||||
|
for variable in watch:
|
||||||
|
result.update(sorted(variable.items(frame, normalize)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class UnavailableSource(object):
|
||||||
|
def __getitem__(self, i):
|
||||||
|
return u'SOURCE IS UNAVAILABLE'
|
||||||
|
|
||||||
|
|
||||||
|
source_and_path_cache = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_path_and_source_from_frame(frame):
|
||||||
|
globs = frame.f_globals or {}
|
||||||
|
module_name = globs.get('__name__')
|
||||||
|
file_name = frame.f_code.co_filename
|
||||||
|
cache_key = (module_name, file_name)
|
||||||
|
try:
|
||||||
|
return source_and_path_cache[cache_key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
loader = globs.get('__loader__')
|
||||||
|
|
||||||
|
source = None
|
||||||
|
if hasattr(loader, 'get_source'):
|
||||||
|
try:
|
||||||
|
source = loader.get_source(module_name)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
if source is not None:
|
||||||
|
source = source.splitlines()
|
||||||
|
if source is None:
|
||||||
|
ipython_filename_match = ipython_filename_pattern.match(file_name)
|
||||||
|
if ipython_filename_match:
|
||||||
|
entry_number = int(ipython_filename_match.group(1))
|
||||||
|
try:
|
||||||
|
import IPython
|
||||||
|
ipython_shell = IPython.get_ipython()
|
||||||
|
((_, _, source_chunk),) = ipython_shell.history_manager. \
|
||||||
|
get_range(0, entry_number, entry_number + 1)
|
||||||
|
source = source_chunk.splitlines()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(file_name, 'rb') as fp:
|
||||||
|
source = fp.read().splitlines()
|
||||||
|
except utils.file_reading_errors:
|
||||||
|
pass
|
||||||
|
if not source:
|
||||||
|
# We used to check `if source is None` but I found a rare bug where it
|
||||||
|
# was empty, but not `None`, so now we check `if not source`.
|
||||||
|
source = UnavailableSource()
|
||||||
|
|
||||||
|
# If we just read the source from a file, or if the loader did not
|
||||||
|
# apply tokenize.detect_encoding to decode the source into a
|
||||||
|
# string, then we should do that ourselves.
|
||||||
|
if isinstance(source[0], bytes):
|
||||||
|
encoding = 'utf-8'
|
||||||
|
for line in source[:2]:
|
||||||
|
# File coding may be specified. Match pattern from PEP-263
|
||||||
|
# (https://www.python.org/dev/peps/pep-0263/)
|
||||||
|
match = re.search(br'coding[:=]\s*([-\w.]+)', line)
|
||||||
|
if match:
|
||||||
|
encoding = match.group(1).decode('ascii')
|
||||||
|
break
|
||||||
|
source = [pycompat.text_type(sline, encoding, 'replace') for sline in
|
||||||
|
source]
|
||||||
|
|
||||||
|
result = (file_name, source)
|
||||||
|
source_and_path_cache[cache_key] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_write_function(output, overwrite):
|
||||||
|
is_path = isinstance(output, (pycompat.PathLike, str))
|
||||||
|
if overwrite and not is_path:
|
||||||
|
raise Exception('`overwrite=True` can only be used when writing '
|
||||||
|
'content to file.')
|
||||||
|
if output is None:
|
||||||
|
def write(s):
|
||||||
|
stderr = sys.stderr
|
||||||
|
try:
|
||||||
|
stderr.write(s)
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
# God damn Python 2
|
||||||
|
stderr.write(utils.shitcode(s))
|
||||||
|
elif is_path:
|
||||||
|
return FileWriter(output, overwrite).write
|
||||||
|
elif callable(output):
|
||||||
|
write = output
|
||||||
|
else:
|
||||||
|
assert isinstance(output, utils.WritableStream)
|
||||||
|
|
||||||
|
def write(s):
|
||||||
|
output.write(s)
|
||||||
|
return write
|
||||||
|
|
||||||
|
|
||||||
|
class FileWriter(object):
|
||||||
|
def __init__(self, path, overwrite):
|
||||||
|
self.path = pycompat.text_type(path)
|
||||||
|
self.overwrite = overwrite
|
||||||
|
|
||||||
|
def write(self, s):
|
||||||
|
with open(self.path, 'w' if self.overwrite else 'a',
|
||||||
|
encoding='utf-8') as output_file:
|
||||||
|
output_file.write(s)
|
||||||
|
self.overwrite = False
|
||||||
|
|
||||||
|
|
||||||
|
thread_global = threading.local()
|
||||||
|
DISABLED = bool(os.getenv('PYSNOOPER_DISABLED', ''))
|
||||||
|
|
||||||
|
class Tracer:
|
||||||
|
'''
|
||||||
|
Snoop on the function, writing everything it's doing to stderr.
|
||||||
|
|
||||||
|
This is useful for debugging.
|
||||||
|
|
||||||
|
When you decorate a function with `@pysnooper.snoop()`
|
||||||
|
or wrap a block of code in `with pysnooper.snoop():`, you'll get a log of
|
||||||
|
every line that ran in the function and a play-by-play of every local
|
||||||
|
variable that changed.
|
||||||
|
|
||||||
|
If stderr is not easily accessible for you, you can redirect the output to
|
||||||
|
a file::
|
||||||
|
|
||||||
|
@pysnooper.snoop('/my/log/file.log')
|
||||||
|
|
||||||
|
See values of some expressions that aren't local variables::
|
||||||
|
|
||||||
|
@pysnooper.snoop(watch=('foo.bar', 'self.x["whatever"]'))
|
||||||
|
|
||||||
|
Expand values to see all their attributes or items of lists/dictionaries:
|
||||||
|
|
||||||
|
@pysnooper.snoop(watch_explode=('foo', 'self'))
|
||||||
|
|
||||||
|
(see Advanced Usage in the README for more control)
|
||||||
|
|
||||||
|
Show snoop lines for functions that your function calls::
|
||||||
|
|
||||||
|
@pysnooper.snoop(depth=2)
|
||||||
|
|
||||||
|
Start all snoop lines with a prefix, to grep for them easily::
|
||||||
|
|
||||||
|
@pysnooper.snoop(prefix='ZZZ ')
|
||||||
|
|
||||||
|
On multi-threaded apps identify which thread are snooped in output::
|
||||||
|
|
||||||
|
@pysnooper.snoop(thread_info=True)
|
||||||
|
|
||||||
|
Customize how values are represented as strings::
|
||||||
|
|
||||||
|
@pysnooper.snoop(custom_repr=((type1, custom_repr_func1),
|
||||||
|
(condition2, custom_repr_func2), ...))
|
||||||
|
|
||||||
|
Variables and exceptions get truncated to 100 characters by default. You
|
||||||
|
can customize that:
|
||||||
|
|
||||||
|
@pysnooper.snoop(max_variable_length=200)
|
||||||
|
|
||||||
|
You can also use `max_variable_length=None` to never truncate them.
|
||||||
|
|
||||||
|
Show timestamps relative to start time rather than wall time::
|
||||||
|
|
||||||
|
@pysnooper.snoop(relative_time=True)
|
||||||
|
|
||||||
|
'''
|
||||||
|
def __init__(self, output=None, watch=(), watch_explode=(), depth=1,
|
||||||
|
prefix='', overwrite=False, thread_info=False, custom_repr=(),
|
||||||
|
max_variable_length=100, normalize=False, relative_time=False):
|
||||||
|
self._write = get_write_function(output, overwrite)
|
||||||
|
|
||||||
|
self.watch = [
|
||||||
|
v if isinstance(v, BaseVariable) else CommonVariable(v)
|
||||||
|
for v in utils.ensure_tuple(watch)
|
||||||
|
] + [
|
||||||
|
v if isinstance(v, BaseVariable) else Exploding(v)
|
||||||
|
for v in utils.ensure_tuple(watch_explode)
|
||||||
|
]
|
||||||
|
self.frame_to_local_reprs = {}
|
||||||
|
self.start_times = {}
|
||||||
|
self.depth = depth
|
||||||
|
self.prefix = prefix
|
||||||
|
self.thread_info = thread_info
|
||||||
|
self.thread_info_padding = 0
|
||||||
|
assert self.depth >= 1
|
||||||
|
self.target_codes = set()
|
||||||
|
self.target_frames = set()
|
||||||
|
self.thread_local = threading.local()
|
||||||
|
if len(custom_repr) == 2 and not all(isinstance(x,
|
||||||
|
pycompat.collections_abc.Iterable) for x in custom_repr):
|
||||||
|
custom_repr = (custom_repr,)
|
||||||
|
self.custom_repr = custom_repr
|
||||||
|
self.last_source_path = None
|
||||||
|
self.max_variable_length = max_variable_length
|
||||||
|
self.normalize = normalize
|
||||||
|
self.relative_time = relative_time
|
||||||
|
|
||||||
|
def __call__(self, function_or_class):
|
||||||
|
if DISABLED:
|
||||||
|
return function_or_class
|
||||||
|
|
||||||
|
if inspect.isclass(function_or_class):
|
||||||
|
return self._wrap_class(function_or_class)
|
||||||
|
else:
|
||||||
|
return self._wrap_function(function_or_class)
|
||||||
|
|
||||||
|
def _wrap_class(self, cls):
|
||||||
|
for attr_name, attr in cls.__dict__.items():
|
||||||
|
# Coroutines are functions, but snooping them is not supported
|
||||||
|
# at the moment
|
||||||
|
if pycompat.iscoroutinefunction(attr):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if inspect.isfunction(attr):
|
||||||
|
setattr(cls, attr_name, self._wrap_function(attr))
|
||||||
|
return cls
|
||||||
|
|
||||||
|
def _wrap_function(self, function):
|
||||||
|
self.target_codes.add(function.__code__)
|
||||||
|
|
||||||
|
@functools.wraps(function)
|
||||||
|
def simple_wrapper(*args, **kwargs):
|
||||||
|
with self:
|
||||||
|
return function(*args, **kwargs)
|
||||||
|
|
||||||
|
@functools.wraps(function)
|
||||||
|
def generator_wrapper(*args, **kwargs):
|
||||||
|
gen = function(*args, **kwargs)
|
||||||
|
method, incoming = gen.send, None
|
||||||
|
while True:
|
||||||
|
with self:
|
||||||
|
try:
|
||||||
|
outgoing = method(incoming)
|
||||||
|
except StopIteration:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
method, incoming = gen.send, (yield outgoing)
|
||||||
|
except Exception as e:
|
||||||
|
method, incoming = gen.throw, e
|
||||||
|
|
||||||
|
if pycompat.iscoroutinefunction(function):
|
||||||
|
raise NotImplementedError
|
||||||
|
if pycompat.isasyncgenfunction(function):
|
||||||
|
raise NotImplementedError
|
||||||
|
elif inspect.isgeneratorfunction(function):
|
||||||
|
return generator_wrapper
|
||||||
|
else:
|
||||||
|
return simple_wrapper
|
||||||
|
|
||||||
|
def write(self, s):
|
||||||
|
s = u'{self.prefix}{s}\n'.format(**locals())
|
||||||
|
self._write(s)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if DISABLED:
|
||||||
|
return
|
||||||
|
calling_frame = inspect.currentframe().f_back
|
||||||
|
if not self._is_internal_frame(calling_frame):
|
||||||
|
calling_frame.f_trace = self.trace
|
||||||
|
self.target_frames.add(calling_frame)
|
||||||
|
|
||||||
|
stack = self.thread_local.__dict__.setdefault(
|
||||||
|
'original_trace_functions', []
|
||||||
|
)
|
||||||
|
stack.append(sys.gettrace())
|
||||||
|
self.start_times[calling_frame] = datetime_module.datetime.now()
|
||||||
|
sys.settrace(self.trace)
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_value, exc_traceback):
|
||||||
|
if DISABLED:
|
||||||
|
return
|
||||||
|
stack = self.thread_local.original_trace_functions
|
||||||
|
sys.settrace(stack.pop())
|
||||||
|
calling_frame = inspect.currentframe().f_back
|
||||||
|
self.target_frames.discard(calling_frame)
|
||||||
|
self.frame_to_local_reprs.pop(calling_frame, None)
|
||||||
|
|
||||||
|
### Writing elapsed time: #############################################
|
||||||
|
# #
|
||||||
|
start_time = self.start_times.pop(calling_frame)
|
||||||
|
duration = datetime_module.datetime.now() - start_time
|
||||||
|
elapsed_time_string = pycompat.timedelta_format(duration)
|
||||||
|
indent = ' ' * 4 * (thread_global.depth + 1)
|
||||||
|
self.write(
|
||||||
|
'{indent}Elapsed time: {elapsed_time_string}'.format(**locals())
|
||||||
|
)
|
||||||
|
# #
|
||||||
|
### Finished writing elapsed time. ####################################
|
||||||
|
|
||||||
|
def _is_internal_frame(self, frame):
|
||||||
|
return frame.f_code.co_filename == Tracer.__enter__.__code__.co_filename
|
||||||
|
|
||||||
|
def set_thread_info_padding(self, thread_info):
|
||||||
|
current_thread_len = len(thread_info)
|
||||||
|
self.thread_info_padding = max(self.thread_info_padding,
|
||||||
|
current_thread_len)
|
||||||
|
return thread_info.ljust(self.thread_info_padding)
|
||||||
|
|
||||||
|
def trace(self, frame, event, arg):
|
||||||
|
|
||||||
|
### Checking whether we should trace this line: #######################
|
||||||
|
# #
|
||||||
|
# We should trace this line either if it's in the decorated function,
|
||||||
|
# or the user asked to go a few levels deeper and we're within that
|
||||||
|
# number of levels deeper.
|
||||||
|
|
||||||
|
if not (frame.f_code in self.target_codes or frame in self.target_frames):
|
||||||
|
if self.depth == 1:
|
||||||
|
# We did the most common and quickest check above, because the
|
||||||
|
# trace function runs so incredibly often, therefore it's
|
||||||
|
# crucial to hyper-optimize it for the common case.
|
||||||
|
return None
|
||||||
|
elif self._is_internal_frame(frame):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
_frame_candidate = frame
|
||||||
|
for i in range(1, self.depth):
|
||||||
|
_frame_candidate = _frame_candidate.f_back
|
||||||
|
if _frame_candidate is None:
|
||||||
|
return None
|
||||||
|
elif _frame_candidate.f_code in self.target_codes or _frame_candidate in self.target_frames:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_global.__dict__.setdefault('depth', -1)
|
||||||
|
if event == 'call':
|
||||||
|
thread_global.depth += 1
|
||||||
|
indent = ' ' * 4 * thread_global.depth
|
||||||
|
|
||||||
|
# #
|
||||||
|
### Finished checking whether we should trace this line. ##############
|
||||||
|
|
||||||
|
### Making timestamp: #################################################
|
||||||
|
# #
|
||||||
|
if self.normalize:
|
||||||
|
timestamp = ' ' * 15
|
||||||
|
elif self.relative_time:
|
||||||
|
try:
|
||||||
|
start_time = self.start_times[frame]
|
||||||
|
except KeyError:
|
||||||
|
start_time = self.start_times[frame] = \
|
||||||
|
datetime_module.datetime.now()
|
||||||
|
duration = datetime_module.datetime.now() - start_time
|
||||||
|
timestamp = pycompat.timedelta_format(duration)
|
||||||
|
else:
|
||||||
|
timestamp = pycompat.time_isoformat(
|
||||||
|
datetime_module.datetime.now().time(),
|
||||||
|
timespec='microseconds'
|
||||||
|
)
|
||||||
|
# #
|
||||||
|
### Finished making timestamp. ########################################
|
||||||
|
|
||||||
|
line_no = frame.f_lineno
|
||||||
|
source_path, source = get_path_and_source_from_frame(frame)
|
||||||
|
source_path = source_path if not self.normalize else os.path.basename(source_path)
|
||||||
|
if self.last_source_path != source_path:
|
||||||
|
self.write(u'{indent}Source path:... {source_path}'.
|
||||||
|
format(**locals()))
|
||||||
|
self.last_source_path = source_path
|
||||||
|
source_line = source[line_no - 1]
|
||||||
|
thread_info = ""
|
||||||
|
if self.thread_info:
|
||||||
|
if self.normalize:
|
||||||
|
raise NotImplementedError("normalize is not supported with "
|
||||||
|
"thread_info")
|
||||||
|
current_thread = threading.current_thread()
|
||||||
|
thread_info = "{ident}-{name} ".format(
|
||||||
|
ident=current_thread.ident, name=current_thread.getName())
|
||||||
|
thread_info = self.set_thread_info_padding(thread_info)
|
||||||
|
|
||||||
|
### Reporting newish and modified variables: ##########################
|
||||||
|
# #
|
||||||
|
old_local_reprs = self.frame_to_local_reprs.get(frame, {})
|
||||||
|
self.frame_to_local_reprs[frame] = local_reprs = \
|
||||||
|
get_local_reprs(frame,
|
||||||
|
watch=self.watch, custom_repr=self.custom_repr,
|
||||||
|
max_length=self.max_variable_length,
|
||||||
|
normalize=self.normalize,
|
||||||
|
)
|
||||||
|
|
||||||
|
newish_string = ('Starting var:.. ' if event == 'call' else
|
||||||
|
'New var:....... ')
|
||||||
|
|
||||||
|
for name, value_repr in local_reprs.items():
|
||||||
|
if name not in old_local_reprs:
|
||||||
|
self.write('{indent}{newish_string}{name} = {value_repr}'.format(
|
||||||
|
**locals()))
|
||||||
|
elif old_local_reprs[name] != value_repr:
|
||||||
|
self.write('{indent}Modified var:.. {name} = {value_repr}'.format(
|
||||||
|
**locals()))
|
||||||
|
|
||||||
|
# #
|
||||||
|
### Finished newish and modified variables. ###########################
|
||||||
|
|
||||||
|
|
||||||
|
### Dealing with misplaced function definition: #######################
|
||||||
|
# #
|
||||||
|
if event == 'call' and source_line.lstrip().startswith('@'):
|
||||||
|
# If a function decorator is found, skip lines until an actual
|
||||||
|
# function definition is found.
|
||||||
|
for candidate_line_no in itertools.count(line_no):
|
||||||
|
try:
|
||||||
|
candidate_source_line = source[candidate_line_no - 1]
|
||||||
|
except IndexError:
|
||||||
|
# End of source file reached without finding a function
|
||||||
|
# definition. Fall back to original source line.
|
||||||
|
break
|
||||||
|
|
||||||
|
if candidate_source_line.lstrip().startswith('def'):
|
||||||
|
# Found the def line!
|
||||||
|
line_no = candidate_line_no
|
||||||
|
source_line = candidate_source_line
|
||||||
|
break
|
||||||
|
# #
|
||||||
|
### Finished dealing with misplaced function definition. ##############
|
||||||
|
|
||||||
|
# If a call ends due to an exception, we still get a 'return' event
|
||||||
|
# with arg = None. This seems to be the only way to tell the difference
|
||||||
|
# https://stackoverflow.com/a/12800909/2482744
|
||||||
|
code_byte = frame.f_code.co_code[frame.f_lasti]
|
||||||
|
if not isinstance(code_byte, int):
|
||||||
|
code_byte = ord(code_byte)
|
||||||
|
ended_by_exception = (
|
||||||
|
event == 'return'
|
||||||
|
and arg is None
|
||||||
|
and (opcode.opname[code_byte]
|
||||||
|
not in ('RETURN_VALUE', 'YIELD_VALUE'))
|
||||||
|
)
|
||||||
|
|
||||||
|
if ended_by_exception:
|
||||||
|
self.write('{indent}Call ended by exception'.
|
||||||
|
format(**locals()))
|
||||||
|
else:
|
||||||
|
self.write(u'{indent}{timestamp} {thread_info}{event:9} '
|
||||||
|
u'{line_no:4} {source_line}'.format(**locals()))
|
||||||
|
|
||||||
|
if event == 'return':
|
||||||
|
self.frame_to_local_reprs.pop(frame, None)
|
||||||
|
self.start_times.pop(frame, None)
|
||||||
|
thread_global.depth -= 1
|
||||||
|
|
||||||
|
if not ended_by_exception:
|
||||||
|
return_value_repr = utils.get_shortish_repr(arg,
|
||||||
|
custom_repr=self.custom_repr,
|
||||||
|
max_length=self.max_variable_length,
|
||||||
|
normalize=self.normalize,
|
||||||
|
)
|
||||||
|
self.write('{indent}Return value:.. {return_value_repr}'.
|
||||||
|
format(**locals()))
|
||||||
|
|
||||||
|
if event == 'exception':
|
||||||
|
exception = '\n'.join(traceback.format_exception_only(*arg[:2])).strip()
|
||||||
|
if self.max_variable_length:
|
||||||
|
exception = utils.truncate(exception, self.max_variable_length)
|
||||||
|
self.write('{indent}{exception}'.
|
||||||
|
format(**locals()))
|
||||||
|
|
||||||
|
return self.trace
|
|
@ -0,0 +1,98 @@
|
||||||
|
# Copyright 2019 Ram Rachum and collaborators.
|
||||||
|
# This program is distributed under the MIT license.
|
||||||
|
|
||||||
|
import abc
|
||||||
|
import re
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from .pycompat import ABC, string_types, collections_abc
|
||||||
|
|
||||||
|
def _check_methods(C, *methods):
|
||||||
|
mro = C.__mro__
|
||||||
|
for method in methods:
|
||||||
|
for B in mro:
|
||||||
|
if method in B.__dict__:
|
||||||
|
if B.__dict__[method] is None:
|
||||||
|
return NotImplemented
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return NotImplemented
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class WritableStream(ABC):
|
||||||
|
@abc.abstractmethod
|
||||||
|
def write(self, s):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def __subclasshook__(cls, C):
|
||||||
|
if cls is WritableStream:
|
||||||
|
return _check_methods(C, 'write')
|
||||||
|
return NotImplemented
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
file_reading_errors = (
|
||||||
|
IOError,
|
||||||
|
OSError,
|
||||||
|
ValueError # IronPython weirdness.
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def shitcode(s):
|
||||||
|
return ''.join(
|
||||||
|
(c if (0 < ord(c) < 256) else '?') for c in s
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_repr_function(item, custom_repr):
|
||||||
|
for condition, action in custom_repr:
|
||||||
|
if isinstance(condition, type):
|
||||||
|
condition = lambda x, y=condition: isinstance(x, y)
|
||||||
|
if condition(item):
|
||||||
|
return action
|
||||||
|
return repr
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_REPR_RE = re.compile(r' at 0x[a-f0-9A-F]{4,}')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_repr(item_repr):
|
||||||
|
"""Remove memory address (0x...) from a default python repr"""
|
||||||
|
return DEFAULT_REPR_RE.sub('', item_repr)
|
||||||
|
|
||||||
|
|
||||||
|
def get_shortish_repr(item, custom_repr=(), max_length=None, normalize=False):
|
||||||
|
repr_function = get_repr_function(item, custom_repr)
|
||||||
|
try:
|
||||||
|
r = repr_function(item)
|
||||||
|
except Exception:
|
||||||
|
r = 'REPR FAILED'
|
||||||
|
r = r.replace('\r', '').replace('\n', '')
|
||||||
|
if normalize:
|
||||||
|
r = normalize_repr(r)
|
||||||
|
if max_length:
|
||||||
|
r = truncate(r, max_length)
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def truncate(string, max_length):
|
||||||
|
if (max_length is None) or (len(string) <= max_length):
|
||||||
|
return string
|
||||||
|
else:
|
||||||
|
left = (max_length - 3) // 2
|
||||||
|
right = max_length - 3 - left
|
||||||
|
return u'{}...{}'.format(string[:left], string[-right:])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tuple(x):
|
||||||
|
if isinstance(x, collections_abc.Iterable) and \
|
||||||
|
not isinstance(x, string_types):
|
||||||
|
return tuple(x)
|
||||||
|
else:
|
||||||
|
return (x,)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,133 @@
|
||||||
|
import itertools
|
||||||
|
import abc
|
||||||
|
try:
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
except ImportError:
|
||||||
|
from collections import Mapping, Sequence
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from . import utils
|
||||||
|
from . import pycompat
|
||||||
|
|
||||||
|
|
||||||
|
def needs_parentheses(source):
|
||||||
|
def code(s):
|
||||||
|
return compile(s, '<variable>', 'eval').co_code
|
||||||
|
|
||||||
|
return code('{}.x'.format(source)) != code('({}).x'.format(source))
|
||||||
|
|
||||||
|
|
||||||
|
class BaseVariable(pycompat.ABC):
|
||||||
|
def __init__(self, source, exclude=()):
|
||||||
|
self.source = source
|
||||||
|
self.exclude = utils.ensure_tuple(exclude)
|
||||||
|
self.code = compile(source, '<variable>', 'eval')
|
||||||
|
if needs_parentheses(source):
|
||||||
|
self.unambiguous_source = '({})'.format(source)
|
||||||
|
else:
|
||||||
|
self.unambiguous_source = source
|
||||||
|
|
||||||
|
def items(self, frame, normalize=False):
|
||||||
|
try:
|
||||||
|
main_value = eval(self.code, frame.f_globals or {}, frame.f_locals)
|
||||||
|
except Exception:
|
||||||
|
return ()
|
||||||
|
return self._items(main_value, normalize)
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def _items(self, key, normalize=False):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _fingerprint(self):
|
||||||
|
return (type(self), self.source, self.exclude)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self._fingerprint)
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return (isinstance(other, BaseVariable) and
|
||||||
|
self._fingerprint == other._fingerprint)
|
||||||
|
|
||||||
|
|
||||||
|
class CommonVariable(BaseVariable):
|
||||||
|
def _items(self, main_value, normalize=False):
|
||||||
|
result = [(self.source, utils.get_shortish_repr(main_value, normalize=normalize))]
|
||||||
|
for key in self._safe_keys(main_value):
|
||||||
|
try:
|
||||||
|
if key in self.exclude:
|
||||||
|
continue
|
||||||
|
value = self._get_value(main_value, key)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
result.append((
|
||||||
|
'{}{}'.format(self.unambiguous_source, self._format_key(key)),
|
||||||
|
utils.get_shortish_repr(value)
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _safe_keys(self, main_value):
|
||||||
|
try:
|
||||||
|
for key in self._keys(main_value):
|
||||||
|
yield key
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _keys(self, main_value):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def _format_key(self, key):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _get_value(self, main_value, key):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class Attrs(CommonVariable):
|
||||||
|
def _keys(self, main_value):
|
||||||
|
return itertools.chain(
|
||||||
|
getattr(main_value, '__dict__', ()),
|
||||||
|
getattr(main_value, '__slots__', ())
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_key(self, key):
|
||||||
|
return '.' + key
|
||||||
|
|
||||||
|
def _get_value(self, main_value, key):
|
||||||
|
return getattr(main_value, key)
|
||||||
|
|
||||||
|
|
||||||
|
class Keys(CommonVariable):
|
||||||
|
def _keys(self, main_value):
|
||||||
|
return main_value.keys()
|
||||||
|
|
||||||
|
def _format_key(self, key):
|
||||||
|
return '[{}]'.format(utils.get_shortish_repr(key))
|
||||||
|
|
||||||
|
def _get_value(self, main_value, key):
|
||||||
|
return main_value[key]
|
||||||
|
|
||||||
|
|
||||||
|
class Indices(Keys):
|
||||||
|
_slice = slice(None)
|
||||||
|
|
||||||
|
def _keys(self, main_value):
|
||||||
|
return range(len(main_value))[self._slice]
|
||||||
|
|
||||||
|
def __getitem__(self, item):
|
||||||
|
assert isinstance(item, slice)
|
||||||
|
result = deepcopy(self)
|
||||||
|
result._slice = item
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class Exploding(BaseVariable):
|
||||||
|
def _items(self, main_value, normalize=False):
|
||||||
|
if isinstance(main_value, Mapping):
|
||||||
|
cls = Keys
|
||||||
|
elif isinstance(main_value, Sequence):
|
||||||
|
cls = Indices
|
||||||
|
else:
|
||||||
|
cls = Attrs
|
||||||
|
|
||||||
|
return cls(self.source, self.exclude)._items(main_value, normalize)
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec 2>&1
|
||||||
|
exec multilog t s25000 n4 /var/log/dbus-fzsonick-48tl.TTY
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh
|
||||||
|
exec 2>&1
|
||||||
|
|
||||||
|
exec softlimit -d 100000000 -s 1000000 -a 100000000 /opt/innovenergy/dbus-fzsonick-48tl/start.sh TTY
|
|
@ -0,0 +1,214 @@
|
||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
from convert import mean, read_float, read_led_state, read_bool, count_bits, comma_separated
|
||||||
|
from data import BatterySignal, Battery, LedColor, ServiceSignal, BatteryStatus, LedState
|
||||||
|
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import List, Iterable
|
||||||
|
|
||||||
|
|
||||||
|
def init_service_signals(batteries):
|
||||||
|
# type: (List[Battery]) -> Iterable[ServiceSignal]
|
||||||
|
|
||||||
|
n_batteries = len(batteries)
|
||||||
|
product_name = cfg.PRODUCT_NAME + ' x' + str(n_batteries)
|
||||||
|
|
||||||
|
return [
|
||||||
|
ServiceSignal('/NbOfBatteries', n_batteries), # TODO: nb of operational batteries
|
||||||
|
ServiceSignal('/Mgmt/ProcessName', __file__),
|
||||||
|
ServiceSignal('/Mgmt/ProcessVersion', cfg.SOFTWARE_VERSION),
|
||||||
|
ServiceSignal('/Mgmt/Connection', cfg.CONNECTION),
|
||||||
|
ServiceSignal('/DeviceInstance', cfg.DEVICE_INSTANCE),
|
||||||
|
ServiceSignal('/ProductName', product_name),
|
||||||
|
ServiceSignal('/ProductId', cfg.PRODUCT_ID),
|
||||||
|
ServiceSignal('/Connected', 1)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def init_battery_signals():
|
||||||
|
# type: () -> Iterable[BatterySignal]
|
||||||
|
|
||||||
|
read_voltage = read_float(register=999, scale_factor=0.01, offset=0)
|
||||||
|
read_current = read_float(register=1000, scale_factor=0.01, offset=-10000)
|
||||||
|
|
||||||
|
read_led_amber = read_led_state(register=1004, led=LedColor.amber)
|
||||||
|
read_led_green = read_led_state(register=1004, led=LedColor.green)
|
||||||
|
read_led_blue = read_led_state(register=1004, led=LedColor.blue)
|
||||||
|
read_led_red = read_led_state(register=1004, led=LedColor.red)
|
||||||
|
|
||||||
|
def read_power(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
return int(read_current(status) * read_voltage(status))
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
dv = v_limit - v
|
||||||
|
di = dv / r_int
|
||||||
|
p_limit = v_limit * (i + di)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_current_limit(v, i, i_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
di = i_limit - i
|
||||||
|
dv = di * r_int
|
||||||
|
p_limit = i_limit * (v + dv)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_max_charge_power(bs):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
|
||||||
|
b = bs.battery
|
||||||
|
v = read_voltage(bs)
|
||||||
|
i = read_current(bs)
|
||||||
|
|
||||||
|
p_limits = [
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, b.v_max, b.r_int_min),
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, b.v_max, b.r_int_max),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, b.i_max, b.r_int_min),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, b.i_max, b.r_int_max),
|
||||||
|
]
|
||||||
|
|
||||||
|
p_limit = min(p_limits) # p_limit is normally positive here (signed)
|
||||||
|
p_limit = max(p_limit, 0) # charge power must not become negative
|
||||||
|
|
||||||
|
return int(p_limit)
|
||||||
|
|
||||||
|
def calc_max_discharge_power(bs):
|
||||||
|
# type: (BatteryStatus) -> float
|
||||||
|
|
||||||
|
b = bs.battery
|
||||||
|
v = read_voltage(bs)
|
||||||
|
i = read_current(bs)
|
||||||
|
|
||||||
|
p_limits = [
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, b.v_min, b.r_int_min),
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, b.v_min, b.r_int_max),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, -b.i_max, b.r_int_min),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, -b.i_max, b.r_int_max),
|
||||||
|
]
|
||||||
|
|
||||||
|
p_limit = max(p_limits) # p_limit is normally negative here (signed)
|
||||||
|
p_limit = min(p_limit, 0) # discharge power must not become positive
|
||||||
|
|
||||||
|
return int(-p_limit) # make unsigned!
|
||||||
|
|
||||||
|
def read_battery_cold(status):
|
||||||
|
return \
|
||||||
|
read_led_green(status) >= LedState.blinking_slow and \
|
||||||
|
read_led_blue(status) >= LedState.blinking_slow
|
||||||
|
|
||||||
|
def read_soc(status):
|
||||||
|
soc = read_float(register=1053, scale_factor=0.1, offset=0)(status)
|
||||||
|
|
||||||
|
# if the SOC is 100 but EOC is not yet reached, report 99.9 instead of 100
|
||||||
|
if soc > 99.9 and not read_eoc_reached(status):
|
||||||
|
return 99.9
|
||||||
|
if soc >= 99.9 and read_eoc_reached(status):
|
||||||
|
return 100
|
||||||
|
|
||||||
|
return soc
|
||||||
|
|
||||||
|
def read_eoc_reached(status):
|
||||||
|
return \
|
||||||
|
read_led_green(status) == LedState.on and \
|
||||||
|
read_led_amber(status) == LedState.off and \
|
||||||
|
read_led_blue(status) == LedState.off
|
||||||
|
|
||||||
|
return [
|
||||||
|
BatterySignal('/Dc/0/Voltage', mean, get_value=read_voltage, unit='V'),
|
||||||
|
BatterySignal('/Dc/0/Current', sum, get_value=read_current, unit='A'),
|
||||||
|
BatterySignal('/Dc/0/Power', sum, get_value=read_power, unit='W'),
|
||||||
|
|
||||||
|
BatterySignal('/BussVoltage', mean, read_float(register=1001, scale_factor=0.01, offset=0), unit='V'),
|
||||||
|
BatterySignal('/Soc', mean, read_soc, unit='%'),
|
||||||
|
BatterySignal('/Dc/0/Temperature', mean, read_float(register=1003, scale_factor=0.1, offset=-400), unit='C'),
|
||||||
|
|
||||||
|
BatterySignal('/NumberOfWarningFlags', sum, count_bits(base_register=1005, nb_of_registers=3, nb_of_bits=47)),
|
||||||
|
BatterySignal('/WarningFlags/TaM1', any, read_bool(base_register=1005, bit=1)),
|
||||||
|
BatterySignal('/WarningFlags/TbM1', any, read_bool(base_register=1005, bit=4)),
|
||||||
|
BatterySignal('/WarningFlags/VBm1', any, read_bool(base_register=1005, bit=6)),
|
||||||
|
BatterySignal('/WarningFlags/VBM1', any, read_bool(base_register=1005, bit=8)),
|
||||||
|
BatterySignal('/WarningFlags/IDM1', any, read_bool(base_register=1005, bit=10)),
|
||||||
|
BatterySignal('/WarningFlags/vsM1', any, read_bool(base_register=1005, bit=24)),
|
||||||
|
BatterySignal('/WarningFlags/iCM1', any, read_bool(base_register=1005, bit=26)),
|
||||||
|
BatterySignal('/WarningFlags/iDM1', any, read_bool(base_register=1005, bit=28)),
|
||||||
|
BatterySignal('/WarningFlags/MID1', any, read_bool(base_register=1005, bit=30)),
|
||||||
|
BatterySignal('/WarningFlags/BLPW', any, read_bool(base_register=1005, bit=32)),
|
||||||
|
BatterySignal('/WarningFlags/Ah_W', any, read_bool(base_register=1005, bit=35)),
|
||||||
|
BatterySignal('/WarningFlags/MPMM', any, read_bool(base_register=1005, bit=38)),
|
||||||
|
BatterySignal('/WarningFlags/TCMM', any, read_bool(base_register=1005, bit=39)),
|
||||||
|
BatterySignal('/WarningFlags/TCdi', any, read_bool(base_register=1005, bit=40)),
|
||||||
|
BatterySignal('/WarningFlags/WMTO', any, read_bool(base_register=1005, bit=41)),
|
||||||
|
BatterySignal('/WarningFlags/bit44', any, read_bool(base_register=1005, bit=44)),
|
||||||
|
BatterySignal('/WarningFlags/CELL1', any, read_bool(base_register=1005, bit=46)),
|
||||||
|
BatterySignal('/WarningFlags/bit47WarningDummy', any, read_bool(base_register=1005, bit=47)),
|
||||||
|
|
||||||
|
BatterySignal('/NumberOfAlarmFlags', sum, count_bits(base_register=1009, nb_of_registers=3, nb_of_bits=47)),
|
||||||
|
BatterySignal('/AlarmFlags/Tam', any, read_bool(base_register=1009, bit=0)),
|
||||||
|
BatterySignal('/AlarmFlags/TaM2', any, read_bool(base_register=1009, bit=2)),
|
||||||
|
BatterySignal('/AlarmFlags/Tbm', any, read_bool(base_register=1009, bit=3)),
|
||||||
|
BatterySignal('/AlarmFlags/TbM2', any, read_bool(base_register=1009, bit=5)),
|
||||||
|
BatterySignal('/AlarmFlags/VBm2', any, read_bool(base_register=1009, bit=7)),
|
||||||
|
BatterySignal('/AlarmFlags/IDM2', any, read_bool(base_register=1009, bit=11)),
|
||||||
|
BatterySignal('/AlarmFlags/ISOB', any, read_bool(base_register=1009, bit=12)),
|
||||||
|
BatterySignal('/AlarmFlags/MSWE', any, read_bool(base_register=1009, bit=13)),
|
||||||
|
BatterySignal('/AlarmFlags/FUSE', any, read_bool(base_register=1009, bit=14)),
|
||||||
|
BatterySignal('/AlarmFlags/HTRE', any, read_bool(base_register=1009, bit=15)),
|
||||||
|
BatterySignal('/AlarmFlags/TCPE', any, read_bool(base_register=1009, bit=16)),
|
||||||
|
BatterySignal('/AlarmFlags/STRE', any, read_bool(base_register=1009, bit=17)),
|
||||||
|
BatterySignal('/AlarmFlags/CME', any, read_bool(base_register=1009, bit=18)),
|
||||||
|
BatterySignal('/AlarmFlags/HWFL', any, read_bool(base_register=1009, bit=19)),
|
||||||
|
BatterySignal('/AlarmFlags/HWEM', any, read_bool(base_register=1009, bit=20)),
|
||||||
|
BatterySignal('/AlarmFlags/ThM', any, read_bool(base_register=1009, bit=21)),
|
||||||
|
BatterySignal('/AlarmFlags/vsm1', any, read_bool(base_register=1009, bit=22)),
|
||||||
|
BatterySignal('/AlarmFlags/vsm2', any, read_bool(base_register=1009, bit=23)),
|
||||||
|
BatterySignal('/AlarmFlags/vsM2', any, read_bool(base_register=1009, bit=25)),
|
||||||
|
BatterySignal('/AlarmFlags/iCM2', any, read_bool(base_register=1009, bit=27)),
|
||||||
|
BatterySignal('/AlarmFlags/iDM2', any, read_bool(base_register=1009, bit=29)),
|
||||||
|
BatterySignal('/AlarmFlags/MID2', any, read_bool(base_register=1009, bit=31)),
|
||||||
|
BatterySignal('/AlarmFlags/CCBF', any, read_bool(base_register=1009, bit=33)),
|
||||||
|
BatterySignal('/AlarmFlags/AhFL', any, read_bool(base_register=1009, bit=34)),
|
||||||
|
BatterySignal('/AlarmFlags/TbCM', any, read_bool(base_register=1009, bit=36)),
|
||||||
|
BatterySignal('/AlarmFlags/BRNF', any, read_bool(base_register=1009, bit=37)),
|
||||||
|
BatterySignal('/AlarmFlags/HTFS', any, read_bool(base_register=1009, bit=42)),
|
||||||
|
BatterySignal('/AlarmFlags/DATA', any, read_bool(base_register=1009, bit=43)),
|
||||||
|
BatterySignal('/AlarmFlags/CELL2', any, read_bool(base_register=1009, bit=45)),
|
||||||
|
BatterySignal('/AlarmFlags/bit47AlarmDummy', any, read_bool(base_register=1009, bit=47)),
|
||||||
|
|
||||||
|
BatterySignal('/LedStatus/Red', max, read_led_red),
|
||||||
|
BatterySignal('/LedStatus/Blue', max, read_led_blue),
|
||||||
|
BatterySignal('/LedStatus/Green', max, read_led_green),
|
||||||
|
BatterySignal('/LedStatus/Amber', max, read_led_amber),
|
||||||
|
|
||||||
|
BatterySignal('/IoStatus/MainSwitchClosed', any, read_bool(base_register=1013, bit=0)),
|
||||||
|
BatterySignal('/IoStatus/AlarmOutActive', any, read_bool(base_register=1013, bit=1)),
|
||||||
|
BatterySignal('/IoStatus/InternalFanActive', any, read_bool(base_register=1013, bit=2)),
|
||||||
|
BatterySignal('/IoStatus/VoltMeasurementAllowed', any, read_bool(base_register=1013, bit=3)),
|
||||||
|
BatterySignal('/IoStatus/AuxRelay', any, read_bool(base_register=1013, bit=4)),
|
||||||
|
BatterySignal('/IoStatus/RemoteState', any, read_bool(base_register=1013, bit=5)),
|
||||||
|
BatterySignal('/IoStatus/HeaterOn', any, read_bool(base_register=1013, bit=6)),
|
||||||
|
BatterySignal('/IoStatus/EocReached', min, read_eoc_reached),
|
||||||
|
BatterySignal('/IoStatus/BatteryCold', any, read_battery_cold),
|
||||||
|
|
||||||
|
# see protocol doc page 7
|
||||||
|
BatterySignal('/Info/MaxDischargeCurrent', sum, lambda bs: bs.battery.i_max, unit='A'),
|
||||||
|
BatterySignal('/Info/MaxChargeCurrent', sum, lambda bs: bs.battery.i_max, unit='A'),
|
||||||
|
BatterySignal('/Info/MaxChargeVoltage', min, lambda bs: bs.battery.v_max, unit='V'),
|
||||||
|
BatterySignal('/Info/MinDischargeVoltage', max, lambda bs: bs.battery.v_min, unit='V'),
|
||||||
|
BatterySignal('/Info/BatteryLowVoltage' , max, lambda bs: bs.battery.v_min-2, unit='V'),
|
||||||
|
BatterySignal('/Info/NumberOfStrings', sum, lambda bs: bs.battery.n_strings),
|
||||||
|
|
||||||
|
BatterySignal('/Info/MaxChargePower', sum, calc_max_charge_power),
|
||||||
|
BatterySignal('/Info/MaxDischargePower', sum, calc_max_discharge_power),
|
||||||
|
|
||||||
|
BatterySignal('/FirmwareVersion', comma_separated, lambda bs: bs.battery.firmware_version),
|
||||||
|
BatterySignal('/HardwareVersion', comma_separated, lambda bs: bs.battery.hardware_version),
|
||||||
|
BatterySignal('/BmsVersion', comma_separated, lambda bs: bs.battery.bms_version)
|
||||||
|
|
||||||
|
]
|
Binary file not shown.
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
. /opt/victronenergy/serial-starter/run-service.sh
|
||||||
|
|
||||||
|
app="/opt/innovenergy/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py"
|
||||||
|
args="$tty"
|
||||||
|
start $args
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -13,9 +13,9 @@ DEVICE_INSTANCE = 1
|
||||||
SERVICE_NAME_PREFIX = 'com.victronenergy.battery.'
|
SERVICE_NAME_PREFIX = 'com.victronenergy.battery.'
|
||||||
|
|
||||||
#s3 configuration
|
#s3 configuration
|
||||||
S3BUCKET = "2-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
S3BUCKET = "13-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||||
S3KEY = "EXO5b2e35442791260eaaa7bdc8"
|
S3KEY = "EXOcca50b894afa583d8d380dd1"
|
||||||
S3SECRET = "XFFOVzenDiEQoLPmhK6ML9RfQfsAMhrAs25MfJxi-24"
|
S3SECRET = "7fmdIN1WL8WL9k-20YjLZC5liH2qCwYrGP31Y4dityk"
|
||||||
|
|
||||||
# driver configuration
|
# driver configuration
|
||||||
|
|
||||||
|
|
|
@ -90,25 +90,6 @@ def read_bitmap(register):
|
||||||
|
|
||||||
return get_value
|
return get_value
|
||||||
|
|
||||||
def read_limb_string(register):
|
|
||||||
# type: (int) -> Callable[[BatteryStatus], bitmap]
|
|
||||||
|
|
||||||
def get_value(status):
|
|
||||||
# type: (BatteryStatus) -> bitmap
|
|
||||||
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
|
||||||
|
|
||||||
string1_disabled = int((value & 0b00001) != 0)
|
|
||||||
string2_disabled = int((value & 0b00010) != 0)
|
|
||||||
string3_disabled = int((value & 0b00100) != 0)
|
|
||||||
string4_disabled = int((value & 0b01000) != 0)
|
|
||||||
string5_disabled = int((value & 0b10000) != 0)
|
|
||||||
n_limb_strings = string1_disabled+string2_disabled+string3_disabled+string4_disabled+string5_disabled
|
|
||||||
if n_limb_strings>=2:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
|
|
||||||
return False
|
|
||||||
return get_value
|
|
||||||
|
|
||||||
def append_unit(unit):
|
def append_unit(unit):
|
||||||
# type: (unicode) -> Callable[[unicode], unicode]
|
# type: (unicode) -> Callable[[unicode], unicode]
|
||||||
|
|
|
@ -111,6 +111,14 @@ class S3config:
|
||||||
).decode()
|
).decode()
|
||||||
return f"AWS {s3_key}:{signature}"
|
return f"AWS {s3_key}:{signature}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_authorization(method, bucket, s3_path, date, s3_key, s3_secret, content_type="", md5_hash=""):
|
||||||
|
payload = f"{method}\n{md5_hash}\n{content_type}\n{date}\n/{bucket.strip('/')}/{s3_path.strip('/')}"
|
||||||
|
signature = base64.b64encode(
|
||||||
|
hmac.new(s3_secret.encode(), payload.encode(), hashlib.sha1).digest()
|
||||||
|
).decode()
|
||||||
|
return f"AWS {s3_key}:{signature}"
|
||||||
|
|
||||||
def read_csv_as_string(file_path):
|
def read_csv_as_string(file_path):
|
||||||
"""
|
"""
|
||||||
Reads a CSV file from the given path and returns its content as a single string.
|
Reads a CSV file from the given path and returns its content as a single string.
|
||||||
|
@ -131,22 +139,21 @@ CSV_DIR = "/data/csv_files/"
|
||||||
# Define the path to the file containing the installation name
|
# Define the path to the file containing the installation name
|
||||||
INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name'
|
INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name'
|
||||||
|
|
||||||
|
|
||||||
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
# noinspection PyUnreachableCode
|
# noinspection PyUnreachableCode
|
||||||
if False:
|
if False:
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
def interpret_limb_bitmap(bitmap_value):
|
def interpret_limb_bitmap(bitmap_value):
|
||||||
# The bit for string 1 also monitors all 5 strings: 0000 0000 means All 5 strings activated. 0000 0001 means string 1 disabled.
|
# The bit for string 1 also monitors all 5 strings: 0000 0000 means All 5 strings activated. 0000 0001 means string 1 disabled.
|
||||||
string1_disabled = int((bitmap_value & 0b00001) != 0)
|
string1_disabled = int((bitmap_value & 0b00001) != 0)
|
||||||
string2_disabled = int((bitmap_value & 0b00010) != 0)
|
string2_disabled = int((bitmap_value & 0b00010) != 0)
|
||||||
string3_disabled = int((bitmap_value & 0b00100) != 0)
|
string3_disabled = int((bitmap_value & 0b00100) != 0)
|
||||||
string4_disabled = int((bitmap_value & 0b01000) != 0)
|
string4_disabled = int((bitmap_value & 0b01000) != 0)
|
||||||
string5_disabled = int((bitmap_value & 0b10000) != 0)
|
string5_disabled = int((bitmap_value & 0b10000) != 0)
|
||||||
|
n_limb_strings = string1_disabled+string2_disabled+string3_disabled+string4_disabled+string5_disabled
|
||||||
n_limb_strings = string1_disabled+string2_disabled+string3_disabled+string4_disabled+string5_disabled
|
return n_limb_strings
|
||||||
|
|
||||||
return n_limb_strings
|
|
||||||
|
|
||||||
def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
||||||
# type: (float, float, float, float) -> float
|
# type: (float, float, float, float) -> float
|
||||||
|
@ -733,39 +740,34 @@ def update_state_from_dictionaries(current_warnings, current_alarms, node_number
|
||||||
"Alarms": []
|
"Alarms": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
alarms_number_list = []
|
alarms_number_list = []
|
||||||
for node_number in node_numbers:
|
for node_number in node_numbers:
|
||||||
cnt = 0
|
cnt = 0
|
||||||
for i, alarm_value in enumerate(current_alarms.values()):
|
for alarm_name, alarm_value in current_alarms.items():
|
||||||
if int(list(current_alarms.keys())[i].split("/")[3]) == int(node_number):
|
if str(node_number) in alarm_name and alarm_value:
|
||||||
if alarm_value:
|
cnt+=1
|
||||||
cnt+=1
|
|
||||||
alarms_number_list.append(cnt)
|
alarms_number_list.append(cnt)
|
||||||
|
|
||||||
|
|
||||||
warnings_number_list = []
|
warnings_number_list = []
|
||||||
for node_number in node_numbers:
|
for node_number in node_numbers:
|
||||||
cnt = 0
|
cnt = 0
|
||||||
for i, warning_value in enumerate(current_warnings.values()):
|
for warning_name, warning_value in current_warnings.items():
|
||||||
if int(list(current_warnings.keys())[i].split("/")[3]) == int(node_number):
|
if str(node_number) in warning_name and warning_value:
|
||||||
if warning_value:
|
cnt+=1
|
||||||
cnt+=1
|
|
||||||
warnings_number_list.append(cnt)
|
warnings_number_list.append(cnt)
|
||||||
|
|
||||||
|
|
||||||
# Evaluate alarms
|
# Evaluate alarms
|
||||||
if any(changed_alarms.values()):
|
if any(changed_alarms.values()):
|
||||||
for i, changed_alarm in enumerate(changed_alarms.values()):
|
for i, changed_alarm in enumerate(changed_alarms.values()):
|
||||||
if changed_alarm and list(current_alarms.values())[i]:
|
if changed_alarm and list(current_alarms.values())[i]:
|
||||||
description = list(current_alarms.keys())[i].split("/")[-1]
|
status_message["Alarms"].append(AlarmOrWarning(list(current_alarms.keys())[i],"System").to_dict())
|
||||||
device_created = "Battery node " + list(current_alarms.keys())[i].split("/")[3]
|
|
||||||
status_message["Alarms"].append(AlarmOrWarning(description, device_created).to_dict())
|
|
||||||
|
|
||||||
if any(changed_warnings.values()):
|
if any(changed_warnings.values()):
|
||||||
for i, changed_warning in enumerate(changed_warnings.values()):
|
for i, changed_warning in enumerate(changed_warnings.values()):
|
||||||
if changed_warning and list(current_warnings.values())[i]:
|
if changed_warning and list(current_warnings.values())[i]:
|
||||||
description = list(current_warnings.keys())[i].split("/")[-1]
|
status_message["Warnings"].append(AlarmOrWarning(list(current_warnings.keys())[i],"System").to_dict())
|
||||||
device_created = "Battery node " + list(current_warnings.keys())[i].split("/")[3]
|
|
||||||
status_message["Warnings"].append(AlarmOrWarning(description, device_created).to_dict())
|
|
||||||
|
|
||||||
if any(current_alarms.values()):
|
if any(current_alarms.values()):
|
||||||
status_message["Status"]=2
|
status_message["Status"]=2
|
||||||
|
@ -845,10 +847,44 @@ def read_warning_and_alarm_flags():
|
||||||
CsvSignal('/Battery/Devices/AlarmFlags/LMPA', c.read_bool(register=1005, bit=45)),
|
CsvSignal('/Battery/Devices/AlarmFlags/LMPA', c.read_bool(register=1005, bit=45)),
|
||||||
CsvSignal('/Battery/Devices/AlarmFlags/HEBT', c.read_bool(register=1005, bit=46)),
|
CsvSignal('/Battery/Devices/AlarmFlags/HEBT', c.read_bool(register=1005, bit=46)),
|
||||||
CsvSignal('/Battery/Devices/AlarmFlags/CURM', c.read_bool(register=1005, bit=48)),
|
CsvSignal('/Battery/Devices/AlarmFlags/CURM', c.read_bool(register=1005, bit=48)),
|
||||||
CsvSignal('/Battery/Devices/AlarmFlags/NeedToReplaceBattery',c.read_limb_string(1059)),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
'''def update_for_testing(modbus, batteries, dbus, signals, csv_signals):
|
||||||
|
global ALLOW
|
||||||
|
logging.debug('starting testing update cycle')
|
||||||
|
warning_signals, alarm_signals = read_warning_and_alarm_flags()
|
||||||
|
current_warnings = {}
|
||||||
|
current_alarms = {}
|
||||||
|
statuses = [read_battery_status(modbus, battery) for battery in batteries]
|
||||||
|
node_numbers = [battery.slave_address for battery in batteries]
|
||||||
|
if ALLOW:
|
||||||
|
any_warning_active = False
|
||||||
|
any_alarm_active = False
|
||||||
|
for i, node in enumerate(node_numbers):
|
||||||
|
for s in warning_signals:
|
||||||
|
signal_name = insert_id(s.name, i+1)
|
||||||
|
value = s.get_value(statuses[i])
|
||||||
|
current_warnings[signal_name] = value
|
||||||
|
if ALLOW and value:
|
||||||
|
any_warning_active = True
|
||||||
|
for s in alarm_signals:
|
||||||
|
signal_name = insert_id(s.name, i+1)
|
||||||
|
value = random.choice([True, False])
|
||||||
|
current_alarms[signal_name] = value
|
||||||
|
if ALLOW and value:
|
||||||
|
any_alarm_active = True
|
||||||
|
print(update_state_from_dictionaries(current_warnings, current_alarms))
|
||||||
|
publish_values(dbus, signals, statuses)
|
||||||
|
create_csv_files(csv_signals, statuses, node_numbers)
|
||||||
|
logging.debug('finished update cycle\n')
|
||||||
|
return True'''
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
def update(modbus, batteries, dbus, signals, csv_signals):
|
def update(modbus, batteries, dbus, signals, csv_signals):
|
||||||
|
global start_time
|
||||||
# type: (Modbus, Iterable[Battery], DBus, Iterable[Signal]) -> bool
|
# type: (Modbus, Iterable[Battery], DBus, Iterable[Signal]) -> bool
|
||||||
"""
|
"""
|
||||||
Main update function
|
Main update function
|
||||||
|
@ -867,17 +903,21 @@ def update(modbus, batteries, dbus, signals, csv_signals):
|
||||||
# Iterate over each node and signal to create rows in the new format
|
# Iterate over each node and signal to create rows in the new format
|
||||||
for i, node in enumerate(node_numbers):
|
for i, node in enumerate(node_numbers):
|
||||||
for s in warnings_signals:
|
for s in warnings_signals:
|
||||||
signal_name = insert_id(s.name, node)
|
signal_name = insert_id(s.name, i+1)
|
||||||
value = s.get_value(statuses[i])
|
value = s.get_value(statuses[i])
|
||||||
current_warnings[signal_name] = value
|
current_warnings[signal_name] = value
|
||||||
for s in alarm_signals:
|
for s in alarm_signals:
|
||||||
signal_name = insert_id(s.name, node)
|
signal_name = insert_id(s.name, i+1)
|
||||||
value = s.get_value(statuses[i])
|
value = s.get_value(statuses[i])
|
||||||
current_alarms[signal_name] = value
|
current_alarms[signal_name] = value
|
||||||
#print(update_state_from_dictionaries(current_warnings, current_alarms))
|
#print(update_state_from_dictionaries(current_warnings, current_alarms))
|
||||||
status_message, alarms_number_list, warnings_number_list = update_state_from_dictionaries(current_warnings, current_alarms, node_numbers)
|
status_message, alarms_number_list, warnings_number_list = update_state_from_dictionaries(current_warnings, current_alarms, node_numbers)
|
||||||
publish_values(dbus, signals, statuses)
|
publish_values(dbus, signals, statuses)
|
||||||
create_csv_files(csv_signals, statuses, node_numbers, alarms_number_list, warnings_number_list)
|
elapsed_time = time.time() - start_time
|
||||||
|
if elapsed_time >= 30:
|
||||||
|
create_csv_files(csv_signals, statuses, node_numbers, alarms_number_list, warnings_number_list)
|
||||||
|
start_time = time.time()
|
||||||
|
print(f"Elapsed time: {elapsed_time:.2f} seconds")
|
||||||
logging.debug('finished update cycle\n')
|
logging.debug('finished update cycle\n')
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@ -942,7 +982,7 @@ def get_installation_name(file_path):
|
||||||
return file.read().strip()
|
return file.read().strip()
|
||||||
|
|
||||||
def manage_csv_files(directory_path, max_files=20):
|
def manage_csv_files(directory_path, max_files=20):
|
||||||
csv_files = [f for f in os.listdir(directory_path)]
|
csv_files = [f for f in os.listdir(directory_path) if os.path.isfile(os.path.join(directory_path, f))]
|
||||||
csv_files.sort(key=lambda x: os.path.getctime(os.path.join(directory_path, x)))
|
csv_files.sort(key=lambda x: os.path.getctime(os.path.join(directory_path, x)))
|
||||||
# Remove oldest files if exceeds maximum
|
# Remove oldest files if exceeds maximum
|
||||||
while len(csv_files) > max_files:
|
while len(csv_files) > max_files:
|
||||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,51 @@
|
||||||
|
import serial
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# dbus configuration
|
||||||
|
|
||||||
|
FIRMWARE_VERSION = 1 # value returned by getValue (getText returns string value reported by battery)
|
||||||
|
HARDWARE_VERSION = 1 # value returned by getValue (getText returns string value reported by battery)
|
||||||
|
|
||||||
|
CONNECTION = 'Modbus RTU'
|
||||||
|
PRODUCT_NAME = 'FZS 48TL200'
|
||||||
|
PRODUCT_ID = 0xB012 # assigned by victron
|
||||||
|
DEVICE_INSTANCE = 1
|
||||||
|
SERVICE_NAME_PREFIX = 'com.victronenergy.battery.'
|
||||||
|
|
||||||
|
|
||||||
|
# driver configuration
|
||||||
|
|
||||||
|
SOFTWARE_VERSION = '3.0.3'
|
||||||
|
UPDATE_INTERVAL = 2000 # milliseconds
|
||||||
|
#LOG_LEVEL = logging.INFO
|
||||||
|
LOG_LEVEL = logging.DEBUG
|
||||||
|
|
||||||
|
# modbus configuration
|
||||||
|
|
||||||
|
BASE_ADDRESS = 999
|
||||||
|
#NO_OF_REGISTERS = 63
|
||||||
|
NO_OF_REGISTERS = 64
|
||||||
|
MAX_SLAVE_ADDRESS = 10
|
||||||
|
|
||||||
|
|
||||||
|
# RS 485 configuration
|
||||||
|
|
||||||
|
PARITY = serial.PARITY_ODD
|
||||||
|
TIMEOUT = 0.1 # seconds
|
||||||
|
BAUD_RATE = 115200
|
||||||
|
BYTE_SIZE = 8
|
||||||
|
STOP_BITS = 1
|
||||||
|
MODE = 'rtu'
|
||||||
|
|
||||||
|
|
||||||
|
# battery configuration
|
||||||
|
|
||||||
|
MAX_CHARGE_VOLTAGE = 58
|
||||||
|
I_MAX_PER_STRING = 15
|
||||||
|
NUM_OF_STRING_PER_BATTERY = 5
|
||||||
|
AH_PER_STRING = 40
|
||||||
|
V_MAX = 54.2
|
||||||
|
R_STRING_MIN = 0.125
|
||||||
|
R_STRING_MAX = 0.250
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
from collections import Iterable
|
||||||
|
from decimal import *
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
from data import LedState, BatteryStatus
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def read_bool(register, bit):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], bool]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> bool
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
return value & (1 << bit) > 0
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_float(register, scale_factor=1.0, offset=0.0, places=2):
|
||||||
|
# type: (int, float, float) -> Callable[[BatteryStatus], float]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> float
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
|
||||||
|
if value >= 0x8000: # convert to signed int16
|
||||||
|
value -= 0x10000 # fiamm stores their integers signed AND with sign-offset @#%^&!
|
||||||
|
|
||||||
|
result = (value+offset)*scale_factor
|
||||||
|
return round(result,places)
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_hex_string(register, count):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], str]
|
||||||
|
"""
|
||||||
|
reads count consecutive modbus registers from start_address,
|
||||||
|
and returns a hex representation of it:
|
||||||
|
e.g. for count=4: DEAD BEEF DEAD BEEF.
|
||||||
|
"""
|
||||||
|
start = register - cfg.BASE_ADDRESS
|
||||||
|
end = start + count
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> str
|
||||||
|
return ' '.join(['{0:0>4X}'.format(x) for x in status.modbus_data[start:end]])
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_led_state(register, led):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], int]
|
||||||
|
|
||||||
|
read_lo = read_bool(register, led * 2)
|
||||||
|
read_hi = read_bool(register, led * 2 + 1)
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
|
||||||
|
lo = read_lo(status)
|
||||||
|
hi = read_hi(status)
|
||||||
|
|
||||||
|
if hi:
|
||||||
|
if lo:
|
||||||
|
return LedState.blinking_fast
|
||||||
|
else:
|
||||||
|
return LedState.blinking_slow
|
||||||
|
else:
|
||||||
|
if lo:
|
||||||
|
return LedState.on
|
||||||
|
else:
|
||||||
|
return LedState.off
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_bitmap(register):
|
||||||
|
# type: (int) -> Callable[[BatteryStatus], bitmap]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> bitmap
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
return value
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def append_unit(unit):
|
||||||
|
# type: (unicode) -> Callable[[unicode], unicode]
|
||||||
|
|
||||||
|
def get_text(v):
|
||||||
|
# type: (unicode) -> unicode
|
||||||
|
return "{0}{1}".format(str(v), unit)
|
||||||
|
|
||||||
|
return get_text
|
||||||
|
|
||||||
|
|
||||||
|
def mean(numbers):
|
||||||
|
# type: (Iterable[float] | Iterable[int]) -> float
|
||||||
|
return float("{:.2f}".format(float(sum(numbers)) / len(numbers)))
|
||||||
|
|
||||||
|
def ssum(numbers):
|
||||||
|
# type: (Iterable[float] | Iterable[int]) -> float
|
||||||
|
return float("{:.2f}".format(float(sum(numbers))))
|
||||||
|
|
||||||
|
|
||||||
|
def first(ts):
|
||||||
|
return next(t for t in ts)
|
||||||
|
|
||||||
|
def return_in_list(ts):
|
||||||
|
return ts
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
import config as cfg
|
||||||
|
from collections import Iterable
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class LedState(object):
|
||||||
|
"""
|
||||||
|
from page 6 of the '48TLxxx ModBus Protocol doc'
|
||||||
|
"""
|
||||||
|
off = 0
|
||||||
|
on = 1
|
||||||
|
blinking_slow = 2
|
||||||
|
blinking_fast = 3
|
||||||
|
|
||||||
|
|
||||||
|
class LedColor(object):
|
||||||
|
green = 0
|
||||||
|
amber = 1
|
||||||
|
blue = 2
|
||||||
|
red = 3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CsvSignal(object):
|
||||||
|
def __init__(self, name, get_value, get_text = None):
|
||||||
|
self.name = name
|
||||||
|
self.get_value = get_value if callable(get_value) else lambda _: get_value
|
||||||
|
self.get_text = get_text
|
||||||
|
|
||||||
|
if get_text is None:
|
||||||
|
self.get_text = ""
|
||||||
|
|
||||||
|
class Battery(object):
|
||||||
|
|
||||||
|
""" Data record to hold hardware and firmware specs of the battery """
|
||||||
|
|
||||||
|
def __init__(self, slave_address, hardware_version, firmware_version, bms_version, ampere_hours):
|
||||||
|
# type: (int, str, str, str, int) -> None
|
||||||
|
self.slave_address = slave_address
|
||||||
|
self.hardware_version = hardware_version
|
||||||
|
self.firmware_version = firmware_version
|
||||||
|
self.bms_version = bms_version
|
||||||
|
self.ampere_hours = ampere_hours
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'slave address = {0}\nhardware version = {1}\nfirmware version = {2}\nbms version = {3}\nampere hours = {4}'.format(
|
||||||
|
self.slave_address, self.hardware_version, self.firmware_version, self.bms_version, str(self.ampere_hours))
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryStatus(object):
|
||||||
|
"""
|
||||||
|
record holding the current status of a battery
|
||||||
|
"""
|
||||||
|
def __init__(self, battery, modbus_data):
|
||||||
|
# type: (Battery, list[int]) -> None
|
||||||
|
|
||||||
|
self.battery = battery
|
||||||
|
self.modbus_data = modbus_data
|
|
@ -0,0 +1,731 @@
|
||||||
|
#! /usr/bin/python3 -u
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
import convert as c
|
||||||
|
|
||||||
|
from pymodbus.register_read_message import ReadInputRegistersResponse
|
||||||
|
from pymodbus.client.sync import ModbusSerialClient as Modbus
|
||||||
|
from pymodbus.other_message import ReportSlaveIdRequest
|
||||||
|
from pymodbus.exceptions import ModbusException
|
||||||
|
from pymodbus.pdu import ExceptionResponse
|
||||||
|
|
||||||
|
from dbus.mainloop.glib import DBusGMainLoop
|
||||||
|
from data import BatteryStatus, Battery, LedColor, CsvSignal, LedState
|
||||||
|
|
||||||
|
from collections import Iterable
|
||||||
|
from os import path
|
||||||
|
|
||||||
|
app_dir = path.dirname(path.realpath(__file__))
|
||||||
|
sys.path.insert(1, path.join(app_dir, 'ext', 'velib_python'))
|
||||||
|
|
||||||
|
#from vedbus import VeDbusService as DBus
|
||||||
|
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
import io
|
||||||
|
|
||||||
|
class S3config:
|
||||||
|
def __init__(self):
|
||||||
|
self.bucket = "1-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||||
|
self.region = "sos-ch-dk-2"
|
||||||
|
self.provider = "exo.io"
|
||||||
|
self.key = "EXOcc0e47a4c4d492888ff5a7f2"
|
||||||
|
self.secret = "79QG4unMh7MeVacMnXr5xGxEyAlWZDIdM-dg_nXFFr4"
|
||||||
|
self.content_type = "text/plain; charset=utf-8"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self):
|
||||||
|
return f"{self.bucket}.{self.region}.{self.provider}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return f"https://{self.host}"
|
||||||
|
|
||||||
|
def create_put_request(self, s3_path, data):
|
||||||
|
headers = self._create_request("PUT", s3_path)
|
||||||
|
url = f"{self.url}/{s3_path}"
|
||||||
|
response = requests.put(url, headers=headers, data=data)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _create_request(self, method, s3_path):
|
||||||
|
date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||||
|
auth = self._create_authorization(method, self.bucket, s3_path, date, self.key, self.secret, self.content_type)
|
||||||
|
headers = {
|
||||||
|
"Host": self.host,
|
||||||
|
"Date": date,
|
||||||
|
"Authorization": auth,
|
||||||
|
"Content-Type": self.content_type
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_authorization(method, bucket, s3_path, date, s3_key, s3_secret, content_type="", md5_hash=""):
|
||||||
|
payload = f"{method}\n{md5_hash}\n{content_type}\n{date}\n/{bucket.strip('/')}/{s3_path.strip('/')}"
|
||||||
|
signature = base64.b64encode(
|
||||||
|
hmac.new(s3_secret.encode(), payload.encode(), hashlib.sha1).digest()
|
||||||
|
).decode()
|
||||||
|
return f"AWS {s3_key}:{signature}"
|
||||||
|
|
||||||
|
def read_csv_as_string(file_path):
|
||||||
|
"""
|
||||||
|
Reads a CSV file from the given path and returns its content as a single string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as file:
|
||||||
|
return file.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: The file {file_path} does not exist.")
|
||||||
|
return None
|
||||||
|
except IOError as e:
|
||||||
|
print(f"IO error occurred: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
CSV_DIR = "/data/csv_files_service/"
|
||||||
|
#CSV_DIR = "csv_files/"
|
||||||
|
|
||||||
|
# Define the path to the file containing the installation name
|
||||||
|
INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name'
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
def interpret_limb_bitmap(bitmap_value):
|
||||||
|
# The bit for string 1 also monitors all 5 strings: 0000 0000 means All 5 strings activated. 0000 0001 means string 1 disabled.
|
||||||
|
string1_disabled = int((bitmap_value & 0b00001) != 0)
|
||||||
|
string2_disabled = int((bitmap_value & 0b00010) != 0)
|
||||||
|
string3_disabled = int((bitmap_value & 0b00100) != 0)
|
||||||
|
string4_disabled = int((bitmap_value & 0b01000) != 0)
|
||||||
|
string5_disabled = int((bitmap_value & 0b10000) != 0)
|
||||||
|
n_limb_strings = string1_disabled+string2_disabled+string3_disabled+string4_disabled+string5_disabled
|
||||||
|
return n_limb_strings
|
||||||
|
|
||||||
|
def create_csv_signals(firmware_version):
|
||||||
|
def read_power(status):
|
||||||
|
return int(read_current(status) * read_voltage(status))
|
||||||
|
|
||||||
|
read_voltage = c.read_float(register=999, scale_factor=0.01, offset=0, places=2)
|
||||||
|
read_current = c.read_float(register=1000, scale_factor=0.01, offset=-10000, places=2)
|
||||||
|
|
||||||
|
read_limb_bitmap = c.read_bitmap(1059)
|
||||||
|
|
||||||
|
def string1_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b00001) != 0)
|
||||||
|
|
||||||
|
def string2_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b00010) != 0)
|
||||||
|
|
||||||
|
def string3_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b00100) != 0)
|
||||||
|
|
||||||
|
def string4_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b01000) != 0)
|
||||||
|
|
||||||
|
def string5_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b10000) != 0)
|
||||||
|
|
||||||
|
|
||||||
|
def limp_strings_value(status):
|
||||||
|
return interpret_limb_bitmap(read_limb_bitmap(status))
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
dv = v_limit - v
|
||||||
|
di = dv / r_int
|
||||||
|
p_limit = v_limit * (i + di)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_current_limit(v, i, i_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
di = i_limit - i
|
||||||
|
dv = di * r_int
|
||||||
|
p_limit = i_limit * (v + dv)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_max_charge_power(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status)
|
||||||
|
i_max = n_strings * cfg.I_MAX_PER_STRING
|
||||||
|
v_max = cfg.V_MAX
|
||||||
|
r_int_min = cfg.R_STRING_MIN / n_strings
|
||||||
|
r_int_max = cfg.R_STRING_MAX / n_strings
|
||||||
|
|
||||||
|
v = read_voltage(status)
|
||||||
|
i = read_current(status)
|
||||||
|
|
||||||
|
p_limits = [
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, v_max,r_int_min),
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_max),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_min),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_max),
|
||||||
|
]
|
||||||
|
|
||||||
|
p_limit = min(p_limits) # p_limit is normally positive here (signed)
|
||||||
|
p_limit = max(p_limit, 0) # charge power must not become negative
|
||||||
|
|
||||||
|
return int(p_limit)
|
||||||
|
|
||||||
|
def calc_max_discharge_power(status):
|
||||||
|
n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status)
|
||||||
|
max_discharge_current = n_strings*cfg.I_MAX_PER_STRING
|
||||||
|
return int(max_discharge_current*read_voltage(status))
|
||||||
|
|
||||||
|
def return_led_state_blue(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.blue)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def return_led_state_red(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.red)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def return_led_state_green(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.green)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def return_led_state_amber(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.amber)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
total_current = c.read_float(register=1062, scale_factor=0.01, offset=-10000, places=1)
|
||||||
|
|
||||||
|
def read_total_current(status):
|
||||||
|
return total_current(status)
|
||||||
|
|
||||||
|
def read_heating_current(status):
|
||||||
|
return total_current(status) - read_current(status)
|
||||||
|
|
||||||
|
def read_heating_power(status):
|
||||||
|
return read_voltage(status) * read_heating_current(status)
|
||||||
|
|
||||||
|
soc_ah = c.read_float(register=1002, scale_factor=0.1, offset=-10000, places=1)
|
||||||
|
|
||||||
|
def read_soc_ah(status):
|
||||||
|
return soc_ah(status)
|
||||||
|
|
||||||
|
def hex_string_to_ascii(hex_string):
|
||||||
|
# Ensure the hex_string is correctly formatted without spaces
|
||||||
|
hex_string = hex_string.replace(" ", "")
|
||||||
|
# Convert every two characters (a byte) in the hex string to ASCII
|
||||||
|
ascii_string = ''.join([chr(int(hex_string[i:i+2], 16)) for i in range(0, len(hex_string), 2)])
|
||||||
|
return ascii_string
|
||||||
|
|
||||||
|
battery_status_reader = c.read_hex_string(1060,2)
|
||||||
|
|
||||||
|
def read_eoc_reached(status):
|
||||||
|
battery_status_string = battery_status_reader(status)
|
||||||
|
#if hex_string_to_ascii(battery_status_string) == "EOC_":
|
||||||
|
#return True
|
||||||
|
#return False
|
||||||
|
return hex_string_to_ascii(battery_status_string) == "EOC_"
|
||||||
|
|
||||||
|
def read_serial_number(status):
|
||||||
|
|
||||||
|
serial_regs = [1055, 1056, 1057, 1058]
|
||||||
|
serial_parts = []
|
||||||
|
|
||||||
|
for reg in serial_regs:
|
||||||
|
# reading each register as a single hex value
|
||||||
|
hex_value_fun = c.read_hex_string(reg, 1)
|
||||||
|
hex_value = hex_value_fun(status)
|
||||||
|
|
||||||
|
# append without spaces and leading zeros stripped if any
|
||||||
|
serial_parts.append(hex_value.replace(' ', ''))
|
||||||
|
|
||||||
|
# concatenate all parts to form the full serial number
|
||||||
|
serial_number = ''.join(serial_parts).rstrip('0')
|
||||||
|
|
||||||
|
return serial_number
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/FwVersion', firmware_version),
|
||||||
|
CsvSignal('/Battery/Devices/Dc/Power', read_power, 'W'),
|
||||||
|
CsvSignal('/Battery/Devices/Dc/Voltage', read_voltage, 'V'),
|
||||||
|
CsvSignal('/Battery/Devices/Soc', c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), '%'),
|
||||||
|
CsvSignal('/Battery/Devices/Temperatures/Cells/Average', c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), 'C'),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/Dc/Current', read_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/BusCurrent', read_total_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/CellsCurrent', read_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/HeatingCurrent', read_heating_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/HeatingPower', read_heating_power, 'W'),
|
||||||
|
CsvSignal('/Battery/Devices/SOCAh', read_soc_ah),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Blue', return_led_state_blue),
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Red', return_led_state_red),
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Green', return_led_state_green),
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Amber', return_led_state_amber),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String1Active', string1_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String2Active', string2_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String3Active', string3_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String4Active', string4_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String5Active', string5_disabled),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/ConnectedToDcBus', c.read_bool(register=1013, bit=0)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/AlarmOutActive', c.read_bool(register=1013, bit=1)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/InternalFanActive', c.read_bool(register=1013, bit=2)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/VoltMeasurementAllowed', c.read_bool(register=1013, bit=3)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/AuxRelayBus', c.read_bool(register=1013, bit=4)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/RemoteStateActive', c.read_bool(register=1013, bit=5)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/RiscActive', c.read_bool(register=1013, bit=6)),
|
||||||
|
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/Eoc', read_eoc_reached),
|
||||||
|
CsvSignal('/Battery/Devices/SerialNumber', read_serial_number),
|
||||||
|
CsvSignal('/Battery/Devices/TimeSinceTOC', c.read_float(register=1052)),
|
||||||
|
CsvSignal('/Battery/Devices/MaxChargePower', calc_max_charge_power),
|
||||||
|
CsvSignal('/Battery/Devices/MaxDischargePower', calc_max_discharge_power),
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TaM1', c.read_bool(register=1005, bit=1)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TbM1', c.read_bool(register=1005, bit=4)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/VBm1', c.read_bool(register=1005, bit=6)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/VBM1', c.read_bool(register=1005, bit=8)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/IDM1', c.read_bool(register=1005, bit=10)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/vsm1', c.read_bool(register=1005, bit=22)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/vsM1', c.read_bool(register=1005, bit=24)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/iCM1', c.read_bool(register=1005, bit=26)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/iDM1', c.read_bool(register=1005, bit=28)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/MID1', c.read_bool(register=1005, bit=30)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/BLPW', c.read_bool(register=1005, bit=32)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/CCBF', c.read_bool(register=1005, bit=33)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/Ah_W', c.read_bool(register=1005, bit=35)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/MPMM', c.read_bool(register=1005, bit=38)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TCdi', c.read_bool(register=1005, bit=40)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/LMPW', c.read_bool(register=1005, bit=44)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TOCW', c.read_bool(register=1005, bit=47)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/BUSL', c.read_bool(register=1005, bit=49)),
|
||||||
|
|
||||||
|
# Alarms
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/Tam', c.read_bool(register=1005, bit=0)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/TaM2', c.read_bool(register=1005, bit=2)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/Tbm', c.read_bool(register=1005, bit=3)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/TbM2', c.read_bool(register=1005, bit=5)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/VBm2', c.read_bool(register=1005, bit=7)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/VBM2', c.read_bool(register=1005, bit=9)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/IDM2', c.read_bool(register=1005, bit=11)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/ISOB', c.read_bool(register=1005, bit=12)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/MSWE', c.read_bool(register=1005, bit=13)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/FUSE', c.read_bool(register=1005, bit=14)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HTRE', c.read_bool(register=1005, bit=15)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/TCPE', c.read_bool(register=1005, bit=16)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/STRE', c.read_bool(register=1005, bit=17)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/CME', c.read_bool(register=1005, bit=18)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HWFL', c.read_bool(register=1005, bit=19)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HWEM', c.read_bool(register=1005, bit=20)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/ThM', c.read_bool(register=1005, bit=21)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/vsm2', c.read_bool(register=1005, bit=23)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/vsM2', c.read_bool(register=1005, bit=25)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/iCM2', c.read_bool(register=1005, bit=27)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/iDM2', c.read_bool(register=1005, bit=29)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/MID2', c.read_bool(register=1005, bit=31)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HTFS', c.read_bool(register=1005, bit=42)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/DATA', c.read_bool(register=1005, bit=43)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/LMPA', c.read_bool(register=1005, bit=45)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HEBT', c.read_bool(register=1005, bit=46)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/CURM', c.read_bool(register=1005, bit=48)),
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
def init_modbus(tty):
|
||||||
|
# type: (str) -> Modbus
|
||||||
|
|
||||||
|
logging.debug('initializing Modbus')
|
||||||
|
|
||||||
|
return Modbus(
|
||||||
|
port='/dev/' + tty,
|
||||||
|
method=cfg.MODE,
|
||||||
|
baudrate=cfg.BAUD_RATE,
|
||||||
|
stopbits=cfg.STOP_BITS,
|
||||||
|
bytesize=cfg.BYTE_SIZE,
|
||||||
|
timeout=cfg.TIMEOUT,
|
||||||
|
parity=cfg.PARITY)
|
||||||
|
|
||||||
|
def read_modbus_registers(modbus, slave_address, base_address=cfg.BASE_ADDRESS, count=cfg.NO_OF_REGISTERS):
|
||||||
|
# type: (Modbus, int) -> ReadInputRegistersResponse
|
||||||
|
|
||||||
|
logging.debug('requesting modbus registers {0}-{1}'.format(base_address, base_address + count))
|
||||||
|
|
||||||
|
return modbus.read_input_registers(
|
||||||
|
address=base_address,
|
||||||
|
count=count,
|
||||||
|
unit=slave_address)
|
||||||
|
|
||||||
|
def read_firmware_version(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> str
|
||||||
|
|
||||||
|
logging.debug('reading firmware version')
|
||||||
|
|
||||||
|
try:
|
||||||
|
modbus.connect()
|
||||||
|
|
||||||
|
response = read_modbus_registers(modbus, slave_address, base_address=1054, count=1)
|
||||||
|
register = response.registers[0]
|
||||||
|
|
||||||
|
return '{0:0>4X}'.format(register)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
modbus.close() # close in any case
|
||||||
|
|
||||||
|
def init_main_loop():
|
||||||
|
# type: () -> DBusGMainLoop
|
||||||
|
logging.debug('initializing DBusGMainLoop Loop')
|
||||||
|
DBusGMainLoop(set_as_default=True)
|
||||||
|
return GLib.MainLoop()
|
||||||
|
|
||||||
|
def report_slave_id(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> str
|
||||||
|
|
||||||
|
slave = str(slave_address)
|
||||||
|
|
||||||
|
logging.debug('requesting slave id from node ' + slave)
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
modbus.connect()
|
||||||
|
|
||||||
|
request = ReportSlaveIdRequest(unit=slave_address)
|
||||||
|
response = modbus.execute(request)
|
||||||
|
|
||||||
|
if response is ExceptionResponse or issubclass(type(response), ModbusException):
|
||||||
|
raise Exception('failed to get slave id from ' + slave + ' : ' + str(response))
|
||||||
|
|
||||||
|
return response.identifier
|
||||||
|
|
||||||
|
finally:
|
||||||
|
modbus.close()
|
||||||
|
|
||||||
|
def parse_slave_id(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> (str, str, int)
|
||||||
|
|
||||||
|
slave_id = report_slave_id(modbus, slave_address)
|
||||||
|
|
||||||
|
sid = re.sub(b'[^\x20-\x7E]', b'', slave_id) # remove weird special chars
|
||||||
|
|
||||||
|
match = re.match('(?P<hw>48TL(?P<ah>\d+)) *(?P<bms>.*)', sid.decode('ascii'))
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
raise Exception('no known battery found')
|
||||||
|
|
||||||
|
return match.group('hw'), match.group('bms'), int(match.group('ah'))
|
||||||
|
|
||||||
|
|
||||||
|
def identify_battery(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> Battery
|
||||||
|
|
||||||
|
logging.info('identifying battery...')
|
||||||
|
|
||||||
|
hardware_version, bms_version, ampere_hours = parse_slave_id(modbus, slave_address)
|
||||||
|
firmware_version = read_firmware_version(modbus, slave_address)
|
||||||
|
|
||||||
|
specs = Battery(
|
||||||
|
slave_address=slave_address,
|
||||||
|
hardware_version=hardware_version,
|
||||||
|
firmware_version=firmware_version,
|
||||||
|
bms_version=bms_version,
|
||||||
|
ampere_hours=ampere_hours)
|
||||||
|
|
||||||
|
logging.info('battery identified:\n{0}'.format(str(specs)))
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
def identify_batteries(modbus):
|
||||||
|
# type: (Modbus) -> list[Battery]
|
||||||
|
|
||||||
|
def _identify_batteries():
|
||||||
|
address_range = range(1, cfg.MAX_SLAVE_ADDRESS + 1)
|
||||||
|
|
||||||
|
for slave_address in address_range:
|
||||||
|
try:
|
||||||
|
yield identify_battery(modbus, slave_address)
|
||||||
|
except Exception as e:
|
||||||
|
logging.info('failed to identify battery at {0} : {1}'.format(str(slave_address), str(e)))
|
||||||
|
|
||||||
|
return list(_identify_batteries()) # force that lazy iterable!
|
||||||
|
|
||||||
|
def read_modbus_registers(modbus, slave_address, base_address=cfg.BASE_ADDRESS, count=cfg.NO_OF_REGISTERS):
|
||||||
|
# type: (Modbus, int) -> ReadInputRegistersResponse
|
||||||
|
|
||||||
|
logging.debug('requesting modbus registers {0}-{1}'.format(base_address, base_address + count))
|
||||||
|
|
||||||
|
return modbus.read_input_registers(
|
||||||
|
address=base_address,
|
||||||
|
count=count,
|
||||||
|
unit=slave_address)
|
||||||
|
|
||||||
|
def read_battery_status(modbus, battery):
|
||||||
|
# type: (Modbus, Battery) -> BatteryStatus
|
||||||
|
"""
|
||||||
|
Read the modbus registers containing the battery's status info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging.debug('reading battery status')
|
||||||
|
|
||||||
|
try:
|
||||||
|
modbus.connect()
|
||||||
|
data = read_modbus_registers(modbus, battery.slave_address)
|
||||||
|
return BatteryStatus(battery, data.registers)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
modbus.close() # close in any case
|
||||||
|
|
||||||
|
def get_installation_name(file_path):
|
||||||
|
with open(file_path, 'r') as file:
|
||||||
|
return file.read().strip()
|
||||||
|
|
||||||
|
def manage_csv_files(directory_path, max_files=20):
|
||||||
|
csv_files = [f for f in os.listdir(directory_path)]
|
||||||
|
csv_files.sort(key=lambda x: os.path.getctime(os.path.join(directory_path, x)))
|
||||||
|
|
||||||
|
# Remove oldest files if exceeds maximum
|
||||||
|
while len(csv_files) > max_files:
|
||||||
|
file_to_delete = os.path.join(directory_path, csv_files.pop(0))
|
||||||
|
os.remove(file_to_delete)
|
||||||
|
|
||||||
|
def serialize_for_csv(value):
|
||||||
|
if isinstance(value, (dict, list, tuple)):
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def insert_id(path, id_number):
|
||||||
|
parts = path.split("/")
|
||||||
|
|
||||||
|
insert_position = parts.index("Devices") + 1
|
||||||
|
|
||||||
|
parts.insert(insert_position, str(id_number))
|
||||||
|
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
def create_csv_files(signals, statuses, node_numbers):
|
||||||
|
timestamp = int(time.time())
|
||||||
|
if timestamp % 2 != 0:
|
||||||
|
timestamp -= 1
|
||||||
|
# Create CSV directory if it doesn't exist
|
||||||
|
if not os.path.exists(CSV_DIR):
|
||||||
|
os.makedirs(CSV_DIR)
|
||||||
|
|
||||||
|
#installation_name = get_installation_name(INSTALLATION_NAME_FILE)
|
||||||
|
csv_filename = f"{timestamp}.csv"
|
||||||
|
csv_path = os.path.join(CSV_DIR, csv_filename)
|
||||||
|
|
||||||
|
# Append values to the CSV file
|
||||||
|
with open(csv_path, 'a', newline='') as csvfile:
|
||||||
|
csv_writer = csv.writer(csvfile, delimiter=';')
|
||||||
|
|
||||||
|
# Add a special row for the nodes configuration
|
||||||
|
nodes_config_path = "/Config/Devices/BatteryNodes"
|
||||||
|
nodes_list = ",".join(str(node) for node in node_numbers)
|
||||||
|
config_row = [nodes_config_path, nodes_list, ""]
|
||||||
|
csv_writer.writerow(config_row)
|
||||||
|
|
||||||
|
# Iterate over each node and signal to create rows in the new format
|
||||||
|
for i, node in enumerate(node_numbers):
|
||||||
|
for s in signals:
|
||||||
|
signal_name = insert_id(s.name, i+1)
|
||||||
|
#value = serialize_for_csv(s.get_value(statuses[i]))
|
||||||
|
value = s.get_value(statuses[i])
|
||||||
|
row_values = [signal_name, value, s.get_text]
|
||||||
|
csv_writer.writerow(row_values)
|
||||||
|
|
||||||
|
# Manage CSV files, keep a limited number of files
|
||||||
|
|
||||||
|
# Create the CSV as a string
|
||||||
|
csv_data = read_csv_as_string(csv_path)
|
||||||
|
|
||||||
|
|
||||||
|
# Create an S3config instance
|
||||||
|
s3_config = S3config()
|
||||||
|
response = s3_config.create_put_request(csv_filename, csv_data)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
os.remove(csv_path)
|
||||||
|
print("Success")
|
||||||
|
else:
|
||||||
|
failed_dir = os.path.join(CSV_DIR, "failed")
|
||||||
|
if not os.path.exists(failed_dir):
|
||||||
|
os.makedirs(failed_dir)
|
||||||
|
failed_path = os.path.join(failed_dir, csv_filename)
|
||||||
|
os.rename(csv_path, failed_path)
|
||||||
|
print("Uploading failed")
|
||||||
|
manage_csv_files(failed_dir, 10)
|
||||||
|
|
||||||
|
|
||||||
|
manage_csv_files(CSV_DIR)
|
||||||
|
|
||||||
|
def update(modbus, batteries, csv_signals):
|
||||||
|
# type: (Modbus, Iterable[Battery], DBus, Iterable[Signal]) -> bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
Main update function
|
||||||
|
|
||||||
|
1. requests status record each battery via modbus,
|
||||||
|
2. parses the data using Signal.get_value
|
||||||
|
3. aggregates the data from all batteries into one datum using Signal.aggregate
|
||||||
|
4. publishes the data on the dbus
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging.debug('starting update cycle')
|
||||||
|
|
||||||
|
statuses = [read_battery_status(modbus, battery) for battery in batteries]
|
||||||
|
node_numbers = [battery.slave_address for battery in batteries]
|
||||||
|
|
||||||
|
create_csv_files(csv_signals, statuses, node_numbers)
|
||||||
|
|
||||||
|
logging.debug('finished update cycle\n')
|
||||||
|
return True
|
||||||
|
|
||||||
|
def print_usage():
|
||||||
|
print ('Usage: ' + __file__ + ' <serial device>')
|
||||||
|
print ('Example: ' + __file__ + ' ttyUSB0')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cmdline_args(argv):
|
||||||
|
# type: (list[str]) -> str
|
||||||
|
|
||||||
|
if len(argv) == 0:
|
||||||
|
logging.info('missing command line argument for tty device')
|
||||||
|
print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return argv[0]
|
||||||
|
|
||||||
|
|
||||||
|
alive = True # global alive flag, watchdog_task clears it, update_task sets it
|
||||||
|
|
||||||
|
def create_update_task(modbus, batteries, csv_signals, main_loop):
|
||||||
|
# type: (Modbus, DBus, Iterable[Battery], Iterable[Signal], DBusGMainLoop) -> Callable[[],bool]
|
||||||
|
"""
|
||||||
|
Creates an update task which runs the main update function
|
||||||
|
and resets the alive flag
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update_task():
|
||||||
|
# type: () -> bool
|
||||||
|
|
||||||
|
global alive
|
||||||
|
|
||||||
|
alive = update(modbus, batteries, csv_signals)
|
||||||
|
|
||||||
|
if not alive:
|
||||||
|
logging.info('update_task: quitting main loop because of error')
|
||||||
|
main_loop.quit()
|
||||||
|
|
||||||
|
return alive
|
||||||
|
|
||||||
|
return update_task
|
||||||
|
|
||||||
|
def create_watchdog_task(main_loop):
|
||||||
|
# type: (DBusGMainLoop) -> Callable[[],bool]
|
||||||
|
"""
|
||||||
|
Creates a Watchdog task that monitors the alive flag.
|
||||||
|
The watchdog kills the main loop if the alive flag is not periodically reset by the update task.
|
||||||
|
Who watches the watchdog?
|
||||||
|
"""
|
||||||
|
def watchdog_task():
|
||||||
|
# type: () -> bool
|
||||||
|
|
||||||
|
global alive
|
||||||
|
|
||||||
|
if alive:
|
||||||
|
logging.debug('watchdog_task: update_task is alive')
|
||||||
|
alive = False
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.info('watchdog_task: killing main loop because update_task is no longer alive')
|
||||||
|
main_loop.quit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
return watchdog_task
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
# type: (list[str]) -> ()
|
||||||
|
print("PAME")
|
||||||
|
logging.basicConfig(level=cfg.LOG_LEVEL)
|
||||||
|
logging.info('starting ' + __file__)
|
||||||
|
|
||||||
|
tty = parse_cmdline_args(argv)
|
||||||
|
modbus = init_modbus(tty)
|
||||||
|
|
||||||
|
batteries = identify_batteries(modbus)
|
||||||
|
|
||||||
|
n = len(batteries)
|
||||||
|
|
||||||
|
logging.info('found ' + str(n) + (' battery' if n == 1 else ' batteries'))
|
||||||
|
|
||||||
|
if n <= 0:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
bat = c.first(batteries) # report hw and fw version of first battery found
|
||||||
|
|
||||||
|
csv_signals = create_csv_signals(bat.firmware_version)
|
||||||
|
|
||||||
|
main_loop = init_main_loop() # must run before init_dbus because gobject does some global magic
|
||||||
|
|
||||||
|
# we do not use dbus this time. we only want modbus
|
||||||
|
update_task = create_update_task(modbus, batteries, csv_signals, main_loop)
|
||||||
|
watchdog_task = create_watchdog_task(main_loop)
|
||||||
|
|
||||||
|
GLib.timeout_add(cfg.UPDATE_INTERVAL * 2, watchdog_task) # add watchdog first
|
||||||
|
GLib.timeout_add(cfg.UPDATE_INTERVAL, update_task) # call update once every update_interval
|
||||||
|
|
||||||
|
logging.info('starting GLib.MainLoop')
|
||||||
|
main_loop.run()
|
||||||
|
logging.info('GLib.MainLoop was shut down')
|
||||||
|
|
||||||
|
sys.exit(0xFF) # reaches this only on error
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
. /opt/victronenergy/serial-starter/run-service.sh
|
||||||
|
|
||||||
|
app=/opt/victronenergy/dbus-csv-files/dbus-csv-files.py
|
||||||
|
args="$tty"
|
||||||
|
start $args
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,51 @@
|
||||||
|
import serial
|
||||||
|
import logging
|
||||||
|
|
||||||
|
# dbus configuration
|
||||||
|
|
||||||
|
FIRMWARE_VERSION = 1 # value returned by getValue (getText returns string value reported by battery)
|
||||||
|
HARDWARE_VERSION = 1 # value returned by getValue (getText returns string value reported by battery)
|
||||||
|
|
||||||
|
CONNECTION = 'Modbus RTU'
|
||||||
|
PRODUCT_NAME = 'FZS 48TL200'
|
||||||
|
PRODUCT_ID = 0xB012 # assigned by victron
|
||||||
|
DEVICE_INSTANCE = 1
|
||||||
|
SERVICE_NAME_PREFIX = 'com.victronenergy.battery.'
|
||||||
|
|
||||||
|
|
||||||
|
# driver configuration
|
||||||
|
|
||||||
|
SOFTWARE_VERSION = '3.0.3'
|
||||||
|
UPDATE_INTERVAL = 2000 # milliseconds
|
||||||
|
#LOG_LEVEL = logging.INFO
|
||||||
|
LOG_LEVEL = logging.DEBUG
|
||||||
|
|
||||||
|
# modbus configuration
|
||||||
|
|
||||||
|
BASE_ADDRESS = 999
|
||||||
|
#NO_OF_REGISTERS = 63
|
||||||
|
NO_OF_REGISTERS = 64
|
||||||
|
MAX_SLAVE_ADDRESS = 10
|
||||||
|
|
||||||
|
|
||||||
|
# RS 485 configuration
|
||||||
|
|
||||||
|
PARITY = serial.PARITY_ODD
|
||||||
|
TIMEOUT = 0.1 # seconds
|
||||||
|
BAUD_RATE = 115200
|
||||||
|
BYTE_SIZE = 8
|
||||||
|
STOP_BITS = 1
|
||||||
|
MODE = 'rtu'
|
||||||
|
|
||||||
|
|
||||||
|
# battery configuration
|
||||||
|
|
||||||
|
MAX_CHARGE_VOLTAGE = 58
|
||||||
|
I_MAX_PER_STRING = 15
|
||||||
|
NUM_OF_STRING_PER_BATTERY = 5
|
||||||
|
AH_PER_STRING = 40
|
||||||
|
V_MAX = 54.2
|
||||||
|
R_STRING_MIN = 0.125
|
||||||
|
R_STRING_MAX = 0.250
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
from collections import Iterable
|
||||||
|
from decimal import *
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
from data import LedState, BatteryStatus
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
def read_bool(register, bit):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], bool]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> bool
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
return value & (1 << bit) > 0
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_float(register, scale_factor=1.0, offset=0.0, places=2):
|
||||||
|
# type: (int, float, float) -> Callable[[BatteryStatus], float]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> float
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
|
||||||
|
if value >= 0x8000: # convert to signed int16
|
||||||
|
value -= 0x10000 # fiamm stores their integers signed AND with sign-offset @#%^&!
|
||||||
|
|
||||||
|
result = (value+offset)*scale_factor
|
||||||
|
return round(result,places)
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_hex_string(register, count):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], str]
|
||||||
|
"""
|
||||||
|
reads count consecutive modbus registers from start_address,
|
||||||
|
and returns a hex representation of it:
|
||||||
|
e.g. for count=4: DEAD BEEF DEAD BEEF.
|
||||||
|
"""
|
||||||
|
start = register - cfg.BASE_ADDRESS
|
||||||
|
end = start + count
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> str
|
||||||
|
return ' '.join(['{0:0>4X}'.format(x) for x in status.modbus_data[start:end]])
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_led_state(register, led):
|
||||||
|
# type: (int, int) -> Callable[[BatteryStatus], int]
|
||||||
|
|
||||||
|
read_lo = read_bool(register, led * 2)
|
||||||
|
read_hi = read_bool(register, led * 2 + 1)
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
|
||||||
|
lo = read_lo(status)
|
||||||
|
hi = read_hi(status)
|
||||||
|
|
||||||
|
if hi:
|
||||||
|
if lo:
|
||||||
|
return LedState.blinking_fast
|
||||||
|
else:
|
||||||
|
return LedState.blinking_slow
|
||||||
|
else:
|
||||||
|
if lo:
|
||||||
|
return LedState.on
|
||||||
|
else:
|
||||||
|
return LedState.off
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def read_bitmap(register):
|
||||||
|
# type: (int) -> Callable[[BatteryStatus], bitmap]
|
||||||
|
|
||||||
|
def get_value(status):
|
||||||
|
# type: (BatteryStatus) -> bitmap
|
||||||
|
value = status.modbus_data[register - cfg.BASE_ADDRESS]
|
||||||
|
return value
|
||||||
|
|
||||||
|
return get_value
|
||||||
|
|
||||||
|
|
||||||
|
def append_unit(unit):
|
||||||
|
# type: (unicode) -> Callable[[unicode], unicode]
|
||||||
|
|
||||||
|
def get_text(v):
|
||||||
|
# type: (unicode) -> unicode
|
||||||
|
return "{0}{1}".format(str(v), unit)
|
||||||
|
|
||||||
|
return get_text
|
||||||
|
|
||||||
|
|
||||||
|
def mean(numbers):
|
||||||
|
# type: (Iterable[float] | Iterable[int]) -> float
|
||||||
|
return float("{:.2f}".format(float(sum(numbers)) / len(numbers)))
|
||||||
|
|
||||||
|
def ssum(numbers):
|
||||||
|
# type: (Iterable[float] | Iterable[int]) -> float
|
||||||
|
return float("{:.2f}".format(float(sum(numbers))))
|
||||||
|
|
||||||
|
|
||||||
|
def first(ts):
|
||||||
|
return next(t for t in ts)
|
||||||
|
|
||||||
|
def return_in_list(ts):
|
||||||
|
return ts
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
import config as cfg
|
||||||
|
from collections import Iterable
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class LedState(object):
|
||||||
|
"""
|
||||||
|
from page 6 of the '48TLxxx ModBus Protocol doc'
|
||||||
|
"""
|
||||||
|
off = 0
|
||||||
|
on = 1
|
||||||
|
blinking_slow = 2
|
||||||
|
blinking_fast = 3
|
||||||
|
|
||||||
|
|
||||||
|
class LedColor(object):
|
||||||
|
green = 0
|
||||||
|
amber = 1
|
||||||
|
blue = 2
|
||||||
|
red = 3
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CsvSignal(object):
|
||||||
|
def __init__(self, name, get_value, get_text = None):
|
||||||
|
self.name = name
|
||||||
|
self.get_value = get_value if callable(get_value) else lambda _: get_value
|
||||||
|
self.get_text = get_text
|
||||||
|
|
||||||
|
if get_text is None:
|
||||||
|
self.get_text = ""
|
||||||
|
|
||||||
|
class Signal(object):
|
||||||
|
|
||||||
|
def __init__(self, dbus_path, aggregate, get_value, get_text=None):
|
||||||
|
# type: (str, Callable[[Iterable[object]],object], Callable[[BatteryStatus],object] | object, Callable[[object],unicode] | object)->None
|
||||||
|
"""
|
||||||
|
A Signal holds all information necessary for the handling of a
|
||||||
|
certain datum (e.g. voltage) published by the battery.
|
||||||
|
|
||||||
|
:param dbus_path: str
|
||||||
|
object_path on DBus where the datum needs to be published
|
||||||
|
|
||||||
|
:param aggregate: Iterable[object] -> object
|
||||||
|
function that combines the values of multiple batteries into one.
|
||||||
|
e.g. sum for currents, or mean for voltages
|
||||||
|
|
||||||
|
:param get_value: (BatteryStatus) -> object
|
||||||
|
function to extract the datum from the modbus record,
|
||||||
|
alternatively: a constant
|
||||||
|
|
||||||
|
:param get_text: (object) -> unicode [optional]
|
||||||
|
function to render datum to text, needed by DBus
|
||||||
|
alternatively: a constant
|
||||||
|
"""
|
||||||
|
|
||||||
|
self.dbus_path = dbus_path
|
||||||
|
self.aggregate = aggregate
|
||||||
|
self.get_value = get_value if callable(get_value) else lambda _: get_value
|
||||||
|
self.get_text = get_text if callable(get_text) else lambda _: str(get_text)
|
||||||
|
|
||||||
|
# if no 'get_text' provided use 'default_text' if available, otherwise str()
|
||||||
|
if get_text is None:
|
||||||
|
self.get_text = str
|
||||||
|
|
||||||
|
|
||||||
|
class Battery(object):
|
||||||
|
|
||||||
|
""" Data record to hold hardware and firmware specs of the battery """
|
||||||
|
|
||||||
|
def __init__(self, slave_address, hardware_version, firmware_version, bms_version, ampere_hours):
|
||||||
|
# type: (int, str, str, str, int) -> None
|
||||||
|
self.slave_address = slave_address
|
||||||
|
self.hardware_version = hardware_version
|
||||||
|
self.firmware_version = firmware_version
|
||||||
|
self.bms_version = bms_version
|
||||||
|
self.ampere_hours = ampere_hours
|
||||||
|
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return 'slave address = {0}\nhardware version = {1}\nfirmware version = {2}\nbms version = {3}\nampere hours = {4}'.format(
|
||||||
|
self.slave_address, self.hardware_version, self.firmware_version, self.bms_version, str(self.ampere_hours))
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryStatus(object):
|
||||||
|
"""
|
||||||
|
record holding the current status of a battery
|
||||||
|
"""
|
||||||
|
def __init__(self, battery, modbus_data):
|
||||||
|
# type: (Battery, list[int]) -> None
|
||||||
|
|
||||||
|
self.battery = battery
|
||||||
|
self.modbus_data = modbus_data
|
|
@ -0,0 +1,980 @@
|
||||||
|
#!/usr/bin/python3 -u
|
||||||
|
# coding=utf-8
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
from gi.repository import GLib
|
||||||
|
|
||||||
|
import config as cfg
|
||||||
|
import convert as c
|
||||||
|
|
||||||
|
from pymodbus.register_read_message import ReadInputRegistersResponse
|
||||||
|
from pymodbus.client.sync import ModbusSerialClient as Modbus
|
||||||
|
from pymodbus.other_message import ReportSlaveIdRequest
|
||||||
|
from pymodbus.exceptions import ModbusException
|
||||||
|
from pymodbus.pdu import ExceptionResponse
|
||||||
|
|
||||||
|
from dbus.mainloop.glib import DBusGMainLoop
|
||||||
|
from data import BatteryStatus, Signal, Battery, LedColor, CsvSignal, LedState
|
||||||
|
|
||||||
|
from collections import Iterable
|
||||||
|
from os import path
|
||||||
|
|
||||||
|
app_dir = path.dirname(path.realpath(__file__))
|
||||||
|
sys.path.insert(1, path.join(app_dir, 'ext', 'velib_python'))
|
||||||
|
|
||||||
|
from vedbus import VeDbusService as DBus
|
||||||
|
|
||||||
|
import time
|
||||||
|
import os
|
||||||
|
import csv
|
||||||
|
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import hmac
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
import io
|
||||||
|
|
||||||
|
class S3config:
|
||||||
|
def __init__(self):
|
||||||
|
self.bucket = "1-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||||
|
self.region = "sos-ch-dk-2"
|
||||||
|
self.provider = "exo.io"
|
||||||
|
self.key = "EXOcc0e47a4c4d492888ff5a7f2"
|
||||||
|
self.secret = "79QG4unMh7MeVacMnXr5xGxEyAlWZDIdM-dg_nXFFr4"
|
||||||
|
self.content_type = "text/plain; charset=utf-8"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def host(self):
|
||||||
|
return f"{self.bucket}.{self.region}.{self.provider}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
return f"https://{self.host}"
|
||||||
|
|
||||||
|
def create_put_request(self, s3_path, data):
|
||||||
|
headers = self._create_request("PUT", s3_path)
|
||||||
|
url = f"{self.url}/{s3_path}"
|
||||||
|
response = requests.put(url, headers=headers, data=data)
|
||||||
|
return response
|
||||||
|
|
||||||
|
def _create_request(self, method, s3_path):
|
||||||
|
date = datetime.utcnow().strftime('%a, %d %b %Y %H:%M:%S GMT')
|
||||||
|
auth = self._create_authorization(method, self.bucket, s3_path, date, self.key, self.secret, self.content_type)
|
||||||
|
headers = {
|
||||||
|
"Host": self.host,
|
||||||
|
"Date": date,
|
||||||
|
"Authorization": auth,
|
||||||
|
"Content-Type": self.content_type
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_authorization(method, bucket, s3_path, date, s3_key, s3_secret, content_type="", md5_hash=""):
|
||||||
|
payload = f"{method}\n{md5_hash}\n{content_type}\n{date}\n/{bucket.strip('/')}/{s3_path.strip('/')}"
|
||||||
|
signature = base64.b64encode(
|
||||||
|
hmac.new(s3_secret.encode(), payload.encode(), hashlib.sha1).digest()
|
||||||
|
).decode()
|
||||||
|
return f"AWS {s3_key}:{signature}"
|
||||||
|
|
||||||
|
def read_csv_as_string(file_path):
|
||||||
|
"""
|
||||||
|
Reads a CSV file from the given path and returns its content as a single string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as file:
|
||||||
|
return file.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"Error: The file {file_path} does not exist.")
|
||||||
|
return None
|
||||||
|
except IOError as e:
|
||||||
|
print(f"IO error occurred: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
CSV_DIR = "/data/csv_files/"
|
||||||
|
#CSV_DIR = "csv_files/"
|
||||||
|
|
||||||
|
# Define the path to the file containing the installation name
|
||||||
|
INSTALLATION_NAME_FILE = '/data/innovenergy/openvpn/installation-name'
|
||||||
|
|
||||||
|
|
||||||
|
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
||||||
|
# noinspection PyUnreachableCode
|
||||||
|
if False:
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
def interpret_limb_bitmap(bitmap_value):
|
||||||
|
# The bit for string 1 also monitors all 5 strings: 0000 0000 means All 5 strings activated. 0000 0001 means string 1 disabled.
|
||||||
|
string1_disabled = int((bitmap_value & 0b00001) != 0)
|
||||||
|
string2_disabled = int((bitmap_value & 0b00010) != 0)
|
||||||
|
string3_disabled = int((bitmap_value & 0b00100) != 0)
|
||||||
|
string4_disabled = int((bitmap_value & 0b01000) != 0)
|
||||||
|
string5_disabled = int((bitmap_value & 0b10000) != 0)
|
||||||
|
n_limb_strings = string1_disabled+string2_disabled+string3_disabled+string4_disabled+string5_disabled
|
||||||
|
return n_limb_strings
|
||||||
|
|
||||||
|
def create_csv_signals(firmware_version):
|
||||||
|
def read_power(status):
|
||||||
|
return int(read_current(status) * read_voltage(status))
|
||||||
|
|
||||||
|
read_voltage = c.read_float(register=999, scale_factor=0.01, offset=0, places=2)
|
||||||
|
read_current = c.read_float(register=1000, scale_factor=0.01, offset=-10000, places=2)
|
||||||
|
|
||||||
|
read_limb_bitmap = c.read_bitmap(1059)
|
||||||
|
|
||||||
|
def string1_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b00001) != 0)
|
||||||
|
|
||||||
|
def string2_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b00010) != 0)
|
||||||
|
|
||||||
|
def string3_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b00100) != 0)
|
||||||
|
|
||||||
|
def string4_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b01000) != 0)
|
||||||
|
|
||||||
|
def string5_disabled(status):
|
||||||
|
bitmap_value = read_limb_bitmap(status)
|
||||||
|
return int((bitmap_value & 0b10000) != 0)
|
||||||
|
|
||||||
|
|
||||||
|
def limp_strings_value(status):
|
||||||
|
return interpret_limb_bitmap(read_limb_bitmap(status))
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
dv = v_limit - v
|
||||||
|
di = dv / r_int
|
||||||
|
p_limit = v_limit * (i + di)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_current_limit(v, i, i_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
di = i_limit - i
|
||||||
|
dv = di * r_int
|
||||||
|
p_limit = i_limit * (v + dv)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_max_charge_power(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status)
|
||||||
|
i_max = n_strings * cfg.I_MAX_PER_STRING
|
||||||
|
v_max = cfg.V_MAX
|
||||||
|
r_int_min = cfg.R_STRING_MIN / n_strings
|
||||||
|
r_int_max = cfg.R_STRING_MAX / n_strings
|
||||||
|
|
||||||
|
v = read_voltage(status)
|
||||||
|
i = read_current(status)
|
||||||
|
|
||||||
|
p_limits = [
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, v_max,r_int_min),
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_max),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_min),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_max),
|
||||||
|
]
|
||||||
|
|
||||||
|
p_limit = min(p_limits) # p_limit is normally positive here (signed)
|
||||||
|
p_limit = max(p_limit, 0) # charge power must not become negative
|
||||||
|
|
||||||
|
return int(p_limit)
|
||||||
|
|
||||||
|
def calc_max_discharge_power(status):
|
||||||
|
n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status)
|
||||||
|
max_discharge_current = n_strings*cfg.I_MAX_PER_STRING
|
||||||
|
return int(max_discharge_current*read_voltage(status))
|
||||||
|
|
||||||
|
def return_led_state_blue(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.blue)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def return_led_state_red(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.red)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def return_led_state_green(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.green)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
def return_led_state_amber(status):
|
||||||
|
led_state = c.read_led_state(register=1004, led=LedColor.amber)(status)
|
||||||
|
if led_state == LedState.blinking_fast or led_state == LedState.blinking_slow:
|
||||||
|
return "Blinking"
|
||||||
|
elif led_state == LedState.on:
|
||||||
|
return "On"
|
||||||
|
elif led_state == LedState.off:
|
||||||
|
return "Off"
|
||||||
|
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
total_current = c.read_float(register=1062, scale_factor=0.01, offset=-10000, places=1)
|
||||||
|
|
||||||
|
def read_total_current(status):
|
||||||
|
return total_current(status)
|
||||||
|
|
||||||
|
def read_heating_current(status):
|
||||||
|
return total_current(status) - read_current(status)
|
||||||
|
|
||||||
|
def read_heating_power(status):
|
||||||
|
return read_voltage(status) * read_heating_current(status)
|
||||||
|
|
||||||
|
soc_ah = c.read_float(register=1002, scale_factor=0.1, offset=-10000, places=1)
|
||||||
|
|
||||||
|
def read_soc_ah(status):
|
||||||
|
return soc_ah(status)
|
||||||
|
|
||||||
|
def hex_string_to_ascii(hex_string):
|
||||||
|
# Ensure the hex_string is correctly formatted without spaces
|
||||||
|
hex_string = hex_string.replace(" ", "")
|
||||||
|
# Convert every two characters (a byte) in the hex string to ASCII
|
||||||
|
ascii_string = ''.join([chr(int(hex_string[i:i+2], 16)) for i in range(0, len(hex_string), 2)])
|
||||||
|
return ascii_string
|
||||||
|
|
||||||
|
battery_status_reader = c.read_hex_string(1060,2)
|
||||||
|
|
||||||
|
def read_eoc_reached(status):
|
||||||
|
battery_status_string = battery_status_reader(status)
|
||||||
|
#if hex_string_to_ascii(battery_status_string) == "EOC_":
|
||||||
|
#return True
|
||||||
|
#return False
|
||||||
|
return hex_string_to_ascii(battery_status_string) == "EOC_"
|
||||||
|
|
||||||
|
def read_serial_number(status):
|
||||||
|
|
||||||
|
serial_regs = [1055, 1056, 1057, 1058]
|
||||||
|
serial_parts = []
|
||||||
|
|
||||||
|
for reg in serial_regs:
|
||||||
|
# reading each register as a single hex value
|
||||||
|
hex_value_fun = c.read_hex_string(reg, 1)
|
||||||
|
hex_value = hex_value_fun(status)
|
||||||
|
|
||||||
|
# append without spaces and leading zeros stripped if any
|
||||||
|
serial_parts.append(hex_value.replace(' ', ''))
|
||||||
|
|
||||||
|
# concatenate all parts to form the full serial number
|
||||||
|
serial_number = ''.join(serial_parts).rstrip('0')
|
||||||
|
|
||||||
|
return serial_number
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/FwVersion', firmware_version),
|
||||||
|
CsvSignal('/Battery/Devices/Dc/Power', read_power, 'W'),
|
||||||
|
CsvSignal('/Battery/Devices/Dc/Voltage', read_voltage, 'V'),
|
||||||
|
CsvSignal('/Battery/Devices/Soc', c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), '%'),
|
||||||
|
CsvSignal('/Battery/Devices/Temperatures/Cells/Average', c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), 'C'),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/Dc/Current', read_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/BusCurrent', read_total_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/CellsCurrent', read_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/HeatingCurrent', read_heating_current, 'A'),
|
||||||
|
CsvSignal('/Battery/Devices/HeatingPower', read_heating_power, 'W'),
|
||||||
|
CsvSignal('/Battery/Devices/SOCAh', read_soc_ah),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Blue', return_led_state_blue),
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Red', return_led_state_red),
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Green', return_led_state_green),
|
||||||
|
CsvSignal('/Battery/Devices/Leds/Amber', return_led_state_amber),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String1Active', string1_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String2Active', string2_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String3Active', string3_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String4Active', string4_disabled),
|
||||||
|
CsvSignal('/Battery/Devices/BatteryStrings/String5Active', string5_disabled),
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/ConnectedToDcBus', c.read_bool(register=1013, bit=0)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/AlarmOutActive', c.read_bool(register=1013, bit=1)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/InternalFanActive', c.read_bool(register=1013, bit=2)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/VoltMeasurementAllowed', c.read_bool(register=1013, bit=3)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/AuxRelayBus', c.read_bool(register=1013, bit=4)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/RemoteStateActive', c.read_bool(register=1013, bit=5)),
|
||||||
|
CsvSignal('/Battery/Devices/IoStatus/RiscActive', c.read_bool(register=1013, bit=6)),
|
||||||
|
|
||||||
|
|
||||||
|
CsvSignal('/Battery/Devices/Eoc', read_eoc_reached),
|
||||||
|
CsvSignal('/Battery/Devices/SerialNumber', read_serial_number),
|
||||||
|
CsvSignal('/Battery/Devices/TimeSinceTOC', c.read_float(register=1052)),
|
||||||
|
CsvSignal('/Battery/Devices/MaxChargePower', calc_max_charge_power),
|
||||||
|
CsvSignal('/Battery/Devices/MaxDischargePower', calc_max_discharge_power),
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TaM1', c.read_bool(register=1005, bit=1)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TbM1', c.read_bool(register=1005, bit=4)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/VBm1', c.read_bool(register=1005, bit=6)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/VBM1', c.read_bool(register=1005, bit=8)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/IDM1', c.read_bool(register=1005, bit=10)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/vsm1', c.read_bool(register=1005, bit=22)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/vsM1', c.read_bool(register=1005, bit=24)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/iCM1', c.read_bool(register=1005, bit=26)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/iDM1', c.read_bool(register=1005, bit=28)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/MID1', c.read_bool(register=1005, bit=30)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/BLPW', c.read_bool(register=1005, bit=32)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/CCBF', c.read_bool(register=1005, bit=33)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/Ah_W', c.read_bool(register=1005, bit=35)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/MPMM', c.read_bool(register=1005, bit=38)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TCdi', c.read_bool(register=1005, bit=40)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/LMPW', c.read_bool(register=1005, bit=44)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/TOCW', c.read_bool(register=1005, bit=47)),
|
||||||
|
CsvSignal('/Battery/Devices/WarningFlags/BUSL', c.read_bool(register=1005, bit=49)),
|
||||||
|
|
||||||
|
# Alarms
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/Tam', c.read_bool(register=1005, bit=0)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/TaM2', c.read_bool(register=1005, bit=2)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/Tbm', c.read_bool(register=1005, bit=3)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/TbM2', c.read_bool(register=1005, bit=5)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/VBm2', c.read_bool(register=1005, bit=7)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/VBM2', c.read_bool(register=1005, bit=9)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/IDM2', c.read_bool(register=1005, bit=11)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/ISOB', c.read_bool(register=1005, bit=12)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/MSWE', c.read_bool(register=1005, bit=13)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/FUSE', c.read_bool(register=1005, bit=14)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HTRE', c.read_bool(register=1005, bit=15)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/TCPE', c.read_bool(register=1005, bit=16)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/STRE', c.read_bool(register=1005, bit=17)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/CME', c.read_bool(register=1005, bit=18)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HWFL', c.read_bool(register=1005, bit=19)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HWEM', c.read_bool(register=1005, bit=20)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/ThM', c.read_bool(register=1005, bit=21)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/vsm2', c.read_bool(register=1005, bit=23)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/vsM2', c.read_bool(register=1005, bit=25)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/iCM2', c.read_bool(register=1005, bit=27)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/iDM2', c.read_bool(register=1005, bit=29)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/MID2', c.read_bool(register=1005, bit=31)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HTFS', c.read_bool(register=1005, bit=42)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/DATA', c.read_bool(register=1005, bit=43)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/LMPA', c.read_bool(register=1005, bit=45)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/HEBT', c.read_bool(register=1005, bit=46)),
|
||||||
|
CsvSignal('/Battery/Devices/AlarmFlags/CURM', c.read_bool(register=1005, bit=48)),
|
||||||
|
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def init_signals(hardware_version, firmware_version, n_batteries):
|
||||||
|
# type: (str,str,int) -> Iterable[Signal]
|
||||||
|
"""
|
||||||
|
A Signal holds all information necessary for the handling of a
|
||||||
|
certain datum (e.g. voltage) published by the battery.
|
||||||
|
|
||||||
|
Signal(dbus_path, aggregate, get_value, get_text = str)
|
||||||
|
|
||||||
|
dbus_path: str
|
||||||
|
object_path on DBus where the datum needs to be published
|
||||||
|
|
||||||
|
aggregate: Iterable[object] -> object
|
||||||
|
function that combines the values of multiple batteries into one.
|
||||||
|
e.g. sum for currents, or mean for voltages
|
||||||
|
|
||||||
|
get_value: (BatteryStatus) -> object [optional]
|
||||||
|
function to extract the datum from the modbus record,
|
||||||
|
alternatively: a constant
|
||||||
|
|
||||||
|
get_text: (object) -> unicode [optional]
|
||||||
|
function to render datum to text, needed by DBus
|
||||||
|
alternatively: a constant
|
||||||
|
|
||||||
|
|
||||||
|
The conversion functions use the same parameters (e.g scale_factor, offset)
|
||||||
|
as described in the document 'T48TLxxx ModBus Protocol Rev.7.1' which can
|
||||||
|
be found in the /doc folder
|
||||||
|
"""
|
||||||
|
|
||||||
|
product_id_hex = '0x{0:04x}'.format(cfg.PRODUCT_ID)
|
||||||
|
|
||||||
|
read_voltage = c.read_float(register=999, scale_factor=0.01, offset=0, places=2)
|
||||||
|
read_current = c.read_float(register=1000, scale_factor=0.01, offset=-10000, places=2)
|
||||||
|
|
||||||
|
def read_power(status):
|
||||||
|
return int(read_current(status) * read_voltage(status))
|
||||||
|
|
||||||
|
read_limb_bitmap = c.read_bitmap(1059)
|
||||||
|
def limp_strings_value(status):
|
||||||
|
return interpret_limb_bitmap(read_limb_bitmap(status))
|
||||||
|
|
||||||
|
def max_discharge_current(status):
|
||||||
|
return (cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status))*cfg.I_MAX_PER_STRING
|
||||||
|
|
||||||
|
def max_charge_current(status):
|
||||||
|
return status.battery.ampere_hours/2
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
dv = v_limit - v
|
||||||
|
di = dv / r_int
|
||||||
|
p_limit = v_limit * (i + di)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_power_limit_imposed_by_current_limit(v, i, i_limit, r_int):
|
||||||
|
# type: (float, float, float, float) -> float
|
||||||
|
|
||||||
|
di = i_limit - i
|
||||||
|
dv = di * r_int
|
||||||
|
p_limit = i_limit * (v + dv)
|
||||||
|
|
||||||
|
return p_limit
|
||||||
|
|
||||||
|
def calc_max_charge_power(status):
|
||||||
|
# type: (BatteryStatus) -> int
|
||||||
|
n_strings = cfg.NUM_OF_STRING_PER_BATTERY-limp_strings_value(status)
|
||||||
|
i_max = n_strings * cfg.I_MAX_PER_STRING
|
||||||
|
v_max = cfg.V_MAX
|
||||||
|
r_int_min = cfg.R_STRING_MIN / n_strings
|
||||||
|
r_int_max = cfg.R_STRING_MAX / n_strings
|
||||||
|
|
||||||
|
v = read_voltage(status)
|
||||||
|
i = read_current(status)
|
||||||
|
|
||||||
|
p_limits = [
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, v_max,r_int_min),
|
||||||
|
calc_power_limit_imposed_by_voltage_limit(v, i, v_max, r_int_max),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_min),
|
||||||
|
calc_power_limit_imposed_by_current_limit(v, i, i_max, r_int_max),
|
||||||
|
]
|
||||||
|
|
||||||
|
p_limit = min(p_limits) # p_limit is normally positive here (signed)
|
||||||
|
p_limit = max(p_limit, 0) # charge power must not become negative
|
||||||
|
|
||||||
|
return int(p_limit)
|
||||||
|
|
||||||
|
product_name = cfg.PRODUCT_NAME
|
||||||
|
if n_batteries > 1:
|
||||||
|
product_name = cfg.PRODUCT_NAME + ' x' + str(n_batteries)
|
||||||
|
|
||||||
|
return [
|
||||||
|
# Node Red related dbus paths
|
||||||
|
Signal('/TimeToTOCRequest', min, c.read_float(register=1052)),
|
||||||
|
Signal('/NumOfLimbStrings', c.return_in_list, get_value=limp_strings_value),
|
||||||
|
Signal('/NumOfBatteries', max, get_value=n_batteries),
|
||||||
|
Signal('/Dc/0/Voltage', c.mean, get_value=read_voltage, get_text=c.append_unit('V')),
|
||||||
|
Signal('/Dc/0/Current', c.ssum, get_value=read_current, get_text=c.append_unit('A')),
|
||||||
|
Signal('/Dc/0/Power', c.ssum, get_value=read_power, get_text=c.append_unit('W')),
|
||||||
|
|
||||||
|
Signal('/BussVoltage', c.mean, c.read_float(register=1001, scale_factor=0.01, offset=0, places=2), c.append_unit('V')),
|
||||||
|
Signal('/Soc', c.mean, c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), c.append_unit('%')),
|
||||||
|
Signal('/LowestSoc', min, c.read_float(register=1053, scale_factor=0.1, offset=0, places=1), c.append_unit('%')),
|
||||||
|
Signal('/Dc/0/Temperature', c.mean, c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), c.append_unit(u'°C')),
|
||||||
|
Signal('/Dc/0/LowestTemperature', min, c.read_float(register=1003, scale_factor=0.1, offset=-400, places=1), c.append_unit(u'°C')),
|
||||||
|
|
||||||
|
# Charge/Discharge current, voltage and power
|
||||||
|
Signal('/Info/MaxDischargeCurrent', c.ssum, max_discharge_current,c.append_unit('A')),
|
||||||
|
Signal('/Info/MaxChargeCurrent', c.ssum, max_charge_current, c.append_unit('A')),
|
||||||
|
Signal('/Info/MaxChargeVoltage', min, cfg.MAX_CHARGE_VOLTAGE, c.append_unit('V')),
|
||||||
|
Signal('/Info/MaxChargePower', c.ssum, calc_max_charge_power),
|
||||||
|
|
||||||
|
# Victron mandatory dbus paths
|
||||||
|
Signal('/Mgmt/ProcessName', c.first, __file__),
|
||||||
|
Signal('/Mgmt/ProcessVersion', c.first, cfg.SOFTWARE_VERSION),
|
||||||
|
Signal('/Mgmt/Connection', c.first, cfg.CONNECTION),
|
||||||
|
Signal('/DeviceInstance', c.first, cfg.DEVICE_INSTANCE),
|
||||||
|
Signal('/ProductName', c.first, product_name),
|
||||||
|
Signal('/ProductId', c.first, cfg.PRODUCT_ID, product_id_hex),
|
||||||
|
Signal('/Connected', c.first, 1),
|
||||||
|
#Signal('/FirmwareVersion', c.first, cfg.FIRMWARE_VERSION, firmware_version),
|
||||||
|
Signal('/FirmwareVersion', c.return_in_list, firmware_version),
|
||||||
|
Signal('/HardwareVersion', c.first, cfg.HARDWARE_VERSION, hardware_version),
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
Signal('/Diagnostics/BmsVersion', c.first, lambda s: s.battery.bms_version),
|
||||||
|
|
||||||
|
# Warnings
|
||||||
|
#Signal('/Diagnostics/WarningFlags', c.first, c.read_hex_string(register=1005, count=4)),
|
||||||
|
Signal('/WarningFlags/TaM1', c.return_in_list, c.read_bool(register=1005, bit=1)),
|
||||||
|
Signal('/WarningFlags/TbM1', c.return_in_list, c.read_bool(register=1005, bit=4)),
|
||||||
|
Signal('/WarningFlags/VBm1', c.return_in_list, c.read_bool(register=1005, bit=6)),
|
||||||
|
Signal('/WarningFlags/VBM1', c.return_in_list, c.read_bool(register=1005, bit=8)),
|
||||||
|
Signal('/WarningFlags/IDM1', c.return_in_list, c.read_bool(register=1005, bit=10)),
|
||||||
|
Signal('/WarningFlags/vsm1', c.return_in_list, c.read_bool(register=1005, bit=22)),
|
||||||
|
Signal('/WarningFlags/vsM1', c.return_in_list, c.read_bool(register=1005, bit=24)),
|
||||||
|
Signal('/WarningFlags/iCM1', c.return_in_list, c.read_bool(register=1005, bit=26)),
|
||||||
|
Signal('/WarningFlags/iDM1', c.return_in_list, c.read_bool(register=1005, bit=28)),
|
||||||
|
Signal('/WarningFlags/MID1', c.return_in_list, c.read_bool(register=1005, bit=30)),
|
||||||
|
Signal('/WarningFlags/BLPW', c.return_in_list, c.read_bool(register=1005, bit=32)),
|
||||||
|
Signal('/WarningFlags/CCBF', c.return_in_list, c.read_bool(register=1005, bit=33)),
|
||||||
|
Signal('/WarningFlags/Ah_W', c.return_in_list, c.read_bool(register=1005, bit=35)),
|
||||||
|
Signal('/WarningFlags/MPMM', c.return_in_list, c.read_bool(register=1005, bit=38)),
|
||||||
|
#Signal('/WarningFlags/TCMM', c.return_in_list, c.read_bool(register=1005, bit=39)),
|
||||||
|
Signal('/WarningFlags/TCdi', c.return_in_list, c.read_bool(register=1005, bit=40)),
|
||||||
|
Signal('/WarningFlags/LMPW', c.return_in_list, c.read_bool(register=1005, bit=44)),
|
||||||
|
Signal('/WarningFlags/TOCW', c.return_in_list, c.read_bool(register=1005, bit=47)),
|
||||||
|
Signal('/WarningFlags/BUSL', c.return_in_list, c.read_bool(register=1005, bit=49)),
|
||||||
|
|
||||||
|
# Alarms
|
||||||
|
#Signal('/Diagnostics/AlarmFlags', c.first, c.read_hex_string(register=1009, count=4)),
|
||||||
|
Signal('/AlarmFlags/Tam', c.return_in_list, c.read_bool(register=1005, bit=0)),
|
||||||
|
Signal('/AlarmFlags/TaM2', c.return_in_list, c.read_bool(register=1005, bit=2)),
|
||||||
|
Signal('/AlarmFlags/Tbm', c.return_in_list, c.read_bool(register=1005, bit=3)),
|
||||||
|
Signal('/AlarmFlags/TbM2', c.return_in_list, c.read_bool(register=1005, bit=5)),
|
||||||
|
Signal('/AlarmFlags/VBm2', c.return_in_list, c.read_bool(register=1005, bit=7)),
|
||||||
|
Signal('/AlarmFlags/VBM2', c.return_in_list, c.read_bool(register=1005, bit=9)),
|
||||||
|
Signal('/AlarmFlags/IDM2', c.return_in_list, c.read_bool(register=1005, bit=11)),
|
||||||
|
Signal('/AlarmFlags/ISOB', c.return_in_list, c.read_bool(register=1005, bit=12)),
|
||||||
|
Signal('/AlarmFlags/MSWE', c.return_in_list, c.read_bool(register=1005, bit=13)),
|
||||||
|
Signal('/AlarmFlags/FUSE', c.return_in_list, c.read_bool(register=1005, bit=14)),
|
||||||
|
Signal('/AlarmFlags/HTRE', c.return_in_list, c.read_bool(register=1005, bit=15)),
|
||||||
|
Signal('/AlarmFlags/TCPE', c.return_in_list, c.read_bool(register=1005, bit=16)),
|
||||||
|
Signal('/AlarmFlags/STRE', c.return_in_list, c.read_bool(register=1005, bit=17)),
|
||||||
|
Signal('/AlarmFlags/CME', c.return_in_list, c.read_bool(register=1005, bit=18)),
|
||||||
|
Signal('/AlarmFlags/HWFL', c.return_in_list, c.read_bool(register=1005, bit=19)),
|
||||||
|
Signal('/AlarmFlags/HWEM', c.return_in_list, c.read_bool(register=1005, bit=20)),
|
||||||
|
Signal('/AlarmFlags/ThM', c.return_in_list, c.read_bool(register=1005, bit=21)),
|
||||||
|
Signal('/AlarmFlags/vsm2', c.return_in_list, c.read_bool(register=1005, bit=23)),
|
||||||
|
Signal('/AlarmFlags/vsM2', c.return_in_list, c.read_bool(register=1005, bit=25)),
|
||||||
|
Signal('/AlarmFlags/iCM2', c.return_in_list, c.read_bool(register=1005, bit=27)),
|
||||||
|
Signal('/AlarmFlags/iDM2', c.return_in_list, c.read_bool(register=1005, bit=29)),
|
||||||
|
Signal('/AlarmFlags/MID2', c.return_in_list, c.read_bool(register=1005, bit=31)),
|
||||||
|
#Signal('/AlarmFlags/TcBM', c.return_in_list, c.read_bool(register=1005, bit=36)),
|
||||||
|
#Signal('/AlarmFlags/BRNF', c.return_in_list, c.read_bool(register=1005, bit=37)),
|
||||||
|
Signal('/AlarmFlags/HTFS', c.return_in_list, c.read_bool(register=1005, bit=42)),
|
||||||
|
Signal('/AlarmFlags/DATA', c.return_in_list, c.read_bool(register=1005, bit=43)),
|
||||||
|
Signal('/AlarmFlags/LMPA', c.return_in_list, c.read_bool(register=1005, bit=45)),
|
||||||
|
Signal('/AlarmFlags/HEBT', c.return_in_list, c.read_bool(register=1005, bit=46)),
|
||||||
|
Signal('/AlarmFlags/CURM', c.return_in_list, c.read_bool(register=1005, bit=48)),
|
||||||
|
|
||||||
|
# LedStatus
|
||||||
|
Signal('/Diagnostics/LedStatus/Red', c.first, c.read_led_state(register=1004, led=LedColor.red)),
|
||||||
|
Signal('/Diagnostics/LedStatus/Blue', c.first, c.read_led_state(register=1004, led=LedColor.blue)),
|
||||||
|
Signal('/Diagnostics/LedStatus/Green', c.first, c.read_led_state(register=1004, led=LedColor.green)),
|
||||||
|
Signal('/Diagnostics/LedStatus/Amber', c.first, c.read_led_state(register=1004, led=LedColor.amber)),
|
||||||
|
|
||||||
|
# IO Status
|
||||||
|
Signal('/Diagnostics/IoStatus/MainSwitchClosed', c.return_in_list, c.read_bool(register=1013, bit=0)),
|
||||||
|
Signal('/Diagnostics/IoStatus/AlarmOutActive', c.return_in_list, c.read_bool(register=1013, bit=1)),
|
||||||
|
Signal('/Diagnostics/IoStatus/InternalFanActive', c.return_in_list, c.read_bool(register=1013, bit=2)),
|
||||||
|
Signal('/Diagnostics/IoStatus/VoltMeasurementAllowed', c.return_in_list, c.read_bool(register=1013, bit=3)),
|
||||||
|
Signal('/Diagnostics/IoStatus/AuxRelay', c.return_in_list, c.read_bool(register=1013, bit=4)),
|
||||||
|
Signal('/Diagnostics/IoStatus/RemoteState', c.return_in_list, c.read_bool(register=1013, bit=5)),
|
||||||
|
Signal('/Diagnostics/IoStatus/RiscOn', c.return_in_list, c.read_bool(register=1013, bit=6)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def init_modbus(tty):
|
||||||
|
# type: (str) -> Modbus
|
||||||
|
|
||||||
|
logging.debug('initializing Modbus')
|
||||||
|
|
||||||
|
return Modbus(
|
||||||
|
port='/dev/' + tty,
|
||||||
|
method=cfg.MODE,
|
||||||
|
baudrate=cfg.BAUD_RATE,
|
||||||
|
stopbits=cfg.STOP_BITS,
|
||||||
|
bytesize=cfg.BYTE_SIZE,
|
||||||
|
timeout=cfg.TIMEOUT,
|
||||||
|
parity=cfg.PARITY)
|
||||||
|
|
||||||
|
|
||||||
|
def init_dbus(tty, signals):
|
||||||
|
# type: (str, Iterable[Signal]) -> DBus
|
||||||
|
|
||||||
|
logging.debug('initializing DBus service')
|
||||||
|
dbus = DBus(servicename=cfg.SERVICE_NAME_PREFIX + tty)
|
||||||
|
|
||||||
|
logging.debug('initializing DBus paths')
|
||||||
|
for signal in signals:
|
||||||
|
init_dbus_path(dbus, signal)
|
||||||
|
|
||||||
|
return dbus
|
||||||
|
|
||||||
|
|
||||||
|
# noinspection PyBroadException
|
||||||
|
def try_get_value(sig):
|
||||||
|
# type: (Signal) -> object
|
||||||
|
try:
|
||||||
|
return sig.get_value(None)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def init_dbus_path(dbus, sig):
|
||||||
|
# type: (DBus, Signal) -> ()
|
||||||
|
|
||||||
|
dbus.add_path(
|
||||||
|
sig.dbus_path,
|
||||||
|
try_get_value(sig),
|
||||||
|
gettextcallback=lambda _, v: sig.get_text(v))
|
||||||
|
|
||||||
|
|
||||||
|
def init_main_loop():
|
||||||
|
# type: () -> DBusGMainLoop
|
||||||
|
logging.debug('initializing DBusGMainLoop Loop')
|
||||||
|
DBusGMainLoop(set_as_default=True)
|
||||||
|
return GLib.MainLoop()
|
||||||
|
|
||||||
|
|
||||||
|
def report_slave_id(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> str
|
||||||
|
|
||||||
|
slave = str(slave_address)
|
||||||
|
|
||||||
|
logging.debug('requesting slave id from node ' + slave)
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
modbus.connect()
|
||||||
|
|
||||||
|
request = ReportSlaveIdRequest(unit=slave_address)
|
||||||
|
response = modbus.execute(request)
|
||||||
|
|
||||||
|
if response is ExceptionResponse or issubclass(type(response), ModbusException):
|
||||||
|
raise Exception('failed to get slave id from ' + slave + ' : ' + str(response))
|
||||||
|
|
||||||
|
return response.identifier
|
||||||
|
|
||||||
|
finally:
|
||||||
|
modbus.close()
|
||||||
|
|
||||||
|
|
||||||
|
def identify_battery(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> Battery
|
||||||
|
|
||||||
|
logging.info('identifying battery...')
|
||||||
|
|
||||||
|
hardware_version, bms_version, ampere_hours = parse_slave_id(modbus, slave_address)
|
||||||
|
firmware_version = read_firmware_version(modbus, slave_address)
|
||||||
|
|
||||||
|
specs = Battery(
|
||||||
|
slave_address=slave_address,
|
||||||
|
hardware_version=hardware_version,
|
||||||
|
firmware_version=firmware_version,
|
||||||
|
bms_version=bms_version,
|
||||||
|
ampere_hours=ampere_hours)
|
||||||
|
|
||||||
|
logging.info('battery identified:\n{0}'.format(str(specs)))
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def identify_batteries(modbus):
|
||||||
|
# type: (Modbus) -> list[Battery]
|
||||||
|
|
||||||
|
def _identify_batteries():
|
||||||
|
address_range = range(1, cfg.MAX_SLAVE_ADDRESS + 1)
|
||||||
|
|
||||||
|
for slave_address in address_range:
|
||||||
|
try:
|
||||||
|
yield identify_battery(modbus, slave_address)
|
||||||
|
except Exception as e:
|
||||||
|
logging.info('failed to identify battery at {0} : {1}'.format(str(slave_address), str(e)))
|
||||||
|
|
||||||
|
return list(_identify_batteries()) # force that lazy iterable!
|
||||||
|
|
||||||
|
|
||||||
|
def parse_slave_id(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> (str, str, int)
|
||||||
|
|
||||||
|
slave_id = report_slave_id(modbus, slave_address)
|
||||||
|
|
||||||
|
sid = re.sub(b'[^\x20-\x7E]', b'', slave_id) # remove weird special chars
|
||||||
|
|
||||||
|
match = re.match('(?P<hw>48TL(?P<ah>\d+)) *(?P<bms>.*)', sid.decode('ascii'))
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
raise Exception('no known battery found')
|
||||||
|
|
||||||
|
return match.group('hw'), match.group('bms'), int(match.group('ah'))
|
||||||
|
|
||||||
|
|
||||||
|
def read_firmware_version(modbus, slave_address):
|
||||||
|
# type: (Modbus, int) -> str
|
||||||
|
|
||||||
|
logging.debug('reading firmware version')
|
||||||
|
|
||||||
|
try:
|
||||||
|
modbus.connect()
|
||||||
|
|
||||||
|
response = read_modbus_registers(modbus, slave_address, base_address=1054, count=1)
|
||||||
|
register = response.registers[0]
|
||||||
|
|
||||||
|
return '{0:0>4X}'.format(register)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
modbus.close() # close in any case
|
||||||
|
|
||||||
|
|
||||||
|
def read_modbus_registers(modbus, slave_address, base_address=cfg.BASE_ADDRESS, count=cfg.NO_OF_REGISTERS):
|
||||||
|
# type: (Modbus, int) -> ReadInputRegistersResponse
|
||||||
|
|
||||||
|
logging.debug('requesting modbus registers {0}-{1}'.format(base_address, base_address + count))
|
||||||
|
|
||||||
|
return modbus.read_input_registers(
|
||||||
|
address=base_address,
|
||||||
|
count=count,
|
||||||
|
unit=slave_address)
|
||||||
|
|
||||||
|
|
||||||
|
def read_battery_status(modbus, battery):
|
||||||
|
# type: (Modbus, Battery) -> BatteryStatus
|
||||||
|
"""
|
||||||
|
Read the modbus registers containing the battery's status info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging.debug('reading battery status')
|
||||||
|
|
||||||
|
try:
|
||||||
|
modbus.connect()
|
||||||
|
data = read_modbus_registers(modbus, battery.slave_address)
|
||||||
|
return BatteryStatus(battery, data.registers)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
modbus.close() # close in any case
|
||||||
|
|
||||||
|
|
||||||
|
def publish_values(dbus, signals, statuses):
|
||||||
|
# type: (DBus, Iterable[Signal], Iterable[BatteryStatus]) -> ()
|
||||||
|
|
||||||
|
for s in signals:
|
||||||
|
values = [s.get_value(status) for status in statuses]
|
||||||
|
with dbus as srv:
|
||||||
|
srv[s.dbus_path] = s.aggregate(values)
|
||||||
|
|
||||||
|
|
||||||
|
def update(modbus, batteries, dbus, signals, csv_signals):
|
||||||
|
# type: (Modbus, Iterable[Battery], DBus, Iterable[Signal]) -> bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
Main update function
|
||||||
|
|
||||||
|
1. requests status record each battery via modbus,
|
||||||
|
2. parses the data using Signal.get_value
|
||||||
|
3. aggregates the data from all batteries into one datum using Signal.aggregate
|
||||||
|
4. publishes the data on the dbus
|
||||||
|
"""
|
||||||
|
|
||||||
|
logging.debug('starting update cycle')
|
||||||
|
|
||||||
|
statuses = [read_battery_status(modbus, battery) for battery in batteries]
|
||||||
|
node_numbers = [battery.slave_address for battery in batteries]
|
||||||
|
|
||||||
|
publish_values(dbus, signals, statuses)
|
||||||
|
create_csv_files(csv_signals, statuses, node_numbers)
|
||||||
|
|
||||||
|
logging.debug('finished update cycle\n')
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def print_usage():
|
||||||
|
print ('Usage: ' + __file__ + ' <serial device>')
|
||||||
|
print ('Example: ' + __file__ + ' ttyUSB0')
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cmdline_args(argv):
|
||||||
|
# type: (list[str]) -> str
|
||||||
|
|
||||||
|
if len(argv) == 0:
|
||||||
|
logging.info('missing command line argument for tty device')
|
||||||
|
print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return argv[0]
|
||||||
|
|
||||||
|
|
||||||
|
alive = True # global alive flag, watchdog_task clears it, update_task sets it
|
||||||
|
|
||||||
|
|
||||||
|
def create_update_task(modbus, dbus, batteries, signals, csv_signals, main_loop):
|
||||||
|
# type: (Modbus, DBus, Iterable[Battery], Iterable[Signal], DBusGMainLoop) -> Callable[[],bool]
|
||||||
|
"""
|
||||||
|
Creates an update task which runs the main update function
|
||||||
|
and resets the alive flag
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update_task():
|
||||||
|
# type: () -> bool
|
||||||
|
|
||||||
|
global alive
|
||||||
|
|
||||||
|
alive = update(modbus, batteries, dbus, signals, csv_signals)
|
||||||
|
|
||||||
|
if not alive:
|
||||||
|
logging.info('update_task: quitting main loop because of error')
|
||||||
|
main_loop.quit()
|
||||||
|
|
||||||
|
return alive
|
||||||
|
|
||||||
|
return update_task
|
||||||
|
|
||||||
|
|
||||||
|
def create_watchdog_task(main_loop):
|
||||||
|
# type: (DBusGMainLoop) -> Callable[[],bool]
|
||||||
|
"""
|
||||||
|
Creates a Watchdog task that monitors the alive flag.
|
||||||
|
The watchdog kills the main loop if the alive flag is not periodically reset by the update task.
|
||||||
|
Who watches the watchdog?
|
||||||
|
"""
|
||||||
|
def watchdog_task():
|
||||||
|
# type: () -> bool
|
||||||
|
|
||||||
|
global alive
|
||||||
|
|
||||||
|
if alive:
|
||||||
|
logging.debug('watchdog_task: update_task is alive')
|
||||||
|
alive = False
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logging.info('watchdog_task: killing main loop because update_task is no longer alive')
|
||||||
|
main_loop.quit()
|
||||||
|
return False
|
||||||
|
|
||||||
|
return watchdog_task
|
||||||
|
|
||||||
|
|
||||||
|
def get_installation_name(file_path):
|
||||||
|
with open(file_path, 'r') as file:
|
||||||
|
return file.read().strip()
|
||||||
|
|
||||||
|
def manage_csv_files(directory_path, max_files=20):
|
||||||
|
csv_files = [f for f in os.listdir(directory_path)]
|
||||||
|
csv_files.sort(key=lambda x: os.path.getctime(os.path.join(directory_path, x)))
|
||||||
|
|
||||||
|
# Remove oldest files if exceeds maximum
|
||||||
|
while len(csv_files) > max_files:
|
||||||
|
file_to_delete = os.path.join(directory_path, csv_files.pop(0))
|
||||||
|
os.remove(file_to_delete)
|
||||||
|
|
||||||
|
def serialize_for_csv(value):
|
||||||
|
if isinstance(value, (dict, list, tuple)):
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def insert_id(path, id_number):
|
||||||
|
parts = path.split("/")
|
||||||
|
|
||||||
|
insert_position = parts.index("Devices") + 1
|
||||||
|
|
||||||
|
parts.insert(insert_position, str(id_number))
|
||||||
|
|
||||||
|
return "/".join(parts)
|
||||||
|
|
||||||
|
def create_csv_files(signals, statuses, node_numbers):
|
||||||
|
timestamp = int(time.time())
|
||||||
|
if timestamp % 2 != 0:
|
||||||
|
timestamp -= 1
|
||||||
|
# Create CSV directory if it doesn't exist
|
||||||
|
if not os.path.exists(CSV_DIR):
|
||||||
|
os.makedirs(CSV_DIR)
|
||||||
|
|
||||||
|
#installation_name = get_installation_name(INSTALLATION_NAME_FILE)
|
||||||
|
csv_filename = f"{timestamp}.csv"
|
||||||
|
csv_path = os.path.join(CSV_DIR, csv_filename)
|
||||||
|
|
||||||
|
# Append values to the CSV file
|
||||||
|
with open(csv_path, 'a', newline='') as csvfile:
|
||||||
|
csv_writer = csv.writer(csvfile, delimiter=';')
|
||||||
|
|
||||||
|
# Add a special row for the nodes configuration
|
||||||
|
nodes_config_path = "/Config/Devices/BatteryNodes"
|
||||||
|
nodes_list = ",".join(str(node) for node in node_numbers)
|
||||||
|
config_row = [nodes_config_path, nodes_list, ""]
|
||||||
|
csv_writer.writerow(config_row)
|
||||||
|
|
||||||
|
# Iterate over each node and signal to create rows in the new format
|
||||||
|
for i, node in enumerate(node_numbers):
|
||||||
|
for s in signals:
|
||||||
|
signal_name = insert_id(s.name, i+1)
|
||||||
|
#value = serialize_for_csv(s.get_value(statuses[i]))
|
||||||
|
value = s.get_value(statuses[i])
|
||||||
|
row_values = [signal_name, value, s.get_text]
|
||||||
|
csv_writer.writerow(row_values)
|
||||||
|
|
||||||
|
# Manage CSV files, keep a limited number of files
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Create the CSV as a string
|
||||||
|
csv_data = read_csv_as_string(csv_path)
|
||||||
|
|
||||||
|
|
||||||
|
# Create an S3config instance
|
||||||
|
s3_config = S3config()
|
||||||
|
response = s3_config.create_put_request(csv_filename, csv_data)
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
os.remove(csv_path)
|
||||||
|
print("Success")
|
||||||
|
else:
|
||||||
|
failed_dir = os.path.join(CSV_DIR, "failed")
|
||||||
|
if not os.path.exists(failed_dir):
|
||||||
|
os.makedirs(failed_dir)
|
||||||
|
failed_path = os.path.join(failed_dir, csv_filename)
|
||||||
|
os.rename(csv_path, failed_path)
|
||||||
|
print("Uploading failed")
|
||||||
|
manage_csv_files(failed_dir, 10)
|
||||||
|
|
||||||
|
|
||||||
|
manage_csv_files(CSV_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
# type: (list[str]) -> ()
|
||||||
|
|
||||||
|
logging.basicConfig(level=cfg.LOG_LEVEL)
|
||||||
|
logging.info('starting ' + __file__)
|
||||||
|
|
||||||
|
tty = parse_cmdline_args(argv)
|
||||||
|
modbus = init_modbus(tty)
|
||||||
|
|
||||||
|
batteries = identify_batteries(modbus)
|
||||||
|
|
||||||
|
n = len(batteries)
|
||||||
|
|
||||||
|
logging.info('found ' + str(n) + (' battery' if n == 1 else ' batteries'))
|
||||||
|
|
||||||
|
if n <= 0:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
bat = c.first(batteries) # report hw and fw version of first battery found
|
||||||
|
|
||||||
|
signals = init_signals(bat.hardware_version, bat.firmware_version, n)
|
||||||
|
csv_signals = create_csv_signals(bat.firmware_version)
|
||||||
|
|
||||||
|
main_loop = init_main_loop() # must run before init_dbus because gobject does some global magic
|
||||||
|
dbus = init_dbus(tty, signals)
|
||||||
|
|
||||||
|
update_task = create_update_task(modbus, dbus, batteries, signals, csv_signals, main_loop)
|
||||||
|
watchdog_task = create_watchdog_task(main_loop)
|
||||||
|
|
||||||
|
GLib.timeout_add(cfg.UPDATE_INTERVAL * 2, watchdog_task) # add watchdog first
|
||||||
|
GLib.timeout_add(cfg.UPDATE_INTERVAL, update_task) # call update once every update_interval
|
||||||
|
|
||||||
|
logging.info('starting GLib.MainLoop')
|
||||||
|
main_loop.run()
|
||||||
|
logging.info('GLib.MainLoop was shut down')
|
||||||
|
|
||||||
|
sys.exit(0xFF) # reaches this only on error
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1:])
|
Binary file not shown.
Binary file not shown.
|
@ -0,0 +1,276 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import sys
|
||||||
|
from traceback import print_exc
|
||||||
|
from os import _exit as os_exit
|
||||||
|
from os import statvfs
|
||||||
|
from subprocess import check_output, CalledProcessError
|
||||||
|
import logging
|
||||||
|
import dbus
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
VEDBUS_INVALID = dbus.Array([], signature=dbus.Signature('i'), variant_level=1)
|
||||||
|
|
||||||
|
class NoVrmPortalIdError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Use this function to make sure the code quits on an unexpected exception. Make sure to use it
|
||||||
|
# when using GLib.idle_add and also GLib.timeout_add.
|
||||||
|
# Without this, the code will just keep running, since GLib does not stop the mainloop on an
|
||||||
|
# exception.
|
||||||
|
# Example: GLib.idle_add(exit_on_error, myfunc, arg1, arg2)
|
||||||
|
def exit_on_error(func, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
print ('exit_on_error: there was an exception. Printing stacktrace will be tried and then exit')
|
||||||
|
print_exc()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# sys.exit() is not used, since that throws an exception, which does not lead to a program
|
||||||
|
# halt when used in a dbus callback, see connection.py in the Python/Dbus libraries, line 230.
|
||||||
|
os_exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
__vrm_portal_id = None
|
||||||
|
def get_vrm_portal_id():
|
||||||
|
# The original definition of the VRM Portal ID is that it is the mac
|
||||||
|
# address of the onboard- ethernet port (eth0), stripped from its colons
|
||||||
|
# (:) and lower case. This may however differ between platforms. On Venus
|
||||||
|
# the task is therefore deferred to /sbin/get-unique-id so that a
|
||||||
|
# platform specific method can be easily defined.
|
||||||
|
#
|
||||||
|
# If /sbin/get-unique-id does not exist, then use the ethernet address
|
||||||
|
# of eth0. This also handles the case where velib_python is used as a
|
||||||
|
# package install on a Raspberry Pi.
|
||||||
|
#
|
||||||
|
# On a Linux host where the network interface may not be eth0, you can set
|
||||||
|
# the VRM_IFACE environment variable to the correct name.
|
||||||
|
|
||||||
|
global __vrm_portal_id
|
||||||
|
|
||||||
|
if __vrm_portal_id:
|
||||||
|
return __vrm_portal_id
|
||||||
|
|
||||||
|
portal_id = None
|
||||||
|
|
||||||
|
# First try the method that works if we don't have a data partition. This
|
||||||
|
# will fail when the current user is not root.
|
||||||
|
try:
|
||||||
|
portal_id = check_output("/sbin/get-unique-id").decode("utf-8", "ignore").strip()
|
||||||
|
if not portal_id:
|
||||||
|
raise NoVrmPortalIdError("get-unique-id returned blank")
|
||||||
|
__vrm_portal_id = portal_id
|
||||||
|
return portal_id
|
||||||
|
except CalledProcessError:
|
||||||
|
# get-unique-id returned non-zero
|
||||||
|
raise NoVrmPortalIdError("get-unique-id returned non-zero")
|
||||||
|
except OSError:
|
||||||
|
# File doesn't exist, use fallback
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to getting our id using a syscall. Assume we are on linux.
|
||||||
|
# Allow the user to override what interface is used using an environment
|
||||||
|
# variable.
|
||||||
|
import fcntl, socket, struct, os
|
||||||
|
|
||||||
|
iface = os.environ.get('VRM_IFACE', 'eth0').encode('ascii')
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', iface[:15]))
|
||||||
|
except IOError:
|
||||||
|
raise NoVrmPortalIdError("ioctl failed for eth0")
|
||||||
|
|
||||||
|
__vrm_portal_id = info[18:24].hex()
|
||||||
|
return __vrm_portal_id
|
||||||
|
|
||||||
|
|
||||||
|
# See VE.Can registers - public.docx for definition of this conversion
|
||||||
|
def convert_vreg_version_to_readable(version):
|
||||||
|
def str_to_arr(x, length):
|
||||||
|
a = []
|
||||||
|
for i in range(0, len(x), length):
|
||||||
|
a.append(x[i:i+length])
|
||||||
|
return a
|
||||||
|
|
||||||
|
x = "%x" % version
|
||||||
|
x = x.upper()
|
||||||
|
|
||||||
|
if len(x) == 5 or len(x) == 3 or len(x) == 1:
|
||||||
|
x = '0' + x
|
||||||
|
|
||||||
|
a = str_to_arr(x, 2);
|
||||||
|
|
||||||
|
# remove the first 00 if there are three bytes and it is 00
|
||||||
|
if len(a) == 3 and a[0] == '00':
|
||||||
|
a.remove(0);
|
||||||
|
|
||||||
|
# if we have two or three bytes now, and the first character is a 0, remove it
|
||||||
|
if len(a) >= 2 and a[0][0:1] == '0':
|
||||||
|
a[0] = a[0][1];
|
||||||
|
|
||||||
|
result = ''
|
||||||
|
for item in a:
|
||||||
|
result += ('.' if result != '' else '') + item
|
||||||
|
|
||||||
|
|
||||||
|
result = 'v' + result
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def get_free_space(path):
|
||||||
|
result = -1
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = statvfs(path)
|
||||||
|
result = s.f_frsize * s.f_bavail # Number of free bytes that ordinary users
|
||||||
|
except Exception as ex:
|
||||||
|
logger.info("Error while retrieving free space for path %s: %s" % (path, ex))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sysfs_machine_name():
|
||||||
|
try:
|
||||||
|
with open('/sys/firmware/devicetree/base/model', 'r') as f:
|
||||||
|
return f.read().rstrip('\x00')
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Returns None if it cannot find a machine name. Otherwise returns the string
|
||||||
|
# containing the name
|
||||||
|
def get_machine_name():
|
||||||
|
# First try calling the venus utility script
|
||||||
|
try:
|
||||||
|
return check_output("/usr/bin/product-name").strip().decode('UTF-8')
|
||||||
|
except (CalledProcessError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to sysfs
|
||||||
|
name = _get_sysfs_machine_name()
|
||||||
|
if name is not None:
|
||||||
|
return name
|
||||||
|
|
||||||
|
# Fall back to venus build machine name
|
||||||
|
try:
|
||||||
|
with open('/etc/venus/machine', 'r', encoding='UTF-8') as f:
|
||||||
|
return f.read().strip()
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_product_id():
|
||||||
|
""" Find the machine ID and return it. """
|
||||||
|
|
||||||
|
# First try calling the venus utility script
|
||||||
|
try:
|
||||||
|
return check_output("/usr/bin/product-id").strip().decode('UTF-8')
|
||||||
|
except (CalledProcessError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back machine name mechanism
|
||||||
|
name = _get_sysfs_machine_name()
|
||||||
|
return {
|
||||||
|
'Color Control GX': 'C001',
|
||||||
|
'Venus GX': 'C002',
|
||||||
|
'Octo GX': 'C006',
|
||||||
|
'EasySolar-II': 'C007',
|
||||||
|
'MultiPlus-II': 'C008',
|
||||||
|
'Maxi GX': 'C009',
|
||||||
|
'Cerbo GX': 'C00A'
|
||||||
|
}.get(name, 'C003') # C003 is Generic
|
||||||
|
|
||||||
|
|
||||||
|
# Returns False if it cannot open the file. Otherwise returns its rstripped contents
|
||||||
|
def read_file(path):
|
||||||
|
content = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
content = f.read().rstrip()
|
||||||
|
except Exception as ex:
|
||||||
|
logger.debug("Error while reading %s: %s" % (path, ex))
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_dbus_value(value):
|
||||||
|
if value is None:
|
||||||
|
return VEDBUS_INVALID
|
||||||
|
if isinstance(value, float):
|
||||||
|
return dbus.Double(value, variant_level=1)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return dbus.Boolean(value, variant_level=1)
|
||||||
|
if isinstance(value, int):
|
||||||
|
try:
|
||||||
|
return dbus.Int32(value, variant_level=1)
|
||||||
|
except OverflowError:
|
||||||
|
return dbus.Int64(value, variant_level=1)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return dbus.String(value, variant_level=1)
|
||||||
|
if isinstance(value, list):
|
||||||
|
if len(value) == 0:
|
||||||
|
# If the list is empty we cannot infer the type of the contents. So assume unsigned integer.
|
||||||
|
# A (signed) integer is dangerous, because an empty list of signed integers is used to encode
|
||||||
|
# an invalid value.
|
||||||
|
return dbus.Array([], signature=dbus.Signature('u'), variant_level=1)
|
||||||
|
return dbus.Array([wrap_dbus_value(x) for x in value], variant_level=1)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Wrapping the keys of the dictionary causes D-Bus errors like:
|
||||||
|
# 'arguments to dbus_message_iter_open_container() were incorrect,
|
||||||
|
# assertion "(type == DBUS_TYPE_ARRAY && contained_signature &&
|
||||||
|
# *contained_signature == DBUS_DICT_ENTRY_BEGIN_CHAR) || (contained_signature == NULL ||
|
||||||
|
# _dbus_check_is_valid_signature (contained_signature))" failed in file ...'
|
||||||
|
return dbus.Dictionary({(k, wrap_dbus_value(v)) for k, v in value.items()}, variant_level=1)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
dbus_int_types = (dbus.Int32, dbus.UInt32, dbus.Byte, dbus.Int16, dbus.UInt16, dbus.UInt32, dbus.Int64, dbus.UInt64)
|
||||||
|
|
||||||
|
|
||||||
|
def unwrap_dbus_value(val):
|
||||||
|
"""Converts D-Bus values back to the original type. For example if val is of type DBus.Double,
|
||||||
|
a float will be returned."""
|
||||||
|
if isinstance(val, dbus_int_types):
|
||||||
|
return int(val)
|
||||||
|
if isinstance(val, dbus.Double):
|
||||||
|
return float(val)
|
||||||
|
if isinstance(val, dbus.Array):
|
||||||
|
v = [unwrap_dbus_value(x) for x in val]
|
||||||
|
return None if len(v) == 0 else v
|
||||||
|
if isinstance(val, (dbus.Signature, dbus.String)):
|
||||||
|
return str(val)
|
||||||
|
# Python has no byte type, so we convert to an integer.
|
||||||
|
if isinstance(val, dbus.Byte):
|
||||||
|
return int(val)
|
||||||
|
if isinstance(val, dbus.ByteArray):
|
||||||
|
return "".join([bytes(x) for x in val])
|
||||||
|
if isinstance(val, (list, tuple)):
|
||||||
|
return [unwrap_dbus_value(x) for x in val]
|
||||||
|
if isinstance(val, (dbus.Dictionary, dict)):
|
||||||
|
# Do not unwrap the keys, see comment in wrap_dbus_value
|
||||||
|
return dict([(x, unwrap_dbus_value(y)) for x, y in val.items()])
|
||||||
|
if isinstance(val, dbus.Boolean):
|
||||||
|
return bool(val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
# When supported, only name owner changes for the the given namespace are reported. This
|
||||||
|
# prevents spending cpu time at irrelevant changes, like scripts accessing the bus temporarily.
|
||||||
|
def add_name_owner_changed_receiver(dbus, name_owner_changed, namespace="com.victronenergy"):
|
||||||
|
# support for arg0namespace is submitted upstream, but not included at the time of
|
||||||
|
# writing, Venus OS does support it, so try if it works.
|
||||||
|
if namespace is None:
|
||||||
|
dbus.add_signal_receiver(name_owner_changed, signal_name='NameOwnerChanged')
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
dbus.add_signal_receiver(name_owner_changed,
|
||||||
|
signal_name='NameOwnerChanged', arg0namespace=namespace)
|
||||||
|
except TypeError:
|
||||||
|
dbus.add_signal_receiver(name_owner_changed, signal_name='NameOwnerChanged')
|
|
@ -0,0 +1,614 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
import dbus.service
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
import os
|
||||||
|
import weakref
|
||||||
|
from collections import defaultdict
|
||||||
|
from ve_utils import wrap_dbus_value, unwrap_dbus_value
|
||||||
|
|
||||||
|
# vedbus contains three classes:
|
||||||
|
# VeDbusItemImport -> use this to read data from the dbus, ie import
|
||||||
|
# VeDbusItemExport -> use this to export data to the dbus (one value)
|
||||||
|
# VeDbusService -> use that to create a service and export several values to the dbus
|
||||||
|
|
||||||
|
# Code for VeDbusItemImport is copied from busitem.py and thereafter modified.
|
||||||
|
# All projects that used busitem.py need to migrate to this package. And some
|
||||||
|
# projects used to define there own equivalent of VeDbusItemExport. Better to
|
||||||
|
# use VeDbusItemExport, or even better the VeDbusService class that does it all for you.
|
||||||
|
|
||||||
|
# TODOS
|
||||||
|
# 1 check for datatypes, it works now, but not sure if all is compliant with
|
||||||
|
# com.victronenergy.BusItem interface definition. See also the files in
|
||||||
|
# tests_and_examples. And see 'if type(v) == dbus.Byte:' on line 102. Perhaps
|
||||||
|
# something similar should also be done in VeDbusBusItemExport?
|
||||||
|
# 2 Shouldn't VeDbusBusItemExport inherit dbus.service.Object?
|
||||||
|
# 7 Make hard rules for services exporting data to the D-Bus, in order to make tracking
|
||||||
|
# changes possible. Does everybody first invalidate its data before leaving the bus?
|
||||||
|
# And what about before taking one object away from the bus, instead of taking the
|
||||||
|
# whole service offline?
|
||||||
|
# They should! And after taking one value away, do we need to know that someone left
|
||||||
|
# the bus? Or we just keep that value in invalidated for ever? Result is that we can't
|
||||||
|
# see the difference anymore between an invalidated value and a value that was first on
|
||||||
|
# the bus and later not anymore. See comments above VeDbusItemImport as well.
|
||||||
|
# 9 there are probably more todos in the code below.
|
||||||
|
|
||||||
|
# Some thoughts with regards to the data types:
|
||||||
|
#
|
||||||
|
# Text from: http://dbus.freedesktop.org/doc/dbus-python/doc/tutorial.html#data-types
|
||||||
|
# ---
|
||||||
|
# Variants are represented by setting the variant_level keyword argument in the
|
||||||
|
# constructor of any D-Bus data type to a value greater than 0 (variant_level 1
|
||||||
|
# means a variant containing some other data type, variant_level 2 means a variant
|
||||||
|
# containing a variant containing some other data type, and so on). If a non-variant
|
||||||
|
# is passed as an argument but introspection indicates that a variant is expected,
|
||||||
|
# it'll automatically be wrapped in a variant.
|
||||||
|
# ---
|
||||||
|
#
|
||||||
|
# Also the different dbus datatypes, such as dbus.Int32, and dbus.UInt32 are a subclass
|
||||||
|
# of Python int. dbus.String is a subclass of Python standard class unicode, etcetera
|
||||||
|
#
|
||||||
|
# So all together that explains why we don't need to explicitly convert back and forth
|
||||||
|
# between the dbus datatypes and the standard python datatypes. Note that all datatypes
|
||||||
|
# in python are objects. Even an int is an object.
|
||||||
|
|
||||||
|
# The signature of a variant is 'v'.
|
||||||
|
|
||||||
|
# Export ourselves as a D-Bus service.
|
||||||
|
class VeDbusService(object):
|
||||||
|
def __init__(self, servicename, bus=None):
|
||||||
|
# dict containing the VeDbusItemExport objects, with their path as the key.
|
||||||
|
self._dbusobjects = {}
|
||||||
|
self._dbusnodes = {}
|
||||||
|
self._ratelimiters = []
|
||||||
|
self._dbusname = None
|
||||||
|
|
||||||
|
# dict containing the onchange callbacks, for each object. Object path is the key
|
||||||
|
self._onchangecallbacks = {}
|
||||||
|
|
||||||
|
# Connect to session bus whenever present, else use the system bus
|
||||||
|
self._dbusconn = bus or (dbus.SessionBus() if 'DBUS_SESSION_BUS_ADDRESS' in os.environ else dbus.SystemBus())
|
||||||
|
|
||||||
|
# make the dbus connection available to outside, could make this a true property instead, but ach..
|
||||||
|
self.dbusconn = self._dbusconn
|
||||||
|
|
||||||
|
# Register ourselves on the dbus, trigger an error if already in use (do_not_queue)
|
||||||
|
self._dbusname = dbus.service.BusName(servicename, self._dbusconn, do_not_queue=True)
|
||||||
|
|
||||||
|
# Add the root item that will return all items as a tree
|
||||||
|
self._dbusnodes['/'] = VeDbusRootExport(self._dbusconn, '/', self)
|
||||||
|
|
||||||
|
logging.info("registered ourselves on D-Bus as %s" % servicename)
|
||||||
|
|
||||||
|
# To force immediate deregistering of this dbus service and all its object paths, explicitly
|
||||||
|
# call __del__().
|
||||||
|
def __del__(self):
|
||||||
|
for node in list(self._dbusnodes.values()):
|
||||||
|
node.__del__()
|
||||||
|
self._dbusnodes.clear()
|
||||||
|
for item in list(self._dbusobjects.values()):
|
||||||
|
item.__del__()
|
||||||
|
self._dbusobjects.clear()
|
||||||
|
if self._dbusname:
|
||||||
|
self._dbusname.__del__() # Forces call to self._bus.release_name(self._name), see source code
|
||||||
|
self._dbusname = None
|
||||||
|
|
||||||
|
def get_name(self):
|
||||||
|
return self._dbusname.get_name()
|
||||||
|
|
||||||
|
# @param callbackonchange function that will be called when this value is changed. First parameter will
|
||||||
|
# be the path of the object, second the new value. This callback should return
|
||||||
|
# True to accept the change, False to reject it.
|
||||||
|
def add_path(self, path, value, description="", writeable=False,
|
||||||
|
onchangecallback=None, gettextcallback=None, valuetype=None, itemtype=None):
|
||||||
|
|
||||||
|
if onchangecallback is not None:
|
||||||
|
self._onchangecallbacks[path] = onchangecallback
|
||||||
|
|
||||||
|
itemtype = itemtype or VeDbusItemExport
|
||||||
|
item = itemtype(self._dbusconn, path, value, description, writeable,
|
||||||
|
self._value_changed, gettextcallback, deletecallback=self._item_deleted, valuetype=valuetype)
|
||||||
|
|
||||||
|
spl = path.split('/')
|
||||||
|
for i in range(2, len(spl)):
|
||||||
|
subPath = '/'.join(spl[:i])
|
||||||
|
if subPath not in self._dbusnodes and subPath not in self._dbusobjects:
|
||||||
|
self._dbusnodes[subPath] = VeDbusTreeExport(self._dbusconn, subPath, self)
|
||||||
|
self._dbusobjects[path] = item
|
||||||
|
logging.debug('added %s with start value %s. Writeable is %s' % (path, value, writeable))
|
||||||
|
|
||||||
|
# Add the mandatory paths, as per victron dbus api doc
|
||||||
|
def add_mandatory_paths(self, processname, processversion, connection,
|
||||||
|
deviceinstance, productid, productname, firmwareversion, hardwareversion, connected):
|
||||||
|
self.add_path('/Mgmt/ProcessName', processname)
|
||||||
|
self.add_path('/Mgmt/ProcessVersion', processversion)
|
||||||
|
self.add_path('/Mgmt/Connection', connection)
|
||||||
|
|
||||||
|
# Create rest of the mandatory objects
|
||||||
|
self.add_path('/DeviceInstance', deviceinstance)
|
||||||
|
self.add_path('/ProductId', productid)
|
||||||
|
self.add_path('/ProductName', productname)
|
||||||
|
self.add_path('/FirmwareVersion', firmwareversion)
|
||||||
|
self.add_path('/HardwareVersion', hardwareversion)
|
||||||
|
self.add_path('/Connected', connected)
|
||||||
|
|
||||||
|
# Callback function that is called from the VeDbusItemExport objects when a value changes. This function
|
||||||
|
# maps the change-request to the onchangecallback given to us for this specific path.
|
||||||
|
def _value_changed(self, path, newvalue):
|
||||||
|
if path not in self._onchangecallbacks:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return self._onchangecallbacks[path](path, newvalue)
|
||||||
|
|
||||||
|
def _item_deleted(self, path):
|
||||||
|
self._dbusobjects.pop(path)
|
||||||
|
for np in list(self._dbusnodes.keys()):
|
||||||
|
if np != '/':
|
||||||
|
for ip in self._dbusobjects:
|
||||||
|
if ip.startswith(np + '/'):
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
self._dbusnodes[np].__del__()
|
||||||
|
self._dbusnodes.pop(np)
|
||||||
|
|
||||||
|
def __getitem__(self, path):
|
||||||
|
return self._dbusobjects[path].local_get_value()
|
||||||
|
|
||||||
|
def __setitem__(self, path, newvalue):
|
||||||
|
self._dbusobjects[path].local_set_value(newvalue)
|
||||||
|
|
||||||
|
def __delitem__(self, path):
|
||||||
|
self._dbusobjects[path].__del__() # Invalidates and then removes the object path
|
||||||
|
assert path not in self._dbusobjects
|
||||||
|
|
||||||
|
def __contains__(self, path):
|
||||||
|
return path in self._dbusobjects
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
l = ServiceContext(self)
|
||||||
|
self._ratelimiters.append(l)
|
||||||
|
return l
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
# pop off the top one and flush it. If with statements are nested
|
||||||
|
# then each exit flushes its own part.
|
||||||
|
if self._ratelimiters:
|
||||||
|
self._ratelimiters.pop().flush()
|
||||||
|
|
||||||
|
class ServiceContext(object):
|
||||||
|
def __init__(self, parent):
|
||||||
|
self.parent = parent
|
||||||
|
self.changes = {}
|
||||||
|
|
||||||
|
def __getitem__(self, path):
|
||||||
|
return self.parent[path]
|
||||||
|
|
||||||
|
def __setitem__(self, path, newvalue):
|
||||||
|
c = self.parent._dbusobjects[path]._local_set_value(newvalue)
|
||||||
|
if c is not None:
|
||||||
|
self.changes[path] = c
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
if self.changes:
|
||||||
|
self.parent._dbusnodes['/'].ItemsChanged(self.changes)
|
||||||
|
|
||||||
|
class TrackerDict(defaultdict):
|
||||||
|
""" Same as defaultdict, but passes the key to default_factory. """
|
||||||
|
def __missing__(self, key):
|
||||||
|
self[key] = x = self.default_factory(key)
|
||||||
|
return x
|
||||||
|
|
||||||
|
class VeDbusRootTracker(object):
|
||||||
|
""" This tracks the root of a dbus path and listens for PropertiesChanged
|
||||||
|
signals. When a signal arrives, parse it and unpack the key/value changes
|
||||||
|
into traditional events, then pass it to the original eventCallback
|
||||||
|
method. """
|
||||||
|
def __init__(self, bus, serviceName):
|
||||||
|
self.importers = defaultdict(weakref.WeakSet)
|
||||||
|
self.serviceName = serviceName
|
||||||
|
self._match = bus.get_object(serviceName, '/', introspect=False).connect_to_signal(
|
||||||
|
"ItemsChanged", weak_functor(self._items_changed_handler))
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self._match.remove()
|
||||||
|
self._match = None
|
||||||
|
|
||||||
|
def add(self, i):
|
||||||
|
self.importers[i.path].add(i)
|
||||||
|
|
||||||
|
def _items_changed_handler(self, items):
|
||||||
|
if not isinstance(items, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
for path, changes in items.items():
|
||||||
|
try:
|
||||||
|
v = changes['Value']
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
t = changes['Text']
|
||||||
|
except KeyError:
|
||||||
|
t = str(unwrap_dbus_value(v))
|
||||||
|
|
||||||
|
for i in self.importers.get(path, ()):
|
||||||
|
i._properties_changed_handler({'Value': v, 'Text': t})
|
||||||
|
|
||||||
|
"""
|
||||||
|
Importing basics:
|
||||||
|
- If when we power up, the D-Bus service does not exist, or it does exist and the path does not
|
||||||
|
yet exist, still subscribe to a signal: as soon as it comes online it will send a signal with its
|
||||||
|
initial value, which VeDbusItemImport will receive and use to update local cache. And, when set,
|
||||||
|
call the eventCallback.
|
||||||
|
- If when we power up, save it
|
||||||
|
- When using get_value, know that there is no difference between services (or object paths) that don't
|
||||||
|
exist and paths that are invalid (= empty array, see above). Both will return None. In case you do
|
||||||
|
really want to know ifa path exists or not, use the exists property.
|
||||||
|
- When a D-Bus service leaves the D-Bus, it will first invalidate all its values, and send signals
|
||||||
|
with that update, and only then leave the D-Bus. (or do we need to subscribe to the NameOwnerChanged-
|
||||||
|
signal!?!) To be discussed and make sure. Not really urgent, since all existing code that uses this
|
||||||
|
class already subscribes to the NameOwnerChanged signal, and subsequently removes instances of this
|
||||||
|
class.
|
||||||
|
|
||||||
|
Read when using this class:
|
||||||
|
Note that when a service leaves that D-Bus without invalidating all its exported objects first, for
|
||||||
|
example because it is killed, VeDbusItemImport doesn't have a clue. So when using VeDbusItemImport,
|
||||||
|
make sure to also subscribe to the NamerOwnerChanged signal on bus-level. Or just use dbusmonitor,
|
||||||
|
because that takes care of all of that for you.
|
||||||
|
"""
|
||||||
|
class VeDbusItemImport(object):
|
||||||
|
def __new__(cls, bus, serviceName, path, eventCallback=None, createsignal=True):
|
||||||
|
instance = object.__new__(cls)
|
||||||
|
|
||||||
|
# If signal tracking should be done, also add to root tracker
|
||||||
|
if createsignal:
|
||||||
|
if "_roots" not in cls.__dict__:
|
||||||
|
cls._roots = TrackerDict(lambda k: VeDbusRootTracker(bus, k))
|
||||||
|
|
||||||
|
return instance
|
||||||
|
|
||||||
|
## Constructor
|
||||||
|
# @param bus the bus-object (SESSION or SYSTEM).
|
||||||
|
# @param serviceName the dbus-service-name (string), for example 'com.victronenergy.battery.ttyO1'
|
||||||
|
# @param path the object-path, for example '/Dc/V'
|
||||||
|
# @param eventCallback function that you want to be called on a value change
|
||||||
|
# @param createSignal only set this to False if you use this function to one time read a value. When
|
||||||
|
# leaving it to True, make sure to also subscribe to the NameOwnerChanged signal
|
||||||
|
# elsewhere. See also note some 15 lines up.
|
||||||
|
def __init__(self, bus, serviceName, path, eventCallback=None, createsignal=True):
|
||||||
|
# TODO: is it necessary to store _serviceName and _path? Isn't it
|
||||||
|
# stored in the bus_getobjectsomewhere?
|
||||||
|
self._serviceName = serviceName
|
||||||
|
self._path = path
|
||||||
|
self._match = None
|
||||||
|
# TODO: _proxy is being used in settingsdevice.py, make a getter for that
|
||||||
|
self._proxy = bus.get_object(serviceName, path, introspect=False)
|
||||||
|
self.eventCallback = eventCallback
|
||||||
|
|
||||||
|
assert eventCallback is None or createsignal == True
|
||||||
|
if createsignal:
|
||||||
|
self._match = self._proxy.connect_to_signal(
|
||||||
|
"PropertiesChanged", weak_functor(self._properties_changed_handler))
|
||||||
|
self._roots[serviceName].add(self)
|
||||||
|
|
||||||
|
# store the current value in _cachedvalue. When it doesn't exists set _cachedvalue to
|
||||||
|
# None, same as when a value is invalid
|
||||||
|
self._cachedvalue = None
|
||||||
|
try:
|
||||||
|
v = self._proxy.GetValue()
|
||||||
|
except dbus.exceptions.DBusException:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._cachedvalue = unwrap_dbus_value(v)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if self._match is not None:
|
||||||
|
self._match.remove()
|
||||||
|
self._match = None
|
||||||
|
self._proxy = None
|
||||||
|
|
||||||
|
def _refreshcachedvalue(self):
|
||||||
|
self._cachedvalue = unwrap_dbus_value(self._proxy.GetValue())
|
||||||
|
|
||||||
|
## Returns the path as a string, for example '/AC/L1/V'
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
return self._path
|
||||||
|
|
||||||
|
## Returns the dbus service name as a string, for example com.victronenergy.vebus.ttyO1
|
||||||
|
@property
|
||||||
|
def serviceName(self):
|
||||||
|
return self._serviceName
|
||||||
|
|
||||||
|
## Returns the value of the dbus-item.
|
||||||
|
# the type will be a dbus variant, for example dbus.Int32(0, variant_level=1)
|
||||||
|
# this is not a property to keep the name consistant with the com.victronenergy.busitem interface
|
||||||
|
# returns None when the property is invalid
|
||||||
|
def get_value(self):
|
||||||
|
return self._cachedvalue
|
||||||
|
|
||||||
|
## Writes a new value to the dbus-item
|
||||||
|
def set_value(self, newvalue):
|
||||||
|
r = self._proxy.SetValue(wrap_dbus_value(newvalue))
|
||||||
|
|
||||||
|
# instead of just saving the value, go to the dbus and get it. So we have the right type etc.
|
||||||
|
if r == 0:
|
||||||
|
self._refreshcachedvalue()
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
## Resets the item to its default value
|
||||||
|
def set_default(self):
|
||||||
|
self._proxy.SetDefault()
|
||||||
|
self._refreshcachedvalue()
|
||||||
|
|
||||||
|
## Returns the text representation of the value.
|
||||||
|
# For example when the value is an enum/int GetText might return the string
|
||||||
|
# belonging to that enum value. Another example, for a voltage, GetValue
|
||||||
|
# would return a float, 12.0Volt, and GetText could return 12 VDC.
|
||||||
|
#
|
||||||
|
# Note that this depends on how the dbus-producer has implemented this.
|
||||||
|
def get_text(self):
|
||||||
|
return self._proxy.GetText()
|
||||||
|
|
||||||
|
## Returns true of object path exists, and false if it doesn't
|
||||||
|
@property
|
||||||
|
def exists(self):
|
||||||
|
# TODO: do some real check instead of this crazy thing.
|
||||||
|
r = False
|
||||||
|
try:
|
||||||
|
r = self._proxy.GetValue()
|
||||||
|
r = True
|
||||||
|
except dbus.exceptions.DBusException:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return r
|
||||||
|
|
||||||
|
## callback for the trigger-event.
|
||||||
|
# @param eventCallback the event-callback-function.
|
||||||
|
@property
|
||||||
|
def eventCallback(self):
|
||||||
|
return self._eventCallback
|
||||||
|
|
||||||
|
@eventCallback.setter
|
||||||
|
def eventCallback(self, eventCallback):
|
||||||
|
self._eventCallback = eventCallback
|
||||||
|
|
||||||
|
## Is called when the value of the imported bus-item changes.
|
||||||
|
# Stores the new value in our local cache, and calls the eventCallback, if set.
|
||||||
|
def _properties_changed_handler(self, changes):
|
||||||
|
if "Value" in changes:
|
||||||
|
changes['Value'] = unwrap_dbus_value(changes['Value'])
|
||||||
|
self._cachedvalue = changes['Value']
|
||||||
|
if self._eventCallback:
|
||||||
|
# The reason behind this try/except is to prevent errors silently ending up the an error
|
||||||
|
# handler in the dbus code.
|
||||||
|
try:
|
||||||
|
self._eventCallback(self._serviceName, self._path, changes)
|
||||||
|
except:
|
||||||
|
traceback.print_exc()
|
||||||
|
os._exit(1) # sys.exit() is not used, since that also throws an exception
|
||||||
|
|
||||||
|
|
||||||
|
class VeDbusTreeExport(dbus.service.Object):
|
||||||
|
def __init__(self, bus, objectPath, service):
|
||||||
|
dbus.service.Object.__init__(self, bus, objectPath)
|
||||||
|
self._service = service
|
||||||
|
logging.debug("VeDbusTreeExport %s has been created" % objectPath)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
# self._get_path() will raise an exception when retrieved after the call to .remove_from_connection,
|
||||||
|
# so we need a copy.
|
||||||
|
path = self._get_path()
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
|
self.remove_from_connection()
|
||||||
|
logging.debug("VeDbusTreeExport %s has been removed" % path)
|
||||||
|
|
||||||
|
def _get_path(self):
|
||||||
|
if len(self._locations) == 0:
|
||||||
|
return None
|
||||||
|
return self._locations[0][1]
|
||||||
|
|
||||||
|
def _get_value_handler(self, path, get_text=False):
|
||||||
|
logging.debug("_get_value_handler called for %s" % path)
|
||||||
|
r = {}
|
||||||
|
px = path
|
||||||
|
if not px.endswith('/'):
|
||||||
|
px += '/'
|
||||||
|
for p, item in self._service._dbusobjects.items():
|
||||||
|
if p.startswith(px):
|
||||||
|
v = item.GetText() if get_text else wrap_dbus_value(item.local_get_value())
|
||||||
|
r[p[len(px):]] = v
|
||||||
|
logging.debug(r)
|
||||||
|
return r
|
||||||
|
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='v')
|
||||||
|
def GetValue(self):
|
||||||
|
value = self._get_value_handler(self._get_path())
|
||||||
|
return dbus.Dictionary(value, signature=dbus.Signature('sv'), variant_level=1)
|
||||||
|
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='v')
|
||||||
|
def GetText(self):
|
||||||
|
return self._get_value_handler(self._get_path(), True)
|
||||||
|
|
||||||
|
def local_get_value(self):
|
||||||
|
return self._get_value_handler(self.path)
|
||||||
|
|
||||||
|
class VeDbusRootExport(VeDbusTreeExport):
|
||||||
|
@dbus.service.signal('com.victronenergy.BusItem', signature='a{sa{sv}}')
|
||||||
|
def ItemsChanged(self, changes):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='a{sa{sv}}')
|
||||||
|
def GetItems(self):
|
||||||
|
return {
|
||||||
|
path: {
|
||||||
|
'Value': wrap_dbus_value(item.local_get_value()),
|
||||||
|
'Text': item.GetText() }
|
||||||
|
for path, item in self._service._dbusobjects.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VeDbusItemExport(dbus.service.Object):
|
||||||
|
## Constructor of VeDbusItemExport
|
||||||
|
#
|
||||||
|
# Use this object to export (publish), values on the dbus
|
||||||
|
# Creates the dbus-object under the given dbus-service-name.
|
||||||
|
# @param bus The dbus object.
|
||||||
|
# @param objectPath The dbus-object-path.
|
||||||
|
# @param value Value to initialize ourselves with, defaults to None which means Invalid
|
||||||
|
# @param description String containing a description. Can be called over the dbus with GetDescription()
|
||||||
|
# @param writeable what would this do!? :).
|
||||||
|
# @param callback Function that will be called when someone else changes the value of this VeBusItem
|
||||||
|
# over the dbus. First parameter passed to callback will be our path, second the new
|
||||||
|
# value. This callback should return True to accept the change, False to reject it.
|
||||||
|
def __init__(self, bus, objectPath, value=None, description=None, writeable=False,
|
||||||
|
onchangecallback=None, gettextcallback=None, deletecallback=None,
|
||||||
|
valuetype=None):
|
||||||
|
dbus.service.Object.__init__(self, bus, objectPath)
|
||||||
|
self._onchangecallback = onchangecallback
|
||||||
|
self._gettextcallback = gettextcallback
|
||||||
|
self._value = value
|
||||||
|
self._description = description
|
||||||
|
self._writeable = writeable
|
||||||
|
self._deletecallback = deletecallback
|
||||||
|
self._type = valuetype
|
||||||
|
|
||||||
|
# To force immediate deregistering of this dbus object, explicitly call __del__().
|
||||||
|
def __del__(self):
|
||||||
|
# self._get_path() will raise an exception when retrieved after the
|
||||||
|
# call to .remove_from_connection, so we need a copy.
|
||||||
|
path = self._get_path()
|
||||||
|
if path == None:
|
||||||
|
return
|
||||||
|
if self._deletecallback is not None:
|
||||||
|
self._deletecallback(path)
|
||||||
|
self.remove_from_connection()
|
||||||
|
logging.debug("VeDbusItemExport %s has been removed" % path)
|
||||||
|
|
||||||
|
def _get_path(self):
|
||||||
|
if len(self._locations) == 0:
|
||||||
|
return None
|
||||||
|
return self._locations[0][1]
|
||||||
|
|
||||||
|
## Sets the value. And in case the value is different from what it was, a signal
|
||||||
|
# will be emitted to the dbus. This function is to be used in the python code that
|
||||||
|
# is using this class to export values to the dbus.
|
||||||
|
# set value to None to indicate that it is Invalid
|
||||||
|
def local_set_value(self, newvalue):
|
||||||
|
changes = self._local_set_value(newvalue)
|
||||||
|
if changes is not None:
|
||||||
|
self.PropertiesChanged(changes)
|
||||||
|
|
||||||
|
def _local_set_value(self, newvalue):
|
||||||
|
if self._value == newvalue:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._value = newvalue
|
||||||
|
return {
|
||||||
|
'Value': wrap_dbus_value(newvalue),
|
||||||
|
'Text': self.GetText()
|
||||||
|
}
|
||||||
|
|
||||||
|
def local_get_value(self):
|
||||||
|
return self._value
|
||||||
|
|
||||||
|
# ==== ALL FUNCTIONS BELOW THIS LINE WILL BE CALLED BY OTHER PROCESSES OVER THE DBUS ====
|
||||||
|
|
||||||
|
## Dbus exported method SetValue
|
||||||
|
# Function is called over the D-Bus by other process. It will first check (via callback) if new
|
||||||
|
# value is accepted. And it is, stores it and emits a changed-signal.
|
||||||
|
# @param value The new value.
|
||||||
|
# @return completion-code When successful a 0 is return, and when not a -1 is returned.
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', in_signature='v', out_signature='i')
|
||||||
|
def SetValue(self, newvalue):
|
||||||
|
if not self._writeable:
|
||||||
|
return 1 # NOT OK
|
||||||
|
|
||||||
|
newvalue = unwrap_dbus_value(newvalue)
|
||||||
|
|
||||||
|
# If value type is enforced, cast it. If the type can be coerced
|
||||||
|
# python will do it for us. This allows ints to become floats,
|
||||||
|
# or bools to become ints. Additionally also allow None, so that
|
||||||
|
# a path may be invalidated.
|
||||||
|
if self._type is not None and newvalue is not None:
|
||||||
|
try:
|
||||||
|
newvalue = self._type(newvalue)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 1 # NOT OK
|
||||||
|
|
||||||
|
if newvalue == self._value:
|
||||||
|
return 0 # OK
|
||||||
|
|
||||||
|
# call the callback given to us, and check if new value is OK.
|
||||||
|
if (self._onchangecallback is None or
|
||||||
|
(self._onchangecallback is not None and self._onchangecallback(self.__dbus_object_path__, newvalue))):
|
||||||
|
|
||||||
|
self.local_set_value(newvalue)
|
||||||
|
return 0 # OK
|
||||||
|
|
||||||
|
return 2 # NOT OK
|
||||||
|
|
||||||
|
## Dbus exported method GetDescription
|
||||||
|
#
|
||||||
|
# Returns the a description.
|
||||||
|
# @param language A language code (e.g. ISO 639-1 en-US).
|
||||||
|
# @param length Lenght of the language string.
|
||||||
|
# @return description
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', in_signature='si', out_signature='s')
|
||||||
|
def GetDescription(self, language, length):
|
||||||
|
return self._description if self._description is not None else 'No description given'
|
||||||
|
|
||||||
|
## Dbus exported method GetValue
|
||||||
|
# Returns the value.
|
||||||
|
# @return the value when valid, and otherwise an empty array
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='v')
|
||||||
|
def GetValue(self):
|
||||||
|
return wrap_dbus_value(self._value)
|
||||||
|
|
||||||
|
## Dbus exported method GetText
|
||||||
|
# Returns the value as string of the dbus-object-path.
|
||||||
|
# @return text A text-value. '---' when local value is invalid
|
||||||
|
@dbus.service.method('com.victronenergy.BusItem', out_signature='s')
|
||||||
|
def GetText(self):
|
||||||
|
if self._value is None:
|
||||||
|
return '---'
|
||||||
|
|
||||||
|
# Default conversion from dbus.Byte will get you a character (so 'T' instead of '84'), so we
|
||||||
|
# have to convert to int first. Note that if a dbus.Byte turns up here, it must have come from
|
||||||
|
# the application itself, as all data from the D-Bus should have been unwrapped by now.
|
||||||
|
if self._gettextcallback is None and type(self._value) == dbus.Byte:
|
||||||
|
return str(int(self._value))
|
||||||
|
|
||||||
|
if self._gettextcallback is None and self.__dbus_object_path__ == '/ProductId':
|
||||||
|
return "0x%X" % self._value
|
||||||
|
|
||||||
|
if self._gettextcallback is None:
|
||||||
|
return str(self._value)
|
||||||
|
|
||||||
|
return self._gettextcallback(self.__dbus_object_path__, self._value)
|
||||||
|
|
||||||
|
## The signal that indicates that the value has changed.
|
||||||
|
# Other processes connected to this BusItem object will have subscribed to the
|
||||||
|
# event when they want to track our state.
|
||||||
|
@dbus.service.signal('com.victronenergy.BusItem', signature='a{sv}')
|
||||||
|
def PropertiesChanged(self, changes):
|
||||||
|
pass
|
||||||
|
|
||||||
|
## This class behaves like a regular reference to a class method (eg. self.foo), but keeps a weak reference
|
||||||
|
## to the object which method is to be called.
|
||||||
|
## Use this object to break circular references.
|
||||||
|
class weak_functor:
|
||||||
|
def __init__(self, f):
|
||||||
|
self._r = weakref.ref(f.__self__)
|
||||||
|
self._f = weakref.ref(f.__func__)
|
||||||
|
|
||||||
|
def __call__(self, *args, **kargs):
|
||||||
|
r = self._r()
|
||||||
|
f = self._f()
|
||||||
|
if r == None or f == None:
|
||||||
|
return
|
||||||
|
f(r, *args, **kargs)
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
. /opt/victronenergy/serial-starter/run-service.sh
|
||||||
|
|
||||||
|
app=/opt/victronenergy/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py
|
||||||
|
args="$tty"
|
||||||
|
start $args
|
|
@ -1,311 +0,0 @@
|
||||||
using CliWrap;
|
|
||||||
using HandlebarsDotNet;
|
|
||||||
using InnovEnergy.App.VrmGrabber.Database;
|
|
||||||
using InnovEnergy.Lib.Utils;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using VrmInstallation = InnovEnergy.Lib.Victron.VictronVRM.Installation;
|
|
||||||
|
|
||||||
namespace InnovEnergy.App.VrmGrabber;
|
|
||||||
|
|
||||||
public record InstallationToHtmlInterface(
|
|
||||||
String Name,
|
|
||||||
String Ip,
|
|
||||||
Int64 Vrm,
|
|
||||||
String Identifier,
|
|
||||||
String Serial,
|
|
||||||
String EscapedName,
|
|
||||||
String Online,
|
|
||||||
String LastSeen,
|
|
||||||
String NumBatteries,
|
|
||||||
String BatteryVersion,
|
|
||||||
String BatteryUpdateStatus,
|
|
||||||
String ServerIp = "10.2.0.1", //TODO MAKE ME DYNAMIC
|
|
||||||
String FirmwareVersion = "AF09", //Todo automatically grab newest version?
|
|
||||||
String NodeRedFiles = "NodeRedFiles"
|
|
||||||
);
|
|
||||||
|
|
||||||
[Controller]
|
|
||||||
public class Controller : ControllerBase
|
|
||||||
{
|
|
||||||
|
|
||||||
//Todo automatically grab newest version?
|
|
||||||
private const String FirmwareVersion = "AF09";
|
|
||||||
|
|
||||||
|
|
||||||
[HttpGet]
|
|
||||||
[Route("/")]
|
|
||||||
[Produces("text/html")]
|
|
||||||
public ActionResult Index()
|
|
||||||
{
|
|
||||||
const String source = @"<head>
|
|
||||||
<style>
|
|
||||||
tbody {
|
|
||||||
background-color: #e4f0f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
tbody tr:nth-child(odd) {
|
|
||||||
background-color: #ECE9E9;
|
|
||||||
}
|
|
||||||
|
|
||||||
th, td { /* cell */
|
|
||||||
padding: 0.75rem;
|
|
||||||
font-size: 0.9375rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
th { /* header cell */
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: left;
|
|
||||||
color: #272838;
|
|
||||||
border-bottom: 2px solid #EB9486;
|
|
||||||
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background-color: #F9F8F8;
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
border: 2px solid rgb(200, 200, 200);
|
|
||||||
letter-spacing: 1px;
|
|
||||||
font-family: sans-serif;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
position: absolute; top: 0; bottom: 0; left: 0; right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead th {
|
|
||||||
border: 1px solid rgb(190, 190, 190);
|
|
||||||
padding: 5px 10px;
|
|
||||||
position: sticky;
|
|
||||||
position: -webkit-sticky;
|
|
||||||
top: 0px;
|
|
||||||
background: white;
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
#managerTable {
|
|
||||||
overflow: hidden;
|
|
||||||
}</style></head>
|
|
||||||
|
|
||||||
<div id='managerTable'>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>Name This site is updated once per day!</th>
|
|
||||||
<th>Gui</th>
|
|
||||||
<th>VRM</th>
|
|
||||||
<th>Grafana</th>
|
|
||||||
<th>Identifier</th>
|
|
||||||
<th>Last Seen</th>
|
|
||||||
<th>Serial</th>
|
|
||||||
<th>#Batteries</th>
|
|
||||||
<th>Firmware-Version</th>
|
|
||||||
<th>Update</th>
|
|
||||||
<th>Last Update Status</th>
|
|
||||||
<th>Upload Node Red Files</th>
|
|
||||||
</tr>
|
|
||||||
{{#inst}}
|
|
||||||
{{> installations}}
|
|
||||||
{{/inst}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<div id='managerTable'>";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const String partialSource = @"<tr><td>{{Name}}</td>
|
|
||||||
<td><a target='_blank' href=http://{{Ip}}>{{online}} {{Ip}}</a></td>
|
|
||||||
<td><a target='_blank' href=https://vrm.victronenergy.com/installation/{{Vrm}}/dashboard>VRM</a></td>
|
|
||||||
<td><a target='_blank' href='https://salidomo.innovenergy.ch/d/ENkNRQXmk/installation?refresh=5s&orgId=1&var-Installation={{EscapedName}}&kiosk=tv'>Grafana</a></td>
|
|
||||||
<td>{{Identifier}}</td>
|
|
||||||
<td>{{LastSeen}}</td>
|
|
||||||
<td>{{Serial}}</td>
|
|
||||||
<td>{{NumBatteries}}</td>
|
|
||||||
<td>{{BatteryVersion}}</td>
|
|
||||||
<td><a target='_blank' href=http://{{ServerIp}}/UpdateBatteryFirmware/{{Ip}}>⬆️{{FirmwareVersion}}</a></td>
|
|
||||||
<td>{{BatteryUpdateStatus}}</td>
|
|
||||||
<td><a target='_blank' href=http://{{ServerIp}}/UploadNodeRedFiles/{{Ip}}>⬆️{{NodeRedFiles}}</a></td>
|
|
||||||
</tr>";
|
|
||||||
|
|
||||||
var installationsInDb = Db.Installations.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
if (installationsInDb.Count == 0) return new ContentResult
|
|
||||||
{
|
|
||||||
ContentType = "text/html",
|
|
||||||
Content = "<p>Please wait page is still loading</p>"
|
|
||||||
};
|
|
||||||
|
|
||||||
Handlebars.RegisterTemplate("installations", partialSource);
|
|
||||||
var template = Handlebars.Compile(source);
|
|
||||||
var installsForHtml = installationsInDb.Select(i => new InstallationToHtmlInterface(
|
|
||||||
i.Name,
|
|
||||||
i.Ip,
|
|
||||||
i.Vrm,
|
|
||||||
i.Identifier,
|
|
||||||
i.Serial,
|
|
||||||
i.EscapedName,
|
|
||||||
i.Online,
|
|
||||||
DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(i.LastSeen)).ToString(),
|
|
||||||
i.NumberOfBatteries,
|
|
||||||
i.BatteryFirmwareVersion,
|
|
||||||
i.BatteryUpdateStatus));
|
|
||||||
|
|
||||||
var data = new
|
|
||||||
{
|
|
||||||
inst = installsForHtml,
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = template(data);
|
|
||||||
|
|
||||||
return new ContentResult
|
|
||||||
{
|
|
||||||
ContentType = "text/html",
|
|
||||||
Content = result
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
[HttpGet("UpdateBatteryFirmware/{installationIp}")]
|
|
||||||
public async Task<String> UpdateBatteryFirmware(String installationIp)
|
|
||||||
{
|
|
||||||
//We need the DeviceName of the battery (ttyUSB?)
|
|
||||||
var pathToBattery = await Db.ExecuteBufferedAsyncCommandOnIp(installationIp, "dbus-send --system --dest=com.victronenergy.system --type=method_call --print-reply /ServiceMapping/com_victronenergy_battery_1 com.victronenergy.BusItem.GetText");
|
|
||||||
|
|
||||||
var split = pathToBattery.Split('"');
|
|
||||||
var split2 = pathToBattery.Split(' ');
|
|
||||||
|
|
||||||
if (split.Length < 2 || split2.Length < 1)
|
|
||||||
{
|
|
||||||
Console.WriteLine(pathToBattery + " Split failed ");
|
|
||||||
return "Update failed";
|
|
||||||
}
|
|
||||||
if (split[1] == "Failed" || split2[0] == "Error") return "Update failed";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await SendNewBatteryFirmware(installationIp);
|
|
||||||
var batteryTtyName = split[1].Split(".").Last();
|
|
||||||
var localCommand = "echo start";
|
|
||||||
var installation = Db.Installations.First(installation => installation.Ip == installationIp);
|
|
||||||
installation.BatteryUpdateStatus = "Running";
|
|
||||||
Db.Update(installation: installation);
|
|
||||||
var batteryIdsResult = await Db.ExecuteBufferedAsyncCommandOnIp(installationIp, $"dbus-send --system --dest=com.victronenergy.battery.{batteryTtyName} --type=method_call --print-reply / com.victronenergy.BusItem.GetText | grep -E -o '_Battery/[0-9]+/' | grep -E -o '[0-9]+'| sort -u");
|
|
||||||
var batteryIds = batteryIdsResult.Split("\n").ToList();
|
|
||||||
batteryIds.Pop();
|
|
||||||
|
|
||||||
foreach (var batteryId in batteryIds)
|
|
||||||
{
|
|
||||||
localCommand = localCommand.Append(
|
|
||||||
$" && /opt/innovenergy/scripts/upload-bms-firmware {batteryTtyName} {batteryId} /opt/innovenergy/bms-firmware/{FirmwareVersion}.bin");
|
|
||||||
}
|
|
||||||
#pragma warning disable CS4014
|
|
||||||
// Console.WriteLine(localCommand);
|
|
||||||
Db.ExecuteBufferedAsyncCommandOnIp(installationIp, localCommand)
|
|
||||||
.ContinueWith(async t =>
|
|
||||||
{
|
|
||||||
Console.WriteLine(t.Result);
|
|
||||||
installation.BatteryUpdateStatus = "Complete";
|
|
||||||
// installation.BatteryFirmwareVersion = FirmwareVersion;
|
|
||||||
Db.Update(installation: installation);
|
|
||||||
var vrmInst = await FindVrmInstallationByIp(installation.Ip!);
|
|
||||||
await UpdateVrmTagsToNewFirmware(installationIp);
|
|
||||||
await Db.UpdateAlarms(vrmInst);
|
|
||||||
});
|
|
||||||
#pragma warning restore CS4014
|
|
||||||
return "Battery update is successfully initiated, it will take around 15 minutes to complete! You can close this page now.";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task UpdateVrmTagsToNewFirmware(String installationIp)
|
|
||||||
{
|
|
||||||
var vrmInstallation = await FindVrmInstallationByIp(installationIp);
|
|
||||||
var tags = await vrmInstallation.GetTags();
|
|
||||||
|
|
||||||
async void RemoveTag(String t) => await vrmInstallation.RemoveTags(t);
|
|
||||||
|
|
||||||
tags.Where(tag => tag.StartsWith("FM-"))
|
|
||||||
.Do(RemoveTag);
|
|
||||||
|
|
||||||
await vrmInstallation.AddTags("FM-" + FirmwareVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<VrmInstallation> FindVrmInstallationByIp(String installationIp)
|
|
||||||
{
|
|
||||||
var installationId = Db.Installations.Where(i => i.Ip == installationIp).Select(i => i.Vrm).First();
|
|
||||||
var vrmAccount = await Db.GetVrmAccount();
|
|
||||||
return await vrmAccount.GetInstallation(installationId!);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task SendNewBatteryFirmware(String installationIp)
|
|
||||||
{
|
|
||||||
await Cli.Wrap("rsync")
|
|
||||||
.WithArguments($@"-r --relative bms-firmware/{FirmwareVersion}.bin")
|
|
||||||
.AppendArgument($@"root@{installationIp}:/opt/innovenergy")
|
|
||||||
.ExecuteAsync();
|
|
||||||
}
|
|
||||||
// [HttpGet(nameof(GetInstallation))]
|
|
||||||
// [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
|
|
||||||
// public Object GetInstallation(UInt64 serialNumber)
|
|
||||||
// {
|
|
||||||
// var instList = Db.InstallationsAndDetails.Values.ToList();
|
|
||||||
// foreach (var detailList in instList.Select((value, index) => new { Value = value, Index = index}))
|
|
||||||
// {
|
|
||||||
// if (detailList.Value.All(detail => detail.Json["idSite"]?.GetValue<UInt64>() != serialNumber)) continue;
|
|
||||||
// var retour = Db.InstallationsAndDetails.Keys.ToList()[detailList.Index].Json;
|
|
||||||
// retour["details"] = JsonSerializer.Deserialize<JsonArray>(JsonSerializer.Serialize(detailList.Value.Select(d => d.Json).ToArray()));
|
|
||||||
// return retour;
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// return new NotFoundResult();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// remove the original ones????????
|
|
||||||
[HttpPost("UploadNodeRedFiles/{installationIp}")]
|
|
||||||
public async Task<IActionResult> UploadNodeRedFiles(String installationIp)
|
|
||||||
{
|
|
||||||
// Define the mapping of files to remote locations
|
|
||||||
var fileLocationMappings = new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
{ "flows.json", "/opt/data/nodered/.node-red/" },
|
|
||||||
{ "settings-user.js", "/opt/data/nodered/.node-red/" },
|
|
||||||
{ "rc.local", "/data/" },
|
|
||||||
{ "dbus-fzsonick-48tl", "/data/"}
|
|
||||||
};
|
|
||||||
|
|
||||||
var nodeRedFilesFolder = Path.Combine(Directory.GetCurrentDirectory(), "NodeRedFiles");
|
|
||||||
if (!Directory.Exists(nodeRedFilesFolder))
|
|
||||||
{
|
|
||||||
return BadRequest("NodeRedFiles folder does not exist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var tasks = fileLocationMappings.Select(async mapping =>
|
|
||||||
{
|
|
||||||
var fileName = mapping.Key;
|
|
||||||
var remoteLocation = mapping.Value;
|
|
||||||
|
|
||||||
var filePath = Path.Combine(nodeRedFilesFolder, fileName);
|
|
||||||
if (!System.IO.File.Exists(filePath))
|
|
||||||
{
|
|
||||||
throw new FileNotFoundException($"File {fileName} not found in {nodeRedFilesFolder}.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute the SCP command to upload the file
|
|
||||||
await Cli.Wrap("rsync")
|
|
||||||
.WithArguments($@"-r {filePath}")
|
|
||||||
.AppendArgument($@"root@{installationIp}:{remoteLocation}")
|
|
||||||
.ExecuteAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await Task.WhenAll(tasks);
|
|
||||||
return Ok("All files uploaded successfully.");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return StatusCode(500, $"An error occurred while uploading files: {ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
|
||||||
# Visual Studio Version 17
|
|
||||||
VisualStudioVersion = 17.5.002.0
|
|
||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VrmGrabber", "VrmGrabber.csproj", "{A3BDD9AD-F065-444E-9C2E-F777810E3BF9}"
|
|
||||||
EndProject
|
|
||||||
Global
|
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
|
||||||
Debug|Any CPU = Debug|Any CPU
|
|
||||||
Release|Any CPU = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
|
||||||
{A3BDD9AD-F065-444E-9C2E-F777810E3BF9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{A3BDD9AD-F065-444E-9C2E-F777810E3BF9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{A3BDD9AD-F065-444E-9C2E-F777810E3BF9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{A3BDD9AD-F065-444E-9C2E-F777810E3BF9}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
|
||||||
HideSolutionNode = FALSE
|
|
||||||
EndGlobalSection
|
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
|
||||||
SolutionGuid = {274A19A1-A0B6-4EAF-BCF6-475F7C511EF3}
|
|
||||||
EndGlobalSection
|
|
||||||
EndGlobal
|
|
|
@ -1,302 +0,0 @@
|
||||||
#!/usr/bin/python2 -u
|
|
||||||
# coding=utf-8
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import serial
|
|
||||||
from os import system
|
|
||||||
|
|
||||||
from pymodbus.client.sync import ModbusSerialClient as Modbus
|
|
||||||
from pymodbus.exceptions import ModbusIOException
|
|
||||||
from pymodbus.pdu import ModbusResponse
|
|
||||||
from os.path import dirname, abspath
|
|
||||||
from sys import path, argv, exit
|
|
||||||
|
|
||||||
path.append(dirname(dirname(abspath(__file__))))
|
|
||||||
|
|
||||||
PAGE_SIZE = 0x100
|
|
||||||
HALF_PAGE = PAGE_SIZE / 2
|
|
||||||
WRITE_ENABLE = [1]
|
|
||||||
SERIAL_STARTER_DIR = '/opt/victronenergy/serial-starter/'
|
|
||||||
FIRMWARE_VERSION_REGISTER = 1054
|
|
||||||
|
|
||||||
ERASE_FLASH_REGISTER = 0x2084
|
|
||||||
RESET_REGISTER = 0x2087
|
|
||||||
|
|
||||||
|
|
||||||
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
|
||||||
# noinspection PyUnreachableCode
|
|
||||||
if False:
|
|
||||||
from typing import List, NoReturn, Iterable, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class LockTTY(object):
|
|
||||||
|
|
||||||
def __init__(self, tty):
|
|
||||||
# type: (str) -> None
|
|
||||||
self.tty = tty
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
system(SERIAL_STARTER_DIR + 'stop-tty.sh ' + self.tty)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
system(SERIAL_STARTER_DIR + 'start-tty.sh ' + self.tty)
|
|
||||||
|
|
||||||
|
|
||||||
def calc_stm32_crc_round(crc, data):
|
|
||||||
# type: (int, int) -> int
|
|
||||||
crc = crc ^ data
|
|
||||||
for _ in range(32):
|
|
||||||
xor = (crc & 0x80000000) != 0
|
|
||||||
crc = (crc & 0x7FFFFFFF) << 1 # clear bit 31 because python ints have "infinite" bits
|
|
||||||
if xor:
|
|
||||||
crc = crc ^ 0x04C11DB7
|
|
||||||
|
|
||||||
return crc
|
|
||||||
|
|
||||||
|
|
||||||
def calc_stm32_crc(data):
|
|
||||||
# type: (Iterable[int]) -> int
|
|
||||||
crc = 0xFFFFFFFF
|
|
||||||
|
|
||||||
for dw in data:
|
|
||||||
crc = calc_stm32_crc_round(crc, dw)
|
|
||||||
|
|
||||||
return crc
|
|
||||||
|
|
||||||
|
|
||||||
def init_modbus(tty):
|
|
||||||
# type: (str) -> Modbus
|
|
||||||
|
|
||||||
return Modbus(
|
|
||||||
port='/dev/' + tty,
|
|
||||||
method='rtu',
|
|
||||||
baudrate=115200,
|
|
||||||
stopbits=1,
|
|
||||||
bytesize=8,
|
|
||||||
timeout=0.15, # seconds
|
|
||||||
parity=serial.PARITY_ODD)
|
|
||||||
|
|
||||||
|
|
||||||
def failed(response):
|
|
||||||
# type: (ModbusResponse) -> bool
|
|
||||||
|
|
||||||
return response.function_code > 0x80
|
|
||||||
|
|
||||||
|
|
||||||
def clear_flash(modbus, slave_address):
|
|
||||||
# type: (Modbus, int) -> bool
|
|
||||||
|
|
||||||
print ('erasing flash...')
|
|
||||||
|
|
||||||
write_response = modbus.write_registers(address=0x2084, values=[1], unit=slave_address)
|
|
||||||
|
|
||||||
if failed(write_response):
|
|
||||||
print('erasing flash FAILED')
|
|
||||||
return False
|
|
||||||
|
|
||||||
flash_countdown = 17
|
|
||||||
while flash_countdown > 0:
|
|
||||||
read_response = modbus.read_holding_registers(address=0x2085, count=1, unit=slave_address)
|
|
||||||
|
|
||||||
if failed(read_response):
|
|
||||||
print('erasing flash FAILED')
|
|
||||||
return False
|
|
||||||
|
|
||||||
if read_response.registers[0] != flash_countdown:
|
|
||||||
flash_countdown = read_response.registers[0]
|
|
||||||
|
|
||||||
msg = str(100 * (16 - flash_countdown) / 16) + '%'
|
|
||||||
print '\r{0} '.format(msg),
|
|
||||||
|
|
||||||
print('done!')
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyShadowingBuiltins
|
|
||||||
def bytes_to_words(bytes):
|
|
||||||
# type: (str) -> List[int]
|
|
||||||
return list(struct.unpack('>' + len(bytes)/2 * 'H', bytes))
|
|
||||||
|
|
||||||
|
|
||||||
def send_half_page_1(modbus, slave_address, data, page):
|
|
||||||
# type: (Modbus, int, str, int) -> NoReturn
|
|
||||||
|
|
||||||
first_half = [page] + bytes_to_words(data[:HALF_PAGE])
|
|
||||||
write_first_half = modbus.write_registers(0x2000, first_half, unit=slave_address)
|
|
||||||
|
|
||||||
if failed(write_first_half):
|
|
||||||
raise Exception("Failed to write page " + str(page))
|
|
||||||
|
|
||||||
|
|
||||||
def send_half_page_2(modbus, slave_address, data, page):
|
|
||||||
# type: (Modbus, int, str, int) -> NoReturn
|
|
||||||
|
|
||||||
registers = bytes_to_words(data[HALF_PAGE:]) + calc_crc(page, data) + WRITE_ENABLE
|
|
||||||
result = modbus.write_registers(0x2041, registers, unit=slave_address)
|
|
||||||
|
|
||||||
if failed(result):
|
|
||||||
raise Exception("Failed to write page " + str(page))
|
|
||||||
|
|
||||||
|
|
||||||
def get_fw_name(fw_path):
|
|
||||||
# type: (str) -> str
|
|
||||||
return fw_path.split('/')[-1].split('.')[0]
|
|
||||||
|
|
||||||
|
|
||||||
def upload_fw(modbus, slave_id, fw_path, fw_name):
|
|
||||||
# type: (Modbus, int, str, str) -> NoReturn
|
|
||||||
|
|
||||||
with open(fw_path, "rb") as f:
|
|
||||||
|
|
||||||
size = os.fstat(f.fileno()).st_size
|
|
||||||
n_pages = size / PAGE_SIZE
|
|
||||||
|
|
||||||
print 'uploading firmware ' + fw_name + ' to BMS ...'
|
|
||||||
|
|
||||||
for page in range(0, n_pages):
|
|
||||||
|
|
||||||
page_data = f.read(PAGE_SIZE)
|
|
||||||
|
|
||||||
msg = "page " + str(page + 1) + '/' + str(n_pages) + ' ' + str(100 * page / n_pages + 1) + '%'
|
|
||||||
print '\r{0} '.format(msg),
|
|
||||||
|
|
||||||
if is_page_empty(page_data):
|
|
||||||
continue
|
|
||||||
|
|
||||||
send_half_page_1(modbus, slave_id, page_data, page)
|
|
||||||
send_half_page_2(modbus, slave_id, page_data, page)
|
|
||||||
|
|
||||||
|
|
||||||
def is_page_empty(page):
|
|
||||||
# type: (str) -> bool
|
|
||||||
return page.count('\xff') == len(page)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_bms(modbus, slave_id):
|
|
||||||
# type: (Modbus, int) -> bool
|
|
||||||
|
|
||||||
print ('resetting BMS...')
|
|
||||||
|
|
||||||
result = modbus.write_registers(RESET_REGISTER, [1], unit=slave_id)
|
|
||||||
|
|
||||||
# expecting a ModbusIOException (timeout)
|
|
||||||
# BMS can no longer reply because it is already reset
|
|
||||||
success = isinstance(result, ModbusIOException)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print('done')
|
|
||||||
else:
|
|
||||||
print('FAILED to reset battery!')
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
|
|
||||||
def calc_crc(page, data):
|
|
||||||
# type: (int, str) -> List[int]
|
|
||||||
|
|
||||||
crc = calc_stm32_crc([page] + bytes_to_words(data))
|
|
||||||
crc_bytes = struct.pack('>L', crc)
|
|
||||||
|
|
||||||
return bytes_to_words(crc_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def identify_battery(modbus, slave_id):
|
|
||||||
# type: (Modbus, int) -> Optional[str]
|
|
||||||
|
|
||||||
target = 'battery #' + str(slave_id) + ' at ' + modbus.port
|
|
||||||
|
|
||||||
try:
|
|
||||||
|
|
||||||
print('contacting ' + target + ' ...')
|
|
||||||
|
|
||||||
response = modbus.read_input_registers(address=FIRMWARE_VERSION_REGISTER, count=1, unit=slave_id)
|
|
||||||
fw = '{0:0>4X}'.format(response.registers[0])
|
|
||||||
|
|
||||||
print('found battery with firmware ' + fw)
|
|
||||||
|
|
||||||
return fw
|
|
||||||
|
|
||||||
except:
|
|
||||||
print('failed to communicate with ' + target + ' !')
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def print_usage():
|
|
||||||
print ('Usage: ' + __file__ + ' <serial device> <battery id> <firmware>')
|
|
||||||
print ('Example: ' + __file__ + ' ttyUSB0 2 A08C.bin')
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cmdline_args(argv):
|
|
||||||
# type: (List[str]) -> (str, str, str, str)
|
|
||||||
|
|
||||||
def fail_with(msg):
|
|
||||||
print(msg)
|
|
||||||
print_usage()
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
if len(argv) < 1:
|
|
||||||
fail_with('missing argument for tty device')
|
|
||||||
|
|
||||||
if len(argv) < 2:
|
|
||||||
fail_with('missing argument for battery ID')
|
|
||||||
|
|
||||||
if len(argv) < 3:
|
|
||||||
fail_with('missing argument for firmware')
|
|
||||||
|
|
||||||
return argv[0], int(argv[1]), argv[2], get_fw_name(argv[2])
|
|
||||||
|
|
||||||
|
|
||||||
def verify_firmware(modbus, battery_id, fw_name):
|
|
||||||
# type: (Modbus, int, str) -> NoReturn
|
|
||||||
|
|
||||||
fw_verify = identify_battery(modbus, battery_id)
|
|
||||||
|
|
||||||
if fw_verify == fw_name:
|
|
||||||
print 'SUCCESS'
|
|
||||||
else:
|
|
||||||
print 'FAILED to verify uploaded firmware!'
|
|
||||||
if fw_verify is not None:
|
|
||||||
print 'expected firmware version ' + fw_name + ' but got ' + fw_verify
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_bms_reboot():
|
|
||||||
# type: () -> NoReturn
|
|
||||||
|
|
||||||
# wait 20s for the battery to reboot
|
|
||||||
|
|
||||||
print 'waiting for BMS to reboot...'
|
|
||||||
|
|
||||||
for t in range(20, 0, -1):
|
|
||||||
print '\r{0} '.format(t),
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
print '0'
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
# type: (List[str]) -> NoReturn
|
|
||||||
|
|
||||||
tty, battery_id, fw_path, fw_name = parse_cmdline_args(argv)
|
|
||||||
|
|
||||||
with LockTTY(tty), init_modbus(tty) as modbus:
|
|
||||||
|
|
||||||
if identify_battery(modbus, battery_id) is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
clear_flash(modbus, battery_id)
|
|
||||||
upload_fw(modbus, battery_id, fw_path, fw_name)
|
|
||||||
|
|
||||||
if not reset_bms(modbus, battery_id):
|
|
||||||
return
|
|
||||||
|
|
||||||
wait_for_bms_reboot()
|
|
||||||
|
|
||||||
verify_firmware(modbus, battery_id, fw_name)
|
|
||||||
|
|
||||||
|
|
||||||
main(argv[1:])
|
|
|
@ -1,303 +0,0 @@
|
||||||
#!/usr/bin/python2 -u
|
|
||||||
# coding=utf-8
|
|
||||||
|
|
||||||
import os
|
|
||||||
import struct
|
|
||||||
from time import sleep
|
|
||||||
|
|
||||||
import serial
|
|
||||||
from os import system
|
|
||||||
|
|
||||||
from pymodbus.client.sync import ModbusSerialClient as Modbus
|
|
||||||
from pymodbus.exceptions import ModbusIOException
|
|
||||||
from pymodbus.pdu import ModbusResponse
|
|
||||||
from os.path import dirname, abspath
|
|
||||||
from sys import path, argv, exit
|
|
||||||
|
|
||||||
path.append(dirname(dirname(abspath(__file__))))
|
|
||||||
|
|
||||||
PAGE_SIZE = 0x100
|
|
||||||
HALF_PAGE = PAGE_SIZE / 2
|
|
||||||
WRITE_ENABLE = [1]
|
|
||||||
SERIAL_STARTER_DIR = '/opt/victronenergy/serial-starter/'
|
|
||||||
FIRMWARE_VERSION_REGISTER = 1054
|
|
||||||
|
|
||||||
ERASE_FLASH_REGISTER = 0x2084
|
|
||||||
RESET_REGISTER = 0x2087
|
|
||||||
|
|
||||||
|
|
||||||
# trick the pycharm type-checker into thinking Callable is in scope, not used at runtime
|
|
||||||
# noinspection PyUnreachableCode
|
|
||||||
if False:
|
|
||||||
from typing import List, NoReturn, Iterable, Optional
|
|
||||||
|
|
||||||
|
|
||||||
class LockTTY(object):
|
|
||||||
|
|
||||||
def __init__(self, tty):
|
|
||||||
# type: (str) -> None
|
|
||||||
self.tty = tty
|
|
||||||
|
|
||||||
def __enter__(self):
|
|
||||||
system(SERIAL_STARTER_DIR + 'stop-tty.sh ' + self.tty)
|
|
||||||
return self
|
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
||||||
system(SERIAL_STARTER_DIR + 'start-tty.sh ' + self.tty)
|
|
||||||
|
|
||||||
|
|
||||||
def calc_stm32_crc_round(crc, data):
|
|
||||||
# type: (int, int) -> int
|
|
||||||
crc = crc ^ data
|
|
||||||
for _ in range(32):
|
|
||||||
xor = (crc & 0x80000000) != 0
|
|
||||||
crc = (crc & 0x7FFFFFFF) << 1 # clear bit 31 because python ints have "infinite" bits
|
|
||||||
if xor:
|
|
||||||
crc = crc ^ 0x04C11DB7
|
|
||||||
|
|
||||||
return crc
|
|
||||||
|
|
||||||
|
|
||||||
def calc_stm32_crc(data):
|
|
||||||
# type: (Iterable[int]) -> int
|
|
||||||
crc = 0xFFFFFFFF
|
|
||||||
|
|
||||||
for dw in data:
|
|
||||||
crc = calc_stm32_crc_round(crc, dw)
|
|
||||||
|
|
||||||
return crc
|
|
||||||
|
|
||||||
|
|
||||||
def init_modbus(tty):
|
|
||||||
# type: (str) -> Modbus
|
|
||||||
|
|
||||||
return Modbus(
|
|
||||||
port='/dev/' + tty,
|
|
||||||
method='rtu',
|
|
||||||
baudrate=115200,
|
|
||||||
stopbits=1,
|
|
||||||
bytesize=8,
|
|
||||||
timeout=0.15, # seconds
|
|
||||||
parity=serial.PARITY_ODD)
|
|
||||||
|
|
||||||
|
|
||||||
def failed(response):
|
|
||||||
# type: (ModbusResponse) -> bool
|
|
||||||
|
|
||||||
# Todo 'ModbusIOException' object has no attribute 'function_code'
|
|
||||||
return response.function_code > 0x80
|
|
||||||
|
|
||||||
|
|
||||||
def clear_flash(modbus, slave_address):
|
|
||||||
# type: (Modbus, int) -> bool
|
|
||||||
|
|
||||||
print ('erasing flash...')
|
|
||||||
|
|
||||||
write_response = modbus.write_registers(address=0x2084, values=[1], unit=slave_address)
|
|
||||||
|
|
||||||
if failed(write_response):
|
|
||||||
print('erasing flash FAILED')
|
|
||||||
return False
|
|
||||||
|
|
||||||
flash_countdown = 17
|
|
||||||
while flash_countdown > 0:
|
|
||||||
read_response = modbus.read_holding_registers(address=0x2085, count=1, unit=slave_address)
|
|
||||||
|
|
||||||
if failed(read_response):
|
|
||||||
print('erasing flash FAILED')
|
|
||||||
return False
|
|
||||||
|
|
||||||
if read_response.registers[0] != flash_countdown:
|
|
||||||
flash_countdown = read_response.registers[0]
|
|
||||||
|
|
||||||
msg = str(100 * (16 - flash_countdown) / 16) + '%'
|
|
||||||
print('\r{0} '.format(msg), end=' ')
|
|
||||||
|
|
||||||
print('done!')
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyShadowingBuiltins
|
|
||||||
def bytes_to_words(bytes):
|
|
||||||
# type: (str) -> List[int]
|
|
||||||
return list(struct.unpack('>' + len(bytes)/2 * 'H', bytes))
|
|
||||||
|
|
||||||
|
|
||||||
def send_half_page_1(modbus, slave_address, data, page):
|
|
||||||
# type: (Modbus, int, str, int) -> NoReturn
|
|
||||||
|
|
||||||
first_half = [page] + bytes_to_words(data[:HALF_PAGE])
|
|
||||||
write_first_half = modbus.write_registers(0x2000, first_half, unit=slave_address)
|
|
||||||
|
|
||||||
if failed(write_first_half):
|
|
||||||
raise Exception("Failed to write page " + str(page))
|
|
||||||
|
|
||||||
|
|
||||||
def send_half_page_2(modbus, slave_address, data, page):
|
|
||||||
# type: (Modbus, int, str, int) -> NoReturn
|
|
||||||
|
|
||||||
registers = bytes_to_words(data[HALF_PAGE:]) + calc_crc(page, data) + WRITE_ENABLE
|
|
||||||
result = modbus.write_registers(0x2041, registers, unit=slave_address)
|
|
||||||
|
|
||||||
if failed(result):
|
|
||||||
raise Exception("Failed to write page " + str(page))
|
|
||||||
|
|
||||||
|
|
||||||
def get_fw_name(fw_path):
|
|
||||||
# type: (str) -> str
|
|
||||||
return fw_path.split('/')[-1].split('.')[0]
|
|
||||||
|
|
||||||
|
|
||||||
def upload_fw(modbus, slave_id, fw_path, fw_name):
|
|
||||||
# type: (Modbus, int, str, str) -> NoReturn
|
|
||||||
|
|
||||||
with open(fw_path, "rb") as f:
|
|
||||||
|
|
||||||
size = os.fstat(f.fileno()).st_size
|
|
||||||
n_pages = size / PAGE_SIZE
|
|
||||||
|
|
||||||
print('uploading firmware ' + fw_name + ' to BMS ...')
|
|
||||||
|
|
||||||
for page in range(0, n_pages):
|
|
||||||
|
|
||||||
page_data = f.read(PAGE_SIZE)
|
|
||||||
|
|
||||||
msg = "page " + str(page + 1) + '/' + str(n_pages) + ' ' + str(100 * page / n_pages + 1) + '%'
|
|
||||||
print('\r{0} '.format(msg), end=' ')
|
|
||||||
|
|
||||||
if is_page_empty(page_data):
|
|
||||||
continue
|
|
||||||
|
|
||||||
send_half_page_1(modbus, slave_id, page_data, page)
|
|
||||||
send_half_page_2(modbus, slave_id, page_data, page)
|
|
||||||
|
|
||||||
|
|
||||||
def is_page_empty(page):
|
|
||||||
# type: (str) -> bool
|
|
||||||
return page.count('\xff') == len(page)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_bms(modbus, slave_id):
|
|
||||||
# type: (Modbus, int) -> bool
|
|
||||||
|
|
||||||
print ('resetting BMS...')
|
|
||||||
|
|
||||||
result = modbus.write_registers(RESET_REGISTER, [1], unit=slave_id)
|
|
||||||
|
|
||||||
# expecting a ModbusIOException (timeout)
|
|
||||||
# BMS can no longer reply because it is already reset
|
|
||||||
success = isinstance(result, ModbusIOException)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
print('done')
|
|
||||||
else:
|
|
||||||
print('FAILED to reset battery!')
|
|
||||||
|
|
||||||
return success
|
|
||||||
|
|
||||||
|
|
||||||
def calc_crc(page, data):
|
|
||||||
# type: (int, str) -> List[int]
|
|
||||||
|
|
||||||
crc = calc_stm32_crc([page] + bytes_to_words(data))
|
|
||||||
crc_bytes = struct.pack('>L', crc)
|
|
||||||
|
|
||||||
return bytes_to_words(crc_bytes)
|
|
||||||
|
|
||||||
|
|
||||||
def identify_battery(modbus, slave_id):
|
|
||||||
# type: (Modbus, int) -> Optional[str]
|
|
||||||
|
|
||||||
target = 'battery #' + str(slave_id) + ' at ' + modbus.port
|
|
||||||
|
|
||||||
try:
|
|
||||||
|
|
||||||
print(('contacting ' + target + ' ...'))
|
|
||||||
|
|
||||||
response = modbus.read_input_registers(address=FIRMWARE_VERSION_REGISTER, count=1, unit=slave_id)
|
|
||||||
fw = '{0:0>4X}'.format(response.registers[0])
|
|
||||||
|
|
||||||
print(('found battery with firmware ' + fw))
|
|
||||||
|
|
||||||
return fw
|
|
||||||
|
|
||||||
except:
|
|
||||||
print(('failed to communicate with ' + target + ' !'))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def print_usage():
|
|
||||||
print(('Usage: ' + __file__ + ' <serial device> <battery id> <firmware>'))
|
|
||||||
print(('Example: ' + __file__ + ' ttyUSB0 2 A08C.bin'))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cmdline_args(argv):
|
|
||||||
# type: (List[str]) -> (str, str, str, str)
|
|
||||||
|
|
||||||
def fail_with(msg):
|
|
||||||
print(msg)
|
|
||||||
print_usage()
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
if len(argv) < 1:
|
|
||||||
fail_with('missing argument for tty device')
|
|
||||||
|
|
||||||
if len(argv) < 2:
|
|
||||||
fail_with('missing argument for battery ID')
|
|
||||||
|
|
||||||
if len(argv) < 3:
|
|
||||||
fail_with('missing argument for firmware')
|
|
||||||
|
|
||||||
return argv[0], int(argv[1]), argv[2], get_fw_name(argv[2])
|
|
||||||
|
|
||||||
|
|
||||||
def verify_firmware(modbus, battery_id, fw_name):
|
|
||||||
# type: (Modbus, int, str) -> NoReturn
|
|
||||||
|
|
||||||
fw_verify = identify_battery(modbus, battery_id)
|
|
||||||
|
|
||||||
if fw_verify == fw_name:
|
|
||||||
print('SUCCESS')
|
|
||||||
else:
|
|
||||||
print('FAILED to verify uploaded firmware!')
|
|
||||||
if fw_verify is not None:
|
|
||||||
print('expected firmware version ' + fw_name + ' but got ' + fw_verify)
|
|
||||||
|
|
||||||
|
|
||||||
def wait_for_bms_reboot():
|
|
||||||
# type: () -> NoReturn
|
|
||||||
|
|
||||||
# wait 20s for the battery to reboot
|
|
||||||
|
|
||||||
print('waiting for BMS to reboot...')
|
|
||||||
|
|
||||||
for t in range(20, 0, -1):
|
|
||||||
print('\r{0} '.format(t), end=' ')
|
|
||||||
sleep(1)
|
|
||||||
|
|
||||||
print('0')
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv):
|
|
||||||
# type: (List[str]) -> NoReturn
|
|
||||||
|
|
||||||
tty, battery_id, fw_path, fw_name = parse_cmdline_args(argv)
|
|
||||||
|
|
||||||
with LockTTY(tty), init_modbus(tty) as modbus:
|
|
||||||
|
|
||||||
if identify_battery(modbus, battery_id) is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
clear_flash(modbus, battery_id)
|
|
||||||
upload_fw(modbus, battery_id, fw_path, fw_name)
|
|
||||||
|
|
||||||
if not reset_bms(modbus, battery_id):
|
|
||||||
return
|
|
||||||
|
|
||||||
wait_for_bms_reboot()
|
|
||||||
|
|
||||||
verify_firmware(modbus, battery_id, fw_name)
|
|
||||||
|
|
||||||
|
|
||||||
main(argv[1:])
|
|
|
@ -3978,8 +3978,7 @@ var require_node_domexception = __commonJS({
|
||||||
var import_node_fs, import_node_domexception, stat, BlobDataItem;
|
var import_node_fs, import_node_domexception, stat, BlobDataItem;
|
||||||
var init_from = __esm({
|
var init_from = __esm({
|
||||||
"node_modules/fetch-blob/from.js"() {
|
"node_modules/fetch-blob/from.js"() {
|
||||||
// import_node_fs = require("node:fs");
|
import_node_fs = require("node:fs");
|
||||||
import_node_fs = require("fs");
|
|
||||||
import_node_domexception = __toESM(require_node_domexception(), 1);
|
import_node_domexception = __toESM(require_node_domexception(), 1);
|
||||||
init_file();
|
init_file();
|
||||||
init_fetch_blob();
|
init_fetch_blob();
|
||||||
|
@ -13684,18 +13683,11 @@ var require_linq = __commonJS({
|
||||||
});
|
});
|
||||||
|
|
||||||
// node_modules/node-fetch/src/index.js
|
// node_modules/node-fetch/src/index.js
|
||||||
// var import_node_http2 = __toESM(require("node:http"), 1);
|
var import_node_http2 = __toESM(require("node:http"), 1);
|
||||||
// var import_node_https = __toESM(require("node:https"), 1);
|
var import_node_https = __toESM(require("node:https"), 1);
|
||||||
// var import_node_zlib = __toESM(require("node:zlib"), 1);
|
var import_node_zlib = __toESM(require("node:zlib"), 1);
|
||||||
// var import_node_stream2 = __toESM(require("node:stream"), 1);
|
var import_node_stream2 = __toESM(require("node:stream"), 1);
|
||||||
// var import_node_buffer2 = require("node:buffer");
|
var import_node_buffer2 = require("node:buffer");
|
||||||
|
|
||||||
var import_node_http2 = __toESM(require("http"), 1);
|
|
||||||
var import_node_https = __toESM(require("https"), 1);
|
|
||||||
var import_node_zlib = __toESM(require("zlib"), 1);
|
|
||||||
var import_node_stream2 = __toESM(require("stream"), 1);
|
|
||||||
var import_node_buffer2 = require("buffer");
|
|
||||||
|
|
||||||
|
|
||||||
// node_modules/data-uri-to-buffer/dist/index.js
|
// node_modules/data-uri-to-buffer/dist/index.js
|
||||||
function dataUriToBuffer(uri) {
|
function dataUriToBuffer(uri) {
|
||||||
|
@ -13737,13 +13729,9 @@ function dataUriToBuffer(uri) {
|
||||||
var dist_default = dataUriToBuffer;
|
var dist_default = dataUriToBuffer;
|
||||||
|
|
||||||
// node_modules/node-fetch/src/body.js
|
// node_modules/node-fetch/src/body.js
|
||||||
// var import_node_stream = __toESM(require("node:stream"), 1);
|
var import_node_stream = __toESM(require("node:stream"), 1);
|
||||||
// var import_node_util = require("node:util");
|
var import_node_util = require("node:util");
|
||||||
// var import_node_buffer = require("node:buffer");
|
var import_node_buffer = require("node:buffer");
|
||||||
var import_node_stream = __toESM(require("stream"), 1);
|
|
||||||
var import_node_util = require("util");
|
|
||||||
var import_node_buffer = require("buffer");
|
|
||||||
|
|
||||||
init_fetch_blob();
|
init_fetch_blob();
|
||||||
init_esm_min();
|
init_esm_min();
|
||||||
|
|
||||||
|
@ -14003,10 +13991,8 @@ var writeToStream = async (dest, { body }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// node_modules/node-fetch/src/headers.js
|
// node_modules/node-fetch/src/headers.js
|
||||||
// var import_node_util2 = require("node:util");
|
var import_node_util2 = require("node:util");
|
||||||
// var import_node_http = __toESM(require("node:http"), 1);
|
var import_node_http = __toESM(require("node:http"), 1);
|
||||||
var import_node_util2 = require("util");
|
|
||||||
var import_node_http = __toESM(require("http"), 1);
|
|
||||||
var validateHeaderName = typeof import_node_http.default.validateHeaderName === "function" ? import_node_http.default.validateHeaderName : (name) => {
|
var validateHeaderName = typeof import_node_http.default.validateHeaderName === "function" ? import_node_http.default.validateHeaderName : (name) => {
|
||||||
if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(name)) {
|
if (!/^[\^`\-\w!#$%&'*+.|~]+$/.test(name)) {
|
||||||
const error2 = new TypeError(`Header name must be a valid HTTP token [${name}]`);
|
const error2 = new TypeError(`Header name must be a valid HTTP token [${name}]`);
|
||||||
|
@ -14259,10 +14245,8 @@ Object.defineProperties(Response.prototype, {
|
||||||
});
|
});
|
||||||
|
|
||||||
// node_modules/node-fetch/src/request.js
|
// node_modules/node-fetch/src/request.js
|
||||||
// var import_node_url = require("node:url");
|
var import_node_url = require("node:url");
|
||||||
// var import_node_util3 = require("node:util");
|
var import_node_util3 = require("node:util");
|
||||||
var import_node_url = require("url");
|
|
||||||
var import_node_util3 = require("util");
|
|
||||||
|
|
||||||
// node_modules/node-fetch/src/utils/get-search.js
|
// node_modules/node-fetch/src/utils/get-search.js
|
||||||
var getSearch = (parsedURL) => {
|
var getSearch = (parsedURL) => {
|
||||||
|
@ -14275,8 +14259,7 @@ var getSearch = (parsedURL) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// node_modules/node-fetch/src/utils/referrer.js
|
// node_modules/node-fetch/src/utils/referrer.js
|
||||||
// var import_node_net = require("node:net");
|
var import_node_net = require("node:net");
|
||||||
var import_node_net = require("net");
|
|
||||||
function stripURLForUseAsAReferrer(url, originOnly = false) {
|
function stripURLForUseAsAReferrer(url, originOnly = false) {
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
return "no-referrer";
|
return "no-referrer";
|
||||||
|
@ -15060,29 +15043,20 @@ async function getAllDataFromVrm() {
|
||||||
const nbMppts = devices.count((d) => d.name === "Solar Charger");
|
const nbMppts = devices.count((d) => d.name === "Solar Charger");
|
||||||
return {
|
return {
|
||||||
name: installation.name,
|
name: installation.name,
|
||||||
// inverter: inverter?.productName ?? "unknown",
|
inverter: inverter?.productName ?? "unknown",
|
||||||
inverter: (inverter && inverter.productName) ? inverter.productName : "unknown",
|
inverterFw: inverter?.firmwareVersion ?? "unknown",
|
||||||
// inverterFw: inverter?.firmwareVersion ?? "unknown",
|
|
||||||
inverterFw: (inverter && inverter.firmwareVersion) ? inverter.firmwareVersion : "unknown",
|
|
||||||
identifier: installation.identifier,
|
identifier: installation.identifier,
|
||||||
hasMains: installation.hasMains > 0,
|
hasMains: installation.hasMains > 0,
|
||||||
hasGenerator: installation.hasGenerator > 0,
|
hasGenerator: installation.hasGenerator > 0,
|
||||||
nbMppts,
|
nbMppts,
|
||||||
nbPvInverters,
|
nbPvInverters,
|
||||||
// firmware: gateway?.firmwareVersion ?? "unknown",
|
firmware: gateway?.firmwareVersion ?? "unknown",
|
||||||
// autoUpdate: gateway?.autoUpdate ?? "unknown",
|
autoUpdate: gateway?.autoUpdate ?? "unknown",
|
||||||
// updateTo: gateway?.updateTo ?? "unknown",
|
updateTo: gateway?.updateTo ?? "unknown",
|
||||||
// lastConnection: gateway?.lastConnection ?? 0,
|
lastConnection: gateway?.lastConnection ?? 0,
|
||||||
// lastPowerUpOrRestart: gateway?.lastPowerUpOrRestart ?? 0,
|
lastPowerUpOrRestart: gateway?.lastPowerUpOrRestart ?? 0,
|
||||||
// machineSerialNumber: gateway?.machineSerialNumber ?? "unknown",
|
machineSerialNumber: gateway?.machineSerialNumber ?? "unknown",
|
||||||
// controllerType: gateway?.productName ?? "unknown",
|
controllerType: gateway?.productName ?? "unknown",
|
||||||
firmware: (gateway && gateway.firmwareVersion) ? gateway.firmwareVersion : "unknown",
|
|
||||||
autoUpdate: (gateway && gateway.autoUpdate) ? gateway.autoUpdate : "unknown",
|
|
||||||
updateTo: (gateway && gateway.updateTo) ? gateway.updateTo : "unknown",
|
|
||||||
lastConnection: (gateway && gateway.lastConnection) ? gateway.lastConnection : 0,
|
|
||||||
lastPowerUpOrRestart: (gateway && gateway.lastPowerUpOrRestart) ? gateway.lastPowerUpOrRestart : 0,
|
|
||||||
machineSerialNumber: (gateway && gateway.machineSerialNumber) ? gateway.machineSerialNumber : "unknown",
|
|
||||||
controllerType: (gateway && gateway.productName) ? gateway.productName : "unknown",
|
|
||||||
vrmLink: `vrm.victronenergy.com/installation/${installation.idSite}`,
|
vrmLink: `vrm.victronenergy.com/installation/${installation.idSite}`,
|
||||||
accessLevel: installation.accessLevel,
|
accessLevel: installation.accessLevel,
|
||||||
syscreated: installation.syscreated,
|
syscreated: installation.syscreated,
|
||||||
|
@ -15094,29 +15068,12 @@ async function getAllDataFromVrm() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// function getVpnIpFromHttp(vpnName) {
|
|
||||||
// return fetch(`${vpnIp}/vpn/${vpnName}`).then((r2) => r2.text()).then((t2) => t2.match(rxIp)?.firstOrDefault()?.replace("ifconfig-push", "").trim() ?? "");
|
|
||||||
// }
|
|
||||||
|
|
||||||
function getVpnIpFromHttp(vpnName) {
|
function getVpnIpFromHttp(vpnName) {
|
||||||
return fetch(`${vpnIp}/vpn/${vpnName}`)
|
return fetch(`${vpnIp}/vpn/${vpnName}`).then((r2) => r2.text()).then((t2) => t2.match(rxIp)?.firstOrDefault()?.replace("ifconfig-push", "").trim() ?? "");
|
||||||
.then((r2) => r2.text())
|
|
||||||
.then((t2) => {
|
|
||||||
const match = t2.match(rxIp);
|
|
||||||
return match && match.length > 0 ? match[0].replace("ifconfig-push", "").trim() : "";
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// function getVpnIpFromFs(vpnName) {
|
|
||||||
// return import_fs3.default.readFileSync(`${ccdDir}/${vpnName}`, "utf-8").match(rxIp)?.firstOrDefault()?.replace("ifconfig-push", "").trim() ?? "";
|
|
||||||
// }
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
function getVpnIpFromFs(vpnName) {
|
function getVpnIpFromFs(vpnName) {
|
||||||
const fileContent = fs.readFileSync(`${ccdDir}/${vpnName}`, "utf-8");
|
return import_fs3.default.readFileSync(`${ccdDir}/${vpnName}`, "utf-8").match(rxIp)?.firstOrDefault()?.replace("ifconfig-push", "").trim() ?? "";
|
||||||
const match = fileContent.match(rxIp);
|
|
||||||
return match && match.length > 0 ? match[0].replace("ifconfig-push", "").trim() : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getVpnOnlineStatusFromHttp() {
|
function getVpnOnlineStatusFromHttp() {
|
||||||
return fetch(`${vpnIp}/vpnstatus.txt`).then((r2) => r2.text()).then((s2) => s2.split("\n"));
|
return fetch(`${vpnIp}/vpnstatus.txt`).then((r2) => r2.text()).then((s2) => s2.split("\n"));
|
||||||
}
|
}
|
||||||
|
@ -15250,9 +15207,7 @@ var usage = {
|
||||||
"default value": "if 'field' is omitted it defaults to 'name'"
|
"default value": "if 'field' is omitted it defaults to 'name'"
|
||||||
};
|
};
|
||||||
function handleRequest(request) {
|
function handleRequest(request) {
|
||||||
// const url = request?.url;
|
const url = request?.url;
|
||||||
const url = request && request.url;
|
|
||||||
|
|
||||||
if (isUndefined(url))
|
if (isUndefined(url))
|
||||||
return [];
|
return [];
|
||||||
if (url === "/")
|
if (url === "/")
|
||||||
|
@ -15261,12 +15216,10 @@ function handleRequest(request) {
|
||||||
const [installations, field] = filterInstallations(where5);
|
const [installations, field] = filterInstallations(where5);
|
||||||
if (select6 === "*")
|
if (select6 === "*")
|
||||||
return installations.toArray();
|
return installations.toArray();
|
||||||
// if ((select6 ?? field) === "name")
|
if ((select6 ?? field) === "name")
|
||||||
if ((select6 || field) === "name")
|
|
||||||
return installations.select((i2) => i2.name).toArray();
|
return installations.select((i2) => i2.name).toArray();
|
||||||
const record = {};
|
const record = {};
|
||||||
// installations.toArray().forEach((i2) => record[i2.name] = getField(i2, select6 ?? field));
|
installations.toArray().forEach((i2) => record[i2.name] = getField(i2, select6 ?? field));
|
||||||
installations.toArray().forEach((i2) => record[i2.name] = getField(i2, select6 !== null && select6 !== undefined ? select6 : field));
|
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
function serve(request, response) {
|
function serve(request, response) {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"name": "VrmSync",
|
"name": "IeApi",
|
||||||
"lockfileVersion": 2,
|
"lockfileVersion": 2,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.14.23",
|
"esbuild": "^0.14.23",
|
||||||
"linq-to-typescript": "^10.0.0"
|
"linq-to-typescript": "^10.0.0",
|
||||||
|
"rxjs": "^7.5.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^17.0.21",
|
"@types/node": "^17.0.21",
|
||||||
|
@ -412,13 +413,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/braces": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.0.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
|
@ -1121,11 +1121,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fill-range": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
},
|
},
|
||||||
|
@ -1328,7 +1327,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
|
@ -1390,6 +1388,18 @@
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
@ -1456,11 +1466,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-fetch": {
|
"node_modules/node-fetch": {
|
||||||
"version": "3.3.2",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.0.tgz",
|
||||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
"integrity": "sha512-8xeimMwMItMw8hRrOl3C9/xzU49HV/yE6ORew/l+dxWimO5A4Ra8ld2rerlJvc/O7et5Z1zrWsPX43v1QBjCxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"data-uri-to-buffer": "^4.0.0",
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
"fetch-blob": "^3.1.4",
|
"fetch-blob": "^3.1.4",
|
||||||
|
@ -1658,12 +1667,27 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz",
|
||||||
|
"integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs/node_modules/tslib": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.6.2",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
|
||||||
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
|
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"dependencies": {
|
||||||
|
"lru-cache": "^6.0.0"
|
||||||
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
},
|
},
|
||||||
|
@ -1748,7 +1772,6 @@
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
},
|
},
|
||||||
|
@ -1854,11 +1877,10 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
||||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
@ -1868,6 +1890,12 @@
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||||
"dev": true
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"dev": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -2133,12 +2161,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"braces": {
|
"braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"callsites": {
|
"callsites": {
|
||||||
|
@ -2562,9 +2590,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fill-range": {
|
"fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
|
@ -2766,6 +2794,15 @@
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"lru-cache": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"yallist": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"merge2": {
|
"merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
@ -2810,9 +2847,9 @@
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node-fetch": {
|
"node-fetch": {
|
||||||
"version": "3.3.2",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.0.tgz",
|
||||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
"integrity": "sha512-8xeimMwMItMw8hRrOl3C9/xzU49HV/yE6ORew/l+dxWimO5A4Ra8ld2rerlJvc/O7et5Z1zrWsPX43v1QBjCxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"data-uri-to-buffer": "^4.0.0",
|
"data-uri-to-buffer": "^4.0.0",
|
||||||
|
@ -2930,11 +2967,29 @@
|
||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rxjs": {
|
||||||
|
"version": "7.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.5.tgz",
|
||||||
|
"integrity": "sha512-sy+H0pQofO95VDmFLzyaw9xNJU4KTRSwQIGM6+iG3SypAtCiLDzpeG8sJrNCWn2Up9km+KhkvTdbkrdy+yzZdw==",
|
||||||
|
"requires": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"semver": {
|
"semver": {
|
||||||
"version": "7.6.2",
|
"version": "7.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
|
||||||
"integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==",
|
"integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"lru-cache": "^6.0.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"shebang-command": {
|
"shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
|
@ -3063,9 +3118,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"word-wrap": {
|
"word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
|
||||||
"integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
|
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"wrappy": {
|
"wrappy": {
|
||||||
|
@ -3073,6 +3128,12 @@
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||||
"dev": true
|
"dev": true
|
||||||
|
},
|
||||||
|
"yallist": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
|
||||||
|
"dev": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue