Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using BrickController2.DeviceManagement;
using BrickController2.DeviceManagement.Lego;
using FluentAssertions;
using Xunit;

Expand All @@ -8,9 +7,10 @@ namespace BrickController2.Tests.DeviceManagement;
public class SBrickDeviceManagerTests : DeviceManagerTestBase<SBrickDeviceManager>
{
[Fact]
public void TryGetDevice_VengitManufacturerId_ReturnsSBrickDevice()
public void TryGetDevice_VengitManufacturerIdWithSBrickProductId_ReturnsSBrickDevice()
{
byte[] manufacturerData = [0x98, 0x01];
byte[] manufacturerData = [0x98, 0x01,
0x02, 0x00, 0x00];
var scanResult = CreateScanResult(deviceName: default, manufacturerData: manufacturerData);

var result = _manager.TryGetDevice(scanResult, out var device);
Expand All @@ -25,6 +25,52 @@ public void TryGetDevice_VengitManufacturerId_ReturnsSBrickDevice()
});
}

[Fact]
public void TryGetDevice_VengitManufacturerIdWithSBrickLightProductId_ReturnsSBrickLightDevice()
{
byte[] manufacturerData = [0x98, 0x01,
0x02, 0x03, 0x00,
0x06, 0x00, 0x01, 0x05, 0x00, 0x05, 0x19];
var scanResult = CreateScanResult(deviceName: default, manufacturerData: manufacturerData);

var result = _manager.TryGetDevice(scanResult, out var device);

result.Should().BeTrue();
device.Should().BeEquivalentTo(new FoundDevice()
{
DeviceAddress = scanResult.DeviceAddress,
DeviceName = scanResult.DeviceName,
DeviceType = DeviceType.SBrickLight,
ManufacturerData = manufacturerData
});
}

[Fact]
public void TryGetDevice_VengitManufacturerIdWithUnknownProductId_ReturnsFalse()
{
byte[] manufacturerData = [0x98, 0x01,
0x06, 0x00, 0xAA, 0x00, 0x00, 0x00, 0x00];
var scanResult = CreateScanResult(deviceName: default, manufacturerData: manufacturerData);

var result = _manager.TryGetDevice(scanResult, out var device);

result.Should().BeFalse();
device.DeviceType.Should().Be(DeviceType.Unknown);
}

[Fact]
public void TryGetDevice_VengitManufacturerIdWithMissingProductId_ReturnsFalse()
{
byte[] manufacturerData = [0x98, 0x01,
0x02, 0x03, 0x00];
var scanResult = CreateScanResult(deviceName: default, manufacturerData: manufacturerData);

var result = _manager.TryGetDevice(scanResult, out var device);

result.Should().BeFalse();
device.DeviceType.Should().Be(DeviceType.Unknown);
}

[Fact]
public void TryGetDevice_WrongManufacturerId_ReturnsFalse()
{
Expand Down
2 changes: 2 additions & 0 deletions BrickController2/BrickController2/BrickController2.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
<EmbeddedResource Include="UI\Images\poweredup_image_small.png" />
<EmbeddedResource Include="UI\Images\sbrick_image.png" />
<EmbeddedResource Include="UI\Images\sbrick_image_small.png" />
<EmbeddedResource Include="UI\Images\sbricklight_image.png" />
<EmbeddedResource Include="UI\Images\sbricklight_image_small.png" />
<EmbeddedResource Include="UI\Images\technic_move.png" />
<EmbeddedResource Include="UI\Images\technic_move_small.png" />
<EmbeddedResource Include="UI\Images\wedo2hub_image.png" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using Autofac;
using BrickController2.DeviceManagement.BuWizz;
using BrickController2.DeviceManagement.CaDA;
using BrickController2.DeviceManagement.Lego;
using BrickController2.DeviceManagement.Vendors;
using BrickController2.DeviceManagement.Vengit;
using BrickController2.Extensions;
using BrickController2.PlatformServices.BluetoothLE;

Expand All @@ -19,7 +19,6 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType<DeviceManager>().As<IDeviceManager>().SingleInstance();
builder.RegisterType<ManualDeviceManager>().As<IManualDeviceManager>().SingleInstance();

builder.RegisterType<SBrickDevice>().Keyed<Device>(DeviceType.SBrick);
builder.RegisterType<BuWizzDevice>().Keyed<Device>(DeviceType.BuWizz);
builder.RegisterType<BuWizz2Device>().Keyed<Device>(DeviceType.BuWizz2);
builder.RegisterType<BuWizz3Device>().Keyed<Device>(DeviceType.BuWizz3);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,6 @@ public enum DeviceType
MK5,
MK3_8,
RemoteControl,
SBrickLight,
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using BrickController2.PlatformServices.BluetoothLE;

using static BrickController2.DeviceManagement.Vengit.SBrickProtocol;

namespace BrickController2.DeviceManagement;

/// <summary>
Expand All @@ -15,11 +17,39 @@ public bool TryGetDevice(ScanResult scanResult, out FoundDevice device)
// check if there are any data and it matches Vengit prefix 0x0198
if (scanResult.TryGetManufacturerData(out var manufacturerData) && manufacturerData.StartsWith(ManufacturerId))
{
device = new FoundDevice(scanResult, DeviceType.SBrick, manufacturerData);
return true;
var productId = GetProductId(manufacturerData);

device = productId switch
{
PRODUCT_ID_SBRICK => new FoundDevice(scanResult, DeviceType.SBrick, manufacturerData),
PRODUCT_ID_SBRICK_LIGHT => new FoundDevice(scanResult, DeviceType.SBrickLight, manufacturerData),

_ => default
};
return device.DeviceType != DeviceType.Unknown;
}

device = default;
return false;
}

