diff --git a/.docker-os-detect b/.docker-os-detect new file mode 100644 index 00000000..6287081c --- /dev/null +++ b/.docker-os-detect @@ -0,0 +1,6 @@ +#!/bin/sh + +if uname -r | grep -cq microsoft-; then + echo "WINDOWS DETECTED. Docker-on-windows is not compatible with ankerctl." > /dev/stderr + exit 1 +fi diff --git a/.gitattributes b/.gitattributes index 50ccaa27..05d1335c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,4 +7,4 @@ *.md text *.txt text *.tpl text -*.py text diff=python +*.py text diff=python eol=lf diff --git a/.hintrc b/.hintrc new file mode 100644 index 00000000..bafe5d39 --- /dev/null +++ b/.hintrc @@ -0,0 +1,11 @@ +{ + "extends": ["development"], + "hints": { + "axe/aria": [ + "default", + { + "aria-valid-attr-value": "off" + } + ] + } +} diff --git a/.pep8 b/.pep8 index 3ca433b0..fc32db1e 100644 --- a/.pep8 +++ b/.pep8 @@ -2,3 +2,4 @@ max_line_length = 120 ignore = E221,E226,E231,E241,E261,E203,E741 exclude = libflagship/pppp.py,libflagship/mqtt.py,libflagship/amtypes.py +statistics = True diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 00000000..17496da0 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "tabWidth": 4, + "useTabs": false, + "printWidth": 120, + "singleQuote": false, + "jsxSingleQuote": true, + "quoteProps": "as-needed" +} diff --git a/.vscode/settings.json b/.vscode/settings.json index d5d00188..431a7565 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,12 @@ { - "files.insertFinalNewline": true + "files.insertFinalNewline": true, + "[python]": { + "editor.defaultFormatter": "ms-python.autopep8", + "editor.formatOnSave": true, + "editor.formatOnSaveMode": "modifications" + }, + "python.linting.pycodestyleEnabled": true, + "python.linting.pycodestyleArgs": ["--config", ".pep8"], + "python.formatting.autopep8Args": ["--config", ".pep8"], + "python.formatting.provider": "autopep8" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c561d3fd..8df828e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0] - 2023-05-24 + + - Version 1.0.0! + - Add video streaming support to web ui + - Add support for uploading `login.json` through web ui + - Add print monitoring through web ui + - Add new mqtt types to libflagship + - Add status icons for mqtt, pppp and ctrl websocket + - Add support for restarting web services through web ui + - Add support for turning on/off camera light from web ui + - Add support for controlling video mode (sd/hd) from web ui + - Add `--pppp-dump` option for making a debug packet capture + - Stabilized video streaming, by fixing some rare corner cases. + - Make video stream automatically reconnect on connection loss + - Make video stream automatically suspend when no clients are connected + ## [0.9.0] - 2023-04-17 - First version with github actions for building docker image. (thanks to @cisien) diff --git a/Dockerfile b/Dockerfile index 7157d048..fe946771 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,21 @@ # First stage: build environment FROM python:3.11-bullseye AS build-env -# Set the working directory to /app -WORKDIR /app +COPY .docker-os-detect /tmp/docker-os-detect +RUN sh /tmp/docker-os-detect # Copy the requirements file COPY requirements.txt . +# Disable warning about running as "root" +ENV PIP_ROOT_USER_ACTION=ignore + +# Disable caching - we just want the output +ENV PIP_NO_CACHE_DIR=1 + # Install the dependencies -RUN pip install --no-cache-dir --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt +RUN pip install --upgrade pip && \ + pip install -r requirements.txt # Second stage: runtime environment FROM python:3.11-slim @@ -21,6 +27,8 @@ RUN mkdir -p /root/.config/ # Copy the script and libraries COPY ankerctl.py /app/ +COPY web /app/web/ +COPY ssl /app/ssl/ COPY static /app/static/ COPY libflagship /app/libflagship/ COPY cli /app/cli/ @@ -28,5 +36,7 @@ COPY cli /app/cli/ # Copy the installed dependencies from the build environment COPY --from=build-env /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +STOPSIGNAL SIGINT + ENTRYPOINT ["/app/ankerctl.py"] CMD ["webserver", "run"] diff --git a/Makefile b/Makefile index 4b855721..ecb010bd 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,14 @@ update: @transwarp -D specification -I templates/python/ -L templates/lib -O libflagship -u + @transwarp -D specification -I templates/js/ -L templates/lib -O static -u diff: @transwarp -D specification -I templates/python/ -L templates/lib -O libflagship -d + @transwarp -D specification -I templates/js/ -L templates/lib -O static -d install-tools: git submodule update --init pip install ./transwarp + +clean: + @find -name '*~' -o -name '__pycache__' -print0 | xargs -0 rm -rfv diff --git a/README.md b/README.md index fef18598..a25b1be6 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,184 @@ -# Ankermake M5 Protocol +# AnkerMake M5 Protocol -Welcome! This repository contains `ankerctl.py`, a command-line interface for -monitoring, controlling and interfacing with Ankermake M5 3D printers. +Welcome! This repository contains `ankerctl`, a command-line interface for monitoring, controlling and interfacing with AnkerMake M5 3D printers. -NOTE: This software is in early stages, so expect sharp edges and occasional errors! +**NOTE:** This is our first major release and while we have tested thoroughly there may be bugs. If you encounter one please open a [Github Issue](https://github.com/Ankermgmt/ankermake-m5-protocol/issues/new/choose) -The `ankerctl` program uses [`libflagship`](documentation/libflagship.md), a -library for communicating with the numerous different protocols required for -connecting to an Ankermake M5 printer. The `libflagship` library is also maintained -in this repo, under [`libflagship/`](libflagship/). +The `ankerctl` program uses [`libflagship`](documentation/developer-docs/libflagship.md), a library for communicating with the numerous different protocols required for connecting to an AnkerMake M5 printer. The `libflagship` library is also maintained in this repo, under [`libflagship/`](libflagship/). -## Current features +![Screenshot of ankerctl](/documentation/web-interface.png "Screenshot of ankerctl web interface") - - Print directly from PrusaSlicer, SuperSlicer +## Features - - Connect to Ankermake M5 and Ankermake APIs without using closed-source Anker - software. +### Current Features - - Send raw gcode commands directly to the printer (and see the response) + - Print directly from PrusaSlicer and its derivatives (SuperSlicer, Bamboo Studio, OrcaSlicer, etc.) - - Low-level access to MQTT, PPPP and HTTPS APIs + - Connect to AnkerMake M5 and AnkerMake APIs without using closed-source Anker software. - - Send print jobs to the printer + - Send raw gcode commands to the printer (and see the response). - - Stream camera image/video to your computer + - Low-level access to MQTT, PPPP and HTTPS APIs. + - Send print jobs (gcode files) to the printer. -## Upcoming and planned features + - Stream camera image/video to your computer. - - Easily monitor print status + - Easily monitor print status. - - Integration into other software. Home Assistant? Prusa Slicer? +### Upcoming and Planned Features -Pull requests always welcome :-) + - Integration into other software. Home Assistant? Cura plugin? -## Installation instructions +Let us know what you want to see; Pull requests always welcome! :smile: -First, please follow the [installation -instructions](documentation/example-file-usage/example-file-prerequistes.md) for -your platform. +## Installation -Verify that you can start `ankerctl.py`, and get the help screen: +There are currently two ways to do an install of ankerctl. You can install directly from git utilizing python on your Operating System or you can install from Docker which will install ankerctl in a containerized environment. Only one installation method should be chosen. -### For Windows, use: -```powershell -python3 ankerctl.py -h -``` -If that does not work then try running using `python ankerctl.py -h` +Order of Operations for Success: +- Choose installation method: [Docker](documentation/install-from-docker.md) or [Git](documentation/install-from-git.md) +- Follow the installation intructions for the install method +- Import the login.json file +- Have fun! Either run `ankerctl` from CLI or launch the webserver -### Linux and MacOS, use: -```sh -./ankerctl.py -h -``` +> **Note** +> Minimum version of Python required is 3.10 -You should see the below output - if not then head back to [installation instructions](documentation/example-file-usage/example-file-prerequistes.md) -``` -Usage: ankerctl.py [OPTIONS] COMMAND [ARGS]... - -Options: - -k, --insecure Disable TLS certificate validation - -v, --verbose Increase verbosity - -q, --quiet Decrease verbosity - -h, --help Show this message and exit. - -Commands: - config View and update configuration - http Low-level http api access - mqtt Low-level mqtt api access - pppp Low-level pppp api access -``` +> **Warning** +> Docker Installation ONLY works on Linux at this time -Before you can use ankerctl, you need to import the configuration. +Follow the instructions for a [git install](documentation/install-from-git.md) (recommended), or [docker install](documentation/install-from-docker.md). -Support for logging in with username and password is not yet supported, but an -`auth_token` can be imported from the saved credentials found in `login.json` in -Ankermake Slicer. See `ankerctl.py config import -h` for details: +## Importing configuration -``` -Usage: ankerctl.py config import [OPTIONS] path/to/login.json +1. Import your AnkerMake account data by opening a terminal window in the folder you placed ankerctl in and running the following command: - Import printer and account information from login.json + ```sh + python3 ankerctl.py config import + ``` - When run without filename, attempt to auto-detect login.json in default - install location + When run without filename on Windows and MacOS, the default location of `login.json` will be tried if no filename is specified. -Options: - -h, --help Show this message and exit. -``` + Otherwise, you can specify the file path for `login.json`. Example for Linux: + ```sh + ./ankerctl.py config import ~/.wine/drive_c/users/username/AppData/Local/AnkerMake/AnkerMake_64bit_fp/login.json + ``` + MacOS + ```sh + ./ankerctl.py config import $HOME/Library/Application\ Support/AnkerMake/AnkerMake_64bit_fp/login.json + ``` + Windows + ```sh + python3 ankerctl.py config import %APPDATA%\AnkerMake\AnkerMake_64bit_fp\login.json + ``` -On Windows and MacOS, the default location of `login.json` will be tried if no -filename is specified. Otherwise, you can specify the file path for -`login.json`. Example for linux: + Type `ankerctl.py config import -h` for more details on the import options. Support for logging in with username and password is not yet supported. To learn more about the method used to extract the login information and add printers, see the [MQTT Overview](documentation/developer-docs/mqtt-overview.md) and [Example Files](documentation/developer-docs/example-file-usage) documentation. -```sh -ankerctl.py config import ~/.wine/drive_c/users/username/AppData/Local/AnkerMake/AnkerMake_64bit_fp/login.json -``` + The output when successfully importing a config is similar to this: -The expected output is similar to this: -``` -[*] Loading cache.. -[*] Initializing API.. -[*] Requesting profile data.. -[*] Requesting printer list.. -[*] Requesting pppp keys.. -[*] Adding printer [AK7ABC0123401234] -[*] Finished import -``` + ```sh + [*] Loading cache.. + [*] Initializing API.. + [*] Requesting profile data.. + [*] Requesting printer list.. + [*] Requesting pppp keys.. + [*] Adding printer [AK7ABC0123401234] + [*] Finished import + ``` -At this point, your config is saved to a configuration file managed by -`ankerctl.py`. To see an overview of the stored data, use `config show`: + At this point, your config is saved to a configuration file managed by `ankerctl`. To see an overview of the stored data, use `config show`: -```sh -./ankerctl.py config show -[*] Account: - user_id: 01234567890abcdef012... - email: bob@example.org - region: eu - -[*] Printers: - sn: AK7ABC0123401234 - duid: EUPRAKM-001234-ABCDE -``` + ```sh + ./ankerctl.py config show + [*] Account: + user_id: 01234567890abcdef012... + email: bob@example.org + region: eu + + [*] Printers: + sn: AK7ABC0123401234 + duid: EUPRAKM-001234-ABCDE + ``` -NOTE: The cached login info contains sensitive details. In particular, the -`user_id` field is used when connecting to MQTT servers, and essentially works -as a password. Thus, the end of the value is redacted when printed to screen, to avoid -accidentally disclosing sensitive information. +> **NOTE:** +> The cached login info contains sensitive details. In particular, the `user_id` field is used when connecting to MQTT servers, and essentially works as a password. Thus, the end of the value is redacted when printed to screen, to avoid accidentally disclosing sensitive information. -Now that the printer information is known to `ankerctl`, the tool is ready to use. +2. Now that the printer information is known to `ankerctl`, the tool is ready to use. There’s a lot of available commands and utilities, use a command followed by `-h` to learn what your options are and get more in specific usage instructions. -Some examples: +> **NOTE:** +> As an alternative to using "config import" on the command line, it is possible to upload `login.json` through the web interface. Either method will work fine. -```sh -# attempt to detect printers on local network -./ankerctl.py pppp lan-search +## Usage -# monitor mqtt events -./ankerctl.py mqtt monitor +### Web Interface -# start gcode prompt -./ankerctl.py mqtt gcode +1. Start the webserver by running one of the following commands in the folder you placed ankerctl in. You’ll need to have this running whenever you want to use the web interface or send jobs to the printer via a slicer: -# set printer name -./ankerctl.py mqtt rename-printer BoatyMcBoatFace + Docker Installation Method: -# print boaty.gcode -./ankerctl.py pppp print-file boaty.gcode + ```sh + docker compose up + ``` -# capture 4mb of video from camera -./ankerctl.py pppp capture-video -m 4mb output.h264 -``` + Git Installation Method Using Python: -## Webserver + ```sh + ./ankerctl.py webserver run + ``` -ankerctl can also be used as a webserver to allow slicers like prusaslicer to print directly to the printer. +2. Navigate to [http://localhost:4470](http://localhost:4470) in your browser of choice on the same computer the webserver is running on. + + > **Important** + > If your `login.json` file was not automatically found, you’ll be prompted to upload your `login.json` file and the given the default path it should be found in your corresponding Operating System. + Once the `login.json` has been uploaded, the page will refresh and the web interface is usable. -![Screenshot of prusa slicer](/static/img/setup/prusaslicer-2.png?raw=true "Screenshot of prusa slicer") +### Printing Directly from PrusaSlicer -To start the webserver run the following command, then navigate to [http://localhost:4470](http://localhost:4470) +ankerctl can allow slicers like PrusaSlicer (and its derivatives) to send print jobs to the printer using the slicer’s built in communications tools. The web server must be running in order to send jobs to the printer. -```sh -./ankerctl.py webserver run -``` +Currently there’s no way to store the jobs for later printing on the printer, so you’re limited to using the “Send and Print” option only to immediately start the print once it’s been transmitted. + +Additional instructions can be found in the web interface. + +![Screenshot of prusa slicer](/static/img/setup/prusaslicer-2.png "Screenshot of prusa slicer") + +### Command-line tools -You can alternativly use docker compose to start the webserver running behind nginx +Some examples: ```sh -docker compose up -``` +# run the webserver to control over webgui +./ankerctl.py webserver run +# attempt to detect printers on local network +./ankerctl.py pppp lan-search +# monitor mqtt events +./ankerctl.py mqtt monitor -## Docker +# start gcode prompt +./ankerctl.py mqtt gcode -While running the python script is generally prefered, there may be situations where you want a more portable solution. For this, a docker image is provided. +# set printer name +./ankerctl.py mqtt rename-printer BoatyMcBoatFace -```sh -docker build -t ankerctl . -``` +# print boaty.gcode +./ankerctl.py pppp print-file boaty.gcode -Example usage (no peristent storage) -```bash -docker run \ - -v "$HOME/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json:/tmp/login.json" \ - ankerctl config decode /tmp/login.json -``` +# capture 4mb of video from camera +./ankerctl.py pppp capture-video -m 4mb output.h264 -Example usage (with peristent storage) -```bash -# create volume where we can store configs -docker volume create ankerctl_vol - -# generate /root/.config/ankerctl/default.json which is mounted to the docker volume -docker run \ - -v ankerctl_vol:/root/.config/ankerctl \ - -v "$HOME/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json:/tmp/login.json" \ - ankerctl config import /tmp/login.json - -# Now that there is a /root/.config/ankerctl/default.json file that persists in the docker volume -# we can run ankerctl without having to specify the login.json file -docker run \ - -v ankerctl_vol:/root/.config/ankerctl \ - ankerctl config show +# select printer to use when you have multiple +./ankerctl.py -p # index starts at 0 and goes up to the number of printers you have ``` - ## Legal -This project is in ABSOLUTELY NO WAY endorsed, affiliated with, or supported by -Anker. All information found herein is gathered entirely from reverse -engineering. +This project is **NOT** endorsed, affiliated with, or supported by AnkerMake. All information found herein is gathered entirely from reverse engineering using publicly available knowledge and resources. + +The goal of this project is to make the AnkerMake M5 usable and accessible using only Free and Open Source Software (FOSS). -The goal of this project is to make the Ankermake M5 usable and accessible using -only Free and Open Source Software. +This project is [licensed under the GNU GPLv3](LICENSE), and copyright © 2023 Christian Iversen. -This project is [licensed under the GNU GPLv3](LICENSE), and copyright © 2023 -Christian Iversen. +Some icons from [IconFinder](https://www.iconfinder.com/iconsets/3d-printing-line), and licensed under [Creative Commons](https://creativecommons.org/licenses/by/3.0/) diff --git a/ankerctl.py b/ankerctl.py index 7a60a4ed..6cb07d09 100755 --- a/ankerctl.py +++ b/ankerctl.py @@ -2,12 +2,11 @@ import json import click -import logging import platform -from os import path -from rich import print # you need python3 +import logging as log +from os import path, environ +from rich import print # you need python3 from tqdm import tqdm -from flask import Flask, request, render_template import cli.config import cli.model @@ -15,7 +14,7 @@ import cli.mqtt import cli.util import cli.pppp -import cli.checkver # check python version +import cli.checkver # check python version import libflagship.httpapi import libflagship.logincache @@ -24,17 +23,22 @@ from libflagship.util import enhex from libflagship.mqtt import MqttMsgType from libflagship.pppp import PktLanSearch, P2PCmdType, P2PSubCmdType, FileTransfer -from libflagship.ppppapi import AnkerPPPPApi, FileUploadInfo, PPPPError +from libflagship.ppppapi import FileUploadInfo, PPPPError class Environment: def __init__(self): pass - def require_config(self): + def load_config(self, required=True): with self.config.open() as config: if not getattr(config, 'printers', False): - log.critical("No printers found in config. Please import configuration using 'config import'") + msg = "No printers found in config. Please upload configuration " \ + "using the webserver or 'ankerctl.py config import'" + if required: + log.critical(msg) + else: + log.warning(msg) def upgrade_config_if_needed(self): try: @@ -52,40 +56,48 @@ def upgrade_config_if_needed(self): @click.group(context_settings=dict(help_option_names=["-h", "--help"])) +@click.option("--pppp-dump", required=False, metavar="", type=click.Path(), + help="Enable logging of PPPP data to ") @click.option("--insecure", "-k", is_flag=True, help="Disable TLS certificate validation") @click.option("--verbose", "-v", count=True, help="Increase verbosity") @click.option("--quiet", "-q", count=True, help="Decrease verbosity") +@click.option("--printer", "-p", type=int, default=environ.get('PRINTER_INDEX') or 0, help="Select printer number") @click.pass_context -def main(ctx, verbose, quiet, insecure): +def main(ctx, pppp_dump, verbose, quiet, insecure, printer): ctx.ensure_object(Environment) env = ctx.obj - levels = { - -3: logging.CRITICAL, - -2: logging.ERROR, - -1: logging.WARNING, - 0: logging.INFO, - 1: logging.DEBUG, + -3: log.CRITICAL, + -2: log.ERROR, + -1: log.WARNING, + 0: log.INFO, + 1: log.DEBUG, } env.config = cli.config.configmgr() env.insecure = insecure env.level = max(-3, min(verbose - quiet, 1)) - env.log = cli.logfmt.setup_logging(levels[env.level]) + env.pppp_dump = pppp_dump - global log - log = env.log + cli.logfmt.setup_logging(levels[env.level]) if insecure: import urllib3 urllib3.disable_warnings() + log.warning('[Not Verifying Certificates]') + log.warning('This is insecure and should not be used in production environments.') + log.warning('It is recommended to run without "-k/--insecure".') - env.upgrade_config_if_needed() + if ctx.invoked_subcommand not in {"http", "config"}: + env.upgrade_config_if_needed() + + env.printer_index = printer + log.debug(f"Using printer [{env.printer_index}]") @main.group("mqtt", help="Low-level mqtt api access") @pass_env def mqtt(env): - env.require_config() + env.load_config() @mqtt.command("monitor") @@ -95,7 +107,7 @@ def mqtt_monitor(env): Connect to mqtt broker, and show low-level events in realtime. """ - client = cli.mqtt.mqtt_open(env) + client = cli.mqtt.mqtt_open(env.config, env.printer_index, env.insecure) for msg, body in client.fetchloop(): log.info(f"TOPIC [{msg.topic}]") @@ -110,7 +122,7 @@ def mqtt_monitor(env): del obj["commandType"] print(f" [{cmdtype:4}] {name:20} {obj}") - except: + except Exception: print(f" {obj}") @@ -136,7 +148,7 @@ def mqtt_send(env, command_type, args, force): } if not force: - if command_type == MqttMsgType.ZZ_MQTT_CMD_APP_RECOVER_FACTORY.value: + if command_type == MqttMsgType.ZZ_MQTT_CMD_RECOVER_FACTORY.value: log.fatal("Refusing to perform factory reset (override with --force)") return @@ -144,7 +156,7 @@ def mqtt_send(env, command_type, args, force): log.fatal("Sending DEVICE_NAME_SET without devName= will crash printer (override with --force)") return - client = cli.mqtt.mqtt_open(env) + client = cli.mqtt.mqtt_open(env.config, env.printer_index, env.insecure) cli.mqtt.mqtt_command(client, cmd) @@ -156,7 +168,7 @@ def mqtt_rename_printer(env, newname): Set a new nickname for your printer """ - client = cli.mqtt.mqtt_open(env) + client = cli.mqtt.mqtt_open(env.config, env.printer_index, env.insecure) cmd = { "commandType": MqttMsgType.ZZ_MQTT_CMD_DEVICE_NAME_SET, @@ -175,7 +187,7 @@ def mqtt_gcode(env): Press Ctrl-C to exit. (or Ctrl-D to close connection, except on Windows) """ - client = cli.mqtt.mqtt_open(env) + client = cli.mqtt.mqtt_open(env.config, env.printer_index, env.insecure) while True: gcode = click.prompt("gcode", prompt_suffix="> ") @@ -209,7 +221,7 @@ def pppp_lan_search(env): Works by broadcasting a LAN_SEARCH packet, and waiting for a reply. """ - api = AnkerPPPPApi.open_broadcast() + api = cli.pppp.pppp_open_broadcast(dumpfile=env.pppp_dump) try: api.send(PktLanSearch()) resp = api.recv(timeout=1.0) @@ -232,8 +244,8 @@ def pppp_print_file(env, file, no_act): executing the print job. NOTE: the printer only ever stores ONE uploaded file, so anytime a file is uploaded, the old one is deleted. """ - env.require_config() - api = cli.pppp.pppp_open(env) + env.load_config() + api = cli.pppp.pppp_open(env.config, env.printer_index, dumpfile=env.pppp_dump) data = file.read() fui = FileUploadInfo.from_file(file.name, user_name="ankerctl", user_id="-", machine_id="-") @@ -256,7 +268,8 @@ def pppp_print_file(env, file, no_act): @pppp.command("capture-video") @click.argument("file", required=True, type=click.File("wb"), metavar="") -@click.option("--max-size", "-m", required=True, type=cli.util.FileSizeType(), help="Stop capture at this size (kb, mb, gb, etc)") +@click.option("--max-size", "-m", required=True, type=cli.util.FileSizeType(), + help="Stop capture at this size (kb, mb, gb, etc)") @pass_env def pppp_capture_video(env, file, max_size): """ @@ -265,8 +278,8 @@ def pppp_capture_video(env, file, max_size): The output is in h264 ES (Elementary Stream) format. It can be played with "ffplay" from the ffmpeg program suite. """ - env.require_config() - api = cli.pppp.pppp_open(env) + env.load_config() + api = cli.pppp.pppp_open(env.config, env.printer_index, dumpfile=env.pppp_dump) cmd = {"commandType": P2PSubCmdType.START_LIVE, "data": {"encryptkey": "x", "accountId": "y"}} api.send_xzyh(json.dumps(cmd).encode(), cmd=P2PCmdType.P2P_JSON_CMD) @@ -326,7 +339,13 @@ def http_calc_sec_code(duid, mac): @main.group("config", help="View and update configuration") -def config(): pass +@click.pass_context +def config(ctx): + if ctx.invoked_subcommand in {"import", "decode"}: + return + + env = ctx.obj + env.upgrade_config_if_needed() @config.command("decode") @@ -407,7 +426,7 @@ def config_import(env, fd): config = cli.config.load_config_from_api(auth_token, region, env.insecure) except libflagship.httpapi.APIError as E: log.critical(f"Config import failed: {E} " - "(auth token might be expired: make sure Ankermake Slicer can connect, then try again)") + "(auth token might be expired: make sure Ankermake Slicer can connect, then try again)") except Exception as E: log.critical(f"Config import failed: {E}") @@ -432,78 +451,34 @@ def config_show(env): return log.info("Account:") - print(f" user_id: {cfg.account.user_id[:20]}...") - print(f" auth_token: {cfg.account.auth_token[:20]}...") + print(f" user_id: {cfg.account.user_id[:10]}...") + print(f" auth_token: {cfg.account.auth_token[:10]}...") print(f" email: {cfg.account.email}") print(f" region: {cfg.account.region.upper()}") print() log.info("Printers:") - for p in cfg.printers: + # Sort the list of printers by printer.id + for i, p in enumerate(cfg.printers): + print(f" printer: {i}") + print(f" id: {p.id}") + print(f" name: {p.name}") print(f" duid: {p.p2p_duid}") # Printer Serial Number print(f" sn: {p.sn}") + print(f" model: {p.model}") + print(f" created: {p.create_time}") + print(f" updated: {p.update_time}") print(f" ip: {p.ip_addr}") print(f" wifi_mac: {cli.util.pretty_mac(p.wifi_mac)}") print(f" api_hosts: {', '.join(p.api_hosts)}") print(f" p2p_hosts: {', '.join(p.p2p_hosts)}") + print() @main.group("webserver", help="Built-in webserver support") @pass_env def webserver(env): - env.require_config() - - -app = Flask(__name__, template_folder='./static') -# app.config['TEMPLATES_AUTO_RELOAD'] = True - - -@app.get("/") -def app_root(): - host = request.host.split(':') - requestPort = host[1] if len(host) > 1 else '80' # If there is no 2nd array entry, the request port is 80 - return render_template("index.html", requestPort=requestPort, requestHost=host[0]) - - -@app.get("/api/version") -def app_api_version(): - return { - "api": "0.1", - "server": "1.9.0", - "text": "OctoPrint 1.9.0" - } - - -@app.post("/api/files/local") -def app_api_files_local(): - env = app.config["env"] - - user_name = request.headers.get("User-Agent", "ankerctl").split("/")[0] - - no_act = not cli.util.parse_http_bool(request.form["print"]) - - if no_act: - cli.util.http_abort(409, "Upload-only not supported by Ankermake M5") - - fd = request.files["file"] - - api = cli.pppp.pppp_open(env) - - data = fd.read() - fui = FileUploadInfo.from_data(data, fd.filename, user_name=user_name, user_id="-", machine_id="-") - log.info(f"Going to upload {fui.size} bytes as {fui.name!r}") - try: - cli.pppp.pppp_send_file(api, fui, data) - log.info("File upload complete. Requesting print start of job.") - api.aabb_request(b"", frametype=FileTransfer.END) - except PPPPError as E: - log.error(f"Could not send print job: {E}") - else: - log.info("Successfully sent print job") - finally: - api.stop() - - return {} + env.load_config(False) @webserver.command("run", help="Run ankerctl webserver") @@ -511,11 +486,8 @@ def app_api_files_local(): @click.option("--port", default=4470, envvar="FLASK_PORT", help="Port to bind to") @pass_env def webserver(env, host, port): - env.require_config() - app.config["env"] = env - app.config["port"] = port - app.config["host"] = host - app.run(host=host,port=port) + import web + web.webserver(env.config, env.printer_index, host, port, env.insecure, pppp_dump=env.pppp_dump) if __name__ == "__main__": diff --git a/cli/config.py b/cli/config.py index c17acc6d..539deaca 100644 --- a/cli/config.py +++ b/cli/config.py @@ -1,6 +1,7 @@ import logging as log import contextlib import json +from datetime import datetime from pathlib import Path from platformdirs import PlatformDirs @@ -24,8 +25,6 @@ def __init__(self, dirs: PlatformDirs, classes=None): @contextlib.contextmanager def _borrow(self, value, write, default=None): - if not default: - default = {} pr = self.load(value, default) yield pr if write: @@ -75,7 +74,7 @@ def modify(self): return self._borrow("default", write=True) def open(self): - return self._borrow("default", write=False) + return self._borrow("default", write=False, default=Config(account=None, printers=[])) def configmgr(profile="default"): @@ -107,10 +106,17 @@ def load_config_from_api(auth_token, region, insecure): dsks = {dsk["station_sn"]: dsk for dsk in appapi.equipment_get_dsk_keys(station_sns=sns)["dsk_keys"]} # populate config object with printer list + # Sort the list of printers by printer.id + printers.sort(key=lambda p: p["station_id"]) for pr in printers: station_sn = pr["station_sn"] config.printers.append(Printer( + id=pr["station_id"], sn=station_sn, + name=pr["station_name"], + model=pr["station_model"], + create_time=datetime.fromtimestamp(pr["create_time"]), + update_time=datetime.fromtimestamp(pr["update_time"]), mqtt_key=unhex(pr["secret_key"]), wifi_mac=pr["wifi_mac"], ip_addr=pr["ip_addr"], diff --git a/cli/model.py b/cli/model.py index ca981975..49af6655 100644 --- a/cli/model.py +++ b/cli/model.py @@ -1,4 +1,5 @@ import json +from datetime import datetime from dataclasses import dataclass from libflagship.util import unhex, enhex @@ -12,6 +13,8 @@ def from_dict(cls, data): res[k] = data[k] if v.type == bytes: res[k] = unhex(res[k]) + elif v.type == datetime: + res[k] = datetime.fromtimestamp(res[k]) return cls(**res) def to_dict(self): @@ -20,6 +23,8 @@ def to_dict(self): res[k] = getattr(self, k) if v.type == bytes: res[k] = enhex(res[k]) + elif v.type == datetime: + res[k] = res[k].timestamp() return res @classmethod @@ -32,7 +37,12 @@ def to_json(self): @dataclass class Printer(Serialize): + id: str sn: str + name: str + model: str + create_time: datetime + update_time: datetime wifi_mac: str ip_addr: str mqtt_key: bytes @@ -61,4 +71,7 @@ def mqtt_password(self): @dataclass class Config(Serialize): account: Account - printers: [Printer] + printers: list[Printer] + + def __bool__(self): + return bool(self.account) diff --git a/cli/mqtt.py b/cli/mqtt.py index d4b6e5c4..4bd75651 100644 --- a/cli/mqtt.py +++ b/cli/mqtt.py @@ -3,6 +3,7 @@ import cli.util +from libflagship import ROOT_DIR from libflagship.mqttapi import AnkerMQTTBaseClient servertable = { @@ -11,19 +12,22 @@ } -def mqtt_open(env): - with env.config.open() as cfg: - printer = cfg.printers[0] +def mqtt_open(config, printer_index, insecure): + + with config.open() as cfg: + if printer_index >= len(cfg.printers): + log.critical(f"Printer number {printer_index} out of range, max printer number is {len(cfg.printers)-1} ") + printer = cfg.printers[printer_index] acct = cfg.account server = servertable[acct.region] - env.log.info(f"Connecting to {server}") + log.info(f"Connecting printer {printer.name} ({printer.p2p_duid}) through {server}") client = AnkerMQTTBaseClient.login( printer.sn, acct.mqtt_username, acct.mqtt_password, printer.mqtt_key, - ca_certs="examples/ankermake-mqtt.crt", - verify=not env.insecure, + ca_certs=ROOT_DIR / "ssl/ankermake-mqtt.crt", + verify=not insecure, ) client.connect(server) return client @@ -37,3 +41,13 @@ def mqtt_command(client, msg): click.echo(cli.util.pretty_json(reply)) else: log.error("No response from printer") + + +def mqtt_query(client, msg): + client.query(msg) + + reply = client.await_response(msg["commandType"]) + if reply: + click.echo(cli.util.pretty_json(reply)) + else: + log.error("No response from printer") diff --git a/cli/pppp.py b/cli/pppp.py index 94eef3ca..51ff2ee9 100644 --- a/cli/pppp.py +++ b/cli/pppp.py @@ -1,34 +1,58 @@ import time import uuid -import click import logging as log +from datetime import datetime, timedelta from tqdm import tqdm import cli.util -from libflagship.pppp import PktLanSearch, Duid, P2PCmdType -from libflagship.ppppapi import AnkerPPPPApi, FileTransfer +from libflagship.pktdump import PacketWriter +from libflagship.pppp import Duid, P2PCmdType, FileTransfer +from libflagship.ppppapi import AnkerPPPPApi, PPPPState -def pppp_open(env): - with env.config.open() as cfg: - printer = cfg.printers[0] +def _pppp_dumpfile(api, dumpfile): + if dumpfile: + log.info(f"Logging all pppp traffic to {dumpfile!r}") + pktwr = PacketWriter.open(dumpfile) + api.set_dumper(pktwr) + + +def pppp_open(config, printer_index, timeout=None, dumpfile=None): + if timeout: + deadline = datetime.now() + timedelta(seconds=timeout) + + with config.open() as cfg: + if printer_index >= len(cfg.printers): + log.critical(f"Printer number {printer_index} out of range, max printer number is {len(cfg.printers)-1} ") + printer = cfg.printers[printer_index] api = AnkerPPPPApi.open_lan(Duid.from_string(printer.p2p_duid), host=printer.ip_addr) - log.info("Trying connect over pppp") - api.daemon = True - api.start() + _pppp_dumpfile(api, dumpfile) - api.send(PktLanSearch()) + log.info(f"Trying connect to printer {printer.name} ({printer.p2p_duid}) over pppp using ip {printer.ip_addr}") - while not api.rdy: + api.connect_lan_search() + api.start() + + while api.state != PPPPState.Connected: time.sleep(0.1) + if api.stopped.is_set() or (timeout and (datetime.now() > deadline)): + api.stop() + raise ConnectionRefusedError("Connection rejected by device") log.info("Established pppp connection") return api +def pppp_open_broadcast(dumpfile=None): + api = AnkerPPPPApi.open_broadcast() + api.state = PPPPState.Connected + _pppp_dumpfile(api, dumpfile) + return api + + def pppp_send_file(api, fui, data): log.info("Requesting file transfer..") api.send_xzyh(str(uuid.uuid4())[:16].encode(), cmd=P2PCmdType.P2P_SEND_FILE) diff --git a/docker-compose.yaml b/docker-compose.yaml index 2acbdf9a..a0154e79 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,26 +1,20 @@ -version: '3.9.1' - -# You must manually run `ankerctl config import` atleast once -# docker run \ -# -v ankerctl_vol:/root/.config/ankerctl \ -# -v "$HOME/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json:/tmp/login.json" \ -# ankerctl config import /tmp/login.json services: - ankerctl: - image: ankerctl/ankerctl:latest - container_name: ankerctl - restart: unless-stopped - build: . - environment: - - FLASK_PORT=4470 - - FLASK_HOST=0.0.0.0 - volumes: - - ankerctl_vol:/root/.config/ankerctl - ports: - - 127.0.0.1:4470:4470 - entrypoint: "/app/ankerctl.py" - command: ["webserver", "run"] + ankerctl: + image: ghcr.io/ankermgmt/ankermake-m5-protocol:latest + container_name: ankerctl + restart: unless-stopped + build: . + + # host-mode networking is required for pppp communication with the + # printer, since it is an asymmetrical udp protocol. + network_mode: host + + # bind to localhost by default + environment: + - FLASK_HOST=127.0.0.1 + - FLASK_PORT=4470 + volumes: + - ankerctl_vol:/root/.config/ankerctl volumes: - ankerctl_vol: - external: true + ankerctl_vol: diff --git a/docker-import.sh b/docker-import.sh new file mode 100755 index 00000000..4b4d8e72 --- /dev/null +++ b/docker-import.sh @@ -0,0 +1,47 @@ +#!/bin/sh + +set -e + +if [ $# -eq 0 ]; then + CONTAINER="ankerctl" +elif [ $# -eq 1 ]; then + CONTAINER="$1" +else + echo "usage: $0 [container-name]" + echo "" + echo " Attempt to auto-import AnkerMake Slicer credentials" + echo " ('login.json') into the specified docker container." + echo "" + echo " Container name default to 'ankerctl' if unspecified." + exit 1 +fi + +if [ "$(docker container inspect -f "{{.State.Status}}" "${CONTAINER}")" != "running" ]; then + echo "" + echo ">> Container ${CONTAINER} is not running. Please start container before running this script! <<" + exit 1 +fi + + +WINEPREFIX=${WINEPREFIX:-$HOME/.wine} + +for root in "${APPDATA}" "${HOME}"; do + for prefix in "Library/Application Support" "$WINEPREFIX/drive_c/users/${USER}/AppData/Local"; do + for suffix in "AnkerMake/AnkerMake_64bit_fp/login.json" "Ankermake/AnkerMake_64bit_fp/login.json"; do + name="$root/$prefix/$suffix"; + if [ -f "${name}" ]; then + echo "** Importing ${name} credentials **"; + docker cp -L "${name}" ${CONTAINER}:/tmp + if docker exec -it ${CONTAINER} /app/ankerctl.py -k config import /tmp/login.json; then + echo "Configuration imported successfully. Restarting container..." + docker restart ${CONTAINER} + exit $? + else + echo "Configuration import failed :(" + fi + else + echo "** No ${name} credentials detected **"; + fi + done + done +done diff --git a/documentation/example-file-usage/example-file-prerequistes.md b/documentation/developer-docs/example-file-usage/example-file-prerequistes.md similarity index 100% rename from documentation/example-file-usage/example-file-prerequistes.md rename to documentation/developer-docs/example-file-usage/example-file-prerequistes.md diff --git a/documentation/example-file-usage/extract-auth-token-example-file-usage.md b/documentation/developer-docs/example-file-usage/extract-auth-token-example-file-usage.md similarity index 100% rename from documentation/example-file-usage/extract-auth-token-example-file-usage.md rename to documentation/developer-docs/example-file-usage/extract-auth-token-example-file-usage.md diff --git a/documentation/example-file-usage/mqtt-connect-example-file-usage.md b/documentation/developer-docs/example-file-usage/mqtt-connect-example-file-usage.md similarity index 100% rename from documentation/example-file-usage/mqtt-connect-example-file-usage.md rename to documentation/developer-docs/example-file-usage/mqtt-connect-example-file-usage.md diff --git a/documentation/libflagship.md b/documentation/developer-docs/libflagship.md similarity index 100% rename from documentation/libflagship.md rename to documentation/developer-docs/libflagship.md diff --git a/documentation/mqtt-overview.md b/documentation/developer-docs/mqtt-overview.md similarity index 100% rename from documentation/mqtt-overview.md rename to documentation/developer-docs/mqtt-overview.md diff --git a/documentation/install-from-docker.md b/documentation/install-from-docker.md new file mode 100644 index 00000000..038666df --- /dev/null +++ b/documentation/install-from-docker.md @@ -0,0 +1,42 @@ +# Installation (Docker) + +## login.json pre-requisites for Linux Install + +### AnkerMake Slicer installed on another Machine + +1. Install the [AnkerMake slicer](https://www.ankermake.com/software) on a supported Operating System. Make sure you open it and login via the “Account” dropdown in the top toolbar. + +2. Retreive the ```login.json``` file from the supported operating system: + + Windows Default Location: + ```sh + %APPDATA%\AnkerMake\AnkerMake_64bit_fp\login.json + ``` + + MacOS Default Location: + ```sh + $HOME/Library/Application\ Support/AnkerMake/AnkerMake_64bit_fp/login.json + ``` + +3. Take said ```login.json``` file and store it in a location your docker instance will be able to access it from. + +4. Now follow the Docker Compose Instructions below. + +### Native Linux + +1. Install the [AnkerMake slicer](https://www.ankermake.com/software) on Linux via emulation such as Wine. Make sure you open it and login via the “Account” dropdown in the top toolbar. + +2. Retreive the ```login.json``` file ```~/.wine/drive_c/users/$USER/AppData/Local/AnkerMake/AnkerMake_64bit_fp/login.json``` + +3. Take said ```login.json``` file and store it in a location your docker instance will be able to access it from. + +4. Now follow the Docker Compose Instructions below. + +## Docker Compose Instructions + +To start `ankerctl` using docker compose, run: + +```sh +docker compose pull +docker compose up +``` diff --git a/documentation/install-from-git.md b/documentation/install-from-git.md new file mode 100644 index 00000000..b26cde47 --- /dev/null +++ b/documentation/install-from-git.md @@ -0,0 +1,88 @@ +# Installation (git) + +> **Note** +> Minimum version of Python is 3.10. If having trouble running ankerctl.py try running it via `python3.10 ankerctly.py` or your installed equivalent + +## Windows + +1. Install the [AnkerMake slicer](https://www.ankermake.com/software). Make sure you open it and login via the “Account” dropdown in the top toolbar. + **NOTE:** The slicer app does not need to be open for the rest of these steps. + +2. Using `git` or [GitHub Desktop](https://desktop.github.com/), clone this repository into a location of your choice and then navigate to that location in File Explorer. + +3. Hold down the "Shift" key and right-click on some empty space in the repository top level folder. Select "Open in Terminal" (Windows 11) or "Open PowerShell window here" (Windows 10) from the context menu dropdown. (If the following commands do not work, try re-opening your terminal window as administrator and use the `cd` command to navigate to where you cloned this repository) + +4. Enter in the following command to install/check Python 3: + + ```powershell + python3 + ``` + + Now, one of two things will happen: + +- The first possibility is that the Microsoft Store will open and prompt you to install Python 3.10 or newer. You can do that and that's a perfectly fine way to install Python 3 or you can go to the [Python website](https://www.python.org/downloads/) and install it from there. Either way works, do whichever you prefer. Then enter in the above command again after and you should see what's in the next bullet. + +- The other possibility is that you get a message similar to this: + + ``` + Python 3.10.10 (tags/v3.10.10:aad5f6a, Feb 7 2023, 17:20:36) [MSC v.1929 64 bit (AMD64)] on win32 + Type "help", "copyright", "credits" or "license" for more information. + >>> + ``` + + This means you already have Python 3 installed and are good to go on to the next step. Enter in the following command to exit out of the Python 3 runtime environment. + + ```python + quit() + ``` + +5. Enter in the following command to install the required packages using pip: + + ```powershell + pip3 install -r requirements.txt + ``` + + If required, enter `Y` to all installation prompts. + +6. You now should be able to run ankerctl.py + +## MacOS + +1. Install the [AnkerMake slicer](https://www.ankermake.com/software). Make sure you open it and login via the “Account” dropdown in the top toolbar. + **NOTE:** The slicer app does not need to be open for the rest of these steps. + +2. Using `git` or [GitHub Desktop](https://desktop.github.com/), clone this repository into a location of your choice and then navigate to that location in Finder. + +3. Hold down the "Control" key and click the folder in the path bar, then choose "Open in Terminal". (If you don’t see the path bar at the bottom of the Finder window, choose View > Show Path Bar) + +4. Install Python3 from the [Python website.](https://www.python.org/downloads/macos/), homebrew, or Anaconda. It needs to be 3.10 or newer. + +5. Enter in the following command to install the required pip packages: + + ```bash + pip3 install -r requirements.txt + ``` + + If required, enter `Y` to all installation prompts. + + 6. You now should be able to run ankerctl.py + +## Linux + +1. Install the [AnkerMake slicer](https://www.ankermake.com/software). Alternatively, you can install the slicer on a officially supported operating system that you have access to (Windows or MacOS) and use the `login.json` file from that machine. Either way you choose, make sure you open the slicer and login via the “Account” dropdown in the top toolbar. + + **NOTE:** The slicer app does not need to be open for the rest of these steps. + +2. Using `git`, clone this repository into a location of your choice and then navigate to that location in your terminal app of choice. + +3. Install Python3 from whatever package manager your distro uses via the terminal. The minimum version has to be 3.10 or newer. + +4. Enter in the following command to install the required pip packages: + + ```bash + pip3 install -r requirements.txt + ``` + + If required, enter `Y` to all installation prompts. + +5. You now should be able to run ankerctl.py diff --git a/documentation/web-interface.png b/documentation/web-interface.png new file mode 100644 index 00000000..281ca2db Binary files /dev/null and b/documentation/web-interface.png differ diff --git a/libflagship/__init__.py b/libflagship/__init__.py new file mode 100644 index 00000000..9c7804b4 --- /dev/null +++ b/libflagship/__init__.py @@ -0,0 +1,14 @@ +""" +This module imports the 'Path' class from the 'pathlib' package and sets the ROOT_DIR variable. 'Path' from 'pathlib' +provides a more object-oriented method to handle filesystem paths. + +Variables: +- ROOT_DIR: A Path object pointing to the parent directory of the file this code is in. + +Usage: +- Import this module into your script, then access the ROOT_DIR variable to get the parent directory Path object +for your project. +""" +from pathlib import Path + +ROOT_DIR = Path(__file__).parent.parent diff --git a/libflagship/cyclic.py b/libflagship/cyclic.py new file mode 100644 index 00000000..64631888 --- /dev/null +++ b/libflagship/cyclic.py @@ -0,0 +1,134 @@ +import unittest + + +class CyclicU16(int): + """Cyclic 16-bit unsigned numbers, with special handling of wraparound + + When dealing with 16-bit counters, special care must be taken when + overflowinging the number. For example, consider two increasing counter + variables, x and y. At a point in time, we have: + + x == 0xFFF2 + y == 0xFFF5 + + At this time `x < y` works, as expected. However, 12 steps later, we have: + + x == 0xFFFE + y == 0x0001 + + which will invert the result of `x < y`, even though the counters have + increased at the same rate. + + To handle this situation, we define a range (CyclicU16.wrap) in which the + numbers are assumed to have recently wrapped around, such that: + + x, y = 0xFFFE, 0x0001 + + (x < y) == False + + but: + + x, y = CyclicU16(0xFFFE), CyclicU16(0x0001) + + (x < y) == True + """ + + def __new__(cls, k): + return int.__new__(cls, cls.trunc(k)) + + def __init__(self, k, wrap=0x100): + self._wrap = wrap + + @property + def wrap(self): + return self._wrap + + @staticmethod + def trunc(n): + return int(n) & 0xFFFF + + def __hash__(self): + return int(self) + + def __add__(self, k): + return type(self)(int(self) + int(k)) + + def __sub__(self, k): + return type(self)(int(self) - int(k)) + + def __eq__(self, k): + return int(self) == self.trunc(k) + + def __ne__(self, k): + return not self.__eq__(k) + + def __lt__(self, other): + # if sign bit differs, take wrap into account + if (self ^ other) & 0x8000: + return self.trunc(self - self.wrap) < self.trunc(other - self.wrap) + else: + return int(self) < other + + def __gt__(self, other): + # if sign bit differs, take wrap into account + if (self ^ other) & 0x8000: + return self.trunc(self - self.wrap) > self.trunc(other - self.wrap) + else: + return int(self) > other + + def __le__(self, other): + return not self.__gt__(other) + + def __ge__(self, other): + return not self.__lt__(other) + + +class TestCyclic(unittest.TestCase): + + def test_equal(self): + C = CyclicU16 + + self.assertEqual(C(0x42), 0x42) + self.assertEqual(C(0x10001), 0x1) + + def test_lt(self): + C = CyclicU16 + + self.assertFalse(C(0x1) < C(0x1)) + self.assertFalse(C(0xFFFF) < C(0xFFFF)) + self.assertTrue(C(0x1) < C(0x2)) + self.assertFalse(C(0x2) < C(0x1)) + self.assertTrue(C(0x90) < C(0x120)) + self.assertFalse(C(0x120) < C(0x90)) + self.assertTrue(C(0x101) < C(0x120)) + self.assertFalse(C(0x120) < C(0x101)) + self.assertTrue(C(0xFFFE) < C(0xFFFF)) + self.assertTrue(C(0xFFFE) < C(0x10)) + self.assertFalse(C(0xFFFE) < C(0x110)) + + def test_gt(self): + C = CyclicU16 + + self.assertFalse(C(0x1) > C(0x1)) + self.assertFalse(C(0xFFFF) > C(0xFFFF)) + self.assertTrue(C(0x2) > C(0x1)) + self.assertFalse(C(0x1) > C(0x2)) + self.assertTrue(C(0x120) > C(0x90)) + self.assertFalse(C(0x90) > C(0x120)) + self.assertTrue(C(0x120) > C(0x101)) + self.assertFalse(C(0x101) > C(0x120)) + self.assertTrue(C(0xFFFF) > C(0xFFFE)) + self.assertTrue(C(0x10) > C(0xFFFE)) + self.assertFalse(C(0x110) > C(0xFFFE)) + + def test_overflow(self): + C = CyclicU16 + + n = C(0xFFFE) + self.assertEqual(n, 0xFFFE) + n += 1 + self.assertEqual(n, 0xFFFF) + n += 1 + self.assertEqual(n, 0x0000) + n += 1 + self.assertEqual(n, 0x0001) diff --git a/libflagship/mqtt.py b/libflagship/mqtt.py index 3b5f96ea..40da6935 100644 --- a/libflagship/mqtt.py +++ b/libflagship/mqtt.py @@ -26,8 +26,8 @@ def pack(self): class MqttMsgType(enum.IntEnum): ZZ_MQTT_CMD_EVENT_NOTIFY = 0x03e8 # - ZZ_MQTT_CMD_PRINT_SCHEDULE = 0x03a9 # - ZZ_MQTT_CMD_FIRMWARE_VERSION = 0x03ea # Not implemented? + ZZ_MQTT_CMD_PRINT_SCHEDULE = 0x03e9 # + ZZ_MQTT_CMD_FIRMWARE_VERSION = 0x03ea # Returns firmware version string ZZ_MQTT_CMD_NOZZLE_TEMP = 0x03eb # Set nozzle temperature in units of 1/100th deg C (i.e.31337 is 313.37C) ZZ_MQTT_CMD_HOTBED_TEMP = 0x03ec # Set hotbed temperature in units of 1/100th deg C (i.e. 1337 is 13.37C) ZZ_MQTT_CMD_FAN_SPEED = 0x03ed # Set fan speed @@ -46,7 +46,7 @@ class MqttMsgType(enum.IntEnum): ZZ_MQTT_CMD_MOVE_ZERO = 0x0402 # (probably) Move to home position ZZ_MQTT_CMD_APP_QUERY_STATUS = 0x0403 # ZZ_MQTT_CMD_ONLINE_NOTIFY = 0x0404 # - ZZ_MQTT_CMD_APP_RECOVER_FACTORY = 0x0405 # + ZZ_MQTT_CMD_RECOVER_FACTORY = 0x0405 # Factory reset printer ZZ_MQTT_CMD_BLE_ONOFF = 0x0407 # (probably) Enable/disable Bluetooth Low Energy ("ble") radio ZZ_MQTT_CMD_DELETE_GCODE_FILE = 0x0408 # (probably) Delete specified gcode file ZZ_MQTT_CMD_RESET_GCODE_PARAM = 0x0409 # ? @@ -63,7 +63,10 @@ class MqttMsgType(enum.IntEnum): ZZ_MQTT_CMD_PREVIEW_IMAGE_URL = 0x0414 # ZZ_MQTT_CMD_SYSTEM_CHECK = 0x0419 # ? ZZ_MQTT_CMD_AI_SWITCH = 0x041a # ? - ZZ_STEST_CMD_GCODE_TRANSPOR = 0x07e2 # ? + ZZ_MQTT_CMD_AI_INFO_CHECK = 0x041b # ? + ZZ_MQTT_CMD_MODEL_LAYER = 0x041c # ? + ZZ_MQTT_CMD_MODEL_DL_PROCESS = 0x041d # ? + ZZ_MQTT_CMD_PRINT_MAX_SPEED = 0x041f # ? ZZ_MQTT_CMD_ALEXA_MSG = 0x0bb8 # @classmethod @@ -128,6 +131,8 @@ class MqttMsg(_MqttMsg): @classmethod def parse(cls, p, key): p = mqtt_checksum_remove(p) + if p[6] != 2: + raise ValueError(f"Unsupported mqtt message format (expected 2, but found {p[6]})") body, data = p[:64], mqtt_aes_decrypt(p[64:], key) res = super().parse(body + data) assert res[0].size == (len(p) + 1) diff --git a/libflagship/mqttapi.py b/libflagship/mqttapi.py index 9fac8b46..13dc6627 100644 --- a/libflagship/mqttapi.py +++ b/libflagship/mqttapi.py @@ -48,7 +48,11 @@ def on_publish(self, client, userdata, result): # internal function def _on_message(self, client, userdata, msg): - pkt, tail = MqttMsg.parse(msg.payload, key=self._key) + try: + pkt, tail = MqttMsg.parse(msg.payload, key=self._key) + except Exception as E: + log.error(f"Failed to decode mqtt message: {E}") + return data = json.loads(pkt.data) if isinstance(data, list): @@ -66,15 +70,11 @@ def on_message(self, client, userdata, msg, pkt, tail): pass @classmethod - def login(cls, printersn, username, password, key, ca_certs="ankermake-mqtt.crt", verify=True): + def login(cls, printersn, username, password, key, ca_certs=None, verify=True): client = mqtt.Client() - - if verify: - client.tls_set(ca_certs=ca_certs) - else: - client.tls_set(ca_certs=ca_certs, cert_reqs=ssl.CERT_NONE) - client.tls_insecure_set(True) - + cert_reqs = ssl.CERT_NONE if not verify else ssl.VERIFY_DEFAULT + client.tls_set(ca_certs=ca_certs, cert_reqs=cert_reqs) + client.tls_insecure_set(not verify) client.username_pw_set(username, password) return cls(printersn, client, key) diff --git a/libflagship/pktdump.py b/libflagship/pktdump.py new file mode 100644 index 00000000..64d8d865 --- /dev/null +++ b/libflagship/pktdump.py @@ -0,0 +1,27 @@ +from datetime import datetime +from .util import enhex + + +class PacketWriter: + + def __init__(self, fd): + self.fd = fd + + @staticmethod + def timestamp(): + return datetime.now().isoformat() + + @classmethod + def open(cls, filename, append=True): + fd = open(filename, "a" if append else "w") + fd.write(f"# ========== {cls.timestamp()} Logging starts ==========\n") + return cls(fd) + + def write(self, data, addr, type="-"): + self.fd.write(f"{self.timestamp()} {type} {addr[0]}:{addr[1]} {enhex(data)}\n") + + def rx(self, data, addr): + self.write(data, addr, type="RX") + + def tx(self, data, addr): + self.write(data, addr, type="TX") diff --git a/libflagship/pppp.py b/libflagship/pppp.py index c175118c..0fc0e63a 100644 --- a/libflagship/pppp.py +++ b/libflagship/pppp.py @@ -88,38 +88,140 @@ class Type(enum.IntEnum): INVALID = 0xff # unknown @classmethod - def parse(cls, p): - return cls(struct.unpack("B", p[:1])[0]), p[1:] + def parse(cls, p, typ=u8): + d = typ.parse(p) + return cls(d[0]), d[1] - def pack(self): - return struct.pack("B", self) + def pack(self, typ=u8): + return typ.pack(self) class P2PCmdType(enum.IntEnum): - P2P_JSON_CMD = 0x06a4 # unknown - P2P_SEND_FILE = 0x3a98 # unknown + APP_CMD_START_REC_BROADCASE = 0x0384 # unknown + APP_CMD_STOP_REC_BROADCASE = 0x0385 # unknown + APP_CMD_BIND_BROADCAST = 0x03e8 # unknown + APP_CMD_BIND_SYNC_ACCOUNT_INFO = 0x03e9 # unknown + APP_CMD_UNBIND_ACCOUNT = 0x03ea # unknown + APP_CMD_START_REALTIME_MEDIA = 0x03eb # unknown + APP_CMD_STOP_REALTIME_MEDIA = 0x03ec # unknown + APP_CMD_START_TALKBACK = 0x03ed # unknown + APP_CMD_STOP_TALKBACK = 0x03ee # unknown + APP_CMD_START_VOICECALL = 0x03ef # unknown + APP_CMD_STOP_VOICECALL = 0x03f0 # unknown + APP_CMD_START_RECORD = 0x03f1 # unknown + APP_CMD_STOP_RECORD = 0x03f2 # unknown + APP_CMD_PIR_SWITCH = 0x03f3 # unknown + APP_CMD_CLOSE_PIR = 0x03f4 # unknown + APP_CMD_IRCUT_SWITCH = 0x03f5 # unknown + APP_CMD_CLOSE_IRCUT = 0x03f6 # unknown + APP_CMD_EAS_SWITCH = 0x03f7 # unknown + APP_CMD_CLOSE_EAS = 0x03f8 # unknown + APP_CMD_AUDDEC_SWITCH = 0x03f9 # unknown + APP_CMD_CLOSE_AUDDEC = 0x03fa # unknown + APP_CMD_DEVS_LOCK_SWITCH = 0x03fb # unknown + APP_CMD_DEVS_UNLOCK = 0x03fc # unknown + APP_CMD_RECORD_IMG = 0x03fd # unknown + APP_CMD_RECORD_IMG_STOP = 0x03fe # unknown + APP_CMD_STOP_SHARE = 0x03ff # unknown + APP_CMD_DOWNLOAD_VIDEO = 0x0400 # unknown + APP_CMD_RECORD_VIEW = 0x0401 # unknown + APP_CMD_RECORD_PLAY_CTRL = 0x0402 # unknown + APP_CMD_DELLETE_RECORD = 0x0403 # unknown + APP_CMD_SNAPSHOT = 0x0404 # unknown + APP_CMD_FORMAT_SD = 0x0405 # unknown + APP_CMD_CHANGE_PWD = 0x0406 # unknown + APP_CMD_CHANGE_WIFI_PWD = 0x0407 # unknown + APP_CMD_WIFI_CONFIG = 0x0408 # unknown + APP_CMD_TIME_SYCN = 0x0409 # unknown + APP_CMD_HUB_REBOOT = 0x040a # unknown + APP_CMD_DEVS_SWITCH = 0x040b # unknown + APP_CMD_HUB_TO_FACTORY = 0x040c # unknown + APP_CMD_DEVS_TO_FACTORY = 0x040d # unknown + APP_CMD_DEVS_BIND_BROADCASE = 0x040e # unknown + APP_CMD_DEVS_BIND_NOTIFY = 0x040f # unknown + APP_CMD_DEVS_UNBIND = 0x0410 # unknown + APP_CMD_RECORDDATE_SEARCH = 0x0411 # unknown + APP_CMD_RECORDLIST_SEARCH = 0x0412 # unknown + APP_CMD_GET_UPGRADE_RESULT = 0x0413 # unknown + APP_CMD_P2P_DISCONNECT = 0x0414 # unknown + APP_CMD_DEV_LED_SWITCH = 0x0415 # unknown + APP_CMD_CLOSE_DEV_LED = 0x0416 # unknown + APP_CMD_COLLECT_RECORD = 0x0417 # unknown + APP_CMD_DECOLLECT_RECORD = 0x0418 # unknown + APP_CMD_BATCH_RECORD = 0x0419 # unknown + APP_CMD_STRESS_TEST_OPER = 0x041a # unknown + APP_CMD_DOWNLOAD_CANCEL = 0x041b # unknown + APP_CMD_BIND_SYNC_ACCOUNT_INFO_EX = 0x041e # unknown + APP_CMD_LIVEVIEW_LED_SWITCH = 0x0420 # unknown + APP_CMD_REPAIR_SD = 0x0421 # unknown + APP_CMD_GET_ASEKEY = 0x044c # unknown + APP_CMD_GET_BATTERY = 0x044d # unknown + APP_CMD_SDINFO = 0x044e # unknown + APP_CMD_CAMERA_INFO = 0x044f # unknown + APP_CMD_GET_RECORD_TIME = 0x0450 # unknown + APP_CMD_GET_MDETECT_PARAM = 0x0451 # unknown + APP_CMD_MDETECTINFO = 0x0452 # unknown + APP_CMD_GET_ARMING_INFO = 0x0453 # unknown + APP_CMD_GET_ARMING_STATUS = 0x0454 # unknown + APP_CMD_GET_AUDDEC_INFO = 0x0455 # unknown + APP_CMD_GET_AUDDEC_SENSITIVITY = 0x0456 # unknown + APP_CMD_GET_AUDDE_CSTATUS = 0x0457 # unknown + APP_CMD_GET_MIRRORMODE = 0x0458 # unknown + APP_CMD_GET_IRMODE = 0x0459 # unknown + APP_CMD_GET_IRCUTSENSITIVITY = 0x045a # unknown + APP_CMD_GET_PIRINFO = 0x045b # unknown + APP_CMD_GET_PIRCTRL = 0x045c # unknown + APP_CMD_GET_PIRSENSITIVITY = 0x045d # unknown + APP_CMD_GET_EAS_STATUS = 0x045e # unknown + APP_CMD_GET_CAMERA_LOCK = 0x045f # unknown + APP_CMD_GET_GATEWAY_LOCK = 0x0460 # unknown + APP_CMD_GET_UPDATE_STATUS = 0x0461 # unknown + APP_CMD_GET_ADMIN_PWD = 0x0462 # unknown + APP_CMD_GET_WIFI_PWD = 0x0463 # unknown + APP_CMD_GET_EXCEPTION_LOG = 0x0464 # unknown + APP_CMD_GET_NEWVESION = 0x0465 # unknown + APP_CMD_GET_HUB_TONE_INFO = 0x0466 # unknown + APP_CMD_GET_DEV_TONE_INFO = 0x0467 # unknown + APP_CMD_GET_HUB_NAME = 0x0468 # unknown + APP_CMD_GET_DEVS_NAME = 0x0469 # unknown + APP_CMD_GET_P2P_CONN_STATUS = 0x046a # unknown + APP_CMD_SET_DEV_STORAGE_TYPE = 0x04cc # unknown + APP_CMD_VIDEO_FRAME = 0x0514 # unknown + APP_CMD_AUDIO_FRAME = 0x0515 # unknown + APP_CMD_STREAM_MSG = 0x0516 # unknown + APP_CMD_CONVERT_MP4_OK = 0x0517 # unknown + APP_CMD_DOENLOAD_FINISH = 0x0518 # unknown + APP_CMD_SET_PAYLOAD = 0x0546 # unknown + APP_CMD_NOTIFY_PAYLOAD = 0x0547 # unknown + APP_CMD_MAKER_SET_PAYLOAD = 0x06a4 # unknown + APP_CMD_MAKER_NOTIFY_PAYLOAD = 0x06a5 # unknown + PC_CMD_FILE_RECV = 0x3a98 # unknown + P2P_JSON_CMD = 0x06a4 # unknown + P2P_SEND_FILE = 0x3a98 # unknown @classmethod - def parse(cls, p): - return cls(struct.unpack("B", p[:1])[0]), p[1:] + def parse(cls, p, typ=u16le): + d = typ.parse(p) + return cls(d[0]), d[1] - def pack(self): - return struct.pack("B", self) + def pack(self, typ=u16le): + return typ.pack(self) class P2PSubCmdType(enum.IntEnum): START_LIVE = 0x03e8 # unknown CLOSE_LIVE = 0x03e9 # unknown VIDEO_RECORD_SWITCH = 0x03ea # unknown - LIGHT_STATE_SWITCH = 0x03ab # unknown + LIGHT_STATE_SWITCH = 0x03eb # unknown LIGHT_STATE_GET = 0x03ec # unknown LIVE_MODE_SET = 0x03ed # unknown LIVE_MODE_GET = 0x03ee # unknown @classmethod - def parse(cls, p): - return cls(struct.unpack("B", p[:1])[0]), p[1:] + def parse(cls, p, typ=u16le): + d = typ.parse(p) + return cls(d[0]), d[1] - def pack(self): - return struct.pack("B", self) + def pack(self, typ=u16le): + return typ.pack(self) class FileTransfer(enum.IntEnum): BEGIN = 0x00 # Begin file transfer (sent with metadata) @@ -129,11 +231,12 @@ class FileTransfer(enum.IntEnum): REPLY = 0x80 # Reply from printer @classmethod - def parse(cls, p): - return cls(struct.unpack("B", p[:1])[0]), p[1:] + def parse(cls, p, typ=u8): + d = typ.parse(p) + return cls(d[0]), d[1] - def pack(self): - return struct.pack("B", self) + def pack(self, typ=u8): + return typ.pack(self) class FileTransferReply(enum.IntEnum): OK = 0x00 # Success @@ -143,11 +246,63 @@ class FileTransferReply(enum.IntEnum): ERR_BUSY = 0xff # Printer was not ready to receive @classmethod - def parse(cls, p): - return cls(struct.unpack("B", p[:1])[0]), p[1:] + def parse(cls, p, typ=u8): + d = typ.parse(p) + return cls(d[0]), d[1] + + def pack(self, typ=u8): + return typ.pack(self) + +class Result(enum.IntEnum): + ERROR_P2P_SUCCESSFUL = 0x00000000 # unknown + TFCARD_VOLUME_OVERFLOW = 0xffffff7c # unknown + PARAM_NO_CHANGE = 0xffffff8c # unknown + NOT_FACE = 0xffffff8d # unknown + DEV_BUSY = 0xffffff8e # unknown + DEV_UPDATEING = 0xffffff8f # unknown + HUB_UPDATEING = 0xffffff90 # unknown + OPEN_FILE_FAIL = 0xffffff91 # unknown + INVALID_PARAM = 0xffffff92 # unknown + DEV_OFFLINE = 0xffffff93 # unknown + WAIT_TIMEOUT = 0xffffff94 # unknown + NVALID_PARAM_LEN = 0xffffff95 # unknown + NOT_FIND_DEV = 0xffffff96 # unknown + WRITE_FLASH = 0xffffff97 # unknown + INVALID_ACCOUNT = 0xffffff98 # unknown + INVALID_COMMAND = 0xffffff99 # unknown + MAX_HUB_CONNECT_NUM = 0xffffff9a # unknown + HAVE_CONNECT = 0xffffff9b # unknown + NULL_POINT = 0xffffff9c # unknown + ERROR_P2P_FAIL_TO_CREATE_THREAD = 0xffffffea # unknown + ERROR_P2P_INVALID_APILICENSE = 0xffffffeb # unknown + ERROR_P2P_SESSION_CLOSED_INSUFFICIENT_MEMORY = 0xffffffec # unknown + ERROR_P2P_USER_CONNECT_BREAK = 0xffffffed # unknown + ERROR_P2P_UDP_PORT_BIND_FAILED = 0xffffffee # unknown + ERROR_P2P_MAX_SESSION = 0xffffffef # unknown + ERROR_P2P_USER_LISTEN_BREAK = 0xfffffff0 # unknown + ERROR_P2P_REMOTE_SITE_BUFFER_FULL = 0xfffffff1 # unknown + ERROR_P2P_SESSION_CLOSED_CALLED = 0xfffffff2 # unknown + ERROR_P2P_SESSION_CLOSED_TIMEOUT = 0xfffffff3 # unknown + ERROR_P2P_SESSION_CLOSED_REMOTE = 0xfffffff4 # unknown + ERROR_P2P_INVALID_SESSION_HANDLE = 0xfffffff5 # unknown + ERROR_P2P_NO_RELAY_SERVER_AVAILABLE = 0xfffffff6 # unknown + ERROR_P2P_ID_OUT_OF_DATE = 0xfffffff7 # unknown + ERROR_P2P_INVALID_PREFIX = 0xfffffff8 # unknown + ERROR_P2P_FAIL_TO_RESOLVE_NAME = 0xfffffff9 # unknown + ERROR_P2P_DEVICE_NOT_ONLINE = 0xfffffffa # unknown + ERROR_PPCS_INVALID_PARAMETER = 0xfffffffb # unknown + ERROR_P2P_INVALID_ID = 0xfffffffc # unknown + ERROR_P2P_TIME_OUT = 0xfffffffd # unknown + ERROR_P2P_ALREADY_INITIALIZED = 0xfffffffe # unknown + ERROR_P2P_NOT_INITIALIZED = 0xffffffff # unknown - def pack(self): - return struct.pack("B", self) + @classmethod + def parse(cls, p, typ=u32): + d = typ.parse(p) + return cls(d[0]), d[1] + + def pack(self, typ=u32): + return typ.pack(self) @dataclass @@ -266,7 +421,7 @@ def pack(self): @dataclass class Xzyh(_Xzyh): magic : bytes = field(repr=False, kw_only=True, default=b'XZYH') # unknown - cmd : u16le # Command field (P2PCmdType) + cmd : P2PCmdType # Command field (P2PCmdType) len : u32le # Payload length unk0 : u8 # unknown unk1 : u8 # unknown @@ -280,7 +435,7 @@ class Xzyh(_Xzyh): def parse(cls, p): # not encrypted magic, p = Magic.parse(p, 4, b'XZYH') - cmd, p = u16le.parse(p) + cmd, p = P2PCmdType.parse(p, u16le) len, p = u32le.parse(p) unk0, p = u8.parse(p) unk1, p = u8.parse(p) @@ -294,7 +449,7 @@ def parse(cls, p): def pack(self): p = Magic.pack(self.magic, 4, b'XZYH') - p += u16le.pack(self.cmd) + p += P2PCmdType.pack(self.cmd, u16le) p += u32le.pack(self.len) p += u8.pack(self.unk0) p += u8.pack(self.unk1) diff --git a/libflagship/ppppapi.py b/libflagship/ppppapi.py index ea702688..2d17d398 100644 --- a/libflagship/ppppapi.py +++ b/libflagship/ppppapi.py @@ -4,13 +4,19 @@ import hashlib import logging as log +from enum import Enum from multiprocessing import Pipe from datetime import datetime, timedelta -from threading import Thread, Event +from threading import Thread, Event, Lock from socket import AF_INET from dataclasses import dataclass -from libflagship.pppp import * +from libflagship.cyclic import CyclicU16 +from libflagship.pppp import Type, \ + PktDrw, PktDrwAck, PktClose, PktSessionReady, PktAliveAck, PktDevLgnAckCrc, \ + PktHelloAck, PktP2pRdyAck, PktP2pRdy, PktLanSearch, \ + Host, Message, Xzyh, Aabb, FileTransferReply + PPPP_LAN_PORT = 32108 PPPP_WAN_PORT = 32100 @@ -76,11 +82,27 @@ def __init__(self): self.buf = [] self.rx, self.tx = Pipe(False) - def read(self, size): + def peek(self, size, timeout=None): + # Zero timeout on self.rx.poll() means "wait forever", but we want it to + # mean "no wait", so we emulate that by setting it to 1us. + if timeout == 0.0: + timeout = 0.000001 + + if timeout is not None: + deadline = datetime.now() + timedelta(seconds=timeout) + while len(self.buf) < size: + if timeout and not self.rx.poll(timeout=(deadline - datetime.now()).total_seconds()): + return None self.buf.extend(self.rx.recv()) - res, self.buf = self.buf[:size], self.buf[size:] - return bytes(res) + + return bytes(self.buf[:size]) + + def read(self, size, timeout=None): + res = self.peek(size, timeout) + if res: + self.buf = self.buf[size:] + return res def write(self, data): self.tx.send(data) @@ -88,20 +110,22 @@ def write(self, data): class Channel: - def __init__(self, index, max_in_flight=64): + def __init__(self, index, max_in_flight=64, max_age_warn=128): self.index = index self.rxqueue = {} self.txqueue = [] self.backlog = [] - self.rx_ctr = 0 - self.tx_ctr = 0 - self.tx_ack = 0 + self.rx_ctr = CyclicU16(0) + self.tx_ctr = CyclicU16(0) + self.tx_ack = CyclicU16(0) self.rx = Wire() self.tx = Wire() self.timeout = timedelta(seconds=0.5) self.acks = set() self.event = Event() self.max_in_flight = max_in_flight + self.max_age_warn = max_age_warn + self.lock = Lock() def rx_ack(self, acks): # remove all ACKed packets from transmission queue @@ -120,7 +144,7 @@ def rx_ack(self, acks): def rx_drw(self, index, data): # drop any packets we have already recieved if self.rx_ctr > index: - if self.rx_ctr - index > 100: + if self.max_age_warn and (self.rx_ctr - index > self.max_age_warn): log.warn(f"Dropping old packet: index {index} while expecting {self.rx_ctr}.") return @@ -129,8 +153,9 @@ def rx_drw(self, index, data): # recombine data from queue while self.rx_ctr in self.rxqueue: + data = self.rxqueue[self.rx_ctr] del self.rxqueue[self.rx_ctr] - self.rx_ctr = (self.rx_ctr + 1) & 0xFFFF + self.rx_ctr += 1 self.rx.write(data) def poll(self): @@ -161,8 +186,11 @@ def wait(self): self.event.wait() self.event.clear() - def read(self, nbytes): - return self.rx.read(nbytes) + def peek(self, nbytes, timeout=None): + return self.rx.peek(nbytes, timeout) + + def read(self, nbytes, timeout=None): + return self.rx.read(nbytes, timeout) def write(self, payload, block=True): pdata = payload[:] @@ -175,7 +203,7 @@ def write(self, payload, block=True): # schedule transmission in 1kb chunks data, pdata = pdata[:1024], pdata[1024:] self.backlog.append((deadline, self.tx_ctr, data)) - self.tx_ctr = (self.tx_ctr + 1) & 0xFFFF + self.tx_ctr += 1 tx_ctr_done = self.tx_ctr @@ -190,7 +218,14 @@ def write(self, payload, block=True): return (tx_ctr_start, tx_ctr_done) -class AnkerPPPPApi(Thread): +class PPPPState(Enum): + Idle = 1 + Connecting = 2 + Connected = 3 + Disconnected = 4 + + +class AnkerPPPPBaseApi(Thread): def __init__(self, sock, duid, addr=None): super().__init__() @@ -198,12 +233,12 @@ def __init__(self, sock, duid, addr=None): self.duid = duid self.addr = addr - self.new = True - self.rdy = False + self.state = PPPPState.Idle self.chans = [Channel(n) for n in range(8)] self.running = True self.stopped = Event() + self.dumper = None @classmethod def open(cls, duid, host, port): @@ -225,6 +260,13 @@ def open_broadcast(cls): addr = ("255.255.255.255", PPPP_LAN_PORT) return cls(sock, duid=None, addr=addr) + def connect_lan_search(self): + self.state = PPPPState.Connecting + self.send(PktLanSearch()) + + def set_dumper(self, dumper): + self.dumper = dumper + def stop(self): self.running = False self.stopped.wait() @@ -237,7 +279,7 @@ def run(self): self.process(msg) except TimeoutError: pass - except StopIteration: + except ConnectionResetError: break for idx, ch in enumerate(self.chans): @@ -256,7 +298,7 @@ def process(self, msg): if msg.type == Type.CLOSE: log.error("CLOSE") - raise StopIteration + raise ConnectionResetError elif msg.type == Type.REPORT_SESSION_READY: pkt = PktSessionReady( @@ -294,26 +336,34 @@ def process(self, msg): elif msg.type == Type.P2P_RDY: self.send(PktP2pRdyAck(duid=self.duid, host=self.host)) - - self.new = False - self.rdy = True + self.state = PPPPState.Connected elif msg.type == Type.PUNCH_PKT: - if self.new: + if self.state == PPPPState.Connecting: self.send(PktClose()) self.send(PktP2pRdy(self.duid)) def recv(self, timeout=None): + if self.state in {PPPPState.Idle, PPPPState.Disconnected}: + raise ConnectionError(f"Tried to recv packet in state {self.state}") + self.sock.settimeout(timeout) data, self.addr = self.sock.recvfrom(4096) + if self.dumper: + self.dumper.rx(data, self.addr) msg = Message.parse(data)[0] - log.debug(f"RX <-- {msg}") + log.debug(f"RX <-- {str(msg)[:128]}") return msg def send(self, pkt, addr=None): + if self.state in {PPPPState.Idle, PPPPState.Disconnected}: + raise ConnectionError(f"Tried to send packet in state {self.state}") + resp = pkt.pack() + if self.dumper: + self.dumper.tx(resp, self.addr) msg = Message.parse(resp)[0] - log.debug(f"TX --> {msg}") + log.debug(f"TX --> {str(msg)[:128]}") self.sock.sendto(resp, addr or self.addr) def send_xzyh(self, data, cmd, chan=0, unk0=0, unk1=0, sign_code=0, unk3=0, dev_type=0, block=True): @@ -341,12 +391,29 @@ def send_aabb(self, data, sn=0, pos=0, frametype=0, chan=1, block=True): return self.chans[chan].write(aabb.pack_with_crc(data), block=block) - def recv_xzyh(self, chan=1): + +class AnkerPPPPApi(AnkerPPPPBaseApi): + + def __init__(self, sock, duid, addr=None): + super().__init__(sock, duid, addr) + self.daemon = True + + def recv_xzyh(self, chan=1, timeout=None): fd = self.chans[chan] - xzyh = Xzyh.parse(fd.read(16))[0] - xzyh.data = fd.read(xzyh.len) - return xzyh + with fd.lock: + hdr = fd.peek(16, timeout=timeout) + if not hdr: + return None + + xzyh = Xzyh.parse(hdr)[0] + + data = fd.read(xzyh.len + 16, timeout=timeout) + if not data: + return None + + xzyh.data = data[16:] + return xzyh def recv_aabb(self, chan=1): fd = self.chans[chan] @@ -371,3 +438,20 @@ def recv_aabb_reply(self, chan=1, check=True): def aabb_request(self, data, frametype, pos=0, chan=1, check=True): self.send_aabb(data=data, frametype=frametype, chan=chan, pos=pos) return self.recv_aabb_reply(chan, check) + + +class AnkerPPPPAsyncApi(AnkerPPPPBaseApi): + + def poll(self, timeout=None): + msg = None + try: + msg = self.recv(timeout=timeout) + self.process(msg) + except TimeoutError: + pass + + for idx, ch in enumerate(self.chans): + for pkt in ch.poll(): + self.send(pkt) + + return msg diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..9e6dea2b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +git+https://github.com/chrivers/transwarp.git diff --git a/requirements.txt b/requirements.txt index 1d3cf06f..8e2a025b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,12 @@ paho_mqtt==1.6.1 -pycryptodomex==3.17 -rich==13.3.1 -requests==2.28.2 +pycryptodomex==3.18 +rich==13.3.5 +requests==2.31.0 click==8.1.3 -platformdirs==3.1.1 -git+https://github.com/chrivers/transwarp.git +platformdirs==3.5.1 tinyec==0.4.0 crcmod==1.7 tqdm==4.65.0 -flask==2.2.0 +flask==2.3.2 +flask-sock==0.6.0 +user-agents==2.2.0 diff --git a/specification/mqtt.stf b/specification/mqtt.stf index 2c8286b3..47cb0f1e 100644 --- a/specification/mqtt.stf +++ b/specification/mqtt.stf @@ -62,9 +62,9 @@ enum MqttMsgType ZZ_MQTT_CMD_EVENT_NOTIFY = 0x3e8 # - ZZ_MQTT_CMD_PRINT_SCHEDULE = 0x3a9 + ZZ_MQTT_CMD_PRINT_SCHEDULE = 0x3e9 - # Not implemented? + # Returns firmware version string ZZ_MQTT_CMD_FIRMWARE_VERSION = 0x3ea # Set nozzle temperature in units of 1/100th deg C (i.e.31337 is 313.37C) @@ -121,8 +121,8 @@ enum MqttMsgType # ZZ_MQTT_CMD_ONLINE_NOTIFY = 0x404 - # - ZZ_MQTT_CMD_APP_RECOVER_FACTORY = 0x405 + # Factory reset printer + ZZ_MQTT_CMD_RECOVER_FACTORY = 0x405 # (probably) Enable/disable Bluetooth Low Energy ("ble") radio ZZ_MQTT_CMD_BLE_ONOFF = 0x407 @@ -173,7 +173,16 @@ enum MqttMsgType ZZ_MQTT_CMD_AI_SWITCH = 0x41a # ? - ZZ_STEST_CMD_GCODE_TRANSPOR = 0x7e2 + ZZ_MQTT_CMD_AI_INFO_CHECK = 0x41b + + # ? + ZZ_MQTT_CMD_MODEL_LAYER = 0x41c + + # ? + ZZ_MQTT_CMD_MODEL_DL_PROCESS = 0x41d + + # ? + ZZ_MQTT_CMD_PRINT_MAX_SPEED = 0x41f # ZZ_MQTT_CMD_ALEXA_MSG = 0xbb8 diff --git a/specification/pppp.stf b/specification/pppp.stf index 70f698d8..bfb1b337 100644 --- a/specification/pppp.stf +++ b/specification/pppp.stf @@ -1,4 +1,6 @@ enum Type + @type: u8 + HELLO = 0x00 HELLO_ACK = 0x01 HELLO_TO = 0x02 @@ -74,19 +76,125 @@ enum Type INVALID = 0xFF enum P2PCmdType + @type: u16le + + APP_CMD_START_REC_BROADCASE = 0x384 + APP_CMD_STOP_REC_BROADCASE = 0x385 + APP_CMD_BIND_BROADCAST = 0x3e8 + APP_CMD_BIND_SYNC_ACCOUNT_INFO = 0x3e9 + APP_CMD_UNBIND_ACCOUNT = 0x3ea + APP_CMD_START_REALTIME_MEDIA = 0x3eb + APP_CMD_STOP_REALTIME_MEDIA = 0x3ec + APP_CMD_START_TALKBACK = 0x3ed + APP_CMD_STOP_TALKBACK = 0x3ee + APP_CMD_START_VOICECALL = 0x3ef + APP_CMD_STOP_VOICECALL = 0x3f0 + APP_CMD_START_RECORD = 0x3f1 + APP_CMD_STOP_RECORD = 0x3f2 + APP_CMD_PIR_SWITCH = 0x3f3 + APP_CMD_CLOSE_PIR = 0x3f4 + APP_CMD_IRCUT_SWITCH = 0x3f5 + APP_CMD_CLOSE_IRCUT = 0x3f6 + APP_CMD_EAS_SWITCH = 0x3f7 + APP_CMD_CLOSE_EAS = 0x3f8 + APP_CMD_AUDDEC_SWITCH = 0x3f9 + APP_CMD_CLOSE_AUDDEC = 0x3fa + APP_CMD_DEVS_LOCK_SWITCH = 0x3fb + APP_CMD_DEVS_UNLOCK = 0x3fc + APP_CMD_RECORD_IMG = 0x3fd + APP_CMD_RECORD_IMG_STOP = 0x3fe + APP_CMD_STOP_SHARE = 0x3ff + APP_CMD_DOWNLOAD_VIDEO = 0x400 + APP_CMD_RECORD_VIEW = 0x401 + APP_CMD_RECORD_PLAY_CTRL = 0x402 + APP_CMD_DELLETE_RECORD = 0x403 + APP_CMD_SNAPSHOT = 0x404 + APP_CMD_FORMAT_SD = 0x405 + APP_CMD_CHANGE_PWD = 0x406 + APP_CMD_CHANGE_WIFI_PWD = 0x407 + APP_CMD_WIFI_CONFIG = 0x408 + APP_CMD_TIME_SYCN = 0x409 + APP_CMD_HUB_REBOOT = 0x40a + APP_CMD_DEVS_SWITCH = 0x40b + APP_CMD_HUB_TO_FACTORY = 0x40c + APP_CMD_DEVS_TO_FACTORY = 0x40d + APP_CMD_DEVS_BIND_BROADCASE = 0x40e + APP_CMD_DEVS_BIND_NOTIFY = 0x40f + APP_CMD_DEVS_UNBIND = 0x410 + APP_CMD_RECORDDATE_SEARCH = 0x411 + APP_CMD_RECORDLIST_SEARCH = 0x412 + APP_CMD_GET_UPGRADE_RESULT = 0x413 + APP_CMD_P2P_DISCONNECT = 0x414 + APP_CMD_DEV_LED_SWITCH = 0x415 + APP_CMD_CLOSE_DEV_LED = 0x416 + APP_CMD_COLLECT_RECORD = 0x417 + APP_CMD_DECOLLECT_RECORD = 0x418 + APP_CMD_BATCH_RECORD = 0x419 + APP_CMD_STRESS_TEST_OPER = 0x41a + APP_CMD_DOWNLOAD_CANCEL = 0x41b + APP_CMD_BIND_SYNC_ACCOUNT_INFO_EX = 0x41e + APP_CMD_LIVEVIEW_LED_SWITCH = 0x420 + APP_CMD_REPAIR_SD = 0x421 + APP_CMD_GET_ASEKEY = 0x44c + APP_CMD_GET_BATTERY = 0x44d + APP_CMD_SDINFO = 0x44e + APP_CMD_CAMERA_INFO = 0x44f + APP_CMD_GET_RECORD_TIME = 0x450 + APP_CMD_GET_MDETECT_PARAM = 0x451 + APP_CMD_MDETECTINFO = 0x452 + APP_CMD_GET_ARMING_INFO = 0x453 + APP_CMD_GET_ARMING_STATUS = 0x454 + APP_CMD_GET_AUDDEC_INFO = 0x455 + APP_CMD_GET_AUDDEC_SENSITIVITY = 0x456 + APP_CMD_GET_AUDDE_CSTATUS = 0x457 + APP_CMD_GET_MIRRORMODE = 0x458 + APP_CMD_GET_IRMODE = 0x459 + APP_CMD_GET_IRCUTSENSITIVITY = 0x45a + APP_CMD_GET_PIRINFO = 0x45b + APP_CMD_GET_PIRCTRL = 0x45c + APP_CMD_GET_PIRSENSITIVITY = 0x45d + APP_CMD_GET_EAS_STATUS = 0x45e + APP_CMD_GET_CAMERA_LOCK = 0x45f + APP_CMD_GET_GATEWAY_LOCK = 0x460 + APP_CMD_GET_UPDATE_STATUS = 0x461 + APP_CMD_GET_ADMIN_PWD = 0x462 + APP_CMD_GET_WIFI_PWD = 0x463 + APP_CMD_GET_EXCEPTION_LOG = 0x464 + APP_CMD_GET_NEWVESION = 0x465 + APP_CMD_GET_HUB_TONE_INFO = 0x466 + APP_CMD_GET_DEV_TONE_INFO = 0x467 + APP_CMD_GET_HUB_NAME = 0x468 + APP_CMD_GET_DEVS_NAME = 0x469 + APP_CMD_GET_P2P_CONN_STATUS = 0x46a + APP_CMD_SET_DEV_STORAGE_TYPE = 0x4cc + APP_CMD_VIDEO_FRAME = 0x514 + APP_CMD_AUDIO_FRAME = 0x515 + APP_CMD_STREAM_MSG = 0x516 + APP_CMD_CONVERT_MP4_OK = 0x517 + APP_CMD_DOENLOAD_FINISH = 0x518 + APP_CMD_SET_PAYLOAD = 0x546 + APP_CMD_NOTIFY_PAYLOAD = 0x547 + APP_CMD_MAKER_SET_PAYLOAD = 0x6a4 + APP_CMD_MAKER_NOTIFY_PAYLOAD = 0x6a5 + PC_CMD_FILE_RECV = 0x3a98 + P2P_JSON_CMD = 0x6a4 P2P_SEND_FILE = 0x3a98 enum P2PSubCmdType + @type: u16le + START_LIVE = 0x03e8 CLOSE_LIVE = 0x03e9 VIDEO_RECORD_SWITCH = 0x03ea - LIGHT_STATE_SWITCH = 0x03ab + LIGHT_STATE_SWITCH = 0x03eb LIGHT_STATE_GET = 0x03ec LIVE_MODE_SET = 0x03ed LIVE_MODE_GET = 0x03ee enum FileTransfer + @type: u8 + # Begin file transfer (sent with metadata) BEGIN = 0x00 @@ -103,6 +211,8 @@ enum FileTransfer REPLY = 0x80 enum FileTransferReply + @type: u8 + # Success OK = 0x00 @@ -118,6 +228,51 @@ enum FileTransferReply # Printer was not ready to receive ERR_BUSY = 0xff +enum Result + @type: u32 + + ERROR_P2P_SUCCESSFUL = 0 + TFCARD_VOLUME_OVERFLOW = 0xffffff7c + PARAM_NO_CHANGE = 0xffffff8c + NOT_FACE = 0xffffff8d + DEV_BUSY = 0xffffff8e + DEV_UPDATEING = 0xffffff8f + HUB_UPDATEING = 0xffffff90 + OPEN_FILE_FAIL = 0xffffff91 + INVALID_PARAM = 0xffffff92 + DEV_OFFLINE = 0xffffff93 + WAIT_TIMEOUT = 0xffffff94 + NVALID_PARAM_LEN = 0xffffff95 + NOT_FIND_DEV = 0xffffff96 + WRITE_FLASH = 0xffffff97 + INVALID_ACCOUNT = 0xffffff98 + INVALID_COMMAND = 0xffffff99 + MAX_HUB_CONNECT_NUM = 0xffffff9a + HAVE_CONNECT = 0xffffff9b + NULL_POINT = 0xffffff9c + ERROR_P2P_FAIL_TO_CREATE_THREAD = 0xffffffea + ERROR_P2P_INVALID_APILICENSE = 0xffffffeb + ERROR_P2P_SESSION_CLOSED_INSUFFICIENT_MEMORY = 0xffffffec + ERROR_P2P_USER_CONNECT_BREAK = 0xffffffed + ERROR_P2P_UDP_PORT_BIND_FAILED = 0xffffffee + ERROR_P2P_MAX_SESSION = 0xffffffef + ERROR_P2P_USER_LISTEN_BREAK = 0xfffffff0 + ERROR_P2P_REMOTE_SITE_BUFFER_FULL = 0xfffffff1 + ERROR_P2P_SESSION_CLOSED_CALLED = 0xfffffff2 + ERROR_P2P_SESSION_CLOSED_TIMEOUT = 0xfffffff3 + ERROR_P2P_SESSION_CLOSED_REMOTE = 0xfffffff4 + ERROR_P2P_INVALID_SESSION_HANDLE = 0xfffffff5 + ERROR_P2P_NO_RELAY_SERVER_AVAILABLE = 0xfffffff6 + ERROR_P2P_ID_OUT_OF_DATE = 0xfffffff7 + ERROR_P2P_INVALID_PREFIX = 0xfffffff8 + ERROR_P2P_FAIL_TO_RESOLVE_NAME = 0xfffffff9 + ERROR_P2P_DEVICE_NOT_ONLINE = 0xfffffffa + ERROR_PPCS_INVALID_PARAMETER = 0xfffffffb + ERROR_P2P_INVALID_ID = 0xfffffffc + ERROR_P2P_TIME_OUT = 0xfffffffd + ERROR_P2P_ALREADY_INITIALIZED = 0xfffffffe + ERROR_P2P_NOT_INITIALIZED = 0xffffffff + struct Host pad0: zeroes<1> @@ -151,7 +306,7 @@ struct Xzyh magic: magic<4, 0x585a5948> # Command field (P2PCmdType) - cmd: u16le + cmd: P2PCmdType # Payload length len: u32le diff --git a/examples/ankermake-mqtt.crt b/ssl/ankermake-mqtt.crt similarity index 100% rename from examples/ankermake-mqtt.crt rename to ssl/ankermake-mqtt.crt diff --git a/static/ankersrv.css b/static/ankersrv.css new file mode 100644 index 00000000..658c601d --- /dev/null +++ b/static/ankersrv.css @@ -0,0 +1,104 @@ +/* Color scheme */ + +:root { + --agreen1: #41a03f; + --agreen2: #88f387; + --agreen3: #adf3ac; + --ared1: #a00000; + --ared2: #d40000; +} + +.nav-pills { + --bs-nav-link-color: var(--agreen2); + + --bs-nav-pills-link-active-color: #000000; + --bs-nav-pills-link-active-bg: var(--agreen2); + + --bs-nav-link-hover-color: #000000; + --bs-nav-link-hover-bg: var(--agreen3); +} + +.btn { + --bs-btn-hover-color: #000000; + --bs-btn-hover-bg: var(--agreen3); + --bs-btn-hover-border-color: var(--agreen1); + + --bs-btn-active-color: #000000; + --bs-btn-active-bg: var(--agreen3); +} + +.btn-primary { + --bs-btn-bg: var(--agreen2); + --bs-btn-color: #000000; + --bs-btn-border-color: var(--agreen1); + --bs-btn-active-border-color: var(--agreen1); +} + +.btn-danger { + --bs-btn-color: white; + --bs-btn-bg: var(--ared1); + --bs-btn-active-color: white; + --bs-btn-active-bg: var(--ared2); + --bs-btn-hover-bg: var(--ared2); + --bs-btn-border-color: var(--ared1); + --bs-btn-hover-border-color: var(--ared1); +} + +.progress-bar { + --bs-progress-bar-color: white; + --bs-progress-bar-bg: var(--agreen2); +} + +#progress { + text-shadow: 0 0 7px black, 0 0 7px black; +} + +footer .btn { + --bs-btn-color: var(--agreen2); +} + +footer a { + --bs-link-hover-color-rgb: 255, 255, 255; + --bs-link-color-rgb: 136, 243, 135; +} + +pre, +code { + color: var(--agreen2); + background-color: #292929; +} + +/* Layout */ + +#printProgress { + height: 40px; +} + +.icon { + display: block; + height: 18px; + width: 18px; +} + +.icon-hotend:before { + content: url(img/icon-hotend.svg); +} + +.icon-bed:before { + content: url(img/icon-bed.svg); +} + +.icon-time-passed:before { + content: url(img/icon-time-passed.svg); +} + +.icon-time-remaining:before { + content: url(img/icon-time-remaining.svg); +} + +/* Bootstrap overrides */ + +.img-thumbnail { + border: 0 none; + padding: 0; +} diff --git a/static/ankersrv.js b/static/ankersrv.js new file mode 100644 index 00000000..95274181 --- /dev/null +++ b/static/ankersrv.js @@ -0,0 +1,309 @@ +$(function () { + /** + * Updates the Copywrite year on document ready + */ + $("#copyYear").text(new Date().getFullYear()); + + /** + * Redirect page when modal dialog is shown + */ + var popupModal = document.getElementById("popupModal"); + + popupModal.addEventListener("shown.bs.modal", function (e) { + window.location.href = $("#reload").data("href"); + }); + + /** + * On click of an element with attribute "data-clipboard-src", updates clipboard with text from that element + */ + if (navigator.clipboard) { + /* Clipboard support present: link clipboard icons to source object */ + $("[data-clipboard-src]").each(function(i, elm) { + $(elm).on("click", function () { + const src = $(elm).attr("data-clipboard-src"); + const value = $(src).text(); + navigator.clipboard.writeText(value); + console.log(`Copied ${value} to clipboard`); + }); + }); + } else { + /* Clipboard support missing: remove clipboard icons to minimize confusion */ + $("[data-clipboard-src]").remove(); + }; + + /** + * Initializes bootstrap alerts and sets a timeout for when they should automatically close + */ + $(".alert").each(function (i, alert) { + var bsalert = new bootstrap.Alert(alert); + setTimeout(() => { + bsalert.close(); + }, +alert.getAttribute("data-timeout")); + }); + + /** + * Get temperature from input + * @param {number} temp Temperature in Celsius + * @returns {number} Rounded temperature + */ + function getTemp(temp) { + return Math.round(temp / 100); + } + + /** + * Calculate the percentage between two numbers + * @param {number} layer + * @param {number} total + * @returns {number} percentage + */ + function getPercentage(progress) { + return Math.round(((progress / 100) * 100) / 100); + } + + /** + * Convert time in seconds to hours, minutes, and seconds format + * @param {number} totalseconds + * @returns {string} Formatted time string + */ + function getTime(totalseconds) { + const hours = Math.floor(totalseconds / 3600); + const minutes = Math.floor((totalseconds % 3600) / 60); + const seconds = totalseconds % 60; + + const timeString = + `${hours.toString().padStart(2, "0")}:` + + `${minutes.toString().padStart(2, "0")}:` + + `${seconds.toString().padStart(2, "0")}`; + + return timeString; + } + + /** + * Calculates the AnkerMake M5 Speed ratio ("X-factor") + * @param {number} speed - The speed value in mm/s + * @return {number} The speed factor in units of "X" (50mm/s) + */ + function getSpeedFactor(speed) { + return `X${speed / 50}`; + } + + /** + * AutoWebSocket class + * + * This class wraps a WebSocket, and makes it automatically reconnect if the + * connection is lost. + */ + class AutoWebSocket { + constructor({ + name, + url, + badge=null, + open=null, + close=null, + error=null, + message=null, + binary=false, + reconnect=1000, + }) { + this.name = name; + this.url = url; + this.badge = badge; + this.reconnect = reconnect; + this.open = open; + this.close = close; + this.error = error; + this.message = message; + this.binary = binary; + this.ws = null; + } + + _open() { + $(this.badge).removeClass("text-bg-success text-bg-danger").addClass("text-bg-warning"); + if (this.open) + this.open(this.ws); + } + + _close() { + $(this.badge).removeClass("text-bg-warning text-bg-success").addClass("text-bg-danger"); + console.log(`${this.name} close`); + setTimeout(() => this.connect(), this.reconnect); + if (this.close) + this.close(this.ws); + } + + _error() { + console.log(`${this.name} error`); + this.ws.close(); + if (this.error) + this.error(this.ws); + } + + _message(event) { + $(this.badge).removeClass("text-bg-danger text-bg-warning").addClass("text-bg-success"); + if (this.message) + this.message(event); + } + + connect() { + var ws = this.ws = new WebSocket(this.url); + if (this.binary) + ws.binaryType = "arraybuffer"; + ws.addEventListener("open", this._open.bind(this)); + ws.addEventListener("close", this._close.bind(this)); + ws.addEventListener("error", this._error.bind(this)); + ws.addEventListener("message", this._message.bind(this)); + } + } + + /** + * Auto web sockets + */ + sockets = {}; + + sockets.mqtt = new AutoWebSocket({ + name: "mqtt socket", + url: `ws://${location.host}/ws/mqtt`, + badge: "#badge-mqtt", + + message: function (ev) { + const data = JSON.parse(ev.data); + if (data.commandType == 1001) { + // Returns Print Details + $("#print-name").text(data.name); + $("#time-elapsed").text(getTime(data.totalTime)); + $("#time-remain").text(getTime(data.time)); + const progress = getPercentage(data.progress); + $("#progressbar").attr("aria-valuenow", progress); + $("#progressbar").attr("style", `width: ${progress}%`); + $("#progress").text(`${progress}%`); + } else if (data.commandType == 1003) { + // Returns Nozzle Temp + const current = getTemp(data.currentTemp); + const target = getTemp(data.targetTemp); + $("#nozzle-temp").text(`${current}°C`); + $("#set-nozzle-temp").attr("value", `${target}°C`); + } else if (data.commandType == 1004) { + // Returns Bed Temp + const current = getTemp(data.currentTemp); + const target = getTemp(data.targetTemp); + $("#bed-temp").text(`${current}°C`); + $("#set-bed-temp").attr("value", `${target}°C`); + } else if (data.commandType == 1006) { + // Returns Print Speed + const X = getSpeedFactor(data.value); + $("#print-speed").text(`${data.value}mm/s ${X}`); + } else if (data.commandType == 1052) { + // Returns Layer Info + const layer = `${data.real_print_layer} / ${data.total_layer}`; + $("#print-layer").text(layer); + } else { + console.log("Unhandled mqtt message:", data); + } + }, + + close: function () { + $("#print-name").text(""); + $("#time-elapsed").text("00:00:00"); + $("#time-remain").text("00:00:00"); + $("#progressbar").attr("aria-valuenow", 0); + $("#progressbar").attr("style", "width: 0%"); + $("#progress").text("0%"); + $("#nozzle-temp").text("0°C"); + $("#set-nozzle-temp").attr("value", "0°C"); + $("#bed-temp").text("$0°C"); + $("#set-bed-temp").attr("value", "0°C"); + $("#print-speed").text("0mm/s"); + $("#print-layer").text("0 / 0"); + }, + }); + + /** + * Initializing a new instance of JMuxer for video playback + */ + sockets.video = new AutoWebSocket({ + name: "Video socket", + url: `ws://${location.host}/ws/video`, + badge: "#badge-pppp", + binary: true, + + open: function () { + this.jmuxer = new JMuxer({ + node: "player", + mode: "video", + flushingTime: 0, + fps: 15, + // debug: true, + onReady: function (data) { + console.log(data); + }, + onError: function (data) { + console.log(data); + }, + }); + }, + + message: function (event) { + this.jmuxer.feed({ + video: new Uint8Array(event.data), + }); + }, + + close: function () { + if (!this.jmuxer) + return; + + this.jmuxer.destroy(); + + /* Clear video source (to show loading animation) */ + $("#player").attr("src", ""); + }, + }); + + sockets.ctrl = new AutoWebSocket({ + name: "Control socket", + url: `ws://${location.host}/ws/ctrl`, + badge: "#badge-ctrl", + }); + + /* Only connect websockets if #player element exists in DOM (i.e., if we + * have a configuration). Otherwise we are constantly trying to make + * connections that will never succeed. */ + if ($("#player").length) { + sockets.mqtt.connect(); + sockets.video.connect(); + sockets.ctrl.connect(); + } + + /** + * On click of element with id "light-on", sends JSON data to wsctrl to turn light on + */ + $("#light-on").on("click", function () { + sockets.ctrl.ws.send(JSON.stringify({ light: true })); + return false; + }); + + /** + * On click of element with id "light-off", sends JSON data to wsctrl to turn light off + */ + $("#light-off").on("click", function () { + sockets.ctrl.ws.send(JSON.stringify({ light: false })); + return false; + }); + + /** + * On click of element with id "quality-low", sends JSON data to wsctrl to set video quality to low + */ + $("#quality-low").on("click", function () { + sockets.ctrl.ws.send(JSON.stringify({ quality: 0 })); + return false; + }); + + /** + * On click of element with id "quality-high", sends JSON data to wsctrl to set video quality to high + */ + $("#quality-high").on("click", function () { + sockets.ctrl.ws.send(JSON.stringify({ quality: 1 })); + return false; + }); + +}); diff --git a/static/base.html b/static/base.html new file mode 100644 index 00000000..d92cc10e --- /dev/null +++ b/static/base.html @@ -0,0 +1,86 @@ +{% import "macro.html" as macro -%} + + + + + + {% block title %}{% endblock title %} + + {{ macro.static_css("vendor/bootstrap-5.3.0-alpha3-dist/css/bootstrap.min.css") }} + {{ macro.static_css("vendor/bootstrap-icons-1.10.5/font/bootstrap-icons.min.css") }} + {{ macro.static_css("ankersrv.css") }} + {%- block head %}{% endblock head %} + + + +
+
+ + logo + ankerctl + + +
+
+ + + + + +
+ + {% for category, message in get_flashed_messages(with_categories=true) %} + + {% endfor %} +
+ + +
+ {% block contents %} + {% endblock %} +
+ + +
+
+ {% block footer %} + {% include "footer.html" %} + {% endblock %} +
+
+ {{ macro.static_script("vendor/bootstrap-5.3.0-alpha3-dist/js/bootstrap.bundle.min.js") }} + {{ macro.static_script("vendor/cash.min.js") }} + {{ macro.static_script("vendor/jmuxer.min.js") }} + {{ macro.static_script("ankersrv.js") }} + + diff --git a/static/footer.html b/static/footer.html new file mode 100644 index 00000000..630ae02e --- /dev/null +++ b/static/footer.html @@ -0,0 +1,18 @@ +Copyright ©... + + ankerctl + +
+ + {{ macro.bi_icon("github", "View on Github") }} + diff --git a/static/img/icon-bed.svg b/static/img/icon-bed.svg new file mode 100644 index 00000000..0cc8cea4 --- /dev/null +++ b/static/img/icon-bed.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/img/icon-hotend.svg b/static/img/icon-hotend.svg new file mode 100644 index 00000000..c10ec1a6 --- /dev/null +++ b/static/img/icon-hotend.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/img/icon-time-passed.svg b/static/img/icon-time-passed.svg new file mode 100644 index 00000000..ac677ead --- /dev/null +++ b/static/img/icon-time-passed.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/img/icon-time-remaining.svg b/static/img/icon-time-remaining.svg new file mode 100644 index 00000000..c92ea7c5 --- /dev/null +++ b/static/img/icon-time-remaining.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/static/img/load-screen.svg b/static/img/load-screen.svg new file mode 100644 index 00000000..4783ab29 --- /dev/null +++ b/static/img/load-screen.svg @@ -0,0 +1,184 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/img/logo.svg b/static/img/logo.svg new file mode 100644 index 00000000..b714d38e --- /dev/null +++ b/static/img/logo.svg @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/static/index.html b/static/index.html index 76749b3b..64bffacf 100644 --- a/static/index.html +++ b/static/index.html @@ -1,56 +1,31 @@ - - - - - - ankerctl - - - -
-
-

