[RELEASE] Echo Speaks V4

I disagree!

I’m running OMV 6.x (Sata SSD for the OS) on my ancient HP MicroServer with 8GB ECC Ram and the CPU is a dual core 1.4Ghz AMD Turion N40L.

This server is running my 12TB raid array that contains all of my files and media + Docker and Portainer and 8 total containers.

Frankly, this server would have trouble pulling the skin off custard, yet it’s not being over stressed at all.

I wouldn’t recommend running something like Plex on this type of config (I have a dedicated Plex server, but the media is on my MicroServer), but most other services run just fine.

1 Like

Even a RPi 4 would't be enough for low demand CPU apps?

Evidently high usage CPU apps would not fit in it - well, why not a second RPi Companion (one more requirement to add to my long list: multiple RPi ... :laughing:).

For lightweight docker apps, even a Pi 3 would be adequate.

2 Likes

maybe discuss this somewhere else so those of us interested in ES4 can watch the thread without needless alerts?

2 Likes

@dJOS Derek, I think you just agreed with me. Also, OMV is not really designed as an application server. I am trying to promote the power and efficiency of LXD containers over any VM solution. At the same time, using an old dual core server or a RPi isn't going to give you a great experience overall. Plex is an interesting use case because it crosses the boundaries of requiring lots of storage for media content yet providing enough CPU to transcode up to 4k videos.

Typically NAS systems are woefully underpowered for anything CPU intensive such as transcoding. A small server like the Minisforum UM350 that I have been recommending has plenty of CPU and good memory expansion for most folks. Where it falls short is in storage connectivity.

I am impressed my the UM350 because it scales to 64GB of memory for cheap. Also, I only need a TB or 2 to host containers unlike hosting something like Plex. This thread is about locally hosted server instances to support callable functions from HE. I wanted folks to know that LXD containers and docker nested in LXD is a superior performance model on a really inexpensive device not much more than the RPi.

So, I like 1 Litre servers and even old data center servers. However, I shy away from them in regards to power consumption and heat generation. RPi's flirt with the "way too small" end of the spectrum to provide expansion options.

It's good when someone has an older box to run OMV, Proxmox, or the open source VMWare option. That being said, those options still prominently feature VMs and not containers. LXD just gives so much incredible bang for the buck and it doesn't tie you into an ecosystem like Proxmox.

Bottom line is that HE owners will need supplemental processing power as time goes on and understanding containers running on a local host will go a long way to a satisfactory solution.

2 Likes

Success!!!

I followed what @ jtp10181 posted above again, and now all is good. The Container still says "localhost:8091" but the Image is working with the proper IP Address.

The cookie was successfully refreshed last night.

1 Like

Anybody able to help. My Echo Dots are showing "Announcements" & "Text-to-Speech" as "False" and so none of those functions work.

The "Music" stuff does work though, but not my use case.

So another cookie refresh was supposed to happen today.

My announcements are still working, but my Echo Speaks says this:

image

Does anyone know why it would say 364 days??

Never mind.

Now it says this:

image

Weird.

Hi, I have just added an Echo dot version 5 and I have the same showing next to Announcements & Text-to-Speech, what Echo Dot version do you have @cknickl ? Maybe these have not been added as yet?

Echo5

How'd you get it to refresh, mine hasn't refreshed in 22 days. Everything is online and works fine. Not sure if I should touch anything.

It would really be useful to have a tile for ES Status - I attempted just to use notification (the built in feature) I had never used before - it was a tad clunky. You can send notification - but there isn't any dedicated tile child I can grab onto for some of the important stuff to monitor.
My first problem was white on white text within the html values:
What is on my screen is:
image

The actual text (gained by highlighting over the text)

Current States

  • last5 : Echo Speaks Cookie Refresh: Amazon Cookie Refresh FAILED 408 16Dec2022 07:48
    Info: Notification Test Successful. Notifications Enabled for Echo Speaks 16Dec2

If we had an ambitious person create a tile Ala @thebearmay Hub info that reported certain basic states and status it would be highly useful! I'm thinking:
Last update status
Next Update scheduled
current cookie status
Server type (heroku vs local - determined by IP maybe?)
Server responding

I'd put that tile right next to my top 3 text tiles I keep on screen all day. :slight_smile: I'd do it but I don't have the time right now.

1 Like

Leave it be. Mine says it hasn't refreshed in over 90 days and it works without issue.

At this point, I'm fairly certain that the cookie refresh may not even be needed.

1 Like

I'm using Docker Desktop for Windows 11.

I was having issues with the Image and the Container pointing to the wrong server address. They were showing "localhost:8091" instead of the IP address.

I followed @jpt10181 's post above:

This worked for me.

