Skip to content

Commit 3b636be

Browse files
authored
Captive portal wifi setup (#135)
1 parent 6134c61 commit 3b636be

30 files changed

+1260
-206
lines changed

backend/app/api/frames.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@ async def api_frame_clear_build_cache(id: int, redis: Redis = Depends(get_redis)
489489
try:
490490
ssh = await get_ssh_connection(db, redis, frame)
491491
try:
492-
command = "rm -rf /srv/frameos/build/cache"
492+
command = "rm -rf /srv/frameos/build/cache && echo 'Build cache cleared'"
493493
await exec_command(db, redis, frame, ssh, command)
494494
finally:
495495
await remove_ssh_connection(db, redis, ssh, frame)
@@ -516,6 +516,15 @@ async def api_frame_restart_event(id: int, redis: Redis = Depends(get_redis)):
516516
except Exception as e:
517517
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
518518

519+
@api_with_auth.post("/frames/{id:int}/reboot")
520+
async def api_frame_reboot_event(id: int, redis: Redis = Depends(get_redis)):
521+
try:
522+
from app.tasks import reboot_frame
523+
await reboot_frame(id, redis)
524+
return "Success"
525+
except Exception as e:
526+
raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e))
527+
519528

520529
@api_with_auth.post("/frames/{id:int}/stop")
521530
async def api_frame_stop_event(id: int, redis: Redis = Depends(get_redis)):
@@ -574,6 +583,9 @@ async def api_frame_update_endpoint(
574583
if data.next_action == 'restart':
575584
from app.tasks import restart_frame
576585
await restart_frame(id, redis)
586+
elif data.next_action == 'reboot':
587+
from app.tasks import reboot_frame
588+
await reboot_frame(id, redis)
577589
elif data.next_action == 'stop':
578590
from app.tasks import stop_frame
579591
await stop_frame(id, redis)

backend/app/models/frame.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,15 @@ async def new_frame(db: Session, redis: Redis, name: str, frame_host: str, serve
150150
assets_path='/srv/assets',
151151
save_assets=True,
152152
upload_fonts='', # all
153-
network={"networkCheck": True, "networkCheckTimeoutSeconds": 60, "networkCheckUrl": "https://networkcheck.frameos.net/"},
153+
network={
154+
"networkCheck": True,
155+
"networkCheckTimeoutSeconds": 30,
156+
"networkCheckUrl": "https://networkcheck.frameos.net/",
157+
"wifiHotspot": "disabled",
158+
"wifiHotspotSsid": "FrameOS-Setup",
159+
"wifiHotspotPassword": "frame1234",
160+
"wifiHotspotTimeoutSeconds": 600,
161+
},
154162
control_code={"enabled": "true", "position": "top-right"},
155163
schedule={"events": []},
156164
reboot={"enabled": "true", "crontab": "4 0 * * *"},
@@ -239,8 +247,12 @@ def get_frame_json(db: Session, frame: Frame) -> dict:
239247
} if frame.control_code else {"enabled": False},
240248
"network": {
241249
"networkCheck": network.get('networkCheck', True),
242-
"networkCheckTimeoutSeconds": int(network.get('networkCheckTimeoutSeconds', 60)),
250+
"networkCheckTimeoutSeconds": int(network.get('networkCheckTimeoutSeconds', 30)),
243251
"networkCheckUrl": network.get('networkCheckUrl', "https://networkcheck.frameos.net/"),
252+
"wifiHotspot": network.get('wifiHotspot', "disabled"),
253+
"wifiHotspotSsid": network.get('wifiHotspotSsid', "FrameOS-Setup"),
254+
"wifiHotspotPassword": network.get('wifiHotspotPassword', "frame1234"),
255+
"wifiHotspotTimeoutSeconds": int(network.get('wifiHotspotTimeoutSeconds', 600)),
244256
}
245257
}
246258

backend/app/tasks/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from .fast_deploy_frame import fast_deploy_frame # noqa
22
from .deploy_frame import deploy_frame # noqa
33
from .reset_frame import reset_frame # noqa
4-
from .restart_frame import restart_frame # noqa
4+
from .restart_frame import restart_frame, reboot_frame # noqa
55
from .stop_frame import stop_frame # noqa

backend/app/tasks/deploy_frame.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ async def install_if_necessary(pkg: str, raise_on_error=True) -> int:
128128
# 2. Remote steps
129129
await install_if_necessary("ntp")
130130
await install_if_necessary("build-essential")
131+
await install_if_necessary("hostapd")
131132