ankerctl

-

Congratulations on running ankerctl

-
-
-
-
-
-

Connecting PrusaSlicer/SuperSlicer

-
    -
  1. Go to "Printer settings" tab
  2. -
  3. Click "Gear"⚙️ (Add/Edit Physical Printer)
  4. -
  5. Fill in "Descriptive name for the printer" with whatever you like
  6. -
  7. Select your Ankermake printer profile from the dropdown menu
  8. -
  9. Under "Host Type" select "OctoPrint"
  10. -
  11. In "Hostname, IP or URL" fill in with: {{ requestHost }}:{{ requestPort }} 📋
  12. -
  13. Leave "API Key / Password" blank
  14. -
  15. Click "Test" to validate input
  16. -
  17. Click "Ok" to close the window
  18. -
  19. Slice your file as normal
  20. -
  21. Click "G>" button to start direct upload to the printer
  22. -
  23. Click "Upload and print" - the "Upload"-only is not supported
  24. -
  25. Enjoy printing with ankerctl 😉
  26. -
-
Hint: Ctrl+Shift+G will slice and open the upload to printer window -
-
- step 1 - step 2 - step 3 - step 4 -
-
-
- - - - - +{% extends "base.html" %} +{% import "macro.html" as macro %} + +{% block title -%} + ankerctl{% if printer %} - {{ printer.name }} ({{ printer.sn }}){% endif %} +{%- endblock %} + +{% block contents %} + {% if configure %} + {% include "tabs/home.html" %} + {% include "tabs/instructions.html" %} + {% endif %} + + {% include "tabs/setup.html" %} +{% endblock %} + +{% block menu %} + {% if configure %} + {% call macro.menu_button(id="home-tab", target="home", active=configure) %} + {{ macro.bi_icon("house", "Home") }} + {% endcall %} + + {% call macro.menu_button(id="instructions-tab", target="instructions") %} + {{ macro.bi_icon("list-check", "Instructions") }} + {% endcall %} + {% endif %} + + {% call macro.menu_button(id="setup-tab", target="setup", active=not configure) %} + {{ macro.bi_icon("gear-fill", "Setup") }} + {% endcall %} +{% endblock %} diff --git a/static/libflagship.js b/static/libflagship.js new file mode 100644 index 00000000..5cf749df --- /dev/null +++ b/static/libflagship.js @@ -0,0 +1,52 @@ +// ------------------------------------------ +// Generated by Transwarp +// +// THIS FILE IS AUTOMATICALLY GENERATED. +// DO NOT EDIT. ALL CHANGES WILL BE LOST. +// ------------------------------------------ + +const MqttMsgType = { + ZZ_MQTT_CMD_EVENT_NOTIFY : 0x03e8, + ZZ_MQTT_CMD_PRINT_SCHEDULE : 0x03e9, + ZZ_MQTT_CMD_FIRMWARE_VERSION : 0x03ea, + ZZ_MQTT_CMD_NOZZLE_TEMP : 0x03eb, + ZZ_MQTT_CMD_HOTBED_TEMP : 0x03ec, + ZZ_MQTT_CMD_FAN_SPEED : 0x03ed, + ZZ_MQTT_CMD_PRINT_SPEED : 0x03ee, + ZZ_MQTT_CMD_AUTO_LEVELING : 0x03ef, + ZZ_MQTT_CMD_PRINT_CONTROL : 0x03f0, + ZZ_MQTT_CMD_FILE_LIST_REQUEST : 0x03f1, + ZZ_MQTT_CMD_GCODE_FILE_REQUEST : 0x03f2, + ZZ_MQTT_CMD_ALLOW_FIRMWARE_UPDATE : 0x03f3, + ZZ_MQTT_CMD_GCODE_FILE_DOWNLOAD : 0x03fc, + ZZ_MQTT_CMD_Z_AXIS_RECOUP : 0x03fd, + ZZ_MQTT_CMD_EXTRUSION_STEP : 0x03fe, + ZZ_MQTT_CMD_ENTER_OR_QUIT_MATERIEL : 0x03ff, + ZZ_MQTT_CMD_MOVE_STEP : 0x0400, + ZZ_MQTT_CMD_MOVE_DIRECTION : 0x0401, + ZZ_MQTT_CMD_MOVE_ZERO : 0x0402, + ZZ_MQTT_CMD_APP_QUERY_STATUS : 0x0403, + ZZ_MQTT_CMD_ONLINE_NOTIFY : 0x0404, + ZZ_MQTT_CMD_RECOVER_FACTORY : 0x0405, + ZZ_MQTT_CMD_BLE_ONOFF : 0x0407, + ZZ_MQTT_CMD_DELETE_GCODE_FILE : 0x0408, + ZZ_MQTT_CMD_RESET_GCODE_PARAM : 0x0409, + ZZ_MQTT_CMD_DEVICE_NAME_SET : 0x040a, + ZZ_MQTT_CMD_DEVICE_LOG_UPLOAD : 0x040b, + ZZ_MQTT_CMD_ONOFF_MODAL : 0x040c, + ZZ_MQTT_CMD_MOTOR_LOCK : 0x040d, + ZZ_MQTT_CMD_PREHEAT_CONFIG : 0x040e, + ZZ_MQTT_CMD_BREAK_POINT : 0x040f, + ZZ_MQTT_CMD_AI_CALIB : 0x0410, + ZZ_MQTT_CMD_VIDEO_ONOFF : 0x0411, + ZZ_MQTT_CMD_ADVANCED_PARAMETERS : 0x0412, + ZZ_MQTT_CMD_GCODE_COMMAND : 0x0413, + ZZ_MQTT_CMD_PREVIEW_IMAGE_URL : 0x0414, + ZZ_MQTT_CMD_SYSTEM_CHECK : 0x0419, + ZZ_MQTT_CMD_AI_SWITCH : 0x041a, + ZZ_MQTT_CMD_AI_INFO_CHECK : 0x041b, + ZZ_MQTT_CMD_MODEL_LAYER : 0x041c, + ZZ_MQTT_CMD_MODEL_DL_PROCESS : 0x041d, + ZZ_MQTT_CMD_PRINT_MAX_SPEED : 0x041f, + ZZ_MQTT_CMD_ALEXA_MSG : 0x0bb8, +} diff --git a/static/macro.html b/static/macro.html new file mode 100644 index 00000000..3e42d170 --- /dev/null +++ b/static/macro.html @@ -0,0 +1,44 @@ +{% macro static_script(url) -%} + +{%- endmacro %} + +{% macro static_css(url) -%} + +{%- endmacro %} + +{% macro menu_button(id, target, active=False) %} + +{% endmacro %} + +{%- macro bi_icon(icon, text="", class="") -%} + {%- if text -%} + {{text}} + {%- else -%} + + {%- endif -%} +{%- endmacro -%} + +{% macro clipboard_button(src) %} + +{% endmacro %} diff --git a/static/tabs/home.html b/static/tabs/home.html new file mode 100644 index 00000000..5e62b406 --- /dev/null +++ b/static/tabs/home.html @@ -0,0 +1,227 @@ +
+
+
+
+ + +
+
+
+ {% if printer %} +
+ Connection +
+
+
+ {{ printer.name }} ({{ printer.sn }}) +
+ MQTT + PPPP + CTRL +
+ {% endif %} +
Video Controls
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+
Temperature
+
+
+
+
+
+ +
+ + 0°C + + +
+
+
+
+
+ +
+ + 0°C + + +
+
+
+
+
Print Progress
+
+
+
+
+ + + +
+
+
+
+
+
+ + + 00:00:00 + +
+
+
+
+ + + 00:00:00 + +
+
+
+
+
+
+
+ + 0% + +
+
+
+
+
+
+
+ + + 0/0 + +
+
+
+
+ + + 0mm/s + +
+
+
+
+
+
+
+
+
diff --git a/static/tabs/instructions.html b/static/tabs/instructions.html new file mode 100644 index 00000000..25d3c876 --- /dev/null +++ b/static/tabs/instructions.html @@ -0,0 +1,39 @@ +
+
+
+
+

