PTLevel Water Level Device

After various attempts to build my own water level sensor for my cisterns, I gave up and bought a PTLevel device: https://www.amazon.com/gp/product/B07B6DRC8P/ref=ppx_yo_dt_b_asin_title_o02_s00?ie=UTF8&psc=1

I don't know how to code, but wondered if anyone else would be interested in doing a simple device type that can show the results of this devices output?

The url to request is xxx.xxx.xxx.xxx/config?json=true

The output is:

{"free_space":"667648","rx_id":"227","tx_id":"50","tx_rssi":"-71","rx_rssi":"-59","firmware_version":"212","hardware_version":"4","id":"483FDA91E94F","ip":"10.54.25.254","subnet":"255.255.255.0""gateway":"10.54.25.1","dns":"unknown","tx_firmware_version":"7","tx_hardware_version":"5","fails":"3","rx_sensors":"[]","tx_sensors":"[{"1":366,"z":58},{"2":6.16},{"3":-15.01}]"}

The sections tx_sensors is as follows:

"1":366 -- first variable 366 being the current pressure reading
"z":58 -- second variable 58 being the pressure when nothing is applied

The percentage remaining needs to be calculated somehow. Here's how the manufacturer describes it:

the 1:{value} is the sensor a/d value and the z:{value} is the zero point value. The zero point is the a/d value of the sensor when there is no pressure applied. You'd use that value as the low part of the linear range. You must then figure out the high point of the range which is calculated based on your tank size or by doing a pressure test when your tank is full (basic calibration). Then you have your range and you can calculate the current level based on that range whether it be in percent, inches, volume, etc.

The "2":6.16 is the battery voltage 6.16v.

In the device website, you need to tell the depth of the tank and then calibrate it, so I'm not sure how that all works.

If someone could code this to just show even the raw values, maybe with a manually set point for full/empty, and the battery voltage, that would be amazing!

I'll happily help anyone that wants to take this on, thanks for the consideration!

Except for the missing comma between 255.255.255.0 and gateway the string should be able to be processed using a JsonSlurper to give you a HashMap at which time tx_sensors.1.value should give you the 366...

Threw together some rough code that may work you. Add it under the device code tab and then use it to create a virtual device. Fill in the IP address and a polling rate (in seconds - zero if you only want it to go through once) save, and then click Configure.

Driver Code
 /*
 * PT Device 
 *
 *  Licensed Virtual 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.
 *
 *  Change History:
 *
 *    Date        Who            What
 *    ----        ---            ----
*/
import java.text.SimpleDateFormat
import groovy.json.JsonSlurper

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

metadata {
    definition (
        name: "PT Device", 
        namespace: "thebearmay", 
        author: "Jean P. May, Jr.",
        importUrl:""
    ) {
        capability "Actuator"
        capability "Configuration"
        capability "Initialize"
        attribute "tx1", "number"
        attribute "tx1z", "number"
        attribute "tx2", "decimal"
        attribute "tx3", "decimal"
    }   
}

preferences {
    input("serverAddr", "text", title:"IP Address to Poll:", required: true, submitOnChange:true)

    input("tempPollRate", "number", title: "Polling Rate (seconds)\nDefault:300", defaultValue:300, submitOnChange: true)
    input("security", "bool", title: "Hub Security Enabled?", defaultValue: false, submitOnChange: true)
    if (security) { 
        input("username", "string", title: "Hub Security Username", required: false)
        input("password", "password", title: "Hub Security Password", required: false)
    }
    input("debugEnable", "bool", title: "Enable debug logging?")
}

@SuppressWarnings('unused')
def installed() {
    log.trace "installed()"
    initialize()
}

def initialize(){

}

@SuppressWarnings('unused')
def updated(){
    if(debugEnable) {
        log.debug "updated()"
        runIn(1800,logsOff)
    }			
}

@SuppressWarnings('unused')
def configure() {
    if(debugEnable) log.debug "configure()"
    getPollValues()
}

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

void getPollValues(){
    // start - Modified from dman2306 Rebooter app
    String cookie=(String)null
    if(security) {
        httpPost(
            [
                uri: "http://127.0.0.1:8080",
                path: "/login",
                query: [ loginRedirect: "/" ],
                body: [
                    username: username,
                    password: password,
                    submit: "Login"
                ]
            ]
        ) { resp -> cookie = ((List)((String)resp?.headers?.'Set-Cookie')?.split(';'))?.getAt(0) }
    }
    // End - Modified from dman2306 Rebooter app

    Map params = [
        uri    : "http://${location.hub.localIP}:8080",
        path   : "/hub/advanced/internalTempCelsius",
        headers: ["Cookie": cookie]
    ]
    if (debugEnable) log.debug params
    asynchttpGet("getPTData", params)

    if (debugEnable) log.debug "tempPollRate: $tempPollRate"


    if(tempPollRate == null){
        device.updateSetting("tempPollRate",[value:300,type:"number"])
        runIn(300,"getPollValues")
    }else if(tempPollRate > 0){
        runIn(tempPollRate,"getPollValues")
    }
}
//{"free_space":"667648","rx_id":"227","tx_id":"50","tx_rssi":"-71","rx_rssi":"-59","firmware_version":"212","hardware_version":"4","id":"483FDA91E94F","ip":"10.54.25.254","subnet":"255.255.255.0","gateway":"10.54.25.1","dns":"unknown","tx_firmware_version":"7","tx_hardware_version":"5","fails":"3","rx_sensors":"[]","tx_sensors":"[{"1":366,"z":58},{"2":6.16},{"3":-15.01}]"}

@SuppressWarnings('unused')
void getPTData(resp, data){
    try{
        if (resp.getStatus() == 200){
            if (debugEnable) log.info resp.data
            dataIn = resp.data.toString()
            focusData = dataIn.substring(dataIn.indexOf('"tx_sensors":"')+14,dataIn.length()-2)
            focusData = focusData.replace("{","")
            focusData = focusData.replace("}","")
            updateAttr("debug",focusData)
            HashMap retData=(HashMap)evaluate(focusData)
            updateAttr("tx1",retData['1'])
            updateAttr("txz",retData['z'])
            updateAttr("tx2",retData['2'])
            updateAttr("tx3",retData['3'])
        } else {
            if (!warnSuppress) log.warn "Status ${resp.getStatus()} while fetching Public IP"
        } 
    } catch (Exception ex){
        if (!warnSuppress) log.warn ex
    }
}   


@SuppressWarnings('unused')
void logsOff(){
     device.updateSetting("debugEnable",[value:"false",type:"bool"])
}

Awesome, thank you for giving this a go!

I tried and got nothing.

I see you grab the IP, but it's not used in the code (serverAddr)? And I don't see the url to pull the json.

Is that part of "void getPTData" somehow?

I tried entering the entire URL in the IP box and that didn't work either. http://10.54.25.254/config?json=true and also tried without the http.

Thank you for taking a stab at this!

Oops forgot to change the IP portion - was concentrating on parsing and got sidetracked.

New code:

New Code
 /*
 * PT Device 
 *
 *  Licensed Virtual 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.
 *
 *  Change History:
 *
 *    Date        Who            What
 *    ----        ---            ----
*/
import java.text.SimpleDateFormat
import groovy.json.JsonSlurper

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

metadata {
    definition (
        name: "PT Device", 
        namespace: "thebearmay", 
        author: "Jean P. May, Jr.",
        importUrl:""
    ) {
        capability "Actuator"
        capability "Configuration"
        capability "Initialize"
        attribute "tx1", "number"
        attribute "tx1z", "number"
        attribute "tx2", "decimal"
        attribute "tx3", "decimal"
    }   
}

preferences {
    input("serverAddr", "text", title:"IP address and path to Poll:", required: true, submitOnChange:true)

    input("tempPollRate", "number", title: "Polling Rate (seconds)\nDefault:300", defaultValue:300, submitOnChange: true)
    input("security", "bool", title: "Hub Security Enabled?", defaultValue: false, submitOnChange: true)
    if (security) { 
        input("username", "string", title: "Hub Security Username", required: false)
        input("password", "password", title: "Hub Security Password", required: false)
    }
    input("debugEnable", "bool", title: "Enable debug logging?")
}

@SuppressWarnings('unused')
def installed() {
    log.trace "installed()"
    initialize()
}

def initialize(){

}

@SuppressWarnings('unused')
def updated(){
    if(debugEnable) {
        log.debug "updated()"
        runIn(1800,logsOff)
    }			
}

@SuppressWarnings('unused')
def configure() {
    if(debugEnable) log.debug "configure()"
    getPollValues()
}

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

void getPollValues(){
    // start - Modified from dman2306 Rebooter app
    String cookie=(String)null
    if(security) {
        httpPost(
            [
                uri: "http://127.0.0.1:8080",
                path: "/login",
                query: [ loginRedirect: "/" ],
                body: [
                    username: username,
                    password: password,
                    submit: "Login"
                ]
            ]
        ) { resp -> cookie = ((List)((String)resp?.headers?.'Set-Cookie')?.split(';'))?.getAt(0) }
    }
    // End - Modified from dman2306 Rebooter app

    Map params = [
        uri    : serverAddr,
        headers: ["Cookie": cookie]
    ]
    if (debugEnable) log.debug params
    asynchttpGet("getPTData", params)

    if (debugEnable) log.debug "tempPollRate: $tempPollRate"


    if(tempPollRate == null){
        device.updateSetting("tempPollRate",[value:300,type:"number"])
        runIn(300,"getPollValues")
    }else if(tempPollRate > 0){
        runIn(tempPollRate,"getPollValues")
    }
}
//{"free_space":"667648","rx_id":"227","tx_id":"50","tx_rssi":"-71","rx_rssi":"-59","firmware_version":"212","hardware_version":"4","id":"483FDA91E94F","ip":"10.54.25.254","subnet":"255.255.255.0","gateway":"10.54.25.1","dns":"unknown","tx_firmware_version":"7","tx_hardware_version":"5","fails":"3","rx_sensors":"[]","tx_sensors":"[{"1":366,"z":58},{"2":6.16},{"3":-15.01}]"}

