diff --git a/release/files_common b/release/files_common index cc752ad..b3d0b76 100644 --- a/release/files_common +++ b/release/files_common @@ -569,3 +569,4 @@ selfdrive/frogpilot/frogpilot_process.py selfdrive/frogpilot/controls/frogpilot_planner.py selfdrive/frogpilot/controls/lib/frogpilot_functions.py selfdrive/frogpilot/controls/lib/frogpilot_variables.py +selfdrive/frogpilot/fleet_manager/fleet_manager.py diff --git a/selfdrive/frogpilot/fleetmanager/README.md b/selfdrive/frogpilot/fleetmanager/README.md new file mode 100644 index 0000000..ac554e0 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/README.md @@ -0,0 +1,5 @@ +# Fleet Manager + +Fleet Manger on openpilot allows viewing dashcam footage, screen recordings, error logs and on-device navigation by connecting to the comma device via the same network, with your mobile device or PC. Big thanks to [actuallylemoncurd](https://github.com/actuallylemoncurd), [AlexandreSato](https://github.com/alexandreSato), [ntegan1](https://github.com/ntegan1), [royjr](https://github.com/royjr), [sunnyhaibin] (https://github.com/sunnypilot), [dragonpilot](https://github.com/dragonpilot-community) and [chatgpt](https://chat.openai.com/). + +The network can be set up by Wi-Fi, mobile hotspot, or tethering on the comma device. Navigate to http://ipAddress:8082 to access. diff --git a/selfdrive/frogpilot/fleetmanager/fleet_manager.py b/selfdrive/frogpilot/fleetmanager/fleet_manager.py new file mode 100644 index 0000000..62a7426 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/fleet_manager.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +# otisserv - Copyright (c) 2019-, Rick Lan, dragonpilot community, and a number of other of contributors. +# Fleet Manager - [actuallylemoncurd](https://github.com/actuallylemoncurd), [AlexandreSato](https://github.com/alexandreSato), [ntegan1](https://github.com/ntegan1), [royjr](https://github.com/royjr), and [sunnyhaibin] (https://github.com/sunnypilot) +# Almost everything else - ChatGPT +# dirty PR pusher - mike8643 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import os +import random +import secrets +import threading +import time + +from flask import Flask, jsonify, render_template, Response, request, send_from_directory, session, redirect, url_for +import requests +from requests.exceptions import ConnectionError +from openpilot.common.realtime import set_core_affinity +import openpilot.selfdrive.frogpilot.fleetmanager.helpers as fleet +from openpilot.system.hardware.hw import Paths +from openpilot.common.swaglog import cloudlog +import traceback + +app = Flask(__name__) + +@app.route("/") +def home_page(): + return render_template("index.html") + +@app.errorhandler(500) +def internal_error(exception): + print('500 error caught') + tberror = traceback.format_exc() + return render_template("error.html", error=tberror) + +@app.route("/footage/full//") +def full(cameratype, route): + chunk_size = 1024 * 512 # 5KiB + file_name = cameratype + (".ts" if cameratype == "qcamera" else ".hevc") + vidlist = "|".join(Paths.log_root() + "/" + segment + "/" + file_name for segment in fleet.segments_in_route(route)) + + def generate_buffered_stream(): + with fleet.ffmpeg_mp4_concat_wrap_process_builder(vidlist, cameratype, chunk_size) as process: + for chunk in iter(lambda: process.stdout.read(chunk_size), b""): + yield bytes(chunk) + return Response(generate_buffered_stream(), status=200, mimetype='video/mp4') + + +@app.route("/footage//") +def fcamera(cameratype, segment): + if not fleet.is_valid_segment(segment): + return render_template("error.html", error="invalid segment") + file_name = Paths.log_root() + "/" + segment + "/" + cameratype + (".ts" if cameratype == "qcamera" else ".hevc") + return Response(fleet.ffmpeg_mp4_wrap_process_builder(file_name).stdout.read(), status=200, mimetype='video/mp4') + + +@app.route("/footage/") +def route(route): + if len(route) != 20: + return render_template("error.html", error="route not found") + + if str(request.query_string) == "b''": + query_segment = str("0") + query_type = "qcamera" + else: + query_segment = (str(request.query_string).split(","))[0][2:] + query_type = (str(request.query_string).split(","))[1][:-1] + + links = "" + segments = "" + for segment in fleet.segments_in_route(route): + links += ""+segment+"
" + segments += "'"+segment+"'," + return render_template("route.html", route=route, query_type=query_type, links=links, segments=segments, query_segment=query_segment) + + +@app.route("/footage/") +@app.route("/footage") +def footage(): + route_paths = fleet.all_routes() + gifs = [] + for route_path in route_paths: + input_path = Paths.log_root() + route_path + "--0/qcamera.ts" + output_path = Paths.log_root() + route_path + "--0/preview.gif" + fleet.video_to_img(input_path, output_path) + gif_path = route_path + "--0/preview.gif" + gifs.append(gif_path) + zipped = zip(route_paths, gifs) + return render_template("footage.html", zipped=zipped) + +@app.route("/preserved/") +@app.route("/preserved") +def preserved(): + query_type = "qcamera" + route_paths = [] + gifs = [] + segments = fleet.preserved_routes() + for segment in segments: + input_path = Paths.log_root() + segment + "/qcamera.ts" + output_path = Paths.log_root() + segment + "/preview.gif" + fleet.video_to_img(input_path, output_path) + split_segment = segment.split("--") + route_paths.append(f"{split_segment[0]}--{split_segment[1]}?{split_segment[2]},{query_type}") + gif_path = segment + "/preview.gif" + gifs.append(gif_path) + + zipped = zip(route_paths, gifs, segments) + return render_template("preserved.html", zipped=zipped) + +@app.route("/screenrecords/") +@app.route("/screenrecords") +def screenrecords(): + rows = fleet.list_file(fleet.SCREENRECORD_PATH) + if not rows: + return render_template("error.html", error="no screenrecords found at:

