Intial functionality: connect to HA and trigger action

This commit is contained in:
2022-12-07 17:15:48 +01:00
parent 8a11183393
commit 905b4ad044
37 changed files with 1730 additions and 2 deletions
+6
View File
@@ -1,4 +1,5 @@
# ---> VisualStudioCode
.vscode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
@@ -9,3 +10,8 @@
# Local History for Visual Studio Code
.history/
# Python cache directories
__pycache__/
# Database
*.sqlite3
+2 -2
View File
@@ -1,3 +1,3 @@
# Hasscalled
# Hasscall
**H**ome **ASS**istant **CALL**back sch**ED**uler
**H**ome **AS**i**S**tant **CALL**backs
+26
View File
@@ -0,0 +1,26 @@
# Import core packages
import logging
import os
# Import Flask
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Inject Flask magic
webapp = Flask(__name__)
# Load configuration
webapp.config.from_object('app.config.Config')
# Construct the DB Object (SQLAlchemy interface)
db = SQLAlchemy(webapp)
# Explicitly import all tables
from app.models import *
# Import routing to render the pages
from app.views import *
db.create_all()
webapp.run()
+19
View File
@@ -0,0 +1,19 @@
import os
basedir = os.path.abspath(os.path.dirname(__file__) + "/..")
class Config():
# Set up the App SECRET_KEY
SECRET_KEY = 'XhR*rJN*EGpyPMUwYd4nNPQP72vbaG'
# This will create a file in <app> FOLDER
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'hasscall.sqlite3')
SQLALCHEMY_TRACK_MODIFICATIONS = False
class Defaults():
feedback_level = 'some'
login_enabled = 'true'
username = 'admin'
password = 'hasscall'
+106
View File
@@ -0,0 +1,106 @@
import collections
from html import entities
from homeassistant_api import Client, HomeassistantAPIError
from pyparsing import matchPreviousExpr
URL = "https://home.kvanewijk.nl:42728/api"
TOKEN = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiI0YmQ4ZDEzY2JkYTI0NTdhYWI5YWIzMWU0MTE2Y2FhMCIsImlhdCI6MTY0NzgwMjgxMCwiZXhwIjoxOTYzMTYyODEwfQ.bwXhX7au5MKtHxl-Trj7tLijjATvRaA45iNhTmL0NyM"
class HomeAssistant(object):
instance = None
def __init__(self, url, token) -> None:
self.url = url
self.token = token
self.Client = Client(url, token)
@classmethod
def initialize(cls, url, token):
cls.instance = HomeAssistant(url, token)
@classmethod
def getInstance(cls, url=None, token=None):
if cls.instance is None:
cls.instance = HomeAssistant(url, token)
return cls.instance
@classmethod
def testConnection(cls, url:str, token:str) -> str:
response = None
try:
response = Client(url, token).check_api_running()
except (HomeassistantAPIError, Exception) as e:
print("Error while connecting: {}".format(str(e)))
return response
def get_domains(self):
return self.Client.get_domains()
def get_domain(self, domain):
return self.Client.get_domain(domain)
def get_services(self, domain):
domain = self.Client.get_domain(domain)
od = collections.OrderedDict(sorted(domain.services.items()))
services = list(od.values())
return services
def get_service(self, domain, service):
services = self.get_domain(domain).services
if service in services:
return services[service]
raise KeyError("Service `{}` does not exist in domain `{}`".format(service, domain))
def get_entities(self, domain):
groups = self.Client.get_entities()
entities = []
if domain in groups:
entities = [groups[domain].group_id + "." + e.slug for e in list(groups[domain].entities.values())]
else:
for group in groups.values():
entities.extend([group.group_id + "." + e.slug for e in list(group.entities.values())])
entities.sort()
return entities
def get_entity(self, entity):
return self.Client.get_entity(entity_id=entity)
def domain_has_entities(self, domain):
groups = self.Client.get_entities()
if domain in groups:
if len(groups[domain].entities) > 0:
return True
return False
def get_defaults(self):
domains = self.get_domains()
domain = domains[0].domain_id
services = self.get_services(domain)
service = services[0].service_id
entities = self.get_entities(domain)
entity = entities[0]
return domain, service, entity
def trigger_service(self, domain, service, entity, data):
service_data = {}
if entity:
service_data['entity_id'] = entity
if data:
for line in data.split("\n"):
keypair = line.split(":")
service_data[keypair[0].strip()] = keypair[1].strip()
states = self.Client.trigger_service(domain, service, **service_data)
+3
View File
@@ -0,0 +1,3 @@
import app.models.settings
import app.models.callback
import app.models.schedule
+52
View File
@@ -0,0 +1,52 @@
import uuid
from app import db
def generate_uuid():
return str(uuid.uuid4())
class Callback(db.Model):
# Table structure
__tablename__ = "callbacks"
uuid = db.Column(db.String(32), primary_key=True, default=generate_uuid)
name = db.Column(db.String(64) )
domain = db.Column(db.String(256) )
service = db.Column(db.String(256) )
entity = db.Column(db.String(256) )
data = db.Column(db.Text )
note = db.Column(db.Text )
active = db.Column(db.Boolean )
schedules = db.relationship("Schedule")
def __init__(self, name:str, domain:str, service:str, entity:str) -> None:
self.name = name
self.domain = domain
self.service = service
self.entity = entity
self.text = ""
self.note = ""
self.active = True
def __repr__(self) -> str:
return str(self.name) + ': ' + str(self.domain) + '/' + str(self.service)
@classmethod
def get_callback(cls, uuid:str) -> str:
result = cls.query.filter_by(uuid=uuid).first()
if result is not None:
return result
raise KeyError("UUID {} does not exist".format(uuid))
@classmethod
def get_callbacks(cls) -> str:
result = cls.query.filter_by(active=True).all()
if result is not None:
return result
return []
def save(self):
db.session.add (self)
db.session.commit( )
+61
View File
@@ -0,0 +1,61 @@
from subprocess import call
import uuid
from enum import Enum
from app import db
from app.utils import day_to_string
class Schedule(db.Model):
# Table structure
__tablename__ = "schedules"
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
callback_id = db.Column(db.Integer, db.ForeignKey('callbacks.uuid') )
name = db.Column(db.String(64) )
start_day = db.Column(db.Integer )
start_time = db.Column(db.Time )
end_day = db.Column(db.Integer )
end_time = db.Column(db.Time )
def __init__(self, name:str, callback_id:str, startday, starttime, endday, endtime) -> None:
self.name = name
self.callback_id = callback_id
self.start_day = startday
self.start_time = starttime
self.end_day = endday
self.end_time = endtime
def __repr__(self) -> str:
return str(self.name) + ': ' + str(self.type)
def startday_str(self):
return day_to_string(self.start_day)
def endday_str(self):
return day_to_string(self.end_day)
@classmethod
def get_schedule(cls, id:int) -> str:
result = cls.query.filter_by(id=id).first()
if result is not None:
return result
raise KeyError("ID {} does not exist".format(id))
@classmethod
def get_schedules(cls, uuid:str) -> str:
result = cls.query.filter_by(callback_id=uuid)
if result is not None:
return result
raise KeyError("UUID {} does not exist".format(uuid))
def delete(self):
db.session.delete(self)
db.session.commit( );
def save(self):
db.session.add (self)
db.session.commit( )
+47
View File
@@ -0,0 +1,47 @@
from enum import Enum
from app import db
class Setting(db.Model):
class Keys(Enum):
HASSCALL_USERNAME = "username"
HASSCALL_PASSWORD = "password"
HASSCALL_LOGIN_REQUIRED = "login_required"
HASS_URL = "hass_url"
HASS_TOKEN = "hass_token"
FEEDBACK = "feedback_level"
# Table structure
__tablename__ = "settings"
key = db.Column(db.String(64), primary_key=True )
value = db.Column(db.String(256) )
def __init__(self, key:Keys, value:str) -> None:
self.key = key.value
self.value = value
def __repr__(self) -> str:
return str(self.key) + ' = ' + str(self.value)
@classmethod
def get_entry_or_default(cls, key:Keys, default:str) -> str:
result = cls.query.filter_by(key=key.value).first()
if result is not None:
return result
result = Setting(key, default)
db.session.add (result)
db.session.commit( )
return result
@classmethod
def get_value(cls, key:Keys, default:str = "") -> str:
return cls.get_entry_or_default(key, default).value
@classmethod
def set_value(cls, key:Keys, value:str) -> None:
setting = cls.get_entry_or_default(key, value)
setting.value = value
db.session.commit( )
+70
View File
@@ -0,0 +1,70 @@
function getDomainData() {
var domain = $('#domain-select').val();
$('#service-select').prop('disabled', true);
$('#entity-select').prop('disabled', true);
$.ajax({
type: "GET",
url: "/dynamic/services/" + domain,
contentType: "application/text",
success: function(result) {
$('#service-select').empty().append(result);
$('#service-select').prop('disabled', false);
getServiceFields(true)
}
});
$.ajax({
type: "GET",
url: "/dynamic/entities/" + domain,
contentType: "application/text",
success: function(result) {
$('#entity-select').empty().append(result);
$('#entity-select').prop('disabled', false);
}
});
}
function getServiceFields(update_data) {
var domain = $('#domain-select').val();
var service = $('#service-select').val();
$('#service-fields').empty()
$.ajax({
type: "GET",
url: "/dynamic/service/fields/" + domain + "/" + service,
contentType: "application/text",
success: function(result) {
var res = result.split("&#&");
var show_entity = res[0].trim();
var show_data = res[1].trim();
var html = res[2].trim();
var data = res[3].trim();
if(show_entity == "True") {
$('#entity-area').prop('hidden', false);
}
else {
$('#entity-area').prop('hidden', true);
}
if(show_data == "True") {
$('#data-area').prop('hidden', false);
}
else {
$('#data-area').prop('hidden', true);
}
if(update_data){
$('#data').val(data)
}
$('#data').attr('rows', $('#data').val().split('\n').length)
$('#service-fields').append(html);
}
});
}
$(document).ready(getServiceFields(false))
+20
View File
@@ -0,0 +1,20 @@
blockquote.flashes {
margin: 2rem 0 2rem 0;
padding: 0 0.8rem;
}
blockquote.success {
border-left: 0.35rem solid green;
}
blockquote.warning {
border-left: 0.35rem solid yellow;
}
blockquote.error {
border-left: 0.35rem solid orangered;
}
p.fade {
color: lightgray;
}
View File
+515
View File
@@ -0,0 +1,515 @@
/* Global variables. */
:root {
/* Set sans-serif & mono fonts */
--sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir,
"Nimbus Sans L", Roboto, Noto, "Segoe UI", Arial, Helvetica,
"Helvetica Neue", sans-serif;
--mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
/* Default (light) theme */
--bg: #fff;
--accent-bg: #f5f7ff;
--text: #212121;
--text-light: #585858;
--border: #d8dae1;
--accent: #0d47a1;
--code: #d81b60;
--preformatted: #444;
--marked: #ffdd33;
--disabled: #efefef;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--bg: #212121;
--accent-bg: #2b2b2b;
--text: #dcdcdc;
--text-light: #ababab;
--border: #666;
--accent: #ffb300;
--code: #f06292;
--preformatted: #ccc;
--disabled: #111;
}
/* Add a bit of transparancy so light media isn't so glaring in dark mode */
img,
video {
opacity: 0.8;
}
}
html {
/* Set the font globally */
font-family: var(--sans-font);
scroll-behavior: smooth;
}
/* Make the body a nice central block */
body {
color: var(--text);
background: var(--bg);
font-size: 1.15rem;
line-height: 1.5;
display: grid;
grid-template-columns:
1fr min(45rem, 90%) 1fr;
margin: 0;
}
body>* {
grid-column: 2;
}
/* Make the header bg full width, but the content inline with body */
body > header {
background: var(--accent-bg);
border-bottom: 1px solid var(--border);
text-align: center;
padding: 0 0.5rem 2rem 0.5rem;
grid-column: 1 / -1;
box-sizing: border-box;
}
body > header h1 {
max-width: 1200px;
margin: 1rem auto;
}
body > header p {
max-width: 40rem;
margin: 1rem auto;
}
/* Add a little padding to ensure spacing is correct between content and nav */
main {
padding-top: 1.5rem;
}
body > footer {
margin-top: 4rem;
padding: 2rem 1rem 1.5rem 1rem;
color: var(--text-light);
font-size: 0.9rem;
text-align: center;
border-top: 1px solid var(--border);
}
/* Format headers */
h1 {
font-size: 3rem;
}
h2 {
font-size: 2.6rem;
margin-top: 3rem;
}
h3 {
font-size: 2rem;
margin-top: 3rem;
}
h4 {
font-size: 1.44rem;
}
h5 {
font-size: 1.15rem;
}
h6 {
font-size: 0.96rem;
}
/* Fix line height when title wraps */
h1,
h2,
h3 {
line-height: 1.1;
}
/* Reduce header size on mobile */
@media only screen and (max-width: 720px) {
h1 {
font-size: 2.5rem;
}
h2 {
font-size: 2.1rem;
}
h3 {
font-size: 1.75rem;
}
h4 {
font-size: 1.25rem;
}
}
/* Format links & buttons */
a,
a:visited {
color: var(--accent);
}
a:hover {
text-decoration: none;
}
button,
[role="button"],
input[type="submit"],
input[type="reset"],
input[type="button"] {
border: none;
border-radius: 5px;
background: var(--accent);
font-size: 1rem;
color: var(--bg);
padding: 0.7rem 0.9rem;
margin: 0.5rem 0;
}
button[disabled],
[role="button"][aria-disabled="true"],
input[type="submit"][disabled],
input[type="reset"][disabled],
input[type="button"][disabled],
input[type="checkbox"][disabled],
input[type="radio"][disabled],
select[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
input:disabled,
textarea:disabled,
select:disabled {
cursor: not-allowed;
background-color: var(--disabled);
}
input[type="range"] {
padding: 0;
}
/* Set the cursor to '?' while hovering over an abbreviation */
abbr {
cursor: help;
}
button:focus,
button:enabled:hover,
[role="button"]:focus,
[role="button"]:not([aria-disabled="true"]):hover,
input[type="submit"]:focus,
input[type="submit"]:enabled:hover,
input[type="reset"]:focus,
input[type="reset"]:enabled:hover,
input[type="button"]:focus,
input[type="button"]:enabled:hover {
filter: brightness(1.4);
cursor: pointer;
}
/* Format navigation */
nav {
font-size: 1rem;
line-height: 2;
padding: 1rem 0 0 0;
}
/* Use flexbox to allow items to wrap, as needed */
nav ul,
nav ol {
align-content: space-around;
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
list-style-type: none;
margin: 0;
padding: 0;
}
/* List items are inline elements, make them behave more like blocks */
nav ul li,
nav ol li {
display: inline-block;
}
nav a,
nav a:visited {
margin: 0 1rem 1rem 0;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
display: inline-block;
padding: 0.1rem 1rem;
text-decoration: none;
}
nav a:hover {
color: var(--accent);
border-color: var(--accent);
}
nav a:last-child {
margin-right: 0;
}
/* Reduce nav side on mobile */
@media only screen and (max-width: 750px) {
nav a {
border: none;
padding: 0;
color: var(--accent);
text-decoration: underline;
line-height: 1;
}
}
/* Format the expanding box */
details {
background: var(--accent-bg);
border: 1px solid var(--border);
border-radius: 5px;
margin-bottom: 1rem;
}
summary {
cursor: pointer;
font-weight: bold;
padding: 0.6rem 1rem;
}
details[open] {
padding: 0.6rem 1rem 0.75rem 1rem;
}
details[open] summary + * {
margin-top: 0;
}
details[open] summary {
margin-bottom: 0.5rem;
padding: 0;
}
details[open] > *:last-child {
margin-bottom: 0;
}
/* Format tables */
table {
border-collapse: collapse;
width: 100%;
margin: 1.5rem 0;
}
td,
th {
border: 1px solid var(--border);
text-align: left;
padding: 0.5rem;
}
th {
background: var(--accent-bg);
font-weight: bold;
}
tr:nth-child(even) {
/* Set every other cell slightly darker. Improves readability. */
background: var(--accent-bg);
}
table caption {
font-weight: bold;
margin-bottom: 0.5rem;
}
/* Format forms */
textarea,
select,
input {
font-size: inherit;
font-family: inherit;
padding: 0.5rem;
margin-bottom: 0.5rem;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
box-shadow: none;
box-sizing: border-box;
width: 60%;
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}
/* Add arrow to drop-down */
select {
background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%),
linear-gradient(135deg, var(--text) 51%, transparent 49%);
background-position: calc(100% - 20px), calc(100% - 15px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
select[multiple] {
background-image: none !important;
}
/* checkbox and radio button style */
input[type="checkbox"],
input[type="radio"] {
vertical-align: bottom;
position: relative;
}
input[type="radio"] {
border-radius: 100%;
}
input[type="checkbox"]:checked,
input[type="radio"]:checked {
background: var(--accent);
}
input[type="checkbox"]:checked::after {
/* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */
content: " ";
width: 0.1em;
height: 0.25em;
border-radius: 0;
position: absolute;
top: 0.05em;
left: 0.18em;
background: transparent;
border-right: solid var(--bg) 0.08em;
border-bottom: solid var(--bg) 0.08em;
font-size: 1.8em;
transform: rotate(45deg);
}
input[type="radio"]:checked::after {
/* creates a colored circle for the checked radio button */
content: " ";
width: 0.25em;
height: 0.25em;
border-radius: 100%;
position: absolute;
top: 0.125em;
background: var(--bg);
left: 0.125em;
font-size: 32px;
}
/* Make the textarea wider than other inputs */
textarea {
width: 80%;
}
/* Makes input fields wider on smaller screens */
@media only screen and (max-width: 720px) {
textarea,
select,
input {
width: 100%;
}
}
/* Ensures the checkbox and radio inputs do not have a set width like other input fields */
input[type="checkbox"],
input[type="radio"] {
width: auto;
}
/* do not show border around file selector button */
input[type="file"] {
border: 0;
}
/* Misc body elements */
hr {
color: var(--border);
border-top: 1px;
margin: 1rem auto;
}
mark {
padding: 2px 5px;
border-radius: 4px;
background: var(--marked);
}
main img,
main video {
max-width: 100%;
height: auto;
border-radius: 5px;
}
figure {
margin: 0;
text-align: center;
}
figcaption {
font-size: 0.9rem;
color: var(--text-light);
margin-bottom: 1rem;
}
blockquote {
margin: 2rem 0 2rem 2rem;
padding: 0.4rem 0.8rem;
border-left: 0.35rem solid var(--accent);
color: var(--text-light);
font-style: italic;
}
cite {
font-size: 0.9rem;
color: var(--text-light);
font-style: normal;
}
/* Use mono font for code elements */
code,
pre,
pre span,
kbd,
samp {
font-family: var(--mono-font);
color: var(--code);
}
kbd {
color: var(--preformatted);
border: 1px solid var(--preformatted);
border-bottom: 3px solid var(--preformatted);
border-radius: 5px;
padding: 0.1rem 0.4rem;
}
pre {
padding: 1rem 1.4rem;
max-width: 100%;
overflow: auto;
color: var(--preformatted);
background: var(--accent-bg);
border: 1px solid var(--border);
border-radius: 5px;
}
/* Fix embedded code within pre */
pre code {
color: var(--preformatted);
background: none;
margin: 0;
padding: 0;
}
+13
View File
@@ -0,0 +1,13 @@
{% extends 'base/layout.html' %}
{% block main %}
{{ super() }}
<form method="post">
<label for="username">Username</label><br>
<input name="username" id="username" required><br>
<label for="password">Password</label><br>
<input type="password" name="password" id="password" required>
<input type="submit" value="Log In">
</form>
{% endblock %}
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
{% block head %}
<title>Hasscall</title>
<link rel="stylesheet" href="{{ url_for('static', filename='simple.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='custom.css') }}">
<meta charset="utf-8">
{% endblock %}
</head>
<body>
{% block body %}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<header>
{% block header %}
{% endblock %}
</header>
<main>
{% block main %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<blockquote class="flashes {{ category }}">
<p>{{ message }}</p>
</blockquote>
{% endfor %}
{% endif %}
{% endwith %}
{% endblock %}
</main>
<footer>
<p>Created by Kenneth van Ewijk</p>
</footer>
{% endblock %}
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{% extends "base/base.html" %}
{% block header %}
<nav>
<a href="{{ url_for('index') }}">Home</a>
{% if loggedin %}
<a href="{{ url_for('callbacks') }}">Callbacks</a>
<a href="{{ url_for('settings') }}">Settings</a>
<a href="{{ url_for('logout') }}">Logout ({{ user }})</a>
{% else %}
<a href="{{ url_for('login') }}">Login</a>
{% endif %}
</nav>
<h1>Hasscalled</h1>
<p>Home Assistant Callbacks</p>
{% endblock %}
+74
View File
@@ -0,0 +1,74 @@
{% extends "base/layout.html" %}
{% block main %}
{{ super() }}
<div>
<h1>Callback </h1>
<button onclick="navigator.clipboard.writeText(window.location.origin + '{{ url_for('hook', uuid=callback.uuid) }}');">Copy URL</button>
<button onclick="window.location.href='{{ url_for('schedule', uuid=callback.uuid) }}';">Manage schedule</button>
<form action="{{url_for('save_callback', uuid=callback.uuid) }}" method="POST" id="callback-form">
<h3>Details</h3>
<p>
<label for="uuid">UUID</label><br>
<input type="text" name="uuid" value="{{callback.uuid}}" disabled>
</p>
<p>
<label for="name">Name</label><br>
<input type="text" name="name" value="{{callback.name}}" maxlength="64" required><br>
This will be shown to the user when they open the link.
</p>
<h3>Home Assistant</h3>
<p>
<label for="domain">Domain</label><br>
<select name="domain" id="domain-select" onchange="getDomainData();">
{% for domain in domains %}
<option value="{{ domain.domain_id }}" {% if domain.domain_id == callback.domain %}selected{% endif %}>{{ domain.domain_id }}</option>
{% endfor %}
</select>
</p>
<p>
<label for="service">Service</label><br>
<select name="service" id="service-select" onchange="getServiceFields(true);">
{% for service in services %}
<option value="{{ service.service_id }}" {% if service.service_id == callback.service %}selected{% endif %}>{% if service.name is not none %}{{ service.name }}{% else %}{{service.service_id}}{% endif %}</option>
{% endfor %}
</select>
<details>
<summary>Service fields</summary>
<p id="service-fields"></p>
</details>
</p>
<p id="entity-area">
<label for="entity">Target entity</label><br>
<select name="entity" id="entity-select">
{% for entity in entities %}
<option value="{{ entity }}" {% if entity == callback.entity %}selected{% endif %}>{{entity}}</option>
{% endfor %}
</select>
</p>
<p id="data-area">
<label for="data">Data</label><br>
<textarea name="data" id="data" form="callback-form">{{callback.data}}</textarea>
</p>
<h3>Note</h3>
<p>This will be shown to the user when they open the link.</p>
<p>
<textarea name="note" form="callback-form">{{callback.note}}</textarea>
</p>
<h3>Settings</h3>
<p>
<label>Active</label><br>
<label><input name="active" type="checkbox" value="true" {% if callback.active == True %}checked{% endif %}/> Active</label> <br>
</p>
<input type="submit" value="Submit">
</form>
</div>
<script src="{{ url_for('static', filename='callback.js') }}"></script>
{% endblock %}
+24
View File
@@ -0,0 +1,24 @@
{% extends "base/layout.html" %}
{% block main %}
{{ super() }}
<div>
<h1>Callbacks</h1>
<button onclick="window.location.href='{{ url_for('create_callback') }}';">Create New</button>
<table>
<tr>
<th>Name</th>
<th>Link</th>
</tr>
{% for callback in callbacks %}
<tr>
<td>{{callback.name}}</td>
<td>
<button onclick="window.location.href='{{ url_for('callback', uuid=callback.uuid) }}';">Edit</button>
<button onclick="navigator.clipboard.writeText(window.location.origin + '{{ url_for('hook', uuid=callback.uuid) }}');">Copy URL</button>
</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% for entity in entities %}
<option value="{{ entity }}">{{entity}}</option>
{% endfor %}
+15
View File
@@ -0,0 +1,15 @@
{{show_entity}}
&#&
{{show_data}}
&#&
<div>
{% for field in fields %}
<p {% if field.fade %}class="fade"{% endif %}>
<b>{{field.name}}{% if field.required %}*{% endif %}</b>: {{field.description}}<br>
{% if field.example is not none %}<i>Example:</i> {{field.example}}<br> {% endif %}
{% if field.selector is not none %}<i>Options:</i> {{field.selector}}<br> {% endif %}
</p>
{% endfor %}
</div>
&#&
{{field_ids}}
+3
View File
@@ -0,0 +1,3 @@
{% for service in services %}
<option value="{{ service.service_id }}" >{% if service.name != '' %}{{ service.name }}{% else %}{{service.service_id}}{% endif %}</option>
{% endfor %}
+6
View File
@@ -0,0 +1,6 @@
{% extends "base/base.html" %}
{% block main %}
<div>
<h1>ERROR 404 </h1>
<p>This page does not appear to exist...</p>
{% endblock %}
+7
View File
@@ -0,0 +1,7 @@
{% extends "base/base.html" %}
{% block main %}
<div>
<h1>ERROR 500 </h1>
<p>It seems this is not working right now...</p>
<p class="fade">{{error}}</p>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base/base.html" %}
{% block main %}
{{ super() }}
<div>
<h1>{{name}}</h1>
<p>{{note}}</p>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends "base/layout.html" %}
{% block main %}
{{ super() }}
<div>
<h1>Home </h1>
<p>Welcome, go <a href="{{url_for('settings')}}">Settings</a>.</p>
</div>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base/layout.html" %}
{% block main %}
{{ super() }}
<div>
<h1>Schedule for <u>{{callback.name}}</u></h1>
<button onclick="window.location.href='{{ url_for('callback', uuid=callback.uuid) }}';">Back</button>
<form action="{{url_for('create_schedule', uuid=callback.uuid) }}" method="POST" id="callback-form">
<h3>Create new schedule</h3>
<p>
<label for="name">Name</label><br>
<input type="text" name="name" maxlength="64" required><br>
</p>
<p>
<label for="startday">Start day</label><br>
<select name="startday" id="startday-select">
<option value="0">Monday</option>
<option value="1">Tuesday</option>
<option value="2">Wednesday</option>
<option value="3">Thursday</option>
<option value="4">Friday</option>
<option value="5">Saturday</option>
<option value="6">Sunday</option>
</select>
<label for="starttime">Start time</label><br>
<input type="time" name="starttime">
</p>
<p>
<label for="endday">End day</label><br>
<select name="endday" id="endday-select">
<option value="0">Monday</option>
<option value="1">Tuesday</option>
<option value="2">Wednesday</option>
<option value="3">Thursday</option>
<option value="4">Friday</option>
<option value="5">Saturday</option>
<option value="6">Sunday</option>
</select>
<label for="endtime">End time</label><br>
<input type="time" name="endtime">
</p>
<input type="submit" value="Submit">
</form>
<h3>Current schedules</h3>
<p>
{% if schedules.count() > 0 %}
<table>
<tr>
<th>Name</th>
<th>Start time</th>
<th>End time</th>
<th>Delete</th>
</tr>
{% for schedule in schedules %}
<tr>
<td>{{schedule.name}}</td>
<td>{{schedule.startday_str()}} {{schedule.start_time}}</td>
<td>{{schedule.endday_str()}} {{schedule.end_time}}</td>
<td><button onclick="window.location.href='{{ url_for('delete_schedule', id=schedule.id) }}';">Delete</button></td>
</tr>
{% endfor %}
</table>
{% else %}
There are no schedules set.
{% endif %}
</p>
</div>
<script src="{{ url_for('static', filename='schedule.js') }}"></script>
{% endblock %}
+45
View File
@@ -0,0 +1,45 @@
{% extends "base/layout.html" %}
{% block main %}
{{ super() }}
<div>
<h1>Settings </h1>
<form action="{{url_for('save_settings')}}" method="POST">
<h3>Home Assistant</h3>
<p>
<label for="url">URL</label><br>
<input type="url" name="url" value="{{url}}" required>
</p>
<p>
<label for="token">Authentication Token</label><br>
<input type="text" name="token" value="{{token}}" required>
</p>
<h3>Authorization</h3>
<p>
<label><input name="auth-enabled" type="checkbox" value="true" {% if auth_enabled == True %}checked{% endif %}/> Enabled</label> <br>
</p>
<p>
<label for="auth-user">Username</label><br>
<input type="text" name="auth-user" value="{{username}}">
</p>
<p>
<label for="auth-password">Password</label><br>
<input type="password" name="auth-password" value="">
</p>
<h3>Feedback</h3>
<p>
<label>Level of information:</label><br>
<label><input name="feedback" type="radio" value="none" {% if feedback == 'none' %}checked{% endif %}/> None</label> <br />
<label><input name="feedback" type="radio" value="some" {% if feedback == 'some' %}checked{% endif %}/> Some</label> <br />
<label><input name="feedback" type="radio" value="all" {% if feedback == 'all' %}checked{% endif %}/> All</label>
</p>
<input type="submit" value="Submit">
</form>
</div>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
def day_to_string(day):
if(day == 0):
return "Monday"
if(day == 1):
return "Tuesday"
if(day == 2):
return "Wednesday"
if(day == 3):
return "Thursday"
if(day == 4):
return "Friday"
if(day == 5):
return "Saturday"
if(day == 6):
return "Sunday"
+6
View File
@@ -0,0 +1,6 @@
import app.views.index
import app.views.callbacks
import app.views.hook
import app.views.settings
import app.views.dynamic
import app.views.startup
+77
View File
@@ -0,0 +1,77 @@
import functools
from flask import flash
from flask import g
from flask import redirect
from flask import render_template
from flask import request
from flask import session
from flask import url_for
from werkzeug.security import check_password_hash
from werkzeug.security import generate_password_hash
from app import webapp
from app.models.settings import Setting
def login_required(view):
"""View decorator that redirects anonymous users to the login page."""
@functools.wraps(view)
def wrapped_view(**kwargs):
login_required = Setting.get_value(Setting.Keys.HASSCALL_LOGIN_REQUIRED)
if login_required == 'true':
if g.user is None:
flash("You need to be logged in to access that page!", "warning")
return redirect(url_for("login"))
return view(**kwargs)
return wrapped_view
@webapp.before_request
def load_logged_in_user():
"""If a user id is stored in the session, load the user object from
the database into ``g.user``."""
user_id = session.get("user_id")
if user_id is None:
g.user = None
else:
g.user = (
Setting.get_value(Setting.Keys.HASSCALL_USERNAME)
)
@webapp.route("/login", methods=("GET", "POST"))
def login():
"""Log in a registered user by adding the user id to the session."""
if request.method == "GET":
auth_enabled = Setting.get_value(Setting.Keys.HASSCALL_LOGIN_REQUIRED)
if not auth_enabled == 'true':
flash("Authorization is not enabled.", "error")
return redirect(url_for('index'))
elif request.method == "POST":
username = request.form["username"]
password = request.form["password"]
setting_user = Setting.get_value(Setting.Keys.HASSCALL_USERNAME)
setting_password = Setting.get_value(Setting.Keys.HASSCALL_PASSWORD)
if username != setting_user or not check_password_hash(setting_password, password):
flash("Incorrect username or password!", "error")
else:
session.clear()
session["user_id"] = setting_user
flash("Successfully logged in!", 'success')
return redirect(url_for("callbacks"))
return render_template("auth/login.html")
@webapp.route("/logout")
def logout():
"""Clear the current session, including the stored user id."""
session.clear()
flash("Logged out", "success")
return redirect(url_for("index"))
+181
View File
@@ -0,0 +1,181 @@
from html import entities
from flask import redirect, render_template, request, url_for, flash
from grpc import services
from datetime import time
from app import webapp
from app.homeassistant import HomeAssistant
from app.models.callback import Callback
from app.models.schedule import Schedule
from app.models.settings import Setting
from app.views.auth import login_required
@webapp.route('/callbacks')
@login_required
def callbacks():
hass_url = Setting.get_value(Setting.Keys.HASS_URL)
hass_token = Setting.get_value(Setting.Keys.HASS_TOKEN)
validConnection = HomeAssistant.testConnection(hass_url, hass_token)
if not validConnection:
flash("Valid connection to Home Assistant is required!", "error")
return redirect(url_for('settings'))
callbacks = Callback.get_callbacks()
return render_template("callbacks.html", callbacks=callbacks)
@webapp.route('/create-callback')
@login_required
def create_callback():
domain, service, entity = HomeAssistant.getInstance().get_defaults()
callback = Callback("New callback", domain, service, entity)
callback.save()
flash("Callback created", "success")
return redirect(url_for("callback", uuid=callback.uuid))
@webapp.route('/callback/<uuid>')
@login_required
def callback(uuid:str):
callback = Callback.get_callback(uuid)
domains = HomeAssistant.getInstance().get_domains()
services = HomeAssistant.getInstance().get_services(callback.domain)
entities = HomeAssistant.getInstance().get_entities(callback.domain)
return render_template("callback.html",callback=callback, domains=domains, services=services, entities=entities)
@webapp.route('/callback/schedule/<uuid>')
@login_required
def schedule(uuid:str):
callback = Callback.get_callback(uuid)
schedules = Schedule.get_schedules(uuid)
print(dir(schedules))
return render_template("schedule.html",callback=callback, schedules=schedules)
@webapp.route('/save-callback/<uuid>', methods=['POST'])
@login_required
def save_callback(uuid:str):
callback = None
try:
callback = Callback.get_callback(uuid=uuid)
except Exception as e:
flash("Something went wrong.", "error")
return redirect(url_for('callbacks'))
if not request.form['name']:
flash("Name required", "error")
return redirect(url_for('callback', uuid=callback.uuid))
callback.name = request.form['name']
callback.domain = request.form['domain']
callback.service = request.form['service']
callback.note = request.form['note']
if 'active' in request.form and request.form['active'] == 'true':
callback.active = True
else:
callback.active = False
service = HomeAssistant.getInstance().get_service(callback.domain, callback.service)
entity = None
if 'entity_id' or 'target' in service.fields:
entity = request.form['entity']
callback.entity = entity
data = request.form['data']
data_formatted = ""
if data:
for line in data.split("\n"):
if line.strip():
keypair = line.split(":", 1)
key = ""
value = ""
if len(keypair) == 2:
key = keypair[0].strip()
value = keypair[1].strip()
if not key or not value:
flash("Data ({}) incorrectly formatted!".format(key or value), "warning")
elif key in ['entity_id', 'target'] or key not in service.fields:
flash("{} ({}) not supported for this service".format(key, value), "error")
else:
data_formatted += "{key}: {value}\n".format(key=key, value=value)
callback.data = data_formatted.rstrip()
callback.save()
flash("Changes saved successfully", "success")
return redirect(url_for('callback', uuid=callback.uuid))
@webapp.route('/create_schedule/<uuid>', methods=['POST'])
@login_required
def create_schedule(uuid:str):
callback = None
try:
callback = Callback.get_callback(uuid=uuid)
except Exception as e:
flash("Something went wrong.", "error")
return redirect(url_for('callbacks'))
if not request.form['name']:
flash("Name required", "error")
return redirect(url_for('schedule', uuid=callback.uuid))
name = request.form['name']
startday = request.form['startday']
starttime_str = request.form['starttime']
endday = request.form['endday']
endtime_str = request.form['endtime']
starttime_split = starttime_str.split(":")
starttime = time(int(starttime_split[0]), int(starttime_split[1]), 0, 0)
endtime_split = endtime_str.split(":")
endtime = time(int(endtime_split[0]), int(endtime_split[1]), 0, 0)
if(startday > endday):
flash("Start day has to be the same or earlier than end day", "error")
return redirect(url_for('schedule', uuid=callback.uuid))
if(startday == endday):
if(starttime >= endtime):
flash("Start time needs to be earlier than end time", "error")
return redirect(url_for('schedule', uuid=callback.uuid))
schedule = Schedule(name, uuid, startday, starttime, endday, endtime)
schedule.save()
flash("Schedule created", "success")
return redirect(url_for('schedule', uuid=callback.uuid))
@webapp.route('/delete_schedule/<id>')
@login_required
def delete_schedule(id:int):
schedule = None
try:
schedule = Schedule.get_schedule(id=id)
except Exception as e:
flash("Something went wrong.", "error")
return redirect(url_for('callbacks'))
uuid = schedule.callback_id
schedule.delete()
flash("Schedule deleted", "success")
return redirect(url_for('schedule', uuid=uuid))
+47
View File
@@ -0,0 +1,47 @@
from flask import render_template
from app import webapp
from app.homeassistant import HomeAssistant
from app.views.auth import login_required
@webapp.route('/dynamic/services/<domain>')
@login_required
def dynamic_services(domain:str):
services = HomeAssistant.getInstance().get_services(domain)
return render_template("dynamic/services.html", services=services)
@webapp.route('/dynamic/service/fields/<domain>/<service>')
@login_required
def dynamic_service_fields(domain:str, service:str):
service = HomeAssistant.getInstance().get_service(domain, service)
class Service():
def __init__(self, key, field):
self.name = key if field.name is None else (field.name + " (" + key + ")")
self.description = field.description
self.example = field.example
self.required = False if field.required is None else field.required
self.selector = ", ".join(field.selector)
self.fade = (key in ['target', 'entity_id'])
fields = []
field_ids = []
for key in service.fields:
field = service.fields[key]
fields.append(Service(key, field))
if key not in ['entity_id', 'target']:
field_ids.append(key + ":")
show_entity = (('entity_id' in service.fields or 'target' in service.fields) or len(fields) == 0 or HomeAssistant.getInstance().domain_has_entities(domain))
show_data = (len(fields) > 0)
return render_template("dynamic/service_fields.html", show_entity=show_entity, show_data=show_data, fields=fields, field_ids="\n".join(field_ids))
@webapp.route('/dynamic/entities/<domain>')
@login_required
def dynamic_entities(domain:str):
entities = HomeAssistant.getInstance().get_entities(domain)
return render_template("dynamic/entities.html", entities=entities)
+22
View File
@@ -0,0 +1,22 @@
from flask import render_template
from homeassistant_api import HomeassistantAPIError
from app import webapp
from app.models.callback import Callback
from app.homeassistant import HomeAssistant
@webapp.route('/hook/<uuid>')
def hook(uuid:str):
callback = None
try:
callback = Callback.get_callback(uuid)
except:
return render_template("error/404.html"), 404
try:
HomeAssistant.getInstance().trigger_service(callback.domain, callback.service, callback.entity, callback.data)
except HomeassistantAPIError as e:
return render_template("error/500.html", error=str(e)), 500
return render_template("hook.html", name=callback.name, note=callback.note)
+6
View File
@@ -0,0 +1,6 @@
from flask import render_template
from app import webapp
@webapp.route('/')
def index():
return render_template("index.html")
+64
View File
@@ -0,0 +1,64 @@
from flask import flash, redirect, render_template, request, url_for
from werkzeug.security import generate_password_hash
from app import webapp
from app.config import Defaults
from app.homeassistant import HomeAssistant
from app.models.settings import Setting
from app.views.auth import login_required
@webapp.route('/settings')
@login_required
def settings():
hass_url = Setting.get_value(Setting.Keys.HASS_URL)
hass_token = Setting.get_value(Setting.Keys.HASS_TOKEN)
feedback = Setting.get_value(Setting.Keys.FEEDBACK, Defaults.feedback_level)
auth_required = True if Setting.get_value(Setting.Keys.HASSCALL_LOGIN_REQUIRED) == 'true' else False
username = Setting.get_value(Setting.Keys.HASSCALL_USERNAME)
return render_template('settings.html', token=hass_token, url=hass_url, feedback=feedback, auth_enabled=auth_required, username=username)
@webapp.route('/save-settings', methods=['POST'])
@login_required
def save_settings():
# Authorization
auth_enabled = 'false'
if 'auth-enabled' in request.form and request.form['auth-enabled'] == 'true':
auth_enabled = 'true'
Setting.set_value(Setting.Keys.HASSCALL_LOGIN_REQUIRED, auth_enabled)
username = request.form["auth-user"]
password = request.form["auth-password"]
if not username or not password:
flash("Username and password required!", "error")
else:
Setting.set_value(Setting.Keys.HASSCALL_USERNAME, username)
Setting.set_value(Setting.Keys.HASSCALL_PASSWORD, generate_password_hash(password))
# Feedback
Setting.set_value(Setting.Keys.FEEDBACK, request.form["feedback"])
# Attempt a connection to Home Assistant
url = request.form["url"]
token = request.form["token"]
if not url.endswith("/api"):
url = url + "/api"
try:
validConnection = HomeAssistant.testConnection(url, token)
if not validConnection:
flash("Unable to connect to Home Assistant", "error")
else:
HomeAssistant.initialize(url=url, token=token)
Setting.set_value(Setting.Keys.HASS_URL, url)
Setting.set_value(Setting.Keys.HASS_TOKEN, token)
except:
flash("There was an error!", "error")
flash("Settings saved successfully!", "success")
return redirect(url_for('settings'))
+37
View File
@@ -0,0 +1,37 @@
from flask import g, session
from werkzeug.security import generate_password_hash
from app import webapp
from app.config import Defaults
from app.homeassistant import HomeAssistant
from app.models.settings import Setting
@webapp.before_first_request
def connect_to_homeassistant():
session.clear()
# Setup connection to Home Assistant
hass_url = Setting.get_value(Setting.Keys.HASS_URL)
hass_token = Setting.get_value(Setting.Keys.HASS_TOKEN)
validConnection = HomeAssistant.testConnection(hass_url, hass_token)
if validConnection:
HomeAssistant.initialize(url=hass_url, token=hass_token)
# Create defaults in the database
Setting.get_value(Setting.Keys.HASSCALL_LOGIN_REQUIRED, Defaults.login_enabled)
Setting.get_value(Setting.Keys.HASSCALL_USERNAME, Defaults.username)
Setting.get_value(Setting.Keys.HASSCALL_PASSWORD, generate_password_hash(Defaults.password))
@webapp.context_processor
def inject_user():
data = {'loggedin': False, 'user': None}
login_required = Setting.get_value(Setting.Keys.HASSCALL_LOGIN_REQUIRED)
if login_required == 'true':
if g.user is not None:
data['loggedin'] = True
data['user'] = g.user
return data
+1
View File
@@ -0,0 +1 @@
from app import app