Skip to main content

Device services

Battery, display brightness, motion sensors and the vibration motor, with the APIs a React Native developer already uses. Import the Expo module names; kog build, the simulator and Studio alias them to @kog/device, the same way react-native resolves to Kog's facade:

You importResolves toReference shape
expo-battery@kog/device/batteryexpo-battery
expo-brightness@kog/device/brightnessexpo-brightness
expo-sensors@kog/device/sensorsexpo-sensors
expo-haptics@kog/device/hapticsexpo-haptics
react-native Vibration@kog/react-nativeVibration

You don't install the Expo packages. Names, enum members and values, argument orders, defaults and units match their published typings (checked against version 58 of each package), and every place Kog differs is listed under Differences from Expo.

What a board has comes from its board.yaml, and the firmware drives it:

Serviceboard.yamlSupported boards (firmware)
Battery level and charge stateperipherals.pmic (the AXP2101 PMIC and fuel gauge)Waveshare AMOLED 2.06
Brightnessdisplay.backlight with pwm: true (LEDC PWM) or kind: dcs (the AMOLED's brightness register)CYD, Waveshare 1.46B (PWM); AMOLED 2.06 (DCS)
Accelerometer, gyroscopeperipherals.imu (QMI8658) with sensors: [accelerometer, gyroscope]AMOLED 2.06; Waveshare 1.46B (axis mapping not yet verified on hardware)
Haptics and vibrationperipherals.motor: { pin, active }AMOLED 2.06

On a board without the part, the service reports itself unavailable, as described next. The simulators have every service.

When a board lacks the hardware, the call fails loudly. It throws or rejects a DeviceError whose .code is DEVICE_UNSUPPORTED or SENSOR_UNAVAILABLE, and whose message names the board and the board.yaml entry to add. Each module's isAvailableAsync() is the probe that never throws. The one exception is the battery: a board without a gauge reports Expo's own "unknown" values (level -1, BatteryState.UNKNOWN), and its listeners never fire.

Battery (expo-battery)​

import { useBatteryLevel, useBatteryState, BatteryState } from 'expo-battery';
import { Text } from 'react-native';

export function BatteryBadge() {
const level = useBatteryLevel(); // live: updates the Text when the gauge moves
const charging = useBatteryState() === BatteryState.CHARGING;
return <Text>{Math.round(level * 100)}%{charging ? ' ⚡' : ''}</Text>;
}
ExportBehaviour on Kog
getBatteryLevelAsync()0..1, or -1 when the board has no gauge.
getBatteryStateAsync()A BatteryState.
getPowerStateAsync(){ batteryLevel, batteryState, lowPowerMode }.
isAvailableAsync()true when the board has a gauge.
isLowPowerModeEnabledAsync(), isBatteryOptimizationEnabledAsync()Always false. There is no OS power-saver mode.
addBatteryLevelListener(fn)fn({ batteryLevel }) on every change. Returns { remove() }.
addBatteryStateListener(fn)fn({ batteryState }) on every change.
addLowPowerModeListener(fn)Accepted; never fires.
useBatteryLevel(), useBatteryState(), usePowerState()Live values (see below).
useLowPowerMode()Always false.
BatteryStateUNKNOWN 0, UNPLUGGED 1, CHARGING 2, FULL 3, NOT_CHARGING 4. Kog never reports NOT_CHARGING.

The firmware reads the gauge every 2 seconds and posts a change to the UI task; a level-only change reaches listeners at most once a second. On the AXP2101, a board running from USB with no battery attached reads level -1 and UNKNOWN; plugged in and charging (or topping up) reads CHARGING, the charger's "done" state reads FULL, and plugged in with the charger stopped (a fault or a temperature limit) reads UNPLUGGED, as Android's "not charging" does in Expo. One native watch is shared by every listener and hook, and it is released when the last one goes away.

Hooks are live values. A Kog component body runs once, so the compiler treats useBatteryLevel(), useBatteryState(), usePowerState() and useLowPowerMode() like useColorScheme(): const level = useBatteryLevel(), and anything derived from it in the component body, stays current, as it would after a re-render in React. The hook keeps its battery watch for the component's lifetime and releases it on unmount.

Brightness (expo-brightness)​

import * as Brightness from 'expo-brightness';

if (await Brightness.isAvailableAsync()) await Brightness.setBrightnessAsync(0.3);
ExportBehaviour on Kog
getBrightnessAsync() / setBrightnessAsync(v)0..1; values are clamped and NaN is a TypeError, as in Expo. On a board without a dimmable backlight, the getter returns 1 and the setter rejects DEVICE_UNSUPPORTED ("… board.yaml display.backlight has no pwm/dcs control").
getSystemBrightnessAsync() / setSystemBrightnessAsync(v)The same value as above. One app owns the device, so the app brightness is the system brightness.
restoreSystemBrightnessAsync()Resolves; there is no separate app override to drop.
isUsingSystemBrightnessAsync()Always true.
getSystemBrightnessModeAsync()Always BrightnessMode.MANUAL.
setSystemBrightnessModeAsync(mode)MANUAL resolves. AUTOMATIC rejects DEVICE_UNSUPPORTED, because Kog has no ambient-light controller; read a sensor and call setBrightnessAsync yourself.
addBrightnessListener(fn)fn({ brightness }) whenever the app changes it, on every target (iOS-only in Expo).
getPermissionsAsync(), requestPermissionsAsync(), usePermissions()Always granted.
BrightnessMode, PermissionStatusExpo's enums.

Motion sensors (expo-sensors)​

import { Accelerometer } from 'expo-sensors';

Accelerometer.setUpdateInterval(50);
const sub = Accelerometer.addListener(({ x, y, z, timestamp }) => {
// x, y, z in g; timestamp in seconds
});
// later: sub.remove();
ExportBehaviour on Kog
Accelerometer{ x, y, z, timestamp } in g (1 g = 9.81 m/s²).
Gyroscope{ x, y, z, timestamp } in rad/s.
addListener(fn)Returns { remove() }. Throws SENSOR_UNAVAILABLE naming the board when it has no such sensor.
setUpdateInterval(ms)Default 100 ms (expo-sensors' Android default), minimum 10 ms. It applies to every listener of that sensor.
isAvailableAsync(), hasListeners(), getListenerCount(), removeAllListeners(), removeSubscription(sub)As in Expo.
getPermissionsAsync(), requestPermissionsAsync()Always granted.
DeviceMotion, Magnetometer, MagnetometerUncalibrated, Barometer, LightSensor, PedometerNo Kog board has a driver for these yet. isAvailableAsync() resolves false; addListener and Pedometer.watchStepCount throw SENSOR_UNAVAILABLE, and Pedometer.getStepCountAsync rejects with it.

The axes are the display's: x to the right, y up, z out of the glass, which is Expo's device frame. That holds however the IMU is mounted on the board; peripherals.imu.orientation in board.yaml maps the chip's axes to the display's. A board lying flat, screen up, reads about { x: 0, y: 0, z: 1 }, Android's sign convention (iOS reports z: -1 there). Listeners run on the UI task. The native core runs one subscription per sensor and delivers only the latest sample, so a slow listener never builds up a backlog.

Haptics (expo-haptics)​

import * as Haptics from 'expo-haptics';

await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

Kog boards drive a plain vibration motor, not a Taptic engine, so each feedback type plays a fixed pattern of [wait, on, wait, on, …] milliseconds:

CallPattern (ms)
impactAsync(ImpactFeedbackStyle.Light)[0, 10]
impactAsync(ImpactFeedbackStyle.Medium) (the default)[0, 20]
impactAsync(ImpactFeedbackStyle.Heavy)[0, 35]
impactAsync(ImpactFeedbackStyle.Soft)[0, 15]
impactAsync(ImpactFeedbackStyle.Rigid)[0, 12]
notificationAsync(NotificationFeedbackType.Success) (the default)[0, 15, 60, 15]
notificationAsync(NotificationFeedbackType.Warning)[0, 25, 80, 25]
notificationAsync(NotificationFeedbackType.Error)[0, 30, 50, 30, 50, 30]
selectionAsync()[0, 8]
performAndroidHapticsAsync(type)Confirm plays Success and Reject plays Error. Long_Press plays Heavy. The press-like types (Toggle_On, Clock_Tick, Keyboard_Tap, Virtual_Key, Segment_Tick, …) play Light, and the release-like types (Toggle_Off, Gesture_End, Keyboard_Release, Segment_Frequent_Tick, Text_Handle_Move, …) play the selection tick. No_Haptics does nothing.

The promise resolves once the pattern has started. On a board without a motor, every call rejects DEVICE_UNSUPPORTED, naming the board and peripherals.motor. An unknown style is a TypeError that lists the valid values.

Vibration (react-native)​

import { Vibration } from 'react-native';

Vibration.vibrate(); // 400 ms
Vibration.vibrate([0, 200, 100, 200]); // wait 0, buzz 200, wait 100, buzz 200
Vibration.vibrate([0, 500, 500], true); // repeat until cancel()
Vibration.cancel();

Vibration follows React Native's Android semantics on every Kog target: a number is the buzz length in ms (default 400), and an array is [wait, on, wait, on, …]. With repeat: true, the array loops from its start until cancel(), as on Android, where React Native passes repeat ? 0 : -1. React Native's iOS behaviour, where every buzz lasts a fixed 400 ms, is not emulated. On a board without a motor, vibrate() throws DEVICE_UNSUPPORTED; cancel() does nothing there, because nothing can be vibrating. A negative, NaN or infinite duration is a TypeError.

A new pattern replaces the one that is playing. The motor stops on hot reload.

Differences from Expo​

Expo / React NativeKogWhy
A missing capability resolves as a no-op (haptics on web) or a listener that never fires (sensors).Haptics and Vibration.vibrate reject or throw DEVICE_UNSUPPORTED, and sensor subscriptions throw SENSOR_UNAVAILABLE, naming the board and the board.yaml fix. isAvailableAsync() is the probe.Kog never fails silently. The battery keeps Expo's own unknown values (-1, UNKNOWN).
Vibration buzzes a fixed 400 ms per pulse on iOS.Android semantics on every target.The motor can do exact durations, and one behaviour is easier to reason about.
Haptics drive a Taptic engine (iOS) or the platform's effects (Android).A fixed pattern per feedback type (the table above). performAndroidHapticsAsync plays on every target instead of only Android.Boards have a plain on/off motor.
Low-power mode and battery optimization report the OS setting.Always false; addLowPowerModeListener never fires.There is no OS power-saver mode.
The system brightness and the app brightness are separate on Android.They are the same value; the mode is always MANUAL, and AUTOMATIC rejects. addBrightnessListener fires on every target.One app owns the display, and there is no ambient-light controller.
Permission requests can be denied.Every permission resolves granted, with no prompt; usePermissions() returns the granted response immediately.There is no permission system.
DeviceMotion, Magnetometer, Barometer, LightSensor and Pedometer work where the phone has the part.isAvailableAsync() is false and subscribing throws SENSOR_UNAVAILABLE.No Kog board has drivers for these yet.
Accelerometer axes follow the platform: z is +1 g face up on Android and -1 on iOS.The display frame on every board, with Android's sign: z ≈ +1 g face up.One convention, independent of how the IMU is mounted.
setUpdateInterval goes straight to the OS.It is floored at 10 ms, and a non-finite or negative value is a TypeError.The native core's minimum period.
Battery hooks return a value, and the component re-renders when it changes.The compiler keeps const level = useBatteryLevel() live instead (the component mounts once).Kog has no re-renders; see Thinking in Kog.
DeviceSensor is built from a native module and an event name.The class is exported for typing, but its constructor takes a name and a native backing.Internal plumbing; apps use the exported sensor instances.

Errors​

DeviceError has a stable .code: DEVICE_UNSUPPORTED for a service the board doesn't have, or a mode Kog can't provide, and SENSOR_UNAVAILABLE for a sensor the board doesn't have. Its message reads like kog device: DEVICE_UNSUPPORTED — Vibration.vibrate: board "cyd" has no vibration motor (no peripherals.motor in its board.yaml). Declare the motor as peripherals.motor: { pin, active } in the board's board.yaml …. The board name appears when the build knows it.

In the simulators​

  • Studio and the docs playground: the Device section of the Simulated hardware panel has a battery level slider and a charging toggle (100 % while charging reads as FULL), accelerometer sliders (±2 g, with a "Lay flat" reset) and gyroscope sliders (±5 rad/s). It shows the brightness your app set, and dims the simulator canvas to match. While the motor runs, the frame shakes and a vibrating badge appears; a pulse counter makes even a 10 ms tap visible. Each block appears only when the simulator reports that service.
  • Desktop kog simulate: B lowers the battery 10 %, Shift+B raises it 10 %, and C toggles charging. The arrow keys tilt the accelerometer by 0.25 g per press on x/y, with z recomputed so the total stays 1 g, and L lays it flat (0, 0, 1 g). G toggles a slow gyroscope spin (z = 1 rad/s). Brightness dims the window. While the motor runs, the window title shows [vibrating] and the log prints device: vibrate on/off.

Testing​

@kog/device/testkit installs a deterministic mock of the native bridge for Node tests. Install it after createMockHost(), because the runtime testkit replaces globalThis.__kog:

import { createMockHost } from '@kog/runtime/testkit';
import { installMockDevice, CAP } from '@kog/device/testkit';

createMockHost();
const dev = installMockDevice({ caps: CAP.battery | CAP.vibration, level: 0.8 });
dev.setBattery(0.5, 2); // fires battery listeners
dev.emitSample(0, 0, 0, 1); // accelerometer sample (kind 0 = accel, 1 = gyro)
dev.vibrateCalls; // [{ pattern, repeatFrom }, …]