app/services/import/configuration_importer.rb
Rama develop · réplica de lectura del repositorio original.
module Import
class ConfigurationImporter # rubocop:disable Metrics/ClassLength
include Helpers
# Sections whose data comes from a uniform `section_data` extractor call.
# The remaining keys (sensors, senec, mqtt, shelly, devices, unmanaged)
# need custom handling because of mode differences or non-extractor
# sources, so they're merged in inside `full_result` / `collectors_only_result`.
UNIFORM_FULL_EXTRACTORS = %i[
deployment system dashboard postgresql influxdb redis watchtower ingest power_splitter
public_data_collector forecast tibber senec_charger reverse_proxy backup helios
].freeze
UNIFORM_COLLECTORS_ONLY_EXTRACTORS = %i[
deployment system influxdb watchtower forecast tibber helios
].freeze
def initialize(stack_reader)
@reader = stack_reader
end
# Extracted data as plain hashes (no DB access). `partial_result` is the
# full payload minus the unmanaged section; the unmanaged detector
# depends on a dry-run that runs after the singletons are persisted, so
# callers that don't need the unmanaged section should prefer
# `partial_result` to avoid triggering detection too early.
def result
@result ||= partial_result.merge(unmanaged: unmanaged_detector.detect)
end
def partial_result
@partial_result ||= collectors_only? ? collectors_only_partial_result : full_partial_result
end
# Persist extracted data into config.yaml
def import!
config = Configuration.current
persist_singletons!(config)
sensor_persister.persist!(config) unless collectors_only?
mark_balcony_sensor!(config)
# Snapshot which env keys HELIOS will canonically emit. Runs after the
# singleton sections have been persisted (so Configuration is fully
# populated) but before unmanaged detection consumes the snapshot.
@emitted_canonical_keys = compute_emitted_canonical_keys(config)
persist_unmanaged!(config)
config
end
# Ingest-relevant sensors the import would store as `source: external`
# while HELIOS runs Ingest. Their source writes to InfluxDB today and has to
# write to Ingest from now on, otherwise the house power stays empty (see
# Configuration#ingest_required?). Non-empty → the start page says so before
# the user adopts the stack; the import itself goes ahead.
def external_ingest_sensors
return [] if collectors_only?
# No balcony → HELIOS runs no Ingest, so an external source changes nothing.
# (balcony_detector, not the dry-run config, since the is_balcony flag is
# only set later in #mark_balcony_sensor!.)
return [] if balcony_detector.sensor_names.empty?
dryrun_configuration.external_ingest_inputs
end
def collectors_only?
return @collectors_only if defined?(@collectors_only)
services = @reader.services
has_local_target = services.key?('dashboard') || services.key?('influxdb')
has_any_collector = StackReader::COLLECTOR_SERVICES.any? { |s| services.key?(s) }
@collectors_only = !has_local_target && has_any_collector
end
# Dashboard + InfluxDB run locally, the InfluxDB is exposed for remote
# writes, and nothing local reads a device: no device collector
# (senec/mqtt/shelly). So the collectors run
# elsewhere and push into this stack's InfluxDB. forecast-collector and
# power-splitter are fine (they're not device collectors). Mutually
# exclusive with collectors_only? (which has no local target at all).
def dashboard_only?
return @dashboard_only if defined?(@dashboard_only)
services = @reader.services
has_dashboard = services.key?('dashboard')
has_influxdb = services.key?('influxdb')
@dashboard_only = has_dashboard && has_influxdb && influxdb_exposed? && !local_devices?
end
# Resolved deployment mode (collectors_only and dashboard_only are mutually
# exclusive; everything else is the implicit full default).
def mode
if collectors_only?
ConfigSchema::MODE_COLLECTORS_ONLY
elsif dashboard_only?
ConfigSchema::MODE_DASHBOARD_ONLY
else
ConfigSchema::MODE_FULL
end
end
private
# Whether anything in the stack speaks to a device on site.
def local_devices?
StackReader::DEVICE_COLLECTOR_SERVICES.any? { |name| @reader.services.key?(name) }
end
# True when the imported compose publishes the InfluxDB container port 8086
# to the host (the defining trait of dashboard_only: remote collectors write
# in across the LAN).
def influxdb_exposed?
port = Export::Services::Influxdb::CONTAINER_PORT
Array(@reader.service('influxdb')&.dig('ports')).any? { |entry| container_port(entry) == port }
end
# --- Extractors (lazy-initialized) ---
def senec_extractor
@senec_extractor ||= SenecExtractor.new(@reader)
end
def shelly_extractor
@shelly_extractor ||= ShellyExtractor.new(@reader, sensors_data)
end
def mqtt_extractor
@mqtt_extractor ||= MqttExtractor.new(@reader, sensors_data)
end
def forecast_extractor
@forecast_extractor ||= ForecastExtractor.new(@reader)
end
def tibber_extractor
@tibber_extractor ||= TibberExtractor.new(@reader)
end
def senec_charger_extractor
@senec_charger_extractor ||= SenecChargerExtractor.new(@reader)
end
def watchtower_extractor
@watchtower_extractor ||= WatchtowerExtractor.new(@reader)
end
def system_extractor
@system_extractor ||= SystemExtractor.new(
@reader,
watchtower_interval: watchtower_extractor.interval,
watchtower_schedule: watchtower_extractor.schedule,
proxy_domain: reverse_proxy_extractor.domain,
)
end
def deployment_extractor
@deployment_extractor ||= DeploymentExtractor.new(mode:)
end
# A stack whose Traefik HELIOS adopts gets FORCE_SSL from the reverse-proxy
# mode alone, so the dashboard extractor must not persist the flag a second
# time. An external proxy is different: nothing there implies the flag, so
# it is kept.
def dashboard_extractor
@dashboard_extractor ||=
DashboardExtractor.new(@reader, traefik_managed: reverse_proxy_extractor.managed?)
end
def redis_extractor
@redis_extractor ||= RedisExtractor.new(@reader, volume_resolver)
end
def postgresql_extractor
@postgresql_extractor ||= PostgresqlExtractor.new(@reader, volume_resolver)
end
def influxdb_extractor
@influxdb_extractor ||= InfluxdbExtractor.new(@reader, volume_resolver, collectors_only: collectors_only?)
end
def reverse_proxy_extractor
@reverse_proxy_extractor ||= ReverseProxyExtractor.new(@reader, volume_resolver)
end
def backup_extractor
@backup_extractor ||= BackupExtractor.new(@reader)
end
def ingest_extractor
@ingest_extractor ||= IngestExtractor.new(@reader, volume_resolver, balcony_detector)
end
def helios_extractor
@helios_extractor ||= HeliosExtractor.new(@reader)
end
def power_splitter_extractor
@power_splitter_extractor ||= PowerSplitterExtractor.new(@reader)
end
def public_data_collector_extractor
@public_data_collector_extractor ||= PublicDataCollectorExtractor.new(@reader)
end
def sensors_extractor
@sensors_extractor ||= SensorsExtractor.new(
@reader,
senec_measurement: senec_extractor.measurement,
forecast_measurement: forecast_extractor.measurement,
)
end
def unmanaged_detector
@unmanaged_detector ||= UnmanagedDetector.new(
@reader,
known_measurements:,
traefik_adopted: reverse_proxy_extractor.managed?,
emitted_canonical_keys: emitted_canonical_keys,
)
end
# Dry-run Export::Env on the persisted Configuration so the unmanaged
# detector can ask "will HELIOS canonically emit this var?" instead of
# approximating via a hand-curated MANAGED_ENV_KEYS list. The latter
# silently mis-skipped conditionally-emitted vars (SENEC_HOST in cloud
# mode, SOLCAST_* without solcast forecast, etc.) and dropped values that
# unmanaged services still needed at runtime.
#
# `import!` overrides @emitted_canonical_keys early with the persisted
# Configuration. Tests that hit `result` directly fall through to a
# dry-run on a Configuration assembled from the in-memory partial result.
def emitted_canonical_keys
@emitted_canonical_keys ||= compute_emitted_canonical_keys(dryrun_configuration)
end
def compute_emitted_canonical_keys(config)
env_file = ::Env::File.new(File::NULL)
Export::Env::SECTIONS.each do |klass, enabled|
next if klass == Export::Env::Unmanaged
next unless enabled.call(config)
klass.new(env_file, config).call
end
env_file.keys.to_set
end
# Build a Configuration from the in-memory partial result, running the
# SensorPersister so sensors are stored as the hash-of-hashes shape that
# Configuration's accessors expect — extractor output is a flat
# name → measurement string map and would crash Data.wrap.
def dryrun_configuration
config = Configuration.from_data(partial_result.except(:sensors))
sensor_persister.persist!(config) unless collectors_only?
config
end
def volume_resolver
@volume_resolver ||= VolumeResolver.new(@reader)
end
def balcony_detector
@balcony_detector ||= BalconyDetector.new(@reader, sensors_data)
end
def sensors_data
sensors_extractor.sensors_data
end
def known_measurements
from_sensors = sensors_data.values.filter_map { |v| v.to_s.split(':', 2).first.presence }
from_mqtt = mqtt_extractor.enabled? ? mqtt_extractor.mappings.filter_map { |m| m[:measurement].presence } : []
(from_sensors + from_mqtt).uniq
end
def sensor_persister
@sensor_persister ||= SensorPersister.new(
sensors_data:,
devices: partial_result[:devices],
enabled_collectors: enabled_collectors,
mqtt_mappings: mqtt_extractor.enabled? ? mqtt_extractor.mappings : [],
excluded_sensors: sensors_extractor.excluded_sensor_names,
senec_measurement: senec_extractor.measurement,
forecast_measurement: forecast_extractor.measurement,
)
end
def enabled_collectors
[
(:senec if senec_extractor.enabled?),
(:forecast if forecast_extractor.enabled?),
].compact
end
# --- Result building ---
def full_partial_result
@full_partial_result ||= uniform_sections(UNIFORM_FULL_EXTRACTORS).merge(
sensors: sensors_data,
senec: senec_extractor.section_data,
mqtt: mqtt_section_data,
shelly: shelly_section_data,
devices: build_devices,
)
end
# Sensor canonicalization lives on the remote dashboard host, so HELIOS
# cannot reliably map collector env vars back to canonical sensor names.
# Collector connection data (hosts, credentials) is extracted into the
# usual senec/shelly/mqtt sections; the opaque mapping payload is kept
# as a raw list in mqtt.mappings and shelly.devices.
def collectors_only_partial_result
@collectors_only_partial_result ||= uniform_sections(UNIFORM_COLLECTORS_ONLY_EXTRACTORS).merge(
senec: collectors_only_senec_data,
mqtt: collectors_only_mqtt_data,
shelly: collectors_only_shelly_data,
)
end
def uniform_sections(keys)
keys.index_with { |key| send("#{key}_extractor").section_data }
end
def collectors_only_senec_data
data = senec_extractor.section_data
return nil unless data
data.merge('image' => senec_extractor.image).compact
end
def collectors_only_mqtt_data
broker = mqtt_extractor.broker_data
mappings = mqtt_extractor.raw_mappings
data = (broker || {}).merge(image_data_for('mqtt-collector'),
'mappings' => mappings.presence).compact
data.presence
end
# Full-mode mqtt section: broker plus orphan mappings (preserved so the
# InfluxDB time series stays gap-free across re-export).
def mqtt_section_data
broker = mqtt_extractor.broker_data
orphans = mqtt_extractor.orphan_mappings
data = (broker || {}).merge('mappings' => orphans.presence).compact
data.presence
end
def collectors_only_shelly_data
shelly_section_data(include_image: true)
end
# Shelly section: connection/interval plus, only for standalone devices,
# the per-device list. A device that feeds a `source: shelly` sensor lives
# on that sensor (with its own device_id/host) via SensorPersister, so it
# appears exactly once in config.yaml. shelly.devices therefore holds only
# devices no sensor consumes — see #standalone_shelly_devices.
def shelly_section_data(include_image: false)
section = shelly_extractor.section_data
return nil unless section
extras = {
'password' => shelly_extractor.shared_password,
'devices' => standalone_shelly_devices.presence,
}
# Only collectors-only stacks pin the donor image; full mode manages the
# collector image centrally (same as senec/mqtt), so it stays unset and
# the export falls back to the HELIOS baseline.
extras.merge!(image_data_for('shelly-collector')) if include_image
section.merge(extras).compact.presence
end
# Shelly devices that no imported sensor consumes. In collectors_only mode
# HELIOS imports no logical sensors (canonicalization happens on the remote
# dashboard host), so the full device list survives here as the round-trip
# source of truth. In full mode a device feeding a `source: shelly` sensor
# is represented on that sensor instead, leaving only true standalone
# devices in shelly.devices.
def standalone_shelly_devices
devices = shelly_extractor.raw_devices
return devices if collectors_only?
devices.reject { |device| shelly_sensor_claims_measurement?(device['measurement']) }
end
# True when a `source: shelly` sensor will claim this measurement — i.e. a
# sensor maps it to a Shelly power field. That mirrors the importer's source
# inference (SensorPersister#shelly_device_provides_sensor?): exactly those
# devices get folded onto their sensor, so they must not also linger in
# shelly.devices. A measurement consumed only by a non-power (external/mqtt)
# sensor — or by no sensor at all — stays a standalone device.
def shelly_sensor_claims_measurement?(measurement)
return false if measurement.blank?
claims = SensorMappings::SHELLY_POWER_FIELDS.map { |field| "#{measurement}:#{field}" }
sensors_data.values.any? { |mapping| claims.include?(mapping.to_s) }
end
def build_devices
devices = []
devices << senec_extractor.device_data if senec_extractor.enabled?
devices.concat(shelly_extractor.device_data) if shelly_extractor.enabled?
devices.concat(mqtt_extractor.device_data) if mqtt_extractor.enabled?
devices
end
# --- Persistence ---
def persist_singletons!(config)
%i[deployment system dashboard postgresql influxdb redis watchtower ingest power_splitter
public_data_collector sensors forecast senec mqtt tibber senec_charger shelly reverse_proxy
backup helios].each do |key|
config.update(key.to_s, partial_result[key]) if partial_result[key]
end
end
def persist_unmanaged!(config)
unmanaged = result[:unmanaged]
config.update_unmanaged(unmanaged) if unmanaged.present?
end
def mark_balcony_sensor!(config)
balcony_detector.sensor_names.each do |name|
existing = config.sensor_config(name).to_h
config.update_sensor(name, existing.merge('is_balcony' => true))
end
end
end
end