Coverage for custom_components/remote_logger/remote_logger.py: 92%

106 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-07 04:46 +0000

1"""The remote_logger integration: ship HA system_log_event to an OTLP collector or syslog server.""" 

2 

3from __future__ import annotations 

4 

5import asyncio 

6import contextlib 

7import logging 

8from functools import partial 

9from typing import TYPE_CHECKING, Any 

10 

11import voluptuous as vol 

12from homeassistant.const import EVENT_HOMEASSISTANT_STOP 

13from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE, EVENT_HOMEASSISTANT_FINAL_WRITE, SupportsResponse, callback 

14 

15from .const import ( 

16 BACKEND_SYSLOG, 

17 CONF_BACKEND, 

18 CONF_CUSTOM_EVENTS, 

19 CONF_LOG_HA_CORE_ACTIVITY, 

20 CONF_LOG_HA_CORE_CHANGES, 

21 CONF_LOG_HA_EVENT_BODY, 

22 CONF_LOG_HA_FULL_STATE_CHANGES, 

23 CONF_LOG_HA_LIFECYCLE, 

24 CONF_LOG_HA_STATE_CHANGES, 

25 CORE_ACTIVITY_EVENTS, 

26 CORE_CHANGE_EVENTS, 

27 CORE_STATE_EVENTS, 

28 DOMAIN, 

29 EVENT_SYSTEM_LOG, 

30 LIFECYCLE_EVENTS, 

31 PLATFORMS, 

32) 

33from .otel.exporter import OtlpLogExporter 

34from .syslog.exporter import SyslogExporter 

35 

36REF_CANCEL_LISTENERS = "cancel_listeners" 

37REF_FLUSH_TASK = "flush_task" 

38REF_EXPORTER = "exporter" 

39 

40SERVICE_SEND_LOG = "send_log" 

41SERVICE_SEND_LOG_SCHEMA = vol.Schema({ 

42 vol.Required("event"): str, 

43 vol.Required("message"): str, 

44 vol.Optional("level", default="INFO"): vol.In(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), 

45 vol.Optional("attributes"): dict, 

46}) 

47 

48SERVICE_FLUSH = "flush" 

49 

50SERVICE_LAST_LOG = "last_log" 

51SERVICE_LAST_LOG_SCHEMA = vol.Schema({ 

52 vol.Required("config_entry_id"): str, 

53}) 

54 

55if TYPE_CHECKING: 

56 from collections.abc import Callable 

57 

58 from homeassistant.config_entries import ConfigEntry 

59 from homeassistant.core import HomeAssistant, ServiceCall 

60 

61 from custom_components.remote_logger.exporter import LogSubmission 

62 

63_LOGGER = logging.getLogger(__name__) 

64 

65 

66async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: 

67 """Reload the entry when options are updated.""" 

68 await hass.config_entries.async_reload(entry.entry_id) 

69 

70 

71async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 

72 """Set up remote logs from a config entry.""" 

73 backend = entry.data.get(CONF_BACKEND) 

74 

75 exporter: OtlpLogExporter | SyslogExporter 

76 if backend == BACKEND_SYSLOG: 

77 exporter = SyslogExporter(hass, entry) 

78 label: str = exporter.endpoint_desc 

79 else: 

80 exporter = OtlpLogExporter(hass, entry) 

81 label = exporter.endpoint_url 

82 

83 # Options take precedence over initial data for the three event-subscription keys 

84 opts = {**entry.data, **entry.options} 

85 

86 async def _flush_on_stop(_: Any) -> None: 

87 await exporter.disable_buffer() 

88 

89 cancel_listeners: list[Callable[[], None]] = [ 

90 hass.bus.async_listen(EVENT_SYSTEM_LOG, exporter.handle_event), 

91 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _flush_on_stop), 

92 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _flush_on_stop), 

93 hass.bus.async_listen_once(EVENT_HOMEASSISTANT_FINAL_WRITE, _flush_on_stop), 

94 entry.add_update_listener(_async_update_listener), 

95 ] 

96 _LOGGER.info("remote_logger: listening for system_log_event, exporting %s to %s", backend, label) 

97 

98 event_body: bool = bool(opts.get(CONF_LOG_HA_EVENT_BODY)) 

99 

100 if opts.get(CONF_LOG_HA_LIFECYCLE): 

101 cancel_listeners.extend( 

102 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) for et in LIFECYCLE_EVENTS 

103 ) 

104 _LOGGER.info("remote_logger: listening for HA lifecycle events") 

105 

106 if opts.get(CONF_LOG_HA_CORE_CHANGES): 

107 cancel_listeners.extend( 

108 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) for et in CORE_CHANGE_EVENTS 

109 ) 

110 _LOGGER.info("remote_logger: listening for HA core config events") 

111 

112 if opts.get(CONF_LOG_HA_STATE_CHANGES): 

