Skip to content

Conversation

@ofirzh
Copy link
Contributor

@ofirzh ofirzh commented Aug 23, 2025

…ow support

  • Add Off Timer feature with configurable duration and automatic device shutdown
  • Add CoolDown feature with configurable fan mode transitions and duration
  • Implement independent operation of both features with proper state management
  • Add sensor synchronization and timer completion workflows
  • Support device state monitoring and automatic feature cancellation
  • Include configuration-driven behavior for all timing and fan mode settings

Summary by CodeRabbit

  • New Features
    • Added CoolDown mode with configurable duration and fan transitions.
    • Introduced Off Timer control with duration selection and status sensor.
    • Added Sleep Mode and Eco Mode switches.
    • New select entities for cooldown starting/target fan and durations.
    • Added services: set_timer, cancel_timer, start_cooldown, cancel_cooldown.
    • Faster, real-time device updates via live connection.
  • Documentation
    • Expanded README with detailed Features, CoolDown, and Timer usage.
  • Translations
    • Added English, Hebrew, and Russian labels for new entities and services.
  • Tests
    • Comprehensive tests for switches, sensors, selects, and websocket flows.

…ow support

- Add Off Timer feature with configurable duration and automatic device shutdown
- Add CoolDown feature with configurable fan mode transitions and duration
- Implement independent operation of both features with proper state management
- Add sensor synchronization and timer completion workflows
- Support device state monitoring and automatic feature cancellation
- Include configuration-driven behavior for all timing and fan mode settings
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Aug 23, 2025

Walkthrough

Adds a shared DataUpdateCoordinator with websocket-driven updates, expands supported platforms (sensor, switch, select), introduces CoolDown and Off Timer features with services, selects, and switches, updates config flow to initialize websocket, adds constants and translations, and contributes comprehensive tests and documentation updates.

Changes

Cohort / File(s) Summary
Documentation
README.md
Restructures features; documents CoolDown and Timer; lists entities/services; references new usage docs.
Core setup & coordinator
custom_components/tornado/__init__.py, custom_components/tornado/config_flow.py, custom_components/tornado/const.py
Introduces AuxCloudDataUpdateCoordinator with websocket-driven incremental updates; registers runtime services (timer/cooldown); expands PLATFORMS; initializes websocket during config; adds duration maps.
Aux Cloud WebSocket
custom_components/tornado/aux_cloud/__init__.py, custom_components/tornado/aux_cloud/aux_cloud_ws.py
Adds AuxCloudWebSocket with region-based endpoints, init/keepalive/reconnect, listener dispatch; exposes API method to initialize websocket.
Climate platform refactor
custom_components/tornado/climate.py
Removes local coordinator; uses shared coordinator from init; fetches devices via shared API.
New platforms: select/sensor/switch
custom_components/tornado/select.py, custom_components/tornado/sensor.py, custom_components/tornado/switch.py, custom_components/tornado/services.yaml
Adds selects for fan/duration options; sensor for off-timer status; switches for sleep, eco, cooldown, and off-timer control; defines services for timer control.
Translations
custom_components/tornado/translations/en.json, .../he.json, .../ru.json
Adds localization for new entities and services across English, Hebrew, and Russian.
Tests
tests/test_integration.py, tests/test_select.py, tests/test_sensor.py, tests/test_switch.py, tests/test_websocket.py
Adds extensive unit and integration tests covering websocket lifecycle, coordinator flows, selects, sensor, switches, and feature workflows (cooldown/off-timer).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor User
  participant HA as Home Assistant
  participant CF as Config Flow
  participant API as AuxCloudAPI
  participant WS as AuxCloudWebSocket
  participant Coord as DataUpdateCoordinator
  participant Ent as Entities (Climate/Sensor/Switch/Select)

  User->>CF: Add integration
  CF->>API: login()
  CF->>API: initialize_websocket()
  API->>WS: init with region+headers
  WS-->>API: api_initialized = true
  CF->>HA: create entry

  HA->>Coord: setup with API + WS
  WS-->>Coord: data messages (device updates)
  Coord->>Coord: merge per-device params
  Coord-->>Ent: async_update signals
  Ent->>Ent: read updated device data
Loading
sequenceDiagram
  autonumber
  participant User
  participant HA as HA Services
  participant CD as CoolDown Switch
  participant Sel as Selects (start/target fan, duration)
  participant API as AuxCloudAPI
  note over CD,Sel: CoolDown flow (start → run → complete/cancel)

  User->>HA: tornado.start_cooldown(targets, opts)
  HA->>CD: async_turn_on()
  CD->>Sel: read starting/target fan + duration
  CD->>API: set_device_params(pwr=on, mode=cool, fan=starting)
  CD->>CD: schedule completion at now+duration
  alt Completion
    CD->>API: set_device_params(fan=target)
    CD->>CD: clear cooldown state
  else Cancel/State change
    CD->>CD: cancel timers, reset state
  end
Loading
sequenceDiagram
  autonumber
  participant User
  participant HA as HA Services
  participant OT as Off Timer Control Switch
  participant Sel as Off Timer Duration Select
  participant Sen as Off Timer Sensor
  participant API as AuxCloudAPI

  User->>HA: tornado.set_timer(duration/action)
  HA->>OT: async_turn_on() with duration
  OT->>Sel: read duration
  OT->>API: ensure pwr=on
  OT->>Sen: async_update_timer_state_from_control(active, end_time)
  OT->>OT: schedule turn-off at end_time
  alt Timer expires
    OT->>API: set_device_params(pwr=off)
    OT->>Sen: update inactive/finished
    OT->>OT: reset state
  else Cancel
    HA->>OT: cancel_timer
    OT->>Sen: update inactive
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Suggested reviewers

  • romfreiman

Poem

I twitched my ears at websocket breeze,
Timers tick in minutes, cool like freeze. ❄️
Selects spin fans from low to high,
Switches blink—then hush a sigh.
I hop through updates, quick and bright—
Tornado tamed, in Home Assistant light. 🐇✨

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 27

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
custom_components/tornado/aux_cloud/__init__.py (1)

200-209: Ensure cleanup() also tears down the WebSocket to prevent leaks

cleanup() currently only clears the session reference. If a WS was initialized, background tasks and sockets can persist. Close the WebSocket here.

Apply this diff:

@@
     async def cleanup(self) -> None:
         """Clean up resources."""
         # We never close the session in instance cleanup
         # External sessions are managed by their owners
         # Shared sessions are managed by cleanup_shared_resources
 
         # Just clear the reference and mark as cleaned up
+        # Close websocket if present to stop background tasks/reconnect loops
+        ws_api = getattr(self, "ws_api", None)
+        if ws_api:
+            try:
+                await ws_api.close_websocket()
+            except Exception:  # best-effort cleanup
+                _LOGGER.debug("Error closing websocket during cleanup", exc_info=True)
+
         self.session = None
         self._cleaned_up = True
custom_components/tornado/config_flow.py (1)

100-107: Same here for options: close the temporary WS before returning

Mirror the fix in the options flow to avoid leaked WS tasks/sockets.

Apply this diff:

@@
-                await client.initialize_websocket()
-                
-                return self.async_create_entry(title="", data=user_input)
+                await client.initialize_websocket()
+
+                # Close the temporary WS used only for validation
+                if getattr(client, "ws_api", None):
+                    await client.ws_api.close_websocket()
+
+                return self.async_create_entry(title="", data=user_input)
README.md (1)

12-12: Incorrect manual install path.

The integration domain in code is tornado. Step 2 points to custom_components/tornado_aircon, which will mislead users.

Apply:

-2. Copy the `custom_components/tornado_aircon` directory into your Home Assistant `config/custom_components` directory.
+2. Copy the `custom_components/tornado` directory into your Home Assistant `config/custom_components` directory.
custom_components/tornado/__init__.py (1)

235-256: Close the WebSocket on unload to avoid background tasks and open sockets.

AuxCloudWebSocket.initialize_websocket creates a session and spawns tasks; close_websocket() must be called on unload. Add this before closing the session.

     unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
     if unload_ok:
         entry_data = hass.data[DOMAIN].get(entry.entry_id, {})
         client = entry_data.get("client")
         session = entry_data.get("session")

-        if client:
-            await client.cleanup()
+        if client:
+            # Ensure websocket is closed to stop keepalive/reconnect tasks
+            ws_api = getattr(client, "ws_api", None)
+            if ws_api:
+                try:
+                    await ws_api.close_websocket()
+                except Exception:
+                    _LOGGER.exception("Error closing WebSocket during unload")
+            await client.cleanup()
         if session and not session.closed:
             await session.close()

If you’d like, I can also patch AuxCloudWebSocket to use the shared HA session rather than creating its own (see note below).

🧹 Nitpick comments (33)
custom_components/tornado/const.py (3)

11-11: Add 'cn' to supported REGIONS to match WebSocket region handling

WebSocket code supports a "cn" region and defaults to CN when region ≠ "eu"/"usa". Keep this consistent and explicit in the constants.

Apply:

-REGIONS = ["eu", "usa"]
+REGIONS = ["eu", "usa", "cn"]

14-29: Duration map looks good; consider exposing options once to avoid drift

Mappings are sensible and match en/he/ru translations. If multiple platforms read these, consider exposing a single ordered tuple of option labels derived from this map (for consistent UI ordering) to avoid divergence across select/number implementations.


31-38: Minor consistency: "min" vs "m" suffixes

Off Timer uses "10m", CoolDown uses "1min". This is fine if tests/UI rely on it, but if you want consistency for end users and code, consider standardizing to one suffix in the future (and migrating translations accordingly).

custom_components/tornado/aux_cloud/aux_cloud_ws.py (2)

172-179: Avoid logging full payloads; may leak PII (loginsession/userid) in INIT

Debug log prints entire JSON payload including session/user IDs. Mask or reduce logs.

[security]

Apply:

