feat(profiles): add solo Joy-Con layouts and digital stick mappings

Add per-profile native solo routing, stick/click swapping, SL/SR output mappings, and digital left-stick directions with analog priority. Extend both codecs to schema 11 while preserving legacy profiles, expose the controls and preview in Studio, and carry rail outputs through Pro emulation. Verified with 541 tests, firmware builds, editor checks, and saved-profile preservation during native 0.89 deployment.
This commit is contained in:
Joey Yakimowich-Payne 2026-09-15 17:33:31 -06:00
commit d15b0ec711
31 changed files with 2803 additions and 183 deletions

View file

@ -189,7 +189,7 @@ def test_native_hub_editor_discovers_root_and_preserves_saved_state(
(0x2066, {"c", "right_sl", "right_sr"}),
],
)
def test_switch2_input_choices_are_never_output_targets(
def test_switch2_source_choices_stay_physical_with_expanded_outputs(
monkeypatch: pytest.MonkeyPatch,
product_id: int,
extras: set[str],
@ -207,11 +207,13 @@ def test_switch2_input_choices_are_never_output_targets(
assert owner["controller"]["style"] == "switch"
assert set(owner["source_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
assert set(owner["modifier_controls"]) & set(config_manager.EXTRA_BUTTONS) == extras
assert set(schema["extra_buttons"]).isdisjoint(schema["output_controls"])
assert set(schema["extra_buttons"]) & set(schema["output_controls"]) == {
"left_sl", "left_sr", "right_sl", "right_sr",
}
assert schema["output_controls"] == list(config_manager.OUTPUT_CONTROLS)
@pytest.mark.parametrize("version", [6, 7, 8])
@pytest.mark.parametrize("version", [6, 7, 8, 9])
def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
monkeypatch: pytest.MonkeyPatch,
version: int,
@ -227,8 +229,10 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
legacy_wire[344:] = bytes(40)
elif version == 7:
legacy_wire[364:] = bytes(20)
else:
elif version == 8:
legacy_wire[367:] = bytes(17)
else:
legacy_wire[376:] = bytes(8)
device.profiles[key] = bytes(legacy_wire)
device.profile_aliases[key[0]] = "Living room"
device.profile_names[key] = "Racing"
@ -241,6 +245,8 @@ def test_editor_migrates_old_profiles_and_saves_swing_without_metadata_loss(
== profile
)
assert migrated["profile"]["swing"] == profile.swing.to_json_object()
assert migrated["profile"]["native_joycon_layout"] == "paired"
assert migrated["profile"]["swap_sticks"] is False
draft = migrated["profile"]
draft["extra_button_map"] = dict(
zip(config_manager.EXTRA_BUTTONS, config_manager.LOGICAL_BUTTONS[:7])
@ -657,7 +663,6 @@ def test_editor_reads_writes_and_activates_profiles_atomically(
]
assert playtest["left_stick"] == {"x": -1234, "y": 2345}
assert playtest["triggers"] == {"left": 123, "right": 65000}
assert playtest["battery"] == 100
assert playtest["capabilities"] == [
"rumble",
"lightbar",
@ -1170,3 +1175,95 @@ def test_wii_orientation_endpoint_validation_and_failures(
)
assert status == 503
assert "error" in err
def test_native_layout_draft_roundtrip_is_isolated_from_other_profiles(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
original_profiles = dict(device.profiles)
original_active = dict(device.active_profiles)
original_configuration = device.configuration
key = (device.stable_identity.to_bytes(), 7)
with running_server(monkeypatch, device) as (base_url, token):
status, schema = request_json(f"{base_url}/api/schema")
assert status == 200
assert schema["native_joycon_layouts"] == ["paired", "left_solo", "right_solo"]
status, selected = request_json(f"{base_url}/api/profiles/1/8")
assert status == 200
draft = selected["profile"]
draft["native_joycon_layout"] = "right_solo"
draft["swap_sticks"] = True
draft["button_map"]["left_shoulder"] = "right_sl"
draft["button_map"]["right_shoulder"] = "right_sr"
draft["shift"].update(mode="hold", modifier="select")
draft["shift"]["button_map"]["south"] = "left_sl"
draft["triggers"]["left"]["output"] = "left_sr"
draft["sticks"]["left"]["inner_deadzone"] = 4000
status, validated = request_json(
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token
)
assert status == 200
assert validated["profile"] == draft
assert device.profiles == original_profiles
assert device.active_profiles == original_active
status, _ = request_json(
f"{base_url}/api/profiles/1/8", method="PUT",
value=validated["profile"], token=token,
)
assert status == 200
status, stored = request_json(f"{base_url}/api/profiles/1/8")
assert status == 200
assert stored["profile"] == draft
expected_profiles = dict(original_profiles)
expected_profiles[key] = config_manager.ControllerProfile.from_json_object(draft).to_bytes()
assert device.profiles == expected_profiles
assert device.active_profiles == original_active
assert device.configuration == original_configuration
def test_left_stick_direction_outputs_roundtrip_and_validation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device = FakeDevice()
original_profiles = dict(device.profiles)
original_active = dict(device.active_profiles)
original_configuration = device.configuration
key = (device.stable_identity.to_bytes(), 0)
with running_server(monkeypatch, device) as (base_url, token):
status, schema = request_json(f"{base_url}/api/schema")
assert status == 200
assert "left_stick_up" in schema["output_controls"]
assert "left_stick_down" in schema["output_controls"]
assert "left_stick_left" in schema["output_controls"]
assert "left_stick_right" in schema["output_controls"]
status, selected = request_json(f"{base_url}/api/profiles/1/1")
assert status == 200
draft = selected["profile"]
# Map D-pad to left stick movement
draft["button_map"]["dpad_up"] = "left_stick_up"
draft["button_map"]["dpad_down"] = "left_stick_down"
draft["button_map"]["dpad_left"] = "left_stick_left"
draft["button_map"]["dpad_right"] = "left_stick_right"
draft["extra_button_map"]["c"] = "left_stick_up"
draft["shift"]["button_map"]["south"] = "left_stick_down"
draft["triggers"]["left"]["output"] = "left_stick_left"
status, validated = request_json(
f"{base_url}/api/profiles/validate", method="POST", value=draft, token=token
)
assert status == 200
assert validated["profile"] == draft
assert device.profiles == original_profiles
status, _ = request_json(
f"{base_url}/api/profiles/1/1", method="PUT",
value=validated["profile"], token=token,
)
assert status == 200
status, stored = request_json(f"{base_url}/api/profiles/1/1")
assert status == 200
assert stored["profile"] == draft
expected_profiles = dict(original_profiles)
expected_profiles[key] = config_manager.ControllerProfile.from_json_object(draft).to_bytes()
assert device.profiles == expected_profiles
assert device.active_profiles == original_active
assert device.configuration == original_configuration