seren-psycheion 1.0.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.
Files changed (27) hide show
  1. seren_psycheion-1.0.0/PKG-INFO +69 -0
  2. seren_psycheion-1.0.0/README.md +48 -0
  3. seren_psycheion-1.0.0/pyproject.toml +53 -0
  4. seren_psycheion-1.0.0/seren-psycheion.yaml.sample +22 -0
  5. seren_psycheion-1.0.0/seren_psycheion/__init__.py +23 -0
  6. seren_psycheion-1.0.0/seren_psycheion/__main__.py +53 -0
  7. seren_psycheion-1.0.0/seren_psycheion/_describe.py +40 -0
  8. seren_psycheion-1.0.0/seren_psycheion/_diag.py +18 -0
  9. seren_psycheion-1.0.0/seren_psycheion/_version.py +24 -0
  10. seren_psycheion-1.0.0/seren_psycheion/app.py +165 -0
  11. seren_psycheion-1.0.0/seren_psycheion/config.py +157 -0
  12. seren_psycheion-1.0.0/seren_psycheion/viewer/ui/body.html +94 -0
  13. seren_psycheion-1.0.0/seren_psycheion/viewer/ui/header_aside.html +3 -0
  14. seren_psycheion-1.0.0/seren_psycheion/viewer/ui/scripts.js +144 -0
  15. seren_psycheion-1.0.0/seren_psycheion/viewer/ui/styles.css +31 -0
  16. seren_psycheion-1.0.0/seren_psycheion/viewer/ui/tabs.html +3 -0
  17. seren_psycheion-1.0.0/seren_psycheion.egg-info/PKG-INFO +69 -0
  18. seren_psycheion-1.0.0/seren_psycheion.egg-info/SOURCES.txt +25 -0
  19. seren_psycheion-1.0.0/seren_psycheion.egg-info/dependency_links.txt +1 -0
  20. seren_psycheion-1.0.0/seren_psycheion.egg-info/entry_points.txt +2 -0
  21. seren_psycheion-1.0.0/seren_psycheion.egg-info/requires.txt +9 -0
  22. seren_psycheion-1.0.0/seren_psycheion.egg-info/scm_file_list.json +20 -0
  23. seren_psycheion-1.0.0/seren_psycheion.egg-info/scm_version.json +8 -0
  24. seren_psycheion-1.0.0/seren_psycheion.egg-info/top_level.txt +1 -0
  25. seren_psycheion-1.0.0/setup.cfg +4 -0
  26. seren_psycheion-1.0.0/tests/test_app.py +180 -0
  27. seren_psycheion-1.0.0/tests/test_describe.py +13 -0
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: seren-psycheion
3
+ Version: 1.0.0
4
+ Summary: The mind's workshop for a Seren body: watch what it senses and does, set how it behaves, make its faces.
5
+ Author: Chad Roesler
6
+ License-Expression: AGPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/ChadRoesler/SerenPsycheion
8
+ Project-URL: Repository, https://github.com/ChadRoesler/SerenPsycheion
9
+ Project-URL: Bug Tracker, https://github.com/ChadRoesler/SerenPsycheion/issues
10
+ Keywords: seren,stackchan,robot,console,fastapi,viewer
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: fastapi>=0.110
14
+ Requires-Dist: uvicorn[standard]>=0.27
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: httpx>=0.27
17
+ Requires-Dist: seren-meninges<7,>=6.0.0
18
+ Requires-Dist: seren-sinew<6,>=5.5.1
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8.0; extra == "dev"
21
+
22
+ # seren-psycheion
23
+
24
+ The console and face workshop for a [SerenSoma](https://github.com/ChadRoesler/SerenSoma) body.
25
+
26
+ ```
27
+ pip install seren-psycheion
28
+ seren-psycheion # http://127.0.0.1:7429/viewer
29
+ seren-psycheion --describe # one line of JSON, for installers
30
+ ```
31
+
32
+ ## Configure
33
+
34
+ Copy [`seren-psycheion.yaml.sample`](seren-psycheion.yaml.sample) to
35
+ `~/seren-psycheion/seren-psycheion.yaml` (or point `--config` or
36
+ `$SEREN_PSYCHEION_CONFIG` at it):
37
+
38
+ ```yaml
39
+ soma:
40
+ robot:
41
+ url: http://192.168.0.201:8080
42
+ token_file: ~/seren-soma/state/control_token
43
+ voice:
44
+ url: http://127.0.0.1:7480
45
+ token_file: ~/seren-soma/state/token
46
+ ```
47
+
48
+ The body's tokens are pointers: `token_file`, `token_env` (an environment
49
+ variable's name) or `token_keyring` (`service/username` in the OS keychain).
50
+ `token:` inline is the escape hatch. Psycheion makes every call to the body
51
+ itself; the page never sees these.
52
+
53
+ ## Exposure
54
+
55
+ Psycheion binds to loopback. It can change a robot's lights and read what a
56
+ hand did, so a wider bind with no `server.bearer_token_env` refuses to start
57
+ (Meninges' `enforce_server`), and says how to fix it.
58
+
59
+ ## Routes
60
+
61
+ | | |
62
+ |---|---|
63
+ | `GET /viewer` | The console. |
64
+ | `GET /api/soma` | The robot's status and the voice server's status and settings, each with why it didn't answer if it didn't. |
65
+ | `POST /api/soma/robot/settings` | `dimmer`, `volume` (0 to 100), `brightness` (5 to 100); saved on the robot. |
66
+ | `POST /api/soma/voice/settings` | Any part of the voice server's settings (night, work hours, overwhelm). |
67
+ | `GET /api/soma/touch?after=N` | The touch feed since event N, and overwhelm's progress. |
68
+
69
+ Everything under `/api` needs the bearer when one is set.
@@ -0,0 +1,48 @@
1
+ # seren-psycheion
2
+
3
+ The console and face workshop for a [SerenSoma](https://github.com/ChadRoesler/SerenSoma) body.
4
+
5
+ ```
6
+ pip install seren-psycheion
7
+ seren-psycheion # http://127.0.0.1:7429/viewer
8
+ seren-psycheion --describe # one line of JSON, for installers
9
+ ```
10
+
11
+ ## Configure
12
+
13
+ Copy [`seren-psycheion.yaml.sample`](seren-psycheion.yaml.sample) to
14
+ `~/seren-psycheion/seren-psycheion.yaml` (or point `--config` or
15
+ `$SEREN_PSYCHEION_CONFIG` at it):
16
+
17
+ ```yaml
18
+ soma:
19
+ robot:
20
+ url: http://192.168.0.201:8080
21
+ token_file: ~/seren-soma/state/control_token
22
+ voice:
23
+ url: http://127.0.0.1:7480
24
+ token_file: ~/seren-soma/state/token
25
+ ```
26
+
27
+ The body's tokens are pointers: `token_file`, `token_env` (an environment
28
+ variable's name) or `token_keyring` (`service/username` in the OS keychain).
29
+ `token:` inline is the escape hatch. Psycheion makes every call to the body
30
+ itself; the page never sees these.
31
+
32
+ ## Exposure
33
+
34
+ Psycheion binds to loopback. It can change a robot's lights and read what a
35
+ hand did, so a wider bind with no `server.bearer_token_env` refuses to start
36
+ (Meninges' `enforce_server`), and says how to fix it.
37
+
38
+ ## Routes
39
+
40
+ | | |
41
+ |---|---|
42
+ | `GET /viewer` | The console. |
43
+ | `GET /api/soma` | The robot's status and the voice server's status and settings, each with why it didn't answer if it didn't. |
44
+ | `POST /api/soma/robot/settings` | `dimmer`, `volume` (0 to 100), `brightness` (5 to 100); saved on the robot. |
45
+ | `POST /api/soma/voice/settings` | Any part of the voice server's settings (night, work hours, overwhelm). |
46
+ | `GET /api/soma/touch?after=N` | The touch feed since event N, and overwhelm's progress. |
47
+
48
+ Everything under `/api` needs the bearer when one is set.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "seren-psycheion"
7
+ dynamic = ["version"]
8
+ description = "The mind's workshop for a Seren body: watch what it senses and does, set how it behaves, make its faces."
9
+ license = "AGPL-3.0-or-later"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Chad Roesler" }]
12
+ readme = "README.md"
13
+ keywords = ["seren", "stackchan", "robot", "console", "fastapi", "viewer"]
14
+ dependencies = [
15
+ "fastapi>=0.110",
16
+ "uvicorn[standard]>=0.27",
17
+ "pyyaml>=6.0",
18
+ "httpx>=0.27",
19
+ # the shell, auth, config, update checking
20
+ "seren-meninges>=6.0.0,<7",
21
+ # the request log, and seren_sinew.soma: the wire to the body, spelled once
22
+ "seren-sinew>=5.5.1,<6",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = ["pytest>=8.0"]
27
+
28
+ [project.scripts]
29
+ seren-psycheion = "seren_psycheion.__main__:main"
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/ChadRoesler/SerenPsycheion"
33
+ Repository = "https://github.com/ChadRoesler/SerenPsycheion"
34
+ "Bug Tracker" = "https://github.com/ChadRoesler/SerenPsycheion/issues"
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["."]
38
+ include = ["seren_psycheion*"]
39
+
40
+ # Without this the viewer's .html/.css/.js are absent from the wheel, and /viewer
41
+ # 500s on an installed copy while working from the checkout.
42
+ [tool.setuptools.package-data]
43
+ seren_psycheion = ["viewer/ui/*.html", "viewer/ui/*.css", "viewer/ui/*.js"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+
48
+ [tool.setuptools_scm]
49
+ root = ".."
50
+ version_scheme = "release-branch-semver"
51
+ version_file = "seren_psycheion/_version.py"
52
+ tag_regex = "^v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+.*)$"
53
+ fallback_version = "0.0.0.dev0"
@@ -0,0 +1,22 @@
1
+ # ════════════════════════════════════════════════════════════════════════
2
+ # seren-psycheion.yaml - copy to ~/seren-psycheion/seren-psycheion.yaml
3
+ # ════════════════════════════════════════════════════════════════════════
4
+
5
+ server:
6
+ host: 127.0.0.1 # loopback. Wider needs a token, or Psycheion refuses to start.
7
+ port: 7429
8
+ # bearer_token_env: SEREN_PSYCHEION_TOKEN
9
+
10
+ # The body. Tokens are POINTERS: token_env (an env var's name), token_keyring
11
+ # ("service/username" in the OS keychain) or token_file (a file holding only
12
+ # the token). The browser never sees these; Psycheion makes the calls itself.
13
+ soma:
14
+ robot: # the robot's control API (firmware)
15
+ url: http://192.168.0.201:8080
16
+ # token_file: ~/seren-soma/state/control_token
17
+ voice: # the body's voice server
18
+ url: http://127.0.0.1:7480
19
+ # token_file: ~/seren-soma/state/token
20
+
21
+ updates:
22
+ enabled: true
@@ -0,0 +1,23 @@
1
+ """seren_psycheion - the mind's workshop for a Seren body.
2
+
3
+ SerenSoma is the body: a Stack-chan robot's firmware and the voice server that
4
+ is its nervous system. Psycheion is where you watch it and shape it: what it
5
+ senses and what it was read as, how bright and loud it is, when it's night,
6
+ and (moving in from SerenSoma) the faces it wears.
7
+
8
+ It talks to the body only through seren_sinew.soma, and it holds the body's
9
+ tokens itself: the browser only ever holds Psycheion's own.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ try: # the setuptools-scm build artifact (gitignored); present in installed wheels
14
+ from ._version import version as __version__
15
+ except Exception: # source checkout without a build -> metadata, then dev fallback
16
+ try:
17
+ from importlib.metadata import version as _v
18
+
19
+ __version__ = _v("seren-psycheion")
20
+ except Exception:
21
+ __version__ = "0.0.0.dev0"
22
+
23
+ __all__ = ["__version__"]
@@ -0,0 +1,53 @@
1
+ """Entry point for `python -m seren_psycheion` or the `seren-psycheion` script.
2
+
3
+ --config / -c like the rest of the family; --describe answers before anything
4
+ heavy is imported, so a broken install can still say its own name.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import json
10
+ import sys
11
+
12
+
13
+ def _force_utf8_stdio() -> None:
14
+ for stream in (sys.stdout, sys.stderr):
15
+ try:
16
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
17
+ except (AttributeError, ValueError):
18
+ pass
19
+
20
+
21
+ def main() -> None:
22
+ _force_utf8_stdio()
23
+ if "--describe" in sys.argv[1:]:
24
+ from ._describe import DESCRIBE
25
+ print(json.dumps(DESCRIBE))
26
+ raise SystemExit(0)
27
+
28
+ parser = argparse.ArgumentParser(prog="seren_psycheion",
29
+ description="SerenPsycheion - the mind's workshop for a Seren body.")
30
+ parser.add_argument("--config", "-c", default=None,
31
+ help="Path to seren-psycheion.yaml (default: $SEREN_PSYCHEION_CONFIG, then "
32
+ "~/seren-psycheion/seren-psycheion.yaml, then built-in defaults).")
33
+ args = parser.parse_args()
34
+
35
+ import uvicorn
36
+ from seren_meninges.exposure import enforce_server
37
+
38
+ from ._diag import diag
39
+ from .app import create_app
40
+ from .config import load_config
41
+
42
+ cfg = load_config(args.config)
43
+ # This page can switch a robot's lights and read what a hand did: an open bind
44
+ # with no token is refused here, with the ways out printed.
45
+ enforce_server(cfg.server, service="seren-psycheion", env_prefix="SEREN_PSYCHEION", log=diag)
46
+ app = create_app(cfg)
47
+ diag(f"[seren-psycheion] viewer at http://{cfg.host}:{cfg.port}/viewer")
48
+ diag(f"[seren-psycheion] the body: robot {cfg.soma.robot.url}, voice {cfg.soma.voice.url}")
49
+ uvicorn.run(app, host=cfg.host, port=cfg.port, log_level="info")
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,40 @@
1
+ """SerenPsycheion's identity card. STDLIB ONLY - nothing imported, nothing read.
2
+
3
+ What an INSTALLED Psycheion answers to `--describe`, so it can be compared with
4
+ its Starwright card once that exists. Standard library only, for the reason
5
+ every Seren card is: the moment you most want a thing to say its own name is
6
+ when its install is broken.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ NAME = "seren-psycheion"
11
+ DISPLAY = "Seren Psycheion"
12
+ DESCRIPTION = ("The mind's workshop for a Seren body: watch what it senses and "
13
+ "does, set how it behaves, and make its faces.")
14
+
15
+ # 7429: between Lyceum (7428) and Probe (7430). The Soma voice server is 7480
16
+ # and the robot's own control API 8080; neither is in the family band.
17
+ PORT = 7429
18
+
19
+ GROUP = "auxiliary"
20
+
21
+ # Brain colored.
22
+ ACCENT = "#f2aeb1"
23
+
24
+ # Nothing to install first. A body is reached over its own HTTP APIs, so a
25
+ # Psycheion with no body configured still serves its pages and says so.
26
+ REQUIRES: tuple = ()
27
+ EXTRAS: tuple = ()
28
+
29
+ DESCRIBE = {
30
+ "schema_version": 1,
31
+ "name": NAME,
32
+ "display": DISPLAY,
33
+ "description": DESCRIPTION,
34
+ "port": PORT,
35
+ "group": GROUP,
36
+ "accent": ACCENT,
37
+ "package": "seren-psycheion",
38
+ "requires": list(REQUIRES),
39
+ "extras": list(EXTRAS),
40
+ }
@@ -0,0 +1,18 @@
1
+ """Startup diagnostics that survive a nulled stdout (parity with SerenTheatre's
2
+ and SerenMargin's _diag): `pythonw` nulls sys.stdout, and a write to a closed
3
+ stream would end the run. Print, but never let printing be what kills it."""
4
+ from __future__ import annotations
5
+
6
+ import sys
7
+
8
+
9
+ def diag(message: str) -> None:
10
+ for stream in (sys.stdout, sys.stderr):
11
+ try:
12
+ if stream is None:
13
+ continue
14
+ stream.write(message + "\n")
15
+ stream.flush()
16
+ return
17
+ except Exception: # noqa: BLE001 - a broken stream must not be fatal
18
+ continue
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '1.0.0'
22
+ __version_tuple__ = version_tuple = (1, 0, 0)
23
+
24
+ __commit_id__ = commit_id = 'gba6c1ee84'
@@ -0,0 +1,165 @@
1
+ """The FastAPI app: the viewer, and the body's API passed through with the body's own tokens.
2
+
3
+ The browser holds Psycheion's bearer (Meninges' token modal) and nothing else.
4
+ Psycheion holds the robot's control token and the voice server's bearer, makes
5
+ every call to the body itself through seren_sinew.soma, and never hands either
6
+ token back. A route here answers with what the body said, or 502 and where it
7
+ couldn't reach, never the token or the body's raw error text.
8
+
9
+ There is no camera route. A photo is taken only when the person in the room
10
+ says so (see seren_sinew.soma).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from contextlib import asynccontextmanager
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ import httpx
19
+ from fastapi import FastAPI, HTTPException, Request
20
+ from fastapi.responses import HTMLResponse, JSONResponse
21
+ from seren_meninges.auth import bearer_auth_middleware
22
+ from seren_sinew.cluster.dtos import to_dict
23
+ from seren_sinew.request_log import RequestLoggingMiddleware
24
+ from seren_sinew.soma.client import SomaRobot, SomaVoice
25
+
26
+ from . import __version__ as APP_VERSION
27
+ from ._describe import ACCENT, NAME
28
+ from ._diag import diag
29
+ from .config import PsycheionConfig, load_config
30
+
31
+ try:
32
+ from seren_meninges.viewer import render_from_dir
33
+ except Exception: # noqa: BLE001 - the API stands without the shell, and the viewer says so
34
+ render_from_dir = None # type: ignore[assignment]
35
+
36
+ _VIEWER_DIR = Path(__file__).parent / "viewer" / "ui"
37
+
38
+
39
+ def _unreachable(what: str, url: str, exc: Exception) -> HTTPException:
40
+ """502, saying where and roughly why, never what the body said in full (it can echo headers)."""
41
+ if isinstance(exc, httpx.HTTPStatusError):
42
+ code = exc.response.status_code
43
+ why = "refused the token" if code == 401 else f"answered {code}"
44
+ return HTTPException(502, f"the {what} at {url} {why}")
45
+ return HTTPException(502, f"can't reach the {what} at {url}")
46
+
47
+
48
+ def create_app(config: Optional[PsycheionConfig] = None, *,
49
+ robot_transport: Optional[httpx.AsyncBaseTransport] = None,
50
+ voice_transport: Optional[httpx.AsyncBaseTransport] = None) -> FastAPI:
51
+ cfg = config or load_config()
52
+ soma = cfg.soma
53
+
54
+ @asynccontextmanager
55
+ async def lifespan(app: FastAPI):
56
+ app.state.robot = SomaRobot(soma.robot.url, soma.robot.resolve(), transport=robot_transport)
57
+ app.state.voice = SomaVoice(soma.voice.url, soma.voice.resolve(), transport=voice_transport)
58
+ try:
59
+ yield
60
+ finally:
61
+ await app.state.robot.aclose()
62
+ await app.state.voice.aclose()
63
+
64
+ app = FastAPI(title="SerenPsycheion", version=APP_VERSION, lifespan=lifespan)
65
+ app.state.cfg = cfg
66
+
67
+ # Open on loopback with no token, like the rest of the family; enforce_server
68
+ # refuses a wider bind without one before this ever runs.
69
+ app.add_middleware(bearer_auth_middleware(cfg.server.resolve_bearer()))
70
+ app.add_middleware(RequestLoggingMiddleware, service_name=NAME, env_prefix="SEREN_PSYCHEION")
71
+
72
+ try:
73
+ from seren_meninges.updates import UpdateChecker
74
+ app.state.updates = UpdateChecker(NAME, enabled=cfg.updates.enabled, index_url=cfg.updates.index_url,
75
+ ttl_seconds=cfg.updates.check_interval_hours * 3600.0,
76
+ allow_prerelease=cfg.updates.allow_prerelease, fallback_version=APP_VERSION)
77
+ except Exception as exc: # noqa: BLE001 - cosmetic, never fatal
78
+ app.state.updates = None
79
+ diag(f"[seren-psycheion] update checking unavailable ({exc})")
80
+
81
+ @app.get("/")
82
+ async def root(request: Request) -> dict:
83
+ """The family's info route: who this is, where the page is, and the updates block, which every
84
+ service carries with the same seven keys whatever state its checker is in (CI pins them)."""
85
+ try:
86
+ from seren_meninges.updates import updates_payload
87
+ updates = await updates_payload(getattr(request.app.state, "updates", None),
88
+ distribution=NAME, installed=APP_VERSION)
89
+ except Exception as exc: # noqa: BLE001 - an info route never 500s over a cosmetic check
90
+ updates = {"status": "unavailable", "distribution": NAME, "installed": APP_VERSION, "latest": None,
91
+ "update_available": False, "checked_at": None,
92
+ "detail": f"update checking could not be constructed ({exc}): pip install -U seren-psycheion"}
93
+ return {"service": NAME, "version": APP_VERSION, "viewer": "/viewer", "updates": updates}
94
+
95
+ @app.get("/health")
96
+ async def health():
97
+ return {"ok": True, "service": NAME, "version": APP_VERSION}
98
+
99
+ @app.get("/api/soma")
100
+ async def soma_snapshot(request: Request) -> JSONResponse:
101
+ """Everything the console's front page shows, in one call. Each side
102
+ answers or says why it didn't; one being down doesn't hide the other."""
103
+ out: dict[str, Any] = {"robot": None, "robot_error": None, "voice": None, "voice_error": None,
104
+ "voice_settings": None}
105
+ try:
106
+ out["robot"] = to_dict(await request.app.state.robot.status())
107
+ except (httpx.HTTPError, ValueError) as exc:
108
+ out["robot_error"] = _unreachable("robot", soma.robot.url, exc).detail
109
+ try:
110
+ out["voice"] = to_dict(await request.app.state.voice.status())
111
+ out["voice_settings"] = to_dict(await request.app.state.voice.settings())
112
+ except (httpx.HTTPError, ValueError) as exc:
113
+ out["voice_error"] = _unreachable("voice server", soma.voice.url, exc).detail
114
+ return JSONResponse(out, headers={"Cache-Control": "no-store"})
115
+
116
+ @app.post("/api/soma/robot/settings")
117
+ async def robot_settings(request: Request):
118
+ """{"dimmer": 0..100, "volume": 0..100, "brightness": 5..100}, any of them; saved on the robot."""
119
+ body = await request.json()
120
+ change: dict[str, int] = {}
121
+ for key, low in (("dimmer", 0), ("volume", 0), ("brightness", 5)):
122
+ if key in body:
123
+ v = body[key]
124
+ if not isinstance(v, int) or isinstance(v, bool) or not low <= v <= 100:
125
+ raise HTTPException(400, f"{key} is a whole number from {low} to 100")
126
+ change[key] = v
127
+ try:
128
+ return to_dict(await request.app.state.robot.settings(**change))
129
+ except (httpx.HTTPError, ValueError) as exc:
130
+ raise _unreachable("robot", soma.robot.url, exc)
131
+
132
+ @app.post("/api/soma/voice/settings")
133
+ async def voice_settings(request: Request):
134
+ """Any part of the voice server's settings; it checks them, and its reason for a no comes back as ours."""
135
+ body = await request.json()
136
+ if not isinstance(body, dict):
137
+ raise HTTPException(400, "expected an object of settings")
138
+ try:
139
+ return to_dict(await request.app.state.voice.set_settings(body))
140
+ except httpx.HTTPStatusError as exc:
141
+ if exc.response.status_code == 400:
142
+ raise HTTPException(400, exc.response.text[:200])
143
+ raise _unreachable("voice server", soma.voice.url, exc)
144
+ except (httpx.HTTPError, ValueError) as exc:
145
+ raise _unreachable("voice server", soma.voice.url, exc)
146
+
147
+ @app.get("/api/soma/touch")
148
+ async def touch(request: Request, after: int = 0):
149
+ try:
150
+ return to_dict(await request.app.state.voice.touch(after=after))
151
+ except (httpx.HTTPError, ValueError) as exc:
152
+ raise _unreachable("voice server", soma.voice.url, exc)
153
+
154
+ @app.get("/viewer", include_in_schema=False)
155
+ async def viewer():
156
+ if render_from_dir is None:
157
+ return HTMLResponse("<h1>SerenPsycheion</h1><p>The SerenMeninges UI shell is not importable, "
158
+ "so the viewer can't render. <code>pip install seren-meninges</code></p>",
159
+ status_code=503)
160
+ # no-store: the whole viewer is inlined, and a cached copy would show last week's page
161
+ return HTMLResponse(headers={"Cache-Control": "no-store, must-revalidate"}, content=render_from_dir(
162
+ _VIEWER_DIR, title="SerenPsycheion", brand="Seren<b>Psycheion</b>",
163
+ subtitle=f"v{APP_VERSION} · the mind's workshop for a body", accent=ACCENT))
164
+
165
+ return app