diff --git a/.claude/Screenshot 2026-07-14 at 13.22.14.png b/.claude/Screenshot 2026-07-14 at 13.22.14.png new file mode 100644 index 0000000..491ae36 Binary files /dev/null and b/.claude/Screenshot 2026-07-14 at 13.22.14.png differ diff --git a/.claude/roborock_robot_calls_report.md b/.claude/roborock_robot_calls_report.md new file mode 100644 index 0000000..a7e3fb0 --- /dev/null +++ b/.claude/roborock_robot_calls_report.md @@ -0,0 +1,182 @@ +# Robot command inventory — python-roborock-main + +Repository scanned: `roborock/` source tree (tests excluded for usage counts). + +## 1. V1 raw command catalog (253 commands) + +These are string RPC commands accepted by `device.v1_properties.command.send(command, params)`. Availability and parameter formats depend on robot model and firmware. + +### App / robot actions (50) + +`app_amethyst_self_check` · `app_charge` · `app_delete_wifi` · `app_get_amethyst_status` · `app_get_carpet_deep_clean_status` · `app_get_clean_estimate_info` + +`app_get_dryer_setting` · `app_get_init_status` · `app_get_locale` · `app_get_wifi_list` · `app_goto_target` · `app_keep_easter_egg` + +`app_pause` · `app_rc_end` · `app_rc_move` · `app_rc_start` · `app_rc_stop` · `app_resume_build_map` + +`app_resume_patrol` · `app_segment_clean` · `app_set_amethyst_status` · `app_set_carpet_deep_clean_status` · `app_set_cross_carpet_cleaning_status` · `app_set_door_sill_blocks` + +`app_set_dirty_replenish_clean_status` · `app_set_dryer_setting` · `app_set_dryer_status` · `app_set_dynamic_config` · `app_set_ignore_stuck_point` · `app_set_smart_cliff_forbidden` + +`app_set_smart_door_sill` · `app_spot` · `app_start` · `app_start_build_map` · `app_start_collect_dust` · `app_start_easter_egg` + +`app_start_patrol` · `app_start_pet_patrol` · `app_start_wash` · `app_stat` · `app_stop` · `app_stop_collect_dust` + +`app_stop_wash` · `app_update_unsave_map` · `app_wakeup_robot` · `app_zoned_clean` · `app_empty_rinse_tank_water` · `app_ignore_dirty_objects` + +`app_set_robot_setting` · `app_get_robot_setting` + +### Read / query (79) + +`get_auto_delivery_cleaning_fluid` · `get_camera_status` · `get_carpet_clean_mode` · `get_carpet_mode` · `get_child_lock_status` · `get_clean_follow_ground_material_status` + +`get_clean_motor_mode` · `get_clean_record` · `get_clean_record_map` · `get_clean_sequence` · `get_clean_summary` · `get_collision_avoid_status` + +`get_consumable` · `get_current_sound` · `get_custom_mode` · `get_customize_clean_mode` · `get_device_ice` · `get_device_sdp` + +`get_dnd_timer` · `get_dock_info` · `get_dust_collection_mode` · `get_dust_collection_switch_status` · `get_dynamic_data` · `get_dynamic_map_diff` + +`get_fan_motor_work_timeout` · `get_flow_led_status` · `get_fresh_map` · `get_fw_features` · `get_homesec_connect_status` · `get_identify_furniture_status` + +`get_identify_ground_material_status` · `get_led_status` · `get_log_upload_status` · `get_map` · `get_map_beautification_status` · `get_map_status` + +`get_map_v1` · `get_map_v2` · `get_map_calibration` · `get_mop_motor_status` · `get_mop_template_params_by_id` · `get_mop_template_params_summary` + +`get_multi_map` · `get_multi_maps_list` · `get_network_info` · `get_offline_map_status` · `get_persist_map` · `get_prop` + +`get_random_pkey` · `get_recover_map` · `get_recover_maps` · `get_room_mapping` · `get_scenes_valid_tids` · `get_segment_status` + +`get_serial_number` · `get_server_timer` · `get_smart_wash_params` · `get_sound_progress` · `get_sound_volume` · `get_status` + +`get_testid` · `get_timer` · `get_timer_detail` · `get_timer_summary` · `get_timezone` · `get_turn_server` + +`get_valley_electricity_timer` · `get_wash_debug_params` · `get_wash_towel_mode` · `get_wash_towel_params` · `get_water_box_custom_mode` · `get_stretch_tag_status` + +`get_right_brush_stretch_status` · `get_dirty_object_detect_status` · `get_wash_water_temperature` · `get_pet_supplies_deep_clean_status` · `get_ap_mic_led_status` · `get_handle_leak_water_status` + +`get_gap_deep_clean_status` + +### Configuration (57) + +`set_airdry_hours` · `set_app_timezone` · `set_auto_delivery_cleaning_fluid` · `set_camera_status` · `set_carpet_area` · `set_carpet_clean_mode` + +`set_carpet_mode` · `set_child_lock_status` · `set_clean_follow_ground_material_status` · `set_clean_motor_mode` · `set_clean_sequence` · `set_clean_repeat_times` + +`set_collision_avoid_status` · `set_custom_mode` · `set_customize_clean_mode` · `set_dnd_timer` · `set_dnd_timer_actions` · `set_dust_collection_mode` + +`set_dust_collection_switch_status` · `set_fan_motor_work_timeout` · `set_fds_endpoint` · `set_flow_led_status` · `set_homesec_password` · `set_identify_furniture_status` + +`set_identify_ground_material_status` · `set_ignore_carpet_zone` · `set_ignore_identify_area` · `set_lab_status` · `set_led_status` · `set_map_beautification_status` + +`set_mop_mode` · `set_mop_motor_status` · `set_mop_template_id` · `set_offline_map_status` · `set_scenes_segments` · `set_scenes_zones` + +`set_segment_ground_material` · `set_server_timer` · `set_smart_wash_params` · `set_switch_map_mode` · `set_timer` · `set_timezone` + +`set_valley_electricity_timer` · `set_voice_chat_volume` · `set_wash_debug_params` · `set_wash_towel_mode` · `set_wash_towel_params` · `set_water_box_custom_mode` + +`set_water_box_distance_off` · `set_stretch_tag_status` · `set_right_brush_stretch_status` · `set_dirty_object_detect_status` · `set_wash_water_temperature` · `set_pet_supplies_deep_clean_status` + +`set_ap_mic_led_status` · `set_handle_leak_water_status` · `set_gap_deep_clean_status` + +### Start actions (5) + +`start_camera_preview` · `start_clean` · `start_edit_map` · `start_voice_chat` · `start_wash_then_charge` + +### Stop actions (6) + +`stop_camera_preview` · `stop_fan_motor_work` · `stop_goto_target` · `stop_segment_clean` · `stop_voice_chat` · `stop_zoned_clean` + +### Delete (6) + +`del_clean_record` · `del_clean_record_map_v2` · `del_map` · `del_mop_template_params` · `del_server_timer` · `del_timer` + +### Other / map / service (50) + +`add_mop_template_params` · `change_sound_volume` · `check_homesec_password` · `close_dnd_timer` · `close_valley_electricity_timer` · `dnld_install_sound` + +`enable_homesec_voice` · `enable_log_upload` · `end_edit_map` · `find_me` · `load_multi_map` · `manual_bak_map` + +`manual_segment_map` · `merge_segment` · `mop_mode` · `mop_template_id` · `name_multi_map` · `name_segment` + +`play_audio` · `recover_map` · `recover_multi_map` · `reset_consumable` · `reset_homesec_password` · `reset_map` + +`resolve_error` · `resume_segment_clean` · `resume_zoned_clean` · `retry_request` · `reunion_scenes` · `save_furnitures` + +`save_map` · `send_ice_to_robot` · `send_sdp_to_robot` · `sort_mop_template_params` · `split_segment` · `switch_video_quality` + +`switch_water_mark` · `test_sound_volume` · `upd_server_timer` · `upd_timer` · `update_dock` · `update_mop_template_params` + +`upload_data_for_debug_mode` · `upload_photo` · `use_new_map` · `use_old_map` · `user_upload_log` · `matter.get_status` + +`matter.dnld_key` · `matter.reset` + +## 2. V1 commands explicitly wired into source traits (32) + +- `app_get_init_status` — `roborock/cli.py:1172`, `roborock/devices/traits/v1/device_features.py:34` +- `app_start_wash` — `roborock/devices/traits/v1/wash_towel_mode.py:45` +- `app_stop_wash` — `roborock/devices/traits/v1/wash_towel_mode.py:49` +- `change_sound_volume` — `roborock/devices/traits/v1/volume.py:24` +- `close_dnd_timer` — `roborock/devices/traits/v1/do_not_disturb.py:26`, `roborock/devices/traits/v1/do_not_disturb.py:40` +- `close_valley_electricity_timer` — `roborock/devices/traits/v1/valley_electricity_timer.py:27`, `roborock/devices/traits/v1/valley_electricity_timer.py:42` +- `get_child_lock_status` — `roborock/devices/traits/v1/child_lock.py:11` +- `get_clean_record` — `roborock/devices/traits/v1/clean_summary.py:90`, `roborock/devices/traits/v1/clean_summary.py:96` +- `get_clean_summary` — `roborock/devices/traits/v1/clean_summary.py:71` +- `get_consumable` — `roborock/devices/traits/v1/consumeable.py:52` +- `get_dnd_timer` — `roborock/devices/traits/v1/do_not_disturb.py:11` +- `get_dust_collection_mode` — `roborock/devices/traits/v1/dust_collection_mode.py:12` +- `get_flow_led_status` — `roborock/devices/traits/v1/flow_led_status.py:11` +- `get_led_status` — `roborock/devices/traits/v1/led_status.py:28` +- `get_map_v1` — `roborock/devices/traits/v1/home.py:42`, `roborock/devices/traits/v1/map_content.py:86` +- `get_multi_maps_list` — `roborock/devices/traits/v1/maps.py:53` +- `get_network_info` — `roborock/devices/rpc/v1_channel.py:390`, `roborock/devices/traits/v1/network_info.py:33` +- `get_room_mapping` — `roborock/devices/traits/v1/rooms.py:84` +- `get_smart_wash_params` — `roborock/devices/traits/v1/smart_wash_params.py:12` +- `get_sound_volume` — `roborock/devices/traits/v1/volume.py:19` +- `get_status` — `roborock/devices/traits/v1/status.py:52` +- `get_valley_electricity_timer` — `roborock/devices/traits/v1/valley_electricity_timer.py:11` +- `get_wash_towel_mode` — `roborock/devices/traits/v1/wash_towel_mode.py:16` +- `load_multi_map` — `roborock/devices/traits/v1/maps.py:82` +- `reset_consumable` — `roborock/devices/traits/v1/consumeable.py:62` +- `set_child_lock_status` — `roborock/devices/traits/v1/child_lock.py:22`, `roborock/devices/traits/v1/child_lock.py:28` +- `set_dnd_timer` — `roborock/devices/traits/v1/do_not_disturb.py:21`, `roborock/devices/traits/v1/do_not_disturb.py:32` +- `set_flow_led_status` — `roborock/devices/traits/v1/flow_led_status.py:22`, `roborock/devices/traits/v1/flow_led_status.py:28` +- `set_led_status` — `roborock/devices/traits/v1/led_status.py:39`, `roborock/devices/traits/v1/led_status.py:45` +- `set_valley_electricity_timer` — `roborock/devices/traits/v1/valley_electricity_timer.py:22`, `roborock/devices/traits/v1/valley_electricity_timer.py:33` +- `set_wash_towel_mode` — `roborock/devices/traits/v1/wash_towel_mode.py:41` + +## 3. B01/Q7 named method catalog + +60 known Q7 method strings are declared. The user-facing action wrappers include `service.set_room_clean`, `service.start_recharge`, and `service.find_device`; details are in `roborock/devices/traits/b01/q7/__init__.py`. + +`add_clean_failed.post` · `event.add_clean_failed.post` · `clean_finish.post` · `event.clean_finish.post` · `event.BuildMapFinish.post` · `event.map_change.post` + +`event.work_appoint_clean_failed.post` · `startClean.post` · `service.add_order` · `service.add_sweep_clean` · `service.arrange_room` · `service.del_map` + +`service.del_order` · `service.del_orders` · `service.delete_record_by_url` · `service.download_voice_type` · `service.erase_preference` · `service.find_device` + +`service.get_room_order` · `service.get_voice_download` · `service.hello_wikka` · `service.rename_map` · `service.rename_room` · `service.rename_rooms` + +`service.replace_map` · `service.reset_consumable` · `service.save_carpet` · `service.save_recommend_fb` · `service.save_sill` · `service.set_area_start` + +`service.set_areas_start` · `service.set_cur_map` · `service.set_direction` · `service.set_global_sort` · `service.set_map_hide` · `service.set_multi_room_material` + +`service.set_point_clean` · `service.set_preference` · `service.set_preference_type` · `service.set_quiet_time` · `service.set_room_clean` · `service.set_room_order` + +`service.set_virtual_wall` · `service.set_zone_clean` · `service.set_zone_points` · `service.split_room` · `service.start_explore` · `service.start_point_clean` + +`service.start_recharge` · `service.stop_recharge` · `service.upload_by_mapid` · `service.upload_record_by_url` · `prop.get` · `service.get_map_list` + +`service.upload_by_maptype` · `prop.set` · `service.get_preference` · `service.get_record_list` · `service.get_order` · `prop.post` + +## 4. B01/Q10 direct robot actions + +- Start whole-home cleaning: DP `201`, parameter `1` +- Start room/segment cleaning: DP `201`, parameter `{"cmd": 2, "clean_paramters": [room IDs]}` +- Spot clean: DP `201`, parameter `5` +- Pause: DP `204`, parameter `0` +- Resume: DP `205`, parameter `0` +- Stop: DP `206`, parameter `0` +- Return to dock/charge: DP `202`, parameter `5` +- Empty dustbin: DP `203`, parameter `2` +- Set cleaning mode: DP `137` (enum-mapped) +- Set fan level: DP `123` (enum-mapped) diff --git a/PRIVACY.md b/PRIVACY.md index b1a9e70..3ca9203 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -52,9 +52,9 @@ and with the message broker (MQTT) that **your server** designates. This is used The developer does **not** receive any of this traffic. The app contacts no developer-operated or analytics server. -The only other outbound connections are **links you tap yourself**: an optional -link to the open-source project page (GitHub) and an optional donation link. -These open in your browser and are only visited if you choose to tap them. +The only other outbound connections are **links you tap yourself**: optional links +to the open-source project page (GitHub) and this privacy policy. These open in +your browser and are only visited if you choose to tap them. ## 4. Camera / live view @@ -70,6 +70,8 @@ The app requests only the permissions it needs to function: - **Internet / network state** — to reach the server you configure. - **Wi-Fi state / nearby Wi-Fi devices** — to pair a new vacuum with your Wi-Fi network during setup. +- **Local network (iOS)** — to reach your server and to exchange the setup + handshake with the robot's own access point while pairing it. - **Location (Android 12 and below only)** — required by older versions of Android to scan for Wi-Fi networks during vacuum pairing. The app does not use or collect your location, and this permission is not requested on Android 13+. diff --git a/README.md b/README.md index 22f2b0d..33892e2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ LocalRock is a [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform. - + Download on the App Store @@ -221,11 +221,13 @@ tests/ # protocol/contract fixtures & tests | MQTT over TLS | Yes (KMQTT) | Yes (CocoaMQTT bridge) | | Camera live view (WebRTC) | Implemented, **untested** | Implemented (WebRTC.framework bridge), **untested** | | RSA (onboarding crypto) | Yes | Yes | -| Phone Wi‑Fi pairing (Add vacuum) | Yes | Not yet implemented | +| Phone Wi‑Fi pairing (Add vacuum) | Yes | Yes | **Camera live view note:** the WebRTC live feed is implemented from the protocol reference but has **not been tested or confirmed to work** on real hardware. Treat it as experimental. -**iOS note:** Kotlin/Native iOS targets must be built on macOS. The iOS MQTT and WebRTC integrations rely on Swift Package Manager dependencies ([CocoaMQTT](https://github.com/emqx/CocoaMQTT), [WebRTC](https://github.com/stasel/WebRTC)) that must be added to the `iosApp` Xcode target, and the Swift bridge files (`CocoaMqttTransport.swift`, `WebRtcPeer.swift`) added to the target. Pass `nil` for the WebRTC factory to ship without live view. iOS uses a custom bundle identifier that needs signing/provisioning selected in Xcode. +**iOS note:** Kotlin/Native iOS targets must be built on macOS. The iOS MQTT and WebRTC integrations rely on Swift Package Manager dependencies ([CocoaMQTT](https://github.com/emqx/CocoaMQTT), [WebRTC](https://github.com/stasel/WebRTC)) that must be added to the `iosApp` Xcode target, and the Swift bridge files (`CocoaMqttTransport.swift`, `WebRtcPeer.swift`, `VacuumPairing.swift`) added to the target. Pass `nil` for the WebRTC factory to ship without live view, or `nil` for the pairing factory to hide the "add vacuum" flow. iOS uses a custom bundle identifier that needs signing/provisioning selected in Xcode. + +**iOS pairing entitlement:** joining the robot's access point uses `NEHotspotConfiguration`, which needs the **Hotspot Configuration** capability. It is declared in `iosApp/iosApp.entitlements` and wired into the target, but it only signs once the capability is enabled for the App ID in the Apple Developer portal (Certificates, Identifiers & Profiles → your App ID → Hotspot Configuration). Without it the app fails to sign; with it, the UDP handshake also triggers the local-network prompt backed by `NSLocalNetworkUsageDescription`. --- diff --git a/composeApp/src/androidMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.android.kt b/composeApp/src/androidMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.android.kt index 88f53f0..45ec086 100644 --- a/composeApp/src/androidMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.android.kt +++ b/composeApp/src/androidMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.android.kt @@ -119,3 +119,5 @@ actual fun createVacuumPairingTransport(): VacuumPairingTransport { ?: throw VacuumPairingException("Android context not initialized; call initVacLocalAndroidContext first") return VacuumPairingTransport(ctx) } + +actual val vacuumPairingSupported: Boolean = true diff --git a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/demo/DemoData.kt b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/demo/DemoData.kt index bc66c45..256d190 100644 --- a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/demo/DemoData.kt +++ b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/demo/DemoData.kt @@ -53,14 +53,14 @@ object DemoData { private val products: List = listOf( Product( id = "demo-prod-s8", - name = "S8 Pro Ultra", - model = "roborock.vacuum.a70", + name = "Robot Vacuum", + model = "Vacuum", category = "robot.vacuum.cleaner", ), Product( id = "demo-prod-q7", - name = "Q7 Max+", - model = "roborock.vacuum.a38", + name = "Robot Vacuum", + model = "Vacuum", category = "robot.vacuum.cleaner", ), ) diff --git a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.kt b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.kt index 53befff..45702e3 100644 --- a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.kt +++ b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.kt @@ -16,5 +16,8 @@ class VacuumPairingException(message: String, cause: Throwable? = null) : Runtim expect fun createVacuumPairingTransport(): VacuumPairingTransport +/** False when the platform cannot pair over Wi-Fi, so the UI can hide the flow instead of failing. */ +expect val vacuumPairingSupported: Boolean + const val VACUUM_PAIRING_HOST: String = "192.168.8.1" const val VACUUM_PAIRING_PORT: Int = 55559 diff --git a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/devices/DeviceListScreen.kt b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/devices/DeviceListScreen.kt index 159ef6a..8c1786c 100644 --- a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/devices/DeviceListScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/devices/DeviceListScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import com.kodraliu.localrock.resources.Res import com.kodraliu.localrock.resources.app_icon import com.kodraliu.localrock.shared.model.Device +import com.kodraliu.localrock.shared.onboarding.vacuumPairingSupported import com.kodraliu.localrock.ui.AppColors import org.jetbrains.compose.resources.painterResource @@ -91,8 +92,10 @@ fun DeviceListScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = onAddVacuum) { - Icon(Icons.Default.Add, contentDescription = "Add vacuum") + if (vacuumPairingSupported) { + FloatingActionButton(onClick = onAddVacuum) { + Icon(Icons.Default.Add, contentDescription = "Add vacuum") + } } }, ) { padding -> diff --git a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/SplashScreen.kt b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/SplashScreen.kt index 944337a..7bd12b3 100644 --- a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/SplashScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/SplashScreen.kt @@ -36,7 +36,7 @@ fun SplashScreen(onTimeout: () -> Unit) { color = MaterialTheme.colorScheme.primary, ) Text( - "Local control for Roborock", + "Local control for your robot vacuum", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, diff --git a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/WelcomeScreen.kt b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/WelcomeScreen.kt index ddec6c1..814318e 100644 --- a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/WelcomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/intro/WelcomeScreen.kt @@ -45,7 +45,7 @@ fun WelcomeScreen() { fontWeight = FontWeight.Bold, ) Text( - "Control your Roborock vacuum over your own network — please read this first.", + "Control your robot vacuum over your own network — please read this first.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -60,7 +60,7 @@ fun WelcomeScreen() { "2", "Sign in with your server credentials", "You'll sign in with the email and login code you configured during server setup — " + - "not a new Roborock account.", + "not a new cloud account.", ) Point( "3", diff --git a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/settings/SettingsScreen.kt index cc8e54a..ca25c13 100644 --- a/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/kodraliu/localrock/ui/settings/SettingsScreen.kt @@ -43,8 +43,6 @@ import com.kodraliu.localrock.ui.LocalAppContainer const val APP_VERSION: String = "1.0.0" -private const val DONATE_URL = "https://buymeacoffee.com/sidon" - private const val PROJECT_URL = "https://github.com/DonSidro/LocalRock/" private const val PRIVACY_URL = "https://donsidro.github.io/LocalRock/" @@ -107,7 +105,7 @@ fun SettingsScreen(onDone: () -> Unit, allowCancel: Boolean) { value = url, onValueChange = { url = it }, label = { Text("Server URL") }, - placeholder = { Text("https://api-roborock.example.com") }, + placeholder = { Text("https://api-test.example.com") }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -138,20 +136,20 @@ fun SettingsScreen(onDone: () -> Unit, allowCancel: Boolean) { HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) - SectionLabel("Support") + SectionLabel("Contribute") Text( - "LocalRock is a free, community project. If it's useful to you, a small donation " + - "helps keep it maintained.", + "LocalRock is a free, open-source community project. Issue reports, ideas, and " + + "pull requests are all welcome.", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button( - onClick = { uriHandler.openUri(DONATE_URL) }, + onClick = { uriHandler.openUri(PROJECT_URL) }, modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Default.Favorite, contentDescription = null) Spacer(Modifier.width(8.dp)) - Text("Donate") + Text("Contribute on GitHub") } HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) @@ -162,15 +160,11 @@ fun SettingsScreen(onDone: () -> Unit, allowCancel: Boolean) { style = MaterialTheme.typography.bodyMedium, ) Text( - "Control Roborock vacuums locally through your own server. Not affiliated with, " + + "Control vacuums locally through your own server. Not affiliated with, " + "endorsed by, or connected to Roborock. \"Roborock\" is a trademark of its owner.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - OutlinedButton( - onClick = { uriHandler.openUri(PROJECT_URL) }, - modifier = Modifier.fillMaxWidth(), - ) { Text("Project page") } OutlinedButton( onClick = { uriHandler.openUri(PRIVACY_URL) }, modifier = Modifier.fillMaxWidth(), @@ -219,5 +213,5 @@ private const val ACKNOWLEDGEMENTS = "• multiplatform-settings (Apache-2.0) — Russell Wolf\n" + "• KotlinCrypto hash & macs (Apache-2.0)\n" + "• Okio (Apache-2.0) — Square\n\n" + - "And to the Roborock reverse-engineering community — especially the python-roborock " + + "And to the reverse-engineering community — especially the python-roborock " + "project — whose protocol work made local control possible." diff --git a/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/MainViewController.kt index f6bae51..3a30c13 100644 --- a/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/MainViewController.kt @@ -4,6 +4,8 @@ import androidx.compose.ui.window.ComposeUIViewController import com.russhwolf.settings.NSUserDefaultsSettings import com.kodraliu.localrock.shared.AppContainer import com.kodraliu.localrock.shared.mqtt.MqttNativeTransport +import com.kodraliu.localrock.shared.onboarding.IosPairing +import com.kodraliu.localrock.shared.onboarding.VacuumPairingNativeFactory import com.kodraliu.localrock.shared.webrtc.IosRtc import com.kodraliu.localrock.shared.webrtc.RtcNativePeerFactory import platform.Foundation.NSUserDefaults @@ -15,12 +17,18 @@ import platform.UIKit.UIViewController * - [rtcPeerFactory]: a WebRTC.framework-backed peer factory (see `WebRtcPeer.swift`). Pass `null` * to disable the camera live view; [com.kodraliu.localrock.shared.webrtc.liveViewSupported] * reflects whether it was provided. + * - [pairingFactory]: a NetworkExtension/Network.framework pairing session factory (see + * `VacuumPairing.swift`). Pass `null` to hide the "add vacuum" flow; + * [com.kodraliu.localrock.shared.onboarding.vacuumPairingSupported] reflects whether it was + * provided. Requires the Hotspot Configuration entitlement on the app target. */ fun MainViewController( mqttTransport: MqttNativeTransport, rtcPeerFactory: RtcNativePeerFactory?, + pairingFactory: VacuumPairingNativeFactory?, ): UIViewController { IosRtc.factory = rtcPeerFactory + IosPairing.factory = pairingFactory val defaults = NSUserDefaults(suiteName = "vaclocal") ?: NSUserDefaults.standardUserDefaults val container = AppContainer(NSUserDefaultsSettings(defaults), mqttTransport) return ComposeUIViewController { App(container) } diff --git a/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.ios.kt b/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.ios.kt index d4ab690..b2c8d58 100644 --- a/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/kodraliu/localrock/shared/onboarding/VacuumPairingTransport.ios.kt @@ -1,17 +1,102 @@ package com.kodraliu.localrock.shared.onboarding -actual class VacuumPairingTransport internal constructor() : AutoCloseable { - actual suspend fun joinVacuumWifi(ssidPrefix: String, timeoutMs: Long) { - throw NotImplementedError("VacuumPairingTransport is not yet implemented on iOS") - } - actual suspend fun sendUdp(host: String, port: Int, data: ByteArray) { - throw NotImplementedError("VacuumPairingTransport is not yet implemented on iOS") - } - actual suspend fun receiveUdp(timeoutMs: Long): ByteArray? { - throw NotImplementedError("VacuumPairingTransport is not yet implemented on iOS") +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +/** + * iOS Wi-Fi pairing lives on the Swift side: joining the vacuum's access point needs + * `NEHotspotConfiguration` (NetworkExtension) and the UDP handshake needs `NWConnection` + * (Network.framework), neither of which is reachable from Kotlin/Native. This mirrors the MQTT and + * WebRTC approach: the Swift app implements the callback-based, non-suspend [VacuumPairingNative] + * and injects a factory via `MainViewController`. + * + * See `iosApp/iosApp/VacuumPairing.swift`. + */ + +/** One pairing session, implemented in Swift. Non-suspend so Swift can implement it. */ +interface VacuumPairingNative { + fun joinWifi( + ssidPrefix: String, + timeoutMs: Long, + onJoined: () -> Unit, + onError: (String) -> Unit, + ) + + fun send( + host: String, + port: Int, + data: ByteArray, + onSent: () -> Unit, + onError: (String) -> Unit, + ) + + /** Calls back with null when nothing arrived within [timeoutMs]. */ + fun receive( + timeoutMs: Long, + onReceived: (ByteArray?) -> Unit, + onError: (String) -> Unit, + ) + + /** Tears down the socket and puts the phone back on its normal Wi-Fi network. */ + fun close() +} + +interface VacuumPairingNativeFactory { + fun create(): VacuumPairingNative +} + +/** Holds the Swift-injected factory; set once from `MainViewController(pairingFactory:)`. */ +object IosPairing { + var factory: VacuumPairingNativeFactory? = null +} + +actual val vacuumPairingSupported: Boolean + get() = IosPairing.factory != null + +actual class VacuumPairingTransport internal constructor( + private val native: VacuumPairingNative, +) : AutoCloseable { + + actual suspend fun joinVacuumWifi(ssidPrefix: String, timeoutMs: Long): Unit = + suspendCancellableCoroutine { cont -> + native.joinWifi( + ssidPrefix = ssidPrefix, + timeoutMs = timeoutMs, + onJoined = { cont.resume(Unit) }, + onError = { cont.resumeWithException(VacuumPairingException(it)) }, + ) + } + + actual suspend fun sendUdp(host: String, port: Int, data: ByteArray): Unit = + suspendCancellableCoroutine { cont -> + native.send( + host = host, + port = port, + data = data, + onSent = { cont.resume(Unit) }, + onError = { cont.resumeWithException(VacuumPairingException(it)) }, + ) + } + + actual suspend fun receiveUdp(timeoutMs: Long): ByteArray? = + suspendCancellableCoroutine { cont -> + native.receive( + timeoutMs = timeoutMs, + onReceived = { bytes -> cont.resume(bytes) }, + onError = { cont.resumeWithException(VacuumPairingException(it)) }, + ) + } + + actual override fun close() { + native.close() } - actual override fun close() { /* nothing to clean up on the stub */ } } -actual fun createVacuumPairingTransport(): VacuumPairingTransport = - throw NotImplementedError("Vacuum onboarding is not yet implemented on iOS") +actual fun createVacuumPairingTransport(): VacuumPairingTransport { + val factory = IosPairing.factory + ?: throw VacuumPairingException( + "Wi-Fi pairing is unavailable — no pairing factory was injected into MainViewController" + ) + return VacuumPairingTransport(factory.create()) +} diff --git a/docs/index.html b/docs/index.html index 0aeccf9..4b308cf 100644 --- a/docs/index.html +++ b/docs/index.html @@ -102,8 +102,8 @@

