diff --git a/VERSION b/VERSION
index 88c5fb8..9df886c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.4.0
+1.4.2
diff --git a/external/robocol b/external/robocol
index 0fe3d2c..5cedf32 160000
--- a/external/robocol
+++ b/external/robocol
@@ -1 +1 @@
-Subproject commit 0fe3d2c911d59f14b3a568e995b9e18a6b0adeaa
+Subproject commit 5cedf3290390febb38f094fe4864e2f0f44d88a9
diff --git a/godot/assets/fields/biobuzz.webp b/godot/assets/fields/biobuzz.webp
new file mode 100644
index 0000000..29b8e35
Binary files /dev/null and b/godot/assets/fields/biobuzz.webp differ
diff --git a/godot/assets/fields/biobuzz.webp.import b/godot/assets/fields/biobuzz.webp.import
new file mode 100644
index 0000000..05a5da1
--- /dev/null
+++ b/godot/assets/fields/biobuzz.webp.import
@@ -0,0 +1,40 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://8fyldmx3i7ir"
+path="res://.godot/imported/biobuzz.webp-7e07c77e7e1540149c6b5c1dc566eb8c.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://assets/fields/biobuzz.webp"
+dest_files=["res://.godot/imported/biobuzz.webp-7e07c77e7e1540149c6b5c1dc566eb8c.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/uastc_level=0
+compress/rdo_quality_loss=0.0
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/channel_remap/red=0
+process/channel_remap/green=1
+process/channel_remap/blue=2
+process/channel_remap/alpha=3
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
diff --git a/godot/autoloads/robot_client.gd b/godot/autoloads/robot_client.gd
index 51fdbd0..67ba546 100644
--- a/godot/autoloads/robot_client.gd
+++ b/godot/autoloads/robot_client.gd
@@ -1,6 +1,8 @@
extends Node
signal connection_changed(connected: bool)
+signal link_changed(link: int)
+signal connection_notice(text: String)
signal opmode_list_changed(opmodes: Array)
signal phase_changed(phase: int, opmode_name: String)
signal selected_opmode_changed(opmode_name: String)
@@ -18,8 +20,13 @@ signal video_frame(source: String, texture: Texture2D)
signal video_stream_ended(source: String)
enum Phase { DISCONNECTED, IDLE, INIT, RUNNING }
+enum Link { DISCONNECTED, CONNECTING, CONNECTED }
const GAMEPAD_HZ := 50.0
+const BUSY_NOTICE := (
+ "The Robot Controller at %s is already connected to another Driver Station. "
+ + "Disconnect it there, then select DISCONNECTED to try again."
+)
const LOCATION_RESOURCE := "RESOURCE"
const LOCATION_LOCAL := "LOCAL_STORAGE"
@@ -32,6 +39,7 @@ const LOCATION_NONE := "NONE"
const DEFAULT_LIMELIGHT_STREAM := "http://192.168.43.1:5800/"
var phase: int = Phase.DISCONNECTED
+var link: int = Link.DISCONNECTED
var selected_opmode := ""
var opmodes: Array = []
var configurations: Array = []
@@ -58,6 +66,7 @@ func _ready() -> void:
_bridge = ClassDB.instantiate("RobocolBridge")
add_child(_bridge)
_bridge.connection_changed.connect(_on_bridge_connection_changed)
+ _bridge.peer_busy.connect(_on_bridge_peer_busy)
_bridge.opmode_list_changed.connect(_on_bridge_opmode_list)
_bridge.phase_changed.connect(_on_bridge_phase_changed)
_bridge.telemetry_received.connect(_on_bridge_telemetry)
@@ -87,6 +96,7 @@ func _ready() -> void:
-1 if bind_env.is_empty() else int(bind_env),
-1 if peer_port_env.is_empty() else int(peer_port_env)
)
+ _set_link(Link.CONNECTING)
_setup_video_streams()
@@ -113,6 +123,23 @@ func _setup_video_streams() -> void:
_bridge.add_video_stream("limelight", limelight)
+func set_link_enabled(enabled: bool) -> void:
+ if enabled:
+ connection_notice.emit("")
+ _set_link(Link.CONNECTING)
+ _bridge.connect_rc()
+ else:
+ _set_link(Link.DISCONNECTED)
+ _bridge.disconnect_rc()
+
+
+func _set_link(new_link: int) -> void:
+ if new_link == link:
+ return
+ link = new_link
+ link_changed.emit(new_link)
+
+
func select_opmode(opmode_name: String) -> void:
if phase == Phase.IDLE:
selected_opmode = opmode_name
@@ -249,9 +276,19 @@ func _on_slot_changed(new_slot: int) -> void:
func _on_bridge_connection_changed(connected: bool) -> void:
+ if connected:
+ connection_notice.emit("")
+ _set_link(Link.CONNECTED)
+ elif link == Link.CONNECTED:
+ _set_link(Link.CONNECTING)
connection_changed.emit(connected)
+func _on_bridge_peer_busy(peer: String) -> void:
+ _set_link(Link.DISCONNECTED)
+ connection_notice.emit(BUSY_NOTICE % peer)
+
+
func _on_bridge_opmode_list(list: Array) -> void:
opmodes = list
opmode_list_changed.emit(list)
diff --git a/godot/export_presets.cfg b/godot/export_presets.cfg
index a23ec9e..fbf3ed8 100644
--- a/godot/export_presets.cfg
+++ b/godot/export_presets.cfg
@@ -137,3 +137,10 @@ application/version="1.0"
; rather than leaving it to the exporter.
codesign/codesign=0
notarization/notarization=0
+application/additional_plist_content="LSEnvironment
+
+ SDL_JOYSTICK_MFI
+ 0
+ SDL_HIDAPI_LIBUSB_WHITELIST
+ 0
+"
diff --git a/godot/scenes/main.tscn b/godot/scenes/main.tscn
index a431dab..9e7126e 100644
--- a/godot/scenes/main.tscn
+++ b/godot/scenes/main.tscn
@@ -65,9 +65,10 @@ theme_type_variation = &"StatusBarCard"
layout_mode = 2
theme_override_constants/separation = 16
-[node name="ConnectionLabel" type="Label" parent="Layout/StatusBar/StatusRow/StatusCard/StatusGroup"]
+[node name="ConnectionButton" type="Button" parent="Layout/StatusBar/StatusRow/StatusCard/StatusGroup"]
unique_name_in_owner = true
layout_mode = 2
+flat = true
text = "DISCONNECTED"
[node name="PhaseLabel" type="Label" parent="Layout/StatusBar/StatusRow/StatusCard/StatusGroup"]
@@ -191,6 +192,29 @@ layout_mode = 2
size_flags_vertical = 0
text = "Dismiss"
+[node name="ConnectionBanner" type="PanelContainer" parent="Layout"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 2
+theme_override_styles/panel = SubResource("StyleBoxFlat_error")
+
+[node name="ConnectionRow" type="HBoxContainer" parent="Layout/ConnectionBanner"]
+layout_mode = 2
+theme_override_constants/separation = 12
+
+[node name="ConnectionText" type="Label" parent="Layout/ConnectionBanner/ConnectionRow"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_horizontal = 3
+theme_override_colors/font_color = Color(1, 0.85, 0.85, 1)
+autowrap_mode = 3
+
+[node name="ConnectionDismiss" type="Button" parent="Layout/ConnectionBanner/ConnectionRow"]
+unique_name_in_owner = true
+layout_mode = 2
+size_flags_vertical = 0
+text = "Dismiss"
+
[node name="Pages" type="Control" parent="Layout"]
layout_mode = 2
size_flags_vertical = 3
diff --git a/godot/scripts/pages/config/config_screen.gd b/godot/scripts/pages/config/config_screen.gd
index 52692a0..ec2f7e1 100644
--- a/godot/scripts/pages/config/config_screen.gd
+++ b/godot/scripts/pages/config/config_screen.gd
@@ -8,7 +8,7 @@ var _configs: Array = []
var _stale_key := ""
var _activated_key := ""
var _group := ButtonGroup.new()
-var _selected_name := ""
+var _selected_key := ""
var _pending_metas: Array[Dictionary] = []
var _ambiguity_prompted := false
var _ambiguity_candidates: Array[Dictionary] = []
@@ -67,7 +67,7 @@ func _refresh_list() -> void:
row.toggle_mode = true
row.button_group = _group
row.alignment = HORIZONTAL_ALIGNMENT_LEFT
- row.button_pressed = meta.get("name") == _selected_name
+ row.button_pressed = _config_key(meta) == _selected_key
row.pressed.connect(_on_config_selected.bind(meta))
%ConfigList.add_child(row)
var status := _row_status(meta)
@@ -141,7 +141,7 @@ func _row_status(meta: Dictionary) -> StringName:
func _on_config_selected(meta: Dictionary) -> void:
- _selected_name = meta.get("name", "")
+ _selected_key = _config_key(meta)
_pending_metas.append(meta.duplicate())
RobotClient.request_particular_configuration(meta)
@@ -161,7 +161,7 @@ func grip_tap(grip: StringName) -> void:
func _selected_index() -> int:
for i in _configs.size():
- if _configs[i].get("name") == _selected_name:
+ if _config_key(_configs[i]) == _selected_key:
return i
return -1
@@ -174,7 +174,7 @@ func _on_configuration_received(xml: String) -> void:
func _on_new() -> void:
- _selected_name = ""
+ _selected_key = ""
_editor.load_new(
{
"isDirty": true,
@@ -190,21 +190,21 @@ func _on_new() -> void:
func _on_editor_saved(meta: Dictionary, active_out_of_date: bool) -> void:
- _selected_name = meta.get("name", "")
+ _selected_key = _config_key(meta)
if active_out_of_date:
_stale_key = _config_key(meta)
if _activated_key == _stale_key:
_activated_key = ""
_save_state()
_refresh_list()
- _poll_until_listed(_selected_name)
+ _poll_until_listed(_selected_key)
-func _poll_until_listed(name: String) -> void:
+func _poll_until_listed(key: String) -> void:
for _i in LIST_POLL_TRIES:
RobotClient.request_configurations()
await get_tree().create_timer(LIST_POLL_INTERVAL).timeout
- if _configs.any(func(c: Dictionary) -> bool: return c.get("name") == name):
+ if _listed(key):
return
@@ -220,18 +220,18 @@ func _on_editor_deleted(meta: Dictionary) -> void:
_activated_key = ""
_ambiguous_keys.erase(key)
_save_state()
- if _selected_name == meta.get("name", ""):
- _selected_name = ""
+ if _selected_key == key:
+ _selected_key = ""
_pending_metas.clear()
_refresh_list()
- _poll_until_unlisted(meta.get("name", ""))
+ _poll_until_unlisted(key)
-func _poll_until_unlisted(name: String) -> void:
+func _poll_until_unlisted(key: String) -> void:
for _i in LIST_POLL_TRIES:
RobotClient.request_configurations()
await get_tree().create_timer(LIST_POLL_INTERVAL).timeout
- if not _configs.any(func(c: Dictionary) -> bool: return c.get("name") == name):
+ if not _listed(key):
return
@@ -326,6 +326,10 @@ func _on_ambiguity_dismissed() -> void:
_refresh_list()
+func _listed(key: String) -> bool:
+ return not _listed_config(key).is_empty()
+
+
func _listed_config(key: String) -> Dictionary:
for meta: Dictionary in _configs:
if _config_key(meta) == key:
diff --git a/godot/scripts/pages/drive/drive_page.gd b/godot/scripts/pages/drive/drive_page.gd
index 4bf0961..47e3c0e 100644
--- a/godot/scripts/pages/drive/drive_page.gd
+++ b/godot/scripts/pages/drive/drive_page.gd
@@ -104,7 +104,6 @@ func _apply_source(slot: int, source: int) -> void:
var header: String = SOURCE_NAMES[source]
if CAM_FEEDS.has(source):
- cam.flip_v = source == Source.WEBCAM
cam.texture = _cam_textures.get(CAM_FEEDS[source])
if cam.texture == null:
header += " — no feed"
diff --git a/godot/scripts/pages/field/field_images.gd b/godot/scripts/pages/field/field_images.gd
index e0ee7ec..5108dfb 100644
--- a/godot/scripts/pages/field/field_images.gd
+++ b/godot/scripts/pages/field/field_images.gd
@@ -1,6 +1,7 @@
class_name FieldImages
const FIELDS := [
+ {"name": "2026-27 BIOBUZZ", "path": "res://assets/fields/biobuzz.webp"},
{"name": "2025-26 DECODE", "path": "res://assets/fields/decode.webp"},
{"name": "2024-25 INTO THE DEEP", "path": "res://assets/fields/into_the_deep.webp"},
{"name": "Field (no game elements)", "path": "res://assets/fields/base.webp"},
diff --git a/godot/scripts/shell/status_bar.gd b/godot/scripts/shell/status_bar.gd
index 5e011a0..c851cde 100644
--- a/godot/scripts/shell/status_bar.gd
+++ b/godot/scripts/shell/status_bar.gd
@@ -10,15 +10,32 @@ const PHASE_NAMES := {
const MATCH_SECONDS := 120.0
const ENDGAME_SECONDS := 30.0
const SLOT_STATUS := {1: &"slot1", 2: &"slot2"}
+const LINK_LABELS := {
+ RobotClient.Link.DISCONNECTED: ["DISCONNECTED", &"disconnected"],
+ RobotClient.Link.CONNECTING: ["CONNECTING", &"warn"],
+ RobotClient.Link.CONNECTED: ["CONNECTED", &"connected"],
+}
+const LINK_FONT_COLORS := [
+ &"font_color",
+ &"font_hover_color",
+ &"font_focus_color",
+ &"font_pressed_color",
+ &"font_hover_pressed_color",
+]
var _clock_idle := false
-var _connected := false
+var _disconnect_dialog: ConfirmationDialog
func _ready() -> void:
%StopButton.pressed.connect(_on_action_pressed)
_size_action_button()
- RobotClient.connection_changed.connect(_on_connection_changed)
+ _build_disconnect_dialog()
+ %ConnectionButton.pressed.connect(_on_connection_pressed)
+ RobotClient.link_changed.connect(_on_link_changed)
+ RobotClient.connection_notice.connect(_on_connection_notice)
+ %ConnectionDismiss.pressed.connect(_on_connection_notice.bind(""))
+ _on_link_changed(RobotClient.link)
RobotClient.phase_changed.connect(_on_phase_changed)
RobotClient.selected_opmode_changed.connect(_on_selected_opmode_changed)
RobotClient.battery_voltage_changed.connect(_on_battery_voltage)
@@ -45,15 +62,41 @@ func _untint(label: Label) -> void:
func refresh_tints() -> void:
- _on_connection_changed(_connected)
+ _on_link_changed(RobotClient.link)
_update_slot_badge()
_clock_idle = false
-func _on_connection_changed(connected: bool) -> void:
- _connected = connected
- %ConnectionLabel.text = "CONNECTED" if connected else "DISCONNECTED"
- _tint(%ConnectionLabel, &"connected" if connected else &"disconnected")
+func _build_disconnect_dialog() -> void:
+ _disconnect_dialog = ConfirmationDialog.new()
+ _disconnect_dialog.title = "Disconnect"
+ _disconnect_dialog.dialog_text = "Disconnect from the Robot Controller?"
+ _disconnect_dialog.ok_button_text = "Disconnect"
+ _disconnect_dialog.confirmed.connect(RobotClient.set_link_enabled.bind(false))
+ add_child(_disconnect_dialog)
+ DialogCancel.install(_disconnect_dialog)
+
+
+func _on_connection_pressed() -> void:
+ if RobotClient.link == RobotClient.Link.CONNECTED:
+ _disconnect_dialog.popup_centered()
+ else:
+ RobotClient.set_link_enabled(true)
+
+
+func _on_link_changed(link: int) -> void:
+ var button: Button = %ConnectionButton
+ button.text = LINK_LABELS[link][0]
+ var color := get_theme_color(LINK_LABELS[link][1], ThemeTokens.STATUS_TYPE)
+ for font_color: StringName in LINK_FONT_COLORS:
+ button.add_theme_color_override(font_color, color)
+ if link != RobotClient.Link.CONNECTED:
+ _disconnect_dialog.hide()
+
+
+func _on_connection_notice(text: String) -> void:
+ %ConnectionText.text = text
+ %ConnectionBanner.visible = not text.is_empty()
func _on_phase_changed(phase: int, opmode_name: String) -> void:
diff --git a/rust/robocol_godot/src/lib.rs b/rust/robocol_godot/src/lib.rs
index 8491d90..7d1c20c 100644
--- a/rust/robocol_godot/src/lib.rs
+++ b/rust/robocol_godot/src/lib.rs
@@ -138,6 +138,8 @@ impl RobocolBridge {
#[signal]
fn connection_changed(connected: bool);
#[signal]
+ fn peer_busy(peer: GString);
+ #[signal]
fn opmode_list_changed(opmodes: VarArray);
#[signal]
fn phase_changed(phase: i64, opmode_name: GString);
@@ -214,6 +216,16 @@ impl RobocolBridge {
}
}
+ #[func]
+ fn connect_rc(&self) {
+ self.with_client(RobocolClient::connect);
+ }
+
+ #[func]
+ fn disconnect_rc(&self) {
+ self.with_client(RobocolClient::disconnect);
+ }
+
#[func]
fn stop_client(&mut self) {
self.client = None;
@@ -490,6 +502,11 @@ impl RobocolBridge {
self.emit_stream_ended("webcam");
self.telemetry_by_tag.clear();
}
+ Event::PeerBusy { peer } => {
+ self.signals()
+ .peer_busy()
+ .emit(&GString::from(&peer.ip().to_string()));
+ }
Event::OpModeList(list) => {
let mut opmodes = VarArray::new();
for meta in list {
diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs
index 986fefa..fdc62c2 100644
--- a/rust/xtask/src/main.rs
+++ b/rust/xtask/src/main.rs
@@ -249,9 +249,25 @@ fn clean() -> Result {
fn godot(extra: &[&str]) -> Command {
let mut cmd = Command::new("godot4");
cmd.arg("--path").arg(repo_root().join("godot")).args(extra);
+ apply_input_hints(&mut cmd);
cmd
}
+#[cfg(target_os = "macos")]
+fn apply_input_hints(cmd: &mut Command) {
+ for (key, value) in [
+ ("SDL_JOYSTICK_MFI", "0"),
+ ("SDL_HIDAPI_LIBUSB_WHITELIST", "0"),
+ ] {
+ if std::env::var_os(key).is_none() {
+ cmd.env(key, value);
+ }
+ }
+}
+
+#[cfg(not(target_os = "macos"))]
+fn apply_input_hints(_cmd: &mut Command) {}
+
fn run(mut cmd: Command, label: &str) -> Result {
let status = cmd.status().map_err(|e| format!("{label}: {e}"))?;
if !status.success() {