Refactor controller to HTTP REST API

This commit is contained in:
clerie 2023-12-25 18:45:02 +01:00
parent 6d016d272f
commit b820eaff52
2 changed files with 178 additions and 98 deletions

View File

@ -1,58 +1,46 @@
# FieldPOC controller
The FieldPOC controller is an interactive console to help managing the current state of the FieldPOC system.
The FieldPOC controller is a HTTP REST API to help managing the current state of the FieldPOC system.
Connect to it via the IP address von port set in the [FieldPOC configuration](configuration.md) with `telnet`.
Connect to it via the IP address von port set in the [FieldPOC configuration](configuration.md).
## Commands
## API Endpoints
### Help
### `/sync`
`help`
Show help info.
### Show handlers
`handlers`
Show currently running handlers
### Reconfigure all components
`sync`
```bash
curl --json '{}' http://127.0.0.1:9437/sync
```
Notify all parts of FieldPOC to check configuration of connected components and update it.
### Show queue stats
### `/queues`
`queues`
```bash
curl http://127.0.0.1:9437/queues
```
### Reload configuration
Show internal message queue stats.
`reload`
### `/reload`
Read [FieldPOC configuration](configuration.md) file and apply it.
```bash
curl --json '{}' http://127.0.0.1:9437/reload
```
### Bind extension to DECT device
Read [FieldPOC extensions specification](extensions.md) file and apply it.
`claim <ext> <token>`
### `/claim`
- `ext` is the current extension number of the DECT device.
```bash
curl --json '{"extension": "1111", "token": "1234"}' http://127.0.0.1:9437/claim
```
Bind extension to DECT device
- `extension` is the current extension number of the DECT device.
- `token` is the `dect_claim_token` of the extension that should get applied.
This works because newly connected DECT phones get a temporary number assigned.
This temporary number is usually the current number.
But it is possible to use any extension, so the extension for a device can be changed any time.
### Disconnect
`exit`
Disconnect telnet session.
### Stop controller
`stop`
Shutdown the controller, but FieldPOC continues running.

View File

@ -1,98 +1,190 @@
#!/usr/bin/env python3
import logging
import selectors
import socket
import socketserver
import time
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler, HTTPStatus
import threading
import json
logger = logging.getLogger("fieldpoc.controller")
class Controller:
def __init__(self, fp):
self.fp = fp
self.handlers = [] # collect control sockets for handlers, so we can shut them down on demand
def get_handler(self):
class HandleRequest(socketserver.BaseRequestHandler):
fp = self.fp
controller = self
"""
Create a handler passed to the HTTP server
def setup(self):
self.handler_read, self.handler_write = socket.socketpair()
self.controller.handlers.append(self.handler_write)
Return a subclass of BaseHTTPRequestHandler
"""
def handle(self):
self.request.sendall("FieldPOC interactive controller\n".encode("utf-8"))
# Paths by HTTP request method
PATHS = {
"GET": {},
"POST": {},
}
sel = selectors.DefaultSelector()
sel.register(self.handler_read, selectors.EVENT_READ)
sel.register(self.request, selectors.EVENT_READ)
while True:
self.request.sendall("> ".encode("utf-8"))
def index():
"""
Index page
"""
for key, mask in sel.select():
if key.fileobj == self.handler_read:
self.request.sendall("\ndisconnecting\n".encode("utf-8"))
return
elif key.fileobj == self.request:
data = self.request.recv(1024).decode("utf-8").strip()
return "FieldPOC HTTP Controller"
if data == "help":
self.request.sendall("""Availiable commands:
help Show this info
handlers Show currently running handlers
sync Start syncing
queues Show queue stats
reload Reload extension config file
claim <ext> <token> claim dect extension
exit Disconnect
""".encode("utf-8"))
elif data == "":
continue
elif data == "quit" or data == "exit":
self.request.sendall("disconnecting\n".encode("utf-8"))
return
elif data == "handlers":
self.request.sendall(("\n".join([str(h) for h in self.controller.handlers]) + "\n").encode("utf-8"))
elif data == "sync":
self.fp.queue_all({"type": "sync"})
elif data == "queues":
self.request.sendall(("\n".join(["{} {}".format(name, queue.qsize()) for name, queue in self.fp.queues.items()]) + "\n").encode("utf-8"))
elif data == "reload":
self.fp.reload_extensions()
elif data.startswith("claim"):
data = data.split(" ")
if len(data) == 3:
self.fp.queue_all({"type": "claim", "extension": data[1], "token": data[2]})
else:
self.request.sendall("error: You have to specify calling extension and token\n".encode("utf-8"))
else:
self.request.sendall("Unknown command, type 'help'\n".encode("utf-8"))
PATHS["GET"]["/"] = index
def sync(payload):
"""
Notify all parts of FieldPOC to check configuration of connected components and update it
"""
self.fp.queue_all({"type": "sync"})
return "ok"
PATHS["POST"]["/sync"] = sync
def queues():
"""
Show status of message queues
"""
return {name: queue.qsize() for name, queue in self.fp.queues.items()}
PATHS["GET"]["/queues"] = queues
def reload(payload):
"""
Read extensions specification and apply it
"""
self.fp.reload_extensions()
PATHS["POST"]["/reload"] = reload
def claim(payload):
"""
Bind extension to DECT device
"""
if payload is None:
return {}
try:
self.fp.queue_all({"type": "claim", "extension": payload["extension"], "token": payload["token"]})
return "ok"
except KeyError:
return { "error": "You have to specify calling extension and token" }
PATHS["POST"]["/claim"] = claim
class HandleRequest(BaseHTTPRequestHandler):
def do_GET(self):
if self.path in PATHS["GET"]:
response = PATHS["GET"][self.path]()
return self.make_response(response)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
return
def do_HEAD(self):
if self.path in PATHS["GET"]:
response = PATHS["GET"][self.path]()
return self.make_response(response, head_only=True)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
return
def do_POST(self):
if self.path in PATHS["POST"]:
payload = self.prepare_payload()
response = PATHS["POST"][self.path](payload)
return self.make_response(response)
else:
self.send_error(HTTPStatus.NOT_FOUND, "Not found")
return
def make_response(self, content, head_only=False):
"""
Take a dict and return as HTTP json response
"""
# Convert response to json
content = json.dumps(content) + "\n"
# Encode repose as binary
encoded = content.encode("utf-8")
# Send 200 status code
self.send_response(HTTPStatus.OK)
# Set json header
self.send_header("Content-Type", "application/json; charset=utf-8")
# Specify content length
self.send_header("Conten-Length", str(len(encoded)))
# Finish headers
self.end_headers()
# Return early on HEAD request
if head_only:
return
# Send response
self.wfile.write(encoded)
def prepare_payload(self):
"""
Read payload data from HTTP POST request and parse as json.
"""
# Get payload length
length = int(self.headers["Content-Length"])
# Read payload up to length
encoded = self.rfile.read(length)
# Decode payload to string
content = encoded.decode("utf-8")
# Return early if no payload
if not content:
return None
# Parse payload as json
return json.loads(content)
def finish(self):
self.controller.handlers.remove(self.handler_write)
return HandleRequest
def run(self):
logger.info("starting server")
class Server(socketserver.ThreadingTCPServer):
class Server(ThreadingHTTPServer):
"""
Subclass ThreadingHTTPServer to set custom options
"""
# Sometimes sockets are still bound to addresses and ports
# we just ignore that with this
allow_reuse_address = True
with Server((self.fp.config.controller.host, self.fp.config.controller.port), self.get_handler()) as server:
# Start the server
with Server(
# Passing address and port from config
(self.fp.config.controller.host, self.fp.config.controller.port),
# With the handler class we generated
self.get_handler()
) as server:
# Starting server loop in another thread
threading.Thread(target=server.serve_forever).start()
# Listen on the message queue
while True:
# Wait for a new message and get it
msg = self.fp.queues["controller"].get()
# Remove message from queue
self.fp.queues["controller"].task_done()
if msg.get("type") == "stop":
logger.info("stopping server")
# Stop http server loop
server.shutdown()
for h in self.handlers:
h.send(b'\0')
# Exist message queue listening loop
break