113 cancel_listeners.extend( 

114 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, state_only=True, event_body=event_body)) 

115 for et in CORE_STATE_EVENTS 

116 ) 

117 _LOGGER.info("remote_logger: listening for HA state changes") 

118 

119 if opts.get(CONF_LOG_HA_FULL_STATE_CHANGES): 

120 cancel_listeners.extend( 

121 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, state_only=False, event_body=event_body)) 

122 for et in CORE_STATE_EVENTS 

123 ) 

124 _LOGGER.info("remote_logger: listening for HA state changes") 

125 

126 if opts.get(CONF_LOG_HA_CORE_ACTIVITY): 

127 cancel_listeners.extend( 

128 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) 

129 for et in CORE_ACTIVITY_EVENTS 

130 ) 

131 _LOGGER.info("remote_logger: listening for HA core activity") 

132 

133 custom_events_raw = opts.get(CONF_CUSTOM_EVENTS, "") 

134 cancel_listeners.extend( 

135 hass.bus.async_listen(et, partial(exporter.handle_ha_event, et, event_body=event_body)) 

136 for et in (e.strip() for e in custom_events_raw.splitlines() if e.strip()) 

137 ) 

138 

139 flush_task: asyncio.Task[None] = asyncio.create_task(exporter.flush_loop()) 

140 

141 hass.data.setdefault(DOMAIN, {}) 

142 hass.data[DOMAIN][entry.entry_id] = { 

143 REF_CANCEL_LISTENERS: cancel_listeners, 

144 REF_FLUSH_TASK: flush_task, 

145 REF_EXPORTER: exporter, 

146 } 

147 

148 if not hass.services.has_service(DOMAIN, SERVICE_SEND_LOG): 

149 hass.services.async_register( 

150 DOMAIN, SERVICE_SEND_LOG, partial(handle_send_log, hass.data[DOMAIN]), schema=SERVICE_SEND_LOG_SCHEMA 

151 ) 

152 

153 if not hass.services.has_service(DOMAIN, SERVICE_FLUSH): 

154 hass.services.async_register(DOMAIN, SERVICE_FLUSH, partial(handle_flush, hass.data[DOMAIN])) 

155 

156 if not hass.services.has_service(DOMAIN, SERVICE_LAST_LOG): 

157 hass.services.async_register( 

158 DOMAIN, 

159 SERVICE_LAST_LOG, 

160 partial(handle_last_log, hass.data[DOMAIN]), 

161 schema=SERVICE_LAST_LOG_SCHEMA, 

162 supports_response=SupportsResponse.ONLY, 

163 ) 

164 

165 await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) 

166 

167 return True 

168 

169 

170async def handle_flush(domain_data: dict[str, Any], _call: ServiceCall) -> None: 

171 for entry in domain_data.values(): 

172 await entry[REF_EXPORTER].flush() 

173 

174 

175@callback 

176def handle_last_log(domain_data: dict[str, Any], call: ServiceCall) -> dict[str, Any]: 

177 entry_id: str | None = call.data.get("config_entry_id") 

178 entry = None 

179 if entry_id: 

180 entry = domain_data.get(entry_id) 

181 if entry is None: 

182 return {} 

183 submission: LogSubmission | None = entry[REF_EXPORTER].last_sent_payload 

184 if submission is None: 

185 return {} 

186 return submission.for_display() 

187 

188 

189@callback 

190def handle_send_log(domain_data: dict[str, Any], call: ServiceCall) -> None: 

191 message: str = call.data["message"] 

192 level: str = call.data["level"] 

193 event_name: str = call.data["event"] 

194 attributes: dict[str, Any] | None = call.data.get("attributes") 

195 for entry in domain_data.values(): 

196 entry[REF_EXPORTER].log_direct(event_name, message, level, attributes) 

197 

198 

199async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 

200 """Unload remote_logger config entry. 

201 

202 https://developers.home-assistant.io/docs/config_entries_index#unloading-entries 

203 """ 

204 await hass.config_entries.async_unload_platforms(entry, ["sensor"]) 

205 data = hass.data[DOMAIN].pop(entry.entry_id, None) 

206 if data is None: 

207 return True 

208 

209 for cancel in data.get(REF_CANCEL_LISTENERS, []): 

210 try: 

211 cancel() 

212 except Exception as e: 

213 _LOGGER.warning("Failed to cancel listener on unload: %s", e) 

214 

215 if data.get(REF_FLUSH_TASK): 

216 data[REF_FLUSH_TASK].cancel() 

217 with contextlib.suppress(asyncio.CancelledError): 

218 await data[REF_FLUSH_TASK] 

219 

220 if data.get(REF_EXPORTER): 

221 await data[REF_EXPORTER].flush() 

222 await data[REF_EXPORTER].close() 

223 

224 _LOGGER.info("remote_logger: unloaded, flushed remaining logs") 

225 return True