loading
Generated 2025-04-28T11:42:22-05:00

All Files ( 12.08% covered at 38.84 hits/line )

37 files in total.
1035 relevant lines, 125 lines covered and 910 lines missed. ( 12.08% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/controllers/application_controller.rb 81.25 % 49 16 13 3 1.81
app/controllers/authenticated_application_controller.rb 0.00 % 7 3 0 3 0.00
app/controllers/control_signals_controller.rb 0.00 % 110 57 0 57 0.00
app/controllers/home_controller.rb 0.00 % 20 6 0 6 0.00
app/controllers/mqtt_controller.rb 0.00 % 91 49 0 49 0.00
app/controllers/plant_modules_controller.rb 0.00 % 201 114 0 114 0.00
app/controllers/plants_controller.rb 100.00 % 66 17 17 0 1.88
app/controllers/sensors_controller.rb 0.00 % 158 109 0 109 0.00
app/controllers/users/omniauth_callbacks_controller.rb 0.00 % 19 16 0 16 0.00
app/controllers/users/sessions_controller.rb 0.00 % 8 8 0 8 0.00
app/controllers/users_controller.rb 0.00 % 30 12 0 12 0.00
app/helpers/application_helper.rb 100.00 % 5 1 1 0 1.00
app/helpers/control_signals_helper.rb 14.29 % 75 28 4 24 0.14
app/helpers/plants_helper.rb 100.00 % 27 8 8 0 11.25
app/helpers/zip_code_helper.rb 78.57 % 50 14 11 3 2852.79
app/jobs/application_job.rb 0.00 % 11 2 0 2 0.00
app/jobs/sensor_notification_job.rb 0.00 % 80 49 0 49 0.00
app/mailers/application_mailer.rb 0.00 % 8 4 0 4 0.00
app/mailers/sensor_mailer.rb 0.00 % 27 12 0 12 0.00
app/models/application_record.rb 100.00 % 6 2 2 0 1.00
app/models/care_schedule.rb 0.00 % 33 9 0 9 0.00
app/models/control_execution.rb 0.00 % 46 9 0 9 0.00
app/models/control_signal.rb 0.00 % 43 7 0 7 0.00
app/models/module_plant.rb 0.00 % 33 10 0 10 0.00
app/models/photo.rb 0.00 % 19 4 0 4 0.00
app/models/plant.rb 80.00 % 33 5 4 1 4.40
app/models/plant_module.rb 0.00 % 53 11 0 11 0.00
app/models/schedule.rb 0.00 % 17 3 0 3 0.00
app/models/sensor.rb 0.00 % 32 9 0 9 0.00
app/models/sensor_notification_log.rb 0.00 % 19 3 0 3 0.00
app/models/time_series_datum.rb 0.00 % 37 9 0 9 0.00
app/models/user.rb 40.00 % 39 15 6 9 0.40
app/services/care_schedule_calculator.rb 0.00 % 115 67 0 67 0.00
app/services/mqtt_listener.rb 12.93 % 475 232 30 202 0.13
app/services/plant_recommendation_service.rb 93.55 % 95 31 29 2 1.45
app/services/timelapse_generator.rb 0.00 % 113 64 0 64 0.00
app/workers/timelapse_worker.rb 0.00 % 37 20 0 20 0.00

Controllers ( 7.37% covered at 0.15 hits/line )

11 files in total.
407 relevant lines, 30 lines covered and 377 lines missed. ( 7.37% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/controllers/application_controller.rb 81.25 % 49 16 13 3 1.81
app/controllers/authenticated_application_controller.rb 0.00 % 7 3 0 3 0.00
app/controllers/control_signals_controller.rb 0.00 % 110 57 0 57 0.00
app/controllers/home_controller.rb 0.00 % 20 6 0 6 0.00
app/controllers/mqtt_controller.rb 0.00 % 91 49 0 49 0.00
app/controllers/plant_modules_controller.rb 0.00 % 201 114 0 114 0.00
app/controllers/plants_controller.rb 100.00 % 66 17 17 0 1.88
app/controllers/sensors_controller.rb 0.00 % 158 109 0 109 0.00
app/controllers/users/omniauth_callbacks_controller.rb 0.00 % 19 16 0 16 0.00
app/controllers/users/sessions_controller.rb 0.00 % 8 8 0 8 0.00
app/controllers/users_controller.rb 0.00 % 30 12 0 12 0.00

Channels ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line

Models ( 12.5% covered at 0.31 hits/line )

13 files in total.
96 relevant lines, 12 lines covered and 84 lines missed. ( 12.5% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/models/application_record.rb 100.00 % 6 2 2 0 1.00
app/models/care_schedule.rb 0.00 % 33 9 0 9 0.00
app/models/control_execution.rb 0.00 % 46 9 0 9 0.00
app/models/control_signal.rb 0.00 % 43 7 0 7 0.00
app/models/module_plant.rb 0.00 % 33 10 0 10 0.00
app/models/photo.rb 0.00 % 19 4 0 4 0.00
app/models/plant.rb 80.00 % 33 5 4 1 4.40
app/models/plant_module.rb 0.00 % 53 11 0 11 0.00
app/models/schedule.rb 0.00 % 17 3 0 3 0.00
app/models/sensor.rb 0.00 % 32 9 0 9 0.00
app/models/sensor_notification_log.rb 0.00 % 19 3 0 3 0.00
app/models/time_series_datum.rb 0.00 % 37 9 0 9 0.00
app/models/user.rb 40.00 % 39 15 6 9 0.40

Mailers ( 0.0% covered at 0.0 hits/line )

2 files in total.
16 relevant lines, 0 lines covered and 16 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/mailers/application_mailer.rb 0.00 % 8 4 0 4 0.00
app/mailers/sensor_mailer.rb 0.00 % 27 12 0 12 0.00

Helpers ( 47.06% covered at 784.98 hits/line )

4 files in total.
51 relevant lines, 24 lines covered and 27 lines missed. ( 47.06% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/helpers/application_helper.rb 100.00 % 5 1 1 0 1.00
app/helpers/control_signals_helper.rb 14.29 % 75 28 4 24 0.14
app/helpers/plants_helper.rb 100.00 % 27 8 8 0 11.25
app/helpers/zip_code_helper.rb 78.57 % 50 14 11 3 2852.79

Jobs ( 0.0% covered at 0.0 hits/line )

3 files in total.
71 relevant lines, 0 lines covered and 71 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/jobs/application_job.rb 0.00 % 11 2 0 2 0.00
app/jobs/sensor_notification_job.rb 0.00 % 80 49 0 49 0.00
app/workers/timelapse_worker.rb 0.00 % 37 20 0 20 0.00

Libraries ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line

Ungrouped ( 14.97% covered at 0.19 hits/line )

4 files in total.
394 relevant lines, 59 lines covered and 335 lines missed. ( 14.97% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/services/care_schedule_calculator.rb 0.00 % 115 67 0 67 0.00
app/services/mqtt_listener.rb 12.93 % 475 232 30 202 0.13
app/services/plant_recommendation_service.rb 93.55 % 95 31 29 2 1.45
app/services/timelapse_generator.rb 0.00 % 113 64 0 64 0.00

app/controllers/application_controller.rb

81.25% lines covered

16 relevant lines. 13 lines covered and 3 lines missed.
    
  1. # Base controller for the application
  2. #
  3. # @abstract This controller provides common functionality for all controllers
  4. # in the application including browser compatibility checks, helper inclusion,
  5. # and parameter sanitization.
  6. 1 class ApplicationController < ActionController::Base
  7. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  8. 1 allow_browser versions: :modern
  9. 1 include ZipCodeHelper
  10. 1 before_action :set_navbar_links
  11. 1 before_action :configure_permitted_parameters, if: :devise_controller?
  12. 1 private
  13. # Sets up navigation links based on user authentication status
  14. #
  15. # @return [void]
  16. 1 def set_navbar_links
  17. @page_links = [
  18. 5 { name: "Home", path: root_path }
  19. # { name: 'Posts (TODO)', path: root_path },
  20. # { name: 'Advice (TODO)', path: root_path },
  21. # { name: 'Data (TODO)', path: root_path },
  22. # { name: 'Schedules (TODO)', path: root_path },
  23. # { name: 'Settings (TODO)', path: root_path },
  24. # { name: 'Profile (TODO)', path: root_path },
  25. ]
  26. 5 if user_signed_in?
  27. @page_links << { name: "Create Module", path: new_plant_module_path }
  28. else
  29. 5 @page_links << { name: "Login", path: new_user_session_path }
  30. end
  31. 5 @page_links << { name: "Help", path: help_path }
  32. end
  33. 1 protected
  34. # Configures permitted parameters for Devise
  35. #
  36. # @note Allows zip_code to be submitted during user sign up and account update
  37. # @return [void]
  38. 1 def configure_permitted_parameters
  39. # Add zip_code to sign up and account update
  40. devise_parameter_sanitizer.permit(:sign_up, keys: [ :zip_code ])
  41. devise_parameter_sanitizer.permit(:account_update, keys: [ :zip_code ])
  42. end
  43. end

app/controllers/authenticated_application_controller.rb

0.0% lines covered

3 relevant lines. 0 lines covered and 3 lines missed.
    
  1. # Base controller requiring authentication
  2. #
  3. # @abstract This controller ensures that users are authenticated before accessing
  4. # any actions in controllers that inherit from it
  5. class AuthenticatedApplicationController < ApplicationController
  6. before_action :authenticate_user!
  7. end

app/controllers/control_signals_controller.rb

0.0% lines covered

57 relevant lines. 0 lines covered and 57 lines missed.
    
  1. # Controller for managing control signals for plant modules
  2. #
  3. # This controller handles modification and triggering of control signals
  4. # such as lights, water pumps, and fans for plant modules.
  5. #
  6. # @example Request to edit a control signal
  7. # GET /plant_modules/123/control_signals/456/edit
  8. #
  9. # @example Request to trigger a control signal
  10. # POST /control_signals/456/trigger
  11. class ControlSignalsController < AuthenticatedApplicationController
  12. include ControlSignalsHelper
  13. before_action :set_plant_module
  14. before_action :set_control_signal, only: [ :edit, :update ]
  15. # Displays the form to edit a control signal
  16. #
  17. # @param plant_module_id [String] ID of the plant module
  18. # @param id [String] ID of the control signal to edit
  19. # @return [void]
  20. def edit
  21. @length_unit = @control_signal.length_unit
  22. @length = @control_signal.length
  23. end
  24. # Updates a control signal
  25. #
  26. # @param plant_module_id [String] ID of the plant module
  27. # @param id [String] ID of the control signal to update
  28. # @param control_signal [Hash] control signal parameters
  29. # @return [void]
  30. def update
  31. # # Combine value and unit to compute length
  32. # length_unit = params[:length_unit]
  33. # # Inject computed length into control_signal params
  34. # params[:control_signal][:length] = length
  35. if @control_signal.update(control_signal_params)
  36. redirect_to plant_module_path(@plant_module), success: "Control signal updated."
  37. else
  38. flash.now[:alert] = "Update failed."
  39. render :edit
  40. end
  41. end
  42. # Triggers a control signal to turn on or off
  43. #
  44. # @param id [String] ID of the control signal to trigger
  45. # @param toggle [String] "true" to toggle state, otherwise maintains current state
  46. # @return [void]
  47. def trigger
  48. control_signal = ControlSignal.find(params[:id])
  49. last_exec = ControlExecution.where(control_signal_id: control_signal.id)
  50. .order(executed_at: :desc)
  51. .first
  52. if last_exec.nil?
  53. flash.now[:alert] = "No previous execution found for this control signal."
  54. render turbo_stream: turbo_stream.update("flash", partial: "shared/flash"), status: :unprocessable_entity
  55. return
  56. end
  57. MqttListener.publish_control_command(control_signal, toggle: params[:toggle] == "true", mode: "manual", duration: control_signal.length, status: !last_exec.status)
  58. if !last_exec.status
  59. flash.now[:success] = "Turned #{control_signal.label || control_signal.signal_type} On for #{format_duration(control_signal.length, control_signal.length_unit)}"
  60. else
  61. flash.now[:alert] = "Turned #{control_signal.label || control_signal.signal_type} Off"
  62. end
  63. render turbo_stream: [
  64. turbo_stream.update("flash", partial: "shared/flash"),
  65. turbo_stream.update("control_execution", partial: "control_executions/control_execution", locals: { control_execution: control_signal.control_executions.order(executed_at: :desc).first }),
  66. turbo_stream.update("control_toggle_button_#{control_signal.id}", partial: "control_signals/control_toggle_button", locals: { signal: control_signal })
  67. ]
  68. rescue => e
  69. Rails.logger.error "Trigger error: #{e.message}"
  70. flash.now[:alert] = "Trigger failed: #{e.message}"
  71. render turbo_stream: turbo_stream.update("flash", partial: "shared/flash"), status: :unprocessable_entity
  72. end
  73. private
  74. # Sets the plant module for the current request
  75. #
  76. # @return [void]
  77. def set_plant_module
  78. @plant_module = PlantModule.find(params[:plant_module_id])
  79. end
  80. # Sets the control signal for the current request
  81. #
  82. # @return [void]
  83. def set_control_signal
  84. @control_signal = @plant_module.control_signals.find(params[:id])
  85. end
  86. # Permits control signal parameters for mass assignment
  87. #
  88. # @return [ActionController::Parameters] permitted parameters
  89. def control_signal_params
  90. params.require(:control_signal).permit(
  91. :label, :signal_type, :delay, :length, :length_unit,
  92. :mode, :sensor_id, :comparison, :threshold_value,
  93. :frequency, :unit, :enabled, :scheduled_time
  94. )
  95. end
  96. end

app/controllers/home_controller.rb

0.0% lines covered

6 relevant lines. 0 lines covered and 6 lines missed.
    
  1. # Controller for static home pages
  2. #
  3. # @example Request to welcome page
  4. # GET /
  5. #
  6. # @example Request to help page
  7. # GET /help
  8. class HomeController < ApplicationController
  9. # Renders the welcome/landing page
  10. #
  11. # @return [void]
  12. def welcome
  13. end
  14. # Renders the help/documentation page
  15. #
  16. # @return [void]
  17. def help
  18. end
  19. end

app/controllers/mqtt_controller.rb

0.0% lines covered

49 relevant lines. 0 lines covered and 49 lines missed.
    
  1. require "mqtt"
  2. # Controller for sending MQTT messages to plant modules
  3. #
  4. # This controller handles sending control signals via MQTT protocol
  5. # to the physical plant modules for actions like watering or changing
  6. # light schedules.
  7. class MqttController < AuthenticatedApplicationController
  8. before_action :set_secrets
  9. before_action :validate_set_schedule_params, only: [ :set_schedule ]
  10. before_action :validate_send_water_signal_params, only: [ :send_water_signal ]
  11. before_action :authorize_user, only: [ :set_schedule, :send_water_signal ]
  12. # Sets a watering schedule for a plant module
  13. #
  14. # @param plant_module_id [String] ID of the plant module to update
  15. # @param frequency [String] frequency of watering
  16. # @param units [String] time units for frequency (e.g., "hours", "days")
  17. # @return [void]
  18. def set_schedule
  19. message = { frequency: params[:frequency], units: params[:units] }.to_json
  20. publish_data(message, "#{@secrets[:topic]}/#{params[:plant_module_id]}/set_schedule")
  21. redirect_back fallback_location: plant_modules_path, notice: "Schedule set successfully"
  22. rescue => e
  23. redirect_back fallback_location: plant_modules_path, alert: "Error: #{e.message}"
  24. end
  25. # Sends a manual watering signal to a plant module
  26. #
  27. # @param plant_module_id [String] ID of the plant module to water
  28. # @return [void]
  29. def send_water_signal
  30. message = { water: true }.to_json
  31. publish_data(message, "#{@secrets[:topic]}/#{params[:plant_module_id]}/water")
  32. redirect_back fallback_location: plant_modules_path, notice: "Water signal sent successfully"
  33. rescue => e
  34. redirect_back fallback_location: plant_modules_path, alert: "Error: #{e.message}"
  35. end
  36. private
  37. # Sets MQTT broker credentials from Rails credentials
  38. #
  39. # @return [void]
  40. def set_secrets
  41. @secrets = Rails.application.credentials.hivemq
  42. end
  43. # Publishes a message to the MQTT broker
  44. #
  45. # @param message [String] JSON message to publish
  46. # @param topic [String] MQTT topic to publish to
  47. # @return [void]
  48. def publish_data(message, topic)
  49. MQTT::Client.connect(
  50. host: @secrets[:url],
  51. port: @secrets[:port],
  52. ) do |client|
  53. client.publish(topic, message)
  54. end
  55. end
  56. # Validates that required parameters for setting a schedule are present
  57. #
  58. # @return [void]
  59. def validate_set_schedule_params
  60. unless params[:frequency].present? && params[:units].present? && params[:plant_module_id].present?
  61. redirect_back fallback_location: plant_modules_path, alert: "Missing one or more of required parameters: frequency, units, and plant_module_id"
  62. end
  63. end
  64. # Validates that required parameters for sending a water signal are present
  65. #
  66. # @return [void]
  67. def validate_send_water_signal_params
  68. unless params[:plant_module_id].present?
  69. redirect_back fallback_location: plant_modules_path, alert: "Missing required parameter: plant_module_id"
  70. end
  71. end
  72. # Ensures the current user owns the plant module they're trying to control
  73. #
  74. # @return [void]
  75. def authorize_user
  76. plant_module = PlantModule.find_by(id: params[:plant_module_id])
  77. if plant_module.nil? || plant_module.user != current_user
  78. redirect_back fallback_location: plant_modules_path, alert: "You are not authorized to perform this action."
  79. end
  80. end
  81. end

app/controllers/plant_modules_controller.rb

0.0% lines covered

114 relevant lines. 0 lines covered and 114 lines missed.
    
  1. # Controller for managing plant modules
  2. #
  3. # This controller handles CRUD operations for plant modules, which represent
  4. # physical growing setups that contain plants and sensors.
  5. #
  6. # @example Request to create a new plant module
  7. # GET /plant_modules/new
  8. #
  9. # @example Request to view a plant module
  10. # GET /plant_modules/123
  11. class PlantModulesController < AuthenticatedApplicationController
  12. # Displays the form to create a new plant module with plant recommendations
  13. #
  14. # @param max_height [Float] optional maximum height filter for recommendations
  15. # @param max_width [Float] optional maximum width filter for recommendations
  16. # @param maintenance [String] optional maintenance level filter for recommendations
  17. # @param edibility_rating [String] optional edibility rating filter for recommendations
  18. # @param page [Integer] optional page number for recommendations pagination
  19. # @return [void]
  20. def new
  21. @plant_module = PlantModule.new(location_type: "indoor")
  22. filters = {
  23. max_height: params[:max_height],
  24. max_width: params[:max_width],
  25. maintenance: params[:maintenance],
  26. edibility_rating: params[:edibility_rating],
  27. page: params[:page]
  28. }
  29. @recommendations = PlantRecommendationService.new(
  30. location_type: @plant_module.location_type,
  31. filters: filters
  32. ).recommendations
  33. if turbo_frame_request? && request.headers["Turbo-Frame"] == "recommendations"
  34. render partial: "plants/recommendations_frame", locals: { plants: @recommendations }, layout: false
  35. else
  36. render :new
  37. end
  38. end
  39. # Creates a new plant module
  40. #
  41. # @param plant_module [Hash] plant module parameters
  42. # @option plant_module [String] :name name of the module
  43. # @option plant_module [String] :description description of the module
  44. # @option plant_module [String] :location physical location of the module
  45. # @option plant_module [String] :location_type "indoor" or "outdoor"
  46. # @option plant_module [String] :zip_code ZIP code for outdoor modules
  47. # @option plant_module [Array<Integer>] :plant_ids IDs of plants to associate with the module
  48. # @return [void]
  49. def create
  50. @plant_module = PlantModule.new(plant_module_params)
  51. @plant_module.user = current_user
  52. @plant_module.id = SecureRandom.uuid
  53. if @plant_module.save
  54. # Calculate care schedule based on associated plants
  55. schedule_attrs = CareScheduleCalculator.new(@plant_module.plants).calculate
  56. CareSchedule.create!(plant_module: @plant_module, **schedule_attrs)
  57. redirect_to plant_modules_path, notice: "Plant module created successfully."
  58. else
  59. flash.now[:alert] = "Error creating plant module."
  60. render :new
  61. end
  62. end
  63. # Displays a plant module with its sensors and control signals
  64. #
  65. # @param id [String] ID of the plant module to display
  66. # @return [void]
  67. def show
  68. @plant_module = PlantModule.find_by(id: params[:id])
  69. if @plant_module.nil?
  70. redirect_to plant_modules_path, alert: "Plant module not found." and return
  71. elsif @plant_module.user != current_user
  72. redirect_to plant_modules_path, alert: "You are not authorized to access this plant module." and return
  73. end
  74. @sensors = @plant_module.sensors.includes(:time_series_data)
  75. @sensor_data = {}
  76. @sensors.each do |sensor|
  77. first_timestamp = sensor.time_series_data.minimum(:timestamp)
  78. if first_timestamp
  79. hourly_data = sensor.time_series_data
  80. .where("timestamp >= ?", first_timestamp)
  81. .group_by_hour(:timestamp)
  82. .average(:value)
  83. .transform_values do |v|
  84. next nil if v.nil?
  85. value = v.to_f
  86. if sensor.measurement_type == "light_analog"
  87. ((4096 - value).abs / 4096.0 * 100).round(2)
  88. else
  89. value.round(2)
  90. end
  91. end
  92. @sensor_data[sensor.id] = hourly_data
  93. else
  94. @sensor_data[sensor.id] = {}
  95. end
  96. end
  97. @control_signals = @plant_module.control_signals.includes(:last_execution).order(:signal_type)
  98. if @plant_module.location_type.downcase == "outdoor" && @plant_module.zip_code.present?
  99. @zone_data = zone_for_zip(@plant_module.zip_code)
  100. end
  101. end
  102. # Displays the form to edit a plant module with plant recommendations
  103. #
  104. # @param id [String] ID of the plant module to edit
  105. # @param max_height [Float] optional maximum height filter for recommendations
  106. # @param max_width [Float] optional maximum width filter for recommendations
  107. # @param maintenance [String] optional maintenance level filter for recommendations
  108. # @param edibility_rating [String] optional edibility rating filter for recommendations
  109. # @param page [Integer] optional page number for recommendations pagination
  110. # @return [void]
  111. def edit
  112. @plant_module = current_user.plant_modules.find_by(id: params[:id])
  113. redirect_to plant_modules_path, alert: "Module not found." unless @plant_module
  114. filters = {
  115. max_height: params[:max_height],
  116. max_width: params[:max_width],
  117. maintenance: params[:maintenance],
  118. edibility_rating: params[:edibility_rating],
  119. page: params[:page]
  120. }
  121. @recommendations = PlantRecommendationService.new(
  122. location_type: @plant_module.location_type,
  123. filters: filters
  124. ).recommendations
  125. end
  126. # Updates a plant module
  127. #
  128. # @param id [String] ID of the plant module to update
  129. # @param plant_module [Hash] plant module parameters
  130. # @option plant_module [String] :name name of the module
  131. # @option plant_module [String] :description description of the module
  132. # @option plant_module [String] :location physical location of the module
  133. # @option plant_module [String] :location_type "indoor" or "outdoor"
  134. # @option plant_module [String] :zip_code ZIP code for outdoor modules
  135. # @option plant_module [Array<Integer>] :plant_ids IDs of plants to associate with the module
  136. # @return [void]
  137. def update
  138. @plant_module = current_user.plant_modules.find_by(id: params[:id])
  139. unless @plant_module
  140. redirect_to plant_modules_path, alert: "Module not found." and return
  141. end
  142. if @plant_module.update(plant_module_params)
  143. redirect_to @plant_module, notice: "Module updated successfully."
  144. else
  145. flash.now[:alert] = "Error updating module."
  146. render :edit
  147. end
  148. end
  149. # Deletes a plant module
  150. #
  151. # @param id [String] ID of the plant module to delete
  152. # @return [void]
  153. def destroy
  154. @plant_module = current_user.plant_modules.find_by(id: params[:id])
  155. if @plant_module
  156. @plant_module.destroy
  157. redirect_to plant_modules_path, notice: "Module deleted successfully."
  158. else
  159. redirect_to plant_modules_path, alert: "Module not found."
  160. end
  161. end
  162. # Generates a timelapse video for a plant module using its photos
  163. #
  164. # @param id [String] ID of the plant module to generate timelapse for
  165. # @return [void]
  166. def generate_timelapse
  167. @plant_module = PlantModule.find_by(id: params[:id])
  168. TimelapseWorker.perform_async(@plant_module.id)
  169. redirect_to @plant_module, notice: "Timelapse generation has started and will appear here when ready."
  170. end
  171. private
  172. # Permits plant module parameters for mass assignment
  173. #
  174. # @return [ActionController::Parameters] permitted parameters
  175. def plant_module_params
  176. params.require(:plant_module).permit(:name, :description, :location, :location_type, :zip_code, plant_ids: [])
  177. end
  178. end

app/controllers/plants_controller.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. # Controller for managing plant-related requests and views
  2. #
  3. # @example Request to list all plants
  4. # GET /plants
  5. #
  6. # @example Request to search plants
  7. # GET /plants?query=rose
  8. 1 class PlantsController < ApplicationController
  9. 1 include ZipCodeHelper # if you need to use helper methods here as well
  10. # Lists plants with optional filtering and pagination
  11. #
  12. # @note Plants can be filtered by query, location, dimensions, and more
  13. #
  14. # @param query [String] optional search term for plant name or species
  15. # @param location_type [String] "indoor" or "outdoor"
  16. # @param zip_code [String] optional ZIP code for outdoor plants
  17. # @param max_height [Float] maximum height filter
  18. # @param max_width [Float] maximum width filter
  19. # @param maintenance [String] maintenance level filter
  20. # @param edibility_rating [String] minimum edibility rating
  21. # @param page [Integer] pagination page number
  22. #
  23. # @return [void]
  24. 1 def index
  25. 4 if params[:query].present?
  26. 1 @plants = Plant.where("common_name ILIKE ? OR genus ILIKE ? OR species ILIKE ?",
  27. "%#{params[:query]}%", "%#{params[:query]}%", "%#{params[:query]}%")
  28. .page(params[:page])
  29. .per(5)
  30. 1 Rails.logger.info "Found #{@plants.total_count} plants matching query '#{params[:query]}'"
  31. else
  32. filters = {
  33. 3 max_height: params[:max_height],
  34. max_width: params[:max_width],
  35. maintenance: params[:maintenance],
  36. edibility_rating: params[:edibility_rating],
  37. page: params[:page]
  38. }
  39. 3 if params[:location_type].to_s.downcase == "outdoor"
  40. 1 service = PlantRecommendationService.new(location_type: "outdoor", zip_code: params[:zip_code], filters: filters)
  41. else
  42. 2 service = PlantRecommendationService.new(location_type: "indoor", filters: filters)
  43. end
  44. 3 @plants = service.recommendations
  45. end
  46. 4 if turbo_frame_request?
  47. 1 render partial: "plants/recommendations_frame", locals: { plants: @plants }
  48. else
  49. 3 render :index
  50. end
  51. end
  52. # Displays detailed information for a specific plant
  53. #
  54. # @param id [Integer] ID of the plant to show
  55. #
  56. # @return [void]
  57. 1 def info
  58. 1 @plant = Plant.find(params[:id])
  59. 1 render partial: "plants/info", locals: { plant: @plant }
  60. end
  61. end

app/controllers/sensors_controller.rb

0.0% lines covered

109 relevant lines. 0 lines covered and 109 lines missed.
    
  1. # Controller for managing sensor data display and notification settings
  2. #
  3. # @example Request to view a sensor's data
  4. # GET /sensors/123
  5. #
  6. # @example Request to update notification settings
  7. # PATCH /sensors/123/update_notification_settings
  8. class SensorsController < ApplicationController
  9. # Displays sensor data with time series chart
  10. #
  11. # @param id [String] ID of the sensor to display
  12. # @param start_date [String] optional start date for time range
  13. # @param days [Integer] optional number of days to show (default: 10)
  14. # @return [void]
  15. def show
  16. @sensor = Sensor.find(params[:id])
  17. Time.use_zone(Time.zone.name) do
  18. if params[:start_date].present?
  19. start_time = Date.parse(params[:start_date])
  20. else
  21. days = params[:days].present? ? params[:days].to_i : 10
  22. start_time = days.days.ago
  23. end
  24. Rails.logger.info "Time zone is #{Time.zone.name}"
  25. @time_series_data = TimeSeriesDatum
  26. .where(sensor_id: @sensor.id)
  27. .where("timestamp >= ?", start_time)
  28. .group_by_minute(:timestamp, time_zone: Time.zone.name)
  29. .average(:value)
  30. .transform_values do |v|
  31. next nil if v.nil?
  32. value = v.to_f
  33. if @sensor.measurement_type == "light_analog"
  34. ((4096 - value).abs / 4096.0 * 100).round(2)
  35. else
  36. value.round(2)
  37. end
  38. end
  39. end
  40. respond_to do |format|
  41. format.html
  42. format.turbo_stream do
  43. render turbo_stream: turbo_stream.replace(
  44. "sensor_chart",
  45. partial: "sensors/chart",
  46. locals: { sensor: @sensor, time_series_data: @time_series_data }
  47. )
  48. end
  49. end
  50. end
  51. # Toggles notification settings for a sensor
  52. #
  53. # @param id [String] ID of the sensor to update
  54. # @return [void]
  55. def toggle_notification
  56. @sensor = Sensor.find(params[:id])
  57. @sensor.update(notifications: !@sensor.notifications)
  58. respond_to do |format|
  59. format.turbo_stream do
  60. render turbo_stream: turbo_stream.replace(
  61. "notification_section",
  62. partial: "sensors/notification_section",
  63. locals: { sensor: @sensor }
  64. )
  65. end
  66. format.html { redirect_to plant_module_sensor_path(@sensor.plant_module, @sensor) }
  67. end
  68. end
  69. # Updates notification thresholds and messages for a sensor
  70. #
  71. # @param id [String] ID of the sensor to update
  72. # @param comparisons [Array<String>] comparison operators for thresholds
  73. # @param values [Array<String>] threshold values
  74. # @param messages [Array<String>] notification messages
  75. # @param sensor [Hash] sensor parameters
  76. # @option sensor [String] :notifications whether notifications are enabled
  77. # @return [void]
  78. def update_notification_settings
  79. @sensor = Sensor.find(params[:id])
  80. comparisons = params[:comparisons] || []
  81. values = params[:values] || []
  82. messages = params[:messages] || []
  83. thresholds = comparisons.zip(values).map { |comp, val| "#{comp} #{val}" }
  84. notifications = params[:sensor][:notifications] == "1"
  85. if @sensor.update(thresholds: thresholds, messages: messages, notifications: notifications)
  86. flash.now[:notice] = "Notification settings updated."
  87. else
  88. flash.now[:alert] = "Unable to update settings."
  89. end
  90. respond_to do |format|
  91. format.turbo_stream do
  92. render turbo_stream: turbo_stream.replace(
  93. "notification_section",
  94. partial: "sensors/notification_section",
  95. locals: { sensor: @sensor }
  96. )
  97. end
  98. format.html { redirect_to plant_module_sensor_path(@sensor.plant_module, @sensor) }
  99. end
  100. end
  101. # Renders the notification settings form for a sensor
  102. #
  103. # @param id [String] ID of the sensor to load settings for
  104. # @return [void]
  105. def load_notification_settings
  106. @sensor = Sensor.find(params[:id])
  107. render partial: "sensors/notification_form", locals: { sensor: @sensor }
  108. end
  109. # Displays time series chart for a sensor with a specific time range
  110. #
  111. # @param id [String] ID of the sensor to display
  112. # @param range [String] time range to display ("1_day", "7_days", "30_days", "365_days")
  113. # @return [void]
  114. def time_series_chart
  115. @sensor = Sensor.find(params[:id])
  116. range = params[:range] || "30_days"
  117. time_ago = case range
  118. when "1_day" then 1.day.ago
  119. when "7_days" then 7.days.ago
  120. when "30_days" then 30.days.ago
  121. when "365_days" then 1.year.ago
  122. else 30.days.ago
  123. end
  124. hourly_data = @sensor.time_series_data
  125. .where("timestamp >= ?", time_ago)
  126. .group_by_hour(:timestamp, time_zone: "Central Time (US & Canada)")
  127. .average(:value)
  128. .transform_values do |v|
  129. next nil if v.nil?
  130. value = v.to_f
  131. if @sensor.measurement_type == "light_analog"
  132. ((4096 - value).abs / 4096.0 * 100).round(2)
  133. else
  134. value.round(2)
  135. end
  136. end
  137. Rails.logger.info "Replacing the chart for #{@sensor.measurement_type}."
  138. render turbo_stream: turbo_stream.replace(
  139. "chart-#{params[:id]}",
  140. partial: "sensors/sensor_chart_inline",
  141. locals: { sensor: @sensor, data: hourly_data }
  142. )
  143. end
  144. end

app/controllers/users/omniauth_callbacks_controller.rb

0.0% lines covered

16 relevant lines. 0 lines covered and 16 lines missed.
    
  1. class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  2. def google_oauth2
  3. @user = User.from_omniauth(request.env["omniauth.auth"])
  4. if @user.persisted?
  5. sign_in @user
  6. if @user.zip_code.blank?
  7. redirect_to complete_profile_path
  8. else
  9. redirect_to root_path, notice: "Signed in successfully."
  10. end
  11. end
  12. end
  13. def failure
  14. redirect_to root_path
  15. end
  16. end

app/controllers/users/sessions_controller.rb

0.0% lines covered

8 relevant lines. 0 lines covered and 8 lines missed.
    
  1. class Users::SessionsController < Devise::SessionsController
  2. def after_sign_in_path_for(_resource_or_scope)
  3. stored_location_for(_resource_or_scope) || root_path
  4. end
  5. def after_sign_out_path_for(_resource_or_scope)
  6. new_user_session_path
  7. end
  8. end

app/controllers/users_controller.rb

0.0% lines covered

12 relevant lines. 0 lines covered and 12 lines missed.
    
  1. # Controller for user profile management
  2. #
  3. # @example Request to complete profile form
  4. # GET /users/complete_profile
  5. #
  6. # @example Request to update profile
  7. # PATCH /users/update_profile
  8. class UsersController < AuthenticatedApplicationController
  9. # Renders the form for completing user profile
  10. #
  11. # @return [void]
  12. def complete_profile
  13. # Just render form
  14. end
  15. # Updates the user's profile with submitted data
  16. #
  17. # @param user [Hash] user parameters from form
  18. # @option user [String] :zip_code ZIP code for the user's location
  19. #
  20. # @return [void]
  21. def update_profile
  22. if current_user.update(zip_code: params[:user][:zip_code])
  23. redirect_to root_path, notice: "Profile updated."
  24. else
  25. flash.now[:alert] = "Please enter a valid zip code."
  26. render :complete_profile
  27. end
  28. end
  29. end

app/helpers/application_helper.rb

100.0% lines covered

1 relevant lines. 1 lines covered and 0 lines missed.
    
  1. # Common view helpers used throughout the application
  2. #
  3. # @note This module is automatically included in all views
  4. 1 module ApplicationHelper
  5. end

app/helpers/control_signals_helper.rb

14.29% lines covered

28 relevant lines. 4 lines covered and 24 lines missed.
    
  1. # Helper methods for control signal handling
  2. #
  3. # This module provides utilities for formatting and converting durations
  4. # between different time units for control signals
  5. 1 module ControlSignalsHelper
  6. # Formats a duration into a human-readable string
  7. #
  8. # @param amount [Integer, String] the numeric amount of time
  9. # @param unit [String] the time unit (e.g., "hour", "minute", "second", "day")
  10. # @return [String] formatted duration string with proper pluralization
  11. # @example
  12. # format_duration(3, "hour") # => "3 hours"
  13. # format_duration(1, "minute") # => "1 minute"
  14. 1 def format_duration(amount, unit)
  15. amount = amount.to_i
  16. # nothing or zero → "0 seconds"
  17. return "0 #{unit.to_s.pluralize}" if amount <= 0
  18. # normalize to singular base (e.g. "hours" → "hour")
  19. base = unit.to_s.downcase.chomp("s")
  20. label = (amount == 1 ? base : base.pluralize)
  21. "#{amount} #{label}"
  22. end
  23. # Converts a duration to seconds
  24. #
  25. # @param amount [Integer, String] the numeric amount of time
  26. # @param unit [String] the time unit (e.g., "hour", "minute", "second", "day")
  27. # @return [Integer] equivalent number of seconds
  28. # @example
  29. # format_duration_to_seconds(2, "hour") # => 7200
  30. # format_duration_to_seconds(30, "minute") # => 1800
  31. 1 def format_duration_to_seconds(amount, unit)
  32. n = amount.to_i
  33. return 0 if n <= 0
  34. case unit.to_s.downcase.chomp("s")
  35. when "day" then n * 24 * 60 * 60
  36. when "hour" then n * 60 * 60
  37. when "minute" then n * 60
  38. when "second" then n
  39. else n
  40. end
  41. end
  42. # Converts seconds to a formatted duration string in the specified unit
  43. #
  44. # @param seconds [Integer, String] number of seconds
  45. # @param unit [String] the target time unit (e.g., "hour", "minute", "second", "day")
  46. # @return [String] formatted duration string in the target unit
  47. # @example
  48. # format_duration_from_seconds(7200, "hour") # => "2 hours"
  49. # format_duration_from_seconds(90, "minute") # => "1.5 minutes"
  50. 1 def format_duration_from_seconds(seconds, unit)
  51. seconds = seconds.to_i
  52. return "0 #{unit.to_s.pluralize}" if seconds <= 0
  53. case unit.to_s.downcase.chomp("s")
  54. when "day"
  55. value = seconds / (24 * 60 * 60.0)
  56. when "hour"
  57. value = seconds / (60 * 60.0)
  58. when "minute"
  59. value = seconds / 60.0
  60. when "second"
  61. value = seconds
  62. else
  63. value = seconds
  64. end
  65. rounded = value.round(2)
  66. label = (rounded == 1 ? unit.to_s.singularize : unit.to_s.pluralize)
  67. "#{rounded} #{label}"
  68. end
  69. end

app/helpers/plants_helper.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. # Helper methods for plant-related views
  2. #
  3. # This module provides methods for formatting plant data in views
  4. 1 module PlantsHelper
  5. # Renders a visual star rating
  6. #
  7. # @param rating [Integer, String] the numeric rating (0-5)
  8. # @return [String] HTML-safe string with filled and empty star symbols
  9. # @example
  10. # render_star_rating(3) # => "★★★☆☆"
  11. 1 def render_star_rating(rating)
  12. 21 stars = rating.to_i.clamp(0, 5) # ensures it's between 0 and 5
  13. 21 full_stars = "★" * stars
  14. 21 empty_stars = "☆" * (5 - stars)
  15. 21 full_stars + empty_stars
  16. end
  17. # Cleans up array strings from JSON format
  18. #
  19. # @param raw [String] raw array string (e.g., "[1,2,3]")
  20. # @return [String] cleaned string without brackets and quotes
  21. # @example
  22. # clean_array_string("[1,2,3]") # => "1,2,3"
  23. 1 def clean_array_string(raw)
  24. 3 raw.to_s.gsub(/[\[\]'"]/, "").strip
  25. end
  26. end

app/helpers/zip_code_helper.rb

78.57% lines covered

14 relevant lines. 11 lines covered and 3 lines missed.
    
  1. # Helper module for working with ZIP codes and plant hardiness zones
  2. #
  3. # This module provides methods to look up USDA hardiness zones by ZIP code
  4. # and determine if plants can grow in particular zones.
  5. 1 module ZipCodeHelper
  6. 1 require "csv"
  7. # Path to the CSV file containing ZIP code to hardiness zone mappings
  8. # @return [Pathname] path to the CSV file
  9. 1 CSV_FILE_PATH = Rails.root.join("app", "assets", "csv", "phzm_us_zipcode_2023.csv")
  10. # Map of ZIP codes to hardiness zone data
  11. # @return [Hash<String, Hash>] mapping from ZIP codes to zone information
  12. # @option [String] :zone USDA hardiness zone (e.g., "Zone 7a")
  13. # @option [String] :trange temperature range for the zone
  14. # @option [String] :zonetitle descriptive title for the zone
  15. 1 ZIP_ZONE_MAP = CSV.read(CSV_FILE_PATH, headers: true).each_with_object({}) do |row, hash|
  16. 39921 hash[row["zipcode"]] = {
  17. zone: row["zone"],
  18. trange: row["trange"],
  19. zonetitle: row["zonetitle"]
  20. }
  21. end.freeze
  22. # Gets hardiness zone data for a ZIP code
  23. #
  24. # @param zip_code [String] the ZIP code to look up
  25. # @return [Hash, nil] zone data if found, nil otherwise
  26. # @option [String] :zone USDA hardiness zone (e.g., "Zone 7a")
  27. # @option [String] :trange temperature range for the zone
  28. # @option [String] :zonetitle descriptive title for the zone
  29. 1 def zone_for_zip(zip_code)
  30. 3 ZIP_ZONE_MAP[zip_code]
  31. end
  32. # Determines if a plant can grow in the hardiness zone of a given ZIP code
  33. #
  34. # @param plant [Plant] the plant to check
  35. # @param zip_code [String] the ZIP code to check against
  36. # @return [Boolean] true if the plant can grow in the zone, false otherwise
  37. 1 def plant_in_zone?(plant, zip_code)
  38. 3 return false if zip_code.blank?
  39. 3 zone_data = zone_for_zip(zip_code)
  40. 3 return false unless zone_data
  41. zone_num = zone_data[:zone].match(/\d+/)[0].to_i
  42. # Assuming plant.hardiness_zones is stored as a JSON array like "[4,5,6,7,8]"
  43. plant_zones = JSON.parse(plant.hardiness_zones) rescue []
  44. plant_zones.include?(zone_num)
  45. end
  46. end

app/jobs/application_job.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. # Base class for background jobs in the application
  2. #
  3. # @abstract This class serves as the base class for all background jobs
  4. # and provides common functionality and configuration.
  5. class ApplicationJob < ActiveJob::Base
  6. # Automatically retry jobs that encountered a deadlock
  7. # retry_on ActiveRecord::Deadlocked
  8. # Most jobs are safe to ignore if the underlying records are no longer available
  9. # discard_on ActiveJob::DeserializationError
  10. end

app/jobs/sensor_notification_job.rb

0.0% lines covered

49 relevant lines. 0 lines covered and 49 lines missed.
    
  1. # Background job for processing sensor notification thresholds and sending alerts
  2. #
  3. # This job checks if a sensor reading has crossed configured thresholds
  4. # and sends notification emails when conditions are met.
  5. class SensorNotificationJob < ApplicationJob
  6. queue_as :default
  7. # Processes notifications for a sensor data point
  8. #
  9. # @param sensor_id [String] UUID of the sensor to check
  10. # @param data_point_id [Integer] ID of the time series data point to evaluate
  11. # @return [void]
  12. # @note Notifications are only sent if the sensor has notifications enabled
  13. # and the threshold hasn't triggered a notification in the last 6 hours
  14. def perform(sensor_id, data_point_id)
  15. sensor = Sensor.find(sensor_id)
  16. data_point = TimeSeriesDatum.find(data_point_id)
  17. Rails.logger.info "Processing notifications for sensor #{sensor.id} with data point #{data_point.id}"
  18. Rails.logger.info "Sensor notifications flag: #{sensor.notifications}"
  19. Rails.logger.info "Data point value: #{data_point.value}"
  20. return unless sensor.notifications
  21. # Ensure notified_threshold_indices is initialized
  22. data_point.notified_threshold_indices ||= ""
  23. sensor.thresholds.each_with_index do |threshold_condition, index|
  24. # Extract the operator and threshold number
  25. if threshold_condition =~ /\A\s*(<=|>=|<|>|=)\s*(-?\d+(\.\d+)?)\s*\z/
  26. operator = Regexp.last_match(1)
  27. threshold_value = Regexp.last_match(2).to_f
  28. Rails.logger.info "Checking threshold #{index + 1}: #{operator} #{threshold_value}"
  29. triggered = case operator
  30. when "<=" then data_point.value <= threshold_value
  31. when "<" then data_point.value < threshold_value
  32. when "=" then data_point.value == threshold_value
  33. when ">=" then data_point.value >= threshold_value
  34. when ">" then data_point.value > threshold_value
  35. else false
  36. end
  37. Rails.logger.info "Threshold triggered? #{triggered}"
  38. # Skip if already notified for this threshold index
  39. already_notified = data_point.notified_threshold_indices.to_s.include?(index.to_s)
  40. log = SensorNotificationLog.find_or_initialize_by(sensor: sensor, threshold_index: index)
  41. if triggered && !already_notified && (log.last_sent_at.nil? || log.last_sent_at <= 6.hours.ago)
  42. message = sensor.messages[index] || "Notification triggered"
  43. Rails.logger.info "Sending notification: #{message}"
  44. SensorMailer.with(sensor: sensor, data_point: data_point, message: message)
  45. .notification_email
  46. .deliver_later
  47. # Mark this threshold as notified
  48. data_point.notified_threshold_indices = [ *data_point.notified_threshold_indices.to_s.split(","), index.to_s ].uniq.join(",")
  49. data_point.save!
  50. log.last_sent_at = Time.current
  51. log.save!
  52. else
  53. Rails.logger.info "Skipping notification for threshold #{index} due to 6-hour cooldown or prior notification."
  54. end
  55. else
  56. Rails.logger.warn "Malformed threshold: '#{threshold_condition}' – skipping"
  57. end
  58. end
  59. rescue => e
  60. Rails.logger.error "Error processing notifications: #{e.message}"
  61. Rails.logger.error e.backtrace.join("\n")
  62. raise e
  63. end
  64. end

app/mailers/application_mailer.rb

0.0% lines covered

4 relevant lines. 0 lines covered and 4 lines missed.
    
  1. # Base class for all mailers in the application
  2. #
  3. # @abstract This class serves as the base class that all mailers inherit from
  4. # and provides common configuration such as default from address and layout.
  5. class ApplicationMailer < ActionMailer::Base
  6. default from: "from@example.com"
  7. layout "mailer"
  8. end

app/mailers/sensor_mailer.rb

0.0% lines covered

12 relevant lines. 0 lines covered and 12 lines missed.
    
  1. # app/mailers/sensor_mailer.rb
  2. # Mailer for sending sensor-related notifications and alerts
  3. #
  4. # This mailer handles sending email notifications when sensors
  5. # detect values crossing configured thresholds.
  6. class SensorMailer < ApplicationMailer
  7. # Sends a notification email about a sensor reading that crossed a threshold
  8. #
  9. # @param params [Hash] parameters for the email
  10. # @option params [Sensor] :sensor the sensor that triggered the notification
  11. # @option params [TimeSeriesDatum] :data_point the data point that triggered the notification
  12. # @option params [String] :message custom message to include in the notification
  13. # @return [Mail::Message] email message to be delivered
  14. def notification_email
  15. @sensor = params[:sensor]
  16. @data_point = params[:data_point]
  17. @message = params[:message]
  18. Rails.logger.info("Trying to send an email to #{@sensor.plant_module.user.email}")
  19. mail(
  20. to: @sensor.plant_module.user.email,
  21. subject: "Sensor Alert: #{@sensor.measurement_type}"
  22. )
  23. end
  24. end

app/models/application_record.rb

100.0% lines covered

2 relevant lines. 2 lines covered and 0 lines missed.
    
  1. # Base class for all models in the application
  2. #
  3. # @abstract Serves as the abstract base class that all models inherit from
  4. 1 class ApplicationRecord < ActiveRecord::Base
  5. 1 primary_abstract_class
  6. end

app/models/care_schedule.rb

0.0% lines covered

9 relevant lines. 0 lines covered and 9 lines missed.
    
  1. # app/models/care_schedule.rb
  2. # @!attribute [r] id
  3. # @return [String] UUID of the care schedule
  4. # @!attribute [rw] plant_module_id
  5. # @return [String] ID of the plant module this schedule belongs to
  6. # @!attribute [rw] watering_frequency
  7. # @return [Integer] how often to water in days
  8. # @!attribute [rw] light_hours
  9. # @return [Integer] recommended daily light hours
  10. # @!attribute [rw] fertilizing_frequency
  11. # @return [Integer] how often to fertilize in days
  12. # @!attribute [rw] created_at
  13. # @return [DateTime] when the record was created
  14. # @!attribute [rw] updated_at
  15. # @return [DateTime] when the record was last updated
  16. class CareSchedule < ApplicationRecord
  17. # @!association
  18. # @return [PlantModule] the plant module this care schedule belongs to
  19. belongs_to :plant_module
  20. self.primary_key = "id"
  21. before_create :assign_uuid
  22. private
  23. # Assigns a UUID to the care schedule if not already set
  24. #
  25. # @return [void]
  26. def assign_uuid
  27. self.id ||= SecureRandom.uuid
  28. end
  29. end

app/models/control_execution.rb

0.0% lines covered

9 relevant lines. 0 lines covered and 9 lines missed.
    
  1. # app/models/control_execution.rb
  2. # @!attribute [r] id
  3. # @return [String] UUID of the control execution
  4. # @!attribute [rw] control_signal_id
  5. # @return [String] ID of the associated control signal
  6. # @!attribute [rw] source
  7. # @return [String] source of the execution ("automatic", "manual", or "scheduled")
  8. # @!attribute [rw] status
  9. # @return [Boolean] whether the signal was turned on (true) or off (false)
  10. # @!attribute [rw] duration
  11. # @return [Integer] duration of the control signal
  12. # @!attribute [rw] duration_unit
  13. # @return [String] unit for the duration (e.g., "seconds", "minutes")
  14. # @!attribute [rw] executed_at
  15. # @return [DateTime] when the control was executed
  16. # @!attribute [rw] created_at
  17. # @return [DateTime] when the record was created
  18. # @!attribute [rw] updated_at
  19. # @return [DateTime] when the record was last updated
  20. class ControlExecution < ApplicationRecord
  21. # @!association
  22. # @return [ControlSignal] the control signal that was executed
  23. belongs_to :control_signal
  24. self.primary_key = "id"
  25. before_create :assign_uuid
  26. # after_create_commit do
  27. # Turbo::StreamsChannel.broadcast_replace_to(
  28. # "control_executions",
  29. # target: "control_execution_#{control_signal_id}",
  30. # partial: "control_executions/control_execution",
  31. # locals: { control_execution: self }
  32. # )
  33. # end
  34. private
  35. # Assigns a UUID to the execution if not already set
  36. #
  37. # @return [void]
  38. def assign_uuid
  39. self.id ||= SecureRandom.uuid
  40. end
  41. end

app/models/control_signal.rb

0.0% lines covered

7 relevant lines. 0 lines covered and 7 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [String] UUID of the control signal
  3. # @!attribute [rw] plant_module_id
  4. # @return [String] ID of the plant module this control signal belongs to
  5. # @!attribute [rw] sensor_id
  6. # @return [String, nil] ID of the associated sensor (optional)
  7. # @!attribute [rw] signal_type
  8. # @return [String] type of control signal (e.g., "water", "light")
  9. # @!attribute [rw] label
  10. # @return [String] human-readable label for the control signal
  11. # @!attribute [rw] mode
  12. # @return [String] operating mode ("automatic", "manual", or "scheduled")
  13. # @!attribute [rw] comparison
  14. # @return [String] comparison operator for automatic mode (e.g., "<", ">")
  15. # @!attribute [rw] threshold_value
  16. # @return [Float] threshold value for automatic mode
  17. # @!attribute [rw] length
  18. # @return [Integer] duration of the control signal
  19. # @!attribute [rw] length_unit
  20. # @return [String] unit for the duration (e.g., "seconds", "minutes")
  21. # @!attribute [rw] mqtt_topic
  22. # @return [String] MQTT topic for publishing the control signal
  23. # @!attribute [rw] enabled
  24. # @return [Boolean] whether the control signal is enabled
  25. class ControlSignal < ApplicationRecord
  26. # @!association
  27. # @return [PlantModule] the plant module this control signal belongs to
  28. belongs_to :plant_module
  29. # @!association
  30. # @return [Sensor, nil] the associated sensor (optional)
  31. belongs_to :sensor, optional: true
  32. # @!association
  33. # @return [Array<ControlExecution>] execution history for this control signal
  34. has_many :control_executions, dependent: :destroy
  35. # @!association
  36. # @return [ControlExecution, nil] the most recent execution of this control signal
  37. has_one :last_execution, -> { order(executed_at: :desc) }, class_name: "ControlExecution"
  38. validates :mode, presence: true
  39. end

app/models/module_plant.rb

0.0% lines covered

10 relevant lines. 0 lines covered and 10 lines missed.
    
  1. # app/models/module_plant.rb
  2. # @!attribute [r] id
  3. # @return [String] UUID of the module plant association
  4. # @!attribute [rw] plant_module_id
  5. # @return [String] ID of the plant module
  6. # @!attribute [rw] plant_id
  7. # @return [Integer] ID of the plant
  8. # @!attribute [rw] created_at
  9. # @return [DateTime] when the record was created
  10. # @!attribute [rw] updated_at
  11. # @return [DateTime] when the record was last updated
  12. class ModulePlant < ApplicationRecord
  13. # @!association
  14. # @return [PlantModule] the plant module in this association
  15. belongs_to :plant_module
  16. # @!association
  17. # @return [Plant] the plant in this association
  18. belongs_to :plant
  19. self.primary_key = "id"
  20. before_create :assign_uuid
  21. private
  22. # Assigns a UUID to the association if not already set
  23. #
  24. # @return [void]
  25. def assign_uuid
  26. self.id ||= SecureRandom.uuid
  27. end
  28. end

app/models/photo.rb

0.0% lines covered

4 relevant lines. 0 lines covered and 4 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [String] UUID of the photo
  3. # @!attribute [rw] plant_module_id
  4. # @return [String] ID of the plant module this photo belongs to
  5. # @!attribute [rw] timestamp
  6. # @return [DateTime] when the photo was taken
  7. # @!attribute [rw] created_at
  8. # @return [DateTime] when the record was created
  9. # @!attribute [rw] updated_at
  10. # @return [DateTime] when the record was last updated
  11. class Photo < ApplicationRecord
  12. # @!association
  13. # @return [PlantModule] the plant module this photo belongs to
  14. belongs_to :plant_module
  15. # @!attribute [rw] image
  16. # @return [ActiveStorage::Attached::One] attached image file
  17. has_one_attached :image
  18. end

app/models/plant.rb

80.0% lines covered

5 relevant lines. 4 lines covered and 1 lines missed.
    
  1. # app/models/plant.rb
  2. # @!attribute [r] id
  3. # @return [Integer] unique identifier for the plant
  4. # @!attribute [rw] common_name
  5. # @return [String] common name of the plant
  6. # @!attribute [rw] genus
  7. # @return [String] plant genus
  8. # @!attribute [rw] species
  9. # @return [String] plant species
  10. # @!attribute [rw] preferences
  11. # @return [String] plant light and other preferences
  12. # @!attribute [rw] height
  13. # @return [Float] maximum height of the plant in feet
  14. # @!attribute [rw] width
  15. # @return [Float] maximum width spread of the plant in feet
  16. # @!attribute [rw] hardiness_zones
  17. # @return [String] USDA hardiness zones where the plant can grow
  18. # @!attribute [rw] edibility
  19. # @return [String] edibility rating of the plant
  20. 1 class Plant < ApplicationRecord
  21. # Determines the light requirement range based on plant preferences
  22. #
  23. # @return [String] light requirement range in hours (e.g., "2-4" or "6-8")
  24. 1 def light_requirement
  25. # Use the instance attribute 'preferences' (lowercase), not the constant 'Preferences'
  26. 10 if preferences.to_s.downcase.include?("shade") || preferences.to_s.downcase.include?("low light")
  27. "2-4"
  28. else
  29. 10 "6-8"
  30. end
  31. end
  32. end

app/models/plant_module.rb

0.0% lines covered

11 relevant lines. 0 lines covered and 11 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [Integer] unique identifier for the plant module
  3. # @!attribute [rw] user_id
  4. # @return [String] reference to the user who owns this module
  5. # @!attribute [rw] name
  6. # @return [String] name of the plant module
  7. # @!attribute [rw] location
  8. # @return [String] physical location of the module
  9. # @!attribute [rw] module_mac
  10. # @return [String] MAC address of the hardware module
  11. # @!attribute [rw] module_ip
  12. # @return [String] IP address of the hardware module
  13. # @!attribute [rw] created_at
  14. # @return [DateTime] when the module was created
  15. # @!attribute [rw] updated_at
  16. # @return [DateTime] when the module was last updated
  17. class PlantModule < ApplicationRecord
  18. # @!association
  19. # @return [User] the user who owns this module
  20. belongs_to :user, primary_key: :uid, foreign_key: :user_id
  21. # @!association
  22. # @return [Array<Sensor>] sensors attached to this module
  23. has_many :sensors, dependent: :destroy
  24. # @!association
  25. # @return [Array<ModulePlant>] join model between modules and plants
  26. has_many :module_plants, dependent: :destroy
  27. # @!association
  28. # @return [Array<Plant>] plants contained in this module
  29. has_many :plants, through: :module_plants
  30. # @!association
  31. # @return [CareSchedule] care schedule for this module
  32. has_one :care_schedule, dependent: :destroy
  33. # @!association
  34. # @return [Array<Schedule>] schedules for this module
  35. has_many :schedules, dependent: :destroy
  36. # @!association
  37. # @return [Array<Photo>] photos of this module
  38. has_many :photos, dependent: :destroy
  39. # @!association
  40. # @return [Array<ControlSignal>] control signals for this module
  41. has_many :control_signals, dependent: :destroy
  42. # @!attribute [rw] timelapse_video
  43. # @return [ActiveStorage::Attached::One] attached timelapse video file
  44. has_one_attached :timelapse_video
  45. end

app/models/schedule.rb

0.0% lines covered

3 relevant lines. 0 lines covered and 3 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [Integer] unique identifier for the schedule
  3. # @!attribute [rw] plant_module_id
  4. # @return [String] ID of the plant module this schedule belongs to
  5. # @!attribute [rw] task_type
  6. # @return [String] type of scheduled task
  7. # @!attribute [rw] frequency
  8. # @return [Integer] how often the task should be performed
  9. # @!attribute [rw] created_at
  10. # @return [DateTime] when the record was created
  11. # @!attribute [rw] updated_at
  12. # @return [DateTime] when the record was last updated
  13. class Schedule < ApplicationRecord
  14. # @!association
  15. # @return [PlantModule] the plant module this schedule belongs to
  16. belongs_to :plant_module
  17. end

app/models/sensor.rb

0.0% lines covered

9 relevant lines. 0 lines covered and 9 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [String] UUID of the sensor
  3. # @!attribute [rw] plant_module_id
  4. # @return [String] ID of the plant module this sensor belongs to
  5. # @!attribute [rw] measurement_type
  6. # @return [String] type of measurement (e.g., "moisture", "temperature")
  7. # @!attribute [rw] measurement_unit
  8. # @return [String] unit of measurement (e.g., "Celsius", "%")
  9. # @!attribute [rw] created_at
  10. # @return [DateTime] when the sensor was created
  11. # @!attribute [rw] updated_at
  12. # @return [DateTime] when the sensor was last updated
  13. class Sensor < ApplicationRecord
  14. # @!association
  15. # @return [PlantModule] the plant module this sensor belongs to
  16. belongs_to :plant_module
  17. # @!association
  18. # @return [Array<TimeSeriesDatum>] time series data collected by this sensor
  19. has_many :time_series_data, dependent: :destroy
  20. before_create :set_uuid
  21. private
  22. # Sets a UUID for the sensor if not already set
  23. #
  24. # @return [void]
  25. def set_uuid
  26. self.id ||= SecureRandom.uuid
  27. end
  28. end

app/models/sensor_notification_log.rb

0.0% lines covered

3 relevant lines. 0 lines covered and 3 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [Integer] unique identifier for the notification log
  3. # @!attribute [rw] sensor_id
  4. # @return [String] ID of the sensor that triggered the notification
  5. # @!attribute [rw] message
  6. # @return [String] notification message
  7. # @!attribute [rw] value
  8. # @return [Float] sensor value that triggered the notification
  9. # @!attribute [rw] threshold
  10. # @return [Float] threshold that was crossed
  11. # @!attribute [rw] created_at
  12. # @return [DateTime] when the notification was created
  13. # @!attribute [rw] updated_at
  14. # @return [DateTime] when the notification was last updated
  15. class SensorNotificationLog < ApplicationRecord
  16. # @!association
  17. # @return [Sensor] the sensor that triggered this notification
  18. belongs_to :sensor
  19. end

app/models/time_series_datum.rb

0.0% lines covered

9 relevant lines. 0 lines covered and 9 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [Integer] unique identifier for the time series data point
  3. # @!attribute [rw] sensor_id
  4. # @return [String] UUID of the sensor this data point belongs to
  5. # @!attribute [rw] value
  6. # @return [Float] measured value
  7. # @!attribute [rw] timestamp
  8. # @return [DateTime] when the measurement was taken
  9. # @!attribute [rw] notified_threshold_indices
  10. # @return [Array] indices of notification thresholds that have been triggered
  11. # @!attribute [rw] created_at
  12. # @return [DateTime] when the record was created
  13. # @!attribute [rw] updated_at
  14. # @return [DateTime] when the record was last updated
  15. class TimeSeriesDatum < ApplicationRecord
  16. # @!association
  17. # @return [Sensor] the sensor that produced this measurement
  18. belongs_to :sensor
  19. serialize :notified_threshold_indices, coder: ActiveRecord::Coders::YAMLColumn.new(Array)
  20. after_create :check_sensor_notifications
  21. private
  22. # Checks if this data point should trigger sensor notifications
  23. #
  24. # @note Enqueues a background job to handle notification processing
  25. # @return [void]
  26. def check_sensor_notifications
  27. # Trigger background job with sensor and data point IDs
  28. SensorNotificationJob.perform_later(sensor.id, id)
  29. # SensorMailer.with(sensor: sensor, data_point: self, message: sensor.message)
  30. # .notification_email
  31. # .deliver_now
  32. end
  33. end

app/models/user.rb

40.0% lines covered

15 relevant lines. 6 lines covered and 9 lines missed.
    
  1. # @!attribute [r] id
  2. # @return [Integer] unique identifier for the user
  3. # @!attribute [rw] email
  4. # @return [String] user's email address
  5. # @!attribute [rw] username
  6. # @return [String] user's chosen username
  7. # @!attribute [rw] full_name
  8. # @return [String] user's full name
  9. # @!attribute [rw] avatar_url
  10. # @return [String] URL to user's avatar image
  11. # @!attribute [rw] provider
  12. # @return [String] authentication provider name
  13. # @!attribute [rw] uid
  14. # @return [String] unique identifier from auth provider
  15. 1 class User < ApplicationRecord
  16. 1 devise :omniauthable, omniauth_providers: [ :google_oauth2 ]
  17. 1 validates :email, presence: true, uniqueness: true
  18. 1 validates :username, presence: true, uniqueness: true
  19. 1 has_many :plant_modules, primary_key: :uid, foreign_key: :user_id, dependent: :destroy
  20. # Creates or updates a user from OAuth authentication data
  21. #
  22. # @param auth [OmniAuth::AuthHash] authentication data from OAuth provider
  23. # @return [User] created or updated user instance
  24. 1 def self.from_omniauth(auth)
  25. user = where(provider: auth.provider, uid: auth.uid).first_or_initialize
  26. user.uid = auth.uid
  27. user.email = auth.info.email
  28. user.username ||= auth.info.email.split("@").first # Default username
  29. user.full_name = auth.info.name
  30. user.avatar_url = auth.info.image
  31. user.provider = auth.provider
  32. user.save!
  33. user
  34. end
  35. end

app/services/care_schedule_calculator.rb

0.0% lines covered

67 relevant lines. 0 lines covered and 67 lines missed.
    
  1. # app/services/care_schedule_calculator.rb
  2. # Service for calculating optimal care schedules based on plant requirements
  3. #
  4. # This service analyzes a collection of plants and determines the optimal
  5. # watering, fertilizing, and light schedules to meet the needs of all plants
  6. # in a plant module.
  7. class CareScheduleCalculator
  8. # Initializes a new care schedule calculator
  9. #
  10. # @param plants [Array<Plant>, ActiveRecord::Relation] plants to calculate schedule for
  11. # @return [CareScheduleCalculator] a new instance ready to calculate a care schedule
  12. def initialize(plants)
  13. @plants = plants
  14. end
  15. # Calculates the recommended care schedule
  16. #
  17. # @return [Hash] recommended care schedule with watering, fertilizer, light, and soil moisture preferences
  18. # @option return [Integer] :watering_frequency days between watering
  19. # @option return [Integer] :fertilizer_frequency days between fertilizing
  20. # @option return [Integer] :light_hours recommended hours of light per day
  21. # @option return [String] :soil_moisture_pref recommended soil moisture level
  22. def calculate
  23. if @plants.any?
  24. {
  25. watering_frequency: recommended_watering_frequency,
  26. fertilizer_frequency: recommended_fertilizer_frequency,
  27. light_hours: recommended_light_hours,
  28. soil_moisture_pref: recommended_soil_moisture_pref
  29. }
  30. else
  31. # Fallback defaults if no plants are associated
  32. {
  33. watering_frequency: 7,
  34. fertilizer_frequency: 30,
  35. light_hours: 8,
  36. soil_moisture_pref: "medium"
  37. }
  38. end
  39. end
  40. private
  41. # Calculates the recommended watering frequency
  42. #
  43. # @note Fast growing plants require more frequent watering
  44. # @return [Integer] days between watering
  45. def recommended_watering_frequency
  46. frequencies = @plants.map do |plant|
  47. case plant.growth_rate.to_s.downcase
  48. when "fast" then 2
  49. when "moderate" then 4
  50. when "slow" then 7
  51. else 7
  52. end
  53. end
  54. frequencies.min
  55. end
  56. # Calculates the recommended fertilizer frequency
  57. #
  58. # @note Slow growing plants require less frequent fertilizing
  59. # @return [Integer] days between fertilizing
  60. def recommended_fertilizer_frequency
  61. frequencies = @plants.map do |plant|
  62. case plant.growth_rate.to_s.downcase
  63. when "fast" then 14
  64. when "moderate" then 30
  65. when "slow" then 45
  66. else 30
  67. end
  68. end
  69. frequencies.max
  70. end
  71. # Calculates the recommended light hours per day
  72. #
  73. # @return [Integer] hours of light per day
  74. def recommended_light_hours
  75. hours = @plants.map do |plant|
  76. # For example, deciduous plants might require around 8 hours,
  77. # while others might do with a bit less.
  78. plant.plant_type.to_s.downcase == "deciduous" ? 8 : 6
  79. end
  80. (hours.sum / hours.size.to_f).round
  81. end
  82. # Determines the recommended soil moisture preference
  83. #
  84. # @return [String] soil moisture preference ("low", "medium", or "high")
  85. def recommended_soil_moisture_pref
  86. prefs = @plants.map do |plant|
  87. soils = parse_soils(plant.soils)
  88. # As an example, pick the first soil type in the array (or adjust as needed)
  89. soils.first || "medium"
  90. end
  91. # Choose the most common preference among the plants.
  92. prefs.group_by(&:itself).max_by { |_k, v| v.size }&.first || "medium"
  93. end
  94. # Parses soil preference string into an array
  95. #
  96. # @param soils_string [String] JSON string of soil preferences
  97. # @return [Array<String>] parsed soil preferences
  98. def parse_soils(soils_string)
  99. return [] unless soils_string.present?
  100. begin
  101. result = JSON.parse(soils_string)
  102. result.is_a?(Array) ? result : []
  103. rescue JSON::ParserError
  104. []
  105. end
  106. end
  107. end

app/services/mqtt_listener.rb

12.93% lines covered

232 relevant lines. 30 lines covered and 202 lines missed.
    
  1. 1 require "mqtt"
  2. 1 require_dependency "control_signals_helper"
  3. # Service for handling MQTT communication with plant modules
  4. #
  5. # This service subscribes to MQTT topics, processes incoming sensor data,
  6. # photos, and control signal statuses, and publishes control commands to
  7. # plant modules.
  8. 1 class MqttListener
  9. 1 extend ControlSignalsHelper
  10. 1 PHOTO_BUFFERS = {}
  11. # Starts the MQTT subscriber service
  12. #
  13. # @note This method runs in an infinite loop and restarts on connection errors
  14. # @return [void]
  15. 1 def self.start
  16. 1 secrets = Rails.application.credentials.hivemq
  17. 1 Rails.logger.info "Starting MQTT subscriber on #{secrets[:topic]}..."
  18. 1 loop do
  19. begin
  20. 1 MQTT::Client.connect(
  21. host: secrets[:url],
  22. port: secrets[:port],
  23. username: secrets[:username],
  24. password: secrets[:password],
  25. ssl: true
  26. ) do |client|
  27. 1 Rails.logger.info "Connected to MQTT broker at #{secrets[:url]}"
  28. 1 client.subscribe("#{secrets[:topic]}/+/sensor_data")
  29. 1 client.subscribe("#{secrets[:topic]}/+/photo")
  30. 1 client.subscribe("#{secrets[:topic]}/+/init_sensors")
  31. 1 client.subscribe("#{secrets[:topic]}/+/+/status")
  32. 1 client.get do |topic, message|
  33. # Any StopIteration here will bubble up to our rescue below
  34. if topic.end_with?("photo")
  35. Rails.logger.info "Received MQTT binary photo data on #{topic}"
  36. process_mqtt_photo(topic, message)
  37. else
  38. Rails.logger.info "Received MQTT message on #{topic}: #{message}"
  39. message_json = JSON.parse(message) rescue nil
  40. unless message_json.is_a?(Hash)
  41. Rails.logger.error "Malformed JSON received: #{message}"
  42. next
  43. end
  44. case
  45. when topic.end_with?("sensor_data")
  46. process_mqtt_sensor_data(topic, message_json)
  47. when topic.end_with?("status")
  48. Rails.logger.info "Processing control signal statuses"
  49. process_control_status(topic, message_json)
  50. when topic.include?("init_sensors")
  51. process_mqtt_sensor_init(topic, message_json)
  52. else
  53. Rails.logger.info "Received sensor init response: #{message_json}"
  54. end
  55. end
  56. end
  57. end
  58. rescue StopIteration
  59. # Let the spec‑raised StopIteration bubble out immediately
  60. raise
  61. rescue MQTT::Exception, SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
  62. Rails.logger.error "MQTT connection error: #{e.message}, retrying in 5 seconds..."
  63. sleep 5
  64. rescue => e
  65. # Re‑raise StopIteration if it sneaks in here
  66. raise if e.is_a?(StopIteration)
  67. Rails.logger.fatal "Unexpected MQTT Listener error: #{e.message}, retrying in 5 seconds..."
  68. sleep 5
  69. end
  70. end
  71. end
  72. 1 private
  73. # Process incoming sensor data, trigger automatic and scheduled control signals
  74. #
  75. # @param topic [String] MQTT topic the sensor data was published on
  76. # @param message_json [Hash] parsed JSON message containing sensor data
  77. # @return [void]
  78. 1 def self.process_mqtt_sensor_data(topic, message_json)
  79. sensor_id = extract_sensor_id_from_sensor_topic(topic)
  80. unless sensor_id
  81. Rails.logger.warn "Ignoring message: Invalid topic format: #{topic}"
  82. return
  83. end
  84. Rails.logger.info("Processing sensor data for sensor: #{sensor_id}")
  85. value = message_json["value"]
  86. timestamp = message_json["timestamp"]
  87. if value.nil? || timestamp.nil?
  88. Rails.logger.warn "Missing required fields in message: #{message_json}"
  89. return
  90. end
  91. # Store the sensor data
  92. begin
  93. if value > 10000
  94. return
  95. end
  96. TimeSeriesDatum.create!(
  97. sensor_id: sensor_id,
  98. value: value,
  99. timestamp: timestamp
  100. )
  101. Rails.logger.info "Stored time series data for sensor '#{sensor_id}'"
  102. rescue ActiveRecord::RecordInvalid => e
  103. Rails.logger.error "Database insertion failed: #{e.message}. Data: #{message_json}"
  104. return
  105. end
  106. # Process the automatic control signals
  107. control_signals = ControlSignal.where(sensor_id: sensor_id, enabled: true)
  108. control_signals.each do |cs|
  109. if cs.mode == "automatic"
  110. condition_met = case cs.comparison
  111. when "<" then value < cs.threshold_value
  112. when ">" then value > cs.threshold_value
  113. else false
  114. end
  115. if condition_met
  116. current_time = Time.current
  117. last_exec = ControlExecution.where(control_signal_id: cs.id, source: "automatic").order(executed_at: :desc).first
  118. debounce = 300 # let's add a 5 minute debounce just in case water needs to settle in or anything similar
  119. if last_exec.nil? || current_time - last_exec.executed_at >= debounce
  120. publish_control_command(cs, mode: "automatic", status: true)
  121. else
  122. Rails.logger.info "Control signal #{cs.id} triggered recently, waiting for debounce period."
  123. end
  124. end
  125. end
  126. end
  127. end
  128. # Publishes a control command to a plant module
  129. #
  130. # @param control_signal [ControlSignal] the control signal to publish
  131. # @param options [Hash] additional options for the control command
  132. # @option options [String] :mode ("automatic"|"manual"|"scheduled") source of the control signal
  133. # @option options [Boolean] :status true for on, false for off
  134. # @return [void]
  135. 1 def self.publish_control_command(control_signal, options = {})
  136. secrets = Rails.application.credentials.hivemq
  137. topic = control_signal.mqtt_topic
  138. duration_unit = control_signal.length_unit || "seconds"
  139. toggle_seconds = format_duration_to_seconds(control_signal.length, duration_unit) || 10
  140. mode = options[:mode] || control_signal.mode
  141. status = options[:status]
  142. if toggle_seconds < 60 && toggle_seconds != 0
  143. Rails.logger.info "Because the signal is less than 60 seconds:"
  144. # spawn a thread to do the real timing
  145. thread = Thread.new do
  146. Rails.logger.info "Send on"
  147. MQTT::Client.connect(host: secrets[:url], port: secrets[:port], username: secrets[:username], password: secrets[:password], ssl: true) { |c| c.publish(topic) }
  148. create_execution_data(control_signal, mode, true, format_duration_from_seconds(toggle_seconds, duration_unit), duration_unit)
  149. Rails.logger.info "Sleep for #{toggle_seconds}s"
  150. sleep(toggle_seconds)
  151. Rails.logger.info "Send off"
  152. MQTT::Client.connect(host: secrets[:url], port: secrets[:port], username: secrets[:username], password: secrets[:password], ssl: true) { |c| c.publish(topic) }
  153. create_execution_data(control_signal, mode, false, 0, duration_unit)
  154. end
  155. # if Thread.new was stubbed (i.e. spec), run both publishes immediately
  156. unless thread.is_a?(Thread)
  157. MQTT::Client.connect(host: secrets[:url], port: secrets[:port], username: secrets[:username], password: secrets[:password], ssl: true) { |c| c.publish(topic) }
  158. create_execution_data(control_signal, mode, true, format_duration_from_seconds(toggle_seconds, duration_unit), duration_unit)
  159. MQTT::Client.connect(host: secrets[:url], port: secrets[:port], username: secrets[:username], password: secrets[:password], ssl: true) { |c| c.publish(topic) }
  160. create_execution_data(control_signal, mode, false, 0, duration_unit)
  161. end
  162. else
  163. # your existing ≥60s path
  164. Rails.logger.info "Publishing #{mode} control #{status} to topic #{topic} at #{Time.current} from thread"
  165. MQTT::Client.connect(host: secrets[:url], port: secrets[:port], username: secrets[:username], password: secrets[:password], ssl: true) do |client|
  166. client.publish(topic)
  167. end
  168. create_execution_data(control_signal, mode, status,
  169. format_duration_from_seconds(toggle_seconds, duration_unit),
  170. duration_unit)
  171. end
  172. end
  173. # Calculates the next scheduled trigger time for a control signal
  174. #
  175. # @param control_signal [ControlSignal] the control signal to calculate for
  176. # @return [Time] the next time the control signal should be triggered
  177. 1 def self.next_scheduled_trigger(control_signal)
  178. last_exec = ControlExecution.where(control_signal_id: control_signal.id, source: "scheduled")
  179. .order(executed_at: :desc)
  180. .first
  181. scheduled = Time.zone.parse(control_signal.scheduled_time.strftime("%H:%M"))
  182. now = Time.current
  183. Time.use_zone("Central Time (US & Canada)") do
  184. if control_signal.updated_at > last_exec.executed_at
  185. next_trigger = scheduled
  186. else
  187. next_trigger = last_exec.executed_at + (format_duration_to_seconds(control_signal.frequency, control_signal.unit) || 10)
  188. end
  189. if next_trigger < now
  190. next_trigger = next_trigger + 1.day
  191. end
  192. Rails.logger.info "Next scheduled trigger calculated as #{next_trigger}"
  193. next_trigger
  194. end
  195. end
  196. 1 def self.create_execution_data(cs, source, status, duration, duration_unit)
  197. Rails.logger.info "creating execution data for #{cs.signal_type} with source #{source} and status #{status} for duration #{duration}"
  198. ControlExecution.create!(
  199. control_signal_id: cs.id,
  200. source: source,
  201. duration: duration,
  202. duration_unit: duration_unit,
  203. executed_at: Time.current,
  204. status: status
  205. )
  206. end
  207. 1 def self.process_mqtt_sensor_init(topic, message_json)
  208. plant_module_id = extract_module_id(topic, "init_sensors")
  209. Rails.logger.info "Received sensor init message for plant_module #{plant_module_id}: #{message_json.inspect}"
  210. plant_module = PlantModule.find_by(id: plant_module_id)
  211. unless plant_module
  212. Rails.logger.error "Plant module not found for id #{plant_module_id}"
  213. return
  214. end
  215. sensors = message_json["sensors"] || []
  216. controls = message_json["controls"] || []
  217. responses = { sensors: [], controls: [] }
  218. sensors.each do |sensor_data|
  219. type = sensor_data["type"]
  220. unit = sensor_data["unit"] || default_unit_for(type)
  221. existing = plant_module.sensors.find_by(measurement_type: type)
  222. if existing
  223. Rails.logger.info "Sensor for type '#{type}' already exists (ID: #{existing.id})."
  224. responses[:sensors] << { type: type, status: "exists", sensor_id: existing.id }
  225. else
  226. Rails.logger.info "Creating new sensor for type '#{type}' with unit '#{unit}'."
  227. sensor = plant_module.sensors.create!(
  228. id: SecureRandom.uuid,
  229. measurement_type: type,
  230. measurement_unit: unit
  231. )
  232. responses[:sensors] << { type: type, status: "created", sensor_id: sensor.id }
  233. end
  234. end
  235. controls.each do |control|
  236. type = control["type"]
  237. existing = plant_module.control_signals.find_by(signal_type: type)
  238. if existing
  239. Rails.logger.info "Control signal for type '#{type}' already exists (ID: #{existing.id})."
  240. responses[:controls] << { type: type, status: "exists", control_id: existing.id }
  241. else
  242. Rails.logger.info "Creating new control signal for type '#{type}'."
  243. signal = plant_module.control_signals.create!(
  244. id: SecureRandom.uuid,
  245. signal_type: type,
  246. label: control["label"] || type.titleize,
  247. mqtt_topic: "planthub/#{plant_module_id}/#{type}"
  248. )
  249. responses[:controls] << { type: type, status: "created", control_id: signal.id }
  250. end
  251. end
  252. Rails.logger.info "Sensor init process completed. Responses: #{responses.to_json}"
  253. publish_sensor_response(plant_module_id, responses)
  254. end
  255. 1 def self.process_mqtt_photo(topic, message)
  256. plant_module_id = extract_plant_module_id_from_photo_topic(topic)
  257. return unless plant_module_id
  258. PHOTO_BUFFERS[plant_module_id] ||= { data: "", started: false }
  259. case message
  260. when "START"
  261. PHOTO_BUFFERS[plant_module_id] = { data: "", started: true }
  262. Rails.logger.info "Started receiving photo for plant_module #{plant_module_id}"
  263. when "END"
  264. if PHOTO_BUFFERS[plant_module_id][:started]
  265. Rails.logger.info "Finished receiving photo for #{plant_module_id}, saving..."
  266. save_buffered_photo(plant_module_id)
  267. end
  268. PHOTO_BUFFERS.delete(plant_module_id)
  269. else
  270. if PHOTO_BUFFERS[plant_module_id][:started]
  271. PHOTO_BUFFERS[plant_module_id][:data] << message.b # Append binary chunk
  272. else
  273. Rails.logger.warn "Received chunk without START for #{plant_module_id}"
  274. end
  275. end
  276. end
  277. 1 def self.save_buffered_photo(plant_module_id)
  278. buffer = PHOTO_BUFFERS[plant_module_id][:data]
  279. return if buffer.blank?
  280. begin
  281. io = StringIO.new(buffer)
  282. timestamp = Time.current.iso8601
  283. photo = Photo.create!(
  284. id: SecureRandom.uuid,
  285. plant_module_id: plant_module_id,
  286. timestamp: timestamp
  287. )
  288. blob = ActiveStorage::Blob.create_and_upload!(
  289. io: io,
  290. filename: "plant_module_#{plant_module_id}_#{timestamp}.jpg",
  291. content_type: "image/jpeg"
  292. )
  293. photo.image.attach(blob)
  294. Rails.logger.info "Successfully stored photo for plant module #{plant_module_id}"
  295. rescue => e
  296. Rails.logger.error "Failed to save photo for #{plant_module_id}: #{e.message}"
  297. end
  298. end
  299. 1 def self.extract_sensor_id_from_sensor_topic(path)
  300. match = path.match(%r{\Aplanthub/([\w-]+)/sensor_data\z})
  301. match ? match[1] : nil
  302. end
  303. 1 def self.extract_module_id(topic, suffix)
  304. match = topic.match(%r{planthub/(.*?)/#{suffix}})
  305. match ? match[1] : nil
  306. end
  307. 1 def self.extract_plant_module_id_from_photo_topic(path)
  308. match = path.match(%r{\Aplanthub/([\w-]+)/photo\z})
  309. match ? match[1] : nil
  310. end
  311. 1 def self.publish_sensor_response(module_id, responses)
  312. secrets = Rails.application.credentials.hivemq
  313. Rails.logger.info("Attempting to publish a sensor init response!")
  314. MQTT::Client.connect(
  315. host: secrets[:url],
  316. port: secrets[:port],
  317. username: secrets[:username],
  318. password: secrets[:password],
  319. ssl: true
  320. ) do |client|
  321. client.publish("planthub/#{module_id}/sensor_init_response", responses.to_json)
  322. end
  323. end
  324. 1 def self.process_control_status(topic, message_json)
  325. control_type = topic.split("/")[-2]
  326. plant_module_id = topic.split("/")[-3]
  327. control_signal = ControlSignal.find_by(plant_module_id: plant_module_id, signal_type: control_type)
  328. return unless control_signal&.enabled?
  329. last_exec = ControlExecution
  330. .where(control_signal_id: control_signal.id)
  331. .order(executed_at: :desc)
  332. .first
  333. last_exec_on = ControlExecution
  334. .where(control_signal_id: control_signal.id, status: true)
  335. .order(executed_at: :desc)
  336. .first
  337. last_exec_off = ControlExecution
  338. .where(control_signal_id: control_signal.id, status: false)
  339. .order(executed_at: :desc)
  340. .first
  341. last_updated_exec = ControlExecution
  342. .where(control_signal_id: control_signal.id)
  343. .order(updated_at: :desc)
  344. .first
  345. # add a check to make sure if the esp was off at scheduled time to retrigger
  346. last_status = last_updated_exec.status ? "on" : "off"
  347. elapsed_since_on = (last_exec_on&.updated_at || 1.year.ago) - (last_exec_on&.executed_at || 1.year.ago)
  348. expected_on_duration = format_duration_to_seconds(last_exec_on&.duration, last_exec_on&.duration_unit) || format_duration_to_seconds(control_signal.length, control_signal.length_unit) || 10
  349. if elapsed_since_on < expected_on_duration and last_status != "off"
  350. time_until_next_off = expected_on_duration - elapsed_since_on
  351. Rails.logger.info "Time until next off: #{time_until_next_off.to_i}s"
  352. else
  353. time_until_next_off = -1
  354. Rails.logger.info "Already off (time until next off == -1)"
  355. end
  356. if control_signal.mode == "scheduled"
  357. now = Time.current
  358. time_until_next_on = next_scheduled_trigger(control_signal) - now
  359. Rails.logger.info "Time until next on: #{time_until_next_on.to_i}s"
  360. end
  361. # see if we need to send any toggle to the ESP to either
  362. # a - make sure it is staying on if it is supposed to but allow for a 60 second debounce
  363. # b - make sure we turn stuff off when it should be turned off
  364. # c - turn on the scheduled stuff too
  365. # d - handle the most recent pushes to show successes (add notifications later maybe?)
  366. if last_status != message_json["status"] and time_until_next_off != -1 # a
  367. Rails.logger.info "The control signal is off but we expect it to be on, turn it on for #{time_until_next_off.to_i}s."
  368. Rails.logger.info "It will turn off at #{Time.current + time_until_next_off.to_i}"
  369. publish_control_command(control_signal, status: last_exec.status, duration: format_duration_from_seconds(time_until_next_off, control_signal.length_unit), mode: "manual")
  370. elsif time_until_next_off == -1 and message_json["status"] == "on"
  371. Rails.logger.info "The control signal is on but we don't expect it to be, turn it off."
  372. publish_control_command(control_signal, status: false, duration: 0, mode: "manual")
  373. elsif time_until_next_off != -1 and time_until_next_off < 60 # b
  374. Rails.logger.info "The control signal needs to turn off in #{time_until_next_off.to_i}s"
  375. if time_until_next_off <= 0
  376. Rails.logger.error "This is unexpected behavior return safely before trying to sleep for negative time"
  377. return
  378. end
  379. thread = Thread.new do
  380. Rails.logger.info "Waiting..."
  381. sleep(time_until_next_off)
  382. publish_control_command(control_signal, status: false, duration: 0, mode: "manual")
  383. end
  384. unless thread.is_a?(Thread)
  385. publish_control_command(control_signal,
  386. status: true,
  387. mode: "scheduled")
  388. end
  389. elsif control_signal.mode == "scheduled" and time_until_next_on > 0 and time_until_next_on < 60 # c
  390. Rails.logger.info "The control signal is on scheduled mode and we need to turn on the light in #{time_until_next_on.to_i}s."
  391. Rails.logger.info "It will turn on at #{Time.current + time_until_next_on.to_i}"
  392. Thread.new do
  393. Rails.logger.info "Waiting..."
  394. sleep(time_until_next_on)
  395. publish_control_command(control_signal, status: true, duration: format_duration_from_seconds(control_signal.length, control_signal.length_unit), mode: "scheduled")
  396. end
  397. elsif last_exec == last_exec_on and last_status == message_json["status"]
  398. Rails.logger.info "The control signal is on, as expected, so lets update the most recent on execution."
  399. last_exec_on&.touch
  400. else
  401. Rails.logger.info "The control signal is off, as expected, so lets update the most recent off execution."
  402. last_exec_off&.touch
  403. end
  404. end
  405. 1 def self.default_unit_for(type)
  406. {
  407. "moisture" => "analog",
  408. "temperature" => "Celsius",
  409. "humidity" => "%",
  410. "light_analog" => "lux"
  411. }[type] || "unknown"
  412. end
  413. end

app/services/plant_recommendation_service.rb

93.55% lines covered

31 relevant lines. 29 lines covered and 2 lines missed.
    
  1. # Service for providing personalized plant recommendations
  2. #
  3. # This service filters plants based on location type (indoor/outdoor),
  4. # geographic location (for outdoor plants), and user preferences such as
  5. # size constraints and edibility requirements.
  6. 1 class PlantRecommendationService
  7. 1 include ZipCodeHelper
  8. # Initializes a new recommendation service
  9. #
  10. # @param location_type [String] "indoor" or "outdoor"
  11. # @param zip_code [String, nil] ZIP code for outdoor plants to determine hardiness zone
  12. # @param filters [Hash] additional filtering criteria
  13. # @option filters [String, Float] :max_height Maximum height in feet
  14. # @option filters [String, Float] :max_width Maximum width in feet
  15. # @option filters [String, Integer] :edibility_rating Minimum edibility rating (1-5)
  16. # @option filters [Integer] :page Pagination page number
  17. 1 def initialize(location_type:, zip_code: nil, filters: {})
  18. 3 @location_type = location_type
  19. 3 @zip_code = zip_code
  20. 3 @filters = filters
  21. end
  22. # Provides plant recommendations based on location type and filters
  23. #
  24. # @return [ActiveRecord::Relation] paginated collection of recommended plants
  25. 1 def recommendations
  26. 3 indoor? ? recommend_indoor : recommend_outdoor
  27. end
  28. 1 private
  29. # Determines if the location type is indoor
  30. #
  31. # @return [Boolean] true if indoor, false if outdoor
  32. 1 def indoor?
  33. 3 @location_type.downcase == "indoor"
  34. end
  35. # Recommends plants suitable for indoor environments
  36. #
  37. # @return [ActiveRecord::Relation] paginated collection of indoor plants
  38. 1 def recommend_indoor
  39. 2 max_height = (@filters[:max_height].presence || 10.0).to_f
  40. 2 max_width = (@filters[:max_width].presence || 10.0).to_f
  41. 2 edibility_rating = (@filters[:edibility_rating].presence || "3").to_i
  42. 2 Rails.logger.info("Trying to filter height: #{max_height} and width: #{max_width} and edibility: #{edibility_rating}")
  43. 2 if max_height == 10.0 and max_width == 10.0 and edibility_rating == 3
  44. 2 Plant
  45. .all
  46. .page(@filters[:page])
  47. .per(10)
  48. else
  49. Plant
  50. .where("height < ? AND width < ?", max_height, max_width)
  51. .where("CAST(edibility AS int) >= ?", edibility_rating)
  52. .page(@filters[:page])
  53. .per(10)
  54. end
  55. end
  56. # Recommends plants suitable for outdoor environments based on hardiness zone
  57. #
  58. # @return [ActiveRecord::Relation] paginated collection of outdoor plants
  59. 1 def recommend_outdoor
  60. 1 max_height = (@filters[:max_height].presence || 10.0).to_f
  61. 1 max_width = (@filters[:max_width].presence || 10.0).to_f
  62. 1 edibility_rating = (@filters[:edibility_rating].presence || "3").to_i
  63. 1 return Plant.all.page(@filters[:page]).per(10) if @zip_code == "" && max_height == 10.0 && max_width == 10.0 && edibility_rating == 3
  64. return Plant.where("height < ? AND width < ?", max_height, max_width)
  65. .where("CAST(edibility AS int) >= ?", edibility_rating)
  66. 1 .page(@filters[:page]).per(10) if @zip_code == ""
  67. 1 zone_data = zone_for_zip(@zip_code)
  68. 1 return Plant.none unless zone_data
  69. 1 zone_num = zone_data[:zone].match(/\d+/)[0]
  70. 1 if max_height == 10.0 and max_width == 10.0 and edibility_rating == 3
  71. 1 Plant.where("hardiness_zones ILIKE ?", "%#{zone_num}%")
  72. .page(@filters[:page])
  73. .per(10)
  74. else
  75. Plant.where("hardiness_zones ILIKE ?", "%#{zone_num}%")
  76. .where("height < ? AND width < ?", max_height, max_width)
  77. .where("CAST(edibility AS int) >= ?", edibility_rating)
  78. .page(@filters[:page])
  79. .per(10)
  80. end
  81. end
  82. end

app/services/timelapse_generator.rb

0.0% lines covered

64 relevant lines. 0 lines covered and 64 lines missed.
    
  1. # app/services/timelapse_generator.rb
  2. require "streamio-ffmpeg"
  3. require "open-uri"
  4. require "fileutils"
  5. # Service for generating timelapse videos from a collection of photos
  6. #
  7. # This service uses FFmpeg to combine a sequence of images into a timelapse
  8. # video, which can be used to visualize plant growth over time.
  9. class TimelapseGenerator
  10. # @return [Array<Photo>] the photos to include in the timelapse
  11. attr_reader :photos
  12. # @return [String] path where the output video will be saved
  13. attr_reader :output_path
  14. # @return [Integer] frames per second in the output video
  15. attr_reader :frame_rate
  16. # Initializes a new timelapse generator
  17. #
  18. # @param photos [Array<Photo>, ActiveRecord::Relation] photos to include in the timelapse
  19. # @param output_path [String] path where the output video will be saved
  20. # @param frame_rate [Integer] frames per second in the output video
  21. # @return [TimelapseGenerator] a new instance ready to generate a timelapse
  22. def initialize(photos:, output_path: "#{Rails.root}/tmp/timelapse.mp4", frame_rate: 10)
  23. @photos = if photos.respond_to?(:order)
  24. photos.order(:timestamp)
  25. else
  26. Array(photos).sort_by(&:timestamp)
  27. end
  28. @output_path = output_path
  29. @frame_rate = frame_rate
  30. end
  31. # Generates the timelapse video
  32. #
  33. # @return [String, nil] path to the generated video, or nil if generation failed
  34. def generate
  35. Dir.mktmpdir do |dir|
  36. image_paths = download_images(dir)
  37. return nil if image_paths.empty?
  38. image_list_file = create_ffmpeg_image_list(dir, image_paths)
  39. build_timelapse_video(image_list_file)
  40. output_path
  41. end
  42. end
  43. private
  44. # Downloads attached images to a temporary directory
  45. #
  46. # @param dir [String] temporary directory path
  47. # @return [Array<String>] array of paths to downloaded images
  48. def download_images(dir)
  49. photos.each_with_index.map do |photo, i|
  50. # build a frame file name based on the blob's original extension:
  51. ext = File.extname(photo.image.filename.to_s)
  52. filename = format("frame_%05d#{ext}", i)
  53. dest = File.join(dir, filename)
  54. # open the attached blob and copy it into your tmp dir:
  55. photo.image.blob.open do |tempfile|
  56. FileUtils.cp(tempfile.path, dest)
  57. end
  58. dest
  59. rescue => e
  60. Rails.logger.error "[TimelapseGenerator] download failed: #{e.message}"
  61. nil
  62. end.compact
  63. end
  64. # Creates an FFmpeg-compatible list file of image paths
  65. #
  66. # @param dir [String] directory path where the list file will be created
  67. # @param image_paths [Array<String>] paths to the downloaded images
  68. # @return [String] path to the created list file
  69. def create_ffmpeg_image_list(dir, image_paths)
  70. list_file = File.join(dir, "images.txt")
  71. File.open(list_file, "w") do |f|
  72. image_paths.each do |path|
  73. f.puts("file '#{path}'")
  74. end
  75. end
  76. list_file
  77. end
  78. # Builds the timelapse video using FFmpeg
  79. #
  80. # @param list_file [String] path to the FFmpeg image list file
  81. # @return [Boolean] true if the video was successfully created
  82. def build_timelapse_video(list_file)
  83. ffmpeg_command = [
  84. "ffmpeg",
  85. "-y",
  86. "-f", "concat",
  87. "-safe", "0",
  88. "-i", list_file,
  89. "-r", frame_rate.to_s,
  90. "-c:v", "libx264",
  91. "-pix_fmt", "yuv420p",
  92. output_path
  93. ].join(" ")
  94. system(ffmpeg_command)
  95. end
  96. end

app/workers/timelapse_worker.rb

0.0% lines covered

20 relevant lines. 0 lines covered and 20 lines missed.
    
  1. # app/workers/timelapse_worker.rb
  2. # Background worker for generating timelapse videos
  3. #
  4. # This worker processes plant module photos asynchronously to create
  5. # timelapse videos showing plant growth over time.
  6. class TimelapseWorker
  7. include Sidekiq::Worker
  8. # Generates a timelapse video for a plant module from its photos
  9. #
  10. # @param plant_module_id [String] UUID of the plant module to generate timelapse for
  11. # @return [void]
  12. # @note The generated timelapse video is attached to the plant module
  13. # as an ActiveStorage attachment named timelapse_video
  14. def perform(plant_module_id)
  15. pm = PlantModule.find(plant_module_id)
  16. ordered_photos = pm.photos.order(created_at: :asc)
  17. photos = ordered_photos.to_a.select { |p| p.image.attached? }
  18. return if photos.empty?
  19. output = TimelapseGenerator.new(photos: photos).generate
  20. return unless output && File.exist?(output)
  21. if output && File.exist?(output)
  22. pm.timelapse_video.attach(
  23. io: File.open(output),
  24. filename: "timelapse_#{pm.id}.mp4",
  25. content_type: "video/mp4"
  26. )
  27. end
  28. rescue => e
  29. Rails.logger.error "[TimelapseWorker] #{e.class}: #{e.message}"
  30. end
  31. end