| name | shiny-beacons |
| description | iBeacon and Eddystone ranging, background region monitoring, and broadcasting for .NET MAUI, iOS, Android, macOS, Windows, Linux and Blazor using Shiny.Beacons |
| auto_invoke | true |
| triggers | ["beacon","beacons","ibeacon","eddystone","proximity","proximity uuid","beacon region","beacon ranging","beacon monitoring","beacon advertising","beacon broadcasting","major minor","rssi","tx power","measured power","path loss","beacon distance","estimote","kontakt.io","altbeacon","Shiny.Beacons","IBeaconRangingManager","IBeaconMonitoringManager","IBeaconMonitorDelegate","IEddystoneScanner","IBeaconBroadcaster","BeaconRegion","BeaconRegionState","BeaconRangingOptions","BeaconIdentity","EddystoneUid","EddystoneFrame","[Truncated]"] |
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
services.AddBeaconRanging();
services.AddBeaconMonitoring<MyBeaconMonitorDelegate>();
services.AddEddystoneScanning();
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 =>
{
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));
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}");
}
var access = await monitoring.RequestAccess();
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:
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 the AccessState before 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.
BeaconRegion requires a unique Identifier and a UUID. Major is optional; Minor requires
Major. 0 is a legal value for both — do not treat it as "unset".
- Use
IBeaconMonitorDelegate for background transitions, never a subscription. The app may not
be running when the transition fires.
- Bind lists through
ManagedBeaconScan, not through raw WhenBeaconRanged output.
- Prefer
Beacon.Distance over Beacon.Rssi in user-facing code. Rssi is the raw single-packet
reading and is very noisy; Distance is computed from filtered signal.
Beacon.TxPower is null on Apple platforms. CoreLocation never surfaces the raw advertisement.
Do not write code that requires it cross-platform.
- Do not compare
Beacon records for identity — every observation differs in RSSI and timestamp.
Use Beacon.Identity (a BeaconIdentity of UUID/major/minor).
- Use
IBeaconPacket (in Shiny.BluetoothLE) to read or build a raw iBeacon payload. Never
hand-roll it with Guid.ToByteArray() or BitConverter.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
{
DistanceEstimator = new PathLossDistanceEstimator(3.0),
RssiFilterWindow = TimeSpan.FromSeconds(10),
DefaultTxPower = -59,
ImmediateThreshold = 0.5,
NearThreshold = 3.0,
RegionExitTimeout = TimeSpan.FromSeconds(30)
});
PathLossDistanceEstimator (default) — d = 10^((txPower - rssi) / (10n)). Hardware-neutral
and predictable. Raise n towards 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
IBeaconDistanceEstimator for 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.Distance is metres; a negative value means unknown, not "very close".
EddystoneUid is a value type; Namespace is 20 hex chars, Instance is 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.
AccessState is 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.NotSupported and throws
PlatformNotSupportedException from StartMonitoring. This is deliberate so shared startup code
runs unchanged; branch on CurrentStatus rather than on OperatingSystem.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.
startAdvertising accepts only a local name and service
UUIDs. StartEddystoneUid/StartEddystoneUrl throw PlatformNotSupportedException there.
- 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.
bluetoothd limits how
many advertising instances are active at once (LEAdvertisingManager1.SupportedInstances); when it
is full, RegisterAdvertisement fails and the exception carries BlueZ's own reason.
- Blazor needs
navigator.bluetooth.requestLEScan, which is Chromium-only and behind
chrome://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 ranging
NSLocationAlwaysAndWhenInUseUsageDescription — required for monitoring
NSBluetoothAlwaysUsageDescription — 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" />
<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
Major cuts the scanning work and the false positives.
- Treat
Distance as an estimate with metres of error, not a measurement. Design around
Proximity buckets or "closest beacon wins" rather than absolute distances.
- Calibrate
DefaultTxPower against your actual hardware if the beacons advertise 0. The published
measured power of a beacon at one metre is the number to use.
- Raise
RegionExitTimeout rather than lower it if regions flap. The default 30s already tolerates a
1 Hz beacon interval and a throttled background scan.
- Check
CurrentStatus before offering beacon features in the UI, so a macOS or tvOS build degrades
gracefully instead of throwing.