Last active
January 13, 2024 20:25
-
-
Save jag-k/87f99013b91d8314cf0c1f056e003011 to your computer and use it in GitHub Desktop.
HASS Persons bundle
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: blue; icon-glyph: users; | |
// Config | |
// You can change the config here | |
const WIDGET_TITLE = 'Persons at home'; | |
// ========================================= | |
// PLEASE, DON'T CHANGE ANYTHING BELOW THIS COMMENT, | |
// IF YOU DON'T KNOW WHAT YOU ARE DOING | |
// ========================================= | |
let dm = config.widgetFamily; // Using display mode medium by default | |
function displayMode(setMode) { | |
if (setMode) { | |
dm = setMode; | |
} | |
return (dm || config.widgetFamily); | |
} | |
const FULL_CARD = () => displayMode() !== "small"; | |
// ============================= | |
// Widget JSX Element processing | |
// ============================= | |
function processJSXColor(color) { | |
return typeof color === "string" | |
? new Color(color, 1) | |
: typeof color === "number" | |
? new Color("#" + color.toString(16).padStart(6, "0"), 1) | |
: color; | |
} | |
function processPaddingProps(widget, props, defaultPadding) { | |
widget.useDefaultPadding(); | |
const paddings = defaultPadding || [0, 0, 0, 0]; | |
if (props["p-all"] !== undefined) { | |
paddings[0] = props["p-all"]; | |
paddings[1] = props["p-all"]; | |
paddings[2] = props["p-all"]; | |
paddings[3] = props["p-all"]; | |
} | |
if (props["p-y"] !== undefined) { | |
paddings[0] = props["p-y"]; | |
paddings[2] = props["p-y"]; | |
} | |
if (props["p-x"] !== undefined) { | |
paddings[1] = props["p-x"]; | |
paddings[3] = props["p-x"]; | |
} | |
if (props["p-top"] !== undefined) { | |
paddings[0] = props["p-top"]; | |
} | |
if (props["p-right"] !== undefined) { | |
paddings[1] = props["p-right"]; | |
} | |
if (props["p-bottom"] !== undefined) { | |
paddings[2] = props["p-bottom"]; | |
} | |
if (props["p-left"] !== undefined) { | |
paddings[3] = props["p-left"]; | |
} | |
widget.setPadding(paddings[0], paddings[1], paddings[2], paddings[3]); | |
} | |
function processTextProps(text, props) { | |
switch (props.align) { | |
case "left": | |
text.leftAlignText(); | |
break; | |
case "right": | |
text.rightAlignText(); | |
break; | |
case "center": | |
text.centerAlignText(); | |
break; | |
} | |
if (props.font) { | |
text.font = props.font; | |
} | |
if (props.color) { | |
text.textColor = processJSXColor(props.color); | |
} | |
if (props.lineLimit) { | |
text.lineLimit = props.lineLimit; | |
} | |
if (props.minimumScaleFactor) { | |
text.minimumScaleFactor = props.minimumScaleFactor; | |
} | |
if (props.opacity) { | |
text.textOpacity = props.opacity; | |
} | |
if (props.shadowColor) { | |
text.shadowColor = processJSXColor(props.shadowColor); | |
} | |
if (props.shadowOffset) { | |
text.shadowOffset = props.shadowOffset; | |
} | |
if (props.shadowRadius) { | |
text.shadowRadius = props.shadowRadius; | |
} | |
if (props.url) { | |
text.url = props.url; | |
} | |
} | |
function processStackProps(stack, props) { | |
switch (props.align) { | |
case "top": | |
stack.topAlignContent(); | |
break; | |
case "bottom": | |
stack.bottomAlignContent(); | |
break; | |
case "center": | |
stack.centerAlignContent(); | |
break; | |
} | |
if (props.backgroundColor) { | |
stack.backgroundColor = processJSXColor(props.backgroundColor); | |
} | |
if (props.backgroundGradient) { | |
stack.backgroundGradient = props.backgroundGradient; | |
} | |
if (props.backgroundImage) { | |
stack.backgroundImage = props.backgroundImage; | |
} | |
if (props.borderColor) { | |
stack.borderColor = processJSXColor(props.borderColor); | |
} | |
if (props.borderWidth) { | |
stack.borderWidth = props.borderWidth; | |
} | |
if (props.cornerRadius) { | |
stack.cornerRadius = props.cornerRadius; | |
} | |
if (props.size) { | |
stack.size = props.size; | |
} | |
if (props.spacing) { | |
stack.spacing = props.spacing; | |
} | |
if (props.url) { | |
stack.url = props.url; | |
} | |
processPaddingProps(stack, props, [0, 0, 0, 0]); | |
} | |
function processImageProps(image, props) { | |
switch (props.align) { | |
case "left": | |
image.leftAlignImage(); | |
break; | |
case "right": | |
image.rightAlignImage(); | |
break; | |
case "center": | |
image.centerAlignImage(); | |
break; | |
} | |
if (props.size) { | |
image.imageSize = props.size; | |
} | |
if (props.borderColor) { | |
image.borderColor = processJSXColor(props.borderColor); | |
} | |
if (props.borderWidth) { | |
image.borderWidth = props.borderWidth; | |
} | |
if (props.containerRelativeShape) { | |
image.containerRelativeShape = props.containerRelativeShape; | |
} | |
switch (props.contentMode) { | |
case "filling": | |
image.applyFillingContentMode(); | |
break; | |
case "fitting": | |
image.applyFittingContentMode(); | |
break; | |
} | |
if (props.cornerRadius) { | |
image.cornerRadius = props.cornerRadius; | |
} | |
if (props.opacity) { | |
image.imageOpacity = props.opacity; | |
} | |
if (props.resizable) { | |
image.resizable = props.resizable; | |
} | |
if (props.tintColor) { | |
image.tintColor = processJSXColor(props.tintColor); | |
} | |
if (props.url) { | |
image.url = props.url; | |
} | |
} | |
function processWidgetProps(widget, props) { | |
if (props.backgroundColor) { | |
widget.backgroundColor = processJSXColor(props.backgroundColor); | |
} | |
if (props.backgroundGradient) { | |
widget.backgroundGradient = props.backgroundGradient; | |
} | |
if (props.backgroundImage) { | |
widget.backgroundImage = props.backgroundImage; | |
} | |
if (props.refreshAfterDate) { | |
widget.refreshAfterDate = props.refreshAfterDate; | |
} | |
if (props.spacing !== undefined) { | |
widget.spacing = props.spacing; | |
} | |
if (props.url) { | |
widget.url = props.url; | |
} | |
processPaddingProps(widget, props, [15, 15, 15, 15]); | |
} | |
function processContainerChildren(widget, children) { | |
for (const child of children) { | |
if (child === null || child === undefined || child === false || | |
typeof child === "function") { | |
continue; | |
} | |
else if (typeof child === "string" || typeof child === "number" || | |
typeof child === "bigint" || typeof child === "symbol" || child === true) { | |
widget.addText(String(child)); | |
} | |
else if (typeof child === "object") { | |
if (child instanceof Array) { | |
processContainerChildren(widget, child); | |
} | |
else if (child instanceof Date) { | |
widget.addDate(child); | |
} | |
else if (child instanceof Image) { | |
widget.addImage(child); | |
} | |
else if ("type" in child) { | |
switch (child.type) { | |
case "text": { | |
const text = widget.addText(child.text); | |
processTextProps(text, child.props || {}); | |
break; | |
} | |
case "date": { | |
const init = child.props.date; | |
const date = widget.addDate(init instanceof Date ? init : new Date(init)); | |
switch (child.props.style) { | |
case "date": | |
date.applyDateStyle(); | |
break; | |
case "offset": | |
date.applyOffsetStyle(); | |
break; | |
case "time": | |
date.applyTimeStyle(); | |
break; | |
case "relative": | |
date.applyRelativeStyle(); | |
break; | |
case "timer": | |
date.applyTimerStyle(); | |
break; | |
} | |
processTextProps(date, child.props || {}); | |
break; | |
} | |
case "stack": { | |
const stack = widget.addStack(); | |
switch (child?.props?.layout) { | |
case "vertical": | |
stack.layoutVertically(); | |
break; | |
case "horizontal": | |
stack.layoutHorizontally(); | |
break; | |
} | |
processStackProps(stack, child.props || {}); | |
processContainerChildren(stack, child.children); | |
break; | |
} | |
case "spacer": { | |
const spacer = widget.addSpacer(); | |
if (child?.props?.size) { | |
spacer.length = child.props.length; | |
} | |
break; | |
} | |
case "image": { | |
let init; | |
const { data } = child.props || {}; | |
if (typeof data === "string") { | |
init = Image.fromFile(child.props.fileURL); | |
} | |
else if (data.size) { | |
init = data; | |
} | |
else { | |
init = Image.fromData(data); | |
} | |
const image = widget.addImage(init); | |
processImageProps(image, child.props || {}); | |
break; | |
} | |
} | |
} | |
} | |
} | |
} | |
function widgetCreateElement(element, props, ...children) { | |
if (typeof element === "string") { | |
// intrinsic element | |
switch (element) { | |
case "widget": | |
const widget = new ListWidget(); | |
processContainerChildren(widget, children); | |
processWidgetProps(widget, props || {}); | |
return widget; | |
case "text": | |
let text = ""; | |
for (let child of children) { | |
if (typeof child === "string" || | |
typeof child === "number" || | |
typeof child === "bigint") { | |
text += child; | |
} | |
} | |
return { type: element, props, text }; | |
case "stack": | |
return { type: element, props, children }; | |
case "spacer": | |
return { type: element, props }; | |
case "image": | |
return { type: element, props }; | |
case "date": | |
return { type: element, props }; | |
} | |
} | |
} | |
// ============================= | |
// Alert JSX Element processing | |
// ============================= | |
function processAlertProps(alert, props) { | |
if (props.title) { | |
alert.title = props.title; | |
} | |
if (props.message) { | |
alert.message = props.message; | |
} | |
} | |
function processActionProps(alert, props, children) { | |
const text = props.text || (children instanceof Array ? children.join(" ") : children) || ""; | |
switch (props.type || "action") { | |
case "action": { | |
alert.addAction(text); | |
break; | |
} | |
case "cancel": { | |
alert.addCancelAction(text); | |
break; | |
} | |
case "destructive": { | |
alert.addDestructiveAction(text); | |
break; | |
} | |
} | |
} | |
function processTextFieldProps(alert, props) { | |
(props.secure ? alert.addSecureTextField : alert.addTextField)(props.placeholder || "", props.text || ""); | |
} | |
function processAlertChildren(alert, children) { | |
for (const child of children) { | |
if (typeof child === "string") { | |
return child; | |
} | |
if (typeof child === "object") { | |
if (child instanceof Array) { | |
processAlertChildren(alert, child); | |
} | |
else if ("type" in child) { | |
switch (child.type) { | |
case "action": { | |
processActionProps(alert, child.props || {}, child.children || ""); | |
break; | |
} | |
case "text-field": { | |
processTextFieldProps(alert, child.props || {}); | |
break; | |
} | |
} | |
} | |
} | |
} | |
} | |
function alertCreateElement(element, props, ...children) { | |
if (typeof element === "string") { | |
// intrinsic element | |
switch (element) { | |
case "alert": | |
const alert = new Alert(); | |
processAlertChildren(alert, children); | |
processAlertProps(alert, props || {}); | |
return alert; | |
case "text-field": | |
return { type: element, props }; | |
case "action": | |
return { type: element, props, children }; | |
} | |
} | |
} | |
const createElements = [widgetCreateElement, alertCreateElement]; | |
class ScriptableJSX { | |
static createElement(element, props, ...children) { | |
for (const creator of createElements) { | |
const res = creator(element, props, ...children); | |
if (res) { | |
return res; | |
} | |
} | |
} | |
} | |
// ========================================= | |
// Constants | |
// ========================================= | |
const HASS_URL = "HASS_URL"; | |
const HASS_API_TOKEN = "HASS_TOKEN"; | |
// ========================================= | |
// Colors | |
// ========================================= | |
// language=CSS prefix=*{color:# suffix=;} | |
const BackgroundColor = Color.dynamic(new Color('ccc', 1), new Color('1c1c1c', 1)); | |
// language=CSS prefix=*{color:# suffix=;} | |
const SecondBackgroundColor = Color.dynamic(new Color('000', .1), new Color('fff', .1)); | |
// language=CSS prefix=*{color:# suffix=;} | |
const SecondActiveBackgroundColor = Color.dynamic(new Color('000', .2), new Color('fff', .2)); | |
// language=CSS prefix=*{color:# suffix=;} | |
const TextColor = Color.dynamic(new Color('000', 1), new Color('fff', 1)); | |
// language=CSS prefix=*{color:# suffix=;} | |
const TransparentColor = new Color('000', 0); | |
function baseUrl(baseUrl) { | |
return baseUrl.replace(/\/?$/, ''); | |
} | |
function createRequest(url, token) { | |
const req = new Request(url); | |
req.headers = { 'Authorization': `Bearer ${token || Keychain.get(HASS_API_TOKEN)}` }; | |
return req; | |
} | |
async function getAPIStatus(url, token) { | |
const apiCheckUrl = (url ? baseUrl(url) : Keychain.get(HASS_URL)) + '/api/'; | |
const req = createRequest(apiCheckUrl, token); | |
console.log(`Checking ${apiCheckUrl}`); | |
const d = await req.loadString(); | |
console.log(`d: ${d}`); | |
const data = JSON.stringify(JSON.parse(d)); | |
console.log(`data: ${data}`); | |
return data === JSON.stringify({ "message": "API running." }); | |
} | |
async function getAllStates() { | |
return await createRequest(Keychain.get(HASS_URL) + '/api/states').loadJSON(); | |
} | |
async function getPersonStates() { | |
const states = await getAllStates(); | |
return states.filter(state => state.entity_id.startsWith('person.')); | |
} | |
async function getPicture(person) { | |
return await createRequest(Keychain.get(HASS_URL) + person.attributes.entity_picture).loadImage(); | |
} | |
function createPersonURL(person) { | |
return `${Keychain.get(HASS_URL)}/history?entity_id=${person.entity_id}`; | |
} | |
async function join(array, callback, separator) { | |
if (!separator) { | |
separator = ScriptableJSX.createElement("spacer", null); | |
} | |
const result = []; | |
const length = array.length; | |
for (let i = 0; i < length; i++) { | |
const value = array[i]; | |
const out = await callback(value); | |
if (!out) { | |
break; | |
} | |
result.push(out); | |
if (i !== length - 1) | |
result.push(separator); | |
} | |
return result; | |
} | |
function getKeychain(key) { | |
if (Keychain.contains(key)) { | |
return Keychain.get(key); | |
} | |
} | |
async function login(url, token) { | |
let argsPassed = true; | |
if (!(url && token)) { | |
if (!(Keychain.contains(HASS_URL) && Keychain.contains(HASS_API_TOKEN))) { | |
return false; | |
} | |
url = getKeychain(HASS_URL); | |
token = getKeychain(HASS_API_TOKEN); | |
argsPassed = false; | |
} | |
if (url && token) { | |
const resp = await getAPIStatus(url, token); | |
if (!resp) { | |
return false; | |
} | |
if (config.runsInApp && argsPassed) { | |
const a = (ScriptableJSX.createElement("alert", { title: "Login Successful!", message: "Now you can use the widget!" }, | |
ScriptableJSX.createElement("action", { type: "cancel" }, "Close"))); | |
await a.present(); | |
} | |
console.log(`Success login!`); | |
console.log(`${url}, ${token}`); | |
Keychain.set(HASS_URL, baseUrl(url)); | |
Keychain.set(HASS_API_TOKEN, token); | |
return true; | |
} | |
return false; | |
} | |
async function loginMenu() { | |
const a = (ScriptableJSX.createElement("alert", { title: "Login in HomeAssistant" }, | |
ScriptableJSX.createElement("text-field", { placeholder: 'Full URL to HomeAssistant URL', text: getKeychain(HASS_URL) }), | |
ScriptableJSX.createElement("text-field", { placeholder: 'HomeAssistant Token', text: getKeychain(HASS_URL), secure: true }), | |
ScriptableJSX.createElement("action", { type: "cancel" }, "Cancel"), | |
ScriptableJSX.createElement("action", null, "Login"))); | |
let res = await a.present(); | |
console.log(`${res}, ${a.textFieldValue(0)}, ${a.textFieldValue(1)}`); | |
const [url, token] = [a.textFieldValue(0), a.textFieldValue(1)]; | |
return await login(url, token); | |
} | |
// ========================================= | |
// Widgets | |
// ========================================= | |
async function loginWidget() { | |
return (ScriptableJSX.createElement("widget", { backgroundColor: BackgroundColor }, | |
ScriptableJSX.createElement("text", null, "Before using Widget, please login!"))); | |
} | |
async function personsWidget() { | |
const persons = await getPersonStates(); | |
let personGrid = []; | |
switch (displayMode()) { | |
case "small": | |
personGrid = [ | |
[persons[0], persons[1]], | |
[persons[2], persons[3]], | |
]; | |
break; | |
case "medium": | |
personGrid = [ | |
[persons[0], persons[1], persons[2], persons[3]], | |
]; | |
break; | |
case "large": | |
case "extraLarge": | |
personGrid = [ | |
[persons[0], persons[1], persons[2], persons[3]], | |
[persons[4], persons[5], persons[6], persons[7]], | |
[persons[8], persons[9], persons[10], persons[11]], | |
[persons[12], persons[13], persons[14], persons[15]], | |
]; | |
break; | |
} | |
const widgetTitleText = { | |
font: Font.title3(), | |
color: TextColor, | |
}; | |
const widgetTitle = FULL_CARD() ? [ | |
ScriptableJSX.createElement("stack", { layout: "horizontal" }, | |
ScriptableJSX.createElement("text", { ...widgetTitleText }, WIDGET_TITLE), | |
([ | |
ScriptableJSX.createElement("spacer", null), | |
ScriptableJSX.createElement("text", { ...widgetTitleText }, | |
persons.filter(p => p.state === 'home').length, | |
"/", | |
persons.length) | |
])), | |
ScriptableJSX.createElement("spacer", null) | |
] : []; | |
const jsxPersonsGrid = await join(personGrid, async (row) => (ScriptableJSX.createElement("stack", { layout: "horizontal" }, await join(row, personCard)))); | |
return (ScriptableJSX.createElement("widget", { backgroundColor: BackgroundColor }, | |
widgetTitle, | |
ScriptableJSX.createElement("stack", { layout: "vertical" }, jsxPersonsGrid))); | |
} | |
async function personCard(person) { | |
if (!person) | |
return ScriptableJSX.createElement("stack", null); | |
const at_home = person.state === 'home'; | |
const opacity = at_home ? 1 : 0.5; | |
const image = await getPicture(person); | |
const fontSize = 14; | |
const props = (FULL_CARD() ? | |
{ | |
spacing: 5, | |
'p-all': 7, | |
cornerRadius: 15, | |
backgroundColor: at_home ? SecondActiveBackgroundColor : SecondBackgroundColor | |
} : {}); | |
const friendlyName = (ScriptableJSX.createElement("stack", { layout: "horizontal" }, | |
ScriptableJSX.createElement("spacer", null), | |
ScriptableJSX.createElement("text", { color: TextColor, font: at_home ? Font.mediumSystemFont(fontSize) : Font.regularSystemFont(fontSize), opacity: opacity }, person.attributes.friendly_name), | |
ScriptableJSX.createElement("spacer", null))); | |
return (ScriptableJSX.createElement("stack", { layout: "vertical", ...props, url: createPersonURL(person) }, | |
ScriptableJSX.createElement("image", { data: image, align: "center", opacity: opacity, cornerRadius: image.size.width / 2, borderWidth: 5, borderColor: at_home ? TextColor : TransparentColor, resizable: true }), | |
FULL_CARD() && friendlyName)); | |
} | |
let loggedIn = await login(); | |
const widget = loggedIn ? personsWidget : loginWidget; | |
if (config.runsInWidget) { | |
Script.setWidget(await widget()); | |
} | |
else if (config.runsInApp) { | |
if (loggedIn) { | |
// Showing all widgets | |
displayMode('small'); | |
const widgetSmall = await widget(); | |
await widgetSmall.presentSmall(); | |
displayMode('medium'); | |
const widgetMedium = await widget(); | |
await widgetMedium.presentMedium(); | |
displayMode('large'); | |
const widgetLarge = await widget(); | |
await widgetLarge.presentLarge(); | |
displayMode('extraLarge'); | |
const widgetExtraLarge = await widget(); | |
await widgetExtraLarge.presentExtraLarge(); | |
} | |
else { | |
await loginMenu(); | |
} | |
} | |
Script.complete(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{"always_run_in_app":false,"icon":{"color":"blue","glyph":"users"},"name":"HASS Persons","share_sheet_inputs":[],"default":{"always_run_in_app":false,"icon":{"color":"blue","glyph":"users"},"name":"HASS Persons","share_sheet_inputs":[]},"script":"// Config\n// You can change the config here\nconst WIDGET_TITLE = 'Persons at home';\n// =========================================\n// PLEASE, DON'T CHANGE ANYTHING BELOW THIS COMMENT,\n// IF YOU DON'T KNOW WHAT YOU ARE DOING\n// =========================================\nlet dm = config.widgetFamily; // Using display mode medium by default\nfunction displayMode(setMode) {\n if (setMode) {\n dm = setMode;\n }\n return (dm || config.widgetFamily);\n}\nconst FULL_CARD = () => displayMode() !== \"small\";\n\n// =============================\n// Widget JSX Element processing\n// =============================\nfunction processJSXColor(color) {\n return typeof color === \"string\"\n ? new Color(color, 1)\n : typeof color === \"number\"\n ? new Color(\"#\" + color.toString(16).padStart(6, \"0\"), 1)\n : color;\n}\nfunction processPaddingProps(widget, props, defaultPadding) {\n widget.useDefaultPadding();\n const paddings = defaultPadding || [0, 0, 0, 0];\n if (props[\"p-all\"] !== undefined) {\n paddings[0] = props[\"p-all\"];\n paddings[1] = props[\"p-all\"];\n paddings[2] = props[\"p-all\"];\n paddings[3] = props[\"p-all\"];\n }\n if (props[\"p-y\"] !== undefined) {\n paddings[0] = props[\"p-y\"];\n paddings[2] = props[\"p-y\"];\n }\n if (props[\"p-x\"] !== undefined) {\n paddings[1] = props[\"p-x\"];\n paddings[3] = props[\"p-x\"];\n }\n if (props[\"p-top\"] !== undefined) {\n paddings[0] = props[\"p-top\"];\n }\n if (props[\"p-right\"] !== undefined) {\n paddings[1] = props[\"p-right\"];\n }\n if (props[\"p-bottom\"] !== undefined) {\n paddings[2] = props[\"p-bottom\"];\n }\n if (props[\"p-left\"] !== undefined) {\n paddings[3] = props[\"p-left\"];\n }\n widget.setPadding(paddings[0], paddings[1], paddings[2], paddings[3]);\n}\nfunction processTextProps(text, props) {\n switch (props.align) {\n case \"left\":\n text.leftAlignText();\n break;\n case \"right\":\n text.rightAlignText();\n break;\n case \"center\":\n text.centerAlignText();\n break;\n }\n if (props.font) {\n text.font = props.font;\n }\n if (props.color) {\n text.textColor = processJSXColor(props.color);\n }\n if (props.lineLimit) {\n text.lineLimit = props.lineLimit;\n }\n if (props.minimumScaleFactor) {\n text.minimumScaleFactor = props.minimumScaleFactor;\n }\n if (props.opacity) {\n text.textOpacity = props.opacity;\n }\n if (props.shadowColor) {\n text.shadowColor = processJSXColor(props.shadowColor);\n }\n if (props.shadowOffset) {\n text.shadowOffset = props.shadowOffset;\n }\n if (props.shadowRadius) {\n text.shadowRadius = props.shadowRadius;\n }\n if (props.url) {\n text.url = props.url;\n }\n}\nfunction processStackProps(stack, props) {\n switch (props.align) {\n case \"top\":\n stack.topAlignContent();\n break;\n case \"bottom\":\n stack.bottomAlignContent();\n break;\n case \"center\":\n stack.centerAlignContent();\n break;\n }\n if (props.backgroundColor) {\n stack.backgroundColor = processJSXColor(props.backgroundColor);\n }\n if (props.backgroundGradient) {\n stack.backgroundGradient = props.backgroundGradient;\n }\n if (props.backgroundImage) {\n stack.backgroundImage = props.backgroundImage;\n }\n if (props.borderColor) {\n stack.borderColor = processJSXColor(props.borderColor);\n }\n if (props.borderWidth) {\n stack.borderWidth = props.borderWidth;\n }\n if (props.cornerRadius) {\n stack.cornerRadius = props.cornerRadius;\n }\n if (props.size) {\n stack.size = props.size;\n }\n if (props.spacing) {\n stack.spacing = props.spacing;\n }\n if (props.url) {\n stack.url = props.url;\n }\n processPaddingProps(stack, props, [0, 0, 0, 0]);\n}\nfunction processImageProps(image, props) {\n switch (props.align) {\n case \"left\":\n image.leftAlignImage();\n break;\n case \"right\":\n image.rightAlignImage();\n break;\n case \"center\":\n image.centerAlignImage();\n break;\n }\n if (props.size) {\n image.imageSize = props.size;\n }\n if (props.borderColor) {\n image.borderColor = processJSXColor(props.borderColor);\n }\n if (props.borderWidth) {\n image.borderWidth = props.borderWidth;\n }\n if (props.containerRelativeShape) {\n image.containerRelativeShape = props.containerRelativeShape;\n }\n switch (props.contentMode) {\n case \"filling\":\n image.applyFillingContentMode();\n break;\n case \"fitting\":\n image.applyFittingContentMode();\n break;\n }\n if (props.cornerRadius) {\n image.cornerRadius = props.cornerRadius;\n }\n if (props.opacity) {\n image.imageOpacity = props.opacity;\n }\n if (props.resizable) {\n image.resizable = props.resizable;\n }\n if (props.tintColor) {\n image.tintColor = processJSXColor(props.tintColor);\n }\n if (props.url) {\n image.url = props.url;\n }\n}\nfunction processWidgetProps(widget, props) {\n if (props.backgroundColor) {\n widget.backgroundColor = processJSXColor(props.backgroundColor);\n }\n if (props.backgroundGradient) {\n widget.backgroundGradient = props.backgroundGradient;\n }\n if (props.backgroundImage) {\n widget.backgroundImage = props.backgroundImage;\n }\n if (props.refreshAfterDate) {\n widget.refreshAfterDate = props.refreshAfterDate;\n }\n if (props.spacing !== undefined) {\n widget.spacing = props.spacing;\n }\n if (props.url) {\n widget.url = props.url;\n }\n processPaddingProps(widget, props, [15, 15, 15, 15]);\n}\nfunction processContainerChildren(widget, children) {\n for (const child of children) {\n if (child === null || child === undefined || child === false ||\n typeof child === \"function\") {\n continue;\n }\n else if (typeof child === \"string\" || typeof child === \"number\" ||\n typeof child === \"bigint\" || typeof child === \"symbol\" || child === true) {\n widget.addText(String(child));\n }\n else if (typeof child === \"object\") {\n if (child instanceof Array) {\n processContainerChildren(widget, child);\n }\n else if (child instanceof Date) {\n widget.addDate(child);\n }\n else if (child instanceof Image) {\n widget.addImage(child);\n }\n else if (\"type\" in child) {\n switch (child.type) {\n case \"text\": {\n const text = widget.addText(child.text);\n processTextProps(text, child.props || {});\n break;\n }\n case \"date\": {\n const init = child.props.date;\n const date = widget.addDate(init instanceof Date ? init : new Date(init));\n switch (child.props.style) {\n case \"date\":\n date.applyDateStyle();\n break;\n case \"offset\":\n date.applyOffsetStyle();\n break;\n case \"time\":\n date.applyTimeStyle();\n break;\n case \"relative\":\n date.applyRelativeStyle();\n break;\n case \"timer\":\n date.applyTimerStyle();\n break;\n }\n processTextProps(date, child.props || {});\n break;\n }\n case \"stack\": {\n const stack = widget.addStack();\n switch (child?.props?.layout) {\n case \"vertical\":\n stack.layoutVertically();\n break;\n case \"horizontal\":\n stack.layoutHorizontally();\n break;\n }\n processStackProps(stack, child.props || {});\n processContainerChildren(stack, child.children);\n break;\n }\n case \"spacer\": {\n const spacer = widget.addSpacer();\n if (child?.props?.size) {\n spacer.length = child.props.length;\n }\n break;\n }\n case \"image\": {\n let init;\n const { data } = child.props || {};\n if (typeof data === \"string\") {\n init = Image.fromFile(child.props.fileURL);\n }\n else if (data.size) {\n init = data;\n }\n else {\n init = Image.fromData(data);\n }\n const image = widget.addImage(init);\n processImageProps(image, child.props || {});\n break;\n }\n }\n }\n }\n }\n}\nfunction widgetCreateElement(element, props, ...children) {\n if (typeof element === \"string\") {\n // intrinsic element\n switch (element) {\n case \"widget\":\n const widget = new ListWidget();\n processContainerChildren(widget, children);\n processWidgetProps(widget, props || {});\n return widget;\n case \"text\":\n let text = \"\";\n for (let child of children) {\n if (typeof child === \"string\" ||\n typeof child === \"number\" ||\n typeof child === \"bigint\") {\n text += child;\n }\n }\n return { type: element, props, text };\n case \"stack\":\n return { type: element, props, children };\n case \"spacer\":\n return { type: element, props };\n case \"image\":\n return { type: element, props };\n case \"date\":\n return { type: element, props };\n }\n }\n}\n\n// =============================\n// Alert JSX Element processing\n// =============================\nfunction processAlertProps(alert, props) {\n if (props.title) {\n alert.title = props.title;\n }\n if (props.message) {\n alert.message = props.message;\n }\n}\nfunction processActionProps(alert, props, children) {\n const text = props.text || (children instanceof Array ? children.join(\" \") : children) || \"\";\n switch (props.type || \"action\") {\n case \"action\": {\n alert.addAction(text);\n break;\n }\n case \"cancel\": {\n alert.addCancelAction(text);\n break;\n }\n case \"destructive\": {\n alert.addDestructiveAction(text);\n break;\n }\n }\n}\nfunction processTextFieldProps(alert, props) {\n (props.secure ? alert.addSecureTextField : alert.addTextField)(props.placeholder || \"\", props.text || \"\");\n}\nfunction processAlertChildren(alert, children) {\n for (const child of children) {\n if (typeof child === \"string\") {\n return child;\n }\n if (typeof child === \"object\") {\n if (child instanceof Array) {\n processAlertChildren(alert, child);\n }\n else if (\"type\" in child) {\n switch (child.type) {\n case \"action\": {\n processActionProps(alert, child.props || {}, child.children || \"\");\n break;\n }\n case \"text-field\": {\n processTextFieldProps(alert, child.props || {});\n break;\n }\n }\n }\n }\n }\n}\nfunction alertCreateElement(element, props, ...children) {\n if (typeof element === \"string\") {\n // intrinsic element\n switch (element) {\n case \"alert\":\n const alert = new Alert();\n processAlertChildren(alert, children);\n processAlertProps(alert, props || {});\n return alert;\n case \"text-field\":\n return { type: element, props };\n case \"action\":\n return { type: element, props, children };\n }\n }\n}\n\nconst createElements = [widgetCreateElement, alertCreateElement];\nclass ScriptableJSX {\n static createElement(element, props, ...children) {\n for (const creator of createElements) {\n const res = creator(element, props, ...children);\n if (res) {\n return res;\n }\n }\n }\n}\n\n// =========================================\n// Constants\n// =========================================\nconst HASS_URL = \"HASS_URL\";\nconst HASS_API_TOKEN = \"HASS_TOKEN\";\n// =========================================\n// Colors\n// =========================================\n// language=CSS prefix=*{color:# suffix=;}\nconst BackgroundColor = Color.dynamic(new Color('ccc', 1), new Color('1c1c1c', 1));\n// language=CSS prefix=*{color:# suffix=;}\nconst SecondBackgroundColor = Color.dynamic(new Color('000', .1), new Color('fff', .1));\n// language=CSS prefix=*{color:# suffix=;}\nconst SecondActiveBackgroundColor = Color.dynamic(new Color('000', .2), new Color('fff', .2));\n// language=CSS prefix=*{color:# suffix=;}\nconst TextColor = Color.dynamic(new Color('000', 1), new Color('fff', 1));\n// language=CSS prefix=*{color:# suffix=;}\nconst TransparentColor = new Color('000', 0);\n\nfunction baseUrl(baseUrl) {\n return baseUrl.replace(/\\/?$/, '');\n}\nfunction createRequest(url, token) {\n const req = new Request(url);\n req.headers = { 'Authorization': `Bearer ${token || Keychain.get(HASS_API_TOKEN)}` };\n return req;\n}\nasync function getAPIStatus(url, token) {\n const apiCheckUrl = (url ? baseUrl(url) : Keychain.get(HASS_URL)) + '/api/';\n const req = createRequest(apiCheckUrl, token);\n console.log(`Checking ${apiCheckUrl}`);\n const d = await req.loadString();\n console.log(`d: ${d}`);\n const data = JSON.stringify(JSON.parse(d));\n console.log(`data: ${data}`);\n return data === JSON.stringify({ \"message\": \"API running.\" });\n}\nasync function getAllStates() {\n return await createRequest(Keychain.get(HASS_URL) + '/api/states').loadJSON();\n}\nasync function getPersonStates() {\n const states = await getAllStates();\n return states.filter(state => state.entity_id.startsWith('person.'));\n}\nasync function getPicture(person) {\n return await createRequest(Keychain.get(HASS_URL) + person.attributes.entity_picture).loadImage();\n}\nfunction createPersonURL(person) {\n return `${Keychain.get(HASS_URL)}/history?entity_id=${person.entity_id}`;\n}\nasync function join(array, callback, separator) {\n if (!separator) {\n separator = ScriptableJSX.createElement(\"spacer\", null);\n }\n const result = [];\n const length = array.length;\n for (let i = 0; i < length; i++) {\n const value = array[i];\n const out = await callback(value);\n if (!out) {\n break;\n }\n result.push(out);\n if (i !== length - 1)\n result.push(separator);\n }\n return result;\n}\n\nfunction getKeychain(key) {\n if (Keychain.contains(key)) {\n return Keychain.get(key);\n }\n}\nasync function login(url, token) {\n let argsPassed = true;\n if (!(url && token)) {\n if (!(Keychain.contains(HASS_URL) && Keychain.contains(HASS_API_TOKEN))) {\n return false;\n }\n url = getKeychain(HASS_URL);\n token = getKeychain(HASS_API_TOKEN);\n argsPassed = false;\n }\n if (url && token) {\n const resp = await getAPIStatus(url, token);\n if (!resp) {\n return false;\n }\n if (config.runsInApp && argsPassed) {\n const a = (ScriptableJSX.createElement(\"alert\", { title: \"Login Successful!\", message: \"Now you can use the widget!\" },\n ScriptableJSX.createElement(\"action\", { type: \"cancel\" }, \"Close\")));\n await a.present();\n }\n console.log(`Success login!`);\n console.log(`${url}, ${token}`);\n Keychain.set(HASS_URL, baseUrl(url));\n Keychain.set(HASS_API_TOKEN, token);\n return true;\n }\n return false;\n}\nasync function loginMenu() {\n const a = (ScriptableJSX.createElement(\"alert\", { title: \"Login in HomeAssistant\" },\n ScriptableJSX.createElement(\"text-field\", { placeholder: 'Full URL to HomeAssistant URL', text: getKeychain(HASS_URL) }),\n ScriptableJSX.createElement(\"text-field\", { placeholder: 'HomeAssistant Token', text: getKeychain(HASS_URL), secure: true }),\n ScriptableJSX.createElement(\"action\", { type: \"cancel\" }, \"Cancel\"),\n ScriptableJSX.createElement(\"action\", null, \"Login\")));\n let res = await a.present();\n console.log(`${res}, ${a.textFieldValue(0)}, ${a.textFieldValue(1)}`);\n const [url, token] = [a.textFieldValue(0), a.textFieldValue(1)];\n return await login(url, token);\n}\n\n// =========================================\n// Widgets\n// =========================================\nasync function loginWidget() {\n return (ScriptableJSX.createElement(\"widget\", { backgroundColor: BackgroundColor },\n ScriptableJSX.createElement(\"text\", null, \"Before using Widget, please login!\")));\n}\nasync function personsWidget() {\n const persons = await getPersonStates();\n let personGrid = [];\n switch (displayMode()) {\n case \"small\":\n personGrid = [\n [persons[0], persons[1]],\n [persons[2], persons[3]],\n ];\n break;\n case \"medium\":\n personGrid = [\n [persons[0], persons[1], persons[2], persons[3]],\n ];\n break;\n case \"large\":\n case \"extraLarge\":\n personGrid = [\n [persons[0], persons[1], persons[2], persons[3]],\n [persons[4], persons[5], persons[6], persons[7]],\n [persons[8], persons[9], persons[10], persons[11]],\n [persons[12], persons[13], persons[14], persons[15]],\n ];\n break;\n }\n const widgetTitleText = {\n font: Font.title3(),\n color: TextColor,\n };\n const widgetTitle = FULL_CARD() ? [\n ScriptableJSX.createElement(\"stack\", { layout: \"horizontal\" },\n ScriptableJSX.createElement(\"text\", { ...widgetTitleText }, WIDGET_TITLE),\n ([\n ScriptableJSX.createElement(\"spacer\", null),\n ScriptableJSX.createElement(\"text\", { ...widgetTitleText },\n persons.filter(p => p.state === 'home').length,\n \"/\",\n persons.length)\n ])),\n ScriptableJSX.createElement(\"spacer\", null)\n ] : [];\n const jsxPersonsGrid = await join(personGrid, async (row) => (ScriptableJSX.createElement(\"stack\", { layout: \"horizontal\" }, await join(row, personCard))));\n return (ScriptableJSX.createElement(\"widget\", { backgroundColor: BackgroundColor },\n widgetTitle,\n ScriptableJSX.createElement(\"stack\", { layout: \"vertical\" }, jsxPersonsGrid)));\n}\nasync function personCard(person) {\n if (!person)\n return ScriptableJSX.createElement(\"stack\", null);\n const at_home = person.state === 'home';\n const opacity = at_home ? 1 : 0.5;\n const image = await getPicture(person);\n const fontSize = 14;\n const props = (FULL_CARD() ?\n {\n spacing: 5,\n 'p-all': 7,\n cornerRadius: 15,\n backgroundColor: at_home ? SecondActiveBackgroundColor : SecondBackgroundColor\n } : {});\n const friendlyName = (ScriptableJSX.createElement(\"stack\", { layout: \"horizontal\" },\n ScriptableJSX.createElement(\"spacer\", null),\n ScriptableJSX.createElement(\"text\", { color: TextColor, font: at_home ? Font.mediumSystemFont(fontSize) : Font.regularSystemFont(fontSize), opacity: opacity }, person.attributes.friendly_name),\n ScriptableJSX.createElement(\"spacer\", null)));\n return (ScriptableJSX.createElement(\"stack\", { layout: \"vertical\", ...props, url: createPersonURL(person) },\n ScriptableJSX.createElement(\"image\", { data: image, align: \"center\", opacity: opacity, cornerRadius: image.size.width / 2, borderWidth: 5, borderColor: at_home ? TextColor : TransparentColor, resizable: true }),\n FULL_CARD() && friendlyName));\n}\n\nlet loggedIn = await login();\nconst widget = loggedIn ? personsWidget : loginWidget;\nif (config.runsInWidget) {\n Script.setWidget(await widget());\n}\nelse if (config.runsInApp) {\n if (loggedIn) {\n // Showing all widgets\n displayMode('small');\n const widgetSmall = await widget();\n await widgetSmall.presentSmall();\n displayMode('medium');\n const widgetMedium = await widget();\n await widgetMedium.presentMedium();\n displayMode('large');\n const widgetLarge = await widget();\n await widgetLarge.presentLarge();\n displayMode('extraLarge');\n const widgetExtraLarge = await widget();\n await widgetExtraLarge.presentExtraLarge();\n }\n else {\n await loginMenu();\n }\n}\nScript.complete();"} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
1.0.1 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment