Coverage for src/rift_console/__main__.py: 0%

599 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-08 09:36 +0000

1"""Command-line interface.""" 

2 

3from collections import defaultdict 

4import csv 

5from pathlib import Path 

6import pathlib 

7import re 

8import sys 

9import datetime 

10import os 

11 

12import click 

13from loguru import logger 

14 

15from quart import ( 

16 Quart, 

17 render_template, 

18 redirect, 

19 send_from_directory, 

20 url_for, 

21 request, 

22 flash, 

23) 

24from werkzeug.wrappers.response import Response 

25from hypercorn.config import Config 

26import asyncio 

27from hypercorn.asyncio import serve 

28 

29# shared imports 

30from melvonaut import ebt_calc 

31from rift_console.image_helper import filter_by_date, get_angle, get_date 

32import rift_console.rift_console 

33import shared.constants as con 

34from shared.models import ( 

35 Event, 

36 State, 

37 CameraAngle, 

38 ZonedObjective, 

39 lens_size_by_angle, 

40 live_utc, 

41) 

42import rift_console.image_processing 

43import rift_console.ciarc_api as ciarc_api 

44import rift_console.melvin_api as melvin_api 

45 

46##### LOGGING ##### 

47con.RIFT_LOG_LEVEL = "INFO" 

48logger.remove() 

49logger.add(sink=sys.stderr, level=con.RIFT_LOG_LEVEL, backtrace=True, diagnose=True) 

50logger.add( 

51 sink=con.RIFT_LOG_LOCATION, 

52 rotation="00:00", 

53 level="DEBUG", 

54 backtrace=True, 

55 diagnose=True, 

56) 

57 

58app = Quart(__name__) 

59app.secret_key = "yoursecret_key" 

60app.config["ebt"] = con.CONSOLE_EBT_PATH 

61app.config["live"] = con.CONSOLE_LIVE_PATH 

62app.config["stitched"] = con.CONSOLE_STICHED_PATH 

63app.config["downloaded"] = con.CONSOLE_DOWNLOAD_PATH 

64console = rift_console.rift_console.RiftConsole() 

65 

66# [Routes] 

67@app.route("/view_ebt") 

68async def view_ebt() -> str: 

69 """Show ebt images.""" 

70 # list all images 

71 images = os.listdir(con.CONSOLE_EBT_PATH) 

72 # filter to only png 

73 images = [s for s in images if s.endswith(".png")] 

74 

75 # sort by date modifyed, starting with the newest 

76 images.sort( 

77 key=lambda x: os.path.getmtime(Path(con.CONSOLE_EBT_PATH) / x), reverse=True 

78 ) 

79 # only take first CONSOLE_IMAGE_VIEWER_LIMIT 

80 images = images[: con.CONSOLE_IMAGE_VIEWER_LIMIT] 

81 

82 count = len(images) 

83 logger.warning(f"Showing {count} images of ebt.") 

84 # logger.info(f"Images: {images}") 

85 return await render_template("ebt.html", images=images, count=count) 

86 

87@app.route("/stitches") 

88async def stitches() -> str: 

89 """Show stitched images, e.g. Worldmap, Zoned, Hidden Objectives.""" 

90 # list all images 

91 images = os.listdir(con.CONSOLE_STICHED_PATH) 

92 # filter to only png 

93 images = [s for s in images if s.endswith(".png")] 

94 # filter high res images away 

95 images = [s for s in images if "cut" in s or "thumb" in s] 

96 

97 # sort by date modifyed, starting with the newest 

98 images.sort( 

99 key=lambda x: os.path.getmtime(Path(con.CONSOLE_STICHED_PATH) / x), reverse=True 

100 ) 

101 # only take first CONSOLE_IMAGE_VIEWER_LIMIT 

102 images = images[: con.CONSOLE_IMAGE_VIEWER_LIMIT] 

103 

104 count = len(images) 

105 worldmap = sum("worldmap" in i for i in images) 

106 zoned = sum("zoned" in i for i in images) 

107 hidden = sum("hidden" in i for i in images) 

108 logger.warning(f"Showing {count} stitched images.") 

109 # logger.info(f"Images: {images}") 

110 return await render_template( 

111 "stitched.html", 

112 images=images, 

113 count=count, 

114 worldMap=worldmap, 

115 zoned=zoned, 

116 hidden=hidden, 

117 ) 

118 

119@app.route("/downloads") 

120async def downloads() -> str: 

121 """Show donwloaded indiviual images from melvonaut.""" 

122 images = get_console_images() 

123 

124 # sort by timestamp 

125 images.sort( 

126 key=lambda x: get_date(x), 

127 reverse=True, 

128 ) 

129 

130 # only take first CONSOLE_IMAGE_VIEWER_LIMIT 

131 images = images[: con.CONSOLE_IMAGE_VIEWER_LIMIT] 

132 

133 # find the dates of each image 

134 dates = set() 

135 for image in images: 

136 dates.add(get_date(image)[:10]) 

137 dates_list = list(dates) 

138 dates_list.sort(reverse=True) 

139 

140 image_tupel = [(image, get_date(image)[:10], get_angle(image)) for image in images] 

141 

142 count = len(images) 

143 narrow = sum(CameraAngle.Narrow in i for i in images) 

144 normal = sum(CameraAngle.Normal in i for i in images) 

145 wide = sum(CameraAngle.Wide in i for i in images) 

146 logger.warning( 

147 f"Showing {len(image_tupel)} images, from {len(dates_list)} different dates." 

148 ) 

149 # logger.info(f"Images: {images}") 

150 return await render_template( 

151 "downloads.html", 

152 image_tupel=image_tupel, 

153 count=count, 

154 narrow=narrow, 

155 normal=normal, 

156 wide=wide, 

157 dates=dates_list, 

158 ) 

159 

160@app.route("/live") 

161async def live() -> str: 

162 """Show single images that are taken by a button press in console.""" 

163 # list all images 

