-
Notifications
You must be signed in to change notification settings - Fork 5
feat: Add Off Timer and CoolDown automation features with full workfl… #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…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
WalkthroughAdds 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
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
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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Suggested reviewers
Poem
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 unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
There was a problem hiding this 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 = Truecustom_components/tornado/config_flow.py (1)
100-107: Same here for options: close the temporary WS before returningMirror 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 tocustom_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_websocketcreates 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
AuxCloudWebSocketto 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 handlingWebSocket 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 driftMappings 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" suffixesOff 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 INITDebug 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 inputsA 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 targetsDefinitions 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_listenerreturns an unsubscribe callable. Store and call it inasync_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 = Nonetests/test_switch.py (2)
229-233: Add missing assertion to validate timer cancellation on device power-offThe 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-offMirror 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 sameend_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_optionsAlso 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 publicavailableproperty.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 Falsetests/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 exposingremaining_minutesalongsideremaining_secondsfor 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 tolerateself.websocket = None.
close_websocket()setsself.websocket = None, while_keepalive_loopchecksself.websocket.closedin a loop. Without a guard, this can raiseAttributeErrorif the loop ticks after close. Add a test that simulatesclose_websocket()mid-loop and assert no exception/logged error. Also consider hardening_keepalive_loopto first snapshot-and-checkws = self.websocketand break if falsy or closed.I can draft the negative-path test and the small guard fix in
aux_cloud_ws.pyif you want.custom_components/tornado/__init__.py (7)
6-15: Fix import order and grouping (flake8 I001) and move stdlibtimedeltawith 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: WrapPLATFORMSto 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 onapi.ws_apibeing 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 samews_apiinstance is reused, so the listener remains valid. Consider handling the (unlikely) case wherews_apiis absent (e.g., initialization timeout) by deferring listener registration untilws_apiexists, or guarding with a log and retry.
107-132: Prefer Home Assistant’s shared session over creating a newaiohttp.ClientSession.
async_setup_entrycurrently creates its ownClientSession(). HA best practice isfrom homeassistant.helpers.aiohttp_client import async_get_clientsessionthensession = 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 forcustom_components/tornado/select.py:A005.Example config change (in setup.cfg or pyproject.toml):
[flake8] per-file-ignores = custom_components/tornado/select.py: A005Apply 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
RestoreEntityand restoring the last state inasync_added_to_hass(). This will improve UX and make automations resilient.
210-229: Fragile entity lookup viahass.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_registryandhass.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 forentity_categoryshould match HA’sEntityCategory | 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. Preferawait 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_stateandwas_onare 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.
📒 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 constantsAll 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 consistentKeys map correctly; spacing and terms look good.
custom_components/tornado/climate.py (1)
84-92: Good: Reuse coordinator created in init.pyUsing 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 likeentity.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_taskto 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.
| # 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) | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Service wiring gaps: CoolDown params are ignored; Off Timer service targets missing methods.
start_cooldownsetsentity._cooldown_durationand_target_fan_mode, butTornadoCoolDownSwitch.async_turn_onignores these and fetches values only from Select entities. The service-provided params are currently no-ops.set_timer/cancel_timerexpect entities to implementasync_set_timer/async_cancel_timer.TornadoOffTimerControlSwitchdoesn’t expose these methods, soget_timer_entitywon’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_timerto OffTimerControlSwitch (see switch.py comment + diff),
or adjust the services to call the exposed methods.
|
|
||
| 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...") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
| 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() | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow-up: The underlying WS implementation disables TLS and leaks ClientSession objects
Calling AuxCloudWebSocket.initialize_websocket() currently does:
session = aiohttp.ClientSession()and never closes itws_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.
| 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 |
| 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 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 = NoneAlso 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.
| session = aiohttp.ClientSession() | ||
| self.websocket = await session.ws_connect( | ||
| url, headers=self.headers, ssl=False | ||
| ) |
There was a problem hiding this comment.
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 = NoneFollow-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.
| 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.
| ### 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 | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| ### 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).
| 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, | ||
| ) |
There was a problem hiding this comment.
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.
| 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).""" | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| """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.
| 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 | ||
|
|
There was a problem hiding this comment.
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.
| 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.
| 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 | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 FalseFollow-up (impl change in custom_components/tornado/aux_cloud/aux_cloud_ws.py):
- Remove
ssl=Falseand 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.
| 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.
…ow support
Summary by CodeRabbit