@SuppressWarnings('unused')
void getPTData(resp, data){
    try{
        if (resp.getStatus() == 200){
            if (debugEnable) log.info resp.data
            dataIn = resp.data.toString()
            focusData = dataIn.substring(dataIn.indexOf('"tx_sensors":"')+14,dataIn.length()-2)
            focusData = focusData.replace("{","")
            focusData = focusData.replace("}","")
            updateAttr("debug",focusData)
            HashMap retData=(HashMap)evaluate(focusData)
            updateAttr("tx1",retData['1'])
            updateAttr("txz",retData['z'])
            updateAttr("tx2",retData['2'])
            updateAttr("tx3",retData['3'])
        } else {
            if (!warnSuppress) log.warn "Status ${resp.getStatus()} while fetching IP"
        } 
    } catch (Exception ex){
        if (!warnSuppress) log.warn ex
    }
}   


@SuppressWarnings('unused')
void logsOff(){
     device.updateSetting("debugEnable",[value:"false",type:"bool"])
}

Ah sweet! That's pulled all the variables:

image

Thank you SO much! Now I can try to figure out the best way to make it an accurate percentage, problem is I need to wait for the tank to be refilled, which is gonna be a while! :slight_smile:

Thank you @thebearmay you are awesome!

image

2 Likes

Glad it worked for you, holler when you get the tank full and we can look at the percentage.

2 Likes

Love seeing this kinda stuff expanding the function and utility of "Home" Automation. This use to require very expensive solutions in industrial and field settings.

Actually it's a shame the vendor hasn't created a low power ZigBee/Zwave compatible solution but so many of these outfits figure the only way they're going to sell it is as a package where they provide the A to Z solution.

Makes some sense when you think about their data and presentation capability...but from an HE user perspective this is just ANOTHER monitoring data source one would like to integrate, calculate, track, graph/present, and take action/make decisions off of.

Good on you for helping him.

2 Likes

Finally got the tank filled up!

The Low number when unit was out of water, so atmospheric pressure, was 130. The high with 100% fill was 529.

So the device has me enter the height of the tank (8.5 Ft) and figured the volume based on pressure.

Water pressure is .433psi multiplied by # of feet of water above it, so full, my tank would be 3.68psi at 8.5'. I can't fathom (get it?) the method they are using to compute this.

The difference in readings is basically 400, so every 1% of water usage would be a normalized drop of 4.

I have no idea other than hard coding this, how we could take their readings and make sense of it?

I can add some extra input fields to capture data for computations if we need them. So what do the numbers from the sensors read now that it's full? If I'm reading this right tx1 should now read ~529, (probably add that as an observed constant input in case we need to change it in the future vs. hard coding) assuming z is a constant at 58 we could end up with a % full calculation that looks similar to:

percentFull = (tx1 - 58) / (529-58) 

probably should add a battery level using the base of 6.17 while we're at it...

You might already have looked this kinda stuff up but just in case you haven't... it might put their numbers into perspective.

If you want volume I'm thinking you'd need to calculate it at the particular water level (depth that the pressure computes to) according to the formula:

Formula Volume of Cylinder. Explained with pictures and examples. The formula for ...

I didn't read this in your prior posts but I assume that sensor is at the very bottom of the tank.

Just re-read this, so real easy - I’ll add empty and full calibration inputs, and then use the tx1 reading to give you the %full. Any idea how much the tank holds?

On the battery front, nominal voltage should be 6.0v+ when 100%, and looks to be ~4.8v when needing replaced

New version out there at https://raw.githubusercontent.com/thebearmay/hubitat/main/ptWaterLevel.groovy

(should be able click Import, and copy/paste that link to pull in the updated code)

Wow!

Ok, so the tank is 3k Gallons. I agree, entering the empty and full is super easy to get so no issues there.

I've updated the code, entered the values. I still only show the three tx items though?

Any errors in the log?

Here's what I get from the event log with debug enabled:

txz 58 DEVICE 2022-01-12 12:00:58.548 PM MST
debug [1:484,z:58,2:6.22,3:-15.01] DEVICE 2022-01-12 12:00:58.461 PM MST

Okay, found a couple minor issues. Go ahead and re-import and see if it is better. Should end up with a battery, fillPct, and compLiquid attributes.

  • battery - Percent of battery left based on 6.0v standard for 4 AA (and standard 1.2v per battery "dead")
  • fillPct - Percentage of tank filled
  • compLiquid - Estimated gallons/liters/etc in the tank based on the fillPct and Capacity

If the above is working I’ll go ahead and add a summary html attribute to pull the battery level and fill level onto one attribute tile.

Edit: Attribute added.

Thank you SO much for this! It's working great! The percentage is a bit off from what their app says, but it's certainly close enough without knowing their math.

This is wonderful, I can put more reminders out and put it on my tablet status display now. Their free account only allows two notifications.

They may be using the txz as the unfilled value, but I like the observed value better. Did you try the html attribute on a tile - it combines the battery%, filled % and and gallons onto one tile (uses the attribute template).