├── .env
├── .gitignore
├── .vscode
└── settings.json
├── README.md
├── app.json
├── bun.lockb
├── metro.config.js
├── package.json
├── patches
└── react-server-dom-webpack+19.0.0-rc-6230622a1a-20240610.patch
├── postcss.config.js
├── public
└── index.html
├── src
├── app
│ ├── _layout.tsx
│ ├── device
│ │ └── [device].tsx
│ └── index.tsx
├── assets
│ ├── evan.jpeg
│ ├── expo.png
│ ├── fonts
│ │ └── anonymous_pro
│ │ │ ├── AnonymousPro-Bold.ttf
│ │ │ ├── AnonymousPro-Italic.ttf
│ │ │ ├── AnonymousPro-Regular.ttf
│ │ │ └── OFL.txt
│ ├── icon.png
│ └── splash.png
├── components
│ ├── api.tsx
│ ├── nest
│ │ ├── camera-history.tsx
│ │ ├── nest-actions.tsx
│ │ ├── nest-auth-button.tsx
│ │ ├── nest-brand-button.tsx
│ │ ├── nest-camera-detail.tsx
│ │ ├── nest-device-cards.tsx
│ │ ├── nest-devices-fixture.json
│ │ ├── nest-server-actions.tsx
│ │ ├── thermostat-detail.tsx
│ │ └── webrtc-dom-view.tsx
│ ├── svg
│ │ └── nest.tsx
│ ├── thermostat-skeleton.tsx
│ ├── ui
│ │ ├── FadeIn.tsx
│ │ ├── TouchableBounce.native.tsx
│ │ ├── TouchableBounce.tsx
│ │ └── body.tsx
│ └── user-playlists.tsx
├── global.css
├── hooks
│ └── useHeaderSearch.ts
└── lib
│ ├── local-storage.ts
│ ├── local-storage.web.ts
│ ├── nest-auth
│ ├── auth-server-actions.tsx
│ ├── discovery.tsx
│ ├── index.ts
│ ├── nest-auth-session-provider.tsx
│ ├── nest-client-provider.tsx
│ └── nest-validation.tsx
│ ├── skeleton.tsx
│ └── skeleton.web.tsx
├── tailwind.config.js
├── tsconfig.json
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | EXPO_UNSTABLE_SERVER_ACTIONS=1
2 |
3 | # Nest
4 | # https://console.nest.google.com/device-access/project/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/information
5 | EXPO_PUBLIC_NEST_PROJECT_ID=xxx
6 | EXPO_PUBLIC_GOOGLE_OAUTH_CLIENT_ID_IOS=xxx
7 | NEST_GOOGLE_CLIENT_SECRET=xxx
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
16 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
17 | # The following patterns were generated by expo-cli
18 |
19 | expo-env.d.ts
20 | # @end expo-cli
21 |
22 | /.env.local
23 | /ios
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // use local typescript
3 | "typescript.tsdk": "node_modules/typescript/lib"
4 | }
5 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Expo Router Google Home
2 |
3 | This is an Expo Router app for interacting with Google Home / Nest devices.
4 |
5 | This app uses the new Expo React Server Components (developer preview) and Expo DOM Components features.
6 |
7 | To get running, you'll need to register for an API key in [Google Nest](https://console.nest.google.com/device-access/project-list).
8 |
9 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "rsc-nest",
4 | "slug": "rsc-nest",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "scheme": "exai",
8 | "userInterfaceStyle": "automatic",
9 | "newArchEnabled": true,
10 | "splash": {
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "ios": {
15 | "scheme": [
16 | "com.googleusercontent.apps.549323343471-esl81iea3g398omh5e5nmadnda5700p7"
17 | ],
18 | "infoPlist": {
19 | "ITSAppUsesNonExemptEncryption": false
20 | },
21 | "supportsTablet": true,
22 | "bundleIdentifier": "com.bacon.nest"
23 | },
24 | "android": {
25 | "adaptiveIcon": {
26 | "backgroundColor": "#ffffff"
27 | }
28 | },
29 | "plugins": ["expo-router"],
30 | "experiments": {
31 | "typedRoutes": true,
32 | "reactServerFunctions": true
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/bun.lockb
--------------------------------------------------------------------------------
/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require('expo/metro-config');
3 |
4 | /** @type {import('expo/metro-config').MetroConfig} */
5 | const config = getDefaultConfig(__dirname);
6 |
7 | config.resolver.unstable_enablePackageExports = true;
8 | module.exports = config;
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "rsc-nest",
3 | "license": "0BSD",
4 | "main": "expo-router/entry",
5 | "version": "1.0.0",
6 | "scripts": {
7 | "start": "expo start",
8 | "android": "expo run:android",
9 | "ios": "expo run:ios",
10 | "web": "expo start --web",
11 | "lint": "expo lint",
12 | "prepare": "npx patch-package"
13 | },
14 | "dependencies": {
15 | "@bacons/apple-colors": "^0.0.6",
16 | "@expo/vector-icons": "^14.0.2",
17 | "@react-navigation/native": "7.0.0-rc.20",
18 | "expo": "^52",
19 | "expo-auth-session": "~6.0.0",
20 | "expo-blur": "~14.0.1",
21 | "expo-constants": "~17.0.1",
22 | "expo-font": "~13.0.0",
23 | "expo-haptics": "~14.0.0",
24 | "expo-linking": "~7.0.2",
25 | "expo-router": "~4.0.2",
26 | "expo-splash-screen": "~0.29.7",
27 | "expo-sqlite": "~15.0.1",
28 | "expo-status-bar": "~2.0.0",
29 | "expo-symbols": "~0.2.0",
30 | "expo-system-ui": "~4.0.1",
31 | "expo-web-browser": "~14.0.0",
32 | "postcss": "^8.4.49",
33 | "react": "18.3.1",
34 | "react-dom": "18.3.1",
35 | "react-native": "0.76.1",
36 | "react-native-gesture-handler": "~2.20.2",
37 | "react-native-maps": "1.18.0",
38 | "react-native-reanimated": "~3.16.1",
39 | "react-native-safe-area-context": "4.12.0",
40 | "react-native-screens": "~4.0.0",
41 | "react-native-svg": "15.8.0",
42 | "react-native-web": "~0.19.13",
43 | "react-native-webview": "13.12.2",
44 | "react-server-dom-webpack": "19.0.0-rc-6230622a1a-20240610",
45 | "zod": "^3.23.8"
46 | },
47 | "devDependencies": {
48 | "@babel/core": "^7.20.0",
49 | "@types/react": "~18.3.12",
50 | "autoprefixer": "^10.4.20",
51 | "tailwindcss": "^3.4.15",
52 | "typescript": "^5.4.5"
53 | },
54 | "private": true
55 | }
56 |
--------------------------------------------------------------------------------
/patches/react-server-dom-webpack+19.0.0-rc-6230622a1a-20240610.patch:
--------------------------------------------------------------------------------
1 | diff --git a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js
2 | index c9bc9ea..cf4eebd 100644
3 | --- a/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js
4 | +++ b/node_modules/react-server-dom-webpack/cjs/react-server-dom-webpack-client.browser.development.js
5 | @@ -2113,7 +2113,7 @@ function createModelReject(chunk) {
6 | function createServerReferenceProxy(response, metaData) {
7 | var callServer = response._callServer;
8 |
9 | - var proxy = function () {
10 | + var proxy = async function () {
11 | // $FlowFixMe[method-unbinding]
12 | var args = Array.prototype.slice.call(arguments);
13 | var p = metaData.bound;
14 | @@ -2128,10 +2128,10 @@ function createServerReferenceProxy(response, metaData) {
15 | } // Since this is a fake Promise whose .then doesn't chain, we have to wrap it.
16 | // TODO: Remove the wrapper once that's fixed.
17 |
18 | -
19 | - return Promise.resolve(p).then(function (bound) {
20 | - return callServer(metaData.id, bound.concat(args));
21 | - });
22 | + // HACK: This is required to make native server actions return a non-undefined value.
23 | + // Seems like a bug in the Hermes engine since the same babel transforms work in Chrome/web.
24 | + const _bound = await p;
25 | + return callServer(metaData.id, _bound.concat(args));
26 | };
27 |
28 | registerServerReference(proxy, metaData);
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | %WEB_TITLE%
8 |
9 |
12 |
13 |
14 |
15 |
16 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/app/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { Stack } from "expo-router";
2 | import {
3 | NestClientAuthProvider,
4 | useNestAuth,
5 | } from "@/lib/nest-auth/nest-client-provider";
6 | import { makeRedirectUri } from "expo-auth-session";
7 | import { NestActionsProvider } from "@/components/api";
8 |
9 | import "@/global.css";
10 | import { Platform } from "react-native";
11 |
12 | const redirectUri = makeRedirectUri({
13 | scheme:
14 | "com.googleusercontent.apps.549323343471-esl81iea3g398omh5e5nmadnda5700p7",
15 | });
16 |
17 | export default function Page() {
18 | return (
19 |
48 |
49 |
50 | );
51 | }
52 |
53 | function InnerAuth() {
54 | return (
55 |
56 |
72 |
73 |
81 |
82 |
83 | );
84 | }
85 |
--------------------------------------------------------------------------------
/src/app/device/[device].tsx:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { useNestActions } from "@/components/api";
4 | import ThermostatSkeleton from "@/components/thermostat-skeleton";
5 | import { BodyScrollView } from "@/components/ui/body";
6 | import { useLocalSearchParams } from "expo-router";
7 | import { Suspense, useMemo } from "react";
8 | import { ActivityIndicator, Text } from "react-native";
9 |
10 | export { ErrorBoundary } from "expo-router";
11 |
12 | export default function PlaylistScreen() {
13 | const { device } = useLocalSearchParams<{ device: string }>();
14 |
15 | const actions = useNestActions();
16 |
17 | const view = useMemo(
18 | () => actions.getDeviceInfoAsync({ deviceId: device }),
19 | [device]
20 | );
21 |
22 | return (
23 | <>
24 |
25 | }>{view}
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/index.tsx:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | "use client";
4 |
5 | import { Stack } from "expo-router";
6 | import * as React from "react";
7 | import {
8 | Text,
9 | Button,
10 | View,
11 | ActivityIndicator,
12 | RefreshControl,
13 | } from "react-native";
14 |
15 | import NestButton from "@/components/nest/nest-auth-button";
16 | import { useNestAuth } from "@/lib/nest-auth";
17 | import { BodyScrollView } from "@/components/ui/body";
18 | import { UserPlaylists } from "@/components/user-playlists";
19 |
20 | export default function NestCard() {
21 | const nestAuth = useNestAuth();
22 |
23 | if (!nestAuth.accessToken) {
24 | return ;
25 | }
26 |
27 | return (
28 | <>
29 | nestAuth.clearAccessToken()}
37 | />
38 | );
39 | },
40 | }}
41 | />
42 |
43 | >
44 | );
45 | }
46 |
47 | function AuthenticatedPage() {
48 | const [refreshing, setRefreshing] = React.useState(false);
49 | const [key, setKey] = React.useState(0);
50 |
51 | const onRefresh = React.useCallback(() => {
52 | setRefreshing(true);
53 | setKey((prevKey) => prevKey + 1);
54 | setRefreshing(false);
55 | }, []);
56 |
57 | return (
58 |
61 | }
62 | >
63 |
64 |
65 | );
66 | }
67 |
68 | export { NestError as ErrorBoundary };
69 |
70 | // NOTE: This won't get called because server action invocation happens at the root :(
71 | function NestError({ error, retry }: { error: Error; retry: () => void }) {
72 | const nestAuth = useNestAuth();
73 |
74 | console.log("NestError:", error);
75 | React.useEffect(() => {
76 | if (error.message.includes("access token expired")) {
77 | nestAuth?.clearAccessToken();
78 | }
79 | }, [error, nestAuth]);
80 |
81 | return (
82 |
83 | {error.toString()}
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/src/assets/evan.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/evan.jpeg
--------------------------------------------------------------------------------
/src/assets/expo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/expo.png
--------------------------------------------------------------------------------
/src/assets/fonts/anonymous_pro/AnonymousPro-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/fonts/anonymous_pro/AnonymousPro-Bold.ttf
--------------------------------------------------------------------------------
/src/assets/fonts/anonymous_pro/AnonymousPro-Italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/fonts/anonymous_pro/AnonymousPro-Italic.ttf
--------------------------------------------------------------------------------
/src/assets/fonts/anonymous_pro/AnonymousPro-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/fonts/anonymous_pro/AnonymousPro-Regular.ttf
--------------------------------------------------------------------------------
/src/assets/fonts/anonymous_pro/OFL.txt:
--------------------------------------------------------------------------------
1 | Copyright (c) 2009, Mark Simonson (http://www.ms-studio.com, mark@marksimonson.com),
2 | with Reserved Font Name Anonymous Pro.
3 |
4 | This Font Software is licensed under the SIL Open Font License, Version 1.1.
5 | This license is copied below, and is also available with a FAQ at:
6 | https://openfontlicense.org
7 |
8 |
9 | -----------------------------------------------------------
10 | SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
11 | -----------------------------------------------------------
12 |
13 | PREAMBLE
14 | The goals of the Open Font License (OFL) are to stimulate worldwide
15 | development of collaborative font projects, to support the font creation
16 | efforts of academic and linguistic communities, and to provide a free and
17 | open framework in which fonts may be shared and improved in partnership
18 | with others.
19 |
20 | The OFL allows the licensed fonts to be used, studied, modified and
21 | redistributed freely as long as they are not sold by themselves. The
22 | fonts, including any derivative works, can be bundled, embedded,
23 | redistributed and/or sold with any software provided that any reserved
24 | names are not used by derivative works. The fonts and derivatives,
25 | however, cannot be released under any other type of license. The
26 | requirement for fonts to remain under this license does not apply
27 | to any document created using the fonts or their derivatives.
28 |
29 | DEFINITIONS
30 | "Font Software" refers to the set of files released by the Copyright
31 | Holder(s) under this license and clearly marked as such. This may
32 | include source files, build scripts and documentation.
33 |
34 | "Reserved Font Name" refers to any names specified as such after the
35 | copyright statement(s).
36 |
37 | "Original Version" refers to the collection of Font Software components as
38 | distributed by the Copyright Holder(s).
39 |
40 | "Modified Version" refers to any derivative made by adding to, deleting,
41 | or substituting -- in part or in whole -- any of the components of the
42 | Original Version, by changing formats or by porting the Font Software to a
43 | new environment.
44 |
45 | "Author" refers to any designer, engineer, programmer, technical
46 | writer or other person who contributed to the Font Software.
47 |
48 | PERMISSION & CONDITIONS
49 | Permission is hereby granted, free of charge, to any person obtaining
50 | a copy of the Font Software, to use, study, copy, merge, embed, modify,
51 | redistribute, and sell modified and unmodified copies of the Font
52 | Software, subject to the following conditions:
53 |
54 | 1) Neither the Font Software nor any of its individual components,
55 | in Original or Modified Versions, may be sold by itself.
56 |
57 | 2) Original or Modified Versions of the Font Software may be bundled,
58 | redistributed and/or sold with any software, provided that each copy
59 | contains the above copyright notice and this license. These can be
60 | included either as stand-alone text files, human-readable headers or
61 | in the appropriate machine-readable metadata fields within text or
62 | binary files as long as those fields can be easily viewed by the user.
63 |
64 | 3) No Modified Version of the Font Software may use the Reserved Font
65 | Name(s) unless explicit written permission is granted by the corresponding
66 | Copyright Holder. This restriction only applies to the primary font name as
67 | presented to the users.
68 |
69 | 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
70 | Software shall not be used to promote, endorse or advertise any
71 | Modified Version, except to acknowledge the contribution(s) of the
72 | Copyright Holder(s) and the Author(s) or with their explicit written
73 | permission.
74 |
75 | 5) The Font Software, modified or unmodified, in part or in whole,
76 | must be distributed entirely under this license, and must not be
77 | distributed under any other license. The requirement for fonts to
78 | remain under this license does not apply to any document created
79 | using the Font Software.
80 |
81 | TERMINATION
82 | This license becomes null and void if any of the above conditions are
83 | not met.
84 |
85 | DISCLAIMER
86 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
87 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
88 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
89 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
90 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
91 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
92 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
93 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
94 | OTHER DEALINGS IN THE FONT SOFTWARE.
95 |
--------------------------------------------------------------------------------
/src/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/icon.png
--------------------------------------------------------------------------------
/src/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/assets/splash.png
--------------------------------------------------------------------------------
/src/components/api.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { createNestAPI } from "@/components/nest/nest-actions";
4 | import * as API from "@/components/nest/nest-server-actions";
5 |
6 | export const { Provider: NestActionsProvider, useNest: useNestActions } =
7 | createNestAPI(API);
8 |
--------------------------------------------------------------------------------
/src/components/nest/camera-history.tsx:
--------------------------------------------------------------------------------
1 | // CameraHistory.tsx
2 | "use client";
3 |
4 | import React, { Suspense } from "react";
5 | import { View, Text, StyleSheet, Image } from "react-native";
6 |
7 | export interface CameraEvent {
8 | id: string;
9 | type: "Person" | "Motion" | "Sound";
10 | time: string;
11 | duration: string;
12 | thumbnail?: string;
13 | videoClip?: string;
14 | }
15 |
16 | interface DayEvents {
17 | date: string;
18 | events: CameraEvent[];
19 | }
20 |
21 | interface CameraHistoryProps {
22 | renderCameraHistory: () => Promise;
23 | }
24 |
25 | const HistoryFallback = () => (
26 |
27 | Today
28 | {[1, 2, 3].map((i) => (
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | ))}
40 |
41 | );
42 |
43 | export function CameraHistory({ renderCameraHistory }: CameraHistoryProps) {
44 | return (
45 | }>{renderCameraHistory()}
46 | );
47 | }
48 |
49 | const styles = StyleSheet.create({
50 | daySection: {
51 | padding: 16,
52 | },
53 | daySectionTitle: {
54 | color: "#fff",
55 | fontSize: 18,
56 | fontWeight: "600",
57 | marginBottom: 16,
58 | },
59 | eventCard: {
60 | flexDirection: "row",
61 | justifyContent: "space-between",
62 | alignItems: "center",
63 | marginBottom: 16,
64 | backgroundColor: "#2a2a2a",
65 | padding: 12,
66 | borderRadius: 12,
67 | },
68 | eventInfo: {
69 | flexDirection: "row",
70 | alignItems: "center",
71 | gap: 12,
72 | },
73 | eventType: {
74 | color: "#fff",
75 | fontSize: 16,
76 | fontWeight: "500",
77 | },
78 | eventTime: {
79 | color: "#999",
80 | fontSize: 14,
81 | },
82 | eventThumbnail: {
83 | width: 60,
84 | height: 40,
85 | borderRadius: 6,
86 | backgroundColor: "#333",
87 | },
88 | skeleton: {
89 | backgroundColor: "#333",
90 | borderRadius: 4,
91 | },
92 | iconSkeleton: {
93 | width: 24,
94 | height: 24,
95 | borderRadius: 12,
96 | },
97 | titleSkeleton: {
98 | width: 80,
99 | height: 16,
100 | marginBottom: 4,
101 | },
102 | timeSkeleton: {
103 | width: 120,
104 | height: 14,
105 | },
106 | thumbnailSkeleton: {
107 | width: 60,
108 | height: 40,
109 | borderRadius: 6,
110 | },
111 | });
112 |
--------------------------------------------------------------------------------
/src/components/nest/nest-actions.tsx:
--------------------------------------------------------------------------------
1 | ///
2 | "use client";
3 |
4 | import React from "react";
5 |
6 | // Helper type to extract the parameters excluding the first one (auth)
7 | type ExcludeFirstParameter any> = T extends (
8 | first: any,
9 | ...rest: infer R
10 | ) => any
11 | ? (...args: R) => ReturnType
12 | : never;
13 |
14 | // Helper type to transform all server actions to client actions
15 | type TransformServerActions> = {
16 | [K in keyof T]: ExcludeFirstParameter;
17 | };
18 |
19 | // Type for the auth context
20 | type AuthContext = {
21 | auth: { accessToken: string } | null;
22 | getFreshAccessToken: () => Promise<{ access_token: string }>;
23 | };
24 |
25 | export function createNestAPI<
26 | T extends Record<
27 | string,
28 | (auth: { access_token: string }, ...args: any[]) => any
29 | >
30 | >(serverActions: T) {
31 | // Create a new context with the transformed server actions
32 | const NestContext = React.createContext | null>(
33 | null
34 | );
35 |
36 | // Create the provider component
37 | function NestProvider({
38 | children,
39 | useAuth,
40 | }: {
41 | children: React.ReactNode;
42 | useAuth: () => AuthContext;
43 | }) {
44 | const authContext = useAuth();
45 |
46 | // Transform server actions to inject auth
47 | const transformedActions = React.useMemo(() => {
48 | const actions: Record = {};
49 |
50 | for (const [key, serverAction] of Object.entries(serverActions)) {
51 | actions[key] = async (...args: any[]) => {
52 | if (!authContext.auth) {
53 | return null;
54 | }
55 | return serverAction(await authContext.getFreshAccessToken(), ...args);
56 | };
57 | }
58 |
59 | return actions as TransformServerActions;
60 | }, [authContext]);
61 |
62 | return (
63 |
64 | {children}
65 |
66 | );
67 | }
68 |
69 | // Create a custom hook to use the context
70 | function useNest() {
71 | const context = React.useContext(NestContext);
72 | if (context === null) {
73 | throw new Error("useNest must be used within a NestProvider");
74 | }
75 | return context;
76 | }
77 |
78 | return {
79 | Provider: NestProvider,
80 | useNest,
81 | };
82 | }
83 |
--------------------------------------------------------------------------------
/src/components/nest/nest-auth-button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { NestBrandButton } from "./nest-brand-button";
6 | import { useNestAuth } from "@/lib/nest-auth";
7 |
8 | export default function NestAuthButton() {
9 | const { useNestAuthRequest } = useNestAuth();
10 |
11 | const [request, , promptAsync] = useNestAuthRequest();
12 |
13 | console.log("request", request);
14 |
15 | return (
16 | promptAsync()}
21 | />
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/src/components/nest/nest-brand-button.tsx:
--------------------------------------------------------------------------------
1 | import NestSvg from "@/components/svg/nest";
2 | import * as React from "react";
3 | import { Text, TouchableHighlight, ViewStyle } from "react-native";
4 |
5 | export function NestBrandButton({
6 | title,
7 | disabled,
8 | onPress,
9 | style,
10 | }: {
11 | title: string;
12 | disabled?: boolean;
13 | onPress: () => void;
14 | style?: ViewStyle;
15 | }) {
16 | return (
17 |
34 | <>
35 |
36 | {title}
37 | >
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/nest/nest-camera-detail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState } from "react";
4 | import {
5 | View,
6 | Text,
7 | StyleSheet,
8 | TouchableOpacity,
9 | Image,
10 | ScrollView,
11 | Dimensions,
12 | } from "react-native";
13 | import { MaterialCommunityIcons, Ionicons } from "@expo/vector-icons";
14 | import { Device } from "./nest-server-actions";
15 | import WebRTCPlayer from "./webrtc-dom-view";
16 | import { useNestAuth } from "@/lib/nest-auth";
17 | import { Stack } from "expo-router";
18 | import { CameraHistory } from "./camera-history";
19 |
20 | const SCREEN_HEIGHT = Dimensions.get("window").height;
21 |
22 | interface Event {
23 | type: string;
24 | time: string;
25 | duration?: string;
26 | thumbnail?: string;
27 | }
28 |
29 | const CameraDetailScreen = ({
30 | device,
31 | renderCameraHistory,
32 | }: {
33 | device: Device;
34 | renderCameraHistory: () => Promise;
35 | }) => {
36 | const [isMuted, setIsMuted] = useState(true);
37 | const [isFullscreen, setIsFullscreen] = useState(false);
38 |
39 | const {
40 | traits: {
41 | "sdm.devices.traits.Info": infoTrait,
42 | "sdm.devices.traits.CameraLiveStream": streamTrait,
43 | },
44 | parentRelations,
45 | } = device;
46 |
47 | const customName = infoTrait?.customName;
48 | const roomName = parentRelations[0]?.displayName;
49 | const hasAudio = streamTrait?.audioCodecs?.includes("OPUS");
50 |
51 | const auth = useNestAuth();
52 |
53 | // Mock events data - replace with actual API data
54 | const events: Event[] = [
55 | {
56 | type: "Person",
57 | time: "9:57 PM",
58 | duration: "12 sec",
59 | thumbnail: "/api/placeholder/120/80",
60 | },
61 | {
62 | type: "Person",
63 | time: "9:56 PM",
64 | duration: "16 sec",
65 | thumbnail: "/api/placeholder/120/80",
66 | },
67 | ];
68 |
69 | return (
70 |
71 |
76 |
77 |
80 |
92 |
93 |
94 | setIsMuted(!isMuted)}
97 | >
98 |
103 |
104 | setIsFullscreen(!isFullscreen)}
107 | >
108 |
113 |
114 |
115 |
116 |
117 |
118 | {!isFullscreen && (
119 |
120 |
121 | Today
122 | {events.map((event, index) => (
123 |
124 |
125 |
130 |
131 | {event.type}
132 |
133 | {event.time} • {event.duration}
134 |
135 |
136 |
137 |
141 |
142 | ))}
143 |
144 |
145 |
146 | Yesterday
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | )}
166 |
167 | );
168 | };
169 |
170 | const styles = StyleSheet.create({
171 | container: {
172 | flex: 1,
173 | backgroundColor: "#1a1a1a",
174 | },
175 | videoSection: {
176 | height: SCREEN_HEIGHT * 0.25,
177 | backgroundColor: "#000",
178 | },
179 | fullscreenVideo: {
180 | height: "100%",
181 | },
182 | header: {
183 | position: "absolute",
184 | top: 40,
185 | left: 0,
186 | right: 0,
187 | flexDirection: "row",
188 | justifyContent: "space-between",
189 | alignItems: "center",
190 | zIndex: 10,
191 | paddingHorizontal: 16,
192 | },
193 | headerButton: {
194 | width: 40,
195 | height: 40,
196 | justifyContent: "center",
197 | alignItems: "center",
198 | },
199 | liveIndicator: {
200 | flexDirection: "row",
201 | alignItems: "center",
202 | backgroundColor: "rgba(0,0,0,0.5)",
203 | paddingHorizontal: 12,
204 | paddingVertical: 6,
205 | borderRadius: 16,
206 | },
207 | liveDot: {
208 | width: 8,
209 | height: 8,
210 | borderRadius: 4,
211 | backgroundColor: "#4CAF50",
212 | marginRight: 6,
213 | },
214 | liveText: {
215 | color: "white",
216 | fontSize: 14,
217 | fontWeight: "500",
218 | },
219 | videoControls: {
220 | position: "absolute",
221 | bottom: 16,
222 | right: 16,
223 | flexDirection: "row",
224 | gap: 16,
225 | zIndex: 10,
226 | },
227 | controlButton: {
228 | width: 40,
229 | height: 40,
230 | borderRadius: 20,
231 | backgroundColor: "rgba(0,0,0,0.5)",
232 | justifyContent: "center",
233 | alignItems: "center",
234 | },
235 | eventsSection: {
236 | flex: 1,
237 | },
238 | daySection: {
239 | padding: 16,
240 | },
241 | daySectionTitle: {
242 | color: "#fff",
243 | fontSize: 18,
244 | fontWeight: "600",
245 | marginBottom: 16,
246 | },
247 | eventCard: {
248 | flexDirection: "row",
249 | justifyContent: "space-between",
250 | alignItems: "center",
251 | marginBottom: 16,
252 | backgroundColor: "#2a2a2a",
253 | padding: 12,
254 | borderRadius: 12,
255 | },
256 | eventInfo: {
257 | flexDirection: "row",
258 | alignItems: "center",
259 | gap: 12,
260 | },
261 | eventType: {
262 | color: "#fff",
263 | fontSize: 16,
264 | fontWeight: "500",
265 | },
266 | eventTime: {
267 | color: "#999",
268 | fontSize: 14,
269 | },
270 | eventThumbnail: {
271 | width: 60,
272 | height: 40,
273 | borderRadius: 6,
274 | backgroundColor: "#333",
275 | },
276 | controls: {
277 | flexDirection: "row",
278 | justifyContent: "space-between",
279 | alignItems: "center",
280 | padding: 16,
281 | paddingBottom: 32,
282 | },
283 | bottomButton: {
284 | width: 48,
285 | height: 48,
286 | justifyContent: "center",
287 | alignItems: "center",
288 | },
289 | muteButton: {
290 | width: 64,
291 | height: 64,
292 | borderRadius: 32,
293 | backgroundColor: "#fff",
294 | justifyContent: "center",
295 | alignItems: "center",
296 | },
297 | });
298 |
299 | export default CameraDetailScreen;
300 |
--------------------------------------------------------------------------------
/src/components/nest/nest-device-cards.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | View,
4 | Text,
5 | StyleSheet,
6 | TouchableOpacity,
7 | ScrollView,
8 | } from "react-native";
9 | import { MaterialCommunityIcons } from "@expo/vector-icons";
10 | import { Device, NestDevices } from "./nest-server-actions";
11 | import { Link } from "expo-router";
12 | import TouchableBounce from "../ui/TouchableBounce";
13 | import WebRTCPlayerWithAuth from "./webrtc-dom-view";
14 |
15 | // Temperature conversion utility
16 | const celsiusToFahrenheit = (celsius) => {
17 | return ((celsius * 9) / 5 + 32).toFixed(1);
18 | };
19 |
20 | // Individual Thermostat Component
21 | const ThermostatCard = ({ device }) => {
22 | const {
23 | traits: {
24 | "sdm.devices.traits.Temperature": tempTrait,
25 | "sdm.devices.traits.Humidity": humidityTrait,
26 | "sdm.devices.traits.ThermostatMode": modeTrait,
27 | "sdm.devices.traits.Connectivity": connectivityTrait,
28 | },
29 | parentRelations,
30 | } = device;
31 |
32 | const roomName = parentRelations[0]?.displayName || "Unknown Room";
33 | const temperature = tempTrait?.ambientTemperatureCelsius;
34 | const humidity = humidityTrait?.ambientHumidityPercent;
35 | const mode = modeTrait?.mode;
36 | const isOnline = connectivityTrait?.status === "ONLINE";
37 |
38 | return (
39 |
40 |
41 |
42 |
43 |
48 | {roomName} Thermostat
49 |
55 |
56 |
57 |
58 |
59 | {temperature ? `${celsiusToFahrenheit(temperature)}°F` : "--°F"}
60 |
61 | {humidity !== undefined && (
62 | Humidity: {humidity}%
63 | )}
64 | Mode: {mode || "OFF"}
65 |
66 |
67 |
68 |
69 | );
70 | };
71 |
72 | // Camera Component
73 | async function CameraCard({
74 | device,
75 | accessToken,
76 | }: {
77 | device: Device;
78 | accessToken: string;
79 | }) {
80 | const {
81 | traits: {
82 | "sdm.devices.traits.Info": infoTrait,
83 | "sdm.devices.traits.CameraLiveStream": streamTrait,
84 | },
85 | parentRelations,
86 | } = device;
87 |
88 | const customName = infoTrait?.customName;
89 | const roomName = parentRelations[0]?.displayName;
90 | const hasAudio = streamTrait?.audioCodecs?.includes("OPUS");
91 |
92 | const deviceId = device.name.split("/").pop();
93 |
94 | return (
95 |
96 |
97 |
98 |
99 |
100 | {customName || roomName}
101 |
102 |
103 |
116 |
117 |
118 |
123 |
124 |
125 |
126 |
127 |
128 |
129 | );
130 | }
131 |
132 | // Doorbell Component
133 | const DoorbellCard = ({ device, accessToken }) => {
134 | const {
135 | traits: {
136 | "sdm.devices.traits.Info": infoTrait,
137 | "sdm.devices.traits.CameraImage": imageTrait,
138 | },
139 | } = device;
140 |
141 | const customName = infoTrait?.customName;
142 | const maxResolution = imageTrait?.maxImageResolution;
143 | const deviceId = device.name.split("/").pop();
144 |
145 | return (
146 |
147 |
148 |
149 |
150 |
151 | {customName}
152 |
153 |
154 |
167 |
168 |
169 | {maxResolution && (
170 |
171 | Resolution: {maxResolution.width}x{maxResolution.height}
172 |
173 | )}
174 |
175 |
176 |
177 |
178 | );
179 | };
180 |
181 | // Main Device List Component
182 | export const NestDeviceList = ({
183 | devices,
184 | accessToken,
185 | }: {
186 | devices: NestDevices["devices"];
187 | accessToken: string;
188 | }) => {
189 | const renderDevice = (device: Device) => {
190 | switch (device.type) {
191 | case "sdm.devices.types.THERMOSTAT":
192 | return ;
193 | case "sdm.devices.types.CAMERA":
194 | return (
195 |
200 | );
201 | case "sdm.devices.types.DOORBELL":
202 | return (
203 |
208 | );
209 | default:
210 | return null;
211 | }
212 | };
213 |
214 | return (
215 |
216 | {devices.map(renderDevice)}
217 |
218 | );
219 | };
220 |
221 | const styles = {
222 | container: {
223 | flex: 1,
224 | padding: 16,
225 | },
226 | card: {
227 | backgroundColor: "white",
228 | borderRadius: 12,
229 | padding: 16,
230 | marginBottom: 16,
231 | elevation: 3,
232 | shadowColor: "#000",
233 | shadowOffset: { width: 0, height: 2 },
234 | shadowOpacity: 0.1,
235 | shadowRadius: 4,
236 | },
237 | header: {
238 | flexDirection: "row",
239 | alignItems: "center",
240 | marginBottom: 12,
241 | },
242 | title: {
243 | fontSize: 18,
244 | fontWeight: "600",
245 | marginLeft: 8,
246 | flex: 1,
247 | },
248 | content: {
249 | alignItems: "center",
250 | },
251 | temperature: {
252 | fontSize: 48,
253 | fontWeight: "300",
254 | marginVertical: 8,
255 | },
256 | humidity: {
257 | fontSize: 16,
258 | color: "#757575",
259 | marginVertical: 4,
260 | },
261 | mode: {
262 | fontSize: 16,
263 | color: "#757575",
264 | marginTop: 4,
265 | },
266 | button: {
267 | backgroundColor: "#2196F3",
268 | paddingHorizontal: 20,
269 | paddingVertical: 10,
270 | borderRadius: 8,
271 | marginVertical: 8,
272 | },
273 | buttonText: {
274 | color: "white",
275 | fontSize: 16,
276 | fontWeight: "500",
277 | },
278 | features: {
279 | flexDirection: "row",
280 | justifyContent: "center",
281 | gap: 16,
282 | marginTop: 8,
283 | },
284 | resolution: {
285 | fontSize: 14,
286 | color: "#757575",
287 | marginTop: 8,
288 | },
289 | statusDot: {
290 | width: 8,
291 | height: 8,
292 | borderRadius: 4,
293 | marginLeft: 8,
294 | },
295 | } as const;
296 |
297 | export default NestDeviceList;
298 |
--------------------------------------------------------------------------------
/src/components/nest/nest-devices-fixture.json:
--------------------------------------------------------------------------------
1 | {"devices":[{"name":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEvdK1cepot7Tl8di0HPwGQNKmKXHv3GGrXTEoB1Mh2eRHs3wLTKIkfd3tuENg30fy8Xs5Ihttcvs1t-zHW4rf0WHA","type":"sdm.devices.types.THERMOSTAT","assignee":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEvuSr_trtpgnvr9I2tl8a0LqGfw89qlvOq7CWg_Jw1-lGeOnx0ORTJUinEN9EU50jOrX1cBlNR5j7OjF9Eu1jXV4nGqM0u2vuKPT_YvNqDrm75hwlQjylEY7C8hmSjf2KkgJAwTJ-Q","traits":{"sdm.devices.traits.Info":{"customName":""},"sdm.devices.traits.Humidity":{"ambientHumidityPercent":0},"sdm.devices.traits.Connectivity":{"status":"OFFLINE"},"sdm.devices.traits.Fan":{},"sdm.devices.traits.ThermostatMode":{"mode":"OFF","availableModes":["OFF"]},"sdm.devices.traits.ThermostatEco":{"availableModes":["OFF","MANUAL_ECO"],"mode":"OFF","heatCelsius":4.4444427,"coolCelsius":24.444443},"sdm.devices.traits.ThermostatHvac":{"status":"OFF"},"sdm.devices.traits.Settings":{"temperatureScale":"FAHRENHEIT"},"sdm.devices.traits.ThermostatTemperatureSetpoint":{},"sdm.devices.traits.Temperature":{"ambientTemperatureCelsius":19.64}},"parentRelations":[{"parent":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEvuSr_trtpgnvr9I2tl8a0LqGfw89qlvOq7CWg_Jw1-lGeOnx0ORTJUinEN9EU50jOrX1cBlNR5j7OjF9Eu1jXV4nGqM0u2vuKPT_YvNqDrm75hwlQjylEY7C8hmSjf2KkgJAwTJ-Q","displayName":"Downstairs"}]},{"name":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEvUsl4vf5Bbhux7ceNZn68obGc2yjeQ8xjesybZ2uqW8ybm1c-Yg6pwEY_wmKTjJFm8cqhJ9hUiG-_ig0WBEYU9dA","type":"sdm.devices.types.THERMOSTAT","assignee":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEvnyPY_rnFW_JRHzlpgVNSx9HeWEjpYnKZk-E1cwI5IXCQMppa9pvk0-exBVx57r2zJB7Cwa9_XBK3GniS-KXbKHWTT6r9kOHkmt0tDq6xsHvqnkgDNx1203Glk6NOOiDAS4QMh4vQ","traits":{"sdm.devices.traits.Info":{"customName":""},"sdm.devices.traits.Humidity":{"ambientHumidityPercent":48},"sdm.devices.traits.Connectivity":{"status":"ONLINE"},"sdm.devices.traits.Fan":{"timerMode":"OFF"},"sdm.devices.traits.ThermostatMode":{"mode":"OFF","availableModes":["HEAT","COOL","HEATCOOL","OFF"]},"sdm.devices.traits.ThermostatEco":{"availableModes":["OFF","MANUAL_ECO"],"mode":"OFF","heatCelsius":4.4444427,"coolCelsius":24.444427},"sdm.devices.traits.ThermostatHvac":{"status":"OFF"},"sdm.devices.traits.Settings":{"temperatureScale":"FAHRENHEIT"},"sdm.devices.traits.ThermostatTemperatureSetpoint":{},"sdm.devices.traits.Temperature":{"ambientTemperatureCelsius":20.789993}},"parentRelations":[{"parent":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEvnyPY_rnFW_JRHzlpgVNSx9HeWEjpYnKZk-E1cwI5IXCQMppa9pvk0-exBVx57r2zJB7Cwa9_XBK3GniS-KXbKHWTT6r9kOHkmt0tDq6xsHvqnkgDNx1203Glk6NOOiDAS4QMh4vQ","displayName":"Upstairs"}]},{"name":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEtDbjwxaw0KicmC6YA_jmWBNOgfgqGSPQk_HJcJUNbOfkpu-_tTwKQcqJdSj6xDgave-qkNO16mC2Wk1DYmPdowqw","type":"sdm.devices.types.CAMERA","assignee":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEuM39_kxV7uCBR3JGq61ZYWKIAAWg_cUxT8eWyLiBem8PFEmbPfGBMXGOKTUFP9H5XetbREeHC2xW19ZSTgu2tPEEkICB9oCRR2R-mX-1E4-xHHsKY4rqW0nwWQ_pwja0Yrprp1Fks","traits":{"sdm.devices.traits.Info":{"customName":"Backyard camera"},"sdm.devices.traits.CameraLiveStream":{"videoCodecs":["H264"],"audioCodecs":["OPUS"],"supportedProtocols":["WEB_RTC"]},"sdm.devices.traits.CameraPerson":{},"sdm.devices.traits.CameraMotion":{}},"parentRelations":[{"parent":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEuM39_kxV7uCBR3JGq61ZYWKIAAWg_cUxT8eWyLiBem8PFEmbPfGBMXGOKTUFP9H5XetbREeHC2xW19ZSTgu2tPEEkICB9oCRR2R-mX-1E4-xHHsKY4rqW0nwWQ_pwja0Yrprp1Fks","displayName":"Backyard"}]},{"name":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEscGZNRSC_Nt_HcDDd3y6ztycH86MRuf_JYD-cxD3L0-4-yUgb2UTtQFAUKB98Mp4Fcm_ScuJnNsJIUawnwVjXAxQ","type":"sdm.devices.types.DOORBELL","assignee":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEsQt4gD-JQ1AztQgUcb-xEpotzEhdRAFqDTXL9164reTK-tTcXxBZXEYEFtBVI9zyfiBXqxNgyilA8LdpKemGwyJFwXig2QhUl4thN43loXqtjxM1J331d_MW0lapvEx_uwkfgOfZM","traits":{"sdm.devices.traits.Info":{"customName":"Front door doorbell"},"sdm.devices.traits.CameraLiveStream":{"videoCodecs":["H264"],"audioCodecs":["OPUS"],"supportedProtocols":["WEB_RTC"]},"sdm.devices.traits.CameraImage":{"maxImageResolution":{"width":1920,"height":1200}},"sdm.devices.traits.CameraPerson":{},"sdm.devices.traits.CameraMotion":{},"sdm.devices.traits.DoorbellChime":{},"sdm.devices.traits.CameraClipPreview":{}},"parentRelations":[{"parent":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEsQt4gD-JQ1AztQgUcb-xEpotzEhdRAFqDTXL9164reTK-tTcXxBZXEYEFtBVI9zyfiBXqxNgyilA8LdpKemGwyJFwXig2QhUl4thN43loXqtjxM1J331d_MW0lapvEx_uwkfgOfZM","displayName":"Front door"}]},{"name":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEuEdjaBxQIzT7rWPM7bC85fxXdQnyJ2QxvymxTtd4xNDqUxz5ZGcE3erFQRwha4Xz6BdDotA4gMEDCSP4NUo5aj2g","type":"sdm.devices.types.CAMERA","assignee":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEtDiWA9m5fbON4CjR35qmdVnkAXK8lejgqjCV0Jhl6n1q7WBeEaah5J-Px8fkHLBMLNsYcqj_fPYEi_rBGLTRJLekeIyZO5AFxSlh4bxP3fj_voXzfw7vxeNw2v9DQMfxIOBPkk01s","traits":{"sdm.devices.traits.Info":{"customName":"Entryway camera"},"sdm.devices.traits.CameraLiveStream":{"videoCodecs":["H264"],"audioCodecs":["OPUS"],"supportedProtocols":["WEB_RTC"]},"sdm.devices.traits.CameraPerson":{},"sdm.devices.traits.CameraMotion":{}},"parentRelations":[{"parent":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEtDiWA9m5fbON4CjR35qmdVnkAXK8lejgqjCV0Jhl6n1q7WBeEaah5J-Px8fkHLBMLNsYcqj_fPYEi_rBGLTRJLekeIyZO5AFxSlh4bxP3fj_voXzfw7vxeNw2v9DQMfxIOBPkk01s","displayName":"Entryway"}]},{"name":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEsk69jco-CVCmKqFNCsKAO2_ktF2HGHGraPy_5abb14aqIEmHwHi894vHM8Mnj64NWE2V7-0l9G7B6vp5lBpnFc5g","type":"sdm.devices.types.CAMERA","assignee":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEsBLRjTSLSJJ0PwApHRs_qTkuM4xtdqx9_rSz9Cl278JPvkymT3zhhcca_Nv2Q9mP7o-pAv6OlRhPTN2XK7_7Y1fDoMIlN90bDzJDQfo88d5FRdYr4yS44a_l8iCs8JTuGbBRvcA5Q","traits":{"sdm.devices.traits.Info":{"customName":"driveway camera"},"sdm.devices.traits.CameraLiveStream":{"videoCodecs":["H264"],"audioCodecs":["OPUS"],"supportedProtocols":["WEB_RTC"]},"sdm.devices.traits.CameraPerson":{},"sdm.devices.traits.CameraMotion":{}},"parentRelations":[{"parent":"enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEsBLRjTSLSJJ0PwApHRs_qTkuM4xtdqx9_rSz9Cl278JPvkymT3zhhcca_Nv2Q9mP7o-pAv6OlRhPTN2XK7_7Y1fDoMIlN90bDzJDQfo88d5FRdYr4yS44a_l8iCs8JTuGbBRvcA5Q","displayName":"Driveway"}]}]}
--------------------------------------------------------------------------------
/src/components/nest/nest-server-actions.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import React from "react";
4 |
5 | import { Image, Text, View } from "react-native";
6 | import NestDeviceList from "./nest-device-cards";
7 | import CameraDetailScreen from "./nest-camera-detail";
8 | import ThermostatDetailScreen from "./thermostat-detail";
9 | import { MaterialCommunityIcons } from "@expo/vector-icons";
10 |
11 | async function nestFetchJson(auth: { access_token: string }, url: string) {
12 | const res = await fetch(
13 | `https://smartdevicemanagement.googleapis.com/v1/enterprises/${
14 | process.env.EXPO_PUBLIC_NEST_PROJECT_ID
15 | }/${url.replace(/^\//, "")}`,
16 | {
17 | headers: {
18 | "Content-Type": "application/json",
19 | Authorization: `Bearer ${auth.access_token}`,
20 | },
21 | }
22 | );
23 |
24 | if (res.status !== 200) {
25 | throw new Error(await res.text());
26 | }
27 |
28 | return res.json();
29 | }
30 |
31 | export const getDeviceInfoJsonAsync = async (
32 | auth: { access_token: string },
33 | props: { deviceId: string }
34 | ) => {
35 | // console.log("getDeviceInfoAsync", props);
36 |
37 | return await nestFetchJson(auth, `devices/${props.deviceId}`);
38 | };
39 | export const getDeviceInfoAsync = async (
40 | auth: { access_token: string },
41 | props: { deviceId: string }
42 | ) => {
43 | // console.log("getDeviceInfoAsync", props);
44 |
45 | const data: Device = await nestFetchJson(auth, `devices/${props.deviceId}`);
46 | // console.log("nest.device:", JSON.stringify(data));
47 |
48 | // const dataFixture = {
49 | // name: "enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/devices/AVPHwEtDbjwxaw0KicmC6YA_jmWBNOgfgqGSPQk_HJcJUNbOfkpu-_tTwKQcqJdSj6xDgave-qkNO16mC2Wk1DYmPdowqw",
50 | // type: "sdm.devices.types.CAMERA",
51 | // assignee:
52 | // "enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEuM39_kxV7uCBR3JGq61ZYWKIAAWg_cUxT8eWyLiBem8PFEmbPfGBMXGOKTUFP9H5XetbREeHC2xW19ZSTgu2tPEEkICB9oCRR2R-mX-1E4-xHHsKY4rqW0nwWQ_pwja0Yrprp1Fks",
53 | // traits: {
54 | // "sdm.devices.traits.Info": { customName: "Backyard camera" },
55 | // "sdm.devices.traits.CameraLiveStream": {
56 | // videoCodecs: ["H264"],
57 | // audioCodecs: ["OPUS"],
58 | // supportedProtocols: ["WEB_RTC"],
59 | // },
60 | // "sdm.devices.traits.CameraPerson": {},
61 | // "sdm.devices.traits.CameraMotion": {},
62 | // },
63 | // parentRelations: [
64 | // {
65 | // parent:
66 | // "enterprises/0fe1e2fa-6f3d-4def-8dcd-0d865762ca22/structures/AVPHwEuCQo--bj0J52VoxE2GaQBypuhBLxBE2N4jbD2XsDnugexucqiE-x7tVqCl0BHmCQ9WLGKHitUht8DG8xeLbiLqPw/rooms/AVPHwEuM39_kxV7uCBR3JGq61ZYWKIAAWg_cUxT8eWyLiBem8PFEmbPfGBMXGOKTUFP9H5XetbREeHC2xW19ZSTgu2tPEEkICB9oCRR2R-mX-1E4-xHHsKY4rqW0nwWQ_pwja0Yrprp1Fks",
67 | // displayName: "Backyard",
68 | // },
69 | // ],
70 | // };
71 |
72 | const deviceId = data.name.split("/").pop()!;
73 | // is thermostat
74 | const isThermostat = data.type === "sdm.devices.types.THERMOSTAT";
75 | if (isThermostat) {
76 | return (
77 | {
84 | "use server";
85 |
86 | let command: string;
87 | let params: any = {};
88 |
89 | // Convert command based on mode
90 | switch (props.mode) {
91 | case "HEAT":
92 | // https://developers.google.com/nest/device-access/traits/device/thermostat-temperature-setpoint#setheat
93 | command =
94 | "sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat";
95 | params = { heatCelsius: props.heatCelsius };
96 | break;
97 | case "COOL":
98 | // https://developers.google.com/nest/device-access/traits/device/thermostat-temperature-setpoint#setcool
99 | command =
100 | "sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool";
101 | params = { coolCelsius: props.coolCelsius };
102 | break;
103 | case "HEAT_COOL":
104 | // https://developers.google.com/nest/device-access/traits/device/thermostat-temperature-setpoint#setrange
105 | command =
106 | "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange";
107 | params = {
108 | heatCelsius: props.heatCelsius,
109 | coolCelsius: props.coolCelsius,
110 | };
111 | break;
112 | case "OFF":
113 | command = "sdm.devices.commands.ThermostatMode.SetMode";
114 | params = { mode: "OFF" };
115 | break;
116 | default:
117 | throw new Error(`Invalid thermostat mode: ${props.mode}`);
118 | }
119 |
120 | console.log("data.deviceId", deviceId);
121 | const res = await sendNestCommandAsync({
122 | accessToken: auth.access_token,
123 | deviceId,
124 | command,
125 | params,
126 | });
127 |
128 | console.log("nest.thermostat:", JSON.stringify(res));
129 | return res;
130 | }}
131 | setThermostatMode={async (props: { mode: ThermostatMode }) => {
132 | "use server";
133 | const res = await sendNestCommandAsync({
134 | accessToken: auth.access_token,
135 | deviceId,
136 | command: "sdm.devices.commands.ThermostatMode.SetMode",
137 | params: { mode: props.mode },
138 | });
139 |
140 | console.log("nest.thermostat:", JSON.stringify(res));
141 | return res;
142 | }}
143 | />
144 | );
145 | }
146 |
147 | return (
148 | {
151 | "use server";
152 |
153 | const events = await getCameraEvents(deviceId, auth.access_token);
154 |
155 | return (
156 |
157 | {events.map((event) => (
158 |
159 |
160 |
173 |
174 | {event.type}
175 |
176 | {new Date(event.timestamp).toLocaleTimeString([], {
177 | hour: "numeric",
178 | minute: "2-digit",
179 | })}
180 |
181 |
182 |
183 | {event.imageUrl && (
184 |
188 | )}
189 |
190 | ))}
191 |
192 | );
193 | }}
194 | />
195 | );
196 |
197 | // return (
198 | //
199 | // Device Info
200 | // {JSON.stringify(dataFixture)}
201 | //
202 | // );
203 | // return null;
204 | };
205 |
206 | const styles = {
207 | container: {
208 | padding: 16,
209 | },
210 | eventCard: {
211 | flexDirection: "row",
212 | justifyContent: "space-between",
213 | alignItems: "center",
214 | backgroundColor: "#2a2a2a",
215 | padding: 12,
216 | borderRadius: 12,
217 | marginBottom: 16,
218 | },
219 | eventInfo: {
220 | flexDirection: "row",
221 | alignItems: "center",
222 | gap: 12,
223 | },
224 | eventType: {
225 | color: "#fff",
226 | fontSize: 16,
227 | fontWeight: "500",
228 | },
229 | eventTime: {
230 | color: "#999",
231 | fontSize: 14,
232 | },
233 | eventThumbnail: {
234 | width: 60,
235 | height: 40,
236 | borderRadius: 6,
237 | backgroundColor: "#333",
238 | },
239 | } as const;
240 |
241 | interface CameraEvent {
242 | eventId: string;
243 | timestamp: string;
244 | type: "Motion" | "Person" | "Sound" | "Chime";
245 | imageUrl?: string;
246 | }
247 |
248 | interface GenerateImageResponse {
249 | url: string;
250 | token: string;
251 | }
252 |
253 | export async function getCameraEvents(
254 | deviceId: string,
255 | accessToken: string
256 | ): Promise {
257 | // Here you would implement the actual events API call
258 | // For demonstration, returning mock data that matches the API structure
259 | const events = [
260 | {
261 | eventId: "event1",
262 | timestamp: new Date().toISOString(),
263 | type: "Person" as const,
264 | },
265 | {
266 | eventId: "event2",
267 | timestamp: new Date(Date.now() - 5 * 60000).toISOString(),
268 | type: "Motion" as const,
269 | },
270 | ];
271 |
272 | // Fetch images for each event
273 | const eventsWithImages = await Promise.all(
274 | events.map(async (event) => {
275 | try {
276 | const imageData = await generateEventImage(
277 | deviceId,
278 | event.eventId,
279 | accessToken
280 | );
281 | const imageUrl = await fetchEventImage(imageData.url, imageData.token);
282 | return { ...event, imageUrl };
283 | } catch (error) {
284 | console.error(
285 | `Failed to fetch image for event ${event.eventId}:`,
286 | error
287 | );
288 | return event;
289 | }
290 | })
291 | );
292 |
293 | return eventsWithImages;
294 | }
295 |
296 | async function generateEventImage(
297 | deviceId: string,
298 | eventId: string,
299 | accessToken: string
300 | ): Promise {
301 | const response = await sendNestCommandAsync({
302 | accessToken,
303 | deviceId,
304 | command: "sdm.devices.commands.CameraEventImage.GenerateImage",
305 | params: {
306 | eventId,
307 | },
308 | });
309 |
310 | return {
311 | url: response.results.url,
312 | token: response.results.token,
313 | };
314 | }
315 |
316 | async function listEvents(deviceId: string, accessToken: string) {
317 | const response = await fetch(
318 | `https://smartdevicemanagement.googleapis.com/v1/enterprises/${process.env.EXPO_PUBLIC_NEST_PROJECT_ID}/devices/${deviceId}/events`,
319 | {
320 | headers: {
321 | Authorization: `Bearer ${accessToken}`,
322 | "Content-Type": "application/json",
323 | },
324 | }
325 | );
326 |
327 | if (!response.ok) {
328 | const error = await response.text();
329 | console.error("Failed to fetch events:", error);
330 | throw new Error(error);
331 | }
332 |
333 | return response.json();
334 | }
335 |
336 | async function fetchEventImage(url: string, token: string): Promise {
337 | const response = await fetch(url, {
338 | headers: {
339 | Authorization: `Basic ${token}`,
340 | },
341 | });
342 |
343 | if (!response.ok) {
344 | throw new Error("Failed to fetch event image");
345 | }
346 |
347 | // Convert the image data to base64 or handle it appropriately
348 | const buffer = await response.arrayBuffer();
349 | return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
350 | }
351 |
352 | export type ThermostatMode = "HEAT" | "COOL" | "HEAT_COOL" | "OFF";
353 |
354 | export const renderDevicesAsync = async (auth: { access_token: string }) => {
355 | // const data = require("./nest-devices-fixture.json") as NestDevices;
356 | const data = (await fetch(
357 | `https://smartdevicemanagement.googleapis.com/v1/enterprises/${process.env.EXPO_PUBLIC_NEST_PROJECT_ID}/devices`,
358 | {
359 | headers: {
360 | "Content-Type": "application/json",
361 | Authorization: `Bearer ${auth.access_token}`,
362 | },
363 | }
364 | ).then((res) => res.json())) as NestDevices;
365 |
366 | console.log("nest devices: ", JSON.stringify(data));
367 |
368 | return (
369 |
370 | );
371 | };
372 |
373 | export async function generateWebRtcStream(
374 | auth: { access_token: string },
375 | device: { deviceId: string; offerSdp: string }
376 | ): Promise<{
377 | results: {
378 | answerSdp: string;
379 | expiresAt: string;
380 | mediaSessionId: string;
381 | };
382 | }> {
383 | return sendNestCommandAsync({
384 | accessToken: auth.access_token,
385 | deviceId: device.deviceId,
386 | command: "sdm.devices.commands.CameraLiveStream.GenerateWebRtcStream",
387 | params: {
388 | offerSdp: device.offerSdp,
389 | },
390 | });
391 | }
392 |
393 | async function sendNestCommandAsync(props: {
394 | accessToken: string;
395 | deviceId: string;
396 | command: string;
397 | params: any;
398 | }) {
399 | const res = await fetch(
400 | `https://smartdevicemanagement.googleapis.com/v1/enterprises/${process.env.EXPO_PUBLIC_NEST_PROJECT_ID}/devices/${props.deviceId}:executeCommand`,
401 | {
402 | headers: {
403 | "Content-Type": "application/json",
404 | Authorization: `Bearer ${props.accessToken}`,
405 | },
406 | method: "POST",
407 | body: JSON.stringify({
408 | command: props.command,
409 | params: props.params,
410 | }),
411 | }
412 | );
413 |
414 | if ([400, 429].includes(res.status)) {
415 | const data = await res.json();
416 | const err = new Error(data.error.message);
417 | err.status = data.error.status;
418 | throw err;
419 | }
420 | if (res.status !== 200) {
421 | throw new Error(await res.text());
422 | }
423 | const data = await res.json();
424 |
425 | console.log("nest.cmd:", JSON.stringify(data));
426 | return data;
427 | }
428 |
429 | export interface NestDevices {
430 | devices: Device[];
431 | }
432 |
433 | export interface Device {
434 | name: string;
435 | type: string;
436 | assignee: string;
437 | traits: Traits;
438 | parentRelations: ParentRelation[];
439 | }
440 |
441 | export interface ParentRelation {
442 | parent: string;
443 | displayName: string;
444 | }
445 |
446 | export interface Traits {
447 | "sdm.devices.traits.Info": SdmDevicesTraitsInfo;
448 | "sdm.devices.traits.Humidity"?: SdmDevicesTraitsHumidity;
449 | "sdm.devices.traits.Connectivity"?: SdmDevicesTraitsConnectivityClass;
450 | "sdm.devices.traits.Fan"?: SdmDevicesTraitsFan;
451 | "sdm.devices.traits.ThermostatMode"?: SdmDevicesTraitsThermostatMode;
452 | "sdm.devices.traits.ThermostatEco"?: SdmDevicesTraitsThermostatEco;
453 | "sdm.devices.traits.ThermostatHvac"?: SdmDevicesTraitsConnectivityClass;
454 | "sdm.devices.traits.Settings"?: SdmDevicesTraitsSettings;
455 | "sdm.devices.traits.ThermostatTemperatureSetpoint"?: SdmDevicesTraitsThermostatTemperatureSetpoint;
456 | "sdm.devices.traits.Temperature"?: SdmDevicesTraitsTemperature;
457 | "sdm.devices.traits.CameraLiveStream"?: SdmDevicesTraitsCameraLiveStream;
458 | "sdm.devices.traits.CameraPerson"?: SdmDevicesTraitsCameraClipPreviewClass;
459 | "sdm.devices.traits.CameraMotion"?: SdmDevicesTraitsCameraClipPreviewClass;
460 | "sdm.devices.traits.CameraImage"?: SdmDevicesTraitsCameraImage;
461 | "sdm.devices.traits.DoorbellChime"?: SdmDevicesTraitsCameraClipPreviewClass;
462 | "sdm.devices.traits.CameraClipPreview"?: SdmDevicesTraitsCameraClipPreviewClass;
463 | }
464 |
465 | export interface SdmDevicesTraitsCameraClipPreviewClass {}
466 |
467 | export interface SdmDevicesTraitsCameraImage {
468 | maxImageResolution: MaxImageResolution;
469 | }
470 |
471 | export interface MaxImageResolution {
472 | width: number;
473 | height: number;
474 | }
475 |
476 | export interface SdmDevicesTraitsCameraLiveStream {
477 | videoCodecs: string[];
478 | audioCodecs: string[];
479 | supportedProtocols: string[];
480 | }
481 |
482 | export interface SdmDevicesTraitsConnectivityClass {
483 | status: string;
484 | }
485 |
486 | export interface SdmDevicesTraitsFan {
487 | timerMode?: string;
488 | }
489 |
490 | export interface SdmDevicesTraitsHumidity {
491 | ambientHumidityPercent: number;
492 | }
493 |
494 | export interface SdmDevicesTraitsInfo {
495 | customName: string;
496 | }
497 |
498 | export interface SdmDevicesTraitsSettings {
499 | temperatureScale: string;
500 | }
501 |
502 | export interface SdmDevicesTraitsTemperature {
503 | ambientTemperatureCelsius: number;
504 | }
505 |
506 | export interface SdmDevicesTraitsThermostatEco {
507 | availableModes: string[];
508 | mode: string;
509 | heatCelsius: number;
510 | coolCelsius: number;
511 | }
512 |
513 | export interface SdmDevicesTraitsThermostatMode {
514 | mode: "HEAT" | "COOL" | "HEATCOOL" | "OFF";
515 | availableModes: ("HEAT" | "COOL" | "HEATCOOL" | "OFF")[];
516 | }
517 |
518 | export interface SdmDevicesTraitsThermostatTemperatureSetpoint {
519 | coolCelsius?: number;
520 | }
521 |
--------------------------------------------------------------------------------
/src/components/nest/thermostat-detail.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useState, useCallback } from "react";
4 | import {
5 | View,
6 | Text,
7 | StyleSheet,
8 | TouchableOpacity,
9 | ActivityIndicator,
10 | Dimensions,
11 | PanResponder,
12 | GestureResponderEvent,
13 | PanResponderGestureState,
14 | } from "react-native";
15 | import { MaterialCommunityIcons } from "@expo/vector-icons";
16 | import { Device, ThermostatMode } from "./nest-server-actions";
17 | import { Stack } from "expo-router";
18 |
19 | const CIRCLE_SIZE = Dimensions.get("window").width * 0.85;
20 | const CENTER = CIRCLE_SIZE / 2;
21 | const CIRCLE_WIDTH = 4;
22 |
23 | const ThermostatDetailScreen = ({
24 | device,
25 | updateTemperature,
26 | setThermostatMode,
27 | }: {
28 | device: Device;
29 | updateTemperature: (props: {
30 | mode: ThermostatMode;
31 | heatCelsius?: number;
32 | coolCelsius?: number;
33 | }) => Promise;
34 | setThermostatMode: (props: { mode: ThermostatMode }) => Promise;
35 | }) => {
36 | const [isLoading, setIsLoading] = useState(false);
37 | const [temperature, setTemperature] = useState(72);
38 | const [mode, setMode] = useState("COOL");
39 | const [isAdjusting, setIsAdjusting] = useState(false);
40 |
41 | const {
42 | traits: {
43 | "sdm.devices.traits.Info": infoTrait,
44 | "sdm.devices.traits.Temperature": tempTrait,
45 | },
46 | parentRelations,
47 | } = device;
48 |
49 | const customName = infoTrait?.customName;
50 | const roomName = parentRelations[0]?.displayName;
51 | const currentTemp = tempTrait?.ambientTemperatureCelsius;
52 | const humidity =
53 | device.traits?.["sdm.devices.traits.Humidity"]?.ambientHumidityPercent;
54 |
55 | const panResponder = PanResponder.create({
56 | onStartShouldSetPanResponder: () => true,
57 | onMoveShouldSetPanResponder: () => true,
58 | onPanResponderGrant: () => {
59 | setIsAdjusting(true);
60 | },
61 | onPanResponderMove: (
62 | e: GestureResponderEvent,
63 | gestureState: PanResponderGestureState
64 | ) => {
65 | const { moveY } = gestureState;
66 | const centerY = Dimensions.get("window").height / 2;
67 | const diff = (centerY - moveY) / 20;
68 | setTemperature((prev) =>
69 | Math.round(Math.max(50, Math.min(90, prev + diff)))
70 | );
71 | },
72 | onPanResponderRelease: async () => {
73 | setIsAdjusting(false);
74 | try {
75 | await updateTemperature({
76 | mode,
77 | coolCelsius:
78 | mode === "COOL" ? ((temperature - 32) * 5) / 9 : undefined,
79 | heatCelsius:
80 | mode === "HEAT" ? ((temperature - 32) * 5) / 9 : undefined,
81 | });
82 | } catch (error) {
83 | console.error("Failed to update temperature:", error);
84 | }
85 | },
86 | });
87 |
88 | const handleModeChange = async (newMode: ThermostatMode) => {
89 | setIsLoading(true);
90 | try {
91 | await setThermostatMode({ mode: newMode });
92 | setMode(newMode);
93 | } catch (error) {
94 | console.error("Failed to update mode:", error);
95 | } finally {
96 | setIsLoading(false);
97 | }
98 | };
99 |
100 | return (
101 |
102 |
107 |
108 |
109 |
110 |
111 | {temperature}
112 | {currentTemp && (
113 |
114 |
115 | {Math.round((currentTemp * 9) / 5 + 32)}°
116 |
117 |
118 | )}
119 |
120 |
121 |
122 |
123 |
124 |
125 | Using scheduled temperatures
126 |
127 |
128 |
129 | handleModeChange(mode === "COOL" ? "HEAT" : "COOL")}
132 | >
133 |
138 |
139 | Mode
140 |
141 | {"\n"}
142 | {mode}
143 |
144 |
145 |
146 |
147 |
148 |
153 |
154 | Sensors
155 | {"\n"}1 selected
156 |
157 |
158 |
159 |
160 |
161 |
162 | Fan
163 |
164 |
165 |
166 |
167 | Indoor temperature
168 |
169 | {Math.round((currentTemp * 9) / 5 + 32)}°
170 |
171 |
172 | {humidity && (
173 |
174 | Humidity
175 | {Math.round(humidity)}%
176 |
177 | )}
178 |
179 |
180 |
181 | {isLoading && (
182 |
183 |
184 |
185 | )}
186 |
187 | );
188 | };
189 |
190 | const styles = StyleSheet.create({
191 | container: {
192 | flex: 1,
193 | backgroundColor: "#F8F9FA",
194 | },
195 | header: {
196 | flexDirection: "row",
197 | alignItems: "center",
198 | justifyContent: "space-between",
199 | padding: 16,
200 | },
201 | headerRight: {
202 | flexDirection: "row",
203 | },
204 | iconButton: {
205 | marginLeft: 16,
206 | },
207 | title: {
208 | fontSize: 20,
209 | fontWeight: "600",
210 | },
211 | circleContainer: {
212 | alignItems: "center",
213 | justifyContent: "center",
214 | height: CIRCLE_SIZE,
215 | marginVertical: 20,
216 | },
217 | temperatureRing: {
218 | width: CIRCLE_SIZE,
219 | height: CIRCLE_SIZE,
220 | borderRadius: CIRCLE_SIZE / 2,
221 | borderWidth: CIRCLE_WIDTH,
222 | borderColor: "#E8E9ED",
223 | alignItems: "center",
224 | justifyContent: "center",
225 | },
226 | temperatureDisplay: {
227 | alignItems: "center",
228 | justifyContent: "center",
229 | },
230 | temperatureText: {
231 | fontSize: 72,
232 | fontWeight: "300",
233 | },
234 | currentTempMarker: {
235 | position: "absolute",
236 | top: -60,
237 | },
238 | smallTemp: {
239 | fontSize: 16,
240 | color: "#757575",
241 | },
242 | infoSection: {
243 | flex: 1,
244 | padding: 16,
245 | },
246 | infoRow: {
247 | backgroundColor: "#F1F3F4",
248 | padding: 16,
249 | borderRadius: 12,
250 | marginBottom: 16,
251 | },
252 | infoText: {
253 | color: "#000",
254 | fontSize: 16,
255 | },
256 | controlRow: {
257 | flexDirection: "row",
258 | gap: 16,
259 | marginBottom: 16,
260 | },
261 | controlButton: {
262 | flex: 1,
263 | flexDirection: "row",
264 | alignItems: "center",
265 | gap: 12,
266 | backgroundColor: "#F1F3F4",
267 | padding: 16,
268 | borderRadius: 12,
269 | },
270 | controlText: {
271 | fontSize: 16,
272 | color: "#000",
273 | },
274 | controlSubtext: {
275 | fontSize: 14,
276 | color: "#757575",
277 | },
278 | fanButton: {
279 | flexDirection: "row",
280 | alignItems: "center",
281 | gap: 12,
282 | backgroundColor: "#F1F3F4",
283 | padding: 16,
284 | borderRadius: 12,
285 | marginBottom: 16,
286 | },
287 | fanText: {
288 | fontSize: 16,
289 | color: "#000",
290 | },
291 | stats: {
292 | backgroundColor: "#F1F3F4",
293 | padding: 16,
294 | borderRadius: 12,
295 | },
296 | statRow: {
297 | flexDirection: "row",
298 | justifyContent: "space-between",
299 | marginBottom: 8,
300 | },
301 | statLabel: {
302 | fontSize: 16,
303 | color: "#000",
304 | },
305 | statValue: {
306 | fontSize: 16,
307 | color: "#000",
308 | fontWeight: "500",
309 | },
310 | loadingOverlay: {
311 | ...StyleSheet.absoluteFillObject,
312 | backgroundColor: "rgba(255, 255, 255, 0.7)",
313 | alignItems: "center",
314 | justifyContent: "center",
315 | },
316 | });
317 |
318 | export default ThermostatDetailScreen;
319 |
--------------------------------------------------------------------------------
/src/components/nest/webrtc-dom-view.tsx:
--------------------------------------------------------------------------------
1 | "use dom";
2 |
3 | import React, { useEffect, useRef, useState } from "react";
4 | import { generateWebRtcStream } from "./nest-server-actions";
5 |
6 | interface WebRTCPlayerProps {
7 | onOfferCreated: (offerSdp: string) => void;
8 | answerSdp?: string;
9 | }
10 |
11 | import "@/global.css";
12 |
13 | export default function WebRTCPlayerWithAuth({
14 | accessToken,
15 | deviceId,
16 | hideControls,
17 | }: {
18 | accessToken: string;
19 | deviceId: string;
20 | hideControls?: boolean;
21 | dom?: import("expo/dom").DOMProps;
22 | }) {
23 | const [isOffline, setIsOffline] = useState(false);
24 | const [answerSdp, setAnswerSdp] = useState(undefined);
25 |
26 | useEffect(() => {
27 | document.body.style.overflow = "hidden";
28 | }, []);
29 |
30 | if (isOffline) {
31 | return Battery is dead
;
32 | }
33 | return (
34 | {
37 | generateWebRtcStream(
38 | { access_token: accessToken },
39 | {
40 | deviceId,
41 | offerSdp: offer,
42 | }
43 | )
44 | .then((data) => {
45 | setAnswerSdp(data.results.answerSdp);
46 | })
47 | .catch((err) => {
48 | console.error("Failed to generate WebRTC stream", err.message);
49 | if (
50 | err.message.match("The camera is not available for streaming")
51 | ) {
52 | setIsOffline(true);
53 | }
54 | });
55 | }}
56 | answerSdp={answerSdp}
57 | />
58 | );
59 | }
60 |
61 | interface WebRTCPlayerProps {
62 | onOfferCreated: (offerSdp: string) => void;
63 | answerSdp?: string;
64 | hideControls?: boolean;
65 | }
66 |
67 | function WebRTCPlayer({
68 | onOfferCreated,
69 | hideControls,
70 | answerSdp,
71 | }: WebRTCPlayerProps) {
72 | const videoRef = useRef(null);
73 | const peerConnectionRef = useRef(null);
74 | const [error, setError] = useState("");
75 | const offerCreatedRef = useRef(false);
76 |
77 | useEffect(() => {
78 | let isMounted = true;
79 |
80 | async function setupWebRTC() {
81 | try {
82 | // Only setup if we haven't already
83 | if (offerCreatedRef.current) return;
84 |
85 | const pc = new RTCPeerConnection({
86 | iceServers: [],
87 | sdpSemantics: "unified-plan",
88 | });
89 | peerConnectionRef.current = pc;
90 |
91 | // Add transceivers in the required order
92 | pc.addTransceiver("audio", {
93 | direction: "recvonly",
94 | streams: [new MediaStream()],
95 | });
96 |
97 | pc.addTransceiver("video", {
98 | direction: "recvonly",
99 | streams: [new MediaStream()],
100 | });
101 |
102 | pc.createDataChannel("data");
103 |
104 | // Handle incoming tracks
105 | pc.ontrack = (event) => {
106 | if (videoRef.current && event.streams?.[0]) {
107 | videoRef.current.srcObject = event.streams[0];
108 | }
109 | };
110 |
111 | // Create and set local description
112 | const offer = await pc.createOffer();
113 |
114 | // Ensure the order of m-lines in SDP is correct
115 | let sdp = offer.sdp;
116 | if (sdp) {
117 | const sections = sdp.split("m=");
118 | const ordered = [
119 | sections[0],
120 | sections.find((s) => s.startsWith("audio")),
121 | sections.find((s) => s.startsWith("video")),
122 | sections.find((s) => s.startsWith("application")),
123 | ]
124 | .filter(Boolean)
125 | .join("m=");
126 | offer.sdp = ordered;
127 | }
128 |
129 | await pc.setLocalDescription(offer);
130 |
131 | // Wait for ICE gathering to complete
132 | await new Promise((resolve) => {
133 | if (pc.iceGatheringState === "complete") {
134 | resolve();
135 | } else {
136 | pc.addEventListener("icegatheringstatechange", () => {
137 | if (pc.iceGatheringState === "complete") {
138 | resolve();
139 | }
140 | });
141 | }
142 | });
143 |
144 | if (isMounted && pc.localDescription?.sdp) {
145 | offerCreatedRef.current = true;
146 | onOfferCreated(pc.localDescription.sdp);
147 | }
148 | } catch (err) {
149 | if (isMounted) {
150 | setError(
151 | err instanceof Error ? err.message : "Failed to setup WebRTC"
152 | );
153 | console.error("WebRTC setup error:", err);
154 | }
155 | }
156 | }
157 |
158 | setupWebRTC();
159 |
160 | return () => {
161 | isMounted = false;
162 | if (peerConnectionRef.current) {
163 | peerConnectionRef.current.close();
164 | peerConnectionRef.current = null;
165 | }
166 | offerCreatedRef.current = false;
167 | };
168 | }, []); // Remove onOfferCreated from dependencies
169 |
170 | useEffect(() => {
171 | async function handleAnswer() {
172 | const pc = peerConnectionRef.current;
173 | if (!pc || !answerSdp || !offerCreatedRef.current) return;
174 |
175 | try {
176 | if (pc.signalingState !== "have-local-offer") {
177 | console.log("Wrong signaling state:", pc.signalingState);
178 | return;
179 | }
180 |
181 | console.log("Setting remote description...");
182 | console.log("Current signaling state:", pc.signalingState);
183 | console.log("Current connection state:", pc.connectionState);
184 |
185 | await pc.setRemoteDescription(
186 | new RTCSessionDescription({
187 | type: "answer",
188 | sdp: answerSdp,
189 | })
190 | );
191 |
192 | console.log("Remote description set successfully");
193 | console.log("New signaling state:", pc.signalingState);
194 | console.log("New connection state:", pc.connectionState);
195 | } catch (err) {
196 | setError(
197 | err instanceof Error
198 | ? err.message
199 | : "Failed to set remote description"
200 | );
201 | console.error("Error setting remote description:", err);
202 | }
203 | }
204 |
205 | handleAnswer();
206 | }, [answerSdp]);
207 |
208 | return (
209 |
210 |
217 | {error && (
218 |
219 | {error}
220 |
221 | )}
222 |
223 | );
224 | }
225 |
--------------------------------------------------------------------------------
/src/components/svg/nest.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import * as React from "react";
3 | import Svg, { SvgProps, Path } from "react-native-svg";
4 | /* SVGR has dropped some elements not supported by react-native-svg: title */
5 | const SvgComponent = (props: SvgProps) => (
6 |
9 | );
10 | export default SvgComponent;
11 |
--------------------------------------------------------------------------------
/src/components/thermostat-skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { useEffect } from "react";
4 | import { View, Animated, StyleSheet, Dimensions, Easing } from "react-native";
5 |
6 | const CIRCLE_SIZE = Dimensions.get("window").width * 0.85;
7 |
8 | const ThermostatSkeleton = () => {
9 | const pulseAnim = new Animated.Value(0);
10 |
11 | useEffect(() => {
12 | const pulse = Animated.loop(
13 | Animated.sequence([
14 | Animated.timing(pulseAnim, {
15 | toValue: 1,
16 | duration: 1000,
17 | easing: Easing.ease,
18 | useNativeDriver: true,
19 | }),
20 | Animated.timing(pulseAnim, {
21 | toValue: 0,
22 | duration: 1000,
23 | easing: Easing.ease,
24 | useNativeDriver: true,
25 | }),
26 | ])
27 | );
28 |
29 | pulse.start();
30 |
31 | return () => pulse.stop();
32 | }, []);
33 |
34 | const opacityStyle = {
35 | opacity: pulseAnim.interpolate({
36 | inputRange: [0, 1],
37 | outputRange: [0.3, 0.7],
38 | }),
39 | };
40 |
41 | const SkeletonBlock = ({ style }) => (
42 |
43 | );
44 |
45 | return (
46 |
47 | {/* Temperature Circle */}
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {/* Info Section */}
56 |
57 | {/* Schedule Info */}
58 |
59 |
60 | {/* Mode and Sensors Controls */}
61 |
62 |
63 |
64 |
65 |
66 | {/* Fan Control */}
67 |
68 |
69 | {/* Stats */}
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | );
83 | };
84 |
85 | const styles = StyleSheet.create({
86 | container: {
87 | flex: 1,
88 | backgroundColor: "#F8F9FA",
89 | },
90 | skeleton: {
91 | backgroundColor: "#E1E9EE",
92 | borderRadius: 4,
93 | },
94 | header: {
95 | flexDirection: "row",
96 | alignItems: "center",
97 | justifyContent: "space-between",
98 | padding: 16,
99 | },
100 | headerRight: {
101 | flexDirection: "row",
102 | gap: 16,
103 | },
104 | iconBlock: {
105 | width: 24,
106 | height: 24,
107 | borderRadius: 12,
108 | marginHorizontal: 4,
109 | },
110 | titleBlock: {
111 | width: 120,
112 | height: 24,
113 | borderRadius: 6,
114 | },
115 | circleContainer: {
116 | alignItems: "center",
117 | justifyContent: "center",
118 | height: CIRCLE_SIZE,
119 | marginVertical: 20,
120 | },
121 | temperatureRing: {
122 | width: CIRCLE_SIZE,
123 | height: CIRCLE_SIZE,
124 | borderRadius: CIRCLE_SIZE / 2,
125 | borderWidth: 4,
126 | borderColor: "#E8E9ED",
127 | alignItems: "center",
128 | justifyContent: "center",
129 | },
130 | temperatureBlock: {
131 | width: 80,
132 | height: 60,
133 | borderRadius: 8,
134 | },
135 | currentTempMarker: {
136 | position: "absolute",
137 | top: 40,
138 | width: 32,
139 | height: 16,
140 | borderRadius: 4,
141 | },
142 | infoSection: {
143 | flex: 1,
144 | padding: 16,
145 | },
146 | infoBlock: {
147 | height: 56,
148 | borderRadius: 12,
149 | marginBottom: 16,
150 | },
151 | controlRow: {
152 | flexDirection: "row",
153 | gap: 16,
154 | marginBottom: 16,
155 | },
156 | controlBlock: {
157 | flex: 1,
158 | height: 80,
159 | borderRadius: 12,
160 | },
161 | fanBlock: {
162 | height: 56,
163 | borderRadius: 12,
164 | marginBottom: 16,
165 | },
166 | statsContainer: {
167 | padding: 16,
168 | borderRadius: 12,
169 | backgroundColor: "#F1F3F4",
170 | },
171 | statRow: {
172 | flexDirection: "row",
173 | justifyContent: "space-between",
174 | marginBottom: 12,
175 | },
176 | statLabelBlock: {
177 | width: 120,
178 | height: 16,
179 | borderRadius: 4,
180 | },
181 | statValueBlock: {
182 | width: 32,
183 | height: 16,
184 | borderRadius: 4,
185 | },
186 | });
187 |
188 | export default ThermostatSkeleton;
189 |
--------------------------------------------------------------------------------
/src/components/ui/FadeIn.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMemo, useRef } from "react";
4 | import { Animated } from "react-native";
5 |
6 | export function FadeIn({ children }: { children: React.ReactNode }) {
7 | const opacity = useRef(new Animated.Value(0)).current;
8 | useMemo(() => {
9 | return Animated.timing(opacity, {
10 | toValue: 1,
11 | duration: 500,
12 | useNativeDriver: true,
13 | }).start();
14 | }, []);
15 |
16 | return {children};
17 | }
18 |
--------------------------------------------------------------------------------
/src/components/ui/TouchableBounce.native.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import TouchableBounce from "react-native/Libraries/Components/Touchable/TouchableBounce";
4 |
5 | export default TouchableBounce;
6 |
--------------------------------------------------------------------------------
/src/components/ui/TouchableBounce.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { TouchableOpacity } from "react-native";
4 |
5 | export default TouchableOpacity;
6 |
--------------------------------------------------------------------------------
/src/components/ui/body.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { forwardRef } from "react";
4 | import { ScrollView, ScrollViewProps } from "react-native";
5 |
6 | export const BodyScrollView = forwardRef((props, ref) => {
7 | return (
8 |
14 | );
15 | });
16 |
--------------------------------------------------------------------------------
/src/components/user-playlists.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useNestActions } from "./api";
3 | import { View } from "react-native";
4 | import { SkeletonBox } from "@/lib/skeleton";
5 |
6 | export function UserPlaylists() {
7 | const actions = useNestActions();
8 |
9 | return (
10 | }>
11 | {actions.renderDevicesAsync()}
12 |
13 | );
14 | }
15 |
16 | function SongItemSkeleton() {
17 | const SIZE = 150;
18 | return (
19 |
20 | {[1, 2, 3, 4, 5].map((i) => (
21 |
36 | ))}
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/src/global.css:
--------------------------------------------------------------------------------
1 | /* This file adds the requisite utility classes for Tailwind to work. */
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
--------------------------------------------------------------------------------
/src/hooks/useHeaderSearch.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import { useEffect, useState } from "react";
4 | import { useNavigation } from "expo-router";
5 | import { SearchBarProps } from "react-native-screens";
6 |
7 | export function useHeaderSearch(options: Omit = {}) {
8 | const [search, setSearch] = useState("");
9 | const navigation = useNavigation();
10 |
11 | useEffect(() => {
12 | const interceptedOptions: SearchBarProps = {
13 | ...options,
14 | onChangeText(event) {
15 | setSearch(event.nativeEvent.text);
16 | options.onChangeText?.(event);
17 | },
18 | onSearchButtonPress(e) {
19 | setSearch(e.nativeEvent.text);
20 | options.onSearchButtonPress?.(e);
21 | },
22 | onCancelButtonPress(e) {
23 | setSearch("");
24 | options.onCancelButtonPress?.(e);
25 | },
26 | };
27 |
28 | navigation.setOptions({
29 | headerShown: true,
30 | headerSearchBarOptions: interceptedOptions,
31 | });
32 | }, [options]);
33 |
34 | return search;
35 | }
36 |
--------------------------------------------------------------------------------
/src/lib/local-storage.ts:
--------------------------------------------------------------------------------
1 | import { Storage } from "expo-sqlite/kv-store";
2 |
3 | // localStorage polyfill. Life's too short to not have some storage API.
4 | if (typeof localStorage === "undefined") {
5 | class StoragePolyfill {
6 | /**
7 | * Returns the number of key/value pairs.
8 | *
9 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/length)
10 | */
11 | get length(): number {
12 | return Storage.getAllKeysSync().length;
13 | }
14 | /**
15 | * Removes all key/value pairs, if there are any.
16 | *
17 | * Dispatches a storage event on Window objects holding an equivalent Storage object.
18 | *
19 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/clear)
20 | */
21 | clear(): void {
22 | Storage.clearSync();
23 | }
24 | /**
25 | * Returns the current value associated with the given key, or null if the given key does not exist.
26 | *
27 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/getItem)
28 | */
29 | getItem(key: string): string | null {
30 | return Storage.getItemSync(key) ?? null;
31 | }
32 | /**
33 | * Returns the name of the nth key, or null if n is greater than or equal to the number of key/value pairs.
34 | *
35 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/key)
36 | */
37 | key(index: number): string | null {
38 | return Storage.getAllKeysSync()[index] ?? null;
39 | }
40 | /**
41 | * Removes the key/value pair with the given key, if a key/value pair with the given key exists.
42 | *
43 | * Dispatches a storage event on Window objects holding an equivalent Storage object.
44 | *
45 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/removeItem)
46 | */
47 | removeItem(key: string): void {
48 | Storage.removeItemSync(key);
49 | }
50 | /**
51 | * Sets the value of the pair identified by key to value, creating a new key/value pair if none existed for key previously.
52 | *
53 | * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.)
54 | *
55 | * Dispatches a storage event on Window objects holding an equivalent Storage object.
56 | *
57 | * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Storage/setItem)
58 | */
59 | setItem(key: string, value: string): void {
60 | Storage.setItemSync(key, value);
61 | }
62 | // [name: string]: any;
63 | }
64 |
65 | const localStoragePolyfill = new StoragePolyfill();
66 |
67 | Object.defineProperty(global, "localStorage", {
68 | value: localStoragePolyfill,
69 | });
70 | }
71 |
--------------------------------------------------------------------------------
/src/lib/local-storage.web.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/EvanBacon/expo-router-google-home/6e1bf5baa03fb944061319542acdee6af7d35cc8/src/lib/local-storage.web.ts
--------------------------------------------------------------------------------
/src/lib/nest-auth/auth-server-actions.tsx:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import "server-only";
4 |
5 | import {
6 | NestCodeExchangeResponse,
7 | NestCodeExchangeResponseSchema,
8 | } from "./nest-validation";
9 | import { discovery } from "./discovery";
10 |
11 | const {
12 | EXPO_PUBLIC_GOOGLE_OAUTH_CLIENT_ID_IOS: clientId,
13 | NEST_GOOGLE_CLIENT_SECRET: clientSecret,
14 | } = process.env;
15 |
16 | export async function exchangeAuthCodeAsync(props: {
17 | code: string;
18 | redirectUri: string;
19 | codeVerifier: string;
20 | }): Promise {
21 | // curl -L -X POST 'https://www.googleapis.com/oauth2/v4/token?client_id=549323343471-57tgasajtb6s3e02gk6lsj45rdl2n8lp.apps.googleusercontent.com&client_secret=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&code=4/0AeaYSHA3I2SPA9mc1Y3NxIzWl08qq46_25OSWIX8xj4Sxt8l-2GJ1qsJH4UPTAIUVyYQog&grant_type=authorization_code&redirect_uri=https://www.google.com'
22 | // {
23 | // "access_token": "ya29.a0Ad52N38vnZXbKznlNHuUJSDTXfsc_hULxoFbNz9wllqBOTdBTeg0ZrmbNPc0ON2syd-SRBzpE9j2-CVKwwXBVOU_ir5tYiyQTEvv5pzFx6a_Ih-mtJXU20qyR2PLGpi2hv3G5xCc4876PbzaFqFRyh1vIt41g4OmMwxCaCgYKAcMSARISFQHGX2MiTwpiAnx2CnOBMC_ZMCkeaw0171",
24 | // "expires_in": 3598,
25 | // "refresh_token": "1//0fqneltUusrPvCgYIARAAGA8SNwF-L9IrFfX5ImhtWpytBhsd4r8Hbms-f0U9ZEFEZsbe6nFUcVeWUXPzmhSUbEDGvpEDSPluqw8",
26 | // "scope": "https://www.googleapis.com/auth/sdm.service",
27 | // "token_type": "Bearer"
28 | // }
29 |
30 | const body = await fetch(discovery.tokenEndpoint, {
31 | method: "POST",
32 | headers: {
33 | "content-type": "application/x-www-form-urlencoded",
34 | // Authorization:
35 | // "Basic " +
36 | // Buffer.from(clientId + ":" + clientSecret).toString("base64"),
37 | },
38 | body: new URLSearchParams({
39 | code: props.code,
40 | client_id: clientId!,
41 | // client_secret: process.env.EXPO_GOOGLE_OAUTH_CLIENT_SECRET,
42 | redirect_uri: props.redirectUri,
43 | grant_type: "authorization_code",
44 | code_verifier: props.codeVerifier,
45 | }).toString(),
46 | }).then((res) => res.json());
47 |
48 | if ("error" in body) {
49 | if ("error_description" in body) {
50 | throw new Error(body.error_description);
51 | } else {
52 | throw new Error(body.error);
53 | }
54 | }
55 |
56 | console.log("[SPOTIFY] requestAccessToken:", body);
57 | const response = NestCodeExchangeResponseSchema.parse(body);
58 | if ("expires_in" in response) {
59 | // Set the expiration time to the current time plus the number of seconds until it expires.
60 | response.expires_in = Date.now() + response.expires_in * 1000;
61 | }
62 |
63 | return response;
64 | }
65 |
66 | export async function refreshTokenAsync(
67 | refreshToken: string
68 | ): Promise {
69 | // TODO: Check this against nest docs
70 | const body = await fetch(discovery.tokenEndpoint, {
71 | method: "POST",
72 | headers: {
73 | "content-type": "application/x-www-form-urlencoded",
74 | // Authorization:
75 | // "Basic " +
76 | // Buffer.from(clientId + ":" + clientSecret).toString("base64"),
77 | },
78 | body: new URLSearchParams({
79 | grant_type: "refresh_token",
80 | refresh_token: refreshToken,
81 | client_id: clientId!,
82 | }),
83 | }).then((res) => res.json());
84 |
85 | if ("error" in body) {
86 | if ("error_description" in body) {
87 | throw new Error(body.error_description);
88 | } else {
89 | throw new Error(body.error);
90 | }
91 | }
92 |
93 | console.log("[SPOTIFY] refreshToken:", body);
94 | const response = NestCodeExchangeResponseSchema.parse(body);
95 | if ("expires_in" in response) {
96 | // Set the expiration time to the current time plus the number of seconds until it expires.
97 | response.expires_in = Date.now() + response.expires_in * 1000;
98 | }
99 | response.refresh_token ??= refreshToken;
100 |
101 | return response;
102 | }
103 |
--------------------------------------------------------------------------------
/src/lib/nest-auth/discovery.tsx:
--------------------------------------------------------------------------------
1 | // Endpoint
2 | export const discovery = {
3 | tokenEndpoint: "https://www.googleapis.com/oauth2/v4/token",
4 | authorizationEndpoint: `https://nestservices.google.com/partnerconnections/${process.env.EXPO_PUBLIC_NEST_PROJECT_ID}/auth`,
5 | };
6 |
--------------------------------------------------------------------------------
/src/lib/nest-auth/index.ts:
--------------------------------------------------------------------------------
1 | export { useNestAuth } from "./nest-client-provider";
2 | export { NestSongData } from "./nest-validation";
3 |
--------------------------------------------------------------------------------
/src/lib/nest-auth/nest-auth-session-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AuthRequest,
5 | AuthRequestConfig,
6 | AuthRequestPromptOptions,
7 | AuthSessionResult,
8 | useAuthRequest,
9 | } from "expo-auth-session";
10 | import { NestCodeExchangeResponse } from "./nest-validation";
11 | import { discovery } from "./discovery";
12 |
13 | export function useNestAuthRequest(
14 | {
15 | exchangeAuthCodeAsync,
16 | }: {
17 | exchangeAuthCodeAsync: (props: {
18 | code: string;
19 | codeVerifier: string;
20 | }) => Promise;
21 | },
22 | config: AuthRequestConfig
23 | ): [
24 | AuthRequest | null,
25 | AuthSessionResult | null,
26 | (
27 | options?: AuthRequestPromptOptions
28 | ) => Promise
29 | ] {
30 | const [request, response, promptAsync] = useAuthRequest(
31 | {
32 | ...config,
33 | },
34 | discovery
35 | );
36 |
37 | return [
38 | request,
39 | response,
40 | async (options?: AuthRequestPromptOptions) => {
41 | const response = await promptAsync(options);
42 | if (response.type === "success") {
43 | return exchangeAuthCodeAsync({
44 | code: response.params.code,
45 | codeVerifier: request?.codeVerifier ?? "",
46 | });
47 | } else {
48 | return response;
49 | }
50 | },
51 | ];
52 | }
53 |
--------------------------------------------------------------------------------
/src/lib/nest-auth/nest-client-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import "@/lib/local-storage";
4 |
5 | import React, { use } from "react";
6 |
7 | import {
8 | NestCodeExchangeResponse,
9 | NestCodeExchangeResponseSchema,
10 | } from "./nest-validation";
11 | import * as WebBrowser from "expo-web-browser";
12 | import {
13 | exchangeAuthCodeAsync,
14 | refreshTokenAsync,
15 | } from "./auth-server-actions";
16 | import { useNestAuthRequest } from "./nest-auth-session-provider";
17 | import { AuthRequestConfig } from "expo-auth-session";
18 |
19 | WebBrowser.maybeCompleteAuthSession();
20 |
21 | export const NestAuthContext = React.createContext<{
22 | accessToken: string | null;
23 | auth: NestCodeExchangeResponse | null;
24 | setAccessToken: (access: NestCodeExchangeResponse) => void;
25 | clearAccessToken: () => void;
26 | getFreshAccessToken: () => Promise;
27 | exchangeAuthCodeAsync: (props: {
28 | code: string;
29 | codeVerifier: string;
30 | }) => Promise;
31 | useNestAuthRequest: (
32 | config?: Partial
33 | ) => ReturnType;
34 | } | null>(null);
35 |
36 | export function useNestAuth() {
37 | const ctx = use(NestAuthContext);
38 | if (!ctx) {
39 | throw new Error("NestAuthContext is null");
40 | }
41 | return ctx;
42 | }
43 |
44 | export function NestClientAuthProvider({
45 | config,
46 | children,
47 | cacheKey = "nest-access-token",
48 | }: {
49 | config: AuthRequestConfig;
50 | children: React.ReactNode;
51 | cacheKey?: string;
52 | }) {
53 | const [accessObjectString, setAccessToken] = React.useState(
54 | localStorage.getItem(cacheKey)
55 | );
56 |
57 | const accessObject = React.useMemo(() => {
58 | if (!accessObjectString) {
59 | return null;
60 | }
61 | try {
62 | const obj = JSON.parse(accessObjectString);
63 | return NestCodeExchangeResponseSchema.parse(obj);
64 | } catch (error) {
65 | console.error("Failed to parse Nest access token", error);
66 | localStorage.removeItem(cacheKey);
67 | return null;
68 | }
69 | }, [accessObjectString]);
70 |
71 | const storeAccessToken = (token: NestCodeExchangeResponse) => {
72 | const str = JSON.stringify(token);
73 | setAccessToken(str);
74 | localStorage.setItem(cacheKey, str);
75 | };
76 |
77 | const exchangeAuthCodeAndCacheAsync = async (props: {
78 | code: string;
79 | codeVerifier: string;
80 | }) => {
81 | const res = await exchangeAuthCodeAsync({
82 | code: props.code,
83 | codeVerifier: props.codeVerifier,
84 | redirectUri: config.redirectUri,
85 | });
86 | storeAccessToken(res);
87 | return res;
88 | };
89 |
90 | return (
91 |
94 | useNestAuthRequest(
95 | { exchangeAuthCodeAsync: exchangeAuthCodeAndCacheAsync },
96 | {
97 | ...config,
98 | ...innerConfig,
99 | }
100 | ),
101 | exchangeAuthCodeAsync: exchangeAuthCodeAndCacheAsync,
102 | async getFreshAccessToken() {
103 | if (!accessObject) {
104 | throw new Error("Cannot refresh token without an access object");
105 | }
106 | if (accessObject.expires_in >= Date.now()) {
107 | console.log(
108 | "[SPOTIFY]: Token still valid. Refreshing in: ",
109 | accessObject.expires_in - Date.now()
110 | );
111 | return accessObject;
112 | }
113 | if (!accessObject.refresh_token) {
114 | throw new Error(
115 | "Cannot refresh access because the access object does not contain a refresh token"
116 | );
117 | }
118 |
119 | console.log(
120 | "[SPOTIFY]: Token expired. Refreshing:",
121 | accessObject.refresh_token
122 | );
123 | const nextAccessObject = await refreshTokenAsync(
124 | accessObject.refresh_token
125 | );
126 | storeAccessToken(nextAccessObject);
127 | return nextAccessObject;
128 | },
129 | accessToken: accessObject?.access_token ?? null,
130 | auth: accessObject ?? null,
131 | setAccessToken: storeAccessToken,
132 | clearAccessToken() {
133 | setAccessToken(null);
134 | localStorage.removeItem(cacheKey);
135 | },
136 | }}
137 | >
138 | {children}
139 |
140 | );
141 | }
142 |
--------------------------------------------------------------------------------
/src/lib/skeleton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React from "react";
4 | import { View, StyleSheet, Animated, Easing, ViewStyle } from "react-native";
5 |
6 | const BASE_COLORS = {
7 | dark: { primary: "rgb(17, 17, 17)", secondary: "rgb(51, 51, 51)" },
8 | light: {
9 | primary: "rgb(250, 250, 250)",
10 | secondary: "rgb(205, 205, 205)",
11 | },
12 | } as const;
13 |
14 | const makeColors = (mode: keyof typeof BASE_COLORS) => [
15 | BASE_COLORS[mode].primary,
16 | BASE_COLORS[mode].secondary,
17 | BASE_COLORS[mode].secondary,
18 | BASE_COLORS[mode].primary,
19 | BASE_COLORS[mode].secondary,
20 | BASE_COLORS[mode].primary,
21 | ];
22 |
23 | const DARK_COLORS = new Array(3)
24 | .fill(0)
25 | .map(() => makeColors("dark"))
26 | .flat();
27 |
28 | const LIGHT_COLORS = new Array(3)
29 | .fill(0)
30 | .map(() => makeColors("light"))
31 | .flat();
32 |
33 | export const SkeletonBox = ({
34 | width,
35 | height,
36 | borderRadius = 8,
37 | delay,
38 | }: {
39 | width: number;
40 | height: number;
41 | borderRadius?: number;
42 | delay?: number;
43 | }) => {
44 | return (
45 |
57 | );
58 | };
59 |
60 | const Skeleton = ({
61 | style,
62 | delay,
63 | dark,
64 | }: {
65 | style?: ViewStyle;
66 | delay?: number;
67 | dark?: boolean;
68 | } = {}) => {
69 | const translateX = React.useRef(new Animated.Value(-1)).current;
70 | const [width, setWidth] = React.useState(150);
71 |
72 | const colors = dark ? DARK_COLORS : LIGHT_COLORS;
73 | const targetRef = React.useRef(null);
74 |
75 | const onLayout = React.useCallback(() => {
76 | targetRef.current?.measureInWindow((_x, _y, width, _height) => {
77 | setWidth(width);
78 | });
79 | }, []);
80 |
81 | React.useEffect(() => {
82 | const anim = Animated.loop(
83 | Animated.sequence([
84 | Animated.timing(translateX, {
85 | delay: delay || 0,
86 | toValue: 1,
87 | duration: 5000,
88 | useNativeDriver: process.env.EXPO_OS !== "web",
89 | // Ease in
90 | easing: Easing.in(Easing.ease),
91 | }),
92 | ])
93 | );
94 | anim.start();
95 | return () => {
96 | anim.stop();
97 | };
98 | }, [translateX]);
99 |
100 | const translateXStyle = React.useMemo(
101 | () => ({
102 | transform: [
103 | {
104 | translateX: translateX.interpolate({
105 | inputRange: [-1, 1],
106 | outputRange: [-width * 8, width],
107 | }),
108 | },
109 | ],
110 | }),
111 | [translateX, width]
112 | );
113 |
114 | return (
115 |
129 |
135 |
147 |
148 |
149 | );
150 | };
151 |
152 | export default Skeleton;
153 |
--------------------------------------------------------------------------------
/src/lib/skeleton.web.tsx:
--------------------------------------------------------------------------------
1 | // Use CSS to prevent blocking the suspense loading state with a skeleton loader.
2 | import React from "react";
3 |
4 | export const SkeletonBox = ({
5 | width,
6 | height,
7 | borderRadius = 8,
8 | delay,
9 | }: {
10 | width: number;
11 | height: number;
12 | borderRadius?: number;
13 | delay?: number;
14 | }) => {
15 | return (
16 |
28 | );
29 | };
30 | const Skeleton = ({
31 | style,
32 | delay,
33 | dark,
34 | }: {
35 | style?: any;
36 | delay?: number;
37 | dark?: boolean;
38 | } = {}) => {
39 | return (
40 |
74 | );
75 | };
76 |
77 | export default Skeleton;
78 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,tsx,ts,jsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base.json",
3 | "compilerOptions": {
4 | "strict": true,
5 | "paths": {
6 | "@/*": ["./src/*"]
7 | }
8 | },
9 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
10 | }
11 |
--------------------------------------------------------------------------------