indipyweb 0.0.2__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.
- indipyweb-0.0.2/LICENSE +21 -0
- indipyweb-0.0.2/PKG-INFO +49 -0
- indipyweb-0.0.2/README.md +31 -0
- indipyweb-0.0.2/indipyweb/__init__.py +0 -0
- indipyweb-0.0.2/indipyweb/__main__.py +69 -0
- indipyweb-0.0.2/indipyweb/iclient.py +73 -0
- indipyweb-0.0.2/indipyweb/web/__init__.py +0 -0
- indipyweb-0.0.2/indipyweb/web/app.py +391 -0
- indipyweb-0.0.2/indipyweb/web/device.py +166 -0
- indipyweb-0.0.2/indipyweb/web/edit.py +322 -0
- indipyweb-0.0.2/indipyweb/web/setup.py +164 -0
- indipyweb-0.0.2/indipyweb/web/static/htmx.min.js +1 -0
- indipyweb-0.0.2/indipyweb/web/static/indipyweb.css +68 -0
- indipyweb-0.0.2/indipyweb/web/static/indipyweb.js +37 -0
- indipyweb-0.0.2/indipyweb/web/static/sse.js +290 -0
- indipyweb-0.0.2/indipyweb/web/static/w3-colors-flat.css +40 -0
- indipyweb-0.0.2/indipyweb/web/static/w3.css +251 -0
- indipyweb-0.0.2/indipyweb/web/templates/blobs.html +125 -0
- indipyweb-0.0.2/indipyweb/web/templates/devicepage.html +49 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/admin/adminedit.html +73 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/admin/editoptions.html +62 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/admin/edituser.html +61 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/admin/listusers.html +62 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/admin/optionsdelete.html +10 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/loggedout.html +29 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/login.html +51 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/max_header.html +5 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/min_header.html +4 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/user/userdeleted.html +27 -0
- indipyweb-0.0.2/indipyweb/web/templates/edit/user/useredit.html +83 -0
- indipyweb-0.0.2/indipyweb/web/templates/group.html +29 -0
- indipyweb-0.0.2/indipyweb/web/templates/instruments.html +17 -0
- indipyweb-0.0.2/indipyweb/web/templates/landing.html +78 -0
- indipyweb-0.0.2/indipyweb/web/templates/messages.html +13 -0
- indipyweb-0.0.2/indipyweb/web/templates/notfound.html +32 -0
- indipyweb-0.0.2/indipyweb/web/templates/setup/blobfolder.html +9 -0
- indipyweb-0.0.2/indipyweb/web/templates/setup/indihost.html +9 -0
- indipyweb-0.0.2/indipyweb/web/templates/setup/indiport.html +9 -0
- indipyweb-0.0.2/indipyweb/web/templates/setup/setuppage.html +165 -0
- indipyweb-0.0.2/indipyweb/web/templates/setup/webhost.html +9 -0
- indipyweb-0.0.2/indipyweb/web/templates/setup/webport.html +9 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/blobmember.html +53 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/getvector.html +71 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/lightmember.html +23 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/numbermember.html +35 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/result.html +17 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/switchmember.html +91 -0
- indipyweb-0.0.2/indipyweb/web/templates/vector/textmember.html +36 -0
- indipyweb-0.0.2/indipyweb/web/userdata.py +573 -0
- indipyweb-0.0.2/indipyweb/web/vector.py +269 -0
- indipyweb-0.0.2/pyproject.toml +21 -0
indipyweb-0.0.2/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 bernie-skipole
|
|
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.
|
indipyweb-0.0.2/PKG-INFO
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: indipyweb
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: Web server to communicate to an INDI service.
|
|
5
|
+
Keywords: indi,client,astronomy,instrument
|
|
6
|
+
Author-email: Bernard Czenkusz <bernie@skipole.co.uk>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Astronomy
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Interface Engine/Protocol Translator
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Requires-Dist: indipyclient>=0.8.6
|
|
14
|
+
Requires-Dist: litestar[standard]>=2.18.0
|
|
15
|
+
Requires-Dist: litestar[mako]>=2.18.0
|
|
16
|
+
Project-URL: Source, https://github.com/bernie-skipole/indipyweb
|
|
17
|
+
|
|
18
|
+
# indipyweb
|
|
19
|
+
Web server, providing browser client connections to an INDI service.
|
|
20
|
+
|
|
21
|
+
This does not include the INDI server, this is an INDI client.
|
|
22
|
+
|
|
23
|
+
Requires Python >=3.10 and a virtual environment with:
|
|
24
|
+
|
|
25
|
+
pip install indipyweb
|
|
26
|
+
|
|
27
|
+
Then to run the server:
|
|
28
|
+
|
|
29
|
+
python -m indipyweb
|
|
30
|
+
|
|
31
|
+
This will create a database file holding user information in the working directory, and will run a web server on localhost:8000. Connect with a browser, and initially use the default created user, with username admin and password password! - note the exclamation mark.
|
|
32
|
+
|
|
33
|
+
This server will attempt to connect to an INDI service on localhost:7624, and the user browser should be able to view and set devices, vectors and member values.
|
|
34
|
+
|
|
35
|
+
The package help is:
|
|
36
|
+
|
|
37
|
+
usage: indipyweb [options]
|
|
38
|
+
|
|
39
|
+
Web server to communicate to an INDI service.
|
|
40
|
+
|
|
41
|
+
options:
|
|
42
|
+
-h, --help show this help message and exit
|
|
43
|
+
--port PORT Listening port of the web server.
|
|
44
|
+
--host HOST Hostname/IP of the web server.
|
|
45
|
+
--db DB Folder where the database will be set.
|
|
46
|
+
--version show program's version number and exit
|
|
47
|
+
|
|
48
|
+
Having logged in as admin, choose edit and change your password, you can also choose the system setup to set web and INDI hosts, ports and a folder where any BLOBs sent by the INDI service will be saved. These values will be saved in the database file and read on startup.
|
|
49
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# indipyweb
|
|
2
|
+
Web server, providing browser client connections to an INDI service.
|
|
3
|
+
|
|
4
|
+
This does not include the INDI server, this is an INDI client.
|
|
5
|
+
|
|
6
|
+
Requires Python >=3.10 and a virtual environment with:
|
|
7
|
+
|
|
8
|
+
pip install indipyweb
|
|
9
|
+
|
|
10
|
+
Then to run the server:
|
|
11
|
+
|
|
12
|
+
python -m indipyweb
|
|
13
|
+
|
|
14
|
+
This will create a database file holding user information in the working directory, and will run a web server on localhost:8000. Connect with a browser, and initially use the default created user, with username admin and password password! - note the exclamation mark.
|
|
15
|
+
|
|
16
|
+
This server will attempt to connect to an INDI service on localhost:7624, and the user browser should be able to view and set devices, vectors and member values.
|
|
17
|
+
|
|
18
|
+
The package help is:
|
|
19
|
+
|
|
20
|
+
usage: indipyweb [options]
|
|
21
|
+
|
|
22
|
+
Web server to communicate to an INDI service.
|
|
23
|
+
|
|
24
|
+
options:
|
|
25
|
+
-h, --help show this help message and exit
|
|
26
|
+
--port PORT Listening port of the web server.
|
|
27
|
+
--host HOST Hostname/IP of the web server.
|
|
28
|
+
--db DB Folder where the database will be set.
|
|
29
|
+
--version show program's version number and exit
|
|
30
|
+
|
|
31
|
+
Having logged in as admin, choose edit and change your password, you can also choose the system setup to set web and INDI hosts, ports and a folder where any BLOBs sent by the INDI service will be saved. These values will be saved in the database file and read on startup.
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import sys, argparse, pathlib, asyncio
|
|
4
|
+
|
|
5
|
+
import uvicorn
|
|
6
|
+
|
|
7
|
+
from .iclient import ipywebclient, version
|
|
8
|
+
|
|
9
|
+
from .web.app import ipywebapp
|
|
10
|
+
|
|
11
|
+
from .web.userdata import setupdbase, setconfig, getconfig, get_indiclient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if sys.version_info < (3, 10):
|
|
15
|
+
raise ImportError('indipyweb requires Python >= 3.10')
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def readconfig():
|
|
19
|
+
|
|
20
|
+
parser = argparse.ArgumentParser(usage="indipyweb [options]",
|
|
21
|
+
description="Web server to communicate to an INDI service.")
|
|
22
|
+
parser.add_argument("--port", type=int, help="Listening port of the web server.")
|
|
23
|
+
parser.add_argument("--host", help="Hostname/IP of the web server.")
|
|
24
|
+
parser.add_argument("--db", help="Folder where the database will be set.")
|
|
25
|
+
parser.add_argument("--version", action="version", version=version)
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if args.db:
|
|
30
|
+
try:
|
|
31
|
+
dbfolder = pathlib.Path(args.db).expanduser().resolve()
|
|
32
|
+
except Exception:
|
|
33
|
+
print("Error: If given, the database folder should be an existing directory")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
else:
|
|
36
|
+
if not dbfolder.is_dir():
|
|
37
|
+
print("Error: If given, the database folder should be an existing directory")
|
|
38
|
+
sys.exit(1)
|
|
39
|
+
else:
|
|
40
|
+
dbfolder = pathlib.Path.cwd()
|
|
41
|
+
|
|
42
|
+
setupdbase(args.host, args.port, dbfolder)
|
|
43
|
+
|
|
44
|
+
# create the client, store it for later access with get_indiclient()
|
|
45
|
+
ipywebclient()
|
|
46
|
+
|
|
47
|
+
host = getconfig('host')
|
|
48
|
+
port = getconfig('port')
|
|
49
|
+
app = ipywebapp()
|
|
50
|
+
|
|
51
|
+
return app, host, port
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def indipywebrun():
|
|
55
|
+
"Read the program arguments, setup the database and run the webserver"
|
|
56
|
+
app, host, port = readconfig()
|
|
57
|
+
config = uvicorn.Config(app=app, host=host, port=port, log_level="info")
|
|
58
|
+
server = uvicorn.Server(config)
|
|
59
|
+
await server.serve()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main():
|
|
63
|
+
"Run the program"
|
|
64
|
+
asyncio.run(indipywebrun())
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
# And run main
|
|
69
|
+
main()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Provides ipywebclient, version
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
|
|
8
|
+
import indipyclient as ipc
|
|
9
|
+
|
|
10
|
+
from .web.userdata import DEFINE_EVENT, MESSAGE_EVENT, get_indiclient, getconfig, setconfig, get_device_event, get_vector_event
|
|
11
|
+
|
|
12
|
+
version = "0.0.2"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ipywebclient():
|
|
17
|
+
"Create and store an instance of IPyWebClient"
|
|
18
|
+
|
|
19
|
+
indihost = getconfig("indihost")
|
|
20
|
+
indiport = getconfig("indiport")
|
|
21
|
+
indiclient = IPyWebClient(indihost=indihost, indiport=indiport)
|
|
22
|
+
indiclient.BLOBfolder = getconfig("blobfolder")
|
|
23
|
+
setconfig("indiclient", indiclient)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def do_startup():
|
|
27
|
+
"""Start the client, called from Litestar app, the task is set into
|
|
28
|
+
the global config to ensure a strong reference to it remains"""
|
|
29
|
+
iclient = get_indiclient()
|
|
30
|
+
runclient = asyncio.create_task(iclient.asyncrun())
|
|
31
|
+
setconfig("runclient", runclient)
|
|
32
|
+
|
|
33
|
+
async def do_shutdown():
|
|
34
|
+
"Stop the client, called from Litestar app"
|
|
35
|
+
iclient = get_indiclient()
|
|
36
|
+
iclient.shutdown()
|
|
37
|
+
await iclient.stopped.wait()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class IPyWebClient(ipc.IPyClient):
|
|
41
|
+
|
|
42
|
+
async def rxevent(self, event):
|
|
43
|
+
|
|
44
|
+
if event.eventtype in ("Define", "Delete", "ConnectionMade", "ConnectionLost"):
|
|
45
|
+
DEFINE_EVENT.set()
|
|
46
|
+
DEFINE_EVENT.clear()
|
|
47
|
+
elif event.eventtype == "Message":
|
|
48
|
+
MESSAGE_EVENT.set()
|
|
49
|
+
MESSAGE_EVENT.clear()
|
|
50
|
+
if event.devicename:
|
|
51
|
+
dme = get_device_event(event.devicename)
|
|
52
|
+
dme.set()
|
|
53
|
+
dme.clear()
|
|
54
|
+
|
|
55
|
+
if event.eventtype == "Delete":
|
|
56
|
+
if event.devicename:
|
|
57
|
+
# set a message event, so if the device is deleted
|
|
58
|
+
# when client sends an updatemessages it forces a redirect
|
|
59
|
+
dme = get_device_event(event.devicename)
|
|
60
|
+
dme.set()
|
|
61
|
+
dme.clear()
|
|
62
|
+
|
|
63
|
+
if event.vector:
|
|
64
|
+
if event.eventtype == "TimeOut":
|
|
65
|
+
event.vector.user_string = "Response has timed out"
|
|
66
|
+
event.vector.state = 'Alert'
|
|
67
|
+
event.vector.timestamp = event.timestamp
|
|
68
|
+
else:
|
|
69
|
+
event.vector.user_string = ""
|
|
70
|
+
# set vector event when a vector is updated
|
|
71
|
+
ve = get_vector_event(event.devicename, event.vectorname)
|
|
72
|
+
ve.set()
|
|
73
|
+
ve.clear()
|
|
File without changes
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Creates the main litestar app with the top level routes
|
|
3
|
+
and authentication functions, including setting and testing cookies
|
|
4
|
+
|
|
5
|
+
Note, edit routes are set under edit.edit_router
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
|
|
11
|
+
from os import listdir, remove
|
|
12
|
+
from os.path import isfile, join
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from collections.abc import AsyncGenerator
|
|
17
|
+
|
|
18
|
+
from asyncio.exceptions import TimeoutError
|
|
19
|
+
|
|
20
|
+
from litestar import Litestar, get, post, Request
|
|
21
|
+
from litestar.plugins.htmx import HTMXPlugin, HTMXTemplate, ClientRedirect, ClientRefresh
|
|
22
|
+
from litestar.contrib.mako import MakoTemplateEngine
|
|
23
|
+
from litestar.template.config import TemplateConfig
|
|
24
|
+
from litestar.response import Template, Redirect, File
|
|
25
|
+
from litestar.static_files import create_static_files_router
|
|
26
|
+
from litestar.datastructures import Cookie, State
|
|
27
|
+
|
|
28
|
+
from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult, DefineMiddleware
|
|
29
|
+
from litestar.connection import ASGIConnection
|
|
30
|
+
from litestar.exceptions import NotAuthorizedException, NotFoundException
|
|
31
|
+
|
|
32
|
+
from litestar.response import ServerSentEvent, ServerSentEventMessage
|
|
33
|
+
|
|
34
|
+
from . import userdata, edit, device, vector, setup
|
|
35
|
+
|
|
36
|
+
from ..iclient import do_startup, do_shutdown
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# location of static files, for CSS and javascript
|
|
40
|
+
STATICFILES = Path(__file__).parent.resolve() / "static"
|
|
41
|
+
|
|
42
|
+
# location of template files
|
|
43
|
+
TEMPLATEFILES = Path(__file__).parent.resolve() / "templates"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ShowInstruments:
|
|
47
|
+
"""Iterate with instruments table whenever an instrument change happens."""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.instruments = set()
|
|
51
|
+
self.connected = False
|
|
52
|
+
self.iclient = userdata.get_indiclient()
|
|
53
|
+
|
|
54
|
+
def __aiter__(self):
|
|
55
|
+
return self
|
|
56
|
+
|
|
57
|
+
async def __anext__(self):
|
|
58
|
+
"Whenever there is a change in devices, return a ServerSentEventMessage message"
|
|
59
|
+
while True:
|
|
60
|
+
if self.iclient.stop:
|
|
61
|
+
raise StopAsyncIteration
|
|
62
|
+
if self.iclient.connected != self.connected:
|
|
63
|
+
self.connected = self.iclient.connected
|
|
64
|
+
return ServerSentEventMessage(event="newinstruments")
|
|
65
|
+
# get a set of instrument names for enabled devices
|
|
66
|
+
newinstruments = set(name for name,value in self.iclient.items() if value.enable)
|
|
67
|
+
if newinstruments == self.instruments:
|
|
68
|
+
# No change, wait, at most 5 seconds, for a DEFINE_EVENT
|
|
69
|
+
try:
|
|
70
|
+
await asyncio.wait_for(userdata.DEFINE_EVENT.wait(), timeout=5.0)
|
|
71
|
+
except TimeoutError:
|
|
72
|
+
pass
|
|
73
|
+
# either a DEFINE_EVENT has occurred, or 5 seconds since the last has passed
|
|
74
|
+
# so continue the while loop to check for any new devices
|
|
75
|
+
continue
|
|
76
|
+
# There has been a change, send a newinstruments to the users browser
|
|
77
|
+
self.instruments = newinstruments
|
|
78
|
+
return ServerSentEventMessage(event="newinstruments")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# SSE Handler
|
|
82
|
+
@get(path="/instruments", exclude_from_auth=True, sync_to_thread=False)
|
|
83
|
+
def instruments() -> ServerSentEvent:
|
|
84
|
+
return ServerSentEvent(ShowInstruments())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ShowMessages:
|
|
88
|
+
"""Iterate with messages whenever a message change happens."""
|
|
89
|
+
|
|
90
|
+
def __init__(self):
|
|
91
|
+
self.lasttimestamp = None
|
|
92
|
+
self.connected = False
|
|
93
|
+
self.iclient = userdata.get_indiclient()
|
|
94
|
+
|
|
95
|
+
def __aiter__(self):
|
|
96
|
+
return self
|
|
97
|
+
|
|
98
|
+
async def __anext__(self):
|
|
99
|
+
"Whenever there is a new message, return a ServerSentEventMessage message"
|
|
100
|
+
while True:
|
|
101
|
+
if self.iclient.stop:
|
|
102
|
+
asyncio.sleep(2)
|
|
103
|
+
return ServerSentEventMessage(event="newmessage")
|
|
104
|
+
|
|
105
|
+
if self.iclient.connected != self.connected:
|
|
106
|
+
self.connected = self.iclient.connected
|
|
107
|
+
return ServerSentEventMessage(event="newmessage")
|
|
108
|
+
# get new message
|
|
109
|
+
if self.iclient.messages:
|
|
110
|
+
lasttimestamp = self.iclient.messages[0][0]
|
|
111
|
+
if (self.lasttimestamp is None) or (lasttimestamp != self.lasttimestamp):
|
|
112
|
+
# a new message is received
|
|
113
|
+
self.lasttimestamp = lasttimestamp
|
|
114
|
+
return ServerSentEventMessage(event="newmessages")
|
|
115
|
+
elif self.lasttimestamp is not None:
|
|
116
|
+
# There are no self.iclient.messages, but self.lasttimestamp
|
|
117
|
+
# has a value, so there has been a change
|
|
118
|
+
self.lasttimestamp = None
|
|
119
|
+
return ServerSentEventMessage(event="newmessages")
|
|
120
|
+
# No change, wait, at most 5 seconds, for a MESSAGE_EVENT
|
|
121
|
+
try:
|
|
122
|
+
await asyncio.wait_for(userdata.MESSAGE_EVENT.wait(), timeout=5.0)
|
|
123
|
+
except TimeoutError:
|
|
124
|
+
pass
|
|
125
|
+
# either a MESSAGE_EVENT has occurred, or 5 seconds since the last has passed
|
|
126
|
+
# so continue the while loop to check for any new messages
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# SSE Handler
|
|
130
|
+
@get(path="/messages", exclude_from_auth=True, sync_to_thread=False)
|
|
131
|
+
def messages() -> ServerSentEvent:
|
|
132
|
+
return ServerSentEvent(ShowMessages())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class LoggedInAuth(AbstractAuthenticationMiddleware):
|
|
136
|
+
"""Checks if a logged-in cookie is present, and verifies it
|
|
137
|
+
If ok, returns an AuthenticationResult with the user, and the users
|
|
138
|
+
authorisation level. If not ok raises a NotAuthorizedException"""
|
|
139
|
+
async def authenticate_request(self, connection: ASGIConnection ) -> AuthenticationResult:
|
|
140
|
+
# retrieve the cookie
|
|
141
|
+
auth_cookie = connection.cookies
|
|
142
|
+
if not auth_cookie:
|
|
143
|
+
raise NotAuthorizedException()
|
|
144
|
+
token = auth_cookie.get('token')
|
|
145
|
+
if not token:
|
|
146
|
+
raise NotAuthorizedException()
|
|
147
|
+
# the userdata.verify function looks up a dictionary of logged in users
|
|
148
|
+
userinfo = userdata.verify(token)
|
|
149
|
+
# If not verified, userinfo will be None
|
|
150
|
+
# If verified userinfo will be a userdata.UserInfo object
|
|
151
|
+
if userinfo is None:
|
|
152
|
+
raise NotAuthorizedException()
|
|
153
|
+
# Return an AuthenticationResult which will be
|
|
154
|
+
# made available to route handlers as request: Request[str, str, State]
|
|
155
|
+
return AuthenticationResult(user=userinfo.user, auth=userinfo.auth)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def gotologin_error_handler(request: Request, exc: Exception) -> ClientRedirect|Redirect:
|
|
159
|
+
"""If a NotAuthorizedException is raised, this handles it, and redirects
|
|
160
|
+
the caller to the login page"""
|
|
161
|
+
if request.htmx:
|
|
162
|
+
return ClientRedirect("/login")
|
|
163
|
+
return Redirect("/login")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def gotonotfound_error_handler(request: Request, exc: Exception) -> ClientRedirect|Redirect:
|
|
167
|
+
"""If a NotFoundException is raised, this handles it, and redirects
|
|
168
|
+
the caller to the not found page"""
|
|
169
|
+
if request.htmx:
|
|
170
|
+
return ClientRedirect("/notfound")
|
|
171
|
+
return Redirect("/notfound")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@get("/notfound", exclude_from_auth=True)
|
|
175
|
+
async def notfound(request: Request) -> Template:
|
|
176
|
+
"This is the public root page of your site"
|
|
177
|
+
iclient = userdata.get_indiclient()
|
|
178
|
+
# Check if user is looged in
|
|
179
|
+
loggedin = False
|
|
180
|
+
cookie = request.cookies.get('token', '')
|
|
181
|
+
if cookie:
|
|
182
|
+
userauth = userdata.getuserauth(cookie)
|
|
183
|
+
if userauth is not None:
|
|
184
|
+
loggedin = True
|
|
185
|
+
return Template("notfound.html", context={"hostname":userdata.connectedtext(),
|
|
186
|
+
"loggedin":loggedin})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# Note, all routes with 'exclude_from_auth=True' do not have cookie checked
|
|
191
|
+
# and are not authenticated
|
|
192
|
+
|
|
193
|
+
@get("/", exclude_from_auth=True)
|
|
194
|
+
async def publicroot(request: Request) -> Template:
|
|
195
|
+
"This is the public root page of your site"
|
|
196
|
+
iclient = userdata.get_indiclient()
|
|
197
|
+
# Check if user is looged in
|
|
198
|
+
loggedin = False
|
|
199
|
+
cookie = request.cookies.get('token', '')
|
|
200
|
+
if cookie:
|
|
201
|
+
userauth = userdata.getuserauth(cookie)
|
|
202
|
+
if userauth is not None:
|
|
203
|
+
loggedin = True
|
|
204
|
+
blobfolder = True if iclient.BLOBfolder else False
|
|
205
|
+
|
|
206
|
+
return Template("landing.html", context={"hostname":userdata.connectedtext(),
|
|
207
|
+
"instruments":None,
|
|
208
|
+
"messages":None,
|
|
209
|
+
"loggedin":loggedin,
|
|
210
|
+
"blobfolder":blobfolder})
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@get("/updateinstruments", exclude_from_auth=True)
|
|
214
|
+
async def updateinstruments(request: Request) -> Template:
|
|
215
|
+
"Updates the instruments on the main public page"
|
|
216
|
+
iclient = userdata.get_indiclient()
|
|
217
|
+
instruments = list(name for name,value in iclient.items() if value.enable)
|
|
218
|
+
instruments.sort()
|
|
219
|
+
return HTMXTemplate(template_name="instruments.html", context={"instruments":instruments})
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@get("/updatemessages", exclude_from_auth=True)
|
|
223
|
+
async def updatemessages() -> Template:
|
|
224
|
+
"Updates the messages on the main public page"
|
|
225
|
+
iclient = userdata.get_indiclient()
|
|
226
|
+
if iclient.stop:
|
|
227
|
+
return HTMXTemplate(template_name="messages.html", context={"messages":["Error: client application has stopped"]})
|
|
228
|
+
messages = list(iclient.messages)
|
|
229
|
+
messagelist = list(userdata.localtimestring(t) + " " + m for t,m in messages)
|
|
230
|
+
messagelist.reverse()
|
|
231
|
+
return HTMXTemplate(template_name="messages.html", context={"messages":messagelist})
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@get("/login", exclude_from_auth=True)
|
|
235
|
+
async def login_page(request: Request[str, str, State]) -> Template:
|
|
236
|
+
"Render the login page"
|
|
237
|
+
cookie = request.cookies.get('token')
|
|
238
|
+
# log the user out
|
|
239
|
+
if cookie:
|
|
240
|
+
userdata.logout(request.cookies['token'])
|
|
241
|
+
return Template("edit/login.html", context={"hostname":userdata.connectedtext()})
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@post("/login", exclude_from_auth=True)
|
|
245
|
+
async def login(request: Request) -> Template|ClientRedirect:
|
|
246
|
+
"""This is a handler for the login post, in which the caller is setting their
|
|
247
|
+
username and password into a form.
|
|
248
|
+
Checks the user has logged in correctly, and if so creates a logged-in cookie
|
|
249
|
+
for the caller and redirects the caller to / the root application page"""
|
|
250
|
+
form_data = await request.form()
|
|
251
|
+
username = form_data.get("username")
|
|
252
|
+
password = form_data.get("password")
|
|
253
|
+
# check these on the database of users, this checkuserpassword returns a userdata.UserInfo object
|
|
254
|
+
# if the user exists, and the password is correct, otherwise it returns None
|
|
255
|
+
userinfo = userdata.checkuserpassword(username, password)
|
|
256
|
+
if userinfo is None:
|
|
257
|
+
# sleep to force a time delay to annoy anyone trying to guess a password
|
|
258
|
+
await asyncio.sleep(1.0)
|
|
259
|
+
# unable to find a matching username/password
|
|
260
|
+
# returns an 'Invalid' template which the htmx javascript
|
|
261
|
+
# puts in the right place on the login page
|
|
262
|
+
return HTMXTemplate(None,
|
|
263
|
+
template_str="<p id=\"result\" class=\"vanish\" style=\"color:red\">Invalid</p>")
|
|
264
|
+
# The user checks out ok, create a cookie for this user and set redirect to the /,
|
|
265
|
+
loggedincookie = userdata.createcookie(userinfo.user)
|
|
266
|
+
# redirect with the loggedincookie
|
|
267
|
+
response = ClientRedirect("/")
|
|
268
|
+
response.set_cookie(key = 'token', value=loggedincookie)
|
|
269
|
+
return response
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@get("/logout")
|
|
273
|
+
async def logout(request: Request[str, str, State]) -> Template:
|
|
274
|
+
"Logs the user out, and render the logout page"
|
|
275
|
+
cookie = request.cookies.get('token')
|
|
276
|
+
# log the user out
|
|
277
|
+
if cookie:
|
|
278
|
+
userdata.logout(request.cookies['token'])
|
|
279
|
+
return Template("edit/loggedout.html", context={"hostname":userdata.connectedtext()})
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@get("/blobs")
|
|
283
|
+
async def blobs(request: Request[str, str, State]) -> Template:
|
|
284
|
+
"Shows a page of blob files"
|
|
285
|
+
iclient = userdata.get_indiclient()
|
|
286
|
+
blobfolder = iclient.BLOBfolder
|
|
287
|
+
if blobfolder:
|
|
288
|
+
blobfiles = [f for f in listdir(blobfolder) if isfile(join(blobfolder, f))]
|
|
289
|
+
blobfiles.sort()
|
|
290
|
+
else:
|
|
291
|
+
blobfiles = []
|
|
292
|
+
admin = True if request.auth == "admin" else False
|
|
293
|
+
context = {'blobfiles':blobfiles,
|
|
294
|
+
'admin':admin}
|
|
295
|
+
return Template("blobs.html", context=context)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@get("/getblob/{blobfile:str}", media_type="application/octet")
|
|
299
|
+
async def getblob(blobfile:str, request: Request[str, str, State]) -> File:
|
|
300
|
+
"Download a BLOB to the browser client"
|
|
301
|
+
iclient = userdata.get_indiclient()
|
|
302
|
+
blobfolder = iclient.BLOBfolder
|
|
303
|
+
if not blobfolder:
|
|
304
|
+
raise NotFoundException()
|
|
305
|
+
blobpath = iclient.BLOBfolder / blobfile
|
|
306
|
+
if not blobpath.is_file():
|
|
307
|
+
raise NotFoundException()
|
|
308
|
+
return File(
|
|
309
|
+
path=blobpath,
|
|
310
|
+
filename=blobfile
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
@get("/delblob/{blobfile:str}")
|
|
314
|
+
async def delblob(blobfile:str, request: Request[str, str, State]) -> ClientRefresh:
|
|
315
|
+
"Deletes a blob"
|
|
316
|
+
auth = request.auth
|
|
317
|
+
if auth != "admin":
|
|
318
|
+
raise NotAuthorizedException()
|
|
319
|
+
iclient = userdata.get_indiclient()
|
|
320
|
+
blobfolder = iclient.BLOBfolder
|
|
321
|
+
if not blobfolder:
|
|
322
|
+
raise NotFoundException()
|
|
323
|
+
blobpath = iclient.BLOBfolder / blobfile
|
|
324
|
+
if not blobpath.is_file():
|
|
325
|
+
raise NotFoundException()
|
|
326
|
+
remove(blobpath)
|
|
327
|
+
return ClientRefresh()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@get(["/api", "/api/{device:str}", "/api/{device:str}/{vector:str}"], exclude_from_auth=True, sync_to_thread=False)
|
|
332
|
+
def api(device:str="", vector:str="") -> dict:
|
|
333
|
+
iclient = userdata.get_indiclient()
|
|
334
|
+
if not device:
|
|
335
|
+
# return whole client dict
|
|
336
|
+
shot = iclient.snapshot()
|
|
337
|
+
return shot.dictdump()
|
|
338
|
+
deviceobj = iclient.get(device)
|
|
339
|
+
if deviceobj is None:
|
|
340
|
+
return {}
|
|
341
|
+
if vector:
|
|
342
|
+
vectorobj = deviceobj.data.get(vector)
|
|
343
|
+
if vectorobj is None:
|
|
344
|
+
return {}
|
|
345
|
+
shot = vectorobj.snapshot()
|
|
346
|
+
return shot.dictdump()
|
|
347
|
+
shot = deviceobj.snapshot()
|
|
348
|
+
return shot.dictdump()
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# This defines LoggedInAuth as middleware and also
|
|
352
|
+
# excludes certain paths from authentication.
|
|
353
|
+
# In this case it excludes all routes mounted at or under `/static*`
|
|
354
|
+
# This allows CSS and javascript libraries to be placed there, which
|
|
355
|
+
# therefore do not need authentication to be accessed
|
|
356
|
+
auth_mw = DefineMiddleware(LoggedInAuth, exclude="static")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def ipywebapp():
|
|
360
|
+
# Initialize the Litestar app with a Mako template engine and register the routes
|
|
361
|
+
iclient = userdata.get_indiclient()
|
|
362
|
+
app = Litestar(
|
|
363
|
+
route_handlers=[publicroot,
|
|
364
|
+
updateinstruments,
|
|
365
|
+
updatemessages,
|
|
366
|
+
notfound,
|
|
367
|
+
login_page,
|
|
368
|
+
login,
|
|
369
|
+
logout,
|
|
370
|
+
instruments,
|
|
371
|
+
messages,
|
|
372
|
+
blobs,
|
|
373
|
+
getblob,
|
|
374
|
+
delblob,
|
|
375
|
+
api,
|
|
376
|
+
edit.edit_router, # This router in edit.py deals with routes below /edit
|
|
377
|
+
device.device_router, # This router in device.py deals with routes below /device
|
|
378
|
+
vector.vector_router, # This router in vector.py deals with routes below /vector
|
|
379
|
+
setup.setup_router, # This router in setup.py deals with routes below /setup
|
|
380
|
+
create_static_files_router(path="/static", directories=[STATICFILES]),
|
|
381
|
+
],
|
|
382
|
+
exception_handlers={ NotAuthorizedException: gotologin_error_handler, NotFoundException: gotonotfound_error_handler},
|
|
383
|
+
plugins=[HTMXPlugin()],
|
|
384
|
+
middleware=[auth_mw],
|
|
385
|
+
template_config=TemplateConfig(directory=TEMPLATEFILES,
|
|
386
|
+
engine=MakoTemplateEngine,
|
|
387
|
+
),
|
|
388
|
+
on_startup=[do_startup],
|
|
389
|
+
on_shutdown=[do_shutdown]
|
|
390
|
+
)
|
|
391
|
+
return app
|