# Shiny Beacons

> iBeacon and Eddystone ranging, background region monitoring, and broadcasting for .NET MAUI, iOS, Android, macOS, Windows, Linux and Blazor using Shiny.Beacons

- Skill: `shinyorg/shiny-beacons` (Agent Skill)
- Install (CLI): `npx skillmds@latest add shinyorg/shiny-beacons`
- Raw SKILL.md: https://api.skillmd.com/api/skills/shinyorg/shiny-beacons/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: shinyorg (https://skillmd.com/u/shinyorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/shinyorg/shiny-beacons

---


# 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

```csharp
// 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

```csharp
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:

```csharp
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

```csharp
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

```csharp
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

```csharp
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

1. **Always `RequestAccess()` and check the `AccessState` before ranging, monitoring or broadcasting.**
2. **Inject the managers** (`IBeaconRangingManager`, `IBeaconMonitoringManager`, `IEddystoneScanner`,
   `IBeaconBroadcaster`) — never instantiate them.
3. **Dispose the ranging subscription.** Ranging runs until the last subscriber goes away; leaving a
   subscription alive keeps the radio (and on Apple, CoreLocation) busy.
4. **`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".
5. **Use `IBeaconMonitorDelegate` for background transitions**, never a subscription. The app may not
   be running when the transition fires.
6. **Bind lists through `ManagedBeaconScan`**, not through raw `WhenBeaconRanged` output.
7. **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.
8. **`Beacon.TxPower` is null on Apple platforms.** CoreLocation never surfaces the raw advertisement.
   Do not write code that requires it cross-platform.
9. **Do not compare `Beacon` records for identity** — every observation differs in RSSI and timestamp.
   Use `Beacon.Identity` (a `BeaconIdentity` of UUID/major/minor).
10. **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:

```csharp
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. 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`**
```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 `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.

