Skip to content

Discovery, display consistency, full MQTT mapping, integration tests#32

Open
markus-lassfolk wants to merge 64 commits into
developfrom
dev/review-discovery-display-mqtt
Open

Discovery, display consistency, full MQTT mapping, integration tests#32
markus-lassfolk wants to merge 64 commits into
developfrom
dev/review-discovery-display-mqtt

Conversation

@markus-lassfolk

@markus-lassfolk markus-lassfolk commented Feb 27, 2026

Copy link
Copy Markdown
Owner

Summary

This PR consolidates discovery improvements, display consistency, full MQTT-to-object mapping, and integration tests for review.

Discovery & connection

  • Find-YarboDevice: Discover endpoints, label Rover vs DC (MAC), recommend one per device, output in failover order (recommended first).
  • Find-Yarbo: Deduplicate by SerialNumber (one robot per device).
  • ArpHelper.ps1: Get-MacForIp, Test-LocallyAdministeredMac, Invoke-ArpPrime.
  • NetworkHelper.ps1: Get-PSYarboLocalSubnets (use machine's networks when no -Subnet).
  • YarboEndpoint class; warning when scanning >512 hosts.

Display consistency

  • Script properties Battery, State (and where relevant HeadingDisplay, Time, Areas, SelfOrder, Days, EnabledDisplay, WeatherDisplay, RecommendedDisplay) on YarboRobot, YarboTelemetry, YarboPlan, YarboSchedule, YarboEndpoint so Format-Table, Format-List, and Select-Object * show the same concepts.

Full MQTT → PowerShell objects

  • YarboRobot & YarboTelemetry: All DeviceMSG fields (BatteryMSG, BodyMsg, StateMSG, RTKMSG, RunningStatusMSG, wireless_recharge, ultrasonic_msg, led, route_priority, timestamps); RawMessage / GnggaRaw where applicable.
  • YarboGlobalParams class (MowSpeed, CuttingHeight, RainDelay, ObstacleSensitivity, Timestamp, RawData); Get-YarboGlobalParams returns [YarboGlobalParams].
  • TelemetryParser: ConvertTo-YarboRobot / ConvertTo-YarboTelemetry map every reversed field.

Tests & tooling

  • Integration tests: Find-Yarbo → Connect-Yarbo → Get-Yarbo* (Status, Robot, Battery, Firmware, GlobalParams, Map, Plan, Schedule, Telemetry, Log) → Disconnect (tests/Integration/FindConnectGetYarbo.Integration.Tests.ps1).
  • Class count updated to 11 (YarboGlobalParams added).

Checklist

  • Review discovery (Find-YarboDevice, Find-Yarbo) and Rover/DC/failover behavior
  • Review display script properties and format/type files
  • Review new YarboRobot/YarboTelemetry/YarboGlobalParams properties and parser
  • Run integration tests on a machine that can reach a Yarbo (Invoke-Pester ./tests/Integration/FindConnectGetYarbo.Integration.Tests.ps1 -Tag Integration)

Made with Cursor


Note

Medium Risk
Moderate risk: changes core MQTT module loading/connection and network discovery paths, plus expands cloud credential handling; regressions could break connect/discovery or message routing. Adds multiple new robot-control cmdlets (some destructive) but generally gated behind SupportsShouldProcess/ConfirmImpact.

Overview
Discovery and MQTT reliability are expanded. Find-Yarbo now auto-scans local interface subnets (or -Subnet), verifies brokers via a new C# YarboMqttListener, and deduplicates results by serial number; a new Find-YarboDevice command discovers multiple endpoints per robot, resolves MACs, labels Rover vs DC, and outputs a recommended failover order.

Status/telemetry data and formatting are broadened. YarboRobot/YarboTelemetry gain many additional DeviceMSG-backed fields plus RawMessage, parsing is refactored via a shared Set-YarboCommonFields, and type/format data is updated to use consistent display script properties (e.g. Battery, State, display-friendly heading/time). Get-YarboGlobalParams now returns a typed YarboGlobalParams (with RawData), and Set-YarboGlobalParams accepts either a hashtable or that object.

New tooling, commands, and pipeline updates. Adds env-driven MQTT debug logging (PSYARBO_DEBUG, PSYARBO_DEBUG_RAW), MQTT sniff/record support (Invoke-YarboMqttSniff), and exports additional utility/control cmdlets (camera, maps, firmware update, mower/head settings, etc.). CI is updated to install dependencies and split Pester into unit/integration/root runs, and docs/README are expanded with a new “Commands and Properties” guide and improved examples.

Written by Cursor Bugbot for commit 13b1673. This will update automatically on new commits. Configure here.

markus-lassfolk and others added 2 commits February 27, 2026 11:42
## Summary

<!-- Describe your changes in a few sentences. Link the related
issue(s). -->

Closes #<!-- issue number -->

## Type of change

- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] 🧹 Refactor / code cleanup (no behaviour change)
- [ ] 📝 Documentation update
- [ ] ⚙️ CI/CD / tooling change

## Changes made

<!-- Bullet-point summary of what changed and why. -->

- 
- 

## Testing

<!-- Describe how you tested this. Include commands you ran. -->

- [ ] Added / updated Pester tests
- [ ] All tests pass locally (`Invoke-Pester ./tests`)
- [ ] Tested manually against Yarbo hardware (if applicable — describe
setup)

## Quality checklist

- [ ] **PSScriptAnalyzer**: Zero Error or Warning findings
(`Invoke-ScriptAnalyzer ./src -Settings ./PSScriptAnalyzerSettings.psd1
-Severity Error,Warning`)
- [ ] **Tests pass**: `Invoke-Pester ./tests` — all green
- [ ] **Module manifest updated** if version changed
- [ ] **CHANGELOG.md** updated under `[Unreleased]`
- [ ] **Docs updated** (comment-based help, README if needed)
- [ ] **No secrets, credentials, or PII** in code, comments, or commit
messages
- [ ] **Follows coding standards** in
[CONTRIBUTING.md](../CONTRIBUTING.md)

## Screenshots / output (optional)

<!-- Paste relevant terminal output, before/after if helpful. -->

---

> **Reviewer note**: Branch must be up-to-date with `develop` and all CI
checks must pass before merge.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Medium risk because it changes repository automation to create and
squash-merge PRs automatically based on branch/author heuristics, which
could merge unintended changes if misconfigured. Code changes are
limited to GitHub Actions/workflow logic and removal of a module-side
error reporting hook.
> 
> **Overview**
> Adds a new GitHub Actions workflow (`auto-create-pr.yml`) that
automatically opens PRs to `develop` when bot/agent accounts push to
fix-style branches and no PR exists.
> 
> Expands and modernizes the existing `copilot-automerge` workflow by
updating `actions/github-script` to `v7`, adding needed permissions, and
tightening eligibility to bot-authored PRs on fix branch prefixes; it
now comments on failed checks and squash-merges when all checks pass.
> 
> Removes PS module auto-initialization for error reporting and deletes
the private `ErrorReporter.ps1` implementation.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
f64f653. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: markus-lassfolk <markus@lassfolk.fi>
…ation tests

Discovery & connection
- Find-YarboDevice: discover endpoints, label Rover vs DC (MAC), recommend one per device, failover order
- Find-Yarbo: deduplicate by SerialNumber (one robot per device)
- ArpHelper.ps1: Get-MacForIp, Test-LocallyAdministeredMac, Invoke-ArpPrime
- NetworkHelper.ps1: Get-PSYarboLocalSubnets (use machine's networks when no -Subnet)
- YarboEndpoint class; Find-YarboDevice outputs in try-order (recommended first)
- Warning when scanning >512 hosts (slow, suggest -MaxHosts)

Display consistency (table / fl / select * match)
- Script properties Battery, State (and where relevant HeadingDisplay, Time, Areas, SelfOrder, Days, EnabledDisplay, WeatherDisplay, RecommendedDisplay) on YarboRobot, YarboTelemetry, YarboPlan, YarboSchedule, YarboEndpoint
- Format and Types.ps1xml updated so same concepts appear everywhere

Full MQTT → PowerShell objects (no exceptions)
- YarboRobot: all DeviceMSG fields (BatteryMSG, BodyMsg, StateMSG, RTKMSG, RunningStatusMSG, wireless_recharge, ultrasonic_msg, led, route_priority, timestamps); RawMessage (hidden)
- YarboTelemetry: same + RechargeState, RtkHeadingStatus, LedRegister, GnggaRaw, DeviceTimestamp, BatteryTimestamp
- TelemetryParser: ConvertTo-YarboRobot / ConvertTo-YarboTelemetry map every reversed field
- YarboGlobalParams class (MowSpeed, CuttingHeight, RainDelay, ObstacleSensitivity, Timestamp, RawData); Get-YarboGlobalParams returns [YarboGlobalParams]

Tests & tooling
- Integration tests: Find-Yarbo → Connect-Yarbo → Get-Yarbo* (Status, Robot, Battery, Firmware, GlobalParams, Map, Plan, Schedule, Telemetry, Log) → Disconnect
- Class count 11 (YarboGlobalParams added)
- tools/MqttSniff.ps1, Invoke-YarboMqttSniff (if present)

Other
- Various doc/build/issue template updates from earlier work

Made-with: Cursor
Copilot AI review requested due to automatic review settings February 27, 2026 15:52
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Comment thread src/PSYarbo/Public/Connection/Find-YarboDevice.ps1 Outdated
Comment thread src/PSYarbo/PSYarbo.psm1 Outdated
Comment thread src/PSYarbo/Public/Connection/Connect-Yarbo.ps1
Comment thread src/PSYarbo/Public/Connection/Invoke-YarboMqttSniff.ps1
Comment thread src/PSYarbo/PSYarbo.psm1
Comment thread src/PSYarbo/Public/Status/Get-YarboGlobalParams.ps1

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR significantly enhances the PSYarbo module's discovery, display consistency, MQTT mapping, and testing capabilities. It introduces intelligent network-based robot discovery with Rover vs DC classification, standardizes display properties across all object types, maps all MQTT fields to PowerShell objects, and adds comprehensive integration tests.

Changes:

  • Discovery improvements: Find-YarboDevice distinguishes Rover (direct WiFi) from DC (base station) via MAC address inspection; both Find-Yarbo and Find-YarboDevice auto-detect local subnets when no -Subnet is specified
  • Display consistency: Script properties (Battery, State, HeadingDisplay, etc.) standardized across YarboRobot, YarboTelemetry, YarboPlan, YarboSchedule, YarboEndpoint for uniform output
  • Full MQTT mapping: All DeviceMSG fields now mapped to YarboRobot/YarboTelemetry properties; new YarboGlobalParams class with typed properties; RawMessage/GnggaRaw/RawData preserved for advanced use

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/PSYarbo/Public/Connection/Find-YarboDevice.ps1 New cmdlet for endpoint discovery with Rover/DC classification and failover ordering
src/PSYarbo/Public/Connection/Find-Yarbo.ps1 Refactored to use local subnet detection, C# YarboMqttListener, and deduplicate by SerialNumber
src/PSYarbo/Public/Connection/Connect-Yarbo.ps1 Message handler moved before Connect() call per MQTTnet requirements
src/PSYarbo/Public/Connection/Invoke-YarboMqttSniff.ps1 New troubleshooting cmdlet using YarboMqttListener
src/PSYarbo/Public/Status/Get-YarboGlobalParams.ps1 Returns typed YarboGlobalParams instead of raw PSCustomObject
src/PSYarbo/Private/NetworkHelper.ps1 New Get-PSYarboLocalSubnets helper for auto-detecting scan targets
src/PSYarbo/Private/ArpHelper.ps1 New MAC resolution helpers (Get-MacForIp, Test-LocallyAdministeredMac, Invoke-ArpPrime)
src/PSYarbo/Private/TelemetryParser.ps1 Extended to map all DeviceMSG fields including BatteryTimestamp, RechargeState, RtkHeadingStatus, ultrasonic_msg, etc.
src/PSYarbo/Classes/YarboGlobalParams.ps1 New class for typed global parameters
src/PSYarbo/Classes/YarboEndpoint.ps1 New class for discovery results
src/PSYarbo/Classes/YarboRobot.ps1 Added properties for ultrasonic sensors, wireless charging, rain sensor, device timestamp
src/PSYarbo/Classes/YarboTelemetry.ps1 Added properties matching YarboRobot for display consistency
src/PSYarbo/PSYarbo.psm1 Added C# YarboMqttListener helper for reliable MQTT message capture; switched to direct Assembly.LoadFrom
src/PSYarbo/PSYarbo.psd1 Added ScriptsToProcess for class pre-loading; exported Find-YarboDevice and Invoke-YarboMqttSniff
src/PSYarbo/PSYarbo.Types.ps1xml Added script properties (Battery, State, HeadingDisplay, etc.) for consistent display across all types
src/PSYarbo/PSYarbo.Format.ps1xml Refactored to use script properties from Types.ps1xml; added YarboEndpoint table view
tests/Integration/FindConnectGetYarbo.Integration.Tests.ps1 New integration test suite covering Find-Yarbo → Connect-Yarbo → all Get-Yarbo* cmdlets
tests/PSYarbo.Tests.ps1 Updated class count to 11; added Find-YarboDevice to expected exports
tests/Unit/PrivateFunctions.Tests.ps1 Updated test IP addresses for consistency
tools/MqttSniff.ps1 New troubleshooting script wrapper for Invoke-YarboMqttSniff
README.md Updated documentation for discovery alignment with python-yarbo
CONTRIBUTING.md Updated example documentation

Comment thread src/PSYarbo/PSYarbo.psd1
Comment thread src/PSYarbo/PSYarbo.psm1 Outdated
Comment thread src/PSYarbo/Public/Connection/Find-Yarbo.ps1 Outdated
Comment thread src/PSYarbo/Public/Connection/Find-YarboDevice.ps1 Outdated
…elftest subscription, thread-safety, and parameter type compatibility

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Inconsistent types for wireless charge properties across classes
    • Changed WirelessChargeVoltage and WirelessChargeCurrent from [int] to [double] in YarboTelemetry class and ConvertTo-YarboTelemetry function to match YarboRobot and prevent truncation of decimal values.
  • ✅ Fixed: Massive duplicated subnet scanning logic between discovery functions
    • Extracted duplicated subnet IP generation logic into a shared Get-PSYarboSubnetIpList helper function in NetworkHelper.ps1 and updated both Find-Yarbo and Find-YarboDevice to use it.
  • ✅ Fixed: Type literal throws instead of enabling graceful fallback
    • Replaced unsafe type literal [PSYarbo.Mqtt.YarboMqttListener] with safe 'PSYarbo.Mqtt.YarboMqttListener' -as [type] pattern in both Find-Yarbo and Find-YarboDevice to enable graceful fallback when type is unavailable.
Preview (93d3c64514)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -88,7 +88,7 @@
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -165,6 +165,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. Default is 192.168.1.0/24 (typical home LAN); pass `-Subnet` if your network uses a different range so discovery can scan it. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -89,6 +89,11 @@
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient -and [PSYarbo.Mqtt.MessageReceivedAdapter]) {
+                [PSYarbo.Mqtt.MessageReceivedAdapter]::UnregisterCallback($this.MqttClient)
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,16 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -24,25 +29,39 @@
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
     [string]$LedRegister
+    [int]$WirelessChargeState
     [double]$WirelessChargeVoltage
     [double]$WirelessChargeCurrent
+    [int]$WirelessChargeErrorCode
 
-    # Network
+    # Ultrasonic (ultrasonic_msg)
+    [int]$UltrasonicLeftFront
+    [int]$UltrasonicMiddle
+    [int]$UltrasonicRightFront
+
+    # Network (route_priority)
     [hashtable]$RoutePriority
 
-    # Timestamps
+    # Timestamps (device timestamp + our LastUpdated)
+    [double]$DeviceTimestamp
     [datetime]$LastUpdated
 
     # Connection reference (hidden)
     hidden [string]$Broker
     hidden [int]$Port
 
+    # Full DeviceMSG payload (all MQTT keys available)
+    hidden [PSCustomObject]$RawMessage
+
     [string] ToString() {
         $displayName = if ($this.Name) { $this.Name } else { $this.SerialNumber }
         return "$displayName ($($this.SerialNumber)) - Battery: $($this.BatteryCapacity)% - State: $(if($this.WorkingState){'Active'}else{'Idle'})"

diff --git a/src/PSYarbo/Classes/YarboTelemetry.ps1 b/src/PSYarbo/Classes/YarboTelemetry.ps1
--- a/src/PSYarbo/Classes/YarboTelemetry.ps1
+++ b/src/PSYarbo/Classes/YarboTelemetry.ps1
@@ -2,28 +2,33 @@
     [datetime]$Timestamp
     [string]$SerialNumber
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
     [bool]$BatteryTempError
+    [double]$BatteryTimestamp
 
-    # Position
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # Position (CombinedOdom, RTKMSG, combined_odom_confidence)
     [double]$X
     [double]$Y
     [double]$Phi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
     [double]$RtkDop
     [double]$OdomConfidence
 
-    # Running status
+    # Running status (RunningStatusMSG)
     [int]$ChuteAngle
     [int]$ChuteSteeringEngineInfo
     [double]$HeadGyroPitch
     [double]$HeadGyroRoll
     [int]$RainSensorData
 
-    # State flags
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -31,23 +36,30 @@
     [bool]$IsPaused
     [bool]$IsRecharging
 
-    # Sensors
+    # Sensors (ultrasonic_msg)
     [int]$UltrasonicLeftFront
     [int]$UltrasonicMiddle
     [int]$UltrasonicRightFront
 
-    # Wireless charging
+    # Wireless charging (wireless_recharge)
     [int]$WirelessChargeState
-    [int]$WirelessChargeVoltage
-    [int]$WirelessChargeCurrent
+    [double]$WirelessChargeVoltage
+    [double]$WirelessChargeCurrent
     [int]$WirelessChargeErrorCode
 
-    # GPS (parsed from rtk_base_data.rover.gngga NMEA sentence)
+    # LED (led)
+    [string]$LedRegister
+
+    # GPS (parsed from rtk_base_data.rover.gngga + raw)
     [nullable[double]]$Latitude
     [nullable[double]]$Longitude
     [nullable[double]]$Altitude
     [int]$FixQuality
+    [string]$GnggaRaw
 
+    # Device message timestamp (root timestamp)
+    [double]$DeviceTimestamp
+
     # Raw
     hidden [PSCustomObject]$RawMessage
 

diff --git a/src/PSYarbo/PSYarbo.Format.ps1xml b/src/PSYarbo/PSYarbo.Format.ps1xml
--- a/src/PSYarbo/PSYarbo.Format.ps1xml
+++ b/src/PSYarbo/PSYarbo.Format.ps1xml
@@ -23,10 +23,10 @@
             <TableColumnItems>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>SerialNumber</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>"$($_.BatteryCapacity)%"</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.WorkingState){'Active'}else{'Idle'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Battery</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>State</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>RtkStatus</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Heading, 1)</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>HeadingDisplay</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>ErrorCode</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
@@ -34,7 +34,7 @@
       </TableControl>
     </View>
 
-    <!-- YarboRobot List View -->
+    <!-- YarboRobot List View (Battery and State match table view; raw fields kept for scripting) -->
     <View>
       <Name>YarboRobotList</Name>
       <ViewSelectedBy>
@@ -46,6 +46,8 @@
             <ListItems>
               <ListItem><PropertyName>SerialNumber</PropertyName></ListItem>
               <ListItem><PropertyName>Name</PropertyName></ListItem>
+              <ListItem><PropertyName>Battery</PropertyName></ListItem>
+              <ListItem><PropertyName>State</PropertyName></ListItem>
               <ListItem><PropertyName>HeadType</PropertyName></ListItem>
               <ListItem><PropertyName>BatteryCapacity</PropertyName></ListItem>
               <ListItem><PropertyName>BatteryStatus</PropertyName></ListItem>
@@ -53,6 +55,7 @@
               <ListItem><PropertyName>ChargingStatus</PropertyName></ListItem>
               <ListItem><PropertyName>ErrorCode</PropertyName></ListItem>
               <ListItem><PropertyName>Heading</PropertyName></ListItem>
+              <ListItem><PropertyName>HeadingDisplay</PropertyName></ListItem>
               <ListItem><PropertyName>RtkStatus</PropertyName></ListItem>
               <ListItem><PropertyName>OdomConfidence</PropertyName></ListItem>
               <ListItem><PropertyName>ChuteAngle</PropertyName></ListItem>
@@ -83,14 +86,14 @@
         <TableRowEntries>
           <TableRowEntry>
             <TableColumnItems>
-              <TableColumnItem><ScriptBlock>$_.Timestamp.ToString('HH:mm:ss.ff')</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>"$($_.BatteryCapacity)%"</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.WorkingState){'Active'}else{'Idle'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Time</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Battery</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>State</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>RtkStatus</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Heading, 1)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.X, 3)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Y, 3)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.OdomConfidence, 3)</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>HeadingDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>XDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>YDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>OdomConfidenceDisplay</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>
@@ -129,6 +132,36 @@
       </TableControl>
     </View>
 
