Skip to content

LEGO Technice Move hub improvements#217

Draft
vicocz wants to merge 12 commits intodefaultfrom
local/tehnice-move-hub-improvements
Draft

LEGO Technice Move hub improvements#217
vicocz wants to merge 12 commits intodefaultfrom
local/tehnice-move-hub-improvements

Conversation

@vicocz
Copy link
Copy Markdown
Owner

@vicocz vicocz commented Apr 2, 2026

No description provided.

Copy link
Copy Markdown

Copilot AI left a comment

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 refactors LEGO Powered Up / Control+ Bluetooth device handling by introducing a shared base class for GATT characteristic setup + hub property processing, and adds a PoC calibration flow for Technic Move steering so ABS-position commands work correctly outside PLAYVM mode.

Changes:

  • Add ControlPlusDeviceBase to centralize characteristic discovery/notification setup and hub property requests/parsing.
  • Add a shared NormalizeAngle helper in LegoWirelessProtocol and refactor parsing / byte conversions to use span-based helpers.
  • Update TechnicMoveDevice steering to apply a calibrated zero offset in non-PLAYVM mode and adjust port setup to query APOS before enabling POS notifications.

Reviewed changes

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

Show a summary per file
File Description
BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs Adds shared NormalizeAngle helper for angle math.
BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs Implements non-PLAYVM steering calibration + offset application and LED cleanup on disconnect.
BrickController2/BrickController2/DeviceManagement/Lego/RemoteControl.cs Switches RemoteControl to the new shared Control+ base and reuses hub property handling.
BrickController2/BrickController2/DeviceManagement/Lego/ControlPlusDeviceBase.cs New base class for characteristic setup, write helpers, and hub property request/parse logic.
BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs Migrates to new base class, refactors parsing to spans, and routes writes through shared helpers.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +117 to +126
if (angle >= 180)
{
return angle - (360 * ((angle + 180) / 360));
}
else if (angle < -180)
{
return angle + (360 * ((180 - angle) / 360));
}

return angle;
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

NormalizeAngle does not correctly normalize angles less than -180 for values beyond one full rotation (e.g., angle = -540 currently returns 180, which is outside the intended [-180, 179] range). This can lead to incorrect servo/steering calculations when angles accumulate beyond a single turn. Consider replacing this with a modulo-based normalization that always maps into [-180, 179] (e.g., using ((angle + 180) % 360 + 360) % 360 - 180).

Suggested change
if (angle >= 180)
{
return angle - (360 * ((angle + 180) / 360));
}
else if (angle < -180)
{
return angle + (360 * ((180 - angle) / 360));
}
return angle;
return ((angle + 180) % 360 + 360) % 360 - 180;

Copilot uses AI. Check for mistakes.
return;
}

var dataLength = data[0];
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

dataLength is assigned but never used, which will generate an "unused variable" warning. Either remove it or use it to validate data.Length matches the reported length (if that was the intent).

Suggested change
var dataLength = data[0];
var dataLength = data[0];
if (dataLength != data.Length)
{
return;
}

Copilot uses AI. Check for mistakes.
_virtualPortSendBuffer[7] = (byte)(value2 < 0 ? (255 + value2) : value2);

if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _virtualPortSendBuffer, token))
if (await WriteAsync(_virtualPortSendBuffer, token))
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

This virtual port path switched from a no-response write to WriteAsync (write-with-response). For high-frequency output updates, this can significantly reduce throughput and increase latency. If the protocol doesn’t require an ACK here, consider using WriteNoResponseAsync (or a dedicated helper) to preserve the previous behavior/performance characteristics.

Suggested change
if (await WriteAsync(_virtualPortSendBuffer, token))
if (await WriteNoResponseAsync(_virtualPortSendBuffer, token))

Copilot uses AI. Check for mistakes.
Comment on lines +212 to +221
// query current APOS
await WriteAsync([0x05, 0x00, 0x21, portId, 0x00], token);
await Task.Delay(250, token); //TODO wait for change

// setup channel to report POS position regularly
await WriteAsync(inputFormatForRelAngle, token);
await Task.Delay(250, token); //TODO wait for change

