Implement computer registration

This commit is contained in:
Kai Vogelgesang 2022-01-02 15:18:59 +01:00
parent 227b9ba5dc
commit 63d6fe4b26
Signed by: kai
GPG Key ID: 0A95D3B6E62C0879
8 changed files with 550 additions and 11 deletions

294
backend/lua/framebuffer.lua Normal file
View File

@ -0,0 +1,294 @@
--- https://github.com/SquidDev-CC/cloud-catcher/blob/master/src/host/framebuffer.lua
--- but slightly modified because this isn't cloud catcher
--- Just another frame buffer, but this one is serialisable!
local stringify = require("json").stringify
local colour_lookup = {}
for i = 0, 15 do
colour_lookup[2 ^ i] = string.format("%x", i)
end
--- Create a buffer which can be converted to a string and transmitted.
local function buffer(original)
local text = {}
local text_colour = {}
local back_colour = {}
local palette = {}
local palette_24 = {}
local cursor_x, cursor_y = 1, 1
local cursor_blink = false
local cur_text_colour = "0"
local cur_back_colour = "f"
local sizeX, sizeY = original.getSize()
local color = original.isColor()
local dirty = false
local redirect = {}
if original.getPaletteColour then
for i = 0, 15 do
local c = 2 ^ i
palette[c] = { original.getPaletteColour( c ) }
palette_24[colour_lookup[c]] = colours.rgb8(original.getPaletteColour( c ))
end
end
function redirect.write(writeText)
writeText = tostring(writeText)
original.write(writeText)
dirty = true
-- If we're off the screen then just emulate a write
if cursor_y > sizeY or cursor_y < 1 or cursor_x + #writeText <= 1 or cursor_x > sizeX then
cursor_x = cursor_x + #writeText
return
end
-- Adjust text to fit on screen
if cursor_x < 1 then
writeText = writeText:sub(-cursor_x + 2)
cursor_x = 1
elseif cursor_x + #writeText > sizeX then
writeText = writeText:sub(1, sizeX - cursor_x + 1)
end
local lineText = text[cursor_y]
local lineColor = text_colour[cursor_y]
local lineBack = back_colour[cursor_y]
local preStop = cursor_x - 1
local preStart = math.min(1, preStop)
local postStart = cursor_x + #writeText
local postStop = sizeX
local sub, rep = string.sub, string.rep
text[cursor_y] = sub(lineText, preStart, preStop)..writeText..sub(lineText, postStart, postStop)
text_colour[cursor_y] = sub(lineColor, preStart, preStop)..rep(cur_text_colour, #writeText)..sub(lineColor, postStart, postStop)
back_colour[cursor_y] = sub(lineBack, preStart, preStop)..rep(cur_back_colour, #writeText)..sub(lineBack, postStart, postStop)
cursor_x = cursor_x + #writeText
end
function redirect.blit(writeText, writeFore, writeBack)
original.blit(writeText, writeFore, writeBack)
dirty = true
-- If we're off the screen then just emulate a write
if cursor_y > sizeY or cursor_y < 1 or cursor_x + #writeText <= 1 or cursor_x > sizeX then
cursor_x = cursor_x + #writeText
return
end
if cursor_x < 1 then
--adjust text to fit on screen starting at one.
writeText = writeText:sub(-cursor_x + 2)
writeFore = writeFore:sub(-cursor_x + 2)
writeBack = writeBack:sub(-cursor_x + 2)
cursor_x = 1
elseif cursor_x + #writeText > sizeX then
writeText = writeText:sub(1, sizeX - cursor_x + 1)
writeFore = writeFore:sub(1, sizeX - cursor_x + 1)
writeBack = writeBack:sub(1, sizeX - cursor_x + 1)
end
local lineText = text[cursor_y]
local lineColor = text_colour[cursor_y]
local lineBack = back_colour[cursor_y]
local preStop = cursor_x - 1
local preStart = math.min(1, preStop)
local postStart = cursor_x + #writeText
local postStop = sizeX
local sub = string.sub
text[cursor_y] = sub(lineText, preStart, preStop)..writeText..sub(lineText, postStart, postStop)
text_colour[cursor_y] = sub(lineColor, preStart, preStop)..writeFore..sub(lineColor, postStart, postStop)
back_colour[cursor_y] = sub(lineBack, preStart, preStop)..writeBack..sub(lineBack, postStart, postStop)
cursor_x = cursor_x + #writeText
end
function redirect.clear()
for i = 1, sizeY do
text[i] = string.rep(" ", sizeX)
text_colour[i] = string.rep(cur_text_colour, sizeX)
back_colour[i] = string.rep(cur_back_colour, sizeX)
end
dirty = true
return original.clear()
end
function redirect.clearLine()
-- If we're off the screen then just emulate a clearLine
if cursor_y > sizeY or cursor_y < 1 then
return
end
text[cursor_y] = string.rep(" ", sizeX)
text_colour[cursor_y] = string.rep(cur_text_colour, sizeX)
back_colour[cursor_y] = string.rep(cur_back_colour, sizeX)
dirty = true
return original.clearLine()
end
function redirect.getCursorPos()
return cursor_x, cursor_y
end
function redirect.setCursorPos(x, y)
if type(x) ~= "number" then error("bad argument #1 (expected number, got " .. type(x) .. ")", 2) end
if type(y) ~= "number" then error("bad argument #2 (expected number, got " .. type(y) .. ")", 2) end
if x ~= cursor_x or y ~= cursor_y then
cursor_x = math.floor(x)
cursor_y = math.floor(y)
dirty = true
end
return original.setCursorPos(x, y)
end
function redirect.setCursorBlink(b)
if type(b) ~= "boolean" then error("bad argument #1 (expected boolean, got " .. type(b) .. ")", 2) end
if cursor_blink ~= b then
cursor_blink = b
dirty = true
end
return original.setCursorBlink(b)
end
function redirect.getSize()
return sizeX, sizeY
end
function redirect.scroll(n)
if type(n) ~= "number" then error("bad argument #1 (expected number, got " .. type(n) .. ")", 2) end
local empty_text = string.rep(" ", sizeX)
local empty_text_colour = string.rep(cur_text_colour, sizeX)
local empty_back_colour = string.rep(cur_back_colour, sizeX)
if n > 0 then
for i = 1, sizeY do
text[i] = text[i + n] or empty_text
text_colour[i] = text_colour[i + n] or empty_text_colour
back_colour[i] = back_colour[i + n] or empty_back_colour
end
elseif n < 0 then
for i = sizeY, 1, -1 do
text[i] = text[i + n] or empty_text
text_colour[i] = text_colour[i + n] or empty_text_colour
back_colour[i] = back_colour[i + n] or empty_back_colour
end
end
dirty = true
return original.scroll(n)
end
function redirect.setTextColour(clr)
if type(clr) ~= "number" then error("bad argument #1 (expected number, got " .. type(clr) .. ")", 2) end
local new_colour = colour_lookup[clr] or error("Invalid colour (got " .. clr .. ")" , 2)
if new_colour ~= cur_text_colour then
dirty = true
cur_text_colour = new_colour
end
return original.setTextColour(clr)
end
redirect.setTextColor = redirect.setTextColour
function redirect.setBackgroundColour(clr)
if type(clr) ~= "number" then error("bad argument #1 (expected number, got " .. type(clr) .. ")", 2) end
local new_colour = colour_lookup[clr] or error("Invalid colour (got " .. clr .. ")" , 2)
if new_colour ~= cur_back_colour then
dirty = true
cur_back_colour = new_colour
end
return original.setBackgroundColour(clr)
end
redirect.setBackgroundColor = redirect.setBackgroundColour
function redirect.isColour()
return color == true
end
redirect.isColor = redirect.isColour
function redirect.getTextColour()
return 2 ^ tonumber(cur_text_colour, 16)
end
redirect.getTextColor = redirect.getTextColour
function redirect.getBackgroundColour()
return 2 ^ tonumber(cur_back_colour, 16)
end
redirect.getBackgroundColor = redirect.getBackgroundColour
if original.getPaletteColour then
function redirect.setPaletteColour(colour, r, g, b)
local palcol = palette[colour]
if not palcol then error("Invalid colour (got " .. tostring(colour) .. ")", 2) end
if type(r) == "number" and g == nil and b == nil then
palcol[1], palcol[2], palcol[3] = colours.rgb8(r)
palette_24[colour] = r
else
if type(r) ~= "number" then error("bad argument #2 (expected number, got " .. type(r) .. ")", 2) end
if type(g) ~= "number" then error("bad argument #3 (expected number, got " .. type(g) .. ")", 2) end
if type(b) ~= "number" then error("bad argument #4 (expected number, got " .. type(b ) .. ")", 2 ) end
palcol[1], palcol[2], palcol[3] = r, g, b
palette_24[colour_lookup[colour]] = colours.rgb8(r, g, b)
end
dirty = true
return original.setPaletteColour(colour, r, g, b)
end
redirect.setPaletteColor = redirect.setPaletteColour
function redirect.getPaletteColour(colour)
local palcol = palette[colour]
if not palcol then error("Invalid colour (got " .. tostring(colour) .. ")", 2) end
return palcol[1], palcol[2], palcol[3]
end
redirect.getPaletteColor = redirect.getPaletteColour
end
function redirect.is_dirty() return dirty end
function redirect.clear_dirty() dirty = false end
function redirect.serialize()
local palette = {}
for i = 0, 15 do
palette[i+1] = string.format("#%06x", palette_24[string.format("%x", i)])
end
return stringify { framebuffer = {
width = sizeX, height = sizeY,
cursorX = cursor_x, cursorY = cursor_y, cursorBlink = cursor_blink,
-- curFg = cur_text_colour, curBg = cur_back_colour,
palette = palette,
textBuf = text, fgBuf = text_colour, bgBuf = back_colour
}}
end
-- Ensure we're in sync with the parent terminal
redirect.setCursorPos(1, 1)
redirect.setBackgroundColor(colours.black)
redirect.setTextColor(colours.white)
redirect.clear()
return redirect
end
return { buffer = buffer }

169
backend/lua/json.lua Normal file
View File

@ -0,0 +1,169 @@
local tonumber = tonumber
local function skip_delim(str, pos, delim, err_if_missing)
pos = pos + #str:match('^%s*', pos)
if str:sub(pos, pos) ~= delim then
if err_if_missing then error('Expected ' .. delim) end
return pos, false
end
return pos + 1, true
end
-- A table of JSON->Lua escape characters
local esc_map = { b = '\b', f = '\f', n = '\n', r = '\r', t = '\t' }
local function parse_str_val(str, pos)
local out, n = {}, 0
if pos > #str then error("Malformed JSON (in string)") end
while true do
local c = str:sub(pos, pos)
if c == '"' then return table.concat(out, "", 1, n), pos + 1 end
n = n + 1
if c == '\\' then
local nextc = str:sub(pos + 1, pos + 1)
if not nextc then error("Malformed JSON (in string)") end
if nextc == "u" then
local num = tonumber(str:sub(pos + 2, pos + 5), 16)
if not num then error("Malformed JSON (in unicode string) ") end
if num <= 255 then
pos, out[n] = pos + 6, string.char(num)
else
pos, out[n] = pos + 6, "?"
end
else
pos, out[n] = pos + 2, esc_map[nextc] or nextc
end
else
pos, out[n] = pos + 1, c
end
end
end
local function parse_num_val(str, pos)
local num_str = str:match('^-?%d+%.?%d*[eE]?[+-]?%d*', pos)
local val = tonumber(num_str)
if not val then error('Error parsing number at position ' .. pos .. '.') end
return val, pos + #num_str
end
local null = {}
local literals = {['true'] = true, ['false'] = false, ['null'] = null }
-- Build a table of Lua->JSON escape characters
local escapes = {}
for i = 0, 255 do
local c = string.char(i)
if i >= 32 and i <= 126
then escapes[c] = c
else escapes[c] = ("\\u00%02x"):format(i)
end
end
escapes["\t"], escapes["\n"], escapes["\r"], escapes["\""], escapes["\\"] = "\\t", "\\n", "\\r", "\\\"", "\\\\"
local function parse(str, pos, end_delim)
pos = pos or 1
if pos > #str then error('Reached unexpected end of input.') end
local pos = pos + #str:match('^%s*', pos)
local first = str:sub(pos, pos)
if first == '{' then
local obj, key, delim_found = {}, true, true
pos = pos + 1
while true do
key, pos = parse(str, pos, '}')
if key == nil then return obj, pos end
if not delim_found then error('Comma missing between object items.') end
pos = skip_delim(str, pos, ':', true)
obj[key], pos = parse(str, pos)
pos, delim_found = skip_delim(str, pos, ',')
end
elseif first == '[' then
local arr, val, delim_found = {}, true, true
pos = pos + 1
while true do
val, pos = parse(str, pos, ']')
if val == nil then return arr, pos end
if not delim_found then error('Comma missing between array items.') end
arr[#arr + 1] = val
pos, delim_found = skip_delim(str, pos, ',')
end
elseif first == '"' then
return parse_str_val(str, pos + 1)
elseif first == '-' or first:match('%d') then
return parse_num_val(str, pos)
elseif first == end_delim then
return nil, pos + 1
else
for lit_str, lit_val in pairs(literals) do
local lit_end = pos + #lit_str - 1
if str:sub(pos, lit_end) == lit_str then return lit_val, lit_end + 1 end
end
local pos_info_str = 'position ' .. pos .. ': ' .. str:sub(pos, pos + 10)
error('Invalid json syntax starting at ' .. pos_info_str)
end
end
local format, gsub, tostring, pairs, next, type, concat
= string.format, string.gsub, tostring, pairs, next, type, table.concat
local function stringify_impl(t, out, n)
local ty = type(t)
if ty == "table" then
local first_ty = type(next(t))
if first_ty == "nil" then
-- Assume empty tables are arrays
out[n], n = "{}", n + 1
return n
elseif first_ty == "string" then
out[n], n = "{", n + 1
local first = true
for k, v in pairs(t) do
if first then first = false else out[n], n = ",", n + 1 end
out[n] = format("\"%s\":", k)
n = stringify_impl(v, out, n + 1)
end
out[n], n = "}", n + 1
return n
elseif first_ty == "number" then
out[n], n = "[", n + 1
for i = 1, #t do
if i > 1 then out[n], n = ",", n + 1 end
n = stringify_impl(t[i], out, n)
end
out[n], n = "]", n + 1
return n
else
error("Cannot serialize key " .. first_ty)
end
elseif ty == "string" then
if t:match("^[ -~]*$") then
out[n], n = gsub(format("%q", t), "\n", "n"), n + 1
else
out[n], n = "\"" .. gsub(t, ".", escapes) .. "\"", n + 1
end
return n
elseif ty == "number" or ty == "boolean" then
out[n],n = tostring(t), n + 1
return n
else error("Cannot serialize type " .. ty)
end
end
local function stringify(object)
local buffer = {}
local n = stringify_impl(object, buffer, 1)
return concat(buffer, "", 1, n - 1)
end
local function try_parse(msg)
local ok, res = pcall(parse, msg)
if ok then return res else return nil, res end
end
return {
stringify = stringify,
try_parse = try_parse,
parse = parse,
null = null
}

View File

@ -1,14 +1,22 @@
from typing import *
import os.path
import uuid
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from settings import settings
import auth
from state import StateManager
from proto import Computer, ComputerType
app = FastAPI()
state_manager = StateManager()
@app.get("/api/{token}/validate")
@app.get("/api/{token}/validate", tags=["frontend"])
async def validate_token(token: str):
return {"success": auth.validate_frontend(token)}
@ -20,7 +28,6 @@ async def state_updates_websocket(websocket: WebSocket, token: str):
await websocket.close()
return
await websocket.accept()
await state_manager.on_connect(websocket)
@ -30,6 +37,42 @@ async def state_updates_websocket(websocket: WebSocket, token: str):
except WebSocketDisconnect:
await state_manager.on_disconnect(websocket)
@app.get("/install", tags=["computer"], response_class=PlainTextResponse)
async def serve_install_script():
deploy_url = (
f'{"https" if settings.deploy_tls else "http"}://{settings.delpoy_path}'
)
return f"""
shell.run("wget {os.path.join(deploy_url, "lua/json.lua")}")
shell.run("wget {os.path.join(deploy_url, "lua/framebuffer.lua")}")
"""
class RegistrationRequest(BaseModel):
type: ComputerType
is_advanced: bool
label: Optional[str]
class RegistrationResponse(BaseModel):
token: str
@app.post("/computer/register", tags=["computer"], response_model=RegistrationResponse)
async def issue_new_token(data: RegistrationRequest):
computer = Computer(
uuid=uuid.uuid4(),
group="default",
**data.dict(),
)
await state_manager.on_computer_register(computer)
return {"token": auth.encode({"type": "computer", "uuid": str(computer.uuid)})}
if settings.dev_mode:
print("Starting in development mode.")

View File

@ -1,12 +1,21 @@
from typing import *
from uuid import UUID
from enum import Enum
from pydantic import BaseModel
class ComputerType(str, Enum):
COMPUTER = "computer"
TURTLE = "turtle"
POCKET = "pocket"
class Computer(BaseModel):
type: ComputerType
is_advanced: bool
uuid: UUID
label: str
label: Optional[str]
group: str

View File

@ -8,6 +8,9 @@ class Settings(BaseSettings):
frontend_path: str = "frontend"
database_path: str = "db.json"
deploy_tls: bool = False
delpoy_path: str = "localhost:8000"
secret_key: str

View File

@ -3,6 +3,7 @@ import asyncio
from tinydb import TinyDB
from settings import settings
from proto import Computer
db = TinyDB(settings.database_path)
computers = db.table("computers")
@ -19,7 +20,11 @@ class StateManager:
self.current_state = {"computers": computers.all()}
async def push_state(self, socket):
await socket.send_json(self.current_state)
try:
await socket.send_json(self.current_state)
except RuntimeError:
print("dead socket?")
self.websockets.remove(socket)
async def on_connect(self, socket):
self.websockets.add(socket)
@ -30,4 +35,13 @@ class StateManager:
async def on_change(self):
self.update_state()
await asyncio.gather(self.push_state(socket) for socket in self.websockets)
await asyncio.gather(*[self.push_state(socket) for socket in self.websockets])
async def on_computer_register(self, computer: Computer):
# uUiD iS nOt JsOn SeRiAlIzAbLe
computer_data = computer.dict()
computer_data["uuid"] = str(computer_data["uuid"])
computers.insert(computer_data)
await self.on_change()

View File

@ -48,7 +48,7 @@ const Groups: React.FC<{ computers: Array<Computer> }> = ({ computers }) => {
return <>
{
Array.from(groupMap.entries()).map((entry, index) => {
Array.from(groupMap.entries()).map((entry) => {
const [group, computers] = entry;
return <div key={group}>
<p>{group}:</p>
@ -63,13 +63,13 @@ const Groups: React.FC<{ computers: Array<Computer> }> = ({ computers }) => {
const CardList: React.FC<{ computers: Array<Computer> }> = ({ computers }) => {
return <>
{computers.map(
(computer, index) => <ComputerCard key={computer.uuid} computer={computer} />
(computer) => <ComputerCard key={computer.uuid} computer={computer} />
)}
</>
}
const ComputerCard: React.FC<{ computer: Computer }> = ({ computer }) => {
return <div>
{computer.label}
{computer.is_advanced ? "advanced " : ""}{computer.type} {computer.label || "(no label)"}
</div>
}

View File

@ -1,9 +1,16 @@
export type ComputerType =
"computer" |
"turtle" |
"pocket"
export type Computer = {
type: ComputerType,
is_advanced: boolean,
uuid: string,
label: string,
label: string | undefined,
group: string,
}
export type State = {
computers: Array<Computer>
};
}