commit fe58b5a8747343a115efef18ee7fe0ecd31f27c1 Author: Franziska Kunsmann Date: Fri Dec 18 21:55:00 2020 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9573b7c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +services.json +config.json +*.xml +*.pyc +.venv +*.swp diff --git a/hosted.js b/hosted.js new file mode 100644 index 0000000..87a9f38 --- /dev/null +++ b/hosted.js @@ -0,0 +1,138 @@ +/* + * info-beamer hosted.js Mockup for local development. + * You can find the latest version of this file at: + * + * https://github.com/info-beamer/package-sdk + * + * BSD 2-Clause License + * + * Copyright (c) 2017-2019 Florian Wesch + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + */ +(function() { + +var head = document.getElementsByTagName("head")[0]; +var asset_root = "https://cdn.infobeamer.com/s/mock-use-latest/"; + +function setupResources(js, css) { + for (var idx = 0; idx < js.length; idx++) { + var script = document.createElement('script'); + script.setAttribute("type","text/javascript"); + script.setAttribute("src", asset_root + 'js/' + js[idx]); + head.appendChild(script); + } + + for (var idx = css.length-1; idx >= 0; idx--) { + var link = document.createElement('link') + link.setAttribute('rel', 'stylesheet') + link.setAttribute('type', 'text/css') + link.setAttribute('href', asset_root + 'css/' + css[idx]) + head.insertBefore(link, head.firstChild); + } +} + +var style = document.createElement('style'); +var rules = document.createTextNode( + "body { width: 750px; margin: auto; }" +) +style.type = 'text/css'; +style.appendChild(rules); +head.appendChild(style); + +if (window.MOCK_ASSETS == undefined) + console.error("[MOCK HOSTED.JS] window.MOCK_ASSETS undefined"); +if (window.MOCK_NODE_ASSETS == undefined) + console.error("[MOCK HOSTED.JS] window.MOCK_NODE_ASSETS undefined"); +if (window.MOCK_DEVICES == undefined) + console.error("[MOCK HOSTED.JS] window.MOCK_DEVICES undefined"); +if (window.MOCK_CONFIG == undefined) + console.error("[MOCK HOSTED.JS] window.MOCK_CONFIG undefined"); + +var ib = { + assets: window.MOCK_ASSETS, + node_assets: window.MOCK_NODE_ASSETS, + config: window.MOCK_CONFIG, + devices: window.MOCK_DEVICES, + doc_link_base: 'data:text/plain,This would have opened the package documentation for ', + apis: { + geo: { + get: function(params) { + if (!params.q) { + console.error("no q parameter for weather query"); + } + return new Promise(function(resolve, reject) { + setTimeout(function() { // simulate latency + resolve({"hits":[ + {"lat":49.00937,"lon":8.40444,"name":"Karlsruhe (Baden-W\u00fcrttemberg, Germany)"}, + {"lat":48.09001,"lon":-100.62042,"name":"Karlsruhe (North Dakota, United States)"} + ]}) + }, 800); + }) + }, + } + } +} + +ib.setDefaultStyle = function() { + setupResources([], ['reset.css', 'bootstrap.css']) +} + +var asset_chooser_response = window.MOCK_ASSET_CHOOSER_RESPONSE +if (asset_chooser_response) { + console.log("[MOCK HOSTED.JS] emulating asset chooser"); + ib.assetChooser = function() { + console.log("[MOCK HOSTED.JS] asset chooser mockup returns", asset_chooser_response); + return new Promise(function(resolve) { + resolve(asset_chooser_response); + }) + } +} + +ib.setConfig = function(config) { + var as_string = JSON.stringify(config); + ib.config = JSON.parse(as_string); + console.log("[MOCK HOSTED.JS] setConfig", as_string); +} + +ib.getConfig = function(cb) { + console.warn("[MOCK HOSTED.JS] using .getConfig is deprecated. Use .ready.then(...) instead"); + cb(ib.config); +} + +ib.getDocLink = function(name) { + return ib.doc_link_base + name; +} + +ib.onAssetUpdate = function(cb) { + console.warn("[MOCK HOSTED.JS] onAssetUpdate is a no-op in the mock environment"); +} + +ib.ready = new Promise(function(resolve) { + console.log("[MOCK HOSTED.JS] ready"); + resolve(ib.config); +}) + +window.infobeamer = window.ib = ib; +})(); diff --git a/hosted.lua b/hosted.lua new file mode 100644 index 0000000..2bad686 --- /dev/null +++ b/hosted.lua @@ -0,0 +1,231 @@ +--[[ + + Part of info-beamer hosted. You can find the latest version + of this file at: + + https://github.com/info-beamer/package-sdk + + Copyright (c) 2014,2015,2016,2017 Florian Wesch + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are + met: + + Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the + distribution. + + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS + IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, + THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR + PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR + CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR + PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF + LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING + NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +]]-- + +local resource_types = { + ["image"] = function(value) + local surface + local image = { + asset_name = value.asset_name, + filename = value.filename, + type = value.type, + } + + function image.ensure_loaded() + if not surface then + surface = resource.load_image(value.asset_name) + end + return surface + end + function image.load() + image.ensure_loaded() + local state = surface:state() + return state ~= "loading" + end + function image.get_surface() + return image.ensure_loaded() + end + function image.draw(...) + image.ensure_loaded():draw(...) + end + function image.unload() + if surface then + surface:dispose() + surface = nil + end + end + function image.get_config() + return image + end + return image + end; + ["video"] = function(value) + local surface + local video = { + asset_name = value.asset_name, + filename = value.filename, + type = value.type, + } + function video.ensure_loaded(opt) + if not surface then + surface = util.videoplayer(value.asset_name, opt) + end + return surface + end + function video.load(opt) + video.ensure_loaded(opt) + local state = surface:state() + return state ~= "loading" + end + function video.get_surface(opt) + return video.ensure_loaded(opt) + end + function video.draw(...) + video.ensure_loaded():draw(...) + end + function video.unload() + if surface then + surface:dispose() + surface = nil + end + end + function video.get_config() + return video + end + return video + end; + ["child"] = function(value) + local surface + local child = { + asset_name = value.asset_name, + filename = value.filename, + type = value.type, + } + function child.ensure_loaded() + if surface then + surface:dispose() + end + surface = resource.render_child(value.asset_name) + return surface + end + function child.load() + return true + end + function child.get_surface() + return child.ensure_loaded() + end + function child.draw(...) + child.ensure_loaded():draw(...) + end + function child.unload() + if surface then + surface:dispose() + surface = nil + end + end + function child.get_config() + return child + end + return child + end; + ["json"] = function(value) + return require("json").decode(value) + end; +} + +local types = { + ["date"] = function(value) + return value + end; + ["json"] = function(value) + return value + end; + ["text"] = function(value) + return value + end; + ["string"] = function(value) + return value + end; + ["integer"] = function(value) + return value + end; + ["select"] = function(value) + return value + end; + ["device"] = function(value) + return value + end; + ["boolean"] = function(value) + return value + end; + ["duration"] = function(value) + return value + end; + ["custom"] = function(value) + return value + end; + ["color"] = function(value) + local color = {} + color.r = value.r + color.g = value.g + color.b = value.b + color.a = value.a + color.rgba_table = {color.r, color.g, color.b, color.a} + color.rgba = function() + return color.r, color.g, color.b, color.a + end + color.rgb_with_a = function(a) + return color.r, color.g, color.b, a + end + color.clear = function() + gl.clear(color.r, color.g, color.b, color.a) + end + return color + end; + ["resource"] = function(value) + return resource_types[value.type](value) + end; + ["font"] = function(value) + return resource.load_font(value.asset_name) + end; +} + +local function parse_config(options, config) + local function parse_recursive(options, config, target) + for _, option in ipairs(options) do + local name = option.name + if name then + if option.type == "list" then + local list = {} + for _, child_config in ipairs(config[name]) do + local child = {} + parse_recursive(option.items, child_config, child) + list[#list + 1] = child + end + target[name] = list + else + target[name] = types[option.type](config[name]) + end + end + end + end + local current_config = {} + parse_recursive(options, config, current_config) + return current_config +end + +return { + parse_config = parse_config; +} diff --git a/hosted.py b/hosted.py new file mode 100644 index 0000000..c4f9669 --- /dev/null +++ b/hosted.py @@ -0,0 +1,553 @@ +# +# Part of info-beamer hosted. You can find the latest version +# of this file at: +# +# https://github.com/info-beamer/package-sdk +# +# Copyright (c) 2014,2015,2016,2017,2018 Florian Wesch +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# +# Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +VERSION = "1.3" + +import os +import sys +import json +import time +import errno +import socket +import select +import pyinotify +import thread +import threading +import requests +from tempfile import NamedTemporaryFile + +types = {} + +def init_types(): + def type(fn): + types[fn.__name__] = fn + return fn + + @type + def color(value): + return value + + @type + def string(value): + return value + + @type + def text(value): + return value + + @type + def section(value): + return value + + @type + def boolean(value): + return value + + @type + def select(value): + return value + + @type + def duration(value): + return value + + @type + def integer(value): + return value + + @type + def float(value): + return value + + @type + def font(value): + return value + + @type + def device(value): + return value + + @type + def resource(value): + return value + + @type + def json(value): + return value + + @type + def custom(value): + return value + + @type + def date(value): + return value + +init_types() + +def log(msg): + print >>sys.stderr, "[hosted.py] %s" % msg + +def abort_service(reason): + log("restarting service (%s)" % reason) + try: + thread.interrupt_main() + except: + pass + time.sleep(2) + os.kill(os.getpid(), 2) + time.sleep(2) + os.kill(os.getpid(), 15) + time.sleep(2) + os.kill(os.getpid(), 9) + time.sleep(100) + +class Configuration(object): + def __init__(self): + self._restart = False + self._options = [] + self._config = {} + self._parsed = {} + self.parse_node_json(do_update=False) + self.parse_config_json() + + def restart_on_update(self): + log("going to restart when config is updated") + self._restart = True + + def parse_node_json(self, do_update=True): + with open("node.json") as f: + self._options = json.load(f).get('options', []) + if do_update: + self.update_config() + + def parse_config_json(self, do_update=True): + with open("config.json") as f: + self._config = json.load(f) + if do_update: + self.update_config() + + def update_config(self): + if self._restart: + return abort_service("restart_on_update set") + + def parse_recursive(options, config, target): + # print 'parsing', config + for option in options: + if not 'name' in option: + continue + if option['type'] == 'list': + items = [] + for item in config[option['name']]: + parsed = {} + parse_recursive(option['items'], item, parsed) + items.append(parsed) + target[option['name']] = items + continue + target[option['name']] = types[option['type']](config[option['name']]) + + parsed = {} + parse_recursive(self._options, self._config, parsed) + log("updated config") + self._parsed = parsed + + @property + def raw(self): + return self._config + + @property + def metadata(self): + return self._config['__metadata'] + + def __getitem__(self, key): + return self._parsed[key] + + def __getattr__(self, key): + return self._parsed[key] + +def setup_inotify(configuration): + class EventHandler(pyinotify.ProcessEvent): + def process_default(self, event): + basename = os.path.basename(event.pathname) + if basename == 'node.json': + log("node.json changed") + configuration.parse_node_json() + elif basename == 'config.json': + log("config.json changed!") + configuration.parse_config_json() + elif basename.endswith('.py'): + abort_service("python file changed") + + wm = pyinotify.WatchManager() + + notifier = pyinotify.ThreadedNotifier(wm, EventHandler()) + notifier.daemon = True + notifier.start() + + wm.add_watch('.', pyinotify.IN_MOVED_TO) + +class Node(object): + def __init__(self, node): + self._node = node + self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + + def send_raw(self, raw): + log("sending %r" % (raw,)) + self._sock.sendto(raw, ('127.0.0.1', 4444)) + + def send(self, data): + self.send_raw(self._node + data) + + @property + def is_top_level(self): + return self._node == "root" + + @property + def path(self): + return self._node + + def write_file(self, filename, content): + f = NamedTemporaryFile(prefix='.hosted-py-tmp', dir=os.getcwd()) + try: + f.write(content) + except: + traceback.print_exc() + f.close() + raise + else: + f.delete = False + f.close() + os.rename(f.name, filename) + + def write_json(self, filename, data): + self.write_file(filename, json.dumps( + data, + ensure_ascii=False, + separators=(',',':'), + ).encode('utf8')) + + class Sender(object): + def __init__(self, node, path): + self._node = node + self._path = path + + def __call__(self, data): + if isinstance(data, (dict, list)): + raw = "%s:%s" % (self._path, json.dumps( + data, + ensure_ascii=False, + separators=(',',':'), + ).encode('utf8')) + else: + raw = "%s:%s" % (self._path, data) + self._node.send_raw(raw) + + def __getitem__(self, path): + return self.Sender(self, self._node + path) + + def __call__(self, data): + return self.Sender(self, self._node)(data) + + def scratch_cached(self, filename, generator): + cached = os.path.join(os.environ['SCRATCH'], filename) + + if not os.path.exists(cached): + f = NamedTemporaryFile(prefix='scratch-cached-tmp', dir=os.environ['SCRATCH']) + try: + generator(f) + except: + raise + else: + f.delete = False + f.close() + os.rename(f.name, cached) + + if os.path.exists(filename): + try: + os.unlink(filename) + except: + pass + os.symlink(cached, filename) + +class APIError(Exception): + pass + +class APIProxy(object): + def __init__(self, apis, api_name): + self._apis = apis + self._api_name = api_name + + @property + def url(self): + index = self._apis.get_api_index() + if not self._api_name in index: + raise APIError("api '%s' not available" % (self._api_name,)) + return index[self._api_name]['url'] + + def unwrap(self, r): + r.raise_for_status() + if r.status_code == 304: + return None + if r.headers['content-type'] == 'application/json': + resp = r.json() + if not resp['ok']: + raise APIError(u"api call failed: %s" % ( + resp.get('error', ''), + )) + return resp.get(self._api_name) + else: + return r.content + + def add_defaults(self, kwargs): + if not 'timeout' in kwargs: + kwargs['timeout'] = 10 + + def get(self, **kwargs): + self.add_defaults(kwargs) + try: + return self.unwrap(self._apis.session.get( + url = self.url, + **kwargs + )) + except APIError: + raise + except Exception as err: + raise APIError(err) + + def post(self, **kwargs): + self.add_defaults(kwargs) + try: + return self.unwrap(self._apis.session.post( + url = self.url, + **kwargs + )) + except APIError: + raise + except Exception as err: + raise APIError(err) + +class APIs(object): + def __init__(self, config): + self._config = config + self._index = None + self._valid_until = 0 + self._lock = threading.Lock() + self._session = requests.Session() + self._session.headers.update({ + 'User-Agent': 'hosted.py version/%s' % (VERSION,) + }) + + def update_apis(self): + log("fetching api index") + r = self._session.get( + url = self._config.metadata['api'], + timeout = 5, + ) + r.raise_for_status() + resp = r.json() + if not resp['ok']: + raise APIError("cannot retrieve api index") + self._index = resp['apis'] + self._valid_until = resp['valid_until'] - 300 + + def get_api_index(self): + with self._lock: + now = time.time() + if now > self._valid_until: + self.update_apis() + return self._index + + @property + def session(self): + return self._session + + def list(self): + try: + index = self.get_api_index() + return sorted(index.keys()) + except Exception as err: + raise APIError(err) + + def __getitem__(self, api_name): + return APIProxy(self, api_name) + + def __getattr__(self, api_name): + return APIProxy(self, api_name) + +class GPIO(object): + def __init__(self): + self._pin_fd = {} + self._state = {} + self._fd_2_pin = {} + self._poll = select.poll() + self._lock = threading.Lock() + + def setup_pin(self, pin, direction="in", invert=False): + if not os.path.exists("/sys/class/gpio/gpio%d" % pin): + with open("/sys/class/gpio/export", "wb") as f: + f.write(str(pin)) + # mdev is giving the newly create GPIO directory correct permissions. + for i in range(10): + try: + with open("/sys/class/gpio/gpio%d/active_low" % pin, "wb") as f: + f.write("1" if invert else "0") + break + except IOError as err: + if err.errno != errno.EACCES: + raise + time.sleep(0.1) + log("waiting for GPIO permissions") + else: + raise IOError(errno.EACCES, "Cannot access GPIO") + with open("/sys/class/gpio/gpio%d/direction" % pin, "wb") as f: + f.write(direction) + + def set_pin_value(self, pin, high): + with open("/sys/class/gpio/gpio%d/value" % pin, "wb") as f: + f.write("1" if high else "0") + + def monitor(self, pin, invert=False): + if pin in self._pin_fd: + return + self.setup_pin(pin, direction="in", invert=invert) + with open("/sys/class/gpio/gpio%d/edge" % pin, "wb") as f: + f.write("both") + fd = os.open("/sys/class/gpio/gpio%d/value" % pin, os.O_RDONLY) + self._state[pin] = bool(int(os.read(fd, 5))) + self._fd_2_pin[fd] = pin + self._pin_fd[pin] = fd + self._poll.register(fd, select.POLLPRI | select.POLLERR) + + def poll(self, timeout=1000): + changes = [] + for fd, evt in self._poll.poll(timeout): + os.lseek(fd, 0, 0) + state = bool(int(os.read(fd, 5))) + pin = self._fd_2_pin[fd] + with self._lock: + prev_state, self._state[pin] = self._state[pin], state + if state != prev_state: + changes.append((pin, state)) + return changes + + def poll_forever(self): + while 1: + for event in self.poll(): + yield event + + def on(self, pin): + with self._lock: + return self._state.get(pin, False) + +class Device(object): + def __init__(self): + self._socket = None + self._gpio = GPIO() + + @property + def gpio(self): + return self._gpio + + @property + def screen_resolution(self): + with open("/sys/class/graphics/fb0/virtual_size", "rb") as f: + return [int(val) for val in f.read().strip().split(',')] + + def ensure_connected(self): + if self._socket: + return True + try: + log("establishing upstream connection") + self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._socket.connect(os.getenv('SYNCER_SOCKET', "/tmp/syncer")) + return True + except Exception as err: + log("cannot connect to upstream socket: %s" % (err,)) + return False + + def send_raw(self, raw): + try: + if self.ensure_connected(): + self._socket.send(raw + '\n') + except Exception as err: + log("cannot send to upstream: %s" % (err,)) + if self._socket: + self._socket.close() + self._socket = None + + def send_upstream(self, **data): + self.send_raw(json.dumps(data)) + + def turn_screen_off(self): + self.send_raw("tv off") + + def turn_screen_on(self): + self.send_raw("tv on") + + def screen(self, on=True): + if on: + self.turn_screen_on() + else: + self.turn_screen_off() + + def reboot(self): + self.send_raw("system reboot") + + def halt_until_powercycled(self): + self.send_raw("system halt") + + def restart_infobeamer(self): + self.send_raw("infobeamer restart") + + def verify_cache(self): + self.send_raw("syncer verify_cache") + +if __name__ == "__main__": + device = Device() + while 1: + try: + command = raw_input("syncer> ") + device.send_raw(command) + except (KeyboardInterrupt, EOFError): + break + except: + import traceback + traceback.print_exc() +else: + log("starting version %s" % (VERSION,)) + node = NODE = Node(os.environ['NODE']) + device = DEVICE = Device() + config = CONFIG = Configuration() + api = API = APIs(CONFIG) + setup_inotify(CONFIG) + log("ready to go!") diff --git a/node.json b/node.json new file mode 100644 index 0000000..fb64c62 --- /dev/null +++ b/node.json @@ -0,0 +1,45 @@ +{ + "name": "Icinga2 Statusmonitor", + "permissions": { + "network": "Needs to connect to icinga2 api" + }, + "options": [{ + "title": "Background color", + "ui_width": 4, + "name": "background_color", + "type": "color", + "default": [0,0,0,1] + }, { + "title": "Font", + "ui_width": 8, + "name": "font", + "type": "font", + "default": "silkscreen.ttf" + }, { + "title": "icinga2 API user", + "ui_width": 6, + "name": "api_user", + "type": "string", + "default": "readonly" + }, { + "title": "icinga2 API password", + "ui_width": 6, + "name": "api_password", + "type": "string", + "default": "really_secure" + }, { + "title": "icinga2 API URL for hosts", + "ui_width": 12, + "name": "url_hosts", + "type": "string", + "default": "https://icinga2/api/v1/objects/hosts", + "hint": "Full URL to the API endpoint which returns a list of monitored hosts" + }, { + "title": "icinga2 API URL for services", + "ui_width": 12, + "name": "url_services", + "type": "string", + "default": "https://icinga2/api/v1/objects/services?filter=service.state!=ServiceOK", + "hint": "Full URL to the API endpoint which returns a list of monitored services. Keeping the filter is strongly recommended!" + }] +} diff --git a/node.lua b/node.lua new file mode 100644 index 0000000..370f269 --- /dev/null +++ b/node.lua @@ -0,0 +1,69 @@ +util.init_hosted() + +local json = require "json" +local services = {} +local host_width = 0 +local time_width = 0 + +local c_hard = {} +c_hard[0] = resource.create_colored_texture(0, 0.6, 0, 1) +c_hard[1] = resource.create_colored_texture(0.8, 0.9, 0, 1) +c_hard[2] = resource.create_colored_texture(0.8, 0, 0, 1) +c_hard[3] = resource.create_colored_texture(0.6, 0, 0.7, 1) + +local c_soft = {} +c_soft[1] = resource.create_colored_texture(0.3, 0.4, 0, 1) +c_soft[2] = resource.create_colored_texture(0.3, 0, 0, 1) +c_soft[3] = resource.create_colored_texture(0.3, 0, 0.4, 1) + +local c_text = {} +c_text[0] = {1, 1, 1} +c_text[1] = {0, 0, 0} +c_text[2] = {1, 1, 1} +c_text[3] = {1, 1, 1} + +gl.setup(NATIVE_WIDTH, NATIVE_HEIGHT) + +util.file_watch("services.json", function(content) + services = json.decode(content) + host_width = 0 + + for idx, service in ipairs(services.services) do + host_width = math.max(host_width, CONFIG.font:width(service.host, 50)) + end + + time_width = CONFIG.font:width(services.prettytime, 30) +end) + +local white = resource.create_colored_texture(1,1,1,1) +local base_time = N.base_time or 0 + +function node.render() + CONFIG.background_color.clear() + CONFIG.font:write(NATIVE_WIDTH/2-time_width/2, 10, services.prettytime, 30, 1,1,1,1) + + local y = 50 + for idx, serv in ipairs(services.services) do + my_height = (#serv.output*40)+90 + + if serv.type == 0 then + c_soft[serv.state]:draw(0, y, NATIVE_WIDTH, y+my_height) + else + c_hard[serv.state]:draw(0, y, NATIVE_WIDTH, y+my_height) + end + + y = y+20 + + CONFIG.font:write(10, y, serv.host, 50, c_text[serv.state][1],c_text[serv.state][2],c_text[serv.state][2],1) + CONFIG.font:write(host_width+40, y, serv.service, 50, c_text[serv.state][1],c_text[serv.state][2],c_text[serv.state][3],1) + + y = y+60 + + for idx, line in ipairs(serv.output) do + CONFIG.font:write(host_width+40, y, line, 30, c_text[serv.state][1],c_text[serv.state][2],c_text[serv.state][3],1) + y = y+40 + end + + y = y+12 + end +end diff --git a/package.json b/package.json new file mode 100644 index 0000000..578908e --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "icinga2beamer", + "author": "hi@kunsmann.eu", + "desc": "icinga2 information radiator" +} diff --git a/package.png b/package.png new file mode 100644 index 0000000..3f0dea6 Binary files /dev/null and b/package.png differ diff --git a/service b/service new file mode 100755 index 0000000..a20a3ee --- /dev/null +++ b/service @@ -0,0 +1,102 @@ +#!/usr/bin/env python +import requests +import traceback +import time +import json +import urllib2 +import pytz +import hashlib +from datetime import datetime, timedelta +from itertools import islice + +from hosted import CONFIG, NODE + +CONFIG.restart_on_update() + +def current_time(): + timezone = pytz.timezone("Europe/Berlin") + now = datetime.utcnow() + now = now.replace(tzinfo=pytz.utc) + now = now.astimezone(timezone) + now = now.replace(tzinfo=None) + return now + +def to_unixtimestamp(dt): + return int(time.mktime(dt.timetuple())) + +def regenerate(): + now = current_time() + + services = { + 'generated': to_unixtimestamp(now), + 'prettytime': now.strftime('%d.%m.%Y %H:%M:%S'), + 'services': [], + } + + try: + hosts = requests.get(CONFIG["url_hosts"], auth=(CONFIG["api_user"], CONFIG["api_password"]), verify=False).json() + serv = requests.get(CONFIG["url_services"], auth=(CONFIG["api_user"], CONFIG["api_password"]), verify=False).json() + + if 'results' not in hosts: + raise KeyError('API call for hosts did not return any results') + if 'results' not in serv: + raise KeyError('API call for services did not return any results') + + for host in hosts['results']: + if host['attrs']['downtime_depth'] > 0: + continue + + if host['attrs']['problem']: + services['services'].append({ + 'host': host['attrs']['display_name'], + 'service': '-- HOST --', + 'state': 2, + 'type': host['attrs']['state_type'], + 'output': host['attrs']['last_check_result']['output'].splitlines(), + }) + + for svc in serv['results']: + if host['attrs']['downtime_depth'] > 0: + continue + + if svc['attrs']['problem']: + services['services'].append({ + 'host': svc['attrs']['host_name'], + 'service': svc['attrs']['display_name'], + 'state': svc['attrs']['state'], + 'type': svc['attrs']['state_type'], + 'output': svc['attrs']['last_check_result']['output'].splitlines(), + }) + + services['services'].sort(key=lambda x: str(x['type'])+str(x['state'])+x['host']+x['service']) + services['services'].reverse() + except Exception as e: + services['services'].append({ + 'host': 'icinga2beamer', + 'service': 'INTERNAL', + 'state': 2, + 'output': [repr(e)], + }) + + if len(services['services']) == 0: + services['services'].append({ + 'host': '', + 'service': 'icinga2', + 'state': 0, + 'output': ['Everything is fine. Go get some coffee.'], + }) + + with file("services.json", "wb") as f: + f.write(json.dumps(services, ensure_ascii=False).encode("utf8")) + +def main(): + while 1: + try: + regenerate() + except Exception: + traceback.print_exc() + + time.sleep(20) + +if __name__ == "__main__": + main() diff --git a/silkscreen.ttf b/silkscreen.ttf new file mode 100644 index 0000000..e2dd974 Binary files /dev/null and b/silkscreen.ttf differ