diff --git a/.gitignore b/.gitignore index 8569c75..57a7f36 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ __pycache__/ +\.idea/ *.pyc build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a1551fa --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +repos: + - repo: https://github.com/asottile/pyupgrade + rev: v3.2.2 + hooks: + - id: pyupgrade + args: [ --py310 ] + + - repo: https://github.com/psf/black + rev: 22.10.0 + hooks: + - id: black + args: + - --safe + - --quiet + files: ^((script|tests)/.+)?[^/]+\.py$ + + - repo: https://github.com/codespell-project/codespell + rev: v2.2.2 + hooks: + - id: codespell + args: + - --skip="./.*,*.csv,*.json" + - --quiet-level=2 + exclude_types: [csv, json] + exclude: ^tests/fixtures/ + + - repo: https://github.com/pycqa/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + files: ^(script|tests)/.+\.py$ + + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + hooks: + - id: isort \ No newline at end of file diff --git a/Contributors.md b/Contributors.md new file mode 100644 index 0000000..8c8e80c --- /dev/null +++ b/Contributors.md @@ -0,0 +1,4 @@ +# Contributors +- [Maor] (https://github.com/maorcc) +- [On Freund] (https://github.com/OnFreund) +- [Elad Bar] (https://github.com/elad-bar) \ No newline at end of file diff --git a/README.md b/README.md index 5ea955b..97410f8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # pyrympro -A python library to communitcate with [Read Your Meter Pro](https://rym-pro.com/). +A python library to communicate with [Read Your Meter Pro](https://rym-pro.com/). ## Installation @@ -13,15 +13,73 @@ Python 3.7 and above are supported. ## How to use +### Initialize + +**With predefined client session** + ```python -from pyrympro import RymPro -rym = RymPro() -# you can also pass in your own session +import aiohttp +from pyrympro.rympro import RymPro + +session = aiohttp.ClientSession() rym = RymPro(session) +``` + +**Let client generate session** +```python +from pyrympro.rympro import RymPro + +rym = RymPro() +``` + +### Initialize client and login +```python +from pyrympro.rympro import RymPro + +rym = RymPro() +await rym.initialize("", "") +``` + +### Get the latest details +```python +from pyrympro.rympro import RymPro + +rym = RymPro() +await rym.initialize("", "") + +await rym.update() + +print(f"profile: {rym.profile}") +print(f"meters: {rym.meters}") +print(f"customer_service: {rym.customer_service}") +print(f"settings: {rym.settings}") +``` + +### Set alerts for leaks in all channels +```python +from pyrympro.helpers.enums import MediaTypes, AlertTypes +from pyrympro.rympro import RymPro + +rym = RymPro() +await rym.initialize("", "") + +await rym.update() + +print(f"settings: {rym.settings}") + +await rym.set_alert_settings(AlertTypes.LEAK, MediaTypes.ALL, True) + +print(f"settings: {rym.settings}") +``` + +**Channels** + +- None (MediaTypes.NONE) +- Email (MediaTypes.EMAIL) +- SMS (MediaTypes.SMS) +- All (MediaTypes.ALL) -# device_id can be anything you choose -await rym.login("", "", "") -info = await rym.account_info() -meter_reads = await rym.last_read() -... -``` \ No newline at end of file +**Alert Types** +- Daily exception (AlertTypes.DAILY_EXCEPTION) +- Leak (AlertTypes.LEAK) +- Consumption identified will away or vacation (AlertTypes.CONSUMPTION_WHILE_AWAY) \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyrympro/__init__.py b/pyrympro/__init__.py index e50c37d..e69de29 100644 --- a/pyrympro/__init__.py +++ b/pyrympro/__init__.py @@ -1 +0,0 @@ -from .rympro import CannotConnectError, OperationError, UnauthorizedError, RymPro diff --git a/pyrympro/const.py b/pyrympro/const.py deleted file mode 100644 index a9d06f6..0000000 --- a/pyrympro/const.py +++ /dev/null @@ -1,14 +0,0 @@ -from enum import Enum - -BASE_URL_OLD = "https://api.city-mind.com" -BASE_URL_NEW = "https://api-ctm.city-mind.com" - -CONSUMER_URL_OLD = f"{BASE_URL_OLD}/consumer" -CONSUMER_URL_NEW = f"{BASE_URL_NEW}/consumer" - -CONSUMPTION_URL = f"{BASE_URL_NEW}/consumption" - -class Endpoint(Enum): - LOGIN = f"{CONSUMER_URL_OLD}/login" - ACCOUNT_INFO = f"{CONSUMER_URL_OLD}/me" - LAST_READ = f"{CONSUMPTION_URL}/last-read" diff --git a/pyrympro/helpers/__init__.py b/pyrympro/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyrympro/helpers/const.py b/pyrympro/helpers/const.py new file mode 100644 index 0000000..1799e36 --- /dev/null +++ b/pyrympro/helpers/const.py @@ -0,0 +1,137 @@ +BASE_URL_OLD = "https://api.city-mind.com" +BASE_URL_NEW = "https://api-ctm.city-mind.com" + +CONSUMER_URL_OLD = f"{BASE_URL_OLD}/consumer" +CONSUMER_URL_NEW = f"{BASE_URL_NEW}/consumer" + +CONSUMPTION_URL = f"{BASE_URL_NEW}/consumption" + +API_HEADER_TOKEN = "x-access-token" + +API_URL_OLD = "https://api.city-mind.com" +API_URL_NEW = "https://api-ctm.city-mind.com" + +CITY_MIND_WEBSITE = "https://rym-pro.com" + +ENDPOINT_PARAMETER_METER_ID = "meter_id" +ENDPOINT_PARAMETER_YESTERDAY = "yesterday" +ENDPOINT_PARAMETER_TODAY = "today" +ENDPOINT_PARAMETER_LAST_DAY_MONTH = "last_day_month" +ENDPOINT_PARAMETER_MUNICIPALITY_ID = "municipality_id" +ENDPOINT_PARAMETER_CURRENT_MONTH = "current_month" +ENDPOINT_PARAMETER_ALERT_TYPE = "alert_type" + +ENDPOINT_CONSUMER_OLD = f"{API_URL_OLD}/consumer" +ENDPOINT_CONSUMER_NEW = f"{API_URL_NEW}/consumer" + +ENDPOINT_CONSUMPTION_NEW = f"{API_URL_NEW}/consumption" + +ENDPOINT_MUNICIPALS_OLD = f"{API_URL_OLD}/municipals" +ENDPOINT_MUNICIPALS_NEW = f"{API_URL_NEW}/municipality" + +ENDPOINT_LOGIN = f"{ENDPOINT_CONSUMER_OLD}/login" + +ENDPOINT_ME = f"{ENDPOINT_CONSUMER_OLD}/me" +ENDPOINT_METERS = f"{ENDPOINT_CONSUMER_NEW}/meters" +ENDPOINT_UNITS = f"{ENDPOINT_MUNICIPALS_OLD}/h1/measurmentunits" +ENDPOINT_CUSTOMER_SERVICE = f"{ENDPOINT_MUNICIPALS_OLD}/municipalCustomerService" + +ENDPOINT_ALERTS_FOR_SETTINGS = f"{ENDPOINT_CONSUMER_NEW}/alertsForSettings" + +ENDPOINT_LAST_READ = f"{ENDPOINT_CONSUMPTION_NEW}/last-read" +ENDPOINT_CONSUMPTION_DAILY = f"{ENDPOINT_CONSUMPTION_NEW}/daily/lastbillingCycle/{{meter_id}}/{{yesterday}}/{{today}}" +ENDPOINT_CONSUMPTION_MONTHLY = f"{ENDPOINT_CONSUMPTION_NEW}/monthly/{{meter_id}}/{{current_month}}/{{last_day_month}}" +ENDPOINT_VACATIONS = f"{ENDPOINT_CONSUMER_OLD}/vacations" +ENDPOINT_MY_ALERTS = f"{ENDPOINT_CONSUMER_NEW}/myalerts" +ENDPOINT_MY_ALERTS_SETTINGS = f"{ENDPOINT_CONSUMER_OLD}/myalerts/settings" +ENDPOINT_MY_MESSAGES = f"{ENDPOINT_MUNICIPALS_NEW}/{{municipality_id}}/messages" +ENDPOINT_MY_MESSAGE_SUBJECTS = f"{ENDPOINT_MY_MESSAGES}/message-subjects" +ENDPOINT_CONSUMPTION_LOW_RATE_LIMIT = f"{ENDPOINT_CONSUMPTION_NEW}/Low-Rate-Limit" +ENDPOINT_CONSUMPTION_FORECAST = f"{ENDPOINT_CONSUMPTION_NEW}/forecast/{{meter_id}}" +ENDPOINT_MY_ALERTS_SETTINGS_UPDATE = f"{ENDPOINT_MY_ALERTS}/settings/{{alert_type}}" + +API_DATA_TOKEN = "token" +API_DATA_ERROR_CODE = "code" +API_DATA_ERROR_REASON = "error" + +API_DATA_SECTION_ME = "me" +API_DATA_SECTION_METERS = "meters" +# API_DATA_SECTION_UNITS = "units" +API_DATA_SECTION_CUSTOMER_SERVICE = "customer-service" +# API_DATA_SECTION_MY_MESSAGE_SUBJECTS = "my-message-subjects" +# API_DATA_SECTION_ALERTS_FOR_SETTINGS = "alerts-for-settings" +API_DATA_SECTION_LAST_READ = "last-read" +# API_DATA_SECTION_CONSUMPTION_LOW_RATE_LIMIT = "consumption-low-rate-limit" +API_DATA_SECTION_VACATIONS = "vacations" +API_DATA_SECTION_MY_ALERTS = "my-alerts" +API_DATA_SECTION_MY_MESSAGES = "my-messages" +API_DATA_SECTION_CONSUMPTION_DAILY = "consumption-daily" +API_DATA_SECTION_CONSUMPTION_MONTHLY = "consumption-monthly" +API_DATA_SECTION_CONSUMPTION_FORECAST = "consumption-forecast" +API_DATA_SECTION_SETTINGS = "settings" + +CUSTOMER_SERVICE_PHONE_NUMBER = "phoneNumber" +CUSTOMER_SERVICE_DESCRIPTION = "description" +CUSTOMER_SERVICE_PHONE_MUNICIPAL_ID = "municipalID" +CUSTOMER_SERVICE_EMAIL = "Email" + +METER_COUNT = "meterCount" +METER_SERIAL_NUMBER = "meterSn" +METER_FULL_ADDRESS = "fullAddress" + +LAST_READ_METER_COUNT = METER_COUNT +LAST_READ_METER_ID = "meterId" +LAST_READ_VALUE = "read" + +ME_FIRST_NAME = "firstName" +ME_LAST_NAME = "lastName" +ME_ACCOUNT_NUMBER = "accountNumber" +ME_PHONE_NUMBER_SECTION = "phoneNumber" +ME_COUNTRY_CODE = "countryCode" +ME_PHONE_NUMBER = "phoneNumber" +ME_ADDITIONAL_PHONE_NUMBER = "AdditionalPhoneNumber" +ME_MUNICIPAL_ID = "municipalId" + +CONSUMPTION_METER_COUNT = METER_COUNT +CONSUMPTION_VALUE = "cons" +CONSUMPTION_DATE = "consDate" +CONSUMPTION_ESTIMATION_TYPE = "estimationType" +CONSUMPTION_METER_STATUS_DESC = "meterStatusDesc" +CONSUMPTION_COMMON_DATA = "commonCons" + +CONSUMPTION_FORECAST_ESTIMATED_CONSUMPTION = "estimatedConsumption" + +SETTINGS_ALERT_TYPE_ID = "alertTypeId" +SETTINGS_MEDIA_TYPE_ID = "mediaTypeId" + +ENDPOINT_DATA_INITIALIZE = { + API_DATA_SECTION_ME: ENDPOINT_ME, + API_DATA_SECTION_METERS: ENDPOINT_METERS, + API_DATA_SECTION_CUSTOMER_SERVICE: ENDPOINT_CUSTOMER_SERVICE, +} + +ENDPOINT_DATA_UPDATE = { + # API_DATA_SECTION_VACATIONS: ENDPOINT_VACATIONS, + # API_DATA_SECTION_MY_ALERTS: ENDPOINT_MY_ALERTS, + # API_DATA_SECTION_MY_MESSAGES: ENDPOINT_MY_MESSAGES, + API_DATA_SECTION_SETTINGS: ENDPOINT_MY_ALERTS_SETTINGS +} + +ENDPOINT_DATA_UPDATE_PER_METER = { + API_DATA_SECTION_LAST_READ: ENDPOINT_LAST_READ, + API_DATA_SECTION_CONSUMPTION_DAILY: ENDPOINT_CONSUMPTION_DAILY, + API_DATA_SECTION_CONSUMPTION_MONTHLY: ENDPOINT_CONSUMPTION_MONTHLY, + API_DATA_SECTION_CONSUMPTION_FORECAST: ENDPOINT_CONSUMPTION_FORECAST, +} + +ENDPOINT_DATA_RELOAD = {API_DATA_SECTION_SETTINGS: ENDPOINT_MY_ALERTS_SETTINGS} + + +LOGIN_EMAIL = "email" +LOGIN_PASSWORD = "pw" +LOGIN_DEVICE_ID = "deviceId" + +DEFAULT_DEVICE_ID = "test" + +FORMAT_DATE_ISO = "%Y-%m-%d" +FORMAT_DATE_YEAR_MONTH = "%Y-%m" diff --git a/pyrympro/helpers/enums.py b/pyrympro/helpers/enums.py new file mode 100644 index 0000000..bf00402 --- /dev/null +++ b/pyrympro/helpers/enums.py @@ -0,0 +1,14 @@ +from enum import Enum, IntEnum + + +class MediaTypes(Enum): + NONE = "0" + EMAIL = "3" + SMS = "1" + ALL = "4" + + +class AlertTypes(IntEnum): + DAILY_EXCEPTION = 12 + LEAK = 23 + CONSUMPTION_WHILE_AWAY = 1001 diff --git a/pyrympro/helpers/exceptions.py b/pyrympro/helpers/exceptions.py new file mode 100644 index 0000000..095d095 --- /dev/null +++ b/pyrympro/helpers/exceptions.py @@ -0,0 +1,10 @@ +class CannotConnectError(Exception): + """Exception to indicate an error in connection.""" + + +class UnauthorizedError(Exception): + """Exception to indicate an error in authorization.""" + + +class OperationError(Exception): + """Exception to indicate an error in operation.""" diff --git a/pyrympro/models/base.py b/pyrympro/models/base.py new file mode 100644 index 0000000..cf17324 --- /dev/null +++ b/pyrympro/models/base.py @@ -0,0 +1,16 @@ +import json + + +class BaseObject: + _data: dict | None + + def __init__(self, data: dict): + self._data = data + + def as_dict(self) -> dict: + return self._data + + def __repr__(self): + json_data = json.dumps(self.as_dict(), indent=4) + + return json_data diff --git a/pyrympro/models/consumption.py b/pyrympro/models/consumption.py new file mode 100644 index 0000000..359d55f --- /dev/null +++ b/pyrympro/models/consumption.py @@ -0,0 +1,54 @@ +from datetime import datetime + +from ..helpers.const import * +from .base import BaseObject + + +class Consumption(BaseObject): + def __init__(self, data: dict): + super().__init__(data) + + @property + def meter_count(self) -> int: + return self._data.get(CONSUMPTION_METER_COUNT) + + @property + def date(self) -> datetime: + date_iso = self._data.get(CONSUMPTION_DATE) + date = datetime.fromisoformat(date_iso) + + return date + + @property + def consumption(self) -> str: + return self._data.get(CONSUMPTION_VALUE) + + @property + def estimation_type(self) -> str: + return self._data.get(CONSUMPTION_ESTIMATION_TYPE) + + @property + def common_consumption(self) -> str: + return self._data.get(CONSUMPTION_COMMON_DATA) + + @property + def status_description(self) -> str: + return self._data.get(CONSUMPTION_METER_STATUS_DESC) + + @staticmethod + def load(data: dict): + consumption = Consumption(data) + + return consumption + + def as_dict(self) -> dict: + data = { + "meter_count": self.meter_count, + "date": self.date.isoformat(), + "consumption": self.consumption, + "estimation_type": self.estimation_type, + "common_consumption": self.common_consumption, + "status_description": self.status_description, + } + + return data diff --git a/pyrympro/models/customer_service.py b/pyrympro/models/customer_service.py new file mode 100644 index 0000000..21a3993 --- /dev/null +++ b/pyrympro/models/customer_service.py @@ -0,0 +1,37 @@ +from ..helpers.const import * +from .base import BaseObject + + +class CustomerService(BaseObject): + def __init__(self, data: dict): + super().__init__(data) + + @property + def phone_number(self) -> str: + return self._data.get(CUSTOMER_SERVICE_PHONE_NUMBER) + + @property + def description(self) -> str: + return self._data.get(CUSTOMER_SERVICE_DESCRIPTION) + + @property + def municipal_id(self) -> str: + return self._data.get(CUSTOMER_SERVICE_PHONE_MUNICIPAL_ID) + + @property + def email(self) -> str: + return self._data.get(CUSTOMER_SERVICE_EMAIL) + + @staticmethod + def load(data: dict): + return CustomerService(data) + + def as_dict(self) -> dict: + data = { + "phone_number": self.phone_number, + "description": self.description, + "municipal_id": self.municipal_id, + "email": self.email, + } + + return data diff --git a/pyrympro/models/meter.py b/pyrympro/models/meter.py new file mode 100644 index 0000000..dc8ba6e --- /dev/null +++ b/pyrympro/models/meter.py @@ -0,0 +1,83 @@ +from ..helpers.const import * +from .base import BaseObject +from .consumption import Consumption + + +class Meter(BaseObject): + meter_id: str | None + last_read: float | None + forecast: float | None + daily_consumption: list[Consumption] | None + monthly_consumption: list[Consumption] | None + + def __init__(self, data: dict): + super().__init__(data) + + self.meter_id = None + self.last_read = None + self.forecast = None + self.daily_consumption = None + self.monthly_consumption = None + + @property + def meter_count(self) -> int: + return self._data.get(METER_COUNT) + + @property + def serial_number(self) -> str: + return self._data.get(METER_SERIAL_NUMBER) + + @property + def full_address(self) -> str: + return self._data.get(METER_FULL_ADDRESS) + + @staticmethod + def load(data: dict) -> list: + meters = [] + + for item in data: + meter = Meter(item) + meters.append(meter) + + return meters + + @staticmethod + def update_last_read(meter, data: dict): + meter.meter_id = data.get(LAST_READ_METER_ID) + meter.last_read = data.get(LAST_READ_VALUE) + + @staticmethod + def update_daily_consumption(meter, data: dict): + if meter.daily_consumption is None: + meter.daily_consumption = [] + + consumption = Consumption.load(data) + + meter.daily_consumption.append(consumption) + + @staticmethod + def update_monthly_consumption(meter, data: dict): + if meter.monthly_consumption is None: + meter.monthly_consumption = [] + + consumption = Consumption.load(data) + + meter.monthly_consumption.append(consumption) + + @staticmethod + def update_forecast(meter, data: dict): + meter.forecast = data.get(CONSUMPTION_FORECAST_ESTIMATED_CONSUMPTION) + + def as_dict(self) -> dict: + data = { + "meter_count": self.meter_count, + "meter_id": self.meter_id, + "serial_number": self.serial_number, + "full_address": self.full_address, + "last_read": self.last_read, + "forecast": self.forecast, + "daily_consumption": [x.as_dict() for x in self.daily_consumption], + "monthly_consumption": [x.as_dict() for x in self.monthly_consumption], + } + + return data diff --git a/pyrympro/models/profile.py b/pyrympro/models/profile.py new file mode 100644 index 0000000..9b6d767 --- /dev/null +++ b/pyrympro/models/profile.py @@ -0,0 +1,51 @@ +from ..helpers.const import * +from .base import BaseObject + + +class Profile(BaseObject): + def __init__(self, data: dict): + super().__init__(data) + + @property + def first_name(self) -> str: + return self._data.get(ME_FIRST_NAME) + + @property + def last_name(self) -> str: + return self._data.get(ME_LAST_NAME) + + @property + def account_number(self) -> str: + return self._data.get(ME_ACCOUNT_NUMBER) + + @property + def _phone_number_section(self) -> dict: + return self._data.get(ME_PHONE_NUMBER_SECTION, {}) + + @property + def phone_number(self) -> str: + return self._phone_number_section.get(ME_PHONE_NUMBER) + + @property + def additional_phone_number(self) -> str: + return self._phone_number_section.get(ME_ADDITIONAL_PHONE_NUMBER) + + @property + def municipal_id(self) -> str: + return self._data.get(ME_MUNICIPAL_ID) + + @staticmethod + def load(data: dict): + return Profile(data) + + def as_dict(self) -> dict: + data = { + "first_name": self.first_name, + "last_name": self.last_name, + "account_number": self.account_number, + "phone_number": self.phone_number, + "additional_phone_number": self.additional_phone_number, + "municipal_id": self.municipal_id, + } + + return data diff --git a/pyrympro/models/settings.py b/pyrympro/models/settings.py new file mode 100644 index 0000000..53c2643 --- /dev/null +++ b/pyrympro/models/settings.py @@ -0,0 +1,30 @@ +from ..helpers.const import * +from .base import BaseObject + + +class Settings(BaseObject): + def __init__(self, data: dict): + super().__init__(data) + + @property + def alert_type_id(self) -> int: + return self._data.get(SETTINGS_ALERT_TYPE_ID) + + @property + def media_type_id(self) -> str: + return self._data.get(SETTINGS_MEDIA_TYPE_ID) + + @staticmethod + def load(data: dict) -> list: + all_settings = [] + + for item in data: + settings = Settings(item) + all_settings.append(settings) + + return all_settings + + def as_dict(self) -> dict: + data = {"meter_count": self.alert_type_id, "meter_id": self.media_type_id} + + return data diff --git a/pyrympro/rympro.py b/pyrympro/rympro.py index 5a3e7a8..8f48d6e 100644 --- a/pyrympro/rympro.py +++ b/pyrympro/rympro.py @@ -1,90 +1,418 @@ -"""Implementation of a RymPro inteface.""" -import aiohttp -import asyncio +"""Implementation of a RymPro interface.""" +import logging +import sys +from collections.abc import Awaitable, Callable +from datetime import datetime, timedelta + +from aiohttp import ClientResponseError, ClientSession + +from .helpers.const import * +from .helpers.enums import AlertTypes, MediaTypes +from .helpers.exceptions import CannotConnectError, UnauthorizedError +from .models.customer_service import CustomerService +from .models.meter import Meter +from .models.profile import Profile +from .models.settings import Settings + +_LOGGER = logging.getLogger(__name__) -from .const import Endpoint class RymPro: - """A connection to RYM Pro.""" - - def __init__(self, session=None): - """Initialize the object.""" - self._created_session = False - self._session = session - self._access_token = None - - async def close(self): - """Close the connection.""" - if self._created_session and self._session is not None: - await self._session.close() - self._session = None - self._created_session = False - - async def login(self, email, password, device_id): - """Login to RYM Pro.""" - if self._session == None: - self._session = aiohttp.ClientSession() - self._created_session = True - headers = {"Content-Type": "application/json"} - body = {"email": email, "pw": password, "deviceId": device_id} - try: - async with self._session.post( - Endpoint.LOGIN.value, headers=headers, json=body - ) as response: - json = await response.json() - token = json.get("token") - error_code = json.get("code") - error = json.get("error") - - if error_code == 5060: - raise UnauthorizedError(error) - elif token is None or error_code: - raise CannotConnectError(f"code: {error_code}, error: {error}") + """A connection to RYM Pro.""" + + _data: dict + _alert_settings_actions: dict[ + bool, Callable[[str, AlertTypes, list[int]], Awaitable[dict]] + ] + _session: ClientSession | None + + _username: str | None + _password: str | None + _device_id: str | None + _token: str | None + + _today: str | None + _yesterday: str | None + _last_day_of_current_month: str | None + _current_month: str | None + + _profile: Profile | None + + def __init__(self, session: ClientSession | None = None): + """Initialize the object.""" + self._session = ClientSession() if session is None else session + self._username = None + self._password = None + self._device_id = None + self._token = None + + self._today = None + self._yesterday = None + self._last_day_of_current_month = None + self._current_month = None + + self._data = {} + self._alert_settings_actions = { + True: self._put, + False: self._delete, + } + + self._profile = None + + self._data_mapper = { + API_DATA_SECTION_ME: Profile.load, + API_DATA_SECTION_METERS: Meter.load, + API_DATA_SECTION_CUSTOMER_SERVICE: CustomerService.load, + API_DATA_SECTION_SETTINGS: Settings.load, + } + + self._meter_data_mapper = { + API_DATA_SECTION_LAST_READ: Meter.update_last_read, + API_DATA_SECTION_CONSUMPTION_DAILY: Meter.update_daily_consumption, + API_DATA_SECTION_CONSUMPTION_MONTHLY: Meter.update_monthly_consumption, + API_DATA_SECTION_CONSUMPTION_FORECAST: Meter.update_forecast, + } + + @property + def _headers(self): + headers = {API_HEADER_TOKEN: self.token} + + return headers + + @property + def _municipal_id(self) -> str | None: + return None if self.profile is None else self.profile.municipal_id + + @property + def token(self): + return self._token + + @property + def profile(self) -> Profile: + """Holds details of customer account details.""" + profile = self._data.get(API_DATA_SECTION_ME) + + return profile + + @property + def meters(self) -> list[Meter]: + """Holds details of all meters, including their consumption, last read and forecast.""" + meters = self._data.get(API_DATA_SECTION_METERS) + + return meters + + @property + def customer_service(self) -> dict | None: + """Holds details of customer service.""" + customer_service = self._data.get(API_DATA_SECTION_CUSTOMER_SERVICE) + + return customer_service + + @property + def vacations(self) -> dict | None: + """Holds details of vacations.""" + vacations = self._data.get(API_DATA_SECTION_VACATIONS) + + return vacations + + @property + def my_alerts(self) -> dict | None: + """Holds details of alerts.""" + my_alerts = self._data.get(API_DATA_SECTION_MY_ALERTS) + + return my_alerts + + @property + def my_messages(self) -> dict | None: + """Holds details of messages.""" + my_messages = self._data.get(API_DATA_SECTION_MY_MESSAGES) + + return my_messages + + @property + def settings(self) -> dict | None: + """Holds the communication settings.""" + settings = self._data.get(API_DATA_SECTION_SETTINGS) + + return settings + + async def close(self): + """Close the connection.""" + if self._session is not None: + await self._session.close() + + self._session = None + + async def initialize( + self, username: str, password: str, device_id: str | None = DEFAULT_DEVICE_ID + ): + self._username = username + self._password = password + self._device_id = device_id + + await self._login() + + async def update(self): + _LOGGER.debug(f"Updating data for user {self._username}") + + if self.token is None: + await self._login() + + else: + self._set_date() + + if self._municipal_id is None: + await self._load_data(ENDPOINT_DATA_INITIALIZE) + + await self._load_data(ENDPOINT_DATA_UPDATE) + + for meter in self.meters: + meter_count = str(meter.meter_count) + + await self._load_data(ENDPOINT_DATA_UPDATE_PER_METER, meter_count) + + async def set_alert_settings( + self, alert_type: AlertTypes, media_type: MediaTypes, enabled: bool + ): + _LOGGER.info(f"Updating alert {alert_type} on media {media_type} to {enabled}") + + action = self._alert_settings_actions[enabled] + data = [int(media_type.value)] + + await action(ENDPOINT_MY_ALERTS_SETTINGS_UPDATE, alert_type, data) + + await self._load_data(ENDPOINT_DATA_RELOAD) + + def _set_date(self): + today = datetime.now() + today_iso = today.strftime(FORMAT_DATE_ISO) + + if self._today != today_iso: + yesterday = today - timedelta(days=1) + + year = today.year if today.month <= 11 else today.year + 1 + month = today.month + 1 if today.month <= 11 else 1 + + next_month_date = datetime(year=year, month=month, day=1) + last_day_of_current_month = next_month_date - timedelta(days=1) + + self._today = today_iso + self._yesterday = yesterday.strftime(FORMAT_DATE_ISO) + self._current_month = today.strftime(FORMAT_DATE_YEAR_MONTH) + self._last_day_of_current_month = last_day_of_current_month.strftime( + FORMAT_DATE_ISO + ) + + async def _login(self): + """Login to RYM Pro.""" + _LOGGER.debug(f"Updating data for user {self._username}") + + self._token = None + + login_data = { + LOGIN_EMAIL: self._username, + LOGIN_PASSWORD: self._password, + LOGIN_DEVICE_ID: self._device_id, + } + + result = await self._post(ENDPOINT_LOGIN, login_data) + + if result is not None: + token = result.get(API_DATA_TOKEN) + error_code = result.get(API_DATA_ERROR_CODE) + error = result.get(API_DATA_ERROR_REASON) + + if error_code == 5060: + raise UnauthorizedError(error) + + elif token is None or error_code: + raise CannotConnectError(f"code: {error_code}, error: {error}") + + self._token = token + + async def _load_data(self, endpoints: dict, meter_count: str | None = None): + for endpoint_key in endpoints: + if self.token is not None: + _LOGGER.debug(f"Reloading data for {endpoint_key}") + + endpoint = endpoints.get(endpoint_key) + + data = await self._get(endpoint, meter_count) + + if data is not None: + if endpoint_key in self._data_mapper: + self._handle_generic_data_mapper(endpoint_key, data) + + elif endpoint_key in self._meter_data_mapper: + self._handle_meter_data_mapper(endpoint_key, meter_count, data) + + else: + _LOGGER.debug(f"Ignoring: {endpoint_key}") + + def _handle_generic_data_mapper(self, endpoint_key, data): + mapper = self._data_mapper[endpoint_key] + + mapper_value = mapper(data) + + self._data[endpoint_key] = mapper_value + + def _handle_meter_data_mapper( + self, endpoint_key: str, meter_count: str, data: dict | list + ): + current_meter: Meter | None = None + for meter in self.meters: + + if meter_count == str(meter.meter_count): + current_meter = meter + break + + mapper = self._meter_data_mapper[endpoint_key] + + if isinstance(data, list): + for data_item in data: + data_item_meter = str(data_item.get(METER_COUNT)) + + if data_item_meter == meter_count: + mapper(current_meter, data_item) + else: - self._access_token = token - except aiohttp.client_exceptions.ClientConnectorError as e: - raise CannotConnectError from e - - async def account_info(self): - """Get the account information.""" - return await self._get(Endpoint.ACCOUNT_INFO) - - async def last_read(self): - """Get the latest meter reads.""" - return await self._get(Endpoint.LAST_READ) - - async def _get(self, endpoint, params=None): - if not self._access_token: - raise OperationError("Please login") - headers = {"Content-Type": "application/json", "x-access-token": self._access_token} - try: - async with self._session.get(endpoint.value, headers=headers) as response: - if response.status == 200: - return await response.json() + mapper(current_meter, data) + + async def _get(self, endpoint: str, meter_count: str | None = None) -> dict | None: + result = None + + try: + url = self._build_endpoint(endpoint, meter_count) + + async with self._session.get(url, headers=self._headers) as response: + response.raise_for_status() + + result = await response.json() + + except ClientResponseError as crex: + self._handle_client_error(crex, endpoint) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to get JSON from {endpoint}, Error: {ex}, Line: {line_number}" + ) + + return result + + async def _post(self, endpoint: str, request_data: dict) -> dict | None: + result = None + + try: + url = self._build_endpoint(endpoint) + + _LOGGER.debug(f"POST {url}") + + async with self._session.post( + url, json=request_data, ssl=False + ) as response: + result = await response.json() + + response.raise_for_status() + + except ClientResponseError as crex: + self._handle_client_error(crex, endpoint) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to post JSON to {endpoint}, Error: {ex}, Line: {line_number}" + ) + + return result + + async def _put(self, endpoint: str, alert_type: AlertTypes, data: list[int]): + result = None + + try: + url = self._build_endpoint(endpoint, alert_type=alert_type) + + async with self._session.put( + url, headers=self._headers, json=data + ) as response: + _LOGGER.debug(f"Status of {url}: {response.status}") + + response.raise_for_status() + + result = await response.json() + + except ClientResponseError as crex: + self._handle_client_error(crex, endpoint) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to put data to {endpoint}, Error: {ex}, Line: {line_number}" + ) + + return result + + async def _delete(self, endpoint: str, alert_type: AlertTypes, data: list[int]): + result = None + + try: + url = self._build_endpoint(endpoint, alert_type=alert_type) + + async with self._session.delete( + url, headers=self._headers, json=data, ssl=False + ) as response: + _LOGGER.debug(f"Status of {url}: {response.status}") + + response.raise_for_status() + + result = await response.json() + + except ClientResponseError as crex: + self._handle_client_error(crex, endpoint) + + except Exception as ex: + exc_type, exc_obj, tb = sys.exc_info() + line_number = tb.tb_lineno + + _LOGGER.error( + f"Failed to delete data from {endpoint}, Error: {ex}, Line: {line_number}" + ) + + return result + + def _handle_client_error(self, crex: ClientResponseError, endpoint: str): + if crex.status == 401: + _LOGGER.error("Token expired, please try to re-login") + self._data = {} + else: - raise OperationError(response) - except (asyncio.TimeoutError, aiohttp.ClientError) as error: - raise OperationError() from error - - # async def _post(self, endpoint, json_payload=None): - # if not self._access_token: - # raise OperationError("Please login") - # headers = {"Content-Type": "application/json", "x-access-token": self._access_token} - # try: - # async with self._session.post(endpoint.value, headers=headers, json=json_payload) as response: - # if response.status == 200: - # return await response.json() - # else: - # raise OperationError(response) - # except (asyncio.TimeoutError, aiohttp.ClientError) as error: - # raise OperationError() from error - - -class CannotConnectError(Exception): - """Exception to indicate an error in connection.""" - -class UnauthorizedError(Exception): - """Exception to indicate an error in authorization.""" - -class OperationError(Exception): - """Exception to indicate an error in operation.""" + _LOGGER.error( + f"Failed to delete data from {endpoint}, HTTP Status: {crex.message} ({crex.status})" + ) + + def _build_endpoint( + self, + endpoint: str, + meter_count: str | None = None, + alert_type: AlertTypes | None = None, + ): + + data = { + ENDPOINT_PARAMETER_METER_ID: meter_count, + ENDPOINT_PARAMETER_YESTERDAY: self._yesterday, + ENDPOINT_PARAMETER_TODAY: self._today, + ENDPOINT_PARAMETER_LAST_DAY_MONTH: self._last_day_of_current_month, + ENDPOINT_PARAMETER_MUNICIPALITY_ID: self._municipal_id, + ENDPOINT_PARAMETER_CURRENT_MONTH: self._current_month, + ENDPOINT_PARAMETER_ALERT_TYPE: alert_type.value, + } + + url = endpoint.format(**data) + + return url diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..791c5e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +setuptools~=60.2.0 +aiohttp \ No newline at end of file diff --git a/setup.py b/setup.py index 1e812a1..f426321 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- # Note: To use the 'upload' functionality of this file, you must: # $ pipenv install twine --dev @@ -9,18 +8,20 @@ import sys from shutil import rmtree -from setuptools import find_packages, setup, Command +from setuptools import Command, find_packages, setup # Package meta-data. -NAME = 'pyrympro' -DESCRIPTION = 'A python library to communitcate with Read Your Meter Pro (https://rym-pro.com/).' -URL = 'https://github.com/OnFreund/pyrympro' -EMAIL = 'onfreund@gmail.com' -AUTHOR = 'On Freund' -REQUIRES_PYTHON = '>=3.7.0' -VERSION = '0.0.1' +NAME = "pyrympro" +DESCRIPTION = ( + "A python library to communicate with Read Your Meter Pro (https://rym-pro.com/)." +) +URL = "https://github.com/OnFreund/pyrympro" +EMAIL = "onfreund@gmail.com" +AUTHOR = "On Freund" +REQUIRES_PYTHON = ">=3.7.0" +VERSION = "0.0.1" -REQUIRED = ['aiohttp'] +REQUIRED = ["aiohttp"] EXTRAS = {} @@ -30,8 +31,8 @@ # Import the README and use it as the long-description. # Note: this will only work if 'README.md' is present in your MANIFEST.in file! try: - with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = '\n' + f.read() + with open(os.path.join(here, "README.md"), encoding="utf-8") as f: + long_description = "\n" + f.read() except FileNotFoundError: long_description = DESCRIPTION @@ -39,22 +40,22 @@ about = {} if not VERSION: project_slug = NAME.lower().replace("-", "_").replace(" ", "_") - with open(os.path.join(here, project_slug, '__version__.py')) as f: + with open(os.path.join(here, project_slug, "__version__.py")) as f: exec(f.read(), about) else: - about['__version__'] = VERSION + about["__version__"] = VERSION class UploadCommand(Command): """Support setup.py upload.""" - description = 'Build and publish the package.' + description = "Build and publish the package." user_options = [] @staticmethod def status(s): """Prints things in bold.""" - print('\033[1m{0}\033[0m'.format(s)) + print(f"\033[1m{s}\033[0m") def initialize_options(self): pass @@ -64,20 +65,20 @@ def finalize_options(self): def run(self): try: - self.status('Removing previous builds…') - rmtree(os.path.join(here, 'dist')) + self.status("Removing previous builds…") + rmtree(os.path.join(here, "dist")) except OSError: pass - self.status('Building Source and Wheel distribution…') - os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable)) + self.status("Building Source and Wheel distribution…") + os.system(f"{sys.executable} setup.py sdist bdist_wheel") - self.status('Uploading the package to PyPI via Twine…') - os.system('twine upload dist/*') + self.status("Uploading the package to PyPI via Twine…") + os.system("twine upload dist/*") - self.status('Pushing git tags…') - os.system('git tag v{0}'.format(about['__version__'])) - os.system('git push --tags') + self.status("Pushing git tags…") + os.system("git tag v{}".format(about["__version__"])) + os.system("git push --tags") sys.exit() @@ -85,10 +86,10 @@ def run(self): # Where the magic happens: setup( name=NAME, - version=about['__version__'], + version=about["__version__"], description=DESCRIPTION, long_description=long_description, - long_description_content_type='text/markdown', + long_description_content_type="text/markdown", author=AUTHOR, author_email=EMAIL, python_requires=REQUIRES_PYTHON, @@ -96,26 +97,25 @@ def run(self): packages=find_packages(exclude=["tests", "*.tests", "*.tests.*", "tests.*"]), # If your package is a single module, use this instead of 'packages': # py_modules=['mypackage'], - # entry_points={ # 'console_scripts': ['mycli=mymodule:cli'], # }, install_requires=REQUIRED, extras_require=EXTRAS, include_package_data=True, - license='MIT', + license="MIT", classifiers=[ # Trove classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: Implementation :: CPython', - 'Programming Language :: Python :: Implementation :: PyPy' + "License :: OSI Approved :: MIT License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", ], # $ setup.py publish support. cmdclass={ - 'upload': UploadCommand, + "upload": UploadCommand, }, ) diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/client.py b/test/client.py new file mode 100644 index 0000000..4a1e1d9 --- /dev/null +++ b/test/client.py @@ -0,0 +1,87 @@ +import asyncio +import logging +import os +import sys + +from ..pyrympro.helpers.enums import AlertTypes, MediaTypes +from ..pyrympro.rympro import RymPro + +DEBUG = str(os.environ.get("DEBUG", False)).lower() == str(True).lower() + +log_level = logging.DEBUG if DEBUG else logging.INFO + +root = logging.getLogger() +root.setLevel(log_level) + +stream_handler = logging.StreamHandler(sys.stdout) +stream_handler.setLevel(log_level) +formatter = logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") +stream_handler.setFormatter(formatter) +root.addHandler(stream_handler) + +_LOGGER = logging.getLogger(__name__) + + +class Test: + def __init__(self): + self._username = os.environ.get("USERNAME") + self._password = os.environ.get("PASSWORD") + + async def login(self): + client = RymPro() + + try: + await client.initialize(self._username, self._password) + + await client.close() + + except Exception as ex: + _LOGGER.error(f"Failed to login client, Error: {ex}") + await client.close() + + async def load_data(self): + client = RymPro() + + try: + await client.initialize(self._username, self._password) + + await client.update() + + _LOGGER.info(f"profile: {client.profile}") + _LOGGER.info(f"meters: {client.meters}") + _LOGGER.info(f"customer_service: {client.customer_service}") + _LOGGER.info(f"settings: {client.settings}") + + await client.close() + + except Exception as ex: + _LOGGER.error(f"Failed to load client, Error: {ex}") + await client.close() + + async def set_leak_alert_all_channels(self): + client = RymPro() + + try: + await client.initialize(self._username, self._password) + + await client.update() + + _LOGGER.info(f"settings: {client.settings}") + + await client.set_alert_settings(AlertTypes.LEAK, MediaTypes.ALL, True) + + _LOGGER.info(f"settings: {client.settings}") + + await client.close() + + except Exception as ex: + _LOGGER.error(f"Failed to load client, Error: {ex}") + await client.close() + + +test = Test() +loop = asyncio.new_event_loop() + +loop.run_until_complete(test.login()) +loop.run_until_complete(test.load_data()) +loop.run_until_complete(test.set_leak_alert_all_channels())