Intial functionality: connect to HA and trigger action
This commit is contained in:
@@ -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
|
||||
@@ -1,3 +1,3 @@
|
||||
# Hasscalled
|
||||
# Hasscall
|
||||
|
||||
**H**ome **ASS**istant **CALL**back sch**ED**uler
|
||||
**H**ome **AS**i**S**tant **CALL**backs
|
||||
@@ -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()
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
import app.models.settings
|
||||
import app.models.callback
|
||||
import app.models.schedule
|
||||
@@ -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( )
|
||||
@@ -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( )
|
||||
@@ -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( )
|
||||
@@ -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))
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,3 @@
|
||||
{% for entity in entities %}
|
||||
<option value="{{ entity }}">{{entity}}</option>
|
||||
{% endfor %}
|
||||
@@ -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}}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,9 @@
|
||||
{% extends "base/base.html" %}
|
||||
{% block main %}
|
||||
{{ super() }}
|
||||
|
||||
<div>
|
||||
<h1>{{name}}</h1>
|
||||
<p>{{note}}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,6 @@
|
||||
from flask import render_template
|
||||
from app import webapp
|
||||
|
||||
@webapp.route('/')
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user