microcycler 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.
@@ -0,0 +1,24 @@
1
+ .DS_Store
2
+ .env
3
+
4
+ # Generated
5
+ RELEASE_NOTES.md
6
+
7
+ # Dashboard
8
+ dashboard/node_modules/
9
+ dashboard/.react-router/
10
+ dashboard/build/
11
+
12
+ # Firmware
13
+ firmware/**/*.o
14
+ firmware/**/*.elf
15
+ firmware/**/*.bin
16
+ firmware/**/*.hex
17
+ firmware/**/*.map
18
+ firmware/build/
19
+
20
+ # Scripts
21
+ scripts/node_modules/
22
+
23
+ # Keys
24
+ keys/
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: microcycler
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Microcycler battery testing platform
5
+ Project-URL: Homepage, https://github.com/your-org/microcycler
6
+ Project-URL: Documentation, https://docs.microcycler.io
7
+ Project-URL: Issues, https://github.com/your-org/microcycler/issues
8
+ License: MIT
9
+ Keywords: EIS,battery,cycler,electrochemistry,lab,testing
10
+ Classifier: Development Status :: 2 - Pre-Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
19
+ Classifier: Topic :: Scientific/Engineering :: Physics
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: all
22
+ Requires-Dist: numpy>=1.24; extra == 'all'
23
+ Requires-Dist: pandas>=2.0; extra == 'all'
24
+ Provides-Extra: dev
25
+ Requires-Dist: build; extra == 'dev'
26
+ Requires-Dist: pytest; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio; extra == 'dev'
28
+ Requires-Dist: twine; extra == 'dev'
29
+ Provides-Extra: numpy
30
+ Requires-Dist: numpy>=1.24; extra == 'numpy'
31
+ Provides-Extra: pandas
32
+ Requires-Dist: pandas>=2.0; extra == 'pandas'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Microcycler Python SDK
36
+
37
+ Python SDK for the [Microcycler](https://github.com/your-org/microcycler) battery testing platform.
38
+
39
+ > **Status:** Pre-alpha — API subject to change.
40
+
41
+ ## Installation
42
+
43
+ ```bash
44
+ pip install microcycler
45
+ ```
46
+
47
+ ## Quickstart
48
+
49
+ ### Connect and read telemetry
50
+
51
+ ```python
52
+ import asyncio
53
+ from microcycler import Cycler
54
+
55
+ async def main():
56
+ async with Cycler("192.168.1.100") as cycler:
57
+ print(await cycler.identify())
58
+ t = await cycler.get_telemetry()
59
+ print(f"State: {t.state}")
60
+ print(f"Voltage: {t.voltage:.3f} V")
61
+ print(f"Current: {t.current:.3f} A")
62
+ print(f"Temp: {t.temperature:.1f} °C")
63
+ print(f"Capacity: {t.capacity_mah:.1f} mAh")
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ### Discover cyclers on the network
69
+
70
+ ```python
71
+ from microcycler.streaming import discover
72
+
73
+ hosts = asyncio.run(discover())
74
+ print(hosts) # ['192.168.1.100', '192.168.1.101']
75
+ ```
76
+
77
+ ### Stream live telemetry via UDP
78
+
79
+ ```python
80
+ from microcycler.streaming import CyclerStream
81
+
82
+ async def monitor():
83
+ async with CyclerStream() as stream:
84
+ async for telemetry in stream:
85
+ print(telemetry.voltage, telemetry.current)
86
+ ```
87
+
88
+ ### Build and run a recipe
89
+
90
+ ```python
91
+ from microcycler import Cycler
92
+ from microcycler.recipes import Recipe, CC, CV, Rest, Loop
93
+
94
+ recipe = (
95
+ Recipe(name="Standard Formation")
96
+ .add(CC(current=0.1, cutoff_voltage=4.2))
97
+ .add(CV(voltage=4.2, cutoff_current=0.02))
98
+ .add(Rest(duration=1800))
99
+ .add(CC(current=-0.2, cutoff_voltage=2.8))
100
+ .add(Rest(duration=1800))
101
+ .add(Loop(cycles=3))
102
+ )
103
+
104
+ async def run():
105
+ async with Cycler("192.168.1.100") as cycler:
106
+ await cycler.load_recipe(recipe)
107
+ await cycler.start()
108
+ ```
109
+
110
+ ## Recipe steps
111
+
112
+ | Step | Description |
113
+ |------|-------------|
114
+ | `CC(current, cutoff_voltage, cutoff_time, cutoff_capacity)` | Constant current |
115
+ | `CV(voltage, cutoff_current, cutoff_time)` | Constant voltage |
116
+ | `CP(power, cutoff_voltage, cutoff_time)` | Constant power |
117
+ | `CR(resistance, cutoff_voltage, cutoff_time)` | Constant resistance |
118
+ | `Rest(duration)` | Open-circuit rest |
119
+ | `EIS(freq_start, freq_end, amplitude, points_per_decade)` | Impedance sweep |
120
+ | `Loop(cycles)` | Repeat preceding steps N times |
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,90 @@
1
+ # Microcycler Python SDK
2
+
3
+ Python SDK for the [Microcycler](https://github.com/your-org/microcycler) battery testing platform.
4
+
5
+ > **Status:** Pre-alpha — API subject to change.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install microcycler
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ### Connect and read telemetry
16
+
17
+ ```python
18
+ import asyncio
19
+ from microcycler import Cycler
20
+
21
+ async def main():
22
+ async with Cycler("192.168.1.100") as cycler:
23
+ print(await cycler.identify())
24
+ t = await cycler.get_telemetry()
25
+ print(f"State: {t.state}")
26
+ print(f"Voltage: {t.voltage:.3f} V")
27
+ print(f"Current: {t.current:.3f} A")
28
+ print(f"Temp: {t.temperature:.1f} °C")
29
+ print(f"Capacity: {t.capacity_mah:.1f} mAh")
30
+
31
+ asyncio.run(main())
32
+ ```
33
+
34
+ ### Discover cyclers on the network
35
+
36
+ ```python
37
+ from microcycler.streaming import discover
38
+
39
+ hosts = asyncio.run(discover())
40
+ print(hosts) # ['192.168.1.100', '192.168.1.101']
41
+ ```
42
+
43
+ ### Stream live telemetry via UDP
44
+
45
+ ```python
46
+ from microcycler.streaming import CyclerStream
47
+
48
+ async def monitor():
49
+ async with CyclerStream() as stream:
50
+ async for telemetry in stream:
51
+ print(telemetry.voltage, telemetry.current)
52
+ ```
53
+
54
+ ### Build and run a recipe
55
+
56
+ ```python
57
+ from microcycler import Cycler
58
+ from microcycler.recipes import Recipe, CC, CV, Rest, Loop
59
+
60
+ recipe = (
61
+ Recipe(name="Standard Formation")
62
+ .add(CC(current=0.1, cutoff_voltage=4.2))
63
+ .add(CV(voltage=4.2, cutoff_current=0.02))
64
+ .add(Rest(duration=1800))
65
+ .add(CC(current=-0.2, cutoff_voltage=2.8))
66
+ .add(Rest(duration=1800))
67
+ .add(Loop(cycles=3))
68
+ )
69
+
70
+ async def run():
71
+ async with Cycler("192.168.1.100") as cycler:
72
+ await cycler.load_recipe(recipe)
73
+ await cycler.start()
74
+ ```
75
+
76
+ ## Recipe steps
77
+
78
+ | Step | Description |
79
+ |------|-------------|
80
+ | `CC(current, cutoff_voltage, cutoff_time, cutoff_capacity)` | Constant current |
81
+ | `CV(voltage, cutoff_current, cutoff_time)` | Constant voltage |
82
+ | `CP(power, cutoff_voltage, cutoff_time)` | Constant power |
83
+ | `CR(resistance, cutoff_voltage, cutoff_time)` | Constant resistance |
84
+ | `Rest(duration)` | Open-circuit rest |
85
+ | `EIS(freq_start, freq_end, amplitude, points_per_decade)` | Impedance sweep |
86
+ | `Loop(cycles)` | Repeat preceding steps N times |
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "microcycler"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the Microcycler battery testing platform"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = ["battery", "cycler", "electrochemistry", "EIS", "testing", "lab"]
13
+ classifiers = [
14
+ "Development Status :: 2 - Pre-Alpha",
15
+ "Intended Audience :: Science/Research",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Chemistry",
23
+ "Topic :: Scientific/Engineering :: Physics",
24
+ ]
25
+
26
+ dependencies = []
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest", "pytest-asyncio", "build", "twine"]
30
+ numpy = ["numpy>=1.24"]
31
+ pandas = ["pandas>=2.0"]
32
+ all = ["numpy>=1.24", "pandas>=2.0"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/your-org/microcycler"
36
+ Documentation = "https://docs.microcycler.io"
37
+ Issues = "https://github.com/your-org/microcycler/issues"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/microcycler"]
@@ -0,0 +1,48 @@
1
+ """
2
+ Microcycler Python SDK
3
+ ======================
4
+
5
+ Python SDK for the Microcycler battery testing platform.
6
+
7
+ Basic usage::
8
+
9
+ import asyncio
10
+ from microcycler import Cycler
11
+
12
+ async def main():
13
+ async with Cycler("192.168.1.100") as cycler:
14
+ telemetry = await cycler.get_telemetry()
15
+ print(f"Voltage: {telemetry.voltage:.3f} V")
16
+ print(f"Current: {telemetry.current:.3f} A")
17
+
18
+ asyncio.run(main())
19
+
20
+ Recipe usage::
21
+
22
+ from microcycler import Cycler
23
+ from microcycler.recipes import Recipe, CC, CV, Rest, Loop
24
+
25
+ recipe = (
26
+ Recipe()
27
+ .add(CC(current=1.0, cutoff_voltage=4.2))
28
+ .add(CV(voltage=4.2, cutoff_current=0.05))
29
+ .add(Rest(duration=300))
30
+ .add(Loop(cycles=100))
31
+ )
32
+
33
+ async with Cycler("192.168.1.100") as cycler:
34
+ await cycler.load_recipe(recipe)
35
+ await cycler.start()
36
+ """
37
+
38
+ from .client import Cycler
39
+ from .types import CyclerTelemetry, CyclerHealth, CyclerLimits, CyclerState
40
+
41
+ __version__ = "0.1.0"
42
+ __all__ = [
43
+ "Cycler",
44
+ "CyclerTelemetry",
45
+ "CyclerHealth",
46
+ "CyclerLimits",
47
+ "CyclerState",
48
+ ]
@@ -0,0 +1,187 @@
1
+ """
2
+ TCP client for communicating with a Microcycler device.
3
+
4
+ The cycler exposes a SCPI-like command interface over TCP (port 5025) and
5
+ broadcasts UDP telemetry packets for discovery and streaming (port 5026).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ from types import TracebackType
13
+ from typing import Optional, Type
14
+
15
+ from .recipes import Recipe
16
+ from .types import CyclerHealth, CyclerTelemetry
17
+
18
+ _TCP_PORT = 5025
19
+ _CONNECT_TIMEOUT = 5.0
20
+ _COMMAND_TIMEOUT = 10.0
21
+
22
+
23
+ class CyclerError(Exception):
24
+ """Raised when the cycler returns an error response."""
25
+
26
+
27
+ class Cycler:
28
+ """
29
+ Async TCP client for a single Microcycler unit.
30
+
31
+ Usage as an async context manager is recommended::
32
+
33
+ async with Cycler("192.168.1.100") as cycler:
34
+ telemetry = await cycler.get_telemetry()
35
+
36
+ You may also manage the connection manually::
37
+
38
+ cycler = Cycler("192.168.1.100")
39
+ await cycler.connect()
40
+ try:
41
+ await cycler.start()
42
+ finally:
43
+ await cycler.disconnect()
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ host: str,
49
+ port: int = _TCP_PORT,
50
+ connect_timeout: float = _CONNECT_TIMEOUT,
51
+ command_timeout: float = _COMMAND_TIMEOUT,
52
+ ) -> None:
53
+ self.host = host
54
+ self.port = port
55
+ self._connect_timeout = connect_timeout
56
+ self._command_timeout = command_timeout
57
+ self._reader: Optional[asyncio.StreamReader] = None
58
+ self._writer: Optional[asyncio.StreamWriter] = None
59
+ self._lock = asyncio.Lock()
60
+
61
+ # ------------------------------------------------------------------
62
+ # Connection management
63
+ # ------------------------------------------------------------------
64
+
65
+ async def connect(self) -> None:
66
+ """Open the TCP connection to the cycler."""
67
+ self._reader, self._writer = await asyncio.wait_for(
68
+ asyncio.open_connection(self.host, self.port),
69
+ timeout=self._connect_timeout,
70
+ )
71
+
72
+ async def disconnect(self) -> None:
73
+ """Close the TCP connection."""
74
+ if self._writer is not None:
75
+ self._writer.close()
76
+ try:
77
+ await self._writer.wait_closed()
78
+ except Exception:
79
+ pass
80
+ self._writer = None
81
+ self._reader = None
82
+
83
+ async def __aenter__(self) -> "Cycler":
84
+ await self.connect()
85
+ return self
86
+
87
+ async def __aexit__(
88
+ self,
89
+ exc_type: Optional[Type[BaseException]],
90
+ exc_val: Optional[BaseException],
91
+ exc_tb: Optional[TracebackType],
92
+ ) -> None:
93
+ await self.disconnect()
94
+
95
+ # ------------------------------------------------------------------
96
+ # Low-level transport
97
+ # ------------------------------------------------------------------
98
+
99
+ async def _send(self, command: str) -> str:
100
+ """Send a SCPI command and return the response line."""
101
+ if self._writer is None or self._reader is None:
102
+ raise CyclerError("Not connected. Call connect() or use as async context manager.")
103
+
104
+ async with self._lock:
105
+ self._writer.write((command.strip() + "\n").encode())
106
+ await self._writer.drain()
107
+ response = await asyncio.wait_for(
108
+ self._reader.readline(),
109
+ timeout=self._command_timeout,
110
+ )
111
+ line = response.decode().strip()
112
+ if line.startswith("ERR"):
113
+ raise CyclerError(line)
114
+ return line
115
+
116
+ async def _query_json(self, command: str) -> dict:
117
+ raw = await self._send(command)
118
+ return json.loads(raw)
119
+
120
+ # ------------------------------------------------------------------
121
+ # Queries
122
+ # ------------------------------------------------------------------
123
+
124
+ async def get_telemetry(self) -> CyclerTelemetry:
125
+ """Return the current cell telemetry snapshot."""
126
+ data = await self._query_json("MEAS:TEL?")
127
+ return CyclerTelemetry.from_dict(data)
128
+
129
+ async def get_health(self) -> CyclerHealth:
130
+ """Return the cycler hardware health snapshot."""
131
+ data = await self._query_json("MEAS:HEALTH?")
132
+ return CyclerHealth.from_dict(data)
133
+
134
+ async def identify(self) -> str:
135
+ """Return the cycler identification string (*IDN?)."""
136
+ return await self._send("*IDN?")
137
+
138
+ # ------------------------------------------------------------------
139
+ # Control
140
+ # ------------------------------------------------------------------
141
+
142
+ async def load_recipe(self, recipe: Recipe) -> None:
143
+ """Upload a recipe to the cycler's RAM."""
144
+ payload = json.dumps(recipe.to_dict())
145
+ await self._send(f"REC:LOAD {payload}")
146
+
147
+ async def start(self) -> None:
148
+ """Start the loaded recipe."""
149
+ await self._send("REC:START")
150
+
151
+ async def pause(self) -> None:
152
+ """Pause the running recipe."""
153
+ await self._send("REC:PAUSE")
154
+
155
+ async def resume(self) -> None:
156
+ """Resume a paused recipe."""
157
+ await self._send("REC:RESUME")
158
+
159
+ async def stop(self) -> None:
160
+ """Abort the running recipe and return to IDLE."""
161
+ await self._send("REC:STOP")
162
+
163
+ async def set_output(self, *, voltage: float, current: float) -> None:
164
+ """
165
+ Raw output override (host must remain connected).
166
+ Intended for direct/manual control outside of a recipe.
167
+ """
168
+ await self._send(f"OUT:VOLT {voltage:.4f};OUT:CURR {current:.4f}")
169
+
170
+ async def set_limits(
171
+ self,
172
+ *,
173
+ voltage_upper: float,
174
+ voltage_lower: float,
175
+ current_upper: float,
176
+ temperature_upper: float,
177
+ ) -> None:
178
+ """Update the safety limits on the cycler."""
179
+ payload = json.dumps(
180
+ {
181
+ "voltage_upper": voltage_upper,
182
+ "voltage_lower": voltage_lower,
183
+ "current_upper": current_upper,
184
+ "temperature_upper": temperature_upper,
185
+ }
186
+ )
187
+ await self._send(f"LIM:SET {payload}")
@@ -0,0 +1,130 @@
1
+ """
2
+ Recipe DSL for building cycler test sequences.
3
+
4
+ A Recipe is an ordered list of steps. Steps are serialized to JSON and
5
+ uploaded to the cycler via the TCP client (REC:LOAD command).
6
+
7
+ Example::
8
+
9
+ from microcycler.recipes import Recipe, CC, CV, Rest, Loop
10
+
11
+ recipe = (
12
+ Recipe(name="Formation C/10")
13
+ .add(CC(current=0.1, cutoff_voltage=4.2, cutoff_time=36000))
14
+ .add(CV(voltage=4.2, cutoff_current=0.02))
15
+ .add(Rest(duration=1800))
16
+ .add(CC(current=-0.1, cutoff_voltage=2.8)) # discharge
17
+ .add(Rest(duration=1800))
18
+ .add(Loop(cycles=3))
19
+ )
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import asdict, dataclass, field
25
+ from typing import Any, List, Optional
26
+
27
+
28
+ # ------------------------------------------------------------------
29
+ # Step base
30
+ # ------------------------------------------------------------------
31
+
32
+
33
+ @dataclass
34
+ class _Step:
35
+ def to_dict(self) -> dict:
36
+ d = asdict(self)
37
+ d["type"] = self.__class__.__name__.upper()
38
+ return {k: v for k, v in d.items() if v is not None}
39
+
40
+
41
+ # ------------------------------------------------------------------
42
+ # Concrete step types
43
+ # ------------------------------------------------------------------
44
+
45
+
46
+ @dataclass
47
+ class CC(_Step):
48
+ """Constant-current step."""
49
+ current: float
50
+ cutoff_voltage: Optional[float] = None
51
+ cutoff_time: Optional[float] = None # seconds
52
+ cutoff_capacity: Optional[float] = None # mAh
53
+
54
+
55
+ @dataclass
56
+ class CV(_Step):
57
+ """Constant-voltage step."""
58
+ voltage: float
59
+ cutoff_current: Optional[float] = None
60
+ cutoff_time: Optional[float] = None
61
+
62
+
63
+ @dataclass
64
+ class CP(_Step):
65
+ """Constant-power step."""
66
+ power: float # watts; positive = charge
67
+ cutoff_voltage: Optional[float] = None
68
+ cutoff_time: Optional[float] = None
69
+
70
+
71
+ @dataclass
72
+ class CR(_Step):
73
+ """Constant-resistance step."""
74
+ resistance: float # ohms
75
+ cutoff_voltage: Optional[float] = None
76
+ cutoff_time: Optional[float] = None
77
+
78
+
79
+ @dataclass
80
+ class Rest(_Step):
81
+ """Open-circuit rest step."""
82
+ duration: float # seconds
83
+ cutoff_voltage: Optional[float] = None
84
+
85
+
86
+ @dataclass
87
+ class Loop(_Step):
88
+ """Repeat all preceding steps N times."""
89
+ cycles: int
90
+
91
+
92
+ @dataclass
93
+ class EIS(_Step):
94
+ """Electrochemical Impedance Spectroscopy sweep."""
95
+ freq_start: float = 100_000.0 # Hz
96
+ freq_end: float = 0.01 # Hz
97
+ amplitude: float = 0.01 # A (perturbation amplitude)
98
+ points_per_decade: int = 10
99
+
100
+
101
+ # ------------------------------------------------------------------
102
+ # Recipe container
103
+ # ------------------------------------------------------------------
104
+
105
+
106
+ class Recipe:
107
+ """
108
+ An ordered sequence of cycler steps.
109
+
110
+ Methods are chainable::
111
+
112
+ recipe = Recipe().add(CC(...)).add(CV(...)).add(Rest(...))
113
+ """
114
+
115
+ def __init__(self, name: str = "Untitled Recipe") -> None:
116
+ self.name = name
117
+ self._steps: List[_Step] = []
118
+
119
+ def add(self, step: _Step) -> "Recipe":
120
+ self._steps.append(step)
121
+ return self
122
+
123
+ def to_dict(self) -> dict:
124
+ return {
125
+ "name": self.name,
126
+ "steps": [s.to_dict() for s in self._steps],
127
+ }
128
+
129
+ def __repr__(self) -> str:
130
+ return f"Recipe(name={self.name!r}, steps={len(self._steps)})"
@@ -0,0 +1,115 @@
1
+ """
2
+ Async UDP telemetry streaming.
3
+
4
+ The cycler broadcasts telemetry as UDP JSON packets on port 5026.
5
+ Use CyclerStream to consume these packets as an async iterator, or
6
+ use discover() to find cyclers on the local network.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import socket
14
+ from typing import AsyncIterator, List
15
+
16
+ from .types import CyclerTelemetry
17
+
18
+ _UDP_PORT = 5026
19
+ _BROADCAST_ADDR = "255.255.255.255"
20
+ _DISCOVERY_TIMEOUT = 3.0
21
+ _MAX_PACKET = 4096
22
+
23
+
24
+ class _UDPProtocol(asyncio.DatagramProtocol):
25
+ def __init__(self, queue: asyncio.Queue) -> None:
26
+ self._queue = queue
27
+
28
+ def datagram_received(self, data: bytes, addr: tuple) -> None:
29
+ self._queue.put_nowait((data, addr))
30
+
31
+ def error_received(self, exc: Exception) -> None:
32
+ pass
33
+
34
+
35
+ class CyclerStream:
36
+ """
37
+ Async iterator that yields CyclerTelemetry from UDP broadcast packets.
38
+
39
+ Usage::
40
+
41
+ async for telemetry in CyclerStream():
42
+ print(telemetry.voltage, telemetry.current)
43
+ """
44
+
45
+ def __init__(self, host: str = "", port: int = _UDP_PORT) -> None:
46
+ self._host = host
47
+ self._port = port
48
+ self._transport: asyncio.BaseTransport | None = None
49
+ self._queue: asyncio.Queue = asyncio.Queue()
50
+
51
+ async def __aenter__(self) -> "CyclerStream":
52
+ loop = asyncio.get_running_loop()
53
+ self._transport, _ = await loop.create_datagram_endpoint(
54
+ lambda: _UDPProtocol(self._queue),
55
+ local_addr=(self._host, self._port),
56
+ allow_broadcast=True,
57
+ )
58
+ return self
59
+
60
+ async def __aexit__(self, *_) -> None:
61
+ if self._transport:
62
+ self._transport.close()
63
+
64
+ def __aiter__(self) -> AsyncIterator[CyclerTelemetry]:
65
+ return self
66
+
67
+ async def __anext__(self) -> CyclerTelemetry:
68
+ data, _ = await self._queue.get()
69
+ return CyclerTelemetry.from_dict(json.loads(data))
70
+
71
+
72
+ async def discover(
73
+ timeout: float = _DISCOVERY_TIMEOUT,
74
+ port: int = _UDP_PORT,
75
+ ) -> List[str]:
76
+ """
77
+ Send a broadcast discovery ping and collect responding cycler IPs.
78
+
79
+ Returns a list of IP address strings.
80
+
81
+ Usage::
82
+
83
+ hosts = await discover()
84
+ # ['192.168.1.100', '192.168.1.101']
85
+ """
86
+ found: dict[str, bool] = {}
87
+ queue: asyncio.Queue = asyncio.Queue()
88
+
89
+ loop = asyncio.get_running_loop()
90
+
91
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
92
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
93
+ sock.setblocking(False)
94
+
95
+ transport, _ = await loop.create_datagram_endpoint(
96
+ lambda: _UDPProtocol(queue),
97
+ sock=sock,
98
+ )
99
+
100
+ try:
101
+ transport.sendto(b"DISC?\n", (_BROADCAST_ADDR, port))
102
+ deadline = loop.time() + timeout
103
+ while True:
104
+ remaining = deadline - loop.time()
105
+ if remaining <= 0:
106
+ break
107
+ try:
108
+ _, addr = await asyncio.wait_for(queue.get(), timeout=remaining)
109
+ found[addr[0]] = True
110
+ except asyncio.TimeoutError:
111
+ break
112
+ finally:
113
+ transport.close()
114
+
115
+ return list(found.keys())
@@ -0,0 +1,66 @@
1
+ """
2
+ Shared data types mirroring the dashboard's TypeScript definitions in
3
+ dashboard/app/lib/types.ts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Tuple
11
+
12
+
13
+ class CyclerState(str, Enum):
14
+ IDLE = "IDLE"
15
+ CHARGING = "CHARGING"
16
+ DISCHARGING = "DISCHARGING"
17
+ REST = "REST"
18
+ COMPLETE = "COMPLETE"
19
+ ERROR = "ERROR"
20
+
21
+
22
+ @dataclass
23
+ class CyclerLimits:
24
+ voltage_upper: float
25
+ voltage_lower: float
26
+ current_upper: float
27
+ temperature_upper: float
28
+
29
+
30
+ @dataclass
31
+ class CyclerTelemetry:
32
+ device_id: str
33
+ firmware: str
34
+ hardware: str
35
+ state: CyclerState
36
+ voltage: float
37
+ current: float
38
+ temperature: float
39
+ step: int
40
+ cycle: int
41
+ elapsed: float
42
+ capacity_mah: float
43
+ energy_mwh: float
44
+ limits: CyclerLimits
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: dict) -> "CyclerTelemetry":
48
+ limits = CyclerLimits(**data.pop("limits"))
49
+ return cls(**data, limits=limits)
50
+
51
+
52
+ @dataclass
53
+ class CyclerHealth:
54
+ device_id: str
55
+ board_temp: float
56
+ cpu_load_core1: float
57
+ cpu_load_core2: float
58
+ heatsink_temps: Tuple[float, float, float, float]
59
+ main_current_draw: float
60
+ fan_current_draw: float
61
+ die_temp: float
62
+
63
+ @classmethod
64
+ def from_dict(cls, data: dict) -> "CyclerHealth":
65
+ data["heatsink_temps"] = tuple(data["heatsink_temps"])
66
+ return cls(**data)