private static byte GetProductId(ReadOnlySpan<byte> manufacturerData)
{
// walk through SBrick Data Records to look for 0x00 Product type
int length = 2;
ReadOnlySpan<byte> dataRecord = manufacturerData;

while (length < dataRecord.Length)
{
dataRecord = dataRecord[length..];
length = 1 + dataRecord[0];

if (length > 2 && dataRecord[1] == DATA_RECORD_PRODUCT_TYPE)
{
return dataRecord[2];
}
}

return PRODUCT_ID_UNKNOWN;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BrickController2.DeviceManagement.IO;
using BrickController2.Helpers;
using BrickController2.PlatformServices.BluetoothLE;

using static BrickController2.DeviceManagement.Vengit.SBrickProtocol;

namespace BrickController2.DeviceManagement.Vengit;

internal class SBrickLightDevice : BluetoothDevice
{
private const int BANK_0_CHANNELS = 16;
private const int BANK_1_CHANNELS = 8;

private readonly OutputValuesGroup<byte> _bankOutputs0 = new(BANK_0_CHANNELS);
private readonly OutputValuesGroup<byte> _bankOutputs1 = new(BANK_1_CHANNELS);

private IGattCharacteristic? _firmwareRevisionCharacteristic;
private IGattCharacteristic? _hardwareRevisionCharacteristic;
private IGattCharacteristic? _remoteControlCharacteristic;

public SBrickLightDevice(string name, string address, byte[] deviceData, IDeviceRepository deviceRepository, IBluetoothLEService bleService)
: base(name, address, deviceRepository, bleService)
{
}

public override DeviceType DeviceType => DeviceType.SBrickLight;
public override string BatteryVoltageSign => "V";
public override int NumberOfChannels => BANK_0_CHANNELS + BANK_1_CHANNELS;
protected override bool AutoConnectOnFirstConnect => false;

public override void SetOutput(int channel, float value)
{
CheckChannel(channel);
value = CutOutputValue(value);

// for lights use 0-255 range
var rawValue = (byte)(Math.Abs(value) * 255);

if (channel >= BANK_0_CHANNELS)
{
int lightChannel = channel - BANK_0_CHANNELS;
_bankOutputs1.SetOutput(lightChannel, rawValue);
}
else
{
_bankOutputs0.SetOutput(channel, rawValue);
}
}

protected override Task<bool> ValidateServicesAsync(IEnumerable<IGattService>? services, CancellationToken token)
{
var deviceInformationService = services?.FirstOrDefault(s => s.Uuid == GattProtocol.DeviceInformationServiceUuid);
_firmwareRevisionCharacteristic = deviceInformationService?.Characteristics?.FirstOrDefault(c => c.Uuid == GattProtocol.FirmwareRevisionCharacteristicUuid);
_hardwareRevisionCharacteristic = deviceInformationService?.Characteristics?.FirstOrDefault(c => c.Uuid == GattProtocol.HardwareRevisionCharacteristicUuid);

var remoteControlService = services?.FirstOrDefault(s => s.Uuid == SBrickProtocol.ServiceUuid);
_remoteControlCharacteristic = remoteControlService?.Characteristics?.FirstOrDefault(c => c.Uuid == RemoteControlCharacteristicUuid);

return Task.FromResult(
_firmwareRevisionCharacteristic is not null &&
_hardwareRevisionCharacteristic is not null &&
_remoteControlCharacteristic is not null);
}

protected override async Task<bool> AfterConnectSetupAsync(bool requestDeviceInformation, CancellationToken token)
{
try
{
if (requestDeviceInformation)
{
await ReadDeviceInfo(token).ConfigureAwait(false);
}
}
catch { }

return true;
}

protected override async Task ProcessOutputsAsync(CancellationToken token)
{
try
{
// reset outputs
_bankOutputs0.Initialize();
_bankOutputs1.Initialize();

while (!token.IsCancellationRequested)
{
// process first bank 0
bool changed = await TryProcessChanges(_bankOutputs0, LIGHTS_FLAGS_APPLY | LIGHTS_FLAGS_BANK_0, token);

// process additional bank 1
if (await TryProcessChanges(_bankOutputs1, LIGHTS_FLAGS_APPLY | LIGHTS_FLAGS_BANK_1, token))
{
changed = true;
}

if (!changed)
{
await Task.Delay(10, token).ConfigureAwait(false);
}
}
}
catch
{
}
}

private async Task<bool> TryProcessChanges(OutputValuesGroup<byte> valueBank, byte flags, CancellationToken token)
{
try
{
if (valueBank.TryGetValues(out var values))
{
var command = BuildSetAllLights(flags, values);
var success = await _bleDevice!.WriteAsync(_remoteControlCharacteristic!, command, token).ConfigureAwait(false);
if (success)
{
// confirm successful sending
valueBank.Commmit();
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

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

Corrected spelling of 'Commmit' to 'Commit'.

Suggested change
valueBank.Commmit();
valueBank.Commit();

Copilot uses AI. Check for mistakes.
await Task.Delay(5, token).ConfigureAwait(false);
return true;
}
}
return false;
}
catch
{
return false;
}
}

private async Task ReadDeviceInfo(CancellationToken token)
{
var firmwareData = await _bleDevice!.ReadAsync(_firmwareRevisionCharacteristic!, token);
var firmwareVersion = firmwareData?.ToAsciiStringSafe();
if (!string.IsNullOrEmpty(firmwareVersion))
{
FirmwareVersion = firmwareVersion;
}

var hardwareData = await _bleDevice.ReadAsync(_hardwareRevisionCharacteristic!, token);
var hardwareVersion = hardwareData?.ToAsciiStringSafe();
if (!string.IsNullOrEmpty(hardwareVersion))
{
HardwareVersion = hardwareVersion;
}

// 0x0F Query ADC | voltage on 0x08
await _bleDevice.WriteAsync(_remoteControlCharacteristic!, [0x0f, 0x08], token);
var voltageBuffer = await _bleDevice!.ReadAsync(_remoteControlCharacteristic!, token);
if (voltageBuffer is not null && voltageBuffer.Length >= 2)
{
var rawVoltage = voltageBuffer[0] + (voltageBuffer[1] << 8);
var voltage = (rawVoltage * 0.42567F) / 2047;
BatteryVoltage = voltage.ToString("F2");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;

namespace BrickController2.DeviceManagement.Vengit;

/// <summary>
/// Contains implementation of SBrick protocol <see href="https://social.sbrick.com/custom/The_SBrick_BLE_Protocol.pdf"/>
/// </summary>
internal static class SBrickProtocol
{
/// <summary>
/// SBrick - Remote control service UUID
/// </summary>
public static readonly Guid ServiceUuid = new("4dc591b0-857c-41de-b5f1-15abda665b0c");
/// <summary>
/// Remote control service - Remote control commands characteristic UUID
/// </summary>
public static readonly Guid RemoteControlCharacteristicUuid = new("02b8cbcc-0e25-4bda-8790-a15f53e6010f");

// Light flags
public const byte LIGHTS_FLAGS_BANK_0 = 0x00;
public const byte LIGHTS_FLAGS_BANK_1 = 0x01;
public const byte LIGHTS_FLAGS_APPLY = 0x80;

// data records
public const byte DATA_RECORD_PRODUCT_TYPE = 0x00;

public const byte PRODUCT_ID_SBRICK = 0x00;
public const byte PRODUCT_ID_SBRICK_LIGHT = 0x01;
public const byte PRODUCT_ID_UNKNOWN = 0xFF;

// message builders
public static byte[] BuildSetAllLights(byte flags, ReadOnlySpan<byte> values)
{
// 0x36 Set all lights
var buffer = new byte[2 + values.Length];

buffer[0] = 0x36;
buffer[1] = flags;
values.CopyTo(buffer.AsSpan(2));

return buffer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using BrickController2.DeviceManagement.DI;
using BrickController2.DeviceManagement.Vendors;
using BrickController2.Extensions;

namespace BrickController2.DeviceManagement.Vengit;

/// <summary>
/// Vendor: Vengit and all its device types: SBrick, SBrick Plus, SBrick Light and implementation of IBluetoothLEDeviceManager
/// </summary>
internal class Vengit : Vendor<Vengit>
{
public override string VendorName => "Vengit";

protected override void Register(VendorBuilder<Vengit> builder)
{
// classic devices
builder.ContainerBuilder
.RegisterDevice<SBrickDevice>(DeviceType.SBrick)
.RegisterDevice<SBrickLightDevice>(DeviceType.SBrickLight);

// device manager
builder.RegisterDeviceManager<SBrickDeviceManager>();
}
}
Loading