feat(companion): implement ble

This commit is contained in:
2026-01-12 22:24:33 +00:00
parent 8305726f83
commit 75cfbe8dd4
55 changed files with 3457 additions and 142 deletions

View File

@@ -1,22 +0,0 @@
import { View } from "react-native";
import { Container } from "@/components/Container";
import { Text } from "@/components/ui/text";
export default function BleScreen() {
return (
<View className={styles.container}>
<Container>
<Text variant="h3">BLE</Text>
<Text className={styles.subtitle}>
Port of SwiftUI BleStatusView will live here.
</Text>
</Container>
</View>
);
}
const styles = {
container: "flex flex-1 bg-white",
subtitle: "mt-2 text-sm text-muted-foreground",
};

24
aris/apps/companion/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
node_modules/
.expo/
dist/
npm-debug.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
# expo router
expo-env.d.ts
ios
android
# macOS
.DS_Store
# Temporary files created by Metro to check the health of the file watcher
.metro-health-check*

View File

@@ -31,7 +31,11 @@
"assetBundlePatterns": ["**/*"], "assetBundlePatterns": ["**/*"],
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"bundleIdentifier": "sh.nym.aris" "bundleIdentifier": "sh.nym.aris",
"backgroundModes": ["bluetooth-peripheral"],
"infoPlist": {
"NSBluetoothAlwaysUsageDescription": "Allow Bluetooth to connect to Iris Glass."
}
}, },
"android": { "android": {
"package": "sh.nym.aris", "package": "sh.nym.aris",

View File

@@ -0,0 +1,255 @@
import { useEffect, useState, type ReactNode } from "react";
import { Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
import { Container } from "@/components/Container";
import { SafeAreaScrollView } from "@/components/safe-area-scroll-view";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { Ble } from "@aris/ble";
import { useBleStore } from "@/store/ble";
import { cn } from "@/lib/utils";
import { Ionicons } from "@expo/vector-icons";
const formatTime = (timestamp: number | null) => {
if (!timestamp) {
return "Never";
}
return new Date(timestamp).toLocaleTimeString();
};
export default function BleScreen() {
const initialize = useBleStore((state) => state.initialize);
const isSupported = useBleStore((state) => state.isSupported);
const insets = useSafeAreaInsets();
useEffect(() => {
initialize();
}, [initialize]);
if (!isSupported) {
return (
<View className="flex flex-1 bg-white">
<Container className="px-8">
<View className="gap-1">
<Text variant="h3">BLE</Text>
<Text className="text-sm text-muted-foreground">
Bluetooth peripheral status and controls.
</Text>
</View>
<View className="mt-4 rounded-xl border border-muted bg-muted/20 px-4 py-3">
<Text className="text-sm font-medium">BLE Unavailable</Text>
<Text className="text-sm text-muted-foreground">
This page is currently supported on iOS only.
</Text>
</View>
</Container>
</View>
);
}
return (
<View className="flex flex-1 bg-white">
<View>
<SafeAreaScrollView>
<View className="gap-4" style={{ paddingBottom: insets.bottom + 24 }}>
<View className="gap-1 px-8">
<Text variant="h3">BLE</Text>
<Text className="text-sm text-muted-foreground">
Bluetooth peripheral status and controls.
</Text>
</View>
<View className="w-full h-px bg-muted" />
<ControlPanel />
<View className="w-full h-px bg-muted" />
<BleStatusSection />
<View className="w-full h-px bg-muted" />
<TelemetrySection />
<View className="w-full h-px bg-muted" />
<UuidsSection />
</View>
</SafeAreaScrollView>
</View>
</View>
);
}
function ValueRow({ label, value }: { label: ReactNode; value: ReactNode }) {
return (
<View className="flex flex-row items-center justify-between">
{typeof label === "string" ? (
<Text className="text-sm text-muted-foreground">{label}</Text>
) : (
label
)}
{typeof value === "string" || typeof value === "number" ? (
<Text className="text-sm">{value}</Text>
) : (
value
)}
</View>
);
}
function BleStatusSection() {
const bluetoothState = useBleStore((state) => state.bluetoothState);
const isSubscribed = useBleStore((state) => state.isSubscribed);
const subscribedCount = useBleStore((state) => state.subscribedCount);
const connectionLabel = isSubscribed ? "Connected" : "Not Connected";
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Status
</Text>
<View className="gap-1">
<ValueRow label="State" value={connectionLabel} />
<ValueRow label="Bluetooth" value={bluetoothState} />
<ValueRow label="Subscribers" value={subscribedCount} />
</View>
</View>
);
}
function ControlPanel() {
const advertisingEnabled = useBleStore((state) => state.advertisingEnabled);
const setAdvertisingEnabled = useBleStore(
(state) => state.setAdvertisingEnabled,
);
const wifiRequested = useBleStore((state) => state.wifiRequested);
const setWifiRequested = useBleStore((state) => state.setWifiRequested);
const sendFixtureFeedNow = useBleStore((state) => state.sendFixtureFeedNow);
const handleCopyUUIDs = async () => {
const uuids = Ble.getUuids();
const text = [
`SERVICE_UUID=${uuids.serviceUUID}`,
`FEED_TX_UUID=${uuids.feedTxUUID}`,
`CONTROL_RX_UUID=${uuids.controlRxUUID}`,
`WIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`,
].join("\n");
await Clipboard.setStringAsync(text);
};
return (
<View className="px-8">
<Text variant="h4" className="mb-3 font-medium">
Control Panel
</Text>
<View className="flex-row gap-3 w-full mb-3">
<ControlTile
icon={<Ionicons name="radio-outline" size={20} />}
label="Advertising"
checked={advertisingEnabled}
onCheckChange={setAdvertisingEnabled}
/>
<ControlTile
icon={<Ionicons name="wifi-outline" size={20} />}
label="WiFi"
checked={wifiRequested}
onCheckChange={setWifiRequested}
/>
</View>
<View className="gap-2">
<Button onPress={sendFixtureFeedNow} variant="outline">
<Text>Send Fixture Feed Now</Text>
</Button>
<Button onPress={handleCopyUUIDs} variant="outline">
<Text>Copy UUIDs</Text>
</Button>
</View>
</View>
);
}
function ControlTile({
icon,
label,
checked,
onCheckChange,
}: {
icon: ReactNode;
label: ReactNode;
checked: boolean;
onCheckChange: (checked: boolean) => void;
}) {
const [isPressed, setIsPressed] = useState(false);
return (
<Pressable
onPressIn={() => {
setIsPressed(true);
}}
onPressOut={() => {
setIsPressed(false);
}}
onPress={() => {
onCheckChange(!checked);
}}
className={cn(
"flex-1 items-start justify-center rounded-md px-3 py-2 gap-1",
checked ? "border-2 border-primary" : "border border-border",
{ "bg-accent": isPressed },
)}
>
{icon}
<Text className={cn({ "font-bold": checked })}>{label}</Text>
</Pressable>
);
}
function TelemetrySection() {
const lastMsgIdSent = useBleStore((state) => state.lastMsgIdSent);
const lastPingAt = useBleStore((state) => state.lastPingAt);
const lastDataAt = useBleStore((state) => state.lastDataAt);
const lastNotifyAt = useBleStore((state) => state.lastNotifyAt);
const notifyQueueDepth = useBleStore((state) => state.notifyQueueDepth);
const droppedNotifyPackets = useBleStore(
(state) => state.droppedNotifyPackets,
);
const lastCommand = useBleStore((state) => state.lastCommand);
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
Telemetry
</Text>
<View className="gap-1">
<ValueRow label="Last msgId" value={lastMsgIdSent} />
<ValueRow label="Last ping" value={formatTime(lastPingAt)} />
<ValueRow label="Last data" value={formatTime(lastDataAt)} />
<ValueRow label="Notify queue" value={notifyQueueDepth} />
{droppedNotifyPackets > 0 ? (
<ValueRow
label="Dropped notify packets"
value={droppedNotifyPackets}
/>
) : null}
<ValueRow label="Last notify" value={formatTime(lastNotifyAt)} />
{lastCommand ? (
<ValueRow label="Last control" value={lastCommand} />
) : null}
</View>
</View>
);
}
function UuidsSection() {
const uuids = Ble.getUuids();
return (
<View className="px-8">
<Text variant="h4" className="mb-2 font-medium">
UUIDs
</Text>
<Text selectable className="text-xs text-muted-foreground">
{`SERVICE_UUID=${uuids.serviceUUID}\nFEED_TX_UUID=${uuids.feedTxUUID}\nCONTROL_RX_UUID=${uuids.controlRxUUID}\nWIFI_REQUEST_TX_UUID=${uuids.wifiRequestTxUUID}`}
</Text>
</View>
);
}

View File

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View File

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

2062
aris/apps/companion/bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
import { SafeAreaView } from "react-native-safe-area-context";
export const Container = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<SafeAreaView className={cn("flex flex-1", className)}>
{children}
</SafeAreaView>
);
};

