├── scripts ├── remote.sh └── check-nomad-deployment.sh ├── test ├── f1_bot_test.exs ├── f1_bot │ └── time_test.exs ├── test_helper.exs ├── support │ ├── conn_case.ex │ ├── channel_case.ex │ └── data_case.ex ├── f1_bot_web │ └── channels │ │ ├── api_socket_test.exs │ │ └── radio_transcript_channel_test.exs └── integration │ ├── miami_2022_quali_test.exs │ ├── canada_2022_quali_test.exs │ ├── monza_2022_race_test.exs │ └── saudi_2022_quali_test.exs ├── priv ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ └── 20230428103850_add_api_client.exs │ └── seeds.exs └── static │ ├── favicon.png │ ├── fonts │ ├── Roboto │ │ ├── Roboto-Black.ttf │ │ ├── Roboto-Bold.ttf │ │ ├── Roboto-Italic.ttf │ │ ├── Roboto-Light.ttf │ │ ├── Roboto-Medium.ttf │ │ ├── Roboto-Thin.ttf │ │ ├── Roboto-Regular.ttf │ │ ├── Roboto-BlackItalic.ttf │ │ ├── Roboto-BoldItalic.ttf │ │ ├── Roboto-LightItalic.ttf │ │ ├── Roboto-ThinItalic.ttf │ │ └── Roboto-MediumItalic.ttf │ └── Exo2 │ │ ├── Exo2-VariableFont_wght.ttf │ │ └── Exo2-Italic-VariableFont_wght.ttf │ └── robots.txt ├── assets ├── discord_emojis │ ├── quick.png │ ├── timer.png │ ├── flag_red.png │ ├── tyre_wet.png │ ├── flag_yellow.png │ ├── speedometer.png │ ├── tyre_hard.png │ ├── tyre_medium.png │ ├── tyre_soft.png │ ├── tyre_test.png │ ├── announcement.png │ ├── flag_chequered.png │ └── tyre_intermediate.png ├── css │ ├── fonts.css │ └── app.css ├── js │ ├── Visualizations │ │ ├── DataPayloads.ts │ │ ├── index.ts │ │ ├── FPQualiLapTimeChart.ts │ │ ├── DatasetUtils.ts │ │ └── LapTimeScale.ts │ ├── Storage.ts │ ├── app.ts │ └── DarkModeObserver.ts └── tailwind.config.js ├── lib ├── f1_bot │ ├── repo.ex │ ├── f1_session │ │ ├── live_timing_handlers │ │ │ ├── processing_result.ex │ │ │ ├── helpers.ex │ │ │ ├── packet.ex │ │ │ ├── session_status.ex │ │ │ ├── lap_count.ex │ │ │ ├── session_info.ex │ │ │ ├── track_status.ex │ │ │ ├── driver_list.ex │ │ │ ├── processing_options.ex │ │ │ ├── extrapolated_clock.ex │ │ │ ├── position_data.ex │ │ │ └── car_telemetry.ex │ │ ├── race_control │ │ │ └── message.ex │ │ ├── driver_data_repo │ │ │ ├── sector.ex │ │ │ ├── personal_best_stats.ex │ │ │ ├── transcripts.ex │ │ │ ├── events.ex │ │ │ ├── transcript.ex │ │ │ ├── stint.ex │ │ │ └── lap.ex │ │ ├── event_generator │ │ │ ├── charts.ex │ │ │ ├── periodic.ex │ │ │ └── state_sync.ex │ │ ├── race_control.ex │ │ ├── common │ │ │ ├── time_series_store.ex │ │ │ └── event.ex │ │ ├── event_generator.ex │ │ ├── clock.ex │ │ ├── driver_cache │ │ │ └── driver_info.ex │ │ ├── session_info.ex │ │ └── lap_counter.ex │ ├── external_api │ │ ├── discord │ │ │ ├── console.ex │ │ │ ├── live.ex │ │ │ ├── permissions.ex │ │ │ └── commands │ │ │ │ ├── response.ex │ │ │ │ ├── option_validator.ex │ │ │ │ └── graph.ex │ │ ├── signalr │ │ │ ├── encoding.ex │ │ │ ├── ws_client.ex │ │ │ └── negotiation.ex │ │ └── discord.ex │ ├── data_transform │ │ ├── parsers.ex │ │ ├── format │ │ │ ├── lap_time_duration.ex │ │ │ ├── session_time.ex │ │ │ ├── lap_time_duration_no_minutes.ex │ │ │ ├── session_clock.ex │ │ │ └── lap_time_delta.ex │ │ ├── format.ex │ │ └── parse │ │ │ ├── session_clock.ex │ │ │ ├── session_time.ex │ │ │ └── lap_time_duration.ex │ ├── math.ex │ ├── demo │ │ ├── supervisor.ex │ │ └── fake_radio_generator.ex │ ├── ets.ex │ ├── release.ex │ ├── delayed_events │ │ └── supervisor.ex │ ├── light_copy.ex │ ├── output │ │ └── common.ex │ ├── authentication.ex │ ├── authentication │ │ └── api_client.ex │ ├── time.ex │ ├── pubsub.ex │ ├── plotting.ex │ ├── analysis │ │ ├── common.ex │ │ ├── gap_to_leader.ex │ │ └── lap_times.ex │ ├── replay │ │ └── options.ex │ ├── delayed_events.ex │ └── application.ex ├── f1_bot_web │ ├── components │ │ ├── layouts.ex │ │ ├── layouts │ │ │ ├── app.html.heex │ │ │ ├── live.html.heex │ │ │ └── root.html.heex │ │ ├── popup_link.hooks.ts │ │ ├── utility.ex │ │ ├── popup_link.ex │ │ ├── lap_and_clock.ex │ │ ├── chart_js.ex │ │ ├── chart_js.hooks.ts │ │ ├── core_components.ex │ │ ├── utility.hooks.ts │ │ ├── lap_time_field.ex │ │ ├── driver_selector.ex │ │ ├── tyre_symbol.ex │ │ └── delay_control.ex │ ├── plug │ │ ├── health_check.ex │ │ └── user_uuid.ex │ ├── supervisor.ex │ ├── router.ex │ ├── controllers │ │ ├── error_json.ex │ │ └── error_html.ex │ ├── internal_router.ex │ ├── live │ │ ├── chart.sface │ │ ├── helpers.ex │ │ └── telemetry.sface │ ├── channels │ │ ├── radio_transcript_channel.ex │ │ ├── transcriber_service_channel.ex │ │ └── api_socket.ex │ ├── internal_endpoint.ex │ ├── endpoint.ex │ └── telemetry.ex ├── map_utils.ex ├── mix │ └── tasks │ │ └── backtest.ex └── f1_bot_web.ex ├── .gitattributes ├── package.json ├── env.fish ├── .formatter.exs ├── entrypoint.sh ├── tsconfig.json ├── docker-compose.yml ├── config ├── prod.exs ├── test.exs ├── config.exs └── dev.exs ├── .dockerignore ├── .gitignore ├── Dockerfile └── .env.example /scripts/remote.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | 6 | bin/f1bot remote -------------------------------------------------------------------------------- /test/f1_bot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule F1BotTest do 2 | use ExUnit.Case 3 | doctest F1Bot 4 | end 5 | -------------------------------------------------------------------------------- /priv/repo/migrations/.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto_sql], 3 | inputs: ["*.exs"] 4 | ] 5 | -------------------------------------------------------------------------------- /priv/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/favicon.png -------------------------------------------------------------------------------- /assets/discord_emojis/quick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/quick.png -------------------------------------------------------------------------------- /assets/discord_emojis/timer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/timer.png -------------------------------------------------------------------------------- /assets/discord_emojis/flag_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/flag_red.png -------------------------------------------------------------------------------- /assets/discord_emojis/tyre_wet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/tyre_wet.png -------------------------------------------------------------------------------- /test/f1_bot/time_test.exs: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.TimeTest do 2 | use ExUnit.Case, async: true 3 | doctest F1Bot.Time 4 | end 5 | -------------------------------------------------------------------------------- /assets/discord_emojis/flag_yellow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/flag_yellow.png -------------------------------------------------------------------------------- /assets/discord_emojis/speedometer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/speedometer.png -------------------------------------------------------------------------------- /assets/discord_emojis/tyre_hard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/tyre_hard.png -------------------------------------------------------------------------------- /assets/discord_emojis/tyre_medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/tyre_medium.png -------------------------------------------------------------------------------- /assets/discord_emojis/tyre_soft.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/tyre_soft.png -------------------------------------------------------------------------------- /assets/discord_emojis/tyre_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/tyre_test.png -------------------------------------------------------------------------------- /assets/discord_emojis/announcement.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/announcement.png -------------------------------------------------------------------------------- /assets/discord_emojis/flag_chequered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/flag_chequered.png -------------------------------------------------------------------------------- /lib/f1_bot/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Repo do 2 | use Ecto.Repo, 3 | otp_app: :f1_bot, 4 | adapter: Ecto.Adapters.SQLite3 5 | end 6 | -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Black.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Bold.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Italic.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Light.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Medium.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Thin.ttf -------------------------------------------------------------------------------- /assets/discord_emojis/tyre_intermediate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/assets/discord_emojis/tyre_intermediate.png -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-Regular.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-BlackItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-BlackItalic.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-BoldItalic.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-LightItalic.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-ThinItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-ThinItalic.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Exo2/Exo2-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Exo2/Exo2-VariableFont_wght.ttf -------------------------------------------------------------------------------- /priv/static/fonts/Roboto/Roboto-MediumItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Roboto/Roboto-MediumItalic.ttf -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Force GitHub code highlighting on Surface UI files, incorrect but better than nothing. 2 | *.sface linguist-language=heex 3 | * text=auto eol=lf 4 | -------------------------------------------------------------------------------- /priv/static/fonts/Exo2/Exo2-Italic-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recursiveGecko/race_bot/HEAD/priv/static/fonts/Exo2/Exo2-Italic-VariableFont_wght.ttf -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :info) 2 | 3 | ExUnit.start( 4 | exclude: [skip_inconclusive: true, uses_live_timing_data: true], 5 | capture_log: true 6 | ) 7 | 8 | Ecto.Adapters.SQL.Sandbox.mode(F1Bot.Repo, :manual) 9 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/layouts.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Layouts do 2 | use F1BotWeb, :html 3 | 4 | embed_templates "layouts/*" 5 | 6 | def phx_host() do 7 | F1Bot.get_env(F1BotWeb.Endpoint)[:url][:host] 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /priv/static/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "chart.js": "^4.2.1", 4 | "chartjs-adapter-date-fns": "^3.0.0", 5 | "chartjs-plugin-annotation": "^2.1.2", 6 | "chartjs-plugin-zoom": "^2.0.0", 7 | "date-fns": "^2.29.3" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/layouts/app.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | <%= @inner_content %> 5 |
6 | -------------------------------------------------------------------------------- /env.fish: -------------------------------------------------------------------------------- 1 | # To load, run one of: 2 | # $ source env.fish 3 | # $ . env.fish 4 | 5 | set file ".env" 6 | set contents (grep -v '^#' "$file" | grep "=") 7 | 8 | for line in $contents 9 | set name (echo $line | cut -d '=' -f 1) 10 | set value (echo $line | cut -d '=' -f 2-) 11 | set --export --global "$name" "$value" 12 | end 13 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/processing_result.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.ProcessingResult do 2 | use TypedStruct 3 | 4 | typedstruct do 5 | field :session, F1Session.t(), enforce: true 6 | field :events, [Event.t()], enforce: true 7 | field :reset_session, boolean(), default: false 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/f1_bot_web/plug/health_check.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.HealthCheck do 2 | import Plug.Conn 3 | 4 | def init(opts), do: opts |> Enum.into(%{}) 5 | 6 | def call(%Plug.Conn{request_path: path} = conn, _opts = %{path: path}) do 7 | conn 8 | |> send_resp(200, "OK") 9 | |> halt() 10 | end 11 | 12 | def call(conn, _opts), do: conn 13 | end 14 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | import_deps: [:ecto, :phoenix, :surface], 4 | inputs: [ 5 | "*.{heex,ex,exs}", 6 | "{config,lib,test}/**/*.{heex,ex,exs}", 7 | "priv/*/seeds.exs", 8 | "{lib,test}/**/*.sface" 9 | ], 10 | subdirectories: ["priv/*/migrations"], 11 | plugins: [Phoenix.LiveView.HTMLFormatter, Surface.Formatter.Plugin] 12 | ] 13 | -------------------------------------------------------------------------------- /priv/repo/seeds.exs: -------------------------------------------------------------------------------- 1 | # Script for populating the database. You can run it as: 2 | # 3 | # mix run priv/repo/seeds.exs 4 | # 5 | # Inside the script, you can read and write to any of your 6 | # repositories directly: 7 | # 8 | # F1Bot.Repo.insert!(%F1Bot.SomeSchema{}) 9 | # 10 | # We recommend using the bang functions (`insert!`, `update!` 11 | # and so on) as they will fail if something goes wrong. 12 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -xe 4 | 5 | # Fix permissions in Docker Desktop (https://github.com/docker/for-win/issues/2476) 6 | chown -R app:app /data 7 | 8 | echo "::: Running migrations and starting the release :::" 9 | 10 | setpriv --reuid=app --regid=app --clear-groups /app/bin/f1bot eval 'F1Bot.Release.migrate()' 11 | exec setpriv --reuid=app --regid=app --clear-groups /app/bin/f1bot start 12 | -------------------------------------------------------------------------------- /lib/f1_bot_web/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Supervisor do 2 | use Supervisor 3 | 4 | def start_link(_init_arg) do 5 | Supervisor.start_link(__MODULE__, nil, name: __MODULE__) 6 | end 7 | 8 | def init(_state) do 9 | children = [ 10 | F1BotWeb.Endpoint, 11 | F1BotWeb.InternalEndpoint, 12 | ] 13 | 14 | Supervisor.init(children, strategy: :one_for_one) 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/race_control/message.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.RaceControl.Message do 2 | @moduledoc "" 3 | use TypedStruct 4 | 5 | typedstruct do 6 | @typedoc "Race Control Message" 7 | 8 | field(:source, String.t()) 9 | field(:message, String.t()) 10 | field(:flag, atom()) 11 | field(:mentions, list()) 12 | end 13 | 14 | def new do 15 | %__MODULE__{} 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/discord/console.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.Discord.Console do 2 | @moduledoc "" 3 | @behaviour F1Bot.ExternalApi.Discord 4 | require Logger 5 | 6 | def post_message(message_or_tuple) do 7 | message = 8 | case message_or_tuple do 9 | {_type, message} -> message 10 | message -> message 11 | end 12 | 13 | Logger.info("[DISCORD] #{message}") 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /priv/repo/migrations/20230428103850_add_api_client.exs: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Repo.Migrations.AddApiClient do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table("api_client") do 6 | add :client_name, :string, null: false 7 | add :client_secret, :string, null: false 8 | add :scopes, :map, null: false 9 | end 10 | 11 | create index("api_client", [:client_name], unique: true) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/layouts/live.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 5 | 6 | 9 | 10 | <%= @inner_content %> 11 |
12 | -------------------------------------------------------------------------------- /lib/f1_bot_web/plug/user_uuid.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Plug.UserUUID do 2 | alias Ecto.UUID 3 | import Plug.Conn 4 | 5 | def init(opts), do: opts 6 | 7 | def call(conn, _opts) do 8 | conn = fetch_session(conn) 9 | user_uuid = get_session(conn, :user_uuid) 10 | 11 | if user_uuid == nil do 12 | conn 13 | |> put_session(:user_uuid, UUID.generate()) 14 | else 15 | conn 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "target": "ES2020", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "DOM", 8 | "ES2020" 9 | ], 10 | "paths": { 11 | "@assets/*": [ 12 | "./assets/js/*", 13 | ], 14 | "@deps/*": [ 15 | "./deps/*" 16 | ], 17 | }, 18 | "strictNullChecks": true, 19 | "noEmit": true, 20 | "skipLibCheck": true 21 | } 22 | } -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_data_repo/sector.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverDataRepo.Sector do 2 | @moduledoc """ 3 | Holds information about a single sector. 4 | """ 5 | use TypedStruct 6 | 7 | typedstruct do 8 | field(:time, Timex.Duration.t()) 9 | field(:timestamp, DateTime.t()) 10 | end 11 | 12 | def new(time = %Timex.Duration{}, timestamp = %DateTime{}), 13 | do: %__MODULE__{ 14 | time: time, 15 | timestamp: timestamp 16 | } 17 | end 18 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/popup_link.hooks.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | mounted() { 3 | const button = this.el; 4 | 5 | button.addEventListener('click', () => { 6 | const href = button.getAttribute('data-href'); 7 | const height = button.getAttribute('data-height') || 500; 8 | const width = button.getAttribute('data-width') || 500; 9 | 10 | if (href) { 11 | window.open(href, '_blank', `popup,height=${height},width=${width}`) 12 | } 13 | }); 14 | } 15 | } -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_data_repo/personal_best_stats.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverDataRepo.PersonalBestStats do 2 | use TypedStruct 3 | 4 | typedstruct do 5 | field :driver_number, pos_integer() 6 | field :lap_time_ms, pos_integer() | nil 7 | field :sectors_ms, %{1 => pos_integer() | nil, 2 => pos_integer() | nil, 3 => pos_integer() | nil} 8 | field :top_speed, pos_integer() | nil 9 | end 10 | 11 | def new(driver_number), do: %__MODULE__{driver_number: driver_number} 12 | end 13 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/parsers.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Parse do 2 | @moduledoc false 3 | alias F1Bot.DataTransform.Parse 4 | 5 | defdelegate parse_lap_time(str), to: Parse.LapTimeDuration, as: :parse 6 | defdelegate parse_session_time(str), to: Parse.SessionTime, as: :parse 7 | defdelegate parse_session_clock(str), to: Parse.SessionClock, as: :parse 8 | 9 | def parse_iso_timestamp(str) do 10 | {:ok, datetime} = Timex.parse(str, "{ISO:Extended}") 11 | 12 | datetime 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/f1_bot/math.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Math do 2 | @moduledoc false 3 | def find_closest_point(point_list, point) do 4 | Enum.min_by(point_list, &point_distance_3d(point, &1), nil) 5 | end 6 | 7 | def point_distance_3d( 8 | %{x: x1, y: y1, z: z1}, 9 | %{x: x2, y: y2, z: z2} 10 | ) do 11 | x_delta = x1 - x2 12 | y_delta = y1 - y2 13 | z_delta = z1 - z2 14 | 15 | (:math.pow(x_delta, 2) + :math.pow(y_delta, 2) + :math.pow(z_delta, 2)) 16 | |> :math.sqrt() 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/f1_bot_web/router.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Router do 2 | use F1BotWeb, :router 3 | 4 | pipeline :browser do 5 | plug :accepts, ["html"] 6 | plug :fetch_session 7 | plug :fetch_live_flash 8 | plug :put_root_layout, {F1BotWeb.Layouts, :root} 9 | plug :protect_from_forgery 10 | plug :put_secure_browser_headers 11 | end 12 | 13 | pipeline :api do 14 | plug :accepts, ["json"] 15 | end 16 | 17 | scope "/", F1BotWeb do 18 | pipe_through :browser 19 | 20 | live "/", Live.Telemetry 21 | live "/chart", Live.Chart 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /assets/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Exo 2'; 3 | font-weight: 100 300 400 500 600 700 800 900; 4 | src: url('/fonts/Exo2/Exo2-VariableFont_wght.ttf') format("truetype-variations"); 5 | } 6 | 7 | @font-face { 8 | font-family: 'Exo 2'; 9 | font-style: italic; 10 | font-weight: 100 300 400 500 600 700 800 900; 11 | src: url('/fonts/Exo2/Exo2-Italic-VariableFont_wght.ttf') format("truetype-variations"); 12 | } 13 | 14 | @font-face { 15 | font-family: 'Roboto'; 16 | font-weight: 400; 17 | src: url('/fonts/Roboto/Roboto-Regular.ttf') format("truetype"); 18 | } -------------------------------------------------------------------------------- /lib/f1_bot_web/controllers/error_json.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.ErrorJSON do 2 | # If you want to customize a particular status code, 3 | # you may add your own clauses, such as: 4 | # 5 | # def render("500.json", _assigns) do 6 | # %{errors: %{detail: "Internal Server Error"}} 7 | # end 8 | 9 | # By default, Phoenix returns the status message from 10 | # the template name. For example, "404.json" becomes 11 | # "Not Found". 12 | def render(template, _assigns) do 13 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}} 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | f1bot: 3 | build: 4 | context: "." 5 | image: "${DOCKER_IMAGE:-ghcr.io/recursivegecko/race_bot:master}" 6 | environment: 7 | - DATABASE_PATH=/data/f1bot.db 8 | - PHX_SERVER="true" 9 | - PORT=4000 10 | ports: 11 | - "${DOCKER_BIND_PORT:-4000}:4000" 12 | env_file: .env 13 | restart: unless-stopped 14 | container_name: "${DOCKER_CONTAINER_NAME:-f1bot}" 15 | volumes: 16 | - "${DOCKER_DATA_VOLUME:-data}:/data" 17 | volumes: 18 | data: 19 | -------------------------------------------------------------------------------- /lib/f1_bot/demo/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Demo.Supervisor do 2 | use Supervisor 3 | alias F1Bot.Demo 4 | 5 | def start_link(init_arg) do 6 | Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__) 7 | end 8 | 9 | @impl true 10 | def init(_init_arg) do 11 | children = [ 12 | Demo.FakeRadioGenerator, 13 | {Task, &start_demo_mode_replay/0}, 14 | ] 15 | 16 | Supervisor.init(children, strategy: :one_for_one) 17 | end 18 | 19 | defp start_demo_mode_replay() do 20 | url = F1Bot.demo_mode_url() 21 | F1Bot.Replay.Server.start_replay(url, 1, true) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/utility.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.Utility do 2 | use F1BotWeb, :component 3 | alias Phoenix.LiveView 4 | 5 | @impl true 6 | def render(assigns) do 7 | ~F""" 8 | """ 9 | end 10 | 11 | @doc """ 12 | Push a partial params object to the client to be merged into the 13 | existing params stored in localStorage. Sent to the server when connection 14 | is re-established. 15 | Use for saving the user's preferences such as the selected drivers and 16 | live data delay. 17 | """ 18 | def save_params(socket, params = %{}) do 19 | socket 20 | |> LiveView.push_event("save-params", params) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/f1_bot/ets.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Ets do 2 | def new(table_name) do 3 | :ets.new(table_name, [:named_table, :public, :set, read_concurrency: true]) 4 | end 5 | 6 | def fetch(table_name, key) do 7 | case :ets.lookup(table_name, key) do 8 | [{^key, value}] -> 9 | {:ok, value} 10 | 11 | _ -> 12 | {:error, :no_data} 13 | end 14 | end 15 | 16 | def insert(table_name, key, value) do 17 | :ets.insert(table_name, {key, value}) 18 | end 19 | 20 | def clear(table_name) do 21 | try do 22 | :ets.delete_all_objects(table_name) 23 | rescue 24 | e in ArgumentError -> {:error, e} 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/f1_bot_web/controllers/error_html.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.ErrorHTML do 2 | use F1BotWeb, :html 3 | 4 | # If you want to customize your error pages, 5 | # uncomment the embed_templates/1 call below 6 | # and add pages to the error directory: 7 | # 8 | # * lib/f1_bot_web/controllers/error_html/404.html.heex 9 | # * lib/f1_bot_web/controllers/error_html/500.html.heex 10 | # 11 | # embed_templates "error_html/*" 12 | 13 | # The default is to render a plain text page based on 14 | # the template name. For example, "404.html" becomes 15 | # "Not Found". 16 | def render(template, _assigns) do 17 | Phoenix.Controller.status_message_from_template(template) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/f1_bot/release.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Release do 2 | alias Ecto.Migrator 3 | require Logger 4 | 5 | @app :f1_bot 6 | 7 | def migrate() do 8 | for repo <- repos() do 9 | Logger.info("Migrating #{inspect(repo)}") 10 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :up, all: true)) 11 | end 12 | end 13 | 14 | def rollback(repo, version) do 15 | Logger.info("Rolling back migrations for #{inspect(repo)} to version #{version}") 16 | {:ok, _, _} = Migrator.with_repo(repo, &Migrator.run(&1, :down, to: version)) 17 | end 18 | 19 | def repos do 20 | Application.load(@app) 21 | Application.fetch_env!(@app, :ecto_repos) 22 | |> IO.inspect() 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/f1_bot/delayed_events/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DelayedEvents.Supervisor do 2 | use Supervisor 3 | 4 | def start_link(_init_arg) do 5 | Supervisor.start_link(__MODULE__, nil, name: __MODULE__) 6 | end 7 | 8 | @impl true 9 | def init(_init_arg) do 10 | children = 11 | for delay_ms <- F1Bot.DelayedEvents.available_delays() do 12 | init_arg = [ 13 | delay_ms: delay_ms, 14 | ] 15 | 16 | module = F1Bot.DelayedEvents.Rebroadcaster 17 | 18 | %{ 19 | id: :"#{module}::#{delay_ms}", 20 | start: {module, :start_link, [init_arg]} 21 | } 22 | end 23 | 24 | Supervisor.init(children, strategy: :one_for_one) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.Helpers do 2 | require Logger 3 | alias F1Bot.F1Session.LiveTimingHandlers.ProcessingOptions 4 | 5 | @log_dir "tmp" 6 | 7 | def maybe_log_driver_data(label, driver_number, data, options = %ProcessingOptions{}) do 8 | if options.log_drivers != nil and driver_number in options.log_drivers do 9 | data_fmt = inspect(data, limit: :infinity) 10 | line = "#{label} for ##{driver_number}: #{data_fmt}" 11 | 12 | Logger.info(line) 13 | 14 | File.mkdir_p(@log_dir) 15 | path = Path.join([@log_dir, "car_#{driver_number}.txt"]) 16 | File.write(path, [line, "\n"], [:append]) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/event_generator/charts.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.EventGenerator.Charts do 2 | alias F1Bot.F1Session 3 | alias F1Bot.F1Session.Common.Event 4 | alias F1Bot.F1Session.SessionInfo 5 | 6 | def chart_init_events(session = %F1Session{}), do: chart_init_events(session.session_info) 7 | 8 | def chart_init_events(session_info = %SessionInfo{}) do 9 | is_race = SessionInfo.is_race?(session_info) 10 | 11 | lap_time_chart_class = 12 | # Must match a class from /assets/js/Visualizations 13 | if is_race do 14 | "RaceLapTimeChart" 15 | else 16 | "FPQualiLapTimeChart" 17 | end 18 | 19 | [ 20 | Event.new("chart_init:lap_times", lap_time_chart_class) 21 | ] 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_data_repo/transcripts.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverDataRepo.Transcripts do 2 | use TypedStruct 3 | 4 | alias F1Bot.F1Session.DriverDataRepo.Transcript 5 | alias F1Bot.F1Session.Common.Event 6 | 7 | typedstruct do 8 | field :driver_number, pos_integer() 9 | field :transcripts, [Transcript.t()], default: [] 10 | end 11 | 12 | def new(driver_number) when is_integer(driver_number) do 13 | %__MODULE__{ 14 | driver_number: driver_number 15 | } 16 | end 17 | 18 | def append(this = %__MODULE__{}, transcript = %Transcript{}) do 19 | %{this | transcripts: [transcript | this.transcripts]} 20 | end 21 | 22 | def to_init_event(this) do 23 | Event.new("driver_transcripts_init:#{this.driver_number}", %{transcripts: this.transcripts}) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/f1_bot_web/internal_router.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.InternalRouter do 2 | use F1BotWeb, :router 3 | import Phoenix.LiveDashboard.Router 4 | 5 | pipeline :browser do 6 | plug :accepts, ["html"] 7 | plug :fetch_session 8 | plug :fetch_live_flash 9 | plug :put_root_layout, {F1BotWeb.Layouts, :root} 10 | plug :protect_from_forgery 11 | plug :put_secure_browser_headers 12 | end 13 | 14 | pipeline :api do 15 | plug :accepts, ["json"] 16 | end 17 | 18 | scope "/" do 19 | pipe_through :browser 20 | 21 | live "/site", F1BotWeb.Live.Telemetry 22 | 23 | live_dashboard "/", 24 | metrics: F1BotWeb.Telemetry, 25 | additional_pages: [ 26 | # TODO: Current flame_on doesn't support phoenix_live_dashboard 0.7.x 27 | # flame_on: FlameOn.DashboardPage 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/signalr/encoding.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.SignalR.Encoding do 2 | @moduledoc """ 3 | Decoding & decompression functions for inflating certain live timing API data feeds (e.g. telemetry) 4 | """ 5 | 6 | def decode_live_timing_data(base64_encoded_compressed_json) do 7 | with {:ok, decoded} <- Base.decode64(base64_encoded_compressed_json), 8 | {:ok, decompressed} <- safe_zlib_unzip(decoded), 9 | {:ok, data} <- Jason.decode(decompressed) do 10 | {:ok, data} 11 | else 12 | :error -> {:error, :base64_decoding_error} 13 | {:error, error} -> {:error, error} 14 | end 15 | end 16 | 17 | def safe_zlib_unzip(data) do 18 | try do 19 | data = :zlib.unzip(data) 20 | {:ok, data} 21 | rescue 22 | _e -> {:error, :zlib_error} 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | # 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | 13 | config :f1_bot, 14 | auto_reload_session: true 15 | 16 | config :f1_bot, F1BotWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json" 17 | 18 | config :f1_bot, F1BotWeb.InternalEndpoint, 19 | cache_static_manifest: "priv/static/cache_manifest.json" 20 | 21 | # Do not print debug messages in production 22 | config :logger, level: :info 23 | 24 | config :logger, :console, metadata: [:error_code, :line, :mfa] 25 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/format/lap_time_duration.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Format.LapTimeDuration do 2 | @moduledoc false 3 | use Timex.Format.Duration.Formatter 4 | 5 | @impl true 6 | def format(duration) do 7 | total_mils = 8 | duration 9 | |> Timex.Duration.to_milliseconds() 10 | |> round() 11 | 12 | milliseconds = 13 | total_mils 14 | |> rem(1000) 15 | |> Integer.to_string() 16 | |> String.pad_leading(3, "0") 17 | 18 | seconds = 19 | total_mils 20 | |> div(1000) 21 | |> rem(60) 22 | |> Integer.to_string() 23 | |> String.pad_leading(2, "0") 24 | 25 | minutes = 26 | total_mils 27 | |> div(1000) 28 | |> div(60) 29 | |> Integer.to_string() 30 | 31 | "#{minutes}:#{seconds}.#{milliseconds}" 32 | end 33 | 34 | @impl true 35 | def lformat(duration, _), do: format(duration) 36 | end 37 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/format/session_time.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Format.SessionTime do 2 | @moduledoc false 3 | use Timex.Format.Duration.Formatter 4 | 5 | @impl true 6 | def format(duration) do 7 | total_mils = 8 | duration 9 | |> Timex.Duration.to_milliseconds() 10 | |> round() 11 | 12 | seconds = 13 | total_mils 14 | |> div(1000) 15 | |> rem(60) 16 | |> Integer.to_string() 17 | |> String.pad_leading(2, "0") 18 | 19 | minutes = 20 | total_mils 21 | |> div(1000) 22 | |> div(60) 23 | |> rem(60) 24 | |> Integer.to_string() 25 | 26 | hours = 27 | total_mils 28 | |> div(1000) 29 | |> div(60) 30 | |> div(60) 31 | |> Integer.to_string() 32 | 33 | "#{hours}:#{minutes}:#{seconds}" 34 | end 35 | 36 | @impl true 37 | def lformat(duration, _), do: format(duration) 38 | end 39 | -------------------------------------------------------------------------------- /lib/f1_bot_web/live/chart.sface: -------------------------------------------------------------------------------- 1 |
2 |
3 | <.brand class="shrink-0 hidden lg:flex mr-8" /> 4 | 5 |
6 | 7 |
8 | 9 |
10 | 11 | 16 |
17 | 18 | 19 |
20 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/format/lap_time_duration_no_minutes.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Format.LapTimeDurationNoMinutes do 2 | @moduledoc false 3 | use Timex.Format.Duration.Formatter 4 | 5 | @impl true 6 | def format(duration) do 7 | total_mils = 8 | duration 9 | |> Timex.Duration.to_milliseconds() 10 | |> round() 11 | 12 | if total_mils >= 60_000 do 13 | raise "Duration is too long to be formatted as a lap time without minutes" 14 | end 15 | 16 | milliseconds = 17 | total_mils 18 | |> rem(1000) 19 | |> Integer.to_string() 20 | |> String.pad_leading(3, "0") 21 | 22 | seconds = 23 | total_mils 24 | |> div(1000) 25 | |> rem(60) 26 | |> Integer.to_string() 27 | |> String.pad_leading(2, "0") 28 | 29 | "#{seconds}.#{milliseconds}" 30 | end 31 | 32 | @impl true 33 | def lformat(duration, _), do: format(duration) 34 | end 35 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/packet.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.Packet do 2 | @moduledoc """ 3 | Struct that contains information about every packet received from the live timing API. 4 | 5 | Fields: 6 | 7 | - `:topic`: Websocket feed that the packet came from (e.g. car telemetry, lap times) 8 | - `:data`: Topic-specific payload 9 | - `:timestamp`: Timestamp of the packet, determined by the API 10 | - `:init`: Flag that determines whether this is an initialization packet received 11 | immediately after establishing websocket connection. This is useful for topics that 12 | contain static information, like driver names and session information. 13 | """ 14 | use TypedStruct 15 | 16 | typedstruct do 17 | field(:topic, binary(), enforce: true) 18 | field(:data, any(), enforce: true) 19 | field(:timestamp, DateTime, enforce: true) 20 | field(:init, boolean(), default: false) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/f1_bot/light_copy.ex: -------------------------------------------------------------------------------- 1 | defprotocol F1Bot.LightCopy do 2 | @doc """ 3 | Recursively copies data with heavy bits (e.g. telemetry and position history) removed. 4 | Structs containing heavy data must implement the protocol and strip the data. 5 | """ 6 | @fallback_to_any true 7 | def light_copy(data) 8 | end 9 | 10 | defimpl F1Bot.LightCopy, for: Any do 11 | alias F1Bot.LightCopy 12 | 13 | def light_copy(data) when is_list(data) do 14 | Enum.map(data, &LightCopy.light_copy/1) 15 | end 16 | 17 | def light_copy(%module{} = data) when is_struct(data) do 18 | map = 19 | data 20 | |> Map.from_struct() 21 | |> LightCopy.light_copy() 22 | 23 | struct(module, map) 24 | end 25 | 26 | def light_copy(%{} = data) when is_map(data) do 27 | data 28 | |> Enum.map(fn {k, v} -> {k, LightCopy.light_copy(v)} end) 29 | |> Enum.into(%{}) 30 | end 31 | 32 | def light_copy(data) do 33 | data 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /assets/js/Visualizations/DataPayloads.ts: -------------------------------------------------------------------------------- 1 | type TrackStatusDataPoint = { 2 | id: string; 3 | ts_from?: number, 4 | ts_to?: number, 5 | lap_from?: number, 6 | lap_to?: number, 7 | status: string, 8 | type: 'instant' | 'interval' 9 | } 10 | 11 | interface TrackStatusData { 12 | dataset: 'driver_data'; 13 | op: 'insert' | 'replace'; 14 | data: TrackStatusDataPoint[]; 15 | } 16 | 17 | type LapTimeDataPoint = { lap: number, t: number, ts: number }; 18 | 19 | interface DriverLapTimeData { 20 | dataset: 'driver_data'; 21 | op: 'insert' | 'replace'; 22 | driver_number: number; 23 | driver_name: string; 24 | driver_abbr: string; 25 | team_color: string; 26 | chart_order: number; 27 | chart_team_order: number; 28 | data: LapTimeDataPoint[]; 29 | } 30 | 31 | interface AnyChartData { 32 | dataset: string; 33 | op: 'insert' | 'replace'; 34 | } 35 | 36 | export { TrackStatusData, TrackStatusDataPoint, AnyChartData, DriverLapTimeData, LapTimeDataPoint }; -------------------------------------------------------------------------------- /assets/js/Storage.ts: -------------------------------------------------------------------------------- 1 | export const Storage = { 2 | save(key: string, value: string | number | T) { 3 | let saveValue: string; 4 | 5 | switch (typeof value) { 6 | case 'object': 7 | saveValue = JSON.stringify(value); 8 | break; 9 | case "number": 10 | saveValue = value.toString(); 11 | break; 12 | case 'string': 13 | saveValue = value; 14 | break; 15 | default: 16 | console.warn('Ignoring LocalStorage save request for value of type', typeof value, value) 17 | return; 18 | } 19 | 20 | window.localStorage.setItem(key, saveValue); 21 | }, 22 | 23 | load(key: string, defaultVal: T, parseFn?: (x: string) => T) { 24 | const loaded = window.localStorage.getItem(key); 25 | 26 | if (loaded && parseFn) { 27 | return parseFn(loaded) 28 | } else if (loaded) { 29 | return loaded; 30 | } else { 31 | return defaultVal; 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /lib/f1_bot_web/channels/radio_transcript_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.RadioTranscriptChannel do 2 | use F1BotWeb, :channel 3 | require Logger 4 | 5 | alias F1Bot.TranscriberService 6 | alias F1BotWeb.ApiSocket 7 | 8 | @impl true 9 | def join("radio_transcript:status", _payload, socket) do 10 | if ApiSocket.client_has_scope?(socket, :read_transcripts) do 11 | send(self(), {:after_join, :status}) 12 | {:ok, socket} 13 | else 14 | {:error, :unauthorized} 15 | end 16 | end 17 | 18 | @impl true 19 | def join("radio_transcript:" <> _driver_number, _payload, socket) do 20 | if ApiSocket.client_has_scope?(socket, :read_transcripts) do 21 | {:ok, socket} 22 | else 23 | {:error, :unauthorized} 24 | end 25 | end 26 | 27 | @impl true 28 | def handle_info({:after_join, :status}, socket) do 29 | status = TranscriberService.status() 30 | push(socket, "status", status) 31 | {:noreply, socket} 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/race_control.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.RaceControl do 2 | @moduledoc """ 3 | Stores and generates events for messages from race control. 4 | """ 5 | use TypedStruct 6 | alias F1Bot.F1Session 7 | 8 | typedstruct do 9 | @typedoc "Race Control messages" 10 | 11 | field(:messages, [F1Session.RaceControl.Message.t()], default: []) 12 | end 13 | 14 | def new do 15 | %__MODULE__{} 16 | end 17 | 18 | def push_messages( 19 | race_control = %__MODULE__{}, 20 | new_messages 21 | ) do 22 | messages = race_control.messages ++ new_messages 23 | race_control = %{race_control | messages: messages} 24 | 25 | events = 26 | for m <- new_messages do 27 | make_race_control_message_event(m) 28 | end 29 | 30 | {race_control, events} 31 | end 32 | 33 | defp make_race_control_message_event(payload) do 34 | F1Bot.F1Session.Common.Event.new("race_control:message", payload) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/format/session_clock.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Format.SessionClock do 2 | @moduledoc false 3 | use Timex.Format.Duration.Formatter 4 | 5 | @impl true 6 | def format(duration) do 7 | total_mils = 8 | duration 9 | |> Timex.Duration.to_milliseconds() 10 | |> round() 11 | 12 | seconds = 13 | total_mils 14 | |> div(1000) 15 | |> rem(60) 16 | |> Integer.to_string() 17 | |> String.pad_leading(2, "0") 18 | 19 | minutes = 20 | total_mils 21 | |> div(1000) 22 | |> div(60) 23 | |> rem(60) 24 | |> Integer.to_string() 25 | |> String.pad_leading(2, "0") 26 | 27 | 28 | hours = 29 | total_mils 30 | |> div(1000) 31 | |> div(60) 32 | |> div(60) 33 | |> Integer.to_string() 34 | |> String.pad_leading(2, "0") 35 | 36 | "#{hours}:#{minutes}:#{seconds}" 37 | end 38 | 39 | @impl true 40 | def lformat(duration, _), do: format(duration) 41 | end 42 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/format.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Format do 2 | @moduledoc false 3 | alias F1Bot.DataTransform.Format 4 | 5 | def format_lap_time(duration, maybe_drop_minutes \\ false) do 6 | formatter = 7 | if maybe_drop_minutes and Timex.Duration.to_milliseconds(duration) < 60_000 do 8 | Format.LapTimeDurationNoMinutes 9 | else 10 | Format.LapTimeDuration 11 | end 12 | 13 | case Timex.format_duration(duration, formatter) do 14 | {:error, _err} -> "ERROR" 15 | val -> val 16 | end 17 | end 18 | 19 | def format_lap_delta(_duration = nil), do: "+-0.000" 20 | 21 | def format_lap_delta(duration) do 22 | case Timex.format_duration(duration, Format.LapTimeDelta) do 23 | {:error, _err} -> "ERROR" 24 | val -> val 25 | end 26 | end 27 | 28 | def format_session_clock(duration) do 29 | case Timex.format_duration(duration, Format.SessionClock) do 30 | {:error, _err} -> "--:--:--" 31 | val -> val 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/parse/session_clock.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Parse.SessionClock do 2 | @moduledoc false 3 | use Timex.Parse.Duration.Parser 4 | import NimbleParsec 5 | 6 | @impl true 7 | def parse(timestamp_string) do 8 | case parse_timestamp(timestamp_string) do 9 | {:ok, [hour, min, sec], _remaining, _, _, _} -> 10 | total_ms = sec * 1_000 + min * 60 * 1_000 + hour * 3600 * 1_000 11 | duration = Timex.Duration.from_milliseconds(total_ms) 12 | 13 | {:ok, duration} 14 | 15 | {:error, error, _, _, _, _} -> 16 | {:error, error} 17 | end 18 | end 19 | 20 | ## 21 | ## Session clock parser, e.g. 0:17:45 into [0, 17, 45] 22 | ## 23 | 24 | non_second_parser = 25 | integer(min: 1) 26 | |> ignore(ascii_char([?:])) 27 | 28 | second_parser = 29 | integer(min: 1) 30 | 31 | timestamp_combined = 32 | non_second_parser 33 | |> concat(non_second_parser) 34 | |> concat(second_parser) 35 | 36 | defparsecp(:parse_timestamp, timestamp_combined) 37 | end 38 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/discord/live.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.Discord.Live do 2 | @moduledoc "" 3 | require Logger 4 | @behaviour F1Bot.ExternalApi.Discord 5 | 6 | @impl F1Bot.ExternalApi.Discord 7 | def post_message(message_or_tuple) do 8 | {type, message} = 9 | case message_or_tuple do 10 | message when is_binary(message) -> {:default, message} 11 | {type, message} -> {type, message} 12 | end 13 | 14 | channel_ids = 15 | case type do 16 | :radio -> F1Bot.get_env(:discord_channel_ids_radios, []) 17 | _ -> F1Bot.get_env(:discord_channel_ids_messages, []) 18 | end 19 | 20 | Logger.info("[DISCORD] #{message} (to channels: #{inspect(channel_ids)})") 21 | 22 | for channel_id <- channel_ids do 23 | case Nostrum.Api.create_message(channel_id, message) do 24 | {:ok, _result} -> 25 | :ok 26 | 27 | {:error, err} -> 28 | Logger.error("Failed to post Discord message: #{inspect(err)}") 29 | end 30 | end 31 | 32 | :ok 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/popup_link.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.PopupLink do 2 | use F1BotWeb, :component 3 | 4 | prop id, :string, required: true 5 | prop href, :uri, required: true 6 | prop class, :css_class, default: "" 7 | prop width, :integer, default: 1000 8 | prop height, :integer, default: 500 9 | slot default, required: true 10 | 11 | @impl true 12 | def render(assigns) do 13 | ~F""" 14 | 32 | """ 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/lap_and_clock.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.LapAndClock do 2 | use F1BotWeb, :component 3 | 4 | alias F1Bot.DataTransform.Format 5 | 6 | prop is_race, :boolean, required: true 7 | prop lap_counter, :any, required: true 8 | prop session_clock, :any, required: true 9 | 10 | @impl true 11 | def render(assigns) do 12 | ~F""" 13 |
14 | {#if @is_race and @lap_counter != nil} 15 |
16 | Lap 17 | {@lap_counter.current || 0} 18 | / 19 | {@lap_counter.total} 20 |
21 | {/if} 22 | 23 | {#if @session_clock != nil} 24 | 25 | {Format.format_session_clock(@session_clock)} 26 | 27 | {/if} 28 |
29 | """ 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/f1_bot/output/common.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Output.Common do 2 | alias F1Bot.F1Session.Common.Event 3 | 4 | @post_after_race_lap 5 5 | 6 | def should_post_stats(_event = %Event{meta: meta}) 7 | when meta != nil do 8 | lap = meta.lap_number || 0 9 | is_race = meta.session_type == "Race" 10 | 11 | lap > @post_after_race_lap or not is_race 12 | end 13 | 14 | def get_driver_name_by_number(_event = %Event{meta: meta}, driver_number) do 15 | case meta[:driver_info][driver_number] do 16 | %{last_name: name} when name != nil -> name 17 | %{short_name: name} when name != nil -> name 18 | %{driver_abbr: name} when name != nil -> name 19 | _ -> "Car #{driver_number}" 20 | end 21 | end 22 | 23 | def get_driver_abbr_by_number(_event = %Event{meta: meta}, driver_number) do 24 | case meta[:driver_info][driver_number] do 25 | %{driver_abbr: abbr} -> abbr 26 | %{last_name: name} when name != nil -> name 27 | %{short_name: name} when name != nil -> name 28 | _ -> "Car #{driver_number}" 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/signalr/ws_client.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.SignalR.WSClient do 2 | use Fresh, restart: :temporary 3 | require Logger 4 | 5 | alias F1Bot.ExternalApi.SignalR 6 | 7 | @impl Fresh 8 | def handle_connect(_status, _headers, state) do 9 | SignalR.Client.ws_handle_connected() 10 | {:ok, state} 11 | end 12 | 13 | @impl Fresh 14 | def handle_in(message, state) do 15 | # IO.inspect(message, label: "in") 16 | SignalR.Client.ws_handle_message(message) 17 | {:ok, state} 18 | end 19 | 20 | @impl Fresh 21 | def handle_error(error, _state) do 22 | Logger.error("SignalR connection error: #{inspect(error)}") 23 | 24 | {:close, {:error, error}} 25 | end 26 | 27 | @impl Fresh 28 | def handle_disconnect(code, reason, _state) do 29 | Logger.error("SignalR disconnected: #{inspect({code, reason})}") 30 | 31 | {:close, {:error, reason}} 32 | end 33 | 34 | def send(message) do 35 | # IO.inspect(message, label: "out") 36 | Fresh.send(__MODULE__, message) 37 | end 38 | 39 | def name, do: {:local, __MODULE__} 40 | end 41 | -------------------------------------------------------------------------------- /scripts/check-nomad-deployment.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | eval_id=$(echo "$1" | grep -ioP "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}") 6 | deployment_id=$(nomad eval status -json $eval_id | jq -rM '.DeploymentID') 7 | 8 | i=0 9 | while [ $i -ne 120 ]; do 10 | i=$(($i+1)) 11 | 12 | echo "-------" 13 | date 14 | nomad deployment status -verbose "$deployment_id" 15 | full_status=$(nomad deployment status -json "$deployment_id") 16 | echo 17 | 18 | status=$(echo "$full_status" | jq -rM '.Status') 19 | description=$(echo "$full_status" | jq -rM '.StatusDescription') 20 | 21 | if [ "$status" = "failed" ]; then 22 | nomad deployment status -verbose "$deployment_id" 23 | echo "Deployment failed" 24 | exit 1 25 | fi 26 | 27 | if [ "$status" = "successful" ]; then 28 | nomad deployment status -verbose "$deployment_id" 29 | echo "Deployment successful" 30 | exit 0 31 | fi 32 | 33 | sleep 5 34 | done 35 | 36 | nomad deployment status -verbose "$deployment_id" 37 | echo "Deployment timed out" 38 | exit 1 -------------------------------------------------------------------------------- /lib/f1_bot/authentication.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Authentication do 2 | import Ecto.Query 3 | alias F1Bot.Repo 4 | alias F1Bot.Authentication.ApiClient 5 | 6 | def create_api_client(name, scopes) do 7 | params = %{ 8 | client_name: name, 9 | scopes: scopes 10 | } 11 | 12 | params 13 | |> ApiClient.create_changeset() 14 | |> Repo.insert() 15 | end 16 | 17 | def list_all_api_clients() do 18 | Repo.all(ApiClient) 19 | end 20 | 21 | def find_api_client_by_name(name) do 22 | query = 23 | ApiClient 24 | |> where([c], c.client_name == ^name) 25 | 26 | case Repo.one(query) do 27 | nil -> {:error, :not_found} 28 | client -> {:ok, client} 29 | end 30 | end 31 | 32 | def update_api_client_scopes(data = %ApiClient{}, scopes) do 33 | data 34 | |> ApiClient.update_changeset(%{scopes: scopes}) 35 | |> Repo.update() 36 | end 37 | 38 | def delete_api_client_by_name(name) do 39 | query = 40 | ApiClient 41 | |> where([c], c.client_name == ^name) 42 | 43 | {count, _} = Repo.delete_all(query) 44 | count > 0 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/session_status.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.SessionStatus do 2 | @moduledoc """ 3 | Handler for session status received from live timing API. 4 | 5 | The handler parses the status as an atom and passes it on to the F1 session instance. 6 | """ 7 | require Logger 8 | alias F1Bot.F1Session.LiveTimingHandlers 9 | 10 | alias F1Bot.F1Session 11 | alias LiveTimingHandlers.{Packet, ProcessingResult} 12 | 13 | @behaviour LiveTimingHandlers 14 | @scope "SessionStatus" 15 | 16 | @impl F1Bot.F1Session.LiveTimingHandlers 17 | def process_packet( 18 | session, 19 | %Packet{ 20 | topic: @scope, 21 | data: %{"Status" => status} 22 | }, 23 | _options 24 | ) do 25 | status = 26 | status 27 | |> String.trim() 28 | |> String.downcase() 29 | |> String.to_atom() 30 | 31 | {session, events} = F1Session.push_session_status(session, status) 32 | 33 | result = %ProcessingResult{ 34 | session: session, 35 | events: events 36 | } 37 | 38 | {:ok, result} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/lap_count.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.LapCount do 2 | @moduledoc """ 3 | Handler for lap count updates received from live timing API. 4 | """ 5 | require Logger 6 | alias F1Bot.F1Session.LiveTimingHandlers 7 | 8 | alias F1Bot.F1Session 9 | alias LiveTimingHandlers.{Packet, ProcessingResult} 10 | 11 | @behaviour LiveTimingHandlers 12 | @scope "LapCount" 13 | 14 | @impl F1Bot.F1Session.LiveTimingHandlers 15 | def process_packet( 16 | session, 17 | %Packet{ 18 | topic: @scope, 19 | data: data, 20 | timestamp: timestamp 21 | }, 22 | _options 23 | ) do 24 | current = data["CurrentLap"] 25 | total = data["TotalLaps"] 26 | 27 | current = if is_integer(current), do: current, else: nil 28 | total = if is_integer(total), do: total, else: nil 29 | 30 | {session, events} = F1Session.push_lap_counter_update(session, current, total, timestamp) 31 | 32 | result = %ProcessingResult{ 33 | session: session, 34 | events: events 35 | } 36 | 37 | {:ok, result} 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/chart_js.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.ChartJS do 2 | use F1BotWeb, :component 3 | alias Phoenix.LiveView 4 | 5 | prop chart_id, :string, required: true 6 | prop class, :css_class, default: "" 7 | 8 | @impl true 9 | def render(assigns) do 10 | ~F""" 11 |
12 | 13 | Your browser does not support HTML5 canvas to render this chart. 14 | 15 |
16 | """ 17 | end 18 | 19 | def initialize(socket, id, chart_class) do 20 | payload = %{chart_class: chart_class} 21 | LiveView.push_event(socket, "chartjs:#{id}:init", payload) 22 | end 23 | 24 | def insert_data(socket, id, payload) do 25 | payload = 26 | payload 27 | |> Map.put(:op, "insert") 28 | 29 | LiveView.push_event(socket, "chartjs:#{id}:data", payload) 30 | end 31 | 32 | def replace_data(socket, id, payload) do 33 | payload = 34 | payload 35 | |> Map.put(:op, "replace") 36 | 37 | LiveView.push_event(socket, "chartjs:#{id}:data", payload) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "./fonts.css"; 2 | @import "tailwindcss/base"; 3 | @import "tailwindcss/components"; 4 | @import "tailwindcss/utilities"; 5 | 6 | /* Import scoped CSS rules for components */ 7 | @import "./_components.css"; 8 | 9 | .data-update-highlight { 10 | animation: data-update-highlight 2s ease-in-out; 11 | --highlight-bg-color: rgb(255, 251, 0); 12 | --highlight-color: none; 13 | } 14 | 15 | .dark .data-update-highlight { 16 | --highlight-bg-color: hsl(220, 15%, 23%); 17 | --highlight-color: hsl(0, 0%, 100%); 18 | } 19 | 20 | html:not(.dark) { 21 | color-scheme: light; 22 | } 23 | 24 | html.dark { 25 | color-scheme: dark; 26 | } 27 | 28 | @keyframes data-update-highlight { 29 | 0% { 30 | /* opacity: 1; */ 31 | background-color: none; 32 | color: none; 33 | } 34 | 25% { 35 | /* opacity: 0.3; */ 36 | background-color: var(--highlight-bg-color); 37 | color: var(--highlight-color); 38 | } 39 | 75% { 40 | /* opacity: 0.3; */ 41 | background-color: var(--highlight-bg-color); 42 | color: var(--highlight-color); 43 | } 44 | 100% { 45 | /* opacity: 1; */ 46 | background-color: none; 47 | color: none; 48 | } 49 | } -------------------------------------------------------------------------------- /lib/f1_bot_web/live/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.LiveHelpers do 2 | alias Phoenix.LiveView 3 | 4 | def fetch_delayed_event_payload(event_scope, delay_ms, default_val) do 5 | case F1Bot.DelayedEvents.fetch_latest_event(delay_ms, event_scope) do 6 | {:ok, data} -> data.payload 7 | {:error, :no_data} -> default_val 8 | end 9 | end 10 | 11 | def subscribe_to_own_events(socket, session) do 12 | user_uuid = session["user_uuid"] 13 | 14 | if user_uuid do 15 | F1Bot.PubSub.subscribe("user_events:#{user_uuid}") 16 | end 17 | 18 | socket 19 | |> Phoenix.Component.assign(:user_uuid, user_uuid) 20 | end 21 | 22 | def broadcast_own_event(_user_uuid = nil, _message), do: :ignore 23 | 24 | def broadcast_own_event(user_uuid, message) do 25 | F1Bot.PubSub.broadcast("user_events:#{user_uuid}", message) 26 | end 27 | 28 | def get_check_param(socket, param_name, default_val, check_fn) 29 | when is_binary(param_name) and is_function(check_fn, 1) do 30 | sent_val = LiveView.get_connect_params(socket)[param_name] 31 | 32 | if sent_val != nil and check_fn.(sent_val) do 33 | sent_val 34 | else 35 | default_val 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/chart_js.hooks.ts: -------------------------------------------------------------------------------- 1 | import { ChartVisualization, createChart } from '@assets/Visualizations'; 2 | 3 | export default { 4 | mounted() { 5 | this.props = { id: this.el.id } 6 | console.log("Chart.js mounted", this.el); 7 | 8 | this.handleEvent(`chartjs:${this.props.id}:init`, async ({ chart_class }) => { 9 | console.log(`${this.props.id} Received init event:`, chart_class) 10 | 11 | const existing = this.props.chart as ChartVisualization; 12 | 13 | if (existing) { 14 | console.log('Destroying previous Chart.js instance'); 15 | existing.destroy(); 16 | } 17 | 18 | this.props.chart = createChart(chart_class, this.el); 19 | }); 20 | 21 | // Handles streaming data into the chart, replacing the entire dataset or inserting new data 22 | this.handleEvent(`chartjs:${this.props.id}:data`, async (updatePayload) => { 23 | console.log(`${this.props.id} Received data event:`, updatePayload) 24 | 25 | const chart = this.props.chart as ChartVisualization; 26 | 27 | if (!chart) { 28 | console.warn('Chart not initialized, skipping', this.props) 29 | return; 30 | } 31 | 32 | chart.updateData(updatePayload); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | f1_bot-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # In case you use Node.js/npm, you want to ignore these. 32 | npm-debug.log 33 | /assets/node_modules/ 34 | 35 | # Database files 36 | *.db 37 | *.db-* 38 | 39 | # Temporary files, for example, from tests. 40 | /tmp/ 41 | 42 | .elixir_ls 43 | .lexical 44 | .vscode 45 | .env 46 | *.zip 47 | 48 | # Ignore generated js hook files for components 49 | assets/js/_hooks/ 50 | 51 | # Ignore generated CSS file for components 52 | assets/css/_components.css -------------------------------------------------------------------------------- /assets/js/Visualizations/index.ts: -------------------------------------------------------------------------------- 1 | import { Chart } from 'chart.js/auto'; 2 | import 'chartjs-adapter-date-fns'; 3 | import annotationPlugin from 'chartjs-plugin-annotation'; 4 | // import zoomPlugin from 'chartjs-plugin-zoom'; 5 | 6 | import { RaceLapTimeChart } from "./RaceLapTimeChart" 7 | import { LapTimeScale } from './LapTimeScale'; 8 | import { AnyChartData } from './DataPayloads'; 9 | import { FPQualiLapTimeChart } from "./FPQualiLapTimeChart"; 10 | 11 | // Chart.register(zoomPlugin); 12 | Chart.register(annotationPlugin); 13 | Chart.register(LapTimeScale); 14 | 15 | interface ChartVisualization { 16 | updateData(data: AnyChartData): void; 17 | 18 | destroy(): void; 19 | } 20 | 21 | interface ConstructableChart { 22 | new(canvas: HTMLCanvasElement): ChartVisualization; 23 | } 24 | 25 | const ChartJsCharts: Record = { 26 | RaceLapTimeChart, 27 | FPQualiLapTimeChart, 28 | } 29 | 30 | const createChart = (chartType: string, canvas: HTMLCanvasElement): ChartVisualization => { 31 | const chartClass: ConstructableChart | undefined = ChartJsCharts[chartType]; 32 | 33 | if (!chartClass) { 34 | throw new Error(`Chart type '${chartType}' not found`); 35 | } 36 | 37 | return new chartClass(canvas); 38 | } 39 | 40 | export { 41 | createChart, 42 | ChartVisualization 43 | }; -------------------------------------------------------------------------------- /test/support/conn_case.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.ConnCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | tests that require setting up a connection. 5 | 6 | Such tests rely on `Phoenix.ConnTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use F1BotWeb.ConnCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | use F1BotWeb, :verified_routes 23 | 24 | # Import conveniences for testing with connections 25 | import Plug.Conn 26 | import Phoenix.ConnTest 27 | import F1BotWeb.ConnCase 28 | 29 | alias F1BotWeb.Router.Helpers, as: Routes 30 | 31 | # The default endpoint for testing 32 | @endpoint F1BotWeb.Endpoint 33 | end 34 | end 35 | 36 | setup tags do 37 | F1Bot.DataCase.setup_sandbox(tags) 38 | {:ok, conn: Phoenix.ConnTest.build_conn()} 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/f1_bot/authentication/api_client.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Authentication.ApiClient do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | 5 | @secret_bytes 64 6 | @scopes [ 7 | :transcriber_service, 8 | :read_transcripts 9 | ] 10 | 11 | schema("api_client") do 12 | field(:client_name, :string) 13 | field(:client_secret, :string) 14 | field(:scopes, {:array, Ecto.Enum}, values: @scopes) 15 | end 16 | 17 | def create_changeset(data \\ %__MODULE__{}, params) do 18 | data 19 | |> cast(params, [:client_name, :scopes]) 20 | |> validate_required([:client_name, :scopes]) 21 | |> put_change(:client_secret, generate_secret()) 22 | end 23 | 24 | def update_changeset(data, params) do 25 | data 26 | |> cast(params, [:scopes]) 27 | |> validate_required([:scopes]) 28 | end 29 | 30 | def generate_secret() do 31 | :crypto.strong_rand_bytes(@secret_bytes) 32 | |> Base.encode64(padding: false) 33 | end 34 | 35 | def verify_secret(this = %__MODULE__{}, provided_secret) do 36 | try do 37 | # Constant time comparison 38 | :crypto.hash_equals(this.client_secret, provided_secret) 39 | rescue 40 | _e -> false 41 | end 42 | end 43 | 44 | def token(this = %__MODULE__{}) do 45 | "#{this.client_name}:#{this.client_secret}" 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/session_info.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.SessionInfo do 2 | @moduledoc """ 3 | Handler for session information updates received from live timing API. 4 | 5 | The handler parses session information and passes it on to the F1 session instance. 6 | 7 | See `F1Bot.F1Session.SessionInfo` for more information. 8 | """ 9 | require Logger 10 | alias F1Bot.F1Session.LiveTimingHandlers 11 | 12 | alias F1Bot.F1Session 13 | alias LiveTimingHandlers.{Packet, ProcessingResult, ProcessingOptions} 14 | 15 | @behaviour LiveTimingHandlers 16 | @scope "SessionInfo" 17 | 18 | @impl F1Bot.F1Session.LiveTimingHandlers 19 | def process_packet( 20 | session, 21 | %Packet{ 22 | topic: @scope, 23 | data: data 24 | }, 25 | options = %ProcessingOptions{} 26 | ) do 27 | local_time = options.local_time_fn.() 28 | session_info = F1Bot.F1Session.SessionInfo.parse_from_json(data) 29 | 30 | {session, events, reset_session} = 31 | F1Session.push_session_info(session, session_info, local_time, options.ignore_reset) 32 | 33 | result = %ProcessingResult{ 34 | session: session, 35 | events: events, 36 | reset_session: reset_session 37 | } 38 | 39 | {:ok, result} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.CoreComponents do 2 | use Phoenix.Component 3 | use F1BotWeb, :verified_routes 4 | 5 | attr :class, :string, default: "" 6 | def brand(assigns) do 7 | ~H""" 8 | 9 | logo 10 | 11 | Race Bot for F1 12 | <%= if F1Bot.demo_mode?() do %> 13 | | Demo 14 | <% end %> 15 | 16 | 17 | """ 18 | end 19 | 20 | attr :class, :string, default: "" 21 | def other_site_link(assigns) do 22 | {other_site_name, other_site_link} = 23 | if F1Bot.demo_mode?() do 24 | {"Live Site", "https://racing.recursiveprojects.cloud"} 25 | else 26 | {"Demo Site", "https://racing-dev.recursiveprojects.cloud"} 27 | end 28 | 29 | assigns = 30 | assigns 31 | |> assign(:other_site_name, other_site_name) 32 | |> assign(:other_site_link, other_site_link) 33 | 34 | ~H""" 35 | 36 | 37 | <%= @other_site_name %> 38 | 39 | """ 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/utility.hooks.ts: -------------------------------------------------------------------------------- 1 | import { Storage } from "@assets/Storage"; 2 | 3 | const HighlightOnChange = { 4 | mounted() { 5 | this.lastVal = this.el.innerText; 6 | }, 7 | 8 | updated() { 9 | const animationClass = 'data-update-highlight'; 10 | 11 | const add = () => this.el.classList.add(animationClass); 12 | const remove = () => this.el.classList.remove(animationClass); 13 | 14 | // Sometimes an update is triggered even though the content hasn't changed. 15 | if (this.el.innerText === this.lastVal) return; 16 | this.lastVal = this.el.innerText; 17 | 18 | if (this.timeout) { 19 | clearTimeout(this.timeout); 20 | remove(); 21 | } 22 | 23 | this.timeout = setTimeout(() => { 24 | remove(); 25 | }, 2000); 26 | 27 | add(); 28 | } 29 | }; 30 | 31 | const SaveParams = { 32 | mounted() { 33 | this.handleEvent('save-params', (newParams) => { 34 | let params; 35 | 36 | try { 37 | params = Storage.load('params', {}, JSON.parse) 38 | } catch (e) { 39 | params = {}; 40 | console.error('Failed to load params from localStorage', e); 41 | } 42 | 43 | Object.assign(params, newParams); 44 | Storage.save('params', params); 45 | }) 46 | } 47 | } 48 | 49 | export { HighlightOnChange, SaveParams }; 50 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | let plugin = require('tailwindcss/plugin') 5 | 6 | module.exports = { 7 | darkMode: 'class', 8 | content: [ 9 | './js/**/*.js', 10 | '../lib/*_web.ex', 11 | '../lib/*_web/**/*.*ex', 12 | '../lib/*_web/**/*.sface', 13 | '../lib/*_web/**/*.heex', 14 | ], 15 | theme: { 16 | extend: { 17 | fontFamily: { 18 | 'roboto': ['Roboto', 'sans-serif'], 19 | 'exo2': ["'Exo 2'", 'sans-serif'], 20 | }, 21 | screens: { 22 | 'xs': '430px', 23 | 'sm': '640px', 24 | 'md': '768px', 25 | 'lg': '1024px', 26 | 'xl': '1280px', 27 | '2xl': '1536px', 28 | } 29 | } 30 | }, 31 | plugins: [ 32 | require('@tailwindcss/forms'), 33 | plugin(({ addVariant }) => addVariant('phx-no-feedback', ['&.phx-no-feedback', '.phx-no-feedback &'])), 34 | plugin(({ addVariant }) => addVariant('phx-click-loading', ['&.phx-click-loading', '.phx-click-loading &'])), 35 | plugin(({ addVariant }) => addVariant('phx-submit-loading', ['&.phx-submit-loading', '.phx-submit-loading &'])), 36 | plugin(({ addVariant }) => addVariant('phx-change-loading', ['&.phx-change-loading', '.phx-change-loading &'])) 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/event_generator/periodic.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.EventGenerator.Periodic do 2 | alias F1Bot.F1Session.{Clock, EventGenerator} 3 | 4 | def periodic_events(session, event_generator, local_time = %DateTime{}) do 5 | {event_generator, clock_events} = 6 | maybe_generate_session_clock_events(event_generator, session.clock, local_time) 7 | 8 | {event_generator, clock_events} 9 | end 10 | 11 | defp maybe_generate_session_clock_events( 12 | event_generator = %EventGenerator{}, 13 | _clock = nil, 14 | _local_time 15 | ) do 16 | {event_generator, []} 17 | end 18 | 19 | defp maybe_generate_session_clock_events( 20 | event_generator = %EventGenerator{}, 21 | clock = %Clock{}, 22 | local_time 23 | ) do 24 | with session_clock <- Clock.session_clock_from_local_time(clock, local_time), 25 | last_session_clock <- event_generator.event_deduplication[:session_clock], 26 | true <- session_clock != last_session_clock do 27 | events = [Clock.to_event(clock, local_time)] 28 | 29 | event_generator = 30 | put_in(event_generator, [Access.key(:event_deduplication), :session_clock], session_clock) 31 | 32 | {event_generator, events} 33 | else 34 | _ -> {event_generator, []} 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :f1_bot, 4 | connect_to_signalr: false, 5 | start_discord: false, 6 | discord_api_module: F1Bot.ExternalApi.Discord.Console 7 | 8 | # Configure your database 9 | # 10 | # The MIX_TEST_PARTITION environment variable can be used 11 | # to provide built-in test partitioning in CI environment. 12 | # Run `mix help test` for more information. 13 | config :f1_bot, F1Bot.Repo, 14 | database: Path.expand("../f1bot_test.db", Path.dirname(__ENV__.file)), 15 | pool_size: 5, 16 | pool: Ecto.Adapters.SQL.Sandbox 17 | 18 | # We don't run a server during test. If one is required, 19 | # you can enable the server option below. 20 | config :f1_bot, F1BotWeb.Endpoint, 21 | url: [host: "localhost"], 22 | http: [ip: {127, 0, 0, 1}, port: 4002], 23 | secret_key_base: "jCvmuMYzhlV6gYjGyhfx9EQx8opC4ZdOy4E5xS1fti38f6L9SeaUAUAeqyt4ZXMv", 24 | server: false 25 | 26 | config :f1_bot, F1BotWeb.InternalEndpoint, 27 | url: [host: "localhost"], 28 | http: [ip: {127, 0, 0, 1}, port: 4003], 29 | secret_key_base: "rmcLIUvCsAQzDrAJIz/36NFPe4eo1zpD4nL85nHzzgQmy8JI33GuCUeVqSJnisjX", 30 | live_view: [signing_salt: "M85ifOQO13BPHueM2huMVoFzN90ACBfU"], 31 | server: false 32 | 33 | # Print only warnings and errors during test 34 | config :logger, level: :warning 35 | 36 | # Initialize plugs at runtime for faster test compilation 37 | config :phoenix, :plug_init_mode, :runtime 38 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/parse/session_time.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Parse.SessionTime do 2 | @moduledoc false 3 | use Timex.Parse.Duration.Parser 4 | import NimbleParsec 5 | 6 | @impl true 7 | def parse(timestamp_string) do 8 | case parse_timestamp(timestamp_string) do 9 | {:ok, [hour, min, sec, mil], _remaining, _, _, _} -> 10 | microseconds = 11 | mil * 1000 + sec * 1_000_000 + min * 60 * 1_000_000 + hour * 3600 * 1_000_000 12 | 13 | duration = 14 | %Timex.Duration{ 15 | megaseconds: 0, 16 | seconds: 0, 17 | microseconds: microseconds 18 | } 19 | |> Timex.Duration.normalize() 20 | 21 | {:ok, duration} 22 | 23 | {:error, error, _, _, _, _} -> 24 | {:error, error} 25 | end 26 | end 27 | 28 | ## 29 | ## Timestamp (session time) parser, e.g. 1:10:15.234 into [1, 10, 15, 234] 30 | ## 31 | 32 | non_second_parser = 33 | integer(min: 1) 34 | |> ignore(ascii_char([?:])) 35 | 36 | second_parser = 37 | integer(min: 1) 38 | |> ignore(ascii_char([?.])) 39 | 40 | milliseconds_parser = integer(min: 1, max: 3) 41 | 42 | timestamp_combined = 43 | non_second_parser 44 | |> concat(non_second_parser) 45 | |> concat(second_parser) 46 | |> concat(milliseconds_parser) 47 | 48 | defparsecp(:parse_timestamp, timestamp_combined) 49 | end 50 | -------------------------------------------------------------------------------- /lib/f1_bot_web/internal_endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.InternalEndpoint do 2 | use Phoenix.Endpoint, otp_app: :f1_bot 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_f1_bot_internal_key", 10 | signing_salt: "G3CIDBOYtP43/SmPwwyTJ6XcMXI/GQJp" 11 | ] 12 | 13 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 14 | 15 | # Serve at "/" the static files from "priv/static" directory. 16 | # 17 | # You should set gzip to true if you are running phx.digest 18 | # when deploying your static files in production. 19 | plug Plug.Static, 20 | at: "/", 21 | from: :f1_bot, 22 | gzip: false, 23 | only: F1BotWeb.static_paths() 24 | 25 | plug Phoenix.LiveDashboard.RequestLogger, 26 | param_key: "request_logger", 27 | cookie_key: "request_logger" 28 | 29 | plug Plug.RequestId 30 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 31 | 32 | plug Plug.Parsers, 33 | parsers: [:urlencoded, :multipart, :json], 34 | pass: ["*/*"], 35 | json_decoder: Phoenix.json_library() 36 | 37 | plug Plug.MethodOverride 38 | plug Plug.Head 39 | plug Plug.Session, @session_options 40 | plug F1BotWeb.InternalRouter 41 | end 42 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/track_status.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.TrackStatus do 2 | @moduledoc """ 3 | Handler for track status received from live timing API. 4 | 5 | The handler parses the status as an atom and passes it on to the F1 session instance. 6 | """ 7 | require Logger 8 | alias F1Bot.F1Session.LiveTimingHandlers 9 | 10 | alias F1Bot.F1Session 11 | alias LiveTimingHandlers.{Packet, ProcessingResult} 12 | 13 | @behaviour LiveTimingHandlers 14 | @scope "TrackStatus" 15 | 16 | @status_map %{ 17 | 1 => :all_clear, 18 | 2 => :yellow_flag, 19 | 4 => :safety_car, 20 | 5 => :red_flag, 21 | 6 => :virtual_safety_car 22 | } 23 | 24 | @impl F1Bot.F1Session.LiveTimingHandlers 25 | def process_packet( 26 | session, 27 | %Packet{ 28 | topic: @scope, 29 | data: %{"Status" => status_str}, 30 | timestamp: timestamp 31 | }, 32 | _options 33 | ) do 34 | status_int = String.to_integer(status_str) 35 | status = Map.get(@status_map, status_int) 36 | 37 | {session, events} = 38 | if status == nil do 39 | {session, []} 40 | else 41 | F1Session.push_track_status(session, status, timestamp) 42 | end 43 | 44 | result = %ProcessingResult{ 45 | session: session, 46 | events: events 47 | } 48 | 49 | {:ok, result} 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /assets/js/app.ts: -------------------------------------------------------------------------------- 1 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 2 | import "@deps/phoenix_html" 3 | // Establish Phoenix Socket and LiveView configuration. 4 | import { Socket } from "@deps/phoenix" 5 | import { LiveSocket } from "@deps/phoenix_live_view" 6 | import topbar from "../vendor/topbar" 7 | import Hooks from "./_hooks" 8 | import { Storage } from "./Storage" 9 | import { DarkModeObserver } from "./DarkModeObserver"; 10 | 11 | DarkModeObserver.init(); 12 | 13 | const fetchParams = () => { 14 | const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") 15 | const params = { _csrf_token: csrfToken }; 16 | 17 | try { 18 | const storedParams = Storage.load('params', {}, JSON.parse); 19 | Object.assign(params, storedParams); 20 | } catch (e) { 21 | console.error('Failed to load params from localStorage', e); 22 | } 23 | 24 | return params; 25 | } 26 | const liveSocket = new LiveSocket("/live", Socket, { params: fetchParams, hooks: Hooks }) 27 | 28 | // Show progress bar on live navigation and form submits 29 | topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) 30 | window.addEventListener("phx:page-loading-start", info => topbar.show()) 31 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 32 | 33 | // connect if there are any LiveViews on the page 34 | liveSocket.connect(); 35 | 36 | (window as any).liveSocket = liveSocket; -------------------------------------------------------------------------------- /lib/f1_bot/time.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Time do 2 | @doc """ 3 | Equivalent to Timex.between?/3 except it treats nil `from_ts` and `to_ts` 4 | values as negative and positive infinity 5 | 6 | ## Examples 7 | 8 | iex> F1Bot.Time.between?( 9 | ...> Timex.from_unix(10), 10 | ...> Timex.from_unix(5), 11 | ...> nil 12 | ...> ) 13 | true 14 | 15 | iex> F1Bot.Time.between?( 16 | ...> Timex.from_unix(10), 17 | ...> nil, 18 | ...> Timex.from_unix(15) 19 | ...> ) 20 | true 21 | 22 | iex> F1Bot.Time.between?( 23 | ...> Timex.from_unix(10), 24 | ...> nil, 25 | ...> nil 26 | ...> ) 27 | true 28 | 29 | iex> F1Bot.Time.between?( 30 | ...> Timex.from_unix(10), 31 | ...> Timex.from_unix(1000), 32 | ...> Timex.from_unix(2000) 33 | ...> ) 34 | false 35 | """ 36 | @spec between?(DateTime.t(), DateTime.t() | nil, DateTime.t() | nil) :: 37 | boolean() 38 | def between?(ts, from_ts, to_ts) do 39 | cond do 40 | from_ts == nil and to_ts == nil -> 41 | true 42 | 43 | from_ts == nil -> 44 | Timex.before?(ts, to_ts) 45 | 46 | to_ts == nil -> 47 | Timex.after?(ts, from_ts) 48 | 49 | true -> 50 | Timex.between?(ts, from_ts, to_ts) 51 | end 52 | end 53 | 54 | def unix_timestamp_now(precision) when precision in [:second, :millisecond] do 55 | DateTime.utc_now() 56 | |> DateTime.to_unix(precision) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/parse/lap_time_duration.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Parse.LapTimeDuration do 2 | @moduledoc false 3 | use Timex.Parse.Duration.Parser 4 | import NimbleParsec 5 | 6 | @impl true 7 | def parse(lap_time_str) do 8 | case parse_lap_time(lap_time_str) do 9 | {:ok, [min, sec, mil], _remaining, _, _, _} -> 10 | microseconds = mil * 1000 + sec * 1_000_000 + min * 60 * 1_000_000 11 | 12 | duration = 13 | %Timex.Duration{ 14 | megaseconds: 0, 15 | seconds: 0, 16 | microseconds: microseconds 17 | } 18 | |> Timex.Duration.normalize() 19 | 20 | {:ok, duration} 21 | 22 | {:error, error, _, _, _, _} -> 23 | {:error, error} 24 | end 25 | end 26 | 27 | ## 28 | ## Lap time parser, e.g. 1:15.234 into [1, 15, 234] 29 | ## Minutes are optional and replaced by 0, e.g. "59.212" -> [0, 59, 212] 30 | ## 31 | 32 | lap_time_minutes_parser = 33 | choice([ 34 | integer(min: 1) 35 | |> ignore(ascii_char([?:])), 36 | empty() 37 | |> replace(0) 38 | ]) 39 | 40 | lap_time_seconds_parser = 41 | integer(min: 1, max: 2) 42 | |> ignore(ascii_char([?.])) 43 | 44 | lap_time_milliseconds_parser = integer(min: 1, max: 3) 45 | 46 | lap_time_combined = 47 | lap_time_minutes_parser 48 | |> concat(lap_time_seconds_parser) 49 | |> concat(lap_time_milliseconds_parser) 50 | 51 | defparsecp(:parse_lap_time, lap_time_combined) 52 | end 53 | -------------------------------------------------------------------------------- /lib/f1_bot/data_transform/format/lap_time_delta.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataTransform.Format.LapTimeDelta do 2 | @moduledoc false 3 | use Timex.Format.Duration.Formatter 4 | 5 | @impl true 6 | def format(duration) do 7 | total_mils = 8 | duration 9 | |> Timex.Duration.to_milliseconds() 10 | |> round() 11 | 12 | milliseconds = 13 | total_mils 14 | |> rem(1000) 15 | |> abs() 16 | 17 | seconds = 18 | total_mils 19 | |> div(1000) 20 | |> rem(60) 21 | |> abs() 22 | 23 | minutes = 24 | total_mils 25 | |> div(1000) 26 | |> div(60) 27 | |> abs() 28 | 29 | vals = {minutes, seconds, milliseconds} 30 | 31 | sign = 32 | if total_mils < 0 do 33 | "-" 34 | else 35 | "+" 36 | end 37 | 38 | sign 39 | |> maybe_add_minutes(vals) 40 | |> maybe_add_seconds(vals) 41 | |> maybe_add_milliseconds(vals) 42 | end 43 | 44 | @impl true 45 | def lformat(duration, _), do: format(duration) 46 | 47 | defp maybe_add_minutes(str, {min, _sec, _ms}) when min > 0, do: str <> "#{min}:" 48 | defp maybe_add_minutes(str, {_min, _sec, _ms}), do: str 49 | 50 | defp maybe_add_seconds(str, {min, sec, _ms}) when min == 0, do: str <> "#{sec}." 51 | 52 | defp maybe_add_seconds(str, {_min, sec, _ms}), 53 | do: str <> String.pad_leading("#{sec}", 2, "0") <> "." 54 | 55 | defp maybe_add_milliseconds(str, {_min, _sec, ms}), 56 | do: str <> String.pad_leading("#{ms}", 3, "0") 57 | end 58 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/common/time_series_store.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.Common.TimeSeriesStore do 2 | @moduledoc """ 3 | Generic storage for time-series information such as car telemetry. 4 | """ 5 | use TypedStruct 6 | alias Timex.Duration 7 | 8 | typedstruct do 9 | field(:data, [any()], default: []) 10 | end 11 | 12 | def new() do 13 | %__MODULE__{} 14 | end 15 | 16 | def push_data( 17 | self = %__MODULE__{data: data}, 18 | sample = %{timestamp: %DateTime{}} 19 | ) do 20 | %{self | data: [sample | data]} 21 | end 22 | 23 | def find_samples_between( 24 | _self = %__MODULE__{data: data}, 25 | from_ts = %DateTime{}, 26 | to_ts = %DateTime{} 27 | ) do 28 | data 29 | |> Enum.filter(fn %{timestamp: ts} -> 30 | Timex.between?(ts, from_ts, to_ts, inclusive: true) 31 | end) 32 | |> Enum.reverse() 33 | end 34 | 35 | def find_min_sample_around_ts( 36 | self = %__MODULE__{}, 37 | ts = %DateTime{}, 38 | window_ms, 39 | sample_cost_fn 40 | ) 41 | when is_function(sample_cost_fn, 1) do 42 | delta = Duration.from_milliseconds(window_ms) 43 | 44 | from_ts = Timex.subtract(ts, delta) 45 | to_ts = Timex.add(ts, delta) 46 | 47 | result = 48 | self 49 | |> find_samples_between(from_ts, to_ts) 50 | |> Enum.min_by(sample_cost_fn, nil) 51 | 52 | case result do 53 | nil -> {:error, :empty} 54 | sample -> {:ok, sample} 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/f1_bot_web/channels/api_socket_test.exs: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.ApiSocketTest do 2 | use F1BotWeb.ChannelCase 3 | 4 | alias F1Bot.Authentication 5 | alias F1Bot.Authentication.ApiClient 6 | alias F1BotWeb.ApiSocket 7 | 8 | @moduletag :channel 9 | 10 | describe "authentication" do 11 | test "clients without token can't connect", _context do 12 | result = connect(ApiSocket, %{}) 13 | assert match?({:error, :missing_token}, result) 14 | end 15 | 16 | test "clients with invalid token format can't connect", _context do 17 | result = connect(ApiSocket, %{token: "foo"}) 18 | assert match?({:error, :invalid_token_format}, result) 19 | end 20 | 21 | test "clients with unknown token user can't connect", _context do 22 | result = connect(ApiSocket, %{token: "unknown_name:unknown_secret"}) 23 | assert match?({:error, :unauthorized}, result) 24 | end 25 | 26 | test "clients with invalid client secret can't connect", _context do 27 | {:ok, client} = Authentication.create_api_client("test_client", []) 28 | result = connect(ApiSocket, %{token: "#{client.client_name}:unknown_secret"}) 29 | assert match?({:error, :unauthorized}, result) 30 | end 31 | 32 | test "clients with a valid secret can connect", _context do 33 | {:ok, client} = Authentication.create_api_client("test_client", []) 34 | result = connect(ApiSocket, %{token: ApiClient.token(client)}) 35 | assert match?({:ok, %Phoenix.Socket{}}, result) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/event_generator/state_sync.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.EventGenerator.StateSync do 2 | alias F1Bot.F1Session 3 | alias F1Bot.F1Session.EventGenerator 4 | alias F1Bot.F1Session.EventGenerator.{Driver, Charts} 5 | 6 | def state_sync_events(session = %F1Session{}, local_time) do 7 | driver_numbers = 1..99 8 | 9 | clock_events = 10 | if session.clock do 11 | [F1Session.Clock.to_event(session.clock, local_time)] 12 | else 13 | [] 14 | end 15 | 16 | event_generator = EventGenerator.clear_deduplication(session.event_generator, :driver_summary) 17 | session = %{session | event_generator: event_generator} 18 | 19 | {session, summary_events} = 20 | driver_numbers 21 | |> Enum.reduce({session, []}, fn driver_number, {session, events} -> 22 | {session, new_events} = Driver.summary_events(session, driver_number) 23 | {session, new_events ++ events} 24 | end) 25 | 26 | events = 27 | [ 28 | clock_events, 29 | Charts.chart_init_events(session), 30 | F1Session.DriverCache.to_event(session.driver_cache), 31 | F1Session.SessionInfo.to_event(session.session_info), 32 | F1Session.LapCounter.to_event(session.lap_counter), 33 | F1Session.TrackStatusHistory.to_chart_events(session.track_status_history), 34 | summary_events, 35 | Enum.map(driver_numbers, &Driver.lap_time_chart_events(session, &1)) 36 | ] 37 | |> List.flatten() 38 | 39 | {session, events} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/f1_bot/pubsub.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.PubSub do 2 | @moduledoc "" 3 | require Logger 4 | alias Phoenix.PubSub 5 | alias F1Bot.F1Session.Common.Event 6 | alias F1Bot.DelayedEvents 7 | 8 | def topic_for_event(scope) do 9 | "event:#{scope}" 10 | end 11 | 12 | @spec subscribe_to_event(String.t()) :: any() 13 | def subscribe_to_event(scope) do 14 | topic = topic_for_event(scope) 15 | subscribe(topic) 16 | end 17 | 18 | def subscribe(topic, opts \\ []) do 19 | Logger.debug( 20 | "[#{inspect(self())}] Subscribing to topic: #{topic} with opts: #{inspect(opts)}" 21 | ) 22 | 23 | PubSub.subscribe(__MODULE__, topic, opts) 24 | end 25 | 26 | def subscribe_all(topics) when is_list(topics) do 27 | Enum.each(topics, &subscribe(&1)) 28 | end 29 | 30 | @spec broadcast_events([Event.t()], boolean()) :: any() 31 | def broadcast_events(events, rebroadcast_delayed \\ true) do 32 | for e <- events do 33 | topic = topic_for_event(e.scope) 34 | broadcast(topic, e) 35 | end 36 | 37 | if rebroadcast_delayed do 38 | DelayedEvents.push_to_all(events) 39 | end 40 | end 41 | 42 | def broadcast(topic, message) do 43 | Logger.debug("Broadcasting event: #{topic}, payload: #{inspect(message, limit: 3)}") 44 | PubSub.broadcast(__MODULE__, topic, message) 45 | end 46 | 47 | def unsubscribe(topic) do 48 | PubSub.unsubscribe(__MODULE__, topic) 49 | end 50 | 51 | def unsubscribe_all(topics) when is_list(topics) do 52 | Enum.each(topics, &unsubscribe(&1)) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/discord/permissions.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.Discord.Permissions do 2 | alias Nostrum.Cache.GuildCache 3 | alias Nostrum.Permission 4 | 5 | @doc """ 6 | Checks whether @everyone role has "use external emojis" permission. 7 | 8 | This also determines whether we can use external emojis in slash command responses 9 | due to Discord not respecting permissions granted via individual roles. 10 | 11 | Discussion of this API limitation: https://github.com/discord/discord-api-docs/issues/2612 12 | """ 13 | @spec everyone_has_external_emojis?(pos_integer()) :: {:ok, boolean()} | {:error, any()} 14 | def everyone_has_external_emojis?(guild_id) when is_integer(guild_id) do 15 | case guild_role_permissions(guild_id, guild_id) do 16 | {:ok, permissions} -> 17 | has_perm = :use_external_emojis in permissions 18 | {:ok, has_perm} 19 | 20 | {:error, error} -> 21 | {:error, error} 22 | end 23 | end 24 | 25 | @spec guild_role_permissions(pos_integer(), pos_integer()) :: 26 | {:ok, [Permission.t()]} | {:error, any()} 27 | def guild_role_permissions(guild_id, role_id) when is_integer(guild_id) do 28 | with {:ok, cache} <- GuildCache.get(guild_id), 29 | role when is_map(role) <- cache.roles[role_id] do 30 | permissions = Permission.from_bitset(role.permissions) 31 | {:ok, permissions} 32 | else 33 | {:error, err} -> {:error, err} 34 | nil -> {:error, :role_not_found} 35 | v -> {:error, {:unknown_value, v}} 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/driver_list.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.DriverList do 2 | @moduledoc """ 3 | Handler for driver list updates received from live timing API. 4 | 5 | The handler parses driver information and passes it on to the F1 session instance. 6 | """ 7 | require Logger 8 | alias F1Bot.F1Session.LiveTimingHandlers 9 | import LiveTimingHandlers.Helpers 10 | 11 | alias F1Bot.F1Session 12 | alias F1Session.DriverCache.DriverInfo 13 | alias LiveTimingHandlers.{Packet, ProcessingResult} 14 | 15 | @behaviour LiveTimingHandlers 16 | @scope "DriverList" 17 | 18 | @impl F1Bot.F1Session.LiveTimingHandlers 19 | def process_packet( 20 | session, 21 | %Packet{ 22 | topic: @scope, 23 | data: data 24 | }, 25 | options 26 | ) do 27 | parsed_drivers = 28 | for {driver_no, driver_json} <- data, is_map(driver_json) do 29 | driver_number = driver_json["RacingNumber"] || driver_no 30 | driver_number = driver_number |> String.trim() |> String.to_integer() 31 | driver_json = Map.put(driver_json, "RacingNumber", driver_number) 32 | 33 | maybe_log_driver_data("Driver info", driver_number, driver_json, options) 34 | 35 | DriverInfo.parse_from_json(driver_json) 36 | end 37 | 38 | {session, events} = F1Session.push_driver_list_update(session, parsed_drivers) 39 | 40 | result = %ProcessingResult{ 41 | session: session, 42 | events: events 43 | } 44 | 45 | {:ok, result} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/processing_options.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.ProcessingOptions do 2 | @moduledoc """ 3 | Packet processing options. 4 | 5 | Fields: 6 | - `:ignore_reset` - If true, session reset mechanisms won't fire. This is useful for 7 | processing session replays 8 | - `:log_stray_packets` - If true, packets received while session is inactive will be logged. 9 | - `:log_drivers` - Packets related to specified drivers will be logged to console and log file. 10 | - `:local_time_fn` - 0-arity function to get the current local time for the purposes of packet processing. 11 | This can be overriden so that server time ("Utc" field in many Packets) is used in place of local system time, 12 | useful when replaying sessions where current local time is irrelevant and leads to inconsistencies such 13 | as the session clock not being reported correctly due to nearly instant passage of time. 14 | - `:skip_heavy_events` - If true, events such as driver summaries won't be created, this is useful 15 | during session replays to speed up processing time. 16 | """ 17 | use TypedStruct 18 | 19 | typedstruct do 20 | field(:ignore_reset, boolean()) 21 | field(:log_stray_packets, boolean()) 22 | field(:log_drivers, [integer()]) 23 | field(:local_time_fn, function()) 24 | field(:skip_heavy_events, boolean()) 25 | end 26 | 27 | def new(), do: %__MODULE__{} 28 | 29 | def merge(a = %__MODULE__{}, b = %__MODULE__{}) do 30 | MapUtils.patch_ignore_nil(a, b) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/event_generator.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.EventGenerator do 2 | use TypedStruct 3 | alias F1Bot.F1Session.EventGenerator 4 | 5 | typedstruct do 6 | field(:event_deduplication, map(), default: %{}) 7 | end 8 | 9 | def new do 10 | %__MODULE__{} 11 | end 12 | 13 | def put_deduplication(event_generator, key, value) do 14 | dedup = event_generator.event_deduplication 15 | 16 | event_generator 17 | |> Map.put(:event_deduplication, Map.put(dedup, key, value)) 18 | end 19 | 20 | def clear_deduplication(event_generator) do 21 | %{event_generator | event_deduplication: %{}} 22 | end 23 | 24 | def clear_deduplication(event_generator, key) do 25 | dedup = event_generator.event_deduplication 26 | 27 | event_generator 28 | |> Map.put(:event_deduplication, Map.delete(dedup, key)) 29 | end 30 | 31 | defdelegate make_driver_summary_events(session, driver_number), 32 | to: EventGenerator.Driver, 33 | as: :summary_events 34 | 35 | defdelegate make_events_on_any_new_driver_data(session, driver_number), 36 | to: EventGenerator.Driver, 37 | as: :on_any_new_driver_data 38 | 39 | defdelegate make_lap_time_chart_events(session, driver_number), 40 | to: EventGenerator.Driver, 41 | as: :lap_time_chart_events 42 | 43 | defdelegate make_periodic_events(session, event_generator, local_time), 44 | to: EventGenerator.Periodic, 45 | as: :periodic_events 46 | 47 | defdelegate make_state_sync_events(session, local_time), 48 | to: EventGenerator.StateSync, 49 | as: :state_sync_events 50 | end 51 | -------------------------------------------------------------------------------- /test/integration/miami_2022_quali_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Integration.Miami2022QualiTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias F1Bot.F1Session 5 | alias F1Bot.Replay 6 | 7 | @moduletag :uses_live_timing_data 8 | 9 | setup_all context do 10 | replay_options = %Replay.Options{ 11 | exclude_files_regex: ~r/\.z\./ 12 | } 13 | 14 | {:ok, %{session: session}} = 15 | "https://livetiming.formula1.com/static/2022/2022-05-08_Miami_Grand_Prix/2022-05-07_Qualifying" 16 | |> Replay.start_replay(replay_options) 17 | 18 | Map.put(context, :session, session) 19 | end 20 | 21 | test "lap fields are correctly merged for #16 Charles Leclerc", context do 22 | laps = laps_for_driver(context.session, 16) 23 | 24 | # During quali sessions the first lap is always "fake" for some reason, 25 | # 2nd lap starts as soon as drivers leave the pits for the first time 26 | laps_without_first = Enum.reject(laps, fn %{number: n} -> n == 1 end) 27 | 28 | laps_with_number_only = 29 | Enum.filter(laps_without_first, fn l -> 30 | l.number != nil and l.sectors == nil and l.time == nil 31 | end) 32 | 33 | assert laps_with_number_only == [], inspect(laps_with_number_only, pretty: true) 34 | end 35 | 36 | defp laps_for_driver(session, driver_number) do 37 | {:ok, driver_data} = F1Session.driver_session_data(session, driver_number) 38 | 39 | driver_data.laps.data 40 | |> Map.values() 41 | |> order_laps() 42 | end 43 | 44 | defp order_laps(laps) do 45 | Enum.sort_by(laps, fn l -> l.number end, :asc) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/support/channel_case.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.ChannelCase do 2 | @moduledoc """ 3 | This module defines the test case to be used by 4 | channel tests. 5 | 6 | Such tests rely on `Phoenix.ChannelTest` and also 7 | import other functionality to make it easier 8 | to build common data structures and query the data layer. 9 | 10 | Finally, if the test case interacts with the database, 11 | we enable the SQL sandbox, so changes done to the database 12 | are reverted at the end of every test. If you are using 13 | PostgreSQL, you can even run database tests asynchronously 14 | by setting `use F1BotWeb.ChannelCase, async: true`, although 15 | this option is not recommended for other databases. 16 | """ 17 | 18 | use ExUnit.CaseTemplate 19 | 20 | using do 21 | quote do 22 | # Import conveniences for testing with channels 23 | import Phoenix.ChannelTest 24 | import F1BotWeb.ChannelCase 25 | 26 | # The default endpoint for testing 27 | @endpoint F1BotWeb.Endpoint 28 | end 29 | end 30 | 31 | setup tags do 32 | F1Bot.DataCase.setup_sandbox(tags) 33 | :ok 34 | end 35 | 36 | defmacro assert_push_on_topic(topic, event, payload, timeout_ms) do 37 | quote location: :keep, 38 | bind_quoted: [timeout_ms: timeout_ms, topic: topic, event: event], 39 | unquote: true do 40 | assert_receive( 41 | %Phoenix.Socket.Message{ 42 | topic: ^topic, 43 | event: ^event, 44 | payload: unquote(payload) 45 | }, 46 | timeout_ms 47 | ) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/integration/canada_2022_quali_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Integration.Canada2022QualiTest do 2 | @moduledoc """ 3 | Live Timing API recorded a lap time of 1:11.324 for Max Verstappen (#1) 4 | which was 10 seconds faster than anyone else in the session and obviously incorrect. 5 | 6 | The lap is missing a lap number and sector times. 7 | """ 8 | use ExUnit.Case, async: true 9 | alias F1Bot.Replay 10 | 11 | @moduletag :uses_live_timing_data 12 | 13 | setup_all context do 14 | replay_options = %Replay.Options{ 15 | exclude_files_regex: ~r/\.z\./ 16 | } 17 | 18 | {:ok, %{session: session}} = 19 | "https://livetiming.formula1.com/static/2022/2022-06-19_Canadian_Grand_Prix/2022-06-18_Qualifying/" 20 | |> Replay.start_replay(replay_options) 21 | 22 | {:ok, fastest_lap} = "1:21.299" |> F1Bot.DataTransform.Parse.parse_lap_time() 23 | 24 | context 25 | |> Map.put(:session, session) 26 | |> Map.put(:actual_fastest_lap, fastest_lap) 27 | end 28 | 29 | test "disregards incorrect lap times", context do 30 | [fastest_lap | _rest] = 31 | context.session 32 | |> all_laps() 33 | |> Enum.filter(fn lap -> lap.time != nil end) 34 | |> Enum.sort_by(fn lap -> Timex.Duration.to_milliseconds(lap.time) end, :asc) 35 | 36 | assert fastest_lap.time == context.actual_fastest_lap 37 | end 38 | 39 | defp all_laps(session) do 40 | all_driver_data = session.driver_data_repo.drivers 41 | 42 | for driver_data <- Map.values(all_driver_data), 43 | lap <- Map.values(driver_data.laps.data) do 44 | lap 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/lap_time_field.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.LapTimeField do 2 | use F1BotWeb, :live_component 3 | alias F1Bot.DataTransform.Format 4 | 5 | prop class, :css_class 6 | prop stat, :map, required: true 7 | prop overall_fastest_class, :css_class, default: "border-purple-600" 8 | prop personal_fastest_class, :css_class, default: "border-green-600" 9 | prop not_fastest_class, :css_class, default: "border-transparent" 10 | prop can_drop_minute, :boolean, default: true 11 | 12 | @impl true 13 | def render(assigns) do 14 | ~F""" 15 | 29 | {format_value(@stat, @can_drop_minute)} 30 | 31 | """ 32 | end 33 | 34 | defp format_value(stat, can_drop_minute) do 35 | case stat.value do 36 | nil -> "—" 37 | value -> Format.format_lap_time(value, can_drop_minute) 38 | end 39 | end 40 | 41 | defp class_for_best_type(options) do 42 | best_type = options[:stat][:best] 43 | 44 | case best_type do 45 | :overall -> options[:overall_fastest_class] 46 | :personal -> options[:personal_fastest_class] 47 | _ -> options[:not_fastest_class] 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | f1_bot-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # Ignore digested assets 32 | /priv/static/**/*-[a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9][a-z0-9].* 33 | 34 | # Ignore compressed assets 35 | /priv/static/**/*.gz 36 | 37 | # In case you use Node.js/npm, you want to ignore these. 38 | npm-debug.log 39 | node_modules/ 40 | 41 | # Database files 42 | *.db 43 | *.db-* 44 | 45 | # Temporary files, for example, from tests. 46 | /tmp/ 47 | 48 | .elixir_ls 49 | .lexical 50 | .vscode 51 | .idea 52 | .env 53 | *.zip 54 | 55 | # Ignore generated js hook files for components 56 | assets/js/_hooks/ 57 | 58 | # Ignore generated CSS file for components 59 | assets/css/_components.css 60 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/driver_selector.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.DriverSelector do 2 | use F1BotWeb, :component 3 | 4 | prop driver_list, :list, required: true 5 | prop drivers_of_interest, :list, required: true 6 | prop toggle_driver, :event, required: true 7 | 8 | @impl true 9 | def render(assigns) do 10 | ~F""" 11 |
12 | {#for driver_info <- @driver_list} 13 | 26 | {/for} 27 |
28 | """ 29 | end 30 | 31 | defp selected_classes(driver_info, drivers_of_interest) do 32 | if driver_info.driver_number in drivers_of_interest do 33 | "bg-white dark:bg-[hsl(220,10%,20%)]" 34 | else 35 | "bg-slate-200 dark:bg-[hsl(220,10%,11%)]" 36 | end 37 | end 38 | 39 | defp selected_styles(driver_info, drivers_of_interest) do 40 | team_color = "##{driver_info.team_color}" 41 | if driver_info.driver_number in drivers_of_interest do 42 | "border-left-color: #{team_color}; border-bottom-color: #{team_color};" 43 | else 44 | "border-left-color: #{team_color}; border-bottom-color: transparent;" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /assets/js/Visualizations/FPQualiLapTimeChart.ts: -------------------------------------------------------------------------------- 1 | import { AnnotationOptions } from "chartjs-plugin-annotation"; 2 | import { TrackStatusDataPoint } from "./DataPayloads"; 3 | import { RaceLapTimeChart } from "./RaceLapTimeChart"; 4 | 5 | class FPQualiLapTimeChart extends RaceLapTimeChart { 6 | constructor(canvas: HTMLCanvasElement) { 7 | super(canvas); 8 | } 9 | 10 | protected makeIntervalAnnotation(point: TrackStatusDataPoint): AnnotationOptions { 11 | const options = super.makeIntervalAnnotation(point); 12 | options.xMin = point.ts_from; 13 | options.xMax = point.ts_to; 14 | return options; 15 | } 16 | 17 | protected makeInstantAnnotation(point: TrackStatusDataPoint): AnnotationOptions { 18 | const options = super.makeIntervalAnnotation(point); 19 | options.xMin = point.ts_from; 20 | options.xMax = point.ts_to; 21 | return options; 22 | } 23 | 24 | protected schema() { 25 | const schema = super.schema() as any; 26 | const scales = schema.options.scales; 27 | const plugins = schema.options.plugins; 28 | 29 | schema.options.parsing.xAxisKey = 'ts'; 30 | scales.x.title.text = 'Session Time'; 31 | 32 | const newXScaleOpts = { 33 | type: 'time', 34 | display: true, 35 | time: { 36 | unit: 'minute', 37 | displayFormats: { 38 | minute: 'H:mm', 39 | }, 40 | tooltipFormat: 'H:mm:ss' 41 | } 42 | }; 43 | 44 | Object.assign(scales.x, newXScaleOpts); 45 | 46 | delete plugins.tooltip.callbacks.title; 47 | delete scales.x.ticks.stepSize; 48 | delete scales.x.ticks.precision; 49 | 50 | return schema; 51 | } 52 | } 53 | 54 | export { FPQualiLapTimeChart }; -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/clock.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.Clock do 2 | use TypedStruct 3 | 4 | alias F1Bot.F1Session.Common.Event 5 | 6 | typedstruct do 7 | field :utc_server_time_sync, DateTime.t() 8 | field :utc_local_time_sync, DateTime.t() 9 | field :session_clock, Timex.Duration.t() 10 | field :is_running, boolean() 11 | end 12 | 13 | def new(server_time, local_time, session_clock, is_running) do 14 | %__MODULE__{ 15 | utc_server_time_sync: server_time, 16 | utc_local_time_sync: local_time, 17 | session_clock: session_clock, 18 | is_running: is_running 19 | } 20 | end 21 | 22 | def session_clock_from_server_time(clock, server_time) do 23 | if clock.is_running do 24 | time_since_clock_started = Timex.diff(server_time, clock.utc_server_time_sync, :duration) 25 | 26 | remaining = 27 | clock.session_clock 28 | |> Timex.Duration.sub(time_since_clock_started) 29 | 30 | if Timex.Duration.to_milliseconds(remaining) < 0 do 31 | Timex.Duration.from_seconds(0) 32 | else 33 | remaining 34 | end 35 | else 36 | clock.session_clock 37 | end 38 | end 39 | 40 | def session_clock_from_local_time(clock, local_time) do 41 | local_time_delta = Timex.diff(local_time, clock.utc_local_time_sync, :duration) 42 | server_time = Timex.add(clock.utc_server_time_sync, local_time_delta) 43 | 44 | session_clock_from_server_time(clock, server_time) 45 | end 46 | 47 | def to_event(clock = %__MODULE__{}, local_time) do 48 | session_clock = session_clock_from_local_time(clock, local_time) 49 | Event.new("session_clock:changed", session_clock) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/f1_bot_web/channels/radio_transcript_channel_test.exs: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.RadioTranscriptChannelTest do 2 | use F1BotWeb.ChannelCase 3 | 4 | alias F1BotWeb.ApiSocket 5 | alias F1Bot.Authentication 6 | alias F1Bot.Authentication.ApiClient 7 | @moduletag :channel 8 | 9 | # Timeout for message assertions 10 | @ms_to 1000 11 | 12 | setup do 13 | {:ok, read_only_client} = Authentication.create_api_client("ro_client", [:read_transcripts]) 14 | {:ok, unauth_client} = Authentication.create_api_client("unauth", []) 15 | 16 | {:ok, read_only_socket} = connect(ApiSocket, %{token: ApiClient.token(read_only_client)}) 17 | {:ok, unauth_socket} = connect(ApiSocket, %{token: ApiClient.token(unauth_client)}) 18 | 19 | %{ 20 | read_only_client: read_only_client, 21 | read_only_socket: read_only_socket, 22 | unauth_client: unauth_client, 23 | unauth_socket: unauth_socket, 24 | } 25 | end 26 | 27 | describe "radio_transcript channel auth" do 28 | test "read_transcript client can join the channel", %{read_only_socket: read_only} do 29 | result = join(read_only, "radio_transcript:all") 30 | assert match?({:ok, _reply, _socket}, result) 31 | end 32 | 33 | test "other API clients can't join the channel", %{unauth_socket: unauth} do 34 | result = join(unauth, "radio_transcript:all") 35 | assert match?({:error, :unauthorized}, result) 36 | end 37 | end 38 | 39 | describe "status" do 40 | test "status is sent after joining :status subchannel", %{read_only_socket: socket} do 41 | {:ok, _reply, _socket} = subscribe_and_join(socket, "radio_transcript:status") 42 | 43 | assert_push("status", %{online: _, drivers: [_ | _]}, @ms_to) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/f1_bot_web/channels/transcriber_service_channel.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.TranscriberServiceChannel do 2 | use F1BotWeb, :channel 3 | require Logger 4 | 5 | alias Ecto.Changeset 6 | alias F1Bot.TranscriberService 7 | alias F1Bot.F1Session.Server 8 | alias F1Bot.F1Session.DriverDataRepo.Transcript 9 | alias F1BotWeb.ApiSocket 10 | 11 | @impl true 12 | def join("transcriber_service", _payload, socket) do 13 | if ApiSocket.client_has_scope?(socket, :transcriber_service) do 14 | {:ok, socket} 15 | else 16 | {:error, :unauthorized} 17 | end 18 | end 19 | 20 | @impl true 21 | def handle_in("transcript", payload, socket) do 22 | case Transcript.validate(payload) do 23 | {:ok, transcript} -> 24 | process_transcript(transcript, socket) 25 | {:reply, :ok, socket} 26 | 27 | {:error, changeset = %Changeset{}} -> 28 | Logger.warning("Received invalid transcript: #{inspect(changeset)}") 29 | {:reply, {:error, :invalid_data}, socket} 30 | end 31 | end 32 | 33 | @impl true 34 | def handle_in("update-status", payload, socket) do 35 | case TranscriberService.Status.validate(payload) do 36 | {:ok, status_update} -> 37 | TranscriberService.update_status(status_update) 38 | {:reply, :ok, socket} 39 | 40 | {:error, changeset = %Changeset{}} -> 41 | Logger.warning("Received invalid status update: #{inspect(changeset)}") 42 | {:reply, {:error, :invalid_data}, socket} 43 | end 44 | end 45 | 46 | defp process_transcript(transcript = %Transcript{}, _socket) do 47 | Logger.info("Received transcript: #{inspect(transcript)}") 48 | Server.process_transcript(transcript) 49 | Transcript.broadcast_to_channels(transcript) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/f1_bot/plotting.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Plotting do 2 | @moduledoc """ 3 | """ 4 | alias F1Bot.Plotting 5 | 6 | def plot_lap_times(driver_numbers, options \\ []) do 7 | Plotting.LapTime.plot(driver_numbers, options) 8 | end 9 | 10 | def plot_gap(driver_numbers, options \\ []) do 11 | Plotting.Gap.plot(driver_numbers, options) 12 | end 13 | 14 | def do_gnuplot(file_path, options, datasets) do 15 | case Gnuplot.plot(options, datasets) do 16 | {:ok, _commands} -> 17 | if check_file_ok(file_path) do 18 | {:ok, file_path} 19 | else 20 | cleanup(file_path) 21 | {:error, :file_not_created} 22 | end 23 | 24 | {:error, _, err} -> 25 | cleanup(file_path) 26 | {:error, err} 27 | end 28 | end 29 | 30 | def cleanup(file_path) do 31 | File.rm(file_path) 32 | end 33 | 34 | def check_file_ok(file_path) do 35 | case File.stat(file_path) do 36 | {:ok, %{size: size, type: type}} -> 37 | type == :regular and size > 0 38 | 39 | _ -> 40 | false 41 | end 42 | end 43 | 44 | def verify_datasets_nonempty(datasets) do 45 | all_points = List.flatten(datasets) 46 | 47 | if length(all_points) > 0 do 48 | :ok 49 | else 50 | :error 51 | end 52 | end 53 | 54 | def create_temp_file_path(extension) do 55 | charset = safe_chars() 56 | 57 | rand = 58 | for _ <- 1..20 do 59 | Enum.random(charset) 60 | end 61 | |> to_string() 62 | 63 | file_name = "f1_gnuplot_#{rand}.#{extension}" 64 | 65 | System.tmp_dir() 66 | |> Path.join(file_name) 67 | end 68 | 69 | defp safe_chars() do 70 | [?0..?9, ?a..?z, ?A..?Z] 71 | |> Enum.map_join(&Enum.to_list/1) 72 | |> to_charlist() 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/f1_bot/analysis/common.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Analysis.Common do 2 | @moduledoc """ 3 | Common functions for analysis 4 | """ 5 | 6 | alias F1Bot.F1Session 7 | alias F1Bot.F1Session.TrackStatusHistory 8 | alias F1Bot.F1Session.DriverDataRepo.{DriverData, Laps} 9 | 10 | def all_driver_data(session = %F1Session{}) do 11 | case F1Session.driver_list(session) do 12 | {:ok, driver_list} -> 13 | data = 14 | driver_list 15 | |> Stream.map(& &1.driver_number) 16 | |> Stream.map(&{&1, F1Session.driver_session_data(session, &1)}) 17 | |> Stream.filter(fn {status, _} -> status == :ok end) 18 | |> Stream.map(fn {_ok, data} -> data end) 19 | |> Enum.into(%{}) 20 | 21 | {:ok, data} 22 | end 23 | end 24 | 25 | def fetch_driver_laps(session = %F1Session{}, driver_no) do 26 | case F1Session.driver_session_data(session, driver_no) do 27 | {:ok, %DriverData{laps: laps}} -> {:ok, laps} 28 | {:error, error} -> {:error, error} 29 | end 30 | end 31 | 32 | def fetch_driver_stints(session = %F1Session{}, driver_no) do 33 | case F1Session.driver_session_data(session, driver_no) do 34 | {:ok, %DriverData{stints: stints}} -> {:ok, stints} 35 | {:error, error} -> {:error, error} 36 | end 37 | end 38 | 39 | def fetch_driver_lap(all_driver_data, driver_no, lap_no) do 40 | case all_driver_data[driver_no] do 41 | nil -> {:error, :not_found} 42 | %DriverData{laps: laps} -> Laps.fetch_by_number(laps, lap_no) 43 | end 44 | end 45 | 46 | def neutralized_periods(session = %F1Session{}) do 47 | TrackStatusHistory.find_intervals_with_status(session.track_status_history, [ 48 | :red_flag, 49 | :safety_car, 50 | :virtual_safety_car 51 | ]) 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM elixir:1.15-slim AS build 2 | 3 | ENV MIX_ENV=prod 4 | 5 | RUN apt-get update && \ 6 | apt-get install -y build-essential git npm && \ 7 | apt-get clean && rm -f /var/lib/apt/lists/*_* 8 | 9 | WORKDIR /app 10 | 11 | # Fetch dependencies 12 | RUN mix local.hex --force && mix local.rebar --force 13 | COPY mix.exs mix.lock ./ 14 | RUN mix deps.get --only "${MIX_ENV}" 15 | 16 | # Copy static configs and compile dependencies 17 | RUN mkdir config 18 | COPY config/config.exs config/${MIX_ENV}.exs config/ 19 | RUN mix deps.compile 20 | 21 | # Build assets and code 22 | COPY priv priv 23 | COPY lib lib 24 | COPY assets assets 25 | COPY tsconfig.json package*json ./ 26 | RUN mix assets.setup && \ 27 | mix assets.deploy && \ 28 | mix compile 29 | 30 | # Create release 31 | COPY config/runtime.exs config/ 32 | RUN mix release 33 | 34 | # ================================================= 35 | 36 | FROM debian:12-slim 37 | 38 | RUN apt-get update -y && \ 39 | apt-get install -y libstdc++6 openssl libncurses5 locales && \ 40 | apt-get clean && \ 41 | rm -f /var/lib/apt/lists/*_* 42 | 43 | ENV MIX_ENV=prod 44 | 45 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen 46 | ENV LANG en_US.UTF-8 47 | ENV LANGUAGE en_US:en 48 | ENV LC_ALL en_US.UTF-8 49 | 50 | RUN useradd --create-home --uid 1000 app 51 | 52 | WORKDIR /app 53 | RUN chown app /app 54 | 55 | COPY --from=build --chown=app:root /app/_build/${MIX_ENV}/rel/f1bot /app/ 56 | COPY entrypoint.sh scripts LICENSE.md /app/ 57 | 58 | # Entrypoint drops privileges to the `app` user 59 | USER root 60 | 61 | # Explicitly invoke `bash` to ensure that the entrypoint script can 62 | # run even without the `execute` bit set. 63 | # This happens when the repo is cloned on a Windows filesystem. 64 | CMD ["/bin/bash", "/app/entrypoint.sh"] 65 | -------------------------------------------------------------------------------- /lib/f1_bot_web/live/telemetry.sface: -------------------------------------------------------------------------------- 1 |
2 |
3 | <.brand /> 4 |
5 | 6 |
7 | 8 |
9 |
10 | 11 |
12 | 13 |
14 | 15 |
16 | 17 |
18 | 19 |
20 |
21 | 22 | 23 |
24 |
25 | {@session_info.gp_name} - {@session_info.type} 26 | 27 | Open charts 28 | 29 |
30 | 31 |
32 | {#for driver_info <- @driver_list} 33 | 39 | {/for} 40 |
41 |
42 |
43 |
44 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # In default (dev) environment, with :external_apis_enabled set to false and 2 | # :discord_api_module set to the `Console` variant, the bot isn't going to connect 3 | # to Discord, so you can fill these variables with random/garbage values. 4 | 5 | DISCORD_TOKEN= 6 | 7 | # Comma-separated list 8 | DISCORD_CHANNEL_IDS_MESSAGES= 9 | # Radio transcripts 10 | DISCORD_CHANNEL_IDS_RADIOS= 11 | DISCORD_SERVER_IDS_COMMANDS= 12 | 13 | # Override emojis when self-hosting. Emojis from `assets/discord_emojis` need to be uploaded to your own Discord server. 14 | # To find the emoji ID, you can insert the emoji in Discord message box, then type a backslash directly in front of it. 15 | # After you send the message, the emoji ID should appear in chat. 16 | # DISCORD_EMOJI_ANNOUNCEMENT=<:YOUR_EMOJI_NAME:YOUR_EMOJI_ID> 17 | # DISCORD_EMOJI_QUICK= 18 | # DISCORD_EMOJI_SPEEDOMETER= 19 | # DISCORD_EMOJI_TIMER= 20 | # DISCORD_EMOJI_HARD_TYRE= 21 | # DISCORD_EMOJI_MEDIUM_TYRE= 22 | # DISCORD_EMOJI_SOFT_TYRE= 23 | # DISCORD_EMOJI_TEST_TYRE= 24 | # DISCORD_EMOJI_WET_TYRE= 25 | # DISCORD_EMOJI_INTERMEDIATE_TYRE= 26 | # DISCORD_EMOJI_FLAG_YELLOW= 27 | # DISCORD_EMOJI_FLAG_RED= 28 | # DISCORD_EMOJI_FLAG_CHEQUERED= 29 | 30 | DEMO_MODE_URL= 31 | # The hostname of the Phoenix server, without the protocol. 32 | # If this is wrong, the websocket connections will fail. 33 | PHX_HOST=racing.recursiveprojects.cloud 34 | # Secret used for signing cookies 35 | SECRET_KEY_BASE=use `mix phx.gen.secret` or `openssl rand -hex 64` to generate this 36 | 37 | # Used in docker-compose.yml 38 | # Port to expose on the host, `port` or `IP:port` 39 | DOCKER_BIND_PORT=4000 40 | # Docker volume or host mount point 41 | DOCKER_DATA_VOLUME=data 42 | DOCKER_CONTAINER_NAME=f1bot 43 | # Tag `master` for stable, or `develop` for in-development versions 44 | DOCKER_IMAGE=ghcr.io/recursivegecko/race_bot:master 45 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/discord.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.Discord do 2 | @moduledoc "" 3 | @callback post_message(String.t()) :: :ok | {:error, any()} 4 | 5 | def post_message(message_or_tuple) do 6 | impl = F1Bot.get_env(:discord_api_module, F1Bot.ExternalApi.Discord.Console) 7 | impl.post_message(message_or_tuple) 8 | end 9 | 10 | def get_emoji_or_default(emoji, default) do 11 | case get_emoji_with_env_override(emoji) do 12 | nil -> default 13 | val -> val 14 | end 15 | end 16 | 17 | def get_emoji_with_env_override(emoji) do 18 | emoji_upcase = emoji |> to_string() |> String.upcase() 19 | env_var = "DISCORD_EMOJI_" <> emoji_upcase 20 | 21 | case System.get_env(env_var) do 22 | nil -> default_emoji(emoji) 23 | val -> val 24 | end 25 | end 26 | 27 | def default_emoji(:announcement), do: "<:f1_announcement:918883988867788830>" 28 | def default_emoji(:quick), do: "<:f1_quick:918883439028109313>" 29 | def default_emoji(:speedometer), do: "<:f1_speedometer:918882472551395388>" 30 | def default_emoji(:timer), do: "<:f1_timer:918882914899484672>" 31 | def default_emoji(:hard_tyre), do: "<:f1_tyre_hard:918870511713415278>" 32 | def default_emoji(:medium_tyre), do: "<:f1_tyre_medium:918870511772123186>" 33 | def default_emoji(:soft_tyre), do: "<:f1_tyre_soft:918870511646310440>" 34 | def default_emoji(:test_tyre), do: "<:f1_tyre_test:918870511801487420>" 35 | def default_emoji(:wet_tyre), do: "<:f1_tyre_wet:918870511591763998>" 36 | def default_emoji(:intermediate_tyre), do: "<:f1_tyre_intermediate:918870511625306142>" 37 | def default_emoji(:flag_yellow), do: "<:f1_flag_yellow:918888979808518174>" 38 | def default_emoji(:flag_red), do: "<:f1_flag_red:918888944450547803>" 39 | def default_emoji(:flag_chequered), do: "<:f1_flag_chequered:919209710647935037>" 40 | def default_emoji(_), do: nil 41 | end 42 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.DataCase do 2 | @moduledoc """ 3 | This module defines the setup for tests requiring 4 | access to the application's data layer. 5 | 6 | You may define functions here to be used as helpers in 7 | your tests. 8 | 9 | Finally, if the test case interacts with the database, 10 | we enable the SQL sandbox, so changes done to the database 11 | are reverted at the end of every test. If you are using 12 | PostgreSQL, you can even run database tests asynchronously 13 | by setting `use F1Bot.DataCase, async: true`, although 14 | this option is not recommended for other databases. 15 | """ 16 | 17 | use ExUnit.CaseTemplate 18 | 19 | using do 20 | quote do 21 | alias F1Bot.Repo 22 | 23 | import Ecto 24 | import Ecto.Changeset 25 | import Ecto.Query 26 | import F1Bot.DataCase 27 | end 28 | end 29 | 30 | setup tags do 31 | F1Bot.DataCase.setup_sandbox(tags) 32 | :ok 33 | end 34 | 35 | @doc """ 36 | Sets up the sandbox based on the test tags. 37 | """ 38 | def setup_sandbox(tags) do 39 | pid = Ecto.Adapters.SQL.Sandbox.start_owner!(F1Bot.Repo, shared: not tags[:async]) 40 | on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end) 41 | end 42 | 43 | @doc """ 44 | A helper that transforms changeset errors into a map of messages. 45 | 46 | assert {:error, changeset} = Accounts.create_user(%{password: "short"}) 47 | assert "password is too short" in errors_on(changeset).password 48 | assert %{password: ["password is too short"]} = errors_on(changeset) 49 | 50 | """ 51 | def errors_on(changeset) do 52 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> 53 | Regex.replace(~r"%{(\w+)}", message, fn _, key -> 54 | opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() 55 | end) 56 | end) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/integration/monza_2022_race_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Integration.Monza2022RaceTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias F1Bot.F1Session 5 | alias F1Bot.Replay 6 | 7 | @moduletag :uses_live_timing_data 8 | 9 | setup_all context do 10 | replay_options = %Replay.Options{ 11 | exclude_files_regex: ~r/\.z\./ 12 | } 13 | 14 | {:ok, %{session: session}} = 15 | "https://livetiming.formula1.com/static/2022/2022-09-11_Italian_Grand_Prix/2022-09-11_Race" 16 | |> Replay.start_replay(replay_options) 17 | 18 | Map.put(context, :session, session) 19 | end 20 | 21 | test "correctly processes starting tyre compound for #1 Max Verstappen", context do 22 | stints = stints_for_driver(context.session, 1) 23 | 24 | compounds = stints |> Enum.map(fn s -> s.compound end) 25 | lap_numbers = stints |> Enum.map(fn s -> s.lap_number end) 26 | 27 | expected_compounds = [:soft, :medium, :soft] 28 | expected_lap_numbers = [1, 26, 49] 29 | 30 | assert compounds == expected_compounds 31 | assert lap_numbers == expected_lap_numbers 32 | end 33 | 34 | test "correctly processes stints for #16 Charles Leclerc", context do 35 | stints = stints_for_driver(context.session, 16) 36 | 37 | compounds = stints |> Enum.map(fn s -> s.compound end) 38 | lap_numbers = stints |> Enum.map(fn s -> s.lap_number end) 39 | 40 | expected_compounds = [:soft, :medium, :soft, :soft] 41 | expected_lap_numbers = [1, 13, 34, 49] 42 | 43 | assert compounds == expected_compounds 44 | assert lap_numbers == expected_lap_numbers 45 | end 46 | 47 | defp stints_for_driver(session, driver_number) do 48 | {:ok, driver_data} = F1Session.driver_session_data(session, driver_number) 49 | driver_data.stints.data |> order_stints() 50 | end 51 | 52 | defp order_stints(stints) do 53 | Enum.sort_by(stints, fn s -> s.number end, :asc) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_data_repo/events.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverDataRepo.Events do 2 | @moduledoc """ 3 | Helper functions to generate events for `F1Bot.F1Session.DriverDataRepo` functions. 4 | """ 5 | alias F1Bot.F1Session.Common.Event 6 | 7 | def make_agg_fastest_lap_event(driver_number, type, lap_time, lap_delta) do 8 | make_aggregate_stats_event(:fastest_lap, %{ 9 | driver_number: driver_number, 10 | lap_time: lap_time, 11 | lap_delta: lap_delta, 12 | type: type 13 | }) 14 | end 15 | 16 | def make_agg_fastest_sector_event(driver_number, type, sector, sector_time, sector_delta) do 17 | make_aggregate_stats_event(:fastest_sector, %{ 18 | driver_number: driver_number, 19 | sector: sector, 20 | sector_time: sector_time, 21 | sector_delta: sector_delta, 22 | type: type 23 | }) 24 | end 25 | 26 | def make_agg_top_speed_event(driver_number, type, speed, speed_delta) do 27 | make_aggregate_stats_event(:top_speed, %{ 28 | driver_number: driver_number, 29 | speed: speed, 30 | speed_delta: speed_delta, 31 | type: type 32 | }) 33 | end 34 | 35 | def make_tyre_change_events( 36 | driver, 37 | _result = %{is_correction: is_correction, stint: stint} 38 | ) do 39 | event = 40 | make_event(driver, :tyre_change, %{ 41 | is_correction: is_correction, 42 | compound: stint.compound, 43 | age: stint.age 44 | }) 45 | 46 | [event] 47 | end 48 | 49 | def make_tyre_change_events(_driver, _result = nil), do: [] 50 | 51 | defp make_event(self, type, payload) do 52 | payload = 53 | payload 54 | |> Map.put(:driver_number, self.number) 55 | 56 | Event.new("driver:#{type}", payload) 57 | end 58 | 59 | defp make_aggregate_stats_event(type, payload) do 60 | Event.new("aggregate_stats:#{type}", payload) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_cache/driver_info.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverCache.DriverInfo do 2 | @moduledoc """ 3 | Personal information about a driver. 4 | """ 5 | use TypedStruct 6 | 7 | typedstruct do 8 | @typedoc "Information about a driver" 9 | 10 | field(:full_name, String.t()) 11 | field(:first_name, String.t()) 12 | field(:last_name, String.t()) 13 | field(:short_name, String.t()) 14 | field(:driver_number, pos_integer()) 15 | field(:driver_abbr, String.t()) 16 | field(:team_color, String.t()) 17 | field(:team_name, String.t()) 18 | field(:picture_url, String.t()) 19 | field(:chart_order, pos_integer()) 20 | field(:chart_team_order, pos_integer()) 21 | end 22 | 23 | def parse_from_json(json) do 24 | data = 25 | %{ 26 | "FullName" => :full_name, 27 | "FirstName" => :first_name, 28 | "LastName" => :last_name, 29 | "HeadshotUrl" => :picture_url, 30 | "BroadcastName" => :short_name, 31 | "RacingNumber" => :driver_number, 32 | "Tla" => :driver_abbr, 33 | "TeamColour" => :team_color, 34 | "TeamName" => :team_name 35 | } 36 | |> Enum.reduce(%{}, fn {source, target}, final -> 37 | if json[source] != nil do 38 | Map.put(final, target, json[source]) 39 | else 40 | final 41 | end 42 | end) 43 | 44 | struct!(__MODULE__, data) 45 | end 46 | 47 | def team_color_int(%__MODULE__{team_color: color_hex}) when is_binary(color_hex) do 48 | case Integer.parse(color_hex, 16) do 49 | {int, _} -> int 50 | _ -> 0 51 | end 52 | end 53 | 54 | def team_color_int(%__MODULE__{}), do: 0 55 | 56 | def has_personal_info?(info = %__MODULE__{}) do 57 | some_name = info.full_name || info.first_name || info.last_name 58 | some_name != nil and info.driver_number != nil and info.driver_abbr != nil 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/extrapolated_clock.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.ExtrapolatedClock do 2 | @moduledoc """ 3 | Handler for extrapolated clock which keeps track of remaining session time, 4 | useful for qualifying. 5 | """ 6 | require Logger 7 | alias F1Bot.F1Session.LiveTimingHandlers 8 | 9 | alias F1Bot.F1Session 10 | alias F1Bot.DataTransform.Parse 11 | alias LiveTimingHandlers.{Packet, ProcessingResult, ProcessingOptions} 12 | 13 | @behaviour LiveTimingHandlers 14 | @scope "ExtrapolatedClock" 15 | 16 | @impl F1Bot.F1Session.LiveTimingHandlers 17 | def process_packet( 18 | session, 19 | %Packet{ 20 | topic: @scope, 21 | data: data = %{"Remaining" => remaining, "Utc" => utc} 22 | }, 23 | options = %ProcessingOptions{} 24 | ) do 25 | with {:ok, remaining} <- Parse.parse_session_clock(remaining), 26 | {:ok, server_time} <- Timex.parse(utc, "{ISO:Extended}") do 27 | local_time = options.local_time_fn.() 28 | is_running = !!data["Extrapolating"] 29 | 30 | {session, events} = 31 | F1Session.update_clock(session, server_time, local_time, remaining, is_running) 32 | 33 | result = %ProcessingResult{ 34 | session: session, 35 | events: events 36 | } 37 | 38 | {:ok, result} 39 | else 40 | {:error, error} -> 41 | Logger.error("Failed to parse extrapolated clock: #{inspect(error)}") 42 | 43 | result = %ProcessingResult{ 44 | session: session, 45 | events: [] 46 | } 47 | 48 | {:ok, result} 49 | end 50 | end 51 | 52 | @impl F1Bot.F1Session.LiveTimingHandlers 53 | def process_packet( 54 | _session, 55 | %Packet{ 56 | topic: @scope, 57 | data: data 58 | }, 59 | _options 60 | ) do 61 | {:error, {:invalid_clock_data, data}} 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/map_utils.ex: -------------------------------------------------------------------------------- 1 | defmodule MapUtils do 2 | @doc """ 3 | Patches the old map or struct, ignoring nil values in the new map 4 | """ 5 | def patch_ignore_nil(old, new) when is_map(old) and is_map(new) do 6 | {original_struct, old_map, new_map} = prepare_for_merge(old, new) 7 | 8 | merged = 9 | Map.merge(old_map, new_map, fn _k, a, b -> 10 | if b == nil do 11 | a 12 | else 13 | b 14 | end 15 | end) 16 | 17 | if original_struct do 18 | struct!(original_struct, merged) 19 | else 20 | merged 21 | end 22 | end 23 | 24 | @doc """ 25 | Patches the old map, only changing fields that were nil 26 | """ 27 | def patch_missing(old, new) when is_map(old) and is_map(new) do 28 | {original_struct, old_map, new_map} = prepare_for_merge(old, new) 29 | 30 | merged = 31 | Map.merge(old_map, new_map, fn _k, a, b -> 32 | if a != nil do 33 | a 34 | else 35 | b 36 | end 37 | end) 38 | 39 | if original_struct do 40 | struct!(original_struct, merged) 41 | else 42 | merged 43 | end 44 | end 45 | 46 | defp prepare_for_merge(old, new) when is_map(old) and is_map(new) do 47 | old_map = if is_struct(old), do: Map.from_struct(old), else: old 48 | new_map = if is_struct(new), do: Map.from_struct(new), else: new 49 | 50 | case {old, new} do 51 | {%struct{}, %struct{}} -> 52 | {struct, old_map, new_map} 53 | 54 | {%struct{}, new} when not is_struct(new) -> 55 | {struct, old_map, new_map} 56 | 57 | {old, %_struct{}} when not is_struct(old) -> 58 | {nil, old_map, new_map} 59 | 60 | {old, new} when not (is_struct(old) or is_struct(new)) -> 61 | {nil, old_map, new_map} 62 | 63 | _ -> 64 | raise "Structs must be the same type. Attempted to patch #{inspect(old.__struct__)} with #{inspect(new.__struct__)}" 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /assets/js/Visualizations/DatasetUtils.ts: -------------------------------------------------------------------------------- 1 | const DataSetUtils = { 2 | /** 3 | * Merges two datasets together, removing points that are no longer in the new dataset and adding points that are new. 4 | * The reason this is necessary is because Chart.js would animate the entire dataset rather than the last added point 5 | * if we just replaced it with a new one. 6 | * 7 | * @param existingData Dataset to modify 8 | * @param newData New dataset to merge into existingData 9 | * @param keyFn Function that maps a point in the dataset to its unique identifier (e.g. X axis value) 10 | */ 11 | mergeDataset(existingData: T[], newData: T[], keyFn: (point: T) => number) { 12 | const existingKeys = new Set(existingData.map(keyFn)); 13 | const newKeys = new Set(newData.map(keyFn)); 14 | const keysToRemove = new Set([...existingKeys].filter(x => !newKeys.has(x))); 15 | const keysToAdd = new Set([...newKeys].filter(x => !existingKeys.has(x))); 16 | 17 | const keysToUpdate = new Set([...existingKeys].filter(x => newKeys.has(x))); 18 | 19 | for (let point of existingData) { 20 | const pointKey = keyFn(point); 21 | 22 | if (keysToRemove.has(pointKey)) { 23 | existingData.splice(existingData.indexOf(point), 1); 24 | } 25 | 26 | if (keysToUpdate.has(pointKey)) { 27 | const newPoint = newData.find(x => keyFn(x) === pointKey); 28 | Object.assign(point, newPoint); 29 | } 30 | } 31 | 32 | for (let point of newData) { 33 | if (!keysToAdd.has(keyFn(point))) continue; 34 | 35 | // Assumes existingData is sorted by keyFn in ascending order 36 | const insertIndex = existingData.findIndex(x => keyFn(x) > keyFn(point)); 37 | 38 | if(insertIndex === -1) { 39 | existingData.push(point); 40 | } else { 41 | // Data must be inserted in the correct location for lines to be drawn correctly 42 | existingData.splice(insertIndex, 0, point); 43 | } 44 | } 45 | } 46 | } 47 | 48 | export { DataSetUtils }; -------------------------------------------------------------------------------- /lib/f1_bot_web/channels/api_socket.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.ApiSocket do 2 | use Phoenix.Socket 3 | require Logger 4 | 5 | alias F1Bot.Authentication 6 | alias F1Bot.Authentication.ApiClient 7 | 8 | channel("transcriber_service", F1BotWeb.TranscriberServiceChannel) 9 | channel("radio_transcript:*", F1BotWeb.RadioTranscriptChannel) 10 | 11 | @impl true 12 | def connect(params, socket, _connect_info) do 13 | with {:ok, client} <- authorized?(socket, params) do 14 | socket = assign(socket, :authenticated_api_client, client) 15 | {:ok, socket} 16 | end 17 | end 18 | 19 | @impl true 20 | def id(socket) do 21 | %ApiClient{client_name: client_name} = socket.assigns.authenticated_api_client 22 | "api_socket:#{client_name}" 23 | end 24 | 25 | def authorized?(_socket, params) do 26 | with %{"token" => token} <- params, 27 | [client_name, client_secret] <- String.split(token, ":", parts: 2), 28 | {:ok, client} <- Authentication.find_api_client_by_name(client_name), 29 | true <- ApiClient.verify_secret(client, client_secret) do 30 | {:ok, client} 31 | else 32 | %{} -> 33 | Logger.warning("ApiSocket: Missing token") 34 | {:error, :missing_token} 35 | 36 | parts = [_ | _] -> 37 | Logger.warning("ApiSocket: Invalid token format (#{length(parts)} parts)") 38 | {:error, :invalid_token_format} 39 | 40 | {:error, :not_found} -> 41 | Logger.warning("ApiSocket: Invalid client name") 42 | {:error, :unauthorized} 43 | 44 | false -> 45 | Logger.warning("ApiSocket: Invalid client secret") 46 | {:error, :unauthorized} 47 | 48 | e -> 49 | Logger.warning("ApiSocket: Unknown error #{inspect(e)}") 50 | {:error, :unauthorized} 51 | end 52 | end 53 | 54 | def client_has_scope?(socket, scope) do 55 | case socket.assigns[:authenticated_api_client] do 56 | %F1Bot.Authentication.ApiClient{scopes: scopes} -> 57 | scope in scopes 58 | 59 | _ -> 60 | false 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_data_repo/transcript.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverDataRepo.Transcript do 2 | use Ecto.Schema 3 | import Ecto.Changeset 4 | alias F1Bot.F1Session.Common.Event 5 | 6 | @derive Jason.Encoder 7 | @primary_key {:id, Ecto.UUID, []} 8 | 9 | embedded_schema do 10 | field(:driver_number, :integer) 11 | field(:duration_sec, :float) 12 | field(:utc_date, :utc_datetime) 13 | field(:estimated_real_date, :utc_datetime) 14 | field(:playhead_utc_date, :utc_datetime) 15 | field(:meeting_session_key, :integer) 16 | field(:meeting_key, :integer) 17 | field(:message, :string) 18 | end 19 | 20 | def validate(params) do 21 | %__MODULE__{} 22 | |> cast(params, [ 23 | :id, 24 | :driver_number, 25 | :utc_date, 26 | :playhead_utc_date, 27 | :estimated_real_date, 28 | :message, 29 | :duration_sec, 30 | :meeting_session_key, 31 | :meeting_key 32 | ]) 33 | |> validate_required([ 34 | :id, 35 | :driver_number, 36 | :utc_date, 37 | :message, 38 | :duration_sec, 39 | :meeting_session_key, 40 | :meeting_key 41 | ]) 42 | |> apply_action(:validate) 43 | end 44 | 45 | def to_event(this = %__MODULE__{}) do 46 | date = 47 | if this.estimated_real_date != nil do 48 | this.estimated_real_date 49 | else 50 | recording_date = this.utc_date || this.playhead_utc_date || DateTime.utc_now() 51 | DateTime.add(recording_date, -20, :second) 52 | end 53 | 54 | ts = DateTime.to_unix(date, :millisecond) 55 | Event.new("driver:transcript", %{transcript: this}, ts) 56 | end 57 | 58 | def broadcast_to_channels(transcript = %__MODULE__{}) do 59 | broadcast_to_topics = [ 60 | "radio_transcript:#{transcript.driver_number}", 61 | "radio_transcript:all" 62 | ] 63 | 64 | for topic <- broadcast_to_topics do 65 | F1BotWeb.Endpoint.broadcast_from( 66 | self(), 67 | topic, 68 | "transcript", 69 | transcript 70 | ) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/signalr/negotiation.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.SignalR.Negotiation do 2 | @moduledoc """ 3 | HTTP client for SignalR connection negotiation. 4 | 5 | Useful documentation for SignalR 1.2: 6 | https://blog.3d-logic.com/2015/03/29/signalr-on-the-wire-an-informal-description-of-the-signalr-protocol/ 7 | """ 8 | @finch_instance F1Bot.Finch 9 | 10 | require Logger 11 | 12 | def negotiate(opts) do 13 | connection_data = 14 | opts 15 | |> Keyword.fetch!(:conn_data) 16 | |> Jason.encode!() 17 | 18 | query = 19 | %{ 20 | clientProtocol: "1.2", 21 | connectionData: connection_data 22 | } 23 | |> URI.encode_query() 24 | 25 | base_path = Keyword.fetch!(opts, :base_path) 26 | 27 | url = 28 | %URI{ 29 | scheme: Keyword.fetch!(opts, :scheme), 30 | host: Keyword.fetch!(opts, :hostname), 31 | port: Keyword.fetch!(opts, :port), 32 | path: "#{base_path}/negotiate", 33 | query: query 34 | } 35 | |> URI.to_string() 36 | 37 | Logger.info("Negotiating SignalR at '#{url}'") 38 | 39 | headers = [ 40 | {"user-agent", Keyword.fetch!(opts, :user_agent)} 41 | ] 42 | 43 | Finch.build(:get, url, headers) 44 | |> Finch.request(@finch_instance, receive_timeout: 2000) 45 | |> parse_response() 46 | end 47 | 48 | defp parse_response({:ok, %{status: 200, body: body, headers: headers}}) do 49 | cookies = 50 | headers 51 | |> Enum.filter(fn {name, _v} -> name == "set-cookie" end) 52 | |> Enum.map(fn {_name, val} -> val end) 53 | |> Enum.map(fn val -> String.split(val, ";") end) 54 | |> Enum.map(fn [val | _] -> val end) 55 | |> Enum.map(fn val -> String.split(val, "=") end) 56 | |> Enum.map(fn [name, value] -> {name, value} end) 57 | |> Enum.into(%{}) 58 | 59 | parsed = Jason.decode!(body) 60 | 61 | %{ 62 | "TryWebSockets" => true, 63 | "ProtocolVersion" => "1.2" 64 | } = parsed 65 | 66 | response = %{ 67 | data: parsed, 68 | cookies: cookies 69 | } 70 | 71 | {:ok, response} 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /assets/js/Visualizations/LapTimeScale.ts: -------------------------------------------------------------------------------- 1 | import {Scale, Tick} from "chart.js"; 2 | 3 | class LapTimeScale extends Scale { 4 | static id = 'laptime'; 5 | static step = 1000; 6 | 7 | constructor(cfg: any) { 8 | super(cfg) 9 | } 10 | 11 | determineDataLimits() { 12 | const {min, max} = this.getMinMax(false); 13 | this.min = min; 14 | this.max = max; 15 | } 16 | 17 | buildTicks(): Tick[] { 18 | const step = LapTimeScale.step; 19 | const roundedMin = this.roundToMs(this.min, step, Math.ceil); 20 | const roundedMax = this.roundToMs(this.max, step, Math.floor); 21 | 22 | const ticks: Tick[] = []; 23 | for (let x = roundedMin; x <= roundedMax; x += step) { 24 | ticks.push({ 25 | value: x, 26 | major: false 27 | }) 28 | } 29 | return ticks; 30 | } 31 | 32 | getLabelForValue(value: number): string { 33 | return this.formatMs(value, 3); 34 | } 35 | 36 | getPixelForValue(value: number, index?: number): number { 37 | const pos = this.getDecimalForValue(value) 38 | return this.getPixelForDecimal(pos) 39 | } 40 | 41 | generateTickLabels(ticks: Tick[]) { 42 | for (const tick of ticks) { 43 | tick.label = this.formatMs(tick.value, 0); 44 | } 45 | } 46 | 47 | private getDecimalForValue(value) { 48 | return value === null ? NaN : (value - this.min) / (this.max - this.min); 49 | } 50 | 51 | private roundToMs(value: number, ms: number, roundFn: (x: number) => number) { 52 | return roundFn(value / ms) * ms; 53 | } 54 | 55 | private formatMs(value: number | unknown, msPrecision: number) { 56 | if (typeof value !== 'number') return ''; 57 | 58 | const milliseconds = value % 1000; 59 | const totalSeconds = (value - milliseconds) / 1000; 60 | const seconds = totalSeconds % 60; 61 | const minutes = (totalSeconds - seconds) / 60; 62 | 63 | const fmtSec = seconds.toString().padStart(2, '0'); 64 | 65 | 66 | if (msPrecision <= 0) { 67 | return `${minutes}:${fmtSec}`; 68 | } else { 69 | const fmtMs = milliseconds.toString().padStart(msPrecision, '0').slice(0, msPrecision); 70 | return `${minutes}:${fmtSec}.${fmtMs}`; 71 | } 72 | } 73 | } 74 | 75 | export {LapTimeScale} -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/driver_data_repo/stint.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.DriverDataRepo.Stint do 2 | @moduledoc """ 3 | Holds information about a single stint. 4 | """ 5 | use TypedStruct 6 | 7 | typedstruct do 8 | @typedoc "Stint information" 9 | 10 | field(:number, pos_integer(), enforce: true) 11 | field(:compound, atom(), enforce: true) 12 | field(:age, non_neg_integer(), enforce: true) 13 | field(:total_laps, non_neg_integer(), enforce: true) 14 | field(:tyres_changed, boolean(), enforce: true) 15 | field(:lap_number, non_neg_integer(), enforce: true) 16 | field(:timestamp, DateTime, enforce: true) 17 | end 18 | 19 | def new(stint_data) do 20 | struct!(__MODULE__, stint_data) 21 | end 22 | 23 | def update(self = %__MODULE__{}, stint_data) do 24 | old = Map.from_struct(self) 25 | 26 | data = 27 | Map.merge( 28 | old, 29 | stint_data, 30 | fn k, v1, v2 -> merge_fn(old, stint_data, k, v1, v2) end 31 | ) 32 | 33 | new_self = struct!(__MODULE__, data) 34 | 35 | update_type = 36 | cond do 37 | self.compound == nil and new_self.compound != nil -> 38 | :changed_compound_from_nil 39 | 40 | self.compound != new_self.compound -> 41 | :changed_compound 42 | 43 | new_self == self -> 44 | :no_changes 45 | 46 | true -> 47 | :other_fields 48 | end 49 | 50 | {update_type, new_self} 51 | end 52 | 53 | def count_laps(self = %__MODULE__{}) do 54 | cond do 55 | self.total_laps == nil -> nil 56 | self.age == nil -> self.total_laps 57 | true -> self.total_laps - self.age 58 | end 59 | end 60 | 61 | defp merge_fn(_self_map, _stint_data, _key = :timestamp, v1, v2) do 62 | # TODO: Timestamp should be set only once when the stint starts except when the stint 63 | # has been erroneously started earlier in the session (occasional SignalR API bug) 64 | cond do 65 | v2 == nil -> v1 66 | v1 == nil -> v2 67 | true -> v1 68 | end 69 | end 70 | 71 | defp merge_fn(_self_map, _stint_data, _key, v1, v2) do 72 | if v2 == nil do 73 | v1 74 | else 75 | v2 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/f1_bot/analysis/gap_to_leader.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Analysis.GapToLeader do 2 | @moduledoc """ 3 | Calculates per-lap gap to the leader for creating visualizations 4 | """ 5 | import F1Bot.Analysis.Common 6 | 7 | alias F1Bot.F1Session 8 | alias F1Bot.F1Session.DriverDataRepo.Lap 9 | 10 | def calculate(session = %F1Session{}) do 11 | from_lap = 1 12 | to_lap = session.lap_counter.current 13 | 14 | with true <- to_lap != nil, 15 | {:ok, driver_data} <- all_driver_data(session) do 16 | data = 17 | for lap <- from_lap..to_lap do 18 | calculate_for_lap(driver_data, lap) 19 | end 20 | 21 | {:ok, data} 22 | end 23 | end 24 | 25 | defp calculate_for_lap(all_driver_data, lap_no) do 26 | driver_numbers = Map.keys(all_driver_data) 27 | 28 | # Find lap timestamps for all drivers 29 | all_driver_timestamps = 30 | for driver_no <- driver_numbers do 31 | ts = 32 | case timestamp_for_driver_lap(all_driver_data, driver_no, lap_no) do 33 | nil -> nil 34 | ts -> DateTime.to_unix(ts, :millisecond) 35 | end 36 | 37 | {driver_no, ts} 38 | end 39 | 40 | # Find the driver who completed the lap first 41 | first_ts = 42 | all_driver_timestamps 43 | |> Enum.filter(fn {_, ts} -> ts != nil end) 44 | |> Enum.sort_by(fn {_, ts} -> ts end, :asc) 45 | |> List.first() 46 | |> case do 47 | nil -> nil 48 | {_, ts} -> ts 49 | end 50 | 51 | # Calculate gaps for all drivers 52 | gap_per_driver = 53 | for {driver_no, ts} <- all_driver_timestamps, into: %{} do 54 | delta_seconds = 55 | if first_ts == nil or ts == nil do 56 | nil 57 | else 58 | (ts - first_ts) / 1000 59 | end 60 | 61 | {driver_no, delta_seconds} 62 | end 63 | 64 | %{ 65 | lap_number: lap_no, 66 | gap_per_driver: gap_per_driver 67 | } 68 | end 69 | 70 | defp timestamp_for_driver_lap(all_driver_data, driver_no, lap_no) do 71 | case fetch_driver_lap(all_driver_data, driver_no, lap_no) do 72 | {:ok, %Lap{timestamp: ts}} when ts != nil -> ts 73 | _ -> nil 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/common/event.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.Common.Event do 2 | @moduledoc "" 3 | use TypedStruct 4 | 5 | alias F1Bot.F1Session 6 | 7 | typedstruct do 8 | @typedoc "Emitted state machine event" 9 | 10 | field(:scope, binary(), enforce: true) 11 | field(:payload, any(), enforce: true) 12 | field(:timestamp, integer()) 13 | field(:sort_key, {integer(), integer()}) 14 | field(:meta, map()) 15 | end 16 | 17 | @spec new(binary(), any(), pos_integer() | nil) :: t() 18 | def new(scope, payload, timestamp \\ nil) do 19 | timestamp = 20 | cond do 21 | is_nil(timestamp) -> 22 | F1Bot.Time.unix_timestamp_now(:millisecond) 23 | 24 | timestamp < 1_000_000_000_000 -> 25 | raise ArgumentError, 26 | "timestamp must be in milliseconds (note: heuristic based on the value)" 27 | 28 | true -> 29 | timestamp 30 | end 31 | 32 | sort_key = {timestamp, :rand.uniform(1_000_000_000)} 33 | 34 | %__MODULE__{ 35 | scope: scope, 36 | payload: payload, 37 | timestamp: timestamp, 38 | sort_key: sort_key 39 | } 40 | end 41 | 42 | def attach_driver_info(events, session, driver_numbers) when is_list(events) do 43 | driver_info_map = 44 | for driver_no <- driver_numbers, into: %{} do 45 | driver_info = 46 | case F1Session.driver_info_by_number(session, driver_no) do 47 | {:ok, info} -> info 48 | {:error, _} -> nil 49 | end 50 | 51 | {driver_no, driver_info} 52 | end 53 | 54 | for e <- events do 55 | existing_meta = e.meta || %{} 56 | new_meta = Map.merge(existing_meta, %{driver_info: driver_info_map}) 57 | %{e | meta: new_meta} 58 | end 59 | end 60 | 61 | def attach_session_info(events, session = %F1Session{}) when is_list(events) do 62 | new_meta = %{ 63 | lap_number: session.lap_counter.current, 64 | session_type: session.session_info.type, 65 | session_status: session.session_status 66 | } 67 | 68 | for e <- events do 69 | existing_meta = e.meta || %{} 70 | meta = Map.merge(existing_meta, new_meta) 71 | 72 | Map.put(e, :meta, meta) 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/live_timing_handlers/position_data.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.LiveTimingHandlers.PositionData do 2 | @moduledoc """ 3 | Handler for car position received from live timing API. 4 | 5 | The handler decompresses and parses car position data and passes it on to the F1 session instance. 6 | """ 7 | require Logger 8 | alias F1Bot.F1Session.LiveTimingHandlers 9 | 10 | alias F1Bot.F1Session 11 | alias LiveTimingHandlers.{Packet, ProcessingResult} 12 | 13 | @behaviour LiveTimingHandlers 14 | @scope "Position" 15 | 16 | @impl F1Bot.F1Session.LiveTimingHandlers 17 | def process_packet( 18 | session, 19 | %Packet{ 20 | topic: @scope, 21 | data: encoded 22 | }, 23 | _options 24 | ) do 25 | case F1Bot.ExternalApi.SignalR.Encoding.decode_live_timing_data(encoded) do 26 | {:ok, %{"Position" => batches}} -> 27 | session = process_decoded_data(session, batches) 28 | 29 | result = %ProcessingResult{ 30 | session: session, 31 | events: [] 32 | } 33 | 34 | {:ok, result} 35 | 36 | {:error, error} -> 37 | {:error, "Error decoding telemetry data: #{error}"} 38 | end 39 | end 40 | 41 | defp process_decoded_data(session, batches) do 42 | batches 43 | |> Enum.reduce(session, fn batch, session -> 44 | %{"Entries" => cars, "Timestamp" => ts} = batch 45 | ts = F1Bot.DataTransform.Parse.parse_iso_timestamp(ts) 46 | 47 | reduce_positions_per_timestamp(session, cars, ts) 48 | end) 49 | end 50 | 51 | defp reduce_positions_per_timestamp(session, cars, timestamp) do 52 | cars 53 | |> Enum.reduce(session, fn {driver_number, car_pos}, session -> 54 | driver_number = String.trim(driver_number) |> String.to_integer() 55 | parsed = parse_position_data(car_pos, timestamp) 56 | 57 | F1Session.push_position(session, driver_number, parsed) 58 | end) 59 | end 60 | 61 | defp parse_position_data(car_pos, timestamp) do 62 | %{ 63 | x: Map.fetch!(car_pos, "X"), 64 | y: Map.fetch!(car_pos, "Y"), 65 | z: Map.fetch!(car_pos, "Z"), 66 | status: Map.fetch!(car_pos, "Status"), 67 | timestamp: timestamp 68 | } 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/f1_bot_web/endpoint.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Endpoint do 2 | use Phoenix.Endpoint, otp_app: :f1_bot 3 | 4 | # The session will be stored in the cookie and signed, 5 | # this means its contents can be read but not tampered with. 6 | # Set :encryption_salt if you would also like to encrypt it. 7 | @session_options [ 8 | store: :cookie, 9 | key: "_f1_bot_key", 10 | signing_salt: "ClYKEeTa" 11 | ] 12 | 13 | defp add_response_headers(conn, _opts) do 14 | conn 15 | |> put_resp_header("Referrer-Policy", "no-referrer") 16 | end 17 | 18 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 19 | socket "/api-socket", F1BotWeb.ApiSocket, 20 | websocket: true, 21 | longpoll: false 22 | 23 | plug F1BotWeb.HealthCheck, 24 | path: "/health-check" 25 | 26 | # Serve at "/" the static files from "priv/static" directory. 27 | plug Plug.Static, 28 | at: "/", 29 | from: :f1_bot, 30 | gzip: true, 31 | only: F1BotWeb.static_paths(), 32 | # Favicon with digest suffix is served from root and doesn't get 33 | # matched by the above rule, ":only" only matches exact paths and files 34 | # inside matching subdirectories, here we allow an arbitrary suffix 35 | # for files in root 36 | only_matching: ~w(favicon) 37 | 38 | # Code reloading can be explicitly enabled under the 39 | # :code_reloader configuration of your endpoint. 40 | if code_reloading? do 41 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket 42 | plug Phoenix.LiveReloader 43 | plug Phoenix.CodeReloader 44 | plug Phoenix.Ecto.CheckRepoStatus, otp_app: :f1_bot 45 | end 46 | 47 | plug Phoenix.LiveDashboard.RequestLogger, 48 | param_key: "request_logger", 49 | cookie_key: "request_logger" 50 | 51 | plug Plug.RequestId 52 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint] 53 | 54 | plug Plug.Parsers, 55 | parsers: [:urlencoded, :multipart, :json], 56 | pass: ["*/*"], 57 | json_decoder: Phoenix.json_library() 58 | 59 | plug Plug.MethodOverride 60 | plug Plug.Head 61 | plug Plug.Session, @session_options 62 | 63 | plug F1BotWeb.Plug.UserUUID 64 | 65 | plug :add_response_headers 66 | 67 | plug F1BotWeb.Router 68 | end 69 | -------------------------------------------------------------------------------- /lib/f1_bot/f1_session/session_info.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.F1Session.SessionInfo do 2 | @moduledoc """ 3 | Stores and handles changes to current session information. 4 | """ 5 | use TypedStruct 6 | 7 | alias F1Bot.F1Session.Common.Event 8 | 9 | @api_base_path "http://livetiming.formula1.com/static/" 10 | 11 | typedstruct do 12 | @typedoc "F1 Session Info" 13 | 14 | field(:gp_name, String.t()) 15 | field(:type, String.t()) 16 | field(:www_path, String.t()) 17 | field(:start_date, DateTime.t()) 18 | field(:end_date, DateTime.t()) 19 | end 20 | 21 | def new do 22 | %__MODULE__{} 23 | end 24 | 25 | def parse_from_json(json) do 26 | utc_offset = 27 | Map.fetch!(json, "GmtOffset") 28 | |> String.split(":") 29 | |> Enum.take(2) 30 | |> Enum.join(":") 31 | 32 | utc_offset = 33 | if String.starts_with?(utc_offset, "-") do 34 | utc_offset 35 | else 36 | "+" <> utc_offset 37 | end 38 | 39 | {:ok, start_date, _} = 40 | (Map.fetch!(json, "StartDate") <> utc_offset) 41 | |> DateTime.from_iso8601() 42 | 43 | {:ok, end_date, _} = 44 | (Map.fetch!(json, "EndDate") <> utc_offset) 45 | |> DateTime.from_iso8601() 46 | 47 | data = %{ 48 | start_date: start_date, 49 | end_date: end_date, 50 | type: Map.fetch!(json, "Name"), 51 | gp_name: get_in(json, ["Meeting", "Name"]), 52 | www_path: @api_base_path <> Map.fetch!(json, "Path") 53 | } 54 | 55 | struct!(__MODULE__, data) 56 | end 57 | 58 | def update( 59 | old = %__MODULE__{}, 60 | new = %__MODULE__{} 61 | ) do 62 | name_match = old.gp_name == new.gp_name 63 | session_match = old.type == new.type 64 | 65 | session_changed? = not (name_match and session_match) 66 | 67 | old = Map.from_struct(old) 68 | new = Map.from_struct(new) 69 | 70 | merged = MapUtils.patch_ignore_nil(old, new) 71 | session_info = struct!(__MODULE__, merged) 72 | 73 | events = [to_event(session_info)] 74 | 75 | {session_info, events, session_changed?} 76 | end 77 | 78 | def is_race?(session_info) do 79 | session_info.type == "Race" 80 | end 81 | 82 | def to_event(session_info = %__MODULE__{}) do 83 | Event.new("session_info:changed", session_info) 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/f1_bot/analysis/lap_times.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Analysis.LapTimes do 2 | @moduledoc """ 3 | Collects lap times for creating visualizations 4 | """ 5 | import F1Bot.Analysis.Common 6 | 7 | alias F1Bot.F1Session 8 | alias F1Bot.F1Session.DriverDataRepo.{Lap, Laps} 9 | 10 | @doc """ 11 | Compile a list of lap times for a given driver 12 | """ 13 | def calculate(session = %F1Session{}, driver_number) do 14 | from_lap = 1 15 | 16 | to_lap = 17 | case session.lap_counter.current do 18 | nil -> 9999 19 | current -> current 20 | end 21 | 22 | neutralized_periods = neutralized_periods(session) 23 | 24 | # TODO: Optimise this, it's hard to cache results such as 'is_neutralized?' 25 | # because the underlying data is not always consistent with the API occasionally 26 | # providing garbage stint data and then correcting it later 27 | 28 | with {:ok, %Laps{data: laps_data}} <- fetch_driver_laps(session, driver_number), 29 | {:ok, stints} <- fetch_driver_stints(session, driver_number) do 30 | laps = 31 | laps_data 32 | |> Map.values() 33 | |> Enum.sort_by(& &1.number, :asc) 34 | 35 | data = 36 | for lap = %Lap{} <- laps, 37 | lap.number != nil and lap.number <= to_lap and lap.number >= from_lap, 38 | lap.time != nil, 39 | not Lap.is_inlap?(lap, stints), 40 | not Lap.is_outlap?(lap, stints), 41 | not Lap.is_outlap_after_red_flag?(lap), 42 | not Lap.is_neutralized?(lap, neutralized_periods), 43 | not (!!lap.is_outlier) do 44 | lap_to_chart_point(lap) 45 | end 46 | 47 | {:ok, data} 48 | end 49 | end 50 | 51 | def lap_to_chart_point(lap = %Lap{}) do 52 | lap 53 | |> point() 54 | |> serialize_point_values() 55 | end 56 | 57 | defp point(lap) do 58 | %{ 59 | lap: lap.number, 60 | t: lap.time, 61 | ts: lap.timestamp 62 | } 63 | end 64 | 65 | defp serialize_point_values(point) do 66 | point 67 | |> update_in([:ts], fn 68 | nil -> nil 69 | ts -> DateTime.to_unix(ts, :millisecond) 70 | end) 71 | |> update_in([:t], fn 72 | nil -> nil 73 | time -> Timex.Duration.to_milliseconds(time, truncate: true) 74 | end) 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :f1_bot, 4 | ecto_repos: [F1Bot.Repo], 5 | signalr_topics: [ 6 | "TrackStatus", 7 | "TeamRadio", 8 | "RaceControlMessages", 9 | "SessionInfo", 10 | "SessionStatus", 11 | "TimingAppData", 12 | "TimingData", 13 | "DriverList", 14 | "WeatherData", 15 | # Car telemetry 16 | "CarData.z", 17 | # Car position (GPS) 18 | "Position.z", 19 | "Heartbeat", 20 | # Session time remaining and real time clock sync 21 | "ExtrapolatedClock", 22 | # Session-wise current lap counter and total # of laps 23 | "LapCount" 24 | ] 25 | 26 | # Configures the endpoint 27 | config :f1_bot, F1BotWeb.Endpoint, 28 | render_errors: [ 29 | formats: [html: F1BotWeb.ErrorHTML, json: F1BotWeb.ErrorJSON], 30 | layout: false 31 | ], 32 | live_view: [signing_salt: "nDtTgwk3"], 33 | pubsub_server: F1Bot.PubSub 34 | 35 | config :f1_bot, F1BotWeb.InternalEndpoint, 36 | render_errors: [ 37 | formats: [html: F1BotWeb.ErrorHTML, json: F1BotWeb.ErrorJSON], 38 | layout: false 39 | ], 40 | live_view: [signing_salt: "BlByFUSB8j91iHK0qEAfCcjXHGvQBxjs"], 41 | pubsub_server: F1Bot.PubSub 42 | 43 | # Configure esbuild (the version is required) 44 | config :esbuild, 45 | version: "0.14.29", 46 | default: [ 47 | args: 48 | ~w(assets/js/app.ts --bundle --sourcemap --target=es2017 --outdir=priv/static/assets --tsconfig=tsconfig.json --external:/fonts/* --external:/images/*) 49 | ] 50 | 51 | config :tailwind, 52 | version: "3.2.7", 53 | default: [ 54 | args: ~w( 55 | --config=tailwind.config.js 56 | --input=css/app.css 57 | --output=../priv/static/assets/app.css 58 | ), 59 | cd: Path.expand("../assets", __DIR__) 60 | ] 61 | 62 | # Configures Elixir's Logger 63 | config :logger, :console, 64 | format: "$time $metadata[$level] $message\n", 65 | metadata: [:request_id] 66 | 67 | # Use Jason for JSON parsing in Phoenix 68 | config :phoenix, :json_library, Jason 69 | config :phoenix, :filter_parameters, [ 70 | "password", 71 | "secret", 72 | "secret_key", 73 | "token", 74 | "secret_token", 75 | "client_secret", 76 | "nonce" 77 | ] 78 | 79 | config :gnuplot, 80 | timeout: {3000, :ms} 81 | 82 | # config :logger, :console, metadata: [:mfa] 83 | 84 | import_config "#{Mix.env()}.exs" 85 | -------------------------------------------------------------------------------- /lib/f1_bot/external_api/discord/commands/response.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.ExternalApi.Discord.Commands.Response do 2 | @moduledoc """ 3 | Functions for composing and sending responses to slash commands. 4 | """ 5 | import Bitwise 6 | require Logger 7 | alias Nostrum.Api 8 | 9 | @type flags :: :ephemeral 10 | 11 | # https://discord.com/developers/docs/resources/channel#message-object-message-flags 12 | @flags %{ 13 | ephemeral: 1 <<< 6 14 | } 15 | 16 | # https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-response-object-interaction-callback-type 17 | @interaction_callback_type %{ 18 | channel_message: 4, 19 | deferred_channel_message: 5 20 | } 21 | 22 | def send_interaction_response(response, interaction) do 23 | Api.create_interaction_response(interaction, response) 24 | end 25 | 26 | # Silence Dialyzer warnings due to bad Nostrum API types 27 | @dialyzer {:nowarn_function, send_followup_response: 2} 28 | def send_followup_response(response, interaction) do 29 | Api.create_followup_message(interaction.token, response) 30 | |> maybe_handle_followup_error() 31 | end 32 | 33 | def make_message(flags, message) when is_list(flags) do 34 | %{ 35 | type: @interaction_callback_type.channel_message, 36 | data: %{ 37 | content: message, 38 | flags: combine_flags(flags) 39 | } 40 | } 41 | end 42 | 43 | def make_deferred_message(flags) when is_list(flags) do 44 | %{ 45 | type: @interaction_callback_type.deferred_channel_message, 46 | data: %{ 47 | flags: combine_flags(flags) 48 | } 49 | } 50 | end 51 | 52 | def make_followup_message(flags, content, files \\ [], embeds \\ []) 53 | when is_list(flags) and is_list(embeds) do 54 | %{ 55 | content: content, 56 | embeds: embeds, 57 | files: files, 58 | flags: combine_flags(flags) 59 | } 60 | end 61 | 62 | def combine_flags(flags) do 63 | Enum.reduce(flags, 0, fn flag, combined -> 64 | flag_val = Map.fetch!(@flags, flag) 65 | combined ||| flag_val 66 | end) 67 | end 68 | 69 | defp maybe_handle_followup_error(res = {:ok, _}), do: res 70 | 71 | defp maybe_handle_followup_error(res = {:error, error}) do 72 | Logger.error("Error occurred while posting command followup message: #{inspect(error)}") 73 | res 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /assets/js/DarkModeObserver.ts: -------------------------------------------------------------------------------- 1 | class DarkModeObserver { 2 | private static el: Element; 3 | private static observer: MutationObserver; 4 | private static darkModeEnabled: boolean; 5 | 6 | private static DARK_EL_QUERY = 'html'; 7 | private static DARK_CLASS = 'dark'; 8 | private static EVENT_NAME = 'darkModeChanged'; 9 | 10 | static init() { 11 | if (this.observer != null) { 12 | throw new Error('DarkModeObserver already initialized'); 13 | } 14 | 15 | const el = document.querySelector(this.DARK_EL_QUERY); 16 | if (!el) { 17 | throw new Error(`DarkModeObserver could not find dark mode element with selector: ${this.DARK_EL_QUERY}`); 18 | } 19 | this.el = el; 20 | 21 | this.darkModeEnabled = this.checkDarkModeEnabled(); 22 | 23 | this.observer = new MutationObserver(this.handleMutation.bind(this)); 24 | this.observer.observe(el, { attributes: true, attributeFilter: ['class'] }); 25 | 26 | console.log('DarkModeObserver initialized'); 27 | } 28 | 29 | static subscribe(listener: EventListener) { 30 | if (!this.el) throw new Error('DarkModeObserver must be initialized first'); 31 | 32 | this.el.addEventListener(this.EVENT_NAME, listener); 33 | } 34 | 35 | static unsubscribe(listener: EventListener) { 36 | if (!this.el) throw new Error('DarkModeObserver must be initialized first'); 37 | 38 | this.el.removeEventListener(this.EVENT_NAME, listener); 39 | } 40 | 41 | static isDarkModeEnabled() { 42 | if (!this.el) throw new Error('DarkModeObserver must be initialized first'); 43 | 44 | return this.darkModeEnabled; 45 | } 46 | 47 | private static checkDarkModeEnabled() { 48 | if (!this.el) throw new Error('DarkModeObserver must be initialized first'); 49 | 50 | return this.el.classList.contains(this.DARK_CLASS); 51 | } 52 | 53 | private static handleMutation(mutations) { 54 | const wasEnabled = this.darkModeEnabled; 55 | this.darkModeEnabled = this.checkDarkModeEnabled(); 56 | 57 | if (wasEnabled !== this.darkModeEnabled) { 58 | console.log(`DarkModeObserver: dark mode ${this.darkModeEnabled ? 'enabled' : 'disabled'}`); 59 | 60 | const event = new CustomEvent(this.EVENT_NAME, { 61 | detail: { 62 | darkModeEnabled: this.darkModeEnabled 63 | } 64 | }); 65 | 66 | this.el.dispatchEvent(event) 67 | } 68 | } 69 | } 70 | 71 | export { DarkModeObserver } -------------------------------------------------------------------------------- /lib/f1_bot/replay/options.ex: -------------------------------------------------------------------------------- 1 | defmodule F1Bot.Replay.Options do 2 | @moduledoc """ 3 | All options are optional unless otherwise noted. 4 | 5 | * `:report_progress` - If true, logs processing progress to the console. 6 | 7 | * `:exclude_files_regex` - A regex that excludes matching `.jsonStream` 8 | files from the download and replay, e.g. to exclude bulky `*.z.jsonStream` files 9 | when they are not needed. 10 | 11 | * `:replay_while_fn` - a 3-arity function that receives the current replay state, 12 | current packet and its timestamp in milliseconds. 13 | This function is called *before* the packet is processed. If the function 14 | returns false, the packet is left unprocessed (kept), replay is paused and `start_replay/2` 15 | will return the current replay state. 16 | Replay can by resumed by calling `replay_dataset/2` with the returned state and new 17 | options (e.g. different `replay_while_fn/3`). 18 | 19 | * `:packets_fn` - a 3-arity function that receives the current replay state, 20 | current packet and its timestamp in milliseconds. 21 | This function is called for every packet and can be used to implement custom packet 22 | processing logic (e.g. to simply print all received packets to console). 23 | By default this function will process the packet using 24 | `LiveTimingHandlers.process_live_timing_packet/3` and store the resulting `F1Session` 25 | state in the replay state. 26 | 27 | * `:events_fn` - a 1-arity function that will receive a list of events produced by **default** 28 | `:packets_fn` implementation. This option has no effect when `:packets_fn` is overriden. 29 | By default `:events_fn` is unspecified, but `Mix.Tasks.Backtest` for example overrides 30 | it to broadcast events on the PubSub bus. See module docs for more details. 31 | 32 | * `:processing_options` - a map of options that will be passed to default `:packets_fn`, 33 | this option has no effect when `:packets_fn` is overriden. 34 | """ 35 | use TypedStruct 36 | alias F1Bot.F1Session.LiveTimingHandlers.ProcessingOptions 37 | 38 | typedstruct do 39 | field(:report_progress, boolean()) 40 | field(:exclude_files_regex, Regex.t()) 41 | field(:replay_while_fn, function()) 42 | field(:packets_fn, function()) 43 | field(:events_fn, function()) 44 | field(:processing_options, ProcessingOptions.t(), default: ProcessingOptions.new()) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/tyre_symbol.ex: -------------------------------------------------------------------------------- 1 | defmodule F1BotWeb.Component.TyreSymbol do 2 | @moduledoc """ 3 | A component that renders a tyre symbol. 4 | 5 | Thanks to https://github.com/f1multiviewer for the tyre SVG path. 6 | """ 7 | use F1BotWeb, :component 8 | 9 | prop class, :css_class 10 | prop tyre_age, :number, required: true 11 | prop compound, :any, required: true 12 | 13 | @impl true 14 | def render(assigns) do 15 | ~F""" 16 | 17 | 18 | 27 | {letter_for_compound(@compound)} 28 | 29 | 37 | 46 | {age_text(@tyre_age)} 47 | 48 | 49 | 50 | """ 51 | end 52 | 53 | defp color_for_compound(compound) do 54 | case compound do 55 | :soft -> "#DA291C" 56 | :medium -> "#FFD100" 57 | :hard -> "#c4c4c0" 58 | :intermediate -> "#43B02A" 59 | :wet -> "#0067AD" 60 | _ -> "##ffffff4d" 61 | end 62 | end 63 | 64 | defp letter_for_compound(compound) do 65 | case compound do 66 | :soft -> "S" 67 | :medium -> "M" 68 | :hard -> "H" 69 | :intermediate -> "I" 70 | :wet -> "W" 71 | _ -> "?" 72 | end 73 | end 74 | 75 | defp age_text(tyre_age) do 76 | if tyre_age in [0, nil] do 77 | "New" 78 | else 79 | "#{tyre_age} L" 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/f1_bot_web/components/layouts/root.html.heex: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <.live_title suffix=" ~ Race Bot"> 9 | <%= assigns[:page_title] || "F1 Live Telemetry" %> 10 | 11 | 12 | 13 | 15 |