164 images = os.listdir(con.CONSOLE_LIVE_PATH) 

165 # filter to only png 

166 images = [s for s in images if s.endswith(".png")] 

167 

168 # sort by date modifyed, starting with the newest 

169 images.sort( 

170 key=lambda x: os.path.getmtime(Path(con.CONSOLE_LIVE_PATH) / x), reverse=True 

171 ) 

172 # only take first CONSOLE_IMAGE_VIEWER_LIMIT 

173 images = images[: con.CONSOLE_IMAGE_VIEWER_LIMIT] 

174 

175 count = len(images) 

176 narrow = sum(CameraAngle.Narrow in i for i in images) 

177 normal = sum(CameraAngle.Normal in i for i in images) 

178 wide = sum(CameraAngle.Wide in i for i in images) 

179 logger.warning(f"Showing {count} images.") 

180 # logger.info(f"Images: {images}") 

181 return await render_template( 

182 "live.html", images=images, count=count, narrow=narrow, normal=normal, wide=wide 

183 ) 

184 

185 

186@app.route("/", methods=["GET"]) 

187async def index() -> str: 

188 """Main web-page.""" 

189 if console.live_telemetry: 

190 return await render_template( 

191 "main.html", 

192 last_backup_date=console.last_backup_date.isoformat()[:-13] 

193 if console.last_backup_date 

194 else "", 

195 is_network_simulation=console.is_network_simulation, 

196 user_speed_multiplier=console.user_speed_multiplier, 

197 # live telemtry 

198 timestamp=console.live_telemetry.timestamp.isoformat()[:-6], 

199 state=console.live_telemetry.state, 

200 angle=console.live_telemetry.angle, 

201 width_x=console.live_telemetry.width_x, 

202 height_y=console.live_telemetry.height_y, 

203 vx=console.live_telemetry.vx, 

204 vy=console.live_telemetry.vy, 

205 fuel=console.live_telemetry.fuel, 

206 battery=console.live_telemetry.battery, 

207 max_battery=console.live_telemetry.max_battery, 

208 distance_covered=console.live_telemetry.distance_covered, 

209 area_covered_narrow=f"{console.live_telemetry.area_covered.narrow * 100 :.2f}", # convert percantage 

210 area_covered_normal=f"{console.live_telemetry.area_covered.normal * 100 :.2f}", # convert percantage 

211 area_covered_wide=f"{console.live_telemetry.area_covered.wide * 100 :.2f}", # convert percantage 

212 active_time=console.live_telemetry.active_time, 

213 images_taken=console.live_telemetry.images_taken, 

214 objectives_done=console.live_telemetry.objectives_done, 

215 objectives_points=console.live_telemetry.objectives_points, 

216 data_volume_sent=console.live_telemetry.data_volume.data_volume_sent, 

217 data_volume_received=console.live_telemetry.data_volume.data_volume_received, 

218 # keep track of state history 

219 prev_state=console.prev_state, 

220 next_state=console.next_state, 

221 slots_used=console.slots_used, 

222 # tables 

223 slots=console.slots, 

224 zoned_objectives=console.zoned_objectives, 

225 beacon_objectives=console.beacon_objectives, 

226 achievements=console.achievements, 

227 completed_ids=console.completed_ids, 

228 # slot times 

229 next_slot_start=console.slots[0].start.strftime("%H:%M:%S"), 

230 slot_ends=console.slots[0].end.strftime("%H:%M:%S"), 

231 # drawn map 

232 draw_zoned_obj=console.get_draw_zoned_obj(), 

233 camera_size=lens_size_by_angle(console.live_telemetry.angle), 

234 past_traj=console.past_traj, 

235 future_traj=console.future_traj, 

236 # melvonaut api 

237 api=console.live_melvonaut_api, 

238 melvonaut_image_count=console.melvonaut_image_count, 

239 console_image_count=console.console_image_count, 

240 console_image_dates=console.console_image_dates, 

241 melvin_task=console.melvin_task, 

242 melvin_lens=console.melvin_lens, 

243 # ebt ping list 

244 ebt_ping_list=console.ebt_ping_list, 

245 ) 

246 else: 

247 return await render_template( 

248 "main.html", 

249 last_backup_date=console.last_backup_date, 

250 is_network_simulation=console.is_network_simulation, 

251 user_speed_multiplier=console.user_speed_multiplier, 

252 prev_state=State.Unknown, 

253 next_state=State.Unknown, 

254 state=State.Unknown, 

255 # need default to prevent crash 

256 draw_zoned_obj=[], 

257 past_traj=[], 

258 future_traj=[], 

259 width_x=0, 

260 height_y=0, 

261 # tables 

262 slots=console.slots, 

263 zoned_objectives=console.zoned_objectives, 

264 beacon_objectives=console.beacon_objectives, 

265 achievements=console.achievements, 

266 completed_ids=console.completed_ids, 

267 next_slot_start=console.slots[0].start.strftime("%H:%M:%S") 

268 if len(console.slots) > 0 

269 else "noData", 

270 slot_ends=console.slots[0].end.strftime("%H:%M:%S") 

271 if len(console.slots) > 0 

272 else "noData", 

273 # melvonaut api 

274 api=console.live_melvonaut_api, 

275 melvonaut_image_count=console.melvonaut_image_count, 

276 console_image_count=console.console_image_count, 

277 console_image_dates=console.console_image_dates, 

278 melvin_task=console.melvin_task, 

279 melvin_lens=console.melvin_lens, 

280 # ebt ping list 

281 ebt_ping_list=console.ebt_ping_list, 

282 ) 

283 

284 

285# [BUTTONS] 

286@app.route("/melvonaut_api", methods=["POST"]) 

287async def melvonaut_api() -> Response: 

288 """Buttons for Melvonaut API.""" 

289 global console 

290 

291 # read which button was pressed 

292 form = await request.form 

293 button = form.get("button", type=str) 

294 

295 match button: 

296 case "status": 

