Skip to content

Commit

Permalink
Tank Module via HTTP/Hubitat/Fibaro (#205)
Browse files Browse the repository at this point in the history
This PR adds support for Tank modules accessed by sending HTTP polls to a Hubitat which is configured to communicate with Fibaro Smart Implant temperature sensors on the tank via Fibaro / Hubitat ZWave network. 

Changes: 
1. New CACS, Components and enums added for Fibaro Smart Implant, Hubitat, Hubitat Tank Module and generic RESTPoller. 
2. HardwareLayout has been modified: 
    1. Components and CACS in OtherComponents/OtherCACs can be decoded from TypeName without need to specify the decoding class. 
    2. A "resolve" phase has been added after loading CACs, Components and Nodes to allow validation of relationships between entities when that validation requires all CACs, Components and Nodes to have been decoded. 
3. Code generation has been broken by this merge. Fixing up code generation will be addressed by #214. More code generation discussion in #215.
  • Loading branch information
anschweitzer committed Oct 31, 2023
1 parent 619e793 commit 353386c
Show file tree
Hide file tree
Showing 33 changed files with 2,094 additions and 64 deletions.
172 changes: 171 additions & 1 deletion poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "gridworks-protocol"
version = "0.5.7"
version = "0.6.0"
description = "Gridworks Protocol"
authors = ["Jessica Millar <[email protected]>"]
license = "MIT"
Expand All @@ -24,6 +24,7 @@ pydantic = "^1.10.2"
pendulum = "^2.1.2"
fastapi-utils = "^0.2.1"
gridworks = "^0.2.9"
yarl = "^1.9.2"

[tool.poetry.dev-dependencies]
Pygments = ">=2.10.0"
Expand Down
24 changes: 19 additions & 5 deletions src/gwproto/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import gwproto.enums as enums
import gwproto.messages as messages
import gwproto.property_format as property_format
from gwproto.data_classes.hardware_layout import HardwareLayout
from gwproto.data_classes.sh_node import ShNode
from gwproto.decoders import CallableDecoder
from gwproto.decoders import Decoder
from gwproto.decoders import DecoderItem
Expand All @@ -11,9 +10,16 @@
from gwproto.decoders import MQTTCodec
from gwproto.decoders import OneDecoderExtractor
from gwproto.decoders import PydanticDecoder
from gwproto.decoders import PydanticTypeNameDecoder
from gwproto.decoders import create_discriminator
from gwproto.decoders import create_message_payload_discriminator
from gwproto.decoders import get_pydantic_literal_type_name
from gwproto.decoders import pydantic_named_types
from gwproto.default_decoders import CacDecoder
from gwproto.default_decoders import ComponentDecoder
from gwproto.default_decoders import decode_to_data_class
from gwproto.default_decoders import default_cac_decoder
from gwproto.default_decoders import default_component_decoder
from gwproto.errors import SchemaError
from gwproto.message import Header
from gwproto.message import Message
Expand All @@ -24,25 +30,33 @@

__all__ = [
"as_enum",
"CacDecoder",
"CallableDecoder",
"ComponentDecoder",
"create_discriminator",
"create_message_payload_discriminator",
"decode_to_data_class",
"Decoder",
"DecoderItem",
"DecodedMQTTTopic",
"Decoders",
"enums",
"default_cac_decoder",
"default_component_decoder",
"get_pydantic_literal_type_name",
"HardwareLayout",
"Header",
"MakerDecoder",
"MakerExtractor",
"Message",
"messages",
"MessageDiscriminator",
"SchemaError",
"MQTTCodec",
"MQTTTopic",
"OneDecoderExtractor",
"property_format",
"PydanticDecoder",
"PydanticTypeNameDecoder",
"pydantic_named_types",
"SchemaError",
"ShNode",
]
5 changes: 5 additions & 0 deletions src/gwproto/data_classes/cacs/fibaro_smart_implant_cac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from gwproto.data_classes.component_attribute_class import ComponentAttributeClass


class FibaroSmartImplantCac(ComponentAttributeClass):
...
5 changes: 5 additions & 0 deletions src/gwproto/data_classes/cacs/hubitat_cac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from gwproto.data_classes.component_attribute_class import ComponentAttributeClass


class HubitatCac(ComponentAttributeClass):
...
5 changes: 5 additions & 0 deletions src/gwproto/data_classes/cacs/hubitat_tank_module_cac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from gwproto.data_classes.component_attribute_class import ComponentAttributeClass


class HubitatTankModuleCac(ComponentAttributeClass):
...
5 changes: 5 additions & 0 deletions src/gwproto/data_classes/cacs/rest_poller_cac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from gwproto.data_classes.component_attribute_class import ComponentAttributeClass


class RESTPollerCac(ComponentAttributeClass):
...
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from typing import Optional

from gwproto.data_classes.component import Component


class FibaroSmartImplantComponent(Component):
def __init__(
self,
component_id: str,
component_attribute_class_id: str,
display_name: Optional[str] = None,
hw_uid: Optional[str] = None,
):
super().__init__(
component_id=component_id,
component_attribute_class_id=component_attribute_class_id,
display_name=display_name,
hw_uid=hw_uid,
)
29 changes: 29 additions & 0 deletions src/gwproto/data_classes/components/hubitat_component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from typing import Optional

import yarl

from gwproto.data_classes.component import Component
from gwproto.types.hubitat_gt import HubitatGt


class HubitatComponent(Component):
hubitat_gt: HubitatGt

def __init__(
self,
component_id: str,
component_attribute_class_id: str,
hubitat_gt: HubitatGt,
display_name: Optional[str] = None,
hw_uid: Optional[str] = None,
):
self.hubitat_gt = hubitat_gt
super().__init__(
component_id=component_id,
component_attribute_class_id=component_attribute_class_id,
display_name=display_name,
hw_uid=hw_uid,
)

def urls(self) -> dict[str, Optional[yarl.URL]]:
return self.hubitat_gt.urls()
113 changes: 113 additions & 0 deletions src/gwproto/data_classes/components/hubitat_tank_component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
from typing import Optional

import yarl

from gwproto.data_classes.component import Component
from gwproto.data_classes.components.hubitat_component import HubitatComponent
from gwproto.data_classes.resolver import ComponentResolver
from gwproto.data_classes.sh_node import ShNode
from gwproto.types.hubitat_component_gt import HubitatComponentGt
from gwproto.types.hubitat_component_gt import HubitatRESTResolutionSettings
from gwproto.types.hubitat_gt import HubitatGt
from gwproto.types.hubitat_tank_gt import FibaroTempSensorSettings
from gwproto.types.hubitat_tank_gt import FibaroTempSensorSettingsGt
from gwproto.types.hubitat_tank_gt import HubitatTankSettingsGt
from gwproto.types.telemetry_reporting_config import TelemetryReportingConfig


class HubitatTankComponent(Component, ComponentResolver):
hubitat: HubitatComponentGt
sensor_supply_voltage: float
devices_gt: list[FibaroTempSensorSettingsGt]
devices: list[FibaroTempSensorSettings] = []

def __init__(
self,
component_id: str,
component_attribute_class_id: str,
tank_gt: HubitatTankSettingsGt,
display_name: Optional[str] = None,
hw_uid: Optional[str] = None,
):
# Create self.hubitat as a proxy containing only the id
# of the hubitat; the actual component data will be resolved
# when resolve() is called; Here in the constructor we cannot
# rely on the actual HubitatComponentGt existing yet.
self.hubitat = HubitatComponentGt(
ComponentId=tank_gt.hubitat_component_id,
ComponentAttributeClassId="00000000-0000-0000-0000-000000000000",
Hubitat=HubitatGt(
Host="",
MakerApiId=-1,
AccessToken="",
MacAddress="000000000000",
),
)
self.sensor_supply_voltage = tank_gt.sensor_supply_voltage
self.devices_gt = list(tank_gt.devices)
super().__init__(
display_name=display_name,
component_id=component_id,
hw_uid=hw_uid,
component_attribute_class_id=component_attribute_class_id,
)

def resolve(
self,
tank_node_name: str,
nodes: dict[str, ShNode],
components: dict[str, Component],
):
hubitat_component = components.get(self.hubitat.ComponentId, None)
if hubitat_component is None:
raise ValueError(
"ERROR. No component found for "
f"HubitatTankComponent.hubitat.CompnentId {self.hubitat.ComponentId}"
)
if not isinstance(hubitat_component, HubitatComponent):
raise ValueError(
"ERROR. Referenced hubitat component has type "
f"{type(hubitat_component)}; "
"must be instance of HubitatComponent. "
f"Hubitat component id: {self.hubitat.ComponentId}"
)
hubitat_component_gt = HubitatComponentGt.from_data_class(hubitat_component)
hubitat_settings = HubitatRESTResolutionSettings(hubitat_component_gt)
devices = [
FibaroTempSensorSettings.create(
tank_name=tank_node_name,
settings_gt=device_gt,
hubitat=hubitat_settings,
)
for device_gt in self.devices_gt
if device_gt.enabled
]
for device in devices:
if device.node_name not in nodes:
raise ValueError(
f"ERROR. Node not found for tank temp sensor <{device.node_name}>"
)
# replace proxy hubitat component, which only had component id.
# with the actual hubitat component containing data.
self.hubitat = hubitat_component_gt
self.devices = devices

def urls(self) -> dict[str, Optional[yarl.URL]]:
urls = self.hubitat.urls()
for device in self.devices:
urls[device.node_name] = device.url
return urls

@property
def config_list(self) -> list[TelemetryReportingConfig]:
return [
TelemetryReportingConfig(
TelemetryName=device.telemetry_name,
AboutNodeName=device.node_name,
ReportOnChange=False,
SamplePeriodS=int(device.rest.poll_period_seconds),
Exponent=device.exponent,
Unit=device.unit,
)
for device in self.devices
]
24 changes: 24 additions & 0 deletions src/gwproto/data_classes/components/rest_poller_component.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from typing import Optional

from gwproto.data_classes.component import Component
from gwproto.types.rest_poller_gt import RESTPollerSettings


class RESTPollerComponent(Component):
rest: RESTPollerSettings

def __init__(
self,
component_id: str,
component_attribute_class_id: str,
rest: RESTPollerSettings,
display_name: Optional[str] = None,
hw_uid: Optional[str] = None,
):
self.rest = rest
super().__init__(
display_name=display_name,
component_id=component_id,
hw_uid=hw_uid,
component_attribute_class_id=component_attribute_class_id,
)
Loading

0 comments on commit 353386c

Please sign in to comment.