Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
31db415be8
|
@ -1,54 +0,0 @@
|
|||
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.
|
@ -1,160 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
#!/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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,139 +0,0 @@
|
|||
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,214 +0,0 @@
|
|||
# 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.
|
@ -111,14 +111,6 @@ class S3config:
|
|||
).decode()
|
||||
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):
|
||||
"""
|
||||
Reads a CSV file from the given path and returns its content as a single string.
|
||||
|
@ -139,7 +131,6 @@ CSV_DIR = "/data/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:
|
||||
|
@ -152,7 +143,9 @@ def interpret_limb_bitmap(bitmap_value):
|
|||
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 calc_power_limit_imposed_by_voltage_limit(v, i, v_limit, r_int):
|
||||
|
@ -740,34 +733,39 @@ def update_state_from_dictionaries(current_warnings, current_alarms, node_number
|
|||
"Alarms": []
|
||||
}
|
||||
|
||||
|
||||
alarms_number_list = []
|
||||
for node_number in node_numbers:
|
||||
cnt = 0
|
||||
for alarm_name, alarm_value in current_alarms.items():
|
||||
if str(node_number) in alarm_name and alarm_value:
|
||||
for i, alarm_value in enumerate(current_alarms.values()):
|
||||
if int(list(current_alarms.keys())[i].split("/")[3]) == int(node_number):
|
||||
if alarm_value:
|
||||
cnt+=1
|
||||
alarms_number_list.append(cnt)
|
||||
|
||||
|
||||
warnings_number_list = []
|
||||
for node_number in node_numbers:
|
||||
cnt = 0
|
||||
for warning_name, warning_value in current_warnings.items():
|
||||
if str(node_number) in warning_name and warning_value:
|
||||
for i, warning_value in enumerate(current_warnings.values()):
|
||||
if int(list(current_warnings.keys())[i].split("/")[3]) == int(node_number):
|
||||
if warning_value:
|
||||
cnt+=1
|
||||
warnings_number_list.append(cnt)
|
||||
|
||||
|
||||
# Evaluate alarms
|
||||
if any(changed_alarms.values()):
|
||||
for i, changed_alarm in enumerate(changed_alarms.values()):
|
||||
if changed_alarm and list(current_alarms.values())[i]:
|
||||
status_message["Alarms"].append(AlarmOrWarning(list(current_alarms.keys())[i],"System").to_dict())
|
||||
description = list(current_alarms.keys())[i].split("/")[-1]
|
||||
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()):
|
||||
for i, changed_warning in enumerate(changed_warnings.values()):
|
||||
if changed_warning and list(current_warnings.values())[i]:
|
||||
status_message["Warnings"].append(AlarmOrWarning(list(current_warnings.keys())[i],"System").to_dict())
|
||||
description = list(current_warnings.keys())[i].split("/")[-1]
|
||||
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()):
|
||||
status_message["Status"]=2
|
||||
|
@ -847,44 +845,10 @@ def read_warning_and_alarm_flags():
|
|||
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)),
|
||||
CsvSignal('/Battery/Devices/AlarmFlags/2 or more string are disabled',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):
|
||||
global start_time
|
||||
# type: (Modbus, Iterable[Battery], DBus, Iterable[Signal]) -> bool
|
||||
"""
|
||||
Main update function
|
||||
|
@ -903,21 +867,17 @@ def update(modbus, batteries, dbus, signals, csv_signals):
|
|||
# Iterate over each node and signal to create rows in the new format
|
||||
for i, node in enumerate(node_numbers):
|
||||
for s in warnings_signals:
|
||||
signal_name = insert_id(s.name, i+1)
|
||||
signal_name = insert_id(s.name, node)
|
||||
value = s.get_value(statuses[i])
|
||||
current_warnings[signal_name] = value
|
||||
for s in alarm_signals:
|
||||
signal_name = insert_id(s.name, i+1)
|
||||
signal_name = insert_id(s.name, node)
|
||||
value = s.get_value(statuses[i])
|
||||
current_alarms[signal_name] = value
|
||||
#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)
|
||||
publish_values(dbus, signals, statuses)
|
||||
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')
|
||||
return True
|
||||
|
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.'
|
||||
|
||||
#s3 configuration
|
||||
S3BUCKET = "13-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||
S3KEY = "EXOcca50b894afa583d8d380dd1"
|
||||
S3SECRET = "7fmdIN1WL8WL9k-20YjLZC5liH2qCwYrGP31Y4dityk"
|
||||
S3BUCKET = "2-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||
S3KEY = "EXO5b2e35442791260eaaa7bdc8"
|
||||
S3SECRET = "XFFOVzenDiEQoLPmhK6ML9RfQfsAMhrAs25MfJxi-24"
|
||||
|
||||
# driver configuration
|
||||
|
||||
|
|
|
@ -90,6 +90,25 @@ def read_bitmap(register):
|
|||
|
||||
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):
|
||||
# type: (unicode) -> Callable[[unicode], unicode]
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,51 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
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
|
|
@ -1,731 +0,0 @@
|
|||
#! /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:])
|
|
@ -1,7 +0,0 @@
|
|||
#!/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.
|
@ -1,51 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
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
|
||||
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
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
|
|
@ -1,980 +0,0 @@
|
|||
#!/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.
|
@ -1,276 +0,0 @@
|
|||
#!/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')
|
|
@ -1,614 +0,0 @@
|
|||
#!/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)
|
|
@ -1,7 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
. /opt/victronenergy/serial-starter/run-service.sh
|
||||
|
||||
app=/opt/victronenergy/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py
|
||||
args="$tty"
|
||||
start $args
|
|
@ -564,12 +564,20 @@ public class Controller : ControllerBase
|
|||
return Ok();
|
||||
}
|
||||
|
||||
[HttpPost(nameof(InsertNewAction))]
|
||||
public async Task<ActionResult<IEnumerable<Object>>> InsertNewAction([FromBody] UserAction action, Token authToken)
|
||||
{
|
||||
var session = Db.GetSession(authToken);
|
||||
var actionSuccess = await session.RecordUserAction(action);
|
||||
return actionSuccess ? Ok() : Unauthorized();
|
||||
|
||||
}
|
||||
|
||||
|
||||
[HttpPost(nameof(EditInstallationConfig))]
|
||||
public async Task<ActionResult<IEnumerable<Object>>> EditInstallationConfig([FromBody] Configuration config, Int64 installationId,Token authToken)
|
||||
{
|
||||
var session = Db.GetSession(authToken);
|
||||
//Console.WriteLine(config.GridSetPoint);
|
||||
|
||||
// Send configuration changes
|
||||
var success = await session.SendInstallationConfig(installationId, config);
|
||||
|
@ -577,7 +585,15 @@ public class Controller : ControllerBase
|
|||
// Record configuration change
|
||||
if (success)
|
||||
{
|
||||
var actionSuccess = await session.RecordUserAction(installationId, config);
|
||||
// Create a new UserAction object
|
||||
var action = new UserAction
|
||||
{
|
||||
InstallationId = installationId,
|
||||
Timestamp = DateTime.Now,
|
||||
Description = config.GetConfigurationString()
|
||||
};
|
||||
|
||||
var actionSuccess = await session.RecordUserAction(action);
|
||||
return actionSuccess?Ok():Unauthorized();
|
||||
}
|
||||
|
||||
|
|
|
@ -21,9 +21,8 @@ public class Installation : TreeNode
|
|||
public String WriteRoleId { get; set; } = "";
|
||||
|
||||
public int Product { get; set; } = 0;
|
||||
public int Device { get; set; } = 0;
|
||||
[Ignore]
|
||||
public String OrderNumbers { get; set; }
|
||||
public String VrmLink { get; set; } = "";
|
||||
|
||||
|
||||
}
|
|
@ -102,22 +102,14 @@ public static class SessionMethods
|
|||
&& await installation.SendConfig(configuration);
|
||||
}
|
||||
|
||||
public static async Task<Boolean> RecordUserAction(this Session? session, Int64 installationId, Configuration newConfiguration)
|
||||
public static async Task<Boolean> RecordUserAction(this Session? session, UserAction action)
|
||||
{
|
||||
var user = session?.User;
|
||||
var timestamp = DateTime.Now;
|
||||
|
||||
if (user is null || user.UserType == 0)
|
||||
return false;
|
||||
|
||||
// Create a new UserAction object
|
||||
var action = new UserAction
|
||||
{
|
||||
UserName = user.Name,
|
||||
InstallationId = installationId,
|
||||
Timestamp = timestamp,
|
||||
Description = newConfiguration.GetConfigurationString()
|
||||
};
|
||||
action.UserName = user.Name;
|
||||
|
||||
// Save the configuration change to the database
|
||||
Db.HandleAction(action);
|
||||
|
|
|
@ -88,7 +88,7 @@ public static partial class Db
|
|||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("---------------Added the new Error to the database-----------------");
|
||||
Console.WriteLine("---------------Added the new Action to the database-----------------");
|
||||
Create(newAction);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ using InnovEnergy.App.Backend.Database;
|
|||
using InnovEnergy.App.Backend.DataTypes;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using InnovEnergy.Lib.Mailer;
|
||||
|
||||
namespace InnovEnergy.App.Backend.Websockets;
|
||||
|
||||
|
@ -58,9 +59,8 @@ public static class RabbitMqManager
|
|||
//Consumer received a message
|
||||
if (receivedStatusMessage != null)
|
||||
{
|
||||
Console.WriteLine("----------------------------------------------");
|
||||
|
||||
int installationId = (int)Db.Installations.Where(f => f.Product == receivedStatusMessage.Product && f.S3BucketId == receivedStatusMessage.InstallationId).Select(f => f.Id).FirstOrDefault();
|
||||
Installation installation = Db.Installations.FirstOrDefault(f => f.Product == receivedStatusMessage.Product && f.S3BucketId == receivedStatusMessage.InstallationId);
|
||||
int installationId = (int )installation.Id;
|
||||
Console.WriteLine("Received a message from installation: " + installationId + " , product is: "+receivedStatusMessage.Product+ " and status is: " + receivedStatusMessage.Status);
|
||||
|
||||
//This is a heartbit message, just update the timestamp for this installation.
|
||||
|
@ -98,17 +98,51 @@ public static class RabbitMqManager
|
|||
if (receivedStatusMessage.Alarms != null)
|
||||
{
|
||||
|
||||
string monitorLink;
|
||||
if (installation.Product == 0)
|
||||
{
|
||||
monitorLink =
|
||||
$"https://monitor.innov.energy/installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
else
|
||||
{
|
||||
monitorLink =
|
||||
$"https://monitor.innov.energy/salidomo_installations/list/installation/{installation.S3BucketId}/batteryview";
|
||||
}
|
||||
|
||||
foreach (var alarm in receivedStatusMessage.Alarms)
|
||||
{
|
||||
Error newError = new Error
|
||||
{
|
||||
InstallationId = installationId,
|
||||
InstallationId = installation.Id,
|
||||
Description = alarm.Description,
|
||||
Date = alarm.Date,
|
||||
Time = alarm.Time,
|
||||
DeviceCreatedTheMessage = alarm.CreatedBy,
|
||||
Seen = false
|
||||
}; Console.WriteLine("Add an alarm for installation "+installationId);
|
||||
};
|
||||
|
||||
Console.WriteLine("Add an alarm for installation "+installationId);
|
||||
|
||||
// Send replace battery email to support team if this alarm is "NeedToReplaceBattery"
|
||||
if (alarm.Description == "2 or more string are disabled")
|
||||
{
|
||||
Console.WriteLine("Send replace battery email to the support team for installation "+installationId);
|
||||
string recipient = "support@innov.energy";
|
||||
string subject = $"Battery Alarm from {installation.InstallationName}: 2 or more strings broken";
|
||||
string text = $"Dear InnovEnergy Support Team,\n" +
|
||||
$"\n"+
|
||||
$"Installation Name: {installation.InstallationName}\n"+
|
||||
$"\n"+
|
||||
$"Installation Monitor Link: {monitorLink}\n"+
|
||||
$"\n"+
|
||||
$"Please exchange: {alarm.CreatedBy}\n"+
|
||||
$"\n"+
|
||||
$"Error created date and time: {alarm.Date} {alarm.Time}\n"+
|
||||
$"\n"+
|
||||
$"Thank you for your great support:)";
|
||||
Mailer.Send("InnovEnergy Support Team", recipient, subject, text);
|
||||
}
|
||||
//Create a new error and add it to the database
|
||||
Db.HandleError(newError, installationId);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,311 @@
|
|||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
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,644 +0,0 @@
|
|||
#!/usr/bin/python -u
|
||||
# coding=utf-8
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import states as State
|
||||
import target_type as TargetType
|
||||
|
||||
from random import randint
|
||||
from python_libs.ie_dbus.dbus_service import DBusService
|
||||
from python_libs.ie_utils.main_loop import run_on_main_loop
|
||||
|
||||
# noinspection PyUnreachableCode
|
||||
if False:
|
||||
from typing import NoReturn, Optional, Any, Iterable, List
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
VERSION = '1.0.0'
|
||||
PRODUCT = 'Controller'
|
||||
|
||||
GRID_SERVICE_PREFIX = 'com.victronenergy.grid.'
|
||||
BATTERY_SERVICE_PREFIX = 'com.victronenergy.battery.'
|
||||
INVERTER_SERVICE_PREFIX = 'com.victronenergy.vebus.'
|
||||
SYSTEM_SERVICE_PREFIX = 'com.victronenergy.system'
|
||||
HUB4_SERVICE_PREFIX = 'com.victronenergy.hub4'
|
||||
SETTINGS_SERVICE_PREFIX = 'com.victronenergy.settings'
|
||||
|
||||
UPDATE_PERIOD_MS = 2000
|
||||
MAX_POWER_PER_BATTERY = 2500
|
||||
|
||||
MAX_DAYS_WITHOUT_EOC = 7
|
||||
SECONDS_PER_DAY = 24 * 60 * 60
|
||||
|
||||
GRID_SET_POINT_SETTING = PRODUCT + '/GridSetPoint'
|
||||
LAST_EOC_SETTING = PRODUCT + '/LastEOC'
|
||||
CALIBRATION_CHARGE_START_TIME_OF_DAY_SETTING = PRODUCT + '/CalibrationChargeStartTime'
|
||||
|
||||
HEAT_LOSS = 150 # W
|
||||
P_CONST = 0.5 # W/W
|
||||
|
||||
Epoch = int
|
||||
Seconds = int
|
||||
|
||||
|
||||
def time_now():
|
||||
return int(time.time())
|
||||
|
||||
|
||||
class Controller(object):
|
||||
|
||||
def __init__(self, measurement, target, target_type, state):
|
||||
# type: (float, float, int, int) -> NoReturn
|
||||
self.target_type = target_type
|
||||
self.target = target
|
||||
self.measurement = measurement
|
||||
self.state = state
|
||||
|
||||
d_p = target - measurement
|
||||
self.delta = d_p * P_CONST
|
||||
|
||||
@staticmethod
|
||||
def min(controllers):
|
||||
# type: (Iterable[Controller]) -> Controller
|
||||
return min(controllers, key=lambda c: c.delta)
|
||||
|
||||
@staticmethod
|
||||
def max(controllers):
|
||||
# type: (Iterable[Controller]) -> Controller
|
||||
return max(controllers, key=lambda c: c.delta)
|
||||
|
||||
def clamp(self, lower_limit_controllers, upper_limit_controllers):
|
||||
# type: (List[Controller],List[Controller]) -> Controller
|
||||
c_min = Controller.min(upper_limit_controllers + [self])
|
||||
return Controller.max(lower_limit_controllers + [c_min])
|
||||
|
||||
|
||||
# noinspection PyMethodMayBeStatic
|
||||
class InnovEnergyController(DBusService):
|
||||
|
||||
def __init__(self):
|
||||
|
||||
super(InnovEnergyController, self).__init__(PRODUCT.lower())
|
||||
|
||||
self.settings.add_setting(path=LAST_EOC_SETTING, default_value=0) # unix epoch timestamp
|
||||
self.settings.add_setting(path=GRID_SET_POINT_SETTING, default_value=0) # grid setpoint, Watts
|
||||
|
||||
self.settings.add_setting(path=CALIBRATION_CHARGE_START_TIME_OF_DAY_SETTING, default_value=32400) # 09:00
|
||||
|
||||
self.own_properties.set('/ProductName', PRODUCT)
|
||||
self.own_properties.set('/Mgmt/ProcessName', __file__)
|
||||
self.own_properties.set('/Mgmt/ProcessVersion', VERSION)
|
||||
self.own_properties.set('/Mgmt/Connection', 'dbus')
|
||||
self.own_properties.set('/ProductId', PRODUCT)
|
||||
self.own_properties.set('/FirmwareVersion', VERSION)
|
||||
self.own_properties.set('/HardwareVersion', VERSION)
|
||||
self.own_properties.set('/Connected', 1)
|
||||
self.own_properties.set('/TimeToCalibrationCharge', 'unknown')
|
||||
self.own_properties.set('/State', 0)
|
||||
|
||||
self.phases = [
|
||||
p for p in ['/Hub4/L1/AcPowerSetpoint', '/Hub4/L2/AcPowerSetpoint', '/Hub4/L3/AcPowerSetpoint']
|
||||
if self.remote_properties.exists(self.inverter_service + p)
|
||||
]
|
||||
|
||||
self.n_phases = len(self.phases)
|
||||
print ('The system has ' + str(self.n_phases) + ' phase' + ('s' if self.n_phases != 1 else ''))
|
||||
|
||||
self.max_inverter_power = 32700
|
||||
# ^ defined in https://github.com/victronenergy/dbus_modbustcp/blob/master/CCGX-Modbus-TCP-register-list.xlsx
|
||||
|
||||
def clamp_power_command(self, value):
|
||||
# type: (float) -> int
|
||||
|
||||
value = max(value, -self.max_inverter_power)
|
||||
value = min(value, self.max_inverter_power)
|
||||
|
||||
return int(value)
|
||||
|
||||
def get_service(self, prefix):
|
||||
# type: (str) -> Optional[unicode]
|
||||
service = next((s for s in self.available_services if s.startswith(prefix)), None)
|
||||
|
||||
if service is None:
|
||||
raise Exception('no service matching ' + prefix + '* available')
|
||||
|
||||
return service
|
||||
|
||||
def is_service_available(self, prefix):
|
||||
# type: (str) -> bool
|
||||
return next((True for s in self.available_services if s.startswith(prefix)), False)
|
||||
|
||||
@property
|
||||
def battery_service(self):
|
||||
# type: () -> Optional[unicode]
|
||||
return self.get_service(BATTERY_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def battery_available(self):
|
||||
# type: () -> bool
|
||||
return self.is_service_available(BATTERY_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def grid_service(self):
|
||||
# type: () -> Optional[unicode]
|
||||
return self.get_service(GRID_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def grid_meter_available(self):
|
||||
# type: () -> bool
|
||||
return self.is_service_available(GRID_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def inverter_service(self):
|
||||
# type: () -> Optional[unicode]
|
||||
return self.get_service(INVERTER_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def inverter_available(self):
|
||||
# type: () -> bool
|
||||
return self.is_service_available(INVERTER_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def system_service(self):
|
||||
# type: () -> Optional[unicode]
|
||||
return self.get_service(SYSTEM_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def system_service_available(self):
|
||||
# type: () -> bool
|
||||
return self.is_service_available(SYSTEM_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def hub4_service(self):
|
||||
# type: () -> Optional[unicode]
|
||||
return self.get_service(HUB4_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def hub4_service_available(self):
|
||||
# type: () -> bool
|
||||
return self.is_service_available(HUB4_SERVICE_PREFIX)
|
||||
|
||||
@property
|
||||
def inverter_power_setpoint(self):
|
||||
# type: () -> float
|
||||
return sum((self.get_inverter_prop(p) for p in self.phases))
|
||||
|
||||
def get_battery_prop(self, dbus_path):
|
||||
# type: (str) -> Any
|
||||
battery_service = self.battery_service
|
||||
return self.remote_properties.get(battery_service + dbus_path).value
|
||||
|
||||
def get_grid_prop(self, dbus_path):
|
||||
# type: (str) -> Any
|
||||
return self.remote_properties.get(self.grid_service + dbus_path).value
|
||||
|
||||
def get_inverter_prop(self, dbus_path):
|
||||
# type: (str) -> Any
|
||||
return self.remote_properties.get(self.inverter_service + dbus_path).value
|
||||
|
||||
def get_system_prop(self, dbus_path):
|
||||
# type: (str) -> Any
|
||||
system_service = self.system_service
|
||||
return self.remote_properties.get(system_service + dbus_path).value
|
||||
|
||||
def get_hub4_prop(self, dbus_path):
|
||||
# type: (str) -> Any
|
||||
hub4_service = self.hub4_service
|
||||
return self.remote_properties.get(hub4_service + dbus_path).value
|
||||
|
||||
def set_settings_prop(self, dbus_path, value):
|
||||
# type: (str, Any) -> bool
|
||||
return self.remote_properties.set(SETTINGS_SERVICE_PREFIX + dbus_path, value)
|
||||
|
||||
def set_inverter_prop(self, dbus_path, value):
|
||||
# type: (str, Any) -> bool
|
||||
inverter_service = self.inverter_service
|
||||
return self.remote_properties.set(inverter_service + dbus_path, value)
|
||||
|
||||
@property
|
||||
def max_battery_charge_power(self):
|
||||
# type: () -> int
|
||||
return self.get_battery_prop('/Info/MaxChargePower')
|
||||
|
||||
@property
|
||||
def max_battery_discharge_power(self):
|
||||
# type: () -> int
|
||||
return self.get_battery_prop('/Info/MaxDischargePower')
|
||||
|
||||
@property
|
||||
def max_configured_charge_power(self):
|
||||
# type: () -> Optional[int]
|
||||
max_power = self.settings.get('/Settings/CGwacs/MaxChargePower')
|
||||
return max_power if max_power >= 0 else None
|
||||
|
||||
@property
|
||||
def max_configured_discharge_power(self): # unsigned
|
||||
# type: () -> Optional[int]
|
||||
max_power = self.settings.get('/Settings/CGwacs/MaxDischargePower')
|
||||
return max_power if max_power >= 0 else None
|
||||
|
||||
@property
|
||||
def max_charge_power(self):
|
||||
# type: () -> int
|
||||
if self.max_configured_charge_power is None:
|
||||
return self.max_battery_charge_power
|
||||
else:
|
||||
return min(self.max_battery_charge_power, self.max_configured_charge_power)
|
||||
|
||||
@property
|
||||
def max_discharge_power(self): # unsigned
|
||||
# type: () -> int
|
||||
if self.max_configured_discharge_power is None:
|
||||
return self.max_battery_discharge_power
|
||||
else:
|
||||
return min(self.max_battery_discharge_power, self.max_configured_discharge_power)
|
||||
|
||||
def set_inverter_power_setpoint(self, power):
|
||||
# type: (float) -> NoReturn
|
||||
|
||||
if self.settings.get('/Settings/CGwacs/BatteryLife/State') == 9:
|
||||
self.settings.set('/Settings/CGwacs/BatteryLife/State', 0) # enables scheduled charge
|
||||
self.settings.set('/Settings/CGwacs/Hub4Mode', 3) # disable hub4
|
||||
self.set_inverter_prop('/Hub4/DisableCharge', 0)
|
||||
self.set_inverter_prop('/Hub4/DisableFeedIn', 0)
|
||||
|
||||
power = self.clamp_power_command(power / self.n_phases)
|
||||
for p in self.phases:
|
||||
self.set_inverter_prop(p, power + randint(-1, 1)) # use randint to force dbus re-send
|
||||
|
||||
def set_controller_state(self, state):
|
||||
# type: (int) -> NoReturn
|
||||
self.own_properties.set('/State', state)
|
||||
|
||||
@property
|
||||
def grid_power(self):
|
||||
# type: () -> Optional[float]
|
||||
try:
|
||||
return self.get_grid_prop('/Ac/Power')
|
||||
except:
|
||||
return None
|
||||
|
||||
@property
|
||||
def battery_cold(self):
|
||||
# type: () -> bool
|
||||
return self.get_battery_prop('/IoStatus/BatteryCold') == 1
|
||||
|
||||
@property
|
||||
def eoc_reached(self):
|
||||
# type: () -> bool
|
||||
if not self.battery_available:
|
||||
return False
|
||||
|
||||
return min(self.get_battery_prop('/EOCReached')) == 1
|
||||
|
||||
@property
|
||||
def battery_power(self):
|
||||
# type: () -> float
|
||||
return self.get_battery_prop('/Dc/0/Power')
|
||||
|
||||
@property
|
||||
def inverter_ac_in_power(self):
|
||||
# type: () -> float
|
||||
return self.get_inverter_prop('/Ac/ActiveIn/P')
|
||||
|
||||
@property
|
||||
def inverter_ac_out_power(self):
|
||||
# type: () -> float
|
||||
return self.get_inverter_prop('/Ac/Out/P')
|
||||
|
||||
@property
|
||||
def soc(self):
|
||||
# type: () -> float
|
||||
return self.get_battery_prop('/Soc')
|
||||
|
||||
@property
|
||||
def n_batteries(self):
|
||||
# type: () -> int
|
||||
return self.get_battery_prop('/NbOfBatteries')
|
||||
|
||||
@property
|
||||
def min_soc(self):
|
||||
# type: () -> float
|
||||
return self.settings.get('/Settings/CGwacs/BatteryLife/MinimumSocLimit')
|
||||
|
||||
@property
|
||||
def should_hold_min_soc(self):
|
||||
# type: () -> bool
|
||||
return self.min_soc <= self.soc <= self.min_soc + 5
|
||||
|
||||
@property
|
||||
def utc_offset(self):
|
||||
# type: () -> int
|
||||
|
||||
# stackoverflow.com/a/1301528
|
||||
# stackoverflow.com/a/3168394
|
||||
|
||||
os.environ['TZ'] = self.settings.get('/Settings/System/TimeZone')
|
||||
time.tzset()
|
||||
is_dst = time.daylight and time.localtime().tm_isdst > 0
|
||||
return -(time.altzone if is_dst else time.timezone)
|
||||
|
||||
@property
|
||||
def grid_set_point(self):
|
||||
# type: () -> float
|
||||
return self.settings.get('/Settings/CGwacs/AcPowerSetPoint')
|
||||
|
||||
@property
|
||||
def time_to_calibration_charge_str(self):
|
||||
# type: () -> str
|
||||
return self.own_properties.get('/TimeToCalibrationCharge').text
|
||||
|
||||
@property
|
||||
def calibration_charge_deadline(self):
|
||||
# type: () -> Epoch
|
||||
|
||||
utc_offset = self.utc_offset
|
||||
ultimate_deadline = self.settings.get(LAST_EOC_SETTING) + MAX_DAYS_WITHOUT_EOC * SECONDS_PER_DAY
|
||||
midnight_before_udl = int((ultimate_deadline + utc_offset) / SECONDS_PER_DAY) * SECONDS_PER_DAY - utc_offset # round off to last midnight
|
||||
|
||||
dead_line = midnight_before_udl + self.calibration_charge_start_time_of_day
|
||||
|
||||
while dead_line > ultimate_deadline: # should fire at most once, but let's be defensive...
|
||||
dead_line -= SECONDS_PER_DAY # too late, advance one day
|
||||
|
||||
return dead_line
|
||||
|
||||
@property
|
||||
def time_to_calibration_charge(self):
|
||||
# type: () -> Seconds
|
||||
return self.calibration_charge_deadline - time_now()
|
||||
|
||||
@property
|
||||
def grid_blackout(self):
|
||||
# type: () -> bool
|
||||
return self.get_inverter_prop('/Leds/Mains') < 1
|
||||
|
||||
@property
|
||||
def scheduled_charge(self):
|
||||
# type: () -> bool
|
||||
return self.get_hub4_prop('/Overrides/ForceCharge') != 0
|
||||
|
||||
@property
|
||||
def calibration_charge_start_time_of_day(self):
|
||||
# type: () -> Seconds
|
||||
return self.settings.get(CALIBRATION_CHARGE_START_TIME_OF_DAY_SETTING) # seconds since midnight
|
||||
|
||||
@property
|
||||
def must_do_calibration_charge(self):
|
||||
# type: () -> bool
|
||||
return self.time_to_calibration_charge <= 0
|
||||
|
||||
def controller_charge_to_min_soc(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement=self.battery_power,
|
||||
target=self.max_charge_power,
|
||||
target_type=TargetType.BATTERY_DC,
|
||||
state=State.CHARGE_TO_MIN_SOC
|
||||
)
|
||||
|
||||
def controller_hold_min_soc(self):
|
||||
# type: () -> Controller
|
||||
|
||||
# TODO: explain
|
||||
|
||||
a = -4 * HEAT_LOSS * self.n_batteries
|
||||
b = -a * (self.min_soc + .5)
|
||||
|
||||
target_dc_power = a * self.soc + b
|
||||
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = target_dc_power,
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.HOLD_MIN_SOC
|
||||
)
|
||||
|
||||
def controller_calibration_charge(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = self.max_charge_power,
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.CALIBRATION_CHARGE
|
||||
)
|
||||
|
||||
def controller_limit_discharge_power(self): # signed
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = -self.max_discharge_power, # add sign!
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.LIMIT_DISCHARGE_POWER
|
||||
)
|
||||
|
||||
def controller_limit_charge_power(self):
|
||||
# type: () -> Controller
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = self.max_charge_power,
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.LIMIT_CHARGE_POWER
|
||||
)
|
||||
|
||||
def controller_optimize_self_consumption(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.grid_power,
|
||||
target = self.grid_set_point,
|
||||
target_type = TargetType.GRID_AC,
|
||||
state = State.OPTIMIZE_SELF_CONSUMPTION
|
||||
)
|
||||
|
||||
def controller_heating(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = self.max_charge_power,
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.HEATING
|
||||
)
|
||||
|
||||
def controller_scheduled_charge(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = self.max_charge_power,
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.SCHEDULED_CHARGE
|
||||
)
|
||||
|
||||
def controller_no_grid_meter(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.battery_power,
|
||||
target = self.max_charge_power,
|
||||
target_type = TargetType.BATTERY_DC,
|
||||
state = State.NO_GRID_METER_AVAILABLE
|
||||
)
|
||||
|
||||
def controller_no_battery(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = self.inverter_ac_in_power,
|
||||
target = 0,
|
||||
target_type = TargetType.INVERTER_AC_IN,
|
||||
state = State.NO_BATTERY_AVAILABLE
|
||||
)
|
||||
|
||||
def controller_bridge_grid_blackout(self):
|
||||
# type: () -> Controller
|
||||
|
||||
return Controller(
|
||||
measurement = 0,
|
||||
target = 0,
|
||||
target_type = TargetType.GRID_AC,
|
||||
state = State.BRIDGE_GRID_BLACKOUT
|
||||
)
|
||||
|
||||
def update_eoc(self):
|
||||
|
||||
if self.eoc_reached:
|
||||
print('battery has reached EOC')
|
||||
self.settings.set(LAST_EOC_SETTING, time_now())
|
||||
|
||||
self.publish_time_to_calibration_charge()
|
||||
|
||||
def publish_time_to_calibration_charge(self):
|
||||
|
||||
total_seconds = self.time_to_calibration_charge
|
||||
|
||||
if total_seconds <= 0:
|
||||
time_to_eoc_str = 'now'
|
||||
else:
|
||||
total_minutes, seconds = divmod(total_seconds, 60)
|
||||
total_hours, minutes = divmod(total_minutes, 60)
|
||||
total_days, hours = divmod(total_hours, 24)
|
||||
|
||||
days_str = (str(total_days) + 'd') if total_days > 0 else ''
|
||||
hours_str = (str(hours) + 'h') if total_hours > 0 else ''
|
||||
minutes_str = (str(minutes) + 'm') if total_days == 0 else ''
|
||||
|
||||
time_to_eoc_str = "{0} {1} {2}".format(days_str, hours_str, minutes_str).strip()
|
||||
|
||||
self.own_properties.set('/TimeToCalibrationCharge', time_to_eoc_str)
|
||||
|
||||
def print_system_stats(self, controller):
|
||||
# type: (Controller) -> NoReturn
|
||||
|
||||
def soc_setpoint():
|
||||
if controller.state == State.CALIBRATION_CHARGE or controller.state == State.NO_GRID_METER_AVAILABLE:
|
||||
return ' => 100%'
|
||||
if controller.state == State.CHARGE_TO_MIN_SOC:
|
||||
return ' => ' + str(int(self.min_soc)) + '%'
|
||||
return ''
|
||||
|
||||
def setpoint(target_type):
|
||||
if target_type != controller.target_type:
|
||||
return ''
|
||||
return ' => ' + str(int(controller.target)) + 'W'
|
||||
|
||||
def p(power):
|
||||
# type: (Optional[float]) -> str
|
||||
if power is None:
|
||||
return ' --- W'
|
||||
else:
|
||||
return str(int(power)) + 'W'
|
||||
|
||||
ac_loads = None if self.grid_power is None else self.grid_power - self.inverter_ac_in_power
|
||||
delta = p(controller.delta) if controller.delta < 0 else '+' + p(controller.delta)
|
||||
battery_power = self.battery_power if self.battery_available else None
|
||||
soc_ = str(self.soc) + '%' if self.battery_available else '---'
|
||||
|
||||
print (State.name_of[controller.state])
|
||||
print ('')
|
||||
print ('time to CC: ' + self.time_to_calibration_charge_str)
|
||||
print (' SOC: ' + soc_ + soc_setpoint())
|
||||
print (' grid: ' + p(self.grid_power) + setpoint(TargetType.GRID_AC))
|
||||
print (' battery: ' + p(battery_power) + setpoint(TargetType.BATTERY_DC))
|
||||
print (' AC in: ' + p(self.inverter_ac_in_power) + ' ' + delta)
|
||||
print (' AC out: ' + p(self.inverter_ac_out_power))
|
||||
print (' AC loads: ' + p(ac_loads))
|
||||
|
||||
def choose_controller(self):
|
||||
# type: () -> Controller
|
||||
|
||||
if self.grid_blackout:
|
||||
return self.controller_bridge_grid_blackout()
|
||||
|
||||
if not self.battery_available:
|
||||
return self.controller_no_battery()
|
||||
|
||||
if self.battery_cold:
|
||||
return self.controller_heating()
|
||||
|
||||
if self.scheduled_charge:
|
||||
return self.controller_scheduled_charge()
|
||||
|
||||
if self.must_do_calibration_charge:
|
||||
return self.controller_calibration_charge()
|
||||
|
||||
if self.soc < self.min_soc:
|
||||
return self.controller_charge_to_min_soc()
|
||||
|
||||
if not self.grid_meter_available:
|
||||
return self.controller_no_grid_meter()
|
||||
|
||||
hold_min_soc = self.controller_hold_min_soc()
|
||||
limit_discharge_power = self.controller_limit_discharge_power() # signed
|
||||
|
||||
lower_limit = [limit_discharge_power, hold_min_soc]
|
||||
|
||||
# No upper limit. We no longer actively limit charge power. DC/DC Charger inside the BMS will do that for us.
|
||||
upper_limit = []
|
||||
|
||||
optimize_self_consumption = self.controller_optimize_self_consumption()
|
||||
|
||||
return optimize_self_consumption.clamp(lower_limit, upper_limit)
|
||||
|
||||
def update(self):
|
||||
|
||||
print('iteration started\n')
|
||||
|
||||
self.update_eoc()
|
||||
|
||||
if self.inverter_available:
|
||||
|
||||
controller = self.choose_controller()
|
||||
power = self.inverter_ac_in_power + controller.delta
|
||||
|
||||
self.set_inverter_power_setpoint(power)
|
||||
self.set_controller_state(controller.state)
|
||||
self.print_system_stats(controller) # for debug
|
||||
|
||||
else:
|
||||
self.set_controller_state(State.NO_INVERTER_AVAILABLE)
|
||||
print('inverter not available!')
|
||||
|
||||
print('\niteration finished\n')
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
print('starting ' + __file__)
|
||||
|
||||
with InnovEnergyController() as service:
|
||||
run_on_main_loop(service.update, UPDATE_PERIOD_MS)
|
||||
|
||||
print(__file__ + ' has shut down')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
|
@ -1,202 +0,0 @@
|
|||
#!/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
|
|
@ -1,496 +0,0 @@
|
|||
#!/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)
|
|
@ -1,3 +0,0 @@
|
|||
#!/bin/sh
|
||||
exec 2>&1
|
||||
exec multilog t s25000 n4 /var/log/dbus-fzsonick-48tl.TTY
|
|
@ -1,4 +0,0 @@
|
|||
#!/bin/sh
|
||||
exec 2>&1
|
||||
|
||||
exec softlimit -d 100000000 -s 1000000 -a 100000000 /opt/innovenergy/dbus-fzsonick-48tl/start.sh TTY
|
|
@ -1,7 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
. /opt/victronenergy/serial-starter/run-service.sh
|
||||
|
||||
app="/opt/innovenergy/dbus-fzsonick-48tl/dbus-fzsonick-48tl.py"
|
||||
args="$tty"
|
||||
start $args
|
|
@ -54,6 +54,6 @@ INNOVENERGY_PROTOCOL_VERSION = '48TL200V3'
|
|||
|
||||
|
||||
# S3 Credentials
|
||||
S3BUCKET = "5-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||
S3KEY = "EXO6bb63d9bbe5f938a68fa444b"
|
||||
S3SECRET = "A4-5wIjIlAqn-p0cUkQu0f9fBIrX1V5PGTBDwjsrlR8"
|
||||
S3BUCKET = "10-c0436b6a-d276-4cd8-9c44-1eae86cf5d0e"
|
||||
S3KEY = "EXOa8cc58d2e51e389fed9ccbfa"
|
||||
S3SECRET = "hofDGMmSSN1OACYXHWRUGdG61mFjBxKC18sF0VpMQgY"
|
Binary file not shown.
|
@ -169,6 +169,26 @@ def read_bitmap(register):
|
|||
|
||||
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 return_in_list(ts):
|
||||
return ts
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue