diff --git a/home_assistant_unavailable_devices.md b/home_assistant_unavailable_devices.md index c0ab69f..bfe1ec6 100644 --- a/home_assistant_unavailable_devices.md +++ b/home_assistant_unavailable_devices.md @@ -83,9 +83,9 @@ triggers: attribute: entities conditions: - condition: template - value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}" - condition: template - value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}" actions: - variables: entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}" @@ -129,9 +129,9 @@ triggers: attribute: entities conditions: - condition: template - value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}" - condition: template - value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}" actions: - variables: entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}" diff --git a/home_assistant_unavailable_devices_en.md b/home_assistant_unavailable_devices_en.md index d43a588..c72f195 100644 --- a/home_assistant_unavailable_devices_en.md +++ b/home_assistant_unavailable_devices_en.md @@ -83,9 +83,9 @@ triggers: attribute: entities conditions: - condition: template - value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}" - condition: template - value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}" actions: - variables: entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}" @@ -129,9 +129,9 @@ triggers: attribute: entities conditions: - condition: template - value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}" - condition: template - value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}" actions: - variables: entities: "{{ state_attr(trigger.entity_id, 'entities') | default([], true) }}" diff --git a/scripts/requirements.txt b/scripts/requirements.txt index 4e52c22..b7e1768 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,6 +1,5 @@ # Common packages are included in many tools. aiohttp -aiofiles # youtube_data_tool google-api-python-client diff --git a/scripts/telegram_bot_handle_tool.py b/scripts/telegram_bot_handle_tool.py index bc6ff3e..463fb62 100644 --- a/scripts/telegram_bot_handle_tool.py +++ b/scripts/telegram_bot_handle_tool.py @@ -6,7 +6,6 @@ from pathlib import Path from typing import Any -import aiofiles import aiohttp from homeassistant.helpers import network @@ -130,26 +129,47 @@ async def _get_file(session: aiohttp.ClientSession, file_id: str) -> str | None: return data.get("result", {}).get("file_path") -async def _download_and_save_file( - session: aiohttp.ClientSession, file_path: str, destination: str -) -> bool: - """Download a file from Telegram and save it directly to disk (streaming). +async def _download_file( + session: aiohttp.ClientSession, file_id: str +) -> tuple[str, None] | tuple[None, str]: + """Download a file from Telegram and save it under DIRECTORY (streaming). Args: session: Shared aiohttp session. - file_path: Path returned by Telegram `getFile`. - destination: Local destination path. + file_id: Telegram file identifier. Returns: - True if successful, False otherwise. + Full file path of the saved file, or None on failure. """ - url = f"https://api.telegram.org/file/bot{TOKEN}/{file_path}" - async with session.get(url) as resp: - resp.raise_for_status() - async with aiofiles.open(destination, "wb") as f: - async for chunk in resp.content.iter_chunked(4096): - await f.write(chunk) - return True + try: + online_file_path = await _get_file(session, file_id) + if not online_file_path: + return None, "Unable to retrieve the file_path from Telegram." + + url = f"https://api.telegram.org/file/bot{TOKEN}/{online_file_path}" + async with session.get(url) as resp: + resp.raise_for_status() + + file_name = os.path.basename(online_file_path) + base, ext = os.path.splitext(file_name) + timestamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + file_name = f"{base}_{timestamp}_{secrets.token_hex(4)}{ext}" + + file_path = os.path.join(DIRECTORY, file_name) + + f = await asyncio.to_thread(open, file_path, "wb") + try: + async for chunk in resp.content.iter_chunked(65536): # 64KB chunks + if chunk: + await asyncio.to_thread(f.write, chunk) + await asyncio.to_thread(f.flush) + await asyncio.to_thread(os.fsync, f.fileno()) + finally: + await asyncio.to_thread(f.close) + + return file_path, None + except Exception as error: + return None, f"An unexpected error occurred during download: {error}" async def _send_message( @@ -241,20 +261,21 @@ async def _send_photo( if message_thread_id: form.add_field("message_thread_id", str(message_thread_id)) - async with aiofiles.open(file_path, "rb") as f: - file_content = await f.read() - - form.add_field( - name="photo", - value=file_content, - filename=filename, - content_type=content_type, - ) + f = await asyncio.to_thread(open, file_path, "rb") + try: + form.add_field( + name="photo", + value=f, + filename=filename, + content_type=content_type, + ) - url = f"https://api.telegram.org/bot{TOKEN}/sendPhoto" - async with session.post(url, data=form) as resp: - resp.raise_for_status() - return await resp.json() + url = f"https://api.telegram.org/bot{TOKEN}/sendPhoto" + async with session.post(url, data=form) as resp: + resp.raise_for_status() + return await resp.json() + finally: + await asyncio.to_thread(f.close) async def _get_webhook_info(session: aiohttp.ClientSession) -> dict[str, Any]: @@ -552,26 +573,12 @@ async def get_telegram_file(file_id: str) -> dict[str, Any]: session = await _ensure_session() await _ensure_dir(DIRECTORY) - online_file_path = await _get_file(session, file_id) - if not online_file_path: - return {"error": "Unable to retrieve the file_path from Telegram."} - - file_name = os.path.basename(online_file_path) - base, ext = os.path.splitext(file_name) - # Use timestamp and random token to prevent filename collisions - file_name = f"{base}_{int(time.time())}_{secrets.token_hex(4)}{ext}" - - file_path = os.path.join(DIRECTORY, file_name) - - try: - await _download_and_save_file(session, online_file_path, file_path) - except Exception as error: - return { - "error": f"Unable to download or save the file from Telegram: {error}" - } + file_path, error = await _download_file(session, file_id) + if not file_path: + return {"error": f"Unable to download the file from Telegram. {error}"} mimetypes.add_type("text/plain", ".yaml") - mime_type, _ = mimetypes.guess_file_type(file_name) + mime_type, _ = mimetypes.guess_file_type(file_path) file_path = _to_relative_path(file_path) response: dict[str, Any] = {"file_path": file_path, "mime_type": mime_type} support_file_types = ( diff --git a/scripts/zalo_bot_handle_tool.py b/scripts/zalo_bot_handle_tool.py index 557546d..169e20b 100644 --- a/scripts/zalo_bot_handle_tool.py +++ b/scripts/zalo_bot_handle_tool.py @@ -2,12 +2,12 @@ import mimetypes import os import secrets +import shutil import time from pathlib import Path from typing import Any from urllib.parse import urlparse -import aiofiles import aiohttp from homeassistant.helpers import network @@ -139,21 +139,26 @@ async def _download_file( content_type = resp.headers.get("Content-Type", "") ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or "" - # Use safe filename from URL or default, then append unique token parsed_url = urlparse(url) original_name = Path(parsed_url.path).name if not Path(original_name).suffix and ext: original_name += ext base, extension = os.path.splitext(original_name) - # Construct unique filename: name_timestamp_random.ext - file_name = f"{base}_{int(time.time())}_{secrets.token_hex(4)}{extension}" + timestamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + file_name = f"{base}_{timestamp}_{secrets.token_hex(4)}{extension}" file_path = os.path.join(DIRECTORY, file_name) - async with aiofiles.open(file_path, "wb") as f: - async for chunk in resp.content.iter_chunked(4096): - await f.write(chunk) + f = await asyncio.to_thread(open, file_path, "wb") + try: + async for chunk in resp.content.iter_chunked(65536): + if chunk: + await asyncio.to_thread(f.write, chunk) + await asyncio.to_thread(f.flush) + await asyncio.to_thread(os.fsync, f.fileno()) + finally: + await asyncio.to_thread(f.close) return file_path, None except Exception as error: @@ -339,15 +344,7 @@ async def _copy_to_www(file_path: str) -> tuple[str, str]: name = f"{secrets.token_urlsafe(16)}-{Path(normalized).name}" dest_path = os.path.join(WWW_DIRECTORY, name) - # Streaming copy - async with ( - aiofiles.open(normalized, "rb") as src, - aiofiles.open(dest_path, "wb") as dst, - ): - async for ( - chunk - ) in src: # aiofiles supports async iteration (default chunk size) - await dst.write(chunk) + await asyncio.to_thread(shutil.copyfile, normalized, dest_path) public_url = f"{external}/local/zalo/{name}" return public_url, dest_path diff --git a/scripts/zalo_custom_bot_handle_tool.py b/scripts/zalo_custom_bot_handle_tool.py index 1a39919..769556f 100644 --- a/scripts/zalo_custom_bot_handle_tool.py +++ b/scripts/zalo_custom_bot_handle_tool.py @@ -7,7 +7,6 @@ from typing import Any from urllib.parse import urlparse -import aiofiles import aiohttp from homeassistant.helpers import network @@ -119,21 +118,26 @@ async def _download_file( content_type = resp.headers.get("Content-Type", "") ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or "" - # Use safe filename from URL or default, then append unique token parsed_url = urlparse(url) original_name = Path(parsed_url.path).name if not Path(original_name).suffix and ext: original_name += ext base, extension = os.path.splitext(original_name) - # Construct unique filename: name_timestamp_random.ext - file_name = f"{base}_{int(time.time())}_{secrets.token_hex(4)}{extension}" + timestamp = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) + file_name = f"{base}_{timestamp}_{secrets.token_hex(4)}{extension}" file_path = os.path.join(DIRECTORY, file_name) - async with aiofiles.open(file_path, "wb") as f: - async for chunk in resp.content.iter_chunked(4096): - await f.write(chunk) + f = await asyncio.to_thread(open, file_path, "wb") + try: + async for chunk in resp.content.iter_chunked(65536): + if chunk: + await asyncio.to_thread(f.write, chunk) + await asyncio.to_thread(f.flush) + await asyncio.to_thread(os.fsync, f.fileno()) + finally: + await asyncio.to_thread(f.close) return file_path, None except Exception as error: diff --git a/telegram_bot_webhook.yaml b/telegram_bot_webhook.yaml index 6a521cd..93f8c3b 100644 --- a/telegram_bot_webhook.yaml +++ b/telegram_bot_webhook.yaml @@ -349,7 +349,7 @@ actions: - choose: - conditions: - condition: template - value_template: "{{ supported is defined and not bool(supported) }}" + value_template: "{{ supported is defined and not supported | bool(true) }}" sequence: - action: pyscript.send_telegram_message data: diff --git a/traffic_fine_notification.yaml b/traffic_fine_notification.yaml index 5b5babe..d1345a8 100644 --- a/traffic_fine_notification.yaml +++ b/traffic_fine_notification.yaml @@ -116,9 +116,9 @@ triggers: entity_id: !input vehicles conditions: - condition: template - value_template: "{{ trigger.from_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.from_state is not none and trigger.from_state.state not in ['unavailable', 'unknown'] }}" - condition: template - value_template: "{{ trigger.to_state.state not in ['unavailable', 'unknown'] }}" + value_template: "{{ trigger.to_state is not none and trigger.to_state.state not in ['unavailable', 'unknown'] }}" - condition: template value_template: "{{ not trigger.to_state.attributes.data.get('error') }}" actions: diff --git a/zalo_bot_webhook.yaml b/zalo_bot_webhook.yaml index d7a1aa2..3c5b594 100644 --- a/zalo_bot_webhook.yaml +++ b/zalo_bot_webhook.yaml @@ -179,7 +179,7 @@ actions: - choose: - conditions: - condition: template - value_template: "{{ supported is defined and not bool(supported) }}" + value_template: "{{ supported is defined and not supported | bool(true) }}" sequence: - action: pyscript.send_zalo_message data: diff --git a/zalo_custom_bot_webhook.yaml b/zalo_custom_bot_webhook.yaml index 546de0f..df847f0 100644 --- a/zalo_custom_bot_webhook.yaml +++ b/zalo_custom_bot_webhook.yaml @@ -182,7 +182,7 @@ actions: - choose: - conditions: - condition: template - value_template: "{{ supported is defined and not bool(supported) }}" + value_template: "{{ supported is defined and not supported | bool(true) }}" sequence: - action: zalo_bot.send_message data: