I know you have the screens setup with your system but I bought the following item that I can place wherever i want to provide visual alerts. I figured you might be interested in it.
I fell off a little here since I ended up with a wasted day and also converting a home server to a linux box. From my end I’m pretty sure I need to start moving forward with version support since I was dumb and spent hours adding and testing features only for chatgpt to irrepairable break it all and remove functions without my permission, only to put them back in but in a broken state. I actually went down a long line of questioning with chatgpt and found a few things that are very important while using it as a tool. It has a block where it cannot access any information less than 3 years old. It cannot read any link you send it a link for. Unless directly asked it will not mention these things and try to satisfy your request by making up information of flat out lying. It will very willingly break every function of a script to add a new function that uses commands it completely made up. It also keeps no memory of a previous session… a computer update restart set this one up so I lost all working versions of the script i neglected to save.
I had built in polling, and temperature compensation that had variable time intervals that could be set manually in script or changed by a mqtt command. Plus these could be turned on or off for each sensor with an mqtt request to toggle it. I also had a function to convert the temp sensor output to F or K via mqtt command while keeping the polling in celcius for the temp compensation to work right.
I’ve also learned that while a pH temp compensation slope is minimal, the EC is a curve. With a max 15*F swing in my system the EC barely responds to dosing while cold then skyrockets when the water warms up. Because of this I made my automation only dose nutrients while the sun is up.
Something I dont know much about HA but I think may be really helpful is making the sensors have enough data in the yaml to register in the STATISTICS secion of developer tools. I think it would open up a lot of the new 2025 release functionality of the history chart when you click on a graph rather than seeing a bar with lots of colors. Also I think it will let us have access to remove some database entries if you end up doing what I did and sending 99:5.8 as a ph response and having my graph turn into a flat line with a spike for the next 24 hours cause I didnt think test functions with a different topic and sensor entity.
ESPhome is just a well developed and more documented path. I’ve kind of shifted this opinion back into RPI as I’m starting to enjoy where this journey is taking me in learning all this stuff and using AI tools for coding. Again I have you and this thread to thank for setting me on this path. That pixel clock is clever, I can see all the uses as a visual alert device. I could use something like this in a lot of ways.
I’ll get back to developing the script again soon. I’m sifting through other ai tools that a specifically for code development and I’ve started a github. And layed out a basic install. It would probably be a decent idea to make it a one stop source for how to do everything in this thread so others that make it here dont have to sift through the entire conversation. I think the way it works is that we can they develop seperate forks of the script and put it all together with offical releases. I’m still really new to all of this.
https://github.com/theOriflamme/EZO
I’m going to have to reread though your post again and will comment back on it. You can blame me for failing to advise you of the important fact that ChatGpt has a three year lapse in updates. However, that’s why I use it more as a tool than a complete fix. The GitHub AI seems to be more up-to-date and has been a better source of information.
Oh, and ChatGPT does keep a history. It’s on the lefthand side and you might have to expand it but all of your chat history should be there.
Add a unit of measurement to your sensor and you will get a line graph opposed to a bar with colors.
EDIT: For some reason doing this impacts the sensor and I no longer get a new value. I have to figure out why.
EDIT: I found this error in the logs so I had to adjust the script to remove the null characters. I also added error handling for 255, 254, and 2 codes so that they’re not accidently sent because it will throw off the graph.
ValueError: could not convert string to float: ‘12.83\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00’
Here’s my updates script.
import paho.mqtt.client as mqtt
import time
import signal
import logging
from AtlasI2C import AtlasI2C # Import the AtlasI2C class from your module
import json
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
# Load configurations from a JSON file
CONFIG_FILE = "config.json"
with open(CONFIG_FILE, "r") as file:
config = json.load(file)
MQTT_BROKER = config["mqtt"]["broker"]
MQTT_PORT = config["mqtt"]["port"]
MQTT_USERNAME = config["mqtt"]["username"]
MQTT_PASSWORD = config["mqtt"]["password"]
POLLING_INTERVAL = config["polling_interval"]
SENSORS = config["sensors"]
def parse_sensor_response(response):
"""
Parses the sensor response to extract the relevant value.
Args:
response (str): The raw response from the sensor.
Returns:
str: The extracted sensor value.
"""
# Clean the response by removing null characters
response = response.replace('\x00', '')
# Assuming the response is in the format "SOME_PREFIX: value"
# Modify this parsing logic based on the actual response format
try:
# Split the response and take the last part as the value
value = response.split(":")[-1].strip()
return value
except Exception as e:
logging.error(f"Error parsing sensor response: {e}")
return None
def handle_error_code(response_code):
"""
Handles the sensor response error codes.
Args:
response_code (int): The error code from the sensor response.
Returns:
bool: True if the error is recoverable and should be retried, False otherwise.
"""
if response_code == 255:
logging.warning("Sensor response: No data to send.")
return False # No data to send, do not retry
elif response_code == 254:
logging.warning("Sensor response: Still processing, not ready.")
return True # Still processing, retry
elif response_code == 2:
logging.error("Sensor response: Syntax error.")
return False # Syntax error, do not retry
elif response_code == 1:
return False # Successful request, no retry needed
else:
logging.error(f"Unknown sensor response code: {response_code}")
return False # Unknown error code, do not retry
def poll_sensor(client, address, topic):
"""
Polls the sensor at the specified I2C address and publishes the data to the given MQTT topic.
Args:
client (mqtt.Client): The MQTT client instance.
address (int): The I2C address of the sensor.
topic (str): The MQTT topic to publish the sensor data to.
"""
ezo_sensor = AtlasI2C(address=address)
try:
while True:
response = ezo_sensor.query("R") # Query the sensor with the "R" command
if response.isdigit():
response_code = int(response)
if handle_error_code(response_code):
time.sleep(1) # Wait before retrying
continue # Retry the query
else:
return # Exit the function if no retry is needed
value = parse_sensor_response(response)
if value is not None:
client.publish(topic, value) # Publish the parsed value to the MQTT topic
logging.info(f"Published to {topic}: {value}")
else:
logging.error(f"Failed to parse value from sensor at address {address}")
break # Exit the loop if the response is successfully processed
except Exception as e:
logging.error(f"Error querying sensor at address {address}: {e}")
def graceful_exit(signum, frame):
"""
Gracefully stops the MQTT client loop and exits the script when a termination signal is received.
"""
logging.info("Termination signal received. Exiting...")
client.loop_stop()
client.disconnect()
exit(0)
# Attach signal handlers for graceful exit
signal.signal(signal.SIGINT, graceful_exit)
signal.signal(signal.SIGTERM, graceful_exit)
# Create MQTT client instance
client = mqtt.Client()
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD) # Set username and password
# Connect to MQTT broker
client.connect(MQTT_BROKER, MQTT_PORT, 60)
# Start the MQTT client loop
client.loop_start()
# Main polling loop
try:
while True:
for sensor in SENSORS:
poll_sensor(client, sensor["address"], sensor["topic"])
# Add a per-sensor delay to avoid overloading sensors with frequent polling
time.sleep(sensor.get("polling_delay", 1)) # Default to 1 second if not specified
time.sleep(POLLING_INTERVAL) # Delay before starting the next polling cycle
except KeyboardInterrupt:
graceful_exit(None, None)
For some reason this did not work. I tried creating another one and inviting you as a collaborator.
Those are called Null Characters and I’ve had luck using strip to take them out of the response before publishing it. They message size filler show up as squares in mqtt explorer. Those showing up happens to be my first indication that chatgpt violated my rules primer for it and altered functions without asking for my explicit permission. Here’s what my strip looks like.
# Extract the value after the colon and remove null filler
if ":" in response:
stripped_response = response.split(":", 1)[1].strip()
# Get everything after the colon
stripped_response = stripped_response.replace("\x00", "")
# Remove any null filler
else:
stripped_response = response.replace("\x00", "")
# Handle cases without a colon
response_topic = f"{MQTT_TOPIC_BASE}/{sensor_address}"
client.publish(response_topic, stripped_response)
Ok new update, it autopolls every 10 seconds (define in the config) temp compensation every 30 seconds (define in the config) all commands are sent into a que system and there are multiple threads managing the command load. I’m not sure if the 300ms wait is per sensor or for the entire I2C bus. I figure its safer to keep a que for the entire bus. There are commands to turn sensor polling and temp comp on and off mqtt payload but it was really finicky about saving those dynamically to a .json so I bailed on that for now. The commands work but the script will default back to the config file settings.
config.py
# config.py
# Define MQTT settings with username and password
MQTT_BROKER = "hostname" # IP or hostname of your MQTT broker
MQTT_PORT = 1883
MQTT_USERNAME = "username" # Replace with your MQTT username
MQTT_PASSWORD = "password" # Replace with your MQTT password
MQTT_TOPIC_COMMAND = "ezo/command" # Topic to receive commands
MQTT_TOPIC_BASE = "ezo" # Base topic for publishing responses, e.g., "ezo/{address}"
# List of sensor addresses to poll
polling_sensor_addresses = [99, 100, 102] # Example addresses
polling_enabled = {addr: True for addr in polling_sensor_addresses} # Default polling state
polling_interval = 10 # Default polling interval (seconds)
# List of sensors for temperature compensation
temperature_compensation_sensors = [99, 100] # Example addresses
temperature_compensation_interval = 30 # Temperature compensation interval (seconds)
mqtt_ezo.py
import paho.mqtt.client as mqtt
import time
import threading
import queue
from AtlasI2C import AtlasI2C # Import the AtlasI2C class from your module
import logging
from config import MQTT_BROKER, MQTT_PORT, MQTT_USERNAME, MQTT_PASSWORD, MQTT_TOPIC_COMMAND, MQTT_TOPIC_BASE, polling_sensor_addresses, polling_enabled, polling_interval, temperature_compensation_sensors, temperature_compensation_interval
# Configure logging to print logs to the console
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s'
)
# Command queue and lock for sequential I2C processing
sensor_command_queue = queue.Queue()
i2c_lock = threading.Lock()
# Function to process commands from the queue
def process_commands():
while True:
sensor_address, command = sensor_command_queue.get() # Get the next command
try:
with i2c_lock: # Ensure only one command accesses the I2C bus at a time
ezo_sensor = AtlasI2C(address=sensor_address)
response = ezo_sensor.query(command) # Execute the command
logging.debug(f"Processed command '{command}' for sensor {sensor_address}: {response}")
# Extract the value after the colon and remove null filler
if ":" in response:
stripped_response = response.split(":", 1)[1].strip() # Get everything after the colon
stripped_response = stripped_response.replace("\x00", "") # Remove any null filler
else:
stripped_response = response.replace("\x00", "") # Handle cases without a colon
response_topic = f"{MQTT_TOPIC_BASE}/{sensor_address}"
client.publish(response_topic, stripped_response)
except Exception as e:
logging.error(f"Error processing command for sensor {sensor_address}: {e}")
finally:
sensor_command_queue.task_done() # Mark the command as processed
# Function to handle incoming MQTT commands
def handle_command(payload):
try:
address, command = payload.split(":")
address = int(address)
if command.startswith("TC"):
handle_temperature_compensation_command(address, command)
else:
sensor_command_queue.put((address, command)) # Add the command to the queue
except Exception as e:
logging.error(f"Error processing command payload '{payload}': {e}")
# Function to handle temperature compensation commands
def handle_temperature_compensation_command(address, command):
try:
_, state = command.split(",")
if state.lower() == "on":
if address not in temperature_compensation_sensors:
temperature_compensation_sensors.append(address)
logging.debug(f"Added sensor {address} to temperature compensation list.")
elif state.lower() == "off":
if address in temperature_compensation_sensors:
temperature_compensation_sensors.remove(address)
logging.debug(f"Removed sensor {address} from temperature compensation list.")
except Exception as e:
logging.error(f"Error processing temperature compensation command '{command}' for sensor {address}: {e}")
# Function to perform temperature compensation
def perform_temperature_compensation():
while True:
try:
# Get the most recent temperature reading from sensor 102
with i2c_lock:
ezo_sensor = AtlasI2C(address=102)
temperature_response = ezo_sensor.query("R")
if ":" in temperature_response:
temperature = temperature_response.split(":", 1)[1].strip().replace("\x00", "")
else:
temperature = temperature_response.replace("\x00", "")
logging.debug(f"Most recent temperature reading from sensor 102: {temperature}")
# Update temperature compensation for defined sensors
for address in temperature_compensation_sensors:
sensor_command_queue.put((address, f"T,{temperature}"))
logging.debug(f"Queued temperature compensation command for sensor {address} with temperature {temperature}")
except Exception as e:
logging.error(f"Error performing temperature compensation: {e}")
time.sleep(temperature_compensation_interval)
# MQTT callbacks
def on_message(client, userdata, msg):
payload = msg.payload.decode()
logging.debug(f"Received command: {payload}")
handle_command(payload)
def on_connect(client, userdata, flags, rc):
logging.debug(f"Connected with result code {rc}")
client.subscribe(MQTT_TOPIC_COMMAND)
# Create MQTT client instance
client = mqtt.Client()
client.username_pw_set(MQTT_USERNAME, MQTT_PASSWORD)
client.on_connect = on_connect
client.on_message = on_message
# Connect to MQTT broker
logging.debug(f"Connecting to MQTT broker at {MQTT_BROKER}...")
client.connect(MQTT_BROKER, MQTT_PORT, 60)
# Start the MQTT loop
client.loop_start()
# Function to add polling commands to the queue
def poll_sensors():
while True:
for address in polling_sensor_addresses:
if polling_enabled[address]:
sensor_command_queue.put((address, "R")) # Add polling command to the queue
time.sleep(polling_interval)
# Start the polling thread
polling_thread = threading.Thread(target=poll_sensors, daemon=True)
polling_thread.start()
# Start the temperature compensation thread
temperature_compensation_thread = threading.Thread(target=perform_temperature_compensation, daemon=True)
temperature_compensation_thread.start()
# Start the command processing thread
command_processing_thread = threading.Thread(target=process_commands, daemon=True)
command_processing_thread.start()
# Main loop for script
try:
while True:
logging.debug("Script is running...")
time.sleep(60) # Sleep to keep the main thread alive
except KeyboardInterrupt:
logging.info("Script interrupted by user.")
finally:
client.loop_stop()
logging.debug("MQTT loop stopped.")
I replaced my script and config file with the one you created and it seems to be working fine. The only thing I needed to change was the state topic for the HA sensors. I don’t know if it matters but when thinking of naming convention would it make more sense to have the state topic be ezo/99_reading
instead of just ezo/99
.? I’m just thinking ahead and if for some reason one of us decides to add the slope percentage or something else to the polling.
Just in case you want to give something else a try, below is a custom card I started. It’s nothing fancy. All you need to do is create a file in your www
directory called atlasiot-card.js
and copy the below code into it. Then go to your HA Settings, click on Dashboards, then click on the three dots in the upper right-hand corner, and click Resources. Add /local/atlasiot-card.js
under URL and choose JavaScript module
. After restarting HA you can add the custom:atlasiot-card
card to your dashboard. All you need to enter is the entity_id for your sensor and a sensor type from the following list: do, ph, ec, temp, orp, co2, o2, rh, rgb
. Based on the sensor type, the cards background and font color will change to the same color scheme AtlasIoT uses. The card also has input fields for a sensor address and a sensor command with a send button to publish it. There’s also a field that provides the sensor response. However, if using multiped cards the response will be shown on all of them.
type: custom:atlasiot-card
entity: sensor.atlas_ph_value
sensor-type: [ do, ph, ec, temp, orp, co2, o2, rh, rgb ]
class AtlasiotCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.state = {
sensorAddress: '',
sensorCommand: ''
};
}
set hass(hass) {
this._hass = hass; // Store hass instance for later use
const config = this.config;
if (!config) return;
const entity = hass.states[config.entity];
const value = entity ? entity.state : 'Unavailable';
const responseEntity = hass.states['sensor.ezo_sensor_response'];
const responseValue = responseEntity ? responseEntity.state : 'Unavailable';
// Update the dynamic content only if elements exist
const sensorValueElement = this.shadowRoot.getElementById('sensor-value');
const responseValueElement = this.shadowRoot.getElementById('response-value');
if (sensorValueElement) {
sensorValueElement.textContent = `Value: ${value}`;
}
if (responseValueElement) {
responseValueElement.textContent = `Response Value: ${responseValue}`;
}
}
connectedCallback() {
this.render();
}
render() {
const config = this.config;
if (!config) return;
const sensorType = config['sensor-type'].toLowerCase(); // Convert sensor-type to lowercase
// Determine the style based on the sensor-type selected
let backgroundColor;
let textColor;
switch (sensorType) {
case 'do':
backgroundColor = 'rgba(243, 187, 67, 1.0)';
textColor = 'black';
break;
case 'ph':
backgroundColor = 'rgba(255, 77, 0, 1.0)';
textColor = 'white';
break;
case 'ec':
backgroundColor = 'rgba(80, 174, 87, 1.0)';
textColor = 'white';
break;
case 'temp':
backgroundColor = 'rgba(255, 77, 0, 1.0)';
textColor = 'black';
break;
case 'orp':
backgroundColor = 'rgba(190, 191, 193, 1.0)';
textColor = 'white';
break;
case 'co2':
backgroundColor = 'rgba(230, 135, 59, 1.0)';
textColor = 'black';
break;
case 'o2':
backgroundColor = 'rgba(177, 50, 46, 1.0)';
textColor = 'white';
break;
case 'rh':
backgroundColor = 'rgba(75, 166, 112, 1.0)';
textColor = 'black';
break;
case 'rgb':
backgroundColor = 'rgba(234, 234, 254, 1.0)';
textColor = 'black';
break;
default:
backgroundColor = 'white';
textColor = 'black';
}
// Create the static content for the card
this.shadowRoot.innerHTML = `
<style>
.atlasiot-card {
padding: 16px;
background-color: ${backgroundColor};
color: ${textColor};
border-radius: 16px;
box-shadow: var(--ha-card-box-shadow);
text-align: center;
}
.atlasiot-card h1 {
margin: 0;
font-size: 24px;
}
.atlasiot-card p {
margin: 4px 0;
font-size: 20px;
}
.atlasiot-card input, .atlasiot-card button {
margin: 8px 0;
padding: 8px;
font-size: 16px;
}
.atlasiot-card input {
width: calc(100% - 18px);
}
</style>
<div class="atlasiot-card">
<h1>${config['sensor-type']} Sensor</h1>
<p id="sensor-value">Value: Unavailable</p>
<input type="text" id="sensor-address" placeholder="Enter Sensor Address" value="${this.state.sensorAddress}">
<input type="text" id="sensor-command" placeholder="Enter Command" value="${this.state.sensorCommand}">
<button id="send-command">Send Command</button>
<p id="response-value">Response Value: Unavailable</p>
</div>
`;
// Add event listeners to the input fields to update the state
this.shadowRoot.getElementById('sensor-address').addEventListener('input', (e) => {
this.state.sensorAddress = e.target.value;
});
this.shadowRoot.getElementById('sensor-command').addEventListener('input', (e) => {
this.state.sensorCommand = e.target.value;
});
// Add event listener to the button
this.shadowRoot.getElementById('send-command').addEventListener('click', () => {
const address = this.state.sensorAddress;
const command = this.state.sensorCommand;
if (address && command) {
const payload = `${address}:${command}`;
console.log(`Publishing to MQTT: ${payload}`); // Log the payload for debugging
this._hass.callService('mqtt', 'publish', {
topic: 'ezo/command',
payload: payload
}).then(() => {
console.log('MQTT command sent successfully');
}).catch((error) => {
console.error('Error sending MQTT command:', error);
});
} else {
alert('Please enter both address and command');
}
});
}
setConfig(config) {
if (!config.entity) {
throw new Error('You need to define an entity');
}
if (!config['sensor-type']) {
throw new Error('You need to define a sensor-type');
}
this.config = config;
}
getCardSize() {
return 1;
}
}
customElements.define('atlasiot-card', AtlasiotCard);
The cards work. Not sure if there is more to the appearance than what loaded or this is where you left off but this could see this working well for doing things like calibration.
I can see that being a possible solution. When I was looking at the DO sensor it can use multiple factors for compensation, like temperature and salinity. While right now Im doing compensation all onboard the RPI, its quite possible to run the scripts inside HA and publish commands out to several sub topics rather than stacking it all in one and slicing it up. Hell right now I have the EC sensor publishing every attribute it can and slice it up in the default order, but turning one attribute off would break my entities. I think I want to attempt to build up devices rather than entities, or even make my individual RPIs into devices with connected entities. I’m getting the impression an entire integration package could be made rather than just running scripts on RPI’s and manually building up every single entity and automation.
I agree and I’ve seen this done with other integrations.