297 console.live_melvonaut_api = melvin_api.live_melvonaut() 

298 if not console.live_melvonaut_api: 

299 await flash("Could not contact Melvonaut API - live_melvonaut.") 

300 console.melvin_task = melvin_api.get_setting(setting="CURRENT_MELVIN_TASK") 

301 console.melvin_lens = melvin_api.get_setting( 

302 setting="TARGET_CAMERA_ANGLE_ACQUISITION" 

303 ) 

304 case "tunnel": 

305 melvin_api.create_tunnel() 

306 await flash("Created Tunnel for 15 min.") 

307 

308 case "mapping" | "ebt": 

309 if melvin_api.set_setting(setting="CURRENT_MELVIN_TASK", value=button): 

310 await info(f"Set MelvinSettings-Task to {button} done.") 

311 console.melvin_task = melvin_api.get_setting( 

312 setting="CURRENT_MELVIN_TASK" 

313 ) 

314 else: 

315 await warning(f"Set MelvinSettings-Task to {button} failed!") 

316 case "narrow" | "normal" | "wide": 

317 if melvin_api.set_setting( 

318 setting="TARGET_CAMERA_ANGLE_ACQUISITION", value=button 

319 ): 

320 await info(f"Set MelvinSettings-Angle to {button} done.") 

321 console.melvin_lens = melvin_api.get_setting( 

322 setting="TARGET_CAMERA_ANGLE_ACQUISITION" 

323 ) 

324 else: 

325 await warning(f"Set MelvinSettings-Angle to {button} failed!") 

326 case "count": 

327 await check_images() 

328 

329 images = melvin_api.list_images() 

330 if type(images) is list: 

331 console.melvonaut_image_count = len(images) 

332 else: 

333 await flash("Could not contact Melvonaut API - count.") 

334 case "sync": 

335 images = melvin_api.list_images() 

336 if type(images) is list: 

337 console.melvonaut_image_count = len(images) 

338 success = 0 

339 failed = 0 

340 already_there = 0 

341 for image in images: 

342 if os.path.isfile(con.CONSOLE_DOWNLOAD_PATH + image): 

343 already_there += 1 

344 logger.info(f'File "{image}" exists, not downloaded') 

345 continue 

346 r = melvin_api.get_download_save_image(image) 

347 if r: 

348 with open( 

349 con.CONSOLE_DOWNLOAD_PATH + image, 

350 "wb", 

351 ) as f: 

352 f.write(r.content) 

353 success += 1 

354 logger.info(f'Downloaded "{image}" success!') 

355 else: 

356 failed += 1 

357 logger.warning(f'File "{image}" failed!') 

358 if failed > 10: 

359 await info("Cancle since connection probably interrupted.") 

360 break 

361 await info( 

362 f"Downloaded Images from Melvonaut, success: {success}, failed: {failed}, already exisiting: {already_there}" 

363 ) 

364 else: 

365 await flash("Could not contact Melvonaut API - count.") 

366 

367 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH) 

368 console.console_image_count = sum( 

369 file.is_file() for file in folder.rglob("*.png") 

370 ) 

371 case "clear": 

372 if melvin_api.clear_images(): 

373 if console.melvonaut_image_count != -1: 

374 await info(f"Cleared {console.melvonaut_image_count} images.") 

375 else: 

376 await info("Cleared all images.") 

377 console.melvonaut_image_count = 0 

378 else: 

379 await flash("Clearing of images failed!") 

380 

381 case "sync_logs": 

382 logs = melvin_api.list_logs() 

383 dir = "logs-" + live_utc().strftime("%Y-%m-%dT%H:%M:%S") 

384 path = Path(con.CONSOLE_FROM_MELVONAUT_PATH + dir) 

385 if not path.exists(): 

386 path.mkdir() 

387 if type(logs) is list: 

388 console.melvonaut_image_count = len(logs) 

389 success = 0 

390 failed = 0 

391 already_there = 0 

392 for log in logs: 

393 r = melvin_api.get_download_save_log(log) 

394 if r: 

395 with open( 

396 con.CONSOLE_FROM_MELVONAUT_PATH + dir + "/" + log, 

397 "wb", 

398 ) as f: 

399 f.write(r.content) 

400 success += 1 

401 # logger.info(f'Downloaded "{log}" success!') 

402 else: 

403 failed += 1 

404 logger.warning(f'File "{log}" failed!') 

405 await info( 

406 f"Downloaded Logs from Melvonaut, success: {success}, failed: {failed} to {con.CONSOLE_FROM_MELVONAUT_PATH + dir}" 

407 ) 

408 else: 

409 await flash("Could not contact Melvonaut API - count.") 

410 

411 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH) 

412 console.console_image_count = sum( 

413 file.is_file() for file in folder.rglob("*.png") 

414 ) 

415 case "clear_logs": 

416 if melvin_api.clear_logs(): 

417 await flash("Cleared all logs.") 

418 else: 

419 await flash("Clearing of logs failed!") 

420 case "down_telemetry": 

421 res = melvin_api.download_telemetry() 

422 if res: 

423 await flash(res) 

424 else: 

425 await flash( 

426 "Could not contact Melvonaut API - cant download telemetry." 

427 ) 

428 case "clear_telemetry": 

429 res = melvin_api.clear_telemetry() 

430 if res: 

431 await flash(res) 

432 else: 

433 await flash("Could not contact Melvonaut API - cant clear telemetry.") 

434 case "down_events": 

435 res = melvin_api.download_events() 

436 if res: 

437 await flash(res) 

438 else: 

439 await flash("Could not contact Melvonaut API - cant download events.") 

440 case "clear_events": 

441 res = melvin_api.clear_events() 

442 if res: 

443 await flash(res) 

444 else: 

445 await flash("Could not contact Melvonaut API - cant clear events.") 

446 case _: 

447 await warning(f"Unknown button pressed: {button}.") 

448 

449 return redirect(url_for("index")) 

