243 lines
6.9 KiB
Python
243 lines
6.9 KiB
Python
|
#!/usr/bin/python -u
|
||
|
# coding=utf-8
|
||
|
|
||
|
from python_libs.ie_dbus.dbus_service import DBusService
|
||
|
from python_libs.ie_utils.filters import DebounceFilter
|
||
|
from python_libs.ie_utils.main_loop import run_on_main_loop
|
||
|
from python_libs.ie_utils.mixins import memoize
|
||
|
|
||
|
import logging
|
||
|
import relay_function
|
||
|
import relay_polarity
|
||
|
import test_state
|
||
|
|
||
|
# noinspection PyUnreachableCode
|
||
|
if False:
|
||
|
from typing import NoReturn, Optional
|
||
|
|
||
|
logging.basicConfig(level=logging.DEBUG)
|
||
|
_log = logging.getLogger(__name__)
|
||
|
|
||
|
VERSION = '1.2.0'
|
||
|
PRODUCT = 'Relay1'
|
||
|
|
||
|
UPDATE_PERIOD_MS = 2000
|
||
|
BATTERY_SERVICE_PREFIX = 'com.victronenergy.battery.'
|
||
|
|
||
|
# settings
|
||
|
DebounceFilterStrength = PRODUCT + '/DebounceFilterTime'
|
||
|
ConnectLoadThreshold = PRODUCT + '/ConnectLoadThreshold'
|
||
|
NominalPowerOfLoad = PRODUCT + '/NominalPowerOfLoad'
|
||
|
Relay1Function = PRODUCT + '/Relay1Function'
|
||
|
Relay1Polarity = PRODUCT + '/Relay1Polarity'
|
||
|
|
||
|
MinimumSocLimit = 'CGwacs/BatteryLife/MinimumSocLimit'
|
||
|
|
||
|
# remote properties
|
||
|
Relay1State = 'system/Relay/1/State'
|
||
|
|
||
|
|
||
|
class Relay1Service(DBusService):
|
||
|
|
||
|
def __init__(self):
|
||
|
|
||
|
super(Relay1Service, self).__init__(PRODUCT.lower())
|
||
|
|
||
|
self.settings.add_setting(path=DebounceFilterStrength, default_value=180, min=0, max=10000)
|
||
|
self.settings.add_setting(path=NominalPowerOfLoad, default_value=10000, min=0, max=100000) # watts
|
||
|
self.settings.add_setting(path=ConnectLoadThreshold, default_value=50000, min=0, max=50000) # watts
|
||
|
self.settings.add_setting(path=Relay1Function, default_value=relay_function.NONE)
|
||
|
self.settings.add_setting(path=Relay1Polarity, default_value=relay_polarity.NORMALLY_OPEN)
|
||
|
|
||
|
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('/LogicalState', 0)
|
||
|
self.own_properties.set('/TestRelay1', -1, writable=True)
|
||
|
|
||
|
self.debounce_filter = DebounceFilter(False, self.settings.get(DebounceFilterStrength))
|
||
|
|
||
|
@memoize
|
||
|
def is_relay1_available(self):
|
||
|
# type: () -> bool
|
||
|
return self.remote_properties.exists(Relay1State)
|
||
|
|
||
|
@property
|
||
|
def battery_service(self):
|
||
|
return next((s for s in self.available_services if s.startswith(BATTERY_SERVICE_PREFIX)), None)
|
||
|
|
||
|
@property
|
||
|
def grid_power(self):
|
||
|
# type: () -> float
|
||
|
|
||
|
l1 = self.remote_properties.get('system/Ac/Grid/L1/Power').value
|
||
|
l2 = self.remote_properties.get('system/Ac/Grid/L2/Power').value
|
||
|
l3 = self.remote_properties.get('system/Ac/Grid/L3/Power').value
|
||
|
|
||
|
l1 = l1 if isinstance(l1, (float, int)) else 0.
|
||
|
l2 = l2 if isinstance(l2, (float, int)) else 0.
|
||
|
l3 = l3 if isinstance(l3, (float, int)) else 0.
|
||
|
|
||
|
return -(l1 + l2 + l3)
|
||
|
|
||
|
@property
|
||
|
def test_relay(self):
|
||
|
# type: () -> Optional[bool]
|
||
|
|
||
|
test = self.own_properties.get('/TestRelay1').value
|
||
|
|
||
|
if test == test_state.NO_TEST:
|
||
|
return None
|
||
|
|
||
|
return test == test_state.TEST_RELAY_ON
|
||
|
|
||
|
@property
|
||
|
def connect_load_threshold(self):
|
||
|
# type: () -> int
|
||
|
return self.settings.get(ConnectLoadThreshold)
|
||
|
|
||
|
@property
|
||
|
def disconnect_load_threshold(self):
|
||
|
# type: () -> int
|
||
|
return self.connect_load_threshold - self.settings.get(NominalPowerOfLoad)
|
||
|
|
||
|
@property
|
||
|
def load_relay_target_state(self):
|
||
|
# type: () -> Optional[bool]
|
||
|
|
||
|
if self.grid_power > self.connect_load_threshold:
|
||
|
return True
|
||
|
if self.grid_power < self.disconnect_load_threshold:
|
||
|
return False
|
||
|
else:
|
||
|
return None # dead-band
|
||
|
|
||
|
@property
|
||
|
def battery_has_alarm(self):
|
||
|
# type: () -> bool
|
||
|
return self.remote_properties.get(self.battery_service + '/NumberOfAlarmFlags').value > 0
|
||
|
|
||
|
@property
|
||
|
def soc(self):
|
||
|
# type: () -> float
|
||
|
return self.remote_properties.get(self.battery_service + '/Soc').value
|
||
|
|
||
|
# noinspection PyBroadException
|
||
|
@property
|
||
|
def min_soc_limit(self):
|
||
|
# type: () -> int
|
||
|
try:
|
||
|
return int(self.settings.get(MinimumSocLimit))
|
||
|
except Exception:
|
||
|
return 101 # force alarm when MinimumSocLimit setting is not readable
|
||
|
|
||
|
@property
|
||
|
def alarm_relay_target_state(self):
|
||
|
# type: () -> bool
|
||
|
|
||
|
if self.battery_service is None:
|
||
|
_log.debug('no battery available!')
|
||
|
return True # force alarm
|
||
|
|
||
|
_log.debug('soc = ' + str(self.soc))
|
||
|
_log.debug('min_soc = ' + str(self.min_soc_limit))
|
||
|
_log.debug('battery_alarm = ' + str(self.battery_has_alarm))
|
||
|
|
||
|
return self.soc < self.min_soc_limit or self.battery_has_alarm
|
||
|
|
||
|
@property
|
||
|
def relay1_target_state(self):
|
||
|
# type: () -> Optional[bool]
|
||
|
|
||
|
if self.test_relay is not None:
|
||
|
_log.info('testing ' + self.relay1_function + ' relay')
|
||
|
return self.test_relay
|
||
|
|
||
|
_log.debug('relay1_function is ' + self.relay1_function)
|
||
|
|
||
|
if self.relay1_function == relay_function.ALARM:
|
||
|
return self.alarm_relay_target_state
|
||
|
elif self.relay1_function == relay_function.LOAD:
|
||
|
return self.load_relay_target_state
|
||
|
|
||
|
return None
|
||
|
|
||
|
@property
|
||
|
def relay1_function(self):
|
||
|
# type: () -> str
|
||
|
return self.settings.get(Relay1Function)
|
||
|
|
||
|
@property
|
||
|
def relay1_polarity(self):
|
||
|
# type: () -> str
|
||
|
return self.settings.get(Relay1Polarity)
|
||
|
|
||
|
def display_state(self, state):
|
||
|
# type: (bool) -> str
|
||
|
|
||
|
if self.relay1_function == relay_function.ALARM:
|
||
|
return 'alarm is on' if state else 'alarm is off'
|
||
|
|
||
|
if self.relay1_function == relay_function.LOAD:
|
||
|
return 'load is connected' if state else 'load is disconnected'
|
||
|
|
||
|
return 'relay is disabled'
|
||
|
|
||
|
def set_relay1(self, state):
|
||
|
# type: (bool) -> NoReturn
|
||
|
|
||
|
changed = self.remote_properties.set(Relay1State, 1 if state else 0)
|
||
|
|
||
|
if changed or _log.getEffectiveLevel() <= logging.DEBUG:
|
||
|
_log.info('Relay is now ' + ('energized' if state else 'de-energized'))
|
||
|
|
||
|
def debounce(self, state):
|
||
|
# type: (Optional[bool]) -> Optional[bool]
|
||
|
|
||
|
if state is None:
|
||
|
return None
|
||
|
|
||
|
filter_length = int(self.settings.get(DebounceFilterStrength) * 1000 / UPDATE_PERIOD_MS)
|
||
|
return self.debounce_filter.update(state, filter_length)
|
||
|
|
||
|
def update(self):
|
||
|
|
||
|
if self.relay1_function == relay_function.NONE:
|
||
|
self.set_relay1(False) # make sure relay is de-energized when it is disabled
|
||
|
return
|
||
|
|
||
|
state = self.relay1_target_state # using tri-state logic, True/False/None
|
||
|
if state is None: # None acts as a NOP
|
||
|
return
|
||
|
|
||
|
if self.test_relay is None: # do not debounce when testing
|
||
|
state = self.debounce(state)
|
||
|
|
||
|
self.own_properties.set('/LogicalState', state)
|
||
|
_log.debug(self.display_state(state))
|
||
|
|
||
|
if self.relay1_polarity == relay_polarity.NORMALLY_CLOSED:
|
||
|
_log.debug('inverting command because relay is configured to be normally-closed')
|
||
|
state = not state
|
||
|
|
||
|
self.set_relay1(state)
|
||
|
|
||
|
|
||
|
def main():
|
||
|
|
||
|
_log.info('starting ' + __file__)
|
||
|
|
||
|
with Relay1Service() as service:
|
||
|
run_on_main_loop(service.update, UPDATE_PERIOD_MS)
|
||
|
|
||
|
_log.info(__file__ + ' has shut down')
|
||
|
|
||
|
|
||
|
if __name__ == '__main__':
|
||
|
main()
|