Ctrl K
ComunidadGuías…

app/services/import/configuration_importer.rb

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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