For my post above (about the 364 days), I must have looked at it during a refresh and saw the goofiness I posted above. Once I left and went back to the page, it said "19 minutes ago"....

Some rough code for a device driver that may work:

Rough Driver Code for Summary Tile
 /*

 */

import java.text.SimpleDateFormat
import groovy.json.JsonSlurper
import groovy.transform.Field
@Field static final String okSymFLD       = "\u2713"
@Field static final String notOkSymFLD    = "<span style='color:red'>\u2715</span>"
@Field static final String sBLANK         = ''



@SuppressWarnings('unused')
static String version() {return "0.0.1"}

metadata {
    definition (
        name: "ES Tile", 
        namespace: "thebearmay", 
        author: "Jean P. May, Jr.",
        importUrl:"https://raw.githubusercontent.com/thebearmay/hubitat/main/xxxx.groovy"
    ) {
        capability "Actuator"
        capability "Refresh"
        
        attribute "cookieRefreshDays", "number"
        attribute "serverData","string"
        attribute "cookieData","string"
        attribute "csrf","string"
        attribute "amazonDomain","string"
        attribute "tm2NewAtRfrsh", "string"
        attribute "tmFromAtRrsh", "string"
        
        attribute "html","string"
        attribute "htmlAlt", "string"
       
        //command "processPage"
        command "refreshHTML"
    }   
}

preferences {
    input("security", "bool", title: "Hub Security Enabled", defaultValue: false, submitOnChange: true, width:4)
    if (security) { 
        input("username", "string", title: "Hub Security Username", required: false, width:4)
        input("password", "password", title: "Hub Security Password", required: false, width:4)
    }
    input("pollRate","number", title:"Poll rate (in minutes) Disable:0):", defaultValue:720, submitOnChange:true, width:4)

}

@SuppressWarnings('unused')
def installed() {

}
void updateAttr(String aKey, aValue, String aUnit = ""){
    sendEvent(name:aKey, value:aValue, unit:aUnit)
}

void refresh(){
    processPage()
    refreshHTML()
    if(pollRate == null)
        device.updateSetting("pollRate",[value:720,type:"number"])   
    if(pollRate > 0) {
        runIn(pollRate*60,"refresh")
    }

}

def updated(){
    if(pollRate == null)
        device.updateSetting("pollRate",[value:720,type:"number"])   
    if(pollRate > 0) {
        runIn(pollRate*60,"refresh")
    }
}
void processPage(){
    app = findPage()
    if(app==-1) {
        log.error "Echo Speaks not Installed"
        return
    }
    pData=readPage("http://127.0.0.1:8080/installedapp/status/$app")
    dWork = pData.substring(pData.indexOf('refreshCookieDays'),pData.indexOf('refreshCookieDays')+500)
    dWork.replace('<','')
    dWork=dWork.split(' ')
    dWork.each{
        if(it.isNumber()) updateAttr("cookieRefreshDays",it.toInteger())
    }
    dWork = pData.substring(pData.indexOf('serverDataMap'),pData.indexOf('serverDataMap')+800)
    dWork = dWork.substring(dWork.indexOf('{'),dWork.indexOf('}')+1)
   
    createServerMap(dWork)
    if(pData.indexOf("cookieData") >-1){
        updateAttr("cookieData",true)
        if(pData.indexOf("csrf") > -1)
            updateAttr("csrf", true)
    }
    dWork = pData.substring(pData.indexOf('amazonDomain'),pData.indexOf('amazonDomain')+300)
    dWork.replace('<','')
    dWork=dWork.split(' ')
    dWork.each{
        if(it.contains(".")) updateAttr("amazonDomain",it.trim())
    }

}

Integer findPage(){

	def params = [
		uri: "http://127.0.0.1:8080/installedapp/list",
		textParser: true
	  ]
	
	def allAppsList = []
    def allAppNames = []
	try {
		httpGet(params) { resp ->    
			def matcherText = resp.data.text.replace("\n","").replace("\r","")
			def matcher = matcherText.findAll(/(<tr class="app-row" data-app-id="[^<>]+">.*?<\/tr>)/).each {
				def allFields = it.findAll(/(<td .*?<\/td>)/) // { match,f -> return f } 
				def id = it.find(/data-app-id="([^"]+)"/) { match,i -> return i.trim() }
				def title = allFields[0].find(/data-order="([^"]+)/) { match,t -> return t.trim() }
				allAppsList += [id:id,title:title]
                allAppNames << title
			}

		}
	} catch (e) {
		log.error "Error retrieving installed apps: ${e}"
        log.error(getExceptionMessageWithLine(e))
	}
    
    for(i=0;i < allAppsList.size();i++) { 
        if(allAppsList[i].title == 'Echo Speaks') {
            //log.debug "Found it"
            return allAppsList[i].id.toInteger()
            break
        }
    }
    return -1
}

