feat(companion): implement ble
This commit is contained in:
81
aris/apps/companion/app/(tabs)/_layout.tsx
Normal file
81
aris/apps/companion/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Tabs } from "expo-router";
|
||||
|
||||
const iconSize = 22;
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs screenOptions={{ headerShown: false }}>
|
||||
<Tabs.Screen
|
||||
name="orchestrator"
|
||||
options={{
|
||||
title: "Orchestrator",
|
||||
tabBarLabel: "Orchestrator",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
color={color}
|
||||
name={focused ? "flash" : "flash-outline"}
|
||||
size={iconSize}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "BLE",
|
||||
tabBarLabel: "BLE",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
color={color}
|
||||
name={focused ? "bluetooth" : "bluetooth-outline"}
|
||||
size={iconSize}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="todos"
|
||||
options={{
|
||||
title: "Todos",
|
||||
tabBarLabel: "Todos",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
color={color}
|
||||
name={focused ? "checkmark-done" : "checkmark-done-outline"}
|
||||
size={iconSize}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="stocks"
|
||||
options={{
|
||||
title: "Stocks",
|
||||
tabBarLabel: "Stocks",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
color={color}
|
||||
name={focused ? "stats-chart" : "stats-chart-outline"}
|
||||
size={iconSize}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: "Settings",
|
||||
tabBarLabel: "Settings",
|
||||
tabBarIcon: ({ color, focused }) => (
|
||||
<Ionicons
|
||||
color={color}
|
||||
name={focused ? "settings" : "settings-outline"}
|
||||
size={iconSize}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
255
aris/apps/companion/app/(tabs)/index.tsx
Normal file
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>
|
||||
);
|
||||
}
|
||||
22
aris/apps/companion/app/(tabs)/orchestrator.tsx
Normal file
22
aris/apps/companion/app/(tabs)/orchestrator.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export default function OrchestratorScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Container>
|
||||
<Text variant="h3">Orchestrator</Text>
|
||||
<Text className={styles.subtitle}>
|
||||
Port of SwiftUI OrchestratorView will live here.
|
||||
</Text>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: "flex flex-1 bg-white",
|
||||
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||
};
|
||||
22
aris/apps/companion/app/(tabs)/settings.tsx
Normal file
22
aris/apps/companion/app/(tabs)/settings.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Container>
|
||||
<Text variant="h3">Settings</Text>
|
||||
<Text className={styles.subtitle}>
|
||||
Port of SwiftUI SettingsView will live here.
|
||||
</Text>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: "flex flex-1 bg-white",
|
||||
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||
};
|
||||
22
aris/apps/companion/app/(tabs)/stocks.tsx
Normal file
22
aris/apps/companion/app/(tabs)/stocks.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export default function StocksScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Container>
|
||||
<Text variant="h3">Stocks</Text>
|
||||
<Text className={styles.subtitle}>
|
||||
Port of SwiftUI StockSettingsView will live here.
|
||||
</Text>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: "flex flex-1 bg-white",
|
||||
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||
};
|
||||
22
aris/apps/companion/app/(tabs)/todos.tsx
Normal file
22
aris/apps/companion/app/(tabs)/todos.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/Container";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export default function TodosScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Container>
|
||||
<Text variant="h3">Todos</Text>
|
||||
<Text className={styles.subtitle}>
|
||||
Port of SwiftUI TodosView will live here.
|
||||
</Text>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: "flex flex-1 bg-white",
|
||||
subtitle: "mt-2 text-sm text-muted-foreground",
|
||||
};
|
||||
46
aris/apps/companion/app/+html.tsx
Normal file
46
aris/apps/companion/app/+html.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ScrollViewStyleReset } from 'expo-router/html';
|
||||
|
||||
// This file is web-only and used to configure the root HTML for every
|
||||
// web page during static rendering.
|
||||
// The contents of this function only run in Node.js environments and
|
||||
// do not have access to the DOM or browser APIs.
|
||||
export default function Root({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
|
||||
|
||||
{/*
|
||||
This viewport disables scaling which makes the mobile website act more like a native app.
|
||||
However this does reduce built-in accessibility. If you want to enable scaling, use this instead:
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
*/}
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1.00001,viewport-fit=cover"
|
||||
/>
|
||||
{/*
|
||||
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
|
||||
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
|
||||
*/}
|
||||
<ScrollViewStyleReset />
|
||||
|
||||
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
|
||||
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
|
||||
{/* Add any additional <head> elements that you want globally available on web... */}
|
||||
</head>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
const responsiveBackground = `
|
||||
body {
|
||||
background-color: #fff;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000;
|
||||
}
|
||||
}`;
|
||||
26
aris/apps/companion/app/+not-found.tsx
Normal file
26
aris/apps/companion/app/+not-found.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Link, Stack } from 'expo-router';
|
||||
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { Container } from '@/components/Container';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Stack.Screen options={{ title: 'Oops!' }} />
|
||||
<Container>
|
||||
<Text className={styles.title}>{"This screen doesn't exist."}</Text>
|
||||
<Link href="/" className={styles.link}>
|
||||
<Text className={styles.linkText}>Go to home screen!</Text>
|
||||
</Link>
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: `flex flex-1 bg-white`,
|
||||
title: `text-xl font-bold`,
|
||||
link: `mt-4 pt-4`,
|
||||
linkText: `text-base text-[#2e78b7]`,
|
||||
};
|
||||
15
aris/apps/companion/app/_layout.tsx
Normal file
15
aris/apps/companion/app/_layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import "../global.css";
|
||||
|
||||
import { Stack } from "expo-router";
|
||||
import { PortalHost } from "@rn-primitives/portal";
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<PortalHost />
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
aris/apps/companion/app/details.tsx
Normal file
23
aris/apps/companion/app/details.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { View } from 'react-native';
|
||||
|
||||
import { Stack, useLocalSearchParams } from 'expo-router';
|
||||
|
||||
import { Container } from '@/components/Container';
|
||||
import { ScreenContent } from '@/components/ScreenContent';
|
||||
|
||||
export default function Details() {
|
||||
const { name } = useLocalSearchParams();
|
||||
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Stack.Screen options={{ title: 'Details' }} />
|
||||
<Container>
|
||||
<ScreenContent path="screens/details.tsx" title={`Showing details for user ${name}`} />
|
||||
</Container>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = {
|
||||
container: 'flex flex-1 bg-white',
|
||||
};
|
||||
Reference in New Issue
Block a user