+    <!-- YarboEndpoint Table View (issue #30: Rover vs DC discovery) -->
+    <View>
+      <Name>YarboEndpoint</Name>
+      <ViewSelectedBy>
+        <TypeName>YarboEndpoint</TypeName>
+      </ViewSelectedBy>
+      <TableControl>
+        <TableHeaders>
+          <TableColumnHeader><Label>IPAddress</Label><Width>16</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Port</Label><Width>6</Width><Alignment>Right</Alignment></TableColumnHeader>
+          <TableColumnHeader><Label>Path</Label><Width>6</Width></TableColumnHeader>
+          <TableColumnHeader><Label>MacAddress</Label><Width>18</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Recommended</Label><Width>12</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Status</Label><Width>10</Width></TableColumnHeader>
+        </TableHeaders>
+        <TableRowEntries>
+          <TableRowEntry>
+            <TableColumnItems>
+              <TableColumnItem><PropertyName>IPAddress</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Port</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Path</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>MacAddress</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>RecommendedDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Status</PropertyName></TableColumnItem>
+            </TableColumnItems>
+          </TableRowEntry>
+        </TableRowEntries>
+      </TableControl>
+    </View>
+
     <!-- YarboPlan Table View -->
     <View>
       <Name>YarboPlan</Name>
@@ -147,8 +180,8 @@
             <TableColumnItems>
               <TableColumnItem><PropertyName>Id</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>$_.AreaIds -join ', '</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.EnableSelfOrder){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Areas</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>SelfOrder</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>
@@ -178,14 +211,11 @@
               <TableColumnItem><PropertyName>Id</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>PlanId</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.Enabled){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>
-                if ($_.WeekDay -eq 127) { 'Daily' }
-                else { ($_.GetDays() | ForEach-Object { $_.ToString().Substring(0,3) }) -join ',' }
-              </ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>EnabledDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Days</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>StartTime</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>EndTime</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.IsWeatherSchedule){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>WeatherDisplay</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>

diff --git a/src/PSYarbo/PSYarbo.Types.ps1xml b/src/PSYarbo/PSYarbo.Types.ps1xml
--- a/src/PSYarbo/PSYarbo.Types.ps1xml
+++ b/src/PSYarbo/PSYarbo.Types.ps1xml
@@ -11,13 +11,29 @@
   <Type>
     <Name>YarboRobot</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Battery</Name>
+        <GetScriptBlock>"$($this.BatteryCapacity)%"</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>State</Name>
+        <GetScriptBlock>if($this.WorkingState){'Active'}else{'Idle'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>HeadingDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Heading, 1)</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>SerialNumber</Name>
           <Name>Name</Name>
+          <Name>Battery</Name>
+          <Name>State</Name>
+          <Name>HeadingDisplay</Name>
           <Name>HeadType</Name>
           <Name>BatteryCapacity</Name>
+          <Name>BatteryStatus</Name>
           <Name>WorkingState</Name>
           <Name>ChargingStatus</Name>
           <Name>ErrorCode</Name>
@@ -48,19 +64,51 @@
     </Members>
   </Type>
 
-  <!-- YarboTelemetry: key telemetry fields -->
+  <!-- YarboTelemetry: key telemetry fields. Battery and State match YarboRobot for consistent display. -->
   <Type>
     <Name>YarboTelemetry</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Battery</Name>
+        <GetScriptBlock>"$($this.BatteryCapacity)%"</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>State</Name>
+        <GetScriptBlock>if($this.WorkingState){'Active'}else{'Idle'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>Time</Name>
+        <GetScriptBlock>$this.Timestamp.ToString('HH:mm:ss.ff')</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>HeadingDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Heading, 1)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>XDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.X, 3)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>YDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Y, 3)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>OdomConfidenceDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.OdomConfidence, 3)</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
+          <Name>Time</Name>
           <Name>Timestamp</Name>
           <Name>SerialNumber</Name>
+          <Name>Battery</Name>
+          <Name>State</Name>
           <Name>BatteryCapacity</Name>
           <Name>WorkingState</Name>
           <Name>RtkStatus</Name>
           <Name>Heading</Name>
+          <Name>HeadingDisplay</Name>
           <Name>X</Name>
           <Name>Y</Name>
           <Name>OdomConfidence</Name>
@@ -69,36 +117,61 @@
     </Members>
   </Type>
 
-  <!-- YarboPlan: key plan fields -->
+  <!-- YarboPlan: key plan fields. Areas and SelfOrder match table view. -->
   <Type>
     <Name>YarboPlan</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Areas</Name>
