def __init__(self, pin, pull=None, released_component=None, double_pressed_component=None, double_pressed_method="on", long_pressed_component=None, long_pressed_method="on", suppress=False): """ Basic functionality for push is to toggle a device. Double press and long press are just extended functionality. :param pin: pin number or name :param pull: None for no pullup or pull_down, otherwise value of pull configuration :param released_component: component name of component to be turned on when button pressed :param double_pressed_component: component name of component to be turned on when button double pressed :param double_pressed_method: string of the method of the component that is to be called :param long_pressed_component: component name of component to be turned on when button long pressed :param long_pressed_method: string of the method of the component that is to be called :param suppress: Suppress calling release function after double click and long press. Will delay release function by 300ms if double click is used. """ self._component = released_component self._event = Event() # Synchronous method _event.set() to prevent queue overflows from pressing button too often super().__init__(pin, pull, None, "off", self._event, "set", double_pressed_component, double_pressed_method, long_pressed_component, long_pressed_method, suppress) asyncio.get_event_loop().create_task(self._watcher())
def getStateChangeEvent(self): """ Returns an event that gets triggered on every state change :return: Event """ if self._event is None: from pysmartnode.utils.event import Event self._event = Event() return self._event
def getReadingsEvent(self): """ Returns an event that gets triggered on every read :return: Event """ if self._event is None: from pysmartnode.utils.event import Event self._event = Event() return self._event
async def _loop(self): if self._PIN_BELL_IRQ_DIRECTION == machine.Pin.IRQ_FALLING: self._pin_bell = Pin(self._pin_bell, machine.Pin.IN, machine.Pin.PULL_UP) else: self._pin_bell = Pin(self._pin_bell, machine.Pin.IN) self._event_bell = Event() self._timer_lock = Lock() self._pin_bell.irq(trigger=self._PIN_BELL_IRQ_DIRECTION, handler=self.__irqBell) self._event_bell.clear() asyncio.get_event_loop().create_task(self.__bell()) self._timer_bell = machine.Timer(1) await _log.asyncLog("info", "Bell initialized") gc.collect()
class Bell(Component): def __init__(self, pin, debounce_time, on_time=None, irq_direction=None, mqtt_topic=None, friendly_name=None, friendly_name_last=None): super().__init__() self._topic = mqtt_topic or _mqtt.getDeviceTopic(_component_name) self._PIN_BELL_IRQ_DIRECTION = irq_direction or machine.Pin.IRQ_FALLING self._debounce_time = debounce_time self._on_time = on_time or 500 self._pin_bell = pin self._last_activation = 0 self._frn = friendly_name self._frn_l = friendly_name_last async def _init(self): await super()._init() if self._PIN_BELL_IRQ_DIRECTION == machine.Pin.IRQ_FALLING: self._pin_bell = Pin(self._pin_bell, machine.Pin.IN, machine.Pin.PULL_UP) else: self._pin_bell = Pin(self._pin_bell, machine.Pin.IN) self._event_bell = Event() self._timer_lock = Lock() irq = self._pin_bell.irq(trigger=self._PIN_BELL_IRQ_DIRECTION, handler=self.__irqBell) self._event_bell.clear() asyncio.get_event_loop().create_task(self.__bell()) self._timer_bell = machine.Timer(1) await _log.asyncLog("info", "Bell initialized") gc.collect() async def __bell(self): while True: await self._event_bell diff = time.ticks_diff(time.ticks_ms(), self._last_activation) if diff > 10000: _log.error("Bell rang {!s}s ago, not activated ringing".format(diff / 1000)) self._event_bell.clear() return else: await _mqtt.publish(self._topic, "ON", qos=1) await asyncio.sleep_ms(self._on_time) await _mqtt.publish(self._topic, "OFF", qos=1, retain=True) if config.RTC_SYNC_ACTIVE: t = time.localtime() await _mqtt.publish(_mqtt.getDeviceTopic("last_bell"), "{}-{:02d}-{:02d} {:02d}:{:02d}:{:02d}".format(t[0], t[1], t[2], t[3], t[4], t[5]), qos=1, retain=True) self._event_bell.clear() if diff > 500: _log.warn("Bell rang {!s}ms ago, activated ringing".format(diff)) def __irqBell(self, p): if self._timer_lock.locked() is True or self._event_bell.is_set() is True: return else: self._timer_lock.acquire() # not checking return value as we checked locked state above self._timer_bell.init(period=self._debounce_time, mode=machine.Timer.ONE_SHOT, callback=self.__irqTime) def __irqTime(self, t): if self._PIN_BELL_IRQ_DIRECTION == machine.Pin.IRQ_FALLING and self._pin_bell.value() == 0: self._last_activation = time.ticks_ms() self._event_bell.set() elif self._PIN_BELL_IRQ_DIRECTION == machine.Pin.IRQ_RISING and self._pin_bell.value() == 1: self._last_activation = time.ticks_ms() self._event_bell.set() self._timer_bell.deinit() self._timer_lock.release() async def _discovery(self): await self._publishDiscovery("binary_sensor", self._topic, "bell", '"ic":"mdi:bell",', "Doorbell", self._frn) gc.collect() if config.RTC_SYNC_ACTIVE is True: await self._publishDiscovery("sensor", _mqtt.getDeviceTopic("last_bell"), "last_bell", TIMELAPSE_TYPE, "Last Bell", self._frn_l) gc.collect()
def __init__(self, TEMP_SENSOR, REACTION_TIME, HYSTERESIS_LOW, HYSTERESIS_HIGH, SHUTDOWN_CYCLES, START_CYCLES, FROST_TEMP=16, SHUTDOWN_TEMP=29, TARGET_TEMP=22, TARGET_TEMP_TOPIC=None, STATUS_TOPIC=None, POWER_TOPIC=None, MODE_TOPIC=None, friendly_name_temperature=None, friendly_name_mode=None, friendly_name_power=None, friendly_name_status=None): super().__init__() # self.__sensor = config.getComponent(TEMP_SENSOR) self.__sensor = TEMP_SENSOR # registerComponents already gets the component if self.__sensor is None: log.critical( "Can't initialize heater as temperature sensor {!r} does not exist!" .format(TEMP_SENSOR)) raise TypeError( "temperature sensor {!r} does not exist".format(TEMP_SENSOR)) if hasattr(self.__sensor, "tempHumid") is False and hasattr( self.__sensor, "temperature") is False: log.critical( "Can't initialize heater as temperature sensor {!r} has no supported coroutine for getting temperature" .format(TEMP_SENSOR)) raise TypeError( "temperature sensor {!r} does not have supported API") self.__interval = REACTION_TIME self.__hysteresis_low = HYSTERESIS_LOW self.__hysteresis_high = HYSTERESIS_HIGH self.__shutdown_cycles = SHUTDOWN_CYCLES self.__start_cycles = START_CYCLES self.__frost_temp = FROST_TEMP self.__shutdown_temp = SHUTDOWN_TEMP self.__target_temp = TARGET_TEMP self.__status_topic = STATUS_TOPIC or _mqtt.getDeviceTopic( "heater/status") self.__power_topic = POWER_TOPIC or _mqtt.getDeviceTopic( "heater/power") self.__mode_cmd_topic = MODE_TOPIC or _mqtt.getDeviceTopic( "heater/mode", is_request=True) self.__target_temp_cmd_topic = TARGET_TEMP_TOPIC or _mqtt.getDeviceTopic( "heater/temp", is_request=True) self._subscribe(self.__mode_cmd_topic, self.setMode) self._subscribe(self.__target_temp_cmd_topic, self._requestTemp) ###### # internal variables ###### self.__active_mode = "INTERNAL" self.__modes = {"INTERNAL": self.__modeInternal} self.__plugins = {} self.__target_power = 0 self.__event = Event() self.__cycles_target_reached = -2 # will immediately react to current temperature self.__loop_started = False self.__timer_time = 0 # has to be object variable so that _watch can update it too self.__last_error = None self.__setHeaterPower = None # coro of registered hardware self.__initializeHardware = None # initialization coro if hardware requires it ##### global _heater if _heater is None: _heater = self self._frn_temp = friendly_name_temperature self._frn_mode = friendly_name_mode self._frn_power = friendly_name_power self._frn_status = friendly_name_status
class Heater(Component): def __init__(self, TEMP_SENSOR, REACTION_TIME, HYSTERESIS_LOW, HYSTERESIS_HIGH, SHUTDOWN_CYCLES, START_CYCLES, FROST_TEMP=16, SHUTDOWN_TEMP=29, TARGET_TEMP=22, TARGET_TEMP_TOPIC=None, STATUS_TOPIC=None, POWER_TOPIC=None, MODE_TOPIC=None, friendly_name_temperature=None, friendly_name_mode=None, friendly_name_power=None, friendly_name_status=None): super().__init__() # self.__sensor = config.getComponent(TEMP_SENSOR) self.__sensor = TEMP_SENSOR # registerComponents already gets the component if self.__sensor is None: log.critical( "Can't initialize heater as temperature sensor {!r} does not exist!" .format(TEMP_SENSOR)) raise TypeError( "temperature sensor {!r} does not exist".format(TEMP_SENSOR)) if hasattr(self.__sensor, "tempHumid") is False and hasattr( self.__sensor, "temperature") is False: log.critical( "Can't initialize heater as temperature sensor {!r} has no supported coroutine for getting temperature" .format(TEMP_SENSOR)) raise TypeError( "temperature sensor {!r} does not have supported API") self.__interval = REACTION_TIME self.__hysteresis_low = HYSTERESIS_LOW self.__hysteresis_high = HYSTERESIS_HIGH self.__shutdown_cycles = SHUTDOWN_CYCLES self.__start_cycles = START_CYCLES self.__frost_temp = FROST_TEMP self.__shutdown_temp = SHUTDOWN_TEMP self.__target_temp = TARGET_TEMP self.__status_topic = STATUS_TOPIC or _mqtt.getDeviceTopic( "heater/status") self.__power_topic = POWER_TOPIC or _mqtt.getDeviceTopic( "heater/power") self.__mode_cmd_topic = MODE_TOPIC or _mqtt.getDeviceTopic( "heater/mode", is_request=True) self.__target_temp_cmd_topic = TARGET_TEMP_TOPIC or _mqtt.getDeviceTopic( "heater/temp", is_request=True) self._subscribe(self.__mode_cmd_topic, self.setMode) self._subscribe(self.__target_temp_cmd_topic, self._requestTemp) ###### # internal variables ###### self.__active_mode = "INTERNAL" self.__modes = {"INTERNAL": self.__modeInternal} self.__plugins = {} self.__target_power = 0 self.__event = Event() self.__cycles_target_reached = -2 # will immediately react to current temperature self.__loop_started = False self.__timer_time = 0 # has to be object variable so that _watch can update it too self.__last_error = None self.__setHeaterPower = None # coro of registered hardware self.__initializeHardware = None # initialization coro if hardware requires it ##### global _heater if _heater is None: _heater = self self._frn_temp = friendly_name_temperature self._frn_mode = friendly_name_mode self._frn_power = friendly_name_power self._frn_status = friendly_name_status async def _discovery(self): name = "heater_target_temp" await self._publishDiscovery(_component_type, self.__target_temp_cmd_topic[:-4], name, DISCOVERY_SWITCH, self._frn_temp or " Target Temperature") gc.collect() name = "heater_mode" await self._publishDiscovery(_component_type, self.__mode_cmd_topic[:-4], name, DISCOVERY_SWITCH, self._frn_mode or "Mode") gc.collect() name = "heater_power" await self._publishDiscovery(_component_type, self.__power_topic, name, "", self._frn_power or "Power") gc.collect() name = "heater_status" await self._publishDiscovery(_component_type, self.__status_topic, name, "", self._frn_status or "Power") gc.collect() async def _updateMQTTStatus(self): await _mqtt.publish(self.__power_topic, self.__target_power, qos=1, retain=True) if self.__last_error is None: if self.__target_power == 0: await _mqtt.publish(self.__status_topic, "OFF", qos=1, retain=True) else: await _mqtt.publish(self.__status_topic, "ON", qos=1, retain=True) else: await _mqtt.publish(self.__status_topic, self.__last_error, qos=1, retain=True) await _mqtt.publish(self.__target_temp_cmd_topic[:-4], self.__target_temp, qos=1, retain=True) await _mqtt.publish(self.__mode_cmd_topic[:-4], self.__active_mode, qos=1, retain=True) def registerHardware(self, set_power, hardware_init=None): self.__setHeaterPower = set_power self.__initializeHardware = hardware_init def registerPlugin(self, coro, name): if coro is None: raise TypeError("Can't register plugin of type None") log.debug("Registering plugin {!s}".format(name), local_only=True) if coro not in self.__plugins: self.__plugins[name] = coro return True else: log.warn("Plugin {!s} already registered") return False def getInterval(self): return self.__interval def getHysteresisLow(self): return self.__hysteresis_low def getHysteresisHigh(self): return self.__hysteresis_high def getShutdownCycles(self): return self.__shutdown_cycles def getStartCycles(self): return self.__start_cycles def getFrostTemperature(self): return self.__frost_temp def getShutdownTemperature(self): return self.__shutdown_temp def getTargetTemp(self): return self.__target_temp def setTargetTemp(self, temp): log.debug("Set target temp to {!s}".format(temp), local_only=True) self.__target_temp = temp if self.__loop_started: self.__cycles_target_reached = -2 # makes heater react to temp change immediately def getStatusTopic(self): return self.__status_topic def getPowerTopic(self): return self.__power_topic def getModeCmdTopic(self): return self.__mode_cmd_topic def getActiveMode(self): return self.__active_mode def setEvent(self): self.__event.set() def getTargetPower(self): return self.__target_power def setLastError(self, error): self.__last_error = error def getLastError(self): return self.__last_error async def _init(self): await super()._init() await log.asyncLog("info", "Heater Core version {!s}".format(__version__)) if self.__initializeHardware is not None: await self.__initializeHardware() asyncio.get_event_loop().create_task(self._timer()) asyncio.get_event_loop().create_task(self._watch()) gc.collect() def addMode(self, mode, coro): self.__modes[mode] = coro def hasStarted(self): return self.__loop_started async def setMode(self, topic, msg, retain): if msg not in self.__modes: log.error("Mode {!r} not supported".format(msg)) return None if self.__last_error == "FROST": log.warn("Can't change mode to {!r} as temperature is below frost") return None if self.__loop_started: # wait for execution of cycle to end while self.__event.is_set(): await asyncio.sleep_ms(50) log.debug("setMode {!s}".format(msg), local_only=True) self.__active_mode = msg self.__cycles_target_reached = -2 if retain else 0 if self.__loop_started: self.__event.set() await _mqtt.publish(self.__mode_cmd_topic[:-4], msg, retain=True, qos=1) gc.collect() return True async def _requestTemp(self, topic, msg, retain): if self.__loop_started: while self.__event.is_set(): await asyncio.sleep_ms(50) try: temp = float(msg) except ValueError: log.error( "Error converting requested temp to float: {!r}".format(msg)) return None self.__target_temp = temp log.debug("requestTemp {!s}".format(temp), local_only=True) # await _mqtt.publish(self.__target_temp_topic[:-4], self.__target_temp, retain=True, qos=1) if self.__loop_started: self.__cycles_target_reached = -2 # makes heater react to temp change immediately self.__event.set() return True async def _timer(self): self.__timer_time = time.ticks_ms() while True: while (time.ticks_ms() - self.__timer_time) < (self.__interval * 1000): await asyncio.sleep(1) self.__timer_time = time.ticks_ms( ) # will also be set by _watch when resetting event log.debug("Reaction time reached", local_only=True) self.__event.set() async def _getTemperature(self): if hasattr(self.__sensor, "tempHumid"): return (await self.__sensor.tempHumid())["temperature"] elif hasattr(self.__sensor, "temperature"): return await self.__sensor.temperature() else: raise TypeError("No supported temperature sensor API") async def _watch(self): await asyncio.sleep( 10 ) # gives time to get retained values for targetTemp,Mode,Power,etc; and register plugins await self.__setHeaterPower(self.__target_power) # otherwise heater power would be floating or depending on how the hardware initializes self.__loop_started = True temp_reading_errors = 0 frost_cycles = 0 while True: current_temp = await self._getTemperature() log.debug("CurrentTemp: {!s}".format(current_temp), local_only=True) do_update = True if current_temp is False or current_temp is None: if self.__last_error != "NO_TEMP": log.error( "heater could not get current temperature, try {!s}". format(temp_reading_errors)) temp_reading_errors += 1 if temp_reading_errors >= 3: log.critical( "heater could not get temperature 3 times, shutting down heater" ) self.__last_error = "NO_TEMP" await self.setHeaterPower(0) self.__timer_time = time.ticks_ms( ) - self.__interval / 2 * 1000 # wait only 1/2 reaction time do_update = False elif current_temp < self.__frost_temp and self.__target_power < 100: if self.__last_error != "FROST": if self.__active_mode != "INTERNAL": self.__active_mode = "INTERNAL" # if different mode was active, it has failed await self.setHeaterPower(100) self.__last_error = "FROST" else: frost_cycles += 1 if frost_cycles >= 3: log.critical( "Heater can't raise temperature above frost") temp_reading_errors = 0 do_update = False elif current_temp > self.__shutdown_temp: if self.__last_error != "TOO_HOT": if self.__active_mode != "INTERNAL": self.__active_mode = "INTERNAL" # if different mode was active, it has failed (or summer) log.error( "heater shutdown due to too high temperature of {!r}°C" .format(current_temp)) await self.setHeaterPower(0) self.__last_error = "TOO_HOT" # could be summer temp_reading_errors = 0 frost_cycles = 0 do_update = False else: frost_cycles = 0 temp_reading_errors = 0 if do_update: last_error = self.__last_error data = {"current_temp": current_temp} for plugin in self.__plugins: try: await self.__plugins[plugin](self, data) except Exception as e: log.error("Plugin {!s} failed: {}".format(plugin, e)) await self.__modes[self.__active_mode](self, data) if self.__last_error == last_error: # reset error if not set by the mode coroutine # as error is no longer valid if execution of mode was successful self.__last_error = None await self._updateMQTTStatus() await self.__event self.__event.clear() self.__timer_time = time.ticks_ms() # in case of shutdown, code currently not reached anyways # await self._setHeaterPower(0) async def __modeInternal(self, heater, data): log.debug(data, local_only=True) current_temp = data["current_temp"] power = None target_power = self.__target_power if target_power > 0 and current_temp > self.__target_temp + self.__hysteresis_high: self.__cycles_target_reached += 1 if self.__cycles_target_reached >= self.__shutdown_cycles or self.__cycles_target_reached == -1: power = 0 self.__cycles_target_reached = 0 elif target_power == 0 and current_temp < self.__target_temp - self.__hysteresis_low: self.__cycles_target_reached += 1 if self.__cycles_target_reached >= self.__start_cycles or self.__cycles_target_reached == -1: power = 100 self.__cycles_target_reached = 0 else: self.__cycles_target_reached = 0 return # temperature in between; will keep 100% if rising and 0% if falling. # nothing to do. Could improve this by adding a timer checking if temp is really rising and warn otherwise log.debug("cycles_target_reached: {!s}".format( self.__cycles_target_reached), local_only=True) if power is None: return await self.setHeaterPower(power) async def setHeaterPower(self, power): if self.__setHeaterPower is None: log.error("No hardware registered to control heater") await _mqtt.publish(self.__status_topic, "ERR: NO HARDWARE", qos=1, retain=True) else: if not await self.__setHeaterPower(power): log.error( "Could not set heater power to {!s}%, shutting heater down" .format(power)) await self.__setHeaterPower(0) self.__last_error = "ERR: HARDWARE" else: self.__target_power = power
def __init__(self, temperature_sensor: ComponentSensor, heating_unit: ComponentSwitch, modes: list, interval: float = 300, temp_step=0.1, min_temp: float = 16, max_temp: float = 26, temp_low: float = 20, temp_high: float = 21, away_temp_low: float = 16, away_temp_high: float = 17, friendly_name=None, discover=True): self.checkSensorType(temperature_sensor, SENSOR_TEMPERATURE) self.checkSwitchType(heating_unit) # This makes it possible to use multiple instances of MyComponent global _unit_index _unit_index += 1 super().__init__(COMPONENT_NAME, __version__, _unit_index, discover) self._temp_step = temp_step self._min_temp = min_temp self._max_temp = max_temp self.temp_sensor: ComponentSensor = temperature_sensor self.heating_unit: ComponentSwitch = heating_unit self._modes = {} if "off" not in modes: modes.append("off") for mode in modes: if mode not in MODES_SUPPORTED: _log.error("Mode {!s} not supported".format(mode)) modes.remove(mode) else: try: mod = __import__( "pysmartnode.components.devices.climate.{}".format( mode), globals(), locals(), [], 0) except ImportError as e: _log.error("Mode {!s} not available: {!s}".format(mode, e)) continue if hasattr(mod, mode): modeobj = getattr(mod, mode) else: _log.error("Mode {!s} has no class {!r}".format( mode, mode)) continue try: modeobj = modeobj(self) except Exception as e: _log.error("Error creating mode {!s} object: {!s}".format( mode, e)) continue self._modes[mode] = modeobj self._frn = friendly_name self.state = { CURRENT_TEMPERATURE_HIGH: temp_high, # current temperature high CURRENT_TEMPERATURE_LOW: temp_low, # current temperature low AWAY_MODE_STATE: AWAY_OFF, # away mode "ON"/"OFF" STORAGE_AWAY_TEMPERATURE_HIGH: away_temp_high, # away temperature low STORAGE_AWAY_TEMPERATURE_LOW: away_temp_low, # away temperature high STORAGE_TEMPERATURE_HIGH: temp_high, # temperature high, storage value STORAGE_TEMPERATURE_LOW: temp_low, # temperature low, storage value CURRENT_MODE: str(self._modes["off"]), CURRENT_ACTION: ACTION_OFF } self.event = Event() self.lock = config.Lock() # every extneral change (like mode) that could break an ongoing trigger needs # to be protected by self.lock. self.log = _log gc.collect() self._mode_topic = _mqtt.getDeviceTopic("{!s}{!s}/statem/set".format( COMPONENT_NAME, self._count)) self._temp_low_topic = _mqtt.getDeviceTopic( "{!s}{!s}/statetl/set".format(COMPONENT_NAME, self._count)) self._temp_high_topic = _mqtt.getDeviceTopic( "{!s}{!s}/stateth/set".format(COMPONENT_NAME, self._count)) self._away_topic = _mqtt.getDeviceTopic("{!s}{!s}/stateaw/set".format( COMPONENT_NAME, self._count)) _mqtt.subscribeSync(self._mode_topic, self.changeMode, self) _mqtt.subscribeSync(self._temp_low_topic, self.changeTempLow, self) _mqtt.subscribeSync(self._temp_high_topic, self.changeTempHigh, self) _mqtt.subscribeSync(self._away_topic, self.changeAwayMode, self) self._restore_done = False asyncio.get_event_loop().create_task(self._loop(interval))
class Climate(Component): def __init__(self, temperature_sensor: ComponentSensor, heating_unit: ComponentSwitch, modes: list, interval: float = 300, temp_step=0.1, min_temp: float = 16, max_temp: float = 26, temp_low: float = 20, temp_high: float = 21, away_temp_low: float = 16, away_temp_high: float = 17, friendly_name=None, discover=True): self.checkSensorType(temperature_sensor, SENSOR_TEMPERATURE) self.checkSwitchType(heating_unit) # This makes it possible to use multiple instances of MyComponent global _unit_index _unit_index += 1 super().__init__(COMPONENT_NAME, __version__, _unit_index, discover) self._temp_step = temp_step self._min_temp = min_temp self._max_temp = max_temp self.temp_sensor: ComponentSensor = temperature_sensor self.heating_unit: ComponentSwitch = heating_unit self._modes = {} if "off" not in modes: modes.append("off") for mode in modes: if mode not in MODES_SUPPORTED: _log.error("Mode {!s} not supported".format(mode)) modes.remove(mode) else: try: mod = __import__( "pysmartnode.components.devices.climate.{}".format( mode), globals(), locals(), [], 0) except ImportError as e: _log.error("Mode {!s} not available: {!s}".format(mode, e)) continue if hasattr(mod, mode): modeobj = getattr(mod, mode) else: _log.error("Mode {!s} has no class {!r}".format( mode, mode)) continue try: modeobj = modeobj(self) except Exception as e: _log.error("Error creating mode {!s} object: {!s}".format( mode, e)) continue self._modes[mode] = modeobj self._frn = friendly_name self.state = { CURRENT_TEMPERATURE_HIGH: temp_high, # current temperature high CURRENT_TEMPERATURE_LOW: temp_low, # current temperature low AWAY_MODE_STATE: AWAY_OFF, # away mode "ON"/"OFF" STORAGE_AWAY_TEMPERATURE_HIGH: away_temp_high, # away temperature low STORAGE_AWAY_TEMPERATURE_LOW: away_temp_low, # away temperature high STORAGE_TEMPERATURE_HIGH: temp_high, # temperature high, storage value STORAGE_TEMPERATURE_LOW: temp_low, # temperature low, storage value CURRENT_MODE: str(self._modes["off"]), CURRENT_ACTION: ACTION_OFF } self.event = Event() self.lock = config.Lock() # every extneral change (like mode) that could break an ongoing trigger needs # to be protected by self.lock. self.log = _log gc.collect() self._mode_topic = _mqtt.getDeviceTopic("{!s}{!s}/statem/set".format( COMPONENT_NAME, self._count)) self._temp_low_topic = _mqtt.getDeviceTopic( "{!s}{!s}/statetl/set".format(COMPONENT_NAME, self._count)) self._temp_high_topic = _mqtt.getDeviceTopic( "{!s}{!s}/stateth/set".format(COMPONENT_NAME, self._count)) self._away_topic = _mqtt.getDeviceTopic("{!s}{!s}/stateaw/set".format( COMPONENT_NAME, self._count)) _mqtt.subscribeSync(self._mode_topic, self.changeMode, self) _mqtt.subscribeSync(self._temp_low_topic, self.changeTempLow, self) _mqtt.subscribeSync(self._temp_high_topic, self.changeTempHigh, self) _mqtt.subscribeSync(self._away_topic, self.changeAwayMode, self) self._restore_done = False asyncio.get_event_loop().create_task(self._loop(interval)) async def _init_network(self): await _mqtt.awaitSubscriptionsDone( ) # wait until subscriptions are done # because received messages will take up RAM and the discovery message # of climate is very big and could easily fail if RAM is fragmented. gc.collect() await asyncio.sleep(1) gc.collect() await super()._init_network() # let discovery succeed first because it is a big message await _mqtt.subscribe( _mqtt.getDeviceTopic("{!s}{!s}/state".format( COMPONENT_NAME, self._count)), self._restore, self) gc.collect() async def _loop(self, interval): interval = interval * 1000 t = time.ticks_ms() while not self._restore_done and time.ticks_diff(time.ticks_ms(), t) < 30000: await asyncio.sleep(1) # wait for network to finish so the old state can be restored, or time out (30s) if not self._restore_done: await _mqtt.unsubscribe( _mqtt.getDeviceTopic("{!s}{!s}/state".format( COMPONENT_NAME, self._count)), self) self._restore_done = True self.event.set() await asyncio.sleep(1) t = 0 while True: while time.ticks_diff(time.ticks_ms(), t) < interval and not self.event.is_set(): await asyncio.sleep_ms(100) if self.event.is_set(): self.event.clear() async with self.lock: cur_temp = await self.temp_sensor.getValue(SENSOR_TEMPERATURE) try: await self._modes[self.state[CURRENT_MODE] ].trigger(self, cur_temp) except Exception as e: _log.error("Error executing mode {!s}: {!s}".format( self.state[CURRENT_MODE], e)) await _mqtt.publish(_mqtt.getDeviceTopic( "{!s}{!s}/state".format(COMPONENT_NAME, self._count)), self.state, qos=1, retain=True, timeout=4) t = time.ticks_ms() async def _restore(self, topic, msg, retain): # used to restore the state after a restart await _mqtt.unsubscribe( _mqtt.getDeviceTopic("{!s}{!s}/state".format( COMPONENT_NAME, self._count)), self) mode = msg[CURRENT_MODE] del msg[CURRENT_MODE] del msg[CURRENT_ACTION] # is going to be set after trigger() self.state.update(msg) try: await self.changeMode(topic, mode, retain) # uses lock except AttributeError as e: await _log.asyncLog("error", e, timeout=10) self._restore_done = True await asyncio.sleep(1) self.event.set() async def changeAwayMode(self, topic, msg, retain): if msg in _mqtt.payload_on: if self.state[AWAY_MODE_STATE] == AWAY_ON: return False # no publish needed as done in _loop async with self.lock: self.state[AWAY_MODE_STATE] = AWAY_ON self.state[CURRENT_TEMPERATURE_HIGH] = self.state[ STORAGE_AWAY_TEMPERATURE_HIGH] self.state[CURRENT_TEMPERATURE_LOW] = self.state[ STORAGE_AWAY_TEMPERATURE_LOW] self.event.set() return False # no publish needed as done in _loop elif msg in _mqtt.payload_off: if self.state[AWAY_MODE_STATE] == AWAY_OFF: return False # no publish needed as done in _loop async with self.lock: self.state[AWAY_MODE_STATE] = AWAY_OFF self.state[CURRENT_TEMPERATURE_HIGH] = self.state[ STORAGE_TEMPERATURE_HIGH] self.state[CURRENT_TEMPERATURE_LOW] = self.state[ STORAGE_TEMPERATURE_LOW] self.event.set() return False # no publish needed as done in _loop else: raise TypeError("Unsupported payload {!s}".format(msg)) async def changeMode(self, topic, msg, retain): if msg not in self._modes: raise AttributeError("Mode {!s} not supported".format(msg)) if msg == self.state[CURRENT_MODE]: return False # no publish needed as done in _loop # mode already active async with self.lock: mode = self._modes[msg] if await self._modes[self.state[CURRENT_MODE]].deactivate(self): if await mode.activate(self): self.state[CURRENT_MODE] = msg self.event.set() return False # no publish needed as done in _loop else: self.state[CURRENT_MODE] = MODE_OFF await self._modes[MODE_OFF].activate() self.event.set() return False else: return False async def changeTempHigh(self, topic, msg, retain): msg = float(msg) if msg > self._max_temp: raise ValueError("Can't set temp to {!s}, max temp is {!s}".format( msg, self._max_temp)) if self.state[CURRENT_TEMPERATURE_HIGH] == msg: return False # already set to requested temperature, prevents unneeded event & publish self.state[CURRENT_TEMPERATURE_HIGH] = msg if self.state[AWAY_MODE_STATE] == AWAY_ON: self.state[STORAGE_AWAY_TEMPERATURE_HIGH] = msg else: self.state[STORAGE_TEMPERATURE_HIGH] = msg self.event.set() return False async def changeTempLow(self, topic, msg, retain): msg = float(msg) if msg < self._min_temp: raise ValueError("Can't set temp to {!s}, min temp is {!s}".format( msg, self._min_temp)) if self.state[CURRENT_TEMPERATURE_LOW] == msg: return False # already set to requested temperature, prevents unneeded event & publish self.state[CURRENT_TEMPERATURE_LOW] = msg if self.state[AWAY_MODE_STATE] == AWAY_ON: self.state[STORAGE_AWAY_TEMPERATURE_LOW] = msg else: self.state[STORAGE_TEMPERATURE_LOW] = msg self.event.set() return False async def _discovery(self, register=True): name = "{!s}{!s}".format(COMPONENT_NAME, self._count) base_topic = _mqtt.getRealTopic(_mqtt.getDeviceTopic(name)) modes = ujson.dumps([str(mode) for mode in self._modes]) gc.collect() if register: sens = CLIMATE_DISCOVERY.format( base_topic, self._frn or name, self._composeAvailability(), sys_vars.getDeviceID(), name, # unique_id _mqtt.getRealTopic( self.temp_sensor.getTopic(SENSOR_TEMPERATURE)), # current_temp_topic self.temp_sensor.getTemplate(SENSOR_TEMPERATURE), # cur_temp_template self._temp_step, self._min_temp, self._max_temp, modes, sys_vars.getDeviceDiscovery()) else: sens = "" gc.collect() topic = Component._getDiscoveryTopic(_COMPONENT_TYPE, name) await _mqtt.publish(topic, sens, qos=1, retain=True)
class ComponentSwitch(Component): """ Generic Switch class. Use it according to the template. """ def __init__(self, component_name, version, unit_index: int, command_topic=None, instance_name=None, wait_for_lock=True, discover=True, restore_state=True, friendly_name=None, initial_state=None): """ :param component_name: name of the component that is subclassing this switch (used for discovery and topics) :param version: version of the component module. will be logged over mqtt :param unit_index: counter of the registerd unit of this sensor_type (used for default topics) :param command_topic: command_topic of subclass which controls the switch state. optional. :param instance_name: name of the instance. If not provided will get composed of component_name<count> :param wait_for_lock: if True then every request waits for the lock to become available, :param restore_state: restore the retained state topic state meaning the previous device request has to finish before the new one is started. Otherwise the new one will get ignored. :param friendly_name: friendly name for homeassistant gui :param initial_state: intitial state of the switch. By default unknown so first state change request will set initial state. """ super().__init__(component_name, version, unit_index, discover=discover) # discover: boolean, if this component should publish its mqtt discovery. # This can be used to prevent combined Components from exposing underlying # hardware components like a power switch self._state = initial_state # initial state is unknown if None self._topic = command_topic or _mqtt.getDeviceTopic( "{!s}{!s}/set".format(component_name, self._count)) _mqtt.subscribeSync(self._topic, self.on_message, self, check_retained_state=restore_state) self.lock = config.Lock() # in case switch activates a device that will need a while to finish self._wfl = wait_for_lock self._name = instance_name self._event = None self._frn = friendly_name self._pub_task = None gc.collect() def getStateChangeEvent(self): """ Returns an event that gets triggered on every state change :return: Event """ if self._event is None: from pysmartnode.utils.event import Event self._event = Event() return self._event def _setState(self, state): if state != self._state and self._event is not None: self._event.set(state) self._state = state async def on_message(self, topic, msg, retain): """ Standard callback to change the device state from mqtt. Can be subclassed if extended functionality is needed. """ if msg in _mqtt.payload_on: if not self._state: # False or None (unknown) await self.on() elif msg in _mqtt.payload_off: if self._state is not False: # True or None (unknown) await self.off() else: raise TypeError("Payload {!s} not supported".format(msg)) return False # will not publish the requested state to mqtt as already done by on()/off() async def __publish(self, msg): await _mqtt.publish(self._topic[:-4], msg, qos=1, retain=True) self._pub_task = None async def on(self): """Turn switch on. Can be used by other components to control this component""" if self.lock.locked() is True and self._wfl is False: return False async with self.lock: res = await self._on( ) # if _on() returns True the value should be published if res is True: self._setState(True) if self._pub_task: asyncio.cancel(self._pub_task) self._pub_task = None self._pub_task = asyncio.get_event_loop().create_task( self.__publish("ON")) return res async def off(self): """Turn switch off. Can be used by other components to control this component""" if self.lock.locked() is True and self._wfl is False: return False async with self.lock: res = await self._off( ) # if _off() returns True the value should be published if res is True: self._setState(False) if self._pub_task: asyncio.cancel(self._pub_task) self._pub_task = None self._pub_task = asyncio.get_event_loop().create_task( self.__publish("OFF")) return res async def toggle(self): """Toggle device state. Can be used by other component to control this component""" if self._state is True: return await self.off() else: return await self.on() def state(self): return self._state async def _discovery(self, register=True): name = self._name or "{!s}{!s}".format(self.COMPONENT_NAME, self._count) if register: await self._publishDiscovery("switch", self._topic[:-4], name, DISCOVERY_SWITCH, self._frn) else: await self._deleteDiscovery("switch", name) # note that _publishDiscovery does expect the state topic # but we have the command topic stored. def topic(self): return self._topic
class ComponentSensor(Component): def __init__(self, component_name, version, unit_index: int, discover, interval_publish=None, interval_reading=None, mqtt_topic=None, log=None, expose_intervals=False, intervals_topic=None): """ :param component_name: Name of the component, used for default topics and logging :param version: version of the component module, used for logging purposes :param unit_index: counter of the registerd unit of this sensor_type (used for default topics) :param discover: if the sensor component should send its homeassistnat discovery message :param interval_publish: seconds, set to interval_reading to publish every reading. -1 for not publishing. :param interval_reading: seconds, set to -1 for not reading/publishing periodically. >0 possible for reading, 0 not allowed for reading.. Be careful with relying on reading sensors quickly because every publish will take at most 5 seconds per sensor_type until it times out. If you rely on quick and reliable sensor reading times choose interval_publish=-1 and start your own coroutine for publishing values. :param mqtt_topic: optional custom mqtt topic :param expose_intervals: Expose intervals to mqtt so they can be changed remotely :param intervals_topic: if expose_intervals then use this topic to change intervals. Defaults to <home>/<device-id>/<COMPONENT_NAME><_unit_index>/interval/set """ super().__init__(component_name, version, unit_index, discover) self._values = {} self._log = log or logging.getLogger(component_name) # _intpb can be >0, -1 for not publishing or 0/None for INTERVAL_SENSOR_PUBLISH self._intpb: float = interval_publish or config.INTERVAL_SENSOR_PUBLISH self._intrd: float = config.INTERVAL_SENSOR_READ if interval_reading is None else interval_reading if self._intpb < self._intrd: raise ValueError( "interval_publish can't be lower than interval_reading") self._topic = mqtt_topic # can be None self._event = None self._reading: bool = False # cheaper than Lock if expose_intervals: _mqtt.subscribeSync(intervals_topic or _mqtt.getDeviceTopic( "{!s}/interval/set".format(self._default_name())), self.setInterval, self, qos=1, check_retained_state=True) self._loop_coro = None if self._intrd > 0: # if interval_reading==-1 no loop will be started self._loop_coro = self._loop() asyncio.get_event_loop().create_task(self._loop_coro) # self._loop_coro will get canceled when component is removed. gc.collect() async def _remove(self): """Called by component base class when a sensor component should be removed""" if self._loop_coro is not None: asyncio.cancel(self._loop_coro) await super()._remove() def _addSensorType(self, sensor_type: str, precision: int, offset: float, value_template: str, unit_of_meas: str, friendly_name: str = None, topic: str = None, discovery_type: str = None, binary_sensor: bool = False): """ :param sensor_type: Name of the sensor type, preferrably used by references to .definitons module :param precision: digits after separator "." :param offset: float offset to account for bad sensor readings :param value_template: value template in homeassistant. :param unit_of_meas: unit of measurement in homeassistant :param friendly_name: friendly name in homeassistant, falls back to sensor_type if not given :param topic: Each sensor_type can have its own topic or publish on one topic :param discovery_type: custom discovery configuration if sensor is not supported by standard types with device_class, unit_of_meas, value_template :param binary_sensor: if sensor is a binary_sensor, otherwise default sensor. :return: """ tp = [ int(precision), float(offset), value_template, unit_of_meas, friendly_name, topic, discovery_type, binary_sensor, None, None ] # tp[-1] is last sensor reading, tp[-2] is timestamp of last reading (not None) self._values[sensor_type] = tp def setReadingInterval(self, *args): """ Change the reading interval. Call function like setReadingInterval(5). Note that changing read interval to be bigger than publish interval will make publish interval to behave like read interval. :param args: args expected so function can be exposed to mqtt directly. :return: """ self._intrd = float(args[0] if len(args) == 1 else args[1]) return True def setPublishInterval(self, *args): """ Change the publish interval. Call function like setReadingInterval(5). Note that changing publish interval to be smaller than read interval will make publish interval to behave like read interval. :param args: args expected so function can be exposed to mqtt directly. :return: """ self._intpb = float(args[0] if len(args) == 1 else args[1]) return True def setInterval(self, *args): """ Change both intervals using a dictionary. Can be exposed to mqtt :param args: :return: """ ints = args[0] if len(args) == 1 else args[1] if type(ints) != dict: raise TypeError("Interval change needs to be dict") if "reading" in ints: self.setReadingInterval(ints["reading"]) if "publish" in ints: self.setPublishInterval(ints["publish"]) return True def getReadingsEvent(self): """ Returns an event that gets triggered on every read :return: Event """ if self._event is None: from pysmartnode.utils.event import Event self._event = Event() return self._event async def _publishValues(self, timeout: float = 5): d = {} t = self._topic or _mqtt.getDeviceTopic(self._default_name()) for sensor_type in self._values: val = self._values[sensor_type] if val[-1] is not None: if val[5] is None: # no topic for sensor_type d[sensor_type] = val[-1] else: msg = val[-1] if type(msg) == bool and val[7]: # binary sensor msg = _mqtt.payload_on[ 0] if msg else _mqtt.payload_off[0] # elif type(msg)==float: # msg =("{0:." + str(val[0]) + "f}").format(msg) # on some platforms this might make sense as a workaround for 25.3000000001 await _mqtt.publish(val[5], msg, qos=1, timeout=timeout) if len(d) == 1 and "value_json" not in self._values[list( d.keys())[0]][2]: # topic has no json template so send it without dict d = d[list(d.keys())[0]] if type(d) != dict or len( d) > 0: # single value or dict with at least one entry await _mqtt.publish(t, d, qos=1, timeout=timeout) def _default_name(self): """ Can be used to override the default naming pattern, e.g. if the sensors have a unique id """ return "{!s}{!s}".format(self.COMPONENT_NAME, self._count) async def _discovery(self, register=True): for sensor_type in self._values: val = self._values[sensor_type] if len(self._values) > 0: name = "{!s}{!s}".format(self._default_name(), sensor_type[0].upper()) else: name = self._default_name() tp = val[6] or self._composeSensorType(sensor_type, val[3], val[2]) if register: await self._publishDiscovery( "binary_sensor" if val[7] else "sensor", self.getTopic(sensor_type), name, tp, val[4] or "{}{}".format(sensor_type[0].upper(), sensor_type[1:])) else: await self._deleteDiscovery("binary_sensor", name) del name, tp gc.collect() @property def sensor_types(self): """ Returns the registered sensor types so other components can check if the sensor object supports the type they want to read. """ return self._values.keys() def _checkType(self, sensor_type): if sensor_type not in self._values: raise ValueError("sensor_type {!s} unknown".format(sensor_type)) async def getValues(self): """Returns all current values as a dictionary. No read or publish possible""" return dict((x, self._values[x][-1]) for x in self._values) def getTimestamps(self): return dict((x, self._values[x][-2]) for x in self._values) def getTimestamp(self, sensor_type=None): """Return timestamp of last successful sensor read (last value that was not None)""" self._checkType(sensor_type) return self._values[sensor_type][-2] async def getValue(self, sensor_type, publish=True, timeout: float = 5, max_age: float = None): """ Return last sensor reading of type "sensor_type". Only reads sensor if no loop is reading it periodically unless no_stale is True. If a loop is active, it will return the last read value which will be at most <self._intrd> old. Params publish and timeout will only have an effect if no loop is active or no_stale True. :param sensor_type: str representation of the sensor_type. :param publish: if value should be published if sensor has to be read for returned value :param timeout: timeout for publishing the value :param max_age: Specify how old the value can be. If it is older, the sensor will be read again. Makes long intervals possible with other components that rely on having a "live" sensor reading. :return: float or whatever the sensor_type has as a standard, None if no value available """ self._checkType(sensor_type) if max_age: if time.ticks_diff(time.ticks_ms(), self._values[sensor_type][-2]) / 1000 > max_age: max_age = True else: max_age = False if max_age or self._intrd == -1: if self._reading: # if currently reading, don't read again as value will be "live" while self._reading: await asyncio.sleep_ms(100) else: self._reading = True await self._read() self._reading = False if publish: await self._publishValues(timeout=timeout) return self._values[sensor_type][-1] def getTemplate(self, sensor_type): self._checkType(sensor_type) return self._values[sensor_type][2] def getTopic(self, sensor_type): self._checkType(sensor_type) return self._values[sensor_type][ 5] or self._topic or _mqtt.getDeviceTopic(self._default_name()) async def _setValue(self, sensor_type, value, timeout=10): """ Set the newly read value for the given sensor_type :param sensor_type: :param value: :param timeout: timeout for logging if value is None or exception in processing value. If sensor is designed to be read quickly and logging if no value could be read is not relevant, then the sensor _read function should not call _setValue if it has no value. :return: """ self._checkType(sensor_type) s = self._values[sensor_type] if value is not None: if type(value) in (int, float): try: value = round(value, s[0]) value += s[1] except Exception as e: log = self._log or logging.getLogger(self.COMPONENT_NAME) await log.asyncLog("error", "Error processing value:", e, timeout=timeout) value = None elif type(value) not in (bool, str): log = self._log or logging.getLogger(self.COMPONENT_NAME) await log.asyncLog("error", "Error processing value:", value, "no known type", timeout=timeout) value = None else: log = self._log or logging.getLogger(self.COMPONENT_NAME) await log.asyncLog("warn", "Got no value for", sensor_type, timeout=timeout) s[-1] = value if value: s[-2] = time.ticks_ms() async def _loop(self): await asyncio.sleep(1) d = float("inf") if self._intpb == -1 else (self._intpb / self._intrd) i = d + 1 # so first reading gets published pbc = None try: while True: # d recalculated in loop so _intpb and _intrd can be changed during runtime d = float("inf") if self._intpb == -1 else (self._intpb / self._intrd) pb = i >= d i = 1 if pb else i + 1 t = time.ticks_ms() while self._reading: # wait when sensor is read because of a getValue(no_stale=True) request await asyncio.sleep_ms(50) self._reading = True await self._read() self._reading = False if self._event: self._event.set() if pb: if pbc is not None: asyncio.cancel(pbc) vals = 0 # counting sensor_types which have a topic as those get published separately for tp in self._values: if tp[5] is not None: vals += 1 vals = vals or 1 # if no type has a topic, one is still used to publish sl = self._intrd * 1000 - time.ticks_diff( time.ticks_ms(), t) if sl / 1000 > 5 * vals: # if enough time is left until next reading then await publishing values # (5 seconds per unique sensor_type) await self._publishValues() else: # otherwise start task to publish values which might get canceled if # it can't finish until next publish is requested. pbc = self._publishValues() asyncio.get_event_loop().create_task(pbc) # sleep until the sensor should be read again. Using loop with 100ms makes # changing the read interval during runtime possible with a reaction time of 100ms. while True: sl = self._intrd * 1000 - time.ticks_diff( time.ticks_ms(), t) sl = 100 if sl > 100 else sl if sl > 0 else 0 # sleeping with 0 lets other coros run in between await asyncio.sleep_ms(sl) if sl == 0: # sleeping done break except asyncio.CancelledError: if pbc is not None: asyncio.cancel(pbc) raise except NotImplementedError: raise except Exception as e: if config.DEBUG: import sys sys.print_exception(e) await self._log.asyncLog("critical", "Exception in component loop:", e) async def _read(self): """ Subclass to read and store all sensor values. See sensor_template for examples of how to implement the _read method. :return: """ raise NotImplementedError("No sensor _read method implemented")