feat(companion): implement ble
@@ -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
@@ -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*
|
||||||
@@ -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",
|
||||||
255
aris/apps/companion/app/(tabs)/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
2062
aris/apps/companion/bun.lock
Normal file
16
aris/apps/companion/components/Container.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
aris/apps/companion/components/safe-area-scroll-view.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
aris/apps/companion/components/ui/switch.tsx
Normal 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 };
|
||||||
51
aris/apps/companion/lib/ble/fixtures.ts
Normal 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;
|
||||||
24
aris/apps/companion/metro.config.js
Normal 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,
|
||||||
|
});
|
||||||
57
aris/apps/companion/package.json
Normal 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
|
||||||
|
}
|
||||||
61
aris/apps/companion/store/ble.ts
Normal 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
668
aris/bun.lock
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
2
aris/packages/ble/.eslintrc.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// @generated by expo-module-scripts
|
||||||
|
module.exports = require('expo-module-scripts/eslintrc.base.js');
|
||||||
32
aris/packages/ble/README.md
Normal 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 — 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).
|
||||||
7
aris/packages/ble/expo-module.config.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"platforms": ["apple"],
|
||||||
|
"apple": {
|
||||||
|
"modules": ["ArisBleModule"],
|
||||||
|
"appDelegateSubscribers": ["ArisBleAppDelegateSubscriber"]
|
||||||
|
}
|
||||||
|
}
|
||||||
35
aris/packages/ble/package.json
Normal 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": "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
aris/packages/ble/src/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { Ble, defaultBleState } from "./native";
|
||||||
|
export { BleBluetoothState } from "./types";
|
||||||
|
export type {
|
||||||
|
BleNativeModuleEvents,
|
||||||
|
BleStatePayload,
|
||||||
|
BleUuids,
|
||||||
|
} from "./types";
|
||||||
90
aris/packages/ble/src/native.ts
Normal 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,
|
||||||
|
};
|
||||||
39
aris/packages/ble/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
9
aris/packages/ble/tsconfig.json
Normal 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__/*"]
|
||||||
|
}
|
||||||