Added support for power plug and spam sensor
This commit is contained in:
1 parent
0de8352417
commit
1fd4dab916
2 files changed
+793
-25
No files matched your search
+287
-25
@@ -1,6 +1,6 @@
|
||||
import appdaemon.plugins.hass.hassapi as hass
|
||||
from requests import get
|
||||
from requests.exceptions import Timeout,ConnectionError
|
||||
from requests.exceptions import Timeout,ConnectionError,RequestException
|
||||
from ad_toolbox.smartobject import SmartObject
|
||||
import ad_toolbox.smartcondition as SmartCondition
|
||||
import time
|
||||
@@ -15,6 +15,24 @@ class Vertex2(SmartObject):
|
||||
self._stop_interrupt_listener()
|
||||
self._interrupt_stop = threading.Event()
|
||||
self._status_requested = threading.Event()
|
||||
self._last_logged_status = None
|
||||
self._spam_lock = threading.RLock()
|
||||
self._spam_last_interrupt = None
|
||||
self._spam_snapshot = None
|
||||
self._spam_count = 0
|
||||
self._spam_timer = None
|
||||
self._spam_deadline = None
|
||||
self.no_signal_spam_sensor = None
|
||||
if self.args.get('no_signal_spam_sensor'):
|
||||
self.no_signal_spam_sensor = self.create_entity(
|
||||
self.args['no_signal_spam_sensor'], state='off')
|
||||
self._connection_lock = threading.Lock()
|
||||
self._interrupt_connection = None
|
||||
self.startup_delay = max(0, float(self.args.get('startup_delay', 30)))
|
||||
self._startup_deadline = 0
|
||||
self._startup_commands = []
|
||||
self.power_plug_entity = self.args.get("vertex2_power_plug", {}).get("entity")
|
||||
self._power_allowed = self.power_plug_entity is None
|
||||
self._command_processing_lock = threading.Lock()
|
||||
self._command_processing_handle = None
|
||||
self._command_processing_deadline = None
|
||||
@@ -44,6 +62,11 @@ class Vertex2(SmartObject):
|
||||
)
|
||||
for index in range(2)
|
||||
}
|
||||
self.plus5_sensors = {
|
||||
index: self.create_entity(self.args[key], state='unavailable')
|
||||
for index, key in enumerate(('tx0_plus5_sensor', 'tx1_plus5_sensor'))
|
||||
if self.args.get(key)
|
||||
}
|
||||
self._tx_video_signal_values = {0: 'unavailable', 1: 'unavailable'}
|
||||
|
||||
self.audio_out_signal_sensor = self.create_entity(
|
||||
@@ -99,7 +122,11 @@ class Vertex2(SmartObject):
|
||||
state='off',
|
||||
)
|
||||
|
||||
self.power_plug_entity = None
|
||||
if "vertex2_power_plug" in self.args:
|
||||
self.power_plug_entity = self.args["vertex2_power_plug"]["entity"]
|
||||
self.power_plug_conditions_evaluator = SmartCondition.Evaluator(self,self.args["vertex2_power_plug"]["conditions"],condition_name = 'vertex2_power_plug',on_change_cb = self.on_change_power_plug_conditions)
|
||||
if self.power_plug_entity is not None:
|
||||
self.listen_state(self.on_power_plug_state_change, self.power_plug_entity)
|
||||
|
||||
if "filmmaker_mode_conditions" in self.args:
|
||||
self.filmmaker_mode_conditions = SmartCondition.Evaluator(self,self.args['filmmaker_mode_conditions'], condition_name = "filmmaker_mode_conditions",on_change_cb = self.on_filmmaker_mode_conditions_change)
|
||||
@@ -152,9 +179,6 @@ class Vertex2(SmartObject):
|
||||
))
|
||||
self.on_update_output_selection()
|
||||
|
||||
if "vertex2_power_plug" in self.args:
|
||||
self.power_plug_entity = self.args["vertex2_power_plug"]["entity"]
|
||||
self.power_plug_conditions_evaluator = SmartCondition.Evaluator(self,self.args["vertex2_power_plug"]["conditions"],condition_name = 'vertex2_power_plug',on_change_cb = self.on_change_power_plug_conditions)
|
||||
self.vertex2_tcp_port = int(self.args.get('vertex2_tcp_port', 2220))
|
||||
self._interrupt_thread = threading.Thread(
|
||||
target=self._interrupt_listener,
|
||||
@@ -165,6 +189,65 @@ class Vertex2(SmartObject):
|
||||
self.update_vertex2_states()
|
||||
self._interrupt_thread.start()
|
||||
|
||||
def _reset_no_signal_spam(self, state='off'):
|
||||
if getattr(self, 'no_signal_spam_sensor', None) is None:
|
||||
return
|
||||
with self._spam_lock:
|
||||
if self._spam_timer is not None:
|
||||
self.cancel_timer(self._spam_timer)
|
||||
self._spam_timer = None
|
||||
self._spam_deadline = None
|
||||
self._spam_last_interrupt = None
|
||||
self._spam_snapshot = None
|
||||
self._spam_count = 0
|
||||
if self.no_signal_spam_sensor.get_state() != state:
|
||||
self.no_signal_spam_sensor.set_state(state=state)
|
||||
|
||||
def _observe_no_signal_spam(self, status):
|
||||
if getattr(self, 'no_signal_spam_sensor', None) is None:
|
||||
return
|
||||
with self._spam_lock:
|
||||
missing = ('', 'no signal')
|
||||
if any(status[field].strip().lower() not in missing for field in ('rx0', 'tx0')):
|
||||
self._reset_no_signal_spam()
|
||||
return
|
||||
snapshot = tuple(status[field].strip() for field in
|
||||
('rx0', 'rx1', 'tx0', 'tx1', 'audout', 'insel'))
|
||||
if self._spam_snapshot is not None and snapshot != self._spam_snapshot:
|
||||
self._reset_no_signal_spam()
|
||||
at = status.get('interrupt_at')
|
||||
if at is None:
|
||||
if self.no_signal_spam_sensor.get_state() == 'unavailable':
|
||||
self.no_signal_spam_sensor.set_state(state='off')
|
||||
return # Polls and commands do not count as device interrupts.
|
||||
now = time.monotonic()
|
||||
if now - at >= 4:
|
||||
return # Do not revive a streak with a delayed callback.
|
||||
previous = self._spam_last_interrupt
|
||||
self._spam_last_interrupt = at
|
||||
snapshot = tuple(status[field].strip() for field in
|
||||
('rx0', 'rx1', 'tx0', 'tx1', 'audout', 'insel'))
|
||||
self._spam_count = (self._spam_count + 1
|
||||
if previous is not None and 2 <= at - previous <= 4
|
||||
and snapshot == self._spam_snapshot else 1)
|
||||
self._spam_snapshot = snapshot
|
||||
state = 'on' if self._spam_count >= 3 else 'off'
|
||||
if self.no_signal_spam_sensor.get_state() != state:
|
||||
self.no_signal_spam_sensor.set_state(state=state)
|
||||
if self._spam_timer is not None:
|
||||
self.cancel_timer(self._spam_timer)
|
||||
self._spam_deadline = at + 4
|
||||
self._spam_timer = self.run_in(
|
||||
self._expire_no_signal_spam, max(0, self._spam_deadline - now),
|
||||
deadline=self._spam_deadline)
|
||||
|
||||
def _expire_no_signal_spam(self, kwargs):
|
||||
with self._spam_lock:
|
||||
if kwargs['deadline'] != self._spam_deadline:
|
||||
return
|
||||
self._spam_timer = None
|
||||
self._reset_no_signal_spam()
|
||||
|
||||
def _interrupt_listener(self, interrupt_stop):
|
||||
address = urlparse(self.args['vertex2_address'])
|
||||
host = address.hostname
|
||||
@@ -173,10 +256,15 @@ class Vertex2(SmartObject):
|
||||
expected = set(status_fields) | {'insel'}
|
||||
|
||||
while not interrupt_stop.is_set():
|
||||
if not self._vertex2_ready():
|
||||
interrupt_stop.wait(0.5)
|
||||
continue
|
||||
try:
|
||||
with socket.create_connection((host, self.vertex2_tcp_port), timeout=10) as connection:
|
||||
if interrupt_stop.is_set():
|
||||
break
|
||||
with self._connection_lock:
|
||||
self._interrupt_connection = connection
|
||||
if interrupt_stop.is_set() or not self._vertex2_powered():
|
||||
continue
|
||||
# Detect a silent reboot/network loss without polling HDMI status.
|
||||
connection.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
||||
connection.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 10)
|
||||
@@ -188,12 +276,16 @@ class Vertex2(SmartObject):
|
||||
buffer = ''
|
||||
status = {}
|
||||
status_started = None
|
||||
reconfigure = True
|
||||
pending_interrupt_at = None
|
||||
|
||||
while not interrupt_stop.is_set():
|
||||
while not interrupt_stop.is_set() and self._vertex2_powered():
|
||||
if status_started is None and self._status_requested.is_set():
|
||||
self._status_requested.clear()
|
||||
status = {}
|
||||
status_started = time.monotonic()
|
||||
request_interrupt_at = pending_interrupt_at
|
||||
pending_interrupt_at = None
|
||||
for command in commands:
|
||||
connection.sendall((command + '\r').encode('ascii'))
|
||||
|
||||
@@ -204,7 +296,7 @@ class Vertex2(SmartObject):
|
||||
raise OSError('Vertex2 TCP status response timed out')
|
||||
continue
|
||||
|
||||
if interrupt_stop.is_set():
|
||||
if interrupt_stop.is_set() or not self._vertex2_powered():
|
||||
break
|
||||
if not data:
|
||||
raise OSError('Vertex2 closed the IP INTERRUPT connection')
|
||||
@@ -217,8 +309,7 @@ class Vertex2(SmartObject):
|
||||
line, buffer = buffer.split('\n', 1)
|
||||
line = line.strip()
|
||||
if line == 'int':
|
||||
self._refresh_command_processing()
|
||||
self.log_info('Vertex2 IP INTERRUPT received')
|
||||
pending_interrupt_at = time.monotonic()
|
||||
self._status_requested.set()
|
||||
elif status_started is not None:
|
||||
if line.startswith('insel '):
|
||||
@@ -235,7 +326,10 @@ class Vertex2(SmartObject):
|
||||
self.run_in(self._apply_vertex2_status, 0,
|
||||
rx0=snapshot['rx0'], rx1=snapshot['rx1'],
|
||||
tx0=snapshot['tx0'], tx1=snapshot['tx1'],
|
||||
audout=snapshot['audout'], insel=snapshot['insel'])
|
||||
audout=snapshot['audout'], insel=snapshot['insel'],
|
||||
reconfigure=reconfigure,
|
||||
interrupt_at=request_interrupt_at)
|
||||
reconfigure = False
|
||||
status_started = None
|
||||
|
||||
if status_started is not None and time.monotonic() - status_started > 5:
|
||||
@@ -244,6 +338,10 @@ class Vertex2(SmartObject):
|
||||
except (OSError, ValueError) as error:
|
||||
if interrupt_stop.is_set():
|
||||
break
|
||||
if not self._vertex2_powered():
|
||||
continue
|
||||
self._reset_no_signal_spam('unavailable')
|
||||
self._invalidate_plus5_sensors()
|
||||
self.unreachable_sensor.set_state(state='on')
|
||||
self.video_tx_signal_sensor.set_state(state='unavailable')
|
||||
for sensor in self.video_tx_signal_sensors.values():
|
||||
@@ -254,11 +352,29 @@ class Vertex2(SmartObject):
|
||||
self.audio_out_signal_sensor.set_state(state='unavailable')
|
||||
self.log_warning(f'Vertex2 IP INTERRUPT unavailable: {error}')
|
||||
interrupt_stop.wait(5)
|
||||
finally:
|
||||
with self._connection_lock:
|
||||
self._interrupt_connection = None
|
||||
|
||||
def _close_interrupt_connection(self):
|
||||
connection_lock = getattr(self, "_connection_lock", None)
|
||||
if connection_lock is None:
|
||||
return
|
||||
with connection_lock:
|
||||
connection = self._interrupt_connection
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
connection.close()
|
||||
|
||||
def _stop_interrupt_listener(self):
|
||||
self._reset_no_signal_spam('unavailable')
|
||||
interrupt_stop = getattr(self, '_interrupt_stop', None)
|
||||
if interrupt_stop is not None:
|
||||
interrupt_stop.set()
|
||||
self._close_interrupt_connection()
|
||||
interrupt_thread = getattr(self, '_interrupt_thread', None)
|
||||
if interrupt_thread is not None:
|
||||
# Allow the 10s connect timeout or 1s recv timeout to finish.
|
||||
@@ -296,6 +412,7 @@ class Vertex2(SmartObject):
|
||||
except KeyError: pass
|
||||
|
||||
def on_update_input_selection(self):
|
||||
input_selected = None
|
||||
for evaluator in self.input_selection_evaluators:
|
||||
if evaluator[0].evaluate(False) == SmartCondition.Result.Succeeded:
|
||||
evaluator[0].log_evaluation_result()
|
||||
@@ -304,7 +421,7 @@ class Vertex2(SmartObject):
|
||||
|
||||
self.log_info("Input selection conditions changed")
|
||||
|
||||
if input_selected != "None":
|
||||
if input_selected is not None and str(input_selected) != "None":
|
||||
self.select_input(input_selected)
|
||||
self.update_vertex2_states()
|
||||
|
||||
@@ -384,17 +501,101 @@ class Vertex2(SmartObject):
|
||||
else:
|
||||
self.toggle_filmmaker_mode(False)
|
||||
|
||||
def _vertex2_powered(self):
|
||||
return self._power_allowed and (
|
||||
self.power_plug_entity is None
|
||||
or self.get_state(self.power_plug_entity) == 'on'
|
||||
)
|
||||
|
||||
def _vertex2_ready(self):
|
||||
return self._vertex2_powered() and time.monotonic() >= self._startup_deadline
|
||||
|
||||
def _begin_startup(self):
|
||||
self._startup_deadline = time.monotonic() + self.startup_delay
|
||||
self.log_info(f'Waiting {self.startup_delay:g}s for Vertex2 to start')
|
||||
|
||||
def _flush_startup_commands(self):
|
||||
with self._connection_lock:
|
||||
commands = self._startup_commands
|
||||
self._startup_commands = []
|
||||
for cmd, value in commands:
|
||||
self.send_cmd(cmd, value)
|
||||
|
||||
def _reconfigure_vertex2(self):
|
||||
# Conditions may not have changed while the device was powered off.
|
||||
# Evaluate them again instead of relying on change callbacks or old commands.
|
||||
if self.power_plug_entity is not None:
|
||||
result = self.power_plug_conditions_evaluator.evaluate(False)
|
||||
self.on_change_power_plug_conditions(None, result)
|
||||
if not self._vertex2_ready():
|
||||
return
|
||||
|
||||
self.log_info('Reapplying Vertex2 configuration after connection restored')
|
||||
if self.input_selection_evaluators:
|
||||
self.on_update_input_selection()
|
||||
if self.output_selection_evaluators:
|
||||
# Also reapplies audio/video EDID and audio mute for the selected output.
|
||||
self.on_update_output_selection()
|
||||
else:
|
||||
selected_output = self.output_names.get(self.output_selected_sensor.get_state())
|
||||
if selected_output in (0, 1):
|
||||
self.apply_output_audio_settings(selected_output)
|
||||
|
||||
for output_index, conditions in self.output_config_conditions.items():
|
||||
evaluator = conditions.get('force_plus5')
|
||||
if evaluator is not None:
|
||||
self.on_output_plus5_condition_change(
|
||||
output_index, None, evaluator.evaluate(False),
|
||||
)
|
||||
if 'filmmaker_mode_conditions' in self.args:
|
||||
self.on_filmmaker_mode_conditions_change(
|
||||
None, self.filmmaker_mode_conditions.evaluate(False),
|
||||
)
|
||||
|
||||
def _suspend_vertex2(self):
|
||||
self._reset_no_signal_spam()
|
||||
self._invalidate_plus5_sensors()
|
||||
self._close_interrupt_connection()
|
||||
with self._connection_lock:
|
||||
self._startup_commands.clear()
|
||||
self._startup_deadline = 0
|
||||
self._status_requested.clear()
|
||||
self._update_output_video_sensors('', '')
|
||||
self.audio_out_signal_sensor.set_state(state='unavailable')
|
||||
self.unreachable_sensor.set_state(state='off')
|
||||
with self._command_processing_lock:
|
||||
self._command_processing_deadline = None
|
||||
if self._command_processing_handle is not None:
|
||||
self.cancel_timer(self._command_processing_handle)
|
||||
self._command_processing_handle = None
|
||||
if self.command_processing_sensor is not None:
|
||||
self.command_processing_sensor.set_state(state='off')
|
||||
|
||||
def on_power_plug_state_change(self, entity, attribute, old, new, kwargs):
|
||||
if self._vertex2_powered():
|
||||
if old != 'on' and new == 'on':
|
||||
self._begin_startup()
|
||||
self.update_vertex2_states()
|
||||
else:
|
||||
self._suspend_vertex2()
|
||||
|
||||
def on_change_power_plug_conditions(self,prev_result,result):
|
||||
if result == SmartCondition.Result.Succeeded:
|
||||
self._power_allowed = result == SmartCondition.Result.Succeeded
|
||||
if self._power_allowed:
|
||||
if self.get_state(self.power_plug_entity) != 'on':
|
||||
self._begin_startup()
|
||||
self.turn_on(self.power_plug_entity)
|
||||
else:
|
||||
# Close TCP before asking Home Assistant to cut the power.
|
||||
self._suspend_vertex2()
|
||||
self.turn_off(self.power_plug_entity)
|
||||
|
||||
def _refresh_command_processing(self, start=False):
|
||||
def _start_command_processing(self, only_if_active=False):
|
||||
# Only commands start a window; changed status may extend an active one.
|
||||
if self.command_processing_sensor is None:
|
||||
return
|
||||
with self._command_processing_lock:
|
||||
if not start and self.command_processing_sensor.get_state() != 'on':
|
||||
if only_if_active and self._command_processing_deadline is None:
|
||||
return
|
||||
if self._command_processing_handle is not None:
|
||||
self.cancel_timer(self._command_processing_handle)
|
||||
@@ -417,8 +618,16 @@ class Vertex2(SmartObject):
|
||||
self.command_processing_sensor.set_state(state='off')
|
||||
|
||||
def send_cmd(self,cmd,value = ''):
|
||||
if not self._vertex2_powered():
|
||||
return
|
||||
if not self._vertex2_ready():
|
||||
with self._connection_lock:
|
||||
self._startup_commands.append((cmd, value))
|
||||
return
|
||||
request = f'{self.args["vertex2_address"]}/cmd?{cmd}={value}'
|
||||
self._refresh_command_processing(start=True)
|
||||
if cmd in ('tx0plus5', 'tx1plus5'):
|
||||
self._invalidate_plus5_sensors()
|
||||
self._start_command_processing()
|
||||
try:
|
||||
r = get(request, timeout=5)
|
||||
if cmd == 'edid' and value.startswith(('audiotx', 'audioout')):
|
||||
@@ -429,6 +638,43 @@ class Vertex2(SmartObject):
|
||||
except (Timeout,ConnectionError):
|
||||
self.log_warning(f"{request} is unreachable")
|
||||
|
||||
finally:
|
||||
if cmd in ('tx0plus5', 'tx1plus5'):
|
||||
# Read back the device setting even if no IP interrupt follows.
|
||||
self._status_requested.set()
|
||||
|
||||
def _invalidate_plus5_sensors(self):
|
||||
for sensor in self.plus5_sensors.values():
|
||||
if sensor.get_state() != 'unavailable':
|
||||
sensor.set_state(state='unavailable')
|
||||
|
||||
def _refresh_plus5_sensors(self):
|
||||
if not self.plus5_sensors:
|
||||
return
|
||||
if not self._vertex2_ready():
|
||||
self._invalidate_plus5_sensors()
|
||||
return
|
||||
try:
|
||||
# Same read-only endpoint used by the Vertex2 configuration page.
|
||||
response = get(f'{self.args["vertex2_address"].rstrip("/")}/ssi/confpage.ssi', timeout=5)
|
||||
response.raise_for_status()
|
||||
config = response.json()
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError('expected a configuration object')
|
||||
except (RequestException, ValueError) as error:
|
||||
self._invalidate_plus5_sensors()
|
||||
self.log_warning(f'Vertex2 +5V readback failed: {error}')
|
||||
return
|
||||
if not self._vertex2_ready():
|
||||
self._invalidate_plus5_sensors()
|
||||
return
|
||||
for index in range(2):
|
||||
value = config.get(f'tx{index}plus5')
|
||||
state = {'0': 'off', '1': 'on'}.get(str(value), 'unavailable')
|
||||
sensor = self.plus5_sensors.get(index)
|
||||
if sensor is not None and sensor.get_state() != state:
|
||||
sensor.set_state(state=state)
|
||||
|
||||
def mute_tx0(self, mute):
|
||||
self.send_cmd("mutetx0audio","on" if mute else "off")
|
||||
|
||||
@@ -535,9 +781,8 @@ class Vertex2(SmartObject):
|
||||
return {"name": config, "icon": None}
|
||||
|
||||
def update_vertex2_states(self):
|
||||
if self.power_plug_entity is not None and self.get_state(self.power_plug_entity) != 'on':
|
||||
self.run_in(self._apply_vertex2_status, 0,
|
||||
rx0='', rx1='', tx0='', tx1='', audout='', insel='0 0')
|
||||
if not self._vertex2_powered():
|
||||
self._suspend_vertex2()
|
||||
return
|
||||
self._status_requested.set()
|
||||
|
||||
@@ -597,11 +842,8 @@ class Vertex2(SmartObject):
|
||||
self.allm_sensor.set_state(state=selected_allm)
|
||||
|
||||
def _apply_vertex2_status(self, kwargs):
|
||||
self.log_info(
|
||||
f"Vertex2 TCP status: RX0={kwargs['rx0']!r}, RX1={kwargs['rx1']!r}, "
|
||||
f"TX0={kwargs['tx0']!r}, TX1={kwargs['tx1']!r}, "
|
||||
f"AUDOUT={kwargs['audout']!r}, insel={kwargs['insel']!r}"
|
||||
)
|
||||
if not self._vertex2_powered():
|
||||
return
|
||||
try:
|
||||
portselect = [int(value) for value in kwargs['insel'].split()]
|
||||
if len(portselect) != 2:
|
||||
@@ -611,6 +853,22 @@ class Vertex2(SmartObject):
|
||||
self.unreachable_sensor.set_state(state='on')
|
||||
return
|
||||
|
||||
self._observe_no_signal_spam(kwargs)
|
||||
|
||||
# Ignore callback metadata (e.g. reconfigure) when comparing device state.
|
||||
snapshot = tuple(kwargs[field].strip() for field in
|
||||
('rx0', 'rx1', 'tx0', 'tx1', 'audout', 'insel'))
|
||||
previous = self._last_logged_status
|
||||
if snapshot != previous:
|
||||
self._last_logged_status = snapshot
|
||||
self.log_info(
|
||||
f"Vertex2 TCP status: RX0={kwargs['rx0']!r}, RX1={kwargs['rx1']!r}, "
|
||||
f"TX0={kwargs['tx0']!r}, TX1={kwargs['tx1']!r}, "
|
||||
f"AUDOUT={kwargs['audout']!r}, insel={kwargs['insel']!r}"
|
||||
)
|
||||
if previous is not None:
|
||||
self._start_command_processing(only_if_active=True)
|
||||
|
||||
if portselect[0] == 4: # 4 = copy TX1
|
||||
portselect[0] = portselect[1]
|
||||
if portselect[1] == 4: # 4 = copy TX0
|
||||
@@ -643,3 +901,7 @@ class Vertex2(SmartObject):
|
||||
if self.audio_out_signal_sensor.get_state() != audio_out_signal:
|
||||
self.audio_out_signal_sensor.set_state(state=audio_out_signal)
|
||||
self.unreachable_sensor.set_state(state='off')
|
||||
self._flush_startup_commands()
|
||||
if kwargs.get('reconfigure', False):
|
||||
self._reconfigure_vertex2()
|
||||
self._refresh_plus5_sensors()
|
||||
Reference in new issue
Block a user