diff --git a/gw_spaceheat/actors/__init__.py b/gw_spaceheat/actors/__init__.py index fcc6ccc8..11caf3a6 100644 --- a/gw_spaceheat/actors/__init__.py +++ b/gw_spaceheat/actors/__init__.py @@ -1,23 +1,27 @@ """Temporary package with asyncio actor implementation, currently exists with actors package to make work in progress easier.""" +from actors.api_tank_module import ApiTankModule from actors.home_alone import HomeAlone from actors.honeywell_thermostat import HoneywellThermostat from actors.hubitat import Hubitat from actors.hubitat_poller import HubitatPoller from actors.hubitat_tank_module import HubitatTankModule from actors.multipurpose_sensor import MultipurposeSensor +from actors.parentless import Parentless from actors.power_meter import PowerMeter from actors.scada import Scada from actors.scada_interface import ScadaInterface __all__ = [ + "ApiTankModule", "HomeAlone", "HoneywellThermostat", "Hubitat", "HubitatPoller", "HubitatTankModule", "MultipurposeSensor", + "Parentless", "PowerMeter", "Scada", "ScadaInterface", diff --git a/gw_spaceheat/actors/api_tank_module.py b/gw_spaceheat/actors/api_tank_module.py new file mode 100644 index 00000000..7546b805 --- /dev/null +++ b/gw_spaceheat/actors/api_tank_module.py @@ -0,0 +1,234 @@ +import json +import math +import time +from functools import cached_property +from typing import Dict, List +from typing import Literal +from typing import Optional +from aiohttp.web_request import Request +from aiohttp.web_response import Response +from gwproactor import Actor +from gwproactor import Problems +from gwproactor import ServicesInterface +from gwproto import Message +from gwproto.types.web_server_gt import DEFAULT_WEB_SERVER_NAME +from gwproto.types import TankModuleParams +from gwproto.types import SyncedReadings +from gwproto.data_classes.components import PicoTankModuleComponent +from pydantic import BaseModel + +from result import Ok +from result import Result + + +R_FIXED_KOHMS = 5.65 +R_PICO_KOHMS = 30 +THERMISTOR_BETA = 3977 +THERMISTOR_T0 = 298 # i.e. 25 degrees C +THERMISTOR_R0_KOHMS = 10 + + + +class MicroVolts(BaseModel): + HwUid: str + AboutNodeNameList: List[str] + MicroVoltsList: List[int] + TypeName: Literal["microvolts"] = "microvolts" + Version: Literal["100"] = "100" + + + +class ApiTankModule(Actor): + + _component: PicoTankModuleComponent + params_by_hw_uid: Dict[str, TankModuleParams] + def __init__( + self, + name: str, + services: ServicesInterface, + ): + super().__init__(name, services) + component = services.hardware_layout.component(name) + if not isinstance(component, PicoTankModuleComponent): + display_name = getattr( + component.gt, "display_name", "MISSING ATTRIBUTE display_name" + ) + raise ValueError( + f"ERROR. Component <{display_name}> has type {type(component)}. " + f"Expected PicoComponent.\n" + f" Node: {self.name}\n" + f" Component id: {component.gt.ComponentId}" + ) + self._component = component + if self._component.gt.Enabled: + self._services.add_web_route( + server_name=DEFAULT_WEB_SERVER_NAME, + method="POST", + path="/" + self.microvolts_path, + handler=self._handle_microvolts_post, + ) + self._services.add_web_route( + server_name=DEFAULT_WEB_SERVER_NAME, + method="POST", + path="/" + self.params_path, + handler=self._handle_params_post, + ) + self.params_by_hw_uid = {} + self.set_params() + + def set_params(self) -> None: + c = self._component.gt + id_a = c.PicoHwUidList[0] + self.params_by_hw_uid[id_a] = TankModuleParams( + HwUid=id_a, + ActorNodeName=self.name, + PicoAB="a", + CapturePeriodS=c.ConfigList[0].CapturePeriodS, + Samples=c.Samples, + NumSampleAverages=c.NumSampleAverages, + AsyncCaptureDeltaMicroVolts=c.ConfigList[0].AsyncCaptureDelta, + ) + id_b = c.PicoHwUidList[1] + self.params_by_hw_uid[id_b] = TankModuleParams( + HwUid=id_a, + ActorNodeName=self.name, + PicoAB="b", + CapturePeriodS=c.ConfigList[2].CapturePeriodS, + Samples=c.Samples, + NumSampleAverages=c.NumSampleAverages, + AsyncCaptureDeltaMicroVolts=c.ConfigList[2].AsyncCaptureDelta, + ) + + @cached_property + def microvolts_path(self) -> str: + return f"{self.name}/microvolts" + + @cached_property + def params_path(self) -> str: + return f"{self.name}/tank-module-params" + + async def _get_text(self, request: Request) -> Optional[str]: + try: + return await request.text() + except Exception as e: + self.services.send_threadsafe( + Message( + Payload=Problems(errors=[e]).problem_event( + summary=( + f"ERROR awaiting post ext <{self.name}>: {type(e)} <{e}>" + ), + ) + ) + ) + return None + + def _report_post_error(self, exception: BaseException, text: str) -> None: + self.services.send_threadsafe( + Message( + Payload=Problems( + msg=f"request: <{text}>", + errors=[exception] + ).problem_event( + summary=( + "Pico POST processing error for " + f"<{self._name}>: {type(exception)} <{exception}>" + ), + ) + ) + ) + + async def _handle_microvolts_post(self, request: Request) -> Response: + text = await self._get_text(request) + self.readings_text = text + if isinstance(text, str): + try: + self.services.send_threadsafe( + Message( + Src=self.name, + Dst=self.name, + Payload=MicroVolts(**json.loads(text)) + ) + ) + except Exception as e: # noqa + self._report_post_error(e, text) + return Response() + + async def _handle_params_post(self, request: Request) -> Response: + text = await self._get_text(request) + self.params_text = text + try: + params = TankModuleParams(**json.loads(text)) + except BaseException as e: + self._report_post_error(e, "malformed tankmodule parameters!") + return + if params.ActorNodeName != self.name: + return + if params.HwUid not in self._component.gt.PicoHwUidList: + self._report_post_error(ValueError("params"), + f"{params.HwUid} not associated with {params.ActorNodeName}!") + return + + return Response(text=self.params_by_hw_uid[params.HwUid].model_dump_json()) + + def _process_microvolts(self, data: MicroVolts) -> None: + self.latest_readings = data + about_node_list = [] + value_list = [] + for i in range(len(data.AboutNodeNameList)): + volts = data.MicroVoltsList[i] / 1e6 + try: + r_therm_kohms = thermistor_resistance(volts) + temp_c = temp_beta(r_therm_kohms, fahrenheit=False) + value_list.append(int(temp_c * 1000)) + about_node_list.append(data.AboutNodeNameList[i]) + except BaseException as e: + self.services.send_threadsafe( + Message( + Payload=Problems( + msg=f"Volts to temp problem for {data.AboutNodeNameList[i]} with {volts} V", + errors=[e] + ).problem_event( + summary=( + "Volts to temp problem" + ), + ) + ) + ) + msg = SyncedReadings(ChannelNameList=about_node_list, + ValueList=value_list, + ScadaReadTimeUnixMs=int(time.time() * 1000)) + # print(f"Publishing to local: ({msg.ScadaReadTimeUnixMs})") + self.services._publish_to_local(self._node, msg) + + def process_message(self, message: Message) -> Result[bool, BaseException]: + match message.Payload: + case MicroVolts(): + self._process_microvolts(message.Payload) + return Ok(True) + + def start(self) -> None: + """IOLoop will take care of start.""" + + def stop(self) -> None: + """IOLoop will take care of stop.""" + + async def join(self) -> None: + """IOLoop will take care of shutting down the associated task.""" + +def thermistor_resistance(volts, r_fixed=R_FIXED_KOHMS, r_pico=R_PICO_KOHMS): + r_therm = 1/((3.3/volts-1)/r_fixed - 1/r_pico) + return r_therm + +def temp_beta(r_therm_kohms: float, fahrenheit: bool=False) -> float: + """ + beta formula specs for the Amphenol MA100GG103BN + Uses T0 and R0 are a matching pair of values: this is a 10 K thermistor + which means at 25 deg C (T0) it has a resistance of 10K Ohms + + [More info](https://drive.google.com/drive/u/0/folders/1f8SaqCHOFt8iJNW64A_kNIBGijrJDlsx) + """ + t0, r0, beta = THERMISTOR_T0, THERMISTOR_R0_KOHMS, THERMISTOR_BETA + r_therm = r_therm_kohms + temp_c = 1 / ((1/t0) + (math.log(r_therm/r0) / beta)) - 273 + temp_f = 32 + (temp_c * 9/5) + return round(temp_f, 2) if fahrenheit else round(temp_c, 2) \ No newline at end of file diff --git a/gw_spaceheat/actors/parentless.py b/gw_spaceheat/actors/parentless.py new file mode 100644 index 00000000..705deb1b --- /dev/null +++ b/gw_spaceheat/actors/parentless.py @@ -0,0 +1,173 @@ +"""Parentless (Scada2) implementation""" +import importlib +import asyncio +import threading +from typing import Any, Optional +from typing import List + +from gwproto.message import Message +from gwproto.types import Report +from gwproto.types import SnapshotSpaceheat +from gwproto.data_classes.house_0_names import H0N +from gwproto.data_classes.hardware_layout import HardwareLayout +from gwproto.data_classes.sh_node import ShNode + +from actors.api_tank_module import MicroVolts +from actors.scada_interface import ScadaInterface +from actors.config import ScadaSettings +from gwproactor import QOS +from gwproactor import ActorInterface +from gwproactor.message import MQTTReceiptPayload +from gwproactor.proactor_implementation import Proactor +from actors.scada import ( + LocalMQTTCodec, +) + +class Scada2Data: + latest_snap: Optional[SnapshotSpaceheat] + latest_report: Optional[Report] + def __init__(self) -> None: + self.latest_snap = None + self.latest_report = None + +class Parentless(ScadaInterface, Proactor): + ASYNC_POWER_REPORT_THRESHOLD = 0.05 + DEFAULT_ACTORS_MODULE = "actors" + LOCAL_MQTT = "local" + _data: Scada2Data + + def __init__( + self, + name: str, + settings: ScadaSettings, + hardware_layout: HardwareLayout, + actors_package_name: Optional[str] = None + ): + super().__init__(name=name, settings=settings, hardware_layout=hardware_layout) + self._links.add_mqtt_link( + Parentless.LOCAL_MQTT, self.settings.local_mqtt, LocalMQTTCodec(self._layout) + ) + self._links.subscribe( + Parentless.LOCAL_MQTT, + f"gw/{H0N.primary_scada}/#", + qos=QOS.AtMostOnce, + ) + self._data = Scada2Data() + self._links.log_subscriptions("construction") + self.actors_package_name = actors_package_name + if actors_package_name is None: + self.actors_package_name = self.DEFAULT_ACTORS_MODULE + + actor_nodes = self.get_actor_nodes() + for actor_node in actor_nodes: + self.add_communicator( + ActorInterface.load( + actor_node.Name, + str(actor_node.actor_class), + self, self.DEFAULT_ACTORS_MODULE + ) + ) + + def init(self) -> None: + """Called after constructor so derived functions can be used in setup.""" + print("hi!") + + def get_actor_nodes(self) -> List[ShNode]: + actors_package = importlib.import_module(self.actors_package_name) + actor_nodes = [] + my_kids = [node for node in self._layout.nodes.values() if + self._layout.parent_node(node) == self._node and + node != self._node and + node.has_actor] + for node in my_kids: + if not getattr(actors_package, node.actor_class): + raise ValueError( + f"ERROR. Actor class {node.actor_class} for node {node.Name} " + f"not in actors package {self.actors_package_name}" + ) + else: + actor_nodes.append(node) + return actor_nodes + + @property + def name(self): + return self._name + + @property + def node(self) -> ShNode: + return self._node + + @property + def settings(self): + return self._settings + + @property + def data(self) -> Scada2Data: + return self._data + + @property + def hardware_layout(self) -> HardwareLayout: + return self._layout + + def _publish_to_local(self, from_node: ShNode, payload, qos: QOS = QOS.AtMostOnce): + message = Message(Src=from_node.Name, Payload=payload) + return self._links.publish_message(Parentless.LOCAL_MQTT, message, qos=qos) + + def _derived_process_message(self, message: Message): + self._logger.path("++Parentless._derived_process_message %s/%s", message.Header.Src, message.Header.MessageType) + path_dbg = 0 + #from_node = self._layout.node(message.Header.Src, None) + match message.Payload: + case MicroVolts(): + path_dbg |= 0x00000001 + self.get_communicator(message.Header.Dst).process_message(message) + case _: + raise ValueError( + f"There is no handler for mqtt message payload type [{type(message.Payload)}]" + ) + self._logger.path("--Parentless._derived_process_message path:0x%08X", path_dbg) + + def _derived_process_mqtt_message( + self, message: Message[MQTTReceiptPayload], decoded: Any + ): + self._logger.path("++Parentless._derived_process_mqtt_message %s", message.Payload.message.topic) + path_dbg = 0 + mqtt_msg = message.Payload.message + from_node_name = mqtt_msg.topic.split('/')[1] + from_node = self._layout.node(from_node_name) + codec = self._links._mqtt_codecs['local'] + payload = codec.decode(topic=mqtt_msg.topic, payload=mqtt_msg.payload).Payload + if from_node: + match payload: + case Report(): + path_dbg |= 0x00000001 + self.report_received(payload) + case SnapshotSpaceheat(): + path_dbg |= 0x00000002 + self.snapshot_received(payload) + case _: + raise ValueError( + f"There is no handler for mqtt message payload type [{type(decoded.Payload)}]\n" + f"Received\n\t topic: [{message.Payload.message.topic}]" + ) + self._logger.path("--Parentless._derived_process_mqtt_message path:0x%08X", path_dbg) + + def snapshot_received(self, payload: SnapshotSpaceheat)-> None: + self._data.latest_snap = payload + + def report_received(self, payload: Report)-> None: + self._data.latest_report = payload + + def run_in_thread(self, daemon: bool = True) -> threading.Thread: + async def _async_run_forever(): + try: + await self.run_forever() + + finally: + self.stop() + + def _run_forever(): + asyncio.run(_async_run_forever()) + thread = threading.Thread(target=_run_forever, daemon=daemon) + thread.start() + return thread diff --git a/gw_spaceheat/command_line_utils.py b/gw_spaceheat/command_line_utils.py index 87fd2fb5..8a81d01c 100644 --- a/gw_spaceheat/command_line_utils.py +++ b/gw_spaceheat/command_line_utils.py @@ -3,7 +3,7 @@ import sys import argparse from pathlib import Path -from typing import Optional, Sequence, Tuple +from typing import Optional, Sequence, Tuple, List import traceback import dotenv @@ -14,7 +14,7 @@ from gwproactor.config.paths import TLSPaths from pydantic import BaseModel -from actors import Scada +from actors import Scada, Parentless from actors.config import ScadaSettings from gwproto.data_classes.hardware_layout import HardwareLayout from gwproto.data_classes.sh_node import ShNode @@ -81,18 +81,18 @@ def get_requested_names(args: argparse.Namespace) -> Optional[set[str]]: requested = None else: requested = set(args.nodes) - requested.add(H0N.scada) + requested.add(H0N.primary_scada) requested.add(H0N.home_alone) return requested -def get_actor_nodes(requested_names: Optional[set[str]], layout: HardwareLayout, actors_package_name: str) -> Tuple[ShNode, list[ShNode]]: +def get_nodes_run_by_scada(requested_names: Optional[set[str]], layout: HardwareLayout, actors_package_name: str) -> Tuple[ShNode, list[ShNode]]: actors_package = importlib.import_module(actors_package_name) if requested_names: requested_nodes = [layout.node(name) for name in requested_names] else: requested_nodes = layout.nodes.values() - actor_nodes = [] + actor_nodes: List[ShNode] = [] scada_node: Optional[ShNode] = None for node in requested_nodes: if node.ActorClass not in [ActorClass.Atn, ActorClass.HomeAlone] and node.has_actor: @@ -110,6 +110,7 @@ def get_actor_nodes(requested_names: Optional[set[str]], layout: HardwareLayout, ) else: actor_nodes.append(node) + actor_nodes = [n for n in actor_nodes if layout.parent_node(n) == scada_node] return scada_node, actor_nodes def missing_tls_paths(paths: TLSPaths) -> list[tuple[str, Optional[Path]]]: @@ -139,6 +140,7 @@ def check_tls_paths_present(model: BaseModel | BaseSettings, raise_error: bool = return error_str def get_scada( + name: str = H0N.primary_scada, argv: Optional[Sequence[str]] = None, run_in_thread: bool = False, add_screen_handler: bool = True, @@ -168,15 +170,23 @@ def get_scada( check_tls_paths_present(settings) requested_names = get_requested_names(args) layout = HardwareLayout.load(settings.paths.hardware_layout, included_node_names=requested_names) - scada_node, actor_nodes = get_actor_nodes(requested_names, layout, actors_package_name) - print(f"actor_nodes is {actor_nodes}") - scada = Scada(name=scada_node.Name, settings=settings, hardware_layout=layout, actor_nodes=actor_nodes) - if run_in_thread: - logger.info("run_async_actors_main() starting") - scada.run_in_thread() + if name == H0N.primary_scada: + scada_node, actor_nodes = get_nodes_run_by_scada(requested_names, layout, actors_package_name) + print(f"actor nodes run by scada: {actor_nodes}") + scada = Scada(name=scada_node.Name, settings=settings, hardware_layout=layout, actor_nodes=actor_nodes) + if run_in_thread: + logger.info("run_async_actors_main() starting") + scada.run_in_thread() + else: + print(f"name is {name}") + scada = Parentless(name=name, settings=settings,hardware_layout=layout, actors_package_name=actors_package_name) + if run_in_thread: + logger.info("run_async_actors_main() starting") + scada.run_in_thread() return scada + async def run_async_actors_main(argv: Optional[Sequence[str]] = None): exception_logger = logging.getLogger(ScadaSettings().logging.base_log_name) try: diff --git a/gw_spaceheat/layout_gen/__init__.py b/gw_spaceheat/layout_gen/__init__.py index cf6ddeb5..8c63b625 100644 --- a/gw_spaceheat/layout_gen/__init__.py +++ b/gw_spaceheat/layout_gen/__init__.py @@ -13,6 +13,8 @@ from layout_gen.tank import FibaroGenCfg from layout_gen.tank import TankGenCfg from layout_gen.tank import add_tank +from layout_gen.tank2 import add_tank2 +from layout_gen.tank2 import Tank2Cfg from layout_gen.web_server import add_web_server __all__ = [ @@ -21,6 +23,7 @@ "add_thermostat", "add_hubitat_thermostat", "add_tank", + "add_tank2", "add_tsnap_multipurpose", "add_web_server", "PowerMeterGenConfig", @@ -31,6 +34,7 @@ "HubitatThermostatGenCfg", "StubConfig", "TankGenCfg", + "Tank2Cfg", "TSnapMultipurposeGenCfg", "SensorNodeGenCfg", diff --git a/gw_spaceheat/layout_gen/egauge.py b/gw_spaceheat/layout_gen/egauge.py index 607898e3..52ccbd2b 100644 --- a/gw_spaceheat/layout_gen/egauge.py +++ b/gw_spaceheat/layout_gen/egauge.py @@ -132,6 +132,7 @@ def add_egauge( ShNodeId=db.make_node_id(egauge.NodeName), Name=egauge.NodeName, ActorClass=ActorClass.PowerMeter, + ActorHierarchyName=f"{H0N.primary_scada}.{egauge.NodeName}", DisplayName=egauge.NodeDisplayName, ComponentId=db.component_id_by_alias(egauge.ComponentDisplayName), ) diff --git a/gw_spaceheat/layout_gen/hubitat.py b/gw_spaceheat/layout_gen/hubitat.py index ee5cf2eb..4c377f50 100644 --- a/gw_spaceheat/layout_gen/hubitat.py +++ b/gw_spaceheat/layout_gen/hubitat.py @@ -43,6 +43,7 @@ def add_hubitat( SpaceheatNodeGt( ShNodeId=db.make_node_id(H0N.hubitat), Name=H0N.hubitat, + ActorHierarchyName=f"{H0N.primary_scada}.{H0N.hubitat}", ActorClass=ActorClass.Hubitat, DisplayName=hubitat_component_alias, ComponentId=db.component_id_by_alias(hubitat_component_alias), diff --git a/gw_spaceheat/layout_gen/layout_db.py b/gw_spaceheat/layout_gen/layout_db.py index 46806585..f794c79b 100644 --- a/gw_spaceheat/layout_gen/layout_db.py +++ b/gw_spaceheat/layout_gen/layout_db.py @@ -119,6 +119,7 @@ class ChannelStubDb(ChannelStub): @dataclass class StubConfig: atn_gnode_alias: str = "d1.isone.ct.newhaven.orange1" + terminal_asset_alias: Optional[str] = None scada_display_name: str = "Dummy Orange Scada" add_stub_power_meter: bool = True power_meter_cac_alias: str = "Dummy Power Meter Cac" @@ -281,7 +282,10 @@ def __init__( if add_stubs: self.add_stubs(stub_config) - self.terminal_asset_alias = self.misc["MyTerminalAssetGNode"]["Alias"] + + @property + def terminal_asset_alias(self): + return self.misc["MyTerminalAssetGNode"]["Alias"] def cac_id_by_alias(self, make_model: str) -> Optional[str]: return self.maps.cacs_by_alias.get(make_model, None) @@ -390,6 +394,7 @@ def add_data_channels(self, dcs: list[DataChannelGt]): if layout_list_name not in self.lists: self.lists[layout_list_name] = [] self.lists[layout_list_name].append(dc) + def add_stub_power_meter(self, cfg: Optional[StubConfig] = None): if cfg is None: @@ -482,7 +487,7 @@ def add_stub_power_meter(self, cfg: Optional[StubConfig] = None): CapturedByNodeName=H0N.primary_power_meter, TelemetryName=TelemetryName.PowerW, InPowerMetering=True, - TerminalAssetAlias="some.ta.alias" + TerminalAssetAlias=self.terminal_asset_alias ), DataChannelGt( Name=H0CN.hp_idu_pwr, @@ -492,12 +497,12 @@ def add_stub_power_meter(self, cfg: Optional[StubConfig] = None): CapturedByNodeName=H0N.primary_power_meter, TelemetryName=TelemetryName.PowerW, InPowerMetering=True, - TerminalAssetAlias="some.ta.alias" + TerminalAssetAlias=self.terminal_asset_alias ) ] ) - def add_stub_scada(self, cfg: Optional[StubConfig] = None): + def add_stub_scadas(self, cfg: Optional[StubConfig] = None): if cfg is None: cfg = StubConfig() if self.loaded.gnodes: @@ -517,9 +522,12 @@ def add_stub_scada(self, cfg: Optional[StubConfig] = None): "GNodeStatusValue": "Active", "PrimaryGNodeRoleAlias": "Scada" } + ta_alias = f"{cfg.atn_gnode_alias}.ta" + if cfg.terminal_asset_alias: + ta_alias = cfg.terminal_asset_alias self.misc["MyTerminalAssetGNode"] = { "GNodeId": str(uuid.uuid4()), - "Alias": f"{cfg.atn_gnode_alias}.ta", + "Alias": ta_alias, "DisplayName": "TerminalAsset GNode", "GNodeStatusValue": "Active", "PrimaryGNodeRoleAlias": "TerminalAsset" @@ -528,11 +536,17 @@ def add_stub_scada(self, cfg: Optional[StubConfig] = None): self.add_nodes( [ SpaceheatNodeGt( - ShNodeId=self.make_node_id(H0N.scada), - Name=H0N.scada, + ShNodeId=self.make_node_id(H0N.primary_scada), + Name=H0N.primary_scada, ActorClass=ActorClass.Scada, DisplayName=cfg.scada_display_name, ), + SpaceheatNodeGt( + ShNodeId=self.make_node_id(H0N.secondary_scada), + Name=H0N.secondary_scada, + ActorClass=ActorClass.Parentless, + DisplayName="Secondary Scada" + ), SpaceheatNodeGt( ShNodeId=self.make_node_id(H0N.home_alone), Name=H0N.home_alone, @@ -541,13 +555,15 @@ def add_stub_scada(self, cfg: Optional[StubConfig] = None): ) ] ) + def add_stubs(self, cfg: Optional[StubConfig] = None): if cfg is None: cfg = StubConfig() + self.add_stub_scadas(cfg) if cfg.add_stub_power_meter: self.add_stub_power_meter(cfg) - self.add_stub_scada(cfg) + def dict(self) -> dict: d = dict( diff --git a/gw_spaceheat/layout_gen/multi.py b/gw_spaceheat/layout_gen/multi.py index 8b511ed3..3c3e309d 100644 --- a/gw_spaceheat/layout_gen/multi.py +++ b/gw_spaceheat/layout_gen/multi.py @@ -17,7 +17,7 @@ from gwproto.enums import ThermistorDataMethod from pydantic import BaseModel from gwproto.data_classes.house_0_names import H0Readers - +from gwproto.data_classes.house_0_names import H0N from layout_gen.layout_db import LayoutDb class SensorNodeGenCfg(BaseModel): @@ -110,6 +110,7 @@ def add_tsnap_multipurpose( SpaceheatNodeGt( ShNodeId=db.make_node_id(H0Readers.analog_temp), Name=H0Readers.analog_temp, + ActorHierarchyName=f"{H0N.primary_scada}.{H0Readers.analog_temp}", ActorClass=ActorClass.MultipurposeSensor, DisplayName=' '.join(part.upper() for part in H0Readers.analog_temp.split('-')), ComponentId=db.component_id_by_alias(tsnap.component_alias()) diff --git a/gw_spaceheat/layout_gen/poller.py b/gw_spaceheat/layout_gen/poller.py index ba486f42..921297c8 100644 --- a/gw_spaceheat/layout_gen/poller.py +++ b/gw_spaceheat/layout_gen/poller.py @@ -14,7 +14,7 @@ from gwproto.types.hubitat_gt import HubitatGt from gwproto.types import ChannelConfig from pydantic import BaseModel - +from gwproto.data_classes.house_0_names import H0N from layout_gen import LayoutDb from layout_gen.hubitat import add_hubitat @@ -141,6 +141,7 @@ def add_thermostat( SpaceheatNodeGt( ShNodeId=db.make_node_id(stat_node_name), Name=stat_node_name, + ActorHierarchyName=f"{H0N.primary_scada}.{stat_node_name}", ActorClass=stat_cfg.actor_class, DisplayName=stat_display_name, ComponentId=db.component_id_by_alias(stat_component_display_name) diff --git a/gw_spaceheat/layout_gen/tank2.py b/gw_spaceheat/layout_gen/tank2.py new file mode 100644 index 00000000..74a72354 --- /dev/null +++ b/gw_spaceheat/layout_gen/tank2.py @@ -0,0 +1,108 @@ +from gwproto.types import PicoTankModuleComponentGt +from typing import List +from pydantic import BaseModel +from gwproto.property_format import SpaceheatName +from layout_gen import LayoutDb +from gwproto.types.component_attribute_class_gt import ComponentAttributeClassGt +from gwproto.types.data_channel_gt import DataChannelGt +from gwproto.enums import MakeModel, Unit, ActorClass, TelemetryName +from gwproto.types.channel_config import ChannelConfig +from gwproto.types import SpaceheatNodeGt +from gwproto.data_classes.house_0_names import H0N + + +class Tank2Cfg(BaseModel): + SerialNumber: str + PicoIds: List[str] + ActorNodeName: SpaceheatName = "buffer" + CapturePeriodS: int = 60 + AsyncCaptureDeltaMicroVolts: int = 2000 + Samples:int = 1000 + NumSampleAverages:int = 10 + Enabled: bool = True + SendMicroVolts: bool = False + + def component_display_name(self) -> str: + return f"{self.ActorNodeName} PicoTankModule" + + + +def add_tank2( + db: LayoutDb, + tank_cfg: Tank2Cfg +) -> None: + if not db.cac_id_by_alias(MakeModel.GRIDWORKS__TANKMODULE2): + db.add_cacs( + [ + ComponentAttributeClassGt( + ComponentAttributeClassId=db.make_cac_id(MakeModel.GRIDWORKS__TANKMODULE2), + DisplayName="GridWorks TankModule2 (Uses 2 picos)", + MakeModel=MakeModel.GRIDWORKS__TANKMODULE2, + ), + ] + ) + + if not db.component_id_by_alias(tank_cfg.component_display_name): + config_list = [] + for i in range(1,5): + config_list.append( + ChannelConfig( + ChannelName=f"{tank_cfg.ActorNodeName}-depth{i}", + PollPeriodMs=1000, + CapturePeriodS=tank_cfg.CapturePeriodS, + AsyncCapture=True, + AsyncCaptureDelta=tank_cfg.AsyncCaptureDeltaMicroVolts, + Exponent=3, + Unit=Unit.Celcius + ) + ) + db.add_components( + [ + PicoTankModuleComponentGt( + ComponentId=db.make_component_id(tank_cfg.component_display_name), + ComponentAttributeClassId=db.cac_id_by_alias(MakeModel.GRIDWORKS__TANKMODULE2), + DisplayName=tank_cfg.component_display_name(), + HwUid=tank_cfg.SerialNumber, + ConfigList=config_list, + PicoHwUidList=tank_cfg.PicoIds, + Enabled=tank_cfg.Enabled, + SendMicroVolts=tank_cfg.SendMicroVolts, + Samples=tank_cfg.Samples, + NumSampleAverages=tank_cfg.NumSampleAverages + ), + ] + ) + + db.add_nodes( + [ + SpaceheatNodeGt( + ShNodeId=db.make_node_id(tank_cfg.ActorNodeName), + Name=tank_cfg.ActorNodeName, + ActorHierarchyName=f"{H0N.secondary_scada}.{tank_cfg.ActorNodeName}", + ActorClass=ActorClass.ApiTankModule, + DisplayName=f"{tank_cfg.ActorNodeName.capitalize()} Tank", + ComponentId=db.component_id_by_alias(tank_cfg.component_display_name()) + ) + ] + [ + SpaceheatNodeGt( + ShNodeId=db.make_node_id(f"{tank_cfg.ActorNodeName}-depth{i}"), + Name=f"{tank_cfg.ActorNodeName}-depth{i}", + ActorClass=ActorClass.NoActor, + DisplayName=f"{tank_cfg.ActorNodeName}-depth{i}", + ) + for i in range(1,5) + ] + ) + + db.add_data_channels( + [ DataChannelGt( + Name=f"{tank_cfg.ActorNodeName}-depth{i}", + DisplayName=f"{tank_cfg.ActorNodeName.capitalize()} Depth {i}", + AboutNodeName=f"{tank_cfg.ActorNodeName}-depth{i}", + CapturedByNodeName=tank_cfg.ActorNodeName, + TelemetryName=TelemetryName.WaterTempCTimes1000, + TerminalAssetAlias=db.terminal_asset_alias, + Id=db.make_channel_id(f"{tank_cfg.ActorNodeName}-depth{i}") + ) for i in range(1,5) + ] + ) \ No newline at end of file diff --git a/gw_spaceheat/show_layout.py b/gw_spaceheat/show_layout.py index 8562ad7a..3ba7406e 100644 --- a/gw_spaceheat/show_layout.py +++ b/gw_spaceheat/show_layout.py @@ -15,7 +15,7 @@ from actors import Scada from actors.config import ScadaSettings -from command_line_utils import get_actor_nodes +from command_line_utils import get_nodes_run_by_scada from command_line_utils import get_requested_names from gwproactor.config import MQTTClient from gw.errors import DcError @@ -338,7 +338,7 @@ def print_layout_table(layout: HardwareLayout): def try_scada_load(requested_names: Optional[set[str]], layout: HardwareLayout, settings: ScadaSettings, raise_errors: bool = False) -> Optional[Scada]: settings = settings.model_copy(deep=True) settings.paths.mkdirs() - scada_node, actor_nodes = get_actor_nodes(requested_names, layout, Scada.DEFAULT_ACTORS_MODULE) + scada_node, actor_nodes = get_nodes_run_by_scada(requested_names, layout, Scada.DEFAULT_ACTORS_MODULE) scada = None for k, v in settings.model_fields.items(): if isinstance(v, MQTTClient): diff --git a/tests/config/hardware-layout.json b/tests/config/hardware-layout.json index 8ba1afa1..6545ca25 100644 --- a/tests/config/hardware-layout.json +++ b/tests/config/hardware-layout.json @@ -68,6 +68,50 @@ "TerminalAssetAlias": "d1.isone.ct.newhaven.orange1.ta", "TypeName": "data.channel.gt", "Version": "001" + }, + { + "AboutNodeName": "buffer-depth1", + "CapturedByNodeName": "buffer", + "DisplayName": "Buffer Depth 1", + "Id": "fce72680-0592-47a6-be22-aad0e7a7527d", + "Name": "buffer-depth1", + "TelemetryName": "WaterTempCTimes1000", + "TerminalAssetAlias": "d1.isone.ct.newhaven.orange1.ta", + "TypeName": "data.channel.gt", + "Version": "001" + }, + { + "AboutNodeName": "buffer-depth2", + "CapturedByNodeName": "buffer", + "DisplayName": "Buffer Depth 2", + "Id": "bd8010bd-3307-4d74-9c9b-9544fcff29b2", + "Name": "buffer-depth2", + "TelemetryName": "WaterTempCTimes1000", + "TerminalAssetAlias": "d1.isone.ct.newhaven.orange1.ta", + "TypeName": "data.channel.gt", + "Version": "001" + }, + { + "AboutNodeName": "buffer-depth3", + "CapturedByNodeName": "buffer", + "DisplayName": "Buffer Depth 3", + "Id": "bf9e1389-0c63-4b99-a1de-0e20cdd5019d", + "Name": "buffer-depth3", + "TelemetryName": "WaterTempCTimes1000", + "TerminalAssetAlias": "d1.isone.ct.newhaven.orange1.ta", + "TypeName": "data.channel.gt", + "Version": "001" + }, + { + "AboutNodeName": "buffer-depth4", + "CapturedByNodeName": "buffer", + "DisplayName": "Buffer Depth 4", + "Id": "54aa8087-9b21-401a-9d47-b5242262a3ae", + "Name": "buffer-depth4", + "TelemetryName": "WaterTempCTimes1000", + "TerminalAssetAlias": "d1.isone.ct.newhaven.orange1.ta", + "TypeName": "data.channel.gt", + "Version": "001" } ], "ElectricMeterCacs": [ @@ -149,6 +193,20 @@ "PrimaryGNodeRoleAlias": "TerminalAsset" }, "OtherCacs": [ + { + "ComponentAttributeClassId": "39943778-3cfd-4cff-8ee3-99478193d7c0", + "DisplayName": "Web Server CAC", + "MakeModel": "UNKNOWNMAKE__UNKNOWNMODEL", + "TypeName": "component.attribute.class.gt", + "Version": "001" + }, + { + "ComponentAttributeClassId": "f88fbf89-5b74-46d6-84a3-8e7494d08435", + "DisplayName": "GridWorks TankModule2 (Uses 2 picos)", + "MakeModel": "GRIDWORKS__TANKMODULE2", + "TypeName": "component.attribute.class.gt", + "Version": "001" + }, { "ComponentAttributeClassId": "62528da5-b510-4ac2-82c1-3782842eae07", "DisplayName": "Hubitat Elevation C-7", @@ -166,6 +224,83 @@ } ], "OtherComponents": [ + { + "ComponentAttributeClassId": "39943778-3cfd-4cff-8ee3-99478193d7c0", + "ComponentId": "69e8869f-b0d0-4017-aba9-7e03c021d692", + "ConfigList": [], + "DisplayName": "Web Server default", + "TypeName": "web.server.component.gt", + "Version": "001", + "WebServer": { + "Enabled": true, + "Host": "0.0.0.0", + "Kwargs": {}, + "Name": "default", + "Port": 8000 + } + }, + { + "ComponentAttributeClassId": "f88fbf89-5b74-46d6-84a3-8e7494d08435", + "ComponentId": "2690ebcf-76e0-4ec8-ab2b-2ec3328087fe", + "ConfigList": [ + { + "AsyncCapture": true, + "AsyncCaptureDelta": 2000, + "CapturePeriodS": 60, + "ChannelName": "buffer-depth1", + "Exponent": 3, + "PollPeriodMs": 1000, + "TypeName": "channel.config", + "Unit": "Celcius", + "Version": "000" + }, + { + "AsyncCapture": true, + "AsyncCaptureDelta": 2000, + "CapturePeriodS": 60, + "ChannelName": "buffer-depth2", + "Exponent": 3, + "PollPeriodMs": 1000, + "TypeName": "channel.config", + "Unit": "Celcius", + "Version": "000" + }, + { + "AsyncCapture": true, + "AsyncCaptureDelta": 2000, + "CapturePeriodS": 60, + "ChannelName": "buffer-depth3", + "Exponent": 3, + "PollPeriodMs": 1000, + "TypeName": "channel.config", + "Unit": "Celcius", + "Version": "000" + }, + { + "AsyncCapture": true, + "AsyncCaptureDelta": 2000, + "CapturePeriodS": 60, + "ChannelName": "buffer-depth4", + "Exponent": 3, + "PollPeriodMs": 1000, + "TypeName": "channel.config", + "Unit": "Celcius", + "Version": "000" + } + ], + "DisplayName": "buffer PicoTankModule", + "Enabled": true, + "HwUid": "101", + "NumSampleAverages": 10, + "PicoHwUidList": [ + "pico_4c1a21", + "pico_487a22" + ], + "Samples": 1000, + "SendMicroVolts": false, + "TypeName": "pico.tank.module.component.gt", + "Version": "000" + }, { "ComponentAttributeClassId": "62528da5-b510-4ac2-82c1-3782842eae07", "ComponentId": "4a5e3fca-2b8e-4321-bd61-b43401535cc2", @@ -284,6 +419,14 @@ "TypeName": "spaceheat.node.gt", "Version": "200" }, + { + "ActorClass": "Parentless", + "DisplayName": "Little Orange House Second Scada", + "Name": "s2", + "ShNodeId": "a7842e1d-05d6-4111-b7fc-1f240ed0d0b7", + "TypeName": "spaceheat.node.gt", + "Version": "200" + }, { "ActorClass": "HomeAlone", "DisplayName": "HomeAlone", @@ -296,6 +439,7 @@ "ActorClass": "PowerMeter", "ComponentId": "2bfd0036-0b0e-4732-8790-bc7d0536a85e", "DisplayName": "Main Power Meter Little Orange House Test System", + "ActorHierarchyName": "s.power-meter", "Name": "power-meter", "ShNodeId": "0dd8a803-4724-4f49-b845-14ff57bdb3e6", "TypeName": "spaceheat.node.gt", @@ -341,6 +485,7 @@ "ActorClass": "Hubitat", "ComponentId": "4a5e3fca-2b8e-4321-bd61-b43401535cc2", "DisplayName": "Hubitat 82:22:22", + "ActorHierarchyName": "s.hubitat", "Name": "hubitat", "ShNodeId": "9c486fe7-370d-4eba-b6d2-26e1059f66aa", "TypeName": "spaceheat.node.gt", @@ -350,6 +495,7 @@ "ActorClass": "HoneywellThermostat", "ComponentId": "e5e2fb2c-3f96-4079-907f-f96db22d1568", "DisplayName": "Zone 1 Main Thermostat", + "ActorHierarchyName": "s.zone1-main-stat", "Name": "zone1-main-stat", "ShNodeId": "24212380-515b-4842-9541-ad680919de19", "TypeName": "spaceheat.node.gt", @@ -362,6 +508,48 @@ "ShNodeId": "9d1067d3-4d20-4bad-a7f7-a14e277c6cb2", "TypeName": "spaceheat.node.gt", "Version": "200" + }, + { + "ActorClass": "ApiTankModule", + "ComponentId": "2690ebcf-76e0-4ec8-ab2b-2ec3328087fe", + "DisplayName": "Buffer Tank", + "Name": "buffer", + "ActorHierarchyName": "s2.buffer", + "ShNodeId": "68596845-2e66-4f9d-a876-90da381ddc37", + "TypeName": "spaceheat.node.gt", + "Version": "200" + }, + { + "ActorClass": "NoActor", + "DisplayName": "buffer-depth1", + "Name": "buffer-depth1", + "ShNodeId": "575642e0-2c83-49b5-b682-8032d6a63f7e", + "TypeName": "spaceheat.node.gt", + "Version": "200" + }, + { + "ActorClass": "NoActor", + "DisplayName": "buffer-depth2", + "Name": "buffer-depth2", + "ShNodeId": "04eb01a7-af6c-40fd-a5bd-f0ca1e3919f4", + "TypeName": "spaceheat.node.gt", + "Version": "200" + }, + { + "ActorClass": "NoActor", + "DisplayName": "buffer-depth3", + "Name": "buffer-depth3", + "ShNodeId": "4f78d07e-3ddc-4cc9-8955-5a1796d7b740", + "TypeName": "spaceheat.node.gt", + "Version": "200" + }, + { + "ActorClass": "NoActor", + "DisplayName": "buffer-depth4", + "Name": "buffer-depth4", + "ShNodeId": "cf5a5804-81a7-464d-b70d-590f41401cf2", + "TypeName": "spaceheat.node.gt", + "Version": "200" } ] } \ No newline at end of file diff --git a/tests/tests_actors/test_power_meter.py b/tests/tests_actors/test_power_meter.py index 32d335fa..22863ee9 100644 --- a/tests/tests_actors/test_power_meter.py +++ b/tests/tests_actors/test_power_meter.py @@ -33,10 +33,10 @@ def test_power_meter_small(): copy_keys("scada", settings) settings.paths.mkdirs() layout = HardwareLayout.load(settings.paths.hardware_layout) - scada = Scada(H0N.scada, settings, layout) + scada = Scada(H0N.primary_scada, settings, layout) # Raise exception if initiating node is anything except the unique power meter node with pytest.raises(Exception): - PowerMeter(H0N.scada, services=scada) + PowerMeter(H0N.primary_scada, services=scada) meter = PowerMeter(H0N.primary_power_meter, services=scada) assert isinstance(meter._sync_thread, PowerMeterDriverThread) diff --git a/tests/tests_actors/test_scada.py b/tests/tests_actors/test_scada.py index f64d361e..6c6a31f2 100644 --- a/tests/tests_actors/test_scada.py +++ b/tests/tests_actors/test_scada.py @@ -29,7 +29,7 @@ def test_scada_small(): copy_keys("scada", settings) settings.paths.mkdirs() layout = HardwareLayout.load(settings.paths.hardware_layout) - scada = Scada(H0N.scada, settings=settings, hardware_layout=layout) + scada = Scada(H0N.primary_scada, settings=settings, hardware_layout=layout) assert layout.power_meter_node == layout.node(H0N.primary_power_meter) assert ( @@ -100,7 +100,7 @@ def test_scada_small(): # actors = Actors( # settings, # layout=layout, -# scada=ScadaRecorder(H0N.scada, settings, hardware_layout=layout) +# scada=ScadaRecorder(H0N.primary_scada, settings, hardware_layout=layout) # ) # actors.scada._scada_atn_fast_dispatch_contract_is_alive_stub = True # actors.scada._last_status_second = int(time.time()) @@ -258,7 +258,7 @@ async def test_scada_periodic_status_delivery(tmp_path, monkeypatch, request): actors = Actors( settings, layout=layout, - scada=ScadaRecorder(H0N.scada, settings, hardware_layout=layout), + scada=ScadaRecorder(H0N.primary_scada, settings, hardware_layout=layout), atn_settings=atn_settings, ) actors.scada._last_report_second = int(time.time()) @@ -334,7 +334,7 @@ async def test_scada_report_content_dynamics(tmp_path, monkeypatch, request): actors = Actors( settings, layout=layout, - scada=ScadaRecorder(H0N.scada, settings, hardware_layout=layout), + scada=ScadaRecorder(H0N.primary_scada, settings, hardware_layout=layout), atn_settings=AsyncFragmentRunner.make_atn_settings() ) actors.scada._last_status_second = int(time.time()) diff --git a/tests/utils/fragment_runner.py b/tests/utils/fragment_runner.py index 670b5d52..de1f7ef9 100644 --- a/tests/utils/fragment_runner.py +++ b/tests/utils/fragment_runner.py @@ -85,7 +85,7 @@ def __init__( ) self.scada = kwargs.get( "scada", - ScadaRecorder(H0N.scada, settings, hardware_layout=layout) + ScadaRecorder(H0N.primary_scada, settings, hardware_layout=layout) ) self.meter = kwargs.get( "meter",