Skip to content

Commit

Permalink
Scene state (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
mariusandra committed Feb 4, 2024
1 parent 1efe80c commit 6b878fe
Show file tree
Hide file tree
Showing 38 changed files with 862 additions and 124 deletions.
38 changes: 34 additions & 4 deletions backend/app/api/frames.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,46 @@ def api_frame_get_image(id: int):
except Exception as e:
return jsonify({'error': 'Internal Server Error', 'message': str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR

@api.route('/frames/<int:id>/event/render', methods=['POST'])
@api.route('/frames/<int:id>/state', methods=['GET'])
@login_required
def api_frame_render_event(id: int):
def api_frame_get_state(id: int):
frame = Frame.query.get_or_404(id)
cache_key = f'frame:{frame.frame_host}:{frame.frame_port}:state'
url = f'http://{frame.frame_host}:{frame.frame_port}/state'

try:
last_state = redis.get(cache_key)
if last_state:
return Response(last_state, content_type='application/json')

response = requests.get(url, timeout=15)
if response.status_code == 200:
redis.set(cache_key, response.content, ex=1) # cache for 1 second
return Response(response.content, content_type='application/json')
else:
last_state = redis.get(cache_key)
if last_state:
return Response(last_state, content_type='application/json')
return jsonify({"error": "Unable to fetch state"}), response.status_code
except requests.exceptions.Timeout:
return jsonify({'error': f'Request Timeout to {url}'}), HTTPStatus.REQUEST_TIMEOUT
except Exception as e:
return jsonify({'error': 'Internal Server Error', 'message': str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR

@api.route('/frames/<int:id>/event/<event>', methods=['POST'])
@login_required
def api_frame_event(id: int, event: str):
frame = Frame.query.get_or_404(id)
try:
response = requests.post(f'http://{frame.frame_host}:{frame.frame_port}/event/render')
if request.is_json:
headers = {"Content-Type": "application/json"}
response = requests.post(f'http://{frame.frame_host}:{frame.frame_port}/event/{event}', json=request.json, headers=headers)
else:
response = requests.post(f'http://{frame.frame_host}:{frame.frame_port}/event/{event}')
if response.status_code == 200:
return "OK", 200
else:
return jsonify({"error": "Unable to refresh frame"}), response.status_code
return jsonify({"error": "Unable to reach frame"}), response.status_code
except Exception as e:
return jsonify({'error': 'Internal Server Error', 'message': str(e)}), HTTPStatus.INTERNAL_SERVER_ERROR

Expand Down
114 changes: 98 additions & 16 deletions backend/app/codegen/scene_nim.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def write_scene_nim(frame: Frame, scene: dict) -> str:
next_nodes = {}
prev_nodes = {}
field_inputs: dict[str, dict[str, str]] = {}
source_field_inputs: dict[str, dict[str, tuple[str, str]]] = {}
node_fields: dict[str, dict[str, str]] = {}

def node_id_to_integer(node_id: str) -> int:
Expand Down Expand Up @@ -56,6 +57,12 @@ def node_id_to_integer(node_id: str) -> int:
if not field_inputs.get(target):
field_inputs[target] = {}
field_inputs[target][field] = source_handle.replace('code/', '')
if source_handle.startswith('field/') and target_handle.startswith('fieldInput/'):
target_field = target_handle.replace('fieldInput/', '')
source_field = source_handle.replace('field/', '')
if not source_field_inputs.get(target):
source_field_inputs[target] = {}
source_field_inputs[target][target_field] = (source, source_field)

for node in nodes:
node_id = node['id']
Expand Down Expand Up @@ -89,8 +96,8 @@ def node_id_to_integer(node_id: str) -> int:

if len(sources) > 0:
node_app_id = "nodeapp_" + node_id.replace('-', '_')
app_import = f"import apps/{node_app_id}/app as nodeApp{node_id_to_integer(node_app_id)}"
scene_object_fields += [f"{app_id}: nodeApp{node_id_to_integer(node_app_id)}.App"]
app_import = f"import apps/{node_app_id}/app as nodeApp{node_id_to_integer(node_id)}"
scene_object_fields += [f"{app_id}: nodeApp{node_id_to_integer(node_id)}.App"]
else:
app_import = f"import apps/{name}/app as {name}App"
scene_object_fields += [f"{app_id}: {name}App.App"]
Expand Down Expand Up @@ -120,6 +127,7 @@ def node_id_to_integer(node_id: str) -> int:
app_config[key] = value

field_inputs_for_node = field_inputs.get(node_id, {})
source_field_inputs_for_node = source_field_inputs.get(node_id, {})
node_fields_for_node = node_fields.get(node_id, {})

app_config_pairs = []
Expand Down Expand Up @@ -153,7 +161,7 @@ def node_id_to_integer(node_id: str) -> int:
if len(sources) > 0:
node_app_id = "nodeapp_" + node_id.replace('-', '_')
init_apps += [
f"scene.{app_id} = nodeApp{node_id_to_integer(node_app_id)}.init({node_integer}.NodeId, scene, nodeApp{node_id_to_integer(node_app_id)}.AppConfig({', '.join(app_config_pairs)}))"
f"scene.{app_id} = nodeApp{node_id_to_integer(node_id)}.init({node_integer}.NodeId, scene, nodeApp{node_id_to_integer(node_id)}.AppConfig({', '.join(app_config_pairs)}))"
]
else:
init_apps += [
Expand All @@ -165,6 +173,8 @@ def node_id_to_integer(node_id: str) -> int:
]
for key, code in field_inputs_for_node.items():
run_node_lines += [f" self.{app_id}.appConfig.{key} = {code}"]
for key, (source_id, source_key) in source_field_inputs_for_node.items():
run_node_lines += [f" self.{app_id}.appConfig.{key} = self.node{node_id_to_integer(source_id)}.appConfig.{source_key}"]

next_node_id = next_nodes.get(node_id, None)
run_node_lines += [
Expand All @@ -174,14 +184,71 @@ def node_id_to_integer(node_id: str) -> int:

scene_object_fields.sort(key=natural_keys)

set_scene_state_lines = [
' if context.payload.hasKey("state") and context.payload["state"].kind == JObject:',
' let payload = context.payload["state"]',
' for field in PUBLIC_STATE_FIELDS:',
' let key = field.name',
' if payload.hasKey(key) and payload[key] != self.state{key}:',
' self.state[key] = copy(payload[key])',
' if context.payload.hasKey("render"):',
' sendEvent("render", %*{})',
]

for event, nodes in event_nodes.items():
run_event_lines += [f"of \"{event}\":", ]
run_event_lines += [f"of \"{event}\":"]
if event == 'setSceneState':
run_event_lines += set_scene_state_lines
for node in nodes:
next_node = next_nodes.get(node['id'], '-1')
run_event_lines += [f" try: self.runNode({node_id_to_integer(next_node)}.NodeId, context)"]
run_event_lines += [f" except Exception as e: self.logger.log(%*{{\"event\": \"{sanitize_nim_string(event)}:error\","]
run_event_lines += [f" \"node\": {node_id_to_integer(next_node)}, \"error\": $e.msg, \"stacktrace\": e.getStackTrace()}})"]
run_event_lines += [f" except Exception as e: self.logger.log(%*{{\"event\": \"{sanitize_nim_string(event)}:error\","
f" \"node\": {node_id_to_integer(next_node)}, \"error\": $e.msg, \"stacktrace\": e.getStackTrace()}})"]
if not event_nodes.get('setSceneState', None):
run_event_lines += ["of \"setSceneState\":"]
run_event_lines += set_scene_state_lines

state_init_fields = []
public_state_fields = []
persisted_state_fields = []
for field in scene.get('fields', []):
name = field.get('name', '')
if name == "":
continue
type = field.get('type', 'string')
value = field.get('value', '')
if type == 'integer':
state_init_fields += [f"\"{sanitize_nim_string(name)}\": %*({int(value)})"]
elif type == 'float':
state_init_fields += [f"\"{sanitize_nim_string(name)}\": %*({float(value)})"]
elif type == 'boolean':
state_init_fields += [f"\"{sanitize_nim_string(name)}\": %*({'true' if value == 'true' else 'false'})"]
elif type == 'json':
state_init_fields += [f"\"{sanitize_nim_string(name)}\": parseJson(\"{sanitize_nim_string(str(value))}\")"]
else:
state_init_fields += [f"\"{sanitize_nim_string(name)}\": %*(\"{sanitize_nim_string(str(value))}\")"]
if field.get('access', 'private') == 'public':
opts = ""
if field.get('type', 'string') == 'select':
opts = ", ".join([f"\"{sanitize_nim_string(option)}\"" for option in field.get('options', [])])

public_state_fields.append(
f"StateField(name: \"{sanitize_nim_string(field.get('name', ''))}\", " \
f"label: \"{sanitize_nim_string(field.get('label', field.get('name', '')))}\", " \
f"fieldType: \"{sanitize_nim_string(field.get('type', 'string'))}\", options: @[{opts}], " \
f"placeholder: \"{sanitize_nim_string(field.get('placeholder', ''))}\", " \
f"required: {'true' if field.get('required', False) else 'false'}, " \
f"secret: {'true' if field.get('secret', False) else 'false'})"
)
if field.get('persist', 'memory') == 'disk':
persisted_state_fields.append(f"\"{sanitize_nim_string(name)}\"")

newline = "\n"
if len(public_state_fields) > 0:
public_state_fields_seq = "@[\n " + (",\n ".join([field for field in public_state_fields])) + "\n]"
else:
public_state_fields_seq = "@[]"

scene_source = f"""
import pixie, json, times, strformat
Expand All @@ -190,6 +257,8 @@ def node_id_to_integer(node_id: str) -> int:
{newline.join(imports)}
const DEBUG = {'true' if frame.debug else 'false'}
let PUBLIC_STATE_FIELDS*: seq[StateField] = {public_state_fields_seq}
let PERSISTED_STATE_KEYS*: seq[string] = @[{', '.join(persisted_state_fields)}]
type Scene* = ref object of FrameScene
{(newline + " ").join(scene_object_fields)}
Expand Down Expand Up @@ -221,27 +290,40 @@ def node_id_to_integer(node_id: str) -> int:
{(newline + " ").join(run_event_lines)}
else: discard
proc init*(frameConfig: FrameConfig, logger: Logger, dispatchEvent: proc(
event: string, payload: JsonNode)): Scene =
var state = %*{{}}
let scene = Scene(frameConfig: frameConfig, logger: logger, state: state,
dispatchEvent: dispatchEvent)
proc init*(frameConfig: FrameConfig, logger: Logger, dispatchEvent: proc(event: string, payload: JsonNode), persistedState: JsonNode): Scene =
var state = %*{{{", ".join(state_init_fields)}}}
if persistedState.kind == JObject:
for key in persistedState.keys:
state[key] = persistedState[key]
let scene = Scene(frameConfig: frameConfig, logger: logger, state: state, dispatchEvent: dispatchEvent)
let self = scene
var context = ExecutionContext(scene: scene, event: "init", payload: %*{{
}}, image: newImage(1, 1), loopIndex: 0, loopKey: ".")
var context = ExecutionContext(scene: scene, event: "init", payload: state, image: newImage(1, 1), loopIndex: 0, loopKey: ".")
result = scene
scene.execNode = (proc(nodeId: NodeId, context: var ExecutionContext) = self.runNode(nodeId, context))
scene.execNode = (proc(nodeId: NodeId, context: var ExecutionContext) = scene.runNode(nodeId, context))
{(newline + " ").join(init_apps)}
runEvent(scene, context)
proc getPublicState*(self: Scene): JsonNode =
result = %*{{}}
for field in PUBLIC_STATE_FIELDS:
let key = field.name
if self.state.hasKey(key):
result[key] = self.state{{key}}
proc getPersistedState*(self: Scene): JsonNode =
result = %*{{}}
for key in PERSISTED_STATE_KEYS:
if self.state.hasKey(key):
result[key] = self.state{{key}}
proc render*(self: Scene): Image =
var context = ExecutionContext(
scene: self,
event: "render",
payload: %*{{}},
image: case self.frameConfig.rotate:
of 90, 270: newImage(self.frameConfig.height, self.frameConfig.width)
else: newImage(self.frameConfig.width, self.frameConfig.height),
of 90, 270: newImage(self.frameConfig.height, self.frameConfig.width)
else: newImage(self.frameConfig.width, self.frameConfig.height),
loopIndex: 0,
loopKey: "."
)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/models/frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ def create_default_scene() -> dict:

def get_frame_json(frame: Frame) -> dict:
frame_json = {
"frameHost": frame.frame_host,
"name": frame.name,
"framePort": frame.frame_port or 8787,
"serverHost": frame.server_host or "localhost",
"serverPort": frame.server_port or 8989,
Expand Down
3 changes: 2 additions & 1 deletion backend/app/tasks/deploy_frame.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def install_if_necessary(package: str, raise_on_error = True) -> int:
log(id, "stdout", f"> add /srv/frameos/build/build_{build_id}.tar.gz")
scp.put(archive_path, f"/srv/frameos/build/build_{build_id}.tar.gz")
exec_command(frame, ssh, f"cd /srv/frameos/build && tar -xzf build_{build_id}.tar.gz && rm build_{build_id}.tar.gz")
exec_command(frame, ssh, f"cd /srv/frameos/build/build_{build_id} && make -j$(nproc)")
exec_command(frame, ssh, f"cd /srv/frameos/build/build_{build_id} && PARALLEL_MEM=$(awk '/MemTotal/{{printf \"%.0f\\n\", $2/1024/150}}' /proc/meminfo) && PARALLEL=$(($PARALLEL_MEM < $(nproc) ? $PARALLEL_MEM : $(nproc))) && make -j$PARALLEL")
exec_command(frame, ssh, f"mkdir -p /srv/frameos/releases/release_{build_id}")
exec_command(frame, ssh, f"cp /srv/frameos/build/build_{build_id}/frameos /srv/frameos/releases/release_{build_id}/frameos")
log(id, "stdout", f"> add /srv/frameos/releases/release_{build_id}/frame.json")
Expand All @@ -129,6 +129,7 @@ def install_if_necessary(package: str, raise_on_error = True) -> int:
service_contents = file.read().replace("%I", frame.ssh_user)
with SCPClient(ssh.get_transport()) as scp:
scp.putfo(StringIO(service_contents), f"/srv/frameos/releases/release_{build_id}/frameos.service")
exec_command(frame, ssh, f"mkdir -p /srv/frameos/state && ln -s /srv/frameos/state /srv/frameos/releases/release_{build_id}/state")
exec_command(frame, ssh, f"sudo cp /srv/frameos/releases/release_{build_id}/frameos.service /etc/systemd/system/frameos.service")
exec_command(frame, ssh, "sudo chown root:root /etc/systemd/system/frameos.service")
exec_command(frame, ssh, "sudo chmod 644 /etc/systemd/system/frameos.service")
Expand Down
1 change: 1 addition & 0 deletions frameos/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ nimcache
testresults
nimble.develop
nimble.paths
scene.json
27 changes: 27 additions & 0 deletions frameos/assets/web/control.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Frame Control</title>
<style>
body {
padding: 0;
margin: 0 10px;
background: white;
position: relative;
}
</style>
</head>
<body>
<h1>Frame Control</h1>
<h2>Actions:</h2>
<script>function postRender() { fetch('/event/render', {method:'POST',headers:{'Content-Type': 'application/json'},body:JSON.stringify({})}) }</script>
<form onSubmit='postRender(); return false'><input type='submit' value='Render'></form>
<h2>State</h2>
<script>function postSetSceneState() { var data={render:true,state:{/*$$fieldsSubmitHtml$$*/}};fetch('/event/setSceneState', {method:'POST',headers:{'Content-Type': 'application/json'},body:JSON.stringify(data)}); document.getElementById('setSceneState').value = 'Now wait a while...'; }</script>
<form onSubmit='postSetSceneState(); return false'>
/*$$fieldsHtml$$*/
</form>
</body>
</html>
2 changes: 1 addition & 1 deletion frameos/src/apps/qr/app.nim
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ proc error*(self: App, message: string) =

proc run*(self: App, context: ExecutionContext) =
let code = if self.appConfig.code == "": (if self.frameConfig.framePort mod 1000 == 443: "https" else: "http") &
"://" & self.frameConfig.frameHost & ":" & $self.frameConfig.framePort else: self.appConfig.code
"://" & self.frameConfig.frameHost & ":" & $self.frameConfig.framePort & "/c" else: self.appConfig.code
let myQR = newQR(code)

let width = case self.appConfig.sizeUnit
Expand Down
2 changes: 2 additions & 0 deletions frameos/src/frameos/config.nim
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ proc setConfigDefaults*(config: var FrameConfig) =
if config.scalingMode == "": config.scalingMode = "cover"
if config.framePort == 0: config.framePort = 8787
if config.frameHost == "": config.frameHost = "localhost"
if config.name == "": config.name = config.frameHost

proc loadConfig*(filename: string = "frame.json"): FrameConfig =
let data = parseFile(filename)
result = FrameConfig(
name: data{"name"}.getStr(),
serverHost: data{"serverHost"}.getStr(),
serverPort: data{"serverPort"}.getInt(),
serverApiKey: data{"serverApiKey"}.getStr(),
Expand Down
Loading

0 comments on commit 6b878fe

Please sign in to comment.