Initial commit: Chrony NTP Server add-on for Home Assistant

- NTP client syncs from configurable pools and servers
- NTP server mode serves time to LAN clients
- Web UI dashboard showing sync status, sources, and clients
- Supports iburst and maxsources options

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Adam Harrison-Fuller
2026-01-14 22:34:06 +00:00
co-authored by Claude Opus 4.5
commit 8963c16212
18 changed files with 1003 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Chrony NTP Server Web UI for Home Assistant."""
import json
import os
import subprocess
from flask import Flask, render_template, jsonify
app = Flask(__name__)
OPTIONS_FILE = "/data/options.json"
def get_options():
"""Load current options from Home Assistant."""
try:
with open(OPTIONS_FILE, 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {
"ntp_pools": [
{"pool": "pool.ntp.org", "iburst": True}
],
"ntp_servers": [
{"server": "time.google.com", "iburst": True}
],
"allow_clients": ["192.168.0.0/16", "10.0.0.0/8", "172.16.0.0/12"],
"enable_ntp_server": True,
"detailed_logging": False
}
def get_chrony_status():
"""Get current chrony synchronization status."""
status = {
"tracking": {},
"sources": [],
"clients": []
}
try:
# Get tracking info
result = subprocess.run(
["chronyc", "-c", "tracking"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
fields = result.stdout.strip().split(',')
if len(fields) >= 14:
status["tracking"] = {
"reference_id": fields[0],
"reference_name": fields[1],
"stratum": fields[2],
"ref_time": fields[3],
"system_time": fields[4],
"last_offset": fields[5],
"rms_offset": fields[6],
"frequency": fields[7],
"residual_freq": fields[8],
"skew": fields[9],
"root_delay": fields[10],
"root_dispersion": fields[11],
"update_interval": fields[12],
"leap_status": fields[13]
}
# Get sources
result = subprocess.run(
["chronyc", "-c", "sources"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if line:
fields = line.split(',')
if len(fields) >= 10:
status["sources"].append({
"mode": fields[0],
"state": fields[1],
"name": fields[2],
"stratum": fields[3],
"poll": fields[4],
"reach": fields[5],
"last_rx": fields[6],
"last_sample_offset": fields[7],
"last_sample_error": fields[8]
})
# Get clients (if server mode enabled)
result = subprocess.run(
["chronyc", "-c", "clients"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if line:
fields = line.split(',')
if len(fields) >= 5:
status["clients"].append({
"hostname": fields[0],
"ntp_requests": fields[1],
"drop": fields[2],
"last_rx": fields[4]
})
except subprocess.TimeoutExpired:
status["error"] = "Timeout getting chrony status"
except Exception as e:
status["error"] = str(e)
return status
@app.route('/')
def index():
"""Main dashboard page."""
options = get_options()
status = get_chrony_status()
return render_template('index.html', options=options, status=status)
@app.route('/api/status')
def api_status():
"""API endpoint for current status."""
return jsonify(get_chrony_status())
@app.route('/api/options')
def api_options():
"""API endpoint for current options."""
return jsonify(get_options())
@app.route('/api/force-sync', methods=['POST'])
def force_sync():
"""Force immediate time synchronization."""
try:
result = subprocess.run(
["chronyc", "makestep"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return jsonify({"success": True, "message": "Time step applied"})
else:
return jsonify({"success": False, "message": result.stderr}), 500
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@app.route('/api/burst', methods=['POST'])
def burst_sync():
"""Trigger burst mode for faster synchronization."""
try:
result = subprocess.run(
["chronyc", "burst", "4/4"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
return jsonify({"success": True, "message": "Burst mode activated"})
else:
return jsonify({"success": False, "message": result.stderr}), 500
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
if __name__ == '__main__':
# Get ingress port from environment
port = int(os.environ.get('INGRESS_PORT', 8099))
app.run(host='0.0.0.0', port=port, debug=False)