- 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>
170 lines
5.4 KiB
Python
170 lines
5.4 KiB
Python
#!/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)
|