├── 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 | <%= Phoenix.Flash.get(@flash, :info) %>
3 | <%= Phoenix.Flash.get(@flash, :error) %>
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 |
3 | <%= Phoenix.Flash.get(@flash, :info) %>
4 |
5 |
6 |
7 | <%= Phoenix.Flash.get(@flash, :error) %>
8 |
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 |
12 |
13 | Tip: Click on legend entries to toggle visibility, hover over them to glance at the data, shift-click to hide all other entries.
14 | Hover over the data points to see exact values.
15 |
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 |
26 |
27 | <#slot />
28 |
29 |
30 |
31 |
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 |
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 |
24 | {driver_info.last_name || driver_info.short_name}
25 |
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 |
16 |
17 |
18 |
19 | <%= @inner_content %>
20 |
21 |
22 |
23 |
54 |
55 |
56 | This is a fan project. All product and company names are trademarks™ or registered® trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them.
57 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/lib/f1_bot_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule F1BotWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.stop.duration",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.router_dispatch.stop.duration",
29 | tags: [:route],
30 | unit: {:native, :millisecond}
31 | ),
32 |
33 | # Database Metrics
34 | summary("f1bot.repo.query.total_time",
35 | unit: {:native, :millisecond},
36 | description: "The sum of the other measurements"
37 | ),
38 | summary("f1bot.repo.query.decode_time",
39 | unit: {:native, :millisecond},
40 | description: "The time spent decoding the data received from the database"
41 | ),
42 | summary("f1bot.repo.query.query_time",
43 | unit: {:native, :millisecond},
44 | description: "The time spent executing the query"
45 | ),
46 | summary("f1bot.repo.query.queue_time",
47 | unit: {:native, :millisecond},
48 | description: "The time spent waiting for a database connection"
49 | ),
50 | summary("f1bot.repo.query.idle_time",
51 | unit: {:native, :millisecond},
52 | description:
53 | "The time the connection spent waiting before being checked out for the query"
54 | ),
55 |
56 | # VM Metrics
57 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
58 | summary("vm.total_run_queue_lengths.total"),
59 | summary("vm.total_run_queue_lengths.cpu"),
60 | summary("vm.total_run_queue_lengths.io")
61 | ]
62 | end
63 |
64 | defp periodic_measurements do
65 | [
66 | # A module, function and arguments to be invoked periodically.
67 | # This function must call :telemetry.execute/3 and a metric must be added above.
68 | # {F1BotWeb, :count_users, []}
69 | ]
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/config/dev.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 | default_delay_ms: 1_000,
8 | auto_reload_session: false
9 |
10 | config :logger,
11 | level: :info
12 |
13 | # Do not include metadata nor timestamps in development logs
14 | config :logger, :console, format: "[$level] $message\n"
15 |
16 | # Configure your database
17 | config :f1_bot, F1Bot.Repo,
18 | database: Path.expand("../f1bot_dev.db", Path.dirname(__ENV__.file)),
19 | pool_size: 5,
20 | stacktrace: true,
21 | show_sensitive_data_on_connection_error: true
22 |
23 | # For development, we disable any cache and enable
24 | # debugging and code reloading.
25 | #
26 | # The watchers configuration can be used to run external
27 | # watchers to your application. For example, we use it
28 | # with esbuild to bundle .js and .css sources.
29 | config :f1_bot, F1BotWeb.Endpoint,
30 | # Binding to loopback ipv4 address prevents access from other machines.
31 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
32 | url: [host: "localhost"],
33 | http: [ip: {0, 0, 0, 0}, port: 4000],
34 | check_origin: false,
35 | code_reloader: true,
36 | debug_errors: true,
37 | secret_key_base: "U/lrJxiO8Pof0MWxTnEHM+CvYJ4559nfslojhRY3ui9hEcZX5N3bUrpGPRtKLX8b",
38 | watchers: [
39 | # Start the esbuild watcher by calling Esbuild.install_and_run(:default, args)
40 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
41 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
42 | ],
43 | # Watch static and templates for browser reloading.
44 | reloadable_compilers: [:gettext, :elixir, :surface],
45 | live_reload: [
46 | patterns: [
47 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
48 | ~r"lib/f1_bot_web/(controllers|live|components)/.*(ex|heex|sface)$"
49 | ]
50 | ]
51 |
52 | config :f1_bot, F1BotWeb.InternalEndpoint,
53 | url: [host: "localhost"],
54 | http: [ip: {127, 0, 0, 1}, port: 4001],
55 | check_origin: false,
56 | debug_errors: true,
57 | secret_key_base: "kIaE5yMJsNdbC2xW+TtE/ImirvCTyyzkOoftsQPWimDQHfZnwXkx/4sWIww9hWt0",
58 | live_view: [signing_salt: "AOtJEjcdsssJpmsFIkNl6ksdtAQuwavZ"]
59 |
60 | # Set a higher stacktrace during development. Avoid configuring such
61 | # in production as building large stacktraces may be expensive.
62 | config :phoenix, :stacktrace_depth, 20
63 |
64 | # Initialize plugs at runtime for faster development compilation
65 | config :phoenix, :plug_init_mode, :runtime
66 |
--------------------------------------------------------------------------------
/lib/mix/tasks/backtest.ex:
--------------------------------------------------------------------------------
1 | # credo:disable-for-this-file Credo.Check.Warning.IoInspect
2 | defmodule Mix.Tasks.Backtest do
3 | @moduledoc """
4 | Downloads archives of previous races and runs an offline backtest which prints all
5 | Discord messages to console.
6 |
7 | Usage:
8 | ```
9 | iex -S mix backtest --url "http://livetiming.formula1.com/static/2022/2022-05-08_Miami_Grand_Prix/2022-05-08_Race"
10 | ```
11 | """
12 |
13 | use Mix.Task
14 | require Config
15 | require Logger
16 |
17 | alias F1Bot.Replay
18 | alias F1Bot.F1Session.LiveTimingHandlers.ProcessingOptions
19 |
20 | @impl Mix.Task
21 | def run(argv) do
22 | configure()
23 |
24 | parsed_args = parse_argv(argv)
25 | url = Keyword.fetch!(parsed_args, :url)
26 |
27 | Logger.info("Downloading & parsing dataset.")
28 |
29 | replay_options = %Replay.Options{
30 | exclude_files_regex: ~r/\.z\./,
31 | # Broadcast events on the PubSub bus, this allows us to quickly review
32 | # the sanity of F1 packet processing logic by inspecting the console output
33 | # for simulated Discord messages.
34 | events_fn: &F1Bot.PubSub.broadcast_events/1,
35 | report_progress: true,
36 | processing_options: %ProcessingOptions{
37 | skip_heavy_events: false
38 | }
39 | }
40 |
41 | # profile_start()
42 | {:ok, %{session: session}} = Replay.start_replay(url, replay_options)
43 | # profile_end()
44 |
45 | F1Bot.F1Session.Server.replace_session(session)
46 |
47 | total_mem_mb = (:erlang.memory(:total) / 1024 / 1024) |> round()
48 | Logger.info("Total memory usage: #{total_mem_mb} MB")
49 | end
50 |
51 | def profile_start() do
52 | :eprof.start_profiling([self()])
53 | end
54 |
55 | def profile_end() do
56 | :eprof.stop_profiling()
57 | :eprof.analyze()
58 | end
59 |
60 | def parse_argv(argv) do
61 | {parsed_args, _, _} =
62 | OptionParser.parse(argv,
63 | strict: [
64 | url: :string
65 | ]
66 | )
67 |
68 | parsed_args
69 | end
70 |
71 | def configure() do
72 | Application.put_env(:gnuplot, :timeout, {3000, :ms})
73 | Application.put_env(:f1_bot, :connect_to_signalr, false)
74 | Application.put_env(:f1_bot, :start_discord, false)
75 | Application.put_env(:f1_bot, :external_apis_enabled, false)
76 | Application.put_env(:f1_bot, :discord_api_module, F1Bot.ExternalApi.Discord.Console)
77 | Logger.configure(level: :info)
78 |
79 | Finch.start_link(name: __MODULE__)
80 | {:ok, _} = Application.ensure_all_started(:f1_bot)
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/f1_bot/delayed_events.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.DelayedEvents do
2 | require Logger
3 | alias F1Bot.DelayedEvents.Rebroadcaster
4 |
5 | @min_delay 1_000
6 | @max_delay 45_000
7 | @delay_step 1_000
8 | @available_delays @min_delay..@max_delay//@delay_step
9 |
10 | defdelegate fetch_latest_event(delay_ms, event_scope),
11 | to: F1Bot.DelayedEvents.Rebroadcaster
12 |
13 | def default_delay(), do: F1Bot.get_env(:default_delay_ms, 20_000)
14 | def available_delays, do: @available_delays
15 | def min_delay_ms, do: @min_delay
16 | def max_delay_ms, do: @max_delay
17 | def delay_step, do: @delay_step
18 | def is_valid_delay?(delay_ms), do: delay_ms in @available_delays
19 |
20 | def subscribe_with_delay(scopes, delay_ms, send_init_events) do
21 | if delay_ms in @available_delays do
22 | topics =
23 | Enum.map(scopes, fn scope ->
24 | delayed_topic_for_event(scope, delay_ms)
25 | end)
26 |
27 | F1Bot.PubSub.subscribe_all(topics)
28 |
29 | if send_init_events do
30 | send_init_events(scopes, delay_ms, self())
31 | end
32 |
33 | {:ok, topics}
34 | else
35 | {:error, :invalid_delay}
36 | end
37 | end
38 |
39 | @doc """
40 | Send the latest event for each topic pair for topics that follow the
41 | init + delta pattern, e.g. charts where the init event contains the bulky
42 | chart specification and later events only contain new data points to add.
43 | """
44 | def oneshot_init(scopes, delay_ms) do
45 | if delay_ms in @available_delays do
46 | send_init_events(scopes, delay_ms, self())
47 | :ok
48 | else
49 | {:error, :invalid_delay}
50 | end
51 | end
52 |
53 | def delayed_topic_for_event(scope, delay_ms) do
54 | base_topic = F1Bot.PubSub.topic_for_event(scope)
55 | "delayed:#{delay_ms}::#{base_topic}"
56 | end
57 |
58 | def clear_all_caches() do
59 | Logger.warning("Clearing all delayed events caches")
60 |
61 | for delay_ms <- @available_delays do
62 | Rebroadcaster.clear_cache(delay_ms)
63 | end
64 | end
65 |
66 | def push_to_all(_events = []), do: {:ok, :empty}
67 |
68 | def push_to_all(events) do
69 | for delay_ms <- @available_delays,
70 | via = Rebroadcaster.server_via(delay_ms) do
71 | send(via, {:events, events})
72 | end
73 | end
74 |
75 | defp send_init_events(scopes, delay_ms, pid) do
76 | for scope <- scopes do
77 | case fetch_latest_event(delay_ms, scope) do
78 | {:ok, event} -> send(pid, event)
79 | {:error, :no_data} -> :skip
80 | end
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/test/integration/saudi_2022_quali_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Integration.Saudi2022QualiTest 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-03-27_Saudi_Arabian_Grand_Prix/2022-03-26_Qualifying"
16 | |> Replay.start_replay(replay_options)
17 |
18 | Map.put(context, :session, session)
19 | end
20 |
21 | test "correctly processes stints for #11 Sergio Perez", context do
22 | stints = stints_for_driver(context.session, 11)
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, :soft, :soft, :soft, :soft, :soft]
28 | expected_lap_numbers = [2, 4, 9, 12, 15, 18]
29 |
30 | assert compounds == expected_compounds
31 | assert lap_numbers == expected_lap_numbers
32 | end
33 |
34 | test "correctly processes lap times for #11 Sergio Perez", context do
35 | lap_times = lap_times_for_driver(context.session, 11, 100)
36 |
37 | expected_lap_times =
38 | [
39 | "1:30.111",
40 | # "2:27.094",
41 | "1:29.705",
42 | "1:28.924",
43 | "1:32.296",
44 | "1:28.554",
45 | "1:28.200"
46 | ]
47 | |> parse_lap_times()
48 |
49 | assert lap_times == expected_lap_times
50 | end
51 |
52 | defp stints_for_driver(session, driver_number) do
53 | {:ok, driver_data} = F1Session.driver_session_data(session, driver_number)
54 | driver_data.stints.data |> order_stints()
55 | end
56 |
57 | defp lap_times_for_driver(session, driver_number, max_time_sec) do
58 | max_time = Timex.Duration.from_seconds(max_time_sec)
59 |
60 | {:ok, driver_data} = F1Session.driver_session_data(session, driver_number)
61 |
62 | driver_data.laps.data
63 | |> Map.values()
64 | |> Enum.filter(fn l -> l.time != nil end)
65 | |> Enum.filter(fn l -> Timex.Duration.diff(l.time, max_time, :milliseconds) < 0 end)
66 | |> Enum.sort_by(fn l -> l.number end, :asc)
67 | |> Enum.map(fn l -> l.time end)
68 | end
69 |
70 | defp parse_lap_times(lap_times) do
71 | lap_times
72 | |> Enum.map(fn l ->
73 | {:ok, lap_time} = F1Bot.DataTransform.Parse.parse_lap_time(l)
74 | lap_time
75 | end)
76 | end
77 |
78 | defp order_stints(stints) do
79 | Enum.sort_by(stints, fn s -> s.number end, :asc)
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/lib/f1_bot/f1_session/lap_counter.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.F1Session.LapCounter do
2 | @moduledoc """
3 | Stores current and maximum lap number for the session
4 | """
5 | use TypedStruct
6 |
7 | alias F1Bot.F1Session.Common.Event
8 |
9 | typedstruct do
10 | field(:current, integer() | nil)
11 | field(:total, integer() | nil)
12 | field(:lap_timestamps, map(), default: %{})
13 | end
14 |
15 | def new() do
16 | %__MODULE__{
17 | current: nil,
18 | total: nil
19 | }
20 | end
21 |
22 | def new(current, total) do
23 | %__MODULE__{
24 | current: current,
25 | total: total
26 | }
27 | end
28 |
29 | def update(lap_counter, current_lap, total_laps, timestamp) do
30 | lap_counter = update_timestamps(lap_counter, current_lap, timestamp)
31 |
32 | new_current_lap = current_lap || lap_counter.current
33 | new_total_laps = total_laps || lap_counter.total
34 |
35 | %{lap_counter | current: new_current_lap, total: new_total_laps}
36 | end
37 |
38 | def timestamp_to_lap_number(lap_counter, timestamp) do
39 | lap_counter.lap_timestamps
40 | |> Enum.sort_by(fn {lap, _timestamps} -> lap end, :asc)
41 | |> Enum.find_value(fn {lap, lap_ts} ->
42 | if F1Bot.Time.between?(timestamp, lap_ts.start, lap_ts.end) do
43 | lap
44 | else
45 | nil
46 | end
47 | end)
48 | |> case do
49 | nil -> {:error, :not_found}
50 | lap -> {:ok, lap}
51 | end
52 | end
53 |
54 | def to_event(lap_counter = %__MODULE__{}) do
55 | Event.new("lap_counter:changed", lap_counter)
56 | end
57 |
58 | defp update_timestamps(lap_counter, _current_lap = nil, _timestamp), do: lap_counter
59 |
60 | defp update_timestamps(lap_counter, current_lap, timestamp) when is_integer(current_lap) do
61 | lap_timestamps =
62 | lap_counter.lap_timestamps
63 | |> Map.update(
64 | current_lap,
65 | %{start: timestamp, end: nil},
66 | &MapUtils.patch_missing(&1, %{start: timestamp})
67 | )
68 |
69 | lap_counter = %{lap_counter | lap_timestamps: lap_timestamps}
70 |
71 | lap_counter
72 | |> maybe_update_prev_lap_timestamp(current_lap, timestamp)
73 | end
74 |
75 | defp maybe_update_prev_lap_timestamp(lap_counter, current_lap, timestamp) do
76 | if current_lap > 1 do
77 | prev_lap = current_lap - 1
78 |
79 | lap_timestamps =
80 | lap_counter.lap_timestamps
81 | |> Map.update(
82 | prev_lap,
83 | %{start: nil, end: timestamp},
84 | &MapUtils.patch_missing(&1, %{end: timestamp})
85 | )
86 |
87 | %{lap_counter | lap_timestamps: lap_timestamps}
88 | else
89 | lap_counter
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/lib/f1_bot/f1_session/live_timing_handlers/car_telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.F1Session.LiveTimingHandlers.CarTelemetry do
2 | @moduledoc """
3 | Handler for car telemetry received from live timing API.
4 |
5 | The handler decompresses and parses car telemetry channels 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 "CarData"
15 |
16 | # {'0': 'RPM', '2': 'Speed', '3': 'nGear', '4': 'Throttle', '5': 'Brake', '45': 'DRS'}
17 | @channels %{
18 | "0" => :rpm,
19 | "2" => :speed,
20 | "3" => :gear,
21 | "4" => :throttle,
22 | "5" => :brake,
23 | "45" => :drs
24 | }
25 |
26 | @drs_values %{
27 | 0 => :off,
28 | 8 => :available,
29 | 10 => :on,
30 | 12 => :on,
31 | 14 => :on
32 | }
33 |
34 | @impl F1Bot.F1Session.LiveTimingHandlers
35 | def process_packet(
36 | session,
37 | %Packet{
38 | topic: @scope,
39 | data: encoded
40 | },
41 | _options
42 | ) do
43 | case F1Bot.ExternalApi.SignalR.Encoding.decode_live_timing_data(encoded) do
44 | {:ok, %{"Entries" => batches}} ->
45 | session = process_decoded_data(session, batches)
46 |
47 | result = %ProcessingResult{
48 | session: session,
49 | events: []
50 | }
51 |
52 | {:ok, result}
53 |
54 | {:error, error} ->
55 | {:error, "Error decoding telemetry data: #{error}"}
56 | end
57 | end
58 |
59 | defp process_decoded_data(session, batches) do
60 | batches
61 | |> Enum.reduce(session, fn batch, session ->
62 | %{"Cars" => cars, "Utc" => ts} = batch
63 | ts = F1Bot.DataTransform.Parse.parse_iso_timestamp(ts)
64 |
65 | reduce_telemetry_per_timestamp(session, cars, ts)
66 | end)
67 | end
68 |
69 | defp reduce_telemetry_per_timestamp(session, cars, timestamp) do
70 | cars
71 | |> Enum.reduce(session, fn {driver_number, %{"Channels" => channels}}, session ->
72 | driver_number = String.trim(driver_number) |> String.to_integer()
73 | channels = parse_telemetry_channels(channels, timestamp)
74 |
75 | F1Session.push_telemetry(session, driver_number, channels)
76 | end)
77 | end
78 |
79 | defp parse_telemetry_channels(channels, timestamp) do
80 | parsed =
81 | @channels
82 | |> Enum.map(fn {source, target} -> {target, channels[source]} end)
83 | |> Enum.into(%{})
84 |
85 | drs = Map.get(@drs_values, parsed.drs, :off)
86 |
87 | %{parsed | drs: drs}
88 | |> Map.put(:timestamp, timestamp)
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/lib/f1_bot/external_api/discord/commands/option_validator.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.ExternalApi.Discord.Commands.OptionValidator do
2 | @moduledoc """
3 | Parses and validates Discord command options
4 | """
5 | alias F1Bot
6 |
7 | def validate_graph_metric(options, name) do
8 | metric_option = Enum.find(options, fn opt -> opt.name == name end)
9 |
10 | case metric_option do
11 | %{value: x} when x in ["gap", "lap_time"] -> {:ok, String.to_atom(x)}
12 | nil -> {:error, "Metric option not provided"}
13 | _ -> {:error, "Invalid metric option"}
14 | end
15 | end
16 |
17 | def validate_graph_style(options, name) do
18 | metric_option = Enum.find(options, fn opt -> opt.name == name end)
19 |
20 | case metric_option do
21 | %{value: x} when x in ["points", "lines"] -> {:ok, String.to_atom(x)}
22 | nil -> {:ok, :line}
23 | _ -> {:error, "Invalid style option"}
24 | end
25 | end
26 |
27 | def validate_driver_list(options, name) do
28 | drivers_option = Enum.find(options, fn opt -> opt.name == name end)
29 |
30 | if drivers_option != nil do
31 | drivers =
32 | drivers_option.value
33 | |> String.split([",", " "])
34 | |> Enum.map(&String.replace(&1, ~r/[., ]/, ""))
35 | |> Enum.filter(fn x -> String.length(x) > 0 end)
36 |
37 | drivers =
38 | for str <- drivers do
39 | validate_driver_value(str)
40 | end
41 |
42 | errors =
43 | for {status, err} <- drivers,
44 | status == :error do
45 | err
46 | end
47 |
48 | drivers =
49 | for {status, driver} <- drivers,
50 | status == :ok do
51 | driver
52 | end
53 |
54 | if length(errors) > 0 do
55 | {:error, errors |> Enum.join(", ")}
56 | else
57 | {:ok, drivers}
58 | end
59 | else
60 | {:error, "Drivers option not provided"}
61 | end
62 | end
63 |
64 | def validate_driver(options, name) do
65 | driver_option = Enum.find(options, fn opt -> opt.name == name end)
66 |
67 | if driver_option != nil do
68 | driver_option.value
69 | |> String.trim()
70 | |> validate_driver_value()
71 | else
72 | {:error, "Driver option not provided"}
73 | end
74 | end
75 |
76 | defp validate_driver_value(str) do
77 | lookup_result =
78 | case Integer.parse(str) do
79 | :error ->
80 | F1Bot.driver_info_by_abbr(str)
81 |
82 | {int, _} ->
83 | F1Bot.driver_info_by_number(int)
84 | end
85 |
86 | case lookup_result do
87 | {:error, _} ->
88 | {:error, "Unknown driver #{str}"}
89 |
90 | {:ok, %{driver_number: num}} ->
91 | {:ok, num}
92 | end
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/lib/f1_bot/f1_session/driver_data_repo/lap.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.F1Session.DriverDataRepo.Lap do
2 | @moduledoc """
3 | Holds information about a single lap.
4 | """
5 | use TypedStruct
6 |
7 | alias F1Bot.F1Session.DriverDataRepo.{Stints, Sector}
8 | alias F1Bot.F1Session.TrackStatusHistory
9 |
10 | @type sector_map :: %{
11 | 1 => Sector.t() | nil,
12 | 2 => Sector.t() | nil,
13 | 3 => Sector.t() | nil
14 | }
15 |
16 | typedstruct do
17 | @typedoc "Lap information"
18 |
19 | field(:number, pos_integer())
20 | field(:time, Timex.Duration.t())
21 | field(:timestamp, DateTime.t())
22 |
23 | field(:sectors, sector_map(),
24 | default: %{1 => nil, 2 => nil, 3 => nil}
25 | )
26 |
27 | field(:is_outlier, boolean())
28 | field(:is_valid, boolean(), default: true)
29 | # TODO: Not implemened yet
30 | field(:is_deleted, boolean(), default: false)
31 | field(:top_speed, pos_integer(), default: 0)
32 | end
33 |
34 | def new(args) when is_list(args) do
35 | struct!(__MODULE__, args)
36 | end
37 |
38 | @spec is_neutralized?(t(), [TrackStatusHistory.interval()]) :: boolean()
39 | def is_neutralized?(lap = %__MODULE__{}, neutralized_intervals) do
40 | if lap.time == nil or lap.timestamp == nil do
41 | false
42 | else
43 | lap_start = Timex.subtract(lap.timestamp, lap.time)
44 | lap_end = lap.timestamp
45 |
46 | Enum.any?(neutralized_intervals, fn %{starts_at: starts_at, ends_at: ends_at} ->
47 | # Add a margin for drivers to return to racing speed
48 | ends_at =
49 | if ends_at == nil do
50 | nil
51 | else
52 | Timex.add(ends_at, Timex.Duration.from_seconds(5))
53 | end
54 |
55 | started_during_neutral = F1Bot.Time.between?(lap_start, starts_at, ends_at)
56 | ended_during_neutral = F1Bot.Time.between?(lap_end, starts_at, ends_at)
57 | short_neutral_during_lap = F1Bot.Time.between?(starts_at, lap_start, lap_end)
58 |
59 | started_during_neutral or ended_during_neutral or short_neutral_during_lap
60 | end)
61 | end
62 | end
63 |
64 | @spec is_outlap_after_red_flag?(t()) :: boolean()
65 | def is_outlap_after_red_flag?(lap = %__MODULE__{}) do
66 | lap.sectors == nil and lap.time != nil and Timex.Duration.to_seconds(lap.time) > 180
67 | end
68 |
69 | @spec is_inlap?(t(), Stints.t()) :: boolean()
70 | def is_inlap?(lap = %__MODULE__{}, stints = %Stints{}) do
71 | inlaps =
72 | stints.data
73 | |> Enum.map(&(&1.lap_number - 1))
74 |
75 | lap.number in inlaps
76 | end
77 |
78 | @spec is_outlap?(t(), Stints.t()) :: boolean()
79 | def is_outlap?(lap = %__MODULE__{}, stints = %Stints{}) do
80 | outlaps =
81 | stints.data
82 | |> Enum.map(& &1.lap_number)
83 |
84 | lap.number in outlaps
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/lib/f1_bot_web/components/delay_control.ex:
--------------------------------------------------------------------------------
1 | defmodule F1BotWeb.Component.DelayControl do
2 | use F1BotWeb, :live_component
3 | alias F1Bot.DelayedEvents
4 |
5 | prop pubsub_delay_ms, :integer, required: true
6 | data delay_step_ms, :integer, default: DelayedEvents.delay_step()
7 | data min_delay_ms, :integer, default: DelayedEvents.min_delay_ms()
8 | data max_delay_ms, :integer, default: DelayedEvents.max_delay_ms()
9 | data can_decrease, :boolean, default: false
10 | data can_increase, :boolean, default: false
11 |
12 | def update(new_assigns, socket) do
13 | socket = assign(socket, new_assigns)
14 |
15 | assigns = socket.assigns
16 |
17 | socket =
18 | socket
19 | |> assign(
20 | :can_increase,
21 | assigns.pubsub_delay_ms + assigns.delay_step_ms <= assigns.max_delay_ms
22 | )
23 | |> assign(
24 | :can_decrease,
25 | assigns.pubsub_delay_ms - assigns.delay_step_ms >= assigns.min_delay_ms
26 | )
27 |
28 | {:ok, socket}
29 | end
30 |
31 | @impl true
32 | def render(assigns) do
33 | ~F"""
34 |
35 |
Delay
36 |
37 |
38 | −
47 |
48 | {(@pubsub_delay_ms / 1000) |> trunc()}s
49 |
50 | +
59 |
60 |
61 | """
62 | end
63 |
64 | @impl true
65 | def handle_event("delay-inc", _params, socket) do
66 | delay_ms = socket.assigns.pubsub_delay_ms + socket.assigns.delay_step_ms
67 |
68 | send(self(), {:delay_control_set, delay_ms})
69 | socket = Component.Utility.save_params(socket, %{delay_ms: delay_ms})
70 |
71 | {:noreply, socket}
72 | end
73 |
74 | @impl true
75 | def handle_event("delay-dec", _params, socket) do
76 | delay_ms = socket.assigns.pubsub_delay_ms - socket.assigns.delay_step_ms
77 |
78 | send(self(), {:delay_control_set, delay_ms})
79 | socket = Component.Utility.save_params(socket, %{delay_ms: delay_ms})
80 |
81 | {:noreply, socket}
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/lib/f1_bot/application.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.Application do
2 | @moduledoc false
3 |
4 | use Application
5 | require Logger
6 |
7 | @impl true
8 | def start(_type, _args) do
9 | if F1Bot.demo_mode?() do
10 | Logger.info("[DEMO] Starting in demo mode with url: #{F1Bot.demo_mode_url()}")
11 | end
12 |
13 | start_if_feature_flag_enabled(:start_discord, :nostrum)
14 |
15 | children =
16 | [
17 | {Finch, name: F1Bot.Finch},
18 | {DynamicSupervisor, name: F1Bot.DynamicSupervisor, strategy: :one_for_one},
19 | F1BotWeb.Telemetry,
20 | F1Bot.Repo,
21 | {Phoenix.PubSub, name: F1Bot.PubSub},
22 | F1Bot.DelayedEvents.Supervisor,
23 | F1Bot.Output.Discord,
24 | F1Bot.F1Session.Server,
25 | F1BotWeb.Supervisor,
26 | F1Bot.Replay.Server,
27 | F1Bot.TranscriberService
28 | ]
29 | |> add_if_feature_flag_enabled(:connect_to_signalr, {
30 | F1Bot.ExternalApi.SignalR.Client,
31 | [
32 | scheme: "https",
33 | hostname: "livetiming.formula1.com",
34 | base_path: "/signalr",
35 | user_agent: "",
36 | port: 443,
37 | hub: "Streaming",
38 | topics:
39 | case F1Bot.fetch_env(:signalr_topics) do
40 | {:ok, topics} -> topics
41 | end
42 | ]
43 | })
44 | |> add_if_feature_flag_enabled(:start_discord, F1Bot.ExternalApi.Discord.Commands)
45 | |> add_if_feature_flag_enabled(:auto_reload_session, {
46 | Task,
47 | fn -> F1Bot.reload_session(true) end
48 | })
49 | |> add_if_demo_mode_enabled(F1Bot.Demo.Supervisor)
50 |
51 | # See https://hexdocs.pm/elixir/Supervisor.html
52 | # for other strategies and supported options
53 | opts = [
54 | strategy: :one_for_one,
55 | max_restarts: 1000,
56 | name: F1Bot.Supervisor
57 | ]
58 |
59 | Supervisor.start_link(children, opts)
60 | end
61 |
62 | # Tell Phoenix to update the endpoint configuration
63 | # whenever the application is updated.
64 | @impl true
65 | def config_change(changed, _new, removed) do
66 | F1BotWeb.Endpoint.config_change(changed, removed)
67 | :ok
68 | end
69 |
70 | defp start_if_feature_flag_enabled(feature_flag, application) do
71 | if feature_flag_enabled?(feature_flag) do
72 | {:ok, _} = Application.ensure_all_started(application)
73 | end
74 | end
75 |
76 | defp add_if_feature_flag_enabled(children, feature_flag, child) do
77 | if feature_flag_enabled?(feature_flag) and not F1Bot.demo_mode?() do
78 | children ++ [child]
79 | else
80 | children
81 | end
82 | end
83 |
84 | defp add_if_demo_mode_enabled(children, child) do
85 | if F1Bot.demo_mode?() do
86 | children ++ [child]
87 | else
88 | children
89 | end
90 | end
91 |
92 | defp feature_flag_enabled?(feature_flag) do
93 | F1Bot.get_env(feature_flag, false)
94 | end
95 | end
96 |
--------------------------------------------------------------------------------
/lib/f1_bot/demo/fake_radio_generator.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.Demo.FakeRadioGenerator do
2 | use GenServer
3 |
4 | alias F1Bot.F1Session.Server
5 | alias F1Bot.F1Session.DriverDataRepo.Transcript
6 | alias F1Bot.DataTransform.Format
7 |
8 | def start_link(init_arg) do
9 | GenServer.start_link(__MODULE__, init_arg, name: __MODULE__)
10 | end
11 |
12 | @impl true
13 | def init(_init_arg) do
14 | F1Bot.PubSub.subscribe_to_event("aggregate_stats:fastest_sector")
15 | F1Bot.PubSub.subscribe_to_event("aggregate_stats:fastest_lap")
16 | {:ok, nil}
17 | end
18 |
19 | @impl true
20 | def handle_info(
21 | _e = %{
22 | scope: "aggregate_stats:fastest_sector",
23 | payload: %{
24 | driver_number: driver_number,
25 | type: type,
26 | sector: sector,
27 | sector_time: sector_time
28 | }
29 | },
30 | state
31 | ) do
32 | sector_time = Format.format_lap_time(sector_time, true)
33 |
34 | prefix =
35 | case type do
36 | :personal -> "New PB in sector #{sector}"
37 | :overall -> "Fastest sector #{sector} out of anyone"
38 | end
39 |
40 | msg = "#{prefix}, #{sector_time}"
41 |
42 | # TODO: Get session time
43 | utc_then = DateTime.utc_now()
44 |
45 | transcript = %Transcript{
46 | id: Ecto.UUID.generate(),
47 | driver_number: driver_number,
48 | duration_sec: 5,
49 | utc_date: utc_then,
50 | playhead_utc_date: utc_then,
51 | estimated_real_date: utc_then,
52 | message: msg,
53 | meeting_session_key: 0,
54 | meeting_key: 0
55 | }
56 |
57 | Server.process_transcript(transcript)
58 | Transcript.broadcast_to_channels(transcript)
59 |
60 | {:noreply, state}
61 | end
62 |
63 | @impl true
64 | def handle_info(
65 | _e = %{
66 | scope: "aggregate_stats:fastest_lap",
67 | payload: %{
68 | driver_number: driver_number,
69 | type: type,
70 | lap_time: lap_time
71 | }
72 | },
73 | state
74 | ) do
75 | lap_time = Format.format_lap_time(lap_time, true)
76 |
77 | prefix =
78 | case type do
79 | :personal -> "You just set a new PB"
80 | :overall -> "That's P1, you just set the fastest lap"
81 | end
82 |
83 | msg = "#{prefix}, #{lap_time}"
84 |
85 | # TODO: Get session time
86 | utc_then = DateTime.utc_now()
87 |
88 | transcript = %Transcript{
89 | id: Ecto.UUID.generate(),
90 | driver_number: driver_number,
91 | duration_sec: 5,
92 | utc_date: utc_then,
93 | playhead_utc_date: utc_then,
94 | estimated_real_date: utc_then,
95 | message: msg,
96 | meeting_session_key: 0,
97 | meeting_key: 0
98 | }
99 |
100 | Server.process_transcript(transcript)
101 | Transcript.broadcast_to_channels(transcript)
102 |
103 | {:noreply, state}
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/lib/f1_bot/external_api/discord/commands/graph.ex:
--------------------------------------------------------------------------------
1 | defmodule F1Bot.ExternalApi.Discord.Commands.Graph do
2 | @moduledoc """
3 | Handles Discord command for creating graphs
4 | """
5 | require Logger
6 | alias Nostrum.Struct.Interaction
7 | alias F1Bot
8 | alias F1Bot.ExternalApi.Discord
9 | alias F1Bot.ExternalApi.Discord.Commands.{Response, OptionValidator}
10 | alias F1Bot.Plotting
11 |
12 | @behaviour Discord.Commands
13 |
14 | @impl Discord.Commands
15 | def handle_interaction(interaction = %Interaction{}, internal_args) do
16 | flags = Map.get(internal_args, :flags, [])
17 |
18 | case parse_interaction_options(interaction) do
19 | {:ok, parsed_opts} ->
20 | flags
21 | |> Response.make_deferred_message()
22 | |> Response.send_interaction_response(interaction)
23 |
24 | do_create_chart(interaction, parsed_opts, internal_args)
25 |
26 | {:error, option_error} ->
27 | flags
28 | |> Response.make_message("Error: #{option_error}")
29 | |> Response.send_interaction_response(interaction)
30 | end
31 | end
32 |
33 | # Silence Dialyzer warnings for bad &Api.create_followup_message/2 types
34 | @dialyzer {:no_fail_call, do_create_chart: 3}
35 | @dialyzer {:no_return, do_create_chart: 3}
36 | defp do_create_chart(interaction, options, internal_args) do
37 | flags = Map.get(internal_args, :flags, [])
38 |
39 | {:ok, info} = F1Bot.session_info()
40 |
41 | chart_response =
42 | case options.metric do
43 | :gap ->
44 | Plotting.plot_gap(options.drivers, style: options.style)
45 |
46 | :lap_time ->
47 | x_axis = if info.type =~ ~r/^(quali|practice)/iu, do: :timestamp, else: :lap
48 | Plotting.plot_lap_times(options.drivers, style: options.style, x_axis: x_axis)
49 | end
50 |
51 | case chart_response do
52 | {:ok, file_path} ->
53 | flags
54 | |> Response.make_followup_message(nil, [file_path])
55 | |> Response.send_followup_response(interaction)
56 |
57 | Plotting.cleanup(file_path)
58 |
59 | {:error, :dataset_empty} ->
60 | flags
61 | |> Response.make_followup_message("Data is not available yet.")
62 | |> Response.send_followup_response(interaction)
63 |
64 | {:error, error} ->
65 | Logger.error("Error generating chart: #{inspect(error)}")
66 |
67 | flags
68 | |> Response.make_followup_message("Something went wrong.")
69 | |> Response.send_followup_response(interaction)
70 | end
71 | end
72 |
73 | defp parse_interaction_options(interaction = %Interaction{}) do
74 | %Interaction{
75 | data: %{
76 | options: options
77 | }
78 | } = interaction
79 |
80 | with {:ok, metric} <- OptionValidator.validate_graph_metric(options, "metric"),
81 | {:ok, drivers} <- OptionValidator.validate_driver_list(options, "drivers"),
82 | {:ok, style} <- OptionValidator.validate_graph_style(options, "style") do
83 | opts = %{
84 | metric: metric,
85 | drivers: drivers,
86 | style: style
87 | }
88 |
89 | {:ok, opts}
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/lib/f1_bot_web.ex:
--------------------------------------------------------------------------------
1 | defmodule F1BotWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use F1BotWeb, :controller
9 | use F1BotWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.png robots.txt)
21 |
22 | def controller do
23 | quote do
24 | use Phoenix.Controller,
25 | formats: [:html, :json],
26 | layouts: [html: F1BotWeb.Layouts]
27 |
28 | import Plug.Conn
29 |
30 | unquote(verified_routes())
31 | end
32 | end
33 |
34 | def html do
35 | quote do
36 | use Phoenix.Component
37 |
38 | # Import convenience functions from controllers
39 | import Phoenix.Controller,
40 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
41 |
42 | # Include general helpers for rendering HTML
43 | unquote(html_helpers())
44 | end
45 | end
46 |
47 | def live_view do
48 | quote do
49 | use Surface.LiveView,
50 | layout: {F1BotWeb.Layouts, :app}
51 |
52 | import F1BotWeb.LiveHelpers
53 |
54 | unquote(html_helpers())
55 | end
56 | end
57 |
58 | def live_component do
59 | quote do
60 | use Surface.LiveComponent
61 |
62 | import F1BotWeb.LiveHelpers
63 |
64 | unquote(html_helpers())
65 | end
66 | end
67 |
68 | def component do
69 | quote do
70 | use Surface.Component
71 |
72 | unquote(html_helpers())
73 | end
74 | end
75 |
76 | def router do
77 | quote do
78 | use Phoenix.Router
79 |
80 | import Plug.Conn
81 | import Phoenix.Controller
82 | import Phoenix.LiveView.Router
83 | end
84 | end
85 |
86 | def channel do
87 | quote do
88 | use Phoenix.Channel
89 | end
90 | end
91 |
92 | defp html_helpers do
93 | quote do
94 | # HTML escaping functionality
95 | import Phoenix.HTML
96 | # Core UI components and translation
97 | import F1BotWeb.Component.CoreComponents
98 | # import SampleAppWeb.Gettext
99 |
100 | # Shortcut for generating JS commands
101 | alias Phoenix.LiveView.JS
102 |
103 | alias F1BotWeb.Component
104 |
105 | # Routes generation with the ~p sigil
106 | unquote(verified_routes())
107 | end
108 | end
109 |
110 | def verified_routes do
111 | quote do
112 | use Phoenix.VerifiedRoutes,
113 | endpoint: F1BotWeb.Endpoint,
114 | router: F1BotWeb.Router,
115 | statics: F1BotWeb.static_paths()
116 | end
117 | end
118 |
119 | @doc """
120 | When used, dispatch to the appropriate controller/view/etc.
121 | """
122 | defmacro __using__(which) when is_atom(which) do
123 | apply(__MODULE__, which, [])
124 | end
125 | end
126 |
--------------------------------------------------------------------------------