diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index dc3dd1e6..a5338153 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -45,7 +45,7 @@ The integration polls the AC Infinity cloud API and exposes device state, enviro ```bash docker-compose up homeassistant ``` - This starts a Home Assistant instance with the custom_components directory volume-mounted, allowing you to test changes in real-time. + This starts a Home Assistant instance with the custom_components directory volume-mounted, allowing you to test changes in real-time. Access Home Assistant at `http://localhost:8123` ### IDE Configuration @@ -197,6 +197,79 @@ The integration supports two distinct API patterns: | Standard | `is_ai_controller == False` | `update_device_settings` | | AI | `is_ai_controller == True` | `update_ai_device_control_and_settings` | +### Mutually Exclusive Sensor Pairs + +Some physical sensors on AI+ controllers report a single measurement in one of two units depending on device configuration (e.g. Fahrenheit vs. Celsius, µS/cm vs. mS/cm). The API exposes these as separate `sensorType` integers, but they should map to a **single Home Assistant entity** whose unit reflects whichever type the device is actually reporting. + +This is achieved by giving both `SensorType` entries the **same `SensorReferenceKey`** in `SENSOR_DESCRIPTIONS`. Because the entity unique ID is derived from the reference key (not the sensor type), only one entity is ever registered for a given sensor port — whichever type is present in the sensor array passes the `suitable_fn` check, and the other is silently ignored. + +**Known mutually exclusive pairs:** + +| Sensor | Type A | Type B | +|--------|--------|--------| +| Temperature (probe / controller) | `PROBE_TEMPERATURE_F` (0) / `CONTROLLER_TEMPERATURE_F` (4) | `PROBE_TEMPERATURE_C` (1) / `CONTROLLER_TEMPERATURE_C` (5) | +| Hydro water temperature | `HYDRO_WATER_TEMPERATURE_F` (18) | `HYDRO_WATER_TEMPERATURE_C` (19) | +| Hydro EC | `HYDRO_EC_US` (14) — µS/cm | `HYDRO_EC_MS` (15) — mS/cm | +| Hydro TDS | `HYDRO_TDS_PPM` (16) — ppm | `HYDRO_TDS_PPT` (17) — ppt | + +**Example — adding a new mutually exclusive pair:** + +1. Add both sensor type constants to `SensorType` in `const.py`: + ```python + class SensorType: + MY_SENSOR_UNIT_A = 42 + MY_SENSOR_UNIT_B = 43 + ``` + +2. Add a **single** shared reference key to `SensorReferenceKey` in `const.py`: + ```python + class SensorReferenceKey: + MY_SENSOR = "mySensor" + ``` + +3. Register **both** types in `SENSOR_DESCRIPTIONS` in `sensor.py` using the **same** `key` and `translation_key`: + ```python + SensorType.MY_SENSOR_UNIT_A: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.MY_SENSOR, + native_unit_of_measurement="unit_a", + translation_key="my_sensor", + ... + ), + SensorType.MY_SENSOR_UNIT_B: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.MY_SENSOR, + native_unit_of_measurement="unit_b", + translation_key="my_sensor", + ... + ), + ``` + +Only the type present in the device's sensor array at runtime will produce a live entity. If the user later reconfigures the device to report the other unit, a Home Assistant reload is required to swap the entity. + +### Sensor Device Grouping + +Each physical USB-C sensor accessory is represented as a child **device** in Home Assistant (visible under Settings → Devices). The grouping is determined in `ACInfinitySensor.__get_device_info` in `core.py`, which maps `sensorType` integers to a `DeviceInfo` object. Multiple sensor types from the same physical hardware share one `DeviceInfo` — keyed by a stable identifier built from `controller_id`, `sensor_port`, and a short hardware slug. + +**Known sensor device groupings:** + +| Physical Device | Model | Sensor Types | +|-----------------|-------|--------------| +| Probe Sensor | UIS Controller Sensor Probe (AC-SPC24) | `PROBE_TEMPERATURE_F/C` (0/1), `PROBE_HUMIDITY` (2), `PROBE_VPD` (3) | +| CO2 + Light Sensor | UIS CO2 + Light Sensor (AC-COS3) | `CO2` (11), `LIGHT` (12) | +| Water Sensor | UIS Water Sensor (AC-WDS3) | `WATER` (20) | +| Soil Sensor | UIS Soil Sensor (AC-SLS3) | `SOIL` (10) | +| Hydro Sensor | UIS Hydro Sensor (AC-HDS3) | `HYDRO_PH` (13), `HYDRO_EC_US/MS` (14/15), `HYDRO_TDS_PPM/PPT` (16/17), `HYDRO_WATER_TEMPERATURE_F/C` (18/19) | +| Controller (built-in) | — (returns the controller's own `DeviceInfo`) | `CONTROLLER_TEMPERATURE_F/C` (4/5), `CONTROLLER_HUMIDITY` (6), `CONTROLLER_VPD` (7) | + +Controller-type sensor readings (types 4–7) do not create a child device — they attach directly to the controller device itself. + +Unknown sensor types fall through to a catch-all that creates a generic `"Unknown Sensor"` device and logs a warning with a link to open a GitHub issue. + +**When adding a new physical sensor device:** + +1. Add the `SensorType` constants in `const.py`. +2. Add a new `case` block in `ACInfinitySensor.__get_device_info` in `core.py`, grouping all types that come from the same hardware under one `DeviceInfo`. The identifier slug should be lowercase and match the device's model suffix (e.g. `_hds3` for AC-HDS3). +3. Register the sensor types in `SENSOR_DESCRIPTIONS` in `sensor.py`. + ### Entity Description Pattern Each platform uses frozen dataclasses with mixins: @@ -306,5 +379,3 @@ GitHub Actions runs: - `pytest --cov=custom_components/ac_infinity --cov-report=xml --cov-report=term` for testing and coverage collection - `pyright` for type checking - Qodana scan and quality gate to block PRs when conditions are not met - -Access Home Assistant at `http://localhost:8123` diff --git a/README.md b/README.md index 8a314ed6..19719332 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,7 @@ # homeassistant-acinfinity [![hacs_badge](https://img.shields.io/badge/HACS-Default-orange.svg?style=for-the-badge)](https://github.com/custom-components/hacs) -[![Tests](https://github.com/dalinicus/homeassistant-acinfinity/actions/workflows/tests.yaml/badge.svg)](https://github.com/dalinicus/homeassistant-acinfinity/actions/workflows/tests.yaml) - [![HACS/HASS](https://github.com/dalinicus/homeassistant-acinfinity/actions/workflows/validate.yaml/badge.svg)](https://github.com/dalinicus/homeassistant-acinfinity/actions/workflows/validate.yaml) -[![CodeQL](https://github.com/dalinicus/homeassistant-acinfinity/actions/workflows/codeql.yaml/badge.svg)](https://github.com/dalinicus/homeassistant-acinfinity/actions/workflows/codeql.yaml) - This is a custom component for [Home Assistant](http://home-assistant.io) that adds support for [AC Infinity](https://acinfinity.com/) grow tent devices within the [Smart UIS Controller](https://acinfinity.com/smart-controllers/) cloud ecosystem. diff --git a/custom_components/ac_infinity/const.py b/custom_components/ac_infinity/const.py index 8be0480a..292827e6 100644 --- a/custom_components/ac_infinity/const.py +++ b/custom_components/ac_infinity/const.py @@ -30,6 +30,9 @@ class MdiIcon: POWER_PLUG = "mdi:power-plug" WAVES = "mdi:waves" POWER = "mdi:power" + PH = "mdi:ph" + SINE_WAVE = "mdi:sine-wave" + WATER_OPACITY = "mdi:water-opacity" class ConfigurationKey: @@ -120,6 +123,13 @@ class SensorType: SOIL = 10 CO2 = 11 LIGHT = 12 + HYDRO_PH = 13 + HYDRO_EC_US = 14 + HYDRO_EC_MS = 15 + HYDRO_TDS_PPM = 16 + HYDRO_TDS_PPT = 17 + HYDRO_WATER_TEMPERATURE_F = 18 + HYDRO_WATER_TEMPERATURE_C = 19 WATER = 20 @@ -136,6 +146,10 @@ class SensorReferenceKey: LIGHT_SENSOR = "lightSensor" WATER = "waterSensor" SOIL = "soilSensor" + HYDRO_PH = "hydroPh" + HYDRO_EC = "hydroEc" + HYDRO_TDS = "hydroTds" + HYDRO_WATER_TEMPERATURE = "hydroWaterTemperature" # noinspection SpellCheckingInspection diff --git a/custom_components/ac_infinity/core.py b/custom_components/ac_infinity/core.py index 5464ebb2..84920577 100644 --- a/custom_components/ac_infinity/core.py +++ b/custom_components/ac_infinity/core.py @@ -209,6 +209,24 @@ def __get_device_info( via_device=controller.identifier, model="UIS Soil Sensor (AC-SLS3)", ) + case ( + SensorType.HYDRO_PH + | SensorType.HYDRO_EC_US + | SensorType.HYDRO_EC_MS + | SensorType.HYDRO_TDS_PPM + | SensorType.HYDRO_TDS_PPT + | SensorType.HYDRO_WATER_TEMPERATURE_F + | SensorType.HYDRO_WATER_TEMPERATURE_C + ): + return DeviceInfo( + identifiers={ + (DOMAIN, f"{controller.controller_id}_{sensor_port}_hds3") + }, + name=f"{controller.controller_name} Hydro Sensor", + manufacturer=MANUFACTURER, + via_device=controller.identifier, + model="UIS Hydro Sensor (AC-HDS3)", + ) case ( SensorType.CONTROLLER_TEMPERATURE_F | SensorType.CONTROLLER_TEMPERATURE_C diff --git a/custom_components/ac_infinity/manifest.json b/custom_components/ac_infinity/manifest.json index 1cb14d13..ba0bb0e8 100644 --- a/custom_components/ac_infinity/manifest.json +++ b/custom_components/ac_infinity/manifest.json @@ -10,5 +10,5 @@ "iot_class": "cloud_polling", "issue_tracker": "https://github.com/dalinicus/homeassistant-acinfinity", "requirements": [], - "version": "2.1.1" + "version": "2.2.0" } diff --git a/custom_components/ac_infinity/sensor.py b/custom_components/ac_infinity/sensor.py index 4c121a59..4b457e8b 100644 --- a/custom_components/ac_infinity/sensor.py +++ b/custom_components/ac_infinity/sensor.py @@ -15,6 +15,7 @@ CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, Platform, + UnitOfConductivity, UnitOfPressure, UnitOfTemperature, UnitOfTime, @@ -406,6 +407,90 @@ def __get_next_mode_change_timestamp( suitable_fn=__suitable_fn_sensor_default, get_value_fn=__get_value_fn_sensor_value_default, ), + SensorType.HYDRO_PH: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_PH, + device_class=SensorDeviceClass.PH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=None, + suggested_unit_of_measurement=None, + icon=MdiIcon.PH, + translation_key="hydro_ph_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_default, + get_value_fn=__get_value_fn_sensor_value_default, + ), + SensorType.HYDRO_EC_US: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_EC, + device_class=SensorDeviceClass.CONDUCTIVITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM, + suggested_unit_of_measurement=None, + icon=MdiIcon.SINE_WAVE, + translation_key="hydro_ec_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_default, + get_value_fn=__get_value_fn_sensor_value_default, + ), + SensorType.HYDRO_EC_MS: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_EC, + device_class=SensorDeviceClass.CONDUCTIVITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfConductivity.MILLISIEMENS_PER_CM, + suggested_unit_of_measurement=None, + icon=MdiIcon.SINE_WAVE, + translation_key="hydro_ec_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_default, + get_value_fn=__get_value_fn_sensor_value_default, + ), + SensorType.HYDRO_TDS_PPM: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_TDS, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + suggested_unit_of_measurement=None, + icon=MdiIcon.WATER_OPACITY, + translation_key="hydro_tds_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_default, + get_value_fn=__get_value_fn_sensor_value_default, + ), + SensorType.HYDRO_TDS_PPT: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_TDS, + device_class=None, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="ppt", + suggested_unit_of_measurement=None, + icon=MdiIcon.WATER_OPACITY, + translation_key="hydro_tds_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_default, + get_value_fn=__get_value_fn_sensor_value_default, + ), + SensorType.HYDRO_WATER_TEMPERATURE_F: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_WATER_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_unit_of_measurement=None, + icon=MdiIcon.WATER_THERMOMETER_OUTLINE, + translation_key="hydro_water_temperature_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_temperature, + get_value_fn=__get_value_fn_sensor_value_temperature, + ), + SensorType.HYDRO_WATER_TEMPERATURE_C: ACInfinitySensorSensorEntityDescription( + key=SensorReferenceKey.HYDRO_WATER_TEMPERATURE, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_unit_of_measurement=None, + icon=MdiIcon.WATER_THERMOMETER_OUTLINE, + translation_key="hydro_water_temperature_sensor", + enabled_fn=enabled_fn_sensor, + suitable_fn=__suitable_fn_sensor_temperature, + get_value_fn=__get_value_fn_sensor_value_temperature, + ), } DEVICE_DESCRIPTIONS: list[ACInfinityDeviceSensorEntityDescription] = [ diff --git a/custom_components/ac_infinity/strings.json b/custom_components/ac_infinity/strings.json index 321125ca..9992758b 100644 --- a/custom_components/ac_infinity/strings.json +++ b/custom_components/ac_infinity/strings.json @@ -247,6 +247,18 @@ "soil_sensor": { "name": "Soil Moisture" }, + "hydro_ph_sensor": { + "name": "pH" + }, + "hydro_ec_sensor": { + "name": "EC" + }, + "hydro_tds_sensor": { + "name": "TDS" + }, + "hydro_water_temperature_sensor": { + "name": "Water Temperature" + }, "current_power": { "name": "Current Power" }, diff --git a/custom_components/ac_infinity/translations/en.json b/custom_components/ac_infinity/translations/en.json index cce043b7..e93da728 100644 --- a/custom_components/ac_infinity/translations/en.json +++ b/custom_components/ac_infinity/translations/en.json @@ -247,6 +247,18 @@ "soil_sensor": { "name": "Soil Moisture" }, + "hydro_ph_sensor": { + "name": "pH" + }, + "hydro_ec_sensor": { + "name": "EC" + }, + "hydro_tds_sensor": { + "name": "TDS" + }, + "hydro_water_temperature_sensor": { + "name": "Water Temperature" + }, "current_power": { "name": "Current Power" }, diff --git a/tests/data_models.py b/tests/data_models.py index 916ef496..6b6262dd 100644 --- a/tests/data_models.py +++ b/tests/data_models.py @@ -151,6 +151,7 @@ CO2_LIGHT_ACCESS_PORT = 2 SOIL_SENSOR_PORT = 3 WATER_SENSOR_PORT = 4 +HYDRO_SENSOR_PORT = 5 CONTROLLER_ACCESS_PORT = 7 UNKNOWN_ACCESS_PORT = 22 @@ -262,6 +263,69 @@ "sensorData": 1, } +SENSOR_PROPERTY_HYDRO_PH = { + "sensorType": SensorType.HYDRO_PH, + "sensorUnit": 0, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 576, +} + +SENSOR_PROPERTY_HYDRO_EC_US = { + "sensorType": SensorType.HYDRO_EC_US, + "sensorUnit": 0, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 0, +} + +SENSOR_PROPERTY_HYDRO_EC_MS = { + "sensorType": SensorType.HYDRO_EC_MS, + "sensorUnit": 0, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 0, +} + +SENSOR_PROPERTY_HYDRO_TDS_PPM = { + "sensorType": SensorType.HYDRO_TDS_PPM, + "sensorUnit": 0, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 0, +} + +SENSOR_PROPERTY_HYDRO_TDS_PPT = { + "sensorType": SensorType.HYDRO_TDS_PPT, + "sensorUnit": 0, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 0, +} + +SENSOR_PROPERTY_HYDRO_WATER_TEMP_F = { + "sensorType": SensorType.HYDRO_WATER_TEMPERATURE_F, + "sensorUnit": 0, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 7394, +} + +SENSOR_PROPERTY_HYDRO_WATER_TEMP_C = { + "sensorType": SensorType.HYDRO_WATER_TEMPERATURE_C, + "sensorUnit": 1, + "sensorPrecision": 3, + "sensorTrend": 0, + "accessPort": HYDRO_SENSOR_PORT, + "sensorData": 2330, +} + SENSOR_PROPERTY_UNKNOWN = { "sensorType": 999, "sensorUnit": 0, @@ -396,6 +460,13 @@ SENSOR_PROPERTY_LIGHT, SENSOR_PROPERTY_SOIL, SENSOR_PROPERTY_WATER, + SENSOR_PROPERTY_HYDRO_PH, + SENSOR_PROPERTY_HYDRO_EC_US, + SENSOR_PROPERTY_HYDRO_EC_MS, + SENSOR_PROPERTY_HYDRO_TDS_PPM, + SENSOR_PROPERTY_HYDRO_TDS_PPT, + SENSOR_PROPERTY_HYDRO_WATER_TEMP_F, + SENSOR_PROPERTY_HYDRO_WATER_TEMP_C, SENSOR_PROPERTY_UNKNOWN, ], "sensorCalibrationState": [ @@ -790,6 +861,10 @@ (str(AI_DEVICE_ID), CO2_LIGHT_ACCESS_PORT, SensorType.LIGHT): SENSOR_PROPERTY_LIGHT, (str(AI_DEVICE_ID), SOIL_SENSOR_PORT, SensorType.SOIL): SENSOR_PROPERTY_SOIL, (str(AI_DEVICE_ID), WATER_SENSOR_PORT, SensorType.WATER): SENSOR_PROPERTY_WATER, + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_PH): SENSOR_PROPERTY_HYDRO_PH, + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_EC_US): SENSOR_PROPERTY_HYDRO_EC_US, + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_TDS_PPM): SENSOR_PROPERTY_HYDRO_TDS_PPM, + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_F): SENSOR_PROPERTY_HYDRO_WATER_TEMP_F, (str(AI_DEVICE_ID), UNKNOWN_ACCESS_PORT, 999): SENSOR_PROPERTY_UNKNOWN, } diff --git a/tests/test_core.py b/tests/test_core.py index d2b67e48..c79259fe 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -460,8 +460,8 @@ async def test_controller_sensors_property_returns_all_sensors_for_ai(self, mock """controller.sensors property should return all sensors for AI controllers""" controller = ACInfinityController(AI_CONTROLLER_PROPERTIES) - # AI controller has 13 sensors in test data (including unknown sensor type) - assert len(controller.sensors) == 13 + # AI controller has 20 sensors in test data (including unknown sensor type) + assert len(controller.sensors) == 20 # Verify sensor types are correctly parsed sensor_types = [sensor.sensor_type for sensor in controller.sensors] @@ -477,6 +477,13 @@ async def test_controller_sensors_property_returns_all_sensors_for_ai(self, mock assert SensorType.LIGHT in sensor_types assert SensorType.SOIL in sensor_types assert SensorType.WATER in sensor_types + assert SensorType.HYDRO_PH in sensor_types + assert SensorType.HYDRO_EC_US in sensor_types + assert SensorType.HYDRO_EC_MS in sensor_types + assert SensorType.HYDRO_TDS_PPM in sensor_types + assert SensorType.HYDRO_TDS_PPT in sensor_types + assert SensorType.HYDRO_WATER_TEMPERATURE_F in sensor_types + assert SensorType.HYDRO_WATER_TEMPERATURE_C in sensor_types assert 999 in sensor_types # Unknown sensor type # Verify each sensor has the correct controller reference diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 7f7c45df..fe139dfe 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -1,4 +1,5 @@ from datetime import datetime +import copy from zoneinfo import ZoneInfo import pytest @@ -7,6 +8,8 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, + Platform, + UnitOfConductivity, UnitOfPressure, UnitOfTemperature, ) @@ -40,10 +43,17 @@ CO2_LIGHT_ACCESS_PORT, CONTROLLER_ACCESS_PORT, DEVICE_ID, + HYDRO_SENSOR_PORT, MAC_ADDR, PROBE_ACCESS_PORT, SENSOR_PROPERTY_CONTROLLER_TEMP_C, SENSOR_PROPERTY_CONTROLLER_TEMP_F, + SENSOR_PROPERTY_HYDRO_EC_MS, + SENSOR_PROPERTY_HYDRO_EC_US, + SENSOR_PROPERTY_HYDRO_TDS_PPM, + SENSOR_PROPERTY_HYDRO_TDS_PPT, + SENSOR_PROPERTY_HYDRO_WATER_TEMP_C, + SENSOR_PROPERTY_HYDRO_WATER_TEMP_F, SENSOR_PROPERTY_PROBE_TEMP_C, SENSOR_PROPERTY_PROBE_TEMP_F, SOIL_SENSOR_PORT, @@ -68,7 +78,7 @@ async def test_async_setup_all_sensors_created(self, setup): test_objects.entities.add_entities_callback, ) - assert len(test_objects.entities._added_entities) == 28 + assert len(test_objects.entities._added_entities) == 32 async def test_async_setup_entry_temperature_created(self, setup): """Sensor for device reported temperature is created on setup for non-ai controllers""" @@ -817,6 +827,309 @@ async def test_async_update_ai_soil_sensor_value_correct( assert isinstance(entity, ACInfinitySensorSensorEntity) assert entity.native_value == expected + async def test_async_setup_entry_ai_hydro_ph_created(self, setup): + """Sensor for hydro pH is created on setup""" + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_PH, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_PH}" + ) + assert entity.entity_description.device_class == SensorDeviceClass.PH + assert entity.entity_description.native_unit_of_measurement is None + assert entity.device_info is not None + + @pytest.mark.parametrize("value,expected", [(0, 0), (576, 5.76), (None, 0)]) + async def test_async_update_ai_hydro_ph_value_correct(self, setup, value, expected): + """Reported pH value is scaled by precision""" + + test_objects: ACTestObjects = setup + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_PH, + ) + + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_PH) + ][SensorPropertyKey.SENSOR_DATA] = value + + entity._handle_coordinator_update() + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert entity.native_value == expected + + async def test_async_setup_entry_ai_hydro_ec_us_created(self, setup): + """Sensor for hydro EC (µS/cm) is created when EC_US type is present""" + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_EC, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_EC}" + ) + assert entity.entity_description.device_class == SensorDeviceClass.CONDUCTIVITY + assert ( + entity.entity_description.native_unit_of_measurement + == UnitOfConductivity.MICROSIEMENS_PER_CM + ) + assert entity.device_info is not None + + async def test_async_setup_entry_ai_hydro_ec_ms_created(self, setup): + """Sensor for hydro EC (mS/cm) is created when EC_MS type is present instead of EC_US""" + + test_objects: ACTestObjects = setup + test_objects.ac_infinity._sensor_properties = copy.deepcopy(test_objects.ac_infinity._sensor_properties) + test_objects.ac_infinity._sensor_properties.pop( + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_EC_US), None + ) + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_EC_MS) + ] = SENSOR_PROPERTY_HYDRO_EC_MS + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_EC, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_EC}" + ) + assert entity.entity_description.device_class == SensorDeviceClass.CONDUCTIVITY + assert ( + entity.entity_description.native_unit_of_measurement + == UnitOfConductivity.MILLISIEMENS_PER_CM + ) + assert entity.device_info is not None + + @pytest.mark.parametrize("value,expected", [(0, 0), (4460, 44.6), (None, 0)]) + async def test_async_update_ai_hydro_ec_value_correct(self, setup, value, expected): + """Reported EC value is scaled by precision""" + + test_objects: ACTestObjects = setup + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_EC, + ) + + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_EC_US) + ][SensorPropertyKey.SENSOR_DATA] = value + + entity._handle_coordinator_update() + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert entity.native_value == expected + + async def test_async_setup_entry_ai_hydro_tds_ppm_created(self, setup): + """Sensor for hydro TDS (ppm) is created when TDS_PPM type is present""" + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_TDS, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_TDS}" + ) + assert entity.entity_description.device_class is None + assert ( + entity.entity_description.native_unit_of_measurement + == CONCENTRATION_PARTS_PER_MILLION + ) + assert entity.device_info is not None + + async def test_async_setup_entry_ai_hydro_tds_ppt_created(self, setup): + """Sensor for hydro TDS (ppt) is created when TDS_PPT type is present instead of TDS_PPM""" + + test_objects: ACTestObjects = setup + test_objects.ac_infinity._sensor_properties = copy.deepcopy(test_objects.ac_infinity._sensor_properties) + test_objects.ac_infinity._sensor_properties.pop( + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_TDS_PPM), None + ) + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_TDS_PPT) + ] = SENSOR_PROPERTY_HYDRO_TDS_PPT + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_TDS, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_TDS}" + ) + assert entity.entity_description.device_class is None + assert entity.entity_description.native_unit_of_measurement == "ppt" + assert entity.device_info is not None + + @pytest.mark.parametrize("value,expected", [(0, 0), (148, 1.48), (None, 0)]) + async def test_async_update_ai_hydro_tds_value_correct(self, setup, value, expected): + """Reported TDS value is scaled by precision""" + + test_objects: ACTestObjects = setup + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_TDS, + ) + + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_TDS_PPM) + ][SensorPropertyKey.SENSOR_DATA] = value + + entity._handle_coordinator_update() + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert entity.native_value == expected + + async def test_async_setup_entry_ai_hydro_water_temperature_f_created(self, setup): + """Sensor for hydro water temperature (F) is created when HYDRO_WATER_TEMPERATURE_F type is present""" + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_WATER_TEMPERATURE, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_WATER_TEMPERATURE}" + ) + assert entity.entity_description.device_class == SensorDeviceClass.TEMPERATURE + assert ( + entity.entity_description.native_unit_of_measurement + == UnitOfTemperature.CELSIUS + ) + assert entity.device_info is not None + + async def test_async_setup_entry_ai_hydro_water_temperature_c_created(self, setup): + """Sensor for hydro water temperature (C) is created when HYDRO_WATER_TEMPERATURE_C type is present instead of F""" + + test_objects: ACTestObjects = setup + test_objects.ac_infinity._sensor_properties = copy.deepcopy(test_objects.ac_infinity._sensor_properties) + test_objects.ac_infinity._sensor_properties.pop( + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_F), + None, + ) + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_C) + ] = SENSOR_PROPERTY_HYDRO_WATER_TEMP_C + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_WATER_TEMPERATURE, + ) + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert ( + entity.unique_id + == f"{DOMAIN}_{AI_MAC_ADDR}_sensor_{HYDRO_SENSOR_PORT}_{SensorReferenceKey.HYDRO_WATER_TEMPERATURE}" + ) + assert entity.entity_description.device_class == SensorDeviceClass.TEMPERATURE + assert ( + entity.entity_description.native_unit_of_measurement + == UnitOfTemperature.CELSIUS + ) + assert entity.device_info is not None + + @pytest.mark.parametrize( + "value,expected", [(0, -17.78), (7394, 23.3), (None, -17.78)] + ) + async def test_async_update_ai_hydro_water_temperature_f_value_correct( + self, setup, value, expected + ): + """Fahrenheit hydro water temperature is converted and reported as Celsius""" + + test_objects: ACTestObjects = setup + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_F) + ] = SENSOR_PROPERTY_HYDRO_WATER_TEMP_F + test_objects.ac_infinity._sensor_properties.pop( + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_C), + None, + ) + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_WATER_TEMPERATURE, + ) + + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_F) + ][SensorPropertyKey.SENSOR_DATA] = value + + entity._handle_coordinator_update() + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert entity.native_value == expected + + @pytest.mark.parametrize("value,expected", [(0, 0), (2330, 23.3), (None, 0)]) + async def test_async_update_ai_hydro_water_temperature_c_value_correct( + self, setup, value, expected + ): + """Celsius hydro water temperature is reported as-is""" + + test_objects: ACTestObjects = setup + test_objects.ac_infinity._sensor_properties.pop( + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_F), + None, + ) + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_C) + ] = SENSOR_PROPERTY_HYDRO_WATER_TEMP_C + + entity = await execute_and_get_sensor_entity( + setup, + async_setup_entry, + HYDRO_SENSOR_PORT, + SensorReferenceKey.HYDRO_WATER_TEMPERATURE, + ) + + test_objects.ac_infinity._sensor_properties[ + (str(AI_DEVICE_ID), HYDRO_SENSOR_PORT, SensorType.HYDRO_WATER_TEMPERATURE_C) + ][SensorPropertyKey.SENSOR_DATA] = value + + entity._handle_coordinator_update() + + assert isinstance(entity, ACInfinitySensorSensorEntity) + assert entity.native_value == expected + @pytest.mark.parametrize("port", [1, 2, 3, 4]) async def test_async_setup_entry_current_power_created_for_each_port( self, setup, port