132133
if drivers.get("evdev"):
133134
await install_if_necessary("libevdev-dev")

backend/app/tasks/restart_frame.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,32 @@ async def restart_frame_task(ctx: dict[str, Any], id: int):
4141
finally:
4242
if ssh is not None:
4343
await remove_ssh_connection(db, redis, ssh, frame)
44+
45+
async def reboot_frame(id: int, redis: Redis):
46+
await redis.enqueue_job("reboot_frame", id=id)
47+
48+
async def reboot_frame_task(ctx: dict[str, Any], id: int):
49+
db: Session = ctx['db']
50+
redis: Redis = ctx['redis']
51+
52+
ssh = None
53+
frame = None
54+
try:
55+
frame = db.get(Frame, id)
56+
if not frame:
57+
await log(db, redis, id, "stderr", "Frame not found")
58+
return
59+
60+
frame.status = 'rebooting'
61+
await update_frame(db, redis, frame)
62+
ssh = await get_ssh_connection(db, redis, frame)
63+
await exec_command(db, redis, frame, ssh, "sudo reboot")
64+
65+
except Exception as e:
66+
await log(db, redis, id, "stderr", str(e))
67+
if frame:
68+
frame.status = 'uninitialized'
69+
await update_frame(db, redis, frame)
70+
finally:
71+
if ssh is not None:
72+
await remove_ssh_connection(db, redis, ssh, frame)

backend/app/tasks/worker.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from app.tasks.deploy_frame import deploy_frame_task
1313
from app.tasks.fast_deploy_frame import fast_deploy_frame_task
1414
from app.tasks.reset_frame import reset_frame_task
15-
from app.tasks.restart_frame import restart_frame_task
15+
from app.tasks.restart_frame import restart_frame_task, reboot_frame_task
1616
from app.tasks.stop_frame import stop_frame_task
1717
from app.config import config
1818
from app.redis import create_redis_connection
@@ -49,6 +49,7 @@ class WorkerSettings:
4949
func(fast_deploy_frame_task, name="fast_deploy_frame"),
5050
func(reset_frame_task, name="reset_frame"),
5151
func(restart_frame_task, name="restart_frame"),
52+
func(reboot_frame_task, name="reboot_frame"),
5253
func(stop_frame_task, name="stop_frame"),
5354
]
5455
on_startup = startup

frameos/frame.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@
6969
},
7070
"network": {
7171
"networkCheck": true,
72-
"networkCheckSeconds": 60,
73-
"networkCheckUrl": "https://networkcheck.frameos.net/"
72+
"networkCheckTimeoutSeconds": 30,
73+
"networkCheckUrl": "https://networkcheck.frameos.net/",
74+
"wifiHotspot": "disabled",
75+
"wifiHotspotSsid": "FrameOS",
76+
"wifiHotspotPassword": "frame1234",
77+
"wifiHotspotTimeoutSeconds": 600
7478
}
7579
}

frameos/src/frameos/channels.nim

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ var eventChannel*: Channel[(Option[SceneId], string, JsonNode)]
99
eventChannel.open()
1010

1111
# Send an event to the current scene
12-
proc sendEvent*(event: string, payload: JsonNode) =
12+
proc sendEvent*(event: string, payload: JsonNode) {.gcsafe.} =
1313
eventChannel.send((none(SceneId), event, payload))
1414

1515
# Send an event to a specific scene

frameos/src/frameos/config.nim

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,12 @@ proc loadNetwork*(data: JsonNode): NetworkConfig =
6161
else:
6262
result = NetworkConfig(
6363
networkCheck: data{"networkCheck"}.getBool(),
64-
networkCheckTimeoutSeconds: data{"networkCheckTimeoutSeconds"}.getFloat(60),
64+
networkCheckTimeoutSeconds: data{"networkCheckTimeoutSeconds"}.getFloat(30),
6565
networkCheckUrl: data{"networkCheckUrl"}.getStr("https://networkcheck.frameos.net"),
66+
wifiHotspot: if data{"networkCheck"}.getBool(): data{"wifiHotspot"}.getStr("disabled") else: "disabled",
67+
wifiHotspotSsid: data{"wifiHotspotSsid"}.getStr("FrameOS-Setup"),
68+
wifiHotspotPassword: data{"wifiHotspotPassword"}.getStr("frame1234"),
69+
wifiHostpotTimeoutSeconds: data{"wifiHotspotTimeoutSeconds"}.getFloat(600),
6670
)
6771

