mirror of
https://github.com/mtan93/SmartThingsPublic.git
synced 2026-03-17 21:03:30 +00:00
MSA-1354: This submission adds LaMetric Time (smart display) support to SmartThings. It allows to display notifications on LaMetric when any event occurs inside a smart home.
Would be great to have the device listed in Displays or similar category. Device info: http://lametric.com
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* Lametric Notifier
|
||||
*
|
||||
* Copyright 2016 Smart Atoms Ltd.
|
||||
* Author: Mykola Kirichuk
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
|
||||
* in compliance with the License. You may obtain a copy of the License at:
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
|
||||
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
|
||||
* for the specific language governing permissions and limitations under the License.
|
||||
*
|
||||
*/
|
||||
import groovy.json.JsonOutput
|
||||
|
||||
definition(
|
||||
name: "LaMetric Notifier",
|
||||
namespace: "com.lametric",
|
||||
author: "Mykola Kirichuk",
|
||||
description: "Notify about changes with sound and message on your LaMetric",
|
||||
category: "Fun & Social",
|
||||
iconUrl: "https://developer.lametric.com/assets/smart_things/weather_60.png",
|
||||
iconX2Url: "https://developer.lametric.com/assets/smart_things/weather_120.png",
|
||||
iconX3Url: "https://developer.lametric.com/assets/smart_things/weather_120.png")
|
||||
|
||||
|
||||
preferences {
|
||||
page(name: "mainPage", title: "Show a message on your LaMetric when something happens", install: true, uninstall: true)
|
||||
page(name: "timeIntervalInput", title: "Only during a certain time") {
|
||||
section {
|
||||
input "starting", "time", title: "Starting", required: false
|
||||
input "ending", "time", title: "Ending", required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
def getSoundList() {
|
||||
[
|
||||
"none":"No Sound",
|
||||
"car" : "Car",
|
||||
"cash" : "Cash Register",
|
||||
"cat" : "Cat Meow",
|
||||
"dog" : "Dog Bark",
|
||||
"dog2" : "Dog Bark 2",
|
||||
"letter_email" : "The mail has arrived",
|
||||
"knock-knock" : "Knocking Sound",
|
||||
"bicycle" : "Bicycle",
|
||||
"negative1" : "Negative 1",
|
||||
"negative2" : "Negative 2",
|
||||
"negative3" : "Negative 3",
|
||||
"negative4" : "Negative 4",
|
||||
"negative5" : "Negative 5",
|
||||
"lose1" : "Lose 1",
|
||||
"lose2" : "Lose 2",
|
||||
"energy" : "Energy",
|
||||
"water1" : "Water 1",
|
||||
"water2" : "Water 2",
|
||||
"notification" : "Notification 1",
|
||||
"notification2" : "Notification 2",
|
||||
"notification3" : "Notification 3",
|
||||
"notification4" : "Notification 4",
|
||||
"open_door" : "Door unlocked",
|
||||
"win" : "Win",
|
||||
"win2" : "Win 2",
|
||||
"positive1" : "Positive 1",
|
||||
"positive2" : "Positive 2",
|
||||
"positive3" : "Positive 3",
|
||||
"positive4" : "Positive 4",
|
||||
"positive5" : "Positive 5",
|
||||
"positive6" : "Positive 6",
|
||||
"statistic" : "Page turning",
|
||||
"wind" : "Wind",
|
||||
"wind_short" : "Small Wind",
|
||||
]
|
||||
}
|
||||
|
||||
def getControlToAttributeMap(){
|
||||
[
|
||||
"motion": "motion.active",
|
||||
"contact": "contact.open",
|
||||
"contactClosed": "contact.close",
|
||||
"acceleration": "acceleration.active",
|
||||
"mySwitch": "switch.on",
|
||||
"mySwitchOff": "switch.off",
|
||||
"arrivalPresence": "presence.present",
|
||||
"departurePresence": "presence.not present",
|
||||
"smoke": "smoke.detected",
|
||||
"smoke1": "smoke.tested",
|
||||
"water": "water.wet",
|
||||
"button1": "button.pushed",
|
||||
"triggerModes": "mode",
|
||||
"timeOfDay": "time",
|
||||
]
|
||||
}
|
||||
|
||||
def getPriorityList(){
|
||||
[
|
||||
"warning":"Not So Important (may be ignored at night)",
|
||||
"critical": "Very Important"
|
||||
]
|
||||
}
|
||||
|
||||
def getIconsList(){
|
||||
state.icons = state.icons?:["1":"default"]
|
||||
}
|
||||
|
||||
|
||||
def getIconLabels() {
|
||||
state.iconLabels = state.iconLabels?:["1":"Default Icon"]
|
||||
}
|
||||
|
||||
def getSortedIconLabels() {
|
||||
state.iconLabels = state.iconLabels?:["1":"Default Icon"]
|
||||
state.iconLabels.sort {a,b -> a.key.toInteger() <=> b.key.toInteger()};
|
||||
}
|
||||
def getLametricHost() { "https://developer.lametric.com" }
|
||||
def getDefaultIconData() { """data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAe0lEQVQYlWNUVFBgYGBgYNi6bdt/BiTg7eXFyMDAwMCELBmz7z9DzL7/KBoYr127BpeEgbV64QzfRVYxMDAwMLAgSy5xYoSoeMPAwPkmjOG7yCqIgjf8WVC90xnQAdwKj7OZcMGD8m/hVjDBXLvDGKEbJunt5cXISMibAF0FMibYF7nMAAAAAElFTkSuQmCC""" }
|
||||
|
||||
def mainPage() {
|
||||
def iconRequestOptions = [headers: ["Accept": "application/json"],
|
||||
uri: "${lametricHost}/api/v2/icons", query:["fields":"id,title,type,code"]];
|
||||
|
||||
def icons = getIconsList();
|
||||
def iconLabels = getIconLabels();
|
||||
if (icons?.size() <= 2)
|
||||
{
|
||||
log.debug iconRequestOptions
|
||||
try {
|
||||
httpGet(iconRequestOptions) { resp ->
|
||||
int i = 2;
|
||||
resp.data.data.each(){
|
||||
def iconId = it?.id
|
||||
def iconType = it?.type
|
||||
def prefix = "i"
|
||||
if (iconId)
|
||||
{
|
||||
if (iconType == "movie")
|
||||
{
|
||||
prefix = "a"
|
||||
}
|
||||
def iconurl = "${lametricHost}/content/apps/icon_thumbs/${prefix}${iconId}_icon_thumb_big.png";
|
||||
icons["$i"] = it.code
|
||||
iconLabels["$i"] = it.title
|
||||
} else {
|
||||
log.debug "wrong id"
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
} catch (e)
|
||||
{
|
||||
log.debug "fail ${e}";
|
||||
}
|
||||
}
|
||||
dynamicPage(name: "mainPage") {
|
||||
def anythingSet = anythingSet()
|
||||
def notificationMessage = defaultNotificationMessage();
|
||||
log.debug "set $anythingSet"
|
||||
if (anythingSet) {
|
||||
section("Show message when"){
|
||||
ifSet "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true , submitOnChange:true
|
||||
ifSet "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production
|
||||
ifSet "triggerModes", "mode", title: "System Changes Mode", required: false, multiple: true, submitOnChange:true
|
||||
ifSet "timeOfDay", "time", title: "At a Scheduled Time", required: false, submitOnChange:true
|
||||
}
|
||||
}
|
||||
def hideable = anythingSet || app.installationState == "COMPLETE"
|
||||
def sectionTitle = anythingSet ? "Select additional triggers" : "Show message when..."
|
||||
|
||||
section(sectionTitle, hideable: hideable, hidden: true){
|
||||
ifUnset "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "button1", "capability.button", title: "Button Press", required:false, multiple:true //remove from production
|
||||
ifUnset "triggerModes", "mode", title: "System Changes Mode", description: "Select mode(s)", required: false, multiple: true, submitOnChange:true
|
||||
ifUnset "timeOfDay", "time", title: "At a Scheduled Time", required: false, submitOnChange:true
|
||||
}
|
||||
|
||||
section (title:"Select LaMetrics"){
|
||||
input "selectedDevices", "capability.notification", required: true, multiple:true
|
||||
}
|
||||
section (title: "Configure message"){
|
||||
input "defaultMessage", "bool", title: "Use Default Text:\n\"$notificationMessage\"", required: false, defaultValue: true, submitOnChange:true
|
||||
def showMessageInput = (settings["defaultMessage"] == null || settings["defaultMessage"] == true) ? false : true;
|
||||
if (showMessageInput)
|
||||
{
|
||||
input "customMessage","text",title:"Use Custom Text", defaultValue:"", required:false, multiple: false
|
||||
}
|
||||
input "selectedIcon", "enum", title: "With Icon", required: false, multiple: false, defaultValue:"1", options: getSortedIconLabels()
|
||||
input "selectedSound", "enum", title: "With Sound", required: true, defaultValue:"none" , options: soundList
|
||||
input "showPriority", "enum", title: "Is This Notification Very Important?", required: true, multiple:false, defaultValue: "warning", options: priorityList
|
||||
}
|
||||
section("More options", hideable: true, hidden: true) {
|
||||
href "timeIntervalInput", title: "Only during a certain time", description: timeLabel ?: "Tap to set", state: timeLabel ? "complete" : "incomplete"
|
||||
input "days", "enum", title: "Only on certain days of the week", multiple: true, required: false,
|
||||
options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
if (settings.modes) {
|
||||
input "modes", "mode", title: "Only when mode is", multiple: true, required: false
|
||||
}
|
||||
}
|
||||
section([mobileOnly:true]) {
|
||||
label title: "Assign a name", required: false
|
||||
mode title: "Set for specific mode(s)", required: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private songOptions() {
|
||||
log.trace "song option"
|
||||
// Make sure current selection is in the set
|
||||
|
||||
def options = new LinkedHashSet()
|
||||
if (state.selectedSong?.station) {
|
||||
options << state.selectedSong.station
|
||||
}
|
||||
else if (state.selectedSong?.description) {
|
||||
// TODO - Remove eventually? 'description' for backward compatibility
|
||||
options << state.selectedSong.description
|
||||
}
|
||||
|
||||
// Query for recent tracks
|
||||
def states = sonos.statesSince("trackData", new Date(0), [max:30])
|
||||
def dataMaps = states.collect{it.jsonValue}
|
||||
options.addAll(dataMaps.collect{it.station})
|
||||
|
||||
log.trace "${options.size()} songs in list"
|
||||
options.take(20) as List
|
||||
}
|
||||
|
||||
private anythingSet() {
|
||||
for (it in controlToAttributeMap) {
|
||||
log.debug ("key ${it.key} value ${settings[it.key]} ${settings[it.key]?true:false}")
|
||||
if (settings[it.key]) {
|
||||
log.debug constructMessageFor(it.value, settings[it.key])
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
def defaultNotificationMessage(){
|
||||
def message = "";
|
||||
for (it in controlToAttributeMap) {
|
||||
if (settings[it.key]) {
|
||||
message = constructMessageFor(it.value, settings[it.key])
|
||||
break;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
def constructMessageFor(group, device)
|
||||
{
|
||||
log.debug ("$group $device")
|
||||
def message;
|
||||
def firstDevice;
|
||||
if (device instanceof List)
|
||||
{
|
||||
firstDevice = device[0];
|
||||
} else {
|
||||
firstDevice = device;
|
||||
}
|
||||
switch(group)
|
||||
{
|
||||
case "motion.active":
|
||||
message = "Motion detected by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "contact.open":
|
||||
message = "Openning detected by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "contact.closed":
|
||||
message = "Closing detected by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "acceleration.active":
|
||||
message = "Acceleration detected by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "switch.on":
|
||||
message = "$firstDevice.displayName turned on at $location.name"
|
||||
break;
|
||||
case "switch.off":
|
||||
message = "$firstDevice.displayName turned off at $location.name"
|
||||
break;
|
||||
case "presence.present":
|
||||
message = "$firstDevice.displayName detected arrival at $location.name"
|
||||
break;
|
||||
case "presence.not present":
|
||||
message = "$firstDevice.displayName detected departure at $location.name"
|
||||
break;
|
||||
case "smoke.detected":
|
||||
message = "Smoke detected by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "smoke.tested":
|
||||
message = "Smoke tested by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "water.wet":
|
||||
message = "Dampness detected by $firstDevice.displayName at $location.name"
|
||||
break;
|
||||
case "button.pushed":
|
||||
message = "$firstDevice.displayName pushed at $location.name"
|
||||
break;
|
||||
case "time":
|
||||
break;
|
||||
// case "mode":
|
||||
// message = "Mode changed to ??? at $location.name"
|
||||
break;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
private ifUnset(Map options, String name, String capability) {
|
||||
if (!settings[name]) {
|
||||
input(options, name, capability)
|
||||
}
|
||||
}
|
||||
|
||||
private ifSet(Map options, String name, String capability) {
|
||||
if (settings[name]) {
|
||||
input(options, name, capability)
|
||||
}
|
||||
}
|
||||
|
||||
def installed() {
|
||||
|
||||
log.debug "Installed with settings: ${settings}"
|
||||
subscribeToEvents()
|
||||
}
|
||||
|
||||
def updated() {
|
||||
log.debug "Updated with settings: ${settings}"
|
||||
unsubscribe()
|
||||
unschedule()
|
||||
subscribeToEvents()
|
||||
}
|
||||
|
||||
def subscribeToEvents() {
|
||||
log.trace "subscribe to events"
|
||||
log.debug "${contact} ${contactClosed} ${mySwitch} ${mySwitchOff} ${acceleration}${arrivalPresence} ${button1}"
|
||||
// subscribe(app, appTouchHandler)
|
||||
subscribe(contact, "contact.open", eventHandler)
|
||||
subscribe(contactClosed, "contact.closed", eventHandler)
|
||||
subscribe(acceleration, "acceleration.active", eventHandler)
|
||||
subscribe(motion, "motion.active", eventHandler)
|
||||
subscribe(mySwitch, "switch.on", eventHandler)
|
||||
subscribe(mySwitchOff, "switch.off", eventHandler)
|
||||
subscribe(arrivalPresence, "presence.present", eventHandler)
|
||||
subscribe(departurePresence, "presence.not present", eventHandler)
|
||||
subscribe(smoke, "smoke.detected", eventHandler)
|
||||
subscribe(smoke, "smoke.tested", eventHandler)
|
||||
subscribe(smoke, "carbonMonoxide.detected", eventHandler)
|
||||
subscribe(water, "water.wet", eventHandler)
|
||||
subscribe(button1, "button.pushed", eventHandler)
|
||||
|
||||
if (triggerModes) {
|
||||
subscribe(location, modeChangeHandler)
|
||||
}
|
||||
|
||||
if (timeOfDay) {
|
||||
schedule(timeOfDay, scheduledTimeHandler)
|
||||
}
|
||||
}
|
||||
|
||||
def eventHandler(evt) {
|
||||
log.trace "eventHandler(${evt?.name}: ${evt?.value})"
|
||||
def name = evt?.name;
|
||||
def value = evt?.value;
|
||||
|
||||
if (allOk) {
|
||||
log.trace "allOk"
|
||||
// def lastTime = state[frequencyKey(evt)]
|
||||
// if (oncePerDayOk(lastTime)) {
|
||||
/*
|
||||
if (frequency) {
|
||||
if (lastTime == null || now() - lastTime >= frequency * 60000) {
|
||||
takeAction(evt)
|
||||
}
|
||||
else {
|
||||
log.debug "Not taking action because $frequency minutes have not elapsed since last action"
|
||||
}
|
||||
/* }
|
||||
else {*/
|
||||
takeAction(evt)
|
||||
// }
|
||||
}
|
||||
else {
|
||||
log.debug "Not taking action because it was already taken today"
|
||||
}
|
||||
}
|
||||
def modeChangeHandler(evt) {
|
||||
log.trace "modeChangeHandler $evt.name: $evt.value ($triggerModes)"
|
||||
if (evt?.value in triggerModes) {
|
||||
eventHandler(evt)
|
||||
}
|
||||
}
|
||||
|
||||
def scheduledTimeHandler() {
|
||||
eventHandler(null)
|
||||
}
|
||||
|
||||
def appTouchHandler(evt) {
|
||||
takeAction(evt)
|
||||
}
|
||||
|
||||
private takeAction(evt) {
|
||||
|
||||
log.trace "takeAction()"
|
||||
def messageToShow
|
||||
if (defaultMessage)
|
||||
{
|
||||
messageToShow = constructMessageFor("${evt.name}.${evt.value}", evt.device);
|
||||
} else {
|
||||
messageToShow = customMessage;
|
||||
}
|
||||
if (messageToShow)
|
||||
{
|
||||
log.debug "text ${messageToShow}"
|
||||
def notification = [:];
|
||||
def frame1 = [:];
|
||||
frame1.text = messageToShow;
|
||||
if (selectedIcon != "1")
|
||||
{
|
||||
frame1.icon = state.icons[selectedIcon];
|
||||
} else {
|
||||
frame1.icon = defaultIconData;
|
||||
}
|
||||
def soundId = sound;
|
||||
def sound = [:];
|
||||
sound.id = selectedSound;
|
||||
sound.category = "notifications";
|
||||
def frames = [];
|
||||
frames << frame1;
|
||||
def model = [:];
|
||||
model.frames = frames;
|
||||
if (selectedSound != "none")
|
||||
{
|
||||
model.sound = sound;
|
||||
}
|
||||
notification.model = model;
|
||||
notification.priority = showPriority;
|
||||
def serializedData = new JsonOutput().toJson(notification);
|
||||
|
||||
selectedDevices.each { lametricDevice ->
|
||||
log.trace "send notification to ${lametricDevice} ${serializedData}"
|
||||
lametricDevice.deviceNotification(serializedData)
|
||||
}
|
||||
} else {
|
||||
log.debug "No message to show"
|
||||
}
|
||||
|
||||
log.trace "Exiting takeAction()"
|
||||
}
|
||||
|
||||
private frequencyKey(evt) {
|
||||
"lastActionTimeStamp"
|
||||
}
|
||||
|
||||
private dayString(Date date) {
|
||||
def df = new java.text.SimpleDateFormat("yyyy-MM-dd")
|
||||
if (location.timeZone) {
|
||||
df.setTimeZone(location.timeZone)
|
||||
}
|
||||
else {
|
||||
df.setTimeZone(TimeZone.getTimeZone("America/New_York"))
|
||||
}
|
||||
df.format(date)
|
||||
}
|
||||
|
||||
private oncePerDayOk(Long lastTime) {
|
||||
def result = true
|
||||
if (oncePerDay) {
|
||||
result = lastTime ? dayString(new Date()) != dayString(new Date(lastTime)) : true
|
||||
log.trace "oncePerDayOk = $result"
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// TODO - centralize somehow
|
||||
private getAllOk() {
|
||||
modeOk && daysOk && timeOk
|
||||
}
|
||||
|
||||
private getModeOk() {
|
||||
def result = !modes || modes.contains(location.mode)
|
||||
log.trace "modeOk = $result"
|
||||
result
|
||||
}
|
||||
|
||||
private getDaysOk() {
|
||||
def result = true
|
||||
if (days) {
|
||||
def df = new java.text.SimpleDateFormat("EEEE")
|
||||
if (location.timeZone) {
|
||||
df.setTimeZone(location.timeZone)
|
||||
}
|
||||
else {
|
||||
df.setTimeZone(TimeZone.getTimeZone("America/New_York"))
|
||||
}
|
||||
def day = df.format(new Date())
|
||||
result = days.contains(day)
|
||||
}
|
||||
log.trace "daysOk = $result"
|
||||
result
|
||||
}
|
||||
|
||||
private getTimeOk() {
|
||||
def result = true
|
||||
if (starting && ending) {
|
||||
def currTime = now()
|
||||
def start = timeToday(starting, location?.timeZone).time
|
||||
def stop = timeToday(ending, location?.timeZone).time
|
||||
result = start < stop ? currTime >= start && currTime <= stop : currTime <= stop || currTime >= start
|
||||
}
|
||||
log.trace "timeOk = $result"
|
||||
result
|
||||
}
|
||||
|
||||
private hhmm(time, fmt = "h:mm a")
|
||||
{
|
||||
def t = timeToday(time, location.timeZone)
|
||||
def f = new java.text.SimpleDateFormat(fmt)
|
||||
f.setTimeZone(location.timeZone ?: timeZone(time))
|
||||
f.format(t)
|
||||
}
|
||||
|
||||
private getTimeLabel()
|
||||
{
|
||||
(starting && ending) ? hhmm(starting) + "-" + hhmm(ending, "h:mm a z") : ""
|
||||
}
|
||||
Reference in New Issue
Block a user