Repository: mill1000/midea-msmart (python)

Issue:
Control Support for CLI
Hi,
any chance to add control support for the CLI tool?
Currently only discover and query is possible but not control the device via CLI.

Kind Regards

Pull request description:
Add control capability to CLI
Add new `control` command to the CLI which enables setting device properties.

Basic syntax is as follows:
```
msmart-ng control DEVICE_IP setting1=value1 setting2=value2
```

The CLI will attempt to convert the incoming values to the correct types. 

- Enum types can use the number and/or the name. 
- Booleans can be written as true/false or True/False


Example
```
msmart-ng control DEVICE_IP power_state=True fan_speed=20 target_temperature=25 operational_mode=heat display_on=True
```

Close #96 

Patch:
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 8965acd..4870083 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -1,5 +1,5 @@
 {
-  "image": "mcr.microsoft.com/devcontainers/python:3.11",
+  "image": "mcr.microsoft.com/devcontainers/python:3.12",
   "features": {
     "ghcr.io/devcontainers/features/git:1": {}
   },
diff --git a/.github/workflows/prune-ghcr.yml b/.github/workflows/prune-ghcr.yml
index 3ffd8aa..9759d40 100644
--- a/.github/workflows/prune-ghcr.yml
+++ b/.github/workflows/prune-ghcr.yml
@@ -36,8 +36,6 @@ jobs:
         with:
           container: ${{ env.CONTAINER_NAME }}
           token: ${{ secrets.GITHUB_TOKEN }}
-          # Prune all untagged
-          prune-untagged: true
           # Keep PRs, releases, latest and edge
           keep-tags-regexes: |
             ^pr-
diff --git a/Dockerfile b/Dockerfile
index 281803d..2fc75f5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,5 +1,5 @@
 # Build step
-FROM python:3.11-alpine as build
+FROM python:3.12-alpine AS build
 RUN apk add --update git
 RUN pip install build
 WORKDIR /msmart-build
@@ -8,8 +8,8 @@ RUN python -m build
 
 # Production step
 # Using base alpine package so we can utilize pycryptodome in package repo
-FROM alpine:3.18
-RUN apk add --update python3 py3-pip py3-pycryptodome
+FROM alpine:3.20
+RUN apk add --update python3 pipx py3-pycryptodome
 COPY --from=build /msmart-build/dist/msmart_ng-*.whl /tmp
-RUN pip install /tmp/msmart_ng-*.whl
+RUN PIPX_BIN_DIR=/usr/bin pipx install --system-site-packages /tmp/msmart_ng-*.whl
 ENTRYPOINT ["/usr/bin/msmart-ng"]
diff --git a/README.md b/README.md
index eb9e7ff..2b5ea41 100644
--- a/README.md
+++ b/README.md
@@ -68,20 +68,20 @@ pip install msmart-ng
 
 ## Usage
 ### CLI
-A simple command line interface is provided to discover and query devices. 
+A simple command line interface is provided to discover, query and contorl devices. 
 
 ```shell
 $ msmart-ng --help
-usage: msmart-ng [-h] [-v] {discover,query} ...
+usage: msmart-ng [-h] [-v] {discover,query,control,download} ...
 
 Command line utility for msmart-ng.
 
 options:
-  -h, --help        show this help message and exit
-  -v, --version     show program's version number and exit
+  -h, --help            show this help message and exit
+  -v, --version         show program's version number and exit
 
 Command:
-  {discover,query}
+  {discover,query,control,download}
 ```
 
 Each subcommand has additional help available. e.g. `msmart-ng discover --help`
@@ -125,6 +125,21 @@ $ msmart-ng query <HOST>
 
 Device capabilities can be queried with the `--capabilities` argument.
 
+#### Control
+Control device state with the `msmart-ng control` subcommand. The command takes a space seperated list of key-value pairs of settings to control.
+
+Enumerated settings like `operational_mode`, `fan_speed`, and `swing_mode` can accept integer or string values. e.g. `operational_mode=cool`, `fan_speed=100` or `swing_mode=both`.
+
+Number settings like `target_temperature` can accept floating point or integer values. e.g. `target_temperature=20.5`.
+
+Boolean settings like `display_on` and `beep` can accept integer or string values. e.g. `display_on=True` or `beep=0`.
+
+**Note:** Version 3 devices need to specify either the `--auto` argument or the `--token`, `--key` and `--id` arguments to make a connection.
+
+```shell
+$ msmart-ng control <HOST> operational_mode=cool target_temperature=20.5 fan_speed=100 display_on=True beep=0
+```
+
 ### Home Assistant
 Use [this fork](https://github.com/mill1000/midea-ac-py) of midea-ac-py to control devices from Home Assistant.
 
diff --git a/msmart/cli.py b/msmart/cli.py
index 60304ff..025e556 100644
--- a/msmart/cli.py
+++ b/msmart/cli.py
@@ -1,4 +1,5 @@
 import argparse
+import ast
 import asyncio
 import logging
 from typing import NoReturn
@@ -9,6 +10,7 @@ from msmart.const import OPEN_MIDEA_APP_ACCOUNT, OPEN_MIDEA_APP_PASSWORD
 from msmart.device import AirConditioner as AC
 from msmart.discover import Discover
 from msmart.lan import AuthenticationError
+from msmart.utils import MideaIntEnum
 
 _LOGGER = logging.getLogger(__name__)
 
@@ -40,8 +42,8 @@ async def _discover(args) -> None:
         _LOGGER.info("Found device:\n%s", device.to_dict())
 
 
-async def _query(args) -> None:
-    """Query device state or capabilities."""
+async def _connect(args) -> AC:
+    """Connect to a device directly or via discovery."""
 
     if args.auto and (args.token or args.key or args.device_id):
         _LOGGER.warning(
@@ -69,6 +71,15 @@ async def _query(args) -> None:
         _LOGGER.error("Device is not supported.")
         exit(1)
 
+    return device
+
+
+async def _query(args) -> None:
+    """Query device state or capabilities."""
+
+    # Connect to the device
+    device = await _connect(args)
+
     if args.capabilities:
         _LOGGER.info("Querying device capabilities.")
         await device.get_capabilities()
@@ -102,6 +113,104 @@ async def _query(args) -> None:
         _LOGGER.info("%s", device)
 
 
+async def _control(args) -> None:
+    """Control device state."""
+
+    KEY_DISPLAY_ON = "display_on"
+
+    # Local function to attempt to parse and covert the value to the supplied type
+    def convert(v, t):
+        try:
+            return t(ast.literal_eval(v))
+        except (ValueError, SyntaxError):
+            _LOGGER.error("Value '%s' is not a valid %s",
+                          v, t.__qualname__)
+            exit(1)
+
+    # Parse each setting, checking if the property exists and the supplied value is valid
+    new_properties = {}
+    for name, value in (s.split("=") for s in args.settings):
+        # Check if property exists
+        prop = getattr(AC, name, None)
+        if prop is None or not isinstance(prop, property):
+            _LOGGER.error("'%s' is not a valid device property.", name)
+            exit(1)
+
+        # Check if property has a setter, with special handling for the display
+        if name != KEY_DISPLAY_ON and prop.fset is None:
+            _LOGGER.error("'%s' property is not writable.", name)
+            exit(1)
+
+        # Get the default value of the property and its type
+        attr_value = getattr(AC("0.0.0.0", 0, 0), name)
+        attr_type = type(attr_value)
+
+        if isinstance(attr_value, MideaIntEnum):
+            # Attempt to parse input as a number
+            try:
+                value = ast.literal_eval(value)
+            except ValueError:
+                pass
+
+            if isinstance(value, (int, float)):
+                # Try to convert number to enum
+                try:
+                    new_properties[name] = attr_type(value)
+                except ValueError:
+                    # Allow raw integers for AC.FanSpeed
+                    if attr_type == AC.FanSpeed:
+                        new_properties[name] = int(value)
+                    else:
+                        _LOGGER.error("Value '%d' is not a valid %s",
+                                      value, attr_type.__qualname__)
+                        exit(1)
+            else:
+                # Try to convert string to enum
+                try:
+                    new_properties[name] = attr_type[value.upper()]
+                except KeyError:
+                    _LOGGER.error("Value '%s' is not a valid %s",
+                                  value, attr_type.__qualname__)
+                    exit(1)
+        elif isinstance(attr_value, bool):
+            new_properties[name] = convert(value.capitalize(), bool)
+        else:
+            new_properties[name] = convert(value, attr_type)
+
+    # Connect to the device
+    device = await _connect(args)
+
+    # Get current state
+    _LOGGER.info("Querying device state.")
+    await device.refresh()
+
+    if not device.online:
+        _LOGGER.error("Device is not online.")
+        exit(1)
+
+    if args.capabilities:
+        _LOGGER.info("Querying device capabilities.")
+        await device.get_capabilities()
+
+    # Handle display which is unique
+    if (display := new_properties.pop(KEY_DISPLAY_ON, None)) is not None:
+        if display != device.display_on:
+            _LOGGER.info("Setting '%s' to %s.", KEY_DISPLAY_ON, display)
+            await device.toggle_display()
+
+    # Don't apply if there's not new settings
+    if not new_properties:
+        return
+
+    # Set remaining properties
+    for prop, value in new_properties.items():
+        _LOGGER.info("Setting '%s' to %s.", prop, repr(value))
+        setattr(device, prop, value)
+
+    # Apply to device
+    await device.apply()
+
+
 async def _download(args) -> None:
     """Download a device's protocol implementation from the cloud."""
 
@@ -235,6 +344,33 @@ def main() -> NoReturn:
                               type=bytes.fromhex)
     query_parser.set_defaults(func=_query)
 
+    # Setup control parser
+    control_parser = subparsers.add_parser("control",
+                                           description="Control a device on the local network.",
+                                           parents=[common_parser])
+    control_parser.add_argument("host",
+                                help="Hostname or IP address of device.")
+    control_parser.add_argument("--capabilities",
+                                help="Query device capabilities before sending commands.",
+                                action="store_true")
+    control_parser.add_argument("--auto",
+                                help="Automatically authenticate V3 devices.",
+                                action="store_true")
+    control_parser.add_argument("--id",
+                                help="Device ID for V3 devices.",
+                                dest="device_id", type=int, default=0)
+    control_parser.add_argument("--token",
+                                help="Authentication token for V3 devices.",
+                                type=bytes.fromhex)
+    control_parser.add_argument("--key",
+                                help="Authentication key for V3 devices.",
+                                type=bytes.fromhex)
+    control_parser.add_argument("settings",
+                                nargs="+",
+                                metavar="setting=value",
+                                help="Space separated key-value pairs of settings to control.")
+    control_parser.set_defaults(func=_control)
+
     # Setup download parser
     download = subparsers.add_parser("download",
                                      description="Download a device's plugin and protocol implementation from the cloud.",
diff --git a/msmart/device/AC/command.py b/msmart/device/AC/command.py
index 5ada242..cf3a44c 100644
--- a/msmart/device/AC/command.py
+++ b/msmart/device/AC/command.py
@@ -19,10 +19,11 @@ class InvalidResponseException(Exception):
 
 
 class ResponseId(IntEnum):
-    STATE = 0xC0
-    CAPABILITIES = 0xB5
     PROPERTIES_ACK = 0xB0  # In response to property commands
     PROPERTIES = 0xB1
+    CAPABILITIES = 0xB5
+    STATE = 0xC0
+    GROUP_DATA = 0xC1
 
 
 class CapabilityId(IntEnum):
@@ -51,7 +52,7 @@ class CapabilityId(IntEnum):
     PRESET_FREEZE_PROTECTION = 0x0213
     MODES = 0x0214
     SWING_MODES = 0x0215
-    POWER = 0x0216
+    ENERGY = 0x0216  # AKA electricity
     FILTER_REMIND = 0x0217
     AUX_ELECTRIC_HEAT = 0x0219  # AKA PTC
     PRESET_TURBO = 0x021A
@@ -152,6 +153,40 @@ class GetStateCommand(Command):
         ]))
 
 