void createServerMap(sData){
    sWork = sData.replace("&#x3D;",'\":\"')
    sWork = sWork.replace(', ','","')
    sWork = sWork.replace('{','{\"')
    sWork = sWork.replace('}','\"}') 
    updateAttr("serverData", sWork)
}

void refreshHTML(){
    Long tNow = new Date().getTime()
    def jSlurp = new JsonSlurper()
    serverData = jSlurp.parseText(device.currentValue("serverData",true))
    nextCookieRefreshDur()
    wkStr = "<table style='color:mediumblue;font-size:small'><tr><th>Auth Status: "
    if(device.currentValue("csrf",true) == "true" && device.currentValue("cookieData",true) == "true")
       wkStr+=okSymFLD
    else
        wkStr+=notOkSymFLD
    wkStr+="</th></tr><tr><td>&nbsp;&nbsp;Cookie: "
    if(device.currentValue("cookieData",true) == "true")
       wkStr+=okSymFLD
    else
        wkStr+=notOkSymFLD
    wkStr+="</td></tr><tr><td>&nbsp;&nbsp;CSRF: "
    if(device.currentValue("csrf",true) == "true")
       wkStr+=okSymFLD
    else
        wkStr+=notOkSymFLD
    wkStr+="</td></tr><tr><th>Cookie Data</th></tr>"
    wkStr2 = wkStr
    wkStr+="<tr><td>Last Refresh: ${serverData.lastCookieRrshDt}</td></tr>"
    startDate = Date.parse("E MMM dd HH:mm:ss z yyyy", serverData.lastCookieRrshDt).getTime()
    nextDate = startDate + (86400000 * device.currentValue("cookieRefreshDays").toInteger())
    //log.debug "$tNow $nextDate"
    SimpleDateFormat sdf = new SimpleDateFormat("E MMM dd HH:mm:ss z yyyy")
    if(nextDate > tNow){
        wkStr+="<tr><td>Next Refresh: ${sdf.format(nextDate)}</td></tr>"
    } else {
        wkStr+="<tr><td style='color:red;font-weight:bold'>Missed Refresh: ${sdf.format(nextDate)}</td></tr>"
    }
    wkStr+="</td></tr><tr><th>Server Data</th></tr>"
    wkStr+="<tr><td>Heroku: "
    if(serverData.onHeroku == "true")
       wkStr+=okSymFLD
    else
        wkStr+=notOkSymFLD
    wkStr+="</td></tr><tr><td>Local Server: "
    if(serverData.isLocal == "true")
       wkStr+=okSymFLD
    else
        wkStr+=notOkSymFLD
    wkStr+="</td></tr><tr><td>Server IP: ${serverData.serverHost}</td></tr>"
    wkStr+="<tr><td>Domain: ${device.currentValue("amazonDomain",true)}</td></tr>"
    
    wkStr+="</table>"
    updateAttr("html",wkStr)
    
    wkStr2+="<tr><td>Last Refresh: ${device.currentValue("tmFromAtRrsh",true)} ago</td></tr>"
    if(nextDate > tNow){
        wkStr2+="<tr><td>Next Refresh: ${device.currentValue("tm2NewAtRfrsh")}</td></tr>"
    } else {
        wkStr2+="<tr><td style='color:red;font-weight:bold'>Missed Refresh: ${sdf.format(nextDate)}</td></tr>"
    }
    wkStr2+="</td></tr><tr><th>Server Data</th></tr>"
    wkStr2+="<tr><td>Heroku: "
    if(serverData.onHeroku == "true")
       wkStr2+=okSymFLD
    else
        wkStr2+=notOkSymFLD
    wkStr2+="</td></tr><tr><td>Local Server: "
    if(serverData.isLocal == "true")
       wkStr2+=okSymFLD
    else
        wkStr2+=notOkSymFLD
    wkStr2+="</td></tr><tr><td>Server IP: ${serverData.serverHost}</td></tr>"
    wkStr2+="<tr><td>Domain: ${device.currentValue("amazonDomain",true)}</td></tr>"
    wkStr2+="</table>"
    updateAttr("htmlAlt",wkStr2)
    
}