450 

451 

452@app.route("/results", methods=["POST"]) 

453async def results() -> Response: 

454 """Upload world map/images/beacon position""" 

455 # read which button was pressed 

456 form = await request.form 

457 button = form.get("button", type=str) 

458 

459 match button: 

460 case "check_images": 

461 await check_images() 

462 case "check_pings": 

463 files = os.listdir(con.CONSOLE_FROM_MELVONAUT_PATH) 

464 # filter to only png 

465 files = [ 

466 f 

467 for f in files 

468 if f.startswith("MelvonautEvents") and f.endswith(".csv") 

469 ] 

470 

471 if len(files) == 0: 

472 await warning("No Events files, aborting!") 

473 return redirect(url_for("index")) 

474 # sort by date modifyed, starting with the newest 

475 files.sort( 

476 key=lambda x: os.path.getmtime( 

477 Path(con.CONSOLE_FROM_MELVONAUT_PATH) / x 

478 ), 

479 reverse=True, 

480 ) 

481 events = Event.load_events_from_csv( 

482 path=con.CONSOLE_FROM_MELVONAUT_PATH + files[0] 

483 ) 

484 console.console_found_events = events 

485 await flash(f"Loading file {con.CONSOLE_FROM_MELVONAUT_PATH + files[0]}.") 

486 found_ids = set() 

487 ping_count: dict[int, int] = defaultdict(int) 

488 total_pings = 0 

489 pattern = r"GALILEO_MSG_EB,ID_(\d+),DISTANCE_" 

490 for event in events: 

491 match = re.search(pattern, event.event) 

492 if match: 

493 matched_id = int(match.group(1)) 

494 found_ids.add(matched_id) 

495 ping_count[matched_id] += 1 

496 total_pings += 1 

497 ids_list = list(found_ids) 

498 ids_list.sort() 

499 

500 console.ebt_ping_list = [(id, ping_count[id]) for id in ids_list] 

501 await info( 

502 f"Log contained {total_pings} pings of {len(console.ebt_ping_list)} different events." 

503 ) 

504 

505 case "worldmap": 

506 image_path = con.CONSOLE_STICHED_PATH + ( 

507 form.get("path_world", type=str) or "error_path" 

508 ) 

509 if not os.path.isfile(path=image_path): 

510 await warning( 

511 f"Cant upload world map, file: {image_path} does not exist." 

512 ) 

513 return redirect(url_for("index")) 

514 

515 if "thumb" in image_path: 

516 await warning( 

517 "DANGER you are uploading a Thumbnail image with lower resolution!!!" 

518 ) 

519 

520 res = ciarc_api.upload_worldmap(image_path=image_path) 

521 

522 if res: 

523 await flash(res) 

524 if res.startswith("Image uploaded successfully"): 

525 await warning(f"Worldmap - {image_path}.") 

526 

527 case "obj": 

528 image_path = con.CONSOLE_STICHED_PATH + ( 

529 form.get("path_obj", type=str) or "error_path" 

530 ) 

531 id = form.get("objective_id", type=int) or 0 

532 

533 if "thumb" in image_path: 

534 await warning( 

535 "DANGER you are uploading a Thumbnail image with lower resolution!!!" 

536 ) 

537 

538 if not os.path.isfile(image_path): 

539 await warning( 

540 f"Cant upload objective {id}, file: {image_path} does not exist." 

541 ) 

542 return redirect(url_for("index")) 

543 

544 res = ciarc_api.upload_objective(image_path=image_path, objective_id=id) 

545 

546 if res: 

547 await flash(res) 

548 if res.startswith("Image uploaded successfully"): 

549 await warning(f"Objective {id} - {image_path}") 

550 else: 

551 await warning(f"Could not upload objective {id} - {image_path}") 

552 

553 case "beacon": 

554 id = form.get("beacon_id", type=int) or 0 

555 height = form.get("height", type=int) or 0 

556 width = form.get("width", type=int) or 0 

557 res = ciarc_api.send_beacon( 

558 beacon_id=id, 

559 height=height, 

560 width=width, 

561 ) 

562 if res: 

563 status: str = res["status"] 

564 await flash(status + f" (id was {id}).") 

565 if status.startswith( 

566 "The beacon could not be found around the given location" 

567 ): 

568 await flash( 

569 f"Attempts made: {res["attempts_made"]} of 3, guess was ({width},{height})" 

570 ) 

571 if status.startswith("The beacon was found!"): 

572 console.completed_ids.append(id) 

573 case "calc_ebt": 

574 id = form.get("choose_id", type=int) or 0 

575 if not id or id == 0: 

576 await warning("Tried to calculate ebt but no id given, aborting.") 

577 return redirect(url_for("index")) 

578 # parse list of pings 

579 pings = ebt_calc.parse_pings(id=id, events=console.console_found_events) 

580 # find points that are in all circles 

581 res = ebt_calc.find_matches(pings=pings) 

582 

583 (x, y) = ebt_calc.draw_res(id=id, res=res, pings=pings) 

584 

585 await flash( 

586 f"For EBT_{id} found {len(res)} points that are matched by {len(pings)} pings. Centoid is: ({x},{y})" 

587 ) 

588 

589 case "stitch": 

590 choose_date = form.get("choose_date", type=str) 

591 if not choose_date: 

592 await warning("Tried to stitch worldmap but no date given, aborting.") 

593 return redirect(url_for("index")) 

594 

595 images = get_console_images() 

596 filtered_images = [] 

597 for image in images: 

598 if choose_date in image: 

599 filtered_images.append(image) 

600 await warning( 

601 f"Starting stitching, found {len(images)} images and {len(filtered_images)} with right day." 

602 ) 

603 asyncio.create_task( 

604 async_world_map( 

605 filtered_images=filtered_images, choose_date=choose_date 

606 ) 

607 ) 

608 case "stitch_area": 

