├── frame.jpg ├── requirements.txt ├── piframe_client.png ├── app ├── public │ ├── favicon.ico │ ├── img │ │ └── icon │ │ │ ├── sunset.png │ │ │ ├── humidity.png │ │ │ ├── sunrise.png │ │ │ ├── refresh.svg │ │ │ └── settings.svg │ └── index.html ├── src │ ├── index.js │ ├── Sync.js │ ├── Clock.js │ ├── Verse.js │ ├── shared.js │ ├── Frame.js │ ├── Photos.js │ ├── Weather.js │ ├── index.css │ ├── Extensions.js │ └── Settings.js └── package.json ├── run.sh ├── .gitignore ├── server ├── verse.py ├── run.py ├── photos.py ├── settings.py ├── extensions.py └── weather.py ├── setup.sh ├── LICENSE └── README.md /frame.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobhjustice/PiFrame/HEAD/frame.jpg -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask_cors 3 | beautifulsoup4 4 | requests 5 | flickrapi -------------------------------------------------------------------------------- /piframe_client.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobhjustice/PiFrame/HEAD/piframe_client.png -------------------------------------------------------------------------------- /app/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobhjustice/PiFrame/HEAD/app/public/favicon.ico -------------------------------------------------------------------------------- /app/public/img/icon/sunset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobhjustice/PiFrame/HEAD/app/public/img/icon/sunset.png -------------------------------------------------------------------------------- /app/public/img/icon/humidity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobhjustice/PiFrame/HEAD/app/public/img/icon/humidity.png -------------------------------------------------------------------------------- /app/public/img/icon/sunrise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacobhjustice/PiFrame/HEAD/app/public/img/icon/sunrise.png -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | echo "Starting PiFrame..." 2 | python3 /home/pi/PiFrame/server/run.py & 3 | serve -s /home/pi/PiFrame/app/build -l 3000 & -------------------------------------------------------------------------------- /app/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import { Frame } from './Frame' 5 | 6 | 7 | ReactDOM.render( 8 | , 9 | document.getElementById('root') 10 | ); -------------------------------------------------------------------------------- /app/public/img/icon/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /app/node_modules 6 | 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # production 14 | build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | __pycache__ 28 | secret.py 29 | /app/public/img/flickr 30 | 31 | /vim.exe.stackdump 32 | settings.json -------------------------------------------------------------------------------- /server/verse.py: -------------------------------------------------------------------------------- 1 | # PiFrame verse.py 2 | # Scrape's BibleGateway for its verse of the day for the "Verse" extension. 3 | 4 | import requests, json 5 | from bs4 import BeautifulSoup 6 | 7 | def get(): 8 | doc = requests.get("https://www.biblegateway.com/", timeout=10) 9 | soup = BeautifulSoup(doc.text) 10 | votd = soup.findAll("div", {"class": "votd-box"})[0] 11 | quote = votd.findAll("p")[0].text 12 | reference = votd.findAll("a")[0].text 13 | data = { 14 | "quote": quote, 15 | "reference": reference 16 | } 17 | jsonString = json.dumps(data) 18 | return jsonString 19 | -------------------------------------------------------------------------------- /app/src/Sync.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | const images = require.context('../public/img/icon', true); 3 | 4 | // Sync triggers a full reload of the Frame 5 | export class Sync extends React.Component { 6 | 7 | constructor(props) { 8 | super(props) 9 | } 10 | 11 | render() { 12 | return( 13 |
14 | 15 |
Sync
16 |
17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PiFrame", 3 | "version": "1.0.1", 4 | "private": true, 5 | "dependencies": { 6 | "react": "^16.8.6", 7 | "react-dom": "^16.8.6", 8 | "react-scripts": "^3.0.1" 9 | }, 10 | "scripts": { 11 | "start": "react-scripts start", 12 | "build": "react-scripts build", 13 | "test": "react-scripts test", 14 | "eject": "react-scripts eject" 15 | }, 16 | "eslintConfig": { 17 | "extends": "react-app" 18 | }, 19 | "browserslist": { 20 | "production": [ 21 | ">0.2%", 22 | "not dead", 23 | "not op_mini all" 24 | ], 25 | "development": [ 26 | "last 1 chrome version", 27 | "last 1 firefox version", 28 | "last 1 safari version" 29 | ] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | echo "Pulling latest changes..." 2 | git pull origin master 3 | chmod +x /home/pi/PiFrame/setup.sh 4 | chmod +x /home/pi/PiFrame/run.sh 5 | 6 | # Python is pre-installed 7 | echo "Squaring away all python dependencies..." 8 | python3 -m pip install --upgrade pip 9 | sudo pip3 install -r requirements.txt 10 | 11 | # Need to install npm to run our client 12 | echo "Setting up node for usage" 13 | sudo apt-get update 14 | sudo apt-get upgrade 15 | sudo apt-get install nodejs npm 16 | npm update 17 | sudo npm install -g serve 18 | 19 | echo "Building react app" 20 | cd /home/pi/PiFrame/app 21 | npm run build 22 | 23 | # Set browser on load/run on load 24 | cd .. 25 | echo "Finished running set up. To start PiFrame, run ./run.sh" 26 | 27 | # self updating chron job? -------------------------------------------------------------------------------- /app/public/img/icon/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 19 | Pi-Frame App 20 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jacob Harrison Justice 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /app/src/Clock.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getDisplayTime, getDateString } from './shared' 3 | 4 | // ClockProperties contain backing information for the Clock component. 5 | // Should be instantiated from the Extensions level and passed into any instance of Clock. 6 | export class ClockProperties { 7 | // @param isEnabled {bool} the current enabled status of this extension in settings 8 | // @param time {DateTime} the current time on render 9 | constructor(isEnabled, time) { 10 | this.isEnabled = isEnabled 11 | this.isLoaded = time !== undefined 12 | this.time = time 13 | } 14 | } 15 | 16 | // Clock displays information for the Clock extension. 17 | // It should render each second within extensions with the current formatted time. 18 | export class Clock extends React.Component { 19 | render() { 20 | if (!this.props.isLoaded || !this.props.isEnabled) { 21 | return null 22 | } 23 | 24 | return ( 25 |
26 |
27 | {getDisplayTime(this.props.time, true)} 28 |
29 |
{getDateString(this.props.time, true)}
30 |
31 | ); 32 | } 33 | } -------------------------------------------------------------------------------- /app/src/Verse.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // VerseProperties contain backing information for the Verse component. 4 | // Should be instantiated from the Extensions level and passed into any instance of Verse. 5 | export class VerseProperties { 6 | // @param isEnabled {bool} the current enabled status of this extension in settings 7 | // @param quote {string} the quoted verse to be primarilly displayed 8 | // @param reference {string} information to reference the source of quote (i.e., book chapter:verse) 9 | constructor(isEnabled, quote, reference) { 10 | this.isEnabled = isEnabled 11 | this.isLoaded = quote !== undefined 12 | if (this.isLoaded) { 13 | this.quote = quote 14 | this.reference = reference 15 | } 16 | } 17 | } 18 | 19 | // Verse displays information for the Verse extension. 20 | // It should render every hour, though is only expected to change once a day. 21 | export class Verse extends React.Component { 22 | render() { 23 | if (!this.props.isLoaded || !this.props.isEnabled) { 24 | return null 25 | } 26 | 27 | return ( 28 |
29 |
30 |
"{this.props.quote}"
31 |
- {this.props.reference}
32 |
33 |
34 | ); 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/shared.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | const months = ["Jan", "Feb", "March", "April", "May", "June", "July", "Aug", "Sep", "Oct", "Nov", "Dec"] 3 | 4 | // server is the root of the backing flask server 5 | export const server = "http://127.0.0.1:5000/" 6 | 7 | // getDisplayTime converts a DateTime object into a readable 12 hour time string 8 | // @param date {DateTime} the time to display 9 | // @param isBlinkingOnSecond {bool} true indicates that the time should hide the ":" on even seconds 10 | // @param isInline {bool} true indicates the returned div has class "inline" 11 | // @return {string} a div with content of the time formatted in "hh:mm AM/PM" format 12 | export function getDisplayTime(date, isBlinkingOnSecond, isInline) { 13 | if (date === undefined) { 14 | return 15 | } 16 | return ( 17 |
18 | {((date.getHours() + 11) % 12 + 1 < 10 ? "0" : "") + ((date.getHours() + 11) % 12 + 1)} 19 |
:
20 | {(date.getMinutes() < 10 ? "0" : "") + date.getMinutes()} 21 | {date.getHours() >= 12 ? " PM" : " AM"} 22 |
) 23 | } 24 | 25 | // getDateString converts a DateTime object into a readable date string 26 | // @param date {DateTime} the date to parse 27 | // @param includeYear {bool} true indicates the year should be included 28 | // @return {string} the date in "month day{, year}" format 29 | export function getDateString(date, includeYear) { 30 | let str = months[date.getMonth()] 31 | str += " " 32 | str += date.getDate() 33 | if (includeYear) { 34 | str += ", " 35 | str += date.getFullYear() 36 | } 37 | return str 38 | } -------------------------------------------------------------------------------- /server/run.py: -------------------------------------------------------------------------------- 1 | # PiFrame run.py 2 | # Builds flask's REST endpoints for retrieving information for the frame's operations 3 | 4 | from flask import Flask, session, make_response, request, current_app, jsonify 5 | from flask_cors import CORS 6 | import settings, extensions, verse, photos, json, weather 7 | 8 | 9 | app = Flask(__name__) 10 | app.config['SECRET_KEY'] = 'super secret' 11 | CORS(app) 12 | 13 | # Get Settings 14 | # Retrieve the current user settings on file 15 | @app.route('/settings/', methods=["GET"]) 16 | def getSettings(): 17 | userSettings = settings.read() 18 | jsonString = json.dumps(userSettings.toJSON()) 19 | return jsonString 20 | 21 | # Get Verse 22 | # Scrape BibleGateway for the verse of the day 23 | @app.route('/verse/', methods=["GET"]) 24 | def getVerse(): 25 | userSettings = settings.read() 26 | if userSettings.Verse.isEnabled != True: 27 | return '{"isNotEnabled": "True"}' 28 | json = verse.get() 29 | return json 30 | 31 | # Get Images 32 | # Retrieve the albums on flickr based on the api credentials 33 | @app.route('/images/', methods=["GET"]) 34 | def getImages(): 35 | userSettings = settings.read() 36 | if userSettings.Photos.isEnabled != True: 37 | return '{"isNotEnabled": "True"}' 38 | json = photos.getAlbumsForClient(userSettings.Photos) 39 | userSettings.write() 40 | return json 41 | 42 | # Get Weather 43 | # Retrieve the current weather data from open weather map 44 | @app.route('/weather//', methods=["GET"]) 45 | def getWeather(includeForecast): 46 | userSettings = settings.read() 47 | if userSettings.Weather.isEnabled != True: 48 | return '{"isNotEnabled": "True"}' 49 | json = weather.getWeather(includeForecast, userSettings.Weather) 50 | return json 51 | 52 | # Post Settings 53 | # Update the user's settings, and save to the .json storage 54 | @app.route('/settings/', methods=["POST"]) 55 | def postSettings(): 56 | settingPayload = request.get_json() 57 | userSettings = settings.update(settingPayload) 58 | jsonString = json.dumps(userSettings.toJSON()) 59 | return jsonString 60 | 61 | if __name__ == '__main__': 62 | app.run() -------------------------------------------------------------------------------- /server/photos.py: -------------------------------------------------------------------------------- 1 | # PiFrame photos.py 2 | # Downloads photos from flickr and packages them for use on the client for the "Photos" extension. 3 | 4 | import settings, flickrapi, requests, os, json 5 | 6 | # AlbumSet is the top level container for the photo collection. 7 | class AlbumSet: 8 | def __init__(self): 9 | self.albums = [] 10 | 11 | # addAlbum appends an album object to the list 12 | def addAlbum(self, album): 13 | self.albums.append(album) 14 | 15 | # toJSON converts the AlbumSet object into a JSON string 16 | def toJSON(self): 17 | return json.dumps(self, default=lambda albumset: albumset.__dict__, sort_keys=True, indent=4) 18 | 19 | # Album stores information from the flickr website. 20 | # It is also in charge of holding all the photos within the album 21 | class Album: 22 | def __init__(self, name, id, isEnabled, path): 23 | self.name = name 24 | self.id = id 25 | self.isEnabled = isEnabled 26 | self.photos = [] 27 | self.path = path 28 | 29 | # addPhoto appends a photo to the albums list of photos 30 | def addPhoto(self, photo): 31 | self.photos.append(photo) 32 | 33 | # Photo represents a single photograph within flickr. 34 | # Since an album holds all the shared details needed by the client, the photo only needs to store the file name. 35 | class Photo: 36 | def __init__(self, name): 37 | self.name = name 38 | 39 | # getAlbumsForClient calls getAlbums to package all of the photos from flickr and returns a JSON string for the client 40 | def getAlbumsForClient(userSettings): 41 | # Retrieve all albums 42 | albums = getAlbums(userSettings) 43 | 44 | # Save the current album information to settings 45 | userSettings.setAlbums(albums) 46 | 47 | # Format the album JSON 48 | jsonString = json.dumps(albums.toJSON()) 49 | return jsonString 50 | 51 | # getAlbums retrieves all albums from flickr and packages them inside of an AlbumSet 52 | def getAlbums(userSettings): 53 | apiKey = userSettings.apiKey 54 | apiSecret = userSettings.apiSecret 55 | apiUser = userSettings.apiUser 56 | 57 | # If API key, secret key, or user is not set, let the user know 58 | if apiKey == None or apiKey == "" or apiSecret == None or apiSecret == "" or apiUser == None or apiUser == "": 59 | return '{"error": "API"}' 60 | 61 | flickr = flickrapi.FlickrAPI(apiKey, apiSecret) 62 | userIDRequest = "%s@N05" % (apiUser) 63 | result = flickr.photosets.getList(user_id=userIDRequest, format='parsed-json') 64 | jsonAlbums = result['photosets']['photoset'] 65 | albums = AlbumSet() 66 | for a in jsonAlbums: 67 | albumID = a['id'] 68 | albumTitle = a['title']['_content'] 69 | isEnabled = userSettings.isAlbumEnabled(albumID) 70 | pathFromImg = "flickr/%s" % (albumTitle) 71 | album = Album(albumTitle, albumID, isEnabled, pathFromImg) 72 | 73 | currentDir = os.path.dirname(__file__) 74 | rootDir = os.path.join(currentDir, '..') 75 | localAlbumURL = "%s/app/public/img/%s/" % (rootDir, pathFromImg) 76 | 77 | if os.path.isdir(localAlbumURL) == False: 78 | os.makedirs(localAlbumURL) 79 | for photo in flickr.walk_set(album.id): 80 | photoID = photo.get('id') 81 | downloadURL = "http://farm%s.staticflickr.com/%s/%s_%s_b.jpg" % (photo.get('farm'), photo.get('server'), photoID, photo.get('secret')) 82 | localURL = localAlbumURL + photoID + ".jpg" 83 | 84 | r = requests.get(downloadURL, timeout=60) 85 | image_file = open(localURL, 'wb') 86 | image_file.write(r.content) 87 | image_file.close() 88 | 89 | album.addPhoto(Photo(photoID)) 90 | albums.addAlbum(album) 91 | return albums -------------------------------------------------------------------------------- /app/src/Frame.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Extensions } from './Extensions' 3 | import { Settings, SettingsProperties, ClockSettings, VerseSettings, WeatherSettings, PhotosSettings } from './Settings' 4 | import { server } from './shared' 5 | 6 | // Frame is the top level within the application. 7 | // It splits into two responsibilities: Settings and Extensions. 8 | // Extensions allows for the rendering of each feature within the page. 9 | // Settings is the backing of a user's options for those extensions. 10 | // Settings are passed as a property down to Extensions. 11 | // Any settings modification requires reload of all features, so modifying the settings 12 | // results in a re-render of Extensions. 13 | export class Frame extends React.Component { 14 | constructor() { 15 | super() 16 | 17 | let settings = new SettingsProperties() 18 | this.state = { 19 | versionSinceMount: 0, 20 | Settings: settings 21 | } 22 | } 23 | 24 | componentDidMount() { 25 | this.getSettings() 26 | } 27 | 28 | // settingsUpdateCallback is passed into settings and is called whenever a user saves out of the modal 29 | // It's important this function exists at this level so that we can update state and apply updates to extensions downstream 30 | // @param result {JSON} the response from the server when saving an update to settings 31 | settingsUpdateCallback = (result) => { 32 | let userSettings = this.parseSettingsResponseToObject(result) 33 | this.setState({ 34 | Settings: userSettings, 35 | versionSinceMount: this.state.versionSinceMount + 1, 36 | }) 37 | } 38 | 39 | render() { 40 | if (!this.state.isLoaded) { 41 | return null 42 | } 43 | return( 44 |
45 | 46 | 47 |
48 | ); 49 | } 50 | 51 | // parseSettingsResponseToObject takes a JSON object from the server and makes it into an expected SettingsProperties object 52 | // This is technically not needed due to the lack of type checking in JavaScript, but is useful as a saftey check 53 | // as it helps encourage explicit additions to the client when adding information to the server. 54 | // @param response {JSON} the settings object from the server 55 | // @return {SettingsProperties} the JavaScript object of the responses 56 | parseSettingsResponseToObject(response) { 57 | let settings = JSON.parse(response) 58 | 59 | let photoSettings = new PhotosSettings( 60 | settings.Photos.isEnabled, 61 | settings.Photos.apiKey, 62 | settings.Photos.apiSecret, 63 | settings.Photos.apiUser, 64 | settings.Photos.albumSet 65 | ) 66 | 67 | let clockSettings = new ClockSettings( 68 | settings.Clock.isEnabled 69 | ) 70 | 71 | let verseSettings = new VerseSettings( 72 | settings.Verse.isEnabled 73 | ) 74 | 75 | let weatherSettings = new WeatherSettings( 76 | settings.Weather.isEnabled, 77 | settings.Weather.zip, 78 | settings.Weather.apiKey 79 | ) 80 | 81 | return new SettingsProperties( 82 | clockSettings, 83 | photoSettings, 84 | verseSettings, 85 | weatherSettings 86 | ) 87 | } 88 | 89 | // getSettings retrieves the settings data for the user 90 | // This is done by calling the settings REST endpoint in /server/run.py 91 | getSettings() { 92 | fetch(server + "settings") 93 | .then(res => res.json()) 94 | .then( 95 | (result) => { 96 | let userSettings = this.parseSettingsResponseToObject(result) 97 | 98 | this.setState({ 99 | isLoaded: true, 100 | Settings: userSettings 101 | }) 102 | }, 103 | (error) => { 104 | console.log(error) 105 | } 106 | ) 107 | } 108 | } -------------------------------------------------------------------------------- /server/settings.py: -------------------------------------------------------------------------------- 1 | # PiFrame settings.py 2 | # Manages writing/reading the .json file backing user settings for the app. 3 | # This informs the application of user settings for enabled extensions, and further settings and persisted data used by extensions 4 | 5 | import extensions 6 | import json 7 | 8 | # FILE_NAME is the name of the .json file in charge of storing settings 9 | FILE_NAME = "settings.json" 10 | 11 | # ALL_EXTENSIONS is a collection of all extensions.ExtensionSetting subclasses that currently exist. 12 | # Any new extensions should be appended to this array. 13 | ALL_EXTENSIONS = [ 14 | extensions.PhotosSettings, 15 | extensions.WeatherSettings, 16 | extensions.VerseSettings, 17 | extensions.ClockSettings 18 | ] 19 | 20 | # Settings is the state of the current user's settings within the application 21 | class Settings: 22 | # New settings should have their ExtensionSetting class added to the parameter and instantiated within __init__ 23 | # Note that each property should have the same name as the map key (i.e., self.Photos = exts["Photos"]) 24 | # :exts: is a map of ExtensionSetting.type() to ExtensionSetting 25 | # :isInitial: is a boolean value to represent if the Settings are created from a default (initial) creation 26 | def __init__(self, exts, isInitial): 27 | self.Photos = exts["Photos"] 28 | self.Weather = exts["Weather"] 29 | self.Verse = exts["Verse"] 30 | self.Clock = exts["Clock"] 31 | self.isInitial = isInitial 32 | 33 | # toJSON returns the JSON output to write to the .json file 34 | def toJSON(self): 35 | jsonStr = json.dumps(self, default=lambda settings: settings.__dict__, ensure_ascii=False, indent=4) 36 | return jsonStr 37 | 38 | # write serves as a utility for writing information into the .json file. 39 | # This should be called after any sort of change to settings data is made. 40 | def write(self): 41 | with open(FILE_NAME, 'w') as f: 42 | settings = self.toJSON() 43 | f.write(settings) 44 | 45 | # read serves as a utility for reading information from the .json file into the application. 46 | # This should only be called on startup, since at any other point, since the application should maintain the valid state of the settings. 47 | # If no file can be found, __initialSetup should be called to set the default settings 48 | def read(): 49 | try: 50 | with open(FILE_NAME, 'r') as f: 51 | jsonValue = json.load(f) 52 | return parseSettingsJSON(jsonValue) 53 | except IOError: 54 | return __initialSetup() 55 | 56 | # update takes a user's settings and applies them to the .json file for local use 57 | # :jsonObject: the json of the user's selected options 58 | # return :Settings: after setting the user's options so that the client has a copy of the latest settings. 59 | def update(jsonObject): 60 | userSettings = parseSettingsJSON(jsonObject) 61 | userSettings.write() 62 | return userSettings 63 | 64 | # parseSettingsFromJSON transforms a JSON dictionary into a full Settings object 65 | # :jsonObject: is the parsed JSON of the .json file 66 | # return :Settings: object parsed from data 67 | def parseSettingsJSON(jsonObject): 68 | exts = {} 69 | for item in ALL_EXTENSIONS: 70 | prop = None 71 | key = item.type() 72 | try: 73 | data = jsonObject[key] 74 | prop = item.createFromDict(data) 75 | except: 76 | prop = item.createDefault() 77 | finally: 78 | exts[key] = prop 79 | return Settings(exts, False) 80 | 81 | # __initialSetup is called if the .json file does not exist. 82 | # It sets up the .json file with default values for all expected data. 83 | def __initialSetup(): 84 | exts = {} 85 | for item in ALL_EXTENSIONS: 86 | prop = None 87 | key = item.type() 88 | prop = item.createDefault() 89 | exts[key] = prop 90 | 91 | # Write to the .json file, and return our json object 92 | data = Settings(exts, True) 93 | data.write() 94 | return data -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PiFrame for Rasberry Pi 2 | ## What is PiFrame? 3 | PiFrame is a photo frame application, designed to run on the Rasberry Pi. I have wanted to build something with a Pi for a while, so decided I'd make something sentimental for my fiancee's 21st birthday. 4 | 5 | ## User Manual 6 | #### Shopping List 7 | You will need a few supplies. You can look around for better deals on different products, or go for a bigger screen, but here is what I used: 8 | - Rasberry Pi 3B 9 | - 8 GB micro SD card 10 | - 7 inch touchscreen for Rasberry Pi 11 | - 1 HDMI cable 12 | - 2 microUSB cables 13 | - 1 USB keyboard (for setup) 14 | - 1 USB mouse (for setup) 15 | 16 | #### Setup 17 | Step 1) Download Raspbian following instructions here: https://thepi.io/how-to-install-raspbian-on-the-raspberry-pi/ 18 | 19 | Step 2) Move the SD card into your Pi, plug it up to the screen, and power on! 20 | 21 | Step 3) Open up the terminal and download this git repository (in the `/pi/home` directory): `git clone https://github.com/jacobhjustice/PiFrame` 22 | 23 | Step 4) Setup all app dependencies by running `./setup.sh` 24 | 25 | Step 5) Run the app on startup by running `crontab -e` in the terminal, and add `@reboot /home/pi/PiFrame/run.sh` 26 | 27 | Step 6) Open app on startup by running `sudo nano /etc/xdg/lxsession/LXDE-pi/autostart` and appending the following to the end of file: 28 | ``` 29 | @xset s off 30 | @xset -dpms 31 | @xset s noblank 32 | @chromium-browser --kiosk http://localhost:3000 33 | ``` 34 | 35 | *Optional* For UI enhancements (hide mouse cursor and don't sleep screen) , run the following commands to add dependencies: 36 | ``` 37 | sudo apt install unclutter 38 | sudo apt-get install xscreensaver 39 | ``` 40 | 41 | Then select the menu at the top left corner -> preference -> screensaver and select "disable screensaver" 42 | 43 | Afterwards, run `sudo nano /etc/xdg/lxsession/LXDE-pi/autostart` and append `unclutter -idle 0` to the file 44 | 45 | Step 7) Reboot the pi by runing `reboot` in the terminal. Should launch the app and open the kiosk browser to it. From this point, you'll need to enter in your User Settings by selecting the button in the bottom left to add any API keys. 46 | 47 | ## Developer Manual 48 | #### Organization 49 | The source code is split into two major parts: The client (`/app`) and the backend (`/server`). 50 | 51 | The backend holds a Flask server that is setup in `/server/run.py` (which is a good place to start if you want to look at how the backend logic works), which exposes endpoints for individual features (these are called extensions, but that's for later). The client is a React.js app that uses information from the server to display information for each feature. The base of the client is mounted in `/app/index.js`. 52 | 53 | #### PiFrame was meant to be built upon! 54 | Every feature within the application is called an `extension`. These extensions are made to be able to be turned on/off, and to function independantly of each other! This is great because that means any features you don't want to use can be disabled, ignored, or even removed. Even better, this methodology allows for more extensions to easilly be added! As such, each extension should exist in its own file (both in client and backend). 55 | 56 | Every extension has three things in common: they are organized, persistent, and *always* on time! 57 | - *Organized*: Each extension contains attributes for itself. That's it, no tricks. Extensions are self-serving, and do not reference one another. This removes any concerns of circular dependencies, spaghetti code, etc. 58 | - *Persistent*: Every extension exists within `Settings`, which means that it's data is stored locally and reloaded. Either via the settings interface (which calls an endpoint to update the settings), or within the server code. 59 | - *On time*: On the client, extensions exist within the `Extensions` element (clever name, right?). Once the component is mounted (or updated.. but more on that later), all client updates are driven by a single central timer with an intereval of 1 second. This means that renders happen up to once per second (in the case of the `Clock` extension). Extensions can either choose to update after a set amount of time (`Photos` updates every 6 seconds), or at a given time (`Weather` updates on the minute). 60 | 61 | #### To add an extension... 62 | - Implement the {*extension_name*}Settings class within /server/extensions.py 63 | - Add the new {*extension_name*}Settings class to the ALL_EXTENSIONS array and the `Settings` constructor in /server/settings.py 64 | - Add a new file for any utility functions if needed in /server/{*extension_name*}.py 65 | - Add a new route for a REST endpoint in /server/run.py 66 | - Implement an {*extension_name} and {*extension_name*}Properties class at minimum in /app/src/{*extension_name*}.js 67 | - Implement client-side code in /app/src/index.js to render the extension 68 | - Append settings for the extension within /app/src/Settings.js 69 | 70 | When in doubt, follow an existing example! `Photos` and `Weather` are more complex models that make for a good base for future use-cases. 71 | 72 | Below, depicts the relationship behtween Settings and Extensions in the app 73 | 74 | ![](piframe_client.png) 75 | 76 | ## That's It! 77 | This is everything you need to know to either dive into the code or install the application. 78 | 79 | ![](frame.jpg) 80 | -------------------------------------------------------------------------------- /app/src/Photos.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // ImageManager is used to manage the active picture being displayed in the Photos compontnet. 4 | // It exists as a member of the PhotosProperties class and should retrieve an image every 6 seconds. 5 | class ImageManager { 6 | // @param albumSet {AlbumSet} the current collection of pictures to render from 7 | constructor(albumSet) { 8 | if(albumSet !== undefined) { 9 | let albums = [] 10 | albumSet.albums.forEach(album => { 11 | if (album.isEnabled && album.photos.length > 0) { 12 | albums.push(album) 13 | } 14 | }); 15 | 16 | this.albums = albums 17 | this.currentAlbum = 0 18 | this.currentPhoto = 0 19 | this.current = undefined 20 | this.images = require.context('../public/img/', true); 21 | 22 | this.isLocked = false 23 | } 24 | } 25 | 26 | // Retrieve the next photo in line. Look for the next photo in the current album, but if all photos have been traversed, move to the next album (or restart with the first album) 27 | // Set the next photo's url to be used in the Photos component 28 | getPhoto() { 29 | if(this.isLocked) { 30 | return 31 | } 32 | 33 | var album = this.albums[this.currentAlbum] 34 | 35 | if(album === undefined) { 36 | this.currentAlbum = 0 37 | return 38 | } 39 | 40 | if(!album.isEnabled) { 41 | this.currentAlbum = (this.currentAlbum + 1) % this.albums.length 42 | } 43 | 44 | var photo = album.photos[this.currentPhoto] 45 | if(photo === undefined) { 46 | this.currentAlbum = (this.currentAlbum + 1) % this.albums.length 47 | this.currentPhoto = 0 48 | return 49 | } 50 | 51 | if (this.currentPhoto + 1 === this.albums[this.currentAlbum].photos.length) { 52 | this.currentPhoto = 0 53 | this.currentAlbum = (this.currentAlbum + 1) % this.albums.length 54 | } 55 | 56 | this.currentPhoto = (this.currentPhoto + 1) % this.albums[this.currentAlbum].photos.length 57 | var image = album.path + "/" + photo.name + ".jpg" 58 | 59 | try { 60 | // Set the next image and close the gate until an update from timer 61 | this.current = this.images(`./` + image) 62 | this.isLocked = true 63 | } 64 | catch(error) { 65 | console.error(error); 66 | } 67 | } 68 | 69 | // openGate is called to let the image manager know that it can update the image 70 | openGate() { 71 | this.isLocked = false 72 | } 73 | } 74 | 75 | // PhotosProperties contain backing information for the Photos component. 76 | // Should be instantiated from the Extensions level and passed into any instance of Photos. 77 | export class PhotosProperties { 78 | // @param isEnabled {bool} the current enabled status of this extension in settings 79 | // @param error {string} if error is not null, then an error occurred during data retrieval 80 | // @param albumSet {AlbumSet} the current collection of pictures to be passed to the ImageManager 81 | // @param tick {number} the seconds since the image has been rendered (from 0 - 5) 82 | constructor(isEnabled, error, albumSet, tick) { 83 | this.isEnabled = isEnabled 84 | this.error = error 85 | this.tick = tick 86 | this.isLoaded = albumSet !== undefined 87 | if (this.isLoaded) { 88 | this.imageManager = new ImageManager(albumSet) 89 | } else { 90 | this.tick = 0 91 | } 92 | } 93 | } 94 | 95 | // Photos displays information for the Photos extension. 96 | // Technically, the component is rendered every second, but in practicality it is only updated every 6 seconds 97 | export class Photos extends React.Component { 98 | render() { 99 | if(this.props.error != null) { 100 | return ( 101 |
102 |
{this.props.error === "UNKNOWN" ?
An error has occured and could not fetch images. Please make sure you are connected to the internet.
:
An error has occurred and could not fetch images. Please be sure that your API key is correct from Flickr.
}
103 |
104 | ) 105 | } 106 | 107 | if (!this.props.isEnabled) { 108 | return null 109 | } 110 | 111 | if(!this.props.isLoaded) { 112 | return(
113 |
Fetching Images...
114 |
) 115 | } 116 | 117 | if (this.props.tick === 0 || this.props.imageManager.current === undefined) { 118 | this.props.imageManager.getPhoto() 119 | } else if (this.props.tick > 0) { 120 | this.props.imageManager.openGate() 121 | } 122 | return ( 123 |
124 |
Loading...
125 |
126 | ); 127 | } 128 | } -------------------------------------------------------------------------------- /server/extensions.py: -------------------------------------------------------------------------------- 1 | # PiFrame extensions.py 2 | # Manages active extension information for the PiFrame application. 3 | # Any feature within the app is an extension, as it can be turned on/off, or even removed from the code base. 4 | # No extensions should ever rely on each other, and should be self sufficient (in other words, if two extensions rely on one another, they should be grouped into one). 5 | 6 | import enum 7 | from abc import ABC, abstractmethod 8 | import photos 9 | 10 | # ExtensionSetting is an abstract class that is used as a base for every category of settings. 11 | # Each extension should have an equivalent property of an ExtensionSetting subclass within the Settings object in /server/settings.py. 12 | # For example, the Photos extension has a Photos property in settings, whih is of type PhotosSettings (which inherits from ExtensionSetting). 13 | # Every ExtensionSetting instance is responsible for holding its type, its enabled status, and any specific data corresponding to that extension. 14 | # ExtensionSetting should never be allowed to inherit from each other, as this would introduce coupling between extensions. 15 | class ExtensionSetting(ABC): 16 | 17 | # type describes the sort of ExtensionSetting that the class is 18 | @staticmethod 19 | def type(): 20 | return None 21 | 22 | # isEnabled determines if the required property isEnabled is set to True 23 | def isEnabled(self): 24 | return self.isEnabled 25 | 26 | # createDefault is used to create an instance of ExtensionSetting with default properties. 27 | # This is used if the setting has no data associated with it (i.e., first time using the application, or a new setting is used). 28 | # It is important each child of ExtensionSetting has its own seperate implementation to avoid over-writing all instances of ExtensionSetting 29 | # return :ExtensionSetting: that is created by default 30 | @staticmethod 31 | @abstractmethod 32 | def createDefault(): 33 | pass 34 | 35 | # createFromDict is used to create an instance of ExtensionSetting persisted data from the parsed .json file 36 | # :data: is the dictionary of relevant data returned from settings.read() 37 | @staticmethod 38 | @abstractmethod 39 | def createFromDict(data): 40 | pass 41 | 42 | # PhotosSettings contains all settings relevant to the Photos feature. 43 | # Related code and documentation can be found within /server/photos.py. 44 | class PhotosSettings(ExtensionSetting): 45 | def __init__(self, isEnabled, albumSet, apiKey, apiSecret, apiUser): 46 | self.isEnabled = isEnabled 47 | self.albumSet = albumSet 48 | self.apiKey = apiKey 49 | self.apiSecret = apiSecret 50 | self.apiUser = apiUser 51 | 52 | # isAlbumEnabled checks the current settings to check if a given albumID is enabled 53 | # Pertains to extensions.Extensions.picture 54 | # :albumID: the string ID that is the photos.Album.id being searched for 55 | def isAlbumEnabled(self, albumID): 56 | # If album exists in settings, return its enabled status 57 | for a in self.albumSet.albums: 58 | if a.id == albumID: 59 | return a.isEnabled 60 | 61 | # Return true by default if not found 62 | return True 63 | 64 | # setAlbums updates the settings to use the provided album set 65 | # Pertains to extensions.Extensions.picture 66 | # :albumSet: the current photos.AlbumSet value after any sort of data retrival from flickr 67 | def setAlbums(self, albumSet): 68 | self.albumSet = albumSet 69 | 70 | @staticmethod 71 | def type(): 72 | return "Photos" 73 | 74 | @staticmethod 75 | def createDefault(): 76 | return PhotosSettings(True, photos.AlbumSet(), "", "", "") 77 | 78 | @staticmethod 79 | def createFromDict(data): 80 | albums = photos.AlbumSet() # Load in base information about albums... will load photos in seperately only when needed 81 | if data["albumSet"] != None and len(data["albumSet"]["albums"]) > 0: 82 | for a in data["albumSet"]["albums"]: 83 | album = photos.Album(a["name"], a["id"], a["isEnabled"], a["path"]) 84 | for p in a["photos"]: 85 | album.addPhoto(photos.Photo(p["name"])) 86 | albums.addAlbum(album) 87 | isEnabled = data["isEnabled"] 88 | apiKey = data["apiKey"] 89 | apiSecret = data["apiSecret"] 90 | apiUser = data["apiUser"] 91 | return PhotosSettings(isEnabled, albums, apiKey, apiSecret, apiUser) 92 | 93 | class WeatherSettings(ExtensionSetting): 94 | def __init__(self, isEnabled, zipcode, apiKey): 95 | self.isEnabled = isEnabled 96 | self.zip = zipcode 97 | self.apiKey = apiKey 98 | 99 | @staticmethod 100 | def type(): 101 | return "Weather" 102 | 103 | @staticmethod 104 | def createDefault(): 105 | return WeatherSettings(True, "", "") 106 | 107 | @staticmethod 108 | def createFromDict(data): 109 | isEnabled = data["isEnabled"] 110 | zip = data["zip"] 111 | apiKey = data["apiKey"] 112 | return WeatherSettings(isEnabled, zip, apiKey) 113 | 114 | class VerseSettings(ExtensionSetting): 115 | def __init__(self, isEnabled): 116 | self.isEnabled = isEnabled 117 | 118 | @staticmethod 119 | def type(): 120 | return "Verse" 121 | 122 | @staticmethod 123 | def createDefault(): 124 | return VerseSettings(True) 125 | 126 | @staticmethod 127 | def createFromDict(data): 128 | isEnabled = data["isEnabled"] 129 | return VerseSettings(isEnabled) 130 | 131 | class ClockSettings(ExtensionSetting): 132 | def __init__(self, isEnabled): 133 | self.isEnabled = isEnabled 134 | 135 | @staticmethod 136 | def type(): 137 | return "Clock" 138 | 139 | @staticmethod 140 | def createDefault(): 141 | return ClockSettings(True) 142 | 143 | @staticmethod 144 | def createFromDict(data): 145 | isEnabled = data["isEnabled"] 146 | return ClockSettings(isEnabled) -------------------------------------------------------------------------------- /server/weather.py: -------------------------------------------------------------------------------- 1 | # PiFrame weather.py 2 | # Manages weather data as well as forecast for the "Weather" Extension 3 | # Uses Open Weather API https://openweathermap.org/api 4 | import requests, settings, json, datetime 5 | 6 | # Request URLS for weather 7 | currentWeatherRequestURL = lambda zip, apiKey : ("http://api.openweathermap.org/data/2.5/weather?zip=%s&appid=%s&units=imperial" % (zip, apiKey)) 8 | forecastWeatherRequestURL = lambda zip, apiKey : ("http://api.openweathermap.org/data/2.5/forecast?zip=%s&appid=%s&units=imperial" % (zip, apiKey)) 9 | weatherIconURL = lambda iconCode : "http://openweathermap.org/img/wn/%s@2x.png" % (iconCode) 10 | 11 | # WeatherResponse is a serializable response containing requested weather information 12 | class WeatherResponse: 13 | def __init__(self, location, sunrise, sunset, currentResponse, todayForecast, upcomingForecast): 14 | self.location = location 15 | self.sunrise = sunrise 16 | self.sunset = sunset 17 | self.currentResponse = currentResponse 18 | self.todaysForecast = todayForecast 19 | self.upcomingForecasts = upcomingForecast 20 | 21 | def toJSON(self): 22 | return json.dumps(self, default=lambda weather: weather.__dict__) 23 | 24 | # WeatherResponseItem represents a single weather log 25 | class WeatherResponseItem: 26 | def __init__(self, iconURL, epochTime, temperature, minTemperature, maxTemperature, humidity): 27 | self.iconURL = iconURL 28 | self.temperature = round(temperature, 0) 29 | self.minTemperature = round(minTemperature, 0) 30 | self.maxTemperature = round(maxTemperature, 0) 31 | self.humidity = humidity 32 | self.time = epochTime 33 | 34 | # getWeatherResponseItemFromData is used to create a WeatherResponseItem object from a dictionary of weather data 35 | # param :data: a dictionary of information from the API call 36 | # param :timeStamp: the datetime that the weather information corresponds to 37 | # return :WeatherResponseItem: the response item created with data 38 | def getWeatherResponseItemFromData(data, timeStamp): 39 | iconURL = weatherIconURL(data["weather"][0]["icon"]) 40 | temperature = data["main"]["temp"] 41 | maxTemp = data["main"]["temp_max"] 42 | minTemp = data["main"]["temp_min"] 43 | humidity = data["main"]["humidity"] 44 | time = int(timeStamp.timestamp()) 45 | return WeatherResponseItem(iconURL, time, temperature, minTemp, maxTemp, humidity) 46 | 47 | # getWeather queries the weather API for the client. By default, the current data is retrieved. 48 | # param :includeForecast: a boolean value that indicates if forecast data should be included in the request 49 | # return :WeatherResponse: the results of the weather query/parse 50 | def getWeather(includeForecast, settings): 51 | zip = settings.zip 52 | apiKey = settings.apiKey 53 | 54 | # If API key is not set, let the user know 55 | if apiKey == None or apiKey == "": 56 | return '{"error": "API"}' 57 | 58 | url = currentWeatherRequestURL(zip, apiKey) 59 | response = requests.get(url, timeout=10) 60 | 61 | # Make sure request was completed 62 | if response.status_code != 200: 63 | return '{"error": "REQUEST"}' 64 | 65 | data = response.json() 66 | 67 | location = data["name"] 68 | sunset = data["sys"]["sunset"] 69 | sunrise = data["sys"]["sunrise"] 70 | timeStamp = datetime.datetime.now() 71 | 72 | current = getWeatherResponseItemFromData(data, timeStamp) 73 | 74 | todayForecast = [] 75 | upcomingForecast = [] 76 | if includeForecast: 77 | url = forecastWeatherRequestURL(zip, apiKey) 78 | response = requests.get(url, timeout=10) 79 | 80 | # If request wasn't completed, skip to end and return what we have 81 | if response.status_code == 200: 82 | data = response.json() 83 | currentDay = timeStamp.day 84 | entriesForCurrentDay = [] 85 | for update in data["list"]: 86 | dt = datetime.datetime.fromtimestamp(update["dt"]) 87 | dataDay = dt.day 88 | responseItem = getWeatherResponseItemFromData(update, dt) 89 | 90 | # Keep a list of weather for a given day 91 | entriesForCurrentDay.append(responseItem) 92 | 93 | # Should record forecasts for the next 24 hours 94 | if len(todayForecast) < 8: 95 | todayForecast.append(responseItem) 96 | 97 | # Once we move to a new day add the normalized information to our upcomingForecast list 98 | # Note, only the next 4 full days are recorded, not including the current day 99 | if currentDay != dataDay and len(upcomingForecast) < 5: 100 | if len(entriesForCurrentDay) == 8: 101 | entryFromDaysForecast = parseAveragesForDaysForecast(entriesForCurrentDay) 102 | upcomingForecast.append(entryFromDaysForecast) 103 | entriesForCurrentDay = [] 104 | currentDay = dataDay 105 | 106 | # Return our results 107 | returnObj = WeatherResponse(location, sunrise, sunset, current, todayForecast, upcomingForecast) 108 | return returnObj.toJSON() 109 | 110 | # parseAveragesForDaysForecast goes over all 8 weather entries for a given day and creates one entry for the full day. 111 | # This means taking the over all max and min temperatures, as well as the average temperature and humidity 112 | # return :WeatherResponseItem: The consolidated response item 113 | def parseAveragesForDaysForecast(entriesForCurrentDay): 114 | temp = 0 115 | humidity = 0 116 | max_temp = -1000 117 | min_temp = 1000 118 | time = entriesForCurrentDay[0].time 119 | for entry in entriesForCurrentDay: 120 | temp += entry.temperature 121 | humidity += entry.humidity 122 | max_temp = entry.maxTemperature if entry.maxTemperature > max_temp else max_temp 123 | min_temp = entry.minTemperature if entry.minTemperature < min_temp else min_temp 124 | temp = temp / 8 125 | humidity = humidity / 8 126 | return WeatherResponseItem("", time, temp, min_temp, max_temp, humidity) -------------------------------------------------------------------------------- /app/src/Weather.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getDisplayTime, getDateString } from './shared' 3 | const images = require.context('../public/img/icon', true); 4 | 5 | // CurrentWeatherProperties contain backing information for the CurrentWeather component. 6 | // Should be instantiated from the Extensions level and passed into any instance of CurrentWeather. 7 | export class CurrentWeatherProperties { 8 | // @param isEnabled {bool} the current enabled status of this extension in settings 9 | // @param error {string} if error is not null, then an error occurred during data retrieval 10 | // @param location {string} the location (city) corresponding to the zip-code stored in settings for weather 11 | // @param sunriseEpoch {number} estimated value of the current day's sunrise in seconds with 0 being the epoch value 12 | // @param sunriseEpoch {number} estimated value of the current day's sunset in seconds with 0 being the epoch value 13 | // @param temperature {number} current temperature value in farenheit 14 | // @param humidity {number} current percentage of humidity in the air 15 | // @param icon {string} URL of the icon that represents the current weather 16 | constructor(isEnabled, error, location, sunriseEpoch, sunsetEpoch, temperature, humidity, icon) { 17 | this.isEnabled = isEnabled 18 | this.error = error 19 | this.isLoaded = this.error != null || temperature !== undefined 20 | if (this.isLoaded) { 21 | this.location = location 22 | this.sunrise = new Date(sunriseEpoch*1000) 23 | this.sunset = new Date(sunsetEpoch*1000) 24 | this.temperature = temperature 25 | this.humidity = humidity 26 | this.icon = icon 27 | } 28 | } 29 | } 30 | 31 | // CurrentWeather displays information for the current section of the Weather extension. 32 | // It should render each minute within extensions with data for the current weather. 33 | export class CurrentWeather extends React.Component { 34 | render() { 35 | if (!this.props.isLoaded || !this.props.isEnabled || this.props.error != null) { 36 | return null 37 | } 38 | 39 | return ( 40 |
41 |
42 |
{this.props.location}
43 |
44 |
45 |
46 | 47 |
{this.props.temperature}° F
48 |
49 |
50 | Sunrise 51 |
{getDisplayTime(this.props.sunrise, false)}
52 |
53 |
54 | Sunset 55 |
{getDisplayTime(this.props.sunset, false)}
56 |
57 |
58 | Humidity 59 |
{this.props.humidity}%
60 |
61 |
62 |
63 | ); 64 | } 65 | } 66 | 67 | // WeatherForecastItemProperties contain information for each individual WeatherForecastItem 68 | // Should be instantiated from the Extensions level and used to create WetherForecastItems for the WeatherForecastProperties 69 | export class WeatherForecastItemProperties { 70 | // @param temperature {number} estimated target temperature value in farenheit 71 | // @param timeEpoch {number} The gien time of this forecast in seconds with 0 being the epoch value 72 | // @param icon {string} URL of the icon that represents the target weather 73 | constructor(temperature, timeEpoch, icon) { 74 | this.isLoaded = temperature !== undefined 75 | if (this.isLoaded) { 76 | this.temperature = temperature 77 | this.icon = icon 78 | this.time = new Date(timeEpoch*1000) 79 | } 80 | } 81 | } 82 | 83 | // WeatherForecastItem displays information for an individual forecast within the upcoming section of the Weather extension. 84 | // It should render within the rendering of WeatherForecast 85 | class WeatherForecastItem extends React.Component { 86 | render() { 87 | return( 88 |
89 |
90 | 91 |
92 |
93 |
{getDisplayTime(this.props.time, false, true)} - {getDateString(this.props.time, false)}
94 |
{this.props.temperature}° F
95 |
96 |
97 | ); 98 | } 99 | } 100 | 101 | // WeatherForecastProperties contain backing information for the WeatherForecast component. 102 | // Should be instantiated from the Extensions level and passed into any instance of WeatherForecast. 103 | export class WeatherForecastProperties { 104 | // @param isEnabled {bool} the current enabled status of this extension in settings 105 | // @param error {string} if error is not null, then an error occurred during data retrieval 106 | // @param dailyForecasts {array[WeatherForecastItemProperties]} list of all properties used to render each WeatherForecastItem 107 | constructor (isEnabled, error, dailyForecasts) { 108 | this.isEnabled = isEnabled 109 | this.error = error 110 | this.isLoaded = this.error != null || (dailyForecasts !== undefined && dailyForecasts.length === 8) 111 | if (this.isLoaded) { 112 | this.dailyForecasts = dailyForecasts 113 | } 114 | } 115 | } 116 | 117 | // WeatherForecast displays information for the upcoming section of the Weather extension. 118 | // It should render each hour within extensions with expected data for the upcoming weather. 119 | export class WeatherForecast extends React.Component { 120 | render() { 121 | if (!this.props.isLoaded || !this.props.isEnabled) { 122 | return null 123 | } 124 | 125 | if (this.props.error != null) { 126 | return ( 127 |
128 |
{this.props.error === "UNKNOWN" ?
An error has occured and could not fetch weather. Please make sure you are connected to the internet.
:
An error has occurred and could not fetch weather. Please be sure that your API key is correct from the weather service.
}
129 |
130 | ) 131 | } 132 | let forecasts = [] 133 | this.props.dailyForecasts.forEach((forecast) => { 134 | let item = new WeatherForecastItem(forecast) 135 | forecasts.push(item.render()) 136 | }) 137 | 138 | return( 139 |
140 | {forecasts} 141 |
142 | ); 143 | } 144 | } -------------------------------------------------------------------------------- /app/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: #19181a; 3 | color: #b19f9e; 4 | 5 | /* Use vh instead of vw since a screen's height will be more valuable real estate. */ 6 | /* 40 em is total screen height. */ 7 | font-size: 2.5vh; 8 | font-weight: bold; 9 | } 10 | 11 | .lineWrapper { 12 | width: 100%; 13 | } 14 | 15 | #currentDetails { 16 | position: absolute; 17 | top: 25px; 18 | left: 0px; 19 | z-index: 89; 20 | height: 60px; 21 | } 22 | 23 | #currentWeather { 24 | display: block; 25 | } 26 | 27 | .modalContent { 28 | width: 100%; 29 | height: 100%; 30 | padding: 75px; 31 | overflow-y: auto; 32 | box-sizing: border-box; 33 | } 34 | 35 | .buttonWrapper { 36 | position: fixed; 37 | height: 75px; 38 | bottom: 0; 39 | width: 90vw; 40 | left: 5vw; 41 | padding: 20px; 42 | box-sizing: border-box; 43 | text-align: center; 44 | background-color: rgba(25, 24, 26, .5); 45 | } 46 | 47 | .buttonWrapper .processing { 48 | color: #cebc81; 49 | font-size: 32px; 50 | margin: 0 auto; 51 | text-align: center; 52 | } 53 | 54 | .button { 55 | height: 30px; 56 | border-radius: 7px; 57 | padding: 0 10px; 58 | cursor: pointer; 59 | background-color: #b19f9e; 60 | color: #19181a; 61 | line-height: 30px; 62 | font-weight: bold; 63 | display: inline-block; 64 | margin: 0 20px; 65 | } 66 | 67 | .extension { 68 | background-color: rgba(25, 24, 26, .5); 69 | padding: 5px; 70 | box-sizing: border-box; 71 | } 72 | 73 | .button.big { 74 | min-width: 200px; 75 | height: 40px; 76 | margin: 0 15px; 77 | font-size: 20px; 78 | line-height: 40px; 79 | } 80 | 81 | .button.denial { 82 | background-color: #a16e83; 83 | } 84 | 85 | .button.approval { 86 | background-color: #479761; 87 | } 88 | 89 | .button .header { 90 | vertical-align: top; 91 | display: inline-block; 92 | } 93 | 94 | .right-align { 95 | text-align: right; 96 | width: 50%; 97 | } 98 | 99 | .left-align { 100 | text-align: left; 101 | width: 50%; 102 | } 103 | 104 | .error { 105 | padding: 20px; 106 | color: #a16e83; 107 | } 108 | 109 | .errorWrapper { 110 | width: 200px; 111 | } 112 | 113 | .inline { 114 | display: inline-block; 115 | } 116 | 117 | .center { 118 | text-align: center; 119 | margin: 0 auto; 120 | } 121 | 122 | .left { 123 | float: left; 124 | } 125 | 126 | .right { 127 | float: right; 128 | } 129 | 130 | #clock { 131 | padding-left: 30px; 132 | z-index: 100; 133 | } 134 | 135 | #currentWeather { 136 | z-index: 89; 137 | } 138 | 139 | #currentWeather .lineWrapper { 140 | height: 2.5em; 141 | } 142 | 143 | #currentWeather #locationsDetails .lineWrapper { 144 | height: 4em; 145 | max-height: 50px; 146 | margin-bottom: 1.5em; 147 | } 148 | 149 | #currentWeather .header { 150 | font-size: 2.3em; 151 | line-height: 0px; 152 | margin: 1em 0 .5em 30px; 153 | color: #cebc81; 154 | 155 | } 156 | 157 | #currentWeather .weatherIcon { 158 | margin: 0 1em 0 30px; 159 | height: 100%; 160 | 161 | } 162 | 163 | #currentWeather .subtitle { 164 | font-size: 1.3em; 165 | line-height: 50px; 166 | vertical-align: top; 167 | } 168 | 169 | #weatherForecast { 170 | position: absolute; 171 | top: 0; 172 | right: 0; 173 | height: 100vh; 174 | z-index: 89; 175 | overflow: hidden; 176 | 177 | /* Padding is handled in each item */ 178 | padding: 0; 179 | } 180 | 181 | #weatherForecast .weatherForecastItem { 182 | height: 5em; 183 | box-sizing: border-box; 184 | position: relative; 185 | display: block; 186 | } 187 | 188 | #weatherForecast .weatherForecastItem:not(:last-child) { 189 | border-bottom: 2px solid #a16e83; 190 | } 191 | 192 | #weatherForecast .weatherForecastItem .itemWrapper { 193 | display: inline-block; 194 | height: 100%; 195 | box-sizing: border-box; 196 | position: relative; 197 | padding: .9em 1em; 198 | min-width: 75px; 199 | 200 | } 201 | 202 | #weatherForecast .weatherForecastItem .itemWrapper img { 203 | height: 70px; 204 | position: absolute; 205 | top: 0; 206 | left: 0; 207 | right: 0; 208 | bottom: 0; 209 | margin: auto; 210 | } 211 | 212 | #weatherForecast .weatherForecastItem .itemWrapper .header { 213 | font-size: 1.75em; 214 | } 215 | 216 | #weatherForecast .weatherForecastItem .itemWrapper .subtitle { 217 | font-size: 1em; 218 | } 219 | 220 | #clock .header { 221 | font-size: 2.5em; 222 | font-weight: bold; 223 | color: #479761; 224 | } 225 | 226 | .hidden { 227 | visibility: hidden; 228 | } 229 | 230 | .no-display { 231 | display: none; 232 | } 233 | 234 | #clock .header div { 235 | display: inline; 236 | } 237 | 238 | #clock .subtitle { 239 | font-size: 1.25em; 240 | color: #cebc81; 241 | } 242 | 243 | #verse { 244 | position: absolute; 245 | bottom: 0px; 246 | z-index: 89; 247 | padding: 10px 0; 248 | left: 35vw; 249 | width: 30vw; 250 | } 251 | 252 | #verse .wrapper { 253 | width: 100%; 254 | padding: 5px; 255 | box-sizing: border-box; 256 | margin: 0 auto; 257 | position: relative; 258 | } 259 | 260 | #verse .wrapper .quote { 261 | width: 100%; 262 | font-size:19px; 263 | text-align: center; 264 | font-weight: bold; 265 | color: #cebc81; 266 | } 267 | 268 | #verse .wrapper .reference { 269 | max-width: 90%; 270 | text-align: right; 271 | float: right; 272 | font-size: 16px; 273 | /* color: #ffffff; */ 274 | } 275 | 276 | #photo { 277 | z-index: 5; 278 | position: absolute; 279 | top: 0; 280 | left: 0; 281 | width: 100vw; 282 | height: 100vh; 283 | text-align: center; 284 | 285 | } 286 | 287 | #photo .header { 288 | margin-top: 50px; 289 | } 290 | 291 | #photo .dimmer { 292 | /* position: absolute; */ 293 | height: 100%; 294 | width: 100%; 295 | background-color: rgba(50, 50, 50, .3); 296 | z-index: 5; 297 | } 298 | 299 | #photo .wrapper { 300 | position: relative; 301 | height: 100%; 302 | width: 100%; 303 | } 304 | 305 | #photo img { 306 | top: 0; 307 | left: 0; 308 | right: 0; 309 | bottom: 0; 310 | margin: auto; 311 | position: absolute; 312 | filter: brightness(100%); 313 | max-width: 100%; 314 | max-height: 100%; 315 | } 316 | 317 | #sync { 318 | position: absolute; 319 | bottom: 20px; 320 | left: 130px; 321 | z-index: 20; 322 | } 323 | 324 | #settings #settingsButton { 325 | position: absolute; 326 | bottom: 20px; 327 | left: 20px; 328 | z-index: 20; 329 | } 330 | 331 | #settings #settingsButton img, #sync img { 332 | width: 16px; 333 | height: 20px; 334 | margin-top: 5px; 335 | margin-right: 5px; 336 | } 337 | 338 | 339 | #settingsModal { 340 | width: 100vw; 341 | height: 100vh; 342 | position: fixed; 343 | top: 0; 344 | left: 0; 345 | right: 0; 346 | bottom: 0; 347 | z-index: 300; 348 | background-color: #19181a; 349 | text-align: center; 350 | } 351 | 352 | #settingsModal .extensionHeader{ 353 | font-size: 32px; 354 | color: #cebc81; 355 | display: inline-block; 356 | padding: 5px 20px; 357 | border-bottom: 5px solid #b19f9e; 358 | border-radius: 0 0 7.5px 7.5px; 359 | } 360 | 361 | #settingsModal .extensionSubheader { 362 | font-size: 24px; 363 | margin-top: 20px; 364 | } 365 | 366 | #settingsModal .extensionContentWrapper { 367 | padding: 25px 75px; 368 | 369 | } 370 | 371 | #settingsModal .extensionContentWrapper label { 372 | font-size: 20px; 373 | display: block; 374 | } 375 | 376 | #settingsModal .extensionContentWrapper label div { 377 | display: inline-block; 378 | } 379 | 380 | #settingsModal .extensionContentWrapper label:not(:first-child) { 381 | margin-top:20px; 382 | } 383 | 384 | #settingsModal .extensionContentWrapper label input { 385 | margin-left: 20px; 386 | } -------------------------------------------------------------------------------- /app/src/Extensions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { CurrentWeather, CurrentWeatherProperties, WeatherForecast, WeatherForecastProperties, WeatherForecastItemProperties } from './Weather' 3 | import { Clock, ClockProperties } from './Clock' 4 | import { Photos, PhotosProperties } from './Photos' 5 | import { Verse, VerseProperties } from './Verse' 6 | import { server } from './shared' 7 | import { Sync } from './Sync' 8 | 9 | // Extensions drives each extension within the application 10 | // It has two responsibilities: to maintain each extension's state, and to implement settings/results from the server. 11 | // By letting Extensions maintain/update each extension individually, we can drive the entire app from one timer. 12 | // This allows us to sync up updates, and also control the flow of requests better. 13 | // Extensions doesn't need to worry about Settings; those are passed into it's props. 14 | export class Extensions extends React.Component { 15 | componentDidMount() { 16 | this.frameSetup() 17 | } 18 | 19 | // frameSetup is called if either... 20 | // 1) the component is mounted for the initial time 21 | // 2) settings were updated 22 | frameSetup() { 23 | // Each second, each extension can be updated. 24 | // This global timer drives all events that happen, whether ever second, hour, or day. 25 | // There are two options that can be used to update: 26 | // 1) Use some "lastUpdated" value within an extension's properties. 27 | // This could be a time checked against the current time. 28 | // If a sufficient amount of time has passed, then update the extension accordingly. 29 | // 2) Update the extension at a given time. 30 | // This could be each second update, on the minute, at midnight, etc. 31 | // Either option is equally viable, and really depends on the extension that is being used. 32 | // State should only be set once at the end of this intereval block. 33 | // If properties are being update, set the new properties, otherwise use the current ones 34 | this.interval = setInterval(() => { 35 | let currentTime = new Date() 36 | 37 | // Clock: Every second 38 | let clockProps = new ClockProperties(this.props.settings.clock.isEnabled, currentTime) 39 | 40 | // Photos: Should use existing properties, but increment the timer. Every 6th second, the manager get a new photo. 41 | let photosProps = this.state.Photos 42 | photosProps.tick = (this.state.Photos.tick + 1) % 6 43 | 44 | // Run on the minute (i.e., seconds === 0) 45 | if (currentTime.getSeconds() === 0) { 46 | let fullForecastForWeather = false 47 | 48 | // Run on the hour (i.e., minutes === 0) 49 | if (currentTime.getMinutes() === 0) { 50 | fullForecastForWeather = true 51 | this.getVerse() 52 | 53 | // Run at midnight (i.e., hours ===0) 54 | if (currentTime.getHours() === 0) { 55 | this.getImages() 56 | } 57 | } 58 | this.getWeather(fullForecastForWeather) 59 | } 60 | 61 | // Update renderings 62 | this.setState({ 63 | Clock: clockProps, 64 | Photos: photosProps 65 | }) 66 | }, 1000) 67 | 68 | this.getWeather(true) 69 | this.getVerse() 70 | 71 | // Images are not retrieved on startup since they are cached 72 | } 73 | 74 | componentDidUpdate(oldProps) { 75 | // If the properties get updated, we have recieved an update in settings 76 | // If settings are updated, we want to do a full re-render based on our new settings. 77 | if(oldProps.settings !== this.props.settings) { 78 | clearInterval(this.interval); 79 | this.setDefaults() 80 | this.frameSetup() 81 | } 82 | } 83 | 84 | render() { 85 | let currentWeather = new CurrentWeather(this.state.CurrentWeather) 86 | let weatherForecast = new WeatherForecast(this.state.WeatherForecast) 87 | let photos = new Photos(this.state.Photos) 88 | let verse = new Verse(this.state.Verse) 89 | let clock = new Clock(this.state.Clock) 90 | 91 | return ( 92 |
93 |
94 | {clock.render()} 95 | {currentWeather.render()} 96 |
97 | {verse.render()} 98 | {photos.render()} 99 | {weatherForecast.render()} 100 | 101 |
102 | ); 103 | } 104 | 105 | 106 | componentWillUnmount() { 107 | clearInterval(this.interval); 108 | } 109 | 110 | // setDefaults sets each expected property in the state to default (empty) values 111 | // It is intended that these values are changed after fetching data from the server 112 | setDefaults() { 113 | let defaultCurrentWeatherProps = new CurrentWeatherProperties(this.props.settings.weather.isEnabled, null) 114 | let defaultForecastWeatherProps = new WeatherForecastProperties(this.props.settings.weather.isEnabled, null) 115 | let defaultClockProps = new ClockProperties(this.props.settings.clock.isEnabled, new Date()) 116 | let photosProps = new PhotosProperties(this.props.settings.photos.isEnabled) 117 | let verseProps = new VerseProperties(this.props.settings.verse.isEnabled) 118 | this.currentPhoto = 0 119 | this.currentAlbum = 0 120 | this.setState({ 121 | CurrentWeather: defaultCurrentWeatherProps, 122 | WeatherForecast: defaultForecastWeatherProps, 123 | Clock: defaultClockProps, 124 | Photos: photosProps, 125 | Verse: verseProps, 126 | }) 127 | } 128 | 129 | // syncCallback is passed into sync and is called when a user presses the button. 130 | // When called, all extensions will recieve a "Hard Reload". Some may be programmed to only update under this circumstance, such as Photos 131 | syncCallback = () => { 132 | this.setState({ 133 | syncHidden: true 134 | }) 135 | this.setDefaults() 136 | this.getWeather(true) 137 | this.getVerse() 138 | this.getImages() 139 | setTimeout(() => { 140 | this.setState({ 141 | syncHidden: false 142 | }) 143 | }, 5000); 144 | } 145 | 146 | constructor(props) { 147 | super(props) 148 | 149 | let defaultCurrentWeatherProps = new CurrentWeatherProperties(this.props.settings.weather.isEnabled, null) 150 | let defaultForecastWeatherProps = new WeatherForecastProperties(this.props.settings.weather.isEnabled, null) 151 | let defaultClockProps = new ClockProperties(this.props.settings.clock.isEnabled, new Date()) 152 | let photosProps = new PhotosProperties(this.props.settings.photos.isEnabled, null, this.props.settings.photos.albumSet, 1) 153 | let verseProps = new VerseProperties(this.props.settings.verse.isEnabled) 154 | this.currentPhoto = 0 155 | this.currentAlbum = 0 156 | 157 | this.state = { 158 | CurrentWeather: defaultCurrentWeatherProps, 159 | WeatherForecast: defaultForecastWeatherProps, 160 | Clock: defaultClockProps, 161 | Photos: photosProps, 162 | Verse: verseProps, 163 | } 164 | } 165 | 166 | // getImages adds photos from flickr to local storage, and returns the albumSet 167 | // This is done by calling the images REST endpoint in /server/run.py 168 | getImages() { 169 | fetch(server + "images") 170 | .then(res => res.json()) 171 | .then( 172 | (result) => { 173 | if (result.isNotEnabled) { 174 | let photosProps = new PhotosProperties(false) 175 | this.setState({ 176 | Photos: photosProps 177 | }) 178 | return 179 | } 180 | 181 | if (result.error != null) { 182 | this.setState({ 183 | Photos: new PhotosProperties(true, result.error) 184 | }) 185 | return 186 | } 187 | 188 | let images = JSON.parse(result) 189 | let photosProps = new PhotosProperties(this.props.settings.photos.isEnabled, null, images, 1) 190 | this.setState({ 191 | Photos: photosProps 192 | }) 193 | }, 194 | (error) => { 195 | console.log(error) 196 | this.setState({ 197 | Photos: new PhotosProperties(true, "UNKNOWN") 198 | }) 199 | return 200 | } 201 | ) 202 | } 203 | 204 | // getWeather retrieves the weather data for current weather, and optionally, forecast 205 | // This is done by calling the weather REST endpoint in /server/run.py 206 | // @param includeForecast {bool} retrieve forecast in returned data (if false, should ignore any potential data in forecast fields) 207 | getWeather(includeForecast) { 208 | fetch(server + "weather/" + (includeForecast ? "1" : "0")) 209 | .then(res => res.json()) 210 | .then( 211 | (result) => { 212 | // If not enabled, set state to default weather 213 | if (result.isNotEnabled) { 214 | this.setState({ 215 | CurrentWeather: new CurrentWeatherProperties(this.props.settings.weather.isEnabled), 216 | WeatherForecast: new WeatherForecastProperties(this.props.settings.weather.isEnabled) 217 | }); 218 | return 219 | } 220 | 221 | if (result.error) { 222 | this.setState({ 223 | CurrentWeather: new CurrentWeatherProperties(true, result.error), 224 | WeatherForecast: new WeatherForecastProperties(true, result.error) 225 | }); 226 | return 227 | } 228 | 229 | let currentWeather = new CurrentWeatherProperties( 230 | this.props.settings.weather.isEnabled, 231 | null, 232 | result.location, 233 | result.sunrise, 234 | result.sunset, 235 | result.currentResponse.temperature, 236 | result.currentResponse.humidity, 237 | result.currentResponse.iconURL, 238 | ) 239 | let forecastWeather = this.state.WeatherForecast 240 | if (includeForecast) { 241 | let forecasts = [] 242 | result.todaysForecast.forEach((data) => { 243 | forecasts.push(new WeatherForecastItemProperties(data.temperature, data.time, data.iconURL)) 244 | }) 245 | forecastWeather = new WeatherForecastProperties(this.props.settings.weather.isEnabled, null, forecasts) 246 | } 247 | this.setState({ 248 | CurrentWeather: currentWeather, 249 | WeatherForecast: forecastWeather 250 | }); 251 | }, 252 | (error) => { 253 | console.log(error) 254 | this.setState({ 255 | CurrentWeather: new CurrentWeatherProperties(true, "UNKNOWN"), 256 | WeatherForecast: new WeatherForecastProperties(true, "UNKNOWN") 257 | }); 258 | return 259 | } 260 | ) 261 | } 262 | 263 | // getVerse retrieves the Bible verse of the day 264 | // This is done by calling the verse REST endpoint in /server/run.py 265 | getVerse() { 266 | fetch(server + "verse") 267 | .then(res => res.json()) 268 | .then( 269 | (result) => { 270 | // If not enabled, set state to default verse 271 | if (result.isNotEnabled) { 272 | this.setState({ 273 | Verse: new VerseProperties(this.props.settings.verse.isEnabled), 274 | }); 275 | return 276 | } 277 | 278 | let verse = new VerseProperties( 279 | this.props.settings.verse.isEnabled, 280 | result.quote, 281 | result.reference 282 | ) 283 | this.setState({ Verse: verse }) 284 | }, 285 | (error) => { 286 | console.log(error) 287 | } 288 | ) 289 | } 290 | } -------------------------------------------------------------------------------- /app/src/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {server} from './shared' 3 | const images = require.context('../public/img/icon', true); 4 | 5 | // ClockSettings contains all settings for the Clock extension 6 | // It exists as a member of the SettingsProperties class 7 | export class ClockSettings { 8 | // @param isEnabled {bool} enabled status of the extension 9 | constructor (isEnabled) { 10 | this.isEnabled = isEnabled 11 | } 12 | } 13 | 14 | // VerseSettings contains all settings for the Verse extension 15 | // It exists as a member of the SettingsProperties class 16 | export class VerseSettings { 17 | // @param isEnabled {bool} enabled status of the extension 18 | constructor (isEnabled) { 19 | this.isEnabled = isEnabled 20 | } 21 | } 22 | 23 | // WeatherSettings contains all settings for the Weather extension 24 | // It exists as a member of the SettingsProperties class 25 | export class WeatherSettings { 26 | // @param isEnabled {bool} enabled status of the extension 27 | // @param zip {string} the zipcode to pull weather for 28 | // @param apiKey {string} the key to access weather map API for the user 29 | constructor (isEnabled, zip, apiKey) { 30 | this.isEnabled = isEnabled 31 | this.zip = zip 32 | this.apiKey = apiKey 33 | } 34 | } 35 | 36 | // PhotosSettings contains all settings for the Photos extension 37 | // It exists as a member of the SettingsProperties class 38 | export class PhotosSettings { 39 | // @param isEnabled {bool} enabled status of the extension 40 | // @param albumList {Object} the current AlbumSet as far as settings knows (used for enabled statuses) 41 | // @param apiKey {string} the key to access flickr API for the user 42 | // @param apiSecret {string} the secret key to access flickr API for the user 43 | // @param apiUser {string} the account id of the user matching th egiven API key 44 | // @param albumSet {Object} the current list of albums as far as settings knows (used for enabled statuses) 45 | constructor (isEnabled, apiKey, apiSecret, apiUser, albumSet) { 46 | this.isEnabled = isEnabled 47 | this.apiKey = apiKey 48 | this.apiSecret = apiSecret 49 | this.apiUser = apiUser 50 | this.albumSet = albumSet 51 | } 52 | } 53 | 54 | // SettingsProperties is a wrapper that contains settings for all extensions 55 | // Any extension should exist as a property within SettingsProperties 56 | export class SettingsProperties { 57 | // @param clockSettings {ClockSettings} active settings for Clock extension 58 | // @param photosSettings {PhotosSettings} active settings for Photos extension 59 | // @param verseSettings {VerseSettings} active settings for Verse extension 60 | // @param weatherSettings {WeatherSettings} active settings for Weather extension 61 | constructor (clockSettings, photosSettings, verseSettings, weatherSettings) { 62 | this.clock = clockSettings 63 | this.photos = photosSettings 64 | this.verse = verseSettings 65 | this.weather = weatherSettings 66 | } 67 | } 68 | 69 | // ModalProperties serves to transfer properties to be used in populating the SettingsModal 70 | // Should be instantiated from the Settings level and passed into any instance of SettingsModal. 71 | class ModalProperties { 72 | constructor(settingsProperties, isOpen, closeCallback, updateCallback) { 73 | this.settingsProperties = settingsProperties 74 | this.isOpen = isOpen 75 | this.closeCallback = closeCallback 76 | this.updateCallback = updateCallback 77 | } 78 | } 79 | 80 | // Settings serves as a wrapper for the SettingsModal with a button to toggle display 81 | export class Settings extends React.Component { 82 | 83 | // getModalProps uses the current properties/state on the object in order to create properties for an instance of SettingsModal 84 | // @return {ModalProperties} the properties to be used on any active SettingsModal 85 | getModalProps() { 86 | return new ModalProperties(this.props.settings, this.state.isOpen, this.closeModal, this.props.updateCallback) 87 | } 88 | 89 | constructor(props) { 90 | super(props) 91 | 92 | this.state = { 93 | isOpen: props.isOpen 94 | } 95 | } 96 | 97 | render() { 98 | let modal = new SettingsModal(this.getModalProps()) 99 | return( 100 |
101 |
102 | 103 |
Settings
104 |
105 | {modal.render()} 106 |
107 | ); 108 | } 109 | 110 | // openModal changes renders the SettingsModal by updating state 111 | openModal = () => { 112 | this.setState({ 113 | isOpen: true 114 | }); 115 | } 116 | 117 | // closeModal is passed into the SettingsModal to update the Settings state to close 118 | closeModal = () => { 119 | this.setState({ 120 | isOpen: false 121 | }) 122 | } 123 | } 124 | 125 | // SettingsModal contains all UI for the user to interact with their current settings 126 | class SettingsModal extends React.Component { 127 | constructor(props) { 128 | super(props) 129 | 130 | this.state = { 131 | isProcessing: false 132 | } 133 | } 134 | 135 | render() { 136 | if (!this.props.isOpen) { 137 | return null 138 | } 139 | 140 | return( 141 |
142 | 143 |
144 | {/* Clock Settings */} 145 |
146 |
Clock Settings
147 |
148 | 152 |
153 |
154 | 155 | {/* Verse Settings */} 156 |
157 |
Verse Settings
158 |
159 | 163 |
164 |
165 | 166 | {/* Weather Settings */} 167 |
168 |
Weather Settings
169 |
170 | 174 | 178 | 182 |
183 |
184 | 185 | {/* Photos Settings */} 186 |
187 |
Photos Settings
188 |
189 | 193 | 197 | 201 | 205 |
206 | Displayed Albums 207 |
208 | {this.getAlbumSettingsComponents()} 209 | 210 |
211 |
212 | {/* Any future extension should be added here, mimicking the format from above */} 213 |
214 | 215 | 216 |
217 |
218 | 219 |
220 |
Cancel
221 |
222 |
223 |
Save
224 |
225 |
226 |
227 |
Processing...
228 |
229 |
230 | 231 |
232 | ); 233 | } 234 | 235 | // getAlbumSettingsComponents is called to create components for each album 236 | // @return {Component} a toggleable setting reflecting the enabled status of an album 237 | getAlbumSettingsComponents() { 238 | let components = [] 239 | this.props.settingsProperties.photos.albumSet.albums.forEach(album => { 240 | components.push( 241 | 245 | ) 246 | }); 247 | return components 248 | } 249 | 250 | // saveSettings is the function called when the save button is pressed. 251 | // It is responsible for converting the status of each component into a JSON payload for the server to process. 252 | // Once the server processes the new settings, this settings object is passed back to the Frame component via callback function 253 | // The save event ultimately leads to a reload of each extension. 254 | saveSettings = () => { 255 | // Albums can be set to enabled or disabled, but are not added 256 | // So iterate over what we have currently, and set according to the user-inputted settings 257 | let newAlbumSet = this.props.settingsProperties.photos.albumSet 258 | newAlbumSet.albums.forEach(album => { 259 | let albumEnabledElement = this["album_" + album.id] 260 | if (albumEnabledElement !== undefined) { 261 | album.isEnabled = albumEnabledElement.checked 262 | } 263 | }); 264 | 265 | // Any future settings should be added to this JSON payload 266 | // the JSON class properties should match that found in /server/extensions.py and the settings.json file 267 | let settings = { 268 | Clock: { 269 | isEnabled: this.clockIsEnabled.checked 270 | }, 271 | Verse: { 272 | isEnabled: this.verseIsEnabled.checked 273 | }, 274 | Weather: { 275 | isEnabled: this.weatherIsEnabled.checked, 276 | zip: this.weatherZip.value, 277 | apiKey: this.weatherAPIKey.value 278 | }, 279 | Photos: { 280 | isEnabled: this.photosIsEnabled.checked, 281 | apiKey: this.photosAPIKey.value, 282 | apiSecret: this.photosAPISecret.value, 283 | apiUser: this.photosAPIUser.value, 284 | albumSet: newAlbumSet 285 | } 286 | } 287 | 288 | fetch(server + 'settings/', { 289 | method: 'POST', 290 | headers: { 291 | 'Accept': 'application/json', 292 | 'Content-Type': 'application/json', 293 | }, 294 | body: JSON.stringify(settings) 295 | }) 296 | .then(res => res.json()) 297 | .then( 298 | (result) => { 299 | this.setState({ 300 | isProcessing: false 301 | }) 302 | this.props.updateCallback(result) 303 | this.props.closeCallback() 304 | }, 305 | (error) => { 306 | console.log(error) 307 | this.setState({ 308 | isProcessing: false 309 | }) 310 | } 311 | ) 312 | } 313 | } --------------------------------------------------------------------------------