Connecting PrusaSlicer/SuperSlicer

+
    +
  1. Go to "Printer settings" tab
  2. +
  3. Click the "Gear" icon {{ macro.bi_icon("gear-fill") }} + (Add/Edit Physical Printer)
  4. +
  5. Fill in "Descriptive name for the printer" with whatever you like
  6. +
  7. Select your Ankermake printer instructions from the dropdown menu
  8. +
  9. Under "Host Type" select "OctoPrint"
  10. +
  11. In "Hostname, IP or URL" fill in with: + {{ request_host }}:{{ request_port }} + {{ macro.clipboard_button("#octoPrintHost") }} +
  12. +
  13. Leave "API Key / Password" blank
  14. +
  15. Click "Test" to validate input
  16. +
  17. Click "Ok" to close the window
  18. +
  19. Slice your file as normal
  20. +
  21. Click "G{{- macro.bi_icon("caret-right-fill") -}}" button to start + direct upload to the printer
  22. +
  23. Click "Upload and print" - the "Upload"-only is not supported
  24. + +
  25. Enjoy printing with ankerctl 😉
  26. +
+ + Hint: Ctrl+Shift+G will slice and open the upload to printer window + +
+
+ step 1 + step 2 + step 3 + step 4 +
+
+
+
diff --git a/static/tabs/setup.html b/static/tabs/setup.html new file mode 100644 index 00000000..5026cec1 --- /dev/null +++ b/static/tabs/setup.html @@ -0,0 +1,74 @@ +
+
+
+
+
+
+
+ Upload AnkerMake Login File +
+ +
+ + +
+ + +
+
+ +
+
+
+ Reload Ankerctl Services +
+
+

