Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scene state #60

Merged
merged 28 commits into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading