leancpms-cli 0.1.0__py3-none-any.whl
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.
- cpms/__init__.py +0 -0
- cpms/cli/__init__.py +0 -0
- cpms/cli/client.py +126 -0
- cpms/cli/login.py +232 -0
- cpms/cli/main.py +982 -0
- cpms/manifest/__init__.py +0 -0
- cpms/manifest/documents.py +118 -0
- cpms/manifest/schema.py +374 -0
- leancpms_cli-0.1.0.dist-info/METADATA +211 -0
- leancpms_cli-0.1.0.dist-info/RECORD +13 -0
- leancpms_cli-0.1.0.dist-info/WHEEL +4 -0
- leancpms_cli-0.1.0.dist-info/entry_points.txt +2 -0
- leancpms_cli-0.1.0.dist-info/licenses/LICENSE +662 -0
|
File without changes
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Reading, writing and diffing manifest documents. No framework, no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
from pydantic import TypeAdapter, ValidationError
|
|
12
|
+
|
|
13
|
+
from .schema import MANIFEST_VERSION, Document
|
|
14
|
+
|
|
15
|
+
_adapter: TypeAdapter[Document] = TypeAdapter(Document)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ManifestError(ValueError):
|
|
19
|
+
"""A document that does not validate. `errors` is a list of readable lines."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, errors: list[str]):
|
|
22
|
+
super().__init__("; ".join(errors))
|
|
23
|
+
self.errors = errors
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse(data: Any) -> Document:
|
|
27
|
+
try:
|
|
28
|
+
return _adapter.validate_python(data)
|
|
29
|
+
except ValidationError as e:
|
|
30
|
+
raise ManifestError(
|
|
31
|
+
[f"{'.'.join(str(p) for p in err['loc']) or '(document)'}: {err['msg']}" for err in e.errors()]
|
|
32
|
+
) from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_yaml(text: str) -> list[Document]:
|
|
36
|
+
"""One or more documents separated by `---`."""
|
|
37
|
+
try:
|
|
38
|
+
raw = [d for d in yaml.safe_load_all(text) if d is not None]
|
|
39
|
+
except yaml.YAMLError as e:
|
|
40
|
+
raise ManifestError([f"YAML: {e}"]) from None
|
|
41
|
+
return [parse(d) for d in raw]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def json_schema() -> dict[str, Any]:
|
|
45
|
+
"""The JSON Schema of a document of any kind, for editors (`cpms manifest schema`).
|
|
46
|
+
|
|
47
|
+
It says less than `parse`: the checks across fields (budget within the
|
|
48
|
+
fuse, unique keys, references between plans and tariffs, valid time
|
|
49
|
+
zones) are only in the validators, so `validate` and `plan` still decide.
|
|
50
|
+
"""
|
|
51
|
+
return {"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
52
|
+
"title": f"LeanCPMS manifest document ({MANIFEST_VERSION})",
|
|
53
|
+
**_adapter.json_schema()}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def to_data(doc: Document) -> dict[str, Any]:
|
|
57
|
+
"""The stored form: JSON-safe, money as strings, defaults left out."""
|
|
58
|
+
return doc.model_dump(mode="json", by_alias=True, exclude_defaults=True)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def to_yaml(docs: list[dict[str, Any]]) -> str:
|
|
62
|
+
return "---\n".join(yaml.safe_dump(d, sort_keys=False, allow_unicode=True) for d in docs)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def digest(data: dict[str, Any] | None) -> str:
|
|
66
|
+
"""Stable hash of a stored document; `apply` uses it for optimistic concurrency."""
|
|
67
|
+
if data is None:
|
|
68
|
+
return "none"
|
|
69
|
+
return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class Change:
|
|
74
|
+
op: str # "+", "-", "~"
|
|
75
|
+
path: str
|
|
76
|
+
old: Any = None
|
|
77
|
+
new: Any = None
|
|
78
|
+
|
|
79
|
+
def __str__(self) -> str:
|
|
80
|
+
if self.op == "~":
|
|
81
|
+
return f"~ {self.path}: {_short(self.old)} → {_short(self.new)}"
|
|
82
|
+
return f"{self.op} {self.path}"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _short(v: Any) -> str:
|
|
86
|
+
s = json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v
|
|
87
|
+
return s if len(s) <= 60 else s[:57] + "..."
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _keyed(items: list[Any]) -> bool:
|
|
91
|
+
return bool(items) and all(isinstance(i, dict) and "key" in i for i in items)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def diff(old: Any, new: Any, path: str = "") -> list[Change]:
|
|
95
|
+
"""Changes from `old` to `new`; lists of objects with `key` are matched by key."""
|
|
96
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
97
|
+
out: list[Change] = []
|
|
98
|
+
for k in list(old) + [k for k in new if k not in old]:
|
|
99
|
+
p = f"{path}.{k}" if path else k
|
|
100
|
+
if k not in new:
|
|
101
|
+
out.append(Change("-", p, old[k], None))
|
|
102
|
+
elif k not in old:
|
|
103
|
+
out.append(Change("+", p, None, new[k]))
|
|
104
|
+
else:
|
|
105
|
+
out.extend(diff(old[k], new[k], p))
|
|
106
|
+
return out
|
|
107
|
+
if isinstance(old, list) and isinstance(new, list) and (_keyed(old) or _keyed(new)) and (
|
|
108
|
+
all(isinstance(i, dict) and "key" in i for i in old + new)
|
|
109
|
+
):
|
|
110
|
+
o = {i["key"]: i for i in old}
|
|
111
|
+
n = {i["key"]: i for i in new}
|
|
112
|
+
out = []
|
|
113
|
+
for k in o:
|
|
114
|
+
p = f"{path}[{k}]"
|
|
115
|
+
out.extend([Change("-", p, o[k], None)] if k not in n else diff(o[k], n[k], p))
|
|
116
|
+
out.extend(Change("+", f"{path}[{k}]", None, n[k]) for k in n if k not in o)
|
|
117
|
+
return out
|
|
118
|
+
return [] if old == new else [Change("~", path, old, new)]
|
cpms/manifest/schema.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""The manifest: three kinds of document per organization (architecture §6.1).
|
|
2
|
+
|
|
3
|
+
Plain Pydantic, no framework. The same models are the JSON Schema of the
|
|
4
|
+
files, the payload of the API, and the balancer's input.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from decimal import Decimal
|
|
11
|
+
from typing import Annotated, Literal, Sequence, Union
|
|
12
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
|
+
|
|
14
|
+
from pydantic import (
|
|
15
|
+
BaseModel,
|
|
16
|
+
AfterValidator,
|
|
17
|
+
BeforeValidator,
|
|
18
|
+
ConfigDict,
|
|
19
|
+
Field,
|
|
20
|
+
StringConstraints,
|
|
21
|
+
WithJsonSchema,
|
|
22
|
+
model_validator,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
MANIFEST_VERSION = "cpms/v1"
|
|
26
|
+
|
|
27
|
+
Key = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9_-]{0,62}$")]
|
|
28
|
+
Phase = Literal["L1", "L2", "L3"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _no_float(value: object) -> object:
|
|
32
|
+
# YAML turns 4.50 into a float, and a float is not money. Quote it.
|
|
33
|
+
if isinstance(value, float):
|
|
34
|
+
raise ValueError("amounts must be quoted strings, e.g. \"4.50\", not floats")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# What the JSON Schema says (an editor's view): an integer or a string, never a float.
|
|
39
|
+
_DECIMAL_TEXT = WithJsonSchema({"anyOf": [{"type": "integer"}, {"type": "string"}],
|
|
40
|
+
"description": 'quoted, e.g. "4.50"; a float is not money'})
|
|
41
|
+
Money = Annotated[Decimal, BeforeValidator(_no_float), _DECIMAL_TEXT]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _quoted(value: object) -> object:
|
|
45
|
+
# A coordinate as a YAML float loses its trailing zeros; OCPI sends it as a string.
|
|
46
|
+
if isinstance(value, (float, int)) and not isinstance(value, bool):
|
|
47
|
+
raise ValueError("coordinates must be quoted strings, e.g. \"59.3600000\"")
|
|
48
|
+
return value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _zone(value: str) -> str:
|
|
52
|
+
try:
|
|
53
|
+
ZoneInfo(value)
|
|
54
|
+
except (ZoneInfoNotFoundError, ValueError):
|
|
55
|
+
raise ValueError(f"unknown time zone {value!r} (an IANA name such as Europe/Stockholm)") from None
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
TimeZone = Annotated[str, AfterValidator(_zone)]
|
|
60
|
+
|
|
61
|
+
# The portal's own pages under /portal/ (cpms/portal/urls.py): an organization keyed so would be hidden
|
|
62
|
+
# behind them, since /portal/<organization>/ is matched after them. A test keeps the list complete.
|
|
63
|
+
RESERVED_ORGANIZATION_KEYS = frozenset({"cli", "login", "logout", "inactive"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _not_reserved(value: str) -> str:
|
|
67
|
+
if value in RESERVED_ORGANIZATION_KEYS:
|
|
68
|
+
raise ValueError(f"{value!r} is reserved (a portal page); choose another organization key")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
OrganizationKey = Annotated[Key, AfterValidator(_not_reserved)]
|
|
73
|
+
Rate = Annotated[Decimal, BeforeValidator(_no_float), Field(ge=0, le=1), _DECIMAL_TEXT]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Strict(BaseModel):
|
|
77
|
+
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _unique(items: Sequence[object], attr: str, what: str) -> None:
|
|
81
|
+
seen: set[object] = set()
|
|
82
|
+
for item in items:
|
|
83
|
+
value = getattr(item, attr)
|
|
84
|
+
if value in seen:
|
|
85
|
+
raise ValueError(f"duplicate {what} {value!r}")
|
|
86
|
+
seen.add(value)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- organization -----------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Theme(Strict):
|
|
93
|
+
"""How the organization's driver pages look (architecture §14). Optional; the default is neutral."""
|
|
94
|
+
|
|
95
|
+
primary: Annotated[str, StringConstraints(pattern=r"^#[0-9a-fA-F]{6}$")] | None = None
|
|
96
|
+
logo_url: Annotated[str, StringConstraints(pattern=r"^https://\S+$", max_length=500)] | None = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Organization(Strict):
|
|
100
|
+
key: OrganizationKey
|
|
101
|
+
name: str
|
|
102
|
+
org_number: str | None = None
|
|
103
|
+
vat_number: str | None = None
|
|
104
|
+
address: str | None = None
|
|
105
|
+
email: str | None = None
|
|
106
|
+
payment_mode: Literal["direct", "platform"] = "direct"
|
|
107
|
+
currency: Literal["SEK", "EUR", "NOK", "DKK"] = "SEK"
|
|
108
|
+
display_timezone: str = "Europe/Stockholm"
|
|
109
|
+
# The eMI3 operator id, e.g. "SE*KEL": the OCPI party (country code, party id) and the EVSE ids' prefix.
|
|
110
|
+
operator_id: Annotated[str, StringConstraints(pattern=r"^[A-Z]{2}\*[A-Z0-9]{3}$")] | None = None
|
|
111
|
+
theme: Theme | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class OrganizationDocument(Strict):
|
|
115
|
+
manifest: Literal["cpms/v1"]
|
|
116
|
+
kind: Literal["organization"]
|
|
117
|
+
organization: Organization
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def org_key(self) -> str:
|
|
121
|
+
return self.organization.key
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def doc_key(self) -> str:
|
|
125
|
+
return self.organization.key
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- price list -------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class FlatPrice(Strict):
|
|
132
|
+
from_: dt.date = Field(alias="from")
|
|
133
|
+
per_kwh: Money = Field(ge=0)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SpotPrice(Strict):
|
|
137
|
+
from_: dt.date = Field(alias="from")
|
|
138
|
+
spot_markup_per_kwh: Money = Field(ge=0)
|
|
139
|
+
fallback_per_kwh: Money | None = Field(default=None, ge=0)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class _TariffBase(Strict):
|
|
143
|
+
key: Key
|
|
144
|
+
name: str | None = None
|
|
145
|
+
amounts: Literal["incl_vat", "excl_vat"] # required: which side of the price is exact
|
|
146
|
+
vat_rate: Rate
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def _dated_rows(self): # type: ignore[no-untyped-def]
|
|
150
|
+
prices = getattr(self, "prices")
|
|
151
|
+
if not prices:
|
|
152
|
+
raise ValueError(f"tariff {self.key!r} has no price rows")
|
|
153
|
+
dates = [p.from_ for p in prices]
|
|
154
|
+
if dates != sorted(dates) or len(set(dates)) != len(dates):
|
|
155
|
+
raise ValueError(f"tariff {self.key!r}: price rows must be in strictly increasing date order")
|
|
156
|
+
return self
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FlatTariff(_TariffBase):
|
|
160
|
+
kind: Literal["flat"]
|
|
161
|
+
prices: list[FlatPrice]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SpotTariff(_TariffBase):
|
|
165
|
+
kind: Literal["spot"]
|
|
166
|
+
spot_area: Literal["SE1", "SE2", "SE3", "SE4"]
|
|
167
|
+
prices: list[SpotPrice]
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
Tariff = Annotated[Union[FlatTariff, SpotTariff], Field(discriminator="kind")]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Fee(Strict):
|
|
174
|
+
from_: dt.date = Field(alias="from")
|
|
175
|
+
monthly: Money = Field(ge=0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class Billing(Strict):
|
|
179
|
+
period: Literal["month"] = "month"
|
|
180
|
+
fee: Literal["in_advance"] = "in_advance"
|
|
181
|
+
energy: Literal["in_arrears"] = "in_arrears"
|
|
182
|
+
on_end: Literal["public"] = "public"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Plan(Strict):
|
|
186
|
+
key: Key
|
|
187
|
+
name: str | None = None
|
|
188
|
+
tariff: Key
|
|
189
|
+
requires: Literal["none", "subscription", "roaming"]
|
|
190
|
+
preauth_amount: Money | None = Field(default=None, gt=0) # incl. VAT, reserved on the card
|
|
191
|
+
fees: list[Fee] = []
|
|
192
|
+
billing: Billing | None = None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class RoamingChannel(Strict):
|
|
196
|
+
default: Key
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class Channels(Strict):
|
|
200
|
+
adhoc: list[Key] = []
|
|
201
|
+
roaming: RoamingChannel | None = None
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class PricelistDocument(Strict):
|
|
205
|
+
manifest: Literal["cpms/v1"]
|
|
206
|
+
kind: Literal["pricelist"]
|
|
207
|
+
organization: Key
|
|
208
|
+
tariffs: list[Tariff]
|
|
209
|
+
plans: list[Plan]
|
|
210
|
+
channels: Channels
|
|
211
|
+
|
|
212
|
+
@property
|
|
213
|
+
def org_key(self) -> str:
|
|
214
|
+
return self.organization
|
|
215
|
+
|
|
216
|
+
@property
|
|
217
|
+
def doc_key(self) -> str:
|
|
218
|
+
return "pricelist"
|
|
219
|
+
|
|
220
|
+
@model_validator(mode="after")
|
|
221
|
+
def _references(self): # type: ignore[no-untyped-def]
|
|
222
|
+
_unique(self.tariffs, "key", "tariff")
|
|
223
|
+
_unique(self.plans, "key", "plan")
|
|
224
|
+
tariffs = {t.key for t in self.tariffs}
|
|
225
|
+
plans = {p.key: p for p in self.plans}
|
|
226
|
+
for p in self.plans:
|
|
227
|
+
if p.tariff not in tariffs:
|
|
228
|
+
raise ValueError(f"plan {p.key!r} names unknown tariff {p.tariff!r}")
|
|
229
|
+
for key in self.channels.adhoc:
|
|
230
|
+
if key not in plans:
|
|
231
|
+
raise ValueError(f"channel adhoc names unknown plan {key!r}")
|
|
232
|
+
if plans[key].requires != "none":
|
|
233
|
+
raise ValueError(f"channel adhoc plan {key!r} must have requires: none")
|
|
234
|
+
if plans[key].preauth_amount is None:
|
|
235
|
+
raise ValueError(f"channel adhoc plan {key!r} needs a preauth_amount")
|
|
236
|
+
if self.channels.roaming and self.channels.roaming.default not in plans:
|
|
237
|
+
raise ValueError(f"channel roaming names unknown plan {self.channels.roaming.default!r}")
|
|
238
|
+
return self
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --- site -------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class Policy(Strict):
|
|
245
|
+
mode: Literal["fair_share"] = "fair_share"
|
|
246
|
+
min_amps: int = Field(default=6, ge=6, le=32)
|
|
247
|
+
max_amps: int = Field(default=32, ge=6, le=63)
|
|
248
|
+
single_phase_threshold_amps: int = Field(default=10, ge=6, le=32)
|
|
249
|
+
single_phase_amps: int = Field(default=16, ge=6, le=32)
|
|
250
|
+
pause_allowed: bool = True
|
|
251
|
+
hysteresis_amps: int = Field(default=2, ge=0, le=10)
|
|
252
|
+
phase_switch_cooldown_s: int = Field(default=300, ge=0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class PostgresMeter(Strict):
|
|
256
|
+
type: Literal["postgres_energy_readings"]
|
|
257
|
+
dsn_env: str
|
|
258
|
+
location: str
|
|
259
|
+
max_age_s: int = Field(default=30, gt=0)
|
|
260
|
+
margin_amps: int = Field(default=2, ge=0)
|
|
261
|
+
fallback_external_amps: int | None = Field(default=None, ge=0)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class HttpMeter(Strict):
|
|
265
|
+
type: Literal["http"]
|
|
266
|
+
url: str
|
|
267
|
+
auth_header_env: str | None = None
|
|
268
|
+
max_age_s: int = Field(default=30, gt=0)
|
|
269
|
+
margin_amps: int = Field(default=2, ge=0)
|
|
270
|
+
fallback_external_amps: int | None = Field(default=None, ge=0)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
ExternalMeter = Annotated[Union[PostgresMeter, HttpMeter], Field(discriminator="type")]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class Outlet(Strict):
|
|
277
|
+
connector: int = Field(ge=1, le=8)
|
|
278
|
+
key: Key
|
|
279
|
+
public: bool = True
|
|
280
|
+
max_amps: int | None = Field(default=None, ge=6, le=32) # the balancer's cap and the connector's rating
|
|
281
|
+
# What OCPI says about the outlet. Required on a published site (plan checks), defaults otherwise.
|
|
282
|
+
plug: Literal["type2_socket", "type2_cable"] | None = None
|
|
283
|
+
phases: Literal[1, 3] | None = None
|
|
284
|
+
evse_id: Annotated[str, StringConstraints(max_length=48)] | None = None # overrides the derived eMI3 id
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class ChargePoint(Strict):
|
|
288
|
+
key: Key
|
|
289
|
+
ocpp_id: Annotated[str, StringConstraints(min_length=1, max_length=64)]
|
|
290
|
+
name: str | None = None
|
|
291
|
+
phase_rotation: list[Phase] = ["L1", "L2", "L3"]
|
|
292
|
+
rotation_verified: bool = False
|
|
293
|
+
auth: Literal["none", "basic"] = "none" # the key is generated server-side, never in the file
|
|
294
|
+
outlets: list[Outlet]
|
|
295
|
+
|
|
296
|
+
@model_validator(mode="after")
|
|
297
|
+
def _physics(self): # type: ignore[no-untyped-def]
|
|
298
|
+
if sorted(self.phase_rotation) != ["L1", "L2", "L3"]:
|
|
299
|
+
raise ValueError(f"charge point {self.key!r}: phase_rotation must be a permutation of L1, L2, L3")
|
|
300
|
+
if not self.outlets:
|
|
301
|
+
raise ValueError(f"charge point {self.key!r} has no outlets")
|
|
302
|
+
_unique(self.outlets, "connector", f"connector on {self.key!r}")
|
|
303
|
+
return self
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class FuseGroup(Strict):
|
|
307
|
+
key: Key
|
|
308
|
+
name: str | None = None
|
|
309
|
+
physical_fuse_amps: int = Field(gt=0)
|
|
310
|
+
budget_amps: int = Field(gt=0)
|
|
311
|
+
balancing: bool = True
|
|
312
|
+
policy: Policy = Policy()
|
|
313
|
+
external_meter: ExternalMeter | None = None
|
|
314
|
+
charge_points: list[ChargePoint] = []
|
|
315
|
+
|
|
316
|
+
@model_validator(mode="after")
|
|
317
|
+
def _physics(self): # type: ignore[no-untyped-def]
|
|
318
|
+
if self.budget_amps > self.physical_fuse_amps:
|
|
319
|
+
raise ValueError(
|
|
320
|
+
f"fuse group {self.key!r}: budget_amps {self.budget_amps} exceeds physical_fuse_amps {self.physical_fuse_amps}"
|
|
321
|
+
)
|
|
322
|
+
_unique(self.charge_points, "key", "charge point")
|
|
323
|
+
return self
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class Coordinates(Strict):
|
|
327
|
+
"""WGS 84, as OCPI writes them: decimal degrees as strings with 5 to 7 decimals."""
|
|
328
|
+
|
|
329
|
+
# The validator outermost, so the pattern also reaches the JSON Schema.
|
|
330
|
+
latitude: Annotated[str, StringConstraints(pattern=r"^-?[0-9]{1,2}\.[0-9]{5,7}$"), BeforeValidator(_quoted)]
|
|
331
|
+
longitude: Annotated[str, StringConstraints(pattern=r"^-?[0-9]{1,3}\.[0-9]{5,7}$"), BeforeValidator(_quoted)]
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class Site(Strict):
|
|
335
|
+
key: Key
|
|
336
|
+
name: str
|
|
337
|
+
address: str | None = None # street and number
|
|
338
|
+
city: str | None = None
|
|
339
|
+
postal_code: str | None = None
|
|
340
|
+
country: Annotated[str, StringConstraints(pattern=r"^[A-Z]{3}$")] = "SWE" # ISO 3166-1 alpha-3
|
|
341
|
+
coordinates: Coordinates | None = None
|
|
342
|
+
timezone: TimeZone | None = None # defaults to the organization's display_timezone
|
|
343
|
+
publish: bool = False # in OCPI (Nobil, roaming) only when true
|
|
344
|
+
notes: str | None = None
|
|
345
|
+
fuse_groups: list[FuseGroup]
|
|
346
|
+
|
|
347
|
+
@model_validator(mode="after")
|
|
348
|
+
def _unique_keys(self): # type: ignore[no-untyped-def]
|
|
349
|
+
_unique(self.fuse_groups, "key", "fuse group")
|
|
350
|
+
cps = [cp for fg in self.fuse_groups for cp in fg.charge_points]
|
|
351
|
+
_unique(cps, "key", "charge point")
|
|
352
|
+
_unique(cps, "ocpp_id", "ocpp_id")
|
|
353
|
+
_unique([o for cp in cps for o in cp.outlets], "key", "outlet")
|
|
354
|
+
return self
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class SiteDocument(Strict):
|
|
358
|
+
manifest: Literal["cpms/v1"]
|
|
359
|
+
kind: Literal["site"]
|
|
360
|
+
organization: Key
|
|
361
|
+
site: Site
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def org_key(self) -> str:
|
|
365
|
+
return self.organization
|
|
366
|
+
|
|
367
|
+
@property
|
|
368
|
+
def doc_key(self) -> str:
|
|
369
|
+
return self.site.key
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
Document = Annotated[
|
|
373
|
+
Union[OrganizationDocument, PricelistDocument, SiteDocument], Field(discriminator="kind")
|
|
374
|
+
]
|