luxos 0.0.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.
luxos/__init__.py ADDED
File without changes
luxos/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from luxos.scripts.luxos import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
luxos/api.json ADDED
@@ -0,0 +1,215 @@
1
+ {
2
+ "addgroup": {
3
+ "logon_required": false
4
+ },
5
+ "addpool": {
6
+ "logon_required": false
7
+ },
8
+ "asc": {
9
+ "logon_required": false
10
+ },
11
+ "asccount": {
12
+ "logon_required": false
13
+ },
14
+ "atm": {
15
+ "logon_required": false
16
+ },
17
+ "atmset": {
18
+ "logon_required": true
19
+ },
20
+ "autotunerget": {
21
+ "logon_required": false
22
+ },
23
+ "autotunerset": {
24
+ "logon_required": true
25
+ },
26
+ "check": {
27
+ "logon_required": false
28
+ },
29
+ "coin": {
30
+ "logon_required": false
31
+ },
32
+ "config": {
33
+ "logon_required": false
34
+ },
35
+ "curtail": {
36
+ "logon_required": true
37
+ },
38
+ "devdetails": {
39
+ "logon_required": false
40
+ },
41
+ "devs": {
42
+ "logon_required": false
43
+ },
44
+ "disableboard": {
45
+ "logon_required": true
46
+ },
47
+ "disablepool": {
48
+ "logon_required": false
49
+ },
50
+ "edevs": {
51
+ "logon_required": false
52
+ },
53
+ "enableboard": {
54
+ "logon_required": true
55
+ },
56
+ "enablepool": {
57
+ "logon_required": false
58
+ },
59
+ "estats": {
60
+ "logon_required": false
61
+ },
62
+ "fans": {
63
+ "logon_required": false
64
+ },
65
+ "fanset": {
66
+ "logon_required": true
67
+ },
68
+ "frequencyget": {
69
+ "logon_required": false
70
+ },
71
+ "frequencyset": {
72
+ "logon_required": true
73
+ },
74
+ "frequencystop": {
75
+ "logon_required": true
76
+ },
77
+ "groupquota": {
78
+ "logon_required": false
79
+ },
80
+ "healthchipget": {
81
+ "logon_required": false
82
+ },
83
+ "healthchipset": {
84
+ "logon_required": true
85
+ },
86
+ "healthctrl": {
87
+ "logon_required": false
88
+ },
89
+ "healthctrlset": {
90
+ "logon_required": true
91
+ },
92
+ "immersionswitch": {
93
+ "logon_required": true
94
+ },
95
+ "kill": {
96
+ "logon_required": false
97
+ },
98
+ "lcd": {
99
+ "logon_required": false
100
+ },
101
+ "limits": {
102
+ "logon_required": false
103
+ },
104
+ "logoff": {
105
+ "logon_required": true
106
+ },
107
+ "logon": {
108
+ "logon_required": false
109
+ },
110
+ "logset": {
111
+ "logon_required": false
112
+ },
113
+ "netset": {
114
+ "logon_required": true
115
+ },
116
+ "poolopts": {
117
+ "logon_required": false
118
+ },
119
+ "pooloptsset": {
120
+ "logon_required": false
121
+ },
122
+ "pools": {
123
+ "logon_required": false
124
+ },
125
+ "power": {
126
+ "logon_required": false
127
+ },
128
+ "profileget": {
129
+ "logon_required": false
130
+ },
131
+ "profilenew": {
132
+ "logon_required": true
133
+ },
134
+ "profilerem": {
135
+ "logon_required": true
136
+ },
137
+ "profiles": {
138
+ "logon_required": false
139
+ },
140
+ "profilerestore": {
141
+ "logon_required": true
142
+ },
143
+ "profileset": {
144
+ "logon_required": true
145
+ },
146
+ "reboot": {
147
+ "logon_required": true
148
+ },
149
+ "rebootdevice": {
150
+ "logon_required": true
151
+ },
152
+ "removegroup": {
153
+ "logon_required": false
154
+ },
155
+ "resetminer": {
156
+ "logon_required": true
157
+ },
158
+ "resetconfig": {
159
+ "logon_required": true
160
+ },
161
+ "removepool": {
162
+ "logon_required": false
163
+ },
164
+ "session": {
165
+ "logon_required": false
166
+ },
167
+ "stats": {
168
+ "logon_required": false
169
+ },
170
+ "summary": {
171
+ "logon_required": false
172
+ },
173
+ "switchpool": {
174
+ "logon_required": false
175
+ },
176
+ "tempctrl": {
177
+ "logon_required": false
178
+ },
179
+ "tempctrlset": {
180
+ "logon_required": true
181
+ },
182
+ "temps": {
183
+ "logon_required": false
184
+ },
185
+ "tempsensor": {
186
+ "logon_required": false
187
+ },
188
+ "tempsensorset": {
189
+ "logon_required": true
190
+ },
191
+ "tunerstatus": {
192
+ "logon_required": false
193
+ },
194
+ "tunerswitch": {
195
+ "logon_required": true
196
+ },
197
+ "uninstallluxos": {
198
+ "logon_required": true
199
+ },
200
+ "updaterun": {
201
+ "logon_required": true
202
+ },
203
+ "updateset": {
204
+ "logon_required": true
205
+ },
206
+ "version": {
207
+ "logon_required": false
208
+ },
209
+ "voltageget": {
210
+ "logon_required": false
211
+ },
212
+ "voltageset": {
213
+ "logon_required": true
214
+ }
215
+ }
luxos/api.py ADDED
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import json
5
+ import importlib.resources
6
+
7
+ log = logging.getLogger(__name__)
8
+
9
+
10
+ COMMANDS = json.loads(
11
+ (importlib.resources.files("luxos") / "api.json")
12
+ .read_text()
13
+ )
14
+
15
+
16
+ def logon_required(cmd: str, commands_list=COMMANDS) -> bool | None:
17
+ # Check if the command requires logon to LuxOS API
18
+
19
+ if cmd not in COMMANDS:
20
+ log.info("%s command is not supported. "
21
+ "Try again with a different command.", cmd)
22
+ return None
23
+
24
+ return COMMANDS[cmd]["logon_required"]
25
+
26
+
27
+ # TODO timeouts should be float | None
28
+ def execute_command(host: str, port: int, timeout: int, cmd: str, params: list[str], verbose: bool):
29
+ from .scripts import luxos
30
+ return luxos.execute_command(host, port, timeout, cmd, params, verbose)
31
+
32
+
luxos/asyncops.py ADDED
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import logging
5
+ import asyncio
6
+ import json
7
+
8
+ from typing import Any
9
+
10
+ from . import exceptions
11
+ from . import api
12
+
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ TIMEOUT = 3.0 # default timeout for operations
17
+
18
+
19
+ def wrapped(function):
20
+ """wraps a function acting on a host and re-raise with internal exceptions
21
+
22
+ This re-raise exceptions so they all derive from MinerConnectionError, eg:
23
+ @wrapped
24
+ def somcode(host: str, port: int, ...):
25
+ ...
26
+
27
+ try:
28
+ await somecode()
29
+ except MinerConnectionError as e: <- this will catch all exceptions!
30
+ e.address
31
+ raise MyNewExecption() from e <- this will re-raise
32
+ """
33
+ @functools.wraps(function)
34
+ async def _function(host: str, port: int, *args, **kwargs):
35
+ try:
36
+ return await function(host, port, *args, **kwargs)
37
+ except asyncio.TimeoutError as e:
38
+ # we augment underlying TimeOuts
39
+ raise exceptions.MinerCommandTimeoutError(host, port) from e
40
+ except exceptions.MinerConnectionError:
41
+ raise
42
+ except Exception as e:
43
+ # we augment any other exception with (host, port) info
44
+ log.exception("internal error")
45
+ raise exceptions.MinerConnectionError(host, port, "internal error") from e
46
+ return _function
47
+
48
+
49
+ async def _roundtrip(
50
+ host: str, port: int, cmd: bytes | str, timeout: float | None
51
+ ) -> str:
52
+ """simple asyncio socket based send/receive function
53
+
54
+ Example:
55
+ print(await _roundtrip(host, port, version))
56
+ -> (str) "{'STATUS': [{'Code': 22, 'Description'...."
57
+ """
58
+ reader, writer = await asyncio.wait_for(
59
+ asyncio.open_connection(host, port), timeout
60
+ )
61
+
62
+ writer.write(cmd.encode() if isinstance(cmd, str) else cmd)
63
+ await writer.drain()
64
+
65
+ response = bytearray()
66
+ while True:
67
+ data = await asyncio.wait_for(reader.read(1), timeout=timeout)
68
+ if not data:
69
+ break
70
+ null_index = data.find(b"\x00")
71
+ if null_index >= 0:
72
+ response += data[:null_index]
73
+ break
74
+ response += data
75
+
76
+ return response.decode()
77
+
78
+ # TODO add annotations
79
+ async def roundtrip(
80
+ host: str,
81
+ port: int,
82
+ cmd: bytes | str | dict[str, Any],
83
+ timeout: float | None = None,
84
+ asjson: bool | None = True,
85
+ retry: int = 0,
86
+ retry_delay: float | None = None,
87
+ ):
88
+ """utility wrapper around _roundrip
89
+
90
+ Example:
91
+ print(await roundtrip(host, port, {"version"}))
92
+ -> (json) {'STATUS': [{'Code': 22, 'Description': 'LUXminer 20 ...
93
+ print(await roundtrip(host, port, "version"))
94
+ -> (str) "{'STATUS': [{'Code': 22, 'Description': 'LUXminer 20 ..
95
+ """
96
+ timeout = TIMEOUT if timeout is None else timeout
97
+ count = 0
98
+
99
+ if not isinstance(cmd, (bytes, str)):
100
+ cmd = json.dumps(cmd, indent=2, sort_keys=True)
101
+ if asjson is None:
102
+ asjson = True
103
+
104
+ last_exception = None
105
+ while count <= retry:
106
+ try:
107
+ timeout = TIMEOUT if timeout is None else timeout
108
+ res = await _roundtrip(host, port, cmd, timeout)
109
+ if asjson:
110
+ return json.loads(res)
111
+ else:
112
+ return res
113
+ except (Exception, asyncio.TimeoutError) as e:
114
+ last_exception = e
115
+ if retry_delay:
116
+ await asyncio.sleep(retry_delay)
117
+ count += 1
118
+ if last_exception is not None:
119
+ raise last_exception
120
+
121
+
122
+ def validate_message(
123
+ host: str,
124
+ port: int,
125
+ res: dict[str, Any],
126
+ extrakey: str | None = None,
127
+ minfields: None | int = None,
128
+ maxfields: None | int = None,
129
+ ) -> dict[str, Any]:
130
+ # all miner message comes with a STATUS
131
+ for key in ["STATUS", "id", *([extrakey] if extrakey else [])]:
132
+ if key in res:
133
+ continue
134
+ raise exceptions.MinerCommandMalformedMessageError(host, port, f"missing {key} from logon message", res)
135
+
136
+ if not extrakey or not (minfields or maxfields):
137
+ return res
138
+
139
+ n = len(res[extrakey])
140
+ msg = None
141
+ if minfields and (n < minfields):
142
+ msg = f"found {n} fields for {extrakey} invalid: " f"{n} <= {minfields}"
143
+ elif maxfields and (n > maxfields):
144
+ msg = f"found {n} fields for {extrakey} invalid: " f"{n} >= {maxfields}"
145
+ if msg is None:
146
+ return res[extrakey]
147
+ raise exceptions.MinerCommandMalformedMessageError(host, port, msg, res)
148
+
149
+
150
+ @wrapped
151
+ async def logon(host: str, port: int, timeout: float | None = 3) -> str:
152
+ timeout = TIMEOUT if timeout is None else timeout
153
+ res = await roundtrip(host, port, {"command": "logon"}, timeout)
154
+
155
+ # when we first logon, we'll receive a token (session_id)
156
+ # [STATUS][SessionID]
157
+ # on subsequent logon, we receive a
158
+ # [STATUS][Msg] == "Another session is active" ([STATUS][Code] 402)
159
+ if "SESSION" not in res and res.get("STATUS", [{}])[0].get("Code") == 402:
160
+ raise exceptions.MinerCommandSessionAlreadyActive(host, port, "connection active", res)
161
+ sessions = validate_message(host, port, res, "SESSION", 1, 1)
162
+
163
+ session = sessions[0] # type: ignore
164
+
165
+ if "SessionID" not in session:
166
+ raise exceptions.MinerCommandSessionAlreadyActive(host, port, "no SessionID in data", res)
167
+ return str(session["SessionID"])
168
+
169
+
170
+ @wrapped
171
+ async def logoff(
172
+ host: str, port: int, sid: str, timeout: float | None = 3
173
+ ) -> dict[str, Any]:
174
+ timeout = TIMEOUT if timeout is None else timeout
175
+ return await roundtrip(host, port, {"command": "logoff", "parameter": sid}, timeout)
176
+
177
+
178
+ @wrapped
179
+ async def execute_command(
180
+ host: str,
181
+ port: int,
182
+ timeout_sec: float | None,
183
+ cmd: str,
184
+ parameters: list[str] | None = None,
185
+ verbose: bool = False,
186
+ asjson: bool | None = True,
187
+ add_address: bool = False
188
+ ) -> tuple[tuple[str, int], dict[str, Any]] | dict[str, Any]:
189
+ timeout = TIMEOUT if timeout_sec is None else timeout_sec
190
+ parameters = parameters or []
191
+
192
+ sid = None
193
+ if api.logon_required(cmd):
194
+ sid = await logon(host, port)
195
+ parameters = [sid, *parameters]
196
+ log.info("session id requested & obtained for %s:%i (%s)", host, port, sid)
197
+ else:
198
+ log.debug("no logon required for command %s on %s:%i", cmd, host, port)
199
+
200
+ try:
201
+ packet = {"command": cmd}
202
+ if parameters:
203
+ packet["parameter"] = ",".join(parameters)
204
+ ret = await roundtrip(host, port, packet, timeout, asjson=asjson)
205
+ return ((host, port), ret) if add_address else ret
206
+ finally:
207
+ if sid:
208
+ await logoff(host, port, sid)
luxos/exceptions.py ADDED
@@ -0,0 +1,24 @@
1
+ class LuxosBaseException(Exception):
2
+ pass
3
+
4
+
5
+ class MinerConnectionError(LuxosBaseException):
6
+ def __init__(self, host: str, port: int, *args, **kwargs):
7
+ super().__init__(host, port, *args, **kwargs)
8
+ self.address = (host, port)
9
+
10
+ def __str__(self):
11
+ return (f"<{self.address[0]}:{self.address[1]}>: {self.__class__.__name__}, "
12
+ f"{self.args[2] if self.args[2:] else 'unknown reason'}")
13
+
14
+
15
+ class MinerCommandSessionAlreadyActive(MinerConnectionError):
16
+ pass
17
+
18
+
19
+ class MinerCommandTimeoutError(MinerConnectionError):
20
+ pass
21
+
22
+
23
+ class MinerCommandMalformedMessageError(MinerConnectionError):
24
+ pass
luxos/misc.py ADDED
@@ -0,0 +1,31 @@
1
+ # catch-all module (find later a better place)
2
+ from __future__ import annotations
3
+ import sys
4
+ import itertools
5
+
6
+
7
+ if sys.version_info >= (3, 12):
8
+ batched = itertools.batched
9
+ else:
10
+ def batched(iterable, n):
11
+ if n < 1:
12
+ raise ValueError("n must be at least one")
13
+ it = iter(iterable)
14
+ while batch := tuple(itertools.islice(it, n)):
15
+ yield batch
16
+
17
+
18
+ def indent(txt: str, pre: str = " " * 2) -> str:
19
+ """simple text indentation"""
20
+
21
+ from textwrap import dedent
22
+
23
+ txt = dedent(txt)
24
+ if txt.endswith("\n"):
25
+ last_eol = "\n"
26
+ txt = txt[:-1]
27
+ else:
28
+ last_eol = ""
29
+
30
+ result = pre + txt.replace("\n", "\n" + pre) + last_eol
31
+ return result if result.strip() else result.strip()
File without changes
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import json
4
+
5
+ from .. import misc
6
+ from .. import asyncops
7
+
8
+
9
+ async def run(
10
+ ip_list: list[str],
11
+ port: int,
12
+ cmd: str,
13
+ params: list[str],
14
+ timeout: float,
15
+ delay: float | None,
16
+ details: bool,
17
+ batchsize: int = 0,
18
+ ) -> None:
19
+ result = {}
20
+ batchsize = max(batchsize, 2)
21
+
22
+ for grupid, addresses in enumerate(
23
+ misc.batched([(ip, port) for ip in ip_list], n=batchsize)
24
+ ):
25
+ tasks = []
26
+ for host, port in addresses:
27
+ tasks.append(
28
+ asyncops.execute_command(
29
+ host, port, timeout, cmd, params, add_address=True
30
+ )
31
+ )
32
+ result[grupid] = await asyncio.gather(*tasks, return_exceptions=True)
33
+
34
+ # runs only on batchsize, wait delay then proceed onto the next batch
35
+ if delay:
36
+ await asyncio.sleep(delay)
37
+
38
+ alltasks = [task for group in result.values() for task in group]
39
+ successes = [task for task in alltasks if not isinstance(task, Exception)]
40
+ failures = [task for task in alltasks if isinstance(task, Exception)]
41
+
42
+ # print a nice report
43
+ print(f"task executed sucessfully: {len(successes)}")
44
+ if details:
45
+ for (host, port), task in successes: # type: ignore
46
+ print(f" > {host}:{port}")
47
+ print(misc.indent(json.dumps(task, indent=2, sort_keys=True), pre=" | "))
48
+ print(f"task executed failures: {len(failures)}")
49
+ for failure in failures:
50
+ print(f" {failure}")
51
+
52
+
53
+ def main(*args, **kwargs) -> None:
54
+ asyncio.run(run(*args, **kwargs))