Compare commits

..

27 Commits

Author SHA1 Message Date
a1dc088a23 Fix repeating notifications in Accident.js 2021-01-06 13:55:56 +05:30
d4a19060e8 Add notifications to pet mode 2021-01-06 13:55:22 +05:30
fe9f7e81e9 Add Smart Lights to SmartHome 2021-01-05 22:28:57 +05:30
6e07c2be85 Add marketplace 2021-01-04 23:53:59 +05:30
03db7575fd Changes in theming 2021-01-04 23:53:20 +05:30
df7470f147 Add Maps plugin 2021-01-04 14:54:45 +05:30
17a3436017 Fix typo 2021-01-04 12:53:20 +05:30
4bdcd49e1f Add SmartHome and relevant config 2021-01-04 12:18:38 +05:30
031c1ede21 Add Voice Wave Animation for recording and show recorded text 2021-01-04 03:45:09 +05:30
08e5c2e3d2 Add socket.io event handlers for voice:* messages 2021-01-04 00:59:02 +05:30
2c80c4b279 Add voiceSlice redux slice for voice icon state 2021-01-04 00:58:07 +05:30
66d688291c Add locking mechanism around sendNotification
Workaround for multiple messages being sent
2021-01-03 12:51:50 +05:30
49430114fe Use standardized colors instead of color codes 2021-01-03 12:25:49 +05:30
cfd0056197 Add PetMode plugin and integrate it into home page 2021-01-02 04:05:03 +05:30
facd8d912f Integrate telegram with accident plugin 2021-01-01 15:58:29 +05:30
b743cbc9a5 Add telegram util to send telegram messages and location 2021-01-01 15:58:02 +05:30
ace77028c4 Rename plugins to fit conventions 2021-01-01 14:05:39 +05:30
dd215a8acc Create Accident plugin 2021-01-01 13:58:34 +05:30
90a03dc17c Make no-unused-vars a warning instead of an error 2021-01-01 12:54:19 +05:30
e1c42c90d5 Fix rendering issues with plugins
Plugins wouldn't render if the number of hooks changed. This was because
the dynamic content substitution would create variable number of hooks.
This has been fixed by creating an element instead of placing content.
2021-01-01 01:23:42 +05:30
cb8f6daccc Add temperature plugin 2020-12-21 22:25:00 +05:30
822eff6c5d Remove MPD and Weather plugins 2020-12-21 21:59:10 +05:30
ce86f1e029 Remove App.css 2020-12-21 17:53:34 +05:30
7ed58e94d8 Create the front page of the OSD 2020-12-21 17:51:27 +05:30
446afb939a Add manual plugin 2020-12-21 17:26:58 +05:30
cae5d1c95a Move page structure of warning into GenericPageWithIcon 2020-12-21 16:49:33 +05:30
fde5018da8 Add warning plugin. Add grommet-icons as dependency 2020-12-21 15:43:25 +05:30
24 changed files with 622 additions and 80 deletions

View File

@@ -34,7 +34,8 @@ module.exports = {
"semi": [
"error",
"always"
]
],
"no-unused-vars": [1]
},
"settings": {
"react": {

View File

@@ -7,7 +7,9 @@
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"axios": "^0.21.1",
"grommet": "^2.16.2",
"grommet-icons": "^4.5.0",
"polished": "^4.0.5",
"react": "^17.0.1",
"react-dom": "^17.0.1",

View File

@@ -1,39 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-float infinite 3s ease-in-out;
}
}
.App-header {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
}
.App-link {
color: rgb(112, 76, 182);
}
@keyframes App-logo-float {
0% {
transform: translateY(0);
}
50% {
transform: translateY(10px)
}
100% {
transform: translateY(0px)
}
}

View File

