diff --git a/.gitignore b/.gitignore
index 6b5f357..a6bdd65 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
.vscode/
config.py
+config.json
*.pyc
*.swp
diff --git a/.travis.yml b/.travis.yml
index ee5e61d..1cc6047 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -5,6 +5,5 @@ install:
- pip install -r requirements.txt
- pip install pylint
script:
- - pylint config.py.sample
- pylint weather.py
diff --git a/PiWeatherRockConfig.service b/PiWeatherRockConfig.service
new file mode 100644
index 0000000..a6a5a4b
--- /dev/null
+++ b/PiWeatherRockConfig.service
@@ -0,0 +1,17 @@
+# this file is managed by Puppet
+[Unit]
+Description=PiWeatherRock Config Service
+After=network.target
+
+[Service]
+Type=simple
+User=pi
+Group=pi
+WorkingDirectory=/home/pi/PiWeatherRock
+Environment='DISPLAY=:0'
+ExecStart=/usr/bin/python3 /home/pi/PiWeatherRock/config_page.py
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/config.json-sample b/config.json-sample
new file mode 100644
index 0000000..f6edf3b
--- /dev/null
+++ b/config.json-sample
@@ -0,0 +1,23 @@
+{
+ "version": "0.0.13",
+ "ds_api_key": "API_KEY_HERE",
+ "lat": 0.112358,
+ "lon": 0.246810,
+ "units": "us",
+ "lang": "en",
+ "fullscreen": true,
+ "icon_offset": -23.5,
+ "update_freq": 300,
+ "info_pause": 300,
+ "info_delay": 900,
+ "plugins": {
+ "daily": {
+ "enabled": true,
+ "pause": 60
+ },
+ "hourly": {
+ "enabled": true,
+ "pause": 60
+ }
+ }
+}
diff --git a/config.py.sample b/config.py.sample
deleted file mode 100644
index f075cd2..0000000
--- a/config.py.sample
+++ /dev/null
@@ -1,47 +0,0 @@
-# -*- coding: utf-8 -*-
-# Settings used by weather.py
-""" Constants used in weather.py """
-# This is your Dark Sky API key
-DS_API_KEY = 'yourkeyhere'
-
-# Seconds between API calls.
-# Note: the free Dark Sky access only allows for 1,000 calls / day
-# aka a check ~ every 1.44 minutes
-DS_CHECK_INTERVAL = 300 # 5 minutes
-
-# The location you want to check
-# 33.7490° N, 84.3880° W == Atlanta, GA
-LAT = 33.7490
-LON = -84.3880
-
-# Set units based on the `units` section of
-# https://darksky.net/dev/docs
-# Valid values are: 'ca', 'si', uk2', and 'us'
-UNITS = 'us'
-
-# Set the language the information from Dark Sky's API
-# is returned in. Units are not changed by this; they rely
-# on the setting above named UNITS. See the `lang` section of
-# https://darksky.net/dev/docs
-LANG = 'en'
-
-# Full screen is for when this is running on a TV or
-# similar device with a much higher resolution.
-FULLSCREEN = True
-
-# If the weather icons are overlapping the text try adjusting
-# this value. Negative values raise the icon.
-LARGE_ICON_OFFSET = -23.5
-
-# Number of seconds to pause on the Daily weather screen.
-DAILY_PAUSE = 60 # 1 minute
-
-# Number of seconds to pause on the Hourly weather screen.
-HOURLY_PAUSE = 60 # 1 minute
-
-# Number of seconds to pause on the Info screen.
-INFO_SCREEN_PAUSE = 300 # 5 minutes
-
-# Number of seconds between showing the info screen.
-INFO_SCREEN_DELAY = 900 # 15 minutes
-
diff --git a/config_page.py b/config_page.py
new file mode 100644
index 0000000..129382a
--- /dev/null
+++ b/config_page.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# BEGIN LICENSE
+
+# Permission is hereby granted, free of charge, to any person
+# obtaining a copy of this software and associated documentation
+# files (the "Software"), to deal in the Software without
+# restriction, including without limitation the rights to use,
+# copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the
+# Software is furnished to do so, subject to the following
+# conditions:
+#
+# The above copyright notice and this permission notice shall be
+# included in all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+# OTHER DEALINGS IN THE SOFTWARE.
+# END LICENSE
+
+""" Configuration page for PiWeatherRock. """
+
+__version__ = "0.0.13"
+
+###############################################################################
+# Raspberry Pi Weather Display Config Page Plugin
+# Original By: github user: metaMMA 2020-03-15
+###############################################################################
+
+# standard imports
+import os
+import json
+
+# third-party imports
+import cherrypy
+
+with open(os.path.join(os.getcwd(), 'html/config.html'), 'r') as f:
+ html = f.read()
+
+
+class Config:
+
+ @cherrypy.expose()
+ def index(self):
+ return html
+
+ @cherrypy.tools.json_in()
+ @cherrypy.expose
+ def upload(self):
+ dst = f"{os.getcwd()}/config.json"
+
+ input_json = cherrypy.request.json
+ with open(dst, 'w') as f:
+ json.dump(input_json, f, indent=2, separators=(',', ': '))
+ self.index()
+
+
+if __name__ == '__main__':
+ cherrypy.quickstart(Config(), config={
+ 'global': {
+ 'server.socket_port': 8888,
+ 'server.socket_host': '0.0.0.0'
+ },
+ '/serialize_script.js': {
+ 'tools.staticfile.on': True,
+ 'tools.staticfile.filename': os.path.join(
+ os.getcwd(), "html/serialize_script.js")
+ },
+ '/style.css': {
+ 'tools.staticfile.on': True,
+ 'tools.staticfile.filename': os.path.join(
+ os.getcwd(), "html/style.css")
+ },
+ '/chancetstorms.png': {
+ 'tools.staticfile.on': True,
+ 'tools.staticfile.filename': os.path.join(
+ os.getcwd(), "icons/256/chancetstorms.png")
+ },
+ '/bg.png': {
+ 'tools.staticfile.on': True,
+ 'tools.staticfile.filename': os.path.join(
+ os.getcwd(), "icons/bg.png")
+ },
+ '/config.json': {
+ 'tools.staticfile.on': True,
+ 'tools.staticfile.filename': os.path.join(
+ os.getcwd(), "config.json")
+ }
+ })
diff --git a/html/config.html b/html/config.html
new file mode 100644
index 0000000..cf2ec39
--- /dev/null
+++ b/html/config.html
@@ -0,0 +1,274 @@
+
+
+
+
+
+
+
+
+ PiWeatherRock Configuration
+
+
+
+
+
+
+
+
PiWeatherRock Configuration Page
+
+
+
+
Basic
+
+
Advanced
+
+
+
+
+
+
+
diff --git a/html/serialize_script.js b/html/serialize_script.js
new file mode 100644
index 0000000..d886930
--- /dev/null
+++ b/html/serialize_script.js
@@ -0,0 +1,345 @@
+/*!
+ SerializeJSON jQuery plugin.
+ https://github.com/marioizquierdo/jquery.serializeJSON
+ version 2.9.0 (Jan, 2018)
+
+ Copyright (c) 2012-2018 Mario Izquierdo
+ Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
+ and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
+*/
+(function (factory) {
+ if (typeof define === 'function' && define.amd) { // AMD. Register as an anonymous module.
+ define(['jquery'], factory);
+ } else if (typeof exports === 'object') { // Node/CommonJS
+ var jQuery = require('jquery');
+ module.exports = factory(jQuery);
+ } else { // Browser globals (zepto supported)
+ factory(window.jQuery || window.Zepto || window.$); // Zepto supported on browsers as well
+ }
+
+}(function ($) {
+ "use strict";
+
+ // jQuery('form').serializeJSON()
+ $.fn.serializeJSON = function (options) {
+ var f, $form, opts, formAsArray, serializedObject, name, value, parsedValue, _obj, nameWithNoType, type, keys, skipFalsy;
+ f = $.serializeJSON;
+ $form = this; // NOTE: the set of matched elements is most likely a form, but it could also be a group of inputs
+ opts = f.setupOpts(options); // calculate values for options {parseNumbers, parseBoolens, parseNulls, ...} with defaults
+
+ // Use native `serializeArray` function to get an array of {name, value} objects.
+ formAsArray = $form.serializeArray();
+ f.readCheckboxUncheckedValues(formAsArray, opts, $form); // add objects to the array from unchecked checkboxes if needed
+
+ // Convert the formAsArray into a serializedObject with nested keys
+ serializedObject = {};
+ $.each(formAsArray, function (i, obj) {
+ name = obj.name; // original input name
+ value = obj.value; // input value
+ _obj = f.extractTypeAndNameWithNoType(name);
+ nameWithNoType = _obj.nameWithNoType; // input name with no type (i.e. "foo:string" => "foo")
+ type = _obj.type; // type defined from the input name in :type colon notation
+ if (!type) type = f.attrFromInputWithName($form, name, 'data-value-type');
+ f.validateType(name, type, opts); // make sure that the type is one of the valid types if defined
+
+ if (type !== 'skip') { // ignore inputs with type 'skip'
+ keys = f.splitInputNameIntoKeysArray(nameWithNoType);
+ parsedValue = f.parseValue(value, name, type, opts); // convert to string, number, boolean, null or customType
+
+ skipFalsy = !parsedValue && f.shouldSkipFalsy($form, name, nameWithNoType, type, opts); // ignore falsy inputs if specified
+ if (!skipFalsy) {
+ f.deepSet(serializedObject, keys, parsedValue, opts);
+ }
+ }
+ });
+ return serializedObject;
+ };
+
+ // Use $.serializeJSON as namespace for the auxiliar functions
+ // and to define defaults
+ $.serializeJSON = {
+
+ defaultOptions: {
+ checkboxUncheckedValue: undefined, // to include that value for unchecked checkboxes (instead of ignoring them)
+
+ parseNumbers: false, // convert values like "1", "-2.33" to 1, -2.33
+ parseBooleans: false, // convert "true", "false" to true, false
+ parseNulls: false, // convert "null" to null
+ parseAll: false, // all of the above
+ parseWithFunction: null, // to use custom parser, a function like: function(val){ return parsed_val; }
+
+ skipFalsyValuesForTypes: [], // skip serialization of falsy values for listed value types
+ skipFalsyValuesForFields: [], // skip serialization of falsy values for listed field names
+
+ customTypes: {}, // override defaultTypes
+ defaultTypes: {
+ "string": function(str) { return String(str); },
+ "number": function(str) { return Number(str); },
+ "boolean": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1; },
+ "null": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1 ? str : null; },
+ "array": function(str) { return JSON.parse(str); },
+ "object": function(str) { return JSON.parse(str); },
+ "auto": function(str) { return $.serializeJSON.parseValue(str, null, null, {parseNumbers: true, parseBooleans: true, parseNulls: true}); }, // try again with something like "parseAll"
+ "skip": null // skip is a special type that makes it easy to ignore elements
+ },
+
+ useIntKeysAsArrayIndex: false // name="foo[2]" value="v" => {foo: [null, null, "v"]}, instead of {foo: ["2": "v"]}
+ },
+
+ // Merge option defaults into the options
+ setupOpts: function(options) {
+ var opt, validOpts, defaultOptions, optWithDefault, parseAll, f;
+ f = $.serializeJSON;
+
+ if (options == null) { options = {}; } // options ||= {}
+ defaultOptions = f.defaultOptions || {}; // defaultOptions
+
+ // Make sure that the user didn't misspell an option
+ validOpts = ['checkboxUncheckedValue', 'parseNumbers', 'parseBooleans', 'parseNulls', 'parseAll', 'parseWithFunction', 'skipFalsyValuesForTypes', 'skipFalsyValuesForFields', 'customTypes', 'defaultTypes', 'useIntKeysAsArrayIndex']; // re-define because the user may override the defaultOptions
+ for (opt in options) {
+ if (validOpts.indexOf(opt) === -1) {
+ throw new Error("serializeJSON ERROR: invalid option '" + opt + "'. Please use one of " + validOpts.join(', '));
+ }
+ }
+
+ // Helper to get the default value for this option if none is specified by the user
+ optWithDefault = function(key) { return (options[key] !== false) && (options[key] !== '') && (options[key] || defaultOptions[key]); };
+
+ // Return computed options (opts to be used in the rest of the script)
+ parseAll = optWithDefault('parseAll');
+ return {
+ checkboxUncheckedValue: optWithDefault('checkboxUncheckedValue'),
+
+ parseNumbers: parseAll || optWithDefault('parseNumbers'),
+ parseBooleans: parseAll || optWithDefault('parseBooleans'),
+ parseNulls: parseAll || optWithDefault('parseNulls'),
+ parseWithFunction: optWithDefault('parseWithFunction'),
+
+ skipFalsyValuesForTypes: optWithDefault('skipFalsyValuesForTypes'),
+ skipFalsyValuesForFields: optWithDefault('skipFalsyValuesForFields'),
+ typeFunctions: $.extend({}, optWithDefault('defaultTypes'), optWithDefault('customTypes')),
+
+ useIntKeysAsArrayIndex: optWithDefault('useIntKeysAsArrayIndex')
+ };
+ },
+
+ // Given a string, apply the type or the relevant "parse" options, to return the parsed value
+ parseValue: function(valStr, inputName, type, opts) {
+ var f, parsedVal;
+ f = $.serializeJSON;
+ parsedVal = valStr; // if no parsing is needed, the returned value will be the same
+
+ if (opts.typeFunctions && type && opts.typeFunctions[type]) { // use a type if available
+ parsedVal = opts.typeFunctions[type](valStr);
+ } else if (opts.parseNumbers && f.isNumeric(valStr)) { // auto: number
+ parsedVal = Number(valStr);
+ } else if (opts.parseBooleans && (valStr === "true" || valStr === "false")) { // auto: boolean
+ // the above 'false' and above/below 'true' were changed from string to boolean by metaMMA 2020-03-25
+ parsedVal = (valStr === "true");
+ } else if (opts.parseNulls && valStr == "null") { // auto: null
+ parsedVal = null;
+ } else if (opts.typeFunctions && opts.typeFunctions["string"]) { // make sure to apply :string type if it was re-defined
+ parsedVal = opts.typeFunctions["string"](valStr);
+ }
+
+ // Custom parse function: apply after parsing options, unless there's an explicit type.
+ if (opts.parseWithFunction && !type) {
+ parsedVal = opts.parseWithFunction(parsedVal, inputName);
+ }
+
+ return parsedVal;
+ },
+
+ isObject: function(obj) { return obj === Object(obj); }, // is it an Object?
+ isUndefined: function(obj) { return obj === void 0; }, // safe check for undefined values
+ isValidArrayIndex: function(val) { return /^[0-9]+$/.test(String(val)); }, // 1,2,3,4 ... are valid array indexes
+ isNumeric: function(obj) { return obj - parseFloat(obj) >= 0; }, // taken from jQuery.isNumeric implementation. Not using jQuery.isNumeric to support old jQuery and Zepto versions
+
+ optionKeys: function(obj) { if (Object.keys) { return Object.keys(obj); } else { var key, keys = []; for(key in obj){ keys.push(key); } return keys;} }, // polyfill Object.keys to get option keys in IE<9
+
+
+ // Fill the formAsArray object with values for the unchecked checkbox inputs,
+ // using the same format as the jquery.serializeArray function.
+ // The value of the unchecked values is determined from the opts.checkboxUncheckedValue
+ // and/or the data-unchecked-value attribute of the inputs.
+ readCheckboxUncheckedValues: function (formAsArray, opts, $form) {
+ var selector, $uncheckedCheckboxes, $el, uncheckedValue, f, name;
+ if (opts == null) { opts = {}; }
+ f = $.serializeJSON;
+
+ selector = 'input[type=checkbox][name]:not(:checked):not([disabled])';
+ $uncheckedCheckboxes = $form.find(selector).add($form.filter(selector));
+ $uncheckedCheckboxes.each(function (i, el) {
+ // Check data attr first, then the option
+ $el = $(el);
+ uncheckedValue = $el.attr('data-unchecked-value');
+ if (uncheckedValue == null) {
+ uncheckedValue = opts.checkboxUncheckedValue;
+ }
+
+ // If there's an uncheckedValue, push it into the serialized formAsArray
+ if (uncheckedValue != null) {
+ if (el.name && el.name.indexOf("[][") !== -1) { // identify a non-supported
+ throw new Error("serializeJSON ERROR: checkbox unchecked values are not supported on nested arrays of objects like '"+el.name+"'. See https://github.com/marioizquierdo/jquery.serializeJSON/issues/67");
+ }
+ formAsArray.push({name: el.name, value: uncheckedValue});
+ }
+ });
+ },
+
+ // Returns and object with properties {name_without_type, type} from a given name.
+ // The type is null if none specified. Example:
+ // "foo" => {nameWithNoType: "foo", type: null}
+ // "foo:boolean" => {nameWithNoType: "foo", type: "boolean"}
+ // "foo[bar]:null" => {nameWithNoType: "foo[bar]", type: "null"}
+ extractTypeAndNameWithNoType: function(name) {
+ var match;
+ if (match = name.match(/(.*):([^:]+)$/)) {
+ return {nameWithNoType: match[1], type: match[2]};
+ } else {
+ return {nameWithNoType: name, type: null};
+ }
+ },
+
+
+ // Check if this input should be skipped when it has a falsy value,
+ // depending on the options to skip values by name or type, and the data-skip-falsy attribute.
+ shouldSkipFalsy: function($form, name, nameWithNoType, type, opts) {
+ var f = $.serializeJSON;
+
+ var skipFromDataAttr = f.attrFromInputWithName($form, name, 'data-skip-falsy');
+ if (skipFromDataAttr != null) {
+ return skipFromDataAttr !== 'false'; // any value is true, except if explicitly using 'false'
+ }
+
+ var optForFields = opts.skipFalsyValuesForFields;
+ if (optForFields && (optForFields.indexOf(nameWithNoType) !== -1 || optForFields.indexOf(name) !== -1)) {
+ return true;
+ }
+
+ var optForTypes = opts.skipFalsyValuesForTypes;
+ if (type == null) type = 'string'; // assume fields with no type are targeted as string
+ if (optForTypes && optForTypes.indexOf(type) !== -1) {
+ return true
+ }
+
+ return false;
+ },
+
+ // Finds the first input in $form with this name, and get the given attr from it.
+ // Returns undefined if no input or no attribute was found.
+ attrFromInputWithName: function($form, name, attrName) {
+ var escapedName, selector, $input, attrValue;
+ escapedName = name.replace(/(:|\.|\[|\]|\s)/g,'\\$1'); // every non-standard character need to be escaped by \\
+ selector = '[name="' + escapedName + '"]';
+ $input = $form.find(selector).add($form.filter(selector)); // NOTE: this returns only the first $input element if multiple are matched with the same name (i.e. an "array[]"). So, arrays with different element types specified through the data-value-type attr is not supported.
+ return $input.attr(attrName);
+ },
+
+ // Raise an error if the type is not recognized.
+ validateType: function(name, type, opts) {
+ var validTypes, f;
+ f = $.serializeJSON;
+ validTypes = f.optionKeys(opts ? opts.typeFunctions : f.defaultOptions.defaultTypes);
+ if (!type || validTypes.indexOf(type) !== -1) {
+ return true;
+ } else {
+ throw new Error("serializeJSON ERROR: Invalid type " + type + " found in input name '" + name + "', please use one of " + validTypes.join(', '));
+ }
+ },
+
+
+ // Split the input name in programatically readable keys.
+ // Examples:
+ // "foo" => ['foo']
+ // "[foo]" => ['foo']
+ // "foo[inn][bar]" => ['foo', 'inn', 'bar']
+ // "foo[inn[bar]]" => ['foo', 'inn', 'bar']
+ // "foo[inn][arr][0]" => ['foo', 'inn', 'arr', '0']
+ // "arr[][val]" => ['arr', '', 'val']
+ splitInputNameIntoKeysArray: function(nameWithNoType) {
+ var keys, f;
+ f = $.serializeJSON;
+ keys = nameWithNoType.split('['); // split string into array
+ keys = $.map(keys, function (key) { return key.replace(/\]/g, ''); }); // remove closing brackets
+ if (keys[0] === '') { keys.shift(); } // ensure no opening bracket ("[foo][inn]" should be same as "foo[inn]")
+ return keys;
+ },
+
+ // Set a value in an object or array, using multiple keys to set in a nested object or array:
+ //
+ // deepSet(obj, ['foo'], v) // obj['foo'] = v
+ // deepSet(obj, ['foo', 'inn'], v) // obj['foo']['inn'] = v // Create the inner obj['foo'] object, if needed
+ // deepSet(obj, ['foo', 'inn', '123'], v) // obj['foo']['arr']['123'] = v //
+ //
+ // deepSet(obj, ['0'], v) // obj['0'] = v
+ // deepSet(arr, ['0'], v, {useIntKeysAsArrayIndex: true}) // arr[0] = v
+ // deepSet(arr, [''], v) // arr.push(v)
+ // deepSet(obj, ['arr', ''], v) // obj['arr'].push(v)
+ //
+ // arr = [];
+ // deepSet(arr, ['', v] // arr => [v]
+ // deepSet(arr, ['', 'foo'], v) // arr => [v, {foo: v}]
+ // deepSet(arr, ['', 'bar'], v) // arr => [v, {foo: v, bar: v}]
+ // deepSet(arr, ['', 'bar'], v) // arr => [v, {foo: v, bar: v}, {bar: v}]
+ //
+ deepSet: function (o, keys, value, opts) {
+ var key, nextKey, tail, lastIdx, lastVal, f;
+ if (opts == null) { opts = {}; }
+ f = $.serializeJSON;
+ if (f.isUndefined(o)) { throw new Error("ArgumentError: param 'o' expected to be an object or array, found undefined"); }
+ if (!keys || keys.length === 0) { throw new Error("ArgumentError: param 'keys' expected to be an array with least one element"); }
+
+ key = keys[0];
+
+ // Only one key, then it's not a deepSet, just assign the value.
+ if (keys.length === 1) {
+ if (key === '') {
+ o.push(value); // '' is used to push values into the array (assume o is an array)
+ } else {
+ o[key] = value; // other keys can be used as object keys or array indexes
+ }
+
+ // With more keys is a deepSet. Apply recursively.
+ } else {
+ nextKey = keys[1];
+
+ // '' is used to push values into the array,
+ // with nextKey, set the value into the same object, in object[nextKey].
+ // Covers the case of ['', 'foo'] and ['', 'var'] to push the object {foo, var}, and the case of nested arrays.
+ if (key === '') {
+ lastIdx = o.length - 1; // asume o is array
+ lastVal = o[lastIdx];
+ if (f.isObject(lastVal) && (f.isUndefined(lastVal[nextKey]) || keys.length > 2)) { // if nextKey is not present in the last object element, or there are more keys to deep set
+ key = lastIdx; // then set the new value in the same object element
+ } else {
+ key = lastIdx + 1; // otherwise, point to set the next index in the array
+ }
+ }
+
+ // '' is used to push values into the array "array[]"
+ if (nextKey === '') {
+ if (f.isUndefined(o[key]) || !$.isArray(o[key])) {
+ o[key] = []; // define (or override) as array to push values
+ }
+ } else {
+ if (opts.useIntKeysAsArrayIndex && f.isValidArrayIndex(nextKey)) { // if 1, 2, 3 ... then use an array, where nextKey is the index
+ if (f.isUndefined(o[key]) || !$.isArray(o[key])) {
+ o[key] = []; // define (or override) as array, to insert values using int keys as array indexes
+ }
+ } else { // for anything else, use an object, where nextKey is going to be the attribute name
+ if (f.isUndefined(o[key]) || !f.isObject(o[key])) {
+ o[key] = {}; // define (or override) as object, to set nested properties
+ }
+ }
+ }
+
+ // Recursively set the inner object
+ tail = keys.slice(1);
+ f.deepSet(o[key], tail, value, opts);
+ }
+ }
+
+ };
+
+}));
\ No newline at end of file
diff --git a/html/style.css b/html/style.css
new file mode 100644
index 0000000..2777f4a
--- /dev/null
+++ b/html/style.css
@@ -0,0 +1,298 @@
+.page_title {
+ position: absolute;
+ width: 72vw;
+ left: 14vw;
+ top: 112px;
+ font-size: 3vw;
+ text-align: center;
+ color: #fff;
+ font-family: 'FreeSans', 'bold';
+ font-weight: bold;
+}
+
+#version {
+ width: 72vw;
+ left: 12.5vw;
+ font-size: 24px;
+ color: #007fff;
+ text-align: center;
+ position: absolute;
+ padding-top: 180px;
+}
+
+a:link {
+ color: white;
+ background-color: transparent;
+ text-decoration: none;
+}
+
+a:visited {
+ color: white;
+ background-color: transparent;
+ text-decoration: none;
+}
+
+#lcloud{
+ width: 256px;
+ position: relative;
+ float:left;
+ display: inline;
+}
+
+#rcloud {
+ width: 256px;
+ float:right;
+ position: relative;
+ display: inline;
+}
+
+#scroller * {
+ /* don't allow the children of the scrollable element to be selected as an anchor node */
+ overflow-anchor: none;
+}
+
+#anchor {
+ /* allow the final child to be selected as an anchor node */
+ overflow-anchor: auto;
+
+ /* anchor nodes are required to have non-zero area */
+ height: 1px;
+}
+
+.refresh {
+ display: inline-block;
+ position: relative;
+ left: 30vw;
+}
+
+.log {
+ width: 75vw;
+ position: relative;
+ left: 120px;
+ top: 180px;
+}
+
+.settings {
+ position: relative;
+ width: 50vw;
+ left: 20vw;
+ color: #007fff;
+ font-size: 32px;
+ font-weight: bold;
+ display: inline-block;
+ padding-top: 1em;
+}
+
+#toggle {
+ font-size: 24px;
+ color: #007fff;
+ text-align: center;
+ position: relative;
+ padding-top: 25vh;
+}
+
+.plugin_toggle {
+ position:relative;
+ top: -8px;
+ font-size: 24px;
+ color: #007fff;
+ display: inline-block;
+}
+
+#start_toggle {
+ color: #007fff;
+ text-align: center;
+ position: relative;
+ left: -1em;
+}
+
+.left {
+ position: relative;
+ top: 0.85em;
+ padding: 0.25em;
+ display: inline;
+ color: #C0C0C0;
+}
+
+.right {
+ position: relative;
+ top: 0.85em;
+ padding: 0.25em;
+ display: inline;
+}
+
+body {
+ font-size: 24px;
+ color: #fff;
+ background-color: #111;
+}
+.myDiv {
+ position: relative;
+ z-index: 1;
+}
+
+.myDiv .bg {
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: url('bg.png');
+ opacity: .1;
+ width: 100%;
+ height: 100%;
+ }
+
+.var {
+ position: relative;
+ padding-top: 0.5em;
+ width: 50vw;
+ padding-left: 20vw;
+ display: inline-block;
+}
+
+.submit{
+ margin-top: 2em;
+ margin-bottom: 2em;
+}
+
+.opt {
+ position: relative;
+ padding-top: 0.5em;
+ float: right;
+ width: 15vw;
+ left: -25vw;
+ margin-right: -4em;
+ display: inline-block;
+}
+
+.extra {
+ position: relative;
+ float: right;
+ width: 15vw;
+ left: 10vw;
+ display: inline-block;
+}
+
+/* The switch - the box around the slider */
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 2.75em;
+ height: 1.5em;
+}
+
+/* Hide default HTML checkbox */
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+/* The slider */
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ -webkit-transition: .4s;
+ transition: .4s;
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 1.25em;
+ width: 1.25em;
+ top: 0.125em;
+ left: 0.125em;
+ background-color: #444;
+ -webkit-transition: .4s;
+ transition: .4s;
+}
+
+input:checked + .slider {
+ background-color: #2196F3;
+}
+
+input:focus + .slider {
+ box-shadow: 0 0 1px #2196F3;
+}
+
+input:checked + .slider:before {
+ -webkit-transform: translateX(1.25em);
+ -ms-transform: translateX(1.25em);
+ transform: translateX(1.25em);
+}
+
+/* Rounded sliders */
+.slider.round {
+ border-radius: 1.25em;
+}
+
+.slider.round:before {
+ border-radius: 50%;
+}
+
+ /* Tooltip container */
+.tooltip {
+ position: relative;
+ display: inline-block;
+ border-bottom: 1px dotted black; /* If you want dots under the hoverable text */
+}
+
+/* Tooltip text */
+.tooltip .tooltiptext {
+ visibility: hidden;
+ width: 400px;
+ background-color: #555;
+ color: #fff;
+ text-align: center;
+ padding: 5px 0;
+ border-radius: 6px;
+
+ /* Position the tooltip text */
+ position: absolute;
+ z-index: 1;
+ bottom: 125%;
+ left: 50%;
+ margin-left: -60px;
+
+ /* Fade in tooltip */
+ opacity: 0;
+ transition: opacity 0.3s;
+}
+
+/* Tooltip arrow */
+.tooltip .tooltiptext::after {
+ content: "";
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ margin-left: -5px;
+ border-width: 5px;
+ border-style: solid;
+ border-color: #555 transparent transparent transparent;
+}
+
+/* Show the tooltip text when you mouse over the tooltip container */
+.tooltip:hover .tooltiptext {
+ visibility: visible;
+ opacity: 1;
+}
+.submit_div {
+ position: relative;
+ width: 50vw;
+ display: inline-block;
+}
+
+.submit {
+ display: inline-block;
+ position: relative;
+ width: 10vw;
+ left: 22vw;
+}
diff --git a/icons/bg.png b/icons/bg.png
new file mode 100644
index 0000000..afa9027
Binary files /dev/null and b/icons/bg.png differ
diff --git a/requirements.txt b/requirements.txt
index 34d01d4..8f53997 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,3 +2,4 @@ darkskylib
pygame
pyserial
requests
+cherrypy
diff --git a/scripts/upgrade.py b/scripts/upgrade.py
new file mode 100644
index 0000000..1ae3424
--- /dev/null
+++ b/scripts/upgrade.py
@@ -0,0 +1,85 @@
+import json
+import os
+import re
+from shutil import copyfile
+import socket
+
+pi_ip = socket.gethostbyname(socket.gethostname() + ".local")
+
+if os.path.exists("config.json"):
+ with open("config.json", "r") as f:
+ old_config = json.load(f)
+ with open("config.json-sample", "r") as f:
+ new_config = json.load(f)
+ old_major, old_minor, old_build = [int(x) for x in
+ old_config['version'].split(".")]
+ new_major, new_minor, new_build = [int(x) for x in
+ new_config['version'].split(".")]
+ if old_build > new_build:
+ print(f"Error: Current version number is greater than the most "
+ f"recently released version.")
+ elif new_build == old_build:
+ print("No upgrade needed. This is the most recently released version.")
+ else:
+ # This is for future upgrades.
+ # Code will run when current version of config.json is older than the
+ # most recently released version.
+ pass
+elif os.path.exists("config.py"):
+ print(f"\nImporting current configuration settings.\n\n"
+ f"Go to http://{pi_ip}:8888 to view new configuration interface.\n")
+ old_config_dict = {}
+ with open("config.py", "r") as f:
+ old_config = f.read()
+ old_config_dict["ds_api_key"] = re.findall(
+ r"(?<=DS_API_KEY = \').*?(?=')", old_config)[0]
+ old_config_dict["update_freq"] = int(re.findall(
+ r"(?<=DS_CHECK_INTERVAL = )\d+", old_config)[0])
+ old_config_dict["lat"] = float(re.findall(
+ r"(?<=LAT = )[0-9\.-]+", old_config)[0])
+ old_config_dict["lon"] = float(re.findall(
+ r"(?<=LON = )[0-9\.-]+", old_config)[0])
+ old_config_dict["units"] = re.findall(
+ r"(?<=UNITS = \').*?(?=\')", old_config)[0]
+ old_config_dict["lang"] = re.findall(
+ r"(?<=LANG = \').*?(?=\')", old_config)[0]
+ fs_test = re.findall(
+ r"(?<=FULLSCREEN = )(?:True|False)", old_config, re.IGNORECASE)[0]
+ if "t" in [ch for ch in fs_test.lower()]:
+ old_config_dict["fullscreen"] = True
+ else:
+ old_config_dict["fullscreen"] = False
+ old_config_dict["icon_offset"] = float(re.findall(
+ r"(?<=LARGE_ICON_OFFSET = )[0-9.-]+", old_config)[0])
+
+ daily_pause_list = re.findall(
+ r"(?<=DAILY_PAUSE = )[0-9.-]+", old_config)
+ hourly_pause_list = re.findall(
+ r"(?<=HOURLY_PAUSE = )[0-9.-]+", old_config)
+ info_pause_list = re.findall(
+ r"(?<=INFO_SCREEN_PAUSE = )[0-9.-]+", old_config)
+ info_delay_list = re.findall(
+ r"(?<=INFO_SCREEN_DELAY = )[0-9.-]+", old_config)
+
+ if (daily_pause_list and hourly_pause_list and
+ info_pause_list and info_delay_list):
+ old_config_dict["plugins"] = {}
+ old_config_dict["plugins"]["daily"] = {}
+ old_config_dict["plugins"]["hourly"] = {}
+ old_config_dict["plugins"]["daily"]["pause"] = int(daily_pause_list[0])
+ old_config_dict["plugins"]["daily"]["enabled"] = True
+ old_config_dict["plugins"]["hourly"]["pause"] = int(hourly_pause_list[0])
+ old_config_dict["plugins"]["hourly"]["enabled"] = True
+ old_config_dict["info_pause"] = int(info_pause_list[0])
+ old_config_dict["info_delay"] = int(info_delay_list[0])
+ with open("config.json-sample", "r") as f:
+ new_config_dict = json.load(f)
+ for key in old_config_dict.keys():
+ new_config_dict[key] = old_config_dict[key]
+ with open("config.json", "w") as f:
+ json.dump(new_config_dict, f)
+ os.remove("config.py")
+else:
+ copyfile("config.json-sample", "config.json")
+ print(f"\nYou must configure PiWeatherRock.\n\n"
+ f"Go to http://{pi_ip}:8888 to configure.\n")
diff --git a/setup.pp b/setup.pp
index fe710db..5727f7c 100644
--- a/setup.pp
+++ b/setup.pp
@@ -96,6 +96,7 @@
'pygame',
'pyserial',
'requests',
+ 'cherrypy',
]
python::pip { $python_packages:
@@ -103,6 +104,15 @@
require => Package[ $main_packages, $piweatherrock_packages, ],
}
+# Run upgrade script to import current config values to new config file
+exec { 'import config':
+ user => 'pi',
+ path => '/bin:/usr/bin',
+ command => 'python3 /home/pi/PiWeatherRock/scripts/upgrade.py',
+ unless => 'grep "0.0.13" config.json',
+ notify => Service['PiWeatherRock.service'],
+}
+
systemd::unit_file { 'PiWeatherRock.service':
source => 'file:///home/pi/PiWeatherRock/PiWeatherRock.service',
require => Python::Pip[$python_packages],
@@ -120,3 +130,21 @@
Vcsrepo['/home/pi/PiWeatherRock'],
],
}
+
+systemd::unit_file { 'PiWeatherRockConfig.service':
+ source => 'file:///home/pi/PiWeatherRock/PiWeatherRockConfig.service',
+ require => Python::Pip[$python_packages],
+ notify => Service['PiWeatherRockConfig.service'],
+}
+
+service {'PiWeatherRockConfig.service':
+ ensure => running,
+ enable => true,
+ require => Systemd::Unit_file['PiWeatherRockConfig.service'],
+ subscribe => [
+ Exec['enable display-setup-script'],
+ File['/home/pi/bin/xhost.sh'],
+ Python::Pip[$python_packages],
+ Vcsrepo['/home/pi/PiWeatherRock'],
+ ],
+}
diff --git a/weather.conf b/weather.conf
deleted file mode 100644
index 0acf33e..0000000
--- a/weather.conf
+++ /dev/null
@@ -1,15 +0,0 @@
-# My Weather App
-
-description "My Pyton / PyGame LCD Display App"
-author "James"
-
-#start on runlevel [2345]
-#stop on runlevel [016]
-
-start on startup
-stop on shutdown
-
-env DISPLAY=:0.0
-
-chdir /home/pi/Weather/
-exec sudo python /home/pi/Weather/weather.py > /tmp/weather.out 2>&1
diff --git a/weather.py b/weather.py
index 787e9dc..079e1f3 100644
--- a/weather.py
+++ b/weather.py
@@ -28,7 +28,7 @@
""" Fetches weather reports from Dark Sky for displaying on a screen. """
-__version__ = "0.0.12"
+__version__ = "0.0.13"
###############################################################################
# Raspberry Pi Weather Display
@@ -43,6 +43,7 @@
import sys
import syslog
import time
+import json
# third party imports
from darksky import forecast
@@ -50,8 +51,8 @@
# from pygame.locals import *
import requests
-# local imports
-import config
+with open("config.json", "r") as f:
+ CONFIG = json.load(f)
# globals
MOUSE_X, MOUSE_Y = 0, 0
@@ -136,11 +137,11 @@ def get_abbreviation(phrase):
return abbreviation
-def get_windspeed_abbreviation(unit=config.UNITS):
+def get_windspeed_abbreviation(unit=CONFIG["units"]):
return get_abbreviation(units_decoder(unit)['windSpeed'])
-def get_temperature_letter(unit=config.UNITS):
+def get_temperature_letter(unit=CONFIG["units"]):
return units_decoder(unit)['temperature'].split(' ')[-1][0].upper()
@@ -248,7 +249,7 @@ def __init__(self):
# for fontname in pygame.font.get_fonts():
# print(fontname)
- if config.FULLSCREEN:
+ if CONFIG["fullscreen"]:
self.xmax = pygame.display.Info().current_w - 35
self.ymax = pygame.display.Info().current_h - 5
if self.xmax <= 1024:
@@ -271,15 +272,15 @@ def __del__(self):
"Destructor to make sure pygame shuts down, etc."
def get_forecast(self):
- if (time.time() - self.last_update_check) > config.DS_CHECK_INTERVAL:
+ if (time.time() - self.last_update_check) > CONFIG["update_freq"]:
self.last_update_check = time.time()
try:
- self.weather = forecast(config.DS_API_KEY,
- config.LAT,
- config.LON,
+ self.weather = forecast(CONFIG["ds_api_key"],
+ CONFIG["lat"],
+ CONFIG["lon"],
exclude='minutely',
- units=config.UNITS,
- lang=config.LANG)
+ units=CONFIG["units"],
+ lang=CONFIG["lang"])
sunset_today = datetime.datetime.fromtimestamp(
self.weather.daily[0].sunsetTime)
@@ -428,7 +429,7 @@ def display_subwindow(self, data, day, c_times):
if icon_size_y < 90:
icon_y_offset = (90 - icon_size_y) / 2
else:
- icon_y_offset = config.LARGE_ICON_OFFSET
+ icon_y_offset = CONFIG["icon_offset"]
self.screen.blit(icon, (self.xmax *
(subwindow_centers * c_times) -
@@ -715,11 +716,11 @@ def disp_info(self, in_daylight, day_hrs, day_mins, seconds_til_daylight,
small_font = pygame.font.SysFont(
font_name, int(self.ymax * time_height_small), bold=1)
- hours_and_minites = time.strftime("%I:%M", time.localtime())
+ hours_and_minutes = time.strftime("%I:%M", time.localtime())
am_pm = time.strftime(" %p", time.localtime())
rendered_hours_and_minutes = regular_font.render(
- hours_and_minites, True, text_color)
+ hours_and_minutes, True, text_color)
(tx1, ty1) = rendered_hours_and_minutes.get_size()
rendered_am_pm = small_font.render(am_pm, True, text_color)
(tx2, ty2) = rendered_am_pm.get_size()
@@ -879,20 +880,22 @@ def daylight(weather):
D_COUNT = 0
H_COUNT = 0
# Default in config.py.sample: pause for 5 minutes on info screen.
- if NON_WEATHER_TIMEOUT > (config.INFO_SCREEN_PAUSE * 10):
+ if NON_WEATHER_TIMEOUT > (CONFIG["info_pause"] * 10):
MODE = 'd'
D_COUNT = 1
syslog.syslog("Switching to weather mode")
else:
NON_WEATHER_TIMEOUT = 0
PERIODIC_INFO_ACTIVATION += 1
- # Default in config.py.sample: flip between 2 weather screens
+ # Default is to flip between 2 weather screens
# for 15 minutes before showing info screen.
- if PERIODIC_INFO_ACTIVATION > (config.INFO_SCREEN_DELAY * 10):
+ if PERIODIC_INFO_ACTIVATION > (CONFIG["info_delay"] * 10):
MODE = 'i'
syslog.syslog("Switching to info mode")
- elif (PERIODIC_INFO_ACTIVATION % (((config.DAILY_PAUSE * D_COUNT) +
- (config.HOURLY_PAUSE * H_COUNT)) * 10)) == 0:
+ elif (PERIODIC_INFO_ACTIVATION % (
+ ((CONFIG["plugins"]["daily"]["pause"] * D_COUNT)
+ + (CONFIG["plugins"]["hourly"]["pause"] * H_COUNT))
+ * 10)) == 0:
if MODE == 'd':
syslog.syslog("Switching to HOURLY")
MODE = 'h'