3. Data transmitted, and to whom

The developer does not receive any of this traffic. The app contacts no developer-operated or analytics server.

-

The only other outbound connections are links you tap yourself: an optional - link to the open-source project page (GitHub) and an optional donation link. These open in your +

The only other outbound connections are links you tap yourself: optional links + to the open-source project page (GitHub) and this privacy policy. These open in your browser and are only visited if you choose to tap them.

4. Camera / live view

@@ -117,6 +117,8 @@

5. Permissions

  • Internet / network state — to reach the server you configure.
  • Wi-Fi state / nearby Wi-Fi devices — to pair a new vacuum with your Wi-Fi network during setup.
  • +
  • Local network (iOS) — to reach your server and to exchange the setup + handshake with the robot's own access point while pairing it.
  • Location (Android 12 and below only) — required by older versions of Android to scan for Wi-Fi networks during vacuum pairing. The app does not use or collect your location, and this permission is not requested on Android 13+.
  • diff --git a/docs/screenshots/localrock-icon-1a.svg b/docs/screenshots/localrock-icon-1a.svg new file mode 100644 index 0000000..712e436 --- /dev/null +++ b/docs/screenshots/localrock-icon-1a.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 03811bd..4ef2166 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -190,6 +190,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; @@ -224,6 +225,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO; + CODE_SIGN_ENTITLEMENTS = iosApp/iosApp.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 2; diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 3f5737d..1fa9b29 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -7,9 +7,12 @@ struct ComposeView: UIViewControllerRepresentable { // Inject the Swift-backed platform pieces into the shared Kotlin code: // - CocoaMQTT transport for MQTT // - WebRTC.framework peer factory for the camera live view (pass nil to disable it) + // - NetworkExtension/Network.framework session factory for Wi-Fi vacuum pairing + // (pass nil to hide the "add vacuum" flow) MainViewControllerKt.MainViewController( mqttTransport: CocoaMqttTransport(), - rtcPeerFactory: WebRtcNativeFactory() + rtcPeerFactory: WebRtcNativeFactory(), + pairingFactory: VacuumPairingFactory() ) } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index eafc13f..9524ced 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -4,6 +4,8 @@ CADisableMinimumFrameDurationOnPhone + NSLocalNetworkUsageDescription + LocalRock talks directly to your robot vacuum over your local network: to your own self-hosted server, and to the robot's own Wi-Fi access point while you set it up. Nothing is sent to the developer. NSCameraUsageDescription LocalRock displays the live video feed from your robot vacuum's built-in camera. It does not access your device's own camera. NSMicrophoneUsageDescription diff --git a/iosApp/iosApp/VacuumPairing.swift b/iosApp/iosApp/VacuumPairing.swift new file mode 100644 index 0000000..92fbe61 --- /dev/null +++ b/iosApp/iosApp/VacuumPairing.swift @@ -0,0 +1,286 @@ +import Foundation +import Network +import NetworkExtension +import ComposeApp + +// iOS Wi-Fi pairing for LocalRock, implementing the Kotlin `VacuumPairingNative` contract. +// +// Pairing works like this: the vacuum, when unprovisioned, exposes an open access point named +// `roborock-vacuum-`. The phone joins that AP, exchanges a UDP handshake with the robot +// at 192.168.8.1:55559 (hello -> RSA-wrapped session key -> Wi-Fi config -> ack), and then leaves. +// +// Two Apple APIs are needed, neither of which Kotlin/Native can reach: +// - NEHotspotConfiguration (NetworkExtension) to join the vacuum's AP. This requires the Hotspot +// Configuration entitlement on the app target (`iosApp.entitlements`). +// - NWConnection (Network.framework) for the UDP exchange. Talking to a private LAN address makes +// iOS show the local-network prompt (NSLocalNetworkUsageDescription in Info.plist). +// +// The shared Kotlin `VacuumOnboarder` drives the protocol and the step ordering; this class is just +// the platform plumbing: join, send, receive, clean up. + +final class VacuumPairingFactory: VacuumPairingNativeFactory { + + /// Drops any hotspot configuration left behind by an earlier session. A pairing attempt that + /// died without tearing down leaves the phone on the robot's AP, where the server is + /// unreachable — and since pairing talks to the server *before* it joins the robot, a stale + /// configuration would sink the next attempt during login, with a DNS error that says nothing + /// about Wi-Fi. Cheap to run at launch, so run it at launch. + init() { + let manager = NEHotspotConfigurationManager.shared + manager.getConfiguredSSIDs { configured in + for ssid in configured where ssid.hasPrefix(Self.vacuumSsidPrefix) { + manager.removeConfiguration(forSSID: ssid) + } + } + } + + func create() -> VacuumPairingNative { VacuumPairingSession() } + + // Matches the default in the shared Kotlin `VacuumPairingTransport.joinVacuumWifi`. + private static let vacuumSsidPrefix = "roborock-vacuum-" +} + +final class VacuumPairingSession: VacuumPairingNative { + + private let queue = DispatchQueue(label: "com.kodraliu.localrock.pairing") + private var connection: NWConnection? + private var joinedSsid: String? + private var joinedPrefix: String? + + // MARK: Joining the vacuum's access point + + func joinWifi( + ssidPrefix: String, + timeoutMs: Int64, + onJoined: @escaping () -> Void, + onError: @escaping (String) -> Void + ) { + // The join and the timeout race each other; whichever lands first wins. + let done = CallbackGuard() + + let config = NEHotspotConfiguration(ssidPrefix: ssidPrefix) + // Don't leave the vacuum's AP in the user's known-networks list after pairing. + config.joinOnce = true + + // Remember the prefix even if the join later fails: iOS may already have associated, and + // `close()` has to be able to undo that or the phone is stranded on an AP with no route to + // the user's server. + joinedPrefix = ssidPrefix + + queue.asyncAfter(deadline: .now() + .milliseconds(Int(timeoutMs))) { + guard done.claim() else { return } + onError("Timed out waiting to join the vacuum's Wi-Fi network (\(ssidPrefix)…). " + + "Make sure the robot is in pairing mode and close to the phone.") + } + + NEHotspotConfigurationManager.shared.apply(config) { error in + if let error = error as NSError?, + error.code != NEHotspotConfigurationError.alreadyAssociated.rawValue { + guard done.claim() else { return } + onError(Self.describe(error, ssidPrefix: ssidPrefix)) + return + } + // `apply` reports success as soon as iOS accepts the join, not once Wi-Fi has finished + // associating, so the current network needs to be sampled until it settles rather than + // read once. + self.confirmJoined(ssidPrefix: ssidPrefix, done: done, onJoined: onJoined) + } + } + + /// Polls the current network until it is the vacuum's AP. `fetchCurrent` is unreliable right + /// after a join — it can report the previous network, or nothing at all — so a single mismatched + /// sample is not treated as failure. If it never confirms, the handshake proceeds anyway and the + /// UDP hello timeout becomes the real verdict: a false "you're not on the vacuum's network" is + /// worse than a clean handshake timeout, because the former blocks a pairing that would work. + private func confirmJoined( + ssidPrefix: String, + done: CallbackGuard, + attempt: Int = 0, + onJoined: @escaping () -> Void + ) { + NEHotspotNetwork.fetchCurrent { network in + if let network, network.ssid.hasPrefix(ssidPrefix) { + guard done.claim() else { return } + self.joinedSsid = network.ssid + onJoined() + return + } + guard attempt < Self.joinConfirmAttempts else { + guard done.claim() else { return } + onJoined() // Unconfirmed, but let the handshake decide. + return + } + self.queue.asyncAfter(deadline: .now() + .milliseconds(Self.joinConfirmIntervalMs)) { + self.confirmJoined( + ssidPrefix: ssidPrefix, + done: done, + attempt: attempt + 1, + onJoined: onJoined + ) + } + } + } + + private static let joinConfirmAttempts = 20 // ~10s of association grace + private static let joinConfirmIntervalMs = 500 + + // MARK: UDP handshake + + func send( + host: String, + port: Int32, + data: KotlinByteArray, + onSent: @escaping () -> Void, + onError: @escaping (String) -> Void + ) { + let payload = Data(data.toUInt8()) + withReadyConnection(host: host, port: port, onError: onError) { connection in + connection.send(content: payload, completion: .contentProcessed { error in + if let error { + onError("UDP send failed: \(error.localizedDescription)") + } else { + onSent() + } + }) + } + } + + func receive( + timeoutMs: Int64, + onReceived: @escaping (KotlinByteArray?) -> Void, + onError: @escaping (String) -> Void + ) { + guard let connection else { + onError("Cannot receive before sending: no UDP socket is open.") + return + } + + // The robot answers to the source port of our datagram, so the same NWConnection is reused + // for the whole exchange rather than opened per message. + let done = CallbackGuard() + + queue.asyncAfter(deadline: .now() + .milliseconds(Int(timeoutMs))) { + guard done.claim() else { return } + onReceived(nil) // A timeout is a normal outcome here, not an error. + } + + connection.receiveMessage { data, _, _, error in + guard done.claim() else { return } + if let error { + onError("UDP receive failed: \(error.localizedDescription)") + return + } + guard let data, !data.isEmpty else { + onReceived(nil) + return + } + onReceived(KotlinByteArray.from([UInt8](data))) + } + } + + func close() { + connection?.cancel() + connection = nil + + // Put the phone back on its normal network. A prefix-joined configuration is registered + // under the *prefix*, not under the SSID the phone actually landed on, so removing only the + // resolved SSID is a silent no-op that strands the phone on the robot's AP — with no DNS and + // no route to the user's server. Remove every configuration this session could have created, + // and let iOS's own list be the source of truth. + let manager = NEHotspotConfigurationManager.shared + let ssid = joinedSsid + let prefix = joinedPrefix + + if let prefix { manager.removeConfiguration(forSSID: prefix) } + if let ssid { manager.removeConfiguration(forSSID: ssid) } + manager.getConfiguredSSIDs { configured in + for entry in configured { + if entry == ssid || entry == prefix || (prefix.map { entry.hasPrefix($0) } ?? false) { + manager.removeConfiguration(forSSID: entry) + } + } + } + + joinedSsid = nil + joinedPrefix = nil + } + + // MARK: Helpers + + /// Opens the UDP socket on first use and calls `body` once it is ready to carry traffic. + private func withReadyConnection( + host: String, + port: Int32, + onError: @escaping (String) -> Void, + body: @escaping (NWConnection) -> Void + ) { + if let connection, connection.state == .ready { + body(connection) + return + } + + guard let nwPort = NWEndpoint.Port(rawValue: UInt16(clamping: port)) else { + onError("Invalid pairing port: \(port)") + return + } + + let parameters = NWParameters.udp + // The vacuum's AP has no internet, so pin the socket to Wi-Fi and stop iOS from quietly + // routing these datagrams over cellular. + parameters.requiredInterfaceType = .wifi + parameters.prohibitExpensivePaths = false + + let connection = NWConnection( + host: NWEndpoint.Host(host), + port: nwPort, + using: parameters + ) + self.connection = connection + + let done = CallbackGuard() + connection.stateUpdateHandler = { state in + switch state { + case .ready: + guard done.claim() else { return } + body(connection) + case .failed(let error): + guard done.claim() else { return } + onError("UDP socket failed: \(error.localizedDescription)") + case .cancelled: + guard done.claim() else { return } + onError("UDP socket was cancelled.") + default: + break + } + } + connection.start(queue: queue) + } + + private static func describe(_ error: NSError, ssidPrefix: String) -> String { + switch NEHotspotConfigurationError(rawValue: error.code) { + case .userDenied: + return "Joining the vacuum's Wi-Fi network was declined." + case .invalidSSIDPrefix: + return "iOS rejected the vacuum SSID prefix \(ssidPrefix)." + case .pending, .systemConfiguration, .unknown, .joinOnceNotSupported: + return "iOS could not join the vacuum's Wi-Fi network: \(error.localizedDescription)" + default: + return "Could not join the vacuum's Wi-Fi network: \(error.localizedDescription)" + } + } +} + +/// Both the network callback and its timeout can fire; the first one through wins and the loser is +/// dropped, so the Kotlin continuation is never resumed twice. +private final class CallbackGuard { + private let lock = NSLock() + private var used = false + + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if used { return false } + used = true + return true + } +} diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements new file mode 100644 index 0000000..7026276 --- /dev/null +++ b/iosApp/iosApp/iosApp.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.networking.HotspotConfiguration + + +