+ If you are experiencing issues with printing or viewing the video feed reloading + the Ankerctl services will often resolve these issues. +

+ +
+
+
+
+
+
+
+ AnkerMake M5 Config +
+
+
{{ anker_config }}
+
+
+
+
+
+
diff --git a/static/vendor/bootstrap-icons-1.10.5/font/bootstrap-icons.min.css b/static/vendor/bootstrap-icons-1.10.5/font/bootstrap-icons.min.css new file mode 100644 index 00000000..088ba569 --- /dev/null +++ b/static/vendor/bootstrap-icons-1.10.5/font/bootstrap-icons.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap Icons v1.10.5 (https://icons.getbootstrap.com/) + * Copyright 2019-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?1fa40e8900654d2863d011707b9fb6f2") format("woff2"),url("fonts/bootstrap-icons.woff?1fa40e8900654d2863d011707b9fb6f2") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\f164"}.bi-badge-cc::before{content:"\f165"}.bi-badge-hd-fill::before{content:"\f166"}.bi-badge-hd::before{content:"\f167"}.bi-badge-tm-fill::before{content:"\f168"}.bi-badge-tm::before{content:"\f169"}.bi-badge-vo-fill::before{content:"\f16a"}.bi-badge-vo::before{content:"\f16b"}.bi-badge-vr-fill::before{content:"\f16c"}.bi-badge-vr::before{content:"\f16d"}.bi-badge-wc-fill::before{content:"\f16e"}.bi-badge-wc::before{content:"\f16f"}.bi-bag-check-fill::before{content:"\f170"}.bi-bag-check::before{content:"\f171"}.bi-bag-dash-fill::before{content:"\f172"}.bi-bag-dash::before{content:"\f173"}.bi-bag-fill::before{content:"\f174"}.bi-bag-plus-fill::before{content:"\f175"}.bi-bag-plus::before{content:"\f176"}.bi-bag-x-fill::before{content:"\f177"}.bi-bag-x::before{content:"\f178"}.bi-bag::before{content:"\f179"}.bi-bar-chart-fill::before{content:"\f17a"}.bi-bar-chart-line-fill::before{content:"\f17b"}.bi-bar-chart-line::before{content:"\f17c"}.bi-bar-chart-steps::before{content:"\f17d"}.bi-bar-chart::before{content:"\f17e"}.bi-basket-fill::before{content:"\f17f"}.bi-basket::before{content:"\f180"}.bi-basket2-fill::before{content:"\f181"}.bi-basket2::before{content:"\f182"}.bi-basket3-fill::before{content:"\f183"}.bi-basket3::before{content:"\f184"}.bi-battery-charging::before{content:"\f185"}.bi-battery-full::before{content:"\f186"}.bi-battery-half::before{content:"\f187"}.bi-battery::before{content:"\f188"}.bi-bell-fill::before{content:"\f189"}.bi-bell::before{content:"\f18a"}.bi-bezier::before{content:"\f18b"}.bi-bezier2::before{content:"\f18c"}.bi-bicycle::before{content:"\f18d"}.bi-binoculars-fill::before{content:"\f18e"}.bi-binoculars::before{content:"\f18f"}.bi-blockquote-left::before{content:"\f190"}.bi-blockquote-right::before{content:"\f191"}.bi-book-fill::before{content:"\f192"}.bi-book-half::before{content:"\f193"}.bi-book::before{content:"\f194"}.bi-bookmark-check-fill::before{content:"\f195"}.bi-bookmark-check::before{content:"\f196"}.bi-bookmark-dash-fill::before{content:"\f197"}.bi-bookmark-dash::before{content:"\f198"}.bi-bookmark-fill::before{content:"\f199"}.bi-bookmark-heart-fill::before{content:"\f19a"}.bi-bookmark-heart::before{content:"\f19b"}.bi-bookmark-plus-fill::before{content:"\f19c"}.bi-bookmark-plus::before{content:"\f19d"}.bi-bookmark-star-fill::before{content:"\f19e"}.bi-bookmark-star::before{content:"\f19f"}.bi-bookmark-x-fill::before{content:"\f1a0"}.bi-bookmark-x::before{content:"\f1a1"}.bi-bookmark::before{content:"\f1a2"}.bi-bookmarks-fill::before{content:"\f1a3"}.bi-bookmarks::before{content:"\f1a4"}.bi-bookshelf::before{content:"\f1a5"}.bi-bootstrap-fill::before{content:"\f1a6"}.bi-bootstrap-reboot::before{content:"\f1a7"}.bi-bootstrap::before{content:"\f1a8"}.bi-border-all::before{content:"\f1a9"}.bi-border-bottom::before{content:"\f1aa"}.bi-border-center::before{content:"\f1ab"}.bi-border-inner::before{content:"\f1ac"}.bi-border-left::before{content:"\f1ad"}.bi-border-middle::before{content:"\f1ae"}.bi-border-outer::before{content:"\f1af"}.bi-border-right::before{content:"\f1b0"}.bi-border-style::before{content:"\f1b1"}.bi-border-top::before{content:"\f1b2"}.bi-border-width::before{content:"\f1b3"}.bi-border::before{content:"\f1b4"}.bi-bounding-box-circles::before{content:"\f1b5"}.bi-bounding-box::before{content:"\f1b6"}.bi-box-arrow-down-left::before{content:"\f1b7"}.bi-box-arrow-down-right::before{content:"\f1b8"}.bi-box-arrow-down::before{content:"\f1b9"}.bi-box-arrow-in-down-left::before{content:"\f1ba"}.bi-box-arrow-in-down-right::before{content:"\f1bb"}.bi-box-arrow-in-down::before{content:"\f1bc"}.bi-box-arrow-in-left::before{content:"\f1bd"}.bi-box-arrow-in-right::before{content:"\f1be"}.bi-box-arrow-in-up-left::before{content:"\f1bf"}.bi-box-arrow-in-up-right::before{content:"\f1c0"}.bi-box-arrow-in-up::before{content:"\f1c1"}.bi-box-arrow-left::before{content:"\f1c2"}.bi-box-arrow-right::before{content:"\f1c3"}.bi-box-arrow-up-left::before{content:"\f1c4"}.bi-box-arrow-up-right::before{content:"\f1c5"}.bi-box-arrow-up::before{content:"\f1c6"}.bi-box-seam::before{content:"\f1c7"}.bi-box::before{content:"\f1c8"}.bi-braces::before{content:"\f1c9"}.bi-bricks::before{content:"\f1ca"}.bi-briefcase-fill::before{content:"\f1cb"}.bi-briefcase::before{content:"\f1cc"}.bi-brightness-alt-high-fill::before{content:"\f1cd"}.bi-brightness-alt-high::before{content:"\f1ce"}.bi-brightness-alt-low-fill::before{content:"\f1cf"}.bi-brightness-alt-low::before{content:"\f1d0"}.bi-brightness-high-fill::before{content:"\f1d1"}.bi-brightness-high::before{content:"\f1d2"}.bi-brightness-low-fill::before{content:"\f1d3"}.bi-brightness-low::before{content:"\f1d4"}.bi-broadcast-pin::before{content:"\f1d5"}.bi-broadcast::before{content:"\f1d6"}.bi-brush-fill::before{content:"\f1d7"}.bi-brush::before{content:"\f1d8"}.bi-bucket-fill::before{content:"\f1d9"}.bi-bucket::before{content:"\f1da"}.bi-bug-fill::before{content:"\f1db"}.bi-bug::before{content:"\f1dc"}.bi-building::before{content:"\f1dd"}.bi-bullseye::before{content:"\f1de"}.bi-calculator-fill::before{content:"\f1df"}.bi-calculator::before{content:"\f1e0"}.bi-calendar-check-fill::before{content:"\f1e1"}.bi-calendar-check::before{content:"\f1e2"}.bi-calendar-date-fill::before{content:"\f1e3"}.bi-calendar-date::before{content:"\f1e4"}.bi-calendar-day-fill::before{content:"\f1e5"}.bi-calendar-day::before{content:"\f1e6"}.bi-calendar-event-fill::before{content:"\f1e7"}.bi-calendar-event::before{content:"\f1e8"}.bi-calendar-fill::before{content:"\f1e9"}.bi-calendar-minus-fill::before{content:"\f1ea"}.bi-calendar-minus::before{content:"\f1eb"}.bi-calendar-month-fill::before{content:"\f1ec"}.bi-calendar-month::before{content:"\f1ed"}.bi-calendar-plus-fill::before{content:"\f1ee"}.bi-calendar-plus::before{content:"\f1ef"}.bi-calendar-range-fill::before{content:"\f1f0"}.bi-calendar-range::before{content:"\f1f1"}.bi-calendar-week-fill::before{content:"\f1f2"}.bi-calendar-week::before{content:"\f1f3"}.bi-calendar-x-fill::before{content:"\f1f4"}.bi-calendar-x::before{content:"\f1f5"}.bi-calendar::before{content:"\f1f6"}.bi-calendar2-check-fill::before{content:"\f1f7"}.bi-calendar2-check::before{content:"\f1f8"}.bi-calendar2-date-fill::before{content:"\f1f9"}.bi-calendar2-date::before{content:"\f1fa"}.bi-calendar2-day-fill::before{content:"\f1fb"}.bi-calendar2-day::before{content:"\f1fc"}.bi-calendar2-event-fill::before{content:"\f1fd"}.bi-calendar2-event::before{content:"\f1fe"}.bi-calendar2-fill::before{content:"\f1ff"}.bi-calendar2-minus-fill::before{content:"\f200"}.bi-calendar2-minus::before{content:"\f201"}.bi-calendar2-month-fill::before{content:"\f202"}.bi-calendar2-month::before{content:"\f203"}.bi-calendar2-plus-fill::before{content:"\f204"}.bi-calendar2-plus::before{content:"\f205"}.bi-calendar2-range-fill::before{content:"\f206"}.bi-calendar2-range::before{content:"\f207"}.bi-calendar2-week-fill::before{content:"\f208"}.bi-calendar2-week::before{content:"\f209"}.bi-calendar2-x-fill::before{content:"\f20a"}.bi-calendar2-x::before{content:"\f20b"}.bi-calendar2::before{content:"\f20c"}.bi-calendar3-event-fill::before{content:"\f20d"}.bi-calendar3-event::before{content:"\f20e"}.bi-calendar3-fill::before{content:"\f20f"}.bi-calendar3-range-fill::before{content:"\f210"}.bi-calendar3-range::before{content:"\f211"}.bi-calendar3-week-fill::before{content:"\f212"}.bi-calendar3-week::before{content:"\f213"}.bi-calendar3::before{content:"\f214"}.bi-calendar4-event::before{content:"\f215"}.bi-calendar4-range::before{content:"\f216"}.bi-calendar4-week::before{content:"\f217"}.bi-calendar4::before{content:"\f218"}.bi-camera-fill::before{content:"\f219"}.bi-camera-reels-fill::before{content:"\f21a"}.bi-camera-reels::before{content:"\f21b"}.bi-camera-video-fill::before{content:"\f21c"}.bi-camera-video-off-fill::before{content:"\f21d"}.bi-camera-video-off::before{content:"\f21e"}.bi-camera-video::before{content:"\f21f"}.bi-camera::before{content:"\f220"}.bi-camera2::before{content:"\f221"}.bi-capslock-fill::before{content:"\f222"}.bi-capslock::before{content:"\f223"}.bi-card-checklist::before{content:"\f224"}.bi-card-heading::before{content:"\f225"}.bi-card-image::before{content:"\f226"}.bi-card-list::before{content:"\f227"}.bi-card-text::before{content:"\f228"}.bi-caret-down-fill::before{content:"\f229"}.bi-caret-down-square-fill::before{content:"\f22a"}.bi-caret-down-square::before{content:"\f22b"}.bi-caret-down::before{content:"\f22c"}.bi-caret-left-fill::before{content:"\f22d"}.bi-caret-left-square-fill::before{content:"\f22e"}.bi-caret-left-square::before{content:"\f22f"}.bi-caret-left::before{content:"\f230"}.bi-caret-right-fill::before{content:"\f231"}.bi-caret-right-square-fill::before{content:"\f232"}.bi-caret-right-square::before{content:"\f233"}.bi-caret-right::before{content:"\f234"}.bi-caret-up-fill::before{content:"\f235"}.bi-caret-up-square-fill::before{content:"\f236"}.bi-caret-up-square::before{content:"\f237"}.bi-caret-up::before{content:"\f238"}.bi-cart-check-fill::before{content:"\f239"}.bi-cart-check::before{content:"\f23a"}.bi-cart-dash-fill::before{content:"\f23b"}.bi-cart-dash::before{content:"\f23c"}.bi-cart-fill::before{content:"\f23d"}.bi-cart-plus-fill::before{content:"\f23e"}.bi-cart-plus::before{content:"\f23f"}.bi-cart-x-fill::before{content:"\f240"}.bi-cart-x::before{content:"\f241"}.bi-cart::before{content:"\f242"}.bi-cart2::before{content:"\f243"}.bi-cart3::before{content:"\f244"}.bi-cart4::before{content:"\f245"}.bi-cash-stack::before{content:"\f246"}.bi-cash::before{content:"\f247"}.bi-cast::before{content:"\f248"}.bi-chat-dots-fill::before{content:"\f249"}.bi-chat-dots::before{content:"\f24a"}.bi-chat-fill::before{content:"\f24b"}.bi-chat-left-dots-fill::before{content:"\f24c"}.bi-chat-left-dots::before{content:"\f24d"}.bi-chat-left-fill::before{content:"\f24e"}.bi-chat-left-quote-fill::before{content:"\f24f"}.bi-chat-left-quote::before{content:"\f250"}.bi-chat-left-text-fill::before{content:"\f251"}.bi-chat-left-text::before{content:"\f252"}.bi-chat-left::before{content:"\f253"}.bi-chat-quote-fill::before{content:"\f254"}.bi-chat-quote::before{content:"\f255"}.bi-chat-right-dots-fill::before{content:"\f256"}.bi-chat-right-dots::before{content:"\f257"}.bi-chat-right-fill::before{content:"\f258"}.bi-chat-right-quote-fill::before{content:"\f259"}.bi-chat-right-quote::before{content:"\f25a"}.bi-chat-right-text-fill::before{content:"\f25b"}.bi-chat-right-text::before{content:"\f25c"}.bi-chat-right::before{content:"\f25d"}.bi-chat-square-dots-fill::before{content:"\f25e"}.bi-chat-square-dots::before{content:"\f25f"}.bi-chat-square-fill::before{content:"\f260"}.bi-chat-square-quote-fill::before{content:"\f261"}.bi-chat-square-quote::before{content:"\f262"}.bi-chat-square-text-fill::before{content:"\f263"}.bi-chat-square-text::before{content:"\f264"}.bi-chat-square::before{content:"\f265"}.bi-chat-text-fill::before{content:"\f266"}.bi-chat-text::before{content:"\f267"}.bi-chat::before{content:"\f268"}.bi-check-all::before{content:"\f269"}.bi-check-circle-fill::before{content:"\f26a"}.bi-check-circle::before{content:"\f26b"}.bi-check-square-fill::before{content:"\f26c"}.bi-check-square::before{content:"\f26d"}.bi-check::before{content:"\f26e"}.bi-check2-all::before{content:"\f26f"}.bi-check2-circle::before{content:"\f270"}.bi-check2-square::before{content:"\f271"}.bi-check2::before{content:"\f272"}.bi-chevron-bar-contract::before{content:"\f273"}.bi-chevron-bar-down::before{content:"\f274"}.bi-chevron-bar-expand::before{content:"\f275"}.bi-chevron-bar-left::before{content:"\f276"}.bi-chevron-bar-right::before{content:"\f277"}.bi-chevron-bar-up::before{content:"\f278"}.bi-chevron-compact-down::before{content:"\f279"}.bi-chevron-compact-left::before{content:"\f27a"}.bi-chevron-compact-right::before{content:"\f27b"}.bi-chevron-compact-up::before{content:"\f27c"}.bi-chevron-contract::before{content:"\f27d"}.bi-chevron-double-down::before{content:"\f27e"}.bi-chevron-double-left::before{content:"\f27f"}.bi-chevron-double-right::before{content:"\f280"}.bi-chevron-double-up::before{content:"\f281"}.bi-chevron-down::before{content:"\f282"}.bi-chevron-expand::before{content:"\f283"}.bi-chevron-left::before{content:"\f284"}.bi-chevron-right::before{content:"\f285"}.bi-chevron-up::before{content:"\f286"}.bi-circle-fill::before{content:"\f287"}.bi-circle-half::before{content:"\f288"}.bi-circle-square::before{content:"\f289"}.bi-circle::before{content:"\f28a"}.bi-clipboard-check::before{content:"\f28b"}.bi-clipboard-data::before{content:"\f28c"}.bi-clipboard-minus::before{content:"\f28d"}.bi-clipboard-plus::before{content:"\f28e"}.bi-clipboard-x::before{content:"\f28f"}.bi-clipboard::before{content:"\f290"}.bi-clock-fill::before{content:"\f291"}.bi-clock-history::before{content:"\f292"}.bi-clock::before{content:"\f293"}.bi-cloud-arrow-down-fill::before{content:"\f294"}.bi-cloud-arrow-down::before{content:"\f295"}.bi-cloud-arrow-up-fill::before{content:"\f296"}.bi-cloud-arrow-up::before{content:"\f297"}.bi-cloud-check-fill::before{content:"\f298"}.bi-cloud-check::before{content:"\f299"}.bi-cloud-download-fill::before{content:"\f29a"}.bi-cloud-download::before{content:"\f29b"}.bi-cloud-drizzle-fill::before{content:"\f29c"}.bi-cloud-drizzle::before{content:"\f29d"}.bi-cloud-fill::before{content:"\f29e"}.bi-cloud-fog-fill::before{content:"\f29f"}.bi-cloud-fog::before{content:"\f2a0"}.bi-cloud-fog2-fill::before{content:"\f2a1"}.bi-cloud-fog2::before{content:"\f2a2"}.bi-cloud-hail-fill::before{content:"\f2a3"}.bi-cloud-hail::before{content:"\f2a4"}.bi-cloud-haze-fill::before{content:"\f2a6"}.bi-cloud-haze::before{content:"\f2a7"}.bi-cloud-haze2-fill::before{content:"\f2a8"}.bi-cloud-lightning-fill::before{content:"\f2a9"}.bi-cloud-lightning-rain-fill::before{content:"\f2aa"}.bi-cloud-lightning-rain::before{content:"\f2ab"}.bi-cloud-lightning::before{content:"\f2ac"}.bi-cloud-minus-fill::before{content:"\f2ad"}.bi-cloud-minus::before{content:"\f2ae"}.bi-cloud-moon-fill::before{content:"\f2af"}.bi-cloud-moon::before{content:"\f2b0"}.bi-cloud-plus-fill::before{content:"\f2b1"}.bi-cloud-plus::before{content:"\f2b2"}.bi-cloud-rain-fill::before{content:"\f2b3"}.bi-cloud-rain-heavy-fill::before{content:"\f2b4"}.bi-cloud-rain-heavy::before{content:"\f2b5"}.bi-cloud-rain::before{content:"\f2b6"}.bi-cloud-slash-fill::before{content:"\f2b7"}.bi-cloud-slash::before{content:"\f2b8"}.bi-cloud-sleet-fill::before{content:"\f2b9"}.bi-cloud-sleet::before{content:"\f2ba"}.bi-cloud-snow-fill::before{content:"\f2bb"}.bi-cloud-snow::before{content:"\f2bc"}.bi-cloud-sun-fill::before{content:"\f2bd"}.bi-cloud-sun::before{content:"\f2be"}.bi-cloud-upload-fill::before{content:"\f2bf"}.bi-cloud-upload::before{content:"\f2c0"}.bi-cloud::before{content:"\f2c1"}.bi-clouds-fill::before{content:"\f2c2"}.bi-clouds::before{content:"\f2c3"}.bi-cloudy-fill::before{content:"\f2c4"}.bi-cloudy::before{content:"\f2c5"}.bi-code-slash::before{content:"\f2c6"}.bi-code-square::before{content:"\f2c7"}.bi-code::before{content:"\f2c8"}.bi-collection-fill::before{content:"\f2c9"}.bi-collection-play-fill::before{content:"\f2ca"}.bi-collection-play::before{content:"\f2cb"}.bi-collection::before{content:"\f2cc"}.bi-columns-gap::before{content:"\f2cd"}.bi-columns::before{content:"\f2ce"}.bi-command::before{content:"\f2cf"}.bi-compass-fill::before{content:"\f2d0"}.bi-compass::before{content:"\f2d1"}.bi-cone-striped::before{content:"\f2d2"}.bi-cone::before{content:"\f2d3"}.bi-controller::before{content:"\f2d4"}.bi-cpu-fill::before{content:"\f2d5"}.bi-cpu::before{content:"\f2d6"}.bi-credit-card-2-back-fill::before{content:"\f2d7"}.bi-credit-card-2-back::before{content:"\f2d8"}.bi-credit-card-2-front-fill::before{content:"\f2d9"}.bi-credit-card-2-front::before{content:"\f2da"}.bi-credit-card-fill::before{content:"\f2db"}.bi-credit-card::before{content:"\f2dc"}.bi-crop::before{content:"\f2dd"}.bi-cup-fill::before{content:"\f2de"}.bi-cup-straw::before{content:"\f2df"}.bi-cup::before{content:"\f2e0"}.bi-cursor-fill::before{content:"\f2e1"}.bi-cursor-text::before{content:"\f2e2"}.bi-cursor::before{content:"\f2e3"}.bi-dash-circle-dotted::before{content:"\f2e4"}.bi-dash-circle-fill::before{content:"\f2e5"}.bi-dash-circle::before{content:"\f2e6"}.bi-dash-square-dotted::before{content:"\f2e7"}.bi-dash-square-fill::before{content:"\f2e8"}.bi-dash-square::before{content:"\f2e9"}.bi-dash::before{content:"\f2ea"}.bi-diagram-2-fill::before{content:"\f2eb"}.bi-diagram-2::before{content:"\f2ec"}.bi-diagram-3-fill::before{content:"\f2ed"}.bi-diagram-3::before{content:"\f2ee"}.bi-diamond-fill::before{content:"\f2ef"}.bi-diamond-half::before{content:"\f2f0"}.bi-diamond::before{content:"\f2f1"}.bi-dice-1-fill::before{content:"\f2f2"}.bi-dice-1::before{content:"\f2f3"}.bi-dice-2-fill::before{content:"\f2f4"}.bi-dice-2::before{content:"\f2f5"}.bi-dice-3-fill::before{content:"\f2f6"}.bi-dice-3::before{content:"\f2f7"}.bi-dice-4-fill::before{content:"\f2f8"}.bi-dice-4::before{content:"\f2f9"}.bi-dice-5-fill::before{content:"\f2fa"}.bi-dice-5::before{content:"\f2fb"}.bi-dice-6-fill::before{content:"\f2fc"}.bi-dice-6::before{content:"\f2fd"}.bi-disc-fill::before{content:"\f2fe"}.bi-disc::before{content:"\f2ff"}.bi-discord::before{content:"\f300"}.bi-display-fill::before{content:"\f301"}.bi-display::before{content:"\f302"}.bi-distribute-horizontal::before{content:"\f303"}.bi-distribute-vertical::before{content:"\f304"}.bi-door-closed-fill::before{content:"\f305"}.bi-door-closed::before{content:"\f306"}.bi-door-open-fill::before{content:"\f307"}.bi-door-open::before{content:"\f308"}.bi-dot::before{content:"\f309"}.bi-download::before{content:"\f30a"}.bi-droplet-fill::before{content:"\f30b"}.bi-droplet-half::before{content:"\f30c"}.bi-droplet::before{content:"\f30d"}.bi-earbuds::before{content:"\f30e"}.bi-easel-fill::before{content:"\f30f"}.bi-easel::before{content:"\f310"}.bi-egg-fill::before{content:"\f311"}.bi-egg-fried::before{content:"\f312"}.bi-egg::before{content:"\f313"}.bi-eject-fill::before{content:"\f314"}.bi-eject::before{content:"\f315"}.bi-emoji-angry-fill::before{content:"\f316"}.bi-emoji-angry::before{content:"\f317"}.bi-emoji-dizzy-fill::before{content:"\f318"}.bi-emoji-dizzy::before{content:"\f319"}.bi-emoji-expressionless-fill::before{content:"\f31a"}.bi-emoji-expressionless::before{content:"\f31b"}.bi-emoji-frown-fill::before{content:"\f31c"}.bi-emoji-frown::before{content:"\f31d"}.bi-emoji-heart-eyes-fill::before{content:"\f31e"}.bi-emoji-heart-eyes::before{content:"\f31f"}.bi-emoji-laughing-fill::before{content:"\f320"}.bi-emoji-laughing::before{content:"\f321"}.bi-emoji-neutral-fill::before{content:"\f322"}.bi-emoji-neutral::before{content:"\f323"}.bi-emoji-smile-fill::before{content:"\f324"}.bi-emoji-smile-upside-down-fill::before{content:"\f325"}.bi-emoji-smile-upside-down::before{content:"\f326"}.bi-emoji-smile::before{content:"\f327"}.bi-emoji-sunglasses-fill::before{content:"\f328"}.bi-emoji-sunglasses::before{content:"\f329"}.bi-emoji-wink-fill::before{content:"\f32a"}.bi-emoji-wink::before{content:"\f32b"}.bi-envelope-fill::before{content:"\f32c"}.bi-envelope-open-fill::before{content:"\f32d"}.bi-envelope-open::before{content:"\f32e"}.bi-envelope::before{content:"\f32f"}.bi-eraser-fill::before{content:"\f330"}.bi-eraser::before{content:"\f331"}.bi-exclamation-circle-fill::before{content:"\f332"}.bi-exclamation-circle::before{content:"\f333"}.bi-exclamation-diamond-fill::before{content:"\f334"}.bi-exclamation-diamond::before{content:"\f335"}.bi-exclamation-octagon-fill::before{content:"\f336"}.bi-exclamation-octagon::before{content:"\f337"}.bi-exclamation-square-fill::before{content:"\f338"}.bi-exclamation-square::before{content:"\f339"}.bi-exclamation-triangle-fill::before{content:"\f33a"}.bi-exclamation-triangle::before{content:"\f33b"}.bi-exclamation::before{content:"\f33c"}.bi-exclude::before{content:"\f33d"}.bi-eye-fill::before{content:"\f33e"}.bi-eye-slash-fill::before{content:"\f33f"}.bi-eye-slash::before{content:"\f340"}.bi-eye::before{content:"\f341"}.bi-eyedropper::before{content:"\f342"}.bi-eyeglasses::before{content:"\f343"}.bi-facebook::before{content:"\f344"}.bi-file-arrow-down-fill::before{content:"\f345"}.bi-file-arrow-down::before{content:"\f346"}.bi-file-arrow-up-fill::before{content:"\f347"}.bi-file-arrow-up::before{content:"\f348"}.bi-file-bar-graph-fill::before{content:"\f349"}.bi-file-bar-graph::before{content:"\f34a"}.bi-file-binary-fill::before{content:"\f34b"}.bi-file-binary::before{content:"\f34c"}.bi-file-break-fill::before{content:"\f34d"}.bi-file-break::before{content:"\f34e"}.bi-file-check-fill::before{content:"\f34f"}.bi-file-check::before{content:"\f350"}.bi-file-code-fill::before{content:"\f351"}.bi-file-code::before{content:"\f352"}.bi-file-diff-fill::before{content:"\f353"}.bi-file-diff::before{content:"\f354"}.bi-file-earmark-arrow-down-fill::before{content:"\f355"}.bi-file-earmark-arrow-down::before{content:"\f356"}.bi-file-earmark-arrow-up-fill::before{content:"\f357"}.bi-file-earmark-arrow-up::before{content:"\f358"}.bi-file-earmark-bar-graph-fill::before{content:"\f359"}.bi-file-earmark-bar-graph::before{content:"\f35a"}.bi-file-earmark-binary-fill::before{content:"\f35b"}.bi-file-earmark-binary::before{content:"\f35c"}.bi-file-earmark-break-fill::before{content:"\f35d"}.bi-file-earmark-break::before{content:"\f35e"}.bi-file-earmark-check-fill::before{content:"\f35f"}.bi-file-earmark-check::before{content:"\f360"}.bi-file-earmark-code-fill::before{content:"\f361"}.bi-file-earmark-code::before{content:"\f362"}.bi-file-earmark-diff-fill::before{content:"\f363"}.bi-file-earmark-diff::before{content:"\f364"}.bi-file-earmark-easel-fill::before{content:"\f365"}.bi-file-earmark-easel::before{content:"\f366"}.bi-file-earmark-excel-fill::before{content:"\f367"}.bi-file-earmark-excel::before{content:"\f368"}.bi-file-earmark-fill::before{content:"\f369"}.bi-file-earmark-font-fill::before{content:"\f36a"}.bi-file-earmark-font::before{content:"\f36b"}.bi-file-earmark-image-fill::before{content:"\f36c"}.bi-file-earmark-image::before{content:"\f36d"}.bi-file-earmark-lock-fill::before{content:"\f36e"}.bi-file-earmark-lock::before{content:"\f36f"}.bi-file-earmark-lock2-fill::before{content:"\f370"}.bi-file-earmark-lock2::before{content:"\f371"}.bi-file-earmark-medical-fill::before{content:"\f372"}.bi-file-earmark-medical::before{content:"\f373"}.bi-file-earmark-minus-fill::before{content:"\f374"}.bi-file-earmark-minus::before{content:"\f375"}.bi-file-earmark-music-fill::before{content:"\f376"}.bi-file-earmark-music::before{content:"\f377"}.bi-file-earmark-person-fill::before{content:"\f378"}.bi-file-earmark-person::before{content:"\f379"}.bi-file-earmark-play-fill::before{content:"\f37a"}.bi-file-earmark-play::before{content:"\f37b"}.bi-file-earmark-plus-fill::before{content:"\f37c"}.bi-file-earmark-plus::before{content:"\f37d"}.bi-file-earmark-post-fill::before{content:"\f37e"}.bi-file-earmark-post::before{content:"\f37f"}.bi-file-earmark-ppt-fill::before{content:"\f380"}.bi-file-earmark-ppt::before{content:"\f381"}.bi-file-earmark-richtext-fill::before{content:"\f382"}.bi-file-earmark-richtext::before{content:"\f383"}.bi-file-earmark-ruled-fill::before{content:"\f384"}.bi-file-earmark-ruled::before{content:"\f385"}.bi-file-earmark-slides-fill::before{content:"\f386"}.bi-file-earmark-slides::before{content:"\f387"}.bi-file-earmark-spreadsheet-fill::before{content:"\f388"}.bi-file-earmark-spreadsheet::before{content:"\f389"}.bi-file-earmark-text-fill::before{content:"\f38a"}.bi-file-earmark-text::before{content:"\f38b"}.bi-file-earmark-word-fill::before{content:"\f38c"}.bi-file-earmark-word::before{content:"\f38d"}.bi-file-earmark-x-fill::before{content:"\f38e"}.bi-file-earmark-x::before{content:"\f38f"}.bi-file-earmark-zip-fill::before{content:"\f390"}.bi-file-earmark-zip::before{content:"\f391"}.bi-file-earmark::before{content:"\f392"}.bi-file-easel-fill::before{content:"\f393"}.bi-file-easel::before{content:"\f394"}.bi-file-excel-fill::before{content:"\f395"}.bi-file-excel::before{content:"\f396"}.bi-file-fill::before{content:"\f397"}.bi-file-font-fill::before{content:"\f398"}.bi-file-font::before{content:"\f399"}.bi-file-image-fill::before{content:"\f39a"}.bi-file-image::before{content:"\f39b"}.bi-file-lock-fill::before{content:"\f39c"}.bi-file-lock::before{content:"\f39d"}.bi-file-lock2-fill::before{content:"\f39e"}.bi-file-lock2::before{content:"\f39f"}.bi-file-medical-fill::before{content:"\f3a0"}.bi-file-medical::before{content:"\f3a1"}.bi-file-minus-fill::before{content:"\f3a2"}.bi-file-minus::before{content:"\f3a3"}.bi-file-music-fill::before{content:"\f3a4"}.bi-file-music::before{content:"\f3a5"}.bi-file-person-fill::before{content:"\f3a6"}.bi-file-person::before{content:"\f3a7"}.bi-file-play-fill::before{content:"\f3a8"}.bi-file-play::before{content:"\f3a9"}.bi-file-plus-fill::before{content:"\f3aa"}.bi-file-plus::before{content:"\f3ab"}.bi-file-post-fill::before{content:"\f3ac"}.bi-file-post::before{content:"\f3ad"}.bi-file-ppt-fill::before{content:"\f3ae"}.bi-file-ppt::before{content:"\f3af"}.bi-file-richtext-fill::before{content:"\f3b0"}.bi-file-richtext::before{content:"\f3b1"}.bi-file-ruled-fill::before{content:"\f3b2"}.bi-file-ruled::before{content:"\f3b3"}.bi-file-slides-fill::before{content:"\f3b4"}.bi-file-slides::before{content:"\f3b5"}.bi-file-spreadsheet-fill::before{content:"\f3b6"}.bi-file-spreadsheet::before{content:"\f3b7"}.bi-file-text-fill::before{content:"\f3b8"}.bi-file-text::before{content:"\f3b9"}.bi-file-word-fill::before{content:"\f3ba"}.bi-file-word::before{content:"\f3bb"}.bi-file-x-fill::before{content:"\f3bc"}.bi-file-x::before{content:"\f3bd"}.bi-file-zip-fill::before{content:"\f3be"}.bi-file-zip::before{content:"\f3bf"}.bi-file::before{content:"\f3c0"}.bi-files-alt::before{content:"\f3c1"}.bi-files::before{content:"\f3c2"}.bi-film::before{content:"\f3c3"}.bi-filter-circle-fill::before{content:"\f3c4"}.bi-filter-circle::before{content:"\f3c5"}.bi-filter-left::before{content:"\f3c6"}.bi-filter-right::before{content:"\f3c7"}.bi-filter-square-fill::before{content:"\f3c8"}.bi-filter-square::before{content:"\f3c9"}.bi-filter::before{content:"\f3ca"}.bi-flag-fill::before{content:"\f3cb"}.bi-flag::before{content:"\f3cc"}.bi-flower1::before{content:"\f3cd"}.bi-flower2::before{content:"\f3ce"}.bi-flower3::before{content:"\f3cf"}.bi-folder-check::before{content:"\f3d0"}.bi-folder-fill::before{content:"\f3d1"}.bi-folder-minus::before{content:"\f3d2"}.bi-folder-plus::before{content:"\f3d3"}.bi-folder-symlink-fill::before{content:"\f3d4"}.bi-folder-symlink::before{content:"\f3d5"}.bi-folder-x::before{content:"\f3d6"}.bi-folder::before{content:"\f3d7"}.bi-folder2-open::before{content:"\f3d8"}.bi-folder2::before{content:"\f3d9"}.bi-fonts::before{content:"\f3da"}.bi-forward-fill::before{content:"\f3db"}.bi-forward::before{content:"\f3dc"}.bi-front::before{content:"\f3dd"}.bi-fullscreen-exit::before{content:"\f3de"}.bi-fullscreen::before{content:"\f3df"}.bi-funnel-fill::before{content:"\f3e0"}.bi-funnel::before{content:"\f3e1"}.bi-gear-fill::before{content:"\f3e2"}.bi-gear-wide-connected::before{content:"\f3e3"}.bi-gear-wide::before{content:"\f3e4"}.bi-gear::before{content:"\f3e5"}.bi-gem::before{content:"\f3e6"}.bi-geo-alt-fill::before{content:"\f3e7"}.bi-geo-alt::before{content:"\f3e8"}.bi-geo-fill::before{content:"\f3e9"}.bi-geo::before{content:"\f3ea"}.bi-gift-fill::before{content:"\f3eb"}.bi-gift::before{content:"\f3ec"}.bi-github::before{content:"\f3ed"}.bi-globe::before{content:"\f3ee"}.bi-globe2::before{content:"\f3ef"}.bi-google::before{content:"\f3f0"}.bi-graph-down::before{content:"\f3f1"}.bi-graph-up::before{content:"\f3f2"}.bi-grid-1x2-fill::before{content:"\f3f3"}.bi-grid-1x2::before{content:"\f3f4"}.bi-grid-3x2-gap-fill::before{content:"\f3f5"}.bi-grid-3x2-gap::before{content:"\f3f6"}.bi-grid-3x2::before{content:"\f3f7"}.bi-grid-3x3-gap-fill::before{content:"\f3f8"}.bi-grid-3x3-gap::before{content:"\f3f9"}.bi-grid-3x3::before{content:"\f3fa"}.bi-grid-fill::before{content:"\f3fb"}.bi-grid::before{content:"\f3fc"}.bi-grip-horizontal::before{content:"\f3fd"}.bi-grip-vertical::before{content:"\f3fe"}.bi-hammer::before{content:"\f3ff"}.bi-hand-index-fill::before{content:"\f400"}.bi-hand-index-thumb-fill::before{content:"\f401"}.bi-hand-index-thumb::before{content:"\f402"}.bi-hand-index::before{content:"\f403"}.bi-hand-thumbs-down-fill::before{content:"\f404"}.bi-hand-thumbs-down::before{content:"\f405"}.bi-hand-thumbs-up-fill::before{content:"\f406"}.bi-hand-thumbs-up::before{content:"\f407"}.bi-handbag-fill::before{content:"\f408"}.bi-handbag::before{content:"\f409"}.bi-hash::before{content:"\f40a"}.bi-hdd-fill::before{content:"\f40b"}.bi-hdd-network-fill::before{content:"\f40c"}.bi-hdd-network::before{content:"\f40d"}.bi-hdd-rack-fill::before{content:"\f40e"}.bi-hdd-rack::before{content:"\f40f"}.bi-hdd-stack-fill::before{content:"\f410"}.bi-hdd-stack::before{content:"\f411"}.bi-hdd::before{content:"\f412"}.bi-headphones::before{content:"\f413"}.bi-headset::before{content:"\f414"}.bi-heart-fill::before{content:"\f415"}.bi-heart-half::before{content:"\f416"}.bi-heart::before{content:"\f417"}.bi-heptagon-fill::before{content:"\f418"}.bi-heptagon-half::before{content:"\f419"}.bi-heptagon::before{content:"\f41a"}.bi-hexagon-fill::before{content:"\f41b"}.bi-hexagon-half::before{content:"\f41c"}.bi-hexagon::before{content:"\f41d"}.bi-hourglass-bottom::before{content:"\f41e"}.bi-hourglass-split::before{content:"\f41f"}.bi-hourglass-top::before{content:"\f420"}.bi-hourglass::before{content:"\f421"}.bi-house-door-fill::before{content:"\f422"}.bi-house-door::before{content:"\f423"}.bi-house-fill::before{content:"\f424"}.bi-house::before{content:"\f425"}.bi-hr::before{content:"\f426"}.bi-hurricane::before{content:"\f427"}.bi-image-alt::before{content:"\f428"}.bi-image-fill::before{content:"\f429"}.bi-image::before{content:"\f42a"}.bi-images::before{content:"\f42b"}.bi-inbox-fill::before{content:"\f42c"}.bi-inbox::before{content:"\f42d"}.bi-inboxes-fill::before{content:"\f42e"}.bi-inboxes::before{content:"\f42f"}.bi-info-circle-fill::before{content:"\f430"}.bi-info-circle::before{content:"\f431"}.bi-info-square-fill::before{content:"\f432"}.bi-info-square::before{content:"\f433"}.bi-info::before{content:"\f434"}.bi-input-cursor-text::before{content:"\f435"}.bi-input-cursor::before{content:"\f436"}.bi-instagram::before{content:"\f437"}.bi-intersect::before{content:"\f438"}.bi-journal-album::before{content:"\f439"}.bi-journal-arrow-down::before{content:"\f43a"}.bi-journal-arrow-up::before{content:"\f43b"}.bi-journal-bookmark-fill::before{content:"\f43c"}.bi-journal-bookmark::before{content:"\f43d"}.bi-journal-check::before{content:"\f43e"}.bi-journal-code::before{content:"\f43f"}.bi-journal-medical::before{content:"\f440"}.bi-journal-minus::before{content:"\f441"}.bi-journal-plus::before{content:"\f442"}.bi-journal-richtext::before{content:"\f443"}.bi-journal-text::before{content:"\f444"}.bi-journal-x::before{content:"\f445"}.bi-journal::before{content:"\f446"}.bi-journals::before{content:"\f447"}.bi-joystick::before{content:"\f448"}.bi-justify-left::before{content:"\f449"}.bi-justify-right::before{content:"\f44a"}.bi-justify::before{content:"\f44b"}.bi-kanban-fill::before{content:"\f44c"}.bi-kanban::before{content:"\f44d"}.bi-key-fill::before{content:"\f44e"}.bi-key::before{content:"\f44f"}.bi-keyboard-fill::before{content:"\f450"}.bi-keyboard::before{content:"\f451"}.bi-ladder::before{content:"\f452"}.bi-lamp-fill::before{content:"\f453"}.bi-lamp::before{content:"\f454"}.bi-laptop-fill::before{content:"\f455"}.bi-laptop::before{content:"\f456"}.bi-layer-backward::before{content:"\f457"}.bi-layer-forward::before{content:"\f458"}.bi-layers-fill::before{content:"\f459"}.bi-layers-half::before{content:"\f45a"}.bi-layers::before{content:"\f45b"}.bi-layout-sidebar-inset-reverse::before{content:"\f45c"}.bi-layout-sidebar-inset::before{content:"\f45d"}.bi-layout-sidebar-reverse::before{content:"\f45e"}.bi-layout-sidebar::before{content:"\f45f"}.bi-layout-split::before{content:"\f460"}.bi-layout-text-sidebar-reverse::before{content:"\f461"}.bi-layout-text-sidebar::before{content:"\f462"}.bi-layout-text-window-reverse::before{content:"\f463"}.bi-layout-text-window::before{content:"\f464"}.bi-layout-three-columns::before{content:"\f465"}.bi-layout-wtf::before{content:"\f466"}.bi-life-preserver::before{content:"\f467"}.bi-lightbulb-fill::before{content:"\f468"}.bi-lightbulb-off-fill::before{content:"\f469"}.bi-lightbulb-off::before{content:"\f46a"}.bi-lightbulb::before{content:"\f46b"}.bi-lightning-charge-fill::before{content:"\f46c"}.bi-lightning-charge::before{content:"\f46d"}.bi-lightning-fill::before{content:"\f46e"}.bi-lightning::before{content:"\f46f"}.bi-link-45deg::before{content:"\f470"}.bi-link::before{content:"\f471"}.bi-linkedin::before{content:"\f472"}.bi-list-check::before{content:"\f473"}.bi-list-nested::before{content:"\f474"}.bi-list-ol::before{content:"\f475"}.bi-list-stars::before{content:"\f476"}.bi-list-task::before{content:"\f477"}.bi-list-ul::before{content:"\f478"}.bi-list::before{content:"\f479"}.bi-lock-fill::before{content:"\f47a"}.bi-lock::before{content:"\f47b"}.bi-mailbox::before{content:"\f47c"}.bi-mailbox2::before{content:"\f47d"}.bi-map-fill::before{content:"\f47e"}.bi-map::before{content:"\f47f"}.bi-markdown-fill::before{content:"\f480"}.bi-markdown::before{content:"\f481"}.bi-mask::before{content:"\f482"}.bi-megaphone-fill::before{content:"\f483"}.bi-megaphone::before{content:"\f484"}.bi-menu-app-fill::before{content:"\f485"}.bi-menu-app::before{content:"\f486"}.bi-menu-button-fill::before{content:"\f487"}.bi-menu-button-wide-fill::before{content:"\f488"}.bi-menu-button-wide::before{content:"\f489"}.bi-menu-button::before{content:"\f48a"}.bi-menu-down::before{content:"\f48b"}.bi-menu-up::before{content:"\f48c"}.bi-mic-fill::before{content:"\f48d"}.bi-mic-mute-fill::before{content:"\f48e"}.bi-mic-mute::before{content:"\f48f"}.bi-mic::before{content:"\f490"}.bi-minecart-loaded::before{content:"\f491"}.bi-minecart::before{content:"\f492"}.bi-moisture::before{content:"\f493"}.bi-moon-fill::before{content:"\f494"}.bi-moon-stars-fill::before{content:"\f495"}.bi-moon-stars::before{content:"\f496"}.bi-moon::before{content:"\f497"}.bi-mouse-fill::before{content:"\f498"}.bi-mouse::before{content:"\f499"}.bi-mouse2-fill::before{content:"\f49a"}.bi-mouse2::before{content:"\f49b"}.bi-mouse3-fill::before{content:"\f49c"}.bi-mouse3::before{content:"\f49d"}.bi-music-note-beamed::before{content:"\f49e"}.bi-music-note-list::before{content:"\f49f"}.bi-music-note::before{content:"\f4a0"}.bi-music-player-fill::before{content:"\f4a1"}.bi-music-player::before{content:"\f4a2"}.bi-newspaper::before{content:"\f4a3"}.bi-node-minus-fill::before{content:"\f4a4"}.bi-node-minus::before{content:"\f4a5"}.bi-node-plus-fill::before{content:"\f4a6"}.bi-node-plus::before{content:"\f4a7"}.bi-nut-fill::before{content:"\f4a8"}.bi-nut::before{content:"\f4a9"}.bi-octagon-fill::before{content:"\f4aa"}.bi-octagon-half::before{content:"\f4ab"}.bi-octagon::before{content:"\f4ac"}.bi-option::before{content:"\f4ad"}.bi-outlet::before{content:"\f4ae"}.bi-paint-bucket::before{content:"\f4af"}.bi-palette-fill::before{content:"\f4b0"}.bi-palette::before{content:"\f4b1"}.bi-palette2::before{content:"\f4b2"}.bi-paperclip::before{content:"\f4b3"}.bi-paragraph::before{content:"\f4b4"}.bi-patch-check-fill::before{content:"\f4b5"}.bi-patch-check::before{content:"\f4b6"}.bi-patch-exclamation-fill::before{content:"\f4b7"}.bi-patch-exclamation::before{content:"\f4b8"}.bi-patch-minus-fill::before{content:"\f4b9"}.bi-patch-minus::before{content:"\f4ba"}.bi-patch-plus-fill::before{content:"\f4bb"}.bi-patch-plus::before{content:"\f4bc"}.bi-patch-question-fill::before{content:"\f4bd"}.bi-patch-question::before{content:"\f4be"}.bi-pause-btn-fill::before{content:"\f4bf"}.bi-pause-btn::before{content:"\f4c0"}.bi-pause-circle-fill::before{content:"\f4c1"}.bi-pause-circle::before{content:"\f4c2"}.bi-pause-fill::before{content:"\f4c3"}.bi-pause::before{content:"\f4c4"}.bi-peace-fill::before{content:"\f4c5"}.bi-peace::before{content:"\f4c6"}.bi-pen-fill::before{content:"\f4c7"}.bi-pen::before{content:"\f4c8"}.bi-pencil-fill::before{content:"\f4c9"}.bi-pencil-square::before{content:"\f4ca"}.bi-pencil::before{content:"\f4cb"}.bi-pentagon-fill::before{content:"\f4cc"}.bi-pentagon-half::before{content:"\f4cd"}.bi-pentagon::before{content:"\f4ce"}.bi-people-fill::before{content:"\f4cf"}.bi-people::before{content:"\f4d0"}.bi-percent::before{content:"\f4d1"}.bi-person-badge-fill::before{content:"\f4d2"}.bi-person-badge::before{content:"\f4d3"}.bi-person-bounding-box::before{content:"\f4d4"}.bi-person-check-fill::before{content:"\f4d5"}.bi-person-check::before{content:"\f4d6"}.bi-person-circle::before{content:"\f4d7"}.bi-person-dash-fill::before{content:"\f4d8"}.bi-person-dash::before{content:"\f4d9"}.bi-person-fill::before{content:"\f4da"}.bi-person-lines-fill::before{content:"\f4db"}.bi-person-plus-fill::before{content:"\f4dc"}.bi-person-plus::before{content:"\f4dd"}.bi-person-square::before{content:"\f4de"}.bi-person-x-fill::before{content:"\f4df"}.bi-person-x::before{content:"\f4e0"}.bi-person::before{content:"\f4e1"}.bi-phone-fill::before{content:"\f4e2"}.bi-phone-landscape-fill::before{content:"\f4e3"}.bi-phone-landscape::before{content:"\f4e4"}.bi-phone-vibrate-fill::before{content:"\f4e5"}.bi-phone-vibrate::before{content:"\f4e6"}.bi-phone::before{content:"\f4e7"}.bi-pie-chart-fill::before{content:"\f4e8"}.bi-pie-chart::before{content:"\f4e9"}.bi-pin-angle-fill::before{content:"\f4ea"}.bi-pin-angle::before{content:"\f4eb"}.bi-pin-fill::before{content:"\f4ec"}.bi-pin::before{content:"\f4ed"}.bi-pip-fill::before{content:"\f4ee"}.bi-pip::before{content:"\f4ef"}.bi-play-btn-fill::before{content:"\f4f0"}.bi-play-btn::before{content:"\f4f1"}.bi-play-circle-fill::before{content:"\f4f2"}.bi-play-circle::before{content:"\f4f3"}.bi-play-fill::before{content:"\f4f4"}.bi-play::before{content:"\f4f5"}.bi-plug-fill::before{content:"\f4f6"}.bi-plug::before{content:"\f4f7"}.bi-plus-circle-dotted::before{content:"\f4f8"}.bi-plus-circle-fill::before{content:"\f4f9"}.bi-plus-circle::before{content:"\f4fa"}.bi-plus-square-dotted::before{content:"\f4fb"}.bi-plus-square-fill::before{content:"\f4fc"}.bi-plus-square::before{content:"\f4fd"}.bi-plus::before{content:"\f4fe"}.bi-power::before{content:"\f4ff"}.bi-printer-fill::before{content:"\f500"}.bi-printer::before{content:"\f501"}.bi-puzzle-fill::before{content:"\f502"}.bi-puzzle::before{content:"\f503"}.bi-question-circle-fill::before{content:"\f504"}.bi-question-circle::before{content:"\f505"}.bi-question-diamond-fill::before{content:"\f506"}.bi-question-diamond::before{content:"\f507"}.bi-question-octagon-fill::before{content:"\f508"}.bi-question-octagon::before{content:"\f509"}.bi-question-square-fill::before{content:"\f50a"}.bi-question-square::before{content:"\f50b"}.bi-question::before{content:"\f50c"}.bi-rainbow::before{content:"\f50d"}.bi-receipt-cutoff::before{content:"\f50e"}.bi-receipt::before{content:"\f50f"}.bi-reception-0::before{content:"\f510"}.bi-reception-1::before{content:"\f511"}.bi-reception-2::before{content:"\f512"}.bi-reception-3::before{content:"\f513"}.bi-reception-4::before{content:"\f514"}.bi-record-btn-fill::before{content:"\f515"}.bi-record-btn::before{content:"\f516"}.bi-record-circle-fill::before{content:"\f517"}.bi-record-circle::before{content:"\f518"}.bi-record-fill::before{content:"\f519"}.bi-record::before{content:"\f51a"}.bi-record2-fill::before{content:"\f51b"}.bi-record2::before{content:"\f51c"}.bi-reply-all-fill::before{content:"\f51d"}.bi-reply-all::before{content:"\f51e"}.bi-reply-fill::before{content:"\f51f"}.bi-reply::before{content:"\f520"}.bi-rss-fill::before{content:"\f521"}.bi-rss::before{content:"\f522"}.bi-rulers::before{content:"\f523"}.bi-save-fill::before{content:"\f524"}.bi-save::before{content:"\f525"}.bi-save2-fill::before{content:"\f526"}.bi-save2::before{content:"\f527"}.bi-scissors::before{content:"\f528"}.bi-screwdriver::before{content:"\f529"}.bi-search::before{content:"\f52a"}.bi-segmented-nav::before{content:"\f52b"}.bi-server::before{content:"\f52c"}.bi-share-fill::before{content:"\f52d"}.bi-share::before{content:"\f52e"}.bi-shield-check::before{content:"\f52f"}.bi-shield-exclamation::before{content:"\f530"}.bi-shield-fill-check::before{content:"\f531"}.bi-shield-fill-exclamation::before{content:"\f532"}.bi-shield-fill-minus::before{content:"\f533"}.bi-shield-fill-plus::before{content:"\f534"}.bi-shield-fill-x::before{content:"\f535"}.bi-shield-fill::before{content:"\f536"}.bi-shield-lock-fill::before{content:"\f537"}.bi-shield-lock::before{content:"\f538"}.bi-shield-minus::before{content:"\f539"}.bi-shield-plus::before{content:"\f53a"}.bi-shield-shaded::before{content:"\f53b"}.bi-shield-slash-fill::before{content:"\f53c"}.bi-shield-slash::before{content:"\f53d"}.bi-shield-x::before{content:"\f53e"}.bi-shield::before{content:"\f53f"}.bi-shift-fill::before{content:"\f540"}.bi-shift::before{content:"\f541"}.bi-shop-window::before{content:"\f542"}.bi-shop::before{content:"\f543"}.bi-shuffle::before{content:"\f544"}.bi-signpost-2-fill::before{content:"\f545"}.bi-signpost-2::before{content:"\f546"}.bi-signpost-fill::before{content:"\f547"}.bi-signpost-split-fill::before{content:"\f548"}.bi-signpost-split::before{content:"\f549"}.bi-signpost::before{content:"\f54a"}.bi-sim-fill::before{content:"\f54b"}.bi-sim::before{content:"\f54c"}.bi-skip-backward-btn-fill::before{content:"\f54d"}.bi-skip-backward-btn::before{content:"\f54e"}.bi-skip-backward-circle-fill::before{content:"\f54f"}.bi-skip-backward-circle::before{content:"\f550"}.bi-skip-backward-fill::before{content:"\f551"}.bi-skip-backward::before{content:"\f552"}.bi-skip-end-btn-fill::before{content:"\f553"}.bi-skip-end-btn::before{content:"\f554"}.bi-skip-end-circle-fill::before{content:"\f555"}.bi-skip-end-circle::before{content:"\f556"}.bi-skip-end-fill::before{content:"\f557"}.bi-skip-end::before{content:"\f558"}.bi-skip-forward-btn-fill::before{content:"\f559"}.bi-skip-forward-btn::before{content:"\f55a"}.bi-skip-forward-circle-fill::before{content:"\f55b"}.bi-skip-forward-circle::before{content:"\f55c"}.bi-skip-forward-fill::before{content:"\f55d"}.bi-skip-forward::before{content:"\f55e"}.bi-skip-start-btn-fill::before{content:"\f55f"}.bi-skip-start-btn::before{content:"\f560"}.bi-skip-start-circle-fill::before{content:"\f561"}.bi-skip-start-circle::before{content:"\f562"}.bi-skip-start-fill::before{content:"\f563"}.bi-skip-start::before{content:"\f564"}.bi-slack::before{content:"\f565"}.bi-slash-circle-fill::before{content:"\f566"}.bi-slash-circle::before{content:"\f567"}.bi-slash-square-fill::before{content:"\f568"}.bi-slash-square::before{content:"\f569"}.bi-slash::before{content:"\f56a"}.bi-sliders::before{content:"\f56b"}.bi-smartwatch::before{content:"\f56c"}.bi-snow::before{content:"\f56d"}.bi-snow2::before{content:"\f56e"}.bi-snow3::before{content:"\f56f"}.bi-sort-alpha-down-alt::before{content:"\f570"}.bi-sort-alpha-down::before{content:"\f571"}.bi-sort-alpha-up-alt::before{content:"\f572"}.bi-sort-alpha-up::before{content:"\f573"}.bi-sort-down-alt::before{content:"\f574"}.bi-sort-down::before{content:"\f575"}.bi-sort-numeric-down-alt::before{content:"\f576"}.bi-sort-numeric-down::before{content:"\f577"}.bi-sort-numeric-up-alt::before{content:"\f578"}.bi-sort-numeric-up::before{content:"\f579"}.bi-sort-up-alt::before{content:"\f57a"}.bi-sort-up::before{content:"\f57b"}.bi-soundwave::before{content:"\f57c"}.bi-speaker-fill::before{content:"\f57d"}.bi-speaker::before{content:"\f57e"}.bi-speedometer::before{content:"\f57f"}.bi-speedometer2::before{content:"\f580"}.bi-spellcheck::before{content:"\f581"}.bi-square-fill::before{content:"\f582"}.bi-square-half::before{content:"\f583"}.bi-square::before{content:"\f584"}.bi-stack::before{content:"\f585"}.bi-star-fill::before{content:"\f586"}.bi-star-half::before{content:"\f587"}.bi-star::before{content:"\f588"}.bi-stars::before{content:"\f589"}.bi-stickies-fill::before{content:"\f58a"}.bi-stickies::before{content:"\f58b"}.bi-sticky-fill::before{content:"\f58c"}.bi-sticky::before{content:"\f58d"}.bi-stop-btn-fill::before{content:"\f58e"}.bi-stop-btn::before{content:"\f58f"}.bi-stop-circle-fill::before{content:"\f590"}.bi-stop-circle::before{content:"\f591"}.bi-stop-fill::before{content:"\f592"}.bi-stop::before{content:"\f593"}.bi-stoplights-fill::before{content:"\f594"}.bi-stoplights::before{content:"\f595"}.bi-stopwatch-fill::before{content:"\f596"}.bi-stopwatch::before{content:"\f597"}.bi-subtract::before{content:"\f598"}.bi-suit-club-fill::before{content:"\f599"}.bi-suit-club::before{content:"\f59a"}.bi-suit-diamond-fill::before{content:"\f59b"}.bi-suit-diamond::before{content:"\f59c"}.bi-suit-heart-fill::before{content:"\f59d"}.bi-suit-heart::before{content:"\f59e"}.bi-suit-spade-fill::before{content:"\f59f"}.bi-suit-spade::before{content:"\f5a0"}.bi-sun-fill::before{content:"\f5a1"}.bi-sun::before{content:"\f5a2"}.bi-sunglasses::before{content:"\f5a3"}.bi-sunrise-fill::before{content:"\f5a4"}.bi-sunrise::before{content:"\f5a5"}.bi-sunset-fill::before{content:"\f5a6"}.bi-sunset::before{content:"\f5a7"}.bi-symmetry-horizontal::before{content:"\f5a8"}.bi-symmetry-vertical::before{content:"\f5a9"}.bi-table::before{content:"\f5aa"}.bi-tablet-fill::before{content:"\f5ab"}.bi-tablet-landscape-fill::before{content:"\f5ac"}.bi-tablet-landscape::before{content:"\f5ad"}.bi-tablet::before{content:"\f5ae"}.bi-tag-fill::before{content:"\f5af"}.bi-tag::before{content:"\f5b0"}.bi-tags-fill::before{content:"\f5b1"}.bi-tags::before{content:"\f5b2"}.bi-telegram::before{content:"\f5b3"}.bi-telephone-fill::before{content:"\f5b4"}.bi-telephone-forward-fill::before{content:"\f5b5"}.bi-telephone-forward::before{content:"\f5b6"}.bi-telephone-inbound-fill::before{content:"\f5b7"}.bi-telephone-inbound::before{content:"\f5b8"}.bi-telephone-minus-fill::before{content:"\f5b9"}.bi-telephone-minus::before{content:"\f5ba"}.bi-telephone-outbound-fill::before{content:"\f5bb"}.bi-telephone-outbound::before{content:"\f5bc"}.bi-telephone-plus-fill::before{content:"\f5bd"}.bi-telephone-plus::before{content:"\f5be"}.bi-telephone-x-fill::before{content:"\f5bf"}.bi-telephone-x::before{content:"\f5c0"}.bi-telephone::before{content:"\f5c1"}.bi-terminal-fill::before{content:"\f5c2"}.bi-terminal::before{content:"\f5c3"}.bi-text-center::before{content:"\f5c4"}.bi-text-indent-left::before{content:"\f5c5"}.bi-text-indent-right::before{content:"\f5c6"}.bi-text-left::before{content:"\f5c7"}.bi-text-paragraph::before{content:"\f5c8"}.bi-text-right::before{content:"\f5c9"}.bi-textarea-resize::before{content:"\f5ca"}.bi-textarea-t::before{content:"\f5cb"}.bi-textarea::before{content:"\f5cc"}.bi-thermometer-half::before{content:"\f5cd"}.bi-thermometer-high::before{content:"\f5ce"}.bi-thermometer-low::before{content:"\f5cf"}.bi-thermometer-snow::before{content:"\f5d0"}.bi-thermometer-sun::before{content:"\f5d1"}.bi-thermometer::before{content:"\f5d2"}.bi-three-dots-vertical::before{content:"\f5d3"}.bi-three-dots::before{content:"\f5d4"}.bi-toggle-off::before{content:"\f5d5"}.bi-toggle-on::before{content:"\f5d6"}.bi-toggle2-off::before{content:"\f5d7"}.bi-toggle2-on::before{content:"\f5d8"}.bi-toggles::before{content:"\f5d9"}.bi-toggles2::before{content:"\f5da"}.bi-tools::before{content:"\f5db"}.bi-tornado::before{content:"\f5dc"}.bi-trash-fill::before{content:"\f5dd"}.bi-trash::before{content:"\f5de"}.bi-trash2-fill::before{content:"\f5df"}.bi-trash2::before{content:"\f5e0"}.bi-tree-fill::before{content:"\f5e1"}.bi-tree::before{content:"\f5e2"}.bi-triangle-fill::before{content:"\f5e3"}.bi-triangle-half::before{content:"\f5e4"}.bi-triangle::before{content:"\f5e5"}.bi-trophy-fill::before{content:"\f5e6"}.bi-trophy::before{content:"\f5e7"}.bi-tropical-storm::before{content:"\f5e8"}.bi-truck-flatbed::before{content:"\f5e9"}.bi-truck::before{content:"\f5ea"}.bi-tsunami::before{content:"\f5eb"}.bi-tv-fill::before{content:"\f5ec"}.bi-tv::before{content:"\f5ed"}.bi-twitch::before{content:"\f5ee"}.bi-twitter::before{content:"\f5ef"}.bi-type-bold::before{content:"\f5f0"}.bi-type-h1::before{content:"\f5f1"}.bi-type-h2::before{content:"\f5f2"}.bi-type-h3::before{content:"\f5f3"}.bi-type-italic::before{content:"\f5f4"}.bi-type-strikethrough::before{content:"\f5f5"}.bi-type-underline::before{content:"\f5f6"}.bi-type::before{content:"\f5f7"}.bi-ui-checks-grid::before{content:"\f5f8"}.bi-ui-checks::before{content:"\f5f9"}.bi-ui-radios-grid::before{content:"\f5fa"}.bi-ui-radios::before{content:"\f5fb"}.bi-umbrella-fill::before{content:"\f5fc"}.bi-umbrella::before{content:"\f5fd"}.bi-union::before{content:"\f5fe"}.bi-unlock-fill::before{content:"\f5ff"}.bi-unlock::before{content:"\f600"}.bi-upc-scan::before{content:"\f601"}.bi-upc::before{content:"\f602"}.bi-upload::before{content:"\f603"}.bi-vector-pen::before{content:"\f604"}.bi-view-list::before{content:"\f605"}.bi-view-stacked::before{content:"\f606"}.bi-vinyl-fill::before{content:"\f607"}.bi-vinyl::before{content:"\f608"}.bi-voicemail::before{content:"\f609"}.bi-volume-down-fill::before{content:"\f60a"}.bi-volume-down::before{content:"\f60b"}.bi-volume-mute-fill::before{content:"\f60c"}.bi-volume-mute::before{content:"\f60d"}.bi-volume-off-fill::before{content:"\f60e"}.bi-volume-off::before{content:"\f60f"}.bi-volume-up-fill::before{content:"\f610"}.bi-volume-up::before{content:"\f611"}.bi-vr::before{content:"\f612"}.bi-wallet-fill::before{content:"\f613"}.bi-wallet::before{content:"\f614"}.bi-wallet2::before{content:"\f615"}.bi-watch::before{content:"\f616"}.bi-water::before{content:"\f617"}.bi-whatsapp::before{content:"\f618"}.bi-wifi-1::before{content:"\f619"}.bi-wifi-2::before{content:"\f61a"}.bi-wifi-off::before{content:"\f61b"}.bi-wifi::before{content:"\f61c"}.bi-wind::before{content:"\f61d"}.bi-window-dock::before{content:"\f61e"}.bi-window-sidebar::before{content:"\f61f"}.bi-window::before{content:"\f620"}.bi-wrench::before{content:"\f621"}.bi-x-circle-fill::before{content:"\f622"}.bi-x-circle::before{content:"\f623"}.bi-x-diamond-fill::before{content:"\f624"}.bi-x-diamond::before{content:"\f625"}.bi-x-octagon-fill::before{content:"\f626"}.bi-x-octagon::before{content:"\f627"}.bi-x-square-fill::before{content:"\f628"}.bi-x-square::before{content:"\f629"}.bi-x::before{content:"\f62a"}.bi-youtube::before{content:"\f62b"}.bi-zoom-in::before{content:"\f62c"}.bi-zoom-out::before{content:"\f62d"}.bi-bank::before{content:"\f62e"}.bi-bank2::before{content:"\f62f"}.bi-bell-slash-fill::before{content:"\f630"}.bi-bell-slash::before{content:"\f631"}.bi-cash-coin::before{content:"\f632"}.bi-check-lg::before{content:"\f633"}.bi-coin::before{content:"\f634"}.bi-currency-bitcoin::before{content:"\f635"}.bi-currency-dollar::before{content:"\f636"}.bi-currency-euro::before{content:"\f637"}.bi-currency-exchange::before{content:"\f638"}.bi-currency-pound::before{content:"\f639"}.bi-currency-yen::before{content:"\f63a"}.bi-dash-lg::before{content:"\f63b"}.bi-exclamation-lg::before{content:"\f63c"}.bi-file-earmark-pdf-fill::before{content:"\f63d"}.bi-file-earmark-pdf::before{content:"\f63e"}.bi-file-pdf-fill::before{content:"\f63f"}.bi-file-pdf::before{content:"\f640"}.bi-gender-ambiguous::before{content:"\f641"}.bi-gender-female::before{content:"\f642"}.bi-gender-male::before{content:"\f643"}.bi-gender-trans::before{content:"\f644"}.bi-headset-vr::before{content:"\f645"}.bi-info-lg::before{content:"\f646"}.bi-mastodon::before{content:"\f647"}.bi-messenger::before{content:"\f648"}.bi-piggy-bank-fill::before{content:"\f649"}.bi-piggy-bank::before{content:"\f64a"}.bi-pin-map-fill::before{content:"\f64b"}.bi-pin-map::before{content:"\f64c"}.bi-plus-lg::before{content:"\f64d"}.bi-question-lg::before{content:"\f64e"}.bi-recycle::before{content:"\f64f"}.bi-reddit::before{content:"\f650"}.bi-safe-fill::before{content:"\f651"}.bi-safe2-fill::before{content:"\f652"}.bi-safe2::before{content:"\f653"}.bi-sd-card-fill::before{content:"\f654"}.bi-sd-card::before{content:"\f655"}.bi-skype::before{content:"\f656"}.bi-slash-lg::before{content:"\f657"}.bi-translate::before{content:"\f658"}.bi-x-lg::before{content:"\f659"}.bi-safe::before{content:"\f65a"}.bi-apple::before{content:"\f65b"}.bi-microsoft::before{content:"\f65d"}.bi-windows::before{content:"\f65e"}.bi-behance::before{content:"\f65c"}.bi-dribbble::before{content:"\f65f"}.bi-line::before{content:"\f660"}.bi-medium::before{content:"\f661"}.bi-paypal::before{content:"\f662"}.bi-pinterest::before{content:"\f663"}.bi-signal::before{content:"\f664"}.bi-snapchat::before{content:"\f665"}.bi-spotify::before{content:"\f666"}.bi-stack-overflow::before{content:"\f667"}.bi-strava::before{content:"\f668"}.bi-wordpress::before{content:"\f669"}.bi-vimeo::before{content:"\f66a"}.bi-activity::before{content:"\f66b"}.bi-easel2-fill::before{content:"\f66c"}.bi-easel2::before{content:"\f66d"}.bi-easel3-fill::before{content:"\f66e"}.bi-easel3::before{content:"\f66f"}.bi-fan::before{content:"\f670"}.bi-fingerprint::before{content:"\f671"}.bi-graph-down-arrow::before{content:"\f672"}.bi-graph-up-arrow::before{content:"\f673"}.bi-hypnotize::before{content:"\f674"}.bi-magic::before{content:"\f675"}.bi-person-rolodex::before{content:"\f676"}.bi-person-video::before{content:"\f677"}.bi-person-video2::before{content:"\f678"}.bi-person-video3::before{content:"\f679"}.bi-person-workspace::before{content:"\f67a"}.bi-radioactive::before{content:"\f67b"}.bi-webcam-fill::before{content:"\f67c"}.bi-webcam::before{content:"\f67d"}.bi-yin-yang::before{content:"\f67e"}.bi-bandaid-fill::before{content:"\f680"}.bi-bandaid::before{content:"\f681"}.bi-bluetooth::before{content:"\f682"}.bi-body-text::before{content:"\f683"}.bi-boombox::before{content:"\f684"}.bi-boxes::before{content:"\f685"}.bi-dpad-fill::before{content:"\f686"}.bi-dpad::before{content:"\f687"}.bi-ear-fill::before{content:"\f688"}.bi-ear::before{content:"\f689"}.bi-envelope-check-fill::before{content:"\f68b"}.bi-envelope-check::before{content:"\f68c"}.bi-envelope-dash-fill::before{content:"\f68e"}.bi-envelope-dash::before{content:"\f68f"}.bi-envelope-exclamation-fill::before{content:"\f691"}.bi-envelope-exclamation::before{content:"\f692"}.bi-envelope-plus-fill::before{content:"\f693"}.bi-envelope-plus::before{content:"\f694"}.bi-envelope-slash-fill::before{content:"\f696"}.bi-envelope-slash::before{content:"\f697"}.bi-envelope-x-fill::before{content:"\f699"}.bi-envelope-x::before{content:"\f69a"}.bi-explicit-fill::before{content:"\f69b"}.bi-explicit::before{content:"\f69c"}.bi-git::before{content:"\f69d"}.bi-infinity::before{content:"\f69e"}.bi-list-columns-reverse::before{content:"\f69f"}.bi-list-columns::before{content:"\f6a0"}.bi-meta::before{content:"\f6a1"}.bi-nintendo-switch::before{content:"\f6a4"}.bi-pc-display-horizontal::before{content:"\f6a5"}.bi-pc-display::before{content:"\f6a6"}.bi-pc-horizontal::before{content:"\f6a7"}.bi-pc::before{content:"\f6a8"}.bi-playstation::before{content:"\f6a9"}.bi-plus-slash-minus::before{content:"\f6aa"}.bi-projector-fill::before{content:"\f6ab"}.bi-projector::before{content:"\f6ac"}.bi-qr-code-scan::before{content:"\f6ad"}.bi-qr-code::before{content:"\f6ae"}.bi-quora::before{content:"\f6af"}.bi-quote::before{content:"\f6b0"}.bi-robot::before{content:"\f6b1"}.bi-send-check-fill::before{content:"\f6b2"}.bi-send-check::before{content:"\f6b3"}.bi-send-dash-fill::before{content:"\f6b4"}.bi-send-dash::before{content:"\f6b5"}.bi-send-exclamation-fill::before{content:"\f6b7"}.bi-send-exclamation::before{content:"\f6b8"}.bi-send-fill::before{content:"\f6b9"}.bi-send-plus-fill::before{content:"\f6ba"}.bi-send-plus::before{content:"\f6bb"}.bi-send-slash-fill::before{content:"\f6bc"}.bi-send-slash::before{content:"\f6bd"}.bi-send-x-fill::before{content:"\f6be"}.bi-send-x::before{content:"\f6bf"}.bi-send::before{content:"\f6c0"}.bi-steam::before{content:"\f6c1"}.bi-terminal-dash::before{content:"\f6c3"}.bi-terminal-plus::before{content:"\f6c4"}.bi-terminal-split::before{content:"\f6c5"}.bi-ticket-detailed-fill::before{content:"\f6c6"}.bi-ticket-detailed::before{content:"\f6c7"}.bi-ticket-fill::before{content:"\f6c8"}.bi-ticket-perforated-fill::before{content:"\f6c9"}.bi-ticket-perforated::before{content:"\f6ca"}.bi-ticket::before{content:"\f6cb"}.bi-tiktok::before{content:"\f6cc"}.bi-window-dash::before{content:"\f6cd"}.bi-window-desktop::before{content:"\f6ce"}.bi-window-fullscreen::before{content:"\f6cf"}.bi-window-plus::before{content:"\f6d0"}.bi-window-split::before{content:"\f6d1"}.bi-window-stack::before{content:"\f6d2"}.bi-window-x::before{content:"\f6d3"}.bi-xbox::before{content:"\f6d4"}.bi-ethernet::before{content:"\f6d5"}.bi-hdmi-fill::before{content:"\f6d6"}.bi-hdmi::before{content:"\f6d7"}.bi-usb-c-fill::before{content:"\f6d8"}.bi-usb-c::before{content:"\f6d9"}.bi-usb-fill::before{content:"\f6da"}.bi-usb-plug-fill::before{content:"\f6db"}.bi-usb-plug::before{content:"\f6dc"}.bi-usb-symbol::before{content:"\f6dd"}.bi-usb::before{content:"\f6de"}.bi-boombox-fill::before{content:"\f6df"}.bi-displayport::before{content:"\f6e1"}.bi-gpu-card::before{content:"\f6e2"}.bi-memory::before{content:"\f6e3"}.bi-modem-fill::before{content:"\f6e4"}.bi-modem::before{content:"\f6e5"}.bi-motherboard-fill::before{content:"\f6e6"}.bi-motherboard::before{content:"\f6e7"}.bi-optical-audio-fill::before{content:"\f6e8"}.bi-optical-audio::before{content:"\f6e9"}.bi-pci-card::before{content:"\f6ea"}.bi-router-fill::before{content:"\f6eb"}.bi-router::before{content:"\f6ec"}.bi-thunderbolt-fill::before{content:"\f6ef"}.bi-thunderbolt::before{content:"\f6f0"}.bi-usb-drive-fill::before{content:"\f6f1"}.bi-usb-drive::before{content:"\f6f2"}.bi-usb-micro-fill::before{content:"\f6f3"}.bi-usb-micro::before{content:"\f6f4"}.bi-usb-mini-fill::before{content:"\f6f5"}.bi-usb-mini::before{content:"\f6f6"}.bi-cloud-haze2::before{content:"\f6f7"}.bi-device-hdd-fill::before{content:"\f6f8"}.bi-device-hdd::before{content:"\f6f9"}.bi-device-ssd-fill::before{content:"\f6fa"}.bi-device-ssd::before{content:"\f6fb"}.bi-displayport-fill::before{content:"\f6fc"}.bi-mortarboard-fill::before{content:"\f6fd"}.bi-mortarboard::before{content:"\f6fe"}.bi-terminal-x::before{content:"\f6ff"}.bi-arrow-through-heart-fill::before{content:"\f700"}.bi-arrow-through-heart::before{content:"\f701"}.bi-badge-sd-fill::before{content:"\f702"}.bi-badge-sd::before{content:"\f703"}.bi-bag-heart-fill::before{content:"\f704"}.bi-bag-heart::before{content:"\f705"}.bi-balloon-fill::before{content:"\f706"}.bi-balloon-heart-fill::before{content:"\f707"}.bi-balloon-heart::before{content:"\f708"}.bi-balloon::before{content:"\f709"}.bi-box2-fill::before{content:"\f70a"}.bi-box2-heart-fill::before{content:"\f70b"}.bi-box2-heart::before{content:"\f70c"}.bi-box2::before{content:"\f70d"}.bi-braces-asterisk::before{content:"\f70e"}.bi-calendar-heart-fill::before{content:"\f70f"}.bi-calendar-heart::before{content:"\f710"}.bi-calendar2-heart-fill::before{content:"\f711"}.bi-calendar2-heart::before{content:"\f712"}.bi-chat-heart-fill::before{content:"\f713"}.bi-chat-heart::before{content:"\f714"}.bi-chat-left-heart-fill::before{content:"\f715"}.bi-chat-left-heart::before{content:"\f716"}.bi-chat-right-heart-fill::before{content:"\f717"}.bi-chat-right-heart::before{content:"\f718"}.bi-chat-square-heart-fill::before{content:"\f719"}.bi-chat-square-heart::before{content:"\f71a"}.bi-clipboard-check-fill::before{content:"\f71b"}.bi-clipboard-data-fill::before{content:"\f71c"}.bi-clipboard-fill::before{content:"\f71d"}.bi-clipboard-heart-fill::before{content:"\f71e"}.bi-clipboard-heart::before{content:"\f71f"}.bi-clipboard-minus-fill::before{content:"\f720"}.bi-clipboard-plus-fill::before{content:"\f721"}.bi-clipboard-pulse::before{content:"\f722"}.bi-clipboard-x-fill::before{content:"\f723"}.bi-clipboard2-check-fill::before{content:"\f724"}.bi-clipboard2-check::before{content:"\f725"}.bi-clipboard2-data-fill::before{content:"\f726"}.bi-clipboard2-data::before{content:"\f727"}.bi-clipboard2-fill::before{content:"\f728"}.bi-clipboard2-heart-fill::before{content:"\f729"}.bi-clipboard2-heart::before{content:"\f72a"}.bi-clipboard2-minus-fill::before{content:"\f72b"}.bi-clipboard2-minus::before{content:"\f72c"}.bi-clipboard2-plus-fill::before{content:"\f72d"}.bi-clipboard2-plus::before{content:"\f72e"}.bi-clipboard2-pulse-fill::before{content:"\f72f"}.bi-clipboard2-pulse::before{content:"\f730"}.bi-clipboard2-x-fill::before{content:"\f731"}.bi-clipboard2-x::before{content:"\f732"}.bi-clipboard2::before{content:"\f733"}.bi-emoji-kiss-fill::before{content:"\f734"}.bi-emoji-kiss::before{content:"\f735"}.bi-envelope-heart-fill::before{content:"\f736"}.bi-envelope-heart::before{content:"\f737"}.bi-envelope-open-heart-fill::before{content:"\f738"}.bi-envelope-open-heart::before{content:"\f739"}.bi-envelope-paper-fill::before{content:"\f73a"}.bi-envelope-paper-heart-fill::before{content:"\f73b"}.bi-envelope-paper-heart::before{content:"\f73c"}.bi-envelope-paper::before{content:"\f73d"}.bi-filetype-aac::before{content:"\f73e"}.bi-filetype-ai::before{content:"\f73f"}.bi-filetype-bmp::before{content:"\f740"}.bi-filetype-cs::before{content:"\f741"}.bi-filetype-css::before{content:"\f742"}.bi-filetype-csv::before{content:"\f743"}.bi-filetype-doc::before{content:"\f744"}.bi-filetype-docx::before{content:"\f745"}.bi-filetype-exe::before{content:"\f746"}.bi-filetype-gif::before{content:"\f747"}.bi-filetype-heic::before{content:"\f748"}.bi-filetype-html::before{content:"\f749"}.bi-filetype-java::before{content:"\f74a"}.bi-filetype-jpg::before{content:"\f74b"}.bi-filetype-js::before{content:"\f74c"}.bi-filetype-jsx::before{content:"\f74d"}.bi-filetype-key::before{content:"\f74e"}.bi-filetype-m4p::before{content:"\f74f"}.bi-filetype-md::before{content:"\f750"}.bi-filetype-mdx::before{content:"\f751"}.bi-filetype-mov::before{content:"\f752"}.bi-filetype-mp3::before{content:"\f753"}.bi-filetype-mp4::before{content:"\f754"}.bi-filetype-otf::before{content:"\f755"}.bi-filetype-pdf::before{content:"\f756"}.bi-filetype-php::before{content:"\f757"}.bi-filetype-png::before{content:"\f758"}.bi-filetype-ppt::before{content:"\f75a"}.bi-filetype-psd::before{content:"\f75b"}.bi-filetype-py::before{content:"\f75c"}.bi-filetype-raw::before{content:"\f75d"}.bi-filetype-rb::before{content:"\f75e"}.bi-filetype-sass::before{content:"\f75f"}.bi-filetype-scss::before{content:"\f760"}.bi-filetype-sh::before{content:"\f761"}.bi-filetype-svg::before{content:"\f762"}.bi-filetype-tiff::before{content:"\f763"}.bi-filetype-tsx::before{content:"\f764"}.bi-filetype-ttf::before{content:"\f765"}.bi-filetype-txt::before{content:"\f766"}.bi-filetype-wav::before{content:"\f767"}.bi-filetype-woff::before{content:"\f768"}.bi-filetype-xls::before{content:"\f76a"}.bi-filetype-xml::before{content:"\f76b"}.bi-filetype-yml::before{content:"\f76c"}.bi-heart-arrow::before{content:"\f76d"}.bi-heart-pulse-fill::before{content:"\f76e"}.bi-heart-pulse::before{content:"\f76f"}.bi-heartbreak-fill::before{content:"\f770"}.bi-heartbreak::before{content:"\f771"}.bi-hearts::before{content:"\f772"}.bi-hospital-fill::before{content:"\f773"}.bi-hospital::before{content:"\f774"}.bi-house-heart-fill::before{content:"\f775"}.bi-house-heart::before{content:"\f776"}.bi-incognito::before{content:"\f777"}.bi-magnet-fill::before{content:"\f778"}.bi-magnet::before{content:"\f779"}.bi-person-heart::before{content:"\f77a"}.bi-person-hearts::before{content:"\f77b"}.bi-phone-flip::before{content:"\f77c"}.bi-plugin::before{content:"\f77d"}.bi-postage-fill::before{content:"\f77e"}.bi-postage-heart-fill::before{content:"\f77f"}.bi-postage-heart::before{content:"\f780"}.bi-postage::before{content:"\f781"}.bi-postcard-fill::before{content:"\f782"}.bi-postcard-heart-fill::before{content:"\f783"}.bi-postcard-heart::before{content:"\f784"}.bi-postcard::before{content:"\f785"}.bi-search-heart-fill::before{content:"\f786"}.bi-search-heart::before{content:"\f787"}.bi-sliders2-vertical::before{content:"\f788"}.bi-sliders2::before{content:"\f789"}.bi-trash3-fill::before{content:"\f78a"}.bi-trash3::before{content:"\f78b"}.bi-valentine::before{content:"\f78c"}.bi-valentine2::before{content:"\f78d"}.bi-wrench-adjustable-circle-fill::before{content:"\f78e"}.bi-wrench-adjustable-circle::before{content:"\f78f"}.bi-wrench-adjustable::before{content:"\f790"}.bi-filetype-json::before{content:"\f791"}.bi-filetype-pptx::before{content:"\f792"}.bi-filetype-xlsx::before{content:"\f793"}.bi-1-circle-fill::before{content:"\f796"}.bi-1-circle::before{content:"\f797"}.bi-1-square-fill::before{content:"\f798"}.bi-1-square::before{content:"\f799"}.bi-2-circle-fill::before{content:"\f79c"}.bi-2-circle::before{content:"\f79d"}.bi-2-square-fill::before{content:"\f79e"}.bi-2-square::before{content:"\f79f"}.bi-3-circle-fill::before{content:"\f7a2"}.bi-3-circle::before{content:"\f7a3"}.bi-3-square-fill::before{content:"\f7a4"}.bi-3-square::before{content:"\f7a5"}.bi-4-circle-fill::before{content:"\f7a8"}.bi-4-circle::before{content:"\f7a9"}.bi-4-square-fill::before{content:"\f7aa"}.bi-4-square::before{content:"\f7ab"}.bi-5-circle-fill::before{content:"\f7ae"}.bi-5-circle::before{content:"\f7af"}.bi-5-square-fill::before{content:"\f7b0"}.bi-5-square::before{content:"\f7b1"}.bi-6-circle-fill::before{content:"\f7b4"}.bi-6-circle::before{content:"\f7b5"}.bi-6-square-fill::before{content:"\f7b6"}.bi-6-square::before{content:"\f7b7"}.bi-7-circle-fill::before{content:"\f7ba"}.bi-7-circle::before{content:"\f7bb"}.bi-7-square-fill::before{content:"\f7bc"}.bi-7-square::before{content:"\f7bd"}.bi-8-circle-fill::before{content:"\f7c0"}.bi-8-circle::before{content:"\f7c1"}.bi-8-square-fill::before{content:"\f7c2"}.bi-8-square::before{content:"\f7c3"}.bi-9-circle-fill::before{content:"\f7c6"}.bi-9-circle::before{content:"\f7c7"}.bi-9-square-fill::before{content:"\f7c8"}.bi-9-square::before{content:"\f7c9"}.bi-airplane-engines-fill::before{content:"\f7ca"}.bi-airplane-engines::before{content:"\f7cb"}.bi-airplane-fill::before{content:"\f7cc"}.bi-airplane::before{content:"\f7cd"}.bi-alexa::before{content:"\f7ce"}.bi-alipay::before{content:"\f7cf"}.bi-android::before{content:"\f7d0"}.bi-android2::before{content:"\f7d1"}.bi-box-fill::before{content:"\f7d2"}.bi-box-seam-fill::before{content:"\f7d3"}.bi-browser-chrome::before{content:"\f7d4"}.bi-browser-edge::before{content:"\f7d5"}.bi-browser-firefox::before{content:"\f7d6"}.bi-browser-safari::before{content:"\f7d7"}.bi-c-circle-fill::before{content:"\f7da"}.bi-c-circle::before{content:"\f7db"}.bi-c-square-fill::before{content:"\f7dc"}.bi-c-square::before{content:"\f7dd"}.bi-capsule-pill::before{content:"\f7de"}.bi-capsule::before{content:"\f7df"}.bi-car-front-fill::before{content:"\f7e0"}.bi-car-front::before{content:"\f7e1"}.bi-cassette-fill::before{content:"\f7e2"}.bi-cassette::before{content:"\f7e3"}.bi-cc-circle-fill::before{content:"\f7e6"}.bi-cc-circle::before{content:"\f7e7"}.bi-cc-square-fill::before{content:"\f7e8"}.bi-cc-square::before{content:"\f7e9"}.bi-cup-hot-fill::before{content:"\f7ea"}.bi-cup-hot::before{content:"\f7eb"}.bi-currency-rupee::before{content:"\f7ec"}.bi-dropbox::before{content:"\f7ed"}.bi-escape::before{content:"\f7ee"}.bi-fast-forward-btn-fill::before{content:"\f7ef"}.bi-fast-forward-btn::before{content:"\f7f0"}.bi-fast-forward-circle-fill::before{content:"\f7f1"}.bi-fast-forward-circle::before{content:"\f7f2"}.bi-fast-forward-fill::before{content:"\f7f3"}.bi-fast-forward::before{content:"\f7f4"}.bi-filetype-sql::before{content:"\f7f5"}.bi-fire::before{content:"\f7f6"}.bi-google-play::before{content:"\f7f7"}.bi-h-circle-fill::before{content:"\f7fa"}.bi-h-circle::before{content:"\f7fb"}.bi-h-square-fill::before{content:"\f7fc"}.bi-h-square::before{content:"\f7fd"}.bi-indent::before{content:"\f7fe"}.bi-lungs-fill::before{content:"\f7ff"}.bi-lungs::before{content:"\f800"}.bi-microsoft-teams::before{content:"\f801"}.bi-p-circle-fill::before{content:"\f804"}.bi-p-circle::before{content:"\f805"}.bi-p-square-fill::before{content:"\f806"}.bi-p-square::before{content:"\f807"}.bi-pass-fill::before{content:"\f808"}.bi-pass::before{content:"\f809"}.bi-prescription::before{content:"\f80a"}.bi-prescription2::before{content:"\f80b"}.bi-r-circle-fill::before{content:"\f80e"}.bi-r-circle::before{content:"\f80f"}.bi-r-square-fill::before{content:"\f810"}.bi-r-square::before{content:"\f811"}.bi-repeat-1::before{content:"\f812"}.bi-repeat::before{content:"\f813"}.bi-rewind-btn-fill::before{content:"\f814"}.bi-rewind-btn::before{content:"\f815"}.bi-rewind-circle-fill::before{content:"\f816"}.bi-rewind-circle::before{content:"\f817"}.bi-rewind-fill::before{content:"\f818"}.bi-rewind::before{content:"\f819"}.bi-train-freight-front-fill::before{content:"\f81a"}.bi-train-freight-front::before{content:"\f81b"}.bi-train-front-fill::before{content:"\f81c"}.bi-train-front::before{content:"\f81d"}.bi-train-lightrail-front-fill::before{content:"\f81e"}.bi-train-lightrail-front::before{content:"\f81f"}.bi-truck-front-fill::before{content:"\f820"}.bi-truck-front::before{content:"\f821"}.bi-ubuntu::before{content:"\f822"}.bi-unindent::before{content:"\f823"}.bi-unity::before{content:"\f824"}.bi-universal-access-circle::before{content:"\f825"}.bi-universal-access::before{content:"\f826"}.bi-virus::before{content:"\f827"}.bi-virus2::before{content:"\f828"}.bi-wechat::before{content:"\f829"}.bi-yelp::before{content:"\f82a"}.bi-sign-stop-fill::before{content:"\f82b"}.bi-sign-stop-lights-fill::before{content:"\f82c"}.bi-sign-stop-lights::before{content:"\f82d"}.bi-sign-stop::before{content:"\f82e"}.bi-sign-turn-left-fill::before{content:"\f82f"}.bi-sign-turn-left::before{content:"\f830"}.bi-sign-turn-right-fill::before{content:"\f831"}.bi-sign-turn-right::before{content:"\f832"}.bi-sign-turn-slight-left-fill::before{content:"\f833"}.bi-sign-turn-slight-left::before{content:"\f834"}.bi-sign-turn-slight-right-fill::before{content:"\f835"}.bi-sign-turn-slight-right::before{content:"\f836"}.bi-sign-yield-fill::before{content:"\f837"}.bi-sign-yield::before{content:"\f838"}.bi-ev-station-fill::before{content:"\f839"}.bi-ev-station::before{content:"\f83a"}.bi-fuel-pump-diesel-fill::before{content:"\f83b"}.bi-fuel-pump-diesel::before{content:"\f83c"}.bi-fuel-pump-fill::before{content:"\f83d"}.bi-fuel-pump::before{content:"\f83e"}.bi-0-circle-fill::before{content:"\f83f"}.bi-0-circle::before{content:"\f840"}.bi-0-square-fill::before{content:"\f841"}.bi-0-square::before{content:"\f842"}.bi-rocket-fill::before{content:"\f843"}.bi-rocket-takeoff-fill::before{content:"\f844"}.bi-rocket-takeoff::before{content:"\f845"}.bi-rocket::before{content:"\f846"}.bi-stripe::before{content:"\f847"}.bi-subscript::before{content:"\f848"}.bi-superscript::before{content:"\f849"}.bi-trello::before{content:"\f84a"}.bi-envelope-at-fill::before{content:"\f84b"}.bi-envelope-at::before{content:"\f84c"}.bi-regex::before{content:"\f84d"}.bi-text-wrap::before{content:"\f84e"}.bi-sign-dead-end-fill::before{content:"\f84f"}.bi-sign-dead-end::before{content:"\f850"}.bi-sign-do-not-enter-fill::before{content:"\f851"}.bi-sign-do-not-enter::before{content:"\f852"}.bi-sign-intersection-fill::before{content:"\f853"}.bi-sign-intersection-side-fill::before{content:"\f854"}.bi-sign-intersection-side::before{content:"\f855"}.bi-sign-intersection-t-fill::before{content:"\f856"}.bi-sign-intersection-t::before{content:"\f857"}.bi-sign-intersection-y-fill::before{content:"\f858"}.bi-sign-intersection-y::before{content:"\f859"}.bi-sign-intersection::before{content:"\f85a"}.bi-sign-merge-left-fill::before{content:"\f85b"}.bi-sign-merge-left::before{content:"\f85c"}.bi-sign-merge-right-fill::before{content:"\f85d"}.bi-sign-merge-right::before{content:"\f85e"}.bi-sign-no-left-turn-fill::before{content:"\f85f"}.bi-sign-no-left-turn::before{content:"\f860"}.bi-sign-no-parking-fill::before{content:"\f861"}.bi-sign-no-parking::before{content:"\f862"}.bi-sign-no-right-turn-fill::before{content:"\f863"}.bi-sign-no-right-turn::before{content:"\f864"}.bi-sign-railroad-fill::before{content:"\f865"}.bi-sign-railroad::before{content:"\f866"}.bi-building-add::before{content:"\f867"}.bi-building-check::before{content:"\f868"}.bi-building-dash::before{content:"\f869"}.bi-building-down::before{content:"\f86a"}.bi-building-exclamation::before{content:"\f86b"}.bi-building-fill-add::before{content:"\f86c"}.bi-building-fill-check::before{content:"\f86d"}.bi-building-fill-dash::before{content:"\f86e"}.bi-building-fill-down::before{content:"\f86f"}.bi-building-fill-exclamation::before{content:"\f870"}.bi-building-fill-gear::before{content:"\f871"}.bi-building-fill-lock::before{content:"\f872"}.bi-building-fill-slash::before{content:"\f873"}.bi-building-fill-up::before{content:"\f874"}.bi-building-fill-x::before{content:"\f875"}.bi-building-fill::before{content:"\f876"}.bi-building-gear::before{content:"\f877"}.bi-building-lock::before{content:"\f878"}.bi-building-slash::before{content:"\f879"}.bi-building-up::before{content:"\f87a"}.bi-building-x::before{content:"\f87b"}.bi-buildings-fill::before{content:"\f87c"}.bi-buildings::before{content:"\f87d"}.bi-bus-front-fill::before{content:"\f87e"}.bi-bus-front::before{content:"\f87f"}.bi-ev-front-fill::before{content:"\f880"}.bi-ev-front::before{content:"\f881"}.bi-globe-americas::before{content:"\f882"}.bi-globe-asia-australia::before{content:"\f883"}.bi-globe-central-south-asia::before{content:"\f884"}.bi-globe-europe-africa::before{content:"\f885"}.bi-house-add-fill::before{content:"\f886"}.bi-house-add::before{content:"\f887"}.bi-house-check-fill::before{content:"\f888"}.bi-house-check::before{content:"\f889"}.bi-house-dash-fill::before{content:"\f88a"}.bi-house-dash::before{content:"\f88b"}.bi-house-down-fill::before{content:"\f88c"}.bi-house-down::before{content:"\f88d"}.bi-house-exclamation-fill::before{content:"\f88e"}.bi-house-exclamation::before{content:"\f88f"}.bi-house-gear-fill::before{content:"\f890"}.bi-house-gear::before{content:"\f891"}.bi-house-lock-fill::before{content:"\f892"}.bi-house-lock::before{content:"\f893"}.bi-house-slash-fill::before{content:"\f894"}.bi-house-slash::before{content:"\f895"}.bi-house-up-fill::before{content:"\f896"}.bi-house-up::before{content:"\f897"}.bi-house-x-fill::before{content:"\f898"}.bi-house-x::before{content:"\f899"}.bi-person-add::before{content:"\f89a"}.bi-person-down::before{content:"\f89b"}.bi-person-exclamation::before{content:"\f89c"}.bi-person-fill-add::before{content:"\f89d"}.bi-person-fill-check::before{content:"\f89e"}.bi-person-fill-dash::before{content:"\f89f"}.bi-person-fill-down::before{content:"\f8a0"}.bi-person-fill-exclamation::before{content:"\f8a1"}.bi-person-fill-gear::before{content:"\f8a2"}.bi-person-fill-lock::before{content:"\f8a3"}.bi-person-fill-slash::before{content:"\f8a4"}.bi-person-fill-up::before{content:"\f8a5"}.bi-person-fill-x::before{content:"\f8a6"}.bi-person-gear::before{content:"\f8a7"}.bi-person-lock::before{content:"\f8a8"}.bi-person-slash::before{content:"\f8a9"}.bi-person-up::before{content:"\f8aa"}.bi-scooter::before{content:"\f8ab"}.bi-taxi-front-fill::before{content:"\f8ac"}.bi-taxi-front::before{content:"\f8ad"}.bi-amd::before{content:"\f8ae"}.bi-database-add::before{content:"\f8af"}.bi-database-check::before{content:"\f8b0"}.bi-database-dash::before{content:"\f8b1"}.bi-database-down::before{content:"\f8b2"}.bi-database-exclamation::before{content:"\f8b3"}.bi-database-fill-add::before{content:"\f8b4"}.bi-database-fill-check::before{content:"\f8b5"}.bi-database-fill-dash::before{content:"\f8b6"}.bi-database-fill-down::before{content:"\f8b7"}.bi-database-fill-exclamation::before{content:"\f8b8"}.bi-database-fill-gear::before{content:"\f8b9"}.bi-database-fill-lock::before{content:"\f8ba"}.bi-database-fill-slash::before{content:"\f8bb"}.bi-database-fill-up::before{content:"\f8bc"}.bi-database-fill-x::before{content:"\f8bd"}.bi-database-fill::before{content:"\f8be"}.bi-database-gear::before{content:"\f8bf"}.bi-database-lock::before{content:"\f8c0"}.bi-database-slash::before{content:"\f8c1"}.bi-database-up::before{content:"\f8c2"}.bi-database-x::before{content:"\f8c3"}.bi-database::before{content:"\f8c4"}.bi-houses-fill::before{content:"\f8c5"}.bi-houses::before{content:"\f8c6"}.bi-nvidia::before{content:"\f8c7"}.bi-person-vcard-fill::before{content:"\f8c8"}.bi-person-vcard::before{content:"\f8c9"}.bi-sina-weibo::before{content:"\f8ca"}.bi-tencent-qq::before{content:"\f8cb"}.bi-wikipedia::before{content:"\f8cc"} \ No newline at end of file diff --git a/static/vendor/bootstrap-icons-1.10.5/font/fonts/bootstrap-icons.woff b/static/vendor/bootstrap-icons-1.10.5/font/fonts/bootstrap-icons.woff new file mode 100644 index 00000000..6e72a590 Binary files /dev/null and b/static/vendor/bootstrap-icons-1.10.5/font/fonts/bootstrap-icons.woff differ diff --git a/static/vendor/bootstrap-icons-1.10.5/font/fonts/bootstrap-icons.woff2 b/static/vendor/bootstrap-icons-1.10.5/font/fonts/bootstrap-icons.woff2 new file mode 100644 index 00000000..3b957d5a Binary files /dev/null and b/static/vendor/bootstrap-icons-1.10.5/font/fonts/bootstrap-icons.woff2 differ diff --git a/static/vendor/jmuxer.min.js b/static/vendor/jmuxer.min.js new file mode 100644 index 00000000..f0aa4a7d --- /dev/null +++ b/static/vendor/jmuxer.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("stream")):"function"==typeof define&&define.amd?define(["stream"],t):(e="undefined"!=typeof globalThis?globalThis:e||self).JMuxer=t(e.stream)}(this,(function(e){"use strict";function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},t(e)}function n(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function r(e,t){for(var n=0;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:i}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var s,a=!0,o=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return a=e.done,e},e:function(e){o=!0,s=e},f:function(){try{a||null==n.return||n.return()}finally{if(o)throw s}}}}var v,m;function k(e){if(v){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r1?t-1:0),r=1;r>5,this.ntype=31&this.payload[0],this.isvcl=1==this.ntype||5==this.ntype,this.stype="",this.isfmb=!1}return i(e,[{key:"toString",value:function(){return"".concat(e.type(this),": NRI: ").concat(this.getNri())}},{key:"getNri",value:function(){return this.nri}},{key:"type",value:function(){return this.ntype}},{key:"isKeyframe",value:function(){return this.ntype===e.IDR}},{key:"getPayload",value:function(){return this.payload}},{key:"getPayloadSize",value:function(){return this.payload.byteLength}},{key:"getSize",value:function(){return 4+this.getPayloadSize()}},{key:"getData",value:function(){var e=new Uint8Array(this.getSize());return new DataView(e.buffer).setUint32(0,this.getSize()-4),e.set(this.getPayload(),4),e}}],[{key:"NDR",get:function(){return 1}},{key:"IDR",get:function(){return 5}},{key:"SEI",get:function(){return 6}},{key:"SPS",get:function(){return 7}},{key:"PPS",get:function(){return 8}},{key:"AUD",get:function(){return 9}},{key:"TYPES",get:function(){var t;return s(t={},e.IDR,"IDR"),s(t,e.SEI,"SEI"),s(t,e.SPS,"SPS"),s(t,e.PPS,"PPS"),s(t,e.NDR,"NDR"),s(t,e.AUD,"AUD"),t}},{key:"type",value:function(t){return t.ntype in e.TYPES?e.TYPES[t.ntype]:"UNKNOWN"}}]),e}();function S(e,t){var n=new Uint8Array((0|e.byteLength)+(0|t.byteLength));return n.set(e,0),n.set(t,0|e.byteLength),n}var w=function(){function e(t){n(this,e),this.data=t,this.index=0,this.bitLength=8*t.byteLength}return i(e,[{key:"setData",value:function(e){this.data=e,this.index=0,this.bitLength=8*e.byteLength}},{key:"bitsAvailable",get:function(){return this.bitLength-this.index}},{key:"skipBits",value:function(e){if(this.bitsAvailable1&&void 0!==arguments[1])||arguments[1],n=this.getBits(e,this.index,t);return n}},{key:"getBits",value:function(e,t){var n=!(arguments.length>2&&void 0!==arguments[2])||arguments[2];if(this.bitsAvailable>>r,s=8-r;if(s>=e)return n&&(this.index+=e),i>>s-e;n&&(this.index+=s);var a=e-s;return i<>>1:-1*(e>>>1)}},{key:"readBoolean",value:function(){return 1===this.readBits(1)}},{key:"readUByte",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1;return this.readBits(8*e)}},{key:"readUShort",value:function(){return this.readBits(16)}},{key:"readUInt",value:function(){return this.readBits(32)}}]),e}(),x=function(){function e(t){n(this,e),this.remuxer=t,this.track=t.mp4track}return i(e,[{key:"parseSPS",value:function(t){var n=e.readSPS(new Uint8Array(t));this.track.fps=n.fps,this.track.width=n.width,this.track.height=n.height,this.track.sps=[new Uint8Array(t)],this.track.codec="avc1.";for(var r=new DataView(t.buffer,t.byteOffset+1,4),i=0;i<3;++i){var s=r.getUint8(i).toString(16);s.length<2&&(s="0"+s),this.track.codec+=s}}},{key:"parsePPS",value:function(e){this.track.pps=[new Uint8Array(e)]}},{key:"parseNAL",value:function(e){if(!e)return!1;var t=!1;switch(e.type()){case b.IDR:case b.NDR:t=!0;break;case b.PPS:this.track.pps||(this.parsePPS(e.getPayload()),!this.remuxer.readyToDecode&&this.track.pps&&this.track.sps&&(this.remuxer.readyToDecode=!0)),t=!0;break;case b.SPS:this.track.sps||(this.parseSPS(e.getPayload()),!this.remuxer.readyToDecode&&this.track.pps&&this.track.sps&&(this.remuxer.readyToDecode=!0)),t=!0;break;case b.AUD:k("AUD - ignoing");break;case b.SEI:k("SEI - ignoing")}return t}}],[{key:"extractNALu",value:function(e){for(var t,n,r=0,i=e.byteLength,s=0,a=[],o=0;r0&&x[1]>0&&(d=x[0]/x[1])}if(u.readBoolean()&&u.skipBits(1),u.readBoolean()&&(u.skipBits(4),u.readBoolean()&&u.skipBits(24)),u.readBoolean()&&(u.skipUEG(),u.skipUEG()),u.readBoolean()){var A=u.readUInt(),U=u.readUInt();u.readBoolean()&&(y=U/(2*A))}}return{fps:y>0?y:void 0,width:Math.ceil((16*(i+1)-2*c-2*f)*d),height:(2-a)*(s+1)*16-(a?2:4)*(l+h)}}},{key:"parseHeader",value:function(e){var t=new w(e.getPayload());t.readUByte(),e.isfmb=0===t.readUEG(),e.stype=t.readUEG()}}]),e}(),A=function(){function e(t){n(this,e),this.remuxer=t,this.track=t.mp4track}return i(e,[{key:"extractAAC",value:function(t){var n,r,i=0,s=t.byteLength,a=[];if(!e.isAACPattern(t))return g("Invalid ADTS audio format"),a;for(n=e.getHeaderLength(t),this.aacHeader||(this.aacHeader=t.subarray(0,n));i>>6),t=(60&i[2])>>>2,n=(1&i[2])<<2,n|=(192&i[3])>>>6,r[0]=e<<3,r[0]|=(14&t)>>1,r[1]|=(1&t)<<7,r[1]|=n<<3,this.track.codec="mp4a.40."+e,this.track.channelCount=n,this.track.config=r,this.remuxer.readyToDecode=!0)}}],[{key:"samplingRateMap",get:function(){return[96e3,88200,64e3,48e3,44100,32e3,24e3,22050,16e3,12e3,11025,8e3,7350]}},{key:"getHeaderLength",value:function(e){return 1&e[1]?7:9}},{key:"getFrameLength",value:function(e){return(3&e[3])<<11|e[4]<<3|(224&e[5])>>>5}},{key:"isAACPattern",value:function(e){return 255===e[0]&&240==(240&e[1])&&0==(6&e[1])}}]),e}(),U=function(){function e(t){n(this,e),this.listener={},this.type=""|t}return i(e,[{key:"on",value:function(e,t){return this.listener[e]||(this.listener[e]=[]),this.listener[e].push(t),!0}},{key:"off",value:function(e,t){if(this.listener[e]){var n=this.listener[e].indexOf(t);return n>-1&&this.listener[e].splice(n,1),!0}return!1}},{key:"offAll",value:function(){this.listener={}}},{key:"dispatch",value:function(e,t){return!!this.listener[e]&&(this.listener[e].map((function(e){e.apply(null,[t])})),!0)}}]),e}(),B=function(){function e(){n(this,e)}return i(e,null,[{key:"init",value:function(){var t;for(t in e.types={avc1:[],avcC:[],btrt:[],dinf:[],dref:[],esds:[],ftyp:[],hdlr:[],mdat:[],mdhd:[],mdia:[],mfhd:[],minf:[],moof:[],moov:[],mp4a:[],mvex:[],mvhd:[],sdtp:[],stbl:[],stco:[],stsc:[],stsd:[],stsz:[],stts:[],tfdt:[],tfhd:[],traf:[],trak:[],trun:[],trex:[],tkhd:[],vmhd:[],smhd:[]},e.types)e.types.hasOwnProperty(t)&&(e.types[t]=[t.charCodeAt(0),t.charCodeAt(1),t.charCodeAt(2),t.charCodeAt(3)]);var n=new Uint8Array([0,0,0,0,0,0,0,0,118,105,100,101,0,0,0,0,0,0,0,0,0,0,0,0,86,105,100,101,111,72,97,110,100,108,101,114,0]),r=new Uint8Array([0,0,0,0,0,0,0,0,115,111,117,110,0,0,0,0,0,0,0,0,0,0,0,0,83,111,117,110,100,72,97,110,100,108,101,114,0]);e.HDLR_TYPES={video:n,audio:r};var i=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,12,117,114,108,32,0,0,0,1]),s=new Uint8Array([0,0,0,0,0,0,0,0]);e.STTS=e.STSC=e.STCO=s,e.STSZ=new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0]),e.VMHD=new Uint8Array([0,0,0,1,0,0,0,0,0,0,0,0]),e.SMHD=new Uint8Array([0,0,0,0,0,0,0,0]),e.STSD=new Uint8Array([0,0,0,0,0,0,0,1]);var a=new Uint8Array([105,115,111,109]),o=new Uint8Array([97,118,99,49]),u=new Uint8Array([0,0,0,1]);e.FTYP=e.box(e.types.ftyp,a,u,a,o),e.DINF=e.box(e.types.dinf,e.box(e.types.dref,i))}},{key:"box",value:function(e){for(var t=arguments.length,n=new Array(t>1?t-1:0),r=1;r>24&255,i[1]=s>>16&255,i[2]=s>>8&255,i[3]=255&s,i.set(e,4),a=0,s=8;a>24&255,t>>16&255,t>>8&255,255&t,n>>24,n>>16&255,n>>8&255,255&n,85,196,0,0]))}},{key:"mdia",value:function(t){return e.box(e.types.mdia,e.mdhd(t.timescale,t.duration),e.hdlr(t.type),e.minf(t))}},{key:"mfhd",value:function(t){return e.box(e.types.mfhd,new Uint8Array([0,0,0,0,t>>24,t>>16&255,t>>8&255,255&t]))}},{key:"minf",value:function(t){return"audio"===t.type?e.box(e.types.minf,e.box(e.types.smhd,e.SMHD),e.DINF,e.stbl(t)):e.box(e.types.minf,e.box(e.types.vmhd,e.VMHD),e.DINF,e.stbl(t))}},{key:"moof",value:function(t,n,r){return e.box(e.types.moof,e.mfhd(t),e.traf(r,n))}},{key:"moov",value:function(t,n,r){for(var i=t.length,s=[];i--;)s[i]=e.trak(t[i]);return e.box.apply(null,[e.types.moov,e.mvhd(r,n)].concat(s).concat(e.mvex(t)))}},{key:"mvex",value:function(t){for(var n=t.length,r=[];n--;)r[n]=e.trex(t[n]);return e.box.apply(null,[e.types.mvex].concat(r))}},{key:"mvhd",value:function(t,n){var r=new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,2,t>>24&255,t>>16&255,t>>8&255,255&t,n>>24&255,n>>16&255,n>>8&255,255&n,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,255,255]);return e.box(e.types.mvhd,r)}},{key:"sdtp",value:function(t){var n,r,i=t.samples||[],s=new Uint8Array(4+i.length);for(r=0;r>>8&255),s.push(255&i),s=s.concat(Array.prototype.slice.call(r));for(n=0;n>>8&255),a.push(255&i),a=a.concat(Array.prototype.slice.call(r));var o=e.box(e.types.avcC,new Uint8Array([1,s[3],s[4],s[5],255,224|t.sps.length].concat(s).concat([t.pps.length]).concat(a))),u=t.width,c=t.height;return e.box(e.types.avc1,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,u>>8&255,255&u,c>>8&255,255&c,0,72,0,0,0,72,0,0,0,0,0,0,0,1,18,98,105,110,101,108,112,114,111,46,114,117,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,24,17,17]),o,e.box(e.types.btrt,new Uint8Array([0,28,156,128,0,45,198,192,0,45,198,192])))}},{key:"esds",value:function(e){var t=e.config.byteLength,n=new Uint8Array(26+t+3);return n.set([0,0,0,0,3,23+t,0,1,0,4,15+t,64,21,0,0,0,0,0,0,0,0,0,0,0,5,t]),n.set(e.config,26),n.set([6,1,2],26+t),n}},{key:"mp4a",value:function(t){var n=t.audiosamplerate;return e.box(e.types.mp4a,new Uint8Array([0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,t.channelCount,0,16,0,0,0,0,n>>8&255,255&n,0,0]),e.box(e.types.esds,e.esds(t)))}},{key:"stsd",value:function(t){return"audio"===t.type?e.box(e.types.stsd,e.STSD,e.mp4a(t)):e.box(e.types.stsd,e.STSD,e.avc1(t))}},{key:"tkhd",value:function(t){var n=t.id,r=t.duration,i=t.width,s=t.height,a=t.volume;return e.box(e.types.tkhd,new Uint8Array([0,0,0,7,0,0,0,0,0,0,0,0,n>>24&255,n>>16&255,n>>8&255,255&n,0,0,0,0,r>>24,r>>16&255,r>>8&255,255&r,0,0,0,0,0,0,0,0,0,0,0,0,a>>0&255,a%1*10>>0&255,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,64,0,0,0,i>>8&255,255&i,0,0,s>>8&255,255&s,0,0]))}},{key:"traf",value:function(t,n){var r=e.sdtp(t),i=t.id;return e.box(e.types.traf,e.box(e.types.tfhd,new Uint8Array([0,0,0,0,i>>24,i>>16&255,i>>8&255,255&i])),e.box(e.types.tfdt,new Uint8Array([0,0,0,0,n>>24,n>>16&255,n>>8&255,255&n])),e.trun(t,r.length+16+16+8+16+8+8),r)}},{key:"trak",value:function(t){return t.duration=t.duration||4294967295,e.box(e.types.trak,e.tkhd(t),e.mdia(t))}},{key:"trex",value:function(t){var n=t.id;return e.box(e.types.trex,new Uint8Array([0,0,0,0,n>>24,n>>16&255,n>>8&255,255&n,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,1]))}},{key:"trun",value:function(t,n){var r,i,s,a,o,u,c=t.samples||[],f=c.length,l=12+16*f,h=new Uint8Array(l);for(n+=8+l,h.set([0,0,15,1,f>>>24&255,f>>>16&255,f>>>8&255,255&f,n>>>24&255,n>>>16&255,n>>>8&255,255&n],0),r=0;r>>24&255,s>>>16&255,s>>>8&255,255&s,a>>>24&255,a>>>16&255,a>>>8&255,255&a,o.isLeading<<2|o.dependsOn,o.isDependedOn<<6|o.hasRedundancy<<4|o.paddingValue<<1|o.isNonSync,61440&o.degradPrio,15&o.degradPrio,u>>>24&255,u>>>16&255,u>>>8&255,255&u],12+16*r);return e.box(e.types.trun,h)}},{key:"initSegment",value:function(t,n,r){e.types||e.init();var i,s=e.moov(t,n,r);return(i=new Uint8Array(e.FTYP.byteLength+s.byteLength)).set(e.FTYP),i.set(s,e.FTYP.byteLength),i}}]),e}(),D=1,C=function(){function e(){n(this,e)}return i(e,[{key:"flush",value:function(){this.mp4track.len=0,this.mp4track.samples=[]}},{key:"isReady",value:function(){return!(!this.readyToDecode||!this.samples.length)||null}}],[{key:"getTrackID",value:function(){return D++}}]),e}(),E=function(e){a(r,e);var t=l(r);function r(e){var i;return n(this,r),(i=t.call(this)).readyToDecode=!1,i.nextDts=0,i.dts=0,i.mp4track={id:C.getTrackID(),type:"audio",channelCount:0,len:0,fragmented:!0,timescale:e,duration:e,samples:[],config:"",codec:""},i.samples=[],i.aac=new A(c(i)),i}return i(r,[{key:"resetTrack",value:function(){this.readyToDecode=!1,this.mp4track.codec="",this.mp4track.channelCount="",this.mp4track.config="",this.mp4track.timescale=this.timescale,this.nextDts=0,this.dts=0}},{key:"remux",value:function(e){if(e.length>0)for(var t=0;t0&&this.readyToDecode&&(this.mp4track.len+=a,this.samples.push({units:s,size:a,keyFrame:i.keyFrame,duration:i.duration,compositionTimeOffset:i.compositionTimeOffset}))}}catch(e){n.e(e)}finally{n.f()}}},{key:"getPayload",value:function(){if(!this.isReady())return null;var e,t,n=new Uint8Array(this.mp4track.len),r=0,i=this.mp4track.samples;for(this.dts=this.nextDts;this.samples.length;){var s=this.samples.shift(),a=s.units;if((t=s.duration)<=0)k("remuxer: invalid sample duration at DTS: ".concat(this.nextDts," :").concat(t)),this.mp4track.len-=s.size;else{this.nextDts+=t,e={size:s.size,duration:t,cts:s.compositionTimeOffset||0,flags:{isLeading:0,isDependedOn:0,hasRedundancy:0,degradPrio:0,isNonSync:s.keyFrame?0:1,dependsOn:s.keyFrame?2:1}};var o,u=p(a);try{for(u.s();!(o=u.n()).done;){var c=o.value;n.set(c.getData(),r),r+=c.getSize()}}catch(e){u.e(e)}finally{u.f()}i.push(e)}}return i.length?new Uint8Array(n.buffer,0,this.mp4track.len):null}}]),r}(C),P=function(e){a(r,e);var t=l(r);function r(e){var i;return n(this,r),(i=t.call(this,"remuxer")).initialized=!1,i.trackTypes=[],i.tracks={},i.seq=1,i.env=e,i.timescale=1e3,i.mediaDuration=0,i.aacParser=null,i}return i(r,[{key:"addTrack",value:function(e){if("video"!==e&&"both"!==e||(this.tracks.video=new T(this.timescale),this.trackTypes.push("video")),"audio"===e||"both"===e){var t=new E(this.timescale);this.aacParser=t.getAacParser(),this.tracks.audio=t,this.trackTypes.push("audio")}}},{key:"reset",value:function(){var e,t=p(this.trackTypes);try{for(t.s();!(e=t.n()).done;){var n=e.value;this.tracks[n].resetTrack()}}catch(e){t.e(e)}finally{t.f()}this.initialized=!1}},{key:"destroy",value:function(){this.tracks={},this.offAll()}},{key:"flush",value:function(){if(this.initialized){var e,t=p(this.trackTypes);try{for(t.s();!(e=t.n()).done;){var n=e.value,r=this.tracks[n],i=r.getPayload();if(i&&i.byteLength){var s={type:n,payload:S(B.moof(this.seq,r.dts,r.mp4track),B.mdat(i)),dts:r.dts};"video"===n&&(s.fps=r.mp4track.fps),this.dispatch("buffer",s);var a=(o=r.dts/this.timescale,u=void 0,c=void 0,f=void 0,l=void 0,l="",u=Math.floor(o),(c=parseInt(u/3600,10)%24)>0&&(l+=(c<10?"0"+c:c)+":"),l+=((f=parseInt(u/60,10)%60)<10?"0"+f:f)+":"+((u=u<0?0:u%60)<10?"0"+u:u));k("put segment (".concat(n,"): dts: ").concat(r.dts," frames: ").concat(r.mp4track.samples.length," second: ").concat(a)),r.flush(),this.seq++}}}catch(e){t.e(e)}finally{t.f()}}else this.isReady()&&(this.dispatch("ready"),this.initSegment(),this.initialized=!0,this.flush());var o,u,c,f,l}},{key:"initSegment",value:function(){var e,t=[],n=p(this.trackTypes);try{for(n.s();!(e=n.n()).done;){var r=e.value,i=this.tracks[r];if("browser"==this.env){var s={type:r,payload:B.initSegment([i.mp4track],this.mediaDuration,this.timescale)};this.dispatch("buffer",s)}else t.push(i.mp4track)}}catch(e){n.e(e)}finally{n.f()}if("node"==this.env){var a={type:"all",payload:B.initSegment(t,this.mediaDuration,this.timescale)};this.dispatch("buffer",a)}k("Initial segment generated.")}},{key:"isReady",value:function(){var e,t=p(this.trackTypes);try{for(t.s();!(e=t.n()).done;){var n=e.value;if(!this.tracks[n].readyToDecode||!this.tracks[n].samples.length)return!1}}catch(e){t.e(e)}finally{t.f()}return!0}},{key:"remux",value:function(e){var t,n=p(this.trackTypes);try{for(n.s();!(t=n.n()).done;){var r=t.value,i=e[r];"audio"===r&&this.tracks.video&&!this.tracks.video.readyToDecode||i.length>0&&this.tracks[r].remux(i)}}catch(e){n.e(e)}finally{n.f()}this.flush()}}]),r}(U),L=function(e){a(r,e);var t=l(r);function r(e,i){var s;return n(this,r),(s=t.call(this,"buffer")).type=i,s.queue=new Uint8Array,s.cleaning=!1,s.pendingCleaning=0,s.cleanOffset=30,s.cleanRanges=[],s.sourceBuffer=e,s.sourceBuffer.addEventListener("updateend",(function(){s.pendingCleaning>0&&(s.initCleanup(s.pendingCleaning),s.pendingCleaning=0),s.cleaning=!1,s.cleanRanges.length&&s.doCleanup()})),s.sourceBuffer.addEventListener("error",(function(){s.dispatch("error",{type:s.type,name:"buffer",error:"buffer error"})})),s}return i(r,[{key:"destroy",value:function(){this.queue=null,this.sourceBuffer=null,this.offAll()}},{key:"doCleanup",value:function(){if(this.cleanRanges.length){var e=this.cleanRanges.shift();k("".concat(this.type," remove range [").concat(e[0]," - ").concat(e[1],")")),this.cleaning=!0,this.sourceBuffer.remove(e[0],e[1])}else this.cleaning=!1}},{key:"initCleanup",value:function(e){try{if(this.sourceBuffer.updating)return void(this.pendingCleaning=e);if(this.sourceBuffer.buffered&&this.sourceBuffer.buffered.length&&!this.cleaning){for(var t=0;tthis.cleanOffset&&n<(r=e-this.cleanOffset)&&this.cleanRanges.push([n,r])}this.doCleanup()}}catch(e){g("Error occured while cleaning ".concat(this.type," buffer - ").concat(e.name,": ").concat(e.message))}}},{key:"doAppend",value:function(){if(this.queue.length&&this.sourceBuffer&&!this.sourceBuffer.updating)try{this.sourceBuffer.appendBuffer(this.queue),this.queue=new Uint8Array}catch(t){var e="unexpectedError";"QuotaExceededError"===t.name?(k("".concat(this.type," buffer quota full")),e="QuotaExceeded"):(g("Error occured while appending ".concat(this.type," buffer - ").concat(t.name,": ").concat(t.message)),e="InvalidStateError"),this.dispatch("error",{type:this.type,name:e,error:"buffer error"})}}},{key:"feed",value:function(e){this.queue=S(this.queue,e)}}]),r}(U);return function(r){a(o,r);var s=l(o);function o(e){var r;n(this,o),(r=s.call(this,"jmuxer")).isReset=!1;return r.options=Object.assign({},{node:"",mode:"both",flushingTime:500,maxDelay:500,clearBuffer:!0,fps:30,readFpsFromTrack:!1,debug:!1,onReady:function(){},onError:function(){},onMissingVideoFrames:function(){},onMissingAudioFrames:function(){}},e),r.env="object"===("undefined"==typeof process?"undefined":t(process))&&"undefined"==typeof window?"node":"browser",r.options.debug&&(v=console.log,m=console.error),r.options.fps||(r.options.fps=30),r.frameDuration=1e3/r.options.fps|0,r.remuxController=new P(r.env),r.remuxController.addTrack(r.options.mode),r.initData(),r.remuxController.on("buffer",r.onBuffer.bind(c(r))),"browser"==r.env&&(r.remuxController.on("ready",r.createBuffer.bind(c(r))),r.initBrowser()),r}return i(o,[{key:"initData",value:function(){this.lastCleaningTime=Date.now(),this.kfPosition=[],this.kfCounter=0,this.pendingUnits={},this.remainingData=new Uint8Array,this.startInterval()}},{key:"initBrowser",value:function(){"string"==typeof this.options.node&&""==this.options.node&&g("no video element were found to render, provide a valid video element"),this.node="string"==typeof this.options.node?document.getElementById(this.options.node):this.options.node,this.mseReady=!1,this.setupMSE()}},{key:"createStream",value:function(){var t=this.feed.bind(this),n=this.destroy.bind(this);return this.stream=new e.Duplex({writableObjectMode:!0,read:function(e){},write:function(e,n,r){t(e),r()},final:function(e){n(),e()}}),this.stream}},{key:"setupMSE",value:function(){if(window.MediaSource=window.MediaSource||window.WebKitMediaSource,!window.MediaSource)throw"Oops! Browser does not support media source extension.";this.isMSESupported=!!window.MediaSource,this.mediaSource=new MediaSource,this.url=URL.createObjectURL(this.mediaSource),this.node.src=this.url,this.mseEnded=!1,this.mediaSource.addEventListener("sourceopen",this.onMSEOpen.bind(this)),this.mediaSource.addEventListener("sourceclose",this.onMSEClose.bind(this)),this.mediaSource.addEventListener("webkitsourceopen",this.onMSEOpen.bind(this)),this.mediaSource.addEventListener("webkitsourceclose",this.onMSEClose.bind(this))}},{key:"endMSE",value:function(){if(!this.mseEnded)try{this.mseEnded=!0,this.mediaSource.endOfStream()}catch(e){g("mediasource is not available to end")}}},{key:"feed",value:function(e){var t,n,r,i=!1,s={video:[],audio:[]};if(e&&this.remuxController){if(r=e.duration?parseInt(e.duration):0,e.video){e.video=S(this.remainingData,e.video);var a=h(x.extractNALu(e.video),2);if(t=a[0],n=a[1],this.remainingData=n||new Uint8Array,!(t.length>0))return g("Failed to extract any NAL units from video data:",n),void("function"==typeof this.options.onMissingVideoFrames&&this.options.onMissingVideoFrames.call(null,e));s.video=this.getVideoFrames(t,r,e.compositionTimeOffset),i=!0}if(e.audio){if(!((t=this.remuxController.aacParser.extractAAC(e.audio)).length>0))return g("Failed to extract audio data from:",e.audio),void("function"==typeof this.options.onMissingAudioFrames&&this.options.onMissingAudioFrames.call(null,e));s.audio=this.getAudioFrames(t,r),i=!0}i?this.remuxController.remux(s):g("Input object must have video and/or audio property. Make sure it is a valid typed array")}}},{key:"getVideoFrames",value:function(e,t,n){var r,i=this,s=[],a=[],o=0,u=!1,c=!1;this.pendingUnits.units&&(s=this.pendingUnits.units,c=this.pendingUnits.vcl,u=this.pendingUnits.keyFrame,this.pendingUnits={});var f,l=p(e);try{for(l.s();!(f=l.n()).done;){var h=f.value,d=new b(h);d.type()!==b.IDR&&d.type()!==b.NDR||x.parseHeader(d),s.length&&c&&(d.isfmb||!d.isvcl)&&(a.push({units:s,keyFrame:u}),s=[],u=!1,c=!1),s.push(d),u=u||d.isKeyframe(),c=c||d.isvcl}}catch(e){l.e(e)}finally{l.f()}if(s.length)if(t)if(c)a.push({units:s,keyFrame:u});else{var y=a.length-1;y>=0&&(a[y].units=a[y].units.concat(s))}else this.pendingUnits={units:s,keyFrame:u,vcl:c};return r=t?t/a.length|0:this.frameDuration,o=t?t-r*a.length:0,a.map((function(e){e.duration=r,e.compositionTimeOffset=n,o>0&&(e.duration++,o--),i.kfCounter++,e.keyFrame&&i.options.clearBuffer&&i.kfPosition.push(i.kfCounter*r/1e3)})),k("jmuxer: No. of frames of the last chunk: ".concat(a.length)),a}},{key:"getAudioFrames",value:function(e,t){var n,r,i=[],s=0,a=p(e);try{for(a.s();!(r=a.n()).done;){var o=r.value;i.push({units:o})}}catch(e){a.e(e)}finally{a.f()}return n=t?t/i.length|0:this.frameDuration,s=t?t-n*i.length:0,i.map((function(e){e.duration=n,s>0&&(e.duration++,s--)})),i}},{key:"destroy",value:function(){if(this.stopInterval(),this.stream&&(this.remuxController.flush(),this.stream.push(null),this.stream=null),this.remuxController&&(this.remuxController.destroy(),this.remuxController=null),this.bufferControllers){for(var e in this.bufferControllers)this.bufferControllers[e].destroy();this.bufferControllers=null,this.endMSE()}this.node=!1,this.mseReady=!1,this.videoStarted=!1,this.mediaSource=null}},{key:"reset",value:function(){if(this.stopInterval(),this.isReset=!0,this.node.pause(),this.remuxController&&this.remuxController.reset(),this.bufferControllers){for(var e in this.bufferControllers)this.bufferControllers[e].destroy();this.bufferControllers=null,this.endMSE()}this.initData(),"browser"==this.env&&this.initBrowser(),k("JMuxer was reset")}},{key:"createBuffer",value:function(){if(this.mseReady&&this.remuxController&&this.remuxController.isReady()&&!this.bufferControllers)for(var e in this.bufferControllers={},this.remuxController.tracks){var t=this.remuxController.tracks[e];if(!o.isSupported("".concat(e,'/mp4; codecs="').concat(t.mp4track.codec,'"')))return g("Browser does not support codec"),!1;var n=this.mediaSource.addSourceBuffer("".concat(e,'/mp4; codecs="').concat(t.mp4track.codec,'"'));this.bufferControllers[e]=new L(n,e),this.bufferControllers[e].on("error",this.onBufferError.bind(this))}}},{key:"startInterval",value:function(){var e=this;this.interval=setInterval((function(){e.options.flushingTime?e.applyAndClearBuffer():e.bufferControllers&&e.cancelDelay()}),this.options.flushingTime||1e3)}},{key:"stopInterval",value:function(){this.interval&&clearInterval(this.interval)}},{key:"cancelDelay",value:function(){if(this.node.buffered&&this.node.buffered.length>0&&!this.node.seeking){var e=this.node.buffered.end(0);e-this.node.currentTime>this.options.maxDelay/1e3&&(console.log("delay"),this.node.currentTime=e-.001)}}},{key:"releaseBuffer",value:function(){for(var e in this.bufferControllers)this.bufferControllers[e].doAppend()}},{key:"applyAndClearBuffer",value:function(){this.bufferControllers&&(this.releaseBuffer(),this.clearBuffer())}},{key:"getSafeClearOffsetOfBuffer",value:function(e){for(var t,n="audio"===this.options.mode&&e||0,r=0;r=e);r++)t=this.kfPosition[r];return t&&(this.kfPosition=this.kfPosition.filter((function(e){return e=t}))),n}},{key:"clearBuffer",value:function(){if(this.options.clearBuffer&&Date.now()-this.lastCleaningTime>1e4){for(var e in this.bufferControllers){var t=this.getSafeClearOffsetOfBuffer(this.node.currentTime);this.bufferControllers[e].initCleanup(t)}this.lastCleaningTime=Date.now()}}},{key:"onBuffer",value:function(e){this.options.readFpsFromTrack&&void 0!==e.fps&&this.options.fps!=e.fps&&(this.options.fps=e.fps,this.frameDuration=Math.ceil(1e3/e.fps),k("JMuxer changed FPS to ".concat(e.fps," from track data"))),"browser"==this.env?this.bufferControllers&&this.bufferControllers[e.type]&&this.bufferControllers[e.type].feed(e.payload):this.stream&&this.stream.push(e.payload),0===this.options.flushingTime&&this.applyAndClearBuffer()}},{key:"onMSEOpen",value:function(){this.mseReady=!0,URL.revokeObjectURL(this.url),"function"==typeof this.options.onReady&&this.options.onReady.call(null,this.isReset)}},{key:"onMSEClose",value:function(){this.mseReady=!1,this.videoStarted=!1}},{key:"onBufferError",value:function(e){if("QuotaExceeded"==e.name)return k("JMuxer cleaning ".concat(e.type," buffer due to QuotaExceeded error")),void this.bufferControllers[e.type].initCleanup(this.node.currentTime);"InvalidStateError"==e.name?(k("JMuxer is reseting due to InvalidStateError"),this.reset()):this.endMSE(),"function"==typeof this.options.onError&&this.options.onError.call(null,e)}}],[{key:"isSupported",value:function(e){return window.MediaSource&&window.MediaSource.isTypeSupported(e)}}]),o}(U)})); diff --git a/templates/js/libflagship.js.tpl b/templates/js/libflagship.js.tpl new file mode 100644 index 00000000..a9bef111 --- /dev/null +++ b/templates/js/libflagship.js.tpl @@ -0,0 +1,10 @@ +<% import js %>\ +${js.header()} + +% for enum in [_mqtt.get("MqttMsgType")]: +const ${enum.name} = { + % for const in enum.consts: + ${const.aligned_name} : ${const.aligned_hex_value}, + % endfor +} +% endfor diff --git a/templates/lib/js.py b/templates/lib/js.py new file mode 100644 index 00000000..7300c5d0 --- /dev/null +++ b/templates/lib/js.py @@ -0,0 +1,8 @@ +def header(): + return \ + "// ------------------------------------------\n" \ + "// Generated by Transwarp\n" \ + "//\n" \ + "// THIS FILE IS AUTOMATICALLY GENERATED.\n" \ + "// DO NOT EDIT. ALL CHANGES WILL BE LOST.\n" \ + "// ------------------------------------------" diff --git a/templates/python/mqtt.py.tpl b/templates/python/mqtt.py.tpl index 186748b4..0f0a344e 100644 --- a/templates/python/mqtt.py.tpl +++ b/templates/python/mqtt.py.tpl @@ -53,6 +53,12 @@ class MqttMsg(_MqttMsg): @classmethod def parse(cls, p, key): p = mqtt_checksum_remove(p) + ## Old printer firmwares transmit mqtt messages in an earlier, + ## unsupported format. Peek at p[6] (corresponding to `.m5` field), + ## which seems to be set to 1 for old-style messages, and 2 for + ## new-style messages. + if p[6] != 2: + raise ValueError(f"Unsupported mqtt message format (expected 2, but found {p[6]})") body, data = p[:64], mqtt_aes_decrypt(p[64:], key) res = super().parse(body + data) assert res[0].size == (len(p) + 1) diff --git a/templates/python/pppp.py.tpl b/templates/python/pppp.py.tpl index 52f2c8e3..2bfa4dd6 100644 --- a/templates/python/pppp.py.tpl +++ b/templates/python/pppp.py.tpl @@ -65,11 +65,12 @@ class ${enum.name}(enum.IntEnum): %endfor @classmethod - def parse(cls, p): - return cls(struct.unpack("B", p[:1])[0]), p[1:] + def parse(cls, p, typ=${enum.field("@type").type}): + d = typ.parse(p) + return cls(d[0]), d[1] - def pack(self): - return struct.pack("B", self) + def pack(self, typ=${enum.field("@type").type}): + return typ.pack(self) %endif %endfor diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 00000000..31111e4f --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,285 @@ +""" +This module is designed to implement a Flask web server for video +streaming and handling other functionalities of AnkerMake M5. +It also implements various services, routes and functions including. + +Methods: + - startup(): Registers required services on server start + +Routes: + - /ws/mqtt: Handles receiving and sending messages on the 'mqttqueue' stream service through websocket + - /ws/video: Handles receiving and sending messages on the 'videoqueue' stream service through websocket + - /ws/ctrl: Handles controlling of light and video quality through websocket + - /video: Handles the video streaming/downloading feature in the Flask app + - /: Renders the html template for the root route, which is the homepage of the Flask app + - /api/version: Returns the version details of api and server as dictionary + - /api/ankerctl/config/upload: Handles the uploading of configuration file \ + to Flask server and returns a HTML redirect response + - /api/ankerctl/server/reload: Reloads the Flask server and returns a HTML redirect response + - /api/files/local: Handles the uploading of files to Flask server and returns a dictionary containing file details + +Functions: + - webserver(config, host, port, **kwargs): Starts the Flask webserver + +Services: + - util: Houses utility services for use in the web module + - config: Handles configuration manipulation for ankerctl +""" +import json +import logging as log + +from secrets import token_urlsafe as token +from flask import Flask, flash, request, render_template, Response, session, url_for +from flask_sock import Sock +from user_agents import parse as user_agent_parse + +from libflagship import ROOT_DIR + +from web.lib.service import ServiceManager + +import web.config +import web.platform +import web.util + +import cli.util +import cli.config + + +app = Flask(__name__, root_path=ROOT_DIR, static_folder="static", template_folder="static") +# secret_key is required for flash() to function +app.secret_key = token(24) +app.config.from_prefixed_env() +app.svc = ServiceManager() + +sock = Sock(app) + + +# autopep8: off +import web.service.pppp +import web.service.video +import web.service.mqtt +import web.service.filetransfer +# autopep8: on + + +@sock.route("/ws/mqtt") +def mqtt(sock): + """ + Handles receiving and sending messages on the 'mqttqueue' stream service through websocket + """ + if not app.config["login"]: + return + for data in app.svc.stream("mqttqueue"): + log.debug(f"MQTT message: {data}") + sock.send(json.dumps(data)) + + +@sock.route("/ws/video") +def video(sock): + """ + Handles receiving and sending messages on the 'videoqueue' stream service through websocket + """ + if not app.config["login"]: + return + for msg in app.svc.stream("videoqueue"): + sock.send(msg.data) + + +@sock.route("/ws/ctrl") +def ctrl(sock): + """ + Handles controlling of light and video quality through websocket + """ + if not app.config["login"]: + return + + # send a response on connect, to let the client know the connection is ready + sock.send(json.dumps({"ankerctl": 1})) + + while True: + msg = json.loads(sock.receive()) + + if "light" in msg: + with app.svc.borrow("videoqueue") as vq: + vq.api_light_state(msg["light"]) + + if "quality" in msg: + with app.svc.borrow("videoqueue") as vq: + vq.api_video_mode(msg["quality"]) + + +@app.get("/video") +def video_download(): + """ + Handles the video streaming/downloading feature in the Flask app + """ + def generate(): + if not app.config["login"]: + return + for msg in app.svc.stream("videoqueue"): + yield msg.data + + return Response(generate(), mimetype="video/mp4") + + +@app.get("/") +def app_root(): + """ + Renders the html template for the root route, which is the homepage of the Flask app + """ + config = app.config["config"] + with config.open() as cfg: + user_agent = user_agent_parse(request.headers.get("User-Agent")) + user_os = web.platform.os_platform(user_agent.os.family) + + if cfg: + anker_config = str(web.config.config_show(cfg)) + printer = cfg.printers[app.config["printer_index"]] + else: + anker_config = "No printers found, please load your login config..." + printer = None + + if ":" in request.host: + request_host, request_port = request.host.split(":", 1) + else: + request_host = request.host + request_port = "80" + + return render_template( + "index.html", + request_host=request_host, + request_port=request_port, + configure=app.config["login"], + login_file_path=web.platform.login_path(user_os), + anker_config=anker_config, + printer=printer + ) + + +@app.get("/api/version") +def app_api_version(): + """ + Returns the version details of api and server as dictionary + + Returns: + A dictionary containing version details of api and server + """ + return {"api": "0.1", "server": "1.9.0", "text": "OctoPrint 1.9.0"} + + +@app.post("/api/ankerctl/config/upload") +def app_api_ankerctl_config_upload(): + """ + Handles the uploading of configuration file to Flask server + + Returns: + A HTML redirect response + """ + if request.method != "POST": + return web.util.flash_redirect(url_for('app_root')) + if "login_file" not in request.files: + return web.util.flash_redirect(url_for('app_root'), "No file found", "danger") + file = request.files["login_file"] + + try: + web.config.config_import(file, app.config["config"]) + return web.util.flash_redirect(url_for('app_api_ankerctl_server_reload'), + "AnkerMake Config Imported!", "success") + except web.config.ConfigImportError as err: + log.exception(f"Config import failed: {err}") + return web.util.flash_redirect(url_for('app_root'), f"Error: {err}", "danger") + except Exception as err: + log.exception(f"Config import failed: {err}") + return web.util.flash_redirect(url_for('app_root'), f"Unexpected Error occurred: {err}", "danger") + + +@app.get("/api/ankerctl/server/reload") +def app_api_ankerctl_server_reload(): + """ + Reloads the Flask server + + Returns: + A HTML redirect response + """ + config = app.config["config"] + + with config.open() as cfg: + app.config["login"] = bool(cfg) + if not cfg: + return web.util.flash_redirect(url_for('app_root'), "No printers found in config", "warning") + if "_flashes" in session: + session["_flashes"].clear() + + try: + app.svc.restart_all(await_ready=False) + except Exception as err: + log.exception(err) + return web.util.flash_redirect(url_for('app_root'), f"Ankerctl could not be reloaded: {err}", "danger") + + return web.util.flash_redirect(url_for('app_root'), "Ankerctl reloaded successfully", "success") + + +@app.post("/api/files/local") +def app_api_files_local(): + """ + Handles the uploading of files to Flask server + + Returns: + A dictionary containing file details + """ + user_name = request.headers.get("User-Agent", "ankerctl").split(url_for('app_root'))[0] + + no_act = not cli.util.parse_http_bool(request.form["print"]) + + if no_act: + cli.util.http_abort(409, "Upload-only not supported by Ankermake M5") + + fd = request.files["file"] + + with app.svc.borrow("filetransfer") as ft: + try: + ft.send_file(fd, user_name) + except ConnectionError as E: + log.error(f"Connection error: {E}") + # This message will be shown in i.e. PrusaSlicer, so attempt to + # provide a readable explanation. + cli.util.http_abort( + 503, + "Cannot connect to printer!\n" \ + "\n" \ + "Please verify that printer is online, and on the same network as ankerctl.\n" \ + "\n" \ + f"Exception information: {E}" + ) + + return {} + + +def webserver(config, printer_index, host, port, insecure=False, **kwargs): + """ + Starts the Flask webserver + + Args: + - config: A configuration object containing configuration information + - host: A string containing host address to start the server + - port: An integer specifying the port number of server + - **kwargs: A dictionary containing additional configuration information + + Returns: + - None + """ + with config.open() as cfg: + if cfg and printer_index >= len(cfg.printers): + log.critical(f"Printer number {printer_index} out of range, max printer number is {len(cfg.printers)-1} ") + app.config["config"] = config + app.config["login"] = bool(cfg) + app.config["printer_index"] = printer_index + app.config["port"] = port + app.config["host"] = host + app.config["insecure"] = insecure + app.config.update(kwargs) + app.svc.register("pppp", web.service.pppp.PPPPService()) + app.svc.register("videoqueue", web.service.video.VideoQueue()) + app.svc.register("mqttqueue", web.service.mqtt.MqttQueue()) + app.svc.register("filetransfer", web.service.filetransfer.FileTransferService()) + app.run(host=host, port=port) diff --git a/web/config.py b/web/config.py new file mode 100644 index 00000000..291ebf18 --- /dev/null +++ b/web/config.py @@ -0,0 +1,104 @@ +"""Module: config_manager + +This module provides utility functions for importing, showing, and handling printer +configuration settings. + +Classes: +- ConfigImportError: Raised when there is an error with the config api. + +Functions: +- config_show(config): Takes a configuration object as input and returns a string + representation of the configuration. +- config_import(login_file, config): Loads the configuration from the API. login_file is a + file object containing the user's login information, + while config is a configuration object. +Returns: +- config_output: A formatted string containing the configuration information. +""" +import libflagship.httpapi +import libflagship.logincache + +import cli.util +import cli.config + + +class ConfigImportError(Exception): + """ + Raised when there is an error with the config api. + """ + + +def config_show(config: object): + """ + Takes a configuration object as input and returns a string representation of the configuration. + + Args: + - config: A configuration object. + + Returns: + - config_output: A formatted string containing the configuration information. + """ + + config_output = f"""Account: + user_id: {config.account.user_id[:10]}...[REDACTED] + auth_token: {config.account.auth_token[:10]}...[REDACTED] + email: {config.account.email} + region: {config.account.region.upper()} + +""" + config_output += "Printers:\n" + for i, printer in enumerate(config.printers): + config_output += f"""\ + printer: {i} + id: {printer.id} + name: {printer.name} + duid: {printer.p2p_duid} + sn: {printer.sn} + model: {printer.model} + created: {printer.create_time} + updated: {printer.update_time} + ip: {printer.ip_addr} + wifi_mac: {cli.util.pretty_mac(printer.wifi_mac)} +""" + config_output += " api_hosts:\n" + for host in printer.api_hosts: + config_output += f" - {host}\n" + config_output += " p2p_hosts:\n" + for host in printer.p2p_hosts: + config_output += f" - {host}\n" + return config_output + + +def config_import(login_file: object, config: object): + """ + Loads the configuration from the API. login_file is a file object containing the user's login information, + while config is a configuration object. + + Args: + - login_file: A file object containing the user's login information. + - config: A configuration object. + + Returns: + - config_output: A formatted string containing the configuration information. + """ + # extract auth token + cache = libflagship.logincache.load(login_file.stream.read())["data"] + auth_token = cache["auth_token"] + + # extract account region + region = libflagship.logincache.guess_region(cache["ab_code"]) + + try: + new_config = cli.config.load_config_from_api(auth_token, region, False) + except libflagship.httpapi.APIError as err: + raise ConfigImportError( + f"Config import failed: {err}. \ + Auth token might be expired: make sure Ankermake Slicer can connect, then try again") + except Exception as err: + raise ConfigImportError(f"Config import failed: {err}") + + try: + config.save("default", new_config) + except Exception as E: + raise ConfigImportError(f"Config import failed: {E}") + return new_config diff --git a/web/lib/service.py b/web/lib/service.py new file mode 100644 index 00000000..fa625de6 --- /dev/null +++ b/web/lib/service.py @@ -0,0 +1,364 @@ +import atexit +import logging as log +import contextlib + +from enum import Enum +from threading import Thread, Event +from datetime import datetime, timedelta +from multiprocessing import Queue + + +class Holdoff: + + def __init__(self): + self.deadline = None + + def reset(self, delay=None): + self.deadline = datetime.now() + if delay: + self.deadline += timedelta(seconds=delay) + + @property + def passed(self): + return datetime.now() > self.deadline + + +class ServiceError(Exception): + pass + + +class ServiceStoppedError(ServiceError): + pass + + +class ServiceSignal(Exception): + pass + + +class ServiceRestartSignal(ServiceSignal): + pass + + +class RunState(Enum): + Starting = 2 + Running = 3 + # Idle = 4 + Stopping = 5 + Stopped = 6 + + +class Service(Thread): + + def __init__(self): + super().__init__() + self.running = True + self.deadline = None + self.state = RunState.Stopped + self.wanted = False + self._event = Event() + self.handlers = [] + self._holdoff = Holdoff() + self.daemon = True + super().start() + + @property + def name(self): + return type(self).__name__ + + def start(self): + log.info(f"{self.name}: Requesting start") + self.wanted = True + self._event.set() + + def stop(self): + log.info(f"{self.name}: Requesting stop") + self.wanted = False + self._event.set() + + def restart(self): + log.info(f"{self.name}: Requesting restart") + wanted = self.wanted + self.stop() + self.await_stopped() + if wanted: + self.start() + self.await_ready() + + def shutdown(self): + if self.state != RunState.Stopped: + self.stop() + self.await_stopped() + + self.running = False + self._event.set() + return self.join() + + def idle(self, timeout=None): + if self._event.wait(timeout=timeout): + self._event.clear() + + def _attempt_start(self): + try: + log.debug(f"{self.name} worker starting..") + self.worker_start() + except Exception as E: + if self.wanted: + if isinstance(E, TimeoutError): + pass + elif isinstance(E, ServiceStoppedError): + log.error(f"{self.name}: Failed to start worker: {E}. Retrying in 1 second.") + else: + log.exception(f"{self.name}: Failed to start worker: {E}. Retrying in 1 second.") + self._holdoff.reset(delay=1) + else: + if not isinstance(E, (TimeoutError, ServiceStoppedError)): + log.error(f"{self.name}: Failed to start worker: {E}. Shutting down service.") + self.state = RunState.Stopped + else: + log.info(f"{self.name}: Worker started") + self.state = RunState.Running + + def _attempt_run(self): + try: + self.worker_run(timeout=0.1) + except ServiceRestartSignal: + log.info(f"{self.name}: Service requested restart.") + self._holdoff.reset(delay=1) + self.state = RunState.Stopping + except Exception: + log.exception(f"{self.name}: Unexpected exception while running worker") + log.warning(f"{self.name}: Stopping worker due to exception") + self._holdoff.reset() + self.state = RunState.Stopping + + def _attempt_stop(self): + try: + self.worker_stop() + except Exception as E: + log.exception(f"{self.name}: Failed to stop worker: {E}. Retrying in 1 second.") + self._holdoff.reset(delay=1) + else: + log.info(f"{self.name}: Worker stopped") + self.state = RunState.Stopped + + def run(self): + self.worker_init() + + while self.running: + if self.state == RunState.Starting: + if self._holdoff.passed: + self._attempt_start() + else: + self.idle(timeout=0.1) + + elif self.state == RunState.Running: + if self.wanted: + self._attempt_run() + else: + log.debug(f"{self.name}: Stopping worker") + self._holdoff.reset() + self.state = RunState.Stopping + + elif self.state == RunState.Stopping: + if self._holdoff.passed: + self._attempt_stop() + else: + self.idle(timeout=0.1) + + elif self.state == RunState.Stopped: + if self.wanted: + log.debug(f"{self.name}: Starting worker") + self._holdoff.reset() + self.state = RunState.Starting + else: + self.idle(timeout=0.1) + else: + raise ValueError("Unknown state value") + + log.debug(f"{self.name}: Shutting down thread") + if self.state == RunState.Running: + self.handlers.clear() + self.worker_stop() + log.debug(f"{self.name}: Thread exit") + + def worker_init(self): + pass + + def worker_start(self): + pass + + def worker_run(self, timeout): + pass + + def worker_stop(self): + pass + + def notify(self, data): + for handler in self.handlers: + handler(data) + + @contextlib.contextmanager + def tap(self, handler): + self.handlers.append(handler) + try: + yield self + finally: + self.handlers.remove(handler) + + def await_ready(self): + while True: + log.debug(f"{self.name}: Awaiting ready ({self.state})") + if not (self.running and self.wanted): + raise ServiceStoppedError(f"{self.name}: Waiting for stopped thread") + + if self.state == RunState.Running: + log.debug(f"{self.name}: Ready") + return True + + self.idle(timeout=0.4) + + def await_stopped(self): + while True: + if self.wanted: + log.warning(f"{self.name}: Service started while waiting for it to stop") + return False + + if self.state == RunState.Stopped: + log.debug(f"{self.name}: Stopped") + return True + + self.idle(timeout=0.4) + + +class ServiceManager: + + def __init__(self): + self.svcs = {} + self.refs = {} + atexit.register(self.atexit) + + def __iter__(self): + return iter(self.svcs) + + def __contains__(self, name): + return name in self.svcs + + def atexit(self): + log.debug("ServiceManager: Shutting down threads..") + self.dump() + for svc in self.svcs.values(): + if svc.state != RunState.Stopped: + svc.stop() + + log.debug("ServiceManager: Waiting for threads to stop..") + self.dump() + for svc in self.svcs.values(): + svc.await_stopped() + + log.debug("ServiceManager: Cleaning up threads..") + self.dump() + for svc in self.svcs.values(): + svc.shutdown() + + log.info("ServiceManager: Shutdown complete") + + def dump(self): + log.debug("Service state") + for name in self.svcs: + svc = self.svcs[name] + ref = self.refs[name] + log.debug(f" [{ref:>4}] {name:20} running={svc.running} state={svc.state} wanted={svc.wanted}") + + def register(self, name: str, svc: Service): + if name in self: + raise KeyError(f"Trying to register {name!r} as {svc} while already taken by {self.svcs[name]}") + + self.svcs[name] = svc + self.refs[name] = 0 + + def unregister(self, name: str): + if name not in self: + raise KeyError(f"Trying to unregister unknown service {name!r}") + + if self.refs[name]: + raise ServiceError(f"Trying to unregister service {name!r} with {self.refs[name]} reference(s)") + + del self.svcs[name] + del self.refs[name] + + def restart_all(self, await_ready=True): + wanted = {} + + for name, svc in self.svcs.items(): + wanted[name] = svc.wanted + svc.stop() + + for name, svc in self.svcs.items(): + svc.await_stopped() + + for name, svc in self.svcs.items(): + if not wanted[name]: + continue + + svc.start() + + if not await_ready: + continue + + try: + svc.await_ready() + except ServiceStoppedError: + # ignore service stopped error, since restart_all() is a + # best-effort function. + pass + + def get(self, name: str, ready=True) -> Service: + if name not in self: + raise KeyError(f"Requested unknown service {name!r}") + + svc = self.svcs[name] + self.refs[name] += 1 + + if self.refs[name] == 1: + svc.start() + + if ready: + try: + svc.await_ready() + except ServiceError: + self.put(name) + raise + + return svc + + def put(self, name: str): + if name not in self: + raise KeyError(f"Requested unknown service {name!r}") + + svc = self.svcs[name] + + assert self.refs[name] + + self.refs[name] -= 1 + + if not self.refs[name]: + svc.stop() + + @contextlib.contextmanager + def borrow(self, name: str): + svc = self.get(name) + try: + yield svc + finally: + self.put(name) + + def stream(self, name: str): + try: + with self.borrow(name) as svc: + queue = Queue() + + with svc.tap(lambda data: queue.put(data)): + while svc.state == RunState.Running: + yield queue.get() + except (EOFError, OSError, ServiceStoppedError): + return diff --git a/web/platform.py b/web/platform.py new file mode 100644 index 00000000..a9c689d4 --- /dev/null +++ b/web/platform.py @@ -0,0 +1,19 @@ +def os_platform(os_family: str): + if os_family.startswith('Mac OS'): + return 'macos' + elif os_family.startswith('Windows'): + return 'windows' + elif 'Linux' in os_family: + return 'linux' + else: + return None + + +def login_path(platform: str): + if platform == 'macos': + return '~/Library/Application Support/AnkerMake/AnkerMake_64bit_fp/login.json' + elif platform == 'windows': + return r'%LOCALAPPDATA%\Ankermake\AnkerMake_64bit_fp\login.json' + else: + return 'Unsupported OS: You must supply path to login.json' + \ No newline at end of file diff --git a/web/service/filetransfer.py b/web/service/filetransfer.py new file mode 100644 index 00000000..5ef8eb49 --- /dev/null +++ b/web/service/filetransfer.py @@ -0,0 +1,77 @@ +import uuid +import logging as log + +from multiprocessing import Queue + +from ..lib.service import Service +from .. import app + +from libflagship.pppp import P2PCmdType, Aabb, FileTransfer +from libflagship.ppppapi import FileUploadInfo, PPPPError + +import cli.mqtt +import cli.util + + +class FileTransferService(Service): + + def api_aabb(self, api, frametype, msg=b"", pos=0): + api.send_aabb(msg, frametype=frametype, pos=pos) + + def api_aabb_request(self, api, frametype, msg=b"", pos=0): + self.api_aabb(api, frametype, msg, pos) + resp = self._tap.get() + log.debug(f"{self.name}: Aabb response: {resp}") + + def send_file(self, fd, user_name): + try: + api = self.pppp._api + except AttributeError: + raise ConnectionError("No pppp connection to printer") + + data = fd.read() + fui = FileUploadInfo.from_data(data, fd.filename, user_name=user_name, user_id="-", machine_id="-") + log.info(f"Going to upload {fui.size} bytes as {fui.name!r}") + try: + log.info("Requesting file transfer..") + api.send_xzyh(str(uuid.uuid4())[:16].encode(), cmd=P2PCmdType.P2P_SEND_FILE) + + log.info("Sending file metadata..") + self.api_aabb(api, FileTransfer.BEGIN, bytes(fui) + b"\x00") + + log.info("Sending file contents..") + blocksize = 1024 * 32 + chunks = cli.util.split_chunks(data, blocksize) + pos = 0 + + for chunk in chunks: + self.api_aabb_request(api, FileTransfer.DATA, chunk, pos) + pos += len(chunk) + + log.info("File upload complete. Requesting print start of job.") + + self.api_aabb_request(api, FileTransfer.END) + except PPPPError as E: + log.error(f"Could not send print job: {E}") + else: + log.info("Successfully sent print job") + + def handler(self, data): + chan, msg = data + if isinstance(msg, Aabb): + self._tap.put(msg) + + def worker_start(self): + self.pppp = app.svc.get("pppp") + self._tap = Queue() + + self.pppp.handlers.append(self.handler) + + def worker_run(self, timeout): + self.idle(timeout=timeout) + + def worker_stop(self): + self.pppp.handlers.remove(self.handler) + del self._tap + + app.svc.put("pppp") diff --git a/web/service/mqtt.py b/web/service/mqtt.py new file mode 100644 index 00000000..af74c6d1 --- /dev/null +++ b/web/service/mqtt.py @@ -0,0 +1,29 @@ +import logging as log + +from ..lib.service import Service +from .. import app + +from libflagship.util import enhex + +import cli.mqtt + + +class MqttQueue(Service): + + def worker_start(self): + self.client = cli.mqtt.mqtt_open( + app.config["config"], + app.config["printer_index"], + app.config["insecure"] + ) + + def worker_run(self, timeout): + for msg, body in self.client.fetch(timeout=timeout): + log.info(f"TOPIC [{msg.topic}]") + log.debug(enhex(msg.payload[:])) + + for obj in body: + self.notify(obj) + + def worker_stop(self): + del self.client diff --git a/web/service/pppp.py b/web/service/pppp.py new file mode 100644 index 00000000..285d80fe --- /dev/null +++ b/web/service/pppp.py @@ -0,0 +1,113 @@ +import json +import logging as log + +from datetime import datetime, timedelta + +from ..lib.service import Service, ServiceRestartSignal, ServiceStoppedError +from .. import app + +from libflagship.pktdump import PacketWriter +from libflagship.pppp import P2PCmdType, PktClose, Duid, Type, Xzyh, Aabb +from libflagship.ppppapi import AnkerPPPPAsyncApi, PPPPState + + +class PPPPService(Service): + + def api_command(self, commandType, **kwargs): + if not hasattr(self, "_api"): + raise ConnectionError("No pppp connection") + cmd = { + "commandType": commandType, + **kwargs + } + return self._api.send_xzyh( + json.dumps(cmd).encode(), + cmd=P2PCmdType.P2P_JSON_CMD, + block=False + ) + + def worker_start(self): + config = app.config["config"] + + deadline = datetime.now() + timedelta(seconds=2) + + with config.open() as cfg: + if not cfg: + raise ServiceStoppedError("No config available") + printer = cfg.printers[app.config["printer_index"]] + + api = AnkerPPPPAsyncApi.open_lan(Duid.from_string(printer.p2p_duid), host=printer.ip_addr) + if app.config["pppp_dump"]: + dumpfile = app.config["pppp_dump"] + log.info(f"Logging all pppp traffic to {dumpfile!r}") + pktwr = PacketWriter.open(dumpfile) + api.set_dumper(pktwr) + + log.info(f"Trying connect to printer {printer.name} ({printer.p2p_duid}) over pppp using ip {printer.ip_addr}") + + api.connect_lan_search() + + while api.state != PPPPState.Connected: + try: + msg = api.recv(timeout=(deadline - datetime.now()).total_seconds()) + api.process(msg) + except StopIteration: + raise ConnectionRefusedError("Connection rejected by device") + + log.info("Established pppp connection") + self._api = api + + def _recv_aabb(self, fd): + data = fd.read(12) + aabb = Aabb.parse(data)[0] + p = data + fd.read(aabb.len + 2) + aabb, data = Aabb.parse_with_crc(p)[:2] + return aabb, data + + def worker_run(self, timeout): + try: + msg = self._api.poll(timeout=timeout) + except ConnectionResetError: + raise ServiceRestartSignal() + + if not msg or msg.type != Type.DRW: + return + + ch = self._api.chans[msg.chan] + + with ch.lock: + data = ch.peek(16, timeout=0) + if not data: + return + + if data[:4] == b'XZYH': + hdr = ch.peek(16, timeout=0) + if not hdr: + return + + xzyh = Xzyh.parse(hdr)[0] + data = ch.read(xzyh.len + 16, timeout=0) + if not data: + return None + + xzyh.data = data[16:] + self.notify((msg.chan, xzyh)) + elif data[:2] == b'\xAA\xBB': + aabb, data = self._recv_aabb(ch) + if len(data) != 1: + raise ValueError(f"Unexpected reply from aabb request: {data}") + + aabb.data = data + self.notify((msg.chan, aabb)) + else: + raise ValueError(f"Unexpected data in stream: {data!r}") + + def worker_stop(self): + self._api.send(PktClose()) + del self._api + + @property + def connected(self): + if not hasattr(self, "_api"): + return False + return self._api.state == PPPPState.Connected diff --git a/web/service/video.py b/web/service/video.py new file mode 100644 index 00000000..7d738fb8 --- /dev/null +++ b/web/service/video.py @@ -0,0 +1,83 @@ +import json +import logging as log + +from queue import Empty +from multiprocessing import Queue + +from ..lib.service import Service, ServiceRestartSignal +from .. import app + +from libflagship.pppp import P2PSubCmdType, Xzyh + + +class VideoQueue(Service): + + def api_start_live(self): + self.pppp.api_command(P2PSubCmdType.START_LIVE, data={ + "encryptkey": "x", + "accountId": "y", + }) + + def api_stop_live(self): + self.pppp.api_command(P2PSubCmdType.CLOSE_LIVE) + + def api_light_state(self, light): + self.saved_light_state = light + self.pppp.api_command(P2PSubCmdType.LIGHT_STATE_SWITCH, data={ + "open": light, + }) + + def api_video_mode(self, mode): + self.saved_video_mode = mode + self.pppp.api_command(P2PSubCmdType.LIVE_MODE_SET, data={ + "mode": mode + }) + + def _handler(self, data): + chan, msg = data + + if chan != 1: + return + + if not isinstance(msg, Xzyh): + return + + self.notify(msg) + + def worker_init(self): + self.saved_light_state = None + self.saved_video_mode = None + + def worker_start(self): + self.pppp = app.svc.get("pppp") + + self.api_id = id(self.pppp._api) + + self.pppp.handlers.append(self._handler) + + self.api_start_live() + + if self.saved_light_state is not None: + self.api_light_state(self.saved_light_state) + + if self.saved_video_mode is not None: + self.api_video_mode(self.saved_video_mode) + + def worker_run(self, timeout): + self.idle(timeout=timeout) + + if not self.pppp.connected: + raise ServiceRestartSignal("No pppp connection") + + if id(self.pppp._api) != self.api_id: + raise ServiceRestartSignal("New pppp connection detected, restarting video feed") + + def worker_stop(self): + try: + self.api_stop_live() + except Exception as E: + log.warning(f"{self.name}: Failed to send stop command ({E})") + + self.pppp.handlers.remove(self._handler) + + app.svc.put("pppp") diff --git a/web/util.py b/web/util.py new file mode 100644 index 00000000..24bf5cda --- /dev/null +++ b/web/util.py @@ -0,0 +1,26 @@ +from flask import flash, redirect + + +def flash_redirect(path: str, message: str | None = None, category="info"): + """ + Flashes a message and redirects the user to the specified path. + + Args: + - path (str): A string representing the path to redirect the user to. + - message (str | None): An optional string message to flash to the user. + - category (str): A string representing the category of the flashed message. + Possible values are "info" (default), "danger", "warning", "success". + + Raises: + - ValueError: If the path parameter is not provided. + + Returns: + - A Flask redirect object. + """ + if not path: + raise ValueError("Redirect path is required") + + if message: + flash(message, category) + + return redirect(path)