squoredata 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.
- squoredata/__init__.py +72 -0
- squoredata/devinfo.py +68 -0
- squoredata/game.py +39 -0
- squoredata/livedata.py +233 -0
- squoredata/livestatus.py +63 -0
- squoredata/match.py +84 -0
- squoredata/point.py +109 -0
- squoredata/py.typed +0 -0
- squoredata-0.1.0.dist-info/METADATA +92 -0
- squoredata-0.1.0.dist-info/RECORD +12 -0
- squoredata-0.1.0.dist-info/WHEEL +4 -0
- squoredata-0.1.0.dist-info/licenses/LICENSE +21 -0
squoredata/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Self-contained Squore data structures.
|
|
2
|
+
|
|
3
|
+
This package encapsulates the data structures that a Squore tablet uses, as
|
|
4
|
+
opposed to the surrounding server logic in the ``tcboard`` project.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .devinfo import BatteryStatus, DeviceInfo, SquoreDeviceInfo
|
|
8
|
+
from .game import Game, Result, ScoresType, ScoreType
|
|
9
|
+
from .livedata import (
|
|
10
|
+
InconsistentStateError,
|
|
11
|
+
LockStateType,
|
|
12
|
+
ServerType,
|
|
13
|
+
SquoreMatchLiveData,
|
|
14
|
+
TimerInfo,
|
|
15
|
+
Timing,
|
|
16
|
+
When,
|
|
17
|
+
)
|
|
18
|
+
from .livestatus import LiveStatus
|
|
19
|
+
from .match import (
|
|
20
|
+
Clubs,
|
|
21
|
+
Colors,
|
|
22
|
+
Countries,
|
|
23
|
+
Event,
|
|
24
|
+
Format,
|
|
25
|
+
Metadata,
|
|
26
|
+
Players,
|
|
27
|
+
PlayersProps,
|
|
28
|
+
SquoreMatch,
|
|
29
|
+
Wifi,
|
|
30
|
+
)
|
|
31
|
+
from .point import (
|
|
32
|
+
AppealType,
|
|
33
|
+
PlayerIndex,
|
|
34
|
+
PlayerLetter,
|
|
35
|
+
Point,
|
|
36
|
+
ServerSide,
|
|
37
|
+
make_point_from_squore_line,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"AppealType",
|
|
42
|
+
"BatteryStatus",
|
|
43
|
+
"Clubs",
|
|
44
|
+
"Colors",
|
|
45
|
+
"Countries",
|
|
46
|
+
"DeviceInfo",
|
|
47
|
+
"Event",
|
|
48
|
+
"Format",
|
|
49
|
+
"Game",
|
|
50
|
+
"InconsistentStateError",
|
|
51
|
+
"LiveStatus",
|
|
52
|
+
"LockStateType",
|
|
53
|
+
"Metadata",
|
|
54
|
+
"PlayerIndex",
|
|
55
|
+
"PlayerLetter",
|
|
56
|
+
"Players",
|
|
57
|
+
"PlayersProps",
|
|
58
|
+
"Point",
|
|
59
|
+
"Result",
|
|
60
|
+
"ScoreType",
|
|
61
|
+
"ScoresType",
|
|
62
|
+
"ServerSide",
|
|
63
|
+
"ServerType",
|
|
64
|
+
"SquoreDeviceInfo",
|
|
65
|
+
"SquoreMatch",
|
|
66
|
+
"SquoreMatchLiveData",
|
|
67
|
+
"TimerInfo",
|
|
68
|
+
"Timing",
|
|
69
|
+
"When",
|
|
70
|
+
"Wifi",
|
|
71
|
+
"make_point_from_squore_line",
|
|
72
|
+
]
|
squoredata/devinfo.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# needed < 3.14 so that annotations aren't evaluated
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, model_validator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BatteryStatus(BaseModel):
|
|
11
|
+
"""The battery status of a device."""
|
|
12
|
+
|
|
13
|
+
percentage: int
|
|
14
|
+
charging: bool
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeviceInfo(BaseModel):
|
|
18
|
+
"""Base information about a device."""
|
|
19
|
+
|
|
20
|
+
deviceid: str | None = None
|
|
21
|
+
timestamp: datetime | None = None
|
|
22
|
+
|
|
23
|
+
def model_post_init(self, ctx: Any) -> None:
|
|
24
|
+
if self.timestamp is None:
|
|
25
|
+
self.timestamp = (ctx or {}).get("timestamp") or datetime.now()
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def device(self) -> str | None:
|
|
29
|
+
return self.deviceid
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def batterystatus(self) -> BatteryStatus | None:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
def debug_repr(self) -> str:
|
|
36
|
+
return str(self)
|
|
37
|
+
|
|
38
|
+
def __str__(self) -> str:
|
|
39
|
+
return self.device or "(no device ID)"
|
|
40
|
+
|
|
41
|
+
def __hash__(self) -> int:
|
|
42
|
+
return hash(self.device)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_NONEXISTENT = object()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SquoreDeviceInfo(DeviceInfo):
|
|
49
|
+
"""Device information as reported by a Squore tablet."""
|
|
50
|
+
|
|
51
|
+
batteryCharging: bool
|
|
52
|
+
batteryPercentage: int
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="before")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _rename_device(cls, data: Any) -> Any:
|
|
57
|
+
if (
|
|
58
|
+
isinstance(data, dict)
|
|
59
|
+
and (dev := data.get("device", _NONEXISTENT)) is not _NONEXISTENT
|
|
60
|
+
):
|
|
61
|
+
data = {k: v for k, v in data.items() if k != "device"} | {"deviceid": dev}
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def batterystatus(self) -> BatteryStatus:
|
|
66
|
+
return BatteryStatus(
|
|
67
|
+
percentage=self.batteryPercentage, charging=self.batteryCharging
|
|
68
|
+
)
|
squoredata/game.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Game-level data as used by Squore."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, field_serializer
|
|
6
|
+
|
|
7
|
+
from .point import Point
|
|
8
|
+
|
|
9
|
+
type ScoreType = tuple[int, int]
|
|
10
|
+
type ScoresType = list[ScoreType]
|
|
11
|
+
|
|
12
|
+
# type Result = tuple[int, int]
|
|
13
|
+
type Result = list[int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Game(BaseModel):
|
|
17
|
+
"""A single game within a match."""
|
|
18
|
+
|
|
19
|
+
score: ScoreType
|
|
20
|
+
starttime: datetime | None = None
|
|
21
|
+
endtime: datetime | None = None
|
|
22
|
+
winner: int | None = None
|
|
23
|
+
# Winner seems a redundant field, but it is not. A game could be live, i.e. ongoing,
|
|
24
|
+
# and then None means there is no winner. We could of course compute this too, but
|
|
25
|
+
# then we'd need to know the match format here. Let's keep it simple for now.
|
|
26
|
+
scoreline: list[Point] = []
|
|
27
|
+
|
|
28
|
+
@field_serializer("score", mode="plain")
|
|
29
|
+
def _score_as_list(self, value: ScoreType) -> Result:
|
|
30
|
+
# Convert the tuple to a list because otherwise json2ts generates:
|
|
31
|
+
#
|
|
32
|
+
# /**
|
|
33
|
+
# * @minItems 2
|
|
34
|
+
# * @maxItems 2
|
|
35
|
+
# */
|
|
36
|
+
# export type Result = [unknown, unknown];
|
|
37
|
+
#
|
|
38
|
+
# and thus loses the typing.
|
|
39
|
+
return list(value)
|
squoredata/livedata.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Live match data as reported by a Squore tablet."""
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Literal, Never
|
|
6
|
+
|
|
7
|
+
from pydantic import (
|
|
8
|
+
BaseModel,
|
|
9
|
+
Field,
|
|
10
|
+
computed_field,
|
|
11
|
+
field_serializer,
|
|
12
|
+
field_validator,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .game import Game, ScoresType
|
|
16
|
+
from .livestatus import LiveStatus
|
|
17
|
+
from .match import SquoreMatch
|
|
18
|
+
from .point import PlayerLetter, Point, ServerSide, make_point_from_squore_line
|
|
19
|
+
|
|
20
|
+
type ServerType = PlayerLetter
|
|
21
|
+
type LockStateType = ( # src/com/doubleyellow/scoreboard/model/LockState.java
|
|
22
|
+
Literal["Unlocked"]
|
|
23
|
+
| Literal["UnlockedManual"]
|
|
24
|
+
| Literal["UnlockedEndOfFinalGame"]
|
|
25
|
+
| Literal["LockedManual"]
|
|
26
|
+
| Literal["LockedManualGUI"]
|
|
27
|
+
| Literal["LockedEndOfMatch"]
|
|
28
|
+
| Literal["LockedIdleTime"]
|
|
29
|
+
| Literal["SharedEndedMatch"]
|
|
30
|
+
| Literal["LockedEndOfMatchRetired"]
|
|
31
|
+
| Literal["LockedEndOfMatchConduct"]
|
|
32
|
+
| Literal["LockedEndOfMatchTimeBased"]
|
|
33
|
+
| None
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InconsistentStateError(Exception):
|
|
38
|
+
"""The Squore state is internally inconsistent."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, matchid: str, deviceid: str) -> None:
|
|
41
|
+
super().__init__(
|
|
42
|
+
f"Inconsistent Squore state (bug #120), matchid={matchid}, "
|
|
43
|
+
f"deviceid={deviceid}"
|
|
44
|
+
)
|
|
45
|
+
self.matchid = matchid
|
|
46
|
+
self.deviceid = deviceid
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class When(BaseModel, extra="forbid"):
|
|
50
|
+
"""The date and time a match is scheduled for."""
|
|
51
|
+
|
|
52
|
+
date: str
|
|
53
|
+
time: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Timing(BaseModel, extra="forbid"):
|
|
57
|
+
"""Timing information for one game."""
|
|
58
|
+
|
|
59
|
+
start: str
|
|
60
|
+
end: str
|
|
61
|
+
offsets: list[int] | None = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TimerInfo(BaseModel, extra="forbid"):
|
|
65
|
+
"""Information about a running countdown timer."""
|
|
66
|
+
|
|
67
|
+
type: (
|
|
68
|
+
Literal["Warmup"]
|
|
69
|
+
| Literal["UntilStartOfFirstGame"]
|
|
70
|
+
| Literal["UntilStartOfNextGame"]
|
|
71
|
+
)
|
|
72
|
+
totalSeconds: int
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SquoreMatchLiveData(SquoreMatch, extra="forbid"):
|
|
76
|
+
"""A Squore match together with the live data reported by the tablet."""
|
|
77
|
+
|
|
78
|
+
appName: Literal["Squore"]
|
|
79
|
+
appPackage: Literal["com.doubleyellow.scoreboard"]
|
|
80
|
+
court: int | None
|
|
81
|
+
timestamp: datetime | None = None
|
|
82
|
+
customData: dict[str, Any] | None = None
|
|
83
|
+
gamescores: str | None = None
|
|
84
|
+
isGameBall: bool
|
|
85
|
+
isHandOut: bool
|
|
86
|
+
isMatchBall: bool
|
|
87
|
+
isVictoryFor: PlayerLetter | None = None
|
|
88
|
+
isUndo: bool = False
|
|
89
|
+
lastCall: str | None = None
|
|
90
|
+
lastScorer: PlayerLetter | None = None
|
|
91
|
+
liveScoreDeviceId: str
|
|
92
|
+
lockState: LockStateType = None
|
|
93
|
+
maxNrOfPowerPlays: int | None = None
|
|
94
|
+
result: str
|
|
95
|
+
score: list[list[str]] = Field(default_factory=list[list[str]])
|
|
96
|
+
server: ServerType
|
|
97
|
+
serveSide: ServerSide
|
|
98
|
+
start: str
|
|
99
|
+
timerInfo: TimerInfo | None = None
|
|
100
|
+
timing: list[Timing] = Field(default_factory=list[Timing])
|
|
101
|
+
when: When
|
|
102
|
+
|
|
103
|
+
def model_post_init(self, ctx: Any) -> None:
|
|
104
|
+
if self.timestamp is None:
|
|
105
|
+
self.timestamp = (ctx or {}).get("timestamp") or datetime.now()
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def matchid(self) -> str:
|
|
109
|
+
return self.metadata.sourceID
|
|
110
|
+
|
|
111
|
+
@computed_field # type: ignore[prop-decorator]
|
|
112
|
+
@property
|
|
113
|
+
def status(self) -> LiveStatus:
|
|
114
|
+
if self.timerInfo:
|
|
115
|
+
if self.timerInfo.type == "Warmup":
|
|
116
|
+
return LiveStatus.WARMUP
|
|
117
|
+
|
|
118
|
+
if self.timerInfo.type == "UntilStartOfFirstGame":
|
|
119
|
+
return LiveStatus.PREPARE
|
|
120
|
+
|
|
121
|
+
elif self.timerInfo.totalSeconds == 15:
|
|
122
|
+
return LiveStatus.FIFTEENSECONDS
|
|
123
|
+
|
|
124
|
+
return LiveStatus.BETWEENGAMES
|
|
125
|
+
|
|
126
|
+
elif self.score:
|
|
127
|
+
if self.isMatchBall:
|
|
128
|
+
return LiveStatus.MATCHBALL
|
|
129
|
+
|
|
130
|
+
elif self.isGameBall:
|
|
131
|
+
return LiveStatus.GAMEBALL
|
|
132
|
+
|
|
133
|
+
elif self.isVictoryFor:
|
|
134
|
+
return LiveStatus.FINISHED
|
|
135
|
+
|
|
136
|
+
return LiveStatus.ONGOING
|
|
137
|
+
|
|
138
|
+
return LiveStatus.READY
|
|
139
|
+
|
|
140
|
+
def nr_games_played(self) -> int:
|
|
141
|
+
return sum(int(x) for x in self.result.split("-"))
|
|
142
|
+
|
|
143
|
+
@computed_field # type: ignore[prop-decorator]
|
|
144
|
+
@property
|
|
145
|
+
def scores(self) -> ScoresType:
|
|
146
|
+
if self.gamescores is None:
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
ret = []
|
|
150
|
+
for gscore in self.gamescores.split(","):
|
|
151
|
+
ret.append((int((s := gscore.split("-"))[0]), int(s[1])))
|
|
152
|
+
return ret
|
|
153
|
+
|
|
154
|
+
@field_serializer("scores", mode="plain")
|
|
155
|
+
def _scores_as_lists(self, value: ScoresType) -> list[list[int]]:
|
|
156
|
+
# Convert the tuples to lists for the schema, because otherwise json2ts
|
|
157
|
+
# loses the element typing and emits [unknown, unknown].
|
|
158
|
+
return [list(game) for game in value]
|
|
159
|
+
|
|
160
|
+
def _get_scoreline(
|
|
161
|
+
self, scores: list[str], offsets: list[int] | None
|
|
162
|
+
) -> list[Point]:
|
|
163
|
+
ret: list[Point] = []
|
|
164
|
+
|
|
165
|
+
prev_offset = 0
|
|
166
|
+
for point, offset in itertools.zip_longest(scores, offsets or []):
|
|
167
|
+
ret.append(
|
|
168
|
+
make_point_from_squore_line(point, duration=offset - prev_offset)
|
|
169
|
+
)
|
|
170
|
+
prev_offset = offset
|
|
171
|
+
|
|
172
|
+
return ret
|
|
173
|
+
|
|
174
|
+
@computed_field # type: ignore[prop-decorator]
|
|
175
|
+
@property
|
|
176
|
+
def games(self) -> list[Game]:
|
|
177
|
+
nr_games_played = self.nr_games_played()
|
|
178
|
+
ret = []
|
|
179
|
+
|
|
180
|
+
is_cur = False
|
|
181
|
+
for i, ((a, b), points, timings) in enumerate(
|
|
182
|
+
zip(
|
|
183
|
+
self.scores,
|
|
184
|
+
self.score,
|
|
185
|
+
[t for t in self.timing if t.offsets is not None],
|
|
186
|
+
strict=True,
|
|
187
|
+
)
|
|
188
|
+
):
|
|
189
|
+
scoreline = self._get_scoreline(points, timings.offsets)
|
|
190
|
+
|
|
191
|
+
is_cur = i == nr_games_played
|
|
192
|
+
ret.append(
|
|
193
|
+
Game(
|
|
194
|
+
score=(a, b),
|
|
195
|
+
starttime=datetime.fromisoformat(timings.start),
|
|
196
|
+
endtime=None if is_cur else datetime.fromisoformat(timings.end),
|
|
197
|
+
winner=None if is_cur else int(a < b),
|
|
198
|
+
scoreline=scoreline,
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
not is_cur
|
|
204
|
+
and self.status >= LiveStatus.READY
|
|
205
|
+
and self.status < LiveStatus.FINISHED
|
|
206
|
+
) or (
|
|
207
|
+
len(self.timing) == 1
|
|
208
|
+
and self.timing[0].offsets is None
|
|
209
|
+
and self.score == []
|
|
210
|
+
):
|
|
211
|
+
ret.append(Game(score=(0, 0)))
|
|
212
|
+
return ret
|
|
213
|
+
|
|
214
|
+
@computed_field # type: ignore[prop-decorator]
|
|
215
|
+
@property
|
|
216
|
+
def winner(self) -> int | None:
|
|
217
|
+
if self.isVictoryFor is None:
|
|
218
|
+
return None
|
|
219
|
+
return {"A": 0, "B": 1}[self.isVictoryFor]
|
|
220
|
+
|
|
221
|
+
def validate_livedata(self) -> Never | None:
|
|
222
|
+
if len([t for t in self.timing if t.offsets is not None]) != len(self.scores):
|
|
223
|
+
raise InconsistentStateError(
|
|
224
|
+
matchid=self.matchid, deviceid=self.liveScoreDeviceId
|
|
225
|
+
)
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
@field_validator("court", mode="before")
|
|
229
|
+
@classmethod
|
|
230
|
+
def handle_no_court_as_string(
|
|
231
|
+
cls, data: Literal["null"] | int | None
|
|
232
|
+
) -> int | None:
|
|
233
|
+
return None if data == "null" else data
|
squoredata/livestatus.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# needed < 3.14 so that annotations aren't evaluated
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import IntEnum, auto
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LiveStatus(IntEnum):
|
|
8
|
+
"""The status of a live match, derived from the Squore live data."""
|
|
9
|
+
|
|
10
|
+
UNKNOWN = 0
|
|
11
|
+
WARMUP = auto()
|
|
12
|
+
PREPARE = auto()
|
|
13
|
+
FIFTEENSECONDS = auto()
|
|
14
|
+
READY = auto()
|
|
15
|
+
ONGOING = auto()
|
|
16
|
+
GAMEBALL = auto()
|
|
17
|
+
BETWEENGAMES = auto()
|
|
18
|
+
MATCHBALL = auto()
|
|
19
|
+
FINISHED = auto()
|
|
20
|
+
EXTERNAL = auto()
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
return self.name
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def short(self) -> str:
|
|
27
|
+
return {
|
|
28
|
+
self.UNKNOWN: "???",
|
|
29
|
+
self.WARMUP: "WUP",
|
|
30
|
+
self.PREPARE: "PRE",
|
|
31
|
+
self.READY: "RDY",
|
|
32
|
+
self.ONGOING: "ONG",
|
|
33
|
+
self.GAMEBALL: "GBL",
|
|
34
|
+
self.BETWEENGAMES: "PAU",
|
|
35
|
+
self.FIFTEENSECONDS: "15s",
|
|
36
|
+
self.MATCHBALL: "MBL",
|
|
37
|
+
self.FINISHED: "FIN",
|
|
38
|
+
self.EXTERNAL: "EXT",
|
|
39
|
+
}[self]
|
|
40
|
+
|
|
41
|
+
def can_come_after(self, other: LiveStatus) -> bool:
|
|
42
|
+
if self == self.UNKNOWN:
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
elif other < self.ONGOING:
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
elif self == self.ONGOING and other in (
|
|
49
|
+
self.BETWEENGAMES,
|
|
50
|
+
self.FIFTEENSECONDS,
|
|
51
|
+
self.GAMEBALL,
|
|
52
|
+
self.MATCHBALL,
|
|
53
|
+
):
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
elif self == self.FIFTEENSECONDS and other in (
|
|
57
|
+
self.GAMEBALL,
|
|
58
|
+
self.BETWEENGAMES,
|
|
59
|
+
self.PREPARE,
|
|
60
|
+
):
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
return self >= other
|
squoredata/match.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""The static Squore match description, i.e. a match without live data."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from .devinfo import SquoreDeviceInfo
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Players(BaseModel, extra="forbid"):
|
|
11
|
+
"""The names of the two players."""
|
|
12
|
+
|
|
13
|
+
A: str
|
|
14
|
+
B: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PlayersProps(BaseModel, extra="forbid"):
|
|
18
|
+
"""A per-player attribute, one value for each player."""
|
|
19
|
+
|
|
20
|
+
A: str | None = None
|
|
21
|
+
B: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Colors(PlayersProps): ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Countries(PlayersProps): ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Clubs(PlayersProps): ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Event(BaseModel, extra="forbid"):
|
|
34
|
+
"""The event a match belongs to."""
|
|
35
|
+
|
|
36
|
+
name: str
|
|
37
|
+
division: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Format(BaseModel, extra="forbid"):
|
|
41
|
+
"""The scoring format of a match."""
|
|
42
|
+
|
|
43
|
+
numberOfPointsToWinGame: int = 11
|
|
44
|
+
numberOfGamesToWinMatch: int = 3
|
|
45
|
+
useHandInHandOutScoring: bool = False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Wifi(BaseModel, extra="forbid"):
|
|
49
|
+
"""The WiFi details a device is connected to."""
|
|
50
|
+
|
|
51
|
+
ipaddress: str | None = None
|
|
52
|
+
ssid: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Metadata(BaseModel, extra="forbid"):
|
|
56
|
+
"""Metadata a Squore tablet attaches to a match."""
|
|
57
|
+
|
|
58
|
+
sourceID: str
|
|
59
|
+
device: SquoreDeviceInfo | None = None
|
|
60
|
+
source: str | None = None
|
|
61
|
+
version: int | None = None
|
|
62
|
+
language: str | None = None
|
|
63
|
+
wifi: Wifi | None = None
|
|
64
|
+
shareURL: str | None = None
|
|
65
|
+
sourceFeedbackState: (
|
|
66
|
+
Literal["SourceAcceptedFinalResult"] | Literal["SourceRejectedResult"] | None
|
|
67
|
+
) = None
|
|
68
|
+
sourcePostResultUrl: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SquoreMatch(BaseModel, extra="forbid"):
|
|
72
|
+
"""A match as used by Squore, without any live information."""
|
|
73
|
+
|
|
74
|
+
players: Players
|
|
75
|
+
clubs: Clubs | None = None
|
|
76
|
+
colors: Colors | None = None
|
|
77
|
+
countries: Countries | None = None
|
|
78
|
+
event: Event
|
|
79
|
+
format: Format
|
|
80
|
+
metadata: Metadata
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def matchid(self) -> str:
|
|
84
|
+
return self.metadata.sourceID
|
squoredata/point.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Point-level data as used by Squore.
|
|
2
|
+
|
|
3
|
+
The types and the parser are ported from the ``tcboard`` project so that the
|
|
4
|
+
Squore data structures are self-contained.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"AppealType",
|
|
14
|
+
"PlayerIndex",
|
|
15
|
+
"PlayerLetter",
|
|
16
|
+
"Point",
|
|
17
|
+
"ServerSide",
|
|
18
|
+
"make_point_from_squore_line",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
type PlayerLetter = Literal["A"] | Literal["B"]
|
|
22
|
+
type PlayerIndex = Literal[0] | Literal[1]
|
|
23
|
+
type ServerSide = Literal["L"] | Literal["R"]
|
|
24
|
+
type AppealType = Literal["S"] | Literal["L"] | Literal["N"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Point(BaseModel):
|
|
28
|
+
"""A single point in a game."""
|
|
29
|
+
|
|
30
|
+
server: PlayerIndex | None
|
|
31
|
+
serveside: ServerSide | None
|
|
32
|
+
player: PlayerIndex
|
|
33
|
+
data: int | AppealType
|
|
34
|
+
duration: int | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
PATTERN = re.compile(
|
|
38
|
+
r"(?P<AX>(?P<AS>[-RL])(?P<AP>\d+|-)|ST|[YN]L)"
|
|
39
|
+
r"(?P<BX>(?P<BS>[-RL])(?P<BP>\d+|-)|ST|[YN]L)",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
type PointDataType = AppealType | int
|
|
43
|
+
PointDataTypeValidator: TypeAdapter[PointDataType] = TypeAdapter(PointDataType)
|
|
44
|
+
PlayerIndexValidator: TypeAdapter[PlayerIndex] = TypeAdapter(PlayerIndex)
|
|
45
|
+
PlayerLetterValidator: TypeAdapter[PlayerLetter] = TypeAdapter(PlayerLetter)
|
|
46
|
+
ServerSideValidator: TypeAdapter[ServerSide] = TypeAdapter(ServerSide)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_point_from_squore_line(
|
|
50
|
+
squore: str | None, *, duration: int | None = None
|
|
51
|
+
) -> Point:
|
|
52
|
+
"""Parse a Squore line representation into a :class:`Point`.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
squore: The Squore line representation, e.g. ``"R1--"``.
|
|
56
|
+
duration: The optional duration of the point in seconds.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
The parsed point.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
ValueError: If the line cannot be parsed.
|
|
63
|
+
"""
|
|
64
|
+
m = None
|
|
65
|
+
if squore is not None:
|
|
66
|
+
m = re.fullmatch(PATTERN, squore)
|
|
67
|
+
|
|
68
|
+
if m is None:
|
|
69
|
+
raise ValueError(f"Not a Squore line I can parse: {squore}")
|
|
70
|
+
|
|
71
|
+
d = m.groupdict()
|
|
72
|
+
|
|
73
|
+
winner = int((a := d["AX"]) == "--") # bool to int: True→1, False→0
|
|
74
|
+
server, side = None, None
|
|
75
|
+
data: PointDataType
|
|
76
|
+
match a, d["BX"]:
|
|
77
|
+
case ("ST", _) | (_, "ST"):
|
|
78
|
+
data = "S"
|
|
79
|
+
case ("YL", _) | (_, "YL"):
|
|
80
|
+
data = "L"
|
|
81
|
+
case ("NL", _) | (_, "NL"):
|
|
82
|
+
data = "N"
|
|
83
|
+
case _:
|
|
84
|
+
try:
|
|
85
|
+
winner = PlayerIndexValidator.validate_python(
|
|
86
|
+
# bool to int: True→1, False→0
|
|
87
|
+
int(d["AP"] == "-")
|
|
88
|
+
)
|
|
89
|
+
server = PlayerIndexValidator.validate_python(
|
|
90
|
+
# bool to int: True→1, False→0
|
|
91
|
+
int(d["AS"] == "-")
|
|
92
|
+
)
|
|
93
|
+
side = ServerSideValidator.validate_python(
|
|
94
|
+
a if (a := d["AS"]) != "-" else d["BS"]
|
|
95
|
+
)
|
|
96
|
+
data = PointDataTypeValidator.validate_python(
|
|
97
|
+
a if (a := d["AP"]) != "-" else d["BP"]
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
except ValidationError as err:
|
|
101
|
+
raise ValueError(f"Not a Squore line I can parse: {squore}") from err
|
|
102
|
+
|
|
103
|
+
return Point(
|
|
104
|
+
server=server,
|
|
105
|
+
serveside=side,
|
|
106
|
+
data=data,
|
|
107
|
+
player=1 if winner else 0,
|
|
108
|
+
duration=duration,
|
|
109
|
+
)
|
squoredata/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: squoredata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-contained Pydantic data structures used by Squore tablets
|
|
5
|
+
Author-email: "martin f. krafft" <squoredata@pobox.madduck.net>
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.13
|
|
11
|
+
Requires-Dist: pydantic
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: coverage; extra == 'dev'
|
|
14
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest-asyncio; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
18
|
+
Requires-Dist: pytest-mock; extra == 'dev'
|
|
19
|
+
Requires-Dist: pytest-ruff; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# squoredata
|
|
24
|
+
|
|
25
|
+
Self-contained [Pydantic](https://docs.pydantic.dev) v2 data structures used by
|
|
26
|
+
[Squore](https://github.com/obbimi/Squore) scoreboard tablets.
|
|
27
|
+
|
|
28
|
+
The package was extracted from [`tcboard`](https://github.com/madduck/tcboard).
|
|
29
|
+
It contains only the data models, not the surrounding server logic. TypeScript
|
|
30
|
+
bindings are generated from the models so that a JavaScript or TypeScript
|
|
31
|
+
project can consume the same shape.
|
|
32
|
+
|
|
33
|
+
## Models
|
|
34
|
+
|
|
35
|
+
- `SquoreMatch` — a match as used by Squore, without any live information.
|
|
36
|
+
- `SquoreMatchLiveData` — a `SquoreMatch` together with the live data a tablet
|
|
37
|
+
reports. It adds the `court` and `timestamp` fields.
|
|
38
|
+
|
|
39
|
+
Supporting models: `Players`, `Colors`, `Countries`, `Clubs`, `Event`, `Format`,
|
|
40
|
+
`Wifi`, `Metadata`, `SquoreDeviceInfo`, `TimerInfo`, `Timing`, `When`, `Point`,
|
|
41
|
+
`Game`, and the `LiveStatus` enum.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
pip install squoredata
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## TypeScript bindings
|
|
50
|
+
|
|
51
|
+
`scripts/generate_ts.py` renders the JSON Schema of each model and converts it
|
|
52
|
+
to TypeScript with
|
|
53
|
+
[`json-schema-to-typescript`](https://www.npmjs.com/package/json-schema-to-typescript).
|
|
54
|
+
The output lives in `typescript/` and is committed:
|
|
55
|
+
|
|
56
|
+
- `SquoreMatch.d.ts`, `SquoreMatchLiveData.d.ts` — the interfaces
|
|
57
|
+
- `SquoreMatch.schema.json`, `SquoreMatchLiveData.schema.json` — the schemata
|
|
58
|
+
- `index.d.ts` — re-exports
|
|
59
|
+
|
|
60
|
+
The Hatch build hook (`hatch_build.py`) runs the generator before a wheel is
|
|
61
|
+
built, so a release can never carry stale bindings.
|
|
62
|
+
|
|
63
|
+
Install the generated bindings into another project from the `typescript/`
|
|
64
|
+
directory, or from a published `squoredata-types` package:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
npm install /path/to/squoredata/typescript
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Golden tests
|
|
71
|
+
|
|
72
|
+
The files in `golden/` are the shared contract between the two sides:
|
|
73
|
+
|
|
74
|
+
- `tests/test_golden.py` serialises and re-validates the models against the
|
|
75
|
+
golden JSON.
|
|
76
|
+
- `typescript/tests/golden.ts` assigns the same JSON to the generated
|
|
77
|
+
interfaces, so `tsc --noEmit` fails when the two sides diverge.
|
|
78
|
+
|
|
79
|
+
Regenerate the golden files with `python scripts/make_golden.py` after a model
|
|
80
|
+
change.
|
|
81
|
+
|
|
82
|
+
## Development
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
pip install -e .[dev]
|
|
86
|
+
ruff check .
|
|
87
|
+
pytest
|
|
88
|
+
mypy --strict .
|
|
89
|
+
|
|
90
|
+
npm install
|
|
91
|
+
node_modules/.bin/tsc --noEmit -p tsconfig.json
|
|
92
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
squoredata/__init__.py,sha256=V2-mhv1O2mLD4AD74TF4BAIHfRqWxLdKPaTDDXMSbQ4,1359
|
|
2
|
+
squoredata/devinfo.py,sha256=N5KfBVzQvdWVKo90ulCkSsZHx4F0oU8JoXivv7S1X_k,1686
|
|
3
|
+
squoredata/game.py,sha256=cou5uHA5Tje2g2Ppu9fHhCP9pIaYpFRbemDmNdFHTgY,1146
|
|
4
|
+
squoredata/livedata.py,sha256=R6Nj4Bs9T14f4om4XHDU48MhCQRbKTQx8kxEqTnN_1Q,6933
|
|
5
|
+
squoredata/livestatus.py,sha256=8bWQoZBBftSPuDyb0fDlBaXVQkysvliKfvwnkA7Y12k,1558
|
|
6
|
+
squoredata/match.py,sha256=OLCQZ52WNoOOIGmharNOnbkyyLXLcKDHwh1dwR7VgHE,1887
|
|
7
|
+
squoredata/point.py,sha256=-s9EwOHy_G9X91pV-D4yY6SYkPvdLkFW2VQ-NKReU1A,3171
|
|
8
|
+
squoredata/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
squoredata-0.1.0.dist-info/METADATA,sha256=lAgmvWSLTU4aPXS1tEy5ZzsTLwfpKn8h-J2eGJSnFjE,2997
|
|
10
|
+
squoredata-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
squoredata-0.1.0.dist-info/licenses/LICENSE,sha256=7fO2Xb6dFmiG-ouqDpqSjikytIJF-y2An2ga8jVGLb4,1073
|
|
12
|
+
squoredata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 martin f. krafft
|
|
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.
|