ev-charging-status 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ev_charging_status-0.1.0/LICENSE +21 -0
- ev_charging_status-0.1.0/PKG-INFO +133 -0
- ev_charging_status-0.1.0/README.md +113 -0
- ev_charging_status-0.1.0/pyproject.toml +34 -0
- ev_charging_status-0.1.0/setup.cfg +4 -0
- ev_charging_status-0.1.0/src/ev_charging_status/__init__.py +3 -0
- ev_charging_status-0.1.0/src/ev_charging_status/__main__.py +4 -0
- ev_charging_status-0.1.0/src/ev_charging_status/api/__init__.py +9 -0
- ev_charging_status-0.1.0/src/ev_charging_status/api/base.py +50 -0
- ev_charging_status-0.1.0/src/ev_charging_status/api/client.py +25 -0
- ev_charging_status-0.1.0/src/ev_charging_status/api/sema.py +45 -0
- ev_charging_status-0.1.0/src/ev_charging_status/api/shellrecharge.py +37 -0
- ev_charging_status-0.1.0/src/ev_charging_status/cli.py +118 -0
- ev_charging_status-0.1.0/src/ev_charging_status/config.py +158 -0
- ev_charging_status-0.1.0/src/ev_charging_status/db.py +443 -0
- ev_charging_status-0.1.0/src/ev_charging_status/models.py +25 -0
- ev_charging_status-0.1.0/src/ev_charging_status/notifications.py +17 -0
- ev_charging_status-0.1.0/src/ev_charging_status/parsers.py +142 -0
- ev_charging_status-0.1.0/src/ev_charging_status/seeds.py +40 -0
- ev_charging_status-0.1.0/src/ev_charging_status/service.py +148 -0
- ev_charging_status-0.1.0/src/ev_charging_status/sheet_sync.py +139 -0
- ev_charging_status-0.1.0/src/ev_charging_status.egg-info/PKG-INFO +133 -0
- ev_charging_status-0.1.0/src/ev_charging_status.egg-info/SOURCES.txt +25 -0
- ev_charging_status-0.1.0/src/ev_charging_status.egg-info/dependency_links.txt +1 -0
- ev_charging_status-0.1.0/src/ev_charging_status.egg-info/entry_points.txt +3 -0
- ev_charging_status-0.1.0/src/ev_charging_status.egg-info/requires.txt +3 -0
- ev_charging_status-0.1.0/src/ev_charging_status.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 guocity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ev-charging-status
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Poll EV charging APIs and write station status events to TimescaleDB/PostgreSQL.
|
|
5
|
+
Author: guocity
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/guocity/ev_charging_status
|
|
8
|
+
Project-URL: Repository, https://github.com/guocity/ev_charging_status
|
|
9
|
+
Keywords: ev,charging,timescaledb,postgresql,polling
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Topic :: Utilities
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: psycopg[binary]<4,>=3.1
|
|
17
|
+
Requires-Dist: python-dotenv<2,>=1.0
|
|
18
|
+
Requires-Dist: requests<3,>=2.31
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# EV Charging Status
|
|
22
|
+
|
|
23
|
+
Python app that polls Shell Recharge/Greenlots and SEMA/Blink charger APIs and writes station status events to TimescaleDB/PostgreSQL.
|
|
24
|
+
|
|
25
|
+
## Setup
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv sync
|
|
29
|
+
cp .env.example .env
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Put real DB credentials and API tokens in `.env`. `.env` is ignored by git.
|
|
33
|
+
|
|
34
|
+
Run commands through `uv run`, for example:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
uv run ev-status --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Database
|
|
41
|
+
|
|
42
|
+
Initialize schema and seed default sites/statuses/stations:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv run ev-status init-db
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Only rows with `sites.enabled = true` are polled.
|
|
49
|
+
|
|
50
|
+
## Run
|
|
51
|
+
|
|
52
|
+
First run, or after recreating/changing the database schema, initialize the database once:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv run ev-status init-db
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Routine one-time poll:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run ev-status poll
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
You can combine initialization and polling for a safe first run:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv run ev-status poll --init-db
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`--init-db` is not required for every poll. It only ensures the database, tables, indexes, default sites, default statuses, default stations, and TimescaleDB setup exist before polling.
|
|
71
|
+
|
|
72
|
+
Continuous 5-minute loop (or `POLL_INTERVAL_SECONDS`):
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
uv run ev-status run-loop
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For a safe first continuous run:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run ev-status run-loop --init-db
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Sync historical rows from the public Google Sheet CSV into `station_status_events`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv run ev-status sync-sheet
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Set `SYNC_SHEET_ID` in `.env`. By default the command reads `Sheet1` using:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
https://docs.google.com/spreadsheets/d/<SYNC_SHEET_ID>/gviz/tq?tqx=out:csv&sheet=Sheet1
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The sheet must include columns like `current_time`, `station_name`, and `status`. The sync command inserts only rows that do not already exist with the same `(time, station_id, status)`. Rows for station IDs not present in the `stations` table are ignored.
|
|
97
|
+
|
|
98
|
+
You can override the tab or full CSV URL:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv run ev-status sync-sheet --sheet-name Sheet1
|
|
102
|
+
uv run ev-status sync-sheet --url 'https://docs.google.com/spreadsheets/d/{SHEETID}/gviz/tq?tqx=out:csv&sheet=Sheet1'
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Optimize `station_status_events` storage:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run ev-status optimize-db
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This rounds existing event timestamps to second precision, drops the redundant `(time DESC, station_id)` index, and compresses TimescaleDB chunks older than 7 days. Timestamp precision is also second-level for new polls.
|
|
112
|
+
|
|
113
|
+
You can change the compression cutoff:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
uv run ev-status optimize-db --compress-older-than '1 day'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Show total database size:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
uv run ev-status db-size
|
|
123
|
+
# include table/relation sizes
|
|
124
|
+
uv run ev-status db-size --tables
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Tables
|
|
128
|
+
|
|
129
|
+
- `sites`: site enable/disable control
|
|
130
|
+
- `status_lookup`: status IDs; new normalized statuses are inserted automatically
|
|
131
|
+
- `stations`: station metadata inserted once on first sight
|
|
132
|
+
- `station_status_events`: Timescale hypertable with exactly `(time, station_id, status)`
|
|
133
|
+
- `site_latest_snapshot`: latest raw JSONB API response per site
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# EV Charging Status
|
|
2
|
+
|
|
3
|
+
Python app that polls Shell Recharge/Greenlots and SEMA/Blink charger APIs and writes station status events to TimescaleDB/PostgreSQL.
|
|
4
|
+
|
|
5
|
+
## Setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
uv sync
|
|
9
|
+
cp .env.example .env
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Put real DB credentials and API tokens in `.env`. `.env` is ignored by git.
|
|
13
|
+
|
|
14
|
+
Run commands through `uv run`, for example:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
uv run ev-status --help
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Database
|
|
21
|
+
|
|
22
|
+
Initialize schema and seed default sites/statuses/stations:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
uv run ev-status init-db
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Only rows with `sites.enabled = true` are polled.
|
|
29
|
+
|
|
30
|
+
## Run
|
|
31
|
+
|
|
32
|
+
First run, or after recreating/changing the database schema, initialize the database once:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run ev-status init-db
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Routine one-time poll:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv run ev-status poll
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
You can combine initialization and polling for a safe first run:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv run ev-status poll --init-db
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`--init-db` is not required for every poll. It only ensures the database, tables, indexes, default sites, default statuses, default stations, and TimescaleDB setup exist before polling.
|
|
51
|
+
|
|
52
|
+
Continuous 5-minute loop (or `POLL_INTERVAL_SECONDS`):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
uv run ev-status run-loop
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
For a safe first continuous run:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
uv run ev-status run-loop --init-db
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Sync historical rows from the public Google Sheet CSV into `station_status_events`:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv run ev-status sync-sheet
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Set `SYNC_SHEET_ID` in `.env`. By default the command reads `Sheet1` using:
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
https://docs.google.com/spreadsheets/d/<SYNC_SHEET_ID>/gviz/tq?tqx=out:csv&sheet=Sheet1
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The sheet must include columns like `current_time`, `station_name`, and `status`. The sync command inserts only rows that do not already exist with the same `(time, station_id, status)`. Rows for station IDs not present in the `stations` table are ignored.
|
|
77
|
+
|
|
78
|
+
You can override the tab or full CSV URL:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
uv run ev-status sync-sheet --sheet-name Sheet1
|
|
82
|
+
uv run ev-status sync-sheet --url 'https://docs.google.com/spreadsheets/d/{SHEETID}/gviz/tq?tqx=out:csv&sheet=Sheet1'
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Optimize `station_status_events` storage:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv run ev-status optimize-db
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
This rounds existing event timestamps to second precision, drops the redundant `(time DESC, station_id)` index, and compresses TimescaleDB chunks older than 7 days. Timestamp precision is also second-level for new polls.
|
|
92
|
+
|
|
93
|
+
You can change the compression cutoff:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv run ev-status optimize-db --compress-older-than '1 day'
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Show total database size:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
uv run ev-status db-size
|
|
103
|
+
# include table/relation sizes
|
|
104
|
+
uv run ev-status db-size --tables
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Tables
|
|
108
|
+
|
|
109
|
+
- `sites`: site enable/disable control
|
|
110
|
+
- `status_lookup`: status IDs; new normalized statuses are inserted automatically
|
|
111
|
+
- `stations`: station metadata inserted once on first sight
|
|
112
|
+
- `station_status_events`: Timescale hypertable with exactly `(time, station_id, status)`
|
|
113
|
+
- `site_latest_snapshot`: latest raw JSONB API response per site
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ev-charging-status"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Poll EV charging APIs and write station status events to TimescaleDB/PostgreSQL."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "guocity" }]
|
|
13
|
+
keywords = ["ev", "charging", "timescaledb", "postgresql", "polling"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Operating System :: OS Independent",
|
|
17
|
+
"Topic :: Utilities",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"psycopg[binary]>=3.1,<4",
|
|
21
|
+
"python-dotenv>=1.0,<2",
|
|
22
|
+
"requests>=2.31,<3",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/guocity/ev_charging_status"
|
|
27
|
+
Repository = "https://github.com/guocity/ev_charging_status"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
ev-status = "ev_charging_status.cli:main"
|
|
31
|
+
ev-charging-status = "ev_charging_status.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from collections import deque
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
|
|
10
|
+
from ..config import Settings
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BaseApiEndpoint:
|
|
16
|
+
"""Shared HTTP, retry, and rate-limit helpers for API endpoint clients."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, settings: Settings, session: Optional[requests.Session] = None) -> None:
|
|
19
|
+
self.settings = settings
|
|
20
|
+
self.session = session or requests.Session()
|
|
21
|
+
self._calls: deque[float] = deque()
|
|
22
|
+
|
|
23
|
+
def _wait_for_rate_limit(self, *, max_calls: int, period_seconds: int) -> None:
|
|
24
|
+
now = time.monotonic()
|
|
25
|
+
while self._calls and now - self._calls[0] >= period_seconds:
|
|
26
|
+
self._calls.popleft()
|
|
27
|
+
if len(self._calls) >= max_calls:
|
|
28
|
+
sleep_for = period_seconds - (now - self._calls[0])
|
|
29
|
+
if sleep_for > 0:
|
|
30
|
+
logger.info("API rate limit reached; sleeping %.2f seconds", sleep_for)
|
|
31
|
+
time.sleep(sleep_for)
|
|
32
|
+
now = time.monotonic()
|
|
33
|
+
while self._calls and now - self._calls[0] >= period_seconds:
|
|
34
|
+
self._calls.popleft()
|
|
35
|
+
self._calls.append(time.monotonic())
|
|
36
|
+
|
|
37
|
+
def _get_json(self, url: str, headers: Optional[dict[str, str]] = None) -> Optional[Any]:
|
|
38
|
+
for attempt in range(1, 4):
|
|
39
|
+
try:
|
|
40
|
+
response = self.session.get(url, headers=headers, timeout=self.settings.request_timeout)
|
|
41
|
+
response.raise_for_status()
|
|
42
|
+
return response.json()
|
|
43
|
+
except (requests.RequestException, ValueError) as exc:
|
|
44
|
+
if attempt < 3:
|
|
45
|
+
sleep_for = 2 ** (attempt - 1)
|
|
46
|
+
logger.warning("HTTP/JSON error for %s (attempt %s/3): %s", url, attempt, exc)
|
|
47
|
+
time.sleep(sleep_for)
|
|
48
|
+
else:
|
|
49
|
+
logger.warning("HTTP/JSON error for %s: %s", url, exc)
|
|
50
|
+
return None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ..config import Settings
|
|
8
|
+
from .sema import SemaLocationEndpoint
|
|
9
|
+
from .shellrecharge import ShellRechargeSiteSearchEndpoint
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ChargingApiClient:
|
|
13
|
+
"""Convenience facade over individual endpoint clients."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, settings: Settings, session: Optional[requests.Session] = None) -> None:
|
|
16
|
+
self.settings = settings
|
|
17
|
+
self.session = session or requests.Session()
|
|
18
|
+
self.shellrecharge_site_search = ShellRechargeSiteSearchEndpoint(settings, session=self.session)
|
|
19
|
+
self.sema_location = SemaLocationEndpoint(settings, session=self.session)
|
|
20
|
+
|
|
21
|
+
def fetch_shellrecharge_by_siteid(self, site_id: str) -> Optional[Any]:
|
|
22
|
+
return self.shellrecharge_site_search.fetch(site_id)
|
|
23
|
+
|
|
24
|
+
def fetch_sema_station_json(self) -> Optional[Any]:
|
|
25
|
+
return self.sema_location.fetch()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ..config import Settings
|
|
8
|
+
from .base import BaseApiEndpoint
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SemaLocationEndpoint(BaseApiEndpoint):
|
|
12
|
+
"""SEMA/Blink location endpoint containing charger statuses."""
|
|
13
|
+
|
|
14
|
+
rate_limit_calls = 2
|
|
15
|
+
rate_limit_period_seconds = 10
|
|
16
|
+
|
|
17
|
+
def __init__(self, settings: Settings, session: Optional[requests.Session] = None) -> None:
|
|
18
|
+
super().__init__(settings, session=session)
|
|
19
|
+
|
|
20
|
+
def fetch(self) -> Optional[Any]:
|
|
21
|
+
self._wait_for_rate_limit(
|
|
22
|
+
max_calls=self.rate_limit_calls,
|
|
23
|
+
period_seconds=self.rate_limit_period_seconds,
|
|
24
|
+
)
|
|
25
|
+
url = self.settings.sema_api_url or (
|
|
26
|
+
"https://apigw.blinknetwork.com/v1/locations/"
|
|
27
|
+
f"{self.settings.sema_location_id}?isPrivateStationReq=false"
|
|
28
|
+
)
|
|
29
|
+
headers = {
|
|
30
|
+
"accept": "application/json",
|
|
31
|
+
"x-ios-bundle-identifier": "com.blinknetwork.mobile2",
|
|
32
|
+
"priority": "u=3, i",
|
|
33
|
+
"accept-language": "en-US,en;q=0.9",
|
|
34
|
+
"os-type": "ios",
|
|
35
|
+
"device-os-version": "19.0",
|
|
36
|
+
"user-agent": "BlinkMobile/1034 CFNetwork Darwin",
|
|
37
|
+
"x-app-version": "3.1.25",
|
|
38
|
+
"device-locale": "en-US",
|
|
39
|
+
}
|
|
40
|
+
if self.settings.sema_authorization:
|
|
41
|
+
auth = self.settings.sema_authorization.strip()
|
|
42
|
+
headers["authorization"] = auth if auth.lower().startswith("bearer ") else f"Bearer {auth}"
|
|
43
|
+
if self.settings.sema_firebase_appcheck:
|
|
44
|
+
headers["x-firebase-appcheck"] = self.settings.sema_firebase_appcheck
|
|
45
|
+
return self._get_json(url, headers=headers)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ..config import Settings
|
|
8
|
+
from .base import BaseApiEndpoint
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ShellRechargeSiteSearchEndpoint(BaseApiEndpoint):
|
|
12
|
+
"""Shell Recharge / Greenlots site search endpoint."""
|
|
13
|
+
|
|
14
|
+
rate_limit_calls = 4
|
|
15
|
+
rate_limit_period_seconds = 10
|
|
16
|
+
|
|
17
|
+
def __init__(self, settings: Settings, session: Optional[requests.Session] = None) -> None:
|
|
18
|
+
super().__init__(settings, session=session)
|
|
19
|
+
|
|
20
|
+
def fetch(self, site_id: str) -> Optional[Any]:
|
|
21
|
+
self._wait_for_rate_limit(
|
|
22
|
+
max_calls=self.rate_limit_calls,
|
|
23
|
+
period_seconds=self.rate_limit_period_seconds,
|
|
24
|
+
)
|
|
25
|
+
url = f"https://sky.shellrecharge.com/greenlots/coreapi/v4/sites/search/{site_id}"
|
|
26
|
+
headers = {
|
|
27
|
+
"accept": "*/*",
|
|
28
|
+
"content-type": "application/json",
|
|
29
|
+
"accept-language": "en",
|
|
30
|
+
"user-agent": "Shell Recharge/7.6.0 (com.zecosystems.greenlots; build:412; iOS) Alamofire/5.9.1",
|
|
31
|
+
"priority": "u=3, i",
|
|
32
|
+
}
|
|
33
|
+
if self.settings.shell_cookie:
|
|
34
|
+
headers["Cookie"] = self.settings.shell_cookie
|
|
35
|
+
elif self.settings.shell_jsessionid:
|
|
36
|
+
headers["Cookie"] = f'JSESSIONID="{self.settings.shell_jsessionid}"'
|
|
37
|
+
return self._get_json(url, headers=headers)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .config import Settings
|
|
8
|
+
from .service import ChargingStatusService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _configure_logging(verbose: bool = False) -> None:
|
|
12
|
+
logging.basicConfig(
|
|
13
|
+
level=logging.DEBUG if verbose else logging.INFO,
|
|
14
|
+
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
19
|
+
parser = argparse.ArgumentParser(description="Poll EV charging status into TimescaleDB/PostgreSQL.")
|
|
20
|
+
parser.add_argument("--verbose", action="store_true", help="Enable debug logging")
|
|
21
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
22
|
+
|
|
23
|
+
subparsers.add_parser("init-db", help="Create schema and seed sites/statuses/stations")
|
|
24
|
+
|
|
25
|
+
poll_parser = subparsers.add_parser("poll", help="Run one polling cycle")
|
|
26
|
+
poll_parser.add_argument("--init-db", action="store_true", help="Initialize schema before polling")
|
|
27
|
+
|
|
28
|
+
loop_parser = subparsers.add_parser("run-loop", help="Poll forever every POLL_INTERVAL_SECONDS")
|
|
29
|
+
loop_parser.add_argument("--init-db", action="store_true", help="Initialize schema before starting loop")
|
|
30
|
+
|
|
31
|
+
sync_parser = subparsers.add_parser(
|
|
32
|
+
"sync-sheet",
|
|
33
|
+
help="Sync historical station_status_events from a public Google Sheet CSV",
|
|
34
|
+
)
|
|
35
|
+
sync_parser.add_argument("--init-db", action="store_true", help="Initialize schema before syncing")
|
|
36
|
+
sync_parser.add_argument("--sheet-name", help="Google Sheet tab name; defaults to SYNC_SHEET_NAME or Sheet1")
|
|
37
|
+
sync_parser.add_argument("--url", help="Override CSV URL; otherwise built from SYNC_SHEET_ID")
|
|
38
|
+
|
|
39
|
+
optimize_parser = subparsers.add_parser(
|
|
40
|
+
"optimize-db",
|
|
41
|
+
help="Optimize station_status_events storage",
|
|
42
|
+
)
|
|
43
|
+
optimize_parser.add_argument(
|
|
44
|
+
"--compress-older-than",
|
|
45
|
+
default="7 days",
|
|
46
|
+
help="Compress chunks older than this PostgreSQL interval; default: 7 days",
|
|
47
|
+
)
|
|
48
|
+
optimize_parser.add_argument(
|
|
49
|
+
"--keep-redundant-index",
|
|
50
|
+
action="store_true",
|
|
51
|
+
help="Keep idx_sse_site_time instead of dropping it",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
size_parser = subparsers.add_parser("db-size", help="Show PostgreSQL database size")
|
|
55
|
+
size_parser.add_argument("--tables", action="store_true", help="Also show table/relation sizes")
|
|
56
|
+
return parser
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main(argv: list[str] | None = None) -> int:
|
|
60
|
+
parser = build_parser()
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
_configure_logging(args.verbose)
|
|
63
|
+
|
|
64
|
+
settings = Settings.from_env()
|
|
65
|
+
service = ChargingStatusService(settings)
|
|
66
|
+
|
|
67
|
+
if args.command == "init-db":
|
|
68
|
+
service.init_db()
|
|
69
|
+
return 0
|
|
70
|
+
if args.command == "poll":
|
|
71
|
+
inserted = service.poll_once(init_schema=args.init_db)
|
|
72
|
+
logging.getLogger(__name__).info("Inserted %s total status events", inserted)
|
|
73
|
+
return 0
|
|
74
|
+
if args.command == "run-loop":
|
|
75
|
+
service.run_forever(init_schema=args.init_db)
|
|
76
|
+
return 0
|
|
77
|
+
if args.command == "sync-sheet":
|
|
78
|
+
stats = service.sync_google_sheet(
|
|
79
|
+
init_schema=args.init_db,
|
|
80
|
+
sheet_name=args.sheet_name,
|
|
81
|
+
csv_url=args.url,
|
|
82
|
+
)
|
|
83
|
+
print(
|
|
84
|
+
"Synced Google Sheet: "
|
|
85
|
+
f"fetched_rows={stats['fetched_rows']}, "
|
|
86
|
+
f"parsed_rows={stats['parsed_rows']}, "
|
|
87
|
+
f"skipped_parse_rows={stats['skipped_parse_rows']}, "
|
|
88
|
+
f"unique_rows={stats['unique_rows']}, "
|
|
89
|
+
f"skipped_unknown_stations={stats['skipped_unknown_stations']}, "
|
|
90
|
+
f"inserted_rows={stats['inserted_rows']}"
|
|
91
|
+
)
|
|
92
|
+
return 0
|
|
93
|
+
if args.command == "optimize-db":
|
|
94
|
+
stats = service.optimize_database(
|
|
95
|
+
compress_older_than=args.compress_older_than,
|
|
96
|
+
drop_redundant_index=not args.keep_redundant_index,
|
|
97
|
+
)
|
|
98
|
+
print(
|
|
99
|
+
"Optimized database: "
|
|
100
|
+
f"rounded_time_rows={stats['rounded_time_rows']}, "
|
|
101
|
+
f"dropped_redundant_index={stats['dropped_redundant_index']}, "
|
|
102
|
+
f"compressed_chunks={stats['compressed_chunks']}"
|
|
103
|
+
)
|
|
104
|
+
return 0
|
|
105
|
+
if args.command == "db-size":
|
|
106
|
+
size = service.get_database_size()
|
|
107
|
+
print(f"{size['database_name']}: {size['size_pretty']} ({size['size_bytes']} bytes)")
|
|
108
|
+
if args.tables:
|
|
109
|
+
for row in service.get_relation_sizes():
|
|
110
|
+
print(f"{row['relation_name']}: {row['size_pretty']} ({row['size_bytes']} bytes)")
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
parser.error(f"unknown command: {args.command}")
|
|
114
|
+
return 2
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|