[#255] Model variables as entities #256

Merged
blacklight merged 14 commits from 255-model-variables-as-entities into master 2023-04-29 18:24:25 +02:00
40 changed files with 941 additions and 111 deletions

View File

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" type="module" src="/static/js/chunk-vendors.95bedba1.js"></script><script defer="defer" type="module" src="/static/js/app.9381dbaf.js"></script><link href="/static/css/chunk-vendors.0fcd36f0.css" rel="stylesheet"><link href="/static/css/app.11a00465.css" rel="stylesheet"><script defer="defer" src="/static/js/chunk-vendors-legacy.79dede0c.js" nomodule></script><script defer="defer" src="/static/js/app-legacy.5d55c8be.js" nomodule></script></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><link rel="stylesheet" href="/fonts/poppins.css"><title>platypush</title><script defer="defer" type="module" src="/static/js/chunk-vendors.95bedba1.js"></script><script defer="defer" type="module" src="/static/js/app.d27bc1d7.js"></script><link href="/static/css/chunk-vendors.0fcd36f0.css" rel="stylesheet"><link href="/static/css/app.11a00465.css" rel="stylesheet"><script defer="defer" src="/static/js/chunk-vendors-legacy.79dede0c.js" nomodule></script><script defer="defer" src="/static/js/app-legacy.ac3a24a2.js" nomodule></script></head><body><noscript><strong>We're sorry but platypush doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[8358],{8358:function(e,n,t){t.r(n),t.d(n,{default:function(){return R}});t(8309);var l=t(6252),a=t(3577),r=t(9963),i=function(e){return(0,l.dD)("data-v-0f534cfd"),e=e(),(0,l.Cn)(),e},u={key:0,class:"entity variable-container"},s={class:"col-1 icon"},o={class:"col-s-6 col-m-7 label"},c=["textContent"],d=["textContent"],p={class:"row"},v={class:"row"},f={class:"col-9"},m=["value","disabled"],h={class:"col-3 pull-right"},b=["disabled"],g=i((function(){return(0,l._)("i",{class:"fas fa-trash"},null,-1)})),_=[g],k=["disabled"],y=i((function(){return(0,l._)("i",{class:"fas fa-check"},null,-1)})),w=[y];function C(e,n,t,i,g,y){var C,x=(0,l.up)("EntityIcon");return null!=e.value.value?((0,l.wg)(),(0,l.iD)("div",u,[(0,l._)("div",{class:(0,a.C_)(["head",{collapsed:e.collapsed}])},[(0,l._)("div",s,[(0,l.Wm)(x,{entity:e.value,loading:e.loading,error:e.error},null,8,["entity","loading","error"])]),(0,l._)("div",o,[(0,l._)("div",{class:"name",textContent:(0,a.zw)(e.value.name)},null,8,c)]),(0,l._)("div",{class:"col-s-4 col-m-3 value-container",onClick:n[0]||(n[0]=(0,r.iM)((function(n){return e.collapsed=!e.collapsed}),["stop"]))},[null!=(null===(C=e.value)||void 0===C?void 0:C.value)?((0,l.wg)(),(0,l.iD)("span",{key:0,class:"value",textContent:(0,a.zw)(e.value.value)},null,8,d)):(0,l.kq)("",!0)]),(0,l._)("div",{class:"col-1 collapse-toggler",onClick:n[1]||(n[1]=(0,r.iM)((function(n){return e.collapsed=!e.collapsed}),["stop"]))},[(0,l._)("i",{class:(0,a.C_)(["fas",{"fa-chevron-down":e.collapsed,"fa-chevron-up":!e.collapsed}])},null,2)])],2),e.collapsed?(0,l.kq)("",!0):((0,l.wg)(),(0,l.iD)("div",{key:0,class:"body",onClick:n[4]||(n[4]=(0,r.iM)((function(){return e.prevent&&e.prevent.apply(e,arguments)}),["stop"]))},[(0,l._)("div",p,[(0,l._)("form",{onSubmit:n[3]||(n[3]=(0,r.iM)((function(){return y.setValue&&y.setValue.apply(y,arguments)}),["prevent"]))},[(0,l._)("div",v,[(0,l._)("div",f,[(0,l._)("input",{type:"text",value:e.value.value,placeholder:"Variable value",disabled:e.loading,ref:"text"},null,8,m)]),(0,l._)("div",h,[(0,l._)("button",{type:"button",title:"Clear",onClick:n[2]||(n[2]=(0,r.iM)((function(){return y.clearValue&&y.clearValue.apply(y,arguments)}),["stop"])),disabled:e.loading},_,8,b),(0,l._)("button",{type:"submit",title:"Edit",disabled:e.loading},w,8,k)])])],32)])]))])):(0,l.kq)("",!0)}var x=t(8534),V=(t(5666),t(7909)),q=t(5017),M={name:"Variable",components:{EntityIcon:q["default"]},mixins:[V["default"]],data:function(){return{collapsed:!0}},computed:{isCollapsed:function(){return this.collapsed}},methods:{clearValue:function(){var e=this;return(0,x.Z)(regeneratorRuntime.mark((function n(){return regeneratorRuntime.wrap((function(n){while(1)switch(n.prev=n.next){case 0:return e.$emit("loading",!0),n.prev=1,n.next=4,e.request("variable.unset",{name:e.value.name});case 4:return n.prev=4,e.$emit("loading",!1),n.finish(4);case 7:case"end":return n.stop()}}),n,null,[[1,,4,7]])})))()},setValue:function(){var e=this;return(0,x.Z)(regeneratorRuntime.mark((function n(){var t,l;return regeneratorRuntime.wrap((function(n){while(1)switch(n.prev=n.next){case 0:if(t=e.$refs.text.value,null!==t&&void 0!==t&&t.length){n.next=5;break}return n.next=4,e.clearValue();case 4:return n.abrupt("return",n.sent);case 5:return e.$emit("loading",!0),n.prev=6,l={},l[e.value.name]=t,n.next=11,e.request("variable.set",l);case 11:return n.prev=11,e.$emit("loading",!1),n.finish(11);case 14:case"end":return n.stop()}}),n,null,[[6,,11,14]])})))()}}},$=t(3744);const D=(0,$.Z)(M,[["render",C],["__scopeId","data-v-0f534cfd"]]);var R=D}}]);
//# sourceMappingURL=8358-legacy.248b1f34.js.map

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
"use strict";(self["webpackChunkplatypush"]=self["webpackChunkplatypush"]||[]).push([[8358],{8358:function(l,e,a){a.r(e),a.d(e,{default:function(){return D}});var t=a(6252),s=a(3577),i=a(9963);const n=l=>((0,t.dD)("data-v-0f534cfd"),l=l(),(0,t.Cn)(),l),o={key:0,class:"entity variable-container"},c={class:"col-1 icon"},d={class:"col-s-6 col-m-7 label"},u=["textContent"],r=["textContent"],v={class:"row"},p={class:"row"},f={class:"col-9"},h=["value","disabled"],_={class:"col-3 pull-right"},b=["disabled"],m=n((()=>(0,t._)("i",{class:"fas fa-trash"},null,-1))),y=[m],g=["disabled"],k=n((()=>(0,t._)("i",{class:"fas fa-check"},null,-1))),C=[k];function w(l,e,a,n,m,k){const w=(0,t.up)("EntityIcon");return null!=l.value.value?((0,t.wg)(),(0,t.iD)("div",o,[(0,t._)("div",{class:(0,s.C_)(["head",{collapsed:l.collapsed}])},[(0,t._)("div",c,[(0,t.Wm)(w,{entity:l.value,loading:l.loading,error:l.error},null,8,["entity","loading","error"])]),(0,t._)("div",d,[(0,t._)("div",{class:"name",textContent:(0,s.zw)(l.value.name)},null,8,u)]),(0,t._)("div",{class:"col-s-4 col-m-3 value-container",onClick:e[0]||(e[0]=(0,i.iM)((e=>l.collapsed=!l.collapsed),["stop"]))},[null!=l.value?.value?((0,t.wg)(),(0,t.iD)("span",{key:0,class:"value",textContent:(0,s.zw)(l.value.value)},null,8,r)):(0,t.kq)("",!0)]),(0,t._)("div",{class:"col-1 collapse-toggler",onClick:e[1]||(e[1]=(0,i.iM)((e=>l.collapsed=!l.collapsed),["stop"]))},[(0,t._)("i",{class:(0,s.C_)(["fas",{"fa-chevron-down":l.collapsed,"fa-chevron-up":!l.collapsed}])},null,2)])],2),l.collapsed?(0,t.kq)("",!0):((0,t.wg)(),(0,t.iD)("div",{key:0,class:"body",onClick:e[4]||(e[4]=(0,i.iM)(((...e)=>l.prevent&&l.prevent(...e)),["stop"]))},[(0,t._)("div",v,[(0,t._)("form",{onSubmit:e[3]||(e[3]=(0,i.iM)(((...l)=>k.setValue&&k.setValue(...l)),["prevent"]))},[(0,t._)("div",p,[(0,t._)("div",f,[(0,t._)("input",{type:"text",value:l.value.value,placeholder:"Variable value",disabled:l.loading,ref:"text"},null,8,h)]),(0,t._)("div",_,[(0,t._)("button",{type:"button",title:"Clear",onClick:e[2]||(e[2]=(0,i.iM)(((...l)=>k.clearValue&&k.clearValue(...l)),["stop"])),disabled:l.loading},y,8,b),(0,t._)("button",{type:"submit",title:"Edit",disabled:l.loading},C,8,g)])])],32)])]))])):(0,t.kq)("",!0)}var V=a(7909),x=a(5017),q={name:"Variable",components:{EntityIcon:x["default"]},mixins:[V["default"]],data:function(){return{collapsed:!0}},computed:{isCollapsed(){return this.collapsed}},methods:{async clearValue(){this.$emit("loading",!0);try{await this.request("variable.unset",{name:this.value.name})}finally{this.$emit("loading",!1)}},async setValue(){const l=this.$refs.text.value;if(!l?.length)return await this.clearValue();this.$emit("loading",!0);try{const e={};e[this.value.name]=l,await this.request("variable.set",e)}finally{this.$emit("loading",!1)}}}},M=a(3744);const $=(0,M.Z)(q,[["render",w],["__scopeId","data-v-0f534cfd"]]);var D=$}}]);
//# sourceMappingURL=8358.c0dcf298.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -125,6 +125,9 @@
"tv.samsung.ws": {
"class": "fas fa-tv"
},
"variable": {
"class": "fas fa-square-root-variable"
},
"zigbee.mqtt": {
"imgUrl": "/icons/zigbee.svg"
},

View File

@ -7,10 +7,12 @@
<Selector :entity-groups="entityGroups" :value="selector" @input="selector = $event" />
</div>
<div class="col-1 right">
<button title="Refresh" @click="refresh">
<i class="fa fa-sync-alt" />
</button>
<div class="col-1 actions-container right">
<Dropdown title="Actions" icon-class="fas fa-ellipsis">
<DropdownItem icon-class="fa fa-sync-alt" text="Refresh" @click="refresh" />
<DropdownItem icon-class="fa fa-square-root-variable"
text="Set Variable" @click="variableModalVisible = true" />
</Dropdown>
</div>
</header>
@ -26,6 +28,7 @@
v-if="modalEntityId && entities[modalEntityId]"
/>
<VariableModal :visible="variableModalVisible" @close="variableModalVisible = false" />
<NoItems v-if="!Object.keys(displayGroups || {})?.length">No entities found</NoItems>
<div class="groups-container" v-else>
@ -75,6 +78,8 @@
</template>
<script>
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
import Utils from "@/Utils"
import Loading from "@/components/Loading";
import Icon from "@/components/elements/Icon";
@ -82,14 +87,25 @@ import NoItems from "@/components/elements/NoItems";
import Entity from "./Entity.vue";
import Selector from "./Selector.vue";
import EntityModal from "./Modal"
import VariableModal from "./VariableModal"
import { bus } from "@/bus";
import icons from '@/assets/icons.json'
import meta from './meta.json'
export default {
name: "Entities",
components: {Loading, Icon, Entity, Selector, NoItems, EntityModal},
mixins: [Utils],
components: {
Dropdown,
DropdownItem,
Entity,
EntityModal,
Icon,
Loading,
NoItems,
Selector,
VariableModal,
},
props: {
// Entity scan timeout in seconds
@ -108,6 +124,7 @@ export default {
entities: {},
modalEntityId: null,
modalVisible: false,
variableModalVisible: false,
selector: {
grouping: 'category',
selectedEntities: {},
@ -205,10 +222,10 @@ export default {
const entities = (group ? group.entities : this.entities) || {}
const args = {}
if (group)
args.plugins = Object.keys(entities.reduce((obj, entity) => {
args.plugins = Object.values(entities).reduce((obj, entity) => {
obj[entity.plugin] = true
return obj
}, {}))
}, {})
this.loadingEntities = Object.values(entities).reduce((obj, entity) => {
if (this._shouldSkipLoading(entity))
@ -419,12 +436,44 @@ export default {
right: 0;
text-align: right;
margin-right: 0.5em;
padding-right: 0.5em;
padding-right: 0;
button {
padding: 0.5em 0;
}
}
:deep(.right) {
.dropdown-container {
.dropdown {
min-width: 10em;
.item {
box-shadow: none;
.text {
text-align: left;
margin-left: 0.75em;
}
}
}
button {
margin-right: 0;
text-align: center;
background: transparent;
border: 0;
&:hover {
color: $default-hover-fg;
}
i {
margin-left: 0.5em;
}
}
}
}
}
.groups-canvas {

View File

@ -0,0 +1,108 @@
<template>
<div class="entity variable-container" v-if="value.value != null">
<div class="head" :class="{collapsed: collapsed}">
<div class="col-1 icon">
<EntityIcon :entity="value" :loading="loading" :error="error" />
</div>
<div class="col-s-6 col-m-7 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-s-4 col-m-3 value-container" @click.stop="collapsed = !collapsed">
<span class="value" v-text="value.value" v-if="value?.value != null" />
</div>
<div class="col-1 collapse-toggler" @click.stop="collapsed = !collapsed">
<i class="fas" :class="{'fa-chevron-down': collapsed, 'fa-chevron-up': !collapsed}" />
</div>
</div>
<div class="body" v-if="!collapsed" @click.stop="prevent">
<div class="row">
<form @submit.prevent="setValue">
<div class="row">
<div class="col-9">
<input type="text" :value="value.value" placeholder="Variable value" :disabled="loading" ref="text" />
</div>
<div class="col-3 pull-right">
<button type="button" title="Clear" @click.stop="clearValue" :disabled="loading">
<i class="fas fa-trash" />
</button>
<button type="submit" title="Edit" :disabled="loading">
<i class="fas fa-check" />
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
export default {
name: 'Variable',
components: {EntityIcon},
mixins: [EntityMixin],
data: function() {
return {
collapsed: true,
}
},
computed: {
isCollapsed() {
return this.collapsed
},
},
methods: {
async clearValue() {
this.$emit('loading', true)
try {
await this.request('variable.unset', {name: this.value.name})
} finally {
this.$emit('loading', false)
}
},
async setValue() {
const value = this.$refs.text.value
if (!value?.length)
return await this.clearValue()
this.$emit('loading', true)
try {
const args = {}
args[this.value.name] = value
await this.request('variable.set', args)
} finally {
this.$emit('loading', false)
}
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.head .value-container {
text-align: right;
}
form {
width: 100%;
.row {
width: 100%;
input[type=text] {
width: 100%;
}
}
}
</style>

View File

@ -0,0 +1,145 @@
<template>
<Modal :visible="visible" title="Set Variable" ref="modal" @open="onOpen">
<div class="variable-modal-container">
<form @submit.prevent="setValue">
<div class="row">
<div class="col-s-12 col-m-4 label">
<label for="name">Variable Name</label>
</div>
<div class="col-s-12 col-m-8 value">
<input type="text" id="variable-name" v-model="varName"
placeholder="Variable Name" :disabled="loading" ref="varName" />
</div>
</div>
<div class="row">
<div class="col-s-12 col-m-4 label">
<label for="name">Variable Value</label>
</div>
<div class="col-s-12 col-m-8 value">
<input type="text" id="variable-value" v-model="varValue" ref="varValue"
placeholder="Variable Value" :disabled="loading" />
</div>
</div>
<div class="row button-container">
<button type="submit" title="Set" :disabled="loading">
<i class="fas fa-check" />
</button>
</div>
</form>
</div>
</Modal>
</template>
<script>
import Modal from "@/components/Modal";
import Utils from "@/Utils";
export default {
name: "VariableModal",
components: {Modal},
mixins: [Utils],
emits: ['close'],
props: {
visible: {
type: Boolean,
default: false,
},
},
data() {
return {
loading: false,
varName: null,
varValue: null,
};
},
methods: {
async clearValue() {
this.loading = true
try {
await this.request('variable.unset', {name: this.varName.trim()})
} finally {
this.loading = false
}
},
async setValue() {
const varName = this.varName.trim()
if (!varName?.length) {
this.notifyWarning('No variable name has been specified')
}
const value = this.varValue
if (!value?.length) {
await this.clearValue()
} else {
this.loading = true
try {
const args = {}
args[varName] = value
await this.request('variable.set', args)
} finally {
this.loading = false
}
}
this.$refs.varName.value = ''
this.$refs.varValue.value = ''
this.$refs.modal.close()
},
onOpen() {
this.$nextTick(() => {
this.$refs.varName.focus()
})
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.variable-modal-container {
form {
padding: 1em 0;
label {
font-weight: bold;
}
.row {
padding: 0.25em 1em;
display: flex;
align-items: center;
}
.button-container {
display: flex;
justify-content: center;
margin-top: 0.5em;
margin-bottom: -0.75em;
padding-top: 0.5em;
border-top: 1px solid $border-color-1;
button {
min-width: 10em;
background: none;
border-radius: 1.5em;
&:hover {
background: $hover-bg;
}
}
}
@include from($tablet) {
.value {
text-align: right;
}
}
}
}
</style>

View File

@ -343,6 +343,14 @@
}
},
"variable": {
"name": "Variable",
"name_plural": "Variables",
"icon": {
"class": "fas fa-square-root-variable"
}
},
"voltage_sensor": {
"name": "Sensor",
"name_plural": "Sensors",

View File

@ -11,7 +11,7 @@ import shutil
import socket
import sys
from urllib.parse import quote
from typing import Optional, Set
from typing import Dict, Optional, Set
import yaml
@ -129,7 +129,7 @@ class Config:
}
if 'logging' in self._config:
for (k, v) in self._config['logging'].items():
for k, v in self._config['logging'].items():
if k == 'filename':
logfile = os.path.expanduser(v)
logdir = os.path.dirname(logfile)
@ -158,7 +158,7 @@ class Config:
os.environ[k] = str(v)
self.backends = {}
self.plugins = {}
self.plugins = self._core_plugins
self.event_hooks = {}
self.procedures = {}
self.constants = {}
@ -173,6 +173,12 @@ class Config:
self._init_components()
self._init_dashboards(self._config['dashboards_dir'])
@property
def _core_plugins(self) -> Dict[str, dict]:
return {
'variable': {},
}
def _create_default_config(self):
cfg_mod_dir = os.path.dirname(os.path.abspath(__file__))
cfgfile = self._cfgfile_locations[0]
@ -330,7 +336,7 @@ class Config:
if 'constants' in self._config:
self.constants = self._config['constants']
for (key, value) in self._default_constants.items():
for key, value in self._default_constants.items():
self.constants[key] = value
def _get_dashboard(

View File

@ -1,12 +1,16 @@
import logging
import inspect
import json
import os
import pathlib
import subprocess
import sys
import types
from datetime import datetime
import pkgutil
from typing import Callable, Dict, Final, Optional, Set, Type, Tuple, Any
import pkgutil
from dateutil.tz import tzutc
from sqlalchemy import (
Boolean,
@ -23,6 +27,7 @@ from sqlalchemy import (
from sqlalchemy.orm import ColumnProperty, backref, relationship
from sqlalchemy.orm.exc import ObjectDeletedError
import platypush
from platypush.common.db import Base
from platypush.message import JSONAble, Message
@ -170,7 +175,10 @@ if 'entity' not in Base.metadata:
return normalized_name
except ObjectDeletedError as e:
logger.warning(
f'Could not access column "{col.key}" for entity ID "{self.id}": {e}'
'Could not access column "%s" for entity ID "{%s}": {%s}',
col.key,
self.id,
e,
)
return None
@ -267,9 +275,9 @@ def _discover_entity_types():
isinstance(e, (ImportError, ModuleNotFoundError))
and modname[len(__package__) + 1 :] in _import_error_ignored_modules
):
logger.debug(f'Could not import module {modname}')
logger.debug('Could not import module %s', modname)
else:
logger.warning(f'Could not import module {modname}')
logger.warning('Could not import module %s', modname)
logger.exception(e)
continue
@ -292,7 +300,31 @@ def init_entities_db():
"""
from platypush.context import get_plugin
run_db_migrations()
_discover_entity_types()
db = get_plugin('db')
assert db
db.create_all(db.get_engine(), Base)
def run_db_migrations():
"""
Run the database migrations upon engine initialization.
"""
logger.info('Running database migrations')
alembic_ini = os.path.join(
os.path.dirname(inspect.getabsfile(platypush)), 'migrations', 'alembic.ini'
)
subprocess.run(
[
sys.executable,
'-m',
'alembic',
'-c',
alembic_ini,
'upgrade',
'head',
],
check=True,
)

View File

@ -0,0 +1,29 @@
import logging
from sqlalchemy import Column, ForeignKey, Integer, String
from platypush.common.db import Base
from . import Entity
logger = logging.getLogger(__name__)
if 'variable' not in Base.metadata:
class Variable(Entity):
"""
Models a variable entity.
"""
__tablename__ = 'variable'
id = Column(
Integer, ForeignKey('entity.id', ondelete='CASCADE'), primary_key=True
)
value = Column(String)
__table_args__ = {'keep_existing': True}
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}

View File

@ -0,0 +1,106 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
version_locations = %(here)s/alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
# Use os.pathsep. Default configuration used for new projects.
version_path_separator = os
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = %(DB_ENGINE)s
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,92 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from platypush.config import Config
from platypush.common.db import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
def set_db_engine():
db_conf = Config.get('db')
assert db_conf, 'Could not retrieve the database configuration'
engine = db_conf['engine']
assert engine, 'No database engine configured'
config = context.config
section = config.config_ini_section
config.set_section_option(section, 'DB_ENGINE', engine)
set_db_engine()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,153 @@
"""Migrate variable table
Revision ID: c39ac404119b
Revises: d030953a871d
Create Date: 2023-04-28 22:35:28.307954
"""
import sqlalchemy as sa
from alembic import op
from platypush.entities import Entity
# revision identifiers, used by Alembic.
revision = 'c39ac404119b'
down_revision = 'd030953a871d'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Get the connection and the existing `variable` table
conn = op.get_bind()
metadata = sa.MetaData()
metadata.reflect(bind=conn)
VariableOld = metadata.tables.get('variable')
if VariableOld is None:
print('The table `variable` does not exist, skipping migration')
return
# Create the `variable_new` table
VariableNew = op.create_table(
'variable_new',
sa.Column(
'id',
sa.Integer,
sa.ForeignKey(Entity.id, ondelete='CASCADE'),
primary_key=True,
),
sa.Column('value', sa.String),
)
assert VariableNew is not None, 'Could not create table "variable_new"'
# Select all existing variables
existing_vars = {
var.name: var.value for var in conn.execute(sa.select(VariableOld)).all()
}
# Insert all the existing variables as entities
if existing_vars:
conn.execute(
sa.insert(Entity).values(
[
{
'external_id': name,
'name': name,
'type': 'variable',
'plugin': 'variable',
}
for name in existing_vars
]
)
)
# Fetch all the newly inserted variables
new_vars = {
entity.id: entity.name
for entity in conn.execute(
sa.select(Entity.id, Entity.name).where(
sa.or_(
*[
sa.and_(
Entity.external_id == name,
Entity.type == 'variable',
Entity.plugin == 'variable',
)
for name in existing_vars
]
)
)
).all()
}
# Insert the mapping on the `variable_new` table
op.bulk_insert(
VariableNew,
[
{
'id': id,
'value': existing_vars.get(name),
}
for id, name in new_vars.items()
],
)
# Rename/drop the tables
op.rename_table('variable', 'variable_old')
op.rename_table('variable_new', 'variable')
op.drop_table('variable_old')
def downgrade() -> None:
# Get the connection and the existing `variable` table
conn = op.get_bind()
metadata = sa.MetaData()
metadata.reflect(bind=conn)
VariableNew = metadata.tables['variable']
if VariableNew is None:
print('The table `variable` does not exist, skipping migration')
return
# Create the `variable_old` table
VariableOld = op.create_table(
'variable_old',
sa.Column('name', sa.String, primary_key=True, nullable=False),
sa.Column('value', sa.String),
)
assert VariableOld is not None, 'Could not create table "variable_old"'
# Select all existing variables
existing_vars = {
var.name: var.value
for var in conn.execute(
sa.select(Entity.name, VariableNew.c.value).join(
Entity, Entity.id == VariableNew.c.id
)
).all()
}
# Insert the mapping on the `variable_old` table
if existing_vars:
op.bulk_insert(
VariableOld,
[
{
'name': name,
'value': value,
}
for name, value in existing_vars.items()
],
)
# Delete existing references on the `entity` table
conn.execute(sa.delete(Entity).where(Entity.type == 'variable'))
# Rename/drop the tables
op.rename_table('variable', 'variable_new')
op.rename_table('variable_old', 'variable')
op.drop_table('variable_new')

View File

@ -0,0 +1,22 @@
"""Base alembic version
Revision ID: d030953a871d
Revises:
Create Date: 2023-04-28 22:32:49.460118
"""
# revision identifiers, used by Alembic.
revision = 'd030953a871d'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@ -67,6 +67,42 @@ class Plugin(EventGenerator, ExtensionWithManifest): # lgtm [py/missing-call-to
get_decorators(self.__class__, climb_class_hierarchy=True).get('action', [])
)
@property
def _db(self):
"""
:return: The reference to the :class:`platypush.plugins.db.DbPlugin`.
"""
from platypush.context import get_plugin
from platypush.plugins.db import DbPlugin
db: DbPlugin = get_plugin(DbPlugin) # type: ignore
assert db, 'db plugin not initialized'
return db
@property
def _redis(self):
"""
:return: The reference to the :class:`platypush.plugins.redis.RedisPlugin`.
"""
from platypush.context import get_plugin
from platypush.plugins.redis import RedisPlugin
redis: RedisPlugin = get_plugin(RedisPlugin) # type: ignore
assert redis, 'db plugin not initialized'
return redis
@property
def _entities(self):
"""
:return: The reference to the :class:`platypush.plugins.entities.EntitiesPlugin`.
"""
from platypush.context import get_plugin
from platypush.plugins.entities import EntitiesPlugin
entities: EntitiesPlugin = get_plugin(EntitiesPlugin) # type: ignore
assert entities, 'entities plugin not initialized'
return entities
def run(self, method, *args, **kwargs):
assert (
method in self.registered_actions

View File

@ -1,6 +1,7 @@
from queue import Queue, Empty
from threading import Thread
from time import time
from traceback import format_exception
from typing import Optional, Any, Collection, Mapping
from sqlalchemy import or_, text
@ -49,7 +50,9 @@ class EntitiesPlugin(Plugin):
"""
entity_registry = get_entities_registry()
selected_types = []
all_types = {e.__tablename__.lower(): e for e in entity_registry}
all_types = {
e.__tablename__.lower(): e for e in entity_registry # type: ignore
}
if types:
selected_types = {t.lower() for t in types}
@ -148,8 +151,9 @@ class EntitiesPlugin(Plugin):
plugin_name, result = q.get(block=True, timeout=0.5)
if isinstance(result, Exception):
self.logger.warning(
f'Could not load results from plugin {plugin_name}: {result}'
'Could not load results from plugin %s: %s', plugin_name, result
)
self.logger.warning(''.join(format_exception(result)))
else:
results.append(result)
except Empty:
@ -243,7 +247,10 @@ class EntitiesPlugin(Plugin):
with self._get_session(locked=True) as session:
objs = session.query(Entity).filter(Entity.id.in_(entities.keys())).all()
for obj in objs:
obj.meta = {**(obj.meta or {}), **(entities.get(str(obj.id), {}))}
obj.meta = { # type: ignore
**dict(obj.meta or {}), # type: ignore
**(entities.get(str(obj.id), {})),
}
session.add(obj)
session.commit()

View File

@ -606,6 +606,7 @@ class SystemPlugin(SensorPlugin, EntityManager):
**disk,
)
for disk in entities['disks']
if disk.get('device')
],
*[
NetworkInterfaceModel(
@ -615,6 +616,7 @@ class SystemPlugin(SensorPlugin, EntityManager):
**nic,
)
for nic in entities.get('network', [])
if nic.get('interface')
],
*[
SystemTemperature(
@ -624,6 +626,7 @@ class SystemPlugin(SensorPlugin, EntityManager):
**temp,
)
for temp in entities.get('temperature', [])
if temp.get('id') and temp.get('label')
],
*[
SystemFan(
@ -633,6 +636,7 @@ class SystemPlugin(SensorPlugin, EntityManager):
**fan,
)
for fan in entities.get('fans', [])
if fan.get('id') and fan.get('label')
],
*[
SystemBattery(

View File

@ -1,24 +1,12 @@
from sqlalchemy import Column, String
from typing import Collection, Dict, Iterable, Optional, Union
from typing_extensions import override
from platypush.common.db import declarative_base
from platypush.context import get_plugin
from platypush.entities import EntityManager
from platypush.entities.variables import Variable
from platypush.plugins import Plugin, action
from platypush.plugins.db import DbPlugin
Base = declarative_base()
# pylint: disable=too-few-public-methods
class Variable(Base):
"""Models the variable table"""
__tablename__ = 'variable'
name = Column(String, primary_key=True, nullable=False)
value = Column(String)
class VariablePlugin(Plugin):
class VariablePlugin(Plugin, EntityManager):
"""
This plugin allows you to manipulate context variables that can be
accessed across your tasks. It requires the :mod:`platypush.plugins.db`
@ -27,39 +15,34 @@ class VariablePlugin(Plugin):
"""
def __init__(self, **kwargs):
"""
The plugin will create a table named ``variable`` on the database
configured in the :mod:`platypush.plugins.db` plugin. You'll have
to specify a default ``engine`` in your ``db`` plugin configuration.
"""
super().__init__(**kwargs)
db_plugin = get_plugin('db')
redis_plugin = get_plugin('redis')
assert db_plugin, 'Database plugin not configured'
assert redis_plugin, 'Redis plugin not configured'
self.redis_plugin = redis_plugin
self.db_plugin: DbPlugin = db_plugin
self.db_plugin.create_all(self.db_plugin.get_engine(), Base)
db = self._db
self._db_vars: Dict[str, Optional[str]] = {}
""" Local cache for db variables. """
with db.get_session() as session:
self._db_vars.update(
{ # type: ignore
str(var.name): var.value for var in session.query(Variable).all()
}
)
@action
def get(self, name, default_value=None):
def get(self, name: Optional[str] = None, default_value=None):
"""
Get the value of a variable by name from the local db.
:param name: Variable name
:type name: str
:param name: Variable name. If not specified, all the stored variables will be returned.
:param default_value: What will be returned if the variable is not defined (default: None)
:returns: A map in the format ``{"<name>":"<value>"}``
"""
with self.db_plugin.get_session() as session:
var = session.query(Variable).filter_by(name=name).first()
return {name: (var.value if var is not None else default_value)}
return (
{name: self._db_vars.get(name, default_value)}
if name is not None
else self.status().output
)
@action
def set(self, **kwargs):
@ -69,53 +52,36 @@ class VariablePlugin(Plugin):
:param kwargs: Key-value list of variables to set (e.g. ``foo='bar', answer=42``)
"""
with self.db_plugin.get_session() as session:
existing_vars = {
var.name: var
for var in session.query(Variable)
.filter(Variable.name.in_(kwargs.keys()))
.all()
}
new_vars = {
name: Variable(name=name, value=value)
for name, value in kwargs.items()
if name not in existing_vars
}
for name, var in existing_vars.items():
var.value = kwargs[name] # type: ignore
session.add_all([*existing_vars.values(), *new_vars.values()])
self.publish_entities(kwargs)
self._db_vars.update(kwargs)
return kwargs
@action
def unset(self, name):
def unset(self, name: str):
"""
Unset a variable by name if it's set on the local db.
:param name: Name of the variable to remove
:type name: str
:param name: Name of the variable to remove.
"""
with self.db_plugin.get_session() as session:
session.query(Variable).filter_by(name=name).delete()
with self._db.get_session() as session:
entity = session.query(Variable).filter(Variable.name == name).first()
if entity is not None:
self._entities.delete(entity.id)
self._db_vars.pop(name, None)
return True
@action
def mget(self, name):
def mget(self, name: str):
"""
Get the value of a variable by name from Redis.
:param name: Variable name
:type name: str
:returns: A map in the format ``{"<name>":"<value>"}``
"""
return self.redis_plugin.mget([name])
return self._redis.mget([name])
@action
def mset(self, **kwargs):
@ -123,37 +89,65 @@ class VariablePlugin(Plugin):
Set a variable or a set of variables on Redis.
:param kwargs: Key-value list of variables to set (e.g. ``foo='bar', answer=42``)
:returns: A map with the set variables
"""
self.redis_plugin.mset(**kwargs)
self._redis.mset(**kwargs)
return kwargs
@action
def munset(self, name):
def munset(self, name: str):
"""
Unset a Redis variable by name if it's set
:param name: Name of the variable to remove
:type name: str
"""
return self.redis_plugin.delete(name)
return self._redis.delete(name)
@action
def expire(self, name, expire):
def expire(self, name: str, expire: int):
"""
Set a variable expiration on Redis
:param name: Variable name
:type name: str
:param expire: Expiration time in seconds
:type expire: int
"""
return self.redis_plugin.expire(name, expire)
return self._redis.expire(name, expire)
@override
def transform_entities(
self, entities: Union[dict, Iterable]
) -> Collection[Variable]:
variables = (
[
{
'name': name,
'value': value,
}
for name, value in entities.items()
]
if isinstance(entities, dict)
else entities
)
return super().transform_entities(
[
Variable(id=var['name'], name=var['name'], value=var['value'])
for var in variables
]
)
@override
@action
def status(self, *_, **__):
variables = {
name: value for name, value in self._db_vars.items() if value is not None
}
self.publish_entities(variables)
return variables
# vim:sw=4:ts=4:et:

View File

@ -13,7 +13,8 @@ python-dateutil
tz
frozendict
requests
sqlalchemy<2.0.0
sqlalchemy
alembic
bcrypt
rsa
zeroconf>=0.27.0

View File

@ -38,6 +38,9 @@ setup(
url="https://platypush.tech",
packages=find_packages(),
include_package_data=True,
package_data={
'platypush': ['alembic.ini', 'alembic/*', 'alembic/**/*'],
},
entry_points={
'console_scripts': [
'platypush=platypush:main',
@ -58,6 +61,7 @@ setup(
'requests',
'croniter',
'sqlalchemy',
'alembic',
'websockets',
'websocket-client',
'wheel',