nmos-cmd 0.0.1__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.
nmos_cmd/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from .version import __version__
nmos_cmd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cmd import cli
2
+ from .utility import load_config
3
+
4
+ def main():
5
+ cli(default_map=load_config())
nmos_cmd/cmd.py ADDED
@@ -0,0 +1,312 @@
1
+ import os
2
+ from pathlib import Path
3
+ import json
4
+ import requests
5
+ import click
6
+ from .utility import get_folder_in_config as _F
7
+ from .utility import success, error, info, load_config, save_config
8
+
9
+
10
+ class NMOS:
11
+
12
+ def __init__(self, is04_ver=None, is04_port=None, is05_ver=None, is05_port=None):
13
+ cfg = load_config()
14
+ self.cfg = {'timeout': 30,
15
+ 'is04_ver': '1.2',
16
+ 'is04_port': 3212,
17
+ 'is05_ver': '1.0',
18
+ 'is05_port': 3215}
19
+ if "main" in cfg:
20
+ self.cfg.update(cfg['main'])
21
+ self.timeout = self._set_with_default(30, self.cfg['timeout'])
22
+ self.is04_ver = self._set_with_default(is04_ver, self.cfg['is04_ver'])
23
+ self.is04_port = self._set_with_default(is04_port, self.cfg['is04_port'])
24
+ self.is05_ver = self._set_with_default(is05_ver, self.cfg['is05_ver'])
25
+ self.is05_port = self._set_with_default(is05_port, self.cfg['is05_port'])
26
+
27
+ def _set_with_default(self, value, default=None):
28
+ if value is not None:
29
+ return value
30
+ return default
31
+
32
+ def get_is04_url(self, device):
33
+ """
34
+ Generate the IS04 url (node) for device
35
+ """
36
+ return f"http://{device}:{self.is04_port}/x-nmos/node/v{self.is04_ver}"
37
+
38
+ def get_is05_url(self, device):
39
+ """
40
+ Generate the IS05 url (connection) for device
41
+ """
42
+ return f"http://{device}:{self.is05_port}/x-nmos/connection/v{self.is05_ver}"
43
+
44
+ def get_receiver(self, device, receiver):
45
+ """
46
+ Return the receiver streams of "device" filtered by the name in "receiver"
47
+
48
+ The output is the dictionary: {stream label: stream id}
49
+ """
50
+ result = {}
51
+ res = requests.get(f"{self.get_is04_url(device)}/devices/", timeout=self.timeout)
52
+ d = res.json()
53
+ if isinstance(receiver, str):
54
+ receiver = [receiver]
55
+ for dd in d:
56
+ for rid in dd["receivers"]:
57
+ r = requests.get(f"{self.get_is04_url(device)}/receivers/{rid}",
58
+ timeout=self.timeout)
59
+ r = r.json()
60
+ if not receiver or all(rec in r["label"] for rec in receiver):
61
+ result[r['label']] = r['id']
62
+
63
+ return result
64
+
65
+ def get_sender(self, device, sender, download=False):
66
+ """
67
+ Return the sender streams of "device" filtered by the name in "sender"
68
+
69
+ The output is the dictionary: {stream label: stream id}, ant it will also
70
+ download the corresponding sdp to be used by the receiver streams.
71
+ """
72
+ result = {}
73
+ res = requests.get(f"{self.get_is04_url(device)}/devices/", timeout=self.timeout)
74
+ d = res.json()
75
+ if isinstance(sender, str):
76
+ sender = [sender]
77
+ for dd in d:
78
+ for sid in dd["senders"]:
79
+ r = requests.get(f"{self.get_is04_url(device)}/senders/{sid}", timeout=self.timeout)
80
+ r = r.json()
81
+ if not sender or all(rec in r["label"] for rec in sender):
82
+ result[r['label']] = r['id']
83
+ if download:
84
+ self.download_sdp(r['manifest_href'], _F(device), r['id'])
85
+
86
+ return result
87
+
88
+ def download_sdp(self, url, folder, filename=""):
89
+ """
90
+ Download the sdp file from url to folder/filename.
91
+ """
92
+ try:
93
+ response = requests.get(url, timeout=self.timeout)
94
+ # Raise an exception for bad status codes (4XX or 5XX)
95
+ response.raise_for_status()
96
+ if not filename:
97
+ filename = Path(url).name
98
+ os.makedirs(folder, exist_ok=True)
99
+ with open(Path(folder)/filename, 'wb') as file:
100
+ file.write(response.content)
101
+ except requests.exceptions.RequestException as e:
102
+ error(f"Download '{url}' failed: {e}")
103
+
104
+
105
+ def list_device(self, device, receiver, sender):
106
+ """
107
+ List the sender and receiver streams from device
108
+ """
109
+ res = requests.get(f"{self.get_is04_url(device)}/devices/", timeout=self.timeout)
110
+ d = res.json()
111
+ os.makedirs(_F(device), exist_ok=True)
112
+ for dd in d:
113
+ info(dd["description"])
114
+ for rid in dd["receivers"]:
115
+ r = requests.get(f"{self.get_is04_url(device)}/receivers/{rid}",
116
+ timeout=self.timeout)
117
+ r = r.json()
118
+ if not receiver or all(rx in r["label"] for rx in receiver):
119
+ info(f" {r['label']} {r['id']}")
120
+ for sid in dd["senders"]:
121
+ r = requests.get(f"{self.get_is04_url(device)}/senders/{sid}", timeout=self.timeout)
122
+ r = r.json()
123
+ if not sender or all(tx in r["label"] for tx in sender):
124
+ info(f" {r['label']} {r['id']} {r['manifest_href']}")
125
+
126
+ def _parse_device(self, device, port, ver):
127
+ """
128
+ Parse the device, supported format IP@name or IP:port@name or IP:port:version@name
129
+
130
+ If port/version is defined in "device", it will take priority.
131
+ """
132
+ ip, name = device.split('@')
133
+ ip = ip.split(':')
134
+ if port is None:
135
+ port = self.is04_port
136
+ if ver is None:
137
+ ver = self.is04_ver
138
+ if len(ip) == 1:
139
+ ip = ip[0]
140
+ elif len(ip) == 2:
141
+ ip, port = ip
142
+ elif len(ip) == 3:
143
+ ip, port, ver = ip
144
+ return ip, port, ver, name
145
+
146
+ def generate_patch(self, sender, sender_port, sender_version, receiver,
147
+ receiver_port, receiver_version, stream, output):
148
+ """
149
+ Generate the config json file to connect the receiver streams to the
150
+ corresponding sender streams.
151
+ """
152
+ info("Get sender streams")
153
+ tx_ip, tx_port, tx_ver, tx_name = self._parse_device(sender, sender_port, sender_version)
154
+ tx_d = NMOS(is04_port=tx_port, is04_ver=tx_ver)
155
+ tx = tx_d.get_sender(tx_ip, tx_name, True)
156
+
157
+ info("Get receiver streams")
158
+ rx_ip, rx_port, rx_ver, rx_name = self._parse_device(receiver, receiver_port,
159
+ receiver_version)
160
+ rx_d = NMOS(is04_port=rx_port, is04_ver=rx_ver)
161
+ rx = rx_d.get_receiver(rx_ip, rx_name)
162
+ mapping = []
163
+ info("Connect sender streams to receiver streams")
164
+ for s in stream:
165
+ s = s.split(":")
166
+ if len(s) == 1:
167
+ s = [s, s]
168
+ # find the tx stream
169
+ s_tx = [ r for r in tx if s[0] in r]
170
+ if len(s_tx) != 1:
171
+ error(f"Multiple or no streams found in sender for '{s[0]}'!")
172
+ return
173
+ s_tx = s_tx[0]
174
+
175
+ # find the rx stream
176
+ s_rx = [ r for r in rx if s[1] in r]
177
+ if len(s_rx) != 1:
178
+ error(f"Multiple or no streams found in receiver '{s[1]}'!")
179
+ return
180
+ s_rx = s_rx[0]
181
+
182
+ with open(Path(_F(tx_ip))/tx[s_tx], 'r', encoding='utf-8') as file:
183
+ sdp = file.read()
184
+ mapping.append([rx_ip, s_rx, rx[s_rx], sdp])
185
+
186
+ # save the patch configuration
187
+ p = Path(output)
188
+ if not p.suffix:
189
+ p = p.with_suffix('.json')
190
+ info(f"Save the config to {p}")
191
+ with open(p, "w", encoding='utf-8') as json_file:
192
+ json.dump(mapping, json_file, indent=4)
193
+
194
+ def apply_patch(self, cfg):
195
+ """
196
+ Sent the PATCH command one by one defined in the configuration json file.
197
+ """
198
+ with open(cfg, 'r', encoding='utf-8') as file:
199
+ cfg = json.load(file)
200
+ for c in cfg:
201
+ device, rx_name, rx, sdp = c
202
+ data = {"activation":{"mode": "activate_immediate",
203
+ "requested_time": None
204
+ },
205
+ "transport_file": {"data": "\r\n".join(sdp.lstrip().split('\n')),
206
+ "type": "application/sdp"
207
+ }
208
+ }
209
+
210
+ url = f'{self.get_is05_url(device)}/single/receivers/{rx}/staged'
211
+ response = requests.patch(url, json=data, timeout=self.timeout)
212
+ if response.status_code != 200:
213
+ error(f"Patch '{rx_name}' failed: {response.status_code}")
214
+ error(f" {url}")
215
+ else:
216
+ success(f"Patch '{rx_name}' succeeded: {response.status_code}")
217
+
218
+ @click.group()
219
+ def cli():
220
+ """
221
+ A tool to connect the NMOS sender streams to receiver streams.
222
+ """
223
+
224
+ @cli.command(name="list", context_settings={'show_default': True})
225
+ @click.option('--device', help='the device IP')
226
+ @click.option('--port', default=3212, help='NMOS IS04 port')
227
+ @click.option('--version', default="1.2", type=click.Choice(['1.0', '1.1', '1.2', '1.3']),
228
+ help='NMOS IS04 version')
229
+ @click.option('--receiver', multiple=True, help='only show the receiver streams with specific name')
230
+ @click.option('--sender', multiple=True, help='only show the sender streams with specific name')
231
+ def list_device(device, port, version, receiver, sender):
232
+ """
233
+ List streams of a device.
234
+ """
235
+ n = NMOS(is04_ver=version, is04_port=port)
236
+ n.list_device(device, receiver, sender)
237
+
238
+ @cli.group()
239
+ def patch():
240
+ """
241
+ Send NMOS PATCH command.
242
+
243
+ e.g., to connect the sender streams to receiver streams.
244
+ """
245
+
246
+ @patch.command(name="config", context_settings={'show_default': True})
247
+ @click.option('--sender', help='sender device IP@name or IP:port@name or IP:port:version@name')
248
+ @click.option('--sender_port', default=3212, help='NMOS IS04 port')
249
+ @click.option('--sender_version', default="1.2", type=click.Choice(['1.0', '1.1', '1.2', '1.3']),
250
+ help='NMOS IS04 version')
251
+ @click.option('--receiver', help='receiver device IP@name or IP:port@name or IP:port:version@name')
252
+ @click.option('--receiver_port', default=3212, help='NMOS IS04 port')
253
+ @click.option('--receiver_version', default="1.2", type=click.Choice(['1.0', '1.1', '1.2', '1.3']),
254
+ help='NMOS IS04 version')
255
+ @click.option('--stream', default=["video:video", "audio output 1:audio input 1", "data:data"],
256
+ multiple=True, help='the stream to be configured, in format "sender stream"@"receiver stream"')
257
+ @click.option('--output', help='the output patch configuration filename')
258
+ def generate_patch(sender, sender_port, sender_version, receiver, receiver_port,
259
+ receiver_version, stream, output):
260
+ """
261
+ Generate the configuration file to connect the sender streams to the receiver streams.
262
+ """
263
+ n = NMOS()
264
+ n.generate_patch(sender, sender_port, sender_version, receiver, receiver_port,
265
+ receiver_version, stream, output)
266
+
267
+ @patch.command(name="apply", context_settings={'show_default': True})
268
+ @click.option('--config', 'cfg', default="config.json", help='the patch configuration file')
269
+ @click.option('--port', default=3215, help='NMOS IS05 port')
270
+ @click.option('--version', default='1.0', type=click.Choice(['1.0', '1.1']),
271
+ help='NMOS IS05 version')
272
+ def apply_patch(cfg, port, version):
273
+ """
274
+ Apply the PATCH to the receiver streams defined in "--config".
275
+ """
276
+ n = NMOS(is05_ver=version, is05_port=port)
277
+ n.apply_patch(cfg)
278
+
279
+ @cli.group(name="config", context_settings={'show_default': True})
280
+ def config():
281
+ """
282
+ Set default option for each command/subcommand.
283
+ """
284
+
285
+ @config.command(name="set", context_settings={'show_default': True})
286
+ @click.argument('key')
287
+ @click.argument('value')
288
+ def config_set(key, value):
289
+ """
290
+ Set default option for each command.
291
+
292
+ For example, the following command will set the default port for "list" command to 3213
293
+
294
+ nmos-patch config set list.port 3213
295
+ """
296
+
297
+ cfg = load_config()
298
+ t = cfg
299
+ for k in key.split('.')[:-1]:
300
+ if k not in cfg:
301
+ t[k] = {}
302
+ t = t[k]
303
+ t[key.split('.')[-1]] = value
304
+ save_config(cfg)
305
+
306
+ @config.command(name="list", context_settings={'show_default': True})
307
+ def config_list():
308
+ """
309
+ List all the default options
310
+ """
311
+ cfg = load_config()
312
+ info(json.dumps(cfg, indent=4))
nmos_cmd/utility.py ADDED
@@ -0,0 +1,55 @@
1
+ from pathlib import Path
2
+ import json
3
+ import platformdirs
4
+ import click
5
+ import tqdm
6
+ from .version import PROJECT_NAME
7
+
8
+ def get_config_dir():
9
+ config_dir = platformdirs.user_config_dir(PROJECT_NAME, "nmos-cmd team")
10
+ return config_dir
11
+
12
+ def get_folder_in_config(folder):
13
+ return Path(get_config_dir())/folder
14
+
15
+
16
+ def echo(*args, **kwargs):
17
+ if not kwargs.get('tqdm_mode', False):
18
+ click.secho(*args, **kwargs)
19
+ else:
20
+ end = '\n'
21
+ if not kwargs.get('nl', True):
22
+ end = ''
23
+ kwargs.pop('nl', None)
24
+ tqdm.tqdm.write(click.style(*args, **kwargs), end=end)
25
+
26
+ def info(*args, **kwargs):
27
+ echo(*args, **kwargs)
28
+
29
+ def error(*args, **kwargs):
30
+ if 'fg' not in kwargs:
31
+ kwargs['fg'] = 'red'
32
+ echo(*args, **kwargs)
33
+
34
+ def warning(*args, **kwargs):
35
+ if 'fg' not in kwargs:
36
+ kwargs['fg'] = 'blue'
37
+ echo(*args, **kwargs)
38
+
39
+ def success(*args, **kwargs):
40
+ if 'fg' not in kwargs:
41
+ kwargs['fg'] = 'green'
42
+ info(*args, **kwargs)
43
+
44
+ def load_config():
45
+ cfg = {}
46
+ filename = Path(get_config_dir())/f"{PROJECT_NAME}.ini"
47
+ if filename.is_file():
48
+ with open(filename, 'r', encoding='utf-8') as file:
49
+ cfg = json.load(file)
50
+ return cfg
51
+
52
+ def save_config(cfg):
53
+ filename = Path(get_config_dir())/f"{PROJECT_NAME}.ini"
54
+ with open(filename, 'w', encoding='utf-8') as file:
55
+ json.dump(cfg, file, indent=4)
nmos_cmd/version.py ADDED
@@ -0,0 +1,9 @@
1
+ PROJECT_NAME = 'nmos-cmd'
2
+
3
+ VER_MAJOR = 0
4
+ VER_MINOR = 0
5
+ VER_RELEASE = 1
6
+
7
+ VER_FLAGS = ""
8
+
9
+ __version__ = f'{VER_MAJOR}.{VER_MINOR}.{VER_RELEASE}{VER_FLAGS}'
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: nmos-cmd
3
+ Version: 0.0.1
4
+ Summary: nmos-cmd is a command line tool to send NMOS requests
5
+ Author-email: Tianzhu Qiao <tq@feiyilin.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/tianzhuqiao/nmos-cmd
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.0
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: click>=8.1
13
+ Requires-Dist: packaging
14
+ Requires-Dist: platformdirs
15
+ Requires-Dist: requests
16
+ Requires-Dist: tqdm
17
+ Dynamic: license-file
18
+
19
+ # nmos-patch
20
+ **nmos-cmd** is a tool to make it easier to send [NMOS](https://specs.amwa.tv/nmos/) requests.
21
+
22
+
23
+ ## Installation
24
+ ```
25
+ $ pip install nmos-cmd
26
+ ```
27
+
28
+ To start from terminal
29
+ ```
30
+ $ nmos-cmd --help
31
+ Usage: nmos-cmd [OPTIONS] COMMAND [ARGS]...
32
+
33
+ A tool to connect the NMOS sender streams to receiver streams.
34
+
35
+ Options:
36
+ --help Show this message and exit.
37
+
38
+ Commands:
39
+ config Set default option for each command/subcommand.
40
+ list List streams of a device.
41
+ patch Send NMOS PATCH command.
42
+ ```
43
+
@@ -0,0 +1,11 @@
1
+ nmos_cmd/__init__.py,sha256=Jzb_RfgvXCrm_SQ4AfeGVi1N36YybxnM5mpyxrnihgI,33
2
+ nmos_cmd/__main__.py,sha256=9n_evVQeeWJM7ad-3rM4uF9uy03rYe-B7XKRrdP3fYY,102
3
+ nmos_cmd/cmd.py,sha256=1zj8g2tZQAmtxkQ5aGPvRpMntoFjQpmjryogBrqCW-U,12352
4
+ nmos_cmd/utility.py,sha256=p9gZKFXts2WkjT9ifV6EjSusW1HfrKhaINY3ZlKs7Zk,1407
5
+ nmos_cmd/version.py,sha256=8CIQlFZjA6U1eXPY8U73OUUWIYf4nCvlnsOb4yJ1KMA,193
6
+ nmos_cmd-0.0.1.dist-info/licenses/LICENSE,sha256=7s89MNphVRVRMzPSy7iJFZUdJa_96B-Dps6lb-JPWZ0,1090
7
+ nmos_cmd-0.0.1.dist-info/METADATA,sha256=uuiv-PSOviLuFpd9aASAqiAzw545nYt_TDX4OU70GPg,1031
8
+ nmos_cmd-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
9
+ nmos_cmd-0.0.1.dist-info/entry_points.txt,sha256=XujchMayA6fatP1x1dUuBwzUdCbLFrBKpg_olo931hg,52
10
+ nmos_cmd-0.0.1.dist-info/top_level.txt,sha256=N_aRZs3cuWMwBpB56JB34t8ZaL4Wim4Uc9yNKLEE43Y,9
11
+ nmos_cmd-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nmos-cmd = nmos_cmd.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tianzhu Qiao
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.
@@ -0,0 +1 @@
1
+ nmos_cmd