609 start = datetime.datetime.fromisoformat( 

610 form.get("start_stitch", type=str) or "2025-01-01T00:00" 

611 ).replace(tzinfo=datetime.timezone.utc) 

612 end = datetime.datetime.fromisoformat( 

613 form.get("end_stitch", type=str) or "2025-01-01T00:00" 

614 ).replace(tzinfo=datetime.timezone.utc) 

615 optic_required = CameraAngle( 

616 form.get("angle", type=str) or CameraAngle.Unknown 

617 ) 

618 zone = ( 

619 form.get("x1", type=int) or 0, 

620 form.get("y1", type=int) or 0, 

621 form.get("x2", type=int) or 0, 

622 form.get("y2", type=int) or 0, 

623 ) 

624 

625 await check_images() 

626 

627 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH) 

628 images = [str(file) for file in folder.rglob("*.png") if file.is_file()] 

629 

630 filtered_images = [] 

631 for image in images: 

632 if optic_required in image: 

633 filtered_images.append(image) 

634 

635 final_images = filter_by_date(images=filtered_images, start=start, end=end) 

636 

637 message = f"{len(filtered_images)} have right lens of which {len(final_images)} are in time window." 

638 logger.warning(message) 

639 await flash(message) 

640 

641 if len(final_images) == 0: 

642 logger.warning("Aborting since 0 images") 

643 await flash("Aborting since 0 images") 

644 return redirect(url_for("index")) 

645 

646 final_images = [image.split("/")[-1] for image in final_images] 

647 

648 panorama = rift_console.image_processing.stitch_images( 

649 image_path=con.CONSOLE_DOWNLOAD_PATH, image_name_list=final_images 

650 ) 

651 

652 remove_offset = ( 

653 con.STITCHING_BORDER, 

654 con.STITCHING_BORDER, 

655 con.WORLD_X + con.STITCHING_BORDER, 

656 con.WORLD_Y + con.STITCHING_BORDER, 

657 ) 

658 panorama = panorama.crop(remove_offset) 

659 

660 space = "" 

661 count = 0 

662 path = f"{con.CONSOLE_STICHED_PATH}hidden_{optic_required}_{zone[0]}_{zone[1]}_{zone[2]}_{zone[3]}_{len(final_images)}_{space}.png" 

663 while os.path.isfile(path): 

664 count += 1 

665 space = "_" + str(count) 

666 path = f"{con.CONSOLE_STICHED_PATH}hidden_{optic_required}_{zone[0]}_{zone[1]}_{zone[2]}_{zone[3]}_{len(final_images)}_{space}.png" 

667 

668 panorama.save(path) 

669 

670 rift_console.image_processing.create_thumbnail(path) 

671 

672 rift_console.image_processing.cut( 

673 panorama_path=path, X1=zone[0], Y1=zone[1], X2=zone[2], Y2=zone[3] 

674 ) 

675 

676 await warning( 

677 f"Saved stitch of {optic_required}_{zone[0]}_{zone[1]}_{zone[2]}_{zone[3]} - {len(final_images)} images to {path}" 

678 ) 

679 

680 case _: 

681 await warning(f"Unknown button pressed: {button}.") 

682 

683 # await update_telemetry() 

684 

685 return redirect(url_for("index")) 

686 

687 

688@app.route("/obj_mod", methods=["POST"]) 

689async def obj_mod() -> Response: 

690 """Add/Modify zoned_objectives""" 

691 # read which button was pressed 

692 form = await request.form 

693 button = form.get("button", type=str) 

694 

695 match button: 

696 case "write_obj": 

697 await update_telemetry() 

698 if console.zoned_objectives: 

699 csv_file_path = ( 

700 con.CONSOLE_LOG_PATH 

701 + "ObjectiveDump_" 

702 + str(len(console.zoned_objectives)) 

703 + "_" 

704 + live_utc().strftime("%Y-%m-%dT%H:%M:%S") 

705 + ".csv" 

706 ) 

707 with open(csv_file_path, mode="w", newline="") as file: 

708 writer = csv.writer(file) 

709 

710 writer.writerow( 

711 [ 

712 "id", 

713 "name", 

714 "secret", 

715 "description", 

716 "X1", 

717 "Y1", 

718 "X2", 

719 "Y2", 

720 "optic_required", 

721 "coverage_required", 

722 "start", 

723 "end", 

724 "decrease_rate", 

725 ] 

726 ) 

727 

728 for o in console.zoned_objectives: 

729 if o.zone: 

730 writer.writerow( 

731 [ 

732 o.id, 

733 o.name, 

734 o.secret, 

735 o.description, 

736 o.zone[0], 

737 o.zone[1], 

738 o.zone[2], 

739 o.zone[3], 

740 o.optic_required, 

741 o.coverage_required, 

742 o.start, 

743 o.end, 

744 o.decrease_rate, 

745 ] 

746 ) 

747 else: 

748 writer.writerow( 

749 [ 

750 o.id, 

751 o.name, 

752 o.secret, 

753 o.description, 

754 "-", 

755 "-", 

756 "-", 

757 "-", 

758 o.optic_required, 

759 o.coverage_required, 

760 o.start, 

761 o.end, 

762 o.decrease_rate, 

763 ] 

764 ) 

765 

766 await info(f"Wrote {len(console.zoned_objectives)} to {csv_file_path}.") 

767 else: 

768 await warning("Cant write objective, no data.") 

769 

770 case "zoned": 

771 secret = form.get("secret", type=str) 

772 if secret == "True": 

773 if not ciarc_api.add_modify_zoned_objective( 

774 id=form.get("obj_id", type=int) or 0, 

775 name=form.get("name", type=str) or "name", 

776 start=datetime.datetime.fromisoformat( 

777 form.get("start", type=str) or "2025-01-01T00:00" 

778 ), 

779 end=datetime.datetime.fromisoformat( 

780 form.get("end", type=str) or "2025-01-01T00:00" 

781 ), 

782 optic_required=CameraAngle( 

783 form.get("angle", type=str) or CameraAngle.Unknown 

784 ), 

785 secret=True, 

786 zone=(0, 0, 0, 0), 

787 coverage_required=form.get("coverage_required", type=float) or 0.99, 

788 description=form.get("description", type=str) or "desc", 

789 ): 

