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
+
+
+
Basic Settings
+
+
API Key: + This is your Dark Sky API key. Click the link and sign-up for free to retrieve it. +
+ +
+
+
Latitude: + Click the link. Right click on desired location on the map, and click "show address" for coordinates. +
+ +
+
+
Longitude: + Click the link. Right click on desired location on the map, and click "show address" for coordinates. +
+ +
+
+
Units: + Information about units can be found by clicking the link, and searching for "units" section. +
+ +
+
+
Language: + Information about languages can be found by clicking the link, and searching for "languages" section. +
+ +
+
+
Fullscreen: + Use fullscreen when running on a TV or similar device with a higher resolution. +
+ +
+
+
Icon Offset: + If the weather icons are overlapping the text, try adjusting this value. Negative values raise the icon. +
+ +
+
+
Frequency: + Number of seconds between API calls. Free Dark Sky access only allows for 1,000 calls/day (every 86.4 seconds). +
+ +
+ +
+
+ +
+ + 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'