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.
- pixdial/__init__.py +4 -0
- pixdial/__main__.py +3 -0
- pixdial/cli.py +146 -0
- pixdial/config.py +229 -0
- pixdial/data/60-pixdial.rules +5 -0
- pixdial/data/pixdial.service +16 -0
- pixdial/demo.py +69 -0
- pixdial/designs/__init__.py +57 -0
- pixdial/designs/agents.py +83 -0
- pixdial/designs/base.py +144 -0
- pixdial/designs/clock.py +55 -0
- pixdial/designs/colour.py +114 -0
- pixdial/designs/cpu.py +229 -0
- pixdial/designs/frame.py +208 -0
- pixdial/designs/geometry.py +126 -0
- pixdial/designs/gpu.py +156 -0
- pixdial/designs/motion.py +38 -0
- pixdial/designs/power.py +199 -0
- pixdial/designs/thermals.py +51 -0
- pixdial/devices/__init__.py +9 -0
- pixdial/devices/base.py +50 -0
- pixdial/devices/png.py +39 -0
- pixdial/devices/slv3.py +169 -0
- pixdial/devices/usbfs.py +95 -0
- pixdial/doctor.py +105 -0
- pixdial/fonts/LICENSE-Lato.txt +108 -0
- pixdial/fonts/Lato-Black.ttf +0 -0
- pixdial/fonts/Lato-Bold.ttf +0 -0
- pixdial/fonts/Lato-Regular.ttf +0 -0
- pixdial/gallery.py +143 -0
- pixdial/plugins.py +55 -0
- pixdial/py.typed +0 -0
- pixdial/readings.py +106 -0
- pixdial/runtime/__init__.py +10 -0
- pixdial/runtime/feed.py +22 -0
- pixdial/runtime/layout.py +85 -0
- pixdial/runtime/report.py +22 -0
- pixdial/runtime/runner.py +92 -0
- pixdial/runtime/sampler.py +70 -0
- pixdial/runtime/screen.py +136 -0
- pixdial/sources/__init__.py +13 -0
- pixdial/sources/base.py +29 -0
- pixdial/sources/command.py +50 -0
- pixdial/sources/cpu.py +47 -0
- pixdial/sources/hwmon.py +86 -0
- pixdial/sources/nvml.py +74 -0
- pixdial-0.1.0.dist-info/METADATA +268 -0
- pixdial-0.1.0.dist-info/RECORD +52 -0
- pixdial-0.1.0.dist-info/WHEEL +4 -0
- pixdial-0.1.0.dist-info/entry_points.txt +2 -0
- pixdial-0.1.0.dist-info/licenses/LICENSE +675 -0
- pixdial-0.1.0.dist-info/licenses/src/pixdial/fonts/LICENSE-Lato.txt +108 -0
pixdial/__init__.py
ADDED
pixdial/__main__.py
ADDED
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))
|