-            _LOGGER.debug("Sent JSON data via WebSocket: %s", json_data)
+            safe_msgtype = data.get("msgtype")
+            _LOGGER.debug("Sent WebSocket message (msgtype=%s)", safe_msgtype)

10-13: Optional: use a dict map for region→URL and validate inputs

A small map improves readability and helps validate unknown regions (instead of silently using CN).

Example:

REGION_WS_URL = {
    "eu": WEBSOCKET_SERVER_URL_EU,
    "usa": WEBSOCKET_SERVER_URL_USA,
    "cn": WEBSOCKET_SERVER_URL_CN,
}
self.websocket_url = REGION_WS_URL.get(region, WEBSOCKET_SERVER_URL_USA)

Also align with REGIONS in const.py.

custom_components/tornado/translations/en.json (1)

61-114: Translations look consistent with options; minor naming consistency ("CoolDown" vs "Cooldown")

Keys and values align with constants. Consider normalizing to “Cooldown” casing across UI for polish. Not blocking.

custom_components/tornado/services.yaml (1)

1-37: Services look consistent; minor UX improvement: broaden targets

Definitions are solid. Consider allowing both sensor and switch entities (and device/area targeting) so users can call services from either the timer sensor or the control switch.

Example diff:

 set_timer:
   name: Set Timer
   description: Set a timer for the Tornado AC unit
   target:
-    entity:
-      integration: tornado
-      domain: sensor
+    entity:
+      integration: tornado
+      domain:
+        - sensor
+        - switch
+    device:
+      integration: tornado
+    area: {}
@@
     action:
       name: Action
       description: What to do when timer expires
       required: false
       default: "turn_off"
       selector:
         select:
           options:
             - "turn_off"
             - "sleep_mode"
 
 cancel_timer:
   name: Cancel Timer
   description: Cancel the active timer for the Tornado AC unit
   target:
-    entity:
-      integration: tornado
-      domain: sensor
+    entity:
+      integration: tornado
+      domain:
+        - sensor
+        - switch
+    device:
+      integration: tornado
+    area: {}
custom_components/tornado/climate.py (1)

170-171: Unsubscribe the coordinator listener on entity removal

async_add_listener returns an unsubscribe callable. Store and call it in async_will_remove_from_hass() to avoid leaks.

Apply this diff:

@@
-        coordinator.async_add_listener(self._handle_coordinator_update)
+        self._unsub_coordinator = coordinator.async_add_listener(
+            self._handle_coordinator_update
+        )
         _LOGGER.info("Entity initialized for device %s", self._device_id)

And extend async_will_remove_from_hass:

@@
     async def async_added_to_hass(self) -> None:
         """When entity is added to hass."""
         await super().async_added_to_hass()
         self._handle_coordinator_update()
 
@@
     async def async_will_remove_from_hass(self) -> None:
         """Cleanup when entity is removed."""
-        # existing behavior (if any)
+        if getattr(self, "_unsub_coordinator", None):
+            try:
+                self._unsub_coordinator()
+            finally:
+                self._unsub_coordinator = None
tests/test_switch.py (2)

229-233: Add missing assertion to validate timer cancellation on device power-off

The test sets up the condition and calls the handler but doesn't assert the expected cancellation.

Apply this diff:

@@
         with patch.object(off_timer_switch, "_cancel_timer") as mock_cancel:
             off_timer_switch._handle_coordinator_update()
             
             # Timer should be cancelled when device is turned off
+            mock_cancel.assert_called_once()

409-412: Add missing assertion to validate cooldown cancellation on device power-off

Mirror the off-timer test to assert expected cancellation.

Apply this diff:

@@
         with patch.object(cooldown_switch, "_cancel_cooldown") as mock_cancel:
             cooldown_switch._handle_coordinator_update()
             
             # Cooldown should be cancelled when device is turned off
+            mock_cancel.assert_called_once()
tests/test_sensor.py (1)

146-151: Avoid real sleeps in tests; simulate time progression instead.

The await asyncio.sleep(0.1) increases flakiness and slows the suite. Since you re-use the same end_time, the assertion doesn’t depend on real time.

Apply:

-        # Wait a short time and check progression
-        await asyncio.sleep(0.1)
-
-        # Update again (simulating periodic update)
+        # Update again (simulating periodic update, no real sleep needed)
         await off_timer_sensor.async_update_timer_state_from_control(True, end_time)
README.md (1)

54-54: Verify linked docs exist in the repo.

COOLDOWN_USAGE.md and TIMER_USAGE.md are referenced; make sure they are added in this PR to avoid dead links.

I can draft these usage docs if they’re pending.

Also applies to: 62-62

tests/test_select.py (4)

10-17: Derive expected duration options from the source of truth to avoid drift.

Hard-coding the options list makes the test brittle (also conflicts with README’s 8h note). Import OFF_TIMER_DURATION_MAP and use its keys.

Apply:

-from custom_components.tornado.select import (
+from custom_components.tornado.select import (
     TornadoOffTimerDurationSelect,
     TornadoCoolDownStartingFanSelect,
     TornadoCoolDownTargetFanSelect,
     TornadoCoolDownDurationSelect,
 )
-from custom_components.tornado.const import DOMAIN
+from custom_components.tornado.const import DOMAIN, OFF_TIMER_DURATION_MAP
@@
-        expected_options = ["10m", "30m", "1h", "2h", "3h", "4h", "5h", "6h", "7h", "8h", "9h", "10h", "11h", "12h"]
-        assert off_timer_duration_select.options == expected_options
+        expected_options = list(OFF_TIMER_DURATION_MAP.keys())
+        assert off_timer_duration_select.options == expected_options

Also applies to: 113-116


153-158: Assert availability explicitly after coordinator update.

Currently the test only asserts the object is not None. Check the entity’s availability contract.

Apply:

-        off_timer_duration_select._handle_coordinator_update()
-        # Off timer duration select is always available, so just test that it doesn't crash
-        assert off_timer_duration_select is not None
+        off_timer_duration_select._handle_coordinator_update()
+        assert off_timer_duration_select.available is True

197-205: Prefer checking the public available property.

This better reflects the entity contract rather than internal _device.

Apply:

-        assert cooldown_starting_fan_select._device is not None
+        assert cooldown_starting_fan_select.available is True

207-213: When device is unavailable, update and assert unavailability.

Call the coordinator update hook and verify availability.

Apply:

         mock_coordinator.data = {}
-        # Test that _device returns None when no device data
-        assert cooldown_starting_fan_select._device is None
+        cooldown_starting_fan_select._handle_coordinator_update()
+        assert cooldown_starting_fan_select.available is False
tests/test_integration.py (2)

362-367: Assert that cancellation is triggered when device becomes unavailable.

You patch the cancellation methods but don’t assert they were called.

Apply:

             off_timer_switch._handle_coordinator_update()
             cooldown_switch._handle_coordinator_update()
             
-            # Both features should be cancelled
-            # Test that _device returns None when no device data
+            # Both features should be cancelled
+            assert mock_cancel_timer.called, "Off timer was not cancelled on device unavailability"
+            assert mock_cancel_cooldown.called, "Cooldown was not cancelled on device unavailability"
+            # Test that _device returns None when no device data
             assert off_timer_switch._device is None
             assert cooldown_switch._device is None

390-397: Also assert cancellations when device power is turned off.

Mirror the previous test to ensure safety behavior is validated.

Apply:

             off_timer_switch._handle_coordinator_update()
             cooldown_switch._handle_coordinator_update()
             
-            # Both features should be cancelled when device is turned off
+            # Both features should be cancelled when device is turned off
+            assert mock_cancel_timer.called, "Off timer was not cancelled when device power turned off"
+            assert mock_cancel_cooldown.called, "Cooldown was not cancelled when device power turned off"
custom_components/tornado/sensor.py (1)

146-196: Consider exposing remaining_minutes alongside remaining_seconds for convenience.

Many dashboards/automations consume minutes directly; adding it reduces duplication. Optional, backward-compatible.

Apply:

             self._attr_extra_state_attributes = {
                 "timer_active": timer_active,
                 "display_text": display_text,
                 "timer_end_time": timer_end_time.isoformat() if timer_end_time else None,
                 "remaining_seconds": remaining_seconds,
+                "remaining_minutes": remaining_minutes,
             }
@@
             self._attr_extra_state_attributes = {
                 "timer_active": False,
                 "display_text": "No off timer",
                 "timer_end_time": None,
                 "remaining_seconds": 0,
+                "remaining_minutes": 0,
             }
tests/test_websocket.py (1)

491-511: Add a regression test: keepalive loop must tolerate self.websocket = None.

close_websocket() sets self.websocket = None, while _keepalive_loop checks self.websocket.closed in a loop. Without a guard, this can raise AttributeError if the loop ticks after close. Add a test that simulates close_websocket() mid-loop and assert no exception/logged error. Also consider hardening _keepalive_loop to first snapshot-and-check ws = self.websocket and break if falsy or closed.

I can draft the negative-path test and the small guard fix in aux_cloud_ws.py if you want.

custom_components/tornado/__init__.py (7)

6-15: Fix import order and grouping (flake8 I001) and move stdlib timedelta with stdlib imports.

Sort and group imports as stdlib → third-party → local to satisfy the linter and keep consistency.

Apply:

-from __future__ import annotations
-
-import base64
-import json
-import logging
-from typing import TYPE_CHECKING
-
-import aiohttp
-from homeassistant.const import Platform
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
-from datetime import timedelta
+from __future__ import annotations
+
+from datetime import timedelta
+import base64
+import json
+import logging
+from typing import TYPE_CHECKING
+
+import aiohttp
+from homeassistant.const import Platform
+from homeassistant.helpers.update_coordinator import (
+    DataUpdateCoordinator,
+    UpdateFailed,
+)

23-23: Wrap PLATFORMS to satisfy E501 and improve readability.

-PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SENSOR, Platform.SWITCH, Platform.SELECT]
+PLATFORMS: list[Platform] = [
+    Platform.CLIMATE,
+    Platform.SENSOR,
+    Platform.SWITCH,
+    Platform.SELECT,
+]

43-50: Trim trailing whitespace and extraneous spaces in blank lines (W291/W293).

There are lines with trailing spaces and blank lines with whitespace. Please run the formatter or remove the stray spaces to make the linter happy.


60-61: Use double quotes for strings to satisfy flake8-quotes (Q000).

-                    decoded_str = decoded_bytes.decode('utf-8')
+                    decoded_str = decoded_bytes.decode("utf-8")

63-63: Break long log lines to satisfy E501.

A few _LOGGER.debug(...) lines exceed 88 chars. Please wrap them; example:

-                    _LOGGER.debug("Decoded device params for %s: %s", endpoint_id, device_params)
+                    _LOGGER.debug(
+                        "Decoded device params for %s: %s",
+                        endpoint_id,
+                        device_params,
+                    )

Repeat similarly for other long log lines in this method.


133-142: Listener registration relies on api.ws_api being initialized. Ensure robustness if websocket init is slow or reconnecting.

You instantiate the coordinator after await client.initialize_websocket(), which is fine. During reconnects, the same ws_api instance is reused, so the listener remains valid. Consider handling the (unlikely) case where ws_api is absent (e.g., initialization timeout) by deferring listener registration until ws_api exists, or guarding with a log and retry.


107-132: Prefer Home Assistant’s shared session over creating a new aiohttp.ClientSession.

async_setup_entry currently creates its own ClientSession(). HA best practice is from homeassistant.helpers.aiohttp_client import async_get_clientsession then session = async_get_clientsession(hass). This avoids multiple connectors and centralizes lifecycle management.

custom_components/tornado/select.py (3)

1-15: Import order and linter: sort imports (I001) and silence false-positive A005 for HA platform module.

  • Sort imports into stdlib → third-party → local.
  • The linter flags “Module 'select' shadows stdlib” (A005). In HA, the platform file must be named select.py. Add a per-file ignore in your flake8/ruff config for custom_components/tornado/select.py:A005.

Example config change (in setup.cfg or pyproject.toml):

[flake8]
per-file-ignores =
    custom_components/tornado/select.py: A005

Apply import sort:

-from __future__ import annotations
-
-import logging
-from typing import TYPE_CHECKING, Any
-
-from homeassistant.components.select import (
-    SelectEntity,
-    SelectEntityDescription,
-)
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.entity import EntityCategory
-from homeassistant.helpers.update_coordinator import CoordinatorEntity
-
-from .const import (
-    DOMAIN,
-    OFF_TIMER_DURATION_MAP,
-    COOLDOWN_DURATION_MAP,
-)
-from .climate import FAN_MODE_MAP, FAN_MODE_MAP_REVERSE
+from __future__ import annotations
+
+import logging
+from typing import TYPE_CHECKING, Any
+
+from homeassistant.components.select import (
+    SelectEntity,
+    SelectEntityDescription,
+)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers.entity import EntityCategory
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import COOLDOWN_DURATION_MAP, DOMAIN, OFF_TIMER_DURATION_MAP
+from .climate import FAN_MODE_MAP, FAN_MODE_MAP_REVERSE

110-141: Persist select choices across restarts (use RestoreEntity).

These selects hold user configuration (fan modes, durations). They reset to defaults after HA restarts. Consider subclassing RestoreEntity and restoring the last state in async_added_to_hass(). This will improve UX and make automations resilient.


210-229: Fragile entity lookup via hass.data["entity_components"]; consider central registry or entity registry IDs.

The lookup works but scales poorly and couples to internal structures. Consider:

  • Keeping a per-device pointer map in hass.data[DOMAIN][entry_id] updated on entity add/remove, or
  • Resolving entities via entity_registry and hass.states.get() then using the platform’s own registry to reach the object.

Low priority if device count is small.

custom_components/tornado/switch.py (3)

679-681: Type hint for entity_category should match HA’s EntityCategory | None.

-    def entity_category(self) -> str | None:
+    def entity_category(self) -> EntityCategory | None:

756-768: Avoid arbitrary sleep after powering on; request a refresh or rely on websocket.

await asyncio.sleep(1) introduces flakiness. Prefer await self.coordinator.async_request_refresh() or let the websocket push update the state before starting the timer (you can start the timer immediately and let subsequent updates adjust the sensor).


459-466: You store original state but never use it; clarify intended CoolDown semantics.

self._original_state and was_on are recorded but not applied on completion/cancel. If the intent is to restore previous power/fan state, implement it in _complete_cooldown()/_cancel_cooldown(). If not, remove the dead state to avoid confusion.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between f2af312 and 221ddd8.

📒 Files selected for processing (19)
  • README.md (1 hunks)
  • custom_components/tornado/__init__.py (2 hunks)
  • custom_components/tornado/aux_cloud/__init__.py (3 hunks)
  • custom_components/tornado/aux_cloud/aux_cloud_ws.py (1 hunks)
  • custom_components/tornado/climate.py (2 hunks)
  • custom_components/tornado/config_flow.py (2 hunks)
  • custom_components/tornado/const.py (1 hunks)
  • custom_components/tornado/select.py (1 hunks)
  • custom_components/tornado/sensor.py (1 hunks)
  • custom_components/tornado/services.yaml (1 hunks)
  • custom_components/tornado/switch.py (1 hunks)
  • custom_components/tornado/translations/en.json (1 hunks)
  • custom_components/tornado/translations/he.json (1 hunks)
  • custom_components/tornado/translations/ru.json (1 hunks)
  • tests/test_integration.py (1 hunks)
  • tests/test_select.py (1 hunks)
  • tests/test_sensor.py (1 hunks)
  • tests/test_switch.py (1 hunks)
  • tests/test_websocket.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (13)
custom_components/tornado/switch.py (5)
custom_components/tornado/__init__.py (2)
  • AuxCloudDataUpdateCoordinator (27-104)
  • async_setup_entry (107-232)
custom_components/tornado/climate.py (5)
  • async_setup_entry (78-111)
  • _device (179-183)
  • _handle_coordinator_update (201-265)
  • async_turn_on (331-339)
  • async_turn_off (341-349)
custom_components/tornado/select.py (13)
  • async_setup_entry (36-107)
  • _device (149-153)
  • _device (280-284)
  • _handle_coordinator_update (231-238)
  • _handle_coordinator_update (362-369)
  • _handle_coordinator_update (447-451)
  • _handle_coordinator_update (562-566)
  • entity_category (144-146)
  • entity_category (275-277)
  • entity_category (408-410)
  • entity_category (488-490)
  • current_option (413-415)
  • current_option (493-495)
custom_components/tornado/sensor.py (4)
  • async_setup_entry (31-72)
  • _device (116-120)
  • _handle_coordinator_update (123-130)
  • async_update_timer_state_from_control (146-195)
custom_components/tornado/aux_cloud/__init__.py (2)
  • get_devices (369-406)
  • set_device_params (591-610)
tests/test_select.py (1)
custom_components/tornado/select.py (29)
  • TornadoOffTimerDurationSelect (372-451)
  • TornadoCoolDownStartingFanSelect (110-238)
  • TornadoCoolDownTargetFanSelect (241-369)
  • TornadoCoolDownDurationSelect (454-566)
  • entity_category (144-146)
  • entity_category (275-277)
  • entity_category (408-410)
  • entity_category (488-490)
  • icon (165-176)
  • icon (296-307)
  • options (418-420)
  • current_option (413-415)
  • current_option (493-495)
  • async_select_option (178-209)
  • async_select_option (309-340)
  • async_select_option (422-444)
  • async_select_option (503-540)
  • _handle_coordinator_update (231-238)
  • _handle_coordinator_update (362-369)
  • _handle_coordinator_update (447-451)
  • _handle_coordinator_update (562-566)
  • _device (149-153)
  • _device (280-284)
  • available (156-162)
  • available (287-293)
  • available (498-501)
  • _get_cooldown_switch (211-228)
  • _get_cooldown_switch (342-359)
  • _get_cooldown_switch (542-559)
custom_components/tornado/aux_cloud/aux_cloud_ws.py (1)
custom_components/tornado/aux_cloud/__init__.py (1)
  • initialize_websocket (849-873)
tests/test_switch.py (5)
custom_components/tornado/switch.py (35)
  • TornadoOffTimerControlSwitch (640-936)
  • TornadoCoolDownSwitch (294-637)
  • entity_category (341-343)
  • entity_category (679-681)
  • is_on (691-693)
  • _get_off_timer_duration_from_select (863-878)
  • _start_timer (779-796)
  • _cancel_timer (798-812)
  • _timer_completed (814-836)
  • async_turn_on (167-178)
  • async_turn_on (267-278)
  • async_turn_on (445-508)
  • async_turn_on (747-773)
  • async_turn_off (180-191)
  • async_turn_off (280-291)
  • async_turn_off (510-519)
  • async_turn_off (775-777)
  • _handle_coordinator_update (135-165)
  • _handle_coordinator_update (235-265)
  • _handle_coordinator_update (375-443)
  • _handle_coordinator_update (696-733)
  • _device (128-132)
  • _device (228-232)
  • _device (346-350)
  • _device (684-688)
  • extra_state_attributes (353-372)
  • extra_state_attributes (736-745)
  • _periodic_sensor_update (851-861)
  • _get_starting_fan_mode_from_select (602-607)
  • _get_target_fan_mode_from_select (609-614)
  • _get_cooldown_duration_from_select (616-631)
  • _cancel_cooldown (521-538)
  • _complete_cooldown (540-571)
  • async_will_remove_from_hass (633-637)
  • async_will_remove_from_hass (932-936)
custom_components/tornado/climate.py (5)
  • icon (186-188)
  • async_turn_on (331-339)
  • async_turn_off (341-349)
  • _handle_coordinator_update (201-265)
  • _device (179-183)
custom_components/tornado/select.py (12)
  • icon (165-176)
  • icon (296-307)
  • entity_category (144-146)
  • entity_category (275-277)
  • entity_category (408-410)
  • entity_category (488-490)
  • _handle_coordinator_update (231-238)
  • _handle_coordinator_update (362-369)
  • _handle_coordinator_update (447-451)
  • _handle_coordinator_update (562-566)
  • _device (149-153)
  • _device (280-284)
custom_components/tornado/aux_cloud/__init__.py (1)
  • set_device_params (591-610)
custom_components/tornado/sensor.py (2)
  • _handle_coordinator_update (123-130)
  • _device (116-120)
tests/test_integration.py (4)
custom_components/tornado/sensor.py (4)
  • TornadoOffTimerSensor (75-195)
  • async_update_timer_state_from_control (146-195)
  • _handle_coordinator_update (123-130)
  • _device (116-120)
custom_components/tornado/switch.py (23)
  • TornadoOffTimerControlSwitch (640-936)
  • TornadoCoolDownSwitch (294-637)
  • async_turn_on (167-178)
  • async_turn_on (267-278)
  • async_turn_on (445-508)
  • async_turn_on (747-773)
  • is_on (691-693)
  • extra_state_attributes (353-372)
  • extra_state_attributes (736-745)
  • async_turn_off (180-191)
  • async_turn_off (280-291)
  • async_turn_off (510-519)
  • async_turn_off (775-777)
  • _timer_completed (814-836)
  • _complete_cooldown (540-571)
  • _handle_coordinator_update (135-165)
  • _handle_coordinator_update (235-265)
  • _handle_coordinator_update (375-443)
  • _handle_coordinator_update (696-733)
  • _device (128-132)
  • _device (228-232)
  • _device (346-350)
  • _device (684-688)
custom_components/tornado/select.py (16)
  • TornadoOffTimerDurationSelect (372-451)
  • TornadoCoolDownStartingFanSelect (110-238)
  • TornadoCoolDownTargetFanSelect (241-369)
  • TornadoCoolDownDurationSelect (454-566)
  • async_select_option (178-209)
  • async_select_option (309-340)
  • async_select_option (422-444)
  • async_select_option (503-540)
  • current_option (413-415)
  • current_option (493-495)
  • _handle_coordinator_update (231-238)
  • _handle_coordinator_update (362-369)
  • _handle_coordinator_update (447-451)
  • _handle_coordinator_update (562-566)
  • _device (149-153)
  • _device (280-284)
tests/test_switch.py (3)
  • mock_coordinator (45-50)
  • off_timer_switch (54-71)
  • cooldown_switch (75-85)
tests/test_websocket.py (1)
custom_components/tornado/aux_cloud/aux_cloud_ws.py (11)
  • AuxCloudWebSocket (15-193)
  • send_data (162-179)
  • initialize_websocket (34-65)
  • close_websocket (181-193)
  • _schedule_reconnect (145-148)
  • _listen_to_websocket (67-103)
  • add_websocket_listener (137-143)
  • _notify_listeners (127-135)
  • _keepalive_websocket (105-120)
  • _keepalive_loop (122-125)
  • _reconnect (150-160)
custom_components/tornado/config_flow.py (2)
custom_components/tornado/aux_cloud/__init__.py (1)
  • initialize_websocket (849-873)
custom_components/tornado/aux_cloud/aux_cloud_ws.py (1)
  • initialize_websocket (34-65)
tests/test_sensor.py (1)
custom_components/tornado/sensor.py (5)
  • TornadoOffTimerSensor (75-195)
  • async_update_timer_state_from_control (146-195)
  • _format_time_remaining (132-144)
  • _handle_coordinator_update (123-130)
  • _device (116-120)
custom_components/tornado/sensor.py (4)
custom_components/tornado/__init__.py (2)
  • AuxCloudDataUpdateCoordinator (27-104)
  • async_setup_entry (107-232)
custom_components/tornado/climate.py (3)
  • async_setup_entry (78-111)
  • _device (179-183)
  • _handle_coordinator_update (201-265)
custom_components/tornado/select.py (7)
  • async_setup_entry (36-107)
  • _device (149-153)
  • _device (280-284)
  • _handle_coordinator_update (231-238)
  • _handle_coordinator_update (362-369)
  • _handle_coordinator_update (447-451)
  • _handle_coordinator_update (562-566)
custom_components/tornado/switch.py (9)
  • async_setup_entry (32-91)
  • _device (128-132)
  • _device (228-232)
  • _device (346-350)
  • _device (684-688)
  • _handle_coordinator_update (135-165)
  • _handle_coordinator_update (235-265)
  • _handle_coordinator_update (375-443)
  • _handle_coordinator_update (696-733)
custom_components/tornado/aux_cloud/__init__.py (1)
custom_components/tornado/aux_cloud/aux_cloud_ws.py (2)
  • AuxCloudWebSocket (15-193)
  • initialize_websocket (34-65)
custom_components/tornado/__init__.py (6)
custom_components/tornado/aux_cloud/__init__.py (5)
  • AuxCloudAPI (113-873)
  • get_devices (369-406)
  • initialize_websocket (849-873)
  • refresh (827-847)
  • cleanup (200-208)
tests/test_aux_cloud.py (1)
  • api (69-82)
custom_components/tornado/aux_cloud/aux_cloud_ws.py (2)
  • add_websocket_listener (137-143)
  • initialize_websocket (34-65)
tests/test_climate.py (2)
  • coordinator (55-62)
  • entity (66-75)
custom_components/tornado/climate.py (3)
  • async_turn_on (331-339)
  • async_turn_off (341-349)
  • async_unload_entry (352-364)
custom_components/tornado/switch.py (8)
  • async_turn_on (167-178)
  • async_turn_on (267-278)
  • async_turn_on (445-508)
  • async_turn_on (747-773)
  • async_turn_off (180-191)
  • async_turn_off (280-291)
  • async_turn_off (510-519)
  • async_turn_off (775-777)
custom_components/tornado/select.py (5)
custom_components/tornado/__init__.py (2)
  • AuxCloudDataUpdateCoordinator (27-104)
  • async_setup_entry (107-232)
custom_components/tornado/climate.py (4)
  • async_setup_entry (78-111)
  • _device (179-183)
  • available (174-176)
  • _handle_coordinator_update (201-265)
custom_components/tornado/switch.py (9)
  • async_setup_entry (32-91)
  • _device (128-132)
  • _device (228-232)
  • _device (346-350)
  • _device (684-688)
  • _handle_coordinator_update (135-165)
  • _handle_coordinator_update (235-265)
  • _handle_coordinator_update (375-443)
  • _handle_coordinator_update (696-733)
custom_components/tornado/aux_cloud/__init__.py (1)
  • get_devices (369-406)
tests/test_switch.py (1)
  • cooldown_switch (75-85)
custom_components/tornado/climate.py (3)
custom_components/tornado/__init__.py (1)
  • AuxCloudDataUpdateCoordinator (27-104)
tests/test_climate.py (1)
  • coordinator (55-62)
custom_components/tornado/aux_cloud/__init__.py (1)
  • get_devices (369-406)
🪛 GitHub Actions: Lint
tests/test_select.py

[error] 1-1: E501 Line too long (90 > 88).

custom_components/tornado/aux_cloud/aux_cloud_ws.py

[error] 16-16: ANN201 Missing return type annotation for private function ___init__.

tests/test_integration.py

[error] 3-3: I001: Import block is unsorted or un-formatted.

custom_components/tornado/config_flow.py

[error] 102-102: W293: Blank line contains whitespace.

tests/test_sensor.py

[error] 3-3: Import block is unsorted or unformatted.

custom_components/tornado/sensor.py

[error] 3-3: I001: Import block is unsorted or un-formatted.

custom_components/tornado/__init__.py

[error] 4-4: I001: Import block is unsorted or un-formatted. Organize imports.


[error] 23-23: E501: Line too long (97 > 88).


[error] 46-46: W293: Blank line contains whitespace.


[error] 48-48: W291: Trailing whitespace.


[error] 49-49: W291: Trailing whitespace.


[error] 50-50: W291: Trailing whitespace.


[error] 60-60: Q000: Single quotes found but double quotes preferred.


[error] 63-63: E501: Line too long (97 > 88).

custom_components/tornado/select.py

[error] 1-1: A005: Module 'select' shadows a Python standard-library module.


[error] 3-3: I001: Import block is unsorted or un-formatted.

custom_components/tornado/climate.py

[error] 4-4: I001: Import block is unsorted or un-formatted.


[error] 19-19: F401: 'DataUpdateCoordinator' imported but unused.


[error] 22-22: TC001: Move application import .AuxCloudDataUpdateCoordinator into a type-checking block.

🪛 LanguageTool
README.md

[grammar] ~39-~39: There might be a mistake here.
Context: ... power, mode, temperature, and fan speed

  • Real-time Monitoring: Current temperat...

(QB_NEW_EN)


[grammar] ~40-~40: There might be a mistake here.
Context: ...rature, humidity, and operational status

  • Advanced Timer System: Set timers with...

(QB_NEW_EN)


[grammar] ~41-~41: There might be a mistake here.
Context: ... multiple actions (turn off, sleep mode)

  • 🆕 CoolDown Mode: Instant cooling reli...

(QB_NEW_EN)


[grammar] ~43-~43: There might be a mistake here.
Context: ...atic transition to quiet operation

  • Sleep Mode: Direct control of quiet ope...

(QB_NEW_EN)


[grammar] ~44-~44: There might be a mistake here.
Context: ...ct control of quiet operation mode

  • Smart Automations: Perfect integration ...

(QB_NEW_EN)


[grammar] ~44-~44: There might be a mistake here.
Context: ...h Home Assistant automations and scripts

CoolDown Feature

The new CoolDown featur...

(QB_NEW_EN)


[grammar] ~46-~46: There might be a mistake here.
Context: ...ions and scripts

CoolDown Feature

The new CoolDown feature provides instan...

(QB_NEW_EN)


[grammar] ~47-~47: There might be a mistake here.
Context: ...feature provides instant cooling relief:

  • One-button activation - Even if AC is ...

