From 4f8603392fce22420f8dd1ebbb85f34955b72f40 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Sat, 26 Oct 2024 20:38:04 +0200 Subject: [PATCH 01/14] Techic Move hub - base implementation -test screen --- .../BrickController2/BrickController2.csproj | 2 + .../BluetoothDeviceManager.cs | 1 + .../DeviceManagement/ControlPlusDevice.cs | 79 ++++++++++-------- .../DI/DeviceManagementModule.cs | 1 + .../DeviceManagement/DeviceType.cs | 1 + .../DeviceManagement/TechnicMoveDevice.cs | 39 +++++++++ .../UI/Controls/DeviceChannelLabel.cs | 1 + .../UI/Controls/DeviceChannelSelector.xaml | 19 +++++ .../UI/Controls/DeviceChannelSelector.xaml.cs | 13 ++- .../Converters/DeviceTypeToImageConverter.cs | 2 + .../DeviceTypeToSmallImageConverter.cs | 3 + .../UI/Images/technic_move.png | Bin 0 -> 74797 bytes .../UI/Images/technic_move_small.png | Bin 0 -> 16689 bytes 13 files changed, 122 insertions(+), 39 deletions(-) create mode 100644 BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs create mode 100644 BrickController2/BrickController2/UI/Images/technic_move.png create mode 100644 BrickController2/BrickController2/UI/Images/technic_move_small.png diff --git a/BrickController2/BrickController2/BrickController2.csproj b/BrickController2/BrickController2/BrickController2.csproj index 46b4cca3..ce027e5c 100644 --- a/BrickController2/BrickController2/BrickController2.csproj +++ b/BrickController2/BrickController2/BrickController2.csproj @@ -46,6 +46,8 @@ + + diff --git a/BrickController2/BrickController2/DeviceManagement/BluetoothDeviceManager.cs b/BrickController2/BrickController2/DeviceManagement/BluetoothDeviceManager.cs index 821959b4..2f496f9c 100644 --- a/BrickController2/BrickController2/DeviceManagement/BluetoothDeviceManager.cs +++ b/BrickController2/BrickController2/DeviceManagement/BluetoothDeviceManager.cs @@ -96,6 +96,7 @@ public async Task ScanAsync(Func ValidateServicesAsync(IEnumerable (byte)channelIndex; + protected virtual int GetChannelIndex(byte portId) => portId; + protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] data) { if (characteristicGuid != CHARACTERISTIC_UUID || data.Length < 4) @@ -224,7 +227,7 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] case 0x46: // Port value (combined mode) lock (_positionLock) { - var portId = data[3]; + var channel = GetChannelIndex(data[3]); var modeMask = data[5]; var dataIndex = 6; @@ -235,7 +238,7 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] new byte[] { data[dataIndex + 1], data[dataIndex + 0] }; var absPosition = BitConverter.ToInt16(absPosBuffer, 0); - _absolutePositions[portId] = absPosition; + _absolutePositions[channel] = absPosition; dataIndex += 2; } @@ -250,7 +253,7 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] new byte[] { data[dataIndex + 3], data[dataIndex + 2], data[dataIndex + 1], data[dataIndex + 0] }; var relPosition = BitConverter.ToInt32(relPosBuffer, 0); - _relativePositions[portId] = relPosition; + _relativePositions[channel] = relPosition; } else if ((dataIndex + 1) < data.Length) { @@ -259,15 +262,15 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] new byte[] { data[dataIndex + 1], data[dataIndex + 0] }; var relPosition = BitConverter.ToInt16(relPosBuffer, 0); - _relativePositions[portId] = relPosition; + _relativePositions[channel] = relPosition; } else { - _relativePositions[portId] = data[dataIndex]; + _relativePositions[channel] = data[dataIndex]; } - _positionsUpdated[portId] = true; - _positionUpdateTimes[portId] = DateTime.Now; + _positionsUpdated[channel] = true; + _positionUpdateTimes[channel] = DateTime.Now; } } @@ -398,7 +401,7 @@ private async Task SendOutputValueAsync(int channel, CancellationToken tok if (v != _lastOutputValues[channel] || sendAttemptsLeft > 0) { - _sendBuffer[3] = (byte)channel; + _sendBuffer[3] = GetPortId(channel); _sendBuffer[7] = (byte)(v < 0 ? (255 + v) : v); if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _sendBuffer, token)) @@ -476,7 +479,7 @@ private async Task SendServoOutputValueAsync(int channel, CancellationToke return true; } - _servoSendBuffer[3] = (byte)channel; + _servoSendBuffer[3] = GetPortId(channel); _servoSendBuffer[6] = (byte)(servoValue & 0xff); _servoSendBuffer[7] = (byte)((servoValue >> 8) & 0xff); _servoSendBuffer[8] = (byte)((servoValue >> 16) & 0xff); @@ -517,7 +520,7 @@ private async Task SendStepperOutputValueAsync(int channel, CancellationTo } var stepperAngle = _stepperAngles[channel]; - _stepperSendBuffer[3] = (byte)channel; + _stepperSendBuffer[3] = GetPortId(channel); _stepperSendBuffer[6] = (byte)(stepperAngle & 0xff); _stepperSendBuffer[7] = (byte)((stepperAngle >> 8) & 0xff); _stepperSendBuffer[8] = (byte)((stepperAngle >> 16) & 0xff); @@ -554,21 +557,22 @@ private async Task SetupChannelForPortInformationAsync(int channel, Cancel { try { - var lockBuffer = new byte[] { 0x05, 0x00, 0x42, (byte)channel, 0x02 }; - var inputFormatForAbsAngleBuffer = new byte[] { 0x0a, 0x00, 0x41, (byte)channel, 0x03, 0x02, 0x00, 0x00, 0x00, 0x01 }; - var inputFormatForRelAngleBuffer = new byte[] { 0x0a, 0x00, 0x41, (byte)channel, 0x02, 0x02, 0x00, 0x00, 0x00, 0x01 }; - var modeAndDataSetBuffer = new byte[] { 0x08, 0x00, 0x42, (byte)channel, 0x01, 0x00, 0x30, 0x20 }; - var unlockAndEnableBuffer = new byte[] { 0x05, 0x00, 0x42, (byte)channel, 0x03 }; + var portId = GetPortId(channel); + var lockBuffer = new byte[] { 0x05, 0x00, 0x42, portId, 0x02 }; + var inputFormatForAbsAngleBuffer = new byte[] { 0x0a, 0x00, 0x41, portId, 0x03, 0x02, 0x00, 0x00, 0x00, 0x01 }; + var inputFormatForRelAngleBuffer = new byte[] { 0x0a, 0x00, 0x41, portId, 0x02, 0x02, 0x00, 0x00, 0x00, 0x01 }; + var modeAndDataSetBuffer = new byte[] { 0x08, 0x00, 0x42, portId, 0x01, 0x00, 0x30, 0x20 }; + var unlockAndEnableBuffer = new byte[] { 0x05, 0x00, 0x42, portId, 0x03 }; var result = true; result = result && await _bleDevice!.WriteAsync(_characteristic!, lockBuffer, token); - await Task.Delay(20); + await Task.Delay(20, token); result = result && await _bleDevice!.WriteAsync(_characteristic!, inputFormatForAbsAngleBuffer, token); - await Task.Delay(20); + await Task.Delay(20, token); result = result && await _bleDevice!.WriteAsync(_characteristic!, inputFormatForRelAngleBuffer, token); - await Task.Delay(20); + await Task.Delay(20, token); result = result && await _bleDevice!.WriteAsync(_characteristic!, modeAndDataSetBuffer, token); - await Task.Delay(20); + await Task.Delay(20, token); result = result && await _bleDevice!.WriteAsync(_characteristic!, unlockAndEnableBuffer, token); return result; @@ -592,11 +596,11 @@ private async Task ResetServoAsync(int channel, int baseAngle, Cancellatio result = result && await ResetAsync(channel, 0, token); result = result && await StopAsync(channel, token); result = result && await TurnAsync(channel, 0, 40, token); - await Task.Delay(50); + await Task.Delay(50, token); result = result && await StopAsync(channel, token); result = result && await ResetAsync(channel, resetToAngle, token); result = result && await TurnAsync(channel, 0, 40, token); - await Task.Delay(500); + await Task.Delay(500, token); result = result && await StopAsync(channel, token); var diff = Math.Abs(NormalizeAngle(_absolutePositions[channel] - baseAngle)); @@ -606,7 +610,7 @@ private async Task ResetServoAsync(int channel, int baseAngle, Cancellatio result = result && await ResetAsync(channel, 0, token); result = result && await StopAsync(channel, token); result = result && await TurnAsync(channel, 0, 40, token); - await Task.Delay(50); + await Task.Delay(50, token); result = result && await StopAsync(channel, token); } @@ -627,19 +631,19 @@ private async Task ResetServoAsync(int channel, int baseAngle, Cancellatio result = result && await ResetAsync(channel, 0, token); result = result && await StopAsync(channel, token); result = result && await TurnAsync(channel, 0, 50, token); - await Task.Delay(600); + await Task.Delay(600, token); result = result && await StopAsync(channel, token); - await Task.Delay(500); + await Task.Delay(500, token); var absPositionAt0 = _absolutePositions[channel]; result = result && await TurnAsync(channel, -160, 60, token); - await Task.Delay(600); + await Task.Delay(600, token); result = result && await StopAsync(channel, token); - await Task.Delay(500); + await Task.Delay(500, token); var absPositionAtMin160 = _absolutePositions[channel]; result = result && await TurnAsync(channel, 160, 60, token); - await Task.Delay(600); + await Task.Delay(600, token); result = result && await StopAsync(channel, token); - await Task.Delay(500); + await Task.Delay(500, token); var absPositionAt160 = _absolutePositions[channel]; var midPoint1 = NormalizeAngle((absPositionAtMin160 + absPositionAt160) / 2); @@ -653,11 +657,11 @@ private async Task ResetServoAsync(int channel, int baseAngle, Cancellatio result = result && await ResetAsync(channel, 0, token); result = result && await StopAsync(channel, token); result = result && await TurnAsync(channel, 0, 40, token); - await Task.Delay(50); + await Task.Delay(50, token); result = result && await StopAsync(channel, token); result = result && await ResetAsync(channel, resetToAngle, token); result = result && await TurnAsync(channel, 0, 40, token); - await Task.Delay(600); + await Task.Delay(600, token); result = result && await StopAsync(channel, token); return (result, baseAngle / 180F); @@ -718,31 +722,34 @@ private int CalculateServoSpeed(int channel, int targetAngle) private Task StopAsync(int channel, CancellationToken token) { - return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x08, 0x00, 0x81, (byte)channel, 0x11, 0x51, 0x00, 0x00 }, token); + var portId = GetPortId(channel); + return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x08, 0x00, 0x81, portId, 0x11, 0x51, 0x00, 0x00 }, token); } private Task TurnAsync(int channel, int angle, int speed, CancellationToken token) { angle = NormalizeAngle(angle); + var portId = GetPortId(channel); var a0 = (byte)(angle & 0xff); var a1 = (byte)((angle >> 8) & 0xff); var a2 = (byte)((angle >> 16) & 0xff); var a3 = (byte)((angle >> 24) & 0xff); - return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x0e, 0x00, 0x81, (byte)channel, 0x11, 0x0d, a0, a1, a2, a3, (byte)speed, 0x64, 0x7e, 0x00 }, token); + return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x0e, 0x00, 0x81, portId, 0x11, 0x0d, a0, a1, a2, a3, (byte)speed, 0x64, 0x7e, 0x00 }, token); } private Task ResetAsync(int channel, int angle, CancellationToken token) { angle = NormalizeAngle(angle); + var portId = GetPortId(channel); var a0 = (byte)(angle & 0xff); var a1 = (byte)((angle >> 8) & 0xff); var a2 = (byte)((angle >> 16) & 0xff); var a3 = (byte)((angle >> 24) & 0xff); - return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x0b, 0x00, 0x81, (byte)channel, 0x11, 0x51, 0x02, a0, a1, a2, a3 }, token); + return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x0b, 0x00, 0x81, portId, 0x11, 0x51, 0x02, a0, a1, a2, a3 }, token); } private async Task RequestHubPropertiesAsync(CancellationToken token) @@ -750,19 +757,19 @@ private async Task RequestHubPropertiesAsync(CancellationToken token) try { // Request firmware version - await Task.Delay(TimeSpan.FromMilliseconds(300)); + await Task.Delay(TimeSpan.FromMilliseconds(300), token); await _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x05, 0x00, 0x01, 0x03, 0x05 }, token); var data = await _bleDevice!.ReadAsync(_characteristic!, token); ProcessHubPropertyData(data); // Request hardware version - await Task.Delay(TimeSpan.FromMilliseconds(300)); + await Task.Delay(TimeSpan.FromMilliseconds(300), token); await _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x05, 0x00, 0x01, 0x04, 0x05 }, token); data = await _bleDevice!.ReadAsync(_characteristic!, token); ProcessHubPropertyData(data); // Request battery voltage - await Task.Delay(TimeSpan.FromMilliseconds(300)); + await Task.Delay(TimeSpan.FromMilliseconds(300), token); await _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x05, 0x00, 0x01, 0x06, 0x05 }, token); data = await _bleDevice!.ReadAsync(_characteristic!, token); ProcessHubPropertyData(data); diff --git a/BrickController2/BrickController2/DeviceManagement/DI/DeviceManagementModule.cs b/BrickController2/BrickController2/DeviceManagement/DI/DeviceManagementModule.cs index 2c21dbc7..cf00591f 100644 --- a/BrickController2/BrickController2/DeviceManagement/DI/DeviceManagementModule.cs +++ b/BrickController2/BrickController2/DeviceManagement/DI/DeviceManagementModule.cs @@ -23,6 +23,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().Keyed(DeviceType.DuploTrainHub); builder.RegisterType().Keyed(DeviceType.CircuitCubes); builder.RegisterType().Keyed(DeviceType.WeDo2); + builder.RegisterType().Keyed(DeviceType.TechnicMove); builder.Register(c => { diff --git a/BrickController2/BrickController2/DeviceManagement/DeviceType.cs b/BrickController2/BrickController2/DeviceManagement/DeviceType.cs index 64d3ca5e..c4749c5d 100644 --- a/BrickController2/BrickController2/DeviceManagement/DeviceType.cs +++ b/BrickController2/BrickController2/DeviceManagement/DeviceType.cs @@ -14,5 +14,6 @@ public enum DeviceType BuWizz3, CircuitCubes, WeDo2, + TechnicMove } } diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs new file mode 100644 index 00000000..64f5c351 --- /dev/null +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -0,0 +1,39 @@ +using BrickController2.PlatformServices.BluetoothLE; +using System; + +namespace BrickController2.DeviceManagement +{ + internal class TechnicMoveDevice : ControlPlusDevice + { + private const byte PORT_DRIVE_MOTOR_1 = 0x32; + private const byte PORT_DRIVE_MOTOR_2 = 0x33; + private const byte PORT_STEERING_MOTOR = 0x34; + + public TechnicMoveDevice(string name, string address, byte[] deviceData, IDeviceRepository deviceRepository, IBluetoothLEService bleService) + : base(name, address, deviceRepository, bleService) + { + } + + public override DeviceType DeviceType => DeviceType.TechnicMove; + public override int NumberOfChannels => 3; + + public override bool CanAutoCalibrateOutput(int channel) => channel == 2; + public override bool CanResetOutput(int channel) => channel == 2; + + protected override byte GetPortId(int channelIndex) => channelIndex switch + { + 0 => PORT_DRIVE_MOTOR_1, + 1 => PORT_DRIVE_MOTOR_2, + 2 => PORT_STEERING_MOTOR, + _ => throw new ArgumentException($"Value of channel '{channelIndex}' is out of supported range.", nameof(channelIndex)) + }; + + protected override int GetChannelIndex(byte portId) => portId switch + { + PORT_DRIVE_MOTOR_1 => 0, + PORT_DRIVE_MOTOR_2 => 1, + PORT_STEERING_MOTOR => 2, + _ => throw new ArgumentException($"Value of port ID '{portId}' is out of supported ranges.", nameof(portId)) + }; + } +} diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs index b64a0cda..138f543c 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs @@ -51,6 +51,7 @@ private void SetChannelText() case DeviceType.PoweredUp: case DeviceType.TechnicHub: case DeviceType.WeDo2: + case DeviceType.TechnicMove: Text = _controlPlusChannelLetters[Math.Min(Math.Max(Channel, 0), 3)]; break; diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml index 53d95f53..c26ade24 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml @@ -214,6 +214,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs index f829e1ed..bded0d22 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs @@ -1,7 +1,7 @@ -using Microsoft.Maui.Controls; -using Microsoft.Maui.Controls.Xaml; -using BrickController2.DeviceManagement; +using BrickController2.DeviceManagement; using BrickController2.UI.Commands; +using Microsoft.Maui.Controls; +using Microsoft.Maui.Controls.Xaml; namespace BrickController2.UI.Controls { @@ -44,6 +44,9 @@ public DeviceChannelSelector() CircuitCubesC.Command = new SafeCommand(() => SelectedChannel = 2); WedoChannel0.Command = new SafeCommand(() => SelectedChannel = 0); WedoChannel1.Command = new SafeCommand(() => SelectedChannel = 1); + TechnicMoveChannelA.Command = new SafeCommand(() => SelectedChannel = 0); + TechnicMoveChannelB.Command = new SafeCommand(() => SelectedChannel = 1); + TechnicMoveChannelC.Command = new SafeCommand(() => SelectedChannel = 2); } public static BindableProperty DeviceTypeProperty = BindableProperty.Create(nameof(DeviceType), typeof(DeviceType), typeof(DeviceChannelSelector), default(DeviceType), BindingMode.OneWay, null, OnDeviceTypeChanged); @@ -76,6 +79,7 @@ private static void OnDeviceTypeChanged(BindableObject bindable, object oldValue dcs.DuploTrainHubSection.IsVisible = deviceType == DeviceType.DuploTrainHub; dcs.CircuitCubes.IsVisible = deviceType == DeviceType.CircuitCubes; dcs.Wedo2Section.IsVisible = deviceType == DeviceType.WeDo2; + dcs.TechnicMoveSection.IsVisible = deviceType == DeviceType.TechnicMove; } } @@ -116,6 +120,9 @@ private static void OnSelectedChannelChanged(BindableObject bindable, object old dcs.CircuitCubesC.SelectedChannel = selectedChannel; dcs.WedoChannel0.SelectedChannel = selectedChannel; dcs.WedoChannel1.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannelA.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannelB.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannelC.SelectedChannel = selectedChannel; } } } diff --git a/BrickController2/BrickController2/UI/Converters/DeviceTypeToImageConverter.cs b/BrickController2/BrickController2/UI/Converters/DeviceTypeToImageConverter.cs index ba6938e5..c20f6efa 100644 --- a/BrickController2/BrickController2/UI/Converters/DeviceTypeToImageConverter.cs +++ b/BrickController2/BrickController2/UI/Converters/DeviceTypeToImageConverter.cs @@ -49,6 +49,8 @@ public class DeviceTypeToImageConverter : IValueConverter case DeviceType.WeDo2: return ResourceHelper.GetImageResource("wedo2hub_image.png"); + case DeviceType.TechnicMove: + return ResourceHelper.GetImageResource("technic_move.png"); default: return null; } diff --git a/BrickController2/BrickController2/UI/Converters/DeviceTypeToSmallImageConverter.cs b/BrickController2/BrickController2/UI/Converters/DeviceTypeToSmallImageConverter.cs index bc6d7b5b..682b9a0d 100644 --- a/BrickController2/BrickController2/UI/Converters/DeviceTypeToSmallImageConverter.cs +++ b/BrickController2/BrickController2/UI/Converters/DeviceTypeToSmallImageConverter.cs @@ -44,6 +44,9 @@ public class DeviceTypeToSmallImageConverter : IValueConverter case DeviceType.WeDo2: return ResourceHelper.GetImageResource("wedo2hub_image_small.png"); + case DeviceType.TechnicMove: + return ResourceHelper.GetImageResource("technic_move_small.png"); + default: return null; } diff --git a/BrickController2/BrickController2/UI/Images/technic_move.png b/BrickController2/BrickController2/UI/Images/technic_move.png new file mode 100644 index 0000000000000000000000000000000000000000..e273428c7c29aec015da59cbecaf79d2c49242fc GIT binary patch literal 74797 zcmV)$K#sqOP)VKn1`$9^y@7-JiHGi;18u%GelP|DFR)?Q zhW#)Djh4|yQ6eRZ&F*G*b#0lInU(u;_j4lrFA;I#oO^F(Wpz`mrtVKx=e_5iHO@I- z{PF$Y|3?Z_o7&W-HnoqXr9bKunA+5)Hnpien@s_HYEzrq)IO#*1@Ng&ZE92dnA#M; zKZI@V9HgMt0vNk!06JjJu~vGhv@+M$mhF%F{H8Xwsr`7{6u_UsHV^i0;GZ|MeE1bG z@EU%;npty_D!7k8ex^Y0Z0+oP3n2MEfNm>oj<2n)P62&tQ=8gHYEuAz3fnq3nm5M0 zYV-Un=v}{LbNlZPO}1uiURrP9AX?`+Sc}lEOdr2>Rc_wd*xA{_pT2K3{3QbXky7wL zTiDW|&Om8cUtO63{M4p4wM*F)z|XO*?LBRj{<(aZ{YOKvp9IKWMOdabQ&sSe&47gg zqBMSNfi*2q`BycrpIZ3U43aLcf&btUe*XYL{5}GBXKQEo_lz~)OO)Qdw!Sub=Tn>7 z)TZWa3gFImjt;fS@Gm`p(@HOc1Y*4A5A=^x+^9o+n(R{9Zcd4M~^f3T0=J5!H+YEzrq zUuyv0-aSNo%+W)JU&w~pD;CU~h_tUGh~L7`Oexh$lrP(1{<+L#Uqg)iG=lYK2l#uyQx$FW5r8%Ww%ad#rbegZ88y1D^VGwp z1IVc(p?_Dk%|DSs@>ABrzXO){*th7lKib)S8$a*jvfsjg@1ci&0ExC*sr!(@F(_@W zudieu{z|4cwW&Q%_SXWyw-1gIo10az`LE`~{MQh$Uk6f-fN6x4(Bm~1>_@AF|2`d! z;i%YvdYUh1}jkH}%)^Jb!a&vX|gT zaGiX>2)uJuW@av99=r*+6u_cpe7 zzoTsSFYxB65yXGt@_v2R;Luq)TZ{F*^dQ)Z|xl}L7smd0sH6BS^ZjO%vD@` z1ul^LU1agLm)%dMq;Uzn%~ODP)x#g>@%w%FeV?~InFo9hm?~L7eM*M7o#wnQaYCf9 znX{yd0r&}(B)?3nsC_VfXWsZDKa7us_V;P*DS)^gkX`)A#gf12lg3eY74WVLz?Qk^0`X-E{Q0||BL zA9;%6g{GPSP4;8#D8N52W*9WZr6%C!z^4{;^k@7m>J^qoKEox)om1IM&!Zm+&~xfi z8rbHf=2^4^@)~|JtRYfI1nvAx*<@#Pd-pvHhEy^C3OC#Uk~;!C!GAraRd#J{bs9vS z+SD##&ozK=@9wG7_St{Y9kzd?-8r2#q^1y1z;_>UTx+N&1mZaMuhpQIrjR5xT;2zr z(4}bxaWO%~%*b;3{w&`4(|XdET|b2vuU(_B4#_{nO%L&2?%}dWc+GoY^#11V?w@L<{sbIsVOd?@SK{1IUd98!~GYc%K_zdijTFO^OYTz>y-j zCkWzc1A!fvr6~e?1HoRA$DTNk-5Ze&g7lt0ep(N>m}M<_KLb}b%0ei$mrI;HJW>y}rgj|k38%V7JC{T%{mhwm((R5CK+>oA}Y z%olJ0(J*)L?-ymwoKFM)?dCc;+}PSW#)VV>>;px#3?v`m=li(zktUBF;Pl%1+SFs8 z+FxsXZUMa8>tF2;`v0KQ>*!&B2$?l5m0ck~(0f~U&=|~ffpx+RO`i~?RYx)BLLNB* zd@T`R*R>Qq_8O!$^5hL9I%P^GjX)`jbLXvqkWa1mU_1$sCxNwJejf0bK>wUJii4lk zW$`g&Kk_-g;VS$GT$ZQ<@5*KS?^SP`mtlvfm7Jw_OGk7HRfX54&pG zDNfk)1qZ^8TQ8lGEs~5<2i8^v%;f-REu8y!GuR+H0&-BrK`eHTq-IG?fyf!2Q;z@h zUOcs}bt3@ljukpm-Yl=9S5LCgE3!{M?_YLpX^c(CBj3aGco#kQe~HW9!~3>NlWX8- zsuM$hkFY~O#pQMi@>Bb9vF8@RZI-=08208!^k!HI;lP^;ZASp%!BabTlCl;BU~?vF zRRkRVt<9_eJtxR$0)ak%PU#)~<-pfw$LB}oXH zmM!NFC?$1?oaTj8O7NuVd=t`b%NI_l9ODU(v(zR`dw5zy!MN=N%JzF%Li(qU~Fz7v4z&7PyD%irJ1_bLOjS{3X~Gvf`1V>>bQ)6ow#-K z))jj0TG#lubOP_8a)aQXzyN{uqSCabBqDISZQjW!2s2$mpjed*sxl{Sp$y{eVLw%8 zpg}zau*XIU6jBG#HCrWKI9*AqD~+QHASZf@Y?v1Vg^W0P7z5%^4w#1TxH^LWqPF=W zk31lG(f#)2OjI(|IpnD!fz#HGCH_q7_wWRNS1GdrR1_>5KEw|6?E1~?Q!V?{o@;w< z0UY7A6?=8ISUypM?SpdG6qJUwV?aCyx2A$DhY~m$-b>L@X!6u^Ss|q;B2hD%a6$s5 zD)iJ9dhHDYas+y!V+i0WU|Pq|1e}b$^xcvgW>As%sSC^18c|RNOWpdJ|695a z$+(m#uiR+rhDv0Q8q2CgrbBqQBrR0BvSBDfh$&-JpW`LA2C-~(6 z+vQ#CiGQ%Uy|a(&?cnwPIiBGYywAIMyYuzc89}C`WT6XE^6$XChw#747K3fO9$3S=U0KoZ?taQnQeQ}n83LYA0Vi6fZUM z)@%G%HB$WCgtSilx?)bAyk>A5yU%tclo>jjfUdUy`hxE8Mxa=89C8*xt&XZIY6jS( zL}UAIs?;Iy%EJ3I2)kJEB(x$R&$-KEJt6+rwW}x4W2!|{d$la@k(|_lC)foFl6f;($6}@WMgaN zxABDTL~?Lle!C_N^(*yNy0UH%YynQ6>zf!m@6wmNZ)AWowG-dpjLBD(Fe)0tXT*65sg## z>GB!^czgAUujP_BP-gGja`ytm#9zF@>v0_jbpW}M?IW+-d+JQxP;3Jv)t|CYRY=z_ zf~VdjfvHj#M*B`nI;y(w*edy2e&URA9((N35Abt+!Xty8I+Y{inlb!fYWOrT4P#F6 zQajjQkMXcwJnlPq-MgFHTi?_wy-(6d>=`=i*H)&0KD8f;J+}bP{Hb!e<3>Z(nfAbD z@|?YA0&IS7#{joq*Mq$hcRB#Ss;o=9P^#Fb=t9Jll{Cpb+BkPEC9iJ;wT4=`5rJ_r zRm(w8x~fEUb$sp&>>M-|N~V#ZPhEGiM+9`@*;Dl5Qq=q3y$Z@NUq(~6vg zm9ySlb`Z%NEB978uQ>}+%$(^T27I^8uo6QU`27X-jhLT6W1N+etj+=Y%GtSf@&wOw z2`_aiOW+1++;RJ_(SA~<>~o7vw~HTlH@0@};gVClwSR=a-ldu`Ak@sh(G+Oay_;)O zP5aa?Z_h1&19(jtnIf%NX~=A2b;&uP9ec3jmVqt6*@Jt`iI1aU{P15of@%jLu~zzA zJuSm3b+;}M1!35mP4F#G`N1fgmxTImr*2AG?W+i#;AAtej61R?!CSsEzbxX+yR z9Y`8cK$!?Jnfk@V^~wl2d%VpP*l|nOQilsoI}yZtwmcfjoga$TWG=0P55(wC5SXvnVXd z3y3}$3#d=I?6SY93#1p-{ggWk6@WY}qkX!!T=dq4*Ku$QI+trDb-5zcbkv0&3`F0a z2%yvGM3R$4?$}I)e(|ap0{RBYA)z6o7>Vr4p9=9kItCG|21H{Wd6t)vj=mFkCssJ| zQV|#5mb&n^eFx$nJ}yhH3%)vY$v?rvwozI?ebvI1`^#{(&gEPy${qrHamq*W@KS(X ziR2EF;_38rocdKfZ4*3vccOR;s1K#$YfJe1Eubt^YL9&Qk7P`iXI3ZsxRXEF*xdSK z{Qe%c+Zo=&6Wqxzc2xKmI=JV-_4U;$z)$Tt25@UK;ZQXveN@NUvDZ6p`MB@r0r%Kb zh&l4gtT^M2J#O1c#}qGKm8f0o2xD5rM2j-wv0Lo|nWAe?SPrNnkG+E)c_Q9A{Y?|D z5tArMNTR}DwR+0grZuK)5)LT9HKtY|de9}~^HSjLYt!fo#p1!cv@b1ml<_hjh?1{( zd)IkI+XxrWMa)vXzIFWackv(1BGo|IY;i6+7lJ0`oE2A2K#M0! zf4DYGsu|#G#IX~vPM_;_Q1qF&c1PI|(ij+sGKOnW#cCaw6y($5qtE_(xTZ>7lmg`_J%0FIBLE*Kz!4bEA)K^M`np@8R-e+!Lu|Xv(}N zH?OTf>olxWd(Q2-1#pXiZJpQ2dTs+Q%hHVJvSGZpormI7(Hq7HsRaC^7c?W?@`%{Z zD?exJn2!kz zjstPOjdIfUP1a=)NvxHR%;-`C8nxf3ob&%TI&j!nfH$Syt&Z)4lOh2A>-6L{kFMYJefp``4i&`(bq<{$x0wL0BCG|*o#fbCDIReZh zmAJ!*&Vf|hR5D;FS*E@S7?fOI1aw~aJZQSNr^gcT|}#f!{LenEUk<3FEg=Yf56 zD87yW@{a1!-iH9m8OTPSuhP?{Gn+Y{o##LOT59sI$xrSr(rFi+pp8e{TW{h1zn6l3 z7w`P<;&ZrpbA75`|G3%n3*e!Q9R#HZdEdr);CY2QsRFk3fwnNcjDN#@m*7+_v96q# zjlOB)xbATs#3YksoKoZvzf-4>ffp4{+}4J3w z>LMtRN(4%pIbU972)OwDOfzsNTys5Tj$ISD*G0q5^s#!)Vl_7P8yH)*=O zu-wXcC%t4ngYx=L4r`z_(8EvQ3-Ss4{3|)sJJ^|j6Fc*NwzaeSe_WrI=>1sPa|__; z$?i*#PpA%hIdcSM0`43?`-GnS0-iZ>NhVRHq`;z-b$+x&ia+_u3wWu4-~&qvxVT4( zsvNS*>N)Gtpk)mFWx)3%1nWF@&$E|D8oodmfviw7lBwzCibaQ1T!H^aDi}p04Bj@X z6ys!)?EQ~4otGh67zPSQu7QGYTm@W9hN7D`41b z5>9F=HLyP~8{LEG2TD#FRBEcrLo3Tf_wKeyiqf@^=V4OmDtO0~NesGmi5>?#FA1n> zJaX{-x`I$w9b&FNJ`Rl8FDE?7a})!A48-(#)C-5w?^TOO3Vj$jD&6ux)Y=|Rl=8Fq z^u3uS={N3fZvEG<+`Rta;wzro^I*>{fFrW?_zy^6YLI1+4e=i(OhhuaYEV&?JZP$1 z=%|s3$K}_eboSg9OA=Mli|Hbm=uV9PS?wi(k&Zm};Eh{H`EASRRaMmh&k$sJbL|3} zMH;;gzwtL4P^zq1hun1_im%y9;Gj6eK;0w-c$a9EA~+a+c7;m@)?=IdZ{`B5#EFxi z-5=sFeNnh5p<*a-N{SfHG^Khdq=rfHU#)?_F2PjlWQcnY^DuK$_?u;Av;t}*xH&Qt zkSb`GwDkK^PR!viI1WOp(HEo&=#%QR7&oD`Unlm=<87+jT_j!Q^&=2}8n4_Kc><`w z7?)zEXDcsaN%B7B^<hE{1p%4vwvcF_c0*Z+ z8ohZNlagQ|8Rb$#!ciVl$YBgkQoMIwFCKCvOFX&&xpkoK1FD`NZ~zx}=C?~Y>oFqC zr_5~ zO9|AWL7H_c0M=}A7j$p|+0*~ms@PWYU)GGl*3 z*LuH=tNw3Ke>PM5QQ30^;M;pAs@w0+WVZk6>1pT9)3Xj7934Y{Foa<~l!uPoWtt>l zKrfX@<`{$$0SK-$&4D!m_K=Mq>WJh5?rg;O*1~e3sV(?|9l_BLC1=Z?Gra(Ql8BhT zfJ)XVbJ3M5TvkWFsRA@dvLPKcQ-Hefl$-~qbqVDCwa_*2A%ZtAXv!t0h{oW95qqcV4F4xNRzVtl6KSRY!oEOFR7zX{7+miz@_f{#;wvSlT;|> z(Lh0wOfsfiCj2(8e@UW_BR+-d>5$}6o8nGzX<=Jdtk7|dFTy5VH!JSVOLy*1zS^lh5B6LD_}1>JY7hErC*AI6&rXm3 zgLb#`g`<SXc)h2M3z{5 z&LVBPfV@}e6rD%_fA3j?KYzNm%#8s29=Y;4zx-fh4UBdCxyU=?Dv{hilGehBKE-+J za3sHyS(M6Un^N`1zI3?I3w2?bej!gCz4UsLNTIKq=+`Nb+8~t-B`Uf3HYIZE z8dkeLm*pRH&z%TS3q{0%!Ng~Vj(X?k>eXXU9m+C8mJ5#AWRePH62~6+blSgbIRfX* zsZrt)gZJ|JQrv&N)HbF{7`QpMEuV#+&tYIBIm@e}$a?+m7qE4I8fXsEsXf>BBLnb9 zn>)46(7e{|c0Tv;@#BBD-|v34*Xzx9x*bhHyFPl{L#JBcVym75O(b1A(LCm3$|J4G z6HGx)>k3IRXyr6q6z`cF3@OL0xY{4MtVJ4}lh?=qu2m`6$lN)5sFv%NgykZ#237Ty zl*sdPW4&`Kdh0-|S4oQr7JKVa0+atfBAYCIBeW1XVGd;(p`0Bs;KtAz5YKtF)(06? zxGeXBEcAyj(qOFP<(1oyTrkl%K{*57qld^t=O~(sFFV~ok7_cZ#kez+Ix9ZQG~rG} z%Je2G<*JQIy#~#ClM8^+`ZPTraqo0r1=CeHeO6lH|7sC%UqVn+wn%13=QylTEV07G z%L3!yEEMZ|&>jJF*Jf79C^MxVBbU(-t(!Z*=2b&}M9u>T`cCt@avdS#ymBg$oag*( z&~qxIvz-5i{eW2*LhawXyRrAbzVgCq{~23nYJUaxR}SF!9_`eIgWi|+50C%OX|MB* zZnyt(x7(ei;{Vnb0W@Ygtbh5-fR%9FJfR`tD%V7z$3}iu0OfI4Q@;hnX9V zD>px;R0d2OnCnEm6{1{>Pd1VCP*-TG9PCu!14H7C`Xrz%FWzbu{v(!N1n1QP=wk|^ z+i}DC5b%ts4DJLi2eZOy^LjGNDE*%&f$;pfmFeRofv6~FU>)IW-GMof$(nlS4SPaZ zWtGlGN!%dHAmt&xZ^tlDa5MRU#0G>zM*#Qp)lu6IHSdl7n|P0%1Ey}Gje6bDFil?0 z9=og6?hhukK_Wc2^SIB`sGnzauCWSb#44I{2H#QuTCD1gr<{`PZm0JMuuUn#Xevo< zCig%tj!focqEbW1F!#-YOylDiHUPIZHjLD0oirVOB_H;#!_*6ZuI)nsc)Mqs8{0d# zx3;(bE%qy}_76PBFp?i^ zcO+kxP}u>yZJe>Fbnw=B9j)98pR*?^iv{q)9G%)%H}uC6y1{eXbbSFbR7|yqcO@iF zR0emPl|Vh>=|^~XhcLz!odTXwvedfeLl~!3hsaHhqjm;F`Qm+hhS`uX6?6N3OY0Jk zd>b?GPIsC*UgalnAVAR~g=GFgDxGA~OUf#_34lhtdK&IKxcEonCIb@sj3?Hh8tjcm zDA7xGVrUt4KomrkE08HFLY@?r1?HBo;A{F9x7ov#cBSxsh{(a@^Zexg>dWivCgC+G$3 zt0XzVhvyUMZ7&apLuG>(Kznf>a}*^nfXc$?{PSfvPn<`R`|_pW`B-`Rtph>7N#eBI zEcs1bgflX03LaT9?JZZbqZdU0TRAUX>WV0rS}tiv^qU(+_-iok+LV*SY0o(e7w@kx zXvib#Rk`!@I*7#co00Gz4g+~wUhBzF}1%6dnN!s>_4x#O3IrBJA4uYux_ZtuEt*1DmGmX4-z9w6Mw8-qrUU@`YPq4^4e_AtDNcZMwD6QfegvGiO z4x??g6R{9y6!A3+1UrCn>LACl2k$)`B&x7(eLY9nCy(B8HyZM^4QH4Nub!xa>`H3F zty6o}d{R;fo|3!~pX++UGb;Hy?Zrg_G>q$H9CMbIIz}b4AB}I45gWQ|kk@0BCSh*d zjX)?zftPWXa?U|w8BxmjnA;`=SL|(9f!oJ{c=U&kNw3qH>Gt|FxW-jn{weL_4^&&e z-WVc)AOHTJ{`o`P?p=HX{}e|wd(B#Ux2BUv^_kYztGCvNFts15JrjWM?(eJ|9Pa+p zUibKg<@p(WXwbXq4q>L%Kmf1t>oIOvMkX~#WM+3tvJvi8`SG<|OC)hxJFRy#?2h&% zYuW;ok|<=+f^hXxR={0b+=SIO|6$ogfOild zpTBHSTBpL`IP>?^Fc5Ph74hPgfNZgI-*x$a^gVMU8!dfi?y0dNQOhumCiP~N>n!Qn z?e{5&I*_=Cl6IzA>^uz~dF-T&p-j_y9duB{BwPf{w+W}rSbv3yt8)$`)e@^l+HK;- z{me*ZZrg?=Hp9=FgeyG9J)~@9E7N}SbCEl}cktd_YfS=f_nUIZ8e~K04F}wg%|?T% zM4=kddC3Pg=&DsvXRFv|Kapzv>p1G9NvQU))ixLA7XI~H-@pGqzj){NTeGwJY5T6J zUD7TM;Ju+yfB2_={%eo7wtnUK^jM@#mP^q?=Zt!0O3-L9=a{DA(Xcn_wr8)ArD8q+ zmWGEk;WDQJbo|gI?uBIOzy$A66?0XRc}iuH<&HN^-OHaV14+a?vu7Rvu{i7aU_2Qy zzO3Zs=h}?dmH42Lk)Ww}MM7$NDth%a0jEC~kf1Z8b^M&VCP>ze^V&6!R!SYD+o$)` zmGefuMtxFQ+jxq(%)R7E1olr;7MJ(o+sKxe`*!fgI66Yn6x?+|z~>>UUlcQ{=*XddYbIRmAxJ^KUiHb_>YU#|qCz@!&flIo{JaxhWN8nN5(0SQm z|LEZKxZdgXVK5j3 zZ-GqloR5mVlo};x;6vPOw4knC;UlJ35?A?7hoVzK#^*U`&s_(b3?j)n9%k&72XEZU zguI|U9>^Q+fz&_9X`%y_6VAd^_T_#novY#*6L}6yHos~#1Rjc82$?5NT^pn|nZ#2SSaDGbpEd_6-;g8Uo84EDh5QxxoZhG>$guqECAy9KPOf8k!l6Z0ruES1rT7sk3jDeW_W;arhBMDEP z@`lIrgx6F9;GNeD9~uK1nhYX zVvI4)!yZS*5-^r173)`x*HNs@kWL}y-{o^W)b#6*GbvT=h6lQc#C{EtHdXrCvn|!|juZ#Eug$Lq@0qU$a{~xE*rv6J)&YfR8%ayz){?FnEk9 z*FrW{{lvff-)w09WO9F7Y<8ZiCQ4@FK%C=p6GwA^t@`r*sv-qRAf=1UTOZ)IUf&eB zbtBxnD!g=wlO_^8RYeEr1KpvMGvM|SFgW(>)Z1)lX73D zc3Ha=fS>eD?JwT?{=ePsoIteXoAjYm*#)WZI%xhy5~$P%>0@n?WC2#;D(=^_GZRw@y>~8=VoSUJeB7i zKwo#D)9*mH+k?49mT($QlB1CVoez8e*qP9{p+C%dnZkFRaNI(VlkVzunTxc4*2ir%#TCBs;;rG* z;@mt<7q>h1pi{fFT?)XvZS%>qllISc+8y>}+4(Cwv@Uhofm#G#G+Z=VI683KNjMQ7 z%%KpzJb?f`_{u3cn1)G3%gc4Ay#`waj{u4z6cV(O#S7F3rIMGx*?HA&eQnduC`D0d*ZnGaif|T*r}Yb1W1cfhfI#)<_U73 zCdGH%0)OuDIfWUHMD?Ldxsr;TNIZHK>asJ&8zGG3yxgv$PS>!~C6#K(aams1R6NTo zZrH>7*Y6LZ*XzUb$_gYnB8C0{a(o{+ZbDRBR70J!Cf)Z!23Yo}{WBRqn?8MBuK)bG zT4SU7L;ZfAM+cc9uRD_xYQ)KBhE-$S5#zDeuCCG*b0?G!4V>ZVynQr;!61`jW5I zc;-I)_3_0R3gbv6IzSOXX&1*=l0_%AFr?rS167*Jz*zYss*=a%j~;uq<4r|>BLJzS zIZmoSJ`Rke*p+Ch<@yAmc{t5*5rd_4F8qFaFVLX_D{4G7Wqk)|gU3kJN*G#`w@xoy zsJ?sv&z~**b({u|KvWwlTmVs? zZ^=xRb_#_SeBT(dirwCL%2i8bGUNz$>G3fvTv_IhcYyb=pXZ?nSSSj zyz3a8OT6|N%5Jh1otjKVSn%7=dUcS{ZIID8r1v<>{0;UANE?EelL|FXBdwLtIjf3hRy0Y zyh?Kv%cJxbA2-6`vUo?Dc))IPtds*N32V@KlHOV!R85P31qzSd+Hip4;>xve%m?@o2z?ZMaqW^S#s&%_HX)(TGp~$LZY%~c zzAY~T_3>PK0cxde(`f%pOR-i{?r$;YriFs-muJHB;_{2-2^)M@U#Lcm>$u##5B-TR z8C-xk1tX=_4$%`sIBB2pX7lKY(=?-IC>fL{vw~ZE>^Z57_ zPER|m6yqF^MiXk5YVxBuj?WSuYdGw4pcQ{c$BK@JUVCo9&a((Y5+nN^=0>!!11YhZ zvR+s8)>O5QqW{X*$Vlt5?FeUBR;yHw%-%nNyyw1S^MK<)CY0*5#&OfB8-iPY@B@HQ z)@mZ@+=dc@sAD2dHb`de*!_pIioLGD!$Tp74AM&oLDRTnU`2x|IwT)BC68PJrlN)s zu-EKiDp#V`Xj9y8e_pv{zy`G+FZLir zR>`H1<0+qu;7rQ%$~>sTq`A@qmS+=XZ{15#3$Na10*}5{s(PN*dAZCrWBcRtd)nzh zbC&qJGx9&D#kRo*jC9S>8|^(_?-3WX`1tR_ zX)}+DyBeYwG9=CrPpCi0pnY<}yx@=%BXXvIW@lP3H#-M)H+ zbx~}oY2%MIkO?W+Q9(Y%Qdz{x@P%!6%cK~^+<%6rv&eULKu8t$`47!6pJ{u|IH3Dn^ z>$6#tI?T54dBy8EJnq36dhBPNHtcLXgzx^zAHmj>J#?gIpf^AVs5Jw(v*A4U1)rXH zB2&ART?)YG=VmQ}j`c*Uin8$dk#!&*KVoA`b%vKW-a2gIoEiV1pHsxC*jexzs*f*! zH`ZwpG8hn-jKe zOjzFmya2n6ymZHhg9^($7?_a#7X!P1NuqNnZ*&xlDiS|~rTH-^Xe|a-5w9+?O#1aiWay zp02xX<}}xA;VV&N31K!gaD3Y3Y}DC>S=O|lwvXV^=6z_j7NCos_k;WQV0U*1 zGIU<(l{-8*01`iBthDK!H^I`>E@77f@Mg2NSF1HU;jM5|G@oE0U|E33MRk@vFKsKj zU(YMX>z#|?=j=b;E`YJ|;|6gb3WQ zH3$7Z1GvdDn44=r6NioB-dcQcyXc7w_{5rxly$&FA5bC`NBg@SXrCR!($WI%ssVm9 zVq+;=$%+ny&nxi_?l~*DVks*?pUZV({64&wE;U?gJW41IQ8^~@>^`}KhWIusb+A=6 zj7lski+IX7K4!)qz7+nluD-b|8s&^eE%aL=8a)1;eFKvhB#6nKhhDxP+*Tg6qr1-E z-KZ&XBbjmB`UoB4e+a4_f6hUJ$${V8-6NmwZ$by<&aM&+FXl@1_gCuK6O}SdrA?*J zh~9%SLCL38lw4#n*juMBJw82x2b+(0q_(=Y23N1H!|6#EHXm+)uQ9Z|b_G_iUWM*i z4~~vbq0yL!vy(&E-`$38uY>1b$NNZ$J#C&6k!ZvWz3>lx!c$|r6o6y5nbqrcAvQvW z+o(+f?ujYgV}^w>z__LH7S%nEU0x_ts@p}}H-g*Y!3k_`JZ6uv(QNT7>dj^yfpiuh z$do;FmNM!FPaaWg2>qfx=ujXPy;t%!Y6x1lUbxCH|JwGnPhjC%_(VF;e0^7#pYt<0h)i5(ns7{-!iT4vhrJZB@omX4*06GBjr$ zv=YDvC&^=#2o&conU--oRhz?ssPg3w6oVxf1ylBXZar?VD$oZYEqrvR)e$#_l}5hg ztM7QlSi^ z&uNP_;Qc`#NH&&}w~dZVA03e{Iz)2n85g?C(LqtTP6}M!^6*2Q+|+_yE)&zhwPvd| z$En#64FRF-?UqZz7RzkposWW#Tk65P67(78f0k|1ucOODJrV@q?X7+Ii*J9ML33ew z35S7?;Q${}o8@rz+A6&K+N*H$)=kI7%E&KGTzzq(TX-(Vlqnc4!-?liN|@PJ0?UiD zu)ekoL-e{ce6Ta)YG)Jgo+dm5O=?Zy_wOYk3AjT@m`B$UHARXe zC(%m>p;deyfdm4Dj^TUZYl%l4oPeK9*1-jySo-Ld;q;qOG2?2!aH! zOBW#b@q=P}DXHr-LKA6M9+6mASuhKMO)~+kJy#u;2d(+}LCU8$85PoWgn5q7u1Q%z zb$ogPM|(T)mw)kXI6CdZ;_^Ct{)=D2k>&!_lN#6Zq2Oi@0bFaTi^Q3%{7gx;QkCix z>^tB72u^Zpa7h5i_kIV5wMYD-(F2wj8E-Oj^|iQT5RZp=@$gi+`C=`ct6)*%Co`xe zq6?=l<%aYbz_ z5dbJsB*n_$J!$k&n3p}$cEZs*ZM@^*J@u0KTGvXIKpo<KA4^kPxM!`lEosxL% zB8`lI(5qXVJf=jAIe;a)DR*A94`8!ao+dIT{kl=Ee1LQ$5=-zMjVrFC{Z-YNzR(eH zQo$&ts!~x0pk_2B-_M(x+4y;l+qX==2*~G7brh`|0lA5mYcD(G`(ePlC*0~LY&x-< zj=?+r{;c3#-O3jdqdNGMDi8GBlS<}b7w-Mw9oXC6fgGKV@GnTR)7b}W?hDEx4K)En}%^u%V^dz4~9gGm$6o@0bp4>vbq69N0$ z`a0}x?ZW26yD;c?VL(H~Ogy2j%`^DF{?Gp%e*Txf0k6LPdHCiZeG5K#=Pjy-3@fXv zu(Y%Ua|^R@Wn~o>7U!TbJ1d?D&1B=eGU{lwk`lV?ZM)fGfGgT5e!&UU!37!1AYJz0 zM!9hLJ@(RlF)Wro0;w}J_ySmTY*GqzyKN5WQecvTwsSKr4oLYbi_)={&nK?>Sg3@c z7=DogCo8yjJ{PZRjBmQ5XmUrv_-i0~qnO^yu!@KG;U+jPXc!QgBLlurK1D5SsV8ny zDL6$PWdV2OqpIY6Ar9g{1{} z<&8JsGoSk`EHBd>7Ig-0NoAKamVZO`%o)_Favf!uCMj30V~|QB>cW-Xl@xA2+?IBa zZIoj1Ee1dpBacrgIC{p{+U*UX+vz~B-(yfAj3O?&j>B7xok*jeA~+^6gB}!lOjoX~ zIQ{(Ss~)uIF<2=T+B&RLDy*);-$a)?tK5`#<*PaR1QiaPh82VY8LbQBs1YNI7_M5# zH;F*G#vY;x)QNR~KIWNZe*JvMp!CE!DkkXmEBj(I{wdZ$_(H)?>ReLi{BZFoTa03S zU6hIEa0#!w6cB~c(QP4b^i);G+fV{eZ`g*TqdhKQXXyOsW*Lh648nFal^eBdb7ldS z7MCHz(Q49|!5xyqYaV82=Ews#4Hrk7+K0DG12_WaAs3;_xS~VgSI5;MDFE3@zzRx( z^YW)y>l&|TZHX5z)(7^y8#Sq&LUmToQ@!{i_9AXG9LVZ+PNh&kdNJMZ3DYJ+GvJ}4 z6yDDRZasYT7`E=;g{gQ6yAE6_AZh0fZXfOa|q=Jh`LCWlgvrtyB|>k#5x`a$SMk-Mw1{H&zCE_ z>%4aFp;whIA<4*ybHL7x_JT@BLvogBIO)QopYAlT+qQnn`wCVVD|2%`xus4n^y9Mw|nCrKNS_4+tcUFJ9uwmF4B1~9dc z#4Zis3rj1H7w6_+fm#ExR2AQF%opfrfOsGH`K`CU3qSRL_o_m8mkBRGBu~gQ`NW@y-;&2=o!tpLPR;VJ8iC20d0fl(!pVG z16r*nwowB=x1itcL4P3c8Fe;PpQGNakGVVG`0?UCCQ<6GPt&J$tSLHbpV3;T_f<$b z5{-KNG{l~_0KC_pLquKBcazD?NK}Av2*z>hj?Ph0=>X$KNHi-CsYX1IY3Ge92u?$+ zfT1>hnpuvdp8B|g0`Y_8p0lLLoGl5{pVG=v6L;)$!23M&Z>%xWnb0g-huuCW$2Vv54-Vn@=m6%I=i$wteiLREmbfh+Z$5_h$r-;WqEbnr zfxtP7FGXvnA*BLbu#~)d>Xd3)6fmh9?Qkh6(z4Ig;LeLTxdzHvy93+XTX1~RMi0Bq z^(qX@)g+6uUgLQVVPRn&R#ui_ef@?=`|w}8-GwH4@5MbGrOI-vN9(OS^L1WEr@4#w zQvt-lxH%O`*hD(lK)=M2NB8WoC(nFL!Lbwll(|ZNbkB$0n^#fwMk7f_n)T_$J!CH( z3UA!+gUr(}q0~bJ?mie=h0##F*9AkB^Zd2D7MkT|TwuQ1vQ=(=~4gr86))Gt0?5)bY)tBe#0>3QKR(MCuP^!yFdp=b?Rc2uXbgt}S1Mh2<3n zdN-4mZZ=vUj<}y%uxFVQljIiAY&LlIZhD_u?5$W%Vj1*1(9T5dKt-m=3uO&8-9UU8 z*pJaZtIsO}M2VuEJTFX4Y$IA^40!vk&6pu=SSNZ^OxQ|Bjm_3993AZQxi@B7e0-Cu zVkliWl5D5PBj(7Lr0AGTWg9G;VSFWtUr~bz4aQ)^BT#-?!jJ9$$3PF^+NI&AJH6)VDjt@|c*A9p5Uf(7l=HWVbJ`6-3y zb)a7vH8@V48ekYzZw!aft}PRP&P6;!KsyJKG?%F%$v~}e=sGF-BLJSQMz%=`51sm( z#Fwi@0L_y?am0G{^AdN=7=Vr?hLbF=lR|$vT4UV|K}<2$U}kO}n&?E#AZXJS&MmFO z=fCth9u>~v^F^Ne{QLr(;Ilk)eHCuM{4#WVLpLK)o#PH&bo_9XIyW;jKZkqXpLjx3 zduqEhfFmfMx=5hNaG~&G8|Y;=oTte$ES_a#$eL234hS&l>0r;2Cx)#%FR!S@K@a#K zs>`fguqzu^jNX5^L`XzAzL7eK#lO2!n zLQ~oa?J_?%&y7YSY@Sk48QxyG^1K8%wLq7!zZ+WH2TUgy|B6jVf6MBW@%N_e;g>;Y z5@n&vY3GiHp%FnC*^SU0D$Kpg)c}1hb_a|m6MmXNC!WhRO(i2Y_4@9pgsWmM-^uSH zFqf+zKhofm03^{P`K9Of!I#7mD+V4WYME$n=KwP+#Qc}M$YRxz$1jv2eva#;kpENcXPMT&;X|?9z1lQMILv0;rZl#V#oa_?lnL>EM-+5G$ND18QH`nlG zn1w(7!ME`x7_e9N{#)OL)3XDfuz|d9s#ZwH*xTBGfA#? zQ$cg+8LeHt3X3btu(GlOON$HaEotpbjtxZZp#2Q%;-%KpCJuG$B4boC`e1!J%i17N z7UBM@1jYlgny|c}Ov?01b^Y=^l^~~@NXv{Kn_JyavmhNzAfJnC@Dq6W{nDJ6!t4}J zvX?&kDvm*A;U*X!5a2m=5buk*btxObVo+s*^p#^EZaO^W-VttRN(9{yv`pVs7I5S4-)!3UYV$2+e6%6U#A zc~^@|3$T9kCTx)xUQgkb*ItDO_wGX0_eH2gCoS_1=CFS2CM+ynfyXjJ8i=@dP?uV4IGP@nVo@`Ui%b$=1ZSveY$w1LT3rjZ{16m;j%u1 zk<8nurBpy#ca#tQqoQ5_;F(+#YI>*$f-Q^(<3hpVtw=H|*7HCe|31QBh5h*S_3F*7 z^6J&F9`7NlW#d3xn=ze)3H4!O^Rmg1WonC&rl)^|ijg2+5iqqNa&?!5scRgE;W$RA zz2viX&pF3xhGW2+?&BB`4E{FLdF3q7*zqy8cyB%B<6dI7xzJd(w87lpOHc7j=(uaAbUJoOr$N3Yk#-?3vr zk6tsKqd9mfU|o0RPU+-C8b)W#I&}mZnt+SxUPH&N%95y^WiQ zq6(_j&*#-@FB+Wng3)pv{XW_m#K(v;SA(FhbibtIU=SjDhB8EH_9T-PG>ugS72`W5 z7c|Yl?^3z40YmoEsYA7?RCMIJ@a{-b0@pD0=&6n%Y4r77oQB3N+c?brfeukcFTfno9o;sgbQamn`M&4d#NWkA{*5JlngU0_QeM_j`S8ixh6Zcn4N+RA3y?cBzKTpaY~lA`g<} zh7`1F&Chaz4()@c(ISZ<@yeQ2I9^TRo(sq9s<=)`>B)#lS8ki?jhn=Uk00Tbm&G7H zZd+BMg*=Flb{bL-2EflV%HZ$7PSh;LIR>d>$;7G1*hL=lHZ_AIoKzCxIG=&nD<+xt z;2oTl+!yBZlPG&&_DOS-+r#FO>+L;f-hY%mEx+){kDUB^lL}V#x$H5iPG0)NYur3( zt--b5hxjZ#y!$ph*w}$jf9~@L-m6gLmlo3-YOgWGK1;JkbZQ@kU7kWwR@Z&K;;a~k zO|lVQ4vN=kY!$+ary4T)ln#^#fqSb-75j|L$U{6GK4gQlzWfd%Jxh32o|@>fqlpRR z*m69B%dcKvhuQfS9339RG5%XzUWCtm;fv^L9{?%6XId?&H)^cG`k*V%_b_e>&y zuH-3bQ7Bj_j}VpfP?c9PXkVYiGAT*rWl0VQPI5-{QUGo1Ji0L*(b+zp>(LRcUAqSF z-@WT-9vtpkC~BSSBKbrTYD5u49g>W|H4l%ea@_XDCY+q?!w=s69(?^9-+*Nt{vzs88SnX2hb|l zrHa3*cwUsd=f2oeq3d%`&{znNd*NYC(hU3vpt$#>s-VetCsRM;c>ZXvRqGPioJ3K% zL;FeA7}QPie8*t~Prq<+FT4pog>Bnv@V-eYoi{i?76hQ74Bmb>&S?^dNsj^VvWeOT zn8K{`>3+Mwi@W1dG8?yaLSA?a9&S8@#ic9g7)VZnwQb&Nw$+4#qa8RsIl^b4fgMf? z#ar&~LL>sfchV|7W*9cLXR^!72nmDIG>kOkDkbB9e8e+%z&Xx@QUST=V?Zljmgzif zQ$uh~8@2F)5pPIZReA`it_f?rjnu{9neHiVJSl>tbiC!oW&F1Y$HymJ{?+6sJS3*| z>7k@ipUm!^G40UAA)+r6P839oX4jcp2-$_PB}r0vK1^(ihe|+A)Xn_D6@DqGa1iOR z>6im9lBEQWCNKbJ?Gsp7TEfnyjv%WYpXoe%+=nm0S~Otcb<${{#6HaD^J+T(VA_1Vub$4MSKwJXI1di`Ql z!lig=-{_gw2;^&&*;nIx;R{s7mG|U1DPOP;WruH3k*;uQ#g!E?Zd*K)Hu^7@#4WzL zPf2A@qo!gIcc;SshVk8n1T%S0{2xc@B<`B5TV|y5CJ#EpdwzOM*}Ah(TVQ>yoSW}k ztoMlv3&nBmWYyEC<7D{-x@hXPK-EOsQ)aR1_F?zQ6S;>p_bioJ#`l7fcwT<>4Y>7^ zg0;0PoIy+-lkro*Szmk60lHFs8t|Qa5>tC>dlnt|hRyN^cyI2EQ-pXUkQ)qgr&dwm zXH%&r$@tV}GNM1KLCA@<#bXtWcg~#G!|CX?1mZa&Sr|6V8Q7`Zr2x90d_Y4N|4@DI zl`E^zn(OhUb7*_x9lC3wO9Cc)Qny8|&9#d2J0W?w>m#QlXgKbsi?XSLDRBnsumg za!(x>PzTnP;jr~ySDAlyh~^wp0TF6*8eJsBapTLkf}Z#C$|}73_Pen0-~rrz=?-jf zZo#{6eGmGS0Mzerv8$cO4{@FE!uHl<`07u;$tlxB{}7Gk;f4XmCr8n64ck;IHp(+0 z-g%w9_S9$ddF4(3)M7$m*=OQB-^z1TTp<**#jDEip%(@3sTWo4<2o}DT^IHm*T46a zjFwdbbyDsGa<2Jp_$Bj6qtw~)c1_W3WB-Vwnm*SU>DE&iHfOl>0*jw$gjl>DU?WLM zNi{N#2ltXl&LEY05rcQNR4t!x=s6?@W5|=>iW5YOX#!(o2JEcNqqDMt&&k^A3g17v z?}@7i!vdg0I%S%inFGH>FXl9niSDPhq2AoyOK)CVozBejA??xteyv%{{>A_OFKMRJ zkWE>g#Y83>&cle=^U=2Q(#cFb5F}S&j!XCQT{xpp^jU_nP9-p5eFAm%yjv1OAwZ{> zh@^T%LG11B!xQu}=4NN1*=#`rhqgKd;vA2iYPfmx23)_sjt_K?xp>;P+oKtEdOY3K z+3~TY$NL}=PavG}bW&N4FDt$f$H!*~_FWE$4zez+uV00YjYmAK+MSp0peNknZHXEp zV6>va6!ih_GHoO*8$52ivJyt>Z&;^(A>F+=Z(g|}t)!Jm+O*T&2aHofb6mf%&R@3> zv>WvX%g7$ye}KTdID;>D-XFrZ{_yvqiNODrpZVLY^-U*vuns%)z8($BV(NGxZ^kh zdHmFYoVD9<=cSjRbJm5Evs1YJ@{7!kpPU@I&c$%?EYT5*-u(KF>(E+UgzYB}IkS&s zhlT-GoSNcAjbxUQN=O26+GWaulM<$IE-Fc^zGLhgX%+-+|d&Y9Xatx3T#nh~9b z@SJdzh{8d;%uPS-ocF3LLyzKIs?LTgJp6<+STk3&spQi`!Rdk{1bljZ*mKD43{%eK zB9SHSJ=MvXpPS+MJ&n*FZfwI5g7=`GbDPpkOvZG$X3ipx##)V*yDn4etKz4+WX zD*-kuhuk>BOA6h&d!Y)SzsqSzup^}s5Ve|y^=mJ|=Rf}?SYBS@dq5>KI8iK>_c`&o z9Ek*XW5)mzaM{BgFkS5D`Pa3zU$0@?3^?~dckn-Z@7;g!2e`#|aNu$uTlc7uraKuV zTXj`C#&xpm>nl_FVa1*ez$p*`<6$#SWC`)A=pl;2KH(i1QLjWpMG0(N7YyJ7TaSxnRmK_Z-}M@fJ@8OC0hiKl>RD3~fDrjGlUr zuZseRbW)Vz_PNi0j=iL#!$YoN-lyr>NXF>(^m!h)^hmoX&#pMHmf;XzLP`iaxc``I zS`c+~_UHhI!FbIyQ%vr21j}O_(;L9Izx7S{`Y--63~?mF)2}57-o|*C??-G^TBq#& z#rIZ5Qx-%#VW87Fm5j(lxPGbdSSM*Kd+T&uDOje2MgcoEz!!s}2X7j&j;G;cGorp%e!)y0aotE+He5cBQeBM;j2}*-m(kO1 zSb6AJKIwv@q|PAEw9}WOUN!bcZMBb=LhraXVj1l8TNx(saK_+#e>4jtZCKswnXq+6z1yt|feIJhYcew5| zDO;$dLfj{xt5k~aNU5+{g+}nt)X=R%ltia5rFeZjG)??;bu9_f*4^w!Nq!uhw$*d zyA0^_vkTBYX~Su~#*PZmAiC$(XY#O zQAu9os&S^e37`D*XTS%7y$X!lR(NjBNn^ZgS1(2;EfH0Wm!T%E77~||1)tm`6IulI zxx0BRPDwa$FpFn8i_N)=YrLLY^Rrzl5(etO1lBL;Biz;Yy{(P^2QTzKj)}MMe714x z4cvVj?{)X)`pU4{1V4`UOaPu`{SWYFd?x6eE48}Ge2G~GcfTywfAfY=c;NscFj*RB z7kiv|KIa8Gi*2j>J8%DL?0R0-#TsBzq9n5n%4Fo78V>k~7M!MOBY>e7g95rV{3oRe zJ@Yilt9a-LG_AQ1oTLZ;gQ59C=EEyf4ywYq@unhEK;UCrXW%#I%o3;c))|Zr)y! zfE7GVu0|Mw72kFBzKl1Yf8dLd`==~t-8Al!Q5_#jBKiD7h1}xs&i4?i8qlq_~JpEu=Xqa3p9)BbEVnE)&708ZHo06dRf=R9GI|fHqgWa_@Prq}RZj#6(nThB zB1b59-a9)Ux$D@SmqQ~<{>%04Ez={j_^i@?SjiQQjs>)fcb$|=+Jn2hm7MKML49jN z=U1G!;sSR*fv;36CaJ#PNrVO7>Er86bDOW?MXeg8K8;sPnlma-dw{=h;PQRk_v1%f zkG_dpeGlKh$9SKPeq8zWX9Dm%H`|Kzp0-~Afwd!$7)%#+A>z@p^~$T5J%Nk;e@?MEO9Jf~xZ@s#&g2yjyS?L@EkTuNI>Kw#jvcb(H!=5(2yd4B}uLK%&_oxFpJUoQC`56W!8XYXGEJ1CvE)-HI>@{knoaiAa z5sywX#HYNIVIBW01Smg0v{5oCELGeOzZn4+W3nOL8r5eXglkUS(4OnTP$qDr766Ge zE*aQ;U~}k#-4YX_I_{*2$5B3j>lxoqG zZtVEw{#se1XzJu6VH+&sqL+9%woM=4AJdPNYGjmoa(8R%1N`ZIyu;tdzxPw6pI|%E z)NZFZQtn<~U-?)nVxEmcAssl=jFU@Sl8x1zsxrOe?_)K%bdik%?kcFCCqq1E&oG`> zKM#<~?GR7zCiIEs6!aOFT`8V=#-IBfq#O#rjn-;Xz2z1x%(Y;CW)^19+io=b96+St zkVY@kxQx0ht5@L7zx5`9!Y4TR*+yWnZlY5J4sk?8>#*QZOT|M9e3O!g4zy7> z>btfb)%J(B3nMX)+J#3MsFA>d7jX{{)G^%=K09@jafPLT{up zc_skIhvYaEu-C;yR-GJm)ExNvOV2l9S% zS0dC)vS4IV0@=cYktkhUzz0V*GqoB={7G8I6Sk3e?)2*YK^KMyE(E?*F|frFuLiz+ zH9oHFSF;9v9(3qbWYk0QHO2cSEjkVLX=Zv-b`XdYEl{g3Fg?@l_2BmH+i-Gp!rJy5 zFWzQF3~}7P3ZAhhlshG`eeso-VP$oVlec^s-B?oU+uAo@nO9eZ_w6Zyl3K`X`)xt9 zxOmA(pp)ee#l7((gp!)h6alngQMP{WeliryZJ$E3F~c-VM#akzXel5^6c^Q~Z>Sj@ zwH&ji`-2beNl+0x+D^LzH($63b-bAE?QLwAE|*`WB(l?!Q{jq91dvHfbn!SOH3i2h zgX6%tL$aeDw+%INY?zDG*IuFLi6-s1|KL8H;iy34`M&nqpTN$j2~8YX(DY`59!=og zf!+Y`@ev-DI;1Ae`ZUWEo#+HdAfafTcd!c2+y^$bF92hFW;1%_Ox<~!M3!q8HzI1T z>U%$}w|$#r{by*5h9`WA5B}p^+av6*9^fnZ1U>e9koW&z z?0^Z-kJnd!G!n;W0&r}wL73($x*O+$ZwRVb883tD95qy5CHGvOQyA;QpyilzK8H@Z zOvh0rkhKFN>)e+35Epd@GGlw>NxC3Y$l@ffQrDr@^g!i1101&JL!KI~eR9Gc4VMTY z)dkgIZ_q>;sY{Gue+_ChT*e(Y=9(}&dmYxVUF8{tPSNuwnPR)sWeFguPAGwAeqkA2 zeeG3v^k|bS%YE^uz65(ucF`-}X1zH9(9qF7b93|X<)8YStaYXdk;N;Ef>-fp8+CtS zc@dkC)a$}*_^&E)&*iF8D;}Gdn-eF@sLK%bqKZY)FgaHho98S~KEs7^Ua{wNquFxng7=-}W89&c^qzB@3tun4zad<9zA zaR|>ZlZYsKPP7M7!}Ec1^+P;Qy8qp?L+17C^%(^J2CgqEDPvRsZ>@dFDl5rhsRCb@ zrI;|nfxYva1R70fRs8AKjSo4yy3d zjSbL10p4Ms)pb5}Q&d4imCk+ij3z%k>>b7HI*(~|lhJ(?MY>eULuKa9MCau^_5x(& zoNDMHEiK}6wP>A`({OSSwH+xOvaHLh1LCH+xD@f5Nl}(Ip()SE<7&1fv7CmH!vR-h zY~XS_%Ju6v5R}>+AWhOa`1I#K19x7!4I2*;tj^Auw_jXZf}7WGNwF&cre)@5=O(sU zd}aO?xG-Ds{8TqcsvWFrPY>vo4mfrUN?S!EfRVZ;^u02I^mmQCZYt9|qiF&R>;!cU zR#z6`1UpYs3>}|P{hR~1`_9|!+0HMo!HpMgv!R2nRPwB;dkyj|Q0{QuOwX{;qlb{=*jGw=TP zS8G@GHr+ElGn^T6Ky%+jc&I<#1Q?KE69yz35=_Vz^uwSD*nlC(KWM@>ECGTHh_(#d zFa!$%ZP>60n2=$LUSuxASvk||bXRrP@~Yl`_sj^#*&-tI=6&x~P0w%?Gu7|Cdvl45 zj5u-5ch33FGdeaeYrL5}>p9ZL@4+6bns$>bUA_gDH0gH@zd~Bliz*-ugjhcVE%Sz& zAe!w9%;d~Cq%jn4D7x(6l(=4uoyLDbQ23Sk$xnR}j!%vVzBznyOj&}T{@mxh$v>f2 zhz{*6>yYMua(YeUjXkTCB@_uhwZe)*5!z-zt;S73>_C2U z3Y*(I%n8494X)kTg=;r&Gxv@cwj4=PfmF$3PVoj2+F$E@NoJ_vt!SxF(Zpq4kAxi9!!M^#U1G zTNE>xd%`*2@;?7*ZRs;uOL*zfThT-R>UNfHcE^A4rD;+8>)-zH!EbC7{olTGbN8yI z{*~JELMSs$5pP2tf?yHMH$0*^UV`qcnx=#%@yT`V#{?jA-OH|P(wFu<2$XhAyLsJ< z5`T0C#DzZ1kT8J73;{j2BPo`jGHw<6gX#iL81nZ|0o?FarLU=NJ*ml2A>6K}hZ{-IVXD@Eq1yl_%eB>aAX>U?;s{q@L<$m5O(tEU z9zH&L3Qr#1hr@#d%Bt=9a}di$cnZTXZRrT&VCKb)@&?fAf!u4xgMu8)Kk?H)52xc3 zxOV-z3Jol*Bq2-rD9WTdc?)D1N+paPb zF1CKZA8nml)%;#%AXi@GY+Ry-z=SSW`3X_|LU&%invLKu+<$oZf8M>d_pu4_=Mv!E zqT3}Wrn_vA1pq)?Q_|rbEf~>+G$*8irMI`s?sW;=k;I1ABSV-m@8SrQ_DzD%6HNsF zM$@*ktC~f4UGZ}g-YyE;T1%9_t0*a}T4bUXBxZNvHD%~k<<${L1aGMec=TdPsVf{-T@t-nE5!HR)o0@yl~Q5k*z+3lvk@cJ}NWy zxk27hlGqcq`j#x;z#X1CDBB^KP`_0UQ3~TGxRzWTIDp4-l+bt#2Zs(OvjrR7aihTG(u4$a$OXv))38AFW#JfL zM2Xe#Odg#AAvcKrj5OLswRpn95rY>pFxseW6(%l4t7HZAu_WR%$zz!m6kq!N_YX#u zpx)vf;lwDN=frYBHFT}ye_%muM~-m+ttl?hEFYPPap_8)xf$T60}LZ%gpeswCRqXK5*B8w;Sf_b+@J6lIlK zCJ5;oZECP^8nmcceau_?rMFu9UVwxN3M51`iLYYRfe|J!OB^{Sm>?#8e0=O>zVSkf z!{(5qBd1t!OA#@XC9^qVIXK07+s6S`oQ^9UZhkBf6UBfhkE``ZoA*fHN0UAkL0bxG ztVF=ebgcCF)C#;n5x@|?DMpfgS4mOE_mZAkkOaq=|{Nxg{nbO^7KG6H>4o+YK*>kPaYM@~2CN)2;p9gzrs?XGp(T z52#^G%_~C+mjn$eL^;KIphwm$pq75o{HfiEo!012m@i7&xG?(^v0@}NGhRDKyc@&l z7RwpK43x^>p(r7Qs``B_KGG%r^lTpVAhCm0Uiv&{`PKvGD zN7%}b`OY@ro|y^0tz+lxQvTsN6K@HOsSmx;7W4fd>eq1dsql`i?fdcjD$X^?)uTXqCYZm0>C%q{7s5T!NuPpo^p#yk{7IoDGW-E zt;Z@*NTs0s=oDLRAcSq!oskeYM>66LP|gyQ@Rtar65fwQn;^G~?*nCALlj~a5?^() zc#dI?nSs~7(MlsUG4^lj zXxu8eZOEK8m{+lNesuiT-nsweS6;h&ZT#K#^Z6hYuu~FBX;ML>Il&dQOd3uYo1{WF ziLH>;Z|OA}nQ$lD8Xr;M8PcQZ{N-#ak2+v^M|To32&*5(fN_u93aaO2iZ*xK0_ z8d*-#!mvvuI74mq=B?ZQZyTO+HlKRS#%}=q0YOvq8N(M?f({EwGfhDz#StWp9rx9S z;MU*CNtsK?V8EmE;8cvyldb$taokxXs^T%R;X823%$fP;?+SFrm454Y}DXeq3Mt+f-qW)Q3z_Qb`9?jw|g$HWped-gqKzK7WBwB!tJR7FrFx82YiPo7X z5eyf#Oz3)T$n1*?P<_iyA2SLs8@@Dw&l@)n_JL&-_iKX%7A+*!UYfXkeH;X$1|4Vm zsQDrd42v_iC)*;5_Guz9GyBto0d^7uY%J zH>Lit3+4wL*S@GVEqT^I6#g|tZ9uPF@PCFytYF?{z__syqhsROv?BX-Zt*oNEUNO= z`Le<^{y!Z7UKHM__7b$2Cf(G2hI&yaTV=+#1O3R7GMW(DRZO zadP=vex5zXNU8^UtrG>Cet~~P@!)_G$ zzXl>{oA1QS)A%?~1UmKyae97M{&KJUR zGs-&4n+qrT8?(p8rzaFb)$0vqP#Et+j^xqSCXBr%4%M6r1Vm`ODV$vW1(^E6Tp`wS zqCv~SF$yHn(i+F7KWhu3=EZHpYay7NYw`Rh*6IYmgcgCVY2q$9N7xB#TJ1AI=wb~t zq&7}M(xPSEk`PbT@{5|lOYy`Amub|4u`JMa8_sHDz_r&`EVtC@m_kxvGh=H&wV%sm ziz@1I+m2_fJZAE)5e{J*;DXGU7jEAnj63V(Fxni#aDc3mbJ~n_-e8b5LBe2M- zWu{LV`HlE`tbwB#$4zWmv08wF9gqxc zoBWMsrLe+v4{19cXO^9uViAK4(#Da~1I;vyc%X_NSq_?NZUj{cjEJ=ku4=+bSf z>!Q+Q<#<&HE1|Z)X0#fS#>?}Z^!tEYd>qgFoEUi5X1R$QxoQ}R7fdU2S542DXz18R z8zblzTZBj2B~Ntx1}6wPe1IZe=vb^Fi3CfL-47_zf(tH$w2b<#MEImB=gJ@5=u(V| zStBg#+PCVAvQ4BRkO`_&2YAAj=u?6n#6ifLlEx>CL`(Xp0w?WTrf^M_+#8bZz?kHZ zbZqL!^Ek;Q1}=br(I{3nm*#N_<4|Bjw(9t_s*-;ZdA5X|Bh+62KyLaJi>x^C< zYSg`6L2>j*f;B#$Fb0myewGN3VE_6)+`V%fHaEAurahu49~97q*QSXRrQ4+%st5xW zq@Y{a8fC}1oE(w*jBnq zu4K-r z1dSPmH|2NviM+YFNno2@GVcPdc?VRXyYl8J3UTC57;SFAlSdC>G~B``k8+_F_l^9| z0#!6(%EO{{8rsOxr5OTwVT^UsSm3M4AEl5+#-!T4red4g`7Xr3CnAQSmUQA(DLQAu zLeoz}Hme@5*U^o|!1|1Qs~ZUfL8(_zp-dAkwXQ{A>(OPRWmB{Y#ACB(P|hf@LpC$3 z8(6@#>c<*Lo*CKku(pU}A>%43QX%y~D=kPwVSl+q^3*RF1YhmF$BiRovZ^&D-}Spa zI6gg-YU>$skT4YENVp##osjaeu{odxj@&W*!GLIb_jb2oFhI?{D}tNZ2Z{Hc7$vFez8$lSBgVzkKTDp;K?-+VoKW{nX59}&Tj%3a>xm%VFuCO&=XqGESXByP zBPzHuWj=l4W*2j;68`(T#!XidnP4U2PT!k|S7#G_hTaFx!uiMX%+E(=RhVA4)9D6B z8<|!%XoE}&ptd)yAitnlcq*?m{qzr3k7erwTdp z-8zUQ&JPyYc?~*|&D`qoiSQ0$t7SY}pZo{AU95OXX+3lsQ#Pw3h>DpJpJB zDNPv{1rs)782o2m1EAw@=R5OS3JO>ZtFpoqjL-{C*x{hhW~UTk-QM1%L0Hjb>71F@ zc6!0-7ba($0A9yF*Fpmz-^eq>YtnUfap7Iahjb(W+PL!$Po> zUG1$2#=d6EzaMQKhP_t1UZZ5CjT3P)&YqO?_M%r1xmM+6ha>2g>w7MzV|e=X7#wjg zWW=9dLKQS_n?&A~kO*$WJnnVqMrU-r`OO&VxId0X_kIij!)!a6u};xRE~(Iua;(o6yY*5Jx1oTCvtYs`3x zLrsNu;%JkeQMjN!e_9Np`8jZy79QRxxr<~v#F=JHMkj>UoX|qL$?S(9A z^hJoPF6bUh%EYXo)5&1N3u4r$Ihp~9|4TNxkvnCEg)_!*cs7Rq(^EJj4wVu%&SqYS zpA%sil3ERhJtDN~V*K~U&A66-$h%g9$5}&`zQqPbevurYa}5-0~-t>%XlGza2jxXeo(BQKhhq;0of)!tjR{0ma!0~<=JvcR(}TjsRB5b~gMu14U~rBvX1SlUORqV5vwL@&v~3U!l`n#p zUi& zn4{%giFnHPqdx3+5Lf&7yQ#zW<7>YQaRkThz5(nNxCns90MZtxY zyM4m`DXPI_vgx2NiOH6f>)e^FwLF{A(a0cTxgvn-{)*&%G=#W@c7ELYIy?I>-x#o% ziNd%9=~iq2UqGP0sj$EX@r6k0yD$b)zrF0i0w;bLNd`T6dIHcJaY;hVG)12K$wb~g zN8%Kc{fZ6em8;ql?pmLRCO@kcrZ9hc8n~YrInKwa*R;n-r#YRG5Jx9tMPWBf?kqir z5eH321Q};C;qN%eUBA;|$W`Sh?cDE(7+Q#vwHOw}i-PB4%|?`T8>3CoDTOR~X_62* zADjtVI5o!*0q)FH2fKk4tbgUPrlep>hDT71wDneZQASP;C24~8*o=VHw`iitg>2o)s2dsxJQgnEZ!(=q;-_`U%sE7dPiiZ8Iz7g!NI z5~3;EYEyW(LK(0+-_~-=T(CYarITp1^jexR$svJREtZ=@0)Z4tjpfFT46P>2hTYrS z2D_NT2ak@x6n#(+8fxksvYC@?(pc_VzuJViscYWt7C&dxejM#e0{qe8ab8yT)?_xm zxj3J_^x)pTf9>g$Cq(W~e8|g^NdIekZittbG}IXn2-fJ+NsidHGsuC`qlu2fAHDj> z9X#mbKvq=7>h$cKLa$KU(2zw6M@*chs&%h3(<5mtk8EgIqKL_4DS0 zZ=2}qNUK>1X{clJdmG=kNd%w^eO3TQWMyhO;Ex)H?OJhX8+-tI&Gb7F%(kJip%AW; z)1Z!NXA0nEEsD1YqzkaaNyeSy+sbuhJ9U=r|1qute2}BF6VYSxgx!}(EnjnMp@3j{1 zB|>`LXDE=DT1QGwoguuU~4pl zVLzvgGpvO-C+xfQ|NO4CK&Gy;RU0IRHWRfYJYr{igC@e+>4YN${AZ6IA5d1>=Ei^s z<5lj0ftv8v`19bj0ueZ(wq1+sdRLy;z!QCdr>ydT(9Qv!y2dDoJ@D|4fH&e&tSRDq* z42l0U;V6YsK%Y5u!3pXTonSbwsEIofj;I$~@|+uXL{=0(!N(@35-LQQUC_Iq%;t0i zr*U_@5H}3T^jJ$TiXIVPIY|;iZI!}CQ~!yKNgKZk{?-)U>)KT_CH2^}MFmL42CdoB zCPY&_Co$z8)ow0tRtlu0NjLm%h}wL%gvt2?&d;V^3B{1+ih?3A8bvKVtSw6SYA}J; z)@1|!YqMg5a>g2dY4ZGg1ED-TJjV6<;`wCy#rHq@@JCKhPyWi|gU8#)N5`Fc#ig?j z&rV?Jg*EPUz2N|E?QB6_bm4e>4)gH@W{88&OJY^4@b_xT9^i_F4`O=aRiq#25IR0R zfx%!18yiEIOlKnOtQX!xsrHeFSj@VVinEqO72|;MFU+e95~NSijjZOJWAfGt(~H8H z+O}V7>+FEZNr?c&8I*~0vF@;0cdvL%sv5;(!adVDmH?P8L@h+JC*^7gVZ&?GQHu4} zz6pfWw)|W$KEtgVWJxX&j5^oTEdY|w8%;EOE1WMlUvPn0NN5$V(3(QrroubH(XA>2 z5W_BwPA%>~dYJ^u?2!CU%q$|2TS}TDt={tvL$x!&D z<~}3?NqY=gKaSuelop1Ztlr86?xQ*9s0gg%i3v#s6~->0H|S6}B(f6%@u%cGf1QMA zI?aGqyG~nuW?VHf?y{!y+tx5{2$3aL6dU4dDI+g8Vd7S?a0wd~Oq?N=3LZbKsF()E z?|XZNDjD^%o{W7}TZ3(Dn|@Pk@ruvm-^dK;-*?|u1b9{1FN|lC-+1@!_kQf1ci-NA z^Q||{;o%d)yK$fvs9{$^#I(QMLL=gAi}UJk=5@D4|+c72fn{R|%9L z31tEn6>_Z-2!-AQWcAOr-=Lc<&gq2+0ky!wL7*3F&e`}o$N(uZ)tdftKnlN-HXjBF zUH*)dGzAh2x_}{6K9dSUM3gl~UjwjECu-mh5pQ8tp}gNE=Q(AJ`fDvJ9Ru`j<>8qw z(y}iS>TPRJk0+RxSOWQZooL_~k4?TeNrTrAP!ix}6vCVi4?$zckloX2!nJJ?u=Ur# zP|=h@vJ^i{maT3^m0Ew?^R>^^(q{7SyKXB2{LzCaf98XS4}b9={q8?1-g@)fFrA*0 zNr4;>s38xA1-)>5P;{7}c8o6HAS1kR>om`u)Lb8C-0<`$)Gk$KI14wHjpcyfG1 zfA8JcC9R#Di_!H(Sy0HZ7Go>vV=uIMn5Y(KLo>yVfU`n5Y!P4DWIQHKjm$xaW=&cq zs>rm+Ku$uYlQVf1X4au|l`+;-ywwG05U3&<&?%eCw;Z{e-ISO zbEe<(1xc$;qfSx*v)Xh*8c=*hBUr))B7!msfQb2p*liX1dTNTy$hVQ#q^^alCaZ#( z6xN1QHf^C;hHMs^@tInys+BgWbE9@Df7`^;XdFi$Ta2bk?oAJqQM3s@BwimKhD1^n zHa;t_N0zT>YX)4R@j(z20X(aPFm$72s$Xt`Gr&^>4zQ)`HsHYBaG{z+} zam-xgoCz2s{-oTBlE#peTr&Pypu$rS-+R&JV#OW5W-=@fmq$@%WPIo+jqSzmHkZzI z{F(T;kW7jdvQ%*6ni7n=T()Z5dR?MusBDcPu#$wqvSN9F$|%Nxd)1MSAucCB&M5!- zot^|#tZC{`%6N0gMU7e|(9^d_Xw`Rbnn25_o-Zah#*0ZYoqJ8$cVKV-2Bq~Pn;L@q z$lZ;#0P&&oPM0vWt`sfB7rMK%1EmD;H4ue?xCY4E?56!}=PKN8R*g$fruBSb}vjCSS#EqeN}o`O zuyPUdA&~^2Cgjt%-lmbSHdjBMyCo8Em9t$1LwcSTY(Fo@6a;1 zCIy>vx<{Igd10%+zLjRfGs#@e6z{J;VjWQYsP}mzb0!c zXAT;Dek#;}Y4(`EVuA8la`d$TD=3IOU>9=~nW_xPJ0kx2WaOM|-WqNgUW=}M19fHY zyJ)KrinY_yF5&Z^{|tQM6EDKkCx>wVqmN*Ab_UBihX-Qh2&ycIzmc^$V@Db7(*7k4 z{AVWzNAURI04lGMkG&bPG`Zg~84R3G=5Tg;4&6Z?dVY{L{BFAS-~e_X9l(pHPY8z_ z-@XASFW!ZdyD!1C+hZKoIgkP};YCiyQ`YYN?r9~JpDLYHOB!%pWW12@aWgWz&5H6fRSl((duoPpuuB{4AeJNbrbVb85F-weMNE4}m8U7G852=~-483g}Tn@Kdg@L7BaD>W0FKH12LvijiZ_1ZLd zB^OJ1Q*1LbW=+#u);CCqkdpL4Rv)`EK<7YRd*rNJ2spQ>5po0yk&aw0Tc7L^EI5T~ zSTHoXaWv(BC0kbevgUacy6aAqmywOs} z>P0zf>b<`ISCFR84m}B#GOWBrtHny%y9;oV#&Q$E6#CTUcq_$&u|=HQhVe-?aC&6f zP_j_})iLdF#U|)O+LT6jyN5V{b0Qdp>SL!Mu$2QsBRAb I`HXKPnj~m zo33{=G}MUH81Qx(=jB#7Y7gEgYUc# zU;XScy#M((;Bd4H=tRvcNh$U_;*+r73Mn4H| z#H2Y$KW8T^uvSe>cPhwI=S2fPLCu-8aI5!HU@|$LKGuG<3-^rDz3Mx3l@e&v#pGS- z;trQ+$HxN{1&RCz@hNCZIV8|Xyh6D8`f_n;b205d;U7u=W*DI^RBJNa#qW6^k%C>B zXpAA{8&-t8T_+4LXdz5pq<8w59Q&;qM1~?hbRgBQ7c3V#K4Uj!RR&m!H-QqgCJ}LR z=%Xq>kT}+y(oUExI18y#yrktiVO@+2gb6&7Dh7}YXoGH;d#vyCl8wLdGi{uhBx6`P zg0r0T3s)jqwM*J^mw=Uf^C_;QF(ve&W{R?f>fZtrb|LF1CiOXTL|PKwlH?6aO1DG@ zrDLnxx>W~dgjA#Ut($@&qA(MvdG$Dix2E$c33faNLp2djA;o6NRiaS9mmxfftx@&g zr4|?!rS`H523aT&p4HZt3;Wf_6h*iy!2Mv-oJAghaycVmi;V_U3F?>3$AI|ud|nb! zJ(h~X>N!YQ@3%1m|L4NQMK~HpedN4(qp5=F_O4vQ_M=DeV_*Lo{MpQpR=3LnWj2GZ z7pkvM&)}s$`eXRL*%RIwizxOD!48dobG91bwdRRS;UDiF+3r?-r%s*(bD&j z9w&lD1RlnNIM=-d_Db$#IfA4^?ifokS|gw=%&mKJq;Gr8a-0sldFap8iin3S<#jXOQSoVa*e1}lUO%Z zrI0H) z#6}5O?R(YC=c1gNA`hXd_>Te~{5!@PrRQg3v7(|=l?fdtC+vJ_^WF~vF3ub!f*0pCgA&W;354L^pe%$|#F4*78 zvyRbXs_FYQYW^i5Q9-Y>vLhv_#kt^|xDz4vU7#@F+-6$1xj%OJ_z2!OJc5oF{>wA} zGI||vrJ|Od!?*nT-@0`jzV?OB!0G-C_+Vba^3f9-jPYbjj#SDx>vv%^>ch_V77^%o zecyYV8!#FSyf$8OrD)4LcIh>AD{u~GrWwJZZMBR~DT0sr6Wdj`73ZP;6w^)xvQy!l zgyNOr7>YO@Ye_~HSVuw|RWfSAHCI;WJ_e=AY4nSU5DqZ35}i1@a7@@ioH|I!RIiey zCX>x(QmsBduUvt~oM8=jq3Bt`s8#UEb&ahHiy*x7)pG-ylt!T>yJi-g9rB&PEwn+% zeOjHEsc_FUxqXeb73MqlLyuBLiWnj*g++Lb`FEQP2*Y)JUwuEem7}DsHN|UFAX7GE zVzSr$Ef6$t*LY4nX61et5Hh95yC(Jokd&cIj!6=fAXs3A1hDA3P);jCeYUqYNuyo5 zS`Uw*?|CMnb~Jn& zf;8BCH02!jA3udxy@vdi=^TC^W?+`*et^4huQP=1pa19K*bDCe{opa2eCGqM&f~Ra zton`p!w^Q)qBF#>W>eW!z3DSq&_Hf&VQs>ZpGe{`f({hJA!+4Drzc*aKZXH*FWPRd zo^U5M2W29xivy<#lfWma5v%Z4Ob&^?Fgq35=#*k|VTBw%6uXTWI||KihoKzA2Qg$4 zpApM!S)=t3GchBh^g%MDDp6;vR3PBuzzaJkT9QpdLn^ul;o(M-n&joSn)vE$O21nR z=851FAw%*z6?`&t)by^wMOj1c>1Q>;(=J$QK2THd>q<$tR71DfO6jzqafiRf0+Px; zO4b(RBEjlDIxP;>Y6H zcnccS?SP$?3qlL3+8k$E*c{HhwvEN8bAnDP3M(YY7<7|xA>0(#)BrhWkiR|Sd4w=5 zt|uv6OzT-;CUS04G3Xx8fvPfI=x+HxwoF-mYMCwml{fF*`;W7%_{|<tj$B+F`oq9vy)@MEoFMRUTaPQ4`;K{*L8r;3TeTqZJsu{F{ zRwdzq$oJ6qn)}de;khYb+C!l^=kGJ1cvWnOk3W8T1gBnbpPZah-*5N2oSa~qQ(m4L z`wlztWeP{*YTZ=86D&tok_#MaOmtQvmM=)&ZUvzWN=D14~|j<=n%b0_1p-b^ML zP8bsMStO6Q!ALZb3k9+SBj(M6!!P;eIWeI)Io1&fofAh#d(@4o?-?LB=Firw*;O8y z%e6kR?V|YtYROxWd^PliG|iLZ&jXL`ijMXKsZv+8P^^OX^>< zkB8BstbWxC`NtCre;=sc)8&nx`1}!V3mmNu7REHO?csj+vHg26b8-?PvP+C zM_zc(>A|&pQP=Nix&OX5CzJO!o`1L#M$?*xrh~ryBXl1?_!^Y+a zHaE7&d5T_R-7VQKlZ$gvGDXA87PnMo9jUMmy=2-1(|dT^wM4M9FP2GXM0^o`)(Do8_ErV0y)nj7~MuPh6JmX!L5zyvozJ5(+w=M&^o?NN9j6^J3TW1=fT#!*qu3&OrkgvA|4 z=E5F^AQrr~NJfaj=E${-1UG$-TDkuPv(i!V!Vt<{w~)|XCtNy?LJ~;{&?uU@+sI7z z>;A0Ie)*ek{(E2i?58fx)?A4~!9opwcj01`V8O1uil~g!V9*0rBJ@;%^AqL*uINrl z_>*WBnEtx(4?Pp{t`in7H$FS3M4Q)NdkJ2+a}y?B06*$Kfx*TICbLs79Qh@pA7#)V zL9d4%>>)WbyLp#h;Hek*sA->{jtPFL6q}TP=%|=`VLRvzKw07n={N1JMIN3x&SQRP z;(p?lNY30D(&~=SDUk>_4K@*#Bvd(AQj?m0;ZU(Mgc=b%rd-1r6UI}L(rivY*$gYd zp+jLQT74!apkYXfDw}t@oJmSrx|Mg4ufQu5<1Rv(g}VqK)%hCf`Dts@h*u(Z^m*54 z=nf*j9Uzt(Y$4AVjx|9mPDu`Hf`%qiVwY#hF}BGAUO&I)s0+AOa@|JXQ;&2CIZ?`Y zsph*4LPs^PntJRsZk<2lh?@G%>UcAWdI+)nS7;2OnD~rWaO}1T>+iPly13%8=odrY zmc`lsGr7q{~~O-KBtH74$a#&L6m-eFrVwRe>X1BTlRo$R_eH$ucc6 zg;mvWl`(FfX|d^?vdBpT$4qa^?9K@fPpyYam=kST=WLPrwBwbmzYJye$6tQy?f>SB zpM1ToyiaIt`K=2S50dcdpHjnLuvwlwLBm!&1x1>WYl)doJUEs;%ucjwvq zNDIbYR0m5n9R#JQayf8yX(Y`W3}-~#LtU5kXa!d{M8(+^Y>R{Q!_H1xcLlQwtErTv z-eKmJlk7(4Z6f2(gWNXKcq2;7g2{;EMf?X@9%?d{i;}c>%~aEDP7LEzyp+0}Y@9f8 zs1PoiY^bScqNx{M;;o?+Xv9zx%{)3lqp_aK2A2rYCS0H_i1Fa~!-Bn3$PfaRX&52L z($>F^FjI5WNO4rvY-{~WoNy`3u#&l{nrFzclh|RSq*Uw=QtM2UH`FH(He}K4OkyJA z^gBB{_Rp2kItp4~qsk1YU|*rNu!*%j<4VK1tIpkZm={Dr-3F^PkGm?@dMda{8xMvS zTcY}kQwjh}j8%p-;-R-f3;)sO=fET7W_*7?kpZ zmD{rK`~7SF24DA|{QgB_yDGpl@`qKT2kJ=2?^oVgCIVi<@o*91g{?5SxhiC%5m}q% z!L(k$9F2Njfama8e~s-V=HtG<`-e+RX&tflY}v6xA&kgxA|zGBd$K4gtPx|k$CQ=1 zU{lK`8gF>p+GE4%vdfaf4f}%%au?i145c8xg+jRyp{C9y$HJR6I_ozWFDSj{KlJ}H zsd}=+5GAiUV8530ISb-a9akI+>Rc3{Mx2Zwm0Yl21QUs{NHf%k!yypw*99$&s~>cX z_QCVYtR(*R!f(Q88O$+p3PYYSfkK?4gd1jg0~$&3!35U?h;T_fQf{sSL^c3OI_FlW zO-&P1bKQuxPy1WIAtVfIeMAJ`24OYTh}#9rMd5NaOqeKJ`C6K38K@Hg-QmI|;$)tw z4QUk?fyCM7XdZxJXS&`-!*lsFj#kl4xn?6cj&{h?cfLl2zDSVk^IaB7_$=+R@WV@6 zm}JREmig!do{mBoNv04c#DpQEczMjyCH}$H&ob;s>YD3GUcwL-Zz~A$kl!9BpsFd!sJ=;&~sw z;I%`_Rv6FVw`L`LNDmWV7Bh`m+OaUcc0n};nQ^f60^gE`BU)D+2MA$Z^3S^X02qph z8Kp~PVnfV|wBM))5o93~iFgv0%r1v)i}m zdho&%VHVu%7ye$DsW&eBw`JUbQKu}iEuu11jX?^QC#bnK8HL=Q+e zA{62hQN8k1X}`6Qbu%jJ<0=f0wyOx_6fb`kZ5-Q0TSbUk3fDALey*E$V4jx{cufMf z%~otB@P0)SIqwz-ovW(+IZwrMZ1~S1eZN)n_QBHQe zwu~j-96R$2h||H}AM4^_rRw9ShlEGti=EGxY~}&SefIpzJLu-{;NT$>`QQZblL12+ ziFOxpG%w^gMgw}TIc*5&F!6%jTNUVaw^;nvs^+HzJyqCL2>c;Uy$srI=Bi+l>S9(6 z9V2XzV5}jd=k zXx~@PK@^}Ge^g?aurep5;XucX`%BS1Ce^%otu#Y$1Xf*Bio( zD8JCE)94aP$CtH57&rO0GGSq~RGghCd1Xww{D151h_xS`Kd>g-e>18VK0I~YwGTHnH)3W7%xva00ohJt&U z;AvK130`PiXoj^;;~@ApDyNA%5aW}R5;^QsiWVW=!^YosLAd>Xl7@>Mdd zb=jFs;Z1!eX2E9@z-=3=hAFBwP9k;9WiQme6Hk98zmwb+C$DPM2uJFia016V@eR;s zS0)7<9P@m$_0BVEd=iZ#a^%dtxd@hAou~ArKQS|&BpG|=K&JO&)ezUF?Ubb2{{Eh`Y zX?Tuz#sxaYG9xLcVwSr4QpDfJUNC(9jo0D!d+))|`OzWw_7 z;l%@on3?47w6$eUGUca)3so`bpC73r&AF-;Fq%Q93qOa+8RPCeb2AQi z8gvL=!K&7MZ(a1awg^hW-5aA+-u&j*;oSece)9(X+uhltI(#U!vCD|NA@4dypOg!R zcksK?TO{~9h9@E;WF;(YMzlc#QFEwN=vVBh%|xr9kVYE?Kqh!GS9{4xlSDYGV@wG1 zB+l{@9lr|_Xaj%Get(b-VbIKjHXgr#8kW`ePLlsNTMQOU#X?&Xy*N}I;yF{xoCHpL z*8b9AEdt%D84Hd1l60|7y~^_~XzXEwgn+xM;$Y-D0m`#+@$$)aLZ;Aj(Ly!-miwQ^ zLbi;^8Z5j9fvpkr>%L0`c3TWNuD!hRM%&(4`rRsqzIuh=Va zNW3Y&F2>OPJ!cEAye^?nVO}{Yj2!-*3U5abqv`)400FXL>r0Zh>cli=0$lQELl0Pz z5Y;V~&2ZOJ?^1zG8e-#^RSgG^Hs~_2l~Gt5hSy-&-{uxPd1)K|UoRZbA3TCzT2A06 zHeQ9Vkv3@SF<+8~94vCnZxBP}I2%f`2@TWRs-_|CTJ23zm(wW| zFKlnzx&c4_lRr-EiXYv(4~IvGF!Ms0NNgNsuF?xF^9ttW5@ud|9xQwG-G&$7yPKPE ze0%~2j~~InYvDKU+@Y{Xq^n#kDmZ+4&kOYl-4AiLjjavX-QI)^e{3=6(L)aX=Hc9s zC#3%b;SS_(z_NDetJPxgWDLbewNe6*gOI~lpn!{OFAk|8+X=+LMV6$-|m25fyAs$@Q@98R8ypH0h zqGW=eQt6G8HNb*9@wbT97o3?c7TQ&ajJZ87&`ga!kO``Hh;Nrv(k8)L=+9N6Q<~#* zfnaa8)QV{icQchfJWA5?h6UkTj)4rqJZ1jxs00!`qIPhie5pc)0+OQD#6l;*jnIh1 zmTSZo6^{|;WFGjD3~A+vZR2oYym=uSy2=%Gq?WUil%C2D{U&iPJ zF$oQ&mOF`m4tkLs6U($>IL?L_V4GgBUAul0HvG?g)Q97pUHHbW+tB&eo6y6e3>e$I z61BVjSt=J78{d-$hF-Ga6lBee16edNl0eN}#wp2U^8Zl#bzVST+uH%Re;PhseV@y!?R!q|Uye0ao6jKIQ+Cavkv z1VKK9vOI;!`4qa{r}Q#0^%r58;b_yFY+Z8V;@EF(jKF`$_@Li-P@rSxCkl1^X{&TX zR{>tJ5?X~$U*6OF(&RVoYtA?;vfe$(l z!~>7MRlcx8Q)5X|J0 zkOk{9(1?;Ws>BKAT>7=a4$2PA;ZDwh3VC5ft46qlqejT*Q$*pJOGqzjI5IIA0 zpkgLMjD{=E5sIBck4W+g==8l3=-1-=@7#lf!*j31opIgGZVyKL`(6Q@3!^Fr5~P{c z;rX)MRj6TJ&{hO^k-1yFBHJT(pb=b&ly4TvXi}7k;gN^1e{G+}`J)f+do4bPYuB#%@$6CMY{c`1J%8S|Atjm|lm59E-h5jk z979nrOHMR$(V-T`Q-nB0@tGJ;-svzC5Bds-lf^2@nF~KT;f5E1(QraVAA)9lwN*>U zE8J24M!r|=i`93DKM73*YcoQ_X*Q)^E#WU=D9O|`N`+V=j-FgUq~bw@_bi+5DU%^b zUGavZg+U_kH=FoiDz<7tHUib8!!9BHGa5#y7O+MJt$|UJ=dWqG7YONR)zniMDs03? zvtaAjq~jZxzlZFUub)w+r=M;K5jV_^hQ0fL%>p_|6B>3UmfAS~rfBoNIf$Mi(fnqp-r`~$+ zd)c>j?S^0fI}~bcmm0^uI`&@Kdf@N(m5at`RRhn8vmz_TzW-aC<>x=EOo}=&E^X_f zYazLTZw)N$4H30>yatIW@m-9S&NvO+Wo)X`cmbO*7=V6)=FR>t?0Er>Wt^N{w*ko_ zO*Hhh2ttUjL$d#TK z_Pg6#l)Qu*@ps;PAKrQU9T*IIv>{!)z7KnQH)#``&BoMQ+#E3^s@J_nY>a2)bKjRG z%$A6uR}}lcC>I2~s56tbj1GrU6$R)PU6wpMi%b)?(y>!b+{LQx&(WU8)DLmt%{2Zs zT4r6YfVMzLwl_bxr(vTbonV$}^^|t-M`&{#6=P2BI;o5`RNXjSmp1W2nU|q2vSv zT9+N#ZwVSPD!`?fdBIU7cwSfd|LGW73#&wSPMn}2lXA&^Gl~$(Iyj6ML_fBJTD=z{7DP+-gHF{)R$s72Suj1MR!phSk^mi`;kjHe%mXRa zs~%r~rj?TL4%u<8F%L}w@+qdwyu(YKKsh=tmXcAK!KQyPnHRpePhs5m=YQxkF!{u5 z@bt+6Jb3t!7}mTD4bC;%?2t3v8cAI8mIPJ=M}geUVLVyDyYIY1g*JZVN4`K#Roq0q zo&fjn-G_<+_J=J9`w2y%qxknbZXj*lg&kDJJlTZIYNR@t*S z7kurwE3`Hte$htyyzgyYjQCZAchtAF4nY+*L``s`Yss3%d5%E>;1*OEqL+CkhB%W7 zax@|&Vi7tao+FJc5jrts07co>iD7^`)*)I1wQiwFu!PBr6$OY~bSR2wxY2>~{2bo= z)?4tEZ@lA|S{HUsPXTUuB@1vJPhc@!_~Q%m6XLzGe~aaE<@q7Zob_!*fcw6kZw`k4 z+3T;odbl|n+?kBW|I*3Xc>8>OHkwWrWE$Qk+Cglaz>FESMxc4Dpup}fi z%}CM^J{f!`eAOZkNVAnV@Zg_h*-(!{k!MOBHsoct93Neu%)D4)gh|xCIXkqXn;5E3 zsE$t&I{v2pPCwy8xu8k7mkJUj9>mb2CDpj(g`61X6qQ zsWb~OH~@~0$8dZyAyW+tEup^w=`wM!nNbr-!*;=KI&#=Pc=!l9%Zh6j3g?4>Q9Leb z6a&d|#%al96dv#9QoXX4B`2mhYF!Ina~;VA(0J?>>q?=H3lf3i)n)UxD#W7-p=LUp z(@M>&S}^}g2^!*@#PgTaGNeee4#D3$DQc|R8zrMT3(BRZ5FQCIryCRBi)x8#NFu~_ z@I38V7#`Q1^W6yXm21-l>vKRef5|TsLhX`}W#>W^3Tnourzc*SIrdg4@&WnvwlRXu z(a;O@A@uzE8+83ogi|`*_-->!-bqvRb#oxawFHTN0SV{TP{inGLP%yozNZ4trxWG!T&%&JkS4nw?4aB}f=ufyIS0cSV4|dgr>k_u>7I z_V+e^V}I}UPx_nu58mW1=JUm?-W>mw*YJMPcmFf~DRQ@{zA;~v|Df9~9(Yr~d+)*F zum1M${2#A>aR0rPI(2Ca-^q({4G)l(C?dyT(Sv0*q0f^b7Q(tiMV16~Q+$w}K~{=P z)C(=7J2#yppcnde=Uj)sHl|4vV~q!ID`>@c;xt`b$`V?^PbqX$(!VKU3oE=h$d zJ?qpa(B~BPNQGgzs#LpxPOYO>QnEGL_PS%+H2EgPk_z#QsBF`gB}x?=#tKvMT1oFq z)(t`zvl5O^OA?%&P6_8T3&(!mZ;a0Tn(LBfI`ChP{5tP#459DWPZt%R9Qm0zXn=zq z_;w4`xD$D%S~tRz+&7#{evO@FaGI6y_``?r=3DRk_2?+i0{e@yrPI|SG7jcmxT7%W z4SIors5u`qu+*j_t_%c1oAAa+l;U_=&+JE%m;r$K7otg5o0AzU|bgEOA~={yxo$ijZZ`cUy%dLV4>w1J60$wtbpEDA9l93N#nqAqyp0Z zx|scmNkQnCK>rWH0q z7=mE2sjW~S`}H5JYLm^dws?w$r8JdQ{QO2B3`}6Tm13q7({)%!X(u3h6Wq zwKxfL<6th^SSS2U6cN-CE4?x_iSWLtMWMfbqvh*e9gex^yQYA@C=I_&3umjSik7aN ziw**1@5F{y3`K>w+uPmxZ-3a+<$Oy#85>hNj&;0tY{7#`4Eg(F-r?j@yG}m9!D_~NdkkDf)V^@tdXZ!m^z5m z#Hq9d6HQz$7L3sqo&LbTyj|G$8tG_rNY#D#Fx4`>`A)r2(IsM5wFU@qo?rE%I)Oo{!<-cOFpXBGh6CJEYgZ{Xq+x!jNzuLU-ZD~<=oczKFhE}5njwQ$=(AJUQ(lB(tvbIhZ7#XNMMC`JY**F1^;I$ruD-xkR#g__Hf{*QYodyp6CAeV z=klr41i*^PxLVFnj;Qhv9n8Krn55ahA;N{moK}ur*yhN%X zE2%`ng>9_>f1dX0?dykMee3?;c;VI^b2>TymBXVaw2dIftw}tLf2(FI9i>^DU|J5u zOsN+mH6fcK#YN-GjqG0%>upn>T|OSIfzd`62E(B@t2UtTfA+n$ndfO7CT|cLo0(2E zjTqqjG)&EkF;|z>H4puLEfJ9e5JE?=^ade``tjQF(vm9cuBp6tqUW+?Np6qHGdlN-yrO~zxd89apXbOuYz1QQKHkfzXU2iF$q5LP2o!nicF zaV11+?hY3@Kil5znpUzxOWOfA2j?u)~}TgdAUb?Njh`Kl8Kj^FRMn1WghizKDF?fko9ug;5jYu=Tvv zicF~JZ(7WqmoM8e#L(VBm+JLW#G2ncF!S1ZZT;cH58;Ck?-PuA?dEmp?(DJRnx$2> zs7~>P?PC?-*uL<|yN`b7cmLpTEvm&E)5+wYMMoXv5wvIaJ(F-b*IzVF^4w(Mg_&BQ zAc$;L41@=f^da32b>hoU(x?p!Ka82imxvAxG^>bjoazgzvsG`veaijrh~^LB@c^MV zUadQ+kj_o*^HjhaNpwif^5CGcCTc^e)*j-Sg(eIq=MqB>m!P#qolpR9m~qayDw1T) zQS)Qp!DKdv@i~VM^4iYCLP?*5v0HSU&iy+p7XzC7j=c3wA?u6_*tu<>tKcVu3Lv?<;%$xdwaEA9KrXN`38W(!%6z|kd4$KcozkMB##(VP_Y z5XW0^rdJ&EjWPLl*=4gFStGs1hm^*o@humW{fvr^MKUqsnddo(^O>RXtcDSAhf;jOQH9X|Z<0RFju@k{W^%Xh&n$UK%5c}TSG z7?^?66`4o}y~1ns=R|vf>u_`Lnzwul7!CWdw|CvY>!p`J8?d#t4b$^u_=7+AeQymf z;nw~>>>nKX<2E24_9Z35NxkPa^$nBuu?ui)ue|i~yHAb}|N7C<@lU^h?}LG@W+735 zTxVu=@z6v!QSf3YTZcTwjTe^H&sBKFDR!>^fKzpIj6?5qC>|7(dT8Al%}8_smn98Y z>L05KMrEu_^3$e-TxVavTnqJ;yt0)4D^Ycl=KpTb>B)aLfgoez<<)-CtRt8->ASyf3ie z(t4SO<`2S=1@V(C(0M;z!p_zJo*vxuTK73=-Bcq}qQ8jL%PqXpGK2r^fBG%h*x7;W z|MD&jFh;v{fg>lX1N<}NXbOTqC{eEB^-_2_gFdWI*TAN?^0#-l;K|d6-rD!dBUTkV ze+UO3eh9P5Yvg_tJvw{}H&F}s=c&RecX@ExFnv!1`0KA+Mc^jkfk_7;hbiNVu;>byMN3&sC!gXlT5w@s*c6TLSlqV8+Z?HyjAWl5|^+)3L_) zfb}*=5krRnZ9tO0x)$xljW?6B4wi*_0rd^1+1J*d*-Iv#exGH@?2+y!5iiYaWqAx* zXpUiXeXPa8U5o>-g$?VHX>LB2*OwEG1oI~`lk((jNpouQW6c;tJS;*y*B<` z_^v#C{2u(Tzx5B{r+)Uw;8S<^#a}1rfK!W~DM7G)07A5w1zj#@6i9&ZWVbhf?a`(e zh$pT!IpMZtc}5wWOfW-+5fqNQjFd_h z;Z#6lit*3LwPYPaBK;twLaMdy;J}lOkTQxNUqWFs#)GR^3p1lKV{t*-csBhOaP2kR z8LcfB$^q5rB&^T{C!Z`5Q|~kNR>Bm+bVr9L4n=7|V;Dn5Ux0#ub|+eVSyJ{6#2gHZ z%tq&zW4g{#t8#ly1t%(T$rxrk#IQ$-MiI*3)`SujyozBaLbWZ$`!a40{5?w|4%Uvh zrIBDbOD&a#_=!3(^E_)}4vY0hKavffzIA*&^QI#|zMr?mN~r||Y=pz)tkTIWG~5$5 zkF(gNK%S+MQXK72sg@0_+)yNjVMq}XMDp*@_drQsin(iL^*j;cZ36v5AAtO%Z!N<< z1e*khVYT+$lpQb)XH4IF;EGQa_ZpP}aA9Sm>m_`ZCCA!;fm0D;ESO;U80GI?%%)sN zb~%Uf?3}sraU9Dk;i(q^o%0ia@ZPuK5C8D%aOcL)kZ?AX*TVN91R>{SAZ!RTB1JTo zVq0JV7i*YSde|kZ5X8TyQ_P0!!q(Of6#XtKN60EV8BhG$TEdfuTQE6#8Qg7~#Q0OD z=A}aAdn&-Uc6Z-@_0^aEgW2@_pLy%Ow|AzKDK8rnwI20LI&s@!>?>=*K-eH^E9KJg zM3omzS|DajWju)%87E9sqG>WYk&%V88Wo}C%GiWeO^h=|Nejyqix*8cUHpl|ZJiKV ztA!_8NqTJU_sj%gqRliErCQisLojMx!03XzD&HSdPgkf};2yIe2h@nnQ<4i3iaZ{U$v@J+1KGJ3>u}z*aSFPMN*$fz(A?P68 zI1qi$B#MJuz{}M%I54t*7#3`0$LQ&x!!(6)7p#jA-srse@hDDYZCri|t}YqUAbb$*zsUz}e0H^v03EAQ1G>>bL%VcQ+|!1HIuV5_5d5+n#NUo%Xt8u}y zIFWP?qy7rVLfvT`IK=)+|6^&drqw{15cI2hZ!Cwh)DA0JpcZ9ZtR2~amD+w!+j^~j zHIhb^mP^2%#@_;csWkvXW8fs>EnTN*^_Dg=LfSH{FWnDl-se0iQKQZ}79Kvl4+qER zaQE6S47}F7T&iCw_JYo{T$A_w{0yEPJcgUMUWARUO@H4$W!;`5F&~BkdjXBaSk?>s z&5a%S;V=Fu1xajdU?}r8XR@Ch!^|NK21dsX{L*uOx&mBVr`!A8>wDM!&G};azdU|; z|Bc0RUVC4vfFQ35!5f%a=u9Z+V8nDK)6A?2B-OQuC@VvpR!dfe*F-NWa8FofE5mq9 zEiTNHk+_5I=R1=WcVPUmnSyqQs!i|#tDXv?C18P`0pJN-9PF2(ZTQ#-hnQ9tn%e#UUatd4~?CUV; zIA#qqK8#m8WEBNl^NzAu)5BA)_{K?1zeH1Dkf%I3DMU%LUf zU%bov7HyFHz53@0tUZb_L$E|4$a3-aLt7&PB~gM*16m4m10aFpRySX)HL0=WnH)>E zPBrev7_|C1THby0SSQ@&4tNY}Z|~BO(e!$e51f7ctPGWoi#Twkm{1lV^wZ6u^qR3L zFb|^OOl7q`a$eA6?Nh)*13i#~k5Z?LrTi7W-XKunv zpLn$@I-T!-7-3uc<$vMN;?utK>wo9J`jQ{1zj5&7!C%DsahNg1!Rv;(PbzhQuo;O3 zAtD!%@DOf3DO}4W+m!K z)5NY_FZ4f>zPgtDcnvA6N#F2%E#o2Yz}6n1<+!!*jzVMcxON?|e!dzxB;+u2R+2)v zmT;#LB;q;nz&@r&CE z1nDI6S;s%f2%fK{tV`rfr_Gc?X}NgDaOkz+!xKMt8*t;sEjT z5r7%dIIuDP$SHV>7Qrw-AnQ&ICUl)iN`MRKzHB5F9=j z;v_V_^3M=aKViv+Clky4OgMxiwBcF=pW@(jMljO=)*Jin0$Sl02I;thHKT^V#CASVmEMQ7E z7_Rq9kx~A#bBqMiOij-0;y&bRam9#?N`5J=t!F1^{C|&x!RfT~|@`3nQ zDxI68C}r{YWQ?s-r!apPHcJuWZBFl|1?oTv#SEK@s>sL$Fp|_$sbZBpCecv%u}035 zp?{xqFFf~ezX;n8AAvjaS6$?=Toh1sEsZl#8h{N$a`W~buQ?-S^O79ZNR2R_%%SIB zMb}@C!*%^V#%J)yU;P@)7Z&z!s@3jjTsC($`0rAnUc=B9mrDbp%bw4U;cI{VCt$NK zy#B^(q@d2mV;G;GX2ap=Qu+LS72w!@?XUc$dw=uq{=L7sy*c{N?|t~uHqt9n>?D4P znc7%?Ew6&9Qexg8c^rXbUyV!)Cjy!XmpI6uOj3kJO4jJvbgU~Nb2ma>VPdUH0%V%)3Lv70e9}a z0$yW+Yx_Ii1m2^K026W+iy4*7JI4u)l&BSB+POe5a6TnxC`Jf&C{xc19RF{xHzIgo zS@9-}MRB#rbt!cp_4)16#<58ny{*cvV&u9RS{D_Wbl$0>QV-X6)%=OrGeqdG5<#6w zh-s#O^UDa=Qz0JSVKR4{pqokFql_2}axk@xS);n*dbd%kl&rkw5X%KM7kwer8lW1>h-hdYeT??+j@Q-+udBlsb>} zwem0_1R4DxOi)~s#)UEa+&3n7g;MSN2ZBU z;nWs5=#sF*OYRbI@28I+5f86&7_R;r;VwQ57V0`XQd+qnos$5RIx=2&;@~7eT*X|HDjq_~KAHe?3z8A1( zULH(@`q^9avnhP%{SRPa4HalGdCHzhDV3!33?6*s6@oM9zW55*;gBPQ{I|>VQ~2PW z_bC0oGwo9Ow~oa6FK1Qe`~QO}!2jxB`isYZ=fC{#{{6hje|OLu{P~U1=;zMHXLlyE zS&tl?5=P|^mm*NM0!dp4<{M5FN^b>r9MRiGY^)MiuiO&mMr9!!lVSy*pG?9`Lbb5b zhC!KfvI!7Zg}=66$QZY2GOkpEUTV_FbM-W(lRDKx<4@IkwJBD<$_Wx7grM!`tLUsc zcb#-!B-}V@WFRJgrEJnpMfua@pCZp(im%yo_Q^bFjZub z>-9;adVP17;e(PRaF7-P7yWd?f;q!-U3rJF?M$3wTiXL-c%08N*xuR%)Pt8uExTZ< zV2rCzL=WV>oa!j{Mk8;sZ<01YpU+|D-_sx~K%>_5UF2abQTgaGs$sb{Du=UG#Akzy zh^VpC#*iee#8+op1$68CaW1g%)vnQS%IjvK0-e?kPO-YfdIqU-+0Cgd}Cwg@$B&%^cfEyj6E#IW5AC$_yeBDe%N3$ zV|dt@hYgr*%q9>BkkG#Lre3RSugWztmW+sZ&bjwSL}pc~1p>s9SIVxctgOsf|9kF# z{_~&gHI#|Q6M$Eh$GVWY3Vl658iot+7*$-8t$vMV1fT%9hsQpjufxt=J8;sT<8wDL zK{VG^XgAwH!DGO>b%WgqXLfCeZ9BFx<;6XD@iWMBgUl@0fGiRv+XUcq_YLw+&nFzYC_m@(t)=G(LC570|imHVDS*eD5rfAIjVUo+|`&TjW3t&XZ_r zwQ=4kvRKfI-m^SY-18>!_D_J<&4kd!oRb zBxEN~o`Ib^_ro<;Uye)V2u__m1+9+91sn9ro`c}&IZ(u#dDrf32*@>ztSmlW6$IH@ zwG0JHUecs!3R?(DPW3}!WlBIkNuKJ}Dn?yhXt$cs?Rpqxi6kdU&Xfk6Zc_{A0*?}$ zhzt3m2K6dNi{m8@La2+(M!+k-lYkf}lmfNwGx7B~TSn<};$fq+Ku#bbeGM~o+WF7h zQ_=cASwWBxA_#3d4Sgv8qGh;y;Ta_TEr}X4#Y&qfq)5^nvAkxpy77cUf$V;X@2R0e zyM5{$!kl0hAT18H~zz`{vGWtNoDSlj}N}*uRrvJxy_FDb6Ps3Me3_8_JXb79}oyLgUG|2`>rFB$Xz7 zT2$6QbTc0wiN?Q_C8z2y26|IyH&TVllXKj{gi-C^#y3uODiGTpCn0G1H}TLbvu_~w zou?dK$Saln&EPKF)RVIaHS$TuD3qF8kj|NRF_!8pNNJqSrGrm@oR=!m6qP2AS?1*` z@v4r&^5PO0xWhNz^eixSA5I=S4NFT@!d!>5=gz@nk39}6%gfMQYq0-F9V6(guekzl ze*Ozs#~aCHd2IMR0aJx4`NYtTm$RwMi!GxF5xs>I74W@rjK2Ed(xXcWQx`lcx$cYh zYgGAbTQ+x#p-7(0)eK!rP8(ki$u%ni53%x6xtd5SGj&#B3`DnblBFbS6e~Gp%;nw$ z>V3}#pKWjZ169WxoyUz`Br`t`1sm(i=QBocl=I3~U1rPWdB`eH%6X9tFV1Pnl$ZWR zm>i$9Zeo}|qQ=BJv>K}{>ztgb!=BxH@LY7EN0TZ>*!VtU;}hJE*2Ha_SU3iI|Hp@c&Hs%Yf6Z^z zVdn57(7gC^h;0oP7nkup%1r4{6B){V1icR8M-C@ryKfrCkB1KYdl`4U>es1>=M(gJ z&j1{BL5poM7v4wMsV3)?BjZ58!A9)OxmzJT|^Vr70^Brh3R^aUHEPn06i(mc{m>3^t z5MN$d;kPAFxAQr%8o{epH-)j3!O}lG%9hDeG3xR}azFwF)+Na@n#vx@_)G$*TpteW}+k-Ni0O-%hIyMO=xJ ze2^%WNP6j4sN%9rN$?6Mps6A==QuF=)h`2v?eLh>haE#W(h5dpCaZ_16)QoSDSGC z&)tMknFdcR%t6=np;*`9=o2TPv9`wG9r!(rj(f}}Q=?h8=fdYc_gPq7U4z&BhyRUb zW#9Pn7vaq8IeeXtH)I)h?B2m(J~OkOwZ?^#&2?oClSOfS*qg<=> z50?7O|o0eZVUDhX$5Kty*hoZK;A-p;ykFTi` zZ^$ld*Rjm!Jx?1}VX6c36h}6xneA|t`4F*>68#l;55I<>!o$@OHRLAlll*nMT^A=% z8$sH|2z!-%p~Jq%5vVXcN*gawLx696``hrGn{I&1F1r|S*b;kUG}{fBTUdbmA9@%k zY!`O#zYt#VqT69N0=iA@?1hoqe}y$?P7Y&yK$bc3iC>Xy#l^u)p9xzCFx+pIh@ zhY&A&s zrW6fY7-7OV-cg=t6-PZ=J!<18lcb7%F*c02(?B7684U8*g04t9JBdh!gDJKB`nptg z^2g%&fk-_q>;n*DhbZ(Q!ee+2S5(xA=iJ;fM!h8lan|v>J!rK$(C)an`rHQ3zV33M ze4_A8(s|;gmWEtF#aeC~#wIGvm9xoMAo6l19*@Z*4^mA#OSbjK~%UPGk*iu(DL|N0k*ZaF6( zJU;SIpZjnS_=irPIayn6G;e4$S8wfy;SR6o?(ls#-|vSBrIOm)hMbh3r~CN5rKED4 ztP7kjSrMvq!*v^kXdo|at2}S@4D27vJdUHXzRpFZ&T;x zHubQqmJ4`u7r5F>%h^f6M4GHoL(4qG7+!R0nVX$e^M;Wa;Hm7rYCe$E$yq9*iR4Np z9(?Cvj`X}dxZ0xcjU!Kem^T>m2IXTrl=xgX`4E4NuPtH3MN}6h9&>nni8|m07JXNO zw`hx|X1ho#8$7p*k@)Ah*kbnu7eI5ZjVok)pNJY}_{?#)ap_EgZlX@8b2ZT=k?V^h z7|GMoL}{8auf`}*sXHb|fb8W%7RY9fZ(yQk_^guQAazDEEuNC2zHox{Bjx+93w4Cp zSdl{~gf1Js3c-wfwA+0*@myOrw7y@5d ztMLkjDF}L3q9cfYq2m{e!Wah6>@^cZlBi1>uw#$wd!3aA5%csscU07W+Si`Ry>7^XWZCY!!xR@#{38d?VCVraK zbY9i!xUU`udWL2u^M~bkVe+o8!xL)__*l0|^B&;Qf;|4b&1nNn?&2mXF&>9%xq@4! z;nx~B8edyk0UKB5_4*jz`zG$zaN))J@e>8$Pi6etzq-Bo(NBK%!3!_ApzL`4J5QcI z@%|tTx4VveHD2hyptX?&2g%U`)k6#ZsQ5R{CB^kw^|s69q-Dq;Tm6Lbpv5yUiu`CRD0LZm3PyL8sM& zcB>;IJ>BFyQO;!cOw{SXpY}q!SP- zBOnzCENH$->Z7t{`ZOpir^ebMKj5*mUZqllGOidq{g~@6h*xJLw7#H%gp+8cQKg50?! z_&QGTr$KPPIL$++y$CPDNPI$e7a|%mSHRT`LNJL1=sKjkj#lF2S|~pdjvPL296Ypt zQy~AT18^F@^70qEANj=Re9OpbZ+YDv^Y|1!?#0JnzWXo!O2?((u72R}?DcxrJ8t*F zcBezs%M4CfpWxhc`k~JgdaI^i7|vJj|d`q7lU-GAwa^(#wf`fF67C|($)iTS~Vw@#G6O!1L}qIg8BNt zi!vqEps5HDgpUYrFBNl?%MEHq$_4*Fi}>uh)d0=LthKdJ$77CQmGax2Mo>JB3@+EGVP#vp69#=Rt85)N4_vW1z=bGjZ>Je@k;vS`SPd^smO;l&6xdMv_ z+!Q&t8ZB_!4ugKRR_9Qsa^w++IvzZX=a(39c?eh!Nbdy|e1<&6@jOtS0M!vt5m*PW zA(H7%P%Ab(-l1@}3!c=m0)9#XoW`&H;_Vyg8311QTmPP(KS_^2`_SKf-Elm-=elFQ zA8u>6n_E5C-KrVJbr?b1*pK|3c&Io9RuFgX$caz14ED_{>joj-}MAQK;yH799V}$F&?t>S?6h_4!0>j+G5(I9< zxf0^T3HU4o?sBCJ;|N3qkI(UB-wzUu4=aZ{J+Ttq_Tn&7Dj>$;1&7E>3Qf)j^p{Tcw(8Hm}FZ7s`@25jGC;|F_P2}ovz@(fz6|D z8b7rF{#3^A{Q7UiAN%wtJv?pm|K{gkHuv|R{G9f0Uw%72_n8l^H9OJ5!t8Xn+uQ4T z?(=XNd7Wk%H{$ZN>_>s6fQXN^#9d~XQ%(a^#~vYim>N_|Mes3tJ9_vS1hLKD7-!F%ghw8I6dJ2dHr}G=F1_q> zxbn)&Vb8AZAR4#O#pAP;GA;kfrZ%t30ud_kA#leC-XOZ8T7()DV60x|_iHsBHfo{* z7vi;<>cf4@8LCC0AtUx2gE$RRrZ9jPZ2oA`{Aloer>jC4tnd$(jC3syOehjB7d>!s zCD2%Fuw;*qm5MVoWsH?x*U9;fV|hJmN#Ps~`cp&G5^Ds*EWp&(38>cV!Wojy%0yFh zQL~|QF%AE%K@(w)_246sVG zbI`;Im%w`l9~1bPl3%HwVh)1^KPRpc;?sN0CI<(^hu3OVF{yF?QH^eDf76Rwj-P1& zr}1xo{^iLPLEtT(dx@%mV*2G>@B52y6^oVWj_X|Q_BuQLFnktXI?u*s@Ikx*a>|2P zr>^1*8>t@yBnd#sfT*BYI6jln1>JO7U&iU1X6-}|PZ6YxOM3XVX__ZXRYWp6U;mCO zaicaf;lYU~Hx%6h17Fp9l4rXy5Xo;&Z)Ck&o=hwLRI2`-%242)I`U~SF);=6vuB~z zrsAC%)W=$EHdU*S!-0zrfO+y1;EIgZ1_auaWEPWGz}14|lUrfy&KWUz5;cKQR?zbh zaH+h$$4wqGdy?c=)X(Brq{eH4gV!0*iRa$GZ!b;^AoWB+;o|fnx>c&Kn59xk}KaXb=ty51Gudl&KrKQHB0n1}}6&$m>@4%p17M zszQ+pWr{_f{HQ36z}w0hOhIw3$+BglR%iQILk3v1U)*BQbK&TthoS5CAV(CK+X2I# z;g1Z6=E9Yk7UK%3=fGCOhUWp72Czp8B{Fx?n^kafo|u%^hM3eUkih&i^z)tf-uss;D=lMgX=!KR^saS%@A5{gaZRV& zp2Yijn(lQx6Gc@!tnT3`(bzSeySiWyZ&qREB9Vh4_b)eo9T#HrUM9J53) zeaN6ny(ceEXV#N-5w z;qslpip;Or#7!5yBW>yugkWF5$Wym1R?rY}N5xOvn-iqsDpVRJVdZK*iTRmYH5$6| z;TfzQi21!^c3b4*fv=-VL`u*{D-k#dm2Q489n4l`2JrMNx~k;3Om%BG^L z0)jauGP%IY;7QER6`)$B;YXJAQ#C_qa&(r$xar}_x#)$IDG6R0(YPDT(nZq z>HDfctSfkou$sux~3sXBl9ngBQ?2aJO%l^rg7AinfsyQw0(agjtuC zQvXjfMgabOj){qC{}*3)D_4wu_>-UdhcRbttl4bT78X{fyY0?4AG%j-HoTzabS`rv zx5(x9q;iO)+9d`^+X|?pBS3oNs|Z?409%(5#%%F~E_rPNSY0mJMY**EMJ*E-#VK%r zipr%2veMvcii3=V2Mtd;GOMq6@dRwy8+y#M3(|w%%Z=OM@6R{Zfxn_H*1d2HAr1kherb~BgzUdAZ(!u@-pfH!!1 zwF@h&Yp}Gq1gqp!=yX|W!ES+r8#1(y>xZIQh?*!CEO6Gk7-a`goXoND79~4Lhb|GGGDsq)g9{E(ZIra+DBa{H6edOPrckql#C0Qn1>AT-m#a;wT?EnPNlUaW z!thNj76o7>@iSGR_G4*C1!>_C0DEOu5p0Q*C;t<1F2#Ky{OPlS14!=?F}SfYNq>|Ke1$cQeh0*$DmqTxSLQF_3*^QvnYQbuw1=aCM$Q6p> z{Nj1G?Yx7lbZ6tMCLJRH|31b|*IY9Yy1wG&FQcDVDYa^faj=1qMTgWCamL1D%wzBQ^S>V_|Gc zw`|6fXD0_%(PX(;xl&*~zvS;H5Wsu5M8E5lMGZXjHnF+43fr8U3?)Y zmfD>z(?q30i974qy11FooH>P&@ex>Bm}e(LqD3r>syXT<*lj8wn-ox6E5`nSJyJM7xK2Tq?p2Yv|bFCF3E zHyQ{|S{@uf2A>vt1i)dy^0p*+(1b>txM1#;sS6rG1EI_zP}gZ2bE4>o5}d$kJ85l8 zn-kIXDsn1XP8!LnqEMOqCg5U^^C(KImlbWtjYfppW}-F*D=W*az_HBq`d2_XoW$fp zHTs4f48=92)Fs>v$r9UasDd?VQB5!L14{Ng(4nTp4Jxk6LA{U%5haU>==9mMI5Fhd zsnX8np-lD&B1=jwBZVz6HGZI?r=STtS_} zKw=SJONr~6VGFB;z~ys}yV1ep+}Y%UNyZ4kf5_vVzyAgT^!NSY|MlJvyMBLf&uKj? z3j5FN`R?;Dx~jBVot)EixVS__;gNz(akI*DcwLs*@M1pFByh{AL; zA->1RXnJNloH~61?z`^+xZ%0i!`NgM3MG>zhQ24NG0EnjR!Qxc^~i@Df;$!cEX*%K zvnePOowVNs43-5Ix=}Mp?DMZAhCFUmBu8Mt&AB6CoYoJR%Ao5ZSwYDRI(>%cx^Uw7 zaR%-B9De-4ek`yCQTST-4s!z}it`Md7;3mU;jeCXiHOk&9WD}sG?)01~ zX(by-sbV;3UmVIx&%{Mx(?hBaeobh=bP`FfXL*h&K?_MgfXww2$MQOp^%7AZnz--8 zrRVY%cljw-YW&tT$*~#;k3pd2z}qqn@bGcIR@hL54qU$rkT+rJf;~Weug{%10S`ZR z7}gpcn3|@N{tENq-kiT5>Nd0yK++ARs__yqADAJi=%fhK7!iHcrq7H;Jx-1=BviokpB>^Zi@oyP5`Pu8H= zY=POf1Z6;q7ei;)LyPGF*%^kOr*uguY?LV%K2o-=ann5-VGE3Jkd-B8esiLZ$?E;pbvV3kT_e z!ubBhg_n3#Y%UVlY=Kbo{BrpCa+{q~#=Rrf}^VQnad&W;g)KLg_r zUi%+O9(0Nx?|jdn-CZn{F07O*Kj&SvO|H#g8-_ilyG=`o86eF zI0h4*g9+9rQ@bcnjt!d?Xlb-Xt+rstF8KDhzrz&6m6u-zV{9lSmdNb$K@pTDO{BOG zCFFKn7hZB1jL&R?X1gbQMT(USMXV+@%b=EL#EtTVs&g6tM*9~gNl)sH=&*#(DwJ3y zLfcszud@e->EmQV+T>6auxR9xY4v&jGeUt#POl4{P7h|r$1&0_v%{URrWHIf%j$Bv z)Hn~es}rj=QMg2jy?#g%$v#(9QbPo4P;vONqqsd&aNBJ+<76gkBK&Rut6fo!&omHu z$0LB3%0(ET9D`i31Qr*h3i}9Q)hy_@zI5|bKHt0@^0=tl2$~qUgIo9gAbQMV%o77eTA&nxcNRWOfiTzbXV#4)>kFyTFpfF!Wiy zLh2TxX6Pui4qZfTn()Nr0j|6Pw*x1;-50~z3lEC-#P{B|b1#JY0u=Gw;(gEb8)^Be z>RK}c_Cmtp;_UBjdV|O@0`O;eyz!3Tq08hkdi?R9zSk%fN*}3JYg2xI`dR&;zpdZ* zZtgn0>8{(Wb=s|Ruh-RlFHpz~A~__|M;yvzl99qnL2z>-v#l;}6NdTNESF44ob`Ut zgWD}=6k*JxWjf7MQgfrRVI$-?^YhRgv%a{8{~i4Nb+6eN*o}k+$_}07gA`GnIX4Sy zOUrEdKfQGtwr<^uOW-`ObhuR+bENX7)KoymA^9O8LB?@+u zh%!of-dH*c3JRmorc$JAA%iW7?(Bla%7lr@DQ*rC;P1Iczz|;ZEcJ_gF;Pm+fp}QW zGB{C7k-KeKLS{s*PMDgWfT_uGJeDSVpvN-ertJ>Z$|Re-j-qv|ES+;0 zl46mURwRw-67>l$W#l!W>@d_d!8(4C1da@Y&m)}+YMV#zKqg*HWzkB7ju+8Q$@L^x zhSel>b>P+%3(&WmsH8u@Px7-3#_dG~f9`q)r;p}s6hW7Xb z^yTHgWy=;Z$@?+;fYa)Tld&|(fNHL7i48l?7yo<$1`C=IgcoPK}Nj`CA9*#b7niHPYQW>Mjah4v|Yjx&h z$%jUdM7MK7I$EhvLa$uU^JJ<3)1@MpzOoMprRpe3CIjk~Nfbq==Wr=~h!Zk(?3rI& zf=e&n#~k)rvj?T=DY)>8iy(d!m+4K6(EB2x$(?p6GG(QZ&t^(>1dee8mDot;bx~c} zqf+^vw18Ex7Y;0jM5{d+iL3K&=+XjL<4B&8tdm>zB5)PJYpuYkW2f=63Vgjh@%Ujl za`*@|TTNaG&}VPowFj=e=4!afY-#TMi7pbB1^R82#CNVPh~25BHo*ZNu!j=$ig(B6HB{lL2y9E7EbF@A3mK#{d< z9pBdFDp;BbxK|h+h{eSf-sgO&juUd*!TWZ^f&ZkB|9r>q&=pIR%aVLN@b>roxsDNe zvG4mkd#QJgz8K6kzLETu!G4gJ8X1PFQ`|f=R#0H$5UBF1v z13!p_B88&xSR^(L-Gcr558*Puh^rwV`s9vC?tA1q*dadE;r-aiRwLSA)smK#3kO3| zSQPRlIIw>o9;+TKki5~uNv>|gu_KSd(WA$q*=}>UA@Vw*iRS2$!*FhXkyjPBzwl<* zwS9(7xB}_+m^ul{UOU-@bR?V!*~^{3Pn(>l23B##gFjX~BYuj75=BJC03J@x9S>JI z;HENL{o-T^4`EEU_eI7$q1B#T;&=#k3YtCR7KIv+^)j zs|p2|Du~0saq?9-cj*?-@NVJe_?vQSMIc1m%Cl}S>1N~aRy-WMSxME$zIOZQtnfT;m~9{)4(#x$v=9H~k`6m3uxd+g*Xc>MSgXf3aD zgtvS59+o9eV5Ck(KvdC45;*3)Ws;CSpDQ1Aje&o>US{wjqcUoeKoy*VesP(XrQ}@+ zm*`U%eXli}V3q4|(IuC_SbZECown$2DGi{+WMyDPI0<|9?Sz~WvoHBtV-;4G7GZf| z4%XII@i~9!*rw)X+&U040Gc^ox~LN4$g_&8g6-R?;4~VrJiiK7aSGb)RXBa>6u9JN z5_s&@(&wZj5dki=8;kIT&wQ43?XP&nFJiQck#4UqG~jZX6otYD)-=;@LfjoFjuXCI zFeD<^^JICH#Cljp;PhUsvI!iRJ+sWpk#eQV=2OLTfgL_6>B?pGvJ#VX4n~y^gK(mx zo|RN_oxULg5M;nOWj&n>86q)ec)hyefC zLyy4mQ|B0%NkT`RUB;)zV0z00?A^BqX0~rJ~Ze$4p`MPzh|fGaOWVGK|J6IwSvp>9b2XlluTBJv^#5X_S`JY z%`HN!)#izbcyUowqhh`D4~^|j1$CxzAyXMlTZiUi6DNcaiZv5famnwyqWqo{etg>zT~v!3d|6y50(rnYDgAE@prm79}I~7flEdH zpd_~9H&j6yO06PK0<`+U)krKS4LWvy=z-&y+=-q*yO1kzL|(*5n^a0dFu-017_daJ zl^QO@F?VdD6&m@&P&X!n%Qi?k(uKZ81z9DfGvf05bLUQj(+Ob9%na1W$7Q`m#6>{V zPAy6j*)nM_rNLSV%ZT5jB<9cZc;{Px&&4nEANj;*{+WKpW%@^gC_LD0wl3>9y<1zY zW__*EJmB|xGoI&`BHuUrsvrW=s4SFbU2!P;4MNI0YmnN=3XIK0%SsZM0nuSlUzb!9 zGBZKss>J88ie>}#i;^(P?G~}=CM!@SL0cc7%U`RN2S`*95>8GD^Kl57v!=vc5BI0l z^`A#GZsMGUk(=9t6Gu?;@^15fShlDZr1I`q z6Kk*YO{;9PkZq2Q|Jyie(dve1k^!C~Vck#@ovBFf=5vJUvR0kEVFVzF=>FJYx*pf9ptQU-EzYOCL*)R(?H7DXS89N%vF-CaGW zy}R9N@ALiOGK@@aaC+`k;Q3Q*%&&*s+iHL?KKiNnDFQECev#^Yl+;okkg`(R#hYE7SCm?1;4z-O7gk^vRqFmk3?c_X& z{f0zipQcpO6_hICA<(CuwtdeBx7T5FGA@wfW)6M+jBTuqT>uMjTL z_8CkKnHZf{DplT2s<_SL`=32|93FV|2rREQVG_?RU8me;G4|p3k;8EE^dfBEwT~&7 zcB9Fw00MB5YSIKv=Qz2-WYG|dXf&gT;Qp2Ko+uar_|N3{m6tt_F8iK*5amLeC-}-; z_n5uFLrjbIbUV)TIvwx-SX^Bw1->)kIBwbZeIp1Xjhk4oD$NiDM!KGWr_!P8nm!nL ztDzkBhX!kYvq`FyR!M?R+^+1uVRmclXOJ>!U3ibu_Mm^`;xCg6? zv#>O`1ls=XP_!(e$O(ms6ZIpd~#lSJf7qo zPZqGme;+w<5*~W!F_>RkWf>vW2XJL4j)nQNaR0pz<4VJT^4J77Jzkqz1Pqd(Rv%-H zJ`IZgW7S1T0yu*AqN(eD{(>8?JM!dLr5GasAB|+(@|K>-Qb(ZMbKpXl*|i6}z-I?Ta`g-SC@G%lQ(s5+F-fVW-DJbs#+3wDOR`ke=ZQ%8uX{{Q5H%zw z=?DR{4IxI%b$e14;fOYIT+6A8oCW0)v}o{rGl1)m1PQcHfu#PZClht2qK76ee7SoP zPH5zhux%8blad8<6gRyyBZRATu zjJh2dt4wh|CiFd4tI+)*at0Ce^&pSAX_!q*H{Xf-_mOosz$Rk^;G;1xZo1|gx@s5Y zLyddrpRa%Sf%nid-1GcutJ^x{yZ+BDt+uXecbZ#pseCDdL)Go~iwG>5P)aC(+{BN3 zGfG8)c=G!+*~QX7kzq<+zES0;NMeca6PaR0)iUt2d~OiD)9(k9rP6c>ls#}}vt`sx z>JU<5^d%o`DP;vmoT9wkKtRgj(%L2qQ;JAqp@g9_{w{*$TB9urcknpmZK`n0<8r?U z<5i(aCr=KN_r*dkL8OMvMFdbT=Ah0R?J-_0&^D1PAw|2SBd3U+s2yQI6>$lD7^(_M z0&btX1a-Nj+ZA*}a}63ThxO3;${I$YK1|Haz|K9pAUbsxaD@gvsu&G~O>D&7?j!bP zBV;SrYEUSaL>e5A4|%S4dw#NQT+~F3n(^EjX%qf6dBP} zrf%~pC&FWH;_;u{G6fYXwwjuP<3}IGNlnl;LEnM<9(;f&PXuJ<-D#qrZRWx+O*DY! z39_$r>qg%+jQjJp`6e@Geeu@ouRgE-Ta6KbkH%9T&%OEzMUJlF<8FF<;jViM?RKl+ zcDomMy6!gD>s^5Wdl4@8ukL!CUB2gO6!|?BMT!FYb&SxTOc4Dhi*{a6 zU8R_NJcbOGLODW-K8ogphzg1vc4Bncdt7XVNtBLmA*i-t>(mwmzZxtov=AKYu>as8 z@Nu74SDH}p`%HxhHAx^XiGg|KE!%e?=vDbxkWB$av*d5iQ9g0y4C=UQz!e=G)4W~6 zsMbn+>1l}LEc(1EE`OkPn;^hNvNMc$vLcF#474U@ws9_Ea%!Br9+AT#Z7az*yIz;o zK16-x@xAlpE-2lzG?ByQ36C0kh>J&uLZJjYPCWU%0o!J_Fvm?R4w`%#O$SfA$(Kux zD_KoJJ4uBJ+X{rDfwrz2r)@L$dBf7*d)rOdJ^a01$0GnAji)tU_?&AMm;QD7`NY}P zkL}$t9)IDR2<)xaLA)uSv%IqU?2gmf6ZV6HUf|Uc-1A|i%D~sFQr3gFipVohNrj+- z@8Ke$>{wUEuPQfbB+^9nPFZPT3f^fGXJA>G5WR>br~qvQEVg_lHHIft85C*jqGLRP zB=^Fk=Die|b3{&Ee_XlS8ubhomaZ8JiRl92JjI(vhFTqbf1E5qYiOX)uP`uJmJTH{ z*(%#mt5gtRY&MnRYSGMb;na>IrswxCax8O?O4kiw+VzFAp!EDU z4dRGxn4H)GPCG#G>%i9CJHg7^aO%`)oMe2qie>dlm|EVFjPasNE`zNI{FDs!*m}SR zF9<|SI>}2@$9rz7O|nOtAPp@!!P#EbsVSJsU5B!$O1cO}!aOU$NVk|T!R}ovmyjVG)|`7J_-qa>7U^wqtp%B_?sCej)nBwi0@Yboda8a-&nH zPvK`2c=9RWais~Ci#AP@+xoGu4#^nFb|E&Nz-#C?U;O;*zq;Xt`xM3qz(?Z;ItaY! z>kDsSV1MAq?COI@pE&l{PPbj}cAbOWR%fr*_n(DP?oCe5*@Mwu38OWQ-8v#!15V*j zq;NIEZ>Po6@?RsltCRA1MZXMHU}nL67|at8&x|A)7RLjB%jG1znm3Z3k01ey!8@R? z>%K@V#@v8hC}tvIryBayc*6(4h#4epLC+JIu}!OGg3TrD>hmP$u4{?vB^EmYN-NEc zH9V3wcA?R6U}>o#90JJ)yI?`3SmGuQg%U>6__;cDau3N>Deh#k84$q z(AkR|CmvcQP$zmi-YkK`iGdnpEUh*Wh+>%Dx(!TR4YV){cf3ICE~xqe(KhVh1-rQ% zKjx^{Wf>-S7NUYCwY`JqZveE2k* zVRYZaC=c2(END%R>ZwUFDO_sxGAmokMXDSX9yz=n8RnJU6>Z4-t&*0cDvMSwfFQ~Z=WAxgdWB>b-Ue*{lKYvZeOQ}E@W>XLjr4@ z_)kD0i_?WhI*O%xVyZFRG|@TqyD%~4%Di5MbvnnO%-xV@H}lm5Y<-NnL!X@q36w}? zW`aLk0J>upCCrr{ylkEeDqjq{O zS0|FhZepwo+E`V#3==B{gnfL^GVj;oN&{Bg4gy&KNoS!1ycK7mfnrZYVGe1!ql^-X z4+x+C^zg+T0gI*=7WeqHEgcq?mT~(6C>9MkaqI}3c>FlDyDe_sL0;o$PMm_nk30&? z_`2uZ^jw&ls0h0V#oA@)ERB=Kz&iDup5rFOlzSlVoGNHZ-9p^>_?X2VfF>vEtRCt) zO+2<`j@%n79b6UVId74(tPkm0{H1jl=Jzm006rSe__*zcYuQQ}gdUw9|47e&>Wg2i zyN@+X#g~7Ff-`gI9K_T$_c_G7+fUD>XTJvT-k2XtH${TZI?tBx^7Ext^ z%jUjF`cYSUYJo-OSHvH8yWk>t6PWa=imxy1eMOKc07nDCgNI=bH#sLjOlg>7VKdvu z*BVShP3H7&jr97fUt+ zbRlQ)!(yW?GXYzcZ?d)SIBz@g=EQB20hgvQnHp1IgilFBVua?-Ey3)<8XmJj=fPy9 zr21W|c_&Iez17|%GQqz1eRAq(E|5GL9bdbc<0{eBwHDNBW3YJcIGjCs2D;>5jw>bB zol_p7PyH@u;d7t-7@RtB9A5dVSHoB>&t7DpX@i%KCYqkm-|7MG8%~q^KYag zqx-+|?fV~Sw_3Irhx^*C&TiLrrvuNssvk$s>vp;aT(_5}2w1hC4W$k^9s=f#O8OJs zcvgQt{C##H_z?}za|8Ceh!7-PTow~g?8~Y{)5s+SLR`#47QD1kW+eQ)Ba_>7QO9$3 zY>|B~l^v>3Z>VTofIH)%io!QJUc!SM(psp@Zpb6Rs|EtDboUH?IUhM@UNG_|_dW#V z<&_po;mGfsIuaFfc?9($CrGV=GG~gJL!TTQgLo{$i7em*F1;x?DIiHW9)r`T=V4{F z1->sfE|S|TqVDxH5Myzs>wP*g(QErD!;+cE`us;^@tb4o+)ks#Ka(fa+Tya{+9RR< zNJ?pGGNUHZoj%<2&AZ^xrB}htx7>gLENd5(k2?q&g>nn@_Y`d#MxH4zk}jGQf$J8_ zLJ`^7v9MOH;e^p3zm*_oTYsf%xvPrhoNwyZ?-`nQ?6wzNcivL@^NkUJkH(MvxaGPl z>HZGj$NI*#-6t#;!A-|g-Rg8mNI59?7BavOCvwG!FB z^$D?XV_=V^43EE$Ww&))##4m7v^WoLrwQG)Rp_?c5cnQT#VGo+th`VgNU-HRYZyun zIp$L9faG=*v62$RvU;XzoXq8d@ASba<1-hQQhm1AdQdGQ3}q1$4|dm~ zp_F$9q`%XW??Vuk`wGEj<*dG*lA%bQl9;3e?^2TrScADGlW{elH}PRZtz3puKFYN|FUs%A%Gh zQT$uC>$1zB>$JHFvdo<b5E@+ZXQG^( z>ju+EJxpl`e$6^+C5F|xd1x#yaR(n#9MDZoO(Cd*kDV_tH_nl+jKFo-0N7wU!Q|o{ zQ`Z78v;~BmN4wqb#|+E(cD|55niIu>d+-hR;bⅈCv%)^HmrHRs`vt<+TULX3uCt zztJRrtcI?O<1j455l)SnWFn~b0O`DS2%V6%(`yZfi(xGCK&NP&TDF;n)VcTB%t#E5gRpiGx_kCA@jR}49QOOR||NNSRpHS0EjJlCaiiO)T8V?mL`Vsf= z7yg>z?Tx!TaCc3Sx&M&4BhEs$M`2*gRDVdcz@ZQ3m> zmp^J4`igFuhfTx$ysqi97@^PVmgau??>{^YPY=n*KmFbxzLmCuOvx|8sPrZH^$}Q}W(I)zb!PX*fWa&UQ8e6rh%J)krW0RJ(Xr!; z(tiyS{lAh0iZ|4g_vgmM_#RIjJ`UlFZxP!#RE5j$4Wf{Xerl{WAPzz(mu$!(c-Jab zJ{FYN#h3K$V&Q(A*gyEPTW|V-gEx&4fRDyc^?2#?uW#a)2flpwqYpZhO}p(hw>gf# zyVGnO==bAmgTDWK*K=ktLN8F1rn3}GKu07CXpAU;<;8ip?;GENwdGZexGEfRlSL~f zuMFA7PBRTQd$P>jcTLOqre)>6g^}s3W#kTPanz1Y=zRJgKDl=tpB~6d_A~NjLV*Of#wwv(#%QXq+u~c*ns0)Xt2d>oA(>OjG1FNdWqzYA6 zzZD?L-zUXW>tlXV9g9naSdb%{K0JwR@HadJO`*Ce(c6TQhG8mPliQLppj4|Qin^(? zle!L38NX-`M`lzxHu`Ge3L#&%A)m9LOVSqNwppI3$vg$MPfM*9XmV<{)`SCNvdJnR z0fIx9+I^Zhn$1;MnO}iYDGxig?||CW1l0oIr4#q^R_^b0!+bYh7pFJb@uxjT06rQ& z6XVwFFIGJI6Y}w8e7ye)cix@rbp7pa&$+#^wtT4F>F#y||LQpG*E(+7cG^u?JiCxv zURu+~$M(YHx~%pfo5EQ7*o+Gq|}se`yiVf z4}Hggt|DNG?%a_k7ND=v1RSyJQs5_jVl*xNURBJdm5bu9xIsjeP8Ztn43ka1f{3yc zPMj&8fT>V@5AMC^K5+4KcWm9xKDVU0 zsn_dY2;%6qrluc%@e6Kw$`khYGe!VD8b8zHg*RPK@|6?#`18;I%blhZ1m#wf z&9c!$f)Ej?>Hr!HCpnc7lK^8PNFqh(C<&gXREH9V4Q==>AbpVrnSR{r6B3e$dWI%_ zG^tIROL}|KllQ=LJWeiCG)@sR`-uk;l(~U|&A*#KfNeCI+^&qIpt@m5hsa2(enc`; zH!aw5~H3(3gEm+ffX4jg^(LHNu+eG=vt7rFHR+{qJg=IjZ$;N-#ZIXB$! z@Wj@w3%9@MhYQ{`MgTqKA~GjK4^sj|K**3@}68y z8$%Si6@l^BFhslsm-b^o>hefSZC8`@=wPJI=%tGuKAP+gnH{<$g0nr7lxI4-9Y#W3 z0-$X$0mx5?Gb*5@ASX`f4SH~kwn%!JWAsafIX%zkhRLqmV;-6k#kwYF4=Q&jfG^}L zFg{*~*|`O%l*eFZ$1do0x?uV)1Uac(2}MF%G46%pIPBfOTQ)WpQ8yLp1hQ#GWAz*y ze)Mtp`j@^8tzIAUwQ;b>q0q9)aLhaW*s=e9?9rod{KB1|3pd>1AMh9f_-KsAPwDut zZ+rux(keba{`-IQwol{>`7OF`T!C@tix8|1U}SkQLi04jPjR?vWtcul}RE8RlxzK3E7)L_-eh#skuZSb#Tx=Y{MeLcrZ=HsS2-IWFfXtJ-3z z2mw{ZMg;N>SD8*uPC&EUg|_3s_?E4(1OIM$X%WG;CwqQE#_Pm{JqIp@ox7%3OHS`Y zv}lf$e=UIcE4XE|xU)+Z zz?rBYoqql6{#_q_gkuEYqcIvkW8;ti^P31{$kY8h^6_`Cx#M?@YNb45n&y?dZNE^r z^a~K=FV;1E41v9b4}%?5h|VBMpeCS0(cMW}R2w9+lXiQWDnQbtFI8G)DPEZU8^M>8 zp9m4_Q6Tzc2A&6gy8#d0eHR>k;uM5>0dg35lW7&nBR$s_LdDSIG9l>3&}y`xK2d{x zd-fm*pJP6JdTJ}o%xve%OiDnK7kjl{h4E^Y{a|YJp12cEy9cXFEoe5nq!NF;R^R$r zjJE&M(4s}$tJU9l#mjyqxsE3rBLE+b(HISQ|9g1Z&qYzMe(fEf&*gH}d_H%9VVT=7 zm_CFN^GmE;;i9~qo6t>+kxdC58YD^-F*1clR8dK?zTs4!0&#t#I9^2Hh9SGZp#>Al4YfgB(;AUJP3UE#-4DMQ}g$utNV zS&Z+3$Qnav?msVw`G`Ye@oSz?bQ`Hb9RoL443Wv&79jL*AWXL)UPap%J9*{fP>%`Hx=wcje_rwh4!DJa@{&dzgk)6zO%;-pZkm0)6g zj5+jnw*!9AXI|aF2)7{)p4p$)pHh0 z@r2ikRHEn!sSa5JC7atMIU;o>A`Y6m^sLOU!R+E{!}sEUo*JM2_}EyztJ~J$a;4Cx z26AzHuoVTN-)yYiH8(%MYhhtwys@^n&D4xrqaZlgTwL1O&6!%{PqIfkX>!PIu~3=h z1`TAyW#)2Fv<$YeU0rU?wi>-p0N5WjbN0i(^{SUQe`E*w`xqktAC1u%jqhoE{KE|9 zP5F3?p8MtBeD&MoFs|8l?poWjZ>$uGR~5?neT714mzlSUxqMNl1_-JEDy2}Qi4{iO z6seosC_{_8?T){+)Hvz&;GT)`$@f*NwMTNfeA$F}>6Nc|arnXi_fy)de)VPX-+uU0 z=OCxwS)QIQZk?W9@*wU-S{&e~d?t+JywmPn+v{}?%$+`4TspU~t-Z3mFJCB3mTQ%2 zK4<3gxndmTjE<-2-w6HipL<^TKY#Cz*EF7?L;F3C5rB`zXpF|w9)I`24-h0%V)TE# z=C@z_#nqKY4c3fpIeX=je8Ilh%;#TX7{-Beu~;uvN??~uOqB@m7B;x_6LH<`X7{+~ z1%DBO^|ktB{fRBrQshJE{KhM8@2JoF&0iG>#85dS>P&Xx;Y<5=0gC+ zcQh;aQN;4oZ+XLOp0>*4H-G70(R(QV+Lzn((9?b2AD1x#@X;8J(fFZ{KYiC95~x#A z(P!WMwmt(oi84@tU~u4Z~E<@s3`cE93uc9jnNp5AL)48 zo8Lf^#Z~!u@EP8mpXf0H@X;8J(HM=N(DDBRL-RuiEy3Q400000NkvXXu0mjfREf44 literal 0 HcmV?d00001 diff --git a/BrickController2/BrickController2/UI/Images/technic_move_small.png b/BrickController2/BrickController2/UI/Images/technic_move_small.png new file mode 100644 index 0000000000000000000000000000000000000000..e0f926c6982bd34a5e1d696006b6f572222995cb GIT binary patch literal 16689 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 M07*qoM6N<$f_&j7%>V!Z literal 0 HcmV?d00001 From 073081160a4efcbf802f594ae6e3c2ff42440cf1 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Sun, 27 Oct 2024 22:27:55 +0100 Subject: [PATCH 02/14] Support LED channels - test screen --- .../DeviceManagement/ControlPlusDevice.cs | 32 +++++++++++++------ .../DeviceManagement/TechnicMoveDevice.cs | 28 +++++++++++++++- .../UI/Controls/DeviceChannelLabel.cs | 12 +++++-- README.md | 1 + 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index bfe52736..bfe2bf41 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -170,9 +170,28 @@ protected override async Task ValidateServicesAsync(IEnumerable WriteNoResponseAsync(byte[] data, CancellationToken token = default) + => _bleDevice!.WriteNoResponseAsync(_characteristic!, data, token); + + protected Task WriteAsync(byte[] data, CancellationToken token = default) + => _bleDevice!.WriteAsync(_characteristic!, data, token); + protected virtual byte GetPortId(int channelIndex) => (byte)channelIndex; protected virtual int GetChannelIndex(byte portId) => portId; + protected virtual byte GetChannelValue(int value) + // calculate raw motor value + => (byte)(value < 0 ? (255 + value) : value); + + protected virtual async Task SendOutputValueAsync(int channel, int value, CancellationToken token = default) + { + // send base motor value (-100 .. 100 %) + _sendBuffer[3] = GetPortId(channel); + _sendBuffer[7] = GetChannelValue(value); + + return await WriteNoResponseAsync(_sendBuffer, token); + } + protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] data) { if (characteristicGuid != CHARACTERISTIC_UUID || data.Length < 4) @@ -401,19 +420,12 @@ private async Task SendOutputValueAsync(int channel, CancellationToken tok if (v != _lastOutputValues[channel] || sendAttemptsLeft > 0) { - _sendBuffer[3] = GetPortId(channel); - _sendBuffer[7] = (byte)(v < 0 ? (255 + v) : v); - - if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _sendBuffer, token)) - { - _lastOutputValues[channel] = v; - await Task.Delay(SEND_DELAY, token); - return true; - } - else + if (!await SendOutputValueAsync(channel, v, token)) { return false; } + _lastOutputValues[channel] = v; + await Task.Delay(SEND_DELAY, token); } return true; diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 64f5c351..56572059 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -1,5 +1,7 @@ using BrickController2.PlatformServices.BluetoothLE; using System; +using System.Threading; +using System.Threading.Tasks; namespace BrickController2.DeviceManagement { @@ -8,6 +10,7 @@ internal class TechnicMoveDevice : ControlPlusDevice private const byte PORT_DRIVE_MOTOR_1 = 0x32; private const byte PORT_DRIVE_MOTOR_2 = 0x33; private const byte PORT_STEERING_MOTOR = 0x34; + private const byte PORT_6LEDS = 0x35; public TechnicMoveDevice(string name, string address, byte[] deviceData, IDeviceRepository deviceRepository, IBluetoothLEService bleService) : base(name, address, deviceRepository, bleService) @@ -15,7 +18,7 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice } public override DeviceType DeviceType => DeviceType.TechnicMove; - public override int NumberOfChannels => 3; + public override int NumberOfChannels => 9; public override bool CanAutoCalibrateOutput(int channel) => channel == 2; public override bool CanResetOutput(int channel) => channel == 2; @@ -25,6 +28,7 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice 0 => PORT_DRIVE_MOTOR_1, 1 => PORT_DRIVE_MOTOR_2, 2 => PORT_STEERING_MOTOR, + 3 or 4 or 5 or 6 or 7 or 8 => PORT_6LEDS, _ => throw new ArgumentException($"Value of channel '{channelIndex}' is out of supported range.", nameof(channelIndex)) }; @@ -33,7 +37,29 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice PORT_DRIVE_MOTOR_1 => 0, PORT_DRIVE_MOTOR_2 => 1, PORT_STEERING_MOTOR => 2, + // PORT_6LEDS is not supported _ => throw new ArgumentException($"Value of port ID '{portId}' is out of supported ranges.", nameof(portId)) }; + + protected override byte GetChannelValue(int value) + { + // TODO fix max 100% + return base.GetChannelValue(value); + } + + protected override Task SendOutputValueAsync(int channel, int value, CancellationToken token = default) + { + // 6LED + if (channel > 2) + { + var rawValue = (byte)Math.Abs(value); + var ledMask = 1 << (channel - 3); + var cmd = new byte[] { 9, 0x00, 0x81, PORT_6LEDS, 0x11, 0x51, 0x00, (byte)ledMask, rawValue }; + + return WriteNoResponseAsync(cmd, token); + } + + return base.SendOutputValueAsync(channel, value, token); + } } } diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs index 138f543c..ab08c36d 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs @@ -8,6 +8,7 @@ namespace BrickController2.UI.Controls public class DeviceChannelLabel : Label { private readonly static string[] _controlPlusChannelLetters = new[] { "A", "B", "C", "D" }; + private readonly static string[] _technicMove = ["A", "B", "C", "1", "2", "3", "4", "5", "6"]; private readonly static string[] _circuitCubesChannelLetters = new[] { "A", "B", "C" }; private readonly static string[] _buwizz3ChannelLetters = new[] { "1", "2", "3", "4", "A", "B" }; @@ -51,16 +52,18 @@ private void SetChannelText() case DeviceType.PoweredUp: case DeviceType.TechnicHub: case DeviceType.WeDo2: + SetChannelText(_controlPlusChannelLetters); + break; case DeviceType.TechnicMove: - Text = _controlPlusChannelLetters[Math.Min(Math.Max(Channel, 0), 3)]; + SetChannelText(_technicMove); break; case DeviceType.CircuitCubes: - Text = _circuitCubesChannelLetters[Math.Min(Math.Max(Channel, 0), 2)]; + SetChannelText(_circuitCubesChannelLetters); break; case DeviceType.BuWizz3: - Text = _buwizz3ChannelLetters[Math.Min(Math.Max(Channel, 0), 6)]; + SetChannelText(_buwizz3ChannelLetters); break; case DeviceType.Infrared: @@ -74,5 +77,8 @@ private void SetChannelText() break; } } + + private void SetChannelText(string[] labels) + => Text = labels[Math.Min(Math.Max(Channel, 0), labels.Length - 1)]; } } diff --git a/README.md b/README.md index b4dd8e0c..bc5c1051 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Cross platform mobile application for controlling your creations using a bluetoo - Lego Boost Hub - Lego Technic Hub - Lego WeDo 2.0 Smart Hub +- Lego Technic Move Hub - Circuit Cubes ## Project details From ee30e34fb6b929f2f8452cf58fe32af2004fc5ab Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Wed, 30 Oct 2024 22:00:57 +0100 Subject: [PATCH 03/14] Add light channels 1-6 and restrict channel setup --- .../DeviceManagement/TechnicMoveDevice.cs | 1 + .../UI/Controls/DeviceChannelSelector.xaml | 12 +++++++++--- .../UI/Controls/DeviceChannelSelector.xaml.cs | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 56572059..9dc026a6 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -22,6 +22,7 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice public override bool CanAutoCalibrateOutput(int channel) => channel == 2; public override bool CanResetOutput(int channel) => channel == 2; + public override bool CanChangeOutputType(int channel) => channel == 2; protected override byte GetPortId(int channelIndex) => channelIndex switch { diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml index c26ade24..fc0ec3fb 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml @@ -227,9 +227,15 @@ - - - + + + + + + + + + diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs index bded0d22..769cb5c4 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs @@ -47,6 +47,12 @@ public DeviceChannelSelector() TechnicMoveChannelA.Command = new SafeCommand(() => SelectedChannel = 0); TechnicMoveChannelB.Command = new SafeCommand(() => SelectedChannel = 1); TechnicMoveChannelC.Command = new SafeCommand(() => SelectedChannel = 2); + TechnicMoveChannel1.Command = new SafeCommand(() => SelectedChannel = 3); + TechnicMoveChannel2.Command = new SafeCommand(() => SelectedChannel = 4); + TechnicMoveChannel3.Command = new SafeCommand(() => SelectedChannel = 5); + TechnicMoveChannel4.Command = new SafeCommand(() => SelectedChannel = 6); + TechnicMoveChannel5.Command = new SafeCommand(() => SelectedChannel = 7); + TechnicMoveChannel6.Command = new SafeCommand(() => SelectedChannel = 8); } public static BindableProperty DeviceTypeProperty = BindableProperty.Create(nameof(DeviceType), typeof(DeviceType), typeof(DeviceChannelSelector), default(DeviceType), BindingMode.OneWay, null, OnDeviceTypeChanged); @@ -123,6 +129,12 @@ private static void OnSelectedChannelChanged(BindableObject bindable, object old dcs.TechnicMoveChannelA.SelectedChannel = selectedChannel; dcs.TechnicMoveChannelB.SelectedChannel = selectedChannel; dcs.TechnicMoveChannelC.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannel1.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannel2.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannel3.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannel4.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannel5.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannel6.SelectedChannel = selectedChannel; } } } From ee7cc38a668db30b413d02058402a6741afee637 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Fri, 1 Nov 2024 23:48:39 +0100 Subject: [PATCH 04/14] LWP protocol, conversions PLAYVM try to setup servo --- .../DeviceManagement/ControlPlusDevice.cs | 81 ++++++++++++------- .../DeviceManagement/TechnicMoveDevice.cs | 66 +++++++++++---- .../Protocols/LegoWirelessProtocol.cs | 61 ++++++++++++++ 3 files changed, 164 insertions(+), 44 deletions(-) create mode 100644 BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index bfe2bf41..b768b0c0 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -6,6 +6,8 @@ using System.Threading; using System.Threading.Tasks; +using static BrickController2.Protocols.LegoWirelessProtocol; + namespace BrickController2.DeviceManagement { internal abstract class ControlPlusDevice : BluetoothDevice @@ -183,13 +185,25 @@ protected virtual byte GetChannelValue(int value) // calculate raw motor value => (byte)(value < 0 ? (255 + value) : value); - protected virtual async Task SendOutputValueAsync(int channel, int value, CancellationToken token = default) + protected virtual byte[] GetOutputCommand(int channel, int value) { // send base motor value (-100 .. 100 %) _sendBuffer[3] = GetPortId(channel); _sendBuffer[7] = GetChannelValue(value); - return await WriteNoResponseAsync(_sendBuffer, token); + return _sendBuffer; + } + + protected virtual byte[] GetServoCommand(int channel, int servoValue, int servoSpeed) + { + _servoSendBuffer[3] = GetPortId(channel); + _servoSendBuffer[6] = (byte)(servoValue & 0xff); + _servoSendBuffer[7] = (byte)((servoValue >> 8) & 0xff); + _servoSendBuffer[8] = (byte)((servoValue >> 16) & 0xff); + _servoSendBuffer[9] = (byte)((servoValue >> 24) & 0xff); + _servoSendBuffer[10] = (byte)servoSpeed; + + return _servoSendBuffer; } protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] data) @@ -240,7 +254,21 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] break; case 0x45: // Port value (single mode) - DumpData("Port value (single)", data); + lock (_positionLock) + { + if (data.Length == 6) + { + var channel = GetChannelIndex(data[3]); + var absPosition = ToInt16(data, 4); + _absolutePositions[channel] = absPosition; + } + else if (data.Length == 8) + { + var channel = GetChannelIndex(data[3]); + var relPosition = ToInt32(data, 4); + _relativePositions[channel] = relPosition; + } + } break; case 0x46: // Port value (combined mode) @@ -304,14 +332,15 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] break; case 0x82: // Port output command feedback + DumpData("Output command feedback", data); break; } } private void DumpData(string header, byte[] data) { - //var s = BitConverter.ToString(data); - //Console.WriteLine(header + " - " + s); + var s = BitConverter.ToString(data); + Console.WriteLine(header + " - " + s); } protected override async Task ProcessOutputsAsync(CancellationToken token) @@ -358,9 +387,7 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf { if (_channelOutputTypes[channel] == ChannelOutputType.ServoMotor) { - await SetupChannelForPortInformationAsync(channel, token); - await Task.Delay(300, token); - await ResetServoAsync(channel, _servoBaseAngles[channel], token); + await SetupServoAsync(channel, _servoBaseAngles[channel], token); } } @@ -372,6 +399,13 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf } } + protected virtual async Task SetupServoAsync(int channel, int baseAngle, CancellationToken token = default) + { + await SetupChannelForPortInformationAsync(channel, token); + await Task.Delay(300, token); + await ResetServoAsync(channel, baseAngle, token); + } + private async Task SendOutputValuesAsync(CancellationToken token) { try @@ -420,7 +454,8 @@ private async Task SendOutputValueAsync(int channel, CancellationToken tok if (v != _lastOutputValues[channel] || sendAttemptsLeft > 0) { - if (!await SendOutputValueAsync(channel, v, token)) + var outputCmd = GetOutputCommand(channel, v); + if (!await WriteNoResponseAsync(outputCmd, token)) { return false; } @@ -491,23 +526,13 @@ private async Task SendServoOutputValueAsync(int channel, CancellationToke return true; } - _servoSendBuffer[3] = GetPortId(channel); - _servoSendBuffer[6] = (byte)(servoValue & 0xff); - _servoSendBuffer[7] = (byte)((servoValue >> 8) & 0xff); - _servoSendBuffer[8] = (byte)((servoValue >> 16) & 0xff); - _servoSendBuffer[9] = (byte)((servoValue >> 24) & 0xff); - _servoSendBuffer[10] = (byte)servoSpeed; - - if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _servoSendBuffer, token)) - { - _lastOutputValues[channel] = v; - await Task.Delay(SEND_DELAY, token); - return true; - } - else + var servoCmd = GetServoCommand(channel, servoValue, servoSpeed); + if (!await WriteNoResponseAsync(servoCmd, token)) { return false; } + _lastOutputValues[channel] = v; + await Task.Delay(SEND_DELAY, token); } return true; @@ -541,16 +566,12 @@ private async Task SendStepperOutputValueAsync(int channel, CancellationTo if (v != _lastOutputValues[channel] && Math.Abs(v) == 100) { - if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _stepperSendBuffer, token)) - { - _lastOutputValues[channel] = v; - await Task.Delay(SEND_DELAY, token); - return true; - } - else + if (!await WriteNoResponseAsync(_stepperSendBuffer, token)) { return false; } + _lastOutputValues[channel] = v; + await Task.Delay(SEND_DELAY, token); } else { diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 9dc026a6..391a33f8 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; +using static BrickController2.Protocols.LegoWirelessProtocol; namespace BrickController2.DeviceManagement { @@ -11,6 +12,10 @@ internal class TechnicMoveDevice : ControlPlusDevice private const byte PORT_DRIVE_MOTOR_2 = 0x33; private const byte PORT_STEERING_MOTOR = 0x34; private const byte PORT_6LEDS = 0x35; + private const byte PORT_PLAYVM = 0x36; + + private const byte PLAYVM_CALIBRATE_STEERING = 0x08; + private const byte PLAYVM_COMMAND = 0x10; public TechnicMoveDevice(string name, string address, byte[] deviceData, IDeviceRepository deviceRepository, IBluetoothLEService bleService) : base(name, address, deviceRepository, bleService) @@ -38,29 +43,62 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice PORT_DRIVE_MOTOR_1 => 0, PORT_DRIVE_MOTOR_2 => 1, PORT_STEERING_MOTOR => 2, + // special handling for PLAYVM + PORT_PLAYVM => 2, // PORT_6LEDS is not supported _ => throw new ArgumentException($"Value of port ID '{portId}' is out of supported ranges.", nameof(portId)) }; - protected override byte GetChannelValue(int value) - { - // TODO fix max 100% - return base.GetChannelValue(value); - } + protected override byte GetChannelValue(int value) => ToByte(value); - protected override Task SendOutputValueAsync(int channel, int value, CancellationToken token = default) + protected override byte[] GetOutputCommand(int channel, int value) { // 6LED - if (channel > 2) + var ledIndex = channel - 3; + if (ledIndex >= 0) { - var rawValue = (byte)Math.Abs(value); - var ledMask = 1 << (channel - 3); - var cmd = new byte[] { 9, 0x00, 0x81, PORT_6LEDS, 0x11, 0x51, 0x00, (byte)ledMask, rawValue }; - - return WriteNoResponseAsync(cmd, token); + var rawValue = ToByte(Math.Abs(value)); + var ledMask = ToByte(1 << ledIndex); + return [9, + 0x00, PORT_OUTPUT_COMMAND, PORT_6LEDS, FEEDBACK_ACTION_BOTH, + PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, PORT_MODE_0, ledMask, rawValue]; } - - return base.SendOutputValueAsync(channel, value, token); + + return base.GetOutputCommand(channel, value); + } + + protected override byte[] GetServoCommand(int channel, int servoValue, int servoSpeed) + => BuildPlayVmCmd(servoValue: servoValue); + + protected override async Task SetupServoAsync(int channel, int baseAngle, CancellationToken token = default) + { + // setup channel to report ABS position + var portId = GetPortId(channel); + var inputFormatForAbsAngle = BuildPortInputFormatSetup(portId, PORT_MODE_3); + + await WriteAsync(inputFormatForAbsAngle, token); + await Task.Delay(100, token); + + // reset servo via PLAYVM + // PLAYVM cmd supports only servo on C channel + var servoCmd = BuildPlayVmCmd(servoValue: 0, vmCmd: PLAYVM_COMMAND); + await WriteNoResponseAsync(servoCmd, token); + await Task.Delay(100, token); + + // do calibration + var calibrateCmd = BuildPlayVmCmd(servoValue: 0, vmCmd: PLAYVM_CALIBRATE_STEERING); + await WriteNoResponseAsync(calibrateCmd, token); + await Task.Delay(1500, token); + } + + private static byte[] BuildPlayVmCmd(int speedValue = 0, int servoValue = 0, byte vmCmd = PLAYVM_COMMAND) + { + // PLAYVM cmd supports only servo on C channel + var speedRaw = ToByte(speedValue); + var steeringRaw = ToByte(servoValue); + return [13, + 0x00, PORT_OUTPUT_COMMAND, PORT_PLAYVM, FEEDBACK_ACTION_BOTH, + PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, PORT_MODE_0, 0x03, 0x00, speedRaw, steeringRaw, vmCmd, 0x00]; } } } diff --git a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs new file mode 100644 index 00000000..bd77fe24 --- /dev/null +++ b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs @@ -0,0 +1,61 @@ +using System; +using System.Buffers.Binary; + +namespace BrickController2.Protocols; + +/// +/// Contains implementation of Lego Wireless Protocol +/// Inspired by +/// +internal static class LegoWirelessProtocol +{ + + + // port modes + public const byte PORT_MODE_0 = 0x00; + public const byte PORT_MODE_1 = 0x01; + public const byte PORT_MODE_2 = 0x02; + public const byte PORT_MODE_3 = 0x03; + public const byte PORT_MODE_4 = 0x04; + + // output command + public const byte PORT_OUTPUT_COMMAND = 0x81; + + public const byte PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT = 0x51; + + // input command (single) + public const byte PORT_INPUT_COMMAND = 0x41; + + public const byte PORT_VALUE_NOTIFICATION_DISABLED = 0x00; + public const byte PORT_VALUE_NOTIFICATION_ENABLED = 0x01; + + public const byte FEEDBACK_ACTION_NO_ACTION = 0x00; + public const byte FEEDBACK_ACTION_ACTION_COMPLETION = 0x01; + public const byte FEEDBACK_ACTION_ACTION_START = 0x10; + public const byte FEEDBACK_ACTION_BOTH = 0x11; + + // conversion methods + public static void ToBytes(int value, out byte b0, out byte b1, out byte b2, out byte b3) + { + b0 = (byte)(value & 0xff); + b1 = (byte)((value >> 8) & 0xff); + b2 = (byte)((value >> 16) & 0xff); + b3 = (byte)((value >> 24) & 0xff); + } + + public static byte ToByte(int value) => (byte)(value & 0xFF); + + public static short ToInt16(byte[] value, int startIndex) => ToInt16(value.AsSpan(startIndex)); + public static int ToInt32(byte[] value, int startIndex) => ToInt32(value.AsSpan(startIndex)); + + public static short ToInt16(ReadOnlySpan value) => BinaryPrimitives.ReadInt16LittleEndian(value); + public static int ToInt32(ReadOnlySpan value) => BinaryPrimitives.ReadInt32LittleEndian(value); + + // message builder + public static byte[] BuildPortInputFormatSetup(byte portId, byte portMode, int interval = 2, byte notification = PORT_VALUE_NOTIFICATION_ENABLED) + { + // Message Type - Port Input Format Setup (Single) [0x41] + ToBytes(interval, out var i0, out var i1, out var i2, out var i3); + return [0x0a, 0x00, PORT_INPUT_COMMAND, portId, portMode, i0, i1, i2, i3, notification]; + } +} From 01ef0702d04e7a4174a63bb88f94c4554e9835f7 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Sat, 2 Nov 2024 22:18:45 +0100 Subject: [PATCH 05/14] PLAYVM applies C channel servo --- .../DeviceManagement/ControlPlusDevice.cs | 8 ++- .../DeviceManagement/TechnicMoveDevice.cs | 51 ++++++++----------- .../Protocols/LegoWirelessProtocol.cs | 28 +++++++++- 3 files changed, 53 insertions(+), 34 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index b768b0c0..df7f528f 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -17,7 +17,7 @@ internal abstract class ControlPlusDevice : BluetoothDevice private static readonly Guid SERVICE_UUID = new Guid("00001623-1212-efde-1623-785feabcd123"); private static readonly Guid CHARACTERISTIC_UUID = new Guid("00001624-1212-efde-1623-785feabcd123"); - private static readonly TimeSpan SEND_DELAY = TimeSpan.FromMilliseconds(60); + private static readonly TimeSpan SEND_DELAY = TimeSpan.FromMilliseconds(10); private static readonly TimeSpan POSITION_EXPIRATION = TimeSpan.FromMilliseconds(200); private readonly byte[] _sendBuffer = new byte[] { 8, 0x00, 0x81, 0x00, 0x11, 0x51, 0x00, 0x00 }; @@ -178,6 +178,9 @@ protected Task WriteNoResponseAsync(byte[] data, CancellationToken token = protected Task WriteAsync(byte[] data, CancellationToken token = default) => _bleDevice!.WriteAsync(_characteristic!, data, token); + protected Task SendDelayAsync(CancellationToken token = default) + => Task.Delay(SEND_DELAY, token); + protected virtual byte GetPortId(int channelIndex) => (byte)channelIndex; protected virtual int GetChannelIndex(byte portId) => portId; @@ -267,6 +270,9 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] var channel = GetChannelIndex(data[3]); var relPosition = ToInt32(data, 4); _relativePositions[channel] = relPosition; + + _positionsUpdated[channel] = true; + _positionUpdateTimes[channel] = DateTime.Now; } } break; diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 391a33f8..2c8ac634 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -8,15 +8,6 @@ namespace BrickController2.DeviceManagement { internal class TechnicMoveDevice : ControlPlusDevice { - private const byte PORT_DRIVE_MOTOR_1 = 0x32; - private const byte PORT_DRIVE_MOTOR_2 = 0x33; - private const byte PORT_STEERING_MOTOR = 0x34; - private const byte PORT_6LEDS = 0x35; - private const byte PORT_PLAYVM = 0x36; - - private const byte PLAYVM_CALIBRATE_STEERING = 0x08; - private const byte PLAYVM_COMMAND = 0x10; - public TechnicMoveDevice(string name, string address, byte[] deviceData, IDeviceRepository deviceRepository, IBluetoothLEService bleService) : base(name, address, deviceRepository, bleService) { @@ -43,8 +34,6 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice PORT_DRIVE_MOTOR_1 => 0, PORT_DRIVE_MOTOR_2 => 1, PORT_STEERING_MOTOR => 2, - // special handling for PLAYVM - PORT_PLAYVM => 2, // PORT_6LEDS is not supported _ => throw new ArgumentException($"Value of port ID '{portId}' is out of supported ranges.", nameof(portId)) }; @@ -59,46 +48,46 @@ protected override byte[] GetOutputCommand(int channel, int value) { var rawValue = ToByte(Math.Abs(value)); var ledMask = ToByte(1 << ledIndex); - return [9, - 0x00, PORT_OUTPUT_COMMAND, PORT_6LEDS, FEEDBACK_ACTION_BOTH, - PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, PORT_MODE_0, ledMask, rawValue]; + return BuildPortOutput_LedMask(PORT_6LEDS, PORT_MODE_0, ledMask, rawValue); } - return base.GetOutputCommand(channel, value); } protected override byte[] GetServoCommand(int channel, int servoValue, int servoSpeed) - => BuildPlayVmCmd(servoValue: servoValue); + => BuildPortOutput_PlayVm(servoValue: servoValue); + + protected override async Task AfterConnectSetupAsync(bool requestDeviceInformation, CancellationToken token) + { + try + { + // switch lights off + var lightsOffCmd = BuildPortOutput_LedMask(PORT_6LEDS, PORT_MODE_0, 0xff, 0x00); + await WriteNoResponseAsync(lightsOffCmd, token); + } + catch + { } + + return await base.AfterConnectSetupAsync(requestDeviceInformation, token); + } protected override async Task SetupServoAsync(int channel, int baseAngle, CancellationToken token = default) { // setup channel to report ABS position var portId = GetPortId(channel); var inputFormatForAbsAngle = BuildPortInputFormatSetup(portId, PORT_MODE_3); - await WriteAsync(inputFormatForAbsAngle, token); - await Task.Delay(100, token); + await SendDelayAsync(token); // reset servo via PLAYVM // PLAYVM cmd supports only servo on C channel - var servoCmd = BuildPlayVmCmd(servoValue: 0, vmCmd: PLAYVM_COMMAND); + var servoCmd = BuildPortOutput_PlayVm(servoValue: 0, vmCmd: PLAYVM_COMMAND); await WriteNoResponseAsync(servoCmd, token); - await Task.Delay(100, token); + await SendDelayAsync(token); // do calibration - var calibrateCmd = BuildPlayVmCmd(servoValue: 0, vmCmd: PLAYVM_CALIBRATE_STEERING); + var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); await WriteNoResponseAsync(calibrateCmd, token); await Task.Delay(1500, token); } - - private static byte[] BuildPlayVmCmd(int speedValue = 0, int servoValue = 0, byte vmCmd = PLAYVM_COMMAND) - { - // PLAYVM cmd supports only servo on C channel - var speedRaw = ToByte(speedValue); - var steeringRaw = ToByte(servoValue); - return [13, - 0x00, PORT_OUTPUT_COMMAND, PORT_PLAYVM, FEEDBACK_ACTION_BOTH, - PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, PORT_MODE_0, 0x03, 0x00, speedRaw, steeringRaw, vmCmd, 0x00]; - } } } diff --git a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs index bd77fe24..4a9dcb13 100644 --- a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs +++ b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs @@ -9,7 +9,11 @@ namespace BrickController2.Protocols; /// internal static class LegoWirelessProtocol { - + // TechnicMove hub ports + public const byte PORT_DRIVE_MOTOR_1 = 0x32; + public const byte PORT_DRIVE_MOTOR_2 = 0x33; + public const byte PORT_STEERING_MOTOR = 0x34; + public const byte PORT_6LEDS = 0x35; // port modes public const byte PORT_MODE_0 = 0x00; @@ -23,6 +27,13 @@ internal static class LegoWirelessProtocol public const byte PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT = 0x51; + // - output / playvm command + public const byte PORT_PLAYVM = 0x36; + + public const byte PLAYVM_LIGHTS_OFF_OFF = 0x04; + public const byte PLAYVM_CALIBRATE_STEERING = 0x08; + public const byte PLAYVM_COMMAND = 0x10; + // input command (single) public const byte PORT_INPUT_COMMAND = 0x41; @@ -51,11 +62,24 @@ public static void ToBytes(int value, out byte b0, out byte b1, out byte b2, out public static short ToInt16(ReadOnlySpan value) => BinaryPrimitives.ReadInt16LittleEndian(value); public static int ToInt32(ReadOnlySpan value) => BinaryPrimitives.ReadInt32LittleEndian(value); - // message builder + // message builders public static byte[] BuildPortInputFormatSetup(byte portId, byte portMode, int interval = 2, byte notification = PORT_VALUE_NOTIFICATION_ENABLED) { // Message Type - Port Input Format Setup (Single) [0x41] ToBytes(interval, out var i0, out var i1, out var i2, out var i3); return [0x0a, 0x00, PORT_INPUT_COMMAND, portId, portMode, i0, i1, i2, i3, notification]; } + + public static byte[] BuildPortOutput_LedMask(byte portId, byte portMode, byte ledMask, byte value) + // Message Type - Port Output Command [0x81] | Write Direct + => [9, 0x00, PORT_OUTPUT_COMMAND, portId, FEEDBACK_ACTION_BOTH, + PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, portMode, ledMask, value]; + + public static byte[] BuildPortOutput_PlayVm(int speedValue = 0, int servoValue = 0, byte vmCmd = PLAYVM_LIGHTS_OFF_OFF) + { + var speedRaw = ToByte(speedValue); + var steeringRaw = ToByte(servoValue); + return [13, 0x00, PORT_OUTPUT_COMMAND, PORT_PLAYVM, FEEDBACK_ACTION_BOTH, + PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, PORT_MODE_0, 0x03, 0x00, speedRaw, steeringRaw, vmCmd, 0x00]; + } } From 4f384773c98af01ad2c4a73149993f8a845d18f6 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Tue, 5 Nov 2024 22:06:13 +0100 Subject: [PATCH 06/14] Minor cleanup --- .../DeviceManagement/ControlPlusDevice.cs | 43 +++++++++------ .../DeviceManagement/TechnicMoveDevice.cs | 55 +++++++++++-------- 2 files changed, 57 insertions(+), 41 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index df7f528f..66876d5e 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -393,7 +393,9 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf { if (_channelOutputTypes[channel] == ChannelOutputType.ServoMotor) { - await SetupServoAsync(channel, _servoBaseAngles[channel], token); + await SetupChannelForPortInformationAsync(channel, token); + await Task.Delay(300, token); + await ResetServoAsync(channel, _servoBaseAngles[channel], token); } } @@ -405,13 +407,6 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf } } - protected virtual async Task SetupServoAsync(int channel, int baseAngle, CancellationToken token = default) - { - await SetupChannelForPortInformationAsync(channel, token); - await Task.Delay(300, token); - await ResetServoAsync(channel, baseAngle, token); - } - private async Task SendOutputValuesAsync(CancellationToken token) { try @@ -461,12 +456,16 @@ private async Task SendOutputValueAsync(int channel, CancellationToken tok if (v != _lastOutputValues[channel] || sendAttemptsLeft > 0) { var outputCmd = GetOutputCommand(channel, v); - if (!await WriteNoResponseAsync(outputCmd, token)) + if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, outputCmd, token)) + { + _lastOutputValues[channel] = v; + await Task.Delay(SEND_DELAY, token); + return true; + } + else { return false; } - _lastOutputValues[channel] = v; - await Task.Delay(SEND_DELAY, token); } return true; @@ -533,12 +532,16 @@ private async Task SendServoOutputValueAsync(int channel, CancellationToke } var servoCmd = GetServoCommand(channel, servoValue, servoSpeed); - if (!await WriteNoResponseAsync(servoCmd, token)) + if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, servoCmd, token)) + { + _lastOutputValues[channel] = v; + await Task.Delay(SEND_DELAY, token); + return true; + } + else { return false; } - _lastOutputValues[channel] = v; - await Task.Delay(SEND_DELAY, token); } return true; @@ -572,12 +575,16 @@ private async Task SendStepperOutputValueAsync(int channel, CancellationTo if (v != _lastOutputValues[channel] && Math.Abs(v) == 100) { - if (!await WriteNoResponseAsync(_stepperSendBuffer, token)) + if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _stepperSendBuffer, token)) + { + _lastOutputValues[channel] = v; + await Task.Delay(SEND_DELAY, token); + return true; + } + else { return false; } - _lastOutputValues[channel] = v; - await Task.Delay(SEND_DELAY, token); } else { @@ -791,7 +798,7 @@ private Task ResetAsync(int channel, int angle, CancellationToken token) return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x0b, 0x00, 0x81, portId, 0x11, 0x51, 0x02, a0, a1, a2, a3 }, token); } - private async Task RequestHubPropertiesAsync(CancellationToken token) + protected async Task RequestHubPropertiesAsync(CancellationToken token) { try { diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 2c8ac634..5cd6f0d6 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -60,34 +60,43 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf { try { + // Wait until ports finish communicating with the hub + await Task.Delay(1000, token); + // switch lights off var lightsOffCmd = BuildPortOutput_LedMask(PORT_6LEDS, PORT_MODE_0, 0xff, 0x00); await WriteNoResponseAsync(lightsOffCmd, token); - } - catch - { } + await SendDelayAsync(token); - return await base.AfterConnectSetupAsync(requestDeviceInformation, token); - } + if (requestDeviceInformation) + { + await RequestHubPropertiesAsync(token); + } - protected override async Task SetupServoAsync(int channel, int baseAngle, CancellationToken token = default) - { - // setup channel to report ABS position - var portId = GetPortId(channel); - var inputFormatForAbsAngle = BuildPortInputFormatSetup(portId, PORT_MODE_3); - await WriteAsync(inputFormatForAbsAngle, token); - await SendDelayAsync(token); - - // reset servo via PLAYVM - // PLAYVM cmd supports only servo on C channel - var servoCmd = BuildPortOutput_PlayVm(servoValue: 0, vmCmd: PLAYVM_COMMAND); - await WriteNoResponseAsync(servoCmd, token); - await SendDelayAsync(token); - - // do calibration - var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); - await WriteNoResponseAsync(calibrateCmd, token); - await Task.Delay(1500, token); + // TODO conditionally apply PLAYVM + + // setup channel to report ABS position + var inputFormatForAbsAngle = BuildPortInputFormatSetup(PORT_STEERING_MOTOR, PORT_MODE_3); + await WriteAsync(inputFormatForAbsAngle, token); + await SendDelayAsync(token); + + // reset servo via PLAYVM + // PLAYVM cmd supports only servo on C channel + var servoCmd = BuildPortOutput_PlayVm(servoValue: 0, vmCmd: PLAYVM_COMMAND); + await WriteNoResponseAsync(servoCmd, token); + await SendDelayAsync(token); + + // do calibration + var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); + await WriteNoResponseAsync(calibrateCmd, token); + await Task.Delay(1500, token); + + return true; + } + catch + { + return false; + } } } } From 45441ab9869c968dc56e8c08b064325e0be67aed Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Sun, 10 Nov 2024 21:19:41 +0100 Subject: [PATCH 07/14] try to write speed as "virtual" channel if PLAYVM mode is active --- .../DeviceManagement/ControlPlusDevice.cs | 43 +++++-- .../DeviceManagement/Device.cs | 2 +- .../DeviceManagement/TechnicMoveDevice.cs | 105 ++++++++++++++---- .../UI/Controls/DeviceChannelLabel.cs | 5 +- .../UI/Controls/DeviceChannelSelector.xaml | 1 + .../UI/Controls/DeviceChannelSelector.xaml.cs | 24 ++-- .../UI/Pages/ControllerActionPage.xaml | 2 +- 7 files changed, 141 insertions(+), 41 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index 66876d5e..51f5f18a 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -127,11 +127,7 @@ public override void SetOutput(int channel, float value) lock (_outputLock) { - if (_outputValues[channel] != intValue) - { - _outputValues[channel] = intValue; - _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; - } + SetChannelOutput(channel, intValue); } } @@ -188,6 +184,23 @@ protected virtual byte GetChannelValue(int value) // calculate raw motor value => (byte)(value < 0 ? (255 + value) : value); + protected virtual void SetChannelOutput(int channel, int value) + { + if (_outputValues[channel] != value) + { + _outputValues[channel] = value; + _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; + } + } + /// + /// Reset send attemps counter for given + /// + /// Expected to be called under the lock + protected void ResetSendAttemps(int channel) + { + _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; + } + protected virtual byte[] GetOutputCommand(int channel, int value) { // send base motor value (-100 .. 100 %) @@ -358,11 +371,7 @@ protected override async Task ProcessOutputsAsync(CancellationToken token) { for (int channel = 0; channel < NumberOfChannels; channel++) { - _outputValues[channel] = 0; - _lastOutputValues[channel] = 1; - _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; - _positionsUpdated[channel] = false; - _positionUpdateTimes[channel] = DateTime.MinValue; + InitializeChannelInfo(channel); } } @@ -377,6 +386,20 @@ protected override async Task ProcessOutputsAsync(CancellationToken token) catch { } } + /// + /// Initialize channel data when output processing is going to be started + /// + protected virtual void InitializeChannelInfo(int channel, + int lastOutputValue = 1, + int sendAttempsLeft = MAX_SEND_ATTEMPTS) + { + _outputValues[channel] = 0; + _lastOutputValues[channel] = lastOutputValue; + _sendAttemptsLeft[channel] = sendAttempsLeft; + _positionsUpdated[channel] = false; + _positionUpdateTimes[channel] = DateTime.MinValue; + } + protected override async Task AfterConnectSetupAsync(bool requestDeviceInformation, CancellationToken token) { try diff --git a/BrickController2/BrickController2/DeviceManagement/Device.cs b/BrickController2/BrickController2/DeviceManagement/Device.cs index 066e0295..0d0d959a 100644 --- a/BrickController2/BrickController2/DeviceManagement/Device.cs +++ b/BrickController2/BrickController2/DeviceManagement/Device.cs @@ -116,7 +116,7 @@ public override string ToString() return Name; } - protected void CheckChannel(int channel) + protected virtual void CheckChannel(int channel) { if (channel < 0 || channel >= NumberOfChannels) { diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 5cd6f0d6..ebab6925 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -1,5 +1,7 @@ using BrickController2.PlatformServices.BluetoothLE; using System; +using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using static BrickController2.Protocols.LegoWirelessProtocol; @@ -8,7 +10,14 @@ namespace BrickController2.DeviceManagement { internal class TechnicMoveDevice : ControlPlusDevice { - public TechnicMoveDevice(string name, string address, byte[] deviceData, IDeviceRepository deviceRepository, IBluetoothLEService bleService) + private bool _applyPlayVmMode; + private byte _virtualMotorValue; + + public TechnicMoveDevice(string name, + string address, + byte[] deviceData, + IDeviceRepository deviceRepository, + IBluetoothLEService bleService) : base(name, address, deviceRepository, bleService) { } @@ -16,10 +25,26 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice public override DeviceType DeviceType => DeviceType.TechnicMove; public override int NumberOfChannels => 9; + // This is now mandatory as the hub does not support generic servo / stepper commands (yet) + public bool EnablePlayVmMode => true; + public override bool CanAutoCalibrateOutput(int channel) => channel == 2; public override bool CanResetOutput(int channel) => channel == 2; public override bool CanChangeOutputType(int channel) => channel == 2; + public override Task ConnectAsync(bool reconnect, Action onDeviceDisconnected, IEnumerable channelConfigurations, bool startOutputProcessing, bool requestDeviceInformation, CancellationToken token) + { + // autodetect PLAYVM mode for A / B channels (as testing page should not be affected) + _applyPlayVmMode = startOutputProcessing && + channelConfigurations.Any(c => c.Channel == 12); + + // filter out non standard channels + var filteredConfigurtions = channelConfigurations + .Where(c => c.Channel != 12); + + return base.ConnectAsync(reconnect, onDeviceDisconnected, filteredConfigurtions, startOutputProcessing, requestDeviceInformation, token); + } + protected override byte GetPortId(int channelIndex) => channelIndex switch { 0 => PORT_DRIVE_MOTOR_1, @@ -38,8 +63,42 @@ public TechnicMoveDevice(string name, string address, byte[] deviceData, IDevice _ => throw new ArgumentException($"Value of port ID '{portId}' is out of supported ranges.", nameof(portId)) }; + protected override void CheckChannel(int channel) + { + if (_applyPlayVmMode && channel == 12) + return; + + base.CheckChannel(channel); + } + + protected override void SetChannelOutput(int channel, int intValue) + { + if (_applyPlayVmMode && channel == 12) + { + // reset servo writes + ResetSendAttemps(2); + // store virtual motor value + _virtualMotorValue = GetChannelValue(channel); + } + else + { + base.SetChannelOutput(channel, intValue); + } + } + protected override byte GetChannelValue(int value) => ToByte(value); + protected override void InitializeChannelInfo(int channel, int lastOutputValue = 1, int sendAttempsLeft = 10) + { + // if PLAYVM enabled, reset A / B channels diffrently in order to avoid output write + if (_applyPlayVmMode && (channel == 0 || channel == 1)) + { + lastOutputValue = 0; + sendAttempsLeft = 0; + } + base.InitializeChannelInfo(channel, lastOutputValue, sendAttempsLeft); + } + protected override byte[] GetOutputCommand(int channel, int value) { // 6LED @@ -54,7 +113,13 @@ protected override byte[] GetOutputCommand(int channel, int value) } protected override byte[] GetServoCommand(int channel, int servoValue, int servoSpeed) - => BuildPortOutput_PlayVm(servoValue: servoValue); + { + if (_applyPlayVmMode) + { + return BuildPortOutput_PlayVm(speedValue: _virtualMotorValue, servoValue: servoValue); + } + return base.GetServoCommand(channel, servoValue, servoSpeed); + } protected override async Task AfterConnectSetupAsync(bool requestDeviceInformation, CancellationToken token) { @@ -73,24 +138,24 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf await RequestHubPropertiesAsync(token); } - // TODO conditionally apply PLAYVM - - // setup channel to report ABS position - var inputFormatForAbsAngle = BuildPortInputFormatSetup(PORT_STEERING_MOTOR, PORT_MODE_3); - await WriteAsync(inputFormatForAbsAngle, token); - await SendDelayAsync(token); - - // reset servo via PLAYVM - // PLAYVM cmd supports only servo on C channel - var servoCmd = BuildPortOutput_PlayVm(servoValue: 0, vmCmd: PLAYVM_COMMAND); - await WriteNoResponseAsync(servoCmd, token); - await SendDelayAsync(token); - - // do calibration - var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); - await WriteNoResponseAsync(calibrateCmd, token); - await Task.Delay(1500, token); - + if (_applyPlayVmMode) + { + // setup channel to report ABS position + var inputFormatForAbsAngle = BuildPortInputFormatSetup(PORT_STEERING_MOTOR, PORT_MODE_3); + await WriteAsync(inputFormatForAbsAngle, token); + await SendDelayAsync(token); + + // reset servo via PLAYVM + // PLAYVM cmd supports only servo on C channel + var servoCmd = BuildPortOutput_PlayVm(servoValue: 0, vmCmd: PLAYVM_COMMAND); + await WriteNoResponseAsync(servoCmd, token); + await SendDelayAsync(token); + + // do calibration + var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); + await WriteNoResponseAsync(calibrateCmd, token); + await Task.Delay(1500, token); + } return true; } catch diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs index ab08c36d..f73bfc8b 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs @@ -55,7 +55,10 @@ private void SetChannelText() SetChannelText(_controlPlusChannelLetters); break; case DeviceType.TechnicMove: - SetChannelText(_technicMove); + if (Channel == 12) + Text = "AB"; + else + SetChannelText(_technicMove); break; case DeviceType.CircuitCubes: diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml index fc0ec3fb..8a22550f 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml @@ -229,6 +229,7 @@ + diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs index 769cb5c4..beabe23d 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs @@ -2,6 +2,7 @@ using BrickController2.UI.Commands; using Microsoft.Maui.Controls; using Microsoft.Maui.Controls.Xaml; +using Device = BrickController2.DeviceManagement.Device; namespace BrickController2.UI.Controls { @@ -46,6 +47,7 @@ public DeviceChannelSelector() WedoChannel1.Command = new SafeCommand(() => SelectedChannel = 1); TechnicMoveChannelA.Command = new SafeCommand(() => SelectedChannel = 0); TechnicMoveChannelB.Command = new SafeCommand(() => SelectedChannel = 1); + TechnicMoveChannelAB.Command = new SafeCommand(() => SelectedChannel = 12); TechnicMoveChannelC.Command = new SafeCommand(() => SelectedChannel = 2); TechnicMoveChannel1.Command = new SafeCommand(() => SelectedChannel = 3); TechnicMoveChannel2.Command = new SafeCommand(() => SelectedChannel = 4); @@ -55,13 +57,13 @@ public DeviceChannelSelector() TechnicMoveChannel6.Command = new SafeCommand(() => SelectedChannel = 8); } - public static BindableProperty DeviceTypeProperty = BindableProperty.Create(nameof(DeviceType), typeof(DeviceType), typeof(DeviceChannelSelector), default(DeviceType), BindingMode.OneWay, null, OnDeviceTypeChanged); - public static BindableProperty SelectedChannelProperty = BindableProperty.Create(nameof(SelectedChannel), typeof(int), typeof(DeviceChannelSelector), 0, BindingMode.TwoWay, null, OnSelectedChannelChanged); + public static readonly BindableProperty DeviceProperty = BindableProperty.Create(nameof(Device), typeof(Device), typeof(DeviceChannelSelector), default(Device), BindingMode.OneWay, null, OnDeviceChanged); + public static readonly BindableProperty SelectedChannelProperty = BindableProperty.Create(nameof(SelectedChannel), typeof(int), typeof(DeviceChannelSelector), 0, BindingMode.TwoWay, null, OnSelectedChannelChanged); - public DeviceType DeviceType + public Device Device { - get => (DeviceType)GetValue(DeviceTypeProperty); - set => SetValue(DeviceTypeProperty, value); + get => (Device)GetValue(DeviceProperty); + set => SetValue(DeviceProperty, value); } public int SelectedChannel @@ -70,11 +72,11 @@ public int SelectedChannel set => SetValue(SelectedChannelProperty, value); } - private static void OnDeviceTypeChanged(BindableObject bindable, object oldValue, object newValue) + private static void OnDeviceChanged(BindableObject bindable, object oldValue, object newValue) { - if (bindable is DeviceChannelSelector dcs) + if (bindable is DeviceChannelSelector dcs && newValue is Device device) { - var deviceType = (DeviceType)newValue; + var deviceType = device.DeviceType; dcs.SbrickSection.IsVisible = deviceType == DeviceType.SBrick; dcs.BuWizzSection.IsVisible = deviceType == DeviceType.BuWizz || deviceType == DeviceType.BuWizz2; dcs.BuWizz3Section.IsVisible = deviceType == DeviceType.BuWizz3; @@ -85,7 +87,12 @@ private static void OnDeviceTypeChanged(BindableObject bindable, object oldValue dcs.DuploTrainHubSection.IsVisible = deviceType == DeviceType.DuploTrainHub; dcs.CircuitCubes.IsVisible = deviceType == DeviceType.CircuitCubes; dcs.Wedo2Section.IsVisible = deviceType == DeviceType.WeDo2; + // Technic Move enablement + var isPlayVm = device is TechnicMoveDevice moveDevice && moveDevice.EnablePlayVmMode; dcs.TechnicMoveSection.IsVisible = deviceType == DeviceType.TechnicMove; + dcs.TechnicMoveChannelA.IsVisible = !isPlayVm; + dcs.TechnicMoveChannelB.IsVisible = !isPlayVm; + dcs.TechnicMoveChannelAB.IsVisible = isPlayVm; } } @@ -128,6 +135,7 @@ private static void OnSelectedChannelChanged(BindableObject bindable, object old dcs.WedoChannel1.SelectedChannel = selectedChannel; dcs.TechnicMoveChannelA.SelectedChannel = selectedChannel; dcs.TechnicMoveChannelB.SelectedChannel = selectedChannel; + dcs.TechnicMoveChannelAB.SelectedChannel = selectedChannel; dcs.TechnicMoveChannelC.SelectedChannel = selectedChannel; dcs.TechnicMoveChannel1.SelectedChannel = selectedChannel; dcs.TechnicMoveChannel2.SelectedChannel = selectedChannel; diff --git a/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml b/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml index c5fc3462..c8d44af3 100644 --- a/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml +++ b/BrickController2/BrickController2/UI/Pages/ControllerActionPage.xaml @@ -52,7 +52,7 @@ - + From 29181dfdbf921220d96ce02861543bd0c64694e3 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Mon, 11 Nov 2024 21:39:59 +0100 Subject: [PATCH 08/14] Fix port config --- .../DeviceManagement/ControlPlusDevice.cs | 28 ++++----- .../DeviceManagement/Device.cs | 2 +- .../DeviceManagement/TechnicMoveDevice.cs | 61 +++++++++---------- 3 files changed, 43 insertions(+), 48 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index 51f5f18a..5902d23d 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -127,7 +127,11 @@ public override void SetOutput(int channel, float value) lock (_outputLock) { - SetChannelOutput(channel, intValue); + if (_outputValues[channel] != intValue) + { + _outputValues[channel] = intValue; + _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; + } } } @@ -184,22 +188,13 @@ protected virtual byte GetChannelValue(int value) // calculate raw motor value => (byte)(value < 0 ? (255 + value) : value); - protected virtual void SetChannelOutput(int channel, int value) + protected void ResetSendAttemps(int channel) { - if (_outputValues[channel] != value) + lock (_outputLock) { - _outputValues[channel] = value; _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; } } - /// - /// Reset send attemps counter for given - /// - /// Expected to be called under the lock - protected void ResetSendAttemps(int channel) - { - _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; - } protected virtual byte[] GetOutputCommand(int channel, int value) { @@ -404,8 +399,7 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf { try { - // Wait until ports finish communicating with the hub - await Task.Delay(1000, token); + await WaitForPortSetupCompletedAsync(token); if (requestDeviceInformation) { @@ -430,6 +424,12 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf } } + protected async Task WaitForPortSetupCompletedAsync(CancellationToken token) + { + // Wait until ports finish communicating with the hub + await Task.Delay(1000, token); + } + private async Task SendOutputValuesAsync(CancellationToken token) { try diff --git a/BrickController2/BrickController2/DeviceManagement/Device.cs b/BrickController2/BrickController2/DeviceManagement/Device.cs index 0d0d959a..066e0295 100644 --- a/BrickController2/BrickController2/DeviceManagement/Device.cs +++ b/BrickController2/BrickController2/DeviceManagement/Device.cs @@ -116,7 +116,7 @@ public override string ToString() return Name; } - protected virtual void CheckChannel(int channel) + protected void CheckChannel(int channel) { if (channel < 0 || channel >= NumberOfChannels) { diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index ebab6925..67a9d476 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -10,6 +10,9 @@ namespace BrickController2.DeviceManagement { internal class TechnicMoveDevice : ControlPlusDevice { + private const int CHANNEL_VM = 12; // artificial channel to mimic combined AB ports in PLAYVM + private const int CHANNEL_C = 2; + private bool _applyPlayVmMode; private byte _virtualMotorValue; @@ -28,23 +31,39 @@ public TechnicMoveDevice(string name, // This is now mandatory as the hub does not support generic servo / stepper commands (yet) public bool EnablePlayVmMode => true; - public override bool CanAutoCalibrateOutput(int channel) => channel == 2; - public override bool CanResetOutput(int channel) => channel == 2; - public override bool CanChangeOutputType(int channel) => channel == 2; + public override bool CanAutoCalibrateOutput(int channel) => channel == CHANNEL_C; + public override bool CanResetOutput(int channel) => channel == CHANNEL_C; + public override bool CanChangeOutputType(int channel) => channel == CHANNEL_C; public override Task ConnectAsync(bool reconnect, Action onDeviceDisconnected, IEnumerable channelConfigurations, bool startOutputProcessing, bool requestDeviceInformation, CancellationToken token) { // autodetect PLAYVM mode for A / B channels (as testing page should not be affected) _applyPlayVmMode = startOutputProcessing && - channelConfigurations.Any(c => c.Channel == 12); + channelConfigurations.Any(c => c.Channel == CHANNEL_VM); // filter out non standard channels var filteredConfigurtions = channelConfigurations - .Where(c => c.Channel != 12); + .Where(c => c.Channel != CHANNEL_VM); return base.ConnectAsync(reconnect, onDeviceDisconnected, filteredConfigurtions, startOutputProcessing, requestDeviceInformation, token); } + public override void SetOutput(int channel, float value) + { + if (channel == CHANNEL_VM) + { + // reset servo writes + ResetSendAttemps(CHANNEL_C); + // store virtual motor value to be later send with PLAYVM + var intValue = (int)(100 * CutOutputValue(value)); + _virtualMotorValue = GetChannelValue(intValue); + } + else + { + base.SetOutput(channel, value); + } + } + protected override byte GetPortId(int channelIndex) => channelIndex switch { 0 => PORT_DRIVE_MOTOR_1, @@ -63,35 +82,12 @@ public override Task ConnectAsync(bool reconnect, Action _ => throw new ArgumentException($"Value of port ID '{portId}' is out of supported ranges.", nameof(portId)) }; - protected override void CheckChannel(int channel) - { - if (_applyPlayVmMode && channel == 12) - return; - - base.CheckChannel(channel); - } - - protected override void SetChannelOutput(int channel, int intValue) - { - if (_applyPlayVmMode && channel == 12) - { - // reset servo writes - ResetSendAttemps(2); - // store virtual motor value - _virtualMotorValue = GetChannelValue(channel); - } - else - { - base.SetChannelOutput(channel, intValue); - } - } - protected override byte GetChannelValue(int value) => ToByte(value); protected override void InitializeChannelInfo(int channel, int lastOutputValue = 1, int sendAttempsLeft = 10) { - // if PLAYVM enabled, reset A / B channels diffrently in order to avoid output write - if (_applyPlayVmMode && (channel == 0 || channel == 1)) + // if PLAYVM enabled, reset A / B channels diffrently in order to avoid output writes + if (_applyPlayVmMode && channel < CHANNEL_C) { lastOutputValue = 0; sendAttempsLeft = 0; @@ -125,8 +121,7 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf { try { - // Wait until ports finish communicating with the hub - await Task.Delay(1000, token); + await WaitForPortSetupCompletedAsync(token); // switch lights off var lightsOffCmd = BuildPortOutput_LedMask(PORT_6LEDS, PORT_MODE_0, 0xff, 0x00); @@ -154,7 +149,7 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf // do calibration var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); await WriteNoResponseAsync(calibrateCmd, token); - await Task.Delay(1500, token); + await Task.Delay(1200, token); } return true; } From 4e8892fe1360cf0f2478fef26a10649aa64f70a9 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Tue, 12 Nov 2024 20:32:16 +0100 Subject: [PATCH 09/14] Optimize command writes - avoid writing multiple times --- .../DeviceManagement/ControlPlusDevice.cs | 17 +++++++++++++---- .../DeviceManagement/TechnicMoveDevice.cs | 2 +- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index 5902d23d..317aad8a 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -188,11 +188,15 @@ protected virtual byte GetChannelValue(int value) // calculate raw motor value => (byte)(value < 0 ? (255 + value) : value); - protected void ResetSendAttemps(int channel) + protected void ResetSendAttemps(int channel, int attemps = MAX_SEND_ATTEMPTS) { lock (_outputLock) { - _sendAttemptsLeft[channel] = MAX_SEND_ATTEMPTS; + // do it conditionally + if (_sendAttemptsLeft[channel] != MAX_SEND_ATTEMPTS) + { + _sendAttemptsLeft[channel] = attemps; + } } } @@ -269,12 +273,14 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] { if (data.Length == 6) { + // assume 16bit data is ABS var channel = GetChannelIndex(data[3]); var absPosition = ToInt16(data, 4); _absolutePositions[channel] = absPosition; } else if (data.Length == 8) { + // assume 32 bit data is REL var channel = GetChannelIndex(data[3]); var relPosition = ToInt32(data, 4); _relativePositions[channel] = relPosition; @@ -353,8 +359,8 @@ protected override void OnCharacteristicChanged(Guid characteristicGuid, byte[] private void DumpData(string header, byte[] data) { - var s = BitConverter.ToString(data); - Console.WriteLine(header + " - " + s); + //var s = BitConverter.ToString(data); + //Console.WriteLine(header + " - " + s); } protected override async Task ProcessOutputsAsync(CancellationToken token) @@ -482,6 +488,7 @@ private async Task SendOutputValueAsync(int channel, CancellationToken tok if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, outputCmd, token)) { _lastOutputValues[channel] = v; + ResetSendAttemps(channel, 0); await Task.Delay(SEND_DELAY, token); return true; } @@ -558,6 +565,7 @@ private async Task SendServoOutputValueAsync(int channel, CancellationToke if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, servoCmd, token)) { _lastOutputValues[channel] = v; + ResetSendAttemps(channel, 0); await Task.Delay(SEND_DELAY, token); return true; } @@ -601,6 +609,7 @@ private async Task SendStepperOutputValueAsync(int channel, CancellationTo if (await _bleDevice!.WriteNoResponseAsync(_characteristic!, _stepperSendBuffer, token)) { _lastOutputValues[channel] = v; + ResetSendAttemps(channel, 0); await Task.Delay(SEND_DELAY, token); return true; } diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 67a9d476..d9b02329 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -52,7 +52,7 @@ public override void SetOutput(int channel, float value) { if (channel == CHANNEL_VM) { - // reset servo writes + // reset servo writes to enforce update ResetSendAttemps(CHANNEL_C); // store virtual motor value to be later send with PLAYVM var intValue = (int)(100 * CutOutputValue(value)); From dd1f2d0a9f4d6dc0e29be13d8d19ee026fed0fac Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Tue, 12 Nov 2024 21:29:38 +0100 Subject: [PATCH 10/14] simple output reset --- .../DeviceManagement/TechnicMoveDevice.cs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index d9b02329..04887cfd 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -35,6 +35,21 @@ public TechnicMoveDevice(string name, public override bool CanResetOutput(int channel) => channel == CHANNEL_C; public override bool CanChangeOutputType(int channel) => channel == CHANNEL_C; + public override async Task ResetOutputAsync(int channel, float value, CancellationToken token) + { + var baseAngle = Convert.ToInt32(value * 180); + + // reset servo via PLAYVM + var servoCmd = BuildPortOutput_PlayVm(servoValue: baseAngle, vmCmd: PLAYVM_COMMAND); + await WriteNoResponseAsync(servoCmd, token); + await SendDelayAsync(token); + + // do calibration + var calibrateCmd = BuildPortOutput_PlayVm(servoValue: baseAngle, vmCmd: PLAYVM_CALIBRATE_STEERING); + await WriteNoResponseAsync(calibrateCmd, token); + await Task.Delay(1200, token); + } + public override Task ConnectAsync(bool reconnect, Action onDeviceDisconnected, IEnumerable channelConfigurations, bool startOutputProcessing, bool requestDeviceInformation, CancellationToken token) { // autodetect PLAYVM mode for A / B channels (as testing page should not be affected) From d042b223f26075bb23dc093bbaee40498325276a Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Wed, 13 Nov 2024 19:05:57 +0100 Subject: [PATCH 11/14] Refactor reset servo --- .../DeviceManagement/ControlPlusDevice.cs | 30 +++--- .../DeviceManagement/TechnicMoveDevice.cs | 100 ++++++++++-------- .../Protocols/LegoWirelessProtocol.cs | 18 ++++ .../UI/Controls/DeviceChannelLabel.cs | 2 +- .../UI/Controls/DeviceChannelSelector.xaml.cs | 2 +- README.md | 2 +- 6 files changed, 94 insertions(+), 60 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs index 317aad8a..b4ef9d00 100644 --- a/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/ControlPlusDevice.cs @@ -172,15 +172,20 @@ protected override async Task ValidateServicesAsync(IEnumerable WriteNoResponseAsync(byte[] data, CancellationToken token = default) - => _bleDevice!.WriteNoResponseAsync(_characteristic!, data, token); + protected async Task WriteNoResponseAsync(byte[] data, bool withSendDelay = false, CancellationToken token = default) + { + var result = await _bleDevice!.WriteNoResponseAsync(_characteristic!, data, token); + + if (withSendDelay) + { + await Task.Delay(SEND_DELAY, token); + } + return result; + } protected Task WriteAsync(byte[] data, CancellationToken token = default) => _bleDevice!.WriteAsync(_characteristic!, data, token); - protected Task SendDelayAsync(CancellationToken token = default) - => Task.Delay(SEND_DELAY, token); - protected virtual byte GetPortId(int channelIndex) => (byte)channelIndex; protected virtual int GetChannelIndex(byte portId) => portId; @@ -405,7 +410,8 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf { try { - await WaitForPortSetupCompletedAsync(token); + // Wait until ports finish communicating with the hub + await Task.Delay(1000, token); if (requestDeviceInformation) { @@ -430,12 +436,6 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf } } - protected async Task WaitForPortSetupCompletedAsync(CancellationToken token) - { - // Wait until ports finish communicating with the hub - await Task.Delay(1000, token); - } - private async Task SendOutputValuesAsync(CancellationToken token) { try @@ -631,7 +631,7 @@ private async Task SendStepperOutputValueAsync(int channel, CancellationTo } } - private async Task SetupChannelForPortInformationAsync(int channel, CancellationToken token) + protected virtual async Task SetupChannelForPortInformationAsync(int channel, CancellationToken token) { try { @@ -661,7 +661,7 @@ private async Task SetupChannelForPortInformationAsync(int channel, Cancel } } - private async Task ResetServoAsync(int channel, int baseAngle, CancellationToken token) + protected virtual async Task ResetServoAsync(int channel, int baseAngle, CancellationToken token) { try { @@ -830,7 +830,7 @@ private Task ResetAsync(int channel, int angle, CancellationToken token) return _bleDevice!.WriteAsync(_characteristic!, new byte[] { 0x0b, 0x00, 0x81, portId, 0x11, 0x51, 0x02, a0, a1, a2, a3 }, token); } - protected async Task RequestHubPropertiesAsync(CancellationToken token) + private async Task RequestHubPropertiesAsync(CancellationToken token) { try { diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 04887cfd..f50dfb35 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -10,7 +10,8 @@ namespace BrickController2.DeviceManagement { internal class TechnicMoveDevice : ControlPlusDevice { - private const int CHANNEL_VM = 12; // artificial channel to mimic combined AB ports in PLAYVM + public const int CHANNEL_VM = 12; // artificial channel to mimic combined AB ports in PLAYVM + private const int CHANNEL_C = 2; private bool _applyPlayVmMode; @@ -35,20 +36,6 @@ public TechnicMoveDevice(string name, public override bool CanResetOutput(int channel) => channel == CHANNEL_C; public override bool CanChangeOutputType(int channel) => channel == CHANNEL_C; - public override async Task ResetOutputAsync(int channel, float value, CancellationToken token) - { - var baseAngle = Convert.ToInt32(value * 180); - - // reset servo via PLAYVM - var servoCmd = BuildPortOutput_PlayVm(servoValue: baseAngle, vmCmd: PLAYVM_COMMAND); - await WriteNoResponseAsync(servoCmd, token); - await SendDelayAsync(token); - - // do calibration - var calibrateCmd = BuildPortOutput_PlayVm(servoValue: baseAngle, vmCmd: PLAYVM_CALIBRATE_STEERING); - await WriteNoResponseAsync(calibrateCmd, token); - await Task.Delay(1200, token); - } public override Task ConnectAsync(bool reconnect, Action onDeviceDisconnected, IEnumerable channelConfigurations, bool startOutputProcessing, bool requestDeviceInformation, CancellationToken token) { @@ -134,38 +121,67 @@ protected override byte[] GetServoCommand(int channel, int servoValue, int servo protected override async Task AfterConnectSetupAsync(bool requestDeviceInformation, CancellationToken token) { - try + if (await base.AfterConnectSetupAsync(requestDeviceInformation, token)) { - await WaitForPortSetupCompletedAsync(token); - - // switch lights off - var lightsOffCmd = BuildPortOutput_LedMask(PORT_6LEDS, PORT_MODE_0, 0xff, 0x00); - await WriteNoResponseAsync(lightsOffCmd, token); - await SendDelayAsync(token); - - if (requestDeviceInformation) + try { - await RequestHubPropertiesAsync(token); + // hub LED + var color = _applyPlayVmMode ? HUB_LED_COLOR_MAGENTA : HUB_LED_COLOR_NONE; + var ledCmd = BuildPortOutput_HubLed(PORT_HUB_LED, HUB_LED_MODE_COLOR, color); + await WriteNoResponseAsync(ledCmd, withSendDelay: true, token: token); + + // switch lights off + var lightsOffCmd = BuildPortOutput_LedMask(PORT_6LEDS, PORT_MODE_0, 0xff, 0x00); + return await WriteNoResponseAsync(lightsOffCmd, withSendDelay: true, token: token); } - - if (_applyPlayVmMode) + catch { - // setup channel to report ABS position - var inputFormatForAbsAngle = BuildPortInputFormatSetup(PORT_STEERING_MOTOR, PORT_MODE_3); - await WriteAsync(inputFormatForAbsAngle, token); - await SendDelayAsync(token); - - // reset servo via PLAYVM - // PLAYVM cmd supports only servo on C channel - var servoCmd = BuildPortOutput_PlayVm(servoValue: 0, vmCmd: PLAYVM_COMMAND); - await WriteNoResponseAsync(servoCmd, token); - await SendDelayAsync(token); - - // do calibration - var calibrateCmd = BuildPortOutput_PlayVm(vmCmd: PLAYVM_CALIBRATE_STEERING); - await WriteNoResponseAsync(calibrateCmd, token); - await Task.Delay(1200, token); } + } + + return false; + } + + protected override async Task SetupChannelForPortInformationAsync(int channel, CancellationToken token) + { + if (!EnablePlayVmMode) + { + return await base.SetupChannelForPortInformationAsync(channel, token); + } + + try + { + // setup channel to report ABS position + var portId = GetPortId(channel); + var inputFormatForAbsAngle = BuildPortInputFormatSetup(portId, PORT_MODE_3); + return await WriteAsync(inputFormatForAbsAngle, token); + } + catch + { + return false; + } + } + + protected override async Task ResetServoAsync(int channel, int baseAngle, CancellationToken token) + { + if (!EnablePlayVmMode) + { + return await base.ResetServoAsync(channel, baseAngle, token); + } + + try + { + // reset servo via PLAYVM + // PLAYVM cmd supports only servo on C channel + var servoCmd = BuildPortOutput_PlayVm(servoValue: baseAngle, vmCmd: PLAYVM_COMMAND); + await WriteNoResponseAsync(servoCmd, token: token); + await Task.Delay(100, token); + + // do calibration + var calibrateCmd = BuildPortOutput_PlayVm(servoValue: baseAngle, vmCmd: PLAYVM_CALIBRATE_STEERING); + await WriteNoResponseAsync(calibrateCmd, token: token); + await Task.Delay(750, token); + return true; } catch diff --git a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs index 4a9dcb13..bb6b8c9d 100644 --- a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs +++ b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs @@ -14,6 +14,7 @@ internal static class LegoWirelessProtocol public const byte PORT_DRIVE_MOTOR_2 = 0x33; public const byte PORT_STEERING_MOTOR = 0x34; public const byte PORT_6LEDS = 0x35; + public const byte PORT_HUB_LED = 0x3F; // port modes public const byte PORT_MODE_0 = 0x00; @@ -34,6 +35,18 @@ internal static class LegoWirelessProtocol public const byte PLAYVM_CALIBRATE_STEERING = 0x08; public const byte PLAYVM_COMMAND = 0x10; + // - output / HUB LED colors + public const byte HUB_LED_MODE_COLOR = 0x00; + public const byte HUB_LED_MODE_RGB = 0x01; + + public const byte HUB_LED_COLOR_NONE = 0x00; + public const byte HUB_LED_COLOR_MAGENTA = 0x02; + public const byte HUB_LED_COLOR_BLUE = 0x03; + public const byte HUB_LED_COLOR_GREEN = 0x06; + public const byte HUB_LED_COLOR_YELLOW = 0x07; + public const byte HUB_LED_COLOR_ORANGE = 0x08; + public const byte HUB_LED_COLOR_RED = 0x09; + // input command (single) public const byte PORT_INPUT_COMMAND = 0x41; @@ -75,6 +88,11 @@ public static byte[] BuildPortOutput_LedMask(byte portId, byte portMode, byte le => [9, 0x00, PORT_OUTPUT_COMMAND, portId, FEEDBACK_ACTION_BOTH, PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, portMode, ledMask, value]; + public static byte[] BuildPortOutput_HubLed(byte portId, byte mode, byte color) + // Message Type - Port Output Command [0x81] | Write Direct + => [8, 0x00, PORT_OUTPUT_COMMAND, portId, FEEDBACK_ACTION_BOTH, + PORT_OUTPUT_SUBCOMMAND_WRITE_DIRECT, mode, color]; + public static byte[] BuildPortOutput_PlayVm(int speedValue = 0, int servoValue = 0, byte vmCmd = PLAYVM_LIGHTS_OFF_OFF) { var speedRaw = ToByte(speedValue); diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs index f73bfc8b..bf10194e 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelLabel.cs @@ -55,7 +55,7 @@ private void SetChannelText() SetChannelText(_controlPlusChannelLetters); break; case DeviceType.TechnicMove: - if (Channel == 12) + if (Channel == TechnicMoveDevice.CHANNEL_VM) Text = "AB"; else SetChannelText(_technicMove); diff --git a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs index beabe23d..106728d0 100644 --- a/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs +++ b/BrickController2/BrickController2/UI/Controls/DeviceChannelSelector.xaml.cs @@ -47,7 +47,7 @@ public DeviceChannelSelector() WedoChannel1.Command = new SafeCommand(() => SelectedChannel = 1); TechnicMoveChannelA.Command = new SafeCommand(() => SelectedChannel = 0); TechnicMoveChannelB.Command = new SafeCommand(() => SelectedChannel = 1); - TechnicMoveChannelAB.Command = new SafeCommand(() => SelectedChannel = 12); + TechnicMoveChannelAB.Command = new SafeCommand(() => SelectedChannel = TechnicMoveDevice.CHANNEL_VM); TechnicMoveChannelC.Command = new SafeCommand(() => SelectedChannel = 2); TechnicMoveChannel1.Command = new SafeCommand(() => SelectedChannel = 3); TechnicMoveChannel2.Command = new SafeCommand(() => SelectedChannel = 4); diff --git a/README.md b/README.md index bc5c1051..714bc71c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Cross platform mobile application for controlling your creations using a bluetoo - Lego Boost Hub - Lego Technic Hub - Lego WeDo 2.0 Smart Hub -- Lego Technic Move Hub +- Lego Technic Move Hub (PLAYVM mode) - Circuit Cubes ## Project details From dfe05beeea0bf977bb8fffd29f132f7892d85028 Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Wed, 13 Nov 2024 20:19:24 +0100 Subject: [PATCH 12/14] Filter Stepper output type for TechnicMove --- .../UI/ViewModels/ControllerActionPageViewModel.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/BrickController2/BrickController2/UI/ViewModels/ControllerActionPageViewModel.cs b/BrickController2/BrickController2/UI/ViewModels/ControllerActionPageViewModel.cs index 5adca421..7b1d192b 100644 --- a/BrickController2/BrickController2/UI/ViewModels/ControllerActionPageViewModel.cs +++ b/BrickController2/BrickController2/UI/ViewModels/ControllerActionPageViewModel.cs @@ -228,15 +228,20 @@ private async Task OpenDeviceDetailsAsync() private async Task SelectChannelOutputTypeAsync() { + // do simple filtering of Stepper for TechnicMove + var channelOutputTypes = SelectedDevice?.DeviceType != DeviceType.TechnicMove ? + Enum.GetNames() : + Enum.GetNames().Where(x => x != Enum.GetName(ChannelOutputType.StepperMotor)); + var result = await _dialogService.ShowSelectionDialogAsync( - Enum.GetNames(typeof(ChannelOutputType)), + channelOutputTypes, Translate("ChannelType"), Translate("Cancel"), _disappearingTokenSource?.Token ?? default); if (result.IsOk) { - Action.ChannelOutputType = (ChannelOutputType)Enum.Parse(typeof(ChannelOutputType), result.SelectedItem); + Action.ChannelOutputType = Enum.Parse(result.SelectedItem); } } From 6fa6a1fedc4827b28265f5b08b776c59764609ae Mon Sep 17 00:00:00 2001 From: Vit Nemecky Date: Wed, 13 Nov 2024 20:47:59 +0100 Subject: [PATCH 13/14] disable auto calibration + finetune hub led --- .../BrickController2/DeviceManagement/TechnicMoveDevice.cs | 4 ++-- .../BrickController2/Protocols/LegoWirelessProtocol.cs | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index f50dfb35..35a6a983 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -32,7 +32,7 @@ public TechnicMoveDevice(string name, // This is now mandatory as the hub does not support generic servo / stepper commands (yet) public bool EnablePlayVmMode => true; - public override bool CanAutoCalibrateOutput(int channel) => channel == CHANNEL_C; + public override bool CanAutoCalibrateOutput(int channel) => false; public override bool CanResetOutput(int channel) => channel == CHANNEL_C; public override bool CanChangeOutputType(int channel) => channel == CHANNEL_C; @@ -126,7 +126,7 @@ protected override async Task AfterConnectSetupAsync(bool requestDeviceInf try { // hub LED - var color = _applyPlayVmMode ? HUB_LED_COLOR_MAGENTA : HUB_LED_COLOR_NONE; + var color = _applyPlayVmMode ? HUB_LED_COLOR_MAGENTA : HUB_LED_COLOR_WHITE; var ledCmd = BuildPortOutput_HubLed(PORT_HUB_LED, HUB_LED_MODE_COLOR, color); await WriteNoResponseAsync(ledCmd, withSendDelay: true, token: token); diff --git a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs index bb6b8c9d..e6d7ae6e 100644 --- a/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs +++ b/BrickController2/BrickController2/Protocols/LegoWirelessProtocol.cs @@ -40,12 +40,16 @@ internal static class LegoWirelessProtocol public const byte HUB_LED_MODE_RGB = 0x01; public const byte HUB_LED_COLOR_NONE = 0x00; + public const byte HUB_LED_COLOR_PINK = 0x01; public const byte HUB_LED_COLOR_MAGENTA = 0x02; public const byte HUB_LED_COLOR_BLUE = 0x03; + public const byte HUB_LED_COLOR_LIGHT_BLUE = 0x04; + public const byte HUB_LED_COLOR_CYAN = 0x05; public const byte HUB_LED_COLOR_GREEN = 0x06; public const byte HUB_LED_COLOR_YELLOW = 0x07; public const byte HUB_LED_COLOR_ORANGE = 0x08; public const byte HUB_LED_COLOR_RED = 0x09; + public const byte HUB_LED_COLOR_WHITE = 0xA; // input command (single) public const byte PORT_INPUT_COMMAND = 0x41; From 8d5b0a768569eb41478e5678494ef55aa4d94997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADt=20N=C4=9Bmeck=C3=BD?= Date: Thu, 12 Dec 2024 22:43:40 +0100 Subject: [PATCH 14/14] Avoid PLAYVM if C channel is not servo --- .../DeviceManagement/TechnicMoveDevice.cs | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs index 35a6a983..63896f29 100644 --- a/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs +++ b/BrickController2/BrickController2/DeviceManagement/TechnicMoveDevice.cs @@ -12,6 +12,8 @@ internal class TechnicMoveDevice : ControlPlusDevice { public const int CHANNEL_VM = 12; // artificial channel to mimic combined AB ports in PLAYVM + private const int CHANNEL_A = 0; + private const int CHANNEL_B = 1; private const int CHANNEL_C = 2; private bool _applyPlayVmMode; @@ -41,7 +43,8 @@ public override Task ConnectAsync(bool reconnect, Action { // autodetect PLAYVM mode for A / B channels (as testing page should not be affected) _applyPlayVmMode = startOutputProcessing && - channelConfigurations.Any(c => c.Channel == CHANNEL_VM); + channelConfigurations.Any(c => c.Channel == CHANNEL_VM) && + channelConfigurations.Any(c => c.Channel == CHANNEL_C && c.ChannelOutputType == CreationManagement.ChannelOutputType.ServoMotor); // filter out non standard channels var filteredConfigurtions = channelConfigurations @@ -54,11 +57,21 @@ public override void SetOutput(int channel, float value) { if (channel == CHANNEL_VM) { - // reset servo writes to enforce update - ResetSendAttemps(CHANNEL_C); - // store virtual motor value to be later send with PLAYVM - var intValue = (int)(100 * CutOutputValue(value)); - _virtualMotorValue = GetChannelValue(intValue); + // for PLAYVM mode + if (_applyPlayVmMode) + { + // reset servo writes to enforce update + ResetSendAttemps(CHANNEL_C); + // store virtual motor value to be later send with PLAYVM + var intValue = (int)(100 * CutOutputValue(value)); + _virtualMotorValue = GetChannelValue(intValue); + } + else + { + // user somehow defined this VM channel without servo setup for C channel + base.SetOutput(CHANNEL_A, -value); + base.SetOutput(CHANNEL_B, value); + } } else {