6872
proc loadConfig*(filename: string = "frame.json"): FrameConfig =

frameos/src/frameos/frameos.nim

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import json, asyncdispatch, pixie, strutils, times, os
2-
import httpclient
1+
import json, asyncdispatch, pixie, strutils, times, os, httpclient, options
32
import drivers/drivers as drivers
43
import frameos/config
54
import frameos/logger
@@ -8,6 +7,7 @@ import frameos/runner
87
import frameos/server
98
import frameos/scheduler
109
import frameos/types
10+
import frameos/portal as netportal
1111
import lib/tz
1212

1313
proc newFrameOS*(): FrameOS =
@@ -19,7 +19,11 @@ proc newFrameOS*(): FrameOS =
1919
result = FrameOS(
2020
frameConfig: frameConfig,
2121
logger: logger,
22-
metricsLogger: metricsLogger
22+
metricsLogger: metricsLogger,
23+
network: Network(
24+
status: NetworkStatus.idle,
25+
hotspotStatus: HotspotStatus.disabled,
26+
),
2327
)
2428
drivers.init(result)
2529
result.runner = newRunner(frameConfig)
@@ -45,36 +49,24 @@ proc start*(self: FrameOS) {.async.} =
4549
"gpioButtons": self.frameConfig.gpioButtons,
4650
}}
4751
self.logger.log(message)
52+
netportal.setLogger(self.logger)
4853

49-
# Check if there's an internet connection or until timeout
50-
if self.frameConfig.network.networkCheck and self.frameConfig.network.networkCheckTimeoutSeconds > 0:
51-
let url = self.frameConfig.network.networkCheckUrl
52-
let timeout = self.frameConfig.network.networkCheckTimeoutSeconds
53-
let timer = epochTime()
54-
var attempt = 1
55-
self.logger.log(%*{"event": "networkCheck", "url": url})
56-
while true:
57-
if epochTime() - timer >= timeout:
58-
self.logger.log(%*{"event": "networkCheck", "status": "timeout", "seconds": timeout})
59-
break
60-
let client = newHttpClient(timeout = 5000)
61-
try:
62-
let response = client.get(url)
63-
if response.status.startsWith("200"):
64-
self.logger.log(%*{"event": "networkCheck", "attempt": attempt, "status": "success"})
65-
break
66-
else:
67-
self.logger.log(%*{"event": "networkCheck", "attempt": attempt, "status": "failed",
68-
"response": response.status})
69-
except CatchableError as e:
70-
self.logger.log(%*{"event": "networkCheck", "attempt": attempt, "status": "error", "error": e.msg})
71-
finally:
72-
client.close()
73-
sleep(attempt * 1000)
74-
attempt += 1
54+
var firstSceneId: Option[SceneId] = none(SceneId)
55+
if self.frameConfig.network.networkCheck:
56+
let connected = checkNetwork(self)
57+
if self.frameConfig.network.wifiHotspot == "bootOnly":
58+
if connected:
59+
netportal.stopAp(self)
60+
else:
61+
netportal.startAp(self)
62+
firstSceneId = some("system/wifiHotspot".SceneId)
63+
else:
64+
self.logger.log(%*{"event": "networkCheck", "status": "skipped"})
7565

76-
self.runner.start()
77-
result = self.server.startServer()
66+
self.runner.start(firstSceneId)
67+
68+
## This call never returns
69+
await self.server.startServer()
7870

7971
proc startFrameOS*() {.async.} =
8072
var frameOS = newFrameOS()

frameos/src/frameos/logger.nim

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,10 +59,8 @@ proc processQueue(self: LoggerThread): int =
5959
self.lastSendAt = epochTime()
6060
if response.code != Http200:
6161
echo "Error sending logs: HTTP " & $response.status
62-
logToFile(self.frameConfig.logToFile, %*{"error": "Error sending logs", "status": response.status})
6362
except CatchableError as e:
6463
echo "Error sending logs: " & $e.msg
65-
logToFile(self.frameConfig.logToFile, %*{"error": "Error sending logs", "message": e.msg})
6664
finally:
6765
client.close()
6866

0 commit comments

Comments
 (0)