+class GetEnergyUsageCommand(Command):
+    """Command to query energy usage from device."""
+
+    def __init__(self) -> None:
+        super().__init__(frame_type=FrameType.QUERY)
+
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
+        payload = bytearray(20)
+
+        payload[0] = 0x41
+        payload[1] = 0x21
+        payload[2] = 0x01
+        payload[3] = 0x44
+
+        return super().tobytes(payload)
+
+
+class GetHumidityCommand(Command):
+    """Command to query indoor humidity from device."""
+
+    def __init__(self) -> None:
+        super().__init__(frame_type=FrameType.QUERY)
+
+    def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
+        payload = bytearray(20)
+
+        payload[0] = 0x41
+        payload[1] = 0x21
+        payload[2] = 0x01
+        payload[3] = 0x45
+
+        return super().tobytes(payload)
+
+
 class SetStateCommand(Command):
     """Command to set basic state of the device."""
 
@@ -171,6 +206,7 @@ class SetStateCommand(Command):
         self.freeze_protection_mode = False
         self.follow_me = False
         self.purifier = False
+        self.target_humidity = 40
 
     def tobytes(self) -> bytes:  # pyright: ignore[reportIncompatibleMethodOverride] # nopep8
         # Build beep and power status bytes
@@ -212,7 +248,10 @@ class SetStateCommand(Command):
         turbo_alt = 0x20 if self.turbo_mode else 0
         follow_me = 0x80 if self.follow_me else 0
 
