diff --git a/.gitignore b/.gitignore index fc7ae7d..45ab9e5 100644 --- a/.gitignore +++ b/.gitignore @@ -130,4 +130,9 @@ dmypy.json # hacs -.local/ \ No newline at end of file +.local/ + +MQTT.md + +# Copilot instructions +.github/copilot-instructions.md \ No newline at end of file diff --git a/README.md b/README.md index 8177a59..b9e1b31 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,66 @@ # Tinxy Home Assistant Integration -Integrate [Tinxy.in](https://tinxy.in/) smart switches and devices with Home Assistant using this custom component. +> **Important Notice** +> Users of the Tinxy cloud integration must update to version **5.0.0** before **10th March 2026**. +> Older versions will stop working after this date. Please update via HACS or manually at the earliest. -Join [Discord server](https://discord.gg/VH4jgz2f) for support. +A custom Home Assistant integration for [Tinxy.in](https://tinxy.in/) smart devices. +Uses MQTT push for real-time state updates and control with no polling and no delays. -## Overview +--- -This repository provides a Home Assistant integration for Tinxy smart devices, allowing you to control switches, fans, lights, and locks directly from Home Assistant. +## Community + +Have questions, need help, or want to share feedback? Join the Tinxy community on Discord. + +**[Join Discord](https://discord.gg/cSPwkkQg)** + +--- + +## Supported Devices + +- Switches +- Lights (including dimmer) +- Fans (with speed presets: Low / Medium / High) +- Locks + +--- ## Installation -### 1. Install HACS -HACS is a community app store for Home Assistant. Follow the [HACS setup guide](https://hacs.xyz/docs/setup/prerequisites) to install. - -### 2. Add Repository to HACS -Once HACS is installed: -1. Navigate to **HACS → Integrations**. - -2. Click the three dots at the top right and select **Custom repositories**. - -3. Paste `https://github.com/arevindh/ha-tinxy-cloud`, select **Integration**, and click **Add**. - -4. Close the Custom repositories section. -5. Click **Explore & Download** at the bottom right. - ![image](https://user-images.githubusercontent.com/693151/220522243-48b85c0f-59ff-45f6-b664-37157eb1ec15.png) -6. Search for `Tinxy`, then add and install it. -7. Restart Home Assistant. - -## Usage - -1. **Get API Key:** - - Obtain your API key from the Tinxy mobile application. -2. **Configure Integration:** - - Go to **Settings → Integrations** in Home Assistant. - - Search for "Tinxy" and click on it. - - ![screen-1](https://user-images.githubusercontent.com/693151/220121949-4f48a2ad-bae5-42e9-9167-b6bc8f524251.png) - - Enter your API key and click **Submit**. - - ![screen-2](https://user-images.githubusercontent.com/693151/220121597-624f3abf-2d28-4ca9-8764-0fb9e819e138.png) - - Click **Finish** on the next screen. - - You can find all devices in the integration screen. +### HACS (Recommended) + +This integration is available in the HACS default store. + +1. Open **HACS** in Home Assistant and go to **Integrations**. +2. Search for **Tinxy** and click **Download**. +3. Restart Home Assistant. + +### Manual + +Copy the `custom_components/tinxy` folder into your Home Assistant `custom_components` directory and restart. + +--- ## Configuration -- You can adjust the `scan_interval` parameter to change how often device status is updated. It is recommended to keep this above `7` seconds to avoid slowing down your Home Assistant server. +1. In Home Assistant, go to **Settings → Devices & Services → Add Integration**. +2. Search for **Tinxy**. +3. Enter your API key from the Tinxy mobile app and click **Submit**. + +All paired Tinxy devices will be discovered and added automatically. -## Known Issues +--- -- Due to response delays from the Tinxy API, toggling devices may have a delay of approximately 3 seconds. +## Local Control + +A local-network alternative is available at [arevindh/tinxylocal](https://github.com/arevindh/tinxylocal) for users who prefer not to use the cloud. Note that the local integration supports only a limited number of older device models. **EVA series devices are not supported** by the local integration. + +--- ## License See [LICENSE](LICENSE) for details. + diff --git a/custom_components/tinxy/__init__.py b/custom_components/tinxy/__init__.py index 1307266..1fa6db8 100644 --- a/custom_components/tinxy/__init__.py +++ b/custom_components/tinxy/__init__.py @@ -13,9 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, TINXY_BACKEND -from .tinxycloud import TinxyCloud, TinxyHostConfiguration +from .const import DOMAIN, TINXY_BACKEND, CONF_MQTT_USERNAME, CONF_MQTT_PASSWORD +from .tinxycloud import TinxyCloud, TinxyHostConfiguration, TinxyMQTTCredentials from .coordinator import TinxyUpdateCoordinator +from .mqtt_client import TinxyMQTTClient # Logger for this module LOGGER = logging.getLogger(__name__) @@ -26,6 +27,7 @@ Platform.LIGHT, Platform.FAN, Platform.LOCK, + Platform.SENSOR, ] async def async_setup_entry( @@ -47,29 +49,76 @@ async def async_setup_entry( # Ensure domain data exists hass.data.setdefault(DOMAIN, {}) - # Create web session for API communication + # Create web session for REST API communication web_session = async_get_clientsession(hass) - # Prepare host configuration for TinxyCloud + # Prepare host configuration host_config = TinxyHostConfiguration( api_token=entry.data[CONF_API_KEY], api_url=TINXY_BACKEND, ) - # Initialize TinxyCloud API + # Initialize REST API client and fetch device list (done once at startup) api = TinxyCloud(host_config=host_config, web_session=web_session) try: await api.sync_devices() - LOGGER.info("Successfully synced Tinxy devices for entry_id=%s", entry.entry_id) + LOGGER.info("Successfully synced %d Tinxy devices.", len(api.devices)) except Exception as exc: LOGGER.error("Failed to sync Tinxy devices: %s", exc) return False - # Create update coordinator for device state management + # Create coordinator (MQTT push-driven; performs one REST fetch on first_refresh) coordinator = TinxyUpdateCoordinator(hass, api) - # Store API and coordinator in hass data - hass.data[DOMAIN][entry.entry_id] = (api, coordinator) + # Collect unique physical device IDs (strip the "-relay_no" suffix) + unique_device_ids: list[str] = list( + {d["device_id"] for d in api.list_all_devices()} + ) + + # Create MQTT client wired to coordinator's push-update method + # Credentials are cached in entry.data to avoid an API call on every startup. + # The fetcher uses the cache on the first call and force-refreshes on auth failures. + _cred_fetched_once = False + + async def _get_mqtt_credentials() -> TinxyMQTTCredentials: + nonlocal _cred_fetched_once + if not _cred_fetched_once and CONF_MQTT_USERNAME in entry.data: + _cred_fetched_once = True + LOGGER.debug("Tinxy: using cached MQTT credentials.") + return TinxyMQTTCredentials( + username=entry.data[CONF_MQTT_USERNAME], + password=entry.data[CONF_MQTT_PASSWORD], + ) + # Fetch fresh credentials from the API (first run or after auth failure) + creds = await api.async_get_mqtt_credentials() + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_MQTT_USERNAME: creds.username, + CONF_MQTT_PASSWORD: creds.password, + }, + ) + _cred_fetched_once = True + LOGGER.debug("Tinxy: MQTT credentials fetched and cached.") + return creds + + mqtt_client = TinxyMQTTClient( + hass=hass, + on_state_update=coordinator.async_update_from_mqtt, + credentials_fetcher=_get_mqtt_credentials, + ) + # Give the REST client a reference so set_device_state() uses MQTT + api.mqtt_client = mqtt_client + + # Start MQTT connection in the background + await mqtt_client.async_start(unique_device_ids) + LOGGER.info( + "Tinxy MQTT client started for %d physical devices.", len(unique_device_ids) + ) + + # Store references for platforms and unload + hass.data[DOMAIN][entry.entry_id] = (api, coordinator, mqtt_client) # Forward setup to supported platforms await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -93,9 +142,16 @@ async def async_unload_entry( bool: True if unload was successful, False otherwise. """ LOGGER.info("Unloading Tinxy integration for entry_id=%s", entry.entry_id) + + # Stop MQTT client before unloading platforms + entry_data = hass.data[DOMAIN].get(entry.entry_id) + if entry_data and len(entry_data) >= 3: + mqtt_client: TinxyMQTTClient = entry_data[2] + await mqtt_client.async_stop() + LOGGER.info("Tinxy MQTT client stopped.") + unload_ok: bool = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - # Remove stored API and coordinator hass.data[DOMAIN].pop(entry.entry_id, None) LOGGER.info("Tinxy integration unloaded for entry_id=%s", entry.entry_id) else: diff --git a/custom_components/tinxy/const.py b/custom_components/tinxy/const.py index 1a6f626..08d82a5 100644 --- a/custom_components/tinxy/const.py +++ b/custom_components/tinxy/const.py @@ -3,3 +3,9 @@ DOMAIN = "tinxy" COORDINATOR = "coordinator" TINXY_BACKEND = "https://ha-backend.tinxy.in/" +MQTT_SERVER = "mqtt.tinxy.in" +MQTT_PORT = 1883 + +# Config entry keys for persisted MQTT credentials +CONF_MQTT_USERNAME = "mqtt_username" +CONF_MQTT_PASSWORD = "mqtt_password" \ No newline at end of file diff --git a/custom_components/tinxy/coordinator.py b/custom_components/tinxy/coordinator.py index a351a7d..b8fc9b0 100644 --- a/custom_components/tinxy/coordinator.py +++ b/custom_components/tinxy/coordinator.py @@ -1,100 +1,141 @@ """ Coordinator for Tinxy Home Assistant integration. -Handles periodic data updates from the Tinxy API and manages device status lookup. -Follows Home Assistant best practices for custom components. +All device state is delivered via MQTT push only. At startup every relay is +initialised with safe defaults; the broker's retained /info messages (buffered +before coordinator.data is ready) are replayed on top immediately after, +giving entities their correct last-known state. """ -from datetime import timedelta import logging -from typing import Any, Dict -import async_timeout +from typing import Any, Dict, List, Tuple from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.helpers.debounce import Debouncer -from .tinxycloud import TinxyAuthenticationException, TinxyException +from .tinxycloud import TinxyException # Module-level logger _LOGGER = logging.getLogger(__name__) -# Constants for configuration -UPDATE_INTERVAL_SECONDS: int = 7 -REQUEST_REFRESH_DELAY: float = 0.35 -API_TIMEOUT_SECONDS: int = 10 - - class TinxyUpdateCoordinator(DataUpdateCoordinator): """ - Coordinates updates from the Tinxy API for Home Assistant entities. + Coordinates device state for Home Assistant entities. - Periodically fetches device status and merges device info for fast entity lookup. - Handles authentication and API errors gracefully. + * First refresh - builds the state dict from the device list with safe defaults. + * Retained MQTT /info messages that arrive before the first refresh completes + are buffered and replayed on top of the defaults. + * Ongoing updates - driven entirely by MQTT pushes; no polling timer is set. """ def __init__(self, hass: HomeAssistant, api_client: Any) -> None: """ - Initialize the TinxyUpdateCoordinator. + Initialize the coordinator. Args: - hass (HomeAssistant): The Home Assistant instance. - api_client (Any): The Tinxy API client instance. + hass: The HomeAssistant instance. + api_client: TinxyCloud REST client instance. """ super().__init__( hass, _LOGGER, name="Tinxy", - update_interval=timedelta(seconds=UPDATE_INTERVAL_SECONDS), - request_refresh_debouncer=Debouncer( - hass, - _LOGGER, - cooldown=REQUEST_REFRESH_DELAY, - immediate=False, - ), + update_interval=None, # MQTT push-driven – no polling ) self.hass: HomeAssistant = hass self.api_client: Any = api_client - # Fetch all devices once at startup; assumes device list is static + # Static device list populated after sync_devices(); treated as immutable. self.all_devices: list[dict] = self.api_client.list_all_devices() + # Buffer for MQTT messages that arrive before coordinator.data is ready. + self._mqtt_buffer: List[Tuple[str, Dict[str, Any]]] = [] + + # ------------------------------------------------------------------ + # DataUpdateCoordinator interface + # ------------------------------------------------------------------ async def _async_update_data(self) -> Dict[str, dict]: """ - Fetch and merge device status from the Tinxy API. + Build initial state from the device list with safe defaults. + Replays any buffered MQTT retained messages on top so the very first + state shown to the user reflects the broker's last-known device state. Returns: - Dict[str, dict]: Mapping of device IDs to merged device info and status. + Dict mapping composite relay IDs to device + state dicts. Raises: - ConfigEntryAuthFailed: If authentication fails. - UpdateFailed: For other API errors. + UpdateFailed: If the device list is unexpectedly empty. """ try: - async with async_timeout.timeout(API_TIMEOUT_SECONDS): - status_by_id: Dict[str, dict] = {} - # Fetch latest status for all devices - status_result: dict = await self.api_client.get_all_status() - - for device in self.all_devices: - device_id = device.get("id") - if device_id in status_result: - # Merge static device info with dynamic status - status_by_id[device_id] = {**device, **status_result[device_id]} - else: - # Log warning if device status is missing - _LOGGER.warning(f"No status found for device ID: {device_id}") - - _LOGGER.debug(f"Fetched status for {len(status_by_id)} devices.") - return status_by_id - except TinxyAuthenticationException as auth_err: - _LOGGER.error("Authentication failed with Tinxy API: %s", auth_err) - raise ConfigEntryAuthFailed from auth_err - except TinxyException as api_err: - _LOGGER.error("Error communicating with Tinxy API: %s", api_err) - raise UpdateFailed(f"Error communicating with API: {api_err}") from api_err + status_by_id: Dict[str, dict] = {} + + for device in self.all_devices: + relay_id = device.get("id") + if relay_id: + status_by_id[relay_id] = { + **device, + "state": 0, + "status": 0, + "brightness": 0, + } + + if not status_by_id: + raise UpdateFailed("No devices found - check API key and device setup.") + + # Replay buffered MQTT retained messages over the defaults. + if self._mqtt_buffer: + _LOGGER.debug( + "Tinxy: replaying %d buffered MQTT messages.", len(self._mqtt_buffer) + ) + for relay_id, state_dict in self._mqtt_buffer: + if relay_id in status_by_id: + status_by_id[relay_id] = {**status_by_id[relay_id], **state_dict} + self._mqtt_buffer.clear() + + _LOGGER.info( + "Tinxy: initialised state for %d relays (MQTT retained messages will follow).", + len(status_by_id), + ) + return status_by_id + + except UpdateFailed: + raise + except TinxyException as exc: + raise UpdateFailed(f"Tinxy error during setup: {exc}") from exc except Exception as exc: - _LOGGER.exception("Unexpected error during Tinxy data update: %s", exc) + _LOGGER.exception("Tinxy: unexpected error during setup: %s", exc) raise UpdateFailed(f"Unexpected error: {exc}") from exc + + # ------------------------------------------------------------------ + # MQTT push interface + # ------------------------------------------------------------------ + + def async_update_from_mqtt(self, relay_id: str, state_dict: Dict[str, Any]) -> None: + """ + Merge MQTT state into the coordinator's data store and notify listeners. + + If coordinator.data is not yet populated (first refresh still in progress), + the update is buffered and will be replayed once it completes. + + Args: + relay_id: Composite ID, e.g. "aabbcc112233ddeeff445566-2". + state_dict: At minimum {"state": bool}; may include {"brightness": int}. + """ + if self.data is None: + # First refresh not yet complete – buffer for replay. + self._mqtt_buffer.append((relay_id, state_dict)) + _LOGGER.debug("Tinxy MQTT: buffered update for %s (coordinator not ready)", relay_id) + return + + if relay_id not in self.data: + _LOGGER.debug("Tinxy MQTT: received update for unknown relay %s", relay_id) + return + + # Build updated data dict (shallow copy so DataUpdateCoordinator detects change) + new_data = dict(self.data) + new_data[relay_id] = {**self.data[relay_id], **state_dict} + + # async_set_updated_data notifies all subscribed CoordinatorEntity instances + self.async_set_updated_data(new_data) + _LOGGER.debug("Tinxy MQTT state applied: %s → %s", relay_id, state_dict) diff --git a/custom_components/tinxy/fan.py b/custom_components/tinxy/fan.py index cf243a3..05d6388 100644 --- a/custom_components/tinxy/fan.py +++ b/custom_components/tinxy/fan.py @@ -71,7 +71,7 @@ async def async_setup_entry( await coordinator.async_config_entry_first_refresh() all_devices = apidata.list_fans() - result = await apidata.get_all_status() + result = coordinator.data fans: List[TinxyFan] = [] for device in all_devices: @@ -202,7 +202,6 @@ async def async_turn_on( ) except Exception as exc: _LOGGER.error("Failed to turn on Tinxy fan '%s': %s", self.device.name, exc) - await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """ @@ -217,7 +216,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: _LOGGER.info("Turned off Tinxy fan '%s'.", self.device.name) except Exception as exc: _LOGGER.error("Failed to turn off Tinxy fan '%s': %s", self.device.name, exc) - await self.coordinator.async_request_refresh() async def async_set_preset_mode(self, preset_mode: str) -> None: """ @@ -236,7 +234,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: ) except Exception as exc: _LOGGER.error("Failed to set preset mode for Tinxy fan '%s': %s", self.device.name, exc) - await self.coordinator.async_request_refresh() def _calculate_percent(self, preset_mode: Optional[str]) -> int: """ diff --git a/custom_components/tinxy/light.py b/custom_components/tinxy/light.py index 63de482..f251a03 100644 --- a/custom_components/tinxy/light.py +++ b/custom_components/tinxy/light.py @@ -51,11 +51,11 @@ async def async_setup_entry( async_add_entities: Callback to add entities. """ try: - apidata, coordinator = hass.data[DOMAIN][entry.entry_id] + apidata, coordinator = hass.data[DOMAIN][entry.entry_id][0], hass.data[DOMAIN][entry.entry_id][1] await coordinator.async_config_entry_first_refresh() all_devices = apidata.list_lights() - result = await apidata.get_all_status() + result = coordinator.data status_list = { device["id"]: {**device, **result[device["id"]]} for device in all_devices if device["id"] in result @@ -311,4 +311,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) except Exception as exc: LOGGER.error("Failed to turn OFF Tinxy light %s: %s", self.idx, exc) - await self.coordinator.async_request_refresh() diff --git a/custom_components/tinxy/lock.py b/custom_components/tinxy/lock.py index 5d14524..2249724 100644 --- a/custom_components/tinxy/lock.py +++ b/custom_components/tinxy/lock.py @@ -56,12 +56,12 @@ async def async_setup_entry( entry: ConfigEntry for this integration. async_add_entities: Callback to add entities. """ - apidata, coordinator = hass.data[DOMAIN][entry.entry_id] + apidata, coordinator = hass.data[DOMAIN][entry.entry_id][0], hass.data[DOMAIN][entry.entry_id][1] await coordinator.async_config_entry_first_refresh() # Gather lock devices and their status all_devices: List[Dict[str, Any]] = apidata.list_locks() - status_result: Dict[str, Dict[str, Any]] = await apidata.get_all_status() + status_result: Dict[str, Dict[str, Any]] = coordinator.data lock_entities: List[TinxyLock] = [] for device in all_devices: @@ -195,7 +195,6 @@ async def async_unlock(self, **kwargs: Any) -> None: _LOGGER.info(f"Unlock command sent for lock {self.device.id}") except Exception as exc: _LOGGER.error(f"Failed to unlock lock {self.device.id}: {exc}") - await self.coordinator.async_request_refresh() async def async_lock(self, **kwargs: Any) -> None: """ @@ -210,5 +209,4 @@ async def async_lock(self, **kwargs: Any) -> None: ) _LOGGER.info(f"Lock command sent for lock {self.device.id}") except Exception as exc: - _LOGGER.error(f"Failed to lock lock {self.device.id}: {exc}") - await self.coordinator.async_request_refresh() \ No newline at end of file + _LOGGER.error(f"Failed to lock lock {self.device.id}: {exc}") \ No newline at end of file diff --git a/custom_components/tinxy/manifest.json b/custom_components/tinxy/manifest.json index bde7baf..e302d3f 100644 --- a/custom_components/tinxy/manifest.json +++ b/custom_components/tinxy/manifest.json @@ -5,8 +5,8 @@ "config_flow": true, "dependencies": [], "documentation": "https://github.com/arevindh/ha-tinxy-cloud", - "iot_class": "cloud_polling", + "iot_class": "cloud_push", "issue_tracker": "https://github.com/arevindh/ha-tinxy-cloud/issues", - "requirements": [], - "version": "4.0.2" + "requirements": ["aiomqtt>=1.2.1"], + "version": "5.0.0" } diff --git a/custom_components/tinxy/mqtt_client.py b/custom_components/tinxy/mqtt_client.py new file mode 100644 index 0000000..957b3a8 --- /dev/null +++ b/custom_components/tinxy/mqtt_client.py @@ -0,0 +1,326 @@ +""" +Tinxy MQTT Client + +Manages persistent MQTT connection to the Tinxy broker for real-time device state +updates (subscribe) and device control commands (publish). + +Protocol notes derived from broker observation: + Subscribe : /{device_id}/# + Status msg : /{device_id}/info → {"rssi":…,"status":1,"state":"0011","bright":"100100100100"} + state[i] → relay (i+1) on/off ('1'=on, '0'=off) + bright[i*3:(i+1)*3] → brightness % for relay (i+1), zero-padded to 3 chars + Command msg: /{device_id} ← publish {"n": relay_no, "on": "1"|"0"} + ← publish {"n": relay_no, "bright": value} +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any, Awaitable, Callable, Dict, Optional, Set + +import aiomqtt + +from homeassistant.core import HomeAssistant +from .tinxycloud import TinxyMQTTCredentials + +_LOGGER = logging.getLogger(__name__) + +RECONNECT_DELAY = 5 # seconds – network drop reconnect delay +MAX_AUTH_RETRIES = 3 # max consecutive credential-refresh attempts + + +class TinxyMQTTClient: + """ + Lightweight MQTT client for the Tinxy broker. + + Subscribes to every known device topic, parses /info payloads into + per-relay state dicts, and forwards them to a caller-supplied callback. + + Also exposes async_publish_command() for sending toggle / brightness commands. + """ + + def __init__( + self, + hass: HomeAssistant, + on_state_update: Callable[[str, Dict[str, Any]], None], + credentials_fetcher: Callable[[], Awaitable[TinxyMQTTCredentials]], + ) -> None: + """ + Initialise the client (does NOT connect yet). + + Args: + hass: HomeAssistant instance. + on_state_update: Callback fired with (relay_id, state_dict) on every + /info message. + credentials_fetcher: Async callable that returns fresh TinxyMQTTCredentials. + Called once at startup and again whenever the broker + rejects the connection (e.g. after a password reset). + """ + self._hass = hass + self._on_state_update = on_state_update + self._credentials_fetcher = credentials_fetcher + self._credentials: Optional[TinxyMQTTCredentials] = None + self._device_ids: Set[str] = set() + self._task: Optional[asyncio.Task] = None + self._client: Optional[aiomqtt.Client] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def async_start(self, device_ids: list[str]) -> None: + """Fetch credentials, then start the MQTT background loop.""" + self._device_ids = set(device_ids) + # Fetch credentials eagerly so a bad API key fails at setup time. + self._credentials = await self._credentials_fetcher() + self._task = self._hass.async_create_background_task( + self._run_loop(), "tinxy_mqtt_loop" + ) + _LOGGER.info( + "Tinxy MQTT client started for %d devices on %s:%d", + len(device_ids), self._credentials.broker, self._credentials.port, + ) + + async def async_stop(self) -> None: + """Cancel the background loop.""" + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._client = None + _LOGGER.debug("Tinxy MQTT client stopped.") + + async def async_publish_command( + self, + device_id: str, + relay_no: int, + state: str, + brightness: Optional[int] = None, + ) -> None: + """ + Publish a toggle or brightness command to a device. + + Args: + device_id: The Tinxy device _id (not the relay composite id). + relay_no: 1-based relay number. + state: "ON" or "OFF" (used when brightness is None). + brightness: 0-100 percentage; when provided, sends a brightness command + instead of a simple toggle. + """ + if self._client is None: + _LOGGER.warning( + "Tinxy MQTT: cannot publish command for %s – not connected.", device_id + ) + return + + if brightness is not None: + payload = json.dumps({"n": relay_no, "bright": brightness, "by": "Home Assistant"}) + else: + payload = json.dumps({"n": relay_no, "on": "1" if state == "ON" else "0", "by": "Home Assistant"}) + + topic = f"/{device_id}" + try: + await self._client.publish(topic, payload, qos=0) + _LOGGER.debug("MQTT publish → %s : %s", topic, payload) + except Exception as exc: # noqa: BLE001 + _LOGGER.error("Tinxy MQTT: failed to publish command: %s", exc) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + async def _run_loop(self) -> None: + """Persistent MQTT loop with automatic reconnection and credential refresh.""" + auth_retries = 0 + + while True: + try: + creds = self._credentials + async with aiomqtt.Client( + hostname=creds.broker, + port=creds.port, + username=creds.username, + password=creds.password, + identifier=f"ha-tinxy-{creds.username}", + ) as client: + self._client = client + auth_retries = 0 # reset on successful connect + _LOGGER.info( + "Tinxy MQTT connected to %s as %s", + creds.broker, creds.username, + ) + + for device_id in self._device_ids: + await client.subscribe(f"/{device_id}/#", qos=0) + _LOGGER.debug("Subscribed to /%s/#", device_id) + + async for message in client.messages: + self._dispatch_message(str(message.topic), message.payload) + + except aiomqtt.MqttConnectError as exc: + # Broker rejected the connection – likely stale credentials + # (e.g. user reset their password). + self._client = None + auth_retries += 1 + if auth_retries > MAX_AUTH_RETRIES: + _LOGGER.error( + "Tinxy MQTT: broker rejected credentials %d times – giving up. " + "Reload the integration after fixing the account password.", + auth_retries, + ) + return + _LOGGER.warning( + "Tinxy MQTT: broker rejected connection (%s) – refreshing credentials " + "(attempt %d/%d).", + exc, auth_retries, MAX_AUTH_RETRIES, + ) + try: + self._credentials = await self._credentials_fetcher() + except Exception as fetch_exc: # noqa: BLE001 + _LOGGER.error( + "Tinxy MQTT: failed to refresh credentials: %s – retrying in %ds", + fetch_exc, RECONNECT_DELAY, + ) + await asyncio.sleep(RECONNECT_DELAY) + # Reconnect immediately with fresh credentials (no extra sleep). + + except aiomqtt.MqttError as exc: + self._client = None + _LOGGER.warning( + "Tinxy MQTT connection lost: %s – reconnecting in %ds", + exc, RECONNECT_DELAY, + ) + await asyncio.sleep(RECONNECT_DELAY) + except asyncio.CancelledError: + self._client = None + _LOGGER.debug("Tinxy MQTT loop cancelled.") + return + except Exception as exc: # noqa: BLE001 + self._client = None + _LOGGER.error( + "Tinxy MQTT unexpected error: %s – reconnecting in %ds", + exc, RECONNECT_DELAY, + ) + await asyncio.sleep(RECONNECT_DELAY) + + def _dispatch_message(self, topic: str, payload: bytes) -> None: + """Route an incoming MQTT message to the appropriate handler.""" + try: + data = json.loads(payload) + except (json.JSONDecodeError, ValueError, TypeError): + _LOGGER.debug("Tinxy MQTT: ignoring non-JSON payload on %s", topic) + return + + # Topic format: /{device_id} or /{device_id}/subtopic + parts = topic.strip("/").split("/") + if not parts: + return + + device_id = parts[0] + subtopic = parts[1] if len(parts) > 1 else None + + try: + if subtopic == "info": + self._handle_info(device_id, data) + elif subtopic is None: + # Root topic /{device_id} – command-echo from the broker. + # Arrives ~200 ms before the /info confirmation; use it for + # an immediate optimistic state update. + self._handle_command_echo(device_id, data) + except Exception as exc: # noqa: BLE001 + _LOGGER.warning( + "Tinxy MQTT: error handling message on %s: %s", topic, exc + ) + + def _handle_command_echo( + self, device_id: str, data: Dict[str, Any] + ) -> None: + """ + Handle a command-echo on /{device_id} (no subtopic). + + Payload shapes observed: + {"n": 2, "on": "1"} – relay toggle (on = "1" | "0") + {"n": 1, "bright": 66} – brightness change + + Fires an immediate optimistic callback. The authoritative /info + message that follows will overwrite it with the confirmed state. + """ + relay_no = data.get("n") + if relay_no is None: + return + + relay_id = f"{device_id}-{relay_no}" + state_dict: Dict[str, Any] = {} + + if "on" in data: + state_dict["state"] = str(data["on"]) == "1" + if "bright" in data: + try: + state_dict["brightness"] = int(data["bright"]) + except (ValueError, TypeError): + pass + # A brightness command implies the relay is on + if "state" not in state_dict: + state_dict["state"] = True + + if not state_dict: + return + + _LOGGER.debug("MQTT command-echo for %s: %s", relay_id, state_dict) + try: + self._on_state_update(relay_id, state_dict) + except Exception as exc: # noqa: BLE001 + _LOGGER.error( + "Tinxy MQTT: error in command-echo callback for %s: %s", relay_id, exc + ) + + def _handle_info(self, device_id: str, data: Dict[str, Any]) -> None: + """ + Parse a /{device_id}/info message and fire per-relay callbacks. + + state : string where state[i] == '1' → relay (i+1) is ON + bright : string where bright[i*3:(i+1)*3] gives brightness% for relay (i+1) + values are zero-padded to 3 chars, e.g. "066100100100" + """ + # Defensively cast to str – some firmware versions send these as numbers + state_str = str(data.get("state", "")) + bright_str = str(data.get("bright", "")) + + for i, char in enumerate(state_str): + relay_no = i + 1 + relay_id = f"{device_id}-{relay_no}" + + state_dict: Dict[str, Any] = {"state": char == "1"} + + # status field from /info marks the device as online (1) or offline (0) + if "status" in data: + state_dict["status"] = data["status"] + + # Parse brightness for this relay if available + b_start = i * 3 + b_end = b_start + 3 + if len(bright_str) >= b_end: + try: + state_dict["brightness"] = int(bright_str[b_start:b_end]) + except ValueError: + pass + + # Forward device-level diagnostics on every relay update so the + # RSSI sensor (keyed on relay -1) can read them from coordinator data. + if "rssi" in data: + state_dict["rssi"] = data["rssi"] + if "ip" in data: + state_dict["ip"] = data["ip"] + if "version" in data: + state_dict["fw_version"] = data["version"] + + try: + self._on_state_update(relay_id, state_dict) + except Exception as exc: # noqa: BLE001 + _LOGGER.error( + "Tinxy MQTT: error in state-update callback for %s: %s", relay_id, exc + ) diff --git a/custom_components/tinxy/sensor.py b/custom_components/tinxy/sensor.py new file mode 100644 index 0000000..3c41e2c --- /dev/null +++ b/custom_components/tinxy/sensor.py @@ -0,0 +1,151 @@ +""" +Tinxy Sensor Platform – diagnostic sensors (RSSI, IP address). + +Two diagnostic sensors are created per physical Tinxy device: + • RSSI – signal strength in dBm + • IP – current IP address on the local network + +Both values arrive via MQTT /info messages. Devices that never send these +fields (e.g. wired locks, EVA hub) will show state "unavailable" until a +message with the relevant field is received. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from homeassistant.components.sensor import SensorEntity, SensorStateClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TinxyUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Create RSSI and IP diagnostic sensors per physical Tinxy device.""" + apidata = hass.data[DOMAIN][entry.entry_id][0] + coordinator: TinxyUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][1] + + await coordinator.async_config_entry_first_refresh() + + seen: set[str] = set() + entities: list[SensorEntity] = [] + + for device in apidata.list_all_devices(): + device_id: str = device["device_id"] + if device_id in seen: + continue + seen.add(device_id) + + relay_key = f"{device_id}-1" + if relay_key not in (coordinator.data or {}): + continue + + entities.append(TinxyRSSISensor(coordinator, relay_key)) + entities.append(TinxyIPSensor(coordinator, relay_key)) + + async_add_entities(entities) + _LOGGER.info("Tinxy: added %d diagnostic sensor(s).", len(entities)) + + +class _TinxyDiagnosticSensor(CoordinatorEntity, SensorEntity): + """Shared base for Tinxy diagnostic sensors.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__(self, coordinator: TinxyUpdateCoordinator, relay_key: str) -> None: + super().__init__(coordinator) + self._relay_key = relay_key + + @property + def _data(self) -> Dict[str, Any]: + return self.coordinator.data.get(self._relay_key, {}) + + @property + def _device_name(self) -> str: + return ( + self._data.get("device", {}).get("name") + or self._data.get("name", "Tinxy") + ) + + @property + def device_info(self) -> Dict[str, Any]: + return self._data.get("device", {}) + + @callback + def _handle_coordinator_update(self) -> None: + self.async_write_ha_state() + + +class TinxyRSSISensor(_TinxyDiagnosticSensor): + """Signal strength (RSSI) diagnostic sensor. + + Shows dBm value from MQTT /info. Firmware version is an extra attribute. + Unavailable on devices that never emit rssi (e.g. wired locks). + """ + + _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT + _attr_state_class = SensorStateClass.MEASUREMENT + _attr_icon = "mdi:wifi" + + @property + def unique_id(self) -> str: + return f"{self._data.get('device_id')}-rssi" + + @property + def name(self) -> str: + return f"{self._device_name} RSSI" + + @property + def native_value(self) -> Optional[int]: + return self._data.get("rssi") + + @property + def available(self) -> bool: + # Unavailable until the first MQTT /info with an rssi field arrives. + return "rssi" in self._data + + @property + def extra_state_attributes(self) -> Dict[str, Any]: + attrs: Dict[str, Any] = {} + if "fw_version" in self._data: + attrs["firmware_version"] = self._data["fw_version"] + return attrs + + +class TinxyIPSensor(_TinxyDiagnosticSensor): + """Local IP address diagnostic sensor. + + Shows the device's current LAN IP from MQTT /info. + Unavailable on devices that never emit ip (e.g. wired locks). + """ + + _attr_icon = "mdi:ip-network" + + @property + def unique_id(self) -> str: + return f"{self._data.get('device_id')}-ip" + + @property + def name(self) -> str: + return f"{self._device_name} IP Address" + + @property + def native_value(self) -> Optional[str]: + return self._data.get("ip") + + @property + def available(self) -> bool: + # Unavailable until the first MQTT /info with an ip field arrives. + return "ip" in self._data diff --git a/custom_components/tinxy/strings.json b/custom_components/tinxy/strings.json index 44aecc6..afb186c 100644 --- a/custom_components/tinxy/strings.json +++ b/custom_components/tinxy/strings.json @@ -2,8 +2,8 @@ "config": { "step": { "user": { - "title": "Tinxy Cloud Setup", - "description": "Enter your Tinxy Cloud API key to connect your devices.", + "title": "Connect to Tinxy", + "description": "Enter your Tinxy API key to get started. You can find this in the Tinxy mobile app under Settings.", "data": { "api_key": "[%key:common::config_flow::data::api_key%]" } diff --git a/custom_components/tinxy/switch.py b/custom_components/tinxy/switch.py index dc70a93..48084d9 100644 --- a/custom_components/tinxy/switch.py +++ b/custom_components/tinxy/switch.py @@ -46,11 +46,11 @@ async def async_setup_entry( async_add_entities: Callback to add entities to Home Assistant. """ try: - apidata, coordinator = hass.data[DOMAIN][entry.entry_id] + apidata, coordinator = hass.data[DOMAIN][entry.entry_id][0], hass.data[DOMAIN][entry.entry_id][1] await coordinator.async_config_entry_first_refresh() all_devices = apidata.list_switches() - result = await apidata.get_all_status() + result = coordinator.data switch_entities: List[TinxySwitch] = [] for device in all_devices: @@ -160,7 +160,6 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) except Exception as exc: _LOGGER.error("Failed to turn ON Tinxy switch %s: %s", self.device.id, exc) - await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """ @@ -179,4 +178,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) except Exception as exc: _LOGGER.error("Failed to turn OFF Tinxy switch %s: %s", self.device.id, exc) - await self.coordinator.async_request_refresh() diff --git a/custom_components/tinxy/tinxycloud.py b/custom_components/tinxy/tinxycloud.py index 93f8090..4e59ea7 100644 --- a/custom_components/tinxy/tinxycloud.py +++ b/custom_components/tinxy/tinxycloud.py @@ -6,6 +6,8 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional +from .const import MQTT_SERVER, MQTT_PORT + class TinxyException(Exception): """Base exception for Tinxy API errors.""" @@ -36,6 +38,16 @@ def __post_init__(self) -> None: raise TinxyException("No API URL was provided.") +@dataclass +class TinxyMQTTCredentials: + """Per-user MQTT broker credentials fetched from the Tinxy API.""" + + username: str # "user-{userId}" + password: str # mqttPassword from API + broker: str = MQTT_SERVER + port: int = MQTT_PORT + + class TinxyCloud: """Tinxy Cloud API client for Home Assistant integration.""" @@ -74,6 +86,8 @@ def __init__(self, host_config: TinxyHostConfiguration, web_session: Any) -> Non self.host_config = host_config self.web_session = web_session self.devices: List[Dict[str, Any]] = [] + # Set by __init__.py after the MQTT client is created + self.mqtt_client: Optional[Any] = None async def tinxy_request( self, path: str, payload: Optional[Dict[str, Any]] = None, method: str = "GET" @@ -119,6 +133,41 @@ async def sync_devices(self) -> None: device_list.extend(self.parse_device(item)) self.devices = device_list + async def async_get_mqtt_credentials(self) -> "TinxyMQTTCredentials": + """Fetch per-user MQTT credentials from the Tinxy API. + + Endpoint: GET /v2/users/me + Response: {"userId": "…", "mqttPassword": "…"} + + MQTT username is constructed as "user-{userId}". + + Returns: + TinxyMQTTCredentials with username, password, broker and port. + + Raises: + TinxyAuthenticationException: If the API returns an auth error. + TinxyException: On any other failure. + """ + try: + data = await self.tinxy_request("v2/users/me") + except TinxyException as exc: + raise TinxyAuthenticationException( + f"Failed to fetch MQTT credentials: {exc}" + ) from exc + + user_id = data.get("userId") + password = data.get("mqttPassword") + + if not user_id or not password: + raise TinxyAuthenticationException( + f"MQTT credentials response missing fields: {data}" + ) + + return TinxyMQTTCredentials( + username=f"user-{user_id}", + password=password, + ) + def list_switches(self) -> List[Dict[str, Any]]: """Return a list of all switch devices.""" return [device for device in self.devices if device["device_type"] == "Switch"] @@ -139,84 +188,6 @@ def list_locks(self) -> List[Dict[str, Any]]: """Return a list of all lock devices.""" return [device for device in self.devices if device["gtype"] in self.GTYPE_LOCK] - async def get_device_state(self, device_id: str, device_number: str) -> Dict[str, Any]: - """Get the current state of a device. - - Args: - device_id: The device ID - device_number: The device number - - Returns: - Device state information - """ - return await self.tinxy_request( - f"v2/devices/{device_id}/state?deviceNumber={device_number}" - ) - - def state_to_val(self, state: str) -> bool: - """Convert string state to boolean value. - - Args: - state: String state ("ON" or "OFF") - - Returns: - Boolean representation of the state - """ - return state == "ON" - - async def get_all_status(self) -> Dict[str, Dict[str, Any]]: - """Get status of all devices. - - Returns: - Dictionary mapping device IDs to their status information - """ - status_data = await self.tinxy_request("v2/devices_state") - device_status = {} - - for status in status_data: - if "state" not in status: - continue - - if isinstance(status["state"], list): - # Handle multi-device status - for item in status["state"]: - device_id = f"{status['_id']}-{item.get('number', 1)}" - single_device = self._extract_device_state(item.get("state", {})) - if "number" not in item: - single_device["item"] = item - device_status[device_id] = single_device - else: - # Handle single device status - device_id = f"{status['_id']}-1" - single_device = self._extract_device_state(status["state"]) - device_status[device_id] = single_device - - return device_status - - def _extract_device_state(self, state_data: Dict[str, Any]) -> Dict[str, Any]: - """Extract device state information from API response. - - Args: - state_data: Raw state data from API - - Returns: - Processed device state information - """ - single_device = {} - - if "state" in state_data: - single_device["state"] = self.state_to_val(state_data["state"]) - if "status" in state_data: - single_device["status"] = state_data["status"] - if "brightness" in state_data: - single_device["brightness"] = state_data["brightness"] - if "door" in state_data: # For lock devices - single_device["door"] = state_data["door"] - if "colorTemperatureInKelvin" in state_data: - single_device["colorTemperatureInKelvin"] = state_data["colorTemperatureInKelvin"] - - return single_device - async def set_device_state( self, item_id: str, @@ -224,21 +195,49 @@ async def set_device_state( state: str, brightness: Optional[int] = None, color_temp: Optional[int] = None, - ) -> Dict[str, Any]: - """Set device state. + ) -> Optional[Dict[str, Any]]: + """Set device state via MQTT (preferred) or REST fallback. + + MQTT command format: + topic : /{device_id} + payload : {"n": relay_no, "on": "1"|"0"} (toggle) + {"n": relay_no, "bright": 0-100} (brightness) + + color_temp is not supported over MQTT; when provided the REST API is + used regardless of MQTT availability. Args: - item_id: Device identifier - device_number: Device number - state: Desired state - brightness: Optional brightness level - color_temp: Optional color temperature + item_id: Tinxy device _id (the raw MongoDB ID). + device_number: 1-based relay/node number. + state: "ON" or "OFF". + brightness: Optional 0-100 brightness percentage. + color_temp: Optional colour temperature in Kelvin. Returns: - API response + None when sent via MQTT; REST response dict when falling back. """ - payload = {"request": {"state": state}, "deviceNumber": device_number} - + # Normalise types – platforms pass relay_no as str and state as int/str + relay_no_int: int = int(device_number) + if isinstance(state, int): + state_str = "ON" if state else "OFF" + else: + state_str = str(state).upper() + + # --- MQTT path --- + if self.mqtt_client is not None and color_temp is None: + await self.mqtt_client.async_publish_command( + device_id=item_id, + relay_no=relay_no_int, + state=state_str, + brightness=brightness, + ) + return None + + # --- REST fallback (color_temp or no MQTT client) --- + payload: Dict[str, Any] = { + "request": {"state": state_str}, + "deviceNumber": relay_no_int, + } if brightness is not None: payload["request"]["brightness"] = brightness if color_temp is not None: diff --git a/custom_components/tinxy/translations/en.json b/custom_components/tinxy/translations/en.json index 64e8625..316e67c 100644 --- a/custom_components/tinxy/translations/en.json +++ b/custom_components/tinxy/translations/en.json @@ -1,15 +1,17 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Tinxy is already configured. You can manage your devices from the integration page." }, "error": { - "cannot_connect": "Failed to connect", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" + "cannot_connect": "Unable to connect to the Tinxy service. Please check your network and try again.", + "invalid_auth": "The API key provided is invalid. Please verify it in the Tinxy mobile app.", + "unknown": "An unexpected error occurred. Please try again or check the logs for details." }, "step": { "user": { + "title": "Connect to Tinxy", + "description": "Enter your Tinxy API key to get started. You can find this in the Tinxy mobile app under Settings.", "data": { "api_key": "API Key" } diff --git a/custom_components/tinxy/translations/hi.json b/custom_components/tinxy/translations/hi.json new file mode 100644 index 0000000..fee7e7a --- /dev/null +++ b/custom_components/tinxy/translations/hi.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy पहले से कॉन्फ़िगर किया हुआ है। आप इंटीग्रेशन पेज से अपने डिवाइस प्रबंधित कर सकते हैं।" + }, + "error": { + "cannot_connect": "Tinxy सेवा से कनेक्ट नहीं हो सका। कृपया अपना नेटवर्क जांचें और पुनः प्रयास करें।", + "invalid_auth": "दिया गया API Key अमान्य है। कृपया Tinxy मोबाइल ऐप में इसे सत्यापित करें।", + "unknown": "एक अप्रत्याशित त्रुटि हुई। कृपया पुनः प्रयास करें या लॉग जांचें।" + }, + "step": { + "user": { + "title": "Tinxy से कनेक्ट करें", + "description": "शुरू करने के लिए अपना Tinxy API Key दर्ज करें। आप इसे Tinxy मोबाइल ऐप की सेटिंग्स में पा सकते हैं।", + "data": { + "api_key": "API Key" + } + } + } + } +} diff --git a/custom_components/tinxy/translations/kn.json b/custom_components/tinxy/translations/kn.json new file mode 100644 index 0000000..c7c9f57 --- /dev/null +++ b/custom_components/tinxy/translations/kn.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy ಈಗಾಗಲೇ ಕಾನ್ಫಿಗರ್ ಮಾಡಲಾಗಿದೆ. ಇಂಟಿಗ್ರೇಶನ್ ಪುಟದಿಂದ ನಿಮ್ಮ ಸಾಧನಗಳನ್ನು ನಿರ್ವಹಿಸಬಹುದು." + }, + "error": { + "cannot_connect": "Tinxy ಸೇವೆಗೆ ಸಂಪರ್ಕಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ. ನಿಮ್ಮ ನೆಟ್‌ವರ್ಕ್ ಪರಿಶೀಲಿಸಿ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.", + "invalid_auth": "ನೀಡಿದ API Key ಅಮಾನ್ಯವಾಗಿದೆ. ದಯವಿಟ್ಟು Tinxy ಮೊಬೈಲ್ ಆ್ಯಪ್‌ನಲ್ಲಿ ಇದನ್ನು ಪರಿಶೀಲಿಸಿ.", + "unknown": "ಅನಿರೀಕ್ಷಿತ ದೋಷ ಸಂಭವಿಸಿದೆ. ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ ಅಥವಾ ಲಾಗ್‌ಗಳನ್ನು ಪರಿಶೀಲಿಸಿ." + }, + "step": { + "user": { + "title": "Tinxy ಗೆ ಸಂಪರ್ಕಿಸಿ", + "description": "ಪ್ರಾರಂಭಿಸಲು ನಿಮ್ಮ Tinxy API Key ನಮೂದಿಸಿ. Tinxy ಮೊಬೈಲ್ ಆ್ಯಪ್‌ನ ಸೆಟ್ಟಿಂಗ್ಸ್ ವಿಭಾಗದಲ್ಲಿ ಇದನ್ನು ಕಾಣಬಹುದು.", + "data": { + "api_key": "API Key" + } + } + } + } +} diff --git a/custom_components/tinxy/translations/ml.json b/custom_components/tinxy/translations/ml.json new file mode 100644 index 0000000..0c5e76a --- /dev/null +++ b/custom_components/tinxy/translations/ml.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy ഇതിനകം കോൺഫിഗർ ചെയ്തിട്ടുണ്ട്. ഇന്റഗ്രേഷൻ പേജിൽ നിന്ന് നിങ്ങളുടെ ഉപകരണങ്ങൾ നിയന്ത്രിക്കാം." + }, + "error": { + "cannot_connect": "Tinxy സേവനത്തിലേക്ക് കണക്റ്റ് ചെയ്യാൻ കഴിഞ്ഞില്ല. നിങ്ങളുടെ നെറ്റ്‌വർക്ക് പരിശോധിച്ച് വീണ്ടും ശ്രമിക്കുക.", + "invalid_auth": "നൽകിയ API Key അസാധുവാണ്. Tinxy മൊബൈൽ ആപ്പിൽ ഇത് പരിശോധിക്കുക.", + "unknown": "അപ്രതീക്ഷിതമായ ഒരു പിശക് സംഭവിച്ചു. വീണ്ടും ശ്രമിക്കുക അല്ലെങ്കിൽ ലോഗുകൾ പരിശോധിക്കുക." + }, + "step": { + "user": { + "title": "Tinxy-യിലേക്ക് കണക്റ്റ് ചെയ്യുക", + "description": "ആരംഭിക്കാൻ നിങ്ങളുടെ Tinxy API Key നൽകുക. Tinxy മൊബൈൽ ആപ്പിന്റെ സെറ്റിംഗ്സ് വിഭാഗത്തിൽ ഇത് കണ്ടെത്താം.", + "data": { + "api_key": "API Key" + } + } + } + } +} diff --git a/custom_components/tinxy/translations/mr.json b/custom_components/tinxy/translations/mr.json new file mode 100644 index 0000000..c025df7 --- /dev/null +++ b/custom_components/tinxy/translations/mr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy आधीच कॉन्फिगर केलेले आहे. इंटिग्रेशन पेजवरून तुम्ही तुमची उपकरणे व्यवस्थापित करू शकता." + }, + "error": { + "cannot_connect": "Tinxy सेवेशी कनेक्ट करणे शक्य झाले नाही. कृपया तुमचे नेटवर्क तपासा आणि पुन्हा प्रयत्न करा.", + "invalid_auth": "दिलेली API Key अवैध आहे. कृपया Tinxy मोबाइल अॅपमध्ये ती सत्यापित करा.", + "unknown": "एक अनपेक्षित त्रुटी आली. कृपया पुन्हा प्रयत्न करा किंवा लॉग तपासा." + }, + "step": { + "user": { + "title": "Tinxy शी कनेक्ट करा", + "description": "सुरू करण्यासाठी तुमची Tinxy API Key प्रविष्ट करा. ही Tinxy मोबाइल अॅपच्या सेटिंग्ज विभागात सापडेल.", + "data": { + "api_key": "API Key" + } + } + } + } +} diff --git a/custom_components/tinxy/translations/pa.json b/custom_components/tinxy/translations/pa.json new file mode 100644 index 0000000..847e512 --- /dev/null +++ b/custom_components/tinxy/translations/pa.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy ਪਹਿਲਾਂ ਹੀ ਕੌਂਫਿਗਰ ਕੀਤਾ ਜਾ ਚੁੱਕਾ ਹੈ। ਤੁਸੀਂ ਇੰਟੀਗ੍ਰੇਸ਼ਨ ਪੇਜ ਤੋਂ ਆਪਣੇ ਡਿਵਾਈਸਾਂ ਦਾ ਪ੍ਰਬੰਧਨ ਕਰ ਸਕਦੇ ਹੋ।" + }, + "error": { + "cannot_connect": "Tinxy ਸੇਵਾ ਨਾਲ ਕਨੈਕਟ ਨਹੀਂ ਹੋ ਸਕਿਆ। ਕਿਰਪਾ ਕਰਕੇ ਆਪਣਾ ਨੈੱਟਵਰਕ ਜਾਂਚੋ ਅਤੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ।", + "invalid_auth": "ਦਿੱਤਾ ਗਿਆ API Key ਗਲਤ ਹੈ। ਕਿਰਪਾ ਕਰਕੇ Tinxy ਮੋਬਾਈਲ ਐਪ ਵਿੱਚ ਇਸਦੀ ਪੁਸ਼ਟੀ ਕਰੋ।", + "unknown": "ਇੱਕ ਅਣਕਿਆਸੀ ਗਲਤੀ ਆਈ। ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਜਾਂ ਲੌਗ ਜਾਂਚੋ।" + }, + "step": { + "user": { + "title": "Tinxy ਨਾਲ ਕਨੈਕਟ ਕਰੋ", + "description": "ਸ਼ੁਰੂ ਕਰਨ ਲਈ ਆਪਣੀ Tinxy API Key ਦਰਜ ਕਰੋ। ਇਹ Tinxy ਮੋਬਾਈਲ ਐਪ ਦੀਆਂ ਸੈਟਿੰਗਾਂ ਵਿੱਚ ਮਿਲੇਗੀ।", + "data": { + "api_key": "API Key" + } + } + } + } +} diff --git a/custom_components/tinxy/translations/ta.json b/custom_components/tinxy/translations/ta.json new file mode 100644 index 0000000..99cef69 --- /dev/null +++ b/custom_components/tinxy/translations/ta.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy ஏற்கனவே உள்ளமைக்கப்பட்டுள்ளது. இன்டெக்ரேஷன் பக்கத்தில் இருந்து உங்கள் சாதனங்களை நிர்வகிக்கலாம்." + }, + "error": { + "cannot_connect": "Tinxy சேவையுடன் இணைக்க முடியவில்லை. உங்கள் நெட்வொர்க்கை சரிபார்த்து மீண்டும் முயற்சிக்கவும்.", + "invalid_auth": "வழங்கிய API Key தவறானது. Tinxy மொபைல் ஆப்பில் இதை சரிபார்க்கவும்.", + "unknown": "எதிர்பாராத பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும் அல்லது பதிவுகளை சரிபார்க்கவும்." + }, + "step": { + "user": { + "title": "Tinxy-உடன் இணைக்கவும்", + "description": "தொடங்க உங்கள் Tinxy API Key ஐ உள்ளிடவும். Tinxy மொபைல் ஆப்பின் அமைப்புகள் பகுதியில் இதை காணலாம்.", + "data": { + "api_key": "API Key" + } + } + } + } +} diff --git a/custom_components/tinxy/translations/te.json b/custom_components/tinxy/translations/te.json new file mode 100644 index 0000000..0b58b75 --- /dev/null +++ b/custom_components/tinxy/translations/te.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Tinxy ఇప్పటికే కాన్ఫిగర్ చేయబడింది. ఇంటిగ్రేషన్ పేజీ నుండి మీ పరికరాలను నిర్వహించవచ్చు." + }, + "error": { + "cannot_connect": "Tinxy సేవకు కనెక్ట్ చేయడం సాధ్యం కాలేదు. మీ నెట్‌వర్క్‌ను తనిఖీ చేసి మళ్ళీ ప్రయత్నించండి.", + "invalid_auth": "అందించిన API Key చెల్లదు. దయచేసి Tinxy మొబైల్ యాప్‌లో దీన్ని ధృవీకరించండి.", + "unknown": "అనుకోని లోపం సంభవించింది. దయచేసి మళ్ళీ ప్రయత్నించండి లేదా లాగ్‌లు తనిఖీ చేయండి." + }, + "step": { + "user": { + "title": "Tinxy కి కనెక్ట్ చేయండి", + "description": "ప్రారంభించడానికి మీ Tinxy API Key ని నమోదు చేయండి. Tinxy మొబైల్ యాప్ సెట్టింగ్స్ విభాగంలో ఇది కనుగొనవచ్చు.", + "data": { + "api_key": "API Key" + } + } + } + } +}