790 await flash("Adding secret zoned objective failed, check logs.") 

791 else: 

792 if not ciarc_api.add_modify_zoned_objective( 

793 id=form.get("obj_id", type=int) or 0, 

794 name=form.get("name", type=str) or "name", 

795 start=datetime.datetime.fromisoformat( 

796 form.get("start", type=str) or "2025-01-01T00:00" 

797 ), 

798 end=datetime.datetime.fromisoformat( 

799 form.get("end", type=str) or "2025-01-01T00:00" 

800 ), 

801 optic_required=CameraAngle( 

802 form.get("angle", type=str) or CameraAngle.Unknown 

803 ), 

804 secret=False, 

805 zone=( 

806 form.get("x1", type=int) or 0, 

807 form.get("y1", type=int) or 0, 

808 form.get("x2", type=int) or 0, 

809 form.get("y2", type=int) or 0, 

810 ), 

811 coverage_required=form.get("coverage_required", type=float) or 0.99, 

812 description=form.get("description", type=str) or "desc", 

813 ): 

814 await flash("Adding Zoned Objective failed, check logs.") 

815 case "ebt": 

816 if not ciarc_api.add_modify_ebt_objective( 

817 id=form.get("obj_id", type=int) or 0, 

818 name=form.get("name", type=str) or "name", 

819 start=datetime.datetime.fromisoformat( 

820 form.get("start_ebt", type=str) or "2025-01-01T00:00" 

821 ), 

822 end=datetime.datetime.fromisoformat( 

823 form.get("end_ebt", type=str) or "2025-01-01T00:00" 

824 ), 

825 description=form.get("description", type=str) or "desc", 

826 beacon_height=form.get("beacon_height", type=int) or 0, 

827 beacon_width=form.get("beacon_width", type=int) or 0, 

828 ): 

829 await flash("Adding EBT Objective failed, check logs.") 

830 case _: 

831 await warning(f"Unknown button pressed: {button}.") 

832 

833 await update_telemetry() 

834 

835 return redirect(url_for("index")) 

836 

837 

838@app.route("/book_slot/<int:slot_id>", methods=["POST"]) 

839async def book_slot(slot_id: int) -> Response: 

840 """Book com slots.""" 

841 # read which button was pressed 

842 form = await request.form 

843 button = form.get("button", type=str) 

844 

845 if button == "book": 

846 ciarc_api.book_slot(slot_id=slot_id, enabled=True) 

847 else: 

848 ciarc_api.book_slot(slot_id=slot_id, enabled=False) 

849 

850 # await update_telemetry() 

851 

852 return redirect(url_for("index")) 

853 

854 

855@app.route("/stitch_obj/<int:obj_id>", methods=["POST"]) 

856async def stitch_obj(obj_id: int) -> Response: 

857 """Part of upload panel, stitching of objectives.""" 

858 logger.info(f"Stiching Zoned Objective with id {obj_id}.") 

859 

860 res_obj = None 

861 for obj in console.zoned_objectives: 

862 if obj.id == obj_id: 

863 res_obj = obj 

864 break 

865 if not res_obj or not res_obj.zone: 

866 await warning( 

867 "Objective Id {obj_id} not found, cant stitch without coordinates." 

868 ) 

869 return redirect(url_for("index")) 

870 await check_images() 

871 

872 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH) 

873 images = [str(file) for file in folder.rglob("*.png") if file.is_file()] 

874 

875 filtered_images = [] 

876 for image in images: 

877 if res_obj.optic_required in image: 

878 filtered_images.append(image) 

879 

880 final_images = filter_by_date( 

881 images=filtered_images, start=res_obj.start, end=res_obj.end 

882 ) 

883 

884 message = f"{len(filtered_images)} have right lens of which {len(final_images)} are in time window." 

885 await warning(message) 

886 

887 if len(final_images) == 0: 

888 await warning("Aborting since 0 images") 

889 return redirect(url_for("index")) 

890 

891 # run this in background 

892 asyncio.create_task(async_stitching(res_obj=res_obj, final_images=final_images)) 

893 

894 return redirect(url_for("index")) 

895 

896 

897@app.route("/del_obj/<int:obj_id>", methods=["POST"]) 

898async def del_obj(obj_id: int) -> Response: 

899 """Deleting objectives.""" 

900 ciarc_api.delete_objective(id=obj_id) 

901 await update_telemetry() 

902 

903 return redirect(url_for("index")) 

904 

905 

906@app.route("/satellite_handler", methods=["POST"]) 

907async def satellite_handler() -> Response: 

908 """Wrapper for Melvin control.""" 

909 global console 

910 

911 # read which button was pressed 

912 form = await request.form 

913 button = form.get("button", type=str) 

914 

915 # keep track of next and prev state 

916 old_state = State.Unknown 

917 if console.live_telemetry: 

918 old_state = console.live_telemetry.state 

919 

920 match button: 

921 case "telemetry": 

922 pass 

923 case "image": 

924 await update_telemetry() 

925 if console.live_telemetry: 

926 t = ciarc_api.console_api_image(console.live_telemetry.angle) 

927 if t: 

928 await flash( 

929 f"Got image @{con.CONSOLE_LIVE_PATH}live_{console.live_telemetry.angle}_{t}.png" 

930 ) 

931 else: 

932 await flash("Could not get image, not in acquistion mode?") 

933 else: 

934 await flash("No Telemetry, cant take image!") 

935 case "acquisition": 

936 if ciarc_api.change_state(State.Acquisition): 

937 console.prev_state = old_state 

938 console.next_state = State.Acquisition 

939 else: 

940 await flash("Could not change State") 

941 case "charge": 