-        # Build alternate turbo byte
+        # Build target humidity byte
+        humidity = self.target_humidity & 0x7F
+
+        # Build freeze protection byte
         freeze_protect = 0x80 if self.freeze_protection_mode else 0
 
         return super().tobytes(bytes([
@@ -239,8 +278,10 @@ class SetStateCommand(Command):
             0x00, 0x00, 0x00,
             # Alternate temperature
             temperature_alt,
+            # Target humidity
+            humidity,
             # Unknown
-            0x00, 0x00,
+            0x00,
             # Frost/freeze protection
             freeze_protect,
             # Unknown
@@ -357,6 +398,9 @@ class Response():
             # Validate the frame
             Frame.validate(frame_mv)
 
+            # Default to base class
+            response_class = Response
+
             # Fetch the appropriate response class from the ID
             frame_type = frame_mv[9]
             response_id = frame_mv[10]
@@ -367,8 +411,13 @@ class Response():
                 response_class = CapabilitiesResponse
             elif response_id in [ResponseId.PROPERTIES, ResponseId.PROPERTIES_ACK]:
                 response_class = PropertiesResponse
-            else:
-                response_class = Response
+            elif response_id == ResponseId.GROUP_DATA:
+                # Response type depends on an additional "group" byte
+                group = frame_mv[13] & 0xF
+                if group == 4:
+                    response_class = EnergyUsageResponse
+                elif group == 5:
+                    response_class = HumidityResponse
 
             # Validate the payload CRC
             # ...except for properties which certain devices send invalid CRCs
@@ -436,10 +485,10 @@ class CapabilitiesResponse(Response):
                 reader("auto_mode", lambda v: v in [0, 1, 2, 7, 8, 9]),
             ],
             CapabilityId.ONE_KEY_NO_WIND_ON_ME: reader("one_key_no_wind_on_me", get_value(1)),
-            CapabilityId.POWER: [
-                reader("power_stats", lambda v: v in [2, 3, 4, 5]),
-                reader("power_setting", lambda v: v in [3, 5]),
-                reader("power_bcd", lambda v: v in [4, 5]),
+            CapabilityId.ENERGY: [
+                reader("energy_stats", lambda v: v in [2, 3, 4, 5]),
+                reader("energy_setting", lambda v: v in [3, 5]),
+                reader("energy_bcd", lambda v: v in [2, 3]),
             ],
             CapabilityId.PRESET_ECO: [
                 reader("eco_mode", get_value(1)),
@@ -623,11 +672,13 @@ class CapabilitiesResponse(Response):
 
     @property
     def eco_mode(self) -> bool:
-        return self._capabilities.get("eco_mode", False) or self._capabilities.get("eco_mode_2", False)
+        return (self._capabilities.get("eco_mode", False)
+                or self._capabilities.get("eco_mode_2", False))
 
     @property
     def turbo_mode(self) -> bool:
-        return self._capabilities.get("turbo_heat", False) or self._capabilities.get("turbo_cool", False)
+        return (self._capabilities.get("turbo_heat", False)
+                or self._capabilities.get("turbo_cool", False))
 
     @property
     def freeze_protection_mode(self) -> bool:
@@ -652,6 +703,16 @@ class CapabilitiesResponse(Response):
         mode = ["cool", "auto", "heat"]
         return max([self._capabilities.get(f"{m}_max_temperature", 30) for m in mode])
 
+    @property
+    def energy_stats(self) -> bool:
+        return self._capabilities.get("energy_stats", False)
+
+    @property
+    def humidity(self) -> bool:
+        # TODO Unsure the difference between these two
+        return (self._capabilities.get("humidity_auto_set", False)
+                or self._capabilities.get("humidity_manual_set", False))
+
 
 class StateResponse(Response):
     """Response to state query."""
@@ -675,6 +736,7 @@ class StateResponse(Response):
         self.freeze_protection_mode = None
         self.follow_me = None
         self.purifier = None
+        self.target_humidity = None
 
         _LOGGER.debug("State response payload: %s", payload.hex())
 
@@ -763,10 +825,13 @@ class StateResponse(Response):
         if self.outdoor_temperature:
             self.outdoor_temperature += (payload[15] >> 4) / 10
 
-        # TODO dudanov/MideaUART humidity set point in byte 19, mask 0x7F
-
         # TODO Some payloads are shorter than expected. Unsure what, when or why
-        # This length was picked arbitrarily from one user's shorter payload
+        # The lengths below were picked arbitrarily from user payload data
+        if len(payload) < 20:
+            return
+
+        self.target_humidity = payload[19] & 0x7F
+
         if len(payload) < 22:
             return
 
@@ -850,3 +915,74 @@ class PropertiesResponse(Response):
 
     def get_property(self, id: PropertyId) -> Optional[Any]:
         return self._properties.get(id, None)
+
+
+class EnergyUsageResponse(Response):
+    """Response to a GetEnergyUsageCommand."""
+
+    def __init__(self, payload: memoryview) -> None:
+        super().__init__(payload)
+
+        self.total_energy = None
+        self.current_energy = None
+        self.real_time_power = None
+
+        _LOGGER.debug("Energy response payload: %s", payload.hex())
+
+        self._parse(payload)
+
+    def _parse(self, payload: memoryview) -> None:
+        # Response is technically a "group data 4" response
+        # and may contain other interesting data
+
+        def decode_bcd(d: int) -> int:
+            return 10 * (d >> 4) + (d & 0xF)
+
+        # Lua reference decodes real time power field in BCD and binary form
+        # JS reference decodes multiple energy/power fields in BCD only.
+
+        # Total energy in bytes 4 - 8
+        total_energy = (10000 * decode_bcd(payload[4]) +
+                        100 * decode_bcd(payload[5]) +
+                        1 * decode_bcd(payload[6]) +
+                        0.01 * decode_bcd(payload[7]))
+
+        # JS references decodes bytes 8 - 11 as "total running energy"
+        # Older JS does not decode these bytes, and sample payloads contain bogus data
+
+        # Current run energy consumption bytes 12 - 16
+        current_energy = (10000 * decode_bcd(payload[12]) +
+                          100 * decode_bcd(payload[13]) +
+                          1 * decode_bcd(payload[14]) +
+                          0.01 * decode_bcd(payload[15]))
+
+        # Real time power usage bytes 16 - 18
+        real_time_power = (1000 * decode_bcd(payload[16]) +
+                           10 * decode_bcd(payload[17]) +
+                           0.1 * decode_bcd(payload[18]))
+
+        # Assume energy monitory is valid if at least one stats is non zero
+        valid = total_energy or current_energy or real_time_power
+
+        self.total_energy = total_energy if valid else None
+        self.current_energy = current_energy if valid else None
+        self.real_time_power = real_time_power if valid else None
+
+
+class HumidityResponse(Response):
+    """Response to a GetHumidityCommand."""
+
+    def __init__(self, payload: memoryview) -> None:
+        super().__init__(payload)
+
+        self.humidity = None
+
+        _LOGGER.debug("Humidity response payload: %s", payload.hex())
+
+        self._parse(payload)
+
+    def _parse(self, payload: memoryview) -> None:
+        # Response is technically a "group data 5" response
+        # and may contain other interesting data
+
+        self.humidity = payload[4] if payload[4] != 0 else None
diff --git a/msmart/device/AC/device.py b/msmart/device/AC/device.py
index 9e5e7a0..9e06949 100644
--- a/msmart/device/AC/device.py
+++ b/msmart/device/AC/device.py
@@ -8,8 +8,10 @@ from msmart.const import DeviceType
 from msmart.frame import InvalidFrameException
 from msmart.utils import MideaIntEnum
 
-from .command import (CapabilitiesResponse, GetCapabilitiesCommand,
-                      GetPropertiesCommand, GetStateCommand,
+from .command import (CapabilitiesResponse, EnergyUsageResponse,
+                      GetCapabilitiesCommand, GetEnergyUsageCommand,
+                      GetHumidityCommand, GetPropertiesCommand,
+                      GetStateCommand, HumidityResponse,
                       InvalidResponseException, PropertiesResponse, PropertyId,
                       Response, ResponseId, SetPropertiesCommand,
                       SetStateCommand, StateResponse, ToggleDisplayCommand)
@@ -34,6 +36,7 @@ class AirConditioner(Device):
         DRY = 3
         HEAT = 4
         FAN_ONLY = 5
+        # TODO SMART_DRY = 6
 
         DEFAULT = FAN_ONLY
 
@@ -83,6 +86,7 @@ class AirConditioner(Device):
         self._filter_alert = False
         self._follow_me = False
         self._purifier = False
+        self._target_humidity = 40
 
         # Support all known modes initially
         self._supported_op_modes = cast(
@@ -104,6 +108,14 @@ class AirConditioner(Device):
         self._indoor_temperature = None
         self._outdoor_temperature = None
 
+        self._supports_humidity = False
+        self._indoor_humidity = None
+
+        self._request_energy_usage = False
+        self._total_energy_usage = None
+        self._current_energy_usage = None
+        self._real_time_power_usage = None
+
         # Default to assuming device can't handle any properties
         self._supported_properties = set()
         self._updated_properties = set()
@@ -111,9 +123,10 @@ class AirConditioner(Device):
         self._horizontal_swing_angle = AirConditioner.SwingAngle.OFF
         self._vertical_swing_angle = AirConditioner.SwingAngle.OFF
 
-    def _update_state(self, res: Union[StateResponse, PropertiesResponse]) -> None:
-        if isinstance(res, StateResponse):
+    def _update_state(self, res: Response) -> None:
+        """Update the local state from a device state response."""
 
+        if isinstance(res, StateResponse):
             self._power_state = res.power_on
 
             self._target_temperature = res.target_temperature
@@ -152,6 +165,8 @@ class AirConditioner(Device):
             self._follow_me = res.follow_me
             self._purifier = res.purifier
 
+            self._target_humidity = res.target_humidity
+
         elif isinstance(res, PropertiesResponse):
 
             if (angle := res.get_property(PropertyId.SWING_LR_ANGLE)) is not None:
@@ -164,6 +179,18 @@ class AirConditioner(Device):
                     AirConditioner.SwingAngle,
                     AirConditioner.SwingAngle.get_from_value(angle))
 
+        elif isinstance(res, EnergyUsageResponse):
+            self._total_energy_usage = res.total_energy
+            self._current_energy_usage = res.current_energy
+            self._real_time_power_usage = res.real_time_power
+
+        elif isinstance(res, HumidityResponse):
+            self._indoor_humidity = res.humidity
+
+        else:
+            _LOGGER.debug("Ignored unknown response from %s:%d: %s",
+                          self.ip, self.port, res.payload.hex())
+
     def _update_capabilities(self, res: CapabilitiesResponse) -> None:
         # Build list of supported operation modes
         op_modes = [AirConditioner.OperationalMode.FAN_ONLY]
@@ -216,23 +243,21 @@ class AirConditioner(Device):
         self._min_target_temperature = res.min_temperature
         self._max_target_temperature = res.max_temperature
 
-        self._supported_properties.clear()
+        # Allow capabilities to enable energy usage requests, but not disable them
+        # We've seen devices that claim no capability but return energy data
+        self._request_energy_usage |= res.energy_stats
+
+        self._supports_humidity = res.humidity
+
         # Add supported properties based on capabilities
+        self._supported_properties.clear()
+
         if res.swing_vertical_angle:
             self._supported_properties.add(PropertyId.SWING_UD_ANGLE)
 
         if res.swing_horizontal_angle:
             self._supported_properties.add(PropertyId.SWING_LR_ANGLE)
 
-    def _process_state_response(self, response: Response) -> None:
-        """Update the local state from a device state response."""
-
-        if isinstance(response, (StateResponse, PropertiesResponse)):
-            self._update_state(response)
-        else:
-            _LOGGER.debug("Ignored unknown response from %s:%d: %s",
-                          self.ip, self.port, response.payload.hex())
-
     async def _send_command_get_responses(self, command) -> List[Response]:
         """Send a command and return all valid responses."""
 
@@ -319,16 +344,27 @@ class AirConditioner(Device):
     async def refresh(self) -> None:
         """Refresh the local copy of the device state by sending a GetState command."""
 
-        cmd = GetStateCommand()
-        # Process any state responses from the device
-        for response in await self._send_command_get_responses(cmd):
-            self._process_state_response(response)
+        commands = []
+
+        # Always request state updates
+        commands.append(GetStateCommand())
+
+        # Fetch power stats if supported
+        if self._request_energy_usage:
+            commands.append(GetEnergyUsageCommand())
+
+        # Fetch humidity if supported
+        if self._supports_humidity:
+            commands.append(GetHumidityCommand())
 
         # Update supported properties
         if len(self._supported_properties):
-            cmd = GetPropertiesCommand(self._supported_properties)
+            commands.append(GetPropertiesCommand(self._supported_properties))
+
+        # Send commands and process any responses
+        for cmd in commands:
             for response in await self._send_command_get_responses(cmd):
-                self._process_state_response(response)
+                self._update_state(response)
 
     async def apply(self) -> None:
         """Apply the local state to the device."""
@@ -362,8 +398,7 @@ class AirConditioner(Device):
         cmd = SetStateCommand()
         cmd.beep_on = self._beep_on
         cmd.power_on = or_default(self._power_state, False)
-        cmd.target_temperature = or_default(
-            self._target_temperature, 25)  # TODO?
+        cmd.target_temperature = or_default(self._target_temperature, 25)
         cmd.operational_mode = self._operational_mode
         cmd.fan_speed = self._fan_speed
         cmd.swing_mode = self._swing_mode
@@ -375,10 +410,11 @@ class AirConditioner(Device):
         cmd.fahrenheit = or_default(self._fahrenheit_unit, False)
         cmd.follow_me = or_default(self._follow_me, False)
         cmd.purifier = or_default(self._purifier, False)
+        cmd.target_humidity = or_default(self._target_humidity, 40)
 
         # Process any state responses from the device
         for response in await self._send_command_get_responses(cmd):
-            self._process_state_response(response)
+            self._update_state(response)
 
         # Done if no properties need updating
         if not len(self._updated_properties):
@@ -400,7 +436,7 @@ class AirConditioner(Device):
         # Build command with properties
         cmd = SetPropertiesCommand(props)
         for response in await self._send_command_get_responses(cmd):
-            self._process_state_response(response)
+            self._update_state(response)
 
         # Reset updated properties set
         self._updated_properties.clear()
@@ -603,21 +639,62 @@ class AirConditioner(Device):
     def supports_filter_reminder(self) -> Optional[bool]:
         return self._supports_filter_reminder
 
+    @property
+    def enable_energy_usage_requests(self) -> bool:
+        return self._request_energy_usage
+
+    @enable_energy_usage_requests.setter
+    def enable_energy_usage_requests(self, enable: bool) -> None:
+        self._request_energy_usage = enable
+
+    @property
+    def total_energy_usage(self) -> Optional[float]:
+        return self._total_energy_usage
+
+    @property
+    def current_energy_usage(self) -> Optional[float]:
+        return self._current_energy_usage
+
+    @property
+    def real_time_power_usage(self) -> Optional[float]:
+        return self._real_time_power_usage
+
+    @property
+    def indoor_humidity(self) -> Optional[int]:
+        return self._indoor_humidity
+
+    @property
+    def target_humidity(self) -> Optional[int]:
+        return self._target_humidity
+
+    @target_humidity.setter
+    def target_humidity(self, humidity: int) -> None:
+        self._target_humidity = humidity
+
     def to_dict(self) -> dict:
         return {**super().to_dict(), **{
             "power": self.power_state,
             "mode": self.operational_mode,
             "fan_speed": self.fan_speed,
             "swing_mode": self.swing_mode,
+            "horizontal_swing_angle": self.horizontal_swing_angle,
+            "vertical_swing_angle": self.vertical_swing_angle,
             "target_temperature": self.target_temperature,
             "indoor_temperature": self.indoor_temperature,
             "outdoor_temperature": self.outdoor_temperature,
+            "target_humidity": self.target_humidity,
+            "indoor_humidity": self.indoor_humidity,
             "eco": self.eco_mode,
             "turbo": self.turbo_mode,
             "freeze_protection": self.freeze_protection_mode,
             "sleep": self.sleep_mode,
-            "follow_me": self.follow_me,
             "display_on": self.display_on,
             "beep": self.beep,
             "fahrenheit": self.fahrenheit,
+            "filter_alert": self.filter_alert,
+            "follow_me": self.follow_me,
+            "purifier": self.purifier,
+            "total_energy_usage": self.total_energy_usage,
+            "current_energy_usage": self.current_energy_usage,
+            "real_time_power_usage": self.real_time_power_usage,
         }}


Test patch:
diff --git a/msmart/device/AC/test_command.py b/msmart/device/AC/test_command.py
index 1687058..c0ee681 100644
--- a/msmart/device/AC/test_command.py
+++ b/msmart/device/AC/test_command.py
@@ -1,14 +1,11 @@
 import logging
 import unittest
-from typing import Union, cast
+from typing import cast
 
 from msmart.const import DeviceType, FrameType
 from msmart.frame import Frame, InvalidFrameException
 
-from .command import (CapabilitiesResponse, CapabilityId, Command,
-                      GetPropertiesCommand, GetStateCommand,
-                      InvalidResponseException, PropertiesResponse, PropertyId,
-                      Response, SetPropertiesCommand, StateResponse)
+from .command import *
 
 
 class _TestResponseBase(unittest.TestCase):
@@ -19,7 +16,7 @@ class _TestResponseBase(unittest.TestCase):
         self.assertTrue(hasattr(obj, attr),
                         msg=f"Object {obj} lacks attribute '{attr}'.")
 
-    def _test_build_response(self, msg) -> Union[StateResponse, CapabilitiesResponse, PropertiesResponse, Response]:
+    def _test_build_response(self, msg) -> Response:
         """Build a response from the frame and assert it exists."""
         resp = Response.construct(msg)
         self.assertIsNotNone(resp)
@@ -319,7 +316,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "cool_mode": True, "dry_mode": True,
             "auto_mode": True,
             "swing_horizontal": True, "swing_vertical": True,
-            "power_stats": False, "power_setting": False, "power_bcd": False,
+            "energy_stats": False, "energy_setting": False, "energy_bcd": False,
             "filter_notice": False, "filter_clean": False,
             "turbo_heat": True, "turbo_cool": True
         }
@@ -361,7 +358,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "eco_mode": True, "eco_mode_2": False, "silky_cool": False,
             "heat_mode": True, "cool_mode": True, "dry_mode": True,
             "auto_mode": True, "swing_horizontal": True, "swing_vertical": True,
-            "power_stats": False, "power_setting": False, "power_bcd": False,
+            "energy_stats": False, "energy_setting": False, "energy_bcd": False,
             "turbo_heat": True, "turbo_cool": True,
             "fan_custom": True, "fan_silent": False, "fan_low": False,
             "fan_medium": False,  "fan_high": False, "fan_auto": False,
@@ -489,7 +486,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "breeze_control": True,
             "heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
             "swing_horizontal": True, "swing_vertical": True,
-            "power_stats": False, "power_setting": False, "power_bcd": False,
+            "energy_stats": False, "energy_setting": False, "energy_bcd": False,
             "turbo_heat": True, "turbo_cool": True,
             "fan_silent": False, "fan_low": False, "fan_medium": False, "fan_high": False, "fan_auto": False, "fan_custom": True,
             "humidity_auto_set": False, "humidity_manual_set": True,
@@ -533,7 +530,7 @@ class TestCapabilitiesResponse(_TestResponseBase):
             "breeze_control": True,
             "heat_mode": True, "cool_mode": True, "dry_mode": True, "auto_mode": True,
             "swing_horizontal": True, "swing_vertical": True,
-            "power_stats": False, "power_setting": False, "power_bcd": False,
+            "energy_stats": False, "energy_setting": False, "energy_bcd": False,
             "turbo_heat": True, "turbo_cool": True,
             "fan_silent": False, "fan_low": False, "fan_medium": False, "fan_high": False, "fan_auto": False, "fan_custom": True,
             "humidity_auto_set": False, "humidity_manual_set": True,
@@ -731,5 +728,56 @@ class TestResponseConstruct(_TestResponseBase):
         self.assertEqual(type(resp), PropertiesResponse)
 
 
+class TestGroupDataResponse(_TestResponseBase):
+    """Test group data response messages."""
+
+    def test_energy_usage(self) -> None:
+        """Test we decode energy usage responses correctly."""
+        TEST_RESPONSES = {
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2181633174
+            (679.2, 0, 0): bytes.fromhex("aa1fac00000000000303c121014400067920000000000000000000000000aabf"),
+
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2191412432
+            (5650.02, 1514.0, 0): bytes.fromhex("aa20ac00000000000203c121014400564a02640000000014ae0000000000041a22"),
+
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218753545
+            (None, None, None): bytes.fromhex("aa20ac00000000000303c1210144000000000000000000000000000000000843bc"),
+        }
+
+        for power, response in TEST_RESPONSES.items():
+            resp = self._test_build_response(response)
+
+            # Assert response is a correct type
+            self.assertEqual(type(resp), EnergyUsageResponse)
+            resp = cast(EnergyUsageResponse, resp)
+
+            total, current, real_time = power
+
+            self.assertEqual(resp.total_energy, total)
+            self.assertEqual(resp.current_energy, current)
+            self.assertEqual(resp.real_time_power, real_time)
+
+    def test_humidity(self) -> None:
+        """Test we decode humidity responses correctly."""
+        TEST_RESPONSES = {
+            # Device supports humidity
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218019069
+            63: bytes.fromhex("aa20ac00000000000303c12101453f546c005d0a000000de1f0000ba9a0004af9c"),
+
+            # Device does not support humidity
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2192724566
+            None: bytes.fromhex("aa1fac00000000000303c1210145000000000000000000000000000000001aed"),
+        }
+
+        for humidity, response in TEST_RESPONSES.items():
+            resp = self._test_build_response(response)
+
+            # Assert response is a correct type
+            self.assertEqual(type(resp), HumidityResponse)
+            resp = cast(HumidityResponse, resp)
+
+            self.assertEqual(resp.humidity, humidity)
+
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/msmart/device/AC/test_device.py b/msmart/device/AC/test_device.py
index 39cd763..f6b083b 100644
--- a/msmart/device/AC/test_device.py
+++ b/msmart/device/AC/test_device.py
@@ -1,6 +1,7 @@
 import unittest
 
-from .command import PropertiesResponse, Response, StateResponse
+from .command import (EnergyUsageResponse, HumidityResponse,
+                      PropertiesResponse, Response, StateResponse)
 from .device import AirConditioner as AC
 
 
@@ -118,7 +119,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
 
         # Create a dummy device and process the response
         device = AC(0, 0, 0)
-        device._process_state_response(resp)
+        device._update_state(resp)
 
         # Assert state is expected
         self.assertEqual(device.target_temperature, 21.0)
@@ -154,7 +155,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         self.assertEqual(type(resp), PropertiesResponse)
 
         # Process the response
-        device._process_state_response(resp)
+        device._update_state(resp)
 
         # Assert state is expected
         self.assertEqual(device.horizontal_swing_angle, AC.SwingAngle.OFF)
@@ -180,7 +181,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         self.assertEqual(type(resp), PropertiesResponse)
 
         # Process the response
-        device._process_state_response(resp)
+        device._update_state(resp)
 
         # Assert state is expected
         self.assertEqual(device.horizontal_swing_angle, AC.SwingAngle.POS_3)
@@ -207,7 +208,7 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         self.assertEqual(type(resp), PropertiesResponse)
 
         # Process response
-        device._process_state_response(resp)
+        device._update_state(resp)
 
         # Assert that only the properties in the response are updated
         self.assertEqual(device.horizontal_swing_angle, AC.SwingAngle.POS_3)
@@ -215,6 +216,60 @@ class TestUpdateStateFromResponse(unittest.TestCase):
         # Assert other properties are untouched
         self.assertEqual(device.vertical_swing_angle, AC.SwingAngle.POS_5)
 
+    def test_energy_usage_response(self) -> None:
+        """Test parsing of EnergyUsageResponses into device state."""
+        TEST_RESPONSES = {
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2191412432
+            (5650.02, 1514.0, 0): bytes.fromhex("aa20ac00000000000203c121014400564a02640000000014ae0000000000041a22"),
+
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218753545
+            (None, None, None): bytes.fromhex("aa20ac00000000000303c1210144000000000000000000000000000000000843bc"),
+        }
+
+        for power, response in TEST_RESPONSES.items():
+            resp = Response.construct(response)
+            self.assertIsNotNone(resp)
+
+            # Assert response is a state response
+            self.assertEqual(type(resp), EnergyUsageResponse)
+
+            # Create a dummy device and process the response
+            device = AC(0, 0, 0)
+            device._update_state(resp)
+
+            total, current, real_time = power
+
+            # Assert state is expected
+            self.assertEqual(device.total_energy_usage, total)
+            self.assertEqual(device.current_energy_usage, current)
+            self.assertEqual(device.real_time_power_usage, real_time)
+
+    def test_humidity_response(self) -> None:
+        """Test parsing of HumidityResponses into device state."""
+        TEST_RESPONSES = {
+            # Device supports humidity
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2218019069
+            63: bytes.fromhex("aa20ac00000000000303c12101453f546c005d0a000000de1f0000ba9a0004af9c"),
+
+            # Device does not support humidity
+            # https://github.com/mill1000/midea-msmart/pull/116#issuecomment-2192724566
+            None: bytes.fromhex("aa1fac00000000000303c1210145000000000000000000000000000000001aed"),
+        }
+
+        for humidity, response in TEST_RESPONSES.items():
+            resp = Response.construct(response)
+            self.assertIsNotNone(resp)
+
+            # Assert response is a state response
+            self.assertEqual(type(resp), HumidityResponse)
+
+            # Create a dummy device and process the response
+            device = AC(0, 0, 0)
+            device._update_state(resp)
+
+            # Assert state is expected
+            self.assertEqual(device.indoor_humidity, humidity)
+
 
 if __name__ == "__main__":
     unittest.main()