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
« prev ^ index » next coverage.py v7.8.0, created at 2025-04-08 09:36 +0000
1"""Command-line interface."""
3from collections import defaultdict
4import csv
5from pathlib import Path
6import pathlib
7import re
8import sys
9import datetime
10import os
12import click
13from loguru import logger
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
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
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)
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()
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")]
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]
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)
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]
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]
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 )
119@app.route("/downloads")
120async def downloads() -> str:
121 """Show donwloaded indiviual images from melvonaut."""
122 images = get_console_images()
124 # sort by timestamp
125 images.sort(
126 key=lambda x: get_date(x),
127 reverse=True,
128 )
130 # only take first CONSOLE_IMAGE_VIEWER_LIMIT
131 images = images[: con.CONSOLE_IMAGE_VIEWER_LIMIT]
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)
140 image_tupel = [(image, get_date(image)[:10], get_angle(image)) for image in images]
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 )
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")]
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]
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 )
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 )
285# [BUTTONS]
286@app.route("/melvonaut_api", methods=["POST"])
287async def melvonaut_api() -> Response:
288 """Buttons for Melvonaut API."""
289 global console
291 # read which button was pressed
292 form = await request.form
293 button = form.get("button", type=str)
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.")
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()
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.")
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!")
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.")
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}.")
449 return redirect(url_for("index"))
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)
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 ]
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()
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 )
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"))
515 if "thumb" in image_path:
516 await warning(
517 "DANGER you are uploading a Thumbnail image with lower resolution!!!"
518 )
520 res = ciarc_api.upload_worldmap(image_path=image_path)
522 if res:
523 await flash(res)
524 if res.startswith("Image uploaded successfully"):
525 await warning(f"Worldmap - {image_path}.")
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
533 if "thumb" in image_path:
534 await warning(
535 "DANGER you are uploading a Thumbnail image with lower resolution!!!"
536 )
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"))
544 res = ciarc_api.upload_objective(image_path=image_path, objective_id=id)
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}")
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)
583 (x, y) = ebt_calc.draw_res(id=id, res=res, pings=pings)
585 await flash(
586 f"For EBT_{id} found {len(res)} points that are matched by {len(pings)} pings. Centoid is: ({x},{y})"
587 )
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"))
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 )
625 await check_images()
627 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH)
628 images = [str(file) for file in folder.rglob("*.png") if file.is_file()]
630 filtered_images = []
631 for image in images:
632 if optic_required in image:
633 filtered_images.append(image)
635 final_images = filter_by_date(images=filtered_images, start=start, end=end)
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)
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"))
646 final_images = [image.split("/")[-1] for image in final_images]
648 panorama = rift_console.image_processing.stitch_images(
649 image_path=con.CONSOLE_DOWNLOAD_PATH, image_name_list=final_images
650 )
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)
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"
668 panorama.save(path)
670 rift_console.image_processing.create_thumbnail(path)
672 rift_console.image_processing.cut(
673 panorama_path=path, X1=zone[0], Y1=zone[1], X2=zone[2], Y2=zone[3]
674 )
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 )
680 case _:
681 await warning(f"Unknown button pressed: {button}.")
683 # await update_telemetry()
685 return redirect(url_for("index"))
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)
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)
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 )
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 )
766 await info(f"Wrote {len(console.zoned_objectives)} to {csv_file_path}.")
767 else:
768 await warning("Cant write objective, no data.")
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}.")
833 await update_telemetry()
835 return redirect(url_for("index"))
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)
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)
850 # await update_telemetry()
852 return redirect(url_for("index"))
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}.")
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()
872 folder = pathlib.Path(con.CONSOLE_DOWNLOAD_PATH)
873 images = [str(file) for file in folder.rglob("*.png") if file.is_file()]
875 filtered_images = []
876 for image in images:
877 if res_obj.optic_required in image:
878 filtered_images.append(image)
880 final_images = filter_by_date(
881 images=filtered_images, start=res_obj.start, end=res_obj.end
882 )
884 message = f"{len(filtered_images)} have right lens of which {len(final_images)} are in time window."
885 await warning(message)
887 if len(final_images) == 0:
888 await warning("Aborting since 0 images")
889 return redirect(url_for("index"))
891 # run this in background
892 asyncio.create_task(async_stitching(res_obj=res_obj, final_images=final_images))
894 return redirect(url_for("index"))
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()
903 return redirect(url_for("index"))
906@app.route("/satellite_handler", methods=["POST"])
907async def satellite_handler() -> Response:
908 """Wrapper for Melvin control."""
909 global console
911 # read which button was pressed
912 form = await request.form
913 button = form.get("button", type=str)
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
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.")
974 await update_telemetry()
975 return redirect(url_for("index"))
978@app.route("/control_handler", methods=["POST"])
979async def control_handler() -> Response:
980 """Wrapper for CIARC API simulation manipulation."""
981 global console
983 # read which button was pressed
984 form = await request.form
985 button = form.get("button")
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}.")
1035 await update_telemetry()
1036 return redirect(url_for("index"))
1039async def update_telemetry() -> None:
1040 """Query CIARC API for new telemetry, very helpful while developing."""
1041 global console
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
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()
1064 if console.live_telemetry and console.live_telemetry.state != State.Transition:
1065 console.next_state = State.Unknown
1067 else:
1068 await flash("Could not contact CIARC API.")
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)
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]
1085 panorama = rift_console.image_processing.stitch_images(
1086 image_path=con.CONSOLE_DOWNLOAD_PATH, image_name_list=final_images
1087 )
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)
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"
1107 panorama.save(path)
1108 rift_console.image_processing.create_thumbnail(path)
1110 if not res_obj.zone:
1111 await warning(f"{res_obj} has no zone, can not stitch, aborting!")
1112 return
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 )
1122 await warning(
1123 f"Saved stitch of {res_obj.name} - {len(final_images)} images to {path}"
1124 )
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 )
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)
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"
1150 panorama.save(path)
1152 rift_console.image_processing.create_thumbnail(path)
1154 await warning(
1155 f"Saved {choose_date} panorama of {len(filtered_images)} images to {path}"
1156 )
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]
1171 await info(
1172 f"Counted {console.console_image_count} images on console from {len(console.console_image_dates)} different dates."
1173 )
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")]
1182 return images
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)
1200@click.group()
1201@click.version_option()
1202def main() -> None:
1203 """Rift Console."""
1204 pass
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...")
1211 config = Config()
1212 config.bind = ["0.0.0.0:3000"]
1214 asyncio.run(serve(app, config))
1216 # old run command
1217 # app.run(port=3000, debug=False, host="0.0.0.0")
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...")
1224 config = Config()
1225 config.bind = ["0.0.0.0:4000"]
1227 asyncio.run(serve(app, config))
1229if __name__ == "__main__":
1230 main(prog_name="Rift Console") # pragma: no cover