View File

@@ -0,0 +1,19 @@
import { ScrollView } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
export function SafeAreaScrollView({ children }: React.PropsWithChildren) {
const insets = useSafeAreaInsets();
return (
<ScrollView
style={{
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}}
>
{children}
</ScrollView>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from '@/lib/utils';
import * as SwitchPrimitives from '@rn-primitives/switch';
import { Platform } from 'react-native';
function Switch({
className,
...props
}: SwitchPrimitives.RootProps & React.RefAttributes<SwitchPrimitives.RootRef>) {
return (
<SwitchPrimitives.Root
className={cn(
'flex h-[1.15rem] w-8 shrink-0 flex-row items-center rounded-full border border-transparent shadow-sm shadow-black/5',
Platform.select({
web: 'focus-visible:border-ring focus-visible:ring-ring/50 peer inline-flex outline-none transition-all focus-visible:ring-[3px] disabled:cursor-not-allowed',
}),
props.checked ? 'bg-primary' : 'bg-input dark:bg-input/80',
props.disabled && 'opacity-50',
className
)}
{...props}>
<SwitchPrimitives.Thumb
className={cn(
'bg-background size-4 rounded-full transition-transform',
Platform.select({
web: 'pointer-events-none block ring-0',
}),
props.checked
? 'dark:bg-primary-foreground translate-x-3.5'
: 'dark:bg-foreground translate-x-0'
)}
/>
</SwitchPrimitives.Root>
);
}
export { Switch };

View File

@@ -0,0 +1,51 @@
export const fullFeedFixture = {
schema: 1,
generated_at: 1767716400,
feed: [
{
id: "demo:welcome",
type: "INFO",
title: "Glass Now online",
subtitle: "Connected to iPhone",
priority: 0.8,
ttl_sec: 86400,
bucket: "RIGHT_NOW",
actions: ["DISMISS"],
},
{
id: "cal:demo:1767717000",
type: "CALENDAR_EVENT",
title: "Team Sync",
subtitle: "",
priority: 0.7,
ttl_sec: 5400,
starts_at: 1767717000,
bucket: "FYI",
actions: ["DISMISS"],
},
{
id: "demo:next",
type: "INFO",
title: "Next: Calendar",
subtitle: "Then Weather + POI",
priority: 0.4,
ttl_sec: 86400,
bucket: "FYI",
actions: ["DISMISS"],
},
{
id: "music:now:demo",
type: "NOW_PLAYING",
title: "Midnight City",
subtitle: "M83 • Hurry Up, We're Dreaming",
priority: 0.35,
ttl_sec: 30,
bucket: "FYI",
actions: ["DISMISS"],
},
],
meta: {
winner_id: "demo:welcome",
unread_count: 4,
},
} as const;

View File

@@ -0,0 +1,24 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.disableHierarchicalLookup = true;
module.exports = withNativeWind(config, {
input: "./global.css",
inlineRem: 16,
});

View File

@@ -0,0 +1,57 @@
{
"name": "aris",
"version": "1.0.0",
"main": "expo-router/entry",
"scripts": {
"android": "expo run:android",
"ios": "expo run:ios",
"start": "expo start",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web"
},
"dependencies": {
"@aris/ble": "*",
"@expo/vector-icons": "^15.0.2",
"@react-navigation/native": "^7.1.6",
"@rn-primitives/portal": "^1.3.0",
"@rn-primitives/slot": "^1.2.0",
"@rn-primitives/switch": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "^54.0.0",
"expo-clipboard": "~8.0.8",
"expo-constants": "~18.0.9",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"typescript": "~5.9.2"
},
"private": true
}

View File

@@ -0,0 +1,61 @@
import type { EventSubscription } from "expo-modules-core";
import { create } from "zustand";
import { fullFeedFixture } from "@/lib/ble/fixtures";
import { Ble, defaultBleState } from "@aris/ble";
import type { BleStatePayload } from "@aris/ble";
export type BleStoreState = BleStatePayload & {
isSupported: boolean;
isReady: boolean;
initialize: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendFixtureFeedNow: () => void;
};
let subscription: EventSubscription | null = null;
const noopSubscription: EventSubscription = {
remove: () => undefined,
};
export const useBleStore = create<BleStoreState>((set, get) => ({
...defaultBleState,
isSupported: Ble.isSupported,
isReady: false,
initialize: () => {
if (!Ble.isSupported || get().isReady) {
return;
}
const state = Ble.getState();
set({ ...state, isReady: true });
subscription?.remove();
subscription =
Ble.addStateListener?.((nextState) => {
set({ ...nextState });
}) ?? noopSubscription;
Ble.start();
},
setAdvertisingEnabled: (enabled) => {
if (!Ble.isSupported) {
return;
}
set({ advertisingEnabled: enabled });
Ble.setAdvertisingEnabled(enabled);
},
setWifiRequested: (requested) => {
if (!Ble.isSupported) {
return;
}
set({ wifiRequested: requested });
Ble.setWifiRequested(requested);
},
sendFixtureFeedNow: () => {
if (!Ble.isSupported) {
return;
}
const payload = JSON.stringify(fullFeedFixture);
Ble.sendOpaque(payload, 1);
},
}));

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
import { SafeAreaView } from 'react-native';
export const Container = ({ children }: { children: React.ReactNode }) => {
return <SafeAreaView className={styles.container}>{children}</SafeAreaView>;
};
const styles = {
container: 'flex flex-1 m-6',
};

View File

@@ -1,13 +0,0 @@
// Learn more https://docs.expo.io/guides/customizing-metro
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");
/** @type {import('expo/metro-config').MetroConfig} */
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, {
input: "./global.css",
inlineRem: 16,
});

View File

@@ -1,54 +1,5 @@
{ {
"name": "aris", "name": "iris",
"version": "1.0.0", "private": true,
"main": "expo-router/entry", "workspaces": ["apps/*", "packages/*"]
"scripts": {
"android": "expo run:android",
"ios": "expo run:ios",
"start": "expo start",
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-navigation/native": "^7.1.6",
"@rn-primitives/portal": "^1.3.0",
"@rn-primitives/slot": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"expo": "^54.0.0",
"expo-constants": "~18.0.9",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.10",
"expo-status-bar": "~3.0.8",
"expo-system-ui": "~6.0.7",
"expo-web-browser": "~15.0.7",
"nativewind": "latest",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.5.1",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zustand": "^4.5.1"
},
"devDependencies": {
"@babel/core": "^7.20.0",
"@types/react": "~19.1.10",
"eslint": "^9.25.1",
"eslint-config-expo": "~10.0.0",
"eslint-config-prettier": "^10.1.2",
"prettier": "^3.2.5",
"tailwindcss": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.5.11",
"typescript": "~5.9.2"
},
"private": true
} }

View File

@@ -0,0 +1,2 @@
// @generated by expo-module-scripts
module.exports = require('expo-module-scripts/eslintrc.base.js');

View File

@@ -0,0 +1,32 @@
# @aris/ble
BLE peripheral module for the Aris companion app.
# API documentation
- [Documentation for the latest stable release](https://docs.expo.dev/versions/latest/sdk/example.com/)
- [Documentation for the main branch](https://docs.expo.dev/versions/unversioned/sdk/example.com/)
# Installation in managed Expo projects
For [managed](https://docs.expo.dev/archive/managed-vs-bare/) Expo projects, please follow the installation instructions in the [API documentation for the latest stable release](#api-documentation). If you follow the link and there is no documentation available then this library is not yet usable within managed projects &mdash; it is likely to be included in an upcoming Expo SDK release.
# Installation in bare React Native projects
For bare React Native projects, you must ensure that you have [installed and configured the `expo` package](https://docs.expo.dev/bare/installing-expo-modules/) before continuing.
### Add the package to your npm dependencies
```
npm install @aris/ble
```
### Configure for iOS
Run `npx pod-install` after installing the npm package.
# Contributing
Contributions are very welcome! Please refer to guidelines described in the [contributing guide]( https://github.com/expo/expo#contributing).

View File

@@ -0,0 +1,7 @@
{
"platforms": ["apple"],
"apple": {
"modules": ["ArisBleModule"],
"appDelegateSubscribers": ["ArisBleAppDelegateSubscriber"]
}
}

View File

@@ -0,0 +1,35 @@
{
"name": "@aris/ble",
"version": "0.1.0",
"description": "BLE peripheral module for the Aris companion app.",
"author": "Iris",
"homepage": "https://example.com",
"main": "build/index.js",
"types": "build/index.d.ts",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"scripts": {
"build": "expo-module build",
"clean": "expo-module clean",
"lint": "expo-module lint",
"test": "expo-module test",
"prepare": "expo-module prepare",
"prepublishOnly": "expo-module prepublishOnly",
"expo-module": "expo-module"
},
"keywords": ["react-native", "expo", "ble"],
"license": "MIT",
"devDependencies": {
"expo-module-scripts": "^5.0.8"
},
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
}

View File

@@ -0,0 +1,7 @@
export { Ble, defaultBleState } from "./native";
export { BleBluetoothState } from "./types";
export type {
BleNativeModuleEvents,
BleStatePayload,
BleUuids,
} from "./types";

View File

@@ -0,0 +1,90 @@
import { NativeModule, requireNativeModule } from "expo";
import { Platform } from "react-native";
import type { EventSubscription } from "expo-modules-core";
import {
BleBluetoothState,
type BleNativeModuleEvents,
type BleStatePayload,
type BleUuids,
} from "./types";
declare class ArisBleNativeModule extends NativeModule<BleNativeModuleEvents> {
start: () => void;
stop: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendOpaque: (payload: string, msgType?: number) => void;
getState: () => BleStatePayload;
serviceUUID: string;
feedTxUUID: string;
controlRxUUID: string;
wifiRequestTxUUID: string;
}
const isSupported = Platform.OS === "ios";
const nativeModule = isSupported
? requireNativeModule<ArisBleNativeModule>("ArisBle")
: null;
export const defaultBleState: BleStatePayload = {
bluetoothState: BleBluetoothState.Unknown,
advertisingEnabled: true,
isAdvertising: false,
isSubscribed: false,
subscribedCount: 0,
lastMsgIdSent: 0,
lastPingAt: null,
lastCommand: null,
notifyQueueDepth: 0,
droppedNotifyPackets: 0,
lastNotifyAt: null,
lastDataAt: null,
wifiRequested: false,
};
const emptyUuids: BleUuids = {
serviceUUID: "",
feedTxUUID: "",
controlRxUUID: "",
wifiRequestTxUUID: "",
};
type BleApi = {
isSupported: boolean;
start: () => void;
stop: () => void;
setAdvertisingEnabled: (enabled: boolean) => void;
setWifiRequested: (requested: boolean) => void;
sendOpaque: (payload: string, msgType?: number) => void;
getState: () => BleStatePayload;
addStateListener: (
listener: (state: BleStatePayload) => void,
) => EventSubscription | undefined;
getUuids: () => BleUuids;
};
export const Ble: BleApi = {
isSupported,
start: () => nativeModule?.start?.(),
stop: () => nativeModule?.stop?.(),
setAdvertisingEnabled: (enabled: boolean) =>
nativeModule?.setAdvertisingEnabled?.(enabled),
setWifiRequested: (requested: boolean) =>
nativeModule?.setWifiRequested?.(requested),
sendOpaque: (payload: string, msgType?: number) =>
nativeModule?.sendOpaque?.(payload, msgType),
getState: () => nativeModule?.getState?.() ?? { ...defaultBleState },
addStateListener: (listener: (state: BleStatePayload) => void) =>
nativeModule?.addListener("onStateChange", listener),
getUuids: (): BleUuids =>
nativeModule
? {
serviceUUID: nativeModule.serviceUUID,
feedTxUUID: nativeModule.feedTxUUID,
controlRxUUID: nativeModule.controlRxUUID,
wifiRequestTxUUID: nativeModule.wifiRequestTxUUID,
}
: emptyUuids,
};

View File

@@ -0,0 +1,39 @@
export const BleBluetoothState = {
Unknown: "Unknown",
Resetting: "Resetting",
Unsupported: "Unsupported",
Unauthorized: "Unauthorized",
PoweredOff: "Powered Off",
PoweredOn: "Powered On",
Other: "Other",
} as const;
export type BleBluetoothState =
(typeof BleBluetoothState)[keyof typeof BleBluetoothState];
export type BleStatePayload = {
bluetoothState: BleBluetoothState;
advertisingEnabled: boolean;
isAdvertising: boolean;
isSubscribed: boolean;
subscribedCount: number;
lastMsgIdSent: number;
lastPingAt: number | null;
lastCommand: string | null;
notifyQueueDepth: number;
droppedNotifyPackets: number;
lastNotifyAt: number | null;
lastDataAt: number | null;
wifiRequested: boolean;
};
export type BleUuids = {
serviceUUID: string;
feedTxUUID: string;
controlRxUUID: string;
wifiRequestTxUUID: string;
};
export type BleNativeModuleEvents = {
onStateChange: (state: BleStatePayload) => void;
};

View File

@@ -0,0 +1,9 @@
// @generated by expo-module-scripts
{
"extends": "expo-module-scripts/tsconfig.base",
"compilerOptions": {
"outDir": "./build"
},
"include": ["./src"],
"exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
}