Shiny Beacons
iBeacon and Eddystone ranging, background region monitoring, and broadcasting.
When to Use This Skill
Use this skill when the user needs to:
- Detect nearby iBeacons and estimate the distance to them
- Be notified when the device enters or leaves a beacon region, including in the background
- Read Eddystone UID, URL or TLM (telemetry) frames
- Turn the device itself into an iBeacon or Eddystone beacon
- Tune beacon distance/proximity accuracy
- Diagnose why beacon distances are jumping around, or why a region never fires
Library Overview
| Property | Value |
|---|---|
| NuGet | Shiny.Beacons |
| Namespace | Shiny.Beacons |
| Platforms | iOS, Mac Catalyst, macOS, Android, Windows, Linux, Blazor WebAssembly. No tvOS target — tvOS binds no CLBeacon* type at all |
| DI Namespace | Shiny (extension methods on IServiceCollection) |
| Depends on | Shiny.BluetoothLE, Shiny.BluetoothLE.Hosting |
The one thing to understand first
iBeacon and Eddystone travel through different parts of a BLE advertisement, and that dictates everything else.
- iBeacon is manufacturer data under Apple's company id
0x004C. CoreBluetooth strips it from every scan result on iOS, Mac Catalyst and macOS. There is no scan configuration that gets it back. So on Apple platforms iBeacon goes through CoreLocation and costs a location permission. Everywhere else Shiny parses the advertisement directly and it costs a Bluetooth permission. - Eddystone is service data under UUID
0xFEAA, which CoreBluetooth passes through untouched. It behaves identically on every platform, Apple included.
Never tell a user that an iOS app can range iBeacons over IBleManager.Scan(). It cannot.
Setup
// Foreground ranging - "which beacons are near me and how far?"
services.AddBeaconRanging();
// Background region monitoring - "tell me when I enter/leave"
services.AddBeaconMonitoring<MyBeaconMonitorDelegate>();
// Eddystone frames (works everywhere, including Apple)
services.AddEddystoneScanning();
// Become a beacon
services.AddBeaconBroadcasting();
All four accept an optional BeaconRangingOptions. AddBeaconMonitoring also wires the default
repository so regions survive a process restart.
On Linux or any plain-.NET host, register a IBleManager first (AddBluetoothLE() from
Shiny.BluetoothLE.Linux) — the beacon registrations throw a named error if none is present.
Ranging
public class BeaconViewModel(IBeaconRangingManager ranging)
{
IDisposable? sub;
public async Task Start()
{
var access = await ranging.RequestAccess();
if (access != AccessState.Available)
return;
var region = new BeaconRegion("store-front", Guid.Parse("B9407F30-F5F8-466E-AFF9-25556B57FE6D"));
this.sub = ranging
.WhenBeaconRanged(region)
.Subscribe(beacon =>
{
// beacon.Distance is metres, beacon.Proximity is the bucket
Console.WriteLine($"{beacon.Major}/{beacon.Minor}: {beacon.Distance:N1}m ({beacon.Proximity})");
});
}
public void Stop() => this.sub?.Dispose();
}
Ranging starts on first subscription and stops when the last subscription is disposed. It is a foreground activity — use monitoring for anything that has to work with the app closed.
ManagedBeaconScan for UI
WhenBeaconRanged emits once per advertisement, which is far too chatty to bind a list to. Use the
managed scan, which keeps one entry per beacon and updates it in place:
var scan = ranging.CreateManagedScan();
await scan.Start(region, scheduler, clearTime: TimeSpan.FromSeconds(15));
// scan.Beacons is an INotifyReadOnlyCollection<ManagedBeacon>
clearTime drops beacons that stop advertising, so the list reflects what is actually in range.
Monitoring
public class MyBeaconMonitorDelegate(INotificationManager notifications) : IBeaconMonitorDelegate
{
public Task OnStatusChanged(BeaconRegionState newStatus, BeaconRegion region)
=> notifications.Send("Beacons", $"{region.Identifier}: {newStatus}");
}
// somewhere in your app
var access = await monitoring.RequestAccess(); // asks for ALWAYS location on Apple
if (access == AccessState.Available)
await monitoring.StartMonitoring(new BeaconRegion("store-front", uuid, major: 1));
Monitored regions are persisted and re-armed on the next launch. Monitoring reports only entry/exit — it never reports distance. Range the region in the foreground when you need that.
Eddystone
var access = await scanner.RequestAccess();
scanner.WhenFrameReceived().Subscribe(frame =>
{
switch (frame)
{
case EddystoneUidFrame uid:
Console.WriteLine($"{uid.Uid.Namespace}/{uid.Uid.Instance} at {uid.Distance:N1}m");
break;
case EddystoneUrlFrame url:
Console.WriteLine(url.Url);
break;
case EddystoneTlmFrame { IsEncrypted: false } tlm:
// BatteryVolts is null on a mains-powered beacon, TemperatureCelsius null with no sensor
Console.WriteLine($"{tlm.BatteryVolts}V {tlm.TemperatureCelsius}C up {tlm.Uptime}");
break;
}
});
Correlate a TLM frame with the beacon's UID/URL frame using frame.PeripheralId — a beacon
interleaves frame types, and only the peripheral identifier ties them together.
EID frames surface their raw rotating identifier as EddystoneEidFrame.EphemeralId. Resolving one
back to a registered beacon needs the deployment's identity key and the Curve25519/AES-EAX
derivation from the spec, which this library does not implement.
Broadcasting
await broadcaster.StartIBeacon(uuid, major: 1, minor: 2);
await broadcaster.StartEddystoneUid(EddystoneUid.Parse("0102030405060708090A", "0B0C0D0E0F10"));
await broadcaster.StartEddystoneUrl("https://shinylib.net/");
broadcaster.Stop();
Only one advertisement runs at a time; starting a second replaces the first.
Code Generation Instructions
- Always
RequestAccess()and check theAccessStatebefore ranging, monitoring or broadcasting. - Inject the managers (
IBeaconRangingManager,IBeaconMonitoringManager,IEddystoneScanner,IBeaconBroadcaster) — never instantiate them. - Dispose the ranging subscription. Ranging runs until the last subscriber goes away; leaving a subscription alive keeps the radio (and on Apple, CoreLocation) busy.
BeaconRegionrequires a uniqueIdentifierand a UUID.Majoris optional;MinorrequiresMajor.0is a legal value for both — do not treat it as "unset".- Use
IBeaconMonitorDelegatefor background transitions, never a subscription. The app may not be running when the transition fires. - Bind lists through
ManagedBeaconScan, not through rawWhenBeaconRangedoutput. - Prefer
Beacon.DistanceoverBeacon.Rssiin user-facing code.Rssiis the raw single-packet reading and is very noisy;Distanceis computed from filtered signal. Beacon.TxPoweris null on Apple platforms. CoreLocation never surfaces the raw advertisement. Do not write code that requires it cross-platform.- Do not compare
Beaconrecords for identity — every observation differs in RSSI and timestamp. UseBeacon.Identity(aBeaconIdentityof UUID/major/minor). - Use
IBeaconPacket(inShiny.BluetoothLE) to read or build a raw iBeacon payload. Never hand-roll it withGuid.ToByteArray()orBitConverter.GetBytes()— both are little-endian and iBeacon is big-endian throughout.
Tuning distance accuracy
Everything lives on BeaconRangingOptions, passed to the Add* call:
services.AddBeaconRanging(new BeaconRangingOptions
{
// cluttered indoor space attenuates faster than free space (2.0)
DistanceEstimator = new PathLossDistanceEstimator(3.0),
// shorter window reacts faster to movement; longer reads more steadily when stationary
RssiFilterWindow = TimeSpan.FromSeconds(10),
// when a beacon advertises no calibration value
DefaultTxPower = -59,
ImmediateThreshold = 0.5,
NearThreshold = 3.0,
RegionExitTimeout = TimeSpan.FromSeconds(30)
});
PathLossDistanceEstimator(default) —d = 10^((txPower - rssi) / (10n)). Hardware-neutral and predictable. Raisentowards 3-4 indoors.CurveFitDistanceEstimator— the Radius Networks/AltBeacon empirical fit. Use it for parity with other Android beacon stacks; its constants were fitted to one specific device.- Implement
IBeaconDistanceEstimatorfor your own model.
Samples are always fed through a windowed trimmed-mean filter first (RssiFilter), which is what
actually removes the jitter. On Apple platforms CoreLocation does its own filtering and hands back a
distance directly, so the filter and estimator are bypassed there — only the thresholds apply.
Conventions
Proximity:Unknown,Immediate(<0.5m),Near(<3m),Far.BeaconRegionState:Unknown,Entered,Exited.Beacon.Distanceis metres; a negative value means unknown, not "very close".EddystoneUidis a value type;Namespaceis 20 hex chars,Instanceis 12.- Eddystone calibrates transmit power at 0 metres, iBeacon at 1 metre — a 41 dBm difference the parser accounts for. Do not "fix" an Eddystone TxPower by comparing it to an iBeacon one.
AccessStateis from Shiny.Core:Available,Denied,Disabled,Restricted,NotSupported,Unknown.
Platform Notes
| Platform | Ranging | Monitoring | Eddystone | Broadcast |
|---|---|---|---|---|
| iOS / Mac Catalyst | CoreLocation | CLMonitor (18+), CLLocationManager below |
BLE scan | iBeacon only |
| macOS | CoreLocation | Not supported by the OS | BLE scan | iBeacon only |
| Android | BLE scan | BLE scan + foreground service | BLE scan | iBeacon + Eddystone |
| Windows | BLE scan | BLE scan | BLE scan | iBeacon + Eddystone |
| Linux | BLE scan | BLE scan | BLE scan | iBeacon + Eddystone |
| Blazor WASM | BLE scan | BLE scan | BLE scan | No |
| tvOS | — | — | — | — |
- macOS monitoring registers successfully but reports
AccessState.NotSupportedand throwsPlatformNotSupportedExceptionfromStartMonitoring. This is deliberate so shared startup code runs unchanged; branch onCurrentStatusrather than onOperatingSystem.IsMacOS(). - iOS caps an app at 20 monitored regions across beacons and geofences together. Below iOS 18 Shiny throws a named error at the cap; iOS itself would silently drop the excess.
- Apple cannot broadcast Eddystone.
startAdvertisingaccepts only a local name and service UUIDs.StartEddystoneUid/StartEddystoneUrlthrowPlatformNotSupportedExceptionthere. - Apple broadcasting stops working when backgrounded. iOS moves the advertisement into an overflow area only another iOS device explicitly scanning for the same service can read.
- Linux broadcasting needs a BlueZ that will let you register an advertisement. BlueZ calls back
into the process to read the payload, and the adapter has to be powered.
bluetoothdlimits how many advertising instances are active at once (LEAdvertisingManager1.SupportedInstances); when it is full,RegisterAdvertisementfails and the exception carries BlueZ's own reason. - Blazor needs
navigator.bluetooth.requestLEScan, which is Chromium-only and behindchrome://flags/#enable-experimental-web-platform-features. The chooser fallback reports no advertisement payload, so beacons are invisible through it.
Permissions
iOS / Mac Catalyst Info.plist
NSLocationWhenInUseUsageDescription— required for rangingNSLocationAlwaysAndWhenInUseUsageDescription— required for monitoringNSBluetoothAlwaysUsageDescription— required for Eddystone and broadcasting- Word the "always" string for what background monitoring actually does. The system shows that upgrade prompt once, so ask from the feature that needs it rather than at launch.
Android AndroidManifest.xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- monitoring only -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
The foreground service type is connectedDevice, not location — Android 14 rejects a mismatched
type, and a BLE scan is not a location activity.
Windows — bluetooth capability in the app manifest for a packaged app.
Best Practices
- Range in the foreground, monitor in the background. Do not try to keep a ranging subscription alive to approximate monitoring — it will be killed and it will drain the battery.
- Keep the region as narrow as the use case allows. A UUID-only region matches every beacon in the
deployment; adding
Majorcuts the scanning work and the false positives. - Treat
Distanceas an estimate with metres of error, not a measurement. Design aroundProximitybuckets or "closest beacon wins" rather than absolute distances. - Calibrate
DefaultTxPoweragainst your actual hardware if the beacons advertise0. The published measured power of a beacon at one metre is the number to use. - Raise
RegionExitTimeoutrather than lower it if regions flap. The default 30s already tolerates a 1 Hz beacon interval and a throttled background scan. - Check
CurrentStatusbefore offering beacon features in the UI, so a macOS or tvOS build degrades gracefully instead of throwing.