Repository: funtastix/pyrinnaitouch (language: python)

Issue:
Improved Zone support and Code refactor: reorganised code base
Reworked parsing and handling of zones, based on issue #8 and tested with mocked version.
Breaking down files and classes, add constants, and improve overall maintainability of the code.


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index 0317cc8..99ba7fa 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -7,11 +7,11 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: ["3.8", "3.9"]
+        python-version: ["3.10"]
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v4
       with:
         python-version: ${{ matrix.python-version }}
     - name: Install dependencies
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
index 3bfabfc..7d29880 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/python-publish.yml
@@ -18,9 +18,9 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-    - uses: actions/checkout@v2
+    - uses: actions/checkout@v3
     - name: Set up Python
-      uses: actions/setup-python@v2
+      uses: actions/setup-python@v4
       with:
         python-version: '3.x'
     - name: Install dependencies
diff --git a/pyrinnaitouch/__init__.py b/pyrinnaitouch/__init__.py
index d3b52b8..350926b 100644
--- a/pyrinnaitouch/__init__.py
+++ b/pyrinnaitouch/__init__.py
@@ -2,10 +2,25 @@
     The primary purpose of this is to be integrated into Home Assistant.
 """
 
-from .system import BrivisStatus, RinnaiSystem
-from .evap import EvapStatus
-from .heater import HeaterStatus
-from .cooler import CoolingStatus
-from .util import SchedulePeriod
+from .system import RinnaiSystemStatus, RinnaiSystem
+from .unit_status import RinnaiUnitStatus
+from .const import (
+    RinnaiSchedulePeriod,
+    RinnaiCapabilities,
+    RinnaiOperatingMode,
+    RinnaiSystemMode,
+    TEMP_FAHRENHEIT,
+    TEMP_CELSIUS
+)
 
-__ALL__ = [BrivisStatus, HeaterStatus, CoolingStatus, EvapStatus, RinnaiSystem, SchedulePeriod]
+__ALL__ = [
+    RinnaiSystemStatus,
+    RinnaiUnitStatus,
+    RinnaiSystem,
+    RinnaiSchedulePeriod,
+    RinnaiCapabilities,
+    RinnaiOperatingMode,
+    RinnaiSystemMode,
+    TEMP_CELSIUS,
+    TEMP_FAHRENHEIT
+    ]
diff --git a/pyrinnaitouch/commands.py b/pyrinnaitouch/commands.py
index 8c322f6..38ad150 100644
--- a/pyrinnaitouch/commands.py
+++ b/pyrinnaitouch/commands.py
@@ -5,82 +5,42 @@
 MODE_EVAP_CMD = '{"SYST": {"OSS": {"MD": "E" } } }'
 MODE_HEAT_CMD = '{"SYST": {"OSS": {"MD": "H" } } }'
 
-# Heating Commands
-HEAT_ON_CMD = '{"HGOM": {"OOP": {"ST": "N" } } }'
-HEAT_OFF_CMD = '{"HGOM": {"OOP": {"ST": "F" } } }'
-HEAT_CIRC_FAN_ON = '{"HGOM": {"OOP": {"ST": "Z" } } }'
-HEAT_CIRC_FAN_SPEED = '{{"HGOM": {{"OOP": {{"FL": "{speed}" }} }} }}' # 1 - 16
+UNIT_ON_CMD = '{{"{unit_id}": {{"OOP": {{"ST": "N" }} }} }}'
+UNIT_OFF_CMD = '{{"{unit_id}": {{"OOP": {{"ST": "F" }} }} }}'
+UNIT_CIRC_FAN_ON = '{{"{unit_id}": {{"OOP": {{"ST": "Z" }} }} }}'
+UNIT_CIRC_FAN_SPEED = '{{"{unit_id}": {{"OOP": {{"FL": "{speed}" }} }} }}' # 1 - 16
 
-HEAT_SET_TEMP = '{{"HGOM": {{"GSO": {{"SP": "{temp}" }} }} }}'
-HEAT_SET_MANUAL = '{"HGOM": {"GSO": {"OP": "M" } } }'
-HEAT_SET_AUTO = '{"HGOM": {"GSO": {"OP": "A" } } }'
-HEAT_ADVANCE = '{"HGOM": {"GSO": {"AO": "A" } } }'
-HEAT_ADVANCE_CANCEL = '{"HGOM": {"GSO": {"AO": "N" } } }'
+UNIT_SET_TEMP = '{{"{unit_id}": {{"GSO": {{"SP": "{temp}" }} }} }}'
+UNIT_SET_MANUAL = '{{"{unit_id}": {{"GSO": {{"OP": "M" }} }} }}'
+UNIT_SET_AUTO = '{{"{unit_id}": {{"GSO": {{"OP": "A" }} }} }}'
+UNIT_ADVANCE = '{{"{unit_id}": {{"GSO": {{"AO": "A" }} }} }}'
+UNIT_ADVANCE_CANCEL = '{{"{unit_id}": {{"GSO": {{"AO": "N" }} }} }}'
 
-HEAT_ZONE_ON = '{{"HGOM": {{"Z{zone}O": {{"UE": "Y" }} }} }}'
-HEAT_ZONE_OFF = '{{"HGOM": {{"Z{zone}O": {{"UE": "N" }} }} }}'
-HEAT_ZONE_SET_TEMP = '{{"HGOM": {{"Z{zone}O": {{"SP": "{temp}" }} }} }}'
-HEAT_ZONE_SET_MANUAL = '{{"HGOM": {{"Z{zone}O": {{"OP": "M" }} }} }}'
-HEAT_ZONE_SET_AUTO = '{{"HGOM": {{"Z{zone}O": {{"OP": "A" }} }} }}'
-HEAT_ZONE_ADVANCE = '{{"HGOM": {{"Z{zone}O": {{"AO": "A" }} }} }}'
-HEAT_ZONE_ADVANCE_CANCEL = '{{"HGOM": {{"Z{zone}O": {{"AO": "A" }} }} }}'
+UNIT_ZONE_ON = '{{"{unit_id}": {{"Z{zone}O": {{"UE": "Y" }} }} }}'
+UNIT_ZONE_OFF = '{{"{unit_id}": {{"Z{zone}O": {{"UE": "N" }} }} }}'
+UNIT_ZONE_SET_TEMP = '{{"{unit_id}": {{"Z{zone}O": {{"SP": "{temp}" }} }} }}'
+UNIT_ZONE_SET_MANUAL = '{{"{unit_id}": {{"Z{zone}O": {{"OP": "M" }} }} }}'
+UNIT_ZONE_SET_AUTO = '{{"{unit_id}": {{"Z{zone}O": {{"OP": "A" }} }} }}'
+UNIT_ZONE_ADVANCE = '{{"{unit_id}": {{"Z{zone}O": {{"AO": "A" }} }} }}'
+UNIT_ZONE_ADVANCE_CANCEL = '{{"{unit_id}": {{"Z{zone}O": {{"AO": "A" }} }} }}'
 
-HEAT_COMMANDS = [
-    HEAT_ON_CMD,
-    HEAT_OFF_CMD,
-    HEAT_SET_TEMP,
-    HEAT_CIRC_FAN_ON,
-    HEAT_ZONE_ON,
-    HEAT_ZONE_OFF,
-    HEAT_SET_MANUAL,
-    HEAT_SET_AUTO,
-    HEAT_ADVANCE,
-    HEAT_ADVANCE_CANCEL,
-    HEAT_ZONE_SET_TEMP,
-    HEAT_ZONE_SET_MANUAL,
-    HEAT_ZONE_SET_AUTO,
-    HEAT_ZONE_ADVANCE,
-    HEAT_ZONE_ADVANCE_CANCEL,
-    HEAT_CIRC_FAN_SPEED
-]
-
-# Cooling Commands
-COOL_ON_CMD = '{"CGOM": {"OOP": {"ST": "N" } } }'
-COOL_OFF_CMD = '{"CGOM": {"OOP": {"ST": "F" } } }'
-COOL_CIRC_FAN_ON = '{"CGOM": {"OOP": {"ST": "Z" } } }'
-COOL_CIRC_FAN_SPEED = '{{"CGOM": {{"OOP": {{"FL": "{speed}" }} }} }}' # 1 - 16
-
-COOL_SET_TEMP = '{{"CGOM": {{"GSO": {{"SP": "{temp}" }} }} }}'
-COOL_SET_MANUAL = '{"CGOM": {"GSO": {"OP": "M" } } }'
-COOL_SET_AUTO = '{"CGOM": {"GSO": {"OP": "A" } } }'
-COOL_ADVANCE = '{"CGOM": {"GSO": {"AO": "A" } } }'
-COOL_ADVANCE_CANCEL = '{"CGOM": {"GSO": {"AO": "N" } } }'
-
-COOL_ZONE_ON = '{{"CGOM": {{"Z{zone}O": {{"UE": "Y" }} }} }}'
-COOL_ZONE_OFF = '{{"CGOM": {{"Z{zone}O": {{"UE": "N" }} }} }}'
-COOL_ZONE_SET_TEMP = '{{"CGOM": {{"Z{zone}O": {{"SP": "{temp}" }} }} }}'
-COOL_ZONE_SET_MANUAL = '{{"CGOM": {{"Z{zone}O": {{"OP": "M" }} }} }}'
-COOL_ZONE_SET_AUTO = '{{"CGOM": {{"Z{zone}O": {{"OP": "A" }} }} }}'
-COOL_ZONE_ADVANCE = '{{"CGOM": {{"Z{zone}O": {{"AO": "A" }} }} }}'
-COOL_ZONE_ADVANCE_CANCEL = '{{"CGOM": {{"Z{zone}O": {{"AO": "N" }} }} }}'
-
-COOL_COMMANDS = [
-    COOL_ON_CMD,
-    COOL_OFF_CMD,
-    COOL_SET_TEMP,
-    COOL_CIRC_FAN_ON,
-    COOL_ZONE_ON,
-    COOL_ZONE_OFF,
-    COOL_SET_MANUAL,
-    COOL_SET_AUTO,
-    COOL_ADVANCE,
-    COOL_ADVANCE_CANCEL,
-    COOL_ZONE_SET_TEMP,
-    COOL_ZONE_SET_MANUAL,
-    COOL_ZONE_SET_AUTO,
-    COOL_ZONE_ADVANCE,
-    COOL_ZONE_ADVANCE_CANCEL,
-    COOL_CIRC_FAN_SPEED
+UNIT_COMMANDS = [
+    UNIT_ON_CMD,
+    UNIT_OFF_CMD,
+    UNIT_SET_TEMP,
+    UNIT_CIRC_FAN_ON,
+    UNIT_ZONE_ON,
+    UNIT_ZONE_OFF,
+    UNIT_SET_MANUAL,
+    UNIT_SET_AUTO,
+    UNIT_ADVANCE,
+    UNIT_ADVANCE_CANCEL,
+    UNIT_ZONE_SET_TEMP,
+    UNIT_ZONE_SET_MANUAL,
+    UNIT_ZONE_SET_AUTO,
+    UNIT_ZONE_ADVANCE,
+    UNIT_ZONE_ADVANCE_CANCEL,
+    UNIT_CIRC_FAN_SPEED
 ]
 
 # Evap Cooling commands
@@ -94,8 +54,6 @@
 EVAP_FAN_OFF = '{"ECOM": {"GSO": {"FS": "F" } } }'
 EVAP_FAN_SPEED = '{{"ECOM": {{"GSO": {{"FL": "{speed}" }} }} }}' # 1 - 16
 
-EVAP_SET_MANUAL = '{"ECOM": {"GSO": {"OP": "M" } } }'
-EVAP_SET_AUTO = '{"ECOM": {"GSO": {"OP": "A" } } }'
 EVAP_SET_COMFORT = '{{"ECOM": {{"GSO": {{"SP": "{comfort}" }} }} }}'
 
 EVAP_ZONE_ON = '{{"ECOM": {{"GSO": {{"Z{zone}UE": "Y" }} }} }}'
@@ -104,6 +62,8 @@
 EVAP_ZONE_SET_AUTO = '{{"ECOM": {{"GSS": {{"Z{zone}AE": "Y" }} }} }}'
 
 EVAP_COMMANDS = [
+    UNIT_ON_CMD,
+    UNIT_OFF_CMD,
     EVAP_ON_CMD,
     EVAP_OFF_CMD,
     EVAP_PUMP_ON,
@@ -111,8 +71,6 @@
     EVAP_FAN_ON,
     EVAP_FAN_OFF,
     EVAP_FAN_SPEED,
-    EVAP_SET_MANUAL,
-    EVAP_SET_AUTO,
     EVAP_SET_COMFORT,
     EVAP_ZONE_ON,
     EVAP_ZONE_OFF,
@@ -124,13 +82,8 @@
     MODE_COOL_CMD,
     MODE_EVAP_CMD,
     MODE_HEAT_CMD,
-    HEAT_ON_CMD,
-    COOL_ON_CMD,
+    UNIT_ON_CMD,
     EVAP_ON_CMD,
-    HEAT_SET_AUTO,
-    HEAT_SET_MANUAL,
-    COOL_SET_AUTO,
-    COOL_SET_MANUAL,
-    EVAP_SET_AUTO,
-    EVAP_SET_MANUAL
+    UNIT_SET_AUTO,
+    UNIT_SET_MANUAL
 ]
diff --git a/pyrinnaitouch/connection.py b/pyrinnaitouch/connection.py
new file mode 100644
index 0000000..6d86b6c
--- /dev/null
+++ b/pyrinnaitouch/connection.py
@@ -0,0 +1,119 @@
+"""Handle connectivity"""
+import logging
+import socket
+import time
+import queue
+
+_LOGGER = logging.getLogger(__name__)
+
+class RinnaiConnection:
+    """Manage the connection to the unit"""
+
+    clients = {}
+
+    def __init__(self, ip_address: str) -> None:
+        self._touch_ip = ip_address
+        self._touch_port = 27847
+        self._send_sequence = 1
+        self._client = None
+        self._lastclosed = 0
+        self._jsonerrors = 0
+        self._senderqueue = queue.Queue()
+        if ip_address not in RinnaiConnection.clients:
+            RinnaiConnection.clients[ip_address] = self._client
+        else:
+            self._client = RinnaiConnection.clients[ip_address]
+
+    def __str__(self) -> str:
+        return self._client.__str__() + "/" + str(self._client._closed) # pylint: disable=protected-access
+
+    def update_send_sequence(self, seq: int) -> None:
+        """Determine new send sequence"""
+        if seq >= 255:
+            seq = 0
+        else:
+            seq = seq + 1
+        self._send_sequence = seq
+
+    def shutdown(self) -> None:
+        """Shut down the connection"""
+        try:
+            self._client.shutdown(socket.SHUT_RDWR)
+            self._client.close()
+            self._lastclosed = time.time()
+        except (OSError, ConnectionError) as ocerr:
+            _LOGGER.debug("Exception during client shutdown %s", ocerr)
+
+    def send(self, message: bytes) -> None:
+        """Send a message to the unit"""
+        self._client.sendall(message)
+
+    def get_queued_command(self) -> bytes:
+        """Non-blocking method to retrieve any queued commands. May throw queue.Empty exception"""
+        return self._senderqueue.get(False)
+
+    def receive_data(self) -> bytes:
+        """Receive data from the unit"""
+        return self._client.recv(8096)
+
+    def log_json_error(self) -> None:
+        """Log that a JSON parsing error has occurred from data received on this connection"""
+        self._jsonerrors = self._jsonerrors + 1
+
+    def renew_connection(self) -> bool:
+        """Safely renew the connection if it is disconnected."""
+        connection_error = False
+        try:
+            if self._client is not None:
+                if (
+                    self._client.getpeername
+                    and self._client.getpeername() is not None
+                    and self._jsonerrors < 4
+                ):
+                    return True
+        except (OSError, ConnectionError) as ocerr:
+            _LOGGER.debug("Error 1st phase during renewConnection %s", ocerr)
+            connection_error = True
+
+        if (
+            self._client is None
+            or self._client._closed # pylint: disable=protected-access
+            or connection_error
+            or (self._jsonerrors > 2)
+        ):
+            try:
+                if connection_error or (self._jsonerrors > 2):
+                    self._client.close()
+                self._jsonerrors = 0
+                self.connect_to_touch(self._touch_ip,self._touch_port)
+                RinnaiConnection.clients[self._touch_ip] = self._client
+                _LOGGER.debug("Connected to %s", self._client.getpeername())
+                return True
+            except ConnectionRefusedError as crerr:
+                _LOGGER.debug("Error during renewConnection %s", crerr)
+            except ConnectionError as cerr:
+                _LOGGER.debug("Error during renewConnection %s", cerr)
+            except Exception as eerr: # pylint: disable=broad-except
+                _LOGGER.debug("Error during renewConnection %s", eerr)
+        return False
+
+    def connect_to_touch(self, touch_ip: str, port: int) -> None:
+        """Connect the client."""
+        # create an ipv4 (AF_INET) socket object using the tcp protocol (SOCK_STREAM)
+        _LOGGER.debug("Creating new client...")
+        try:
+            client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+            client.settimeout(10)
+            client.connect((touch_ip, port))
+            self._client = client
+            _LOGGER.debug("Client connection created: %s", self._client.getpeername())
+        except ConnectionRefusedError as crerr:
+            _LOGGER.debug("Client refused connection: %s", crerr)
+            #should really take a few hours break to recover!
+            raise crerr
+
+    def dispatch_command(self, cmd: str) -> None:
+        """Dispatch the command via the sender queue."""
+        seq = str(self._send_sequence).zfill(6)
+        _LOGGER.debug("Sending command: %s", "N" + seq + cmd)
+        self._senderqueue.put(("N" + seq + cmd).encode())
diff --git a/pyrinnaitouch/const.py b/pyrinnaitouch/const.py
new file mode 100644
index 0000000..9569f8a
--- /dev/null
+++ b/pyrinnaitouch/const.py
@@ -0,0 +1,106 @@
+"""Constants and enumerations"""
+from enum import Enum, Flag
+
+class RinnaiSystemMode(Enum):
+    """Define system modes."""
+    HEATING = 1
+    EVAP = 2
+    COOLING = 3
+    RC = 4
+    NONE = 5
+
+class RinnaiOperatingMode(Enum):
+    """Define unit operating modes."""
+    NONE = 0
+    MANUAL = 1
+    AUTO = 2
+
+class RinnaiCapabilities(Flag):
+    """Define system capabilities."""
+    NONE = 0
+    HEATER = 1
+    COOLER = 2
+    EVAP = 4
+
+class RinnaiSchedulePeriod(Enum):
+    """Define system schedule time periods."""
+    def __str__(self):
+        return str(self.value)
+
+    NONE = None
+    WAKE = "W"
+    LEAVE = "L"
+    RETURN = "R"
+    PRE_SLEEP = "P"
+    SLEEP = "S"
+
+class RinnaiUnitId(Enum):
+    """Define unit ids."""
+    def __str__(self):
+        return str(self.value)
+
+    HEATER = "HGOM"
+    COOLER = "CGOM"
+    EVAP = "ECOM"
+
+ZONE_A = "A"
+ZONE_B = "B"
+ZONE_C = "C"
+ZONE_D = "D"
+COMMON_ZONE = "U"
+
+MAIN_ZONES = [ ZONE_A, ZONE_B, ZONE_C, ZONE_D ]
+ALL_ZONES = [ ZONE_A, ZONE_B, ZONE_C, ZONE_D, COMMON_ZONE ]
+
+TEMP_CELSIUS = "°C"
+TEMP_FAHRENHEIT = "°F"
+
+SYSTEM = "SYST"
+
+GENERAL_SYSTEM_OPERATION = "GSO"
+GENERAL_SYSTEM_STATUS = "GSS"
+OVERALL_OPERATION = "OOP"
+CONFIGURATION = "CFG"
+CAPABILITIES = "AVM"
+FAULT_INFO = "FLT"
+FAULT_DETECTED = "AV"
+
+GAS_HEATING = "HG"
+COOLING_ADDON = "CG"
+COOLING_EVAPORATIVE = "EC"
+MODULE_ENABLED = "Y"
+
+MULTI_SET_POINT = "MTSP"
+
+FIRMWARE_VERSION = "VR"
+WIFI_MODULE_VERSION = "CV"
+
+SWITCH_STATE = "SW"
+OPERATING_STATE = "ST"
+FAN_STATE = "FS"
+PUMP_STATE = "PS"
+STATE_ON = "N"
+STATE_OFF = "F"
+STATE_FAN_ONLY = "Z"
+
+PREWETTING = "PW"
+COOLER_BUSY = "BY"
+PREHEATING = "PH"
+MEASURED_TEMPERATURE = "MT"
+
+FAN_SPEED_LEVEL = "FL"
+SET_POINT = "SP" # either comfort level or set temp
+TEMPERATURE_UNIT = "TU"
+UNIT_FAHRENHEIT = "F"
+
+SCHEDULE_PERIOD = "AT"
+ADVANCE_PERIOD = "AZ"
+SCHEDULE_OVERRIDE = "AO"
+ADVANCED = "A"
+
+USER_ENABLED = "UE"
+AUTO_ENABLED = "AE"
+
+OPERATING_PROGRAM = "OP"
+MODE_MANUAL = "M"
+MODE_AUTO = "A"
diff --git a/pyrinnaitouch/cooler.py b/pyrinnaitouch/cooler.py
index 9740854..a405030 100644
--- a/pyrinnaitouch/cooler.py
+++ b/pyrinnaitouch/cooler.py
@@ -2,6 +2,7 @@
 import logging
 
 from .util import get_attribute, y_n_to_bool, symbol_to_schedule_period
+from .zone import Zone
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -15,14 +16,9 @@ def handle_cooling_mode(j,brivis_status):
         _LOGGER.error("No CFG - Not happy, Jan")
 
     else:
-        if y_n_to_bool(get_attribute(cfg, "ZAIS", None)):
-            brivis_status.cooling_status.zones.append("A")
-        if y_n_to_bool(get_attribute(cfg, "ZBIS", None)):
-            brivis_status.cooling_status.zones.append("B")
-        if y_n_to_bool(get_attribute(cfg, "ZCIS", None)):
-            brivis_status.cooling_status.zones.append("C")
-        if y_n_to_bool(get_attribute(cfg, "ZDIS", None)):
-            brivis_status.cooling_status.zones.append("D")
+        for zoneid in ["A","B","C","D", "U"]:
+            if y_n_to_bool(get_attribute(cfg, "Z"+zoneid+"IS", None)):
+                brivis_status.cooling_status.zones[zoneid] = Zone(zoneid)
 
     oop = get_attribute(j[1].get("CGOM"),"OOP",None)
     if not oop:
@@ -46,32 +42,35 @@ def handle_cooling_mode(j,brivis_status):
             _LOGGER.debug("Fan Speed is: %s", fan_speed)
             brivis_status.cooling_status.fan_speed = int(fan_speed) # Should catch errors!
 
-            # GSO should be there
-            gso = get_attribute(j[1].get("CGOM"),"GSO",None)
-            if not gso:
-                # Probably an error
-                _LOGGER.error("No GSO when cooling on. Not happy, Jan")
+            if brivis_status.is_multi_set_point:
+                pass
             else:
-                # Cooling is on - get attributes
-                op_mode = get_attribute(gso,"OP",None)
-                _LOGGER.debug("Cooling OpMode is: %s", op_mode) # A = Auto, M = Manual
-                brivis_status.cooling_status.set_mode(op_mode)
-
-                # Set temp?
-                set_temp = get_attribute(gso,"SP",None)
-                _LOGGER.debug("Cooling set temp is: %s", set_temp)
-                brivis_status.cooling_status.set_temp = int(set_temp)
-
-                brivis_status.cooling_status.set_advanced(get_attribute(gso,"AO",None))
-
-                gss = get_attribute(j[1].get("CGOM"),"GSS",None)
-                if not gss:
-                    _LOGGER.error("No GSS here")
+                # GSO should be there
+                gso = get_attribute(j[1].get("CGOM"),"GSO",None)
+                if not gso:
+                    # Probably an error
+                    _LOGGER.error("No GSO when cooling on. Not happy, Jan")
                 else:
-                    period = symbol_to_schedule_period(get_attribute(gss,"AT",None))
-                    brivis_status.cooling_status.schedule_period = period
-                    period = symbol_to_schedule_period(get_attribute(gss,"AZ",None))
-                    brivis_status.cooling_status.advance_period = period
+                    # Cooling is on - get attributes
+                    op_mode = get_attribute(gso,"OP",None)
+                    _LOGGER.debug("Cooling OpMode is: %s", op_mode) # A = Auto, M = Manual
+                    brivis_status.cooling_status.set_mode(op_mode)
+
+                    # Set temp?
+                    set_temp = get_attribute(gso,"SP",None)
+                    _LOGGER.debug("Cooling set temp is: %s", set_temp)
+                    brivis_status.cooling_status.set_temp = int(set_temp)
+
+                    brivis_status.cooling_status.set_advanced(get_attribute(gso,"AO",None))
+
+                    gss = get_attribute(j[1].get("CGOM"),"GSS",None)
+                    if not gss:
+                        _LOGGER.error("No GSS here")
+                    else:
+                        period = symbol_to_schedule_period(get_attribute(gss,"AT",None))
+                        brivis_status.cooling_status.schedule_period = period
+                        period = symbol_to_schedule_period(get_attribute(gss,"AZ",None))
+                        brivis_status.cooling_status.advance_period = period
 
         elif switch == "F":
             # Cooling is off
@@ -89,45 +88,43 @@ def handle_cooling_mode(j,brivis_status):
             _LOGGER.debug("Fan Speed is: %s", fan_speed)
             brivis_status.cooling_status.fan_speed = int(fan_speed) # Should catch errors!
 
-        zone_a = zone_b = zone_c = zone_d = None
-        zone = get_attribute(j[1].get("CGOM"),"ZAO",None)
-        if zone:
-            zone_a = get_attribute(zone,"UE",None)
-            brivis_status.cooling_status.zone_a_set_temp = get_attribute(zone,"SP", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZBO",None)
-        if zone:
-            zone_b = get_attribute(zone,"UE",None)
-            brivis_status.cooling_status.zone_b_set_temp = get_attribute(zone,"SP", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZCO",None)
-        if zone:
-            zone_c = get_attribute(zone,"UE",None)
-            brivis_status.cooling_status.zone_c_set_temp = get_attribute(zone,"SP", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZDO",None)
-        if zone:
-            zone_d = get_attribute(zone,"UE",None)
-            brivis_status.cooling_status.zone_d_set_temp = get_attribute(zone,"SP", 999)
-        brivis_status.cooling_status.set_zones(zone_a,zone_b,zone_c,zone_d)
-
-        zone = get_attribute(j[1].get("CGOM"),"ZAS",None)
-        if zone:
-            brivis_status.cooling_status.zone_a_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.cooling_status.zone_a_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZBS",None)
-        if zone:
-            brivis_status.cooling_status.zone_b_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.cooling_status.zone_b_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZCS",None)
-        if zone:
-            brivis_status.cooling_status.zone_c_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.cooling_status.zone_c_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZDS",None)
-        if zone:
-            brivis_status.cooling_status.zone_d_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.cooling_status.zone_d_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("CGOM"),"ZUS",None)
-        if zone:
-            brivis_status.cooling_status.common_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.cooling_status.temperature = get_attribute(zone,"MT", 999)
+        # Single Point
+        # ZXO => user enabled
+        # ZXS => *MT (temp) and *AE (Auto enabled (calling for heat))
+        # Multi Point
+        # ZXO => user enabled for Fan_Only, OP (auto/manual), SP (set_temp), AO (schedule override)
+        # ZXS => *AE (calling for heat), FS (fan active), PH (preheat), *MT (temp),
+        #         AT (schedule_period), AZ (advance_period)
+        for zoneid in ["A","B","C","D","U"]:
+            zone = get_attribute(j[1].get("CGOM"),"Z"+zoneid+"O",None)
+            if zone and zoneid in brivis_status.cooling_status.zones.keys():
+                # this one is single set point and multi set point with fan only
+                if brivis_status.is_multi_set_point \
+                   or brivis_status.cooling_status.circulation_fan_on:
+                    brivis_status.cooling_status.zones[zoneid].user_enabled = \
+                        y_n_to_bool(get_attribute(zone,"UE",None))
+                # these ones are multi only
+                if brivis_status.is_multi_set_point:
+                    brivis_status.cooling_status.zones[zoneid].set_temp = \
+                        get_attribute(zone,"SP", 999)
+                    brivis_status.cooling_status.zones[zoneid].set_advanced(
+                        get_attribute(zone,"AO", None))
+                    brivis_status.cooling_status.zones[zoneid].set_mode(
+                        get_attribute(zone,"OP",None))
+
+            zone = get_attribute(j[1].get("CGOM"),"Z"+zoneid+"S",None)
+            if zone and zoneid in brivis_status.cooling_status.zones.keys():
+                # these ones are common
+                brivis_status.cooling_status.zones[zoneid].auto_mode = \
+                    y_n_to_bool(get_attribute(zone,"AE",None))
+                brivis_status.cooling_status.zones[zoneid].temperature = \
+                    get_attribute(zone,"MT", 999)
+                # these ones are multi only
+                if brivis_status.is_multi_set_point:
+                    brivis_status.cooling_status.zones[zoneid].advance_period = \
+                        symbol_to_schedule_period(get_attribute(zone,"AZ",None))
+                    brivis_status.cooling_status.zones[zoneid].schedule_period = \
+                        symbol_to_schedule_period(get_attribute(zone,"AT",None))
 
 class CoolingStatus():
     """Cooling function status"""
@@ -144,25 +141,11 @@ class CoolingStatus():
     schedule_period = None
     advance_period = None
     advanced = False
+    has_common_zone = False
+    common_zone = False
 
     #zones
-    zones = []
-    zone_a = False
-    zone_a_auto = False
-    zone_a_temp = 999
-    zone_a_set_temp = 999
-    zone_b = False
-    zone_b_auto = False
-    zone_b_temp = 999
-    zone_b_set_temp = 999
-    zone_c = False
-    zone_c_auto = False
-    zone_c_temp = 999
-    zone_c_set_temp = 999
-    zone_d = False
-    zone_d_auto = False
-    zone_d_temp = 999
-    zone_d_set_temp = 999
+    zones = {}
 
     def set_mode(self,mode):
         """Set auto/manual mode."""
@@ -174,14 +157,6 @@ def set_mode(self,mode):
             self.auto_mode = False
             self.manual_mode = True
 
-    def set_zones(self,zone_a,zone_b,zone_c,zone_d):
-        """Define zones."""
-        # Y = On, N = off
-        self.zone_a = y_n_to_bool(zone_a)
-        self.zone_b = y_n_to_bool(zone_b)
-        self.zone_c = y_n_to_bool(zone_c)
-        self.zone_d = y_n_to_bool(zone_d)
-
     def set_circulation_fan_on(self,status_str):
         """Set circ fan state."""
         # Z = On, N = Off
diff --git a/pyrinnaitouch/evap.py b/pyrinnaitouch/evap.py
index 3ed13c3..cae7aac 100644
--- a/pyrinnaitouch/evap.py
+++ b/pyrinnaitouch/evap.py
@@ -2,6 +2,7 @@
 import logging
 
 from .util import get_attribute, y_n_to_bool
+from .zone import Zone
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -15,15 +16,11 @@ def handle_evap_mode(j,brivis_status):
         _LOGGER.error("No CFG - Not happy, Jan")
 
     else:
-        if y_n_to_bool(get_attribute(cfg, "ZAIS", None)):
-            brivis_status.heater_status.zones.append("A")
-        if y_n_to_bool(get_attribute(cfg, "ZBIS", None)):
-            brivis_status.heater_status.zones.append("B")
-        if y_n_to_bool(get_attribute(cfg, "ZCIS", None)):
-            brivis_status.heater_status.zones.append("C")
-        if y_n_to_bool(get_attribute(cfg, "ZDIS", None)):
-            brivis_status.heater_status.zones.append("D")
+        for zoneid in ["A","B","C","D", "U"]:
+            if y_n_to_bool(get_attribute(cfg, "Z"+zoneid+"IS", None)):
+                brivis_status.evap_status.zones[zoneid] = Zone(zoneid)
 
+    # no multi, there's always a GSO
     gso = get_attribute(j[1].get("ECOM"),"GSO",None)
     if not gso:
         _LOGGER.error("No GSO here")
@@ -53,31 +50,26 @@ def handle_evap_mode(j,brivis_status):
                 _LOGGER.debug("Water Pump is: %s", water_pump)
                 brivis_status.evap_status.set_water_pump(water_pump)
 
-                brivis_status.evap_status.zone_a = y_n_to_bool(get_attribute(gso,"ZAUE",False))
-                brivis_status.evap_status.zone_b = y_n_to_bool(get_attribute(gso,"ZBUE",False))
-                brivis_status.evap_status.zone_c = y_n_to_bool(get_attribute(gso,"ZCUE",False))
-                brivis_status.evap_status.zone_d = y_n_to_bool(get_attribute(gso,"ZDUE",False))
-
             else:
                 # Evap is on and auto - look for comfort level
                 comfort = get_attribute(gso, "SP", 0)
                 _LOGGER.debug("Comfort Level is: %s", comfort)
                 brivis_status.evap_status.set_comfort(comfort)
 
-                brivis_status.evap_status.zone_a = False
-                brivis_status.evap_status.zone_b = False
-                brivis_status.evap_status.zone_c = False
-                brivis_status.evap_status.zone_d = False
+            for zoneid in ["A","B","C","D","U"]:
+                if zoneid in brivis_status.evap_status.zones.keys():
+                    brivis_status.evap_status.zones[zoneid].user_enabled = \
+                        y_n_to_bool(get_attribute(gso,"Z"+zoneid+"UE",False))
+
 
             gss = get_attribute(j[1].get("ECOM"),"GSS",None)
             if not gss:
                 _LOGGER.error("No GSS here")
             else:
-                brivis_status.evap_status.common_auto = y_n_to_bool(get_attribute(gss,"ZUAE",False))
-                brivis_status.evap_status.zone_a_auto = y_n_to_bool(get_attribute(gss,"ZAAE",False))
-                brivis_status.evap_status.zone_b_auto = y_n_to_bool(get_attribute(gss,"ZBAE",False))
-                brivis_status.evap_status.zone_c_auto = y_n_to_bool(get_attribute(gss,"ZCAE",False))
-                brivis_status.evap_status.zone_d_auto = y_n_to_bool(get_attribute(gss,"ZDAE",False))
+                for zoneid in ["A","B","C","D","U"]:
+                    if zoneid in brivis_status.evap_status.zones.keys():
+                        brivis_status.evap_status.zones[zoneid].auto_mode = \
+                            y_n_to_bool(get_attribute(gss,"Z"+zoneid+"AE",False))
 
                 brivis_status.evap_status.prewetting = y_n_to_bool(get_attribute(gss,"PW",False))
                 brivis_status.evap_status.cooler_busy = y_n_to_bool(get_attribute(gss,"BY",False))
@@ -89,17 +81,6 @@ def handle_evap_mode(j,brivis_status):
             brivis_status.system_on = False
             brivis_status.evap_status.evap_on = False
 
-            brivis_status.evap_status.zone_a = False
-            brivis_status.evap_status.zone_b = False
-            brivis_status.evap_status.zone_c = False
-            brivis_status.evap_status.zone_d = False
-
-            brivis_status.evap_status.common_auto = False
-            brivis_status.evap_status.zone_a_auto = False
-            brivis_status.evap_status.zone_b_auto = False
-            brivis_status.evap_status.zone_c_auto = False
-            brivis_status.evap_status.zone_d_auto = False
-
 class EvapStatus():
     """Evap function status"""
     evap_on = False
@@ -109,21 +90,12 @@ class EvapStatus():
     fan_speed = 0
     water_pump_on = False
     comfort = 0
-    common_auto = False
     temperature = 999
     prewetting = False
     cooler_busy = False
 
     #zones
-    zones = []
-    zone_a = False
-    zone_a_auto = False
-    zone_b = False
-    zone_b_auto = False
-    zone_c = False
-    zone_c_auto = False
-    zone_d = False
-    zone_d_auto = False
+    zones = {}
 
     def set_fan(self,status_str):
         """Set fan state."""
diff --git a/pyrinnaitouch/event.py b/pyrinnaitouch/event.py
new file mode 100644
index 0000000..9e809c9
--- /dev/null
+++ b/pyrinnaitouch/event.py
@@ -0,0 +1,27 @@
+"""Module to capture event handling"""
+try:
+    from typing import Self
+except ImportError:
+    from typing_extensions import Self
+
+class Event():
+    """Simple event class."""
+
+    def __init__(self) -> None:
+        self.__eventhandlers = []
+
+    def __iadd__(self, handler) -> Self:
+        """Add event handler."""
+        self.__eventhandlers.append(handler)
+        return self
+
+    def __isub__(self, handler) -> Self:
+        """Remove event handler."""
+        self.__eventhandlers.remove(handler)
+        return self
+
+    def __call__(self, *args, **keywargs) -> None:
+        """Call event handler."""
+        for eventhandler in self.__eventhandlers:
+            if eventhandler is not None:
+                eventhandler(*args, **keywargs)
diff --git a/pyrinnaitouch/heater.py b/pyrinnaitouch/heater.py
index 641792d..92ef582 100644
--- a/pyrinnaitouch/heater.py
+++ b/pyrinnaitouch/heater.py
@@ -2,6 +2,7 @@
 import logging
 
 from .util import get_attribute, y_n_to_bool, symbol_to_schedule_period
+from .zone import Zone
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -15,14 +16,9 @@ def handle_heating_mode(j,brivis_status):
         _LOGGER.error("No CFG - Not happy, Jan")
 
     else:
-        if y_n_to_bool(get_attribute(cfg, "ZAIS", None)):
-            brivis_status.heater_status.zones.append("A")
-        if y_n_to_bool(get_attribute(cfg, "ZBIS", None)):
-            brivis_status.heater_status.zones.append("B")
-        if y_n_to_bool(get_attribute(cfg, "ZCIS", None)):
-            brivis_status.heater_status.zones.append("C")
-        if y_n_to_bool(get_attribute(cfg, "ZDIS", None)):
-            brivis_status.heater_status.zones.append("D")
+        for zoneid in ["A","B","C","D", "U"]:
+            if y_n_to_bool(get_attribute(cfg, "Z"+zoneid+"IS", None)):
+                brivis_status.heater_status.zones[zoneid] = Zone(zoneid)
 
     oop = get_attribute(j[1].get("HGOM"),"OOP",None)
     if not oop:
@@ -46,34 +42,37 @@ def handle_heating_mode(j,brivis_status):
             _LOGGER.debug("Fan Speed is: %s", fan_speed)
             brivis_status.heater_status.fan_speed = int(fan_speed) # Should catch errors!
 
-            # GSO should be there
-            gso = get_attribute(j[1].get("HGOM"),"GSO",None)
-            if not gso:
-                # Probably an error
-                _LOGGER.error("No GSO when heater on. Not happy, Jan")
+            if brivis_status.is_multi_set_point:
+                pass
             else:
-                # Heater is on - get attributes
-                op_mode = get_attribute(gso,"OP",None)
-                _LOGGER.debug("Heat OpMode is: %s", op_mode) # A = Auto, M = Manual
-                brivis_status.heater_status.set_mode(op_mode)
-
-                # Set temp?
-                set_temp = get_attribute(gso,"SP",None)
-                _LOGGER.debug("Heat set temp is: %s", set_temp)
-                brivis_status.heater_status.set_temp = int(set_temp)
-
-                brivis_status.heater_status.set_advanced(get_attribute(gso,"AO",None))
-
-                gss = get_attribute(j[1].get("HGOM"),"GSS",None)
-                if not gss:
-                    _LOGGER.error("No GSS here")
+                # GSO should be there for single set point
+                gso = get_attribute(j[1].get("HGOM"),"GSO",None)
+                if not gso:
+                    # Probably an error
+                    _LOGGER.error("No GSO when heater on. Not happy, Jan")
                 else:
-                    preheat = y_n_to_bool(get_attribute(gss,"PH",False))
-                    brivis_status.heater_status.preheating = preheat
-                    period = symbol_to_schedule_period(get_attribute(gss,"AT",None))
-                    brivis_status.heater_status.schedule_period = period
-                    period = symbol_to_schedule_period(get_attribute(gss,"AZ",None))
-                    brivis_status.heater_status.advance_period = period
+                    # Heater is on - get attributes
+                    op_mode = get_attribute(gso,"OP",None)
+                    _LOGGER.debug("Heat OpMode is: %s", op_mode) # A = Auto, M = Manual
+                    brivis_status.heater_status.set_mode(op_mode)
+
+                    # Set temp?
+                    set_temp = get_attribute(gso,"SP",None)
+                    _LOGGER.debug("Heat set temp is: %s", set_temp)
+                    brivis_status.heater_status.set_temp = int(set_temp)
+
+                    brivis_status.heater_status.set_advanced(get_attribute(gso,"AO",None))
+
+                    gss = get_attribute(j[1].get("HGOM"),"GSS",None)
+                    if not gss:
+                        _LOGGER.error("No GSS here")
+                    else:
+                        preheat = y_n_to_bool(get_attribute(gss,"PH",False))
+                        brivis_status.heater_status.preheating = preheat
+                        period = symbol_to_schedule_period(get_attribute(gss,"AT",None))
+                        brivis_status.heater_status.schedule_period = period
+                        period = symbol_to_schedule_period(get_attribute(gss,"AZ",None))
+                        brivis_status.heater_status.advance_period = period
 
         elif switch == "F":
             # Heater is off
@@ -92,45 +91,43 @@ def handle_heating_mode(j,brivis_status):
             _LOGGER.debug("Fan Speed is: %s", fan_speed)
             brivis_status.heater_status.fan_speed = int(fan_speed) # Should catch errors!
 
-        zone_a = zone_b = zone_c = zone_d = None
-        zone = get_attribute(j[1].get("HGOM"),"ZAO",None)
-        if zone:
-            zone_a = get_attribute(zone,"UE",None)
-            brivis_status.heater_status.zone_a_set_temp = get_attribute(zone,"SP", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZBO",None)
-        if zone:
-            zone_b = get_attribute(zone,"UE",None)
-            brivis_status.heater_status.zone_b_set_temp = get_attribute(zone,"SP", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZCO",None)
-        if zone:
-            zone_c = get_attribute(zone,"UE",None)
-            brivis_status.heater_status.zone_c_set_temp = get_attribute(zone,"SP", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZDO",None)
-        if zone:
-            zone_d = get_attribute(zone,"UE",None)
-            brivis_status.heater_status.zone_d_set_temp = get_attribute(zone,"SP", 999)
-        brivis_status.heater_status.set_zones(zone_a,zone_b,zone_c,zone_d)
-
-        zone = get_attribute(j[1].get("HGOM"),"ZAS",None)
-        if zone:
-            brivis_status.heater_status.zone_a_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.heater_status.zone_a_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZBS",None)
-        if zone:
-            brivis_status.heater_status.zone_b_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.heater_status.zone_b_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZCS",None)
-        if zone:
-            brivis_status.heater_status.zone_c_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.heater_status.zone_c_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZDS",None)
-        if zone:
-            brivis_status.heater_status.zone_d_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.heater_status.zone_d_temp = get_attribute(zone,"MT", 999)
-        zone = get_attribute(j[1].get("HGOM"),"ZUS",None)
-        if zone:
-            brivis_status.heater_status.common_auto = y_n_to_bool(get_attribute(zone,"AE",None))
-            brivis_status.heater_status.temperature = get_attribute(zone,"MT", 999)
+        # Single Point
+        # ZXO => user enabled
+        # ZXS => *MT (temp) and *AE (Auto enabled (calling for heat))
+        # Multi Point
+        # ZXO => user enabled for Fan_Only, OP (auto/manual), SP (set_temp), AO (schedule override)
+        # ZXS => *AE (calling for heat), FS (fan active), PH (preheat), *MT (temp),
+        #         AT (schedule_period), AZ (advance_period)
+        for zoneid in ["A","B","C","D","U"]:
+            zone = get_attribute(j[1].get("HGOM"),"Z"+zoneid+"O",None)
+            if zone and zoneid in brivis_status.heater_status.zones.keys():
+                # this one is single set point and multi set point with fan only
+                if brivis_status.is_multi_set_point or \
+                    brivis_status.heater_status.circulation_fan_on:
+                    brivis_status.heater_status.zones[zoneid].user_enabled = \
+                        y_n_to_bool(get_attribute(zone,"UE",None))
+                # these ones are multi only
+                if brivis_status.is_multi_set_point:
+                    brivis_status.heater_status.zones[zoneid].set_temp = \
+                        get_attribute(zone,"SP", 999)
+                    brivis_status.heater_status.zones[zoneid].set_advanced(
+                        get_attribute(zone,"AO", None))
+                    brivis_status.heater_status.zones[zoneid].set_mode(
+                        get_attribute(zone,"OP",None))
+
+            zone = get_attribute(j[1].get("HGOM"),"Z"+zoneid+"S",None)
+            if zone and zoneid in brivis_status.heater_status.zones.keys():
+                # these ones are common
+                brivis_status.heater_status.zones[zoneid].auto_mode = \
+                    y_n_to_bool(get_attribute(zone,"AE",None))
+                brivis_status.heater_status.zones[zoneid].temperature = \
+                    get_attribute(zone,"MT", 999)
+                # these ones are multi only
+                if brivis_status.is_multi_set_point:
+                    brivis_status.heater_status.zones[zoneid].advance_period = \
+                        symbol_to_schedule_period(get_attribute(zone,"AZ",None))
+                    brivis_status.heater_status.zones[zoneid].schedule_period = \
+                        symbol_to_schedule_period(get_attribute(zone,"AT",None))
 
 class HeaterStatus():
     """Heater function status"""
@@ -142,7 +139,6 @@ class HeaterStatus():
     manual_mode = False
     auto_mode = False
     set_temp = 0
-    common_auto = False
     temperature = 999
     preheating = False
     schedule_period = None
@@ -150,23 +146,7 @@ class HeaterStatus():
     advanced = False
 
     #zones
-    zones = []
-    zone_a = False
-    zone_a_auto = False
-    zone_a_temp = 999
-    zone_a_set_temp = 999
-    zone_b = False
-    zone_b_auto = False
-    zone_b_temp = 999
-    zone_b_set_temp = 999
-    zone_c = False
-    zone_c_auto = False
-    zone_c_temp = 999
-    zone_c_set_temp = 999
-    zone_d = False
-    zone_d_auto = False
-    zone_d_temp = 999
-    zone_d_set_temp = 999
+    zones = {}
 
     def set_mode(self,mode):
         """Set auto/manual mode."""
@@ -178,14 +158,6 @@ def set_mode(self,mode):
             self.auto_mode = False
             self.manual_mode = True
 
-    def set_zones(self,zone_a,zone_b,zone_c,zone_d):
-        """Define zones."""
-        # Y = On, N = off
-        self.zone_a = y_n_to_bool(zone_a)
-        self.zone_b = y_n_to_bool(zone_b)
-        self.zone_c = y_n_to_bool(zone_c)
-        self.zone_d = y_n_to_bool(zone_d)
-
     def set_circulation_fan_on(self,status_str):
         """Set circ fan state."""
         # Z = On, N = Off
diff --git a/pyrinnaitouch/receiver.py b/pyrinnaitouch/receiver.py
new file mode 100644
index 0000000..c4dec29
--- /dev/null
+++ b/pyrinnaitouch/receiver.py
@@ -0,0 +1,84 @@
+"""Receiver Thread to continually get data from the connection"""
+import time
+import json
+import re
+import logging
+import queue
+from .connection import RinnaiConnection
+from .util import daemonthreaded
+
+_LOGGER = logging.getLogger(__name__)
+
+class RinnaiReceiver:
+    """Receiver to handle receiving and sending data to the unit"""
+
+    def __init__(self, connection: RinnaiConnection, receiverqueue: queue) -> None:
+        self._connection = connection
+        self._receiverqueue = receiverqueue
+        self._lastdata = ''
+        self._counter = 0
+
+    @daemonthreaded
+    def receiver(self) -> None:
+        """Main send and receive thread to process and send messages."""
+        while True:
+            self._counter+=1
+            # send next message if any
+            try:
+                message = self._connection.get_queued_command()
+                self._connection.send(message)
+                _LOGGER.error("Fired off command: (%s)", message.decode())
+                time.sleep(0.05)
+                self._counter = 0
+            except ConnectionError as connerr:
+                _LOGGER.error("Couldn't send command (connection): (%s)", repr(connerr))
+                self._connection.renew_connection()
+            except queue.Empty:
+                pass
+
+            #send empty command ever so often
+            try:
+                self.send_empty_command()
+            except (ConnectionError, TimeoutError) as err:
+                _LOGGER.error("Couldn't send empty command (connection): (%s)", repr(err))
+
+            #receive status
+            try:
+                self.receive_data()
+            except ConnectionError as connerr:
+                _LOGGER.error("Couldn't decode JSON (connection), skipping (%s)", repr(connerr))
+                self._connection.shutdown()
+                self._connection.renew_connection()
+            except TimeoutError as timeouterr:
+                _LOGGER.error("Socket timed out, renewing connection (%s)", repr(timeouterr))
+                self._connection.shutdown()
+                self._connection.renew_connection()
+            except AttributeError as atterr:
+                _LOGGER.error("Couldn't decode JSON (probably HELLO), skipping (%s)", repr(atterr))
+
+    def receive_data(self) -> None:
+        """handle receiving and high level parsing of data."""
+        temp = self._connection.receive_data()
+        if temp:
+            #_LOGGER.debug("Received data: (%s)", temp.decode())
+            data = temp
+            exp = re.search('^.*([0-9]{6}).*(\[[^\[]*\])[^]]*$', str(data)) # pylint: disable=anomalous-backslash-in-string
+            seq = int(exp.group(1))
+            self._connection.update_send_sequence(seq)
+            json_str = exp.group(2)
+            if json_str != self._lastdata:
+                _LOGGER.debug("Sequence: %s Json: %s", seq, json_str)
+                status_json = json.loads(json_str)
+                self._receiverqueue.put(status_json)
+                self._lastdata = json_str
+
+    def send_empty_command(self) -> None:
+        """Send empty command every 10 receive cycles."""
+        if self._counter > 10:
+            try:
+                cmd = "NA"
+                self._connection.send(cmd.encode())
+                self._counter = 0
+            except ConnectionError as connerr:
+                _LOGGER.error("Couldn't send command (connection): (%s)", repr(connerr))
+                self._connection.renew_connection()
diff --git a/pyrinnaitouch/system.py b/pyrinnaitouch/system.py
index 23ba8ec..7fc9180 100644
--- a/pyrinnaitouch/system.py
+++ b/pyrinnaitouch/system.py
@@ -1,49 +1,19 @@
 ﻿"""Main system control"""
 import logging
-import socket
-import time
-import json
-import re
-import threading
 import queue
-from enum import Enum
-from dataclasses import dataclass
-from .heater import handle_heating_mode, HeaterStatus
-from .cooler import handle_cooling_mode, CoolingStatus
-from .evap import handle_evap_mode, EvapStatus
+from typing import Any
+
+from .const import RinnaiSystemMode, RinnaiUnitId
+try:
+    from typing import Self
+except ImportError:
+    from typing_extensions import Self
+
+from .connection import RinnaiConnection
+from .receiver import RinnaiReceiver
+from .event import Event
+from .system_status import RinnaiSystemStatus
 from .commands import (
-    HEAT_ON_CMD,
-    HEAT_OFF_CMD,
-    HEAT_SET_TEMP,
-    HEAT_CIRC_FAN_ON,
-    HEAT_ZONE_ON,
-    HEAT_ZONE_OFF,
-    HEAT_SET_MANUAL,
-    HEAT_SET_AUTO,
-    HEAT_ADVANCE,
-    HEAT_ADVANCE_CANCEL,
-    HEAT_ZONE_SET_TEMP,
-    HEAT_ZONE_SET_MANUAL,
-    HEAT_ZONE_SET_AUTO,
-    HEAT_ZONE_ADVANCE,
-    HEAT_ZONE_ADVANCE_CANCEL,
-    HEAT_CIRC_FAN_SPEED,
-    COOL_ON_CMD,
-    COOL_OFF_CMD,
-    COOL_SET_TEMP,
-    COOL_CIRC_FAN_ON,
-    COOL_ZONE_ON,
-    COOL_ZONE_OFF,
-    COOL_SET_MANUAL,
-    COOL_SET_AUTO,
-    COOL_ADVANCE,
-    COOL_ADVANCE_CANCEL,
-    COOL_ZONE_SET_TEMP,
-    COOL_ZONE_SET_MANUAL,
-    COOL_ZONE_SET_AUTO,
-    COOL_ZONE_ADVANCE,
-    COOL_ZONE_ADVANCE_CANCEL,
-    COOL_CIRC_FAN_SPEED,
     EVAP_ON_CMD,
     EVAP_OFF_CMD,
     EVAP_PUMP_ON,
@@ -51,8 +21,6 @@
     EVAP_FAN_ON,
     EVAP_FAN_OFF,
     EVAP_FAN_SPEED,
-    EVAP_SET_MANUAL,
-    EVAP_SET_AUTO,
     EVAP_SET_COMFORT,
     EVAP_ZONE_ON,
     EVAP_ZONE_OFF,
@@ -61,598 +29,266 @@
     MODE_COOL_CMD,
     MODE_EVAP_CMD,
     MODE_HEAT_CMD,
-    HEAT_COMMANDS,
-    COOL_COMMANDS,
     EVAP_COMMANDS,
-    MODE_COMMANDS
+    MODE_COMMANDS,
+    UNIT_ADVANCE,
+    UNIT_ADVANCE_CANCEL,
+    UNIT_CIRC_FAN_ON,
+    UNIT_CIRC_FAN_SPEED,
+    UNIT_COMMANDS,
+    UNIT_OFF_CMD,
+    UNIT_ON_CMD,
+    UNIT_SET_AUTO,
+    UNIT_SET_MANUAL,
+    UNIT_SET_TEMP,
+    UNIT_ZONE_ADVANCE,
+    UNIT_ZONE_ADVANCE_CANCEL,
+    UNIT_ZONE_OFF,
+    UNIT_ZONE_ON,
+    UNIT_ZONE_SET_AUTO,
+    UNIT_ZONE_SET_MANUAL,
+    UNIT_ZONE_SET_TEMP
 )
 
-from .util import get_attribute, y_n_to_bool
+from .util import daemonthreaded
 
 _LOGGER = logging.getLogger(__name__)
 
-def daemonthreaded(function_arg):
-    """Decoration to start object function as thread"""
-    def wrapper(*args, **kwargs):
-        thread = threading.Thread(target=function_arg, args=args, kwargs=kwargs)
-        thread.setDaemon(True)
-        thread.start()
-        return thread
-    return wrapper
-
-class Event():
-    """Simple event class."""
-
-    def __init__(self):
-        self.__eventhandlers = []
-
-    def __iadd__(self, handler):
-        """Add event handler."""
-        self.__eventhandlers.append(handler)
-        return self
-
-    def __isub__(self, handler):
-        """Remove event handler."""
-        self.__eventhandlers.remove(handler)
-        return self
-
-    def __call__(self, *args, **keywargs):
-        """Call event handler."""
-        for eventhandler in self.__eventhandlers:
-            if eventhandler is not None:
-                eventhandler(*args, **keywargs)
-
-@dataclass
-class BrivisStatus():
-    """Overall Class for describing status"""
-    # pylint: disable=too-many-instance-attributes
-    #modes
-    #TODO: turn into mode
-    evap_mode: bool = False
-    cooling_mode: bool = False
-    heater_mode: bool = False
-    system_on: bool = False
-    temp_unit: str = None
-    #turn into capabilities
-    has_heater: bool = True
-    has_cooling: bool = True
-    has_evap: bool = True
-
-    #system info
-    firmware_version: str = None
-    wifi_module_version: str = None
-
-    #zones
-    zone_a_desc: str = None
-    zone_b_desc: str = None
-    zone_c_desc: str = None
-    zone_d_desc: str = None
-    is_multi_set_point: bool = False
-
-    #faults
-    has_fault: bool = False
-
-    heater_status: HeaterStatus = HeaterStatus()
-    cooling_status: CoolingStatus = CoolingStatus()
-    evap_status: EvapStatus = EvapStatus()
-
-    def set_mode(self,mode):
-        """Set the mode."""
-        if mode == Mode.HEATING:
-            self.heater_mode = True
-            self.cooling_mode = False
-            self.evap_mode = False
-        elif mode == Mode.COOLING:
-            self.heater_mode = False
-            self.cooling_mode = True
-            self.evap_mode = False
-        elif mode == Mode.EVAP:
-            self.heater_mode = False
-            self.cooling_mode = False
-            self.evap_mode = True
-
-class Mode(Enum):
-    """Define system modes."""
-    HEATING = 1
-    EVAP = 2
-    COOLING = 3
-    RC = 4
-    NONE = 5
-
 class RinnaiSystem:
     """Main controller class to interact with the Rinnai Touch Wifi unit."""
     # pylint: disable=too-many-instance-attributes,too-many-public-methods
 
-    clients = {}
     instances = {}
 
-    TEMP_CELSIUS = "°C"
-    TEMP_FAHRENHEIT = "°F"
-
-    def __init__(self, ip_address):
-        self._touch_ip = ip_address
-        self._touch_port = 27847
-        self._send_sequence = 1
+    def __init__(self, ip_address: str) -> None:
+        self._connection = RinnaiConnection(ip_address)
         self._lastupdated = 0
-        self._status = BrivisStatus()
-        self._lastclosed = 0
-        self._client = None
-        self._zones = []
-        self._jsonerrors = 0
+        self._status = RinnaiSystemStatus()
         self._nosendupdates = 0
-        self._senderqueue = queue.Queue()
         self._receiverqueue = queue.Queue()
-        if ip_address not in RinnaiSystem.clients:
-            RinnaiSystem.clients[ip_address] = self._client
-        else:
-            self._client = RinnaiSystem.clients[ip_address]
         RinnaiSystem.instances[ip_address] = self
         self._on_updated = Event()
 
-    def set_zones(self, zones):
-        """Set the active zones in the system."""
-        self._zones = zones
-
     @staticmethod
-    def get_instance(ip_address):
+    def get_instance(ip_address: str) -> Self:
         """Get a single instance of the system defined by its IP address."""
         if ip_address in RinnaiSystem.instances:
             return RinnaiSystem.instances[ip_address]
         return RinnaiSystem(ip_address)
 
-    def subscribe_updates(self,obj_method):
+    def subscribe_updates(self,obj_method: Any) -> None:
         """Subscribe to updates when the system status refreshes."""
         self._on_updated += obj_method
 
-    def unsubscribe_updates(self,obj_method):
+    def unsubscribe_updates(self,obj_method: Any) -> None:
         """Unsubscribe from updates received when the system status refreshes."""
         self._on_updated -= obj_method
 
     @daemonthreaded
-    def receiver(self):
-        """Main send and receive thread to process and send messages."""
-        # pylint: disable=too-many-statements
-        lastdata = ''
-        counter = 0
-
-        while True:
-            counter+=1
-            # send next message if any
-            try:
-                message = self._senderqueue.get(False)
-                self._client.sendall(message)
-                _LOGGER.error("Fired off command: (%s)", message.decode())
-                time.sleep(0.05)
-                counter = 0
-            except ConnectionError as connerr:
-                _LOGGER.error("Couldn't send command (connection): (%s)", repr(connerr))
-                self.renew_connection()
-            except queue.Empty:
-                pass
-
-            #send empty command ever so often
-            if counter > 10:
-                try:
-                    cmd = "NA"
-                    self._client.sendall(cmd.encode())
-                    counter = 0
-                except ConnectionError as connerr:
-                    _LOGGER.error("Couldn't send command (connection): (%s)", repr(connerr))
-                    self.renew_connection()
-
-            #receive status
-            try:
-                temp = self._client.recv(8096)
-                if temp:
-                    #_LOGGER.debug("Received data: (%s)", temp.decode())
-                    data = temp
-                    exp = re.search('^.*([0-9]{6}).*(\[[^\[]*\])[^]]*$', str(data)) # pylint: disable=anomalous-backslash-in-string
-                    seq = int(exp.group(1))
-                    if seq >= 255:
-                        seq = 0
-                    else:
-                        seq = seq + 1
-                    self._send_sequence = seq
-                    json_str = exp.group(2)
-                    if json_str != lastdata:
-                        _LOGGER.debug("Sequence: %s Json: %s", seq, json_str)
-                        status_json = json.loads(json_str)
-                        self._receiverqueue.put(status_json)
-                        lastdata = json_str
-            except ConnectionError as connerr:
-                _LOGGER.error("Couldn't decode JSON (connection), skipping (%s)", repr(connerr))
-                self._client.shutdown(socket.SHUT_RDWR)
-                self._client.close()
-                self._lastclosed = time.time()
-                self.renew_connection()
-            except TimeoutError as timeouterr:
-                _LOGGER.error("Socket timed out, renewing connection (%s)", repr(timeouterr))
-                self._client.shutdown(socket.SHUT_RDWR)
-                self._client.close()
-                self._lastclosed = time.time()
-                self.renew_connection()
-            except AttributeError as atterr:
-                _LOGGER.error("Couldn't decode JSON (probably HELLO), skipping (%s)", repr(atterr))
-
-    @daemonthreaded
-    def poll_loop(self):
+    def poll_loop(self) -> None:
         """Main poll thread to receive updated messages from the unit."""
         #create the first connection
-        self.renew_connection()
+        self._connection.renew_connection()
         #start the receiver thread
-        self.receiver()
+        receiver = RinnaiReceiver(self._connection, self._receiverqueue)
+        receiver.receiver()
 
         #enter loop, wait for received (new) messages and push them to hass
         while True:
-            new_status = self._receiverqueue.get()
-            if new_status:
-                status = BrivisStatus()
-                res = self.handle_status(status, new_status)
+            new_status_json = self._receiverqueue.get()
+            if new_status_json:
+                status = RinnaiSystemStatus()
+                res = status.handle_status(new_status_json)
                 if res:
                     self._status = status
                     self._on_updated()
-
-    def renew_connection(self):
-        """Safely renew the connection if it is disconnected."""
-        connection_error = False
-        try:
-            if self._client is not None:
-                if (
-                    self._client.getpeername
-                    and self._client.getpeername() is not None
-                    and self._jsonerrors < 4
-                ):
-                    return True
-        except (OSError, ConnectionError) as ocerr:
-            _LOGGER.debug("Error 1st phase during renewConnection %s", ocerr)
-            connection_error = True
-
-        if (
-            self._client is None
-            or self._client._closed # pylint: disable=protected-access
-            or connection_error
-            or (self._jsonerrors > 2)
-        ):
-            try:
-                if connection_error or (self._jsonerrors > 2):
-                    self._client.close()
-                self._jsonerrors = 0
-                self.connect_to_touch(self._touch_ip,self._touch_port)
-                RinnaiSystem.clients[self._touch_ip] = self._client
-                _LOGGER.debug("Connected to %s", self._client.getpeername())
-                return True
-            except ConnectionRefusedError as crerr:
-                _LOGGER.debug("Error during renewConnection %s", crerr)
-            except ConnectionError as cerr:
-                _LOGGER.debug("Error during renewConnection %s", cerr)
-            except Exception as eerr: # pylint: disable=broad-except
-                _LOGGER.debug("Error during renewConnection %s", eerr)
-        return False
-
-    def connect_to_touch(self, touch_ip, port):
-        """Connect the client."""
-        # create an ipv4 (AF_INET) socket object using the tcp protocol (SOCK_STREAM)
-        _LOGGER.debug("Creating new client...")
-        try:
-            client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-            client.settimeout(10)
-            client.connect((touch_ip, port))
-            self._client = client
-            _LOGGER.debug("Client connection created: %s", self._client.getpeername())
-        except ConnectionRefusedError as crerr:
-            _LOGGER.debug("Client refused connection: %s", crerr)
-            raise crerr
-            #should really take a few hours break to recover!
-
-    def handle_status(self, brivis_status, status_json):
-        """Handle the JSON response from the system."""
-        # pylint: disable=too-many-branches,too-many-statements
-
-        try:
-            #jStr = status[14:]
-            #_LOGGER.debug(json.dumps(j[0], indent = 4))
-            cfg = get_attribute(status_json[0].get("SYST"),"CFG",None)
-            if not cfg:
-                # Probably an error
-                _LOGGER.error("No CFG - Not happy, Jan")
-
-            else:
-                if get_attribute(cfg, "TU", None) == "F":
-                    brivis_status.temp_unit = RinnaiSystem.TEMP_FAHRENHEIT
-                else:
-                    brivis_status.temp_unit = RinnaiSystem.TEMP_CELSIUS
-
-                brivis_status.is_multi_set_point = y_n_to_bool(get_attribute(cfg, "MTSP", None))
-                brivis_status.zone_a_desc = get_attribute(cfg, "ZA", None).strip()
-                brivis_status.zone_b_desc = get_attribute(cfg, "ZB", None).strip()
-                brivis_status.zone_c_desc = get_attribute(cfg, "ZC", None).strip()
-                brivis_status.zone_d_desc = get_attribute(cfg, "ZD", None).strip()
-                brivis_status.firmware_version = get_attribute(cfg, "VR", None).strip()
-                brivis_status.wifi_module_version = get_attribute(cfg, "CV", None).strip()
-
-            avm = get_attribute(status_json[0].get("SYST"),"AVM",None)
-            if not avm:
-                # Probably an error
-                _LOGGER.error("No AVM - Not happy, Jan")
-
-            else:
-                if get_attribute(avm, "HG", None) == "Y":
-                    brivis_status.has_heater = True
                 else:
-                    brivis_status.has_heater = False
-                if get_attribute(avm, "CG", None) == "Y":
-                    brivis_status.has_cooling = True
-                else:
-                    brivis_status.has_cooling = False
-                if get_attribute(avm, "EC", None) == "Y":
-                    brivis_status.has_evap = True
-                else:
-                    brivis_status.has_evap = False
-
-            flt = get_attribute(status_json[0].get("SYST"), "FLT", None)
-            if not flt:
-                # Probably an error
-                _LOGGER.error("No FLT - Not happy, Jan")
-
-            else:
-                brivis_status.has_fault = y_n_to_bool(get_attribute(flt, "AV", None))
-
-            parts = []
-            for part in status_json:
-                parts.extend(part.keys())
-
-            if 'HGOM' in parts:
-                handle_heating_mode(status_json,brivis_status)
-                brivis_status.set_mode(Mode.HEATING)
-                _LOGGER.debug("We are in HEAT mode")
-
-            elif 'CGOM' in parts:
-                handle_cooling_mode(status_json,brivis_status)
-                brivis_status.set_mode(Mode.COOLING)
-                _LOGGER.debug("We are in COOL mode")
-
-            elif 'ECOM' in parts:
-                handle_evap_mode(status_json,brivis_status)
-                brivis_status.set_mode(Mode.EVAP)
-                _LOGGER.debug("We are in EVAP mode")
+                    self._connection.log_json_error()
 
-            else:
-                _LOGGER.debug("Unknown mode")
-            return True
-        except Exception as err: # pylint: disable=broad-except
-            _LOGGER.error("Couldn't decode JSON (exception), skipping (%s)", repr(err))
-            self._jsonerrors = self._jsonerrors + 1
-            return False
 
-    async def set_cooling_mode(self):
+    async def set_cooling_mode(self) -> bool:
         """Set system to cooling mode."""
         return self.validate_and_send(MODE_COOL_CMD)
 
-    async def set_evap_mode(self):
+    async def set_evap_mode(self) -> bool:
         """Set system to evap mode."""
         return self.validate_and_send(MODE_EVAP_CMD)
 
-    async def set_heater_mode(self):
+    async def set_heater_mode(self) -> bool:
         """Set system to heater mode."""
         return self.validate_and_send(MODE_HEAT_CMD)
 
-    async def turn_heater_on(self):
-        """Turn heater on (and system)."""
-        return self.validate_and_send(HEAT_ON_CMD)
-
-    async def turn_heater_off(self):
-        """Turn heater off (and system)."""
-        return self.validate_and_send(HEAT_OFF_CMD)
-
-    async def turn_heater_fan_only(self):
-        """Turn circ fan on in heating mode while system is off."""
-        return self.validate_and_send(HEAT_CIRC_FAN_ON)
-
-    async def set_heater_temp(self, temp):
-        """Set target temperature in heating mode."""
-        cmd=HEAT_SET_TEMP
+    async def turn_unit_on(self) -> bool:
+        """Turn unit on (and system)."""
+        cmd = UNIT_ON_CMD
         if self.validate_command(cmd):
-            self.send_command(cmd.format(temp=temp))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
             return True
         return False
 
-    async def set_heater_auto(self):
-        """Set to auto mode in heater."""
-        return self.validate_and_send(HEAT_SET_AUTO)
-
-    async def set_heater_manual(self):
-        """Set to manual mode in heater."""
-        return self.validate_and_send(HEAT_SET_MANUAL)
-
-    async def heater_advance(self):
-        """Press advance button in heater mode."""
-        return self.validate_and_send(HEAT_ADVANCE)
-
-    async def heater_advance_cancel(self):
-        """Press advance cancel button in heater mode."""
-        return self.validate_and_send(HEAT_ADVANCE_CANCEL)
-
-    async def turn_heater_zone_on(self, zone):
-        """Turn a zone on in heating mode."""
-        cmd=HEAT_ZONE_ON
+    async def turn_heater_on(self) -> bool:
+        """Turn unit on (and system)."""
+        cmd = UNIT_ON_CMD
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(
+                cmd.format(unit_id=str(RinnaiUnitId.HEATER)))
             return True
         return False
 
-    async def turn_heater_zone_off(self, zone):
-        """Turn a zone off in heating mode."""
-        cmd=HEAT_ZONE_OFF
+    async def turn_cooler_on(self) -> bool:
+        """Turn unit on (and system)."""
+        cmd = UNIT_ON_CMD
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(
+                cmd.format(unit_id=str(RinnaiUnitId.COOLER)))
             return True
         return False
 
-    async def set_heater_zone_temp(self, zone, temp):
-        """Set target temperature for a zone in heating mode."""
-        cmd=HEAT_ZONE_SET_TEMP
+    async def turn_unit_off(self) -> bool:
+        """Turn unit off (and system)."""
+        cmd = UNIT_OFF_CMD
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone, temp=temp))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
             return True
         return False
 
-    async def set_heater_zone_auto(self, zone):
-        """Set zone to auto mode in heating."""
-        cmd=HEAT_ZONE_SET_AUTO
+    async def turn_unit_fan_only(self) -> bool:
+        """Turn circ fan on in while system is off."""
+        cmd = UNIT_CIRC_FAN_ON
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
             return True
         return False
 
-    async def set_heater_zone_manual(self, zone):
-        """Set zone to manual mode in heating."""
-        cmd=HEAT_ZONE_SET_MANUAL
+    async def set_unit_temp(self, temp: int) -> bool:
+        """Set target temperature."""
+        cmd=UNIT_SET_TEMP
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id,temp=temp))
             return True
         return False
 
-    async def set_heater_zone_advance(self, zone):
-        """Press zone advance button in heater mode."""
-        cmd=HEAT_ZONE_ADVANCE
+    async def set_unit_auto(self) -> bool:
+        """Set to auto mode."""
+        cmd = UNIT_SET_AUTO
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
             return True
         return False
 
-    async def set_heater_zone_advance_cancel(self, zone):
-        """Press zone advance cacnel button in heater mode."""
-        cmd=HEAT_ZONE_ADVANCE_CANCEL
+    async def set_unit_manual(self) -> bool:
+        """Set to manual mode."""
+        cmd = UNIT_SET_MANUAL
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
             return True
         return False
 
-    async def turn_cooling_on(self):
-        """Turn cooling on (and system)."""
-        return self.validate_and_send(COOL_ON_CMD)
-
-    async def turn_cooling_off(self):
-        """Turn cooling off (and system)."""
-        return self.validate_and_send(COOL_OFF_CMD)
-
-    async def turn_cooling_fan_only(self):
-        """Turn circ fan on (fan only) in cooling mode while system off."""
-        return self.validate_and_send(COOL_CIRC_FAN_ON)
-
-    async def set_cooling_temp(self, temp):
-        """Set main target temperature in cooling mode."""
-        cmd=COOL_SET_TEMP
+    async def unit_advance(self) -> bool:
+        """Press advance button."""
+        cmd = UNIT_ADVANCE
         if self.validate_command(cmd):
-            self.send_command(cmd.format(temp=temp))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
             return True
         return False
 
-    async def set_cooling_auto(self):
-        """Set auto mode in cooling."""
-        return self.validate_and_send(COOL_SET_AUTO)
-
-    async def set_cooling_manual(self):
-        """Set manual mode in cooling."""
-        return self.validate_and_send(COOL_SET_MANUAL)
-
-    async def cooling_advance(self):
-        """Press advance button in cooling mode."""
-        return self.validate_and_send(COOL_ADVANCE)
-
-    async def cooling_advance_cancel(self):
-        """Press advance cancel button in cooling mode."""
-        return self.validate_and_send(COOL_ADVANCE_CANCEL)
+    async def unit_advance_cancel(self) -> bool:
+        """Press advance cancel button."""
+        cmd = UNIT_ADVANCE_CANCEL
+        if self.validate_command(cmd):
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id))
+            return True
+        return False
 
-    async def turn_cooling_zone_on(self, zone):
-        """Turn zone on in cooling mode."""
-        cmd=COOL_ZONE_ON
+    async def turn_unit_zone_on(self, zone: str) -> bool:
+        """Turn a zone on."""
+        cmd=UNIT_ZONE_ON
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id,zone=zone))
             return True
         return False
 
-    async def turn_cooling_zone_off(self, zone):
-        """Turn zone off in cooling mode."""
-        cmd=COOL_ZONE_OFF
+    async def turn_unit_zone_off(self, zone: str) -> bool:
+        """Turn a zone off."""
+        cmd=UNIT_ZONE_OFF
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id,zone=zone))
             return True
         return False
 
-    async def set_cooling_zone_temp(self, zone, temp):
-        """Set zone target temperature in cooling."""
-        cmd=COOL_ZONE_SET_TEMP
+    async def set_unit_zone_temp(self, zone: str, temp: int) -> bool:
+        """Set target temperature for a zone."""
+        cmd=UNIT_ZONE_SET_TEMP
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone, temp=temp))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id, zone=zone, temp=temp))
             return True
         return False
 
-    async def set_cooling_zone_auto(self, zone):
-        """Set zone to auto mode in cooling."""
-        cmd=COOL_ZONE_SET_AUTO
+    async def set_unit_zone_auto(self, zone: str) -> bool:
+        """Set zone to auto mode."""
+        cmd=UNIT_ZONE_SET_AUTO
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id, zone=zone))
             return True
         return False
 
-    async def set_cooling_zone_manual(self, zone):
-        """Set zone to manual mode in cooling."""
-        cmd=COOL_ZONE_SET_MANUAL
+    async def set_unit_zone_manual(self, zone: str) -> bool:
+        """Set zone to manual mode."""
+        cmd=UNIT_ZONE_SET_MANUAL
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id, zone=zone))
             return True
         return False
 
-    async def set_cooling_zone_advance(self, zone):
-        """Press advance button in a zone in cooling mode."""
-        cmd=COOL_ZONE_ADVANCE
+    async def set_unit_zone_advance(self, zone: str) -> bool:
+        """Press zone advance button."""
+        cmd=UNIT_ZONE_ADVANCE
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id, zone=zone))
             return True
         return False
 
-    async def set_cooling_zone_advance_cancel(self, zone):
-        """Press advance cancel button in a zone in cooling mode."""
-        cmd=COOL_ZONE_ADVANCE_CANCEL
+    async def set_unit_zone_advance_cancel(self, zone: str) -> bool:
+        """Press zone advance cacnel button."""
+        cmd=UNIT_ZONE_ADVANCE_CANCEL
         if self.validate_command(cmd):
-            self.send_command(cmd.format(zone=zone))
+            self.send_command(cmd.format(unit_id=self._status.unit_status.unit_id, zone=zone))
             return True
         return False
 
-    async def turn_evap_on(self):
+    async def turn_evap_on(self) -> bool:
         """Turn on evap (and system)."""
         return self.validate_and_send(EVAP_ON_CMD)
 
-    async def turn_evap_off(self):
+    async def turn_evap_off(self) -> bool:
         """Turn off evap (and system)."""
         return self.validate_and_send(EVAP_OFF_CMD)
 
-    async def turn_evap_pump_on(self):
+    async def turn_evap_pump_on(self) -> bool:
         """Turn water pump on in evap mode."""
         return self.validate_and_send(EVAP_PUMP_ON)
 
-    async def turn_evap_pump_off(self):
+    async def turn_evap_pump_off(self) -> bool:
         """Turn water pump off in evap mode."""
         return self.validate_and_send(EVAP_PUMP_OFF)
 
-    async def turn_evap_fan_on(self):
+    async def turn_evap_fan_on(self) -> bool:
         """Turn fan on in evap mode."""
         return self.validate_and_send(EVAP_FAN_ON)
 
-    async def turn_evap_fan_off(self):
+    async def turn_evap_fan_off(self) -> bool:
         """Turn fan off in evap mode."""
         return self.validate_and_send(EVAP_FAN_OFF)
 
-    async def set_evap_auto(self):
-        """Set to auto mode in evap."""
-        return self.validate_and_send(EVAP_SET_AUTO)
-
-    async def set_evap_manual(self):
-        """Set to manual mode in evap."""
-        return self.validate_and_send(EVAP_SET_MANUAL)
-
-    async def set_evap_fanspeed(self, speed):
+    async def set_evap_fanspeed(self, speed: int) -> bool:
         """Set fan speed in evap mode."""
         cmd=EVAP_FAN_SPEED
         if self.validate_command(cmd):
@@ -660,23 +296,16 @@ async def set_evap_fanspeed(self, speed):
             return True
         return False
 
-    async def set_heater_fanspeed(self, speed):
-        """Set fan speed in heater mode."""
-        cmd=HEAT_CIRC_FAN_SPEED
+    async def set_unit_fanspeed(self, speed: int) -> bool:
+        """Set fan speed."""
+        cmd=UNIT_CIRC_FAN_SPEED
         if self.validate_command(cmd):
-            self.send_command(cmd.format(speed=f'{speed:02d}'))
+            self.send_command(
+                cmd.format(unit_id=self._status.unit_status.unit_id, peed=f'{speed:02d}'))
             return True
         return False
 
-    async def set_cooling_fanspeed(self, speed):
-        """Set fan speed in cooling mode."""
-        cmd=COOL_CIRC_FAN_SPEED
-        if self.validate_command(cmd):
-            self.send_command(cmd.format(speed=f'{speed:02d}'))
-            return True
-        return False
-
-    async def set_evap_comfort(self, comfort):
+    async def set_evap_comfort(self, comfort: int) -> bool:
         """Set comfort level in Evap auto mode."""
         cmd=EVAP_SET_COMFORT
         if self.validate_command(cmd):
@@ -684,7 +313,7 @@ async def set_evap_comfort(self, comfort):
             return True
         return False
 
-    async def turn_evap_zone_on(self, zone):
+    async def turn_evap_zone_on(self, zone: str) -> bool:
         """Turn zone off in Evap mode."""
         cmd=EVAP_ZONE_ON
         if self.validate_command(cmd):
@@ -692,7 +321,7 @@ async def turn_evap_zone_on(self, zone):
             return True
         return False
 
-    async def turn_evap_zone_off(self, zone):
+    async def turn_evap_zone_off(self, zone: str) -> bool:
         """Turn zone off in Evap mode."""
         cmd=EVAP_ZONE_OFF
         if self.validate_command(cmd):
@@ -700,7 +329,7 @@ async def turn_evap_zone_off(self, zone):
             return True
         return False
 
-    async def set_evap_zone_auto(self, zone):
+    async def set_evap_zone_auto(self, zone: str) -> bool:
         """Set zone to Auto mode on Evap."""
         cmd=EVAP_ZONE_SET_AUTO
         if self.validate_command(cmd):
@@ -708,7 +337,7 @@ async def set_evap_zone_auto(self, zone):
             return True
         return False
 
-    async def set_evap_zone_manual(self, zone):
+    async def set_evap_zone_manual(self, zone: str) -> bool:
         """Set zone to manual mode on Evap."""
         cmd=EVAP_ZONE_SET_MANUAL
         if self.validate_command(cmd):
@@ -716,56 +345,47 @@ async def set_evap_zone_manual(self, zone):
             return True
         return False
 
-    def get_stored_status(self):
+    def get_stored_status(self) -> RinnaiSystemStatus:
         """Get the current status without a refresh."""
         return self._status
 
-    def validate_command(self, cmd):
+    def validate_command(self, cmd: str) -> bool:
         """Validate a command is appropriat to the current operating mode."""
         if cmd in MODE_COMMANDS:
             return True
-        if cmd in HEAT_COMMANDS and self._status.heater_mode:
-            return True
-        if cmd in COOL_COMMANDS and self._status.cooling_mode:
+        if cmd in UNIT_COMMANDS \
+            and self._status.mode in (RinnaiSystemMode.HEATING, RinnaiSystemMode.COOLING):
             return True
-        if cmd in EVAP_COMMANDS and self._status.evap_mode:
+        if cmd in EVAP_COMMANDS and self._status.mode == RinnaiSystemMode.EVAP:
             return True
         return False
 
-    def send_command(self, cmd):
+    def send_command(self, cmd: str) -> None:
         """Send the command to the unit."""
-        seq = str(self._send_sequence).zfill(6)
-        #self._sendSequence = self._sendSequence + 1
-        _LOGGER.debug("Sending command: %s", "N" + seq + cmd)
-        self.send_to_touch("N" + seq + cmd)
+        self._connection.dispatch_command(cmd)
 
-    def validate_and_send(self, cmd):
+    def validate_and_send(self, cmd: str) -> bool:
         """Validate and send a command."""
         if self.validate_command(cmd):
             self.send_command(cmd)
             return True
-        _LOGGER.error("Validation of command failed. Not sending")
+        _LOGGER.error("Validation of command failed. Not sending. CMD: %s, Mode: %s", \
+            cmd, self._status.mode)
         return False
 
-    def get_status(self):
+    def get_status(self) -> RinnaiSystemStatus:
         """Retrieve initial empty status from the unit."""
-        if self.renew_connection():
-            _LOGGER.debug("Client Variable: %s / %s", self._client, self._client._closed) # pylint: disable=protected-access
+        if self._connection.renew_connection():
+            _LOGGER.debug("Client Connection: %s", self._connection) # pylint: disable=protected-access
             self.poll_loop()
         else:
             _LOGGER.debug("renewing connection failed, ooops")
 
         return self._status
 
-    async def async_will_remove_from_hass(self):
+    async def async_will_remove_from_hass(self) -> None:
         """Call this when removing the integration from home assistant."""
         try:
-            self._client.shutdown(socket.SHUT_RDWR)
-            self._client.close()
+            self._connection.shutdown()
         except: # pylint: disable=bare-except
             _LOGGER.debug("Nothing to close")
-
-    def send_to_touch(self, cmd):
-        """Send the command."""
-        #_LOGGER.debug("DEBUG: {}".format(cmd))
-        self._senderqueue.put(cmd.encode())
diff --git a/pyrinnaitouch/system_status.py b/pyrinnaitouch/system_status.py
new file mode 100644
index 0000000..546b2b0
--- /dev/null
+++ b/pyrinnaitouch/system_status.py
@@ -0,0 +1,143 @@
+"""Main system status"""
+import logging
+from dataclasses import dataclass
+from typing import Any, Dict, Optional
+
+from .unit_status import RinnaiUnitStatus
+from .const import (
+    CAPABILITIES,
+    CONFIGURATION,
+    COOLING_ADDON,
+    COOLING_EVAPORATIVE,
+    FAULT_DETECTED,
+    FAULT_INFO,
+    FIRMWARE_VERSION,
+    GAS_HEATING,
+    MAIN_ZONES,
+    MODULE_ENABLED,
+    MULTI_SET_POINT,
+    SYSTEM,
+    TEMPERATURE_UNIT,
+    UNIT_FAHRENHEIT,
+    WIFI_MODULE_VERSION,
+    RinnaiCapabilities,
+    RinnaiSystemMode,
+    TEMP_CELSIUS,
+    TEMP_FAHRENHEIT,
+    RinnaiUnitId
+    )
+from .util import UnknownModeException, get_attribute, y_n_to_bool
+
+_LOGGER = logging.getLogger(__name__)
+
+@dataclass
+class RinnaiSystemStatus():
+    """Overall Class for describing status"""
+    # pylint: disable=too-many-instance-attributes
+
+    def __init__(self) -> None:
+        self.mode: RinnaiSystemMode = RinnaiSystemMode.NONE
+        self.system_on: bool = False
+        self.temp_unit: str = TEMP_CELSIUS
+        self.capabilities: RinnaiCapabilities = RinnaiCapabilities.NONE
+        self.unit_status: RinnaiUnitStatus = RinnaiUnitStatus()
+
+        #system info
+        self.firmware_version: Optional[str] = None
+        self.wifi_module_version: Optional[str] = None
+
+        #zones
+        self.zone_descriptions: Dict[str, Optional[str]] = {}
+
+        self.is_multi_set_point: bool = False
+
+        #faults
+        self.has_fault: bool = False
+
+    def handle_status(self, status_json: Any) -> bool:
+        """Handle the JSON response from the system."""
+        try:
+            #_LOGGER.debug(json.dumps(j[0], indent = 4))
+            self.set_config(get_attribute(status_json[0].get(SYSTEM), CONFIGURATION,None))
+            self.set_capabilities(get_attribute(status_json[0].get(SYSTEM), CAPABILITIES,None))
+            self.set_fault(get_attribute(status_json[0].get(SYSTEM), FAULT_INFO, None))
+
+            parts = []
+            for part in status_json:
+                parts.extend(part.keys())
+
+            if str(RinnaiUnitId.HEATER) in parts:
+                capability = RinnaiCapabilities.HEATER
+                self.mode = RinnaiSystemMode.HEATING
+                _LOGGER.debug("We are in HEAT mode")
+
+            elif str(RinnaiUnitId.COOLER) in parts:
+                capability = RinnaiCapabilities.COOLER
+                self.mode = RinnaiSystemMode.COOLING
+                _LOGGER.debug("We are in COOL mode")
+
+            elif str(RinnaiUnitId.EVAP) in parts:
+                capability = RinnaiCapabilities.EVAP
+                self.mode = RinnaiSystemMode.EVAP
+                _LOGGER.debug("We are in EVAP mode")
+
+            else:
+                _LOGGER.debug("Unknown mode")
+                raise UnknownModeException("Unknown mode, this is not going well.")
+
+            self.unit_status = RinnaiUnitStatus()
+            self.unit_status.handle_status(
+                capability,
+                self.is_multi_set_point,
+                self.set_system_status,
+                status_json
+                )
+            return True
+
+        except Exception as err: # pylint: disable=broad-except
+            _LOGGER.error("Couldn't decode JSON (exception), skipping (%s)", repr(err))
+            return False
+
+    def set_system_status(self, is_on: bool) -> None:
+        """Set on/off status for the entire system"""
+        self.system_on = is_on
+
+    def set_config(self, cfg):
+        """Parse and set system configuration."""
+        if not cfg:
+            # Probably an error
+            _LOGGER.error("No CFG - Not happy, Jan")
+        else:
+            if get_attribute(cfg, TEMPERATURE_UNIT, None) == UNIT_FAHRENHEIT:
+                self.temp_unit = TEMP_FAHRENHEIT
+            else:
+                self.temp_unit = TEMP_CELSIUS
+
+            self.is_multi_set_point = y_n_to_bool(get_attribute(cfg, MULTI_SET_POINT, None))
+            for zone in MAIN_ZONES:
+                self.zone_descriptions[zone] = get_attribute(cfg, "Z" + zone, None).strip()
+
+            self.firmware_version = get_attribute(cfg, FIRMWARE_VERSION, None).strip()
+            self.wifi_module_version = get_attribute(cfg, WIFI_MODULE_VERSION, None).strip()
+
+    def set_fault(self, flt) -> None:
+        """Parse and set fault state."""
+        if not flt:
+            # Probably an error
+            _LOGGER.error("No FLT - Not happy, Jan")
+        else:
+            self.has_fault = y_n_to_bool(get_attribute(flt, FAULT_DETECTED, None))
+
+    def set_capabilities(self, avm: Any) -> None:
+        """Parse and set system capabilities, e.g. heater, cooler, evap."""
+        if not avm:
+                # Probably an error
+            _LOGGER.error("No AVM - Not happy, Jan")
+        else:
+            self.capabilities = RinnaiCapabilities.NONE
+            if get_attribute(avm, GAS_HEATING, None) == MODULE_ENABLED:
+                self.capabilities |= RinnaiCapabilities.HEATER
+            if get_attribute(avm, COOLING_ADDON, None) == MODULE_ENABLED:
+                self.capabilities |= RinnaiCapabilities.COOLER
+            if get_attribute(avm, COOLING_EVAPORATIVE, None) == MODULE_ENABLED:
+                self.capabilities |= RinnaiCapabilities.EVAP
diff --git a/pyrinnaitouch/unit_status.py b/pyrinnaitouch/unit_status.py
new file mode 100644
index 0000000..7c7b77b
--- /dev/null
+++ b/pyrinnaitouch/unit_status.py
@@ -0,0 +1,331 @@
+"""Unit status handling"""
+import logging
+from typing import Any, Callable, Dict, Optional
+
+from .const import (
+    ADVANCE_PERIOD,
+    ADVANCED,
+    ALL_ZONES,
+    AUTO_ENABLED,
+    CONFIGURATION,
+    COOLER_BUSY,
+    FAN_SPEED_LEVEL,
+    FAN_STATE,
+    GENERAL_SYSTEM_OPERATION,
+    GENERAL_SYSTEM_STATUS,
+    MEASURED_TEMPERATURE,
+    MODE_AUTO,
+    MODE_MANUAL,
+    OPERATING_PROGRAM,
+    OPERATING_STATE,
+    OVERALL_OPERATION,
+    PREHEATING,
+    PREWETTING,
+    PUMP_STATE,
+    SCHEDULE_OVERRIDE,
+    SCHEDULE_PERIOD,
+    SET_POINT,
+    STATE_FAN_ONLY,
+    STATE_OFF,
+    STATE_ON,
+    SWITCH_STATE,
+    USER_ENABLED,
+    RinnaiCapabilities,
+    RinnaiOperatingMode,
+    RinnaiSchedulePeriod,
+    RinnaiUnitId
+    )
+
+from .util import get_attribute, symbol_to_schedule_period, y_n_to_bool
+from .zone import Zone
+
+_LOGGER = logging.getLogger(__name__)
+
+class RinnaiUnitStatus():
+    """Handle and represent the status of the unit, e.g. heater, cooler, evap"""
+    # pylint: disable=too-many-instance-attributes
+
+    def __init__(self) -> None:
+        self.capability: RinnaiCapabilities = RinnaiCapabilities.NONE
+        self.unit_id: Optional[str] = None
+        self.is_on: bool = False
+        self.fan_speed: int = 0
+        self.circulation_fan_on: bool = False
+        self.operating_mode: RinnaiOperatingMode = RinnaiOperatingMode.NONE
+        self.set_temp: int = 0
+        self.comfort: int = 0
+        self.temperature: int = 999
+        self.preheating: bool = False
+        self.schedule_period: RinnaiSchedulePeriod = RinnaiSchedulePeriod.NONE
+        self.advance_period: RinnaiSchedulePeriod = RinnaiSchedulePeriod.NONE
+        self.advanced: bool = False
+        self.fan_on: bool = False
+        self.water_pump_on: bool = False
+        self.prewetting: bool = False
+        self.cooler_busy: bool = False
+        self.zones: Dict[object] = {}
+
+    def set_mode(self,mode: str) -> None:
+        """Set auto/manual mode."""
+        # A = Auto Mode and M = Manual Mode
+        if mode == MODE_AUTO:
+            self.operating_mode = RinnaiOperatingMode.AUTO
+        else:
+            self.operating_mode = RinnaiOperatingMode.MANUAL
+
+    def set_circulation_fan_on(self,status_str: str) -> None:
+        """Set circ fan state."""
+        # Z = On, N = Off
+        if status_str == STATE_FAN_ONLY:
+            self.circulation_fan_on = True
+        else:
+            self.circulation_fan_on = False
+
+    def set_advanced(self,status_str: str) -> None:
+        """Set advanced state."""
+        # A = Advance, N = None, O = Operation (what is that?)
+        if status_str == ADVANCED:
+            self.advanced = True
+        else:
+            self.advanced = False
+
+    def set_fan(self,status_str: str) -> None:
+        """Set fan state."""
+        # N = On, F = Off
+        if status_str == STATE_ON:
+            self.fan_on = True
+        else:
+            self.fan_on = False
+
+    def set_fan_speed(self,speed_int: int) -> None:
+        """Set fan speed."""
+        self.fan_speed = speed_int
+
+    def set_water_pump(self,status_str: str) -> None:
+        """Set water pump state."""
+        # N = On, F = Off
+        if status_str == STATE_ON:
+            self.water_pump_on = True
+        else:
+            self.water_pump_on = False
+
+    def set_comfort(self, comfort: int) -> None:
+        """Set target comfort level."""
+        self.comfort = comfort
+
+    def handle_status(
+            self,
+            capability: RinnaiCapabilities,
+            is_multi_set_point: bool,
+            set_parent_status: Callable,
+            status_json: Any
+        ) -> None:
+        """Parse operational part of JSON."""
+        self.set_capability(capability)
+        self.set_config(get_attribute(status_json[1].get(self.unit_id),CONFIGURATION,None))
+
+        if capability == RinnaiCapabilities.EVAP:
+            self.parse_evap_gso(set_parent_status, status_json)
+        else:
+            oop = get_attribute(status_json[1].get(self.unit_id),OVERALL_OPERATION,None)
+            if not oop:
+                # Probably an error
+                _LOGGER.error("No OOP - Not happy, Jan")
+
+            else:
+                self.schedule_period = RinnaiSchedulePeriod.NONE
+                self.advance_period = RinnaiSchedulePeriod.NONE
+                self.advanced = False
+
+                switch = get_attribute(oop,OPERATING_STATE,None)
+                if switch == STATE_ON:
+                    _LOGGER.debug("Unit is ON")
+                    set_parent_status(True)
+                    self.is_on = True
+                    self.set_circulation_fan_on(switch)
+
+                    # Heater is on - get attributes
+                    fan_speed = get_attribute(oop,FAN_SPEED_LEVEL,None)
+                    _LOGGER.debug("Fan Speed is: %s", fan_speed)
+                    self.fan_speed = int(fan_speed) # Should catch errors!
+
+                    if is_multi_set_point:
+                        pass
+                    else:
+                        # GSO should be there for single set point
+                        self.parse_standard_gso(status_json)
+
+                elif switch == STATE_OFF:
+                    # Unit is off
+                    _LOGGER.debug("Unit is OFF")
+                    set_parent_status(False)
+                    self.is_on = False
+                    self.set_circulation_fan_on(switch)
+
+                elif switch == STATE_FAN_ONLY:
+                    _LOGGER.debug("Circulation Fan is: %s", switch)
+                    set_parent_status(True)
+                    self.is_on = False
+                    self.set_circulation_fan_on(switch)
+
+                    fan_speed = get_attribute(oop,FAN_SPEED_LEVEL,None)
+                    _LOGGER.debug("Fan Speed is: %s", fan_speed)
+                    self.fan_speed = int(fan_speed) # Should catch errors!
+
+                # Single Point
+                # ZXO => user enabled
+                # ZXS => *MT (temp) and *AE (Auto enabled (calling for heat))
+                # Multi Point
+                # ZXO => user enabled for Fan_Only, OP (auto/manual),
+                #        SP (set_temp), AO (schedule override)
+                # ZXS => *AE (calling for heat), FS (fan active), PH (preheat), *MT (temp),
+                #         AT (schedule_period), AZ (advance_period)
+                for zoneid in ALL_ZONES:
+                    zone = get_attribute(status_json[1].get(self.unit_id),"Z"+zoneid+"O",None)
+                    self.parse_zone_operation(is_multi_set_point, zoneid, zone)
+
+                    zone = get_attribute(status_json[1].get(self.unit_id),"Z"+zoneid+"S",None)
+                    self.parse_zone_state(is_multi_set_point, zoneid, zone)
+
+    def parse_zone_state(self, is_multi_set_point: bool, zoneid: str, zone: Any) -> None:
+        """Parse Zone Status"""
+        if zone and zoneid in self.zones.keys(): # pylint: disable=consider-iterating-dictionary
+                        # these ones are common
+            self.zones[zoneid].auto_mode = y_n_to_bool(get_attribute(zone,AUTO_ENABLED,None))
+            self.zones[zoneid].temperature = get_attribute(zone,MEASURED_TEMPERATURE, 999)
+                        # these ones are multi only
+            if is_multi_set_point:
+                self.zones[zoneid].advance_period = \
+                                symbol_to_schedule_period(get_attribute(zone,ADVANCE_PERIOD,None))
+                self.zones[zoneid].schedule_period = \
+                                symbol_to_schedule_period(get_attribute(zone,SCHEDULE_PERIOD,None))
+
+    def parse_zone_operation(self, is_multi_set_point: bool, zoneid: str, zone: Any) -> None:
+        """Parse Zone Settings"""
+        if zone and zoneid in self.zones.keys(): # pylint: disable=consider-iterating-dictionary
+                        # this one is single set point and multi set point with fan only
+            if is_multi_set_point or self.circulation_fan_on:
+                self.zones[zoneid].user_enabled = y_n_to_bool(get_attribute(zone,USER_ENABLED,None))
+                        # these ones are multi only
+            if is_multi_set_point:
+                self.zones[zoneid].set_temp = get_attribute(zone,SET_POINT, 999)
+                self.zones[zoneid].set_advanced(get_attribute(zone,SCHEDULE_OVERRIDE, None))
+                self.zones[zoneid].set_mode(get_attribute(zone,OPERATING_PROGRAM,None))
+
+    def parse_standard_gso(self, status_json: Any) -> None:
+        """Parse the GSO part of the JSON for heaters and coolers"""
+        gso = get_attribute(status_json[1].get(self.unit_id),GENERAL_SYSTEM_OPERATION,None)
+        if not gso:
+            # Probably an error
+            _LOGGER.error("No GSO when heater on. Not happy, Jan")
+        else:
+            # Unit is on - get attributes
+            op_mode = get_attribute(gso,OPERATING_PROGRAM,None)
+            _LOGGER.debug("Unit OpMode is: %s", op_mode) # A = Auto, M = Manual
+            self.set_mode(op_mode)
+
+            set_temp = get_attribute(gso,SET_POINT,None)
+            _LOGGER.debug("Unit set temp is: %s", set_temp)
+            self.set_temp = int(set_temp)
+
+            self.set_advanced(get_attribute(gso,SCHEDULE_OVERRIDE,None))
+
+            gss = get_attribute(status_json[1].get(self.unit_id),GENERAL_SYSTEM_STATUS,None)
+            if not gss:
+                _LOGGER.error("No GSS here")
+            else:
+                if self.capability == RinnaiCapabilities.HEATER:
+                    preheat = y_n_to_bool(get_attribute(gss,PREHEATING,False))
+                    self.preheating = preheat
+                period = symbol_to_schedule_period(get_attribute(gss,SCHEDULE_PERIOD,None))
+                self.schedule_period = period
+                period = symbol_to_schedule_period(get_attribute(gss,ADVANCE_PERIOD,None))
+                self.advance_period = period
+
+    def parse_evap_gso(
+            self,
+            set_parent_status: Callable,
+            status_json: Any
+            ) -> None:
+        """Parse the GSO part of the JSON for evaps"""
+        # no multi, there's always a GSO
+        gso = get_attribute(status_json[1].get(self.unit_id),GENERAL_SYSTEM_OPERATION,None)
+        if not gso:
+            _LOGGER.error("No GSO here")
+        else:
+            switch = get_attribute(gso,SWITCH_STATE, None)
+            if switch == STATE_ON:
+                opmode = get_attribute(gso, OPERATING_PROGRAM, None)
+                self.set_mode(opmode)
+
+                _LOGGER.debug("Unit is ON")
+                set_parent_status(True)
+                self.is_on = True
+
+                if opmode == MODE_MANUAL:
+                    # Evap is on and manual - what is the fan speed
+                    evap_fan = get_attribute(gso,FAN_STATE,None)
+                    _LOGGER.debug("Fan is: %s", evap_fan)
+                    self.set_fan(evap_fan)
+
+                    fan_speed = get_attribute(gso,FAN_SPEED_LEVEL,None)
+                    _LOGGER.debug("Fan Speed is: %s", fan_speed)
+                    self.set_fan_speed(int(fan_speed))
+
+                    water_pump = get_attribute(gso,PUMP_STATE,None)
+                    _LOGGER.debug("Water Pump is: %s", water_pump)
+                    self.set_water_pump(water_pump)
+
+                else:
+                    # Evap is on and auto - look for comfort level
+                    comfort = get_attribute(gso, SET_POINT, 0)
+                    _LOGGER.debug("Comfort Level is: %s", comfort)
+                    self.set_comfort(comfort)
+
+                for zoneid in ALL_ZONES:
+                    if zoneid in self.zones.keys(): # pylint: disable=consider-iterating-dictionary
+                        self.zones[zoneid].user_enabled = \
+                            y_n_to_bool(get_attribute(gso,"Z"+zoneid+USER_ENABLED,False))
+
+
+                gss = get_attribute(status_json[1].get(self.unit_id),GENERAL_SYSTEM_STATUS,None)
+                if not gss:
+                    _LOGGER.error("No GSS here")
+                else:
+                    for zoneid in ALL_ZONES:
+                        if zoneid in self.zones.keys(): # pylint: disable=consider-iterating-dictionary
+                            self.zones[zoneid].auto_mode = \
+                                y_n_to_bool(get_attribute(gss,"Z"+zoneid+AUTO_ENABLED,False))
+
+                    self.prewetting = y_n_to_bool(get_attribute(gss,PREWETTING,False))
+                    self.cooler_busy = y_n_to_bool(get_attribute(gss,COOLER_BUSY,False))
+
+
+            elif switch == STATE_OFF:
+                # Evap is off
+                _LOGGER.debug("Unit is OFF")
+                set_parent_status(False)
+                self.is_on = False
+
+    def set_config(self, cfg: Any) -> None:
+        """Set the zone configuration"""
+        if not cfg:
+            # Probably an error
+            _LOGGER.error("No CFG - Not happy, Jan")
+
+        else:
+            for zoneid in ALL_ZONES:
+                if y_n_to_bool(get_attribute(cfg, "Z"+zoneid+"IS", None)):
+                    self.zones[zoneid] = Zone(zoneid)
+
+    def set_capability(self, capability: RinnaiCapabilities) -> None:
+        """Set the unit type"""
+        self.capability = capability
+        if self.capability == RinnaiCapabilities.COOLER:
+            self.unit_id = str(RinnaiUnitId.COOLER)
+        elif self.capability == RinnaiCapabilities.HEATER:
+            self.unit_id = str(RinnaiUnitId.HEATER)
+        elif self.capability == RinnaiCapabilities.EVAP:
+            self.unit_id = str(RinnaiUnitId.EVAP)
+        else:
+            self.unit_id = None
diff --git a/pyrinnaitouch/util.py b/pyrinnaitouch/util.py
index 4bcae2c..3c7fd9a 100644
--- a/pyrinnaitouch/util.py
+++ b/pyrinnaitouch/util.py
@@ -1,37 +1,48 @@
 ﻿"""Utility functions"""
-import logging
-from enum import Enum
+import threading
+from typing import Any, Callable
+from .const import RinnaiSchedulePeriod
 
-_LOGGER = logging.getLogger(__name__)
-
-def get_attribute(data, attribute, default_value):
+def get_attribute(data: Any, attribute: str, default_value: Any) -> Any:
     """get json attriubte from data."""
     return data.get(attribute) or default_value
 
-def y_n_to_bool(str_arg):
+def y_n_to_bool(str_arg: str) -> bool:
     """Convert Rinnai YN to Bool"""
     if str_arg == "Y":
         return True
     return False
 
-class SchedulePeriod(Enum):
-    """Define system schedule time periods."""
-    WAKE = "W"
-    LEAVE = "L"
-    RETURN = "R"
-    PRE_SLEEP = "P"
-    SLEEP = "S"
-
-def symbol_to_schedule_period(symbol):
+def symbol_to_schedule_period(symbol: str) -> RinnaiSchedulePeriod:
     """Convert JSON symbol to schedule time periods."""
     if symbol == "W":
-        return SchedulePeriod.WAKE
+        return RinnaiSchedulePeriod.WAKE
     if symbol == "L":
-        return SchedulePeriod.LEAVE
+        return RinnaiSchedulePeriod.LEAVE
     if symbol == "R":
-        return SchedulePeriod.RETURN
+        return RinnaiSchedulePeriod.RETURN
     if symbol == "P":
-        return SchedulePeriod.PRE_SLEEP
+        return RinnaiSchedulePeriod.PRE_SLEEP
     if symbol == "S":
-        return SchedulePeriod.SLEEP
-    return None
+        return RinnaiSchedulePeriod.SLEEP
+    return RinnaiSchedulePeriod.NONE
+
+def daemonthreaded(function_arg: Callable) -> Callable:
+    """Decoration to start object function as thread"""
+    def wrapper(*args, **kwargs) -> threading.Thread:
+        thread = threading.Thread(target=function_arg, args=args, kwargs=kwargs)
+        thread.daemon = True
+        thread.start()
+        return thread
+    return wrapper
+
+class UnknownModeException(Exception):
+    """Exception to catch system being in an unknown mode"""
+
+    # Constructor or Initializer
+    def __init__(self, value):
+        self.value = value
+
+    # __str__ is to print() the value
+    def __str__(self):
+        return repr(self.value)
diff --git a/pyrinnaitouch/zone.py b/pyrinnaitouch/zone.py
new file mode 100644
index 0000000..259985a
--- /dev/null
+++ b/pyrinnaitouch/zone.py
@@ -0,0 +1,35 @@
+"""Class to define the properties of a climate zone"""
+
+from .const import ADVANCED, MODE_AUTO, MODE_MANUAL
+
+
+class Zone():
+    """Class to define the properties of a climate zone"""
+
+    def __init__(self, name: str) -> None:
+        self.name = name
+
+    name = ""
+    temperature = 999
+    set_temp = 0 # < 8 means off
+    schedule_period = None
+    advance_period = None
+    advanced = False
+    user_enabled = False # applies only to fan_only
+    auto_mode = False
+
+    def set_mode(self, mode: str) -> None:
+        """Set auto/manual mode."""
+        # A = Auto Mode and M = Manual Mode
+        if mode == MODE_AUTO:
+            self.auto_mode = True
+        elif mode == MODE_MANUAL:
+            self.auto_mode = False
+
+    def set_advanced(self, status_str: str) -> None:
+        """Set advanced state."""
+        # A = Advance, N = None, O = Operation (what is that?)
+        if status_str == ADVANCED:
+            self.advanced = True
+        else:
+            self.advanced = False
diff --git a/setup.py b/setup.py
index df7fe00..d3a6e03 100644
--- a/setup.py
+++ b/setup.py
@@ -8,20 +8,20 @@
 setup(
     name="pyrinnaitouch",
     packages=find_packages(exclude=["tests", "tests.*"]),
-    version="0.10.5",
+    version="0.12.1",
     license="mit",
     description="A python interface to the Rinnai Touch Wifi controller",
     long_description=LONG_DESCRIPTION,
     long_description_content_type="text/markdown",
     author="Funtastix",
     url="https://github.com/funtastix/pyrinnaitouch",
-    download_url="https://github.com/funtastix/pyrinnaitouch/archive/refs/tags/v0.10.5.tar.gz",
+    download_url="https://github.com/funtastix/pyrinnaitouch/archive/refs/tags/v0.12.1.tar.gz",
     keywords=[
         "Rinnai Touch",
         "Brivis",
         "IoT",
     ],
-    python_requires=">=3.7",
+    python_requires=">=3.9",
     tests_require=["pytest"],
     install_requires=[
         "asyncio",


Test patch:
diff --git a/tests/test_system_parse.py b/tests/test_system_parse.py
index 65175fe..f1f9875 100644
--- a/tests/test_system_parse.py
+++ b/tests/test_system_parse.py
@@ -1,4 +1,4 @@
-from pyrinnaitouch.system import BrivisStatus, RinnaiSystem
+from pyrinnaitouch.system import RinnaiSystem
 
 def get_test_json():
     """Fixture to return a sample JSON response str"""
@@ -6,5 +6,4 @@ def get_test_json():
 
 def test_zones(self):
     system = RinnaiSystem.getInstance("10.0.0.1")
-    system.setZones(["A", "B"])
-    assert "A" in system._zones and "B" in system._zones
+    assert system