# Sensors

> expo-sensors wrapped for every SymbioteNative adapter — accelerometer, gyroscope, magnetometer, barometer, light sensor, device motion, and step counting.

`@symbiote-native/sensors` wraps [`expo-sensors`](https://github.com/expo/expo/tree/main/packages/expo-sensors)
— Accelerometer, Barometer, DeviceMotion, Gyroscope, LightSensor, Magnetometer,
MagnetometerUncalibrated, and Pedometer — so every SymbioteNative adapter can use them. Unlike the
[slider](/docs/packages/slider/) (a native **view**) or [splash screen](/docs/packages/splash-screen/)
(one imperative **TurboModule**), `expo-sensors` is built on `expo-modules-core`: every sensor is a
pure `EventEmitter` + async-function surface, with no Fabric view or `ViewConfig` involved at all.

| OS platform | Support |
| ----------- | ------- |
| iOS         | ✅ live |
| Android     | ✅ live |

| Framework adapter | Support |
| ----------------- | ------- |
| React             | ✅ live |
| Vue               | ✅ live |
| Angular           | ✅ live |
| Svelte            | ✅ live |
| Solid             | ✅ live |

## Installation

```sh
npm install @symbiote-native/sensors
```

Scaffolding or extending a SymbioteNative app? `npx @symbiote-native/cli new --sensors` (or
`add --sensors` in an existing app) installs and wires this for you — see
[`@symbiote-native/cli`](https://github.com/OneEyed1366/symbiote-native/tree/master/packages/cli).

`expo-sensors` and `expo-modules-core` come along as regular dependencies, pinned to exact
versions — never install either yourself, and never add the `expo` meta-package to your project
(it bundles its own Metro/Babel pipeline, which conflicts with this project's own).

<Aside type="danger" title="Native setup is required before first use">
  `expo-sensors`' native code is discovered by `expo-modules-autolinking`, a
  different mechanism from the `react-native.config.cjs`/podspec autolinking
  every other SymbioteNative wrapper uses — and it isn't the standard Expo setup
  flow either, since this project never installs the `expo` meta-package. Follow
  [How to: wire up an Expo native
  module](/docs/howtos/expo-native-module-setup/) once per app first; it covers
  this package and every future `expo-modules-core` package with zero further
  native changes. DeviceMotion and Pedometer need an `NSMotionUsageDescription`
  string on iOS; `symbiote-expo-link` inserts a generic default when that key is
  absent, and leaves your own wording alone if you write one.
</Aside>

## Usage

<Aside type="note">
  Every hook/composable/`connect()` below returns `null` until the first native
  reading arrives. Check `isAvailableAsync()` separately if you need to tell
  "not available on this device" apart from "no reading yet" — see
  [Notes](#notes) below.
</Aside>

### A `DeviceSensor` (Accelerometer, Barometer, DeviceMotion, Gyroscope, LightSensor, Magnetometer, MagnetometerUncalibrated)

<Tabs syncKey="framework">
  <TabItem label="React">
    ```tsx
    import { useAccelerometer } from '@symbiote-native/sensors/react';

    export default function AccelerometerReading() {
      const accelerometer = useAccelerometer();

      return (
        <text>
          {accelerometer && `x ${accelerometer.x} · y ${accelerometer.y} · z ${accelerometer.z}`}
        </text>
      );
    }
    ```

  </TabItem>
  <TabItem label="Vue">
    ```vue
    <script setup lang="ts">
    import { useAccelerometer } from '@symbiote-native/sensors/vue';

    const accelerometer = useAccelerometer();
    </script>

    <template>
      <text>{{ accelerometer && `x ${accelerometer.x} · y ${accelerometer.y} · z ${accelerometer.z}` }}</text>
    </template>
    ```

  </TabItem>
  <TabItem label="Angular">
    ```ts
    import { Component, inject } from '@angular/core';
    import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';
    import { AccelerometerService } from '@symbiote-native/sensors/angular';

    @Component({
      standalone: true,
      imports: [SYMBIOTE_ELEMENTS],
      template: `<text>{{ accelerometer()?.x }} · {{ accelerometer()?.y }} · {{ accelerometer()?.z }}</text>`,
    })
    export class AccelerometerReading {
      readonly accelerometer = inject(AccelerometerService).connect();
    }
    ```

  </TabItem>
  <TabItem label="Svelte">
    ```svelte
    <script lang="ts">
      import { useAccelerometer } from '@symbiote-native/sensors/svelte';

      const accelerometer = useAccelerometer(); // { readonly current: IAccelerometerMeasurement | null }
    </script>

    <text>
      {accelerometer.current && `x ${accelerometer.current.x} · y ${accelerometer.current.y} · z ${accelerometer.current.z}`}
    </text>
    ```

  </TabItem>
  <TabItem label="Solid">
    ```tsx
    import { createAccelerometer } from '@symbiote-native/sensors/solid';

    export function AccelerometerReading() {
      const accelerometer = createAccelerometer();

      return (
        <text>
          {accelerometer() && `x ${accelerometer()?.x} · y ${accelerometer()?.y} · z ${accelerometer()?.z}`}
        </text>
      );
    }
    ```

    `createAccelerometer` returns an `Accessor<IAccelerometerMeasurement | null>`: call it,
    `accelerometer()`, everywhere you'd read the value. A component body runs once, so a
    destructured snapshot would freeze at `null`.

  </TabItem>
</Tabs>

Every other `DeviceSensor` (Barometer, DeviceMotion, Gyroscope, LightSensor, Magnetometer,
MagnetometerUncalibrated) follows the exact same shape — swap `Accelerometer`/`accelerometer` for
the sensor's own name.

### Pedometer — free functions, no shared instance

Unlike every other sensor, upstream `Pedometer` has no shared instance to hang `addListener`/
`setUpdateInterval` off — it ships as plain functions instead:

<Tabs syncKey="framework">
  <TabItem label="React">
    ```tsx
    import { usePedometer } from '@symbiote-native/sensors/react';

    export default function StepCount() {
      const pedometer = usePedometer(); // { steps: number } | null, live-subscribed

      return <text>{pedometer && `${pedometer.steps} steps`}</text>;
    }
    ```

  </TabItem>
  <TabItem label="Vue">
    ```vue
    <script setup lang="ts">
    import { usePedometer } from '@symbiote-native/sensors/vue';

    const pedometer = usePedometer();
    </script>

    <template>
      <text>{{ pedometer && `${pedometer.steps} steps` }}</text>
    </template>
    ```

  </TabItem>
  <TabItem label="Angular">
    ```ts
    import { Component, inject } from '@angular/core';
    import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';
    import { PedometerService } from '@symbiote-native/sensors/angular';

    @Component({
      standalone: true,
      imports: [SYMBIOTE_ELEMENTS],
      template: `<text>{{ pedometer()?.steps }} steps</text>`,
    })
    export class StepCount {
      readonly pedometer = inject(PedometerService).connect();
    }
    ```

  </TabItem>
  <TabItem label="Svelte">
    ```svelte
    <script lang="ts">
      import { usePedometer } from '@symbiote-native/sensors/svelte';

      const pedometer = usePedometer(); // { readonly current: IPedometerResult | null }
    </script>

    <text>{pedometer.current && `${pedometer.current.steps} steps`}</text>
    ```

  </TabItem>
  <TabItem label="Solid">
    ```tsx
    import { createPedometer } from '@symbiote-native/sensors/solid';

    export function StepCount() {
      const pedometer = createPedometer(); // Accessor<IPedometerResult | null>

      return <text>{pedometer() && `${pedometer()?.steps} steps`}</text>;
    }
    ```

  </TabItem>
</Tabs>

The one-shot functions (`getStepCountAsync`, `isAvailableAsync`, the permission functions) are
already framework-agnostic — import them straight from the package root, on any adapter:

```ts
import { getStepCountAsync, isAvailableAsync } from '@symbiote-native/sensors';

const available = await isAvailableAsync();
const { steps } = await getStepCountAsync(startDate, endDate); // iOS only in practice
```

## API

### Sensor object (`Accelerometer`, `Barometer`, `DeviceMotion`, `Gyroscope`, `LightSensor`, `Magnetometer`, `MagnetometerUncalibrated`)

Each is a shared singleton, importable from the package root, that every hook/composable/service
subscribes to underneath.

| Method                                                     | Signature                                                | Description                                                                                                                  |
| ---------------------------------------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `addListener`                                              | `(listener: (measurement) => void) => EventSubscription` | Subscribes to live readings; call `.remove()` on the returned subscription to unsubscribe                                    |
| `setUpdateInterval`                                        | `(intervalMs: number) => void`                           | Requests a new native sampling interval; warns and no-ops where the platform/sensor doesn't support it                       |
| `isAvailableAsync`                                         | `() => Promise<boolean>`                                 | Whether this sensor's hardware is present and enabled on the current device — always `false` on a simulator with no real IMU |
| `getPermissionsAsync` / `requestPermissionsAsync`          | `() => Promise<PermissionResponse>`                      | Reads/asks for the platform permission this sensor needs; a sensor needing none resolves already-granted                     |
| `hasListeners` / `getListenerCount` / `removeAllListeners` | —                                                        | Inspect or clear this sensor's own subscriptions                                                                             |

### Hooks / composables / runes / services / primitives

| React (`/react`)              | Vue (`/vue`)                  | Angular (`/angular`)                        | Svelte (`/svelte`)            | Solid (`/solid`)                 | Signature                                         | Returns                                             |
| ----------------------------- | ----------------------------- | ------------------------------------------- | ----------------------------- | -------------------------------- | ------------------------------------------------- | --------------------------------------------------- |
| `useAccelerometer`            | `useAccelerometer`            | `AccelerometerService.connect()`            | `useAccelerometer`            | `createAccelerometer`            | `(updateIntervalMs?: number \| Accessor<number>)` | Live `IAccelerometerMeasurement \| null`            |
| `useBarometer`                | `useBarometer`                | `BarometerService.connect()`                | `useBarometer`                | `createBarometer`                | `(updateIntervalMs?: number \| Accessor<number>)` | Live `IBarometerMeasurement \| null`                |
| `useDeviceMotion`             | `useDeviceMotion`             | `DeviceMotionService.connect()`             | `useDeviceMotion`             | `createDeviceMotion`             | `(updateIntervalMs?: number \| Accessor<number>)` | Live `IDeviceMotionMeasurement \| null`             |
| `useGyroscope`                | `useGyroscope`                | `GyroscopeService.connect()`                | `useGyroscope`                | `createGyroscope`                | `(updateIntervalMs?: number \| Accessor<number>)` | Live `IGyroscopeMeasurement \| null`                |
| `useLightSensor`              | `useLightSensor`              | `LightSensorService.connect()`              | `useLightSensor`              | `createLightSensor`              | `(updateIntervalMs?: number \| Accessor<number>)` | Live `ILightSensorMeasurement \| null`              |
| `useMagnetometer`             | `useMagnetometer`             | `MagnetometerService.connect()`             | `useMagnetometer`             | `createMagnetometer`             | `(updateIntervalMs?: number \| Accessor<number>)` | Live `IMagnetometerMeasurement \| null`             |
| `useMagnetometerUncalibrated` | `useMagnetometerUncalibrated` | `MagnetometerUncalibratedService.connect()` | `useMagnetometerUncalibrated` | `createMagnetometerUncalibrated` | `(updateIntervalMs?: number \| Accessor<number>)` | Live `IMagnetometerUncalibratedMeasurement \| null` |
| `usePedometer`                | `usePedometer`                | `PedometerService.connect()`                | `usePedometer`                | `createPedometer`                | `()` — no interval, Pedometer has none            | Live `IPedometerResult \| null`                     |

React/Vue return the measurement directly; Angular's `connect()` returns a `Signal` — read it as
`accelerometer()` in code or `accelerometer()` in a template. Svelte's rune returns a boxed getter,
`{ readonly current: IAccelerometerMeasurement | null }` — read it as `.current`, same reason Vue's
`Ref` needs `.value`: Svelte 5 reactivity is lexically scoped to the declaring module and does not
survive being returned as a raw value from a plain function. Solid's primitive returns the same
plain `Accessor<... | null>` shape covered above. Passing `updateIntervalMs` re-subscribes with
the new native sampling rate whenever it changes across renders on React; Vue and Svelte both
apply it once at subscribe time and never react to a later change, since neither takes it as a
getter. Solid takes either form: a plain number applies once at subscribe time like Vue/Svelte,
or an `Accessor<number | undefined>` re-applies the rate on every later change, with the
mount-time value still applying synchronously before the first sample.

### Measurement shapes

| Sensor                                  | Fields                                                                                                | Units                                        |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------- |
| Accelerometer                           | `x`, `y`, `z`, `timestamp`                                                                            | g-force (1g = 9.81 m/s²)                     |
| Gyroscope                               | `x`, `y`, `z`, `timestamp`                                                                            | rad/s                                        |
| Magnetometer / MagnetometerUncalibrated | `x`, `y`, `z`, `timestamp`                                                                            | µT                                           |
| Barometer                               | `pressure`, `relativeAltitude?`, `timestamp`                                                          | hPa; `relativeAltitude` (meters) is iOS-only |
| LightSensor                             | `illuminance`, `timestamp`                                                                            | lux — Android-only, see [Notes](#notes)      |
| DeviceMotion                            | `acceleration`, `accelerationIncludingGravity`, `rotation`, `rotationRate`, `interval`, `orientation` | see below                                    |
| Pedometer                               | `steps`                                                                                               | count                                        |

`DeviceMotion`'s nested fields: `acceleration`/`accelerationIncludingGravity`/`rotationRate` are
`{ x, y, z, timestamp }` (m/s² for acceleration, deg/s for `rotationRate`); `rotation` is
`{ alpha, beta, gamma, timestamp }` in degrees; `interval` is milliseconds; `orientation` is a
`DeviceMotionOrientation` enum (`Portrait`/`RightLandscape`/`UpsideDown`/`LeftLandscape`).

### Pedometer free functions

| Signature                                                               | Description                                                                                               |
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `watchStepCount(callback: (result) => void): EventSubscription`         | Live step-count subscription — the same primitive `usePedometer` wraps                                    |
| `getStepCountAsync(start: Date, end: Date): Promise<{ steps: number }>` | One-shot historical step count between two dates — iOS only in practice, Android has no native equivalent |
| `isAvailableAsync(): Promise<boolean>`                                  | Whether step counting is available on this device                                                         |
| `getPermissionsAsync` / `requestPermissionsAsync`                       | `() => Promise<PermissionResponse>`                                                                       |

## Notes

<Aside type="note" title="Simulators have no real IMU or step-counter hardware">
  `isAvailableAsync()` genuinely returns `false` on an iOS Simulator for every
  CoreMotion-backed sensor and `CMPedometer`. On an Android emulator, readings
  drift on their own even at rest, since the emulator synthesizes motion data
  instead of returning frozen zeros. Neither is a wiring bug — verify sensor
  behavior on a real device.
</Aside>

<Aside
  type="caution"
  title="Guard DeviceMotion's nested fields, not just the top-level object"
>
  `rotation`/`acceleration`/`accelerationIncludingGravity`/`rotationRate` can be
  absent from the very first event — the underlying platform sensor hasn't
  reported yet. Guard the nested field itself (`deviceMotion?.rotation && ...`),
  not just `deviceMotion && ...` — an unguarded nested read throws, and in React
  Native that throw can silently blank the screen with no visible error anywhere
  (no LogBox redbox, no logcat exception).
</Aside>

<Aside type="tip" title="rotation.beta can read NaN near pitch ±90°">
  An inherent Euler-angle gimbal-lock singularity in the platform's
  device-attitude math (the same one `DeviceOrientationEvent.beta` has on the
  web) — not a bug in this package. Check `Number.isNaN(...)` if you display it
  directly.
</Aside>

`LightSensor` is Android-only — `expo-sensors` ships no iOS light-sensor implementation, so
`isAvailableAsync()` always resolves `false` on iOS.

## Common questions

- **No data in the simulator or emulator.** Sensors need a physical device.
- **iOS DeviceMotion or Pedometer fails.** `NSMotionUsageDescription` must be in Info.plist.
- **Updates are capped at 200 Hz on Android 12+.** Add the `HIGH_SAMPLING_RATE_SENSORS` permission
  to AndroidManifest.xml for a faster rate.
- **How do I change the rate?** Call the sensor's `setUpdateInterval` with milliseconds.
- **Permission calls.** Each sensor exposes `getPermissionsAsync` and `requestPermissionsAsync`.

Sources: [Expo docs: Accelerometer](https://docs.expo.dev/versions/latest/sdk/accelerometer/),
[Expo docs: DeviceMotion](https://docs.expo.dev/versions/latest/sdk/devicemotion/),
[expo/expo#12501](https://github.com/expo/expo/pull/12501),
[expo-sensors README](https://github.com/expo/expo/blob/main/packages/expo-sensors/README.md).

## How the wrapper works

`@symbiote-native/sensors` ships **zero React/Vue/Angular/Svelte/Solid logic in `expo-sensors` itself** — that
package's own JS hard-imports the `expo` meta-package (which this project never installs), so
every sensor's `DeviceSensor` base class and per-sensor subclass is hand-ported, verbatim, into
this package's own `core/`, changing only the one import line that now pulls
`PermissionResponse`/`PermissionStatus` from `expo-modules-core` instead of `expo`:

```
packages/sensors/src/
├── core/                DeviceSensor base class + one class per sensor; native/ resolves each
│                        sensor's native module by name via expo-modules-core's
│                        requireNativeModule. Pedometer is free functions instead — upstream
│                        has no shared instance for it.
├── react/hooks/         @symbiote-native/sensors/react   — useAccelerometer, useBarometer, ...
├── vue/composables/     @symbiote-native/sensors/vue     — useAccelerometer, useBarometer, ... (same names)
├── svelte/runes/        @symbiote-native/sensors/svelte  — useAccelerometer, useBarometer, ... (same names)
└── angular/services/    @symbiote-native/sensors/angular — AccelerometerService, BarometerService, ...
```

Each adapter's hook/composable/rune/service is a thin lifecycle wrapper — subscribe on mount,
unsubscribe on unmount — over the same `core` singleton; the subscription, permission, and
update-interval logic is written once and shared by all four, the same logic/lifecycle split as
every other SymbioteNative component (see [how it works](/docs/how-it-works/)). The native code
itself is never vendored or copied — `expo-modules-autolinking` resolves it straight out of
`node_modules` (see [the native setup guide](/docs/howtos/expo-native-module-setup/)).
