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() {