diff --git a/README.md b/README.md index 6430a19..f9bb042 100644 --- a/README.md +++ b/README.md @@ -17,22 +17,23 @@ $ pip3 install elicznik With the package installed readings can be retrieved by simply running the `elicznik` command: ``` -usage: elicznik [-h] [--format {raw,table,csv}] username password [start date] [end date] +usage: elicznik [-h] [--format {table,csv}] [--api {chart,csv}] username password [start_date] [end_date] positional arguments: username tauron-dystrybucja.pl user name password tauron-dystrybucja.pl password - start date Start date of date range to be retrieved, in ISO8601 format. If the end date is omitted, it's the only date for which + start_date Start date of date range to be retrieved, in ISO8601 format. If the end date is omitted, it's the only date for which measurements are retrieved. - end date End date of date range to be retrieved, inclusive, in ISO8601 format. Can be omitted to only retrieve a single day's + end_date End date of date range to be retrieved, inclusive, in ISO8601 format. Can be omitted to only retrieve a single day's measurements. -optional arguments: +options: -h, --help show this help message and exit - --format {raw,table,csv} - Specify the output format + --format {table,csv} Specify the output format + --api {chart,csv} Specify which Tauron API to use to get the measurements. ``` + ### Example ``` @@ -92,6 +93,32 @@ with elicznik.ELicznik("freddy@example.com", "secretpassword") as m: ``` +## Notes on APIs and the `--api` command line switch + +Tauron exposes two API endpoints for retrieving meter readings -- one for downloading CSV (and XLS) data, +the other is a back-end supporting the charts in the Web UI. In theory, both endpoints are equivalent. +They can be used to get exactly the same data. In practice, the endpoint for downloading CSV data seems +more stable -- in contrast to the chart one, which changed a few times in the past. The CSV endpoint is +also more robust and allows downloading more data with fewer requests. + +This project supports fetching data from both. CSV is the default and recommended one, but it's possible +to switch to the chart API endpoint in case of problems. + +This can be done by adding `--api=chart` on the command line: +``` +$ elicznik --api=chart freddy@example.com secretpassword 2021-07-10 +``` + +Both APIs can also be used explicitly from code. The `elicznik` module defines two classes for that `ELicznikChart` +and `ELicznikCSV`. `ELicznik` is just an alias for `ELicznikCSV`: +``` +import elicznik + +with elicznik.ELicznikChart("freddy@example.com", "secretpassword") as m: + ... +``` + + ## TODO & bugs * Add support for accounts with multiple meters diff --git a/src/elicznik/__init__.py b/src/elicznik/__init__.py index 55f6f10..3b9ed81 100644 --- a/src/elicznik/__init__.py +++ b/src/elicznik/__init__.py @@ -1 +1 @@ -from .elicznik import ELicznik +from .elicznik import ELicznik, ELicznikChart, ELicznikCSV diff --git a/src/elicznik/__main__.py b/src/elicznik/__main__.py index 2c4fd5d..fa504ad 100644 --- a/src/elicznik/__main__.py +++ b/src/elicznik/__main__.py @@ -5,7 +5,7 @@ import sys import tabulate -from .elicznik import ELicznik +from .elicznik import ELicznikChart, ELicznikCSV def main(): @@ -16,6 +16,12 @@ def main(): default="table", help="Specify the output format", ) + parser.add_argument( + "--api", + choices=["chart", "csv"], + default="csv", + help="Specify which Tauron API to use to get the measurements. " + ) parser.add_argument("username", help="tauron-dystrybucja.pl user name") parser.add_argument("password", help="tauron-dystrybucja.pl password") parser.add_argument( @@ -39,7 +45,9 @@ def main(): args = parser.parse_args() - with ELicznik(args.username, args.password) as elicznik: + elicznik_class = ELicznikCSV if args.api == "csv" else ELicznikChart + + with elicznik_class(args.username, args.password) as elicznik: result = elicznik.get_readings(args.start_date, args.end_date) if args.format == "table": diff --git a/src/elicznik/elicznik.py b/src/elicznik/elicznik.py index e59a199..d548fae 100755 --- a/src/elicznik/elicznik.py +++ b/src/elicznik/elicznik.py @@ -5,10 +5,8 @@ import datetime from .session import Session - -class ELicznik: +class ELicznikBase: LOGIN_URL = "https://logowanie.tauron-dystrybucja.pl/login" - DATA_URL = "https://elicznik.tauron-dystrybucja.pl/energia/do/dane" def __init__(self, username, password): self.username = username @@ -33,7 +31,48 @@ class ELicznik: def __exit__(self, exc_type, exc_val, exc_tb): pass - def get_raw_data(self, start_date, end_date=None): + +class ELicznikChart(ELicznikBase): + CHART_URL = "https://elicznik.tauron-dystrybucja.pl/energia/api" + + def _get_raw_daily_readings(self, type_, date): + data = self.session.post( + self.CHART_URL, + data={ + "type": type_, + "from": date.strftime("%d.%m.%Y"), + "to": date.strftime("%d.%m.%Y"), + "profile": "full time", + }, + ).json().get("data", {}).get("values", []) + + return ((datetime.datetime.combine(date, datetime.time(h)), value) for h, value in enumerate(data)) + + def _get_raw_readings(self, type_, start_date, end_date=None): + end_date = end_date or start_date + while start_date <= end_date: + yield from self._get_raw_daily_readings(type_, start_date) + start_date += datetime.timedelta(days=1) + + def get_readings_production(self, start_date, end_date=None): + return dict(self._get_raw_readings("oze", start_date, end_date)) + + def get_readings_consumption(self, start_date, end_date=None): + return dict(self._get_raw_readings("consum", start_date, end_date)) + + def get_readings(self, start_date, end_date=None): + consumed = self.get_readings_consumption(start_date, end_date) + produced = self.get_readings_production(start_date, end_date) + return sorted( + (timestamp, consumed.get(timestamp), produced.get(timestamp)) + for timestamp in set(consumed) | set(produced) + ) + + +class ELicznikCSV(ELicznikBase): + DATA_URL = "https://elicznik.tauron-dystrybucja.pl/energia/do/dane" + + def _get_raw_data(self, start_date, end_date=None): end_date = end_date or start_date return self.session.post( self.DATA_URL, @@ -48,20 +87,18 @@ class ELicznik: ).text.splitlines() @staticmethod - def parse_timestamp(timespec): + def _parse_timestamp(timespec): date, time = timespec.split(None, 1) hour = int(time.split(":")[0]) - 1 - return datetime.datetime.strptime(date, "%Y-%m-%d") + datetime.timedelta( - hours=hour - ) + return datetime.datetime.strptime(date, "%Y-%m-%d") + datetime.timedelta(hours=hour) def get_readings(self, start_date, end_date=None): end_date = end_date or start_date - data = self.get_raw_data(start_date, end_date) + data = self._get_raw_data(start_date, end_date) records = [ { - "timestamp": self.parse_timestamp(rec["Data"]), + "timestamp": self._parse_timestamp(rec["Data"]), "value": float(rec[" Wartość kWh"].replace(",", ".")), "type": rec["Rodzaj"], } @@ -75,12 +112,14 @@ class ELicznik: ] prod = { - rec["timestamp"]: rec["value"] for rec in records if rec["type"] == "pobór" + rec["timestamp"]: rec["value"] + for rec in records + if rec["type"] == "oddanie" } cons = { rec["timestamp"]: rec["value"] for rec in records - if rec["type"] == "oddanie" + if rec["type"] == "pobór" } # TODO @@ -90,3 +129,5 @@ class ELicznik: (timestamp, cons.get(timestamp), prod.get(timestamp)) for timestamp in set(cons) | set(prod) ) + +ELicznik = ELicznikCSV