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.
- seren_psycheion-1.0.0/PKG-INFO +69 -0
- seren_psycheion-1.0.0/README.md +48 -0
- seren_psycheion-1.0.0/pyproject.toml +53 -0
- seren_psycheion-1.0.0/seren-psycheion.yaml.sample +22 -0
- seren_psycheion-1.0.0/seren_psycheion/__init__.py +23 -0
- seren_psycheion-1.0.0/seren_psycheion/__main__.py +53 -0
- seren_psycheion-1.0.0/seren_psycheion/_describe.py +40 -0
- seren_psycheion-1.0.0/seren_psycheion/_diag.py +18 -0
- seren_psycheion-1.0.0/seren_psycheion/_version.py +24 -0
- seren_psycheion-1.0.0/seren_psycheion/app.py +165 -0
- seren_psycheion-1.0.0/seren_psycheion/config.py +157 -0
- seren_psycheion-1.0.0/seren_psycheion/viewer/ui/body.html +94 -0
- seren_psycheion-1.0.0/seren_psycheion/viewer/ui/header_aside.html +3 -0
- seren_psycheion-1.0.0/seren_psycheion/viewer/ui/scripts.js +144 -0
- seren_psycheion-1.0.0/seren_psycheion/viewer/ui/styles.css +31 -0
- seren_psycheion-1.0.0/seren_psycheion/viewer/ui/tabs.html +3 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/PKG-INFO +69 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/SOURCES.txt +25 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/dependency_links.txt +1 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/entry_points.txt +2 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/requires.txt +9 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/scm_file_list.json +20 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/scm_version.json +8 -0
- seren_psycheion-1.0.0/seren_psycheion.egg-info/top_level.txt +1 -0
- seren_psycheion-1.0.0/setup.cfg +4 -0
- seren_psycheion-1.0.0/tests/test_app.py +180 -0
- 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
|