942 if ciarc_api.change_state(State.Charge): 

943 console.prev_state = old_state 

944 console.next_state = State.Charge 

945 else: 

946 await flash("Could not change State") 

947 case "communication": 

948 if ciarc_api.change_state(State.Communication): 

949 console.prev_state = old_state 

950 console.next_state = State.Communication 

951 else: 

952 await flash("Could not change State") 

953 case "narrow": 

954 if not ciarc_api.change_angle(CameraAngle.Narrow): 

955 await flash("Could not change Camera Angle") 

956 case "normal": 

957 if not ciarc_api.change_angle(CameraAngle.Normal): 

958 await flash("Could not change Camera Angle") 

959 case "wide": 

960 if not ciarc_api.change_angle(CameraAngle.Wide): 

961 await flash("Could not change Camera Angle") 

962 case "velocity": 

963 vel_x = form.get("vel_x", type=float) 

964 vel_y = form.get("vel_y", type=float) 

965 if vel_x and vel_y: 

966 if not ciarc_api.change_velocity(vel_x=vel_x, vel_y=vel_y): 

967 await flash("Could not change Velocity") 

968 else: 

969 logger.warning("Cant change velocity since vel_x/vel_y not set!") 

970 case _: 

971 logger.error(f"Unknown button pressed: {button}") 

972 await flash("Unknown button pressed.") 

973 

974 await update_telemetry() 

975 return redirect(url_for("index")) 

976 

977 

978@app.route("/control_handler", methods=["POST"]) 

979async def control_handler() -> Response: 

980 """Wrapper for CIARC API simulation manipulation.""" 

981 global console 

982 

983 # read which button was pressed 

984 form = await request.form 

985 button = form.get("button") 

986 

987 match button: 

988 case "reset": 

989 ciarc_api.reset() 

990 console = rift_console.rift_console.RiftConsole() 

991 case "load": 

992 ciarc_api.load_backup(console.last_backup_date) 

993 console.live_telemetry = None 

994 console.prev_state = State.Unknown 

995 console.next_state = State.Unknown 

996 case "save": 

997 console.last_backup_date = ciarc_api.save_backup() 

998 case "on_sim": 

999 if console.user_speed_multiplier: 

1000 ciarc_api.change_simulation_env( 

1001 is_network_simulation=True, 

1002 user_speed_multiplier=console.user_speed_multiplier, 

1003 ) 

1004 else: 

1005 ciarc_api.change_simulation_env(is_network_simulation=True) 

1006 logger.warning("Reset simulation speed to 1.") 

1007 console.is_network_simulation = True 

1008 case "off_sim": 

1009 if console.user_speed_multiplier: 

1010 ciarc_api.change_simulation_env( 

1011 is_network_simulation=False, 

1012 user_speed_multiplier=console.user_speed_multiplier, 

1013 ) 

1014 else: 

1015 ciarc_api.change_simulation_env(is_network_simulation=False) 

1016 logger.warning("Reset simulation speed to 1.") 

1017 console.is_network_simulation = False 

1018 case "sim_speed": 

1019 speed = form.get("sim_speed", type=int) 

1020 if speed: 

1021 if console.is_network_simulation is not None: 

1022 ciarc_api.change_simulation_env( 

1023 is_network_simulation=console.is_network_simulation, 

1024 user_speed_multiplier=speed, 

1025 ) 

1026 else: 

1027 ciarc_api.change_simulation_env(user_speed_multiplier=speed) 

1028 logger.warning("Disabled network simulation.") 

1029 console.user_speed_multiplier = speed 

1030 else: 

1031 logger.warning("Cant change sim_speed since speed not set!") 

1032 case _: 

1033 await warning(f"Unknown button pressed: {button}.") 

1034 

1035 await update_telemetry() 

1036 return redirect(url_for("index")) 

1037 

1038 

1039async def update_telemetry() -> None: 

1040 """Query CIARC API for new telemetry, very helpful while developing.""" 

1041 global console 

1042 

1043 res = ciarc_api.update_api() 

1044 if res: 

1045 ( 

1046 slots_used, 

1047 slots, 

1048 zoned_objectives, 

1049 beacon_objectives, 

1050 achievements, 

1051 ) = res 

1052 console.slots_used = slots_used 

1053 console.slots = slots 

1054 console.zoned_objectives = zoned_objectives 

1055 console.beacon_objectives = beacon_objectives 

1056 console.achievements = achievements 

1057 

1058 tel = ciarc_api.live_telemetry() 

1059 if tel: 

1060 console.live_telemetry = tel 

1061 console.user_speed_multiplier = tel.simulation_speed 

1062 (console.past_traj, console.future_traj) = console.predict_trajektorie() 

1063 

1064 if console.live_telemetry and console.live_telemetry.state != State.Transition: 

1065 console.next_state = State.Unknown 

1066 

1067 else: 

1068 await flash("Could not contact CIARC API.") 

1069 

1070 

1071# [HELPER] 

1072async def info(mes: str) -> None: 

1073 """Log to console and show message on webpage.""" 

1074 logger.info(mes) 

1075 await flash(mes) 

1076async def warning(mes: str) -> None: 

1077 """Log to console and show message on webpage.""" 

1078 logger.warning(mes) 

1079 await flash(mes) 

1080 

1081async def async_stitching(res_obj: ZonedObjective, final_images: list[str]) -> None: 

1082 """Tried to outsource stitching to another thread, so main thread already returns, but not completed.""" 

1083 final_images = [image.split("/")[-1] for image in final_images] 

1084 

1085 panorama = rift_console.image_processing.stitch_images( 

1086 image_path=con.CONSOLE_DOWNLOAD_PATH, image_name_list=final_images 

1087 ) 

1088 

1089 remove_offset = ( 

1090 con.STITCHING_BORDER, 

1091 con.STITCHING_BORDER, 

1092 con.WORLD_X + con.STITCHING_BORDER, 

1093 con.WORLD_Y + con.STITCHING_BORDER, 

1094 ) 

