mitsubishi_heatpump 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8314b54176fb56cef543e4d4f9d7fe66b80d1ac9ed10ad6da8ac847bb5afce70
4
+ data.tar.gz: f8a8874b0f8211523354c406a08e03e2fceeee7bac2642cf3a3ee9ed77e134c4
5
+ SHA512:
6
+ metadata.gz: bbe752efd1571bba5eb2aa708ee4a43c521a4bdfc221952175591a1fb0c980c833224bf5f7d2d5f85f9c2d4fd2032141171122661640ceb7ca9c22932e7556c5
7
+ data.tar.gz: df5b374886f07d08e0cc7f17b9999c615581fbbe5ac39e289c233355f18bffb24f7e2dc6199807422e1bb39c3308e95e57074c558f266b754621bcbdfdb0db74
data/bin/mitsubishi ADDED
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "mitsubishi_heatpump"
6
+
7
+ std_opts = { port: 80 }
8
+ settings = {}
9
+
10
+ # rubocop:todo Metrics/BlockLength -- Refactor into Parser class
11
+ option_parser = OptionParser.new do |opts|
12
+ opts.banner = "Usage: mitsubishi HOST [options]"
13
+
14
+ opts.on("HOST", "Heatpump host") do |host|
15
+ std_opts[:host] = host
16
+ end
17
+
18
+ opts.on("-p", "--port PORT", Integer, "Heatpump port (default 80)") do |port|
19
+ std_opts[:port] = port
20
+ end
21
+
22
+ opts.on("--power POWER", "Set power (on/off)") do |power|
23
+ if power.downcase == "on"
24
+ settings[:power] = MitsubishiHeatpump::DataTypes::Power.ON
25
+ elsif power.downcase == "off"
26
+ settings[:power] = MitsubishiHeatpump::DataTypes::Power.OFF
27
+ end
28
+ end
29
+
30
+ opts.on("--target-temp TEMP", Integer, "Set target temperature") do |temp|
31
+ settings[:target_temp] = MitsubishiHeatpump::DataTypes::EnhancedTemperature.new(temp)
32
+ end
33
+
34
+ opts.on("--mode MODE", "Set mode (auto/cool/heat/dry/fan)") do |mode|
35
+ case mode.downcase
36
+ when "auto"
37
+ settings[:operation_mode] = MitsubishiHeatpump::DataTypes::OperationMode.AUTO
38
+ when "cool"
39
+ settings[:operation_mode] = MitsubishiHeatpump::DataTypes::OperationMode.COOL
40
+ when "heat"
41
+ settings[:operation_mode] = MitsubishiHeatpump::DataTypes::OperationMode.HEAT
42
+ when "dry"
43
+ settings[:operation_mode] = MitsubishiHeatpump::DataTypes::OperationMode.DEHUMIDIFY
44
+ when "fan"
45
+ settings[:operation_mode] = MitsubishiHeatpump::DataTypes::OperationMode.FAN
46
+ end
47
+ end
48
+
49
+ opts.on("--fan-speed SPEED", "Set fan speed (auto/quiet/low/medium/high/very_high)") do |speed|
50
+ settings[:fan_speed] = case speed.downcase
51
+ when "auto"
52
+ MitsubishiHeatpump::DataTypes::FanMode.AUTO
53
+ when "quiet"
54
+ MitsubishiHeatpump::DataTypes::FanMode.QUIET
55
+ when "low"
56
+ MitsubishiHeatpump::DataTypes::FanMode.LOW
57
+ when "medium"
58
+ MitsubishiHeatpump::DataTypes::FanMode.MEDIUM
59
+ when "high"
60
+ MitsubishiHeatpump::DataTypes::FanMode.HIGH
61
+ when "very_high"
62
+ MitsubishiHeatpump::DataTypes::FanMode.VERY_HIGH
63
+ end
64
+ end
65
+
66
+ opts.on("--vertical-vane POSITION", "Set vertical vane position (auto/1-5/swing)") do |position|
67
+ position = case position.downcase
68
+ when "auto"
69
+ 0
70
+ when "swing"
71
+ 7
72
+ else
73
+ position.to_i
74
+ end
75
+ settings[:vertical_vane] = MitsubishiHeatpump::DataTypes::VerticalVane.new(position)
76
+ end
77
+
78
+ opts.on("--horizontal-vane POSITION", "Set horizontal vane position (auto/full_left/left/center/right/full_right/"\
79
+ "split_center_left/split_center_right/split_left_right/split_center_left_right/swing)") do |position|
80
+ settings[:horizontal_vane] = case position.downcase
81
+ when "auto"
82
+ MitsubishiHeatpump::DataTypes::HorizontalVane.AUTO
83
+ when "full_left"
84
+ MitsubishiHeatpump::DataTypes::HorizontalVane.FULL_LEFT
85
+ when "left"
86
+ MitsubishiHeatpump::DataTypes::HorizontalVane.LEFT
87
+ when "center"
88
+ MitsubishiHeatpump::DataTypes::HorizontalVane.CENTER
89
+ when "right"
90
+ MitsubishiHeatpump::DataTypes::HorizontalVane.RIGHT
91
+ when "full_right"
92
+ MitsubishiHeatpump::DataTypes::HorizontalVane.FULL_RIGHT
93
+ when "split_center_left"
94
+ MitsubishiHeatpump::DataTypes::HorizontalVane.SPLIT_CENTER_LEFT
95
+ when "split_center_right"
96
+ MitsubishiHeatpump::DataTypes::HorizontalVane.SPLIT_CENTER_RIGHT
97
+ when "split_left_right"
98
+ MitsubishiHeatpump::DataTypes::HorizontalVane.SPLIT_LEFT_RIGHT
99
+ when "split_center_left_right"
100
+ MitsubishiHeatpump::DataTypes::HorizontalVane.SPLIT_CENTER_LEFT_RIGHT
101
+ when "swing"
102
+ MitsubishiHeatpump::DataTypes::HorizontalVane.SWING
103
+ end
104
+ end
105
+
106
+ opts.on("--dry-run", "Perform a dry run without sending commands") do
107
+ std_opts[:dry_run] = true
108
+ end
109
+
110
+ opts.on_tail("-h", "--help", "Show this message") do
111
+ puts opts
112
+ exit
113
+ end
114
+ end
115
+ # rubocop:enable Metrics/BlockLength
116
+
117
+ option_parser.parse!(ARGV)
118
+
119
+ std_opts[:host] = ARGV.shift
120
+
121
+ if std_opts[:host].nil?
122
+ puts "Error: HOST is required\n"
123
+ puts option_parser.help
124
+ exit 1
125
+ end
126
+
127
+ api = MitsubishiHeatpump::API.new(std_opts[:host], std_opts[:port])
128
+
129
+ if settings.empty?
130
+ resp = api.status
131
+ else
132
+ command = MitsubishiHeatpump::Protocol::PDU.new(
133
+ MitsubishiHeatpump::Protocol::SetRequest.new(
134
+ MitsubishiHeatpump::Protocol::SetSettings.new(
135
+ **settings
136
+ )
137
+ )
138
+ )
139
+ puts "Sending command:"
140
+ puts command.inspect
141
+ resp = api.send_command(command) unless std_opts[:dry_run]
142
+ end
143
+
144
+ resp.each do |pdu|
145
+ puts "#{pdu}\n\n"
146
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MitsubishiHeatpump
4
+ module ABC
5
+ ##
6
+ # Packet base class
7
+ class Packet
8
+ attr_reader :command
9
+
10
+ def initialize(command)
11
+ @command = command
12
+ end
13
+
14
+ def serialize
15
+ [@command.class::COMMAND_ID] + @command.serialize
16
+ end
17
+
18
+ def inspect
19
+ "#{self.class.name.split("::").last} => #{@command.inspect}"
20
+ end
21
+
22
+ alias to_s inspect
23
+
24
+ def self.parse(payload)
25
+ command_type = self::COMMAND_TYPES[payload[0]]
26
+ raise ProtocolError, "Unsupported command type" unless command_type
27
+
28
+ new(command_type.parse(payload[1..]))
29
+ end
30
+ end
31
+
32
+ ##
33
+ # Command base class
34
+ class Command
35
+ UPDATE_FLAGS_SIZE = 0
36
+ def initialize(**kwargs)
37
+ kwargs.each do |key, value|
38
+ raise ArgumentError, "Unknown parameter: #{key}" unless self.class::BYTE_VALUES.key?(key)
39
+
40
+ instance_variable_set("@#{key}", value)
41
+ end
42
+ end
43
+
44
+ def self.parse(payload)
45
+ new(**self::BYTE_VALUES.transform_values do |param|
46
+ param.cls.parse(
47
+ param.mask && param.byte.is_a?(Integer) ? payload[param.byte] & param.mask : payload[param.byte]
48
+ )
49
+ end)
50
+ end
51
+
52
+ # rubocop:disable Metrics/MethodLength, Metrics/AbcSize -- Hard to refactor without increasing runtime complexity
53
+ def serialize
54
+ update_flag_size = self.class::UPDATE_FLAGS_SIZE
55
+ update_flags = DataTypes::UpdateFlags.new(0, update_flag_size)
56
+ bytes = [0] * 15
57
+ self.class::BYTE_VALUES.each do |key, param|
58
+ byte = param.byte
59
+ flag = param.update_flag || 0
60
+ value = instance_variable_get("@#{key}")&.serialize
61
+ next unless value
62
+
63
+ update_flags |= flag
64
+ if byte.is_a?(Integer)
65
+ bytes[byte] |= value
66
+ else
67
+ bytes[byte] = value
68
+ end
69
+ end
70
+ update_flags.serialize + bytes[update_flag_size..]
71
+ end
72
+
73
+ def inspect
74
+ "#{self.class.name.split("::").last} => " + instance_variables.map do |var|
75
+ "#{var[1..]}=#{instance_variable_get(var).inspect}"
76
+ end.join(", ")
77
+ end
78
+
79
+ alias to_s inspect
80
+ end
81
+ # rubocop:enable Metrics/MethodLength, Metrics/AbcSize
82
+
83
+ ##
84
+ # Generic data type base class.
85
+ class DataType
86
+ attr_reader :value
87
+
88
+ def initialize(value, byte_size = 1)
89
+ raise ArgumentError, "Invalid #{self.class}: #{value}" unless validate?(value)
90
+
91
+ @value = value
92
+ @byte_size = byte_size
93
+ end
94
+
95
+ def self.parse(data)
96
+ if data.is_a?(Array)
97
+ byte_size = data.length
98
+ value = 0
99
+ data.each_with_index do |b, i|
100
+ value |= b << (8 * (byte_size - 1 - i))
101
+ end
102
+ new(value, byte_size)
103
+ else
104
+ new(data)
105
+ end
106
+ end
107
+
108
+ def serialize
109
+ if @byte_size == 1
110
+ @value
111
+ else
112
+ result = []
113
+ @byte_size.times do |i|
114
+ result << ((@value >> (8 * (@byte_size - 1 - i))) & 0xff)
115
+ end
116
+ result
117
+ end
118
+ end
119
+
120
+ def inspect
121
+ "#{self.class.name.split("::").last} (#{@value})"
122
+ end
123
+
124
+ alias to_s inspect
125
+ end
126
+
127
+ ##
128
+ # Range data type base class.
129
+ class RangeType < DataType
130
+ def validate?(data)
131
+ self.class::RANGE.include?(data)
132
+ end
133
+ end
134
+
135
+ ##
136
+ # Enum data type base class.
137
+ class EnumType < DataType
138
+ def self.respond_to_missing?(method_name, include_private = false)
139
+ constants.include?(method_name) || super
140
+ end
141
+
142
+ def self.method_missing(method_name, *args, &)
143
+ return new(const_get(method_name)) if respond_to_missing?(method_name)
144
+
145
+ super
146
+ end
147
+
148
+ def validate?(data)
149
+ self.class.constants.map { |const| self.class.const_get(const) }.include?(data)
150
+ end
151
+
152
+ def inspect
153
+ const_name = self.class.constants.find { |const| self.class.const_get(const) == @value }
154
+ "#{self.class.name.split("::").last}.#{const_name}"
155
+ end
156
+ end
157
+
158
+ ##
159
+ # BitMap data type base class.
160
+ class BitMapType < EnumType
161
+ def respond_to_missing?(method_name, include_private = false)
162
+ valid = self.class.constants
163
+ valid |= valid.map { |c| "#{c.downcase}?".to_sym }
164
+ valid.include?(method_name) ||
165
+ super
166
+ end
167
+
168
+ def method_missing(method_name, *args, &)
169
+ if respond_to_missing?(method_name)
170
+ return (@value & self.class.const_get(method_name[..-2])).anybits? if method_name.to_s.end_with?("?")
171
+
172
+ return self | self.class.const_get(method_name)
173
+ end
174
+
175
+ super
176
+ end
177
+
178
+ def |(other)
179
+ case other
180
+ when BitMapType
181
+ self.class.new(@value | other.value, @byte_size)
182
+ when Integer
183
+ self.class.new(@value | other, @byte_size)
184
+ else
185
+ raise ArgumentError, "Cannot perform bitwise OR with #{other.class}"
186
+ end
187
+ end
188
+
189
+ def &(other)
190
+ case other
191
+ when BitMapType
192
+ self.class.new(@value & other.value, @byte_size)
193
+ when Integer
194
+ self.class.new(@value & other, @byte_size)
195
+ else
196
+ raise ArgumentError, "Cannot perform bitwise AND with #{other.class}"
197
+ end
198
+ end
199
+
200
+ def validate?(data)
201
+ data.is_a?(Integer) && data >= 0 && data <= self.class.constants.map { |const| self.class.const_get(const) }.sum
202
+ end
203
+
204
+ def inspect
205
+ flags = self.class.constants.select { |const| @value.anybits?(self.class.const_get(const)) }
206
+ "#{self.class.name.split("::").last} (#{flags.empty? ? value : flags.map(&:to_s).join("|")})"
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MitsubishiHeatpump
4
+ ##
5
+ # Mitsubishi Heatpump API class for communicating with the heat pump unit.
6
+ class API
7
+ KEY_SIZE = 16
8
+ IV_SIZE = 16
9
+ DEFAULT_KEY = "unregistered\0\0\0\0"
10
+
11
+ def initialize(host, port = 80, key = DEFAULT_KEY)
12
+ raise ArgumentError, "Host is required" if host.nil?
13
+
14
+ @host = host
15
+ @port = port
16
+ @key = key.length < KEY_SIZE ? key + ("\x00" * (KEY_SIZE - key.length)) : key[0, KEY_SIZE]
17
+ end
18
+
19
+ def encrypt_payload(payload)
20
+ cipher = OpenSSL::Cipher.new("AES-128-CBC")
21
+ cipher.encrypt
22
+ cipher.key = @key
23
+ cipher.padding = 0
24
+ iv = cipher.random_iv
25
+ payload += "\x80#{"\x00" * (16 - ((payload.length + 1) % 16))}"
26
+ encrypted = cipher.update(payload) + cipher.final
27
+ Base64.strict_encode64(iv + encrypted)
28
+ end
29
+
30
+ def decrypt_payload(encrypted_payload)
31
+ decoded = Base64.strict_decode64(encrypted_payload)
32
+ iv = decoded[0, IV_SIZE]
33
+ cipher = OpenSSL::Cipher.new("AES-128-CBC")
34
+ cipher.decrypt
35
+ cipher.key = @key
36
+ cipher.iv = iv
37
+ cipher.padding = 0
38
+ decrypted = cipher.update(decoded[IV_SIZE..]) + cipher.final
39
+ decrypted.encode.rstrip.encode("UTF-8", invalid: :replace, undef: :replace).chomp("\ufffd")
40
+ end
41
+
42
+ def make_request(payload)
43
+ body = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><ESV>#{encrypt_payload(payload)}</ESV>"
44
+ Net::HTTP.start(@host, @port, use_ssl: false) do |http|
45
+ req = Net::HTTP::Post.new("/smart")
46
+ req.body = body
47
+ res = http.request(req)
48
+ xml = Nokogiri::XML.parse(res.body)
49
+ return parse_response(decrypt_payload(xml.at("ESV").content))
50
+ end
51
+ end
52
+
53
+ def status
54
+ payload = "<CSV><CONNECT>ON</CONNECT></CSV>"
55
+ make_request(payload)
56
+ end
57
+
58
+ def send_command(command)
59
+ payload = command.serialize
60
+ make_request("<CSV><CONNECT>ON</CONNECT><CODE><VALUE>#{payload}</VALUE></CODE></CSV>")
61
+ end
62
+
63
+ def parse_response(resp)
64
+ pdus = []
65
+ xml = Nokogiri::XML.parse(resp)
66
+
67
+ xml.xpath("./LSV/CODE/VALUE").each do |node|
68
+ pdus << MitsubishiHeatpump::Protocol::PDU.parse(node.content)
69
+ rescue MitsubishiHeatpump::ProtocolError
70
+ next
71
+ end
72
+ pdus
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MitsubishiHeatpump
4
+ module DataTypes
5
+ class Power < ABC::EnumType
6
+ OFF = 0
7
+ ON = 1
8
+ TEST_MODE = 2
9
+ end
10
+
11
+ class OperatingMode < ABC::EnumType
12
+ HEAT = 1
13
+ DEHUMIDIFY = 2
14
+ COOL = 3
15
+ FAN = 7
16
+ AUTO = 8
17
+ I_SEE_HEAT = 9
18
+ I_SEE_DRY = 10
19
+ I_SEE_COOL = 11
20
+ AUTO_HEAT = 33
21
+ AUTO_COOL = 35
22
+ end
23
+
24
+ class FanMode < ABC::EnumType
25
+ AUTO = 0
26
+ QUIET = 1
27
+ LOW = 2
28
+ MEDIUM = 3
29
+ HIGH = 5
30
+ VERY_HIGH = 6
31
+ end
32
+
33
+ class VerticalVane < ABC::EnumType
34
+ AUTO = 0
35
+ POS_1 = 1
36
+ POS_2 = 2
37
+ POS_3 = 3
38
+ POS_4 = 4
39
+ POS_5 = 5
40
+ SWING = 7
41
+ end
42
+
43
+ ##
44
+ # Horizontal vane position data type.
45
+ class HorizontalVane < ABC::EnumType
46
+ AUTO = 0
47
+ FULL_LEFT = 1
48
+ LEFT = 2
49
+ CENTER = 3
50
+ RIGHT = 4
51
+ FULL_RIGHT = 5
52
+ SPLIT_CENTER_LEFT = 6
53
+ SPLIT_CENTER_RIGHT = 7
54
+ SPLIT_LEFT_RIGHT = 8
55
+ SPLIT_CENTER_LEFT_RIGHT = 9
56
+ SWING = 12
57
+
58
+ def self.parse(data)
59
+ super(data & 0x0f)
60
+ end
61
+ end
62
+
63
+ ##
64
+ # Horizontal vane adjustment data type.
65
+ class HorizontalVaneAdjustment < ABC::EnumType
66
+ OFF = 0
67
+ ON = 128
68
+
69
+ def self.parse(data)
70
+ super(data & 0xf0)
71
+ end
72
+ end
73
+
74
+ class TimerMode < ABC::EnumType
75
+ NONE = 0
76
+ OFF = 1
77
+ ON = 2
78
+ BOTH = 3
79
+ end
80
+
81
+ class FilterReset < ABC::EnumType
82
+ NO = 0
83
+ YES = 1
84
+ end
85
+
86
+ class PowerSavingMode < ABC::EnumType
87
+ OFF = 0
88
+ ON = 10
89
+ end
90
+
91
+ class AirflowControlMode < ABC::EnumType
92
+ EVEN = 0
93
+ INDIRECT = 1
94
+ DIRECT = 2
95
+ end
96
+
97
+ class RemoteProhibitFlags < ABC::BitMapType
98
+ LOCK_POWER = 0x01
99
+ LOCK_MODE = 0x02
100
+ LOCK_TEMPERATURE = 0x04
101
+ end
102
+
103
+ class StatusFlags < ABC::BitMapType
104
+ FILTER = 0x01
105
+ DEFROST = 0x02
106
+ HOT_ADJUST = 0x04
107
+ STANDBY = 0x08
108
+ end
109
+
110
+ ##
111
+ # Update flags data type.
112
+ class UpdateFlags < ABC::BitMapType
113
+ def serialize
114
+ if @byte_size.zero?
115
+ return []
116
+ elsif @byte_size == 1
117
+ return [@value]
118
+ end
119
+
120
+ super
121
+ end
122
+
123
+ def validate?(data)
124
+ data.is_a?(Integer)
125
+ end
126
+ end
127
+
128
+ ##
129
+ # Enhanced temperature data type.
130
+ class EnhancedTemperature < ABC::RangeType
131
+ RANGE = (-64..63.5)
132
+
133
+ def self.parse(data)
134
+ new((data - 128) * 0.5)
135
+ end
136
+
137
+ def serialize
138
+ ((@value * 2) + 128).to_i & 0xff
139
+ end
140
+ end
141
+
142
+ ##
143
+ # Legacy setpoint temperature data type.
144
+ class LegacySetpointTemperature < ABC::RangeType
145
+ RANGE = (16..31.5)
146
+
147
+ def self.parse(data)
148
+ new(31 - (data & 0x0f) + (((data & 0x10) >> 4) * 0.5))
149
+ end
150
+
151
+ def serialize
152
+ int_temp = (31 - @value.floor) & 0x0f
153
+ half_temp = ((@value % 1) >= 0.5 ? 1 : 0) << 4
154
+ int_temp | half_temp
155
+ end
156
+ end
157
+
158
+ ##
159
+ # Legacy heat pump temperature data type.
160
+ class LegacyHeatPumpTemperature < ABC::RangeType
161
+ RANGE = (10..41)
162
+
163
+ def self.parse(data)
164
+ new(data + 10)
165
+ end
166
+
167
+ def serialize
168
+ @value.to_i - 10
169
+ end
170
+ end
171
+
172
+ ##
173
+ # Legacy room temperature data type.
174
+ class LegacyRoomTemperature < ABC::RangeType
175
+ RANGE = (8..39.5)
176
+
177
+ def self.parse(data)
178
+ new((data * 0.5) + 8)
179
+ end
180
+
181
+ def serialize
182
+ ((@value - 8) * 2).to_i
183
+ end
184
+ end
185
+
186
+ class DehumidifyValue < ABC::RangeType
187
+ RANGE = (0..100)
188
+ end
189
+
190
+ ##
191
+ # Generic scalar data type.
192
+ class Scalar < ABC::DataType
193
+ def validate?(data)
194
+ data.is_a?(Integer)
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MitsubishiHeatpump
4
+ module Protocol
5
+ ByteParam = Struct.new("ByteParam", :byte, :cls, :update_flag, :mask)
6
+
7
+ class SetSettings < ABC::Command
8
+ COMMAND_ID = 0x01
9
+ UPDATE_FLAGS_SIZE = 2
10
+ BYTE_VALUES = {
11
+ power: ByteParam.new(2, DataTypes::Power, 0x0100),
12
+ operating_mode: ByteParam.new(3, DataTypes::OperatingMode, 0x0200),
13
+ legacy_target_temp: ByteParam.new(4, DataTypes::LegacySetpointTemperature, 0x0400),
14
+ fan_speed: ByteParam.new(5, DataTypes::FanMode, 0x0800),
15
+ vertical_vane: ByteParam.new(6, DataTypes::VerticalVane, 0x1000),
16
+ remote_prohibit_flags: ByteParam.new(10, DataTypes::RemoteProhibitFlags, 0x4000),
17
+ horizontal_vane: ByteParam.new(12, DataTypes::HorizontalVane, 0x0001),
18
+ target_temp: ByteParam.new(13, DataTypes::EnhancedTemperature, 0x0400)
19
+ }.freeze
20
+ end
21
+
22
+ class GetSettings < ABC::Command
23
+ COMMAND_ID = 0x02
24
+ BYTE_VALUES = {
25
+ power: ByteParam.new(2, DataTypes::Power),
26
+ operating_mode: ByteParam.new(3, DataTypes::OperatingMode),
27
+ legacy_target_temp: ByteParam.new(4, DataTypes::LegacySetpointTemperature),
28
+ fan_speed: ByteParam.new(5, DataTypes::FanMode),
29
+ vertical_vane: ByteParam.new(6, DataTypes::VerticalVane),
30
+ remote_prohibit_flags: ByteParam.new(7, DataTypes::RemoteProhibitFlags),
31
+ horizontal_vane: ByteParam.new(9, DataTypes::HorizontalVane, 0, 0x0f),
32
+ horizontal_vane_adjustment: ByteParam.new(9, DataTypes::HorizontalVaneAdjustment, 0, 0xf0),
33
+ target_temp: ByteParam.new(10, DataTypes::EnhancedTemperature),
34
+ target_humidity: ByteParam.new(11, DataTypes::DehumidifyValue),
35
+ power_saving_mode: ByteParam.new(12, DataTypes::PowerSavingMode),
36
+ airflow_control_mode: ByteParam.new(13, DataTypes::AirflowControlMode),
37
+ left_vertical_vane: ByteParam.new(14, DataTypes::Scalar)
38
+ }.freeze
39
+ end
40
+
41
+ class GetTemperatures < ABC::Command
42
+ COMMAND_ID = 0x03
43
+ BYTE_VALUES = {
44
+ legacy_temp: ByteParam.new(2, DataTypes::LegacyHeatPumpTemperature),
45
+ outdoor_unit_temp: ByteParam.new(4, DataTypes::EnhancedTemperature),
46
+ current_temp: ByteParam.new(5, DataTypes::EnhancedTemperature),
47
+ unknown1: ByteParam.new(6, DataTypes::EnhancedTemperature),
48
+ unknown2: ByteParam.new(7, DataTypes::Scalar),
49
+ unknown3: ByteParam.new(8, DataTypes::Scalar),
50
+ runtime: ByteParam.new(10..12, DataTypes::Scalar)
51
+ }.freeze
52
+ end
53
+
54
+ class GetErrorStatus < ABC::Command
55
+ COMMAND_ID = 0x04
56
+ BYTE_VALUES = {
57
+ error_code: ByteParam.new(3..4, DataTypes::Scalar),
58
+ short_error_code: ByteParam.new(5, DataTypes::Scalar)
59
+ }.freeze
60
+ end
61
+
62
+ class GetTimerInfo < ABC::Command
63
+ COMMAND_ID = 0x05
64
+ BYTE_VALUES = {
65
+ timer_mode: ByteParam.new(2, DataTypes::TimerMode),
66
+ on_minutes_set: ByteParam.new(3, DataTypes::Scalar),
67
+ off_minutes_set: ByteParam.new(4, DataTypes::Scalar),
68
+ on_minutes_remaining: ByteParam.new(5, DataTypes::Scalar),
69
+ off_minutes_remaining: ByteParam.new(6, DataTypes::Scalar)
70
+ }.freeze
71
+ end
72
+
73
+ class GetOperationStatus < ABC::Command
74
+ COMMAND_ID = 0x06
75
+ BYTE_VALUES = {
76
+ compressor_frequency: ByteParam.new(2, DataTypes::Scalar),
77
+ operating: ByteParam.new(3, DataTypes::Power),
78
+ input_power: ByteParam.new(4..5, DataTypes::Scalar),
79
+ lifetime_consumption: ByteParam.new(6..7, DataTypes::Scalar),
80
+ unknown1: ByteParam.new(10, DataTypes::Scalar)
81
+ }.freeze
82
+ end
83
+
84
+ class SetRunStatus < ABC::Command
85
+ COMMAND_ID = 0x08
86
+ UPDATE_FLAGS_SIZE = 2
87
+ BYTE_VALUES = {
88
+ filter_reset: ByteParam.new(2, DataTypes::FilterReset, 0x0001),
89
+ dehumidify_value: ByteParam.new(3, DataTypes::DehumidifyValue, 0x0004),
90
+ power_saving_mode: ByteParam.new(4, DataTypes::PowerSavingMode, 0x0008),
91
+ airflow_control_mode: ByteParam.new(5, DataTypes::AirflowControlMode, 0x0020)
92
+ }.freeze
93
+ end
94
+
95
+ class GetRunStatus < ABC::Command
96
+ COMMAND_ID = 0x09
97
+ BYTE_VALUES = {
98
+ flags: ByteParam.new(2, DataTypes::StatusFlags),
99
+ actual_fan_speed: ByteParam.new(3, DataTypes::Scalar),
100
+ auto_mode: ByteParam.new(4, DataTypes::Scalar)
101
+ }.freeze
102
+ end
103
+
104
+ class SetRequest < ABC::Packet
105
+ PACKET_TYPE = 0x41
106
+ COMMAND_TYPES = {
107
+ SetSettings => 0x01,
108
+ SetRunStatus => 0x08
109
+ }.freeze
110
+ end
111
+
112
+ class SetResponse < ABC::Packet
113
+ PACKET_TYPE = 0x61
114
+ end
115
+
116
+ class GetRequest < ABC::Packet
117
+ PACKET_TYPE = 0x42
118
+ end
119
+
120
+ class GetResponse < ABC::Packet
121
+ PACKET_TYPE = 0x62
122
+ COMMAND_TYPES = {
123
+ 0x02 => GetSettings,
124
+ 0x03 => GetTemperatures,
125
+ 0x04 => GetErrorStatus,
126
+ 0x05 => GetTimerInfo,
127
+ 0x06 => GetOperationStatus,
128
+ 0x09 => GetRunStatus
129
+ }.freeze
130
+ end
131
+
132
+ class ConnectRequest < ABC::Packet
133
+ PACKET_TYPE = 0x5a
134
+ end
135
+
136
+ class ConnectResponse < ABC::Packet
137
+ PACKET_TYPE = 0x7a
138
+ end
139
+
140
+ class IdentifyRequest < ABC::Packet
141
+ PACKET_TYPE = 0x5b
142
+ end
143
+
144
+ class IdentifyResponse < ABC::Packet
145
+ PACKET_TYPE = 0x7b
146
+ end
147
+
148
+ ##
149
+ # Protocol Data Unit (PDU) for Mitsubishi Heatpump communication.
150
+ class PDU
151
+ SYNC_BYTE = 0xfc
152
+ PROTOCOL_ID = [0x01, 0x30].freeze
153
+ PACKET_TYPES = {
154
+ 0x41 => SetRequest,
155
+ 0x61 => SetResponse,
156
+ 0x42 => GetRequest,
157
+ 0x62 => GetResponse,
158
+ 0x5a => ConnectRequest,
159
+ 0x7a => ConnectResponse,
160
+ 0x5b => IdentifyRequest,
161
+ 0x7b => IdentifyResponse
162
+ }.freeze
163
+
164
+ attr_reader :packet
165
+
166
+ def initialize(packet)
167
+ @packet = packet
168
+ end
169
+
170
+ def self.parse(hex_string)
171
+ bytes = [hex_string].pack("H*").bytes
172
+
173
+ validate(bytes)
174
+
175
+ new(PACKET_TYPES[bytes[1]].parse(bytes[5, bytes[4]]))
176
+ end
177
+
178
+ def serialize
179
+ payload = @packet.serialize
180
+ length = payload.length
181
+ bytes = [SYNC_BYTE, @packet.class::PACKET_TYPE, *PROTOCOL_ID, length] + payload
182
+ checksum = Util.calculate_checksum(bytes)
183
+ bytes << checksum
184
+ bytes.map { |b| format("%02x", b) }.join
185
+ end
186
+
187
+ def inspect
188
+ "#{self.class.name.split("::").last} => #{@packet.inspect}"
189
+ end
190
+
191
+ alias to_s inspect
192
+
193
+ def self.validate(bytes)
194
+ raise ProtocolError, "Invalid packet" if bytes.empty? || bytes[0] != SYNC_BYTE
195
+
196
+ raise ProtocolError, "Unsupported packet type" unless PACKET_TYPES[bytes[1]]
197
+
198
+ raise ProtocolError, "Unsupported protocol" unless bytes[2..3] == PROTOCOL_ID
199
+
200
+ raise ProtocolError, "Invalid checksum" unless Util.calculate_checksum(bytes[..-2]) == bytes.last
201
+ end
202
+ end
203
+
204
+ class ProtocolError < StandardError; end
205
+ end
206
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MitsubishiHeatpump
4
+ ##
5
+ # Utility functions for Mitsubishi Heatpump protocol.
6
+ class Util
7
+ def self.calculate_checksum(bytes)
8
+ (0xfc - bytes.sum) & 0xff
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MitsubishiHeatpump
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "base64"
6
+ require "nokogiri"
7
+ require "mitsubishi_heatpump/util"
8
+ require "mitsubishi_heatpump/abc"
9
+ require "mitsubishi_heatpump/api"
10
+ require "mitsubishi_heatpump/data_types"
11
+ require "mitsubishi_heatpump/protocol"
metadata ADDED
@@ -0,0 +1,122 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mitsubishi_heatpump
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Anders Alfredsson
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-02-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: nokogiri
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.18'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.18'
27
+ - !ruby/object:Gem::Dependency
28
+ name: bundler
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '2.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '2.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '3.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rubocop
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '1.82'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.82'
83
+ description:
84
+ email: andersb86@gmail.com
85
+ executables:
86
+ - mitsubishi
87
+ extensions: []
88
+ extra_rdoc_files: []
89
+ files:
90
+ - bin/mitsubishi
91
+ - lib/mitsubishi_heatpump.rb
92
+ - lib/mitsubishi_heatpump/abc.rb
93
+ - lib/mitsubishi_heatpump/api.rb
94
+ - lib/mitsubishi_heatpump/data_types.rb
95
+ - lib/mitsubishi_heatpump/protocol.rb
96
+ - lib/mitsubishi_heatpump/util.rb
97
+ - lib/mitsubishi_heatpump/version.rb
98
+ homepage: https://github.com/pacive/mitsubishi_heatpump
99
+ licenses:
100
+ - MIT
101
+ metadata:
102
+ rubygems_mfa_required: 'true'
103
+ post_install_message:
104
+ rdoc_options: []
105
+ require_paths:
106
+ - lib
107
+ required_ruby_version: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - "~>"
110
+ - !ruby/object:Gem::Version
111
+ version: '3.1'
112
+ required_rubygems_version: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ requirements: []
118
+ rubygems_version: 3.3.26
119
+ signing_key:
120
+ specification_version: 4
121
+ summary: A gem for local control of Mitsubishi heat pumps
122
+ test_files: []