@@ -1,32 +1,52 @@
import React from "react";
import { useSelector, useDispatch } from "react-redux";
import { Box, Button, Clock, Text } from "grommet";
import { setPlugin, selectCore } from "./coreSlice";
import * as plugins from "./plugins";
import { selectVoice } from "./voiceSlice";
import VoiceBars from "./components/VoiceBars";
import plugins from "./plugins";
function Core() {
const coreState = useSelector(selectCore);
const voiceState = useSelector(selectVoice);
const dispatch = useDispatch();
const plugin = plugins.default[coreState.plugin];
const plugin = plugins[coreState.plugin];
const props = {
data: coreState.data,
close: () => dispatch(setPlugin(false))
};
return <>
{plugin ? null :
<>
<p>Welcome to Honda!</p>
{Object.keys(plugins.default).map(
i => <p
onClick={() => dispatch(setPlugin(i))}
key={i}
<Box height="5vh" background="light-1" justify="center" direction="row">
{voiceState.recording ? <VoiceBars style={{height: "100%"}}/>:
voiceState.text === null ? null:
<Text style={{paddingTop: "1em"}}>{voiceState.text}</Text>
}
</Box>
{plugin ? React.createElement(plugin, props, null) : (
<Box
align="center"
background="light-1"
width="100vw"
justify="center"
direction="column"
alignContent="center"
height="95vh"
>
{plugins.default[i].pluginName}
</p>)
<Clock size="xxlarge" />
<Clock size="xxlarge" type="digital" />
<Box direction="row" margin="1.5em" wrap={true}>
<Button onClick={() => dispatch(setPlugin("petMode"))} primary
label="Pet Mode" />
<Button onClick={() => dispatch(setPlugin("smartHome"))} primary
label="Smart Home" margin="0 0 0 1em" />
<Button onClick={() => dispatch(setPlugin("maps"))} primary
label="Maps" margin="0 0 0 1em" />
<Button onClick={() => dispatch(setPlugin("marketplace"))} primary
label="Marketplace" margin="0 0 0 1em" />
</Box>
</Box>)
}
</>
}
{plugin ? plugin(props) :null}
</>;
}

View File

@@ -4,6 +4,7 @@ import io from "socket.io-client";
import { WS_BASE } from "./config";
import { useDispatch } from "react-redux";
import { setData, setPlugin } from "./coreSlice";
import { setRecording, setText } from "./voiceSlice";
const WebSocketContext = createContext(null);
@@ -17,8 +18,8 @@ function WebSocketProvider({ children }) {
socket = io.connect(WS_BASE);
socket.on("switchPlugin", (payload) => {
dispatch(setData(payload.data || {}));
dispatch(setPlugin(payload.plugin));
dispatch(setData(payload.data));
if (payload.time) {
setTimeout(() => {
dispatch(setData({}));
@@ -26,6 +27,20 @@ function WebSocketProvider({ children }) {
}, payload.time);
}
});
socket.on("voice:wakeword", () => {
dispatch(setRecording(true));
dispatch(setText(null));
});
socket.on("voice:record_end", () => {
dispatch(setRecording(false));
dispatch(setText(null));
});
socket.on("voice:utterance", payload => {
dispatch(setRecording(false));
dispatch(setText(payload.text));
setTimeout(() => dispatch(setText(null)), 3000);
});
}
return (

View File

@@ -1,8 +1,10 @@
import { configureStore } from "@reduxjs/toolkit";
import coreReducer from "../coreSlice";
import voiceReducer from "../voiceSlice";
export default configureStore({
reducer: {
core: coreReducer,
voice: voiceReducer,
},
});

View File

@@ -0,0 +1,79 @@
import React from "react";
function VoiceBars() {
return (
<div className="waves" style={{transform: "rotate(180deg)", marginTop: "130px", transition: "1s"}}>
<svg width="100vw" fill="none" version="1.1"
xmlns="http://www.w3.org/2000/svg">
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#00B4DB" />
<stop offset="50%" stopColor="#224488" />
<stop offset="100%" stopColor="#0083B0" />
</linearGradient>
<path
fill="url(#grad1)"
d="
M0 67
C 273,183
822,-40
1920.00,106
V 359
H 0
V 67
Z">
<animate
repeatCount="indefinite"
fill="url(#grad1)"
attributeName="d"
dur="5s"
attributeType="XML"
values="
M0 77
C 473,283
822,-40
1920,116
V 359
H 0
V 67
Z;
M0 77
C 473,-40
1222,283
1920,136
V 359
H 0
V 67
Z;
M0 77
C 973,260
1722,-53
1920,120
V 359
H 0
V 67
Z;
M0 77
C 473,283
822,-40
1920,116
V 359
H 0
V 67
Z
">
</animate>
</path>
</svg>
</div>
);
}
export default VoiceBars;

85
src/plugins/Accident.js Normal file
View File

@@ -0,0 +1,85 @@
import React, { useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { Box, Button, Heading, Text } from "grommet";
import { Aid, Impact } from "grommet-icons";
import Telegram from "../utils/telegram";
import { TG_API, TG_USERID } from "../config";
function sendNotification(close) {
if (window.sendingAccidentAlert)
return;
window.sendingAccidentAlert = true;
const bot = new Telegram(TG_API);
bot.sendMessage(TG_USERID, "User detected in an accident. Location has been attached below.");
navigator.geolocation.getCurrentPosition(
position => {
window.sendingAccidentAlert = false;
bot.sendLocation( TG_USERID,
position.coords.latitude, position.coords.longitude);
},
() => {
window.sendingAccidentAlert = false;
bot.sendMessage(TG_USERID, "Error retrieving location");
}
);
setTimeout(close, 2000);
}
function Accident(props) {
const threshold = 20;
const [ time, setTime ] = useState(threshold);
const countRef = useRef(null);
function decrementTime(time) {
if (time === 0) {
clearInterval(countRef.current);
sendNotification(props.close);
return 0;
}
return time - 1;
}
useEffect(() => {
countRef.current = setInterval(() => setTime(decrementTime), 1000);
return () => clearInterval(countRef.current);
}, []);
function dismiss() {
clearTimeout(countRef.current);
props.close();
}
return (
<Box
align="center"
background="light-1"
width="100vw"
justify="center"
direction="column"
alignContent="center"
height="95vh"
>
{
time === 0 ?
<Aid color="plain" size="xlarge" />
:<Impact color="status-critical" size="xlarge" />
}
<Heading>{
time === 0 ?
"Calling Ambulance!"
:"Accident Detected!"
}</Heading>
<Text>{
time === 0 ? "Calling Ambulance":
`To cancel calling the ambulance, press the button below. (${time})`
}</Text>
<Button primary size="large" onClick={dismiss}
label="Dismiss" margin="1.5em" />
</Box>
);
}
Accident.propTypes = {
close: PropTypes.func
};
export default Accident;

View File

@@ -0,0 +1,35 @@
import React from "react";
import PropTypes from "prop-types";
import { Box, Button, Heading, Text } from "grommet";
function GenericPageWithIcon(props) {
return (
<Box
align="center"
background="light-1"
width="100vw"
justify="center"
direction="column"
alignContent="center"
height="95vh"
style={{
padding: "0 3em",
}}
>
{props.icon}
<Heading>{props.title}</Heading>
<Text style={{ textAlign: "center" }}>{props.description}</Text>
<Button primary size="large" onClick={props.close}
label="Dismiss" margin="1.5em" />
</Box>
);
}
GenericPageWithIcon.propTypes = {
title: PropTypes.string,
description: PropTypes.string,
icon: PropTypes.node,
close: PropTypes.func
};
export default GenericPageWithIcon;

23
src/plugins/Manual.js Normal file
View File

@@ -0,0 +1,23 @@
import React from "react";
import PropTypes from "prop-types";
import GenericPageWithIcon from "./GenericPageWithIcon";
import { StatusInfo } from "grommet-icons";
function Manual(props) {
return (
<GenericPageWithIcon
title={props.data.title}
description={props.data.description}
icon={<StatusInfo color="plain" size="xlarge" />}
close={props.close}
/>);
}
Manual.propTypes = {
data: PropTypes.object,
close: PropTypes.func
};
Manual.pluginName = "Manual";
export default Manual;

53
src/plugins/Maps.js Normal file
View File

@@ -0,0 +1,53 @@
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Box, Button } from "grommet";
import { MAPS_API } from "../config";
function Maps(props) {
const [ location, setLocation ] = useState([null, null]);
useEffect(() => {
navigator.geolocation.getCurrentPosition(
position => setLocation([position.coords.latitude, position.coords.longitude]),
() => {}
);
});
const mapsEmbedUrl = new URL("https://www.google.com/maps/embed/v1/view");
mapsEmbedUrl.searchParams.append("key", MAPS_API);
mapsEmbedUrl.searchParams.append("center", `${location[0]},${location[1]}`);
mapsEmbedUrl.searchParams.append("zoom", 14);
return (
<Box
align="center"
background="light-1"
width="100vw"
justify="center"
direction="column"
alignContent="center"
height="95vh"
>
{location[0] === null ?
"Loading Location":
<iframe
width="650"
height="300"
frameBorder="0"
style={{ border: 0 }}
src={mapsEmbedUrl.href}>
</iframe>
}
<Button primary size="large" onClick={props.close}
label="Dismiss" margin="1.5em" />
</Box>
);
}
Maps.propTypes = {
data: PropTypes.object,
close: PropTypes.func
};
Maps.pluginName = "Maps";
export default Maps;

View File

@@ -0,0 +1,67 @@
import React from "react";
import PropTypes from "prop-types";
import { Box, Button, Card, CardBody, CardFooter, CardHeader, Heading } from "grommet";
import { InstallOption, Trash } from "grommet-icons";
function Marketplace(props) {
const plugins = [
{
name: "Maps",
description: "Google Maps is a mapping service. It offers satellite imagery, aerial photography, street maps, 360° interactive panoramic views of streets, real-time traffic conditions, and route planning for traveling.",
installed: true
},
{
name: "Smart Home",
description: "Smart Home Plugin lets you integrate with various well known smart devices allowing you to stream video from various devices.",
installed: true
},
{
name: "Spotify",
description: "With Spotify, you have access to a world of music. You can listen to artists and albums, or create your own playlist of your favourite songs.",
installed: false
},
];
return (
<Box
align="center"
background="light-1"
width="100vw"
justify="center"
direction="column"
alignContent="center"
height="95vh"
>
<Box wrap="wrap" direction="row">
{
plugins.map(i =>
<Card key={i.name} style={{margin: "1em", width: "30%"}}>
<CardHeader pad="medium"><Heading>{i.name}</Heading></CardHeader>
<CardBody pad="medium">{i.description}</CardBody>
<CardFooter pad="medium" justify="center">
<Button
label={i.installed ? "Uninstall": "Install"}
icon={i.installed ?
<Trash color="status-error" /> :
<InstallOption color="status-ok" />
}
/>
</CardFooter>
</Card>
)
}
</Box>
<Button primary size="large" onClick={props.close}
label="Dismiss" margin="1.5em" />
</Box>
);
}
Marketplace.propTypes = {
data: PropTypes.object,
close: PropTypes.func
};
Marketplace.pluginName = "Marketplace";
export default Marketplace;

59
src/plugins/PetMode.js Normal file
View File

@@ -0,0 +1,59 @@
import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";
import GenericPageWithIcon from "./GenericPageWithIcon";
import { Sun } from "grommet-icons";
import axios from "axios";
import Telegram from "../utils/telegram";
import { CAR_API, TG_API, TG_USERID } from "../config";
function sendNotification(temp) {
if (window.sendingPetAlert)
return;
window.sendingPetAlert = true;
const bot = new Telegram(TG_API);
bot.sendMessage(TG_USERID, `The temperature in the car is ${temp}°C. Please check your child/pet.`);
window.sendingPetAlert = false;
}
function PetMode(props) {
const [ temp, setTemp ] = useState(null);
const [ , setNotified ] = useState(false);
useEffect(() => {
axios.get(`${CAR_API}data/InsideTemperature`)
.then(resp => setTemp(resp.data.value));
const id = setInterval(
() => axios.get(`${CAR_API}data/InsideTemperature`)
.then(resp => {
setTemp(resp.data.value);
if (resp.data.value > 35 || resp.data.value < 5) {
setNotified(notified => {
if (notified)
return true;
sendNotification(resp.data.value);
return true;
});
}
}),
1000
);
return () => clearInterval(id);
}, []);
if (temp === null)
return <></>;
return (
<GenericPageWithIcon
title={temp === null ? "Fetching temperature": `${temp}°C`}
description={`The temperature inside is ${temp}°C. The pet / child is ${temp > 35 ? "un": ""}safe.`}
icon={<Sun color={temp > 35 ? "status-critical": "plain"} size="xlarge" />}
close={props.close}
/>);
}
PetMode.propTypes = {
close: PropTypes.func
};
PetMode.pluginName = "Temperature";
export default PetMode;

50
src/plugins/SmartHome.js Normal file
View File

@@ -0,0 +1,50 @@
import React, {useState, useEffect} from "react";
import PropTypes from "prop-types";
import { Box, Button } from "grommet";
import axios from "axios";
import { CAMERA_URL, LIGHTS_URL } from "../config";
function SmartHome(props) {
const [ idx, setIdx ] = useState(0);
useEffect(() => {
const id = setInterval(() => setIdx(i => i + 1), 20);
return () => clearInterval(id);
}, []);
function turnOn() {
axios.get(`${LIGHTS_URL}/on`);
}
function turnOff() {
axios.get(`${LIGHTS_URL}/off`);
}
return (
<Box
align="center"
background="light-1"
width="100vw"
justify="center"
direction="column"
alignContent="center"
height="95vh"
>
<img src={`${CAMERA_URL}?id=${idx}`} />
<Box direction="row" margin="1em 0">
<Button primary size="large" onClick={turnOn}
label="Turn On Lights" margin="0 0 0 0" />
<Button primary size="large" onClick={turnOff}
label="Turn Off Lights" margin="0 0 0 1em" />
</Box>
<Button primary size="large" onClick={props.close}
label="Dismiss" margin="0" />
</Box>
);
}
SmartHome.propTypes = {
data: PropTypes.object,
close: PropTypes.func
};
SmartHome.pluginName = "Smart Home";
export default SmartHome;

View File

@@ -0,0 +1,23 @@
import React from "react";
import PropTypes from "prop-types";
import GenericPageWithIcon from "./GenericPageWithIcon";
import { Sun } from "grommet-icons";
function Temperature(props) {
return (
<GenericPageWithIcon
title={props.data.temperature}
description={`The current temperature outside is ${props.data.temperature}`}
icon={<Sun color="plain" size="xlarge" />}
close={props.close}
/>);
}
Temperature.propTypes = {
data: PropTypes.object,
close: PropTypes.func
};
Temperature.pluginName = "Temperature";
export default Temperature;

23
src/plugins/Warning.js Normal file
View File

@@ -0,0 +1,23 @@
import React from "react";
import PropTypes from "prop-types";
import GenericPageWithIcon from "./GenericPageWithIcon";
import { Alert } from "grommet-icons";
function Warning(props) {
return (
<GenericPageWithIcon
title={props.data.title}
description={props.data.description}
icon={<Alert color="status-critical" size="xlarge" />}
close={props.close}
/>);
}
Warning.propTypes = {
data: PropTypes.object,
close: PropTypes.func
};
Warning.pluginName = "Warning";
export default Warning;

View File

@@ -1,6 +1,19 @@
import Mpd from "./mpd";
import Weather from "./weather";
import Warning from "./Warning";
import Manual from "./Manual";
import Temperature from "./Temperature";
import Accident from "./Accident";
import PetMode from "./PetMode";
import SmartHome from "./SmartHome";
import Maps from "./Maps";
import Marketplace from "./Marketplace";
export default {
mpd: Mpd,
weather: Weather,
warning: Warning,
manual: Manual,
temperature: Temperature,
accident: Accident,
petMode: PetMode,
smartHome: SmartHome,
maps: Maps,
marketplace: Marketplace,
};

View File

@@ -1,9 +0,0 @@
import React from "react";
function Mpd() {
return <p> MPD Plugin </p>;
}
Mpd.pluginName = "Music Player";
export default Mpd;

View File

@@ -1,9 +0,0 @@
import React from "react";
function Weather() {
return <p> Weather Plugin </p>;
}
Weather.pluginName = "Weather";
export default Weather;

View File

@@ -1,3 +1,9 @@
const WS_BASE = "http://localhost:5050/";
const TG_API = "xxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxx";
const TG_USERID = 123456789;
const CAR_API = "http://localhost:5000/";
const CAMERA_URL = "http://path.to/still/image.jpg";
const MAPS_API = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
const LIGHTS_URL = "http://192.168.1.50:5000/";
export { WS_BASE };
export { WS_BASE, TG_API, TG_USERID, CAR_API, CAMERA_URL, MAPS_API, LIGHTS_URL };

0
src/utils/index.js Normal file
View File

19
src/utils/telegram.js Normal file
View File

@@ -0,0 +1,19 @@
import axios from "axios";
class Telegram {
constructor(token) {
this.axios = axios.create({
baseURL: `https://api.telegram.org/bot${token}`
});
}
sendMessage(chat_id, text) {
this.axios.post("/sendMessage", {chat_id, text});
}
sendLocation(chat_id, latitude, longitude) {
this.axios.post("/sendLocation", {chat_id, latitude, longitude});
}
}
export default Telegram;

17
src/voiceSlice.js Normal file
View File

@@ -0,0 +1,17 @@
import { createSlice } from "@reduxjs/toolkit";
export const voiceSlice = createSlice({
name: "voice",
initialState: {
recording: false,
text: null
},
reducers: {
setRecording: (state, action) => ({...state, recording: action.payload}),
setText: (state, action) => ({...state, text: action.payload}),
}
});
export const { setRecording, setText } = voiceSlice.actions;
export const selectVoice = state => state.voice;
export default voiceSlice.reducer;

View File

@@ -2590,6 +2590,13 @@ axe-core@^4.0.2:
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.1.tgz#70a7855888e287f7add66002211a423937063eaf"
integrity sha512-5Kgy8Cz6LPC9DJcNb3yjAXTu3XihQgEdnIg50c//zOC/MyLP0Clg+Y8Sh9ZjjnvBrDZU4DgXS9C3T9r4/scGZQ==
axios@^0.21.1:
version "0.21.1"
resolved "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
dependencies:
follow-redirects "^1.10.0"
axobject-query@^2.2.0:
version "2.2.0"
resolved "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
@@ -5204,7 +5211,7 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
follow-redirects@^1.0.0:
follow-redirects@^1.0.0, follow-redirects@^1.10.0:
version "1.13.1"
resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==