pixdial 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.
Files changed (52) hide show
  1. pixdial/__init__.py +4 -0
  2. pixdial/__main__.py +3 -0
  3. pixdial/cli.py +146 -0
  4. pixdial/config.py +229 -0
  5. pixdial/data/60-pixdial.rules +5 -0
  6. pixdial/data/pixdial.service +16 -0
  7. pixdial/demo.py +69 -0
  8. pixdial/designs/__init__.py +57 -0
  9. pixdial/designs/agents.py +83 -0
  10. pixdial/designs/base.py +144 -0
  11. pixdial/designs/clock.py +55 -0
  12. pixdial/designs/colour.py +114 -0
  13. pixdial/designs/cpu.py +229 -0
  14. pixdial/designs/frame.py +208 -0
  15. pixdial/designs/geometry.py +126 -0
  16. pixdial/designs/gpu.py +156 -0
  17. pixdial/designs/motion.py +38 -0
  18. pixdial/designs/power.py +199 -0
  19. pixdial/designs/thermals.py +51 -0
  20. pixdial/devices/__init__.py +9 -0
  21. pixdial/devices/base.py +50 -0
  22. pixdial/devices/png.py +39 -0
  23. pixdial/devices/slv3.py +169 -0
  24. pixdial/devices/usbfs.py +95 -0
  25. pixdial/doctor.py +105 -0
  26. pixdial/fonts/LICENSE-Lato.txt +108 -0
  27. pixdial/fonts/Lato-Black.ttf +0 -0
  28. pixdial/fonts/Lato-Bold.ttf +0 -0
  29. pixdial/fonts/Lato-Regular.ttf +0 -0
  30. pixdial/gallery.py +143 -0
  31. pixdial/plugins.py +55 -0
  32. pixdial/py.typed +0 -0
  33. pixdial/readings.py +106 -0
  34. pixdial/runtime/__init__.py +10 -0
  35. pixdial/runtime/feed.py +22 -0
  36. pixdial/runtime/layout.py +85 -0
  37. pixdial/runtime/report.py +22 -0
  38. pixdial/runtime/runner.py +92 -0
  39. pixdial/runtime/sampler.py +70 -0
  40. pixdial/runtime/screen.py +136 -0
  41. pixdial/sources/__init__.py +13 -0
  42. pixdial/sources/base.py +29 -0
  43. pixdial/sources/command.py +50 -0
  44. pixdial/sources/cpu.py +47 -0
  45. pixdial/sources/hwmon.py +86 -0
  46. pixdial/sources/nvml.py +74 -0
  47. pixdial-0.1.0.dist-info/METADATA +268 -0
  48. pixdial-0.1.0.dist-info/RECORD +52 -0
  49. pixdial-0.1.0.dist-info/WHEEL +4 -0
  50. pixdial-0.1.0.dist-info/entry_points.txt +2 -0
  51. pixdial-0.1.0.dist-info/licenses/LICENSE +675 -0
  52. pixdial-0.1.0.dist-info/licenses/src/pixdial/fonts/LICENSE-Lato.txt +108 -0
pixdial/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """pixdial: a Python SDK and CLI for hardware screens on Linux."""
2
+
3
+ NAME = "pixdial" # the project's name at run time: plugin entry-point groups, config and state folders, messages
4
+ __version__ = "0.1.0"
pixdial/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
pixdial/cli.py ADDED
@@ -0,0 +1,146 @@
1
+ """The command line. Exit codes: 0 done, 1 a check or a search came up short, 2 a usage or config error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import signal
7
+ import sys
8
+ import threading
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from . import NAME, __version__, demo, doctor, plugins
13
+ from . import config as cfg
14
+ from .designs import BUILTIN as DESIGNS
15
+ from .designs import THEMES, Design
16
+ from .devices import BUILTIN as DEVICES
17
+ from .devices import Device
18
+ from .gallery import Demo, gallery
19
+ from .runtime import ROTATE, Report
20
+ from .sources import BUILTIN as SOURCES
21
+ from .sources import Source
22
+
23
+ OK, SHORT, USAGE = 0, 1, 2
24
+
25
+
26
+ def main(argv: Sequence[str] | None = None) -> int:
27
+ args = parser().parse_args(argv)
28
+ try:
29
+ return int(args.command(args))
30
+ except cfg.ConfigError as error:
31
+ print(f"{NAME}: {args.config or cfg.config_path()}: {error}", file=sys.stderr)
32
+ return USAGE
33
+
34
+
35
+ def parser() -> argparse.ArgumentParser:
36
+ p = argparse.ArgumentParser(prog=NAME, description="A Python SDK and CLI for hardware screens on Linux.")
37
+ p.add_argument("--version", action="version", version=f"{NAME} {__version__}")
38
+ p.add_argument("--config", type=Path, help=f"the config file (default {cfg.config_path()})")
39
+ sub = p.add_subparsers(required=True, metavar="COMMAND")
40
+
41
+ run_ = sub.add_parser("run", help="show the designs on the panels until stopped")
42
+ run_.set_defaults(command=run)
43
+
44
+ show_ = sub.add_parser("show", help="pick the designs a running `run` shows; alone, the current ones")
45
+ show_.add_argument("specs", nargs="*", metavar="DESIGN", help=f"one per panel (name or name:theme), or {ROTATE}")
46
+ show_.set_defaults(command=show)
47
+
48
+ list_ = sub.add_parser("list", help="the designs, themes, sources and devices")
49
+ list_.set_defaults(command=list_all)
50
+
51
+ devices_ = sub.add_parser("devices", help="the panels attached now")
52
+ devices_.set_defaults(command=devices)
53
+
54
+ render_ = sub.add_parser("render", help="designs as PNG files from demo readings, without hardware")
55
+ render_.add_argument("specs", nargs="*", metavar="DESIGN", help="name or name:theme (default: every design)")
56
+ render_.add_argument("--state", default="busy", choices=demo.STATES, help="the demo readings (default busy)")
57
+ render_.add_argument("--scale", type=int, default=1, choices=(1, 2, 3, 4), help="1 is the panel's 400 px")
58
+ render_.add_argument("--out", type=Path, default=Path(), help="the folder to write to (default here)")
59
+ render_.add_argument("--gallery", type=Path, metavar="DIR", help="every design, sheets and gallery.html, to DIR")
60
+ render_.set_defaults(command=render)
61
+
62
+ doctor_ = sub.add_parser("doctor", help="check the config, panels, permissions and sources")
63
+ doctor_.set_defaults(command=check)
64
+ return p
65
+
66
+
67
+ def run(args: argparse.Namespace) -> int:
68
+ runner = cfg.runner(cfg.load(args.config), Report())
69
+ stop = threading.Event()
70
+ for sig in (signal.SIGTERM, signal.SIGINT):
71
+ signal.signal(sig, lambda *_: stop.set())
72
+ runner.run(stop)
73
+ return OK
74
+
75
+
76
+ def show(args: argparse.Namespace) -> int:
77
+ config = cfg.load(args.config)
78
+ layout = cfg.layout(config, cfg.catalog(config))
79
+ if args.specs:
80
+ try:
81
+ layout.save(args.specs)
82
+ except ValueError as error:
83
+ print(f"{NAME}: {error}; the panels keep {' '.join(layout.load())}", file=sys.stderr)
84
+ return USAGE
85
+ print(" ".join(layout.load()))
86
+ return OK
87
+
88
+
89
+ def list_all(args: argparse.Namespace) -> int:
90
+ config = cfg.load(args.config)
91
+ designs, broken = plugins.available("designs", Design, DESIGNS)
92
+ print("designs:")
93
+ for name, cls in designs.items():
94
+ print(f" {name:10} {cls.shows}{f' (animated, {cls.fps} frames/s)' if cls.fps else ''}")
95
+ print(f"themes: {', '.join(THEMES)} (name:theme; {config.theme} is the default)")
96
+ for kind, base, builtin in (("sources", Source, SOURCES), ("devices", Device, DEVICES)):
97
+ found, more = plugins.available(kind, base, builtin)
98
+ print(f"{kind}: {', '.join(found)}")
99
+ broken |= more
100
+ for name, why in broken.items():
101
+ print(f"broken plugin {name}: {why}", file=sys.stderr)
102
+ return OK
103
+
104
+
105
+ def devices(args: argparse.Namespace) -> int:
106
+ panels = {device.id: name for name, device in cfg.devices(cfg.load(args.config))}
107
+ found = 0
108
+ for kind, cls in plugins.available("devices", Device, DEVICES)[0].items():
109
+ for panel in cls.discover():
110
+ found += 1
111
+ access = "ok" if panel.access else "no access (`doctor` says how to fix it)"
112
+ where = f"panel {panels[panel.id]!r}" if panel.id in panels else "not in the config"
113
+ print(f"{kind} {panel.id} {panel.where} {access} {where}")
114
+ if not found:
115
+ print("no panels attached", file=sys.stderr)
116
+ return OK if found else SHORT
117
+
118
+
119
+ def render(args: argparse.Namespace) -> int:
120
+ config = cfg.load(args.config)
121
+ catalog = cfg.catalog(config)
122
+ if args.gallery:
123
+ gallery(args.gallery, catalog, config.readings)
124
+ print(args.gallery / "gallery.html")
125
+ return OK
126
+ specs = args.specs or list(catalog.designs)
127
+ for spec in specs:
128
+ try:
129
+ catalog.check(spec)
130
+ except ValueError as error:
131
+ print(f"{NAME}: {error}", file=sys.stderr)
132
+ return USAGE
133
+ args.out.mkdir(parents=True, exist_ok=True)
134
+ for spec in specs:
135
+ path = args.out / f"{spec.replace(':', '-')}-{args.state}@{args.scale}x.png"
136
+ Demo(catalog.make(spec, args.scale), args.state, config.readings).render().save(path)
137
+ print(path)
138
+ return OK
139
+
140
+
141
+ def check(args: argparse.Namespace) -> int:
142
+ problems = 0
143
+ for c in doctor.checks(args.config):
144
+ problems += not c.ok
145
+ print(f"{'ok' if c.ok else 'FAIL':4} {c.what:16} {c.detail}", flush=True)
146
+ return OK if not problems else SHORT
pixdial/config.py ADDED
@@ -0,0 +1,229 @@
1
+ """The config file (TOML, under XDG_CONFIG_HOME), checked with errors that say where, and what it builds.
2
+
3
+ A ConfigError's message says where in the file; the caller adds which file.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import inspect
9
+ import itertools
10
+ import os
11
+ import tomllib
12
+ from collections import Counter
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from . import NAME, plugins
19
+ from .designs import BUILTIN as DESIGNS
20
+ from .designs import THEMES, Design
21
+ from .devices import BUILTIN as DEVICES
22
+ from .devices import Device
23
+ from .readings import Meta
24
+ from .runtime import Catalog, Feed, Layout, Report, Runner, Sampler, Screen
25
+ from .sources import BUILTIN as SOURCES
26
+ from .sources import Source
27
+
28
+
29
+ class ConfigError(ValueError):
30
+ pass
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Entry:
35
+ """A [[panels]] or [[sources]] table: its kind (a device or source name, or "module:Class") and the rest of its
36
+ keys, which go to that class as options."""
37
+
38
+ kind: str
39
+ options: Mapping[str, Any]
40
+ name: str # in log lines: the panel's name, or the source's kind (numbered when it comes twice)
41
+
42
+
43
+ DEFAULT_SOURCES = (
44
+ Entry("cpu", {}, "cpu"),
45
+ Entry("rapl", {}, "rapl"),
46
+ Entry("nvml", {}, "nvml"),
47
+ Entry("hwmon", {"readings": {"cpu_c": ["k10temp/Tctl", "coretemp/Package id 0"]}}, "hwmon"),
48
+ )
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Config:
53
+ path: Path
54
+ theme: str = "warm"
55
+ layout: tuple[str, ...] = () # the designs top to bottom, or ("rotate",); empty: the first designs
56
+ rotate_every: float = 60.0
57
+ rotation: tuple[str, ...] = () # empty: every design
58
+ panels: tuple[Entry, ...] = ()
59
+ sources: tuple[Entry, ...] = DEFAULT_SOURCES
60
+ readings: Mapping[str, Meta] = field(default_factory=dict)
61
+
62
+
63
+ def config_path() -> Path:
64
+ return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / NAME / "config.toml"
65
+
66
+
67
+ def state_path() -> Path:
68
+ """Where `show` keeps the layout for `run`."""
69
+ return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") / NAME / "layout"
70
+
71
+
72
+ def load(path: Path | None = None) -> Config:
73
+ """The config at path, or at the default place; the defaults where there is no file at the default place."""
74
+ file = path or config_path()
75
+ try:
76
+ data = tomllib.loads(file.read_text())
77
+ except FileNotFoundError:
78
+ if path is not None:
79
+ raise ConfigError("no such file") from None
80
+ data = {}
81
+ except (OSError, tomllib.TOMLDecodeError) as error:
82
+ raise ConfigError(str(error)) from error
83
+ return parse(data, file)
84
+
85
+
86
+ def parse(data: Mapping[str, Any], path: Path) -> Config:
87
+ known(data, "", "theme", "layout", "rotate_every", "rotation", "panels", "sources", "readings")
88
+ layout = get(data, "layout", (str, list), [])
89
+ every = get(data, "rotate_every", (int, float), 60.0)
90
+ if every <= 0:
91
+ raise ConfigError(f"rotate_every: must be above 0, not {every}")
92
+ panels = [panel(table, i) for i, table in enumerate(get(data, "panels", list, []))]
93
+ if dupes := [n for n, count in Counter(p.name for p in panels).items() if count > 1]:
94
+ raise ConfigError(f"panels: two panels named {dupes[0]!r}")
95
+ sources = [entry(table, f"sources[{i}]", "type") for i, table in enumerate(get(data, "sources", list, []))]
96
+ return Config(
97
+ path=path,
98
+ theme=get(data, "theme", str, "warm"),
99
+ layout=tuple(strings([layout] if isinstance(layout, str) else layout, "layout")),
100
+ rotate_every=float(every),
101
+ rotation=tuple(strings(get(data, "rotation", list, []), "rotation")),
102
+ panels=tuple(panels),
103
+ sources=tuple(numbered(sources)) if "sources" in data else DEFAULT_SOURCES,
104
+ readings={key: meta(table, f"readings.{key}") for key, table in get(data, "readings", dict, {}).items()},
105
+ )
106
+
107
+
108
+ def panel(table: Any, i: int) -> Entry:
109
+ """A panel: its device (slv3 unless it says), its name (its number unless it says), the device's options."""
110
+ where = f"panels[{i}]"
111
+ e = entry(table, where, "device", "slv3")
112
+ name = get(e.options, "name", str, str(i + 1), where)
113
+ return Entry(e.kind, {k: v for k, v in e.options.items() if k != "name"}, name)
114
+
115
+
116
+ def entry(table: Any, where: str, kind_key: str, default: str | None = None) -> Entry:
117
+ if not isinstance(table, dict):
118
+ raise ConfigError(f"{where}: must be a table")
119
+ kind = get(table, kind_key, str, default, where)
120
+ if kind is None:
121
+ raise ConfigError(f"{where}: needs {kind_key}")
122
+ return Entry(kind, {k: v for k, v in table.items() if k != kind_key}, kind)
123
+
124
+
125
+ def numbered(entries: Sequence[Entry]) -> list[Entry]:
126
+ """Sources named by their kind, numbered where a kind comes more than once."""
127
+ counts = Counter(e.kind for e in entries)
128
+ seen: Counter[str] = Counter()
129
+ out = []
130
+ for e in entries:
131
+ seen[e.kind] += 1
132
+ out.append(Entry(e.kind, e.options, f"{e.kind} {seen[e.kind]}" if counts[e.kind] > 1 else e.kind))
133
+ return out
134
+
135
+
136
+ def meta(table: Any, where: str) -> Meta:
137
+ if not isinstance(table, dict):
138
+ raise ConfigError(f"{where}: must be a table")
139
+ known(table, where, "max", "warn", "label")
140
+ return Meta(
141
+ max=get(table, "max", (int, float), None, where),
142
+ warn=get(table, "warn", (int, float), None, where),
143
+ label=get(table, "label", str, None, where),
144
+ )
145
+
146
+
147
+ def known(table: Mapping[str, Any], where: str, *keys: str) -> None:
148
+ for key in table:
149
+ if key not in keys:
150
+ raise ConfigError(f"{where + ': ' if where else ''}unknown key {key!r} (known: {', '.join(keys)})")
151
+
152
+
153
+ KINDS = {str: "text", int: "a number", float: "a number", list: "a list", dict: "a table"}
154
+
155
+
156
+ def get(table: Mapping[str, Any], key: str, kind: type | tuple[type, ...], default: Any, where: str = "") -> Any:
157
+ """table[key] if it is of that kind (TOML's true and false are not numbers), else default if it is missing."""
158
+ value = table.get(key, default)
159
+ if value is not default and (not isinstance(value, kind) or isinstance(value, bool)):
160
+ kinds = kind if isinstance(kind, tuple) else (kind,)
161
+ wanted = " or ".join(dict.fromkeys(KINDS[k] for k in kinds))
162
+ raise ConfigError(f"{where + '.' if where else ''}{key}: must be {wanted}, not {value!r}")
163
+ return value
164
+
165
+
166
+ def strings(values: list[Any], where: str) -> list[str]:
167
+ if not all(isinstance(v, str) for v in values):
168
+ raise ConfigError(f"{where}: must be a list of names")
169
+ return values
170
+
171
+
172
+ # ---------------------------------------------------------------- what it builds
173
+
174
+
175
+ def catalog(config: Config) -> Catalog:
176
+ if config.theme not in THEMES:
177
+ raise ConfigError(f"theme: no theme {config.theme!r} (themes: {', '.join(THEMES)})")
178
+ designs, _ = plugins.available("designs", Design, DESIGNS)
179
+ return Catalog(designs, THEMES, config.theme)
180
+
181
+
182
+ def layout(config: Config, catalog: Catalog) -> Layout:
183
+ names = list(catalog.designs)
184
+ rotation = config.rotation or tuple(names)
185
+ for name in rotation:
186
+ if name not in catalog.designs:
187
+ raise ConfigError(f"rotation: no design {name!r}")
188
+ default = config.layout or tuple(itertools.islice(itertools.cycle(names), len(config.panels)))
189
+ try:
190
+ return Layout(catalog, len(config.panels), state_path(), default, rotation, config.rotate_every)
191
+ except ValueError as error:
192
+ raise ConfigError(f"layout: {error}") from None
193
+
194
+
195
+ def devices(config: Config) -> list[tuple[str, Device]]:
196
+ return [(e.name, build("devices", Device, DEVICES, e, f"panels[{i}]")) for i, e in enumerate(config.panels)]
197
+
198
+
199
+ def sources(config: Config) -> list[tuple[str, Source]]:
200
+ return [(e.name, build("sources", Source, SOURCES, e, f"sources[{i}]")) for i, e in enumerate(config.sources)]
201
+
202
+
203
+ def build[C](kind: str, base: type[C], builtin: Mapping[str, type[C]], e: Entry, where: str) -> C:
204
+ """The class an entry names, made with its options."""
205
+ try:
206
+ cls = plugins.resolve(kind, base, builtin, e.kind)
207
+ except plugins.PluginError as error:
208
+ raise ConfigError(f"{where}: {error}") from None
209
+ params = inspect.signature(cls).parameters.values()
210
+ takes = [p.name for p in params if p.kind in (p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY)]
211
+ if (unknown := [k for k in e.options if k not in takes]) and all(p.kind != p.VAR_KEYWORD for p in params):
212
+ raise ConfigError(f"{where}: {e.kind} takes no {unknown[0]!r} (it takes {', '.join(takes) or 'nothing'})")
213
+ if needed := [p.name for p in params if p.name in takes and p.default is p.empty and p.name not in e.options]:
214
+ raise ConfigError(f"{where}: {e.kind} needs {', '.join(needed)}")
215
+ try:
216
+ return cls(**e.options)
217
+ except (TypeError, ValueError) as error:
218
+ raise ConfigError(f"{where}: {error}") from None
219
+
220
+
221
+ def runner(config: Config, report: Report) -> Runner:
222
+ """Everything `run` needs, checked before anything opens."""
223
+ if not config.panels:
224
+ raise ConfigError(f"no [[panels]] (`{NAME} devices` lists the panels attached)")
225
+ found = catalog(config)
226
+ plan = layout(config, found)
227
+ screens = [Screen(name, device, report) for name, device in devices(config)]
228
+ feed = Feed(config.readings, {spec for cls in found.designs.values() for spec in cls.trails})
229
+ return Runner(screens, Sampler(sources(config), report), feed, found, plan, report)
@@ -0,0 +1,5 @@
1
+ # Lets the user logged in at the machine use Lian Li UNI FAN SL V3 Wireless LCD panels (USB 1cbe:0005) without root.
2
+ # Install: sudo cp 60-pixdial.rules /etc/udev/rules.d/ && sudo udevadm control --reload && sudo udevadm trigger
3
+ # On a machine nobody logs in to locally, give a group access instead: replace TAG+="uaccess" with
4
+ # GROUP="<group>", MODE="0660" and add the user to that group.
5
+ SUBSYSTEM=="usb", ATTRS{idVendor}=="1cbe", ATTRS{idProduct}=="0005", TAG+="uaccess"
@@ -0,0 +1,16 @@
1
+ # A systemd user unit for `pixdial run`. Install:
2
+ # mkdir -p ~/.config/systemd/user && cp pixdial.service ~/.config/systemd/user/
3
+ # systemctl --user daemon-reload && systemctl --user enable --now pixdial
4
+ # ExecStart assumes `uv tool install` or `pipx install` (~/.local/bin); point it at your pixdial otherwise.
5
+ # To keep it running while you are logged out: loginctl enable-linger
6
+ [Unit]
7
+ Description=pixdial: live dashboards on the fans' LCD panels
8
+
9
+ [Service]
10
+ ExecStart=%h/.local/bin/pixdial run
11
+ Restart=always
12
+ RestartSec=10
13
+ Nice=10
14
+
15
+ [Install]
16
+ WantedBy=default.target
pixdial/demo.py ADDED
@@ -0,0 +1,69 @@
1
+ """Made-up readings of a busy workstation, for rendering designs without hardware (previews, the gallery, tests).
2
+
3
+ States: idle; busy (a build and a GPU job, the CPU near its limit, three agent sessions working); waiting (an agent
4
+ session waiting for you); full (everything at its limit); missing (no readings at all). The machine reports the limits
5
+ a real one would (an NVML power limit and slowdown point, a PSU's temperature crit).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import math
12
+
13
+ import numpy as np
14
+
15
+ from .readings import Reading
16
+
17
+ STATES = ("idle", "busy", "waiting", "full", "missing")
18
+ START = datetime.datetime(2026, 11, 4, 21, 42).timestamp() # the clock shows 21:42
19
+
20
+
21
+ def readings(state: str, t: float) -> dict[str, Reading]:
22
+ """The readings at second t of a state."""
23
+ if state == "missing":
24
+ return {}
25
+ rng = np.random.default_rng(int(t * 10) + STATES.index(state))
26
+ wave = math.sin(t / 9)
27
+ if state == "idle":
28
+ threads = np.clip(rng.gamma(1.2, 1.5, 48), 0, 100)
29
+ cpu_w, cpu_c = 41 + 2 * wave, 34 + wave
30
+ gpu_w, gpu_c, use = 10.5 + wave, 31, 0
31
+ pins = [0.38, 0.41, 0.36, 0.40, 0.37, 0.39]
32
+ psu_c, fan, quota, working, waiting = 33, 0, 89.8, 0, False
33
+ elif state == "busy":
34
+ threads = np.clip(rng.normal(55 + 25 * math.sin(t / 7), 22, 48), 0, 100)
35
+ cpu_w, cpu_c = 230 + 60 * wave, 93 + 2 * wave
36
+ gpu_w, gpu_c, use = 455 + 40 * math.sin(t / 5), 71, 98
37
+ pins = [7.4, 7.1, 7.6, 7.3, 6.9, 7.5]
38
+ psu_c, fan, quota, working, waiting = 46, 640, 58, 3, False
39
+ elif state == "waiting":
40
+ threads = np.clip(rng.normal(16, 9, 48), 0, 100)
41
+ cpu_w, cpu_c = 92 + 8 * wave, 57 + wave
42
+ gpu_w, gpu_c, use = 120 + 10 * wave, 47, 31
43
+ pins = [1.8, 1.7, 1.9, 1.8, 1.6, 1.8]
44
+ psu_c, fan, quota, working, waiting = 39, 0, 31, 1, True
45
+ else: # full
46
+ threads = np.full(48, 100.0)
47
+ cpu_w, cpu_c = 352 + 3 * wave, 95
48
+ gpu_w, gpu_c, use = 600, 93, 100
49
+ pins = [9.0, 8.7, 9.3, 9.1, 8.6, 9.0]
50
+ psu_c, fan, quota, working, waiting = 52, 980, 12.4, 3, True
51
+ psu_w = 60 + cpu_w * 1.08 + gpu_w * 1.04 + (240 * max(0, math.sin(t / 15)) if state == "busy" else 0)
52
+ return {
53
+ "cpu_load": Reading(float(np.mean(threads)), "%"),
54
+ "threads": Reading(threads, "%"),
55
+ "cpu_c": Reading(cpu_c, "°C"),
56
+ "cpu_w": Reading(cpu_w, "W"),
57
+ "gpu_w": Reading(gpu_w, "W", max=600),
58
+ "gpu_c": Reading(gpu_c, "°C", max=93),
59
+ "gpu_use": Reading(use, "%"),
60
+ "pins": Reading(np.array(pins), "A"),
61
+ "pin_total": Reading(sum(pins) * 0.98, "A"),
62
+ "pin_balance": Reading(min(pins) / max(pins) * 0.97),
63
+ "psu_w": Reading(psu_w, "W"),
64
+ "psu_c": Reading(psu_c, "°C", max=70),
65
+ "psu_fan": Reading(fan, "rpm"),
66
+ "quota": Reading(quota, "%"),
67
+ "working": Reading(working),
68
+ "waiting": Reading(waiting),
69
+ }
@@ -0,0 +1,57 @@
1
+ """Designs: pure renders from a snapshot of readings to a 400 x 400 picture, with no I/O.
2
+
3
+ What a design needs to draw is importable from here; see the README's "Writing a design".
4
+ """
5
+
6
+ from ..readings import Snapshot, TrailSpec
7
+ from .agents import Quota
8
+ from .base import DEFAULT_MAX, Design, fmt, joined, limit, rnd
9
+ from .clock import Clock
10
+ from .colour import LOAD, THEMES, Scale, Theme, current, lerp, share, solid, temperature
11
+ from .cpu import Crown, Grid, Minimal, Orb, Radar
12
+ from .frame import DASH, Frame, Pixels, Still, add, disc, glow_disc, paint, with_text
13
+ from .geometry import MID, SIZE, Kit, Ring, on_circle
14
+ from .gpu import Gpu, Pins, Twin
15
+ from .motion import Eased
16
+ from .power import Flow, History, Psu
17
+ from .thermals import Thermals
18
+
19
+ BUILTIN: dict[str, type[Design]] = {
20
+ d.name: d for d in (Crown, Gpu, Psu, Pins, Flow, Quota, History, Twin, Clock, Minimal, Thermals, Orb, Radar, Grid)
21
+ }
22
+
23
+ __all__ = [
24
+ "BUILTIN",
25
+ "DASH",
26
+ "DEFAULT_MAX",
27
+ "LOAD",
28
+ "MID",
29
+ "SIZE",
30
+ "THEMES",
31
+ "Design",
32
+ "Eased",
33
+ "Frame",
34
+ "Kit",
35
+ "Pixels",
36
+ "Ring",
37
+ "Scale",
38
+ "Snapshot",
39
+ "Still",
40
+ "Theme",
41
+ "TrailSpec",
42
+ "add",
43
+ "current",
44
+ "disc",
45
+ "fmt",
46
+ "glow_disc",
47
+ "joined",
48
+ "lerp",
49
+ "limit",
50
+ "on_circle",
51
+ "paint",
52
+ "rnd",
53
+ "share",
54
+ "solid",
55
+ "temperature",
56
+ "with_text",
57
+ ]
@@ -0,0 +1,83 @@
1
+ """Designs for coding agents: a pool's quota, sessions working, a session waiting for you."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import colorsys
6
+ import math
7
+ from collections.abc import Hashable
8
+
9
+ from ..readings import Snapshot
10
+ from .base import Design, rnd
11
+ from .colour import NEEDS_YOU, QUOTA_RGB, Theme, lerp
12
+ from .frame import Pixels, Still, disc, glow_disc
13
+ from .geometry import on_circle
14
+ from .motion import Eased
15
+
16
+
17
+ def agents(r: Snapshot) -> tuple[int, bool]:
18
+ """(sessions working, whether one waits for you): none while the agents' readings are missing."""
19
+ return r.count("working"), r.flag("waiting")
20
+
21
+
22
+ def pulse(now: float) -> float:
23
+ """A wait's pulse, 0-1, every 1.4 s."""
24
+ return 0.5 + 0.5 * math.cos(now * 2 * math.pi / 1.4)
25
+
26
+
27
+ def sessions(n: int) -> str:
28
+ return f"{n} AGENT{'S' if n > 1 else ''}"
29
+
30
+
31
+ class Quota(Design):
32
+ name = "quota"
33
+ shows = "An agent pool's quota left, agent sessions working, a wait for you"
34
+ about = (
35
+ "The agents' quota as a big coral fuel gauge, one dot per working session circling inside it, and the "
36
+ "whole screen pulsing magenta while a session waits for you."
37
+ )
38
+ fps = 20
39
+ previews = ("busy", "idle", "waiting")
40
+
41
+ working: int # set by update(), with waiting and title
42
+ waiting: bool
43
+ title: str
44
+
45
+ def __init__(self, theme: Theme, scale: int = 1) -> None:
46
+ super().__init__(theme, scale)
47
+ self.quota = Eased(0.5, tau=0.4)
48
+ self.spin = self.pulse = 0.0
49
+
50
+ def update(self, r: Snapshot, now: float, dt: float) -> tuple[Hashable, Hashable | None]:
51
+ self.quota.step(r.number("quota"), dt)
52
+ self.title = r.label("quota") or "QUOTA"
53
+ self.working, self.waiting = working, waiting = agents(r)
54
+ self.spin = (self.spin + dt * 40) % 360
55
+ self.pulse = pulse(now) if waiting else 0.0
56
+ still = rnd(self.quota.value, 2), working, waiting, round(self.pulse * 10)
57
+ return still, self.animating(now) if working else None
58
+
59
+ def still(self) -> Still:
60
+ waiting, q = self.waiting, self.quota.get()
61
+ accent = lerp(self.theme.track, NEEDS_YOU, 0.35 + 0.65 * self.pulse) if waiting else QUOTA_RGB
62
+ f = self.frame(accent if waiting or q is not None else None, 0.12 if waiting else 0.07)
63
+ ring = self.kit.ring(166, 190)
64
+ f.paint(ring, ring.arc(1.0), self.theme.track, glow=0)
65
+ if q is not None:
66
+ f.paint(ring, ring.arc(q / 100), accent)
67
+ self.label(f, self.title)
68
+ f.number(246, q, "%", glow=QUOTA_RGB)
69
+ if waiting:
70
+ self.line(f, "NEEDS YOU", rgb=lerp(self.theme.dim, NEEDS_YOU, 0.4 + 0.6 * self.pulse), size=30, spacing=2)
71
+ elif self.working:
72
+ self.line(f, sessions(self.working), size=30, spacing=2)
73
+ else:
74
+ self.line(f, "WEEK LEFT", rgb=self.theme.dim, size=30, spacing=2)
75
+ return f.finish()
76
+
77
+ def moving(self, px: Pixels, now: float) -> None:
78
+ n = self.working
79
+ for i in range(min(n, 8)):
80
+ x, y = on_circle(146, self.spin + i * 360 / n)
81
+ r, g, b = (255 * c for c in colorsys.hsv_to_rgb((self.spin / 360 + i / n) % 1, 0.7, 1))
82
+ glow_disc(px, self.kit, x, y, 9, (r, g, b), 0.35)
83
+ disc(px, self.kit, x, y, 5.5, (r, g, b))