1095 panorama = panorama.crop(remove_offset) 

1096 

1097 space = "" 

1098 count = 0 

1099 path = ( 

1100 f"{con.CONSOLE_STICHED_PATH}zoned_{len(final_images)}_{res_obj.name}{space}.png" 

1101 ) 

1102 while os.path.isfile(path): 

1103 count += 1 

1104 space = "_" + str(count) 

1105 path = f"{con.CONSOLE_STICHED_PATH}zoned_{len(final_images)}_{res_obj.name}{space}.png" 

1106 

1107 panorama.save(path) 

1108 rift_console.image_processing.create_thumbnail(path) 

1109 

1110 if not res_obj.zone: 

1111 await warning(f"{res_obj} has no zone, can not stitch, aborting!") 

1112 return 

1113 

1114 rift_console.image_processing.cut( 

1115 panorama_path=path, 

1116 X1=res_obj.zone[0], 

1117 Y1=res_obj.zone[1], 

1118 X2=res_obj.zone[2], 

1119 Y2=res_obj.zone[3], 

1120 ) 

1121 

1122 await warning( 

1123 f"Saved stitch of {res_obj.name} - {len(final_images)} images to {path}" 

1124 ) 

1125 

1126 

1127 

1128async def async_world_map(filtered_images: list[str], choose_date: str) -> None: 

1129 """Tried to outsource stitching to another thread, so main thread already returns, but not completed.""" 

1130 panorama = rift_console.image_processing.stitch_images( 

1131 image_path=con.CONSOLE_DOWNLOAD_PATH, image_name_list=filtered_images 

1132 ) 

1133 

1134 remove_offset = ( 

1135 con.STITCHING_BORDER, 

1136 con.STITCHING_BORDER, 

1137 con.WORLD_X + con.STITCHING_BORDER, 

1138 con.WORLD_Y + con.STITCHING_BORDER, 

1139 ) 

1140 panorama = panorama.crop(remove_offset) 

1141 

1142 space = "" 

1143 count = 0 

1144 path = f"{con.CONSOLE_STICHED_PATH}worldmap_{len(filtered_images)}_{choose_date}{space}.png" 

1145 while os.path.isfile(path): 

1146 count += 1 

1147 space = "_" + str(count) 

1148 path = f"{con.CONSOLE_STICHED_PATH}worldmap_{choose_date}{space}.png" 

1149 

1150 panorama.save(path) 

1151 

1152 rift_console.image_processing.create_thumbnail(path) 

1153 

1154 await warning( 

1155 f"Saved {choose_date} panorama of {len(filtered_images)} images to {path}" 

1156 ) 

1157 

1158async def check_images() -> None: 

1159 """Sorts downloaded images on console and saves them by date.""" 

1160 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH) 

1161 console.console_image_count = sum(file.is_file() for file in folder.rglob("*.png")) 

1162 dates = set() 

1163 date_counts: dict[str, int] = defaultdict(int) 

1164 for image in folder.rglob("*.png"): 

1165 dates.add(get_date(image.name)[:10]) 

1166 date_counts[get_date(image.name)[:10]] += 1 

1167 dates_list = list(dates) 

1168 dates_list.sort(reverse=True) 

1169 console.console_image_dates = [(date, date_counts[date]) for date in dates_list] 

1170 

1171 await info( 

1172 f"Counted {console.console_image_count} images on console from {len(console.console_image_dates)} different dates." 

1173 ) 

1174 

1175def get_console_images() -> list[str]: 

1176 """Count donwloaded images.""" 

1177 # list all images 

1178 images = os.listdir(con.CONSOLE_DOWNLOAD_PATH) 

1179 # filter to only png 

1180 images = [s for s in images if s.endswith(".png")] 

1181 

1182 return images 

1183 

1184# [Helper for image viewer] 

1185# called inside html-template to match filename to location 

1186@app.route(f"/{con.CONSOLE_STICHED_PATH}/<path:filename>") 

1187async def uploaded_file_stitched(filename): # type: ignore 

1188 return await send_from_directory(con.CONSOLE_STICHED_PATH, filename) 

1189@app.route(f"/{con.CONSOLE_LIVE_PATH}/<path:filename>") 

1190async def uploaded_file_live(filename): # type: ignore 

1191 return await send_from_directory(con.CONSOLE_LIVE_PATH, filename) 

1192@app.route(f"/{con.CONSOLE_DOWNLOAD_PATH}/<path:filename>") 

1193async def uploaded_file_download(filename): # type: ignore 

1194 return await send_from_directory(con.CONSOLE_DOWNLOAD_PATH, filename) 

1195@app.route(f"/{con.CONSOLE_EBT_PATH}/<path:filename>") 

1196async def uploaded_file_ebt(filename): # type: ignore 

1197 return await send_from_directory(con.CONSOLE_EBT_PATH, filename) 

1198 

1199 

1200@click.group() 

1201@click.version_option() 

1202def main() -> None: 

1203 """Rift Console.""" 

1204 pass 

1205 

1206@main.command() 

1207def run_server() -> None: 

1208 """Run the Quart development server on port 3000.""" 

1209 click.echo("Starting Quart server on port 3000...") 

1210 

1211 config = Config() 

1212 config.bind = ["0.0.0.0:3000"] 

1213 

1214 asyncio.run(serve(app, config)) 

1215 

1216 # old run command 

1217 # app.run(port=3000, debug=False, host="0.0.0.0") 

1218 

1219@main.command() 

1220def run_server_4000() -> None: 

1221 """Original Rift Console CLI command, to run via poetry on a different port""" 

1222 click.echo("Starting Quart server on port 4000...") 

1223 

1224 config = Config() 

1225 config.bind = ["0.0.0.0:4000"] 

1226 

1227 asyncio.run(serve(app, config)) 

1228 

1229if __name__ == "__main__": 

1230 main(prog_name="Rift Console") # pragma: no cover