pax_global_header00006660000000000000000000000064152417734260014525gustar00rootroot0000000000000052 comment=f5d4c15b251de767f7339fd3f0a96054298446fd aeon-matrix-pyyardian-f5d4c15/000077500000000000000000000000001524177342600163645ustar00rootroot00000000000000aeon-matrix-pyyardian-f5d4c15/,github/000077500000000000000000000000001524177342600177225ustar00rootroot00000000000000aeon-matrix-pyyardian-f5d4c15/,github/workflows/000077500000000000000000000000001524177342600217575ustar00rootroot00000000000000aeon-matrix-pyyardian-f5d4c15/,github/workflows/publish.yml000066400000000000000000000012621524177342600241510ustar00rootroot00000000000000name: Publish to PyPI on: push: tags: - 'v*' # Triggers when you push a tag like v1.2.0 jobs: build-n-publish: name: Build and publish to PyPI runs-on: ubuntu-latest environment: name: pypi permissions: id-token: write # MANDATORY for Trusted Publishing contents: read steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install build tools run: python -m pip install build - name: Build package run: python -m build - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1aeon-matrix-pyyardian-f5d4c15/.gitignore000066400000000000000000000000611524177342600203510ustar00rootroot00000000000000.venv dist build *.egg-info __pycache__ .DS_Storeaeon-matrix-pyyardian-f5d4c15/.vscode/000077500000000000000000000000001524177342600177255ustar00rootroot00000000000000aeon-matrix-pyyardian-f5d4c15/.vscode/settings.json000066400000000000000000000002041524177342600224540ustar00rootroot00000000000000{ "python-envs.defaultEnvManager": "ms-python.python:conda", "python-envs.defaultPackageManager": "ms-python.python:conda" }aeon-matrix-pyyardian-f5d4c15/LICENSE000066400000000000000000000020601524177342600173670ustar00rootroot00000000000000MIT License Copyright (c) 2023 Aeon Matrix Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.aeon-matrix-pyyardian-f5d4c15/LICENSE.txt000066400000000000000000000020431524177342600202060ustar00rootroot00000000000000Copyright (c) 2023 Aeon Matrix Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.aeon-matrix-pyyardian-f5d4c15/README.md000066400000000000000000000002401524177342600176370ustar00rootroot00000000000000 Python module for interacting with the Yardian smart irrigation controller. This module communicates directly towards the IP address of the Yardian controller.aeon-matrix-pyyardian-f5d4c15/publish.sh000077500000000000000000000000511524177342600203650ustar00rootroot00000000000000python setup.py sdist twine upload dist/*aeon-matrix-pyyardian-f5d4c15/pyyardian/000077500000000000000000000000001524177342600203645ustar00rootroot00000000000000aeon-matrix-pyyardian-f5d4c15/pyyardian/__init__.py000066400000000000000000000003141524177342600224730ustar00rootroot00000000000000from .async_client import AsyncYardianClient, YardianDeviceState from .exceptions import NotAuthorizedException, NetworkException from .typing import DeviceInfo, OperationInfo __version__ = "1.4.2" aeon-matrix-pyyardian-f5d4c15/pyyardian/async_client.py000066400000000000000000000237431524177342600234220ustar00rootroot00000000000000import logging import aiohttp import asyncio import re from dataclasses import dataclass from .const import MODEL_DETAIL, DEFAULT_TIMEOUT from .exceptions import NotAuthorizedException, NetworkException from .typing import DeviceInfo, OperationInfo _LOGGER = logging.getLogger(__name__) @dataclass class YardianDeviceState: """Data retrieved from a Yardian device.""" zones: list[list] active_zones: set[int] class AsyncYardianClient: def __init__( self, websession: aiohttp.ClientSession, host: str, token: str = None, ) -> None: """Initialize the client. Use .create() for async auto-detection.""" self._websession = websession self._host = host self._token = token self._base_url = f"http://{host}:880" self._base_header = {} self._device_info = None self.model_type = "yp" self._lock = asyncio.Lock() # ADDED: Enforce sequential API calls @classmethod async def create( cls, websession: aiohttp.ClientSession, host: str, token: str = None, ): """Asynchronous factory to create a client and auto-detect the model.""" client = cls(websession, host, token) await client.detect_model() return client async def detect_model(self): """Identify YP or YC by reading the model string via API_GET_DEVICEINFO.""" headers = {"Yardian-Token": self._token} if self._token else {} url = f"http://{self._host}:880/API_GET_DEVICEINFO" try: async with self._lock: async with self._websession.get( url, headers=headers, timeout=DEFAULT_TIMEOUT ) as resp: data = await resp.json(content_type=None) if data and data.get("iCode") == -1000: raise NotAuthorizedException("Invalid token or missing token.") result = data.get("result", data) model = result.get("model", "") is_yc_model = bool(re.search(r"C\d+$", model.upper())) if is_yc_model: self.model_type = "yc" self._base_url = f"http://{self._host}:80" else: self.model_type = "yp" self._base_url = f"http://{self._host}:880" if self._token: self._base_header = {"Yardian-Token": self._token} else: raise NotAuthorizedException("A token is required for all models.") except NotAuthorizedException: raise except Exception as e: raise NetworkException(str(e)) async def fetch_device_info(self) -> DeviceInfo: """Fetch model info on Port 880.""" url = f"http://{self._host}:880/API_GET_DEVICEINFO" try: async with self._lock: async with self._websession.get( url, headers=self._base_header, timeout=DEFAULT_TIMEOUT ) as response: resp = await response.json(content_type=None) if resp is None: _LOGGER.error( "Controller at %s returned empty response during info fetch", self._host, ) return {} result = resp.get("result", resp) model = result.get("model", "") model_info = MODEL_DETAIL.get(model) if not model_info: suffix_match = re.search(r"C\d+$", model.upper()) if suffix_match: suffix = suffix_match.group() base_model = model.upper().replace(suffix, "") base_info = MODEL_DETAIL.get(base_model, {}) if base_info: model_info = { "name": f"{base_info.get('name')} {suffix}", "zones": base_info.get("zones"), } if not model_info: model_info = {} return result | model_info except Exception: raise NetworkException() async def fetch_oper_info(self) -> OperationInfo: """Route to correct info endpoint and normalize data.""" if self.model_type == "yc": async with self._lock: async with self._websession.get( f"{self._base_url}/res/controller", headers=self._base_header ) as response: resp = await response.json(content_type=None) if resp is None: return {} oper_info = resp.get("result", resp) try: async with self._lock: async with self._websession.get( f"{self._base_url}/res/sch-setting", headers=self._base_header ) as sch_response: sch_resp = await sch_response.json(content_type=None) if sch_resp: oper_info["iStandby"] = ( 2147483647 if sch_resp.get("standby_mode") else 0 ) except Exception as e: _LOGGER.warning("Failed to fetch YC schedule settings: %s", e) oper_info["iStandby"] = 0 return oper_info else: async with self._lock: async with self._websession.get( f"{self._base_url}/API_MGR_GET_OPERINFO", headers=self._base_header ) as response: resp = await response.json(content_type=None) if resp is None: return {} return resp.get("result", resp) async def fetch_active_zones(self): """Fetch currently running zone IDs.""" endpoint = ( "/res/running-task" if self.model_type == "yc" else "/API_ZONE_GET_OPENINGZONE" ) async with self._lock: async with self._websession.get( f"{self._base_url}{endpoint}", headers=self._base_header ) as response: resp = await response.json(content_type=None) if resp is None: return [] if self.model_type == "yc": return [task["output_id"] for task in resp] return resp.get("result", []) async def fetch_zone_info(self, amount=None): """Fetch zone metadata (names and status).""" if self.model_type == "yc": async with self._lock: async with self._websession.get( f"{self._base_url}/res/output-setting", headers=self._base_header ) as resp: data = await resp.json(content_type=None) zones = [[z["name"], 1, 0, 0] for z in data] return zones[:amount] if amount else zones else: oper_info = await self.fetch_oper_info() zones = oper_info.get("zones", []) return zones[:amount] if amount else zones async def fetch_device_state(self): """Unified state retrieval.""" if not self._device_info: self._device_info = await self.fetch_device_info() expected_zones = self._device_info.get("zones") zones = await self.fetch_zone_info(amount=expected_zones) active_zones = await self.fetch_active_zones() return YardianDeviceState(zones=zones, active_zones=set(active_zones)) async def start_irrigation(self, zone_id, duration): """Start irrigation with model-specific syntax.""" api_duration = duration * 60 if self.model_type == "yc": url = f"{self._base_url}/res/inst-program" body = {"output_durs": [[zone_id, api_duration]], "store": False} else: url = self._base_url body = { "sEvent": "AE_IRR_START_INST", "sPayload": f"[[-1, 0, 0, {zone_id}, {api_duration}]]", } async with self._lock: await self._websession.post(url, headers=self._base_header, json=body) async def stop_irrigation(self): """Stop current irrigation.""" if self.model_type == "yc": tasks = await self.fetch_active_tasks_raw() async with self._lock: for t in tasks: stop_url = ( f"{self._base_url}/res/running-task/{t['id']}?action=stop" ) await self._websession.patch(stop_url, headers=self._base_header) else: async with self._lock: await self._websession.post( self._base_url, headers=self._base_header, json={"sEvent": "AE_IRR_STOP_INST_TASK"}, ) async def stop_zone(self, zone_id: int): """Stop irrigation for a specific zone.""" if self.model_type == "yc": tasks = await self.fetch_active_tasks_raw() async with self._lock: for t in tasks: if t.get("output_id") == zone_id: stop_url = ( f"{self._base_url}/res/running-task/{t['id']}?action=stop" ) await self._websession.patch( stop_url, headers=self._base_header ) break else: await self.stop_irrigation() async def fetch_active_tasks_raw(self): """Internal helper for YC task ID management.""" url = f"{self._base_url}/res/running-task" async with self._lock: async with self._websession.get(url, headers=self._base_header) as resp: return await resp.json(content_type=None) if resp.status == 200 else [] aeon-matrix-pyyardian-f5d4c15/pyyardian/const.py000066400000000000000000000011101524177342600220550ustar00rootroot00000000000000MODEL_DETAIL = { "YDN1600": {"name": "Yardian", "zones": 12}, "YDN1602": {"name": "Yardian", "zones": 8}, "PRO1600": {"name": "Yardian Pro", "zones": 12}, "PRO1602": {"name": "Yardian Pro", "zones": 8}, "PRO1900": {"name": "Yardian Pro", "zones": 12}, "PRO1902": {"name": "Yardian Pro", "zones": 8}, "PRO1906": {"name": "Yardian Pro", "zones": 6}, "PRO1900C1": {"name": "Yardian Pro C1", "zones": 12}, "PRO1902C1": {"name": "Yardian Pro C1", "zones": 8}, "PRO1906C1": {"name": "Yardian Pro C1", "zones": 6}, } DEFAULT_TIMEOUT = 30aeon-matrix-pyyardian-f5d4c15/pyyardian/exceptions.py000066400000000000000000000003101524177342600231110ustar00rootroot00000000000000class NotAuthorizedException(Exception): """Exception raised when the client is not authorized.""" class NetworkException(Exception): """Exception raised when cannot connect to device."""aeon-matrix-pyyardian-f5d4c15/pyyardian/typing.py000066400000000000000000000012151524177342600222470ustar00rootroot00000000000000from typing import TypedDict, Any class OperationInfo(TypedDict, total=False): sIotcUid: str iTimezoneOffset: int fFreezePrevent: int iRainDelay: int iStandby: int bPopSwitch: bool iMasterValveId: int viMdArgu: list[int] iSensorDelay: int iWaterHammerDuration: int iAlwaysOnZoneId: int iScarecrowZoneId: int iScarecrowDurationSec: int DSFactor: int region: str sensor1: dict[str, Any] sensor2: dict[str, Any] zones: list[list[Any]] class DeviceInfo(TypedDict, total=False): name: str model: str serialNumber: str yid: str zones: intaeon-matrix-pyyardian-f5d4c15/requirement_dev.txt000066400000000000000000000000451524177342600223220ustar00rootroot00000000000000aiohttp==3.8.5 setuptools wheel twineaeon-matrix-pyyardian-f5d4c15/setup.cfg000066400000000000000000000000771524177342600202110ustar00rootroot00000000000000[metadata] description-file=README.md license_files=LICENSE.rstaeon-matrix-pyyardian-f5d4c15/setup.py000066400000000000000000000034601524177342600201010ustar00rootroot00000000000000import os import re from setuptools import setup, find_packages def get_version(): """Extract version from pyyardian/__init__.py""" init_py = os.path.join(os.path.abspath(os.path.dirname(__file__)), "pyyardian", "__init__.py") with open(init_py, "r", encoding="utf-8") as f: # Look for the line __version__ = "..." match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', f.read()) if match: return match.group(1) raise RuntimeError("Unable to find version string.") # Read the contents of your README file for the long description # This ensures your PyPI page looks as good as your GitHub page long_description = "" readme_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), "README.md") if os.path.exists(readme_path): with open(readme_path, "r", encoding="utf-8") as fh: long_description = fh.read() else: # Fallback if README.md is missing long_description = "A Python module for interacting with the Yardian irrigation controller." setup( name="pyyardian", version=get_version(), # Dynamically pulled from __init__.py packages=find_packages(), install_requires=[ "aiohttp>=3.8.0", ], python_requires=">=3.9", # Metadata license="MIT", author="Yardian Support", author_email="contact@aeonmatrix.com", description="A module for interacting with the Yardian irrigation controller", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/aeon-matrix/pyyardian", # Classifiers help users find your project on PyPI classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Topic :: Home Automation", ], )aeon-matrix-pyyardian-f5d4c15/tests/000077500000000000000000000000001524177342600175265ustar00rootroot00000000000000aeon-matrix-pyyardian-f5d4c15/tests/test.py000066400000000000000000000015331524177342600210610ustar00rootroot00000000000000import aiohttp import asyncio from pyyardian.async_client import AsyncYardianClient async def main(): # Toggle between 192.168.1.103 (YP) and 192.168.1.104 (YC) host = "192.168.1.104" token = "FCE25479" async with aiohttp.ClientSession() as session: try: print(f"--- Connecting to {host} ---") cli = await AsyncYardianClient.create(session, host, token) print(f"SUCCESS! Model: {cli.model_type.upper()}") state = await cli.fetch_device_state() print(f"\nZones Total: {len(state.zones)}") print(f"Active IDs: {list(state.active_zones)}") for i, z in enumerate(state.zones): print(f"Zone {i+1}: {z[0]}") except Exception as e: print(f"FAILED: {e}") if __name__ == "__main__": asyncio.run(main())aeon-matrix-pyyardian-f5d4c15/tests/test_error_handling.py000066400000000000000000000014511524177342600241350ustar00rootroot00000000000000import aiohttp import asyncio from pyyardian.async_client import AsyncYardianClient async def main(): # TEST 1: Wrong IP print("Testing connection to non-existent IP...") async with aiohttp.ClientSession() as session: try: await AsyncYardianClient.create(session, "192.168.1.254", "BAD_TOKEN") except Exception: print("SUCCESS: Library caught the network timeout/error.") # TEST 2: Bad Token on YP print("\nTesting bad token on YP (192.168.1.103)...") async with aiohttp.ClientSession() as session: try: await AsyncYardianClient.create(session, "192.168.1.103", "WRONG_TOKEN") except Exception: print("SUCCESS: Library caught the Unauthorized error.") if __name__ == "__main__": asyncio.run(main())aeon-matrix-pyyardian-f5d4c15/tests/test_info_irr.py000066400000000000000000000021331524177342600227450ustar00rootroot00000000000000import aiohttp import asyncio from pyyardian.async_client import AsyncYardianClient async def main(): host = "192.168.1.104" token = "FCE25479" async with aiohttp.ClientSession() as session: try: cli = await AsyncYardianClient.create(session, host, token) info = await cli.fetch_device_info() print(f"--- Device Info for {info.get('model')} ---") print(f"Serial: {info.get('serialNumber')}") print(f"Firmware (yid): {info.get('yid')}") # Check if constants were merged correctly if "description" in info: print(f"Model Description: {info['description']}") # Verify we can see the zone metadata state = await cli.fetch_device_state() for i, zone in enumerate(state.zones): # YP format: [name, enabled, mv_link, unstable] print(f"Zone {i+1} Name: {zone[0]}") except Exception as e: print(f"INFO TEST FAILED: {e}") if __name__ == "__main__": asyncio.run(main())aeon-matrix-pyyardian-f5d4c15/tests/test_start_irr.py000066400000000000000000000013271524177342600231530ustar00rootroot00000000000000import aiohttp import asyncio from pyyardian.async_client import AsyncYardianClient async def main(): host = "192.168.1.104" token = "FCE25479" async with aiohttp.ClientSession() as session: try: cli = await AsyncYardianClient.create(session, host, token) print(f"Starting irrigation on {cli.model_type.upper()}...") await cli.start_irrigation(zone_id=0, duration=60) print("Command Sent.") await asyncio.sleep(2) active = await cli.fetch_active_zones() print(f"Active Zones: {active}") except Exception as e: print(f"FAILED: {e}") if __name__ == "__main__": asyncio.run(main())aeon-matrix-pyyardian-f5d4c15/tests/test_stop_irr.py000066400000000000000000000021261524177342600230010ustar00rootroot00000000000000import aiohttp import asyncio from pyyardian.async_client import AsyncYardianClient async def main(): host = "192.168.1.104" # Change to .103 for YP token = "FCE25479" async with aiohttp.ClientSession() as session: try: cli = await AsyncYardianClient.create(session, host, token) print(f"--- Testing STOP on {cli.model_type.upper()} ---") # 1. Start it first so we have something to stop await cli.start_irrigation(0, 120) print("Irrigation started...") await asyncio.sleep(5) # 2. Stop it print("Sending STOP command...") await cli.stop_irrigation() # 3. Verify await asyncio.sleep(2) active = await cli.fetch_active_zones() if not active: print("SUCCESS: All zones stopped.") else: print(f"FAILED: Zones still running: {active}") except Exception as e: print(f"STOP TEST FAILED: {e}") if __name__ == "__main__": asyncio.run(main())