(QB_NEW_EN)


[grammar] ~56-~56: There might be a mistake here.
Context: ... usage instructions.

Timer System

Advanced software-based timer functional...

(QB_NEW_EN)


[grammar] ~57-~57: There might be a mistake here.
Context: ...nced software-based timer functionality:

  • Flexible durations - Up to 8 hours
  • ...

(QB_NEW_EN)


[grammar] ~58-~58: There might be a mistake here.
Context: ...- Flexible durations - Up to 8 hours

  • Multiple actions - Turn off or enable ...

(QB_NEW_EN)


[grammar] ~59-~59: There might be a mistake here.
Context: ...ctions** - Turn off or enable sleep mode

  • Smart cancellation - Automatically can...

(QB_NEW_EN)


[grammar] ~68-~68: There might be a mistake here.
Context: ...ornado Aircon unit:

Core Entities

  • Climate Entity: `climate.tornado_[devi...

(QB_NEW_EN)


[grammar] ~69-~69: There might be a mistake here.
Context: ...e.tornado_[device_id]` - Main AC control

  • Timer Sensor: `sensor.tornado_[device_...

(QB_NEW_EN)


[grammar] ~70-~70: There might be a mistake here.
Context: ...timer` - Timer status and remaining time

  • Sleep Mode Switch: `switch.tornado_[de...

(QB_NEW_EN)


[grammar] ~71-~71: There might be a mistake here.
Context: ..._sleep_mode` - Direct sleep mode control

  • 🆕 CoolDown Switch: `switch.tornado_[d...

(QB_NEW_EN)


[grammar] ~72-~72: There might be a mistake here.
Context: ...d]_cooldown` - One-button cooling relief

Configuration Entities

  • **Timer Durat...

(QB_NEW_EN)


[grammar] ~74-~74: There might be a mistake here.
Context: ...ing relief

Configuration Entities

  • Timer Duration: `number.tornado_[devic...

(QB_NEW_EN)


[grammar] ~75-~75: There might be a mistake here.
Context: ...ration` - Set timer duration (0-480 min)

  • 🆕 CoolDown Duration: `number.tornado_...

(QB_NEW_EN)


[grammar] ~77-~77: There might be a mistake here.
Context: ...- Set cooldown duration (1-30 min)

  • 🆕 CoolDown Target Fan: `number.tornado_...

(QB_NEW_EN)


[grammar] ~79-~79: There might be a mistake here.
Context: ...n` - Set fan mode after cooldown

Services

  • tornado.set_timer - Set time...

(QB_NEW_EN)


[grammar] ~81-~81: There might be a mistake here.
Context: ...nado.cancel_timer` - Cancel active timer

  • 🆕 tornado.start_cooldown - Start co...

(QB_NEW_EN)


[grammar] ~83-~83: There might be a mistake here.
Context: ...tart cooldown with custom settings

  • 🆕 tornado.cancel_cooldown - Cancel ac...

(QB_NEW_EN)


[grammar] ~84-~84: There might be a mistake here.
Context: ...l_cooldown` - Cancel active cooldown

All entities can be used in automations, s...

(QB_NEW_EN)

🪛 GitHub Actions: Validate
custom_components/tornado/translations/en.json

[error] 1-1: Hassfest translation validation failed. Invalid translations/en.json: expected a dictionary for dictionary value @ data['entity']['number']['tornado_cooldown_duration']['state_attributes']['unit_of_measurement']. Got 'minutes'.

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: build
🔇 Additional comments (12)
custom_components/tornado/translations/ru.json (1)

61-114: RU translations LGTM; options align with constants

All option keys match the constants; casing and terms read naturally. Nice.

custom_components/tornado/translations/he.json (1)

61-114: HE translations LGTM; option keys consistent

Keys map correctly; spacing and terms look good.

custom_components/tornado/climate.py (1)

84-92: Good: Reuse coordinator created in init.py

Using the shared coordinator from domain data avoids duplicate polling and aligns with the new WS-driven flow.

tests/test_sensor.py (2)

56-64: Good initialization coverage.

Validates unique_id, naming, classes, unit, and icon. Solid baseline for the entity contract.


106-117: Time formatting cases well covered.

Edge cases (0/negative) and hour/minute boundaries are comprehensively asserted.

tests/test_select.py (1)

179-196: Icon mapping coverage is great.

Verifies the visual feedback for each fan mode, aligned with FAN_MODE_NAME_TO_VALUE and icon map.

tests/test_integration.py (2)

113-157: End-to-end Off Timer flow looks solid.

Good use of patching to isolate scheduling side effects and asserting sensor updates.


215-263: End-to-end CoolDown flow is well exercised.

Covers config, starting fan write, timer scheduling, and completion behavior.

custom_components/tornado/sensor.py (2)

132-145: Time formatting helper is clean and user-friendly.

Ceil-to-minute logic ensures UI shows “1m” for partial minutes, and hour granularity is intuitive.


95-106: Translation key/name: please confirm translations exist.

translation_key=f"{DOMAIN}_off_timer_status" implies entries like entity.sensor.tornado_off_timer_status.* in translations. Ensure en.json (and others) provide these keys.

If missing, I can add stubs for en/he/ru.

tests/test_websocket.py (1)

83-127: Great end-to-end initialization coverage (including URL, headers, and task scheduling).

The success-path test validates the connection URL, header propagation, WebSocket object storage, and that init/send logic is invoked. Mocking asyncio.create_task to avoid pending tasks is a neat way to keep the suite clean.

custom_components/tornado/select.py (1)

178-188: Block changes during active cooldown: good guardrail.

Preventing edits while cooldown is active avoids confusing, mid-flight reconfigurations. Nice.

Comment on lines +145 to +231
# Register services
def get_timer_entity(entity_id: str):
"""Get timer entity by entity_id."""
entity_registry = hass.helpers.entity_registry.async_get(hass)
entity_entry = entity_registry.async_get(entity_id)

if entity_entry and entity_entry.platform == DOMAIN:
# Get the actual entity object
for component in hass.data.get("entity_components", {}).values():
if hasattr(component, "entities"):
for entity in component.entities:
if entity.entity_id == entity_id and hasattr(entity, 'async_set_timer'):
return entity
return None

async def handle_set_timer(call):
"""Handle set timer service call."""
entity_id = call.data.get("entity_id")
duration = call.data.get("duration", 0)
action = call.data.get("action", "turn_off")
_LOGGER.debug("Setting timer for entity %s with duration %s and action %s", entity_id, duration, action)
entity = get_timer_entity(entity_id)
if entity:
await entity.async_set_timer(duration, action)
else:
_LOGGER.error("Timer entity not found: %s", entity_id)

async def handle_cancel_timer(call):
"""Handle cancel timer service call."""

entity_id = call.data.get("entity_id")
_LOGGER.debug("Handling cancel timer service call for entity %s", entity_id)
entity = get_timer_entity(entity_id)
if entity:
await entity.async_cancel_timer()
else:
_LOGGER.error("Timer entity not found: %s", entity_id)

def get_cooldown_entity(entity_id: str):
"""Get cooldown entity by entity_id."""
# Look through all entity components for the cooldown switch
for component in hass.data.get("entity_components", {}).values():
if hasattr(component, "entities"):
for entity in component.entities:
if (hasattr(entity, "entity_id") and
entity.entity_id == entity_id and
hasattr(entity, "_cooldown_active")):
return entity
return None

async def handle_start_cooldown(call):
"""Handle start cooldown service call."""
entity_id = call.data.get("entity_id")
duration = call.data.get("duration", 5)
target_fan_mode = call.data.get("target_fan_mode", 5)

_LOGGER.debug(
"Handling start cooldown service call for entity %s with duration %s and target fan %s",
entity_id, duration, target_fan_mode
)

entity = get_cooldown_entity(entity_id)
if entity:
# Update configuration
entity._cooldown_duration = duration
entity._target_fan_mode = target_fan_mode
# Start cooldown
await entity.async_turn_on()
else:
_LOGGER.error("CoolDown entity not found: %s", entity_id)

async def handle_cancel_cooldown(call):
"""Handle cancel cooldown service call."""
entity_id = call.data.get("entity_id")
_LOGGER.debug("Handling cancel cooldown service call for entity %s", entity_id)

entity = get_cooldown_entity(entity_id)
if entity:
await entity.async_turn_off()
else:
_LOGGER.error("CoolDown entity not found: %s", entity_id)

hass.services.async_register(DOMAIN, "set_timer", handle_set_timer)
hass.services.async_register(DOMAIN, "cancel_timer", handle_cancel_timer)
hass.services.async_register(DOMAIN, "start_cooldown", handle_start_cooldown)
hass.services.async_register(DOMAIN, "cancel_cooldown", handle_cancel_cooldown)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Service wiring gaps: CoolDown params are ignored; Off Timer service targets missing methods.

  • start_cooldown sets entity._cooldown_duration and _target_fan_mode, but TornadoCoolDownSwitch.async_turn_on ignores these and fetches values only from Select entities. The service-provided params are currently no-ops.
  • set_timer/cancel_timer expect entities to implement async_set_timer/async_cancel_timer. TornadoOffTimerControlSwitch doesn’t expose these methods, so get_timer_entity won’t find a match and services will always log “Timer entity not found”.

Please either:

  • Make CoolDownSwitch consume the service overrides (preferred; see switch.py comment + diff), and
  • Add async_set_timer/async_cancel_timer to OffTimerControlSwitch (see switch.py comment + diff),

or adjust the services to call the exposed methods.

Comment on lines +848 to +872

async def initialize_websocket(self):
"""
Initialize the WebSocket connection to receive real-time updates.
"""
# Check if we're logged in
if not hasattr(self, "loginsession") or not self.loginsession:
_LOGGER.debug("No login session found, attempting to login")
await self.login()

self.ws_api = AuxCloudWebSocket(
region=self.region,
headers=self._get_headers(CompanyId=COMPANY_ID, Origin=self.url),
loginsession=self.loginsession,
userid=self.userid,
)
await self.ws_api.initialize_websocket()

timeout = 10 # Timeout in seconds
start_time = time.time()
while not self.ws_api.api_initialized:
if time.time() - start_time > timeout:
raise TimeoutError("WebSocket API initialization timed out.")

_LOGGER.debug("Waiting for WebSocket API to initialize...")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Close the temporary WebSocket on timeout and use a monotonic clock to avoid dangling tasks

If api_initialized isn't reached, this method raises but leaves the WebSocket and its background tasks running. Also, time.time() is not monotonic. Close the WS before raising and use time.monotonic() for robustness.

Apply this diff:

@@
-        await self.ws_api.initialize_websocket()
-
-        timeout = 10  # Timeout in seconds
-        start_time = time.time()
-        while not self.ws_api.api_initialized:
-            if time.time() - start_time > timeout:
-                raise TimeoutError("WebSocket API initialization timed out.")
-
-            _LOGGER.debug("Waiting for WebSocket API to initialize...")
-            await asyncio.sleep(1)
+        await self.ws_api.initialize_websocket()
+
+        timeout = 10  # Timeout in seconds
+        start_time = time.monotonic()
+        while not self.ws_api.api_initialized:
+            if time.monotonic() - start_time > timeout:
+                # Ensure background WS tasks don't linger
+                try:
+                    await self.ws_api.close_websocket()
+                finally:
+                    raise TimeoutError("WebSocket API initialization timed out.")
+
+            _LOGGER.debug("Waiting for WebSocket API to initialize...")
+            await asyncio.sleep(1)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def initialize_websocket(self):
"""
Initialize the WebSocket connection to receive real-time updates.
"""
# Check if we're logged in
if not hasattr(self, "loginsession") or not self.loginsession:
_LOGGER.debug("No login session found, attempting to login")
await self.login()
self.ws_api = AuxCloudWebSocket(
region=self.region,
headers=self._get_headers(CompanyId=COMPANY_ID, Origin=self.url),
loginsession=self.loginsession,
userid=self.userid,
)
await self.ws_api.initialize_websocket()
timeout = 10 # Timeout in seconds
start_time = time.time()
while not self.ws_api.api_initialized:
if time.time() - start_time > timeout:
raise TimeoutError("WebSocket API initialization timed out.")
_LOGGER.debug("Waiting for WebSocket API to initialize...")
await self.ws_api.initialize_websocket()
timeout = 10 # Timeout in seconds
start_time = time.monotonic()
while not self.ws_api.api_initialized:
if time.monotonic() - start_time > timeout:
# Ensure background WS tasks don't linger
try:
await self.ws_api.close_websocket()
finally:
raise TimeoutError("WebSocket API initialization timed out.")
_LOGGER.debug("Waiting for WebSocket API to initialize...")
await asyncio.sleep(1)
🤖 Prompt for AI Agents
In custom_components/tornado/aux_cloud/__init__.py around lines 848 to 872, the
initialize_websocket loop can raise a TimeoutError while leaving the
AuxCloudWebSocket and its background tasks running and it uses time.time() which
isn’t monotonic; update the loop to use time.monotonic() for timing and, before
raising the TimeoutError, close the temporary websocket and await its shutdown
(e.g. call the ws_api close/cleanup method and await it if it is a coroutine),
guarding the call in case ws_api was never fully created; then raise the
TimeoutError.

Comment on lines +858 to +865
self.ws_api = AuxCloudWebSocket(
region=self.region,
headers=self._get_headers(CompanyId=COMPANY_ID, Origin=self.url),
loginsession=self.loginsession,
userid=self.userid,
)
await self.ws_api.initialize_websocket()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Follow-up: The underlying WS implementation disables TLS and leaks ClientSession objects

Calling AuxCloudWebSocket.initialize_websocket() currently does:

  • session = aiohttp.ClientSession() and never closes it
  • ws_connect(..., ssl=False) which disables TLS verification

Both are security/stability issues. Please fix in custom_components/tornado/aux_cloud/aux_cloud_ws.py.

Proposed patch (in aux_cloud_ws.py):

 class AuxCloudWebSocket:
     def __init__(self, region: str, headers, loginsession, userid):
@@
-        self.websocket: aiohttp.ClientWebSocketResponse = None
+        self.websocket: aiohttp.ClientWebSocketResponse | None = None
+        self._session: aiohttp.ClientSession | None = None
@@
-            session = aiohttp.ClientSession()
-            self.websocket = await session.ws_connect(
-                url, headers=self.headers, ssl=False
-            )
+            session = aiohttp.ClientSession()
+            self._session = session
+            # Use default SSL verification; remove ssl=False
+            self.websocket = await session.ws_connect(url, headers=self.headers)
@@
     async def close_websocket(self):
         """
         Close the WebSocket connection and stop reconnection attempts.
         """
         self._stop_reconnect.set()
         if self._reconnect_task:
             self._reconnect_task.cancel()
             self._reconnect_task = None
 
         if self.websocket:
             await self.websocket.close()
             self.websocket = None
             _LOGGER.warning("WebSocket connection closed.")
+        if self._session and not self._session.closed:
+            await self._session.close()
+            self._session = None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
self.ws_api = AuxCloudWebSocket(
region=self.region,
headers=self._get_headers(CompanyId=COMPANY_ID, Origin=self.url),
loginsession=self.loginsession,
userid=self.userid,
)
await self.ws_api.initialize_websocket()
class AuxCloudWebSocket:
def __init__(self, region: str, headers, loginsession, userid):
self.region = region
self.headers = headers
self.loginsession = loginsession
self.userid = userid
self.websocket: aiohttp.ClientWebSocketResponse | None = None
self._session: aiohttp.ClientSession | None = None
async def initialize_websocket(self):
url = f"{self.region}/ws"
- session = aiohttp.ClientSession()
- self.websocket = await session.ws_connect(
- url, headers=self.headers, ssl=False
session = aiohttp.ClientSession()
self._session = session
# Use default SSL verification; remove ssl=False
self.websocket = await session.ws_connect(url, headers=self.headers)
async def close_websocket(self):
"""
Close the WebSocket connection and stop reconnection attempts.
"""
self._stop_reconnect.set()
if self._reconnect_task:
self._reconnect_task.cancel()
self._reconnect_task = None
if self.websocket:
await self.websocket.close()
self.websocket = None
_LOGGER.warning("WebSocket connection closed.")
if self._session and not self._session.closed:
await self._session.close()
self._session = None

Comment on lines +16 to +27
def __init__(self, region: str, headers, loginsession, userid):
self.websocket_url = (
WEBSOCKET_SERVER_URL_EU
if region == "eu"
else (
WEBSOCKET_SERVER_URL_USA if region == "usa" else WEBSOCKET_SERVER_URL_CN
)
)
self.headers = headers
self.loginsession = loginsession
self.userid = userid

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix linter error and add missing type hints for fields and tasks

Pipeline reports: “ANN201 Missing return type annotation for private function init.” Add -> None and type annotations for key attributes, plus placeholders for task/session handles.

Apply:

-class AuxCloudWebSocket:
-    def __init__(self, region: str, headers, loginsession, userid):
+class AuxCloudWebSocket:
+    def __init__(self, region: str, headers, loginsession, userid) -> None:
         self.websocket_url = (
             WEBSOCKET_SERVER_URL_EU
             if region == "eu"
             else (
                 WEBSOCKET_SERVER_URL_USA if region == "usa" else WEBSOCKET_SERVER_URL_CN
             )
         )
-        self.headers = headers
-        self.loginsession = loginsession
-        self.userid = userid
-
-        self.websocket: aiohttp.ClientWebSocketResponse = None
-        self._listeners = []
-        self._reconnect_task = None
-        self._stop_reconnect = asyncio.Event()
-        self.api_initialized = False
+        self.headers: dict = headers
+        self.loginsession: str = loginsession
+        self.userid: str = userid
+
+        self.websocket: aiohttp.ClientWebSocketResponse | None = None
+        self._listeners: list = []
+        self._reconnect_task: asyncio.Task | None = None
+        self._stop_reconnect: asyncio.Event = asyncio.Event()
+        self.api_initialized: bool = False
+        # New: keep handles to tasks and session for proper cleanup
+        self._listener_task: asyncio.Task | None = None
+        self._keepalive_task: asyncio.Task | None = None
+        self._session: aiohttp.ClientSession | None = None

Also applies to: 28-33

🧰 Tools
🪛 GitHub Actions: Lint

[error] 16-16: ANN201 Missing return type annotation for private function ___init__.

🤖 Prompt for AI Agents
In custom_components/tornado/aux_cloud/aux_cloud_ws.py around lines 16-27 (and
also apply same to lines 28-33), add a return type annotation "-> None" to the
__init__ signature and add explicit type annotations for instance attributes
(e.g. websocket_url: str, headers: dict[str, str] or Mapping[str, str],
loginsession: Optional[YourSessionType], userid: str) and declare placeholders
for async task/session handles (e.g. reconnect_task: Optional[asyncio.Task] =
None, listen_task: Optional[asyncio.Task] = None) so linters know the types;
update assignments inside __init__ to match these annotated attributes and
import typing names (Optional, Mapping) as needed.

Comment on lines +41 to +44
session = aiohttp.ClientSession()
self.websocket = await session.ws_connect(
url, headers=self.headers, ssl=False
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Do not disable TLS verification; manage aiohttp session lifecycle

Passing ssl=False disables certificate verification on wss endpoints (security risk) and violates HA best practices. Also, a new ClientSession is created on every (re)connect and never closed, leaking resources.

Apply:

-            session = aiohttp.ClientSession()
-            self.websocket = await session.ws_connect(
-                url, headers=self.headers, ssl=False
-            )
+            # Reuse a single ClientSession; create if missing/closed
+            if self._session is None or self._session.closed:
+                self._session = aiohttp.ClientSession()
+            self.websocket = await self._session.ws_connect(url, headers=self.headers)

Follow-up:

  • Re-run the CI linters after this change to ensure no more SSL-related warnings.
  • Verify that connection still succeeds against all 3 regions with TLS verification enabled.

🏁 Script executed:

#!/bin/bash
# Locate aux_cloud_ws.py
echo "Locating aux_cloud_ws.py..."
file=$(find . -type f -name "aux_cloud_ws.py")
if [[ -z "$file" ]]; then
  echo "aux_cloud_ws.py not found"
  exit 1
fi
echo "Found file at: $file"
# Display class definition and __init__ to inspect session attribute
echo "=== Lines 1-200 ==="
sed -n '1,200p' "$file"
# Display WebSocket connect implementation around lines 200-300
echo "=== Lines 200-300 ==="
sed -n '200,300p' "$file"

Length of output: 7770


Ensure TLS verification and properly manage the aiohttp ClientSession

The current code creates a new ClientSession for every connect (never closing it) and passes ssl=False, which disables certificate validation on wss:// endpoints. This is a security risk and leaks resources. You should:

• Initialize a single session in __init__
• Reuse (or recreate) that session for each ws_connect call
• Remove ssl=False so that TLS certificates are properly verified
• Close the session when tearing down the WebSocket

Apply the following changes:

— class AuxCloudWebSocket:
    def __init__(self, region: str, headers, loginsession, userid):
        self.websocket_url = (
            ...
        )
        self.headers = headers
        self.loginsession = loginsession
        self.userid = userid

        self.websocket: aiohttp.ClientWebSocketResponse = None
        self._listeners = []
        self._reconnect_task = None
        self._stop_reconnect = asyncio.Event()
        self.api_initialized = False
+       # Single reusable aiohttp session
+       self._session: aiohttp.ClientSession | None = None

    async def initialize_websocket(self):
        url = f"{self.websocket_url}/appsync/apprelay/relayconnect"
        try:
-            session = aiohttp.ClientSession()
-            self.websocket = await session.ws_connect(
-                url, headers=self.headers, ssl=False
-            )
+            # Reuse existing session or create a new one
+            if not self._session or self._session.closed:
+                self._session = aiohttp.ClientSession()
+            # Default SSL verification is enabled by omitting ssl=False
+            self.websocket = await self._session.ws_connect(
+                url, headers=self.headers
+            )
            _LOGGER.info("WebSocket connection established.")
            ...

    async def close_websocket(self):
        self._stop_reconnect.set()
        if self._reconnect_task:
            self._reconnect_task.cancel()
            self._reconnect_task = None

        if self.websocket:
            await self.websocket.close()
            self.websocket = None
            _LOGGER.warning("WebSocket connection closed.")
+       # Also close the shared session to free resources
+       if self._session and not self._session.closed:
+           await self._session.close()
+           self._session = None

Follow-up:

  • Re-run CI linters to confirm no more SSL or session warnings.
  • Test connections against all three regions with TLS verification enabled.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
session = aiohttp.ClientSession()
self.websocket = await session.ws_connect(
url, headers=self.headers, ssl=False
)
class AuxCloudWebSocket:
def __init__(self, region: str, headers, loginsession, userid):
self.websocket_url = (
...
)
self.headers = headers
self.loginsession = loginsession
self.userid = userid
self.websocket: aiohttp.ClientWebSocketResponse = None
self._listeners = []
self._reconnect_task = None
self._stop_reconnect = asyncio.Event()
self.api_initialized = False
# Single reusable aiohttp session
self._session: aiohttp.ClientSession | None = None
async def initialize_websocket(self):
url = f"{self.websocket_url}/appsync/apprelay/relayconnect"
try:
# Reuse existing session or create a new one
if not self._session or self._session.closed:
self._session = aiohttp.ClientSession()
# Default SSL verification is enabled by omitting ssl=False
self.websocket = await self._session.ws_connect(
url, headers=self.headers
)
_LOGGER.info("WebSocket connection established.")
...
async def close_websocket(self):
self._stop_reconnect.set()
if self._reconnect_task:
self._reconnect_task.cancel()
self._reconnect_task = None
if self.websocket:
await self.websocket.close()
self.websocket = None
_LOGGER.warning("WebSocket connection closed.")
# Also close the shared session to free resources
if self._session and not self._session.closed:
await self._session.close()
self._session = None
🤖 Prompt for AI Agents
In custom_components/tornado/aux_cloud/aux_cloud_ws.py around lines 41-44, the
code creates a new aiohttp.ClientSession for each connect and passes ssl=False
which disables TLS validation and leaks sessions; fix by creating and storing a
single aiohttp.ClientSession on self in __init__, reuse that session for
self.websocket = await self.session.ws_connect(url, headers=self.headers)
(remove ssl=False), ensure the session is recreated if closed or invalid before
connecting, and close/await self.session.close() when tearing down the
WebSocket/cleanup path so sessions are not leaked and TLS verification remains
enabled.

Comment on lines +68 to +78
### Core Entities
- **Climate Entity**: `climate.tornado_[device_id]` - Main AC control
- **Timer Sensor**: `sensor.tornado_[device_id]_timer` - Timer status and remaining time
- **Sleep Mode Switch**: `switch.tornado_[device_id]_sleep_mode` - Direct sleep mode control
- **🆕 CoolDown Switch**: `switch.tornado_[device_id]_cooldown` - One-button cooling relief

### Configuration Entities
- **Timer Duration**: `number.tornado_[device_id]_timer_duration` - Set timer duration (0-480 min)
- **🆕 CoolDown Duration**: `number.tornado_[device_id]_cooldown_duration` - Set cooldown duration (1-30 min)
- **🆕 CoolDown Target Fan**: `number.tornado_[device_id]_cooldown_target_fan` - Set fan mode after cooldown

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Entity IDs and platforms don’t match the implementation.

  • Sensor entity in code/tests is “off timer” (e.g., sensor.tornado_[device_id]_off_timer), not ..._timer.
  • Duration entities are Selects, not Numbers (see custom_components/tornado/select.py).

Apply:

-### Core Entities
-- **Climate Entity**: `climate.tornado_[device_id]` - Main AC control
-- **Timer Sensor**: `sensor.tornado_[device_id]_timer` - Timer status and remaining time
-- **Sleep Mode Switch**: `switch.tornado_[device_id]_sleep_mode` - Direct sleep mode control
-- **🆕 CoolDown Switch**: `switch.tornado_[device_id]_cooldown` - One-button cooling relief
+### Core Entities
+- **Climate Entity**: `climate.tornado_[device_id]` - Main AC control
+- **Off Timer Sensor**: `sensor.tornado_[device_id]_off_timer` - Off timer status and remaining time
+- **Sleep Mode Switch**: `switch.tornado_[device_id]_sleep_mode` - Direct sleep mode control
+- **🆕 CoolDown Switch**: `switch.tornado_[device_id]_cooldown` - One-button cooling relief
@@
-### Configuration Entities  
-- **Timer Duration**: `number.tornado_[device_id]_timer_duration` - Set timer duration (0-480 min)
-- **🆕 CoolDown Duration**: `number.tornado_[device_id]_cooldown_duration` - Set cooldown duration (1-30 min)
-- **🆕 CoolDown Target Fan**: `number.tornado_[device_id]_cooldown_target_fan` - Set fan mode after cooldown
+### Configuration Entities
+- **Off Timer Duration**: `select.tornado_[device_id]_off_timer_duration` - Set off timer duration
+- **🆕 CoolDown Duration**: `select.tornado_[device_id]_cooldown_duration` - Set cooldown duration
+- **🆕 CoolDown Target Fan**: `select.tornado_[device_id]_cooldown_target_fan` - Set fan mode after cooldown
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Core Entities
- **Climate Entity**: `climate.tornado_[device_id]` - Main AC control
- **Timer Sensor**: `sensor.tornado_[device_id]_timer` - Timer status and remaining time
- **Sleep Mode Switch**: `switch.tornado_[device_id]_sleep_mode` - Direct sleep mode control
- **🆕 CoolDown Switch**: `switch.tornado_[device_id]_cooldown` - One-button cooling relief
### Configuration Entities
- **Timer Duration**: `number.tornado_[device_id]_timer_duration` - Set timer duration (0-480 min)
- **🆕 CoolDown Duration**: `number.tornado_[device_id]_cooldown_duration` - Set cooldown duration (1-30 min)
- **🆕 CoolDown Target Fan**: `number.tornado_[device_id]_cooldown_target_fan` - Set fan mode after cooldown
### Core Entities
- **Climate Entity**: `climate.tornado_[device_id]` - Main AC control
- **Off Timer Sensor**: `sensor.tornado_[device_id]_off_timer` - Off timer status and remaining time
- **Sleep Mode Switch**: `switch.tornado_[device_id]_sleep_mode` - Direct sleep mode control
- **🆕 CoolDown Switch**: `switch.tornado_[device_id]_cooldown` - One-button cooling relief
### Configuration Entities
- **Off Timer Duration**: `select.tornado_[device_id]_off_timer_duration` - Set off timer duration
- **🆕 CoolDown Duration**: `select.tornado_[device_id]_cooldown_duration` - Set cooldown duration
- **🆕 CoolDown Target Fan**: `select.tornado_[device_id]_cooldown_target_fan` - Set fan mode after cooldown
🧰 Tools
🪛 LanguageTool

[grammar] ~68-~68: There might be a mistake here.
Context: ...ornado Aircon unit:

Core Entities

  • Climate Entity: `climate.tornado_[devi...

(QB_NEW_EN)


[grammar] ~69-~69: There might be a mistake here.
Context: ...e.tornado_[device_id]` - Main AC control

  • Timer Sensor: `sensor.tornado_[device_...

(QB_NEW_EN)


[grammar] ~70-~70: There might be a mistake here.
Context: ...timer` - Timer status and remaining time

  • Sleep Mode Switch: `switch.tornado_[de...

(QB_NEW_EN)


[grammar] ~71-~71: There might be a mistake here.
Context: ..._sleep_mode` - Direct sleep mode control

  • 🆕 CoolDown Switch: `switch.tornado_[d...

(QB_NEW_EN)


[grammar] ~72-~72: There might be a mistake here.
Context: ...d]_cooldown` - One-button cooling relief

Configuration Entities

  • **Timer Durat...

(QB_NEW_EN)


[grammar] ~74-~74: There might be a mistake here.
Context: ...ing relief

Configuration Entities

  • Timer Duration: `number.tornado_[devic...

(QB_NEW_EN)


[grammar] ~75-~75: There might be a mistake here.
Context: ...ration` - Set timer duration (0-480 min)

  • 🆕 CoolDown Duration: `number.tornado_...

(QB_NEW_EN)


[grammar] ~77-~77: There might be a mistake here.
Context: ...- Set cooldown duration (1-30 min)

  • 🆕 CoolDown Target Fan: `number.tornado_...

(QB_NEW_EN)

🤖 Prompt for AI Agents
In README.md around lines 68-78, the documented entity IDs and platforms are
incorrect: update the Timer Sensor ID to use the actual sensor name suffix
`sensor.tornado_[device_id]_off_timer` (not `..._timer`) and change the Timer
Duration and CoolDown Duration entries from Number entities to Select entities
to match custom_components/tornado/select.py (i.e.,
`select.tornado_[device_id]_timer_duration` and
`select.tornado_[device_id]_cooldown_duration`), and verify the CoolDown Target
Fan entry matches its actual platform/entity name in code (adjust to
`select.tornado_[device_id]_cooldown_target_fan` if it is implemented as a
select; otherwise keep the correct platform).

Comment on lines +3 to +18
import asyncio
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util

from custom_components.tornado.sensor import TornadoOffTimerSensor
from custom_components.tornado.switch import TornadoOffTimerControlSwitch, TornadoCoolDownSwitch
from custom_components.tornado.select import (
TornadoOffTimerDurationSelect,
TornadoCoolDownStartingFanSelect,
TornadoCoolDownTargetFanSelect,
TornadoCoolDownDurationSelect,
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix import sorting (lint I001) to unblock CI.

Group stdlib, third-party, then local imports.

Apply:

-import asyncio
-from datetime import timedelta
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from homeassistant.core import HomeAssistant
-from homeassistant.util import dt as dt_util
-
-from custom_components.tornado.sensor import TornadoOffTimerSensor
-from custom_components.tornado.switch import TornadoOffTimerControlSwitch, TornadoCoolDownSwitch
+import asyncio
+from datetime import timedelta
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
+
 from custom_components.tornado.select import (
     TornadoOffTimerDurationSelect,
     TornadoCoolDownStartingFanSelect,
     TornadoCoolDownTargetFanSelect,
     TornadoCoolDownDurationSelect,
 )
+from custom_components.tornado.sensor import TornadoOffTimerSensor
+from custom_components.tornado.switch import (
+    TornadoCoolDownSwitch,
+    TornadoOffTimerControlSwitch,
+)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import asyncio
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from custom_components.tornado.sensor import TornadoOffTimerSensor
from custom_components.tornado.switch import TornadoOffTimerControlSwitch, TornadoCoolDownSwitch
from custom_components.tornado.select import (
TornadoOffTimerDurationSelect,
TornadoCoolDownStartingFanSelect,
TornadoCoolDownTargetFanSelect,
TornadoCoolDownDurationSelect,
)
import asyncio
from datetime import timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from custom_components.tornado.select import (
TornadoOffTimerDurationSelect,
TornadoCoolDownStartingFanSelect,
TornadoCoolDownTargetFanSelect,
TornadoCoolDownDurationSelect,
)
from custom_components.tornado.sensor import TornadoOffTimerSensor
from custom_components.tornado.switch import (
TornadoCoolDownSwitch,
TornadoOffTimerControlSwitch,
)
🧰 Tools
🪛 GitHub Actions: Lint

[error] 3-3: I001: Import block is unsorted or un-formatted.

🤖 Prompt for AI Agents
In tests/test_integration.py around lines 3 to 18, the imports are not
grouped/sorted per lint rule I001; reorder imports to group standard library
first (asyncio, datetime), then third-party (pytest, homeassistant.* and
unittest.mock), then local package imports (custom_components.tornado.*). Ensure
within each group imports are alphabetized and separate groups by a blank line
to satisfy the linter.

@@ -0,0 +1,435 @@
"""Tests for the Tornado AC select component (Off Timer Duration and CoolDown selects)."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix line length (E501): shorten or break the module docstring.

Current line is 90 chars (>88). Rephrase to two-line docstring.

Apply:

-"""Tests for the Tornado AC select component (Off Timer Duration and CoolDown selects)."""
+"""
+Tests for the Tornado AC select component.
+Covers Off Timer Duration and CoolDown selects.
+"""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""Tests for the Tornado AC select component (Off Timer Duration and CoolDown selects)."""
"""
Tests for the Tornado AC select component.
Covers Off Timer Duration and CoolDown selects.
"""
🧰 Tools
🪛 GitHub Actions: Lint

[error] 1-1: E501 Line too long (90 > 88).

🤖 Prompt for AI Agents
In tests/test_select.py around line 1, the module docstring exceeds line-length
(E501); replace the single 90-char string with a two-line docstring: a short
summary on the first line (<=72 chars) and a brief continuation on the second
line so each line is under 88 characters (or wrap to multiple lines), keeping
proper triple-quote delimiters and running flake8 to confirm E501 is resolved.

Comment on lines +3 to +15
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock

import pytest
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util

from custom_components.tornado.sensor import TornadoOffTimerSensor
from custom_components.tornado.const import DOMAIN

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fix import sorting (lint I001) to unblock CI.

isort/flake8 reports the imports are unsorted. Reorder and group stdlib, third-party, then local imports.

Apply:

-import asyncio
-from datetime import datetime, timedelta
-from unittest.mock import AsyncMock, MagicMock
-
-import pytest
-from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
-from homeassistant.const import UnitOfTime
-from homeassistant.core import HomeAssistant
-from homeassistant.util import dt as dt_util
-
-from custom_components.tornado.sensor import TornadoOffTimerSensor
-from custom_components.tornado.const import DOMAIN
+import asyncio
+from datetime import datetime, timedelta
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
+from homeassistant.const import UnitOfTime
+from homeassistant.core import HomeAssistant
+from homeassistant.util import dt as dt_util
+
+from custom_components.tornado.const import DOMAIN
+from custom_components.tornado.sensor import TornadoOffTimerSensor
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from custom_components.tornado.sensor import TornadoOffTimerSensor
from custom_components.tornado.const import DOMAIN
import asyncio
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, MagicMock
import pytest
from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass
from homeassistant.const import UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from custom_components.tornado.const import DOMAIN
from custom_components.tornado.sensor import TornadoOffTimerSensor
🧰 Tools
🪛 GitHub Actions: Lint

[error] 3-3: Import block is unsorted or unformatted.

🤖 Prompt for AI Agents
In tests/test_sensor.py around lines 3 to 15 the imports are unsorted causing
lint I001; reorder them into three groups (standard library, third-party, local)
separated by a single blank line, and alphabetize within each group (e.g., move
datetime and asyncio first, then pytest and homeassistant imports, then
custom_components imports), ensuring import style matches project isort rules so
CI lint passes.

Comment on lines +113 to +116
expected_url = f"{WEBSOCKET_SERVER_URL_EU}/appsync/apprelay/relayconnect"
mock_session.ws_connect.assert_called_once_with(
expected_url, headers=MOCK_HEADERS, ssl=False
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Do not assert ssl=False (tests currently enshrine insecure TLS).

initialize_websocket passes ssl=False to ws_connect, disabling TLS verification. The test asserts this, baking an insecure default into the API surface. Prefer allowing default TLS verification (omit ssl), or explicitly provide a proper SSL context. Update both impl and tests accordingly.

Apply this diff to loosen the assertion now and unblock switching the code to verified TLS:

-                mock_session.ws_connect.assert_called_once_with(
-                    expected_url, headers=MOCK_HEADERS, ssl=False
-                )
+                # Verify URL and headers; don't enshrine disabling TLS
+                mock_session.ws_connect.assert_called_once()
+                called_args, called_kwargs = mock_session.ws_connect.call_args
+                assert called_args[0] == expected_url
+                assert called_kwargs["headers"] == MOCK_HEADERS
+                assert "ssl" not in called_kwargs or called_kwargs["ssl"] is not False

Follow-up (impl change in custom_components/tornado/aux_cloud/aux_cloud_ws.py):

  • Remove ssl=False and rely on default verification (or pass a validated SSL context).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
expected_url = f"{WEBSOCKET_SERVER_URL_EU}/appsync/apprelay/relayconnect"
mock_session.ws_connect.assert_called_once_with(
expected_url, headers=MOCK_HEADERS, ssl=False
)
expected_url = f"{WEBSOCKET_SERVER_URL_EU}/appsync/apprelay/relayconnect"
# Verify URL and headers; don't enshrine disabling TLS
mock_session.ws_connect.assert_called_once()
called_args, called_kwargs = mock_session.ws_connect.call_args
assert called_args[0] == expected_url
assert called_kwargs["headers"] == MOCK_HEADERS
assert "ssl" not in called_kwargs or called_kwargs["ssl"] is not False
🤖 Prompt for AI Agents
In tests/test_websocket.py around lines 113-116, the test currently asserts
ws_connect was called with ssl=False which enshrines insecure TLS; update the
test to stop asserting the ssl argument (i.e., assert called with expected_url
and headers only or use assert_called_once_with(expected_url,
headers=MOCK_HEADERS) without ssl) so the test no longer requires disabling TLS,
and then change the implementation in
custom_components/tornado/aux_cloud/aux_cloud_ws.py to remove the ssl=False
argument so ws_connect relies on the default TLS verification (or pass in a
properly configured SSLContext) ensuring secure connections by default.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant