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",
|
||||
};
|
||||
Reference in New Issue
Block a user