+        <GetScriptBlock>if($this.AreaIds){$this.AreaIds -join ', '}else{''}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>SelfOrder</Name>
+        <GetScriptBlock>if($this.EnableSelfOrder){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>Id</Name>
           <Name>Name</Name>
+          <Name>Areas</Name>
           <Name>AreaIds</Name>
+          <Name>SelfOrder</Name>
           <Name>EnableSelfOrder</Name>
         </ReferencedProperties>
       </PropertySet>
     </Members>
   </Type>
 
-  <!-- YarboSchedule: key schedule fields -->
+  <!-- YarboSchedule: key schedule fields. Days, EnabledDisplay, WeatherDisplay match table view. -->
   <Type>
     <Name>YarboSchedule</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Days</Name>
+        <GetScriptBlock>if($this.WeekDay -eq 127){'Daily'}else{($this.GetDays() | ForEach-Object{$_.ToString().Substring(0,3)}) -join ','}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>EnabledDisplay</Name>
+        <GetScriptBlock>if($this.Enabled){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>WeatherDisplay</Name>
+        <GetScriptBlock>if($this.IsWeatherSchedule){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>Id</Name>
           <Name>Name</Name>
           <Name>PlanId</Name>
+          <Name>EnabledDisplay</Name>
           <Name>Enabled</Name>
+          <Name>Days</Name>
           <Name>WeekDay</Name>
           <Name>StartTime</Name>
           <Name>EndTime</Name>
+          <Name>WeatherDisplay</Name>
           <Name>IsWeatherSchedule</Name>
         </ReferencedProperties>
       </PropertySet>
@@ -123,6 +196,29 @@
     </Members>
   </Type>
 
+  <!-- YarboEndpoint: discovery result. RecommendedDisplay matches table view. -->
+  <Type>
+    <Name>YarboEndpoint</Name>
+    <Members>
+      <ScriptProperty>
+        <Name>RecommendedDisplay</Name>
+        <GetScriptBlock>if($this.Recommended){'⭐ Yes'}else{''}</GetScriptBlock>
+      </ScriptProperty>
+      <PropertySet>
+        <Name>DefaultDisplayPropertySet</Name>
+        <ReferencedProperties>
+          <Name>IPAddress</Name>
+          <Name>Port</Name>
+          <Name>Path</Name>
+          <Name>MacAddress</Name>
+          <Name>RecommendedDisplay</Name>
+          <Name>Recommended</Name>
+          <Name>Status</Name>
+        </ReferencedProperties>
+      </PropertySet>
+    </Members>
+  </Type>
+
   <!-- YarboLightState: all LED channels -->
   <Type>
     <Name>YarboLightState</Name>

diff --git a/src/PSYarbo/PSYarbo.psd1 b/src/PSYarbo/PSYarbo.psd1
--- a/src/PSYarbo/PSYarbo.psd1
+++ b/src/PSYarbo/PSYarbo.psd1
@@ -10,11 +10,26 @@
     PowerShellVersion    = '7.4'
     # MQTTnet loaded via AssemblyLoadContext in PSYarbo.psm1 — not via RequiredAssemblies
     # RequiredAssemblies = @()
+    # Load class definitions before root module so [YarboRobot], [YarboConnection], etc. are visible to OutputType/attributes
+    ScriptsToProcess     = @(
+        'Classes/YarboExceptions.ps1'
+        'Classes/YarboLightState.ps1'
+        'Classes/YarboCommandResult.ps1'
+        'Classes/YarboTelemetry.ps1'
+        'Classes/YarboPlan.ps1'
+        'Classes/YarboSchedule.ps1'
+        'Classes/YarboRobot.ps1'
+        'Classes/YarboGlobalParams.ps1'
+        'Classes/YarboEndpoint.ps1'
+        'Classes/YarboConnection.ps1'
+        'Classes/YarboCloudSession.ps1'
+    )
     FormatsToProcess     = @('PSYarbo.Format.ps1xml')
-    TypesToProcess       = @('PSYarbo.Types.ps1xml')
+    # Types loaded in .psm1 with Update-TypeData -Force to avoid "already present" on re-import
+    # TypesToProcess       = @('PSYarbo.Types.ps1xml')
     FunctionsToExport    = @(
         # Connection
-        'Connect-Yarbo', 'Disconnect-Yarbo', 'Find-Yarbo',
+        'Connect-Yarbo', 'Disconnect-Yarbo', 'Find-Yarbo', 'Find-YarboDevice', 'Invoke-YarboMqttSniff',
         # Status
         'Get-YarboStatus', 'Get-YarboRobot', 'Get-YarboBattery', 'Get-YarboFirmware',
         'Get-YarboGlobalParams',

diff --git a/src/PSYarbo/PSYarbo.psm1 b/src/PSYarbo/PSYarbo.psm1
--- a/src/PSYarbo/PSYarbo.psm1
+++ b/src/PSYarbo/PSYarbo.psm1
@@ -16,37 +16,173 @@
 Set-StrictMode -Version Latest
 $ErrorActionPreference = 'Stop'
 
-#region — Load MQTTnet via isolated AssemblyLoadContext
+#region — Load MQTTnet and C# listener helper
 $libPath = Join-Path $PSScriptRoot 'lib'
 $mqttDllPath = Join-Path $libPath 'MQTTnet.dll'
 
 if (Test-Path $mqttDllPath) {
-    $alcName = 'PSYarboMqttContext'
-    $script:MqttALC = [System.Runtime.Loader.AssemblyLoadContext]::new($alcName, $true)
-    $script:MqttAssembly = $script:MqttALC.LoadFromAssemblyPath((Resolve-Path $mqttDllPath).Path)
-    Write-Verbose "PSYarbo: Loaded MQTTnet via isolated AssemblyLoadContext '$alcName'"
+    $script:MqttAssembly = [System.Reflection.Assembly]::LoadFrom((Resolve-Path $mqttDllPath).Path)
+    Write-Verbose "PSYarbo: Loaded MQTTnet from $mqttDllPath"
+    # C# helper: connects, subscribes, and receives messages entirely in C# so the MQTTnet
+    # ApplicationMessageReceivedAsync event is attached with += and always fires.
+    $listenerCode = @'
+using System;
+using System.Collections;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PSYarbo.Mqtt {
+    public sealed class YarboMqttListener : IDisposable {
+        private readonly MQTTnet.MqttFactory _factory;
+        private MQTTnet.Client.IMqttClient _client;
+        private readonly ArrayList _messages = ArrayList.Synchronized(new ArrayList());
+        private readonly TaskCompletionSource<ReceivedMessage> _firstMessage = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        public YarboMqttListener() {
+            _factory = new MQTTnet.MqttFactory();
+            _client = _factory.CreateMqttClient();
+            _client.ApplicationMessageReceivedAsync += e => {
+                var seg = e.ApplicationMessage.PayloadSegment;
+                int len = seg.Array != null ? seg.Count : 0;
+                byte[] copy = len > 0 ? new byte[len] : Array.Empty<byte>();
+                if (len > 0 && seg.Array != null)
+                    Buffer.BlockCopy(seg.Array, seg.Offset, copy, 0, len);
+                var msg = new ReceivedMessage(e.ApplicationMessage.Topic ?? "", copy);
+                _messages.Add(msg);
+                if (!_firstMessage.Task.IsCompleted)
+                    _firstMessage.TrySetResult(msg);
+                return Task.CompletedTask;
+            };
+        }
+
+        public void Connect(string broker, int port, string clientId, TimeSpan timeout) {
+            var opts = _factory.CreateClientOptionsBuilder()
+                .WithTcpServer(broker, port)
+                .WithClientId(clientId)
+                .WithTimeout(timeout)
+                .Build();
+            _client.ConnectAsync(opts, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public void Subscribe(string[] topicFilters) {
+            var builder = _factory.CreateSubscribeOptionsBuilder();
+            foreach (var topic in topicFilters)
+                builder = builder.WithTopicFilter(topic);
+            var opts = builder.Build();
+            _client.SubscribeAsync(opts, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public void Publish(string topic, byte[] payload) {
+            var msg = _factory.CreateApplicationMessageBuilder()
+                .WithTopic(topic)
+                .WithPayload(payload ?? Array.Empty<byte>())
+                .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtMostOnce)
+                .Build();
+            _client.PublishAsync(msg, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public ReceivedMessage WaitForFirstMessage(int timeoutMs) {
+            try {
+                if (_firstMessage.Task.IsCompleted)
+                    return _firstMessage.Task.Result;
+                return _firstMessage.Task.Wait(timeoutMs) ? _firstMessage.Task.Result : null;
+            } catch { return null; }
+        }
+
+        public ReceivedMessage[] GetReceivedMessages() {
+            var arr = new ReceivedMessage[_messages.Count];
+            _messages.CopyTo(arr);
+            _messages.Clear();
+            return arr;
+        }
+
+        public void Disconnect() {
+            try {
+                _client.DisconnectAsync(_factory.CreateClientDisconnectOptionsBuilder().Build(), CancellationToken.None).GetAwaiter().GetResult();
+            } catch { }
+        }
+
+        public void Dispose() {
+            Disconnect();
+            _client?.Dispose();
+            _client = null;
+        }
+
+        public sealed class ReceivedMessage {
+            public string Topic { get; }
+            public byte[] Payload { get; }
+            public ReceivedMessage(string topic, byte[] payload) { Topic = topic; Payload = payload ?? Array.Empty<byte>(); }
+        }
+    }
+}
+'@
+    try {
+        $runtimeDir = [System.Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory()
+        $refAssemblies = @($script:MqttAssembly)
+        foreach ($name in @('netstandard.dll', 'System.Runtime.dll', 'System.Net.Primitives.dll')) {
+            $path = Join-Path $runtimeDir $name
+            if (Test-Path $path) {
+                $refAssemblies += [System.Reflection.Assembly]::LoadFrom((Resolve-Path $path).Path)
+            }
+        }
+        Add-Type -ReferencedAssemblies $refAssemblies -TypeDefinition $listenerCode -ErrorAction Stop
+        Write-Verbose "PSYarbo: YarboMqttListener loaded"
+    } catch {
+        if ($_.Exception.Message -notmatch 'already exists') {
+            Write-Verbose "PSYarbo: YarboMqttListener failed to load: $($_.Exception.Message)"
+        }
+    }
+    # Legacy adapter (kept for Connect-Yarbo / Send-MqttCommand response path if still used)
+    $adapterCode = @'
+using System.Collections.Generic;
+using System.Threading.Tasks;
+namespace PSYarbo.Mqtt {
+    public static class MessageReceivedAdapter {
+        private static readonly Dictionary<object, System.Func<object, Task>> _callbacks = new Dictionary<object, System.Func<object, Task>>();
+        public static void RegisterCallback(object client, System.Func<object, Task> callback) {
+            lock (_callbacks) {
+                _callbacks[client] = callback;
+            }
+        }
+        public static void UnregisterCallback(object client) {
+            lock (_callbacks) {
+                _callbacks.Remove(client);
+            }
+        }
+        public static Task Handler(object sender, MQTTnet.Client.MqttApplicationMessageReceivedEventArgs ea) {
+            System.Func<object, Task> callback = null;
+            lock (_callbacks) {
+                _callbacks.TryGetValue(sender, out callback);
+            }
+            if (callback != null) return callback(ea);
+            return Task.CompletedTask;
+        }
+    }
+}
+'@
+    try {
+        Add-Type -ReferencedAssemblies $script:MqttAssembly -TypeDefinition $adapterCode -ErrorAction Stop
+    } catch {
+        Write-Verbose "PSYarbo: MessageReceivedAdapter already loaded or failed: $_"
... diff truncated: showing 800 of 2534 lines

Comment thread src/PSYarbo/Classes/YarboTelemetry.ps1
Comment thread src/PSYarbo/Public/Connection/Find-YarboDevice.ps1
Comment thread src/PSYarbo/Public/Connection/Find-Yarbo.ps1
cursoragent and others added 6 commits February 27, 2026 16:18
- Fix wireless charge voltage/current types in YarboTelemetry from [int] to [double] for consistency with YarboRobot and to prevent data loss
- Extract duplicated subnet scanning logic into shared Get-PSYarboSubnetIpList helper function
- Replace unsafe type literals with safe '-as [type]' pattern in Find-Yarbo and Find-YarboDevice to enable graceful fallback
…ceMSG)

- Watch-YarboTelemetry is already event-driven (TelemetryQueue, no get_device_msg polling)
- IncludeHeartbeat and IncludePlanFeedback already present
- Adds -Raw to emit raw decoded DeviceMSG instead of [YarboTelemetry]
Fixes #7

Made-with: Cursor
…ToDock

Send-YarboReturnToDock already implements cmd_recharge with SupportsShouldProcess,
-Connection, Assert-YarboController; exported and documented.
Fixes #8

Made-with: Cursor
…e-YarboConfig; Connect-YarboCloud reads Email from env/config

- Get-YarboConfig: optional -Path; support defaults.broker/port/serialNumber in config file
- Save-YarboConfig: write full config object to path
- Merge-YarboConfig: explicit > env > file > defaults
- Connect-YarboCloud: -Email optional; resolve from $env:YARBO_EMAIL or ~/.psyarbo/config.json
- Unit tests: YARBO_EMAIL resolution, Merge-YarboConfig priority
Fixes #9

Made-with: Cursor
…keyed)

- Get-YarboCloudCredential -Email returns @{ Password; RefreshToken }
- Save-YarboCloudCredential -Email -Password -RefreshToken stores keyed by email
- Connect-YarboCloud: -Password optional; when -Email only, try stored credentials
  (RefreshToken for token refresh, or Password for login); save credentials after login
- about_PSYarbo.help.txt documents SecretManagement and file fallback
Fixes #10

Made-with: Cursor
#31)

- Find-Yarbo / Find-YarboDevice: fallback subnet 192.168.0.0/23 -> 192.0.2.0/24; examples 192.0.2.0/24
- NetworkHelper: example CIDR 192.0.2.0/24
- README: discovery default description (no 192.168.1.0/24)
- Tests: broker/test IPs 192.168.x -> 192.0.2.x
- MqttSniff example: Broker <rover-ip>

Made-with: Cursor
Comment thread src/PSYarbo/Public/Cloud/Connect-YarboCloud.ps1 Outdated
Comment thread src/PSYarbo/Public/Connection/Find-Yarbo.ps1 Outdated
Comment thread src/PSYarbo/Private/ConfigManager.ps1
Comment thread src/PSYarbo/Private/ConfigManager.ps1 Outdated
Comment thread src/PSYarbo/Public/Connection/Connect-Yarbo.ps1
Changed MessageReceivedAdapter to create handlers with correct signature
Func<MqttApplicationMessageReceivedEventArgs, Task> instead of
Func<object, MqttApplicationMessageReceivedEventArgs, Task> to match
MQTTnet's ApplicationMessageReceivedAsync event expectations.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Type literal throws before custom error message executes
    • Changed from direct type literal to safe pattern using '-as [type]' operator to allow graceful error handling when type is unavailable.
Preview (3381ac6210)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -88,7 +88,7 @@
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -165,6 +165,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -89,6 +89,11 @@
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient -and [PSYarbo.Mqtt.MessageReceivedAdapter]) {
+                [PSYarbo.Mqtt.MessageReceivedAdapter]::UnregisterCallback($this.MqttClient)
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,16 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -24,25 +29,39 @@
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
     [string]$LedRegister
+    [int]$WirelessChargeState
     [double]$WirelessChargeVoltage
     [double]$WirelessChargeCurrent
+    [int]$WirelessChargeErrorCode
 
-    # Network
+    # Ultrasonic (ultrasonic_msg)
+    [int]$UltrasonicLeftFront
+    [int]$UltrasonicMiddle
+    [int]$UltrasonicRightFront
+
+    # Network (route_priority)
     [hashtable]$RoutePriority
 
-    # Timestamps
+    # Timestamps (device timestamp + our LastUpdated)
+    [double]$DeviceTimestamp
     [datetime]$LastUpdated
 
     # Connection reference (hidden)
     hidden [string]$Broker
     hidden [int]$Port
 
+    # Full DeviceMSG payload (all MQTT keys available)
+    hidden [PSCustomObject]$RawMessage
+
     [string] ToString() {
         $displayName = if ($this.Name) { $this.Name } else { $this.SerialNumber }
         return "$displayName ($($this.SerialNumber)) - Battery: $($this.BatteryCapacity)% - State: $(if($this.WorkingState){'Active'}else{'Idle'})"

diff --git a/src/PSYarbo/Classes/YarboTelemetry.ps1 b/src/PSYarbo/Classes/YarboTelemetry.ps1
--- a/src/PSYarbo/Classes/YarboTelemetry.ps1
+++ b/src/PSYarbo/Classes/YarboTelemetry.ps1
@@ -2,28 +2,33 @@
     [datetime]$Timestamp
     [string]$SerialNumber
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
     [bool]$BatteryTempError
+    [double]$BatteryTimestamp
 
-    # Position
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # Position (CombinedOdom, RTKMSG, combined_odom_confidence)
     [double]$X
     [double]$Y
     [double]$Phi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
     [double]$RtkDop
     [double]$OdomConfidence
 
-    # Running status
+    # Running status (RunningStatusMSG)
     [int]$ChuteAngle
     [int]$ChuteSteeringEngineInfo
     [double]$HeadGyroPitch
     [double]$HeadGyroRoll
     [int]$RainSensorData
 
-    # State flags
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -31,23 +36,30 @@
     [bool]$IsPaused
     [bool]$IsRecharging
 
-    # Sensors
+    # Sensors (ultrasonic_msg)
     [int]$UltrasonicLeftFront
     [int]$UltrasonicMiddle
     [int]$UltrasonicRightFront
 
-    # Wireless charging
+    # Wireless charging (wireless_recharge)
     [int]$WirelessChargeState
-    [int]$WirelessChargeVoltage
-    [int]$WirelessChargeCurrent
+    [double]$WirelessChargeVoltage
+    [double]$WirelessChargeCurrent
     [int]$WirelessChargeErrorCode
 
-    # GPS (parsed from rtk_base_data.rover.gngga NMEA sentence)
+    # LED (led)
+    [string]$LedRegister
+
+    # GPS (parsed from rtk_base_data.rover.gngga + raw)
     [nullable[double]]$Latitude
     [nullable[double]]$Longitude
     [nullable[double]]$Altitude
     [int]$FixQuality
+    [string]$GnggaRaw
 
+    # Device message timestamp (root timestamp)
+    [double]$DeviceTimestamp
+
     # Raw
     hidden [PSCustomObject]$RawMessage
 

diff --git a/src/PSYarbo/PSYarbo.Format.ps1xml b/src/PSYarbo/PSYarbo.Format.ps1xml
--- a/src/PSYarbo/PSYarbo.Format.ps1xml
+++ b/src/PSYarbo/PSYarbo.Format.ps1xml
@@ -23,10 +23,10 @@
             <TableColumnItems>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>SerialNumber</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>"$($_.BatteryCapacity)%"</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.WorkingState){'Active'}else{'Idle'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Battery</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>State</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>RtkStatus</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Heading, 1)</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>HeadingDisplay</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>ErrorCode</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
@@ -34,7 +34,7 @@
       </TableControl>
     </View>
 
-    <!-- YarboRobot List View -->
+    <!-- YarboRobot List View (Battery and State match table view; raw fields kept for scripting) -->
     <View>
       <Name>YarboRobotList</Name>
       <ViewSelectedBy>
@@ -46,6 +46,8 @@
             <ListItems>
               <ListItem><PropertyName>SerialNumber</PropertyName></ListItem>
               <ListItem><PropertyName>Name</PropertyName></ListItem>
+              <ListItem><PropertyName>Battery</PropertyName></ListItem>
+              <ListItem><PropertyName>State</PropertyName></ListItem>
               <ListItem><PropertyName>HeadType</PropertyName></ListItem>
               <ListItem><PropertyName>BatteryCapacity</PropertyName></ListItem>
               <ListItem><PropertyName>BatteryStatus</PropertyName></ListItem>
@@ -53,6 +55,7 @@
               <ListItem><PropertyName>ChargingStatus</PropertyName></ListItem>
               <ListItem><PropertyName>ErrorCode</PropertyName></ListItem>
               <ListItem><PropertyName>Heading</PropertyName></ListItem>
+              <ListItem><PropertyName>HeadingDisplay</PropertyName></ListItem>
               <ListItem><PropertyName>RtkStatus</PropertyName></ListItem>
               <ListItem><PropertyName>OdomConfidence</PropertyName></ListItem>
               <ListItem><PropertyName>ChuteAngle</PropertyName></ListItem>
@@ -83,14 +86,14 @@
         <TableRowEntries>
           <TableRowEntry>
             <TableColumnItems>
-              <TableColumnItem><ScriptBlock>$_.Timestamp.ToString('HH:mm:ss.ff')</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>"$($_.BatteryCapacity)%"</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.WorkingState){'Active'}else{'Idle'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Time</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Battery</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>State</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>RtkStatus</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Heading, 1)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.X, 3)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Y, 3)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.OdomConfidence, 3)</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>HeadingDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>XDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>YDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>OdomConfidenceDisplay</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>
@@ -129,6 +132,36 @@
       </TableControl>
     </View>
 
+    <!-- YarboEndpoint Table View (issue #30: Rover vs DC discovery) -->
+    <View>
+      <Name>YarboEndpoint</Name>
+      <ViewSelectedBy>
+        <TypeName>YarboEndpoint</TypeName>
+      </ViewSelectedBy>
+      <TableControl>
+        <TableHeaders>
+          <TableColumnHeader><Label>IPAddress</Label><Width>16</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Port</Label><Width>6</Width><Alignment>Right</Alignment></TableColumnHeader>
+          <TableColumnHeader><Label>Path</Label><Width>6</Width></TableColumnHeader>
+          <TableColumnHeader><Label>MacAddress</Label><Width>18</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Recommended</Label><Width>12</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Status</Label><Width>10</Width></TableColumnHeader>
+        </TableHeaders>
+        <TableRowEntries>
+          <TableRowEntry>
+            <TableColumnItems>
+              <TableColumnItem><PropertyName>IPAddress</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Port</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Path</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>MacAddress</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>RecommendedDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Status</PropertyName></TableColumnItem>
+            </TableColumnItems>
+          </TableRowEntry>
+        </TableRowEntries>
+      </TableControl>
+    </View>
+
     <!-- YarboPlan Table View -->
     <View>
       <Name>YarboPlan</Name>
@@ -147,8 +180,8 @@
             <TableColumnItems>
               <TableColumnItem><PropertyName>Id</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>$_.AreaIds -join ', '</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.EnableSelfOrder){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Areas</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>SelfOrder</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>
@@ -178,14 +211,11 @@
               <TableColumnItem><PropertyName>Id</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>PlanId</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.Enabled){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>
-                if ($_.WeekDay -eq 127) { 'Daily' }
-                else { ($_.GetDays() | ForEach-Object { $_.ToString().Substring(0,3) }) -join ',' }
-              </ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>EnabledDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Days</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>StartTime</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>EndTime</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.IsWeatherSchedule){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>WeatherDisplay</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>

diff --git a/src/PSYarbo/PSYarbo.Types.ps1xml b/src/PSYarbo/PSYarbo.Types.ps1xml
--- a/src/PSYarbo/PSYarbo.Types.ps1xml
+++ b/src/PSYarbo/PSYarbo.Types.ps1xml
@@ -11,13 +11,29 @@
   <Type>
     <Name>YarboRobot</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Battery</Name>
+        <GetScriptBlock>"$($this.BatteryCapacity)%"</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>State</Name>
+        <GetScriptBlock>if($this.WorkingState){'Active'}else{'Idle'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>HeadingDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Heading, 1)</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>SerialNumber</Name>
           <Name>Name</Name>
+          <Name>Battery</Name>
+          <Name>State</Name>
+          <Name>HeadingDisplay</Name>
           <Name>HeadType</Name>
           <Name>BatteryCapacity</Name>
+          <Name>BatteryStatus</Name>
           <Name>WorkingState</Name>
           <Name>ChargingStatus</Name>
           <Name>ErrorCode</Name>
@@ -48,19 +64,51 @@
     </Members>
   </Type>
 
-  <!-- YarboTelemetry: key telemetry fields -->
+  <!-- YarboTelemetry: key telemetry fields. Battery and State match YarboRobot for consistent display. -->
   <Type>
     <Name>YarboTelemetry</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Battery</Name>
+        <GetScriptBlock>"$($this.BatteryCapacity)%"</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>State</Name>
+        <GetScriptBlock>if($this.WorkingState){'Active'}else{'Idle'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>Time</Name>
+        <GetScriptBlock>$this.Timestamp.ToString('HH:mm:ss.ff')</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>HeadingDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Heading, 1)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>XDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.X, 3)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>YDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Y, 3)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>OdomConfidenceDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.OdomConfidence, 3)</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
+          <Name>Time</Name>
           <Name>Timestamp</Name>
           <Name>SerialNumber</Name>
+          <Name>Battery</Name>
+          <Name>State</Name>
           <Name>BatteryCapacity</Name>
           <Name>WorkingState</Name>
           <Name>RtkStatus</Name>
           <Name>Heading</Name>
+          <Name>HeadingDisplay</Name>
           <Name>X</Name>
           <Name>Y</Name>
           <Name>OdomConfidence</Name>
@@ -69,36 +117,61 @@
     </Members>
   </Type>
 
-  <!-- YarboPlan: key plan fields -->
+  <!-- YarboPlan: key plan fields. Areas and SelfOrder match table view. -->
   <Type>
     <Name>YarboPlan</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Areas</Name>
+        <GetScriptBlock>if($this.AreaIds){$this.AreaIds -join ', '}else{''}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>SelfOrder</Name>
+        <GetScriptBlock>if($this.EnableSelfOrder){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>Id</Name>
           <Name>Name</Name>
+          <Name>Areas</Name>
           <Name>AreaIds</Name>
+          <Name>SelfOrder</Name>
           <Name>EnableSelfOrder</Name>
         </ReferencedProperties>
       </PropertySet>
     </Members>
   </Type>
 
-  <!-- YarboSchedule: key schedule fields -->
+  <!-- YarboSchedule: key schedule fields. Days, EnabledDisplay, WeatherDisplay match table view. -->
   <Type>
     <Name>YarboSchedule</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Days</Name>
+        <GetScriptBlock>if($this.WeekDay -eq 127){'Daily'}else{($this.GetDays() | ForEach-Object{$_.ToString().Substring(0,3)}) -join ','}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>EnabledDisplay</Name>
+        <GetScriptBlock>if($this.Enabled){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>WeatherDisplay</Name>
+        <GetScriptBlock>if($this.IsWeatherSchedule){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>Id</Name>
           <Name>Name</Name>
           <Name>PlanId</Name>
+          <Name>EnabledDisplay</Name>
           <Name>Enabled</Name>
+          <Name>Days</Name>
           <Name>WeekDay</Name>
           <Name>StartTime</Name>
           <Name>EndTime</Name>
+          <Name>WeatherDisplay</Name>
           <Name>IsWeatherSchedule</Name>
         </ReferencedProperties>
       </PropertySet>
@@ -123,6 +196,29 @@
     </Members>
   </Type>
 
+  <!-- YarboEndpoint: discovery result. RecommendedDisplay matches table view. -->
+  <Type>
+    <Name>YarboEndpoint</Name>
+    <Members>
+      <ScriptProperty>
+        <Name>RecommendedDisplay</Name>
+        <GetScriptBlock>if($this.Recommended){'⭐ Yes'}else{''}</GetScriptBlock>
+      </ScriptProperty>
+      <PropertySet>
+        <Name>DefaultDisplayPropertySet</Name>
+        <ReferencedProperties>
+          <Name>IPAddress</Name>
+          <Name>Port</Name>
+          <Name>Path</Name>
+          <Name>MacAddress</Name>
+          <Name>RecommendedDisplay</Name>
+          <Name>Recommended</Name>
+          <Name>Status</Name>
+        </ReferencedProperties>
+      </PropertySet>
+    </Members>
+  </Type>
+
   <!-- YarboLightState: all LED channels -->
   <Type>
     <Name>YarboLightState</Name>

diff --git a/src/PSYarbo/PSYarbo.psd1 b/src/PSYarbo/PSYarbo.psd1
--- a/src/PSYarbo/PSYarbo.psd1
+++ b/src/PSYarbo/PSYarbo.psd1
@@ -10,11 +10,26 @@
     PowerShellVersion    = '7.4'
     # MQTTnet loaded via AssemblyLoadContext in PSYarbo.psm1 — not via RequiredAssemblies
     # RequiredAssemblies = @()
+    # Load class definitions before root module so [YarboRobot], [YarboConnection], etc. are visible to OutputType/attributes
+    ScriptsToProcess     = @(
+        'Classes/YarboExceptions.ps1'
+        'Classes/YarboLightState.ps1'
+        'Classes/YarboCommandResult.ps1'
+        'Classes/YarboTelemetry.ps1'
+        'Classes/YarboPlan.ps1'
+        'Classes/YarboSchedule.ps1'
+        'Classes/YarboRobot.ps1'
+        'Classes/YarboGlobalParams.ps1'
+        'Classes/YarboEndpoint.ps1'
+        'Classes/YarboConnection.ps1'
+        'Classes/YarboCloudSession.ps1'
+    )
     FormatsToProcess     = @('PSYarbo.Format.ps1xml')
-    TypesToProcess       = @('PSYarbo.Types.ps1xml')
+    # Types loaded in .psm1 with Update-TypeData -Force to avoid "already present" on re-import
+    # TypesToProcess       = @('PSYarbo.Types.ps1xml')
     FunctionsToExport    = @(
         # Connection
-        'Connect-Yarbo', 'Disconnect-Yarbo', 'Find-Yarbo',
+        'Connect-Yarbo', 'Disconnect-Yarbo', 'Find-Yarbo', 'Find-YarboDevice', 'Invoke-YarboMqttSniff',
         # Status
         'Get-YarboStatus', 'Get-YarboRobot', 'Get-YarboBattery', 'Get-YarboFirmware',
         'Get-YarboGlobalParams',
@@ -41,7 +56,7 @@
     )
     CmdletsToExport      = @()
     VariablesToExport    = @()
-    AliasesToExport      = @('Return-YarboToDock')
+    AliasesToExport      = @('Return-YarboToDock', 'Start-YarboRecharge')
     PrivateData          = @{
         PSData = @{
             Tags         = @('Yarbo', 'Robot', 'Mower', 'MQTT', 'IoT', 'Automation', 'SnowBlower')

diff --git a/src/PSYarbo/PSYarbo.psm1 b/src/PSYarbo/PSYarbo.psm1
--- a/src/PSYarbo/PSYarbo.psm1
+++ b/src/PSYarbo/PSYarbo.psm1
@@ -16,37 +16,182 @@
 Set-StrictMode -Version Latest
 $ErrorActionPreference = 'Stop'
 
-#region — Load MQTTnet via isolated AssemblyLoadContext
+#region — Load MQTTnet and C# listener helper
 $libPath = Join-Path $PSScriptRoot 'lib'
 $mqttDllPath = Join-Path $libPath 'MQTTnet.dll'
 
 if (Test-Path $mqttDllPath) {
-    $alcName = 'PSYarboMqttContext'
-    $script:MqttALC = [System.Runtime.Loader.AssemblyLoadContext]::new($alcName, $true)
-    $script:MqttAssembly = $script:MqttALC.LoadFromAssemblyPath((Resolve-Path $mqttDllPath).Path)
-    Write-Verbose "PSYarbo: Loaded MQTTnet via isolated AssemblyLoadContext '$alcName'"
+    $script:MqttAssembly = [System.Reflection.Assembly]::LoadFrom((Resolve-Path $mqttDllPath).Path)
+    Write-Verbose "PSYarbo: Loaded MQTTnet from $mqttDllPath"
+    # C# helper: connects, subscribes, and receives messages entirely in C# so the MQTTnet
+    # ApplicationMessageReceivedAsync event is attached with += and always fires.
+    $listenerCode = @'
+using System;
+using System.Collections;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PSYarbo.Mqtt {
+    public sealed class YarboMqttListener : IDisposable {
+        private readonly MQTTnet.MqttFactory _factory;
+        private MQTTnet.Client.IMqttClient _client;
+        private readonly ArrayList _messages = ArrayList.Synchronized(new ArrayList());
+        private readonly TaskCompletionSource<ReceivedMessage> _firstMessage = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        public YarboMqttListener() {
+            _factory = new MQTTnet.MqttFactory();
+            _client = _factory.CreateMqttClient();
+            _client.ApplicationMessageReceivedAsync += e => {
+                var seg = e.ApplicationMessage.PayloadSegment;
+                int len = seg.Array != null ? seg.Count : 0;
+                byte[] copy = len > 0 ? new byte[len] : Array.Empty<byte>();
+                if (len > 0 && seg.Array != null)
+                    Buffer.BlockCopy(seg.Array, seg.Offset, copy, 0, len);
+                var msg = new ReceivedMessage(e.ApplicationMessage.Topic ?? "", copy);
+                _messages.Add(msg);
+                if (!_firstMessage.Task.IsCompleted)
+                    _firstMessage.TrySetResult(msg);
+                return Task.CompletedTask;
+            };
+        }
+
+        public void Connect(string broker, int port, string clientId, TimeSpan timeout) {
+            var opts = _factory.CreateClientOptionsBuilder()
+                .WithTcpServer(broker, port)
+                .WithClientId(clientId)
+                .WithTimeout(timeout)
+                .Build();
+            _client.ConnectAsync(opts, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public void Subscribe(string[] topicFilters) {
+            var builder = _factory.CreateSubscribeOptionsBuilder();
+            foreach (var topic in topicFilters)
+                builder = builder.WithTopicFilter(topic);
+            var opts = builder.Build();
+            _client.SubscribeAsync(opts, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public void Publish(string topic, byte[] payload) {
+            var msg = _factory.CreateApplicationMessageBuilder()
+                .WithTopic(topic)
+                .WithPayload(payload ?? Array.Empty<byte>())
+                .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtMostOnce)
+                .Build();
+            _client.PublishAsync(msg, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public ReceivedMessage WaitForFirstMessage(int timeoutMs) {
+            try {
+                if (_firstMessage.Task.IsCompleted)
+                    return _firstMessage.Task.Result;
+                return _firstMessage.Task.Wait(timeoutMs) ? _firstMessage.Task.Result : null;
+            } catch { return null; }
+        }
+
+        public ReceivedMessage[] GetReceivedMessages() {
+            var arr = new ReceivedMessage[_messages.Count];
+            _messages.CopyTo(arr);
+            _messages.Clear();
+            return arr;
+        }
+
+        public void Disconnect() {
+            try {
+                _client.DisconnectAsync(_factory.CreateClientDisconnectOptionsBuilder().Build(), CancellationToken.None).GetAwaiter().GetResult();
+            } catch { }
+        }
+
+        public void Dispose() {
+            Disconnect();
+            _client?.Dispose();
+            _client = null;
+        }
+
+        public sealed class ReceivedMessage {
+            public string Topic { get; }
+            public byte[] Payload { get; }
+            public ReceivedMessage(string topic, byte[] payload) { Topic = topic; Payload = payload ?? Array.Empty<byte>(); }
+        }
+    }
+}
+'@
+    try {
+        $runtimeDir = [System.Runtime.InteropServices.RuntimeEnvironment]::GetRuntimeDirectory()
+        $refAssemblies = @($script:MqttAssembly)
+        foreach ($name in @('netstandard.dll', 'System.Runtime.dll', 'System.Net.Primitives.dll')) {
+            $path = Join-Path $runtimeDir $name
+            if (Test-Path $path) {
+                $refAssemblies += [System.Reflection.Assembly]::LoadFrom((Resolve-Path $path).Path)
+            }
+        }
+        Add-Type -ReferencedAssemblies $refAssemblies -TypeDefinition $listenerCode -ErrorAction Stop
+        Write-Verbose "PSYarbo: YarboMqttListener loaded"
+    } catch {
+        if ($_.Exception.Message -notmatch 'already exists') {
+            Write-Verbose "PSYarbo: YarboMqttListener failed to load: $($_.Exception.Message)"
+        }
+    }
+    # Legacy adapter (kept for Connect-Yarbo / Send-MqttCommand response path if still used)
+    $adapterCode = @'
+using System.Collections.Generic;
+using System.Threading.Tasks;
+namespace PSYarbo.Mqtt {
+    public static class MessageReceivedAdapter {
+        private static readonly Dictionary<object, System.Func<object, Task>> _callbacks = new Dictionary<object, System.Func<object, Task>>();
+        private static readonly Dictionary<object, System.Func<MQTTnet.Client.MqttApplicationMessageReceivedEventArgs, Task>> _handlers = new Dictionary<object, System.Func<MQTTnet.Client.MqttApplicationMessageReceivedEventArgs, Task>>();
+        public static void RegisterCallback(object client, System.Func<object, Task> callback) {
+            lock (_callbacks) {
+                _callbacks[client] = callback;
+                _handlers[client] = ea => {
+                    System.Func<object, Task> cb = null;
+                    lock (_callbacks) {
+                        _callbacks.TryGetValue(client, out cb);
+                    }
+                    if (cb != null) return cb(ea);
+                    return Task.CompletedTask;
+                };
+            }
+        }
+        public static void UnregisterCallback(object client) {
+            lock (_callbacks) {
... diff truncated: showing 800 of 2901 lines

Comment thread src/PSYarbo/Public/Connection/Invoke-YarboMqttSniff.ps1
cursoragent and others added 2 commits February 27, 2026 17:20
…MqttSniff

Changed from direct type literal [PSYarbo.Mqtt.YarboMqttListener] to safe
pattern 'PSYarbo.Mqtt.YarboMqttListener' -as [type] to allow graceful error
handling when type is not available. This matches the pattern used in
Find-Yarbo and Find-YarboDevice functions.
…ixes #11)

- MockMqttClient: document §8.3 (PublishedMessages, SimulateResponse)
- Fixtures: read_schedules, read_global_params, get_connect_wifi_name;
  cloud-api-responses (login, getUserRobotBindVos, getLatestPubVersion)
- Unit: ZlibCodec, ErrorHelper, YarboSchedule, TelemetryParser, ControllerGuard
- Integration (mocked): Get-YarboStatus, Get-YarboPlan (Tag Mocked)
- CI: run Unit, Integration (exclude real-robot tag), root tests separately;
  install MQTTnet so module loads in tests

Made-with: Cursor
Comment thread tests/Integration/Get-YarboStatus.Tests.ps1 Outdated
Comment thread .github/workflows/ci.yml Outdated
- Fix Battery assertion in Get-YarboStatus.Tests.ps1 to expect '83%' string instead of 83 integer
- Set Run.Exit = true for unit and integration test steps in CI to properly fail on test failures
Comment thread .github/workflows/ci.yml Outdated
Comment thread tests/PSYarbo.Tests.ps1 Outdated
Comment thread src/PSYarbo/Private/ArpHelper.ps1 Outdated
- Remove invalid Run.ExitCode property from Pester configuration (lines 87, 102 in ci.yml)
- Add Invoke-YarboMqttSniff to expected exports test list
- Fix ARP IP lookup to use word boundaries preventing prefix matches (e.g., 192.168.1.1 vs 192.168.1.10)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Handler delegate never removed from MQTTnet event on unregister
    • Added remove_ApplicationMessageReceivedAsync call in YarboConnection.Dispose() to symmetrically detach the event handler before calling UnregisterCallback.
Preview (eedf378b5e)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,20 +72,53 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $true
+          $intConfig.Filter.ExcludeTag     = 'Integration'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          $intResult = Invoke-Pester -Configuration $intConfig
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -88,7 +88,7 @@
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -165,6 +165,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -89,6 +89,15 @@
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient -and [PSYarbo.Mqtt.MessageReceivedAdapter]) {
+                $handlerDelegate = [PSYarbo.Mqtt.MessageReceivedAdapter]::GetHandler($this.MqttClient)
+                if ($null -ne $handlerDelegate) {
+                    $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                }
+                [PSYarbo.Mqtt.MessageReceivedAdapter]::UnregisterCallback($this.MqttClient)
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,16 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -24,25 +29,39 @@
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
     [string]$LedRegister
+    [int]$WirelessChargeState
     [double]$WirelessChargeVoltage
     [double]$WirelessChargeCurrent
+    [int]$WirelessChargeErrorCode
 
-    # Network
+    # Ultrasonic (ultrasonic_msg)
+    [int]$UltrasonicLeftFront
+    [int]$UltrasonicMiddle
+    [int]$UltrasonicRightFront
+
+    # Network (route_priority)
     [hashtable]$RoutePriority
 
-    # Timestamps
+    # Timestamps (device timestamp + our LastUpdated)
+    [double]$DeviceTimestamp
     [datetime]$LastUpdated
 
     # Connection reference (hidden)
     hidden [string]$Broker
     hidden [int]$Port
 
+    # Full DeviceMSG payload (all MQTT keys available)
+    hidden [PSCustomObject]$RawMessage
+
     [string] ToString() {
         $displayName = if ($this.Name) { $this.Name } else { $this.SerialNumber }
         return "$displayName ($($this.SerialNumber)) - Battery: $($this.BatteryCapacity)% - State: $(if($this.WorkingState){'Active'}else{'Idle'})"

diff --git a/src/PSYarbo/Classes/YarboTelemetry.ps1 b/src/PSYarbo/Classes/YarboTelemetry.ps1
--- a/src/PSYarbo/Classes/YarboTelemetry.ps1
+++ b/src/PSYarbo/Classes/YarboTelemetry.ps1
@@ -2,28 +2,33 @@
     [datetime]$Timestamp
     [string]$SerialNumber
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
     [bool]$BatteryTempError
+    [double]$BatteryTimestamp
 
-    # Position
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # Position (CombinedOdom, RTKMSG, combined_odom_confidence)
     [double]$X
     [double]$Y
     [double]$Phi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
     [double]$RtkDop
     [double]$OdomConfidence
 
-    # Running status
+    # Running status (RunningStatusMSG)
     [int]$ChuteAngle
     [int]$ChuteSteeringEngineInfo
     [double]$HeadGyroPitch
     [double]$HeadGyroRoll
     [int]$RainSensorData
 
-    # State flags
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -31,23 +36,30 @@
     [bool]$IsPaused
     [bool]$IsRecharging
 
-    # Sensors
+    # Sensors (ultrasonic_msg)
     [int]$UltrasonicLeftFront
     [int]$UltrasonicMiddle
     [int]$UltrasonicRightFront
 
-    # Wireless charging
+    # Wireless charging (wireless_recharge)
     [int]$WirelessChargeState
-    [int]$WirelessChargeVoltage
-    [int]$WirelessChargeCurrent
+    [double]$WirelessChargeVoltage
+    [double]$WirelessChargeCurrent
     [int]$WirelessChargeErrorCode
 
-    # GPS (parsed from rtk_base_data.rover.gngga NMEA sentence)
+    # LED (led)
+    [string]$LedRegister
+
+    # GPS (parsed from rtk_base_data.rover.gngga + raw)
     [nullable[double]]$Latitude
     [nullable[double]]$Longitude
     [nullable[double]]$Altitude
     [int]$FixQuality
+    [string]$GnggaRaw
 
+    # Device message timestamp (root timestamp)
+    [double]$DeviceTimestamp
+
     # Raw
     hidden [PSCustomObject]$RawMessage
 

diff --git a/src/PSYarbo/PSYarbo.Format.ps1xml b/src/PSYarbo/PSYarbo.Format.ps1xml
--- a/src/PSYarbo/PSYarbo.Format.ps1xml
+++ b/src/PSYarbo/PSYarbo.Format.ps1xml
@@ -23,10 +23,10 @@
             <TableColumnItems>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>SerialNumber</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>"$($_.BatteryCapacity)%"</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.WorkingState){'Active'}else{'Idle'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Battery</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>State</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>RtkStatus</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Heading, 1)</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>HeadingDisplay</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>ErrorCode</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
@@ -34,7 +34,7 @@
       </TableControl>
     </View>
 
-    <!-- YarboRobot List View -->
+    <!-- YarboRobot List View (Battery and State match table view; raw fields kept for scripting) -->
     <View>
       <Name>YarboRobotList</Name>
       <ViewSelectedBy>
@@ -46,6 +46,8 @@
             <ListItems>
               <ListItem><PropertyName>SerialNumber</PropertyName></ListItem>
               <ListItem><PropertyName>Name</PropertyName></ListItem>
+              <ListItem><PropertyName>Battery</PropertyName></ListItem>
+              <ListItem><PropertyName>State</PropertyName></ListItem>
               <ListItem><PropertyName>HeadType</PropertyName></ListItem>
               <ListItem><PropertyName>BatteryCapacity</PropertyName></ListItem>
               <ListItem><PropertyName>BatteryStatus</PropertyName></ListItem>
@@ -53,6 +55,7 @@
               <ListItem><PropertyName>ChargingStatus</PropertyName></ListItem>
               <ListItem><PropertyName>ErrorCode</PropertyName></ListItem>
               <ListItem><PropertyName>Heading</PropertyName></ListItem>
+              <ListItem><PropertyName>HeadingDisplay</PropertyName></ListItem>
               <ListItem><PropertyName>RtkStatus</PropertyName></ListItem>
               <ListItem><PropertyName>OdomConfidence</PropertyName></ListItem>
               <ListItem><PropertyName>ChuteAngle</PropertyName></ListItem>
@@ -83,14 +86,14 @@
         <TableRowEntries>
           <TableRowEntry>
             <TableColumnItems>
-              <TableColumnItem><ScriptBlock>$_.Timestamp.ToString('HH:mm:ss.ff')</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>"$($_.BatteryCapacity)%"</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.WorkingState){'Active'}else{'Idle'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Time</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Battery</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>State</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>RtkStatus</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Heading, 1)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.X, 3)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.Y, 3)</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>[math]::Round($_.OdomConfidence, 3)</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>HeadingDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>XDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>YDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>OdomConfidenceDisplay</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>
@@ -129,6 +132,36 @@
       </TableControl>
     </View>
 
+    <!-- YarboEndpoint Table View (issue #30: Rover vs DC discovery) -->
+    <View>
+      <Name>YarboEndpoint</Name>
+      <ViewSelectedBy>
+        <TypeName>YarboEndpoint</TypeName>
+      </ViewSelectedBy>
+      <TableControl>
+        <TableHeaders>
+          <TableColumnHeader><Label>IPAddress</Label><Width>16</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Port</Label><Width>6</Width><Alignment>Right</Alignment></TableColumnHeader>
+          <TableColumnHeader><Label>Path</Label><Width>6</Width></TableColumnHeader>
+          <TableColumnHeader><Label>MacAddress</Label><Width>18</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Recommended</Label><Width>12</Width></TableColumnHeader>
+          <TableColumnHeader><Label>Status</Label><Width>10</Width></TableColumnHeader>
+        </TableHeaders>
+        <TableRowEntries>
+          <TableRowEntry>
+            <TableColumnItems>
+              <TableColumnItem><PropertyName>IPAddress</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Port</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Path</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>MacAddress</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>RecommendedDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Status</PropertyName></TableColumnItem>
+            </TableColumnItems>
+          </TableRowEntry>
+        </TableRowEntries>
+      </TableControl>
+    </View>
+
     <!-- YarboPlan Table View -->
     <View>
       <Name>YarboPlan</Name>
@@ -147,8 +180,8 @@
             <TableColumnItems>
               <TableColumnItem><PropertyName>Id</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>$_.AreaIds -join ', '</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.EnableSelfOrder){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>Areas</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>SelfOrder</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>
@@ -178,14 +211,11 @@
               <TableColumnItem><PropertyName>Id</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>Name</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>PlanId</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.Enabled){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
-              <TableColumnItem><ScriptBlock>
-                if ($_.WeekDay -eq 127) { 'Daily' }
-                else { ($_.GetDays() | ForEach-Object { $_.ToString().Substring(0,3) }) -join ',' }
-              </ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>EnabledDisplay</PropertyName></TableColumnItem>
+              <TableColumnItem><PropertyName>Days</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>StartTime</PropertyName></TableColumnItem>
               <TableColumnItem><PropertyName>EndTime</PropertyName></TableColumnItem>
-              <TableColumnItem><ScriptBlock>if($_.IsWeatherSchedule){'✓'}else{'✗'}</ScriptBlock></TableColumnItem>
+              <TableColumnItem><PropertyName>WeatherDisplay</PropertyName></TableColumnItem>
             </TableColumnItems>
           </TableRowEntry>
         </TableRowEntries>

diff --git a/src/PSYarbo/PSYarbo.Types.ps1xml b/src/PSYarbo/PSYarbo.Types.ps1xml
--- a/src/PSYarbo/PSYarbo.Types.ps1xml
+++ b/src/PSYarbo/PSYarbo.Types.ps1xml
@@ -11,13 +11,29 @@
   <Type>
     <Name>YarboRobot</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Battery</Name>
+        <GetScriptBlock>"$($this.BatteryCapacity)%"</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>State</Name>
+        <GetScriptBlock>if($this.WorkingState){'Active'}else{'Idle'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>HeadingDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Heading, 1)</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>SerialNumber</Name>
           <Name>Name</Name>
+          <Name>Battery</Name>
+          <Name>State</Name>
+          <Name>HeadingDisplay</Name>
           <Name>HeadType</Name>
           <Name>BatteryCapacity</Name>
+          <Name>BatteryStatus</Name>
           <Name>WorkingState</Name>
           <Name>ChargingStatus</Name>
           <Name>ErrorCode</Name>
@@ -48,19 +64,51 @@
     </Members>
   </Type>
 
-  <!-- YarboTelemetry: key telemetry fields -->
+  <!-- YarboTelemetry: key telemetry fields. Battery and State match YarboRobot for consistent display. -->
   <Type>
     <Name>YarboTelemetry</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Battery</Name>
+        <GetScriptBlock>"$($this.BatteryCapacity)%"</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>State</Name>
+        <GetScriptBlock>if($this.WorkingState){'Active'}else{'Idle'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>Time</Name>
+        <GetScriptBlock>$this.Timestamp.ToString('HH:mm:ss.ff')</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>HeadingDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Heading, 1)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>XDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.X, 3)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>YDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.Y, 3)</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>OdomConfidenceDisplay</Name>
+        <GetScriptBlock>[math]::Round($this.OdomConfidence, 3)</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
+          <Name>Time</Name>
           <Name>Timestamp</Name>
           <Name>SerialNumber</Name>
+          <Name>Battery</Name>
+          <Name>State</Name>
           <Name>BatteryCapacity</Name>
           <Name>WorkingState</Name>
           <Name>RtkStatus</Name>
           <Name>Heading</Name>
+          <Name>HeadingDisplay</Name>
           <Name>X</Name>
           <Name>Y</Name>
           <Name>OdomConfidence</Name>
@@ -69,36 +117,61 @@
     </Members>
   </Type>
 
-  <!-- YarboPlan: key plan fields -->
+  <!-- YarboPlan: key plan fields. Areas and SelfOrder match table view. -->
   <Type>
     <Name>YarboPlan</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Areas</Name>
+        <GetScriptBlock>if($this.AreaIds){$this.AreaIds -join ', '}else{''}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>SelfOrder</Name>
+        <GetScriptBlock>if($this.EnableSelfOrder){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>Id</Name>
           <Name>Name</Name>
+          <Name>Areas</Name>
           <Name>AreaIds</Name>
+          <Name>SelfOrder</Name>
           <Name>EnableSelfOrder</Name>
         </ReferencedProperties>
       </PropertySet>
     </Members>
   </Type>
 
-  <!-- YarboSchedule: key schedule fields -->
+  <!-- YarboSchedule: key schedule fields. Days, EnabledDisplay, WeatherDisplay match table view. -->
   <Type>
     <Name>YarboSchedule</Name>
     <Members>
+      <ScriptProperty>
+        <Name>Days</Name>
+        <GetScriptBlock>if($this.WeekDay -eq 127){'Daily'}else{($this.GetDays() | ForEach-Object{$_.ToString().Substring(0,3)}) -join ','}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>EnabledDisplay</Name>
+        <GetScriptBlock>if($this.Enabled){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
+      <ScriptProperty>
+        <Name>WeatherDisplay</Name>
+        <GetScriptBlock>if($this.IsWeatherSchedule){'✓'}else{'✗'}</GetScriptBlock>
+      </ScriptProperty>
       <PropertySet>
         <Name>DefaultDisplayPropertySet</Name>
         <ReferencedProperties>
           <Name>Id</Name>
           <Name>Name</Name>
           <Name>PlanId</Name>
+          <Name>EnabledDisplay</Name>
           <Name>Enabled</Name>
+          <Name>Days</Name>
           <Name>WeekDay</Name>
           <Name>StartTime</Name>
           <Name>EndTime</Name>
+          <Name>WeatherDisplay</Name>
           <Name>IsWeatherSchedule</Name>
         </ReferencedProperties>
       </PropertySet>
@@ -123,6 +196,29 @@
     </Members>
   </Type>
 
+  <!-- YarboEndpoint: discovery result. RecommendedDisplay matches table view. -->
+  <Type>
+    <Name>YarboEndpoint</Name>
+    <Members>
+      <ScriptProperty>
+        <Name>RecommendedDisplay</Name>
+        <GetScriptBlock>if($this.Recommended){'⭐ Yes'}else{''}</GetScriptBlock>
+      </ScriptProperty>
+      <PropertySet>
+        <Name>DefaultDisplayPropertySet</Name>
+        <ReferencedProperties>
+          <Name>IPAddress</Name>
+          <Name>Port</Name>
+          <Name>Path</Name>
+          <Name>MacAddress</Name>
+          <Name>RecommendedDisplay</Name>
+          <Name>Recommended</Name>
+          <Name>Status</Name>
+        </ReferencedProperties>
+      </PropertySet>
+    </Members>
+  </Type>
+
   <!-- YarboLightState: all LED channels -->
   <Type>
     <Name>YarboLightState</Name>

diff --git a/src/PSYarbo/PSYarbo.psd1 b/src/PSYarbo/PSYarbo.psd1
--- a/src/PSYarbo/PSYarbo.psd1
+++ b/src/PSYarbo/PSYarbo.psd1
@@ -10,11 +10,26 @@
     PowerShellVersion    = '7.4'
     # MQTTnet loaded via AssemblyLoadContext in PSYarbo.psm1 — not via RequiredAssemblies
     # RequiredAssemblies = @()
+    # Load class definitions before root module so [YarboRobot], [YarboConnection], etc. are visible to OutputType/attributes
+    ScriptsToProcess     = @(
+        'Classes/YarboExceptions.ps1'
+        'Classes/YarboLightState.ps1'
+        'Classes/YarboCommandResult.ps1'
+        'Classes/YarboTelemetry.ps1'
+        'Classes/YarboPlan.ps1'
+        'Classes/YarboSchedule.ps1'
+        'Classes/YarboRobot.ps1'
+        'Classes/YarboGlobalParams.ps1'
+        'Classes/YarboEndpoint.ps1'
+        'Classes/YarboConnection.ps1'
+        'Classes/YarboCloudSession.ps1'
+    )
     FormatsToProcess     = @('PSYarbo.Format.ps1xml')
-    TypesToProcess       = @('PSYarbo.Types.ps1xml')
+    # Types loaded in .psm1 with Update-TypeData -Force to avoid "already present" on re-import
+    # TypesToProcess       = @('PSYarbo.Types.ps1xml')
     FunctionsToExport    = @(
         # Connection
-        'Connect-Yarbo', 'Disconnect-Yarbo', 'Find-Yarbo',
+        'Connect-Yarbo', 'Disconnect-Yarbo', 'Find-Yarbo', 'Find-YarboDevice', 'Invoke-YarboMqttSniff',
         # Status
         'Get-YarboStatus', 'Get-YarboRobot', 'Get-YarboBattery', 'Get-YarboFirmware',
         'Get-YarboGlobalParams',
@@ -41,7 +56,7 @@
     )
     CmdletsToExport      = @()
     VariablesToExport    = @()
-    AliasesToExport      = @('Return-YarboToDock')
+    AliasesToExport      = @('Return-YarboToDock', 'Start-YarboRecharge')
     PrivateData          = @{
         PSData = @{
             Tags         = @('Yarbo', 'Robot', 'Mower', 'MQTT', 'IoT', 'Automation', 'SnowBlower')

diff --git a/src/PSYarbo/PSYarbo.psm1 b/src/PSYarbo/PSYarbo.psm1
--- a/src/PSYarbo/PSYarbo.psm1
+++ b/src/PSYarbo/PSYarbo.psm1
@@ -16,37 +16,182 @@
 Set-StrictMode -Version Latest
 $ErrorActionPreference = 'Stop'
 
-#region — Load MQTTnet via isolated AssemblyLoadContext
+#region — Load MQTTnet and C# listener helper
 $libPath = Join-Path $PSScriptRoot 'lib'
 $mqttDllPath = Join-Path $libPath 'MQTTnet.dll'
 
 if (Test-Path $mqttDllPath) {
-    $alcName = 'PSYarboMqttContext'
-    $script:MqttALC = [System.Runtime.Loader.AssemblyLoadContext]::new($alcName, $true)
-    $script:MqttAssembly = $script:MqttALC.LoadFromAssemblyPath((Resolve-Path $mqttDllPath).Path)
-    Write-Verbose "PSYarbo: Loaded MQTTnet via isolated AssemblyLoadContext '$alcName'"
+    $script:MqttAssembly = [System.Reflection.Assembly]::LoadFrom((Resolve-Path $mqttDllPath).Path)
+    Write-Verbose "PSYarbo: Loaded MQTTnet from $mqttDllPath"
+    # C# helper: connects, subscribes, and receives messages entirely in C# so the MQTTnet
+    # ApplicationMessageReceivedAsync event is attached with += and always fires.
+    $listenerCode = @'
+using System;
+using System.Collections;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace PSYarbo.Mqtt {
+    public sealed class YarboMqttListener : IDisposable {
+        private readonly MQTTnet.MqttFactory _factory;
+        private MQTTnet.Client.IMqttClient _client;
+        private readonly ArrayList _messages = ArrayList.Synchronized(new ArrayList());
+        private readonly TaskCompletionSource<ReceivedMessage> _firstMessage = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
+
+        public YarboMqttListener() {
+            _factory = new MQTTnet.MqttFactory();
+            _client = _factory.CreateMqttClient();
+            _client.ApplicationMessageReceivedAsync += e => {
+                var seg = e.ApplicationMessage.PayloadSegment;
+                int len = seg.Array != null ? seg.Count : 0;
+                byte[] copy = len > 0 ? new byte[len] : Array.Empty<byte>();
+                if (len > 0 && seg.Array != null)
+                    Buffer.BlockCopy(seg.Array, seg.Offset, copy, 0, len);
+                var msg = new ReceivedMessage(e.ApplicationMessage.Topic ?? "", copy);
+                _messages.Add(msg);
+                if (!_firstMessage.Task.IsCompleted)
+                    _firstMessage.TrySetResult(msg);
+                return Task.CompletedTask;
+            };
+        }
+
+        public void Connect(string broker, int port, string clientId, TimeSpan timeout) {
+            var opts = _factory.CreateClientOptionsBuilder()
+                .WithTcpServer(broker, port)
+                .WithClientId(clientId)
+                .WithTimeout(timeout)
+                .Build();
+            _client.ConnectAsync(opts, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public void Subscribe(string[] topicFilters) {
+            var builder = _factory.CreateSubscribeOptionsBuilder();
+            foreach (var topic in topicFilters)
+                builder = builder.WithTopicFilter(topic);
+            var opts = builder.Build();
+            _client.SubscribeAsync(opts, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public void Publish(string topic, byte[] payload) {
+            var msg = _factory.CreateApplicationMessageBuilder()
+                .WithTopic(topic)
+                .WithPayload(payload ?? Array.Empty<byte>())
+                .WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtMostOnce)
+                .Build();
+            _client.PublishAsync(msg, CancellationToken.None).GetAwaiter().GetResult();
+        }
+
+        public ReceivedMessage WaitForFirstMessage(int timeoutMs) {
+            try {
+                if (_firstMessage.Task.IsCompleted)
... diff truncated: showing 800 of 3365 lines

Comment thread src/PSYarbo/PSYarbo.psm1 Outdated
cursoragent and others added 3 commits February 27, 2026 18:05
Symmetrically call remove_ApplicationMessageReceivedAsync in YarboConnection.Dispose()
to properly detach the event handler before calling UnregisterCallback. This prevents
unnecessary callback invocations between unregister and client disposal.
- YarboConnection.Dispose: resolve MessageReceivedAdapter at runtime so module
  loads when ScriptsToProcess runs before .psm1 (fixes CI/import failure)
- Invoke-YarboMqttSniff: -RecordPath to save full payloads (Topic, PayloadBase64)
  for later analysis; capture start time per message
- Get-YarboMqttRecordingReport: new cmdlet; reads recording JSON, decodes zlib,
  reports topic coverage vs cmdlets
- Record-YarboMqtt.ps1: 60s real MQTT record + report; -DiscoverBroker support
- Analyze-MqttRecordingGaps.ps1: standalone gap analysis (payload keys not exposed)
- YarboRobot/YarboTelemetry: add all DeviceMSG fields as typed properties
  (Battery current/voltage, RTK/RTCM, RunningStatus, State, LedInfo, EletricMSG,
  SystemInfo, DebugMsg, switches, etc.); TelemetryParser maps all from payload
- tools/MqttSniff.ps1: -RecordPath, doc updates
- Analyze ExposedKeys updated so real recording shows full coverage

Made-with: Cursor
- Thread 2: Replace ArrayList.Synchronized with explicit lock object in
  YarboMqttListener to make CopyTo+Clear compound operation atomic
- Thread 3/4: Rewrite >512-host warning to recommend -Subnet or reducing
  -MaxHosts instead of confusingly suggesting -MaxHosts to scan more
- CI PSUseSingularNouns: Rename Get-PSYarboLocalSubnets → Get-PSYarboLocalSubnet
  in NetworkHelper.ps1, Find-Yarbo.ps1, and Find-YarboDevice.ps1
- CI PSUseConsistentIndentation: Fix ValidateScript block indentation in
  Find-Yarbo.ps1 and Find-YarboDevice.ps1 (16-space body, 12-space closing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminated ~150 lines of duplication between ConvertTo-YarboTelemetry
and ConvertTo-YarboRobot by creating Set-YarboCommonFields helper
function. This consolidates parsing logic for BatteryMSG, StateMSG,
RTKMSG, RunningStatusMSG, wireless_recharge, EletricMSG, ultrasonic_msg,
LED, and misc fields into a single location, reducing maintenance burden
and preventing inconsistencies when adding or fixing field mappings.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: CI integration step may silently pass with zero tests
    • Changed Run.Exit to false and added manual checks to ensure at least one test runs and fail the step if zero tests are executed or any tests fail.
Preview (dbf3c28e99)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,61 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Filter.ExcludeTag     = 'Integration'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
+    [int]$SnowRollerMotor
... diff truncated: showing 800 of 5314 lines

Comment thread .github/workflows/ci.yml
cursoragent and others added 2 commits March 2, 2026 00:04
The integration test step was silently passing even when no tests ran because Run.Exit was true but there was no ErrorOnNoTests safeguard. Changed Run.Exit to false and added manual checks to ensure at least one test runs and fail the step if any tests fail or if zero tests are executed.
…ver/DC message per device

- TelemetryParser: Add SuppressMessageAttribute for Set-YarboCommonFields
  (PSUseShouldProcessForStateChangingFunctions, PSUseSingularNouns) so
  PSScriptAnalyzer tests pass; attributes placed inside function body.
- Find-YarboDevice: Show 'Both endpoints reach the same broker' only when
  at least one device (per SerialNumber) has both Rover and DC endpoints,
  fixing misleading message when multiple devices (e.g. one Rover, one DC).

Made-with: Cursor

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: CI integration tests silently pass due to missing PassThru
    • Added $intConfig.Run.PassThru = $true to enable Invoke-Pester to return the result object needed for TotalCount and FailedCount checks.
Preview (28f929e057)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.ExcludeTag     = 'Integration'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
... diff truncated: showing 800 of 5322 lines

Comment thread .github/workflows/ci.yml
Without PassThru=true, Invoke-Pester returns null instead of a result object, causing TotalCount and FailedCount checks to always pass silently.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Redundant ToUInt32 call inside IP generation loop
    • Replaced redundant ToUInt32 call with direct use of the baseVal variable already computed before the loop.
  • ✅ Fixed: Callback parameter shadows automatic $args variable
    • Renamed callback parameter from $args to $eventArgs to avoid shadowing PowerShell's automatic variable.
Preview (a2f6e6152c)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.ExcludeTag     = 'Integration'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
... diff truncated: showing 800 of 5322 lines

Comment thread src/PSYarbo/Private/NetworkHelper.ps1
# Build connection options
# Attach message handler before connecting (MQTTnet requirement)
$callback = [Func[object, System.Threading.Tasks.Task]] {
param($args)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Callback parameter shadows automatic $args variable

Low Severity

The MQTT message handler scriptblock declares param($args) which shadows PowerShell's automatic $args variable. While this works correctly at runtime (the Func[object, Task] delegate argument binds to it), the naming is misleading and fragile — any future maintainer adding code that relies on the automatic $args would get surprising behavior. A descriptive name like $eventArgs or $ea would be clearer and safer.

Fix in Cursor Fix in Web

- Remove redundant ToUInt32 call in IP generation loop by reusing baseVal variable
- Rename callback parameter from $args to $eventArgs to avoid shadowing automatic variable

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: CI integration test step will always fail with zero tests error
    • Changed CI filter from ExcludeTag='Integration' to Tag='Mocked' to explicitly run only mocked tests and prevent unintentional execution of future untagged tests.
  • ✅ Fixed: Dead $baseBytes reassignment after masking in subnet IP generation
    • Removed the dead assignment of $baseBytes at line 62 which was never used after being reassigned.
Preview (43d6df4fe3)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
... diff truncated: showing 800 of 5321 lines

Comment thread .github/workflows/ci.yml
}
if ($intResult.FailedCount -gt 0) {
exit 1
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI integration test step will always fail with zero tests error

Medium Severity

The CI integration test step runs ./tests/Integration with ExcludeTag = 'Integration', expecting mocked tests tagged Mocked to run. However, the Get-YarboPlan.Tests.ps1 and Get-YarboStatus.Tests.ps1 files use InModuleScope PSYarbo with BeforeAll that directly calls Import-Module, meaning their Describe blocks (tagged Mocked) run outside Pester's tag-filtering flow. If Pester discovers them but the module fails to load (e.g. missing MQTTnet in CI), the step could fail unexpectedly. More critically, the error message at line 111 says "check that mocked test files exist and are tagged 'Mocked'" but the ExcludeTag filter is Integration — this doesn't include only Mocked, it runs everything except Integration. If in the future someone adds a non-tagged test to tests/Integration, it would also run unintentionally.

Fix in Cursor Fix in Web

Comment thread src/PSYarbo/Private/NetworkHelper.ps1 Outdated
$hostBits = 32 - $prefixLen
$baseVal = [System.BitConverter]::ToUInt32($baseBytes, 0)
$baseVal = [uint32]($baseVal -band ([uint32]::MaxValue -shl $hostBits))
$baseBytes = [System.BitConverter]::GetBytes($baseVal)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dead $baseBytes reassignment after masking in subnet IP generation

Low Severity

In Get-PSYarboSubnetIpList, $baseBytes is reassigned from [System.BitConverter]::GetBytes($baseVal) at line 62, but is never read again — the loop on lines 65–70 uses $baseVal directly. This dead store is likely a leftover from the original Find-Yarbo code (which used $baseBytes in the loop). It adds confusion about which variable holds the canonical network address value.

Fix in Cursor Fix in Web

- Change CI integration test filter from ExcludeTag='Integration' to Tag='Mocked' to explicitly run only mocked tests, preventing unintentional execution of future untagged tests
- Remove dead assignment of $baseBytes after subnet masking in Get-PSYarboSubnetIpList (variable was never used after line 62)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

$session.RefreshAuth()
if ($script:YarboCloudSession) { $script:YarboCloudSession.Dispose() }
$script:YarboCloudSession = $session
return $session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stored refresh token rotation not persisted back

Medium Severity

The early-return path for stored refresh-token authentication skips the credential-persistence block. When RefreshAuth() succeeds and the server rotates the refresh token (common for security), $session.RefreshToken is updated in memory but never saved back via Save-YarboCloudCredential or Save-YarboCredential. The next session attempt using stored credentials will send the stale (old) token, which will likely fail with an auth error, breaking the "seamless reconnect without re-entering password" workflow this feature is meant to provide.

Additional Locations (1)

Fix in Cursor Fix in Web

- PSYarbo.psm1: initialize \$script:MqttResolvingHandler = \$null in the
  else branch (MQTTnet.dll absent) so the OnRemove cleanup handler never
  throws under Set-StrictMode -Version Latest. Fixes 94 test container
  failures across all test suites.

- DebugAndSupportBundle.Tests.ps1: align TelemetryLog mock entry with the
  actual property names used by Connect-Yarbo (Timestamp / MessageType /
  Direction / Topic) instead of the incorrect At / Source that caused a
  PropertyNotFoundException under StrictMode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Typed property changes silently lost when saving params
    • Added code to sync typed properties (MowSpeed, CuttingHeight, RainDelay, ObstacleSensitivity) back to the payload hashtable before sending to the device, preventing silent data loss when users modify typed properties.
Preview (7a4a85e039)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
... diff truncated: showing 800 of 5330 lines

$Parameters
} else {
throw "Parameters must be a hashtable or YarboGlobalParams object."
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typed property changes silently lost when saving params

Medium Severity

When Set-YarboGlobalParams receives a YarboGlobalParams object, it exclusively reads from RawData to build the MQTT payload, completely ignoring the typed properties (MowSpeed, CuttingHeight, RainDelay, ObstacleSensitivity). Since Get-YarboGlobalParams populates both RawData and the typed properties independently, a user who modifies $params.MowSpeed = 0.3 will have that change silently discarded — only RawData.mow_speed (still the original value) is sent to the device.

Additional Locations (1)

Fix in Cursor Fix in Web

When Set-YarboGlobalParams receives a YarboGlobalParams object, it now
syncs the typed properties (MowSpeed, CuttingHeight, RainDelay,
ObstacleSensitivity) back to the payload hashtable before sending to
the device. This prevents silent data loss when users modify typed
properties instead of RawData directly.

Fixes bug e1860387-4993-47cc-8858-2cc2682247ef

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Value-type null checks always true, silently overwriting RawData
    • Removed the null checks and sync lines that unconditionally overwrote RawData values with typed property values, allowing user modifications to RawData to be preserved as documented.
Preview (0957680cf9)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
... diff truncated: showing 800 of 5323 lines

if ($null -ne $Parameters.MowSpeed) { $ht['mow_speed'] = $Parameters.MowSpeed }
if ($null -ne $Parameters.CuttingHeight) { $ht['cutting_height'] = $Parameters.CuttingHeight }
if ($null -ne $Parameters.RainDelay) { $ht['rain_delay'] = $Parameters.RainDelay }
if ($null -ne $Parameters.ObstacleSensitivity) { $ht['obstacle_sensitivity'] = $Parameters.ObstacleSensitivity }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Value-type null checks always true, silently overwriting RawData

High Severity

The $null -ne guards on MowSpeed ([double]), CuttingHeight ([int]), RainDelay ([int]), and ObstacleSensitivity ([int]) always evaluate to $true because PowerShell value types are never $null — they default to 0. This means the typed properties always overwrite the corresponding keys in the hashtable built from RawData. The documented usage pattern ($params.RawData.mow_speed = 0.5) is broken: the user's RawData edit is silently replaced by the stale typed property value, causing silent data loss.

Fix in Cursor Fix in Web

Remove lines that unconditionally overwrote RawData values with typed properties. Value types (int, double) cannot be null in PowerShell and default to 0, making the null checks always true. This caused silent data loss when users modified RawData as documented.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 4 potential issues.

Bugbot Autofix resolved 3 of the 4 issues found in the latest run.

  • ✅ Fixed: Test class loading missing two new class files
    • Added YarboGlobalParams and YarboEndpoint to the explicit class loading list in the test file to match the module's 11 classes.
  • ✅ Fixed: Early return bypasses session output logging in cloud auth
    • Wrapped the refresh token attempt in try-catch to allow fallback to password authentication when the stored refresh token is stale or revoked.
  • ✅ Fixed: Subnet IP generation silently skips /31 user input
    • Extended validation range to accept /31 subnets and implemented RFC 3021 point-to-point link handling with both addresses usable.
Preview (0da87b4fcb)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
+    [string]$RtkVersion
+    [int]$RtkSatNum
+    [double]$RtkTimestamp
+    [double]$RtcmAge
+    [object]$RtcmInfo
+    [string]$RoverHeading
+    [string]$BaseGngga
 
-    # Hardware
+    # Hardware (RunningStatusMSG, led, wireless_recharge)
     [int]$ChuteAngle
+    [int]$RainSensorData
+    [int]$ChuteSteeringEngineInfo
+    [int]$ChuteSteeringRunStatus
+    [double]$HeadGyroPitch
+    [double]$HeadGyroRoll
+    [int]$PushPodStatus
+    [int]$PushRodPlace
+    [int]$SnowPipeRunStatus
... diff truncated: showing 800 of 5342 lines

if (-not $toRecommend) { $toRecommend = $rovers[0] }
foreach ($r in $rovers) { $r.Recommended = ($r -eq $toRecommend) }
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recommendation logic inverted: DC preferred over Rover

Medium Severity

The Find-YarboDevice recommendation logic prefers DC (locally-administered MAC) as the recommended endpoint, but the description says "Rover = direct WiFi to the mower" and "DC = base station / data center bridge." The code comment at line 213 says "Prefer DC" and recommends DC first. However, if the locally-administered MAC heuristic is wrong for a given network (e.g. some routers assign locally-administered MACs to all clients), every endpoint gets labeled DC and the first one is recommended — potentially the wrong one. More importantly, an unknown MAC (not resolved) defaults to Rover classification, so a real DC whose MAC can't be resolved would be misclassified and a real Rover whose MAC can't be resolved would be correctly labeled — creating an asymmetric failure mode.

Fix in Cursor Fix in Web

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bugbot Autofix determined this is a false positive.

The logic correctly prefers DC over Rover as documented; defaulting unknown MACs to Rover is intentional conservative behavior to avoid wrong recommendations.

Comment thread tests/PSYarbo.Tests.ps1
It 'Classes directory exists' {
Join-Path $moduleRoot 'Classes' | Should -Exist
(Get-ChildItem -Path (Join-Path $moduleRoot 'Classes') -Filter '*.ps1').Count | Should -Be 9
(Get-ChildItem -Path (Join-Path $moduleRoot 'Classes') -Filter '*.ps1').Count | Should -Be 11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test class loading missing two new class files

Low Severity

The Classes test block loads only 9 class files directly (missing YarboGlobalParams and YarboEndpoint), while the module now has 11 classes. Although the using module directive at line 3 loads all classes via ScriptsToProcess, this direct-loading list is inconsistent with the module's class count assertion at line 104 (Should -Be 11) and could cause failures if the using module path changes or if future tests in this block need the missing classes.

Fix in Cursor Fix in Web

$session.RefreshAuth()
if ($script:YarboCloudSession) { $script:YarboCloudSession.Dispose() }
$script:YarboCloudSession = $session
return $session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early return bypasses session output logging in cloud auth

Medium Severity

When Connect-YarboCloud succeeds via stored RefreshToken (lines 67–73), it returns early without reaching the code at lines 172–175 that disposes a previous $script:YarboCloudSession before overwriting. The early-return path does its own dispose-then-store (lines 71–72), but if RefreshAuth() at line 70 throws, the exception is caught by the outer catch which disposes the new session — however the stored RefreshToken failure never falls through to try the stored Password at line 75, meaning a user with both a stale refresh token and a valid password saved will get an error instead of a graceful fallback.

Additional Locations (1)

Fix in Cursor Fix in Web

)
}
}
continue

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subnet IP generation silently skips /31 user input

Low Severity

Get-PSYarboSubnetIpList rejects prefixes outside /16–/30 range. When ValidateUserInput is $true, it throws for /prefixLen < 16 and /prefixLen > 30, but the second if at line 48 is unreachable after the first if throws at line 43 for the same condition branch. More importantly, /31 point-to-point links are valid and usable in some network configurations but are silently skipped without user feedback when ValidateUserInput is $false (auto-detected subnets).

Fix in Cursor Fix in Web

- Fix test class loading to include YarboGlobalParams and YarboEndpoint (bug 82802596)
- Add try-catch for refresh token with password fallback in Connect-YarboCloud (bug 943ed285)
- Add support for /31 point-to-point subnets per RFC 3021 (bug 8beebbd0)

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

$session.RefreshAuth()
if ($script:YarboCloudSession) { $script:YarboCloudSession.Dispose() }
$script:YarboCloudSession = $session
return $session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refreshed tokens not persisted on early return

Medium Severity

When Connect-YarboCloud succeeds via the stored refresh token path, it returns early without persisting the potentially-rotated tokens back to the credential store. The Save-YarboCredential / Save-YarboCloudCredential calls only exist in the normal email/password login flow. If the cloud API rotates refresh tokens on use, the stored token becomes stale, causing the next auto-login attempt to fail and fall back to password — which may also not be stored, resulting in a PASSWORD_REQUIRED error.

Additional Locations (1)

Fix in Cursor Fix in Web

…, HeadType docs

- Stop-YarboPlan: use stop_plan; Resume-YarboPlan: use resume_plan
- Start-YarboPlan: add -Percent (0-100) for start_plan percent
- Get-YarboSavedWifiList: get_saved_wifi_list
- Set-YarboBladeSpeed, Set-YarboChargeLimit: new cmdlets
- YarboLightState.BodyOn(): body accent preset (lights_body)
- about_PSYarbo_Heads.help.txt: HeadType enum + 7-channel LED reference
- Commands-and-Properties: HeadType values, new exports in tests

Made-with: Cursor

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Refresh token not persisted on stored-credential early return
    • Added credential-saving logic after RefreshAuth() in the stored-credential early return path to persist potentially-rotated refresh tokens, mirroring the behavior of the normal email/password login flow.
Preview (e7dd0d58dc)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier (0=NoHead, 1=SnowBlower, 2=LeafBlower, 3=LawnMower, 4=SmartCover, 5=LawnMowerPro, 99=Trimmer). See `Get-Help about_PSYarbo_Heads` for full list. |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboLightState.ps1 b/src/PSYarbo/Classes/YarboLightState.ps1
--- a/src/PSYarbo/Classes/YarboLightState.ps1
+++ b/src/PSYarbo/Classes/YarboLightState.ps1
@@ -31,6 +31,14 @@
         return [YarboLightState]::new()
     }
 
+    # Body accent lights only (red channels; matches python-yarbo lights_body())
+    static [YarboLightState] BodyOn() {
+        $s = [YarboLightState]::new()
+        $s.BodyLeftRed = 255
+        $s.BodyRightRed = 255
+        return $s
+    }
+
     [string] ToString() {
         return "Head:$($this.HeadLight) L:$($this.LeftWhite) R:$($this.RightWhite) BL:$($this.BodyLeftRed) BR:$($this.BodyRightRed) TL:$($this.TailLeftRed) TR:$($this.TailRightRed)"
     }

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
... diff truncated: showing 800 of 5684 lines

$session.RefreshAuth()
if ($script:YarboCloudSession) { $script:YarboCloudSession.Dispose() }
$script:YarboCloudSession = $session
return $session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refresh token not persisted on stored-credential early return

Medium Severity

When Connect-YarboCloud authenticates via a stored refresh token (the early return path), it calls $session.RefreshAuth() which may receive a new rotated refresh token from the API (line 131–133 of YarboCloudSession.ps1), but returns at line 74 without persisting the updated token via Save-YarboCredential or Save-YarboCloudCredential. The normal email/password login path at lines 164–173 does save tokens. If the Yarbo API rotates refresh tokens on use, the next session will attempt to use the now-stale token, causing authentication failure and forcing the user to re-enter their password.

Additional Locations (1)

Fix in Cursor Fix in Web

cursoragent and others added 2 commits March 2, 2026 08:42
When Connect-YarboCloud authenticates via a stored refresh token, the
RefreshAuth() method may receive a new rotated refresh token from the
API. The early return path was not persisting this updated token,
causing authentication failures in subsequent sessions.

This fix adds credential-saving logic after RefreshAuth() in the
stored-credential path, mirroring the behavior of the normal
email/password login flow.
…e, motor/trimmer, OTA, vision, fence, maps, camera)

- Control: Set-YarboTurnType, Push-YarboSnowDir, Set-YarboChuteSteeringWork, Set-YarboRollerSpeed, Set-YarboBladeHeight, Set-YarboMotorProtect, Set-YarboTrimmer, Set-YarboEdgeBlowing, Set-YarboSmartBlowing, Set-YarboHeatingFilm, Set-YarboModuleLock, Set-YarboFollowState, Start-YarboDrawCmd, Set-YarboGreengrassUpdateSwitch, Set-YarboIpcameraOtaSwitch, Update-YarboFirmware, Set-YarboSmartVision, Set-YarboVideoRecord, Set-YarboBagRecord, Set-YarboChildLock, Set-YarboGeoFence, Set-YarboElecFence, Set-YarboNgzEdge, Invoke-YarboCameraCalibration
- Plans: Clear-YarboMap, Restore-YarboMap, Save-YarboCurrentMap
- Status: Get-YarboCameraStatus
- All new cmdlets have .SYNOPSIS and .EXAMPLE for help tests

Made-with: Cursor

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Null password passed to mandatory SecureString parameter
    • Made Password parameter optional in Save-YarboCloudCredential and added conditional check to only save when provided, preventing parameter binding error when null.
Preview (bb0fd24a03)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier (0=NoHead, 1=SnowBlower, 2=LeafBlower, 3=LawnMower, 4=SmartCover, 5=LawnMowerPro, 99=Trimmer). See `Get-Help about_PSYarbo_Heads` for full list. |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboLightState.ps1 b/src/PSYarbo/Classes/YarboLightState.ps1
--- a/src/PSYarbo/Classes/YarboLightState.ps1
+++ b/src/PSYarbo/Classes/YarboLightState.ps1
@@ -31,6 +31,14 @@
         return [YarboLightState]::new()
     }
 
+    # Body accent lights only (red channels; matches python-yarbo lights_body())
+    static [YarboLightState] BodyOn() {
+        $s = [YarboLightState]::new()
+        $s.BodyLeftRed = 255
+        $s.BodyRightRed = 255
+        return $s
+    }
+
     [string] ToString() {
         return "Head:$($this.HeadLight) L:$($this.LeftWhite) R:$($this.RightWhite) BL:$($this.BodyLeftRed) BR:$($this.BodyRightRed) TL:$($this.TailLeftRed) TR:$($this.TailRightRed)"
     }

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
     # Position / Navigation
     [double]$OdometryX
     [double]$OdometryY
     [double]$OdometryPhi
     [double]$Heading
+    [int]$RtkHeadingStatus
     [string]$RtkStatus
+    [double]$RtkHeadingDop
     [double]$OdomConfidence
+    [double]$RtkGgaAtnDis
+    [double]$RtkHeadingAtnDis
+    [int]$RtkHeadingMulti
+    [int]$RtkHeadingObs
+    [double]$RtkPre4Timestamp
... diff truncated: showing 800 of 6583 lines

try {
Save-YarboCredential -Name 'CloudRefreshToken' -Value $session.RefreshToken
if ($session.Email) {
Save-YarboCloudCredential -Email $session.Email -Password $stored.Password -RefreshToken $session.RefreshToken

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Null password passed to mandatory SecureString parameter

Low Severity

When a stored refresh token succeeds, Save-YarboCloudCredential is called with $stored.Password which can be $null (if only a refresh token was stored, not a password). Since Save-YarboCloudCredential declares Password as [Parameter(Mandatory)] [SecureString], this causes a parameter binding error. The error is caught by the surrounding try/catch so it doesn't crash, but it silently prevents the rotated refresh token from being persisted to email-keyed storage.

Fix in Cursor Fix in Web

When a refresh token succeeds without a stored password, Save-YarboCloudCredential
was called with a null Password parameter, causing a parameter binding error since
Password was mandatory. This prevented the rotated refresh token from being saved.

Made Password parameter optional and conditionally save it only when provided,
matching the existing pattern for RefreshToken.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Single-line function bodies reduce readability and maintainability
    • Reformatted both functions to use multi-line format consistent with other cmdlets in the codebase.
  • ✅ Fixed: Auto-credential refresh path omits BoundSerialNumbers from session
    • Updated RefreshAuth() to populate BoundSerialNumbers if available in API response, and added fallback to fetch device list in Connect-YarboCloud auto-credential path.
Preview (9d87f59d12)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier (0=NoHead, 1=SnowBlower, 2=LeafBlower, 3=LawnMower, 4=SmartCover, 5=LawnMowerPro, 99=Trimmer). See `Get-Help about_PSYarbo_Heads` for full list. |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboCloudSession.ps1 b/src/PSYarbo/Classes/YarboCloudSession.ps1
--- a/src/PSYarbo/Classes/YarboCloudSession.ps1
+++ b/src/PSYarbo/Classes/YarboCloudSession.ps1
@@ -131,6 +131,9 @@
             if ($result.data.refreshToken) {
                 $this.RefreshToken = ConvertTo-SecureString -String $result.data.refreshToken -AsPlainText -Force
             }
+            if ($result.data.snList) {
+                $this.BoundSerialNumbers = @($result.data.snList)
+            }
         } else {
             $codeStr = if ($null -ne $result.code) { $result.code.ToString() } else { 'UNKNOWN' }
             throw [YarboCloudAuthException]::new("Token refresh failed: $($result.message)", $codeStr)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboLightState.ps1 b/src/PSYarbo/Classes/YarboLightState.ps1
--- a/src/PSYarbo/Classes/YarboLightState.ps1
+++ b/src/PSYarbo/Classes/YarboLightState.ps1
@@ -31,6 +31,14 @@
         return [YarboLightState]::new()
     }
 
+    # Body accent lights only (red channels; matches python-yarbo lights_body())
+    static [YarboLightState] BodyOn() {
+        $s = [YarboLightState]::new()
+        $s.BodyLeftRed = 255
+        $s.BodyRightRed = 255
+        return $s
+    }
+
     [string] ToString() {
         return "Head:$($this.HeadLight) L:$($this.LeftWhite) R:$($this.RightWhite) BL:$($this.BodyLeftRed) BR:$($this.BodyRightRed) TL:$($this.TailLeftRed) TR:$($this.TailRightRed)"
     }

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
... diff truncated: showing 800 of 6642 lines

.EXAMPLE
Set-YarboGreengrassUpdateSwitch -State 1
#>
[CmdletBinding(SupportsShouldProcess)] [OutputType([YarboCommandResult])] param([Parameter(Mandatory,Position=0)][ValidateSet(0,1)][int]$State,[Parameter(ValueFromPipeline)][YarboConnection]$Connection) process { $conn = Resolve-YarboConnection -Connection $Connection; if ($PSCmdlet.ShouldProcess($conn.SerialNumber, "Set Greengrass update switch $State")) { Assert-YarboController -Connection $conn; $cmd = 'set_greengrass_auto_update_switch'; return Send-MqttCommand -Connection $conn -Command $cmd -Payload @{ state = $State } } } }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-line function bodies reduce readability and maintainability

Low Severity

Set-YarboGreengrassUpdateSwitch and Set-YarboIpcameraOtaSwitch compress their entire [CmdletBinding], [OutputType], param(), and process {} blocks into a single line. Every other cmdlet added in this PR uses proper multi-line formatting. This makes these two functions extremely hard to read, review, and maintain — a diff or merge conflict here would be very painful.

Additional Locations (1)

Fix in Cursor Fix in Web

}
if ($script:YarboCloudSession) { $script:YarboCloudSession.Dispose() }
$script:YarboCloudSession = $session
return $session

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-credential refresh path omits BoundSerialNumbers from session

Low Severity

The new auto-credential refresh path in the Credential parameter set returns a YarboCloudSession without populating BoundSerialNumbers. The normal email+password login sets $session.BoundSerialNumbers = @($result.data.snList), but RefreshAuth() doesn't set it. Users inspecting the session (e.g., to check which robots are bound) will see an empty array instead of the expected serial list.

Fix in Cursor Fix in Web

- Reformat Set-YarboGreengrassUpdateSwitch and Set-YarboIpcameraOtaSwitch to use multi-line format consistent with other cmdlets
- Update RefreshAuth() to populate BoundSerialNumbers from API response if available
- Fetch BoundSerialNumbers in auto-credential refresh path when not provided by refresh token API

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Misleading indentation hides control flow in credential logic
    • Corrected indentation to properly reflect the nested control flow structure, making the if ($stored.RefreshToken) block clearly nested within if ($stored) and aligning the if ($stored.Password) fallback at the correct level.
Preview (13b1673e14)
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -43,7 +43,7 @@
       render: powershell
       placeholder: |
         # Example:
-        Connect-Yarbo -MowerSerial 'YB-XXXX' -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
         Get-YarboStatus | Format-List
 
   - type: checkboxes

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,6 +5,7 @@
     branches: [develop]
   pull_request:
     branches: [main, develop]
+  workflow_dispatch:
 
 permissions:
   contents: read
@@ -72,20 +73,62 @@
           Set-PSRepository PSGallery -InstallationPolicy Trusted
           Install-Module -Name PSScriptAnalyzer -MinimumVersion 1.21.0 -Scope CurrentUser -Force
 
-      - name: Run Pester tests
+      - name: Install MQTTnet (for module load in tests)
         shell: pwsh
+        run: ./build/Install-Dependencies.ps1
+
+      - name: Run Unit tests
+        shell: pwsh
         run: |
           Import-Module Pester -MinimumVersion 5.0.0 -Force
-          $config = New-PesterConfiguration
-          $config.Run.Path              = './tests'
-          $config.Run.Exit              = $true
-          $config.Output.Verbosity      = 'Detailed'
-          $config.TestResult.Enabled    = $true
-          $config.TestResult.OutputPath = 'output/test-results/pester-${{ matrix.os }}.xml'
-          $config.TestResult.OutputFormat = 'NUnitXml'
           New-Item -ItemType Directory -Path 'output/test-results' -Force | Out-Null
-          Invoke-Pester -Configuration $config
+          $unitConfig = New-PesterConfiguration
+          $unitConfig.Run.Path              = './tests/Unit'
+          $unitConfig.Run.Exit              = $true
+          $unitConfig.Output.Verbosity      = 'Detailed'
+          $unitConfig.Filter.ExcludeTag    = 'Integration'
+          $unitConfig.TestResult.Enabled   = $true
+          $unitConfig.TestResult.OutputPath = 'output/test-results/pester-unit-${{ matrix.os }}.xml'
+          $unitConfig.TestResult.OutputFormat = 'NUnitXml'
+          $unitResult = Invoke-Pester -Configuration $unitConfig
 
+      - name: Run Integration tests (mocked; excludes real-robot Integration tag)
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $intConfig = New-PesterConfiguration
+          $intConfig.Run.Path              = './tests/Integration'
+          $intConfig.Run.Exit              = $false
+          $intConfig.Run.PassThru          = $true
+          $intConfig.Filter.Tag            = 'Mocked'
+          $intConfig.Output.Verbosity      = 'Detailed'
+          $intConfig.TestResult.Enabled    = $true
+          $intConfig.TestResult.OutputPath = 'output/test-results/pester-integration-${{ matrix.os }}.xml'
+          $intConfig.TestResult.OutputFormat = 'NUnitXml'
+          # ErrorOnNoTests removed — not available in all Pester versions
+          $intResult = Invoke-Pester -Configuration $intConfig
+          if ($intResult.TotalCount -eq 0) {
+            Write-Error "Integration test step ran 0 tests — check that mocked test files exist and are tagged 'Mocked'"
+            exit 1
+          }
+          if ($intResult.FailedCount -gt 0) {
+            exit 1
+          }
+
+      - name: Run root tests (PSYarbo.Tests, PSScriptAnalyzer) and exit on failure
+        shell: pwsh
+        run: |
+          Import-Module Pester -MinimumVersion 5.0.0 -Force
+          $rootConfig = New-PesterConfiguration
+          $rootConfig.Run.Path              = './tests/PSYarbo.Tests.ps1', './tests/PSScriptAnalyzer.Tests.ps1'
+          $rootConfig.Run.Exit              = $true
+          $rootConfig.Filter.ExcludeTag     = 'Integration'
+          $rootConfig.Output.Verbosity      = 'Detailed'
+          $rootConfig.TestResult.Enabled    = $true
+          $rootConfig.TestResult.OutputPath = 'output/test-results/pester-root-${{ matrix.os }}.xml'
+          $rootConfig.TestResult.OutputFormat = 'NUnitXml'
+          Invoke-Pester -Configuration $rootConfig
+
       - name: Upload test results
         if: always()
         uses: actions/upload-artifact@v6

diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -114,17 +114,21 @@
         Brief one-line description.
     .DESCRIPTION
         Longer description.
-    .PARAMETER MqttServer
-        MQTT broker hostname or IP.
+    .PARAMETER Broker
+        MQTT broker hostname or IP (use Find-YarboDevice to discover).
+    .PARAMETER SerialNumber
+        Robot serial number.
     .EXAMPLE
-        Connect-Yarbo -MqttServer '192.168.1.100'
+        Connect-Yarbo -Broker '<rover-ip>' -SerialNumber '<serial-number>'
     .NOTES
         Author: Your Name
     #>
     [CmdletBinding()]
     param(
         [Parameter(Mandatory)]
-        [string] $MqttServer
+        [string] $Broker,
+        [Parameter(Mandatory)]
+        [string] $SerialNumber
     )
     # ...
 }

diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -61,7 +61,7 @@
 $conn = $robot | Connect-Yarbo
 
 # — OR — connect directly
-$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+$conn = Connect-Yarbo -Broker <rover-ip> -SerialNumber <serial-number>
 
 # Check status
 Get-YarboStatus
@@ -82,13 +82,21 @@
 
 ---
 
+## Commands and properties (how to get information)
+
+For detailed examples of **which cmdlets to use** and **which properties to read** (battery, RTK, LEDs, `SystemInfo.cpu.Temperature`, `LedInfo`, telemetry, etc.), see:
+
+📖 **[Commands and Properties](docs/Commands-and-Properties.md)** — status vs telemetry, property reference, and example snippets.
+
+---
+
 ## Cmdlet Reference
 
 ### Connection
 
 | Cmdlet | Description |
 |--------|-------------|
-| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT `heart_beat`. Returns `YarboRobot[]`. |
+| `Find-Yarbo` | Auto-discover Yarbo robots via MQTT (DeviceMSG, data_feedback, heart_beat; aligned with [python-yarbo](https://github.com/markus-lassfolk/python-yarbo)). Returns `YarboRobot[]`. |
 | `Connect-Yarbo` | Connect to a robot's MQTT broker. Returns `YarboConnection`. |
 | `Disconnect-Yarbo` | Disconnect and dispose resources. |
 | `Test-YarboConnection` | Test if a connection is alive. |
@@ -155,8 +163,54 @@
 | `Get-YarboVideo` | Get video token/URL. |
 | `Get-YarboPlanHistory` | Get plan execution history from cloud. |
 
+### Utility
+
+| Cmdlet | Description |
+|--------|-------------|
+| `Test-YarboConnection` | Test if a connection is alive. |
+| `Get-YarboLog` | View CommandLog and TelemetryLog (sent/received MQTT). |
+| `Get-YarboMqttRecordingReport` | Report topic coverage from an MQTT recording. |
+| `Export-YarboSupportBundle` | Export a redacted MQTT/connection bundle for support (e.g. GlitchTip). |
+
 ---
 
+## Debug logging and support bundles
+
+To see what is being **sent and received** over MQTT (useful for troubleshooting firmware or unsupported heads/attachments), use either:
+
+- **`-Debug`** — Pass the common `-Debug` switch to any cmdlet that uses MQTT. Debug output shows topic, payload (human-readable JSON), and responses.
+- **Environment variable `PSYARBO_DEBUG`** — Set `$env:PSYARBO_DEBUG = "1"` (or `"true"`) so that debug output is shown for all MQTT traffic without passing `-Debug` every time. Output is written as information stream so it appears in the console.
+
+For **raw payloads** (base64 of the zlib-compressed bytes) instead of human-readable JSON, set **`PSYARBO_DEBUG_RAW=1`** (or `"true"`).
+
+```powershell
+# One-off debug for a single command
+Get-YarboStatus -Debug
+
+# Session-wide debug (no -Debug needed)
+$env:PSYARBO_DEBUG = "1"
+Get-YarboStatus
+Send-YarboCommand -Command read_global_params
+
+# Raw payload format
+$env:PSYARBO_DEBUG_RAW = "1"
+Get-YarboStatus -Debug
+```
+
+To provide a **full MQTT dump** for support (e.g. GlitchTip or when opening an issue), use **`Export-YarboSupportBundle`**. It produces a redacted JSON file from a recording and/or the current connection log. You can upload that file to your issue tracker.
+
+```powershell
+# From an MQTT recording (Invoke-YarboMqttSniff -RecordPath ...)
+Export-YarboSupportBundle -Path ./support-bundle.json -RecordingPath ./mqtt-recording.json
+
+# From the current connection (recent CommandLog + TelemetryLog)
+Export-YarboSupportBundle -Path ./support-bundle.json -Connection $conn
+```
+
+Full in-product documentation: `Get-Help about_PSYarbo_Debug`. This aligns with [python-yarbo #59 — Debug Logging](https://github.com/markus-lassfolk/python-yarbo/issues/59).
+
+---
+
 ## Protocol
 
 PSYarbo uses the `snowbot/{SN}/...` MQTT topic hierarchy discovered by reverse engineering. Payloads are zlib-compressed UTF-8 JSON. Full documentation:
@@ -165,6 +219,8 @@
 
 See also: `Get-Help about_PSYarbo_MQTT`
 
+**Discovery:** Find-Yarbo and Find-YarboDevice use the same strategy as [python-yarbo](https://github.com/markus-lassfolk/python-yarbo): anonymous MQTT client, subscribe to `snowbot/+/device/DeviceMSG`, `snowbot/+/device/data_feedback`, and `snowbot/+/device/heart_beat`, and derive the robot serial number from the first message received. By default, discovery scans all networks this machine has an IP on; pass `-Subnet` (e.g. `192.0.2.0/24`) if you want to limit the scan. If discovery finds no robots, run from a host on the same LAN as the Yarbo and ensure the robot is powered and connected.
+
 ---
 
 ## Contributing

diff --git a/build/build.ps1 b/build/build.ps1
--- a/build/build.ps1
+++ b/build/build.ps1
@@ -76,6 +76,8 @@
 foreach ($file in $psFiles) {
     $lintFindings += Invoke-ScriptAnalyzer -Path $file.FullName -Settings $settings -Severity Error, Warning
 }
+# Only fail on Error/Warning (exclude Information e.g. TypeNotFound in class files loaded via ScriptsToProcess)
+$lintFindings = @($lintFindings | Where-Object { $_.Severity -in 'Error', 'Warning' })
 
 if ($lintFindings.Count -gt 0) {
     $lintFindings | Format-Table -AutoSize

diff --git a/docs/Commands-and-Properties.md b/docs/Commands-and-Properties.md
new file mode 100644
--- /dev/null
+++ b/docs/Commands-and-Properties.md
@@ -1,0 +1,398 @@
+# Commands and Properties — Getting Information from Your Robot
+
+This guide shows which cmdlets to use and which properties to read for status, telemetry, battery, RTK, LEDs, system info, and more.
+
+---
+
+## Connect first
+
+```powershell
+# Discover and connect (recommended)
+$robot = Find-Yarbo | Select-Object -First 1
+$conn = $robot | Connect-Yarbo
+
+# Or connect directly if you know broker and serial number
+$conn = Connect-Yarbo -Broker 192.168.1.24 -SerialNumber 24400102L8HO5227
+```
+
+After connecting, `Get-YarboStatus` and `Get-YarboTelemetry` use the current default connection unless you pass `-Connection $conn`.
+
+---
+
+## Status vs telemetry
+
+| What you want | Cmdlet | Returns |
+|---------------|--------|--------|
+| **Full robot status** (one snapshot) | `Get-YarboStatus` or `Get-YarboRobot` | `YarboRobot` |
+| **Single telemetry snapshot** | `Get-YarboTelemetry` | `YarboTelemetry` |
+| **Streaming telemetry** | `Watch-YarboTelemetry -Duration 00:01:00` | Events (YarboTelemetry objects) |
+| **Battery only** | `Get-YarboBattery` | Integer 0–100 |
+
+`YarboRobot` and `YarboTelemetry` share most of the same properties (both are built from the same MQTT DeviceMSG). Use **Get-YarboStatus** when you want the full status object to inspect or script against.
+
+---
+
+## Getting status and storing it
+
+```powershell
+$status = Get-YarboStatus    # or: Get-YarboRobot
+# $status is a YarboRobot with many properties (see below)
+```
+
+---
+
+## Property reference
+
+### Identity
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `SerialNumber` | string | Robot serial number |
+| `HeadSerialNumber` | string | Attached head serial |
+| `Name` | string | Robot name (if set) — **from cloud only, not MQTT** |
+| `HeadType` | int | Head type identifier (0=NoHead, 1=SnowBlower, 2=LeafBlower, 3=LawnMower, 4=SmartCover, 5=LawnMowerPro, 99=Trimmer). See `Get-Help about_PSYarbo_Heads` for full list. |
+
+**Robot name (the one you gave it):** Local MQTT does **not** provide the robot’s friendly name. That name comes from the **cloud API**. Use `Get-YarboDevice` (after `Connect-YarboCloud`) to get it; each device has a `.name` property (e.g. `"My Yarbo"`). You can match by `SerialNumber` and assign it to your status object if you want:
+
+```powershell
+Connect-YarboCloud -Email you@example.com -Password $pw
+$devices = Get-YarboDevice
+$status = Get-YarboStatus
+$status.Name = ($devices | Where-Object { $_.sn -eq $status.SerialNumber }).name
+# Now $status.Name is e.g. "My Yarbo"
+```
+
+**Example:** `$status.SerialNumber` → `24400102L8HO5227`
+
+---
+
+### Battery (BatteryMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `BatteryCapacity` | int | Charge percentage (0–100) |
+| `BatteryStatus` | int | Status code |
+| `BatteryTempError` | bool | Temperature error flag |
+| `BatteryTimestamp` | double | Device timestamp |
+| `BatteryCurrent` | double | Current (when present) |
+| `BatteryVoltage` | double | Voltage (when present) |
+
+**Examples:**
+
+```powershell
+$status.BatteryCapacity      # e.g. 83
+$status.BatteryCurrent       # current draw
+$status.BatteryVoltage       # voltage
+Get-YarboBattery             # quick 0–100 only
+```
+
+---
+
+### State (StateMSG)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `WorkingState` | int | 0 = idle, non-zero = active |
+| `ChargingStatus` | int | Charging state |
+| `ErrorCode` | int | Error code (0 = none) |
+| `IsPlanning` | bool | Plan in progress |
+| `IsPaused` | bool | Plan paused |
+| `IsRecharging` | bool | Recharge in progress |
+| `CarController` | bool | Car controller flag |
+| `MachineController` | int | Machine controller state |
+| `AdjustAngleStatus` | int | Adjust angle state |
+| `AutoDrawWaitingState` | int | Auto-draw waiting state |
+| `EnStateLed` | int | State LED enable |
+| `EnWarnLed` | int | Warning LED enable |
+| `OnGoingToStartPoint` | bool | Going to start point |
+| `OnMulPoints` | bool | Multi-point state |
+| `RobotFollowState` | int | Follow state |
+| `ScheduleCancel` | int | Schedule cancel state |
+| `VisionAutoDrawState` | int | Vision auto-draw state |
+
+**Examples:**
+
+```powershell
+$status.WorkingState         # 0 = idle
+$status.IsPlanning           # true if plan running
+$status.ErrorCode            # 0 = no error
+```
+
+---
+
+### Position / navigation (RTK, odometry)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `OdometryX`, `OdometryY`, `OdometryPhi` | double | Odometry position |
+| `Heading` | double | Heading (degrees) |
+| `RtkHeadingStatus` | int | RTK heading status |
+| `RtkStatus` | string | RTK status string |
+| `RtkHeadingDop` | double | Heading DOP |
+| `OdomConfidence` | double | Odometry confidence |
+| `RtkGgaAtnDis` | double | GGA attenuation |
+| `RtkHeadingAtnDis` | double | Heading attenuation |
+| `RtkHeadingMulti` | int | Heading multi |
+| `RtkHeadingObs` | int | Heading observations |
+| `RtkPre4Timestamp` | double | Pre4 timestamp |
+| `RtkVersion` | string | RTK firmware version |
+| `RtkSatNum` | int | Number of satellites |
+| `RtkTimestamp` | double | RTK timestamp |
+| `RtcmAge` | double | RTCM age |
+| `RtcmInfo` | object | RTCM info (nested) |
+| `RoverHeading` | string | Rover heading from rtk_base_data |
+| `BaseGngga` | string | Base station GNGGA sentence |
+
+**Examples:**
+
+```powershell
+$status.Heading              # e.g. 339.45
+$status.RtkStatus            # e.g. "4"
+$status.RtkVersion           # RTK version string
+$status.RtkSatNum            # satellite count
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.RoverHeading
+```
+
+---
+
+### Hardware (RunningStatusMSG, LED, wireless charge)
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `ChuteAngle` | int | Chute angle (snow blower) |
+| `RainSensorData` | int | Rain sensor value |
+| `ChuteSteeringEngineInfo` | int | Chute steering engine |
+| `ChuteSteeringRunStatus` | int | Chute run status |
+| `HeadGyroPitch` | double | Head gyro pitch |
+| `HeadGyroRoll` | double | Head gyro roll |
+| `PushPodStatus` | int | Push pod status |
+| `PushRodPlace` | int | Push rod place |
+| `SnowPipeRunStatus` | int | Snow pipe run status |
+| `SnowRollerMotor` | int | Snow roller motor |
+| `ElecNavigationFrontRightSensor` | int | Nav sensor front-right |
+| `ElecNavigationRearRightSensor` | int | Nav sensor rear-right |
+| `LedRegister` | string | Raw LED register value |
+| `LedInfo` | object | LED info (body_left_r/g/b, body_right_*, led_head, led_left_w, led_right_w, tail_left_r, tail_right_r) |
+| `HeadLedBrightness` | int | Head LED brightness |
+| `WirelessChargeState` | int | Wireless charge state |
+| `WirelessChargeVoltage` | double | Charge voltage |
+| `WirelessChargeCurrent` | double | Charge current |
+| `WirelessChargeErrorCode` | int | Charge error code |
+| `BrushlessMotorCurrent` | double | Brushless motor current (EletricMSG) |
+| `NtcTemperature` | double | NTC temperature |
+| `PushPodCurrent` | double | Push pod current |
+
+**Examples:**
+
+```powershell
+$status.ChuteAngle
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+$tel = Get-YarboTelemetry
+$tel.ChuteSteeringRunStatus
+$tel.HeadGyroPitch
+```
+
+---
+
+### Ultrasonic sensors
+
+| Property | Type |
+|----------|------|
+| `UltrasonicLeftFront` | int |
+| `UltrasonicMiddle` | int |
+| `UltrasonicRightFront` | int |
+
+**Example:** `$status.UltrasonicLeftFront`
+
+---
+
+### Network and misc
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `RoutePriority` | hashtable | Route priority (e.g. hg0, wlan0, wwan0) |
+| `BaseStatus` | object | base_status payload |
+| `Bds`, `Bs`, `Ms`, `S`, `Sbs`, `Tms` | object | Misc MQTT fields (when present) |
+| `GreenGrassUpdateSwitch` | int | Green grass update switch |
+| `IpcameraOtaSwitch` | int | IPC camera OTA switch |
+| `SystemInfo` | object | system_info (cpu.Frequency, cpu.Temperature, cpu.Usage, mem.*, userdata.disk.*, topProc) |
+| `DebugMsg` | object | Debug message payload |
+| `DeviceTimestamp` | double | Device message timestamp |
+| `LastUpdated` | datetime | When we last updated this object |
+
+**Examples:**
+
+```powershell
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+$status.RoutePriority.wlan0
+```
+
+---
+
+### YarboTelemetry-only (position as X/Y/Phi)
+
+`Get-YarboTelemetry` returns a `YarboTelemetry` object. It has the same MQTT-derived properties as above, plus:
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `Timestamp` | datetime | When the snapshot was taken |
+| `X`, `Y`, `Phi` | double | Position (CombinedOdom) |
+| `Latitude`, `Longitude`, `Altitude` | double? | Parsed from GPS (rtk_base_data.rover.gngga) |
+| `FixQuality` | int | GPS fix quality |
+| `GnggaRaw` | string | Raw GNGGA NMEA sentence |
+
+**Example:**
+
+```powershell
+$tel = Get-YarboTelemetry
+$tel.X
+$tel.Latitude
+$tel.GnggaRaw
+```
+
+---
+
+## Full MQTT payload (RawMessage)
+
+Every property above is taken from the robot’s MQTT DeviceMSG. The **entire** decoded payload is also available so you can access any field that isn’t mapped to a typed property:
+
+```powershell
+$status = Get-YarboStatus
+$status.RawMessage           # full DeviceMSG PSCustomObject
+$status.RawMessage.SomeKey   # any key from the JSON
+```
+
+---
+
+## Example snippets
+
+```powershell
+# Connect and get status
+$robot = Find-Yarbo | Select-Object -First 1
+$robot | Connect-Yarbo | Out-Null
+$status = Get-YarboStatus
+
+# Battery and basic state
+$status.BatteryCapacity
+$status.BatteryCurrent
+$status.BatteryVoltage
+$status.WorkingState
+
+# RTK / GPS
+$status.RtkVersion
+$status.RtkSatNum
+$status.Heading
+$status.RtcmAge
+
+# System (CPU, memory, disk)
+$status.SystemInfo.cpu.Temperature
+$status.SystemInfo.cpu.Usage
+$status.SystemInfo.mem.MemAvailable
+$status.SystemInfo.userdata.disk.availableSize
+
+# LEDs
+$status.LedInfo.led_head
+$status.LedInfo.body_left_r
+$status.HeadLedBrightness
+
+# Telemetry snapshot (same kind of properties)
+$tel = Get-YarboTelemetry
+$tel.RtkSatNum
+$tel.ChuteSteeringRunStatus
+$tel.BatteryCurrent
+```
+
+---
+
+## Recording MQTT and checking coverage
+
+To record real MQTT from your robot and see which topics map to which cmdlets:
+
+```powershell
+.\tools\Record-YarboMqtt.ps1 -DiscoverBroker
+# Or with a known broker:
+.\tools\Record-YarboMqtt.ps1 -Broker 192.168.1.24 -DurationSeconds 60
+```
+
+Then open the generated report or run:
+
+```powershell
+Get-YarboMqttRecordingReport -RecordingPath .\PSYarbo-MqttRecording-<timestamp>.json
+```
+
+See [README](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md) for full cmdlet lists and installation.
+
+---
+
+## Robot offline or sleeping — what MQTT info do we get?
+
+The MQTT broker runs **on the robot**. What you get depends on whether the robot is reachable and whether it is awake or sleeping.
+
+### Robot completely offline (powered off or not on the network)
+
+- **No MQTT at all.** There is no broker to connect to.
+- `Find-Yarbo` / `Find-YarboDevice` will not return this robot (no TCP, or TCP works but no `heart_beat`/DeviceMSG).
+- `Connect-Yarbo` will fail (TCP connect timeout or connection refused).
+- **You get:** no connection, no status, no telemetry. Use your network tools or cloud app to confirm the robot is on and on the same LAN.
+
+### Robot “sleeping” (suspended / low power, broker still reachable)
+
+If the robot is in sleep mode (e.g. after `Suspend-Yarbo` or auto-sleep), the broker may still accept TCP connections and subscriptions, but the robot often **stops or greatly reduces** publishing:
+
+- **device/DeviceMSG** — full status (battery, RTK, state, etc.) — typically **not published** while sleeping.
+- **device/heart_beat** — simple keepalive with `working_state` — may stop or be rare.
+
+So in practice:
+
+- You **can** often still **connect** (`Connect-Yarbo` succeeds).
+- **get_controller** usually **times out** (robot not responding). You get a warning and the connection is left in **Connected** state without **ControllerAcquired**.
+- **No new DeviceMSG** (and possibly no new heart_beat) until the robot wakes.
+- Commands that send a request and wait for a response (e.g. `Get-YarboStatus` → get_device_msg) will **time out**; you do **not** get fresh status from MQTT.
+
+**What you *do* get in this situation:**
+
+| Source | What you get |
+|--------|-------------------------------|
+| **Connection object** | `State` = Connected, `ControllerAcquired` = false, `Broker`, `SerialNumber`, `ConnectedAt`. |
+| **LastHeartbeat** | Last time a `heart_beat` was received (or `DateTime.MinValue` if none). Stale once the robot is asleep. |
+| **LastWorkingState** | `working_state` from that last heart_beat (0 = idle/sleep). |
+| **Connection.Robot** | Only if at least one **DeviceMSG** was received earlier (e.g. before sleep). That last snapshot is stale. |
+| **Get-YarboStatus** | Times out (no response to get_device_msg). No new data. |
+| **Test-YarboConnection -Detailed** | `Connected` (MQTT client still connected), `HeartbeatAge` (time since last heart_beat), `ControllerAcquired` = false. |
+
+**Example: check if the robot is likely sleeping**
+
+```powershell
+$conn = $conn ?? (Get-YarboConnection)   # your connection
+$detail = Test-YarboConnection -Connection $conn -Detailed
+$detail.Connected          # true = MQTT still connected
+$detail.ControllerAcquired # false = we don't have controller (often means robot sleeping)
+$detail.HeartbeatAge       # time since last heart_beat (e.g. 00:05:00 = 5 minutes)
+$conn.LastWorkingState     # 0 = idle/sleep when we last heard
+# If HeartbeatAge is large and ControllerAcquired is false → robot probably sleeping
+Resume-Yarbo -Connection $conn   # wake the robot, then retry Get-YarboStatus
+```
+
+### Summary
+
+| Robot state | MQTT connection | New DeviceMSG / status | What you can use |
+|-------------|-----------------|------------------------|------------------|
+| **Offline** (off or not on LAN) | No | No | Nothing; fix network/power. |
+| **Sleeping** (broker up, robot not responding) | Yes (usually) | No (timeouts) | Connection metadata, last `Robot` if any, `Test-YarboConnection -Detailed`, `Resume-Yarbo` to wake. |
+| **Active** | Yes | Yes | Full status and telemetry as in the rest of this doc. |
+
+---
+
+## Debug logging and support bundles
+
+To see exactly what is sent and received over MQTT (e.g. for unsupported firmware or heads):
+
+- Use **`-Debug`** on any MQTT cmdlet, or set **`$env:PSYARBO_DEBUG = "1"`** for the whole session. Set **`$env:PSYARBO_DEBUG_RAW = "1"`** for raw (base64) payloads instead of JSON.
+- Use **`Export-YarboSupportBundle -Path ./bundle.json -RecordingPath ./recording.json`** (and/or **`-Connection $conn`**) to produce a redacted JSON file you can attach to an issue or upload to GlitchTip. See README section *Debug logging and support bundles*.

diff --git a/docs/index.md b/docs/index.md
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,8 @@
 ## Quick Links
 
 - 📖 [Full Documentation (README)](https://github.com/markus-lassfolk/PSYarbo/blob/main/README.md)
+- 📋 [Commands and Properties](Commands-and-Properties.md) — how to get status, telemetry, battery, RTK, LEDs, system info
+- 🔧 **In-product help** — After installing: `Get-Help about_PSYarbo`, `Get-Help about_PSYarbo_MQTT`, `Get-Help about_PSYarbo_Debug` (debug logging and support bundles)
 - 🐛 [Report a Bug](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=bug_report.yml)
 - 💡 [Request a Feature](https://github.com/markus-lassfolk/PSYarbo/issues/new?template=feature_request.yml)
 - 💬 [Discussions](https://github.com/markus-lassfolk/PSYarbo/discussions)

diff --git a/src/PSYarbo/Classes/YarboCloudSession.ps1 b/src/PSYarbo/Classes/YarboCloudSession.ps1
--- a/src/PSYarbo/Classes/YarboCloudSession.ps1
+++ b/src/PSYarbo/Classes/YarboCloudSession.ps1
@@ -131,6 +131,9 @@
             if ($result.data.refreshToken) {
                 $this.RefreshToken = ConvertTo-SecureString -String $result.data.refreshToken -AsPlainText -Force
             }
+            if ($result.data.snList) {
+                $this.BoundSerialNumbers = @($result.data.snList)
+            }
         } else {
             $codeStr = if ($null -ne $result.code) { $result.code.ToString() } else { 'UNKNOWN' }
             throw [YarboCloudAuthException]::new("Token refresh failed: $($result.message)", $codeStr)

diff --git a/src/PSYarbo/Classes/YarboConnection.ps1 b/src/PSYarbo/Classes/YarboConnection.ps1
--- a/src/PSYarbo/Classes/YarboConnection.ps1
+++ b/src/PSYarbo/Classes/YarboConnection.ps1
@@ -88,7 +88,20 @@
 
     # Implement IDisposable — dispose all owned IDisposable members.
     # Disconnect-Yarbo calls this indirectly; classes holding a YarboConnection can also call it directly.
+    # Resolve adapter type at runtime so the class can load when ScriptsToProcess runs before the .psm1 defines it.
     [void] Dispose() {
+        try {
+            if ($null -ne $this.MqttClient) {
+                $adapterType = [System.AppDomain]::CurrentDomain.GetAssemblies() | ForEach-Object { $_.GetType('PSYarbo.Mqtt.MessageReceivedAdapter') } | Where-Object { $_ } | Select-Object -First 1
+                if ($null -ne $adapterType) {
+                    $handlerDelegate = $adapterType.GetMethod('GetHandler').Invoke($null, @($this.MqttClient))
+                    if ($null -ne $handlerDelegate) {
+                        $this.MqttClient.GetType().GetMethod('remove_ApplicationMessageReceivedAsync').Invoke($this.MqttClient, @($handlerDelegate)) | Out-Null
+                    }
+                    $adapterType.GetMethod('UnregisterCallback').Invoke($null, @($this.MqttClient))
+                }
+            }
+        } catch { $null = $_ }
         try { if ($null -ne $this.CancellationSource) { $this.CancellationSource.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.CommandSemaphore) { $this.CommandSemaphore.Dispose() } } catch { $null = $_ }
         try { if ($null -ne $this.ResponseSignal) { $this.ResponseSignal.Dispose() } } catch { $null = $_ }

diff --git a/src/PSYarbo/Classes/YarboEndpoint.ps1 b/src/PSYarbo/Classes/YarboEndpoint.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboEndpoint.ps1
@@ -1,0 +1,15 @@
+class YarboEndpoint {
+    [string]$IPAddress
+    [int]$Port
+    [string]$Path
+    [string]$MacAddress
+    [bool]$Recommended
+    [string]$Hostname
+    [string]$SerialNumber
+    [string]$Status
+
+    [string] ToString() {
+        $rec = if ($this.Recommended) { ' (recommended)' } else { '' }
+        return "$($this.IPAddress):$($this.Port) $($this.Path)$rec"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboGlobalParams.ps1 b/src/PSYarbo/Classes/YarboGlobalParams.ps1
new file mode 100644
--- /dev/null
+++ b/src/PSYarbo/Classes/YarboGlobalParams.ps1
@@ -1,0 +1,20 @@
+<#
+.SYNOPSIS
+    Global robot parameters from read_global_params MQTT response.
+    All fields from the data_feedback payload (MQTT reversing).
+    RawData holds the full decoded payload for any extra keys the device sends.
+#>
+class YarboGlobalParams {
+    [double]$MowSpeed
+    [int]$CuttingHeight
+    [int]$RainDelay
+    [int]$ObstacleSensitivity
+    [double]$Timestamp
+
+    # Full MQTT payload so no keys are lost (e.g. future device fields)
+    [PSCustomObject]$RawData
+
+    [string] ToString() {
+        return "MowSpeed=$($this.MowSpeed) CuttingHeight=$($this.CuttingHeight) RainDelay=$($this.RainDelay) ObstacleSensitivity=$($this.ObstacleSensitivity)"
+    }
+}

diff --git a/src/PSYarbo/Classes/YarboLightState.ps1 b/src/PSYarbo/Classes/YarboLightState.ps1
--- a/src/PSYarbo/Classes/YarboLightState.ps1
+++ b/src/PSYarbo/Classes/YarboLightState.ps1
@@ -31,6 +31,14 @@
         return [YarboLightState]::new()
     }
 
+    # Body accent lights only (red channels; matches python-yarbo lights_body())
+    static [YarboLightState] BodyOn() {
+        $s = [YarboLightState]::new()
+        $s.BodyLeftRed = 255
+        $s.BodyRightRed = 255
+        return $s
+    }
+
     [string] ToString() {
         return "Head:$($this.HeadLight) L:$($this.LeftWhite) R:$($this.RightWhite) BL:$($this.BodyLeftRed) BR:$($this.BodyRightRed) TL:$($this.TailLeftRed) TR:$($this.TailRightRed)"
     }

diff --git a/src/PSYarbo/Classes/YarboRobot.ps1 b/src/PSYarbo/Classes/YarboRobot.ps1
--- a/src/PSYarbo/Classes/YarboRobot.ps1
+++ b/src/PSYarbo/Classes/YarboRobot.ps1
@@ -5,11 +5,18 @@
     [string]$Name
     [int]$HeadType
 
-    # Battery
+    # Battery (BatteryMSG)
     [int]$BatteryCapacity
     [int]$BatteryStatus
+    [bool]$BatteryTempError
+    [double]$BatteryTimestamp
+    [double]$BatteryCurrent
+    [double]$BatteryVoltage
 
-    # State
+    # Body (BodyMsg)
+    [int]$RechargeState
+
+    # State (StateMSG)
     [int]$WorkingState
     [int]$ChargingStatus
     [int]$ErrorCode
@@ -18,31 +25,99 @@
     [bool]$IsRecharging
     [bool]$CarController
     [int]$MachineController
+    [int]$AdjustAngleStatus
+    [int]$AutoDrawWaitingState
+    [int]$EnStateLed
+    [int]$EnWarnLed
+    [bool]$OnGoingToStartPoint
+    [bool]$OnMulPoints
+    [int]$RobotFollowState
+    [int]$ScheduleCancel
+    [int]$VisionAutoDrawState
 
... diff truncated: showing 800 of 6642 lines

}
}
if ($stored.Password) { $Password = $stored.Password }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading indentation hides control flow in credential logic

Medium Severity

The if ($stored.RefreshToken) block on line 67 is indented at the same level as the opening brace of if ($stored) on line 66, and the password fallback on line 100 has extra indentation. While the brace structure is technically correct, this makes the nested control flow extremely confusing — it looks like if ($stored.RefreshToken) is a sibling rather than a child of if ($stored). This is a maintenance hazard that could easily lead to logic bugs when future contributors modify this block.

Fix in Cursor Fix in Web

Corrected indentation in Connect-YarboCloud.ps1 lines 66-101 to properly
reflect the nested control flow structure. The if (.RefreshToken)
block is now properly indented as a child of if (), and the
if (.Password) fallback is aligned at the correct nesting level.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

if ($IsWindows) {
$null = Test-Connection -ComputerName $IPAddress -Count 1 -Quiet -ErrorAction SilentlyContinue
} else {
$null = ping -c 1 -W 1 $IPAddress 2>$null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS ping timeout uses wrong units for ARP priming

Low Severity

Invoke-ArpPrime uses ping -c 1 -W 1 for both Linux and macOS, but the -W flag takes seconds on Linux and milliseconds on macOS. On macOS, -W 1 gives a 1ms timeout, making ARP priming almost always fail. This causes Get-MacForIp to return $null for macOS users, so Find-YarboDevice classifies all endpoints as "Rover" regardless of actual path, potentially giving incorrect DC-vs-Rover recommendations.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants