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 |
)
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 | 
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 | 
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.
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 |
51 |
{getDisplayTime(this.props.sunrise, false)}
52 |
53 |
54 |
55 |
{getDisplayTime(this.props.sunset, false)}
56 |
57 |
58 |
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 |
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.
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 | }
--------------------------------------------------------------------------------