String nextCookieRefreshDur() {
    Long tNow = new Date().getTime()
    def jSlurp = new JsonSlurper()
    serverData = jSlurp.parseText(device.currentValue("serverData",true))
    Integer days = device.currentValue("cookieRefreshDays").toInteger()
    String lastCookieRfsh = serverData.lastCookieRrshDt
    if(!lastCookieRfsh) { return "Not Sure"}
    Date lastDt = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(Date.parse("E MMM dd HH:mm:ss z yyyy", lastCookieRfsh)))   
                                                                             
    String dMinus = seconds2Duration(((tNow-lastDt.getTime())/1000).toInteger(),false,3)
    updateAttr("tmFromAtRrsh", dMinus)                                                                        
                                                                             
    Date nextDt = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(lastDt + days))
    Integer diff = ( (nextDt.getTime() - wnow()) / 1000) as Integer
    String dur = seconds2Duration(diff, false, 3)
    // log.debug "now: ${now} | lastDt: ${lastDt} | nextDt: ${nextDt} | Days: $days | Wait: $diff | Dur: ${dur}"
    updateAttr("tm2NewAtRfrsh", dur)
}

String formatDt(Date dt, Boolean tzChg=true) {
    def tf = new SimpleDateFormat("E MMM dd HH:mm:ss z yyyy")
    if(tzChg) { if(location.timeZone) { tf.setTimeZone((TimeZone)location?.timeZone) } }
    return (String)tf.format(dt)
}

private Long wnow(){ return (Long)now() }

@SuppressWarnings('GroovyAssignabilityCheck')
static String seconds2Duration(Integer itimeSec, Boolean postfix=true, Integer tk=2) {
    Integer timeSec=itimeSec
    Integer years = Math.floor(timeSec / 31536000); timeSec -= years * 31536000
    Integer months = Math.floor(timeSec / 31536000); timeSec -= months * 2592000
    Integer days = Math.floor(timeSec / 86400); timeSec -= days * 86400
    Integer hours = Math.floor(timeSec / 3600); timeSec -= hours * 3600
    Integer minutes = Math.floor(timeSec / 60); timeSec -= minutes * 60
    Integer seconds = Integer.parseInt((timeSec % 60) as String, 10)
    Map d = [y: years, mn: months, d: days, h: hours, m: minutes, s: seconds]
    List l = []
    if(d.d > 0) { l.push("${d.d} ${pluralize(d.d, "day")}") }
    if(d.h > 0) { l.push("${d.h} ${pluralize(d.h, "hour")}") }
    if(d.m > 0) { l.push("${d.m} ${pluralize(d.m, "min")}") }
    if(d.s > 0) { l.push("${d.s} ${pluralize(d.s, "sec")}") }
    return l.size() ? "${l.take(tk ?: 2)?.join(", ")}${postfix ? " ago" : sBLANK}".toString() : "Not Sure"
}

static String pluralize(Integer itemVal, String str) { return (itemVal > 1) ? str+"s" : str }

String readPage(fName){
    if(security) cookie = securityLogin().cookie
    def params = [
        uri: fName,
        contentType: "text/html",
        textParser: true,
        headers: [
				"Cookie": cookie
        ]
                    
    ]

    try {
        httpGet(params) { resp ->
            if(resp!= null) {
               int i = 0
               String delim = ""
               i = resp.data.read() 
               while (i != -1){
                   char c = (char) i
                   delim+=c
                   i = resp.data.read() 
               } 
               return delim
            }
            else {
                log.error "Null Response"
            }
        }
    } catch (exception) {
        log.error "Read Ext Error: ${exception.message}"
        return null;
    }
}
HashMap securityLogin(){
    def result = false
    try{
        httpPost(
				[
					uri: "http://127.0.0.1:8080",
					path: "/login",
					query: 
					[
						loginRedirect: "/"
					],
					body:
					[
						username: username,
						password: password,
						submit: "Login"
					],
					textParser: true,
					ignoreSSLIssues: true
				]
		)
		{ resp ->
				if (resp.data?.text?.contains("The login information you supplied was incorrect."))
					result = false
				else {
					cookie = resp?.headers?.'Set-Cookie'?.split(';')?.getAt(0)
					result = true
		    	}
		}
    }catch (e){
			log.error "Error logging in: ${e}"
			result = false
            cookie = null
    }
	return [result: result, cookie: cookie]
}

Edit: Updated code 19Dec2022
Edit 2: Code update for Beta 2.6.3 is in Github - 28Aug2023

3 Likes

Awesome! I'll work deeper on it, out of the box I got this:

Do a second refresh, looks like some of the data may have been in transit when the HTML was created

Simply from that I think I see a bug in the update chron... Note the time til next refresh..

It’s calculating the next based on the Cookie Refresh Days parameter, guess I could look further in the app to see if there is a pending job but I’m guessing from your ES App Screen that you don’t have one which is why you’re showing 361 days out.

In "Echo Speaks - Zones", the method "replayChildInitiatedRefresh()" is spelled incorrectly as "relayChildInitialtedRefresh()" with an L in the middle. I'm using the latest version (4.2.0.8). Apologies if this has been shared already (or I somehow messed this up).

1 Like