mirror of
https://github.com/scratchfoundation/scratch-link.git
synced 2025-08-28 22:39:42 -04:00
adapt base BLESession code from Scratch Link 1.x
This commit is contained in:
parent
cc915a1a03
commit
f4e049ebf9
6 changed files with 787 additions and 7 deletions
297
scratch-link/BLESession.cs
Normal file
297
scratch-link/BLESession.cs
Normal file
|
@ -0,0 +1,297 @@
|
|||
// <copyright file="BLESession.cs" company="Scratch Foundation">
|
||||
// Copyright (c) Scratch Foundation. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace ScratchLink;
|
||||
|
||||
using ScratchLink.JsonRpc;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the cross-platform portions of a BLE session.
|
||||
/// </summary>
|
||||
internal abstract class BLESession : Session
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BLESession"/> class.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="Session.Session(WebSocketContext)"/>
|
||||
public BLESession(WebSocketContext context)
|
||||
: base(context)
|
||||
{
|
||||
this.Handlers["discover"] = this.HandleDiscover;
|
||||
this.Handlers["connect"] = this.HandleConnect;
|
||||
this.Handlers["write"] = this.HandleWrite;
|
||||
this.Handlers["read"] = this.HandleRead;
|
||||
this.Handlers["startNotifications"] = this.HandleStartNotifications;
|
||||
this.Handlers["stopNotifications"] = this.HandleStopNotifications;
|
||||
this.Handlers["getServices"] = this.HandleGetServices;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "discover" request to search for peripherals which match the filter information
|
||||
/// provided in the parameters. Valid in the initial state; transitions to discovery state on success.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("discover").</param>
|
||||
/// <param name="args">
|
||||
/// JSON object containing at least one filter, and optionally an "optionalServices" list. See
|
||||
/// <a href="https://webbluetoothcg.github.io/web-bluetooth/#dictdef-requestdeviceoptions">here</a> for more
|
||||
/// information, but note that the "acceptAllDevices" property is ignored.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected async Task<object> HandleDiscover(string methodName, JsonElement? args)
|
||||
{
|
||||
if (args?.TryGetProperty("filters", out var jsonFilters) != true ||
|
||||
jsonFilters.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("could not parse filters in discovery request"));
|
||||
}
|
||||
|
||||
if (jsonFilters.GetArrayLength() < 1)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("discovery request must include at least one filter"));
|
||||
}
|
||||
|
||||
var filters = jsonFilters.EnumerateArray().Select(jsonFilter => new BLEScanFilter(jsonFilter)).ToList();
|
||||
if (filters.Any(filter => filter.IsEmpty))
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("discovery request includes empty filter"));
|
||||
}
|
||||
|
||||
HashSet<Guid> optionalServices = null;
|
||||
if (args?.TryGetProperty("optionalServices", out var jsonOptionalServices) == true)
|
||||
{
|
||||
if (jsonOptionalServices.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("could not parse optionalServices in discovery request"));
|
||||
}
|
||||
|
||||
optionalServices = new (jsonOptionalServices.EnumerateArray().Select(GattHelpers.GetServiceUuid));
|
||||
}
|
||||
|
||||
return await this.DoDiscover(filters, optionalServices);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-specific implementation for peripheral device discovery.
|
||||
/// </summary>
|
||||
/// <param name="filters">The filters for device discovery. A peripheral device must match at least one filter to pass.</param>
|
||||
/// <param name="optionalServices">Additional services the client might use, in addition to those in the matching filter.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected abstract Task<object> DoDiscover(List<BLEScanFilter> filters, HashSet<Guid> optionalServices);
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "connect" request to connect to a particular peripheral.
|
||||
/// Valid in the discovery state; transitions to connected state on success.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("connect").</param>
|
||||
/// <param name="args">
|
||||
/// A JSON object containing the UUID of a peripheral found by the most recent discovery request.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected async Task<object> HandleConnect(string methodName, JsonElement? args)
|
||||
{
|
||||
if (args?.TryGetProperty("peripheralId", out var jsonPeripheralId) != true)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams("connect request must include peripheralId"));
|
||||
}
|
||||
|
||||
return await this.DoConnect(jsonPeripheralId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Platform-specific implementation for connecting to a peripheral device.
|
||||
/// </summary>
|
||||
/// <param name="jsonPeripheralId">A JSON element representing a platform-specific peripheral ID.</param>
|
||||
/// <returns>A <see cref="Task{TResult}"/> representing the result of the asynchronous operation.</returns>
|
||||
protected abstract Task<object> DoConnect(JsonElement jsonPeripheralId);
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "write" request to write a value to a particular service characteristic.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("write").</param>
|
||||
/// <param name="args">
|
||||
/// The IDs of the service and characteristic along with the message and optionally the message encoding.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected Task<object> HandleWrite(string methodName, JsonElement? args)
|
||||
{
|
||||
return Task.FromResult<object>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "read" request to read the value of a particular service characteristic.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("read").</param>
|
||||
/// <param name="args">
|
||||
/// The IDs of the service and characteristic, an optional encoding to be used in the response, and an optional
|
||||
/// flag to request notification of future changes to this characteristic's value.
|
||||
/// </param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected Task<object> HandleRead(string methodName, JsonElement? args)
|
||||
{
|
||||
return Task.FromResult<object>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "startNotifications" request to start receiving notifications for changes in a characteristic's value.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("startNotifications").</param>
|
||||
/// <param name="args">The service and characteristic for which to start notifications.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected Task<object> HandleStartNotifications(string methodName, JsonElement? args)
|
||||
{
|
||||
return Task.FromResult<object>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "stopNotifications" request to stop receiving notifications for changes in a characteristic's value.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("stopNotifications").</param>
|
||||
/// <param name="args">The service and characteristic for which to stop notifications.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected Task<object> HandleStopNotifications(string methodName, JsonElement? args)
|
||||
{
|
||||
return Task.FromResult<object>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implement the JSON-RPC "getServices" request which lists all available services on the peripheral device.
|
||||
/// </summary>
|
||||
/// <param name="methodName">The name of the method being called ("getServices").</param>
|
||||
/// <param name="args">Ignored.</param>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
protected Task<object> HandleGetServices(string methodName, JsonElement? args)
|
||||
{
|
||||
return Task.FromResult<object>(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Store information associated with one entry in the "filters" array of a "discover" request.
|
||||
/// </summary>
|
||||
protected class BLEScanFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BLEScanFilter"/> class.
|
||||
/// See <a href="https://webbluetoothcg.github.io/web-bluetooth/#bluetoothlescanfilterinit-canonicalizing">here</a>.
|
||||
/// </summary>
|
||||
/// <param name="filter">The JSON element to parse and canonicalize to initialize this filter object.</param>
|
||||
public BLEScanFilter(JsonElement filter)
|
||||
{
|
||||
if (filter.TryGetProperty("name", out var jsonName))
|
||||
{
|
||||
this.Name = jsonName.GetString();
|
||||
}
|
||||
|
||||
if (filter.TryGetProperty("namePrefix", out var jsonNamePrefix))
|
||||
{
|
||||
this.NamePrefix = jsonNamePrefix.GetString();
|
||||
}
|
||||
|
||||
if (filter.TryGetProperty("services", out var jsonServices))
|
||||
{
|
||||
this.RequiredServices = new (jsonServices.EnumerateArray().Select(GattHelpers.GetServiceUuid));
|
||||
if (this.RequiredServices.Count < 1)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams($"filter contains empty or invalid services list: {filter}"));
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.TryGetProperty("manufacturerData", out var jsonManufacturerData))
|
||||
{
|
||||
this.ManufacturerData = new ();
|
||||
foreach (var property in jsonManufacturerData.EnumerateObject())
|
||||
{
|
||||
var manufacturerId = int.Parse(property.Name);
|
||||
var dataFilter = new BLEDataFilter(property.Value);
|
||||
this.ManufacturerData.Add(manufacturerId, dataFilter);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.TryGetProperty("serviceData", out _))
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.ApplicationError("filtering on serviceData is not currently supported"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exact name to search for. A peripheral device will match only if this is its exact name. Ignored if null.
|
||||
/// </summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the name prefix to search for. A peripheral device will match only if its name starts with this. Ignored if null.
|
||||
/// </summary>
|
||||
public string NamePrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the set of required UUIDs for the search. A peripheral device will match only if it offers every service in this set.
|
||||
/// Ignored if null or empty.
|
||||
/// </summary>
|
||||
public HashSet<Guid> RequiredServices { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a map of manufacturer data ID to manufacturer data filter. A peripheral device will match only if every manufacturer data filter matches.
|
||||
/// Ignored if null or empty.
|
||||
/// </summary>
|
||||
public Dictionary<int, BLEDataFilter> ManufacturerData { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether or not this filter is empty. A filter is empty if it matches every possible peripheral device.
|
||||
/// </summary>
|
||||
public bool IsEmpty =>
|
||||
string.IsNullOrWhiteSpace(this.Name) &&
|
||||
string.IsNullOrWhiteSpace(this.NamePrefix) &&
|
||||
(this.RequiredServices == null || this.RequiredServices.Count < 1) &&
|
||||
(this.ManufacturerData == null || this.ManufacturerData.Count < 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Filter for matching BLE data with a mask.
|
||||
/// </summary>
|
||||
protected class BLEDataFilter
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BLEDataFilter"/> class.
|
||||
/// </summary>
|
||||
/// <param name="dataFilter">JSON representation of the data filter.</param>
|
||||
public BLEDataFilter(JsonElement dataFilter)
|
||||
{
|
||||
if (dataFilter.TryGetProperty("dataPrefix", out var jsonDataPrefix))
|
||||
{
|
||||
this.DataPrefix = new (jsonDataPrefix.EnumerateArray().Select(element => element.GetByte()));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.DataPrefix = new ();
|
||||
}
|
||||
|
||||
if (dataFilter.TryGetProperty("mask", out var jsonMask))
|
||||
{
|
||||
this.Mask = new (jsonMask.EnumerateArray().Select(element => element.GetByte()));
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Mask = Enumerable.Repeat<byte>(0xFF, this.DataPrefix.Count).ToList();
|
||||
}
|
||||
|
||||
if (this.DataPrefix.Count != this.Mask.Count)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams(
|
||||
$"length of data prefix ({this.DataPrefix.Count}) does not match length of mask ({this.Mask.Count})"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of bytes to match the candidate data against after masking.
|
||||
/// </summary>
|
||||
public List<byte> DataPrefix { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of bytes to mask the candidate data with before testing for a match.
|
||||
/// </summary>
|
||||
public List<byte> Mask { get; }
|
||||
}
|
||||
}
|
381
scratch-link/GattData.cs
Normal file
381
scratch-link/GattData.cs
Normal file
|
@ -0,0 +1,381 @@
|
|||
// <copyright file="GattData.cs" company="Scratch Foundation">
|
||||
// Copyright (c) Scratch Foundation. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace ScratchLink;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
/// <summary>
|
||||
/// Stores static data about GATT services, names, etc.
|
||||
/// </summary>
|
||||
internal static class GattData
|
||||
{
|
||||
/// <summary>
|
||||
/// Table of well-known GATT service UUIDs.
|
||||
/// See <a href="https://www.bluetooth.com/specifications/gatt/services">here</a> for more info.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, ushort> AssignedServices =
|
||||
new ReadOnlyDictionary<string, ushort>(new Dictionary<string, ushort>
|
||||
{
|
||||
{ "alert_notification", 0x1811 },
|
||||
{ "automation_io", 0x1815 },
|
||||
{ "battery_service", 0x180F },
|
||||
{ "blood_pressure", 0x1810 },
|
||||
{ "body_composition", 0x181B },
|
||||
{ "bond_management", 0x181E },
|
||||
{ "continuous_glucose_monitoring", 0x181F },
|
||||
{ "current_time", 0x1805 },
|
||||
{ "cycling_power", 0x1818 },
|
||||
{ "cycling_speed_and_cadence", 0x1816 },
|
||||
{ "device_information", 0x180A },
|
||||
{ "environmental_sensing", 0x181A },
|
||||
{ "fitness_machine", 0x1826 },
|
||||
{ "generic_access", 0x1800 },
|
||||
{ "generic_attribute", 0x1801 },
|
||||
{ "glucose", 0x1808 },
|
||||
{ "health_thermometer", 0x1809 },
|
||||
{ "heart_rate", 0x180D },
|
||||
{ "http_proxy", 0x1823 },
|
||||
{ "human_interface_device", 0x1812 },
|
||||
{ "immediate_alert", 0x1802 },
|
||||
{ "indoor_positioning", 0x1821 },
|
||||
{ "internet_protocol_support", 0x1820 },
|
||||
{ "link_loss", 0x1803 },
|
||||
{ "location_and_navigation", 0x1819 },
|
||||
{ "mesh_provisioning", 0x1827 },
|
||||
{ "mesh_proxy", 0x1828 },
|
||||
{ "next_dst_change", 0x1807 },
|
||||
{ "object_transfer", 0x1825 },
|
||||
{ "phone_alert_status", 0x180E },
|
||||
{ "pulse_oximeter", 0x1822 },
|
||||
{ "reconnection_configuration", 0x1829 },
|
||||
{ "reference_time_update", 0x1806 },
|
||||
{ "running_speed_and_cadence", 0x1814 },
|
||||
{ "scan_parameters", 0x1813 },
|
||||
{ "transport_discovery", 0x1824 },
|
||||
{ "tx_power", 0x1804 },
|
||||
{ "user_data", 0x181C },
|
||||
{ "weight_scale", 0x181D },
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Table of well-known GATT characteristic UUIDs.
|
||||
/// See <a href="https://www.bluetooth.com/specifications/gatt/characteristics">here</a> for more info.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, ushort> AssignedCharacteristics =
|
||||
new ReadOnlyDictionary<string, ushort>(new Dictionary<string, ushort>
|
||||
{
|
||||
{ "aerobic_heart_rate_lower_limit", 0x2A7E },
|
||||
{ "aerobic_heart_rate_upper_limit", 0x2A84 },
|
||||
{ "aerobic_threshold", 0x2A7F },
|
||||
{ "age", 0x2A80 },
|
||||
{ "aggregate", 0x2A5A },
|
||||
{ "alert_category_id", 0x2A43 },
|
||||
{ "alert_category_id_bit_mask", 0x2A42 },
|
||||
{ "alert_level", 0x2A06 },
|
||||
{ "alert_notification_control_point", 0x2A44 },
|
||||
{ "alert_status", 0x2A3F },
|
||||
{ "altitude", 0x2AB3 },
|
||||
{ "anaerobic_heart_rate_lower_limit", 0x2A81 },
|
||||
{ "anaerobic_heart_rate_upper_limit", 0x2A82 },
|
||||
{ "anaerobic_threshold", 0x2A83 },
|
||||
{ "analog", 0x2A58 },
|
||||
{ "analog_output", 0x2A59 },
|
||||
{ "apparent_wind_direction", 0x2A73 },
|
||||
{ "apparent_wind_speed", 0x2A72 },
|
||||
{ "barometric_pressure_trend", 0x2AA3 },
|
||||
{ "battery_level", 0x2A19 },
|
||||
{ "battery_level_state", 0x2A1B },
|
||||
{ "battery_power_state", 0x2A1A },
|
||||
{ "blood_pressure_feature", 0x2A49 },
|
||||
{ "blood_pressure_measurement", 0x2A35 },
|
||||
{ "body_composition_feature", 0x2A9B },
|
||||
{ "body_composition_measurement", 0x2A9C },
|
||||
{ "body_sensor_location", 0x2A38 },
|
||||
{ "bond_management_control_point", 0x2AA4 },
|
||||
{ "bond_management_feature", 0x2AA5 },
|
||||
{ "boot_keyboard_input_report", 0x2A22 },
|
||||
{ "boot_keyboard_output_report", 0x2A32 },
|
||||
{ "boot_mouse_input_report", 0x2A33 },
|
||||
{ "cgm_feature", 0x2AA8 },
|
||||
{ "cgm_measurement", 0x2AA7 },
|
||||
{ "cgm_session_run_time", 0x2AAB },
|
||||
{ "cgm_session_start_time", 0x2AAA },
|
||||
{ "cgm_specific_ops_control_point", 0x2AAC },
|
||||
{ "cgm_status", 0x2AA9 },
|
||||
{ "cross_trainer_data", 0x2ACE },
|
||||
{ "csc_feature", 0x2A5C },
|
||||
{ "csc_measurement", 0x2A5B },
|
||||
{ "current_time", 0x2A2B },
|
||||
{ "cycling_power_control_point", 0x2A66 },
|
||||
{ "cycling_power_feature", 0x2A65 },
|
||||
{ "cycling_power_measurement", 0x2A63 },
|
||||
{ "cycling_power_vector", 0x2A64 },
|
||||
{ "database_change_increment", 0x2A99 },
|
||||
{ "date_of_birth", 0x2A85 },
|
||||
{ "date_of_threshold_assessment", 0x2A86 },
|
||||
{ "date_time", 0x2A08 },
|
||||
{ "day_date_time", 0x2A0A },
|
||||
{ "day_of_week", 0x2A09 },
|
||||
{ "descriptor_value_changed", 0x2A7D },
|
||||
{ "dew_point", 0x2A7B },
|
||||
{ "digital", 0x2A56 },
|
||||
{ "digital_output", 0x2A57 },
|
||||
{ "dst_offset", 0x2A0D },
|
||||
{ "elevation", 0x2A6C },
|
||||
{ "email_address", 0x2A87 },
|
||||
{ "exact_time_100", 0x2A0B },
|
||||
{ "exact_time_256", 0x2A0C },
|
||||
{ "fat_burn_heart_rate_lower_limit", 0x2A88 },
|
||||
{ "fat_burn_heart_rate_upper_limit", 0x2A89 },
|
||||
{ "firmware_revision_string", 0x2A26 },
|
||||
{ "first_name", 0x2A8A },
|
||||
{ "fitness_machine_control_point", 0x2AD9 },
|
||||
{ "fitness_machine_feature", 0x2ACC },
|
||||
{ "fitness_machine_status", 0x2ADA },
|
||||
{ "five_zone_heart_rate_limits", 0x2A8B },
|
||||
{ "floor_number", 0x2AB2 },
|
||||
{ "gap.appearance", 0x2A01 },
|
||||
{ "gap.central_address_resolution", 0x2AA6 },
|
||||
{ "gap.device_name", 0x2A00 },
|
||||
{ "gap.peripheral_preferred_connection_parameters", 0x2A04 },
|
||||
{ "gap.peripheral_privacy_flag", 0x2A02 },
|
||||
{ "gap.reconnection_address", 0x2A03 },
|
||||
{ "gatt.service_changed", 0x2A05 },
|
||||
{ "gender", 0x2A8C },
|
||||
{ "glucose_feature", 0x2A51 },
|
||||
{ "glucose_measurement", 0x2A18 },
|
||||
{ "glucose_measurement_context", 0x2A34 },
|
||||
{ "gust_factor", 0x2A74 },
|
||||
{ "hardware_revision_string", 0x2A27 },
|
||||
{ "heart_rate_control_point", 0x2A39 },
|
||||
{ "heart_rate_max", 0x2A8D },
|
||||
{ "heart_rate_measurement", 0x2A37 },
|
||||
{ "heat_index", 0x2A7A },
|
||||
{ "height", 0x2A8E },
|
||||
{ "hid_control_point", 0x2A4C },
|
||||
{ "hid_information", 0x2A4A },
|
||||
{ "hip_circumference", 0x2A8F },
|
||||
{ "http_control_point", 0x2ABA },
|
||||
{ "http_entity_body", 0x2AB9 },
|
||||
{ "http_headers", 0x2AB7 },
|
||||
{ "http_status_code", 0x2AB8 },
|
||||
{ "https_security", 0x2ABB },
|
||||
{ "humidity", 0x2A6F },
|
||||
{ "ieee_11073-20601_regulatory_certification_data_list", 0x2A2A },
|
||||
{ "indoor_bike_data", 0x2AD2 },
|
||||
{ "indoor_positioning_configuration", 0x2AAD },
|
||||
{ "intermediate_cuff_pressure", 0x2A36 },
|
||||
{ "intermediate_temperature", 0x2A1E },
|
||||
{ "irradiance", 0x2A77 },
|
||||
{ "language", 0x2AA2 },
|
||||
{ "last_name", 0x2A90 },
|
||||
{ "latitude", 0x2AAE },
|
||||
{ "ln_control_point", 0x2A6B },
|
||||
{ "ln_feature", 0x2A6A },
|
||||
{ "local_east_coordinate", 0x2AB1 },
|
||||
{ "local_north_coordinate", 0x2AB0 },
|
||||
{ "local_time_information", 0x2A0F },
|
||||
{ "location_and_speed", 0x2A67 },
|
||||
{ "location_name", 0x2AB5 },
|
||||
{ "Longitude", 0x2AAF },
|
||||
{ "magnetic_declination", 0x2A2C },
|
||||
{ "Magnetic_flux_density_2D", 0x2AA0 },
|
||||
{ "Magnetic_flux_density_3D", 0x2AA1 },
|
||||
{ "manufacturer_name_string", 0x2A29 },
|
||||
{ "maximum_recommended_heart_rate", 0x2A91 },
|
||||
{ "measurement_interval", 0x2A21 },
|
||||
{ "model_number_string", 0x2A24 },
|
||||
{ "navigation", 0x2A68 },
|
||||
{ "network_availability", 0x2A3E },
|
||||
{ "new_alert", 0x2A46 },
|
||||
{ "object_action_control_point", 0x2AC5 },
|
||||
{ "object_changed", 0x2AC8 },
|
||||
{ "object_first_created", 0x2AC1 },
|
||||
{ "object_id", 0x2AC3 },
|
||||
{ "object_last_modified", 0x2AC2 },
|
||||
{ "object_list_control_point", 0x2AC6 },
|
||||
{ "object_list_filter", 0x2AC7 },
|
||||
{ "object_name", 0x2ABE },
|
||||
{ "object_properties", 0x2AC4 },
|
||||
{ "object_size", 0x2AC0 },
|
||||
{ "object_type", 0x2ABF },
|
||||
{ "ots_feature", 0x2ABD },
|
||||
{ "plx_continuous_measurement", 0x2A5F },
|
||||
{ "plx_features", 0x2A60 },
|
||||
{ "plx_spot_check_measurement", 0x2A5E },
|
||||
{ "pnp_id", 0x2A50 },
|
||||
{ "pollen_concentration", 0x2A75 },
|
||||
{ "position_2d", 0x2A2F },
|
||||
{ "position_3d", 0x2A30 },
|
||||
{ "position_quality", 0x2A69 },
|
||||
{ "pressure", 0x2A6D },
|
||||
{ "protocol_mode", 0x2A4E },
|
||||
{ "pulse_oximetry_control_point", 0x2A62 },
|
||||
{ "rainfall", 0x2A78 },
|
||||
{ "rc_feature", 0x2B1D },
|
||||
{ "rc_settings", 0x2B1E },
|
||||
{ "reconnection_configuration_control_point", 0x2B1F },
|
||||
{ "record_access_control_point", 0x2A52 },
|
||||
{ "reference_time_information", 0x2A14 },
|
||||
{ "removable", 0x2A3A },
|
||||
{ "report", 0x2A4D },
|
||||
{ "report_map", 0x2A4B },
|
||||
{ "resolvable_private_address_only", 0x2AC9 },
|
||||
{ "resting_heart_rate", 0x2A92 },
|
||||
{ "ringer_control_point", 0x2A40 },
|
||||
{ "ringer_setting", 0x2A41 },
|
||||
{ "rower_data", 0x2AD1 },
|
||||
{ "rsc_feature", 0x2A54 },
|
||||
{ "rsc_measurement", 0x2A53 },
|
||||
{ "sc_control_point", 0x2A55 },
|
||||
{ "scan_interval_window", 0x2A4F },
|
||||
{ "scan_refresh", 0x2A31 },
|
||||
{ "scientific_temperature_celsius", 0x2A3C },
|
||||
{ "secondary_time_zone", 0x2A10 },
|
||||
{ "sensor_location", 0x2A5D },
|
||||
{ "serial_number_string", 0x2A25 },
|
||||
{ "service_required", 0x2A3B },
|
||||
{ "software_revision_string", 0x2A28 },
|
||||
{ "sport_type_for_aerobic_and_anaerobic_thresholds", 0x2A93 },
|
||||
{ "stair_climber_data", 0x2AD0 },
|
||||
{ "step_climber_data", 0x2ACF },
|
||||
{ "string", 0x2A3D },
|
||||
{ "supported_heart_rate_range", 0x2AD7 },
|
||||
{ "supported_inclination_range", 0x2AD5 },
|
||||
{ "supported_new_alert_category", 0x2A47 },
|
||||
{ "supported_power_range", 0x2AD8 },
|
||||
{ "supported_resistance_level_range", 0x2AD6 },
|
||||
{ "supported_speed_range", 0x2AD4 },
|
||||
{ "supported_unread_alert_category", 0x2A48 },
|
||||
{ "system_id", 0x2A23 },
|
||||
{ "tds_control_point", 0x2ABC },
|
||||
{ "temperature", 0x2A6E },
|
||||
{ "temperature_celsius", 0x2A1F },
|
||||
{ "temperature_fahrenheit", 0x2A20 },
|
||||
{ "temperature_measurement", 0x2A1C },
|
||||
{ "temperature_type", 0x2A1D },
|
||||
{ "three_zone_heart_rate_limits", 0x2A94 },
|
||||
{ "time_accuracy", 0x2A12 },
|
||||
{ "time_broadcast", 0x2A15 },
|
||||
{ "time_source", 0x2A13 },
|
||||
{ "time_update_control_point", 0x2A16 },
|
||||
{ "time_update_state", 0x2A17 },
|
||||
{ "time_with_dst", 0x2A11 },
|
||||
{ "time_zone", 0x2A0E },
|
||||
{ "training_status", 0x2AD3 },
|
||||
{ "treadmill_data", 0x2ACD },
|
||||
{ "true_wind_direction", 0x2A71 },
|
||||
{ "true_wind_speed", 0x2A70 },
|
||||
{ "two_zone_heart_rate_limit", 0x2A95 },
|
||||
{ "tx_power_level", 0x2A07 },
|
||||
{ "uncertainty", 0x2AB4 },
|
||||
{ "unread_alert_status", 0x2A45 },
|
||||
{ "uri", 0x2AB6 },
|
||||
{ "user_control_point", 0x2A9F },
|
||||
{ "user_index", 0x2A9A },
|
||||
{ "uv_index", 0x2A76 },
|
||||
{ "vo2_max", 0x2A96 },
|
||||
{ "waist_circumference", 0x2A97 },
|
||||
{ "weight", 0x2A98 },
|
||||
{ "weight_measurement", 0x2A9D },
|
||||
{ "weight_scale_feature", 0x2A9E },
|
||||
{ "wind_chill", 0x2A79 },
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Dictionary of UUIDs which are blocked from Web Bluetooth access for security or privacy reasons. See
|
||||
/// <a href="https://github.com/WebBluetoothCG/registries">the Web Bluetooth Registries repository</a> for
|
||||
/// more information.
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<Guid, BlockListStatus> BlockList =
|
||||
|
||||
// Collected from https://github.com/WebBluetoothCG/registries @ 693db2fe6050bee27d198e1584d11fc2732cdbd8
|
||||
new ReadOnlyDictionary<Guid, BlockListStatus>(new Dictionary<Guid, BlockListStatus>
|
||||
{
|
||||
// Services
|
||||
|
||||
// org.bluetooth.service.human_interface_device
|
||||
// Direct access to HID devices like keyboards would let web pages become keyloggers.
|
||||
{ new Guid("00001812-0000-1000-8000-00805f9b34fb"), BlockListStatus.Exclude },
|
||||
|
||||
// Firmware update services that don't check the update's signature present a risk of devices'
|
||||
// software being modified by malicious web pages. Users may connect to a device believing they are
|
||||
// enabling only simple interaction or that they're interacting with the device's manufacturer, but
|
||||
// the site might instead persistently compromise the device.
|
||||
|
||||
// Nordic's Legacy Device Firmware Update service,
|
||||
// http://infocenter.nordicsemi.com/topic/com.nordic.infocenter.sdk5.v11.0.0/examples_ble_dfu.html
|
||||
{ new Guid("00001530-1212-efde-1523-785feabcd123"), BlockListStatus.Exclude },
|
||||
|
||||
// TI's Over-the-Air Download service, http://www.ti.com/lit/ug/swru271g/swru271g.pdf
|
||||
{ new Guid("f000ffc0-0451-4000-b000-000000000000"), BlockListStatus.Exclude },
|
||||
|
||||
// Cypress's Bootloader service.
|
||||
// Documentation at http://www.cypress.com/file/175561/download requires an account.
|
||||
// Linked as CYPRESS BOOTLOADER SERVICE_001-97547.pdf from
|
||||
// http://www.cypress.com/documentation/software-and-drivers/cypresss-custom-ble-profiles-and-services
|
||||
{ new Guid("00060000-0000-1000-8000-00805f9b34fb"), BlockListStatus.Exclude },
|
||||
|
||||
// The FIDO Bluetooth Specification at
|
||||
// https://fidoalliance.org/specs/fido-u2f-bt-protocol-id-20150514.pdf
|
||||
// section 6.7.1 "Bluetooth pairing: Client considerations" warns that system-wide pairing poses
|
||||
// security risks. Specifically, a website could use raw GATT commands to impersonate another website
|
||||
// to the FIDO device.
|
||||
{ new Guid("0000fffd-0000-1000-8000-00805f9b34fb"), BlockListStatus.Exclude },
|
||||
|
||||
// Characteristics
|
||||
|
||||
// org.bluetooth.characteristic.gap.peripheral_privacy_flag
|
||||
// Don't let web pages turn off privacy mode.
|
||||
{ new Guid("00002a02-0000-1000-8000-00805f9b34fb"), BlockListStatus.ExcludeWrites },
|
||||
|
||||
// org.bluetooth.characteristic.gap.reconnection_address
|
||||
// Disallow messing with connection parameters
|
||||
{ new Guid("00002a03-0000-1000-8000-00805f9b34fb"), BlockListStatus.Exclude },
|
||||
|
||||
// org.bluetooth.characteristic.serial_number_string
|
||||
// Block access to standardized unique identifiers, for privacy reasons.
|
||||
{ new Guid("00002a25-0000-1000-8000-00805f9b34fb"), BlockListStatus.Exclude },
|
||||
|
||||
// Descriptors
|
||||
|
||||
// org.bluetooth.descriptor.gatt.client_characteristic_configuration
|
||||
// Writing to this would let a web page interfere with other pages' notifications and indications.
|
||||
{ new Guid("00002902-0000-1000-8000-00805f9b34fb"), BlockListStatus.ExcludeWrites },
|
||||
|
||||
// org.bluetooth.descriptor.gatt.server_characteristic_configuration
|
||||
// Writing to this would let a web page interfere with the broadcasted services.
|
||||
{ new Guid("00002903-0000-1000-8000-00805f9b34fb"), BlockListStatus.ExcludeWrites },
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// Possible values for an item on the GATT block list.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum BlockListStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// This UUID is not blocked: it may be read or written.
|
||||
/// </summary>
|
||||
Include = 0,
|
||||
|
||||
/// <summary>
|
||||
/// This UUID may be written but may not be read.
|
||||
/// </summary>
|
||||
ExcludeReads = 1,
|
||||
|
||||
/// <summary>
|
||||
/// This UUID may be read but may not be written.
|
||||
/// </summary>
|
||||
ExcludeWrites = 2,
|
||||
|
||||
/// <summary>
|
||||
/// This UUID may not be read or written.
|
||||
/// </summary>
|
||||
Exclude = ExcludeReads | ExcludeWrites,
|
||||
}
|
||||
}
|
73
scratch-link/GattHelpers.cs
Normal file
73
scratch-link/GattHelpers.cs
Normal file
|
@ -0,0 +1,73 @@
|
|||
// <copyright file="GattHelpers.cs" company="Scratch Foundation">
|
||||
// Copyright (c) Scratch Foundation. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace ScratchLink;
|
||||
|
||||
using ScratchLink.JsonRpc;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods to deal with GATT names and UUID values.
|
||||
/// Most methods correspond to a similarly named item in the Web Bluetooth specification.
|
||||
/// See <a href="https://webbluetoothcg.github.io/web-bluetooth/">here</a> for more info.
|
||||
/// </summary>
|
||||
internal static class GattHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolve a Web Bluetooth GATT service name to a canonical UUID.
|
||||
/// </summary>
|
||||
/// <see cref="ResolveUuidName"/>
|
||||
/// <param name="nameToken">A short UUID in integer form, a full UUID, or an assigned number's name.</param>
|
||||
/// <returns>The UUID associated with the name.</returns>
|
||||
public static Guid GetServiceUuid(JsonElement nameToken) => ResolveUuidName(nameToken, GattData.AssignedServices);
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a Web Bluetooth GATT "name" to a canonical UUID, using an assigned numbers table if necessary.
|
||||
/// See <a href="https://webbluetoothcg.github.io/web-bluetooth/#resolveuuidname">here</a> for more info.
|
||||
/// </summary>
|
||||
/// <param name="nameToken">A short UUID in integer form, a full UUID, or the name of an assigned number.</param>
|
||||
/// <param name="assignedNumbersTable">The table of assigned numbers to resolve integer names.</param>
|
||||
/// <returns>The UUID associated with the token.</returns>
|
||||
/// <exception cref="JsonRpc2Exception">Thrown if the name cannot be resolved.</exception>
|
||||
public static Guid ResolveUuidName(JsonElement nameToken, IReadOnlyDictionary<string, ushort> assignedNumbersTable)
|
||||
{
|
||||
if (nameToken.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
return CanonicalUuid(nameToken.GetUInt32());
|
||||
}
|
||||
|
||||
var name = nameToken.GetString();
|
||||
|
||||
// Web Bluetooth demands an exact match to this regex but the .NET Guid constructor is more permissive.
|
||||
// See https://webbluetoothcg.github.io/web-bluetooth/#valid-uuid
|
||||
var validGuidRegex = new Regex("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
|
||||
if (validGuidRegex.IsMatch(name))
|
||||
{
|
||||
return new Guid(name);
|
||||
}
|
||||
|
||||
// TODO: does Windows / .NET really have no built-in call for this?
|
||||
if (assignedNumbersTable.TryGetValue(name, out var id))
|
||||
{
|
||||
return CanonicalUuid(id);
|
||||
}
|
||||
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.InvalidParams($"unknown or invalid GATT name: {nameToken}"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a full UUID given a 16-bit or 32-bit "short UUID" alias.
|
||||
/// See <a href="https://webbluetoothcg.github.io/web-bluetooth/#dom-bluetoothuuid-canonicaluuid">here</a> for
|
||||
/// more info.
|
||||
/// </summary>
|
||||
/// <param name="alias">A 16- or 32-bit UUID alias.</param>
|
||||
/// <returns>The associated canonical UUID.</returns>
|
||||
public static Guid CanonicalUuid(uint alias)
|
||||
{
|
||||
return new Guid(alias, 0x0000, 0x1000, 0x80, 0x00, 0x00, 0x80, 0x5f, 0x9b, 0x34, 0xfb);
|
||||
}
|
||||
}
|
22
scratch-link/Platforms/Windows/WinBLESession.cs
Normal file
22
scratch-link/Platforms/Windows/WinBLESession.cs
Normal file
|
@ -0,0 +1,22 @@
|
|||
// <copyright file="WinBLESession.cs" company="Scratch Foundation">
|
||||
// Copyright (c) Scratch Foundation. All rights reserved.
|
||||
// </copyright>
|
||||
|
||||
namespace ScratchLink.Platforms.Windows;
|
||||
|
||||
using System.Net.WebSockets;
|
||||
|
||||
/// <summary>
|
||||
/// Implements a BLE session on Windows.
|
||||
/// </summary>
|
||||
internal class WinBLESession : BLESession
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="WinBLESession"/> class.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="BLESession.BLESession(WebSocketContext)"/>
|
||||
public WinBLESession(WebSocketContext context)
|
||||
: base(context)
|
||||
{
|
||||
}
|
||||
}
|
|
@ -14,6 +14,13 @@ internal class WindowsSessionManager : SessionManager
|
|||
/// <inheritdoc/>
|
||||
protected override Session MakeNewSession(WebSocketContext webSocketContext)
|
||||
{
|
||||
return new Session(webSocketContext);
|
||||
var requestPath = webSocketContext.RequestUri.AbsolutePath;
|
||||
return requestPath switch
|
||||
{
|
||||
"/scratch/ble" => new WinBLESession(webSocketContext),
|
||||
|
||||
// for unrecognized paths, return a base Session for debugging
|
||||
_ => new Session(webSocketContext),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ using System.Text.Json;
|
|||
|
||||
using JsonRpcMethodHandler = Func<
|
||||
string, // method name
|
||||
object, // params / args
|
||||
System.Text.Json.JsonElement?, // params / args
|
||||
Task<object> // return value - must be JSON-serializable
|
||||
>;
|
||||
|
||||
|
@ -58,7 +58,7 @@ internal class Session : IDisposable
|
|||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Session"/> class.
|
||||
/// </summary>
|
||||
/// <param name="context">The WebSocket context which this Session will use for communication.</param>
|
||||
/// <param name="context">The WebSocket context which this session will use for communication.</param>
|
||||
public Session(WebSocketContext context)
|
||||
{
|
||||
this.context = context;
|
||||
|
@ -109,7 +109,7 @@ internal class Session : IDisposable
|
|||
/// <param name="methodName">The name of the method called (expected: "getVersion").</param>
|
||||
/// <param name="args">Any arguments passed to the method by the caller (expected: none).</param>
|
||||
/// <returns>A string representing the protocol version.</returns>
|
||||
protected Task<object> HandleGetVersion(string methodName, object args)
|
||||
protected Task<object> HandleGetVersion(string methodName, JsonElement? args)
|
||||
{
|
||||
return Task.FromResult<object>(new Dictionary<string, string>
|
||||
{
|
||||
|
@ -123,7 +123,7 @@ internal class Session : IDisposable
|
|||
/// <param name="methodName">The name of the method called (expected: "pingMe").</param>
|
||||
/// <param name="args">Any arguments passed to the method by the caller (expected: none).</param>
|
||||
/// <returns>The string "willPing".</returns>
|
||||
protected Task<object> HandlePingMe(string methodName, object args)
|
||||
protected Task<object> HandlePingMe(string methodName, JsonElement? args)
|
||||
{
|
||||
var cancellationToken = this.cancellationTokenSource.Token;
|
||||
Task.Run(async () =>
|
||||
|
@ -223,7 +223,7 @@ internal class Session : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private Task<object> HandleUnrecognizedMethod(string methodName, object args)
|
||||
private Task<object> HandleUnrecognizedMethod(string methodName, JsonElement? args)
|
||||
{
|
||||
throw new JsonRpc2Exception(JsonRpc2Error.MethodNotFound(methodName));
|
||||
}
|
||||
|
@ -237,7 +237,7 @@ internal class Session : IDisposable
|
|||
|
||||
try
|
||||
{
|
||||
result = await handler(request.Method, request.Params);
|
||||
result = await handler(request.Method, request.Params as JsonElement?);
|
||||
}
|
||||
catch (JsonRpc2Exception e)
|
||||
{
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue