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.
Files changed (27) hide show
  1. ev_charging_status-0.1.0/LICENSE +21 -0
  2. ev_charging_status-0.1.0/PKG-INFO +133 -0
  3. ev_charging_status-0.1.0/README.md +113 -0
  4. ev_charging_status-0.1.0/pyproject.toml +34 -0
  5. ev_charging_status-0.1.0/setup.cfg +4 -0
  6. ev_charging_status-0.1.0/src/ev_charging_status/__init__.py +3 -0
  7. ev_charging_status-0.1.0/src/ev_charging_status/__main__.py +4 -0
  8. ev_charging_status-0.1.0/src/ev_charging_status/api/__init__.py +9 -0
  9. ev_charging_status-0.1.0/src/ev_charging_status/api/base.py +50 -0
  10. ev_charging_status-0.1.0/src/ev_charging_status/api/client.py +25 -0
  11. ev_charging_status-0.1.0/src/ev_charging_status/api/sema.py +45 -0
  12. ev_charging_status-0.1.0/src/ev_charging_status/api/shellrecharge.py +37 -0
  13. ev_charging_status-0.1.0/src/ev_charging_status/cli.py +118 -0
  14. ev_charging_status-0.1.0/src/ev_charging_status/config.py +158 -0
  15. ev_charging_status-0.1.0/src/ev_charging_status/db.py +443 -0
  16. ev_charging_status-0.1.0/src/ev_charging_status/models.py +25 -0
  17. ev_charging_status-0.1.0/src/ev_charging_status/notifications.py +17 -0
  18. ev_charging_status-0.1.0/src/ev_charging_status/parsers.py +142 -0
  19. ev_charging_status-0.1.0/src/ev_charging_status/seeds.py +40 -0
  20. ev_charging_status-0.1.0/src/ev_charging_status/service.py +148 -0
  21. ev_charging_status-0.1.0/src/ev_charging_status/sheet_sync.py +139 -0
  22. ev_charging_status-0.1.0/src/ev_charging_status.egg-info/PKG-INFO +133 -0
  23. ev_charging_status-0.1.0/src/ev_charging_status.egg-info/SOURCES.txt +25 -0
  24. ev_charging_status-0.1.0/src/ev_charging_status.egg-info/dependency_links.txt +1 -0
  25. ev_charging_status-0.1.0/src/ev_charging_status.egg-info/entry_points.txt +3 -0
  26. ev_charging_status-0.1.0/src/ev_charging_status.egg-info/requires.txt +3 -0
  27. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """EV charging status polling package."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ from .client import ChargingApiClient
2
+ from .sema import SemaLocationEndpoint
3
+ from .shellrecharge import ShellRechargeSiteSearchEndpoint
4
+
5
+ __all__ = [
6
+ "ChargingApiClient",
7
+ "SemaLocationEndpoint",
8
+ "ShellRechargeSiteSearchEndpoint",
9
+ ]
@@ -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())