pax_global_header00006660000000000000000000000064152023043650014512gustar00rootroot0000000000000052 comment=8f4ab0e329092bd2420569328e81d0cdc86bb2b8 pyyardian-1.3.3/000077500000000000000000000000001520230436500135165ustar00rootroot00000000000000pyyardian-1.3.3/,github/000077500000000000000000000000001520230436500150545ustar00rootroot00000000000000pyyardian-1.3.3/,github/workflows/000077500000000000000000000000001520230436500171115ustar00rootroot00000000000000pyyardian-1.3.3/,github/workflows/publish.yml000066400000000000000000000012621520230436500213030ustar00rootroot00000000000000name: 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/v1pyyardian-1.3.3/.gitignore000066400000000000000000000000611520230436500155030ustar00rootroot00000000000000.venv dist build *.egg-info __pycache__ .DS_Storepyyardian-1.3.3/.vscode/000077500000000000000000000000001520230436500150575ustar00rootroot00000000000000pyyardian-1.3.3/.vscode/settings.json000066400000000000000000000002041520230436500176060ustar00rootroot00000000000000{ "python-envs.defaultEnvManager": "ms-python.python:conda", "python-envs.defaultPackageManager": "ms-python.python:conda" }pyyardian-1.3.3/LICENSE000066400000000000000000000020601520230436500145210ustar00rootroot00000000000000MIT 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.pyyardian-1.3.3/LICENSE.txt000066400000000000000000000020431520230436500153400ustar00rootroot00000000000000Copyright (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.pyyardian-1.3.3/README.md000066400000000000000000000002401520230436500147710ustar00rootroot00000000000000 Python module for interacting with the Yardian smart irrigation controller. This module communicates directly towards the IP address of the Yardian controller.pyyardian-1.3.3/publish.sh000077500000000000000000000000511520230436500155170ustar00rootroot00000000000000python setup.py sdist twine upload dist/*pyyardian-1.3.3/pyyardian/000077500000000000000000000000001520230436500155165ustar00rootroot00000000000000pyyardian-1.3.3/pyyardian/__init__.py000066400000000000000000000003121520230436500176230ustar00rootroot00000000000000from .async_client import AsyncYardianClient, YardianDeviceState from .exceptions import NotAuthorizedException, NetworkException from .typing import DeviceInfo, OperationInfo __version__ = "1.3.3"pyyardian-1.3.3/pyyardian/async_client.py000066400000000000000000000200001520230436500205330ustar00rootroot00000000000000import logging import aiohttp import asyncio 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, username: str = "admin", password: str = "1234" ) -> None: """Initialize the client. Use .create() for async auto-detection.""" self._websession = websession self._host = host self._token = token # Static token for YP self._username = username # YC default: admin self._password = password # YC default: 1234 self._base_url = f"http://{host}:880" self._base_header = {} self._device_info = None self.model_type = "yp" @classmethod async def create(cls, websession: aiohttp.ClientSession, host: str, token: str = None, username: str = "admin", password: str = "1234"): """Asynchronous factory to create a client and auto-detect the model.""" client = cls(websession, host, token, username, password) await client.detect_model() return client async def detect_model(self): """Identify YP or YC by testing Port 880 auth behavior.""" url = f"http://{self._host}:880/API_GET_DEVICEINFO" try: async with self._websession.get(url, timeout=DEFAULT_TIMEOUT) as resp: data = await resp.json(content_type=None) # Behavioral Detection: YP returns -1000 without token if data and data.get("iCode") == -1000: self.model_type = "yp" self._base_url = f"http://{self._host}:880" self._base_header = {"Yardian-Token": self._token} else: # YC allows discovery without token self.model_type = "yc" self._base_url = f"http://{self._host}:80" await self.login_yc() except Exception as e: raise NetworkException(str(e)) async def login_yc(self): """Exchange YC credentials for a JWT.""" url = f"http://{self._host}:80/auth/login" payload = {"user_id": self._username, "password": self._password} async with self._websession.post(url, json=payload, timeout=DEFAULT_TIMEOUT) as resp: if resp.status == 200: result = await resp.json(content_type=None) self._token = result["token"] self._base_header = {"Authorization": f"Bearer {self._token}"} else: raise NotAuthorizedException(f"YC Login Failed: {resp.status}") 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._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") return result | MODEL_DETAIL.get(model, {}) except Exception: raise NetworkException() async def fetch_oper_info(self) -> OperationInfo: """Route to correct info endpoint.""" endpoint = "/res/controller" if self.model_type == "yc" else "/API_MGR_GET_OPERINFO" 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: _LOGGER.error("Controller at %s returned empty response during oper fetch", self._host) 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._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._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() zones = await self.fetch_zone_info() 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. All durations are converted to seconds.""" # Convert the minutes provided by HA into seconds for the hardware api_duration = duration * 60 if self.model_type == "yc": # Standalone C1 / YC Logic url = f"{self._base_url}/res/inst-program" body = {"output_durs": [[zone_id, api_duration]], "store": False} else: # Yardian Pro / YP Logic (Port 880) # Even on YP, the 'Instant' payload expects seconds for precision url = self._base_url body = { "sEvent": "AE_IRR_START_INST", "sPayload": f"[[-1, 0, 0, {zone_id}, {api_duration}]]" } 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() 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: 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() 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._websession.get(url, headers=self._base_header) as resp: return await resp.json(content_type=None) if resp.status == 200 else []pyyardian-1.3.3/pyyardian/const.py000066400000000000000000000011101520230436500172070ustar00rootroot00000000000000MODEL_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 = 30pyyardian-1.3.3/pyyardian/exceptions.py000066400000000000000000000003101520230436500202430ustar00rootroot00000000000000class NotAuthorizedException(Exception): """Exception raised when the client is not authorized.""" class NetworkException(Exception): """Exception raised when cannot connect to device."""pyyardian-1.3.3/pyyardian/typing.py000066400000000000000000000012151520230436500174010ustar00rootroot00000000000000from 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: intpyyardian-1.3.3/requirement_dev.txt000066400000000000000000000000451520230436500174540ustar00rootroot00000000000000aiohttp==3.8.5 setuptools wheel twinepyyardian-1.3.3/setup.cfg000066400000000000000000000000771520230436500153430ustar00rootroot00000000000000[metadata] description-file=README.md license_files=LICENSE.rstpyyardian-1.3.3/setup.py000066400000000000000000000034601520230436500152330ustar00rootroot00000000000000import 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", ], )pyyardian-1.3.3/tests/000077500000000000000000000000001520230436500146605ustar00rootroot00000000000000pyyardian-1.3.3/tests/test.py000066400000000000000000000015331520230436500162130ustar00rootroot00000000000000import 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())pyyardian-1.3.3/tests/test_error_handling.py000066400000000000000000000014511520230436500212670ustar00rootroot00000000000000import 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())pyyardian-1.3.3/tests/test_info_irr.py000066400000000000000000000021331520230436500200770ustar00rootroot00000000000000import 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())pyyardian-1.3.3/tests/test_start_irr.py000066400000000000000000000013271520230436500203050ustar00rootroot00000000000000import 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())pyyardian-1.3.3/tests/test_stop_irr.py000066400000000000000000000021261520230436500201330ustar00rootroot00000000000000import 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())