yam-tui 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ name: release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+
7
+ concurrency: release
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: write
14
+ outputs:
15
+ version: ${{ steps.tag.outputs.version }}
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ with:
19
+ fetch-depth: 0
20
+ - uses: astral-sh/setup-uv@v5
21
+ - name: Compute and push next tag
22
+ id: tag
23
+ run: |
24
+ if git describe --exact-match --tags --match 'v*' HEAD 2>/dev/null; then
25
+ echo "HEAD is already tagged, nothing to release"
26
+ exit 0
27
+ fi
28
+ last=$(git tag --list 'v*' --sort=-v:refname | head -n1)
29
+ if [ -z "$last" ]; then
30
+ next="v0.1.0"
31
+ else
32
+ IFS=. read -r major minor patch <<< "${last#v}"
33
+ next="v${major}.${minor}.$((patch + 1))"
34
+ fi
35
+ git tag "$next"
36
+ git push origin "$next"
37
+ echo "version=$next" >> "$GITHUB_OUTPUT"
38
+ - name: Build
39
+ if: steps.tag.outputs.version
40
+ run: uv build
41
+ - uses: actions/upload-artifact@v4
42
+ if: steps.tag.outputs.version
43
+ with:
44
+ name: dist
45
+ path: dist/
46
+
47
+ publish:
48
+ needs: build
49
+ if: needs.build.outputs.version
50
+ runs-on: ubuntu-latest
51
+ environment: pypi
52
+ permissions:
53
+ id-token: write
54
+ steps:
55
+ - uses: actions/download-artifact@v4
56
+ with:
57
+ name: dist
58
+ path: dist/
59
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,4 @@
1
+ .venv/
2
+ __pycache__/
3
+ dist/
4
+ src/yam_tui/_version.py
yam_tui-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 René Fritze
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
yam_tui-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.5
2
+ Name: yam-tui
3
+ Version: 0.1.0
4
+ Summary: Minimal TUI for controlling a Yamaha RX-V475 AV receiver
5
+ Project-URL: Homepage, https://github.com/renefritze/yam-tui
6
+ Author-email: René Fritze <coding@fritze.me>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Environment :: Console :: Curses
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Home Automation
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+
15
+ # yam-tui
16
+
17
+ Minimal terminal UI for controlling a Yamaha RX-V475 AV receiver on your LAN
18
+ (uses the receiver's XML `/YamahaRemoteControl/ctrl` API; no dependencies).
19
+
20
+ ```
21
+ uv tool install yam-tui # or: pipx install yam-tui
22
+ yam-tui 192.168.0.94 # or set YAMAHA_HOST
23
+ ```
24
+
25
+ | Key | Action |
26
+ |---|---|
27
+ | `p` | power |
28
+ | `↑` / `↓` | volume |
29
+ | `m` | mute |
30
+ | `0`–`9` | jump to input (`0` AUDIO, `1`–`5` HDMI1–5, `6`–`9` AV1–4) |
31
+ | `←` / `→` | previous / next input |
32
+ | `s` | next sound program |
33
+ | `o` | HDMI out on/off |
34
+ | `q` | quit |
35
+
36
+ Other RX-V models using the same XML protocol will probably work, but only the
37
+ RX-V475 is tested.
38
+
39
+ ## Releases
40
+
41
+ Every merge to `main` bumps the patch version from the latest `v*` git tag
42
+ (starting at `v0.1.0`), tags the commit and publishes to PyPI. For a minor/major bump, push the tag
43
+ yourself (e.g. `git tag v0.2.0 && git push --tags`) before merging.
@@ -0,0 +1,29 @@
1
+ # yam-tui
2
+
3
+ Minimal terminal UI for controlling a Yamaha RX-V475 AV receiver on your LAN
4
+ (uses the receiver's XML `/YamahaRemoteControl/ctrl` API; no dependencies).
5
+
6
+ ```
7
+ uv tool install yam-tui # or: pipx install yam-tui
8
+ yam-tui 192.168.0.94 # or set YAMAHA_HOST
9
+ ```
10
+
11
+ | Key | Action |
12
+ |---|---|
13
+ | `p` | power |
14
+ | `↑` / `↓` | volume |
15
+ | `m` | mute |
16
+ | `0`–`9` | jump to input (`0` AUDIO, `1`–`5` HDMI1–5, `6`–`9` AV1–4) |
17
+ | `←` / `→` | previous / next input |
18
+ | `s` | next sound program |
19
+ | `o` | HDMI out on/off |
20
+ | `q` | quit |
21
+
22
+ Other RX-V models using the same XML protocol will probably work, but only the
23
+ RX-V475 is tested.
24
+
25
+ ## Releases
26
+
27
+ Every merge to `main` bumps the patch version from the latest `v*` git tag
28
+ (starting at `v0.1.0`), tags the commit and publishes to PyPI. For a minor/major bump, push the tag
29
+ yourself (e.g. `git tag v0.2.0 && git push --tags`) before merging.
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "yam-tui"
3
+ dynamic = ["version"]
4
+ description = "Minimal TUI for controlling a Yamaha RX-V475 AV receiver"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "René Fritze", email = "coding@fritze.me" }]
9
+ requires-python = ">=3.10"
10
+ dependencies = []
11
+ classifiers = [
12
+ "Environment :: Console :: Curses",
13
+ "Programming Language :: Python :: 3",
14
+ "Topic :: Home Automation",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/renefritze/yam-tui"
19
+
20
+ [project.scripts]
21
+ yam-tui = "yam_tui.app:cli"
22
+
23
+ [build-system]
24
+ requires = ["hatchling", "hatch-vcs"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.version]
28
+ source = "vcs"
29
+
30
+ [tool.hatch.build.hooks.vcs]
31
+ version-file = "src/yam_tui/_version.py"
File without changes
@@ -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 = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ """Minimal curses TUI for Yamaha RX-V475 (XML /YamahaRemoteControl/ctrl API).
3
+
4
+ Usage: yam-tui [receiver-ip] (default: $YAMAHA_HOST or 192.168.0.94)
5
+
6
+ Keys: p power ↑/↓ volume m mute ←/→ or 0-9 input s sound program
7
+ o HDMI out on/off r refresh q quit
8
+ """
9
+ import curses
10
+ import os
11
+ import sys
12
+ import urllib.error
13
+ import urllib.request
14
+ import xml.etree.ElementTree as ET
15
+
16
+ POLL_MS = 2000
17
+ VOL_STEP = 5 # in 0.1 dB units
18
+ VOL_MIN, VOL_MAX = -800, 165
19
+ # number key -> input; 0 is AUDIO, 1-5 are HDMI1-5, 6-9 are AV1-4
20
+ HOTKEYS = {"0": "AUDIO"} | {str(n): f"HDMI{n}" for n in range(1, 6)} | {str(n + 5): f"AV{n}" for n in range(1, 5)}
21
+ PROGRAMS = [
22
+ "Hall in Munich", "Hall in Vienna", "Chamber", "Cellar Club",
23
+ "The Roxy Theatre", "The Bottom Line", "Sci-Fi", "Action Game",
24
+ "Roleplaying Game", "Music Video", "Standard", "Spectacle", "Adventure",
25
+ "Drama", "Mono Movie", "Surround Decoder", "2ch Stereo", "7ch Stereo",
26
+ ]
27
+
28
+
29
+ class Receiver:
30
+ def __init__(self, host):
31
+ self.url = f"http://{host}/YamahaRemoteControl/ctrl"
32
+
33
+ def _send(self, cmd, inner):
34
+ body = f'<YAMAHA_AV cmd="{cmd}">{inner}</YAMAHA_AV>'.encode()
35
+ with urllib.request.urlopen(self.url, body, timeout=3) as r:
36
+ root = ET.fromstring(r.read())
37
+ rc = root.get("RC", "0")
38
+ if rc != "0":
39
+ raise ValueError(f"receiver returned RC={rc}")
40
+ return root
41
+
42
+ def put(self, inner):
43
+ self._send("PUT", inner)
44
+
45
+ def put_main(self, inner):
46
+ self.put(f"<Main_Zone>{inner}</Main_Zone>")
47
+
48
+ def status(self):
49
+ r = self._send("GET", "<Main_Zone><Basic_Status>GetParam</Basic_Status></Main_Zone>")
50
+ t = lambda p: (r.findtext(".//" + p) or "").strip()
51
+ return {
52
+ "power": t("Power_Control/Power") == "On",
53
+ "vol": int(t("Volume/Lvl/Val") or 0),
54
+ "mute": t("Volume/Mute") == "On",
55
+ "input": t("Input/Input_Sel"),
56
+ "program": t("Current/Sound_Program"),
57
+ "straight": t("Current/Straight") == "On",
58
+ "hdmi_out": t("HDMI/Output/OUT_1") == "On",
59
+ }
60
+
61
+ def inputs(self):
62
+ r = self._send("GET", "<Main_Zone><Input><Input_Sel_Item>GetParam</Input_Sel_Item></Input></Main_Zone>")
63
+ return [(i.findtext("Param"), i.findtext("Title").strip())
64
+ for i in r.find(".//Input_Sel_Item")
65
+ if i.findtext("RW") == "RW"]
66
+
67
+ def set_power(self, on):
68
+ self.put_main(f"<Power_Control><Power>{'On' if on else 'Standby'}</Power></Power_Control>")
69
+
70
+ def set_volume(self, v):
71
+ v = max(VOL_MIN, min(VOL_MAX, v))
72
+ self.put_main(f"<Volume><Lvl><Val>{v}</Val><Exp>1</Exp><Unit>dB</Unit></Lvl></Volume>")
73
+
74
+ def set_mute(self, on):
75
+ self.put_main(f"<Volume><Mute>{'On' if on else 'Off'}</Mute></Volume>")
76
+
77
+ def set_input(self, name):
78
+ self.put_main(f"<Input><Input_Sel>{name}</Input_Sel></Input>")
79
+
80
+ def set_program(self, name):
81
+ self.put_main(f"<Surround><Program_Sel><Current><Sound_Program>{name}</Sound_Program></Current></Program_Sel></Surround>")
82
+
83
+ def set_hdmi_out(self, on):
84
+ self.put_main(f"<Sound_Video><HDMI><Output><OUT_1>{'On' if on else 'Off'}</OUT_1></Output></HDMI></Sound_Video>")
85
+
86
+
87
+ def draw(scr, host, st, err, inputs):
88
+ scr.erase()
89
+ h, w = scr.getmaxyx()
90
+
91
+ def put(y, text, attr=0):
92
+ if y < h - 1:
93
+ scr.addnstr(y, 2, text, max(w - 3, 0), attr)
94
+
95
+ put(0, f"Yamaha RX-V475 {host}", curses.A_BOLD)
96
+ if err:
97
+ put(2, f"! {err}", curses.A_REVERSE)
98
+ if st:
99
+ db = st["vol"] / 10
100
+ barw = max(min(w - 30, 40), 5)
101
+ fill = round(barw * (st["vol"] - VOL_MIN) / (VOL_MAX - VOL_MIN))
102
+ put(3, f"Power {'ON' if st['power'] else 'standby'}", curses.A_BOLD if st["power"] else 0)
103
+ put(4, f"Volume [{'#' * fill}{'-' * (barw - fill)}] {db:+.1f} dB")
104
+ put(5, f"Mute {'ON' if st['mute'] else 'off'}")
105
+ put(6, f"HDMI out {'on' if st['hdmi_out'] else 'OFF'}")
106
+ put(7, f"Program {st['program']}{' (straight)' if st['straight'] else ''}")
107
+ put(9, "Input", curses.A_BOLD)
108
+ for n, (param, title) in enumerate(inputs):
109
+ cur = param == st["input"]
110
+ key = next((k for k, v in HOTKEYS.items() if v == param), " ")
111
+ put(10 + n, f"{'>' if cur else ' '} {key} {param:<10} {title}", curses.A_REVERSE if cur else 0)
112
+ help_lines = [
113
+ "p power",
114
+ "↑ / ↓ volume",
115
+ "m mute",
116
+ "0-9 input (see list)",
117
+ "← / → prev / next input",
118
+ "s next sound program",
119
+ "o HDMI out on/off",
120
+ "r refresh",
121
+ "q quit",
122
+ ]
123
+ if w >= 78:
124
+ for n, line in enumerate(help_lines):
125
+ if 3 + n < h:
126
+ scr.addnstr(3 + n, 52, line, w - 53, curses.A_DIM)
127
+ elif h > 1:
128
+ scr.addnstr(h - 1, 2, "p pwr ↑↓ vol m mute 0-9/←→ input s prog o hdmi r q", max(w - 3, 0), curses.A_DIM)
129
+ scr.refresh()
130
+
131
+
132
+ def main(scr, host):
133
+ curses.curs_set(0)
134
+ scr.keypad(True)
135
+ scr.timeout(POLL_MS)
136
+ rx = Receiver(host)
137
+ st, err, inputs = None, None, []
138
+
139
+ def refresh():
140
+ nonlocal st, err, inputs
141
+ try:
142
+ st = rx.status()
143
+ if not inputs:
144
+ inputs = rx.inputs()
145
+ err = None
146
+ except (urllib.error.URLError, OSError, ValueError, ET.ParseError) as e:
147
+ err = f"{type(e).__name__}: {getattr(e, 'reason', e)}"
148
+
149
+ def act(fn, *args):
150
+ nonlocal err
151
+ try:
152
+ fn(*args)
153
+ except (urllib.error.URLError, OSError, ValueError, ET.ParseError) as e:
154
+ err = f"{type(e).__name__}: {getattr(e, 'reason', e)}"
155
+ return
156
+ refresh()
157
+
158
+ refresh()
159
+ while True:
160
+ draw(scr, host, st, err, inputs)
161
+ k = scr.getch()
162
+ if k in (ord("q"), 27):
163
+ return
164
+ if k in (-1, ord("r"), curses.KEY_RESIZE):
165
+ refresh()
166
+ elif not st:
167
+ continue
168
+ elif k == ord("p"):
169
+ act(rx.set_power, not st["power"])
170
+ elif k in (curses.KEY_UP, ord("+"), ord("=")):
171
+ act(rx.set_volume, st["vol"] + VOL_STEP)
172
+ elif k in (curses.KEY_DOWN, ord("-")):
173
+ act(rx.set_volume, st["vol"] - VOL_STEP)
174
+ elif k == ord("m"):
175
+ act(rx.set_mute, not st["mute"])
176
+ elif k == ord("o"):
177
+ act(rx.set_hdmi_out, not st["hdmi_out"])
178
+ elif k == ord("s"):
179
+ cur = PROGRAMS.index(st["program"]) if st["program"] in PROGRAMS else -1
180
+ act(rx.set_program, PROGRAMS[(cur + 1) % len(PROGRAMS)])
181
+ elif 0 <= k < 256 and chr(k) in HOTKEYS and HOTKEYS[chr(k)] in dict(inputs):
182
+ act(rx.set_input, HOTKEYS[chr(k)])
183
+ elif k in (curses.KEY_RIGHT, curses.KEY_LEFT, ord("\t")) and inputs:
184
+ names = [p for p, _ in inputs]
185
+ cur = names.index(st["input"]) if st["input"] in names else 0
186
+ step = -1 if k == curses.KEY_LEFT else 1
187
+ act(rx.set_input, names[(cur + step) % len(names)])
188
+
189
+
190
+ def cli():
191
+ host = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("YAMAHA_HOST", "192.168.0.94")
192
+ try:
193
+ curses.wrapper(main, host)
194
+ except KeyboardInterrupt:
195
+ pass
196
+
197
+
198
+ if __name__ == "__main__":
199
+ cli()
yam_tui-0.1.0/uv.lock ADDED
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 5
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "yam-tui"
7
+ version = "0.1.0"
8
+ source = { editable = "." }