" + fleet.SCREENRECORD_PATH) + return render_template("screenrecords.html", rows=rows, clip=rows[0]) + + +@app.route("/screenrecords/") +def screenrecord(clip): + return render_template("screenrecords.html", rows=fleet.list_files(fleet.SCREENRECORD_PATH), clip=clip) + + +@app.route("/screenrecords/play/pipe/") +def videoscreenrecord(file): + file_name = fleet.SCREENRECORD_PATH + file + return Response(fleet.ffplay_mp4_wrap_process_builder(file_name).stdout.read(), status=200, mimetype='video/mp4') + + +@app.route("/screenrecords/download/") +def download_file(clip): + return send_from_directory(fleet.SCREENRECORD_PATH, clip, as_attachment=True) + + +@app.route("/about") +def about(): + return render_template("about.html") + + +@app.route("/error_logs") +def error_logs(): + rows = fleet.list_file(fleet.ERROR_LOGS_PATH) + if not rows: + return render_template("error.html", error="no error logs found at:

" + fleet.ERROR_LOGS_PATH) + return render_template("error_logs.html", rows=rows) + + +@app.route("/error_logs/") +def open_error_log(file_name): + f = open(fleet.ERROR_LOGS_PATH + file_name) + error = f.read() + return render_template("error_log.html", file_name=file_name, file_content=error) + +@app.route("/addr_input", methods=['GET', 'POST']) +def addr_input(): + preload = fleet.preload_favs() + SearchInput = fleet.get_SearchInput() + token = fleet.get_public_token() + s_token = fleet.get_app_token() + gmap_key = fleet.get_gmap_key() + PrimeType = fleet.get_PrimeType() + lon = float(0.0) + lat = float(0.0) + if request.method == 'POST': + valid_addr = False + postvars = request.form.to_dict() + addr, lon, lat, valid_addr, token = fleet.parse_addr(postvars, lon, lat, valid_addr, token) + if not valid_addr: + # If address is not found, try searching + postvars = request.form.to_dict() + addr = request.form.get('addr_val') + addr, lon, lat, valid_addr, token = fleet.search_addr(postvars, lon, lat, valid_addr, token) + if valid_addr: + # If a valid address is found, redirect to nav_confirmation + return redirect(url_for('nav_confirmation', addr=addr, lon=lon, lat=lat)) + else: + return render_template("error.html") + elif PrimeType != 0: + return render_template("prime.html") + # amap stuff + elif SearchInput == 1: + amap_key, amap_key_2 = fleet.get_amap_key() + if amap_key == "" or amap_key is None or amap_key_2 == "" or amap_key_2 is None: + return redirect(url_for('amap_key_input')) + elif token == "" or token is None: + return redirect(url_for('public_token_input')) + elif s_token == "" or s_token is None: + return redirect(url_for('app_token_input')) + else: + return redirect(url_for('amap_addr_input')) + elif fleet.get_nav_active(): + if SearchInput == 2: + return render_template("nonprime.html", gmap_key=gmap_key, lon=lon, lat=lat, home=preload[0], work=preload[1], fav1=preload[2], fav2=preload[3], fav3=preload[4]) + else: + return render_template("nonprime.html", gmap_key=None, lon=None, lat=None, home=preload[0], work=preload[1], fav1=preload[2], fav2=preload[3], fav3=preload[4]) + elif token == "" or token is None: + return redirect(url_for('public_token_input')) + elif s_token == "" or s_token is None: + return redirect(url_for('app_token_input')) + elif SearchInput == 2: + lon, lat = fleet.get_last_lon_lat() + if gmap_key == "" or gmap_key is None: + return redirect(url_for('gmap_key_input')) + else: + return render_template("addr.html", gmap_key=gmap_key, lon=lon, lat=lat, home=preload[0], work=preload[1], fav1=preload[2], fav2=preload[3], fav3=preload[4]) + else: + return render_template("addr.html", gmap_key=None, lon=None, lat=None, home=preload[0], work=preload[1], fav1=preload[2], fav2=preload[3], fav3=preload[4]) + +@app.route("/nav_confirmation", methods=['GET', 'POST']) +def nav_confirmation(): + token = fleet.get_public_token() + lon = request.args.get('lon') + lat = request.args.get('lat') + addr = request.args.get('addr') + if request.method == 'POST': + postvars = request.form.to_dict() + fleet.nav_confirmed(postvars) + return redirect(url_for('addr_input')) + else: + return render_template("nav_confirmation.html", addr=addr, lon=lon, lat=lat, token=token) + +@app.route("/public_token_input", methods=['GET', 'POST']) +def public_token_input(): + if request.method == 'POST': + postvars = request.form.to_dict() + fleet.public_token_input(postvars) + return redirect(url_for('addr_input')) + else: + return render_template("public_token_input.html") + +@app.route("/app_token_input", methods=['GET', 'POST']) +def app_token_input(): + if request.method == 'POST': + postvars = request.form.to_dict() + fleet.app_token_input(postvars) + return redirect(url_for('addr_input')) + else: + return render_template("app_token_input.html") + +@app.route("/gmap_key_input", methods=['GET', 'POST']) +def gmap_key_input(): + if request.method == 'POST': + postvars = request.form.to_dict() + fleet.gmap_key_input(postvars) + return redirect(url_for('addr_input')) + else: + return render_template("gmap_key_input.html") + +@app.route("/amap_key_input", methods=['GET', 'POST']) +def amap_key_input(): + if request.method == 'POST': + postvars = request.form.to_dict() + fleet.amap_key_input(postvars) + return redirect(url_for('amap_addr_input')) + else: + return render_template("amap_key_input.html") + +@app.route("/amap_addr_input", methods=['GET', 'POST']) +def amap_addr_input(): + if request.method == 'POST': + postvars = request.form.to_dict() + fleet.nav_confirmed(postvars) + return redirect(url_for('amap_addr_input')) + else: + lon, lat = fleet.get_last_lon_lat() + amap_key, amap_key_2 = fleet.get_amap_key() + return render_template("amap_addr_input.html", lon=lon, lat=lat, amap_key=amap_key, amap_key_2=amap_key_2) + +@app.route("/CurrentStep.json", methods=['GET']) +def find_CurrentStep(): + directory = "/data/openpilot/selfdrive/manager/" + filename = "CurrentStep.json" + return send_from_directory(directory, filename, as_attachment=True) + +@app.route("/navdirections.json", methods=['GET']) +def find_nav_directions(): + directory = "/data/openpilot/selfdrive/manager/" + filename = "navdirections.json" + return send_from_directory(directory, filename, as_attachment=True) + +@app.route("/locations", methods=['GET']) +def get_locations(): + data = fleet.get_locations() + return Response(data, content_type="application/json") + +@app.route("/set_destination", methods=['POST']) +def set_destination(): + valid_addr = False + postvars = request.get_json() + data, valid_addr = fleet.set_destination(postvars, valid_addr) + if valid_addr: + return Response('{"success": true}', content_type='application/json') + else: + return Response('{"success": false}', content_type='application/json') + +@app.route("/navigation/", methods=['GET']) +def find_navicon(file_name): + directory = "/data/openpilot/selfdrive/assets/navigation/" + return send_from_directory(directory, file_name, as_attachment=True) + +@app.route("/previewgif/", methods=['GET']) +def find_previewgif(file_path): + directory = "/data/media/0/realdata/" + return send_from_directory(directory, file_path, as_attachment=True) + +@app.route("/tools", methods=['GET']) +def tools_route(): + return render_template("tools.html") + +@app.route("/get_toggle_values", methods=['GET']) +def get_toggle_values_route(): + toggle_values = fleet.get_all_toggle_values() + return jsonify(toggle_values) + +@app.route("/store_toggle_values", methods=['POST']) +def store_toggle_values_route(): + try: + updated_values = request.get_json() + fleet.store_toggle_values(updated_values) + return jsonify({"message": "Values updated successfully"}), 200 + except Exception as e: + return jsonify({"error": "Failed to update values", "details": str(e)}), 400 + +def main(): + try: + set_core_affinity([0, 1, 2, 3]) + except Exception: + cloudlog.exception("fleet_manager: failed to set core affinity") + app.secret_key = secrets.token_hex(32) + app.run(host="0.0.0.0", port=8082) + + +if __name__ == '__main__': + main() diff --git a/selfdrive/frogpilot/fleetmanager/helpers.py b/selfdrive/frogpilot/fleetmanager/helpers.py new file mode 100644 index 0000000..cfe6798 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/helpers.py @@ -0,0 +1,465 @@ +# otisserv - Copyright (c) 2019-, Rick Lan, dragonpilot community, and a number of other of contributors. +# Fleet Manager - [actuallylemoncurd](https://github.com/actuallylemoncurd), [AlexandreSato](https://github.com/alexandreSato), [ntegan1](https://github.com/ntegan1), [royjr](https://github.com/royjr), and [sunnyhaibin] (https://github.com/sunnypilot) +# Almost everything else - ChatGPT +# dirty PR pusher - mike8643 +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +import json +import math +import os +import requests +import subprocess +import time +# otisserv conversion +from common.params import Params, ParamKeyType +from flask import render_template, request, session +from functools import wraps +from pathlib import Path + +from openpilot.system.hardware import PC +from openpilot.system.hardware.hw import Paths +from openpilot.system.loggerd.uploader import listdir_by_creation +from tools.lib.route import SegmentName +from typing import List +from openpilot.system.loggerd.xattr_cache import getxattr + +# otisserv conversion +from urllib.parse import parse_qs, quote + +pi = 3.1415926535897932384626 +x_pi = 3.14159265358979324 * 3000.0 / 180.0 +a = 6378245.0 +ee = 0.00669342162296594323 + +params = Params() +params_memory = Params("/dev/shm/params") +params_storage = Params("/persist/params") + +PRESERVE_ATTR_NAME = 'user.preserve' +PRESERVE_ATTR_VALUE = b'1' +PRESERVE_COUNT = 5 + + +# path to openpilot screen recordings and error logs +if PC: + SCREENRECORD_PATH = os.path.join(str(Path.home()), ".comma", "media", "0", "videos", "") + ERROR_LOGS_PATH = os.path.join(str(Path.home()), ".comma", "community", "crashes", "") +else: + SCREENRECORD_PATH = "/data/media/0/videos/" + ERROR_LOGS_PATH = "/data/community/crashes/" + + +def list_files(path): # still used for footage + return sorted(listdir_by_creation(path), reverse=True) + + +def list_file(path): # new function for screenrecords/error-logs + if os.path.exists(path): + files = os.listdir(path) + sorted_files = sorted(files, reverse=True) + else: + return [] # Return an empty list if there are no files or directory + return sorted_files + + +def is_valid_segment(segment): + try: + segment_to_segment_name(Paths.log_root(), segment) + return True + except AssertionError: + return False + + +def segment_to_segment_name(data_dir, segment): + fake_dongle = "ffffffffffffffff" + return SegmentName(str(os.path.join(data_dir, fake_dongle + "|" + segment))) + + +def all_segment_names(): + segments = [] + for segment in listdir_by_creation(Paths.log_root()): + try: + segments.append(segment_to_segment_name(Paths.log_root(), segment)) + except AssertionError: + pass + return segments + + +def all_routes(): + segment_names = all_segment_names() + route_names = [segment_name.route_name for segment_name in segment_names] + route_times = [route_name.time_str for route_name in route_names] + unique_routes = list(dict.fromkeys(route_times)) + return sorted(unique_routes, reverse=True) + +def preserved_routes(): + dirs = listdir_by_creation(Paths.log_root()) + preserved_segments = get_preserved_segments(dirs) + return sorted(preserved_segments, reverse=True) + +def has_preserve_xattr(d: str) -> bool: + return getxattr(os.path.join(Paths.log_root(), d), PRESERVE_ATTR_NAME) == PRESERVE_ATTR_VALUE + +def get_preserved_segments(dirs_by_creation: List[str]) -> List[str]: + preserved = [] + for n, d in enumerate(filter(has_preserve_xattr, reversed(dirs_by_creation))): + if n == PRESERVE_COUNT: + break + date_str, _, seg_str = d.rpartition("--") + + # ignore non-segment directories + if not date_str: + continue + try: + seg_num = int(seg_str) + except ValueError: + continue + # preserve segment and its prior + preserved.append(d) + + return preserved + +def video_to_gif(input_path, output_path, fps=1, duration=6): # not used right now but can if want longer animated gif + if os.path.exists(output_path): + return + command = [ + 'ffmpeg', '-y', '-i', input_path, + '-filter_complex', + f'fps={fps},scale=240:-1:flags=lanczos,setpts=0.1*PTS,split[s0][s1];[s0]palettegen=max_colors=32[p];[s1][p]paletteuse=dither=bayer', + '-t', str(duration), output_path + ] + subprocess.run(command) + print(f"GIF file created: {output_path}") + +def video_to_img(input_path, output_path, fps=1, duration=6): + if os.path.exists(output_path): + return + subprocess.run(['ffmpeg', '-y', '-i', input_path, '-ss', '5', '-vframes', '1', output_path]) + print(f"GIF file created: {output_path}") + +def segments_in_route(route): + segment_names = [segment_name for segment_name in all_segment_names() if segment_name.time_str == route] + segments = [segment_name.time_str + "--" + str(segment_name.segment_num) for segment_name in segment_names] + return segments + + +def ffmpeg_mp4_concat_wrap_process_builder(file_list, cameratype, chunk_size=1024*512): + command_line = ["ffmpeg"] + if not cameratype == "qcamera": + command_line += ["-f", "hevc"] + command_line += ["-r", "20"] + command_line += ["-i", "concat:" + file_list] + command_line += ["-c", "copy"] + command_line += ["-map", "0"] + if not cameratype == "qcamera": + command_line += ["-vtag", "hvc1"] + command_line += ["-f", "mp4"] + command_line += ["-movflags", "empty_moov"] + command_line += ["-"] + return subprocess.Popen( + command_line, stdout=subprocess.PIPE, + bufsize=chunk_size + ) + + +def ffmpeg_mp4_wrap_process_builder(filename): + """Returns a process that will wrap the given filename + inside a mp4 container, for easier playback by browsers + and other devices. Primary use case is streaming segment videos + to the vidserver tool. + filename is expected to be a pathname to one of the following + /path/to/a/qcamera.ts + /path/to/a/dcamera.hevc + /path/to/a/ecamera.hevc + /path/to/a/fcamera.hevc + """ + basename = filename.rsplit("/")[-1] + extension = basename.rsplit(".")[-1] + command_line = ["ffmpeg"] + if extension == "hevc": + command_line += ["-f", "hevc"] + command_line += ["-r", "20"] + command_line += ["-i", filename] + command_line += ["-c", "copy"] + command_line += ["-map", "0"] + if extension == "hevc": + command_line += ["-vtag", "hvc1"] + command_line += ["-f", "mp4"] + command_line += ["-movflags", "empty_moov"] + command_line += ["-"] + return subprocess.Popen( + command_line, stdout=subprocess.PIPE + ) + + +def ffplay_mp4_wrap_process_builder(file_name): + command_line = ["ffmpeg"] + command_line += ["-i", file_name] + command_line += ["-c", "copy"] + command_line += ["-map", "0"] + command_line += ["-f", "mp4"] + command_line += ["-movflags", "empty_moov"] + command_line += ["-"] + return subprocess.Popen( + command_line, stdout=subprocess.PIPE + ) + +def get_nav_active(): + if params.get("NavDestination", encoding='utf8') is not None: + return True + else: + return False + +def get_public_token(): + token = params.get("MapboxPublicKey", encoding='utf8') + return token.strip() if token is not None else None + +def get_app_token(): + token = params.get("MapboxSecretKey", encoding='utf8') + return token.strip() if token is not None else None + +def get_gmap_key(): + token = params.get("GMapKey", encoding='utf8') + return token.strip() if token is not None else None + +def get_amap_key(): + token = params.get("AMapKey1", encoding='utf8') + token2 = params.get("AMapKey2", encoding='utf8') + return (token.strip() if token is not None else None, token2.strip() if token2 is not None else None) + +def get_SearchInput(): + SearchInput = params.get_int("SearchInput") + return SearchInput + +def get_PrimeType(): + PrimeType = params.get_int("PrimeType") + return PrimeType + +def get_last_lon_lat(): + last_pos = params.get("LastGPSPosition") + if last_pos: + l = json.loads(last_pos) + else: + return 0.0, 0.0 + return l["longitude"], l["latitude"] + +def get_locations(): + data = params.get("ApiCache_NavDestinations", encoding='utf-8') + return data + +def preload_favs(): + try: + nav_destinations = json.loads(params.get("ApiCache_NavDestinations", encoding='utf8')) + except TypeError: + return (None, None, None, None, None) + + locations = {"home": None, "work": None, "fav1": None, "fav2": None, "fav3": None} + + for item in nav_destinations: + label = item.get("label") + if label in locations and locations[label] is None: + locations[label] = item.get("place_name") + + return tuple(locations.values()) + +def parse_addr(postvars, lon, lat, valid_addr, token): + addr = postvars.get("fav_val", [""]) + real_addr = None + if addr != "favorites": + try: + dests = json.loads(params.get("ApiCache_NavDestinations", encoding='utf8')) + except TypeError: + dests = json.loads("[]") + for item in dests: + if "label" in item and item["label"] == addr: + lat, lon, real_addr = item["latitude"], item["longitude"], item["place_name"] + break + return (real_addr, lon, lat, real_addr is not None, token) + +def search_addr(postvars, lon, lat, valid_addr, token): + if "addr_val" in postvars: + addr = postvars.get("addr_val") + if addr != "": + # Properly encode the address to handle spaces + addr_encoded = quote(addr) + query = f"https://api.mapbox.com/geocoding/v5/mapbox.places/{addr_encoded}.json?access_token={token}&limit=1" + # focus on place around last gps position + lngi, lati = get_last_lon_lat() + query += "&proximity=%s,%s" % (lngi, lati) + r = requests.get(query) + if r.status_code != 200: + return (addr, lon, lat, valid_addr, token) + j = json.loads(r.text) + if not j["features"]: + return (addr, lon, lat, valid_addr, token) + lon, lat = j["features"][0]["geometry"]["coordinates"] + valid_addr = True + return (addr, lon, lat, valid_addr, token) + +def set_destination(postvars, valid_addr): + if postvars.get("latitude") is not None and postvars.get("longitude") is not None: + postvars["lat"] = postvars.get("latitude") + postvars["lon"] = postvars.get("longitude") + postvars["save_type"] = "recent" + nav_confirmed(postvars) + valid_addr = True + else: + addr = postvars.get("place_name") + token = get_public_token() + data, lon, lat, valid_addr, token = search_addr(addr, lon, lat, valid_addr, token) + postvars["lat"] = lat + postvars["lon"] = lon + postvars["save_type"] = "recent" + nav_confirmed(postvars) + valid_addr= True + return postvars, valid_addr + +def nav_confirmed(postvars): + if postvars is not None: + lat = float(postvars.get("lat")) + lng = float(postvars.get("lon")) + save_type = postvars.get("save_type") + name = postvars.get("name") if postvars.get("name") is not None else "" + if params.get_int("SearchInput") == 1: + lng, lat = gcj02towgs84(lng, lat) + params.put("NavDestination", "{\"latitude\": %f, \"longitude\": %f, \"place_name\": \"%s\"}" % (lat, lng, name)) + if name == "": + name = str(lat) + "," + str(lng) + new_dest = {"latitude": float(lat), "longitude": float(lng), "place_name": name} + if save_type == "recent": + new_dest["save_type"] = "recent" + else: + new_dest["save_type"] = "favorite" + new_dest["label"] = save_type + val = params.get("ApiCache_NavDestinations", encoding='utf8') + if val is not None: + val = val.rstrip('\x00') + dests = [] if val is None else json.loads(val) + # type idx + type_label_ids = {"home": None, "work": None, "fav1": None, "fav2": None, "fav3": None, "recent": []} + idx = 0 + for d in dests: + if d["save_type"] == "favorite": + type_label_ids[d["label"]] = idx + else: + type_label_ids["recent"].append(idx) + idx += 1 + if save_type == "recent": + id = None + if len(type_label_ids["recent"]) > 10: + dests.pop(type_label_ids["recent"][-1]) + else: + id = type_label_ids[save_type] + if id is None: + dests.insert(0, new_dest) + else: + dests[id] = new_dest + params.put("ApiCache_NavDestinations", json.dumps(dests).rstrip("\n\r")) + +def public_token_input(postvars): + if postvars is None or "pk_token_val" not in postvars or postvars.get("pk_token_val")[0] == "": + return postvars + else: + token = postvars.get("pk_token_val").strip() + if "pk." not in token: + return postvars + else: + params.put("MapboxPublicKey", token) + return token + +def app_token_input(postvars): + if postvars is None or "sk_token_val" not in postvars or postvars.get("sk_token_val")[0] == "": + return postvars + else: + token = postvars.get("sk_token_val").strip() + if "sk." not in token: + return postvars + else: + params.put("MapboxSecretKey", token) + return token + +def gmap_key_input(postvars): + if postvars is None or "gmap_key_val" not in postvars or postvars.get("gmap_key_val")[0] == "": + return postvars + else: + token = postvars.get("gmap_key_val").strip() + params.put("GMapKey", token) + return token + +def amap_key_input(postvars): + if postvars is None or "amap_key_val" not in postvars or postvars.get("amap_key_val")[0] == "": + return postvars + else: + token = postvars.get("amap_key_val").strip() + token2 = postvars.get("amap_key_val_2").strip() + params.put("AMapKey1", token) + params.put("AMapKey2", token2) + return token + +def gcj02towgs84(lng, lat): + dlat = transform_lat(lng - 105.0, lat - 35.0) + dlng = transform_lng(lng - 105.0, lat - 35.0) + radlat = lat / 180.0 * pi + magic = math.sin(radlat) + magic = 1 - ee * magic * magic + sqrtmagic = math.sqrt(magic) + dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi) + dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * pi) + mglat = lat + dlat + mglng = lng + dlng + return [lng * 2 - mglng, lat * 2 - mglat] + +def transform_lat(lng, lat): + ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + 0.1 * lng * lat + 0.2 * math.sqrt(abs(lng)) + ret += (20.0 * math.sin(6.0 * lng * pi) + 20.0 * math.sin(2.0 * lng * pi)) * 2.0 / 3.0 + ret += (20.0 * math.sin(lat * pi) + 40.0 * math.sin(lat / 3.0 * pi)) * 2.0 / 3.0 + ret += (160.0 * math.sin(lat / 12.0 * pi) + 320 * math.sin(lat * pi / 30.0)) * 2.0 / 3.0 + return ret + +def transform_lng(lng, lat): + ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + 0.1 * lng * lat + 0.1 * math.sqrt(abs(lng)) + ret += (20.0 * math.sin(6.0 * lng * pi) + 20.0 * math.sin(2.0 * lng * pi)) * 2.0 / 3.0 + ret += (20.0 * math.sin(lng * pi) + 40.0 * math.sin(lng / 3.0 * pi)) * 2.0 / 3.0 + ret += (150.0 * math.sin(lng / 12.0 * pi) + 300.0 * math.sin(lng / 30.0 * pi)) * 2.0 / 3.0 + return ret + +def get_all_toggle_values(): + toggle_values = {} + for key in params.all_keys(): + key = key.decode('utf-8') if isinstance(key, bytes) else key + if params.get_key_type(key) & ParamKeyType.FROGPILOT_STORAGE: + try: + value = params.get(key) + value = value.decode('utf-8') if isinstance(value, bytes) else value + except Exception: + value = "0" + toggle_values[key] = value if value is not None else "0" + return toggle_values + +def store_toggle_values(updated_values): + for key, value in updated_values.items(): + try: + params.put(key, value.encode('utf-8')) + params_storage.put(key, value.encode('utf-8')) + except Exception as e: + print(f"Failed to update {key}: {e}") + + params_memory.put_bool("FrogPilotTogglesUpdated", True) + time.sleep(1) + params_memory.put_bool("FrogPilotTogglesUpdated", False) diff --git a/selfdrive/frogpilot/fleetmanager/static/favicon.ico b/selfdrive/frogpilot/fleetmanager/static/favicon.ico new file mode 100644 index 0000000..2f24c58 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/static/favicon.ico @@ -0,0 +1 @@ +../../../selfdrive/assets/img_spinner_comma.png \ No newline at end of file diff --git a/selfdrive/frogpilot/fleetmanager/static/frog.png b/selfdrive/frogpilot/fleetmanager/static/frog.png new file mode 100644 index 0000000..5285f0b Binary files /dev/null and b/selfdrive/frogpilot/fleetmanager/static/frog.png differ diff --git a/selfdrive/frogpilot/fleetmanager/templates/about.html b/selfdrive/frogpilot/fleetmanager/templates/about.html new file mode 100644 index 0000000..06d56d0 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/about.html @@ -0,0 +1,21 @@ +{% extends "layout.html" %} + +{% block title %} + About +{% endblock %} + +{% block main %} +
+

About

+
+
+ Special thanks to:

+ ntegan1
+ royjr
+ AlexandreSato
+ actuallylemoncurd
+ sunnyhaibin
+ dragonpilot
+ chatgpt
+
+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/addr.html b/selfdrive/frogpilot/fleetmanager/templates/addr.html new file mode 100644 index 0000000..805d5e7 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/addr.html @@ -0,0 +1,11 @@ +{% extends "layout.html" %} + +{% block title %} + Navigation +{% endblock %} + +{% block main %} +{% with gmap_key=gmap_key, lon=lon, lat=lat, home=home, work=work, fav1=fav1, fav2=fav2, fav3=fav3 %} + {% include "addr_input.html" %} +{% endwith %} +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/addr_input.html b/selfdrive/frogpilot/fleetmanager/templates/addr_input.html new file mode 100644 index 0000000..28ded72 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/addr_input.html @@ -0,0 +1,64 @@ +{% block main %} + +
+
+
+ {% if home or work or fav1 or fav2 or fav3 %} + + {% endif %} + + +
+
+
+ + +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/amap_addr_input.html b/selfdrive/frogpilot/fleetmanager/templates/amap_addr_input.html new file mode 100644 index 0000000..1fe975b --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/amap_addr_input.html @@ -0,0 +1,215 @@ +{% extends "layout.html" %} + +{% block title %} + amap_addr_input +{% endblock %} +{% block main %} + + + + + 输入提示后查询 + + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ + +
+ +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/amap_key_input.html b/selfdrive/frogpilot/fleetmanager/templates/amap_key_input.html new file mode 100644 index 0000000..eb2a9e6 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/amap_key_input.html @@ -0,0 +1,20 @@ +{% extends "layout.html" %} + +{% block title %} + amap_key_input +{% endblock %} + +{% block main %} +
+
+ 请输入您的高德地图 API KEY +
因系统升级,若于 2021/12/02 前申请 key 的人请重新申请新的「key」和「安全密钥」配对。
+
+ + + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/selfdrive/frogpilot/fleetmanager/templates/app_token_input.html b/selfdrive/frogpilot/fleetmanager/templates/app_token_input.html new file mode 100644 index 0000000..b2dc5fa --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/app_token_input.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block title %} + MapBox key input +{% endblock %} + +{% block main %} +
+
+ Set your Mapbox Secret Token +
{{msg}}
+
+ + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/selfdrive/frogpilot/fleetmanager/templates/error.html b/selfdrive/frogpilot/fleetmanager/templates/error.html new file mode 100644 index 0000000..cf4e898 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/error.html @@ -0,0 +1,11 @@ +{% extends "layout.html" %} + +{% block title %} + Error +{% endblock %} + +{% block main %} +
Oops +



+ {{ error | safe }} +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/error_log.html b/selfdrive/frogpilot/fleetmanager/templates/error_log.html new file mode 100644 index 0000000..e8f527e --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/error_log.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block title %} + Error Log +{% endblock %} + +{% block main %} +
+

Error Log of
{{ file_name }}

+
+{% endblock %} + +{% block unformated %} +
{{ file_content }}
+

+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/error_logs.html b/selfdrive/frogpilot/fleetmanager/templates/error_logs.html new file mode 100644 index 0000000..103551f --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/error_logs.html @@ -0,0 +1,14 @@ +{% extends "layout.html" %} + +{% block title %} + Error Logs +{% endblock %} + +{% block main %} +
+

Error Logs

+
+ {% for row in rows %} + {{ row }}
+ {% endfor %} +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/footage.html b/selfdrive/frogpilot/fleetmanager/templates/footage.html new file mode 100644 index 0000000..e9fc7a9 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/footage.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %} + Dashcam Routes +{% endblock %} + +{% block main %} +
+

Dashcam Routes

+
+
+ {% for row, gif in zipped %} +
+
+ GIF +
+

{{ row }}

+
+ +
+
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/gmap_key_input.html b/selfdrive/frogpilot/fleetmanager/templates/gmap_key_input.html new file mode 100644 index 0000000..d407c20 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/gmap_key_input.html @@ -0,0 +1,18 @@ +{% extends "layout.html" %} + +{% block title %} + GMap key input +{% endblock %} + +{% block main %} +
+
+ Set your Google Map API Key +
+ + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/selfdrive/frogpilot/fleetmanager/templates/index.html b/selfdrive/frogpilot/fleetmanager/templates/index.html new file mode 100644 index 0000000..7d33bcd --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/index.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block title %} + Home +{% endblock %} + +{% block main %} +
+

Fleet Manager

+
+ View Dashcam Footage
+
Access Preserved Footage
+
View Screen Recordings
+
Access Error Logs
+
About Fleet Manager
+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/layout.html b/selfdrive/frogpilot/fleetmanager/templates/layout.html new file mode 100644 index 0000000..21d1e48 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/layout.html @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + FrogPilot: {% block title %}{% endblock %} + + + +
{% block main %}{% endblock %}
+ {% block unformated %}{% endblock %} + + + + + + + diff --git a/selfdrive/frogpilot/fleetmanager/templates/nav_confirmation.html b/selfdrive/frogpilot/fleetmanager/templates/nav_confirmation.html new file mode 100644 index 0000000..ee2aa4a --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/nav_confirmation.html @@ -0,0 +1,28 @@ +{% extends "layout.html" %} + +{% block title %} + Nav Search Confirmation +{% endblock %} + +{% block main %} +
+
{{addr}}
+
+
+
+ + + + + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/selfdrive/frogpilot/fleetmanager/templates/nav_directions.html b/selfdrive/frogpilot/fleetmanager/templates/nav_directions.html new file mode 100644 index 0000000..c59582e --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/nav_directions.html @@ -0,0 +1,149 @@ +{% block main %} +
+
+ + +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/nonprime.html b/selfdrive/frogpilot/fleetmanager/templates/nonprime.html new file mode 100644 index 0000000..6daa656 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/nonprime.html @@ -0,0 +1,13 @@ +{% extends "layout.html" %} + +{% block title %} + Nav Driving Directions +{% endblock %} + +{% block main %} +{% with gmap_key=gmap_key, lon=lon, lat=lat, home=home, work=work, fav1=fav1, fav2=fav2, fav3=fav3 %} + {% include "addr_input.html" %} +{% endwith %} + +{% include "nav_directions.html" %} +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/preserved.html b/selfdrive/frogpilot/fleetmanager/templates/preserved.html new file mode 100644 index 0000000..f8f69c6 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/preserved.html @@ -0,0 +1,30 @@ +{% extends "layout.html" %} + +{% block title %} + Preserved Routes +{% endblock %} + +{% block main %} +
+

Preserved Routes

+
+
+ {% for route_path, gif_path, segment in zipped %} +
+
+
+ GIF +
+
+

{{ segment }}

+
+ +
+
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/prime.html b/selfdrive/frogpilot/fleetmanager/templates/prime.html new file mode 100644 index 0000000..78bfb3d --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/prime.html @@ -0,0 +1,9 @@ +{% extends "layout.html" %} + +{% block title %} + Driving Directions +{% endblock %} + +{% block main %} +{% include "nav_directions.html" %} +{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/public_token_input.html b/selfdrive/frogpilot/fleetmanager/templates/public_token_input.html new file mode 100644 index 0000000..b64a880 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/public_token_input.html @@ -0,0 +1,19 @@ +{% extends "layout.html" %} + +{% block title %} + addr_input +{% endblock %} + +{% block main %} +
+
+ Set your Mapbox Public Token +
{{msg}}
+
+ + +
+
+
+ +{% endblock %} \ No newline at end of file diff --git a/selfdrive/frogpilot/fleetmanager/templates/route.html b/selfdrive/frogpilot/fleetmanager/templates/route.html new file mode 100644 index 0000000..adccef8 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/route.html @@ -0,0 +1,68 @@ +{% extends "layout.html" %} + +{% block title %} + Dashcam Segments +{% endblock %} + + +{% block main %} +{% autoescape false %} +
+

Dashcam Segments (one per minute)

+
+ +

+ current segment: +
+ current view: +
+ download full route {{ query_type }} +

+ qcamera - + fcamera - + dcamera - + ecamera +

+ {{ links }} + +{% endautoescape %} +

+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/screenrecords.html b/selfdrive/frogpilot/fleetmanager/templates/screenrecords.html new file mode 100644 index 0000000..e1c995e --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/screenrecords.html @@ -0,0 +1,31 @@ +{% extends "layout.html" %} + +{% block title %} + Screen Recordings +{% endblock %} + +{% block main %} +
+

Screen Recordings

+
+ +

+ current view: +
+ download: {{ clip }}

+ + {% for row in rows %} + {{ row }}
+ {% endfor %} +

+{% endblock %} diff --git a/selfdrive/frogpilot/fleetmanager/templates/tools.html b/selfdrive/frogpilot/fleetmanager/templates/tools.html new file mode 100644 index 0000000..12ac347 --- /dev/null +++ b/selfdrive/frogpilot/fleetmanager/templates/tools.html @@ -0,0 +1,78 @@ +{% extends "layout.html" %} + +{% block title %} +Tools +{% endblock %} + +{% block main %} + + +
+

Toggle Values

+
+ + + +
+
+ + +{% endblock %} diff --git a/selfdrive/manager/process_config.py b/selfdrive/manager/process_config.py index ed4de29..d6454d1 100644 --- a/selfdrive/manager/process_config.py +++ b/selfdrive/manager/process_config.py @@ -89,6 +89,7 @@ procs = [ PythonProcess("webjoystick", "tools.bodyteleop.web", notcar), # FrogPilot processes + PythonProcess("fleet_manager", "selfdrive.frogpilot.fleetmanager.fleet_manager", always_run), PythonProcess("frogpilot_process", "selfdrive.frogpilot.frogpilot_process", always_run), ] diff --git a/selfdrive/navd/navd.py b/selfdrive/navd/navd.py index dfa99b3..863ff15 100755 --- a/selfdrive/navd/navd.py +++ b/selfdrive/navd/navd.py @@ -173,6 +173,42 @@ class RouteEngine: resp.raise_for_status() r = resp.json() + r1 = resp.json() + + # Function to remove specified keys recursively unnessary for display + def remove_keys(obj, keys_to_remove): + if isinstance(obj, list): + return [remove_keys(item, keys_to_remove) for item in obj] + elif isinstance(obj, dict): + return {key: remove_keys(value, keys_to_remove) for key, value in obj.items() if key not in keys_to_remove} + else: + return obj + + keys_to_remove = ['geometry', 'annotation', 'incidents', 'intersections', 'components', 'sub', 'waypoints'] + self.r2 = remove_keys(r1, keys_to_remove) + self.r3 = {} + + # Add items for display under "routes" + if 'routes' in self.r2 and len(self.r2['routes']) > 0: + first_route = self.r2['routes'][0] + nav_destination_json = self.params.get('NavDestination') + + try: + nav_destination_data = json.loads(nav_destination_json) + place_name = nav_destination_data.get('place_name', 'Default Place Name') + first_route['Destination'] = place_name + first_route['Metric'] = self.params.get_bool("IsMetric") + self.r3['CurrentStep'] = 0 + self.r3['uuid'] = self.r2['uuid'] + except json.JSONDecodeError as e: + print(f"Error decoding JSON: {e}") + + # Save slim json as file + with open('navdirections.json', 'w') as json_file: + json.dump(self.r2, json_file, indent=4) + with open('CurrentStep.json', 'w') as json_file: + json.dump(self.r3, json_file, indent=4) + if len(r['routes']): self.route = r['routes'][0]['legs'][0]['steps'] self.route_geometry = [] @@ -308,6 +344,13 @@ class RouteEngine: if self.step_idx + 1 < len(self.route): self.step_idx += 1 self.reset_recompute_limits() + + # Update the 'CurrentStep' value in the JSON + if 'routes' in self.r2 and len(self.r2['routes']) > 0: + self.r3['CurrentStep'] = self.step_idx + # Write the modified JSON data back to the file + with open('CurrentStep.json', 'w') as json_file: + json.dump(self.r3, json_file, indent=4) else: cloudlog.warning("Destination reached") diff --git a/system/fleetmanager/static/frog.png b/system/fleetmanager/static/frog.png new file mode 100644 index 0000000..5285f0b Binary files /dev/null and b/system/fleetmanager/static/frog.png differ diff --git a/system/fleetmanager/templates/index.html b/system/fleetmanager/templates/index.html new file mode 100644 index 0000000..400df47 --- /dev/null +++ b/system/fleetmanager/templates/index.html @@ -0,0 +1,16 @@ +{% extends "layout.html" %} + +{% block title %} + Home +{% endblock %} + +{% block main %} +
+

Fleet Manager

+
+ View Dashcam Footage
+
View Screen Recordings
+
Access Error Logs
+
Navigation
+
Tools
+{% endblock %}