// need to recalculate base angle to support ABS POS commands
_calibratedZeroAngle = CalculateCalibratedTarget(channel);
Copy link

Copilot AI Apr 2, 2026

Choose a reason for hiding this comment

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

Calibration currently relies on fixed delays after requesting APOS/POS (with TODOs) and then immediately computes _calibratedZeroAngle. If the hub response is delayed or dropped, calibration will be computed from stale/default _absolutePositions / _relativePositions values. Consider waiting for the actual port-value message(s) (e.g., via a TaskCompletionSource triggered in OnCharacteristicChanged, or by polling _positionUpdateTimes with a timeout) before calculating the calibrated target.

Copilot uses AI. Check for mistakes.
@vicocz vicocz changed the title [PoC] Local/tehnice move hub improvements [PoC] LEGO Technice Move hub improvements Apr 3, 2026
@vicocz vicocz changed the title [PoC] LEGO Technice Move hub improvements LEGO Technice Move hub improvements Apr 28, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comments suppressed due to low confidence (1)

BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs:384

  • SendOutputValueVirtualAsync now uses WriteAsync (write-with-response) for virtual port updates. Virtual port writes can be high-frequency; using write-with-response can significantly reduce throughput compared to WriteNoResponseAsync and may increase latency/jitter. If the hub supports it, consider switching this back to no-response writes (and keep the existing send delay if needed to avoid flooding).
        private async Task<bool> SendOutputValueVirtualAsync(int virtualChannel, int channel1, int channel2, int value1, int value2, CancellationToken token)
        {
            try
            {
                if (_lastOutputValues[channel1] != value1 || _lastOutputValues[channel2] != value2)
                {
                    _virtualPortSendBuffer[3] = (byte)virtualChannel;
                    _virtualPortSendBuffer[6] = (byte)(value1 < 0 ? (255 + value1) : value1);
                    _virtualPortSendBuffer[7] = (byte)(value2 < 0 ? (255 + value2) : value2);

                    if (await WriteAsync(_virtualPortSendBuffer, token))
                    {
                        _lastOutputValues[channel1] = value1;
                        _lastOutputValues[channel2] = value2;

                        await Task.Delay(SEND_DELAY, token);
                        return true;

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +363 to +364
// in non PLAYVM mode, need to apply calibrated base angle as offset to reach correct position
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + value;
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

SendServoValue is using the already-byte-encoded output value as an angle delta (... + value). Since the upstream value is a percent in [-100..100], this should be scaled by ChannelConfigs[channel].MaxServoAngle (and must preserve sign) before adding to the absolute position; otherwise steering will be wrong, especially for negative values.

Suggested change
// in non PLAYVM mode, need to apply calibrated base angle as offset to reach correct position
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + value;
var percentValue = unchecked((sbyte)(byte)value);
var servoDelta = ChannelConfigs[channel].MaxServoAngle * percentValue / 100;
// in non PLAYVM mode, need to apply calibrated base angle as offset to reach correct position
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + servoDelta;

Copilot uses AI. Check for mistakes.
Comment on lines +383 to +384

result = result && await SendPortOutput_ValueAsync(channel, value, token);
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

SendAllOutputValuesAsync sends each A/B/C output twice: once via the outputType switch and again unconditionally with SendPortOutput_ValueAsync. This duplicates BLE writes (and for servo channels likely sends an incorrect extra direct-mode value command). Remove the unconditional second send, and ensure servo channels only send the servo command variant.

Suggested change
result = result && await SendPortOutput_ValueAsync(channel, value, token);

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +26
/// <summary>Returns a new state with an updated absolute position.</summary>
public ChannelPositionState WithAbsolutePosition(int absolutePosition) =>
this with { AbsolutePosition = absolutePosition, IsUpdated = true, UpdateTime = DateTime.Now };
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

WithAbsolutePosition sets IsUpdated = true and updates UpdateTime. In the existing servo-speed logic (ControlPlusDevice.CalculateServoSpeed), IsUpdated is treated as “new relative position arrived”; marking absolute updates this way can cause servo speed to be computed off stale relative data or to suppress servo writes because UpdateTime looks fresh. Consider only setting IsUpdated/UpdateTime on relative updates, or track absolute-vs-relative updates separately.

Suggested change
/// <summary>Returns a new state with an updated absolute position.</summary>
public ChannelPositionState WithAbsolutePosition(int absolutePosition) =>
this with { AbsolutePosition = absolutePosition, IsUpdated = true, UpdateTime = DateTime.Now };
/// <summary>Returns a new state with an updated absolute position without marking a new relative update.</summary>
public ChannelPositionState WithAbsolutePosition(int absolutePosition) =>
this with { AbsolutePosition = absolutePosition };

Copilot uses AI. Check for mistakes.
Comment on lines +282 to +297
foreach (KeyValuePair<int, Half> change in changes)
{
var value = ToByte(change.Value);
var channelOutputType = change.Key < NumberOfChannels
? ChannelConfigs[change.Key].OutputType
: ChannelOutputType.NormalMotor;

result = change.Key switch
{
// Light channels 1 - 6 require absolute value
>= CHANNEL_1 and <= CHANNEL_6 => await SendPortOutput_6LedAsync(ledIndex: change.Key - CHANNEL_1, value, token),
// all channels command
int.MaxValue => await SendAllOutputValuesAsync(value, token),
// classic output command for A, B, C channels (with servo support)
CHANNEL_C when channelOutputType == ChannelOutputType.ServoMotor => await SendServoValue(change.Key, value, token),
_ => await SendPortOutput_ValueAsync(change.Key, value, token),
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

SendOutputValuesAsync converts the channel value to a byte (ToByte(change.Value)) before deciding whether to send a servo command. This loses the sign (e.g., -100 becomes 156) and also makes it easy to accidentally use the wrapped byte value in servo angle math. Keep the signed/int value for servo calculations (including scaling by MaxServoAngle), and only convert to byte when building motor/LED commands.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +564 to 570
var channelPositions = ChannelRelativePositions.Get(channel);

if (channelPositions.IsUpdated)
{
if (_positionsUpdated[channel])
{
var diffAngle = Math.Abs(_relativePositions[channel] - targetAngle);
_positionsUpdated[channel] = false;
var diffAngle = Math.Abs(channelPositions.Current - targetAngle);
ChannelRelativePositions.Update(channel, x => x.ConsumeUpdate());

Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

CalculateServoSpeed does a Get() and then a separate Update(...ConsumeUpdate()). If a new position update arrives between those calls, ConsumeUpdate() can clear the new update and you’ll compute speed from a stale snapshot. To avoid losing updates, consider an atomic “read-and-consume” operation (e.g., a TryConsumeUpdate method on ChannelStateStore) so the snapshot you use is the same one you clear.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +53
var configs = channelConfigurations
.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType))
.ToDictionary(c => c.Channel, c => c);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

channelConfigurations.Where(...).ToDictionary(c => c.Channel, ...) will throw if multiple controller actions target the same device/channel (which can happen when building channel configs from controller actions). Previously duplicates would effectively overwrite; now this can fail the whole connect path. Consider de-duplicating (e.g., GroupBy channel and take the last/highest-priority config) or building the dictionary with an overwrite strategy instead of ToDictionary.

Suggested change
var configs = channelConfigurations
.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType))
.ToDictionary(c => c.Channel, c => c);
// If multiple configurations target the same channel, keep the last one.
var configs = new Dictionary<int, ChannelConfiguration>();
foreach (var channelConfiguration in channelConfigurations.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType)))
{
configs[channelConfiguration.Channel] = channelConfiguration;
}

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +167
case MESSAGE_TYPE_PORT_VALUE_COMBINED: // Port value (combined mode)
{
if (!TryGetChannelIndex(portId: data[3], out var channel))
{
break;
}

var modeMask = data[5];
var currentData = data.Slice(6); // start at index 6

if ((modeMask & 0x01) != 0)
{
var absPosition = ToInt16(currentData);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

The combined port-value handler reads data[5] and slices at index 6 without validating data.Length. A short/partial BLE notification would cause an IndexOutOfRangeException in the notification callback. Add explicit length checks (e.g., data.Length >= 6 before reading the mode mask, and ensure enough bytes exist before ToInt16(currentData) when ABS is present).

Copilot uses AI. Check for mistakes.
return; // position stable for the required duration
}
}
Debug.WriteLine($"Position time outed having {lastPosition}.");
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

Spelling/grammar: "Position time outed having ..." is awkward and looks like a typo. Consider changing it to "Position timed out (last position: ...)" or similar for clearer debug output.

Suggested change
Debug.WriteLine($"Position time outed having {lastPosition}.");
Debug.WriteLine($"Position timed out (last position: {lastPosition}).");

Copilot uses AI. Check for mistakes.
Comment on lines +309 to +311
// classic output command for A, B, C channels (with servo support)
CHANNEL_C when channelOutputType == ChannelOutputType.ServoMotor => await SendServoValue(change.Key, value, token),
_ => await SendPortOutput_ValueAsync(change.Key, value, token),
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

value here is a byte produced via ToByte(change.Value), which encodes signed motor power in two’s-complement. Passing that byte into SendServoValue loses the sign (implicit byte→int yields 0–255) and also mixes “motor power” encoding with “servo angle” semantics. For servo output, keep the original signed percentage (e.g., from change.Value) and convert it to a target angle using the channel’s max servo angle before building the GotoAbsPosition command.

Copilot uses AI. Check for mistakes.
Comment on lines +376 to +380
var portId = GetPortId(channel);
// in non PLAYVM mode, need to apply calibrated base angle as offset to reach correct position
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + value; //TODO MAX SERVO ANGLE
var cmd = BuildPortOutput_GotoAbsPosition(portId, absPosition, servoSpeed: 50);
return await WriteAsync(cmd, token);
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

SendServoValue currently treats its value parameter as a direct angle delta and adds it to _calibratedZeroAngle + ServoBaseAngle, but the caller passes a raw byte intended for direct motor output (-100..100 encoded as 0–255). This will produce incorrect absolute positions (especially for negative values) and can exceed expected ranges. Consider changing the API to accept a signed percentage (or angle) explicitly, convert percent→degrees using MaxServoAngle, and only then apply the calibrated/base offsets.

Copilot uses AI. Check for mistakes.
Comment on lines +233 to +235
// query current APOS
await WriteAsync([0x05, 0x00, 0x21, portId, 0x00], token);
await Task.Delay(250, token); //TODO wait for change
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

This hard-coded message [0x05, 0x00, 0x21, portId, 0x00] is a protocol-specific constant (0x21) with no local explanation. To make the intent clearer and avoid future drift, consider defining a named constant / helper in LegoWirelessProtocol for the “port information request” message type (and/or the whole request builder) and use it here.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +273 to +289
// setup channel to for APOS, but no notifications
var inputFormatForAbsAngleDisabled = BuildPortInputFormatSetup(portId, PORT_MODE_3, notification: PORT_VALUE_NOTIFICATION_DISABLED);
await WriteAsync(inputFormatForAbsAngleDisabled, token);
await Task.Delay(50, token);

// query current APOS
await WriteAsync([0x05, 0x00, 0x21, portId, 0x00], token);
await Task.Delay(250, token); //TODO wait for change

// setup channel to report POS position regularly
var inputFormatForRelAngle = BuildPortInputFormatSetup(portId, PORT_MODE_2);
await WriteAsync(inputFormatForRelAngle, token);
await Task.Delay(250, token); //TODO wait for change

// need to recalculate zero angle to support ABS POS commands
_calibratedZeroAngle = CalculateCalibratedTarget(channel);

Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

In non-PLAYVM mode, SetupChannelForPortInformationAsync computes _calibratedZeroAngle = CalculateCalibratedTarget(channel) immediately after fixed delays, but it never waits for ChannelAbsPositions / ChannelRelativePositions to actually be updated from the APOS query / POS notifications. If those messages haven't arrived yet, calibration will be computed from default (0) positions. Consider awaiting a first update (e.g., wait until ChannelAbsPositions.Get(channel).IsUpdated and/or ChannelRelativePositions.Get(channel).IsUpdated changes) instead of using fixed Task.Delay placeholders.

Copilot uses AI. Check for mistakes.
Comment on lines +189 to +217
case MESSAGE_TYPE_PORT_VALUE_COMBINED: // Port value (combined mode)
{
if (!TryGetChannelIndex(portId: data[3], out var channel))
{
break;
}

var modeMask = data[5];
var currentData = data.Slice(6); // start at index 6

if ((modeMask & 0x01) != 0)
{
var absPosition = ToInt16(currentData);
ChannelAbsPositions.Update(channel, pos => pos.WithPosition(absPosition));

currentData = currentData.Slice(2);
}

if ((modeMask & 0x02) != 0)
{
// TODO: Read the post value format response and determine the value length accordingly
int relPosition = currentData.Length switch
{
>= 4 => ToInt32(currentData),
>= 2 => ToInt16(currentData),
_ => currentData[0]
};
ChannelRelativePositions.Update(channel, pos => pos.WithPosition(relPosition));
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

MESSAGE_TYPE_PORT_VALUE_COMBINED parsing assumes data.Length >= 6 and that the payload contains enough bytes for each mode (ToInt16(currentData) / ToInt32(currentData)), but there are no length checks before indexing data[5] or slicing/reading. A shorter/malformed notification would throw and potentially break the BLE notification handler. Add explicit minimum-length guards before accessing data[5] and before reading 2/4 bytes from currentData.

Copilot uses AI. Check for mistakes.
Comment on lines +221 to +237
case MESSAGE_TYPE_HUB_ATTACHED_IO: // Hub attached I/O
{
Dump("HUB_ATTACHED_IO", data);

if (TryGetChannelIndex(portId: data[3], out var channel))
{
byte eventType = data[4]; // 0x01 = Attached, 0x00 = Detached, 0x02 = Attached Virtual

if (eventType == 0x01 || eventType == 0x02)
{
var deviceId = ToUInt16(data.Slice(5));
AttachedHubs.Update(channel, info => info.WithDevice(deviceId)); // store portId as "position" for simplicity
}
else if (eventType == 0x00)
{
AttachedHubs.Remove(channel);
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

MESSAGE_TYPE_HUB_ATTACHED_IO handling reads data[4] and ToUInt16(data.Slice(5)) without verifying the message is long enough (needs at least 7 bytes for the device id). Add a data.Length guard (and consider validating the expected event payload size) to avoid ArgumentOutOfRangeException on unexpected messages.

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +7
using System.Numerics;

namespace BrickController2.DeviceManagement.Lego;

/// <summary>
/// Immutable snapshot of a motor channel's position feedback.
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

ChannelAttachmentInfo's XML summary says it's a snapshot of a motor channel's position feedback, but the record represents attachment/device-id info. Also using System.Numerics; is unused here. Updating the summary and removing the unused using will reduce confusion and warnings.

Suggested change
using System.Numerics;
namespace BrickController2.DeviceManagement.Lego;
/// <summary>
/// Immutable snapshot of a motor channel's position feedback.
namespace BrickController2.DeviceManagement.Lego;
/// <summary>
/// Immutable snapshot of a channel's attachment state, including device ID and update metadata.

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +82
public override Task<DeviceConnectionResult> ConnectAsync(
bool reconnect,
Action<Device> onDeviceDisconnected,
IEnumerable<ChannelConfiguration> channelConfigurations,
bool startOutputProcessing,
bool requestDeviceInformation,
CancellationToken token)
{
// reset output values & positions
ResetOutputValues();
ChannelAbsPositions.Clear();
ChannelRelativePositions.Clear();
AttachedHubs.Clear();

// Initialize configuration per channel

// build dictionary, but for supported ones only
var configs = channelConfigurations
.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType))
.ToDictionary(c => c.Channel, c => c);

for (int i = 0; i < NumberOfChannels; i++)
{
configs.TryGetValue(i, out var config);

ChannelConfigs[i] = config.ChannelOutputType switch
{
ChannelOutputType.ServoMotor => new()
{
OutputType = ChannelOutputType.ServoMotor,
MaxServoAngle = config.MaxServoAngle,
ServoBaseAngle = config.ServoBaseAngle
},
ChannelOutputType.StepperMotor => new()
{
OutputType = ChannelOutputType.StepperMotor,
StepperAngle = config.StepperAngle
},
_ => new()
};
}

return base.ConnectAsync(reconnect, onDeviceDisconnected, channelConfigurations, startOutputProcessing, requestDeviceInformation, token);
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

This new WirelessProtocolBasedDevice base class introduces non-trivial connection/config parsing logic (e.g., channel-config normalization and message parsing). There are existing unit tests for OutputValuesGroup and LEGO device discovery, but no tests covering these new behaviors (notably: duplicate channel configurations, and parsing of combined/attached-IO messages). Adding focused unit tests around ConnectAsync config handling and TryProcessMessageData length-guarding would help prevent regressions.

Copilot uses AI. Check for mistakes.
Comment on lines +56 to +58
var configs = channelConfigurations
.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType))
.ToDictionary(c => c.Channel, c => c);
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

ConnectAsync builds configs via ToDictionary(c => c.Channel, ...). channelConfigurations can include multiple entries for the same channel (e.g., multiple actions targeting the same device/channel), which will throw ArgumentException at runtime. Prefer grouping/deduping (e.g., GroupBy(Channel) and take last/first, or DistinctBy) before creating the dictionary, or populate ChannelConfigs via a simple loop that tolerates duplicates.

Suggested change
var configs = channelConfigurations
.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType))
.ToDictionary(c => c.Channel, c => c);
var configs = new Dictionary<int, ChannelConfiguration>();
foreach (var config in channelConfigurations.Where(c => IsOutputTypeSupported(c.Channel, c.ChannelOutputType)))
{
configs[config.Channel] = config;
}

Copilot uses AI. Check for mistakes.
Comment on lines +422 to +441
var stableSince = Stopwatch.StartNew();
var lastUpdated = AttachedHubs.Max(x => x.UpdateTime);

try
{
while (!linkedCts.Token.IsCancellationRequested)
{
await Task.Delay(interval, linkedCts.Token);

var currentValue = AttachedHubs.Max(x => x.UpdateTime);
if (currentValue != lastUpdated)
{
lastUpdated = currentValue;
stableSince.Restart();
}
else if (stableSince.Elapsed >= stabilityTimeout)
{
Dump("Hub connection: HUBS", AttachedHubs.Count);
return; // position stable for the required duration
}
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

AwaitForHubConnectedAsync returns once AttachedHubs.Max(UpdateTime) is stable for 250ms, but if no HUB_ATTACHED_IO messages arrive then lastUpdated stays at DateTime.MinValue and the method still returns after ~250ms (false positive). Consider requiring at least one attachment update (e.g., AttachedHubs.Count > 0 or lastUpdated != DateTime.MinValue) before treating the connection as "stable", otherwise wait until timeout elapses.

Copilot uses AI. Check for mistakes.
Comment on lines +400 to +401
// in non PLAYVM mode, need to apply calibrated base angle as offset to reach correct position
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + value; //TODO MAX SERVO ANGLE
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

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

SendServoValue computes absPosition by adding the raw output value directly to the calibrated/base angles, but the stored output values are percentages (-100..100) in the rest of the codebase (and PLAYVM path scales by MaxServoAngle). This makes non-PLAYVM servo output inconsistent and ignores MaxServoAngle (noted by the TODO). Consider scaling value to degrees using GetMaxServoAngle(channel) (and clamping to valid range) before building the Goto-ABS command.

Suggested change
// in non PLAYVM mode, need to apply calibrated base angle as offset to reach correct position
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + value; //TODO MAX SERVO ANGLE
var maxServoAngle = GetMaxServoAngle(channel);
var clampedValue = Math.Clamp(value, -100, 100);
var relativeAngle = maxServoAngle * clampedValue / 100;
// in non PLAYVM mode, apply calibrated base angle plus the scaled servo offset
var absPosition = _calibratedZeroAngle + ChannelConfigs[channel].ServoBaseAngle + relativeAngle;

Copilot uses AI. Check for mistakes.
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.

2 participants